diff --git a/canon/schemas/security-case-type_v0.1.schema.json b/canon/schemas/security-case-type_v0.1.schema.json index b307910..22ab31c 100644 --- a/canon/schemas/security-case-type_v0.1.schema.json +++ b/canon/schemas/security-case-type_v0.1.schema.json @@ -317,10 +317,11 @@ "type": "object", "additionalProperties": false, "required": [ + "auth", + "entry_point", "id", "owner", - "entry_point", - "auth", + "value_handling", "warden_executes" ], "properties": { @@ -341,7 +342,8 @@ "minLength": 1 }, "warden_executes": { - "type": "boolean" + "type": "boolean", + "description": "ops-warden invokes the owner's declared entry point through the caller proxy, under the caller's identity; ops-warden holds no credential for it (ops-warden ADR-0002)." }, "attended": { "type": "boolean" @@ -355,6 +357,22 @@ "when": { "type": "string", "pattern": "^[a-z][a-z0-9_]* (==|!=) [A-Za-z0-9_.:/-]+$" + }, + "lane": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$", + "description": "ops-warden catalog lane that resolves this step; required when warden_executes or attended" + }, + "value_handling": { + "enum": [ + "none", + "generated_in_custody" + ] + }, + "ready_check": { + "type": "string", + "minLength": 1, + "description": "status-only gate checked before the next step; never reads a value" } } }, diff --git a/canon/standards/security-case-types_v0.1.md b/canon/standards/security-case-types_v0.1.md index e53ee45..c0256f0 100644 --- a/canon/standards/security-case-types_v0.1.md +++ b/canon/standards/security-case-types_v0.1.md @@ -79,10 +79,28 @@ A type is a directory `security-case-types//` in net-kingdom containing initial trust, owner attribution) - `materialization`: the OpenBao structure ops-mason creates per case, as templates over the parameters: policy, role, KV path, catalog entry; - a policy or role templates its rules (`body`), not only its name + a policy, role or catalog entry templates its body (`body`), not only its + name, so that cases add values and never design - `follow_through`: the ops-warden route for each step, `warden_executes`, the auth method and the entry point; which steps need an attended human - login, if any, and why + login, if any, and why. Each step also names: + - `lane`: the reviewed ops-warden catalog lane that resolves it. This is + required for every step ops-warden executes or groups into an attended + session, and ops-warden refuses to plan a step whose lane is missing or + unreviewed. + - `value_handling`: `none`, or `generated_in_custody` for a secret that is + generated inside the broker and custody. A generated value never passes + through the run, its argv, stdout or receipts. + - optionally `ready_check`: a status-only gate that must hold before the + next step (for example, a delivery reporting Ready). It never reads a + value. + + `warden_executes: true` means exactly this: ops-warden invokes the owner's + declared entry point through its existing caller proxy, under the caller's + identity and the caller's own lane. It never means that ops-warden holds a + standing credential for the step; that would make it a broker (ops-warden + ADR-0002). *(Clarified 2026-10-09 from ops-warden's review, + WARDEN-WP-0044-T01 R1–R3.)* - `verification`: the checks that prove a case is live (positive and negative), runnable without reading a secret value - `rollback`: the exact inverse of each step diff --git a/security-case-types/_template/type.yaml b/security-case-types/_template/type.yaml index 3e43244..ded8cae 100644 --- a/security-case-types/_template/type.yaml +++ b/security-case-types/_template/type.yaml @@ -79,6 +79,11 @@ spec: - id: catalog-entry kind: catalog_entry template: "local-identity-{operator_username}" + body: | + id: local-identity-{operator_username} + owner_repo: net-kingdom + warden_executes: false + path_template: {signing_key_ref} # What ops-warden carries out, step by step, on whose entry point. follow_through: @@ -86,8 +91,10 @@ spec: owner: net-kingdom entry_point: "uv run --project local-identity local-identity init" auth: "local operator shell on the target host" + lane: local-identity-operator warden_executes: false attended: true + value_handling: none attended_justification: >- The bootstrap identity exists before any managed identity, so no unattended principal can run it. diff --git a/security-case-types/oidc-browser-relying-party/README.md b/security-case-types/oidc-browser-relying-party/README.md index 0fd172f..e750499 100644 --- a/security-case-types/oidc-browser-relying-party/README.md +++ b/security-case-types/oidc-browser-relying-party/README.md @@ -84,6 +84,18 @@ session (WARDEN-WP-0044). The way to remove this step is a KeyCape change: load clients from a non-secret registry and reference confidential secrets from custody. That would be a 1.1 or 2.0 of this type. +## Lanes, value handling and gates + +Every step ops-warden executes or groups names its catalog lane +(`identity-provisioner-groups` and `keycape-client-secret-generate` are new and +must exist before the first case runs; the attended steps use the existing +`key-cape-oidc-login`). "ops-warden executes" means ops-warden invokes the owner's +entry point through its caller proxy, under the caller's identity: it holds no +credential (canon section 3). `mint-secret` generates the value inside the +broker and custody (`value_handling: generated_in_custody`), so no value passes +through the run. `deliver-secret` gates `bind-env` with a status-only Ready check. +`ensure-groups` adds exactly the memberships recorded in the accepted case. + ## Known limits - No loopback or plain-http redirects (CLI and local development clients). diff --git a/security-case-types/oidc-browser-relying-party/decision/build_intake.py b/security-case-types/oidc-browser-relying-party/decision/build_intake.py index e6c5254..5da50bd 100644 --- a/security-case-types/oidc-browser-relying-party/decision/build_intake.py +++ b/security-case-types/oidc-browser-relying-party/decision/build_intake.py @@ -13,9 +13,10 @@ from pathlib import Path HERE = Path(__file__).resolve().parent HUB_DECISION = "e418263e-a71f-4f01-a970-c87c9e187c09" -# r2 supersedes the first memo (delivered before ops-mason's review). A new id is -# needed: the dispatcher refuses new content under a delivered idempotency key. -MEMO_ID = "nk-wp-0048-t05-oidc-browser-relying-party-r2" +# r3 supersedes r2 (built before ops-warden's review) and the first memo (built +# before ops-mason's review). Each revision needs a new id: the dispatcher +# refuses new content under a delivered idempotency key. +MEMO_ID = "nk-wp-0048-t05-oidc-browser-relying-party-r3" ITEM = f"{MEMO_ID}-packet" packet = (HERE / "packet.md").read_text() diff --git a/security-case-types/oidc-browser-relying-party/decision/intake.json b/security-case-types/oidc-browser-relying-party/decision/intake.json index ade1951..954fe86 100644 --- a/security-case-types/oidc-browser-relying-party/decision/intake.json +++ b/security-case-types/oidc-browser-relying-party/decision/intake.json @@ -1,7 +1,7 @@ { "kind": "informed-decision.memo-intake", "version": 1, - "idempotency_key": "state-hub.e418263e-a71f-4f01-a970-c87c9e187c09.nk-wp-0048-t05-oidc-browser-relying-party-r2", + "idempotency_key": "state-hub.e418263e-a71f-4f01-a970-c87c9e187c09.nk-wp-0048-t05-oidc-browser-relying-party-r3", "memo_class": "decision-record", "review_group": "net-kingdom-admins", "drafted_by": "net-kingdom NK-WP-0048 (claude-code)", @@ -20,7 +20,7 @@ } }, "memo": { - "id": "nk-wp-0048-t05-oidc-browser-relying-party-r2", + "id": "nk-wp-0048-t05-oidc-browser-relying-party-r3", "version": 1, "question": "Acknowledge oidc-browser-relying-party 1.0 as the reusable SecurityCaseType for internal web services that sign humans in through NetKingdom?", "requested_act": "approve", @@ -47,15 +47,15 @@ "step_kind": "approve", "packet": [ { - "item_id": "nk-wp-0048-t05-oidc-browser-relying-party-r2-packet", - "label": "nk-wp-0048-t05-oidc-browser-relying-party-r2-packet", - "hash": "sha256:af6435e2009655c0a2ff2a1d3ae8777254b37621bf56c8930bf2a936653a5a8e" + "item_id": "nk-wp-0048-t05-oidc-browser-relying-party-r3-packet", + "label": "nk-wp-0048-t05-oidc-browser-relying-party-r3-packet", + "hash": "sha256:726dfc010c94d24b95e7de5c1725568531678388c9ef855c7e605d9cf3f50142" } ], "highlights": [ { - "id": "nk-wp-0048-t05-oidc-browser-relying-party-r2-h1", - "item_id": "nk-wp-0048-t05-oidc-browser-relying-party-r2-packet", + "id": "nk-wp-0048-t05-oidc-browser-relying-party-r3-h1", + "item_id": "nk-wp-0048-t05-oidc-browser-relying-party-r3-packet", "severity": "critical", "required_ack": true, "locator": { @@ -66,8 +66,8 @@ "note": "Acknowledging changes nothing live and accepts no case. It decides the shape every future browser client case will follow." }, { - "id": "nk-wp-0048-t05-oidc-browser-relying-party-r2-h2", - "item_id": "nk-wp-0048-t05-oidc-browser-relying-party-r2-packet", + "id": "nk-wp-0048-t05-oidc-browser-relying-party-r3-h2", + "item_id": "nk-wp-0048-t05-oidc-browser-relying-party-r3-packet", "severity": "warning", "required_ack": true, "locator": { @@ -85,9 +85,9 @@ }, "packet": [ { - "item_id": "nk-wp-0048-t05-oidc-browser-relying-party-r2-packet", + "item_id": "nk-wp-0048-t05-oidc-browser-relying-party-r3-packet", "media_type": "text/markdown", - "content": "# Acknowledge SecurityCaseType oidc-browser-relying-party 1.0\n\nWorkplan: NK-WP-0048 (net-kingdom), task NK-WP-0048-T05\nHub decision: e418263e-a71f-4f01-a970-c87c9e187c09\nCanon: canon/standards/security-case-types_v0.1.md (accepted 2026-10-08), section 5\nType: security-case-types/oidc-browser-relying-party/ (type.yaml, README.md, tests)\nMemo: nk-wp-0048-t05-oidc-browser-relying-party-r2. It supersedes memo\nnk-wp-0048-t05-oidc-browser-relying-party, delivered earlier on 2026-10-08,\nwhich described the type before ops-mason's review. Please answer this one;\nboth serve the same hub decision, and the first answer resolves it.\n\n## Question\n\nAcknowledge oidc-browser-relying-party 1.0 as the reusable SecurityCaseType for\ninternal web services that sign humans in through NetKingdom?\n\n## One act\n\nMove the type from `proposed` to `acknowledged`. From then on, a browser OIDC\nclient for an internal service is a SecurityCase of this type: its owner fills in\nthe parameters, the case is accepted (owner and founder, risk class medium),\nops-mason materializes it and ops-warden follows through. The type becomes\n`implemented` only after its first case (Forgejo) is live and verified.\n\n## What acknowledging does not do\n\n- It registers no client, creates no group, mints no secret and changes nothing\n live.\n- It does not accept any case. The Forgejo case needs its own acceptance; its\n parameters were accepted in KEY-WP-0038-T01 and carry over, except custody:\n the type puts Forgejo's secret at `platform/workloads/keycape/forgejo`, not\n the request's proposed `platform/workloads/forgejo/netkingdom-oidc-client`.\n- It does not adopt the existing hand-built clients. Adoption, when it happens,\n does not change their live registrations.\n\n## The shape being acknowledged\n\nParameters: client id, display name, exact https redirect URIs (no wildcards),\nscopes from a closed list, client type (confidential or public), MFA pin, the\nLLDAP groups that gate sign-in and their initial members, the consuming service\nand its owner. A case cannot choose where a confidential client's secret lives:\nthe type derives `platform/workloads/keycape/`, as the live\nsecrets-engine-openbao client already uses. Secrets never appear in a case.\n\nSteps, in order, each on its owner's entry point:\n\n| Step | Owner | Runs | Confidential only |\n| --- | --- | --- | --- |\n| ensure-groups | net-kingdom (identity-provisioner) | ops-warden | no |\n| mint-secret | railiance-platform (credential broker) | ops-warden | yes |\n| deliver-secret | railiance-platform (delivery into the sso namespace) | owner | yes |\n| bind-env | net-kingdom (KeyCape Deployment) | attended operator | yes |\n| register-client | net-kingdom (keycape-client.py) | attended operator | no |\n| hand-over | the consuming service's owner | owner | no |\n\nops-mason builds, for confidential clients, the custody path; two read\npolicies in the existing family, `workload-kv-read-keycape--eso` and\n`workload-kv-read-keycape--`, each granting `read`\non that one data path and nothing else; and the Kubernetes-auth role\n`external-secrets-keycape-` that ESO uses to deliver the secret (its\nservice account only, 15m, no default policy). For every case it adds the\ncatalog entry `-keycape-client`. A case adds no new path\nlayout or policy family.\n\nVerification never reads a value. The authorization endpoint accepts each\nregistered redirect and refuses another; the groups exist; for confidential\nclients, a default identity is denied the custody path and the consumer's policy\nis allowed it. Rollback is the exact inverse, in reverse order.\n\n## Why this shape (alternatives rejected)\n\n- Per-client scripts (the status quo): too much manual work, one review surface\n per client.\n- Wildcard or prefix redirect URIs: the classic open-redirect weakness.\n- Automatic accounts for every NetKingdom user: sign-in is gated by explicit\n groups; the consumer decides account creation.\n- Secrets in case files for convenience: cases carry no secrets.\n- A custody path chosen per case: every case would add its own layout and\n policy family to review (rejected in ops-mason's review).\n\n## Attended steps\n\n`register-client` (and, for confidential clients, `bind-env`) need one attended\noperator session. KeyCape's live client registry is a Kubernetes Secret, and\nagents do not read or write Secrets. Removing this needs a KeyCape change\n(clients from a non-secret registry, secrets referenced from custody). That\nwould be a new version of this type, with its own decision.\n\n## Known limits of 1.0\n\nNo plain-http or loopback redirects (openbao-admin, the coulomb.social dev\nURIs), no resource audience, tenant binding or custom scopes\n(informed-decision-approver), no service clients. Each of these is a later\nversion or a separate type. Of the existing hand-built browser clients,\nuser-engine portal and vergabe fit 1.0 exactly.\n\n## Evidence\n\n- Validator: tools/security-case-types/, 19 tests (a policy or role must\n template its rules, not only its name)\n- This type: 12 tests, including the rendered Forgejo materialization (path,\n both policy bodies, ESO role, catalog entry), the Forgejo case checked field by field against\n the accepted request, and a regression showing that the vergabe case reproduces\n its hand-built registration exactly\n- Entry points: identity-provisioner groups ensure/revoke (39 tests),\n keycape-client.py (15 tests; it refuses a case-set custody path)\n- Declaration net-kingdom.keycape-client-registration passes the Playbook\n Capability Contract validator\n\n## Reviews before this decision\n\nops-mason reviewed the materialization section (MASON-WP-0010-T01) and asked\nfor a derived custody path, reuse of the existing policy family, templated\npolicy rules and the ESO role; all are in this version, and ops-mason finds the\nsection acceptable. ops-warden's review of the follow-through section\n(WARDEN-WP-0044-T01) is still in progress. Answering now acknowledges a\nfollow-through its executor has not yet confirmed.\n" + "content": "# Acknowledge SecurityCaseType oidc-browser-relying-party 1.0\n\nWorkplan: NK-WP-0048 (net-kingdom), task NK-WP-0048-T05\nHub decision: e418263e-a71f-4f01-a970-c87c9e187c09\nCanon: canon/standards/security-case-types_v0.1.md (accepted 2026-10-08), section 5\nType: security-case-types/oidc-browser-relying-party/ (type.yaml, README.md, tests)\nMemo: nk-wp-0048-t05-oidc-browser-relying-party-r3. It supersedes r2\n(nk-wp-0048-t05-oidc-browser-relying-party-r2, which lacked ops-warden's review)\nand the first memo (already returned). Please return r2 with a comment that r3\nsupersedes it, then answer this one. All three serve the same hub decision, and\nthe first accept or decline resolves it.\n\n## Question\n\nAcknowledge oidc-browser-relying-party 1.0 as the reusable SecurityCaseType for\ninternal web services that sign humans in through NetKingdom?\n\n## One act\n\nMove the type from `proposed` to `acknowledged`. From then on, a browser OIDC\nclient for an internal service is a SecurityCase of this type: its owner fills in\nthe parameters, the case is accepted (owner and founder, risk class medium),\nops-mason materializes it and ops-warden follows through. The type becomes\n`implemented` only after its first case (Forgejo) is live and verified.\n\n## What acknowledging does not do\n\n- It registers no client, creates no group, mints no secret and changes nothing\n live.\n- It does not accept any case. The Forgejo case needs its own acceptance; its\n parameters were accepted in KEY-WP-0038-T01 and carry over, except custody:\n the type puts Forgejo's secret at `platform/workloads/keycape/forgejo`, not\n the request's proposed `platform/workloads/forgejo/netkingdom-oidc-client`.\n- It does not adopt the existing hand-built clients. Adoption, when it happens,\n does not change their live registrations.\n- It does not create the two catalog lanes listed below. They are needed before\n the first case runs (T06), each through its own owner.\n\n## The shape being acknowledged\n\nParameters: client id, display name, exact https redirect URIs (no wildcards),\nscopes from a closed list, client type (confidential or public), MFA pin, the\nLLDAP groups that gate sign-in and their initial members, the consuming service\nand its owner. A case cannot choose where a confidential client's secret lives:\nthe type derives `platform/workloads/keycape/`, as the live\nsecrets-engine-openbao client already uses. Secrets never appear in a case.\n\nSteps, in order, each on its owner's entry point and its catalog lane:\n\n| Step | Owner | Runs | Lane | Values | Confidential only |\n| --- | --- | --- | --- | --- | --- |\n| ensure-groups | net-kingdom (identity-provisioner) | ops-warden | identity-provisioner-groups (new) | none | no |\n| mint-secret | railiance-platform (credential broker) | ops-warden | keycape-client-secret-generate (new) | generated in custody | yes |\n| deliver-secret | railiance-platform (delivery into the sso namespace) | owner; gate: Ready, status only | — | none | yes |\n| bind-env | net-kingdom (KeyCape Deployment) | attended operator | key-cape-oidc-login | none | yes |\n| register-client | net-kingdom (keycape-client.py) | attended operator | key-cape-oidc-login | none | no |\n| hand-over | the consuming service's owner | owner | — | none | no |\n\n\"ops-warden runs\" means exactly this, now written into the canon: ops-warden\ninvokes the owner's entry point through its caller proxy, under the caller's\nidentity and lane. It never holds a standing credential for the step. A secret\nis generated inside the broker and custody and never passes through the run.\n`ensure-groups` adds exactly the memberships recorded in the accepted case and\nnothing else.\n\nops-mason builds, for confidential clients, the custody path; two read policies\nin the existing family, `workload-kv-read-keycape--eso` and\n`workload-kv-read-keycape--`, each granting `read` on\nthat one data path and nothing else; and the Kubernetes-auth role\n`external-secrets-keycape-` that ESO uses to deliver the secret (its\nservice account only, 15m, no default policy). For every case it adds the\ncatalog entry `-keycape-client`, whose body the type templates\n(owner key-cape, risk high, the consumer's workload reference, path and field,\nnative delegation, rotation not automatable until a run is proven). A case adds\nno new path layout, policy family or route design.\n\nVerification never reads a value. The authorization endpoint accepts each\nregistered redirect and refuses another; the groups exist with exactly the\naccepted memberships; for confidential clients, the delivery reports Ready, a\ndefault identity is denied the custody path and the consumer's policy is allowed\nit; when MFA is pinned, the registered client shows the pin. Rollback is the\nexact inverse, in reverse order.\n\n## Why this shape (alternatives rejected)\n\n- Per-client scripts (the status quo): too much manual work, one review surface\n per client.\n- Wildcard or prefix redirect URIs: the classic open-redirect weakness.\n- Automatic accounts for every NetKingdom user: sign-in is gated by explicit\n groups; the consumer decides account creation.\n- Secrets in case files for convenience: cases carry no secrets.\n- A custody path chosen per case: every case would add its own layout and\n policy family to review (rejected in ops-mason's review).\n- One type-level catalog route instead of one entry per case: it would drop the\n consumer's workload reference that zone selection needs (ops-warden withdrew\n this proposal and agreed with ops-mason).\n- ops-warden holding a standing service token: it would become a broker, which\n ops-warden's own design rules out.\n\n## Attended steps\n\n`register-client` (and, for confidential clients, `bind-env`) need one attended\noperator session, grouped by ops-warden on the existing `key-cape-oidc-login`\nlane. KeyCape's live client registry is a Kubernetes Secret, and agents do not\nread or write Secrets. Removing this needs a KeyCape change (clients from a\nnon-secret registry, secrets referenced from custody). That would be a new\nversion of this type, with its own decision.\n\n## Known limits of 1.0\n\nNo plain-http or loopback redirects (openbao-admin, the coulomb.social dev\nURIs), no resource audience, tenant binding or custom scopes\n(informed-decision-approver), no service clients. Each of these is a later\nversion or a separate type. Of the existing hand-built browser clients,\nuser-engine portal and vergabe fit 1.0 exactly.\n\n## Evidence\n\n- Validator: tools/security-case-types/, 22 tests (bodies for policies, roles\n and catalog entries; lanes for executed or attended steps; value handling on\n every step; no generated value in an attended step)\n- This type: 14 tests, including the rendered Forgejo case (custody path, both\n policy bodies, ESO role, catalog entry body, lanes, value handling, the\n delivery gate, the MFA and delivery checks), the Forgejo case checked field by\n field against the accepted request, and a regression showing that the vergabe\n case reproduces its hand-built registration exactly\n- Entry points: identity-provisioner groups ensure/revoke (39 tests),\n keycape-client.py (15 tests)\n- Declaration net-kingdom.keycape-client-registration passes the Playbook\n Capability Contract validator\n\n## Reviews before this decision\n\nBoth executors have reviewed their sections, and everything they required is in\nthis version. ops-mason (MASON-WP-0010-T01) asked for a derived custody path,\nreuse of the existing policy family, templated policy rules, the ESO role and a\nper-case catalog entry. ops-warden (WARDEN-WP-0044-T01) asked for a catalog lane\nper step, a narrow canon meaning of \"ops-warden runs\", per-step value handling,\na Ready gate before bind-env, memberships only from the accepted case, MFA and\ndelivery checks, and a templated catalog entry body. ops-warden and ops-mason\nreport no remaining conflict.\n" } ] } diff --git a/security-case-types/oidc-browser-relying-party/decision/packet.md b/security-case-types/oidc-browser-relying-party/decision/packet.md index d75c9d3..d993dc7 100644 --- a/security-case-types/oidc-browser-relying-party/decision/packet.md +++ b/security-case-types/oidc-browser-relying-party/decision/packet.md @@ -4,10 +4,11 @@ Workplan: NK-WP-0048 (net-kingdom), task NK-WP-0048-T05 Hub decision: e418263e-a71f-4f01-a970-c87c9e187c09 Canon: canon/standards/security-case-types_v0.1.md (accepted 2026-10-08), section 5 Type: security-case-types/oidc-browser-relying-party/ (type.yaml, README.md, tests) -Memo: nk-wp-0048-t05-oidc-browser-relying-party-r2. It supersedes memo -nk-wp-0048-t05-oidc-browser-relying-party, delivered earlier on 2026-10-08, -which described the type before ops-mason's review. Please answer this one; -both serve the same hub decision, and the first answer resolves it. +Memo: nk-wp-0048-t05-oidc-browser-relying-party-r3. It supersedes r2 +(nk-wp-0048-t05-oidc-browser-relying-party-r2, which lacked ops-warden's review) +and the first memo (already returned). Please return r2 with a comment that r3 +supersedes it, then answer this one. All three serve the same hub decision, and +the first accept or decline resolves it. ## Question @@ -32,6 +33,8 @@ ops-mason materializes it and ops-warden follows through. The type becomes the request's proposed `platform/workloads/forgejo/netkingdom-oidc-client`. - It does not adopt the existing hand-built clients. Adoption, when it happens, does not change their live registrations. +- It does not create the two catalog lanes listed below. They are needed before + the first case runs (T06), each through its own owner. ## The shape being acknowledged @@ -42,30 +45,41 @@ and its owner. A case cannot choose where a confidential client's secret lives: the type derives `platform/workloads/keycape/`, as the live secrets-engine-openbao client already uses. Secrets never appear in a case. -Steps, in order, each on its owner's entry point: +Steps, in order, each on its owner's entry point and its catalog lane: -| Step | Owner | Runs | Confidential only | -| --- | --- | --- | --- | -| ensure-groups | net-kingdom (identity-provisioner) | ops-warden | no | -| mint-secret | railiance-platform (credential broker) | ops-warden | yes | -| deliver-secret | railiance-platform (delivery into the sso namespace) | owner | yes | -| bind-env | net-kingdom (KeyCape Deployment) | attended operator | yes | -| register-client | net-kingdom (keycape-client.py) | attended operator | no | -| hand-over | the consuming service's owner | owner | no | +| Step | Owner | Runs | Lane | Values | Confidential only | +| --- | --- | --- | --- | --- | --- | +| ensure-groups | net-kingdom (identity-provisioner) | ops-warden | identity-provisioner-groups (new) | none | no | +| mint-secret | railiance-platform (credential broker) | ops-warden | keycape-client-secret-generate (new) | generated in custody | yes | +| deliver-secret | railiance-platform (delivery into the sso namespace) | owner; gate: Ready, status only | — | none | yes | +| bind-env | net-kingdom (KeyCape Deployment) | attended operator | key-cape-oidc-login | none | yes | +| register-client | net-kingdom (keycape-client.py) | attended operator | key-cape-oidc-login | none | no | +| hand-over | the consuming service's owner | owner | — | none | no | -ops-mason builds, for confidential clients, the custody path; two read -policies in the existing family, `workload-kv-read-keycape--eso` and -`workload-kv-read-keycape--`, each granting `read` -on that one data path and nothing else; and the Kubernetes-auth role +"ops-warden runs" means exactly this, now written into the canon: ops-warden +invokes the owner's entry point through its caller proxy, under the caller's +identity and lane. It never holds a standing credential for the step. A secret +is generated inside the broker and custody and never passes through the run. +`ensure-groups` adds exactly the memberships recorded in the accepted case and +nothing else. + +ops-mason builds, for confidential clients, the custody path; two read policies +in the existing family, `workload-kv-read-keycape--eso` and +`workload-kv-read-keycape--`, each granting `read` on +that one data path and nothing else; and the Kubernetes-auth role `external-secrets-keycape-` that ESO uses to deliver the secret (its service account only, 15m, no default policy). For every case it adds the -catalog entry `-keycape-client`. A case adds no new path -layout or policy family. +catalog entry `-keycape-client`, whose body the type templates +(owner key-cape, risk high, the consumer's workload reference, path and field, +native delegation, rotation not automatable until a run is proven). A case adds +no new path layout, policy family or route design. Verification never reads a value. The authorization endpoint accepts each -registered redirect and refuses another; the groups exist; for confidential -clients, a default identity is denied the custody path and the consumer's policy -is allowed it. Rollback is the exact inverse, in reverse order. +registered redirect and refuses another; the groups exist with exactly the +accepted memberships; for confidential clients, the delivery reports Ready, a +default identity is denied the custody path and the consumer's policy is allowed +it; when MFA is pinned, the registered client shows the pin. Rollback is the +exact inverse, in reverse order. ## Why this shape (alternatives rejected) @@ -77,14 +91,20 @@ is allowed it. Rollback is the exact inverse, in reverse order. - Secrets in case files for convenience: cases carry no secrets. - A custody path chosen per case: every case would add its own layout and policy family to review (rejected in ops-mason's review). +- One type-level catalog route instead of one entry per case: it would drop the + consumer's workload reference that zone selection needs (ops-warden withdrew + this proposal and agreed with ops-mason). +- ops-warden holding a standing service token: it would become a broker, which + ops-warden's own design rules out. ## Attended steps `register-client` (and, for confidential clients, `bind-env`) need one attended -operator session. KeyCape's live client registry is a Kubernetes Secret, and -agents do not read or write Secrets. Removing this needs a KeyCape change -(clients from a non-secret registry, secrets referenced from custody). That -would be a new version of this type, with its own decision. +operator session, grouped by ops-warden on the existing `key-cape-oidc-login` +lane. KeyCape's live client registry is a Kubernetes Secret, and agents do not +read or write Secrets. Removing this needs a KeyCape change (clients from a +non-secret registry, secrets referenced from custody). That would be a new +version of this type, with its own decision. ## Known limits of 1.0 @@ -96,22 +116,26 @@ user-engine portal and vergabe fit 1.0 exactly. ## Evidence -- Validator: tools/security-case-types/, 19 tests (a policy or role must - template its rules, not only its name) -- This type: 12 tests, including the rendered Forgejo materialization (path, - both policy bodies, ESO role, catalog entry), the Forgejo case checked field by field against - the accepted request, and a regression showing that the vergabe case reproduces - its hand-built registration exactly +- Validator: tools/security-case-types/, 22 tests (bodies for policies, roles + and catalog entries; lanes for executed or attended steps; value handling on + every step; no generated value in an attended step) +- This type: 14 tests, including the rendered Forgejo case (custody path, both + policy bodies, ESO role, catalog entry body, lanes, value handling, the + delivery gate, the MFA and delivery checks), the Forgejo case checked field by + field against the accepted request, and a regression showing that the vergabe + case reproduces its hand-built registration exactly - Entry points: identity-provisioner groups ensure/revoke (39 tests), - keycape-client.py (15 tests; it refuses a case-set custody path) + keycape-client.py (15 tests) - Declaration net-kingdom.keycape-client-registration passes the Playbook Capability Contract validator ## Reviews before this decision -ops-mason reviewed the materialization section (MASON-WP-0010-T01) and asked -for a derived custody path, reuse of the existing policy family, templated -policy rules and the ESO role; all are in this version, and ops-mason finds the -section acceptable. ops-warden's review of the follow-through section -(WARDEN-WP-0044-T01) is still in progress. Answering now acknowledges a -follow-through its executor has not yet confirmed. +Both executors have reviewed their sections, and everything they required is in +this version. ops-mason (MASON-WP-0010-T01) asked for a derived custody path, +reuse of the existing policy family, templated policy rules, the ESO role and a +per-case catalog entry. ops-warden (WARDEN-WP-0044-T01) asked for a catalog lane +per step, a narrow canon meaning of "ops-warden runs", per-step value handling, +a Ready gate before bind-env, memberships only from the accepted case, MFA and +delivery checks, and a templated catalog entry body. ops-warden and ops-mason +report no remaining conflict. diff --git a/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party.py b/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party.py index e31ccc7..d5151a5 100644 --- a/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party.py +++ b/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party.py @@ -133,3 +133,28 @@ def test_confidential_case_renders_custody_delivery_and_one_attended_session(): plan = r["composition"] assert plan["execution"]["permitted"] is False assert r["execution"] == {"permitted": False} + + +def test_ops_warden_review_is_folded_in(): + issues, r = v.render_case(FORGEJO, as_implemented(), DECLS) + assert msgs(issues) == [] + steps = {s["id"]: s for s in r["follow_through"]} + lanes = {k: s.get("lane") for k, s in steps.items()} + assert lanes["ensure-groups"] == "identity-provisioner-groups" + assert lanes["mint-secret"] == "keycape-client-secret-generate" + assert lanes["bind-env"] == lanes["register-client"] == "key-cape-oidc-login" + assert steps["mint-secret"]["value_handling"] == "generated_in_custody" + assert all(s["value_handling"] == "none" for k, s in steps.items() if k != "mint-secret") + assert "Ready=True" in steps["deliver-secret"]["ready_check"] + assert "accepted case" in steps["ensure-groups"]["entry_point"] + checks = {c["id"] for c in r["verification"]} + assert {"secret-delivered", "mfa-pinned"} <= checks + entry = next(m for m in r["materialization"] if m["kind"] == "catalog_entry") + assert "workload_ref: applicable, name forgejo" in entry["body"] + assert "path_template: platform/workloads/keycape/forgejo" in entry["body"] + + +def test_public_client_without_mfa_skips_delivery_and_mfa_checks(): + _, r = v.render_case(PUBLIC, as_implemented(), DECLS) + checks = {c["id"] for c in r["verification"]} + assert not {"secret-delivered", "mfa-pinned"} & checks diff --git a/security-case-types/oidc-browser-relying-party/type.yaml b/security-case-types/oidc-browser-relying-party/type.yaml index b3edb4b..8e9ee2f 100644 --- a/security-case-types/oidc-browser-relying-party/type.yaml +++ b/security-case-types/oidc-browser-relying-party/type.yaml @@ -139,20 +139,38 @@ spec: - id: catalog-entry kind: catalog_entry template: "{consuming_service}-keycape-client" + body: | + id: {consuming_service}-keycape-client + owner_repo: key-cape + warden_executes: false + risk: high + status: active once verification passes + workload_ref: applicable, name {consuming_service}, declaration_ref of the consumer's published declaration or the explicit unknown form, never inferred + path_template: platform/workloads/keycape/{client_id} + field: CLIENT_SECRET + delegation: native, intended_owner key-cape + rotation: mint-secret then verification; automatable false until a run has been proven + description: >- + One entry per case (ops-mason item 5, ops-warden R4'). It carries the + consumer's workload reference, which ADR-0009 needs for zone selection. follow_through: - id: ensure-groups owner: net-kingdom - entry_point: "identity-provisioner POST /v1/groups/ensure (groups {access_groups}; members {initial_members})" - auth: "identity-provisioner service token (bearer), via the ops-warden proxy; the provisioner holds the LLDAP admin credential" + entry_point: "identity-provisioner POST /v1/groups/ensure (groups {access_groups}; members exactly {initial_members} as recorded in the accepted case, nothing else)" + auth: "the caller's own identity-provisioner credential through its lane; ops-warden invokes it via the caller proxy and holds no token" + lane: identity-provisioner-groups warden_executes: true attended: false + value_handling: none - id: mint-secret owner: railiance-platform when: "client_type == confidential" - entry_point: "railiance-platform credential broker: generate a client secret into bao:platform/workloads/keycape/{client_id}" - auth: "credential broker issuer, scoped to the case's KV path" + entry_point: "railiance-platform credential broker: generate a client secret inside custody at bao:platform/workloads/keycape/{client_id}" + auth: "the caller's broker grant for the case's KV path, through its lane; the value is generated in custody and never returned" + lane: keycape-client-secret-generate warden_executes: true attended: false + value_handling: generated_in_custody - id: deliver-secret owner: railiance-platform when: "client_type == confidential" @@ -160,13 +178,17 @@ spec: auth: "railiance-platform ESO delivery through role external-secrets-keycape-{client_id}; no value leaves custody" warden_executes: false attended: false + value_handling: none + ready_check: "the delivery for sso/keycape-{client_id}-client reports Ready=True (status only, no value read)" - id: bind-env owner: net-kingdom when: "client_type == confidential" entry_point: "patch Deployment sso/keycape: env KEYCAPE__CLIENT_SECRET from sso/keycape-{client_id}-client" auth: "operator kube credentials for namespace sso" + lane: key-cape-oidc-login warden_executes: false attended: true + value_handling: none attended_justification: >- A change to the live identity provider's Deployment. It runs in the same attended operator session as register-client, before it, because @@ -176,8 +198,10 @@ spec: owner: net-kingdom entry_point: "sso-mfa/k8s/keycape/keycape-client.py apply --client-file --case-acceptance (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 @@ -190,6 +214,7 @@ spec: auth: "the consumer owner's own deployment path" warden_executes: false attended: false + value_handling: none verification: - id: authorize-accepts-redirects @@ -211,7 +236,17 @@ spec: expect: allow reads_secret_value: false - id: groups-present - check: "LLDAP groups {access_groups} exist" + check: "LLDAP groups {access_groups} exist, with exactly the memberships of the accepted case added" + 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 diff --git a/tools/security-case-types/security_case_validator.py b/tools/security-case-types/security_case_validator.py index 6d4482a..a461a42 100644 --- a/tools/security-case-types/security_case_validator.py +++ b/tools/security-case-types/security_case_validator.py @@ -46,6 +46,8 @@ CASE_STATUSES = ["proposed", "accepted", "materialized", "live", "retired"] RISK_CLASSES = {"low", "medium", "high"} ACCEPTERS = {"owner", "founder"} MATERIALIZATION_KINDS = {"openbao_policy", "openbao_role", "kv_path", "catalog_entry"} +VALUE_HANDLING = {"none", "generated_in_custody"} +LANE = re.compile(r"^[a-z0-9][a-z0-9-]*$") TYPE_SECTIONS = [ "id", "version", "status", "title", "purpose", "parameters", "composition", "materialization", "follow_through", "verification", "rollback", "acceptance", @@ -256,8 +258,8 @@ def _validate_materialization(items: Any, params: dict[str, dict], issues: list[ seen.add(item.get("id")) if not item.get("template"): issues.append(err(f"{path}[{i}].template", "required")) - if item["kind"] in {"openbao_policy", "openbao_role"} and not item.get("body"): - issues.append(err(f"{path}[{i}].body", "a policy or role needs its rules templated, not only its name")) + if item["kind"] in {"openbao_policy", "openbao_role", "catalog_entry"} and not item.get("body"): + issues.append(err(f"{path}[{i}].body", "a policy, role or catalog entry needs its body templated, not only its name")) _validate_templates(items, path, params, issues) @@ -282,6 +284,21 @@ def _validate_follow_through(steps: Any, issues: list[Issue]) -> set[str]: issues.append(err(f"{p}.warden_executes", "must be true or false")) if s.get("attended") is True and not s.get("attended_justification"): issues.append(err(f"{p}.attended_justification", "an attended step must say why it cannot be unattended")) + # R3 (ops-warden review): every step says what happens to values; a + # generated secret is created inside custody and never passes the run. + if s.get("value_handling") not in VALUE_HANDLING: + issues.append(err(f"{p}.value_handling", f"must be one of {sorted(VALUE_HANDLING)}")) + if s.get("value_handling") == "generated_in_custody" and s.get("attended") is True: + issues.append(err(f"{p}.value_handling", "a value generated in custody is never handled in an attended session")) + # R1: ops-warden resolves every step it executes or groups into an + # attended session through a reviewed catalog lane. + lane = s.get("lane") + if (s.get("warden_executes") is True or s.get("attended") is True) and not lane: + issues.append(err(f"{p}.lane", "a step ops-warden executes or groups needs its catalog lane")) + if lane is not None and not (isinstance(lane, str) and LANE.fullmatch(lane)): + issues.append(err(f"{p}.lane", "must be a catalog id (lowercase letters, digits, hyphens)")) + if "ready_check" in s and not (isinstance(s["ready_check"], str) and s["ready_check"].strip()): + issues.append(err(f"{p}.ready_check", "must describe a status-only check")) return ids diff --git a/tools/security-case-types/tests/test_security_case_validator.py b/tools/security-case-types/tests/test_security_case_validator.py index 2aa7ab5..9bf84d2 100644 --- a/tools/security-case-types/tests/test_security_case_validator.py +++ b/tools/security-case-types/tests/test_security_case_validator.py @@ -183,3 +183,26 @@ def test_bad_condition_is_refused(): found = msgs(v.validate_type(t)) assert any("unknown parameter 'nonexistent'" in m for m in found) assert any("must be ' == '" in m for m in found) + + +def test_follow_through_needs_value_handling_and_lanes(): + t = copy.deepcopy(TYPE) + step = t["spec"]["follow_through"][0] + del step["value_handling"] + del step["lane"] + found = msgs(v.validate_type(t)) + assert any("value_handling" in m for m in found) + assert any("catalog lane" in m for m in found) + + +def test_generated_value_never_in_attended_step(): + t = copy.deepcopy(TYPE) + t["spec"]["follow_through"][0]["value_handling"] = "generated_in_custody" + assert any("never handled in an attended session" in m for m in msgs(v.validate_type(t))) + + +def test_catalog_entry_needs_a_body(): + t = copy.deepcopy(TYPE) + item = next(m for m in t["spec"]["materialization"] if m["kind"] == "catalog_entry") + del item["body"] + assert any("catalog entry needs its body" in m for m in msgs(v.validate_type(t))) diff --git a/workplans/NK-WP-0048-security-case-types.md b/workplans/NK-WP-0048-security-case-types.md index 12dd6a2..84d2e2a 100644 --- a/workplans/NK-WP-0048-security-case-types.md +++ b/workplans/NK-WP-0048-security-case-types.md @@ -259,6 +259,38 @@ hold r2, fold them in and build r3. key-cape and railiance-apps were told about Forgejo's new custody path. +2026-10-09: r2 was not answered (hub decision `e418263e` open; dispatcher 9 +delivered), so r2 is held and r3 replaces it. ops-warden withdrew R4 for R4′ +(message a66a9204): one catalog entry per case with its body templated in the +type. Folded in: + +- Canon section 3: a narrow meaning of `warden_executes: true` (caller proxy, + caller identity, no standing credential; ops-warden ADR-0002), plus per-step + `lane`, `value_handling` and an optional `ready_check`. A catalog entry body is + now required, like policy and role bodies. +- Schema and validator enforce all of these. +- The type: lanes `identity-provisioner-groups` and + `keycape-client-secret-generate` (both new) and `key-cape-oidc-login`; + `mint-secret` is `generated_in_custody`; a Ready gate on `deliver-secret`; + `ensure-groups` adds exactly the accepted case's memberships; new checks + `secret-delivered` and `mfa-pinned`; the catalog-entry body follows R4′. +- 110 tests pass. + +Memo r3 (`nk-wp-0048-t05-oidc-browser-relying-party-r3`, packet +`sha256:726dfc01…`) passes informed-decision's and the dispatcher's validation. +The founder queues it with the same command, returns r2 with a comment that r3 +supersedes it, then answers r3. + +Prerequisites for T06, not for T05: +- the two new lanes, each through its owner; +- a founder-approved rollout of an identity-provisioner image containing + 630ebfd (the deployed one is main-6c4fcaf from 2026-09-24, without + `/v1/groups/ensure`). + +ops-mason's request to create LLDAP group `fluid-telegram-operators` (message +259ec26d) was answered: not possible from here by that path; the founder creates +it in the LLDAP admin UI. + ```task id: NK-WP-0048-T06 status: wait