From c7ad8a5bf08b8aaf1d37b07aa922c86b2ffc5eb0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 15 Oct 2026 00:16:15 +0200 Subject: [PATCH] Prepare the openbao-admin signInDiagnosis flag rollout (NK-WP-0051-T02). One-line, byte-preserving edit of sso/keycape-config with inspect, server dry-run, attended apply and an exact rollback. No values are emitted. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 1041694@bnt-lap001 Assistant-Session: 7af0bb68-ac4a-4ce3-8639-24ddd84c0b52 --- .custodian-brief.md | 36 ++-- .../openbao-signin-diagnosis-rollout.py | 176 ++++++++++++++++++ .../test_openbao_signin_diagnosis_rollout.py | 156 ++++++++++++++++ workplans/NK-WP-0051-sign-in-diagnosis.md | 5 + 4 files changed, 355 insertions(+), 18 deletions(-) create mode 100644 sso-mfa/k8s/keycape/openbao-signin-diagnosis-rollout.py create mode 100644 sso-mfa/k8s/keycape/test_openbao_signin_diagnosis_rollout.py diff --git a/.custodian-brief.md b/.custodian-brief.md index 143b0c2..fc23d24 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,11 +2,18 @@ # Custodian Brief — net-kingdom **Domain:** infotech -**Last synced:** 2026-10-14 21:54 UTC +**Last synced:** 2026-10-14 22:11 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams +### Implement deterministic posture and evidence feedback +Progress: 4/5 done | workplan_id: `9d7b04f9-3803-5613-b7a5-8bd606c77f5a` + +**Open tasks:** +- ! Obtain audit-core freshness adoption `7d2029d2` + *(wait: audit-core tenancy.yaml must publish structured authoritative owner, observation/expiry and replacement-action metadata for WH-ENG-20260822-AUDIT-E2-03. Re-evaluate with explicit current as-of; later recovery prose does not renew E2.)* + ### Publish the NetKingdom emission-cadence security profile Progress: 4/5 done | workplan_id: `04685f94-1991-5e62-80d2-5669913e99fc` @@ -25,12 +32,13 @@ Progress: 3/6 done | workplan_id: `965ad365-6b81-50a1-a2a3-2d0c1fcce0b4` - ! T06 — Resolve the `DataClassification` mismatch `bf8b3e1d` *(wait: Requires InfoTechCanon ruling on public disclosure classification versus synthetic provenance, then ops-warden authoritative dataclass_floor mapping and tests; no public-to-synthetic alias.)* -### Implement deterministic posture and evidence feedback -Progress: 4/5 done | workplan_id: `9d7b04f9-3803-5613-b7a5-8bd606c77f5a` +### Remove bootstrap secret output and verify separated recovery custody +Progress: 1/3 done | workplan_id: `17d315f0-829e-57c7-85cc-3a7914152804` **Open tasks:** -- ! Obtain audit-core freshness adoption `7d2029d2` - *(wait: audit-core tenancy.yaml must publish structured authoritative owner, observation/expiry and replacement-action metadata for WH-ENG-20260822-AUDIT-E2-03. Re-evaluate with explicit current as-of; later recovery prose does not renew E2.)* +- ! Verify owner-approved recovery and current native callers `d68cd4d3` + *(wait: Source caller reconciliation and exact owner-scoped isolated recovery ceremony are required; no live bootstrap or root retrieval is authorized by this source receipt.)* +- ► Reconcile other legacy custody callers and root-output boundaries `479d6970` ### Enterprise Federation & SAML — Expanded-Mode Keycloak Identity Broker Progress: 1/9 done | workplan_id: `1075448f-d533-5f9e-94b7-c3adfe151a07` @@ -66,7 +74,7 @@ Progress: 2/6 done | workplan_id: `c1771c07-4466-5aa2-ad43-df63a471946b` **Open tasks:** - ! KeyCape shows the principal before the callback `a78219bb` - *(wait: Implemented in key-cape af3b1b0 (KEY-WP-0041). Waits for KEY-WP-0041-T02: image build, live signInDiagnosis flag on the OpenBao client, digest.)* + *(wait: Implemented in key-cape af3b1b0 (KEY-WP-0041). Image cdec9dd7 live 2026-10-15. Waits for KEY-WP-0041-T02: live signInDiagnosis flag on the OpenBao client in sso/keycape-config.)* - ! Account site shows both sessions `bdc8b414` *(wait: Implemented in user-engine 80f5870 (USER-WP-0039). Waits for USER-WP-0039-T02: deploy and a mismatched-session check.)* - ! Enroll the negative-login factor `afb5816f` @@ -74,13 +82,6 @@ Progress: 2/6 done | workplan_id: `c1771c07-4466-5aa2-ad43-df63a471946b` - ! Hand the OpenBao proof back to its owners `86fb4a6b` *(wait: Implemented in ops-mason fca5591 (MASON-WP-0012) and railiance-platform 0f0282a (RPF-WP-0057). Waits for CCR-2026-0045 apply, overlay deploy, and the MASON-WP-0012-T04 live proof.)* -### Define an execution-attribution receipt for Railiance runs -Progress: 1/2 done | workplan_id: `e2533f3a-aa43-59b3-bff3-8e64b6149487` - -**Open tasks:** -- ! Agree the evidence holder and schema with audit-core and Railiance `963120c1` - *(wait: Audit-core accepted evidence custody and published field/admission requirements in AUDIT-WP-0014-T01. Still needs Railiance owner confirmation of exact emitter and wire binding before canonical schema/validator implementation, followed by actual emission/readback and attribution/clock/unknown-case acceptance.)* - ### Register Playwright human and agent consumers through governed identity delivery Progress: 1/3 done | workplan_id: `06ba98d5-609e-5aaa-9bf7-4c4f4460e377` @@ -89,13 +90,12 @@ Progress: 1/3 done | workplan_id: `06ba98d5-609e-5aaa-9bf7-4c4f4460e377` *(wait: Await owner-scoped registry/custody activation, native cluster authentication and individual transport, then real human and agent acceptance under CUST-WP-0080-T05.)* - ► Deliver custody integration and guarded registration activation `a9dadd07` -### Remove bootstrap secret output and verify separated recovery custody -Progress: 1/3 done | workplan_id: `17d315f0-829e-57c7-85cc-3a7914152804` +### Define an execution-attribution receipt for Railiance runs +Progress: 1/2 done | workplan_id: `e2533f3a-aa43-59b3-bff3-8e64b6149487` **Open tasks:** -- ! Verify owner-approved recovery and current native callers `d68cd4d3` - *(wait: Source caller reconciliation and exact owner-scoped isolated recovery ceremony are required; no live bootstrap or root retrieval is authorized by this source receipt.)* -- ► Reconcile other legacy custody callers and root-output boundaries `479d6970` +- ! Agree the evidence holder and schema with audit-core and Railiance `963120c1` + *(wait: Audit-core accepted evidence custody and published field/admission requirements in AUDIT-WP-0014-T01. Still needs Railiance owner confirmation of exact emitter and wire binding before canonical schema/validator implementation, followed by actual emission/readback and attribution/clock/unknown-case acceptance.)* ### Cut over NetKingdom identity to railiance01 and retire CoulombCore Progress: 7/8 done | workplan_id: `d76ddccc-00c8-548a-b141-2cd660fa38da` diff --git a/sso-mfa/k8s/keycape/openbao-signin-diagnosis-rollout.py b/sso-mfa/k8s/keycape/openbao-signin-diagnosis-rollout.py new file mode 100644 index 0000000..2fa9a44 --- /dev/null +++ b/sso-mfa/k8s/keycape/openbao-signin-diagnosis-rollout.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Turn on KeyCape's sign-in diagnosis page for the openbao-admin client. + +NK-WP-0051-T02 / KEY-WP-0041-T02. Adds one line, `signInDiagnosis: true`, to +the existing openbao-admin registration in sso/keycape-config. The field +contract is docs/sign-in-diagnosis.md; the image that honours the flag is +key-cape sha256:cdec9dd7 (source 8c6c071), live since 2026-10-15. + +Modes default to non-mutating. `inspect` reads and plans; `dry-run` sends a +server-side dry-run patch; `apply` writes and is reserved for the attended +operator with `--attended` and the observed uid/resourceVersion. `--operation +remove` is the rollback: it removes only that one line. Either way the issuer +reads the change only after `kubectl -n sso rollout restart deploy/keycape`. + +Exact-match edit: flag absent -> add; already true -> noop; any other value, +a missing or duplicated openbao-admin client -> fail closed. The signing key, +every other client and every other config byte are preserved. No Secret value, +parser message or Kubernetes error text leaves the process. +""" +from __future__ import annotations + +import argparse +import base64 +import copy +import importlib.util +import json +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent + + +def module(name, filename): + spec = importlib.util.spec_from_file_location(name, ROOT / filename) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +# Reuse the owner lane's contained kubectl, refusal type and cluster pin. +lane = module('portal_rollout', 'portal-client-rollout.py') +pin = lane.pin +Refused = lane.Refused +require = lane.require + +CLIENT_ID = 'openbao-admin' +FLAG = 'signInDiagnosis' +LINE = FLAG + ': true' + + +def _inspect(secret): + raw, config, _ = pin.issuer_document(secret) + require(config['authelia'].get('issuer') == pin.VERIFIED_UPSTREAM_ISSUER, + 'verified_issuer_pin_required') + clients = config.get('clients') + require(isinstance(clients, list) and clients, 'client_sequence_required') + require(all(isinstance(c, dict) and 'clientId' in c for c in clients), 'client_shape_unexpected') + ids = [c['clientId'] for c in clients] + require(len(ids) == len(set(ids)), 'duplicate_client_id') + require(CLIENT_ID in ids, 'openbao_client_missing') + position = ids.index(CLIENT_ID) + value = clients[position].get(FLAG, None) + require(value in (None, True), 'existing_flag_differs_requires_reconciliation') + root = yaml.compose(raw) + sequence = next(v for k, v in root.value if k.value == 'clients') + require(isinstance(sequence, yaml.nodes.SequenceNode) and not sequence.flow_style, + 'block_client_sequence_required') + item = sequence.value[position] + require(isinstance(item, yaml.nodes.MappingNode) and not item.flow_style, + 'block_client_mapping_required') + return raw, config, position, item + + +def _line_end(raw, index): + end = raw.find('\n', index) + return len(raw) if end < 0 else end + 1 + + +def enable(secret): + """Return (base64 config, changed) with only the flag line added.""" + raw, config, position, item = _inspect(secret) + if config['clients'][position].get(FLAG) is True: + return secret['data']['config.yaml'], False + key, value = item.value[0] + require(isinstance(value, yaml.nodes.ScalarNode) + and key.start_mark.line == value.end_mark.line, 'unexpected_item_layout') + index = _line_end(raw, value.end_mark.index) + prefix = '' if raw[index - 1:index] == '\n' else '\n' + updated = raw[:index] + prefix + ' ' * key.start_mark.column + LINE + '\n' + raw[index:] + expected = copy.deepcopy(config) + expected['clients'][position][FLAG] = True + require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed') + return base64.b64encode(updated.encode()).decode(), True + + +def disable(secret): + """Rollback: return (base64 config, changed) with only the flag line removed.""" + raw, config, position, item = _inspect(secret) + if FLAG not in config['clients'][position]: + return secret['data']['config.yaml'], False + key = next(k for k, _ in item.value if k.value == FLAG) + require(key is not item.value[0][0], 'unexpected_item_layout') + start = raw.rfind('\n', 0, key.start_mark.index) + 1 + end = _line_end(raw, key.start_mark.index) + require(raw[start:end].strip() == LINE, 'unexpected_item_layout') + updated = raw[:start] + raw[end:] + expected = copy.deepcopy(config) + del expected['clients'][position][FLAG] + require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed') + return base64.b64encode(updated.encode()).decode(), True + + +def run(args, kubectl=None): + kubectl = kubectl or lane.kubectl + cluster = kubectl(['get', 'namespace', 'kube-system', '-o', 'json']) + require(cluster['metadata']['uid'] == lane.CLUSTER_UID, 'cluster_identity_changed') + before = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json']) + require(not before['metadata'].get('ownerReferences'), 'controller_owned_secret') + metadata = pin.safe_metadata(before) + encoded, changed = (enable if args.operation == 'add' else disable)(before) + receipt = {'schema': 'netkingdom.keycape-openbao-signin-diagnosis.v1', + 'target': 'sso/keycape-config', 'before': metadata, 'operation': args.operation, + 'client_id': CLIENT_ID, 'flag': FLAG, 'change_needed': changed, 'mode': args.mode, + 'values_emitted': False, 'changed': False} + if args.mode == 'inspect': + return receipt + if args.mode == 'apply': + require(args.attended, 'attended_operator_required') + require(metadata == {'uid': args.expected_uid, 'resource_version': args.expected_resource_version}, + 'observed_revision_changed') + if changed: + patch = [ + {'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']}, + {'op': 'replace', 'path': '/data/config.yaml', 'value': encoded}, + ] + command = ['-n', 'sso', 'patch', 'secret', 'keycape-config', '--type=json', + '--patch-file=/dev/stdin', '-o', 'json'] + if args.mode == 'dry-run': + command += ['--dry-run=server'] + result = kubectl(command, patch) + require(result['data'] == dict(before['data'], **{'config.yaml': encoded}), 'patch_response_mismatch') + if args.mode == 'apply': + after = kubectl(['-n', 'sso', 'get', 'secret', 'keycape-config', '-o', 'json']) + require(after['metadata']['uid'] == metadata['uid'] + and after['data'] == dict(before['data'], **{'config.yaml': encoded}), + 'readback_mismatch_stop_without_stale_replay') + receipt.update(changed=changed, after=pin.safe_metadata(after), restart_required=changed) + receipt.update(unrelated_config_bytes_preserved=True, other_secret_data_unchanged=True) + return receipt + + +def main(argv=None, kubectl=None): + parser = argparse.ArgumentParser(description=__doc__.split('\n', 1)[0]) + parser.add_argument('mode', nargs='?', default='inspect', choices=['inspect', 'dry-run', 'apply']) + parser.add_argument('--operation', default='add', choices=['add', 'remove']) + parser.add_argument('--expected-uid') + parser.add_argument('--expected-resource-version') + parser.add_argument('--attended', action='store_true', help='required for apply') + args = parser.parse_args(argv) + try: + print(json.dumps(run(args, kubectl), sort_keys=True)) + return 0 + except Refused as error: + print(json.dumps({'status': 'refused', 'reason': str(error)})) + except pin.IssuerPinError as error: + print(json.dumps({'status': 'refused', 'reason': str(error)})) + except Exception: + # Parser and Kubernetes errors can contain full Secret material. + print(json.dumps({'status': 'failed', 'reason': 'contained_operation_failed'})) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/sso-mfa/k8s/keycape/test_openbao_signin_diagnosis_rollout.py b/sso-mfa/k8s/keycape/test_openbao_signin_diagnosis_rollout.py new file mode 100644 index 0000000..9877c40 --- /dev/null +++ b/sso-mfa/k8s/keycape/test_openbao_signin_diagnosis_rollout.py @@ -0,0 +1,156 @@ +import base64 +import contextlib +import importlib.util +import io +import json +from pathlib import Path +import unittest + +spec = importlib.util.spec_from_file_location( + 'openbao_signin_diagnosis_rollout', Path(__file__).with_name('openbao-signin-diagnosis-rollout.py')) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +UID = '00000000-0000-4000-8000-000000000001' +CLUSTER = {'metadata': {'uid': module.lane.CLUSTER_UID}} +OPENBAO = (' - clientId: openbao-admin\n' + ' displayName: Railiance OpenBao Admin\n' + ' redirectUris:\n' + ' - http://localhost:8250/oidc/callback\n' + ' clientType: public\n') +OTHER = (' - clientId: user-engine-portal\n' + ' clientType: public\n') + + +def fixture(clients=OTHER + OPENBAO, tail='tokenLifetime: 15m\n'): + # Synthetic marker values only; no live Secret is loaded by this suite. + raw = ('# preserve this comment\nissuer: https://kc.coulomb.social\n' + 'authelia:\n issuer: https://auth.coulomb.social\n clientSecret: synthetic\n' + 'clients:\n' + clients + tail) + secret = {'metadata': {'uid': UID, 'resourceVersion': '7'}, + 'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), + 'signing-key.pem': base64.b64encode(b'synthetic').decode()}} + return secret, raw + + +def decode(encoded): + return base64.b64decode(encoded).decode() + + +class EditTests(unittest.TestCase): + def test_enable_adds_exactly_one_line_after_client_id(self): + secret, raw = fixture() + encoded, changed = module.enable(secret) + self.assertTrue(changed) + expected = raw.replace(' - clientId: openbao-admin\n', + ' - clientId: openbao-admin\n signInDiagnosis: true\n') + self.assertEqual(decode(encoded), expected) + + def test_enable_is_noop_when_already_true(self): + secret, _ = fixture(OPENBAO.replace('clientType: public', 'clientType: public\n signInDiagnosis: true')) + encoded, changed = module.enable(secret) + self.assertFalse(changed) + self.assertEqual(encoded, secret['data']['config.yaml']) + + def test_other_flag_value_fails_closed(self): + secret, _ = fixture(OPENBAO + ' signInDiagnosis: false\n') + for operation in (module.enable, module.disable): + with self.assertRaises(module.Refused): + operation(secret) + + def test_missing_or_duplicate_client_fails_closed(self): + for clients in (OTHER, OPENBAO + OPENBAO): + with self.assertRaises(module.Refused): + module.enable(fixture(clients)[0]) + + def test_unverified_upstream_issuer_refused(self): + secret, raw = fixture() + raw = raw.replace('https://auth.coulomb.social', 'https://evil.test') + secret['data']['config.yaml'] = base64.b64encode(raw.encode()).decode() + with self.assertRaises(module.Refused): + module.enable(secret) + + def test_rollback_restores_exact_bytes(self): + secret, raw = fixture() + enabled, _ = module.enable(secret) + secret['data']['config.yaml'] = enabled + restored, changed = module.disable(secret) + self.assertTrue(changed) + self.assertEqual(decode(restored), raw) + + def test_rollback_absent_is_noop(self): + secret, _ = fixture() + encoded, changed = module.disable(secret) + self.assertFalse(changed) + self.assertEqual(encoded, secret['data']['config.yaml']) + + +class RunTests(unittest.TestCase): + """Drive run() against a fake kubectl; nothing reaches a cluster.""" + + def setUp(self): + self.secret, _ = fixture() + self.calls = [] + + def fake(self, arguments, payload=None): + self.calls.append((arguments, payload)) + if arguments[:2] == ['get', 'namespace']: + return CLUSTER + if 'patch' in arguments: + data = dict(self.secret['data'], **{'config.yaml': payload[-1]['value']}) + if '--dry-run=server' not in arguments: + self.secret = {'metadata': {'uid': UID, 'resourceVersion': '8'}, 'data': data} + return {'data': data} + return self.secret + + def main(self, *argv): + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = module.main(list(argv), kubectl=self.fake) + return code, out.getvalue() + + def patches(self): + return [c for c in self.calls if 'patch' in c[0]] + + def test_default_is_inspect_and_never_patches(self): + code, out = self.main() + self.assertEqual(code, 0) + receipt = json.loads(out) + self.assertTrue(receipt['change_needed']) + self.assertEqual(receipt['before'], {'uid': UID, 'resource_version': '7'}) + self.assertEqual(self.patches(), []) + self.assertNotIn('synthetic', out) + + def test_dry_run_requires_observed_revision_and_is_server_side(self): + code, out = self.main('dry-run') + self.assertEqual((code, json.loads(out)['reason']), (1, 'observed_revision_changed')) + code, out = self.main('dry-run', '--expected-uid', UID, '--expected-resource-version', '7') + self.assertEqual(code, 0) + self.assertIn('--dry-run=server', self.patches()[-1][0]) + self.assertNotIn('synthetic', out) + + def test_apply_requires_attended_and_reports_restart(self): + code, out = self.main('apply', '--expected-uid', UID, '--expected-resource-version', '7') + self.assertEqual((code, json.loads(out)['reason']), (1, 'attended_operator_required')) + self.assertEqual(self.patches(), []) + code, out = self.main('apply', '--attended', '--expected-uid', UID, '--expected-resource-version', '7') + receipt = json.loads(out) + self.assertEqual(code, 0) + self.assertTrue(receipt['changed'] and receipt['restart_required']) + self.assertEqual(receipt['after']['resource_version'], '8') + self.assertEqual(self.patches()[-1][1][:2], [ + {'op': 'test', 'path': '/metadata/uid', 'value': UID}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': '7'}]) + self.assertNotIn('synthetic', out) + + def test_failures_never_print_secret_material(self): + def broken(arguments, payload=None): + raise RuntimeError('synthetic value leaked in error text') + out = io.StringIO() + with contextlib.redirect_stdout(out): + self.assertEqual(module.main([], kubectl=broken), 1) + self.assertNotIn('synthetic', out.getvalue()) + + +if __name__ == '__main__': + unittest.main() diff --git a/workplans/NK-WP-0051-sign-in-diagnosis.md b/workplans/NK-WP-0051-sign-in-diagnosis.md index c3d4066..9bb4c80 100644 --- a/workplans/NK-WP-0051-sign-in-diagnosis.md +++ b/workplans/NK-WP-0051-sign-in-diagnosis.md @@ -113,6 +113,11 @@ current flow. The page has one action, continue, and a line that says how to sign in as someone else. A groups refusal from OpenBao then follows a screen that already named the uid. +The live flag is set with +`sso-mfa/k8s/keycape/openbao-signin-diagnosis-rollout.py` (inspect, server +dry-run, attended apply on the observed uid/resourceVersion; `--operation +remove` is the rollback), followed by an issuer restart. + Acceptance is a deployed key-cape image. The log line contains no token, code, email, or one-time password. This repo records the image digest in the T01 document.