Skip to content

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
mainfrom
worktree-agent-a8e3ffe2bf940addc
Open

feat(auth): provision the first administrator offline -- the not-present arm of ASVS 6.3.2 (BACKLOG #1136, ADR 0183)#924
wshallwshall wants to merge 2 commits into
mainfrom
worktree-agent-a8e3ffe2bf940addc

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

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_admin is untouched, so the shipped default still mints an enabled account named admin, 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 first serve. The seeding path fires only on an empty user table, so an operator-named administrator pre-empts it and the account named admin never 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_USERNAME is unchanged and bootstrap_expiry_hours is unchanged.

Four properties are load-bearing:

  1. The gate is host access -- the service config, the store path and, on an encrypted store, the key material. The same gate admin-unlock ships on (ADR 0171), argued there and not restated.
  2. The refusal asks for an ENABLED ADMINISTRATOR, not an empty table. This corrects the researched guard. _upsert_ad_user assigns 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.
  3. 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.
  4. The credential is claimed at creation. There is no claim ceremony and therefore no half-claimed state. It also keeps an operator-chosen admin out 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.

Plant Reds Evidence
must_change_password=True on the credential write (drops the claim stamp) AC-1 and AC-7 only captured audit log shows auth.bootstrap_admin_retired on the just-provisioned admin
the draft's password_claimed_at refusal restored AC-4's set_password parameter only separates the two interruption points; without that parameter the plant passes

Two smaller corrections from the review pass

  • The mistyped-target substitute read [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-backend path descriptor.
  • The PHI security-notice gate message named this command as a remedy for the instance that had just refused -- where it declines, because by then an enabled Administrator exists. Rephrased as a fresh-install instruction. That is SDS-3.4 applied to the one string this change touched. The gate itself is not weakened; only its message changed, per correction (1) on the item's work list.

Files

File Why
messagefoundry/auth/service.py provision_first_administrator, has_enabled_administrator, two new types; _other_enabled_admin_exists gains an optional exclude_id so the new caller needs no sentinel
messagefoundry/__main__.py the provision-admin subcommand, _read_new_password, the dispatch row
messagefoundry/api/app.py one error-message string
tests/test_provision_first_administrator.py new, 17 tests
tests/test_first_run_default_account.py extracts _directory_signed_in so both 6.3.2 modules consume one measurement instead of two copies
docs/adr/0183-*.md, docs/adr/README.md the owed decision record plus its index row, same commit
docs/SECURITY.md, docs/BACKLOG.md operator documentation and the item's record

Checks 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-commit does not run mypy.
  • pytest -k "auth or bootstrap or admin or provision or cli or doc or adr" -- 2236 passed, 207 skipped
  • messagefoundry adr-analyze -- zero coverage gaps for 0183; every AC link resolves
  • scripts/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.
  • The Postgres and SQL Server store legs. Nothing here changes a store statement, but set_password and set_user_notify_email are now called from a new path on all three backends.

Deliberately not done

  • Retiring _ensure_bootstrap_admin and the WP-3 lifecycle, which is what decides the cell. Measured at initialize()'s 197 call sites across 64 files, plus the expiry settings, _emit_bootstrap_admin, the bootstrap_admin_expiring alert, 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.
  • The scorecard re-score. Not in this repository, and not a builder's act.
  • scripts/dev/sqlserver-docker.ps1:64, whose hard-coded default sa password 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.
  • Unifying provision_first_administrator with create_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 on create_local_user, and it is recorded in the ADR rather than taken here.
  • Unifying the four open-coded "who is an enabled administrator" enumerations. has_notifiable_admin's own docstring already records that as its own item.
  • Extracting the ninth copy of the CLI settings-load/store-open prologue. Outside this diff by eight handlers.

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

wshallwshall and others added 2 commits September 5, 2026 18:50
… 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>
@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-red A required check went red. Attribute it before retrying.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant