Skip to main content
Suchi’s multi-user data model has been there since Phase 0 — every document has an owner_id; every mutation is audit-logged. Phase 6 adds the missing piece: grant read/change/delete access to another user or a group of users, without making them an admin. The whole thing runs on three tables and one Go interface. No external policy engine, no OPA, no cedar — plain SQL grants keyed on (object, principal).

1. Model

Three tables (see core/db/migrations/0021_groups_acls.sql):
  • object_kind is one of document, tag, correspondent, document_type, storage_path. Enforced by a CHECK constraint.
  • principal_kind is user or group.
  • perm_bits is a small bitmask (view=1, change=2, delete=4). Powers-of-two so grants OR together.
  • Unique per (object_kind, object_id, principal_kind, principal_id) — re-granting overwrites bits rather than accumulating (surprising otherwise).

2. Semantics

Every request goes through authz.Authorizer.Can(principal, kind, id, want). The decision:
  1. Anonymous (no principal) → deny.
  2. Admin (role = admin) → allow. Always. Admins bypass ACLs.
  3. Owner (the object has an owner_id column and it matches) → allow. Owners always have the full mask on their own objects.
  4. ACL match. Union perm_bits across every grant that names the caller or any group they belong to. If the union covers want, allow. Otherwise deny.
Two important consequences:
  • Empty object_acls for an object = legacy behavior. Only the owner and admins see it. Turning the feature on for the first time changes nothing about docs you haven’t explicitly shared.
  • Grants are additive, never subtractive. You can’t “revoke” the owner’s access; you can only add more principals.

3. Authorizer implementations

Suchi ships two:
  • RoleAuthorizer — the legacy path. Owner or admin. Zero ACL awareness. Cheap: one query per check. Zero-value ready.
  • ACLAuthorizer — the ACL-aware path. Owner + admin fast paths; otherwise reads object_acls filtered by user and group. Wired as the default at api.New() because it is backward-compatible (empty ACLs behave identically to RoleAuthorizer).
Custom implementations are expected — enterprise builds swap in a SAML/SCIM-aware one without touching handlers. The interface is:
Principal carries pre-loaded group membership so the decision is a pure function of its inputs. Handlers use the s.authorize() helper (in core/api/authz_helpers.go), which loads groups once per request via a context-scoped cache and maps ErrDenied → HTTP 403.

4. Enforcement today

Wired for documents and their sub-endpoints: And for taxonomy mutations: Taxonomy list / get endpoints stay open to every authed user — tags, correspondents, document_types are shared vocabulary in a DMS. Restricting who can see the label “Landlord” exists as a correspondent is unusual and would break autocomplete + browse UX; restricting who can rename or delete it is the real ask, and that’s what these grants unlock. Creation of new taxonomy rows stays admin-only — grants are per-object, so there’s nothing to grant against a row that doesn’t exist yet.

5. REST surface

Groups

Admin-only writes; any authed user can list/get.
Delete-with-grants is a hard refuse (409). Revoke every grant that names the group first, then delete. This is a safety, not a bug — silently vanishing grants is exactly the wrong failure mode.

ACL grants

Admin-only writes.
{kind} is one of document|tag|correspondent|document_type|storage_path. PUT body:
perm_bits is a bitmask — combine the individual bits: Re-PUT with a different perm_bits overwrites; PUT with the same value is a no-op.

6. Worked examples

Share one document with a household member

Doc 17 now appears in user 5’s list, is editable, but cannot be deleted by them.

Give a “finance” group read access to everything an owner tagged “invoices”

Not yet a first-class one-shot — the taxonomy handlers don’t enforce grants yet. For now, script it: iterate over documents with the tag, PUT a grant per doc. When taxonomy enforcement lands, a grant on the tag itself will imply access to documents carrying that tag.

Undo a shared doc

Silently removes the grant. If there was no matching row, the call still returns 204 — idempotent revoke.

Delete a group cleanly

7. Admin UI

The SPA admin panel for groups, custom fields, taxonomy, and users is tracked (see task #140 in tasks.md). Until it lands, drive the list/add/remove/save flows over the JSON API directly; the /api/groups/ and /api/acls/ surfaces described above are the system of record. Per-object “share with…” affordances on the document detail view are planned; today, use the API directly or the eventual admin scripts.

8. Interaction with other engines

  • Share links (share_links) live one layer above ACLs — they’re time-bounded tokens for anonymous recipients. A share link works even for callers who have no ACL grant; it’s a distinct axis.
  • Automations can add/remove permissions indirectly via assign_owner; a dedicated grant_permission action is not yet in the batch. Track in the plan doc.
  • Approvals don’t currently participate in ACLs — anyone with the workflow’s slug can start a run. Adding a workflow kind to object_kind is a follow-up.

9. What’s not (yet) covered

  • Enforcement on taxonomy objects. Grant CRUD is live; handlers don’t consult them yet.
  • Row-level filtering on the list handler beyond documents. /api/tags/ returns every tag; /api/correspondents/ returns every correspondent. Enterprise E2 layers row filtering on top of the Phase 6 primitive.
  • Query-time filter for search. /api/search/ doesn’t yet respect ACLs; a user who can search but isn’t granted read on a document sees its snippet.
Roadmap for these is in the plan doc under Phase 6 tail + E2.

10. Migrating from single-user

If you’ve been running suchi single-user (one owner, everything theirs), Phase 6 changes nothing about behavior. The object_acls table stays empty; the ACLAuthorizer falls through to owner-or-admin. To onboard a second user for real sharing:
  1. Create the user via the setup wizard or POST /api/admin/users.
  2. Decide the sharing model:
    • Shared inbox: create a group, add everyone, grant it view on the docs you want jointly seen.
    • Per-doc: grant the individual user access on each doc.
    • Cross-owner: create a group per household member; grant the “spouse” group on shared docs; individuals stay owners of their private ones.
  3. Adjust as needed — the write path (grants + members) is idempotent; there’s no penalty for iterating.
For the maintainer’s own reference-deployment story, see the plan doc under Phase 8.