NK-WP-0048-T16: propose 1.3 successor type with the execution_descriptors map
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:
parent
acda26c7a9
commit
1920b134e2
9 changed files with 1082 additions and 6 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
254
sso-mfa/k8s/keycape/descriptor-composition-1.3.candidate.json
Normal file
254
sso-mfa/k8s/keycape/descriptor-composition-1.3.candidate.json
Normal 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"
|
||||
}
|
||||
140
sso-mfa/k8s/keycape/test_admit_1_3_type.py
Normal file
140
sso-mfa/k8s/keycape/test_admit_1_3_type.py
Normal 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()
|
||||
|
|
@ -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'])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue