From a1e7302930debd34bb659eae9b93e65869d9c418 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 6 Oct 2026 13:35:12 +0200 Subject: [PATCH] Prepare the secrets-engine-openbao KeyCape client registration as source only Owner-specific exact-match merge and rollback for sso/keycape-config, the one proposed env secretKeyRef, synthetic tests and NK-WP-0046 for KEY-WP-0037-T03. Nothing is applied; the live rollout waits on decision caafe4be. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 132360@bnt-lap001 Assistant-Session: 769b1afd-4330-4c86-96b2-e510e26db42b --- sso-mfa/k8s/keycape/README.md | 23 ++ .../secrets-engine-openbao-client-rollout.py | 222 ++++++++++++++++++ ...-engine-openbao-client.proposed.patch.yaml | 17 ++ ...t_secrets_engine_openbao_client_rollout.py | 211 +++++++++++++++++ ...rets-engine-openbao-client-registration.md | 76 ++++++ 5 files changed, 549 insertions(+) create mode 100644 sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py create mode 100644 sso-mfa/k8s/keycape/secrets-engine-openbao-client.proposed.patch.yaml create mode 100644 sso-mfa/k8s/keycape/test_secrets_engine_openbao_client_rollout.py create mode 100644 workplans/NK-WP-0046-secrets-engine-openbao-client-registration.md diff --git a/sso-mfa/k8s/keycape/README.md b/sso-mfa/k8s/keycape/README.md index b3b711b..d9a9795 100644 --- a/sso-mfa/k8s/keycape/README.md +++ b/sso-mfa/k8s/keycape/README.md @@ -218,6 +218,29 @@ The local callback page exchanges the authorization code and displays only non-secret claims. KeyCape presents a browser OTP challenge between Authelia password login and the final OIDC redirect whenever privacyIDEA requires MFA. +### Prepared, not live: `secrets-engine-openbao` service client (NK-WP-0046) + +Source only for KeyCape KEY-WP-0037-T03; nothing here is registered or deployed. +The live run needs the founder's go (decision `caafe4be-4f68-47b4-9567-316d47b94992`) +and the platform-owned Secret `keycape-secrets-engine-openbao-client` +(key `client-secret`), which does not exist yet. + +- `secrets-engine-openbao-client-rollout.py` — exact-match merge into + `sso/keycape-config`: absent adds only this client, identical is a noop, + duplicates/drift/shared `secretRef` refuse. `--operation remove` is the + rollback and removes only this exact registration. Default mode is + `inspect`; `apply` refuses without `--live-go-decision caafe4be-…` and, + for `add`, until the Deployment already carries the env binding. +- `secrets-engine-openbao-client.proposed.patch.yaml` — the single env + `secretKeyRef` for `KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET`. +- `test_secrets_engine_openbao_client_rollout.py` — synthetic fixtures only. + +Order under the go: Secret materialised → server dry-run of both → env patch +(pod restarts with an unused env) → config `apply` → restart → native issuance +and denial checks (KEY-WP-0037-T04). Rollback reverses it: `--operation remove`, +restart, then drop the env binding. The image pin for that rollout is not yet +chosen; see NK-WP-0046. + ## Secrets managed | Secret name | Keys | Purpose | diff --git a/sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py b/sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py new file mode 100644 index 0000000..2e9ea5c --- /dev/null +++ b/sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Owner-specific KeyCape registration of the secrets-engine-openbao service client. + +KEY-WP-0037-T03 / NK-WP-0046. Source preparation only: the live operation +needs the founder's separate go (State Hub decision +caafe4be-4f68-47b4-9567-316d47b94992) and the platform-materialised Secret +keycape-secrets-engine-openbao-client (key client-secret). This helper merges +only the client registration into sso/keycape-config; the single Deployment +env binding is the separate proposed patch +secrets-engine-openbao-client.proposed.patch.yaml. + +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 under the live go. `--operation remove` is the rollback: it removes +only this exact registration and nothing else. + +Exact-match merge: absent -> add only this client; identical -> noop; +duplicates, drift or a shared secretRef -> fail closed. The signing key, +unrelated clients, policy 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 + +LIVE_GO_DECISION = 'caafe4be-4f68-47b4-9567-316d47b94992' +CLIENT_ID = 'secrets-engine-openbao' +ENV_NAME = 'KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET' +SECRET_NAME = 'keycape-secrets-engine-openbao-client' +SECRET_KEY = 'client-secret' +# Verbatim from key-cape config/service-clients.example.yaml (reviewed at +# KeyCape e22029e). No `audience` key: the audience defaults to the clientId. +CLIENT = { + 'clientId': CLIENT_ID, + 'displayName': 'secrets-engine OpenBao login', + 'allowedScopes': ['openbao:login'], + 'grantTypes': ['client_credentials'], + 'clientType': 'confidential', + 'secretRef': 'env:' + ENV_NAME, + 'serviceSubject': 'service:secrets-engine', + 'tenant': 'tenant:platform', + 'roles': ['secrets-engine'], + 'tokenLifetime': '15m', +} +ENV_BINDING = {'name': ENV_NAME, 'valueFrom': { + 'secretKeyRef': {'name': SECRET_NAME, 'key': SECRET_KEY}}} + + +def _client_node(raw): + root = yaml.compose(raw) + node = next(value for key, value in root.value if key.value == 'clients') + require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style, + 'block_client_sequence_required') + return node + + +def _line_start(raw, index): + start = raw.rfind('\n', 0, index) + 1 + return start if not raw[start:index].strip() else index + + +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') + others = [c for c in clients if c['clientId'] != CLIENT_ID] + require(all(c.get('secretRef') != CLIENT['secretRef'] for c in others), 'secret_ref_already_bound') + return raw, config, ids + + +def replacement(secret): + """Return (base64 config, changed) with only this client added.""" + raw, config, ids = _inspect(secret) + if CLIENT_ID in ids: + require(config['clients'][ids.index(CLIENT_ID)] == CLIENT, + 'existing_registration_differs_requires_reconciliation') + return secret['data']['config.yaml'], False + node = _client_node(raw) + index = _line_start(raw, node.end_mark.index) + indent = ' ' * node.start_mark.column + fragment = yaml.safe_dump([CLIENT], sort_keys=False, width=120) + fragment = ''.join(indent + line if line.strip() else line for line in fragment.splitlines(True)) + if index and raw[index - 1] != '\n': + fragment = '\n' + fragment + updated = raw[:index] + fragment + raw[index:] + expected = copy.deepcopy(config) + expected['clients'].append(CLIENT) + require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed') + return base64.b64encode(updated.encode()).decode(), True + + +def removal(secret): + """Rollback: return (base64 config, changed) with only this exact client removed.""" + raw, config, ids = _inspect(secret) + if CLIENT_ID not in ids: + return secret['data']['config.yaml'], False + position = ids.index(CLIENT_ID) + require(config['clients'][position] == CLIENT, 'existing_registration_differs_requires_reconciliation') + require(len(ids) > 1, 'removal_would_empty_client_sequence') + node = _client_node(raw) + item = node.value[position] + start = raw.rfind('\n', 0, item.start_mark.index) + 1 + require(raw[start:item.start_mark.index].strip() == '-', 'unexpected_item_layout') + if position + 1 < len(node.value): + following = node.value[position + 1].start_mark.index + end = raw.rfind('\n', 0, following) + 1 + else: + end = _line_start(raw, node.end_mark.index) + updated = raw[:start] + raw[end:] + expected = copy.deepcopy(config) + del expected['clients'][position] + require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed') + return base64.b64encode(updated.encode()).decode(), True + + +def deployment_binding(deployment): + """Report whether the live Deployment already carries exactly the one binding.""" + containers = deployment['spec']['template']['spec']['containers'] + container = next(c for c in containers if c['name'] == 'keycape') + matches = [e for e in container.get('env', []) if e.get('name') == ENV_NAME] + require(len(matches) <= 1 and all(e == ENV_BINDING for e in matches), 'env_binding_drift') + return bool(matches) + + +def run(args): + kubectl = 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 = (replacement if args.operation == 'add' else removal)(before) + deployment = kubectl(['-n', 'sso', 'get', 'deployment', 'keycape', '-o', 'json']) + bound = deployment_binding(deployment) + receipt = {'schema': 'netkingdom.keycape-secrets-engine-openbao-client.v1', + 'target': 'sso/keycape-config', 'before': metadata, 'operation': args.operation, + 'client_id': CLIENT_ID, 'change_needed': changed, 'mode': args.mode, + 'deployment_env_bound': bound, 'values_emitted': False, 'changed': False} + if args.mode == 'inspect': + return receipt + if args.mode == 'apply': + # Attended only: the founder's live go must be named and the plan revision pinned. + require(args.live_go_decision == LIVE_GO_DECISION, 'founder_live_go_required') + if args.operation == 'add': + # Startup resolves secretRef; a registration without its env binding + # would make the next pod start fail closed. + require(bound, 'deployment_env_binding_required_first') + 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): + 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('--live-go-decision', help='required for apply: ' + LIVE_GO_DECISION) + args = parser.parse_args(argv) + try: + print(json.dumps(run(args), 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/secrets-engine-openbao-client.proposed.patch.yaml b/sso-mfa/k8s/keycape/secrets-engine-openbao-client.proposed.patch.yaml new file mode 100644 index 0000000..54696cd --- /dev/null +++ b/sso-mfa/k8s/keycape/secrets-engine-openbao-client.proposed.patch.yaml @@ -0,0 +1,17 @@ +# PROPOSED, NOT APPLIED. KEY-WP-0037-T03 / NK-WP-0046. +# Adds exactly one env binding to sso/keycape; values never appear here. +# Prerequisites: platform has materialised Secret keycape-secrets-engine-openbao-client +# (key client-secret) and the founder's live go (decision caafe4be) is resolved. +# Apply as a strategic-merge patch (env merges by name), server dry-run and diff +# first; then fold into deployment.yaml in the same change that records the go. +spec: + template: + spec: + containers: + - name: keycape + env: + - name: KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: keycape-secrets-engine-openbao-client + key: client-secret diff --git a/sso-mfa/k8s/keycape/test_secrets_engine_openbao_client_rollout.py b/sso-mfa/k8s/keycape/test_secrets_engine_openbao_client_rollout.py new file mode 100644 index 0000000..a47d01b --- /dev/null +++ b/sso-mfa/k8s/keycape/test_secrets_engine_openbao_client_rollout.py @@ -0,0 +1,211 @@ +import base64 +import contextlib +import copy +import importlib.util +import io +import json +from pathlib import Path +import unittest + +import yaml + +spec = importlib.util.spec_from_file_location( + 'se_openbao_rollout', Path(__file__).with_name('secrets-engine-openbao-client-rollout.py')) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +UID = '00000000-0000-4000-8000-000000000001' +KEYCAPE_EXAMPLE = Path('/home/worsch/key-cape/config/service-clients.example.yaml') +EXISTING = [ + {'clientId': 'openbao-admin', 'clientType': 'public', + 'redirectUris': ['http://localhost:8250/oidc/callback']}, + {'clientId': 'secrets-engine-approval', 'audience': 'approval-engine', + 'grantTypes': ['client_credentials'], 'clientType': 'confidential', + 'secretRef': 'env:KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET', + 'serviceSubject': 'service:secrets-engine', 'tenant': 'tenant:platform', + 'roles': ['secrets-engine'], 'tokenLifetime': '15m'}, +] + + +def fixture(clients=None, tail='tokenLifetime: 15m\npolicyPath: /var/lib/keycape-policy/policy.yaml\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-marker\n' + 'clients:\n' + yaml.safe_dump(json.loads(json.dumps(EXISTING if clients is None else clients)), + sort_keys=False) + tail) + secret = {'metadata': {'uid': UID, 'resourceVersion': '7'}, + 'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), + 'signing-key.pem': 'c3ludGhldGljLXNpZ25pbmcta2V5'}} + return secret, raw + + +def decode(encoded): + return base64.b64decode(encoded).decode() + + +class RegistrationTests(unittest.TestCase): + def test_add_appends_only_this_client_and_preserves_bytes(self): + secret, raw = fixture() + before = copy.deepcopy(secret) + encoded, changed = module.replacement(secret) + updated = decode(encoded) + self.assertTrue(changed) + head = raw[:raw.index('tokenLifetime:')] + self.assertTrue(updated.startswith(head)) + self.assertTrue(updated.endswith(raw[len(head):])) + expected = yaml.safe_load(raw) + expected['clients'].append(module.CLIENT) + self.assertEqual(yaml.safe_load(updated), expected) + self.assertEqual(secret, before) # input, including the signing key, untouched + + def test_add_at_end_of_document(self): + secret, raw = fixture(tail='') + updated = decode(module.replacement(secret)[0]) + self.assertTrue(updated.startswith(raw)) + self.assertEqual(yaml.safe_load(updated)['clients'][-1], module.CLIENT) + + def test_identical_registration_is_noop(self): + secret, _ = fixture() + secret['data']['config.yaml'] = module.replacement(secret)[0] + self.assertEqual(module.replacement(secret), (secret['data']['config.yaml'], False)) + + def test_drift_fails_closed(self): + for change in [dict(allowedScopes=['openbao:login', 'openbao:admin']), + dict(audience='openbao'), dict(tokenLifetime='1h'), + dict(roles=['secrets-engine', 'platform-admin']), + dict(tenant='tenant:other'), dict(clientType='public'), + dict(serviceSubject='service:other')]: + with self.subTest(change=change), self.assertRaises(module.Refused): + module.replacement(fixture(EXISTING + [dict(module.CLIENT, **change)])[0]) + + def test_duplicates_fail_closed(self): + for clients in [EXISTING + [module.CLIENT, module.CLIENT], + EXISTING + [EXISTING[0]]]: + with self.assertRaises(module.Refused): + module.replacement(fixture(clients)[0]) + with self.assertRaises(module.Refused): + module.removal(fixture(clients)[0]) + + def test_secret_ref_reused_by_another_client_fails_closed(self): + other = dict(EXISTING[1], clientId='other', secretRef=module.CLIENT['secretRef']) + with self.assertRaises(module.Refused): + module.replacement(fixture(EXISTING + [other])[0]) + + def test_unverified_upstream_issuer_refused(self): + secret, raw = fixture() + raw = raw.replace('https://auth.coulomb.social', 'https://auth.example.invalid') + secret['data']['config.yaml'] = base64.b64encode(raw.encode()).decode() + with self.assertRaises(module.Refused): + module.replacement(secret) + + def test_rollback_removes_only_this_client_and_restores_exact_bytes(self): + for tail in ['tokenLifetime: 15m\n', '']: + secret, raw = fixture(tail=tail) + secret['data']['config.yaml'] = module.replacement(secret)[0] + encoded, changed = module.removal(secret) + self.assertTrue(changed) + self.assertEqual(decode(encoded), raw) + + def test_rollback_of_middle_entry_preserves_neighbours(self): + secret, raw = fixture(EXISTING[:1] + [module.CLIENT] + EXISTING[1:]) + updated = decode(module.removal(secret)[0]) + self.assertEqual(updated, fixture()[1]) + + def test_rollback_absent_is_noop_and_drift_refused(self): + secret, _ = fixture() + self.assertEqual(module.removal(secret), (secret['data']['config.yaml'], False)) + with self.assertRaises(module.Refused): + module.removal(fixture(EXISTING + [dict(module.CLIENT, tokenLifetime='1h')])[0]) + + def test_registration_matches_keycape_source(self): + if not KEYCAPE_EXAMPLE.is_file(): + self.skipTest('key-cape checkout absent') + source = yaml.safe_load(KEYCAPE_EXAMPLE.read_text())['clients'] + self.assertEqual([c for c in source if c['clientId'] == module.CLIENT_ID], [module.CLIENT]) + + +class DeploymentBindingTests(unittest.TestCase): + def deployment(self, env): + return {'spec': {'template': {'spec': {'containers': [{'name': 'keycape', 'env': env}]}}}} + + def test_proposed_patch_carries_exactly_the_one_binding(self): + patch = yaml.safe_load(Path(__file__).with_name( + 'secrets-engine-openbao-client.proposed.patch.yaml').read_text()) + container = patch['spec']['template']['spec']['containers'][0] + self.assertEqual(container, {'name': 'keycape', 'env': [module.ENV_BINDING]}) + + def test_live_deployment_yaml_does_not_yet_bind(self): + docs = yaml.safe_load_all(Path(__file__).with_name('deployment.yaml').read_text()) + dep = next(d for d in docs if d and d.get('kind') == 'Deployment') + self.assertFalse(module.deployment_binding(dep)) + + def test_binding_detection_and_drift(self): + self.assertTrue(module.deployment_binding(self.deployment([module.ENV_BINDING]))) + wrong = {'name': module.ENV_NAME, 'value': 'synthetic'} + with self.assertRaises(module.Refused): + module.deployment_binding(self.deployment([wrong])) + + +class RunModeTests(unittest.TestCase): + """Drive run() against a fake kubectl; nothing reaches a cluster.""" + + def setUp(self): + self.secret, _ = fixture() + self.calls = [] + self.original = module.lane.kubectl + self.env = [] + + def fake(arguments, payload=None): + self.calls.append((arguments, payload)) + if arguments[:2] == ['get', 'namespace']: + return {'metadata': {'uid': module.lane.CLUSTER_UID}} + if 'deployment' in arguments: + return {'spec': {'template': {'spec': {'containers': [{'name': 'keycape', 'env': self.env}]}}}} + if 'patch' in arguments: + return {'data': dict(self.secret['data'], **{'config.yaml': payload[-1]['value']})} + return copy.deepcopy(self.secret) + module.lane.kubectl = fake + + def tearDown(self): + module.lane.kubectl = self.original + + def main(self, *argv): + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = module.main(list(argv)) + return code, json.loads(out.getvalue()) + + def test_default_is_inspect_and_never_patches(self): + code, receipt = self.main() + self.assertEqual(code, 0) + self.assertEqual(receipt['mode'], 'inspect') + self.assertTrue(receipt['change_needed']) + self.assertFalse(any('patch' in a for a, _ in self.calls)) + self.assertNotIn('synthetic', json.dumps(receipt)) + + def test_dry_run_is_server_side(self): + code, _ = self.main('dry-run', '--expected-uid', UID, '--expected-resource-version', '7') + self.assertEqual(code, 0) + patches = [a for a, _ in self.calls if 'patch' in a] + self.assertEqual(len(patches), 1) + self.assertIn('--dry-run=server', patches[0]) + + def test_apply_requires_live_go_and_env_binding(self): + base = ['apply', '--expected-uid', UID, '--expected-resource-version', '7'] + self.assertEqual(self.main(*base), (1, {'status': 'refused', 'reason': 'founder_live_go_required'})) + self.assertEqual(self.main(*base, '--live-go-decision', module.LIVE_GO_DECISION), + (1, {'status': 'refused', 'reason': 'deployment_env_binding_required_first'})) + self.assertFalse(any('patch' in a for a, _ in self.calls)) + + def test_stale_revision_refused_before_patch(self): + code, receipt = self.main('dry-run', '--expected-uid', UID, '--expected-resource-version', '6') + self.assertEqual(receipt, {'status': 'refused', 'reason': 'observed_revision_changed'}) + self.assertFalse(any('patch' in a for a, _ in self.calls)) + + def test_unexpected_errors_are_contained(self): + self.secret['data']['config.yaml'] = 'not base64 !!' + self.assertEqual(self.main(), (1, {'status': 'failed', 'reason': 'contained_operation_failed'})) + + +if __name__ == '__main__': + unittest.main() diff --git a/workplans/NK-WP-0046-secrets-engine-openbao-client-registration.md b/workplans/NK-WP-0046-secrets-engine-openbao-client-registration.md new file mode 100644 index 0000000..424a195 --- /dev/null +++ b/workplans/NK-WP-0046-secrets-engine-openbao-client-registration.md @@ -0,0 +1,76 @@ +--- +id: NK-WP-0046 +type: workplan +title: "Deployment-owned registration of the secrets-engine-openbao KeyCape client" +domain: infotech +repo: net-kingdom +status: active +flavor: implementation +owner: claude-code +topic_slug: netkingdom +created: "2026-10-06" +updated: "2026-10-06" +related: [KEY-WP-0037, RPF-WP-0035] +--- + +KeyCape KEY-WP-0037-T03 needs NetKingdom, which owns `sso/keycape` and its +config, to register the confidential service client `secrets-engine-openbao` +(subject service:secrets-engine, tenant tenant:platform, role secrets-engine, +scope openbao:login, client_credentials, 15m, secretRef +env:KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET, no resource-audience +override). Custody boundary 632a8893 is approved; this plan prepares source +only. No live change is authorized until decision +caafe4be-4f68-47b4-9567-316d47b94992 is resolved. + +## Prepare the owner-specific merge, env binding and tests + +```task +id: NK-WP-0046-T01 +status: done +priority: high +``` + +`sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py` (exact-match +add/noop/refuse, byte-preserving; `--operation remove` rollback; default +`inspect`, `apply` gated on the live-go decision id and the env binding), +`secrets-engine-openbao-client.proposed.patch.yaml` (one env secretKeyRef from +`keycape-secrets-engine-openbao-client`/`client-secret`) and +`test_secrets_engine_openbao_client_rollout.py` (synthetic fixtures). The +registration is asserted equal to KeyCape `config/service-clients.example.yaml` +at e22029e. No cluster, OpenBao or ESO was read or written. + +## Choose the reviewed image revision and digest + +```task +id: NK-WP-0046-T02 +status: todo +priority: high +``` + +Deployed pin in Git (`deployment.yaml`, since 019e8f2, 2026-09-28): +`forgejo.coulomb.social/coulomb/key-cape@sha256:6f79a2af1c695d39480173fad013facd84d21e7732860366af519302a2c496b8`, +built from KeyCape source 1164f65 per `history/2026-09-28-telemetry-admin-role.md`. +KeyCape main HEAD is e22029e. Decide whether the registration rides the +deployed image (the client needs no new issuer code) or a newer reviewed +digest; record the exact source revision and digest before the go. A newer +binary must be checked for startup refusals against the live config. + +## Live registration rollout under the founder's go + +```task +id: NK-WP-0046-T03 +status: wait +priority: high +needs_human: true +decision_id: caafe4be-4f68-47b4-9567-316d47b94992 +depends_on: + - NK-WP-0046-T02 +blocking_reason: "Requires the founder's separate live go (caafe4be), the platform-materialised Secret keycape-secrets-engine-openbao-client and the chosen image pin." +``` + +Attended operator only: server dry-run and diff of the env patch and the +config merge, check headroom, apply the env patch, `apply` the merge with the +observed uid/resourceVersion, restart, then hand to KEY-WP-0037-T04 for native +issuance, wrong-secret/excess-scope denial and existing human-login checks. +Rollback: `--operation remove`, restart, remove the env binding. Fold the +binding into `deployment.yaml` in the same change that records the go.