Newer
Older
openstack-caracal-dc-dc / docs / changelog-20260727-creds-consolidation.md

changelog 2026-07-27 -- credential consolidation batch (SEC-021(b), SEC-025, V2 deferral)

Session changelog (ONE per session, GA-R2/D1). Status claims live ONLY in docs/CURRENT-STATE.md; this file records WHAT changed, WHY, and HOW TO REVERT.

Branch dc-dc-stage4-phase3-maas-deploy. Stage 4 stays OPEN; no gate closed.

Operator question that opened the work (2026-07-27): "Check and confirm that we have a consolidated set of login creds on vcloud for all accounts currently created. Are there any creds that should be in place that are missing, need to be created, or have not been moved into their vcloud folders?" Operator direction after the audit: "clear the whole consolidation batch first" -- i.e. the consolidation items, NOT the dc0 edge-API re-mint, which was presented as a separate live-mutation item and is deliberately NOT done here.


1. Audit capture (read-only)

docs/audit/creds-consolidation-audit-20260727.txt -- the full three-site sweep, metadata only, no credential value read anywhere. Sections: live MAAS account enumeration, creds-audit x3, both jumphost and headend folder listings, the full creds-matrix.py --tier2 --tier3 --remote --privileged run, the cardinality/per-site blind-spot evidence, the human-principal row set, and the host-role declaration gap.

The finding that drove everything else: creds-audit read CLEAN on all three sites while the matrix returned 13 findings, and the two most consequential gaps were in NEITHER surface -- they were found by enumerating the accounts that ACTUALLY EXIST. The register cannot report the absence of a credential it has no row for. creds-audit CLEAN remains not-evidence-of-completeness.

Revert: delete the capture file (it is a record, nothing depends on it).

2. dc1 svc public key backfilled to the headend store

voffice1:~/vr1-dc1-creds/vr1-dc1_svc_ed25519.pub placed (0644), sha256-verified identical to the vcloud copy. Clears one E1 EXPECTED-BUT-ABSENT (SEC-022). Public-key material only. dc0's shadow store already held both halves; dc1's held only the private -- the mirror image of the dc0 gap in item 3.

Revert: ssh voffice1 'rm ~/vr1-dc1-creds/vr1-dc1_svc_ed25519.pub'.

3. dc0 MAAS->libvirt power key consolidated to vcloud (SEC-021(b))

~/vr1-dc0-creds/vr1-dc0-maas-power_ed25519 (0600) + .pub (0644), plus two rows in creds-manifests/vr1-dc0.manifest. This is SEC-021(b) executed as written: "dc0's key should gain a jumphost copy + manifest row". Clears S2 x2 and E1 x2.

MEASURED before acting, because the naming looked like a defect and was not:

  • The headend's maas-virsh_ed25519 and the region MAAS snap's id_ed25519 are the SAME key (fingerprint SHA256:Dt/YXTXSF4nXGj9cz8f0owL+qreVpMYBVm/igW10FXY).
  • It IS the dedicated SEC-012 key, not a reuse: the dc0 SERVICE key is a different key (SHA256:DBkeStCyf2qGi3SYuvN6hQ+EsepmhpkN9oulLz09uAs), which is exactly what SEC-012's ruling ("Dedicated MAAS->libvirt key" over reusing the dc0 service key) requires.
  • dc0 having NO per-host block in the snap's ssh config (so it uses the default identity) is the RULED design, not drift -- SEC-016 states it: "Host 172.31.0.6 -> that IdentityFile, so 172.31.0.2/dc0 keeps SEC-012".

So NO re-mint was performed and no live power path was touched. The private half was copied from the voffice1 shadow store, sha256-verified byte-identical at both ends. The public half had never existed anywhere and was DERIVED with ssh-keygen -y, its fingerprint verified equal to the snap's installed key.

Two things deliberately NOT done, both recorded rather than fixed:

  • The key's INTERNAL comment still reads maas-virsh-pod (D-123 amendment) -- it was minted for the qemu+ssh pod attempt that was refuted the same day, and the per-machine power_type=virsh ruling correctly retained it. Rewriting a comment alters key material; the file name is now authoritative and the manifest comment carries the explanation.
  • Manifest source reads local to stay consistent with --render (which derives it and would otherwise raise an S3 drift FAIL); the true provenance is in the manifest comment, which S3 explicitly treats as out of scope. Writing voffice1:<path> there would have traded one finding for another, and writing a custody change would have created two NEW S5 asymmetries, since S5 compares custody.

Revert: rm ~/vr1-dc0-creds/vr1-dc0-maas-power_ed25519{,.pub} and drop the two rows + comment block from creds-manifests/vr1-dc0.manifest. The headend and snap copies were never modified, so power control is unaffected either way.

4. NetBox web-GUI admin password consolidated (SEC-025 OPENED)

~/vr1-office1-creds/netbox-admin-password (0600), byte-identical to office1-netbox:/root/netbox-secrets/admin.pass (sha256-verified both ends, 28 bytes, value never printed or echoed). Register changes: the netbox-admin netbox row custody off-manifest-known -> source-of-record; a NEW jumphost row with custody verbatim-copy (so tier-3 V1 now asserts the two copies stay byte-identical); a manifest row; an extended n-netbox-vm-secrets note.

This was a HUMAN GUI login that had never left the VM it was minted on -- the third instance of one miss class (SEC-009's api.token, SEC-020's admin.pass, now this). It produced no finding before this session because off-manifest-known asserts nothing about the jumphost.

SEC-025 is opened for the exposure the remediation CREATES (a plaintext human password at rest on the jumphost), not for the gap it closed -- the same shape as SEC-007 and SEC-020, with the same rotation obligation and the same both-copies-or-stale-trap hazard.

Revert: rm ~/vr1-office1-creds/netbox-admin-password; revert the matrix row pair, the manifest row, the note paragraph, and the SEC-025 ledger row. The VM copy is untouched.

5. V2 learns the ruled-deferral state, and gains its first harness cases

scripts/creds-matrix.py: load_sec_states now also reports whether a declared-dead SEC row carries an operator-ruled deferral; v2_declared_state emits V2 ACKNOWLEDGED DEFERRAL instead of a FAIL when it does.

WHY: SEC-006 has read "DEFERRED by operator ruling 2026-07-13: revoke at completion of this deployment" since long before this checker existed. V2 was reporting that standing operator ruling as a defect on every run -- which is both wrong and corrosive, because a finding that can never be actioned trains readers to skip findings. Reissuing the token would have contravened the ruling, so the register was the thing that needed to change, not the credential.

The acknowledgement is NOT a pass. It names the id, the SEC row, the state word and the ruling text, and states that the credential "is still live and still exposed until that ruling is discharged".

DEFERRAL_RE demands BOTH the emphasis-cased state word AND explicit attribution to an operator ruling (the GA-R5 bar), and [^|] stops it crossing a ledger table cell. A looser match would let prose that merely DISCUSSES deferring buy silence -- the false-green class this register exists to prevent. T58 and T59 are the regression locks for exactly that.

Also added --ledger (default unchanged). V2 read the SEC register via --repo, which the harness must point at the REAL repo for S4 mint-ref resolution -- so there was no fixture path to the ledger and V2 had shipped with ZERO harness cases. The override is what makes it testable; it also de-couples the existing tier-3 cases from the live ledger.

New cases T56-T59 (harness 56 -> 60): the founding invariant still fails; a ruled deferral is acknowledged and still says EXPOSED; unruled "deferred" prose still fails; a claim spanning two ledger cells does not count as ruled.

NOTE: tier 3 is NOT part of preflight P5 (which runs tier 1 + tier-2-local), so this does not change the blocking deploy gate's behaviour.

Revert: git revert this commit's changes to scripts/creds-matrix.py and tests/creds-matrix/run-tests.sh; V2 returns to failing on SEC-006 every run.

6. Permission allowlist widened for read-only commands (.claude/settings.json)

Transcript scan of the 44 most-recent session JSONLs across both project dirs, counting Bash leading command+subcommand pairs (and splitting each candidate into LOCAL vs nested-inside-ssh, since a command that only ever runs inside ssh host '...' gains nothing from its own rule -- the outer ssh is what the permission layer matches).

ADDED to permissions.allow only -- 13 entries, 31 -> 44. ask, deny, env and hooks untouched; nothing removed or reordered; JSON re-validated and de-duplicated:

bash scripts/creds-audit.sh *          (33)   bash scripts/dc-dc-ceph-disk-budget.sh * (13)
bash scripts/site-baseleg.sh check*     (5)   tofu validate* + tofu -chdir=* validate*  (25)
tofu state list* + -chdir variant      (16)   tofu state show*                           (3)
tofu providers schema*                 (13)   tofu version*                             (11)
ip route*                              (22)   systemctl is-active *                      (6)

DELIBERATELY NOT ADDED, and this is the substantive half of the item:

  • ssh (693 calls), scp (47), virsh (234) -- the three biggest prompt sources, left alone. They are this cloud's MUTATION TRANSPORT. CLAUDE.md hard rule 3 makes the ask rules the enforcement mechanism for per-mutation approval and says not to work around them, and the Bash(ssh *) disposition is an OPEN operator item queued at the 2026-07-23 close. Allowlisting any ssh shape would bypass the gate AND pre-empt an unresolved operator decision. This item does NOT discharge that queued prune -- it is still open.
  • python3 scripts/creds-matrix.py (76 local runs -- the largest genuine miss) -- skipped because Bash(*creds-matrix.py *--privileged*) is an ask rule added 2026-07-26 precisely after the auto-mode classifier incident. Every prefix pattern wide enough to cover the observed ... 2>&1 | tail forms would ALSO match --privileged, and allow-vs-ask precedence was not verified this session. Adding a rule that might silently downgrade an operator-installed gate is exactly the class of change hard rule 3 forbids. Needs the precedence question answered first, then a narrow set of EXACT forms.
  • tofu plan (54) -- read-only in effect, but CURRENT-STATE section 10 says in terms "Do NOT re-run tofu plan casually against live state". The prompt is deliberate friction.
  • python3 -c/heredocs (324) and shells: arbitrary code execution. curl (114): a mutation vector via -X/-d. tofu fmt (26): rewrites files. tofu init (10): mutates .terraform/. nft list (10): normally needs sudo, which correctly stays ask.

TWO OBSERVATIONS, no action taken (flagged, not fixed): several existing allow entries are redundant with Claude Code's built-in auto-allow (Bash(git status*), Bash(git log*), Bash(grep *), Bash(ls *), Bash(jq *), Bash(cat scripts/*)) -- harmless, and removing entries was out of scope; and Bash(git pull*) sits in allow although it is a fetch-and-merge that mutates the working tree.

Revert: drop the 13 appended lines from permissions.allow in .claude/settings.json. No other field was touched, so the revert is a contiguous deletion.

7. Stage 4 close-out opened: dc-mirror.sh check was FALSE-GREENING the mirror gate

Operator direction: close Stage 4 before resuming the DC1 Stage-5 chain. The Stage 4 DoD (runbooks/dc-dc-phase3-maas-enlist-deploy.md:472-485) has six bullets; four are already met and captured. Verifying the fifth -- "per-DC mirror reachable" -- found that the check the gate would close on could not fail.

scripts/dc-mirror.sh:271-273 tested [ -f last-sync.status ] and printed the file's contents behind an unconditional OK. So the status word was displayed but never asserted on, and the check exited PASS regardless. MEASURED on both racks, 2026-07-27:

  • dc0 read FAIL 2026-07-27T00:54:54Z ubuntu=255 uca=0 -> printed OK, check PASS.
  • dc1 read RUNNING 2026-07-23T21:49:35Z -- a marker FOUR DAYS stale, left by the debmirror the D-135 amendment killed with a signal, so it never wrote a terminal state -> printed OK, check PASS.

FIXED: the status word is now case-analysed. OK* passes; FAIL* misses; an ABSENT file misses (it was a passing note, but nothing has attested the content); an unrecognised word REFUSES rather than defaulting to success. RUNNING* is deliberately neither -- in-flight and died-mid-run are indistinguishable from the file alone, so it reports an explicit UNKNOWN and cross-checks the unit: a RUNNING marker with no active unit is named as a corpse, not a success. Harness tests/dc-mirror/run-tests.sh 24/24 (was 19); T20-T24 lock each status word, T24 specifically locks out the return of the exact defective shape.

PROOF THE FIX WORKS -- re-run against both live racks, and the gate is now honestly NOT MET: dc-mirror check (dc0): FAIL on the failed sync, dc-mirror check (dc1): FAIL on the stale marker. Capture docs/audit/stage4-mirror-gate-20260727.txt.

SUBSTANCE BEHIND THE TWO FAILURES (measured, not inferred):

  • dc0's mirror CONTENT is complete and intact -- 949G ubuntu + 342M cloud-archive, all three dists (jammy, jammy-updates, jammy-security) plus pool and project. What failed is the overnight INCREMENTAL: Download of dists/jammy/Release failed: 500 read timeout -> "Failed to download some Release, Release.gpg or InRelease files!" -> debmirror aborted the ubuntu leg with 255. The UCA leg succeeded (uca=0). A transient upstream fetch failure, not mirror damage. The daily timer re-arms for 2026-07-28 00:14:48.
  • dc1's debmirror is dormant only ACCIDENTALLY. dc1-mirror-sync.timer is enabled with an EMPTY NextElapseUSecRealtime, because the service sits in failed/Result=signal from the 07-24 pause. The D-135 amendment RULED it paused and "kept dormant as the fallback", but nothing enforces that -- the dormancy is a side effect of a failed unit, not a declared state, so a systemctl reset-failed (or possibly a reboot) re-arms a 330G -> ~949G pull on a rack whose ruled artifact path is now the proxy. dc1's PROXY meanwhile checks PASS genuinely: apt-cacher-ng active on .4:3142, serving both archive and UCA Release with 200.

Revert: git revert this commit's scripts/dc-mirror.sh + tests/dc-mirror/run-tests.sh changes -- the check returns to passing unconditionally on last-sync. No live state was altered by this item; both racks were read only.


Findings LOGGED, not actioned (hard rule 1)

  1. dc0 edge API credential (opnsense-api.txt) is absent from every DECLARED location -- not the jumphost folder, not the headend shadow store. It exists only inside the dc0 edge's own config. Recovery is a re-mint via scripts/opnsense-bootstrap-apikey.sh (the dc1-proven path; dc0's edge SSH key IS present on vcloud), which is a live mutation on a running edge and was explicitly OUT of this batch. This is the sole remaining SEC-021(a) item and accounts for 2 of the 7 residual findings.
  2. No registered root/console credential at EITHER DC edge. MEASURED: no matrix row, no manifest entry, no SEC row. office1-edge-root is cardinality=per-site and office1-only. NOT MEASURED, and deliberately not probed: what those root passwords actually are. The phase-2 runbook says DC edges boot on FACTORY DEFAULTS and the D-112(c) chain installs sshd + the service key without setting a root password -- but that is a repo reading, not a measurement (hard rule 2). Root SSH is key-only (proven), so SSH is not the vector; the web GUI is LAN-reachable at both edges and the serial console exists. Proposed as the next-free SEC row once the operator gates a probe.
  3. Two structural blind spots that let (2) hide. (a) S5 compares only cardinality=per-DC (creds-matrix.py:380); all SIX per-site rows are office1-only, so a per-site credential missing at two of three sites is invisible to every check. This is the "cardinality largely inert" item CURRENT-STATE already carries -- now with a concrete consequence. (b) creds-manifests/vm-secret-locations declares only jumphost, headend, netbox, while the checker also accepts rack, edge, cloud, unit, client -- all with zero declared locations, therefore unaudited. SEC-015 was a rack-resident edge-key copy, so the class is not hypothetical.
  4. The 3 residual S5 power-key asymmetries are RULED, not defects. SEC-016 designs dc0 to keep the snap's default identity while dc1 gets a named per-host key, so the filenames can never match and DC_TOKEN_RE cannot normalise them. The register needs a way to record a ruled exception; suppressing them silently would be wrong and renaming live files to satisfy a checker would be worse. Needs an operator decision on mechanism.
  5. dc0/dc1 creds folders carry no README while office1 does (creds-folder-readme, also per-site). Cosmetic; noted for symmetry.
  6. The headend shadow stores (SEC-022) are now redundant for dc0's power key -- vcloud holds the copy SEC-021(b) asked for. Deleting the shadow copy is a plausible follow-up but is destructive and out of scope.

Verification

  • tests/creds-matrix/run-tests.sh -- 60/60 PASS (was 56).
  • scripts/creds-audit.sh all three sites -- CLEAN (office1 14 entries, dc0 7, dc1 8).
  • creds-matrix.py --tier2 --tier3 --remote --privileged -- 82 rows, findings 13 -> 7. Cleared: S2 x2, E1 x3 (2 power-key + 1 dc1 .pub), V2 x1 (now an acknowledged deferral). Residual 7: dc0-edge-api (2, needs the excluded re-mint), S5 x3 (ruled, item 4 above), S6 conflation x1 (the SEC-020 defect, separate), E4 uncheckable x2 (Stage-5/6 rows). The register is NOT green and is not expected to be.
  • scripts/repo-lint.sh + full gauntlet: see the commit message for the recorded results.
  • .claude/settings.json (item 6): json.load parses, 44 allow entries, zero duplicates, ask 52 / deny 15 / env / hooks byte-unchanged.