net-kingdom/sso-mfa/k8s/keycape/keycape-client.py
tegwick 8790dd18ae
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
fix(identity): execute reviewed checker from sealed attempt snapshot
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a1468b-63fa-7710-85dc-8f2e16b3ebfa
2026-10-16 23:21:57 +02:00

312 lines
15 KiB
Python

#!/usr/bin/env python3
"""Register or remove exactly one KeyCape client from a SecurityCase, without exporting Secret values.
Entry point of the playbook capability net-kingdom.keycape-client-registration,
composed by the SecurityCaseType oidc-browser-relying-party
(security-case-types/, canon security-case-types_v0.1). It generalizes the
per-client helpers in this directory (portal-client-rollout,
register-coulomb-social, vergabe-client-rollout,
secrets-engine-openbao-client-rollout) and keeps their safeguards:
- contained kubectl, cluster-identity pin and verified upstream issuer pin
- byte-preserving merge: only this client's lines change; every other config
byte, the signing key and unrelated clients are preserved
- exact match: absent -> add; identical -> noop; drift, duplicates or a shared
secretRef -> refuse (update = remove, then add, each accepted)
- compare-and-swap on the Secret's uid and resourceVersion, then read-back
- confidential clients: the KeyCape Deployment must already bind the client's
secret env var (delivered from custody), or the next pod start fails closed
- KeyCape's own startup checks (keycape check-config, key-cape c528daa) on the
live and the proposed registry, fed on stdin and never written to disk; apply
refuses a registry that would stop the single replica at its next start
- no Secret value, parser message or Kubernetes error text leaves the process
Modes: inspect (default), dry-run (server-side), apply. apply requires the
case's acceptance record instead of a per-client founder decision id: the case
was accepted under an acknowledged type.
keycape-client.py client-from-case <case.yaml>
keycape-client.py inspect --client-file client.yaml [--operation remove]
keycape-client.py apply --client-file client.yaml --case-acceptance <record> \\
--check-config <keycape binary> --expected-uid <uid> --expected-resource-version <rv>
"""
from __future__ import annotations
import argparse
import base64
import copy
import importlib.util
import json
import re
import subprocess
import sys
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
lane = module('portal_rollout', 'portal-client-rollout.py')
pin = lane.pin
Refused = lane.Refused
require = lane.require
CASE_TYPE = re.compile(r'^oidc-browser-relying-party@1\.\d+$')
CLIENT_ID = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
# KeyCape refuses '*' and '?' at startup; OAuth 2.0 forbids fragments.
REDIRECT = re.compile(r'^https://[^*?#\s,]+$')
SCOPES = {'openid', 'profile', 'email', 'groups'}
def env_name(client_id):
return 'KEYCAPE_' + client_id.upper().replace('-', '_') + '_CLIENT_SECRET'
def env_secret_name(client_id):
return f'keycape-{client_id}-client'
def client_from_params(params):
"""The KeyCape client record for a case of oidc-browser-relying-party."""
cid = params['client_id']
require(CLIENT_ID.fullmatch(cid or '') is not None, 'invalid_client_id')
redirects = [r for r in params['redirect_uris'].split(',')]
require(redirects and all(REDIRECT.fullmatch(r) for r in redirects), 'redirect_uris_must_be_exact_https')
scopes = params['allowed_scopes'].split(',')
require(scopes[0] == 'openid' and set(scopes) <= SCOPES and len(set(scopes)) == len(scopes),
'allowed_scopes_invalid')
# KeyCape has no per-client group gate; the consumer reads groups, and UserInfo
# returns them only for the groups scope.
require(not params.get('access_groups') or 'groups' in scopes, 'access_groups_require_groups_scope')
require(params['client_type'] in ('confidential', 'public'), 'client_type_invalid')
# The type derives custody at platform/workloads/keycape/<client_id>; a case cannot choose it.
require('secret_ref' not in params, 'secret_ref_is_derived_by_the_type')
require(params['mfa_required'] in ('true', 'false'), 'mfa_required_invalid')
client = {
'clientId': cid,
'displayName': params['display_name'],
'redirectUris': redirects,
'allowedScopes': scopes,
'grantTypes': ['authorization_code'],
'clientType': params['client_type'],
}
if params['client_type'] == 'confidential':
client['secretRef'] = 'env:' + env_name(cid)
if params['mfa_required'] == 'true':
client['mfaRequired'] = True
return client
def client_from_case(case):
spec = case.get('spec') or {}
require(CASE_TYPE.fullmatch(str(spec.get('type', ''))) is not None, 'case_type_not_oidc_browser_relying_party')
return client_from_params(spec.get('parameters') or {})
def env_binding(client):
return {'name': env_name(client['clientId']), 'valueFrom': {
'secretKeyRef': {'name': env_secret_name(client['clientId']), 'key': 'client-secret'}}}
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, client):
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')
if 'secretRef' in client:
others = [c for c in clients if c['clientId'] != client['clientId']]
require(all(c.get('secretRef') != client['secretRef'] for c in others), 'secret_ref_already_bound')
return raw, config, ids
def replacement(secret, client):
"""Return (base64 config, changed) with only this client added."""
raw, config, ids = _inspect(secret, client)
if client['clientId'] in ids:
require(config['clients'][ids.index(client['clientId'])] == 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, client):
"""Rollback: return (base64 config, changed) with only this exact client removed."""
raw, config, ids = _inspect(secret, client)
if client['clientId'] not in ids:
return secret['data']['config.yaml'], False
position = ids.index(client['clientId'])
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):
end = raw.rfind('\n', 0, node.value[position + 1].start_mark.index) + 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_bound(deployment, client):
"""Whether the live Deployment carries exactly this client's secret binding."""
if 'secretRef' not in client:
return None
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(client['clientId'])]
require(len(matches) <= 1 and all(e == env_binding(client) for e in matches), 'env_binding_drift')
return bool(matches)
def check_config(binary, config_text, env_names, policy_store, *, executable_fd=None):
"""KeyCape's startup checks on one registry: names and messages only, no values."""
executable = str(Path(binary).resolve()) if executable_fd is None else f'/proc/self/fd/{executable_fd}'
command = [executable, 'check-config', '--config', '/dev/stdin', '--env-names', ','.join(env_names)]
if policy_store:
command.append('--policy-store')
result = subprocess.run(command, input=config_text.encode(), capture_output=True, timeout=60, env={},
pass_fds=() if executable_fd is None else (executable_fd,))
report = json.loads(result.stdout)
require(isinstance(report, dict) and isinstance(report.get('valid'), bool)
and report['valid'] == (result.returncode == 0), 'check_config_output_shape')
return {'valid': report['valid'], 'clients': report.get('clients'),
'errors': [str(e) for e in report.get('errors') or []]}
def deployment_env_names(deployment):
containers = deployment['spec']['template']['spec']['containers']
container = next(c for c in containers if c['name'] == 'keycape')
return sorted(e['name'] for e in container.get('env', []))
def run(args, client):
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, client)
bound = deployment = None
if 'secretRef' in client or args.check_config:
deployment = kubectl(['-n', 'sso', 'get', 'deployment', 'keycape', '-o', 'json'])
bound = deployment_bound(deployment, client)
checks = None
if args.check_config:
names = deployment_env_names(deployment)
policy_store = 'KEYCAPE_POLICY_PATH' in names
checker_options = ({'executable_fd': args.check_config_fd}
if getattr(args, 'check_config_fd', None) is not None else {})
checks = {stage: check_config(args.check_config, base64.b64decode(config).decode(), names, policy_store, **checker_options)
for stage, config in (('live', before['data']['config.yaml']), ('proposed', encoded))}
receipt = {'schema': 'netkingdom.keycape-client.v1', 'target': 'sso/keycape-config',
'before': metadata, 'operation': args.operation, 'client_id': client['clientId'],
'client_type': client['clientType'], 'change_needed': changed, 'mode': args.mode,
'deployment_env_bound': bound, 'check_config': checks, 'values_emitted': False, 'changed': False}
if args.mode == 'inspect':
return receipt
if args.mode == 'apply':
require(bool(args.case_acceptance), 'case_acceptance_record_required')
receipt['case_acceptance'] = args.case_acceptance
require(checks is not None, 'keycape_check_config_required')
require(checks['proposed']['valid'], 'proposed_registry_fails_keycape_check_config')
if args.operation == 'add' and bound is not None:
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', 'client-from-case'])
parser.add_argument('case', nargs='?', help='case file, for client-from-case')
parser.add_argument('--client-file', help='KeyCape client record (output of client-from-case)')
parser.add_argument('--operation', default='add', choices=['add', 'remove'])
parser.add_argument('--expected-uid')
parser.add_argument('--expected-resource-version')
parser.add_argument('--case-acceptance', help='the case acceptance record; required for apply')
parser.add_argument('--check-config', help='a keycape binary with check-config (key-cape c528daa or later); required for apply')
args = parser.parse_args(argv)
try:
if args.mode == 'client-from-case':
require(bool(args.case), 'case_file_required')
print(yaml.safe_dump(client_from_case(yaml.safe_load(Path(args.case).read_text())), sort_keys=False), end='')
return 0
require(bool(args.client_file), 'client_file_required')
client = yaml.safe_load(Path(args.client_file).read_text())
require(isinstance(client, dict) and 'clientId' in client, 'client_file_shape')
print(json.dumps(run(args, client), 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())