From 989d5d4f5df71c672d4fb3391239239d2c4ed672 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 13:55:18 +0100 Subject: [PATCH] NK-WP-0056-T01 done: Bounded Execution Admission v0.1 (proposed) Standard, schema and validator for the founder's admission of one bounded executor run: exact plan by digest, window by risk class, SI MODE @ SCOPE ceiling with least-privilege coverage, blast radius by consumer, named source-pinned checks, revert per mutating step, budget, single use, and the NK-WP-0040 receipt. check-approval verifies a carrier record (Approval Engine or founder SSH signature): aal2 human approver, not the executor, consumed at most once in the window. Examples express the bind split; step 10 is refused as human-intrinsic. 22 tests; make targets; tool index. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 994874@bnt-lap001 Assistant-Session: 6017c416-d619-47eb-bd1a-f670a02d7d87 --- Makefile | 7 + WORK-RECORDS.md | 2 +- ...unded-execution-admission_v0.1.schema.json | 582 ++++++++++++++++++ .../bounded-execution-admission_v0.1.md | 232 +++++++ .../bind-rotation-svc-authelia.yaml | 197 ++++++ .../bind-split-create-service-accounts.yaml | 135 ++++ .../bind-split-read-only-verify.yaml | 106 ++++ .../bind-split-switch-super-admin.yaml | 136 ++++ tools/bounded-execution-admission/README.md | 18 + .../invalid/human-intrinsic-step.yaml | 147 +++++ .../generate_examples.py | 144 +++++ .../tests/test_validate.py | 195 ++++++ tools/bounded-execution-admission/validate.py | 323 ++++++++++ tools/security-tools.json | 3 + workplans/NK-WP-0056-bounded-executor-path.md | 12 +- 15 files changed, 2237 insertions(+), 2 deletions(-) create mode 100644 canon/schemas/bounded-execution-admission_v0.1.schema.json create mode 100644 canon/standards/bounded-execution-admission_v0.1.md create mode 100644 examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml create mode 100644 examples/bounded-execution-admission/bind-split-create-service-accounts.yaml create mode 100644 examples/bounded-execution-admission/bind-split-read-only-verify.yaml create mode 100644 examples/bounded-execution-admission/bind-split-switch-super-admin.yaml create mode 100644 tools/bounded-execution-admission/README.md create mode 100644 tools/bounded-execution-admission/fixtures/invalid/human-intrinsic-step.yaml create mode 100644 tools/bounded-execution-admission/generate_examples.py create mode 100644 tools/bounded-execution-admission/tests/test_validate.py create mode 100644 tools/bounded-execution-admission/validate.py diff --git a/Makefile b/Makefile index 6a2ce5a..441e7b5 100644 --- a/Makefile +++ b/Makefile @@ -203,6 +203,12 @@ service-identity-test: ## Run service-identity schema/validator tests (NK-WP-005 service-identity-check: ## Evaluate the checked-in service identity declarations (read-only, offline) python3 tools/service-identity/validate.py tools/service-identity/declarations/*.yaml +bounded-execution-admission-test: ## Run bounded-execution admission validator tests (NK-WP-0056-T01) + python3 -m pytest tools/bounded-execution-admission/tests + +bounded-execution-admission-check: ## Validate the example admissions (read-only, offline) + python3 tools/bounded-execution-admission/validate.py validate examples/bounded-execution-admission/*.yaml + bind-split-test: ## LLDAP bind-split helper tests on a throwaway pinned LLDAP (NK-WP-0054-T05; needs docker) uv run --with pytest --with ldap3 --with pyyaml python -m pytest sso-mfa/k8s/lldap/test_bind_split.py sso-mfa/k8s/lldap/test_identity_provisioner_reconcile.py @@ -384,6 +390,7 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a openbao-init-unseal openbao-init-unseal-dry-run \ iam-profile-conformance-test playbook-contract-test posture-feedback-test \ custody-record-test service-identity-test service-identity-check security-tools \ + bounded-execution-admission-test bounded-execution-admission-check \ emission-cadence-profile-test \ posture-feedback-reference security-scenario-compose-lightweight \ security-bootstrap-console-test security-bootstrap-scripts-syntax \ diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 2ee98ff..2fc72a0 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -340,7 +340,7 @@ | task | NK-WP-0055-T14 | todo | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T15 | done | — | workplans/NK-WP-0055-security-platform-production-readiness.md | | task | NK-WP-0055-T16 | wait | — | workplans/NK-WP-0055-security-platform-production-readiness.md | -| task | NK-WP-0056-T01 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md | +| task | NK-WP-0056-T01 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | | task | NK-WP-0056-T02 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md | | task | NK-WP-0056-T03 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md | | task | NK-WP-0056-T04 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | diff --git a/canon/schemas/bounded-execution-admission_v0.1.schema.json b/canon/schemas/bounded-execution-admission_v0.1.schema.json new file mode 100644 index 0000000..fefd2c3 --- /dev/null +++ b/canon/schemas/bounded-execution-admission_v0.1.schema.json @@ -0,0 +1,582 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://netkingdom.io/schemas/bounded-execution-admission_v0.1.schema.json", + "title": "NetKingdom Bounded Execution Admission v0.1", + "description": "An immutable request that a person admits, by digest, for one bounded executor run. Structure only; the semantic rules (BEA-1..BEA-14) are in tools/bounded-execution-admission/validate.py and the standard.", + "type": "object", + "additionalProperties": false, + "required": [ + "apiVersion", + "kind", + "spec" + ], + "properties": { + "apiVersion": { + "const": "netkingdom.io/bounded-execution-admission/v0.1" + }, + "kind": { + "const": "BoundedExecutionAdmission" + }, + "spec": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "purpose", + "risk_class", + "window", + "approval", + "executor", + "scope_ceiling", + "blast_radius", + "trust_requires", + "plan", + "plan_digest", + "checks", + "stop_and_revert", + "budget", + "consumption", + "receipt", + "not_authorized" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^BEA-[0-9]{4}-[0-9]{4}$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "purpose": { + "type": "string", + "minLength": 1 + }, + "work_ref": { + "type": "string" + }, + "risk_class": { + "enum": [ + "low", + "medium", + "high" + ] + }, + "window": { + "type": "object", + "additionalProperties": false, + "required": [ + "not_before", + "not_after" + ], + "properties": { + "not_before": { + "type": "string", + "format": "date-time" + }, + "not_after": { + "type": "string", + "format": "date-time" + } + } + }, + "approval": { + "type": "object", + "additionalProperties": false, + "required": [ + "approvers", + "principal_type", + "assurance", + "carriers" + ], + "properties": { + "approvers": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "principal_type": { + "const": "HUMAN" + }, + "assurance": { + "const": "aal2" + }, + "carriers": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "approval-engine", + "founder-ssh-signature" + ] + } + } + } + }, + "executor": { + "type": "object", + "additionalProperties": false, + "required": [ + "identity", + "principal_type", + "runtime" + ], + "properties": { + "identity": { + "type": "string", + "minLength": 1 + }, + "principal_type": { + "enum": [ + "WORKLOAD", + "AGENT", + "SERVICE", + "HUMAN" + ] + }, + "runtime": { + "type": "string", + "minLength": 1 + }, + "harness_launched": { + "type": "boolean" + } + } + }, + "scope_ceiling": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/grant" + } + }, + "blast_radius": { + "type": "object", + "additionalProperties": false, + "required": [ + "unit", + "consumers", + "max_concurrent" + ], + "properties": { + "unit": { + "enum": [ + "consumer", + "account", + "host", + "tenant" + ] + }, + "consumers": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "max_concurrent": { + "type": "integer", + "minimum": 1 + } + } + }, + "trust_requires": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "description": "Playbook Capability Contract trust states the executor must find satisfied before the first step.", + "items": { + "enum": [ + "bare_host_trust", + "cluster_trust", + "bootstrap_secret_trust", + "bootstrap_identity_trust", + "runtime_secret_trust", + "runtime_identity_trust", + "runtime_authorization_trust", + "tenant_onboarding_trust" + ] + } + }, + "plan": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "steps" + ], + "properties": { + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "repo", + "commit", + "files" + ], + "properties": { + "repo": { + "type": "string" + }, + "commit": { + "type": "string", + "pattern": "^[0-9a-f]{7,40}$" + }, + "files": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/pinned_file" + } + } + } + }, + "steps": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/step" + } + }, + "revert_steps": { + "type": "array", + "items": { + "$ref": "#/$defs/step" + } + } + } + }, + "plan_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/check" + } + }, + "stop_and_revert": { + "type": "object", + "additionalProperties": false, + "required": [ + "on_check_failure", + "on_budget_exhausted", + "on_unknown", + "on_revert_failure" + ], + "properties": { + "on_check_failure": { + "enum": [ + "revert", + "stop" + ] + }, + "on_budget_exhausted": { + "enum": [ + "revert", + "stop" + ] + }, + "on_unknown": { + "const": "stop" + }, + "on_revert_failure": { + "const": "stop-and-alert" + }, + "alert": { + "type": "string" + } + } + }, + "budget": { + "type": "object", + "additionalProperties": false, + "required": [ + "wall_time_seconds", + "attempts", + "cost" + ], + "properties": { + "wall_time_seconds": { + "type": "integer", + "minimum": 1 + }, + "attempts": { + "type": "integer", + "minimum": 1 + }, + "cost": { + "type": "object", + "additionalProperties": false, + "required": [ + "compute_cpu_seconds", + "egress_bytes", + "currency", + "amount" + ], + "properties": { + "compute_cpu_seconds": { + "type": "integer", + "minimum": 0 + }, + "egress_bytes": { + "type": "integer", + "minimum": 0 + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "amount": { + "type": "number", + "minimum": 0 + } + } + } + } + }, + "consumption": { + "type": "object", + "additionalProperties": false, + "required": [ + "single_use", + "consume_before_first_step" + ], + "properties": { + "single_use": { + "const": true + }, + "consume_before_first_step": { + "const": true + } + } + }, + "receipt": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "emit_to", + "retain" + ], + "properties": { + "schema": { + "const": "netkingdom.playbook-capability-contract/v0.2#execution-attribution-receipt" + }, + "emit_to": { + "type": "string", + "minLength": 1 + }, + "retain": { + "const": "by-hash-no-secret-values" + }, + "independent_verifier": { + "type": "string" + } + } + }, + "not_authorized": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "$defs": { + "grant": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "scope", + "environments" + ], + "properties": { + "mode": { + "enum": [ + "USE", + "OBSERVE", + "OPERATE", + "ADMINISTER", + "GOVERN", + "CONSTRUCT", + "SOVEREIGN" + ] + }, + "scope": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$" + }, + "environments": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "DEVELOPMENT", + "TEST", + "STAGING", + "PRODUCTION", + "RECOVERY" + ] + } + } + } + }, + "pinned_file": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "sha256" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "step": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "operation", + "target", + "consumer", + "class", + "mutates", + "requires" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "operation": { + "type": "string", + "minLength": 1 + }, + "target": { + "type": "string", + "minLength": 1 + }, + "consumer": { + "type": "string", + "minLength": 1 + }, + "class": { + "enum": [ + "delegable", + "root-class", + "human-intrinsic" + ] + }, + "mutates": { + "type": "boolean" + }, + "requires": { + "$ref": "#/$defs/grant_need" + }, + "checks_after": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "revert": { + "type": "string" + }, + "irreversible": { + "type": "boolean" + } + } + }, + "grant_need": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "scope" + ], + "properties": { + "mode": { + "enum": [ + "USE", + "OBSERVE", + "OPERATE", + "ADMINISTER", + "GOVERN", + "CONSTRUCT", + "SOVEREIGN" + ] + }, + "scope": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*(/[A-Za-z0-9][A-Za-z0-9._-]*)*$" + } + } + }, + "check": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "description", + "ref", + "sha256", + "timeout_seconds", + "expect" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "description": { + "type": "string", + "minLength": 1 + }, + "ref": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "timeout_seconds": { + "type": "integer", + "minimum": 1 + }, + "settle_seconds": { + "type": "integer", + "minimum": 0 + }, + "expect": { + "const": "pass" + } + } + } + } +} diff --git a/canon/standards/bounded-execution-admission_v0.1.md b/canon/standards/bounded-execution-admission_v0.1.md new file mode 100644 index 0000000..3279b1d --- /dev/null +++ b/canon/standards/bounded-execution-admission_v0.1.md @@ -0,0 +1,232 @@ +--- +id: netkingdom-bounded-execution-admission-v0.1 +type: standard +title: "NetKingdom Bounded Execution Admission v0.1" +domain: netkingdom +status: proposed +version: "0.1" +owner: net-kingdom +created: "2026-10-31" +updated: "2026-10-31" +last_reviewed: "2026-10-31" +review_interval: 3m +scope: accountable-automation +schema: + - canon/schemas/bounded-execution-admission_v0.1.schema.json +validator: + - tools/bounded-execution-admission/validate.py +examples: + - examples/bounded-execution-admission/ +related: + - canon/standards/service-identity-privilege_v0.1.md + - canon/standards/playbook-capability-contract_v0.2.md + - workplans/NK-WP-0056-bounded-executor-path.md + - workplans/NK-WP-0040-execution-attribution-receipt.md +--- + +# NetKingdom Bounded Execution Admission v0.1 + +## 0. Status + +Proposed (NK-WP-0056-T01). Nothing here is enforced by a running service yet. The executor runtime +is T02/T03, budget and kill-switch enforcement is T07, and the first live use is the T06 pilot. A +document that passes the validator is a well-formed **request**. It is an **admission** only together +with a carrier record that a person approved (section 5). + +## 1. Purpose + +SI-15 names the golden path *prepare → admit → execute → verify → retain*, and allows "a bounded +executor under the admission" at step 3. This standard defines what that admission is. The founder +approves, at `aal2`: + +- an exact plan, by digest; +- a validity window; +- a scope ceiling in SI `MODE @ SCOPE` terms; +- a blast-radius rule; +- a stop-and-revert policy tied to named machine checks; +- a risk class; +- an expense budget. + +The executor consumes the admission once. Top-level decisions and guardrails stay with the person, +and execution below them is delegated, bounded and reconstructable. + +Out of scope: who runs the executor (T02), how a carrier stores approvals (Approval Engine owns that), +and the receipt's emission and custody (NK-WP-0040-T02). + +## 2. Vocabulary + +| Term | Meaning | +| --- | --- | +| Admission request | The immutable document defined here (`BoundedExecutionAdmission`), identified by `spec.id` and by its digest | +| Carrier | The system that records a person's approval of one request and its single consumption: Approval Engine, or a founder SSH signature (NK-WP-0049 practice) | +| Admission | A request plus a carrier record that passes BEA-C1..C7 | +| Executor | The non-human identity that runs the plan: a `WORKLOAD`, `SERVICE` or harness-launched `AGENT` (SI-14) | +| Step class | `delegable` (the executor may run it under an admission), `root-class` (the founder admits each run; high risk only), `human-intrinsic` (never in an admission: for example a person's own password) | +| Check | A named, source-pinned machine check that must pass after a mutating step | +| Consumer | The unit the blast radius counts: a service whose behaviour a step can change | + +## 3. The admission request + +A YAML or JSON document validated by `canon/schemas/bounded-execution-admission_v0.1.schema.json`: + +| Field | Meaning | +| --- | --- | +| `spec.id`, `title`, `purpose`, `work_ref` | Identity and intent. `id` is `BEA-YYYY-NNNN` | +| `risk_class` | `low`, `medium` or `high`. Bounds the window, the attempts, the ceiling and the step classes | +| `window.not_before` / `not_after` | When the admission can be consumed. At most 72 h (low), 24 h (medium) or 4 h (high) | +| `approval` | Who may approve (`approvers`, `HUMAN`, `aal2`) and through which carriers | +| `executor` | The executing identity, its principal type and runtime | +| `scope_ceiling` | Grants `{mode, scope, environments}` that bound every step. Scopes are `/`-separated paths, and a grant covers its own scope and anything below it | +| `blast_radius` | `unit`, the `consumers` the plan may touch, and `max_concurrent` | +| `trust_requires` | Playbook Capability Contract trust states the executor must find satisfied before the first step | +| `plan.source` | Repository, commit, and the sha256 of every file the run executes or checks with | +| `plan.steps`, `plan.revert_steps` | Ordered steps: `operation`, `target`, `consumer`, `class`, `mutates`, `requires` (the grant it needs), `checks_after`, `revert` or `irreversible` | +| `plan_digest` | `sha256:` over the canonical JSON of `plan` (sorted keys, no whitespace, UTF-8) | +| `checks` | Named checks: `ref` (a pinned file), `sha256`, `timeout_seconds`, optional `settle_seconds`, `expect: pass` | +| `stop_and_revert` | `on_check_failure` and `on_budget_exhausted` (`revert` or `stop`); `on_unknown: stop`; `on_revert_failure: stop-and-alert` | +| `budget` | `wall_time_seconds`, `attempts`, and `cost` (CPU seconds, egress bytes, currency, amount) | +| `consumption` | `single_use: true`, `consume_before_first_step: true` | +| `receipt` | The NK-WP-0040 execution-attribution receipt, where it goes, and that it is retained by hash with no secret values | +| `not_authorized` | What this admission explicitly does not cover | + +`settle_seconds` exists because of the T08 finding: privacyIDEA's resolver cache hides a broken bind +for up to 120 s, so a check run sooner proves nothing. + +## 4. Rules (validator: `validate`) + +| Rule | Requirement | +| --- | --- | +| BEA-1 | The document matches the schema (`HUMAN` approvers at `aal2`, single use, `on_unknown: stop`) | +| BEA-2 | `plan_digest` equals the digest of `plan`: the person admits exactly this plan | +| BEA-3 | The window is positive and no longer than the risk class allows | +| BEA-4 | The executor is not `HUMAN` and not an approver. An `AGENT` must be harness-launched (SI-14) | +| BEA-5 | No `SOVEREIGN` or `GOVERN` in the ceiling. No `CONSTRUCT` in `PRODUCTION`. `ADMINISTER` needs `max_concurrent: 1`; at a whole-service scope (no `/`) it needs `high` risk and only `root-class` steps | +| BEA-6 | Least privilege: every step and revert step is covered by a ceiling grant of the same mode, and every ceiling grant covers at least one step | +| BEA-7 | No `human-intrinsic` step. A `root-class` step needs `high` risk | +| BEA-8 | Every step's consumer is in the blast radius. `medium` and `high` risk change one consumer at a time | +| BEA-9 | Every mutating step names checks. Every check is used and pinned: its `ref` and `sha256` appear in `plan.source.files` | +| BEA-10 | Every mutating step has a revert step, or is `irreversible` at `high` risk. Revert steps mutate and do not nest. Step ids are unique | +| BEA-11 | A revertable plan reverts on check failure. A plan with nothing to revert stops | +| BEA-12 | The wall time fits the window, the checks (timeouts plus settle times) fit the wall time, and attempts are at most 3 (1 at `high` risk) | +| BEA-13 | The executed source is pinned by file and commit | +| BEA-14 | No secret-shaped value anywhere in the document | + +Findings name the rule and a location, never a value. + +## 5. Carrier and consumption (validator: `check-approval`) + +A carrier record (`netkingdom.io/bounded-execution-approval/v0.1`) states the carrier, its approval +reference, the admission id and digest, the approver (name, principal type, assurance), the approval +time, and the consumptions. + +The **admission digest** is `sha256:` over the canonical JSON of the whole request, including +`plan_digest`. That digest is what a carrier binds. + +| Rule | Requirement | +| --- | --- | +| BEA-C1 | The carrier is one the request allows, and the record has the expected shape | +| BEA-C2 | The record names this admission's id and digest | +| BEA-C3 | The approver is a listed `HUMAN` approver and not the executor | +| BEA-C4 | The approval was made at `aal2` | +| BEA-C5 | The approval was made before the window closed | +| BEA-C6 | Consumed at most once | +| BEA-C7 | The consumption happened in the window, after approval, by the executor identity | + +**Approval Engine (preferred carrier).** Approval Engine already stores an approval that binds action, +actor, principal, purpose and target by digest, carries a validity window, and is consumed exactly +once (`POST /v1/approvals/{id}/consume`), read back through `GET /v1/approvals/{id}`. The proposed +mapping, which is for agreement with Approval Engine and Access under T02, not decided here: + +- `action: bounded-execution`; +- `actor`: the executor identity; +- `principal`: the approver; +- `purpose`: `spec.purpose`; +- `target: {admission_id, admission_digest}`; +- validity equal to `window`; +- the executor consumes before its first step (`consume_before_first_step`). + +Approval Engine's own contract notes that `consume` does not check that the request digest is +equivalent to the decision. An independent mediator must therefore fetch the approval and compare +`target.admission_digest` itself. A `consumed: true` returned by the caller is not proof. + +**Founder SSH signature (transitional carrier).** This is the NK-WP-0049 practice: + +- the founder signs the canonical request with `ssh-keygen -Y sign`, namespace + `netkingdom-bounded-execution`; +- the signature is verified against the repository's `allowed_signers`; +- `aal2` is established by the signing key's custody, not by a token. + +Single use is then only as strong as the executor's own consumption ledger. That is acceptable for +the transition and is recorded as such in the receipt. + +## 6. Mapping + +**SI-15 golden path.** + +| Golden-path step | What it means here | +| --- | --- | +| Prepare | `plan.source` (source-reviewed, pinned files), tested on synthetic fixtures. For bind changes, NK-WP-0056-T08's synthetic stack | +| Admit | Carrier approval of the admission digest at `aal2` | +| Execute | The executor, inside `scope_ceiling`, `blast_radius`, `window` and `budget`, consuming once | +| Verify | The named checks during the run. Afterwards, an independent verifier checks the carrier record (BEA-C1..C7) and the receipt's source hashes | +| Retain | The receipt, kept by hash with no secret values | + +**Playbook Capability Contract.** `trust_requires` uses the contract's trust-state vocabulary. The +executor refuses to start unless each state is satisfied, using the readiness checks of the +capability declarations it runs. A step's `operation` should name a declared capability operation +once Railiance declarations exist for it. + +**Execution-attribution receipt (Playbook Capability Contract v0.2, NK-WP-0040).** One receipt per run: + +| Receipt field | Filled from | +| --- | --- | +| `initiator` | The carrier's approver (the founder). The person authorized the run; the executor ran it | +| `delegation` | Absent unless the approver acted through an agent | +| `executing_workload`, `runtime_principal` | `executor.identity`, `executor.runtime` | +| `run_id` | New per run | +| `source_commit` | `plan.source.commit` | +| `artifact_digest` | `plan_digest` | +| `target`, `action` | Per step | +| `decision_refs` | `spec.id`, the admission digest and the carrier's approval reference | +| `time_interval` | Measured | + +The receipt adds the outcome per step, check results, whether a revert ran, and the budget used. It +never holds a secret value. + +## 7. The bind split as admissions + +NK-WP-0056 tagged each step of the 2026-10-30 LLDAP bind split. The examples express them: + +| Runbook step | Class | Example | +| --- | --- | --- | +| 2, 4, 10 (verify) | delegable, read-only | `bind-split-read-only-verify.yaml`: low risk, `OBSERVE` only, nothing to revert | +| 3 create `svc-*` accounts | root-class | `bind-split-create-service-accounts.yaml`: high, whole-directory `ADMINISTER` (no narrower LLDAP grant, SI-X6), revert deletes what was created | +| 5–8 switch one consumer | delegable | `bind-rotation-svc-authelia.yaml`: medium, one consumer, a synthetic-journey check after the restart, a revert per step. This is the T06 pilot shape | +| 9 change LLDAP's super-admin | root-class | `bind-split-switch-super-admin.yaml`: high, one attempt, `settle_seconds: 125` on the journey check (privacyIDEA cache) | +| 10 `admin`'s own password; seal `ld-admin` | human-intrinsic | Refused: `tools/bounded-execution-admission/fixtures/invalid/human-intrinsic-step.yaml` (BEA-7) | + +The examples are illustrative. They pin real files at a real commit, but the executor and its +operations do not exist yet. + +## 8. Conformance + +```bash +make bounded-execution-admission-test # validator tests +make bounded-execution-admission-check # validate the examples +python3 tools/bounded-execution-admission/validate.py check-approval +python3 tools/bounded-execution-admission/validate.py digest +``` + +Agents may run these: they are offline and read-only. Writing an admission is preparation, and +approving one is the founder's act. + +## 9. Open items + +- T02: the carrier mapping with Approval Engine and Access (action name, actor and representation, + digest equivalence, consume scope for the executor), and the executor runtime owner. +- T03: the executor identity and its grants, matching the ceilings in the examples. +- T07: enforcement of budget and attempts at run time, the founder's kill switch, and aggregate + review through posture feedback (NK-WP-0031). +- NK-WP-0040-T02: receipt emission, custody and schema in audit-core. +- Recurring admissions (one approval for repeated runs of the same plan) are deliberately excluded + from v0.1: every admission is single use. diff --git a/examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml b/examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml new file mode 100644 index 0000000..a6b7584 --- /dev/null +++ b/examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml @@ -0,0 +1,197 @@ +# NK-WP-0056-T01 example: the bind split (NK-WP-0054) as admissions. Illustrative: the executor +# (T03) and its operations do not exist yet; files are pinned at the commit named in plan.source. +apiVersion: netkingdom.io/bounded-execution-admission/v0.1 +kind: BoundedExecutionAdmission +spec: + id: BEA-2026-0001 + title: Rotate the svc-authelia directory bind (bind-split steps 5-8, one consumer) + purpose: 'Pilot shape for NK-WP-0056-T06: generate a new bind password into OpenBao, set it in LLDAP, let ESO deliver it, + restart Authelia, prove sign-in with the synthetic journey; revert on any failed check.' + work_ref: NK-WP-0056-T01 example + risk_class: medium + window: + not_before: '2026-11-03T09:00:00Z' + not_after: '2026-11-03T11:00:00Z' + approval: + approvers: + - admin + principal_type: HUMAN + assurance: aal2 + carriers: + - approval-engine + - founder-ssh-signature + executor: + identity: service:nk-bind-executor + principal_type: WORKLOAD + runtime: k8s sso/serviceaccount nk-bind-executor (planned, NK-WP-0056-T03) + scope_ceiling: + - mode: OPERATE + scope: openbao/platform/workloads/net-kingdom/directory-binds/svc-authelia + environments: &id001 + - PRODUCTION + - mode: ADMINISTER + scope: lldap/people/svc-authelia + environments: *id001 + - mode: OPERATE + scope: k8s/sso/externalsecret/authelia-secrets + environments: *id001 + - mode: OPERATE + scope: k8s/sso/deployment/authelia + environments: *id001 + blast_radius: + unit: consumer + consumers: + - authelia + max_concurrent: 1 + trust_requires: + - runtime_secret_trust + - runtime_identity_trust + plan: + source: + repo: net-kingdom + commit: b8d3dacc4195 + files: + - path: tools/service-binds/bind-lane.py + sha256: 5b31b3567af07bba09530c6d3b0ed8b5c947df22bd13ac0442dc33c0d9b420c0 + - path: sso-mfa/k8s/synthetic-journey/journey.py + sha256: c639054597be7647a3ae88ac7da53490a43b38d9f4f95e24cf4e9f54b18f997c + steps: + - id: generate + operation: openbao.kv.write-next-version + target: openbao:platform/workloads/net-kingdom/directory-binds/svc-authelia + consumer: authelia + class: delegable + mutates: true + requires: + mode: OPERATE + scope: openbao/platform/workloads/net-kingdom/directory-binds/svc-authelia + checks_after: + - kv-advanced + revert: restore-kv + - id: set-directory + operation: lldap.user.set-password-from-kv + target: lldap:svc-authelia + consumer: authelia + class: delegable + mutates: true + requires: + mode: ADMINISTER + scope: lldap/people/svc-authelia + checks_after: + - bind-signs-in + revert: restore-directory + - id: deliver + operation: eso.force-sync + target: k8s:sso/externalsecret/authelia-secrets + consumer: authelia + class: delegable + mutates: true + requires: + mode: OPERATE + scope: k8s/sso/externalsecret/authelia-secrets + checks_after: + - secret-equals-kv + revert: redeliver + - id: restart + operation: k8s.rollout-restart + target: k8s:sso/deployment/authelia + consumer: authelia + class: delegable + mutates: true + requires: + mode: OPERATE + scope: k8s/sso/deployment/authelia + checks_after: + - synthetic-journey + revert: restart-again + revert_steps: + - id: restore-kv + operation: openbao.kv.write-previous-value-as-next-version + target: openbao:platform/workloads/net-kingdom/directory-binds/svc-authelia + consumer: authelia + class: delegable + mutates: true + requires: + mode: OPERATE + scope: openbao/platform/workloads/net-kingdom/directory-binds/svc-authelia + - id: restore-directory + operation: lldap.user.set-password-from-kv + target: lldap:svc-authelia + consumer: authelia + class: delegable + mutates: true + requires: + mode: ADMINISTER + scope: lldap/people/svc-authelia + - id: redeliver + operation: eso.force-sync + target: k8s:sso/externalsecret/authelia-secrets + consumer: authelia + class: delegable + mutates: true + requires: + mode: OPERATE + scope: k8s/sso/externalsecret/authelia-secrets + - id: restart-again + operation: k8s.rollout-restart + target: k8s:sso/deployment/authelia + consumer: authelia + class: delegable + mutates: true + requires: + mode: OPERATE + scope: k8s/sso/deployment/authelia + plan_digest: sha256:33129ab753da95f549fb27cfb5ba622e36fcba3c197f28b08fe3d4f9ff1d64a4 + checks: + - id: kv-advanced + description: KV svc-authelia has exactly one new version, CAS on the previous one + ref: tools/service-binds/bind-lane.py + sha256: 5b31b3567af07bba09530c6d3b0ed8b5c947df22bd13ac0442dc33c0d9b420c0 + timeout_seconds: 30 + expect: pass + - id: bind-signs-in + description: the new KV value signs in to LLDAP as svc-authelia + ref: tools/service-binds/bind-lane.py + sha256: 5b31b3567af07bba09530c6d3b0ed8b5c947df22bd13ac0442dc33c0d9b420c0 + timeout_seconds: 30 + expect: pass + - id: secret-equals-kv + description: 'bind-lane verify: ESO Ready and the delivered Secret equals KV, compared in memory' + ref: tools/service-binds/bind-lane.py + sha256: 5b31b3567af07bba09530c6d3b0ed8b5c947df22bd13ac0442dc33c0d9b420c0 + timeout_seconds: 120 + expect: pass + - id: synthetic-journey + description: syn-journey signs in through KeyCape, Authelia and privacyIDEA at aal2 + ref: sso-mfa/k8s/synthetic-journey/journey.py + sha256: c639054597be7647a3ae88ac7da53490a43b38d9f4f95e24cf4e9f54b18f997c + timeout_seconds: 90 + expect: pass + stop_and_revert: + on_check_failure: revert + on_budget_exhausted: revert + on_unknown: stop + on_revert_failure: stop-and-alert + alert: founder via State Hub message and audit-core + budget: + wall_time_seconds: 1200 + attempts: 2 + cost: + compute_cpu_seconds: 120 + egress_bytes: 5000000 + currency: EUR + amount: 0.05 + consumption: + single_use: true + consume_before_first_step: true + receipt: + schema: netkingdom.playbook-capability-contract/v0.2#execution-attribution-receipt + emit_to: audit-core + retain: by-hash-no-secret-values + independent_verifier: tools/bounded-execution-admission/validate.py check-approval + receipt source hashes + not_authorized: + - any bind other than svc-authelia + - any LLDAP group change + - changing LLDAP's configured super-admin + - reading or changing any human account + - any change outside the sso namespace diff --git a/examples/bounded-execution-admission/bind-split-create-service-accounts.yaml b/examples/bounded-execution-admission/bind-split-create-service-accounts.yaml new file mode 100644 index 0000000..cba8591 --- /dev/null +++ b/examples/bounded-execution-admission/bind-split-create-service-accounts.yaml @@ -0,0 +1,135 @@ +# NK-WP-0056-T01 example: the bind split (NK-WP-0054) as admissions. Illustrative: the executor +# (T03) and its operations do not exist yet; files are pinned at the commit named in plan.source. +apiVersion: netkingdom.io/bounded-execution-admission/v0.1 +kind: BoundedExecutionAdmission +spec: + id: BEA-2026-0002 + title: Create the service bind accounts (bind-split step 3, root-class) + purpose: 'Create svc-authelia, svc-keycape and svc-privacyidea in LLDAP as read-only binds. Needs lldap_admin: LLDAP has + no narrower grant that can create users (SI-X6), so the founder admits this run and the executor runs it.' + work_ref: NK-WP-0056-T01 example + risk_class: high + window: + not_before: '2026-11-03T09:00:00Z' + not_after: '2026-11-03T12:00:00Z' + approval: + approvers: + - admin + principal_type: HUMAN + assurance: aal2 + carriers: + - approval-engine + - founder-ssh-signature + executor: + identity: service:nk-bind-executor + principal_type: WORKLOAD + runtime: k8s sso/serviceaccount nk-bind-executor (planned, NK-WP-0056-T03) + scope_ceiling: + - mode: ADMINISTER + scope: lldap + environments: &id001 + - PRODUCTION + - mode: OPERATE + scope: openbao/platform/workloads/net-kingdom/directory-binds + environments: *id001 + blast_radius: + unit: account + consumers: + - lldap + max_concurrent: 1 + trust_requires: + - runtime_secret_trust + - runtime_identity_trust + plan: + source: + repo: net-kingdom + commit: b8d3dacc4195 + files: + - path: sso-mfa/k8s/lldap/bind-split.py + sha256: 4decc3bbe65120ff6c4cef5613b9a41a60168ae64ecd4c1380f61a2fcd37b6b3 + - path: tools/service-binds/bind-lane.py + sha256: 5b31b3567af07bba09530c6d3b0ed8b5c947df22bd13ac0442dc33c0d9b420c0 + steps: + - id: create-accounts + operation: lldap.user.create-read-only-binds + target: lldap:svc-authelia,svc-keycape,svc-privacyidea + consumer: lldap + class: root-class + mutates: true + requires: + mode: ADMINISTER + scope: lldap + checks_after: + - accounts-read-only + revert: delete-accounts + - id: hold-passwords + operation: openbao.kv.write-cas0 + target: openbao:platform/workloads/net-kingdom/directory-binds + consumer: lldap + class: root-class + mutates: true + requires: + mode: OPERATE + scope: openbao/platform/workloads/net-kingdom/directory-binds + checks_after: + - accounts-sign-in + revert: destroy-kv + revert_steps: + - id: delete-accounts + operation: lldap.user.delete-created + target: lldap:svc-authelia,svc-keycape,svc-privacyidea + consumer: lldap + class: root-class + mutates: true + requires: + mode: ADMINISTER + scope: lldap + - id: destroy-kv + operation: openbao.kv.destroy-created + target: openbao:platform/workloads/net-kingdom/directory-binds + consumer: lldap + class: root-class + mutates: true + requires: + mode: OPERATE + scope: openbao/platform/workloads/net-kingdom/directory-binds + plan_digest: sha256:94910771c288437dbaeeba80e32a4f61ef1cdb7d438e7352d21a5e10fe92e77e + checks: + - id: accounts-read-only + description: each account exists in lldap_strict_readonly only and cannot write + ref: sso-mfa/k8s/lldap/bind-split.py + sha256: 4decc3bbe65120ff6c4cef5613b9a41a60168ae64ecd4c1380f61a2fcd37b6b3 + timeout_seconds: 60 + expect: pass + - id: accounts-sign-in + description: each KV value signs in to LLDAP as its account + ref: tools/service-binds/bind-lane.py + sha256: 5b31b3567af07bba09530c6d3b0ed8b5c947df22bd13ac0442dc33c0d9b420c0 + timeout_seconds: 60 + expect: pass + stop_and_revert: + on_check_failure: revert + on_budget_exhausted: revert + on_unknown: stop + on_revert_failure: stop-and-alert + alert: founder via State Hub message + budget: + wall_time_seconds: 900 + attempts: 1 + cost: + compute_cpu_seconds: 60 + egress_bytes: 1000000 + currency: EUR + amount: 0.02 + consumption: + single_use: true + consume_before_first_step: true + receipt: + schema: netkingdom.playbook-capability-contract/v0.2#execution-attribution-receipt + emit_to: audit-core + retain: by-hash-no-secret-values + independent_verifier: tools/bounded-execution-admission/validate.py check-approval + receipt source hashes + not_authorized: + - switching any consumer to the new accounts + - any human account + - changing LLDAP's configured super-admin diff --git a/examples/bounded-execution-admission/bind-split-read-only-verify.yaml b/examples/bounded-execution-admission/bind-split-read-only-verify.yaml new file mode 100644 index 0000000..1a481b4 --- /dev/null +++ b/examples/bounded-execution-admission/bind-split-read-only-verify.yaml @@ -0,0 +1,106 @@ +# NK-WP-0056-T01 example: the bind split (NK-WP-0054) as admissions. Illustrative: the executor +# (T03) and its operations do not exist yet; files are pinned at the commit named in plan.source. +apiVersion: netkingdom.io/bounded-execution-admission/v0.1 +kind: BoundedExecutionAdmission +spec: + id: BEA-2026-0004 + title: Read-only verification of the directory split (bind-split steps 2, 4 and 10 verify) + purpose: 'Plan and verify reads that the founder used to do signed in as admin: account inventory, group membership, bind + identities of the consumers. No mutation, so nothing to revert.' + work_ref: NK-WP-0056-T01 example + risk_class: low + window: + not_before: '2026-11-03T09:00:00Z' + not_after: '2026-11-04T09:00:00Z' + approval: + approvers: + - admin + principal_type: HUMAN + assurance: aal2 + carriers: + - approval-engine + - founder-ssh-signature + executor: + identity: service:nk-bind-executor + principal_type: WORKLOAD + runtime: k8s sso/serviceaccount nk-bind-executor (planned, NK-WP-0056-T03) + scope_ceiling: + - mode: OBSERVE + scope: lldap/people + environments: &id001 + - PRODUCTION + - mode: OBSERVE + scope: k8s/sso + environments: *id001 + blast_radius: + unit: consumer + consumers: + - lldap + - authelia + - keycape + - privacyidea + max_concurrent: 4 + trust_requires: + - runtime_secret_trust + - runtime_identity_trust + plan: + source: + repo: net-kingdom + commit: b8d3dacc4195 + files: + - path: sso-mfa/k8s/lldap/bind-split.py + sha256: 4decc3bbe65120ff6c4cef5613b9a41a60168ae64ecd4c1380f61a2fcd37b6b3 + steps: + - id: inventory + operation: lldap.read.users-and-groups + target: lldap:people + consumer: lldap + class: delegable + mutates: false + requires: + mode: OBSERVE + scope: lldap/people + checks_after: + - split-shape + - id: consumer-binds + operation: k8s.read.bind-identities + target: k8s:sso + consumer: authelia + class: delegable + mutates: false + requires: + mode: OBSERVE + scope: k8s/sso + checks_after: + - split-shape + plan_digest: sha256:e4867e97a9957d543d5a7e265e56678ce22f3bb87bab64022b381dbee3e2610a + checks: + - id: split-shape + description: every consumer binds as its own svc- account; no service binds as admin + ref: sso-mfa/k8s/lldap/bind-split.py + sha256: 4decc3bbe65120ff6c4cef5613b9a41a60168ae64ecd4c1380f61a2fcd37b6b3 + timeout_seconds: 60 + expect: pass + stop_and_revert: + on_check_failure: stop + on_budget_exhausted: stop + on_unknown: stop + on_revert_failure: stop-and-alert + budget: + wall_time_seconds: 300 + attempts: 3 + cost: + compute_cpu_seconds: 30 + egress_bytes: 500000 + currency: EUR + amount: 0.01 + consumption: + single_use: true + consume_before_first_step: true + receipt: + schema: netkingdom.playbook-capability-contract/v0.2#execution-attribution-receipt + emit_to: audit-core + retain: by-hash-no-secret-values + independent_verifier: tools/bounded-execution-admission/validate.py check-approval + receipt source hashes + not_authorized: + - any write to LLDAP, Kubernetes or OpenBao diff --git a/examples/bounded-execution-admission/bind-split-switch-super-admin.yaml b/examples/bounded-execution-admission/bind-split-switch-super-admin.yaml new file mode 100644 index 0000000..406ddd9 --- /dev/null +++ b/examples/bounded-execution-admission/bind-split-switch-super-admin.yaml @@ -0,0 +1,136 @@ +# NK-WP-0056-T01 example: the bind split (NK-WP-0054) as admissions. Illustrative: the executor +# (T03) and its operations do not exist yet; files are pinned at the commit named in plan.source. +apiVersion: netkingdom.io/bounded-execution-admission/v0.1 +kind: BoundedExecutionAdmission +spec: + id: BEA-2026-0003 + title: Switch LLDAP's configured super-admin to ld-admin (bind-split step 9, root-class) + purpose: Change LLDAP_LDAP_USER_DN from admin to ld-admin and restart LLDAP; admin keeps its rights through lldap_admin + membership. Changes who administers the directory, so the founder admits the run. + work_ref: NK-WP-0056-T01 example + risk_class: high + window: + not_before: '2026-11-03T09:00:00Z' + not_after: '2026-11-03T11:00:00Z' + approval: + approvers: + - admin + principal_type: HUMAN + assurance: aal2 + carriers: + - approval-engine + - founder-ssh-signature + executor: + identity: service:nk-bind-executor + principal_type: WORKLOAD + runtime: k8s sso/serviceaccount nk-bind-executor (planned, NK-WP-0056-T03) + scope_ceiling: + - mode: ADMINISTER + scope: lldap + environments: &id001 + - PRODUCTION + - mode: OPERATE + scope: k8s/sso/deployment/lldap + environments: *id001 + blast_radius: + unit: consumer + consumers: + - lldap + max_concurrent: 1 + trust_requires: + - runtime_secret_trust + - runtime_identity_trust + plan: + source: + repo: net-kingdom + commit: b8d3dacc4195 + files: + - path: sso-mfa/k8s/lldap/bind-split.py + sha256: 4decc3bbe65120ff6c4cef5613b9a41a60168ae64ecd4c1380f61a2fcd37b6b3 + - path: sso-mfa/k8s/synthetic-journey/journey.py + sha256: c639054597be7647a3ae88ac7da53490a43b38d9f4f95e24cf4e9f54b18f997c + steps: + - id: switch-user-dn + operation: lldap.config.set-ldap-user-dn + target: k8s:sso/secret/lldap-secrets#LLDAP_LDAP_USER_DN + consumer: lldap + class: root-class + mutates: true + requires: + mode: ADMINISTER + scope: lldap + checks_after: + - ld-admin-administers + revert: switch-back + - id: restart-lldap + operation: k8s.rollout-restart + target: k8s:sso/deployment/lldap + consumer: lldap + class: root-class + mutates: true + requires: + mode: OPERATE + scope: k8s/sso/deployment/lldap + checks_after: + - synthetic-journey + revert: restart-lldap-again + revert_steps: + - id: switch-back + operation: lldap.config.set-ldap-user-dn + target: k8s:sso/secret/lldap-secrets#LLDAP_LDAP_USER_DN + consumer: lldap + class: root-class + mutates: true + requires: + mode: ADMINISTER + scope: lldap + - id: restart-lldap-again + operation: k8s.rollout-restart + target: k8s:sso/deployment/lldap + consumer: lldap + class: root-class + mutates: true + requires: + mode: OPERATE + scope: k8s/sso/deployment/lldap + plan_digest: sha256:c15793ccaad212eaf24de12a1d13709970be84ee917a994463b68880e905dedf + checks: + - id: ld-admin-administers + description: ld-admin signs in and administers; admin keeps lldap_admin membership + ref: sso-mfa/k8s/lldap/bind-split.py + sha256: 4decc3bbe65120ff6c4cef5613b9a41a60168ae64ecd4c1380f61a2fcd37b6b3 + timeout_seconds: 60 + expect: pass + - id: synthetic-journey + description: 'all three binds still serve sign-in: syn-journey reaches aal2' + ref: sso-mfa/k8s/synthetic-journey/journey.py + sha256: c639054597be7647a3ae88ac7da53490a43b38d9f4f95e24cf4e9f54b18f997c + timeout_seconds: 90 + expect: pass + settle_seconds: 125 + stop_and_revert: + on_check_failure: revert + on_budget_exhausted: revert + on_unknown: stop + on_revert_failure: stop-and-alert + alert: founder via State Hub message; crisis list item for ld-admin + budget: + wall_time_seconds: 900 + attempts: 1 + cost: + compute_cpu_seconds: 60 + egress_bytes: 1000000 + currency: EUR + amount: 0.02 + consumption: + single_use: true + consume_before_first_step: true + receipt: + schema: netkingdom.playbook-capability-contract/v0.2#execution-attribution-receipt + emit_to: audit-core + retain: by-hash-no-secret-values + independent_verifier: tools/bounded-execution-admission/validate.py check-approval + receipt source hashes + not_authorized: + - changing admin's own password + - sealing ld-admin's password (human-intrinsic, step 10) + - any service bind change diff --git a/tools/bounded-execution-admission/README.md b/tools/bounded-execution-admission/README.md new file mode 100644 index 0000000..6dc73c2 --- /dev/null +++ b/tools/bounded-execution-admission/README.md @@ -0,0 +1,18 @@ +# Bounded Execution Admission validator (NK-WP-0056-T01) + +Offline, deterministic checks for `canon/standards/bounded-execution-admission_v0.1.md`. + +```bash +python3 tools/bounded-execution-admission/validate.py validate [--as-of T] FILE... # BEA-1..BEA-14 +python3 tools/bounded-execution-admission/validate.py check-approval ADMISSION RECORD # BEA-C1..BEA-C7 +python3 tools/bounded-execution-admission/validate.py digest FILE # plan and admission digests +python3 tools/bounded-execution-admission/generate_examples.py # re-pin and regenerate examples +make bounded-execution-admission-test bounded-execution-admission-check +``` + +A valid document is a well-formed request, not an admission: an admission also needs a carrier +record approved by a person (Approval Engine, or a founder SSH signature). Findings name the rule +and a location, never a value. Needs `jsonschema` and `PyYAML`. + +Examples (`examples/bounded-execution-admission/`) express the NK-WP-0054 bind split as admissions. +`generate_examples.py` pins real files at the current commit; re-run it when those files change. diff --git a/tools/bounded-execution-admission/fixtures/invalid/human-intrinsic-step.yaml b/tools/bounded-execution-admission/fixtures/invalid/human-intrinsic-step.yaml new file mode 100644 index 0000000..f146e70 --- /dev/null +++ b/tools/bounded-execution-admission/fixtures/invalid/human-intrinsic-step.yaml @@ -0,0 +1,147 @@ +apiVersion: netkingdom.io/bounded-execution-admission/v0.1 +kind: BoundedExecutionAdmission +spec: + id: BEA-2026-0099 + title: Switch LLDAP's configured super-admin to ld-admin (bind-split step 9, root-class) + purpose: Change LLDAP_LDAP_USER_DN from admin to ld-admin and restart LLDAP; admin keeps its rights through lldap_admin + membership. Changes who administers the directory, so the founder admits the run. + work_ref: NK-WP-0056-T01 example + risk_class: high + window: + not_before: '2026-11-03T09:00:00Z' + not_after: '2026-11-03T11:00:00Z' + approval: + approvers: + - admin + principal_type: HUMAN + assurance: aal2 + carriers: + - approval-engine + - founder-ssh-signature + executor: + identity: service:nk-bind-executor + principal_type: WORKLOAD + runtime: k8s sso/serviceaccount nk-bind-executor (planned, NK-WP-0056-T03) + scope_ceiling: + - mode: ADMINISTER + scope: lldap + environments: + - PRODUCTION + - mode: OPERATE + scope: k8s/sso/deployment/lldap + environments: + - PRODUCTION + blast_radius: + unit: consumer + consumers: + - lldap + max_concurrent: 1 + trust_requires: + - runtime_secret_trust + - runtime_identity_trust + plan: + source: + repo: net-kingdom + commit: b8d3dacc4195 + files: + - path: sso-mfa/k8s/lldap/bind-split.py + sha256: 4decc3bbe65120ff6c4cef5613b9a41a60168ae64ecd4c1380f61a2fcd37b6b3 + - path: sso-mfa/k8s/synthetic-journey/journey.py + sha256: c639054597be7647a3ae88ac7da53490a43b38d9f4f95e24cf4e9f54b18f997c + steps: + - id: switch-user-dn + operation: lldap.config.set-ldap-user-dn + target: k8s:sso/secret/lldap-secrets#LLDAP_LDAP_USER_DN + consumer: lldap + class: root-class + mutates: true + requires: + mode: ADMINISTER + scope: lldap + checks_after: + - ld-admin-administers + revert: switch-back + - id: restart-lldap + operation: k8s.rollout-restart + target: k8s:sso/deployment/lldap + consumer: lldap + class: root-class + mutates: true + requires: + mode: OPERATE + scope: k8s/sso/deployment/lldap + checks_after: + - synthetic-journey + revert: restart-lldap-again + - id: change-admin-password + operation: lldap.user.set-password + target: lldap:admin + consumer: lldap + class: human-intrinsic + mutates: true + requires: + mode: ADMINISTER + scope: lldap + checks_after: + - ld-admin-administers + revert: switch-back + revert_steps: + - id: switch-back + operation: lldap.config.set-ldap-user-dn + target: k8s:sso/secret/lldap-secrets#LLDAP_LDAP_USER_DN + consumer: lldap + class: root-class + mutates: true + requires: + mode: ADMINISTER + scope: lldap + - id: restart-lldap-again + operation: k8s.rollout-restart + target: k8s:sso/deployment/lldap + consumer: lldap + class: root-class + mutates: true + requires: + mode: OPERATE + scope: k8s/sso/deployment/lldap + plan_digest: sha256:eb53d0d611a22d43214f7e4afb450e2a1c858110c9d5330b7d562fc98a1c8fe8 + checks: + - id: ld-admin-administers + description: ld-admin signs in and administers; admin keeps lldap_admin membership + ref: sso-mfa/k8s/lldap/bind-split.py + sha256: 4decc3bbe65120ff6c4cef5613b9a41a60168ae64ecd4c1380f61a2fcd37b6b3 + timeout_seconds: 60 + expect: pass + - id: synthetic-journey + description: 'all three binds still serve sign-in: syn-journey reaches aal2' + ref: sso-mfa/k8s/synthetic-journey/journey.py + sha256: c639054597be7647a3ae88ac7da53490a43b38d9f4f95e24cf4e9f54b18f997c + timeout_seconds: 90 + expect: pass + settle_seconds: 125 + stop_and_revert: + on_check_failure: revert + on_budget_exhausted: revert + on_unknown: stop + on_revert_failure: stop-and-alert + alert: founder via State Hub message; crisis list item for ld-admin + budget: + wall_time_seconds: 900 + attempts: 1 + cost: + compute_cpu_seconds: 60 + egress_bytes: 1000000 + currency: EUR + amount: 0.02 + consumption: + single_use: true + consume_before_first_step: true + receipt: + schema: netkingdom.playbook-capability-contract/v0.2#execution-attribution-receipt + emit_to: audit-core + retain: by-hash-no-secret-values + independent_verifier: tools/bounded-execution-admission/validate.py check-approval + receipt source hashes + not_authorized: + - changing admin's own password + - sealing ld-admin's password (human-intrinsic, step 10) + - any service bind change diff --git a/tools/bounded-execution-admission/generate_examples.py b/tools/bounded-execution-admission/generate_examples.py new file mode 100644 index 0000000..cb772e7 --- /dev/null +++ b/tools/bounded-execution-admission/generate_examples.py @@ -0,0 +1,144 @@ +"""Regenerate examples/bounded-execution-admission/ and the human-intrinsic invalid fixture (NK-WP-0056-T01). + +Pins the named files at the current commit and computes plan_digest. Run after changing those files or the schema. +""" +import hashlib, json, pathlib, subprocess, sys, yaml +ROOT = pathlib.Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "tools/bounded-execution-admission")) +import validate as V +commit = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short=12", "HEAD"], capture_output=True, text=True).stdout.strip() +def pin(*paths): return [{"path": p, "sha256": hashlib.sha256((ROOT / p).read_bytes()).hexdigest()} for p in paths] +def sha(p): return hashlib.sha256((ROOT / p).read_bytes()).hexdigest() +JOURNEY = "sso-mfa/k8s/synthetic-journey/journey.py" +LANE = "tools/service-binds/bind-lane.py" +SPLIT = "sso-mfa/k8s/lldap/bind-split.py" +COMMON = { + "approval": {"approvers": ["admin"], "principal_type": "HUMAN", "assurance": "aal2", + "carriers": ["approval-engine", "founder-ssh-signature"]}, + "consumption": {"single_use": True, "consume_before_first_step": True}, + "receipt": {"schema": "netkingdom.playbook-capability-contract/v0.2#execution-attribution-receipt", + "emit_to": "audit-core", "retain": "by-hash-no-secret-values", + "independent_verifier": "tools/bounded-execution-admission/validate.py check-approval + receipt source hashes"}, +} +EXECUTOR = {"identity": "service:nk-bind-executor", "principal_type": "WORKLOAD", + "runtime": "k8s sso/serviceaccount nk-bind-executor (planned, NK-WP-0056-T03)"} +P = ["PRODUCTION"] +def check(cid, desc, ref, timeout, settle=0): + c = {"id": cid, "description": desc, "ref": ref, "sha256": sha(ref), "timeout_seconds": timeout, "expect": "pass"} + if settle: c["settle_seconds"] = settle + return c +def doc(id_, title, purpose, risk, window, ceiling, radius, files, steps, reverts, checks, policy, budget, not_auth, trust=("runtime_secret_trust", "runtime_identity_trust")): + spec = {"id": id_, "title": title, "purpose": purpose, "work_ref": "NK-WP-0056-T01 example", "risk_class": risk, + "window": window, **{"approval": COMMON["approval"]}, "executor": EXECUTOR, "scope_ceiling": ceiling, + "blast_radius": radius, "trust_requires": list(trust), + "plan": {"source": {"repo": "net-kingdom", "commit": commit, "files": pin(*files)}, "steps": steps, + **({"revert_steps": reverts} if reverts else {})}, + "plan_digest": "", "checks": checks, "stop_and_revert": policy, "budget": budget, + "consumption": COMMON["consumption"], "receipt": COMMON["receipt"], "not_authorized": not_auth} + d = {"apiVersion": "netkingdom.io/bounded-execution-admission/v0.1", "kind": "BoundedExecutionAdmission", "spec": spec} + spec["plan_digest"] = V.plan_digest(d) + return d +def step(id_, op, target, consumer, cls, mutates, mode, scope, checks=None, revert=None, irreversible=None): + s = {"id": id_, "operation": op, "target": target, "consumer": consumer, "class": cls, "mutates": mutates, + "requires": {"mode": mode, "scope": scope}} + if checks: s["checks_after"] = checks + if revert: s["revert"] = revert + if irreversible: s["irreversible"] = True + return s + +KV = "openbao/platform/workloads/net-kingdom/directory-binds/svc-authelia" +rotation = doc( + "BEA-2026-0001", "Rotate the svc-authelia directory bind (bind-split steps 5-8, one consumer)", + "Pilot shape for NK-WP-0056-T06: generate a new bind password into OpenBao, set it in LLDAP, let ESO deliver it, restart Authelia, prove sign-in with the synthetic journey; revert on any failed check.", + "medium", {"not_before": "2026-11-03T09:00:00Z", "not_after": "2026-11-03T11:00:00Z"}, + [{"mode": "OPERATE", "scope": KV, "environments": P}, + {"mode": "ADMINISTER", "scope": "lldap/people/svc-authelia", "environments": P}, + {"mode": "OPERATE", "scope": "k8s/sso/externalsecret/authelia-secrets", "environments": P}, + {"mode": "OPERATE", "scope": "k8s/sso/deployment/authelia", "environments": P}], + {"unit": "consumer", "consumers": ["authelia"], "max_concurrent": 1}, + [LANE, JOURNEY], + [step("generate", "openbao.kv.write-next-version", "openbao:" + KV[len("openbao/"):], "authelia", "delegable", True, "OPERATE", KV, ["kv-advanced"], "restore-kv"), + step("set-directory", "lldap.user.set-password-from-kv", "lldap:svc-authelia", "authelia", "delegable", True, "ADMINISTER", "lldap/people/svc-authelia", ["bind-signs-in"], "restore-directory"), + step("deliver", "eso.force-sync", "k8s:sso/externalsecret/authelia-secrets", "authelia", "delegable", True, "OPERATE", "k8s/sso/externalsecret/authelia-secrets", ["secret-equals-kv"], "redeliver"), + step("restart", "k8s.rollout-restart", "k8s:sso/deployment/authelia", "authelia", "delegable", True, "OPERATE", "k8s/sso/deployment/authelia", ["synthetic-journey"], "restart-again")], + [step("restore-kv", "openbao.kv.write-previous-value-as-next-version", "openbao:" + KV[len("openbao/"):], "authelia", "delegable", True, "OPERATE", KV), + step("restore-directory", "lldap.user.set-password-from-kv", "lldap:svc-authelia", "authelia", "delegable", True, "ADMINISTER", "lldap/people/svc-authelia"), + step("redeliver", "eso.force-sync", "k8s:sso/externalsecret/authelia-secrets", "authelia", "delegable", True, "OPERATE", "k8s/sso/externalsecret/authelia-secrets"), + step("restart-again", "k8s.rollout-restart", "k8s:sso/deployment/authelia", "authelia", "delegable", True, "OPERATE", "k8s/sso/deployment/authelia")], + [check("kv-advanced", "KV svc-authelia has exactly one new version, CAS on the previous one", LANE, 30), + check("bind-signs-in", "the new KV value signs in to LLDAP as svc-authelia", LANE, 30), + check("secret-equals-kv", "bind-lane verify: ESO Ready and the delivered Secret equals KV, compared in memory", LANE, 120), + check("synthetic-journey", "syn-journey signs in through KeyCape, Authelia and privacyIDEA at aal2", JOURNEY, 90)], + {"on_check_failure": "revert", "on_budget_exhausted": "revert", "on_unknown": "stop", + "on_revert_failure": "stop-and-alert", "alert": "founder via State Hub message and audit-core"}, + {"wall_time_seconds": 1200, "attempts": 2, "cost": {"compute_cpu_seconds": 120, "egress_bytes": 5000000, "currency": "EUR", "amount": 0.05}}, + ["any bind other than svc-authelia", "any LLDAP group change", "changing LLDAP's configured super-admin", + "reading or changing any human account", "any change outside the sso namespace"]) + +create = doc( + "BEA-2026-0002", "Create the service bind accounts (bind-split step 3, root-class)", + "Create svc-authelia, svc-keycape and svc-privacyidea in LLDAP as read-only binds. Needs lldap_admin: LLDAP has no narrower grant that can create users (SI-X6), so the founder admits this run and the executor runs it.", + "high", {"not_before": "2026-11-03T09:00:00Z", "not_after": "2026-11-03T12:00:00Z"}, + [{"mode": "ADMINISTER", "scope": "lldap", "environments": P}, + {"mode": "OPERATE", "scope": "openbao/platform/workloads/net-kingdom/directory-binds", "environments": P}], + {"unit": "account", "consumers": ["lldap"], "max_concurrent": 1}, + [SPLIT, LANE], + [step("create-accounts", "lldap.user.create-read-only-binds", "lldap:svc-authelia,svc-keycape,svc-privacyidea", "lldap", "root-class", True, "ADMINISTER", "lldap", ["accounts-read-only"], "delete-accounts"), + step("hold-passwords", "openbao.kv.write-cas0", "openbao:platform/workloads/net-kingdom/directory-binds", "lldap", "root-class", True, "OPERATE", "openbao/platform/workloads/net-kingdom/directory-binds", ["accounts-sign-in"], "destroy-kv")], + [step("delete-accounts", "lldap.user.delete-created", "lldap:svc-authelia,svc-keycape,svc-privacyidea", "lldap", "root-class", True, "ADMINISTER", "lldap"), + step("destroy-kv", "openbao.kv.destroy-created", "openbao:platform/workloads/net-kingdom/directory-binds", "lldap", "root-class", True, "OPERATE", "openbao/platform/workloads/net-kingdom/directory-binds")], + [check("accounts-read-only", "each account exists in lldap_strict_readonly only and cannot write", SPLIT, 60), + check("accounts-sign-in", "each KV value signs in to LLDAP as its account", LANE, 60)], + {"on_check_failure": "revert", "on_budget_exhausted": "revert", "on_unknown": "stop", + "on_revert_failure": "stop-and-alert", "alert": "founder via State Hub message"}, + {"wall_time_seconds": 900, "attempts": 1, "cost": {"compute_cpu_seconds": 60, "egress_bytes": 1000000, "currency": "EUR", "amount": 0.02}}, + ["switching any consumer to the new accounts", "any human account", "changing LLDAP's configured super-admin"]) + +superadmin = doc( + "BEA-2026-0003", "Switch LLDAP's configured super-admin to ld-admin (bind-split step 9, root-class)", + "Change LLDAP_LDAP_USER_DN from admin to ld-admin and restart LLDAP; admin keeps its rights through lldap_admin membership. Changes who administers the directory, so the founder admits the run.", + "high", {"not_before": "2026-11-03T09:00:00Z", "not_after": "2026-11-03T11:00:00Z"}, + [{"mode": "ADMINISTER", "scope": "lldap", "environments": P}, + {"mode": "OPERATE", "scope": "k8s/sso/deployment/lldap", "environments": P}], + {"unit": "consumer", "consumers": ["lldap"], "max_concurrent": 1}, + [SPLIT, JOURNEY], + [step("switch-user-dn", "lldap.config.set-ldap-user-dn", "k8s:sso/secret/lldap-secrets#LLDAP_LDAP_USER_DN", "lldap", "root-class", True, "ADMINISTER", "lldap", ["ld-admin-administers"], "switch-back"), + step("restart-lldap", "k8s.rollout-restart", "k8s:sso/deployment/lldap", "lldap", "root-class", True, "OPERATE", "k8s/sso/deployment/lldap", ["synthetic-journey"], "restart-lldap-again")], + [step("switch-back", "lldap.config.set-ldap-user-dn", "k8s:sso/secret/lldap-secrets#LLDAP_LDAP_USER_DN", "lldap", "root-class", True, "ADMINISTER", "lldap"), + step("restart-lldap-again", "k8s.rollout-restart", "k8s:sso/deployment/lldap", "lldap", "root-class", True, "OPERATE", "k8s/sso/deployment/lldap")], + [check("ld-admin-administers", "ld-admin signs in and administers; admin keeps lldap_admin membership", SPLIT, 60), + check("synthetic-journey", "all three binds still serve sign-in: syn-journey reaches aal2", JOURNEY, 90, settle=125)], + {"on_check_failure": "revert", "on_budget_exhausted": "revert", "on_unknown": "stop", + "on_revert_failure": "stop-and-alert", "alert": "founder via State Hub message; crisis list item for ld-admin"}, + {"wall_time_seconds": 900, "attempts": 1, "cost": {"compute_cpu_seconds": 60, "egress_bytes": 1000000, "currency": "EUR", "amount": 0.02}}, + ["changing admin's own password", "sealing ld-admin's password (human-intrinsic, step 10)", "any service bind change"]) + +verify = doc( + "BEA-2026-0004", "Read-only verification of the directory split (bind-split steps 2, 4 and 10 verify)", + "Plan and verify reads that the founder used to do signed in as admin: account inventory, group membership, bind identities of the consumers. No mutation, so nothing to revert.", + "low", {"not_before": "2026-11-03T09:00:00Z", "not_after": "2026-11-04T09:00:00Z"}, + [{"mode": "OBSERVE", "scope": "lldap/people", "environments": P}, + {"mode": "OBSERVE", "scope": "k8s/sso", "environments": P}], + {"unit": "consumer", "consumers": ["lldap", "authelia", "keycape", "privacyidea"], "max_concurrent": 4}, + [SPLIT], + [step("inventory", "lldap.read.users-and-groups", "lldap:people", "lldap", "delegable", False, "OBSERVE", "lldap/people", ["split-shape"]), + step("consumer-binds", "k8s.read.bind-identities", "k8s:sso", "authelia", "delegable", False, "OBSERVE", "k8s/sso", ["split-shape"])], + [], + [check("split-shape", "every consumer binds as its own svc- account; no service binds as admin", SPLIT, 60)], + {"on_check_failure": "stop", "on_budget_exhausted": "stop", "on_unknown": "stop", "on_revert_failure": "stop-and-alert"}, + {"wall_time_seconds": 300, "attempts": 3, "cost": {"compute_cpu_seconds": 30, "egress_bytes": 500000, "currency": "EUR", "amount": 0.01}}, + ["any write to LLDAP, Kubernetes or OpenBao"]) + +out = ROOT / "examples/bounded-execution-admission" +for name, d in [("bind-rotation-svc-authelia", rotation), ("bind-split-create-service-accounts", create), + ("bind-split-switch-super-admin", superadmin), ("bind-split-read-only-verify", verify)]: + (out / f"{name}.yaml").write_text("# NK-WP-0056-T01 example: the bind split (NK-WP-0054) as admissions. Illustrative: the executor\n" + "# (T03) and its operations do not exist yet; files are pinned at the commit named in plan.source.\n" + + yaml.safe_dump(d, sort_keys=False, width=120)) +# Invalid: step 10, the founder's own password, is human-intrinsic. +bad = json.loads(json.dumps(superadmin)); s = bad["spec"]; s["id"] = "BEA-2026-0099" +s["plan"]["steps"].append(step("change-admin-password", "lldap.user.set-password", "lldap:admin", "lldap", "human-intrinsic", True, "ADMINISTER", "lldap", ["ld-admin-administers"], "switch-back")) +s["plan_digest"] = V.plan_digest(bad) +fx = ROOT / "tools/bounded-execution-admission/fixtures"; (fx / "invalid").mkdir(parents=True, exist_ok=True) +(fx / "invalid/human-intrinsic-step.yaml").write_text(yaml.safe_dump(bad, sort_keys=False, width=120)) +print("commit", commit) diff --git a/tools/bounded-execution-admission/tests/test_validate.py b/tools/bounded-execution-admission/tests/test_validate.py new file mode 100644 index 0000000..ad05b30 --- /dev/null +++ b/tools/bounded-execution-admission/tests/test_validate.py @@ -0,0 +1,195 @@ +"""Tests for the Bounded Execution Admission validator (NK-WP-0056-T01). Synthetic data only.""" + +from __future__ import annotations + +import copy +import json +import pathlib +import sys +import unittest + +HERE = pathlib.Path(__file__).resolve().parent +TOOL = HERE.parent +ROOT = TOOL.parents[1] +sys.path.insert(0, str(TOOL)) +import validate as V # noqa: E402 + +SCHEMA = json.loads(V.SCHEMA.read_text()) +EXAMPLES = ROOT / "examples/bounded-execution-admission" +ROTATION = V.load(EXAMPLES / "bind-rotation-svc-authelia.yaml") +SUPERADMIN = V.load(EXAMPLES / "bind-split-switch-super-admin.yaml") +VERIFY = V.load(EXAMPLES / "bind-split-read-only-verify.yaml") + + +def mutate(document, fn, redigest=True): + d = copy.deepcopy(document) + fn(d["spec"]) + if redigest: + d["spec"]["plan_digest"] = V.plan_digest(d) + return d + + +def rules(document): + return {f["rule"] for f in V.evaluate(document, SCHEMA)["findings"]} + + +def record(document, **over): + r = {"apiVersion": V.CARRIER_RECORD, "carrier": "approval-engine", "approval_ref": "apr-synthetic-0001", + "admission_id": document["spec"]["id"], "admission_digest": V.admission_digest(document), + "approver": {"name": "admin", "principal_type": "HUMAN", "assurance": "aal2"}, + "approved_at": "2026-11-03T08:30:00Z", + "consumptions": [{"at": "2026-11-03T09:05:00Z", "by": document["spec"]["executor"]["identity"]}]} + r.update(over) + return r + + +class ExampleTests(unittest.TestCase): + def test_all_examples_valid(self): + for path in sorted(EXAMPLES.glob("*.yaml")): + with self.subTest(path=path.name): + report = V.evaluate(V.load(path), SCHEMA) + self.assertTrue(report["valid"], report["findings"]) + + def test_invalid_fixtures_refused(self): + for path in sorted((TOOL / "fixtures/invalid").glob("*.yaml")): + with self.subTest(path=path.name): + self.assertFalse(V.evaluate(V.load(path), SCHEMA)["valid"]) + + def test_examples_pin_current_source(self): + """Pinned hashes are real files of this repository (they drift when the files change).""" + for path in sorted(EXAMPLES.glob("*.yaml")): + for f in V.load(path)["spec"]["plan"]["source"]["files"]: + self.assertTrue((ROOT / f["path"]).is_file(), f["path"]) + + +class RuleTests(unittest.TestCase): + def test_bea1_schema(self): + self.assertEqual(rules(mutate(ROTATION, lambda s: s.pop("budget"), redigest=False)), {"BEA-1"}) + self.assertEqual(rules(mutate(ROTATION, lambda s: s["approval"].update(assurance="aal1"))), {"BEA-1"}) + self.assertEqual(rules(mutate(ROTATION, lambda s: s["consumption"].update(single_use=False))), {"BEA-1"}) + + def test_bea2_plan_digest_binds_the_exact_plan(self): + changed = mutate(ROTATION, lambda s: s["plan"]["steps"][0].update(operation="something-else"), redigest=False) + self.assertEqual(rules(changed), {"BEA-2"}) + + def test_bea3_window(self): + self.assertIn("BEA-3", rules(mutate(ROTATION, lambda s: s["window"].update(not_after="2026-11-04T10:00:00Z")))) + self.assertIn("BEA-3", rules(mutate(ROTATION, lambda s: s["window"].update(not_after="2026-11-03T08:00:00Z")))) + self.assertIn("BEA-3", rules(mutate(SUPERADMIN, lambda s: s["window"].update(not_after="2026-11-03T14:00:00Z")))) + + def test_window_state(self): + at = V.parse_time + self.assertEqual(V.evaluate(ROTATION, SCHEMA, at("2026-11-03T10:00:00Z"))["window_state"], "open") + self.assertEqual(V.evaluate(ROTATION, SCHEMA, at("2026-11-03T11:00:00Z"))["window_state"], "expired") + self.assertEqual(V.evaluate(ROTATION, SCHEMA, at("2026-11-02T10:00:00Z"))["window_state"], "not-yet") + + def test_bea4_executor(self): + self.assertIn("BEA-4", rules(mutate(ROTATION, lambda s: s["executor"].update(principal_type="HUMAN")))) + self.assertIn("BEA-4", rules(mutate(ROTATION, lambda s: s["executor"].update(identity="admin")))) + self.assertIn("BEA-4", rules(mutate(ROTATION, lambda s: s["executor"].update(principal_type="AGENT")))) + harnessed = mutate(ROTATION, lambda s: s["executor"].update(principal_type="AGENT", harness_launched=True)) + self.assertEqual(rules(harnessed), set()) + + def test_bea5_ceiling(self): + self.assertIn("BEA-5", rules(mutate(ROTATION, lambda s: s["scope_ceiling"].append( + {"mode": "GOVERN", "scope": "lldap", "environments": ["PRODUCTION"]})))) + # whole-directory ADMINISTER for a delegable step at medium risk + def widen(s): + s["scope_ceiling"][1]["scope"] = "lldap" + self.assertIn("BEA-5", rules(mutate(ROTATION, widen))) + + def test_bea6_least_privilege(self): + self.assertIn("BEA-6", rules(mutate(ROTATION, lambda s: s["scope_ceiling"].pop(3)))) # restart uncovered + self.assertIn("BEA-6", rules(mutate(ROTATION, lambda s: s["scope_ceiling"].append( + {"mode": "OPERATE", "scope": "k8s/sso/deployment/keycape", "environments": ["PRODUCTION"]})))) # unused + + def test_bea7_root_class_needs_high_risk(self): + self.assertIn("BEA-7", rules(mutate(ROTATION, lambda s: s["plan"]["steps"][0].update({"class": "root-class"})))) + + def test_bea8_blast_radius(self): + self.assertIn("BEA-8", rules(mutate(ROTATION, lambda s: s["plan"]["steps"][3].update(consumer="keycape")))) + def two_at_once(s): + s["blast_radius"].update(consumers=["authelia", "keycape"], max_concurrent=2) + self.assertIn("BEA-8", rules(mutate(ROTATION, two_at_once))) + + def test_bea9_checks(self): + self.assertIn("BEA-9", rules(mutate(ROTATION, lambda s: s["plan"]["steps"][3].pop("checks_after")))) + self.assertIn("BEA-9", rules(mutate(ROTATION, lambda s: s["checks"][3].update(sha256="0" * 64)))) + self.assertIn("BEA-9", rules(mutate(ROTATION, lambda s: s["checks"].append(dict(s["checks"][0], id="spare"))))) + + def test_bea10_revert(self): + self.assertIn("BEA-10", rules(mutate(ROTATION, lambda s: s["plan"]["steps"][1].pop("revert")))) + def irreversible(s): + s["plan"]["steps"][1].pop("revert") + s["plan"]["steps"][1]["irreversible"] = True + self.assertIn("BEA-10", rules(mutate(ROTATION, irreversible))) # medium risk + + def test_bea11_stop_policy(self): + self.assertIn("BEA-11", rules(mutate(ROTATION, lambda s: s["stop_and_revert"].update(on_check_failure="stop")))) + self.assertIn("BEA-11", rules(mutate(VERIFY, lambda s: s["stop_and_revert"].update(on_check_failure="revert")))) + + def test_bea12_budget(self): + self.assertIn("BEA-12", rules(mutate(ROTATION, lambda s: s["budget"].update(wall_time_seconds=8000)))) + self.assertIn("BEA-12", rules(mutate(SUPERADMIN, lambda s: s["budget"].update(attempts=2)))) + self.assertIn("BEA-12", rules(mutate(ROTATION, lambda s: s["budget"].update(wall_time_seconds=100)))) + + def test_bea14_secret_shaped_values(self): + self.assertIn("BEA-14", rules(mutate(ROTATION, lambda s: s.update(purpose="token hvs.abcdefghijklmnop")))) + + def test_findings_never_echo_values(self): + marker = "hvs.SYNTHETICMARKER12345" + report = V.evaluate(mutate(ROTATION, lambda s: s.update(purpose=marker)), SCHEMA) + self.assertNotIn(marker, json.dumps(report)) + + +class ApprovalTests(unittest.TestCase): + def check(self, document, rec): + return V.check_approval(document, rec) + + def test_valid_record_admits_and_is_consumed(self): + report = self.check(ROTATION, record(ROTATION)) + self.assertTrue(report["admitted"], report) + self.assertTrue(report["consumed"]) + self.assertTrue(self.check(ROTATION, record(ROTATION, consumptions=[]))["admitted"]) + + def test_refusals(self): + other = mutate(ROTATION, lambda s: s.update(title="a different request")) + cases = { + "BEA-C1": record(ROTATION, carrier="email"), + "BEA-C2": record(ROTATION, admission_digest=V.admission_digest(other)), + "BEA-C3": record(ROTATION, approver={"name": "tegwick", "principal_type": "HUMAN", "assurance": "aal2"}), + "BEA-C4": record(ROTATION, approver={"name": "admin", "principal_type": "HUMAN", "assurance": "aal1"}), + "BEA-C5": record(ROTATION, approved_at="2026-11-03T12:00:00Z", consumptions=[]), + "BEA-C6": record(ROTATION, consumptions=[{"at": "2026-11-03T09:05:00Z", "by": "service:nk-bind-executor"}, + {"at": "2026-11-03T09:40:00Z", "by": "service:nk-bind-executor"}]), + "BEA-C7": record(ROTATION, consumptions=[{"at": "2026-11-03T11:30:00Z", "by": "service:nk-bind-executor"}]), + } + for rule, rec in cases.items(): + with self.subTest(rule=rule): + report = self.check(ROTATION, rec) + self.assertFalse(report["admitted"]) + self.assertIn(rule, {f["rule"] for f in report["findings"]}) + + def test_consumed_by_someone_else(self): + rec = record(ROTATION, consumptions=[{"at": "2026-11-03T09:05:00Z", "by": "service:other"}]) + self.assertIn("BEA-C7", {f["rule"] for f in self.check(ROTATION, rec)["findings"]}) + + +class CliTests(unittest.TestCase): + def test_cli(self): + import contextlib + import io + import tempfile + out = io.StringIO() + with contextlib.redirect_stdout(out): + self.assertEqual(V.main(["validate", str(EXAMPLES / "bind-rotation-svc-authelia.yaml")]), 0) + self.assertEqual(V.main(["validate", str(TOOL / "fixtures/invalid/human-intrinsic-step.yaml")]), 1) + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "record.json" + path.write_text(json.dumps(record(ROTATION))) + self.assertEqual(V.main(["check-approval", str(EXAMPLES / "bind-rotation-svc-authelia.yaml"), str(path)]), 0) + self.assertEqual(V.main(["digest", str(EXAMPLES / "bind-rotation-svc-authelia.yaml")]), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/bounded-execution-admission/validate.py b/tools/bounded-execution-admission/validate.py new file mode 100644 index 0000000..b09008a --- /dev/null +++ b/tools/bounded-execution-admission/validate.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Validate NetKingdom Bounded Execution Admissions (standard v0.1, NK-WP-0056-T01). + +Offline and deterministic. Three commands: + + validate [--as-of T] FILE... schema plus rules BEA-1..BEA-14 for each admission + check-approval ADMISSION RECORD rules BEA-C1..BEA-C7: does a carrier record (Approval Engine + approval or founder SSH signature) admit exactly this admission, + and was it consumed at most once, by the executor, in the window + digest FILE print plan_digest and admission_digest (for authoring) + +Findings name rules and locations, never field values. Nothing here contacts a service, reads a +secret or authorizes anything: a valid admission is a well-formed request, not an admission. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import pathlib +import re +import sys +from typing import Any + +import jsonschema +import yaml + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SCHEMA = ROOT / "canon/schemas/bounded-execution-admission_v0.1.schema.json" +SECRET_VALUE = re.compile( + r"(\bhv[sbr]\.[A-Za-z0-9_-]{8,}|\bs\.[A-Za-z0-9]{20,}" + r"|-----BEGIN [A-Z ]*PRIVATE KEY-----|AGE-SECRET-KEY-1[A-Z0-9]{10,}" + r"|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\." + r"|://[^/\s:@]+:[^/\s@]{3,}@)" +) +MAX_WINDOW = {"low": dt.timedelta(hours=72), "medium": dt.timedelta(hours=24), "high": dt.timedelta(hours=4)} +MAX_ATTEMPTS = {"low": 3, "medium": 3, "high": 1} +CARRIER_RECORD = "netkingdom.io/bounded-execution-approval/v0.1" + + +# -------------------------------------------------------------------------------------------------- +# Loading and digests +# -------------------------------------------------------------------------------------------------- + + +def load(path: pathlib.Path) -> Any: + text = path.read_text(encoding="utf-8") + data = json.loads(text) if path.suffix == ".json" else yaml.safe_load(text) + return json.loads(json.dumps(data, default=str)) # YAML dates become strings, as JSON would carry them + + +def canonical(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +def plan_digest(document: dict) -> str: + return "sha256:" + hashlib.sha256(canonical(document["spec"]["plan"])).hexdigest() + + +def admission_digest(document: dict) -> str: + """What a carrier binds: the whole admission, including plan_digest.""" + return "sha256:" + hashlib.sha256(canonical(document)).hexdigest() + + +def parse_time(value: str) -> dt.datetime: + return dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def strings(value: Any): + if isinstance(value, str): + yield value + elif isinstance(value, dict): + for item in value.values(): + yield from strings(item) + elif isinstance(value, list): + for item in value: + yield from strings(item) + + +# -------------------------------------------------------------------------------------------------- +# Rules +# -------------------------------------------------------------------------------------------------- + + +def covers(grant: dict, need: dict) -> bool: + return grant["mode"] == need["mode"] and (need["scope"] == grant["scope"] + or need["scope"].startswith(grant["scope"] + "/")) + + +def evaluate(document: Any, schema: dict, as_of: dt.datetime | None = None) -> dict: + findings: list[dict] = [] + + def fail(rule: str, where: str) -> None: + findings.append({"rule": rule, "where": where}) + + validator = jsonschema.Draft202012Validator(schema, format_checker=jsonschema.FormatChecker()) + schema_errors = sorted(f"{'/'.join(str(p) for p in e.absolute_path) or ''}:{e.validator}" + for e in validator.iter_errors(document)) + if schema_errors: + return {"valid": False, "findings": [{"rule": "BEA-1", "where": e} for e in schema_errors]} + + spec = document["spec"] + risk = spec["risk_class"] + steps = spec["plan"]["steps"] + reverts = spec["plan"].get("revert_steps", []) + every_step = steps + reverts + ceiling = spec["scope_ceiling"] + checks = {c["id"]: c for c in spec["checks"]} + pinned = {f["path"]: f["sha256"] for f in spec["plan"]["source"]["files"]} + + # BEA-2 the admission binds the exact plan. + if spec["plan_digest"] != plan_digest(document): + fail("BEA-2", "spec/plan_digest") + + # BEA-3 a bounded window, tighter for higher risk. + start, end = parse_time(spec["window"]["not_before"]), parse_time(spec["window"]["not_after"]) + window = end - start + if window <= dt.timedelta(0): + fail("BEA-3", "spec/window") + elif window > MAX_WINDOW[risk]: + fail("BEA-3", "spec/window:longer-than-risk-class-allows") + + # BEA-4 the executor is not a person and not an approver; an agent only when harness-launched (SI-14). + executor = spec["executor"] + if executor["principal_type"] == "HUMAN": + fail("BEA-4", "spec/executor/principal_type") + if executor["identity"] in spec["approval"]["approvers"]: + fail("BEA-4", "spec/executor/identity:is-an-approver") + if executor["principal_type"] == "AGENT" and executor.get("harness_launched") is not True: + fail("BEA-4", "spec/executor/harness_launched") + + # BEA-5 the ceiling never holds sovereign or rule-changing authority; whole-service ADMINISTER is + # root-class, high risk and one consumer at a time. + for i, grant in enumerate(ceiling): + where = f"spec/scope_ceiling/{i}" + if grant["mode"] in ("SOVEREIGN", "GOVERN"): + fail("BEA-5", where + ":mode") + if grant["mode"] == "CONSTRUCT" and "PRODUCTION" in grant["environments"]: + fail("BEA-5", where + ":construct-in-production") + if grant["mode"] == "ADMINISTER": + if spec["blast_radius"]["max_concurrent"] != 1: + fail("BEA-5", where + ":administer-needs-max-concurrent-1") + if "/" not in grant["scope"]: + users = [s for s in every_step if covers(grant, s["requires"])] + if risk != "high" or any(s["class"] != "root-class" for s in users): + fail("BEA-5", where + ":whole-service-administer-is-root-class-high-risk") + + # BEA-6 least privilege: every step fits the ceiling, and every ceiling grant is used. + for i, step in enumerate(every_step): + if not any(covers(g, step["requires"]) for g in ceiling): + fail("BEA-6", f"step/{step['id']}/requires:outside-ceiling") + for i, grant in enumerate(ceiling): + if not any(covers(grant, s["requires"]) for s in every_step): + fail("BEA-6", f"spec/scope_ceiling/{i}:unused") + + # BEA-7 classes: human-intrinsic never; root-class only at high risk. + for step in every_step: + if step["class"] == "human-intrinsic": + fail("BEA-7", f"step/{step['id']}/class:human-intrinsic") + if step["class"] == "root-class" and risk != "high": + fail("BEA-7", f"step/{step['id']}/class:root-class-needs-high-risk") + + # BEA-8 blast radius. + radius = spec["blast_radius"] + for step in every_step: + if step["consumer"] not in radius["consumers"]: + fail("BEA-8", f"step/{step['id']}/consumer:outside-blast-radius") + if radius["max_concurrent"] > len(radius["consumers"]): + fail("BEA-8", "spec/blast_radius/max_concurrent") + if risk in ("medium", "high") and radius["max_concurrent"] != 1: + fail("BEA-8", "spec/blast_radius/max_concurrent:one-at-a-time-for-medium-and-high") + + # BEA-9 named machine checks, pinned to the admitted source. + if len(checks) != len(spec["checks"]): + fail("BEA-9", "spec/checks:duplicate-id") + used = set() + for step in steps: + if step["mutates"] and not step.get("checks_after"): + fail("BEA-9", f"step/{step['id']}/checks_after:missing") + for ref in step.get("checks_after", []): + used.add(ref) + if ref not in checks: + fail("BEA-9", f"step/{step['id']}/checks_after:undeclared") + for cid, check in checks.items(): + if cid not in used: + fail("BEA-9", f"check/{cid}:unused") + if pinned.get(check["ref"]) != check["sha256"]: + fail("BEA-9", f"check/{cid}/ref:not-pinned-in-plan-source") + + # BEA-10 every mutating step can be undone, or is declared irreversible at high risk. + ids = [s["id"] for s in every_step] + if len(set(ids)) != len(ids): + fail("BEA-10", "plan:duplicate-step-id") + revert_ids = {s["id"] for s in reverts} + for step in steps: + if not step["mutates"]: + continue + if step.get("irreversible"): + if risk != "high": + fail("BEA-10", f"step/{step['id']}/irreversible:needs-high-risk") + elif step.get("revert") not in revert_ids: + fail("BEA-10", f"step/{step['id']}/revert:missing") + for step in reverts: + if not step["mutates"] or step.get("revert") or step.get("irreversible"): + fail("BEA-10", f"revert_step/{step['id']}:must-mutate-and-not-nest") + + # BEA-11 stop-and-revert follows the plan: a revertable plan reverts on failure. + policy = spec["stop_and_revert"] + if revert_ids and policy["on_check_failure"] != "revert": + fail("BEA-11", "spec/stop_and_revert/on_check_failure") + if not revert_ids and policy["on_check_failure"] == "revert": + fail("BEA-11", "spec/stop_and_revert/on_check_failure:nothing-to-revert") + + # BEA-12 the budget fits the window and the checks fit the budget. + budget = spec["budget"] + if budget["wall_time_seconds"] > max(window.total_seconds(), 0): + fail("BEA-12", "spec/budget/wall_time_seconds:exceeds-window") + if budget["attempts"] > MAX_ATTEMPTS[risk]: + fail("BEA-12", "spec/budget/attempts") + check_time = sum(c["timeout_seconds"] + c.get("settle_seconds", 0) for c in spec["checks"]) + if check_time > budget["wall_time_seconds"]: + fail("BEA-12", "spec/budget/wall_time_seconds:shorter-than-checks") + + # BEA-13 the executed source is pinned and the plan names it. + if not spec["plan"]["source"]["files"]: + fail("BEA-13", "spec/plan/source/files") + + # BEA-14 no secret-shaped values anywhere. + if any(SECRET_VALUE.search(s) for s in strings(document)): + fail("BEA-14", ":secret-shaped-value") + + report = {"valid": not findings, "findings": findings, "plan_digest": plan_digest(document), + "admission_digest": admission_digest(document)} + if as_of is not None: + report["window_state"] = ("not-yet" if as_of < start else "expired" if as_of >= end else "open") + return report + + +def check_approval(document: dict, record: Any) -> dict: + """BEA-C1..C7: a carrier record admits exactly this admission, consumed at most once in the window.""" + findings: list[dict] = [] + + def fail(rule: str, where: str) -> None: + findings.append({"rule": rule, "where": where}) + + spec = document["spec"] + required = ("apiVersion", "carrier", "approval_ref", "admission_id", "admission_digest", "approver", + "approved_at", "consumptions") + if not isinstance(record, dict) or any(k not in record for k in required) \ + or record.get("apiVersion") != CARRIER_RECORD: + return {"admitted": False, "findings": [{"rule": "BEA-C1", "where": ":shape"}]} + if record["carrier"] not in spec["approval"]["carriers"]: + fail("BEA-C1", "record/carrier") + if record["admission_id"] != spec["id"] or record["admission_digest"] != admission_digest(document): + fail("BEA-C2", "record/admission_digest") + approver = record["approver"] if isinstance(record["approver"], dict) else {} + if approver.get("name") not in spec["approval"]["approvers"] or approver.get("principal_type") != "HUMAN": + fail("BEA-C3", "record/approver") + if approver.get("assurance") != "aal2": + fail("BEA-C4", "record/approver/assurance") + if approver.get("name") == spec["executor"]["identity"]: + fail("BEA-C3", "record/approver:is-the-executor") + start, end = parse_time(spec["window"]["not_before"]), parse_time(spec["window"]["not_after"]) + approved = parse_time(record["approved_at"]) + if approved >= end: + fail("BEA-C5", "record/approved_at:after-window") + consumptions = record["consumptions"] if isinstance(record["consumptions"], list) else [] + if len(consumptions) > 1: + fail("BEA-C6", "record/consumptions:more-than-once") + for i, use in enumerate(consumptions): + at = parse_time(use["at"]) + if not (start <= at < end) or at < approved: + fail("BEA-C7", f"record/consumptions/{i}/at") + if use.get("by") != spec["executor"]["identity"]: + fail("BEA-C7", f"record/consumptions/{i}/by") + return {"admitted": not findings, "consumed": len(consumptions) == 1, "findings": findings} + + +# -------------------------------------------------------------------------------------------------- +# CLI +# -------------------------------------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + sub = parser.add_subparsers(dest="command", required=True) + v = sub.add_parser("validate") + v.add_argument("paths", nargs="+", type=pathlib.Path) + v.add_argument("--as-of", help="evaluate the window at this time (ISO 8601)") + c = sub.add_parser("check-approval") + c.add_argument("admission", type=pathlib.Path) + c.add_argument("record", type=pathlib.Path) + d = sub.add_parser("digest") + d.add_argument("path", type=pathlib.Path) + args = parser.parse_args(argv) + schema = json.loads(SCHEMA.read_text(encoding="utf-8")) + + if args.command == "digest": + document = load(args.path) + print(json.dumps({"plan_digest": plan_digest(document), "admission_digest": admission_digest(document)})) + return 0 + if args.command == "check-approval": + document = load(args.admission) + if not evaluate(document, schema)["valid"]: + print(json.dumps({"admitted": False, "findings": [{"rule": "BEA-C0", "where": "admission:invalid"}]})) + return 1 + report = check_approval(document, load(args.record)) + print(json.dumps(report, sort_keys=True)) + return 0 if report["admitted"] else 1 + as_of = parse_time(args.as_of) if args.as_of else None + worst = 0 + for path in args.paths: + report = evaluate(load(path), schema, as_of) + report["file"] = str(path) + print(json.dumps(report, sort_keys=True)) + worst = max(worst, 0 if report["valid"] else 1) + return worst + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/security-tools.json b/tools/security-tools.json index cb7dbfe..cb4dfe8 100644 --- a/tools/security-tools.json +++ b/tools/security-tools.json @@ -17,6 +17,9 @@ {"what": "Who and what unlocks recovery", "where": "docs/crisis-custody-list.md"} ], "tools": [ + {"id": "bounded-execution-admission", "path": "tools/bounded-execution-admission", "answers": "Is this admission request well-formed and bounded (exact plan digest, window, scope ceiling, blast radius, checks, revert, budget), and does a carrier record admit it, consumed at most once?", + "run": "make bounded-execution-admission-check", "test": "make bounded-execution-admission-test", "agent_may_run": "yes", + "standard": "canon/standards/bounded-execution-admission_v0.1.md"}, {"id": "service-identity", "path": "tools/service-identity", "answers": "Do the declared service accounts conform to the identity and privilege standard?", "run": "make service-identity-check", "test": "make service-identity-test", "agent_may_run": "yes", "standard": "canon/standards/service-identity-privilege_v0.1.md"}, diff --git a/workplans/NK-WP-0056-bounded-executor-path.md b/workplans/NK-WP-0056-bounded-executor-path.md index 99aec95..68784a5 100644 --- a/workplans/NK-WP-0056-bounded-executor-path.md +++ b/workplans/NK-WP-0056-bounded-executor-path.md @@ -54,7 +54,7 @@ and step 6 had to be done by a one-off path. ```task id: NK-WP-0056-T01 -status: todo +status: done priority: high assignee: claude-code state_hub_task_id: "99df0ee2-2c17-55f2-9f3e-4277da52e01c" @@ -71,6 +71,16 @@ fixtures. **Done when:** the standard is proposed with a schema, a validator, and the bind-split steps expressed as admissions in its examples. +**Done 2026-10-31.** Proposed `canon/standards/bounded-execution-admission_v0.1.md` with +`canon/schemas/bounded-execution-admission_v0.1.schema.json`, `tools/bounded-execution-admission/validate.py` +(rules BEA-1..14 on the request, BEA-C1..C7 on a carrier record; 22 tests) and four examples in +`examples/bounded-execution-admission/`: the bind split's read-only verify (low), create `svc-*` accounts and switch +LLDAP's super-admin (root-class, high), and one-consumer bind rotation (delegable, medium, the T06 shape). Step 10 is +refused as human-intrinsic. Design choices: the request is immutable and bound by digest; the founder's approval +lives in a carrier (Approval Engine preferred, founder SSH signature transitional), so no second approval system; +`trust_requires` uses the Playbook Capability Contract's trust states; `settle_seconds` carries the T08 +privacyIDEA-cache finding; recurring admissions are excluded from v0.1. The Approval Engine mapping is a proposal for T02. + ## Owner agreement on the components ```task