Last updated 2026-06-29

Authorization Model

This is the deep reference for the relation-tuple engine that backs the workspaces service. It covers the tuple format, every built-in namespace and its exact rewrite rules, how Check evaluates transitively (and why it cannot loop), what Expand returns, and how to add a namespace. The source of truth for the wire API is proto/workspace/v1/workspace.proto; the source of truth for the built-in model is pkg/authz/model.go.

The engine is Zanzibar-inspired: one uniform store of relation tuples plus per-namespace userset-rewrite rules (see ADR-0001). The end user is never the caller — they are data in the request (subject_user_id). Service-to-service authentication is covered in Security & Service Auth.

The relation tuple

A relation tuple is:

namespace:object_id#relation@subject
  • namespace — the kind of object: workspace, group, resource, or a product's own namespace.
  • object_id — the specific object, e.g. acme.
  • relation — the relationship being granted: owner, member, viewer, …
  • subject — the right-hand side. Exactly one of:
    • a concrete user@user:alice — a leaf identity; or
    • a userset — another namespace:object_id#relation, meaning "every subject that holds that relation on that object", e.g. @group:family#member or @workspace:acme#member.
workspace:acme#admin@user:bob # bob is a direct admin of workspace acme
resource:doc1#viewer@group:family#member # everyone in the family group may view doc1

In the proto these are RelationTuple, Subject (a oneof of user_id / SubjectSet), and SubjectSet (namespace / object_id / relation). Every tuple is scoped to a project_id — the isolation shard (see Projects & Multi-Tenancy); tuples in different projects never see each other.

Userset rewrites

A namespace maps each relation to a Rewrite — the rule for computing the set of subjects that hold that relation. A Rewrite is built from four primitives:

PrimitiveZanzibar nameMeaning
this() _this The relation's own directly stored tuples. The leaf. A zero Rewrite means this.
computed("rel") computed_userset Evaluate another relation rel on the same object. Powers role hierarchy: admin includes owner.
tupleToUserset("ts", "rel") tuple_to_userset For every userset stored under relation ts on this object, evaluate rel on each referenced object. Powers inheritance from a parent object.
union(...) union Grant access if any child rewrite grants it.

Unknown namespaces and unknown relations default to this — so a product can store ad-hoc relations without first registering a namespace; they behave as plain stored tuples with no inheritance.

The built-in namespaces

Transcribed verbatim from DefaultModel() in pkg/authz/model.go. this = the directly stored tuples; computed(rel) = relation rel on the same object; tupleToUserset(ts, rel) = walk the usersets stored under ts and check rel on each referenced object.

workspace

The membership grades, ordered owner ⊃ admin ⊃ member ⊃ guest. Each grade unions its own direct tuples with the grade above it, so an owner is implicitly an admin, a member, and a guest.

RelationRewriteReads as
ownerthis()Directly stored owners only.
adminunion(this(), computed("owner"))Direct admins ∪ owners.
memberunion(this(), computed("admin"))Direct members ∪ admins (∪ owners).
guestunion(this(), computed("member"))Direct guests ∪ members (∪ admins ∪ owners).

Role in the proto maps 1:1 onto these relations. WorkspaceService.AddMember and friends write workspace:<id>#<role>@user:<id> tuples.

group

RelationRewriteReads as
memberthis()Directly stored members only.

Just stored tuples — but because a subject may itself be a userset, a group member can be another group, and Check resolves the nesting transitively (see ADR-0003):

group:all-eng#member@group:backend#member # all-eng contains everyone in backend

resource

A generic product object that inherits access from a parent workspace and also supports direct per-object sharing.

RelationRewriteReads as
parentthis()The workspace this resource belongs to (a stored userset).
ownerthis()Directly stored resource owners.
editorunion(this(), computed("owner"), tupleToUserset("parent", "admin"))Direct editors ∪ the resource owner ∪ every admin of the parent workspace.
viewerunion(this(), computed("editor"), tupleToUserset("parent", "member"))Direct viewers ∪ every editor ∪ every member of the parent workspace.

So workspace admins can edit anything in the workspace and members can view it with zero per-resource tuples — while individual users or groups can still be granted editor / viewer directly on a single resource.

How Check evaluates — a worked walk

Check(namespace, object_id, relation, subject_user_id) answers a boolean: does this concrete user hold this relation on this object? Consider these stored tuples in one project:

workspace:acme#admin@user:bob # bob is an admin of acme
resource:doc1#parent@workspace:acme # doc1 lives in acme

Now ask whether bob may view doc1:

curl -X POST http://localhost:8080/workspace.v1.AuthzService/Check \
-H "Authorization: Bearer $WS_SERVICE_TOKEN" -H "Content-Type: application/json" \
-d '{"namespace":"resource","object_id":"doc1","relation":"viewer","subject_user_id":"bob"}'
# => {"allowed":true}

The engine evaluates the rewrite tree depth-first, short-circuiting on the first match:

  1. viewer on resource:doc1 is union(this, computed("editor"), tupleToUserset("parent","member")).
  2. this: no stored resource:doc1#viewer@user:bob — and no userset tuples to recurse into. Miss.
  3. computed("editor"): re-check editor on doc1 = union(this, computed("owner"), tupleToUserset("parent","admin")). this and owner miss; tupleToUserset("parent","admin") reads the parent userset workspace:acme and checks admin on it for bob…
  4. admin on workspace:acme = union(this, computed("owner")). this finds the stored tuple workspace:acme#admin@user:bob. Match.
  5. The match bubbles up through every unioneditor is true, therefore viewer is true. Result: allowed: true.

When a this leaf finds a stored tuple whose subject is a userset (e.g. resource:doc1#viewer@group:family#member), the engine recurses into Check(group, family, member, subject). That single mechanism resolves group grants and nested groups.

Cycle-safety

Traversal carries a visited set keyed by (namespace, object, relation) and a max-depth bound. Re-entering a node already on the current path contributes nothing (the branch returns false) instead of looping, so cyclic group nesting — group:a#member@group:b#member together with group:b#member@group:a#member — terminates safely rather than recursing forever. Exceeding the depth bound is a runaway backstop and returns an error.

What Expand returns

Expand(namespace, object_id, relation) is the set-valued sibling of Check: instead of testing one user, it returns the effective UsersetTree for the relation. The tree mirrors the rewrite: UNION nodes hold child subtrees, and LEAF nodes carry concrete user_ids and nested sets (usersets). Use it to answer "who has access?" and for audit and debugging.

curl -X POST http://localhost:8080/workspace.v1.AuthzService/Expand \
-H "Authorization: Bearer $WS_SERVICE_TOKEN" -H "Content-Type: application/json" \
-d '{"namespace":"workspace","object_id":"acme","relation":"member"}'

ReadRelationTuples is not a permission check and not an expansion — it returns the raw stored tuples matching an exact filter, with no rewrite evaluation. Use Check for decisions and Expand to enumerate effective access.

Adding a new namespace

A consuming product can use the engine two ways:

  1. Ad-hoc, no registration. Just write tuples under a new namespace. Unknown namespaces and relations default to this, so they behave as plain stored grants with no inheritance.
  2. With rewrite rules. To get hierarchy, inheritance, or group expansion, add a Namespace to DefaultModel() in pkg/authz/model.go, mapping each relation to a Rewrite built from this(), union(), computed(), and tupleToUserset(). Mirror the resource namespace: pick a tupleset relation that points at the parent object, then tupleToUserset("parent", "relationOnParent") to inherit.

Design checklist for a new namespace:

  • Which relations are directly grantable (this)?
  • Which are implied by a stronger relation on the same object (computed)?
  • Does the object inherit from a parent — and via which relation (tupleToUserset)?
  • Will subjects ever be groups / usersets? (They always may.)

Zanzibar terminology

This serviceZanzibar paper
relation tuple namespace:object#relation@subjectrelation tuple ⟨object#relation@user⟩
userset subject group:eng#memberuserset group:eng#member
namespace rewrite rulesnamespace configuration / userset rewrites
this()_this
computed()computed_userset
tupleToUserset()tuple_to_userset
Check / ExpandCheck / Expand

The end user being data, not the caller, is also straight from Zanzibar: Check takes the user as an argument and the calling service authenticates as itself.

Where to next