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 (seecore/db/migrations/0021_groups_acls.sql):
object_kindis one ofdocument,tag,correspondent,document_type,storage_path. Enforced by a CHECK constraint.principal_kindisuserorgroup.perm_bitsis 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 throughauthz.Authorizer.Can(principal, kind, id, want).
The decision:
- Anonymous (no principal) → deny.
- Admin (
role = admin) → allow. Always. Admins bypass ACLs. - Owner (the object has an
owner_idcolumn and it matches) → allow. Owners always have the full mask on their own objects. - ACL match. Union
perm_bitsacross every grant that names the caller or any group they belong to. If the union coverswant, allow. Otherwise deny.
- Empty
object_aclsfor 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 readsobject_aclsfiltered by user and group. Wired as the default atapi.New()because it is backward-compatible (empty ACLs behave identically toRoleAuthorizer).
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.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
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
Delete a group cleanly
7. Admin UI
The SPA admin panel for groups, custom fields, taxonomy, and users is tracked (see task #140 intasks.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 dedicatedgrant_permissionaction 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
workflowkind toobject_kindis 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.
10. Migrating from single-user
If you’ve been running suchi single-user (one owner, everything theirs), Phase 6 changes nothing about behavior. Theobject_acls
table stays empty; the ACLAuthorizer falls through to
owner-or-admin.
To onboard a second user for real sharing:
- Create the user via the setup wizard or
POST /api/admin/users. - 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.
- Adjust as needed — the write path (grants + members) is idempotent; there’s no penalty for iterating.