"""No-send role observation, credential containment and exact subject binding.""" import copy import importlib.util import io import json import stat from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[1] SPEC = importlib.util.spec_from_file_location( 'reset_observer', ROOT / 'sso-mfa/k8s/lldap/reset-authority-observe.py') observer = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(observer) class Directory: def __init__(self, subject_roles=(), bind_roles=('lldap_strict_readonly',)): self.roles = {'admin': ['lldap_admin'], 'svc-authelia': list(bind_roles), 'private-person': list(subject_roles), 'tegwick': list(subject_roles)} self.requests = [] self.transform = lambda value: value def __call__(self, request, timeout): payload = json.loads(request.data) self.requests.append((request.full_url, payload)) if request.full_url.endswith('/auth/simple/login'): assert payload == {'username': 'admin', 'password': 'private-password-marker'} return io.BytesIO(b'{"token":"private-token-marker"}') assert request.full_url == 'https://lldap.coulomb.social/api/graphql' assert payload['query'] == observer.QUERY and not payload['query'].startswith('mutation') assert request.get_header('Authorization') == 'Bearer private-token-marker' uid = payload['variables']['id'] value = {'data': {'user': {'id': uid, 'groups': [{'displayName': g} for g in self.roles[uid]]}}} return io.BytesIO(json.dumps(self.transform(value)).encode()) @pytest.mark.parametrize('roles', [[], ['lldap_admin'], ['lldap_password_manager'], ['private-application-admin-group']]) def test_exact_classification_only_and_no_mutations(roles): directory = Directory(roles) result = observer.Observer('https://lldap.coulomb.social', directory).observe( 'private-person', 'private-password-marker') assert [r[1].get('variables', {}).get('id') for r in directory.requests] == [ None, 'admin', 'svc-authelia', 'private-person'] assert result['selected_subject']['builtin_roles']['lldap_admin'] == ('lldap_admin' in roles) assert result['selected_subject']['application_authority_assessed'] is False assert not result['membership_changed'] and not result['password_reset_attempted'] assert not result['grant_approved'] and not result['application_access_verified'] raw = json.dumps(result) for private in ['private-person', 'private-password-marker', 'private-token-marker', 'private-application-admin-group']: assert private not in raw assert len(result['sources']) == 4 def test_ambiguous_bind_roles_reported_without_effective_permission_claim(): directory = Directory(bind_roles=['lldap_strict_readonly', 'lldap_password_manager']) result = observer.Observer('https://lldap.coulomb.social', directory).observe( 'private-person', 'private-password-marker') assert result['bind_has_multiple_builtin_roles'] assert not result['grant_approved'] @pytest.mark.parametrize('defect', ['wrong-subject', 'missing', 'duplicate-group', 'malformed-group', 'error-body', 'extra-fields']) def test_untrusted_responses_refuse_without_reporting_observation(defect): directory = Directory() def transform(value): value = copy.deepcopy(value) if defect == 'wrong-subject': value['data']['user']['id'] = 'different-person' elif defect == 'missing': value['data']['user'] = None elif defect == 'duplicate-group': value['data']['user']['groups'] *= 2 elif defect == 'malformed-group': value['data']['user']['groups'] = [{'displayName': 'unsafe\nvalue'}] elif defect == 'error-body': value['errors'] = [{'message': 'private-provider-body'}] else: value['data']['user']['email'] = 'private@example.test' return value directory.transform = transform with pytest.raises(observer.Refused): observer.Observer('https://lldap.coulomb.social', directory).observe( 'private-person', 'private-password-marker') @pytest.mark.parametrize('subject', ['', 'Bad User', 'alice"}{mutation', 'a' * 129]) def test_invalid_subject_refuses_before_authentication(subject): directory = Directory() with pytest.raises(observer.Refused): observer.Observer('https://lldap.coulomb.social', directory).observe( subject, 'private-password-marker') assert not directory.requests @pytest.mark.parametrize('defect', ['existing', 'symlink', 'public-parent', 'in-repository']) def test_unsafe_receipt_refused_before_private_prompts(tmp_path, monkeypatch, defect): tmp_path.chmod(0o700) path = tmp_path / 'receipt.json' if defect == 'existing': path.write_text('preserve') elif defect == 'symlink': path.symlink_to(tmp_path / 'missing') elif defect == 'public-parent': tmp_path.chmod(0o755) else: (tmp_path / '.git').mkdir() monkeypatch.setattr(observer.sys.stdin, 'isatty', lambda: True) monkeypatch.setattr(observer.sys.stderr, 'isatty', lambda: True) monkeypatch.setattr(observer.getpass, 'getpass', lambda prompt: pytest.fail('private prompt')) assert observer.main(['--receipt', str(path)]) == 1 if defect == 'existing': assert path.read_text() == 'preserve' def test_nonterminal_refuses_before_private_prompt(tmp_path, monkeypatch): monkeypatch.setattr(observer.sys.stdin, 'isatty', lambda: False) monkeypatch.setattr(observer.getpass, 'getpass', lambda prompt: pytest.fail('private prompt')) assert observer.main(['--receipt', str(tmp_path / 'receipt.json')]) == 1 @pytest.mark.parametrize('endpoint', ['https://attacker.invalid', 'https://lldap.coulomb.social@attacker.invalid']) def test_origin_refuses_before_private_prompt(tmp_path, monkeypatch, endpoint): tmp_path.chmod(0o700) monkeypatch.setattr(observer.sys.stdin, 'isatty', lambda: True) monkeypatch.setattr(observer.sys.stderr, 'isatty', lambda: True) monkeypatch.setattr(observer.getpass, 'getpass', lambda prompt: pytest.fail('private prompt')) assert observer.main(['--receipt', str(tmp_path / 'receipt.json'), '--endpoint', endpoint]) == 1 @pytest.mark.parametrize('fail', [False, True]) def test_cli_private_receipt_and_sanitized_failure(tmp_path, monkeypatch, capsys, fail): tmp_path.chmod(0o700) path = tmp_path / 'receipt.json' monkeypatch.setattr(observer.sys.stdin, 'isatty', lambda: True) monkeypatch.setattr(observer.sys.stderr, 'isatty', lambda: True) prompts = [] def private_input(prompt): prompts.append(prompt) return 'private-password-marker' monkeypatch.setattr(observer.getpass, 'getpass', private_input) directory = Directory() monkeypatch.setattr(observer.transport, 'Transport', lambda base: directory) if fail: def broken(value): raise RuntimeError('private-provider-body private-token-marker') directory.transform = broken assert observer.main(['--receipt', str(path)]) == int(fail) assert len(prompts) == 1 captured = capsys.readouterr() for private in ['private-person', 'private-password-marker', 'private-token-marker', 'private-provider-body']: assert private not in captured.out + captured.err if fail: assert not path.exists() else: assert directory.requests[-1][1]['variables']['id'] == 'tegwick' assert stat.S_IMODE(path.stat().st_mode) == 0o600 assert json.loads(path.read_text())['status'] == 'current_reset_roles_observed'