net-kingdom/tests/test_service_secret_custody.py
tegwick e6f4e28164
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
NK-WP-0054-T05 phase 3a: founder-run bind-split helper with end-to-end test
prepare-custody, plan, accounts and verify for ld-admin and the svc-* accounts;
named rotations for the Authelia bind and LLDAP_LDAP_USER_PASS.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 308701@bnt-lap001
Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56
2026-10-29 22:00:49 +01:00

280 lines
15 KiB
Python

"""Service helpers against synthetic files and a recording Kubernetes API stub."""
import base64
import json
import os
from pathlib import Path
import subprocess
import bcrypt
import pytest
ROOT = Path(__file__).resolve().parents[1]
PASSWORD = 'SyntheticServiceCredential12345678901234567890'
CHANGED = 'ReplacementSyntheticCredential12345678901234567890'
VALUES = {
'privacyidea': ['PI_SECRET_KEY', 'PI_PEPPER', 'PI_DB_PASSWORD'],
'lldap': ['LLDAP_JWT_SECRET', 'LLDAP_LDAP_USER_PASS', 'LLDAP_SVC_AUTHELIA_PASS', 'LLDAP_SVC_KEYCAPE_PASS',
'LLDAP_SVC_PRIVACYIDEA_PASS', 'LLDAP_SVC_PROVISIONER_PASS'],
'authelia': ['AUTHELIA_JWT_SECRET', 'AUTHELIA_SESSION_SECRET', 'AUTHELIA_STORAGE_ENCRYPTION_KEY',
'AUTHELIA_OIDC_HMAC_SECRET', 'AUTHELIA_KEYCAPE_CLIENT_SECRET'],
}
COMPONENTS = ['postgresql', 'privacyidea', 'lldap', 'authelia', 'identity-provisioner']
ROTATIONS = [('postgresql','PI_DB_PASSWORD','password'), ('privacyidea','PI_SECRET_KEY','PI_SECRET_KEY'),
('privacyidea','PI_DB_PASSWORD','PI_SQLALCHEMY_DATABASE_URI'),
('lldap','LLDAP_JWT_SECRET','LLDAP_JWT_SECRET'), ('authelia','AUTHELIA_SESSION_SECRET','session_secret'),
('lldap','LLDAP_LDAP_USER_PASS','LLDAP_LDAP_USER_PASS'), ('authelia','LLDAP_SVC_AUTHELIA_PASS','ldap_password')]
@pytest.fixture
def case(tmp_path):
private=tmp_path/'private';private.mkdir(mode=0o700)
for component,names in VALUES.items():
directory=private/component;directory.mkdir(mode=0o700)
env=directory/'secrets.env';env.write_text(''.join(f'{name}={PASSWORD}\n' for name in names));env.chmod(0o600)
binary=tmp_path/'bin';binary.mkdir()
(binary/'kubectl').write_text('''#!/usr/bin/python3
import json,os,sys
from pathlib import Path
args=sys.argv[1:]
raw=sys.stdin.read() if '-f' in args or '--patch-file=/dev/stdin' in args else ''
with open(os.environ['CALLS'],'a') as f:f.write(json.dumps({'args':args,'stdin':raw})+'\\n')
if os.environ.get('FAIL')==args[0]:
print(os.environ['MARKER']);print(os.environ['MARKER'],file=sys.stderr);sys.exit(1)
p=Path(os.environ['STATE'])
if args[0]=='get':
if os.environ.get('BAD_GET'):print(os.environ['BAD_GET'])
elif p.exists():print(p.read_text())
sys.exit(0)
if args[0]=='create' and raw:
if p.exists() or os.environ.get('RACE'):sys.exit(1)
state=json.loads(raw);state['metadata'].update(uid='fixture-uid',resourceVersion='1')
elif args[0]=='patch':
if os.environ.get('RACE'):sys.exit(1)
state=json.loads(p.read_text())
for op in json.loads(raw):
parts=[s.replace('~1','/').replace('~0','~') for s in op['path'].split('/')[1:]]
parent=state
for part in parts[:-1]:parent=parent[part]
if op['op']=='test':
if parent.get(parts[-1])!=op['value']:sys.exit(1)
elif op['op']=='remove':del parent[parts[-1]]
elif op['op']=='replace':parent[parts[-1]]=op['value']
else:sys.exit(1)
state['metadata']['resourceVersion']=str(int(state['metadata']['resourceVersion'])+1)
else:sys.exit(2)
p.write_text(json.dumps(state))
if os.environ.get('BAD_RESPONSE'):state['data']['unexpected']='wrong'
print(json.dumps(state))
''')
(binary/'kubectl').chmod(0o700)
env={'PATH':str(binary)+':'+os.environ['PATH'],'HOME':str(tmp_path),
'CALLS':str(tmp_path/'calls'),'STATE':str(tmp_path/'state'),'MARKER':PASSWORD}
return private,env
def invoke(case,component,rotate=None,**extra):
args=['bash','-x',str(ROOT/f'sso-mfa/k8s/{component}/create-secrets.sh'),str(case[0])]
if rotate:args+=['--rotate-key',rotate]
return subprocess.run(args,env={**case[1],**extra},capture_output=True,text=True,timeout=20)
def calls(case):
p=Path(case[1]['CALLS']);return [json.loads(line) for line in p.read_text().splitlines()] if p.exists() else []
def state(case):return json.loads(Path(case[1]['STATE']).read_text())
def put_state(case,obj):Path(case[1]['STATE']).write_text(json.dumps(obj))
def check_private_result(result):
assert PASSWORD not in result.stdout+result.stderr
assert CHANGED not in result.stdout+result.stderr
assert 'BEGIN PRIVATE KEY' not in result.stdout+result.stderr
def key_source(component,key):return 'lldap' if key.startswith('LLDAP_') else source(component)
def source(component):return {'postgresql':'privacyidea','identity-provisioner':'lldap'}.get(component,component)
@pytest.mark.parametrize('component',COMPONENTS)
def test_create_uses_literal_inputs_stdin_and_no_apply_annotation(case,component):
result=invoke(case,component);assert result.returncode==0,result.stderr;check_private_result(result)
assert PASSWORD not in json.dumps([c['args'] for c in calls(case)])
writes=[c for c in calls(case) if c['args'][0]!='get']
assert len(writes)==1 and writes[0]['args']==['create','-o','json','-f','-']
obj=state(case);assert 'annotations' not in obj['metadata']
values={k:base64.b64decode(v).decode() for k,v in obj['data'].items()}
if component=='postgresql':assert values=={'username':'privacyidea','password':PASSWORD}
elif component=='privacyidea':
assert values['PI_PEPPER']==PASSWORD and values['PI_SECRET_KEY']==PASSWORD
assert values['PI_SQLALCHEMY_DATABASE_URI']==f'postgresql://privacyidea:{PASSWORD}@net-kingdom-pg-rw.databases.svc.cluster.local:5432/privacyidea_db'
elif component=='lldap':assert values=={k:PASSWORD for k in ('LLDAP_JWT_SECRET','LLDAP_LDAP_USER_PASS')}
elif component=='identity-provisioner':assert values=={'username':'svc-provisioner','password':PASSWORD}
else:
key=case[0]/'authelia/oidc_private_key.pem'
assert key.stat().st_mode & 0o077==0 and values['oidc_issuer_private_key']==key.read_text()
assert bcrypt.checkpw(PASSWORD.encode(),values['keycape_client_secret_hash'].encode())
assert values['ldap_password']==PASSWORD
@pytest.mark.parametrize('component',COMPONENTS)
def test_repeated_apply_is_noop_and_preserves_other_data(case,component):
assert invoke(case,component).returncode==0
old=state(case);old['data']['owner-added']='c3ludGhldGlj';old['metadata']['annotations']={'owner':'retained'};put_state(case,old)
count=len(calls(case));result=invoke(case,component)
assert result.returncode==0;check_private_result(result)
assert state(case)==old
assert all(c['args'][0]=='get' for c in calls(case)[count:])
@pytest.mark.parametrize('component,key,field',ROTATIONS)
def test_explicit_rotation_is_conditional_and_changes_only_named_field(case,component,key,field):
assert invoke(case,component).returncode==0
old=state(case);old['data']['owner-added']='c3ludGhldGlj';old['metadata']['annotations']={'owner':'retained','kubectl.kubernetes.io/last-applied-configuration':'synthetic-old-copy'};put_state(case,old)
file=case[0]/key_source(component,key)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}'))
result=invoke(case,component,rotate=key);assert result.returncode==0,result.stderr;check_private_result(result)
updated=state(case)
assert updated['metadata']['annotations']=={'owner':'retained'}
assert updated['data'][field]!=old['data'][field]
assert {k:v for k,v in updated['data'].items() if k!=field}=={k:v for k,v in old['data'].items() if k!=field}
patch=next(c for c in reversed(calls(case)) if c['args'][0]=='patch')
tests={op['path']:op['value'] for op in json.loads(patch['stdin']) if op['op']=='test'}
assert tests['/metadata/uid']==old['metadata']['uid'] and tests['/metadata/resourceVersion']==old['metadata']['resourceVersion']
assert PASSWORD not in json.dumps(patch['args']) and CHANGED not in json.dumps(patch['args'])
@pytest.mark.parametrize('component',COMPONENTS)
def test_differing_values_require_explicit_rotation(case,component):
assert invoke(case,component).returncode==0;old=state(case)
file=case[0]/source(component)/'secrets.env';file.write_text(file.read_text().replace(PASSWORD,CHANGED))
count=len(calls(case));result=invoke(case,component)
assert result.returncode!=0;check_private_result(result);assert state(case)==old
assert all(c['args'][0]=='get' for c in calls(case)[count:])
@pytest.mark.parametrize('component',COMPONENTS)
@pytest.mark.parametrize('defect',['shell','duplicate','public','symlink','hardlink','public-parent'])
def test_bad_private_input_never_reaches_cluster(case,component,defect):
file=case[0]/source(component)/'secrets.env';marker=case[0]/'executed'
if defect=='shell':file.write_text(file.read_text()+f'$(touch {marker})\n')
elif defect=='duplicate':file.write_text(file.read_text()*2)
elif defect=='public':file.chmod(0o644)
elif defect=='public-parent':file.parent.chmod(0o755)
elif defect=='hardlink':os.link(file,file.with_name('alias'))
else:
target=file.with_name('other');file.rename(target);file.symlink_to(target)
result=invoke(case,component);assert result.returncode!=0;check_private_result(result)
assert not marker.exists() and not calls(case)
@pytest.mark.parametrize('component',COMPONENTS)
@pytest.mark.parametrize('failure',['get','create'])
def test_failed_child_diagnostics_never_escape(case,component,failure):
result=invoke(case,component,FAIL=failure);assert result.returncode!=0;check_private_result(result)
assert not Path(case[1]['STATE']).exists()
@pytest.mark.parametrize('defect',['owner','eso-label','identity','type','basic-auth','data','immutable','missing-uid'])
def test_wrong_or_controller_owned_existing_secret_is_refused(case,defect):
assert invoke(case,'lldap').returncode==0;old=state(case)
if defect=='owner':old['metadata']['ownerReferences']=[{'kind':'ExternalSecret','name':'fixture'}]
elif defect=='eso-label':old['metadata']['labels']={'reconcile.external-secrets.io/managed':'true'}
elif defect=='missing-uid':old['metadata']['uid']=None
elif defect=='identity':old['metadata']['namespace']='other'
elif defect=='type':old['type']='kubernetes.io/tls'
elif defect=='basic-auth':old['type']='kubernetes.io/basic-auth'
elif defect=='data':old['data']['LLDAP_JWT_SECRET']='invalid-base64'
else:old['immutable']=True
put_state(case,old);count=len(calls(case));result=invoke(case,'lldap')
assert result.returncode!=0 and state(case)==old
assert all(c['args'][0]=='get' for c in calls(case)[count:])
@pytest.mark.parametrize('component,key,field',ROTATIONS)
def test_concurrent_change_stops_rotation_without_retry(case,component,key,field):
assert invoke(case,component).returncode==0;old=state(case)
file=case[0]/key_source(component,key)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}'))
count=len(calls(case));result=invoke(case,component,rotate=key,RACE='1')
assert result.returncode!=0 and state(case)==old;check_private_result(result)
assert sum(c['args'][0]=='patch' for c in calls(case)[count:])==1
def test_create_race_never_switches_to_overwrite(case):
result=invoke(case,'lldap',RACE='1');assert result.returncode!=0
assert [c['args'][0] for c in calls(case)]==['get','create']
def test_authelia_failure_retains_same_key_on_retry(case):
result=invoke(case,'authelia',FAIL='create');assert result.returncode!=0
key=case[0]/'authelia/oidc_private_key.pem';original=key.read_bytes()
assert invoke(case,'authelia').returncode==0 and key.read_bytes()==original
def test_rotating_session_never_generates_or_replaces_existing_signing_key(case):
assert invoke(case,'authelia').returncode==0;old=state(case)
(case[0]/'authelia/oidc_private_key.pem').unlink()
file=case[0]/'authelia/secrets.env';file.write_text(file.read_text().replace(f'AUTHELIA_SESSION_SECRET={PASSWORD}',f'AUTHELIA_SESSION_SECRET={CHANGED}'))
assert invoke(case,'authelia',rotate='AUTHELIA_SESSION_SECRET').returncode==0
assert state(case)['data']['oidc_issuer_private_key']==old['data']['oidc_issuer_private_key']
assert not (case[0]/'authelia/oidc_private_key.pem').exists()
def test_wrong_response_is_not_reported_as_success(case):
result=invoke(case,'lldap',BAD_RESPONSE='1');assert result.returncode!=0;check_private_result(result)
@pytest.mark.parametrize('annotation',['', 'synthetic-old-copy'])
def test_annotation_cleanup_preserves_secret_values_and_other_annotations(case,annotation):
assert invoke(case,'lldap').returncode==0;old=state(case)
old['metadata']['annotations']={'owner':'retained','kubectl.kubernetes.io/last-applied-configuration':annotation};put_state(case,old)
result=invoke(case,'lldap');assert result.returncode==0
assert state(case)['data']==old['data']
assert state(case)['metadata']['annotations']=={'owner':'retained'}
@pytest.mark.parametrize('component,key,field',ROTATIONS)
def test_rotation_refuses_missing_secret_before_create(case,component,key,field):
result=invoke(case,component,rotate=key);assert result.returncode!=0
assert all(c['args'][0]=='get' for c in calls(case))
assert not (case[0]/'authelia/oidc_private_key.pem').exists()
def test_rotation_cannot_change_an_unrelated_field(case):
assert invoke(case,'privacyidea').returncode==0;old=state(case)
file=case[0]/'privacyidea/secrets.env';file.write_text(file.read_text().replace(PASSWORD,CHANGED))
result=invoke(case,'privacyidea',rotate='PI_SECRET_KEY');assert result.returncode!=0
assert state(case)==old
assert not any(c['args'][0]=='patch' for c in calls(case))
@pytest.mark.parametrize('defect',['symlink','hardlink','public','public-parent','invalid'])
def test_explicit_authelia_key_custody_is_validated_without_overwrite(case,defect):
key=case[0]/'authelia/external.pem';key.write_text('synthetic-invalid-key');key.chmod(0o600)
if defect=='symlink':
target=key.with_name('other');key.rename(target);key.symlink_to(target)
elif defect=='hardlink':os.link(key,key.with_name('alias'))
elif defect=='public':key.chmod(0o644)
elif defect=='public-parent':key.parent.chmod(0o755)
file=case[0]/'authelia/secrets.env';file.write_text(file.read_text()+f'AUTHELIA_OIDC_PRIVATE_KEY_FILE={key}\n')
result=invoke(case,'authelia');assert result.returncode!=0
assert key.read_text()=='synthetic-invalid-key'
assert not any(c['args'][0]!='get' for c in calls(case))
def test_native_kubectl_applies_stdin_patch_and_refuses_stale_version(case,tmp_path):
import shutil
binary=shutil.which('kubectl')
if binary is None:pytest.skip('native kubectl not available')
assert invoke(case,'lldap').returncode==0;old=state(case)
file=case[0]/'lldap/secrets.env';file.write_text(file.read_text().replace(f'LLDAP_JWT_SECRET={PASSWORD}',f'LLDAP_JWT_SECRET={CHANGED}'))
assert invoke(case,'lldap',rotate='LLDAP_JWT_SECRET').returncode==0
patch=next(c['stdin'] for c in reversed(calls(case)) if c['args'][0]=='patch')
original=tmp_path/'original.json';original.write_text(json.dumps(old));original.chmod(0o600)
env={**os.environ,'KUBECONFIG':str(tmp_path/'no-cluster-config'),'HOME':str(tmp_path)}
command=[binary,'patch','--local','--type=json','--patch-file=/dev/stdin','-f',str(original),'-o','json']
result=subprocess.run(command,input=patch,env=env,capture_output=True,text=True,timeout=20)
assert result.returncode==0
assert json.loads(result.stdout)['data']==state(case)['data']
old['metadata']['resourceVersion']='999';original.write_text(json.dumps(old))
refused=subprocess.run(command,input=patch,env=env,capture_output=True,text=True,timeout=20)
assert refused.returncode!=0