Validate cadence contract and require functional MFA verification
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ea3-7939-7b63-8125-699f8b50bedd
This commit is contained in:
parent
d4d61b722e
commit
4e07d60ff1
34 changed files with 1640 additions and 364 deletions
6
Makefile
6
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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
135
canon/standards/emission-cadence-security-profile_v0.1.md
Normal file
135
canon/standards/emission-cadence-security-profile_v0.1.md
Normal 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.
|
||||
|
|
@ -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. |
|
||||
|
|
|
|||
60
docs/attended-procedure-inventory.md
Normal file
60
docs/attended-procedure-inventory.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
20
docs/verification-audit-2026-09-05.md
Normal file
20
docs/verification-audit-2026-09-05.md
Normal 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 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.
|
||||
48
docs/verify-t06.md
Normal file
48
docs/verify-t06.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
56
sso-mfa/k8s/privacyidea/pi_api.py
Normal file
56
sso-mfa/k8s/privacyidea/pi_api.py
Normal 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())
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
128
sso-mfa/k8s/privacyidea/verify_mfa.py
Normal file
128
sso-mfa/k8s/privacyidea/verify_mfa.py
Normal 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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" "$@"
|
||||
|
|
|
|||
218
tests/test_privacyidea_verification.py
Normal file
218
tests/test_privacyidea_verification.py
Normal 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"]
|
||||
)
|
||||
27
tools/emission-cadence-profile/README.md
Normal file
27
tools/emission-cadence-profile/README.md
Normal 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.
|
||||
418
tools/emission-cadence-profile/emission_cadence_profile.py
Normal file
418
tools/emission-cadence-profile/emission_cadence_profile.py
Normal 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())
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
117
workplans/NK-WP-0035-emission-cadence-security-profile.md
Normal file
117
workplans/NK-WP-0035-emission-cadence-security-profile.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue