feat: exercise contained KeyCape approval client rollout
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-09 01:32:45 +02:00
parent 896c1200c0
commit ce826cfa8d
3 changed files with 412 additions and 0 deletions

View file

@ -0,0 +1,30 @@
{
"clients": [
{
"client_id": "secrets-engine-approval",
"live_jwks_signature_verified": true,
"exact_claims_verified": true,
"lifetime_seconds": 900,
"excess_scope_denied": true,
"wrong_secret_denied": true,
"pod_verify_client_passed": false,
"real_predecessor_rotation_tested": false,
"observed_wall_clock_expiry": false
},
{
"client_id": "approval-engine-operator",
"live_jwks_signature_verified": true,
"exact_claims_verified": true,
"lifetime_seconds": 900,
"excess_scope_denied": true,
"wrong_secret_denied": true,
"pod_verify_client_passed": false,
"real_predecessor_rotation_tested": false,
"observed_wall_clock_expiry": false
}
],
"human_client_consume_denied": true,
"target": "disposable pinned KeyCape image; synthetic keys and clients only",
"pod_cli_verification": "deferred to live in-pod check",
"cleanup_complete": true
}

View file

@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""Contained KeyCape approval-client config/image cutover and acceptance."""
from __future__ import annotations
import argparse
import base64
import copy
import importlib.util
import json
import os
from pathlib import Path
import secrets
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import jwt
import yaml
PLATFORM = Path('/home/worsch/railiance-platform')
KEYCAPE = Path('/home/worsch/key-cape')
sys.path.insert(0, str(PLATFORM / 'scripts'))
from state_hub_preflight_lane import LaneError, assert_cluster, bao, command, data
from keycape_approval_custody import require
spec = importlib.util.spec_from_file_location('pin', Path(__file__).with_name('openbao-client-config.py'))
pin = importlib.util.module_from_spec(spec); spec.loader.exec_module(pin)
ISSUER = 'https://kc.coulomb.social'
IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611'
PRIOR_IMAGE = 'forgejo.coulomb.social/coulomb/key-cape:main-153258b'
IDS = ('secrets-engine-approval', 'approval-engine-operator')
SECRET_NAMES = ('keycape-secrets-engine-approval-client', 'keycape-approval-engine-operator-client')
def registrations():
source = yaml.safe_load((KEYCAPE / 'config/service-clients.example.yaml').read_text())['clients']
result = [next(c for c in source if c['clientId'] == name) for name in IDS]
for i, c in enumerate(result):
require(c['audience'] == 'approval-engine' and c['tenant'] == 'tenant:platform'
and c['grantTypes'] == ['client_credentials'] and c['clientType'] == 'confidential'
and c['tokenLifetime'] == '15m', 'registration_contract_drift')
scopes = ['approval:read', 'approval:consume'] if i == 0 else [
'approval:create', 'approval:read', 'approval:approve', 'approval:revoke',
'approval:supersede', 'approval:observe', 'approval:emit']
require(c['allowedScopes'] == scopes and c['roles'] == (["secrets-engine"] if i == 0 else ['approval-operator'])
and c['serviceSubject'] == ('service:secrets-engine' if i == 0 else 'service:approval-engine-operator')
and c['secretRef'] == ('env:KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET' if i == 0 else 'env:KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET'), 'registration_authority_drift')
return result
def replacement(secret, clients):
raw, config, _ = pin.issuer_document(secret)
require(config['authelia'].get('issuer') == pin.VERIFIED_UPSTREAM_ISSUER, 'verified_issuer_pin_required')
require(isinstance(config.get('clients'), list) and config['clients'], 'client_sequence_required')
existing = [c['clientId'] for c in config['clients']]
require(len(existing) == len(set(existing)) and not set(existing).intersection(IDS), 'existing_client_registration_requires_reconciliation')
root = yaml.compose(raw)
node = next(v for k, v in root.value if k.value == 'clients')
require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style, 'block_client_sequence_required')
index = node.end_mark.index
# Insert at the start of the next top-level line, preserving every old byte.
line_start = raw.rfind('\n', 0, index) + 1
if not raw[line_start:index].strip():
index = line_start
indent = ' ' * node.start_mark.column
fragment = yaml.safe_dump(clients, sort_keys=False, width=120)
fragment = ''.join(indent + line if line.strip() else line for line in fragment.splitlines(True))
addition = ('' if index == 0 or raw[index - 1] == '\n' else '\n') + fragment
updated = raw[:index] + addition + raw[index:]
expected = copy.deepcopy(config); expected['clients'].extend(clients)
require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed')
return base64.b64encode(updated.encode()).decode()
def candidate_spec(deployment):
result = copy.deepcopy(deployment['spec'])
require(result.get('replicas', 1) == 1, 'single_replica_required')
container = next(c for c in result['template']['spec']['containers'] if c['name'] == 'keycape')
require(container['image'] == PRIOR_IMAGE, 'prior_image_drift')
patch = yaml.safe_load((KEYCAPE / 'docs/approval-clients-deployment.patch.yaml').read_text())['spec']
approved = patch['template']['spec']['containers'][0]
require(approved['image'] == IMAGE and patch['strategy']['type'] == 'Recreate', 'candidate_source_drift')
env = container.setdefault('env', [])
require(not {x['name'] for x in env}.intersection(x['name'] for x in approved['env']), 'existing_candidate_env')
for item, client, secret_name in zip(approved['env'], registrations(), SECRET_NAMES):
require(item == {'name': client['secretRef'].removeprefix('env:'), 'valueFrom': {
'secretKeyRef': {'name': secret_name, 'key': 'client-secret'}}}, 'env_delivery_contract_drift')
env.extend(approved['env']); container['image'] = IMAGE
container['readinessProbe']['httpGet'] = {'path': '/readyz', 'port': 8080}
result['strategy'] = {'type': 'Recreate'}
return result
def patch_object(kube, kind, obj, path, value, dry=False):
patch = [{'op': 'test', 'path': '/metadata/uid', 'value': obj['metadata']['uid']},
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': obj['metadata']['resourceVersion']},
{'op': 'replace', 'path': path, 'value': value}]
args = kube + ['-n', 'sso', 'patch', kind, obj['metadata']['name'], '--type=json', '--patch-file=/dev/stdin', '-o', 'json']
if dry:
args += ['--dry-run=server']
return data(command(args, payload=patch))
def get(kube, kind, name):
return data(command(kube + ['-n', 'sso', 'get', kind, name, '-o', 'json']))
def ready(kube, image, timeout=150):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
dep = get(kube, 'deployment', 'keycape')
status = dep.get('status', {})
if (status.get('observedGeneration') == dep['metadata']['generation']
and status.get('updatedReplicas') == status.get('readyReplicas') == status.get('availableReplicas') == 1
and status.get('replicas') == 1):
podlist = data(command(kube + ['-n', 'sso', 'get', 'pods', '-l', 'app.kubernetes.io/name=keycape', '-o', 'json']))['items']
if len(podlist) == 1 and not podlist[0]['metadata'].get('deletionTimestamp'):
containers = podlist[0].get('status', {}).get('containerStatuses', [])
if any(c['name'] == 'keycape' and c['ready'] and c['image'] == image for c in containers):
return {'deployment_uid': dep['metadata']['uid'], 'generation': dep['metadata']['generation'],
'pod_uid': podlist[0]['metadata']['uid'], 'single_ready_replica': True}
time.sleep(3)
raise LaneError('keycape_readiness_timeout')
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def http(path, fields=None, credentials=None):
headers = {'Accept': 'application/json'}
if credentials:
headers['Authorization'] = 'Basic ' + base64.b64encode(':'.join(credentials).encode()).decode()
body = None
if fields is not None:
body = urllib.parse.urlencode(fields).encode()
headers['Content-Type'] = 'application/x-www-form-urlencoded'
request = urllib.request.Request(ISSUER + path, data=body, headers=headers)
opener = urllib.request.build_opener(NoRedirect())
try:
with opener.open(request, timeout=20) as response:
return response.status, json.load(response)
except urllib.error.HTTPError as error:
return error.code, json.load(error)
def denied(result, status, feature):
code, body = result
require(code == status and body.get('error') == 'invalid_profile_usage'
and body.get('feature') == feature, 'keycape_denial_inconclusive')
def acceptance(kube, receipt):
code, discovery = http('/.well-known/openid-configuration')
require(code == 200 and discovery['issuer'] == ISSUER
and discovery['token_endpoint'] == ISSUER + '/token'
and discovery['jwks_uri'].startswith(ISSUER + '/'), 'discovery_binding_mismatch')
code, jwks = http(discovery['jwks_uri'].removeprefix(ISSUER))
require(code == 200, 'jwks_failed')
receipt['clients'] = []
for client, secret_name in zip(registrations(), SECRET_NAMES):
secret = get(kube, 'secret', secret_name)
credential = base64.b64decode(secret['data']['client-secret'], validate=True).decode()
scopes = ' '.join(client['allowedScopes'])
result, response = http('/token', {'grant_type': 'client_credentials', 'scope': scopes}, (client['clientId'], credential))
require(result == 200 and response.get('token_type') == 'Bearer' and response.get('expires_in') == 900, 'service_issuance_failed')
token = response['access_token']; header = jwt.get_unverified_header(token)
keys = [key for key in jwks['keys'] if key['kid'] == header.get('kid')]
require(header.get('alg') == 'RS256' and len(keys) == 1, 'signing_key_selection_failed')
claims = jwt.decode(token, jwt.PyJWK.from_dict(keys[0]).key, algorithms=['RS256'],
issuer=ISSUER, audience='approval-engine',
options={'require': ['exp', 'iat', 'sub', 'iss', 'aud']})
require(claims['sub'] == client['serviceSubject'] and claims['tenant'] == 'tenant:platform'
and claims['roles'] == client['roles'] and claims['exp'] - claims['iat'] == 900
and claims['principal_type'] == 'service'
and set(claims['scope'].split()) == set(client['allowedScopes']), 'exact_claims_mismatch')
excessive = 'approval:approve' if client['clientId'] == IDS[0] else 'approval:consume'
denied(http('/token', {'grant_type': 'client_credentials', 'scope': excessive},
(client['clientId'], credential)), 400, 'scope')
denied(http('/token', {'grant_type': 'client_credentials', 'scope': scopes},
(client['clientId'], secrets.token_urlsafe(48))), 401, 'Authorization')
args = kube + ['-n', 'sso', 'exec', 'deployment/keycape', '--', '/keycape', 'verify-client',
'-issuer', ISSUER, '-client-id', client['clientId'], '-audience', 'approval-engine', '-scope', scopes,
'-secret-env', client['secretRef'].removeprefix('env:'), '-expect-subject', client['serviceSubject'],
'-expect-tenant', 'tenant:platform', '-expect-roles', ','.join(client['roles']), '-deny-scope', excessive]
command(args)
receipt['clients'].append({'client_id': client['clientId'], 'live_jwks_signature_verified': True,
'exact_claims_verified': True, 'lifetime_seconds': 900, 'excess_scope_denied': True,
'wrong_secret_denied': True, 'pod_verify_client_passed': True,
'real_predecessor_rotation_tested': False, 'observed_wall_clock_expiry': False})
query = urllib.parse.urlencode({'client_id': 'openbao-admin', 'response_type': 'code',
'redirect_uri': 'http://localhost:8250/oidc/callback', 'scope': 'openid approval:consume',
'code_challenge_method': 'S256', 'code_challenge': 'A' * 43, 'state': secrets.token_urlsafe(32)})
denied(http('/authorize?' + query), 400, 'scope')
receipt['human_client_consume_denied'] = True
def rollout(kube, receipt, recovery_path):
assert_cluster(kube)
before = get(kube, 'secret', 'keycape-config')
deployment = get(kube, 'deployment', 'keycape')
require(before['metadata']['uid'] == '2e94519d-1550-41c7-9701-2efe47fe1fd3'
and deployment['metadata']['uid'] == '99ddd83c-cb3f-4847-bcf8-35f1aa87627f', 'owner_object_identity_mismatch')
config = replacement(before, registrations()); candidate = candidate_spec(deployment)
patch_object(kube, 'secret', before, '/data/config.yaml', config, dry=True)
patch_object(kube, 'deployment', deployment, '/spec', candidate, dry=True)
previous = {'deployment': deployment, 'secret': before}
recovery_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
require(recovery_path.parent.stat().st_mode & 0o077 == 0, 'private_recovery_directory_required')
fd = os.open(recovery_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w') as out:
json.dump(previous, out)
receipt['protected_recovery_retained'] = True
changed = None; deployed = None
try:
changed = patch_object(kube, 'secret', before, '/data/config.yaml', config)
require(changed['data'] == dict(before['data'], **{'config.yaml': config}), 'secret_readback_mismatch')
deployed = patch_object(kube, 'deployment', deployment, '/spec', candidate)
receipt.update(ready(kube, IMAGE))
after = get(kube, 'secret', 'keycape-config')
require(after['data'] == changed['data'], 'configuration_changed_during_rollout')
acceptance(kube, receipt)
receipt.update(status='service_acceptance_passed_pending_fresh_human_login', image=IMAGE,
config_resource_version=after['metadata']['resourceVersion'], signing_key_unchanged=True,
unrelated_config_bytes_preserved=True, existing_human_login_after=False)
except Exception:
# Read after uncertain API outcomes too; never assume a timeout means no write.
now = get(kube, 'secret', 'keycape-config')
require(now['metadata']['uid'] == before['metadata']['uid'], 'rollback_config_identity_drift')
expected_data = dict(before['data'], **{'config.yaml': config})
require(now['data'] in (before['data'], expected_data), 'rollback_config_drift_requires_owner_reconcile')
if now['data'] == expected_data:
patch_object(kube, 'secret', now, '/data/config.yaml', before['data']['config.yaml'])
now = get(kube, 'deployment', 'keycape')
require(now['metadata']['uid'] == deployment['metadata']['uid'], 'rollback_deployment_identity_drift')
expected_spec = deployed['spec'] if deployed else candidate
require(now['spec'] in (deployment['spec'], expected_spec), 'rollback_deployment_drift_requires_owner_reconcile')
if now['spec'] == expected_spec:
patch_object(kube, 'deployment', now, '/spec', deployment['spec'])
ready(kube, PRIOR_IMAGE)
receipt['compatible_pair_restored'] = True
raise
def run(args, receipt):
require(Path.home().parent.name == '.warden-attended-login' and not os.environ.get('BAO_TOKEN')
and not os.environ.get('VAULT_TOKEN'), 'attended_warden_envelope_required')
identity = data(bao(['token', 'lookup', '-format=json']))['data']
require('platform-admin' in identity['policies'] and 'root' not in identity['policies'], 'attended_platform_admin_required')
kube = ['kubectl', '--kubeconfig', args.kubeconfig]
if args.action == 'rollout':
receipt['existing_human_login_before'] = True
rollout(kube, receipt, Path(args.recovery))
else:
assert_cluster(kube); receipt.update(ready(kube, IMAGE)); acceptance(kube, receipt)
receipt.update(status='service_and_existing_human_login_acceptance_passed', existing_human_login_after=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['rollout', 'verify-after-login'])
parser.add_argument('--kubeconfig', required=True)
parser.add_argument('--recovery')
parser.add_argument('--receipt', required=True)
args = parser.parse_args()
receipt = {'schema': 'netkingdom.keycape-approval-rollout.v1', 'status': 'failed', 'values_emitted': False}
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
run(args, receipt)
except Exception as exc:
receipt['error'] = str(exc) if isinstance(exc, (LaneError, pin.IssuerPinError)) else 'contained_operation_failed'
finally:
with os.fdopen(fd, 'w') as out:
json.dump(receipt, out, indent=2); out.write('\n')
return 0 if receipt['status'] != 'failed' else 1
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -0,0 +1,104 @@
import base64
import copy
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import yaml
PATH = Path(__file__).with_name('approval-clients-rollout.py')
spec = importlib.util.spec_from_file_location('rollout', PATH)
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
def fixture(raw=None):
raw = raw or 'authelia:\n issuer: https://auth.coulomb.social\n clientSecret: synthetic-fixture\nclients:\n - clientId: existing-human\n allowedScopes: [openid]\n# trailing owner comment\nother: preserved\n'
return {'kind': 'Secret', 'metadata': {'name': 'keycape-config', 'namespace': 'sso',
'uid': '2e94519d-1550-41c7-9701-2efe47fe1fd3', 'resourceVersion': '100'},
'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), 'key.pem': 'c3ludGhldGlj'}}
def deployment():
source = next(yaml.safe_load_all((PATH.parent / 'deployment.yaml').read_text()))
source['metadata'].update(uid='99ddd83c-cb3f-4847-bcf8-35f1aa87627f', resourceVersion='101')
return source
class RolloutTests(unittest.TestCase):
def test_append_preserves_original_bytes_and_signing_key(self):
secret = fixture(); before = copy.deepcopy(secret)
actual = base64.b64decode(m.replacement(secret, m.registrations())).decode()
original = base64.b64decode(secret['data']['config.yaml']).decode()
insertion_start = original.index('other:')
self.assertTrue(actual.startswith(original[:insertion_start]))
self.assertTrue(actual.endswith(original[insertion_start:]))
self.assertEqual(secret, before)
self.assertEqual(len(yaml.safe_load(actual)['clients']), 3)
def test_indentless_and_final_client_sequence(self):
for text in ['authelia:\n issuer: https://auth.coulomb.social\nclients:\n- clientId: human\nother: keep\n',
'authelia:\n issuer: https://auth.coulomb.social\nclients:\n - clientId: human']:
parsed = yaml.safe_load(base64.b64decode(m.replacement(fixture(text), m.registrations())))
self.assertEqual(len(parsed['clients']), 3)
def test_reject_duplicate_existing_registration(self):
text = 'authelia:\n issuer: https://auth.coulomb.social\nclients:\n - clientId: secrets-engine-approval\n'
with self.assertRaisesRegex(m.LaneError, 'existing_client_registration'):
m.replacement(fixture(text), m.registrations())
def test_reject_ambiguous_yaml_and_wrong_issuer(self):
for raw in ['authelia: {}\nauthelia: {}\nclients: []',
'authelia:\n issuer: https://wrong.invalid\nclients:\n - clientId: human\n']:
with self.assertRaises((m.LaneError, m.pin.IssuerPinError)):
m.replacement(fixture(raw), m.registrations())
def test_candidate_preserves_existing_settings(self):
old = deployment(); result = m.candidate_spec(old)
before = old['spec']['template']['spec']['containers'][0]
after = result['template']['spec']['containers'][0]
self.assertEqual(result['strategy'], {'type': 'Recreate'})
self.assertEqual(after['env'][:len(before['env'])], before['env'])
for field in ['livenessProbe', 'resources', 'volumeMounts', 'startupProbe']:
self.assertEqual(after[field], before[field])
self.assertEqual(after['image'], m.IMAGE)
def test_cas_patch_contains_both_preconditions_and_stdin_only(self):
with patch.object(m, 'command') as command:
command.return_value.stdout = b'{}'
m.patch_object(['kubectl'], 'secret', fixture(), '/data/config.yaml', 'synthetic-payload')
args, kwargs = command.call_args
self.assertNotIn('synthetic-payload', args[0])
self.assertEqual([p['path'] for p in kwargs['payload'][:2]], ['/metadata/uid', '/metadata/resourceVersion'])
def test_failed_acceptance_restores_compatible_pair(self):
state = {'secret': fixture(), 'deployment': deployment()}
original = copy.deepcopy(state)
def fake_patch(kube, kind, obj, path, value, dry=False):
changed = copy.deepcopy(obj)
if path == '/spec': changed['spec'] = value
else: changed['data']['config.yaml'] = value
if not dry:
changed['metadata']['resourceVersion'] = str(int(changed['metadata']['resourceVersion']) + 1)
state[kind] = changed
return copy.deepcopy(changed)
with tempfile.TemporaryDirectory() as directory, patch.object(m, 'assert_cluster'), \
patch.object(m, 'get', side_effect=lambda kube, kind, name: copy.deepcopy(state[kind])), \
patch.object(m, 'patch_object', side_effect=fake_patch), patch.object(m, 'ready', return_value={}), \
patch.object(m, 'acceptance', side_effect=m.LaneError('synthetic_acceptance_failure')):
receipt = {}
with self.assertRaisesRegex(m.LaneError, 'synthetic_acceptance_failure'):
m.rollout([], receipt, Path(directory) / 'recovery.json')
self.assertTrue(receipt['compatible_pair_restored'])
self.assertEqual(state['secret']['data'], original['secret']['data'])
self.assertEqual(state['deployment']['spec'], original['deployment']['spec'])
self.assertEqual((Path(directory) / 'recovery.json').stat().st_mode & 0o777, 0o600)
def test_unrelated_refusal_never_passes(self):
for status, body in [(500, {}), (400, {'error': 'invalid_profile_usage', 'feature': 'client_id'}),
(401, {'error': 'invalid_profile_usage', 'feature': 'scope'})]:
with self.assertRaises(m.LaneError):
m.denied((status, body), 400, 'scope')
if __name__ == '__main__': unittest.main()