2026-10-31 21:30:21 +01:00
apiVersion : netkingdom.io/security-case-type/v0.1
kind : SecurityCaseType
spec :
id : oidc-service-client
2026-11-01 00:34:17 +01:00
version : "0.2"
2026-11-01 10:18:09 +01:00
status : acknowledged
2026-10-31 21:30:21 +01:00
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
2026-11-01 00:34:17 +01:00
constraints : {pattern : "^(?!(external-secrets|openbao)$)[a-z0-9][a-z0-9-]{0,62}$" }
2026-10-31 21:30:21 +01:00
sensitivity : security_sensitive
2026-11-01 00:34:17 +01:00
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).
2026-10-31 21:30:21 +01:00
- name : consumer_service_account
type : string
case_settable : true
2026-11-01 00:34:17 +01:00
constraints : {pattern : "^(?!default$)[a-z0-9][a-z0-9-]{0,62}$" }
2026-10-31 21:30:21 +01:00
sensitivity : security_sensitive
2026-11-01 00:34:17 +01:00
description : The dedicated ServiceAccount whose openbao-audience token may read the client secret; never default (ops-mason R6).
2026-10-31 21:30:21 +01:00
- 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/<client_id>,
# 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"]
}}
2026-11-01 00:34:17 +01:00
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).
2026-10-31 21:30:21 +01:00
- 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]
2026-11-01 00:34:17 +01:00
audience : openbao
2026-10-31 21:30:21 +01:00
token_policies : [ workload-kv-read-keycape-{client_id}-eso]
token_no_default_policy : true
token_ttl : 15m
token_max_ttl : 15m
2026-11-01 00:34:17 +01:00
token_explicit_max_ttl : 15m
description : The Kubernetes-auth role deliver-secret uses; bound to the ESO service account only (ops-mason R3).
2026-10-31 21:30:21 +01:00
- 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
2026-11-01 00:34:17 +01:00
# The delegated applier refuses a role named like its policy (railiance-platform R4).
template : "keycape-{client_id}-{consuming_service}-workload-kv-read"
2026-10-31 21:30:21 +01:00
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
2026-11-01 00:34:17 +01:00
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).
2026-10-31 21:30:21 +01:00
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 : >-
2026-11-01 00:34:17 +01:00
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.
2026-10-31 21:30:21 +01:00
- id : register-client
owner : net-kingdom
entry_point : "sso-mfa/k8s/keycape/keycape-client.py apply --client-file <client-from-case> --case-acceptance <record> --check-config <keycape binary> (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
2026-11-01 00:34:17 +01:00
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)"
2026-10-31 21:30:21 +01:00
expect : present
reads_secret_value : false
- id : excess-scope-refused
2026-11-01 00:34:17 +01:00
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)"
2026-10-31 21:30:21 +01:00
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
2026-11-01 00:34:17 +01:00
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)"
2026-10-31 21:30:21 +01:00
expect : deny
reads_secret_value : false
- id : custody-allowed-to-consumer
2026-11-01 00:34:17 +01:00
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)"
2026-10-31 21:30:21 +01:00
expect : allow
reads_secret_value : false
2026-11-01 00:34:17 +01:00
- 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
2026-10-31 21:30:21 +01:00
- 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
2026-11-01 00:34:17 +01:00
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)"
2026-10-31 21:30:21 +01:00
- 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