railiance-platform/tests/test_keycape_client_secret_generate.py
codex ba445d7aed
Some checks failed
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
CCR intake gate / gate (push) Has been cancelled
RPF-WP-0069-T08: broker admits oidc-service-client@0.2; CCR-2026-0060/0061 aligned to 0.2
The founder acknowledged oidc-service-client 0.2 on 2026-11-01 (memo r2, hub
decision f8044f81, net-kingdom 9ce0d60), meeting the RPF-WP-0060-T01 rule
from review R1. keycape_client_secret_generate.py pins the 0.2 type bytes,
keys parameter pins by exact type reference, and derives confidentiality per
type: browser 1.1 keeps its client_type check, the service type is
confidential by construction (no client_type, unconditional custody path);
a service case carrying client_type is refused. Create-only is unchanged.
Eleven new tests; the existing browser tests are unchanged apart from the
re-keyed pin. CCR-2026-0060/0061 match the 0.2 materialization; references
updated, still proposed. T09 (wait) tracks their approval and apply.

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-11-01 10:24:17 +01:00

528 lines
29 KiB
Python

"""Offline tests for credential.py generate (RPF-WP-0054-T03) against a fake ``bao``.
Runs ``credential.py generate`` as a real subprocess, so the silence contract and
the delegation from credential.py are tested end to end. The fake records which
token each call used: KV calls must use the minted grant token, never the
caller's session.
"""
import hashlib
import importlib.util
import shutil
from unittest import mock
import yaml
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import textwrap
import unittest
ROOT = Path(__file__).resolve().parents[1]
CLI = ROOT / 'scripts/credential.py'
GRANT_TOKEN = 'hvs.grant-' + 'G7h8' * 6
FAKE_BAO = textwrap.dedent(f'''\
#!/usr/bin/env python3
import json, os, sys
sf = os.environ['FAKE_BAO_STATE']
st = json.load(open(sf))
a = sys.argv[1:]
tok = os.environ.get('BAO_TOKEN', 'caller-session')
st['calls'].append({{'args': a, 'token': tok}})
def save(): json.dump(st, open(sf, 'w'))
def out(obj): save(); print(json.dumps(obj)); sys.exit(0)
def missing(): save(); sys.stderr.write('No value found at x\\n'); sys.exit(2)
if a[:2] == ['token', 'lookup']: out({{'data': {{'policies': st['caller_policies']}}}})
if a[:2] == ['token', 'create']:
if st.get('fail_create'): save(); sys.exit(2)
out({{'auth': {{'client_token': '{GRANT_TOKEN}', 'accessor': 'acc'}}}})
if a[:2] == ['token', 'revoke']: st['revoked'] = tok; save(); sys.exit(0)
if a[0] == 'read':
rest = a[-1].split('/', 2)[2]
if rest not in st['kv']: missing()
n = len(st['kv'][rest])
out({{'data': {{'current_version': n, 'versions': {{str(i + 1): {{}} for i in range(n)}}}}}})
if a[0] == 'write':
rest = a[-2].split('/', 2)[2]
body = json.loads(sys.stdin.read())
st['written'] = body
if st.get('race'): st['kv'][rest] = [{{'x': 1}}]; save(); sys.exit(2)
if st.get('fail_write'): save(); sys.exit(2)
e = st['kv'].setdefault(rest, [])
if body['options']['cas'] != len(e): save(); sys.exit(2)
e.append(body['data'])
out({{'data': {{'version': len(e)}}}})
save(); sys.exit(9)
''')
# Exact accepted Forgejo @1.1 shape; materialized only in this fake test.
CASE = '# First KnownSecurityCase of oidc-browser-relying-party 1.1 (NK-WP-0048-T06).\n# Parameters are the request the founder accepted on 2026-10-08\n# (key-cape docs/forgejo-oidc-client-request.yaml, KEY-WP-0038-T01); that\n# acceptance carries over as this case\'s acceptance. One difference: custody is\n# derived by the type (platform/workloads/keycape/forgejo), not the request\'s\n# proposed platform/workloads/forgejo/netkingdom-oidc-client. The founder\n# acknowledged that derivation with the type (memo r4, 2026-10-09).\n# Re-pinned from 1.0 to 1.1 on 2026-10-10 on the founder\'s go, before any\n# apply, so no 1.0 run overlaps (minor change: consumer-enforces-groups).\napiVersion: netkingdom.io/security-case/v0.1\nkind: SecurityCase\nspec:\n id: forgejo-netkingdom-oidc\n type: oidc-browser-relying-party@1.1\n status: materialized\n acceptance:\n at: "2026-10-08"\n record: "key-cape docs/forgejo-oidc-client-request.yaml (status accepted-2026-10-08, KEY-WP-0038-T01); type acknowledged via informed-decision memo nk-wp-0048-t05-oidc-browser-relying-party-r4, hub decision e418263e"\n by:\n - {role: owner, name: "founder (for railiance-apps)"}\n - {role: founder, name: founder}\n owner:\n repo: railiance-apps\n person: founder\n parameters:\n client_id: forgejo\n display_name: Forgejo source forge\n redirect_uris: https://forgejo.coulomb.social/user/oauth2/netkingdom/callback\n allowed_scopes: openid,profile,email,groups\n client_type: confidential\n mfa_required: "true"\n access_groups: forge-admins,forge-coulomb-owners,forge-coulomb-readers\n initial_members: forge-admins=tegwick;forge-coulomb-owners=tegwick\n consuming_service: forgejo\n consumer_owner_repo: railiance-apps\n'
SERVICE_TYPE = Path('/home/worsch/net-kingdom/security-case-types/oidc-service-client/type.yaml')
# The first oidc-service-client case (net-kingdom examples/nk-bind-executor-approval.yaml),
# materialized with an acceptance only in this fake test.
SERVICE_CASE = '''apiVersion: netkingdom.io/security-case/v0.1
kind: SecurityCase
spec:
id: nk-bind-executor-approval
type: oidc-service-client@0.2
status: materialized
acceptance:
at: "2026-11-01"
record: "test fixture only"
by:
- {role: owner, name: founder}
- {role: founder, name: founder}
owner:
repo: net-kingdom
person: founder
parameters:
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 Harness(unittest.TestCase):
CASE_TEXT = CASE
CLIENT_ID = 'forgejo'
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
(self.tmp / 'bin').mkdir()
(self.tmp / 'bin/bao').write_text(FAKE_BAO)
(self.tmp / 'bin/bao').chmod(0o755)
self.state = {'calls': [], 'kv': {},
'caller_policies': ['default', 'credential-broker-keycape-client-secret-generate-issuer']}
self.write_case(self.CASE_TEXT)
def write_case(self, text):
self.case = self.tmp / 'case.yaml'
self.case.write_text(text)
case_id = yaml.safe_load(text)['spec']['id']
self.ref = case_id + '@sha256:' + hashlib.sha256(text.encode()).hexdigest()
def run_cli(self, *extra, client_id=None, ref=None, env_extra=None):
client_id = client_id or self.CLIENT_ID
(self.tmp / 'state.json').write_text(json.dumps(self.state))
receipt = self.tmp / f'r{len(list(self.tmp.glob("r*.json")))}.json'
empty_home = self.tmp / 'empty-home'
empty_home.mkdir(exist_ok=True)
env = {'PATH': os.defpath, 'HOME': str(empty_home)}
env.update(PATH=f'{self.tmp / "bin"}:{env["PATH"]}', FAKE_BAO_STATE=str(self.tmp / 'state.json'),
**(env_extra or {}))
proc = subprocess.run([sys.executable, str(CLI), 'generate', '--grant', 'keycape/client-secret-generate',
'--client-id', client_id, '--case-file', str(self.case),
'--case-ref', ref or self.ref, '--receipt', str(receipt), *extra],
env=env, capture_output=True, text=True)
self.assertEqual((proc.stdout, proc.stderr), ('', ''))
self.state = json.loads((self.tmp / 'state.json').read_text())
text = receipt.read_text()
self.assertNotIn(GRANT_TOKEN, text)
written = self.state.get('written')
if written:
self.assertNotIn(written['data']['CLIENT_SECRET'], text)
return proc.returncode, json.loads(text)
def kv_calls(self):
return [c for c in self.state['calls'] if c['args'][0] in ('read', 'write')]
class Generate(Harness):
def test_generates_once_with_grant_token_and_revokes_it(self):
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (0, 'generated'))
self.assertEqual(receipt['kv_version'], 1)
self.assertTrue(receipt['grant_token_revoked'])
self.assertEqual(self.state['revoked'], GRANT_TOKEN)
self.assertTrue(all(c['token'] == GRANT_TOKEN for c in self.kv_calls()))
self.assertFalse(any(c['args'][0] == 'read' and '/data/' in c['args'][-1] for c in self.kv_calls()))
value = self.state['written']['data']['CLIENT_SECRET']
self.assertEqual(len(value), 64)
self.assertEqual(self.state['written']['options'], {'cas': 0})
def test_existing_entry_is_never_touched(self):
self.state['kv']['workloads/keycape/forgejo'] = [{'CLIENT_SECRET': 'old'}]
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (3, 'already_present'))
self.assertNotIn('written', self.state)
self.assertTrue(receipt['grant_token_revoked'])
def test_lost_cas_race_reports_already_present(self):
self.state['race'] = True
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (3, 'already_present_lost_race'))
def test_dry_run_writes_nothing(self):
code, receipt = self.run_cli('--dry-run')
self.assertEqual((code, receipt['outcome']), (0, 'dry_run_would_generate'))
self.assertNotIn('written', self.state)
def test_case_checks_refuse_before_any_bao_call(self):
cases = [
('case_ref_mismatch', {'ref': 'forgejo-netkingdom-oidc@sha256:' + '0' * 64}),
('case_client_id_mismatch', {'client_id': 'gitea'}),
('client_id_invalid_or_reserved', {'client_id': 'secrets-engine-openbao'}),
('client_id_invalid_or_reserved', {'client_id': '../forgejo'}),
('case_ref_malformed', {'ref': 'forgejo'}),
]
for outcome, kwargs in cases:
with self.subTest(outcome=outcome, **kwargs):
self.state['calls'] = []
code, receipt = self.run_cli(**kwargs)
self.assertEqual((code, receipt['outcome']), (2, outcome))
self.assertEqual(self.state['calls'], [])
def test_unmaterialized_or_public_case_refused(self):
for text, outcome in ((CASE.replace('materialized', 'accepted'), 'case_not_materialized'),
(CASE.replace('confidential', 'public'), 'case_client_not_confidential')):
with self.subTest(outcome=outcome):
self.write_case(text)
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, outcome))
def test_only_exact_acknowledged_type_version_is_admitted(self):
for type_ref in ('oidc-browser-relying-party@1.0',
'oidc-browser-relying-party@1.2',
'oidc-browser-relying-party@1.3',
'oidc-browser-relying-party@99.0',
'confidential-service-client@1.1'):
with self.subTest(type_ref=type_ref):
self.write_case(CASE.replace('oidc-browser-relying-party@1.1', type_ref))
self.state['calls'] = []
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'case_type_unacknowledged'))
self.assertEqual(self.state['calls'], [])
def test_source_acknowledgment_alone_does_not_admit_12(self):
self.write_case(CASE.replace('oidc-browser-relying-party@1.1',
'oidc-browser-relying-party@1.2')
+ ' consumer_namespace: forgejo\n')
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'case_type_unacknowledged'))
self.assertEqual(self.state['calls'], [])
def test_caller_environment_cannot_select_type_or_source_root(self):
code, receipt = self.run_cli(env_extra={
'NETKINGDOM_ROOT': '/nonexistent',
'WARDEN_NETKINGDOM_ROOT': '/nonexistent',
'PYTHONPATH': '/nonexistent',
})
self.assertEqual((code, receipt['outcome']), (0, 'generated'))
def test_acknowledged_11_still_requires_its_exact_parameters(self):
self.write_case(CASE + ' unexpected_target: forgejo\n')
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'case_source_validation_failed'))
self.assertEqual(self.state['calls'], [])
def test_future_revision_requires_explicit_owner_binding_and_acknowledgment(self):
# Synthetic owner configuration exercises the extension mechanism only.
# This is not the actual proposed 1.3 type, acknowledgment or parameter pin.
spec = importlib.util.spec_from_file_location(
'future_fixture_generator', ROOT / 'scripts/keycape_client_secret_generate.py')
broker = importlib.util.module_from_spec(spec)
spec.loader.exec_module(broker)
source = self.tmp / 'synthetic-owner-root'
source.mkdir()
relative, digest = broker.ACKNOWLEDGED_TYPES['oidc-browser-relying-party@1.1']
for rel in (relative, *broker.SOURCE_PINS):
dest = source / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(broker.NETKINGDOM_ROOT / rel, dest)
typ = source / relative
original = typ.read_bytes()
doc = yaml.safe_load(CASE)
doc['spec']['type'] = 'oidc-browser-relying-party@1.3'
typ.write_bytes(original.replace(b'version: "1.1"', b'version: "1.3"'))
# YAML numeric spelling is owner-authored, never an inferred revision.
type_doc = yaml.safe_load(original)
type_doc['spec']['version'] = '1.3'
typ.write_text(yaml.safe_dump(type_doc))
binding = {doc['spec']['type']: (relative, hashlib.sha256(typ.read_bytes()).hexdigest())}
with mock.patch.object(broker, 'NETKINGDOM_ROOT', source):
with self.assertRaises(broker.Exit):
broker.check_acknowledged_case(doc) # no explicit binding
with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, binding):
with self.assertRaises(broker.Exit):
broker.check_acknowledged_case(doc) # no parameter admission
with mock.patch.dict(broker.PARAMETER_PINS, {
'oidc-browser-relying-party@1.3': broker.PARAMETER_PINS['oidc-browser-relying-party@1.1']}):
broker.check_acknowledged_case(doc)
type_doc['spec']['status'] = 'proposed'
typ.write_text(yaml.safe_dump(type_doc))
proposed = {doc['spec']['type']: (relative, hashlib.sha256(typ.read_bytes()).hexdigest())}
with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, proposed):
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
def test_wrong_sources_proposed_unknown_versions_and_symlinks_fail_closed(self):
spec = importlib.util.spec_from_file_location(
'candidate_generator', ROOT / 'scripts/keycape_client_secret_generate.py')
broker = importlib.util.module_from_spec(spec)
spec.loader.exec_module(broker)
source = self.tmp / 'net-kingdom'
source.mkdir()
relative, digest = broker.ACKNOWLEDGED_TYPES['oidc-browser-relying-party@1.1']
for rel in (relative, *broker.SOURCE_PINS):
dest = source / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(broker.NETKINGDOM_ROOT / rel, dest)
doc = yaml.safe_load(CASE)
with mock.patch.object(broker, 'NETKINGDOM_ROOT', source):
broker.check_acknowledged_case(doc)
producer = source / next(iter(broker.SOURCE_PINS))
raw = producer.read_bytes()
producer.write_bytes(raw + b'\n# drift\n')
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
producer.write_bytes(raw)
typ = source / relative
raw = typ.read_bytes()
typ.write_bytes(raw.replace(b'status: acknowledged', b'status: proposed'))
# Even a hypothetical owner pin update cannot admit proposed status.
with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, {
doc['spec']['type']: (relative, hashlib.sha256(typ.read_bytes()).hexdigest())}):
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
typ.write_bytes(raw + b'\n# source drift\n')
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
typ.unlink()
typ.symlink_to(broker.__file__)
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
for ref in ('oidc-browser-relying-party@1.3', 'oidc-browser-relying-party@99.0'):
doc['spec']['type'] = ref
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_type_unacknowledged')
def test_exact_accepted_case_still_requires_materialization(self):
self.write_case(CASE.replace('status: materialized', 'status: accepted'))
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'case_not_materialized'))
self.assertEqual(self.state['calls'], [])
def test_agents_root_and_unbound_callers_refused(self):
code, receipt = self.run_cli(env_extra={'WARDEN_AGENT_ID': 'codex'})
self.assertEqual((code, receipt['outcome']), (2, 'agent_callers_refused'))
self.state['caller_policies'] = ['root']
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'root_refused'))
self.state['caller_policies'] = ['default']
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'caller_lacks_issuer_policy'))
self.assertFalse(any(c['args'][:2] == ['token', 'create'] for c in self.state['calls']))
def test_write_failure_is_reported_and_token_revoked(self):
self.state['fail_write'] = True
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (4, 'write_failed'))
self.assertTrue(receipt['grant_token_revoked'])
class ServiceClientGenerate(Harness):
"""oidc-service-client@0.2 (RPF-WP-0069-T08): admitted, confidential by type, create-only."""
CASE_TEXT = SERVICE_CASE
CLIENT_ID = 'nk-bind-executor-approval'
def test_generates_once_at_the_case_path_and_revokes(self):
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (0, 'generated'))
self.assertEqual(receipt['path'], 'platform/workloads/keycape/nk-bind-executor-approval')
self.assertTrue(receipt['case_verified'])
self.assertTrue(receipt['grant_token_revoked'])
self.assertTrue(all(c['token'] == GRANT_TOKEN for c in self.kv_calls()))
self.assertFalse(any(c['args'][0] == 'read' and '/data/' in c['args'][-1] for c in self.kv_calls()))
self.assertEqual(self.state['written']['options'], {'cas': 0})
self.assertEqual(list(self.state['written']['data']), ['CLIENT_SECRET'])
def test_existing_path_is_refused_and_never_touched(self):
self.state['kv']['workloads/keycape/nk-bind-executor-approval'] = [{'CLIENT_SECRET': 'old'}]
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (3, 'already_present'))
self.assertNotIn('written', self.state)
self.assertTrue(receipt['grant_token_revoked'])
def test_lost_cas_race_and_dry_run(self):
code, receipt = self.run_cli('--dry-run')
self.assertEqual((code, receipt['outcome']), (0, 'dry_run_would_generate'))
self.assertNotIn('written', self.state)
self.state['race'] = True
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (3, 'already_present_lost_race'))
def test_confidentiality_comes_from_the_type_not_a_parameter(self):
# No client_type parameter is needed ...
self.assertNotIn('client_type', SERVICE_CASE)
# ... and a case cannot claim one, either way.
for value in ('confidential', 'public'):
with self.subTest(value=value):
self.write_case(SERVICE_CASE + f' client_type: {value}\n')
self.state['calls'] = []
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'case_client_type_not_admitted'))
self.assertEqual(self.state['calls'], [])
def test_type_refusals_are_refused_before_any_bao_call(self):
bad = {
'service_subject: service:nk-bind-executor': 'service_subject: nk-bind-executor',
'token_lifetime: 15m': 'token_lifetime: 30m',
'allowed_scopes: approval:create,approval:read,approval:consume': 'allowed_scopes: openid,approval:read',
'tenant: tenant:platform': 'tenant: platform',
'audience: approval-engine': 'audience: Approval_Engine',
'consumer_namespace: sso': 'consumer_namespace: SSO',
'consumer_service_account: nk-bind-executor': 'consumer_service_account: ../nk',
}
for old, new in bad.items():
with self.subTest(new=new):
self.assertIn(old, SERVICE_CASE)
self.write_case(SERVICE_CASE.replace(old, new))
self.state['calls'] = []
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'case_source_validation_failed'))
self.assertEqual(self.state['calls'], [])
for extra in (' issuer: https://evil.example\n', ' redirect_uris: https://x.example/cb\n',
' unexpected_target: forgejo\n'):
with self.subTest(extra=extra):
self.write_case(SERVICE_CASE + extra)
self.state['calls'] = []
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'case_source_validation_failed'))
self.assertEqual(self.state['calls'], [])
for required in ('consumer_service_account', 'service_subject', 'audience'):
with self.subTest(missing=required):
text = '\n'.join(l for l in SERVICE_CASE.splitlines() if not l.strip().startswith(required + ':')) + '\n'
self.write_case(text)
self.state['calls'] = []
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, 'case_source_validation_failed'))
self.assertEqual(self.state['calls'], [])
def test_case_and_client_checks_still_apply(self):
for outcome, kwargs in (('case_client_id_mismatch', {'client_id': 'forgejo'}),
('client_id_invalid_or_reserved', {'client_id': 'secrets-engine-openbao'})):
with self.subTest(outcome=outcome):
self.state['calls'] = []
code, receipt = self.run_cli(**kwargs)
self.assertEqual((code, receipt['outcome']), (2, outcome))
self.assertEqual(self.state['calls'], [])
for text, outcome in ((SERVICE_CASE.replace('status: materialized', 'status: proposed'), 'case_not_materialized'),
(SERVICE_CASE.replace(' acceptance:\n at: "2026-11-01"\n record: "test fixture only"\n'
' by:\n - {role: owner, name: founder}\n'
' - {role: founder, name: founder}\n', ''),
'case_source_validation_failed')):
with self.subTest(outcome=outcome):
self.write_case(text)
self.state['calls'] = []
code, receipt = self.run_cli()
self.assertEqual((code, receipt['outcome']), (2, outcome))
self.assertEqual(self.state['calls'], [])
def test_only_exact_acknowledged_service_revision_is_admitted(self):
for type_ref in ('oidc-service-client@0.1', 'oidc-service-client@0.3', 'oidc-service-client@1.1'):
with self.subTest(type_ref=type_ref):
self.write_case(SERVICE_CASE.replace('oidc-service-client@0.2', type_ref))
self.state['calls'] = []
code, receipt = self.run_cli()
# An unlisted type keeps the browser parameter check, which a service case
# (no client_type) fails first; either way nothing reaches bao.
self.assertEqual((code, receipt['outcome']), (2, 'case_client_not_confidential'))
self.assertEqual(self.state['calls'], [])
def _broker_with_copied_sources(self):
spec = importlib.util.spec_from_file_location(
'service_generator', ROOT / 'scripts/keycape_client_secret_generate.py')
broker = importlib.util.module_from_spec(spec)
spec.loader.exec_module(broker)
source = self.tmp / 'net-kingdom'
relative, _ = broker.ACKNOWLEDGED_TYPES['oidc-service-client@0.2']
for rel in (relative, *broker.SOURCE_PINS):
dest = source / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(broker.NETKINGDOM_ROOT / rel, dest)
return broker, source, source / relative
def test_drifted_or_proposed_type_fails_closed(self):
broker, source, typ = self._broker_with_copied_sources()
doc = yaml.safe_load(SERVICE_CASE)
raw = typ.read_bytes()
with mock.patch.object(broker, 'NETKINGDOM_ROOT', source):
broker.check_acknowledged_case(doc)
typ.write_bytes(raw + b'\n# drift\n')
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
typ.write_bytes(raw.replace(b'status: acknowledged', b'status: proposed'))
with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, {
'oidc-service-client@0.2': (str(typ.relative_to(source)), hashlib.sha256(typ.read_bytes()).hexdigest())}):
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
def test_parameter_pin_is_per_type_reference(self):
broker, source, typ = self._broker_with_copied_sources()
doc = yaml.safe_load(SERVICE_CASE)
with mock.patch.object(broker, 'NETKINGDOM_ROOT', source):
with mock.patch.dict(broker.PARAMETER_PINS, {'oidc-service-client@0.2': '0' * 64}):
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_source_validation_failed')
def test_type_that_is_not_confidential_by_construction_is_refused(self):
broker, source, typ = self._broker_with_copied_sources()
doc = yaml.safe_load(SERVICE_CASE)
type_doc = yaml.safe_load(typ.read_bytes())
for item in type_doc['spec']['materialization']:
if item['id'] == 'client-secret-path':
item['when'] = 'client_type == confidential'
typ.write_text(yaml.safe_dump(type_doc))
with mock.patch.object(broker, 'NETKINGDOM_ROOT', source):
with mock.patch.dict(broker.ACKNOWLEDGED_TYPES, {
'oidc-service-client@0.2': (str(typ.relative_to(source)), hashlib.sha256(typ.read_bytes()).hexdigest())}):
with self.assertRaises(broker.Exit) as caught:
broker.check_acknowledged_case(doc)
self.assertEqual(caught.exception.reason, 'case_type_not_confidential')
def test_pins_match_the_acknowledged_source(self):
spec = importlib.util.spec_from_file_location(
'pin_generator', ROOT / 'scripts/keycape_client_secret_generate.py')
broker = importlib.util.module_from_spec(spec)
spec.loader.exec_module(broker)
raw = SERVICE_TYPE.read_bytes()
self.assertEqual(hashlib.sha256(raw).hexdigest(), broker.ACKNOWLEDGED_TYPES['oidc-service-client@0.2'][1])
typ = yaml.safe_load(raw)['spec']
self.assertEqual((typ['version'], typ['status']), ('0.2', 'acknowledged'))
names = {p['name'] for p in typ['parameters']}
self.assertTrue({'client_id', 'audience', 'allowed_scopes', 'service_subject', 'tenant',
'token_lifetime', 'consuming_service', 'consumer_namespace',
'consumer_service_account', 'consumer_owner_repo'} <= names)
self.assertNotIn('client_type', names)
client_id = next(p for p in typ['parameters'] if p['name'] == 'client_id')
self.assertEqual(client_id['constraints']['pattern'], broker.CLIENT_ID_RE.pattern)
self.assertTrue(broker.type_is_confidential(typ))
self.assertEqual(broker.CONFIDENTIALITY['oidc-browser-relying-party@1.1'], 'parameter')
if __name__ == '__main__':
unittest.main()