feat(auth): provision the first administrator offline -- the not-present arm of ASVS 6.3.2 (BACKLOG #1136, ADR 0183) - #924
Open
wshallwshall wants to merge 2 commits into
Open
Conversation
… ADR 0183) ASVS 6.3.2 asks that default user accounts be not present or disabled, and neither arm holds: `_ensure_bootstrap_admin` creates an enabled local account named `admin` on an empty table, and the disabled arm is unexpressible -- `Store.create_user` takes no `disabled` parameter and all three backends hardcode the column. Takes the "not present" arm. `messagefoundry provision-admin --username <name>` run at the host before the first `serve` creates the administrator, and the seeding path then declines on a non-empty table, so no default account is ever present. The gate is filesystem authority over the store, argued once for `admin-unlock` in ADR 0171 and not restated. The refusal asks for an ENABLED ADMINISTRATOR, not an empty table. That corrects the researched guard: `_upsert_ad_user` assigns no role, so one directory sign-in leaves a roleless row and a non-empty table, and an emptiness guard would refuse exactly where the install has no way in. No --password and no --password-file: unattended provisioning is refused in terms rather than left as a hatch that lands a standing Administrator credential in argv or on disk. The credential is claimed at creation, which removes the half-claimed state a restart could land in and keeps an operator-chosen `admin` out of the WP-3 retirement sweep. The row is created with no password hash, so every crash point leaves at most a roleless account; a re-run completes it. NOT A CLOSURE and the cell stays partial: `_ensure_bootstrap_admin` is untouched, so the shipped default still mints `admin`. Retiring the lifecycle is the remaining half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/adr/README.md conflicted the same way as the last one and resolves the other way round. The index ascends by ADR number, this branch adds 0183 and main added 0184, so this branch's row goes FIRST. Taking main's side first by reflex would have put 0184 above 0183. Everything else auto-merged, including messagefoundry/__main__.py and messagefoundry/auth/service.py. Verified the result drops no ## row that main carries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
What this is
BACKLOG #1136, ASVS 6.3.2. The item is a research item whose outcome was already decided on 2026-08-20: an honest pass is reachable with a build, and the arm to take is not present rather than disabled. This executes the first half of that finding and files the decision record the 2026-09-04 pass recorded as owed.
NOT A CLOSURE. The cell stays partial.
_ensure_bootstrap_adminis untouched, so the shipped default still mints an enabled account namedadmin, and the three characterization tests from the previous pass stay green. Nothing was written to the ASVS scorecard; it is not in this repository and the re-score is a separate act.The change
messagefoundry provision-admin --username <name> [--email <address>]creates the first administrator offline, at the host, before the firstserve. The seeding path fires only on an empty user table, so an operator-named administrator pre-empts it and the account namedadminnever exists. That is the verb's first arm, reached by an operator action rather than by a configuration knob.Neither refused move was taken.
BOOTSTRAP_USERNAMEis unchanged andbootstrap_expiry_hoursis unchanged.Four properties are load-bearing:
admin-unlockships on (ADR 0171), argued there and not restated._upsert_ad_userassigns no role, so one completed directory sign-in leaves a roleless row and a non-empty table; an emptiness guard would refuse in exactly the state where the install has no way in.--passwordand no--password-file. Unattended provisioning is refused in terms rather than left as a hatch that lands a standing Administrator credential in argv or on disk.adminout of the WP-3 retirement sweep.What happens on a headless NSSM restart
There is no half-claimed state to restart into, by construction. A restart sees either an install with a provisioned administrator or one without, never a partial ceremony. The interrupted-run case is separate and is handled: the row is created with no password hash, then the credential, then the role, so a crash leaves at most an enabled local account with no roles, which is denied everything by default, and a re-run with the same username completes it.
A draft of this reproduced the item's own named risk
Recorded because the way it hid is the reusable part. The draft's repair also refused a stamped
password_claimed_at-- which is what the credential write sets -- so the second interruption point left a row this command could never complete and WP-3 could never retire. A stranded install, reached through the code written to prevent one.It survived a green suite because the test named both half-written states in its docstring and exercised only the first. It was caught by an adversarial read of the write order, not by any check. The discriminator is now holds no roles: one signal instead of a conjunction, chosen because it is the only one true at both points. The test is parametrized over both, and restoring the draft's refusal reds the second parameter and nothing else.
Planted controls
Two, because a green suite is not evidence on its own.
must_change_password=Trueon the credential write (drops the claim stamp)auth.bootstrap_admin_retiredon the just-provisionedadminpassword_claimed_atrefusal restoredset_passwordparameter onlyTwo smaller corrections from the review pass
[store].path, which is the SQLite settings field: on Postgres or SQL Server it would have named a file the command never touched, so the only control covering that hazard would have misreported on two of three backends. It now reads the opened store's cross-backendpathdescriptor.Files
messagefoundry/auth/service.pyprovision_first_administrator,has_enabled_administrator, two new types;_other_enabled_admin_existsgains an optionalexclude_idso the new caller needs no sentinelmessagefoundry/__main__.pyprovision-adminsubcommand,_read_new_password, the dispatch rowmessagefoundry/api/app.pytests/test_provision_first_administrator.pytests/test_first_run_default_account.py_directory_signed_inso both 6.3.2 modules consume one measurement instead of two copiesdocs/adr/0183-*.md,docs/adr/README.mddocs/SECURITY.md,docs/BACKLOG.mdChecks run
ruff check-- passed (whole repo)ruff format --check-- passed (whole repo)mypy messagefoundry messagefoundry_webconsole --exclude 'messagefoundry/tray/'-- 284 files, clean. This is the command CI runs;pre-commitdoes not run mypy.pytest -k "auth or bootstrap or admin or provision or cli or doc or adr"-- 2236 passed, 207 skippedmessagefoundry adr-analyze-- zero coverage gaps for 0183; every AC link resolvesscripts/docs/backlog_status_check.py,backlog_citation_check.py-- clean/simplify-- four review agents; findings applied or explicitly skipped (see below)Not run: the full suite. The slice above covers everything this change touches. Postgres and SQL Server legs run on hosted runners only.
Legs somebody must read after this process exits
windows-service-smoke(NSSM). Directly relevant: the item's named risk is the headless restart path, and that leg is the only one that exercises the service wrapper. It never runs locally.set_passwordandset_user_notify_emailare now called from a new path on all three backends.Deliberately not done
_ensure_bootstrap_adminand the WP-3 lifecycle, which is what decides the cell. Measured atinitialize()'s 197 call sites across 64 files, plus the expiry settings,_emit_bootstrap_admin, thebootstrap_admin_expiringalert, six documents and four IDE files. That work is now unblocked rather than merely scoped: the replacement way in exists and is tested, so the deletion no longer has to invent one under the stranding risk.scripts/dev/sqlserver-docker.ps1:64, whose hard-coded defaultsapassword is in scope only under the wider reading of this cell's corpus. That scope question is unsettled and is carried into ADR 0183's To resolve on acceptance.provision_first_administratorwithcreate_local_user. They now do the same four writes in different orders, and the order here is a durability property. The reuse reviewer is right that the two must be kept in step by attention; the deeper fix is a "the credential is holder-typed" parameter oncreate_local_user, and it is recorded in the ADR rather than taken here.has_notifiable_admin's own docstring already records that as its own item.Ruling wanted, not blocking
The refusal is wider than the bootstrap guard by design: it makes provisioning available whenever no enabled administrator exists, not only on a virgin store. That overlaps BACKLOG #1236's subject. It grants nothing to a network attacker -- same host boundary as
admin-unlock-- but it is a new standing affordance. It shipped rather than being held because a builder cannot ask and wait, and because the narrower alternative is measured to refuse where an install has no way in. If the owner rules the other way, the change is one predicate.🤖 Generated with Claude Code