diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index cdfbc63..e72fb0f 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/credential-change-requests/CCR-2026-0060-nk-bind-executor-approval-keycape-eso-reader.yaml b/credential-change-requests/CCR-2026-0060-nk-bind-executor-approval-keycape-eso-reader.yaml index 959d371..4d72bea 100644 --- a/credential-change-requests/CCR-2026-0060-nk-bind-executor-approval-keycape-eso-reader.yaml +++ b/credential-change-requests/CCR-2026-0060-nk-bind-executor-approval-keycape-eso-reader.yaml @@ -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 diff --git a/credential-change-requests/CCR-2026-0061-nk-bind-executor-approval-consumer-reader.yaml b/credential-change-requests/CCR-2026-0061-nk-bind-executor-approval-consumer-reader.yaml index 6349516..f93be38 100644 --- a/credential-change-requests/CCR-2026-0061-nk-bind-executor-approval-consumer-reader.yaml +++ b/credential-change-requests/CCR-2026-0061-nk-bind-executor-approval-consumer-reader.yaml @@ -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 diff --git a/credential-grants/catalog.yaml b/credential-grants/catalog.yaml index afb17dd..3ce4a43 100644 --- a/credential-grants/catalog.yaml +++ b/credential-grants/catalog.yaml @@ -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/ 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: diff --git a/openbao/policies/workload-kv-read-keycape-nk-bind-executor-approval-bounded-executor.hcl b/openbao/policies/workload-kv-read-keycape-nk-bind-executor-approval-bounded-executor.hcl index 870d909..c51f358 100644 --- a/openbao/policies/workload-kv-read-keycape-nk-bind-executor-approval-bounded-executor.hcl +++ b/openbao/policies/workload-kv-read-keycape-nk-bind-executor-approval-bounded-executor.hcl @@ -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" { diff --git a/openbao/policies/workload-kv-read-keycape-nk-bind-executor-approval-eso.hcl b/openbao/policies/workload-kv-read-keycape-nk-bind-executor-approval-eso.hcl index a1c80ff..bd4caa4 100644 --- a/openbao/policies/workload-kv-read-keycape-nk-bind-executor-approval-eso.hcl +++ b/openbao/policies/workload-kv-read-keycape-nk-bind-executor-approval-eso.hcl @@ -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" { diff --git a/scripts/keycape_client_secret_generate.py b/scripts/keycape_client_secret_generate.py index 96bdedc..5f011c3 100644 --- a/scripts/keycape_client_secret_generate.py +++ b/scripts/keycape_client_secret_generate.py @@ -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) diff --git a/tests/test_keycape_client_secret_generate.py b/tests/test_keycape_client_secret_generate.py index bd34ffd..829e3da 100644 --- a/tests/test_keycape_client_secret_generate.py +++ b/tests/test_keycape_client_secret_generate.py @@ -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() diff --git a/workplans/RPF-WP-0069-bounded-executor-lanes.md b/workplans/RPF-WP-0069-bounded-executor-lanes.md index 5cc1cfb..c308d54 100644 --- a/workplans/RPF-WP-0069-bounded-executor-lanes.md +++ b/workplans/RPF-WP-0069-bounded-executor-lanes.md @@ -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/`, 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.