Skip to content

tech-debt(auth): "superadmin" is used at 17 require_role call sites but is not a member of the canonical Role enum #12786

Description

@mrveiss

Discovered while fixing #12717 (PR #12785). That PR fixes the four affected voice call sites; this tracks the structural cause, which is a bigger blast radius and deliberately out of its scope.

The gap

autobot_shared/auth/permissions.py:109 defines the canonical role enum:

class Role(str, Enum):
    ADMIN = "admin"
    OPERATOR = "operator"
    ANALYST = "analyst"
    EDITOR = "editor"
    USER = "user"
    READONLY = "readonly"

There is no SUPERADMIN. Yet require_role("admin", "superadmin") appears at 17 call sites across autobot-backend (mcp_token_admin, admin_schedulers, admin_event_logs, voice_bundle_user, admin_pricing, chat_shared_links, admin_retention_policies, voice_bundle_admin, …). It works only because require_role(*roles: Role | str) accepts raw strings and lowercases both sides.

Why this keeps producing bugs

The role is real at runtime but invisible to the type system and to ROLE_PERMISSIONS. That splits authorization into two populations that silently disagree:

  • every require_role(...) guard admits superadmin;
  • every hand-rolled role == "admin" check rejects it.

Wherever a router has both, a superadmin can perform the write but not the read. That has now produced two issues:

PR #12785 adds auth_rbac.ADMIN_ROLES / is_admin_role() so imperative checks have one correct answer, but that is a containment measure. As long as superadmin is a magic string, a new role == "admin" check reintroduces the bug and nothing catches it.

Also worth reconciling

autobot-backend/llc/kb/write_guard.py:23 defines a third notion:

_SUPERADMIN_ROLES: frozenset[str] = frozenset({"platform_admin", "superadmin"})

so platform_admin is superadmin-equivalent there but nowhere else. api/auth.py:286 and llc/api/companies.py:145 use yet another shape (is_platform_admin flag OR role == "admin").

Requested

  • Decide whether superadmin (and platform_admin) are first-class roles. If so, add them to the shared Role enum with explicit ROLE_PERMISSIONS entries.
  • Because that enum is imported by both backends (auth_rbac.py and the slm-backend equivalent), the change needs a cross-backend check that no permission set is silently widened or narrowed.
  • Consider a lint/test gate rejecting new role == "admin" string comparisons in favour of is_admin_role() / require_role(), so the class cannot come back.

Refs #12704, #12717, #12645

Activity

  1. mrveiss commented on Jul 28, 2026

    @mrveiss
    OwnerAuthor

    Triage note (no code change): confirmed this is owner-decision-gated, including the lint-gate bullet, so a future session does not re-derive it.

    The third bullet ("a lint/test gate rejecting new role == "admin" comparisons") looks decision-free but is not, because a gate that fails on existing code is unusable and the project prefers fixing over baselining. The current real sites are:

    autobot-backend/llc/api/companies.py:145   is_platform_admin flag OR role == "admin"
    autobot-backend/api/auth.py:286            current_user.get("admin") OR role == "admin"
    autobot-backend/api/ws_security.py:99      user.get("role") == "admin"
    autobot-backend/api/user_management/users.py:220
    autobot-backend/api/user_management/dependencies.py:190
    autobot-backend/services/mcp_dispatch.py:287
    autobot-slm-backend/services/jwks_verifier.py:154   normalized["admin"] = role == "admin"
    

    Converting these to is_admin_role() is the decision — each one changes who is authorized. jwks_verifier.py:154 is the clearest: it derives the admin bool that flows into the SLM's own checks, so widening it there widens authorization across that backend. That is precisely the "no permission set is silently widened or narrowed" cross-backend check this issue asks for, and it needs an owner call rather than an inference.

    The decision needed: are superadmin and platform_admin first-class members of the shared Role enum with explicit ROLE_PERMISSIONS entries, or does superadmin remain admin-equivalent-by-convention with auth_rbac.is_admin_role() (#12785) as the single containment point?

    Once that is settled, the site conversions and the lint gate are mechanical and can be done in one PR.

  2. mrveiss commented on Jul 29, 2026

    @mrveiss
    OwnerAuthor

    PR #12949 delivers the containment half of this (split to #12948). The enum decision stays here.

    Delivered: 17 hand-rolled admin-role comparisons converted to is_admin_role(), plus an AST guard that fails on any new one.

    Two findings worth recording:

    A regex sweep undercounts this class by >3×. It showed 5 offenders; the AST guard found 17. The shapes that actually occur are user_data["role"] == "admin", current_user.get("role") == "admin" and reversed operands — invisible to a regex keyed on variable names. Anyone re-auditing this should use the AST check, not grep.

    Two sites were not "grant superadmin too":

    services/workflow_rbac.py also held its own _ADMIN_ROLES = {"admin"} — a fifth forked notion after the six in #12704 — now on the shared set.

    One trap for whoever takes the enum work. ADMIN_ROLES/is_admin_role had to move out of auth_rbac into autobot_shared.auth.permissions: auth_rbac imports auth_middleware, so auth_middleware importing back closes a cycle. My first cut did exactly that and module-import smoke went from 14/14 to 6/14 — the backend would not have started, and no unit test caught it. Expect the same hazard when wiring a new Role member into both backends; auth_rbac re-exports the names so existing callers are unaffected.

    Still open here: adding superadmin (and platform_admin) to the shared Role enum with explicit ROLE_PERMISSIONS entries, plus the cross-backend check that no permission set is silently widened or narrowed. That is an owner decision — the enum drives permissions for both backends.

    Also noted and deliberately out of scope: autobot-slm-backend/services/jwks_verifier.py derives its admin claim from role == "admin". It cannot import this helper (separate backend) and changing token claims has its own blast radius, so the guard scopes to autobot-backend.

  3. 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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions