feat(site_lock): optional site-wide password gate, off by default - #240
Merged
Conversation
Staging/pre-launch gate behind one shared password, off by default. Middleware-only module sorting after Auth so it wraps outermost; DB-backed settings (admin UI only); logged-in admins bypass as the lockout escape hatch. Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
Six TDD tasks: package scaffold + settings, attempt limiter, gate page, middleware, admin-bypass coverage, host wiring and docs. Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
DB-backed SiteLockSettings (enabled=False by default) with a validator that refuses to enable the gate behind a blank password, plus an in-memory per-IP attempt limiter with injectable clock for testing. Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
Middleware runs before AuthMiddleware so anonymous visitors get the gate rather than a login redirect. Session marker is a password fingerprint, so rotating the password invalidates existing unlock sessions. Admins holding a live session bypass, stamped once per session. Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
Installs the module in the host, pins SiteLockMiddleware ahead of AuthMiddleware in the middleware-order test, and adds the docs page + nav. Renames the module's test files with a site_lock_ prefix: pytest resolves test modules by basename with no __init__.py, so test_settings.py and test_rate_limit.py collided with the users module's files of the same name. Makes test_wizard_custom_picks_only_yes_answers derive its answer sequence from CATALOG instead of hardcoding a count -- the hardcoded sequence shifted onto the wrong module as soon as a new catalog entry landed. Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
Exercises the real create_app pipeline: module state mounted, settings registered for the admin UI, middleware ordered ahead of Auth, and the gate's behaviour on a booted app both off and on. Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
Browser QA found that enabling the gate without a password looked like it worked. The backend correctly returned 422 and persisted nothing, but the admin saw no error: the toggle stayed on, "Reset to default" appeared, and the obvious reading was that the site was now locked. It was wide open. Cause: a bare `raise ValueError` in a `model_validator(mode="after")` produces `loc=[]`. `ModuleForm.onSave` keys field errors by `loc[-1]` and drops anything it cannot attach to a field, so the message was discarded before render. Raise a pydantic-core validation error pinned to `enabled` instead, so it lands under the toggle the admin just flipped. Worst possible failure mode for a security feature, so it gets a regression test asserting the error's `loc`, not just that it raises. Also add the Install/Usage sections `scripts/check_readmes.py` requires -- this was failing `make lint` since the module landed, missed because I had been gating on `make ci-python-lint` -- and document that the gate returns 403 to the login API too, so no programmatic client can get in while it is on. Verified: make lint (exit 0), make test-py (1562 passed). Claude-Session: https://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu
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
Adds a
site_lockmodule: an optional shared-password gate in front of the whole site — including the login page — so a staging or pre-launch deployment shows nothing to anyone without the password, not even that a login form exists.enabled/password/message). No env vars; changes apply immediately with no restart.How the ordering works
The middleware must run before
AuthMiddleware, or an anonymous visitor gets redirected to the login page instead of the gate — leaking that a login exists. It gets there by declaringdepends_on=["Settings", "Auth"]: modules install in topological order and Starlette'sadd_middlewareis LIFO, so sorting afterAuthmakes this wrap outermost.framework/hosting/tests/test_middleware_order.pypins this, since inverting it breaks the feature without failing any unit test.Verification
Browser-verified against a live server (API :8000 + Vite :5050), plus:
make lint(ruff, ty, Biome, tsc, file-size, metadata, READMEs)make testnpm run buildmake doctorBehaviour confirmed end-to-end in the browser and over HTTP:
/__unlockis not exposed/and/users/loginboth 302 to/__unlock;/healthstays 200next(//evil.com,/\evil.com,https://evil.com) all fall back to//api/*returns403 {"detail": "Site is locked"}rather than a redirectBug found and fixed during QA
Enabling the gate with a blank password looked like it worked. The backend correctly returned 422 and persisted nothing, but the admin saw no error — the toggle stayed on and the obvious reading was that the site was now locked. It was wide open.
A bare
raise ValueErrorin amodel_validator(mode="after")producesloc=[], and the shared settings form keys field errors byloc[-1]and drops anything it can't attach to a field. The error was discarded before render. Fixed by raising a validation error pinned toenabled, with a regression test asserting thelocrather than just that it raises.Known limitation (by design)
The admin bypass only rescues a live session. If nobody is signed in and the password is forgotten, there is no in-app recovery — you clear the override in SQL (documented in the module README). This was the deliberate trade chosen over shipping a
smpy site-lock disableCLI command.Relatedly, while the gate is on there is no programmatic way in at all: CI, uptime monitors and mobile clients all get 403 regardless of credentials.
/healthstays open for probes. Now called out explicitly in the README.Test plan
/__unlockon every URLhttps://claude.ai/code/session_01854CBXUPwhDSAkWbb81aRu