Last updated 2026-06-29
Roles & RBAC
This guide models classic role-based access control with the
built-in workspace namespace: a fixed hierarchy of roles where a
stronger role automatically holds every weaker role's permissions.
What you'll learn: how the
owner ⊃ admin ⊃ member ⊃ guest hierarchy is expressed with
computed_userset rewrites, which relation tuples
AddMember writes, how to ask "can bob administer this workspace?"
with a single Check, and how to map your app's permissions onto
these four relations.
The role hierarchy
The workspace namespace defines four relations. Each one unions
its own directly stored tuples with the relation immediately above it, so a
grant flows down the chain: an owner is implicitly an admin, a member, and a
guest.
owner := this()admin := union(this(), computed("owner"))member := union(this(), computed("admin"))guest := union(this(), computed("member")) this() is the leaf — the directly stored tuples for that
relation. computed("owner") is a computed_userset:
"also count everyone who holds owner on this same object." That
single primitive is the whole RBAC hierarchy.
Role (proto Role) | Relation | Implicitly also holds |
|---|---|---|
ROLE_OWNER | owner | admin, member, guest |
ROLE_ADMIN | admin | member, guest |
ROLE_MEMBER | member | guest |
ROLE_GUEST | guest | — |
AddMember, UpdateMemberRole, and
CreateInvitation reject ROLE_OWNER with
InvalidArgument. The owner is whoever created the workspace.
To hand off ownership, model it in your product; this service does not
re-assign owner through the membership RPCs.
What AddMember writes
You rarely write role tuples by hand.
WorkspaceService.AddMember is the high-level API: give it a
workspace, a user, and a role, and it stores the matching tuple. The call
must carry acting_user_id — the user the action is authorized as
(omitting it returns InvalidArgument).
curl -X POST http://localhost:8080/workspace.v1.WorkspaceService/AddMember \ -H "Authorization: Bearer $WS_SERVICE_TOKEN" \ -H "Content-Type: application/json" \ -d '{"workspace_id":"W","user_id":"bob","role":"ROLE_ADMIN","acting_user_id":"alice"}'That stores a single tuple:
workspace:W#admin@bob AddMember with ROLE_MEMBER would store
workspace:W#member@bob, and so on. There is no
workspace_members table behind the scenes — membership is
the tuple.
The header Authorization: Bearer $WS_SERVICE_TOKEN is the
service credential of the calling product backend, not an end
user. The acting user (alice) and the subject (bob)
are explicit request fields — they are data. See
Security & Service Auth.
Checking a permission
On the request hot path you ask one question: does this user hold this relation on this object? "Can bob administer workspace W?" is exactly:
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":"W","relation":"admin","subject_user_id":"bob"}'{ "allowed": true } subject_user_id is the user being tested — independent of the
caller. Because bob was added as admin, the
admin rule's this() leaf matches directly.
Now ask whether bob is a member. There is no
workspace:W#member@bob tuple, but Check evaluates
the rewrite: member = union(this(), computed("admin")), and
bob is an admin — so the answer is still allowed.
-d '{"namespace":"workspace","object_id":"W","relation":"member","subject_user_id":"bob"}'# -> { "allowed": true } (admin implies member)
Conversely, a plain member fails an admin check — the hierarchy
only flows downward.
Mapping app permissions to relations
RBAC means choosing, per action, the minimum relation that
may perform it, then gating the action with a Check for that
relation. Because the hierarchy is inclusive, you only ever check the floor.
| App action | Minimum relation to Check |
|---|---|
| Delete the workspace, change billing | owner |
| Add/remove members, change settings | admin |
| Create and edit content | member |
| Read-only access | guest |
For example, before letting someone open the settings page, gate it on
Check(workspace, W, admin, <user>). Owners pass it for free
(owner ⊃ admin); members and guests do not.
PERSONAL workspace with a single member — the
owner. It cannot accept new members and cannot be deleted, so
AddMember against it is rejected. RBAC across multiple roles
applies to TEAM workspaces (see
ADR-0002).
Where to next
- Groups & Nesting — grant a whole team a role at once
- Hierarchies & Inheritance — let resources inherit workspace roles
- Authorization Model — the full rewrite reference