From 4e07d60ff17a0fe74c23d352aafaae08a809e413 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 5 Sep 2026 01:28:05 +0200 Subject: [PATCH] Validate cadence contract and require functional MFA verification Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ea3-7939-7b63-8125-699f8b50bedd --- Makefile | 6 +- README.md | 3 + SECURITY-COMPANION.md | 8 +- WORK-RECORDS.md | 16 +- .../emission-cadence-security-profile_v0.1.md | 135 ++++++ docs/architecture/net-kingdom_v0.1.md | 1 + docs/attended-procedure-inventory.md | 60 +++ ...eycape-exposure-resolver-reconciliation.md | 4 +- docs/platform-root-custody.md | 2 + docs/smooth-bootstrap-guide.md | 2 + docs/verification-audit-2026-09-05.md | 20 + docs/verify-t06.md | 48 ++ intakes/intakes.md | 2 + sso-mfa/k8s/README.md | 2 + sso-mfa/k8s/authelia/README.md | 2 + sso-mfa/k8s/backup/DR-RUNBOOK.md | 4 +- sso-mfa/k8s/keycape/README.md | 2 + sso-mfa/k8s/lldap/OPERATOR-GROUPS.md | 2 + sso-mfa/k8s/lldap/README.md | 2 + sso-mfa/k8s/postgresql/README.md | 2 + sso-mfa/k8s/privacyidea/README.md | 2 + sso-mfa/k8s/privacyidea/bootstrap-realm.sh | 34 +- sso-mfa/k8s/privacyidea/pi_api.py | 56 +++ .../reconcile-lldap-resolver-live.sh | 31 +- sso-mfa/k8s/privacyidea/repair-realm-live.sh | 17 +- sso-mfa/k8s/privacyidea/verify_mfa.py | 128 ++++++ sso-mfa/k8s/user-engine/README.md | 2 + sso-mfa/k8s/verify-t06.sh | 288 +----------- tests/test_privacyidea_verification.py | 218 +++++++++ tools/emission-cadence-profile/README.md | 27 ++ .../emission_cadence_profile.py | 418 ++++++++++++++++++ .../tests/test_emission_cadence_profile.py | 289 ++++++++++++ .../NK-WP-0034-verification-that-verifies.md | 54 ++- ...-0035-emission-cadence-security-profile.md | 117 +++++ 34 files changed, 1640 insertions(+), 364 deletions(-) create mode 100644 canon/standards/emission-cadence-security-profile_v0.1.md create mode 100644 docs/attended-procedure-inventory.md create mode 100644 docs/verification-audit-2026-09-05.md create mode 100644 docs/verify-t06.md create mode 100644 sso-mfa/k8s/privacyidea/pi_api.py create mode 100644 sso-mfa/k8s/privacyidea/verify_mfa.py create mode 100644 tests/test_privacyidea_verification.py create mode 100644 tools/emission-cadence-profile/README.md create mode 100644 tools/emission-cadence-profile/emission_cadence_profile.py create mode 100644 tools/emission-cadence-profile/tests/test_emission_cadence_profile.py create mode 100644 workplans/NK-WP-0035-emission-cadence-security-profile.md diff --git a/Makefile b/Makefile index 37f3c1c..fdef922 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ hooks: ## Configure git to use repo-local hooks (.githooks) hooks-test: ## Test that the pre-commit hook blocks plaintext secrets @mkdir -p sso-mfa/bootstrap/secrets/_hooktest - @echo 'PI_SECRET_KEY=deadbeef' > sso-mfa/bootstrap/secrets/_hooktest/test.env + @printf '%s=%s\n' PI_SECRET_KEY deadbeef > sso-mfa/bootstrap/secrets/_hooktest/test.env @git add sso-mfa/bootstrap/secrets/_hooktest/test.env 2>/dev/null || true @if git commit -m "TEST: hook must block this" 2>/dev/null; then \ echo "FAIL: hook did NOT block plaintext commit"; \ @@ -216,6 +216,9 @@ posture-feedback-reference: ## Emit the proposal-only reference report at its fi --fail-on none \ examples/posture-feedback/expired-e2.yaml +emission-cadence-profile-test: ## Run NetKingdom emission-cadence profile tests + python3 -m pytest tools/emission-cadence-profile/tests + security-bootstrap-console-test: ## Run automated tests for bootstrap console UI/sections/runbooks (NET-WP-0018-T07) python3 -m pytest tools/security-bootstrap-console/tests @@ -376,6 +379,7 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a creds-agent-init creds-agent-status creds-emergency-reprint \ openbao-init-unseal openbao-init-unseal-dry-run \ iam-profile-conformance-test playbook-contract-test posture-feedback-test \ + emission-cadence-profile-test \ posture-feedback-reference security-scenario-compose-lightweight \ security-bootstrap-console-test security-bootstrap-scripts-syntax \ security-bootstrap-console security-bootstrap-king-kit \ diff --git a/README.md b/README.md index 8d4b21e..5926182 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ The dynamic, self-optimizing security platform is the long-term direction in — deterministic, plan-only capability and trust composition - [Posture feedback](canon/standards/posture-feedback_v0.1.md) — deterministic, proposal-only posture and evidence remediation findings +- [Emission cadence security profile](canon/standards/emission-cadence-security-profile_v0.1.md) + — NetKingdom obligations over the InfoTechCanon declaration contract; + proposed pending owner-instance migration ## Security Infrastructure Documents diff --git a/SECURITY-COMPANION.md b/SECURITY-COMPANION.md index da470f4..2a2240d 100644 --- a/SECURITY-COMPANION.md +++ b/SECURITY-COMPANION.md @@ -150,8 +150,12 @@ If a control's soundness depends on an event being present, that evidence is **load-bearing**: emission must be atomic with the state change, queued locally, and you **MUST** declare an expected cadence. For rare load-bearing events — revocations, denials, containment — rate monitoring cannot work, so the required -form is **reconciliation or a heartbeat**: a positive claim that can itself go -missing. +NetKingdom profile is **heartbeat plus reconciliation**: a positive claim that +can itself go missing and a comparison of source transitions with accepted +evidence. The mechanically checkable overlay is +[`canon/standards/emission-cadence-security-profile_v0.1.md`](canon/standards/emission-cadence-security-profile_v0.1.md); +it imports the InfoTechCanon contract assigned by `GH-DEC-2026-004` and +remains proposed pending owner-instance validation. Otherwise evidence is **attributive**: seek atomicity, and if you trade it away deliberately, declare the trade and never describe the trail as complete. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index c63349d..2579034 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -27,7 +27,8 @@ | workplan | NK-WP-0031 | blocked | — | workplans/NK-WP-0031-deterministic-posture-feedback.md | | workplan | NK-WP-0032 | blocked | — | workplans/NK-WP-0032-openbao-operator-loopback-callback.md | | workplan | NK-WP-0033 | active | — | workplans/NK-WP-0033-keycape-secret-exposure-rotation.md | -| workplan | NK-WP-0034 | proposed | — | workplans/NK-WP-0034-verification-that-verifies.md | +| workplan | NK-WP-0034 | blocked | — | workplans/NK-WP-0034-verification-that-verifies.md | +| workplan | NK-WP-0035 | blocked | — | workplans/NK-WP-0035-emission-cadence-security-profile.md | | task | NK-WP-ADHOC-2026-07-02-T01 | done | — | workplans/ADHOC-2026-07-02.md | | task | NK-WP-ADHOC-2026-07-02-T02 | done | — | workplans/ADHOC-2026-07-02.md | | task | NK-WP-ADHOC-2026-08-14-T01 | done | — | workplans/ADHOC-2026-08-14.md | @@ -118,10 +119,15 @@ | task | NK-WP-0033-T03 | progress | — | workplans/NK-WP-0033-keycape-secret-exposure-rotation.md | | task | NK-WP-0033-T04 | done | — | workplans/NK-WP-0033-keycape-secret-exposure-rotation.md | | task | NK-WP-0033-T05 | progress | — | workplans/NK-WP-0033-keycape-secret-exposure-rotation.md | -| task | NK-WP-0034-T01 | todo | — | workplans/NK-WP-0034-verification-that-verifies.md | -| task | NK-WP-0034-T02 | todo | — | workplans/NK-WP-0034-verification-that-verifies.md | -| task | NK-WP-0034-T03 | todo | — | workplans/NK-WP-0034-verification-that-verifies.md | -| task | NK-WP-0034-T04 | todo | — | workplans/NK-WP-0034-verification-that-verifies.md | +| task | NK-WP-0034-T01 | wait | — | workplans/NK-WP-0034-verification-that-verifies.md | +| task | NK-WP-0034-T02 | done | — | workplans/NK-WP-0034-verification-that-verifies.md | +| task | NK-WP-0034-T03 | done | — | workplans/NK-WP-0034-verification-that-verifies.md | +| task | NK-WP-0034-T04 | done | — | workplans/NK-WP-0034-verification-that-verifies.md | +| task | NK-WP-0035-T01 | done | — | workplans/NK-WP-0035-emission-cadence-security-profile.md | +| task | NK-WP-0035-T02 | done | — | workplans/NK-WP-0035-emission-cadence-security-profile.md | +| task | NK-WP-0035-T03 | done | — | workplans/NK-WP-0035-emission-cadence-security-profile.md | +| task | NK-WP-0035-T04 | wait | — | workplans/NK-WP-0035-emission-cadence-security-profile.md | | intake | NK-IN-0001 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md | | intake | NK-IN-0002 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md | | intake | NET-IN-0001 | open | — | intakes/intakes.md | +| intake | NET-IN-0002 | open | — | intakes/intakes.md | diff --git a/canon/standards/emission-cadence-security-profile_v0.1.md b/canon/standards/emission-cadence-security-profile_v0.1.md new file mode 100644 index 0000000..d2f2302 --- /dev/null +++ b/canon/standards/emission-cadence-security-profile_v0.1.md @@ -0,0 +1,135 @@ +--- +id: netkingdom-emission-cadence-security-profile-v0.1 +type: standard +title: "NetKingdom Emission Cadence Security Profile v0.1" +domain: netkingdom +status: proposed +version: "0.1" +owner: net-kingdom +created: "2026-09-04" +updated: "2026-09-05" +last_reviewed: "2026-09-05" +review_interval: 3m +scope: evidence-completeness +validator: + - tools/emission-cadence-profile/emission_cadence_profile.py +related: + - GH-DEC-2026-004 + - canon/standards/security-layer-model_v0.7.md + - kings-guard/specs/EmissionCadenceDeclaration.md +--- + +# NetKingdom Emission Cadence Security Profile v0.1 + +## 1. Purpose and import boundary + +This profile makes the evidence-completeness rules in Security Layer Model +v0.7 §9.6 mechanically testable without creating a second declaration +contract. + +The versioned, ecosystem-wide `EmissionCadenceDeclaration` semantic contract +is owned by `info-tech-canon` under `GH-DEC-2026-004`. It defines generic +forms, fields, vocabulary, validation semantics, compatibility, and evolution. +This profile imports that contract and defines only NetKingdom security +obligations over conforming declarations. + +The import is InfoTechCanon `standard/emission-cadence`, document version +`0.1.0`, schema version `0.1` (published in canon 0.7.0; upstream status: draft). + +- Contract: `info-tech-canon/infospace/standards/emission-cadence/InfoTechCanonEmissionCadenceStandard.md` +- Schema: `info-tech-canon/infospace/schemas/emission-cadence.schema.yaml` +- Schema ID: `https://info-tech-canon.local/schemas/emission-cadence.schema.yaml` +- Reviewed schema revision: `b081d39da1353201f879ee6832d4e3e52b791c73` +- Schema SHA-256: `6d52692eb1e4d1325e0d6062d95acded98beddaae5bcfe24c6f0e91f1b6be6ae` + +The schema ID is an identifier; supply the file from the owner checkout, not a +network download from that hostname. This profile remains proposed pending +owner-instance migration and validation. The King's Guard draft is provenance, +not a fallback schema. + +## 2. Ownership + +| Concern | Owner | +| --- | --- | +| Generic declaration contract and schema | `info-tech-canon` | +| These NetKingdom MUST/SHOULD obligations | `net-kingdom` | +| Event-class inventory, evidence classification, rarity, declaration instance, and emission | source repository | +| Stream evaluation and silence findings | `kings-guard` | +| Doctrine and conformance supervision | `gate-house` | + +An observer MUST NOT infer `load-bearing`, `attributive`, or rare-event status +from an event name, payload, observed rate, or historical traffic. The source +publishes those classifications in its authoritative inventory. A conformance +run supplies that inventory to the profile checker; omission from the cadence +document is then detectable rather than circular. + +## 3. Normative obligations + +| Source classification | Obligation | +| --- | --- | +| Load-bearing evidence | Every class **MUST** have a cadence declaration. | +| Attributive evidence | Every class **SHOULD** have a cadence declaration. A deliberate omission is advisory and completeness MUST NOT be claimed. | +| Volume load-bearing evidence | `expected-rate` is permitted when the source explicitly classifies the class as suitable for rate monitoring. A positive window and minimum are required; falling below the minimum is a finding. | +| Rare load-bearing evidence | Rate monitoring is **forbidden**. The declaration **MUST** carry both a heartbeat and reconciliation. | + +For a rare load-bearing class: + +1. `form` is `heartbeat-or-reconciliation`. The historical form name does not + weaken this profile's requirement for both controls. +2. `extensions.netkingdom.rate_monitoring` is `forbidden`. +3. The heartbeat is a positive assertion, normally `nothing-to-report`, and a + missing heartbeat is a `finding`. +4. Reconciliation compares the source's committed transition count with the + evidence engine's accepted count for the same source and class. Divergence + is a `finding`. +5. Locally committed but not yet drained work is lag, not divergence. A source + SHOULD declare a bounded lag signal separately. + +Evidence class and cadence cover different properties. A transactional local +outbox prevents accidental omission across a state change; heartbeat and +reconciliation detect adversarial or operational omission after the fact. +Neither prevents omission by a compromised source, and conformance to this +profile MUST NOT be described as proving stream completeness. + +The entry's `extensions.netkingdom.evidence_class` MUST match the supplied +source classification. NetKingdom fields belong only in this namespace; +flattened draft fields fail generic validation. Reconciliation uses the generic +`compare_local` and `compare_observed` fields. The checker also rejects duplicate +`source_id` values as required by the generic semantic contract, and duplicate +event classes within the profiled declaration. + +## 4. Mechanical conformance + +The checker takes a canonical contract schema, a declaration, and +source-owned class inventory on the command line: + +```bash +uv run tools/emission-cadence-profile/emission_cadence_profile.py \ + --contract-schema ../info-tech-canon/infospace/schemas/emission-cadence.schema.yaml \ + --load-bearing audit.deny \ + --rare-load-bearing audit.deny \ + --attributive audit.allow \ + source-cadence.yaml +``` + +Contract validation always runs first. The checker has no bundled fallback +contract and refuses to profile a document that fails the imported schema. +`--load-bearing`, `--rare-load-bearing`, and `--attributive` are assertions +from the source's authoritative event-class inventory; they are not guesses by +the checker. Rare load-bearing assertions imply load-bearing. + +MUST failures or generic contract failures produce a non-zero exit. Missing +attributive declarations produce a SHOULD finding and succeed by default; +`--fail-on-should` is available for a stricter caller policy. + +## 5. Adoption gate + +This profile can become accepted only when: + +- InfoTechCanon has published the imported contract, schema, version, and + stable locator; +- the checker passes against that schema without a local compatibility copy; +- `approval-engine` and `qonto-assistant` owner-authored instances have been + checked with owner-authored class inventories; and +- `kings-guard` has been given the canonical coordinates so its handover draft + can become historical. diff --git a/docs/architecture/net-kingdom_v0.1.md b/docs/architecture/net-kingdom_v0.1.md index c18856e..123862f 100644 --- a/docs/architecture/net-kingdom_v0.1.md +++ b/docs/architecture/net-kingdom_v0.1.md @@ -114,6 +114,7 @@ N/A for this stub. | `netkingdom-tenancy-posture` | proposed | Graduated multi-tenancy posture. | | `netkingdom-iam-profile-v0.3` | accepted | Current provider-neutral identity contract. | | `netkingdom-posture-feedback-v0.1` | proposed | Governed posture feedback. | +| `netkingdom-emission-cadence-security-profile-v0.1` | proposed | NetKingdom evidence-cadence obligations; owner-instance migration pending. | | `netkingdom-security-layer-model-v0.7` | accepted | Current security-layer interaction model. | | `netkingdom-security-scenario-composition-v0.1` | proposed | Security scenario composition. | | `netkingdom-security-zones-v0.1` | proposed | Security-zone vocabulary and boundaries. | diff --git a/docs/attended-procedure-inventory.md b/docs/attended-procedure-inventory.md new file mode 100644 index 0000000..d6808e1 --- /dev/null +++ b/docs/attended-procedure-inventory.md @@ -0,0 +1,60 @@ +# Attended procedure inventory — 2026-09-05 + +Reviewed all 32 shell scripts under `sso-mfa/k8s/` for NK-WP-0034-T03. +Exercise status: unknown unless the row states otherwise. Existing deployment +or task completion is not treated as proof that a particular script revision +completed successfully with an identified operator. Unknown does not mean never run. + +Runbook matching below requires an explicit script reference in the component +runbook or a dedicated procedure. Missing runbooks are listed separately. + +| Script (under `sso-mfa/k8s/`) | Runbook | Exercise status | +| --- | --- | --- | +| `authelia/create-secrets.sh` | [README.md](../sso-mfa/k8s/authelia/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | +| `keycape/configure-openbao-oidc.sh` | [README.md](../sso-mfa/k8s/keycape/README.md) | unknown | +| `keycape/create-pi-token.sh` | [README.md](../sso-mfa/k8s/keycape/README.md) | unknown | +| `keycape/create-secrets.sh` | [README.md](../sso-mfa/k8s/keycape/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | +| `keycape/patch-openbao-client.sh` | [README.md](../sso-mfa/k8s/keycape/README.md) | unknown | +| `keycape/refresh-pi-token-live.sh` | [README.md](../sso-mfa/k8s/keycape/README.md) | unknown | +| `keycape/register-coulomb-social.sh` | None found | unknown | +| `keycape/verify-openbao-client.sh` | [README.md](../sso-mfa/k8s/keycape/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | +| `lldap/bootstrap-users.sh` | [README.md](../sso-mfa/k8s/lldap/README.md), [OPERATOR-GROUPS.md](../sso-mfa/k8s/lldap/OPERATOR-GROUPS.md) | unknown | +| `lldap/break-glass.sh` | [platform-root-custody.md](../docs/platform-root-custody.md) | unknown | +| `lldap/create-secrets.sh` | [README.md](../sso-mfa/k8s/lldap/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | +| `lldap/create-user.sh` | [OPERATOR-GROUPS.md](../sso-mfa/k8s/lldap/OPERATOR-GROUPS.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | +| `lldap/dry-run-nonroot-user.sh` | [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | +| `lldap/manage-group-members.sh` | [OPERATOR-GROUPS.md](../sso-mfa/k8s/lldap/OPERATOR-GROUPS.md) | unknown | +| `postgresql/create-secrets.sh` | [README.md](../sso-mfa/k8s/postgresql/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | +| `privacyidea/bootstrap-admin.sh` | [README.md](../sso-mfa/k8s/privacyidea/README.md) | unknown | +| `privacyidea/bootstrap-realm.sh` | [keycape-exposure-resolver-reconciliation.md](../docs/keycape-exposure-resolver-reconciliation.md) | unknown | +| `privacyidea/check-user-mfa-state.sh` | [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | +| `privacyidea/create-secrets.sh` | [README.md](../sso-mfa/k8s/privacyidea/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | +| `privacyidea/enckey-bootstrap.sh` | [README.md](../sso-mfa/k8s/privacyidea/README.md) | unknown | +| `privacyidea/reconcile-lldap-resolver-live.sh` | [keycape-exposure-resolver-reconciliation.md](../docs/keycape-exposure-resolver-reconciliation.md) | attempted by Bernd Worsch 2026-08-27; no completed PASS receipt | +| `privacyidea/repair-realm-live.sh` | [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md), [keycape-exposure-resolver-reconciliation.md](../docs/keycape-exposure-resolver-reconciliation.md), [verify-t06.md](../docs/verify-t06.md) | unknown | +| `privacyidea/update-lldap-resolver-live.sh` | None found | unknown | +| `user-engine/verify-operability.sh` | None found | unknown | +| `verify-identity-cutover-dependencies.sh` | None found | unknown | +| `verify-t02.sh` | [README.md](../sso-mfa/k8s/README.md) | unknown | +| `verify-t03.sh` | [DR-RUNBOOK.md](../sso-mfa/k8s/backup/DR-RUNBOOK.md) | unknown | +| `verify-t04.sh` | [DR-RUNBOOK.md](../sso-mfa/k8s/backup/DR-RUNBOOK.md) | unknown | +| `verify-t05.sh` | [DR-RUNBOOK.md](../sso-mfa/k8s/backup/DR-RUNBOOK.md) | unknown | +| `verify-t06.sh` | [verify-t06.md](../docs/verify-t06.md) | unexercised on provider; HTTP simulation passed | +| `verify-t07.sh` | [DR-RUNBOOK.md](../sso-mfa/k8s/backup/DR-RUNBOOK.md) | unknown | +| `verify-t08.sh` | [DR-RUNBOOK.md](../sso-mfa/k8s/backup/DR-RUNBOOK.md) | unknown | + +## Scripts without a matching runbook + +- `sso-mfa/k8s/keycape/register-coulomb-social.sh` — header usage is available, but a runbook with prerequisites, verification and exercise history was not found in the reviewed runbook set. +- `sso-mfa/k8s/privacyidea/update-lldap-resolver-live.sh` — header usage is available, but a runbook with prerequisites, verification and exercise history was not found in the reviewed runbook set. +- `sso-mfa/k8s/user-engine/verify-operability.sh` — header usage is available, but a runbook with prerequisites, verification and exercise history was not found in the reviewed runbook set. +- `sso-mfa/k8s/verify-identity-cutover-dependencies.sh` — header usage is available, but a runbook with prerequisites, verification and exercise history was not found in the reviewed runbook set. + +## Exercise evidence correction + +The resolver reconciliation runbook previously called an attempted run +“exercised” despite the incident explicitly recording no green receipt. Its +header now says unexercised to successful completion. Existing incident +authority/window requirements still apply; this documentation review does not +authorize that attended cutover. The changed helper must be included in the +revision recorded by the operator. diff --git a/docs/keycape-exposure-resolver-reconciliation.md b/docs/keycape-exposure-resolver-reconciliation.md index e816e31..10d56d8 100644 --- a/docs/keycape-exposure-resolver-reconciliation.md +++ b/docs/keycape-exposure-resolver-reconciliation.md @@ -5,7 +5,7 @@ Workplan: `NK-WP-0033` NetKingdom procedure: `sso-mfa/k8s/privacyidea/reconcile-lldap-resolver-live.sh` (repaired 2026-08-27 in `4a38511`; the previously pinned revision could not complete a run — see NK-WP-0033 T05) Platform recovery contract: railiance-platform `453fed3` Owner cutover receipt: State Hub message `45b236c8-052f-43d3-a472-44f8e9694da2` -Exercise status: exercised 2026-08-27 by Bernd Worsch — first run ever; four defects found and fixed (`4a38511`). See `attended-procedure-standard.md`. +Exercise status: unexercised to successful completion — attempted 2026-08-27 by Bernd Worsch; four defects found and fixed (`4a38511`), no green receipt. The 2026-09-05 shared transport refactor also awaits an attended run. See `attended-procedure-standard.md`. This is the remaining attended provider-admin operation after the owner-reported four-class cutover. It updates only privacyIDEA resolver `lldap-coulomb` so the @@ -20,7 +20,7 @@ State Hub, Git, chat, command arguments, or ordinary logs. The operator must record a private approval receipt containing, at minimum: - incident `KEYCAPE-EXPOSURE-20260823-01`; -- the NetKingdom revision containing `reconcile-lldap-resolver-live.sh` and platform contract `453fed3`; +- the NetKingdom revision containing `reconcile-lldap-resolver-live.sh` and its adjacent `pi_api.py` and platform contract `453fed3`; - the owner receipt `45b236c8-052f-43d3-a472-44f8e9694da2`; - the exact start/end window, attended driver, and independent abort operator; - confirmation that the replacement LLDAP credential is the provider-approved diff --git a/docs/platform-root-custody.md b/docs/platform-root-custody.md index b4d1280..b86d984 100644 --- a/docs/platform-root-custody.md +++ b/docs/platform-root-custody.md @@ -1,5 +1,7 @@ # Platform Root Custody +Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](attended-procedure-inventory.md). + Status: active bootstrap policy Date: 2026-05-24 diff --git a/docs/smooth-bootstrap-guide.md b/docs/smooth-bootstrap-guide.md index fb1e2e1..00ca72e 100644 --- a/docs/smooth-bootstrap-guide.md +++ b/docs/smooth-bootstrap-guide.md @@ -1,5 +1,7 @@ # NET-WP-0018 Smooth Bootstrap Guide +Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](attended-procedure-inventory.md). + **Status:** draft (initial consolidation for T05) **Date:** 2026-06-03 **Purpose:** The single operator guide for a smooth, repeatable NetKingdom security bootstrap. An operator knows what to do, in what order, and what (non-secret) evidence proves each step complete. Covers the full sequence from the T05 spec + inputs from T02 runtime architecture, T03 retrospective + gap matrix, existing UX contracts (operator-journey, user-lifecycle), console lifecycle-guide (incl. 0019 T06-adjacent polish), evidence templates/validators, and make targets. diff --git a/docs/verification-audit-2026-09-05.md b/docs/verification-audit-2026-09-05.md new file mode 100644 index 0000000..5064f9b --- /dev/null +++ b/docs/verification-audit-2026-09-05.md @@ -0,0 +1,20 @@ +# SSO/MFA verification audit — 2026-09-05 + +NK-WP-0034-T02. Read-only review of `sso-mfa/k8s/verify-t02.sh` through +`verify-t08.sh`; no live target was contacted. T06 is the only verifier rewritten +in this workplan. The remaining findings are candidates for follow-up work. + +| Script | Intended property | Actual assertion | Verdict | +| --- | --- | --- | --- | +| `verify-t02.sh` | Working namespaces, isolation, certificate issuance and storage | Named namespaces/policies/issuers exist; selected Ready conditions; optional certificate/storage tests can skip. Label and default StorageClass checks inspect command exit rather than nonempty values. | Insufficient: missing values can pass, network isolation never exercised, storage/certificates can remain unproven. | +| `verify-t03.sh` | PostgreSQL usable by privacyIDEA with required database and role | Cluster Ready, running primary, catalog rows for database/role, Secrets and optional scheduled backup resource | Partial: catalog/readiness checks are real but no application credential connection, write/read transaction or restore proof. | +| `verify-t04.sh` | Usable privacyIDEA deployment and bootstrap | Running pod, resource names/ports, certificates/PVCs, key files and admin rows; absent keys/admins can be warnings | Insufficient: no authenticated operation or MFA; warning-only gaps return 0. | +| `verify-t05.sh` | Integrated LLDAP–Authelia–KeyCape SSO | Readiness, resources and Secret presence, health responses and discovery content | Partial: service availability is checked; LDAP authentication, code exchange, claim correctness and login are not. Warnings can return 0. | +| `verify-t06.sh` (before review) | Working realm/resolver and MFA | Realm/resolver existence, list-users response parsed as a dictionary although it is a list, KeyCape token/list endpoint, policies/portal | False-success path: failed admin auth skips core checks, empty user results warn, no MFA validation; warning-only exit is 0. | +| `verify-t06.sh` (this revision) | Known user resolves through the realm and validates OTP | Authenticated realm binding, resolver numeric fields, exact user/resolver match, successful TOTP/HOTP result | Automated functional failure matrix passes; attended provider exercise pending. Does not claim complete browser SSO. | +| `verify-t07.sh` | User management/self-service and registered clients work | Group/user existence, break-glass membership, portal HTTP, discovery and config fields | Insufficient: auth failure can skip most checks with exit 0; no user lifecycle, enrollment or client login. OpenBao error strings beginning `openbao-admin` also match the success branch despite validation failure. | +| `verify-t08.sh` | Recoverable backups, DR and break-glass readiness | CronJobs/RBAC, prior job success, backup file counts and runbook presence; offsite custody always manual warning | Insufficient: no decryption, restore, recovered application operation or emergency login; warning-only exit 0 does not establish recoverability. | + +Runbooks must interpret the legacy verifiers as diagnostics until their missing +properties are exercised. Existing object checks are useful evidence but cannot +close the broader task by themselves. diff --git a/docs/verify-t06.md b/docs/verify-t06.md new file mode 100644 index 0000000..9f70d36 --- /dev/null +++ b/docs/verify-t06.md @@ -0,0 +1,48 @@ +# Verify realm resolution and MFA + +Exercise status: unexercised against a real or scratch privacyIDEA target; simulated HTTP failure matrix passed 2026-09-05 by codex. + +Run `bash sso-mfa/k8s/verify-t06.sh --user platform-root` from the repository +root in an attended terminal. Use an enrolled test user where possible. The +script prompts for the current pi-admin password and, after lookup succeeds, +a fresh TOTP/HOTP code including its token PIN if required. It never reads a +bootstrap bundle or Kubernetes Secret. No password, OTP or token belongs in +command arguments, logs or the receipt. Do not enable shell tracing. + +`--pi-url`, `--realm`, and `--resolver` select a different approved target. +The CLI requires HTTPS. The old positional secrets-directory argument is +removed; `repair-realm-live.sh` now calls this interface and propagates failure. + +Success requires all of these properties: + +- pi-admin authenticates and the named realm binds the expected resolver; +- the resolver is LDAP and `TIMEOUT`, `CACHE_TIMEOUT`, and `SIZELIMIT` are + present non-negative integers; +- the exact username resolves through that resolver when queried by realm; +- `/validate/check` succeeds and identifies a TOTP/HOTP token. Passthrough, + static-password tokens, denied checks and missing token details fail. + +Exit 0 means these properties passed; exit 1 means a failed phase; exit 2 +means invalid usage or missing attendance. The sanitized JSON receipt carries +only result, phase and proof names. A successful OTP validation consumes a code +and may update token counters or authentication audit state. It does not +create or repair configuration. This proves the privacyIDEA leg; complete +KeyCape browser login, OIDC callback, enrollment and recovery remain separate +exercises. Deployment diagnostics remain in `verify-t04.sh` and `verify-t05.sh`. + +The response shapes follow the provider's [realm API](https://privacyidea.readthedocs.io/en/stable/modules/api/realm.html), +[user API](https://privacyidea.readthedocs.io/en/stable/modules/api/user.html), +[resolver API](https://privacyidea.readthedocs.io/en/stable/modules/api/resolver.html) +and [validation API](https://privacyidea.readthedocs.io/en/stable/modules/api/validate.html). +If policy suppresses token details, the verifier cannot establish OTP proof and +fails; do not weaken that check to accept passthrough. + +## Attended acceptance still required + +On an approved scratch target, record the target revision and operator, retain +the resolver tuning configuration privately, then clear each tuning field in +turn. Run the verifier and retain each sanitized FAIL receipt. Restore the +fields and run with a fresh OTP; retain the PASS receipt. Restore the target +configuration even if any step fails. Do not break the production resolver to +perform this test. The automated HTTP fixture demonstrates the same failure +and restoration sequence but does not satisfy this attended exercise. diff --git a/intakes/intakes.md b/intakes/intakes.md index ce67388..811fdf9 100644 --- a/intakes/intakes.md +++ b/intakes/intakes.md @@ -32,6 +32,7 @@ description: 'A conformance sweep on 2026-08-28 found this repository has no lay Standard: net-kingdom/canon/standards/security-layer-model_v0.4.md.' created: '2026-08-28T21:01:51.894396Z' updated: '2026-08-28T21:01:51.894396Z' +state_hub_intake_id: "01a06eb9-8b32-7f2a-aae5-0887b1083137" ``` ## NET-IN-0002 — Amend tenant-engine-boundary-contract_v0.1 for Engine/PIP, shipped guardrails, and PEP-shaped writes @@ -69,5 +70,6 @@ description: > contested that placement). created: '2026-08-29' updated: '2026-08-29' +state_hub_intake_id: "01a06eb9-c4af-7a9e-8ff5-1bc5f845eb38" ``` diff --git a/sso-mfa/k8s/README.md b/sso-mfa/k8s/README.md index 5cdfd96..d989529 100644 --- a/sso-mfa/k8s/README.md +++ b/sso-mfa/k8s/README.md @@ -1,5 +1,7 @@ # T02 — K8s Foundations +Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../docs/attended-procedure-inventory.md). + Phase 1 of NK-WP-0001: namespaces, NetworkPolicies, cert-manager, StorageClass. ## SSO stack overview diff --git a/sso-mfa/k8s/authelia/README.md b/sso-mfa/k8s/authelia/README.md index 83f154a..8cf049e 100644 --- a/sso-mfa/k8s/authelia/README.md +++ b/sso-mfa/k8s/authelia/README.md @@ -1,5 +1,7 @@ # T05b — Authelia (Authentication Frontend) +Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md). + Authelia is the password-authentication frontend for the net-kingdom SSO stack. It acts as an upstream OIDC provider for KeyCape: users are redirected here to enter their password; Authelia validates credentials against LLDAP and returns diff --git a/sso-mfa/k8s/backup/DR-RUNBOOK.md b/sso-mfa/k8s/backup/DR-RUNBOOK.md index f547096..432bdb0 100644 --- a/sso-mfa/k8s/backup/DR-RUNBOOK.md +++ b/sso-mfa/k8s/backup/DR-RUNBOOK.md @@ -1,5 +1,7 @@ # Disaster Recovery Runbook — net-kingdom SSO/MFA Platform +Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md). + **Stack:** LLDAP + Authelia + KeyCape (sso namespace) + privacyIDEA (mfa namespace) **PostgreSQL:** Managed separately by CNPG (`postgresql/scheduled-backup.yaml`) @@ -171,7 +173,7 @@ cd ../keycape && ./create-pi-token.sh && ./create-secrets.sh kubectl rollout restart deployment/keycape -n sso # 7. Verify -./verify-t04.sh && ./verify-t05.sh && ./verify-t06.sh && ./verify-t07.sh && ./verify-t08.sh +./verify-t04.sh && ./verify-t05.sh && ./verify-t06.sh --user platform-root && ./verify-t07.sh && ./verify-t08.sh ``` --- diff --git a/sso-mfa/k8s/keycape/README.md b/sso-mfa/k8s/keycape/README.md index ef68313..730a0db 100644 --- a/sso-mfa/k8s/keycape/README.md +++ b/sso-mfa/k8s/keycape/README.md @@ -1,5 +1,7 @@ # T05c — KeyCape (OIDC Orchestration Layer) +Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md). + KeyCape is the stateless OIDC server that ties the stack together. It orchestrates the full authentication flow: 1. User visits a registered application diff --git a/sso-mfa/k8s/lldap/OPERATOR-GROUPS.md b/sso-mfa/k8s/lldap/OPERATOR-GROUPS.md index 68f6c5f..04f8524 100644 --- a/sso-mfa/k8s/lldap/OPERATOR-GROUPS.md +++ b/sso-mfa/k8s/lldap/OPERATOR-GROUPS.md @@ -1,5 +1,7 @@ # Operator groups — membership runbook +Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md). + Least-privilege **app-scoped** LLDAP groups that gate browser SSO via Authelia domain rules. Distinct from platform groups (`net-kingdom-users` / `net-kingdom-admins`). diff --git a/sso-mfa/k8s/lldap/README.md b/sso-mfa/k8s/lldap/README.md index d734b02..fd90829 100644 --- a/sso-mfa/k8s/lldap/README.md +++ b/sso-mfa/k8s/lldap/README.md @@ -1,5 +1,7 @@ # T05a — LLDAP (Lightweight LDAP Directory) +Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md). + LLDAP is the user and group directory for the net-kingdom SSO stack. It provides LDAP access to Authelia (credential validation) and KeyCape (user attribute lookup). The admin web UI is IP-restricted and never exposed publicly. diff --git a/sso-mfa/k8s/postgresql/README.md b/sso-mfa/k8s/postgresql/README.md index a2ef0d0..6c19846 100644 --- a/sso-mfa/k8s/postgresql/README.md +++ b/sso-mfa/k8s/postgresql/README.md @@ -1,5 +1,7 @@ # T03 — PostgreSQL (CloudNativePG) +Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md). + Phase 2 of NK-WP-0001: CloudNativePG cluster with `keycloak_db` and `privacyidea_db`. ## Prerequisites diff --git a/sso-mfa/k8s/privacyidea/README.md b/sso-mfa/k8s/privacyidea/README.md index a0b7cdf..1a96795 100644 --- a/sso-mfa/k8s/privacyidea/README.md +++ b/sso-mfa/k8s/privacyidea/README.md @@ -1,5 +1,7 @@ # T04 — Phase 3: Deploy privacyIDEA +Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md). + Phase 3 of NK-WP-0001: deploys the MFA core (privacyIDEA) in the `mfa` namespace. **Hostnames (config points CP-NK-002 / CP-NK-003):** diff --git a/sso-mfa/k8s/privacyidea/bootstrap-realm.sh b/sso-mfa/k8s/privacyidea/bootstrap-realm.sh index 347f529..14589a9 100755 --- a/sso-mfa/k8s/privacyidea/bootstrap-realm.sh +++ b/sso-mfa/k8s/privacyidea/bootstrap-realm.sh @@ -24,6 +24,8 @@ set -euo pipefail +PI_HELPER="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/pi_api.py" + NAMESPACE="mfa" SECRETS_DIR="${1:-../../bootstrap/secrets}" PI_URL="${2:-https://pink.coulomb.social}" @@ -46,8 +48,8 @@ LDAP_SIZELIMIT="${LDAP_SIZELIMIT:-500}" PASS_COUNT=0 FAIL_COUNT=0 -ok() { echo " [OK] $1"; ((PASS_COUNT++)); } -fail() { echo " [FAIL] $1"; ((FAIL_COUNT++)); } +ok() { echo " [OK] $1"; PASS_COUNT=$((PASS_COUNT + 1)); } +fail() { echo " [FAIL] $1"; FAIL_COUNT=$((FAIL_COUNT + 1)); } info() { echo " [INFO] $1"; } # ── Validate secrets ────────────────────────────────────────────────────────── @@ -78,12 +80,10 @@ echo "Authenticating to privacyIDEA at $PI_URL ..." if ! AUTH_RESPONSE=$(PI_ADMIN_PASS="$PI_ADMIN_PASS" python3 -c ' import json import os +print() print(json.dumps({"username": "pi-admin", "password": os.environ["PI_ADMIN_PASS"]})) -' | curl -sS -X POST "$PI_URL/auth" \ - -H "Content-Type: application/json" \ - --data-binary @- 2>/dev/null); then - echo "ERROR: Could not reach $PI_URL — is the cluster up and privacyIDEA running?" >&2 - echo " Run verify-t04.sh to diagnose." >&2 +' | python3 "$PI_HELPER" POST "$PI_URL/auth"); then + echo "ERROR: privacyIDEA authentication request failed." >&2 exit 1 fi @@ -92,26 +92,14 @@ PI_TOKEN=$(echo "$AUTH_RESPONSE" | python3 -c \ if [[ -z "$PI_TOKEN" ]]; then echo "ERROR: Authentication failed — check pi-admin credentials and MFA enrollment." >&2 - echo " Response: $AUTH_RESPONSE" >&2 exit 1 fi info "Authenticated as pi-admin (token obtained)" pi_api() { - # pi_api [json-body] - # Content-Type is only set on requests with a body — Werkzeug 3.x raises - # BadRequest if Content-Type: application/json is sent on a bodyless GET. - local method="$1"; local path="$2"; local body="${3:-}" - if [[ -n "$body" ]]; then - printf '%s' "$body" | curl -sf -X "$method" "$PI_URL$path" \ - -H "Authorization: $PI_TOKEN" \ - -H "Content-Type: application/json" \ - --data-binary @- 2>/dev/null || echo "CURL_FAILED" - else - curl -sf -X "$method" "$PI_URL$path" \ - -H "Authorization: $PI_TOKEN" \ - 2>/dev/null || echo "CURL_FAILED" - fi + local method="$1" path="$2" body="${3:-}" + { printf '%s\n' "$PI_TOKEN"; printf '%s' "$body"; } | + python3 "$PI_HELPER" "$method" "$PI_URL$path" || true } check_result() { @@ -332,7 +320,7 @@ echo " WebUI → Config → Policies → mfa-passthru-phase1 → set activ echo " Create a new policy: scope=authentication, action=otppin=tokenpin, realm=$REALM_NAME" echo " This blocks login for users without an enrolled token." echo "" -echo "Next step: ./verify-t06.sh" +echo "Next step: ../verify-t06.sh --user platform-root" if [[ "$FAIL_COUNT" -gt 0 ]]; then exit 1 diff --git a/sso-mfa/k8s/privacyidea/pi_api.py b/sso-mfa/k8s/privacyidea/pi_api.py new file mode 100644 index 0000000..63e325d --- /dev/null +++ b/sso-mfa/k8s/privacyidea/pi_api.py @@ -0,0 +1,56 @@ +"""Shared JSON request transport. Credentials stay in memory or stdin, never argv.""" + +import json +import sys +import urllib.error +import urllib.request + + +def request(url, payload=None, token=None, *, method=None): + headers = {} + data = None + if payload is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(payload).encode("utf-8") + if token: + headers["Authorization"] = token + # Bodyless GET must not advertise JSON: Werkzeug can reject it before routing. + req = urllib.request.Request( + url, + data=data, + headers=headers, + method=method or ("POST" if payload is not None else "GET"), + ) + try: + with urllib.request.urlopen(req, timeout=20) as response: + status, body = response.status, response.read() + try: + return status, json.loads(body) if body else None + except (ValueError, UnicodeError): + return status, None + except urllib.error.HTTPError as exc: + exc.close() + return exc.code, None + except (urllib.error.URLError, TimeoutError, OSError): + return 0, None + + +def main(): + # Shell adapter: token on the first line, optional JSON body on the rest. + token = sys.stdin.readline().rstrip("\n") + body = sys.stdin.read() + status, result = request( + sys.argv[2], + json.loads(body) if body else None, + token or None, + method=sys.argv[1], + ) + if not 200 <= status < 300 or not isinstance(result, dict): + print("CURL_FAILED") + return 1 + print(json.dumps(result)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sso-mfa/k8s/privacyidea/reconcile-lldap-resolver-live.sh b/sso-mfa/k8s/privacyidea/reconcile-lldap-resolver-live.sh index 541d40c..e69cf8f 100755 --- a/sso-mfa/k8s/privacyidea/reconcile-lldap-resolver-live.sh +++ b/sso-mfa/k8s/privacyidea/reconcile-lldap-resolver-live.sh @@ -121,7 +121,8 @@ else fi prompt_secret "one-time MFA code for $MFA_USER@$MFA_REALM" "$tmp/otp" -if python3 - "$tmp/pi-admin" "$tmp/lldap-new" "$tmp/lldap-old" "$tmp/otp" \ +PI_HELPER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +if PYTHONPATH="$PI_HELPER_DIR${PYTHONPATH:+:$PYTHONPATH}" python3 - "$tmp/pi-admin" "$tmp/lldap-new" "$tmp/lldap-old" "$tmp/otp" \ "$PI_URL" "$LLDAP_AUTH_URL" "$LLDAP_URL" "$LLDAP_BASE_DN" "$LLDAP_BIND_DN" \ "$RESOLVER_NAME" "$MFA_USER" "$MFA_REALM" "$KEYCAPE_DISCOVERY_URL" "$MODE" "$tmp/phase" "$PREDECESSOR" \ "$LDAP_TIMEOUT" "$LDAP_CACHE_TIMEOUT" "$LDAP_SIZELIMIT" <<'PY' @@ -148,33 +149,7 @@ def secret(path: str) -> str: raise RuntimeError("empty protected input") return value -def request(url: str, payload: dict | None = None, token: str | None = None) -> tuple[int, dict | None]: - # Content-Type is only set on requests with a body — Werkzeug 3.x raises - # BadRequest if Content-Type: application/json is sent on a bodyless GET, - # and the rejection happens in front of privacyIDEA, so the reply is an HTML - # error page rather than a JSON result. Same fix as bootstrap-realm.sh's - # pi_api helper. - headers = {} - if payload is not None: - headers["Content-Type"] = "application/json" - if token: - headers["Authorization"] = token - data = json.dumps(payload).encode("utf-8") if payload is not None else None - req = urllib.request.Request(url, data=data, headers=headers, method="POST" if payload is not None else "GET") - try: - with urllib.request.urlopen(req, timeout=20) as response: - status = response.status - body = response.read() - if not body: - return status, None - try: - return status, json.loads(body) - except json.JSONDecodeError: - return status, None - except urllib.error.HTTPError as exc: - return exc.code, None - except (urllib.error.URLError, TimeoutError): - return 0, None +from pi_api import request def check_k8s_ready() -> None: workloads = (("sso", "lldap"), ("mfa", "privacyidea"), ("sso", "keycape"), diff --git a/sso-mfa/k8s/privacyidea/repair-realm-live.sh b/sso-mfa/k8s/privacyidea/repair-realm-live.sh index 8854385..afee70d 100755 --- a/sso-mfa/k8s/privacyidea/repair-realm-live.sh +++ b/sso-mfa/k8s/privacyidea/repair-realm-live.sh @@ -40,17 +40,6 @@ printf "LLDAP_LDAP_USER_PASS=%q\n" "$LLDAP_LDAP_USER_PASS" > "$tmp/lldap/secrets bash "$SCRIPT_DIR/bootstrap-realm.sh" "$tmp" "$PI_URL" -if ! bash "$SSO_MFA_K8S_DIR/verify-t06.sh" "$tmp"; then - cat >&2 <<'WARN' - -[WARN] verify-t06 still reports failures. If realm, resolver, policies, and -self-service pass but KeyCape token checks fail, run the KeyCape privacyIDEA -MFA token repair action after platform-root enrollment. -WARN -fi - -cat <<'OK' - -[OK] privacyIDEA coulomb realm repair command finished. Enroll or re-enroll -platform-root TOTP in privacyIDEA next. -OK +echo "Realm configuration applied; functional verification requires an enrolled OTP token." +bash "$SSO_MFA_K8S_DIR/verify-t06.sh" --pi-url "$PI_URL" --user "${MFA_USER:-platform-root}" +echo "[OK] realm repair and functional MFA verification passed." diff --git a/sso-mfa/k8s/privacyidea/verify_mfa.py b/sso-mfa/k8s/privacyidea/verify_mfa.py new file mode 100644 index 0000000..e5ff337 --- /dev/null +++ b/sso-mfa/k8s/privacyidea/verify_mfa.py @@ -0,0 +1,128 @@ +"""Attended functional privacyIDEA proof for verify-t06.sh.""" + +import argparse +import getpass +import json +import sys +import urllib.parse + +from pi_api import request + + +def value(status, body): + if status != 200 or not isinstance(body, dict): + raise ValueError("request failed") + result = body.get("result") + if not isinstance(result, dict) or result.get("status") is not True: + raise ValueError("API operation failed") + return result.get("value") + + +def verify(url, user, realm, resolver, password, otp_prompt): + phase = "authentication" + try: + auth = value( + *request(url + "/auth", {"username": "pi-admin", "password": password}) + ) + token = auth.get("token") if isinstance(auth, dict) else None + if not isinstance(token, str) or not token: + raise ValueError("admin token missing") + phase = "realm-binding" + realms = value(*request(url + "/realm/", token=token)) + bindings = realms[realm]["resolver"] + if not any( + isinstance(item, dict) and item.get("name") == resolver for item in bindings + ): + raise ValueError("realm not bound to expected resolver") + phase = "resolver-tuning" + resolvers = value( + *request( + url + "/resolver/" + urllib.parse.quote(resolver, safe=""), token=token + ) + ) + config = resolvers[resolver] + if config.get("type") != "ldapresolver": + raise ValueError("wrong resolver type") + data = config["data"] + for name in ("TIMEOUT", "CACHE_TIMEOUT", "SIZELIMIT"): + number = data.get(name) + if ( + isinstance(number, bool) + or not isinstance(number, (str, int)) + or not str(number).isascii() + or not str(number).isdigit() + ): + raise ValueError("missing or invalid numeric parameter") + phase = "known-user-lookup" + query = urllib.parse.urlencode({"realm": realm, "username": user}) + users = value(*request(url + "/user/?" + query, token=token)) + if not isinstance(users, list) or not any( + isinstance(item, dict) + and item.get("username") == user + and item.get("resolver") == resolver + for item in users + ): + raise ValueError("known user did not resolve through expected resolver") + phase = "mfa-validation" + otp = otp_prompt() + if not otp: + raise ValueError("MFA input missing") + status, body = request( + url + "/validate/check", {"user": user, "realm": realm, "pass": otp}, token + ) + # passthru can return value=true for token-less users. Require a token + # serial and type in the successful validation, not password-only success. + if value(status, body) is not True: + raise ValueError("MFA denied") + detail = body.get("detail", {}) + if not detail.get("serial") or detail.get("type") not in {"totp", "hotp"}: + raise ValueError("no token-backed MFA proof") + except (OSError, ValueError, TypeError, KeyError, AttributeError, EOFError): + return {"result": "FAIL", "phase": phase} + return { + "result": "PASS", + "phase": "complete", + "proofs": [ + "realm-binding", + "resolver-tuning", + "known-user-lookup", + "token-backed-mfa", + ], + } + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pi-url", default="https://pink.coulomb.social") + parser.add_argument("--user", required=True) + parser.add_argument("--realm", default="coulomb") + parser.add_argument("--resolver", default="lldap-coulomb") + args = parser.parse_args(argv) + if not sys.stdin.isatty(): + print( + "T06 requires an attended terminal for protected password and fresh MFA input.", + file=sys.stderr, + ) + return 2 + if urllib.parse.urlsplit(args.pi_url).scheme != "https": + parser.error("--pi-url must use HTTPS") + try: + password = getpass.getpass("privacyIDEA pi-admin password: ") + if not password: + raise ValueError("empty password") + report = verify( + args.pi_url.rstrip("/"), + args.user, + args.realm, + args.resolver, + password, + lambda: getpass.getpass("Fresh MFA code (include token PIN if required): "), + ) + except (EOFError, KeyboardInterrupt, ValueError): + report = {"result": "FAIL", "phase": "protected-input"} + print(json.dumps(report, sort_keys=True)) + return 0 if report["result"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sso-mfa/k8s/user-engine/README.md b/sso-mfa/k8s/user-engine/README.md index 2cdb094..2eb59e3 100644 --- a/sso-mfa/k8s/user-engine/README.md +++ b/sso-mfa/k8s/user-engine/README.md @@ -1,5 +1,7 @@ # user-engine portal on reef-railiance +Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md). + **Apply home moved.** The managed package is `rapp-user-engine`. Render, deploy, verify, and rollback from that repo (`make deploy`, `make verify-live`). These files remain migration input until that diff --git a/sso-mfa/k8s/verify-t06.sh b/sso-mfa/k8s/verify-t06.sh index e7ccb81..5b3056a 100755 --- a/sso-mfa/k8s/verify-t06.sh +++ b/sso-mfa/k8s/verify-t06.sh @@ -1,285 +1,7 @@ #!/usr/bin/env bash -# verify-t06.sh — verify NK-WP-0001-T06 done-criteria -# -# Checks the MFA flow integration between KeyCape and privacyIDEA. -# -# Sections: -# 1. privacyIDEA pod Running+Ready (namespace: mfa) -# 2. privacyIDEA API reachable -# 3. Realm "coulomb" exists in privacyIDEA -# 4. LDAP resolver "lldap-coulomb" exists -# 5. LDAP resolver resolves users (LLDAP connectivity) -# 6. KeyCape→privacyIDEA token: valid admin token in keycape-pi-token -# 7. KeyCape can list tokens in the coulomb realm -# 8. Self-enrollment policy exists -# 9. Authentication policy exists -# 10. Self-service portal reachable (pink-account.coulomb.social) -# -# Usage: -# chmod +x verify-t06.sh -# ./verify-t06.sh [secrets-dir] -# -# default: ../bootstrap/secrets - +# Functional realm/resolver and token-backed MFA proof, NK-WP-0034. +# Usage: ./verify-t06.sh --user platform-root +# Runbook: docs/verify-t06.md (credentials are prompted, not read from old bundles). set -euo pipefail - -SECRETS_DIR="${1:-../bootstrap/secrets}" -PI_ENV="$SECRETS_DIR/privacyidea/secrets.env" - -PI_HOST="pink.coulomb.social" -PI_URL="https://$PI_HOST" -PI_NAMESPACE="mfa" -SSO_NAMESPACE="sso" -REALM_NAME="coulomb" -RESOLVER_NAME="lldap-coulomb" - -PASS=0 -FAIL=0 -WARN=0 - -pass() { echo " [PASS] $1"; PASS=$((PASS + 1)); } -fail() { echo " [FAIL] $1"; FAIL=$((FAIL + 1)); } -warn() { echo " [WARN] $1"; WARN=$((WARN + 1)); } - -section() { echo ""; echo "── $1 ──────────────────────────────────────"; } - -# ── 1. privacyIDEA pod ──────────────────────────────────────────────────────── -section "1. privacyIDEA pod (namespace: $PI_NAMESPACE)" -PI_POD=$(kubectl get pod -n "$PI_NAMESPACE" \ - -l app.kubernetes.io/name=privacyidea \ - --field-selector=status.phase=Running \ - -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "") - -if [[ -n "$PI_POD" ]]; then - pass "Pod Running: $PI_POD" - READY=$(kubectl get pod -n "$PI_NAMESPACE" "$PI_POD" \ - -o jsonpath='{.status.containerStatuses[0].ready}' 2>/dev/null || echo "false") - if [[ "$READY" == "true" ]]; then - pass "Pod readiness probe passing" - else - fail "Pod is Running but not Ready — check logs: kubectl logs -n $PI_NAMESPACE $PI_POD" - fi -else - fail "No Running privacyIDEA pod in namespace '$PI_NAMESPACE' — run verify-t04.sh" -fi - -# ── 2. privacyIDEA API reachable ────────────────────────────────────────────── -section "2. privacyIDEA API reachable" - -# Authenticate as pi-admin to get a token for subsequent checks. -PI_TOKEN="" -if [[ -f "$PI_ENV" ]]; then - read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; } - PI_ADMIN_PASS=$(read_env "$PI_ENV" PI_ADMIN_PASSWORD) - - if [[ -n "$PI_ADMIN_PASS" ]]; then - AUTH_RESP=$(curl -sf -X POST "$PI_URL/auth" \ - -H "Content-Type: application/json" \ - -d "{\"username\":\"pi-admin\",\"password\":\"$PI_ADMIN_PASS\"}" \ - 2>/dev/null || echo "CURL_FAILED") - if [[ "$AUTH_RESP" != "CURL_FAILED" ]]; then - PI_TOKEN=$(echo "$AUTH_RESP" | python3 -c \ - "import sys,json; print(json.load(sys.stdin)['result']['value']['token'])" \ - 2>/dev/null || echo "") - fi - fi -fi - -if [[ -n "$PI_TOKEN" ]]; then - pass "privacyIDEA API reachable and pi-admin authenticated" -else - warn "Could not authenticate to $PI_URL as pi-admin" - warn " Ensure $PI_ENV exists and pink.coulomb.social is reachable." - warn " Remaining checks that require API access will be skipped." -fi - -pi_get() { - local path="$1" - if [[ -z "$PI_TOKEN" ]]; then echo "NO_TOKEN"; return; fi - curl -sf -X GET "$PI_URL$path" \ - -H "Authorization: $PI_TOKEN" \ - 2>/dev/null || echo "CURL_FAILED" -} - -# ── 3. Realm "netkingdom" exists ────────────────────────────────────────────── -section "3. Realm '$REALM_NAME' in privacyIDEA" -REALM_RESP=$(pi_get "/realm/") -if [[ "$REALM_RESP" == "NO_TOKEN" ]]; then - warn "Skipping realm check — no API token" -elif [[ "$REALM_RESP" == "CURL_FAILED" ]]; then - fail "Could not retrieve realm list from $PI_URL" -else - REALM_EXISTS=$(echo "$REALM_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); print('yes' if '$REALM_NAME' in d.get('result',{}).get('value',{}) else 'no')" \ - 2>/dev/null || echo "no") - if [[ "$REALM_EXISTS" == "yes" ]]; then - pass "Realm '$REALM_NAME' exists" - # Check if it is the default realm - IS_DEFAULT=$(echo "$REALM_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); r=d.get('result',{}).get('value',{}).get('$REALM_NAME',{}); print('yes' if r.get('default') else 'no')" \ - 2>/dev/null || echo "no") - if [[ "$IS_DEFAULT" == "yes" ]]; then - pass "Realm '$REALM_NAME' is the default realm" - else - warn "Realm '$REALM_NAME' exists but is not the default realm" - warn " Run: POST $PI_URL/defaultrealm/$REALM_NAME" - fi - else - fail "Realm '$REALM_NAME' not found — run bootstrap-realm.sh" - fi -fi - -# ── 4. LDAP resolver exists ─────────────────────────────────────────────────── -section "4. LDAP resolver '$RESOLVER_NAME'" -RESOLVER_RESP=$(pi_get "/resolver/$RESOLVER_NAME") -if [[ "$RESOLVER_RESP" == "NO_TOKEN" ]]; then - warn "Skipping resolver check — no API token" -elif [[ "$RESOLVER_RESP" == "CURL_FAILED" ]]; then - fail "Could not retrieve resolver '$RESOLVER_NAME' from $PI_URL" -else - RESOLVER_TYPE=$(echo "$RESOLVER_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); v=d.get('result',{}).get('value',{}).get('data',{}); print(list(v.values())[0].get('type','') if v else '')" \ - 2>/dev/null || echo "") - if [[ "$RESOLVER_TYPE" == "ldapresolver" ]]; then - pass "Resolver '$RESOLVER_NAME' exists (type: ldapresolver)" - elif [[ -z "$RESOLVER_TYPE" ]]; then - fail "Resolver '$RESOLVER_NAME' not found — run bootstrap-realm.sh" - else - warn "Resolver '$RESOLVER_NAME' has unexpected type: '$RESOLVER_TYPE'" - fi -fi - -# ── 5. LDAP resolver connectivity (user resolution) ────────────────────────── -section "5. LDAP resolver user resolution" -# Test resolver by listing users in the netkingdom realm. -USERS_RESP=$(pi_get "/user/?realm=$REALM_NAME&pagesize=1") -if [[ "$USERS_RESP" == "NO_TOKEN" ]]; then - warn "Skipping user resolution check — no API token" -elif [[ "$USERS_RESP" == "CURL_FAILED" ]]; then - fail "Could not query users in realm '$REALM_NAME' — LDAP resolver may be broken" -else - USER_COUNT=$(echo "$USERS_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); print(len(d.get('result',{}).get('value',{}).get('users',[])))" \ - 2>/dev/null || echo "0") - if [[ "$USER_COUNT" -gt 0 ]]; then - pass "LDAP resolver resolves users from LLDAP ($USER_COUNT returned in page)" - else - warn "LDAP resolver returned 0 users — LLDAP may have no users yet, or the resolver may be misconfigured" - warn " Check: WebUI → Config → Resolver → $RESOLVER_NAME → [Test]" - fi -fi - -# ── 6. KeyCape privacyIDEA token ────────────────────────────────────────────── -section "6. KeyCape→privacyIDEA admin token" -# The token lives in the keycape-pi-token Secret in the sso namespace. -# It should have been created by keycape/create-pi-token.sh after T04 bootstrap. -if kubectl get secret keycape-pi-token -n "$SSO_NAMESPACE" &>/dev/null; then - pass "Secret keycape-pi-token exists in namespace $SSO_NAMESPACE" - TOKEN_VALUE=$(kubectl get secret keycape-pi-token -n "$SSO_NAMESPACE" \ - -o jsonpath='{.data.pi_admin_token}' 2>/dev/null | base64 -d 2>/dev/null || echo "") - if [[ -n "$TOKEN_VALUE" && "$TOKEN_VALUE" != "PENDING_create-pi-token.sh" ]]; then - pass "keycape-pi-token contains a non-placeholder token" - else - fail "keycape-pi-token is a placeholder — run keycape/create-pi-token.sh after T04 bootstrap" - fi -else - fail "Secret keycape-pi-token not found in namespace $SSO_NAMESPACE" - fail " Run: cd sso-mfa/k8s/keycape && ./create-pi-token.sh" -fi - -# ── 7. KeyCape can list tokens via privacyIDEA API ─────────────────────────── -section "7. KeyCape→privacyIDEA API connectivity" -# Use the keycape-pi-token to call the token list endpoint. -KC_PI_TOKEN=$(kubectl get secret keycape-pi-token -n "$SSO_NAMESPACE" \ - -o jsonpath='{.data.pi_admin_token}' 2>/dev/null | base64 -d 2>/dev/null || echo "") - -if [[ -z "$KC_PI_TOKEN" || "$KC_PI_TOKEN" == "PENDING_create-pi-token.sh" ]]; then - warn "Skipping connectivity check — keycape-pi-token not populated" -else - TOKEN_RESP=$(curl -sf -X GET "$PI_URL/token/?realm=$REALM_NAME&pagesize=1" \ - -H "Authorization: Bearer $KC_PI_TOKEN" \ - 2>/dev/null || echo "CURL_FAILED") - if [[ "$TOKEN_RESP" == "CURL_FAILED" ]]; then - fail "KeyCape→privacyIDEA: token list request failed (network or auth error)" - else - STATUS=$(echo "$TOKEN_RESP" | python3 -c \ - "import sys,json; print(json.load(sys.stdin).get('result',{}).get('status',''))" \ - 2>/dev/null || echo "") - if [[ "$STATUS" == "True" || "$STATUS" == "true" ]]; then - pass "KeyCape→privacyIDEA: token list API returns status=True" - else - fail "KeyCape→privacyIDEA: token list API returned unexpected status: '$STATUS'" - fi - fi -fi - -# ── 8. Self-enrollment policy ───────────────────────────────────────────────── -section "8. Self-enrollment policy" -POLICY_RESP=$(pi_get "/policy/totp-self-enrollment") -if [[ "$POLICY_RESP" == "NO_TOKEN" ]]; then - warn "Skipping policy check — no API token" -elif [[ "$POLICY_RESP" == "CURL_FAILED" ]]; then - warn "Could not retrieve policy 'totp-self-enrollment'" -else - POLICY_EXISTS=$(echo "$POLICY_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); print('yes' if d.get('result',{}).get('value',{}).get('totp-self-enrollment') else 'no')" \ - 2>/dev/null || echo "no") - if [[ "$POLICY_EXISTS" == "yes" ]]; then - pass "Policy 'totp-self-enrollment' exists" - else - warn "Policy 'totp-self-enrollment' not found — run bootstrap-realm.sh" - fi -fi - -# ── 9. Authentication policy ────────────────────────────────────────────────── -section "9. Authentication policy (passthru phase 1)" -POLICY_RESP=$(pi_get "/policy/mfa-passthru-phase1") -if [[ "$POLICY_RESP" == "NO_TOKEN" ]]; then - warn "Skipping policy check — no API token" -elif [[ "$POLICY_RESP" == "CURL_FAILED" ]]; then - warn "Could not retrieve policy 'mfa-passthru-phase1'" -else - POLICY_EXISTS=$(echo "$POLICY_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); print('yes' if d.get('result',{}).get('value',{}).get('mfa-passthru-phase1') else 'no')" \ - 2>/dev/null || echo "no") - if [[ "$POLICY_EXISTS" == "yes" ]]; then - pass "Policy 'mfa-passthru-phase1' exists (passthru for token-less users)" - else - warn "Policy 'mfa-passthru-phase1' not found — run bootstrap-realm.sh" - fi -fi - -# ── 10. Self-service portal reachable ──────────────────────────────────────── -section "10. Self-service portal (pink-account.coulomb.social)" -PORTAL_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ - "https://pink-account.coulomb.social" 2>/dev/null || echo "000") -if [[ "$PORTAL_STATUS" == "200" || "$PORTAL_STATUS" == "302" ]]; then - pass "Self-service portal reachable (HTTP $PORTAL_STATUS)" -elif [[ "$PORTAL_STATUS" == "000" ]]; then - warn "Self-service portal not reachable — DNS/TLS/ingress may not be configured yet" -else - warn "Self-service portal returned HTTP $PORTAL_STATUS (expected 200 or 302)" -fi - -# ── Summary ─────────────────────────────────────────────────────────────────── -echo "" -echo "════════════════════════════════════════════════════════════" -echo " T06 verification: PASS=$PASS WARN=$WARN FAIL=$FAIL" -echo "════════════════════════════════════════════════════════════" - -if [[ "$FAIL" -gt 0 ]]; then - echo " Result: INCOMPLETE — resolve FAIL items before marking T06 done" - echo "" - echo " Common next steps:" - echo " - Run: sso-mfa/k8s/privacyidea/bootstrap-realm.sh" - echo " - Run: sso-mfa/k8s/keycape/create-pi-token.sh (then restart keycape)" - echo " - Run: sso-mfa/k8s/keycape/create-secrets.sh (to update keycape-config)" - exit 1 -elif [[ "$WARN" -gt 0 ]]; then - echo " Result: PARTIAL — T06 core checks pass; review WARN items" - echo " Enroll a TOTP token and test the end-to-end login flow." - exit 0 -else - echo " Result: COMPLETE — T06 done-criteria met; proceed to T07 (User mgmt & self-service)" - exit 0 -fi +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "$SCRIPT_DIR/privacyidea/verify_mfa.py" "$@" diff --git a/tests/test_privacyidea_verification.py b/tests/test_privacyidea_verification.py new file mode 100644 index 0000000..da377fa --- /dev/null +++ b/tests/test_privacyidea_verification.py @@ -0,0 +1,218 @@ +"""Functional failure matrix through the real HTTP transport, no live credentials.""" + +import copy +import importlib +import json +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +HELPERS = ROOT / "sso-mfa/k8s/privacyidea" +sys.path.insert(0, str(HELPERS)) +pi_api = importlib.import_module("pi_api") +verify_mfa = importlib.import_module("verify_mfa") + + +def result(value, **extra): + return {"result": {"status": True, "value": value}, **extra} + + +@pytest.fixture +def target(): + state = { + "/auth": result({"token": "test-token"}), + "/realm/": result({"coulomb": {"resolver": [{"name": "lldap-coulomb"}]}}), + "/resolver/lldap-coulomb": result( + { + "lldap-coulomb": { + "type": "ldapresolver", + "data": { + "TIMEOUT": "5", + "CACHE_TIMEOUT": "120", + "SIZELIMIT": "500", + }, + } + } + ), + "/user/": result([{"username": "test-user", "resolver": "lldap-coulomb"}]), + "/validate/check": result(True, detail={"serial": "TOTP-TEST", "type": "totp"}), + "requests": [], + } + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_GET(self): + self.respond() + + def do_POST(self): + self.respond() + + def respond(self): + parsed = urlsplit(self.path) + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + state["requests"].append( + (self.command, self.path, dict(self.headers), body) + ) + # Reproduce the original server rejection at the request boundary. + if self.command == "GET" and self.headers.get("Content-Type"): + self.send_error(400) + return + if parsed.path == "/user/": + assert parse_qs(parsed.query) == { + "realm": ["coulomb"], + "username": ["test-user"], + } + response = state.get(parsed.path) + if response is None: + self.send_error(403) + return + self.send_response(200) + self.end_headers() + self.wfile.write(json.dumps(response).encode()) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", state + finally: + server.shutdown() + server.server_close() + thread.join() + + +def run(url): + return verify_mfa.verify( + url, + "test-user", + "coulomb", + "lldap-coulomb", + "test-password", + lambda: "test-otp", + ) + + +@pytest.mark.parametrize("field", ["TIMEOUT", "CACHE_TIMEOUT", "SIZELIMIT"]) +def test_clear_and_restore_tuning(target, field): + url, state = target + data = state["/resolver/lldap-coulomb"]["result"]["value"]["lldap-coulomb"]["data"] + original = data.pop(field) + assert run(url) == {"result": "FAIL", "phase": "resolver-tuning"} + assert all(urlsplit(r[1]).path != "/validate/check" for r in state["requests"]) + data[field] = original + assert run(url)["result"] == "PASS" + + +@pytest.mark.parametrize( + "route,replacement,phase", + [ + ("/auth", None, "authentication"), + ("/auth", result({}), "authentication"), + ("/realm/", result({"coulomb": {"resolver": []}}), "realm-binding"), + ("/user/", result([]), "known-user-lookup"), + ( + "/user/", + result([{"username": "other", "resolver": "lldap-coulomb"}]), + "known-user-lookup", + ), + ( + "/user/", + result([{"username": "test-user", "resolver": "other"}]), + "known-user-lookup", + ), + ("/user/", {"result": {"status": False, "value": []}}, "known-user-lookup"), + ("/validate/check", result(False), "mfa-validation"), + ("/validate/check", result(True), "mfa-validation"), + ( + "/validate/check", + result(True, detail={"serial": "PASSWORD", "type": "spass"}), + "mfa-validation", + ), + ("/validate/check", result(True, detail=None), "mfa-validation"), + ], +) +def test_failures_never_become_success(target, route, replacement, phase): + url, state = target + state[route] = copy.deepcopy(replacement) + assert run(url) == {"result": "FAIL", "phase": phase} + + +def test_transport_and_shell_adapter_share_bodyless_get_behavior(target): + url, state = target + assert run(url)["result"] == "PASS" + proc = subprocess.run( + [sys.executable, str(HELPERS / "pi_api.py"), "GET", url + "/realm/"], + input="test-token\n", + text=True, + capture_output=True, + check=False, + ) + assert proc.returncode == 0 + assert json.loads(proc.stdout)["result"]["status"] is True + for method, path, headers, body in state["requests"]: + if method == "GET": + assert "Content-Type" not in headers + assert not body + else: + assert headers["Content-Type"] == "application/json" + assert isinstance(json.loads(body), dict) + + +def test_shell_entrypoint_requires_attendance_and_does_not_read_bundle(): + proc = subprocess.run( + ["bash", str(ROOT / "sso-mfa/k8s/verify-t06.sh"), "--user", "test-user"], + stdin=subprocess.DEVNULL, + capture_output=True, + check=False, + text=True, + ) + assert proc.returncode == 2 + assert "attended terminal" in proc.stderr + assert not proc.stdout + + +@pytest.mark.parametrize("policy_ok", [True, False]) +def test_bootstrap_uses_shared_transport_and_reports_full_result( + target, tmp_path, policy_ok +): + url, state = target + for route in [ + "/realm/coulomb", + "/defaultrealm/coulomb", + "/policy/totp-self-enrollment", + "/policy/coulomb-friendly-token-labels", + "/policy/mfa-passthru-phase1", + ]: + state[route] = result(True) + if not policy_ok: + state["/policy/totp-self-enrollment"] = None + for folder, content in [ + ("privacyidea", "PI_ADMIN_PASSWORD=test-password\n"), + ("lldap", "LLDAP_LDAP_USER_PASS=test-bind-password\n"), + ]: + directory = tmp_path / folder + directory.mkdir() + (directory / "secrets.env").write_text(content) + proc = subprocess.run( + ["bash", str(HELPERS / "bootstrap-realm.sh"), str(tmp_path), url], + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == (0 if policy_ok else 1) + assert "Realm bootstrap: PASS=" in proc.stdout + assert "FAIL=0" in proc.stdout if policy_ok else "FAIL=1" in proc.stdout + for secret in ["test-token", "test-password", "test-bind-password"]: + assert secret not in proc.stdout + proc.stderr + assert any( + method == "GET" and path == "/resolver/lldap-coulomb" + for method, path, _, _ in state["requests"] + ) diff --git a/tools/emission-cadence-profile/README.md b/tools/emission-cadence-profile/README.md new file mode 100644 index 0000000..48676ae --- /dev/null +++ b/tools/emission-cadence-profile/README.md @@ -0,0 +1,27 @@ +# Emission Cadence Security Profile Checker + +This checker applies the NetKingdom security overlay in +`canon/standards/emission-cadence-security-profile_v0.1.md` only after the +declaration passes an explicitly supplied InfoTechCanon contract schema. + +It deliberately contains no fallback copy of the generic schema. The event +classes passed with `--load-bearing`, `--rare-load-bearing`, and +`--attributive` come from the source's authoritative inventory; the checker +does not infer them from names, payloads, or observed traffic. + +Run its tests with: + +```bash +make emission-cadence-profile-test +``` + +Supply `../info-tech-canon/infospace/schemas/emission-cadence.schema.yaml` +with `--contract-schema`. The canon profile records its version and SHA-256. +Security fields are under each entry's `extensions.netkingdom` namespace. + +The test suite runs contract integration tests directly against a sibling +InfoTechCanon checkout; these tests explicitly skip when it is unavailable. +The small unit-test schema is a test double, not a fallback contract. + +The profile is proposed: current approval-engine and qonto-assistant owner +instances still require migration. Passing a worked example is not adoption. diff --git a/tools/emission-cadence-profile/emission_cadence_profile.py b/tools/emission-cadence-profile/emission_cadence_profile.py new file mode 100644 index 0000000..3f8e679 --- /dev/null +++ b/tools/emission-cadence-profile/emission_cadence_profile.py @@ -0,0 +1,418 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = ["jsonschema>=4.23,<5", "PyYAML>=6,<7"] +# /// + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +import jsonschema +import yaml + +PROFILE_ID = "netkingdom-emission-cadence-security-profile-v0.1" + + +@dataclass(frozen=True, slots=True) +class Finding: + level: str + code: str + event_class: str | None + message: str + + +def load_document(path: Path) -> Any: + with path.open(encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +def _finding(level: str, code: str, event_class: str | None, message: str) -> Finding: + return Finding(level=level, code=code, event_class=event_class, message=message) + + +def _schema_findings(schema: Any, declaration: Any) -> list[Finding]: + try: + jsonschema.Draft202012Validator.check_schema(schema) + except jsonschema.SchemaError as exc: + return [_finding("MUST", "contract-schema-invalid", None, exc.message)] + + validator = jsonschema.Draft202012Validator( + schema, format_checker=jsonschema.FormatChecker() + ) + findings: list[Finding] = [] + for error in sorted( + validator.iter_errors(declaration), + key=lambda item: tuple(str(part) for part in item.path), + ): + location = "/" + "/".join(str(part) for part in error.path) + findings.append( + _finding( + "MUST", + "contract-validation-failed", + None, + f"{location}: {error.message}", + ) + ) + return findings + + +def _entries(declaration: Any) -> tuple[list[dict[str, Any]], list[Finding]]: + if not isinstance(declaration, dict): + return [], [ + _finding( + "MUST", "declaration-not-object", None, "declaration must be an object" + ) + ] + raw_entries = declaration.get("sources") + if not isinstance(raw_entries, list): + return [], [ + _finding( + "MUST", + "sources-unavailable", + None, + "the imported contract must expose cadence entries at /sources", + ) + ] + entries: list[dict[str, Any]] = [] + for index, item in enumerate(raw_entries): + if not isinstance(item, dict): + return [], [ + _finding( + "MUST", + "source-entry-not-object", + None, + f"/sources/{index} must be an object", + ) + ] + entries.append(item) + return entries, [] + + +def evaluate_profile( + declaration: Any, + *, + load_bearing: set[str], + rare_load_bearing: set[str], + attributive: set[str], +) -> list[Finding]: + findings: list[Finding] = [] + overlap = (load_bearing | rare_load_bearing) & attributive + for event_class in sorted(overlap): + findings.append( + _finding( + "MUST", + "inventory-class-conflict", + event_class, + "the source inventory classifies the event as both load-bearing and attributive", + ) + ) + + entries, structural = _entries(declaration) + if structural: + return findings + structural + + indexed: dict[str, dict[str, Any]] = {} + source_ids: set[str] = set() + for entry in entries: + source_id = entry.get("source_id") + if isinstance(source_id, str): + if source_id in source_ids: + findings.append( + _finding( + "MUST", + "duplicate-source-id", + None, + "source_id values must be unique", + ) + ) + source_ids.add(source_id) + event_class = entry.get("event_class") + if not isinstance(event_class, str) or not event_class: + findings.append( + _finding( + "MUST", + "event-class-unavailable", + None, + "each cadence entry needs event_class", + ) + ) + continue + if event_class in indexed: + findings.append( + _finding( + "MUST", + "duplicate-event-class", + event_class, + "only one cadence entry is permitted per event class", + ) + ) + continue + indexed[event_class] = entry + + required_load_bearing = load_bearing | rare_load_bearing + for event_class in sorted(required_load_bearing): + entry = indexed.get(event_class) + if entry is None: + findings.append( + _finding( + "MUST", + "load-bearing-cadence-missing", + event_class, + "a source-declared load-bearing class must declare cadence", + ) + ) + elif ( + entry.get("extensions", {}).get("netkingdom", {}).get("evidence_class") + != "load-bearing" + ): + findings.append( + _finding( + "MUST", + "evidence-class-mismatch", + event_class, + "cadence evidence_class must match the source inventory: load-bearing", + ) + ) + + for event_class in sorted(attributive): + entry = indexed.get(event_class) + if entry is None: + findings.append( + _finding( + "SHOULD", + "attributive-cadence-missing", + event_class, + "an attributive class should declare cadence; completeness must not be claimed", + ) + ) + elif ( + entry.get("extensions", {}).get("netkingdom", {}).get("evidence_class") + != "attributive" + ): + findings.append( + _finding( + "MUST", + "evidence-class-mismatch", + event_class, + "cadence evidence_class must match the source inventory: attributive", + ) + ) + + for event_class in sorted(rare_load_bearing): + entry = indexed.get(event_class) + if entry is None: + continue + if entry.get("form") != "heartbeat-or-reconciliation": + findings.append( + _finding( + "MUST", + "rare-form-invalid", + event_class, + "rare load-bearing evidence requires heartbeat-or-reconciliation", + ) + ) + if ( + entry.get("extensions", {}).get("netkingdom", {}).get("rate_monitoring") + != "forbidden" + ): + findings.append( + _finding( + "MUST", + "rare-rate-monitoring-not-forbidden", + event_class, + "rate_monitoring must be forbidden for rare load-bearing evidence", + ) + ) + heartbeat = entry.get("heartbeat") + if not isinstance(heartbeat, dict): + findings.append( + _finding( + "MUST", + "rare-heartbeat-missing", + event_class, + "rare load-bearing evidence requires a positive heartbeat", + ) + ) + else: + if not heartbeat.get("assertion"): + findings.append( + _finding( + "MUST", + "heartbeat-assertion-missing", + event_class, + "heartbeat must carry a positive assertion", + ) + ) + if heartbeat.get("missing") != "finding": + findings.append( + _finding( + "MUST", + "heartbeat-missing-not-finding", + event_class, + "a missing heartbeat must be a finding", + ) + ) + reconciliation = entry.get("reconciliation", {}) + local = reconciliation.get("compare_local") + observed = reconciliation.get("compare_observed") + divergence = reconciliation.get("divergence") + if local is None or observed is None: + findings.append( + _finding( + "MUST", + "rare-reconciliation-missing", + event_class, + "reconciliation must compare source and evidence-engine counts", + ) + ) + if divergence != "finding": + findings.append( + _finding( + "MUST", + "reconciliation-divergence-not-finding", + event_class, + "reconciliation divergence must be a finding", + ) + ) + + for event_class in sorted(required_load_bearing - rare_load_bearing): + entry = indexed.get(event_class) + if ( + entry is None + or entry.get("extensions", {}).get("netkingdom", {}).get("evidence_class") + != "load-bearing" + ): + continue + if entry.get("form") == "expected-rate": + window = entry.get("window_seconds", entry.get("window")) + if ( + not window + or ( + isinstance(window, str) + and not any(int(part) > 0 for part in re.findall(r"\d+", window)) + ) + or isinstance(window, bool) + or (isinstance(window, (int, float)) and window <= 0) + ): + findings.append( + _finding( + "MUST", + "rate-window-invalid", + event_class, + "expected-rate load-bearing evidence needs a positive window", + ) + ) + if ( + isinstance(entry.get("expected_min"), bool) + or not isinstance(entry.get("expected_min"), int) + or entry["expected_min"] <= 0 + ): + findings.append( + _finding( + "MUST", + "expected-min-invalid", + event_class, + "expected-rate load-bearing evidence needs expected_min > 0", + ) + ) + if entry.get("drop_below") != "finding": + findings.append( + _finding( + "MUST", + "rate-drop-not-finding", + event_class, + "a drop below the declared load-bearing rate must be a finding", + ) + ) + + return sorted( + findings, + key=lambda item: (item.level, item.code, item.event_class or "", item.message), + ) + + +def build_report( + contract_schema: Any, + declaration: Any, + *, + contract_schema_path: str, + declaration_path: str, + load_bearing: set[str], + rare_load_bearing: set[str], + attributive: set[str], + fail_on_should: bool = False, +) -> dict[str, Any]: + findings = _schema_findings(contract_schema, declaration) + contract_valid = not findings + if contract_valid: + findings.extend( + evaluate_profile( + declaration, + load_bearing=load_bearing, + rare_load_bearing=rare_load_bearing, + attributive=attributive, + ) + ) + must_count = sum(item.level == "MUST" for item in findings) + should_count = sum(item.level == "SHOULD" for item in findings) + return { + "profile": PROFILE_ID, + "contract_schema": contract_schema_path, + "declaration": declaration_path, + "contract_valid": contract_valid, + "conformant": must_count == 0 and (not fail_on_should or should_count == 0), + "summary": {"must": must_count, "should": should_count}, + "findings": [asdict(item) for item in findings], + } + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate an imported emission-cadence declaration against the NetKingdom profile." + ) + parser.add_argument("declaration", type=Path) + parser.add_argument("--contract-schema", required=True, type=Path) + parser.add_argument( + "--load-bearing", action="append", default=[], metavar="EVENT_CLASS" + ) + parser.add_argument( + "--rare-load-bearing", action="append", default=[], metavar="EVENT_CLASS" + ) + parser.add_argument( + "--attributive", action="append", default=[], metavar="EVENT_CLASS" + ) + parser.add_argument("--fail-on-should", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + schema = load_document(args.contract_schema) + declaration = load_document(args.declaration) + except (OSError, yaml.YAMLError) as exc: + print(json.dumps({"error": str(exc)}, sort_keys=True), file=sys.stderr) + return 2 + report = build_report( + schema, + declaration, + contract_schema_path=str(args.contract_schema), + declaration_path=str(args.declaration), + load_bearing=set(args.load_bearing), + rare_load_bearing=set(args.rare_load_bearing), + attributive=set(args.attributive), + fail_on_should=args.fail_on_should, + ) + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 if report["conformant"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/emission-cadence-profile/tests/test_emission_cadence_profile.py b/tools/emission-cadence-profile/tests/test_emission_cadence_profile.py new file mode 100644 index 0000000..c68e80f --- /dev/null +++ b/tools/emission-cadence-profile/tests/test_emission_cadence_profile.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import copy +import importlib.util +import pathlib +import sys + +import pytest +import yaml + +TOOL_PATH = pathlib.Path(__file__).resolve().parents[1] / "emission_cadence_profile.py" +SPEC = importlib.util.spec_from_file_location("emission_cadence_profile", TOOL_PATH) +profile = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +sys.modules[SPEC.name] = profile +SPEC.loader.exec_module(profile) + + +CONTRACT_SCHEMA = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["schema_version", "source", "sources"], + "properties": { + "schema_version": {"const": "0.1"}, + "source": {"type": "string"}, + "sources": { + "type": "array", + "items": { + "type": "object", + "required": ["source_id", "event_class", "form"], + "properties": { + "event_class": {"type": "string", "minLength": 1}, + "extensions": { + "type": "object", + "additionalProperties": {"type": "object"}, + }, + "form": {"enum": ["expected-rate", "heartbeat-or-reconciliation"]}, + }, + }, + }, + }, +} + + +def rare_entry() -> dict: + return { + "source_id": "example.audit.deny", + "event_class": "audit.deny", + "extensions": { + "netkingdom": { + "evidence_class": "load-bearing", + "rate_monitoring": "forbidden", + } + }, + "form": "heartbeat-or-reconciliation", + "heartbeat": { + "event_class": "audit.heartbeat", + "interval": "24h", + "assertion": "nothing-to-report", + "missing": "finding", + }, + "reconciliation": { + "compare_local": "source_transition_counts.audit.deny", + "compare_observed": "evidence_counts.audit.deny", + "divergence": "finding", + "undrained_local": "lag-not-divergence", + }, + } + + +def declaration(*entries: dict) -> dict: + return {"schema_version": "0.1", "source": "example", "sources": list(entries)} + + +def report(document: dict, *, load=(), rare=(), attributive=(), schema=CONTRACT_SCHEMA): + return profile.build_report( + schema, + document, + contract_schema_path="info-tech-canon/schema.json", + declaration_path="source/cadence.yaml", + load_bearing=set(load), + rare_load_bearing=set(rare), + attributive=set(attributive), + ) + + +def codes(result: dict) -> set[str]: + return {item["code"] for item in result["findings"]} + + +def test_valid_rare_load_bearing_requires_both_positive_controls() -> None: + result = report(declaration(rare_entry()), rare={"audit.deny"}) + + assert result["contract_valid"] is True + assert result["conformant"] is True + assert result["findings"] == [] + + +def test_contract_validation_runs_before_profile() -> None: + result = report({"source": "example"}, rare={"audit.deny"}) + + assert result["contract_valid"] is False + assert codes(result) == {"contract-validation-failed"} + assert "load-bearing-cadence-missing" not in codes(result) + + +def test_missing_and_mismatched_source_inventory_classes_fail() -> None: + wrong = rare_entry() + wrong["extensions"]["netkingdom"]["evidence_class"] = "attributive" + + missing = report(declaration(), load={"audit.deny"}) + mismatched = report(declaration(wrong), rare={"audit.deny"}) + + assert "load-bearing-cadence-missing" in codes(missing) + assert "evidence-class-mismatch" in codes(mismatched) + assert not missing["conformant"] + assert not mismatched["conformant"] + + +def test_rare_rate_form_is_rejected_but_not_inferred_from_event_name() -> None: + rate = { + "source_id": "example.audit.deny", + "event_class": "audit.deny", + "extensions": { + "netkingdom": { + "evidence_class": "load-bearing", + "rate_monitoring": "forbidden", + } + }, + "form": "expected-rate", + "window": "24h", + "expected_min": 1, + "drop_below": "finding", + } + + del rate["extensions"]["netkingdom"]["rate_monitoring"] + explicit_rare = report(declaration(rate), rare={"audit.deny"}) + source_says_volume = report(declaration(rate), load={"audit.deny"}) + + assert "rare-form-invalid" in codes(explicit_rare) + assert "rare-rate-monitoring-not-forbidden" in codes(explicit_rare) + assert explicit_rare["conformant"] is False + assert source_says_volume["conformant"] is True + + +def test_rare_class_requires_heartbeat_and_reconciliation() -> None: + item = rare_entry() + del item["heartbeat"] + item["reconciliation"] = {"divergence": "ignored"} + + result = report(declaration(item), rare={"audit.deny"}) + + assert { + "rare-heartbeat-missing", + "rare-reconciliation-missing", + "reconciliation-divergence-not-finding", + } <= codes(result) + + +def test_attributive_coverage_is_advisory_by_default() -> None: + result = report(declaration(), attributive={"audit.allow"}) + + assert result["conformant"] is True + assert result["summary"] == {"must": 0, "should": 1} + assert codes(result) == {"attributive-cadence-missing"} + + +def test_duplicate_event_classes_and_inventory_conflict_fail() -> None: + result = report( + declaration(rare_entry(), copy.deepcopy(rare_entry())), + rare={"audit.deny"}, + attributive={"audit.deny"}, + ) + + assert "duplicate-event-class" in codes(result) + assert "inventory-class-conflict" in codes(result) + assert result["conformant"] is False + + +def test_expected_rate_load_bearing_has_positive_threshold_and_finding() -> None: + rate = { + "source_id": "example.audit.decision", + "event_class": "audit.decision", + "extensions": { + "netkingdom": { + "evidence_class": "load-bearing", + "rate_monitoring": "forbidden", + } + }, + "form": "expected-rate", + "window_seconds": 0, + "expected_min": 0, + "drop_below": "log", + } + + result = report(declaration(rate), load={"audit.decision"}) + + assert { + "rate-window-invalid", + "expected-min-invalid", + "rate-drop-not-finding", + } <= codes(result) + + +# Integration uses the owner artifact directly; never vendor a generic schema. +UPSTREAM = pathlib.Path(__file__).resolve().parents[4] / "info-tech-canon" +SCHEMA_PATH = UPSTREAM / "infospace/schemas/emission-cadence.schema.yaml" + + +@pytest.fixture +def upstream_schema(): + if not SCHEMA_PATH.is_file(): + pytest.skip("InfoTechCanon checkout required for contract integration") + return yaml.safe_load(SCHEMA_PATH.read_text()) + + +def canonical_document(): + document = declaration(rare_entry()) + document["declaration_id"] = "example.audit" + return document + + +def test_published_contract_and_namespaced_overlay(upstream_schema): + document = canonical_document() + assert report(document, rare={"audit.deny"}, schema=upstream_schema)["conformant"] + document["sources"][0]["evidence_class"] = "load-bearing" + assert not report(document, rare={"audit.deny"}, schema=upstream_schema)[ + "contract_valid" + ] + + +def test_published_contract_does_not_substitute_for_profile(upstream_schema): + document = canonical_document() + del document["sources"][0]["reconciliation"] + result = report(document, rare={"audit.deny"}, schema=upstream_schema) + assert result["contract_valid"] + assert "rare-reconciliation-missing" in codes(result) + assert not result["conformant"] + + +def test_duplicate_source_ids_fail_even_with_distinct_event_classes(upstream_schema): + document = canonical_document() + other = copy.deepcopy(document["sources"][0]) + other["event_class"] = "audit.revocation" + document["sources"].append(other) + result = report( + document, rare={"audit.deny", "audit.revocation"}, schema=upstream_schema + ) + assert result["contract_valid"] + assert "duplicate-source-id" in codes(result) + assert not result["conformant"] + + +def test_should_policy_and_cli(tmp_path, capsys): + schema = tmp_path / "schema.json" + document = tmp_path / "declaration.yaml" + import json + + schema.write_text(json.dumps(CONTRACT_SCHEMA)) + document.write_text(json.dumps(declaration())) + args = [ + str(document), + "--contract-schema", + str(schema), + "--attributive", + "audit.allow", + ] + assert profile.main(args) == 0 + assert profile.main(args + ["--fail-on-should"]) == 1 + schema.write_text('{"type": "invalid"}') + assert profile.main(args) == 1 + assert "contract-schema-invalid" in capsys.readouterr().out + + +@pytest.mark.parametrize("window", ["PT0S", "P0D", "PT00H00M00S"]) +def test_published_contract_zero_duration_is_not_a_positive_profile_window( + upstream_schema, window +): + document = canonical_document() + entry = document["sources"][0] + entry.pop("heartbeat") + entry.pop("reconciliation") + entry.update( + form="expected-rate", window=window, expected_min=1, drop_below="finding" + ) + result = report(document, load={"audit.deny"}, schema=upstream_schema) + assert result["contract_valid"] + assert "rate-window-invalid" in codes(result) + assert not result["conformant"] diff --git a/workplans/NK-WP-0034-verification-that-verifies.md b/workplans/NK-WP-0034-verification-that-verifies.md index 6e92e94..1b5cf5e 100644 --- a/workplans/NK-WP-0034-verification-that-verifies.md +++ b/workplans/NK-WP-0034-verification-that-verifies.md @@ -4,10 +4,11 @@ type: workplan title: "Make the SSO/MFA verification actually verify" domain: infotech repo: net-kingdom -status: proposed +status: blocked owner: codex topic_slug: infotech created: "2026-08-28" +updated: "2026-09-05" related: - NK-WP-0033 - CUST-ADR-012 @@ -45,7 +46,7 @@ is broken, which is worse than no check, because it is believed. ```task id: NK-WP-0034-T01 -status: todo +status: wait priority: high state_hub_task_id: "9b5d8034-0ef1-53f9-ad4d-37de536bdc62" ``` @@ -65,7 +66,7 @@ them restored, it passes. Demonstrated in both directions, not argued. ```task id: NK-WP-0034-T02 -status: todo +status: done priority: medium state_hub_task_id: "0bd09a40-8b4d-5f9d-8d4c-81c55aa0c565" ``` @@ -83,7 +84,7 @@ Acceptance: one table — script, intended property, actual assertion, verdict. ```task id: NK-WP-0034-T03 -status: todo +status: done priority: medium state_hub_task_id: "becb4dce-4971-5755-888f-5276c4a56fe7" ``` @@ -103,7 +104,7 @@ runbook are listed as a separate finding. ```task id: NK-WP-0034-T04 -status: todo +status: done priority: low state_hub_task_id: "d43ae8e8-eda6-5c1d-9e1b-01966b0e92da" ``` @@ -118,3 +119,46 @@ applied to a copy is a fix that expires the next time someone writes a script. Acceptance: one implementation of the privacyIDEA request path in `sso-mfa/k8s/privacyidea/`; both callers use it; the GET behaviour has a test that fails if the header returns. + + +## Review and implementation — 2026-09-05 + +Reviewed the proposal against current code and implemented all repository-local +changes. T01 waits only for the attended scratch/provider exercise required by +its acceptance. No live credentials were requested and no production resolver +was changed. + +- T01: replaced warning-only T06 success with an attended known-user and + TOTP/HOTP verifier, requiring realm binding, numeric tuning, exact user and + resolver, and token-backed success. Credentials are prompted in process; + missing attendance fails. Updated realm-repair and DR callers. Eighteen + tests exercise the actual shared HTTP transport, including clearing and + restoring every tuning field and rejecting passthrough/static passwords. + See `docs/verify-t06.md` for the remaining attended acceptance and limits. +- T02: completed the per-script property/assertion/verdict table in + `docs/verification-audit-2026-09-05.md`. Other verify scripts retain their + implementations; their gaps are explicitly recorded for follow-up. +- T03: inventoried every shell script under `sso-mfa/k8s/`, added exercise + headers to component runbooks, recorded unknown history and scripts without + runbooks in `docs/attended-procedure-inventory.md`. Corrected the reconciliation + runbook's attempted-run claim: no successful completion receipt exists. +- T04: `privacyidea/pi_api.py` now supplies the request transport to both realm + bootstrap and resolver reconciliation (and T06). Authentication also uses the + shared transport in bootstrap. Its shell adapter keeps the token/body off + argv. Fixed bootstrap's first-success `set -e` counter exit found during the + review. The HTTP fixture rejects JSON Content-Type on GET and verifies the + real Python and shell adapter requests. + +Remaining gate: an attended operator runs the scratch target failure/restoration +procedure and records receipts. An automated fixture is not reported as an +exercised provider run. NK-WP-0033 still needs its own incident receipt and +predecessor-disposition ruling. + +Validation: `python3 -m pytest tests tools -q` passed 106 tests, including 18 +privacyIDEA and 15 cadence cases. Ruff lint/format, shell syntax, embedded Python +compilation and `git diff --check` passed. + +State Hub reconciliation was attempted with both the installed CLI and current +checkout. Full reconciliation remains pending because API queries/writes timed +out or returned connection-refused errors. Generated index/intake metadata was +reviewed; the source files remain authoritative. diff --git a/workplans/NK-WP-0035-emission-cadence-security-profile.md b/workplans/NK-WP-0035-emission-cadence-security-profile.md new file mode 100644 index 0000000..d6ed11c --- /dev/null +++ b/workplans/NK-WP-0035-emission-cadence-security-profile.md @@ -0,0 +1,117 @@ +--- +id: NK-WP-0035 +type: workplan +title: "Publish the NetKingdom emission-cadence security profile" +domain: infotech +repo: net-kingdom +status: blocked +owner: codex +topic_slug: netkingdom +planning_priority: P1 +created: "2026-09-04" +updated: "2026-09-05" +related: + - GH-DEC-2026-004 + - canon/standards/security-layer-model_v0.7.md +--- + +# NK-WP-0035 — NetKingdom emission-cadence security profile + +GH-DEC-2026-004 assigns the ecosystem-wide +`EmissionCadenceDeclaration` contract to `info-tech-canon` and the importing +NetKingdom security profile to this repository. This work accepts that split. +It must not copy the generic schema drafted by `kings-guard` or make the +observer the owner of source classifications. + +## Define the importing security profile + +```task +id: NK-WP-0035-T01 +status: done +priority: high +``` + +Publish the NetKingdom MUST/SHOULD obligations over the generic contract: +load-bearing classes declare cadence, attributive classes should, and rare +load-bearing classes use heartbeat plus reconciliation with rate monitoring +forbidden. Keep source classification owner-authored and preserve the residual +that cadence detects omission only after the fact. + +Implemented as proposed canon at +`canon/standards/emission-cadence-security-profile_v0.1.md`. The profile accepts +the GH-DEC-2026-004 split, requires source-owned classification, distinguishes +MUST from SHOULD coverage, and records that omission detection is after the +fact rather than proof of completeness. + +## Implement mechanical profile validation + +```task +id: NK-WP-0035-T02 +status: done +priority: high +``` + +Validate a declaration against an explicitly supplied InfoTechCanon JSON +Schema before applying the NetKingdom overlay. The checker must not ship a +fallback generic schema, infer evidence class or rarity from event contents, or +treat a SHOULD finding as a MUST failure by default. + +Implemented at +`tools/emission-cadence-profile/emission_cadence_profile.py`. It requires +`--contract-schema`, performs generic validation first, then checks the +NetKingdom overlay against caller-supplied source inventory assertions. No +generic fallback schema or owner declaration instance was added here. + +## Verify the boundary and failure cases + +```task +id: NK-WP-0035-T03 +status: done +priority: medium +``` + +Cover contract-first validation, missing and mismatched class declarations, +the allowed high-volume load-bearing rate form, the forbidden rare-event rate +form, both positive controls for rare load-bearing classes, duplicate classes, +and advisory attributive coverage. + +Verification on 2026-09-04: eight focused tests pass; the 81-test root +`tests/` + `tools/` regression suite passes; Ruff lint and format checks pass; +Python compilation and `git diff --check` pass. + +## Bind and hand off the published contract + +```task +id: NK-WP-0035-T04 +status: wait +priority: high +``` + +When `info-tech-canon` publishes its versioned contract and schema, replace the +pending import locator with its canonical coordinates and digest, validate the +owner-source instances, and notify `kings-guard` to replace its draft-shaped +fixture. This task cannot be completed from NetKingdom without the upstream +artifact and must not be worked around by copying the draft. + +2026-09-05 review: the upstream publication blocker is resolved by +InfoTechCanon 0.7.0 / contract 0.1.0. Bound the profile to the schema coordinates, +revision and SHA-256; fixed the checker to read `extensions.netkingdom`, removed +draft reconciliation aliases, and enforced unique source IDs. Fifteen focused +tests pass, including direct integration with the published owner schema and +CLI exit-policy coverage. + +The current owner instances (`approval-engine/cadence.yaml` and +`qonto-assistant/specs/audit-emission-cadence.yaml`) were reviewed and checked; +both still fail generic contract validation because they use draft envelopes. +No source instance was rewritten by NetKingdom. T04 remains `wait` for each +owner's migration and subsequent profile validation, followed by the King's +Guard handoff. The profile remains proposed. + +Validation: `python3 -m pytest tests tools -q` passed 106 tests, including 18 +privacyIDEA and 15 cadence cases. Ruff lint/format, shell syntax, embedded Python +compilation and `git diff --check` passed. + +State Hub reconciliation was attempted with both the installed CLI and current +checkout. Full reconciliation remains pending because API queries/writes timed +out or returned connection-refused errors. Generated index/intake metadata was +reviewed; the source files remain authoritative.