# 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`, 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 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`; - 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 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-` | 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---workload-kv-read` (policy `workload-kv-read-keycape--`) | ## 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 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, 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-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. - 0.2 acknowledged (2026-11-01): founder accepted memo r2 in informed-decision.