Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
136 lines
5.5 KiB
Python
136 lines
5.5 KiB
Python
#!/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())
|