From 1af18af4fbebe8d838c11bc21422b5932d32e814 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Tue, 27 Jan 2026 17:18:04 +0300 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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: From 88d4905869a2fffd1537afe2accb275b6bda01c4 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Tue, 3 Feb 2026 16:06:16 +0300 Subject: [PATCH 7/8] refactor: fix: delete `host_principal` and refactor task_1110 --- app/entities.py | 4 -- app/ldap_protocol/ldap_requests/add.py | 67 ++++++++++++----------- app/ldap_protocol/ldap_requests/delete.py | 11 ++-- app/ldap_protocol/ldap_requests/modify.py | 14 ++--- app/ldap_protocol/utils/pagination.py | 8 ++- 5 files changed, 55 insertions(+), 49 deletions(-) diff --git a/app/entities.py b/app/entities.py index 9ee945fe6..807df8565 100644 --- a/app/entities.py +++ b/app/entities.py @@ -259,10 +259,6 @@ def get_dn(self, dn: str = "cn") -> str: def is_domain(self) -> bool: return not self.parent_id and self.object_class == "domain" - @property - def host_principal(self) -> str: - return f"host/{self.name}" - @property def path_dn(self) -> str: return ",".join(reversed(self.path)) diff --git a/app/ldap_protocol/ldap_requests/add.py b/app/ldap_protocol/ldap_requests/add.py index a16c6b182..6517c06f3 100644 --- a/app/ldap_protocol/ldap_requests/add.py +++ b/app/ldap_protocol/ldap_requests/add.py @@ -290,6 +290,7 @@ async def handle( # noqa: C901 or "userPrincipalName" in user_attributes ) is_computer = "computer" in self.attrs_dict.get("objectClass", []) + computer_sam_account_name = None if is_user: if not any( @@ -368,33 +369,44 @@ async def handle( # noqa: C901 items_to_add.append(group) group.parent_groups.extend(parent_groups) - elif is_computer and "useraccountcontrol" not in self.l_attrs_dict: - if not any( - group.directory.name.lower() == DOMAIN_COMPUTERS_GROUP_NAME - for group in parent_groups - ): - parent_groups.append( - await get_group( - DOMAIN_COMPUTERS_GROUP_NAME, - ctx.session, - ), - ) - await ctx.session.refresh( - instance=new_dir, - attribute_names=["groups"], - with_for_update=None, - ) - new_dir.groups.extend(parent_groups) + elif is_computer: + computer_sam_account_name = new_dir.name + attributes.append( Attribute( - name="userAccountControl", - value=str( - UserAccountControlFlag.WORKSTATION_TRUST_ACCOUNT, - ), + name="sAMAccountName", + value=computer_sam_account_name, directory_id=new_dir.id, ), ) + if "useraccountcontrol" not in self.l_attrs_dict: + if not any( + group.directory.name.lower() == DOMAIN_COMPUTERS_GROUP_NAME + for group in parent_groups + ): + parent_groups.append( + await get_group( + DOMAIN_COMPUTERS_GROUP_NAME, + ctx.session, + ), + ) + await ctx.session.refresh( + instance=new_dir, + attribute_names=["groups"], + with_for_update=None, + ) + new_dir.groups.extend(parent_groups) + attributes.append( + Attribute( + name="userAccountControl", + value=str( + UserAccountControlFlag.WORKSTATION_TRUST_ACCOUNT, + ), + directory_id=new_dir.id, + ), + ) + if (is_user or is_group) and "gidnumber" not in self.l_attrs_dict: reverse_d_name = new_dir.name[::-1] value = ( @@ -417,15 +429,6 @@ async def handle( # noqa: C901 ), ) - if is_computer: - attributes.append( - Attribute( - name="sAMAccountName", - value=f"{new_dir.name}", - directory_id=new_dir.id, - ), - ) - if not ctx.attribute_value_validator.is_directory_attributes_valid( entity_type.name if entity_type else "", attributes, @@ -477,11 +480,11 @@ async def handle( # noqa: C901 elif is_computer: await ctx.kadmin.add_principal( - f"{new_dir.host_principal}.{base_dn.name}", + f"host/{computer_sam_account_name}.{base_dn.name}", None, ) await ctx.kadmin.add_principal( - new_dir.host_principal, + f"host/{computer_sam_account_name}", None, ) except (KRBAPIAddPrincipalError, KRBAPIConnectionError): diff --git a/app/ldap_protocol/ldap_requests/delete.py b/app/ldap_protocol/ldap_requests/delete.py index 3bf89343b..401a5b98f 100644 --- a/app/ldap_protocol/ldap_requests/delete.py +++ b/app/ldap_protocol/ldap_requests/delete.py @@ -157,10 +157,13 @@ async def handle( # noqa: C901 await ctx.kadmin.del_principal(directory.user.sam_account_name) if await is_computer(directory.id, ctx.session): - await ctx.kadmin.del_principal(directory.host_principal) - await ctx.kadmin.del_principal( - f"{directory.host_principal}.{base_dn.name}", - ) + computer_sam_account_names = directory.attributes_dict.get("sAMAccountName") # noqa: E501 # fmt: skip + if computer_sam_account_names: + computer_sam_account_name = computer_sam_account_names[0] + await ctx.kadmin.del_principal(f"host/{computer_sam_account_name}") # noqa: E501 # fmt: skip + await ctx.kadmin.del_principal(f"host/{computer_sam_account_name}.{base_dn.name}") # noqa: E501 # fmt: skip + else: + raise KRBAPIDeletePrincipalError except KRBAPIPrincipalNotFoundError: pass except (KRBAPIDeletePrincipalError, KRBAPIConnectionError): diff --git a/app/ldap_protocol/ldap_requests/modify.py b/app/ldap_protocol/ldap_requests/modify.py index bc55d6403..2abdc1d39 100644 --- a/app/ldap_protocol/ldap_requests/modify.py +++ b/app/ldap_protocol/ldap_requests/modify.py @@ -8,7 +8,7 @@ from typing import AsyncGenerator, ClassVar from loguru import logger -from pydantic import Field +from pydantic import PrivateAttr from sqlalchemy import Select, and_, delete, func, or_, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -111,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] = Field(default_factory=dict) + _old_vals: dict[str, str | None] = PrivateAttr(default_factory=dict) @classmethod def from_data(cls, data: list[ASN1Row]) -> "ModifyRequest": @@ -637,7 +637,7 @@ def _need_to_cache_samaccountname_old_value( directory.entity_type and directory.entity_type.name == EntityTypeNames.COMPUTER and change.l_type == "samaccountname" - and not self.old_vals.get(change.modification.type), + and not self._old_vals.get(change.modification.type), ) async def _delete( @@ -693,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 = ( @@ -1035,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: @@ -1066,8 +1066,6 @@ async def _get_base_dir( base_dir = base_directory break else: - raise ModifyForbiddenError( - "Base directory for computer not found.", - ) + raise ModifyForbiddenError("Base directory not found.") return base_dir diff --git a/app/ldap_protocol/utils/pagination.py b/app/ldap_protocol/utils/pagination.py index 5e4ef6e4b..6d2d60a96 100644 --- a/app/ldap_protocol/utils/pagination.py +++ b/app/ldap_protocol/utils/pagination.py @@ -95,6 +95,12 @@ class PaginationResult[S, P]: metadata: PaginationMetadata items: Sequence[P] + @classmethod + def _validate_query(cls, query: Select[tuple[S]]) -> bool: + return not ( + query._order_by_clause is None or len(query._order_by_clause) == 0 # noqa SLF001 + ) + @classmethod async def get( cls, @@ -104,7 +110,7 @@ async def get( session: AsyncSession, ) -> Self: """Get paginator.""" - if query._order_by_clause is None or len(query._order_by_clause) == 0: # noqa: SLF001 + if not cls._validate_query(query): raise ValueError("Select query must have an order_by clause.") metadata = PaginationMetadata( From e449199147e3205e561e969a2518b74d503db0b2 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Tue, 3 Feb 2026 16:44:22 +0300 Subject: [PATCH 8/8] fix: noqa task_1110 --- app/ldap_protocol/utils/pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ldap_protocol/utils/pagination.py b/app/ldap_protocol/utils/pagination.py index 6d2d60a96..34f9788d3 100644 --- a/app/ldap_protocol/utils/pagination.py +++ b/app/ldap_protocol/utils/pagination.py @@ -98,7 +98,7 @@ class PaginationResult[S, P]: @classmethod def _validate_query(cls, query: Select[tuple[S]]) -> bool: return not ( - query._order_by_clause is None or len(query._order_by_clause) == 0 # noqa SLF001 + query._order_by_clause is None or len(query._order_by_clause) == 0 # noqa: SLF001 ) @classmethod