RBAC Pattern
How to model 'who can do what' so the checks survive multi-agent edits.
RBAC Pattern
How to model "who can do what" so the checks survive multi-agent edits.
TL;DR (human)
Three nouns: principal, role, capability. Principals (users / agents / service accounts) have roles; roles grant capabilities; capabilities authorize actions. The handler entry-point checks capabilities, never roles directly. Persistent store; audited mutations.
For agents
Why not role-based-everywhere
Putting role checks inline at every handler is the failure mode:
// ✗ wrong
if (ctx.principal.role !== "admin" && ctx.principal.role !== "owner") throw new AuthError(...)Problems:
- Role names drift; every handler hand-maintains its own role allowlist.
- New roles (e.g.
auditor) require touching every handler. - A typo in a role name silently grants access.
Capability-based check delegates the decision:
// ✓ right
if (!ctx.principal.can("users:write")) throw new AuthError("AUTH_FORBIDDEN");can() consults the role → capability mapping, which lives in one place.
Model
Three primary entities:
- Principal — a user, an agent, a service account. Identified by stable id. Has a list of role assignments.
- Role — a named bundle. Has a list of capabilities. Roles are not hierarchical by default (no inheritance) — explicit is safer than implicit.
- Capability — a fine-grained verb on a resource.
users:read,users:write,users:invite,secrets:read,flows:run. Format:\<resource\>:\<verb\>.
Optional fourth:
- Scope — a binding of the role assignment to a tenant / workspace / project. So a principal can be "admin in workspace A" + "member in workspace B".
Storage
Persistent, audited:
roles(id, name, description)
role_capabilities(role_id, capability)
principal_role_assignments(principal_id, role_id, scope_id, expires_at)
capability_definitions(capability, description, sensitivity)Mutations to any of these go through rbac.* contracts, audit-logged.
Check at the boundary
The dispatcher / handler entry point performs the check. Not inside business logic.
async function dispatch(method, params, ctx) {
const entry = REGISTRY[method];
if (entry.requireCapability && !ctx.principal.can(entry.requireCapability)) {
throw new AuthError("AUTH_FORBIDDEN", undefined, {
hint: `Requires capability '${entry.requireCapability}'`,
});
}
// ... continue
}Why at the boundary: business logic gets dozens of agent-authored edits; capability checks belong somewhere stable.
Role + capability lifecycle
| Operation | Contract | Auditable |
|---|---|---|
| Define a new capability | rbac.capability.upsert | ✓ |
| Define a new role | rbac.role.upsert | ✓ |
| Add a capability to a role | rbac.role.grant | ✓ |
| Remove a capability from a role | rbac.role.revoke | ✓ |
| Assign a role to a principal | roles.assign | ✓ |
| Revoke a role from a principal | roles.revoke | ✓ |
| List active assignments | roles.list | (read; logged if sensitive) |
Mutations go through the audit ledger before they execute (see audit-ledger-pattern.md).
Capability naming
\<resource\>:\<verb\> keeps the namespace clean.
users:read,users:write,users:delete.flows:create,flows:edit,flows:run,flows:delete.secrets:read,secrets:write.audit:read,audit:export.
Avoid:
- Mega-capabilities like
admin(too broad; mapped to "all" by accident). - Verb-only capabilities like
read(no resource scope).
Sensitive capabilities
Some capabilities require additional protection:
- Step-up auth: re-prompt for password / 2FA before granting.
- Time-boxed elevation: granted only via break-glass (see Rule 7 in
universal.md). - Consent-gated: require subject consent in addition to caller capability (see Rule 7).
The capability definition itself carries metadata flagging it sensitive; the dispatcher / handler honors the metadata.
Bootstrap problem
First principal of a fresh install: who creates it? Pattern:
- Install-time seed creates an
ownerrole + assigns it to the first user that completes onboarding. - The seed is one-shot — once any principal has the
ownerrole, the seed is inert. - The bootstrap action is itself audit-logged.
Common failure modes
- Inline role checks. Drift; typos. → Capability check at boundary.
- No scope. "admin" globally instead of "admin in workspace X". → Scope every assignment.
- No expiry. Permanent role grants accumulate. →
expires_aton every assignment; long-lived defaults to "until revoked" but visible inroles.list. - Self-grant. Principal grants themselves a role. →
rbac.role.grantrequiresrbac:managecapability the principal does not have on themselves. - Role-grant audit log omits scope. Cannot tell where the grant applied. → Audit entry includes principal, role, scope, granter, time.
See also
universal.md— Rule 7 (consent vs elevation), Rule 5 (audit before).audit-ledger-pattern.mdvault-pattern.md— capability check gates vault reads.