Skip to content

security(rbac): superadmin is administrative but is not a Role member and holds no permissions #13854

Description

@mrveiss

Found while implementing #13820, and deliberately not fixed there.

Problem

superadmin is administrative — ADMIN_ROLES = frozenset({"admin", "superadmin"}), is_admin_role()
returns True for it, and 17+ endpoints gate on require_role("admin", "superadmin"). But it is not
a Role enum member
, so Role("superadmin") raises and it holds no entry in
ROLE_PERMISSIONS
.

The module already names this:

# Making superadmin a first-class Role member is the deeper fix and is still
# open: this enum drives ROLE_PERMISSIONS for BOTH backends.
ADMIN_ROLES: frozenset = frozenset({"admin", "superadmin"})

Why it is filed rather than fixed

The first draft of #13820 mapped superadmin onto admin's permission set via ADMIN_ROLES. Review
measured the effect: +54 permissions, including admin.system, security.manage,
admin.users.write and allow_shell_execute — none of which it previously held through any path.

That reads as a bug fix and is an authorisation expansion. #13820 decided which resolver is
authoritative; it did not decide to widen a role. Those are different changes and the second should
not ride along inside the first.

Two further reasons the ADMIN_ROLES route is wrong even if the expansion is wanted:

  1. ADMIN_ROLES is documented as the answer to "is this role administrative?" — a predicate for
    require_role, not a permission source. Coupling them means any future addition to that
    frozenset silently becomes full admin in the live gate
    , shell execution included, with no edit
    to ROLE_PERMISSIONS and no review of the security layer.
  2. It would grant permissions without a corresponding entry anywhere a reader would look for one.

The consequence today

superadmin resolves to [] in the canonical mapping, so it is denied every mcp.* grant and
everything else ROLE_PERMISSIONS assigns. Whether that is correct depends on what superadmin is
meant to be — which is the decision.

Note it also caused a live misreport in #13228 stage 2, where the shadow logged the most privileged
role in the system as denied on every tool. That was worked around at the consumer with
is_admin_role; the root cause is here.

Decision needed

  1. Make superadmin a first-class Role with its own ROLE_PERMISSIONS entry — the fix the
    module itself points at. Requires deciding whether it equals admin or exceeds it.
  2. Retire superadmin, mapping existing users to admin, and remove it from ADMIN_ROLES.
  3. Leave it as a predicate-only role that holds no granular permissions, and document that
    require_role is the only gate it passes.

Acceptance criteria

Related

#13820 (which found it), #13228 (where it misreported), #12717/#12786 (where ADMIN_ROLES moved).

Activity

  1. mrveiss commented on Aug 24, 2026

    @mrveiss
    OwnerAuthor

    Delivered and merged as 774527f5b (PR #14936).

    • CI: all 10 required contexts present and green — checked by name, not by tally. 58 success / 3 skipped / 0 failures / 0 pending, deduped to the latest run per name.
    • Review: independent security-focused code-reviewer pass. Every claim verified against source; it could not falsify any.

    superadmin did not resolve two ways — it resolved four. role_has_permission short-circuited on is_admin_role, which made ADMIN_ROLES — documented as a predicate — silently the most permissive permission source in the system, while SecurityLayer.check_permission denied it those same permissions. Role("superadmin") raised outright, and require_role admitted it by raw string. Four answers for one input.

    The decision: Role.SUPERADMIN with an explicitly EMPTY permission entry. Verified in base:

    Role.SUPERADMIN: [],

    The alternative was +54 permissions including allow_shell_execute, and it is recorded in the source as a one-line change flagged as needing its own security review and a data migration — which an empty entry does not. That is the security carve-out to the union doctrine: union the checks, never the allowances.

    Review confirmed nothing grants it implicitly — _build_system_roles() yields a safe permissions: [] seed row, auth_rbac._get_user_permissions resolves the member but still gets [], SecurityLayer._get_default_role_permissions has no superadmin key, and RoleUpdateRequest's hand-written regex keeps it non-assignable via that API. The 17 require_role call sites are unchanged and still admit it.

    The one behaviour change is a tightening, stated rather than buried. Removing the short-circuits refuses superadmin at two granular-permission gates. admin is unaffected and that is measured: Permission has exactly 54 members and ROLE_PERMISSIONS[Role.ADMIN] lists exactly those 54, now asserted continuously. A second tightening ships with zero lines changed in its file — SLM's _resolve_role previously downgraded an unknown superadmin to Role.USER, i.e. granted it USER's 15 permissions.

    Two deliberate forks were found and deliberately NOT collapsed. #14024's own body says its three vocabularies are correctly separate concepts, and their member sets confirm three different questions. And write_guard's {platform_admin, superadmin} excludes admin on purpose — the guard exists to stop an org admin reaching a parent org's KB, so folding it into ADMIN_ROLES would have added admin and destroyed the property. A drift guard now compares member sets and pins the exact known overlaps.

    A miss worth recording. Three tests asserting the old contract survived the first sweep, because that sweep grepped Role/ROLE_PERMISSIONS symbols while those files name superadmin as a bare string through _would_deny/_role_satisfies — searching the symbol rather than the behaviour, which is core rule 7 and the exact trap #14024 exists over. Re-swept on the concept: 21 files, 2 genuinely broken, 16 exercising unchanged admission paths, 3 incidental. The shadow test's intent was preserved rather than flipped — its purpose was that an administrative role must never be reported as an unrecognised string, so that is now asserted directly instead of inferred from is None.

    All 12 python-suite shards pass, including shard 4 — which resolves the open caveat that the other eleven had been cancelled rather than passing on the prior head, so more failures of that class could have been hiding. There were none.

    Residuals filed and cross-linked: #14937, #14938, #14939, #14940, #14944.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions