NK-WP-0056-T14 done: SecurityCaseType oidc-service-client 0.1 (proposed)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Confidential client_credentials service clients: one audience, closed
resource scopes, fixed service subject and tenant, at most 15m; secret minted
in custody, read only by KeyCape (ESO) and the consumer's own bound
Kubernetes-auth role (no Secret copy). First case nk-bind-executor-approval
(approval-engine create/read/consume as service:nk-bind-executor). Capability
net-kingdom.keycape-service-client-registration; keycape-client.py renders
service clients in key-cape's shape (adoption of secrets-engine-approval
reproduces key-cape's record). Acknowledgement memo r1 validated against
informed-decision intake and State Hub dispatcher; hub decision f8044f81.
Test fixture's synthetic signing key is now computed, not a literal (secret
scan generic-api-key).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5502@bnt-lap001
Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
This commit is contained in:
tegwick 2026-10-31 21:30:21 +01:00
parent 8cecf8949a
commit 5d5f15df55
13 changed files with 1184 additions and 4 deletions

View file

@ -356,7 +356,7 @@
| task | NK-WP-0056-T11 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
| task | NK-WP-0056-T12 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
| task | NK-WP-0056-T13 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
| task | NK-WP-0056-T14 | progress | — | workplans/NK-WP-0056-bounded-executor-path.md |
| task | NK-WP-0056-T14 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
| task | NK-WP-0056-T15 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md |
| task | NK-WP-0056-T16 | wait | — | workplans/NK-WP-0056-bounded-executor-path.md |
| task | NK-WP-0056-T17 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |

View file

@ -0,0 +1,135 @@
apiVersion: netkingdom.io/playbook-capability/v0.1
kind: PlaybookCapabilityDeclaration
metadata:
id: net-kingdom.keycape-service-client-registration
name: KeyCape confidential service client registration (live registry)
owner: net-kingdom
repo: net-kingdom
domain: infotech
contract_version: "0.1"
source_links:
- label: Entry point
path: sso-mfa/k8s/keycape/keycape-client.py
- label: Tests
path: sso-mfa/k8s/keycape/test_keycape_client.py
- label: SecurityCaseType
path: security-case-types/oidc-service-client/type.yaml
spec:
playbook:
path: sso-mfa/k8s/keycape/keycape-client.py
type: cli-sequence
invocation: keycape-client.py apply --client-file <client.yaml> --case-acceptance <record> --expected-uid <uid> --expected-resource-version <rv>
description: >-
Add or remove exactly one client_credentials service client in the live
KeyCape client registry, with the same safeguards as browser clients:
contained kubectl, cluster and issuer pins, byte-preserving merge,
compare-and-swap, KeyCape check-config, read-back, and no secret values.
Field rules are key-cape's (src/internal/config/validate.go): confidential,
env secretRef, serviceSubject and tenant required, no redirect URIs, token
lifetime 1m-1h. Replaces per-client scripts such as approval-clients-rollout.py.
capabilities:
- id: c1.lightweight-sso
tier: C1
resource_kinds:
- identities
- roles_scopes_policies
description: Register one confidential service client with the running lightweight issuer.
parameters:
- name: client_id
type: string
required: true
constraints:
pattern: ^[a-z0-9][a-z0-9-]{1,62}$
sensitivity: operational
tuning_authority: netkingdom_tunable
description: OAuth2 client_id, unique in the registry.
- name: display_name
type: string
required: true
constraints:
pattern: ^[^\n\r]{1,80}$
sensitivity: public
tuning_authority: netkingdom_tunable
description: Human-readable client name.
- name: audience
type: string
required: true
constraints:
pattern: ^[a-z0-9][a-z0-9-]{1,62}$
sensitivity: security_sensitive
tuning_authority: netkingdom_tunable
description: The single resource server the tokens are for (aud).
- name: allowed_scopes
type: string
required: true
constraints:
pattern: ^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*(,[a-z][a-z0-9-]*:[a-z][a-z0-9-]*)*$
sensitivity: security_sensitive
tuning_authority: netkingdom_tunable
description: Comma-separated resource scopes of the audience; no OIDC identity scopes.
- name: service_subject
type: string
required: true
constraints:
pattern: ^service:[a-z0-9][a-z0-9-]{1,62}$
sensitivity: security_sensitive
tuning_authority: netkingdom_tunable
description: The sub KeyCape puts into the client's tokens.
- name: tenant
type: string
required: true
constraints:
pattern: ^tenant:[a-z0-9][a-z0-9-]{1,62}$
sensitivity: security_sensitive
tuning_authority: netkingdom_tunable
description: The registration-bound tenant claim.
- name: roles
type: string
required: false
constraints:
pattern: ^[a-z][a-z0-9-]{1,62}(,[a-z][a-z0-9-]{1,62})*$
sensitivity: operational
tuning_authority: netkingdom_tunable
description: Comma-separated roles carried in the token.
- name: token_lifetime
type: string
required: true
constraints:
pattern: ^(5|10|15)m$
sensitivity: security_sensitive
tuning_authority: netkingdom_tunable
description: Access-token lifetime; at most 15m (stricter than KeyCape's 1h ceiling).
responsibilities:
- resource_kind: identities
owner: net-kingdom
resources:
- oidc:static-clients
repo_owns: Live registry change for exactly this client, its idempotency and removal; field rules are key-cape's canonical model.
netkingdom_orchestrates: Supply the case's validated, non-secret client fields.
- resource_kind: roles_scopes_policies
owner: key-cape
resources:
- iam-profile:client-scope-allowlists
repo_owns: Issue client_credentials tokens only for the registered scopes, audience, subject and tenant; refuse excess scopes.
netkingdom_orchestrates: Choose audience, scopes, subject and lifetime per SecurityCaseType.
trust:
requires:
- state: runtime_secret_trust
readiness_checks: []
- state: runtime_identity_trust
readiness_checks: []
satisfies:
- state: tenant_onboarding_trust
readiness_checks:
- id: service-token-claims
description: A client_credentials exchange for each allowed scope returns a token with the registered sub, aud, tenant and lifetime.
evidence: The consuming service's own exchange, reported as claims only (no secret or token value).
- id: service-token-refuses-excess-scope
description: A client_credentials exchange for a scope outside allowed_scopes is refused.
evidence: KeyCape returns invalid_scope.
catalog:
publish: capabilities/playbooks/net-kingdom.keycape-service-client-registration.yaml
maturity: implemented
consumers:
- netkingdom-security-scenario-composer
- netkingdom-security-case-types

View file

@ -0,0 +1,129 @@
# oidc-service-client 0.1
An internal workload obtains short-lived KeyCape access tokens for one resource server with the `client_credentials`
grant. The type covers:
- the client registration: audience, resource scopes, service subject, tenant, roles and lifetime;
- the client secret, minted straight into OpenBao custody and readable only by KeyCape (through ESO) and by the
consuming workload (through its own Kubernetes-auth login).
Canon: `canon/standards/security-case-types_v0.1.md`. Status: **proposed** (2026-10-31): complete, tests green,
Decision Memo prepared (`decision/`, hub decision `f8044f81`). Next: `acknowledged` by the founder through
informed-decision. Its first case and
reference instance is the bounded executor's approval client (NK-WP-0056-T14, `examples/nk-bind-executor-approval.yaml`).
## Purpose
KeyCape supports confidential `client_credentials` clients (`src/internal/config/validate.go`: confidential, `env:`
secretRef, `serviceSubject` and `tenant` required, no redirect URIs, lifetime 1m–1h). Every such client so far was
registered by its own script:
- `sso-mfa/k8s/keycape/approval-clients-rollout.py` (`secrets-engine-approval`, `approval-engine-operator`);
- `secrets-engine-openbao-client-rollout.py`.
Each script hard-codes its client. The browser type (`oidc-browser-relying-party`) names service clients with a
resource audience, tenant and custom scopes as needing "a separate type". This is that type.
Its first case is driven by a live need. approval-engine accepts `POST /v1/approvals` only when `binding.actor`
equals the token's `sub`, so the bounded executor must hold its own client to file the approval it later consumes.
### Reference instances and fit
| Client | Fits 0.1 | Note |
| --- | --- | --- |
| nk-bind-executor-approval (new) | yes | first case |
| secrets-engine-approval | yes | adoption would not change its live registration (canon section 9) |
| approval-engine-operator | partly | its scope list is wider than any single workload should hold; adopt only after review |
## Threat model
**Assets:**
- the client secret, which lets its holder obtain tokens as `service_subject`;
- the tokens, which a resource server treats as the workload's identity: for approval-engine, the right to create
approvals with that actor and to consume them.
**Actors:**
- an anonymous network caller;
- another workload in the same namespace;
- a coding agent;
- an operator;
- a compromised consuming workload.
**Controls:**
- The secret is generated inside custody (`mint-secret`, `generated_in_custody`). It is never returned, never written
by hand and never in Git.
- Only two readers exist: KeyCape through the ESO role, and the consumer through its own role. The consumer role is
bound to one ServiceAccount in one namespace with audience `openbao`, and its policy reads one path. No metadata,
list, parent or sibling access.
- There is no Secret copy in the consumer's namespace. The consumer reads natively per call, so a pod in that
namespace without the ServiceAccount has nothing to mount.
- Tokens last at most 15m and there is no refresh token.
- Scopes are a closed list per case. KeyCape refuses an excess scope (`invalid_scope`), and the type verifies that
refusal.
- The subject and tenant are fixed by the registration. The workload cannot choose them.
- What a compromised consumer can do is bounded by its scopes at one audience. For approval-engine that means:
- it can create approvals naming itself as actor, which still need a human entry under `human_control`;
- it can consume approvals of its tenant, which APPROVAL-WP-0007-T02 `actor_consumes` narrows.
## Alternatives considered
- **Keep per-client scripts.** Each repeats the custody, merge and verification logic, and each needs its own
attended founder run. This is the pattern the security case canon replaced for browser clients.
- **Extend `oidc-browser-relying-party`.** The two shapes differ in grant, required fields, the meaning of `sub`, and
how the consumer receives the secret. A combined type would carry parameters that are forbidden in one mode
(KeyCape rejects `serviceSubject` on browser clients). It would be a looser validation, which is a major change by
canon section 6.
- **ESO-deliver the secret into the consumer's namespace** (as the browser type does). Rejected for service clients:
- a long-lived Secret copy beside the workload is readable by anything that can mount Secrets there;
- the bounded executor's lanes already read natively per call (CCR-2026-0058 and CCR-2026-0059).
- **A static bearer instead of a client.** approval-engine expects KeyCape-issued tokens with `aud`, `sub`, tenant and
assurance. A stored bearer would bypass expiry and the subject binding.
## Attended steps
`bind-env` and `register-client` change the live identity provider: the KeyCape Deployment and its client registry,
which is a Kubernetes Secret. Agents do not read or write Secrets. Both steps run in one attended operator session,
`bind-env` first, because KeyCape resolves a confidential client's secret at start and fails closed without it. The
restart drops in-flight logins for every client, so runs go in a quiet window. `mint-secret` and `deliver-secret`
are unattended.
## Lanes, value handling and gates
| Step | Lane | Value handling |
| --- | --- | --- |
| mint-secret | keycape-client-secret-generate (railiance-platform broker) | generated in custody, never returned |
| deliver-secret | ESO role `external-secrets-keycape-<client_id>` | none |
| bind-env, register-client | key-cape-oidc-login (attended) | none |
| hand-over | the consumer's own deployment path | none; the consumer reads through `workload-kv-read-keycape-<client_id>-<consuming_service>` |
## Known limits
- **One audience per case.** A workload that calls two resource servers holds two cases.
- **Scopes are not checked against the audience's own scope list.** The type admits any `resource:verb` shape; the
audience owner reviews the case. A stricter check would need the audience to publish its scopes.
- **KeyCape's client registry is still a Secret.** Registration stays attended until KeyCape loads clients from a
non-secret registry.
- **Rotation is mint-secret, then the consumer's next exchange.** KeyCape reads the secret at start, so a rotation
also restarts KeyCape. That is not automated.
- **Adopting `approval-engine-operator`** needs a scope review first; see the reference table.
## Verification and rollback
Verification never reads the secret or a token:
- the consumer reports the claims of its own exchanges (sub, aud, tenant, scope and lifetime);
- an excess scope is refused;
- a default identity has no access to custody;
- the consumer role admits only its ServiceAccount and audience;
- the consumer policy grants read on the one path;
- ESO delivery to KeyCape reports Ready.
Rollback reverses every follow-through step and every materialized item in dependency order: consumer configuration,
registration, env binding, delivery, custody, roles before their policies, then the catalog entry.
## History
- 0.1 (2026-10-31): first version, for NK-WP-0056-T14; proposed with memo `nk-wp-0056-t14-oidc-service-client-r1`.

View file

@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Build the informed-decision memo intake envelope that acknowledges oidc-service-client 0.1.
Writes intake.json beside this file and validates it with informed-decision's own intake validator and State Hub's
dispatcher contract, when those checkouts are present beside net-kingdom. The envelope is queued by an operator
through the State Hub memo dispatcher (informed-decision deploy/lifecycle/README.md); this script never submits
anything.
"""
import hashlib
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
HUB_DECISION = "f8044f81-f94c-483a-81f9-4f2b02a60d5f"
# Each revision needs a new id: the dispatcher refuses new content under a delivered idempotency key.
MEMO_ID = "nk-wp-0056-t14-oidc-service-client-r1"
ITEM = f"{MEMO_ID}-packet"
TARGET = "oidc-service-client@0.1"
packet = (HERE / "packet.md").read_text()
digest = "sha256:" + hashlib.sha256(packet.encode("utf-8")).hexdigest()
locator = {"kind": "work_record", "workplan": "NK-WP-0056", "task": "NK-WP-0056-T14"}
envelope = {
"kind": "informed-decision.memo-intake",
"version": 1,
"idempotency_key": f"state-hub.{HUB_DECISION}.{MEMO_ID}",
"memo_class": "decision-record",
"review_group": "net-kingdom-admins",
"drafted_by": "net-kingdom NK-WP-0056 (claude-code)",
"serves": {"hub_decision_id": HUB_DECISION, "resolves": "on-answer"},
"approval_intent": {
"action": "acknowledge",
"principal": "net-kingdom-admins",
"purpose": "Admit oidc-service-client 0.1 as a SecurityCaseType (canon security-case-types_v0.1 section 5).",
"target": {"id": TARGET, "type": "security-case-type", "system": "net-kingdom"},
},
"memo": {
"id": MEMO_ID,
"version": 1,
"question": ("Acknowledge oidc-service-client 0.1 as the reusable SecurityCaseType for internal workloads "
"that obtain KeyCape client_credentials tokens for one resource server?"),
"requested_act": "approve",
"binding_level": "organizational",
"brief": (
"NK-WP-0056-T14. Acknowledging admits the shape of confidential service clients: one audience, a closed "
"list of resource scopes, a fixed service subject and tenant, at most 15m, the secret minted in custody "
"and read only by KeyCape (ESO) and the consumer's own bound role. It registers nothing and accepts no "
"case; the first case, the bounded executor's approval client, keeps its own acceptance. One attended "
"operator session per case remains, because KeyCape's live client registry is a Kubernetes Secret."
),
"binding": {
"justification": None,
"principal": {"display_name": "Named reviewer in net-kingdom-admins", "id": "pending-human-session",
"identifiers": [], "kind": "person", "role": "reviewer"},
"target": {"environment": "prod", "id": TARGET, "kind": "security-case-type",
"label": "SecurityCaseType oidc-service-client 0.1", "requires_new_bind": False},
"terms": None,
},
"step_kind": "approve",
"packet": [{"item_id": ITEM, "label": ITEM, "hash": digest}],
"highlights": [
{"id": f"{MEMO_ID}-h1", "item_id": ITEM, "severity": "critical", "required_ack": True,
"locator": locator,
"note": ("Acknowledging changes nothing live and accepts no case. It decides the shape every future "
"service client case will follow, including who may read the client secret.")},
{"id": f"{MEMO_ID}-h2", "item_id": ITEM, "severity": "warning", "required_ack": True,
"locator": locator,
"note": ("The consumer reads the secret natively per call (no Secret copy in its namespace); one "
"attended operator session per case remains for bind-env and register-client.")},
],
"locale": "en",
"approval_id": None,
"approval_binding_digest": None,
"sealed": False,
},
"packet": [{"item_id": ITEM, "media_type": "text/markdown", "content": packet}],
}
out = HERE / "intake.json"
out.write_text(json.dumps(envelope, indent=2, ensure_ascii=False) + "\n")
print(f"wrote {out.name}; packet {digest}")
infd = HERE.parents[3] / "informed-decision"
if infd.is_dir():
sys.path.insert(0, str(infd))
from informed_decision import intake # noqa: E402
grant = intake.SubmitterGrant(submitter_id="state-hub", memo_classes=frozenset({"decision-record"}),
review_groups=frozenset({"net-kingdom-admins"}))
validated = intake.validate_submission(envelope, submitter_id="state-hub", grant=grant)
print("informed-decision intake validation: PASS", validated.memo.id)
else:
print("informed-decision checkout not found beside net-kingdom; validation skipped")
hub = HERE.parents[3] / "state-hub"
if hub.is_dir():
sys.path.insert(0, str(hub))
from api.services import informed_decision_contract as contract # noqa: E402
contract.validate_envelope(envelope)
print("state-hub dispatcher envelope validation: PASS")
else:
print("state-hub checkout not found beside net-kingdom; dispatcher validation skipped")

View file

@ -0,0 +1,93 @@
{
"kind": "informed-decision.memo-intake",
"version": 1,
"idempotency_key": "state-hub.f8044f81-f94c-483a-81f9-4f2b02a60d5f.nk-wp-0056-t14-oidc-service-client-r1",
"memo_class": "decision-record",
"review_group": "net-kingdom-admins",
"drafted_by": "net-kingdom NK-WP-0056 (claude-code)",
"serves": {
"hub_decision_id": "f8044f81-f94c-483a-81f9-4f2b02a60d5f",
"resolves": "on-answer"
},
"approval_intent": {
"action": "acknowledge",
"principal": "net-kingdom-admins",
"purpose": "Admit oidc-service-client 0.1 as a SecurityCaseType (canon security-case-types_v0.1 section 5).",
"target": {
"id": "oidc-service-client@0.1",
"type": "security-case-type",
"system": "net-kingdom"
}
},
"memo": {
"id": "nk-wp-0056-t14-oidc-service-client-r1",
"version": 1,
"question": "Acknowledge oidc-service-client 0.1 as the reusable SecurityCaseType for internal workloads that obtain KeyCape client_credentials tokens for one resource server?",
"requested_act": "approve",
"binding_level": "organizational",
"brief": "NK-WP-0056-T14. Acknowledging admits the shape of confidential service clients: one audience, a closed list of resource scopes, a fixed service subject and tenant, at most 15m, the secret minted in custody and read only by KeyCape (ESO) and the consumer's own bound role. It registers nothing and accepts no case; the first case, the bounded executor's approval client, keeps its own acceptance. One attended operator session per case remains, because KeyCape's live client registry is a Kubernetes Secret.",
"binding": {
"justification": null,
"principal": {
"display_name": "Named reviewer in net-kingdom-admins",
"id": "pending-human-session",
"identifiers": [],
"kind": "person",
"role": "reviewer"
},
"target": {
"environment": "prod",
"id": "oidc-service-client@0.1",
"kind": "security-case-type",
"label": "SecurityCaseType oidc-service-client 0.1",
"requires_new_bind": false
},
"terms": null
},
"step_kind": "approve",
"packet": [
{
"item_id": "nk-wp-0056-t14-oidc-service-client-r1-packet",
"label": "nk-wp-0056-t14-oidc-service-client-r1-packet",
"hash": "sha256:0ed6b3a56d9e4d67d96e51f9ea0e4ddfaca60dee5db9e46f1680576f007758fe"
}
],
"highlights": [
{
"id": "nk-wp-0056-t14-oidc-service-client-r1-h1",
"item_id": "nk-wp-0056-t14-oidc-service-client-r1-packet",
"severity": "critical",
"required_ack": true,
"locator": {
"kind": "work_record",
"workplan": "NK-WP-0056",
"task": "NK-WP-0056-T14"
},
"note": "Acknowledging changes nothing live and accepts no case. It decides the shape every future service client case will follow, including who may read the client secret."
},
{
"id": "nk-wp-0056-t14-oidc-service-client-r1-h2",
"item_id": "nk-wp-0056-t14-oidc-service-client-r1-packet",
"severity": "warning",
"required_ack": true,
"locator": {
"kind": "work_record",
"workplan": "NK-WP-0056",
"task": "NK-WP-0056-T14"
},
"note": "The consumer reads the secret natively per call (no Secret copy in its namespace); one attended operator session per case remains for bind-env and register-client."
}
],
"locale": "en",
"approval_id": null,
"approval_binding_digest": null,
"sealed": false
},
"packet": [
{
"item_id": "nk-wp-0056-t14-oidc-service-client-r1-packet",
"media_type": "text/markdown",
"content": "# Acknowledge SecurityCaseType oidc-service-client 0.1\n\nWorkplan: NK-WP-0056 (net-kingdom), task NK-WP-0056-T14\nHub decision: f8044f81-f94c-483a-81f9-4f2b02a60d5f\nCanon: canon/standards/security-case-types_v0.1.md (accepted 2026-10-08), section 5\nType: security-case-types/oidc-service-client/ (type.yaml, README.md, tests)\nFirst case: examples/nk-bind-executor-approval.yaml\n\n## Question\n\nAcknowledge oidc-service-client 0.1 as the reusable SecurityCaseType for internal workloads that obtain KeyCape\nclient_credentials tokens for one resource server?\n\n## One act\n\nMove the type from `proposed` to `acknowledged`. From then on, a confidential service client is a SecurityCase of\nthis type:\n\n- its owner fills in the parameters (audience, resource scopes, service subject, tenant, lifetime of at most 15m,\n consuming ServiceAccount);\n- the case is accepted by the owner and the founder (risk class medium);\n- ops-mason materializes it, and ops-warden follows through.\n\nThe type becomes `implemented` only after its first case is live and verified. That case is the bounded executor's\napproval client `nk-bind-executor-approval`.\n\n## Why now\n\napproval-engine accepts `POST /v1/approvals` only when `binding.actor` equals the token's `sub`. The bounded\nexecutor (NK-WP-0056) must therefore file its own approval as `service:nk-bind-executor`, which needs its own KeyCape\nclient. Every existing service client was registered by a one-off script. The browser type names service clients\nas needing \"a separate type\".\n\n## What acknowledging does not do\n\n- It registers no client, mints no secret, and changes nothing live.\n- It accepts no case. `nk-bind-executor-approval` keeps its own acceptance.\n- It does not change `oidc-browser-relying-party`.\n\n## Shape (decided here, then reused by every case)\n\n- **Grant:** `client_credentials` only. Confidential, an `env:` secretRef, `serviceSubject` and `tenant`, no\n redirect URIs. These are KeyCape's own rules. The type is stricter: a closed list of resource scopes, and at most\n 15m.\n- **Custody:** `platform/workloads/keycape/<client_id>`, minted inside custody by the railiance-platform broker and\n never returned. Exactly two readers:\n - KeyCape, through ESO;\n - the consuming workload, through its own Kubernetes-auth role (one ServiceAccount, one namespace, audience\n `openbao`, 15m, one policy).\n- **No Secret copy beside the consumer.** It reads per call, as the bounded executor's lanes already do\n (CCR-2026-0058 and CCR-2026-0059).\n- **Verification reads no value:**\n - the consumer reports claims (sub, aud, tenant, scope and lifetime);\n - an excess scope is refused;\n - custody is denied to a default identity;\n - the consumer role admits only its ServiceAccount and audience.\n- **Rollback** covers every step and every materialized item.\n\n## Attended steps\n\nOne attended operator session per case remains (`bind-env`, then `register-client`), because KeyCape's live client\nregistry is a Kubernetes Secret and KeyCape reads the client secret at start. Removing it needs a KeyCape change and\na new type version.\n\n## Alternatives considered\n\n- **Keep per-client scripts:** repeats custody and verification per client.\n- **Extend the browser type:** a looser validation and a major change. KeyCape rejects `serviceSubject` on browser\n clients.\n- **ESO-deliver into the consumer's namespace:** leaves a long-lived Secret copy beside the workload.\n- **A static bearer:** bypasses expiry and the subject binding.\n\n## Reviews requested before you answer\n\n- ops-mason: the materialization section;\n- ops-warden: the follow-through and its lanes;\n- key-cape: the field rules;\n- railiance-platform: the mint lane and the consumer role.\n\nIf a review changes the type, a new memo revision replaces this one.\n"
}
]
}

View file

@ -0,0 +1,80 @@
# Acknowledge SecurityCaseType oidc-service-client 0.1
Workplan: NK-WP-0056 (net-kingdom), task NK-WP-0056-T14
Hub decision: f8044f81-f94c-483a-81f9-4f2b02a60d5f
Canon: canon/standards/security-case-types_v0.1.md (accepted 2026-10-08), section 5
Type: security-case-types/oidc-service-client/ (type.yaml, README.md, tests)
First case: examples/nk-bind-executor-approval.yaml
## Question
Acknowledge oidc-service-client 0.1 as the reusable SecurityCaseType for internal workloads that obtain KeyCape
client_credentials tokens for one resource server?
## One act
Move the type from `proposed` to `acknowledged`. From then on, a confidential service client is a SecurityCase of
this type:
- its owner fills in the parameters (audience, resource scopes, service subject, tenant, lifetime of at most 15m,
consuming ServiceAccount);
- the case is accepted by the owner and the founder (risk class medium);
- ops-mason materializes it, and ops-warden follows through.
The type becomes `implemented` only after its first case is live and verified. That case is the bounded executor's
approval client `nk-bind-executor-approval`.
## Why now
approval-engine accepts `POST /v1/approvals` only when `binding.actor` equals the token's `sub`. The bounded
executor (NK-WP-0056) must therefore file its own approval as `service:nk-bind-executor`, which needs its own KeyCape
client. Every existing service client was registered by a one-off script. The browser type names service clients
as needing "a separate type".
## What acknowledging does not do
- It registers no client, mints no secret, and changes nothing live.
- It accepts no case. `nk-bind-executor-approval` keeps its own acceptance.
- It does not change `oidc-browser-relying-party`.
## Shape (decided here, then reused by every case)
- **Grant:** `client_credentials` only. Confidential, an `env:` secretRef, `serviceSubject` and `tenant`, no
redirect URIs. These are KeyCape's own rules. The type is stricter: a closed list of resource scopes, and at most
15m.
- **Custody:** `platform/workloads/keycape/<client_id>`, minted inside custody by the railiance-platform broker and
never returned. Exactly two readers:
- KeyCape, through ESO;
- the consuming workload, through its own Kubernetes-auth role (one ServiceAccount, one namespace, audience
`openbao`, 15m, one policy).
- **No Secret copy beside the consumer.** It reads per call, as the bounded executor's lanes already do
(CCR-2026-0058 and CCR-2026-0059).
- **Verification reads no value:**
- the consumer reports claims (sub, aud, tenant, scope and lifetime);
- an excess scope is refused;
- custody is denied to a default identity;
- the consumer role admits only its ServiceAccount and audience.
- **Rollback** covers every step and every materialized item.
## Attended steps
One attended operator session per case remains (`bind-env`, then `register-client`), because KeyCape's live client
registry is a Kubernetes Secret and KeyCape reads the client secret at start. Removing it needs a KeyCape change and
a new type version.
## Alternatives considered
- **Keep per-client scripts:** repeats custody and verification per client.
- **Extend the browser type:** a looser validation and a major change. KeyCape rejects `serviceSubject` on browser
clients.
- **ESO-deliver into the consumer's namespace:** leaves a long-lived Secret copy beside the workload.
- **A static bearer:** bypasses expiry and the subject binding.
## Reviews requested before you answer
- ops-mason: the materialization section;
- ops-warden: the follow-through and its lanes;
- key-cape: the field rules;
- railiance-platform: the mint lane and the consumer role.
If a review changes the type, a new memo revision replaces this one.

View file

@ -0,0 +1,26 @@
# First case of oidc-service-client (NK-WP-0056-T14): the bounded executor's approval client.
# approval-engine accepts POST /v1/approvals only when binding.actor equals the token's sub, so the
# executor files its own approval request as service:nk-bind-executor, then reads and consumes it.
# Scopes follow approval-engine docs/bounded-execution-admission-carrier.md section 3.
apiVersion: netkingdom.io/security-case/v0.1
kind: SecurityCase
spec:
id: nk-bind-executor-approval
type: oidc-service-client@0.1
status: proposed
owner:
repo: net-kingdom
person: founder
parameters:
client_id: nk-bind-executor-approval
display_name: NetKingdom bounded executor (approval-engine)
audience: approval-engine
allowed_scopes: approval:create,approval:read,approval:consume
service_subject: service:nk-bind-executor
tenant: tenant:platform
roles: bounded-executor
token_lifetime: 15m
consuming_service: bounded-executor
consumer_namespace: sso
consumer_service_account: nk-bind-executor
consumer_owner_repo: net-kingdom

View file

@ -0,0 +1,119 @@
"""Type-specific tests for oidc-service-client 0.1 (NK-WP-0056-T14)."""
import copy
import importlib.util
import sys
from pathlib import Path
import yaml
TYPE_DIR = Path(__file__).resolve().parents[1]
REPO = TYPE_DIR.parents[1]
SPEC = importlib.util.spec_from_file_location(
"security_case_validator", REPO / "tools" / "security-case-types" / "security_case_validator.py")
v = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = v
SPEC.loader.exec_module(v)
KC_SPEC = importlib.util.spec_from_file_location("keycape_client", REPO / "sso-mfa/k8s/keycape/keycape-client.py")
kc = importlib.util.module_from_spec(KC_SPEC)
KC_SPEC.loader.exec_module(kc)
TYPE = yaml.safe_load((TYPE_DIR / "type.yaml").read_text())
EXECUTOR = yaml.safe_load((TYPE_DIR / "examples" / "nk-bind-executor-approval.yaml").read_text())
DECL_PATHS, _ = v.discover_declarations(TYPE, REPO.parent)
DECLS, _ = v.composer.load_declarations(DECL_PATHS)
def msgs(issues):
return [f"{i.path}: {i.message}" for i in issues if i.level == "ERROR"]
def as_implemented():
t = copy.deepcopy(TYPE)
t["spec"]["status"] = "implemented"
return t
def with_params(case, **changes):
c = copy.deepcopy(case)
for k, val in changes.items():
if val is None:
c["spec"]["parameters"].pop(k, None)
else:
c["spec"]["parameters"][k] = val
return c
def render(case=EXECUTOR):
issues, r = v.render_case(case, as_implemented(), DECLS)
assert msgs(issues) == []
return r
def test_executor_case_renders_custody_and_one_attended_session():
r = render()
order = [s["id"] for s in r["follow_through"]]
assert order == ["mint-secret", "deliver-secret", "bind-env", "register-client", "hand-over"]
attended = [s for s in r["follow_through"] if s.get("attended")]
assert [s["id"] for s in attended] == ["bind-env", "register-client"]
assert {s["lane"] for s in attended} == {"key-cape-oidc-login"}
steps = {s["id"]: s for s in r["follow_through"]}
assert steps["mint-secret"]["entry_point"].endswith("bao:platform/workloads/keycape/nk-bind-executor-approval")
assert "KEYCAPE_NK_BIND_EXECUTOR_APPROVAL_CLIENT_SECRET" in steps["bind-env"]["entry_point"]
assert steps["hand-over"]["owner"] == "net-kingdom"
assert r["execution"] == {"permitted": False}
def test_consumer_reads_natively_with_its_own_bound_role():
items = {m["id"]: m for m in render()["materialization"]}
role = yaml.safe_load(items["consumer-reader-role"]["body"])
assert items["consumer-reader-role"]["template"] == "workload-kv-read-keycape-nk-bind-executor-approval-bounded-executor"
assert role["bound_service_account_names"] == ["nk-bind-executor"]
assert role["bound_service_account_namespaces"] == ["sso"]
assert role["audience"] == "openbao"
assert role["token_policies"] == ["workload-kv-read-keycape-nk-bind-executor-approval-bounded-executor"]
assert role["token_no_default_policy"] is True and role["token_ttl"] == "15m"
policy = items["consumer-reader-policy"]["body"]
assert 'path "platform/data/workloads/keycape/nk-bind-executor-approval"' in policy
assert "metadata" not in policy and "list" not in policy and "*" not in policy
def test_no_secret_copy_is_delivered_to_the_consumer():
r = render()
deliveries = [s for s in r["follow_through"] if "ExternalSecret" in s["entry_point"]]
assert [s["entry_point"].split()[1] for s in deliveries] == ["sso/keycape-nk-bind-executor-approval-client"]
assert not any("eso" in m["id"] and "consumer" in m["id"] for m in r["materialization"])
def test_custody_path_is_derived_not_case_settable():
assert not [p for p in TYPE["spec"]["parameters"] if "path" in p["name"] or p["name"] == "secret_ref"]
issues = v.validate_case(with_params(EXECUTOR, secret_ref="env:OTHER"), as_implemented())
assert msgs(issues)
def test_case_refusals():
for change in [dict(allowed_scopes="openid,approval:read"), dict(token_lifetime="1h"),
dict(service_subject="nk-bind-executor"), dict(tenant="platform"), dict(audience=None),
dict(consumer_service_account=None)]:
assert msgs(v.validate_case(with_params(EXECUTOR, **change), as_implemented())), change
def test_verification_reads_no_value_and_has_negative_checks():
checks = render()["verification"]
assert all(c["reads_secret_value"] is False for c in checks)
assert {c["id"] for c in checks if c["expect"] == "deny"} == {
"excess-scope-refused", "custody-denied-to-default", "consumer-login-bound"}
def test_keycape_record_from_the_case():
record = kc.client_from_case(EXECUTOR)
assert record["grantTypes"] == ["client_credentials"] and record["clientType"] == "confidential"
assert record["serviceSubject"] == "service:nk-bind-executor" and record["audience"] == "approval-engine"
assert record["allowedScopes"] == ["approval:create", "approval:read", "approval:consume"]
assert "redirectUris" not in record and record["tokenLifetime"] == "15m"
def test_every_step_and_item_has_a_rollback():
reversed_ids = {r["reverses"] for r in TYPE["spec"]["rollback"]}
assert {s["id"] for s in TYPE["spec"]["follow_through"]} <= reversed_ids
assert {m["id"] for m in TYPE["spec"]["materialization"]} <= reversed_ids

View file

@ -0,0 +1,56 @@
"""Tests every SecurityCaseType carries: the type validates, its example cases
validate against it, and each example renders into a composer plan.
Copied with the template; keep it and add type-specific tests beside it.
"""
import importlib.util
import sys
from pathlib import Path
import yaml
TYPE_DIR = Path(__file__).resolve().parents[1]
REPO = TYPE_DIR.parents[1]
VALIDATOR = REPO / "tools" / "security-case-types" / "security_case_validator.py"
SPEC = importlib.util.spec_from_file_location("security_case_validator", VALIDATOR)
validator = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = validator
SPEC.loader.exec_module(validator)
TYPE = yaml.safe_load((TYPE_DIR / "type.yaml").read_text())
EXAMPLES = sorted((TYPE_DIR / "examples").glob("*.yaml"))
# Provider declarations live in their owning repositories, checked out side by
# side with net-kingdom (the contract's publication path).
DECLARATIONS, DISCOVERY_ISSUES = validator.discover_declarations(TYPE, REPO.parent)
# Until the type is implemented, examples are checked as if it were.
IMPLEMENTED = TYPE["spec"]["status"] == "implemented"
def errors(issues):
return [f"{i.path}: {i.message}" for i in issues if i.level == "ERROR"]
def test_type_is_complete():
assert errors(validator.validate_type(TYPE, TYPE_DIR)) == []
def test_examples_exist_and_validate():
assert EXAMPLES
for path in EXAMPLES:
case = yaml.safe_load(path.read_text())
assert errors(validator.validate_case(case, TYPE, require_implemented=IMPLEMENTED)) == [], path.name
def test_examples_render_deterministically():
assert errors(DISCOVERY_ISSUES) == [], "provider declarations must be checked out beside net-kingdom"
declarations, load_issues = validator.composer.load_declarations(DECLARATIONS)
assert errors(load_issues) == []
for path in EXAMPLES:
case = yaml.safe_load(path.read_text())
issues, first = validator.render_case(case, TYPE, declarations, require_implemented=IMPLEMENTED)
assert errors(issues) == [], path.name
_, second = validator.render_case(case, TYPE, declarations, require_implemented=IMPLEMENTED)
assert first["digest"] == second["digest"]
assert first["execution"] == {"permitted": False}
assert first["composition"]["execution"]["permitted"] is False

View file

@ -0,0 +1,301 @@
apiVersion: netkingdom.io/security-case-type/v0.1
kind: SecurityCaseType
spec:
id: oidc-service-client
version: "0.1"
status: proposed
title: "Confidential OIDC service client (client_credentials) for an internal workload"
purpose: >-
Let an internal workload obtain short-lived KeyCape access tokens for one
resource server (client_credentials): an exact audience, a closed list of
resource scopes, a fixed service subject and tenant, and a client secret
minted straight into OpenBao custody, readable only by KeyCape (through ESO)
and by the consuming workload (through its own Kubernetes-auth login).
Replaces per-client rollout scripts such as approval-clients-rollout.py.
First case: the bounded executor's approval client (NK-WP-0056-T14).
parameters:
- name: client_id
type: string
case_settable: true
constraints: {pattern: "^[a-z0-9][a-z0-9-]{1,62}$"}
sensitivity: operational
description: OAuth2 client_id, unique in KeyCape.
- name: display_name
type: string
case_settable: true
constraints: {pattern: "^[^\\n\\r]{1,80}$"}
sensitivity: public
description: Human-readable client name.
- name: audience
type: string
case_settable: true
constraints: {pattern: "^[a-z0-9][a-z0-9-]{1,62}$"}
sensitivity: security_sensitive
description: >-
The one resource server the tokens are for (the aud claim). One client per
audience: a workload that calls two resource servers holds two cases.
- name: allowed_scopes
type: string
case_settable: true
constraints: {pattern: "^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*(,[a-z][a-z0-9-]*:[a-z][a-z0-9-]*)*$"}
sensitivity: security_sensitive
description: >-
Comma-separated resource scopes of the audience (for example
approval:read). OIDC identity scopes (openid, profile, email, groups) are
not admitted: a service token carries no directory user.
- name: service_subject
type: string
case_settable: true
constraints: {pattern: "^service:[a-z0-9][a-z0-9-]{1,62}$"}
sensitivity: security_sensitive
description: >-
The sub KeyCape writes into every token of this client. Resource servers
compare it exactly (approval-engine: binding.actor must equal it), so it
is the workload's identity at the resource server.
- name: tenant
type: string
case_settable: true
constraints: {pattern: "^tenant:[a-z0-9][a-z0-9-]{1,62}$"}
sensitivity: security_sensitive
description: The registration-bound tenant claim.
- name: roles
type: string
required: false
case_settable: true
constraints: {pattern: "^[a-z][a-z0-9-]{1,62}(,[a-z][a-z0-9-]{1,62})*$"}
sensitivity: operational
description: Comma-separated roles carried in the token, if the resource server reads them.
- name: token_lifetime
type: string
case_settable: true
constraints: {enum: ["5m", "10m", "15m"]}
sensitivity: security_sensitive
description: Access-token lifetime, at most 15m. There is no refresh token; the workload exchanges again.
- name: consuming_service
type: string
case_settable: true
constraints: {pattern: "^[a-z0-9][a-z0-9-]*$"}
sensitivity: operational
description: The workload that uses the client.
- name: consumer_namespace
type: string
case_settable: true
constraints: {pattern: "^[a-z0-9][a-z0-9-]{0,62}$"}
sensitivity: security_sensitive
description: Namespace of the consuming workload's ServiceAccount.
- name: consumer_service_account
type: string
case_settable: true
constraints: {pattern: "^[a-z0-9][a-z0-9-]{0,62}$"}
sensitivity: security_sensitive
description: The ServiceAccount whose openbao-audience token may read the client secret.
- name: consumer_owner_repo
type: string
case_settable: true
constraints: {pattern: "^[a-z0-9][a-z0-9-]*$"}
sensitivity: operational
description: The repository that owns the consuming workload.
- name: issuer
type: string
case_settable: false
default: https://kc.coulomb.social
sensitivity: security_sensitive
description: The NetKingdom issuer; fixed by the type.
composition:
authority: netkingdom
initial_trust: [runtime_secret_trust, runtime_identity_trust]
capabilities: [c1.lightweight-sso]
providers:
c1.lightweight-sso: net-kingdom.keycape-service-client-registration
parameter_bindings:
net-kingdom.keycape-service-client-registration.client_id: client_id
net-kingdom.keycape-service-client-registration.display_name: display_name
net-kingdom.keycape-service-client-registration.audience: audience
net-kingdom.keycape-service-client-registration.allowed_scopes: allowed_scopes
net-kingdom.keycape-service-client-registration.service_subject: service_subject
net-kingdom.keycape-service-client-registration.tenant: tenant
net-kingdom.keycape-service-client-registration.token_lifetime: token_lifetime
materialization:
# Custody follows oidc-browser-relying-party (platform/workloads/keycape/<client_id>,
# KeyCape reads through ESO). The consumer reads natively with its own short
# Kubernetes-auth role, as the bounded executor's lanes do (CCR-2026-0058/0059),
# so no second Secret copy exists in the consumer's namespace.
- id: client-secret-path
kind: kv_path
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
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: eso-role
kind: openbao_role
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: consumer-reader-policy
kind: openbao_policy
template: "workload-kv-read-keycape-{client_id}-{consuming_service}"
body: |
path "platform/data/workloads/keycape/{client_id}" {{
capabilities = ["read"]
}}
path "auth/token/lookup-self" {{
capabilities = ["read"]
}}
path "auth/token/revoke-self" {{
capabilities = ["update"]
}}
description: The consuming workload reads the same secret. No metadata, list, parent or sibling access.
- id: consumer-reader-role
kind: openbao_role
template: "workload-kv-read-keycape-{client_id}-{consuming_service}"
body: |
auth: kubernetes
bound_service_account_names: [{consumer_service_account}]
bound_service_account_namespaces: [{consumer_namespace}]
audience: openbao
token_policies: [workload-kv-read-keycape-{client_id}-{consuming_service}]
token_no_default_policy: true
token_ttl: 15m
token_max_ttl: 15m
token_explicit_max_ttl: 15m
description: >-
Native login for the consuming workload, one role for this lane only
(railiance-platform: policies are never combined in one role).
- id: catalog-entry
kind: catalog_entry
template: "{consuming_service}-keycape-{client_id}"
body: |
id: {consuming_service}-keycape-{client_id}
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; carries the consumer's workload reference for zone selection (ADR-0009).
follow_through:
- id: mint-secret
owner: railiance-platform
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
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
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
entry_point: "patch Deployment sso/keycape: env KEYCAPE_{client_id:env}_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 KeyCape
resolves a confidential client's secret at start and fails closed if the
binding is missing.
- id: register-client
owner: net-kingdom
entry_point: "sso-mfa/k8s/keycape/keycape-client.py apply --client-file <client-from-case> --case-acceptance <record> --check-config <keycape binary> (net-kingdom.keycape-service-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. One attended operator session applies the rendered
registration together with bind-env. KeyCape loads clients at startup, so
the restart drops in-flight logins for every client; runs go in a quiet
window. The rendered registry must pass KeyCape's own startup checks
(keycape check-config) before the restart.
- id: hand-over
owner: "{consumer_owner_repo}"
entry_point: "configure {consuming_service} with issuer {issuer}, client {client_id}, audience {audience} and its OpenBao role workload-kv-read-keycape-{client_id}-{consuming_service}; it exchanges a fresh token per call and keeps neither the secret nor a token beyond the call"
auth: "the consumer owner's own deployment path"
warden_executes: false
attended: false
value_handling: none
verification:
- id: service-token-claims
check: "{consuming_service} performs one client_credentials exchange per scope in {allowed_scopes} through its own lane and reports the token's sub, aud, tenant, scope and lifetime; expected sub {service_subject}, aud {audience}, tenant {tenant}, lifetime at most {token_lifetime} (claims only; the verifier reads no secret and no token)"
expect: present
reads_secret_value: false
- id: excess-scope-refused
check: "{consuming_service} requests a scope outside {allowed_scopes} for client {client_id}; KeyCape returns invalid_scope"
expect: deny
reads_secret_value: false
- id: custody-denied-to-default
check: "a default-only OpenBao identity has no read capability on bao:platform/workloads/keycape/{client_id}"
expect: deny
reads_secret_value: false
- id: consumer-login-bound
check: "role workload-kv-read-keycape-{client_id}-{consuming_service} admits only {consumer_namespace}/{consumer_service_account} with audience openbao; another ServiceAccount and a wrong audience are denied (capabilities and login result only)"
expect: deny
reads_secret_value: false
- id: custody-allowed-to-consumer
check: "policy workload-kv-read-keycape-{client_id}-{consuming_service} grants read on bao:platform/workloads/keycape/{client_id} and nothing else (capabilities check only)"
expect: allow
reads_secret_value: false
- id: secret-delivered
check: "the delivery for sso/keycape-{client_id}-client reports Ready=True (status only)"
expect: present
reads_secret_value: false
rollback:
- reverses: hand-over
action: "remove client {client_id} from {consuming_service}'s configuration (owner {consumer_owner_repo})"
- reverses: register-client
action: "keycape-client.py apply --operation remove for {client_id}"
- reverses: bind-env
action: "remove env KEYCAPE_{client_id:env}_CLIENT_SECRET from Deployment sso/keycape, after register-client is reversed"
- reverses: deliver-secret
action: "delete ExternalSecret sso/keycape-{client_id}-client and its target"
- reverses: mint-secret
action: "covered by client-secret-path: one metadata delete, after deliver-secret is reversed"
- reverses: client-secret-path
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
action: "delete Kubernetes-auth role external-secrets-keycape-{client_id}"
- reverses: keycape-reader-policy
action: "delete policy workload-kv-read-keycape-{client_id}-eso, after eso-role"
- reverses: consumer-reader-role
action: "delete Kubernetes-auth role workload-kv-read-keycape-{client_id}-{consuming_service}"
- reverses: consumer-reader-policy
action: "delete policy workload-kv-read-keycape-{client_id}-{consuming_service}, after consumer-reader-role"
- reverses: catalog-entry
action: "remove catalog entry {consuming_service}-keycape-{client_id}"
acceptance:
accepted_by: [owner, founder]
risk_class: medium

View file

@ -60,6 +60,13 @@ Refused = lane.Refused
require = lane.require
CASE_TYPE = re.compile(r'^oidc-browser-relying-party@1\.\d+$')
SERVICE_CASE_TYPE = re.compile(r'^oidc-service-client@0\.\d+$')
AUDIENCE = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
RESOURCE_SCOPE = re.compile(r'^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$')
SERVICE_SUBJECT = re.compile(r'^service:[a-z0-9][a-z0-9-]{1,62}$')
TENANT = re.compile(r'^tenant:[a-z0-9][a-z0-9-]{1,62}$')
ROLE = re.compile(r'^[a-z][a-z0-9-]{1,62}$')
LIFETIMES = {'5m', '10m', '15m'}
CLIENT_ID = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
# KeyCape refuses '*' and '?' at startup; OAuth 2.0 forbids fragments.
REDIRECT = re.compile(r'^https://[^*?#\s,]+$')
@ -76,6 +83,8 @@ def env_secret_name(client_id):
def client_from_params(params):
"""The KeyCape client record for a case of oidc-browser-relying-party."""
missing = {'client_id', 'display_name', 'redirect_uris', 'allowed_scopes', 'client_type', 'mfa_required'} - set(params)
require(not missing, 'missing_parameters')
cid = params['client_id']
require(CLIENT_ID.fullmatch(cid or '') is not None, 'invalid_client_id')
redirects = [r for r in params['redirect_uris'].split(',')]
@ -105,9 +114,49 @@ def client_from_params(params):
return client
def service_client_from_params(params):
"""The KeyCape client record for a case of oidc-service-client (client_credentials). Field rules follow
key-cape src/internal/config/validate.go: confidential, env secretRef, serviceSubject and tenant, no redirect URIs;
the type is stricter (closed resource scopes, at most 15m)."""
missing = {'client_id', 'display_name', 'audience', 'allowed_scopes', 'service_subject', 'tenant',
'token_lifetime'} - set(params)
require(not missing, 'missing_parameters')
cid = params['client_id']
require(CLIENT_ID.fullmatch(cid or '') is not None, 'invalid_client_id')
require(AUDIENCE.fullmatch(params.get('audience') or '') is not None, 'audience_invalid')
scopes = (params.get('allowed_scopes') or '').split(',')
require(all(RESOURCE_SCOPE.fullmatch(s) for s in scopes) and len(set(scopes)) == len(scopes),
'allowed_scopes_must_be_distinct_resource_scopes')
require(SERVICE_SUBJECT.fullmatch(params.get('service_subject') or '') is not None, 'service_subject_invalid')
require(TENANT.fullmatch(params.get('tenant') or '') is not None, 'tenant_invalid')
require(params.get('token_lifetime') in LIFETIMES, 'token_lifetime_invalid')
for key in ('redirect_uris', 'client_type', 'mfa_required', 'secret_ref'):
require(key not in params, key + '_not_admitted_for_service_clients')
client = {
'clientId': cid,
'displayName': params['display_name'],
'audience': params['audience'],
'allowedScopes': scopes,
'grantTypes': ['client_credentials'],
'clientType': 'confidential',
'secretRef': 'env:' + env_name(cid),
'serviceSubject': params['service_subject'],
'tenant': params['tenant'],
}
if params.get('roles'):
roles = params['roles'].split(',')
require(all(ROLE.fullmatch(r) for r in roles) and len(set(roles)) == len(roles), 'roles_invalid')
client['roles'] = roles
client['tokenLifetime'] = params['token_lifetime']
return client
def client_from_case(case):
spec = case.get('spec') or {}
require(CASE_TYPE.fullmatch(str(spec.get('type', ''))) is not None, 'case_type_not_oidc_browser_relying_party')
case_type = str(spec.get('type', ''))
if SERVICE_CASE_TYPE.fullmatch(case_type):
return service_client_from_params(spec.get('parameters') or {})
require(CASE_TYPE.fullmatch(case_type) is not None, 'case_type_not_a_keycape_client_type')
return client_from_params(spec.get('parameters') or {})

View file

@ -40,7 +40,7 @@ def fixture(clients=None, tail='tokenLifetime: 15m\n'):
sort_keys=False) + tail)
secret = {'metadata': {'uid': UID, 'resourceVersion': '7'},
'data': {'config.yaml': base64.b64encode(raw.encode()).decode(),
'signing-key.pem': 'c3ludGhldGljLXNpZ25pbmcta2V5'}}
'signing-key.pem': base64.b64encode(b'synthetic-signing-key').decode()}}
return secret, raw
@ -97,6 +97,70 @@ class ClientRecordTests(unittest.TestCase):
allowed_scopes='openid,profile,email'))
EXECUTOR_CASE = Path(__file__).resolve().parents[3] / 'security-case-types/oidc-service-client/examples/nk-bind-executor-approval.yaml'
KEYCAPE_EXAMPLE = Path('/home/worsch/key-cape/config/service-clients.example.yaml')
SERVICE_PARAMS = {
'client_id': 'nk-bind-executor-approval', 'display_name': 'NetKingdom bounded executor (approval-engine)',
'audience': 'approval-engine', 'allowed_scopes': 'approval:create,approval:read,approval:consume',
'service_subject': 'service:nk-bind-executor', 'tenant': 'tenant:platform', 'roles': 'bounded-executor',
'token_lifetime': '15m', 'consuming_service': 'bounded-executor', 'consumer_namespace': 'sso',
'consumer_service_account': 'nk-bind-executor', 'consumer_owner_repo': 'net-kingdom',
}
class ServiceClientTests(unittest.TestCase):
"""oidc-service-client (NK-WP-0056-T14): client_credentials records in key-cape's canonical shape."""
def test_executor_case_renders_the_service_record(self):
case = yaml.safe_load(EXECUTOR_CASE.read_text())
self.assertEqual(kc.client_from_case(case), {
'clientId': 'nk-bind-executor-approval', 'displayName': 'NetKingdom bounded executor (approval-engine)',
'audience': 'approval-engine', 'allowedScopes': ['approval:create', 'approval:read', 'approval:consume'],
'grantTypes': ['client_credentials'], 'clientType': 'confidential',
'secretRef': 'env:KEYCAPE_NK_BIND_EXECUTOR_APPROVAL_CLIENT_SECRET',
'serviceSubject': 'service:nk-bind-executor', 'tenant': 'tenant:platform', 'roles': ['bounded-executor'],
'tokenLifetime': '15m'})
def test_adopting_secrets_engine_approval_reproduces_key_capes_record(self):
if not KEYCAPE_EXAMPLE.exists():
self.skipTest('key-cape checkout absent')
live = next(c for c in yaml.safe_load(KEYCAPE_EXAMPLE.read_text())['clients']
if c['clientId'] == 'secrets-engine-approval')
params = {'client_id': live['clientId'], 'display_name': live['displayName'], 'audience': live['audience'],
'allowed_scopes': ','.join(live['allowedScopes']), 'service_subject': live['serviceSubject'],
'tenant': live['tenant'], 'roles': ','.join(live['roles']), 'token_lifetime': live['tokenLifetime']}
self.assertEqual(kc.service_client_from_params(params), live)
def test_refusals(self):
for change in [dict(allowed_scopes='openid,approval:read'), dict(allowed_scopes='approval:read,approval:read'),
dict(token_lifetime='30m'), dict(service_subject='nk-bind-executor'),
dict(tenant='platform'), dict(audience='Approval Engine'), dict(roles='Bad Role'),
dict(redirect_uris='https://x.example/cb'), dict(client_type='public'),
dict(mfa_required='true'), dict(secret_ref='env:OTHER')]:
with self.subTest(change=change), self.assertRaises(kc.Refused):
kc.service_client_from_params(dict(SERVICE_PARAMS, **change))
def test_case_type_selects_the_builder(self):
case = {'spec': {'type': 'oidc-service-client@0.1', 'parameters': SERVICE_PARAMS}}
self.assertEqual(kc.client_from_case(case)['grantTypes'], ['client_credentials'])
with self.assertRaises(kc.Refused): # service parameters under the browser type
kc.client_from_case({'spec': {'type': 'oidc-browser-relying-party@1.1', 'parameters': SERVICE_PARAMS}})
with self.assertRaises(kc.Refused):
kc.client_from_case({'spec': {'type': 'oidc-service-client@1.0', 'parameters': SERVICE_PARAMS}})
def test_merge_adds_a_service_client_and_refuses_a_shared_secret_ref(self):
service = kc.service_client_from_params(SERVICE_PARAMS)
secret, raw = fixture()
encoded, changed = kc.replacement(secret, service)
self.assertTrue(changed)
expected = yaml.safe_load(raw)
expected['clients'].append(service)
self.assertEqual(yaml.safe_load(decode(encoded)), expected)
with self.assertRaises(kc.Refused):
kc.replacement(fixture(EXISTING + [dict(EXISTING[0], clientId='x', secretRef=service['secretRef'])])[0],
service)
class MergeTests(unittest.TestCase):
def test_add_appends_only_this_client_and_preserves_bytes(self):
secret, raw = fixture()

View file

@ -698,7 +698,7 @@ Follow-up 2026-10-31 for T10/T06 reachability:
```task
id: NK-WP-0056-T14
status: progress
status: done
priority: high
assignee: claude-code
depends_on: [NK-WP-0056-T13]
@ -730,6 +730,29 @@ The split of T14 into T14, T18 and T19 is a founder decision (2026-10-31).
Nothing is applied.
**Done 2026-10-31.** SecurityCaseType `oidc-service-client` 0.1 is `proposed` (`security-case-types/oidc-service-client/`):
- type, README and two test files;
- the first case `examples/nk-bind-executor-approval.yaml`: audience `approval-engine`; scopes `approval:create`,
`approval:read` and `approval:consume`; subject `service:nk-bind-executor`; tenant `tenant:platform`; 15m;
- the consumer reads the secret natively through its own bound role (`sso/nk-bind-executor`, audience `openbao`),
with no Secret copy;
- new capability declaration `net-kingdom.keycape-service-client-registration`.
`keycape-client.py` renders service clients in key-cape's canonical shape. Adopting `secrets-engine-approval`
reproduces key-cape's example record exactly. Both builders refuse missing parameters.
Tests: 138 pass (type, browser type, validator, keycape-client). Memo `nk-wp-0056-t14-oidc-service-client-r1`
(`decision/`) passes informed-decision's intake validator and State Hub's dispatcher contract. Hub decision
`f8044f81`.
Next:
- reviews from ops-mason, ops-warden, key-cape and railiance-platform;
- the operator queues the memo;
- the founder acknowledges;
- the case is accepted and goes live, which makes the type `implemented` (as part of T18/T19).
## Executor approval client and request phase
```task