ADR-0054: Ticket-Scoped Role Grants — State in DB, Validated Per Request
Date: 2026-05-09 UTC
Status: Accepted
Deciders: software-architect
Refs: docs/architecture/rbac-v2/design.md §5, docs/architecture/customer-audit-unified/design.md, ADR-0060, project_workflow_uuid_tracing_decisions.md Q4
Context
Dim-3 audit access (operator_interaction dimension) requires that support agents hold access only while a FreeScout ticket linking them to a specific customer is open. When the ticket closes, access must expire. Two approaches were considered for implementing this temporary, customer-scoped grant.
Decision
Ticket-scoped grants are stored in a DB table (rbac_ticket_grants) and validated per-request against FreeScout ticket status.
The grant is created via POST /api/rbac/grants/ticket-scoped when an operator begins supporting a customer with an active ticket. It is revoked either manually, by a background job that polls FreeScout, or by a FreeScout webhook. On every dim-3 API request, the endpoint re-validates ticket status against FreeScout synchronously — it does not trust the cached grant state alone.
Consequences
Positive:
- The dim-3 access window is bounded by ticket state. Ticket closes → access closes. This is auditable and user-visible (the grant event appears in customer_audit_events).
- The per-request FreeScout revalidation closes the TOCTOU window identified in T-PEN-6 of the unified-audit threat model: an operator cannot use a stale cached grant to continue reading after the ticket closes.
- The DB state provides an audit trail of every grant, its ticket linkage, and its revocation reason.
- Fail-closed design: if FreeScout is unreachable, dim-3 requests return 503, not 200.
Negative: - Every dim-3 request adds ~50ms latency for the FreeScout API call. Acceptable at v1 support volumes. - A background job is needed for automatic revocation (polling interval: 2 minutes via Heroku Scheduler). Without a FreeScout webhook, there is a 2-minute window after ticket close before the DB state is updated. The per-request check closes this window for active requests.
Alternatives Considered
Session-embedded ticket claim (JWT-like)
The ticket-scoped grant is embedded in the operator's session at ticket-open time and expires with the session. No DB table.
Rejected: Sessions can outlive tickets. A support agent whose session was opened with an active ticket and whose ticket closes mid-session would retain access until session expiry. The TOCTOU gap identified in T-PEN-6 is not closed by session-scoped claims alone.
RBAC-layer ticket enforcement (no DB table)
The permission check itself queries FreeScout to determine whether the user "should" have dim-3 access for this customer, without storing a grant.
Rejected: No audit trail for when access was granted. No way to show the user their own grant history. No way to manually revoke before ticket close. The DB table provides the necessary audit anchor.
See docs/architecture/rbac-v2/design.md §5 for the ticket-scoped grant state machine.