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
301 lines
15 KiB
YAML
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
|