net-kingdom/security-case-types/oidc-browser-relying-party/type.yaml
tegwick 35880bbce4
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
NK-WP-0048: run keycape check-config before any registry patch
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
2026-10-10 15:28:05 +02:00

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