net-kingdom/security-case-types/oidc-service-client/type.yaml
tegwick 5d5f15df55
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
NK-WP-0056-T14 done: SecurityCaseType oidc-service-client 0.1 (proposed)
Confidential client_credentials service clients: one audience, closed
resource scopes, fixed service subject and tenant, at most 15m; secret minted
in custody, read only by KeyCape (ESO) and the consumer's own bound
Kubernetes-auth role (no Secret copy). First case nk-bind-executor-approval
(approval-engine create/read/consume as service:nk-bind-executor). Capability
net-kingdom.keycape-service-client-registration; keycape-client.py renders
service clients in key-cape's shape (adoption of secrets-engine-approval
reproduces key-cape's record). Acknowledgement memo r1 validated against
informed-decision intake and State Hub dispatcher; hub decision f8044f81.
Test fixture's synthetic signing key is now computed, not a literal (secret
scan generic-api-key).

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-10-31 21:30:21 +01:00

301 lines
15 KiB
YAML

apiVersion: netkingdom.io/security-case-type/v0.1
kind: SecurityCaseType
spec:
id: oidc-service-client
version: "0.1"
status: proposed
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: "^[a-z0-9][a-z0-9-]{0,62}$"}
sensitivity: security_sensitive
description: Namespace of the consuming workload's ServiceAccount.
- name: consumer_service_account
type: string
case_settable: true
constraints: {pattern: "^[a-z0-9][a-z0-9-]{0,62}$"}
sensitivity: security_sensitive
description: The ServiceAccount whose openbao-audience token may read the client secret.
- 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"]
}}
description: KeyCape reads its client secret only through ESO. No metadata, list, parent or sibling access.
- 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]
token_policies: [workload-kv-read-keycape-{client_id}-eso]
token_no_default_policy: true
token_ttl: 15m
token_max_ttl: 15m
description: The Kubernetes-auth role deliver-secret uses; bound to the ESO service account only.
- 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
template: "workload-kv-read-keycape-{client_id}-{consuming_service}"
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
rotation: mint-secret then verification; automatable false until a run has been proven
description: One entry per case; carries the consumer's workload reference for zone selection (ADR-0009).
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. It runs in the same
attended operator session as register-client, before it, because KeyCape
resolves a confidential client's secret at start and fails closed if the
binding is missing.
- 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
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, scope and lifetime; expected sub {service_subject}, aud {audience}, tenant {tenant}, 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 returns invalid_scope"
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 workload-kv-read-keycape-{client_id}-{consuming_service} 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 nothing else (capabilities check only)"
expect: allow
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 workload-kv-read-keycape-{client_id}-{consuming_service}"
- 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