ADR-0008: Role-based access control via a static permission matrix at the interceptor
Metadata
- Status: Accepted
- Date: 2025-12-22
- Deciders: EagraΓ Clainne Team
- Converted from: RFC 002 (RBAC), docs/rfcs/002-rbac/ β now removed
- Related: ADR-0007 (formerly RFC 001, basic authentication), which this
builds on; ADR-0009 (formerly RFC 003, service-layer authorization), which
builds on this; ADR-0002 (centralised RPC authorization behind
auth.Allow), which later consolidated the enforcement seam described here; ADR-0010 (formerly RFC 004), ADR-0011 (formerly RFC 005, CLI client), ADR-0012 (formerly RFC 006, profile self-service)
Context
ADR-0007's authentication treats every authenticated user identically. A family organization app does not want that: parents need full control, family members should manage their own resources, children should have a narrow "view and complete assigned tasks" surface, and guests should be read-only.
We needed coarse-grained authorization β "may this caller invoke this RPC at all?" β decided before any handler runs, as the foundation for the fine-grained ownership checks that ADR-0009 adds inside the services.
Decision
-
Four fixed, family-oriented roles.
- ADMIN β parents/guardians; full control, manages members and roles.
- MEMBER β standard family members; manage their own resources.
- CHILD β limited access; view and complete assigned tasks, claim rewards.
- GUEST β external users; read-only (e.g. events they are invited to).
(Amended 2026-07-25: the role gate was widened β MEMBER now manages rewards (Create/Update/Delete) alongside ADMIN, and CHILD passes the matrix for event and item mutations. "Their own" is not the matrix's job: the service guards (ADR-0009, ADR-0014) scope children (and members) to events they belong to and items they own, which is what makes the wider gate safe. CHILD remains excluded from reward management, user administration, and β per ADR-0014, a flat "no" that needs no resource context β ItemService/Delete.)
Users hold an array of roles and get the union of their permissions. There is no implicit hierarchy: ADMIN is explicitly listed on every method it may call, rather than inheriting MEMBER/CHILD access.
-
A static, code-defined permission matrix β not database-driven. Every RPC method is explicitly mapped to the roles allowed to call it. The full matrix lives in
pkg/roles/permissions.go(originallyinternal/auth;internal/authretains re-export aliases per ADR-0002). A representative entry:{Service: "ItemService", Method: "CompleteItem", Roles: []string{RoleAdmin, RoleMember, RoleChild}},Being code means the matrix is reviewed, tested (including a completeness test), and versioned with the RPC surface it protects.
-
Enforcement at the interceptor, deny-by-default. The auth interceptor validates the JWT, then checks the matrix before the request reaches any service handler β for both unary and streaming RPCs. A method with no matrix entry is denied, even for ADMIN; only listed public endpoints (login, first registration) bypass the check. ADR-0002 subsequently collapsed this into the single
auth.Allow(procedure, roles)seam. This is deliberately a base layer: services perform their own ownership checks on top (defense in depth, per ADR-0009). -
Roles are carried in the JWT. Tokens include a
rolesstring-array claim, set at login from the user record, so authorization is stateless β no per-request user lookup. The corollary: role changes only take effect at the next token generation. Active sessions keep their old roles until the token expires (typically 24h); we accepted this over token revocation for simplicity. -
Role lifecycle rules.
- The first user created becomes ADMIN. If
EAG_INITIAL_ADMIN_TOKENis configured, the request must present it in anX-Initial-Admin-Tokenheader β protecting exposed deployments from a bootstrap race. Unset (dev convenience), the first user is admin automatically. - Subsequent users default to MEMBER (least privilege β never ADMIN).
- Only ADMIN may set
initial_rolesat creation or change roles afterwards via theAssignRolesRPC (transaction-safe, idempotent); users can never modify their own roles. All assignments are validated against the fixed role set.
- The first user created becomes ADMIN. If
Consequences
Positive
- Fast, uniform rejection. Unauthorized calls fail at the edge with
PermissionDeniedbefore touching service or database code; the check is a sub-microsecond in-memory lookup. - Auditable authorization. The entire coarse-grained policy is one Go file, exercised by matrix and integration tests across all four roles.
- Deny-by-default is enforced structurally. Adding an RPC without a matrix entry yields a dead endpoint, not an open one β forgetting authorization fails closed.
- The roles claim keeps request handling stateless and gave ADR-0009 a
ready-made
UserContext(roles,IsAdmin()) for its ownership checks.
Negative / trade-offs
- Stale roles in live tokens. A demoted (or promoted) user keeps their old permissions until token expiry. Revocation would require server-side state; shorter expiries are the available mitigation.
- The matrix must be maintained by hand. Every new RPC needs an explicit entry; misconfiguration is a policy bug. Mitigated by review and the completeness test, but it is a standing obligation.
- No custom roles. Families cannot define their own roles or per-user permission tweaks; the four roles are compiled in. Acceptable for the family scope; revisit if the product outgrows it.
- No role hierarchy means some repetition in the matrix (ADMIN listed almost everywhere) β the price of being able to read a method's policy in one line.
Alternatives considered
- Attribute-based access control (ABAC). Deciding on attributes (age, family relationship) instead of fixed roles. Rejected: far more complex to implement and to reason about; overkill for a family app where four roles cover the real personas.
- Per-user permission strings (e.g.
"event.read","user.create"). Rejected: fine-grained but verbose to configure and hostile to the admin UX β a parent should assign "CHILD", not curate a permission list. - Database-driven permission matrix. Implicit in the design choice: keeping the matrix in code trades runtime configurability for review, testing, and atomic evolution with the RPC surface β the right trade for a fixed role set.
- Implicit role hierarchy (ADMIN inherits everything). Rejected in favour of explicit per-method role lists: slightly more verbose, but each matrix entry states its policy completely.
- Session invalidation on role change. Rejected for simplicity; role changes apply on re-login, and token expiry bounds the staleness window.