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
528 lines
29 KiB
Python
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()
|