diff --git a/sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md b/sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md index 147a3fc..5bb9be2 100644 --- a/sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md +++ b/sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md @@ -70,6 +70,21 @@ suspension (`netkingdom-`) and LLDAP administrative groups are refused by the existing service-group validator before login. Use their respective owners for those operations; the old unrestricted membership helper is not a fallback. +## Sign-in diagnosis: one uid's groups + +To see which directory groups a login will carry (NK-WP-0051, +[`docs/sign-in-diagnosis.md`](../../../docs/sign-in-diagnosis.md)): + +```bash +python3 lldap_admin.py groups --user signin-probe-negative \ + --root /absolute/private/custody +``` + +It is read-only and has no `--apply`. It prints `uid` and `groups` (sorted +display names, never DNs, mail or credentials). An unknown uid is refused, not +reported as an empty list. Matching the names to an OpenBao role stays a +metadata read in ops-mason. + ## Preservation, interruption and recovery The digest binds origin, operation, target and the observed directory users, diff --git a/sso-mfa/k8s/lldap/lldap_admin.py b/sso-mfa/k8s/lldap/lldap_admin.py index 35db314..0f846db 100644 --- a/sso-mfa/k8s/lldap/lldap_admin.py +++ b/sso-mfa/k8s/lldap/lldap_admin.py @@ -147,8 +147,13 @@ def snapshot(provider, token): def validate_operation(operation, action=None, group=None, user=None): - if operation not in {'bootstrap', 'membership', 'verify'}: + if operation not in {'bootstrap', 'membership', 'verify', 'groups'}: raise Refused() + if operation == 'groups': + # Read-only sign-in diagnosis preview (docs/sign-in-diagnosis.md). + if action is not None or group is not None or not isinstance(user, str) or not re.fullmatch(r'[a-z][a-z0-9._-]{0,127}', user): + raise Refused() + return if operation != 'membership': if any(v is not None for v in (action, group, user)): raise Refused() @@ -167,7 +172,7 @@ def validate_operation(operation, action=None, group=None, user=None): def maintain(provider, base, operation, *, action=None, group=None, user=None, apply=False, expected_digest=None): validate_operation(operation, action, group, user) - if (apply and (operation == 'verify' or action == 'list')) or (expected_digest is not None and not apply): + if (apply and (operation in ('verify', 'groups') or action == 'list')) or (expected_digest is not None and not apply): raise Refused() if apply and (not isinstance(expected_digest, str) or not re.fullmatch(r'[0-9a-f]{64}', expected_digest)): raise Refused() @@ -182,6 +187,11 @@ def maintain(provider, base, operation, *, action=None, group=None, user=None, a 'native_acceptance': 'unverified', 'remaining': ['governed_lifecycle', 'actual_login_and_mfa', 'issued_claims_and_consumer_access', 'session_revocation', 'independent_recovery']} + if operation == 'groups': + if user not in before['users']: + raise Refused() + names = sorted(name for name, item in before['groups'].items() if user in item['members']) + return {'result': 'groups_observed', 'uid': user, 'groups': names, 'native_acceptance': 'unverified'} if operation == 'membership' and (group not in before['groups'] or (user is not None and user not in before['users'])): raise Refused() binding = {'base': endpoint(base), 'operation': operation, 'action': action, 'group': group, 'user': user, 'before': before} @@ -234,11 +244,13 @@ def main(argv=None): try: parser = Parser(description=__doc__) sub = parser.add_subparsers(dest='operation', required=True, parser_class=Parser) - for operation in ('bootstrap', 'membership', 'verify'): + for operation in ('bootstrap', 'membership', 'verify', 'groups'): command = sub.add_parser(operation) command.add_argument('--root', required=True, help='Explicit private custody directory outside Git') command.add_argument('--lldap-url', default='https://lldap.coulomb.social') - if operation != 'verify': + if operation == 'groups': + command.add_argument('--user', required=True) + elif operation != 'verify': command.add_argument('--apply', action='store_true') command.add_argument('--expected-digest') if operation == 'membership': diff --git a/tests/test_lldap_group_maintenance.py b/tests/test_lldap_group_maintenance.py index 0f34f7f..034ce95 100644 --- a/tests/test_lldap_group_maintenance.py +++ b/tests/test_lldap_group_maintenance.py @@ -186,6 +186,34 @@ def test_list_is_nonmutating_and_verification_does_not_claim_acceptance(): assert provider.writes == [] +def test_groups_preview_lists_display_names_for_one_uid_without_writes(): + provider = Directory() + result = admin.maintain(provider, 'https://lldap.coulomb.social', 'groups', user='tegwick') + assert result == {'result': 'groups_observed', 'uid': 'tegwick', + 'groups': ['activity-core-operators', 'lldap_admin'], 'native_acceptance': 'unverified'} + assert provider.writes == [] + + +@pytest.mark.parametrize('kwargs', [{'user': 'nobody'}, {'user': 'Bad uid'}, {'user': None}, + {'user': 'tegwick', 'group': 'lldap_admin'}, + {'user': 'tegwick', 'apply': True, 'expected_digest': '0' * 64}]) +def test_groups_preview_refuses_unknown_or_malformed_requests(kwargs): + provider = Directory() + with pytest.raises(admin.Refused): + admin.maintain(provider, 'https://lldap.coulomb.social', 'groups', **kwargs) + assert provider.writes == [] + + +def test_groups_cli_has_no_apply_and_prints_only_diagnosis_fields(): + out = io.StringIO() + with patch.object(admin, 'private_password', return_value='fixture'), patch.object(admin, 'LLDAPProvisioner', return_value=Directory()), contextlib.redirect_stdout(out): + assert admin.main(['groups', '--root', '/fixture', '--user', 'agent-codex']) == 0 + assert json.loads(out.getvalue()) == {'groups': ['tenant:one:users'], 'native_acceptance': 'unverified', + 'result': 'groups_observed', 'uid': 'agent-codex'} + with contextlib.redirect_stderr(io.StringIO()): + assert admin.main(['groups', '--root', '/fixture', '--user', 'agent-codex', '--apply']) == 1 + + @pytest.mark.parametrize('payload', [{}, {'users': [], 'groups': None}, {'users': [{'id': 'same'}, {'id': 'same'}], 'groups': []}, {'users': [], 'groups': [{'id': True, 'displayName': 'a', 'users': []}]}, {'users': [], 'groups': [{'id': 1, 'displayName': 'a', 'users': [{'id': 'unknown'}]}]}, diff --git a/workplans/NK-WP-0051-sign-in-diagnosis.md b/workplans/NK-WP-0051-sign-in-diagnosis.md index 3634722..3c5f8f2 100644 --- a/workplans/NK-WP-0051-sign-in-diagnosis.md +++ b/workplans/NK-WP-0051-sign-in-diagnosis.md @@ -137,7 +137,7 @@ This repo only points at that requirement from `docs/sign-in-diagnosis.md`. ```task id: NK-WP-0051-T04 -status: todo +status: done depends_on: [NK-WP-0051-T01] priority: high state_hub_task_id: "65f85a11-8512-5700-b258-feb2ead84372"