net-kingdom/tests/test_legacy_lldap_retirement.py
tegwick 84c5dbeec8
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
security: retire unsafe legacy LDAP lifecycle callers
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
2026-10-14 00:08:42 +02:00

109 lines
5.6 KiB
Python

"""Retired callers must refuse before credential discovery or external actions."""
import argparse
import contextlib
import importlib.util
import io
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
from unittest.mock import patch
import pytest
ROOT = Path(__file__).resolve().parents[1]
def console():
path = ROOT / 'tools/security-bootstrap-console/security_bootstrap_console.py'
spec = importlib.util.spec_from_file_location('retirement_console', path)
module = importlib.util.module_from_spec(spec); sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
@pytest.mark.parametrize('name', ['create-user.sh', 'break-glass.sh', 'dry-run-nonroot-user.sh'])
def test_retired_shell_never_sources_credentials_or_calls_external_tools(tmp_path, name):
calls = tmp_path / 'calls'; sourced = tmp_path / 'sourced'; private = tmp_path / 'private'
for service, field in [('lldap', 'LLDAP_LDAP_USER_PASS'), ('breakglass', 'BREAKGLASS_PASSWORD')]:
directory = private / service; directory.mkdir(parents=True, mode=0o700)
(directory / 'secrets.env').write_text(f"{field}='synthetic-private-marker'\ntouch '{sourced}'\n")
bin_dir = tmp_path / 'bin'; bin_dir.mkdir()
for command in ['kubectl', 'curl']:
executable = bin_dir / command
executable.write_text(f"#!/bin/sh\nprintf called >> '{calls}'\nexit 1\n"); executable.chmod(0o700)
env = dict(os.environ, PATH=str(bin_dir) + ':' + os.environ['PATH'],
KUBECTL=str(bin_dir / 'kubectl'), LLDAP_ADMIN_PASS='synthetic-private-marker')
env.pop('BASH_ENV', None)
if name == 'create-user.sh':
env.pop('LLDAP_ADMIN_PASS')
args = ['test-user', 'test@example.test', 'Test User', 'http://127.0.0.1:1', str(private)]
elif name == 'break-glass.sh': args = ['http://127.0.0.1:1', str(private)]
else: args = ['test-user', 'test@example.test', 'Test User']
result = subprocess.run(['/bin/bash', '-x', str(ROOT / 'sso-mfa/k8s/lldap' / name), *args],
env=env, cwd=tmp_path, capture_output=True, text=True, timeout=10)
assert result.returncode == 2
assert not calls.exists() and not sourced.exists()
assert 'synthetic-private-marker' not in result.stdout + result.stderr
assert 'retired' in result.stderr
@pytest.mark.parametrize('target,variable', [('security-bootstrap-onboarding-dry-run','SUBJECT'),
('security-bootstrap-onboarding-dry-run','EMAIL'),
('security-bootstrap-onboarding-dry-run','DISPLAY'),
('security-bootstrap-lifecycle-cleanup-dryrun-users','PATTERN')])
def test_make_retirement_never_interpolates_old_user_arguments(tmp_path, target, variable):
# Harmless leaf replacement isolates the Make interpolation boundary.
shutil.copyfile(ROOT / 'Makefile', tmp_path / 'Makefile')
leaf = tmp_path / 'sso-mfa/k8s/lldap/dry-run-nonroot-user.sh'; leaf.parent.mkdir(parents=True)
leaf.write_text('#!/bin/sh\nexit 2\n'); leaf.chmod(0o700)
marker = tmp_path / 'command-executed'
value = f'$(touch {marker})'
# Escape Make's dollar processing so the old recipe would hand $(...) to the shell.
result = subprocess.run(['make', '--no-print-directory', target, f'{variable}=$' + value],
cwd=tmp_path, capture_output=True, text=True, timeout=10)
assert result.returncode != 0
assert not marker.exists()
@pytest.mark.parametrize('command', ['onboarding-dry-run', 'lifecycle-cleanup-dryrun-users', 'onboarding-dry-run-claims'])
def test_console_retirement_returns_failure_without_delegation(command):
module = console(); output = io.StringIO()
with patch('subprocess.call') as call, patch('subprocess.run') as run, \
patch.object(module, 'load_metadata') as metadata, contextlib.redirect_stdout(output):
code = module.main([command])
assert code == 2
call.assert_not_called(); run.assert_not_called(); metadata.assert_not_called()
assert 'retired' in output.getvalue().lower()
@pytest.mark.parametrize('factory,validator', [('lifecycle_flow_template','print_validate_lifecycle_flow'),
('onboarding_dry_run_template','print_validate_onboarding_dry_run')])
def test_blank_template_is_not_success_evidence(tmp_path, factory, validator):
module = console(); record = getattr(module, factory)()
assert not any(value is True for value in record.values())
path = tmp_path / 'evidence.json'; path.write_text(json.dumps(record))
output = io.StringIO()
with contextlib.redirect_stdout(output): result = getattr(module, validator)(argparse.Namespace(evidence=str(path)))
assert result != 0
def test_console_guidance_routes_to_owner_without_legacy_execution():
module = console(); output = io.StringIO()
with contextlib.redirect_stdout(output): module.print_lifecycle_guide()
assert 'User Engine' in output.getvalue()
assert './create-user.sh' not in output.getvalue()
assert 'safe secret extract' not in output.getvalue()
payload = next(p for p in module.runbook_payloads({'openbao_initialized': True}) if p['name'] == 'User lifecycle dry-run (T06)')
assert payload['state'] == 'retired'
def test_direct_claims_compatibility_helper_never_infers_success():
module = console(); output = io.StringIO()
with contextlib.redirect_stdout(output):
result = module.print_dry_run_oidc_claims_verification('private-subject-marker', ['net-kingdom-admins'])
assert result == 2
assert 'private-subject-marker' not in output.getvalue()
assert 'net-kingdom-admins' not in output.getvalue()