Raxx · internal docs

internal · gated

ADR-0055: RBAC Grant Mutations — Pre-Write Audit Pattern

Date: 2026-05-09 UTC
Status: Accepted
Deciders: software-architect
Refs: docs/architecture/rbac-v2/design.md I-9, docs/security/customer-audit-unified-threat-model.md §T-INS-5


Context

Insider threat scenario T-INS-5 (privilege escalation via RBAC modification) identifies that if an RBAC mutation is applied before the audit row is written, a bad actor could abort the application between the mutation and the audit write, leaving a privilege escalation with no record. The question is where in the transaction the audit row is written relative to the mutation.


Decision

rbac_grants_audit INSERT fires before the rbac_user_groups / rbac_ticket_grants mutation is committed. Both are in the same DB transaction. If the audit INSERT fails, the transaction is rolled back and the grant is not applied.

Implementation: the service layer wraps both operations in a single db.session.begin() block. The audit INSERT comes first. If it raises, the exception propagates and the mutation is never committed.


Consequences

Positive: - No grant can exist without an audit record. The audit trail is the gate, not an afterthought. - A bad actor who gains DB write access cannot apply a grant without leaving a record — unless they also have the ability to roll back the audit INSERT (which would require deleting from rbac_grants_audit, a table with DDL-level UPDATE/DELETE REVOKE). - Consistent with T-INS-5 countermeasure: "RBAC mutation events must be written to customer_audit_events before the mutation takes effect."

Negative: - If the audit table is unavailable (e.g., disk full, schema corruption), no grants can be applied. This is the intended fail-closed behavior — availability of the grant API is subordinate to audit integrity. - Slightly more complex transaction handling: the service must explicitly manage the transaction boundary rather than relying on the default ORM session flush.


Alternatives Considered

Post-write audit (fire and forget)

Write the grant first; log the audit row in a try/except that swallows errors.

Rejected: Any gap between the grant and the audit (exception between the two, or a fire-and-forget pattern that drops the log) means the grant happened silently. The system invariant "every state change affecting permissions writes an audit row" is violated.

Separate audit service (async)

Write the grant; emit an event to an async audit service that writes the row eventually.

Rejected: "Eventually" is not auditable in the presence of a bad actor who can kill the process or drain the queue between grant and log. Synchronous, same-transaction write is the only pattern that guarantees the invariant.


See docs/architecture/rbac-v2/design.md §9.2 for the privilege escalation guard design.