Skip to content

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

Merged
remyluslosius merged 1 commit into
mainfrom
fix/custom-role-escalation
Jul 29, 2026
Merged

fix(rbac): fail closed when the anti-escalation guard cannot resolve a role#765
remyluslosius merged 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
@remyluslosius
remyluslosius merged commit fab700c into main Jul 29, 2026
13 checks passed
remyluslosius added a commit that referenced this pull request Jul 30, 2026
…#767)

* ci: local test database so DB-gated suites run before push, not in CI

The DB-gated suites do not run without OPENWATCH_TEST_DSN, so `make ci-local`
silently skipped them and a change could look clean locally and fail in CI.
That happened three times in one day on the v0.7 branches: the report-schedule
suites (#766), the RBAC role-assign and token tests (#765), and the go mod tidy
drift (#762). Every one was a real defect that a local run would have caught.

scripts/test-db.sh mirrors the CI service container exactly. Image and
credentials match .github/workflows/go-ci.yml, because if they drift then
"passes locally" stops meaning "passes in CI", which is the whole point.

Two settings are load-bearing and are commented as such in the script:

  max_connections=400. The default 100 is not enough. The suite runs packages
  in parallel and internal/db/dbtest clones a template database per package, so
  a default container deadlocks: the first DB test times out at 120s and the
  rest cascade. Found this the honest way, by hitting it. It looks exactly like
  a real failure and is not one, which is worse than no local database at all.

  fsync, synchronous_commit and full_page_writes off. The database is
  disposable; durability buys nothing and costs most of the runtime.

Port defaults to 5455 rather than 5432: on this workstation 5432 is the dev
database and 5433 is hanalyx-postgres. Override with TEST_DB_PORT. The database
name ends in _test so the internal/db guard accepts it without the
ALLOW_NONTEST bypass, which we never want set.

`make ci-local` now warns when OPENWATCH_TEST_DSN is unset and says exactly how
to fix it. A silent skip is what let this through repeatedly; a loud skip is
the minimum.

Verified: full `go test ./internal/...` against the container is clean, and the
three suites CI previously caught now run and pass locally.

* ci: un-ignore scripts/test-db.sh so the Makefile target has a script

The blanket `*test*.sh` in .gitignore swallowed scripts/test-db.sh. `git add`
refused it, the ci-local change shipped referencing a script that was never
committed, and nothing failed loudly: the PR just contained a Makefile target
pointing at a file no one else would have.

Same trap as the blanket `*.spec` rule that drops new packaging spec files.
Negate deliberately and verify with `git check-ignore -v <path>` after adding
anything to scripts/ whose name contains "test".
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