apiVersion: netkingdom.io/security-case-type/v0.1 kind: SecurityCaseType spec: id: oidc-service-client version: "0.2" status: acknowledged title: "Confidential OIDC service client (client_credentials) for an internal workload" purpose: >- Let an internal workload obtain short-lived KeyCape access tokens for one resource server (client_credentials): an exact audience, a closed list of resource scopes, a fixed service subject and tenant, and a client secret minted straight into OpenBao custody, readable only by KeyCape (through ESO) and by the consuming workload (through its own Kubernetes-auth login). Replaces per-client rollout scripts such as approval-clients-rollout.py. First case: the bounded executor's approval client (NK-WP-0056-T14). parameters: - name: client_id type: string case_settable: true constraints: {pattern: "^[a-z0-9][a-z0-9-]{1,62}$"} sensitivity: operational description: OAuth2 client_id, unique in KeyCape. - name: display_name type: string case_settable: true constraints: {pattern: "^[^\\n\\r]{1,80}$"} sensitivity: public description: Human-readable client name. - name: audience type: string case_settable: true constraints: {pattern: "^[a-z0-9][a-z0-9-]{1,62}$"} sensitivity: security_sensitive description: >- The one resource server the tokens are for (the aud claim). One client per audience: a workload that calls two resource servers holds two cases. - name: allowed_scopes type: string case_settable: true constraints: {pattern: "^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*(,[a-z][a-z0-9-]*:[a-z][a-z0-9-]*)*$"} sensitivity: security_sensitive description: >- Comma-separated resource scopes of the audience (for example approval:read). OIDC identity scopes (openid, profile, email, groups) are not admitted: a service token carries no directory user. - name: service_subject type: string case_settable: true constraints: {pattern: "^service:[a-z0-9][a-z0-9-]{1,62}$"} sensitivity: security_sensitive description: >- The sub KeyCape writes into every token of this client. Resource servers compare it exactly (approval-engine: binding.actor must equal it), so it is the workload's identity at the resource server. - name: tenant type: string case_settable: true constraints: {pattern: "^tenant:[a-z0-9][a-z0-9-]{1,62}$"} sensitivity: security_sensitive description: The registration-bound tenant claim. - name: roles type: string required: false case_settable: true constraints: {pattern: "^[a-z][a-z0-9-]{1,62}(,[a-z][a-z0-9-]{1,62})*$"} sensitivity: operational description: Comma-separated roles carried in the token, if the resource server reads them. - name: token_lifetime type: string case_settable: true constraints: {enum: ["5m", "10m", "15m"]} sensitivity: security_sensitive description: Access-token lifetime, at most 15m. There is no refresh token; the workload exchanges again. - name: consuming_service type: string case_settable: true constraints: {pattern: "^[a-z0-9][a-z0-9-]*$"} sensitivity: operational description: The workload that uses the client. - name: consumer_namespace type: string case_settable: true constraints: {pattern: "^(?!(external-secrets|openbao)$)[a-z0-9][a-z0-9-]{0,62}$"} sensitivity: security_sensitive 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: "^(?!default$)[a-z0-9][a-z0-9-]{0,62}$"} sensitivity: security_sensitive 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 constraints: {pattern: "^[a-z0-9][a-z0-9-]*$"} sensitivity: operational description: The repository that owns the consuming workload. - name: issuer type: string case_settable: false default: https://kc.coulomb.social sensitivity: security_sensitive description: The NetKingdom issuer; fixed by the type. composition: authority: netkingdom initial_trust: [runtime_secret_trust, runtime_identity_trust] capabilities: [c1.lightweight-sso] providers: c1.lightweight-sso: net-kingdom.keycape-service-client-registration parameter_bindings: net-kingdom.keycape-service-client-registration.client_id: client_id net-kingdom.keycape-service-client-registration.display_name: display_name net-kingdom.keycape-service-client-registration.audience: audience net-kingdom.keycape-service-client-registration.allowed_scopes: allowed_scopes net-kingdom.keycape-service-client-registration.service_subject: service_subject net-kingdom.keycape-service-client-registration.tenant: tenant net-kingdom.keycape-service-client-registration.token_lifetime: token_lifetime materialization: # Custody follows oidc-browser-relying-party (platform/workloads/keycape/, # KeyCape reads through ESO). The consumer reads natively with its own short # Kubernetes-auth role, as the bounded executor's lanes do (CCR-2026-0058/0059), # so no second Secret copy exists in the consumer's namespace. - id: client-secret-path kind: kv_path template: "platform/workloads/keycape/{client_id}" body: "field CLIENT_SECRET; minted by the credential broker, never written by hand" - id: keycape-reader-policy kind: openbao_policy template: "workload-kv-read-keycape-{client_id}-eso" body: | path "platform/data/workloads/keycape/{client_id}" {{ capabilities = ["read"] }} 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}" body: | 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 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}" body: | path "platform/data/workloads/keycape/{client_id}" {{ capabilities = ["read"] }} path "auth/token/lookup-self" {{ capabilities = ["read"] }} path "auth/token/revoke-self" {{ capabilities = ["update"] }} description: The consuming workload reads the same secret. No metadata, list, parent or sibling access. - id: consumer-reader-role kind: openbao_role # 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}] bound_service_account_namespaces: [{consumer_namespace}] audience: openbao token_policies: [workload-kv-read-keycape-{client_id}-{consuming_service}] token_no_default_policy: true token_ttl: 15m token_max_ttl: 15m token_explicit_max_ttl: 15m description: >- Native login for the consuming workload, one role for this lane only (railiance-platform: policies are never combined in one role). - id: catalog-entry kind: catalog_entry template: "{consuming_service}-keycape-{client_id}" body: | id: {consuming_service}-keycape-{client_id} owner_repo: key-cape warden_executes: false risk: high status: active once verification passes workload_ref: applicable, name {consuming_service}, declaration_ref of the consumer's published declaration or the explicit unknown form, never inferred path_template: platform/workloads/keycape/{client_id} field: CLIENT_SECRET delegation: native, intended_owner key-cape 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 owner: railiance-platform entry_point: "railiance-platform credential broker: generate a client secret inside custody at bao:platform/workloads/keycape/{client_id}" auth: "the caller's broker grant for the case's KV path, through its lane; the value is generated in custody and never returned" lane: keycape-client-secret-generate warden_executes: true attended: false value_handling: generated_in_custody - id: deliver-secret owner: railiance-platform entry_point: "ExternalSecret sso/keycape-{client_id}-client (key client-secret) from bao:platform/workloads/keycape/{client_id}" auth: "railiance-platform ESO delivery through role external-secrets-keycape-{client_id}; no value leaves custody" warden_executes: false attended: false value_handling: none ready_check: "the delivery for sso/keycape-{client_id}-client reports Ready=True (status only, no value read)" - id: bind-env owner: net-kingdom entry_point: "patch Deployment sso/keycape: env KEYCAPE_{client_id:env}_CLIENT_SECRET from sso/keycape-{client_id}-client" auth: "operator kube credentials for namespace sso" lane: key-cape-oidc-login warden_executes: false attended: true value_handling: none attended_justification: >- 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 --case-acceptance --check-config (net-kingdom.keycape-service-client-registration)" auth: "operator write access to the live KeyCape client registry in namespace sso" lane: key-cape-oidc-login warden_executes: false attended: true value_handling: none attended_justification: >- The live KeyCape client registry is a Kubernetes Secret, and agents do not read or write Secrets. One attended operator session applies the rendered registration together with bind-env. KeyCape loads clients at startup, so the restart drops in-flight logins for every client; runs go in a quiet window. The rendered registry must pass KeyCape's own startup checks (keycape check-config) before the restart. - id: hand-over owner: "{consumer_owner_repo}" entry_point: "configure {consuming_service} with issuer {issuer}, client {client_id}, audience {audience} and its OpenBao role workload-kv-read-keycape-{client_id}-{consuming_service}; it exchanges a fresh token per call and keeps neither the secret nor a token beyond the call" auth: "the consumer owner's own deployment path" warden_executes: false attended: false value_handling: none 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, 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 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 check: "a default-only OpenBao identity has no read capability on bao:platform/workloads/keycape/{client_id}" expect: deny reads_secret_value: false - id: consumer-login-bound 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 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 reads_secret_value: false rollback: - reverses: hand-over action: "remove client {client_id} from {consuming_service}'s configuration (owner {consumer_owner_repo})" - reverses: register-client action: "keycape-client.py apply --operation remove for {client_id}" - reverses: bind-env action: "remove env KEYCAPE_{client_id:env}_CLIENT_SECRET from Deployment sso/keycape, after register-client is reversed" - reverses: deliver-secret action: "delete ExternalSecret sso/keycape-{client_id}-client and its target" - reverses: mint-secret action: "covered by client-secret-path: one metadata delete, after deliver-secret is reversed" - reverses: client-secret-path action: "delete metadata and all versions at platform/workloads/keycape/{client_id}, after the ExternalSecret is gone (this also reverses mint-secret)" - reverses: eso-role action: "delete Kubernetes-auth role external-secrets-keycape-{client_id}" - 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 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 action: "remove catalog entry {consuming_service}-keycape-{client_id}" acceptance: accepted_by: [owner, founder] risk_class: medium