ADR-0010: Roles and permissions modelled in Protocol Buffers
Metadata
- Status: Accepted
- Date: 2026-02-01
- Deciders: EagraΓ Clainne Team
- Converted from: RFC 004 (Roles & Permissions in Protobuf), docs/rfcs/004-roles-permissions-protobuf/ β now removed
- Related: ADR-0008 (formerly RFC 002, RBAC β this decision builds on it); ADR-0007 (formerly RFC 001, Authentication); ADR-0009 (formerly RFC 003, Service Authorization); ADR-0002 (Centralised RPC authorization behind auth.Allow)
Context
Roles were originally Go string constants in internal/auth/roles.go
(RoleAdmin = "ADMIN", etc.), and the API contract carried them as
repeated string roles. That arrangement had compounding problems:
- No external access.
internal/cannot be imported, so client libraries and CLI tools had to use magic strings ([]string{"ADMIN", "MEMBER"}) β typo-prone, no IDE help, and typos surfaced only at runtime. - No type safety at the contract.
repeated stringaccepts any value; the protobuf layer could not reject an invalid role. - Inconsistency. EventType, MutationAction, and RelationType were already protobuf enums; Role was the odd one out.
- Go-only. A future TypeScript, Python, or Java client would have had no typed role constants at all.
Roles are fundamental to the API, so they belong in the API contract, not in a private Go package.
Decision
-
Role is a protobuf enum.
proto/api/core/v1/role.protodefinesRolewithROLE_UNSPECIFIED = 0,ROLE_ADMIN,ROLE_MEMBER,ROLE_CHILD,ROLE_GUEST.user.protowas changed soUser.roles,CreateUserRequest.initial_roles, andAssignRolesRequest.rolesarerepeated Roleinstead ofrepeated string. This was an accepted breaking change to the contract β there was no production use, so a clean break beat a compatibility shim. -
Permission and PermissionMatrix are proto messages, for client visibility.
Permissionnames a service, a method, and the roles allowed to call it;PermissionMatrixaggregates them. These exist in the proto (not just in Go) so any generated client can read and reason about the permission model β e.g. a CLI can warn "you don't have permission to create events" before making the call. They are explicitly reference-only: client-side checks are advisory, and the server remains the sole enforcement point (via the interceptor, laterauth.Allowβ see ADR-0002). -
pkg/rolesis the shared, public home for role logic. It wraps the generated enum with ergonomic names (roles.Admin,roles.Member, β¦), aRoletype alias, and helpers (AllRoles,IsValid,FromString,ValidateRoles,CheckPermission).internal/auth/roles.gowas deleted;internal/authandpkg/clientboth consumepkg/roles, so internal enforcement and external clients share one definition. -
The permission matrix is code-defined and immutable at runtime.
pkg/roles/permissions.goholds the matrix as a Go value. Changing permissions requires a code change, a version bump, and a release β there is no runtime permission configuration, no dynamic role creation, and no per-family custom roles (a possible future enhancement, out of scope here). This makes the permission model auditable and version-locked, and makes exposing it to clients safe: it is documentation, not enforcement. -
Database compatibility via string names, tolerant reads. Users are stored as JSONB and protojson serialises enums as their string names, so no schema migration was needed. Because pre-existing data used unprefixed names (
"ADMIN") while the enum serialises as"ROLE_ADMIN", reads handle both formats (theFromStringhelper accepts either) rather than migrating stored data.
Consequences
Positive
- Compile-time safety.
roles.Adminautocompletes; a typo is a build error instead of a runtime failure. Enum zero value isROLE_UNSPECIFIED, so "no role set" is explicit and detectable. - Cross-language by construction. Any language buf can generate for gets typed role constants and the permission matrix for free.
- Contract-governed evolution. Role changes are part of the API spec:
buf breakingdetects them, and schema tooling documents them. - One definition. Server enforcement, client validation, and tests all
import the same
pkg/rolespackage β no drift between "roles the server knows" and "roles the client sends".
Negative / trade-offs
- Breaking contract change. Three request/message fields changed type. Acceptable only because there were no external consumers yet; the same change post-1.0 would have required the rejected two-field hybrid.
- Legacy string handling lives on. The database still contains
unprefixed role strings, so the tolerant
FromStringpath must be kept until (if ever) stored data is migrated. - Shipping the matrix in clients is a soft contract. A stale client's matrix can disagree with the server's; the server is authoritative, but client-side pre-checks can mislead until the client upgrades.
Current state
Implemented in full (2026-02-01): proto definitions, pkg/roles,
internal/auth migration, service and client updates, with the full test
suite (243 tests at the time) passing. ADR-0002 subsequently built on this
by centralising the RPC authorization decision behind auth.Allow, which
consults roles.CheckPermission.
Alternatives considered
- Public Go constants only (
pkg/roleswith strings, no proto change). Rejected: fixes the import problem for Go but leaves the contract as untyped strings, stays inconsistent with the other enums, and does nothing for non-Go clients. - Hybrid two-field migration. Add a
repeated Role role_enumsfield alongside the existingrepeated string roles. Rejected: two fields meaning the same thing is a confusing API and instant technical debt; with no production use, a clean break was better. - Do nothing (clients keep using strings). Rejected: perpetuates magic strings and a poor client developer experience.