Hub decision f8044f81. Becomes implemented when nk-bind-executor-approval is live and verified. 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 |
||
|---|---|---|
| .. | ||
| decision | ||
| examples | ||
| tests | ||
| README.md | ||
| type.yaml | ||
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:
- 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: acknowledged (0.2, 2026-11-01). The founder
accepted informed-decision memo nk-wp-0056-t14-oidc-service-client-r2 (hub decision f8044f81). The type becomes
implemented once its first case, nk-bind-executor-approval, is live and verified. 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.2 | 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. - No Secret copy is made for the consumer: it reads natively per call. KeyCape's own ESO-delivered copy,
keycape-<client_id>-client, lives insso. When the consumer also runs insso, as the first case does, that copy sits beside it. The verificationconsumer-cannot-read-delivered-secretproves 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 with HTTP 400
invalid_profile_usage(featurescope) and issues no token (key-cape R3). The type verifies that refusal. - Service tokens carry
principal_type: serviceand a fixed assurance ofaal1(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; - 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
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
| 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 role keycape-<client_id>-<consuming_service>-workload-kv-read (policy 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 attended and has no lane yet.
mint-secretonly 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.pyaccepts onlyoidc-browser-relying-party@1.1and aclient_typeparameter. Admitting this type is a reviewed source change that lands after acknowledgement (railiance-platform R1, ops-warden R1). Until thenmint-secretrefuses 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-claimsis 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-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, 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, 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. 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-warden95d31c3, key-cape655d8d3, railiance-platform6858bef):- 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
defaultServiceAccount and theexternal-secretsandopenbaonamespaces 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: falseand need_keywords.
Decided here:
consuming_servicestaysbounded-executor, matching CCR-2026-0061 (ops-mason R9). The first case'sconsumer_owner_repoisrapp-bounded-executor, where the consumer's configuration lives (ADR-0015; ops-warden R10). Memo r2 replaces r1. -
0.2 acknowledged (2026-11-01): founder accepted memo r2 in informed-decision.