Validate cadence contract and require functional MFA verification
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ea3-7939-7b63-8125-699f8b50bedd
This commit is contained in:
tegwick 2026-09-05 01:28:05 +02:00
parent d4d61b722e
commit 4e07d60ff1
34 changed files with 1640 additions and 364 deletions

View file

@ -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 \

View file

@ -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

View file

@ -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.

View file

@ -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 |

View file

@ -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.

View file

@ -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. |

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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 LLDAPAutheliaKeyCape 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.

48
docs/verify-t06.md Normal file
View file

@ -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.

View file

@ -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"
```

View file

@ -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

View file

@ -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

View file

@ -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
```
---

View file

@ -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

View file

@ -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`).

View file

@ -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.

View file

@ -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

View file

@ -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):**

View file

@ -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 <method> <path> [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

View file

@ -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())

View file

@ -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"),

View file

@ -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."

View file

@ -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())

View file

@ -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

View file

@ -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]
#
# <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" "$@"

View file

@ -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"]
)

View file

@ -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.

View file

@ -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())

View file

@ -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"]

View file

@ -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.

View file

@ -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.