fix(auth): dev-only bypass + first-user-becomes-owner bootstrap for the email allowlist (#384, #388) - #385
Merged
Merged
Conversation
…nt stays frictionless (#384) The admin-managed allowlist (#374/#375) blocked normal local development -- a fresh checkout could no longer register/sign in without first running scripts/approve_email.py by hand. Restores the pre-#374 frictionless npm run dev experience in development specifically; production enforcement is completely unchanged. - AuthService._require_approval() now auto-creates a real, persisted ApprovedEmail row (added_by="dev-bypass") when app_env == "development" and no admin approval exists yet, instead of raising. Everything downstream (the audit trail, the uniqueness check, the OAuth path via register_oauth_user) behaves identically to a real approval -- this only changes what happens when nobody has approved the address yet, and only in development. - Deliberately development-only, not the broader dev/test leniency pairing used elsewhere in Settings (AUTH_SECRET_KEY/AI_ENCRYPTION_KEY): the backend test suite currently runs under the same default app_env a real local dev server does (nothing had ever overridden APP_ENV for pytest), so reusing that exact pairing would have silently defeated every one of the allowlist-rejection tests. tests/conftest.py's client fixture (and test_approved_emails_migration.py's own fixture) now explicitly set APP_ENV=test, a real and meaningful distinction from "development" going forward, not just this fix's workaround. Fixed the four test_system.py assertions that had hardcoded the old default environment string. - Two new tests (test_auth.py): the bypass actually auto-approves and opens a real session in development, and does NOT apply under test/staging/ production -- both constructed directly against AuthService with an app_env-copied Settings, the same pattern test_register_commit_time_collision_is_reported_as_conflict already uses. Verified for real, not just via the test suite: moved the local .env aside, started the actual backend (npm run dev:backend, zero config) and frontend (npm run dev:frontend) dev servers, and completed a real signup in-browser for an email that was never pre-approved by anything -- POST /auth/register returned 201, landed on a real authenticated Dashboard. Full backend suite green on both SQLite and real PostgreSQL; ruff/mypy clean.
5 tasks
The #384 dev-only bypass only helps when APP_ENV=development. A genuine self-hosted deployment running in production mode still hit the allowlist wall, since nobody is pre-approved except the hardcoded product-owner row seeded by the #374 migration -- which is this project's own owner, not a self-hoster's. AuthService._require_approval now falls back to a new _first_user_bootstrap check, in every environment the dev-only bypass doesn't already cover unconditionally: if the users table has no real account yet (excluding the permanent SEED_USER_ID placeholder every database gets from migration 0002, which must never itself count as "already claimed"), the registering email is auto-approved and becomes the instance's owner -- the standard bootstrap pattern most self-hosted software uses (first person to reach the setup wizard becomes the admin). Every registration after that first real one goes through the normal allowlist exactly as before. Applies identically to register() and register_oauth_user(), since both share this same funnel. The dev-only bypass is checked first, unchanged in behavior: in development every registration is already frictionless regardless of ordinal position, so that's the more specific and accurate reason to attribute there. Bootstrap only ever fires as the fallback for non-development environments. Flagged, not silently resolved: the first-user check and the eventual User insert are not atomic with each other, so two concurrent first-ever registrations on the same fresh database could theoretically both observe zero real users and both become "the owner". This window exists only for the single moment between a fresh instance's first boot and its first successful registration, closes permanently the instant one registration commits, and never reopens or weakens the allowlist afterward. Closing it completely would need a dedicated atomically-claimed mutex (a new table); deliberately not added here given the narrow, single-operator nature of the scenario -- see _first_user_bootstrap's docstring and the PR description for the full tradeoff. Updated tests whose fresh, otherwise-empty test databases were themselves implicitly "the first user" and would now succeed via bootstrap instead of exercising the rejection they were actually testing (test_register_rejects_an_email_that_was_never_approved, test_development_bypass_does_not_apply_outside_development, test_brand_new_unapproved_identity_is_rejected_never_bypassing_the_allowlist, test_brand_new_unapproved_identity_redirects_to_signup_required) to register a baseline user first, closing the bootstrap window before asserting the rejection. Added direct coverage for the new behavior at the AuthService, OAuthService, and seed-placeholder-interaction levels. Documented the behavior in README.md's security guidance, next to the existing outside-dev/test requirements list, since that's where a self-hoster preparing a real deployment would look. Verified: ruff format/check and mypy clean; full backend suite green (1076 passed, 13 skipped) on SQLite, plus the Postgres-gated migration and concurrency suite green against a real local Postgres database. Real manual verification against a genuine self-host boot: ran the actual Alembic migration chain against a fresh SQLite file (not the test fixture's create_all shortcut), started the real app with APP_ENV=production and production-grade AUTH_SECRET_KEY/AI_ENCRYPTION_KEY, and confirmed over real HTTP that the first registration succeeds (201, added_by = "first-user-bootstrap" in the database) while a second, different unapproved email is correctly rejected (422, "hasn't been approved") immediately afterward.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two related allowlist-bypass mechanisms, for two different audiences, both living in
AuthService._require_approval:scripts/approve_email.pyby hand. Adds adevelopment-only bypass restoring the exact pre-feat(auth): replace invite-code registration with an admin-managed email allowlist #374 frictionlessnpm run devexperience; production enforcement is completely unchanged.APP_ENV=development. A genuine self-hosted instance running inproductionmode still hit the same wall, since nobody is pre-approved except the hardcoded product-owner row seeded by the feat(auth): replace invite-code registration with an admin-managed email allowlist #374 migration — which is this project's own owner, not a self-hoster's. Adds the standard "first user becomes the owner" bootstrap pattern: the very first account registered on a fresh instance (no real users yet) is auto-approved regardless of environment, so a self-hoster can actually use their own deployment. Every registration after that first one is gated by the allowlist exactly as before.Linked issues
Closes #384
Closes #388
Scope
What changed
#384 — development-only bypass
AuthService._require_approval()auto-creates a real, persistedApprovedEmailrow (added_by="dev-bypass") whenapp_env == "development"and no admin approval exists yet, instead of raising. Everything downstream (the audit trail, the uniqueness check, the OAuth path viaregister_oauth_user) behaves identically to a real approval.development-only, not the broader dev/test leniency pairing used elsewhere inSettings: the backend test suite runs under the same defaultapp_enva real local dev server does, so reusing that pairing would have silently defeated every allowlist-rejection test.tests/conftest.py'sclientfixture (andtest_approved_emails_migration.py's own fixture) explicitly setAPP_ENV=test, a real distinction. Fixed the fourtest_system.pyassertions that had hardcoded the old default environment string.#388 — first-user-becomes-owner bootstrap
_require_approvalnow falls back to a new_first_user_bootstrapcheck, checked after thedevelopmentbranch above (so in development the dev-bypass reason is still what's attributed — it's already unconditional there) but before the final rejection, so it's the effective gate for every real deployment environment.userstable having no real account yet — explicitly excluding the permanentSEED_USER_IDplaceholder row every database gets from the 0002 migration, which is not a real account and must never itself count as "already claimed". Missing this would have meant bootstrap never fires on any real, Alembic-migrated deployment at all (only on this repo's own test fixtures, which don't seed that row).register()andregister_oauth_user(), since both share this exact funnel — verified explicitly with a new OAuth-path test.ApprovedEmailrow is created and persisted (added_by="first-user-bootstrap"), so the audit trail records exactly why that first account exists, the same as every other approval path.Flagged, not silently resolved — a genuine tradeoff, surfaced rather than guessed at: the first-user check (
SELECT COUNT(*) FROM users WHERE id != SEED_USER_ID) and the eventualUserinsert are not atomic with each other — the insert happens later, in_create_approved_user. Two concurrent first-ever registrations on the same fresh database could both observe zero real users and both be auto-approved as owner. This window:Closing it completely would need a dedicated, atomically-claimed mutex (e.g. a new single-row table claimed via a unique-constraint insert — the exact pattern
_create_approved_useralready uses for email-uniqueness races). I deliberately did not add that here: it's a real design tradeoff between full correctness and a new migration/table for a narrow, single-operator, one-time bootstrap scenario, not something to unilaterally decide either way. See_first_user_bootstrap's docstring for the same explanation in the code itself. Happy to add the mutex table if the extra rigor is wanted.Test updates this required
Several existing tests ran against a fresh, otherwise-empty test database and were themselves — unnoticed until now — implicitly "the first user"; under #388 they'd now succeed via bootstrap instead of exercising the rejection they were actually testing:
test_register_rejects_an_email_that_was_never_approvedtest_development_bypass_does_not_apply_outside_developmenttest_brand_new_unapproved_identity_is_rejected_never_bypassing_the_allowlist(OAuth)test_brand_new_unapproved_identity_redirects_to_signup_required(OAuth, HTTP route level)Each now registers a baseline real user first, closing the bootstrap window, before asserting the rejection they're actually about. Added direct new coverage for #388 itself at three levels:
AuthService(fresh DB succeeds in a non-development environment via theclientfixture's ownAPP_ENV=test, and again explicitly underapp_env="production"; a second registration on the same instance is still rejected; the seed-placeholder row doesn't block bootstrap),OAuthService(the same bootstrap fires for a brand-new verified OAuth identity), and README documentation.Documentation
Added a paragraph to
README.md's security guidance (next to the existing "outsidedevelopment/test, the backend requires..." list, since that's where a self-hoster preparing a real deployment would look) explaining the allowlist, the first-user bootstrap, andapprove_email.pyfor every registration after that.Acceptance criteria completed
#384:
developmentmode, registration (and OAuth first-time sign-in) works with no invite/approval step.#388:
Testing performed
Manual, end to end, #384 (not assumed from the test suite): moved the local
.envaside, deleted the local dev SQLite file, started the real backend and frontend dev servers, then in an actual browser filled out/registerwith an email that had never been approved and submitted it —POST /auth/register → 201 Created, landed on a real authenticated Dashboard. Restored.envand cleaned up afterward.Manual, end to end, #388 (a genuine self-host simulation, not just a unit test): in a scratch directory, ran the real Alembic migration chain (
alembic upgrade head, not the test suite'screate_allshortcut) against a brand-new SQLite file, confirmed via direct SQL query that the resulting database had exactly the seed placeholder user and the pre-approved product-owner email and nothing else — matching what any real self-hoster's fresh database looks like. Started the real app withAPP_ENV=productionand production-gradeAUTH_SECRET_KEY/AI_ENCRYPTION_KEY(confirmed/healthreports"environment": "production"), then over real HTTP:Confirmed via direct SQL query afterward that
approved_emailsrecorded the bootstrap row withadded_by = "first-user-bootstrap". Stopped the server and deleted the scratch database afterward.Screenshots
Not applicable — no UI-visible change in either mechanism; verified via real HTTP requests and database inspection as described above.
Security and data considerations
Explicit reviewer attention requested per CONTRIBUTING.md §11.9 — this touches the access-control gate added in #374/#375.
AuthService._require_approval().app_envis validated against a fixed enum and is not attacker-controllable at request time.development-only branch, unaffected by anything in feat(backend): first user on a fresh self-hosted instance becomes the owner, bypassing the allowlist #388.userstable (excluding the system placeholder) is genuinely empty — the instant one real account exists anywhere on that instance, the bootstrap path is permanently and irreversibly closed for every registration after it, in every environment, forever (there is no "re-open" path, no admin toggle, nothing that could accidentally re-trigger it).approve_email.py) themselves.Dependencies and blocked work
None.
Scope changes or remaining work
None for this PR. Optionally, per the flagged race condition above: a dedicated atomically-claimed mutex table, if full correctness under concurrent first-ever registrations is wanted badly enough to justify a new migration for it.
Contributor checklist
devupstream/devupstream/devREADME.md)Closes) is used only because both issues are fully resolved