From 1af18af4fbebe8d838c11bc21422b5932d32e814 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Tue, 27 Jan 2026 17:18:04 +0300 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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 From e4dc2f19fea98639aa8e2c727a4f8faefc3f7786 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Tue, 3 Feb 2026 12:05:38 +0300 Subject: [PATCH 5/6] refactor: move RenameReq to correct folder task_1110 --- app/api/main/router.py | 33 ++++--------------- app/ldap_protocol/custom_requests/__init__.py | 9 +++++ .../rename.py | 6 ++-- app/ldap_protocol/ldap_requests/__init__.py | 3 +- 4 files changed, 20 insertions(+), 31 deletions(-) create mode 100644 app/ldap_protocol/custom_requests/__init__.py rename app/ldap_protocol/{ldap_requests => custom_requests}/rename.py (95%) diff --git a/app/api/main/router.py b/app/api/main/router.py index 63d56516d..59250708b 100644 --- a/app/api/main/router.py +++ b/app/api/main/router.py @@ -17,13 +17,13 @@ DomainErrorTranslator, ) from enums import DomainCodes +from ldap_protocol.custom_requests.rename import RenameRequest from ldap_protocol.identity.exceptions import UnauthorizedError from ldap_protocol.ldap_requests import ( AddRequest, DeleteRequest, ModifyDNRequest, ModifyRequest, - RenameRequest, ) from ldap_protocol.ldap_responses import LDAPResult from ldap_protocol.utils.queries import set_or_update_primary_group @@ -38,7 +38,6 @@ translator = DomainErrorTranslator(DomainCodes.LDAP) - error_map: ERROR_MAP_TYPE = { UnauthorizedError: rule( status=status.HTTP_401_UNAUTHORIZED, @@ -55,10 +54,7 @@ @entry_router.post("/search", error_map=error_map) -async def search( - request: SearchRequest, - req: Request, -) -> SearchResponse: +async def search(request: SearchRequest, req: Request) -> SearchResponse: """LDAP SEARCH entry request.""" responses = await request.handle_api(req.state.dishka_container) metadata: SearchResultDone = responses.pop(-1) # type: ignore @@ -74,19 +70,13 @@ async def search( @entry_router.post("/add", error_map=error_map) -async def add( - request: AddRequest, - req: Request, -) -> LDAPResult: +async def add(request: AddRequest, req: Request) -> LDAPResult: """LDAP ADD entry request.""" return await request.handle_api(req.state.dishka_container) @entry_router.patch("/update", error_map=error_map) -async def modify( - request: ModifyRequest, - req: Request, -) -> LDAPResult: +async def modify(request: ModifyRequest, req: Request) -> LDAPResult: """LDAP MODIFY entry request.""" return await request.handle_api(req.state.dishka_container) @@ -104,10 +94,7 @@ async def modify_many( @entry_router.put("/update/dn", error_map=error_map) -async def modify_dn( - request: ModifyDNRequest, - req: Request, -) -> LDAPResult: +async def modify_dn(request: ModifyDNRequest, req: Request) -> LDAPResult: """LDAP MODIFY entry DN request.""" return await request.handle_api(req.state.dishka_container) @@ -125,19 +112,13 @@ async def modify_dn_many( @entry_router.put("/rename", error_map=error_map) -async def rename( - request: RenameRequest, - req: Request, -) -> LDAPResult: +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, - req: Request, -) -> LDAPResult: +async def delete(request: DeleteRequest, req: Request) -> LDAPResult: """LDAP DELETE entry request.""" return await request.handle_api(req.state.dishka_container) diff --git a/app/ldap_protocol/custom_requests/__init__.py b/app/ldap_protocol/custom_requests/__init__.py new file mode 100644 index 000000000..2f7a89149 --- /dev/null +++ b/app/ldap_protocol/custom_requests/__init__.py @@ -0,0 +1,9 @@ +"""Custom Requests. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from .rename import RenameRequest + +__all__ = ["RenameRequest"] diff --git a/app/ldap_protocol/ldap_requests/rename.py b/app/ldap_protocol/custom_requests/rename.py similarity index 95% rename from app/ldap_protocol/ldap_requests/rename.py rename to app/ldap_protocol/custom_requests/rename.py index f8ee17667..1748112f8 100644 --- a/app/ldap_protocol/ldap_requests/rename.py +++ b/app/ldap_protocol/custom_requests/rename.py @@ -1,6 +1,6 @@ -"""Schemas for main router. +"""RenameRequest for main router. -Copyright (c) 2024 MultiFactor +Copyright (c) 2026 MultiFactor License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ @@ -17,7 +17,7 @@ class RenameRequest(BaseModel): - """Rename request schema. + """Rename Request. It's not RFC 4511. Combines ModifyDN and Modify operations. """ diff --git a/app/ldap_protocol/ldap_requests/__init__.py b/app/ldap_protocol/ldap_requests/__init__.py index cb44b3060..90ff4cdd8 100644 --- a/app/ldap_protocol/ldap_requests/__init__.py +++ b/app/ldap_protocol/ldap_requests/__init__.py @@ -12,7 +12,6 @@ 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]] = [ @@ -33,4 +32,4 @@ } -__all__ = ["protocol_id_map", "BaseRequest", "RenameRequest"] +__all__ = ["protocol_id_map", "BaseRequest"] From f1d4fff5f557f8a84c209e31257a33cfd9a5d504 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Tue, 3 Feb 2026 13:37:44 +0300 Subject: [PATCH 6/6] refactor: fix copilot notes task_1110 --- app/ldap_protocol/ldap_requests/modify.py | 36 ++++++++++++++++------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/app/ldap_protocol/ldap_requests/modify.py b/app/ldap_protocol/ldap_requests/modify.py index 7ab3333fb..bc55d6403 100644 --- a/app/ldap_protocol/ldap_requests/modify.py +++ b/app/ldap_protocol/ldap_requests/modify.py @@ -8,6 +8,7 @@ from typing import AsyncGenerator, ClassVar from loguru import logger +from pydantic import Field from sqlalchemy import Select, and_, delete, func, or_, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -110,7 +111,7 @@ class ModifyRequest(BaseRequest): # NOTE: If the old value was changed (for example, in _delete) # in one method, then you need to have access to the old value # from other methods (for example, from _add) - _old_vals: dict[str, str | None] = {} + old_vals: dict[str, str | None] = Field(default_factory=dict) @classmethod def from_data(cls, data: list[ASN1Row]) -> "ModifyRequest": @@ -143,7 +144,7 @@ async def _update_password_expiration( return if not ( - change.modification.type == "krbpasswordexpiration" + change.l_type == "krbpasswordexpiration" and change.modification.vals[0] == "19700101000000Z" ): return @@ -284,10 +285,10 @@ async def handle( except MODIFY_EXCEPTION_STACK as err: await ctx.session.rollback() - result_code, message = self._match_bad_response(err) + result_code, error_message = self._match_bad_response(err) yield ModifyResponse( result_code=result_code, - message=message, + error_message=error_message, ) return @@ -333,6 +334,9 @@ def _match_bad_response(self, err: BaseException) -> tuple[LDAPCodes, str]: case ModifyForbiddenError(): return LDAPCodes.OPERATIONS_ERROR, str(err) + case KRBAPIRenamePrincipalError(): + return LDAPCodes.UNAVAILABLE, "Kerberos error" + case KRBAPIPrincipalNotFoundError(): return LDAPCodes.UNAVAILABLE, "Kerberos error" @@ -632,8 +636,8 @@ def _need_to_cache_samaccountname_old_value( return bool( directory.entity_type and directory.entity_type.name == EntityTypeNames.COMPUTER - and change.modification.type == "sAMAccountName" - and not self._old_vals.get(change.modification.type), + and change.l_type == "samaccountname" + and not self.old_vals.get(change.modification.type), ) async def _delete( @@ -689,7 +693,7 @@ async def _delete( if self._need_to_cache_samaccountname_old_value(change, directory): vals = directory.attributes_dict.get(change.modification.type) if vals: - self._old_vals[change.modification.type] = vals[0] + self.old_vals[change.modification.type] = vals[0] if attrs: del_query = ( @@ -826,14 +830,13 @@ async def _add( # noqa: C901 password_use_cases: PasswordPolicyUseCases, password_utils: PasswordUtils, ) -> None: + base_dir = None attrs = [] if change.l_type in ("memberof", "member", "primarygroupid"): await self._add_group_attrs(change, directory, session) return - base_dir = await self._get_base_dir(directory, session) - for value in change.modification.vals: if change.l_type == "useraccountcontrol": uac_val = int(value) @@ -923,6 +926,12 @@ async def _add( # noqa: C901 new_user_principal_name = str(new_value) new_sam_account_name = new_user_principal_name.split("@")[0] # noqa: E501 # fmt: skip elif change.l_type == "samaccountname": + if not base_dir: + base_dir = await self._get_base_dir( + directory, + session, + ) + new_sam_account_name = str(new_value) new_user_principal_name = f"{new_sam_account_name}@{base_dir.name}" # noqa: E501 # fmt: skip @@ -946,12 +955,19 @@ async def _add( # noqa: C901 and directory.entity_type and directory.entity_type.name == EntityTypeNames.COMPUTER ): + if not base_dir: + base_dir = await self._get_base_dir( + directory, + session, + ) + await self._modify_computer_samaccountname( change, kadmin, base_dir, value, ) + attrs.append( Attribute( name=change.modification.type, @@ -1019,7 +1035,7 @@ async def _modify_computer_samaccountname( base_dir: Directory, new_sam_account_name: bytes | str, ) -> None: - old_sam_account_name = self._old_vals.get(change.modification.type) + old_sam_account_name = self.old_vals.get(change.modification.type) new_sam_account_name = str(new_sam_account_name) if not old_sam_account_name: