Skip to content

feat(auth): one permission vocabulary, key scopes as bundles, /me permissions, and API-key scope enforcement (#16270, #16040) - #16298

Merged
mrveiss merged 15 commits into
mainfrom
issue-16270-permission-scopes
Sep 13, 2026
Merged

mrveiss merged 15 commits into
mainfrom
issue-16270-permission-scopes

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 11, 2026 •

Copy link
Copy Markdown
Owner

Closes #16270
Refs #16040. This PR delivers AC2, AC5 and AC7, AC4's mechanism (require_key_permission) and AC6's test of the decision. AC4's routes that declare a scope, and AC6's route-level test, land with #16294, because no route accepts keys until then.

Thinking Path

What Changed

Commit Change
c496187 Refactor: the seeding builders move to permission_seed.py, with no behaviour change. The ratchet entry is deleted, since the file is under MAX_LINES.
816ae02 Seven new Permission members and their grants. key_scopes.py holds all 14 scope bundles.
6d8a801 GET /me returns permissions (as role_has_permission answers them) and is_admin, from api/auth_me.py.
c94b9f9 One scope matcher, shared by has_scope and key_permissions.
e0b1dc6 #16040: owner role ∩ key scopes, require_key_permission, the grace period for pre-enforcement keys, and the reason /api-keys/scopes stays open.

Grant record (#16270 AC4):

permission granted to kind evidence
chat.use, chat.history admin, operator, analyst, editor, user, readonly behaviour-preserving 10 of the 11 backend chat route files are login-only. The one admin-only route in chat_shared_links.py manages share links.
teams.read / create / manage / delete admin policy choice the #16276 ruling
webhooks.trigger admin policy choice It gates nothing today; inbound webhooks authenticate with provider secrets.

Superadmin holds none of these, by design (#13854). /me reports is_admin so the frontend still admits a superadmin.

#16040's acceptance criteria:

  • AC2: permission_allowed grants a permission only if the owner's role holds it AND key_permissions(scopes) carries it.
  • AC4: require_key_permission(permission) answers 403 when a key lacks the scope.
  • AC5: A key created before AUTOBOT_API_KEY_SCOPES_ENFORCED_FROM (default 2026-09-11) keeps its owner's authority, with a warning on every use, until AUTOBOT_API_KEY_LEGACY_GRACE_DAYS (90) have passed. After that it gets 401 until it is re-issued.
  • AC6: api_key_authority_test.py shows a narrow-scope key being refused an out-of-scope permission in the full decision.
  • AC7: GET /api-keys/scopes stays open. Its docstring gives the reason: it returns a static catalogue of scopes, not keys, users or tenant data.

Verification

Model Used

Claude Opus 5

…ed.py (#16270)

permissions.py was frozen at 631 lines and #16270 adds Permission members and grants. The SYSTEM_PERMISSIONS/SYSTEM_ROLES builders and the legacy secrets rows move to autobot_shared/auth/permission_seed.py as pure functions that take the enum, ROLE_PERMISSIONS and _ROLE_META as arguments, so the new module imports nothing from permissions (no cycle, and no bcrypt/JWT for lazy_import_test.py). permissions.py still computes and exports both values once, so every existing import and the SLM parity identity check are untouched. _ROLE_META stays, since roles_are_canonical_test.py imports it.

No behaviour change: the builders are moved line for line, except that the role-to-secrets-extras branch is a lookup by role value, and isinstance(p, Permission) is getattr(p, "value", p). The file is now under MAX_LINES, so its ratchet entry is deleted from both files. Migration 062 comments still name the old helper locations; applied migrations are not edited.
…y API-key scope as a permission bundle (#16270)

Permission gains chat.use, chat.history, teams.read/create/manage/delete and webhooks.trigger: concepts the key scopes and the frontend already named with no counterpart, given dedicated members per the owner ruling rather than migration 062 fold into api.* and admin.users.*. Admin holds all seven (roles_are_canonical_test); superadmin none (#13854).

Grants are recorded in code and on the issue. chat.use/chat.history are behaviour-preserving: every backend chat route is login-only today, so every role that can log in keeps them. teams.* and webhooks.trigger are a policy choice, admin only: teams per the #16276 ruling, webhooks.trigger because it gates nothing yet.

autobot_shared/auth/key_scopes.py holds all 14 scope bundles in one place, per the rulings (settings to admin.config, users to admin.users, admin:* to every member), and key_permissions() resolves a key scope list by the same exact/wildcard/global rules as APIKey.has_scope, failing closed on a malformed value. key_scopes_test.py asserts the bundles cover API_KEY_SCOPES exactly, none is empty, admin:* equals the admin role, and key_permissions agrees with has_scope on every published scope.
…16270)

The frontend keeps its own role-to-permission map (#16243) because /me never said what a caller may do. /me now returns permissions, every Permission value role_has_permission grants the caller role (the function the gates consult, not a second copy of the rule), and is_admin from is_admin_role. Both are needed: superadmin holds no granular permissions by design (#13854), so a frontend gating on permissions alone would hide everything from a superadmin.

api/auth.py is frozen at 770 lines, so /me moves to api/auth_me.py (the password_change.py precedent, #15743), included by auth.py so the path is unchanged; AuthMeResponse extends AuthUserInfoResponse with the two fields. The role default uses Role.USER.value rather than the literal the hardcoded-values hook flags. auth.py drops to 734 and both ceilings follow. auth_me_test.py covers superadmin, admin, three non-admin roles, an unknown role, the identity fields and the 401. The generated api.ts is regenerated by the auto-fix-generated-types workflow.
…_scope and key_permissions (#16270, #16040)

key_permissions re-implemented the exact/wildcard/global rule APIKey.has_scope already had, and repo_tests/api_key_scopes_are_enforced_16040_test.py exists to stop a second copy of that rule drifting in the direction of more privilege. The rule now lives once, as key_scopes.scope_granted (with has_scope fail-closed rationale moved alongside it); has_scope delegates to it and key_permissions is the union of the bundles it allows. Behaviour is unchanged: the same three checks, the same fail-closed guard on a non-list scopes value.
… the key scope bundle (#16040)

AC2: a key caller now carries its owner role (role_for_user, the same rule a session gets) and its scopes, and permission_allowed requires both halves: the role must grant the permission and key_permissions(scopes) must carry it, so a key never exceeds its owner nor its own scopes. The whole decision moves from services/auth.py to the FastAPI-free services/api_key_authority.py (resolve_role with it), real-loaded by the SLM conftest so its co-located test exercises the actual rule; _require_permission_or_403 only turns False into 403.

AC4: require_key_permission(permission) is require_permission for a key-authenticated route; a key lacking the scope gets 403. No production route uses it yet: which routes accept keys is #16294. AC5 (owner ruling): a key created before AUTOBOT_API_KEY_SCOPES_ENFORCED_FROM keeps its owner full authority with a warning on every use until AUTOBOT_API_KEY_LEGACY_GRACE_DAYS (90) have passed, then is refused with 401 until re-issued. AC6: api_key_authority_test.py refuses a narrow-scope key an out-of-scope permission in the full decision. AC7: GET /api-keys/scopes stays open, and its docstring says why.
@coderabbitai

coderabbitai Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 6ac49a58-d5bc-407d-aeb3-a767570cd5dc


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mrveiss

mrveiss commented Sep 11, 2026

Copy link
Copy Markdown
Owner Author

Security review at e0b1dc6a4a: approved. It merges on a settled green at that SHA. One closure ask: #16040 can't close with this PR, so please change Closes #16270, #16040 to Closes #16270 plus Refs #16040.

Commit Check Evidence
c49618761 move Behaviour-preserving permission_seed.py carries the secrets-vault rows unchanged. Base gave admin extras to Role.ADMIN, user extras to Role.USER and [] to everything else. _SECRETS_EXTRA_BY_ROLE.get(role.value, []) with keys "admin" and "user" is the same mapping. getattr(p, "value", p) equals p.value if isinstance(p, Permission) else p for Permission and str items. The builders take their sources as arguments, so there's no import cycle.
816ae0284 vocabulary (#16270) AC1–AC4 Seven members: chat.use, chat.history, teams.read/create/manage/delete, webhooks.trigger. test_team_and_webhook_permissions_are_admin_only, test_superadmin_gains_none_of_them and test_every_role_that_can_log_in_keeps_chat. key_scopes.py holds 14 bundles, test_no_scope_maps_to_an_empty_bundle, and admin:* equals exactly what Role.ADMIN holds. The body records each grant as behaviour-preserving or a policy choice, and the four decisions are recorded on the issue (06:26Z). roles_are_canonical_test is untouched.
6d8a8014c /me (#16270 AC5) permissions and is_admin test_a_superadmin_gets_no_granular_permissions_and_is_admin, test_an_admin_gets_every_permission, test_an_unknown_role_gets_nothing and test_no_session_is_a_401. auth.py 770 → 734, matching the file.
c94b9f99b one matcher scope_granted APIKey.has_scope delegates to it, and key_permissions is built on it. It fails closed on a non-list scopes, because a string would substring-match "*" and grant everything. That's tested for "*", "admin:*", None, a dict and 7, and test_a_resource_wildcard_does_not_leak_into_other_resources.
e0b1dc6a4 #16040 AC1–AC3, AC5, AC7 get_api_key_user returns the owner's role (same rule as sessions), scopes, api_key_id, legacy_full_scope, and "admin" only when the owner is a platform admin and the key holds admin:*. permission_allowed requires the role to grant and, for a key caller, the scopes to carry it (test_a_key_never_exceeds_its_owners_role, test_an_admin_scope_on_a_non_admin_owners_key_grants_no_admin_permission). The AC5 cutoff is a fixed "2026-09-11" (env-overridable, not computed at runtime), and a key with no created_at is held to its scopes (fails closed). After grace, _legacy_grace_or_401 refuses the key; within it, every use is logged. legacy_full_scope counts only as a literal True.

Why #16040 stays open. Its AC4 is "routes declare the scope they require, and a key lacking it gets 403". require_key_permission exists, but, as the body says, no route accepts a key yet (#16294). AC6 asks for a route-level test, and the refusal is tested at the decision level only, because SLM tests can't import services/auth.py in isolation, as stated. Both land with #16294.

Note, non-blocking but worth doing before #16294 wires any route: permission_allowed applies the key half only when "api_key_id" in caller. The real payload sets it today, but no test pins that get_api_key_user's return carries it (every test builds its own dict). A refactor that dropped the key would give every key its owner's full role, with no test failing. An AST pin on the returned dict's keys, the way #16292 pinned main.py, would close that.

github-actions Bot and others added 2 commits September 11, 2026 08:58
Auto-regenerated (py3.14) to match the backend and/or SLM backend
schema so the required verify-generated-types gate(s) pass.
Triggered by auto-fix-generated-types.yml.
@mrveiss

mrveiss commented Sep 11, 2026

Copy link
Copy Markdown
Owner Author

CI red at 083f9e8344, caused by this PR, so it is out of the merge train. There are two reds.

1. SSOT Configuration Compliance (detect-hardcoded-values.sh --audit-baseline)

STALE  other|autobot-backend/api/auth.py|"user"

This PR moves code out of api/auth.py into api/auth_me.py, so the baseline line no longer matches anything. To fix:

  1. Run ./pipeline-scripts/detect-hardcoded-values.sh --prune-baseline.
  2. Commit the pruned baseline.
  3. Confirm that the moved "user" literal does not now fire in api/auth_me.py. The no-growth check blocks that direction independently.

2. CodeQL: 1 new high alert, "Clear-text logging of sensitive information"

  • Where: autobot-infrastructure/shared/scripts/migrations/migrate_config_users.py:150, i.e. logger.debug("Created permission: %s", perm_data["name"]).
  • That file is not in this diff. I have not traced which changed file its source (1) points at; the alert's source link names it.

Once both are green, this PR merges on its own after the train.

…h it (#16270)

The audit-baseline step failed on #16298: the entry 2|other|autobot-backend/api/auth.py|"user" no longer matches anything, because the role default it covered moved to api/auth_me.py (as Role.USER.value, not a literal). The baseline only ever shrinks, so the fixed entry is removed by hand -- the same change --prune-baseline makes.
…eQL alert 1122 (#16270)

CodeQL py/clear-text-logging-sensitive-data flags migrate_config_users.py:150. The SARIF flow runs from _SECRETS_VAULT_LEGACY (permission_seed.py:32, a list of permission NAMES such as secrets:team:read, named for the vault it governs) through SYSTEM_PERMISSIONS to logger.debug("Created permission: %s", ...). The alert is open on base too, with the source in permissions.py; #16298 moved the source, so its check counts the flow as new. The logged value is not a secret, but the per-row debug line adds nothing: the total is logged right after. Fixing it at the sink closes the alert on base as well. The loop has a separate shape defect (tuples indexed by name), filed as #16327.
@mrveiss

mrveiss commented Sep 11, 2026

Copy link
Copy Markdown
Owner Author

Delta review of adda1a5 from a second session (the coordinator): needs one fix. This combines my reviewer's check with 90's pass.

  • The CodeQL sink fix (bccc1c4) is correct. It removes exactly the flagged logger.debug("Created permission: %s", ...) line. No other logger.* call in the file prints a permission name, and the count line stays. The script can't run today anyway (bug(infra): migrate_config_users.py reads SYSTEM_PERMISSIONS and SYSTEM_ROLES as lists of dicts, so both seeding loops raise TypeError #16327), so that line was never reached.
  • The SSOT baseline fix deletes too much. Base's entry was 2|other|autobot-backend/api/auth.py|"user", covering two occurrences. Moving code to auth_me.py took one of them, so the audit flagged the count as too high; it didn't flag the entry as dead. d630882 deletes the whole line, but at this head auth.py:136 still reads "role": "admin" if user.is_platform_admin else "user", now with no baseline entry. The SSOT scan will fail on it. I verified this at review/16298; the SSOT check at this head hasn't reported yet.
  • Fix: replace the literals with the enum auth.py already imports (line 39): role_value(Role.ADMIN) if user.is_platform_admin else role_value(Role.USER). Role.USER = "user" is defined in autobot_shared/auth/permissions.py, and role_value is the sanctioned stringifier (bug(auth): is_admin_role(Role.ADMIN) returns False — str() on a (str, Enum) member yields 'Role.ADMIN' #14944). That leaves this file needing no baseline entry at all, which beats restoring the entry at count 1.

…ries no role literal (#16270)

The previous commit (d630882) deleted the baseline entry 2|other|autobot-backend/api/auth.py|"user" on the audit message "no longer match anything"; the audit meant the count was too high. /me moved one occurrence to auth_me.py, but auth.py:136 still had "role": "admin" if user.is_platform_admin else "user", so the SSOT scan failed on it. The line now uses role_value(Role.ADMIN) / role_value(Role.USER), which auth.py already imports, so no literal remains and no baseline entry is needed. The misleading audit output is #16334.
@mrveiss

mrveiss commented Sep 11, 2026

Copy link
Copy Markdown
Owner Author

Delta review of 219fd8f from a second session (the coordinator): approve.

The one commit replaces auth.py:136's literals with role_value(Role.ADMIN) if user.is_platform_admin else role_value(Role.USER), using the imports already at :39. No "user" literal is left, so no baseline entry is needed. The CodeQL sink fix (bccc1c4) was approved in the previous round.

My owner-approved cancellation matched on branch rather than head, so it caught this head's CI. I've re-run those runs (20 re-run). Once they're green at this head, blocked can come off.

@mrveiss

mrveiss commented Sep 11, 2026

Copy link
Copy Markdown
Owner Author

Delta review adda1a523→219fd8f87: the SSOT red is resolved at its source, so this delta passes.

  • It's one line, autobot-backend/api/auth.py:136: "admin" if … else "user" becomes role_value(Role.ADMIN) if … else role_value(Role.USER). There are no trailers.
  • The claim value is unchanged:
    • Role.ADMIN = "admin" and Role.USER = "user" (autobot_shared/auth/permissions.py:174, :179);
    • role_value returns role.value for a Role (:252-253);
    • both names were already imported (auth.py:39).
  • auth.py now has no "user" literal. The remaining "admin" at :280 is a dict key (current_user.get("admin")), not a role literal. So deleting the baseline entry in d630882 is now correct, because of this commit.

blocked comes off once CI is green at 219fd8f87 and f2's delta review approves. The misleading audit wording behind the first attempt is filed as #16334.

mrveiss added a commit that referenced this pull request Sep 12, 2026
@mrveiss mrveiss added this to the v0.9.0 milestone Sep 12, 2026
mrveiss added a commit that referenced this pull request Sep 12, 2026
…e that under-matches (#16334) (#16365)

* fix(guards): tell a baseline entry that matches nothing apart from one that under-matches (#16334)

hv_stale_baseline_entries now emits claimed|found|key, and --audit-baseline
lists 'matched 0 of N: delete this entry' separately from 'matched k of N:
lower it to k, do not delete'. Calling both 'no longer match anything' made
#16298 delete an entry that still covered a live finding.

* docs(guards): restore the hardcoded-values baseline header order and word the audit's two cases apart (#16334)

4b7c40c (#15732) C-sorted the baseline's 48 header lines, scrambling the
rules the file carries; the same 48 lines are put back in their 0c3a04a
order, body untouched. The header and HARDCODING_PREVENTION.md now describe
--audit-baseline as it reports: delete an entry that matches nothing, lower
one that under-matches.
@mrveiss mrveiss self-assigned this Sep 12, 2026
@mrveiss mrveiss added area: auth-rbac Wave 2 · cluster E — Auth, identity & RBAC canon area: platform-security Wave 3 · cluster AD — Platform security remainder backend bug Something isn't working priority: critical labels Sep 12, 2026
@mrveiss

mrveiss commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

Content review: approve. (Prioritized per the #16493 dependency.)

Verified the core decision chain end-to-end:

  • key_scopes.scope_granted: correctly guards against the string/list membership pitfall (isinstance(scopes, list) before any in check) — a scalar scope string would otherwise pass a substring test against "*" and silently grant everything. One matcher, APIKey.has_scope and key_permissions both delegate to it.
  • services/api_key_authority.permission_allowed: role-grants-permission AND (no api_key_id OR key_scope_allows) — a key can't exceed its owner's role or its own scopes. FastAPI-free, directly unit-tested (17 cases covering wildcard scopes, malformed scopes, non-admin-owner-with-admin-scope, session-vs-key contrast).
  • AC5 grace period wired correctly end-to-end: services/auth.py's _legacy_grace_or_401 computes legacy_grace_deadline(api_key.created_at), raises 401 past the deadline, warns and sets legacy_full_scope: True on the caller dict otherwise; key_scope_allows honors that flag for full owner authority. Fails closed on unknown key age (created_at is None → not legacy).
  • require_key_permission reuses the same _require_permission_or_403 as session-based require_permission — no route wires it yet (correctly deferred to feat(auth): decide which routes accept API keys, and wire X-API-Key through the #16040 mechanism #16294), but the mechanism is real and tested.

Stated gaps (no HTTP-level test through require_key_permission, permission-table seeding drift #16295) are honestly disclosed, not hidden, and don't block this PR's own scope.

closingIssuesReferences=[16270], matches its own Closes #16270. CI green (fail=0, pending=0), no missing required contexts. Ready — and unblocks #16493 per the dependency note.

@mrveiss
mrveiss merged commit fe7b58f into main Sep 13, 2026
86 checks passed
@mrveiss
mrveiss deleted the issue-16270-permission-scopes branch September 13, 2026 00:45
mrveiss added a commit that referenced this pull request Sep 24, 2026
…ons test (#16261) (#17379)

Line 19640 of api_endpoint_migrations_test.py asserted that
inspect.getsource(frontend_config.get_frontend_config) contains "hosts".
host-list disclosure, #15745), so the check goes stale once it merges. The
whole file is skip-marked (#5359/#15173), so nothing caught it. Delete the
one assertion; the method's other nine checks (fallback structure, config
sections) still exercise real, currently-true behavior.

Editing the file put it in no-local-schemas' (#6056) scan, which flagged
four pre-existing test-fixture BaseModel subclasses that predate the hook.
Exempt *_test.py/test_*.py from that endpoint-schema-placement hook -- a
model a test defines inline is a fixture, not a production schema -- the
same exemption made independently on #16298.

Lower both file-size ratchet ceilings (scripts/python_file_size_known_large.py,
repo_tests/python_file_size_ratchet_baseline.py) to the file's new actual
line count, 31039, so the deleted line cannot be re-spent elsewhere.

Not done here, per the issue's own scope: other getsource substring checks
in this parked file may also be stale (e.g. "default_config" is not
currently present in get_frontend_config's source either) -- that drift
belongs to #15173, not this fix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: auth-rbac Wave 2 · cluster E — Auth, identity & RBAC canon area: platform-security Wave 3 · cluster AD — Platform security remainder backend blocked bug Something isn't working priority: critical security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

auth: extend Permission to cover chat, teams and webhooks, define key scopes as permission bundles, and return effective permissions from /me

1 participant