diff --git a/security-case-types/oidc-browser-relying-party/README.md b/security-case-types/oidc-browser-relying-party/README.md index 040e7e7..5a38071 100644 --- a/security-case-types/oidc-browser-relying-party/README.md +++ b/security-case-types/oidc-browser-relying-party/README.md @@ -129,6 +129,9 @@ uses them. ## History +- **1.1 patch** (2026-10-10): `register-client` runs `keycape check-config` + (key-cape c528daa) through `keycape-client.py --check-config` on the live and + the proposed registry; apply refuses a registry KeyCape would refuse at start. - **1.1** (2026-10-10, minor): verification `consumer-enforces-groups`, and hand-over requires the consumer to enforce `access_groups`, because KeyCape has no per-client group gate (key-cape type review R2, key-cape 6038483). No new diff --git a/security-case-types/oidc-browser-relying-party/type.yaml b/security-case-types/oidc-browser-relying-party/type.yaml index dac9e5b..a6dbf28 100644 --- a/security-case-types/oidc-browser-relying-party/type.yaml +++ b/security-case-types/oidc-browser-relying-party/type.yaml @@ -219,7 +219,7 @@ spec: closed if the binding is missing. - id: register-client owner: net-kingdom - entry_point: "sso-mfa/k8s/keycape/keycape-client.py apply --client-file --case-acceptance (net-kingdom.keycape-client-registration)" + entry_point: "sso-mfa/k8s/keycape/keycape-client.py apply --client-file --case-acceptance --check-config (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 @@ -234,8 +234,10 @@ spec: 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 client validation before the restart: a refused entry - stops the single replica and every client loses sign-in (R1). + 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: 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" diff --git a/sso-mfa/k8s/keycape/keycape-client.py b/sso-mfa/k8s/keycape/keycape-client.py index 64a0dae..264f6d9 100644 --- a/sso-mfa/k8s/keycape/keycape-client.py +++ b/sso-mfa/k8s/keycape/keycape-client.py @@ -16,6 +16,9 @@ secrets-engine-openbao-client-rollout) and keeps their safeguards: - compare-and-swap on the Secret's uid and resourceVersion, then read-back - confidential clients: the KeyCape Deployment must already bind the client's secret env var (delivered from custody), or the next pod start fails closed +- KeyCape's own startup checks (keycape check-config, key-cape c528daa) on the + live and the proposed registry, fed on stdin and never written to disk; apply + refuses a registry that would stop the single replica at its next start - no Secret value, parser message or Kubernetes error text leaves the process Modes: inspect (default), dry-run (server-side), apply. apply requires the @@ -25,7 +28,7 @@ was accepted under an acknowledged type. keycape-client.py client-from-case keycape-client.py inspect --client-file client.yaml [--operation remove] keycape-client.py apply --client-file client.yaml --case-acceptance \\ - --expected-uid --expected-resource-version + --check-config --expected-uid --expected-resource-version """ from __future__ import annotations @@ -35,6 +38,7 @@ import copy import importlib.util import json import re +import subprocess import sys from pathlib import Path @@ -194,6 +198,25 @@ def deployment_bound(deployment, client): return bool(matches) +def check_config(binary, config_text, env_names, policy_store): + """KeyCape's startup checks on one registry: names and messages only, no values.""" + command = [binary, 'check-config', '--config', '/dev/stdin', '--env-names', ','.join(env_names)] + if policy_store: + command.append('--policy-store') + result = subprocess.run(command, input=config_text.encode(), capture_output=True, timeout=60) + report = json.loads(result.stdout) + require(isinstance(report, dict) and isinstance(report.get('valid'), bool) + and report['valid'] == (result.returncode == 0), 'check_config_output_shape') + return {'valid': report['valid'], 'clients': report.get('clients'), + 'errors': [str(e) for e in report.get('errors') or []]} + + +def deployment_env_names(deployment): + containers = deployment['spec']['template']['spec']['containers'] + container = next(c for c in containers if c['name'] == 'keycape') + return sorted(e['name'] for e in container.get('env', [])) + + def run(args, client): kubectl = lane.kubectl cluster = kubectl(['get', 'namespace', 'kube-system', '-o', 'json']) @@ -202,19 +225,27 @@ def run(args, client): require(not before['metadata'].get('ownerReferences'), 'controller_owned_secret') metadata = pin.safe_metadata(before) encoded, changed = (replacement if args.operation == 'add' else removal)(before, client) - bound = None - if 'secretRef' in client: + bound = deployment = None + if 'secretRef' in client or args.check_config: deployment = kubectl(['-n', 'sso', 'get', 'deployment', 'keycape', '-o', 'json']) bound = deployment_bound(deployment, client) + checks = None + if args.check_config: + names = deployment_env_names(deployment) + policy_store = 'KEYCAPE_POLICY_PATH' in names + checks = {stage: check_config(args.check_config, base64.b64decode(config).decode(), names, policy_store) + for stage, config in (('live', before['data']['config.yaml']), ('proposed', encoded))} receipt = {'schema': 'netkingdom.keycape-client.v1', 'target': 'sso/keycape-config', 'before': metadata, 'operation': args.operation, 'client_id': client['clientId'], 'client_type': client['clientType'], 'change_needed': changed, 'mode': args.mode, - 'deployment_env_bound': bound, 'values_emitted': False, 'changed': False} + 'deployment_env_bound': bound, 'check_config': checks, 'values_emitted': False, 'changed': False} if args.mode == 'inspect': return receipt if args.mode == 'apply': require(bool(args.case_acceptance), 'case_acceptance_record_required') receipt['case_acceptance'] = args.case_acceptance + require(checks is not None, 'keycape_check_config_required') + require(checks['proposed']['valid'], 'proposed_registry_fails_keycape_check_config') if args.operation == 'add' and bound is not None: require(bound, 'deployment_env_binding_required_first') require(metadata == {'uid': args.expected_uid, 'resource_version': args.expected_resource_version}, @@ -251,6 +282,7 @@ def main(argv=None): parser.add_argument('--expected-uid') parser.add_argument('--expected-resource-version') parser.add_argument('--case-acceptance', help='the case acceptance record; required for apply') + parser.add_argument('--check-config', help='a keycape binary with check-config (key-cape c528daa or later); required for apply') args = parser.parse_args(argv) try: if args.mode == 'client-from-case': diff --git a/sso-mfa/k8s/keycape/test_keycape_client.py b/sso-mfa/k8s/keycape/test_keycape_client.py index d20523d..b57eb23 100644 --- a/sso-mfa/k8s/keycape/test_keycape_client.py +++ b/sso-mfa/k8s/keycape/test_keycape_client.py @@ -156,17 +156,53 @@ class FakeKube: def args(mode, **kw): - base = dict(mode=mode, operation='add', expected_uid=UID, expected_resource_version='7', case_acceptance=None) + base = dict(mode=mode, operation='add', expected_uid=UID, expected_resource_version='7', case_acceptance=None, + check_config='keycape') base.update(kw) return argparse.Namespace(**base) +class FakeCheck: + """Stands in for keycape check-config; refuses registries naming a client in `refuse`.""" + def __init__(self, refuse=()): + self.refuse = refuse + self.calls = [] + + def __call__(self, binary, config_text, env_names, policy_store): + self.calls.append((binary, env_names, policy_store)) + ids = [c['clientId'] for c in yaml.safe_load(config_text)['clients']] + errors = [f'client {i!r}: refused' for i in ids if i in self.refuse] + return {'valid': not errors, 'clients': len(ids), 'errors': errors} + + class RunTests(unittest.TestCase): def setUp(self): - self.original = kc.lane.kubectl + self.original = kc.lane.kubectl, kc.check_config + kc.check_config = FakeCheck() def tearDown(self): - kc.lane.kubectl = self.original + kc.lane.kubectl, kc.check_config = self.original + + def test_apply_needs_keycape_check_config(self): + kc.lane.kubectl = FakeKube(fixture()[0]) + with self.assertRaises(kc.Refused): + kc.run(args('apply', case_acceptance='decision:x', check_config=None), FORGEJO) + + def test_apply_refuses_a_registry_keycape_would_refuse(self): + fake = FakeKube(fixture()[0]) + kc.lane.kubectl = fake + kc.check_config = FakeCheck(refuse=('forgejo',)) + with self.assertRaises(kc.Refused): + kc.run(args('apply', case_acceptance='decision:x'), FORGEJO) + self.assertEqual([], fake.patches) + + def test_inspect_reports_live_and_proposed_checks_with_deployment_env(self): + kc.lane.kubectl = FakeKube(fixture()[0]) + kc.check_config = FakeCheck(refuse=('forgejo',)) + receipt = kc.run(args('inspect'), FORGEJO) + self.assertTrue(receipt['check_config']['live']['valid']) + self.assertFalse(receipt['check_config']['proposed']['valid']) + self.assertEqual([kc.env_name('forgejo')], kc.check_config.calls[0][1]) def test_inspect_changes_nothing_and_emits_no_values(self): fake = FakeKube(fixture()[0]) diff --git a/workplans/NK-WP-0048-security-case-types.md b/workplans/NK-WP-0048-security-case-types.md index bcaa487..be2f278 100644 --- a/workplans/NK-WP-0048-security-case-types.md +++ b/workplans/NK-WP-0048-security-case-types.md @@ -416,6 +416,20 @@ acknowledgment intake keeps `@1.0` as the record of what was acknowledged. ops-mason's build and ops-warden's case-ref digest predate this and need a re-render. +2026-10-10: ops-mason re-rendered the Forgejo case from `@1.1` (message +`22c6bba6`; plan digest `af1f4f81…`, no collisions) and rebuilt the bundle. The +rendered OpenBao objects are identical to 1.0, so the already applied +CCR-2026-0041 stands; 1.1 changes only consumer-side steps. key-cape shipped +`keycape check-config` (c528daa, message `a7b0aeff`): KeyCape's startup checks +without secret values, including the new duplicate-clientId rule. +`keycape-client.py --check-config ` now runs it on the live and the +proposed registry, fed on stdin, and apply refuses a proposed registry that +fails; register-client's entry point names the option (1.1 patch). The deployed +image predates c528daa, so the binary is built from key-cape source for now. +Before the next KeyCape rollout (Forgejo register-client or the CCR-2026-0018 +window), the attended operator runs `inspect --check-config` so the live +registry is checked too. + ```task id: NK-WP-0048-T07 status: wait