How Authorization Works
Capabilities, roles, grants and one tiny recursive query. That's the whole trick!
"Can Alice download this file?" sounds simple, until you add groups, orgs, public links, nested folders, shared drives and a few thousand tenants. Platrium answers it with a small, boring model that lives in plain SQL. Let's walk through it!
Everything here lives in engine/internal/authz (the model and the Authorizer interface) and engine/internal/authz/sqlauthz (the SQL engine). Enforcement happens in fsops, so no resolver or handler can forget to check.
Capabilities Are Bits
A capability is one thing you may do to an item. Each one is a single bit in a 64-bit integer, so a set of capabilities is just a number and "is this allowed?" is one bitwise AND.
| Capability | Bit | Value | Lets you |
|---|---|---|---|
LIST | 0 | 1 | See that an item exists, list a folder |
VIEW | 1 | 2 | Read metadata and preview |
DOWNLOAD | 2 | 4 | Download or copy out the content |
COMMENT | 3 | 8 | Comment (once comments exist) |
CREATE | 8 | 256 | Upload or create inside a folder |
EDIT | 9 | 512 | Rename, replace content |
DELETE | 10 | 1024 | Delete permanently |
MOVE | 11 | 2048 | Move within the drive |
TRASH | 12 | 4096 | Send to trash |
MOVE_OUT | 13 | 8192 | Move or copy out of the drive |
SHARE | 16 | 65536 | Give others access (up to your own) |
MANAGE | 17 | 131072 | Break inheritance, manage settings |
DELETE_DRIVE | 18 | 262144 | Delete a whole drive |
Some capabilities imply others (EDIT implies VIEW, which implies LIST). Grants are normalized when written, so checks never apply rules.
Grants
A grant says: this subject gets these capabilities on this item (and everything under it).
| Field | Meaning |
|---|---|
resource_id | The file, folder or drive root |
subject_type / subject_id | USER, GROUP, TENANT (the whole org) or PUBLIC (anyone) |
caps | The capability snapshot |
role | A display label |
expires_at | Optional expiry |
tenant_id, drive_id | For isolation, and so a drive's grants delete in one statement |
One grant per subject per item, so sharing again just replaces it.
The Check
Two queries do the work: one recursive CTE walks the ancestors up to the drive root, marking every ancestor above a restriction as cut, and one indexed lookup loads the unexpired grants that match your user, groups, tenant or PUBLIC. On a cut ancestor only grants that carry MANAGE count; everywhere else every grant does. CapsMany runs the whole thing for many items at once, which is how folder listings stay fast.
An item you can't see at all is reported as not found, never "forbidden". That way nobody can probe which IDs exist. "Forbidden" only appears when you can see the item but lack the capability.
Inheritance
Grants flow down the tree. A grant on a folder covers everything inside it. To lock a subfolder down, set inherit_perms = false on it: ancestors' grants stop applying there, and only grants on the item itself (and below) count.
There's one exception, and it's what keeps a drive administrable: a grant that carries MANAGE (a Drive Admin's) keeps applying through a restriction. Restricting hides a folder from members, editors and public links, never from the drive's admins. So restricting needs no extra grant to "keep you in", and resuming inheritance leaves nothing behind.
There are no deny rules. Access only ever adds up, which keeps checks order-independent and easy to explain.
Groups: Flatten The Small Side
Groups can nest. We keep the direct memberships in group_members and a flattened group_closures table (every group → every user beneath it), rebuilt in the same transaction as each membership change. Resolving "which groups is Alice in?" is then one indexed lookup, however deep the nesting.
What Enforces It
Every fsops operation requires a capability on the item(s) involved:
| Operation | Needs |
|---|---|
| List a folder, count, breadcrumbs, change feed | LIST on the folder |
| Item or file metadata | VIEW |
| Download | DOWNLOAD |
| Upload, new folder | CREATE on the parent |
| Rename | EDIT |
| Move | MOVE on the item and CREATE on the destination (same drive only) |
| Copy | DOWNLOAD on the source and CREATE on the destination |
Listings, counts, change feeds and breadcrumbs also hide what you can't see. A breadcrumb starts at the topmost folder you can open, so someone shared a deep folder never learns the names above it.
Changing Access: The Rules
Reading access has one choke point (CapsMany). Writing access has one too: the rules in authz.CheckChange.
An item's access is just the set of grants on it, and every write is a change to that set: share, revoke, set general access, restrict, create a drive. So a writer does the same thing every time. It works out what the grants will be, hands the before and after to CheckChange, and writes only if the rules agree.
| Rule | In plain words | Error |
|---|---|---|
no-self-edit | Nobody changes their own access to an item, up or down. You can't make yourself a viewer of your own file or remove the grant you were given. Someone else has to. | ErrInvalid |
role-offered | A grant only carries a role its item and its kind offer: no Drive Admin on a file, no Editor for the public, no expiry where a level can't have one. | ErrInvalid |
public-allowed | A public grant is refused when the tenant has turned public sharing off. | ErrForbidden |
no-escalation | Nobody hands out more capabilities than they hold. | ErrForbidden |
never-orphan | A shared drive keeps at least one permanent manager (a user or a group). Removing, demoting or end-dating the last one is refused. | ErrInvalid |
Where It Lives
The rules are in package authz, with no database access, and they take plain types: a Change, the item's ItemFacts (is it a drive root, is it in a shared drive, does it inherit) and the grants. The adapter fills in the facts and does the locking and writing. That is what keeps them engine-neutral. An OpenFGA engine runs the same rules, and the shared scenarios in authztest check that every engine agrees. See scaling and OpenFGA.
Code Layout
engine/internal/authz/
├── capability.go the bit registry, implied rules, Verbs()/Parse
├── role.go built-in roles and their wording per context
├── level.go the general-access levels (restricted, organization, public)
├── model.go Principal, Grant, Subject, GeneralAccess
├── rules.go the change rules and CheckChange (no storage imports)
├── authz.go the Authorizer interface (reads AND writes)
├── authztest/ scenarios every engine must pass
└── sqlauthz/ the SQL engine: check, grants, groups, shared, general,
and mutate.go (lock, load, check) used by every writerThe Authorizer interface owns writes as well as reads. Each engine stores its own data, so there is never a dual-write to keep in sync. That's what makes swapping engines possible. More on that in scaling and OpenFGA.