NK-WP-0056-T20 done: oidc-service-client 0.2 with the owner reviews; memo r2
All checks were successful
Build and Publish bounded-executor / build-and-push (push) Successful in 24s
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 3s
Identity provider journey acceptance / provider (push) Successful in 1m2s

ESO role audience + explicit max TTL and ESO policy self-lifecycle (ops-mason
R3/R4, railiance-platform R3); consumer role renamed to differ from its policy
(railiance-platform R4); default SA and external-secrets/openbao namespaces
refused (ops-mason R6); consumer-cannot-read-delivered-secret verification
(ops-warden R7, ops-mason R7); excess scope invalid_profile_usage (key-cape
R3); attended rotation, broker admission pending, one rollout per case;
catalog fields (ops-warden R6). First case consumer_owner_repo
rapp-bounded-executor. CCR-2026-0060/0061 names checked by test. Memo r2
validated against informed-decision intake and State Hub dispatcher.

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-11-01 00:34:17 +01:00
parent 1e17b39212
commit 45feaa13cc
10 changed files with 215 additions and 91 deletions

View file

@ -362,7 +362,7 @@
| task | NK-WP-0056-T17 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
| task | NK-WP-0056-T18 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
| task | NK-WP-0056-T19 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md |
| task | NK-WP-0056-T20 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md |
| task | NK-WP-0056-T20 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
| task | NK-WP-0056-T21 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md |
| intake | NK-IN-0001 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md |
| intake | NK-IN-0002 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md |

View file

@ -35,7 +35,7 @@ C, OPS, X = CLI.C, CLI.OPS, CLI.X
ADMISSION = X.REPO / "examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml"
SECRET = "synthetic-client-secret/with:specials"
CLIENT_KV = "platform/workloads/keycape/nk-bind-executor-approval"
APPROVAL_ROLE = "workload-kv-read-keycape-nk-bind-executor-approval-bounded-executor"
APPROVAL_ROLE = "keycape-nk-bind-executor-approval-bounded-executor-workload-kv-read"
class World:

View file

@ -1,4 +1,4 @@
# oidc-service-client 0.1
# oidc-service-client 0.2
An internal workload obtains short-lived KeyCape access tokens for one resource server with the `client_credentials`
grant. The type covers:
@ -7,9 +7,9 @@ grant. The type covers:
- 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
Canon: `canon/standards/security-case-types_v0.1.md`. Status: **proposed** (0.2, 2026-11-01): complete, tests
green, owner reviews folded in, Decision Memo r2 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
@ -29,7 +29,7 @@ equals the token's `sub`, so the bounded executor must hold its own client to fi
### Reference instances and fit
| Client | Fits 0.1 | Note |
| Client | Fits 0.2 | Note |
| --- | --- | --- |
| nk-bind-executor-approval (new) | yes | first case |
| secrets-engine-approval | yes | adoption would not change its live registration (canon section 9) |
@ -58,11 +58,16 @@ equals the token's `sub`, so the bounded executor must hold its own client to fi
- 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.
- No Secret copy is made *for the consumer*: it reads natively per call. KeyCape's own ESO-delivered copy,
`keycape-<client_id>-client`, lives in `sso`. When the consumer also runs in `sso`, as the first case does, that copy
sits beside it. The verification `consumer-cannot-read-delivered-secret` proves with an RBAC can-i answer that the
consumer's ServiceAccount cannot get, list or watch Secrets there (ops-warden R7, ops-mason R7, railiance-platform
R7).
- 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.
- Scopes are a closed list per case. KeyCape refuses an excess scope with HTTP 400 `invalid_profile_usage` (feature
`scope`) and issues no token (key-cape R3). The type verifies that refusal.
- Service tokens carry `principal_type: service` and a fixed assurance of `aal1` (`client_secret`). A service token can
never satisfy an aal2 rule. Human approvals stay with humans (key-cape R2).
- 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`;
@ -85,10 +90,13 @@ equals the token's `sub`, so the bounded executor must hold its own client to fi
## 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.
which is a Kubernetes Secret. Agents do not read or write Secrets. Both steps run in one attended operator session
and **one rollout**: KeyCape runs one replica with strategy Recreate, so the env patch is itself a restart (key-cape
R4). `bind-env` goes first, because KeyCape resolves a confidential client's secret at start and fails closed without
it. `deliver-secret` must report Ready immediately before, or the new pod cannot start and every client loses sign-in.
The restart drops in-flight logins, so runs go in a quiet window. `mint-secret` and `deliver-secret` are unattended,
but `mint-secret` needs a human aal2 login to the issuer role; an agent, including the consumer, cannot mint its own
secret (ops-warden R2).
## Lanes, value handling and gates
@ -97,7 +105,7 @@ are unattended.
| 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>` |
| hand-over | the consumer's own deployment path | none; the consumer reads through role `keycape-<client_id>-<consuming_service>-workload-kv-read` (policy `workload-kv-read-keycape-<client_id>-<consuming_service>`) |
## Known limits
@ -106,24 +114,48 @@ are unattended.
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.
- **Rotation is attended and has no lane yet.** `mint-secret` only creates and refuses an existing path. A rotation
is an attended platform-admin write of the next version with CAS, then the ESO delivery Ready, then one KeyCape
restart. Until KeyCape restarts, the consumer, which reads the new value at once, gets 401 (railiance-platform R5,
ops-warden R5, key-cape R5).
- **The broker must admit the type.** railiance-platform's `keycape_client_secret_generate.py` accepts only
`oidc-browser-relying-party@1.1` and a `client_type` parameter. Admitting this type is a reviewed source change that
lands after acknowledgement (railiance-platform R1, ops-warden R1). Until then `mint-secret` refuses these cases.
- **Each case still needs its CCRs.** The type replaces the shape review, not per-case approval or binding
confirmation (railiance-platform R6). For the first case: CCR-2026-0060 (KeyCape's ESO reader) and CCR-2026-0061
(the consumer reader).
- **Claims are reported by the consumer.** `service-token-claims` is the tested party reporting on itself. KeyCape's
non-secret client view at register-client (`keycape-client.py inspect`) is the independent half (ops-warden R8).
- **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);
- the consumer reports the claims of its own exchanges (sub, aud, tenant, principal_type, 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;
- the consumer policy grants read on the one path, plus token lookup-self and revoke-self;
- the consumer cannot read Secrets in its namespace (RBAC can-i);
- 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.
registration, env binding, delivery, custody, roles before their policies, then the catalog entry. Rollback is
complete once the consumer's tokens have expired (at most 15m) or been revoked (ops-mason R11).
## History
- 0.1 (2026-10-31): first version, for NK-WP-0056-T14; proposed with memo `nk-wp-0056-t14-oidc-service-client-r1`.
- 0.2 (2026-11-01, NK-WP-0056-T20): folds in the owner reviews (ops-mason `3c69a25`, ops-warden `95d31c3`, key-cape
`655d8d3`, railiance-platform `6858bef`):
- the ESO role gains audience and explicit max TTL; the ESO policy gains token self-lifecycle;
- the consumer role is renamed so that it differs from its policy;
- the `default` ServiceAccount and the `external-secrets` and `openbao` namespaces are refused;
- new verification `consumer-cannot-read-delivered-secret`;
- the excess-scope code corrected; rotation corrected; one rollout per case;
- the catalog entry gains lane, auth_method, `exec_capable: false` and need_keywords.
Decided here: `consuming_service` stays `bounded-executor`, matching CCR-2026-0061 (ops-mason R9). The first case's
`consumer_owner_repo` is `rapp-bounded-executor`, where the consumer's configuration lives (ADR-0015; ops-warden
R10). Memo r2 replaces r1.

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Build the informed-decision memo intake envelope that acknowledges oidc-service-client 0.1.
"""Build the informed-decision memo intake envelope that acknowledges oidc-service-client 0.2.
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
@ -14,9 +14,9 @@ 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"
MEMO_ID = "nk-wp-0056-t14-oidc-service-client-r2" # replaces r1 (0.1, before the owner reviews)
ITEM = f"{MEMO_ID}-packet"
TARGET = "oidc-service-client@0.1"
TARGET = "oidc-service-client@0.2"
packet = (HERE / "packet.md").read_text()
digest = "sha256:" + hashlib.sha256(packet.encode("utf-8")).hexdigest()
@ -33,29 +33,30 @@ envelope = {
"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).",
"purpose": "Admit oidc-service-client 0.2 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 "
"question": ("Acknowledge oidc-service-client 0.2 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 "
"NK-WP-0056-T14/T20, r2 after the owner reviews. 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."
"operator session and one KeyCape restart per case remain, because KeyCape's live client registry is a "
"Kubernetes Secret; rotation is attended until a lane exists."
),
"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},
"label": "SecurityCaseType oidc-service-client 0.2", "requires_new_bind": False},
"terms": None,
},
"step_kind": "approve",

View file

@ -1,7 +1,7 @@
{
"kind": "informed-decision.memo-intake",
"version": 1,
"idempotency_key": "state-hub.f8044f81-f94c-483a-81f9-4f2b02a60d5f.nk-wp-0056-t14-oidc-service-client-r1",
"idempotency_key": "state-hub.f8044f81-f94c-483a-81f9-4f2b02a60d5f.nk-wp-0056-t14-oidc-service-client-r2",
"memo_class": "decision-record",
"review_group": "net-kingdom-admins",
"drafted_by": "net-kingdom NK-WP-0056 (claude-code)",
@ -12,20 +12,20 @@
"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).",
"purpose": "Admit oidc-service-client 0.2 as a SecurityCaseType (canon security-case-types_v0.1 section 5).",
"target": {
"id": "oidc-service-client@0.1",
"id": "oidc-service-client@0.2",
"type": "security-case-type",
"system": "net-kingdom"
}
},
"memo": {
"id": "nk-wp-0056-t14-oidc-service-client-r1",
"id": "nk-wp-0056-t14-oidc-service-client-r2",
"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?",
"question": "Acknowledge oidc-service-client 0.2 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.",
"brief": "NK-WP-0056-T14/T20, r2 after the owner reviews. 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 and one KeyCape restart per case remain, because KeyCape's live client registry is a Kubernetes Secret; rotation is attended until a lane exists.",
"binding": {
"justification": null,
"principal": {
@ -37,9 +37,9 @@
},
"target": {
"environment": "prod",
"id": "oidc-service-client@0.1",
"id": "oidc-service-client@0.2",
"kind": "security-case-type",
"label": "SecurityCaseType oidc-service-client 0.1",
"label": "SecurityCaseType oidc-service-client 0.2",
"requires_new_bind": false
},
"terms": null
@ -47,15 +47,15 @@
"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"
"item_id": "nk-wp-0056-t14-oidc-service-client-r2-packet",
"label": "nk-wp-0056-t14-oidc-service-client-r2-packet",
"hash": "sha256:24179bcad018c3809f6dc229dcced9586800de17b5c76aea164d2416208294c9"
}
],
"highlights": [
{
"id": "nk-wp-0056-t14-oidc-service-client-r1-h1",
"item_id": "nk-wp-0056-t14-oidc-service-client-r1-packet",
"id": "nk-wp-0056-t14-oidc-service-client-r2-h1",
"item_id": "nk-wp-0056-t14-oidc-service-client-r2-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 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",
"id": "nk-wp-0056-t14-oidc-service-client-r2-h2",
"item_id": "nk-wp-0056-t14-oidc-service-client-r2-packet",
"severity": "warning",
"required_ack": true,
"locator": {
@ -85,9 +85,9 @@
},
"packet": [
{
"item_id": "nk-wp-0056-t14-oidc-service-client-r1-packet",
"item_id": "nk-wp-0056-t14-oidc-service-client-r2-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"
"content": "# Acknowledge SecurityCaseType oidc-service-client 0.2\n\nWorkplan: NK-WP-0056 (net-kingdom), tasks NK-WP-0056-T14 and T20\nMemo: nk-wp-0056-t14-oidc-service-client-r2. It replaces r1 (0.1, before the owner reviews). If r1 was ever\nqueued, return it with a comment that r2 replaces it.\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.2 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 dedicated ServiceAccount, never `default`, one\n namespace, audience `openbao`, 15m, one policy).\n- **No Secret copy for the consumer.** It reads per call, as the bounded executor's lanes already do (CCR-2026-0058\n and CCR-2026-0059). KeyCape's own delivered copy lives in `sso`. If the consumer runs there too, an RBAC can-i check\n proves it cannot read Secrets.\n- **Verification reads no value:**\n - the consumer reports claims (sub, aud, tenant, principal_type, scope and lifetime);\n - an excess scope is refused with `invalid_profile_usage`;\n - the consumer cannot read Secrets in its namespace;\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 and **one KeyCape restart** per case remain (`bind-env`, then `register-client`, in\none rollout), because KeyCape's live client registry is a Kubernetes Secret and KeyCape reads the client secret at\nstart. Rotation is also attended for now (a platform-admin write, then a KeyCape restart): the mint step only\ncreates.\n\n## What follows acknowledgement\n\n- railiance-platform admits the type in its secret broker (a reviewed source change).\n- The first case is accepted with its CCRs: CCR-2026-0060 (KeyCape's reader) and CCR-2026-0061 (the executor's\n reader).\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 (done 2026-10-31, folded into 0.2)\n\n- ops-mason (`3c69a25`): materialization accepted with changes, all applied.\n- ops-warden (`95d31c3`): follow-through sound; changes applied (rotation, catalog fields, same-namespace check).\n- key-cape (`655d8d3`): field rules confirmed with `keycape check-config` and a local token test; error code and\n restart notes applied.\n- railiance-platform (`6858bef`): custody and the consumer role accepted. Role renamed; per-case CCRs drafted.\n"
}
]
}

View file

@ -1,6 +1,8 @@
# Acknowledge SecurityCaseType oidc-service-client 0.1
# Acknowledge SecurityCaseType oidc-service-client 0.2
Workplan: NK-WP-0056 (net-kingdom), task NK-WP-0056-T14
Workplan: NK-WP-0056 (net-kingdom), tasks NK-WP-0056-T14 and T20
Memo: nk-wp-0056-t14-oidc-service-client-r2. It replaces r1 (0.1, before the owner reviews). If r1 was ever
queued, return it with a comment that r2 replaces it.
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)
@ -8,7 +10,7 @@ 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
Acknowledge oidc-service-client 0.2 as the reusable SecurityCaseType for internal workloads that obtain KeyCape
client_credentials tokens for one resource server?
## One act
@ -45,22 +47,31 @@ as needing "a separate type".
- **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).
- the consuming workload, through its own Kubernetes-auth role (one dedicated ServiceAccount, never `default`, one
namespace, audience `openbao`, 15m, one policy).
- **No Secret copy for the consumer.** It reads per call, as the bounded executor's lanes already do (CCR-2026-0058
and CCR-2026-0059). KeyCape's own delivered copy lives in `sso`. If the consumer runs there too, an RBAC can-i check
proves it cannot read Secrets.
- **Verification reads no value:**
- the consumer reports claims (sub, aud, tenant, scope and lifetime);
- an excess scope is refused;
- the consumer reports claims (sub, aud, tenant, principal_type, scope and lifetime);
- an excess scope is refused with `invalid_profile_usage`;
- the consumer cannot read Secrets in its namespace;
- 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.
One attended operator session and **one KeyCape restart** per case remain (`bind-env`, then `register-client`, in
one rollout), because KeyCape's live client registry is a Kubernetes Secret and KeyCape reads the client secret at
start. Rotation is also attended for now (a platform-admin write, then a KeyCape restart): the mint step only
creates.
## What follows acknowledgement
- railiance-platform admits the type in its secret broker (a reviewed source change).
- The first case is accepted with its CCRs: CCR-2026-0060 (KeyCape's reader) and CCR-2026-0061 (the executor's
reader).
## Alternatives considered
@ -70,11 +81,10 @@ a new type version.
- **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
## Reviews (done 2026-10-31, folded into 0.2)
- 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.
- ops-mason (`3c69a25`): materialization accepted with changes, all applied.
- ops-warden (`95d31c3`): follow-through sound; changes applied (rotation, catalog fields, same-namespace check).
- key-cape (`655d8d3`): field rules confirmed with `keycape check-config` and a local token test; error code and
restart notes applied.
- railiance-platform (`6858bef`): custody and the consumer role accepted. Role renamed; per-case CCRs drafted.

View file

@ -6,7 +6,7 @@ apiVersion: netkingdom.io/security-case/v0.1
kind: SecurityCase
spec:
id: nk-bind-executor-approval
type: oidc-service-client@0.1
type: oidc-service-client@0.2
status: proposed
owner:
repo: net-kingdom
@ -23,4 +23,4 @@ spec:
consuming_service: bounded-executor
consumer_namespace: sso
consumer_service_account: nk-bind-executor
consumer_owner_repo: net-kingdom
consumer_owner_repo: rapp-bounded-executor

View file

@ -60,14 +60,16 @@ def test_executor_case_renders_custody_and_one_attended_session():
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 steps["hand-over"]["owner"] == "rapp-bounded-executor"
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"
# role and policy names differ: the delegated applier refuses a role named like its policy (railiance-platform R4)
assert items["consumer-reader-role"]["template"] == "keycape-nk-bind-executor-approval-bounded-executor-workload-kv-read"
assert items["consumer-reader-policy"]["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"
@ -94,7 +96,8 @@ def test_custody_path_is_derived_not_case_settable():
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)]:
dict(consumer_service_account=None), dict(consumer_service_account="default"),
dict(consumer_namespace="openbao"), dict(consumer_namespace="external-secrets")]:
assert msgs(v.validate_case(with_params(EXECUTOR, **change), as_implemented())), change
@ -102,7 +105,10 @@ 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"}
"excess-scope-refused", "custody-denied-to-default", "consumer-login-bound",
"consumer-cannot-read-delivered-secret"}
excess = next(c for c in checks if c["id"] == "excess-scope-refused")["check"]
assert "invalid_profile_usage" in excess and "invalid_scope" not in excess
def test_keycape_record_from_the_case():
@ -117,3 +123,34 @@ 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
def test_eso_role_and_policy_match_the_live_reader_shape():
items = {m["id"]: m for m in render()["materialization"]}
role = yaml.safe_load(items["eso-role"]["body"])
assert role["audience"] == "openbao" and role["token_explicit_max_ttl"] == "15m" # ops-mason R3
policy = items["keycape-reader-policy"]["body"]
assert '"auth/token/lookup-self"' in policy and '"auth/token/revoke-self"' in policy # ops-mason R4
def test_catalog_entry_names_no_fetch_route_and_attended_rotation():
body = yaml.safe_load(next(m for m in render()["materialization"] if m["id"] == "catalog-entry")["body"])
assert body["exec_capable"] is False and "fetch_command" not in body # ops-warden R6
assert body["lane"] == "secret" and "keycape-nk-bind-executor-approval-bounded-executor-workload-kv-read" in body["auth_method"]
assert "attended platform-admin" in body["rotation"] and "automatable false" in body["rotation"] # R5
def test_first_case_ccrs_name_the_rendered_roles_and_policies():
ccrs = REPO.parent / "railiance-platform" / "credential-change-requests"
found = {c.name: yaml.safe_load(c.read_text()) for c in ccrs.glob("CCR-2026-006[01]-*.yaml")} if ccrs.exists() else {}
if len(found) != 2:
import pytest
pytest.skip("railiance-platform CCR-2026-0060/0061 not checked out")
items = {m["id"]: m["template"] for m in render()["materialization"]}
auth = {name.split("-nk-")[0]: ccr["openbao"]["auth"] for name, ccr in found.items()}
eso, consumer = auth["CCR-2026-0060"], auth["CCR-2026-0061"]
assert eso["role"] == items["eso-role"] and eso["policies"] == [items["keycape-reader-policy"]]
assert consumer["role"] == items["consumer-reader-role"]
assert consumer["policies"] == [items["consumer-reader-policy"]]
assert consumer["bound_claims"] == {"service_account_names": ["nk-bind-executor"],
"service_account_namespaces": ["sso"]}

View file

@ -2,7 +2,7 @@ apiVersion: netkingdom.io/security-case-type/v0.1
kind: SecurityCaseType
spec:
id: oidc-service-client
version: "0.1"
version: "0.2"
status: proposed
title: "Confidential OIDC service client (client_credentials) for an internal workload"
purpose: >-
@ -81,15 +81,18 @@ spec:
- name: consumer_namespace
type: string
case_settable: true
constraints: {pattern: "^[a-z0-9][a-z0-9-]{0,62}$"}
constraints: {pattern: "^(?!(external-secrets|openbao)$)[a-z0-9][a-z0-9-]{0,62}$"}
sensitivity: security_sensitive
description: Namespace of the consuming workload's ServiceAccount.
description: >-
Namespace of the consuming workload's ServiceAccount. Not external-secrets or openbao (ops-mason R6). If it
is sso, KeyCape's own delivered copy lives beside the consumer; verification proves the consumer cannot read
it (consumer-cannot-read-delivered-secret).
- name: consumer_service_account
type: string
case_settable: true
constraints: {pattern: "^[a-z0-9][a-z0-9-]{0,62}$"}
constraints: {pattern: "^(?!default$)[a-z0-9][a-z0-9-]{0,62}$"}
sensitivity: security_sensitive
description: The ServiceAccount whose openbao-audience token may read the client secret.
description: The dedicated ServiceAccount whose openbao-audience token may read the client secret; never default (ops-mason R6).
- name: consumer_owner_repo
type: string
case_settable: true
@ -134,7 +137,15 @@ spec:
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.
path "auth/token/lookup-self" {{
capabilities = ["read"]
}}
path "auth/token/revoke-self" {{
capabilities = ["update"]
}}
description: >-
KeyCape reads its client secret only through ESO. No metadata, list, parent or sibling access; token
self-lifecycle as in CCR-2026-0041 (ops-mason R4, railiance-platform R3).
- id: eso-role
kind: openbao_role
template: "external-secrets-keycape-{client_id}"
@ -142,11 +153,13 @@ spec:
auth: kubernetes
bound_service_account_names: [external-secrets]
bound_service_account_namespaces: [external-secrets]
audience: openbao
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.
token_explicit_max_ttl: 15m
description: The Kubernetes-auth role deliver-secret uses; bound to the ESO service account only (ops-mason R3).
- id: consumer-reader-policy
kind: openbao_policy
template: "workload-kv-read-keycape-{client_id}-{consuming_service}"
@ -163,7 +176,8 @@ spec:
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}"
# The delegated applier refuses a role named like its policy (railiance-platform R4).
template: "keycape-{client_id}-{consuming_service}-workload-kv-read"
body: |
auth: kubernetes
bound_service_account_names: [{consumer_service_account}]
@ -190,8 +204,17 @@ spec:
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).
lane: secret
subsystem: keycape
auth_method: kubernetes role keycape-{client_id}-{consuming_service}-workload-kv-read (audience openbao), bound to {consumer_namespace}/{consumer_service_account}
exec_capable: false
need_keywords: [{client_id}, {audience}, {consuming_service}, keycape client secret]
rotation: >-
attended platform-admin write of the next version with CAS (mint-secret only creates and refuses an
existing path), then the ESO delivery Ready, then one KeyCape restart; automatable false, no lane yet
description: >-
One entry per case; carries the consumer's workload reference for zone selection (ADR-0009). No fetch
command: no operator fetch route is implied (ops-warden R6).
follow_through:
- id: mint-secret
@ -219,10 +242,13 @@ spec:
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.
A change to the live identity provider's Deployment. KeyCape runs one
replica with strategy Recreate, so an env patch is itself a restart
(key-cape R4): bind-env and register-client are applied in one rollout,
bind-env first, because KeyCape resolves a confidential client's secret
at start and fails closed if the binding is missing. deliver-secret must
report Ready immediately before, or the new pod cannot start and every
client loses sign-in.
- 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)"
@ -248,11 +274,11 @@ spec:
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)"
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, principal_type, scope and lifetime; expected sub {service_subject}, aud {audience}, tenant {tenant}, principal_type service, 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"
check: "{consuming_service} requests a scope outside {allowed_scopes} for client {client_id}; KeyCape answers HTTP 400 invalid_profile_usage (feature scope) and issues no token (key-cape R3)"
expect: deny
reads_secret_value: false
- id: custody-denied-to-default
@ -260,13 +286,17 @@ spec:
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)"
check: "role keycape-{client_id}-{consuming_service}-workload-kv-read 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)"
check: "policy workload-kv-read-keycape-{client_id}-{consuming_service} grants read on bao:platform/workloads/keycape/{client_id} and otherwise only token lookup-self and revoke-self (capabilities check only)"
expect: allow
reads_secret_value: false
- id: consumer-cannot-read-delivered-secret
check: "an RBAC can-i check as {consumer_namespace}/{consumer_service_account} for get, list and watch on Secrets in {consumer_namespace} answers no, so KeyCape's delivered copy keycape-{client_id}-client stays out of the consumer's reach (authorization answer only; ops-warden R7, ops-mason R7)"
expect: deny
reads_secret_value: false
- id: secret-delivered
check: "the delivery for sso/keycape-{client_id}-client reports Ready=True (status only)"
expect: present
@ -290,7 +320,7 @@ spec:
- 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}"
action: "delete Kubernetes-auth role keycape-{client_id}-{consuming_service}-workload-kv-read; rollback is complete once the consumer's tokens have expired (at most 15m) or been revoked (ops-mason R11)"
- reverses: consumer-reader-policy
action: "delete policy workload-kv-read-keycape-{client_id}-{consuming_service}, after consumer-reader-role"
- reverses: catalog-entry

View file

@ -896,7 +896,7 @@ Founder decision needed before T14.
```task
id: NK-WP-0056-T20
status: todo
status: done
priority: high
assignee: claude-code
depends_on: [NK-WP-0056-T14]
@ -935,6 +935,20 @@ R1).
**Done when:** the type is at r2 with tests green, the memo r2 is validated, and the CCR names agree with the type.
**Done 2026-11-01.** `oidc-service-client` is at 0.2 (`proposed`) with every review change applied (README history
lists them). Settled here:
- `consuming_service` stays `bounded-executor`;
- the first case's `consumer_owner_repo` is `rapp-bounded-executor`.
The consumer role is `keycape-{client_id}-{consuming_service}-workload-kv-read`. A new test checks CCR-2026-0060 and
CCR-2026-0061 against the rendered role and policy names (they agree). 141 related tests pass.
Memo `nk-wp-0056-t14-oidc-service-client-r2` passes informed-decision's intake validator and State Hub's dispatcher
contract (hub decision `f8044f81`). The rapp follows the role rename (`828992e`).
Next for the founder: queue memo r2 through the State Hub memo dispatcher and acknowledge the type.
## Executor submits its admission memo to informed-decision
```task