fix(security): first-run setup refuses when a real admin already exists (#2381) - #2385
Conversation
…ts (#2381) On a fresh install `setup_completed` stayed false while `_ensure_admin_user` had already created a real admin from `ADMIN_PASSWORD`, and the unauthenticated `POST /api/setup/admin-password` gated only on that flag — so anyone who could reach the instance before the first human page load could overwrite the admin's password hash and bind their own email as its sign-in identity. The flag lied because `setup_completed_backfill` asks "does an admin exist?" during `run_all_migrations`, i.e. before `_ensure_admin_user` populates `users`, and the second pass then records it as applied so it never asks again. It was written for upgrading installs and is structurally a no-op on a fresh one. PostgreSQL was worse: no Alembic revision touches the key at all. Two halves, one shared policy (`utils/admin_identity`) so they cannot drift: * `routers/setup.py` refuses whenever a usable admin exists — its own precondition, not a derived flag. Fail-closed on a read error, and checked above the bcrypt hash on this unauthenticated, unrate-limited route. * `database.py::_mark_setup_completed_if_provisioned{,_engine}` writes the flag when one exists, on both backends. Deliberately a boot-time reconciliation rather than a migration: the affected population has already booted with the backfill recorded, so a migration could never reach them, while this converges them on the next restart. Never raises — `init_database` runs at import. The username now comes from `admin_username()`, not a literal. Against the old hardcoded "admin", an `ADMIN_USERNAME=root` install missed the real admin and `update_user_password` (an upsert) INSERTed a second role='admin' account for the caller. `docker-compose.prod.yml` also never passed `ADMIN_USERNAME`, so the variable was inert in production — verified fixed via `docker compose config`. Also closes the escalation that made the takeover outlive a password reset: `POST /api/auth/email/verify` now re-checks the allow-list before redeeming a code. Codes live in one table keyed on (email, code) with nothing binding a code to its producer; Telegram/WhatsApp `/login <email>` and the MCP inline-auth service mint without an allow-list check, and this is the one redeemer that issues a full platform JWT carrying the matched account's role. The refusal shares the bad-code branch verbatim so it is not a membership oracle. The deeper question — whether an email-column lookup may return the admin row at all — is left open deliberately. Scope of the wizard is now honest: it renders where it has work to do (no admin, no way in) and not where `ADMIN_PASSWORD` provisioned one at boot. That closes trinity-enterprise#49's tokenless window without reinstating the token — ent#49 priced it on "there is no admin yet", which now holds exactly where the wizard still appears. `start.sh`'s closing summary no longer promises a wizard that will not appear, and the printed `--unattended` password finally works. The wizard's two side-jobs are re-homed: the admin sign-in email moves to a dismissible post-login prompt (better placed — an unauthenticated wizard's "admin email" could be typed by whoever loaded the page first), and the product-updates opt-in is tracked as Abilityai/trinity-enterprise#463. Also fixes the unreachable `/setup` redirect in the router guard: it sat inside `if (!to.meta.isSetup)` while `/setup` carries `meta.isSetup: true`, so the wizard rendered on a completed install and only the backend 403 stopped a submit. Fixes #2381
/review ReportBranch: Critical FindingsNone. The security fix is correct, and correct in the places it would have been easy to get wrong. Four things I checked rather than took on trust:
Also verified: Informational Findings[I1] The mutual-lockout state is now unrecoverable in-product, and the new 403 points at the locked door (Confidence: 8/10)
The boot reconcile is what prevents it, and it prevents it well. But note what changed about its failure mode: What sharpens it is the refusal copy:
In the one state where this refusal is unexpected, that advice sends the operator to an endpoint that also answers 403. Two cheap options, either sufficient: append "restart the backend to re-run reconciliation" to [I2] The security comment enumerates 3 of 4 other code producers (Confidence: 9/10)
The fix covers it (the re-check is at the redeemer, so producer count doesn't matter), and nothing legitimate breaks: the Workspace redeems through its own [I3] The PR introduces [I4] The skip message interpolates the exception; its siblings in this same PR use
[I5] New component uses raw gray palette classes rather than semantic tokens (Confidence: 6/10)
ScopeDRIFT, and I'd keep it. The allow-list re-check in The other extras ( Clean Categories
Summary
Residual: the Test Plan's one unchecked box — booting a genuinely fresh install with |
Adds a durable admin-only opt-in surface for the operator-intake (identified contact record: email + optional company/name/role/use_case, POST to the hosted intake endpoint at `intake.abilityai.dev/v1/operator-intake`) beside the existing telemetry-sharing panel. Necessary because #2385 (v0.9.5 cut) stops rendering the first-run welcome form on any install that has a pre-provisioned admin — i.e. every prod install, every start.sh install, every hosted droplet, every ADMIN_PASSWORD-provisioned dev install. Without a Settings home the intake opt-in becomes silently unreachable for the majority of installs. Both surfaces converge on `submit_operator_intake` — no second intake client, no forked payload — and the at-most-once marker is preserved across both producers, so a Settings-driven opt-in on an install where the welcome form also submitted is a no-op. Design decisions (ent#463 AC #4/#5): * Resubmit = no-op. Second submit does not re-fire; at-most-once semantics are the intake's designed contract, and a duplicate lead is worse than a missed one. The panel shows terminal "already submitted" state with the timestamp when known, "date unknown" otherwise (for pre-ent#463 markers). * Opt-out = durable decline, does NOT roll back the marker. The record has already been sent; the panel is honest that record deletion needs a support request to the hosted endpoint. * OPERATOR_INTAKE_ENABLED / DO_NOT_TRACK continue to win over the Settings control (409 on a fresh-install submit attempt while hard-disabled). Auth is admin AND human-only (reject_agent_principal), matching the telemetry-sharing gate; a distinct `operator_intake_consent` audit action distinguishes Settings-driven consent from first-run consent in the audit log. The generic PUT /api/settings/{key} catch-all refuses `operator_intake_*` keys with 422+pointer, mirroring the telemetry_sharing_* guard shape (trinity-ops-agent#232 class: an admin-owned agent key must not be able to write consent through the unvalidated route). Files: * Service: added is_consent_enabled/is_hard_disabled/is_already_submitted/ get_status/set_consent/submit_from_settings. submit_operator_intake now also stamps `operator_intake_submitted_at` so the panel can render honest state on new submissions. * Router: GET/PUT /api/settings/operator-intake (both registered before /{key} — Invariant #4); catch-all guard added. * Model: OperatorIntakeUpdate. * Frontend: OperatorIntakePanel.vue (three states: fresh form / terminal already-submitted / hard-disabled banner) + operatorIntake store, mounted on the General tab beside TelemetrySharingPanel. * Tests: tests/unit/test_ent463_operator_intake_settings.py — 14 cases covering state axes, at-most-once, opt-out no-rollback, hard-disabled 409, agent-principal rejection, audit no-PII, catch-all guard. * Docs: architecture.md § operator_intake_service.py updated. Fixes ent#463 (abilityai/trinity-enterprise). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
setup_completedstayedfalsewhile a real admin already existed, and the unauthenticatedPOST /api/setup/admin-passwordgated only on that flag — so it would overwrite a live admin's password hash and bind the caller's email as its sign-in identity. The endpoint now refuses whenever a usable admin exists (its own precondition, not a derived flag), fail-closed, and above the bcrypt hash.setup_completedis made honest at boot on both backends. PostgreSQL previously had no writer for the key at all./api/auth/email/verifynow re-checks the allow-list before redeeming a code.Why not a migration
setup_completed_backfillasks "does an admin exist?" duringrun_all_migrations, which on a fresh install runs whileusersis still empty — then the second pass records it as applied, so it can never answer correctly for anyone. Every affected install has already booted with it recorded, so a new migration would inherit the same once-only semantics and reach none of them._mark_setup_completed_if_provisioned{,_engine}is a boot-time reconciliation that re-runs every start, so exposed installs converge on their next restart. It never raises —init_databaseruns at import, and a raise there crash-loops the backend.Wizard scope is now honest
ADMIN_PASSWORDmandatory (:?)start.shinteractive /--unattendedThis closes trinity-enterprise#49's tokenless first-run window without reinstating the setup token. ent#49 priced that tradeoff on the premise there is no admin yet — which now holds exactly where the wizard still renders. Same bug class as #177 (pentest 3.1.6, CVSS 7.5), which the token fixed and ent#49 reopened.
Re-homed side-jobs
The wizard was also the only capture point for two things it inherited six months after it was written:
AdminEmailNudge.vue→ Settings → General). Strictly better placed: an unauthenticated wizard's "admin email" can be typed by whoever loads the page first on a hosted install.abilityai/trinity-enterprise#463. Unrelated to telemetry sharing (ent#12), which already has its own Settings surface.Also in scope
ADMIN_USERNAMEwas hardcoded as"admin"inrouters/setup.py. On anADMIN_USERNAME=rootinstall that missed the real admin andupdate_user_password(an upsert) INSERTed a secondrole='admin'account for the caller. Now resolved throughutils/admin_identity.admin_username().docker-compose.prod.ymlnever passedADMIN_USERNAME(present indocker-compose.ymland.env.example), so the variable was inert in production — the feat(mcp): inline email auth — sign in from an MCP client with no API key (#848) #1707 packaging-gap class. Proven bydocker compose configresolution, not grep.start.sh's closing summary no longer promises a wizard that will not appear; the--unattendedpassword it prints now actually works.if (!to.meta.isSetup)while/setupcarriesmeta.isSetup: true, making it unreachable dead code./setuprendered the full wizard on a completed install; only the backend 403 stopped a submit.Deliberately NOT closed
get_or_create_email_userresolves by the email column alone and will return theadminrow for any address bound to it. The allow-list re-check removes the path to a platform JWT, but the underlying design question — may an email lookup ever return the admin account? — touches every email-auth consumer and a wrong narrowing locks operators out of their own instances. Out of scope here.Test Plan
pytest tests/unit/test_2381_setup_fail_closed.py tests/unit/test_2381_email_verify_whitelist.py— 20 new tests: the exploit verbatim, the blank-ADMIN_PASSWORDflow that must stay open, fail-closed on read error,ADMIN_USERNAMEhonoured on both halves, refusal precedes bcrypt, already-broken-install convergence, both DB backends, never-raises on both, andtest_predicate_halves_agreepinning the two halves against drift.12319 passed. The 21 remaining failures (test_736_a2a_outbound_*,test_mcp_validator,test_ent14_registry_url_ssrf,test_ent399_ipv6_origin) reproduce identically on an untouched checkout — pre-existing macOS IPv6/DNS environment failures, not this change.tests/unit/test_2322_mfa_challenge_response.py— its email-route stub now models an allow-listed user (2 tests were failing on the new gate; both are real coverage of the gate, not relaxations).tests/unit/test_setup_operator_profile.py—FakeDBgrowsget_user_by_username; an emptyusersdict is exactly the no-admin install, so all six existing tests keep exercising the happy path.npm run buildclean,npm run test:unit1176 passed, raw-color ratchet clean for the new component.ADMIN_PASSWORDset and confirm/api/setup/admin-passwordanswers 403 before any browser has touched the instance.Fixes #2381