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:

  1. 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.
  2. A learning platform serves both B2C learners who buy a single course and companies who buy seats that grant a whole roster access.
  3. 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.

  1. 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 by project_id.
  2. Namespaces carry rewrite rules. A namespace maps each relation to a userset-rewrite expression — a union of this, computed_userset, and tuple_to_userset. Role hierarchies, inheritance, and group nesting are expressed in these rules rather than in code (pkg/authz/model.go).
  3. Four generic RPCs. AuthzService exposes WriteRelationTuples, ReadRelationTuples (raw read, no rewrites), Check (transitive decision), and Expand (effective userset tree).
  4. The engine is generic; the built-ins are defaults. The service ships workspace, group, and resource namespaces, but the engine is namespace-agnostic. Unknown relations default to the this leaf, 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_userset rule, not a join re-written in four products.
  • Positive. Check/Expand give uniform decision and audit surfaces.
  • Negative / learning curve. Relation tuples and userset rewrites are less immediately legible than a role column; the authorization model exists to flatten that curve.
  • Negative / evaluation cost. A Check may 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.