Authorization

Flametrench authorization is built on a single primitive: relational tuples. Every grant is a row (subject, relation, object); every permission check matches tuples exactly.

This chapter is normative. Rationale and alternatives are in ADR 0001 — Authorization model.

The tuple primitive

Entity shape

  • id — UUIDv7; tup_<hex>.
  • subject_type — the type prefix of the subject. Currently MUST be usr.
  • subject_id — the UUIDv7 of the subject.
  • relation — a string matching ^[a-z_]{2,32}$. Either a registered relation (below) or an application-custom relation.
  • object_type — the type prefix of the object. MAY be any valid type prefix (2–6 lowercase ASCII characters, matching ^[a-z]{2,6}$). Registered object types include org, mem, inv, ses, cred, usr. Applications MAY introduce custom object types (project, doc, etc.).
  • object_id — the UUIDv7 of the object.
  • created_at, created_by (nullable).

The natural key of a tuple is the 5-tuple (subject_type, subject_id, relation, object_type, object_id). This combination MUST be unique; duplicate grants are prohibited.

Registered relations

Six built-in relations. The spec pins their semantic intent; applications SHOULD use them when the intent matches.

RelationSemantic intent
ownerFull control, including ownership transfer. Typically one per org.
adminManage members, settings, and other admins; cannot transfer ownership.
memberDefault org participant; general-purpose access to the org's features.
guestMinimal, typically scoped; limited access.
viewerRead-only on the object.
editorRead and write on the object.

Org-scoped vs. object-scoped

Typical usage:

  • owner, admin, member, guest are applied at the org level (object_type = org). These are membership relations.
  • viewer, editor are applied at any object level (object_type = project, doc, etc.).

The spec does not prohibit other combinations; applications MAY use editor on an org to mean "can edit any org-level configuration," if that matches their model.

Custom relations

Applications MAY register relation names not in the built-in registry. Custom relations follow the same format (^[a-z_]{2,32}$) and carry no spec-defined semantics — the application defines meaning.

The check() primitive

check(subject, relation | [relations], object) → bool

Returns true iff a tuple exists matching the subject, the object, and one of the given relations.

Single-relation form

check(
  subject_type  = "usr",
  subject_id    = "0190f2a8-1b3c-7abc-8123-456789abcdef",
  relation      = "admin",
  object_type   = "org",
  object_id     = "01abcdef-..."
) → bool

Set form

check(
  subject_type  = "usr",
  subject_id    = "...",
  relations     = ["owner", "admin"],
  object_type   = "org",
  object_id     = "..."
) → bool

Returns true if a tuple exists for subject on object with any of the listed relations. Equivalent to the logical OR of individual checks, provided as a single call for ergonomics and atomicity.

Implementations MUST accept both forms. The relation set in the set-form MUST be non-empty.

Exact-match semantics (default)

By default, check() performs exact match only — no derivations, no inheritance. When no rewrite rules are registered, there is:

  • No relation implication. admin does not imply editor. editor does not imply viewer.
  • No parent-child inheritance. viewer of org_acme does not imply viewer of any project in org_acme.
  • No group expansion. Group subjects remain deferred.

If an application's authorization policy requires any of these derivations, three options are available:

  1. Materialize implied tuples at state-change time (write all implied grants explicitly).
  2. Compose checks at call sites using the set form of check().
  3. Register rewrite rules (v0.2) — declarative derivation with depth and fan-out caps. See below.

Rewrite rules (v0.2 / v0.3)

Rewrite rules let you declare authorization policies that check() evaluates automatically — role implication, parent-child inheritance, and similar derivations. Rules are registered at store-construction time; when no rules are registered, check() behavior is byte-identical to pure exact-match (full backward compatibility with any v0.1 call site).

Rationale and the full evaluation model live in ADR 0007 — Authorization rewrite rules.

v0.2: In-memory tuple store supports rewrite rules.
v0.3: PostgresTupleStore.check() accepts the same rules option and evaluates via iterative async expansion (ADR 0017). Adopters who need Postgres durability with rules no longer need an in-memory shadow store.

Three node types

A rule is keyed on (object_type, relation) and contains a union of one or more nodes:

  • this — the explicit-tuple set. Identical to v0.1 exact-match semantics; always implicitly part of every rule's union. Listing this explicitly is documentation, not behavior change.
  • computed_userset { relation: <name> } — anyone holding <name> on the same object. Used for role implication: editor implies viewer.
  • tuple_to_userset { tupleset: { relation: <ttu> }, computed_userset: { relation: <target> } } — anyone holding <target> on the object pointed to by the <ttu> relation from the current object. Used for parent-child inheritance.

Not included in v0.2 rules: intersection, exclusion, and transitive closure. See ADR 0007 for rationale.

Example: role implication and parent-child inheritance

rules:
  proj:
    viewer:
      union:
        - this
        - computed_userset: { relation: editor }
        - tuple_to_userset:
            tupleset: { relation: parent_org }
            computed_userset: { relation: member }
    editor:
      union:
        - this
        - computed_userset: { relation: admin }
  org:
    member:
      this

This expresses:

  • Anyone with editor on a project also has viewer.
  • Anyone with admin on a project also has editor (and transitively viewer).
  • Any org member is implicitly a viewer of org-owned projects via the parent_org hop.

Registering rules in code

from flametrench_authz import InMemoryTupleStore, ComputedUserset, TupleToUserset
 
store = InMemoryTupleStore(
    rules={
        "proj": {
            "viewer": [
                ComputedUserset(relation="editor"),
                TupleToUserset(
                    tupleset_relation="parent_org",
                    computed_userset_relation="member",
                ),
            ],
        },
    },
)

For Postgres (v0.3):

store = PostgresTupleStore(pool=pool, rules=rules)

Depth and fan-out limits

  • Maximum evaluation depth: 8 hops.
  • Maximum fan-out per step: 1024 subjects.

Graphs that exceed either limit raise EvaluationLimitExceededError. Rules are SDK/application configuration, not row-level data — store them in code or in an app-managed config table.

Patterns for working without rewrite rules

Applications on pure exact-match semantics face two patterns for expressing implied grants. Both are spec-supported; neither is favored.

Pattern A — Materialize at state-change time

When the state that implies a grant changes, the application writes the grant as an explicit tuple.

Example: "every org member can view every project in that org"

  • When Alice joins org_acme, the application writes one additional tup_(alice, viewer, project_X) for every existing project in org_acme.
  • When a new project is created in org_acme, the application writes one additional tup_(member, viewer, project) for every active member of the org.
  • When Alice leaves, the application deletes her membership tuple AND all the implied project-viewer tuples.

When to prefer Pattern A:

  • When "who can view X?" must be a trivial SQL query.
  • When membership churn is low relative to resource churn.
  • When audit must show every grant as an explicit row.

Pattern B — Combined checks at query time

The application does not materialize implied grants. Instead, check() calls pass a relation set and the application inspects multiple tuples.

Example: same policy, different implementation

  • check(user, [viewer, editor, owner, admin], project) — first, check direct grants on the project.
  • If that returns false, check(user, [owner, admin, member], project.parent_org) — check org-level membership.

When to prefer Pattern B:

  • When state-change volume would otherwise cause tuple explosions.
  • When derivation logic is narrow and localizable in the application.
  • When audit can tolerate "who can view X?" being a computed query rather than a row lookup.

Mixing patterns

Most real applications use both. Use Pattern A for default grants (org membership implies certain resource grants) and Pattern B for exceptional cases (Carol-the-contractor's per-project scope). Rewrite rules (v0.2) provide a third path that handles both without bespoke application code.

Supporting operations

  • createTuple(subject_type, subject_id, relation, object_type, object_id, created_by?) → tup_id.
  • deleteTuple(tup_id).
  • check(...) — as defined above.
  • listTuplesBySubject(subject, cursor, limit) → [tup] — enumerate what a subject holds.
  • listTuplesByObject(object, relation?, cursor, limit) → [tup] — enumerate who has grants on an object.
  • cascadeRevokeSubject(subject) — delete all tuples with the given subject. Used on user revocation and membership termination.

Enumeration operations MUST paginate with UUIDv7-ordered cursors (seek-based). Applications SHOULD authz-gate enumeration operations via check() with appropriate relations; the spec does not mandate default policy.

Conformance fixtures

  • Exact-match check. Given tup_(alice, editor, project_42) and no other tuples for that subject/object, check(alice, editor, project_42) MUST return true; check(alice, viewer, project_42) MUST return false.

  • Set-form check. Given tup_(alice, editor, project_42), check(alice, [viewer, editor], project_42) MUST return true.

  • No implicit derivation. Given only tup_(alice, admin, org_acme) and no other tuples, check(alice, editor, org_acme) MUST return false. The spec does NOT imply editor from admin without rewrite rules.

  • Empty rules equals v0.1. A store constructed with no rules MUST produce byte-identical results to a v0.1 exact-match store on the same tuple set.

  • Uniqueness of tuples. Attempting to create a second tuple with identical (subject_type, subject_id, relation, object_type, object_id) MUST return an error or be idempotent (returning the existing tuple's ID). Implementations MUST NOT store duplicate rows.

  • Invalid UUID in subject or object. Any tuple operation that would store subject_id = ffffffff-ffff-ffff-ffff-ffffffffffff (Max UUID) or 00000000-0000-0000-0000-000000000000 (Nil UUID) MUST fail; these are not valid Flametrench identifiers per docs/ids.md.

More fixtures are in spec/conformance/ across three rewrite-rule fixture files covering computed_userset, tuple_to_userset, and empty-rules-equals-v0.1 cases.