keycape-client.py --check-config runs KeyCape's startup checks (key-cape c528daa) on the live and proposed registry via stdin; apply refuses a registry KeyCape would refuse at start. Type 1.1 patch names the option. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 736651@bnt-lap001 Assistant-Session: f6189624-fd64-4109-b53e-f8a59ca8a2d8
322 lines
16 KiB
YAML
322 lines
16 KiB
YAML
apiVersion: netkingdom.io/security-case-type/v0.1
|
|
kind: SecurityCaseType
|
|
spec:
|
|
id: oidc-browser-relying-party
|
|
version: "1.1"
|
|
status: acknowledged
|
|
title: "Browser OIDC relying party for an internal service"
|
|
purpose: >-
|
|
Let an internal web service sign its human users in through NetKingdom
|
|
(KeyCape): an exact-redirect OIDC client, optional MFA pin, the LLDAP groups
|
|
the service maps to its own roles, and, for confidential clients, a client
|
|
secret minted straight into OpenBao custody. Replaces the per-client rollout
|
|
scripts in sso-mfa/k8s/keycape. First case: Forgejo (KEY-WP-0038).
|
|
|
|
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: Name shown on the sign-in page.
|
|
- name: redirect_uris
|
|
type: string
|
|
case_settable: true
|
|
constraints: {pattern: "^https://[^*?#\\s,]+(,https://[^*?#\\s,]+)*$"}
|
|
sensitivity: security_sensitive
|
|
description: >-
|
|
Comma-separated exact https redirect URIs. No wildcards, no plain http, no
|
|
query and no fragment: KeyCape refuses '*' and '?' at startup, and OAuth
|
|
2.0 forbids fragments (key-cape type review R1).
|
|
- name: allowed_scopes
|
|
type: string
|
|
case_settable: true
|
|
constraints: {enum: ["openid,profile,email,groups", "openid,email,groups", "openid,profile,groups", "openid,groups"]}
|
|
sensitivity: security_sensitive
|
|
description: >-
|
|
Scopes the client may request, openid first. Always includes groups,
|
|
because access_groups is required and KeyCape's UserInfo returns groups
|
|
only for that scope (key-cape type review R2).
|
|
- name: client_type
|
|
type: string
|
|
case_settable: true
|
|
constraints: {enum: ["confidential", "public"]}
|
|
sensitivity: security_sensitive
|
|
description: confidential for server-side services that can hold a secret; public for SPAs and native apps (PKCE only).
|
|
- name: mfa_required
|
|
type: string
|
|
case_settable: true
|
|
constraints: {enum: ["true", "false"]}
|
|
sensitivity: security_sensitive
|
|
description: >-
|
|
"true" pins MFA on this client; required when the service grants
|
|
administrative or owner rights. "false" is not "no MFA": the client then
|
|
carries no mfaRequired field and KeyCape applies the per-user privacyIDEA
|
|
policy. A renderer must never emit an explicit mfaRequired false, which
|
|
would skip that policy (key-cape type review R3).
|
|
- name: access_groups
|
|
type: string
|
|
case_settable: true
|
|
constraints: {pattern: "^[a-z][a-z0-9-]{1,62}(,[a-z][a-z0-9-]{1,62})*$"}
|
|
sensitivity: operational
|
|
description: >-
|
|
Comma-separated LLDAP groups that the service maps to its roles. Created if
|
|
absent. KeyCape has no per-client group gate: it issues tokens to any
|
|
authenticated user and only carries the groups claim, so the consuming
|
|
service enforces the gate itself (for Forgejo, its required-claim setting;
|
|
key-cape type review R2).
|
|
- name: initial_members
|
|
type: string
|
|
required: false
|
|
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-]*=[a-z][a-z0-9._-]*(\\+[a-z][a-z0-9._-]*)*)*$"}
|
|
sensitivity: operational
|
|
description: "Initial memberships as group=user+user;group=user. Usernames are directory usernames, never secrets."
|
|
- name: consuming_service
|
|
type: string
|
|
case_settable: true
|
|
constraints: {pattern: "^[a-z0-9][a-z0-9-]*$"}
|
|
sensitivity: operational
|
|
description: The service that signs users in with this client.
|
|
- 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 service's configuration.
|
|
- 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-client-registration
|
|
parameter_bindings:
|
|
net-kingdom.keycape-client-registration.client_id: client_id
|
|
net-kingdom.keycape-client-registration.display_name: display_name
|
|
net-kingdom.keycape-client-registration.redirect_uris: redirect_uris
|
|
net-kingdom.keycape-client-registration.allowed_scopes: allowed_scopes
|
|
net-kingdom.keycape-client-registration.client_type: client_type
|
|
net-kingdom.keycape-client-registration.mfa_required: mfa_required
|
|
|
|
materialization:
|
|
# Placement follows the live CCR-2026-0033 reference (secrets-engine-openbao,
|
|
# ops-mason plans/keycape-secrets-engine-openbao-client-custody.md). The path
|
|
# is derived, not case-settable, and the policies reuse the existing
|
|
# workload-kv-read-keycape-<client>-<reader> family (MASON-WP-0010-T01).
|
|
- id: client-secret-path
|
|
kind: kv_path
|
|
when: "client_type == confidential"
|
|
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
|
|
when: "client_type == confidential"
|
|
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: consumer-reader-policy
|
|
kind: openbao_policy
|
|
when: "client_type == confidential"
|
|
template: "workload-kv-read-keycape-{client_id}-{consuming_service}"
|
|
body: |
|
|
path "platform/data/workloads/keycape/{client_id}" {{
|
|
capabilities = ["read"]
|
|
}}
|
|
description: The consuming service reads the same secret. No metadata, list, parent or sibling access.
|
|
- id: eso-role
|
|
kind: openbao_role
|
|
when: "client_type == confidential"
|
|
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: catalog-entry
|
|
kind: catalog_entry
|
|
template: "{consuming_service}-keycape-client"
|
|
body: |
|
|
id: {consuming_service}-keycape-client
|
|
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 (ops-mason item 5, ops-warden R4'). It carries the
|
|
consumer's workload reference, which ADR-0009 needs for zone selection.
|
|
follow_through:
|
|
- id: ensure-groups
|
|
owner: net-kingdom
|
|
entry_point: "identity-provisioner ensure_service_groups, run inside the provisioner pod through the session's contained cluster access (groups {access_groups}; members exactly {initial_members} as recorded in the accepted case, nothing else)"
|
|
auth: "operator cluster access in the attended register-client session; the in-pod call uses the provisioner's own directory binding, and no provisioner or user-engine token is read or sent"
|
|
lane: key-cape-oidc-login
|
|
warden_executes: false
|
|
attended: true
|
|
value_handling: none
|
|
attended_justification: >-
|
|
The provisioner's only HTTP credential is user-engine's runtime token, and
|
|
its NetworkPolicy admits only user-engine pods. A proxy lane would act as
|
|
user-engine (ops-warden review R8; canon section 3; ops-warden ADR-0002).
|
|
Running it in the session register-client already needs adds no attended
|
|
session. It can become unattended once the provisioner has an operator
|
|
credential of its own and an admitted path.
|
|
- id: mint-secret
|
|
owner: railiance-platform
|
|
when: "client_type == confidential"
|
|
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
|
|
when: "client_type == confidential"
|
|
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
|
|
when: "client_type == confidential"
|
|
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-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. Until KeyCape loads clients from a
|
|
non-secret registry with secrets referenced from custody, one attended
|
|
operator session applies the rendered registration. ops-warden groups
|
|
all attended steps of a run into that one session. KeyCape loads clients
|
|
at startup and keeps login, authorization and handoff state in memory, so
|
|
the restart drops in-flight logins and sessions for every client; runs go
|
|
in a quiet window (key-cape type review R4). The rendered registry must
|
|
pass KeyCape's own startup checks (keycape check-config, key-cape
|
|
c528daa, which keycape-client.py runs before it patches) before the
|
|
restart: a refused entry stops the single replica and every client
|
|
loses sign-in (R1).
|
|
- id: hand-over
|
|
owner: "{consumer_owner_repo}"
|
|
entry_point: "configure {consuming_service} with issuer {issuer}, client {client_id} and custody bao:platform/workloads/keycape/{client_id}, and make it require one of {access_groups} in the groups claim before sign-in completes"
|
|
auth: "the consumer owner's own deployment path"
|
|
warden_executes: false
|
|
attended: false
|
|
value_handling: none
|
|
|
|
verification:
|
|
- id: authorize-accepts-redirects
|
|
check: "GET {issuer}/authorize with client {client_id} and each of {redirect_uris} returns the login page"
|
|
expect: present
|
|
reads_secret_value: false
|
|
- id: authorize-refuses-other-redirect
|
|
check: "GET {issuer}/authorize with client {client_id} and an unregistered redirect returns an OAuth error"
|
|
expect: deny
|
|
reads_secret_value: false
|
|
- id: custody-denied-to-default
|
|
when: "client_type == confidential"
|
|
check: "a default-only OpenBao identity has no read capability on bao:platform/workloads/keycape/{client_id}"
|
|
expect: deny
|
|
reads_secret_value: false
|
|
- id: custody-allowed-to-consumer
|
|
when: "client_type == confidential"
|
|
check: "policy workload-kv-read-keycape-{client_id}-{consuming_service} grants read on bao:platform/workloads/keycape/{client_id} (capabilities check only)"
|
|
expect: allow
|
|
reads_secret_value: false
|
|
- id: groups-present
|
|
check: "LLDAP groups {access_groups} exist, with exactly the memberships of the accepted case added"
|
|
expect: present
|
|
reads_secret_value: false
|
|
- id: consumer-enforces-groups
|
|
# KeyCape has no per-client group gate; only the consumer can enforce
|
|
# access_groups (key-cape type review R2, type 1.1).
|
|
check: "{consuming_service}'s OIDC source for {client_id} (owner {consumer_owner_repo}) requires one of {access_groups} in the groups claim (configuration read, no secret value)"
|
|
expect: present
|
|
reads_secret_value: false
|
|
- id: secret-delivered
|
|
when: "client_type == confidential"
|
|
check: "the delivery for sso/keycape-{client_id}-client reports Ready=True (status only)"
|
|
expect: present
|
|
reads_secret_value: false
|
|
- id: mfa-pinned
|
|
when: "mfa_required == true"
|
|
check: "keycape-client.py inspect for {client_id} shows the MFA pin on the registered client (non-secret client view)"
|
|
expect: present
|
|
reads_secret_value: false
|
|
|
|
rollback:
|
|
- reverses: hand-over
|
|
action: "remove the OIDC source for {client_id} from {consuming_service} (owner {consumer_owner_repo})"
|
|
- reverses: register-client
|
|
action: "keycape-client.py apply --operation remove for {client_id}"
|
|
- reverses: bind-env
|
|
when: "client_type == confidential"
|
|
action: "remove env KEYCAPE_{client_id:env}_CLIENT_SECRET from Deployment sso/keycape, after register-client is reversed"
|
|
- reverses: deliver-secret
|
|
when: "client_type == confidential"
|
|
action: "delete ExternalSecret sso/keycape-{client_id}-client and its target"
|
|
- reverses: mint-secret
|
|
when: "client_type == confidential"
|
|
action: "covered by client-secret-path: one metadata delete, after deliver-secret is reversed"
|
|
- reverses: ensure-groups
|
|
action: "identity-provisioner revoke_service_groups inside the provisioner pod (attended, as ensure-groups) with the case's memberships; delete_if_empty only if no other case uses {access_groups}"
|
|
- reverses: client-secret-path
|
|
when: "client_type == confidential"
|
|
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
|
|
when: "client_type == confidential"
|
|
action: "delete Kubernetes-auth role external-secrets-keycape-{client_id}"
|
|
- reverses: keycape-reader-policy
|
|
when: "client_type == confidential"
|
|
action: "delete policy workload-kv-read-keycape-{client_id}-eso, after eso-role"
|
|
- reverses: consumer-reader-policy
|
|
when: "client_type == confidential"
|
|
action: "delete policy workload-kv-read-keycape-{client_id}-{consuming_service}"
|
|
- reverses: catalog-entry
|
|
action: "remove catalog entry {consuming_service}-keycape-client"
|
|
|
|
acceptance:
|
|
accepted_by: [owner, founder]
|
|
risk_class: medium
|