fix(security)!: enforce real scopes and tenancy (#898, P0.4) - #987
Conversation
…ace ownership Closes #898 (P0.4). require_auth handed every JWT principal [read, write, admin] "for backward compatibility", so require_scope(SCOPE_ADMIN) — credential storage, GitHub PAT storage, PR merge — was decorative for anything holding a browser session, and users.is_superuser was never read. JWT scopes now come from the user row: read+write always, admin only for is_superuser (via getattr, so an unexpected principal shape fails closed to non-admin rather than 500). That alone would leave the product with no admin at all: fastapi-users forces is_superuser=False on registration, so nothing ever set it. Two paired fixes: - UserManager.on_after_register promotes the account when it is the instance's sole login-capable user — exactly the bootstrap first user /auth/register admits (#336/#897). The count is re-checked, not assumed. - SchemaManager._ensure_bootstrap_superuser backfills admin to the earliest login-capable account when an upgraded instance has none, so this change cannot silently strip the operator's admin. Idempotent. Also closed, since they bypass or mirror the same gate: - create_api_key refuses an admin-scoped key from a non-superuser. Without it any signed-in user could mint themselves an admin key and escalate straight past the new derivation. - The API-key router mounts require_method_scope, so a scopes:["read"] key can no longer DELETE its owner's write/admin keys. - workspaces_registry.upsert makes ownership write-once (COALESCE order flipped), so user B re-registering user A's repo_path no longer takes the row over. A NULL owner is still claimable on first attribution. BREAKING CHANGE: a non-superuser session no longer holds admin scope. On a fresh install the bootstrap account is promoted automatically and on an existing one the backfill grants admin to the earliest real account, so no operator action is needed — but a second, non-superuser account now gets 403 on credential storage, PAT storage and PR merge. deploy/README.md documents granting admin to another account.
|
Claude finished @frankbria's task in 5m 38s —— View job 🔍 GLM bug-hunting review — completeReviewed PR #987 for concrete defects only (logic errors, security, data loss, races, breaking changes). Style, architecture, naming, and test coverage are out of scope (covered elsewhere).
✅ GLM review: no defects found. What was verified (each against read source, not the diff alone)
No logic error, authz bypass, null path, race, or breaking-contract change met the failure-scenario bar. |
…promotion Review follow-ups on #898. Both CodeRabbit and the opencode/GLM pass independently flagged the same top defect. - Clamp a key's scopes to its owner's live grant at *resolution* time (_scopes_within_owner_grant in get_api_key_auth), not only at creation. The creation guard alone left every pre-#898 admin key working — back then any signed-in user could mint one — so the fix gated new issuance while the existing population sailed past require_scope(SCOPE_ADMIN). Doing it on read needs no migration and, unlike one, also covers an owner demoted later. The key is clamped, not revoked: it keeps read/write. - Make the bootstrap promotion a single atomic UPDATE carrying both guards (sole login-capable account AND no login-capable superuser). _register_lock is an asyncio.Lock, so it does not serialize across uvicorn workers; with count-then-write, two racing first registrations could each see two users and neither promote — zero admins, no in-product way back. - Give '!DISABLED!' one definition, in platform_store (auth imports it; the reverse is not DAG-legal). Two copies had to stay byte-identical forever: the registration gate, the promotion and the backfill all compare against it, so drift would make fresh deploys unclaimable AND silently strip an upgraded deploy's admin, with no error. Pinned by a test. - create_api_key uses getattr for is_superuser, matching require_auth: a principal lacking the column must fail closed to 403, not raise into a 500 that leaves the route ungated. - Document that the backfill runs on every initialize(), so demoting the sole account does not stick. Deliberate — an instance with no reachable admin cannot store credentials or merge PRs. Demoting a non-earliest account still sticks. Both pinned by tests. Two test fixtures change premise, not strength: an admin-scoped key must now belong to a superuser, so test_v2_scope_enforcement mints its admin key for a new superuser (user 2, leaving user 1 non-superuser for the JWT tests) and test_stream_ticket_endpoint promotes its user. Both still assert exactly what they asserted before.
Third-party review round — 2 reviewers, 1 shared top findingBoth CodeRabbit and an opencode/GLM hostile-security pass independently landed on the same defect, which I had only listed as a known limitation. They were right to escalate it, so it's fixed in ef9359e. The shared finding: pre-existing admin keys bypassed the whole fix
Before #898, Both reviewers proposed a one-time migration to strip admin from those rows. I went a step further and clamped at resolution time (
Demonstrated live below. Also fixed
opencode reported no CRITICAL findings, and confirmed "the JWT-side escalation (the headline defect) is correctly closed." One behavioral property I found while demoing, now documented and pinnedThe backfill runs on every Demo — live app, real HTTP, real SQLite, 17/17Test-fixture changes — premise, not strengthTwo fixtures assumed an admin-scoped key can belong to a non-superuser, which is exactly what this PR outlaws. Verification
|
|
Claude finished @frankbria's task in 6m 48s —— View job 🔍 GLM bug-hunting review — completeReviewed PR #987 for concrete defects only (logic errors, security, data loss, races, breaking changes). Style, architecture, naming, and test coverage are out of scope (covered elsewhere). This pass covers the latest commit (
✅ GLM review: no defects found. What was verified (each against read source, not the diff alone)
No logic error, authz bypass, null path, race, or breaking-contract change met the failure-scenario bar. |
Closes #898 (P0.4 —
critical/security, filed by the SaaS launch review).The bug behind the bug
require_authhanded every JWT principal[read, write, admin]"for backward compatibility". That made the entirerequire_scope(SCOPE_ADMIN)layer — credential storage, GitHub PAT storage, PR merge — decorative for anything holding a browser session, and leftusers.is_superuseras dead data.The obvious fix (derive admin from
is_superuser) breaks the product on its own:fastapi_users.get_register_routerhard-forcesis_superuser=Falseon every registration, and the only row that ever carries1is the seeded!DISABLED!placeholder that can never log in. Derive naively and the instance has zero admin principals — Settings, PAT storage and PR merge become permanently 403 with no in-product way out.So this PR is the derivation plus the two things that have to be true for it to ship.
Changes
1. JWT scopes come from the user row (
auth/dependencies.py)[read, write]always,adminonly whenis_superuser. Read viagetattr(..., False)so an unexpected principal shape fails closed to non-admin rather than 500. The auth-disabled synthetic principal is deliberately untouched — it is the single-operator local opt-out and has no user record to read.2. Somebody has to be admin
UserManager.on_after_registerpromotes the account when it is the instance's sole login-capable user — exactly the bootstrap first user/auth/registeradmits (Enforce authentication across the v2 API (routers, WS/SSE, and web-UI login) #336/[P0.3] Gate bootstrap /auth/register behind an out-of-band secret #897). The count is re-checked rather than assumed, so if another registration path is ever added only a genuinely sole account is promoted.SchemaManager._ensure_bootstrap_superuserbackfills admin to the earliest login-capable account when an upgraded instance has none. Idempotent. Without it, deploying this commit silently strips the operator's admin.3. The escalation path that would have made #1 a formality (
auth/api_key_router.py)create_api_keyrefuses anadmin-scoped key from a non-superuser. Not in the issue's criteria, but without it any signed-in user mints themselves an admin key and walks straight past the new derivation. A key can never grant more than its creator holds.4. API-key router enforces scope by method (
auth/api_key_router.py)Router-level
require_method_scope. GET needsread, POST/DELETE needwrite— ascopes:["read"]key can no longer DELETE its owner's write/admin keys.5. Workspace ownership is write-once (
workspace_registry_repository.py)COALESCE argument order flipped, so an already-recorded
owner_user_idis never reassigned: user B re-registering user A'srepo_pathno longer takes the row over. A NULL owner (left by an auth-disabled run) is still claimable on first attribution.Acceptance criteria
tests/auth/test_jwt_scope_derivation.py,tests/ui/test_v2_scope_enforcement.py::TestJwtIsNotAutomaticallyAdmintests/auth/test_api_key_router_scopes.pyowner_user_id; user B re-registering user A's pathtests/platform_store/test_workspace_registry_repository.py::TestOwnerScopingPlus
tests/auth/test_registration_bootstrap.py::TestBootstrapUserBecomesSuperuserandtests/platform_store/test_bootstrap_superuser_backfill.pyfor the two admin-provisioning paths.Testing
tests/auth,tests/platform_store, the v2 scope/auth/registry/settings/github/PR-merge/credential-isolation UI routers,test_api_key_service,test_api_key_commands.test_config_reload_integration.py::test_full_reload_cycle, is a 0.1s-poll file-watcher deadline that flaked under an hour of fsync-saturated IO; it passes in isolation and touches none of the paths here (same class as [P1.34] Parallel-execution barrier timeout is load-sensitive — flakes under full-suite runs #976).ruff checkclean; strictmypyclean on all changed modules.BREAKING CHANGE
A non-superuser session no longer holds admin scope. No operator action is required — fresh installs promote the bootstrap account automatically, and existing installs get the backfill. But a second, non-superuser account now gets 403 on credential storage, PAT storage and PR merge.
Known limitations
Granting admin to an additional account means setting
is_superuser = 1on itsusersrow directly; there is no in-product promotion flow. Documented indeploy/README.md.The registry still lets a second user refresh
name/tech_stackon a row they don't own — metadata, not ownership, and hosted-mode path confinement ([P7.0.1] Workspace path allowlist — prevent authenticated cross-tenant RCE (M1) #655) already blocks cross-tenant reach. Deliberately out of scope.API keys withFixed in ef9359e after both reviewers flagged it: a key's scopes are now clamped to its owner's live grant on every request, so legacy admin keys and later-demoted owners are both covered without a migration.adminscope minted by a non-superuser before this change keep working.on_after_register's sole-user check is in-process; the [P0.3] Gate bootstrap /auth/register behind an out-of-band secret #897 registration lock carries the same documented multi-worker caveat.The bootstrap-superuser backfill runs on every
Database.initialize(), so demoting the solelogin-capable account does not stick. Deliberate — an instance with no reachable admin cannot store
credentials or merge PRs and has no in-product way back. Demoting a non-earliest account still
sticks. Documented in
_ensure_bootstrap_superuserand pinned by tests.