RPF-WP-0069-T08: broker admits oidc-service-client@0.2; CCR-2026-0060/0061 aligned to 0.2
Some checks failed
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
CCR intake gate / gate (push) Has been cancelled

The founder acknowledged oidc-service-client 0.2 on 2026-11-01 (memo r2, hub
decision f8044f81, net-kingdom 9ce0d60), meeting the RPF-WP-0060-T01 rule
from review R1. keycape_client_secret_generate.py pins the 0.2 type bytes,
keys parameter pins by exact type reference, and derives confidentiality per
type: browser 1.1 keeps its client_type check, the service type is
confidential by construction (no client_type, unconditional custody path);
a service case carrying client_type is refused. Create-only is unchanged.
Eleven new tests; the existing browser tests are unchanged apart from the
re-keyed pin. CCR-2026-0060/0061 match the 0.2 materialization; references
updated, still proposed. T09 (wait) tracks their approval and apply.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5502@bnt-lap001
Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
This commit is contained in:
codex 2026-11-01 10:24:17 +01:00
parent 50e51e0fd8
commit ba445d7aed
9 changed files with 364 additions and 42 deletions

View file

@ -233,6 +233,8 @@
| task | RPF-WP-0069-T05 | wait | — | workplans/RPF-WP-0069-bounded-executor-lanes.md |
| task | RPF-WP-0069-T06 | cancel | — | workplans/RPF-WP-0069-bounded-executor-lanes.md |
| task | RPF-WP-0069-T07 | todo | — | workplans/RPF-WP-0069-bounded-executor-lanes.md |
| task | RPF-WP-0069-T08 | done | — | workplans/RPF-WP-0069-bounded-executor-lanes.md |
| task | RPF-WP-0069-T09 | wait | — | workplans/RPF-WP-0069-bounded-executor-lanes.md |
| intake | RPF-IN-0001 | closed | — | intakes/intakes.md |
| intake | RPF-IN-0002 | open | — | intakes/intakes.md |
| intake | RPF-IN-0003 | open | — | intakes/intakes.md |

View file

@ -2,14 +2,14 @@ id: CCR-2026-0060
kind: credential-change-request
schema_version: 1
request_type: workload-kv-read
title: 'nk-bind-executor-approval: keycape-reader-policy and eso-role (oidc-service-client@0.1)'
title: 'nk-bind-executor-approval: keycape-reader-policy and eso-role (oidc-service-client@0.2)'
status: proposed
created: '2026-10-31'
updated: '2026-10-31'
updated: '2026-11-01'
requester:
agent: railiance-platform
reason: 'NK-WP-0056-T14 first case of SecurityCaseType oidc-service-client 0.1 (net-kingdom
5d5f15d, proposed; hub decision f8044f81; memo nk-wp-0056-t14-oidc-service-client-r1).
reason: 'NK-WP-0056-T14 first case of SecurityCaseType oidc-service-client 0.2 (net-kingdom
9ce0d60, acknowledged 2026-11-01; hub decision f8044f81; memo nk-wp-0056-t14-oidc-service-client-r2).
Drafted by the platform owner in its review (docs/credential-lane-designs/oidc-service-client-review.md,
R6). KeyCape reads the client secret of nk-bind-executor-approval only through ESO, as
CCR-2026-0041 for the browser type. Not approved.'
@ -59,8 +59,8 @@ access_frontdoor:
risk:
classification: high
notes:
- Rendered from a proposed SecurityCaseType; the type is not yet acknowledged and the case
is not yet accepted.
- Rendered from the acknowledged SecurityCaseType oidc-service-client 0.2 (names and bodies
checked against its materialization 2026-11-01); the case is not yet accepted.
- Exact read on one KV path; no metadata, list, parent or sibling access.
- The value lets its holder obtain approval-engine tokens as service:nk-bind-executor
(approval:create/read/consume, tenant:platform).
@ -77,11 +77,11 @@ verification:
- sibling GET, parent LIST and metadata read are denied
- wrong ServiceAccount and wrong audience logins are denied
activation_conditions:
- oidc-service-client 0.1 acknowledged by the founder (informed-decision memo, hub decision
- oidc-service-client 0.2 acknowledged by the founder (done 2026-11-01, memo r2, hub decision
f8044f81).
- SecurityCase nk-bind-executor-approval accepted by owner and founder.
- Custody entry minted at version 1 by keycape-client-secret-generate, after the broker
admits the type (review R1).
- Custody entry minted at version 1 by keycape-client-secret-generate, by the broker, which admits
oidc-service-client@0.2 since RPF-WP-0069-T08.
- platform-owner approval of this exact request and bound_claims_confirmed set to true.
lifecycle:
deactivate: delete ExternalSecret sso/keycape-nk-bind-executor-approval-client, then role
@ -92,7 +92,8 @@ lifecycle:
cleanup
security_case:
case: nk-bind-executor-approval
type: oidc-service-client@0.1
type_status: proposed
type_source: net-kingdom security-case-types/oidc-service-client/type.yaml (5d5f15d)
type: oidc-service-client@0.2
type_status: acknowledged
type_source: net-kingdom security-case-types/oidc-service-client/type.yaml (9ce0d60, sha256:cd238693...)
type_acknowledgment: founder accepted informed-decision memo nk-wp-0056-t14-oidc-service-client-r2 on 2026-11-01 (hub decision f8044f81)
acceptance_record: pending

View file

@ -2,14 +2,14 @@ id: CCR-2026-0061
kind: credential-change-request
schema_version: 1
request_type: workload-kv-read
title: 'nk-bind-executor-approval: consumer reader for sso/nk-bind-executor (oidc-service-client@0.1)'
title: 'nk-bind-executor-approval: consumer reader for sso/nk-bind-executor (oidc-service-client@0.2)'
status: proposed
created: '2026-10-31'
updated: '2026-10-31'
updated: '2026-11-01'
requester:
agent: railiance-platform
reason: 'NK-WP-0056-T14 first case of SecurityCaseType oidc-service-client 0.1 (net-kingdom
5d5f15d, proposed; hub decision f8044f81). The bounded executor service:nk-bind-executor
reason: 'NK-WP-0056-T14 first case of SecurityCaseType oidc-service-client 0.2 (net-kingdom
9ce0d60, acknowledged 2026-11-01; hub decision f8044f81). The bounded executor service:nk-bind-executor
reads its approval-engine client secret natively per call to file, read and consume its
own approvals. Drafted by the platform owner in its review
(docs/credential-lane-designs/oidc-service-client-review.md, R2-R6). Not approved.'
@ -45,8 +45,8 @@ openbao:
auth:
method: kubernetes
mount: kubernetes
# Review R4: role name ends with -workload-kv-read so the delegated applier admits it;
# the type r1 template (role name = policy name) would be refused.
# Type 0.2 template keycape-{client_id}-{consuming_service}-workload-kv-read (review R4):
# the delegated applier admits it.
role: keycape-nk-bind-executor-approval-bounded-executor-workload-kv-read
audience: openbao
ttl: 15m
@ -112,8 +112,9 @@ verification:
- Wrong ServiceAccount, wrong namespace and wrong audience logins are denied
- Coding-agent boundary denies data and metadata of the path
activation_conditions:
- oidc-service-client acknowledged by the founder and case nk-bind-executor-approval accepted.
- Custody entry minted (CCR-2026-0040 lane, after broker admission R1).
- oidc-service-client 0.2 acknowledged (done 2026-11-01) and case nk-bind-executor-approval accepted.
- Custody entry minted (CCR-2026-0040 lane; broker admits
oidc-service-client@0.2 since RPF-WP-0069-T08).
- Explicit platform-owner and net-kingdom-owner approval of this exact request.
- bound_claims_confirmed set to true by the platform-owner (ServiceAccount sso/nk-bind-executor
already exists, uid bd15569c, CCR-2026-0058/0059).
@ -121,7 +122,8 @@ verification:
from a reviewed commit; positive and negative results recorded in value-free receipts.
security_case:
case: nk-bind-executor-approval
type: oidc-service-client@0.1
type_status: proposed
type_source: net-kingdom security-case-types/oidc-service-client/type.yaml (5d5f15d)
type: oidc-service-client@0.2
type_status: acknowledged
type_source: net-kingdom security-case-types/oidc-service-client/type.yaml (9ce0d60, sha256:cd238693...)
type_acknowledgment: founder accepted informed-decision memo nk-wp-0056-t14-oidc-service-client-r2 on 2026-11-01 (hub decision f8044f81)
acceptance_record: pending

View file

@ -114,8 +114,9 @@ grants:
description: >
RPF-WP-0054 / CCR-2026-0040. Short-lived token the broker uses inside its
own process to create platform/workloads/keycape/<client_id> once (CAS 0)
for an accepted oidc-browser-relying-party case. Create-only: it cannot
update, read values, write metadata, list or delete. The generated value
for an accepted oidc-browser-relying-party@1.1 or oidc-service-client@0.2
case. Create-only: it cannot update, read values, write metadata, list or
delete. The generated value
never leaves the broker process and the bao child's stdin; the caller gets a
receipt only. Founder decision 2026-10-09 (RPF-WP-0054-T02 option A).
openbao:

View file

@ -1,5 +1,5 @@
# workload-kv-read-keycape-nk-bind-executor-approval-bounded-executor: CCR-2026-0061 (proposed),
# SecurityCase nk-bind-executor-approval (oidc-service-client@0.1, proposed), consumer reader for
# SecurityCase nk-bind-executor-approval (oidc-service-client@0.2, acknowledged), consumer reader for
# sso/nk-bind-executor. Platform-generated from the CCR. Source only; not applied.
path "platform/data/workloads/keycape/nk-bind-executor-approval" {

View file

@ -1,5 +1,5 @@
# workload-kv-read-keycape-nk-bind-executor-approval-eso: CCR-2026-0060 (proposed), SecurityCase
# nk-bind-executor-approval (oidc-service-client@0.1, proposed). Platform-generated from the CCR
# nk-bind-executor-approval (oidc-service-client@0.2, acknowledged). Platform-generated from the CCR
# (token_self_lifecycle true, as CCR-2026-0041). Source only; not applied.
path "platform/data/workloads/keycape/nk-bind-executor-approval" {

View file

@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""RPF-WP-0054-T03: generate a KeyCape confidential-client secret inside custody.
Entry point for the oidc-browser-relying-party ``mint-secret`` step (lane
``keycape-client-secret-generate``). Normally reached as
Entry point for the ``mint-secret`` step (lane ``keycape-client-secret-generate``)
of the acknowledged KeyCape client types: oidc-browser-relying-party@1.1 and
oidc-service-client@0.2 (RPF-WP-0069-T08). Normally reached as
``credential.py generate ...``; ops-warden runs it as a child in the caller's
own environment and holds no token.
@ -16,8 +17,10 @@ Steps (every one fails closed):
the broker issuer policy for this grant.
2. Re-derive the case reference from ``--case-file`` (sha256 over its exact
bytes) and require: it equals ``--case-ref``; type
an exactly source-pinned acknowledged browser revision; status ``materialized``; client_type
``confidential``; ``parameters.client_id`` equals ``--client-id``, which
an exactly source-pinned acknowledged revision; status ``materialized``; the
client is confidential (browser 1.1: ``parameters.client_type``; service 0.2:
derived from the authenticated type, which has no client_type and always
holds custody); ``parameters.client_id`` equals ``--client-id``, which
matches the type's pattern and is not a reserved entry.
3. Mint a 5-minute grant token through token role
``keycape-client-secret-generate``. All KV calls below use only that token.
@ -65,6 +68,19 @@ ACKNOWLEDGED_TYPES = {
'oidc-browser-relying-party@1.1': (
'security-case-types/oidc-browser-relying-party/type.yaml',
'f705c64e4875e9b8e18a912fb51c135e77898926d6a451f5cb74ab3c0cbe4d1c'),
# Acknowledged 2026-11-01: founder accepted informed-decision memo
# nk-wp-0056-t14-oidc-service-client-r2 (hub decision f8044f81; net-kingdom 9ce0d60).
'oidc-service-client@0.2': (
'security-case-types/oidc-service-client/type.yaml',
'cd2386930027ef08f6fb135b2cfda72c355768b06e914cc74812af1bb214fe04'),
}
# How the broker establishes that the client is confidential. 'parameter': the case's
# client_type must be confidential (browser 1.1, unchanged). 'type': the authenticated
# type has no client_type parameter and its client-secret-path is unconditional, so
# every case of it is confidential. A type not listed here keeps the parameter check.
CONFIDENTIALITY = {
'oidc-browser-relying-party@1.1': 'parameter',
'oidc-service-client@0.2': 'type',
}
# Same authenticated producer and parameter projection as ops-warden case_inputs
# at b593c879, restricted to this broker's currently admitted 1.1. Source-only
@ -79,8 +95,10 @@ SOURCE_PINS = {
'tools/playbook-capability-contract/playbook_contract_validator.py':
'2e1e0c809d4bc9d64c2d974cc23fecd1c5636785154c2554edd9191852fd926d',
}
# Keyed by the exact type reference, so two types sharing a version never share a pin.
PARAMETER_PINS = {
'1.1': '9ec2d6113bf70dbd35cfc247412363ff187e792bc82e24159a6f78e090aa3e4c',
'oidc-browser-relying-party@1.1': '9ec2d6113bf70dbd35cfc247412363ff187e792bc82e24159a6f78e090aa3e4c',
'oidc-service-client@0.2': 'dda3bd19f85369c3806abd78014b8fc3d400705d2f091b6256f02912aea51549',
}
_VALIDATE = """
import json, sys
@ -91,7 +109,7 @@ case, typ = json.load(sys.stdin)
issues = v.validate_type(typ) + v.validate_case(case, typ, require_implemented=False)
print('valid' if not v.has_errors(issues) else 'invalid')
"""
# oidc-browser-relying-party@1.1 parameter client_id.
# client_id pattern of oidc-browser-relying-party@1.1 and oidc-service-client@0.2 (identical).
CLIENT_ID_RE = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
CASE_REF_RE = re.compile(r'^[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64}$')
# 48 random bytes -> 64 URL-safe characters, confirmed by key-cape 2026-10-10
@ -139,6 +157,15 @@ def authenticated_source(relative, expected):
return raw
def type_is_confidential(spec):
# Derived from the authenticated type bytes, never from a case parameter.
if any(p.get('name') == 'client_type' for p in spec['parameters']):
return False
paths = [m for m in spec['materialization'] if m.get('id') == 'client-secret-path']
return (len(paths) == 1 and paths[0].get('kind') == 'kv_path' and 'when' not in paths[0]
and paths[0].get('template') == 'platform/workloads/keycape/{client_id}')
def check_acknowledged_case(doc):
# Authenticates the selected TYPE/producer bytes and validates case schema.
# The caller's self-hashed case reference is not authenticated case admission
@ -155,8 +182,10 @@ def check_acknowledged_case(doc):
spec['parameters'], sort_keys=True, separators=(',', ':')).encode()).hexdigest()
if (spec['status'] not in ('acknowledged', 'implemented')
or f"{spec['id']}@{version}" != doc['spec']['type']
or PARAMETER_PINS.get(version) != fingerprint):
or PARAMETER_PINS.get(doc['spec']['type']) != fingerprint):
raise ValueError
if CONFIDENTIALITY.get(doc['spec']['type']) == 'type' and not type_is_confidential(spec):
raise Exit(2, 'case_type_not_confidential')
with tempfile.TemporaryDirectory(prefix='platform-case-validator-') as directory:
for relative, expected in SOURCE_PINS.items():
raw = authenticated_source(relative, expected)
@ -196,8 +225,12 @@ def check_case(args, receipt):
raise Exit(2, 'case_type_unacknowledged')
if spec.get('status') != 'materialized':
raise Exit(2, 'case_not_materialized')
if params.get('client_type') != 'confidential':
raise Exit(2, 'case_client_not_confidential')
if CONFIDENTIALITY.get(spec['type'], 'parameter') == 'parameter':
if params.get('client_type') != 'confidential':
raise Exit(2, 'case_client_not_confidential')
elif 'client_type' in params:
# The service type decides confidentiality; a case cannot claim it.
raise Exit(2, 'case_client_type_not_admitted')
if params.get('client_id') != args.client_id:
raise Exit(2, 'case_client_id_mismatch')
check_acknowledged_case(doc)

View file

@ -62,7 +62,44 @@ FAKE_BAO = textwrap.dedent(f'''\
CASE = '# First KnownSecurityCase of oidc-browser-relying-party 1.1 (NK-WP-0048-T06).\n# Parameters are the request the founder accepted on 2026-10-08\n# (key-cape docs/forgejo-oidc-client-request.yaml, KEY-WP-0038-T01); that\n# acceptance carries over as this case\'s acceptance. One difference: custody is\n# derived by the type (platform/workloads/keycape/forgejo), not the request\'s\n# proposed platform/workloads/forgejo/netkingdom-oidc-client. The founder\n# acknowledged that derivation with the type (memo r4, 2026-10-09).\n# Re-pinned from 1.0 to 1.1 on 2026-10-10 on the founder\'s go, before any\n# apply, so no 1.0 run overlaps (minor change: consumer-enforces-groups).\napiVersion: netkingdom.io/security-case/v0.1\nkind: SecurityCase\nspec:\n id: forgejo-netkingdom-oidc\n type: oidc-browser-relying-party@1.1\n status: materialized\n acceptance:\n at: "2026-10-08"\n record: "key-cape docs/forgejo-oidc-client-request.yaml (status accepted-2026-10-08, KEY-WP-0038-T01); type acknowledged via informed-decision memo nk-wp-0048-t05-oidc-browser-relying-party-r4, hub decision e418263e"\n by:\n - {role: owner, name: "founder (for railiance-apps)"}\n - {role: founder, name: founder}\n owner:\n repo: railiance-apps\n person: founder\n parameters:\n client_id: forgejo\n display_name: Forgejo source forge\n redirect_uris: https://forgejo.coulomb.social/user/oauth2/netkingdom/callback\n allowed_scopes: openid,profile,email,groups\n client_type: confidential\n mfa_required: "true"\n access_groups: forge-admins,forge-coulomb-owners,forge-coulomb-readers\n initial_members: forge-admins=tegwick;forge-coulomb-owners=tegwick\n consuming_service: forgejo\n consumer_owner_repo: railiance-apps\n'
class Generate(unittest.TestCase):
SERVICE_TYPE = Path('/home/worsch/net-kingdom/security-case-types/oidc-service-client/type.yaml')
# The first oidc-service-client case (net-kingdom examples/nk-bind-executor-approval.yaml),
# materialized with an acceptance only in this fake test.
SERVICE_CASE = '''apiVersion: netkingdom.io/security-case/v0.1
kind: SecurityCase
spec:
id: nk-bind-executor-approval
type: oidc-service-client@0.2
status: materialized
acceptance:
at: "2026-11-01"
record: "test fixture only"
by:
- {role: owner, name: founder}
- {role: founder, name: founder}
owner:
repo: net-kingdom
person: founder
parameters:
client_id: nk-bind-executor-approval
display_name: NetKingdom bounded executor (approval-engine)
audience: approval-engine
allowed_scopes: approval:create,approval:read,approval:consume
service_subject: service:nk-bind-executor
tenant: tenant:platform
roles: bounded-executor
token_lifetime: 15m
consuming_service: bounded-executor
consumer_namespace: sso
consumer_service_account: nk-bind-executor
consumer_owner_repo: net-kingdom
'''
class Harness(unittest.TestCase):
CASE_TEXT = CASE
CLIENT_ID = 'forgejo'
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
(self.tmp / 'bin').mkdir()
@ -70,14 +107,16 @@ class Generate(unittest.TestCase):
(self.tmp / 'bin/bao').chmod(0o755)
self.state = {'calls': [], 'kv': {},
'caller_policies': ['default', 'credential-broker-keycape-client-secret-generate-issuer']}
self.write_case(CASE)
self.write_case(self.CASE_TEXT)
def write_case(self, text):
self.case = self.tmp / 'case.yaml'
self.case.write_text(text)
self.ref = 'forgejo-netkingdom-oidc@sha256:' + hashlib.sha256(text.encode()).hexdigest()
case_id = yaml.safe_load(text)['spec']['id']
self.ref = case_id + '@sha256:' + hashlib.sha256(text.encode()).hexdigest()
def run_cli(self, *extra, client_id='forgejo', ref=None, env_extra=None):
def run_cli(self, *extra, client_id=None, ref=None, env_extra=None):
client_id = client_id or self.CLIENT_ID
(self.tmp / 'state.json').write_text(json.dumps(self.state))
receipt = self.tmp / f'r{len(list(self.tmp.glob("r*.json")))}.json'
empty_home = self.tmp / 'empty-home'
@ -101,6 +140,9 @@ class Generate(unittest.TestCase):
def kv_calls(self):
return [c for c in self.state['calls'] if c['args'][0] in ('read', 'write')]
class Generate(Harness):
def test_generates_once_with_grant_token_and_revokes_it(self):
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (0, 'generated'))
@ -218,7 +260,8 @@ class Generate(unittest.TestCase):
with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, binding):
with self.assertRaises(broker.Exit):
broker.check_acknowledged_case(doc) # no parameter admission
with mock.patch.dict(broker.PARAMETER_PINS, {'1.3': broker.PARAMETER_PINS['1.1']}):
with mock.patch.dict(broker.PARAMETER_PINS, {
'oidc-browser-relying-party@1.3': broker.PARAMETER_PINS['oidc-browser-relying-party@1.1']}):
broker.check_acknowledged_case(doc)
type_doc['spec']['status'] = 'proposed'
typ.write_text(yaml.safe_dump(type_doc))
@ -298,5 +341,188 @@ class Generate(unittest.TestCase):
self.assertTrue(receipt['grant_token_revoked'])
class ServiceClientGenerate(Harness):
"""oidc-service-client@0.2 (RPF-WP-0069-T08): admitted, confidential by type, create-only."""
CASE_TEXT = SERVICE_CASE
CLIENT_ID = 'nk-bind-executor-approval'
def test_generates_once_at_the_case_path_and_revokes(self):
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (0, 'generated'))
self.assertEqual(receipt['path'], 'platform/workloads/keycape/nk-bind-executor-approval')
self.assertTrue(receipt['case_verified'])
self.assertTrue(receipt['grant_token_revoked'])
self.assertTrue(all(c['token'] == GRANT_TOKEN for c in self.kv_calls()))
self.assertFalse(any(c['args'][0] == 'read' and '/data/' in c['args'][-1] for c in self.kv_calls()))
self.assertEqual(self.state['written']['options'], {'cas': 0})
self.assertEqual(list(self.state['written']['data']), ['CLIENT_SECRET'])
def test_existing_path_is_refused_and_never_touched(self):
self.state['kv']['workloads/keycape/nk-bind-executor-approval'] = [{'CLIENT_SECRET': 'old'}]
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (3, 'already_present'))
self.assertNotIn('written', self.state)
self.assertTrue(receipt['grant_token_revoked'])
def test_lost_cas_race_and_dry_run(self):
code, receipt = self.run_cli('--dry-run')
self.assertEqual((code, receipt['outcome']), (0, 'dry_run_would_generate'))
self.assertNotIn('written', self.state)
self.state['race'] = True
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (3, 'already_present_lost_race'))
def test_confidentiality_comes_from_the_type_not_a_parameter(self):
# No client_type parameter is needed ...
self.assertNotIn('client_type', SERVICE_CASE)
# ... and a case cannot claim one, either way.
for value in ('confidential', 'public'):
with self.subTest(value=value):
self.write_case(SERVICE_CASE + f' client_type: {value}\n')
self.state['calls'] = []
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'case_client_type_not_admitted'))
self.assertEqual(self.state['calls'], [])
def test_type_refusals_are_refused_before_any_bao_call(self):
bad = {
'service_subject: service:nk-bind-executor': 'service_subject: nk-bind-executor',
'token_lifetime: 15m': 'token_lifetime: 30m',
'allowed_scopes: approval:create,approval:read,approval:consume': 'allowed_scopes: openid,approval:read',
'tenant: tenant:platform': 'tenant: platform',
'audience: approval-engine': 'audience: Approval_Engine',
'consumer_namespace: sso': 'consumer_namespace: SSO',
'consumer_service_account: nk-bind-executor': 'consumer_service_account: ../nk',
}
for old, new in bad.items():
with self.subTest(new=new):
self.assertIn(old, SERVICE_CASE)
self.write_case(SERVICE_CASE.replace(old, new))
self.state['calls'] = []
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'case_source_validation_failed'))
self.assertEqual(self.state['calls'], [])
for extra in (' issuer: https://evil.example\n', ' redirect_uris: https://x.example/cb\n',
' unexpected_target: forgejo\n'):
with self.subTest(extra=extra):
self.write_case(SERVICE_CASE + extra)
self.state['calls'] = []
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'case_source_validation_failed'))
self.assertEqual(self.state['calls'], [])
for required in ('consumer_service_account', 'service_subject', 'audience'):
with self.subTest(missing=required):
text = '\n'.join(l for l in SERVICE_CASE.splitlines() if not l.strip().startswith(required + ':')) + '\n'
self.write_case(text)
self.state['calls'] = []
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'case_source_validation_failed'))
self.assertEqual(self.state['calls'], [])
def test_case_and_client_checks_still_apply(self):
for outcome, kwargs in (('case_client_id_mismatch', {'client_id': 'forgejo'}),
('client_id_invalid_or_reserved', {'client_id': 'secrets-engine-openbao'})):
with self.subTest(outcome=outcome):
self.state['calls'] = []
code, receipt = self.run_cli(**kwargs)
self.assertEqual((code, receipt['outcome']), (2, outcome))
self.assertEqual(self.state['calls'], [])
for text, outcome in ((SERVICE_CASE.replace('status: materialized', 'status: proposed'), 'case_not_materialized'),
(SERVICE_CASE.replace(' acceptance:\n at: "2026-11-01"\n record: "test fixture only"\n'
' by:\n - {role: owner, name: founder}\n'
' - {role: founder, name: founder}\n', ''),
'case_source_validation_failed')):
with self.subTest(outcome=outcome):
self.write_case(text)
self.state['calls'] = []
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, outcome))
self.assertEqual(self.state['calls'], [])
def test_only_exact_acknowledged_service_revision_is_admitted(self):
for type_ref in ('oidc-service-client@0.1', 'oidc-service-client@0.3', 'oidc-service-client@1.1'):
with self.subTest(type_ref=type_ref):
self.write_case(SERVICE_CASE.replace('oidc-service-client@0.2', type_ref))
self.state['calls'] = []
code, receipt = self.run_cli()
# An unlisted type keeps the browser parameter check, which a service case
# (no client_type) fails first; either way nothing reaches bao.
self.assertEqual((code, receipt['outcome']), (2, 'case_client_not_confidential'))
self.assertEqual(self.state['calls'], [])
def _broker_with_copied_sources(self):
spec = importlib.util.spec_from_file_location(
'service_generator', ROOT / 'scripts/keycape_client_secret_generate.py')
broker = importlib.util.module_from_spec(spec)
spec.loader.exec_module(broker)
source = self.tmp / 'net-kingdom'
relative, _ = broker.ACKNOWLEDGED_TYPES['oidc-service-client@0.2']
for rel in (relative, *broker.SOURCE_PINS):
dest = source / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(broker.NETKINGDOM_ROOT / rel, dest)
return broker, source, source / relative
def test_drifted_or_proposed_type_fails_closed(self):
broker, source, typ = self._broker_with_copied_sources()
doc = yaml.safe_load(SERVICE_CASE)
raw = typ.read_bytes()
with mock.patch.object(broker, 'NETKINGDOM_ROOT', source):
broker.check_acknowledged_case(doc)
typ.write_bytes(raw + b'\n# drift\n')
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
typ.write_bytes(raw.replace(b'status: acknowledged', b'status: proposed'))
with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, {
'oidc-service-client@0.2': (str(typ.relative_to(source)), hashlib.sha256(typ.read_bytes()).hexdigest())}):
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
def test_parameter_pin_is_per_type_reference(self):
broker, source, typ = self._broker_with_copied_sources()
doc = yaml.safe_load(SERVICE_CASE)
with mock.patch.object(broker, 'NETKINGDOM_ROOT', source):
with mock.patch.dict(broker.PARAMETER_PINS, {'oidc-service-client@0.2': '0' * 64}):
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
def test_type_that_is_not_confidential_by_construction_is_refused(self):
broker, source, typ = self._broker_with_copied_sources()
doc = yaml.safe_load(SERVICE_CASE)
type_doc = yaml.safe_load(typ.read_bytes())
for item in type_doc['spec']['materialization']:
if item['id'] == 'client-secret-path':
item['when'] = 'client_type == confidential'
typ.write_text(yaml.safe_dump(type_doc))
with mock.patch.object(broker, 'NETKINGDOM_ROOT', source):
with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, {
'oidc-service-client@0.2': (str(typ.relative_to(source)), hashlib.sha256(typ.read_bytes()).hexdigest())}):
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_type_not_confidential')
def test_pins_match_the_acknowledged_source(self):
spec = importlib.util.spec_from_file_location(
'pin_generator', ROOT / 'scripts/keycape_client_secret_generate.py')
broker = importlib.util.module_from_spec(spec)
spec.loader.exec_module(broker)
raw = SERVICE_TYPE.read_bytes()
self.assertEqual(hashlib.sha256(raw).hexdigest(), broker.ACKNOWLEDGED_TYPES['oidc-service-client@0.2'][1])
typ = yaml.safe_load(raw)['spec']
self.assertEqual((typ['version'], typ['status']), ('0.2', 'acknowledged'))
names = {p['name'] for p in typ['parameters']}
self.assertTrue({'client_id', 'audience', 'allowed_scopes', 'service_subject', 'tenant',
'token_lifetime', 'consuming_service', 'consumer_namespace',
'consumer_service_account', 'consumer_owner_repo'} <= names)
self.assertNotIn('client_type', names)
client_id = next(p for p in typ['parameters'] if p['name'] == 'client_id')
self.assertEqual(client_id['constraints']['pattern'], broker.CLIENT_ID_RE.pattern)
self.assertTrue(broker.type_is_confidential(typ))
self.assertEqual(broker.CONFIDENTIALITY['oidc-browser-relying-party@1.1'], 'parameter')
if __name__ == '__main__':
unittest.main()

View file

@ -9,7 +9,7 @@ flavor: implementation
owner: claude
topic_slug: railiance
created: "2026-10-31"
updated: "2026-10-31"
updated: "2026-11-01"
related:
- NK-WP-0056
- NK-WP-0040
@ -158,3 +158,60 @@ net-kingdom-owner approval was recorded by hand in the tool's format). Make vali
that is still collecting role approvals, or have `approve` set `approved` only once all required roles are present,
with a test.
## Broker admits oidc-service-client@0.2 for mint-secret
```task
id: RPF-WP-0069-T08
status: done
priority: high
```
Review R1 of `docs/credential-lane-designs/oidc-service-client-review.md`: the create-only grant
(CCR-2026-0040/0043) already covers `platform/workloads/keycape/<client_id>`, but the broker
`scripts/keycape_client_secret_generate.py` admitted only `oidc-browser-relying-party@1.1` and required
`parameters.client_type == confidential`. The founder acknowledged `oidc-service-client` 0.2 on 2026-11-01 (memo
`nk-wp-0056-t14-oidc-service-client-r2`, hub decision `f8044f81`, net-kingdom `9ce0d60`), which meets the
RPF-WP-0060-T01 rule.
Done 2026-11-01:
- `ACKNOWLEDGED_TYPES` pins `oidc-service-client@0.2` (type sha256 `cd238693...`).
- `PARAMETER_PINS` is keyed by the exact type reference (`dda3bd19...` for 0.2), so two types sharing a version
never share a pin.
- Confidentiality is per type (`CONFIDENTIALITY`). Browser 1.1 still requires `client_type: confidential`. For the
service type it is derived from the authenticated type bytes: no `client_type` parameter and an unconditional
`client-secret-path`. A service case that carries `client_type` is refused (`case_client_type_not_admitted`).
- Browser behaviour is unchanged. Create-only is unchanged: an existing path or a lost CAS race exits 3 and is
never touched.
- Tests (`tests/test_keycape_client_secret_generate.py`, `ServiceClientGenerate`):
- the case generates once at the case path;
- an existing path is refused;
- dry run and a lost CAS race;
- confidentiality comes from the type;
- before any bao call, everything the pinned validator refuses is refused: the subject prefix, a lifetime over
15m, `openid`, the tenant form, the namespace and ServiceAccount patterns, an `issuer` override, unknown
parameters, missing required parameters, and a missing acceptance;
- only 0.2 is admitted;
- a drifted or proposed type, a wrong parameter pin, and a type that is not confidential by construction all
fail closed;
- the pins match the source.
The first case's custody CCRs (CCR-2026-0060/0061) were checked against the acknowledged 0.2 materialization
and now reference it. Activation stays gated on case acceptance, approval and binding confirmation (T09).
## Approve and apply the first service-client case's readers (CCR-2026-0060/0061)
```task
id: RPF-WP-0069-T09
status: wait
priority: medium
```
Waits for net-kingdom's acceptance of case `nk-bind-executor-approval` and the attended mint
(`keycape-client-secret-generate`, T08). Then:
1. platform-owner (and net-kingdom-owner for 0061) approve;
2. confirm the bindings: `external-secrets/external-secrets` for 0060, `sso/nk-bind-executor` (uid bd15569c) for
0061;
3. the delegated applier or an attended platform-admin applies, with value-free positive and negative receipts.