Skip to content

fix(rbac): fail closed when the anti-escalation guard cannot resolve a role - #765

Open
remyluslosius wants to merge 1 commit into
mainfrom
fix/custom-role-escalation
Open

fix(rbac): fail closed when the anti-escalation guard cannot resolve a role#765
remyluslosius wants to merge 1 commit into
mainfrom
fix/custom-role-escalation

Conversation

@remyluslosius

Copy link
Copy Markdown
Contributor

The defect

RoleGrantsWithin returned true (allow) for every role missing from BuiltInRoles. Its comment justified this: an unknown role confers no permissions, so it is trivially within the caller's grant.

That holds for a typo. It does not hold for a custom role, which exists, is not built in, and passes the downstream existence check. So the guard waved through every custom role on both paths that use it: role assignment and API-token minting. The one function whose entire job is to fail closed was failing open on the only case it could not evaluate.

It is inert today because custom-role permissions are never enforced (Identity.HasPermission reads BuiltInRoles only). That is what makes it a landmine rather than a live breach: wiring enforcement would have made every waved-through grant real, retroactively.

Correcting the backlog while fixing it

The backlog says the path opens "for anyone with role:write+role:assign". Only admin holds both, and admin already holds credential:write and remediation:execute, so that actor gains nothing. The reachable paths are narrower and different:

  1. Token minting needs only token:write, which security_admin holds. Once enforcement lands, a security_admin could mint a token bound to an existing custom role granting more than they hold.
  2. An admin who delegates role management via a custom role hands the holder a route to grant themselves anything.

Both are real. Neither is the one described.

Both halves, because either alone leaves a two-step escalation

  • The guard now denies an unresolvable role. RoleGrantsWithinResolved takes a RoleResolver so a legitimate custom role is evaluated on its actual permission set from the roles table rather than blanket-refused. A nil resolver, a missing row, or a query error all deny. Fail-closed on a DB fault is deliberate: failing open there is the same defect by another route.
  • CreateCustomRole now rejects authoring a role granting a permission the creator lacks (403, naming them). Guarding only the assign step still lets a caller author an over-broad role and wait for someone else to assign it.

Negative-tested

Reintroducing the original return true fails AC-19:

guard must DENY an unresolvable role; returning true here is the escalation

SDD

system-rbac 1.1.0 -> 1.2.0, AC-19 (fail-closed guard) and AC-20 (authoring subset check). 116 specs, 116 passing. AC ids checked for collision this time, after the AC-13 duplicate in #763.

Not in scope

Wiring custom-role enforcement into Identity.HasPermission. That is a feature, and it is now safe to build.

…a role

RoleGrantsWithin returned true, meaning allow, for every role missing from
BuiltInRoles. Its comment justified this: an unknown role confers no
permissions, so it is trivially within the caller's grant, and a downstream
existence check rejects it anyway.

That holds for a typo. It does not hold for a custom role, which exists, is
not built in, and passes the downstream check. So the guard waved through
every custom role on both paths that use it: role assignment
(users_handlers.go) and API-token minting (tokens_handlers.go). The one
function whose entire job is to fail closed was failing open on the only case
it could not evaluate.

It is inert today because custom-role permissions are never enforced:
Identity.HasPermission and Permissions both read BuiltInRoles only, so a custom
role currently grants nothing. That is what makes it a landmine rather than a
live breach. Wiring custom-role enforcement without fixing this first would
make every previously waved-through grant real, retroactively.

Correcting the backlog's characterisation of the exploit while fixing it: the
entry says the path opens "for anyone with role:write+role:assign". Only admin
holds both, and admin already holds credential:write and remediation:execute,
so that particular actor gains nothing. The reachable paths are narrower and
different. First, token minting needs only token:write, which security_admin
holds; once enforcement lands, a security_admin could mint a token bound to an
existing custom role granting more than they hold. Second, an admin who
delegates role management through a custom role hands the holder a route to
grant themselves anything. Both are real; neither is the one described.

The fix is both halves, because either alone leaves a two-step escalation:

  - The guard now DENIES an unresolvable role. RoleGrantsWithinResolved takes
    a RoleResolver so a real custom role is evaluated on its actual permission
    set from the roles table rather than blanket-refused; a nil resolver, a
    missing row, or a query error all deny. Fail-closed on a database problem
    is deliberate: failing open there would be the same defect by another
    route.
  - CreateCustomRole now rejects authoring a role that grants a permission the
    creator lacks (ErrRoleExceedsGrant, 403, naming the offending permissions).
    Guarding only the assign step still lets a caller author an over-broad role
    and wait for someone else to assign it.

users.RolePermissions backs the resolver and reports a query error as "unknown"
rather than surfacing it, so the guard cannot be pushed open by a DB fault.

Negative-tested: reintroducing the original `return true` fails AC-19 with
"guard must DENY an unresolvable role; returning true here is the escalation".

Spec: system-rbac 1.1.0 -> 1.2.0, AC-19 (fail-closed guard) and AC-20
(authoring subset check). 116 specs, 116 passing. AC ids checked for collision
this time.

Not in scope: wiring custom-role permission enforcement into
Identity.HasPermission. That is a feature, and it is now safe to build.
@remyluslosius
remyluslosius force-pushed the fix/custom-role-escalation branch from 9108b44 to e7c547d Compare July 28, 2026 12:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant