From 074b3a047b017e66bf9d49b9a698c7285e382f1c Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 8 Oct 2026 23:27:22 +0200 Subject: [PATCH] NK-WP-0048-T05: fold ops-mason's materialization review into oidc-browser-relying-party Derive custody at platform/workloads/keycape/{client_id} (secret_ref is no longer case-settable), reuse the workload-kv-read-keycape-* policy family with templated bodies, add the external-secrets-keycape-{client_id} ESO role, name the catalog entry {consuming_service}-keycape-client and collapse the duplicate KV-path rollback. Schema and validator require a body on policy/role items. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 330051@bnt-lap001 Assistant-Session: b98d4912-95a3-4619-b7fb-57aa1db7407b --- .../security-case-type_v0.1.schema.json | 29 +++++++- canon/standards/security-case-types_v0.1.md | 3 +- security-case-types/_template/type.yaml | 4 ++ .../oidc-browser-relying-party/README.md | 9 ++- .../examples/forgejo.yaml | 3 +- .../tests/test_oidc_browser_relying_party.py | 36 +++++++--- .../oidc-browser-relying-party/type.yaml | 71 ++++++++++++------- sso-mfa/k8s/keycape/keycape-client.py | 3 +- sso-mfa/k8s/keycape/test_keycape_client.py | 5 +- .../security_case_validator.py | 2 + workplans/NK-WP-0048-security-case-types.md | 19 +++++ 11 files changed, 141 insertions(+), 43 deletions(-) diff --git a/canon/schemas/security-case-type_v0.1.schema.json b/canon/schemas/security-case-type_v0.1.schema.json index 7b0e839..b307910 100644 --- a/canon/schemas/security-case-type_v0.1.schema.json +++ b/canon/schemas/security-case-type_v0.1.schema.json @@ -283,8 +283,35 @@ "when": { "type": "string", "pattern": "^[a-z][a-z0-9_]* (==|!=) [A-Za-z0-9_.:/-]+$" + }, + "body": { + "type": "string", + "minLength": 1, + "description": "The rendered object's content over case parameters: a policy's rules or a role's bindings. Required for openbao_policy and openbao_role; literal braces are doubled." } - } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "enum": [ + "openbao_policy", + "openbao_role" + ] + } + }, + "required": [ + "kind" + ] + }, + "then": { + "required": [ + "body" + ] + } + } + ] }, "step": { "type": "object", diff --git a/canon/standards/security-case-types_v0.1.md b/canon/standards/security-case-types_v0.1.md index fcc4d95..e53ee45 100644 --- a/canon/standards/security-case-types_v0.1.md +++ b/canon/standards/security-case-types_v0.1.md @@ -78,7 +78,8 @@ A type is a directory `security-case-types//` in net-kingdom containing in the security-scenario-composition form (fail-closed ordering, explicit initial trust, owner attribution) - `materialization`: the OpenBao structure ops-mason creates per case, as - templates over the parameters: policy, role, KV path, catalog entry + templates over the parameters: policy, role, KV path, catalog entry; + a policy or role templates its rules (`body`), not only its name - `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 diff --git a/security-case-types/_template/type.yaml b/security-case-types/_template/type.yaml index fcdb9e5..3e43244 100644 --- a/security-case-types/_template/type.yaml +++ b/security-case-types/_template/type.yaml @@ -72,6 +72,10 @@ spec: - id: signing-key-policy kind: openbao_policy template: "local-identity-{operator_username}-read" + # A policy or role templates its rules too, not only its name; literal + # braces in HCL are doubled. Prefer a path the type derives over one a + # case sets (see oidc-browser-relying-party). + body: "read on the KV v2 data path of {signing_key_ref} only; no metadata, list, parent or sibling" - id: catalog-entry kind: catalog_entry template: "local-identity-{operator_username}" diff --git a/security-case-types/oidc-browser-relying-party/README.md b/security-case-types/oidc-browser-relying-party/README.md index 02c7983..0fd172f 100644 --- a/security-case-types/oidc-browser-relying-party/README.md +++ b/security-case-types/oidc-browser-relying-party/README.md @@ -44,8 +44,8 @@ secret; group memberships that decide who may sign in and with what rights. | Authorization code or token sent to an attacker-controlled URL | exact `redirect_uris`, https only, no wildcards, validated by the type and enforced by KeyCape | | Anyone with a NetKingdom account gains access to the service | `access_groups` gate sign-in at the consumer; membership is explicit (`initial_members`), never implied | | Stolen password grants admin rights | `mfa_required: "true"` is pinned on the client when the service grants administrative or owner rights, not left to the deployment default | -| Client secret leaks through git, chat or a case file | the secret never appears in a case; it is minted into `secret_ref` by the credential broker, and only two policies can read it | -| Secret readable by a broad identity | verification includes a negative check that a default-only identity cannot read `secret_ref` | +| Client secret leaks through git, chat or a case file | the secret never appears in a case; it is minted by the credential broker into `platform/workloads/keycape/{client_id}`, a path the type derives, and only two read policies (read on that one data path) can reach it, one of them through the ESO role | +| Secret readable by a broad identity | verification includes a negative check that a default-only identity cannot read the custody path | | Over-broad scopes leak profile data | `allowed_scopes` is a closed list; `openid` first | | Silent drift between hand-built clients | one capability, one type, one rollback; per-client scripts retire as clients are adopted | @@ -66,6 +66,11 @@ roles there), token lifetime policy (KeyCape's), and federation (C5). code alone is not enough. - **Put the client secret in the case for convenience.** Rejected: cases carry custody references only (canon section 9). +- **Let each case choose its custody path.** Rejected (ops-mason review, + MASON-WP-0010-T01): the type derives `platform/workloads/keycape/{client_id}` + and reuses the `workload-kv-read-keycape--` policy family and + `external-secrets-keycape-` role from the live CCR-2026-0033 + placement, so a case cannot add a new layout or policy family. ## Attended steps diff --git a/security-case-types/oidc-browser-relying-party/examples/forgejo.yaml b/security-case-types/oidc-browser-relying-party/examples/forgejo.yaml index 5467bfa..79fdb39 100644 --- a/security-case-types/oidc-browser-relying-party/examples/forgejo.yaml +++ b/security-case-types/oidc-browser-relying-party/examples/forgejo.yaml @@ -1,5 +1,7 @@ # First KnownSecurityCase (NK-WP-0048-T06), from the request the founder accepted # on 2026-10-08 (key-cape docs/forgejo-oidc-client-request.yaml, KEY-WP-0038-T01). +# Custody is derived by the type (platform/workloads/keycape/forgejo), not the +# request's proposed platform/workloads/forgejo/netkingdom-oidc-client path. apiVersion: netkingdom.io/security-case/v0.1 kind: SecurityCase spec: @@ -18,6 +20,5 @@ spec: mfa_required: "true" access_groups: forge-admins,forge-coulomb-owners,forge-coulomb-readers initial_members: forge-admins=tegwick;forge-coulomb-owners=tegwick - secret_ref: bao:platform/workloads/forgejo/netkingdom-oidc-client consuming_service: forgejo consumer_owner_repo: railiance-apps 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 89aa9b2..e31ccc7 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 @@ -62,7 +62,7 @@ def test_forgejo_case_matches_the_accepted_request(): assert p["allowed_scopes"].split(",") == accepted["client"]["allowedScopes"] assert p["client_type"] == accepted["client"]["clientType"] assert p["mfa_required"] == str(accepted["client"]["mfaRequired"]).lower() - assert p["secret_ref"] == "bao:" + accepted["custody"]["proposed_openbao_path"] + assert "secret_ref" not in p # derived by the type; diverges from the request's proposed path assert set(p["access_groups"].split(",")) == set(accepted["forgejo_mapping"]["required_claim"]["any_of"]) @@ -73,17 +73,35 @@ def test_redirects_must_be_exact_https(): assert any("redirect_uris" in m for m in msgs(v.validate_case(with_params(FORGEJO, redirect_uris=bad), t))), bad -def test_confidential_client_needs_custody_reference_and_public_does_not(): +def test_custody_path_is_derived_not_case_settable(): t = as_implemented() - assert any("required when client_type == confidential" in m - for m in msgs(v.validate_case(with_params(FORGEJO, secret_ref=None), t))) + assert msgs(v.validate_case(FORGEJO, t)) == [] assert msgs(v.validate_case(PUBLIC, t)) == [] + for chosen in ["bao:platform/workloads/forgejo/netkingdom-oidc-client", "hvs.CAESIabcdefghijklmnop"]: + assert msgs(v.validate_case(with_params(FORGEJO, secret_ref=chosen), t)), chosen -def test_secret_value_instead_of_reference_is_refused(): - t = as_implemented() - found = msgs(v.validate_case(with_params(FORGEJO, secret_ref="hvs.CAESIabcdefghijklmnop"), t)) - assert found +def test_confidential_materialization_reuses_the_keycape_family(): + issues, r = v.render_case(FORGEJO, as_implemented(), DECLS) + assert msgs(issues) == [] + m = {i["id"]: i for i in r["materialization"]} + assert m["client-secret-path"]["template"] == "platform/workloads/keycape/forgejo" + assert m["keycape-reader-policy"]["template"] == "workload-kv-read-keycape-forgejo-eso" + assert m["consumer-reader-policy"]["template"] == "workload-kv-read-keycape-forgejo-forgejo" + assert m["eso-role"]["template"] == "external-secrets-keycape-forgejo" + assert m["catalog-entry"]["template"] == "forgejo-keycape-client" + for pid in ("keycape-reader-policy", "consumer-reader-policy"): + body = m[pid]["body"] + assert body.count("path ") == 1 and 'path "platform/data/workloads/keycape/forgejo" {' in body + assert 'capabilities = ["read"]' in body and "metadata" not in body and "list" not in body + assert "token_no_default_policy: true" in m["eso-role"]["body"] + assert "workload-kv-read-keycape-forgejo-eso" in m["eso-role"]["body"] + + +def test_policy_without_body_is_refused(): + t = copy.deepcopy(TYPE) + t["spec"]["materialization"][1].pop("body") + assert any("body" in m for m in msgs(v.validate_type(t))) def test_scopes_are_a_closed_list(): @@ -110,7 +128,7 @@ def test_confidential_case_renders_custody_delivery_and_one_attended_session(): assert [s["id"] for s in r["follow_through"] if s.get("attended")] == ["bind-env", "register-client"] assert "keycape-client.py apply" in steps["register-client"]["entry_point"] assert "sso/keycape-forgejo-client" in steps["deliver-secret"]["entry_point"] - assert steps["mint-secret"]["entry_point"].endswith("bao:platform/workloads/forgejo/netkingdom-oidc-client") + assert steps["mint-secret"]["entry_point"].endswith("bao:platform/workloads/keycape/forgejo") assert steps["hand-over"]["owner"] == "railiance-apps" plan = r["composition"] assert plan["execution"]["permitted"] is False diff --git a/security-case-types/oidc-browser-relying-party/type.yaml b/security-case-types/oidc-browser-relying-party/type.yaml index bb02562..b3edb4b 100644 --- a/security-case-types/oidc-browser-relying-party/type.yaml +++ b/security-case-types/oidc-browser-relying-party/type.yaml @@ -62,15 +62,6 @@ spec: constraints: {pattern: "^[a-z][a-z0-9-]*=[a-z][a-z0-9._-]*(\\+[a-z][a-z0-9._-]*)*(;[a-z][a-z0-9-]*=[a-z][a-z0-9._-]*(\\+[a-z][a-z0-9._-]*)*)*$"} sensitivity: operational description: "Initial memberships as group=user+user;group=user. Usernames are directory usernames, never secrets." - - name: secret_ref - type: string - required: false - case_settable: true - constraints: - pattern: "^bao:platform/workloads/[a-z0-9/_-]+$" - required_when: "client_type == confidential" - sensitivity: secret_reference - description: OpenBao custody path for a confidential client's secret. Omitted for public clients. - name: consuming_service type: string case_settable: true @@ -105,22 +96,49 @@ spec: net-kingdom.keycape-client-registration.mfa_required: mfa_required materialization: + # Placement follows the live CCR-2026-0033 reference (secrets-engine-openbao, + # ops-mason plans/keycape-secrets-engine-openbao-client-custody.md). The path + # is derived, not case-settable, and the policies reuse the existing + # workload-kv-read-keycape-- family (MASON-WP-0010-T01). - id: client-secret-path kind: kv_path when: "client_type == confidential" - template: "{secret_ref}" + template: "platform/workloads/keycape/{client_id}" + body: "field CLIENT_SECRET; minted by the credential broker, never written by hand" - id: keycape-reader-policy kind: openbao_policy when: "client_type == confidential" - template: "keycape-client-{client_id}-read" + template: "workload-kv-read-keycape-{client_id}-eso" + body: | + path "platform/data/workloads/keycape/{client_id}" {{ + capabilities = ["read"] + }} + description: KeyCape reads its client secret only through ESO. No metadata, list, parent or sibling access. - id: consumer-reader-policy kind: openbao_policy when: "client_type == confidential" - template: "{consuming_service}-oidc-client-{client_id}-read" + template: "workload-kv-read-keycape-{client_id}-{consuming_service}" + body: | + path "platform/data/workloads/keycape/{client_id}" {{ + capabilities = ["read"] + }} + description: The consuming service reads the same secret. No metadata, list, parent or sibling access. + - id: eso-role + kind: openbao_role + when: "client_type == confidential" + template: "external-secrets-keycape-{client_id}" + body: | + auth: kubernetes + bound_service_account_names: [external-secrets] + bound_service_account_namespaces: [external-secrets] + token_policies: [workload-kv-read-keycape-{client_id}-eso] + token_no_default_policy: true + token_ttl: 15m + token_max_ttl: 15m + description: The Kubernetes-auth role deliver-secret uses; bound to the ESO service account only. - id: catalog-entry kind: catalog_entry - template: "oidc-client-{client_id}" - + template: "{consuming_service}-keycape-client" follow_through: - id: ensure-groups owner: net-kingdom @@ -131,15 +149,15 @@ spec: - id: mint-secret owner: railiance-platform when: "client_type == confidential" - entry_point: "railiance-platform credential broker: generate a client secret into {secret_ref}" + 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" warden_executes: true attended: false - id: deliver-secret owner: railiance-platform when: "client_type == confidential" - entry_point: "ExternalSecret sso/keycape-{client_id}-client (key client-secret) from {secret_ref}" - auth: "railiance-platform ESO delivery through the platform ClusterSecretStore; no value leaves custody" + entry_point: "ExternalSecret sso/keycape-{client_id}-client (key client-secret) from bao:platform/workloads/keycape/{client_id}" + auth: "railiance-platform ESO delivery through role external-secrets-keycape-{client_id}; no value leaves custody" warden_executes: false attended: false - id: bind-env @@ -168,7 +186,7 @@ spec: all attended steps of a run into that one session. - id: hand-over owner: "{consumer_owner_repo}" - entry_point: "configure {consuming_service} with issuer {issuer}, client {client_id} and custody {secret_ref}" + entry_point: "configure {consuming_service} with issuer {issuer}, client {client_id} and custody bao:platform/workloads/keycape/{client_id}" auth: "the consumer owner's own deployment path" warden_executes: false attended: false @@ -184,12 +202,12 @@ spec: reads_secret_value: false - id: custody-denied-to-default when: "client_type == confidential" - check: "a default-only OpenBao identity has no read capability on {secret_ref}" + 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 {consuming_service}-oidc-client-{client_id}-read grants read on {secret_ref} (capabilities check only)" + 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 @@ -210,20 +228,23 @@ spec: action: "delete ExternalSecret sso/keycape-{client_id}-client and its target" - reverses: mint-secret when: "client_type == confidential" - action: "destroy the secret at {secret_ref}" + action: "covered by client-secret-path: one metadata delete, after deliver-secret is reversed" - reverses: ensure-groups action: "identity-provisioner POST /v1/groups/revoke 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: "remove the KV path {secret_ref}" + 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 keycape-client-{client_id}-read" + action: "delete policy workload-kv-read-keycape-{client_id}-eso, after eso-role" - reverses: consumer-reader-policy when: "client_type == confidential" - action: "delete policy {consuming_service}-oidc-client-{client_id}-read" + action: "delete policy workload-kv-read-keycape-{client_id}-{consuming_service}" - reverses: catalog-entry - action: "remove catalog entry oidc-client-{client_id}" + action: "remove catalog entry {consuming_service}-keycape-client" acceptance: accepted_by: [owner, founder] diff --git a/sso-mfa/k8s/keycape/keycape-client.py b/sso-mfa/k8s/keycape/keycape-client.py index d4540a0..35a0010 100644 --- a/sso-mfa/k8s/keycape/keycape-client.py +++ b/sso-mfa/k8s/keycape/keycape-client.py @@ -79,6 +79,8 @@ def client_from_params(params): require(scopes[0] == 'openid' and set(scopes) <= SCOPES and len(set(scopes)) == len(scopes), 'allowed_scopes_invalid') require(params['client_type'] in ('confidential', 'public'), 'client_type_invalid') + # The type derives custody at platform/workloads/keycape/; a case cannot choose it. + require('secret_ref' not in params, 'secret_ref_is_derived_by_the_type') require(params['mfa_required'] in ('true', 'false'), 'mfa_required_invalid') client = { 'clientId': cid, @@ -89,7 +91,6 @@ def client_from_params(params): 'clientType': params['client_type'], } if params['client_type'] == 'confidential': - require(str(params.get('secret_ref', '')).startswith('bao:'), 'confidential_client_needs_custody_reference') client['secretRef'] = 'env:' + env_name(cid) if params['mfa_required'] == 'true': client['mfaRequired'] = True diff --git a/sso-mfa/k8s/keycape/test_keycape_client.py b/sso-mfa/k8s/keycape/test_keycape_client.py index ebe0190..1cf3d62 100644 --- a/sso-mfa/k8s/keycape/test_keycape_client.py +++ b/sso-mfa/k8s/keycape/test_keycape_client.py @@ -27,7 +27,7 @@ FORGEJO_PARAMS = { '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', 'secret_ref': 'bao:platform/workloads/forgejo/netkingdom-oidc-client', + 'mfa_required': 'true', } FORGEJO = kc.client_from_params(FORGEJO_PARAMS) @@ -57,7 +57,6 @@ class ClientRecordTests(unittest.TestCase): def test_public_record_has_no_secret(self): params = dict(FORGEJO_PARAMS, client_type='public', mfa_required='false') - params.pop('secret_ref') client = kc.client_from_params(params) self.assertNotIn('secretRef', client) self.assertNotIn('mfaRequired', client) @@ -65,7 +64,7 @@ class ClientRecordTests(unittest.TestCase): def test_invalid_params_refused(self): for change in [dict(redirect_uris='https://x.example/*'), dict(redirect_uris='http://x.example/cb'), dict(allowed_scopes='profile,openid'), dict(allowed_scopes='openid,admin'), - dict(client_type='weird'), dict(secret_ref='plain-value'), dict(client_id='Bad_Id')]: + dict(client_type='weird'), dict(secret_ref='bao:platform/workloads/forgejo/x'), dict(client_id='Bad_Id')]: with self.subTest(change=change), self.assertRaises(kc.Refused): kc.client_from_params(dict(FORGEJO_PARAMS, **change)) diff --git a/tools/security-case-types/security_case_validator.py b/tools/security-case-types/security_case_validator.py index 1dfc54f..6d4482a 100644 --- a/tools/security-case-types/security_case_validator.py +++ b/tools/security-case-types/security_case_validator.py @@ -256,6 +256,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")) _validate_templates(items, path, params, issues) diff --git a/workplans/NK-WP-0048-security-case-types.md b/workplans/NK-WP-0048-security-case-types.md index 927489a..7a0dbb9 100644 --- a/workplans/NK-WP-0048-security-case-types.md +++ b/workplans/NK-WP-0048-security-case-types.md @@ -208,6 +208,25 @@ sides. The dispatcher delivered it to informed-decision (ledger 7 → 8 delivered). T05 waits for the founder's answer in informed-decision, which resolves hub decision `e418263e`. +2026-10-08: ops-mason's review (MASON-WP-0010-T01) folded into the type, still +1.0 `proposed`: `secret_ref` is no longer a parameter, the type derives +`platform/workloads/keycape/{client_id}`; policies are +`workload-kv-read-keycape-{client_id}-eso` and +`workload-kv-read-keycape-{client_id}-{consuming_service}`, each with a templated +body (`read` on that one data path); new `openbao_role` +`external-secrets-keycape-{client_id}` (Kubernetes auth, ESO service account, +15m, no default policy); catalog entry `{consuming_service}-keycape-client`; +the two rollback steps on the KV path collapse into one metadata delete. The +schema and validator now require a `body` on policy and role items. 105 tests +pass. Two consequences: the delivered memo describes the type before these +changes (a case-set custody reference, two differently named policies, no ESO +role), so the founder answers on a superseded shape unless the memo is +re-sent; and Forgejo's custody path moves from the accepted request's proposed +`platform/workloads/forgejo/netkingdom-oidc-client` to +`platform/workloads/keycape/forgejo`, which key-cape (KEY-WP-0038) and +railiance-apps (RAPPS-WP-0016-T02) need to adopt in T06. ops-warden's review +(WARDEN-WP-0044-T01) is still in progress. + ```task id: NK-WP-0048-T06 status: wait