net-kingdom/sso-mfa/k8s/keycape/keycape-client.py
tegwick 5d5f15df55
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
NK-WP-0056-T14 done: SecurityCaseType oidc-service-client 0.1 (proposed)
Confidential client_credentials service clients: one audience, closed
resource scopes, fixed service subject and tenant, at most 15m; secret minted
in custody, read only by KeyCape (ESO) and the consumer's own bound
Kubernetes-auth role (no Secret copy). First case nk-bind-executor-approval
(approval-engine create/read/consume as service:nk-bind-executor). Capability
net-kingdom.keycape-service-client-registration; keycape-client.py renders
service clients in key-cape's shape (adoption of secrets-engine-approval
reproduces key-cape's record). Acknowledgement memo r1 validated against
informed-decision intake and State Hub dispatcher; hub decision f8044f81.
Test fixture's synthetic signing key is now computed, not a literal (secret
scan generic-api-key).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5502@bnt-lap001
Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
2026-10-31 21:30:21 +01:00

361 lines
18 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+$')
SERVICE_CASE_TYPE = re.compile(r'^oidc-service-client@0\.\d+$')
AUDIENCE = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
RESOURCE_SCOPE = re.compile(r'^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$')
SERVICE_SUBJECT = re.compile(r'^service:[a-z0-9][a-z0-9-]{1,62}$')
TENANT = re.compile(r'^tenant:[a-z0-9][a-z0-9-]{1,62}$')
ROLE = re.compile(r'^[a-z][a-z0-9-]{1,62}$')
LIFETIMES = {'5m', '10m', '15m'}
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."""
missing = {'client_id', 'display_name', 'redirect_uris', 'allowed_scopes', 'client_type', 'mfa_required'} - set(params)
require(not missing, 'missing_parameters')
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 service_client_from_params(params):
"""The KeyCape client record for a case of oidc-service-client (client_credentials). Field rules follow
key-cape src/internal/config/validate.go: confidential, env secretRef, serviceSubject and tenant, no redirect URIs;
the type is stricter (closed resource scopes, at most 15m)."""
missing = {'client_id', 'display_name', 'audience', 'allowed_scopes', 'service_subject', 'tenant',
'token_lifetime'} - set(params)
require(not missing, 'missing_parameters')
cid = params['client_id']
require(CLIENT_ID.fullmatch(cid or '') is not None, 'invalid_client_id')
require(AUDIENCE.fullmatch(params.get('audience') or '') is not None, 'audience_invalid')
scopes = (params.get('allowed_scopes') or '').split(',')
require(all(RESOURCE_SCOPE.fullmatch(s) for s in scopes) and len(set(scopes)) == len(scopes),
'allowed_scopes_must_be_distinct_resource_scopes')
require(SERVICE_SUBJECT.fullmatch(params.get('service_subject') or '') is not None, 'service_subject_invalid')
require(TENANT.fullmatch(params.get('tenant') or '') is not None, 'tenant_invalid')
require(params.get('token_lifetime') in LIFETIMES, 'token_lifetime_invalid')
for key in ('redirect_uris', 'client_type', 'mfa_required', 'secret_ref'):
require(key not in params, key + '_not_admitted_for_service_clients')
client = {
'clientId': cid,
'displayName': params['display_name'],
'audience': params['audience'],
'allowedScopes': scopes,
'grantTypes': ['client_credentials'],
'clientType': 'confidential',
'secretRef': 'env:' + env_name(cid),
'serviceSubject': params['service_subject'],
'tenant': params['tenant'],
}
if params.get('roles'):
roles = params['roles'].split(',')
require(all(ROLE.fullmatch(r) for r in roles) and len(set(roles)) == len(roles), 'roles_invalid')
client['roles'] = roles
client['tokenLifetime'] = params['token_lifetime']
return client
def client_from_case(case):
spec = case.get('spec') or {}
case_type = str(spec.get('type', ''))
if SERVICE_CASE_TYPE.fullmatch(case_type):
return service_client_from_params(spec.get('parameters') or {})
require(CASE_TYPE.fullmatch(case_type) is not None, 'case_type_not_a_keycape_client_type')
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())