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
280 lines
15 KiB
Python
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
|