diff --git a/security-case-type-revisions/1.3/oidc-browser-relying-party/type.yaml b/security-case-type-revisions/1.3/oidc-browser-relying-party/type.yaml index 7479022..bb7f0a7 100644 --- a/security-case-type-revisions/1.3/oidc-browser-relying-party/type.yaml +++ b/security-case-type-revisions/1.3/oidc-browser-relying-party/type.yaml @@ -313,6 +313,10 @@ spec: warden_executes: false attended: true value_handling: none + ready_check: after a changed registry the adapter stamps one restart of the single-replica + Recreate issuer and its status-only readiness verdict is ready (rollout 1/1/1, one restarted + Ready pod, discovery serves https://kc.coulomb.social with all endpoints); failed or uncertain + keeps the step uncertain for attended owner rollback or reconciliation (NK-WP-0048-T12) 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 @@ -495,4 +499,4 @@ spec: ensure-groups: sha256:ebc9d5b9167d18e6015f4bbeb9699e8d396d64fbfaf195142781f84f4f47a70a hand-over: sha256:1bce07a3229b7d0c7c730f35ce5b40fe471009a82de8dd53788ed970a9be1224 mint-secret: sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70 - register-client: sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7 + register-client: sha256:434c53e88d4dd86a0325603468c3d6618570f38155484a76ed37d2f1550d43cb diff --git a/sso-mfa/k8s/keycape/descriptor-composition-1.2.candidate.json b/sso-mfa/k8s/keycape/descriptor-composition-1.2.candidate.json index f98010b..82ea9a3 100644 --- a/sso-mfa/k8s/keycape/descriptor-composition-1.2.candidate.json +++ b/sso-mfa/k8s/keycape/descriptor-composition-1.2.candidate.json @@ -150,11 +150,11 @@ "type_owner": "net-kingdom" }, { - "candidate_descriptor_digest": "sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7", + "candidate_descriptor_digest": "sha256:434c53e88d4dd86a0325603468c3d6618570f38155484a76ed37d2f1550d43cb", "descriptor": { "actor": "human", "adapter": "net-kingdom/keycape-registration@v1", - "adapter_source": "sha256:b30f7abfbb5bf439c5ce067325b01c8b711076a38a6fb8484f55d5c8912921f3", + "adapter_source": "sha256:212172e34155d1852375b4c6c2868c3da82f5f8e7c7957f6c9eab1430880f6d3", "allowed_created": [ "keycape-client" ], diff --git a/sso-mfa/k8s/keycape/descriptor-composition-1.3.candidate.json b/sso-mfa/k8s/keycape/descriptor-composition-1.3.candidate.json index d18b649..a6221e5 100644 --- a/sso-mfa/k8s/keycape/descriptor-composition-1.3.candidate.json +++ b/sso-mfa/k8s/keycape/descriptor-composition-1.3.candidate.json @@ -15,7 +15,7 @@ "ensure-groups": "sha256:ebc9d5b9167d18e6015f4bbeb9699e8d396d64fbfaf195142781f84f4f47a70a", "hand-over": "sha256:1bce07a3229b7d0c7c730f35ce5b40fe471009a82de8dd53788ed970a9be1224", "mint-secret": "sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70", - "register-client": "sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7" + "register-client": "sha256:434c53e88d4dd86a0325603468c3d6618570f38155484a76ed37d2f1550d43cb" }, "owner_review": false, "params": { @@ -154,11 +154,11 @@ "type_owner": "net-kingdom" }, { - "candidate_descriptor_digest": "sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7", + "candidate_descriptor_digest": "sha256:434c53e88d4dd86a0325603468c3d6618570f38155484a76ed37d2f1550d43cb", "descriptor": { "actor": "human", "adapter": "net-kingdom/keycape-registration@v1", - "adapter_source": "sha256:b30f7abfbb5bf439c5ce067325b01c8b711076a38a6fb8484f55d5c8912921f3", + "adapter_source": "sha256:212172e34155d1852375b4c6c2868c3da82f5f8e7c7957f6c9eab1430880f6d3", "allowed_created": [ "keycape-client" ], diff --git a/sso-mfa/k8s/keycape/keycape-registration-adapter.py b/sso-mfa/k8s/keycape/keycape-registration-adapter.py index 2bae68d..522ce3b 100644 --- a/sso-mfa/k8s/keycape/keycape-registration-adapter.py +++ b/sso-mfa/k8s/keycape/keycape-registration-adapter.py @@ -24,13 +24,20 @@ from warden.case_receipt_state import Outcome, Refused ROOT = Path(__file__).resolve().parent SOURCE_FILES = ('keycape-registration-adapter.py', 'keycape-client.py', 'portal-client-rollout.py', 'openbao-client-config.py', - 'register-user-engine-portal.py') + 'register-user-engine-portal.py', 'restart-readiness.py') REFERENCE = 'net-kingdom/keycape-registration@v1' # Importing the maintained helper performs no tooling operation. _spec = importlib.util.spec_from_file_location('registration_owner_helper', ROOT / 'keycape-client.py') helper = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(helper) +_spec = importlib.util.spec_from_file_location('registration_restart', ROOT / 'restart-readiness.py') +restart_readiness = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(restart_readiness) +RESTART_KEYS = frozenset({'read', 'write', 'observe', 'sleep', 'issuer'}) +# KeyCape loads clients at startup: a changed registry is not in effect until the +# issuer restarts. Bounded wait: 60 x 5 s. +RESTART_ATTEMPTS, RESTART_INTERVAL = 60, 5 @contextmanager @@ -85,7 +92,7 @@ def descriptor(check_config_digest): def build(*, admitted: Invocation, case_source: bytes, expected_uid: str, expected_resource_version: str, case_acceptance: str, check_config: Path, check_config_digest: str, publish, authenticate, - revoke_session, holder=None, observe=None): + revoke_session, restart, holder=None, observe=None): """Bind one admitted case and observed revision; return a closed kernel Adapter. publish(bytes)->identifier durably retains only the closed metadata payload. @@ -94,6 +101,14 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str, session; Warden independently authenticates it using its own evidence verifier. These callbacks are capabilities, never command strings or case parameters. + restart is a mapping of read()->Deployment, write(Deployment, expected_rv)->rv + (CAS), observe()->(Deployment, pods, discovery), sleep(seconds) and the + expected issuer URL. After any apply that changed the registry, the step + stamps one restart of the single-replica issuer and waits for the reviewed + readiness verdict (restart-readiness.py, NK-WP-0048-T12). Anything but + `ready` raises, so the kernel keeps the step uncertain for attended owner + rollback or reconciliation; it is never retried here. + holder(original_attempt_id)->[(identifier, bytes)] and observe()->secret are optional read-only capabilities, injected together or not at all. With them the Adapter gains reconcile_original: it confirms a creation only from one @@ -133,6 +148,10 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str, raise Refused('acceptance evidence binding differs') if not all(callable(x) for x in (publish, authenticate, revoke_session)): raise Refused('owner evidence and revocation capabilities required') + if (not isinstance(restart, dict) or set(restart) != RESTART_KEYS + or not all(callable(restart[k]) for k in RESTART_KEYS - {'issuer'}) + or not isinstance(restart['issuer'], str) or not restart['issuer'].startswith('https://')): + raise Refused('issuer restart and readiness capabilities required') if (holder is None) != (observe is None) or not all( x is None or callable(x) for x in (holder, observe)): raise Refused('original-attempt holder and readback must be injected together') @@ -152,6 +171,21 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str, if source_digest(check_config_digest) != reviewed_source: raise Refused('owner source changed') + def restart_issuer(inv): + stamp = 'case-' + digest(inv.attempt_id.encode()).split(':', 1)[1][:32] + deployment = restart['read']() + revision = deployment.get('metadata', {}).get('resourceVersion') + planned = restart_readiness.plan_restart(deployment, stamp, revision) + written = restart['write'](planned, revision) + if not isinstance(written, str) or not 0 < len(written) <= 192: + raise Refused('restart write ambiguous; owner reconciliation required') + receipt = restart_readiness.wait_ready(restart['observe'], restart['sleep'], stamp=stamp, + expected_issuer=restart['issuer'], attempts=RESTART_ATTEMPTS, interval=RESTART_INTERVAL) + if receipt['verdict'] != 'ready': + raise Refused(f"issuer restart {receipt['verdict']}; owner reconciliation required") + return {'stamp': stamp, 'before': revision, 'after': written, + 'verdict': receipt['verdict'], 'attempts': receipt['attempts']} + def execute(inv, operation, created=()): nonlocal creation try: @@ -192,6 +226,7 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str, outcome = (Outcome.ROLLED_BACK if operation == 'remove' else Outcome.SUCCESS if changed else Outcome.NO_CHANGE) objects = ('keycape-client',) if operation == 'add' and changed else () + restarted = restart_issuer(inv) if changed else None finally: # Even helper failure must end the parent session. Failures propagate # contained to the kernel and become uncertain, never safe to replay. @@ -202,7 +237,7 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str, payload = json.dumps({'schema': 'netkingdom.keycape-registration.attempt.v1', 'invocation': asdict(inv), 'source': reviewed_source, 'outcome': outcome.value, 'created': objects, 'before': receipt['before'], - 'after': after, 'session_revocation': revocation}, + 'after': after, 'issuer_restart': restarted, 'session_revocation': revocation}, sort_keys=True, separators=(',', ':')).encode() with contained_output(): evidence = publish(payload) diff --git a/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py b/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py index c403916..80d9816 100644 --- a/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py +++ b/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py @@ -22,6 +22,49 @@ f = importlib.util.module_from_spec(spec) spec.loader.exec_module(f) +ISSUER = 'https://kc.coulomb.social' + + +class FakeIssuer: + """Single-replica Recreate issuer; becomes Ready once a restart stamp is written.""" + + def __init__(self, outcome='ready'): + self.outcome, self.writes, self.sleeps = outcome, [], [] + self.deployment = {'kind': 'Deployment', + 'metadata': {'name': 'keycape', 'namespace': 'sso', 'resourceVersion': '40', 'generation': 3}, + 'spec': {'replicas': 1, 'strategy': {'type': 'Recreate'}, + 'template': {'spec': {'containers': [{'name': 'keycape'}]}}}, + 'status': {'observedGeneration': 3, 'updatedReplicas': 1, 'readyReplicas': 1, 'availableReplicas': 1}} + + def read(self): + return copy.deepcopy(self.deployment) + + def write(self, deployment, expected): + assert expected == self.deployment['metadata']['resourceVersion'] + self.writes.append(copy.deepcopy(deployment)) + self.deployment = copy.deepcopy(deployment) + self.deployment['metadata']['resourceVersion'] = str(int(expected) + 1) + self.deployment['metadata']['generation'] += 1 + self.deployment['status']['observedGeneration'] += 1 + return self.deployment['metadata']['resourceVersion'] + + def observe(self): + stamp = self.deployment['spec']['template'].get('metadata', {}).get('annotations', {}).get( + a.restart_readiness.ANNOTATION) + state = {'waiting': {'reason': 'CrashLoopBackOff'}} if self.outcome == 'crash' else {'running': {}} + ready = 'False' if self.outcome in ('crash', 'stuck') else 'True' + pod = {'metadata': {'annotations': {a.restart_readiness.ANNOTATION: stamp}}, + 'status': {'phase': 'Running', 'conditions': [{'type': 'Ready', 'status': ready}], + 'containerStatuses': [{'name': 'keycape', 'state': state}]}} + discovery = {'issuer': ISSUER, 'authorization_endpoint': ISSUER + '/authorize', + 'token_endpoint': ISSUER + '/token', 'jwks_uri': ISSUER + '/jwks'} + return copy.deepcopy(self.deployment), [pod], discovery if ready == 'True' else None + + def capabilities(self): + return {'read': self.read, 'write': self.write, 'observe': self.observe, + 'sleep': self.sleeps.append, 'issuer': ISSUER} + + class RegistrationTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() @@ -44,7 +87,9 @@ class RegistrationTests(unittest.TestCase): expected_uid=f.UID, expected_resource_version='7', case_acceptance='case-acceptance:test', check_config=self.binary, check_config_digest=digest(self.binary.read_bytes()), publish=self.publish, authenticate=lambda ref, payload: self.evidence.get(ref) == payload, - revoke_session=self.revoke) + revoke_session=self.revoke, restart=None) + self.issuer = FakeIssuer() + self.kw['restart'] = self.issuer.capabilities() self.check_patch = patch.object(a.helper, 'check_config', return_value={'valid': True}) self.kube_patch = patch.object(a.helper.lane, 'kubectl', side_effect=self.kube) self.check_patch.start() @@ -106,6 +151,47 @@ class RegistrationTests(unittest.TestCase): self.assertNotIn(b'config.yaml', payload) self.assertNotIn(b'signing-key', payload) + def test_changed_registry_restarts_issuer_and_records_ready_verdict(self): + adapter = a.build(**self.kw) + result = adapter.invoke(self.inv) + self.assertEqual(result.outcome, Outcome.SUCCESS) + self.assertEqual(len(self.issuer.writes), 1) + payload = json.loads(self.evidence[result.evidence]) + self.assertEqual(payload['issuer_restart']['verdict'], 'ready') + self.assertEqual(payload['issuer_restart']['before'], '40') + undone = adapter.rollback(replace(self.inv, attempt_id='attempt-two', operation='rollback'), result.created) + self.assertEqual(undone.outcome, Outcome.ROLLED_BACK) + self.assertEqual(len(self.issuer.writes), 2) + + def test_no_change_does_not_restart_issuer(self): + self.secret, _ = f.fixture(f.EXISTING + [f.FORGEJO]) + result = a.build(**self.kw).invoke(self.inv) + self.assertEqual(result.outcome, Outcome.NO_CHANGE) + self.assertFalse(self.issuer.writes) + self.assertIsNone(json.loads(self.evidence[result.evidence])['issuer_restart']) + + def test_failed_or_stuck_restart_is_uncertain_and_revokes_session(self): + for outcome in ('crash', 'stuck'): + with self.subTest(outcome=outcome): + self.setUp() + self.issuer = FakeIssuer(outcome) + adapter = a.build(**dict(self.kw, restart=self.issuer.capabilities())) + with self.assertRaises(Refused): + adapter.invoke(self.inv) + self.assertEqual(len(self.issuer.writes), 1) + self.assertFalse(self.evidence) + self.assertEqual(self.revocations, [self.inv]) + if outcome == 'stuck': + self.assertEqual(len(self.issuer.sleeps), a.RESTART_ATTEMPTS - 1) + + def test_restart_capabilities_are_required_and_bounded(self): + caps = self.issuer.capabilities() + for bad in (None, {}, dict(caps, issuer='http://kc.coulomb.social'), + dict(caps, write='not-callable'), dict(caps, extra=print)): + with self.subTest(bad=bad if not isinstance(bad, dict) else sorted(bad)): + with self.assertRaisesRegex(Refused, 'restart'): + a.build(**dict(self.kw, restart=bad)) + def test_adapter_refuses_symlink_fifo_and_oversized_source_before_helper(self): symlink = self.binary.with_name('symlink') symlink.symlink_to(self.binary) diff --git a/workplans/NK-WP-0048-security-case-types.md b/workplans/NK-WP-0048-security-case-types.md index 81a5224..8f4c18f 100644 --- a/workplans/NK-WP-0048-security-case-types.md +++ b/workplans/NK-WP-0048-security-case-types.md @@ -630,7 +630,7 @@ publication receipt: Custodian id: NK-WP-0048-T12 status: wait priority: high -blocking_reason: "Restart/readiness source candidate delivered 2026-10-23 (sso-mfa/k8s/keycape/restart-readiness.py, 11 synthetic tests). Awaits the coordinator's 1.3 placement decision (ready_check vs NetKingdom step vs carried limit) at the T16 re-freeze, then owner review and descriptor wiring." +blocking_reason: "Wired into register-client 2026-10-23 per coordinator r6 recommendation (descriptor sha256:434c53e8..43cb; 1.3 map + ready_check updated). Awaits coordinator r7 together with the F3 fix; key-cape to confirm readiness criteria." state_hub_task_id: "7ce4eee3-cd47-55a9-bfa5-4f1835d4d6fd" ``` @@ -682,6 +682,28 @@ Separately: `test_adapter_source_closure.py` already fails 7 probes in this workstation environment, with or without this change, because the probe subprocess cannot import `warden`. +2026-10-23 wiring. Coordinator r6 (Custodian `README-r6.md`, a870092) +recommended putting the restart inside register-client rather than as a bare +ready_check or an eighth step. Done: +- `keycape-registration-adapter.py` now requires a `restart` capability bundle + (read, CAS write, observe, sleep, https issuer). After any apply that changed + the registry, in either direction, it stamps one restart and waits for + readiness (at most 60 x 5 s). `failed` or `uncertain` raises, so the kernel + keeps the step uncertain; the session is still revoked. +- The attempt payload records `issuer_restart`, and `restart-readiness.py` joins + the source closure. +- 4 new tests: ready, no-change without restart, crash/stuck uncertain, and + bounded capabilities. +- The register-client descriptor is now `sha256:434c53e8…43cb` (was + `4ecc769f…26e7`). The 1.2/1.3 compositions are regenerated, and the 1.3 map + plus a status-only `ready_check` on register-client are updated. +- The keycape and type suites pass: 363 passed, 5 skipped. + +The source-closure failure noted above was a relative-PYTHONPATH artifact; fixed +in 3a68d05. Open, from r6: readiness does not prove the new client was loaded +(key-cape to confirm the criteria), and bind-env's own restart has no readiness +check. Coordinator r7 reviews this together with the F3 fix. + ## T13 — Prepare the fixed registration adapter source candidate ```task @@ -870,7 +892,7 @@ id: NK-WP-0048-T16 status: wait depends_on: [NK-WP-0048-T24, NK-WP-0048-T25, NK-WP-0048-T26, NK-WP-0048-T27, NK-WP-0048-T28, NK-WP-0048-T29, NK-WP-0048-T30] priority: high -blocking_reason: "Owner re-check of the 1.3 candidate cannot start: coordinator r5 (001a38c) left NK48-R5-F1..F3 open and their fixes changed 1.3 type.yaml (now ef02dd.., re-check froze e75fe9..). Needs re-freeze + coordinator r6 with no open findings; kernel review r2 is clean." +blocking_reason: "Coordinator r6 (the-custodian a870092) rejected: NK48-R5-F1/F2 resolved, F3 (consumer config inverse) open via T34 / RAPPS-WP-0016-T05. After F3 + T12 integration: re-freeze, coordinator r7, then owner re-check." state_hub_task_id: "200bf500-5084-5ae7-be26-3033fe1f296e" ```