ADR-033 Phase D: controlled LAB provisioning runbook¶
Status: owner-gated procedure; never standing authorization. The first
owner-authorized Exercise 1 reached server-side VERIFIED but its generated
key was not persisted after a caller-side configuration error. The resulting
partial LAB state requires the separately reviewed cleanup below. Every network
step, including cleanup or retry, requires a new explicit owner authorization
naming the disposable LAB target and execution window.
Fixed exercise identity¶
- Username:
pfsense-mcp - Purpose: dedicated exclusively to
pfsense-mcp-server; never shared with a person or other automation. - User description:
Dedicated service account for pfsense-mcp-server - API-key description:
pfsense-mcp-server primary API key - Target profile:
write_protected, mechanically derived bywrite_protected_profile_requirements()and cross-checked against the fresh target schema. Its steady state is the existing 41-privilege READ profile plus onlyapi-v2-firewall-alias-patch, the privilege unique toWriteEndpoints.FIREWALL_ALIAS_DESCRIPTION. - Temporary privilege:
api-v2-auth-key-post, present only between the explicitly verified grant and revoke steps. - Normal authentication: the generated API key. The random password and HTTP Basic Auth are bootstrap-only and must never become runtime configuration.
The account must be enabled and must not hold page-all, an administrator
role/group, an unrelated convenience privilege, or the temporary bootstrap
privilege in steady state. The profile does not make WRITE default-reachable:
the project's capability, authorization, confirmation, and Tier 1 gates remain
independent.
Owner authorization and preflight inputs¶
Before the execution window, the owner must approve all of the following as one fixed ceremony:
- The exact disposable pfSense LAB HTTPS origin and expected appliance identity.
- TLS verification mode. Strict public trust or one fixed CA-file path is
required;
verify=False, redirects, and origin changes are prohibited. This LAB uses repository modePFSENSE_TLS_MODE=autowith the existing fixed CA path supplied asPFSENSE_TLS_CA_FILE.custom_cais not a valid mode.tls.resolve_verify()must resolve to that CA path, preserving HTTPS certificate and hostname verification;insecureis prohibited. - API version
v2and an installedpfSense-pkg-RESTAPIversion within the repository's verified range (v2.7.7throughv2.10.0). A version outside that range stops the exercise. - A freshly fetched OpenAPI schema from that same authenticated target. Every
required privilege must resolve as
SOURCE_CROSS_CHECKED; missing, ambiguous, or source-disagreeing evidence stops before mutation. - A transient administrator API credential authorized to perform only this ceremony. It must not be written by the bootstrap code or appear in command arguments, logs, shell history, evidence, or reports.
- A non-existent
PFSENSE_API_KEY_FILEdestination in a trusted, owner-controlled directory. Existing path, symlink, unsafe ownership/mode, or inability to create mode0600stops before account creation. - An exclusive administrative window: no person or automation may create,
delete, disable, rename, or change privileges on
pfsense-mcpfrom the initial user read until final verification completes. - A manual recovery operator who retains administrator access for the whole window and has reviewed the interruption table below.
The reviewed one-shot driver must construct an admin HttpTransport and a
single-use BasicAuthHttpTransport factory for exactly the same HTTPS origin
and TLS trust. It must call provision_service_account() directly; it must not
add a CLI command, application-startup hook, MCP tool, generic dispatch, or
runtime bootstrap behavior.
Authentication-method transition boundary¶
The 2026-08-19 LAB exercise established a load-bearing operational fact. A
settings save from exact KeyAuth to KeyAuth + BasicAuth persisted, while
the immediately following REST API reads timed out. The owner restored exact
KeyAuth through the WebGUI; two later, independent KeyAuth reads proved the
safe steady state and API health. Cleanup and provisioning did not continue.
Pinned pfSense-pkg-RESTAPI v2.10 source shows that the settings endpoint
writes configuration, always applies the settings model, and starts its apply
dispatcher asynchronously. It does not promise that the connection used
for the settings PATCH remains usable, or that the REST API is immediately
available after the save. The package source does not establish one specific
daemon/restart mechanism for the observed timeout, so operators must not claim
one. The supported conclusion is narrower: connection continuity and
immediate availability across this save are not guarantees.
Any future separately authorized transition must use the closed
AuthMethodTransitionCoordinator and obey all of these rules:
- Capture exact
KeyAuthplus a digest of every unrelated returned setting. Any other initial method set stops without mutation. - The only enable payload is
KeyAuth + BasicAuth; the only restore payload is exactKeyAuth. Each transition is submitted at most once. - A timeout/disconnect after submission is indeterminate, never proof of failure and never permission to resend.
- Discard every pre-transition transport. Construct a fresh transport for each bounded health/verification attempt.
- Independently confirm either the expected state, the unchanged pre-state, or an unexpected/unobservable state. Verify the unrelated-settings digest.
- Verify restoration only through a newly constructed KeyAuth transport; do not depend on BasicAuth, which restoration intentionally removes.
- Exhausted reconnects, malformed evidence, sibling-setting drift, or an
unexpected method set yields
OUT_OF_BAND_RECOVERY_REQUIRED. Stop all cleanup/provisioning. Use the WebGUI recovery procedure and independently prove exactKeyAuthbefore any later owner decision.
The expected final steady state is always exact KeyAuth. The coordinator is
offline-built and remains absent from CLI, application, MCP, cleanup, and
provisioning entry points. It never auto-chains into another operation.
Initial authoritative checks¶
Perform and retain secret-free evidence for:
- Local repository HEAD equals the owner-authorized SHA; working tree clean; CI and CodeQL green.
- Public contract remains 42 tools, zero default-reachable WRITE; write
allow-list remains exactly
FIREWALL_ALIAS_DESCRIPTION. - Bootstrap engine remains absent from server, application, factory, security CLI/doctor, and MCP tool imports.
- TLS certificate and configured origin match the approved target.
- Fresh package version and OpenAPI schema satisfy the derivation gate.
GET /api/v2/usersreturns either nopfsense-mcprecord (new-account exercise) or exactly one owner-created, enabled, project-dedicated record whose complete starting privilege set has been recorded (separately authorizedPRIVILEGES_SYNCEDexercise). A duplicate, disabled account, unexplained existing account, or existingapi-v2-auth-key-poststops.- The key destination is absent. Never delete or overwrite an existing file merely to make this check pass.
New-account transaction¶
The expected sequence is fixed:
NOT_STARTED: authoritative users read confirms the name is absent.- Create one enabled
pfsense-mcpuser with the fixed description, random in-memory password, and exact derived steady-state privileges. - Independent users reread proves exact account identity and privileges;
transition to
USER_CREATED. - Grant exactly the steady-state set plus
api-v2-auth-key-postusing the administrator transport. - Independent reread proves the exact set; transition to
BOOTSTRAP_PRIVILEGE_GRANTED. - Construct one
BasicAuthHttpTransportfor the same origin/TLS trust and make its single self-servicePOST /api/v2/auth/keycall with the fixed key description. No redirect or retry is permitted; transition toKEY_GENERATEDonly on a valid response. - Revoke
api-v2-auth-key-postusing the administrator transport. - Independent reread proves the exact steady-state privilege set and absence
of the temporary privilege; transition through
BOOTSTRAP_PRIVILEGE_REVOKEDtoVERIFIED. - Store the returned key with
config.store_api_key(). This must exclusively create the approvedPFSENSE_API_KEY_FILE, force mode0600, fsync, reread through descriptor-bound validation, and never print the value. A custody failure is not a successful provisioning outcome and requires the manual response below. - With the new API key loaded through
load_api_key(), perform only the separately authorized read-only verification necessary to prove the credential belongs to the expected account and its required READ calls succeed. Do not exercise the alias PATCH during bootstrap Phase D.
Existing-account PRIVILEGES_SYNCED exercise¶
This is a distinct, separately selected case; do not fall into it accidentally. The starting account must have been deliberately prepared by the owner for this LAB evidence and must not be shared.
The engine performs an initial read, then a final authoritative read immediately before PATCH. It sends the union of the final pre-mutation privilege set and the derived target set. After PATCH, an authoritative reread must prove:
- every target privilege is present;
- every privilege in the final pre-mutation snapshot remains present;
- the same enabled account ID/name remains selected; and
api-v2-auth-key-postis absent.
Only then is PRIVILEGES_SYNCED honest. pfSense exposes no revision/CAS token,
so this proves preservation relative to the final pre-mutation snapshot, not
the absence of a change in the remaining read-to-PATCH interval. The exclusive
administrative window is therefore a mandatory precondition, not an inferred
software guarantee. Unexpected state yields FAILED or
BLOCKED_EXISTING_PARTIAL, never success or automatic retry.
Interruption and manual response¶
No failed state automatically resumes, rolls back, retries, deletes an account, or revokes an API key.
| Last proven state | Authoritative observation required | Manual response |
|---|---|---|
NOT_STARTED |
Reread users | If absent, no appliance cleanup. If present unexpectedly, stop for owner review. |
USER_CREATED |
Confirm account identity, enabled state, and full privileges | Owner decides whether to retain or explicitly delete the disposable account. The runner does neither. |
BOOTSTRAP_PRIVILEGE_GRANTED |
Confirm complete privileges and temporary privilege | Treat temporary elevation as present. Owner performs a separately reviewed full-list PATCH removing only api-v2-auth-key-post, then rereads. |
KEY_GENERATED |
Confirm account privileges and enumerate auth-key metadata without exposing key material | Treat the key as issued even if its response/custody is uncertain. Revoke the temporary privilege first; owner then decides whether to revoke the orphan key or recreate the disposable account. Never retry key creation blindly. |
BOOTSTRAP_PRIVILEGE_REVOKED |
Prove exact steady-state privileges and absence of temporary privilege | If key custody succeeded, continue final verification. If custody failed, stop for explicit orphan-key/account remediation. |
VERIFIED |
Repeat final account and key-file checks | Record success only if account state and secure key custody both verify. |
FAILED or process interruption |
Fresh users/auth-key observations; never trust the process-local transaction object | Stop. Classify actual server state using this table and obtain explicit owner approval for any cleanup mutation. |
If PFSENSE_API_KEY_FILE creation fails after key issuance, do not print or
copy the key through an unreviewed channel. The account/key is partial state,
not success. Preserve the secret only through an owner-approved secure custody
mechanism or remediate the orphan key/account explicitly.
Closed orphan-key/account cleanup¶
Cleanup is not part of the successful bootstrap state machine and never chains
into provisioning. It uses only security_bootstrap_recovery.py's two fixed
functions and security_bootstrap_client.py's private transport projections:
revoke_failed_bootstrap_api_key()performs twoGET /api/v2/auth/keysobservations, selects exactly one key whose stable integer ID, ownerpfsense-mcp, and fixed key description agree, sends exactly oneDELETE /api/v2/auth/keybody containing only that ID through a separate single-use administratorBasicAuthHttpTransport, then rereads all key metadata. Upstream v2.10 marks this singular endpoint Basic-Auth-only; the ordinary administrator API-key transport remains read-only in this action. It succeeds only if the ID is absent and every unrelated key's complete non-secret metadata is unchanged.delete_dedicated_recovery_user()freshly derives the exactwrite_protectedprivilege set, performs twoGET /api/v2/usersobservations, requires exactly one enabledscope=useraccount with the fixed name/description, exact target privileges and nopage-all, and twice proves no API key is owned by that username. It sends exactly oneDELETE /api/v2/userbody containing only the stable user ID, then rereads users and keys. It succeeds only if the ID/name and owned keys are absent and unrelated observed user metadata is unchanged.
The exact future owner-authorized cleanup sequence is:
- Diagnose the partial state with authoritative user and key-metadata reads.
- Identify exactly one orphan key by owner, fixed description, stable ID and complete non-secret metadata. Zero, duplicate, or changed matches stop.
- Construct one single-use administrator Basic-Auth transport for the exact
TLS origin. Call
revoke_failed_bootstrap_api_key()once. Never retry DELETE or persist the administrator password. - Independently require the selected ID absent and unrelated keys unchanged.
- Re-derive the target profile and revalidate the exact disposable user,
including
scope=user, enabled state, fixed identity and no remaining key. - Call
delete_dedicated_recovery_user()once. Never retry DELETE. - Independently require zero matching account/key records and unrelated users unchanged.
- Stop. Cleanup success does not authorize provisioning.
- Obtain separate owner authorization before any Exercise 1 retry.
The API provides stable integer IDs but no revision/CAS primitive. Two fresh reads narrow, but cannot eliminate, ID reuse or concurrent-change risk between the last read and DELETE. The exclusive administrative window is mandatory. Any transport uncertainty or postcondition mismatch is partial success: authoritatively reread and return for owner review, never resend automatically.
Persistence decision¶
Cross-process bootstrap-transaction persistence is deferred for the first supervised, one-shot disposable-LAB exercise. This is acceptable only because:
- one operator owns an exclusive, bounded execution window;
- the operation runs synchronously in one process;
- every mutation has an immediate independent authoritative reread;
- interruption causes a hard stop, never automatic resume;
- server-side state is freshly reobserved and manually classified; and
- administrator access and the recovery table remain available throughout.
Persistence is mandatory before bootstrap is exposed through a normal
administrative CLI, used unattended, scheduled, used concurrently, or promoted
beyond a disposable controlled LAB. Offline Slice 1 now implements the
foundation in security_operation_journal.py: an authenticated chained journal
and head, an operation-attributed exclusive local lock, and a pure restart
classifier. It does not reuse Tier 1's RecoveryContract and remains unwired.
The journal must record the send-intent boundary before every future mutation. After interruption, only an exact pre-send checkpoint plus matching fresh authoritative state is resumable. A send-intent or unknown-result checkpoint is never resend authority. Stale/missing/foreign lock evidence, partial server state, pending final verification, or corrupt/untrusted local state blocks new bootstrap and requires the classifier's explicit result. Recovery remains a separate owner-directed action and stops after its verified postcondition; it never automatically continues into provisioning.
This runbook still does not authorize CLI composition. The journal integrity key, paths, component construction, and operator command surface must be wired only by separately reviewed later slices.
Offline CLI/runtime integration Slice 2 now fixes the component-construction
boundary in security_admin_composition.py. The administrative process must
receive explicit secure references for the target, TLS trust, administrator
KeyAuth and BasicAuth files, service-key custody, owner-only state directory,
journal MAC key, captured schema/version evidence, and installed package
version. It never searches for credentials or state. The account
(pfsense-mcp), descriptions, profile (write_protected), API version, and
target-derived journal/lock names are fixed.
Composition authenticates and target-checks any existing journal, inspects the matching lock, validates source-cross-checked privilege evidence, and creates no files, clients, requests, or mutations. Its public surface is read-only restart status only. The fixed bootstrap/recovery/transition bindings remain private and cannot be invoked by a command in this slice. Therefore this runbook still does not authorize or describe a runnable command: journal-aware mutation orchestration, explicit human confirmation, custody sequencing, and command registration remain separate review gates.
Final verification and stop¶
Before declaring the exercise complete, independently prove:
- exactly one enabled
pfsense-mcpaccount exists; - its description is the fixed owner-approved value;
- its privileges equal the freshly derived 42-privilege
write_protectedset; - it lacks
page-all, administrator membership, unrelated privileges, andapi-v2-auth-key-post; - the generated key is usable for approved READ requests and is stored only at the approved owner-only key file;
- Basic Auth credentials were not persisted and are not normal runtime config;
- bootstrap remains absent from normal CLI/application/MCP imports;
- public MCP remains 42 tools with zero default-reachable WRITE; and
- no alias mutation, Nexus access, TPM/witness mutation, release, or publication occurred.
Stop after evidence capture. Phase D does not authorize runtime wiring or any subsequent phase.