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 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 132360@bnt-lap001 Assistant-Session: 769b1afd-4330-4c86-96b2-e510e26db42b
222 lines
10 KiB
Python
222 lines
10 KiB
Python
#!/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())
|