From d96aab2321a0a25356b84ba3607b219d2fe4ccc2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 23 Aug 2026 12:40:52 +0200 Subject: [PATCH] feat(orchestration): compose security scenarios Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02929-244b-7391-b933-c04010e8eedb --- Makefile | 10 +- README.md | 11 +- SCOPE.md | 16 +- WORK-RECORDS.md | 6 + .../security-scenario_v0.1.schema.json | 78 +++ .../playbook-capability-contract_v0.1.md | 18 +- .../security-scenario-composition_v0.1.md | 142 ++++++ .../playbooks/net-kingdom.local-identity.yaml | 114 +++++ examples/security-scenarios/README.md | 17 + .../security-scenarios/c0-local-identity.yaml | 15 + .../2026-08-23-scope-intent-gap-assessment.md | 10 + .../capability.security.iam-tooling-suite.md | 48 +- registry/indexes/capabilities.yaml | 7 +- tools/playbook-capability-contract/README.md | 13 +- .../playbook_contract_validator.py | 53 ++- .../tests/test_playbook_contract_validator.py | 108 +++++ tools/security-scenario-composer/README.md | 24 + .../security_scenario_composer.py | 445 ++++++++++++++++++ .../tests/test_security_scenario_composer.py | 266 +++++++++++ ...rministic-security-scenario-composition.md | 93 ++++ 20 files changed, 1464 insertions(+), 30 deletions(-) create mode 100644 canon/schemas/security-scenario_v0.1.schema.json create mode 100644 canon/standards/security-scenario-composition_v0.1.md create mode 100644 capabilities/playbooks/net-kingdom.local-identity.yaml create mode 100644 examples/security-scenarios/README.md create mode 100644 examples/security-scenarios/c0-local-identity.yaml create mode 100644 tools/security-scenario-composer/README.md create mode 100644 tools/security-scenario-composer/security_scenario_composer.py create mode 100644 tools/security-scenario-composer/tests/test_security_scenario_composer.py create mode 100644 workplans/NK-WP-0030-deterministic-security-scenario-composition.md diff --git a/Makefile b/Makefile index fa8d300..20ea18c 100644 --- a/Makefile +++ b/Makefile @@ -185,12 +185,20 @@ openbao-init-unseal: ## SOPS-held OpenBao init/unseal (NET-WP-0020 T2; requires openbao-init-unseal-dry-run: ## Dry-run the SOPS-held OpenBao init/unseal path @bash sso-mfa/bootstrap/openbao-init-unseal.sh --dry-run -iam-profile-conformance-test: ## Run IAM Profile v0.2 conformance fixture tests +iam-profile-conformance-test: ## Run current IAM Profile conformance fixture tests python3 -m pytest tools/iam-profile-conformance/tests playbook-contract-test: ## Run Playbook Capability Contract fixture tests python3 -m pytest tools/playbook-capability-contract/tests +security-scenario-composer-test: ## Run deterministic security scenario composer tests + python3 -m pytest tools/security-scenario-composer/tests + +security-scenario-compose-c0: ## Compose the plan-only C0 local-identity reference + python3 tools/security-scenario-composer/security_scenario_composer.py \ + --scenario examples/security-scenarios/c0-local-identity.yaml \ + capabilities/playbooks/net-kingdom.local-identity.yaml + security-bootstrap-console-test: ## Run automated tests for bootstrap console UI/sections/runbooks (NET-WP-0018-T07) python3 -m pytest tools/security-bootstrap-console/tests diff --git a/README.md b/README.md index 71cead2..22dcb5b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,19 @@ # NetKingdom -NetKingdom provides a dynamic self optimizing full circle security-platform for kubernetes deployed IT-infrastructures. +NetKingdom is the canonical security architecture, integration boundary, and +bootstrap/reference implementation for NetKingdom environments. It defines +identity, tenancy, credential, workload-zone, and security-composition +contracts while leaving provider and Railiance execution in their owning +repositories. + +The dynamic, self-optimizing security platform is the long-term direction in +[INTENT.md](INTENT.md), not a claim about current delivery. ## Orientation - [SCOPE.md](SCOPE.md) — what this repo owns, current state, and when it is relevant +- [Security scenario composition](canon/standards/security-scenario-composition_v0.1.md) + — deterministic, plan-only capability and trust composition ## Security Infrastructure Documents diff --git a/SCOPE.md b/SCOPE.md index 5af89e6..378e26c 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -42,6 +42,8 @@ about the current implementation. model. - Playbook Capability Contract v0.1 for the boundary between NetKingdom selection/parameterization and Railiance execution. +- Security Scenario Composition v0.1 for deterministic provider selection, + trust ordering, responsibility mapping, and non-executing owner handoff. - Tenancy Posture v0.1 and Security Zones v0.1 proposed standards, their schemas, validators, evidence rules, and publication stewardship. Zone semantics are owned by `zone-engine`; NetKingdom owns their canon publication. @@ -54,6 +56,9 @@ about the current implementation. development, test, and sandbox use. - IAM Profile, playbook-capability, tenancy-posture, custody, evidence, and bootstrap-policy validators. +- `tools/security-scenario-composer/`: fail-closed, plan-only composition of + declared capabilities, parameters, trust transitions, responsibilities, and + readiness obligations. - `tools/security-bootstrap-console/`: guarded platform-root and OpenBao bootstrap workflow, including refusal of unsafe live initialization. - SOPS/age bootstrap integration, credential-generation and rotation helpers, @@ -99,7 +104,7 @@ repositories while NetKingdom retains the contracts and reference evidence. | Tier | Current repository/estate capability | Delivery state | | --- | --- | --- | -| C0 — Bootstrap identity | Local OIDC identity, SOPS/age bootstrap, guarded credential workflow, and greenfield OpenBao init/unseal proof | Implemented as reference/bootstrap tooling | +| C0 — Bootstrap identity | Local OIDC identity, SOPS/age bootstrap, guarded credential workflow, greenfield OpenBao init/unseal proof, and deterministic plan-only C0 composition | Implemented as reference/bootstrap tooling | | C1 — Lightweight SSO | IAM-profile-based KeyCape composition using Authelia and LLDAP | Live-proven integration; implementation externally owned | | C2 — MFA/token authority | Authelia factors and privacyIDEA integration | Live-proven integration; implementation externally owned | | C3 — Runtime secrets | OpenBao custody, bootstrap, policy, delivery, and recovery contracts | Integrated with an externally deployed runtime; production evidence remains gated | @@ -110,7 +115,9 @@ repositories while NetKingdom retains the contracts and reference evidence. Current open work as of 2026-08-23 is either externally blocked, date-gated, or explicit backlog: reef carrier/public-classification decisions in NK-WP-0027, the NK-WP-0022 retirement gate, security tutorials in NK-WP-0009, and -enterprise federation in NK-WP-0011. +enterprise federation in NK-WP-0011. NK-WP-0030 has delivered the local C0 +scenario-composition slice and waits for externally owned C1/C2 provider +declarations before the lightweight SSO scenario can compose. --- @@ -144,7 +151,7 @@ enterprise federation in NK-WP-0011. ```capability type: governance title: NetKingdom security canon -description: Provider-neutral IAM v0.3, user/tenant boundaries, credential, playbook-composition, tenancy-posture, and workload-zone standards with explicit ownership and conformance rules. +description: Provider-neutral IAM v0.3, user/tenant boundaries, credential, playbook and scenario composition, tenancy-posture, and workload-zone standards with explicit ownership and conformance rules. keywords: [iam, oidc, tenancy, workload-identity, security-zones, credentials, canon] ``` @@ -184,5 +191,6 @@ keywords: [meta-orchestration, railiance, responsibility, capability, trust-stat `docs/openbao-unseal-custody-models.md`, and `tools/security-bootstrap-console/` - Executable surfaces: `local-identity/`, `tools/iam-profile-conformance/`, - `tools/playbook-capability-contract/`, and `tools/tenancy-posture/` + `tools/playbook-capability-contract/`, `tools/security-scenario-composer/`, + and `tools/tenancy-posture/` - Work state: `.custodian-brief.md` and `workplans/` diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 3df803d..0b43c78 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -23,6 +23,7 @@ | workplan | NK-WP-0027 | blocked | — | workplans/NK-WP-0027-reef-placement-reconciliation.md | | workplan | NK-WP-0028 | finished | — | workplans/NK-WP-0028-canon-publication-and-grouping-semantics.md | | workplan | NK-WP-0029 | finished | — | workplans/NK-WP-0029-scope-and-intent-reconciliation.md | +| workplan | NK-WP-0030 | blocked | — | workplans/NK-WP-0030-deterministic-security-scenario-composition.md | | task | ADHOC-2026-07-02-T01 | done | — | workplans/ADHOC-2026-07-02.md | | task | ADHOC-2026-07-02-T02 | done | — | workplans/ADHOC-2026-07-02.md | | task | ADHOC-2026-08-14-T01 | done | — | workplans/ADHOC-2026-08-14.md | @@ -94,5 +95,10 @@ | task | NK-WP-0029-T02 | done | — | workplans/NK-WP-0029-scope-and-intent-reconciliation.md | | task | NK-WP-0029-T03 | done | — | workplans/NK-WP-0029-scope-and-intent-reconciliation.md | | task | NK-WP-0029-T04 | done | — | workplans/NK-WP-0029-scope-and-intent-reconciliation.md | +| task | NK-WP-0030-T01 | done | — | workplans/NK-WP-0030-deterministic-security-scenario-composition.md | +| task | NK-WP-0030-T02 | done | — | workplans/NK-WP-0030-deterministic-security-scenario-composition.md | +| task | NK-WP-0030-T03 | done | — | workplans/NK-WP-0030-deterministic-security-scenario-composition.md | +| task | NK-WP-0030-T04 | wait | — | workplans/NK-WP-0030-deterministic-security-scenario-composition.md | +| task | NK-WP-0030-T05 | done | — | workplans/NK-WP-0030-deterministic-security-scenario-composition.md | | intake | NK-IN-0001 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md | | intake | NK-IN-0002 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md | diff --git a/canon/schemas/security-scenario_v0.1.schema.json b/canon/schemas/security-scenario_v0.1.schema.json new file mode 100644 index 0000000..34acced --- /dev/null +++ b/canon/schemas/security-scenario_v0.1.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://netkingdom.local/schemas/security-scenario_v0.1.schema.json", + "title": "NetKingdom Security Scenario v0.1", + "type": "object", + "additionalProperties": false, + "required": ["id", "authority", "requires"], + "properties": { + "id": { + "type": "string", + "pattern": "^scenario:[a-z0-9][a-z0-9._:-]*$" + }, + "authority": { + "enum": ["platform", "netkingdom", "tenant"] + }, + "initial_trust": { + "$ref": "#/$defs/trustStates" + }, + "requires": { + "type": "object", + "additionalProperties": false, + "required": ["capabilities"], + "properties": { + "capabilities": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/capability"} + } + } + }, + "providers": { + "type": "object", + "propertyNames": {"$ref": "#/$defs/capability"}, + "additionalProperties": {"type": "string", "minLength": 1} + }, + "parameter_overrides": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + }, + "$defs": { + "capability": { + "enum": [ + "s1.os-baseline", + "s1.secret-bootstrap", + "s2.cluster-runtime", + "s3.platform-services", + "c0.bootstrap-identity", + "c1.lightweight-sso", + "c2a.light-2fa", + "c2b.token-authority", + "c3.runtime-secrets", + "c4.fine-grained-authorization", + "c5.enterprise-federation", + "c6.self-optimizing-audit" + ] + }, + "trustStates": { + "type": "array", + "uniqueItems": true, + "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" + ] + } + } + } +} diff --git a/canon/standards/playbook-capability-contract_v0.1.md b/canon/standards/playbook-capability-contract_v0.1.md index 0993d71..bb94e9b 100644 --- a/canon/standards/playbook-capability-contract_v0.1.md +++ b/canon/standards/playbook-capability-contract_v0.1.md @@ -6,7 +6,7 @@ domain: netkingdom status: accepted version: "0.1" created: "2026-05-22" -updated: "2026-05-22" +updated: "2026-08-23" scope: meta-orchestration adr: - docs/adr/ADR-0012-playbook-capability-contract-ownership.md @@ -161,6 +161,12 @@ Security-sensitive and secret-reference parameters MUST NOT be `tenant_tunable`. Secret-reference defaults must be references or paths, not plaintext secret values. +For executable validation, a `secret_reference` value MUST be a non-whitespace +URI such as `openbao://kv/platform/example` or +`kubernetes://namespace/name#key`, or an explicit absolute/relative path +beginning with `/`, `./`, or `../`. Bare strings are rejected because the +composer cannot distinguish them from secret values. + Supported constraints: | Constraint | Applies to | Meaning | @@ -244,7 +250,8 @@ separate playbook runner execute safely. ## Scenario Shape -The validator supports a small scenario file for conformance demos: +The validator supports a small scenario file for single-provider conformance +demos: ```yaml id: scenario:s1-host-bootstrap-reference @@ -263,6 +270,13 @@ Allowed scenario authorities are `platform`, `netkingdom`, and `tenant`. Tenant authority cannot override `platform_only`, `security_sensitive`, or `secret_reference` parameters. +The demo composer refuses ambiguous providers and overrides aimed at unselected +declarations. Deterministic multi-provider selection, explicit provider pins, +trust sequencing, responsibility maps, and owner-routed readiness handoffs are +defined by `security-scenario-composition_v0.1.md` and implemented by +`tools/security-scenario-composer/`. Use that contract for operational planning; +the demo path does not authorize or execute playbooks. + ## Conformance A declaration conforms when it passes: diff --git a/canon/standards/security-scenario-composition_v0.1.md b/canon/standards/security-scenario-composition_v0.1.md new file mode 100644 index 0000000..ffebffd --- /dev/null +++ b/canon/standards/security-scenario-composition_v0.1.md @@ -0,0 +1,142 @@ +--- +id: netkingdom-security-scenario-composition-v0.1 +type: standard +title: "NetKingdom Security Scenario Composition v0.1" +domain: netkingdom +status: proposed +version: "0.1" +owner: net-kingdom +created: "2026-08-23" +updated: "2026-08-23" +last_reviewed: "2026-08-23" +review_interval: 3m +scope: meta-orchestration +schema: + - canon/schemas/security-scenario_v0.1.schema.json +validator: + - tools/security-scenario-composer/security_scenario_composer.py +adr: + - docs/adr/ADR-0007-security-orchestration-boundary.md + - docs/adr/ADR-0012-playbook-capability-contract-ownership.md +related: + - canon/standards/playbook-capability-contract_v0.1.md +--- + +# NetKingdom Security Scenario Composition v0.1 + +## 1. Purpose + +This contract defines the deterministic, plan-only boundary between a requested +NetKingdom capability set and the independently owned playbook entry points that +can realize it. It consumes conformant Playbook Capability Contract v0.1 +declarations and produces an owner-routed responsibility, trust, parameter, and +readiness handoff. + +Composition answers **what is selected, in which trust order, with which safe +parameters, and who must execute and evidence it**. It does not run a playbook, +mint a credential, infer authority, or declare a runtime ready. + +## 2. Authority boundary + +- NetKingdom owns scenario intent, provider selection, parameter-policy checks, + trust sequencing, and the composed responsibility map. +- The declaration owner owns the playbook or stable entry point, execution, + rollback, and readiness evidence. +- Railiance retains deployment execution for Railiance-owned declarations. +- A composed plan is not authorization to execute. Any approval, custody, + credential, or change-window gate named by an owner remains in force. + +## 3. Scenario input + +Scenario files conform to +`canon/schemas/security-scenario_v0.1.schema.json`: + +```yaml +id: scenario:c0-local-identity-reference +authority: netkingdom +initial_trust: + - bare_host_trust +requires: + capabilities: + - c0.bootstrap-identity +providers: + c0.bootstrap-identity: net-kingdom.local-identity +parameter_overrides: + net-kingdom.local-identity: + bootstrap_username: bootstrap-admin +``` + +`authority` uses the Playbook Capability Contract vocabulary: `platform`, +`netkingdom`, or `tenant`. Parameter sensitivity and tuning-authority rules are +applied before a plan is emitted. + +`initial_trust` lists trust states established outside this composition. The +composer never assumes an initial trust state. A required state must be present +there or be satisfied by an earlier selected declaration. + +`providers` pins a required capability to an exact declaration id. A pin is +mandatory when more than one valid declaration provides the capability. A pin +may not name an unrequested capability or a declaration that does not provide +the keyed capability. + +## 4. Fail-closed selection rules + +Composition fails when any of the following is true: + +- a declaration is invalid or declaration ids are duplicated; +- a capability is unknown, duplicated, or has no provider; +- multiple providers match and the scenario does not pin one; +- a provider pin does not match the requested capability; +- an override targets an unselected declaration or unknown parameter; +- an override violates type, constraint, sensitivity, or tuning authority; +- a required parameter has neither a default nor an override; +- a required trust state cannot be established without a cycle or inference. + +Selection order never resolves ambiguity. Filesystem order, catalog order, +lexical order, and prior deployment state are not provider authority. + +## 5. Trust sequencing + +The composer starts only with the scenario's explicit `initial_trust` set. It +then selects the lexically first **eligible** declaration, where eligible means +all of that declaration's required trust states have already been established. +After the step, and only for composition purposes, the declaration's satisfied +states become available to later steps. + +Lexical ordering makes independent eligible steps reproducible; it does not +grant one provider precedence during selection. If no remaining declaration is +eligible, composition fails and reports the unresolved trust states. + +Readiness checks attached to a satisfied state are obligations for the owning +executor. They are not marked satisfied merely because the plan contains them. + +## 6. Composition output + +A successful output has +`apiVersion: netkingdom.io/security-scenario-composition/v0.1` and +`kind: SecurityScenarioComposition`. It contains: + +- the requested capabilities and exact selected declaration ids; +- effective parameter values with their source, sensitivity, and tuning + authority; +- ordered execution handoffs containing owner, repository, entry point, + required trust, produced trust, and readiness obligations; +- a flattened responsibility map attributable to declaration ids; +- the final *planned* trust-state set; +- an explicit `execution.permitted: false` boundary. + +The output is non-secret planning material. Declarations and scenarios must use +secret references rather than secret values as required by the Playbook +Capability Contract. + +## 7. Conformance + +Use the canonical tool: + +```text +python3 tools/security-scenario-composer/security_scenario_composer.py \ + --scenario [ ...] +``` + +Exit zero means the declarations and scenario compose deterministically. It +does not mean the plan was executed or its readiness evidence was observed. diff --git a/capabilities/playbooks/net-kingdom.local-identity.yaml b/capabilities/playbooks/net-kingdom.local-identity.yaml new file mode 100644 index 0000000..66e7d65 --- /dev/null +++ b/capabilities/playbooks/net-kingdom.local-identity.yaml @@ -0,0 +1,114 @@ +apiVersion: netkingdom.io/playbook-capability/v0.1 +kind: PlaybookCapabilityDeclaration +metadata: + id: net-kingdom.local-identity + name: NetKingdom local bootstrap identity + owner: net-kingdom + repo: net-kingdom + domain: infotech + contract_version: "0.1" + source_links: + - label: implementation + path: local-identity/ + - label: workplan + path: workplans/archived/260702-NK-WP-0002-local-identity.md +spec: + playbook: + path: local-identity/ + type: cli-sequence + invocation: uv run --project local-identity local-identity init + description: Initialize and serve the loopback-only bootstrap OIDC identity store. + capabilities: + - id: c0.bootstrap-identity + tier: C0 + resource_kinds: + - identities + - secrets_credentials + description: Provide a local OIDC issuer before the managed runtime identity plane exists. + parameters: + - name: local_identity_home + type: string + required: false + default: .local/local-identity + constraints: + pattern: ^[^\n\r]+$ + sensitivity: operational + tuning_authority: netkingdom_tunable + description: LOCAL_IDENTITY_HOME used by the owning execution environment. + - name: bootstrap_username + type: string + required: true + constraints: + pattern: ^[a-z_][a-z0-9_-]{0,31}$ + sensitivity: operational + tuning_authority: netkingdom_tunable + description: Bootstrap username passed to local-identity init. + - name: bootstrap_fullname + type: string + required: true + constraints: + pattern: ^[^\n\r]{1,120}$ + sensitivity: operational + tuning_authority: netkingdom_tunable + description: Display name passed to local-identity init. + - name: bootstrap_email + type: string + required: true + constraints: + pattern: ^[^@\s]+@[^@\s]+$ + sensitivity: operational + tuning_authority: netkingdom_tunable + description: Bootstrap email passed to local-identity init. + - name: listen_port + type: integer + required: false + default: 8443 + constraints: + minimum: 1024 + maximum: 65535 + sensitivity: operational + tuning_authority: netkingdom_tunable + description: Loopback port passed to local-identity serve. + - name: token_ttl_seconds + type: integer + required: false + default: 3600 + constraints: + minimum: 60 + maximum: 3600 + sensitivity: security_sensitive + tuning_authority: platform_only + description: Maximum bootstrap access-token lifetime. + responsibilities: + - resource_kind: identities + owner: net-kingdom + resources: + - local-identity:bootstrap-store + - local-identity:loopback-oidc-issuer + repo_owns: Local store initialization, loopback issuer behavior, token lifecycle, and security checks. + netkingdom_orchestrates: Select C0, provide non-secret bootstrap identity inputs, and require readiness evidence. + - resource_kind: secrets_credentials + owner: net-kingdom + resources: + - local-identity:signing-key + - local-identity:filesystem-permissions + repo_owns: Generate local signing material and enforce private filesystem permissions. + netkingdom_orchestrates: Constrain the store location and retain secret values outside the scenario plan. + trust: + requires: + - state: bare_host_trust + readiness_checks: [] + satisfies: + - state: bootstrap_identity_trust + readiness_checks: + - id: local-identity-permissions + description: The local identity store and signing material have private permissions. + evidence: local-identity security-check exits zero + - id: local-identity-discovery + description: The loopback issuer serves OIDC discovery at the planned issuer URL. + evidence: discovery document is fetched from loopback and its issuer matches the planned port + catalog: + publish: capabilities/playbooks/net-kingdom.local-identity.yaml + maturity: implemented-reference + consumers: + - netkingdom-security-scenario-composer diff --git a/examples/security-scenarios/README.md b/examples/security-scenarios/README.md new file mode 100644 index 0000000..a9ab428 --- /dev/null +++ b/examples/security-scenarios/README.md @@ -0,0 +1,17 @@ +# Security Scenario Examples + +`c0-local-identity.yaml` is the first real, locally owned scenario composition. +It selects the checked-in NetKingdom local-identity declaration, starts from an +explicit `bare_host_trust` state, and produces a plan for +`bootstrap_identity_trust`. + +The example uses non-production identity values and does not execute anything: + +```bash +python3 tools/security-scenario-composer/security_scenario_composer.py \ + --scenario examples/security-scenarios/c0-local-identity.yaml \ + capabilities/playbooks/net-kingdom.local-identity.yaml +``` + +A C1/C2 lightweight SSO example belongs here only after the KeyCape/Railiance +owners publish conformant declarations beside their execution entry points. diff --git a/examples/security-scenarios/c0-local-identity.yaml b/examples/security-scenarios/c0-local-identity.yaml new file mode 100644 index 0000000..1677ad3 --- /dev/null +++ b/examples/security-scenarios/c0-local-identity.yaml @@ -0,0 +1,15 @@ +id: scenario:c0-local-identity-reference +authority: netkingdom +initial_trust: + - bare_host_trust +requires: + capabilities: + - c0.bootstrap-identity +providers: + c0.bootstrap-identity: net-kingdom.local-identity +parameter_overrides: + net-kingdom.local-identity: + local_identity_home: .local/local-identity-reference + bootstrap_username: bootstrap-admin + bootstrap_fullname: Bootstrap Reference Operator + bootstrap_email: bootstrap@example.invalid diff --git a/history/2026-08-23-scope-intent-gap-assessment.md b/history/2026-08-23-scope-intent-gap-assessment.md index ed23cb6..d191475 100644 --- a/history/2026-08-23-scope-intent-gap-assessment.md +++ b/history/2026-08-23-scope-intent-gap-assessment.md @@ -135,3 +135,13 @@ or provider inputs (NK-WP-0027), date-gated (NK-WP-0022), or explicitly backlog (NK-WP-0009 and NK-WP-0011). This is not an assertion that the intended system is complete; it is a statement about the executable queue in this repository on 2026-08-23. + +## Same-day implementation follow-up + +NK-WP-0030 subsequently implemented the first bounded G1 increment: proposed +Security Scenario Composition v0.1 canon, a fail-closed plan-only composer, and +a real C0 local-identity declaration and reference scenario. This establishes +deterministic provider pins, trust ordering, responsibility mapping, and +readiness handoff without executing provider actions. G1 remains open for the +lightweight SSO path until the KeyCape/Railiance owners publish conformant C1 +and C2 declarations. diff --git a/registry/capabilities/capability.security.iam-tooling-suite.md b/registry/capabilities/capability.security.iam-tooling-suite.md index 4618431..d17eef1 100644 --- a/registry/capabilities/capability.security.iam-tooling-suite.md +++ b/registry/capabilities/capability.security.iam-tooling-suite.md @@ -1,9 +1,8 @@ --- id: capability.security.iam-tooling-suite name: NetKingdom Security/IAM Tooling Suite -summary: Dynamic, self-optimizing security platform for Kubernetes-deployed IT infrastructure; owns canonical - IAM/security standards and executable conformance tooling (IAM profile conformance, playbook capability - contract validation, security bootstrap console). +summary: Canonical security architecture and executable reference tooling for IAM, bootstrap, posture validation, + and deterministic plan-only security scenario composition. owner: net-kingdom status: draft domain: infotech @@ -17,17 +16,17 @@ maturity: current: D3 target: D5 confidence: medium - rationale: README plus docs/secrets-engine-security-infrastructure-boundary.md describe an explicit + rationale: SCOPE plus docs/secrets-engine-security-infrastructure-boundary.md describe an explicit integration boundary with OpenBao, flex-auth, user-engine, ops-warden, ops-bridge, info-tech-canon, - and State Hub; canon/standards/ holds versioned standards (iam-profile_v0.2.md, playbook-capability-contract_v0.1.md) - that key-cape and other repos implement against. + and State Hub; canon/standards/ holds versioned IAM v0.3, playbook capability, scenario composition, + tenancy, zone, and boundary standards that provider repos implement against. availability: current: A2 target: A3 confidence: medium - rationale: 'No top-level package manifest, but tools/ holds three real, independently documented and - runnable tools: iam-profile-conformance (executable checks, pytest fixtures), playbook-capability-contract - (executable validator), and security-bootstrap-console (local console + localhost web UI).' + rationale: 'No top-level package manifest, but tools/ holds independently documented and runnable + conformance, posture, scenario-composition, and guarded bootstrap tools; local-identity is separately + packaged and tested.' external_evidence: completeness: level: C1 @@ -35,7 +34,7 @@ external_evidence: basis: scope_vs_intent_and_consumer_expectations satisfied_expectations: - versioned canon standards already implemented by a sibling repo (key-cape) - - three documented, runnable conformance/bootstrap tools under tools/ + - documented, runnable conformance, posture, composition, and bootstrap tools under tools/ broken_expectations: [] out_of_scope_expectations: [] reliability: @@ -46,13 +45,16 @@ external_evidence: - no top-level packaging; each tool under tools/ has its own runtime dependencies, no unified install path yet discovery: - intent: Own the canonical IAM/security standards for the Coulomb ecosystem and provide executable conformance - tooling so implementers (like key-cape) can verify against the standard rather than guessing. + intent: Own canonical NetKingdom security semantics and provide executable reference and conformance tooling + so provider implementations can integrate without inferred authority or hidden deployment coupling. includes: - - canon/standards/ versioned IAM and playbook-capability-contract standards + - canon/standards/ versioned IAM, tenancy, zone, credential, boundary, and composition standards - IAM profile conformance checker - playbook capability contract validator + - deterministic plan-only security scenario composer + - tenancy posture schema and validator - security bootstrap console (local, non-secret-collecting) + - packaged local bootstrap identity CLI excludes: - concrete IAM implementations themselves (see key-cape for lightweight mode) - live secret value handling (bootstrap console explicitly refuses live OpenBao initialization) @@ -65,7 +67,10 @@ availability: current_artifacts: - tools/iam-profile-conformance - tools/playbook-capability-contract + - tools/security-scenario-composer + - tools/tenancy-posture - tools/security-bootstrap-console + - local-identity target_artifacts: [] consumption_modes: - cli @@ -98,17 +103,28 @@ promotion_history: [] ## Overview -`net-kingdom` provides a dynamic, self-optimizing security platform for Kubernetes-deployed infrastructure. It owns the canonical IAM and security standards (implemented by sibling repos like `key-cape`) and ships three executable conformance/bootstrap tools under `tools/`: IAM profile conformance checks, a playbook capability contract validator, and a non-secret-collecting security bootstrap console. +`net-kingdom` provides canonical IAM and security architecture, executable +conformance and posture checks, deterministic plan-only scenario composition, +a local bootstrap identity package, and a non-secret-collecting security +bootstrap console. Provider implementations remain in sibling repositories such +as `key-cape`; the self-optimizing platform remains an intent rather than a +current delivery claim. ## Assessment notes ### Discovery -README plus docs/secrets-engine-security-infrastructure-boundary.md describe an explicit integration boundary with OpenBao, flex-auth, user-engine, ops-warden, ops-bridge, info-tech-canon, and State Hub; canon/standards/ holds versioned standards (iam-profile_v0.2.md, playbook-capability-contract_v0.1.md) that key-cape and other repos implement against. +SCOPE plus docs/secrets-engine-security-infrastructure-boundary.md describe an +explicit integration boundary with OpenBao, flex-auth, user-engine, ops-warden, +ops-bridge, info-tech-canon, and State Hub. `canon/standards/` holds the current +IAM v0.3 and related boundary, posture, zone, credential, playbook, and scenario +composition standards. ### Availability -No top-level package manifest, but tools/ holds three real, independently documented and runnable tools: iam-profile-conformance (executable checks, pytest fixtures), playbook-capability-contract (executable validator), and security-bootstrap-console (local console + localhost web UI). +No top-level package manifest exists. The independently runnable surfaces are +documented under `tools/`, and `local-identity` has its own package manifest and +test environment. ### Completeness diff --git a/registry/indexes/capabilities.yaml b/registry/indexes/capabilities.yaml index fc4e35a..ae4c756 100644 --- a/registry/indexes/capabilities.yaml +++ b/registry/indexes/capabilities.yaml @@ -1,12 +1,11 @@ version: 1 -updated: '2026-07-06' +updated: '2026-08-23' domain: helix_forge capabilities: - id: capability.security.iam-tooling-suite name: NetKingdom Security/IAM Tooling Suite - summary: Dynamic, self-optimizing security platform for Kubernetes-deployed IT infrastructure; owns - canonical IAM/security standards and executable conformance tooling (IAM profile conformance, playbook - capability contract validation, security bootstrap console). + summary: Canonical security architecture and executable reference tooling for IAM, bootstrap, posture + validation, and deterministic plan-only security scenario composition. vector: D3 / A2 / C1 / R1 domain: infotech status: draft diff --git a/tools/playbook-capability-contract/README.md b/tools/playbook-capability-contract/README.md index 5c80668..11fb130 100644 --- a/tools/playbook-capability-contract/README.md +++ b/tools/playbook-capability-contract/README.md @@ -13,7 +13,7 @@ python3 tools/playbook-capability-contract/playbook_contract_validator.py \ ../railiance-infra/capabilities/playbooks/railiance-infra.bootstrap-host.yaml ``` -Validate and compose a sample scenario: +Validate and compose the legacy single-provider conformance sample: ```bash python3 tools/playbook-capability-contract/playbook_contract_validator.py \ @@ -21,6 +21,17 @@ python3 tools/playbook-capability-contract/playbook_contract_validator.py \ --scenario examples/playbook-capability-contract/scenario-s1-host-bootstrap.yaml ``` +The `--scenario` path exists for v0.1 conformance compatibility. It refuses +ambiguous providers and unselected overrides, but it does not perform trust +sequencing or build a complete owner handoff. Use the canonical Security +Scenario Composer for deterministic multi-provider plans: + +```bash +python3 tools/security-scenario-composer/security_scenario_composer.py \ + --scenario examples/security-scenarios/c0-local-identity.yaml \ + capabilities/playbooks/net-kingdom.local-identity.yaml +``` + Run tests: ```bash diff --git a/tools/playbook-capability-contract/playbook_contract_validator.py b/tools/playbook-capability-contract/playbook_contract_validator.py index 2c63031..16a3afe 100644 --- a/tools/playbook-capability-contract/playbook_contract_validator.py +++ b/tools/playbook-capability-contract/playbook_contract_validator.py @@ -60,6 +60,9 @@ TRUST_STATES = { "tenant_onboarding_trust", } SCENARIO_AUTHORITIES = {"platform", "netkingdom", "tenant"} +SECRET_REFERENCE_PATTERN = re.compile( + r"(?:[a-z][a-z0-9+.-]*://\S+|(?:/|\./|\.\./)\S+)" +) @dataclass @@ -127,6 +130,10 @@ def is_type(value: Any, declared_type: str) -> bool: return False +def is_secret_reference(value: Any) -> bool: + return isinstance(value, str) and SECRET_REFERENCE_PATTERN.fullmatch(value) is not None + + def validate_constraints(value: Any, param: dict[str, Any], path: str) -> list[Issue]: issues: list[Issue] = [] constraints = param.get("constraints", {}) @@ -277,6 +284,18 @@ def validate_parameters(spec: dict[str, Any]) -> list[Issue]: if item.get("sensitivity") in {"security_sensitive", "secret_reference"} and item.get("tuning_authority") == "tenant_tunable": issues.append(issue("ERROR", path, "security-sensitive parameters cannot be tenant_tunable")) + if item.get("sensitivity") == "secret_reference": + if declared_type != "string": + issues.append(issue("ERROR", f"{path}.type", "secret_reference parameters must use string type")) + if "default" in item and not is_secret_reference(item["default"]): + issues.append( + issue( + "ERROR", + f"{path}.default", + "secret_reference default must be a URI or explicit absolute/relative path", + ) + ) + if "default" in item and declared_type in PARAM_TYPES and not is_type(item["default"], declared_type): issues.append(issue("ERROR", f"{path}.default", f"default does not match type {declared_type!r}")) if declared_type in PARAM_TYPES: @@ -405,6 +424,15 @@ def validate_override_allowed(param: dict[str, Any], value: Any, scenario_author if scenario_authority == "tenant" and sensitivity in {"security_sensitive", "secret_reference"}: issues.append(issue("ERROR", path, f"tenant authority cannot override {sensitivity} parameter {name!r}")) + if sensitivity == "secret_reference" and not is_secret_reference(value): + issues.append( + issue( + "ERROR", + path, + f"override for {name!r} must be a secret-reference URI or explicit absolute/relative path", + ) + ) + if not is_type(value, declared_type): issues.append(issue("ERROR", path, f"override for {name!r} does not match type {declared_type!r}")) issues.extend(validate_constraints(value, param, path)) @@ -433,10 +461,23 @@ def compose_scenario(declarations: list[Declaration], scenario: dict[str, Any]) selected: list[Declaration] = [] for cap_id in required_caps: - matches = [declaration for declaration in declarations if cap_id in declaration.capabilities] + matches = sorted( + [declaration for declaration in declarations if cap_id in declaration.capabilities], + key=lambda declaration: declaration.id, + ) if not matches: issues.append(issue("ERROR", "scenario.requires.capabilities", f"no declaration provides {cap_id!r}")) continue + if len(matches) > 1: + issues.append( + issue( + "ERROR", + "scenario.requires.capabilities", + f"ambiguous providers {[declaration.id for declaration in matches]}; " + "use the Security Scenario Composer to pin one explicitly", + ) + ) + continue selected.append(matches[0]) # Preserve order while deduplicating declarations selected for several capabilities. @@ -444,6 +485,16 @@ def compose_scenario(declarations: list[Declaration], scenario: dict[str, Any]) for declaration in selected: selected_by_id.setdefault(declaration.id, declaration) + for declaration_id in overrides: + if declaration_id not in selected_by_id: + issues.append( + issue( + "ERROR", + f"scenario.parameter_overrides.{declaration_id}", + "override targets an unselected declaration", + ) + ) + composed = { "scenario": scenario.get("id", "scenario:unnamed"), "authority": authority, diff --git a/tools/playbook-capability-contract/tests/test_playbook_contract_validator.py b/tools/playbook-capability-contract/tests/test_playbook_contract_validator.py index 31dde94..901eadf 100644 --- a/tools/playbook-capability-contract/tests/test_playbook_contract_validator.py +++ b/tools/playbook-capability-contract/tests/test_playbook_contract_validator.py @@ -142,6 +142,81 @@ def test_tenant_tunable_secret_reference_fails(tmp_path): assert any("security-sensitive parameters cannot be tenant_tunable" in msg for msg in error_messages(issues)) +def test_plaintext_secret_reference_default_fails(tmp_path): + data = valid_declaration() + data["spec"]["parameters"][2].update( + { + "type": "string", + "default": "plaintext-looking-value", + "sensitivity": "secret_reference", + "tuning_authority": "platform_only", + } + ) + declaration = declaration_from(data, tmp_path) + + issues = validator.validate_declaration(declaration) + + assert any("secret_reference default must be" in msg for msg in error_messages(issues)) + + +def test_secret_reference_uri_override_passes(tmp_path): + data = valid_declaration() + data["spec"]["parameters"][2].update( + { + "name": "credential_ref", + "type": "string", + "default": "openbao://kv/platform/reference", + "sensitivity": "secret_reference", + "tuning_authority": "platform_only", + } + ) + declaration = declaration_from(data, tmp_path) + scenario = { + "id": "scenario:secret-reference", + "authority": "platform", + "requires": {"capabilities": ["s1.os-baseline"]}, + "parameter_overrides": { + "railiance-infra.bootstrap-host": { + "target_hosts": ["railiance01"], + "credential_ref": "kubernetes://platform/reference#token", + } + }, + } + + issues, _ = validator.compose_scenario([declaration], scenario) + + assert error_messages(issues) == [] + + +def test_plaintext_secret_reference_override_fails(tmp_path): + data = valid_declaration() + data["spec"]["parameters"][2].update( + { + "name": "credential_ref", + "type": "string", + "default": "openbao://kv/platform/reference", + "sensitivity": "secret_reference", + "tuning_authority": "platform_only", + } + ) + declaration = declaration_from(data, tmp_path) + scenario = { + "id": "scenario:bad-secret-reference", + "authority": "platform", + "requires": {"capabilities": ["s1.os-baseline"]}, + "parameter_overrides": { + "railiance-infra.bootstrap-host": { + "target_hosts": ["railiance01"], + "credential_ref": "plaintext-looking-value", + } + }, + } + + issues, _ = validator.compose_scenario([declaration], scenario) + + assert any("must be a secret-reference URI" in msg for msg in error_messages(issues)) + + def test_scenario_composition_selects_and_overrides(tmp_path): declaration = declaration_from(valid_declaration(), tmp_path) scenario = { @@ -196,3 +271,36 @@ def test_required_parameter_without_override_fails(tmp_path): issues, _ = validator.compose_scenario([declaration], scenario) assert any("required parameter has no default or override" in msg for msg in error_messages(issues)) + + +def test_demo_composition_refuses_ambiguous_provider(tmp_path): + first = declaration_from(valid_declaration(), tmp_path) + second_data = valid_declaration() + second_data["metadata"]["id"] = "railiance-infra.bootstrap-host-alternative" + second_path = tmp_path / "alternative.yaml" + second_path.write_text(yaml.safe_dump(second_data, sort_keys=False), encoding="utf-8") + second = validator.Declaration(path=second_path, data=second_data) + scenario = { + "id": "scenario:ambiguous-provider", + "authority": "platform", + "requires": {"capabilities": ["s1.os-baseline"]}, + "parameter_overrides": {}, + } + + issues, _ = validator.compose_scenario([second, first], scenario) + + assert any("ambiguous providers" in msg for msg in error_messages(issues)) + + +def test_demo_composition_refuses_unselected_override(tmp_path): + declaration = declaration_from(valid_declaration(), tmp_path) + scenario = { + "id": "scenario:unselected-override", + "authority": "platform", + "requires": {"capabilities": ["s1.os-baseline"]}, + "parameter_overrides": {"owner.unselected": {"target_hosts": ["railiance01"]}}, + } + + issues, _ = validator.compose_scenario([declaration], scenario) + + assert any("override targets an unselected declaration" in msg for msg in error_messages(issues)) diff --git a/tools/security-scenario-composer/README.md b/tools/security-scenario-composer/README.md new file mode 100644 index 0000000..e352556 --- /dev/null +++ b/tools/security-scenario-composer/README.md @@ -0,0 +1,24 @@ +# Security Scenario Composer + +The composer is the executable, plan-only implementation of +`canon/standards/security-scenario-composition_v0.1.md`. It validates +Playbook Capability Contract v0.1 declarations, selects exact providers, +applies authority-bound overrides, orders trust transitions, and emits an +owner-routed JSON handoff. + +It never invokes an entry point and always emits +`execution.permitted: false`. + +Compose the checked-in C0 reference: + +```bash +python3 tools/security-scenario-composer/security_scenario_composer.py \ + --scenario examples/security-scenarios/c0-local-identity.yaml \ + capabilities/playbooks/net-kingdom.local-identity.yaml +``` + +Run tests: + +```bash +python3 -m pytest tools/security-scenario-composer/tests +``` diff --git a/tools/security-scenario-composer/security_scenario_composer.py b/tools/security-scenario-composer/security_scenario_composer.py new file mode 100644 index 0000000..27b8727 --- /dev/null +++ b/tools/security-scenario-composer/security_scenario_composer.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +"""Compose a deterministic, plan-only NetKingdom security scenario.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from typing import Any + +TOOL_DIR = Path(__file__).resolve().parent +CONTRACT_TOOL_DIR = TOOL_DIR.parent / "playbook-capability-contract" +sys.path.insert(0, str(CONTRACT_TOOL_DIR)) + +import playbook_contract_validator as contract # noqa: E402 + + +PLAN_API_VERSION = "netkingdom.io/security-scenario-composition/v0.1" +PLAN_KIND = "SecurityScenarioComposition" +SCENARIO_ID = re.compile(r"^scenario:[a-z0-9][a-z0-9._:-]*$") +SCENARIO_KEYS = { + "id", + "authority", + "initial_trust", + "requires", + "providers", + "parameter_overrides", +} + +Issue = contract.Issue +Declaration = contract.Declaration + + +def error(path: str, message: str) -> Issue: + return Issue("ERROR", path, message) + + +def has_errors(issues: list[Issue]) -> bool: + return any(item.level == "ERROR" for item in issues) + + +def validate_scenario(scenario: dict[str, Any]) -> list[Issue]: + issues: list[Issue] = [] + unknown_keys = sorted(set(scenario) - SCENARIO_KEYS) + if unknown_keys: + issues.append(error("scenario", f"unknown fields: {unknown_keys}")) + + scenario_id = scenario.get("id") + if not isinstance(scenario_id, str) or not SCENARIO_ID.fullmatch(scenario_id): + issues.append(error("scenario.id", "must match 'scenario:'")) + + authority = scenario.get("authority") + if authority not in contract.SCENARIO_AUTHORITIES: + issues.append(error("scenario.authority", f"unknown authority {authority!r}")) + + requires = scenario.get("requires") + if not isinstance(requires, dict): + issues.append(error("scenario.requires", "must be an object")) + required_caps: list[Any] = [] + else: + unknown_requires = sorted(set(requires) - {"capabilities"}) + if unknown_requires: + issues.append(error("scenario.requires", f"unknown fields: {unknown_requires}")) + required_caps = requires.get("capabilities", []) + if not isinstance(required_caps, list) or not required_caps: + issues.append(error("scenario.requires.capabilities", "must be a non-empty list")) + required_caps = [] + + seen_caps: set[str] = set() + for index, cap_id in enumerate(required_caps): + path = f"scenario.requires.capabilities[{index}]" + if cap_id not in contract.CAPABILITIES: + issues.append(error(path, f"unknown capability id {cap_id!r}")) + if cap_id in seen_caps: + issues.append(error(path, f"duplicate capability id {cap_id!r}")) + if isinstance(cap_id, str): + seen_caps.add(cap_id) + + initial_trust = scenario.get("initial_trust", []) + if not isinstance(initial_trust, list): + issues.append(error("scenario.initial_trust", "must be a list")) + initial_trust = [] + seen_trust: set[str] = set() + for index, state in enumerate(initial_trust): + path = f"scenario.initial_trust[{index}]" + if state not in contract.TRUST_STATES: + issues.append(error(path, f"unknown trust state {state!r}")) + if state in seen_trust: + issues.append(error(path, f"duplicate trust state {state!r}")) + if isinstance(state, str): + seen_trust.add(state) + + providers = scenario.get("providers", {}) + if not isinstance(providers, dict): + issues.append(error("scenario.providers", "must be an object")) + providers = {} + for cap_id, declaration_id in providers.items(): + path = f"scenario.providers.{cap_id}" + if cap_id not in seen_caps: + issues.append(error(path, "provider pin targets an unrequested capability")) + if not isinstance(declaration_id, str) or not declaration_id: + issues.append(error(path, "provider declaration id must be a non-empty string")) + + overrides = scenario.get("parameter_overrides", {}) + if not isinstance(overrides, dict): + issues.append(error("scenario.parameter_overrides", "must be an object")) + else: + for declaration_id, values in overrides.items(): + if not isinstance(declaration_id, str) or not declaration_id: + issues.append(error("scenario.parameter_overrides", "declaration ids must be non-empty strings")) + if not isinstance(values, dict): + issues.append(error(f"scenario.parameter_overrides.{declaration_id}", "must be an object")) + + return issues + + +def validate_declarations(declarations: list[Declaration]) -> list[Issue]: + issues: list[Issue] = [] + ids: dict[str, list[str]] = {} + for declaration in declarations: + for item in contract.validate_declaration(declaration): + issues.append( + Issue( + item.level, + f"declarations[{declaration.path}].{item.path}", + item.message, + ) + ) + ids.setdefault(declaration.id, []).append(str(declaration.path)) + + for declaration_id, paths in sorted(ids.items()): + if not declaration_id: + continue + if len(paths) > 1: + issues.append( + error( + "declarations", + f"duplicate declaration id {declaration_id!r}: {sorted(paths)}", + ) + ) + return issues + + +def declaration_trust(declaration: Declaration, section: str) -> list[dict[str, Any]]: + trust = declaration.data.get("spec", {}).get("trust", {}) + values = trust.get(section, []) if isinstance(trust, dict) else [] + return values if isinstance(values, list) else [] + + +def trust_states(declaration: Declaration, section: str) -> set[str]: + return { + str(item.get("state")) + for item in declaration_trust(declaration, section) + if isinstance(item, dict) and item.get("state") + } + + +def stable_digest(scenario: dict[str, Any], selected: dict[str, Declaration]) -> str: + payload = { + "scenario": scenario, + "declarations": { + declaration_id: selected[declaration_id].data + for declaration_id in sorted(selected) + }, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return f"sha256:{hashlib.sha256(encoded).hexdigest()}" + + +def select_declarations( + declarations: list[Declaration], + scenario: dict[str, Any], +) -> tuple[list[Issue], dict[str, Declaration], dict[str, str]]: + issues: list[Issue] = [] + required_caps = scenario["requires"]["capabilities"] + pins = scenario.get("providers", {}) + selected: dict[str, Declaration] = {} + capability_providers: dict[str, str] = {} + + for cap_id in required_caps: + matches = sorted( + (declaration for declaration in declarations if cap_id in declaration.capabilities), + key=lambda declaration: declaration.id, + ) + pin = pins.get(cap_id) + if pin is not None: + pinned = [declaration for declaration in matches if declaration.id == pin] + if not pinned: + available = [declaration.id for declaration in matches] + issues.append( + error( + f"scenario.providers.{cap_id}", + f"pinned declaration {pin!r} does not provide {cap_id!r}; available={available}", + ) + ) + continue + chosen = pinned[0] + elif not matches: + issues.append( + error( + "scenario.requires.capabilities", + f"no declaration provides {cap_id!r}", + ) + ) + continue + elif len(matches) > 1: + issues.append( + error( + f"scenario.providers.{cap_id}", + f"ambiguous providers {[item.id for item in matches]}; pin one explicitly", + ) + ) + continue + else: + chosen = matches[0] + + selected[chosen.id] = chosen + capability_providers[str(cap_id)] = chosen.id + + return issues, selected, capability_providers + + +def effective_parameters( + declaration: Declaration, + overrides: dict[str, Any], + authority: str, +) -> tuple[list[Issue], dict[str, Any]]: + issues: list[Issue] = [] + params_out: dict[str, Any] = {} + for name in sorted(overrides): + if name not in declaration.parameters: + issues.append( + error( + f"scenario.parameter_overrides.{declaration.id}.{name}", + "unknown parameter override", + ) + ) + + for name, param in sorted(declaration.parameters.items()): + overridden, value = contract.effective_parameter_value(param, overrides, declaration.id) + path = f"scenario.parameter_overrides.{declaration.id}.{name}" + if param.get("required") is True and value is None: + issues.append(error(path, "required parameter has no default or override")) + if overridden: + issues.extend(contract.validate_override_allowed(param, value, authority, path)) + params_out[name] = { + "value": value, + "source": "override" if overridden else "default", + "sensitivity": param.get("sensitivity"), + "tuning_authority": param.get("tuning_authority"), + } + return issues, params_out + + +def sequence_declarations( + selected: dict[str, Declaration], + initial_trust: set[str], +) -> tuple[list[Issue], list[Declaration], set[str]]: + issues: list[Issue] = [] + established = set(initial_trust) + remaining = dict(selected) + ordered: list[Declaration] = [] + + while remaining: + eligible = sorted( + ( + declaration + for declaration in remaining.values() + if trust_states(declaration, "requires") <= established + ), + key=lambda declaration: declaration.id, + ) + if not eligible: + for declaration_id, declaration in sorted(remaining.items()): + missing = sorted(trust_states(declaration, "requires") - established) + issues.append( + error( + f"composition.execution_steps.{declaration_id}", + f"cannot sequence declaration; unresolved trust states: {missing}", + ) + ) + break + chosen = eligible[0] + ordered.append(chosen) + established.update(trust_states(chosen, "satisfies")) + del remaining[chosen.id] + + return issues, ordered, established + + +def compose_scenario( + declarations: list[Declaration], + scenario: dict[str, Any], +) -> tuple[list[Issue], dict[str, Any] | None]: + issues = validate_scenario(scenario) + issues.extend(validate_declarations(declarations)) + if has_errors(issues): + return issues, None + + selection_issues, selected, capability_providers = select_declarations(declarations, scenario) + issues.extend(selection_issues) + if has_errors(issues): + return issues, None + + overrides = scenario.get("parameter_overrides", {}) + for declaration_id in sorted(overrides): + if declaration_id not in selected: + issues.append( + error( + f"scenario.parameter_overrides.{declaration_id}", + "override targets an unselected declaration", + ) + ) + + parameters: dict[str, dict[str, Any]] = {} + authority = str(scenario["authority"]) + for declaration_id, declaration in sorted(selected.items()): + param_issues, values = effective_parameters( + declaration, + overrides.get(declaration_id, {}), + authority, + ) + issues.extend(param_issues) + parameters[declaration_id] = values + + sequence_issues, ordered, planned_trust = sequence_declarations( + selected, + set(scenario.get("initial_trust", [])), + ) + issues.extend(sequence_issues) + if has_errors(issues): + return issues, None + + execution_steps: list[dict[str, Any]] = [] + responsibilities: list[dict[str, Any]] = [] + for index, declaration in enumerate(ordered, start=1): + metadata = declaration.data["metadata"] + spec = declaration.data["spec"] + required = declaration_trust(declaration, "requires") + satisfied = declaration_trust(declaration, "satisfies") + readiness = [ + { + "state": item["state"], + "checks": item.get("readiness_checks", []), + } + for item in satisfied + ] + execution_steps.append( + { + "order": index, + "declaration_id": declaration.id, + "executor_owner": metadata["owner"], + "repo": metadata["repo"], + "capabilities": sorted( + cap_id + for cap_id, provider in capability_providers.items() + if provider == declaration.id + ), + "entry_point": spec["playbook"], + "parameters": parameters[declaration.id], + "requires_trust": required, + "satisfies_trust": satisfied, + "readiness_obligations": readiness, + } + ) + for responsibility in spec["responsibilities"]: + responsibilities.append( + { + "declaration_id": declaration.id, + **responsibility, + } + ) + + composition = { + "apiVersion": PLAN_API_VERSION, + "kind": PLAN_KIND, + "scenario": scenario["id"], + "authority": authority, + "composition_digest": stable_digest(scenario, selected), + "requested_capabilities": list(scenario["requires"]["capabilities"]), + "capability_providers": capability_providers, + "initial_trust": sorted(scenario.get("initial_trust", [])), + "planned_final_trust": sorted(planned_trust), + "execution": { + "mode": "plan-only", + "permitted": False, + "reason": "Composition does not authorize or perform provider execution.", + }, + "execution_steps": execution_steps, + "responsibility_map": responsibilities, + } + return issues, composition + + +def load_declarations(paths: list[str]) -> tuple[list[Declaration], list[Issue]]: + declarations: list[Declaration] = [] + issues: list[Issue] = [] + for raw_path in paths: + path = Path(raw_path) + try: + data = contract.load_yaml(path) + except Exception as exc: + issues.append(error(f"declarations[{path}]", f"failed to load: {exc}")) + continue + declarations.append(Declaration(path=path, data=data)) + return declarations, issues + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Compose a deterministic, non-executing NetKingdom security scenario." + ) + parser.add_argument("declarations", nargs="+", help="Playbook capability declaration YAML files") + parser.add_argument("--scenario", required=True, help="Security scenario YAML file") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + declarations, issues = load_declarations(args.declarations) + try: + scenario = contract.load_yaml(Path(args.scenario)) + except Exception as exc: + issues.append(error("scenario", f"failed to load {args.scenario}: {exc}")) + scenario = {} + + composition: dict[str, Any] | None = None + if not has_errors(issues): + composition_issues, composition = compose_scenario(declarations, scenario) + issues.extend(composition_issues) + + payload = { + "ok": not has_errors(issues), + "issues": [item.__dict__ for item in issues], + "composition": composition, + } + print(json.dumps(payload, indent=2, sort_keys=True)) + return 1 if has_errors(issues) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/security-scenario-composer/tests/test_security_scenario_composer.py b/tools/security-scenario-composer/tests/test_security_scenario_composer.py new file mode 100644 index 0000000..9712731 --- /dev/null +++ b/tools/security-scenario-composer/tests/test_security_scenario_composer.py @@ -0,0 +1,266 @@ +import copy +import importlib.util +import sys +from pathlib import Path + +import yaml + + +TOOL_DIR = Path(__file__).resolve().parents[1] +TOOL_PATH = TOOL_DIR / "security_scenario_composer.py" +sys.path.insert(0, str(TOOL_DIR)) +SPEC = importlib.util.spec_from_file_location("security_scenario_composer", TOOL_PATH) +composer = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = composer +SPEC.loader.exec_module(composer) + + +def declaration_data( + declaration_id="owner.c0", + capability="c0.bootstrap-identity", + tier="C0", + requires=None, + satisfies=None, +): + requires = ["bare_host_trust"] if requires is None else requires + satisfies = ["bootstrap_identity_trust"] if satisfies is None else satisfies + return { + "apiVersion": composer.contract.API_VERSION, + "kind": composer.contract.KIND, + "metadata": { + "id": declaration_id, + "name": declaration_id, + "owner": "owner", + "repo": "owner-repo", + "domain": "infotech", + "contract_version": "0.1", + }, + "spec": { + "playbook": { + "path": "playbooks/reference.yaml", + "type": "reference", + "invocation": "make reference", + "description": "Reference entry point.", + }, + "capabilities": [ + { + "id": capability, + "tier": tier, + "resource_kinds": ["identities"], + "description": "Reference capability.", + } + ], + "parameters": [ + { + "name": "target", + "type": "string", + "required": True, + "default": "reference", + "sensitivity": "operational", + "tuning_authority": "netkingdom_tunable", + "description": "Reference target.", + }, + { + "name": "secure_mode", + "type": "boolean", + "required": False, + "default": True, + "sensitivity": "security_sensitive", + "tuning_authority": "platform_only", + "description": "Security-sensitive reference switch.", + }, + ], + "responsibilities": [ + { + "resource_kind": "identities", + "owner": "owner", + "resources": ["identity:reference"], + "repo_owns": "Reference execution.", + "netkingdom_orchestrates": "Reference selection.", + } + ], + "trust": { + "requires": [ + {"state": state, "readiness_checks": []} + for state in requires + ], + "satisfies": [ + { + "state": state, + "readiness_checks": [ + { + "id": f"{state}-ready", + "description": f"{state} is ready.", + "evidence": "reference evidence", + } + ], + } + for state in satisfies + ], + }, + "catalog": { + "publish": f"capabilities/playbooks/{declaration_id}.yaml", + "maturity": "reference", + "consumers": ["netkingdom-security-scenario-composer"], + }, + }, + } + + +def declaration(tmp_path, data, filename="declaration.yaml"): + path = tmp_path / filename + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + return composer.Declaration(path=path, data=data) + + +def scenario(capabilities=None): + capabilities = ["c0.bootstrap-identity"] if capabilities is None else capabilities + return { + "id": "scenario:test", + "authority": "netkingdom", + "initial_trust": ["bare_host_trust"], + "requires": {"capabilities": capabilities}, + "providers": {}, + "parameter_overrides": {}, + } + + +def messages(issues): + return [item.message for item in issues if item.level == "ERROR"] + + +def test_single_provider_emits_plan_only_handoff(tmp_path): + item = declaration(tmp_path, declaration_data()) + + issues, plan = composer.compose_scenario([item], scenario()) + + assert messages(issues) == [] + assert plan["execution"] == { + "mode": "plan-only", + "permitted": False, + "reason": "Composition does not authorize or perform provider execution.", + } + assert plan["execution_steps"][0]["executor_owner"] == "owner" + assert plan["execution_steps"][0]["readiness_obligations"][0]["checks"] + assert plan["responsibility_map"][0]["declaration_id"] == "owner.c0" + assert plan["composition_digest"].startswith("sha256:") + + +def test_ambiguous_provider_requires_pin(tmp_path): + first = declaration(tmp_path, declaration_data("owner.c0-a"), "a.yaml") + second = declaration(tmp_path, declaration_data("owner.c0-b"), "b.yaml") + + issues, plan = composer.compose_scenario([second, first], scenario()) + + assert plan is None + assert any("ambiguous providers" in message for message in messages(issues)) + + +def test_explicit_provider_pin_resolves_ambiguity(tmp_path): + first = declaration(tmp_path, declaration_data("owner.c0-a"), "a.yaml") + second = declaration(tmp_path, declaration_data("owner.c0-b"), "b.yaml") + request = scenario() + request["providers"] = {"c0.bootstrap-identity": "owner.c0-b"} + + issues, plan = composer.compose_scenario([second, first], request) + + assert messages(issues) == [] + assert plan["capability_providers"] == {"c0.bootstrap-identity": "owner.c0-b"} + + +def test_bad_provider_pin_fails_closed(tmp_path): + item = declaration(tmp_path, declaration_data()) + request = scenario() + request["providers"] = {"c0.bootstrap-identity": "owner.missing"} + + issues, plan = composer.compose_scenario([item], request) + + assert plan is None + assert any("does not provide" in message for message in messages(issues)) + + +def test_override_for_unselected_declaration_fails(tmp_path): + item = declaration(tmp_path, declaration_data()) + request = scenario() + request["parameter_overrides"] = {"owner.other": {"target": "wrong"}} + + issues, plan = composer.compose_scenario([item], request) + + assert plan is None + assert "override targets an unselected declaration" in messages(issues) + + +def test_tenant_cannot_override_platform_only_parameter(tmp_path): + item = declaration(tmp_path, declaration_data()) + request = scenario() + request["authority"] = "tenant" + request["parameter_overrides"] = {"owner.c0": {"secure_mode": False}} + + issues, plan = composer.compose_scenario([item], request) + + assert plan is None + assert any("tenant authority cannot override" in message for message in messages(issues)) + + +def test_trust_dependencies_override_request_order(tmp_path): + c0 = declaration(tmp_path, declaration_data(), "c0.yaml") + c1_data = declaration_data( + "owner.c1", + "c1.lightweight-sso", + "C1", + requires=["bootstrap_identity_trust"], + satisfies=["runtime_identity_trust"], + ) + c1 = declaration(tmp_path, c1_data, "c1.yaml") + request = scenario(["c1.lightweight-sso", "c0.bootstrap-identity"]) + + issues, plan = composer.compose_scenario([c1, c0], request) + + assert messages(issues) == [] + assert [step["declaration_id"] for step in plan["execution_steps"]] == [ + "owner.c0", + "owner.c1", + ] + assert "runtime_identity_trust" in plan["planned_final_trust"] + + +def test_unresolved_trust_fails_closed(tmp_path): + item = declaration(tmp_path, declaration_data()) + request = scenario() + request["initial_trust"] = [] + + issues, plan = composer.compose_scenario([item], request) + + assert plan is None + assert any("unresolved trust states" in message for message in messages(issues)) + + +def test_duplicate_declaration_ids_fail_closed(tmp_path): + data = declaration_data() + first = declaration(tmp_path, data, "a.yaml") + second = declaration(tmp_path, copy.deepcopy(data), "b.yaml") + + issues, plan = composer.compose_scenario([first, second], scenario()) + + assert plan is None + assert any("duplicate declaration id" in message for message in messages(issues)) + + +def test_checked_in_c0_scenario_composes(): + repo_root = Path(__file__).resolve().parents[3] + declaration_path = repo_root / "capabilities/playbooks/net-kingdom.local-identity.yaml" + scenario_path = repo_root / "examples/security-scenarios/c0-local-identity.yaml" + item = composer.Declaration( + path=declaration_path, + data=composer.contract.load_yaml(declaration_path), + ) + request = composer.contract.load_yaml(scenario_path) + + issues, plan = composer.compose_scenario([item], request) + + assert messages(issues) == [] + assert plan["capability_providers"] == { + "c0.bootstrap-identity": "net-kingdom.local-identity" + } + assert plan["execution_steps"][0]["repo"] == "net-kingdom" diff --git a/workplans/NK-WP-0030-deterministic-security-scenario-composition.md b/workplans/NK-WP-0030-deterministic-security-scenario-composition.md new file mode 100644 index 0000000..5758a0c --- /dev/null +++ b/workplans/NK-WP-0030-deterministic-security-scenario-composition.md @@ -0,0 +1,93 @@ +--- +id: NK-WP-0030 +type: workplan +title: "Implement deterministic security scenario composition" +domain: infotech +repo: net-kingdom +status: blocked +owner: codex +topic_slug: netkingdom +planning_priority: P1 +created: "2026-08-23" +updated: "2026-08-23" +--- + +# NK-WP-0030 — Deterministic security scenario composition + +Authorized by the operator as the first implementation increment following the +2026-08-23 scope-to-intent gap assessment. This work closes the locally owned +part of G1 without moving playbook execution into NetKingdom. + +## Define the scenario composition contract + +```task +id: NK-WP-0030-T01 +status: done +priority: high +``` + +Published proposed `security-scenario-composition_v0.1` canon and its scenario +schema. It requires exact provider pins under ambiguity, explicit initial trust, +fail-closed sequencing, owner attribution, readiness obligations, and an +unconditional non-execution boundary. + +## Implement the canonical composer + +```task +id: NK-WP-0030-T02 +status: done +priority: high +``` + +Implemented `tools/security-scenario-composer/`. It consumes Playbook Capability +Contract v0.1 declarations, rejects invalid or ambiguous inputs, validates +authority-bound parameter overrides, orders selected declarations by explicit +trust transitions, and emits a deterministic owner-routed JSON plan with a +content digest and `execution.permitted: false`. The older conformance-demo +selector now also refuses ambiguity and overrides targeting unselected +declarations. + +## Publish a C0 reference composition + +```task +id: NK-WP-0030-T03 +status: done +priority: high +``` + +Published `capabilities/playbooks/net-kingdom.local-identity.yaml` and +`examples/security-scenarios/c0-local-identity.yaml`. The non-secret reference +selects the exact local provider, starts from explicit `bare_host_trust`, and +plans the readiness obligations for `bootstrap_identity_trust` without +executing the CLI entry point. + +## Obtain lightweight-provider declarations + +```task +id: NK-WP-0030-T04 +status: wait +priority: high +``` + +Ask the KeyCape/Railiance owners to publish conformant C1 and selected C2 +declarations beside their execution entry points. Do not fabricate provider +authority in NetKingdom. The complete lightweight SSO scenario remains blocked +until those declarations exist and pass the canonical composer. Routed to +`key-cape` in State Hub message `64d247e4-9170-4d18-b7ed-d8b071282104`. + +## Verify the fail-closed and reference paths + +```task +id: NK-WP-0030-T05 +status: done +priority: medium +``` + +Added ten composer tests and five regression tests for the legacy declaration +validator/demo selector, including secret-reference syntax enforcement. +Exact selection, ambiguity refusal, bad pins, override boundaries, duplicate +ids, dependency ordering, unresolved trust, responsibility/readiness mapping, +and the checked-in C0 composition pass. The complete relevant repository suite +passes 70 tests; the isolated local-identity suite passes 142 tests. JSON/YAML +parsing, Python compilation, the reference CLI, and `git diff --check` also +pass.