App reference

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 string accepts 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

  1. Role is a protobuf enum. proto/api/core/v1/role.proto defines Role with ROLE_UNSPECIFIED = 0, ROLE_ADMIN, ROLE_MEMBER, ROLE_CHILD, ROLE_GUEST. user.proto was changed so User.roles, CreateUserRequest.initial_roles, and AssignRolesRequest.roles are repeated Role instead of repeated string. This was an accepted breaking change to the contract β€” there was no production use, so a clean break beat a compatibility shim.

  2. Permission and PermissionMatrix are proto messages, for client visibility. Permission names a service, a method, and the roles allowed to call it; PermissionMatrix aggregates 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, later auth.Allow β€” see ADR-0002).

  3. pkg/roles is the shared, public home for role logic. It wraps the generated enum with ergonomic names (roles.Admin, roles.Member, …), a Role type alias, and helpers (AllRoles, IsValid, FromString, ValidateRoles, CheckPermission). internal/auth/roles.go was deleted; internal/auth and pkg/client both consume pkg/roles, so internal enforcement and external clients share one definition.

  4. The permission matrix is code-defined and immutable at runtime. pkg/roles/permissions.go holds 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.

  5. 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 (the FromString helper accepts either) rather than migrating stored data.

Consequences

Positive

  • Compile-time safety. roles.Admin autocompletes; a typo is a build error instead of a runtime failure. Enum zero value is ROLE_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 breaking detects them, and schema tooling documents them.
  • One definition. Server enforcement, client validation, and tests all import the same pkg/roles package β€” 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 FromString path 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/roles with 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_enums field alongside the existing repeated 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.