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
7.1 KiB
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_consumesnarrows.
- it can create approvals naming itself as actor, which still need a human entry under
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 ofsub, and how the consumer receives the secret. A combined type would carry parameters that are forbidden in one mode (KeyCape rejectsserviceSubjecton 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:verbshape; 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-operatorneeds 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.