Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1468b-63fa-7710-85dc-8f2e16b3ebfa
312 lines
15 KiB
Python
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())
|