Skip to content

tech-debt(auth): three 'role' vocabularies share the literals 'user' and 'admin' with no type-level separation #14024

Description

@mrveiss

Three unrelated vocabularies use the word "role" and share string values, with nothing at the type level keeping them apart. This already produced a near-miss during #13934.

The three

vocabulary defined in values
platform RBAC autobot_shared/auth/permissions.py::Role admin, operator, analyst, editor, user, readonly
company membership autobot-backend/llc/models/enums.py::MembershipRole owner, admin, lead, member, guest
chat message role autobot_shared/ssot_constants.py::CategoryDefaults.ROLE_* user, assistant, system

Member-set comparison (names differ, so a same-name search finds nothing):

auth.Role      : admin analyst editor operator readonly user
MembershipRole : admin guest lead member owner
shared         : admin
only auth.Role : analyst editor operator readonly user
only Membership: guest lead member owner

These are correctly separate concepts — this is not a request to merge them. The problem is that "user" and "admin" appear in more than one, so a value from the wrong vocabulary type-checks, passes tests, and reads correctly at a glance.

The near-miss

tools/tool_registry.py:87 had "user_role": "user" — an RBAC input, read by worker_node._validate_user_role and _check_task_permission.

The hardcoded-value guard reported it and suggested CategoryDefaults.ROLE_USER, i.e. the chat vocabulary. Both hold "user" today, so applying the suggestion would have tied an authorization decision to a presentation constant, gone green, and been invisible in review. It was caught only because the field name was traced to its consumer before the fix was applied (#13934).

Any future rename or re-tuning of the chat vocabulary would then have silently changed an authz value.

What would help

Not a merge — a distinction that survives a copy-paste:

  1. Have the guard's suggestion be vocabulary-aware, or drop the suggestion for values that exist in more than one vocabulary and say so instead. (Relates to bug(ci): hardcoded-value guard misses literals inside comprehensions — the same value passes or fails by syntactic shape #14005 — the guard's advice is already unsafe to apply blind.)
  2. Make RBAC role values a distinct type at the boundary that consumes them, so a chat-role string cannot satisfy user_role — MembershipRole and auth.Role are already str, Enum; CategoryDefaults is a bare string constant class, which is the one that mixes freely.
  3. At minimum, a docstring on each naming the other two and the values they share, so the next reader is warned at the point of use.

Acceptance criteria

  • A test asserts the overlapping values are known and deliberate — it fails when a new value is added to one vocabulary that collides with another, rather than when someone happens to notice.
  • The guard no longer suggests a chat constant for a field consumed as RBAC (or suggests nothing and explains why).
  • No behaviour change: all three vocabularies keep their current values.

Related

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions