Skip to content

fix(auth): dev-only bypass + first-user-becomes-owner bootstrap for the email allowlist (#384, #388) - #385

Merged
parthrohit22 merged 2 commits into
devfrom
fix/384-dev-allowlist-bypass
Sep 1, 2026
Merged

fix(auth): dev-only bypass + first-user-becomes-owner bootstrap for the email allowlist (#384, #388)#385
parthrohit22 merged 2 commits into
devfrom
fix/384-dev-allowlist-bypass

Conversation

@parthrohit22

@parthrohit22 parthrohit22 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two related allowlist-bypass mechanisms, for two different audiences, both living in AuthService._require_approval:

  1. fix(auth): dev-only bypass for the email allowlist so local development stays frictionless #384 — local development. The admin-managed email allowlist (feat(auth): replace invite-code registration with an admin-managed email allowlist #374/feat(auth): replace invite-code registration with an admin-managed email allowlist (#374) #375) blocked normal local development — a fresh checkout could no longer register or sign in without first running scripts/approve_email.py by hand. Adds a development-only bypass restoring the exact pre-feat(auth): replace invite-code registration with an admin-managed email allowlist #374 frictionless npm run dev experience; production enforcement is completely unchanged.
  2. feat(backend): first user on a fresh self-hosted instance becomes the owner, bypassing the allowlist #388 — real self-hosted deployments. fix(auth): dev-only bypass for the email allowlist so local development stays frictionless #384's bypass only helps in APP_ENV=development. A genuine self-hosted instance running in production mode 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, 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.
  • Deliberately development-only, not the broader dev/test leniency pairing used elsewhere in Settings: the backend test suite runs under the same default app_env a real local dev server does, so reusing that pairing would have silently defeated every allowlist-rejection test. tests/conftest.py's client fixture (and test_approved_emails_migration.py's own fixture) explicitly set APP_ENV=test, a real distinction. Fixed the four test_system.py assertions that had hardcoded the old default environment string.

#388 — first-user-becomes-owner bootstrap

  • _require_approval now falls back to a new _first_user_bootstrap check, checked after the development branch 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.
  • "First" is measured by the users table having no real account yet — explicitly excluding the permanent SEED_USER_ID placeholder 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).
  • Applies identically to register() and register_oauth_user(), since both share this exact funnel — verified explicitly with a new OAuth-path test.
  • A new ApprovedEmail row 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 eventual User insert 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:

  • exists only for the single moment between a fresh instance's first boot and its first successful registration;
  • closes permanently, for good, the instant one registration commits;
  • never reopens or weakens the allowlist for anyone after that.

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_user already 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_approved
  • test_development_bypass_does_not_apply_outside_development
  • test_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 the client fixture's own APP_ENV=test, and again explicitly under app_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 "outside development/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, and approve_email.py for every registration after that.

Acceptance criteria completed

#384:

  • In development mode, registration (and OAuth first-time sign-in) works with no invite/approval step.
  • Production enforcement completely unchanged.
  • Verified for real: both dev servers started fresh, a real signup completed locally with zero prior setup.

#388:

  • Fresh empty DB, non-development environment: the first registration (password or OAuth) succeeds without any pre-approval.
  • Second registration on that same instance, still non-development, no pre-approval: correctly rejected.
  • Existing pre-approved-email tests still pass.
  • The narrow TOCTOU race is understood and explicitly documented as an accepted, bounded risk (see above).
  • README's self-hosting-relevant guidance updated.

Testing performed

cd apps/backend
source .venv/bin/activate
python3 -m pytest -q                                                       → 1076 passed, 13 skipped (SQLite)
PARTHA_TEST_PG_URL=postgresql+psycopg://...  python3 -m pytest -k "postgres or migration or concurrency" -q
                                                                             → 38 passed, real local Postgres
ruff format --check . && ruff check . && python3 -m mypy app               → all clean

Manual, end to end, #384 (not assumed from the test suite): moved the local .env aside, deleted the local dev SQLite file, started the real backend and frontend dev servers, then in an actual browser filled out /register with an email that had never been approved and submitted it — POST /auth/register → 201 Created, landed on a real authenticated Dashboard. Restored .env and 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's create_all shortcut) 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 with APP_ENV=production and production-grade AUTH_SECRET_KEY/AI_ENCRYPTION_KEY (confirmed /health reports "environment": "production"), then over real HTTP:

POST /auth/register {email: "real-self-hoster@example.com", ...} → 201 Created
POST /auth/register {email: "second-uninvited-person@example.com", ...} → 422, "hasn't been approved..."

Confirmed via direct SQL query afterward that approved_emails recorded the bootstrap row with added_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.

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

  • This PR targets dev
  • I claimed the issue and had it assigned or acknowledged before starting substantial work
  • The branch was created from an up-to-date upstream/dev
  • The branch is rebased on the latest upstream/dev
  • This PR addresses clearly scoped issues (two, closely related — both allowlist-bypass mechanisms in the same function)
  • This PR is in scope: it advances tracked issues (Scope section filled)
  • Every acceptance criterion I claim as complete is actually complete
  • Relevant tests pass
  • Documentation is updated for any user-visible change (README.md)
  • No secrets, credentials, local env files, or generated artifacts are included
  • No unrelated files were changed
  • Closing syntax (Closes) is used only because both issues are fully resolved
  • Dependencies and follow-up work are linked (the optional mutex-table hardening is called out above, not a blocker)

…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.
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.
@parthrohit22 parthrohit22 changed the title fix(auth): dev-only bypass for the email allowlist so local development stays frictionless (#384) fix(auth): dev-only bypass + first-user-becomes-owner bootstrap for the email allowlist (#384, #388) Sep 1, 2026
@parthrohit22
parthrohit22 merged commit 5b70997 into dev Sep 1, 2026
10 of 11 checks passed
@parthrohit22
parthrohit22 deleted the fix/384-dev-allowlist-bypass branch September 1, 2026 20:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant