Repository navigation
feat(auth): one permission vocabulary, key scopes as bundles, /me permissions, and API-key scope enforcement (#16270, #16040) - #16298
Conversation
…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.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: 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. Comment |
|
Security review at
Why #16040 stays open. Its AC4 is "routes declare the scope they require, and a key lacking it gets 403". Note, non-blocking but worth doing before #16294 wires any route: |
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.
|
CI red at 1. This PR moves code out of
2. CodeQL: 1 new high alert, "Clear-text logging of sensitive information"
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.
…nto issue-16270-permission-scopes
|
Delta review of adda1a5 from a second session (the coordinator): needs one fix. This combines my reviewer's check with 90's pass.
|
…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.
|
Delta review of 219fd8f from a second session (the coordinator): approve. The one commit replaces 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, |
|
Delta review
|
…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.
|
Content review: approve. (Prioritized per the #16493 dependency.) Verified the core decision chain end-to-end:
Stated gaps (no HTTP-level test through closingIssuesReferences=[16270], matches its own |
…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.
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
Permissionshare one vocabulary. auth: extend Permission to cover chat, teams and webhooks, define key scopes as permission bundles, and return effective permissions from /me #16270 builds that vocabulary. The standing batching goal therefore puts both in one PR.api.*/admin.users.*.settings:*maps toadmin.config.*andusers:*toadmin.users.*.audit:writeandadmin:organizationare frontend-only strings with no key scope; tech-debt(frontend): usePermissions' permission strings share nothing with the backend's Permission vocabulary #16243 retires them.permissions.pywas frozen at 631 lines. Its seeding builders moved to a purepermission_seed.py, which imports nothing back, so there is no cycle. The file is now 595 lines and off the ratchet list.api/auth.pywas frozen at 770 lines./memoved toapi/auth_me.py, leaving 734.repo_tests/api_key_scopes_are_enforced_16040_test.pyforbids a second implementation. Soscope_grantedinkey_scopes.pyis the rule,APIKey.has_scopedelegates to it, andkey_permissionsis built on it.services/auth.pyin isolation:autobot-slm-backendis offpythonpath(tech-debt(tests): cross-backend + conftest-stub namespace pollution causes ~92% of full-suite collection errors and ~1400+ runtime failures (prereq for #10691) #13084) andservicesis stubbed. So the whole decision moved to a FastAPI-freeservices/api_key_authority.py, which the conftest loads for real.services/auth.pyonly turns a refusal into a 403.What Changed
permission_seed.py, with no behaviour change. The ratchet entry is deleted, since the file is underMAX_LINES.Permissionmembers and their grants.key_scopes.pyholds all 14 scope bundles.GET /mereturnspermissions(asrole_has_permissionanswers them) andis_admin, fromapi/auth_me.py.has_scopeandkey_permissions.require_key_permission, the grace period for pre-enforcement keys, and the reason/api-keys/scopesstays open.Grant record (#16270 AC4):
chat.use,chat.historychat_shared_links.pymanages share links.teams.read/create/manage/deletewebhooks.triggerSuperadmin holds none of these, by design (#13854).
/mereportsis_adminso the frontend still admits a superadmin.#16040's acceptance criteria:
permission_allowedgrants a permission only if the owner's role holds it ANDkey_permissions(scopes)carries it.require_key_permission(permission)answers 403 when a key lacks the scope.AUTOBOT_API_KEY_SCOPES_ENFORCED_FROM(default2026-09-11) keeps its owner's authority, with a warning on every use, untilAUTOBOT_API_KEY_LEGACY_GRACE_DAYS(90) have passed. After that it gets 401 until it is re-issued.api_key_authority_test.pyshows a narrow-scope key being refused an out-of-scope permission in the full decision.GET /api-keys/scopesstays open. Its docstring gives the reason: it returns a static catalogue of scopes, not keys, users or tenant data.Verification
autobot_shared, the hardcoded-values hook and the 600-line guard. I did not run tests locally; the repo verifies through CI.get_api_key_userstill returns a dict literal, itsadminstill comes fromhas_scope("admin:*"), it still carriesscopes, and no scope matching exists outside the single implementation.api.tsfor/meis regenerated by theauto-fix-generated-typesworkflow.require_key_permission. SLM tests cannot importservices/auth.pyin isolation.permission_allowedis the whole decision and is tested directly; the dependency only converts False into 403. Route-level tests come with feat(auth): decide which routes accept API keys, and wire X-API-Key through the #16040 mechanism #16294's wiring.permissionstable on existing deployments. This gap predates this PR (feat(llc): who may edit a card — the creator, their manager, and that manager's manager #15765's member has it too) and is filed as bug(auth): new Permission members never reach the permissions table on existing deployments, so LLC roles cannot be granted them #16295.AUTOBOT_API_KEY_SCOPES_ENFORCED_FROMto the actual deploy date./me.Model Used
Claude Opus 5