Skip to content

Fine-grained authorization

Fine-grained authorization

Roles answer "may this person approve invoices in this organization?". Fine-grained authorization answers "may this person edit this document?" — where the answer depends on who owns the document, which folder it is in, who can see that folder, and which group the person is in.

FineGrainedAuthorization gives each environment its own model for that, in the shape of Google Zanzibar (and OpenFGA, SpiceDB and WorkOS FGA):

  • a schema — the resource types and the relations on each, and how each relation is decided from the others;
  • tuples — the facts your app writes: document:readme#viewer@user:alice;
  • checks and list queries, evaluated over both, cached, and as fresh as you ask.

Everything is scoped to the ambient environment. Two environments never share a schema, a tuple, a revision or a cached answer.

It sits beside the platform's own, organization-scoped RelationshipStore (resource grants, groups): that store has a fixed meaning the platform defines; this one holds a model the environment defines for its app.

The schema

# Documents live in folders; a folder's viewers can read everything in it.
type user

type group
  relation member: [user, group#member]

type folder
  relation parent: [folder]
  relation owner: [user]
  relation editor: [user, group#member] or owner or editor from parent
  relation viewer: [user, group#member] or editor or viewer from parent

type document
  relation parent: [folder]
  relation owner: [user]
  relation editor: [user, group#member] or owner or editor from parent
  relation viewer: [user, group#member] or editor or viewer from parent
  relation blocked: [user]
  relation can_read: viewer but not blocked

A relation is decided by:

Written Means
[user, group#member] the subjects a tuple may name directly: any user, or everybody who is a member of one group
owner another relation on the same object (a computed userset)
viewer from parent viewer on every object this one's parent tuples point at (tuple-to-userset)
a or b, a and b, a but not b union, intersection, exclusion — or and and only mix with parentheses

# starts a comment where it begins a line or follows a space.

The parser refuses, by line and all at once: unknown types and relations; a relation that is only computed being written to; x from p where p is not a plain list of types or no type it lists has x; more than one [...] list on one relation; and any exclusion whose subtracted side depends back on the relation it decides ("a viewer, but not a viewer" has no answer). Cycles that only add access — groups in groups, folders in folders — are allowed and bounded at evaluation time.

use Cbox\Id\Kernel\Authorization\Contracts\FineGrainedAuthorization;

$fga = app(FineGrainedAuthorization::class);

$fga->validateSchema($source);   // AuthorizationSchema, or InvalidSchema with every error
$fga->updateSchema($source);     // SchemaState; SchemaConflict if it would strand tuples
$fga->schema();                  // the source as written, the parsed model, its version

A schema change that would strand tuples already written — it drops a relation they are on, or no longer takes the kind of subject they name — is refused with SchemaConflict, listing them. Delete those tuples first: a schema change never removes access silently.

Tuples

use Cbox\Id\Kernel\Authorization\ValueObjects\Tuple;

$write = $fga->writeTuples(
    [Tuple::parse('folder:handbook#owner@user:olivia'), Tuple::parse('document:leave#parent@folder:handbook')],
    [Tuple::parse('document:leave#viewer@user:mallory')],   // deletes, same batch
);

$write->written;      // new tuples (re-writing an existing one is not an error, and not counted)
$write->consistency;  // the revision this batch produced

A batch (up to max_batch, default 100) is atomic and checked against the schema: an unknown type or relation, a computed relation, or a subject the relation does not take refuses the whole batch with InvalidTuple, naming the position. Ids are up to 128 printable characters without spaces, # or @; * on its own is reserved.

Checks and list queries

use Cbox\Id\Kernel\Authorization\ValueObjects\{Check, ResourceRef, SubjectRef};

$fga->check(Check::of('document', 'leave', 'viewer', SubjectRef::of('user', 'alice')))->allowed;

// Is every member of eng a viewer? (a userset as the subject)
$fga->check(Check::of('document', 'leave', 'viewer', SubjectRef::of('group', 'eng', 'member')));

$fga->checkMany([$check1, $check2]);   // one revision, answered in order

$fga->listResources(SubjectRef::of('user', 'alice'), 'viewer', 'document', limit: 100);   // ids, sorted, paged
$fga->listSubjects(ResourceRef::of('document', 'leave'), 'viewer', 'user', after: $cursor);

A check or list query naming a type or relation the schema does not define is refused (UnknownRelation), never answered "no" — a typo would otherwise deny everybody.

How they are evaluated

A relation means the least fixpoint of its definition: a subject has it when a finite chain of tuples and rewrites derives it. A check searches for that chain depth-first. A node already on the current path is a cycle and contributes nothing on that path; an answer is remembered for the rest of the call when it is "yes", or a "no" that did not depend on such a cut, so every node is expanded at most once per subject. The search is bounded by max_depth (default 25); past it the check is refused (ResolutionTooComplex) rather than answered — "no" would be wrong for a grant further down, and wrong the other way inside a but not.

List queries walk the tuple graph as if every operator were a union — outward from the subject using the schema's reverse edges for list-resources, inward from the resource for list-subjects — bounded by max_expansion (default 50 000) visited nodes. Where the relation is union-only all the way down, that walk is the exact answer; otherwise each candidate is confirmed with a check.

The evaluator is tested against a reference implementation — a bottom-up, stratified fixpoint that shares no code with it — on hundreds of random schemas and tuple graphs with cycles, intersections and exclusions, in memory and through the database and cache.

Revisions, consistency tokens and the cache

Each environment's model has one monotonic revision, advanced in the same transaction as every write (a tuple batch that changed something, a schema change); the model's row is locked for that transaction, so writes to one environment are serialized.

Every write and read answers with a consistency token — <revision>.<environment tag> — and every read accepts one: the answer is then at least as fresh as the write that produced it. When the connection read is behind the token (a lagging read replica), the model and its tuples are re-read from the write connection. A token for a revision the environment has not reached, or one minted by another environment, is refused (InvalidConsistencyToken). Without a token a read is as fresh as the connection it reads.

Answers are cached under cbox-fga:<environment>:<revision>:<tag>:<question>. A write advances the revision, so the next read asks a key nothing has answered: invalidation is immediate and atomic across cache nodes, and nothing is ever deleted. The tag is a random value replaced with every revision, so an answer computed inside a transaction that later rolled back can never be found again. The price is that any write cools that environment's whole cache — the right trade for a model read far more than written.

Configuration

// config/cbox-id.php
'fga' => [
    'max_depth' => env('CBOX_ID_FGA_MAX_DEPTH', 25),
    'max_expansion' => env('CBOX_ID_FGA_MAX_EXPANSION', 50000),
    'max_batch' => env('CBOX_ID_FGA_MAX_BATCH', 100),
    'cache' => [
        'enabled' => env('CBOX_ID_FGA_CACHE', true),
        'store' => env('CBOX_ID_FGA_CACHE_STORE'),   // null = the default store
        'ttl' => env('CBOX_ID_FGA_CACHE_TTL', 3600),  // bounds memory, not freshness
    ],
],

Storage and performance

Two tables: fga_stores (one row per environment: the schema and the revision) and fga_tuples. Every query the evaluator makes is one range read on one of two indexes: the unique forward key (environment, resource type, resource id, relation, subject type, subject id, subject relation) for checks and list-subjects, and the reverse index (environment, subject type, subject id, subject relation, resource type, relation) for list-resources and tuple-to-userset walked backwards. A direct list is decided in one read that returns the exact match and the usersets together, so a group with ten thousand direct members costs one row, not ten thousand.

Benchmark note. The folders-and-documents schema above, on 24 306 tuples — 1 000 users in 50 groups nested three deep, 200 folders in a five-way tree four levels deep, 20 000 documents, an editor grant on every seventh — SQLite in memory, PHP 8.5 on an Apple M4 Pro, one process:

Operation Time
Write, 1 000-tuple batches ~7 ms per batch
Check, uncached (random document × random user) 1.3 ms, ~24 index reads
Check, cached 0.08 ms (one revision read, one cache hit)
100 checks in one batch, uncached ~110 ms
List-resources, first 100 of a typical user's documents ~28 ms
List-resources, a user who reaches all 20 000 documents ~290 ms
List-subjects, a document's users ~1 ms

A server database adds a network round trip per index read, so the uncached check is dominated by that: keep the cache on, batch checks that share a subject, and pass a consistency token only when you need the write you just made to be visible.

Testing

InteractsWithFineGrainedAuthorization takes the tuple notation directly, inside an environment:

$this->actingAsEnvironment('env_test');
$this->fgaSchema($schema);
$this->fgaWrite('document:readme#viewer@user:alice');

expect($this->fgaAllows('document:readme#viewer@user:alice'))->toBeTrue();