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
Ownership is set at creation, not granted. 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 workspaces are closed. Every user owns exactly one auto-provisioned 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