App reference

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

  1. 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.

  2. 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 (originally internal/auth; internal/auth retains 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.

  3. 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).

  4. Roles are carried in the JWT. Tokens include a roles string-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.

  5. Role lifecycle rules.

    • The first user created becomes ADMIN. If EAG_INITIAL_ADMIN_TOKEN is configured, the request must present it in an X-Initial-Admin-Token header β€” 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_roles at creation or change roles afterwards via the AssignRoles RPC (transaction-safe, idempotent); users can never modify their own roles. All assignments are validated against the fixed role set.

Consequences

Positive

  • Fast, uniform rejection. Unauthorized calls fail at the edge with PermissionDenied before 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.