Skip to content

security(rbac): two permission resolvers disagree and the live one never reads ROLE_PERMISSIONS #13820

Description

@mrveiss

Found while reviewing #13228 stage 2 (PR #13818).

Problem

There are two permission resolvers, they disagree, and only one has production callers.

Resolver Source of truth Production callers
auth_rbac._get_user_permissions ROLE_PERMISSIONS (autobot_shared/auth/permissions.py) none — only tests
SecurityLayer.check_permission security config self.roles + _get_default_role_permissions, wildcard-matched the live path, via auth_rbac.require_permission → has_permission

So ROLE_PERMISSIONS — the dict both backends import, and the one every Permission member is
assigned in — is not what the running system consults. require_permission resolves through
SecurityLayer, which never reads it.

Why it matters now

#13228 stage 1 added ten MCP_* members and assigned them in ROLE_PERMISSIONS. None of them
exists in security config or in _get_default_role_permissions, so
has_permission(user, "mcp.browser.read") is False for every role, including admin. A stage-3
flip that enforces through the live resolver would deny all MCP access; one that enforces through
ROLE_PERMISSIONS would work but would be the only thing in the system doing so.

Two further divergences in the same pair:

Decision needed

Before #13228 stage 3 can flip anything:

  1. Enforce via ROLE_PERMISSIONS — then SecurityLayer becomes the second-class resolver and
    _get_user_permissions needs wiring in, or
  2. Enforce via SecurityLayer — then the mcp.* grants must be added to security config /
    defaults, and ROLE_PERMISSIONS remains decorative for MCP, or
  3. Converge them — one resolver reading one source.

This is a product/architecture call, not a mechanical fix, which is why it is filed rather than
patched.

Acceptance criteria

Blocks: #13228 (stage 3)

Activity

  1. mrveiss commented on Aug 9, 2026

    @mrveiss
    OwnerAuthor

    Decision (owner, 2026-08-09): ROLE_PERMISSIONS is authoritative

    auth_rbac._get_user_permissions gets wired into the live path. ROLE_PERMISSIONS is the dict both
    backends already import and where every Permission member is assigned, so this is the resolver the
    codebase was written around — SecurityLayer became the live one by accident, not by design.

    What this commits us to

    Acceptance criteria

    • The live require_permission path resolves through ROLE_PERMISSIONS
    • Wildcard matching preserved, with a test naming a files.*-style grant
    • The enable_auth=false bypass is either preserved deliberately or removed deliberately —
      recorded either way, never lost in the move
    • superadmin and mixed-case roles resolve identically through the new path
    • A test asserting both former paths agree for every role across the full Permission enum
    • has_permission(admin, "mcp.browser.read") returns True

    Blocks: #13228 stage 3.

  2. removed
    needs-human-decisionBlocked on a product/architecture decision a human must make
    on Aug 9, 2026
  3. mrveiss commented on Aug 9, 2026

    @mrveiss
    OwnerAuthor

    Closed by PR #13853 — squash-merged to Dev_new_gui as d75a618b1.

    $ git show origin/Dev_new_gui:autobot-backend/security_layer.py | grep -c canonical_role_permissions
    2
    $ pytest security/canonical_permissions_test.py --collect-only -q
    40 tests collected
    

    What the decision produced

    ROLE_PERMISSIONS is now read by the live gate. The permissions #13228 declared resolve for real:

    before:  admin  mcp.browser.read = False     (every role, every mcp.* grant)
    after:   admin  mcp.browser.read = True   mcp.browser.control = True
             user   mcp.browser.read = True   mcp.browser.control = False
             readonly                False                          False
    

    #13228 stage 3 is unblocked.

    Added as a third source rather than replacing the others: SecurityLayer's configured grants, wildcard
    matching, the allow_shell_execute special case and the enable_auth bypass are live semantics
    something depends on, and removing them silently would trade one invisible gap for another — which is
    what this issue was. The enable_auth=false bypass is preserved deliberately, with a test saying
    so; changing it is a separate repo-wide decision.

    The finding underneath

    auth_rbac._get_user_permissions already unioned ROLE_PERMISSIONS with SecurityLayer's
    defaults, correctly, and had no production callers. This was never a missing implementation. It
    was a correct function nobody reached, while the live path called check_permission and skipped it.

    Two HIGH defects review caught in my first attempt

    I opened the permission gate without opening the approval gate. Normalising the role inside
    canonical_role_permissions alone created a second identity for the same role: it cleared shell
    execution via the canonical set, then reached _should_force_approval — which keys on the exact
    string — with an empty permission list, so no approval branch could fire.

    role='admin'      exec_allowed=True   force_approval=True     # gated
    role='Admin'      exec_allowed=True   force_approval=False    # NOT gated
    role='superadmin' exec_allowed=True   force_approval=False    # NOT gated
    

    A HIGH-risk command running unapproved. Normalisation now happens once, and both gates share it.

    The superadmin expansion was withdrawn. Mapping it onto admin's set via ADMIN_ROLES granted
    +54 permissions including admin.system, security.manage and allow_shell_execute — none of
    which it previously held. That reads as a fix and is an authorisation expansion; this issue decided
    which resolver is authoritative, not that a role should be widened. Filed as #13854, which also
    carries the sharper point: ADMIN_ROLES is a require_role predicate, and coupling it to
    permissions means any future addition to that frozenset silently becomes full admin.

    A pre-existing bypass fixed on the way

    Chasing the above turned up the same shape already in the code: check_permission downgrades
    god/root/superuser to admin via _handle_deprecated_role; _should_force_approval never did.
    Those aliases passed the exec gate through the downgrade and skipped approval entirely.

    base (before any change):  god exec_allowed=True  force_approval=False
    now:                       god exec_allowed=True  force_approval=True
    

    Verified against the unmodified base first, so it was clear this was pre-existing rather than
    self-inflicted.

    Also: the canonical set is now resolved from the role as given, before the alias rebinding.
    Reading it after made the documented "downgrade to admin with granular permissions" a near-no-op,
    handing those aliases all 54 admin permissions in place of their 8 defaults.

    Verification

    canonical_permissions_test + auth_rbac_test + security_layer_test + admin_guard   106 passed
    
    Mutation Killed by
    normalise only in the canonical resolver 3 tests
    approval gate stops normalising 6 tests
    resolve canonical after the alias rebinding 3 tests
    reinstate the superadmin expansion 3 tests

    One mutation survived the first pass and is worth recording: removing the shared normalisation
    left all 37 tests green, because my case-insensitivity tests asserted a canonical permission and
    that resolver normalises internally. The added test asserts allow_voice_speak — a permission that
    exists only in the defaults — so it can only pass if all three sources agree on who a role is.

    Follow-up

    #13854 — superadmin is administrative, is not a Role member, and holds no permissions.

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