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
314 lines
15 KiB
Python
314 lines
15 KiB
Python
import argparse
|
|
import base64
|
|
import contextlib
|
|
import copy
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import unittest
|
|
|
|
import yaml
|
|
|
|
spec = importlib.util.spec_from_file_location('keycape_client', Path(__file__).with_name('keycape-client.py'))
|
|
kc = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(kc)
|
|
|
|
vspec = importlib.util.spec_from_file_location('vergabe_rollout', Path(__file__).with_name('vergabe-client-rollout.py'))
|
|
|
|
UID = '00000000-0000-4000-8000-000000000001'
|
|
EXISTING = [
|
|
{'clientId': 'openbao-admin', 'clientType': 'public',
|
|
'redirectUris': ['http://localhost:8250/oidc/callback']},
|
|
{'clientId': 'secrets-engine-approval', 'clientType': 'confidential',
|
|
'secretRef': 'env:KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET'},
|
|
]
|
|
FORGEJO_PARAMS = {
|
|
'client_id': 'forgejo', 'display_name': 'Forgejo source forge',
|
|
'redirect_uris': 'https://forgejo.coulomb.social/user/oauth2/netkingdom/callback',
|
|
'allowed_scopes': 'openid,profile,email,groups', 'client_type': 'confidential',
|
|
'mfa_required': 'true',
|
|
}
|
|
FORGEJO = kc.client_from_params(FORGEJO_PARAMS)
|
|
|
|
|
|
def fixture(clients=None, tail='tokenLifetime: 15m\n'):
|
|
# Synthetic marker values only; no live Secret is loaded by this suite.
|
|
raw = ('# preserve this comment\nissuer: https://kc.coulomb.social\n'
|
|
'authelia:\n issuer: https://auth.coulomb.social\n clientSecret: synthetic-marker\n'
|
|
'clients:\n' + yaml.safe_dump(json.loads(json.dumps(EXISTING if clients is None else clients)),
|
|
sort_keys=False) + tail)
|
|
secret = {'metadata': {'uid': UID, 'resourceVersion': '7'},
|
|
'data': {'config.yaml': base64.b64encode(raw.encode()).decode(),
|
|
'signing-key.pem': base64.b64encode(b'synthetic-signing-key').decode()}}
|
|
return secret, raw
|
|
|
|
|
|
def decode(encoded):
|
|
return base64.b64decode(encoded).decode()
|
|
|
|
|
|
class ClientRecordTests(unittest.TestCase):
|
|
def test_confidential_record_binds_env_secret_and_pins_mfa(self):
|
|
self.assertEqual(FORGEJO['secretRef'], 'env:KEYCAPE_FORGEJO_CLIENT_SECRET')
|
|
self.assertIs(FORGEJO['mfaRequired'], True)
|
|
self.assertEqual(FORGEJO['grantTypes'], ['authorization_code'])
|
|
self.assertEqual(kc.env_binding(FORGEJO)['valueFrom']['secretKeyRef']['name'], 'keycape-forgejo-client')
|
|
|
|
def test_public_record_has_no_secret(self):
|
|
params = dict(FORGEJO_PARAMS, client_type='public', mfa_required='false')
|
|
client = kc.client_from_params(params)
|
|
self.assertNotIn('secretRef', client)
|
|
self.assertNotIn('mfaRequired', client)
|
|
|
|
def test_invalid_params_refused(self):
|
|
for change in [dict(redirect_uris='https://x.example/*'), dict(redirect_uris='http://x.example/cb'),
|
|
dict(allowed_scopes='profile,openid'), dict(allowed_scopes='openid,admin'),
|
|
dict(client_type='weird'), dict(secret_ref='bao:platform/workloads/forgejo/x'), dict(client_id='Bad_Id')]:
|
|
with self.subTest(change=change), self.assertRaises(kc.Refused):
|
|
kc.client_from_params(dict(FORGEJO_PARAMS, **change))
|
|
|
|
def test_vergabe_case_reproduces_the_hand_built_registration(self):
|
|
vergabe = importlib.util.module_from_spec(vspec)
|
|
vspec.loader.exec_module(vergabe)
|
|
derived = kc.client_from_params({
|
|
'client_id': 'vergabe-demo-company', 'display_name': 'Vergabe Demo Company',
|
|
'redirect_uris': vergabe.CLIENT['redirectUris'][0],
|
|
'allowed_scopes': ','.join(vergabe.CLIENT['allowedScopes']),
|
|
'client_type': 'public', 'mfa_required': 'false'})
|
|
self.assertEqual(derived, vergabe.CLIENT)
|
|
|
|
def test_client_from_case_requires_the_type(self):
|
|
case = {'spec': {'type': 'oidc-browser-relying-party@1.0', 'parameters': FORGEJO_PARAMS}}
|
|
self.assertEqual(kc.client_from_case(case), FORGEJO)
|
|
with self.assertRaises(kc.Refused):
|
|
kc.client_from_case({'spec': {'type': 'other@1.0', 'parameters': FORGEJO_PARAMS}})
|
|
|
|
def test_redirects_keycape_would_refuse_at_startup_are_refused(self):
|
|
# key-cape type review R1: a refused entry stops the single replica.
|
|
for bad in ['https://forgejo.example/cb?x=1', 'https://forgejo.example/cb#frag']:
|
|
with self.subTest(redirect=bad), self.assertRaises(kc.Refused):
|
|
kc.client_from_params(dict(FORGEJO_PARAMS, redirect_uris=bad))
|
|
|
|
def test_access_groups_require_groups_scope(self):
|
|
# key-cape type review R2: UserInfo returns groups only for that scope.
|
|
with self.assertRaises(kc.Refused):
|
|
kc.client_from_params(dict(FORGEJO_PARAMS, access_groups='forge-admins',
|
|
allowed_scopes='openid,profile,email'))
|
|
|
|
|
|
EXECUTOR_CASE = Path(__file__).resolve().parents[3] / 'security-case-types/oidc-service-client/examples/nk-bind-executor-approval.yaml'
|
|
KEYCAPE_EXAMPLE = Path('/home/worsch/key-cape/config/service-clients.example.yaml')
|
|
SERVICE_PARAMS = {
|
|
'client_id': 'nk-bind-executor-approval', 'display_name': 'NetKingdom bounded executor (approval-engine)',
|
|
'audience': 'approval-engine', 'allowed_scopes': 'approval:create,approval:read,approval:consume',
|
|
'service_subject': 'service:nk-bind-executor', 'tenant': 'tenant:platform', 'roles': 'bounded-executor',
|
|
'token_lifetime': '15m', 'consuming_service': 'bounded-executor', 'consumer_namespace': 'sso',
|
|
'consumer_service_account': 'nk-bind-executor', 'consumer_owner_repo': 'net-kingdom',
|
|
}
|
|
|
|
|
|
class ServiceClientTests(unittest.TestCase):
|
|
"""oidc-service-client (NK-WP-0056-T14): client_credentials records in key-cape's canonical shape."""
|
|
|
|
def test_executor_case_renders_the_service_record(self):
|
|
case = yaml.safe_load(EXECUTOR_CASE.read_text())
|
|
self.assertEqual(kc.client_from_case(case), {
|
|
'clientId': 'nk-bind-executor-approval', 'displayName': 'NetKingdom bounded executor (approval-engine)',
|
|
'audience': 'approval-engine', 'allowedScopes': ['approval:create', 'approval:read', 'approval:consume'],
|
|
'grantTypes': ['client_credentials'], 'clientType': 'confidential',
|
|
'secretRef': 'env:KEYCAPE_NK_BIND_EXECUTOR_APPROVAL_CLIENT_SECRET',
|
|
'serviceSubject': 'service:nk-bind-executor', 'tenant': 'tenant:platform', 'roles': ['bounded-executor'],
|
|
'tokenLifetime': '15m'})
|
|
|
|
def test_adopting_secrets_engine_approval_reproduces_key_capes_record(self):
|
|
if not KEYCAPE_EXAMPLE.exists():
|
|
self.skipTest('key-cape checkout absent')
|
|
live = next(c for c in yaml.safe_load(KEYCAPE_EXAMPLE.read_text())['clients']
|
|
if c['clientId'] == 'secrets-engine-approval')
|
|
params = {'client_id': live['clientId'], 'display_name': live['displayName'], 'audience': live['audience'],
|
|
'allowed_scopes': ','.join(live['allowedScopes']), 'service_subject': live['serviceSubject'],
|
|
'tenant': live['tenant'], 'roles': ','.join(live['roles']), 'token_lifetime': live['tokenLifetime']}
|
|
self.assertEqual(kc.service_client_from_params(params), live)
|
|
|
|
def test_refusals(self):
|
|
for change in [dict(allowed_scopes='openid,approval:read'), dict(allowed_scopes='approval:read,approval:read'),
|
|
dict(token_lifetime='30m'), dict(service_subject='nk-bind-executor'),
|
|
dict(tenant='platform'), dict(audience='Approval Engine'), dict(roles='Bad Role'),
|
|
dict(redirect_uris='https://x.example/cb'), dict(client_type='public'),
|
|
dict(mfa_required='true'), dict(secret_ref='env:OTHER')]:
|
|
with self.subTest(change=change), self.assertRaises(kc.Refused):
|
|
kc.service_client_from_params(dict(SERVICE_PARAMS, **change))
|
|
|
|
def test_case_type_selects_the_builder(self):
|
|
case = {'spec': {'type': 'oidc-service-client@0.1', 'parameters': SERVICE_PARAMS}}
|
|
self.assertEqual(kc.client_from_case(case)['grantTypes'], ['client_credentials'])
|
|
with self.assertRaises(kc.Refused): # service parameters under the browser type
|
|
kc.client_from_case({'spec': {'type': 'oidc-browser-relying-party@1.1', 'parameters': SERVICE_PARAMS}})
|
|
with self.assertRaises(kc.Refused):
|
|
kc.client_from_case({'spec': {'type': 'oidc-service-client@1.0', 'parameters': SERVICE_PARAMS}})
|
|
|
|
def test_merge_adds_a_service_client_and_refuses_a_shared_secret_ref(self):
|
|
service = kc.service_client_from_params(SERVICE_PARAMS)
|
|
secret, raw = fixture()
|
|
encoded, changed = kc.replacement(secret, service)
|
|
self.assertTrue(changed)
|
|
expected = yaml.safe_load(raw)
|
|
expected['clients'].append(service)
|
|
self.assertEqual(yaml.safe_load(decode(encoded)), expected)
|
|
with self.assertRaises(kc.Refused):
|
|
kc.replacement(fixture(EXISTING + [dict(EXISTING[0], clientId='x', secretRef=service['secretRef'])])[0],
|
|
service)
|
|
|
|
|
|
class MergeTests(unittest.TestCase):
|
|
def test_add_appends_only_this_client_and_preserves_bytes(self):
|
|
secret, raw = fixture()
|
|
before = copy.deepcopy(secret)
|
|
encoded, changed = kc.replacement(secret, FORGEJO)
|
|
updated = decode(encoded)
|
|
self.assertTrue(changed)
|
|
head = raw[:raw.index('tokenLifetime:')]
|
|
self.assertTrue(updated.startswith(head))
|
|
expected = yaml.safe_load(raw)
|
|
expected['clients'].append(FORGEJO)
|
|
self.assertEqual(yaml.safe_load(updated), expected)
|
|
self.assertEqual(secret, before)
|
|
|
|
def test_identical_is_noop_and_drift_refused(self):
|
|
secret, _ = fixture(EXISTING + [FORGEJO])
|
|
self.assertEqual(kc.replacement(secret, FORGEJO), (secret['data']['config.yaml'], False))
|
|
for change in [dict(redirectUris=['https://evil.example/cb']), dict(clientType='public'),
|
|
dict(allowedScopes=['openid'])]:
|
|
with self.subTest(change=change), self.assertRaises(kc.Refused):
|
|
kc.replacement(fixture(EXISTING + [dict(FORGEJO, **change)])[0], FORGEJO)
|
|
|
|
def test_shared_secret_ref_and_duplicates_refused(self):
|
|
other = dict(EXISTING[1], secretRef=FORGEJO['secretRef'])
|
|
with self.assertRaises(kc.Refused):
|
|
kc.replacement(fixture([EXISTING[0], other])[0], FORGEJO)
|
|
with self.assertRaises(kc.Refused):
|
|
kc.replacement(fixture(EXISTING + [EXISTING[0]])[0], FORGEJO)
|
|
|
|
def test_removal_removes_exactly_this_client(self):
|
|
secret, raw = fixture(EXISTING + [FORGEJO])
|
|
encoded, changed = kc.removal(secret, FORGEJO)
|
|
self.assertTrue(changed)
|
|
self.assertEqual(yaml.safe_load(decode(encoded))['clients'], EXISTING)
|
|
self.assertEqual(kc.removal(fixture()[0], FORGEJO), (fixture()[0]['data']['config.yaml'], False))
|
|
|
|
|
|
class FakeKube:
|
|
def __init__(self, secret, bound=True):
|
|
self.secret = secret
|
|
self.bound = bound
|
|
self.patches = []
|
|
|
|
def __call__(self, arguments, payload=None):
|
|
if arguments[:2] == ['get', 'namespace']:
|
|
return {'metadata': {'uid': kc.lane.CLUSTER_UID}}
|
|
if 'deployment' in arguments:
|
|
env = [kc.env_binding(FORGEJO)] if self.bound else []
|
|
return {'spec': {'template': {'spec': {'containers': [{'name': 'keycape', 'env': env}]}}}}
|
|
if 'patch' in arguments:
|
|
self.patches.append(arguments)
|
|
data = dict(self.secret['data'], **{'config.yaml': payload[2]['value']})
|
|
if '--dry-run=server' not in arguments:
|
|
self.secret = dict(self.secret, data=data)
|
|
return {'data': data}
|
|
return copy.deepcopy(self.secret)
|
|
|
|
|
|
def args(mode, **kw):
|
|
base = dict(mode=mode, operation='add', expected_uid=UID, expected_resource_version='7', case_acceptance=None,
|
|
check_config='keycape')
|
|
base.update(kw)
|
|
return argparse.Namespace(**base)
|
|
|
|
|
|
class FakeCheck:
|
|
"""Stands in for keycape check-config; refuses registries naming a client in `refuse`."""
|
|
def __init__(self, refuse=()):
|
|
self.refuse = refuse
|
|
self.calls = []
|
|
|
|
def __call__(self, binary, config_text, env_names, policy_store):
|
|
self.calls.append((binary, env_names, policy_store))
|
|
ids = [c['clientId'] for c in yaml.safe_load(config_text)['clients']]
|
|
errors = [f'client {i!r}: refused' for i in ids if i in self.refuse]
|
|
return {'valid': not errors, 'clients': len(ids), 'errors': errors}
|
|
|
|
|
|
class RunTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.original = kc.lane.kubectl, kc.check_config
|
|
kc.check_config = FakeCheck()
|
|
|
|
def tearDown(self):
|
|
kc.lane.kubectl, kc.check_config = self.original
|
|
|
|
def test_apply_needs_keycape_check_config(self):
|
|
kc.lane.kubectl = FakeKube(fixture()[0])
|
|
with self.assertRaises(kc.Refused):
|
|
kc.run(args('apply', case_acceptance='decision:x', check_config=None), FORGEJO)
|
|
|
|
def test_apply_refuses_a_registry_keycape_would_refuse(self):
|
|
fake = FakeKube(fixture()[0])
|
|
kc.lane.kubectl = fake
|
|
kc.check_config = FakeCheck(refuse=('forgejo',))
|
|
with self.assertRaises(kc.Refused):
|
|
kc.run(args('apply', case_acceptance='decision:x'), FORGEJO)
|
|
self.assertEqual([], fake.patches)
|
|
|
|
def test_inspect_reports_live_and_proposed_checks_with_deployment_env(self):
|
|
kc.lane.kubectl = FakeKube(fixture()[0])
|
|
kc.check_config = FakeCheck(refuse=('forgejo',))
|
|
receipt = kc.run(args('inspect'), FORGEJO)
|
|
self.assertTrue(receipt['check_config']['live']['valid'])
|
|
self.assertFalse(receipt['check_config']['proposed']['valid'])
|
|
self.assertEqual([kc.env_name('forgejo')], kc.check_config.calls[0][1])
|
|
|
|
def test_inspect_changes_nothing_and_emits_no_values(self):
|
|
fake = FakeKube(fixture()[0])
|
|
kc.lane.kubectl = fake
|
|
receipt = kc.run(args('inspect'), FORGEJO)
|
|
self.assertTrue(receipt['change_needed'])
|
|
self.assertFalse(receipt['values_emitted'])
|
|
self.assertEqual([], fake.patches)
|
|
self.assertNotIn('synthetic-marker', json.dumps(receipt))
|
|
|
|
def test_apply_needs_case_acceptance(self):
|
|
kc.lane.kubectl = FakeKube(fixture()[0])
|
|
with self.assertRaises(kc.Refused):
|
|
kc.run(args('apply'), FORGEJO)
|
|
|
|
def test_apply_needs_env_binding_for_confidential_clients(self):
|
|
kc.lane.kubectl = FakeKube(fixture()[0], bound=False)
|
|
with self.assertRaises(kc.Refused):
|
|
kc.run(args('apply', case_acceptance='decision:x'), FORGEJO)
|
|
|
|
def test_apply_refuses_a_changed_revision(self):
|
|
kc.lane.kubectl = FakeKube(fixture()[0])
|
|
with self.assertRaises(kc.Refused):
|
|
kc.run(args('apply', case_acceptance='decision:x', expected_resource_version='6'), FORGEJO)
|
|
|
|
def test_apply_patches_reads_back_and_records_acceptance(self):
|
|
fake = FakeKube(fixture()[0])
|
|
kc.lane.kubectl = fake
|
|
receipt = kc.run(args('apply', case_acceptance='decision:x'), FORGEJO)
|
|
self.assertTrue(receipt['changed'])
|
|
self.assertTrue(receipt['restart_required'])
|
|
self.assertEqual('decision:x', receipt['case_acceptance'])
|
|
self.assertEqual(1, len(fake.patches))
|
|
self.assertIn(FORGEJO, yaml.safe_load(decode(fake.secret['data']['config.yaml']))['clients'])
|
|
|
|
def test_main_contains_failures(self):
|
|
out = io.StringIO()
|
|
with contextlib.redirect_stdout(out):
|
|
code = kc.main(['inspect'])
|
|
self.assertEqual(1, code)
|
|
self.assertEqual('refused', json.loads(out.getvalue())['status'])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|