From 1920b134e2b577e74aaac6b59db5fd785f505a3d Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 17 Oct 2026 11:25:35 +0200 Subject: [PATCH] NK-WP-0048-T16: propose 1.3 successor type with the execution_descriptors map Source-only candidate. The real-kernel admission test shows the remaining gate is an ops-warden 1.3 parameter pin plus acknowledgment. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 903097@bnt-lap001 Assistant-Session: aa320c8f-c3fa-46e7-8541-0b58e1e45d23 --- .../1.3/oidc-browser-relying-party/README.md | 89 ++++ .../examples/example-public-portal.yaml | 20 + .../examples/forgejo.yaml | 21 + .../tests/test_source_contract.py | 88 ++++ .../1.3/oidc-browser-relying-party/type.yaml | 452 ++++++++++++++++++ .../descriptor-composition-1.3.candidate.json | 254 ++++++++++ sso-mfa/k8s/keycape/test_admit_1_3_type.py | 140 ++++++ .../keycape/test_compose_1_2_descriptors.py | 11 +- workplans/NK-WP-0048-security-case-types.md | 13 + 9 files changed, 1082 insertions(+), 6 deletions(-) create mode 100644 security-case-type-revisions/1.3/oidc-browser-relying-party/README.md create mode 100644 security-case-type-revisions/1.3/oidc-browser-relying-party/examples/example-public-portal.yaml create mode 100644 security-case-type-revisions/1.3/oidc-browser-relying-party/examples/forgejo.yaml create mode 100644 security-case-type-revisions/1.3/oidc-browser-relying-party/tests/test_source_contract.py create mode 100644 security-case-type-revisions/1.3/oidc-browser-relying-party/type.yaml create mode 100644 sso-mfa/k8s/keycape/descriptor-composition-1.3.candidate.json create mode 100644 sso-mfa/k8s/keycape/test_admit_1_3_type.py diff --git a/security-case-type-revisions/1.3/oidc-browser-relying-party/README.md b/security-case-type-revisions/1.3/oidc-browser-relying-party/README.md new file mode 100644 index 0000000..d811922 --- /dev/null +++ b/security-case-type-revisions/1.3/oidc-browser-relying-party/README.md @@ -0,0 +1,89 @@ +# Proposed OIDC browser relying-party 1.3 successor candidate + +Status: **proposed**. This is a source candidate for NK-WP-0048-T16. It is not +acknowledged, not implemented, and grants no execution or native authority. The +acknowledged 1.2 package (`../../1.2/oidc-browser-relying-party/`) and canonical 1.1 +stay unchanged, and 1.2's acknowledgment is not inherited. + +## Purpose + +The purpose of the type is 1.2's: register exact-redirect browser OIDC relying +parties through KeyCape, with groups enforced by the consumer. 1.3 changes only two +things: + +1. `version: '1.3'` and `status: proposed`, with the examples pinned to `@1.3`. +2. A new `execution_descriptors` map naming the reviewed machine descriptor digest + for each of the seven follow-through steps. Warden's `Executor.admit` refuses any + step whose descriptor digest is not in the type's map, so this map is what would + let Warden admit the steps at all. It is generated by + `sso-mfa/k8s/keycape/compose-1.2-descriptors.py --version 1.3`, and + `test_admit_1_3_type.py` checks that it equals the composed set. + +Every other byte of the type (parameters, composition, materialization, +follow-through, verification, rollback, acceptance) is 1.2's. The parameter set is +identical, so its fingerprint equals 1.2's. + +Canon `security-case-types_v0.1.md` section 6 treats a changed trust transition as +major. Allowing Warden to admit these steps is one, so 1.3 needs its own Decision +Memo and informed-decision acknowledgment by the founder, the same as a new type. + +## Threat model + +1.2's threat model applies unchanged. 1.3 adds one transition: Warden may admit a +step and dispatch its owner-supplied adapter, which runs injected owner +capabilities. Each descriptor pins its adapter's exact source closure, lane, actor, +effect, allowed created objects and revocation scopes, and admission still requires +authenticated case acceptance, ops-mason materialization, owner descriptor +acceptance, type machine-seam acceptance, catalog routing acceptance and the +caller's session. A mint is admitted only for a human caller. The adapters record +value-free evidence and roll back only objects they created. None of that removes +the need for owners to verify native bindings. + +## Alternatives considered + +- Keep 1.2 as plan-only, with no `execution_descriptors`. Every step stays manual, + and the reviewed adapters are never admitted. +- List only a subset of steps in the map, for example only the Warden-executed and + attended ones. That is open question 3 below. +- Change the kernel to read descriptors from outside the type. This is rejected: + the kernel binds the map to the acknowledged type bytes on purpose. + +## Known limits + +- The adapter set was independently reviewed at net-kingdom `ff8f597` + (the-custodian `docs/changes/netkingdom-target-2026-10-17/nk48-adapters-1.2-review/`, + r1–r3, all nine files approved in r3). Making ensure-groups, hand-over, + deliver-consumer-secret and the composer accept 1.3 changed their source digests, + so those descriptors need a further source review (r4) before this map can be + relied on. +- `test_admit_1_3_type.py` runs Warden's real `Executor.admit` (ops-warden `a45cf47`) + with fixture-only trust. The proposed type is refused. An acknowledged copy is + still refused, because Warden pins parameter fingerprints only for 1.1 and 1.2. + With only a 1.3 pin added in memory, all seven descriptors are admitted, and a + descriptor outside the map is refused. +- All owner capabilities in the tests are stand-ins, and no native binding is + proven. + +Open questions for owner review: + +1. **ops-warden:** add `"1.3"` to `warden/case_inputs.py` `PARAMETER_PINS` with 1.2's + fingerprint. Without it, no 1.3 case is admitted. +2. **mint-secret semantics:** the type says `warden_executes: true, attended: false`, + while the descriptor actor is `human` (founder decision 2026-10-17), and the kernel + refuses a non-human mint. Owners decide whether these are consistent (a human + caller starts an unattended Warden-executed mint) or whether the step should + become `attended: true, warden_executes: false` in this revision. +3. **Owner-run steps with descriptors:** deliver-secret, deliver-consumer-secret and + hand-over are `warden_executes: false, attended: false` (owner path), and + register-client, ensure-groups and bind-env are attended, yet all carry execution + descriptors. The kernel never reads `warden_executes`; only the plan does. Owners + decide whether Warden should admit these steps or whether the map should list only + a subset. + +## Verification + +```sh +python3 tools/security-case-types/security_case_validator.py validate-type security-case-type-revisions/1.3/oidc-browser-relying-party +NET_KINGDOM_ROOT=$PWD python3 -m pytest security-case-type-revisions/1.3/oidc-browser-relying-party/tests +cd sso-mfa/k8s/keycape && PYTHONPATH=$HOME/ops-warden/src ~/ops-warden/.venv/bin/python -m pytest -q test_admit_1_3_type.py +``` diff --git a/security-case-type-revisions/1.3/oidc-browser-relying-party/examples/example-public-portal.yaml b/security-case-type-revisions/1.3/oidc-browser-relying-party/examples/example-public-portal.yaml new file mode 100644 index 0000000..517a9da --- /dev/null +++ b/security-case-type-revisions/1.3/oidc-browser-relying-party/examples/example-public-portal.yaml @@ -0,0 +1,20 @@ +apiVersion: netkingdom.io/security-case/v0.1 +kind: SecurityCase +spec: + id: example-public-portal + type: oidc-browser-relying-party@1.3 + status: proposed + owner: + repo: net-kingdom + person: example-owner + parameters: + client_id: example-portal + display_name: Example portal + redirect_uris: https://portal.example.invalid/auth/callback + allowed_scopes: openid,profile,email,groups + client_type: public + mfa_required: 'false' + access_groups: example-portal-users + consuming_service: example-portal + consumer_owner_repo: net-kingdom + consumer_namespace: example-portal diff --git a/security-case-type-revisions/1.3/oidc-browser-relying-party/examples/forgejo.yaml b/security-case-type-revisions/1.3/oidc-browser-relying-party/examples/forgejo.yaml new file mode 100644 index 0000000..a93715d --- /dev/null +++ b/security-case-type-revisions/1.3/oidc-browser-relying-party/examples/forgejo.yaml @@ -0,0 +1,21 @@ +apiVersion: netkingdom.io/security-case/v0.1 +kind: SecurityCase +spec: + id: forgejo-netkingdom-oidc + type: oidc-browser-relying-party@1.3 + status: proposed + owner: + repo: railiance-apps + person: founder + parameters: + client_id: forgejo + display_name: Forgejo source forge + redirect_uris: https://forgejo.coulomb.social/user/oauth2/netkingdom/callback + allowed_scopes: openid,profile,email,groups + client_type: confidential + mfa_required: 'true' + access_groups: forge-admins,forge-coulomb-owners,forge-coulomb-readers + initial_members: forge-admins=tegwick;forge-coulomb-owners=tegwick + consuming_service: forgejo + consumer_owner_repo: railiance-apps + consumer_namespace: forgejo diff --git a/security-case-type-revisions/1.3/oidc-browser-relying-party/tests/test_source_contract.py b/security-case-type-revisions/1.3/oidc-browser-relying-party/tests/test_source_contract.py new file mode 100644 index 0000000..d00fcf4 --- /dev/null +++ b/security-case-type-revisions/1.3/oidc-browser-relying-party/tests/test_source_contract.py @@ -0,0 +1,88 @@ +"""Source-only contract checks against the actual owner validator and declarations.""" +import copy, importlib.util, os, sys +from pathlib import Path +import pytest, yaml +PACKAGE=Path(__file__).resolve().parents[1] +OWNER=Path(os.environ.get("NET_KINGDOM_ROOT", "/home/worsch/net-kingdom")) +VP=OWNER/"tools/security-case-types/security_case_validator.py" +sys.path.insert(0,str(VP.parent)) +spec=importlib.util.spec_from_file_location("security_case_validator",VP) +v=importlib.util.module_from_spec(spec);sys.modules[spec.name]=v;spec.loader.exec_module(v) +TYPE=yaml.safe_load((PACKAGE/"type.yaml").read_text()) +FORGEJO=yaml.safe_load((PACKAGE/"examples/forgejo.yaml").read_text()) +paths,discovery=v.discover_declarations(TYPE,OWNER.parent) +DECLS,loading=v.composer.load_declarations(paths) +def render(case): + issues,result=v.render_case(case,TYPE,DECLS,require_implemented=False) + assert not v.has_errors(discovery+loading+issues) + return result + +def test_full_package_and_proposed_boundary(): + assert not v.has_errors(v.validate_type(TYPE,PACKAGE)) + assert TYPE["spec"]["status"]=="proposed" # 1.3 is a successor candidate; 1.2's acknowledgment is not inherited + assert FORGEJO["spec"]["status"]=="proposed" + assert "acceptance" not in FORGEJO["spec"] + assert v.has_errors(v.validate_case(FORGEJO,TYPE)) + +def test_acknowledged_is_still_not_implemented_admission(): + acknowledged=copy.deepcopy(TYPE);acknowledged["spec"]["status"]="acknowledged" + assert v.has_errors(v.validate_case(FORGEJO,acknowledged)) + +@pytest.mark.parametrize("example",sorted((PACKAGE/"examples").glob("*.yaml"))) +def test_examples_valid_deterministic_and_nonexecuting(example): + case=yaml.safe_load(example.read_text()) + assert not v.has_errors(v.validate_case(case,TYPE,require_implemented=False)) + first=render(case);assert first==render(case) + assert first["execution"]["permitted"] is False + assert first["composition"]["execution"]["permitted"] is False + +def test_sixth_role_is_exact_consumer_identity_and_namespace(): + case=copy.deepcopy(FORGEJO);case["spec"]["parameters"]["consumer_namespace"]="forgejo-preview" + r=render(case);objects={x["id"]:x for x in r["materialization"]} + assert len(objects)==6 + consumer=yaml.safe_load(objects["consumer-eso-role"]["body"]) + assert consumer["bound_service_account_names"]==["forgejo-keycape-forgejo-eso"] + assert consumer["bound_service_account_namespaces"]==["forgejo-preview"] + assert consumer["audience"]=="openbao" + assert consumer["token_policies"]==["workload-kv-read-keycape-forgejo-forgejo"] + assert consumer["token_no_default_policy"] is True + provider=yaml.safe_load(objects["eso-role"]["body"]) + assert provider["bound_service_account_names"]!=consumer["bound_service_account_names"] + +def test_original_five_structural_bodies_unchanged(): + old=yaml.safe_load((OWNER/"security-case-types/oidc-browser-relying-party/type.yaml").read_text()) + case=yaml.safe_load((OWNER/"security-cases/forgejo-netkingdom-oidc.yaml").read_text()) + issues,before=v.render_case(case,old,DECLS,require_implemented=False) + assert not v.has_errors(issues) + after={x["id"]:x["body"] for x in render(FORGEJO)["materialization"]} + for item in before["materialization"]:assert item["body"]==after[item["id"]] + +def test_public_has_no_secret_or_delivery_hooks(): + r=render(yaml.safe_load((PACKAGE/"examples/example-public-portal.yaml").read_text())) + assert [x["id"] for x in r["materialization"]]==["catalog-entry"] + assert not {"mint-secret","deliver-secret","deliver-consumer-secret","bind-env"}&{x["id"] for x in r["follow_through"]} + +@pytest.mark.parametrize("key,value",[("consumer_namespace",None),("consumer_namespace","default/other"),("consumer_namespace","x"*64),("consuming_service","x"*64),("redirect_uris","http://portal.invalid/callback"),("redirect_uris","https://portal.invalid/*"),("redirect_uris","https://portal.invalid/callback?x=y"),("redirect_uris","https://portal.invalid/callback#x"),("allowed_scopes","openid,email"),("consumer_reader_service_account","default"),("secret_path","other/path")]) +def test_invalid_or_unsettable_parameter_refused(key,value): + case=copy.deepcopy(FORGEJO) + if value is None:case["spec"]["parameters"].pop(key) + else:case["spec"]["parameters"][key]=value + assert v.has_errors(v.validate_case(case,TYPE,require_implemented=False)) + +def test_created_only_role_recovery_precedes_policy_and_needs_revocation(): + rollback=render(FORGEJO)["rollback"];ids=[x["reverses"] for x in rollback] + assert ids.index("consumer-eso-role") + --case-acceptance --check-config (net-kingdom.keycape-client-registration) + auth: operator write access to the live KeyCape client registry in namespace sso + lane: key-cape-oidc-login + warden_executes: false + attended: true + value_handling: none + attended_justification: 'The live KeyCape client registry is a Kubernetes Secret, + and agents do not read or write Secrets. Until KeyCape loads clients from a + non-secret registry with secrets referenced from custody, one attended operator + session applies the rendered registration. ops-warden groups all attended steps + of a run into that one session. KeyCape loads clients at startup and keeps login, + authorization and handoff state in memory, so the restart drops in-flight logins + and sessions for every client; runs go in a quiet window (key-cape type review + R4). The rendered registry must pass KeyCape''s own startup checks (keycape + check-config, key-cape c528daa, which keycape-client.py runs before it patches) + before the restart: a refused entry stops the single replica and every client + loses sign-in (R1).' + - id: deliver-consumer-secret + owner: '{consumer_owner_repo}' + when: client_type == confidential + entry_point: Owner applies dedicated ServiceAccount {consumer_namespace}/{consuming_service}-keycape-{client_id}-eso + (automount=false, no legacy tokens), namespace SecretStore openbao-keycape-{client_id}-consumer + with serviceAccountRef and audiences [openbao], and ExternalSecret {consuming_service}-netkingdom-oidc-client + with Owner target, Retain remote deletion and only CLIENT_SECRET from workloads/keycape/{client_id}. + Admission validates actual installed ESO TokenRequest support and common controller + RBAC before any owner-approved apply. + auth: Consumer owner contained Kubernetes deployment authority; ESO authenticates + to OpenBao role external-secrets-keycape-{client_id}-{consuming_service} with + a TokenRequest for the dedicated namespaced serviceAccountRef; controller remains + trusted + warden_executes: false + attended: false + value_handling: none + ready_check: Namespace SecretStore and ExternalSecret both report Ready=True; + dedicated SA has no legacy token refs; status and fixed metadata only, never + Secret values. + - id: hand-over + owner: '{consumer_owner_repo}' + entry_point: configure {consuming_service} with issuer {issuer}, client {client_id} + and custody bao:platform/workloads/keycape/{client_id}, and make it require + one of {access_groups} in the groups claim before sign-in completes + auth: the consumer owner's own deployment path + warden_executes: false + attended: false + value_handling: none + verification: + - id: authorize-accepts-redirects + check: GET {issuer}/authorize with client {client_id} and each of {redirect_uris} + returns the login page + expect: present + reads_secret_value: false + - id: authorize-refuses-other-redirect + check: GET {issuer}/authorize with client {client_id} and an unregistered redirect + returns an OAuth error + expect: deny + reads_secret_value: false + - id: custody-denied-to-default + when: client_type == confidential + check: a default-only OpenBao identity has no read capability on bao:platform/workloads/keycape/{client_id} + expect: deny + reads_secret_value: false + - id: custody-allowed-to-consumer + when: client_type == confidential + check: policy workload-kv-read-keycape-{client_id}-{consuming_service} grants + read on bao:platform/workloads/keycape/{client_id} (capabilities check only) + expect: allow + reads_secret_value: false + - id: groups-present + check: LLDAP groups {access_groups} exist, with exactly the memberships of the + accepted case added + expect: present + reads_secret_value: false + - id: consumer-enforces-groups + check: '{consuming_service}''s OIDC source for {client_id} (owner {consumer_owner_repo}) + requires one of {access_groups} in the groups claim (configuration read, no + secret value)' + expect: present + reads_secret_value: false + - id: secret-delivered + when: client_type == confidential + check: the delivery for sso/keycape-{client_id}-client reports Ready=True (status + only) + expect: present + reads_secret_value: false + - id: mfa-pinned + when: mfa_required == true + check: keycape-client.py inspect for {client_id} shows the MFA pin on the registered + client (non-secret client view) + expect: present + reads_secret_value: false + - id: consumer-secret-delivered + when: client_type == confidential + check: '{consumer_namespace} SecretStore openbao-keycape-{client_id}-consumer + and ExternalSecret {consuming_service}-netkingdom-oidc-client Ready=True, exact + dedicated SA/audience role binding, fixed CLIENT_SECRET mapping; no Secret value + read' + expect: present + reads_secret_value: false + rollback: + - reverses: hand-over + action: remove the OIDC source for {client_id} from {consuming_service} (owner + {consumer_owner_repo}) + - reverses: deliver-consumer-secret + when: client_type == confidential + action: After reverting consumer secret reference, delete only authenticated created + ExternalSecret/owned target by UID and ownerReference, then SecretStore and + dedicated SA; preserve preexisting-matched resources and never delete a shared + target. Retain audit and ownership receipts; removing SecretStore/SA alone is + not immediate OpenBao token revocation. + - reverses: register-client + action: keycape-client.py apply --operation remove for {client_id} + - reverses: bind-env + when: client_type == confidential + action: remove env KEYCAPE_{client_id:env}_CLIENT_SECRET from Deployment sso/keycape, + after register-client is reversed + - reverses: deliver-secret + when: client_type == confidential + action: delete ExternalSecret sso/keycape-{client_id}-client and its target + - reverses: mint-secret + when: client_type == confidential + action: 'covered by client-secret-path: one metadata delete, after deliver-secret + is reversed' + - reverses: ensure-groups + action: identity-provisioner revoke_service_groups inside the provisioner pod + (attended, as ensure-groups) with the case's memberships; delete_if_empty only + if no other case uses {access_groups} + - reverses: client-secret-path + when: client_type == confidential + action: delete metadata and all versions at platform/workloads/keycape/{client_id}, + after the ExternalSecret is gone (this also reverses mint-secret) + - reverses: eso-role + when: client_type == confidential + action: delete Kubernetes-auth role external-secrets-keycape-{client_id} + - reverses: keycape-reader-policy + when: client_type == confidential + action: delete policy workload-kv-read-keycape-{client_id}-eso, after eso-role + - reverses: consumer-eso-role + when: client_type == confidential + action: Delete only this case-created role external-secrets-keycape-{client_id}-{consuming_service}, + before deleting consumer-reader-policy; revoke case-issued OpenBao tokens or + prove expiration before declaring deactivation. + - reverses: consumer-reader-policy + when: client_type == confidential + action: delete policy workload-kv-read-keycape-{client_id}-{consuming_service} + - reverses: catalog-entry + action: remove catalog entry {consuming_service}-keycape-client + acceptance: + accepted_by: + - owner + - founder + risk_class: medium + # Reviewed machine descriptors (NK-WP-0048-T16 successor candidate). Digests are + # generated by sso-mfa/k8s/keycape/compose-1.2-descriptors.py --version 1.3. + execution_descriptors: + bind-env: sha256:d2511dce3d2ff1d380f845920aa168e6fe09c0f0c382482a796a5bf13619905d + deliver-consumer-secret: sha256:9c289f646516923a4c448a2d3cdd45b33115516e6a171b7955924d8b5d852d2b + deliver-secret: sha256:75aaa7346aebdea2a0eb0ba993995515b5248ffefc34caf8e713525e9b58da85 + ensure-groups: sha256:4411b5cb8944ee76ca1ade62c0c246f061981fb18f0b9afd05934a8935598dc3 + hand-over: sha256:84f0fac401ea2f1e7c305fb1753a34838ac062655199fcfbbd998f39268c3000 + mint-secret: sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70 + register-client: sha256:3ccc8eed545cf31f8a48feb8afa0c1355e126d9549eef9737d92d0c1b87708a8 diff --git a/sso-mfa/k8s/keycape/descriptor-composition-1.3.candidate.json b/sso-mfa/k8s/keycape/descriptor-composition-1.3.candidate.json new file mode 100644 index 0000000..2aae558 --- /dev/null +++ b/sso-mfa/k8s/keycape/descriptor-composition-1.3.candidate.json @@ -0,0 +1,254 @@ +{ + "admitted": false, + "canonical_default": { + "type": "oidc-browser-relying-party@1.1", + "type_yaml_digest": "sha256:f705c64e4875e9b8e18a912fb51c135e77898926d6a451f5cb74ab3c0cbe4d1c", + "unchanged": true + }, + "check_config_digest": "sha256:be37738fe1c08259fb4f9ca0daf0f9301bfffc237e5bb38435525dfbf11ccff5", + "complete_executable_descriptor_set": true, + "dispatch_permitted": false, + "execution_descriptors": { + "bind-env": "sha256:d2511dce3d2ff1d380f845920aa168e6fe09c0f0c382482a796a5bf13619905d", + "deliver-consumer-secret": "sha256:9c289f646516923a4c448a2d3cdd45b33115516e6a171b7955924d8b5d852d2b", + "deliver-secret": "sha256:75aaa7346aebdea2a0eb0ba993995515b5248ffefc34caf8e713525e9b58da85", + "ensure-groups": "sha256:4411b5cb8944ee76ca1ade62c0c246f061981fb18f0b9afd05934a8935598dc3", + "hand-over": "sha256:84f0fac401ea2f1e7c305fb1753a34838ac062655199fcfbbd998f39268c3000", + "mint-secret": "sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70", + "register-client": "sha256:3ccc8eed545cf31f8a48feb8afa0c1355e126d9549eef9737d92d0c1b87708a8" + }, + "owner_review": false, + "params": { + "consumer_owner_repo": "railiance-apps" + }, + "refusal": null, + "review_requirement": "successor candidate only: new descriptor bytes need a reviewed successor revision under canon section 6 and owner review (NK-WP-0048-T16) before pinning", + "schema": "netkingdom.descriptor-composition-candidate.v1", + "selection": "explicit version root 1.3=security-case-type-revisions/1.3; default root unchanged", + "status": "not_an_admission", + "steps": [ + { + "candidate_descriptor_digest": "sha256:4411b5cb8944ee76ca1ade62c0c246f061981fb18f0b9afd05934a8935598dc3", + "descriptor": { + "actor": "human", + "adapter": "net-kingdom/ensure-groups@v1", + "adapter_source": "sha256:87cc12ada32c4d1296070dd1e8586f35151fcb181ecd89d5d1fbb20c78b50a6f", + "allowed_created": [ + "service-group", + "service-group-membership" + ], + "effect": "change", + "lane": "key-cape-oidc-login", + "owner": "net-kingdom", + "retry_safe": false, + "revocation_scopes": [ + "parent-session" + ], + "step": "ensure-groups", + "target_parameter": "client_id", + "version": "warden.case.execution.v1" + }, + "findings": [], + "names_confirmed": null, + "owner_confirmation_required": null, + "step": "ensure-groups", + "type_lane": "key-cape-oidc-login", + "type_owner": "net-kingdom" + }, + { + "candidate_descriptor_digest": "sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70", + "descriptor": { + "actor": "human", + "adapter": "net-kingdom/keycape-mint-secret@v1", + "adapter_source": "sha256:34f79944204a2cf5b2a22baecbee4bde77cbdc0e3f9fc87bc98fa12a9c2c0bc1", + "allowed_created": [ + "custody-secret-version" + ], + "effect": "mint", + "lane": "keycape-client-secret-generate", + "owner": "railiance-platform", + "retry_safe": false, + "revocation_scopes": [ + "parent-session", + "broker-grant" + ], + "step": "mint-secret", + "target_parameter": "client_id", + "version": "warden.case.execution.v1" + }, + "findings": [], + "names_confirmed": { + "by": "founder", + "on": "2026-10-17", + "schema": "railiance-platform.custody-mint.v1", + "task": "NK-WP-0048-T25" + }, + "owner_confirmation_required": null, + "step": "mint-secret", + "type_lane": "keycape-client-secret-generate", + "type_owner": "railiance-platform" + }, + { + "candidate_descriptor_digest": "sha256:75aaa7346aebdea2a0eb0ba993995515b5248ffefc34caf8e713525e9b58da85", + "descriptor": { + "actor": "human", + "adapter": "net-kingdom/deliver-secret@v1", + "adapter_source": "sha256:df609bb93ebdfffb12cde213381a77eb7ac82670f814ff73623c1d98262cc90e", + "allowed_created": [ + "delivered-secret" + ], + "effect": "change", + "lane": "platform-secret-delivery", + "owner": "railiance-platform", + "retry_safe": false, + "revocation_scopes": [ + "parent-session" + ], + "step": "deliver-secret", + "target_parameter": "client_id", + "version": "warden.case.execution.v1" + }, + "findings": [], + "names_confirmed": { + "by": "founder", + "lane": "platform-secret-delivery", + "on": "2026-10-17", + "schema": "netkingdom.deliver-secret.v1", + "task": "NK-WP-0048-T26" + }, + "owner_confirmation_required": null, + "step": "deliver-secret", + "type_lane": null, + "type_owner": "railiance-platform" + }, + { + "candidate_descriptor_digest": "sha256:d2511dce3d2ff1d380f845920aa168e6fe09c0f0c382482a796a5bf13619905d", + "descriptor": { + "actor": "human", + "adapter": "net-kingdom/keycape-bind-env@v1", + "adapter_source": "sha256:cfc8a7f52494869ead07f9878526a27206c99b0f468673e2e7b111d29d175ddf", + "allowed_created": [ + "keycape-env-binding" + ], + "effect": "change", + "lane": "key-cape-oidc-login", + "owner": "net-kingdom", + "retry_safe": false, + "revocation_scopes": [ + "parent-session" + ], + "step": "bind-env", + "target_parameter": "client_id", + "version": "warden.case.execution.v1" + }, + "findings": [], + "names_confirmed": { + "by": "founder", + "on": "2026-10-17", + "schema": "netkingdom.keycape-bind-env.attempt.v1", + "task": "NK-WP-0048-T27" + }, + "owner_confirmation_required": null, + "step": "bind-env", + "type_lane": "key-cape-oidc-login", + "type_owner": "net-kingdom" + }, + { + "candidate_descriptor_digest": "sha256:3ccc8eed545cf31f8a48feb8afa0c1355e126d9549eef9737d92d0c1b87708a8", + "descriptor": { + "actor": "human", + "adapter": "net-kingdom/keycape-registration@v1", + "adapter_source": "sha256:8af23cb5d5fff963d2eff65d8b16dcfd395931ad860357846151019a70b2ed34", + "allowed_created": [ + "keycape-client" + ], + "effect": "change", + "lane": "key-cape-oidc-login", + "owner": "net-kingdom", + "retry_safe": false, + "revocation_scopes": [ + "parent-session" + ], + "step": "register-client", + "target_parameter": "client_id", + "version": "warden.case.execution.v1" + }, + "findings": [], + "names_confirmed": null, + "owner_confirmation_required": null, + "step": "register-client", + "type_lane": "key-cape-oidc-login", + "type_owner": "net-kingdom" + }, + { + "candidate_descriptor_digest": "sha256:9c289f646516923a4c448a2d3cdd45b33115516e6a171b7955924d8b5d852d2b", + "descriptor": { + "actor": "agent", + "adapter": "net-kingdom/deliver-consumer-secret@v1", + "adapter_source": "sha256:8ec63233218f2f9571e1f0605ae29b4378f3126d0ff7f5192e35efa4291f72cd", + "allowed_created": [ + "consumer-eso-sa", + "consumer-secretstore", + "consumer-externalsecret" + ], + "effect": "change", + "lane": "consumer-owner-deployment", + "owner": "railiance-apps", + "retry_safe": false, + "revocation_scopes": [ + "parent-session" + ], + "step": "deliver-consumer-secret", + "target_parameter": "client_id", + "version": "warden.case.execution.v1" + }, + "findings": [], + "names_confirmed": { + "by": "founder", + "lane": "consumer-owner-deployment", + "on": "2026-10-17", + "schema": "netkingdom.consumer-secret-delivery.v1", + "task": "NK-WP-0048-T28" + }, + "owner_confirmation_required": null, + "step": "deliver-consumer-secret", + "type_lane": null, + "type_owner": "{consumer_owner_repo}" + }, + { + "candidate_descriptor_digest": "sha256:84f0fac401ea2f1e7c305fb1753a34838ac062655199fcfbbd998f39268c3000", + "descriptor": { + "actor": "human", + "adapter": "net-kingdom/apps-handover@v1", + "adapter_source": "sha256:ceaefd44168fa49d4ab1bb0f4870fe43ffd3b6a963b59d53970c97b1ed871155", + "allowed_created": [ + "handover-receipt" + ], + "effect": "change", + "lane": "apps-consumer-handover", + "owner": "railiance-apps", + "retry_safe": false, + "revocation_scopes": [ + "parent-session" + ], + "step": "hand-over", + "target_parameter": "client_id", + "version": "warden.case.execution.v1" + }, + "findings": [], + "names_confirmed": { + "by": "founder", + "lane": "apps-consumer-handover", + "on": "2026-10-17", + "schema": "netkingdom.apps-handover.attempt.v1", + "task": "NK-WP-0048-T29" + }, + "owner_confirmation_required": null, + "step": "hand-over", + "type_lane": null, + "type_owner": "{consumer_owner_repo}" + } + ], + "task": "NK-WP-0048-T30", + "type": "oidc-browser-relying-party@1.3" +} diff --git a/sso-mfa/k8s/keycape/test_admit_1_3_type.py b/sso-mfa/k8s/keycape/test_admit_1_3_type.py new file mode 100644 index 0000000..d4ffb8b --- /dev/null +++ b/sso-mfa/k8s/keycape/test_admit_1_3_type.py @@ -0,0 +1,140 @@ +"""Kernel admission against the real 1.3 successor type (NK-WP-0048-T16 candidate). + +Unlike test_admit_1_2_adapter_descriptors.py, nothing is patched into the type's +execution_descriptors: the map tested here is the one committed in +security-case-type-revisions/1.3. The type is `proposed`, so the kernel must refuse +it as it stands. Warden's BrowserInputs also pins each type version's parameter +fingerprint and knows only 1.1 and 1.2 (ops-warden case_inputs.PARAMETER_PINS), so an +acknowledged 1.3 is still refused until ops-warden adds a 1.3 pin. That is an +ops-warden owner change, and it is not made here. The tests show that 1.3's +parameters carry 1.2's pinned fingerprint, and that with only that one pin added in +memory the committed map admits exactly the composed descriptors and nothing else. +Trust is fixture-only, nothing is dispatched, and none of this is owner admission +or an acknowledgment. +""" +from dataclasses import replace +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import yaml + +from warden.case_run import Adapter, Executor, Journal, digest +from warden.case_receipt_state import Refused +import warden.case_inputs as inputs + +ROOT = Path(__file__).parent +REPO = ROOT.parents[2] +spec = importlib.util.spec_from_file_location('compose_13_admit', ROOT / 'compose-1.2-descriptors.py') +c = importlib.util.module_from_spec(spec) +spec.loader.exec_module(c) + +TYPE_DIR = REPO / 'security-case-type-revisions/1.3/oidc-browser-relying-party' +TYPE_BYTES = (TYPE_DIR / 'type.yaml').read_bytes() +ARTIFACT = json.loads((ROOT / 'descriptor-composition-1.3.candidate.json').read_text()) +EVIDENCE = {'fixture-acceptance', 'fixture-materialization', 'fixture-descriptor', 'fixture-session'} + + +def unused(*_): + raise AssertionError('admission must not dispatch') + + +def parameter_fingerprint(type_bytes): + params = yaml.safe_load(type_bytes)['spec']['parameters'] + return hashlib.sha256(json.dumps(params, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +def pinned_13(): + """Warden's parameter pins plus the one 1.3 entry ops-warden would have to add.""" + return mock.patch.dict(inputs.PARAMETER_PINS, {'1.3': parameter_fingerprint(TYPE_BYTES)}) + + +def with_status(status): + """The committed 1.3 type with only its status changed.""" + typ = yaml.safe_load(TYPE_BYTES) + typ['spec']['status'] = status + return yaml.safe_dump(typ).encode() + + +class Admit13TypeTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + case = yaml.safe_load((TYPE_DIR / 'examples/forgejo.yaml').read_bytes()) + case['spec'].update(status='materialized', acceptance={ + 'at': '2026-10-17', 'record': 'fixture-only acceptance, not an owner record', + 'by': [{'name': 'fixture', 'role': 'owner'}, {'name': 'fixture', 'role': 'founder'}]}) + self.case_source = yaml.safe_dump(case).encode() + self.descriptors = dict(c.adapter_descriptors(ARTIFACT['check_config_digest'], + ARTIFACT['params']['consumer_owner_repo'], + version='1.3')) + + def admit(self, desc, type_source): + desc_bytes = c.encode(desc) + adapter = Adapter(desc.adapter, desc.adapter_source, unused, unused) + executor = Executor(Journal(Path(self.temp.name) / desc.step), + {adapter.reference: adapter}, + lambda purpose, source, evidence: evidence in EVIDENCE, + browser_inputs=inputs.BrowserInputs(REPO)) + with mock.patch.dict(os.environ): + os.environ.pop('WARDEN_AGENT_ID', None) + return executor.admit(self.case_source, type_source, desc_bytes, + b'fixture-only-catalog', principal='founder', actor=desc.actor, lane=desc.lane, + acceptance_evidence='fixture-acceptance', + materialization_evidence='fixture-materialization', + descriptor_evidence='fixture-descriptor', session_evidence='fixture-session') + + def test_committed_map_is_exactly_the_composed_set(self): + committed = yaml.safe_load(TYPE_BYTES)['spec']['execution_descriptors'] + composed = {s: digest(c.encode(d)) for s, d in self.descriptors.items()} + self.assertEqual(len(composed), 7) + self.assertEqual(committed, composed) + self.assertEqual(committed, ARTIFACT['execution_descriptors']) + + def test_proposed_type_is_refused_as_it_stands(self): + self.assertEqual(yaml.safe_load(TYPE_BYTES)['spec']['status'], 'proposed') + for step, desc in self.descriptors.items(): + with self.subTest(step=step), self.assertRaisesRegex(Refused, 'type is not acknowledged'): + self.admit(desc, TYPE_BYTES) + + def test_warden_has_no_1_3_parameter_pin_yet(self): + self.assertNotIn('1.3', inputs.PARAMETER_PINS) + # 1.3 keeps 1.2's parameters, so the missing pin is 1.2's fingerprint. + self.assertEqual(parameter_fingerprint(TYPE_BYTES), inputs.PARAMETER_PINS['1.2']) + acknowledged = with_status('acknowledged') + for step, desc in self.descriptors.items(): + with self.subTest(step=step), self.assertRaisesRegex( + Refused, 'failed exact-version validation'): + self.admit(desc, acknowledged) + + def test_committed_map_admits_every_descriptor_once_acknowledged_and_pinned(self): + acknowledged = with_status('acknowledged') + for step, desc in self.descriptors.items(): + with self.subTest(step=step), pinned_13(): + admitted, invocation, _ = self.admit(desc, acknowledged) + self.assertEqual(admitted, desc) + self.assertEqual((invocation.step, invocation.actor, invocation.target), + (desc.step, desc.actor, 'forgejo')) + + def test_descriptor_outside_the_map_is_refused(self): + acknowledged = with_status('acknowledged') + mint = self.descriptors['mint-secret'] + for changed in (replace(mint, lane='other-lane'), replace(mint, retry_safe=True)): + with self.subTest(changed=changed), pinned_13(), self.assertRaisesRegex( + Refused, 'no exact acknowledged machine descriptor'): + self.admit(changed, acknowledged) + + def test_earlier_versions_carry_no_execution_map(self): + for version in ('1.2',): + path = REPO / f'security-case-type-revisions/{version}/oidc-browser-relying-party/type.yaml' + with self.subTest(version=version): + self.assertNotIn('execution_descriptors', yaml.safe_load(path.read_bytes())['spec']) + + +if __name__ == '__main__': + unittest.main() diff --git a/sso-mfa/k8s/keycape/test_compose_1_2_descriptors.py b/sso-mfa/k8s/keycape/test_compose_1_2_descriptors.py index b3e6221..0e194ed 100644 --- a/sso-mfa/k8s/keycape/test_compose_1_2_descriptors.py +++ b/sso-mfa/k8s/keycape/test_compose_1_2_descriptors.py @@ -2,6 +2,7 @@ from dataclasses import replace import importlib.util from pathlib import Path +import tempfile import unittest from warden.case_run import Descriptor @@ -90,17 +91,15 @@ class ComposeTests(unittest.TestCase): c.load_types(version_roots={}) def test_version_selection(self): - absent = not (c.REVISIONS / '1.3').is_dir() for bad in ('1.1', '1.4', '', None): with self.assertRaises(Refused): c.load_types(version=bad) with self.assertRaises(Refused): # a 1.2 root cannot be selected as 1.3 c.load_types(version='1.3', version_roots={'1.2': c.VERSION_ROOTS['1.2']}) - if absent: - with self.assertRaises(Refused): - c.load_types(version='1.3') - self.assertEqual(c.main(['--check-config-digest', 'sha256:' + '0' * 64, - '--consumer-owner-repo', 'railiance-apps', '--version', '1.3']), 3) + with tempfile.TemporaryDirectory() as missing, self.assertRaises(Refused): + c.load_types(version='1.3', version_roots={'1.3': Path(missing) / 'absent'}) + type_spec, _ = c.load_types(version='1.3') # the 1.3 successor candidate exists now + self.assertEqual(type_spec['version'], '1.3') with self.assertRaises(SystemExit): c.main(['--check-config-digest', 'x', '--consumer-owner-repo', 'y', '--version', '1.1']) diff --git a/workplans/NK-WP-0048-security-case-types.md b/workplans/NK-WP-0048-security-case-types.md index 3a08bf3..80c70cb 100644 --- a/workplans/NK-WP-0048-security-case-types.md +++ b/workplans/NK-WP-0048-security-case-types.md @@ -827,6 +827,19 @@ priority: high state_hub_task_id: "200bf500-5084-5ae7-be26-3033fe1f296e" ``` +2026-10-17 successor candidate prepared (source only, `proposed`): +`security-case-type-revisions/1.3/oidc-browser-relying-party/` is the 1.2 package plus +an `execution_descriptors` map of the seven composed descriptors, with examples +pinned to `@1.3` and no acknowledgment. ensure-groups, hand-over, +deliver-consumer-secret and the composer accept 1.3 (835680c, merged acda26c). +`test_admit_1_3_type.py` runs Warden's real admission against the committed 1.3 type: +the proposed type is refused; an acknowledged copy is refused because ops-warden +pins parameter fingerprints only for 1.1 and 1.2; and with only a 1.3 pin (equal to +1.2's fingerprint) all seven are admitted. This is a major change under canon +section 6. Next: r4 source review of the changed adapters and the map, then owner +reviews, including the ops-warden 1.3 pin, the mint-secret warden_executes/human +question and the owner-run steps question, then the founder's Decision Memo. + Owner: NetKingdom for descriptor/type source and exact checker artifact; Warden independently owns global provenance/admission. T22 records the completed seven-step source1.2 inventory and regenerated register-client descriptor using the delivered