NK-WP-0048-T16: propose 1.3 successor type with the execution_descriptors map
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 4s

Source-only candidate. The real-kernel admission test shows the remaining gate is an
ops-warden 1.3 parameter pin plus acknowledgment.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 903097@bnt-lap001
Assistant-Session: aa320c8f-c3fa-46e7-8541-0b58e1e45d23
This commit is contained in:
tegwick 2026-10-17 11:25:35 +02:00
parent acda26c7a9
commit 1920b134e2
9 changed files with 1082 additions and 6 deletions

View file

@ -0,0 +1,89 @@
# Proposed OIDC browser relying-party 1.3 successor candidate
Status: **proposed**. This is a source candidate for NK-WP-0048-T16. It is not
acknowledged, not implemented, and grants no execution or native authority. The
acknowledged 1.2 package (`../../1.2/oidc-browser-relying-party/`) and canonical 1.1
stay unchanged, and 1.2's acknowledgment is not inherited.
## Purpose
The purpose of the type is 1.2's: register exact-redirect browser OIDC relying
parties through KeyCape, with groups enforced by the consumer. 1.3 changes only two
things:
1. `version: '1.3'` and `status: proposed`, with the examples pinned to `@1.3`.
2. A new `execution_descriptors` map naming the reviewed machine descriptor digest
for each of the seven follow-through steps. Warden's `Executor.admit` refuses any
step whose descriptor digest is not in the type's map, so this map is what would
let Warden admit the steps at all. It is generated by
`sso-mfa/k8s/keycape/compose-1.2-descriptors.py --version 1.3`, and
`test_admit_1_3_type.py` checks that it equals the composed set.
Every other byte of the type (parameters, composition, materialization,
follow-through, verification, rollback, acceptance) is 1.2's. The parameter set is
identical, so its fingerprint equals 1.2's.
Canon `security-case-types_v0.1.md` section 6 treats a changed trust transition as
major. Allowing Warden to admit these steps is one, so 1.3 needs its own Decision
Memo and informed-decision acknowledgment by the founder, the same as a new type.
## Threat model
1.2's threat model applies unchanged. 1.3 adds one transition: Warden may admit a
step and dispatch its owner-supplied adapter, which runs injected owner
capabilities. Each descriptor pins its adapter's exact source closure, lane, actor,
effect, allowed created objects and revocation scopes, and admission still requires
authenticated case acceptance, ops-mason materialization, owner descriptor
acceptance, type machine-seam acceptance, catalog routing acceptance and the
caller's session. A mint is admitted only for a human caller. The adapters record
value-free evidence and roll back only objects they created. None of that removes
the need for owners to verify native bindings.
## Alternatives considered
- Keep 1.2 as plan-only, with no `execution_descriptors`. Every step stays manual,
and the reviewed adapters are never admitted.
- List only a subset of steps in the map, for example only the Warden-executed and
attended ones. That is open question 3 below.
- Change the kernel to read descriptors from outside the type. This is rejected:
the kernel binds the map to the acknowledged type bytes on purpose.
## Known limits
- The adapter set was independently reviewed at net-kingdom `ff8f597`
(the-custodian `docs/changes/netkingdom-target-2026-10-17/nk48-adapters-1.2-review/`,
r1–r3, all nine files approved in r3). Making ensure-groups, hand-over,
deliver-consumer-secret and the composer accept 1.3 changed their source digests,
so those descriptors need a further source review (r4) before this map can be
relied on.
- `test_admit_1_3_type.py` runs Warden's real `Executor.admit` (ops-warden `a45cf47`)
with fixture-only trust. The proposed type is refused. An acknowledged copy is
still refused, because Warden pins parameter fingerprints only for 1.1 and 1.2.
With only a 1.3 pin added in memory, all seven descriptors are admitted, and a
descriptor outside the map is refused.
- All owner capabilities in the tests are stand-ins, and no native binding is
proven.
Open questions for owner review:
1. **ops-warden:** add `"1.3"` to `warden/case_inputs.py` `PARAMETER_PINS` with 1.2's
fingerprint. Without it, no 1.3 case is admitted.
2. **mint-secret semantics:** the type says `warden_executes: true, attended: false`,
while the descriptor actor is `human` (founder decision 2026-10-17), and the kernel
refuses a non-human mint. Owners decide whether these are consistent (a human
caller starts an unattended Warden-executed mint) or whether the step should
become `attended: true, warden_executes: false` in this revision.
3. **Owner-run steps with descriptors:** deliver-secret, deliver-consumer-secret and
hand-over are `warden_executes: false, attended: false` (owner path), and
register-client, ensure-groups and bind-env are attended, yet all carry execution
descriptors. The kernel never reads `warden_executes`; only the plan does. Owners
decide whether Warden should admit these steps or whether the map should list only
a subset.
## Verification
```sh
python3 tools/security-case-types/security_case_validator.py validate-type security-case-type-revisions/1.3/oidc-browser-relying-party
NET_KINGDOM_ROOT=$PWD python3 -m pytest security-case-type-revisions/1.3/oidc-browser-relying-party/tests
cd sso-mfa/k8s/keycape && PYTHONPATH=$HOME/ops-warden/src ~/ops-warden/.venv/bin/python -m pytest -q test_admit_1_3_type.py
```

View file

@ -0,0 +1,20 @@
apiVersion: netkingdom.io/security-case/v0.1
kind: SecurityCase
spec:
id: example-public-portal
type: oidc-browser-relying-party@1.3
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,groups
client_type: public
mfa_required: 'false'
access_groups: example-portal-users
consuming_service: example-portal
consumer_owner_repo: net-kingdom
consumer_namespace: example-portal

View file

@ -0,0 +1,21 @@
apiVersion: netkingdom.io/security-case/v0.1
kind: SecurityCase
spec:
id: forgejo-netkingdom-oidc
type: oidc-browser-relying-party@1.3
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
consuming_service: forgejo
consumer_owner_repo: railiance-apps
consumer_namespace: forgejo

View file

@ -0,0 +1,88 @@
"""Source-only contract checks against the actual owner validator and declarations."""
import copy, importlib.util, os, sys
from pathlib import Path
import pytest, yaml
PACKAGE=Path(__file__).resolve().parents[1]
OWNER=Path(os.environ.get("NET_KINGDOM_ROOT", "/home/worsch/net-kingdom"))
VP=OWNER/"tools/security-case-types/security_case_validator.py"
sys.path.insert(0,str(VP.parent))
spec=importlib.util.spec_from_file_location("security_case_validator",VP)
v=importlib.util.module_from_spec(spec);sys.modules[spec.name]=v;spec.loader.exec_module(v)
TYPE=yaml.safe_load((PACKAGE/"type.yaml").read_text())
FORGEJO=yaml.safe_load((PACKAGE/"examples/forgejo.yaml").read_text())
paths,discovery=v.discover_declarations(TYPE,OWNER.parent)
DECLS,loading=v.composer.load_declarations(paths)
def render(case):
issues,result=v.render_case(case,TYPE,DECLS,require_implemented=False)
assert not v.has_errors(discovery+loading+issues)
return result
def test_full_package_and_proposed_boundary():
assert not v.has_errors(v.validate_type(TYPE,PACKAGE))
assert TYPE["spec"]["status"]=="proposed" # 1.3 is a successor candidate; 1.2's acknowledgment is not inherited
assert FORGEJO["spec"]["status"]=="proposed"
assert "acceptance" not in FORGEJO["spec"]
assert v.has_errors(v.validate_case(FORGEJO,TYPE))
def test_acknowledged_is_still_not_implemented_admission():
acknowledged=copy.deepcopy(TYPE);acknowledged["spec"]["status"]="acknowledged"
assert v.has_errors(v.validate_case(FORGEJO,acknowledged))
@pytest.mark.parametrize("example",sorted((PACKAGE/"examples").glob("*.yaml")))
def test_examples_valid_deterministic_and_nonexecuting(example):
case=yaml.safe_load(example.read_text())
assert not v.has_errors(v.validate_case(case,TYPE,require_implemented=False))
first=render(case);assert first==render(case)
assert first["execution"]["permitted"] is False
assert first["composition"]["execution"]["permitted"] is False
def test_sixth_role_is_exact_consumer_identity_and_namespace():
case=copy.deepcopy(FORGEJO);case["spec"]["parameters"]["consumer_namespace"]="forgejo-preview"
r=render(case);objects={x["id"]:x for x in r["materialization"]}
assert len(objects)==6
consumer=yaml.safe_load(objects["consumer-eso-role"]["body"])
assert consumer["bound_service_account_names"]==["forgejo-keycape-forgejo-eso"]
assert consumer["bound_service_account_namespaces"]==["forgejo-preview"]
assert consumer["audience"]=="openbao"
assert consumer["token_policies"]==["workload-kv-read-keycape-forgejo-forgejo"]
assert consumer["token_no_default_policy"] is True
provider=yaml.safe_load(objects["eso-role"]["body"])
assert provider["bound_service_account_names"]!=consumer["bound_service_account_names"]
def test_original_five_structural_bodies_unchanged():
old=yaml.safe_load((OWNER/"security-case-types/oidc-browser-relying-party/type.yaml").read_text())
case=yaml.safe_load((OWNER/"security-cases/forgejo-netkingdom-oidc.yaml").read_text())
issues,before=v.render_case(case,old,DECLS,require_implemented=False)
assert not v.has_errors(issues)
after={x["id"]:x["body"] for x in render(FORGEJO)["materialization"]}
for item in before["materialization"]:assert item["body"]==after[item["id"]]
def test_public_has_no_secret_or_delivery_hooks():
r=render(yaml.safe_load((PACKAGE/"examples/example-public-portal.yaml").read_text()))
assert [x["id"] for x in r["materialization"]]==["catalog-entry"]
assert not {"mint-secret","deliver-secret","deliver-consumer-secret","bind-env"}&{x["id"] for x in r["follow_through"]}
@pytest.mark.parametrize("key,value",[("consumer_namespace",None),("consumer_namespace","default/other"),("consumer_namespace","x"*64),("consuming_service","x"*64),("redirect_uris","http://portal.invalid/callback"),("redirect_uris","https://portal.invalid/*"),("redirect_uris","https://portal.invalid/callback?x=y"),("redirect_uris","https://portal.invalid/callback#x"),("allowed_scopes","openid,email"),("consumer_reader_service_account","default"),("secret_path","other/path")])
def test_invalid_or_unsettable_parameter_refused(key,value):
case=copy.deepcopy(FORGEJO)
if value is None:case["spec"]["parameters"].pop(key)
else:case["spec"]["parameters"][key]=value
assert v.has_errors(v.validate_case(case,TYPE,require_implemented=False))
def test_created_only_role_recovery_precedes_policy_and_needs_revocation():
rollback=render(FORGEJO)["rollback"];ids=[x["reverses"] for x in rollback]
assert ids.index("consumer-eso-role")<ids.index("consumer-reader-policy")
action=next(x["action"] for x in rollback if x["reverses"]=="consumer-eso-role")
assert "case-created" in action and "revoke" in action and "expiration" in action
delivery=next(x["action"] for x in rollback if x["reverses"]=="deliver-consumer-secret")
assert "UID" in delivery and "preexisting" in delivery and "consumer secret reference" in delivery
@pytest.mark.parametrize("omitted",["README.md","tests/test_source_contract.py","examples"])
def test_full_package_validator_rejects_incomplete_supplement(tmp_path,omitted):
import shutil
trial=tmp_path/"package";shutil.copytree(PACKAGE,trial,ignore=shutil.ignore_patterns("__pycache__",".pytest_cache"))
target=trial/omitted
if target.is_dir():
for example in target.glob("*.yaml"):example.unlink()
else:target.unlink()
assert v.has_errors(v.validate_type(TYPE,trial))

View file

@ -0,0 +1,452 @@
apiVersion: netkingdom.io/security-case-type/v0.1
kind: SecurityCaseType
spec:
id: oidc-browser-relying-party
version: '1.3'
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, 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-]{0,62}$
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: consumer_namespace
type: string
case_settable: true
constraints:
pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$
sensitivity: security_sensitive
description: Consumer namespace for namespaced ESO TokenRequest reader; actual
workload owner must confirm; never infer from service name.
- 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:
- 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}\" {{\n capabilities\
\ = [\"read\"]\n}}\n"
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}\" {{\n capabilities\
\ = [\"read\"]\n}}\n"
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: consumer-eso-role
kind: openbao_role
when: client_type == confidential
template: external-secrets-keycape-{client_id}-{consuming_service}
body: 'auth: kubernetes
bound_service_account_names: [{consuming_service}-keycape-{client_id}-eso]
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
'
description: Consumer ESO requests a token for this separate reference identity.
Common ESO controller remains trusted and can request tokens; no cryptographic
controller isolation is claimed.
- 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: deliver-consumer-secret
owner: '{consumer_owner_repo}'
when: client_type == confidential
entry_point: Owner applies dedicated ServiceAccount {consumer_namespace}/{consuming_service}-keycape-{client_id}-eso
(automount=false, no legacy tokens), namespace SecretStore openbao-keycape-{client_id}-consumer
with serviceAccountRef and audiences [openbao], and ExternalSecret {consuming_service}-netkingdom-oidc-client
with Owner target, Retain remote deletion and only CLIENT_SECRET from workloads/keycape/{client_id}.
Admission validates actual installed ESO TokenRequest support and common controller
RBAC before any owner-approved apply.
auth: Consumer owner contained Kubernetes deployment authority; ESO authenticates
to OpenBao role external-secrets-keycape-{client_id}-{consuming_service} with
a TokenRequest for the dedicated namespaced serviceAccountRef; controller remains
trusted
warden_executes: false
attended: false
value_handling: none
ready_check: Namespace SecretStore and ExternalSecret both report Ready=True;
dedicated SA has no legacy token refs; status and fixed metadata only, never
Secret values.
- 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
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
- id: consumer-secret-delivered
when: client_type == confidential
check: '{consumer_namespace} SecretStore openbao-keycape-{client_id}-consumer
and ExternalSecret {consuming_service}-netkingdom-oidc-client Ready=True, exact
dedicated SA/audience role binding, fixed CLIENT_SECRET mapping; no Secret value
read'
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: deliver-consumer-secret
when: client_type == confidential
action: After reverting consumer secret reference, delete only authenticated created
ExternalSecret/owned target by UID and ownerReference, then SecretStore and
dedicated SA; preserve preexisting-matched resources and never delete a shared
target. Retain audit and ownership receipts; removing SecretStore/SA alone is
not immediate OpenBao token revocation.
- 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-eso-role
when: client_type == confidential
action: Delete only this case-created role external-secrets-keycape-{client_id}-{consuming_service},
before deleting consumer-reader-policy; revoke case-issued OpenBao tokens or
prove expiration before declaring deactivation.
- 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
# Reviewed machine descriptors (NK-WP-0048-T16 successor candidate). Digests are
# generated by sso-mfa/k8s/keycape/compose-1.2-descriptors.py --version 1.3.
execution_descriptors:
bind-env: sha256:d2511dce3d2ff1d380f845920aa168e6fe09c0f0c382482a796a5bf13619905d
deliver-consumer-secret: sha256:9c289f646516923a4c448a2d3cdd45b33115516e6a171b7955924d8b5d852d2b
deliver-secret: sha256:75aaa7346aebdea2a0eb0ba993995515b5248ffefc34caf8e713525e9b58da85
ensure-groups: sha256:4411b5cb8944ee76ca1ade62c0c246f061981fb18f0b9afd05934a8935598dc3
hand-over: sha256:84f0fac401ea2f1e7c305fb1753a34838ac062655199fcfbbd998f39268c3000
mint-secret: sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70
register-client: sha256:3ccc8eed545cf31f8a48feb8afa0c1355e126d9549eef9737d92d0c1b87708a8

View file

@ -0,0 +1,254 @@
{
"admitted": false,
"canonical_default": {
"type": "oidc-browser-relying-party@1.1",
"type_yaml_digest": "sha256:f705c64e4875e9b8e18a912fb51c135e77898926d6a451f5cb74ab3c0cbe4d1c",
"unchanged": true
},
"check_config_digest": "sha256:be37738fe1c08259fb4f9ca0daf0f9301bfffc237e5bb38435525dfbf11ccff5",
"complete_executable_descriptor_set": true,
"dispatch_permitted": false,
"execution_descriptors": {
"bind-env": "sha256:d2511dce3d2ff1d380f845920aa168e6fe09c0f0c382482a796a5bf13619905d",
"deliver-consumer-secret": "sha256:9c289f646516923a4c448a2d3cdd45b33115516e6a171b7955924d8b5d852d2b",
"deliver-secret": "sha256:75aaa7346aebdea2a0eb0ba993995515b5248ffefc34caf8e713525e9b58da85",
"ensure-groups": "sha256:4411b5cb8944ee76ca1ade62c0c246f061981fb18f0b9afd05934a8935598dc3",
"hand-over": "sha256:84f0fac401ea2f1e7c305fb1753a34838ac062655199fcfbbd998f39268c3000",
"mint-secret": "sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70",
"register-client": "sha256:3ccc8eed545cf31f8a48feb8afa0c1355e126d9549eef9737d92d0c1b87708a8"
},
"owner_review": false,
"params": {
"consumer_owner_repo": "railiance-apps"
},
"refusal": null,
"review_requirement": "successor candidate only: new descriptor bytes need a reviewed successor revision under canon section 6 and owner review (NK-WP-0048-T16) before pinning",
"schema": "netkingdom.descriptor-composition-candidate.v1",
"selection": "explicit version root 1.3=security-case-type-revisions/1.3; default root unchanged",
"status": "not_an_admission",
"steps": [
{
"candidate_descriptor_digest": "sha256:4411b5cb8944ee76ca1ade62c0c246f061981fb18f0b9afd05934a8935598dc3",
"descriptor": {
"actor": "human",
"adapter": "net-kingdom/ensure-groups@v1",
"adapter_source": "sha256:87cc12ada32c4d1296070dd1e8586f35151fcb181ecd89d5d1fbb20c78b50a6f",
"allowed_created": [
"service-group",
"service-group-membership"
],
"effect": "change",
"lane": "key-cape-oidc-login",
"owner": "net-kingdom",
"retry_safe": false,
"revocation_scopes": [
"parent-session"
],
"step": "ensure-groups",
"target_parameter": "client_id",
"version": "warden.case.execution.v1"
},
"findings": [],
"names_confirmed": null,
"owner_confirmation_required": null,
"step": "ensure-groups",
"type_lane": "key-cape-oidc-login",
"type_owner": "net-kingdom"
},
{
"candidate_descriptor_digest": "sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70",
"descriptor": {
"actor": "human",
"adapter": "net-kingdom/keycape-mint-secret@v1",
"adapter_source": "sha256:34f79944204a2cf5b2a22baecbee4bde77cbdc0e3f9fc87bc98fa12a9c2c0bc1",
"allowed_created": [
"custody-secret-version"
],
"effect": "mint",
"lane": "keycape-client-secret-generate",
"owner": "railiance-platform",
"retry_safe": false,
"revocation_scopes": [
"parent-session",
"broker-grant"
],
"step": "mint-secret",
"target_parameter": "client_id",
"version": "warden.case.execution.v1"
},
"findings": [],
"names_confirmed": {
"by": "founder",
"on": "2026-10-17",
"schema": "railiance-platform.custody-mint.v1",
"task": "NK-WP-0048-T25"
},
"owner_confirmation_required": null,
"step": "mint-secret",
"type_lane": "keycape-client-secret-generate",
"type_owner": "railiance-platform"
},
{
"candidate_descriptor_digest": "sha256:75aaa7346aebdea2a0eb0ba993995515b5248ffefc34caf8e713525e9b58da85",
"descriptor": {
"actor": "human",
"adapter": "net-kingdom/deliver-secret@v1",
"adapter_source": "sha256:df609bb93ebdfffb12cde213381a77eb7ac82670f814ff73623c1d98262cc90e",
"allowed_created": [
"delivered-secret"
],
"effect": "change",
"lane": "platform-secret-delivery",
"owner": "railiance-platform",
"retry_safe": false,
"revocation_scopes": [
"parent-session"
],
"step": "deliver-secret",
"target_parameter": "client_id",
"version": "warden.case.execution.v1"
},
"findings": [],
"names_confirmed": {
"by": "founder",
"lane": "platform-secret-delivery",
"on": "2026-10-17",
"schema": "netkingdom.deliver-secret.v1",
"task": "NK-WP-0048-T26"
},
"owner_confirmation_required": null,
"step": "deliver-secret",
"type_lane": null,
"type_owner": "railiance-platform"
},
{
"candidate_descriptor_digest": "sha256:d2511dce3d2ff1d380f845920aa168e6fe09c0f0c382482a796a5bf13619905d",
"descriptor": {
"actor": "human",
"adapter": "net-kingdom/keycape-bind-env@v1",
"adapter_source": "sha256:cfc8a7f52494869ead07f9878526a27206c99b0f468673e2e7b111d29d175ddf",
"allowed_created": [
"keycape-env-binding"
],
"effect": "change",
"lane": "key-cape-oidc-login",
"owner": "net-kingdom",
"retry_safe": false,
"revocation_scopes": [
"parent-session"
],
"step": "bind-env",
"target_parameter": "client_id",
"version": "warden.case.execution.v1"
},
"findings": [],
"names_confirmed": {
"by": "founder",
"on": "2026-10-17",
"schema": "netkingdom.keycape-bind-env.attempt.v1",
"task": "NK-WP-0048-T27"
},
"owner_confirmation_required": null,
"step": "bind-env",
"type_lane": "key-cape-oidc-login",
"type_owner": "net-kingdom"
},
{
"candidate_descriptor_digest": "sha256:3ccc8eed545cf31f8a48feb8afa0c1355e126d9549eef9737d92d0c1b87708a8",
"descriptor": {
"actor": "human",
"adapter": "net-kingdom/keycape-registration@v1",
"adapter_source": "sha256:8af23cb5d5fff963d2eff65d8b16dcfd395931ad860357846151019a70b2ed34",
"allowed_created": [
"keycape-client"
],
"effect": "change",
"lane": "key-cape-oidc-login",
"owner": "net-kingdom",
"retry_safe": false,
"revocation_scopes": [
"parent-session"
],
"step": "register-client",
"target_parameter": "client_id",
"version": "warden.case.execution.v1"
},
"findings": [],
"names_confirmed": null,
"owner_confirmation_required": null,
"step": "register-client",
"type_lane": "key-cape-oidc-login",
"type_owner": "net-kingdom"
},
{
"candidate_descriptor_digest": "sha256:9c289f646516923a4c448a2d3cdd45b33115516e6a171b7955924d8b5d852d2b",
"descriptor": {
"actor": "agent",
"adapter": "net-kingdom/deliver-consumer-secret@v1",
"adapter_source": "sha256:8ec63233218f2f9571e1f0605ae29b4378f3126d0ff7f5192e35efa4291f72cd",
"allowed_created": [
"consumer-eso-sa",
"consumer-secretstore",
"consumer-externalsecret"
],
"effect": "change",
"lane": "consumer-owner-deployment",
"owner": "railiance-apps",
"retry_safe": false,
"revocation_scopes": [
"parent-session"
],
"step": "deliver-consumer-secret",
"target_parameter": "client_id",
"version": "warden.case.execution.v1"
},
"findings": [],
"names_confirmed": {
"by": "founder",
"lane": "consumer-owner-deployment",
"on": "2026-10-17",
"schema": "netkingdom.consumer-secret-delivery.v1",
"task": "NK-WP-0048-T28"
},
"owner_confirmation_required": null,
"step": "deliver-consumer-secret",
"type_lane": null,
"type_owner": "{consumer_owner_repo}"
},
{
"candidate_descriptor_digest": "sha256:84f0fac401ea2f1e7c305fb1753a34838ac062655199fcfbbd998f39268c3000",
"descriptor": {
"actor": "human",
"adapter": "net-kingdom/apps-handover@v1",
"adapter_source": "sha256:ceaefd44168fa49d4ab1bb0f4870fe43ffd3b6a963b59d53970c97b1ed871155",
"allowed_created": [
"handover-receipt"
],
"effect": "change",
"lane": "apps-consumer-handover",
"owner": "railiance-apps",
"retry_safe": false,
"revocation_scopes": [
"parent-session"
],
"step": "hand-over",
"target_parameter": "client_id",
"version": "warden.case.execution.v1"
},
"findings": [],
"names_confirmed": {
"by": "founder",
"lane": "apps-consumer-handover",
"on": "2026-10-17",
"schema": "netkingdom.apps-handover.attempt.v1",
"task": "NK-WP-0048-T29"
},
"owner_confirmation_required": null,
"step": "hand-over",
"type_lane": null,
"type_owner": "{consumer_owner_repo}"
}
],
"task": "NK-WP-0048-T30",
"type": "oidc-browser-relying-party@1.3"
}

View file

@ -0,0 +1,140 @@
"""Kernel admission against the real 1.3 successor type (NK-WP-0048-T16 candidate).
Unlike test_admit_1_2_adapter_descriptors.py, nothing is patched into the type's
execution_descriptors: the map tested here is the one committed in
security-case-type-revisions/1.3. The type is `proposed`, so the kernel must refuse
it as it stands. Warden's BrowserInputs also pins each type version's parameter
fingerprint and knows only 1.1 and 1.2 (ops-warden case_inputs.PARAMETER_PINS), so an
acknowledged 1.3 is still refused until ops-warden adds a 1.3 pin. That is an
ops-warden owner change, and it is not made here. The tests show that 1.3's
parameters carry 1.2's pinned fingerprint, and that with only that one pin added in
memory the committed map admits exactly the composed descriptors and nothing else.
Trust is fixture-only, nothing is dispatched, and none of this is owner admission
or an acknowledgment.
"""
from dataclasses import replace
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest import mock
import yaml
from warden.case_run import Adapter, Executor, Journal, digest
from warden.case_receipt_state import Refused
import warden.case_inputs as inputs
ROOT = Path(__file__).parent
REPO = ROOT.parents[2]
spec = importlib.util.spec_from_file_location('compose_13_admit', ROOT / 'compose-1.2-descriptors.py')
c = importlib.util.module_from_spec(spec)
spec.loader.exec_module(c)
TYPE_DIR = REPO / 'security-case-type-revisions/1.3/oidc-browser-relying-party'
TYPE_BYTES = (TYPE_DIR / 'type.yaml').read_bytes()
ARTIFACT = json.loads((ROOT / 'descriptor-composition-1.3.candidate.json').read_text())
EVIDENCE = {'fixture-acceptance', 'fixture-materialization', 'fixture-descriptor', 'fixture-session'}
def unused(*_):
raise AssertionError('admission must not dispatch')
def parameter_fingerprint(type_bytes):
params = yaml.safe_load(type_bytes)['spec']['parameters']
return hashlib.sha256(json.dumps(params, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def pinned_13():
"""Warden's parameter pins plus the one 1.3 entry ops-warden would have to add."""
return mock.patch.dict(inputs.PARAMETER_PINS, {'1.3': parameter_fingerprint(TYPE_BYTES)})
def with_status(status):
"""The committed 1.3 type with only its status changed."""
typ = yaml.safe_load(TYPE_BYTES)
typ['spec']['status'] = status
return yaml.safe_dump(typ).encode()
class Admit13TypeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
case = yaml.safe_load((TYPE_DIR / 'examples/forgejo.yaml').read_bytes())
case['spec'].update(status='materialized', acceptance={
'at': '2026-10-17', 'record': 'fixture-only acceptance, not an owner record',
'by': [{'name': 'fixture', 'role': 'owner'}, {'name': 'fixture', 'role': 'founder'}]})
self.case_source = yaml.safe_dump(case).encode()
self.descriptors = dict(c.adapter_descriptors(ARTIFACT['check_config_digest'],
ARTIFACT['params']['consumer_owner_repo'],
version='1.3'))
def admit(self, desc, type_source):
desc_bytes = c.encode(desc)
adapter = Adapter(desc.adapter, desc.adapter_source, unused, unused)
executor = Executor(Journal(Path(self.temp.name) / desc.step),
{adapter.reference: adapter},
lambda purpose, source, evidence: evidence in EVIDENCE,
browser_inputs=inputs.BrowserInputs(REPO))
with mock.patch.dict(os.environ):
os.environ.pop('WARDEN_AGENT_ID', None)
return executor.admit(self.case_source, type_source, desc_bytes,
b'fixture-only-catalog', principal='founder', actor=desc.actor, lane=desc.lane,
acceptance_evidence='fixture-acceptance',
materialization_evidence='fixture-materialization',
descriptor_evidence='fixture-descriptor', session_evidence='fixture-session')
def test_committed_map_is_exactly_the_composed_set(self):
committed = yaml.safe_load(TYPE_BYTES)['spec']['execution_descriptors']
composed = {s: digest(c.encode(d)) for s, d in self.descriptors.items()}
self.assertEqual(len(composed), 7)
self.assertEqual(committed, composed)
self.assertEqual(committed, ARTIFACT['execution_descriptors'])
def test_proposed_type_is_refused_as_it_stands(self):
self.assertEqual(yaml.safe_load(TYPE_BYTES)['spec']['status'], 'proposed')
for step, desc in self.descriptors.items():
with self.subTest(step=step), self.assertRaisesRegex(Refused, 'type is not acknowledged'):
self.admit(desc, TYPE_BYTES)
def test_warden_has_no_1_3_parameter_pin_yet(self):
self.assertNotIn('1.3', inputs.PARAMETER_PINS)
# 1.3 keeps 1.2's parameters, so the missing pin is 1.2's fingerprint.
self.assertEqual(parameter_fingerprint(TYPE_BYTES), inputs.PARAMETER_PINS['1.2'])
acknowledged = with_status('acknowledged')
for step, desc in self.descriptors.items():
with self.subTest(step=step), self.assertRaisesRegex(
Refused, 'failed exact-version validation'):
self.admit(desc, acknowledged)
def test_committed_map_admits_every_descriptor_once_acknowledged_and_pinned(self):
acknowledged = with_status('acknowledged')
for step, desc in self.descriptors.items():
with self.subTest(step=step), pinned_13():
admitted, invocation, _ = self.admit(desc, acknowledged)
self.assertEqual(admitted, desc)
self.assertEqual((invocation.step, invocation.actor, invocation.target),
(desc.step, desc.actor, 'forgejo'))
def test_descriptor_outside_the_map_is_refused(self):
acknowledged = with_status('acknowledged')
mint = self.descriptors['mint-secret']
for changed in (replace(mint, lane='other-lane'), replace(mint, retry_safe=True)):
with self.subTest(changed=changed), pinned_13(), self.assertRaisesRegex(
Refused, 'no exact acknowledged machine descriptor'):
self.admit(changed, acknowledged)
def test_earlier_versions_carry_no_execution_map(self):
for version in ('1.2',):
path = REPO / f'security-case-type-revisions/{version}/oidc-browser-relying-party/type.yaml'
with self.subTest(version=version):
self.assertNotIn('execution_descriptors', yaml.safe_load(path.read_bytes())['spec'])
if __name__ == '__main__':
unittest.main()

View file

@ -2,6 +2,7 @@
from dataclasses import replace
import importlib.util
from pathlib import Path
import tempfile
import unittest
from warden.case_run import Descriptor
@ -90,17 +91,15 @@ class ComposeTests(unittest.TestCase):
c.load_types(version_roots={})
def test_version_selection(self):
absent = not (c.REVISIONS / '1.3').is_dir()
for bad in ('1.1', '1.4', '', None):
with self.assertRaises(Refused):
c.load_types(version=bad)
with self.assertRaises(Refused): # a 1.2 root cannot be selected as 1.3
c.load_types(version='1.3', version_roots={'1.2': c.VERSION_ROOTS['1.2']})
if absent:
with self.assertRaises(Refused):
c.load_types(version='1.3')
self.assertEqual(c.main(['--check-config-digest', 'sha256:' + '0' * 64,
'--consumer-owner-repo', 'railiance-apps', '--version', '1.3']), 3)
with tempfile.TemporaryDirectory() as missing, self.assertRaises(Refused):
c.load_types(version='1.3', version_roots={'1.3': Path(missing) / 'absent'})
type_spec, _ = c.load_types(version='1.3') # the 1.3 successor candidate exists now
self.assertEqual(type_spec['version'], '1.3')
with self.assertRaises(SystemExit):
c.main(['--check-config-digest', 'x', '--consumer-owner-repo', 'y', '--version', '1.1'])

View file

@ -827,6 +827,19 @@ priority: high
state_hub_task_id: "200bf500-5084-5ae7-be26-3033fe1f296e"
```
2026-10-17 successor candidate prepared (source only, `proposed`):
`security-case-type-revisions/1.3/oidc-browser-relying-party/` is the 1.2 package plus
an `execution_descriptors` map of the seven composed descriptors, with examples
pinned to `@1.3` and no acknowledgment. ensure-groups, hand-over,
deliver-consumer-secret and the composer accept 1.3 (835680c, merged acda26c).
`test_admit_1_3_type.py` runs Warden's real admission against the committed 1.3 type:
the proposed type is refused; an acknowledged copy is refused because ops-warden
pins parameter fingerprints only for 1.1 and 1.2; and with only a 1.3 pin (equal to
1.2's fingerprint) all seven are admitted. This is a major change under canon
section 6. Next: r4 source review of the changed adapters and the map, then owner
reviews, including the ops-warden 1.3 pin, the mint-secret warden_executes/human
question and the owner-run steps question, then the founder's Decision Memo.
Owner: NetKingdom for descriptor/type source and exact checker artifact; Warden
independently owns global provenance/admission. T22 records the completed seven-step
source1.2 inventory and regenerated register-client descriptor using the delivered