ADR-033: pfSense least-privilege bootstrap architecture (setup-wizard phase)¶
- Status: §§1-2 (privilege derivation and drift detection) and §5
(transaction state model) are implemented as of Phase B
(2026-08-17) -- see "Implementation Phase B" below. The actual HTTP
provisioning sequence (§3's read-before-write/partial-failure model,
§9's operation list) is implemented, offline-tested only, as of
Phase C (2026-08-17) -- see "Implementation Phase C" below. §4
(
doctorintegration) remains research/design only --doctorandbootstrapremain deliberately separate subsystems. A later, explicitly owner-authorized Phase D LAB exercise partially executed and left the recovery state documented below; it created no standing live authority. CLI/runtime integration Slices 1-3 (durable operation journal, fixed administrative composition, and journal-aware locking orchestration + thepfsense-mcp-security bootstrapsubcommand) are implemented, offline-verified only, as of 2026-08-23 -- see "CLI/ runtime integration Slice 3" below. Owner-authorized 2026-08-17 (initial research pass), 2026-08-17 (Phase B), 2026-08-17 (Phase C), and 2026-08-23 (CLI Integration Slice 3), each explicitly scoped, each gating a later, separately-authorized implementation phase. Update (2026-08-26):pfsense-mcp-security setup(Slices 1-7) is now fully implemented, includingsetup applycomposing this engine forwrite_protected. Under an explicit, ceremony-specific owner authorization the owner, present as recovery operator, executed a bounded live LAB ceremony --bootstrap(and standalonerecover's read-only inspection) were genuinely run against the real disposable LAB appliance with zero unintended mutation; seereports-ai/LIVE_LAB_CEREMONY_2026-08-26.md. That authorization was scoped to that one ceremony and created no standing live authority -- every future live invocation still requires its own fresh, ceremony-specific authorization, exactly as before. Wiring this engine into normal application startup or MCP tool registration remains unauthorized and unimplemented. - Scope: Establishes the exact current minimum privilege
requirements (READ and existing WRITE), the authoritative derivation
mechanism, and the proposed bootstrap security/rollback model. Does
not modify
pfsense-mcp-security doctor, Tier 1, orADR-031's backend/target identity boundary in any way.
Implementation Phase B (2026-08-17)¶
Owner-authorized as "privilege derivation and drift detection only" -- explicitly excluding pfSense user creation, privilege mutation, API-key creation, and any other provisioning action. Implements §§1-2 and §5 below as real, tested, pure production code:
src/pfsense_mcp/security_privileges.py(new):compute_privilege_from_url()(the pureget_method_priv_name()reimplementation),lookup_schema_privileges()/resolve_privilege()(the fail-closed schema+source combination §2/§3 describe),read_profile_requirements()/write_protected_profile_requirements()(derived mechanically fromtools/read/*.py's own source via the same AST techniquescripts/public_contract.pyalready uses, plusWriteEndpoints.active_entries()-- never a hand-maintained tool list),compute_account_drift()/check_package_version_support()(the §4 drift model, with the three evidence classesSCHEMA_DERIVED/SOURCE_CROSS_CHECKED/ACCOUNT_OBSERVEDthis ADR's own "clearly distinguish evidence classes" requirement named). No network access anywhere in this module -- every function takes an already-fetched schema dict; none fetches one.src/pfsense_mcp/security_bootstrap_transaction.py(new): the §5 transaction state model --BootstrapState's six-state lifecycle,allowed_next_states()/is_legal_transition()as the one source of truth for legal transitions, andcheck_invariants()enforcing the one invariant this phase was explicitly asked to make explicit: the temporaryapi-v2-auth-key-postbootstrap privilege must never be present once a transaction reachesBOOTSTRAP_PRIVILEGE_REVOKED/VERIFIED. No HTTP operations, no pfSense contact -- a pure state container an eventual, separately-authorized implementation would drive.- Regression-verified against real evidence: both the 41-privilege
READ set and the 42-privilege
write_protectedset this document's §1 states are reproduced exactly by the new code, cross-checked againsttests/fixtures/pfsense_openapi_schema_trimmed.json-- a real, trimmed (42-path) subset of the actual live schema captured duringADR-026's provisioning work, not synthetic data. - 77 new tests (
tests/test_security_privileges.py,tests/test_security_bootstrap_transaction.py,tests/test_security_privileges_isolation.py) covering every case §7's requirement list named: known-good schema, missing/malformed/ ambiguous/duplicate privilege metadata, endpoint/method mismatch, schema/source agreement and disagreement, supported and unsupported package versions, missing/unexpected/exact account-privilege drift, multiple simultaneous findings, deterministic ordering, and every transaction-state invariant. - Not wired into
security_cli.pyorsecurity_doctor.py-- proven by a dedicated isolation test, not merely by omission. §6'sdoctor-integration candidates remain undone, exactly as this phase's own scope required.
Implementation Phase C (2026-08-17)¶
Owner-authorized as "isolated provisioning engine, offline-tested only" -- authorizing production HTTP provisioning code for the first time, explicitly not authorizing live provisioning against any appliance and explicitly forbidding CLI/runtime wiring. Composes Phase B's primitives with a new, narrow HTTP surface:
src/pfsense_mcp/security_bootstrap_client.py(new): the third module (alongsiderest_api_client.py/write_api_client.py) ever permitted to call aTransport'srequest()directly (scripts/get_only_check.py's allow-list updated accordingly). Exposes exactly four named operations, no generic dispatch:list_users()(GET /api/v2/users),create_user()(POST /api/v2/user),update_user_privileges()(PATCH /api/v2/user, full-replace semantics),create_auth_key()(POST /api/v2/auth/key, must be called on a Transport authenticated as the target account itself). Payload/response shapes are not guessed -- transcribed directly from the real, already-executed, already-authorized live provisioning procedureADR-026's acceptance-matrix work performed (2026-08-16, thepfsense_mcp_tier1_labidentity's creation/key-generation/privilege grant-revoke sequence). The returnedProvisionedApiKey's secret is reachable only via an explicitreveal()method -- never present inrepr()/str()/an ordinary exception message.src/pfsense_mcp/security_bootstrap_engine.py(new):provision_service_account(), the orchestrator. Read-before-write throughout: every mutation is preceded by an observation and followed by an independent re-read that confirms it (never trusts a mutating call's own response echo). Derivation gate is stricter thandoctor's advisoryDriftReport.cleanposture -- a missing schema, any privilege not reachingSOURCE_CROSS_CHECKEDconfidence, or an unsupported package version refuses the whole attempt before any HTTP call. Three top-level paths: (1) account absent → full transaction sequence (NOT_STARTED → USER_CREATED → BOOTSTRAP_PRIVILEGE_GRANTED → KEY_GENERATED → BOOTSTRAP_PRIVILEGE_REVOKED → VERIFIED, driven throughsecurity_bootstrap_transaction.py's state machine exactly); (2) account present and already exactly correct → no-op (ALREADY_SATISFIED, mirroringsecurity_plan.py'sNO_CHANGEpattern); (3) account present but missing some expected privileges → additive-only sync (adds exactly the missing privileges, never touches an unrelated extra privilege already present, never re-creates or re-keys the account). An account already holding the temporary bootstrap privilege is treated as evidence of an interrupted prior run and is refused (BLOCKED_EXISTING_PARTIAL) -- no automatic resume, matching §"Rollback/recovery"'s stated discipline.- No automatic compensating mutation on failure -- if API-key
creation fails after the bootstrap privilege was already granted, or
revocation itself fails after a key was already issued, the engine
does not attempt to auto-revoke or auto-clean-up; it returns a
FAILEDoutcome whoseBootstrapTransaction.failure_detailnames exactly which step failed and states plainly that the temporary privilege is still present and requires manual/operator-reviewed remediation. - Self-service authentication is caller-supplied. Phase C initially
had no Basic-Auth-capable
Transport. A later offline implementation addedtransport/http.py's deliberately single-useBasicAuthHttpTransport: HTTPS/TLS-verification-only, no redirect or retry, strict credential validation, automatic client disposal after one attempt, and no X-API-Key fallback.provision_service_account()still takes aself_service_transport_factory(username, password) -> Transportparameter rather than building one itself. Offline integration tests bind the new transport at that existing seam; no CLI/application runtime path does. Selecting the real target/TLS configuration and invoking it live remain future, separately-authorized work. - CLI/runtime isolation, proven mechanically: neither new module is
referenced by
server.py,application.py,factory.py,security_cli.py,security_doctor.py, or any file undertools/read/-- a dedicated isolation test (tests/test_security_bootstrap_engine_isolation.py) asserts this by direct source inspection, not by omission. The only way to invokeprovision_service_account()in this build is a direct Python import. - 45 new tests (
tests/test_security_bootstrap_client.py,tests/test_security_bootstrap_engine.py,tests/test_security_bootstrap_engine_isolation.py) covering every case requirement 10 named: already-correct account, account absent, wrong/missing expected privileges, unrelated additional privileges preserved, both target profiles, unsupported package version, schema/source disagreement, malformed evidence, each of the four named HTTP operations, account-creation/privilege-update/API-key- creation/revocation/final-verification failure at its own named checkpoint, partial-state reporting, retry/re-entry/idempotency, API-key secret redaction, no temporary privilege in the successful steady state, and no Tier 1 interaction. Full suite: 3032 → 3077. - Zero live pfSense calls made or possible in this build -- neither
new module imports
httpx/requests/socket/urllib(isolation test); every test drives an in-memory fakeTransport.
Offline Phase D prerequisite closure (2026-08-19)¶
No live phase was started. The existing PFSENSE_API_KEY_FILE reader
now has a matching config.store_api_key() custody primitive:
exclusive non-following creation, mode 0600, complete write + fsync,
descriptor-bound reread verification, failure cleanup, and no secret in
errors. It remains unwired from CLI/runtime/bootstrap execution.
PRIVILEGES_SYNCED now means that an authoritative final pre-PATCH
snapshot was taken, the same enabled account remained selected, every
required privilege was observed after PATCH, and every privilege in
that final snapshot remained present. The API supplies no revision/CAS
primitive; the result therefore requires an exclusive administrative
window and does not claim to eliminate the residual read-to-PATCH
TOCTOU interval. The future controlled procedure and interruption
handling are fixed in
ADR033_PHASE_D_LAB_RUNBOOK.md.
Cross-process bootstrap persistence remains deliberately deferred for
the first supervised one-shot LAB exercise.
Summary¶
This project's Tier 1 architecture and its one live-verified WRITE capability already prove least-privilege pfSense credentials work in practice — a scoped identity with exactly 4 privileges executed two real mutations against a disposable LAB appliance (ADR-026). What has never existed is a general, evidence-backed answer to "what is the minimum privilege set for any current configuration of this project, and how would an operator safely reach it" — the WRITE matrix was derived once, by hand, for one capability. This ADR produces that general answer for the full 42-tool READ surface plus the existing WRITE capability, and designs (without implementing) how a future bootstrap operation would apply it safely.
1. Current privilege requirements (evidence, not design)¶
Full matrix, methodology, and live cross-validation:
PFSENSE_LEAST_PRIVILEGE_MATRIX.md.
Summary:
- A. Default READ-only operation: 41 distinct privileges, one per
tool (41 of the 42 registered tools call exactly one pfSense
endpoint each; the 42nd,
pfsense_mcp_info, needs none). Zero overlap between tools — each privilege is used by exactly one tool. - B. Existing WRITE capability (
set_firewall_alias_description_v1): 4 privileges, re-derived independently this pass and found unchanged from ADR-026's live-provisioned values — 3 of the 4 overlap with the READ set (firewall-aliases-get,status-system-get,system-hasync-get); onlyapi-v2-firewall-alias-patchis WRITE-exclusive. Combined READ+WRITE minimum: 42 distinct privileges. - C. Setup/bootstrap-only privileges: exactly one, already
discovered and live-tested during ADR-026's own provisioning:
api-v2-auth-key-post— required only because pfSense's REST API key model (RESTAPIKey, confirmed from source) hardcodes self-service key generation (username = $this->client->username), so a brand-new identity cannot obtain its first API key without briefly holding this one privilege. Confirmed temporary and revocable without invalidating the already-issued key (live-tested in ADR-026: the scoped credential kept working after this privilege was revoked).
Do not assume previously documented privilege IDs remain correct was honored literally: every value above was independently re-derived from the pinned package source and independently re-cross-checked against a real captured live OpenAPI schema this pass, not copied forward from ADR-026's prose. All matched.
2. Derivation mechanism (evidence, not design)¶
Full detail in the matrix document's "Method" section. Summary:
- Source:
pfrest/pfSense-pkg-RESTAPI'sCore/Endpoint.inc::get_method_priv_name()— a deterministic slug of the endpoint's URL plus lowercase HTTP method (/and_→-, strip leading-). - Version stability: confirmed byte-identical across
v2.7.7throughv2.10.0(the current latest tag) — the algorithm has never changed in this project's observed history. This is evidence, not a guarantee for all future versions; see "Detecting privilege drift" below for how a future implementation should treat this. - A materially better live-verification path than re-deriving from
GitHub each time was found this pass: the installed package's own
Schemas/OpenAPISchema.incembeds every operation's exact allowed privileges directly in the live OpenAPI schema'sdescriptiontext ("**Allowed privileges**: [ page-all, api-v2-status-system-get ]"). This project already fetches this schema for endpoint-catalogue purposes — a future implementation should parse this live text as the primary, always-current source, falling back to (or cross-checking against) the pinned-source algorithm only for privileges not yet observable live (e.g. planning a new capability against a target appliance version not yet running). - Ambiguous/renamed/missing detection: an endpoint whose privilege string cannot be found in a freshly-fetched live schema, or whose live schema value disagrees with the pinned-source-derived value, must be treated as unresolved, not assumed — see "Detecting privilege drift" below.
requires_page_all_privilege: 1 of 268 endpoints package-wide hard-codespage-allas the only accepted privilege (a POST-only sync action this project doesn't use). None of the 41 READ privileges or 4 WRITE privileges require it — reconfirmed this pass, not assumed from ADR-026. A future implementation adding any new capability must re-check this per endpoint; it is not a general guarantee.- Modifies nothing: every fetch this pass was a plain HTTPS GET against public GitHub source/tags, or a read of an already-locally-cached live schema snapshot from a prior, separately-authorized session. No installed package file was touched.
3. Bootstrap security model (design, not implemented)¶
Existing user vs. dedicated service account¶
Always a dedicated service account, never the operator's own admin account or an existing shared account. ADR-026's own live evidence already proves this pattern works end-to-end; nothing here changes it. A future bootstrap flow must refuse to proceed against an account it did not itself create and cannot uniquely identify as project-dedicated (e.g. by a recognizable, configurable username prefix/convention), to avoid silently altering an unrelated existing account's privileges.
READ-only vs. write_protected profile¶
Directly follows §1: a read_only-profile deployment needs the 41 READ
privileges only; a write_protected-profile deployment needs the
combined 42. The bootstrap flow must ask which profile the operator
is provisioning for and grant exactly that profile's set — never both
"just in case," never the WRITE set for a read_only deployment.
Setup-specific elevated privileges are temporary, not incidental¶
The one known case (api-v2-auth-key-post) must be modeled as a named,
temporary, always-revoked-before-completion state in any future
transaction design — not simply "one more privilege in the initial
grant that happens to also get removed later." A bootstrap operation
that crashes or is interrupted after granting this privilege but
before revoking it must leave that fact visibly reported, not
silently forgotten (see "Partial failure" below).
Preventing privilege escalation¶
- The bootstrap identity that performs provisioning (the operator's
existing admin credential, used once, interactively, never stored)
is categorically different from the provisioned identity being
created — the design must never let the provisioned identity grant
privileges to itself or any other account.
POST /api/v2/userandPATCH /api/v2/userboth require an authenticated admin-equivalent caller in this package's model; the provisioned scoped identity is never used to call them. - A future bootstrap must compute the exact target privilege set
from §1's matrix (or its live-schema equivalent) and grant exactly
that set — never a broader named pfSense role, never
page-all, never an administrative group, regardless of how convenient that would be to implement.
Preserving existing unrelated privileges¶
If a bootstrap operation ever modifies (rather than creates) an
account — e.g. re-running setup after the tool's own privilege
requirements grew — it must read the account's current full
privilege list first, compute the minimal additive diff, and apply
only that diff, exactly mirroring ADR-026's own live-executed
pattern (PATCH /api/v2/user with the full desired list, always
preceded by an independent GET /api/v2/users read, always followed
by an independent re-read to confirm, never trusting the PATCH
response's own echo). Never assume an account decided to be
project-dedicated has no other privileges — verify before touching.
Privilege removal should never be automatic (except the one named bootstrap case)¶
Beyond revoking the temporary api-v2-auth-key-post bootstrap
privilege (a pfsense-mcp-server-added privilege being removed by the
same operation that added it, on the same run), no future bootstrap
operation should ever remove a privilege automatically — including
privileges that appear to have become unused after a downgrade (e.g.
read_only → nothing, or a future narrower READ tool set). Detected
"privileges wider than currently needed" should be reported
(a doctor-style finding — see §6), never silently pruned; removal
is always an explicit, separate, owner-reviewed action.
Idempotency¶
Re-running bootstrap against an already-correctly-provisioned identity
must be a no-op that reports "already satisfied" — mirroring
security_plan.py's own established AxisTransitionKind.NO_CHANGE
pattern exactly, not a new concept. Re-running against a
partially-provisioned identity (see next) must resume/complete
safely, never duplicate a step already done (e.g. never attempt to
create a user that already exists; detect and continue).
Partial failure¶
Every step of a future bootstrap transaction must be independently
observable and re-verifiable by a fresh read — matching ADR-026's own
executed discipline (never trust an API response's own echo; always
follow with a separate read). A future design should define an
explicit small state sequence for the transaction itself (e.g.
NOT_STARTED → USER_CREATED → BOOTSTRAP_PRIVILEGE_GRANTED →
KEY_GENERATED → BOOTSTRAP_PRIVILEGE_REVOKED → VERIFIED), persisted
locally (not on the appliance) so a crash mid-sequence can be resumed
or reported precisely — never guessed at from scratch.
Rollback/recovery¶
If a bootstrap operation fails partway, the safe default is leave the partial state in place and report it precisely (which step completed, which didn't) rather than attempting automatic rollback — an automatic "undo" that itself fails partway is a worse state than a clearly-reported partial success. Full teardown (deleting the created account) should be an explicit, separate, operator-confirmed action, never automatic.
Re-running setup after package upgrades¶
Directly enabled by §2's live-schema-first derivation strategy: if a future package version renames or restructures an endpoint, its privilege string changes, and re-running bootstrap against the new live schema naturally picks up the new name — as long as the design re-fetches the live schema each run rather than caching a privilege list from install time. A cached list must always be re-validated against a fresh live fetch before being trusted for a new bootstrap run.
Detecting privilege drift¶
Three independent signals, all read-only:
- Live schema's own "Allowed privileges" text disagreeing with what this project's matrix expects for a given endpoint.
- A provisioned identity's actual
privlist (fromGET /api/v2/users) disagreeing with what this project's matrix says it should hold — either broader (scope creep, possibly from manual GUI changes) or narrower (accidental revocation, possibly breaking the deployment). - The installed REST API package's reported version (already visible
via the live OpenAPI schema's own version field, or
pfsense_get_system_packages) falling outside the range this project has verified the algorithm against (v2.7.7–v2.10.0).
All three map directly to future doctor checks — see §6.
4. Credential handling¶
- No new secret store. This project already has an established
pattern (
PFSENSE_API_KEY_FILE, mode-600, exclusive-create, descriptor-bound loading — the existing "Preserve descriptor-bound API-key loading as a security invariant" roadmap commitment). A future bootstrap must write the newly-generated scoped identity's API key through this exact same mechanism, never a second one. - No credential is ever logged, printed, or embedded in an
exception/evidence string — matching
security_discovery.py's andsecurity_doctor.py's already-audited discipline for this class of problem. - The bootstrap-performing admin credential is never persisted by
this tool — supplied interactively for the duration of the
bootstrap operation only (matching how the actual ADR-026
provisioning was performed: the admin
X-API-Keywas used directly by the owner's own session, never written to a file this project manages). - The provisioned identity's password field (required by pfSense's user model even for API-key-only use, per ADR-026's own finding) should be a randomly generated value, held only transiently in memory for the duration of account creation, never written to disk at all — not even temporarily. (ADR-026's live procedure used a mode-600 scratch file for this and deleted it afterward; a future implementation should improve on this by never touching disk for the password at all, since the API key — not the password — is the credential this project actually uses afterward.)
The output half of this design is now implemented offline by
config.store_api_key(): the configured PFSENSE_API_KEY_FILE is
created exclusively through a non-following parent descriptor, forced
to mode 0600, completely written and fsynced, verified through the
existing descriptor-bound loader, and removed on a safely attributable
failure. It remains intentionally unwired from bootstrap, CLI, and
runtime paths.
5. Relationship to ADR-021's two-axis model¶
Privilege assignment follows capability requirements (§1), never anchor assurance:
| Preset | Capability posture | Anchor assurance | pfSense privilege set |
|---|---|---|---|
| READ-only | read_only |
none |
41 READ privileges |
| Software-protected WRITE | write_protected |
software |
42 (READ+WRITE) — identical to hardware-witness WRITE below; the anchor backend affects how the mutation is authorized, never which pfSense privileges the identity holds |
| Hardened hardware-witness WRITE | write_protected |
hardware_witness |
42 (READ+WRITE) — this is what ADR-026 actually live-tested |
Stronger anchor assurance must never be treated as license for
broader pfSense privileges. The TPM witness protects the integrity
of this project's own RecoveryContract store against rollback; it
says nothing about, and must never be conflated with, what the
pfSense-side identity is permitted to do. A hardware_witness
deployment and a (currently unimplemented) software-anchor
deployment need the exact same pfSense privilege set for the same
capability posture — the two axes are independent by ADR-021's own
design, and this ADR's privilege model must not quietly break that
independence.
6. doctor integration opportunities (not implemented this phase)¶
Four candidate future pfsense-mcp-security doctor checks, all
READ-only, all directly justified by §3's "Detecting privilege drift":
- Installed REST API package/version detected — read
pfsense_get_system_packages's existing data (or the live OpenAPI schema's own version field) and report whether it falls within the algorithm-verified range (v2.7.7–v2.10.0), flagging (not failing) an out-of-range version as "unverified, re-confirm before relying on the derived privilege set." - Required privilege definitions found — for a given target profile (READ-only or write_protected), fetch the live OpenAPI schema and confirm every required privilege string actually appears in some operation's "Allowed privileges" text; report any that don't (renamed/removed upstream).
- Configured service account has expected privileges — given a
configured identity,
GET /api/v2/users, compare its actualprivlist against the exact expected set for the configured profile; report both over-grant and under-grant. - Privilege drift detected — the combination of 1–3: a single
doctorverdict distinguishing "matches expected exactly," "broader than expected" (flag, never auto-narrow), and "narrower than expected" (flag, explains why the deployment may be failing).
Not implemented this phase. All four require either a live pfSense
connection (doctor today is 100% local-filesystem/witness-only, by
design — see ADR-033's own §"Safety constraints" below and
security_doctor.py's existing "known limitation" note) or new,
not-yet-reviewed code to call GET /api/v2/users/parse the OpenAPI
schema. Adding either is exactly the kind of change this ADR's own
"do not expand doctor in this phase unless independently justified"
instruction was written to gate — correctly deferred to the
implementation phase this ADR is itself gating.
7. Safety constraints (binding on any future implementation)¶
The eventual bootstrap design must not:
- Grant administrator-equivalent (
page-allor any admin group) access merely for convenience — §1/§3 establish that the narrow privilege set is sufficient and sufficient is what must be granted. - Silently broaden privileges on re-run — §3's idempotency/drift sections require any broadening to be an explicit, reported, operator-confirmed action.
- Remove unrelated existing privileges — §3's "preserving existing unrelated privileges" requires reading before writing, always.
- Derive permissions from untrusted remote text — §2's derivation sources are exactly two: pinned package source at a specific tag, and the live OpenAPI schema of the appliance being provisioned itself (not a third party's report of what it should be).
- Bypass WRITE authorization — nothing in this ADR touches Tier 1's authorization/confirmation/execution chain; pfSense privilege provisioning and Tier 1's cryptographic authorization remain independent layers (see next bullet).
- Make WRITE default-reachable — bootstrap provisions a pfSense-side
identity only; it has no relationship to, and cannot change,
WriteEndpoints/Capability/profile gating, which remain the only things that make a WRITE tool reachable at all. - Weaken Tier 1 — zero files under
tier1/are touched by this ADR or would be touched by implementing it; pfSense privilege provisioning is entirely orthogonal to theRecoveryContract/authorization chain. - Conflate pfSense RBAC with this project's cryptographic authorization
boundary — stated explicitly, as required: a pfSense privilege
answers "can this API credential technically call this endpoint,"
which is necessary but nowhere near sufficient for a mutation to
actually happen in this project — the full Tier 1 chain (plan →
authorization →
RecoveryContract→ confirmation → sealed execution) is the layer that decides whether a specific mutation is authorized, entirely independent of what the underlying pfSense credential is technically capable of. These are two independent defense layers by design, not one system with two names.
8. Evidence¶
In preference order, as required:
- Installed REST API package source —
Core/Endpoint.inc(get_method_priv_name(),get_default_privs(),requires_page_all_privilege),Core/Auth.inc(array_intersect()ANY-match),Schemas/OpenAPISchema.inc(live-schema privilege embedding),Caches/PrivilegesCache.inc(confirms pfSense's own/etc/inc/priv/restapi.priv.incgeneration mechanism — noted as a possible future on-appliance derivation source if this project ever gains local file-read access to the appliance; not usable today). All fetched frompfrest/pfSense-pkg-RESTAPIat the pinned tagv2.10.0(confirmed the current latest tag) plusv2.7.7for the stability cross-check. - Authoritative upstream documentation — none consulted beyond source directly, per this project's own established preference for source over prose wherever both exist.
- Existing independently verified LAB evidence — the previously
captured live OpenAPI schema (
pfsense_openapi_schema.json, fetched during the ADR-026 provisioning work) used to cross-validate all 42 READ privileges plus the WRITE and bootstrap privileges against a real running appliance, not merely computed values.
No privilege semantics in this document were guessed. Where evidence
was insufficient (the exact minimum-privilege guarantee for any future
package version beyond v2.10.0), this document says so explicitly
(§2, §3) rather than extrapolating.
9. Proposed CLI/user flow (illustrative, not committed)¶
Sketch only — exact naming/flags are a future implementation decision, not fixed here:
$ pfsense-mcp-security bootstrap --profile write_protected --dry-run
[reads live OpenAPI schema; computes exact 42-privilege target set]
[prompts for admin credential, interactively, never stored]
[reports the exact proposed user/privilege grant; performs nothing]
$ pfsense-mcp-security bootstrap --profile write_protected
[same, but actually executes: create user -> grant 42 + bootstrap priv
-> generate key -> revoke bootstrap priv -> verify final state]
[writes the resulting API key through the existing PFSENSE_API_KEY_FILE
mechanism; prints nothing sensitive]
A --dry-run/mandatory-confirmation split mirroring security_plan.py's
own established "a plan is never authorization" discipline is the
natural fit, not a new pattern.
10. Proposed deterministic test strategy (described, not implemented)¶
- Golden-file test asserting the derivation function's output for every
URL in
PFSENSE_LEAST_PRIVILEGE_MATRIX.mdmatches the documented value exactly (mirrorssecurity_doctor.py's owntest_artifact_path_env_var_names_match_production_runtimedrift-guard pattern). - Fixture-based tests for the bootstrap transaction's state machine (§3): each state transition, idempotent re-run, partial-failure resume, using a fake/mock pfSense client — no live appliance.
- Negative tests proving the design's own constraints (§7): the
computed grant never includes
page-all; a diff-based re-grant never drops an unrelated existing privilege from a synthetic "already has extra privileges" fixture; the bootstrap privilege is always revoked in the same transaction that added it, proven by a fault-injection test that fails the key-generation step and confirms revocation still occurs (or is clearly reported as pending, per §3's partial-failure design). - Isolation test (mirroring
security_doctor.py/security_discovery.py) proving whatever future module implements this never importspfsense_mcp.tier1and never constructs aWriteApiClient/RecoveryContract.
11. Unresolved blockers¶
- No live privilege-catalog HTTP endpoint exists — the derivation still requires either pinned-source knowledge or a full OpenAPI schema fetch/parse; there is no single lightweight "list valid privileges" call.
PrivilegesCache's on-appliance generated file (/etc/inc/priv/restapi.priv.inc) is not reachable by this project — no SSH/local-file-read capability exists or is authorized; noted as a possible future authoritative source, not usable today.- Version range verified is
v2.7.7–v2.10.0only — a future package major version could in principle change the algorithm; §2's live-schema-first strategy is the designed mitigation, not a proof this can never happen. - Local, cross-process transaction persistence is still not
implemented — Phase C's
BootstrapTransactionlives only in the calling process's memory for the duration of oneprovision_service_account()call; a crash mid-sequence loses the in-memory record entirely (the server-side partial state is still safely, precisely reported via re-reads on the next run — seeBLOCKED_EXISTING_PARTIAL— but there is no local file recording which step was last attempted). Where such a record would live, its exact schema, and its relationship to Tier 1's ownRecoveryContractpersistence remain open questions for a future phase. - No decision has been made on whether
bootstrapbecomes part ofpfsense-mcp-security setupdirectly or a standalone subcommand — §9's sketch is illustrative only. - Resolved offline and partially exercised in the LAB: a Basic-Auth-capable
Transportnow exists —BasicAuthHttpTransportis tested against the realhttpxstack and throughprovision_service_account()'s existing caller-supplied factory seam. It remains runtime-unwired. Read-only LAB authentication attempts correctly returned HTTP 401 while BasicAuth was disabled; a later temporary enable transition exposed the availability hazard documented in the Phase D runbook. This closes an implementation prerequisite, not the separate live provisioning or runtime-wiring authorization boundaries. - The "modify an existing, differently-provisioned account"
additive-sync path (Phase C's
PRIVILEGES_SYNCEDoutcome) has never been exercised against a real appliance — offline-tested only, same as the rest of Phase C; the payload shapes it reuses (PATCH /api/v2/user) are the same live-evidenced shape the create-path also uses, but the specific "existing account, partial privilege set" scenario itself was never live-executed duringADR-026.
GO / NO-GO recommendation (original pass)¶
GO for a narrowly-scoped implementation phase, conditional on:
- Implementation phase begins with the derivation mechanism and its drift-guard test (§10, first bullet) — the lowest-risk, most mechanically verifiable piece, and the prerequisite for everything else. Done, Phase B.
- The bootstrap transaction's state machine (§3) gets its exact schema
designed and reviewed before any code that would call
POST /api/v2/useris written — not concurrently. Done, Phase B (security_bootstrap_transaction.py) — no HTTP code exists anywhere in this project referencingPOST /api/v2/user. - Live provisioning against a real appliance (even the disposable LAB one) remains its own separate, explicit owner authorization, exactly as every prior live action in this project's history has required — this ADR's evidence and design do not themselves authorize touching pfSense. Unchanged after Phase B — Phase B performed zero pfSense contact.
NO-GO on: implementing bootstrap/setup provisioning code in the
same pass as this research (correctly not attempted here or in Phase
B); expanding doctor to make live pfSense calls (§6, correctly
deferred, still deferred after Phase B); assuming any privilege value
in this document is unconditionally guaranteed for package versions
beyond v2.10.0 without re-verification (§2/§3's drift-detection
design exists specifically because this can't be assumed).
GO / NO-GO recommendation (Phase C, post-Phase-B)¶
GO for a narrowly-scoped Phase C implementing the actual HTTP
provisioning operations (POST /api/v2/user, the bootstrap-privilege
grant/revoke, POST /api/v2/auth/key), conditional on:
- Phase C wires
security_privileges.py's derivation functions andsecurity_bootstrap_transaction.py's state machine together with real HTTP calls, rather than reimplementing either — both are now tested, reviewed building blocks, not merely a design to re-derive. Done, Phase C (security_bootstrap_engine.py). - Live provisioning remains its own separate, explicit owner
authorization for the specific target appliance, exactly as §3's
original conditional required — Phase C's own code existing does not
itself authorize running it against pfSense. Unchanged after
Phase C — Phase C performed zero pfSense contact; every test uses
an in-memory fake
Transport. doctorintegration (§6) remains a distinct, separately-scoped decision — Phase C implementing provisioning does not by itself authorize adding live pfSense calls todoctor. Unchanged — neithersecurity_bootstrap_client.pynorsecurity_bootstrap_engine.pyis referenced anywhere insecurity_doctor.py.
GO / NO-GO recommendation (Phase D prerequisites)¶
GO for owner consideration of one narrowly scoped, disposable-LAB-only Phase D; no live action is authorized by this text. The offline prerequisites are now closed:
- Resolved offline: the single-use
BasicAuthHttpTransportexists, is adversarially tested, and composes through the existing factory seam. It is deliberately not wired into a CLI or runtime path and has not been live-tested. - Resolved offline: generated-key custody uses the existing
PFSENSE_API_KEY_FILEmodel with exclusive, owner-only, fail-safe creation and descriptor-bound reread verification. - Resolved by owner decision: the dedicated account is
pfsense-mcp, descriptionDedicated service account for pfsense-mcp-server, key descriptionpfsense-mcp-server primary API key, and target profilewrite_protected(41 READ privileges plus onlyapi-v2-firewall-alias-patch). - Resolved offline as a precise contract:
PRIVILEGES_SYNCEDpreserves every privilege in its final authoritative pre-mutation snapshot and proves every target privilege after PATCH. The absence of an API revision/CAS primitive remains explicit; a controlled live exercise requires an exclusive administrative window. - Deliberately deferred: cross-process transaction persistence is not required for one synchronous, supervised LAB exercise with hard stop, authoritative reobservation, and manual recovery. It is mandatory before unattended or normal CLI/runtime use.
The remaining PRIVILEGES_SYNCED evidence is necessarily live evidence
to be gathered inside a separately owner-authorized Phase D, not an
offline implementation prerequisite. The exact permitted ceremony,
interruption states, recovery actions, and stop conditions are in
ADR033_PHASE_D_LAB_RUNBOOK.md.
Wiring a CLI subcommand or normal runtime remains a distinct later
decision even after Phase D succeeds.
2026-08-19 partial Phase D evidence and transition hardening¶
One owner-authorized absent-account exercise reached verified server-side account/key state but failed local key custody, leaving one disposable account and one unrecoverable orphan key pending cleanup. A later temporary authentication-method enablement persisted but immediate REST API reads timed out; the owner restored exact KeyAuth out of band and two independent reads verified recovery. No cleanup or exercise retry followed.
The resulting closed AuthMethodTransitionCoordinator treats the settings
PATCH as at-most-once and any post-submit disconnect/timeout as indeterminate,
throws away the old connection, performs bounded verification with newly
constructed transports, preserves an unrelated-settings digest, and requires
out-of-band recovery when exact state cannot be proven. It is isolated from
bootstrap, recovery, CLI, application, and MCP paths. Live orphan cleanup,
Exercise 1 retry, Exercise 2, and any runtime wiring remain separately
owner-gated.
CLI/runtime integration Slice 1: durable operation state¶
Phase D Exercise 1 and Exercise 2 subsequently completed under separate owner
authorizations. Their supervised one-shot process model is evidence, not a safe
normal CLI interruption model. Before any mutating administrative CLI can be
wired, security_operation_journal.py now provides an offline-only persistence
foundation:
- an owner-only HMAC-SHA256 chained JSON-lines journal, domain-separated from its separately authenticated head checkpoint and lock metadata;
- append fsync before an atomically replaced head, followed by directory fsync, so torn/truncated appends and journal/head disagreement fail closed;
- immutable operation binding covering operation/type, target identity/origin, account, approved profile, schema evidence, and starting auth methods;
- a closed durable state graph with an explicit send-intent boundary. A crash after that boundary is always classified as unknown delivery and never as permission to resend;
- an owner-only advisory lock whose authenticated operation ownership survives process death as stale metadata. A free OS lock does not erase that evidence or authorize continuation; restart classification is required; and
- a pure restart classifier combining trusted journal/lock/local-artifact evidence with caller-supplied authoritative observations. It has no client, transport, endpoint, CLI, or mutation authority.
Only CREATED/PRE_SEND_READY with matching authoritative clean state can be
PRE_SEND_RESUMABLE. Unknown sends, partial server state, recovery-required
state, pending final verification, and corrupt/untrusted local state remain
distinct. An unfinished journal always blocks a new bootstrap. Recovery actions
are closed enum values, owner-directed, and never chain into provisioning.
The HMAC key is external secure bootstrap material and is never journaled. The journal stores no password, API key, Basic-Auth material, raw response, request, transport, or client. Local HMAC plus an authenticated head detects record forgery, truncation, internal replay, and journal-only rollback. It does not claim protection if a privileged attacker replays both journal and head while also bypassing owner-only directory controls; a stronger external monotonic anchor would be a separate architecture decision and is not required for this local administrative interruption model.
Slice 1 remains unwired from pfsense-mcp-security, application startup, MCP
tools, bootstrap/recovery engines, and doctor. A later composition/CLI slice
requires separate owner authorization.
CLI/runtime integration Slice 2: fixed administrative composition¶
The next offline slice adds security_admin_composition.py as the one fixed
construction boundary for ADR-033 administration. It accepts an explicit
mapping of secure references and binds the complete internal component graph
to one normalized HTTPS origin, one configured appliance identity, the fixed
pfsense-mcp account, the fixed write_protected profile, and one
source-cross-checked schema digest. No endpoint, HTTP method, adapter,
transport, account, profile, or mutation authority is caller-selectable.
The explicit required configuration is:
PFSENSE_API_URL,PFSENSE_IDENTITY,PFSENSE_API_VERSION,PFSENSE_TLS_MODE, and (forauto)PFSENSE_TLS_CA_FILE;PFSENSE_API_KEY_FILE, the administrator KeyAuth credential reference;PFSENSE_ADMIN_USERNAMEandPFSENSE_ADMIN_PASSWORD_FILE, the temporary bootstrap BasicAuth references;PFSENSE_SERVICE_API_KEY_FILE, the exclusive service-key custody target;PFSENSE_ADMIN_STATE_DIRandPFSENSE_ADMIN_JOURNAL_KEY_FILE; andPFSENSE_ADMIN_SCHEMA_FILE,PFSENSE_ADMIN_SCHEMA_VERSION, andPFSENSE_RESTAPI_PACKAGE_VERSION.
Target, identity, credential, custody, state, schema, and integrity-key paths have no discovery fallback and must be explicit. API v2, the account name, descriptions, profile, and target-namespaced journal/lock filenames are safe fixed values. Insecure TLS is refused. Secret files and state directories are owner-only and non-symlink; CA material may be shared read-only but must be a non-symlink regular file owned by the invoking user and not writable by group or other. Existing custody artifacts are validated but never overwritten or read during composition.
The namespace is a domain-separated digest of origin, target identity, account, and profile. Journal and lock paths derive from that stable namespace, preventing accidental cross-target reuse while ensuring changed schema/version evidence cannot select a fresh path and hide an unfinished operation. Schema evidence remains authenticated in the journal binding, so drift fails closed. An existing journal is authenticated and checked against every binding; a corrupt, replayed, mismatched, or unsafe journal/lock fails construction.
The only public service is AdministrativeStatusService, which combines the
authenticated journal, lock observation, an internally revalidated service-key
custody observation, and caller-supplied authoritative observation through
Slice 1's pure classifier. A caller cannot hide an existing or unsafe custody
artifact. The service exposes only classify() and availability(). A new
bootstrap is available only for CLEAN_NO_OPERATION; completed, unfinished,
unknown-send, corrupt, drifted, and recovery states never silently reopen it.
Partial/recovery state exposes only the classifier's closed RecoveryAction.
Fixed KeyAuth/BasicAuth factories and exact bootstrap, recovery, and auth-transition call bindings are assembled privately so later orchestration cannot replace their target or dependencies. They are intentionally absent from the public context API in this slice. In particular, Slice 2 does not yet provide journal-aware mutation orchestration, operation locking around an engine call, key custody, command parsing, or any callable mutating service. No transport is constructed and no file, network, or mutation action occurs merely by building the context.
The module remains absent from pfsense-mcp-security, doctor, application
startup, the MCP factory/server, and all tool modules. A separately authorized
next slice must first compose journal-aware administrative actions and then
register any human-invoked command; construction alone is not CLI wiring and
does not grant permission to provision or recover.
CLI/runtime integration Slice 3: locking orchestration + bootstrap command¶
Slice 3 (2026-08-23, offline-verified only) composes Slice 1's journal/lock
primitives and Slice 2's fixed AdministrativeContext into one narrow
orchestration function, security_bootstrap_orchestration.run_bootstrap()
(and its environment-driven entry point,
run_bootstrap_from_environment()), and exposes exactly one new CLI
subcommand: pfsense-mcp-security bootstrap. This is a composition/
productization slice, not a redesign -- it reuses provision_service_account()
and the composition/journal/lock primitives exactly as already implemented
and reviewed; no engine, client, recovery, or journal semantics changed.
Control flow. run_bootstrap() first calls the context's own read-only
status.classify(authoritative=None) -- the CLI's offline default always
omits a live server observation, so per Slice 1's already-designed,
already-tested classify_restart() behavior, any pre-existing journal is
conservatively reported as requiring recovery attention, regardless of its
specific state. Only CLEAN_NO_OPERATION proceeds to provisioning;
CLEAN_COMPLETED is reported without touching the lock or journal; every
other classification refuses to start. On CLEAN_NO_OPERATION, it acquires
the exclusive lock (contention is reported distinctly and never silently
retried or stolen), creates the journal, appends PRE_SEND_READY then
MUTATION_INTENT_RECORDED unconditionally before calling the engine (the
engine's single blocking call can perform anywhere from zero to several real
HTTP mutations, and the orchestration cannot know in advance which), calls
provision_service_account() inside one try/except Exception, and closes
the journal based on the returned outcome: a verified success
(ALREADY_SATISFIED, ALREADY_SATISFIED_WITH_EXTRA_PRIVILEGES,
PRIVILEGES_SYNCED, COMPLETED) advances through FINAL_VERIFICATION_PENDING
to COMPLETED and releases the lock; anything else
(DERIVATION_FAILED, BLOCKED_EXISTING_PARTIAL, FAILED, or an unexpected
exception) is closed at MUTATION_RESULT_UNKNOWN with the lock held,
requiring human review before any further attempt against the same
target/account/profile namespace.
A deliberate, documented offline-only limitation. Because
classify_restart() treats any pre-existing journal as RECOVERY_REQUIRED
whenever authoritative is None -- which this offline slice's CLI always
is -- there is no journal-closure strategy that lets a second, later
bootstrap invocation against the same namespace proceed automatically,
even one that ended as cleanly as possible (e.g. a purely local
DERIVATION_FAILED, proven zero network activity). This is not a gap Slice
3 needs to fix: it is the correct, fail-closed consequence of Slice 1's
already-accepted design applied honestly to an offline-only caller. Only a
future, separately-authorized live-verification slice -- one that can
supply a real AuthoritativeRestartObservation -- can safely distinguish
"already done, all good" from "needs review" without manual state
inspection or cleanup. run_bootstrap()'s own tests exercise the granular
classification (MUTATION_SENT_RESULT_UNKNOWN, CLEAN_COMPLETED, etc.) by
injecting a synthetic authoritative observation, proving the plumbing is
ready for that future slice without claiming this one performs live
verification.
Custody-key persistence (the one genuine missing seam found). On a
COMPLETED outcome (a brand-new account with a freshly generated API key),
the orchestration persists the revealed key to the already-validated
PFSENSE_SERVICE_API_KEY_FILE custody path before closing the journal,
reusing pfsense_mcp.tier1.artifact_exchange.write_secure_new()'s existing
exclusive-creation, owner-only-permission discipline rather than
introducing a second implementation of it. This was required because Slice
2's own composition-time restart check
(test_custody_artifact_is_observed_internally_and_cannot_be_hidden /
test_completed_operation_never_silently_reopens_bootstrap in
tests/test_security_admin_composition.py) already expects a completed
journal and a present custody artifact to appear together -- nothing
before Slice 3 ever wrote to that path.
CLI surface. pfsense-mcp-security bootstrap [--json] -- the minimal
surface derivable from the existing composition layer: build_admin_context()
already reads its entire target/credential/schema configuration from the
same PFSENSE_*/PFSENSE_ADMIN_* environment variables Slice 2 defined, so
no additional flags for target, credentials, or profile selection were
introduced (matching discover/plan/doctor's own zero-required-flags
pattern). A distinct posture/profile selector was considered and rejected as
premature: this slice provisions only the one existing accepted
write_protected capability posture Slice 2 already fixes; no other posture
combination is wired. Exit codes distinguish success; provisioning failure
(auth/authorization failure, duplicate-account detection, and post-mutation
verification mismatch are all engine-level FAILED outcomes the CLI reports
uniformly under this one code, since the engine itself does not expose a
non-fragile way to sub-classify them further); preflight/derivation failure;
lock contention; prior-operation-requires-recovery; corrupt local state; and
configuration error -- see security_cli.py's bootstrap subparser epilog
for the authoritative, exact list. Never prints, logs, or serializes a
secret; the CLI's --json output surfaces only paths, classifications, and
already-sanitized detail strings.
Isolation preserved. security_cli.py imports only
security_bootstrap_orchestration -- never any of the five lower-level
bootstrap-stack modules it composes -- keeping every existing isolation
assertion in tests/test_security_bootstrap_engine_isolation.py (module
names absent from all shipped runtime entry points and tools/*) true
unmodified; only new, additive tests were needed to prove the new module
is the sole gateway.
Recovery execution remains out of scope. security_bootstrap_recovery.py's
two closed actions (revoke_failed_bootstrap_api_key(),
delete_dedicated_recovery_user()) are never called by this orchestration.
A RECOVERY_REQUIRED/BLOCKED_PRIOR_OPERATION result surfaces the
classifier's recovery_action (when the journal names one) purely as
information for a human -- no recover CLI verb exists in this build, and
none is planned until a separately authorized future slice makes that a
deliberate decision.
What Slice 3 does not authorize. Live LAB bootstrap; live production
bootstrap; credential creation on any real appliance; pfSense mutation;
package installation; privilege changes beyond what Slice 2's fixed
write_protected profile already derives; API-key creation against a real
target; runtime MCP exposure changes; or the full pfsense-mcp-security
setup interactive wizard (reserved, not yet designed in detail).