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#memberor@workspace:acme#member.
- a concrete user —
workspace:acme#admin@user:bob # bob is a direct admin of workspace acmeresource: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:
| Primitive | Zanzibar name | Meaning |
|---|---|---|
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.
| Relation | Rewrite | Reads as |
|---|---|---|
owner | this() | Directly stored owners only. |
admin | union(this(), computed("owner")) | Direct admins ∪ owners. |
member | union(this(), computed("admin")) | Direct members ∪ admins (∪ owners). |
guest | union(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
| Relation | Rewrite | Reads as |
|---|---|---|
member | this() | 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 backendresource
A generic product object that inherits access from a parent workspace and also supports direct per-object sharing.
| Relation | Rewrite | Reads as |
|---|---|---|
parent | this() | The workspace this resource belongs to (a stored userset). |
owner | this() | Directly stored resource owners. |
editor | union(this(), computed("owner"), tupleToUserset("parent", "admin")) | Direct editors ∪ the resource owner ∪ every admin of the parent workspace. |
viewer | union(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 acmeresource:doc1#parent@workspace:acme # doc1 lives in acmeNow 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:
vieweronresource:doc1isunion(this, computed("editor"), tupleToUserset("parent","member")).- this: no stored
resource:doc1#viewer@user:bob— and no userset tuples to recurse into. Miss. - computed("editor"): re-check
editorondoc1=union(this, computed("owner"), tupleToUserset("parent","admin")).thisandownermiss;tupleToUserset("parent","admin")reads the parent usersetworkspace:acmeand checksadminon it for bob… adminonworkspace:acme=union(this, computed("owner")). this finds the stored tupleworkspace:acme#admin@user:bob. Match.- The match bubbles up through every
union—editoris true, thereforevieweris 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:
- 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. - With rewrite rules. To get hierarchy, inheritance, or group expansion, add a
NamespacetoDefaultModel()inpkg/authz/model.go, mapping each relation to aRewritebuilt fromthis(),union(),computed(), andtupleToUserset(). Mirror theresourcenamespace: pick a tupleset relation that points at the parent object, thentupleToUserset("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 service | Zanzibar paper |
|---|---|
relation tuple namespace:object#relation@subject | relation tuple ⟨object#relation@user⟩ |
userset subject group:eng#member | userset group:eng#member |
| namespace rewrite rules | namespace configuration / userset rewrites |
this() | _this |
computed() | computed_userset |
tupleToUserset() | tuple_to_userset |
Check / Expand | Check / 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
- Workspaces & Groups — the product surface over these namespaces.
- Check & Expand — querying the engine in depth.
- Security & Service Auth — how callers authenticate.