From 5d5c9ed012430c66415d998d18744d020851cd22 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 27 Jan 2026 12:42:01 +0300 Subject: [PATCH 1/6] Add: Implement recursive member search functionality in FilterInterpreterProtocol and corresponding tests --- app/ldap_protocol/filter_interpreter.py | 37 +++- .../test_main/test_router/test_search.py | 172 ++++++++++++++++++ 2 files changed, 207 insertions(+), 2 deletions(-) diff --git a/app/ldap_protocol/filter_interpreter.py b/app/ldap_protocol/filter_interpreter.py index c456fac00..35c1dc009 100644 --- a/app/ldap_protocol/filter_interpreter.py +++ b/app/ldap_protocol/filter_interpreter.py @@ -32,16 +32,26 @@ ) from ldap_protocol.utils.helpers import ft_to_dt from ldap_protocol.utils.queries import get_path_filter, get_search_path -from repo.pg.tables import groups_table, queryable_attr as qa, users_table +from repo.pg.tables import ( + directory_table, + groups_table, + queryable_attr as qa, + users_table, +) from .asn1parser import ASN1Row, TagNumbers from .objects import LDAPMatchingRule -from .utils.cte import find_members_recursive_cte, get_filter_from_path +from .utils.cte import ( + find_members_recursive_cte, + find_root_group_recursive_cte, + get_filter_from_path, +) _MEMBERS_ATTRS = { "member", "memberof", f"memberof:{LDAPMatchingRule.LDAP_MATCHING_RULE_TRANSITIVE_EVAL}:", + f"member:{LDAPMatchingRule.LDAP_MATCHING_RULE_TRANSITIVE_EVAL}:", } _RULE_POS = 0 @@ -289,6 +299,8 @@ def _get_member_filter_function( return self._recursive_filter_memberof return self._filter_memberof elif attribute == "member": + if oid == LDAPMatchingRule.LDAP_MATCHING_RULE_TRANSITIVE_EVAL: + return self._recursive_filter_member return self._filter_member else: raise ValueError("Incorrect attribute specified") @@ -317,6 +329,27 @@ def _filter_memberof(self, dn: str) -> UnaryExpression: ), ) # type: ignore + def _recursive_filter_member(self, dn: str) -> UnaryExpression: + """Retrieve query conditions with the member attribute (recursive).""" + cte = find_root_group_recursive_cte([dn]) + + source_directory_id = ( + select(directory_table.c.id) + .where(get_filter_from_path(dn)) + .scalar_subquery() + ) + + return qa(Directory.id).in_( + select(cte.c.directory_id) + .where( + and_( + cte.c.group_id.isnot(None), + cte.c.directory_id != source_directory_id, + ), + ) + .distinct(), + ) # type: ignore + def _filter_member(self, dn: str) -> UnaryExpression: """Retrieve query conditions with the member attribute.""" user_id_subquery = ( diff --git a/tests/test_api/test_main/test_router/test_search.py b/tests/test_api/test_main/test_router/test_search.py index 01fbb59c2..d44b243f5 100644 --- a/tests/test_api/test_main/test_router/test_search.py +++ b/tests/test_api/test_main/test_router/test_search.py @@ -9,6 +9,7 @@ from enums import EntityTypeNames from ldap_protocol.ldap_codes import LDAPCodes +from ldap_protocol.ldap_requests.modify import Operation from tests.search_request_datasets import ( test_search_by_rule_anr_dataset, test_search_by_rule_bit_and_dataset, @@ -304,6 +305,177 @@ async def test_api_search_recursive_memberof(http_client: AsyncClient) -> None: assert all(obj["object_name"] in members for obj in data["search_result"]) +@pytest.mark.asyncio +@pytest.mark.usefixtures("session") +async def test_api_search_recursive_member_user0( + http_client: AsyncClient, +) -> None: + """Test recursive member search for user0.""" + user = "cn=user0,cn=users,dc=md,dc=test" + # user0 находится напрямую в domain admins (не в developers) + expected_groups = [ + "cn=domain admins,cn=groups,dc=md,dc=test", + ] + response = await http_client.post( + "entry/search", + json={ + "base_object": "dc=md,dc=test", + "scope": 2, + "deref_aliases": 0, + "size_limit": 1000, + "time_limit": 10, + "types_only": True, + "filter": f"(member:1.2.840.113556.1.4.1941:={user})", + "attributes": [], + "page_number": 1, + }, + ) + data = response.json() + assert data["resultCode"] == LDAPCodes.SUCCESS + dns = {obj["object_name"] for obj in data["search_result"]} + for group in expected_groups: + assert group in dns, f"Group {group} not found in search results" + assert len(data["search_result"]) >= 1 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("session") +async def test_api_search_recursive_member_user1( + http_client: AsyncClient, +) -> None: + """Test recursive member search with nested groups chain.""" + group1_dn = "cn=recursive_test_group1,cn=groups,dc=md,dc=test" + group2_dn = "cn=recursive_test_group2,cn=groups,dc=md,dc=test" + group3_dn = "cn=recursive_test_group3,cn=groups,dc=md,dc=test" + user = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" + + response = await http_client.post( + "/entry/add", + json={ + "entry": group3_dn, + "password": None, + "attributes": [ + {"type": "name", "vals": ["recursive_test_group3"]}, + {"type": "cn", "vals": ["recursive_test_group3"]}, + { + "type": "objectClass", + "vals": ["top", "posixGroup", "group"], + }, + ], + }, + ) + assert response.json().get("resultCode") == LDAPCodes.SUCCESS + + response = await http_client.post( + "/entry/add", + json={ + "entry": group2_dn, + "password": None, + "attributes": [ + {"type": "name", "vals": ["recursive_test_group2"]}, + {"type": "cn", "vals": ["recursive_test_group2"]}, + { + "type": "objectClass", + "vals": ["top", "posixGroup", "group"], + }, + ], + }, + ) + assert response.json().get("resultCode") == LDAPCodes.SUCCESS + + response = await http_client.post( + "/entry/add", + json={ + "entry": group1_dn, + "password": None, + "attributes": [ + {"type": "name", "vals": ["recursive_test_group1"]}, + {"type": "cn", "vals": ["recursive_test_group1"]}, + { + "type": "objectClass", + "vals": ["top", "posixGroup", "group"], + }, + ], + }, + ) + assert response.json().get("resultCode") == LDAPCodes.SUCCESS + + response = await http_client.patch( + "/entry/update", + json={ + "object": group3_dn, + "changes": [ + { + "operation": Operation.ADD, + "modification": { + "type": "member", + "vals": [user], + }, + }, + ], + }, + ) + assert response.json().get("resultCode") == LDAPCodes.SUCCESS + + response = await http_client.patch( + "/entry/update", + json={ + "object": group2_dn, + "changes": [ + { + "operation": Operation.ADD, + "modification": { + "type": "member", + "vals": [group3_dn], + }, + }, + ], + }, + ) + assert response.json().get("resultCode") == LDAPCodes.SUCCESS + + response = await http_client.patch( + "/entry/update", + json={ + "object": group1_dn, + "changes": [ + { + "operation": Operation.ADD, + "modification": { + "type": "member", + "vals": [group2_dn], + }, + }, + ], + }, + ) + assert response.json().get("resultCode") == LDAPCodes.SUCCESS + + response = await http_client.post( + "entry/search", + json={ + "base_object": "dc=md,dc=test", + "scope": 2, + "deref_aliases": 0, + "size_limit": 1000, + "time_limit": 10, + "types_only": True, + "filter": f"(member:1.2.840.113556.1.4.1941:={user})", + "attributes": [], + "page_number": 1, + }, + ) + data = response.json() + assert data["resultCode"] == LDAPCodes.SUCCESS + dns = {obj["object_name"] for obj in data["search_result"]} + + expected_groups = [group1_dn, group2_dn, group3_dn] + for group in expected_groups: + assert group in dns, ( + f"Group {group} not found in search results. Found groups: {dns}" + ) + + @pytest.mark.asyncio @pytest.mark.usefixtures("session") @pytest.mark.parametrize("dataset", test_search_by_rule_anr_dataset) From 716e7be7716aa0be1026007e8d4c0c95b45ea0e0 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 27 Jan 2026 12:45:54 +0300 Subject: [PATCH 2/6] Refactor: Rename test functions for clarity in recursive member search tests --- tests/test_api/test_main/test_router/test_search.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_api/test_main/test_router/test_search.py b/tests/test_api/test_main/test_router/test_search.py index d44b243f5..ceb56f62a 100644 --- a/tests/test_api/test_main/test_router/test_search.py +++ b/tests/test_api/test_main/test_router/test_search.py @@ -307,12 +307,11 @@ async def test_api_search_recursive_memberof(http_client: AsyncClient) -> None: @pytest.mark.asyncio @pytest.mark.usefixtures("session") -async def test_api_search_recursive_member_user0( +async def test_search_recursive_member( http_client: AsyncClient, ) -> None: """Test recursive member search for user0.""" user = "cn=user0,cn=users,dc=md,dc=test" - # user0 находится напрямую в domain admins (не в developers) expected_groups = [ "cn=domain admins,cn=groups,dc=md,dc=test", ] @@ -340,7 +339,7 @@ async def test_api_search_recursive_member_user0( @pytest.mark.asyncio @pytest.mark.usefixtures("session") -async def test_api_search_recursive_member_user1( +async def test_search_recursive_member_for_many_roots( http_client: AsyncClient, ) -> None: """Test recursive member search with nested groups chain.""" From 45e06eaa9bf42442236dba6bea0f752504fd551b Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 27 Jan 2026 17:20:11 +0300 Subject: [PATCH 3/6] Refactor: Simplify group creation and member addition in recursive member search tests --- app/ldap_protocol/filter_interpreter.py | 10 +- .../test_main/test_router/test_search.py | 145 +++++------------- 2 files changed, 45 insertions(+), 110 deletions(-) diff --git a/app/ldap_protocol/filter_interpreter.py b/app/ldap_protocol/filter_interpreter.py index 35c1dc009..64462a5c5 100644 --- a/app/ldap_protocol/filter_interpreter.py +++ b/app/ldap_protocol/filter_interpreter.py @@ -342,13 +342,9 @@ def _recursive_filter_member(self, dn: str) -> UnaryExpression: return qa(Directory.id).in_( select(cte.c.directory_id) .where( - and_( - cte.c.group_id.isnot(None), - cte.c.directory_id != source_directory_id, - ), - ) - .distinct(), - ) # type: ignore + cte.c.directory_id != source_directory_id, + ), + ) # type: ignore # fmt: skip def _filter_member(self, dn: str) -> UnaryExpression: """Retrieve query conditions with the member attribute.""" diff --git a/tests/test_api/test_main/test_router/test_search.py b/tests/test_api/test_main/test_router/test_search.py index ceb56f62a..765c5fe64 100644 --- a/tests/test_api/test_main/test_router/test_search.py +++ b/tests/test_api/test_main/test_router/test_search.py @@ -343,112 +343,52 @@ async def test_search_recursive_member_for_many_roots( http_client: AsyncClient, ) -> None: """Test recursive member search with nested groups chain.""" + + async def _create_group(dn: str, name: str) -> None: + response = await http_client.post( + "/entry/add", + json={ + "entry": dn, + "password": None, + "attributes": [ + {"type": "name", "vals": [name]}, + {"type": "cn", "vals": [name]}, + { + "type": "objectClass", + "vals": ["top", "posixGroup", "group"], + }, + ], + }, + ) + assert response.json().get("resultCode") == LDAPCodes.SUCCESS + + async def _add_member(dn: str, member: str) -> None: + response = await http_client.patch( + "/entry/update", + json={ + "object": dn, + "changes": [ + { + "operation": Operation.ADD, + "modification": {"type": "member", "vals": [member]}, + }, + ], + }, + ) + assert response.json().get("resultCode") == LDAPCodes.SUCCESS + group1_dn = "cn=recursive_test_group1,cn=groups,dc=md,dc=test" group2_dn = "cn=recursive_test_group2,cn=groups,dc=md,dc=test" group3_dn = "cn=recursive_test_group3,cn=groups,dc=md,dc=test" user = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" - response = await http_client.post( - "/entry/add", - json={ - "entry": group3_dn, - "password": None, - "attributes": [ - {"type": "name", "vals": ["recursive_test_group3"]}, - {"type": "cn", "vals": ["recursive_test_group3"]}, - { - "type": "objectClass", - "vals": ["top", "posixGroup", "group"], - }, - ], - }, - ) - assert response.json().get("resultCode") == LDAPCodes.SUCCESS + await _create_group(group3_dn, "recursive_test_group3") + await _create_group(group2_dn, "recursive_test_group2") + await _create_group(group1_dn, "recursive_test_group1") - response = await http_client.post( - "/entry/add", - json={ - "entry": group2_dn, - "password": None, - "attributes": [ - {"type": "name", "vals": ["recursive_test_group2"]}, - {"type": "cn", "vals": ["recursive_test_group2"]}, - { - "type": "objectClass", - "vals": ["top", "posixGroup", "group"], - }, - ], - }, - ) - assert response.json().get("resultCode") == LDAPCodes.SUCCESS - - response = await http_client.post( - "/entry/add", - json={ - "entry": group1_dn, - "password": None, - "attributes": [ - {"type": "name", "vals": ["recursive_test_group1"]}, - {"type": "cn", "vals": ["recursive_test_group1"]}, - { - "type": "objectClass", - "vals": ["top", "posixGroup", "group"], - }, - ], - }, - ) - assert response.json().get("resultCode") == LDAPCodes.SUCCESS - - response = await http_client.patch( - "/entry/update", - json={ - "object": group3_dn, - "changes": [ - { - "operation": Operation.ADD, - "modification": { - "type": "member", - "vals": [user], - }, - }, - ], - }, - ) - assert response.json().get("resultCode") == LDAPCodes.SUCCESS - - response = await http_client.patch( - "/entry/update", - json={ - "object": group2_dn, - "changes": [ - { - "operation": Operation.ADD, - "modification": { - "type": "member", - "vals": [group3_dn], - }, - }, - ], - }, - ) - assert response.json().get("resultCode") == LDAPCodes.SUCCESS - - response = await http_client.patch( - "/entry/update", - json={ - "object": group1_dn, - "changes": [ - { - "operation": Operation.ADD, - "modification": { - "type": "member", - "vals": [group2_dn], - }, - }, - ], - }, - ) - assert response.json().get("resultCode") == LDAPCodes.SUCCESS + await _add_member(group1_dn, user) + await _add_member(group2_dn, group1_dn) + await _add_member(group3_dn, group2_dn) response = await http_client.post( "entry/search", @@ -470,9 +410,8 @@ async def test_search_recursive_member_for_many_roots( expected_groups = [group1_dn, group2_dn, group3_dn] for group in expected_groups: - assert group in dns, ( - f"Group {group} not found in search results. Found groups: {dns}" - ) + assert group in dns + assert "cn=domain admins,cn=groups,dc=md,dc=test" in dns @pytest.mark.asyncio From 282eb4d44854a9c734fbec57332c2d2566496ea2 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 27 Jan 2026 17:34:31 +0300 Subject: [PATCH 4/6] Enhance: Add distinct clause to directory ID selection in FilterInterpreterProtocol --- app/ldap_protocol/filter_interpreter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/ldap_protocol/filter_interpreter.py b/app/ldap_protocol/filter_interpreter.py index 64462a5c5..ce0b301c5 100644 --- a/app/ldap_protocol/filter_interpreter.py +++ b/app/ldap_protocol/filter_interpreter.py @@ -343,8 +343,9 @@ def _recursive_filter_member(self, dn: str) -> UnaryExpression: select(cte.c.directory_id) .where( cte.c.directory_id != source_directory_id, - ), - ) # type: ignore # fmt: skip + ) + .distinct(), + ) # type: ignore def _filter_member(self, dn: str) -> UnaryExpression: """Retrieve query conditions with the member attribute.""" From 7e7b3bb4b64d6b781c883156566048b1a2bea468 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 27 Jan 2026 17:45:25 +0300 Subject: [PATCH 5/6] Fix: Update distinguished names in recursive member search tests to match directory structure --- tests/test_api/test_main/test_router/test_search.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_api/test_main/test_router/test_search.py b/tests/test_api/test_main/test_router/test_search.py index 765c5fe64..5f1478f95 100644 --- a/tests/test_api/test_main/test_router/test_search.py +++ b/tests/test_api/test_main/test_router/test_search.py @@ -377,9 +377,9 @@ async def _add_member(dn: str, member: str) -> None: ) assert response.json().get("resultCode") == LDAPCodes.SUCCESS - group1_dn = "cn=recursive_test_group1,cn=groups,dc=md,dc=test" - group2_dn = "cn=recursive_test_group2,cn=groups,dc=md,dc=test" - group3_dn = "cn=recursive_test_group3,cn=groups,dc=md,dc=test" + group1_dn = "cn=recursive_test_group1,cn=Groups,dc=md,dc=test" + group2_dn = "cn=recursive_test_group2,cn=Groups,dc=md,dc=test" + group3_dn = "cn=recursive_test_group3,cn=Groups,dc=md,dc=test" user = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" await _create_group(group3_dn, "recursive_test_group3") @@ -411,7 +411,7 @@ async def _add_member(dn: str, member: str) -> None: expected_groups = [group1_dn, group2_dn, group3_dn] for group in expected_groups: assert group in dns - assert "cn=domain admins,cn=groups,dc=md,dc=test" in dns + assert "cn=domain admins,cn=Groups,dc=md,dc=test" in dns @pytest.mark.asyncio From e3eabb88bce229d339e3cac81d839d6df9b9aa32 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 27 Jan 2026 17:51:42 +0300 Subject: [PATCH 6/6] Fix: Correct distinguished name casing in recursive member search test --- tests/test_api/test_main/test_router/test_search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api/test_main/test_router/test_search.py b/tests/test_api/test_main/test_router/test_search.py index 5f1478f95..77baea3f1 100644 --- a/tests/test_api/test_main/test_router/test_search.py +++ b/tests/test_api/test_main/test_router/test_search.py @@ -313,7 +313,7 @@ async def test_search_recursive_member( """Test recursive member search for user0.""" user = "cn=user0,cn=users,dc=md,dc=test" expected_groups = [ - "cn=domain admins,cn=groups,dc=md,dc=test", + "cn=domain admins,cn=Groups,dc=md,dc=test", ] response = await http_client.post( "entry/search",