net-kingdom/security-case-types/oidc-service-client
tegwick 9ce0d6068a
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
oidc-service-client 0.2 acknowledged (founder accepted memo r2 in informed-decision)
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
2026-11-01 10:18:09 +01:00
..
decision NK-WP-0056-T20 done: oidc-service-client 0.2 with the owner reviews; memo r2 2026-11-01 00:34:17 +01:00
examples NK-WP-0056-T20 done: oidc-service-client 0.2 with the owner reviews; memo r2 2026-11-01 00:34:17 +01:00
tests NK-WP-0056-T20 done: oidc-service-client 0.2 with the owner reviews; memo r2 2026-11-01 00:34:17 +01:00
README.md oidc-service-client 0.2 acknowledged (founder accepted memo r2 in informed-decision) 2026-11-01 10:18:09 +01:00
type.yaml oidc-service-client 0.2 acknowledged (founder accepted memo r2 in informed-decision) 2026-11-01 10:18:09 +01:00

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 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-<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: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.