From 1af18af4fbebe8d838c11bc21422b5932d32e814 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Tue, 27 Jan 2026 17:18:04 +0300 Subject: [PATCH 1/4] add: rename route task_1110 --- app/api/main/router.py | 10 ++++ app/api/main/schema.py | 45 +++++++++++++++++- interface | 2 +- .../test_main/test_router/test_rename.py | 46 +++++++++++++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tests/test_api/test_main/test_router/test_rename.py diff --git a/app/api/main/router.py b/app/api/main/router.py index f26881b38..482f5cb5c 100644 --- a/app/api/main/router.py +++ b/app/api/main/router.py @@ -29,6 +29,7 @@ from .schema import ( PrimaryGroupRequest, + RenameRequest, SearchRequest, SearchResponse, SearchResultDone, @@ -123,6 +124,15 @@ async def modify_dn_many( return results +@entry_router.put("/rename", error_map=error_map) +async def rename( + request: RenameRequest, + req: Request, +) -> LDAPResult: + """LDAP rename entry request.""" + return await request.handle_api(req.state.dishka_container) + + @entry_router.delete("/delete", error_map=error_map) async def delete( request: DeleteRequest, diff --git a/app/api/main/schema.py b/app/api/main/schema.py index 537b0af7c..3f3a9e6b6 100644 --- a/app/api/main/schema.py +++ b/app/api/main/schema.py @@ -18,8 +18,17 @@ FilterInterpreterProtocol, StringFilterInterpreter, ) -from ldap_protocol.ldap_requests import SearchRequest as LDAPSearchRequest -from ldap_protocol.ldap_responses import SearchResultDone, SearchResultEntry +from ldap_protocol.ldap_requests import ( + ModifyDNRequest as LDAPModifyDNRequest, + ModifyRequest as LDAPModifyRequest, + SearchRequest as LDAPSearchRequest, +) +from ldap_protocol.ldap_responses import ( + LDAPResult, + SearchResultDone, + SearchResultEntry, +) +from ldap_protocol.objects import Changes from ldap_protocol.utils.const import GRANT_DN_STRING @@ -153,3 +162,35 @@ class PrimaryGroupRequest(BaseModel): directory_dn: GRANT_DN_STRING group_dn: GRANT_DN_STRING + + +class RenameRequest(BaseModel): + """Rename request schema. + + Combines ModifyDN and Modify operations. + """ + + object: str + newrdn: str + changes: list[Changes] + + async def handle_api(self, container: AsyncContainer) -> LDAPResult: + """Handle rename request by executing ModifyDN then Modify.""" + modify_request = LDAPModifyRequest( + object=self.object, + changes=self.changes, + ) + result = await modify_request.handle_api(container) + + if not result or result.result_code != 0: + return result + + modify_dn_request = LDAPModifyDNRequest( + entry=self.object, + newrdn=self.newrdn, + deleteoldrdn=True, + new_superior=None, + ) + result = await modify_dn_request.handle_api(container) + + return result diff --git a/interface b/interface index f31962020..e1ca5656a 160000 --- a/interface +++ b/interface @@ -1 +1 @@ -Subproject commit f31962020a6689e6a4c61fb3349db5b5c7895f92 +Subproject commit e1ca5656aeabc20a1862aeaf11ded72feaa97403 diff --git a/tests/test_api/test_main/test_router/test_rename.py b/tests/test_api/test_main/test_router/test_rename.py new file mode 100644 index 000000000..90eba7a21 --- /dev/null +++ b/tests/test_api/test_main/test_router/test_rename.py @@ -0,0 +1,46 @@ +"""Test API Rename. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +import pytest +from httpx import AsyncClient + +from ldap_protocol.ldap_codes import LDAPCodes +from ldap_protocol.ldap_requests.modify import Operation + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("adding_test_user") +@pytest.mark.usefixtures("setup_session") +@pytest.mark.usefixtures("session") +async def test_api_correct_rename(http_client: AsyncClient) -> None: + response = await http_client.put( + "/entry/rename", + json={ + "object": "cn=test,dc=md,dc=test", + "newrdn": "cn=admin2", + "changes": [ + { + "operation": Operation.REPLACE, + "modification": { + "type": "sAMAccountName", + "vals": ["admin2"], + }, + }, + { + "operation": Operation.REPLACE, + "modification": { + "type": "displayName", + "vals": ["Administrator"], + }, + }, + ], + }, + ) + + data = response.json() + + assert isinstance(data, dict) + assert data.get("resultCode") == LDAPCodes.SUCCESS From 944ce52b036b9c9bd4f1de0c3798e02cbe2c8829 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Wed, 28 Jan 2026 19:47:25 +0300 Subject: [PATCH 2/4] refactor: rename request task_1110 --- app/api/main/schema.py | 65 ++++++++++--- tests/test_api/test_main/conftest.py | 24 +++++ .../test_main/test_router/test_rename.py | 94 ++++++++++++++++++- 3 files changed, 169 insertions(+), 14 deletions(-) diff --git a/app/api/main/schema.py b/app/api/main/schema.py index 3f3a9e6b6..c838cdfa1 100644 --- a/app/api/main/schema.py +++ b/app/api/main/schema.py @@ -4,11 +4,13 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ +from functools import cached_property from ipaddress import IPv4Address, IPv6Address from typing import final from dishka import AsyncContainer from pydantic import BaseModel, Field, PrivateAttr, SecretStr +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.elements import ColumnElement, UnaryExpression from entities import Directory @@ -174,23 +176,60 @@ class RenameRequest(BaseModel): newrdn: str changes: list[Changes] - async def handle_api(self, container: AsyncContainer) -> LDAPResult: - """Handle rename request by executing ModifyDN then Modify.""" - modify_request = LDAPModifyRequest( - object=self.object, - changes=self.changes, - ) - result = await modify_request.handle_api(container) + @cached_property + def _new_object(self) -> str: + return f"{self.newrdn},{','.join(self.object.split(',')[1:])}" - if not result or result.result_code != 0: - return result + @cached_property + def _oldrdn(self) -> str: + return self.object.split(",")[0] + async def _modify_dn_request( + self, + container: AsyncContainer, + entry: str, + newrdn: str, + ) -> LDAPResult: modify_dn_request = LDAPModifyDNRequest( - entry=self.object, - newrdn=self.newrdn, + entry=entry, + newrdn=newrdn, deleteoldrdn=True, new_superior=None, ) - result = await modify_dn_request.handle_api(container) + return await modify_dn_request.handle_api(container) + + async def _clear_session_cache(self, container: AsyncContainer) -> None: + session = await container.get(AsyncSession) + session.expire_all() + + async def _modify_request(self, container: AsyncContainer) -> LDAPResult: + modify_request = LDAPModifyRequest( + object=self._new_object, + changes=self.changes, + ) + return await modify_request.handle_api(container) + + async def handle_api(self, container: AsyncContainer) -> LDAPResult: + """Handle RenameRequest by executing ModifyDN then Modify. + + If ModifyRequest fails, rollback the ModifyDnRequest and return error. + """ + modify_dn_response = await self._modify_dn_request( + container, + self.object, + self.newrdn, + ) + if not modify_dn_response or modify_dn_response.result_code != 0: + return modify_dn_response + + await self._clear_session_cache(container) + + modify_response = await self._modify_request(container) + if not modify_response or modify_response.result_code != 0: + await self._modify_dn_request( + container, + self._new_object, + self._oldrdn, + ) - return result + return modify_response diff --git a/tests/test_api/test_main/conftest.py b/tests/test_api/test_main/conftest.py index 3094ac1db..1ee2f69ba 100644 --- a/tests/test_api/test_main/conftest.py +++ b/tests/test_api/test_main/conftest.py @@ -138,6 +138,30 @@ async def adding_test_user( assert auth.cookies.get("id") +@pytest_asyncio.fixture(scope="function") +async def adding_test_computer( + http_client: AsyncClient, +) -> None: + """Test api correct (name) add.""" + response = await http_client.post( + "/entry/add", + json={ + "entry": "cn=mycomputer,dc=md,dc=test", + "password": None, + "attributes": [ + {"type": "name", "vals": ["mycomputer name"]}, + {"type": "cn", "vals": ["mycomputer"]}, + {"type": "objectClass", "vals": ["computer", "top"]}, + ], + }, + ) + + data = response.json() + + assert isinstance(data, dict) + assert data.get("resultCode") == LDAPCodes.SUCCESS + + @pytest_asyncio.fixture(scope="function") async def add_dns_settings( session: AsyncSession, diff --git a/tests/test_api/test_main/test_router/test_rename.py b/tests/test_api/test_main/test_router/test_rename.py index 90eba7a21..3fe6c6d8d 100644 --- a/tests/test_api/test_main/test_router/test_rename.py +++ b/tests/test_api/test_main/test_router/test_rename.py @@ -41,6 +41,98 @@ async def test_api_correct_rename(http_client: AsyncClient) -> None: ) data = response.json() - assert isinstance(data, dict) assert data.get("resultCode") == LDAPCodes.SUCCESS + + response = await http_client.post( + "entry/search", + json={ + "base_object": "cn=admin2,dc=md,dc=test", + "scope": 0, + "deref_aliases": 0, + "size_limit": 1000, + "time_limit": 10, + "types_only": True, + "filter": "(objectClass=*)", + "attributes": ["*"], + "page_number": 1, + }, + ) + + data = response.json() + assert data["resultCode"] == LDAPCodes.SUCCESS + assert data["search_result"][0]["object_name"] == "cn=admin2,dc=md,dc=test" + + for attr in data["search_result"][0]["partial_attributes"]: + if attr["type"] == "sAMAccountName": + assert attr["vals"][0] == "admin2" + break + else: + raise Exception("User without sAMAccountName") + + for attr in data["search_result"][0]["partial_attributes"]: + if attr["type"] == "displayName": + assert attr["vals"][0] == "Administrator" + break + else: + raise Exception("User without displayName") + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("adding_test_computer") +@pytest.mark.usefixtures("setup_session") +@pytest.mark.usefixtures("session") +async def test_api_correct_rename_computer(http_client: AsyncClient) -> None: + response = await http_client.put( + "/entry/rename", + json={ + "object": "cn=mycomputer,dc=md,dc=test", + "newrdn": "cn=maincomputer", + "changes": [ + { + "operation": Operation.REPLACE, + "modification": { + "type": "sAMAccountName", + "vals": ["main computer"], + }, + }, + { + "operation": Operation.REPLACE, + "modification": { + "type": "displayName", + "vals": ["main computer"], + }, + }, + ], + }, + ) + + data = response.json() + assert isinstance(data, dict) + assert data.get("resultCode") == LDAPCodes.UNDEFINED_ATTRIBUTE_TYPE + + response = await http_client.post( + "entry/search", + json={ + "base_object": "cn=mycomputer,dc=md,dc=test", + "scope": 0, + "deref_aliases": 0, + "size_limit": 1000, + "time_limit": 10, + "types_only": True, + "filter": "(objectClass=*)", + "attributes": ["*"], + "page_number": 1, + }, + ) + + data = response.json() + assert data["resultCode"] == LDAPCodes.SUCCESS + assert data["search_result"][0]["object_name"] == "cn=mycomputer,dc=md,dc=test" # noqa: E501 # fmt: skip + + for attr in data["search_result"][0]["partial_attributes"]: + if attr["type"] == "name": + assert attr["vals"][0] == "mycomputer name" + break + else: + raise Exception("Computer without name") From 66fc7dd58c3f37c1645d1a0c19ca2d9fe81e7863 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Fri, 30 Jan 2026 12:45:04 +0300 Subject: [PATCH 3/4] refactor: fix pr comments task_1110 --- app/api/main/schema.py | 9 ++++----- tests/test_api/test_main/test_router/test_rename.py | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/app/api/main/schema.py b/app/api/main/schema.py index c838cdfa1..bcaa1d2ff 100644 --- a/app/api/main/schema.py +++ b/app/api/main/schema.py @@ -4,7 +4,6 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from functools import cached_property from ipaddress import IPv4Address, IPv6Address from typing import final @@ -176,11 +175,11 @@ class RenameRequest(BaseModel): newrdn: str changes: list[Changes] - @cached_property + @property def _new_object(self) -> str: return f"{self.newrdn},{','.join(self.object.split(',')[1:])}" - @cached_property + @property def _oldrdn(self) -> str: return self.object.split(",")[0] @@ -198,7 +197,7 @@ async def _modify_dn_request( ) return await modify_dn_request.handle_api(container) - async def _clear_session_cache(self, container: AsyncContainer) -> None: + async def _expire_session_objects(self, container: AsyncContainer) -> None: session = await container.get(AsyncSession) session.expire_all() @@ -222,7 +221,7 @@ async def handle_api(self, container: AsyncContainer) -> LDAPResult: if not modify_dn_response or modify_dn_response.result_code != 0: return modify_dn_response - await self._clear_session_cache(container) + await self._expire_session_objects(container) modify_response = await self._modify_request(container) if not modify_response or modify_response.result_code != 0: diff --git a/tests/test_api/test_main/test_router/test_rename.py b/tests/test_api/test_main/test_router/test_rename.py index 3fe6c6d8d..e86804f39 100644 --- a/tests/test_api/test_main/test_router/test_rename.py +++ b/tests/test_api/test_main/test_router/test_rename.py @@ -15,7 +15,7 @@ @pytest.mark.usefixtures("adding_test_user") @pytest.mark.usefixtures("setup_session") @pytest.mark.usefixtures("session") -async def test_api_correct_rename(http_client: AsyncClient) -> None: +async def test_api_correct_rename_user(http_client: AsyncClient) -> None: response = await http_client.put( "/entry/rename", json={ @@ -93,14 +93,14 @@ async def test_api_correct_rename_computer(http_client: AsyncClient) -> None: "operation": Operation.REPLACE, "modification": { "type": "sAMAccountName", - "vals": ["main computer"], + "vals": ["__invalid name for error__"], }, }, { "operation": Operation.REPLACE, "modification": { "type": "displayName", - "vals": ["main computer"], + "vals": ["Main Computer"], }, }, ], From a278193a06e0f0290def4d265667d684f77e0952 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Fri, 30 Jan 2026 14:57:12 +0300 Subject: [PATCH 4/4] refactor: moved RenameRequest task_1110 --- app/api/main/router.py | 2 +- app/api/main/schema.py | 83 +------------------- app/ldap_protocol/ldap_requests/__init__.py | 3 +- app/ldap_protocol/ldap_requests/rename.py | 85 +++++++++++++++++++++ 4 files changed, 90 insertions(+), 83 deletions(-) create mode 100644 app/ldap_protocol/ldap_requests/rename.py diff --git a/app/api/main/router.py b/app/api/main/router.py index 482f5cb5c..63d56516d 100644 --- a/app/api/main/router.py +++ b/app/api/main/router.py @@ -23,13 +23,13 @@ DeleteRequest, ModifyDNRequest, ModifyRequest, + RenameRequest, ) from ldap_protocol.ldap_responses import LDAPResult from ldap_protocol.utils.queries import set_or_update_primary_group from .schema import ( PrimaryGroupRequest, - RenameRequest, SearchRequest, SearchResponse, SearchResultDone, diff --git a/app/api/main/schema.py b/app/api/main/schema.py index bcaa1d2ff..537b0af7c 100644 --- a/app/api/main/schema.py +++ b/app/api/main/schema.py @@ -9,7 +9,6 @@ from dishka import AsyncContainer from pydantic import BaseModel, Field, PrivateAttr, SecretStr -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.elements import ColumnElement, UnaryExpression from entities import Directory @@ -19,17 +18,8 @@ FilterInterpreterProtocol, StringFilterInterpreter, ) -from ldap_protocol.ldap_requests import ( - ModifyDNRequest as LDAPModifyDNRequest, - ModifyRequest as LDAPModifyRequest, - SearchRequest as LDAPSearchRequest, -) -from ldap_protocol.ldap_responses import ( - LDAPResult, - SearchResultDone, - SearchResultEntry, -) -from ldap_protocol.objects import Changes +from ldap_protocol.ldap_requests import SearchRequest as LDAPSearchRequest +from ldap_protocol.ldap_responses import SearchResultDone, SearchResultEntry from ldap_protocol.utils.const import GRANT_DN_STRING @@ -163,72 +153,3 @@ class PrimaryGroupRequest(BaseModel): directory_dn: GRANT_DN_STRING group_dn: GRANT_DN_STRING - - -class RenameRequest(BaseModel): - """Rename request schema. - - Combines ModifyDN and Modify operations. - """ - - object: str - newrdn: str - changes: list[Changes] - - @property - def _new_object(self) -> str: - return f"{self.newrdn},{','.join(self.object.split(',')[1:])}" - - @property - def _oldrdn(self) -> str: - return self.object.split(",")[0] - - async def _modify_dn_request( - self, - container: AsyncContainer, - entry: str, - newrdn: str, - ) -> LDAPResult: - modify_dn_request = LDAPModifyDNRequest( - entry=entry, - newrdn=newrdn, - deleteoldrdn=True, - new_superior=None, - ) - return await modify_dn_request.handle_api(container) - - async def _expire_session_objects(self, container: AsyncContainer) -> None: - session = await container.get(AsyncSession) - session.expire_all() - - async def _modify_request(self, container: AsyncContainer) -> LDAPResult: - modify_request = LDAPModifyRequest( - object=self._new_object, - changes=self.changes, - ) - return await modify_request.handle_api(container) - - async def handle_api(self, container: AsyncContainer) -> LDAPResult: - """Handle RenameRequest by executing ModifyDN then Modify. - - If ModifyRequest fails, rollback the ModifyDnRequest and return error. - """ - modify_dn_response = await self._modify_dn_request( - container, - self.object, - self.newrdn, - ) - if not modify_dn_response or modify_dn_response.result_code != 0: - return modify_dn_response - - await self._expire_session_objects(container) - - modify_response = await self._modify_request(container) - if not modify_response or modify_response.result_code != 0: - await self._modify_dn_request( - container, - self._new_object, - self._oldrdn, - ) - - return modify_response diff --git a/app/ldap_protocol/ldap_requests/__init__.py b/app/ldap_protocol/ldap_requests/__init__.py index 90ff4cdd8..cb44b3060 100644 --- a/app/ldap_protocol/ldap_requests/__init__.py +++ b/app/ldap_protocol/ldap_requests/__init__.py @@ -12,6 +12,7 @@ from .extended import ExtendedRequest from .modify import ModifyRequest from .modify_dn import ModifyDNRequest +from .rename import RenameRequest from .search import SearchRequest requests: list[type[BaseRequest]] = [ @@ -32,4 +33,4 @@ } -__all__ = ["protocol_id_map", "BaseRequest"] +__all__ = ["protocol_id_map", "BaseRequest", "RenameRequest"] diff --git a/app/ldap_protocol/ldap_requests/rename.py b/app/ldap_protocol/ldap_requests/rename.py new file mode 100644 index 000000000..f8ee17667 --- /dev/null +++ b/app/ldap_protocol/ldap_requests/rename.py @@ -0,0 +1,85 @@ +"""Schemas for main router. + +Copyright (c) 2024 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from dishka import AsyncContainer +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from ldap_protocol.ldap_requests import ( + ModifyDNRequest as LDAPModifyDNRequest, + ModifyRequest as LDAPModifyRequest, +) +from ldap_protocol.ldap_responses import LDAPResult +from ldap_protocol.objects import Changes + + +class RenameRequest(BaseModel): + """Rename request schema. + + Combines ModifyDN and Modify operations. + """ + + object: str + newrdn: str + changes: list[Changes] + + @property + def _new_object(self) -> str: + return f"{self.newrdn},{','.join(self.object.split(',')[1:])}" + + @property + def _oldrdn(self) -> str: + return self.object.split(",")[0] + + async def _modify_dn_request( + self, + container: AsyncContainer, + entry: str, + newrdn: str, + ) -> LDAPResult: + modify_dn_request = LDAPModifyDNRequest( + entry=entry, + newrdn=newrdn, + deleteoldrdn=True, + new_superior=None, + ) + return await modify_dn_request.handle_api(container) + + async def _expire_session_objects(self, container: AsyncContainer) -> None: + session = await container.get(AsyncSession) + session.expire_all() + + async def _modify_request(self, container: AsyncContainer) -> LDAPResult: + modify_request = LDAPModifyRequest( + object=self._new_object, + changes=self.changes, + ) + return await modify_request.handle_api(container) + + async def handle_api(self, container: AsyncContainer) -> LDAPResult: + """Handle RenameRequest by executing ModifyDN then Modify. + + If ModifyRequest fails, rollback the ModifyDnRequest and return error. + """ + modify_dn_response = await self._modify_dn_request( + container, + self.object, + self.newrdn, + ) + if not modify_dn_response or modify_dn_response.result_code != 0: + return modify_dn_response + + await self._expire_session_objects(container) + + modify_response = await self._modify_request(container) + if not modify_response or modify_response.result_code != 0: + await self._modify_dn_request( + container, + self._new_object, + self._oldrdn, + ) + + return modify_response