net-kingdom/tests/test_rotation_output.py
tegwick b01a30bd49
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
fix(security): make service Secret updates private and conditional
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
2026-10-13 20:20:42 +02:00

226 lines
11 KiB
Python

"""Rotation entry-point tests use synthetic files and fake cluster commands only."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[1]
BOOT = ROOT / 'sso-mfa/bootstrap'
CASES = {
'PI_SECRET_KEY': 'privacyidea', 'PI_DB_PASSWORD': 'privacyidea',
'LLDAP_JWT_SECRET': 'lldap', 'AUTHELIA_SESSION_SECRET': 'authelia',
'BREAKGLASS_PASSWORD': 'breakglass',
}
SYNTHETIC = 'A' * 64
@pytest.fixture
def rotation(tmp_path):
repo = tmp_path / 'repo'
boot = repo / 'sso-mfa/bootstrap'; boot.mkdir(parents=True)
for name in ('creds-rotate.sh', 'rotation-env.py'):
if (BOOT / name).exists(): shutil.copyfile(BOOT / name, boot / name)
shutil.copyfile(ROOT / 'Makefile', repo / 'Makefile')
(boot / 'creds-state.yaml').write_text('version: 2\n')
secrets = tmp_path / 'private'; secrets.mkdir(mode=0o700)
for component in ('postgresql', 'privacyidea', 'lldap', 'authelia', 'keycape', 'breakglass'):
directory = secrets / component; directory.mkdir(mode=0o700)
entries = [f'{key}=old-value' for key, value in CASES.items() if value == component]
if component == 'lldap': entries.append('LLDAP_LDAP_USER_PASS=old-value')
env_file = directory / 'secrets.env'
env_file.write_text('\n'.join(entries) + '\n'); env_file.chmod(0o600)
helper = repo / f'sso-mfa/k8s/{component}'; helper.mkdir(parents=True)
(helper / 'create-secrets.sh').write_text(
'#!/bin/bash\npython3 - "$@" <<\'PYTRACE\'\n'
'import json,os,sys\nfrom pathlib import Path\n'
'with open(os.environ["HELPERS"],"a") as f:f.write(json.dumps({"component":Path.cwd().name,"args":sys.argv[1:]})+"\\n")\n'
'PYTRACE\nprintf "%s\\n" "$SYNTHETIC" >&2\nexit "${HELPER_FAIL:-0}"\n')
binary = tmp_path / 'bin'; binary.mkdir()
(binary / 'openssl').write_text('#!/bin/sh\nprintf "%s\\n" "$SYNTHETIC"\n')
(binary / 'kubectl').write_text('''#!/usr/bin/python3
import json, os, sys
args = sys.argv[1:]
payload = sys.stdin.read() if ('-i' in args or args[-1:] == ['-']) else ''
with open(os.environ['CALLS'], 'a') as file: file.write(json.dumps({'args': args, 'stdin': payload}) + '\\n')
if args[0] == 'get':
if not os.environ.get('NO_POD'): print(os.environ.get('POD_NAMES', 'pod/disposable-pg'))
else:
print(os.environ['SYNTHETIC'])
print(os.environ['SYNTHETIC'], file=sys.stderr)
if os.environ.get('CLUSTER_FAIL'): sys.exit(1)
''')
for command in binary.iterdir(): command.chmod(0o700)
env = {'PATH': str(binary) + ':' + os.environ['PATH'], 'HOME': str(tmp_path),
'KUBECONFIG': str(tmp_path / 'nonexistent'), 'CALLS': str(tmp_path / 'calls'),
'SYNTHETIC': SYNTHETIC, 'HELPERS': str(tmp_path / 'helpers')}
return repo, boot, secrets, env
def run(rotation, key, **extra):
repo, boot, secrets, env = rotation
return subprocess.run(['bash', '-x', str(boot / 'creds-rotate.sh'), '--non-interactive', str(secrets)],
env={**env, 'SECRET': key, **extra}, capture_output=True, text=True, timeout=10)
def calls(rotation):
file = Path(rotation[3]['CALLS'])
return [json.loads(line) for line in file.read_text().splitlines()] if file.exists() else []
@pytest.mark.parametrize('key', CASES)
def test_success_never_prints_generated_values_or_child_diagnostics(rotation, key):
result = run(rotation, key)
assert result.returncode == 0
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
output = rotation[2] / CASES[key] / 'secrets.env'
assert output.stat().st_mode & 0o077 == 0
assert key + '=' + SYNTHETIC[:40] in output.read_text()
assert f'last_rotated_{key}:' in (rotation[1] / 'creds-state.yaml').read_text()
def test_database_absence_cannot_publish_or_claim_rotation(rotation):
before = (rotation[2] / 'privacyidea/secrets.env').read_bytes()
result = run(rotation, 'PI_DB_PASSWORD', NO_POD='1')
assert result.returncode != 0
assert (rotation[2] / 'privacyidea/secrets.env').read_bytes() == before
assert all(call['args'][0] == 'get' for call in calls(rotation))
assert 'last_rotated_' not in (rotation[1] / 'creds-state.yaml').read_text()
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
def test_database_password_is_stdin_not_command_argument(rotation):
result = run(rotation, 'PI_DB_PASSWORD')
assert result.returncode == 0
sql = next(call for call in calls(rotation) if call['args'][0] == 'exec')
assert bool(SYNTHETIC[:40] in json.dumps(sql['args'])) is False
assert SYNTHETIC[:40] in sql['stdin']
assert 'ON_ERROR_STOP=1' in sql['args']
def test_unimplemented_ldap_manual_step_refuses_unattended_rotation(rotation):
before = (rotation[2] / 'lldap/secrets.env').read_bytes()
result = run(rotation, 'LLDAP_LDAP_USER_PASS')
assert result.returncode != 0
assert not calls(rotation)
assert (rotation[2] / 'lldap/secrets.env').read_bytes() == before
assert 'last_rotated_' not in (rotation[1] / 'creds-state.yaml').read_text()
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
@pytest.mark.parametrize('defect', ['missing', 'duplicate', 'symlink', 'parent-symlink', 'public', 'public-parent', 'public-root', 'hardlink', 'missing-key'])
def test_invalid_custody_file_refused_before_cluster(rotation, defect):
file = rotation[2] / 'privacyidea/secrets.env'
if defect == 'missing': file.unlink()
elif defect == 'duplicate': file.write_text('PI_SECRET_KEY=one\nPI_SECRET_KEY=two\n')
elif defect == 'missing-key': file.write_text('OTHER_KEY=one\n')
elif defect == 'public': file.chmod(0o644)
elif defect == 'public-parent': file.parent.chmod(0o755)
elif defect == 'public-root': rotation[2].chmod(0o755)
elif defect == 'hardlink': os.link(file, file.with_name('alias'))
elif defect == 'symlink':
target = file.with_name('target'); file.rename(target); file.symlink_to(target)
else:
directory = file.parent; target = directory.with_name('moved'); directory.rename(target); directory.symlink_to(target)
result = run(rotation, 'PI_SECRET_KEY')
assert result.returncode != 0
assert not calls(rotation)
assert 'last_rotated_' not in (rotation[1] / 'creds-state.yaml').read_text()
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
@pytest.mark.parametrize('failure', ['HELPER_FAIL', 'CLUSTER_FAIL'])
def test_apply_failure_is_sanitized_and_not_marked_complete(rotation, failure):
result = run(rotation, 'PI_SECRET_KEY', **{failure: '1'})
assert result.returncode != 0
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
assert 'last_rotated_' not in (rotation[1] / 'creds-state.yaml').read_text()
def test_breakglass_no_plaintext_literal_or_client_apply_annotation(rotation):
result = run(rotation, 'BREAKGLASS_PASSWORD')
assert result.returncode == 0
observed = calls(rotation)
assert bool(SYNTHETIC[:40] in json.dumps([c['args'] for c in observed])) is False
assert any('--server-side' in c['args'] for c in observed)
def test_unknown_selector_is_not_echoed_or_evaluated_by_make(rotation):
repo, _, _, env = rotation
payload = 'unknown;touch selector-executed'
result = subprocess.run(['make', '--no-print-directory', 'creds-rotate', 'SECRET=' + payload],
cwd=repo, env=env, capture_output=True, text=True, timeout=10)
assert result.returncode != 0
assert not (repo / 'selector-executed').exists()
assert payload not in result.stdout + result.stderr
assert not calls(rotation)
def test_multiple_database_pods_refused_without_local_update(rotation):
file = rotation[2] / 'privacyidea/secrets.env'
before = file.read_bytes()
result = run(rotation, 'PI_DB_PASSWORD', POD_NAMES='pod/one\npod/two')
assert result.returncode != 0
assert file.read_bytes() == before
assert all(call['args'][0] == 'get' for call in calls(rotation))
def test_database_command_failure_has_no_completion_or_consumer_restart(rotation):
result = run(rotation, 'PI_DB_PASSWORD', CLUSTER_FAIL='1')
assert result.returncode != 0
assert [call['args'][0] for call in calls(rotation)] == ['get', 'exec']
assert 'last_rotated_' not in (rotation[1] / 'creds-state.yaml').read_text()
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
def test_declining_rotation_preserves_custody_file_and_cluster(rotation):
_, boot, secrets, env = rotation
file = secrets / 'privacyidea/secrets.env'
before = file.read_bytes()
result = subprocess.run(['bash', '-x', str(boot / 'creds-rotate.sh'), str(secrets)],
env={**env, 'SECRET': 'PI_SECRET_KEY'}, input='n\n',
capture_output=True, text=True, timeout=10)
assert result.returncode == 0
assert file.read_bytes() == before
assert not calls(rotation)
assert bool(SYNTHETIC[:40] in result.stdout + result.stderr) is False
@pytest.mark.parametrize('key',['AUTHELIA_KEYCAPE_CLIENT_SECRET','KEYCAPE_RSA_KEY','LLDAP_LDAP_USER_PASS'])
@pytest.mark.parametrize('attended',[False,True])
def test_retired_keycape_replacement_refuses_before_any_credential_change(rotation,key,attended):
repo,boot,secrets,env=rotation
signing=secrets/'keycape/key.pem';signing.write_text('synthetic-existing-private-key');signing.chmod(0o600)
before={p:p.read_bytes() for p in secrets.rglob('*') if p.is_file()}
args=['bash','-x',str(boot/'creds-rotate.sh')]
if not attended:args.append('--non-interactive')
result=subprocess.run([*args,str(secrets)],input='yes\n',env={**env,'SECRET':key},capture_output=True,text=True,timeout=10)
assert result.returncode!=0
assert not calls(rotation)
assert {p:p.read_bytes() for p in secrets.rglob('*') if p.is_file()}==before
assert 'last_rotated_' not in (boot/'creds-state.yaml').read_text()
assert 'NK-WP-0049-T02' in result.stderr
@pytest.mark.parametrize('key,components',[
('PI_SECRET_KEY',['privacyidea']),
('PI_DB_PASSWORD',['postgresql','privacyidea']),
('LLDAP_JWT_SECRET',['lldap']),
('AUTHELIA_SESSION_SECRET',['authelia']),
])
def test_rotation_forwards_exact_selector_to_every_secret_consumer(rotation,key,components):
result=run(rotation,key)
assert result.returncode==0
observed=[json.loads(line) for line in Path(rotation[3]['HELPERS']).read_text().splitlines()]
assert observed==[{'component':component,'args':[str(rotation[2]),'--rotate-key',key]} for component in components]
def test_database_secret_failure_stops_before_other_consumer_or_restart(rotation):
result=run(rotation,'PI_DB_PASSWORD',HELPER_FAIL='1')
assert result.returncode!=0
assert [json.loads(line)['component'] for line in Path(rotation[3]['HELPERS']).read_text().splitlines()]==['postgresql']
assert [call['args'][0] for call in calls(rotation)]==['get','exec']
assert 'last_rotated_' not in (rotation[1]/'creds-state.yaml').read_text()
assert 'PI_DB_PASSWORD='+SYNTHETIC[:40] in (rotation[2]/'privacyidea/secrets.env').read_text()