net-kingdom/security-case-types/oidc-browser-relying-party/type.yaml
tegwick eb8d7b2edc
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
NK-WP-0048-T05: done; oidc-browser-relying-party 1.0 acknowledged (memo r4)
The founder accepted memo r4 in informed-decision. Its hub write-back is off
in production, so hub decision e418263e was resolved from the founder's report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 469893@bnt-lap001
Assistant-Session: 49296f15-2171-4137-b972-2443bab95edb
2026-10-09 12:15:50 +02:00

293 lines
14 KiB
YAML

apiVersion: netkingdom.io/security-case-type/v0.1
kind: SecurityCaseType
spec:
id: oidc-browser-relying-party
version: "1.0"
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.
- name: allowed_scopes
type: string
case_settable: true
constraints: {enum: ["openid", "openid,profile", "openid,profile,email", "openid,profile,email,groups", "openid,email", "openid,email,groups", "openid,profile,groups", "openid,groups"]}
sensitivity: security_sensitive
description: Scopes the client may request, openid first.
- 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: Pin MFA on this client. Required "true" when the service grants administrative or owner rights.
- 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 gate sign-in and that the service maps to its roles. Created if absent.
- 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>_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> (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.
- 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}"
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: 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>_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