NK-WP-0048-T04: SecurityCaseType oidc-browser-relying-party 1.0 (proposed)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Composes key-cape.oidc-client-registration; confidential/public conditions,
one justified attended step, threat model, fit table of hand-built clients,
Forgejo and public-portal examples. Validator gains when/required_when and
provider discovery. 43 tests pass. Adds T08 (identity-provisioner groups ensure).

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5457@bnt-lap001
Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012
This commit is contained in:
tegwick 2026-10-08 22:45:31 +02:00
parent 9f49aae925
commit fa2cb57384
12 changed files with 976 additions and 76 deletions

View file

@ -1,7 +1,8 @@
"""Tests every SecurityCaseType carries: the type validates, its example cases
validate against it, and each example renders into a composer plan.
Copied with the template; keep it and add type-specific tests beside it.
Copied with the template: rename it to test_<type_id_with_underscores>_contract.py
(pytest needs unique module names) and add type-specific tests beside it.
"""
import importlib.util
@ -20,7 +21,9 @@ SPEC.loader.exec_module(validator)
TYPE = yaml.safe_load((TYPE_DIR / "type.yaml").read_text())
EXAMPLES = sorted((TYPE_DIR / "examples").glob("*.yaml"))
DECLARATIONS = sorted(str(p) for p in (REPO / "capabilities" / "playbooks").glob("*.yaml"))
# Provider declarations live in their owning repositories, checked out side by
# side with net-kingdom (the contract's publication path).
DECLARATIONS, DISCOVERY_ISSUES = validator.discover_declarations(TYPE, REPO.parent)
# Until the type is implemented, examples are checked as if it were.
IMPLEMENTED = TYPE["spec"]["status"] == "implemented"
@ -41,6 +44,7 @@ def test_examples_exist_and_validate():
def test_examples_render_deterministically():
assert errors(DISCOVERY_ISSUES) == [], "provider declarations must be checked out beside net-kingdom"
declarations, load_issues = validator.composer.load_declarations(DECLARATIONS)
assert errors(load_issues) == []
for path in EXAMPLES:

View file

@ -0,0 +1,100 @@
# oidc-browser-relying-party 1.0
An internal web service signs its human users in through NetKingdom (KeyCape)
with an OIDC authorization-code client. This type covers the client
registration, the LLDAP groups that gate sign-in, and, for confidential
clients, the client secret, minted straight into OpenBao custody and readable
only by KeyCape and the consuming service.
Canon: `canon/standards/security-case-types_v0.1.md`. Status: **proposed**; it
awaits acknowledgment through informed-decision (NK-WP-0048-T05).
## Purpose
Until now every browser client was registered by its own script in
`sso-mfa/k8s/keycape/` (`register-coulomb-social.py`, `portal-client-rollout.py`,
`register-user-engine-portal.py`, `vergabe-client-rollout.py`,
`openbao-client-config.py`), each with its own test and an attended founder run.
The scripts share one pattern: a non-exporting merge into the live client
registry, with inspect, dry-run, apply and remove modes. key-cape now declares
that pattern once as the playbook capability `key-cape.oidc-client-registration`.
This type composes it.
### Reference instances and fit
| Client | Fits 1.0 | Why not, and what would cover it |
| --- | --- | --- |
| user-engine portal | yes | — |
| vergabe-demo-company | yes | — |
| Forgejo (pending, KEY-WP-0038) | yes | first confidential case and the reference case for `implemented` |
| coulomb.social | partly | also registers plain-http loopback redirects for local development |
| openbao-admin | no | a CLI client with plain-http loopback callbacks; candidate type `oidc-cli-loopback-client` |
| informed-decision-approver | no | carries a resource `audience`, a registration-bound `tenant` and custom scopes; candidate 1.1 parameters or a separate type |
Adopting an existing client as a case does not change its live registration
(canon section 9).
## Threat model
Assets: the users' sessions at the consuming service; the confidential client
secret; group memberships that decide who may sign in and with what rights.
| Threat | Control in this type |
| --- | --- |
| Authorization code or token sent to an attacker-controlled URL | exact `redirect_uris`, https only, no wildcards, validated by the type and enforced by KeyCape |
| Anyone with a NetKingdom account gains access to the service | `access_groups` gate sign-in at the consumer; membership is explicit (`initial_members`), never implied |
| Stolen password grants admin rights | `mfa_required: "true"` is pinned on the client when the service grants administrative or owner rights, not left to the deployment default |
| Client secret leaks through git, chat or a case file | the secret never appears in a case; it is minted into `secret_ref` by the credential broker, and only two policies can read it |
| Secret readable by a broad identity | verification includes a negative check that a default-only identity cannot read `secret_ref` |
| Over-broad scopes leak profile data | `allowed_scopes` is a closed list; `openid` first |
| Silent drift between hand-built clients | one capability, one type, one rollback; per-client scripts retire as clients are adopted |
Out of scope: the consuming service's own authorization model (how groups map to
roles there), token lifetime policy (KeyCape's), and federation (C5).
## Alternatives considered
- **Keep per-client scripts.** Rejected by the founder (2026-10-08): too much
manual work, and every script is a separate review surface.
- **Wildcard or prefix redirect URIs.** Rejected: the classic open-redirect
weakness; exact strings cost nothing.
- **Auto-create Forgejo/consumer accounts for any NetKingdom user.** Rejected:
sign-in is gated by explicit groups; the consumer decides account creation
(Forgejo: link existing accounts, no automatic creation).
- **Public clients everywhere (PKCE only).** Allowed for SPAs and native apps;
server-side services use confidential clients, so that a stolen authorization
code alone is not enough.
- **Put the client secret in the case for convenience.** Rejected: cases carry
custody references only (canon section 9).
## Attended steps
`register-client` is attended. KeyCape's live client registry is a Kubernetes
Secret, and agents do not read or write Secrets. ops-warden groups every attended
step of a run into one operator session (WARDEN-WP-0044). The way to remove this
step is a KeyCape change: load clients from a non-secret registry and reference
confidential secrets from custody. That would be a 1.1 or 2.0 of this type.
## Known limits
- No loopback or plain-http redirects (CLI and local development clients).
- No resource `audience`, registration-bound `tenant` or custom scopes.
- No service (`client_credentials`) clients: that is the `oidc-service-client` type.
- Group creation assumes the identity-provisioner exposes `groups ensure`; until
it does, `ensure-groups` is the owner's open item (NK-WP-0048-T04).
- `custody` uses the railiance-platform credential broker; that owner has not yet
declared a `c3.runtime-secrets` capability, so `mint-secret` is a follow-through
step rather than a composed capability. When it is declared, compose it.
## Verification and rollback
Verification proves the registration with the authorization endpoint (accepts
each registered redirect, refuses an unregistered one), checks group existence,
and for confidential clients checks custody by capabilities only: the default
identity is denied, the consumer's policy is allowed. Nothing reads a secret
value.
Rollback is the exact inverse, in reverse order. The hand-over is removed first,
then the registration, then the secret and its policies, then the case's group
memberships. Groups are deleted only if this case created them and no other case
uses them.

View file

@ -0,0 +1,20 @@
# Illustrative public (PKCE-only) client. Example values only.
apiVersion: netkingdom.io/security-case/v0.1
kind: SecurityCase
spec:
id: example-public-portal
type: oidc-browser-relying-party@1.0
status: proposed
owner:
repo: net-kingdom
person: example-owner
parameters:
client_id: example-portal
display_name: Example portal
redirect_uris: https://portal.example.invalid/auth/callback
allowed_scopes: openid,profile,email
client_type: public
mfa_required: "false"
access_groups: example-portal-users
consuming_service: example-portal
consumer_owner_repo: net-kingdom

View file

@ -0,0 +1,23 @@
# First KnownSecurityCase (NK-WP-0048-T06), from the request the founder accepted
# on 2026-10-08 (key-cape docs/forgejo-oidc-client-request.yaml, KEY-WP-0038-T01).
apiVersion: netkingdom.io/security-case/v0.1
kind: SecurityCase
spec:
id: forgejo-netkingdom-oidc
type: oidc-browser-relying-party@1.0
status: proposed
owner:
repo: railiance-apps
person: founder
parameters:
client_id: forgejo
display_name: Forgejo source forge
redirect_uris: https://forgejo.coulomb.social/user/oauth2/netkingdom/callback
allowed_scopes: openid,profile,email,groups
client_type: confidential
mfa_required: "true"
access_groups: forge-admins,forge-coulomb-owners,forge-coulomb-readers
initial_members: forge-admins=tegwick;forge-coulomb-owners=tegwick
secret_ref: bao:platform/workloads/forgejo/netkingdom-oidc-client
consuming_service: forgejo
consumer_owner_repo: railiance-apps

View file

@ -0,0 +1,102 @@
"""Type-specific tests for oidc-browser-relying-party 1.0."""
import copy
import importlib.util
import sys
from pathlib import Path
import yaml
TYPE_DIR = Path(__file__).resolve().parents[1]
REPO = TYPE_DIR.parents[1]
SPEC = importlib.util.spec_from_file_location(
"security_case_validator", REPO / "tools" / "security-case-types" / "security_case_validator.py")
v = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = v
SPEC.loader.exec_module(v)
TYPE = yaml.safe_load((TYPE_DIR / "type.yaml").read_text())
FORGEJO = yaml.safe_load((TYPE_DIR / "examples" / "forgejo.yaml").read_text())
PUBLIC = yaml.safe_load((TYPE_DIR / "examples" / "example-public-portal.yaml").read_text())
DECL_PATHS, _ = v.discover_declarations(TYPE, REPO.parent)
DECLS, _ = v.composer.load_declarations(DECL_PATHS)
def msgs(issues):
return [f"{i.path}: {i.message}" for i in issues if i.level == "ERROR"]
def as_implemented():
t = copy.deepcopy(TYPE)
t["spec"]["status"] = "implemented"
return t
def with_params(case, **changes):
c = copy.deepcopy(case)
for k, val in changes.items():
if val is None:
c["spec"]["parameters"].pop(k, None)
else:
c["spec"]["parameters"][k] = val
return c
def test_forgejo_case_matches_the_accepted_request():
accepted = yaml.safe_load((REPO.parent / "key-cape" / "docs" / "forgejo-oidc-client-request.yaml").read_text())
p = FORGEJO["spec"]["parameters"]
assert p["client_id"] == accepted["client"]["clientId"]
assert p["redirect_uris"].split(",") == accepted["client"]["redirectUris"]
assert p["allowed_scopes"].split(",") == accepted["client"]["allowedScopes"]
assert p["client_type"] == accepted["client"]["clientType"]
assert p["mfa_required"] == str(accepted["client"]["mfaRequired"]).lower()
assert p["secret_ref"] == "bao:" + accepted["custody"]["proposed_openbao_path"]
assert set(p["access_groups"].split(",")) == set(accepted["forgejo_mapping"]["required_claim"]["any_of"])
def test_redirects_must_be_exact_https():
t = as_implemented()
for bad in ["https://forgejo.coulomb.social/*", "http://forgejo.coulomb.social/cb",
"https://a.example/cb, https://b.example/cb"]:
assert any("redirect_uris" in m for m in msgs(v.validate_case(with_params(FORGEJO, redirect_uris=bad), t))), bad
def test_confidential_client_needs_custody_reference_and_public_does_not():
t = as_implemented()
assert any("required when client_type == confidential" in m
for m in msgs(v.validate_case(with_params(FORGEJO, secret_ref=None), t)))
assert msgs(v.validate_case(PUBLIC, t)) == []
def test_secret_value_instead_of_reference_is_refused():
t = as_implemented()
found = msgs(v.validate_case(with_params(FORGEJO, secret_ref="hvs.CAESIabcdefghijklmnop"), t))
assert found
def test_scopes_are_a_closed_list():
t = as_implemented()
assert any("allowed_scopes" in m
for m in msgs(v.validate_case(with_params(FORGEJO, allowed_scopes="openid,admin"), t)))
def test_public_case_renders_without_custody_steps():
issues, r = v.render_case(PUBLIC, as_implemented(), DECLS)
assert msgs(issues) == []
assert [m["id"] for m in r["materialization"]] == ["catalog-entry"]
assert "mint-secret" not in [s["id"] for s in r["follow_through"]]
assert {c["id"] for c in r["verification"]} >= {"authorize-refuses-other-redirect"}
assert not {c["id"] for c in r["verification"]} & {"custody-denied-to-default", "custody-allowed-to-consumer"}
def test_confidential_case_renders_custody_and_one_attended_step():
issues, r = v.render_case(FORGEJO, as_implemented(), DECLS)
assert msgs(issues) == []
steps = {s["id"]: s for s in r["follow_through"]}
assert set(steps) == {"ensure-groups", "mint-secret", "register-client", "hand-over"}
assert [s for s in steps.values() if s.get("attended")] == [steps["register-client"]]
assert steps["mint-secret"]["entry_point"].endswith("bao:platform/workloads/forgejo/netkingdom-oidc-client")
assert steps["hand-over"]["owner"] == "railiance-apps"
plan = r["composition"]
assert plan["execution"]["permitted"] is False
assert r["execution"] == {"permitted": False}

View file

@ -0,0 +1,56 @@
"""Tests every SecurityCaseType carries: the type validates, its example cases
validate against it, and each example renders into a composer plan.
Copied with the template; keep it and add type-specific tests beside it.
"""
import importlib.util
import sys
from pathlib import Path
import yaml
TYPE_DIR = Path(__file__).resolve().parents[1]
REPO = TYPE_DIR.parents[1]
VALIDATOR = REPO / "tools" / "security-case-types" / "security_case_validator.py"
SPEC = importlib.util.spec_from_file_location("security_case_validator", VALIDATOR)
validator = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = validator
SPEC.loader.exec_module(validator)
TYPE = yaml.safe_load((TYPE_DIR / "type.yaml").read_text())
EXAMPLES = sorted((TYPE_DIR / "examples").glob("*.yaml"))
# Provider declarations live in their owning repositories, checked out side by
# side with net-kingdom (the contract's publication path).
DECLARATIONS, DISCOVERY_ISSUES = validator.discover_declarations(TYPE, REPO.parent)
# Until the type is implemented, examples are checked as if it were.
IMPLEMENTED = TYPE["spec"]["status"] == "implemented"
def errors(issues):
return [f"{i.path}: {i.message}" for i in issues if i.level == "ERROR"]
def test_type_is_complete():
assert errors(validator.validate_type(TYPE, TYPE_DIR)) == []
def test_examples_exist_and_validate():
assert EXAMPLES
for path in EXAMPLES:
case = yaml.safe_load(path.read_text())
assert errors(validator.validate_case(case, TYPE, require_implemented=IMPLEMENTED)) == [], path.name
def test_examples_render_deterministically():
assert errors(DISCOVERY_ISSUES) == [], "provider declarations must be checked out beside net-kingdom"
declarations, load_issues = validator.composer.load_declarations(DECLARATIONS)
assert errors(load_issues) == []
for path in EXAMPLES:
case = yaml.safe_load(path.read_text())
issues, first = validator.render_case(case, TYPE, declarations, require_implemented=IMPLEMENTED)
assert errors(issues) == [], path.name
_, second = validator.render_case(case, TYPE, declarations, require_implemented=IMPLEMENTED)
assert first["digest"] == second["digest"]
assert first["execution"] == {"permitted": False}
assert first["composition"]["execution"]["permitted"] is False

View file

@ -0,0 +1,205 @@
apiVersion: netkingdom.io/security-case-type/v0.1
kind: SecurityCaseType
spec:
id: oidc-browser-relying-party
version: "1.0"
status: proposed
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: secret_ref
type: string
required: false
case_settable: true
constraints:
pattern: "^bao:platform/workloads/[a-z0-9/_-]+$"
required_when: "client_type == confidential"
sensitivity: secret_reference
description: OpenBao custody path for a confidential client's secret. Omitted for public clients.
- 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: key-cape.oidc-client-registration
parameter_bindings:
key-cape.oidc-client-registration.client_id: client_id
key-cape.oidc-client-registration.display_name: display_name
key-cape.oidc-client-registration.redirect_uris: redirect_uris
key-cape.oidc-client-registration.allowed_scopes: allowed_scopes
key-cape.oidc-client-registration.client_type: client_type
key-cape.oidc-client-registration.mfa_required: mfa_required
materialization:
- id: client-secret-path
kind: kv_path
when: "client_type == confidential"
template: "{secret_ref}"
- id: keycape-reader-policy
kind: openbao_policy
when: "client_type == confidential"
template: "keycape-client-{client_id}-read"
- id: consumer-reader-policy
kind: openbao_policy
when: "client_type == confidential"
template: "{consuming_service}-oidc-client-{client_id}-read"
- id: catalog-entry
kind: catalog_entry
template: "oidc-client-{client_id}"
follow_through:
- id: ensure-groups
owner: net-kingdom
entry_point: "identity-provisioner groups ensure --groups {access_groups} --members '{initial_members}'"
auth: "NetKingdom identity-provisioner service identity with LLDAP group administration, via the ops-warden proxy"
warden_executes: true
attended: false
- id: mint-secret
owner: railiance-platform
when: "client_type == confidential"
entry_point: "railiance-platform credential broker: generate a client secret into {secret_ref}"
auth: "credential broker issuer, scoped to the case's KV path"
warden_executes: true
attended: false
- id: register-client
owner: key-cape
entry_point: "keycape-client register (key-cape.oidc-client-registration) for {client_id}"
auth: "operator write access to the live KeyCape client registry in namespace sso"
warden_executes: false
attended: true
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 {secret_ref}"
auth: "the consumer owner's own deployment path"
warden_executes: false
attended: false
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 {secret_ref}"
expect: deny
reads_secret_value: false
- id: custody-allowed-to-consumer
when: "client_type == confidential"
check: "policy {consuming_service}-oidc-client-{client_id}-read grants read on {secret_ref} (capabilities check only)"
expect: allow
reads_secret_value: false
- id: groups-present
check: "LLDAP groups {access_groups} exist"
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 register --remove {client_id}"
- reverses: mint-secret
when: "client_type == confidential"
action: "destroy the secret at {secret_ref}"
- reverses: ensure-groups
action: "remove the case's initial memberships; delete groups {access_groups} only if this case created them and no other case uses them"
- reverses: client-secret-path
when: "client_type == confidential"
action: "remove the KV path {secret_ref}"
- reverses: keycape-reader-policy
when: "client_type == confidential"
action: "delete policy keycape-client-{client_id}-read"
- reverses: consumer-reader-policy
when: "client_type == confidential"
action: "delete policy {consuming_service}-oidc-client-{client_id}-read"
- reverses: catalog-entry
action: "remove catalog entry oidc-client-{client_id}"
acceptance:
accepted_by: [owner, founder]
risk_class: medium