#!/usr/bin/env python3 """Restore the approved public portal client without exporting Secret values.""" from __future__ import annotations import argparse import base64 import copy import importlib.util import json from pathlib import Path import subprocess import yaml ROOT = Path('/home/worsch/net-kingdom/sso-mfa/k8s/keycape') 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 pin = module('issuer_pin', 'openbao-client-config.py') portal = module('portal_registration', 'register-user-engine-portal.py') CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' class Refused(Exception): """Only fixed reason codes may leave the operation.""" def require(condition, reason): if not condition: raise Refused(reason) def replacement(secret): raw, config, _ = pin.issuer_document(secret) clients = config.get('clients') require(isinstance(clients, list) and clients, 'client_sequence_required') ids = [client['clientId'] for client in clients] require(len(ids) == len(set(ids)), 'duplicate_client_id') if portal.CLIENT_ID in ids: require(clients[ids.index(portal.CLIENT_ID)] == portal.CLIENT, 'existing_registration_differs_requires_reconciliation') return secret['data']['config.yaml'], False 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') index = node.end_mark.index line_start = raw.rfind('\n', 0, index) + 1 if not raw[line_start:index].strip(): index = line_start indent = ' ' * node.start_mark.column addition = yaml.safe_dump([portal.CLIENT], sort_keys=False) addition = ''.join(indent + line if line.strip() else line for line in addition.splitlines(True)) if index and raw[index - 1] != '\n': addition = '\n' + addition updated = raw[:index] + addition + raw[index:] expected = copy.deepcopy(config) expected['clients'].append(portal.CLIENT) require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed') return base64.b64encode(updated.encode()).decode(), True def kubectl(arguments, payload=None): args = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope', '--server', 'https://127.0.0.1:16444', '--request-timeout=20s', *arguments] result = subprocess.run(args, input=None if payload is None else json.dumps(payload), text=True, capture_output=True, timeout=30) require(result.returncode == 0, 'kubernetes_operation_failed') return json.loads(result.stdout) def run(args): cluster = kubectl(['get', 'namespace', 'kube-system', '-o', 'json']) require(cluster['metadata']['uid'] == 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(before) receipt = {'target': 'sso/keycape-config', 'before': metadata, 'client': portal.CLIENT, 'change_needed': changed, 'mode': args.mode, 'values_emitted': False, 'changed': False} if args.mode == 'inspect': return receipt 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)) receipt.update(unrelated_config_bytes_preserved=True, other_secret_data_unchanged=True) return receipt def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('mode', choices=['inspect', 'dry-run', 'apply']) parser.add_argument('--expected-uid') parser.add_argument('--expected-resource-version') args = parser.parse_args() try: print(json.dumps(run(args), sort_keys=True)) return 0 except Refused 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())