Last updated 2026-06-29
ADR-0001 — Relation tuples as the authorization primitive
Status
Accepted (2026-06-15). Foundational ADR for this service. It is the premise the other workspace ADRs (0002–0003) build on: workspaces, groups, and resource sharing are all expressed as relation tuples over the engine this ADR adopts.
Context
Identity ADR-0001 drew a hard line: identity owns authentication and tenancy; a separate service owns workspaces and fine-grained authorization. This is that service, and it has to answer one question well, for many different products: "may this user do this thing to this object?"
This service is internal. It is called service-to-service
by trusted product backends, never directly by a browser or mobile client.
The caller authenticates as a service — a shared
Authorization: Bearer <service-token> credential (or mTLS
at the mesh) — and, exactly as Zanzibar does, the end user is passed as
data: acting_user_id on the management RPCs and
subject_user_id on Check. End-user authentication
happens at the product edge, before this service is called.
The products that depend on it do not share an access model:
- A workplace collaboration tool (Slack + email + Linear + HR + incident.io) needs company-wide membership, per-issue ownership, on-call groups, and resources that inherit access from the team they live in.
- A learning platform serves both B2C learners who buy a single course and companies who buy seats that grant a whole roster access.
- A personal-assistant app lets end users share individual tasks with specific other people, with no enclosing organisation at all.
The obvious path is a per-product RBAC schema: a workspace_members
table, a course_enrollments table, an issue_shares
table, a task_collaborators table, each with its own role columns
and join logic. That path does not scale across products. Nested groups and
userset-style grants are especially painful to bolt onto flat membership
tables. Google's Zanzibar describes the alternative: a single, uniform store
of relation tuples plus per-namespace
userset-rewrite rules.
Decision
Adopt Zanzibar-style relation tuples as the single authorization primitive.
- One store, one shape. Every grant is a tuple
namespace:object_id#relation@subject, where the subject is a concrete user or a userset. There are no per-product permission tables; there is one tuple store, scoped byproject_id. - Namespaces carry rewrite rules. A namespace maps each relation to a userset-rewrite expression — a union of
this,computed_userset, andtuple_to_userset. Role hierarchies, inheritance, and group nesting are expressed in these rules rather than in code (pkg/authz/model.go). - Four generic RPCs.
AuthzServiceexposesWriteRelationTuples,ReadRelationTuples(raw read, no rewrites),Check(transitive decision), andExpand(effective userset tree). - The engine is generic; the built-ins are defaults. The service ships
workspace,group, andresourcenamespaces, but the engine is namespace-agnostic. Unknown relations default to thethisleaf, so a product can store ad-hoc relations without registering a namespace.
A concrete write-then-check: grant Bob admin on a workspace, then ask the
engine. The service is reached over the Connect protocol at
/workspace.v1.<Service>/<Method>, authenticated with
the service credential.
# Grant bob admin on workspace acme — one tuple write.curl -X POST http://localhost:8080/workspace.v1.AuthzService/WriteRelationTuples \ -H "Authorization: Bearer $WS_SERVICE_TOKEN" -H "Content-Type: application/json" \ -d '{"updates":[{"op":"OP_INSERT","tuple":{"namespace":"workspace","object_id":"acme","relation":"admin","subject":{"user_id":"bob"}}}]}'
# Check resolves the role hierarchy: admin ⊃ member, so bob is a member too.curl -X POST http://localhost:8080/workspace.v1.AuthzService/Check \ -H "Authorization: Bearer $WS_SERVICE_TOKEN" -H "Content-Type: application/json" \ -d '{"namespace":"workspace","object_id":"acme","relation":"member","subject_user_id":"bob"}'# => {"allowed": true}Consequences
- Positive. One model serves every product. A shared task, a Linear issue, a seat-based course, an email group, a company — all are tuples over the same engine, checked by the same
Check. - Positive. Inheritance and group-based grants are declarative. "Workspace admins can edit any resource in it" is one
tuple_to_usersetrule, not a join re-written in four products. - Positive.
Check/Expandgive uniform decision and audit surfaces. - Negative / learning curve. Relation tuples and userset rewrites are less immediately legible than a
rolecolumn; the authorization model exists to flatten that curve. - Negative / evaluation cost. A
Checkmay walk computed and tuple-to-userset edges transitively. The store is indexed on(project_id, namespace, object_id, relation)to bound it, and hot decisions can be cached on the effective userset. - Follow-up. Zanzibar's consistency tokens (
zookies) and a per-namespace schema-registration API are out of scope for the first cut; decisions are read-your-writes against the primary store.