From d650a27cb5914329550ca512db2581045a13ff72 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Thu, 22 Jan 2026 16:26:31 +0300 Subject: [PATCH 01/45] Fix: GC logs (#907) --- docker-compose.dev.yml | 1 + docker-compose.yml | 1 + interface | 2 +- logs/.gitignore | 0 4 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 logs/.gitignore diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a28c8eae4..b07d3a899 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -143,6 +143,7 @@ services: volumes: - ./app:/app - ./certs:/certs + - ./logs:/app/logs - ldap_keytab:/LDAP_keytab/ env_file: local.env command: python -OO multidirectory.py --global_ldap_server diff --git a/docker-compose.yml b/docker-compose.yml index 543042d34..f44f52f81 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -137,6 +137,7 @@ services: volumes: - ./app:/app - ./certs:/certs + - ./logs:/app/logs - ldap_keytab:/LDAP_keytab/ env_file: local.env command: python -OO multidirectory.py --global_ldap_server diff --git a/interface b/interface index f31962020..95ed5e191 160000 --- a/interface +++ b/interface @@ -1 +1 @@ -Subproject commit f31962020a6689e6a4c61fb3349db5b5c7895f92 +Subproject commit 95ed5e191cdafa07b1dfac96a1659926679ead97 diff --git a/logs/.gitignore b/logs/.gitignore new file mode 100644 index 000000000..e69de29bb From 3871a0371c9687da482f8faca3e9d3535a6a6d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=9C=D0=B8=D1=85?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2?= <90135860+TheMihMih@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:22:36 +0300 Subject: [PATCH 02/45] add: cached get_base_directories (#889) --- .../16a9fa2c1f1e_rename_readonly_group.py | 2 - .../71e642808369_add_directory_is_system.py | 9 +++- app/ldap_protocol/auth/setup_gateway.py | 4 +- app/ldap_protocol/utils/async_cache.py | 43 +++++++++++++++++++ app/ldap_protocol/utils/queries.py | 12 +++++- 5 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 app/ldap_protocol/utils/async_cache.py diff --git a/app/alembic/versions/16a9fa2c1f1e_rename_readonly_group.py b/app/alembic/versions/16a9fa2c1f1e_rename_readonly_group.py index b331dddd5..3cfbca4a2 100644 --- a/app/alembic/versions/16a9fa2c1f1e_rename_readonly_group.py +++ b/app/alembic/versions/16a9fa2c1f1e_rename_readonly_group.py @@ -43,7 +43,6 @@ def upgrade(container: AsyncContainer) -> None: # noqa: ARG001 return ro_dir.name = READ_ONLY_GROUP_NAME - ro_dir.create_path(ro_dir.parent, ro_dir.get_dn_prefix()) session.execute( @@ -91,7 +90,6 @@ def downgrade(container: AsyncContainer) -> None: # noqa: ARG001 return ro_dir.name = "readonly domain controllers" - ro_dir.create_path(ro_dir.parent, ro_dir.get_dn_prefix()) session.execute( diff --git a/app/alembic/versions/71e642808369_add_directory_is_system.py b/app/alembic/versions/71e642808369_add_directory_is_system.py index 2526190e4..48ece1bc4 100644 --- a/app/alembic/versions/71e642808369_add_directory_is_system.py +++ b/app/alembic/versions/71e642808369_add_directory_is_system.py @@ -56,8 +56,13 @@ async def _indicate_system_directories( if not base_dn_list: return - for base_dn in base_dn_list: - base_dn.is_system = True + await session.execute( + update(Directory) + .where( + qa(Directory.parent_id).is_(None), + ) + .values(is_system=True), + ) await session.flush() diff --git a/app/ldap_protocol/auth/setup_gateway.py b/app/ldap_protocol/auth/setup_gateway.py index b5bfe580a..4294066c5 100644 --- a/app/ldap_protocol/auth/setup_gateway.py +++ b/app/ldap_protocol/auth/setup_gateway.py @@ -16,6 +16,7 @@ AttributeValueValidator, ) from ldap_protocol.ldap_schema.entity_type_dao import EntityTypeDAO +from ldap_protocol.utils.async_cache import base_directories_cache from ldap_protocol.utils.helpers import create_object_sid, generate_domain_sid from ldap_protocol.utils.queries import get_domain_object_class from password_utils import PasswordUtils @@ -113,6 +114,7 @@ async def setup_enviroment( domain=domain, parent=domain, ) + base_directories_cache.clear() except Exception: import traceback @@ -132,13 +134,13 @@ async def create_dir( is_system=is_system, object_class=data["object_class"], name=data["name"], - parent=parent, ) dir_.groups = [] dir_.create_path(parent, dir_.get_dn_prefix()) self._session.add(dir_) await self._session.flush() + dir_.parent_id = parent.id if parent else None await self._session.refresh(dir_, ["id"]) self._session.add( diff --git a/app/ldap_protocol/utils/async_cache.py b/app/ldap_protocol/utils/async_cache.py new file mode 100644 index 000000000..f66f45cf3 --- /dev/null +++ b/app/ldap_protocol/utils/async_cache.py @@ -0,0 +1,43 @@ +"""Async cache implementation.""" + +import time +from functools import wraps +from typing import Callable, Generic, TypeVar + +from entities import Directory + +T = TypeVar("T") +DEFAULT_CACHE_TIME = 5 * 60 # 5 minutes + + +class AsyncTTLCache(Generic[T]): + def __init__(self, ttl: int | None = DEFAULT_CACHE_TIME) -> None: + self._ttl = ttl + self._value: T | None = None + self._expires_at: float | None = None + + def clear(self) -> None: + self._value = None + self._expires_at = None + + def __call__(self, func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args: tuple, **kwargs: dict) -> T: + if self._value is not None: + if not self._expires_at or self._expires_at > time.monotonic(): + return self._value + self.clear() + + result = await func(*args, **kwargs) + + self._value = result + self._expires_at = ( + time.monotonic() + self._ttl if self._ttl else None + ) + + return result + + return wrapper + + +base_directories_cache = AsyncTTLCache[list[Directory]]() diff --git a/app/ldap_protocol/utils/queries.py b/app/ldap_protocol/utils/queries.py index a1f9243de..5b3528fa6 100644 --- a/app/ldap_protocol/utils/queries.py +++ b/app/ldap_protocol/utils/queries.py @@ -5,6 +5,7 @@ """ import time +from copy import copy from datetime import datetime from typing import Iterator from zoneinfo import ZoneInfo @@ -25,6 +26,7 @@ queryable_attr as qa, ) +from .async_cache import base_directories_cache from .const import EMAIL_RE, GRANT_DN_STRING from .helpers import ( create_integer_hash, @@ -35,13 +37,19 @@ ) +@base_directories_cache async def get_base_directories(session: AsyncSession) -> list[Directory]: """Get base domain directories.""" result = await session.execute( select(Directory) .filter(qa(Directory.parent_id).is_(None)), ) # fmt: skip - return list(result.scalars().all()) + res = [] + for dir_ in result.scalars(): + new_dir = copy(dir_) + session.expunge(new_dir) + res.append(new_dir) + return res async def get_user(session: AsyncSession, name: str) -> User | None: @@ -362,7 +370,7 @@ async def create_group( dir_ = Directory( object_class="", name=name, - parent=parent, + parent_id=parent.id, ) session.add(dir_) await session.flush() From c37f7789e9ebd8eafe9ed7524669188bedd1617d Mon Sep 17 00:00:00 2001 From: Misha-Shvets <76677350+Misha-Shvets@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:39:23 +0300 Subject: [PATCH 03/45] Add: bulk modify DN (#908) --- app/api/main/router.py | 12 +++ interface | 2 +- .../test_main/test_router/test_modify_dn.py | 78 +++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/app/api/main/router.py b/app/api/main/router.py index f4df578e8..f26881b38 100644 --- a/app/api/main/router.py +++ b/app/api/main/router.py @@ -111,6 +111,18 @@ async def modify_dn( return await request.handle_api(req.state.dishka_container) +@entry_router.post("/update_many/dn", error_map=error_map) +async def modify_dn_many( + requests: list[ModifyDNRequest], + req: Request, +) -> list[LDAPResult]: + """LDAP MODIFY entry DN request.""" + results = [] + for request in requests: + results.append(await request.handle_api(req.state.dishka_container)) + return results + + @entry_router.delete("/delete", error_map=error_map) async def delete( request: DeleteRequest, diff --git a/interface b/interface index 95ed5e191..f31962020 160000 --- a/interface +++ b/interface @@ -1 +1 @@ -Subproject commit 95ed5e191cdafa07b1dfac96a1659926679ead97 +Subproject commit f31962020a6689e6a4c61fb3349db5b5c7895f92 diff --git a/tests/test_api/test_main/test_router/test_modify_dn.py b/tests/test_api/test_main/test_router/test_modify_dn.py index b27360dae..950e1801c 100644 --- a/tests/test_api/test_main/test_router/test_modify_dn.py +++ b/tests/test_api/test_main/test_router/test_modify_dn.py @@ -540,3 +540,81 @@ async def test_api_update_dn_invalid_new_superior( assert isinstance(data, dict) assert data.get("resultCode") == LDAPCodes.INVALID_DN_SYNTAX + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("adding_test_user") +@pytest.mark.usefixtures("setup_session") +@pytest.mark.usefixtures("session") +async def test_api_modify_dn_many(http_client: AsyncClient) -> None: + """Test API for bulk modify DN.""" + entry_dn_1 = "cn=test,dc=md,dc=test" + entry_dn_2 = "cn=test2,dc=md,dc=test" + + response = await http_client.post( + "/entry/add", + json={ + "entry": entry_dn_2, + "password": None, + "attributes": [ + {"type": "name", "vals": ["test2"]}, + {"type": "cn", "vals": ["test2"]}, + {"type": "objectClass", "vals": ["organization", "top"]}, + ], + }, + ) + assert response.json()["resultCode"] == LDAPCodes.SUCCESS + + response = await http_client.post( + "/entry/update_many/dn", + json=[ + { + "entry": entry_dn_1, + "newrdn": "cn=test", + "deleteoldrdn": True, + "new_superior": "ou=testModifyDn1,dc=md,dc=test", + }, + { + "entry": entry_dn_2, + "newrdn": "cn=test2", + "deleteoldrdn": True, + "new_superior": "ou=testModifyDn1,dc=md,dc=test", + }, + ], + ) + + data = response.json() + assert all( + result.get("resultCode") == LDAPCodes.SUCCESS for result in data + ) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("adding_test_user") +@pytest.mark.usefixtures("setup_session") +@pytest.mark.usefixtures("session") +async def test_api_modify_dn_many_with_error(http_client: AsyncClient) -> None: + """Test bulk modify DN with one invalid entry.""" + entry_dn = "cn=test,dc=md,dc=test" + + response = await http_client.post( + "/entry/update_many/dn", + json=[ + { + "entry": entry_dn, + "newrdn": "cn=test", + "deleteoldrdn": True, + "new_superior": "ou=testModifyDn1,dc=md,dc=test", + }, + { + "entry": "cn=nonExistent,dc=md,dc=test", + "newrdn": "cn=nonExistent", + "deleteoldrdn": True, + "new_superior": "dc=md,dc=test", + }, + ], + ) + + data = response.json() + assert data[0].get("resultCode") == LDAPCodes.SUCCESS + assert data[1].get("resultCode") == LDAPCodes.NO_SUCH_OBJECT From 002ab5681b881e76c0dd27afadaf27218285490f Mon Sep 17 00:00:00 2001 From: Misha-Shvets <76677350+Misha-Shvets@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:08:06 +0300 Subject: [PATCH 04/45] FIx: update network policy for deleting groups during updates (#909) --- .../policies/network/use_cases.py | 60 ++++++++++------ tests/conftest.py | 9 +++ .../policies/test_network/test_use_case.py | 71 +++++++++++++++++++ 3 files changed, 120 insertions(+), 20 deletions(-) create mode 100644 tests/test_ldap/policies/test_network/test_use_case.py diff --git a/app/ldap_protocol/policies/network/use_cases.py b/app/ldap_protocol/policies/network/use_cases.py index cde4294d6..38dbc11cc 100644 --- a/app/ldap_protocol/policies/network/use_cases.py +++ b/app/ldap_protocol/policies/network/use_cases.py @@ -148,38 +148,58 @@ async def update( ) -> NetworkPolicyDTO: """Update network policy.""" policy = await self._network_policy_gateway.get_with_for_update(dto.id) + + await self._apply_field_updates(policy, dto) + await self._apply_netmask_updates(policy, dto) + await self._apply_group_updates(policy, dto) + + if await self._network_policy_gateway.check_policy_exists(policy): + raise NetworkPolicyAlreadyExistsError("Entry already exists") + + await self._session.commit() + + return _convert_model_to_dto(policy) + + async def _apply_field_updates( + self, + policy: NetworkPolicy, + dto: NetworkPolicyUpdateDTO, + ) -> None: + """Apply regular field updates.""" for field in dto.fields_to_update: value = getattr(dto, field) if value is not None: setattr(policy, field, value) + async def _apply_netmask_updates( + self, + policy: NetworkPolicy, + dto: NetworkPolicyUpdateDTO, + ) -> None: + """Apply netmask updates.""" if dto.netmasks and dto.raw: policy.netmasks = dto.netmasks policy.raw = dto.raw - if ( - dto.groups is not None - and len(dto.groups) > 0 - and len(dto.groups) != 0 - ): - policy.groups = await self._network_policy_gateway.get_groups( - dto.groups, + async def _apply_group_updates( + self, + policy: NetworkPolicy, + dto: NetworkPolicyUpdateDTO, + ) -> None: + """Apply group updates.""" + if dto.groups is not None: + policy.groups = ( + await self._network_policy_gateway.get_groups(dto.groups) + if dto.groups + else [] ) - if ( - dto.mfa_groups is not None - and len(dto.mfa_groups) > 0 - and len(dto.mfa_groups) != 0 - ): - policy.mfa_groups = await self._network_policy_gateway.get_groups( - dto.mfa_groups, - ) - if await self._network_policy_gateway.check_policy_exists(policy): - raise NetworkPolicyAlreadyExistsError( - "Entry already exists", + if dto.mfa_groups is not None: + policy.mfa_groups = ( + await self._network_policy_gateway.get_groups(dto.mfa_groups) + if dto.mfa_groups + else [] ) - await self._session.commit() - return _convert_model_to_dto(policy) async def swap_priorities(self, id1: int, id2: int) -> SwapPrioritiesDTO: """Swap priorities for network policies.""" diff --git a/tests/conftest.py b/tests/conftest.py index c9ba0f8ff..4f95140f8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1064,6 +1064,15 @@ async def network_policy_gateway( yield await container.get(NetworkPolicyGateway) +@pytest_asyncio.fixture(scope="function") +async def network_policy_use_case( + container: AsyncContainer, +) -> AsyncIterator[NetworkPolicyUseCase]: + """Get network policy gateway.""" + async with container(scope=Scope.REQUEST) as container: + yield await container.get(NetworkPolicyUseCase) + + @pytest_asyncio.fixture(scope="function") async def network_policy_validator( container: AsyncContainer, diff --git a/tests/test_ldap/policies/test_network/test_use_case.py b/tests/test_ldap/policies/test_network/test_use_case.py new file mode 100644 index 000000000..d9714f881 --- /dev/null +++ b/tests/test_ldap/policies/test_network/test_use_case.py @@ -0,0 +1,71 @@ +"""Test network policy use case with empty groups. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from ipaddress import IPv4Network + +import pytest + +from enums import MFAFlags +from ldap_protocol.policies.network import NetworkPolicyUseCase +from ldap_protocol.policies.network.dto import ( + NetworkPolicyDTO, + NetworkPolicyUpdateDTO, +) + + +@pytest.mark.asyncio +async def test_create_policy( + network_policy_use_case: NetworkPolicyUseCase, +) -> None: + """Test creating policy with empty groups and mfa_groups.""" + dto = NetworkPolicyDTO[None]( + id=None, + name="Test Empty Groups", + netmasks=[IPv4Network("192.168.1.0/24")], + raw=["192.168.1.0/24"], + priority=2, + mfa_status=MFAFlags.DISABLED, + groups=[], + mfa_groups=[], + ) + + result = await network_policy_use_case.create(dto) + poicy = await network_policy_use_case.get(result.id) + assert poicy.groups == [] + assert poicy.mfa_groups == [] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("setup_session") +async def test_update_policy_to_empty_groups( + network_policy_use_case: NetworkPolicyUseCase, +) -> None: + """Test updating policy from groups to empty.""" + dto = NetworkPolicyDTO[None]( + id=None, + name="Test Update Groups", + netmasks=[IPv4Network("172.16.0.0/12")], + raw=["172.16.0.0/12"], + priority=3, + mfa_status=MFAFlags.DISABLED, + groups=["cn=domain admins,cn=Groups,dc=md,dc=test"], + mfa_groups=["cn=domain admins,cn=Groups,dc=md,dc=test"], + ) + + created = await network_policy_use_case.create(dto) + assert created.groups + assert created.mfa_groups + + update_dto = NetworkPolicyUpdateDTO( + id=created.id, + groups=[], + mfa_groups=[], + ) + + updated = await network_policy_use_case.update(update_dto) + + assert updated.groups == [] + assert updated.mfa_groups == [] From dc747db2a0a84ff561f551cb0f8b549bd1cc4218 Mon Sep 17 00:00:00 2001 From: Misha-Shvets <76677350+Misha-Shvets@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:08:48 +0300 Subject: [PATCH 05/45] Rename base cn to cc (#910) --- .../379fce54fb08_rename_base_cn_to_cc.py | 142 ++++++++++++++++++ .../71e642808369_add_directory_is_system.py | 9 +- .../versions/8164b4a9e1f1_add_ou_computers.py | 10 +- app/constants.py | 6 +- app/enums.py | 6 +- app/ldap_protocol/kerberos/service.py | 4 +- app/ldap_protocol/ldap_requests/modify_dn.py | 4 +- app/ldap_protocol/utils/cte.py | 4 +- app/ldap_protocol/utils/queries.py | 6 +- tests/conftest.py | 2 +- tests/search_request_datasets.py | 74 ++++----- tests/test_api/test_auth/test_router.py | 16 +- tests/test_api/test_auth/test_sessions.py | 4 +- tests/test_api/test_main/conftest.py | 2 +- tests/test_api/test_main/test_kadmin.py | 6 +- .../test_main/test_router/test_add.py | 14 +- .../test_main/test_router/test_modify.py | 30 ++-- .../test_main/test_router/test_modify_dn.py | 12 +- .../test_main/test_router/test_search.py | 28 ++-- tests/test_api/test_network/test_router.py | 14 +- .../test_password_policy_router.py | 4 +- tests/test_api/test_shadow/conftest.py | 6 +- .../test_network/test_pool_client_handler.py | 2 +- .../policies/test_password/datasets.py | 6 +- .../policies/test_password/test_use_cases.py | 6 +- tests/test_ldap/test_bind.py | 4 +- .../test_container_subcontainers.py | 10 +- tests/test_ldap/test_ldap3_lib.py | 4 +- tests/test_ldap/test_passwd_change.py | 4 +- tests/test_ldap/test_roles/conftest.py | 2 +- .../test_roles/test_multiple_access.py | 18 +-- tests/test_ldap/test_roles/test_search.py | 68 ++++----- tests/test_ldap/test_util/test_add.py | 30 ++-- tests/test_ldap/test_util/test_delete.py | 8 +- tests/test_ldap/test_util/test_modify.py | 83 +++++----- tests/test_ldap/test_util/test_search.py | 40 ++--- 36 files changed, 413 insertions(+), 275 deletions(-) create mode 100644 app/alembic/versions/379fce54fb08_rename_base_cn_to_cc.py diff --git a/app/alembic/versions/379fce54fb08_rename_base_cn_to_cc.py b/app/alembic/versions/379fce54fb08_rename_base_cn_to_cc.py new file mode 100644 index 000000000..6d88c6d0f --- /dev/null +++ b/app/alembic/versions/379fce54fb08_rename_base_cn_to_cc.py @@ -0,0 +1,142 @@ +"""Rename base containers. + +users -> Users, groups -> Groups, computers -> Computers. + +Revision ID: 379fce54fb08 +Revises: ec45e3e8aa0f +Create Date: 2026-01-23 12:26:10.758698 + +""" + +from alembic import op +from dishka import AsyncContainer, Scope +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession + +from entities import Attribute, Directory +from repo.pg.tables import queryable_attr as qa + +# revision identifiers, used by Alembic. +revision: None | str = "379fce54fb08" +down_revision: None | str = "ec45e3e8aa0f" +branch_labels: None | list[str] = None +depends_on: None | list[str] = None + + +CONTAINER_RENAMES = { + "users": "Users", + "groups": "Groups", + "computers": "Computers", +} + + +async def _update_descendants( + session: AsyncSession, + parent_id: int, + cn_from: str, + cn_to: str, +) -> None: + """Recursively update paths of all descendants.""" + child_dirs = await session.scalars( + select(Directory).where(qa(Directory.parent_id) == parent_id), + ) + + for child_dir in child_dirs: + child_dir.path = [cn_to if p == cn_from else p for p in child_dir.path] + await session.flush() + await _update_descendants( + session, + child_dir.id, + cn_from=cn_from, + cn_to=cn_to, + ) + + +async def _update_attributes( + session: AsyncSession, + old_value: str, + new_value: str, +) -> None: + """Update attribute values containing old DN references.""" + result = await session.execute( + select(Attribute).where( + qa(Attribute.value).ilike(f"%{old_value}%"), + ), + ) + attributes = result.scalars().all() + + for attr in attributes: + if attr.value and old_value in attr.value: + attr.value = attr.value.replace(old_value, new_value) + + await session.flush() + + +async def _rename_container( + session: AsyncSession, + old_name: str, + new_name: str, +) -> None: + """Rename a single container and update all references.""" + container_dir = await session.scalar( + select(Directory).where( + qa(Directory.name) == old_name, + qa(Directory.is_system).is_(True), + ), + ) + + if not container_dir: + return + + cn_from = f"cn={old_name}" + cn_to = f"cn={new_name}" + + container_dir.name = new_name + container_dir.path = [ + cn_to if p == cn_from else p for p in container_dir.path + ] + + await session.flush() + + await _update_descendants( + session, + container_dir.id, + cn_from=cn_from, + cn_to=cn_to, + ) + + await _update_attributes(session, cn_from, cn_to) + + +def upgrade(container: AsyncContainer) -> None: + """Upgrade: Rename containers to capitalized versions.""" + + async def _rename_containers( + connection: AsyncConnection, # noqa: ARG001 + ) -> None: + async with container(scope=Scope.REQUEST) as cnt: + session = await cnt.get(AsyncSession) + + for old_name, new_name in CONTAINER_RENAMES.items(): + await _rename_container(session, old_name, new_name) + + await session.commit() + + op.run_async(_rename_containers) + + +def downgrade(container: AsyncContainer) -> None: + """Downgrade: Rename containers back to lowercase.""" + + async def _rename_containers_back( + connection: AsyncConnection, # noqa: ARG001 + ) -> None: + async with container(scope=Scope.REQUEST) as cnt: + session = await cnt.get(AsyncSession) + + for old_name, new_name in CONTAINER_RENAMES.items(): + await _rename_container(session, new_name, old_name) + + await session.commit() + + op.run_async(_rename_containers_back) diff --git a/app/alembic/versions/71e642808369_add_directory_is_system.py b/app/alembic/versions/71e642808369_add_directory_is_system.py index 48ece1bc4..398d6f6df 100644 --- a/app/alembic/versions/71e642808369_add_directory_is_system.py +++ b/app/alembic/versions/71e642808369_add_directory_is_system.py @@ -14,13 +14,10 @@ from sqlalchemy.orm import Session from constants import ( - COMPUTERS_CONTAINER_NAME, DOMAIN_ADMIN_GROUP_NAME, DOMAIN_COMPUTERS_GROUP_NAME, DOMAIN_USERS_GROUP_NAME, - GROUPS_CONTAINER_NAME, READ_ONLY_GROUP_NAME, - USERS_CONTAINER_NAME, ) from entities import Directory from ldap_protocol.utils.queries import get_base_directories @@ -72,13 +69,13 @@ async def _indicate_system_directories( qa(Directory.is_system).is_(False), qa(Directory.name).in_( ( - GROUPS_CONTAINER_NAME, + "groups", DOMAIN_ADMIN_GROUP_NAME, DOMAIN_USERS_GROUP_NAME, READ_ONLY_GROUP_NAME, DOMAIN_COMPUTERS_GROUP_NAME, - COMPUTERS_CONTAINER_NAME, - USERS_CONTAINER_NAME, + "computers", + "users", "services", "krbadmin", "kerberos", diff --git a/app/alembic/versions/8164b4a9e1f1_add_ou_computers.py b/app/alembic/versions/8164b4a9e1f1_add_ou_computers.py index 5f8608a4a..a4eb7297c 100644 --- a/app/alembic/versions/8164b4a9e1f1_add_ou_computers.py +++ b/app/alembic/versions/8164b4a9e1f1_add_ou_computers.py @@ -12,7 +12,6 @@ from sqlalchemy import delete, exists, select from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession -from constants import COMPUTERS_CONTAINER_NAME from entities import Directory from extra.alembic_utils import temporary_stub_column from ldap_protocol.roles.role_use_case import RoleUseCase @@ -26,8 +25,9 @@ depends_on: None = None +COMPUTERS = "computers" _OU_COMPUTERS_DATA = { - "name": COMPUTERS_CONTAINER_NAME, + "name": COMPUTERS, "object_class": "organizationalUnit", "attributes": {"objectClass": ["top", "container"]}, "children": [], @@ -53,7 +53,7 @@ async def _create_ou_computers(connection: AsyncConnection) -> None: # noqa: AR exists_ou_computers = await session.scalar( select( exists(Directory) - .where(qa(Directory.name) == COMPUTERS_CONTAINER_NAME), + .where(qa(Directory.name) == COMPUTERS), ), ) # fmt: skip if exists_ou_computers: @@ -68,7 +68,7 @@ async def _create_ou_computers(connection: AsyncConnection) -> None: # noqa: AR ou_computers_dir = await session.scalar( select(Directory) - .where(qa(Directory.name) == COMPUTERS_CONTAINER_NAME), + .where(qa(Directory.name) == COMPUTERS), ) # fmt: skip if not ou_computers_dir: raise Exception("Directory 'ou=computers' not found.") @@ -97,7 +97,7 @@ async def _delete_ou_computers(connection: AsyncConnection) -> None: # noqa: AR await session.execute( delete(Directory) - .where(qa(Directory.name) == COMPUTERS_CONTAINER_NAME), + .where(qa(Directory.name) == COMPUTERS), ) # fmt: skip await session.commit() diff --git a/app/constants.py b/app/constants.py index f54d78a35..902a79f59 100644 --- a/app/constants.py +++ b/app/constants.py @@ -8,9 +8,9 @@ from enums import EntityTypeNames -GROUPS_CONTAINER_NAME = "groups" -COMPUTERS_CONTAINER_NAME = "computers" -USERS_CONTAINER_NAME = "users" +GROUPS_CONTAINER_NAME = "Groups" +COMPUTERS_CONTAINER_NAME = "Computers" +USERS_CONTAINER_NAME = "Users" READ_ONLY_GROUP_NAME = "read-only" diff --git a/app/enums.py b/app/enums.py index f482b928e..264b6c16a 100644 --- a/app/enums.py +++ b/app/enums.py @@ -105,9 +105,9 @@ class RoleConstants(StrEnum): READ_ONLY_ROLE_NAME = "Read Only Role" KERBEROS_ROLE_NAME = "Kerberos Role" - DOMAIN_ADMINS_GROUP_CN = "cn=domain admins,cn=groups," - READONLY_GROUP_CN = "cn=read-only,cn=groups," - KERBEROS_GROUP_CN = "cn=krbadmin,cn=groups," + DOMAIN_ADMINS_GROUP_CN = "cn=domain admins,cn=Groups," + READONLY_GROUP_CN = "cn=read-only,cn=Groups," + KERBEROS_GROUP_CN = "cn=krbadmin,cn=Groups," @verify(UNIQUE) diff --git a/app/ldap_protocol/kerberos/service.py b/app/ldap_protocol/kerberos/service.py index f6a0aae05..985d8cdc1 100644 --- a/app/ldap_protocol/kerberos/service.py +++ b/app/ldap_protocol/kerberos/service.py @@ -145,9 +145,9 @@ def _build_kerberos_admin_dns(self, base_dn: str) -> KerberosAdminDnGroup: :return KerberosAdminDnGroup: dataclass with DN for krbadmin, services_container, krbadmin_group. """ - krbadmin = f"cn=krbadmin,cn=users,{base_dn}" + krbadmin = f"cn=krbadmin,cn=Users,{base_dn}" services_container = get_system_container_dn(base_dn) - krbgroup = f"cn=krbadmin,cn=groups,{base_dn}" + krbgroup = f"cn=krbadmin,cn=Groups,{base_dn}" return KerberosAdminDnGroup( krbadmin_dn=krbadmin, services_container_dn=services_container, diff --git a/app/ldap_protocol/ldap_requests/modify_dn.py b/app/ldap_protocol/ldap_requests/modify_dn.py index 7c315eadd..cdf03ab7b 100644 --- a/app/ldap_protocol/ldap_requests/modify_dn.py +++ b/app/ldap_protocol/ldap_requests/modify_dn.py @@ -62,9 +62,9 @@ class ModifyDNRequest(BaseRequest): entry='cn=main,dc=multifactor,dc=dev' newrdn='cn=main2' deleteoldrdn=true - new_superior='cn=users,dc=multifactor,dc=dev' + new_superior='cn=Users,dc=multifactor,dc=dev' - >>> cn = main2, cn = users, dc = multifactor, dc = dev + >>> cn = main2, cn = Users, dc = multifactor, dc = dev """ PROTOCOL_OP: ClassVar[int] = ProtocolRequests.MODIFY_DN diff --git a/app/ldap_protocol/utils/cte.py b/app/ldap_protocol/utils/cte.py index e2cbe75ee..7b4628254 100644 --- a/app/ldap_protocol/utils/cte.py +++ b/app/ldap_protocol/utils/cte.py @@ -63,7 +63,7 @@ def find_members_recursive_cte( FROM "Directory" JOIN "Groups" ON "Directory".id = "Groups"."directoryId" WHERE "Directory"."path" = - '{dc=test,dc=md,cn=groups,"cn=domain admins"}' + '{dc=test,dc=md,cn=Groups,"cn=domain admins"}' UNION ALL @@ -129,7 +129,7 @@ def find_root_group_recursive_cte(dn_list: list) -> CTE: FROM "Directory" LEFT OUTER JOIN "Groups" ON "Directory".id = "Groups"."directoryId" WHERE "Directory"."path" = - '{dc=test,dc=md,cn=groups,"cn=domain admins"}' + '{dc=test,dc=md,cn=Groups,"cn=domain admins"}' UNION ALL diff --git a/app/ldap_protocol/utils/queries.py b/app/ldap_protocol/utils/queries.py index 5b3528fa6..368af23ec 100644 --- a/app/ldap_protocol/utils/queries.py +++ b/app/ldap_protocol/utils/queries.py @@ -328,7 +328,7 @@ async def get_dn_by_id(id_: int, session: AsyncSession) -> str: """Get dn by id. >>> await get_dn_by_id(0, session) - >>> "cn=groups,dc=example,dc=com" + >>> "cn=Groups,dc=example,dc=com" """ query = select(Directory).filter_by(id=id_) retval = (await session.scalars(query)).one() @@ -353,7 +353,7 @@ async def create_group( ) -> tuple[Directory, Group]: """Create group in default groups path. - cn=name,cn=groups,dc=domain,dc=com + cn=name,cn=Groups,dc=domain,dc=com :param str name: group name :param int sid: objectSid @@ -362,7 +362,7 @@ async def create_group( base_dn_list = await get_base_directories(session) query = select(Directory).filter( - get_filter_from_path("cn=groups," + base_dn_list[0].path_dn), + get_filter_from_path("cn=Groups," + base_dn_list[0].path_dn), ) parent = (await session.scalars(query)).one() diff --git a/tests/conftest.py b/tests/conftest.py index 4f95140f8..efe46fd21 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1003,7 +1003,7 @@ async def setup_session( name="TEST ONLY LOGIN ROLE", creator_upn=None, is_system=True, - groups=["cn=admin login only,cn=groups,dc=md,dc=test"], + groups=["cn=admin login only,cn=Groups,dc=md,dc=test"], permissions=AuthorizationRules.AUTH_LOGIN, ), ) diff --git a/tests/search_request_datasets.py b/tests/search_request_datasets.py index 77557bae9..cb1e7a317 100644 --- a/tests/search_request_datasets.py +++ b/tests/search_request_datasets.py @@ -17,28 +17,28 @@ test_search_by_rule_anr_dataset = [ # with split by space - {"filter": "(anr=Joh Lenno)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, # noqa: E501 - {"filter": "(anr=Lennon John)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, # noqa: E501 - {"filter": "(anr=John Lennon)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, # noqa: E501 - {"filter": "(anr=john lennon)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, # noqa: E501 - {"filter": "(anr==Lennon John)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, # noqa: E501 + {"filter": "(anr=Joh Lenno)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, # noqa: E501 + {"filter": "(anr=Lennon John)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, # noqa: E501 + {"filter": "(anr=John Lennon)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, # noqa: E501 + {"filter": "(anr=john lennon)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, # noqa: E501 + {"filter": "(anr==Lennon John)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, # noqa: E501 # without split by space - {"filter": "(anr=user0)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, - {"filter": "(anr=user0*)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, - {"filter": "(anr>=user0)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, - {"filter": "(anr<=user0)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, - {"filter": "(anr~=user0)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, - {"filter": "(anr==user0)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, - {"filter": "(anr==user0*)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, # noqa: E501 - {"filter": "(aNR=user0*)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, - {"filter": "(anr=uSEr0*)", "objects": ["cn=user0,cn=users,dc=md,dc=test"]}, - {"filter": "(anr=domain admins)", "objects": ["cn=domain admins,cn=groups,dc=md,dc=test"]}, # noqa: E501 + {"filter": "(anr=user0)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, + {"filter": "(anr=user0*)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, + {"filter": "(anr>=user0)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, + {"filter": "(anr<=user0)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, + {"filter": "(anr~=user0)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, + {"filter": "(anr==user0)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, + {"filter": "(anr==user0*)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, # noqa: E501 + {"filter": "(aNR=user0*)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, + {"filter": "(anr=uSEr0*)", "objects": ["cn=user0,cn=Users,dc=md,dc=test"]}, + {"filter": "(anr=domain admins)", "objects": ["cn=domain admins,cn=Groups,dc=md,dc=test"]}, # noqa: E501 {"filter": "(anr=user_admin_3@mail.com)", "objects": ["cn=user_admin_3,ou=test_bit_rules,dc=md,dc=test"]}, # noqa: E501 { "filter": "(anr=user_admin_*)", "objects": [ - "cn=user_admin,cn=users,dc=md,dc=test", - "cn=user_admin_for_roles,cn=users,dc=md,dc=test", + "cn=user_admin,cn=Users,dc=md,dc=test", + "cn=user_admin_for_roles,cn=Users,dc=md,dc=test", "cn=user_admin_1,ou=test_bit_rules,dc=md,dc=test", "cn=user_admin_2,ou=test_bit_rules,dc=md,dc=test", "cn=user_admin_3,ou=test_bit_rules,dc=md,dc=test", @@ -50,11 +50,11 @@ { "filter": f"(useraccountcontrol:1.2.840.113556.1.4.803:={UserAccountControlFlag.NORMAL_ACCOUNT})", # noqa: E501 "objects": [ - "cn=user0,cn=users,dc=md,dc=test", - "cn=user_admin,cn=users,dc=md,dc=test", - "cn=user_admin_for_roles,cn=users,dc=md,dc=test", - "cn=user_non_admin,cn=users,dc=md,dc=test", - "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test", + "cn=user0,cn=Users,dc=md,dc=test", + "cn=user_admin,cn=Users,dc=md,dc=test", + "cn=user_admin_for_roles,cn=Users,dc=md,dc=test", + "cn=user_non_admin,cn=Users,dc=md,dc=test", + "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test", "cn=user_admin_1,ou=test_bit_rules,dc=md,dc=test", "cn=user_admin_2,ou=test_bit_rules,dc=md,dc=test", ], @@ -83,11 +83,11 @@ { "filter": f"(!(userAccountControl:1.2.840.113556.1.4.803:={UserAccountControlFlag.ACCOUNTDISABLE}))", # noqa: E501 "objects": [ - "cn=user0,cn=users,dc=md,dc=test", - "cn=user_admin,cn=users,dc=md,dc=test", - "cn=user_admin_for_roles,cn=users,dc=md,dc=test", - "cn=user_non_admin,cn=users,dc=md,dc=test", - "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test", + "cn=user0,cn=Users,dc=md,dc=test", + "cn=user_admin,cn=Users,dc=md,dc=test", + "cn=user_admin_for_roles,cn=Users,dc=md,dc=test", + "cn=user_non_admin,cn=Users,dc=md,dc=test", + "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test", "cn=user_admin_2,ou=test_bit_rules,dc=md,dc=test", ], }, @@ -104,14 +104,14 @@ + UserAccountControlFlag.NORMAL_ACCOUNT })", "objects": [ - "cn=user0,cn=users,dc=md,dc=test", - "cn=user_admin,cn=users,dc=md,dc=test", - "cn=user_admin_for_roles,cn=users,dc=md,dc=test", + "cn=user0,cn=Users,dc=md,dc=test", + "cn=user_admin,cn=Users,dc=md,dc=test", + "cn=user_admin_for_roles,cn=Users,dc=md,dc=test", "cn=user_admin_1,ou=test_bit_rules,dc=md,dc=test", "cn=user_admin_2,ou=test_bit_rules,dc=md,dc=test", "cn=user_admin_3,ou=test_bit_rules,dc=md,dc=test", - "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test", - "cn=user_non_admin,cn=users,dc=md,dc=test", + "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test", + "cn=user_non_admin,cn=Users,dc=md,dc=test", ], }, { @@ -124,11 +124,11 @@ { "filter": f"(!(userAccountControl:1.2.840.113556.1.4.804:={UserAccountControlFlag.ACCOUNTDISABLE}))", # noqa: E501 "objects": [ - "cn=user0,cn=users,dc=md,dc=test", - "cn=user_admin,cn=users,dc=md,dc=test", - "cn=user_admin_for_roles,cn=users,dc=md,dc=test", - "cn=user_non_admin,cn=users,dc=md,dc=test", - "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test", + "cn=user0,cn=Users,dc=md,dc=test", + "cn=user_admin,cn=Users,dc=md,dc=test", + "cn=user_admin_for_roles,cn=Users,dc=md,dc=test", + "cn=user_non_admin,cn=Users,dc=md,dc=test", + "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test", "cn=user_admin_2,ou=test_bit_rules,dc=md,dc=test", ], }, diff --git a/tests/test_api/test_auth/test_router.py b/tests/test_api/test_auth/test_router.py index c13c0a5a6..ffffd6ef1 100644 --- a/tests/test_api/test_auth/test_router.py +++ b/tests/test_api/test_auth/test_router.py @@ -24,7 +24,7 @@ from ldap_protocol.ldap_codes import LDAPCodes from ldap_protocol.ldap_requests.modify import Operation from ldap_protocol.session_storage import SessionStorage -from ldap_protocol.utils.queries import get_search_path +from ldap_protocol.utils.queries import get_filter_from_path from password_utils import PasswordUtils from repo.pg.tables import queryable_attr as qa from tests.conftest import TestCreds @@ -114,7 +114,7 @@ async def test_first_setup_and_oauth( assert result["user_principal_name"] == "test" assert result["mail"] == "test@md.example-345.ru" assert result["display_name"] == "test" - assert result["dn"] == "cn=test,cn=users,dc=md,dc=test-localhost" + assert result["dn"] == "cn=test,cn=Users,dc=md,dc=test-localhost" result = await session.scalars( select(Directory) @@ -123,9 +123,9 @@ async def test_first_setup_and_oauth( .selectinload(qa(Group.roles)) .selectinload(qa(Role.access_control_entries)), ) - .filter_by( - path=get_search_path( - "cn=read-only,cn=groups,dc=md,dc=test-localhost", + .filter( + get_filter_from_path( + "cn=read-only,cn=Groups,dc=md,dc=test-localhost", ), ), ) @@ -222,7 +222,7 @@ async def test_first_setup_with_invalid_domain( @pytest.mark.usefixtures("session") async def test_update_password_and_check_uac(http_client: AsyncClient) -> None: """Update password and check userAccountControl attr.""" - user_dn = "cn=user0,cn=users,dc=md,dc=test" + user_dn = "cn=user0,cn=Users,dc=md,dc=test" response = await http_client.patch( "entry/update", @@ -468,7 +468,7 @@ async def test_auth_disabled_user( response = await http_client.patch( "entry/update", json={ - "object": "cn=user_admin,cn=users,dc=md,dc=test", + "object": "cn=user_admin,cn=Users,dc=md,dc=test", "changes": [ { "operation": Operation.REPLACE, @@ -507,7 +507,7 @@ async def test_lock_and_unlock_user( storage: SessionStorage, ) -> None: """Block user and verify nsAccountLock and shadowExpires attributes.""" - user_dn = "cn=user_non_admin,cn=users,dc=md,dc=test" + user_dn = "cn=user_non_admin,cn=Users,dc=md,dc=test" dir_ = await session.scalar( select(Directory) .options(joinedload(qa(Directory.user))) diff --git a/tests/test_api/test_auth/test_sessions.py b/tests/test_api/test_auth/test_sessions.py index 59b11208c..e2c6d3fd9 100644 --- a/tests/test_api/test_auth/test_sessions.py +++ b/tests/test_api/test_auth/test_sessions.py @@ -217,7 +217,7 @@ async def test_block_ldap_user_without_session( storage: SessionStorage, ) -> None: """Test blocking ldap user without active session.""" - user_dn = "cn=user_non_admin,cn=users,dc=md,dc=test" + user_dn = "cn=user_non_admin,cn=Users,dc=md,dc=test" un = "user_non_admin" user = await get_user(session, un) @@ -253,7 +253,7 @@ async def test_block_ldap_user_with_active_session( storage: SessionStorage, ) -> None: """Test blocking ldap user with active session.""" - user_dn = "cn=user_non_admin,cn=users,dc=md,dc=test" + user_dn = "cn=user_non_admin,cn=Users,dc=md,dc=test" un = "user_non_admin" pw = "password" diff --git a/tests/test_api/test_main/conftest.py b/tests/test_api/test_main/conftest.py index 8f1b58dea..3094ac1db 100644 --- a/tests/test_api/test_main/conftest.py +++ b/tests/test_api/test_main/conftest.py @@ -106,7 +106,7 @@ async def adding_test_user( "operation": Operation.ADD, "modification": { "type": "memberOf", - "vals": ["cn=domain admins,cn=groups,dc=md,dc=test"], + "vals": ["cn=domain admins,cn=Groups,dc=md,dc=test"], }, }, { diff --git a/tests/test_api/test_main/test_kadmin.py b/tests/test_api/test_main/test_kadmin.py index 0ffbd6ebe..b96d6c6c5 100644 --- a/tests/test_api/test_main/test_kadmin.py +++ b/tests/test_api/test_main/test_kadmin.py @@ -95,7 +95,7 @@ async def test_tree_creation( bind = MutePolicyBindRequest( version=0, - name="cn=krbadmin,cn=users,dc=md,dc=test", + name="cn=krbadmin,cn=Users,dc=md,dc=test", AuthenticationChoice=SimpleAuthentication(password=krbadmin_pw), ) @@ -162,7 +162,7 @@ async def test_setup_call( assert kadmin.setup.call_args.kwargs == { "domain": "md.test", - "admin_dn": "cn=user0,cn=users,dc=md,dc=test", + "admin_dn": "cn=user0,cn=Users,dc=md,dc=test", "services_dn": "ou=System,dc=md,dc=test", "krbadmin_dn": "cn=krbadmin,cn=users,dc=md,dc=test", "krbadmin_password": "Password123", @@ -362,7 +362,7 @@ async def test_extended_pw_change_call( kadmin: AbstractKadmin, ) -> None: """Test anonymous pwd change.""" - user_dn = "cn=user0,cn=users,dc=md,dc=test" + user_dn = "cn=user0,cn=Users,dc=md,dc=test" password = creds.pw new_test_password = "Password123" # noqa await anonymous_ldap_client.bind(user_dn, password) diff --git a/tests/test_api/test_main/test_router/test_add.py b/tests/test_api/test_main/test_router/test_add.py index 3050bedec..ddaf7e218 100644 --- a/tests/test_api/test_main/test_router/test_add.py +++ b/tests/test_api/test_main/test_router/test_add.py @@ -28,7 +28,7 @@ async def test_api_correct_add(http_client: AsyncClient) -> None: {"type": "objectClass", "vals": ["organization", "top"]}, { "type": "memberOf", - "vals": ["cn=domain admins,cn=groups,dc=md,dc=test"], + "vals": ["cn=domain admins,cn=Groups,dc=md,dc=test"], }, ], }, @@ -59,7 +59,7 @@ async def test_api_add_incorrect_computer_name( {"type": "objectClass", "vals": ["computer", "top"]}, { "type": "memberOf", - "vals": ["cn=domain admins,cn=groups,dc=md,dc=test"], + "vals": ["cn=domain admins,cn=Groups,dc=md,dc=test"], }, ], }, @@ -186,7 +186,7 @@ async def test_api_correct_add_double_member_of( user = "cn=test0,dc=md,dc=test" un = "test0" groups = [ - "cn=domain admins,cn=groups,dc=md,dc=test", + "cn=domain admins,cn=Groups,dc=md,dc=test", new_group, ] @@ -307,7 +307,7 @@ async def test_api_correct_add_double_member_of( assert data.get("resultCode") == LDAPCodes.SUCCESS assert data["search_result"][0]["object_name"] == user - created_groups = groups + ["cn=domain users,cn=groups,dc=md,dc=test"] + created_groups = groups + ["cn=domain users,cn=Groups,dc=md,dc=test"] for attr in data["search_result"][0]["partial_attributes"]: if attr["type"] == "memberOf": @@ -528,7 +528,7 @@ async def test_api_double_add(http_client: AsyncClient) -> None: { "type": "memberOf", "vals": [ - "cn=domain admins,cn=groups,dc=md,dc=test", + "cn=domain admins,cn=Groups,dc=md,dc=test", ], }, ], @@ -568,7 +568,7 @@ async def test_api_add_double_case_insensetive( { "type": "memberOf", "vals": [ - "cn=domain admins,cn=groups,dc=md,dc=test", + "cn=domain admins,cn=Groups,dc=md,dc=test", ], }, ], @@ -597,7 +597,7 @@ async def test_api_add_double_case_insensetive( { "type": "memberOf", "vals": [ - "cn=domain admins,cn=groups,dc=md,dc=test", + "cn=domain admins,cn=Groups,dc=md,dc=test", ], }, ], diff --git a/tests/test_api/test_main/test_router/test_modify.py b/tests/test_api/test_main/test_router/test_modify.py index 3e46e879d..82bf7248a 100644 --- a/tests/test_api/test_main/test_router/test_modify.py +++ b/tests/test_api/test_main/test_router/test_modify.py @@ -262,8 +262,8 @@ async def test_api_correct_modify_replace_memberof( http_client: AsyncClient, ) -> None: """Test API for modify object attribute.""" - user = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" - new_group = "cn=domain admins,cn=groups,dc=md,dc=test" + user = "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test" + new_group = "cn=domain admins,cn=Groups,dc=md,dc=test" response = await http_client.patch( "/entry/update", json={ @@ -320,13 +320,13 @@ async def test_api_modify_add_loop_detect_member( response = await http_client.patch( "/entry/update", json={ - "object": "cn=developers,cn=groups,dc=md,dc=test", + "object": "cn=developers,cn=Groups,dc=md,dc=test", "changes": [ { "operation": Operation.ADD, "modification": { "type": "member", - "vals": ["cn=domain admins,cn=groups,dc=md,dc=test"], + "vals": ["cn=domain admins,cn=Groups,dc=md,dc=test"], }, }, ], @@ -347,13 +347,13 @@ async def test_api_modify_add_loop_detect_memberof( response = await http_client.patch( "/entry/update", json={ - "object": "cn=domain admins,cn=groups,dc=md,dc=test", + "object": "cn=domain admins,cn=Groups,dc=md,dc=test", "changes": [ { "operation": Operation.ADD, "modification": { "type": "memberOf", - "vals": ["cn=developers,cn=groups,dc=md,dc=test"], + "vals": ["cn=developers,cn=Groups,dc=md,dc=test"], }, }, ], @@ -374,15 +374,15 @@ async def test_api_modify_replace_loop_detect_member( response = await http_client.patch( "/entry/update", json={ - "object": "cn=developers,cn=groups,dc=md,dc=test", + "object": "cn=developers,cn=Groups,dc=md,dc=test", "changes": [ { "operation": Operation.REPLACE, "modification": { "type": "member", "vals": [ - "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test", - "cn=domain admins,cn=groups,dc=md,dc=test", + "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test", + "cn=domain admins,cn=Groups,dc=md,dc=test", ], }, }, @@ -404,15 +404,15 @@ async def test_api_modify_replace_loop_detect_memberof( response = await http_client.patch( "/entry/update", json={ - "object": "cn=domain admins,cn=groups,dc=md,dc=test", + "object": "cn=domain admins,cn=Groups,dc=md,dc=test", "changes": [ { "operation": Operation.REPLACE, "modification": { "type": "memberOf", "vals": [ - "cn=domain computers,cn=groups,dc=md,dc=test", - "cn=developers,cn=groups,dc=md,dc=test", + "cn=domain computers,cn=Groups,dc=md,dc=test", + "cn=developers,cn=Groups,dc=md,dc=test", ], }, }, @@ -431,7 +431,7 @@ async def test_api_modify_incorrect_uac(http_client: AsyncClient) -> None: response = await http_client.patch( "/entry/update", json={ - "object": "cn=user0,cn=users,dc=md,dc=test", + "object": "cn=user0,cn=Users,dc=md,dc=test", "changes": [ { "operation": Operation.REPLACE, @@ -455,7 +455,7 @@ async def test_qpi_modify_primary_object_classes( http_client: AsyncClient, ) -> None: """Test deleting primary object class.""" - entry_dn = "cn=user0,cn=users,dc=md,dc=test" + entry_dn = "cn=user0,cn=Users,dc=md,dc=test" response = await http_client.patch( "/entry/update", json={ @@ -487,7 +487,7 @@ async def test_api_set_primary_group( ) -> None: """Test API for setting primary group.""" user_dn = "cn=test,dc=md,dc=test" - group_dn = "cn=domain admins,cn=groups,dc=md,dc=test" + group_dn = "cn=domain admins,cn=Groups,dc=md,dc=test" response = await http_client.post( "/entry/set_primary_group", diff --git a/tests/test_api/test_main/test_router/test_modify_dn.py b/tests/test_api/test_main/test_router/test_modify_dn.py index 950e1801c..8313049f5 100644 --- a/tests/test_api/test_main/test_router/test_modify_dn.py +++ b/tests/test_api/test_main/test_router/test_modify_dn.py @@ -219,13 +219,13 @@ async def test_api_modify_dn_with_level_up( @pytest.mark.usefixtures("session") async def test_api_correct_update_dn(http_client: AsyncClient) -> None: """Test API for update DN.""" - old_user_dn = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" + old_user_dn = "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test" newrdn_user = "cn=new_test2" - old_group_dn = "cn=developers,cn=groups,dc=md,dc=test" - new_group_dn = "cn=new_developers,cn=groups,dc=md,dc=test" + old_group_dn = "cn=developers,cn=Groups,dc=md,dc=test" + new_group_dn = "cn=new_developers,cn=Groups,dc=md,dc=test" newrdn_group = "cn=new_developers" - new_superior_group = "cn=groups,dc=md,dc=test" + new_superior_group = "cn=Groups,dc=md,dc=test" new_user_dn = ",".join((newrdn_user, new_superior_group)) @@ -338,8 +338,8 @@ async def test_api_correct_update_dn(http_client: AsyncClient) -> None: @pytest.mark.usefixtures("session") async def test_api_update_dn_with_parent(http_client: AsyncClient) -> None: """Test API for update DN.""" - old_user_dn = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" - new_user_dn = "cn=new_test2,cn=users,dc=md,dc=test" + old_user_dn = "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test" + new_user_dn = "cn=new_test2,cn=Users,dc=md,dc=test" groups_user = None newrdn_user, new_superior = new_user_dn.split(",", maxsplit=1) 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 34a9377aa..01fbb59c2 100644 --- a/tests/test_api/test_main/test_router/test_search.py +++ b/tests/test_api/test_main/test_router/test_search.py @@ -96,8 +96,8 @@ async def test_api_search(http_client: AsyncClient) -> None: assert response["resultCode"] == LDAPCodes.SUCCESS sub_dirs = { - "cn=groups,dc=md,dc=test", - "cn=users,dc=md,dc=test", + "cn=Groups,dc=md,dc=test", + "cn=Users,dc=md,dc=test", "ou=testModifyDn1,dc=md,dc=test", "ou=testModifyDn3,dc=md,dc=test", "ou=test_bit_rules,dc=md,dc=test", @@ -111,7 +111,7 @@ async def test_api_search(http_client: AsyncClient) -> None: @pytest.mark.usefixtures("session") async def test_api_search_filter_memberof(http_client: AsyncClient) -> None: """Test api search.""" - member = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" + member = "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test" raw_response = await http_client.post( "entry/search", json={ @@ -121,7 +121,7 @@ async def test_api_search_filter_memberof(http_client: AsyncClient) -> None: "size_limit": 1000, "time_limit": 10, "types_only": True, - "filter": "(memberOf=cn=developers,cn=groups,dc=md,dc=test)", + "filter": "(memberOf=cn=developers,cn=Groups,dc=md,dc=test)", "attributes": [], "page_number": 1, }, @@ -137,8 +137,8 @@ async def test_api_search_filter_memberof(http_client: AsyncClient) -> None: @pytest.mark.usefixtures("session") async def test_api_search_filter_member(http_client: AsyncClient) -> None: """Test api search.""" - member = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" - group = "cn=developers,cn=groups,dc=md,dc=test" + member = "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test" + group = "cn=developers,cn=Groups,dc=md,dc=test" raw_response = await http_client.post( "entry/search", json={ @@ -241,11 +241,11 @@ async def test_api_search_filter_account_expires( @pytest.mark.usefixtures("session") async def test_api_search_complex_filter(http_client: AsyncClient) -> None: """Test api search.""" - user = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" + user = "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test" raw_response = await http_client.post( "entry/search", json={ - "base_object": "cn=users,dc=md,dc=test", + "base_object": "cn=Users,dc=md,dc=test", "scope": 2, "deref_aliases": 0, "size_limit": 1000, @@ -278,12 +278,12 @@ async def test_api_search_complex_filter(http_client: AsyncClient) -> None: @pytest.mark.usefixtures("session") async def test_api_search_recursive_memberof(http_client: AsyncClient) -> None: """Test api search.""" - group = "cn=domain admins,cn=groups,dc=md,dc=test" + group = "cn=domain admins,cn=Groups,dc=md,dc=test" members = [ - "cn=developers,cn=groups,dc=md,dc=test", - "cn=user0,cn=users,dc=md,dc=test", - "cn=user_admin,cn=users,dc=md,dc=test", - "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test", + "cn=developers,cn=Groups,dc=md,dc=test", + "cn=user0,cn=Users,dc=md,dc=test", + "cn=user_admin,cn=Users,dc=md,dc=test", + "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test", ] response = await http_client.post( "entry/search", @@ -406,7 +406,7 @@ async def test_api_bytes_to_hex(http_client: AsyncClient) -> None: raw_response = await http_client.post( "entry/search", json={ - "base_object": "cn=user0,cn=users,dc=md,dc=test", + "base_object": "cn=user0,cn=Users,dc=md,dc=test", "scope": 0, "deref_aliases": 0, "size_limit": 1000, diff --git a/tests/test_api/test_network/test_router.py b/tests/test_api/test_network/test_router.py index 9155ff65d..70e7f38e6 100644 --- a/tests/test_api/test_network/test_router.py +++ b/tests/test_api/test_network/test_router.py @@ -68,7 +68,7 @@ async def test_add_policy(http_client: AsyncClient) -> None: "name": "local seriveses", "netmasks": raw_netmasks, "priority": 2, - "groups": ["cn=domain admins,cn=groups,dc=md,dc=test"], + "groups": ["cn=domain admins,cn=Groups,dc=md,dc=test"], "is_http": True, "is_ldap": True, "is_kerberos": True, @@ -108,7 +108,7 @@ async def test_add_policy(http_client: AsyncClient) -> None: "name": "local seriveses", "netmasks": compare_netmasks, "raw": raw_netmasks, - "groups": ["cn=domain admins,cn=groups,dc=md,dc=test"], + "groups": ["cn=domain admins,cn=Groups,dc=md,dc=test"], "priority": 2, "mfa_groups": [], "mfa_status": 0, @@ -153,7 +153,7 @@ async def test_update_policy(http_client: AsyncClient) -> None: "/policy", json={ "id": pol_id, - "groups": ["cn=domain admins,cn=groups,dc=md,dc=test"], + "groups": ["cn=domain admins,cn=Groups,dc=md,dc=test"], "name": "Default open policy 2", }, ) @@ -168,7 +168,7 @@ async def test_update_policy(http_client: AsyncClient) -> None: "name": "Default open policy 2", "netmasks": ["0.0.0.0/0"], "raw": ["0.0.0.0/0"], - "groups": ["cn=domain admins,cn=groups,dc=md,dc=test"], + "groups": ["cn=domain admins,cn=Groups,dc=md,dc=test"], "mfa_groups": [], "mfa_status": 0, "priority": 1, @@ -194,7 +194,7 @@ async def test_update_policy(http_client: AsyncClient) -> None: "mfa_groups": [], "mfa_status": 0, "priority": 1, - "groups": ["cn=domain admins,cn=groups,dc=md,dc=test"], + "groups": ["cn=domain admins,cn=Groups,dc=md,dc=test"], "is_http": True, "is_ldap": True, "is_kerberos": True, @@ -363,7 +363,7 @@ async def test_swap(http_client: AsyncClient) -> None: "172.8.4.0/24", ], "priority": 2, - "groups": ["cn=domain admins,cn=groups,dc=md,dc=test"], + "groups": ["cn=domain admins,cn=Groups,dc=md,dc=test"], "is_http": True, "is_ldap": True, "is_kerberos": True, @@ -399,7 +399,7 @@ async def test_swap(http_client: AsyncClient) -> None: assert response[0]["priority"] == 1 assert response[0]["groups"] == [ - "cn=domain admins,cn=groups,dc=md,dc=test", + "cn=domain admins,cn=Groups,dc=md,dc=test", ] assert response[1]["priority"] == 2 assert response[1]["name"] == "Default open policy" diff --git a/tests/test_api/test_password_policy/test_password_policy_router.py b/tests/test_api/test_password_policy/test_password_policy_router.py index 0e3dbba8c..01ff38c44 100644 --- a/tests/test_api/test_password_policy/test_password_policy_router.py +++ b/tests/test_api/test_password_policy/test_password_policy_router.py @@ -77,7 +77,7 @@ async def test_get_password_policy_by_dir_path_dn_with_error( password_use_cases: Mock, ) -> None: """Test get one Password Policy endpoint.""" - path = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" + path = "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test" response = await http_client_with_login_perm.get( f"/password-policy/by_dir_path_dn/{path}", ) @@ -94,7 +94,7 @@ async def test_get_password_policy_by_dir_path_dn( password_use_cases: Mock, ) -> None: """Test get Password Policy by directory path endpoint.""" - path = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" + path = "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test" response = await http_client.get( f"/password-policy/by_dir_path_dn/{path}", ) diff --git a/tests/test_api/test_shadow/conftest.py b/tests/test_api/test_shadow/conftest.py index ab3cb4df8..661b3a307 100644 --- a/tests/test_api/test_shadow/conftest.py +++ b/tests/test_api/test_shadow/conftest.py @@ -50,7 +50,7 @@ async def adding_mfa_user_and_group( response = await http_client.post( "/entry/add", json={ - "entry": "cn=mfa_group,cn=groups,dc=md,dc=test", + "entry": "cn=mfa_group,cn=Groups,dc=md,dc=test", "password": None, "attributes": [ { @@ -111,8 +111,8 @@ async def adding_mfa_user_and_group( { "type": "memberOf", "vals": [ - "cn=mfa_group,cn=groups,dc=md,dc=test", - "cn=domain admins,cn=groups,dc=md,dc=test", + "cn=mfa_group,cn=Groups,dc=md,dc=test", + "cn=domain admins,cn=Groups,dc=md,dc=test", ], }, { diff --git a/tests/test_ldap/policies/test_network/test_pool_client_handler.py b/tests/test_ldap/policies/test_network/test_pool_client_handler.py index 9f212986c..0d2b4c800 100644 --- a/tests/test_ldap/policies/test_network/test_pool_client_handler.py +++ b/tests/test_ldap/policies/test_network/test_pool_client_handler.py @@ -78,7 +78,7 @@ async def test_check_policy_group( assert await network_policy_validator.is_user_group_valid(user, policy) group = await get_group( - dn="cn=domain admins,cn=groups,dc=md,dc=test", + dn="cn=domain admins,cn=Groups,dc=md,dc=test", session=session, ) diff --git a/tests/test_ldap/policies/test_password/datasets.py b/tests/test_ldap/policies/test_password/datasets.py index ea22dea5a..5aad7bf27 100644 --- a/tests/test_ldap/policies/test_password/datasets.py +++ b/tests/test_ldap/policies/test_password/datasets.py @@ -11,7 +11,7 @@ PasswordPolicyDTO[None, int]( id=None, priority=1, - group_paths=["cn=developers,cn=groups,dc=md,dc=test"], + group_paths=["cn=developers,cn=Groups,dc=md,dc=test"], name="Test Password Policy", language="Latin", is_exact_match=True, @@ -36,7 +36,7 @@ PasswordPolicyDTO[None, int]( id=None, priority=1, - group_paths=["cn=developers,cn=groups,dc=md,dc=test"], + group_paths=["cn=developers,cn=Groups,dc=md,dc=test"], name="Test Password Policy2", language="Latin", is_exact_match=True, @@ -61,7 +61,7 @@ PasswordPolicyDTO[None, int]( id=None, priority=1, - group_paths=["cn=developers,cn=groups,dc=md,dc=test"], + group_paths=["cn=developers,cn=Groups,dc=md,dc=test"], name="Test Password Policy3", language="Latin", is_exact_match=True, diff --git a/tests/test_ldap/policies/test_password/test_use_cases.py b/tests/test_ldap/policies/test_password/test_use_cases.py index a518df2e5..2b03dfd1d 100644 --- a/tests/test_ldap/policies/test_password/test_use_cases.py +++ b/tests/test_ldap/policies/test_password/test_use_cases.py @@ -48,7 +48,7 @@ async def test_get_password_policy_by_dir_path_dn( dto = PasswordPolicyDTO[None, int]( id=None, priority=1, - group_paths=["cn=developers,cn=groups,dc=md,dc=test"], + group_paths=["cn=developers,cn=Groups,dc=md,dc=test"], name="Test Password Policy", language="Latin", is_exact_match=True, @@ -75,7 +75,7 @@ async def test_get_password_policy_by_dir_path_dn( policies = await password_use_cases.get_all() assert any(policy.name == "Test Password Policy" for policy in policies) - path_dn = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" + path_dn = "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test" policy = await password_use_cases.get_password_policy_by_dir_path_dn( path_dn, ) @@ -100,7 +100,7 @@ async def test_get_password_policy_by_dir_path_dn_extended( policies = await password_use_cases.get_all() assert any(policy.name == "Test Password Policy" for policy in policies) - path_dn = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" + path_dn = "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test" policy = await password_use_cases.get_password_policy_by_dir_path_dn( path_dn, ) diff --git a/tests/test_ldap/test_bind.py b/tests/test_ldap/test_bind.py index e31353880..d43776644 100644 --- a/tests/test_ldap/test_bind.py +++ b/tests/test_ldap/test_bind.py @@ -287,7 +287,7 @@ async def test_bind_invalid_password_or_user( directory = Directory( name="user0", object_class="", - path=["cn=user0", "cn=users", "dc=md", "dc=test"], + path=["cn=user0", "cn=Users", "dc=md", "dc=test"], rdname="cn", ) session.add(directory) @@ -415,7 +415,7 @@ async def test_bind_disabled_user( directory = Directory( name="user0", object_class="", - path=["cn=user0", "cn=users", "dc=md", "dc=test"], + path=["cn=user0", "cn=Users", "dc=md", "dc=test"], rdname="cn", ) session.add(directory) diff --git a/tests/test_ldap/test_container_restrictions/test_container_subcontainers.py b/tests/test_ldap/test_container_restrictions/test_container_subcontainers.py index 4e1ee2de3..08eca190e 100644 --- a/tests/test_ldap/test_container_restrictions/test_container_subcontainers.py +++ b/tests/test_ldap/test_container_restrictions/test_container_subcontainers.py @@ -20,31 +20,31 @@ ("dn", "rdn_attr", "rdn_value", "object_classes"), [ ( - "cn=testcontainer,cn=users,dc=md,dc=test", + "cn=testcontainer,cn=Users,dc=md,dc=test", "cn", "testcontainer", ["container"], ), ( - "ou=testou,cn=users,dc=md,dc=test", + "ou=testou,cn=Users,dc=md,dc=test", "ou", "testou", ["organizationalUnit"], ), ( - "cn=testuser,cn=users,dc=md,dc=test", + "cn=testuser,cn=Users,dc=md,dc=test", "cn", "testuser", ["user", "organizationalPerson"], ), ( - "cn=testgroup,cn=groups,dc=md,dc=test", + "cn=testgroup,cn=Groups,dc=md,dc=test", "cn", "testgroup", ["group", "posixGroup"], ), ( - "cn=testcomputer,cn=computers,dc=md,dc=test", + "cn=testcomputer,cn=Computers,dc=md,dc=test", "cn", "testcomputer", ["computer", "organizationalPerson"], diff --git a/tests/test_ldap/test_ldap3_lib.py b/tests/test_ldap/test_ldap3_lib.py index aae675e15..756f2d142 100644 --- a/tests/test_ldap/test_ldap3_lib.py +++ b/tests/test_ldap/test_ldap3_lib.py @@ -27,11 +27,11 @@ async def test_ldap3_search(ldap_client: LDAPConnection) -> None: @pytest.mark.usefixtures("session") async def test_ldap3_search_memberof(ldap_client: LDAPConnection) -> None: """Test ldap3 search memberof.""" - member = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" + member = "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test" result = await ldap_client.search( "dc=md,dc=test", - "(memberOf=cn=developers,cn=groups,dc=md,dc=test)", + "(memberOf=cn=developers,cn=Groups,dc=md,dc=test)", ) assert result diff --git a/tests/test_ldap/test_passwd_change.py b/tests/test_ldap/test_passwd_change.py index cbf503b08..f01a93e7a 100644 --- a/tests/test_ldap/test_passwd_change.py +++ b/tests/test_ldap/test_passwd_change.py @@ -23,7 +23,7 @@ async def test_anonymous_pwd_change( password_utils: PasswordUtils, ) -> None: """Test anonymous pwd change.""" - user_dn = "cn=user0,cn=users,dc=md,dc=test" + user_dn = "cn=user0,cn=Users,dc=md,dc=test" password = creds.pw new_test_password = "Password123" # noqa await anonymous_ldap_client.modify_password( @@ -49,7 +49,7 @@ async def test_bind_pwd_change( password_utils: PasswordUtils, ) -> None: """Test anonymous pwd change.""" - user_dn = "cn=user0,cn=users,dc=md,dc=test" + user_dn = "cn=user0,cn=Users,dc=md,dc=test" password = creds.pw new_test_password = "Password123" # noqa await ldap_client.bind(user_dn, password) diff --git a/tests/test_ldap/test_roles/conftest.py b/tests/test_ldap/test_roles/conftest.py index e82c70526..2d5959e27 100644 --- a/tests/test_ldap/test_roles/conftest.py +++ b/tests/test_ldap/test_roles/conftest.py @@ -24,7 +24,7 @@ async def custom_role(role_dao: RoleDAO) -> RoleDTO: name="Custom Role", creator_upn=None, is_system=False, - groups=["cn=domain users,cn=groups,dc=md,dc=test"], + groups=["cn=domain users,cn=Groups,dc=md,dc=test"], ), ) return await role_dao.get(role_dao.get_last_id()) diff --git a/tests/test_ldap/test_roles/test_multiple_access.py b/tests/test_ldap/test_roles/test_multiple_access.py index da8cc17bc..4691ba0fb 100644 --- a/tests/test_ldap/test_roles/test_multiple_access.py +++ b/tests/test_ldap/test_roles/test_multiple_access.py @@ -18,7 +18,7 @@ from ldap_protocol.ldap_schema.entity_type_dao import EntityTypeDAO from ldap_protocol.roles.ace_dao import AccessControlEntryDAO from ldap_protocol.roles.dataclasses import AccessControlEntryDTO, RoleDTO -from ldap_protocol.utils.queries import get_search_path +from ldap_protocol.utils.queries import get_filter_from_path from repo.pg.tables import queryable_attr as qa from tests.conftest import TestCreds @@ -56,7 +56,7 @@ async def test_multiple_access( role_id=custom_role.get_id(), ace_type=AceType.READ, scope=RoleScope.WHOLE_SUBTREE, - base_dn="cn=russia,cn=users,dc=md,dc=test", + base_dn="cn=russia,cn=Users,dc=md,dc=test", entity_type_id=user_entity_type.id, attribute_type_id=user_account_control_attr.id, is_allow=True, @@ -65,7 +65,7 @@ async def test_multiple_access( role_id=custom_role.get_id(), ace_type=AceType.READ, scope=RoleScope.WHOLE_SUBTREE, - base_dn="cn=russia,cn=users,dc=md,dc=test", + base_dn="cn=russia,cn=Users,dc=md,dc=test", entity_type_id=user_entity_type.id, attribute_type_id=user_principal_name.id, is_allow=True, @@ -74,7 +74,7 @@ async def test_multiple_access( role_id=custom_role.get_id(), ace_type=AceType.WRITE, scope=RoleScope.WHOLE_SUBTREE, - base_dn="cn=russia,cn=users,dc=md,dc=test", + base_dn="cn=russia,cn=Users,dc=md,dc=test", entity_type_id=user_entity_type.id, attribute_type_id=posix_email_attr.id, is_allow=True, @@ -83,7 +83,7 @@ async def test_multiple_access( role_id=custom_role.get_id(), ace_type=AceType.DELETE, scope=RoleScope.WHOLE_SUBTREE, - base_dn="cn=russia,cn=users,dc=md,dc=test", + base_dn="cn=russia,cn=Users,dc=md,dc=test", entity_type_id=user_entity_type.id, attribute_type_id=posix_email_attr.id, is_allow=True, @@ -95,9 +95,9 @@ async def test_multiple_access( await perform_ldap_search_and_validate( settings=settings, creds=creds, - search_base="cn=russia,cn=users,dc=md,dc=test", + search_base="cn=russia,cn=Users,dc=md,dc=test", expected_dn=[ - "dn: cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test", + "dn: cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test", ], expected_attrs_present=[ "userAccountControl: 512", @@ -106,7 +106,7 @@ async def test_multiple_access( expected_attrs_absent=["posixEmail: user1@mail.com"], ) - user_dn = "cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" + user_dn = "cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test" query = ( select(Directory) @@ -114,7 +114,7 @@ async def test_multiple_access( subqueryload(qa(Directory.attributes)), joinedload(qa(Directory.user)), ) - .filter_by(path=get_search_path(user_dn)) + .filter(get_filter_from_path(user_dn)) ) directory = (await session.scalars(query)).one() diff --git a/tests/test_ldap/test_roles/test_search.py b/tests/test_ldap/test_roles/test_search.py index a20e8f0dd..0795be89b 100644 --- a/tests/test_ldap/test_roles/test_search.py +++ b/tests/test_ldap/test_roles/test_search.py @@ -30,7 +30,7 @@ async def test_role_search_1(settings: Settings, creds: TestCreds) -> None: settings=settings, creds=creds, search_base=BASE_DN, - expected_dn=["dn: cn=user_non_admin,cn=users,dc=md,dc=test"], + expected_dn=["dn: cn=user_non_admin,cn=Users,dc=md,dc=test"], expected_attrs_present=[], expected_attrs_absent=[], ) @@ -52,7 +52,7 @@ async def test_role_search_2( role_id=custom_role.get_id(), ace_type=AceType.READ, scope=RoleScope.BASE_OBJECT, - base_dn="cn=groups,dc=md,dc=test", + base_dn="cn=Groups,dc=md,dc=test", attribute_type_id=None, entity_type_id=None, is_allow=True, @@ -65,8 +65,8 @@ async def test_role_search_2( creds=creds, search_base=BASE_DN, expected_dn=[ - "dn: cn=groups,dc=md,dc=test", - "dn: cn=user_non_admin,cn=users,dc=md,dc=test", + "dn: cn=Groups,dc=md,dc=test", + "dn: cn=user_non_admin,cn=Users,dc=md,dc=test", ], expected_attrs_present=[], expected_attrs_absent=[], @@ -102,9 +102,9 @@ async def test_role_search_3( creds=creds, search_base=BASE_DN, expected_dn=[ - "dn: cn=groups,dc=md,dc=test", - "dn: cn=users,dc=md,dc=test", - "dn: cn=user_non_admin,cn=users,dc=md,dc=test", + "dn: cn=Groups,dc=md,dc=test", + "dn: cn=Users,dc=md,dc=test", + "dn: cn=user_non_admin,cn=Users,dc=md,dc=test", "dn: ou=test_bit_rules,dc=md,dc=test", "dn: ou=testModifyDn1,dc=md,dc=test", "dn: ou=testModifyDn3,dc=md,dc=test", @@ -130,7 +130,7 @@ async def test_role_search_4( role_id=custom_role.get_id(), ace_type=AceType.READ, scope=RoleScope.WHOLE_SUBTREE, - base_dn="cn=groups,dc=md,dc=test", + base_dn="cn=Groups,dc=md,dc=test", attribute_type_id=None, entity_type_id=None, is_allow=True, @@ -143,13 +143,13 @@ async def test_role_search_4( creds=creds, search_base=BASE_DN, expected_dn=[ - "dn: cn=admin login only,cn=groups,dc=md,dc=test", - "dn: cn=groups,dc=md,dc=test", - "dn: cn=domain admins,cn=groups,dc=md,dc=test", - "dn: cn=domain computers,cn=groups,dc=md,dc=test", - "dn: cn=developers,cn=groups,dc=md,dc=test", - "dn: cn=domain users,cn=groups,dc=md,dc=test", - "dn: cn=user_non_admin,cn=users,dc=md,dc=test", + "dn: cn=admin login only,cn=Groups,dc=md,dc=test", + "dn: cn=Groups,dc=md,dc=test", + "dn: cn=domain admins,cn=Groups,dc=md,dc=test", + "dn: cn=domain computers,cn=Groups,dc=md,dc=test", + "dn: cn=developers,cn=Groups,dc=md,dc=test", + "dn: cn=domain users,cn=Groups,dc=md,dc=test", + "dn: cn=user_non_admin,cn=Users,dc=md,dc=test", ], expected_attrs_present=[], expected_attrs_absent=[], @@ -189,11 +189,11 @@ async def test_role_search_5( creds=creds, search_base=BASE_DN, expected_dn=[ - "dn: cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test", - "dn: cn=user_non_admin,cn=users,dc=md,dc=test", - "dn: cn=user_admin_for_roles,cn=users,dc=md,dc=test", - "dn: cn=user_admin,cn=users,dc=md,dc=test", - "dn: cn=user0,cn=users,dc=md,dc=test", + "dn: cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test", + "dn: cn=user_non_admin,cn=Users,dc=md,dc=test", + "dn: cn=user_admin_for_roles,cn=Users,dc=md,dc=test", + "dn: cn=user_admin,cn=Users,dc=md,dc=test", + "dn: cn=user0,cn=Users,dc=md,dc=test", "dn: cn=user_admin_1,ou=test_bit_rules,dc=md,dc=test", "dn: cn=user_admin_2,ou=test_bit_rules,dc=md,dc=test", "dn: cn=user_admin_3,ou=test_bit_rules,dc=md,dc=test", @@ -231,7 +231,7 @@ async def test_role_search_6( role_id=custom_role.get_id(), ace_type=AceType.READ, scope=RoleScope.BASE_OBJECT, - base_dn="cn=user0,cn=users,dc=md,dc=test", + base_dn="cn=user0,cn=Users,dc=md,dc=test", attribute_type_id=posix_email_attr.id, entity_type_id=user_entity_type.id, is_allow=True, @@ -242,9 +242,9 @@ async def test_role_search_6( await perform_ldap_search_and_validate( settings=settings, creds=creds, - search_base="cn=user0,cn=users,dc=md,dc=test", + search_base="cn=user0,cn=Users,dc=md,dc=test", expected_dn=[ - "dn: cn=user0,cn=users,dc=md,dc=test", + "dn: cn=user0,cn=Users,dc=md,dc=test", ], expected_attrs_present=[ "posixEmail: abctest@mail.com", @@ -281,7 +281,7 @@ async def test_role_search_7( role_id=custom_role.get_id(), ace_type=AceType.READ, scope=RoleScope.BASE_OBJECT, - base_dn="cn=user0,cn=users,dc=md,dc=test", + base_dn="cn=user0,cn=Users,dc=md,dc=test", attribute_type_id=None, entity_type_id=user_entity_type.id, is_allow=True, @@ -290,7 +290,7 @@ async def test_role_search_7( role_id=custom_role.get_id(), ace_type=AceType.READ, scope=RoleScope.BASE_OBJECT, - base_dn="cn=user0,cn=users,dc=md,dc=test", + base_dn="cn=user0,cn=Users,dc=md,dc=test", attribute_type_id=description_attr.id, entity_type_id=user_entity_type.id, is_allow=False, @@ -302,9 +302,9 @@ async def test_role_search_7( await perform_ldap_search_and_validate( settings=settings, creds=creds, - search_base="cn=user0,cn=users,dc=md,dc=test", + search_base="cn=user0,cn=Users,dc=md,dc=test", expected_dn=[ - "dn: cn=user0,cn=users,dc=md,dc=test", + "dn: cn=user0,cn=Users,dc=md,dc=test", ], expected_attrs_present=[ "posixEmail: abctest@mail.com", @@ -350,7 +350,7 @@ async def test_role_search_8( role_id=custom_role.get_id(), ace_type=AceType.READ, scope=RoleScope.BASE_OBJECT, - base_dn="cn=user0,cn=users,dc=md,dc=test", + base_dn="cn=user0,cn=Users,dc=md,dc=test", attribute_type_id=description_attr.id, entity_type_id=user_entity_type.id, is_allow=True, @@ -362,9 +362,9 @@ async def test_role_search_8( await perform_ldap_search_and_validate( settings=settings, creds=creds, - search_base="cn=user0,cn=users,dc=md,dc=test", + search_base="cn=user0,cn=Users,dc=md,dc=test", expected_dn=[ - "dn: cn=user0,cn=users,dc=md,dc=test", + "dn: cn=user0,cn=Users,dc=md,dc=test", ], expected_attrs_present=[ "description: 123 desc", @@ -404,7 +404,7 @@ async def test_role_search_9( role_id=custom_role.get_id(), ace_type=AceType.READ, scope=RoleScope.WHOLE_SUBTREE, - base_dn="cn=user0,cn=users,dc=md,dc=test", + base_dn="cn=user0,cn=Users,dc=md,dc=test", attribute_type_id=posix_email_attr.id, entity_type_id=user_entity_type.id, is_allow=True, @@ -413,7 +413,7 @@ async def test_role_search_9( role_id=custom_role.get_id(), ace_type=AceType.READ, scope=RoleScope.BASE_OBJECT, - base_dn="cn=user0,cn=users,dc=md,dc=test", + base_dn="cn=user0,cn=Users,dc=md,dc=test", attribute_type_id=description_attr.id, entity_type_id=user_entity_type.id, is_allow=False, @@ -425,9 +425,9 @@ async def test_role_search_9( await perform_ldap_search_and_validate( settings=settings, creds=creds, - search_base="cn=user0,cn=users,dc=md,dc=test", + search_base="cn=user0,cn=Users,dc=md,dc=test", expected_dn=[ - "dn: cn=user0,cn=users,dc=md,dc=test", + "dn: cn=user0,cn=Users,dc=md,dc=test", ], expected_attrs_present=[ "posixEmail: abctest@mail.com", diff --git a/tests/test_ldap/test_util/test_add.py b/tests/test_ldap/test_util/test_add.py index eef7d047b..b0312bc98 100644 --- a/tests/test_ldap/test_util/test_add.py +++ b/tests/test_ldap/test_util/test_add.py @@ -23,7 +23,7 @@ from ldap_protocol.roles.ace_dao import AccessControlEntryDAO from ldap_protocol.roles.dataclasses import AccessControlEntryDTO, RoleDTO from ldap_protocol.roles.role_dao import RoleDAO -from ldap_protocol.utils.queries import get_search_path +from ldap_protocol.utils.queries import get_filter_from_path from repo.pg.tables import queryable_attr as qa from tests.conftest import TestCreds @@ -37,7 +37,6 @@ async def test_ldap_root_add( ) -> None: """Test ldapadd on server.""" dn = "cn=test,dc=md,dc=test" - search_path = get_search_path(dn) with tempfile.NamedTemporaryFile("w") as file: file.write( ( @@ -46,7 +45,7 @@ async def test_ldap_root_add( "cn: test\n" "objectClass: organization\n" "objectClass: top\n" - "memberOf: cn=domain admins,cn=groups,dc=md,dc=test\n" + "memberOf: cn=domain admins,cn=Groups,dc=md,dc=test\n" ), ) file.seek(0) @@ -73,7 +72,7 @@ async def test_ldap_root_add( new_dir_query = ( select(Directory) .options(subqueryload(qa(Directory.attributes))) - .filter_by(path=search_path) + .filter(get_filter_from_path(dn)) ) new_dir = (await session.scalars(new_dir_query)).one() @@ -96,8 +95,8 @@ async def test_ldap_user_add_with_group( ) -> None: """Test ldapadd on server.""" user_dn = "cn=test,dc=md,dc=test" - user_search_path = get_search_path(user_dn) - group_dn = "cn=domain admins,cn=groups,dc=md,dc=test" + + group_dn = "cn=domain admins,cn=Groups,dc=md,dc=test" with tempfile.NamedTemporaryFile("w") as file: file.write( @@ -144,7 +143,7 @@ async def test_ldap_user_add_with_group( new_dir_query = ( select(Directory) .options(subqueryload(qa(Directory.attributes)), membership) - .filter_by(path=user_search_path) + .filter(get_filter_from_path(user_dn)) ) new_dir = (await session.scalars(new_dir_query)).one() @@ -163,8 +162,7 @@ async def test_ldap_user_add_group_with_group( user: dict, ) -> None: """Test ldapadd on server.""" - child_group_dn = "cn=twisted,cn=groups,dc=md,dc=test" - child_group_search_path = get_search_path(child_group_dn) + child_group_dn = "cn=twisted,cn=Groups,dc=md,dc=test" group_dn = "cn=domain admins,cn=groups,dc=md,dc=test" with tempfile.NamedTemporaryFile("w") as file: @@ -208,13 +206,16 @@ async def test_ldap_user_add_group_with_group( new_dir_query = ( select(Directory) .options(membership) - .filter_by(path=child_group_search_path) + .filter(get_filter_from_path(child_group_dn)) ) new_dir = (await session.scalars(new_dir_query)).one() assert new_dir.name == "twisted" - groups = [group.directory.path_dn for group in new_dir.group.parent_groups] + groups = [ + group.directory.path_dn.lower() + for group in new_dir.group.parent_groups + ] assert group_dn in groups @@ -287,7 +288,7 @@ async def try_add() -> int: name="Add Role", creator_upn=None, is_system=False, - groups=["cn=domain users,cn=groups," + base_dn], + groups=["cn=domain users,cn=Groups," + base_dn], ), ) @@ -355,7 +356,7 @@ async def test_ldap_user_add_with_duplicate_groups( ) -> None: """Duplicate memberOf yields single membership.""" user_dn = "cn=dup,dc=md,dc=test" - group_dn = "cn=domain admins,cn=groups,dc=md,dc=test" + group_dn = "cn=domain admins,cn=Groups,dc=md,dc=test" with tempfile.NamedTemporaryFile("w") as file: ldif = [ @@ -394,11 +395,10 @@ async def test_ldap_user_add_with_duplicate_groups( assert result == 0 - user_search_path = get_search_path(user_dn) user_row = await session.scalar( select(User) .join(qa(User.directory)) - .filter_by(path=user_search_path) + .filter(get_filter_from_path(user_dn)) .options( selectinload(qa(User.groups)).selectinload(qa(Group.directory)), ), diff --git a/tests/test_ldap/test_util/test_delete.py b/tests/test_ldap/test_util/test_delete.py index f93d1eed9..bff5011c2 100644 --- a/tests/test_ldap/test_util/test_delete.py +++ b/tests/test_ldap/test_util/test_delete.py @@ -39,7 +39,7 @@ async def test_ldap_delete( "cn: test\n" "objectClass: organization\n" "objectClass: top\n" - "memberOf: cn=domain admins,cn=groups,dc=md,dc=test\n" + "memberOf: cn=domain admins,cn=Groups,dc=md,dc=test\n" ), ) file.seek(0) @@ -94,7 +94,7 @@ async def test_ldap_delete( "-x", "-w", user["password"], - "cn=user0,cn=users,dc=md,dc=test", + "cn=user0,cn=Users,dc=md,dc=test", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) @@ -171,7 +171,7 @@ async def try_delete() -> int: name="Delete Role", creator_upn=None, is_system=False, - groups=["cn=domain users,cn=groups," + base_dn], + groups=["cn=domain users,cn=Groups," + base_dn], ), ) @@ -223,7 +223,7 @@ async def test_ldap_delete_primary_object_classes( user: dict, ) -> None: """Test deleting primary object class.""" - entry_dn = "cn=user0,cn=users,dc=md,dc=test" + entry_dn = "cn=user0,cn=Users,dc=md,dc=test" with tempfile.NamedTemporaryFile("w") as file: file.write( ( diff --git a/tests/test_ldap/test_util/test_modify.py b/tests/test_ldap/test_util/test_modify.py index 02b174b4b..eda4c1fe0 100644 --- a/tests/test_ldap/test_util/test_modify.py +++ b/tests/test_ldap/test_util/test_modify.py @@ -25,7 +25,7 @@ from ldap_protocol.roles.ace_dao import AccessControlEntryDAO from ldap_protocol.roles.dataclasses import AccessControlEntryDTO, RoleDTO from ldap_protocol.roles.role_dao import RoleDAO -from ldap_protocol.utils.queries import get_search_path +from ldap_protocol.utils.queries import get_filter_from_path from repo.pg.tables import Attribute, directory_table, queryable_attr as qa from tests.conftest import TestCreds @@ -38,14 +38,14 @@ async def test_ldap_base_modify( user: dict, ) -> None: """Test ldapmodify on server.""" - dn = "cn=user0,cn=users,dc=md,dc=test" + dn = "cn=user0,cn=Users,dc=md,dc=test" query = ( select(Directory) .options( subqueryload(qa(Directory.attributes)), joinedload(qa(Directory.user)), ) - .filter_by(path=get_search_path(dn)) + .filter(get_filter_from_path(dn)) ) directory = (await session.scalars(query)).one() @@ -139,11 +139,11 @@ async def test_ldap_membersip_user_delete( user: dict, ) -> None: """Test ldapmodify on server.""" - dn = "cn=user_admin,cn=users,dc=md,dc=test" + dn = "cn=user_admin,cn=Users,dc=md,dc=test" query = ( select(Directory) .options(selectinload(qa(Directory.groups))) - .filter_by(path=get_search_path(dn)) + .filter(get_filter_from_path(dn)) ) directory = (await session.scalars(query)).one() @@ -187,11 +187,11 @@ async def test_ldap_membersip_self_delete_admin_domain( user: dict, ) -> None: """Test ldapmodify on server.""" - dn = "cn=user0,cn=users,dc=md,dc=test" + dn = "cn=user0,cn=Users,dc=md,dc=test" query = ( select(Directory) .options(selectinload(qa(Directory.groups))) - .filter_by(path=get_search_path(dn)) + .filter(get_filter_from_path(dn)) ) directory = (await session.scalars(query)).one() @@ -201,7 +201,7 @@ async def test_ldap_membersip_self_delete_admin_domain( with tempfile.NamedTemporaryFile("w") as file: file.write( f"dn: {dn}\nchangetype: modify\ndelete: memberOf\n" - "memberOf: cn=domain admins,cn=groups,dc=md,dc=test\n", + "memberOf: cn=domain admins,cn=Groups,dc=md,dc=test\n", ) file.seek(0) proc = await asyncio.create_subprocess_exec( @@ -250,7 +250,7 @@ async def test_self_disable( response = await http_client.patch( "entry/update", json={ - "object": "cn=user0,cn=users,dc=md,dc=test", + "object": "cn=user0,cn=Users,dc=md,dc=test", "changes": [ { "operation": Operation.REPLACE, @@ -288,7 +288,7 @@ async def test_ldap_membersip_user_add( creds: TestCreds, ) -> None: """Test ldapmodify on server.""" - dn = "cn=user_non_admin,cn=users,dc=md,dc=test" + dn = "cn=user_non_admin,cn=Users,dc=md,dc=test" query = ( select(Directory) .options( @@ -296,7 +296,7 @@ async def test_ldap_membersip_user_add( qa(Group.directory), ), ) - .filter_by(path=get_search_path(dn)) + .filter(get_filter_from_path(dn)) ) directory = (await session.scalars(query)).one() @@ -312,7 +312,7 @@ async def test_ldap_membersip_user_add( f"dn: {dn}\n" "changetype: modify\n" "add: memberOf\n" - "memberOf: cn=domain admins,cn=groups,dc=md,dc=test\n" + "memberOf: cn=domain admins,cn=Groups,dc=md,dc=test\n" "-\n" ), ) @@ -351,17 +351,17 @@ async def test_ldap_membersip_user_replace( user: dict, ) -> None: """Test ldapmodify on server.""" - dn = "cn=user_admin,cn=users,dc=md,dc=test" + dn = "cn=user_admin,cn=Users,dc=md,dc=test" query = ( select(Directory) .options(selectinload(qa(Directory.groups))) - .filter_by(path=get_search_path(dn)) + .filter(get_filter_from_path(dn)) ) directory = (await session.scalars(query)).one() assert directory.groups - new_group_dn = "cn=twisted,cn=groups,dc=md,dc=test\n" + new_group_dn = "cn=twisted,cn=Groups,dc=md,dc=test\n" # add new group with tempfile.NamedTemporaryFile("w") as file: @@ -372,7 +372,7 @@ async def test_ldap_membersip_user_replace( "cn: twisted\n" "objectClass: group\n" "objectClass: top\n" - "memberOf: cn=domain admins,cn=groups,dc=md,dc=test\n" + "memberOf: cn=domain admins,cn=Groups,dc=md,dc=test\n" ), ) file.seek(0) @@ -403,7 +403,7 @@ async def test_ldap_membersip_user_replace( f"dn: {dn}\n" "changetype: modify\n" "replace: memberOf\n" - "memberOf: cn=twisted,cn=groups,dc=md,dc=test\n" + "memberOf: cn=twisted,cn=Groups,dc=md,dc=test\n" "-\n" ), ) @@ -442,7 +442,7 @@ async def test_ldap_membersip_grp_replace( user: dict, ) -> None: """Test ldapmodify on server.""" - dn = "cn=domain admins,cn=groups,dc=md,dc=test" + dn = "cn=domain admins,cn=Groups,dc=md,dc=test" query = ( select(Directory) @@ -451,7 +451,7 @@ async def test_ldap_membersip_grp_replace( .selectinload(qa(Group.parent_groups)) .selectinload(qa(Group.directory)), ) - .filter_by(path=get_search_path(dn)) + .filter(get_filter_from_path(dn)) ) directory = await session.scalar(query) @@ -463,7 +463,7 @@ async def test_ldap_membersip_grp_replace( with tempfile.NamedTemporaryFile("w") as file: file.write( ( - "dn: cn=twisted1,cn=groups,dc=md,dc=test\n" + "dn: cn=twisted1,cn=Groups,dc=md,dc=test\n" "name: twisted\n" "cn: twisted\n" "objectClass: group\n" @@ -498,7 +498,7 @@ async def test_ldap_membersip_grp_replace( f"dn: {dn}\n" "changetype: modify\n" "replace: memberOf\n" - "memberOf: cn=twisted1,cn=groups,dc=md,dc=test\n" + "memberOf: cn=twisted1,cn=Groups,dc=md,dc=test\n" "-\n" ), ) @@ -537,7 +537,7 @@ async def test_ldap_modify_dn( user: dict, ) -> None: """Test ldapmodify on server.""" - dn = "cn=user0,cn=users,dc=md,dc=test" + dn = "cn=user0,cn=Users,dc=md,dc=test" with tempfile.NamedTemporaryFile("w") as file: file.write( @@ -546,7 +546,7 @@ async def test_ldap_modify_dn( "changetype: modrdn\n" "newrdn: cn=user2\n" "deleteoldrdn: 1\n" - "newsuperior: cn=users,dc=md,dc=test\n" + "newsuperior: cn=Users,dc=md,dc=test\n" ), ) file.seek(0) @@ -574,7 +574,7 @@ async def test_ldap_modify_dn( select(Directory) .filter( directory_table.c.path - == ["dc=test", "dc=md", "cn=users", "cn=user2"], + == ["dc=test", "dc=md", "cn=Users", "cn=user2"], directory_table.c.entity_type_id.isnot(None), ), ) # fmt: skip @@ -588,7 +588,7 @@ async def test_ldap_modify_password_change( creds: TestCreds, ) -> None: """Test ldapmodify on server.""" - dn = "cn=user0,cn=users,dc=md,dc=test" + dn = "cn=user0,cn=Users,dc=md,dc=test" new_password = "Password12345" # noqa with tempfile.NamedTemporaryFile("w") as file: @@ -655,9 +655,8 @@ async def test_ldap_modify_with_ap( access_control_entry_dao: AccessControlEntryDAO, ) -> None: """Test ldapmodify on server.""" - dn = "cn=users,dc=md,dc=test" + dn = "cn=Users,dc=md,dc=test" base_dn = "dc=md,dc=test" - search_path = get_search_path(dn) query = ( select(Directory) @@ -665,7 +664,7 @@ async def test_ldap_modify_with_ap( subqueryload(qa(Directory.attributes)), joinedload(qa(Directory.user)), ) - .filter_by(path=search_path) + .filter(get_filter_from_path(dn)) ) directory = await session.scalar(query) @@ -719,7 +718,7 @@ async def try_modify() -> int: name="Modify Role", creator_upn=None, is_system=False, - groups=["cn=domain users,cn=groups," + base_dn], + groups=["cn=domain users,cn=Groups," + base_dn], ), ) @@ -831,7 +830,7 @@ async def fetch_directory_by_dn(session: AsyncSession, dn: str) -> Directory: selectinload(qa(Directory.attributes)), joinedload(qa(Directory.group)), ) - .filter(qa(Directory.path) == get_search_path(dn)) + .filter(get_filter_from_path(dn)) ) return (await session.scalars(query)).one() @@ -843,25 +842,25 @@ async def fetch_directory_by_dn(session: AsyncSession, dn: str) -> Directory: [ ( "add", - "cn=developers,cn=groups,dc=md,dc=test", + "cn=developers,cn=Groups,dc=md,dc=test", {"domain admins", "developers"}, True, ), ( "add", - "cn=domain admins,cn=groups,dc=md,dc=test", + "cn=domain admins,cn=Groups,dc=md,dc=test", {"domain admins"}, True, ), ( "delete", - "cn=developers,cn=groups,dc=md,dc=test", + "cn=developers,cn=Groups,dc=md,dc=test", {"domain admins", "developers"}, False, ), ( "replace", - "cn=developers,cn=groups,dc=md,dc=test", + "cn=developers,cn=Groups,dc=md,dc=test", {"domain admins", "developers"}, True, ), @@ -877,7 +876,7 @@ async def test_ldap_modify_primary_group_id_scenarios( creds: TestCreds, ) -> None: """Test ldapmodify request with primaryGroupID for various scenarios.""" - user_dn = "cn=user_admin,cn=users,dc=md,dc=test" + user_dn = "cn=user_admin,cn=Users,dc=md,dc=test" user_dir = await fetch_directory_by_dn(session, user_dn) group_dir = await fetch_directory_by_dn(session, group_dn) @@ -932,22 +931,22 @@ async def test_ldap_modify_primary_group_id_scenarios( ("values", "include_dev_group", "expected_result", "expected_groups"), [ ( - ["cn=domain admins,cn=groups,dc=md,dc=test"], + ["cn=domain admins,cn=Groups,dc=md,dc=test"], True, 1, {"domain admins", "developers"}, ), ( - ["cn=domain admins,cn=groups,dc=md,dc=test"], + ["cn=domain admins,cn=Groups,dc=md,dc=test"], False, 0, {"domain admins"}, ), ( [ - "cn=domain admins,cn=groups,dc=md,dc=test", - "cn=developers,cn=groups,dc=md,dc=test", - "cn=domain computers,cn=groups,dc=md,dc=test", + "cn=domain admins,cn=Groups,dc=md,dc=test", + "cn=developers,cn=Groups,dc=md,dc=test", + "cn=domain computers,cn=Groups,dc=md,dc=test", ], True, 0, @@ -965,8 +964,8 @@ async def test_ldap_modify_replace_memberof_primary_group_various( creds: TestCreds, ) -> None: """Test ldapmodify request replace memberOf attribute.""" - user_dn = "cn=user_admin,cn=users,dc=md,dc=test" - dev_group_dn = "cn=developers,cn=groups,dc=md,dc=test" + user_dn = "cn=user_admin,cn=Users,dc=md,dc=test" + dev_group_dn = "cn=developers,cn=Groups,dc=md,dc=test" user_dir = await fetch_directory_by_dn(session, user_dn) dev_group_dir = await fetch_directory_by_dn(session, dev_group_dn) diff --git a/tests/test_ldap/test_util/test_search.py b/tests/test_ldap/test_util/test_search.py index 903fb2598..338822a62 100644 --- a/tests/test_ldap/test_util/test_search.py +++ b/tests/test_ldap/test_util/test_search.py @@ -62,9 +62,9 @@ async def test_ldap_search(settings: Settings, creds: TestCreds) -> None: result = await proc.wait() assert result == 0 - assert "dn: cn=groups,dc=md,dc=test" in data - assert "dn: cn=users,dc=md,dc=test" in data - assert "dn: cn=user0,cn=users,dc=md,dc=test" in data + assert "dn: cn=Groups,dc=md,dc=test" in data + assert "dn: cn=Users,dc=md,dc=test" in data + assert "dn: cn=user0,cn=Users,dc=md,dc=test" in data @pytest.mark.asyncio @@ -89,7 +89,7 @@ async def test_ldap_search_filter( "dc=md,dc=test", "(&" "(objectClass=user)" - "(memberOf:1.2.840.113556.1.4.1941:=cn=domain admins,cn=groups,dc=md,\ + "(memberOf:1.2.840.113556.1.4.1941:=cn=domain admins,cn=Groups,dc=md,\ dc=test)" ")", stdout=asyncio.subprocess.PIPE, @@ -101,8 +101,8 @@ async def test_ldap_search_filter( result = await proc.wait() assert result == 0 - assert "dn: cn=user0,cn=users,dc=md,dc=test" in data - assert "dn: cn=user1,cn=moscow,cn=russia,cn=users,dc=md,dc=test" in data + assert "dn: cn=user0,cn=Users,dc=md,dc=test" in data + assert "dn: cn=user1,cn=moscow,cn=russia,cn=Users,dc=md,dc=test" in data @pytest.mark.asyncio @@ -298,7 +298,7 @@ async def test_ldap_search_filter_prefix( result = await proc.wait() assert result == 0 - assert "dn: cn=user0,cn=users,dc=md,dc=test" in data + assert "dn: cn=user0,cn=Users,dc=md,dc=test" in data @pytest.mark.asyncio @@ -317,7 +317,7 @@ async def test_bind_policy( assert policy group = await get_group( - dn="cn=domain admins,cn=groups,dc=md,dc=test", + dn="cn=domain admins,cn=Groups,dc=md,dc=test", session=session, ) policy.groups.append(group) @@ -368,7 +368,7 @@ async def test_bind_policy_missing_group( user = (await session.scalars(user_query)).one() policy.groups = await get_groups( - ["cn=domain admins,cn=groups,dc=md,dc=test"], + ["cn=domain admins,cn=Groups,dc=md,dc=test"], session, ) user.groups.clear() @@ -432,7 +432,7 @@ async def test_bvalue_in_search_request( ) -> None: """Test SearchRequest with bytes data.""" request = SearchRequest( - base_object="cn=user0,cn=users,dc=md,dc=test", + base_object="cn=user0,cn=Users,dc=md,dc=test", scope=0, deref_aliases=0, size_limit=0, @@ -525,7 +525,7 @@ async def test_ldap_search_access_control_denied( assert result == 0 assert dn_list == [ - "dn: cn=user_non_admin,cn=users,dc=md,dc=test", + "dn: cn=user_non_admin,cn=Users,dc=md,dc=test", ] await session.commit() @@ -535,7 +535,7 @@ async def test_ldap_search_access_control_denied( name="Groups Read Role", creator_upn=None, is_system=False, - groups=["cn=domain users,cn=groups,dc=md,dc=test"], + groups=["cn=domain users,cn=Groups,dc=md,dc=test"], ), ) @@ -543,7 +543,7 @@ async def test_ldap_search_access_control_denied( role_id=role_dao.get_last_id(), ace_type=AceType.READ, scope=RoleScope.WHOLE_SUBTREE, - base_dn="cn=groups,dc=md,dc=test", + base_dn="cn=Groups,dc=md,dc=test", attribute_type_id=None, entity_type_id=None, is_allow=True, @@ -577,12 +577,12 @@ async def test_ldap_search_access_control_denied( assert result == 0 assert sorted(dn_list) == sorted( [ - "dn: cn=groups,dc=md,dc=test", - "dn: cn=domain admins,cn=groups,dc=md,dc=test", - "dn: cn=admin login only,cn=groups,dc=md,dc=test", - "dn: cn=developers,cn=groups,dc=md,dc=test", - "dn: cn=domain computers,cn=groups,dc=md,dc=test", - "dn: cn=domain users,cn=groups,dc=md,dc=test", - "dn: cn=user_non_admin,cn=users,dc=md,dc=test", + "dn: cn=Groups,dc=md,dc=test", + "dn: cn=domain admins,cn=Groups,dc=md,dc=test", + "dn: cn=admin login only,cn=Groups,dc=md,dc=test", + "dn: cn=developers,cn=Groups,dc=md,dc=test", + "dn: cn=domain computers,cn=Groups,dc=md,dc=test", + "dn: cn=domain users,cn=Groups,dc=md,dc=test", + "dn: cn=user_non_admin,cn=Users,dc=md,dc=test", ], ) From 2cffa21c9454dd5db1850be7b454305378c245ca Mon Sep 17 00:00:00 2001 From: Misha-Shvets <76677350+Misha-Shvets@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:02:02 +0300 Subject: [PATCH 06/45] Add: member cte filter (#914) --- app/ldap_protocol/filter_interpreter.py | 34 +++++- .../test_main/test_router/test_search.py | 110 ++++++++++++++++++ 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/app/ldap_protocol/filter_interpreter.py b/app/ldap_protocol/filter_interpreter.py index c456fac00..ce0b301c5 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,24 @@ 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( + 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..77baea3f1 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,115 @@ 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_search_recursive_member( + http_client: AsyncClient, +) -> None: + """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", + ] + 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_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" + + await _create_group(group3_dn, "recursive_test_group3") + await _create_group(group2_dn, "recursive_test_group2") + await _create_group(group1_dn, "recursive_test_group1") + + 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", + 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 + assert "cn=domain admins,cn=Groups,dc=md,dc=test" in dns + + @pytest.mark.asyncio @pytest.mark.usefixtures("session") @pytest.mark.parametrize("dataset", test_search_by_rule_anr_dataset) From c21df4921e4178e3a0118435ff7f358837604970 Mon Sep 17 00:00:00 2001 From: Misha-Shvets <76677350+Misha-Shvets@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:04:50 +0300 Subject: [PATCH 07/45] Add: include severity in NormalizedAuditEvent dataclass (#917) --- app/ldap_protocol/policies/audit/events/dataclasses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/ldap_protocol/policies/audit/events/dataclasses.py b/app/ldap_protocol/policies/audit/events/dataclasses.py index 78432b6ca..38c922043 100644 --- a/app/ldap_protocol/policies/audit/events/dataclasses.py +++ b/app/ldap_protocol/policies/audit/events/dataclasses.py @@ -208,6 +208,7 @@ def destination_dict(self) -> dict[str, Any]: "policy_id": self.policy_id, "details": self.details, "service_name": self.service_name, + "severity": self.severity, } From 9fef75361c274b316a88dd33805f1e51a0f0ff41 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Fri, 30 Jan 2026 14:49:52 +0300 Subject: [PATCH 08/45] Add: sync user's and computer's names with principal name after ModifyRequest:`sAMAccountName` (#911) --- app/entities.py | 3 - .../scripts/principal_block_user_sync.py | 2 +- app/extra/scripts/uac_sync.py | 2 +- app/ldap_protocol/auth/auth_manager.py | 2 +- app/ldap_protocol/ldap_requests/add.py | 31 +-- app/ldap_protocol/ldap_requests/bind.py | 2 +- app/ldap_protocol/ldap_requests/delete.py | 2 +- app/ldap_protocol/ldap_requests/extended.py | 2 +- app/ldap_protocol/ldap_requests/modify.py | 198 ++++++++++++--- app/ldap_protocol/objects.py | 6 +- app/ldap_protocol/roles/access_manager.py | 9 +- interface | 2 +- tests/test_api/test_main/conftest.py | 24 ++ .../test_main/test_router/test_add.py | 7 + .../test_main/test_router/test_modify.py | 228 +++++++++++++++++- 15 files changed, 447 insertions(+), 73 deletions(-) diff --git a/app/entities.py b/app/entities.py index 535da02f6..9ee945fe6 100644 --- a/app/entities.py +++ b/app/entities.py @@ -372,9 +372,6 @@ class User: "homedirectory": "homeDirectory", } - def get_upn_prefix(self) -> str: - return self.user_principal_name.split("@")[0] - def is_expired(self) -> bool: if self.account_exp is None: return False diff --git a/app/extra/scripts/principal_block_user_sync.py b/app/extra/scripts/principal_block_user_sync.py index 858a65a7d..d4c483728 100644 --- a/app/extra/scripts/principal_block_user_sync.py +++ b/app/extra/scripts/principal_block_user_sync.py @@ -32,7 +32,7 @@ async def principal_block_sync( if "@" in user.user_principal_name: principal_postfix = user.user_principal_name.split("@")[1].upper() - principal_name = f"{user.get_upn_prefix()}@{principal_postfix}" + principal_name = f"{user.sam_account_name}@{principal_postfix}" else: continue diff --git a/app/extra/scripts/uac_sync.py b/app/extra/scripts/uac_sync.py index f0623e1b1..8f9ce4f46 100644 --- a/app/extra/scripts/uac_sync.py +++ b/app/extra/scripts/uac_sync.py @@ -71,7 +71,7 @@ async def disable_accounts( ) # fmt: skip async for user in users: - await kadmin.lock_principal(user.get_upn_prefix()) + await kadmin.lock_principal(user.sam_account_name) await add_lock_and_expire_attributes( session, diff --git a/app/ldap_protocol/auth/auth_manager.py b/app/ldap_protocol/auth/auth_manager.py index 61c336f56..d2e9073fd 100644 --- a/app/ldap_protocol/auth/auth_manager.py +++ b/app/ldap_protocol/auth/auth_manager.py @@ -232,7 +232,7 @@ async def _update_password( if include_krb: await self._kadmin.create_or_update_principal_pw( - user.get_upn_prefix(), + user.sam_account_name, new_password, ) diff --git a/app/ldap_protocol/ldap_requests/add.py b/app/ldap_protocol/ldap_requests/add.py index 75be3f6fc..a16c6b182 100644 --- a/app/ldap_protocol/ldap_requests/add.py +++ b/app/ldap_protocol/ldap_requests/add.py @@ -249,11 +249,7 @@ async def handle( # noqa: C901 # in the attributes if ( attr_name in Directory.ro_fields - or attr_name - in ( - "userpassword", - "unicodepwd", - ) + or attr_name in ("userpassword", "unicodepwd") or attr_name == new_dir.rdname ): continue @@ -342,11 +338,11 @@ async def handle( # noqa: C901 ), ) - for uattr, value in { - "loginShell": "/bin/bash", - "uidNumber": str(create_integer_hash(user.sam_account_name)), - "homeDirectory": f"/home/{user.sam_account_name}", - }.items(): + for uattr, value in ( + ("loginShell", "/bin/bash"), + ("uidNumber", str(create_integer_hash(user.sam_account_name))), + ("homeDirectory", f"/home/{user.sam_account_name}"), + ): if uattr in user_attributes: value = user_attributes[uattr] del user_attributes[uattr] @@ -421,6 +417,15 @@ 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, @@ -461,16 +466,14 @@ async def handle( # noqa: C901 KRBAPIDeletePrincipalError, KRBAPIPrincipalNotFoundError, ): - await ctx.kadmin.del_principal( - user.get_upn_prefix(), - ) + await ctx.kadmin.del_principal(user.sam_account_name) pw = ( self.password.get_secret_value() if self.password else None ) - await ctx.kadmin.add_principal(user.get_upn_prefix(), pw) + await ctx.kadmin.add_principal(user.sam_account_name, pw) elif is_computer: await ctx.kadmin.add_principal( diff --git a/app/ldap_protocol/ldap_requests/bind.py b/app/ldap_protocol/ldap_requests/bind.py index 445b2f25c..ad764649e 100644 --- a/app/ldap_protocol/ldap_requests/bind.py +++ b/app/ldap_protocol/ldap_requests/bind.py @@ -209,7 +209,7 @@ async def handle( KRBAPIConnectionError, ): await ctx.kadmin.add_principal( - user.get_upn_prefix(), + user.sam_account_name, self.authentication_choice.password.get_secret_value(), 0.1, ) diff --git a/app/ldap_protocol/ldap_requests/delete.py b/app/ldap_protocol/ldap_requests/delete.py index e2b127331..3bf89343b 100644 --- a/app/ldap_protocol/ldap_requests/delete.py +++ b/app/ldap_protocol/ldap_requests/delete.py @@ -154,7 +154,7 @@ async def handle( # noqa: C901 await ctx.session_storage.clear_user_sessions( directory.user.id, ) - await ctx.kadmin.del_principal(directory.user.get_upn_prefix()) + 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) diff --git a/app/ldap_protocol/ldap_requests/extended.py b/app/ldap_protocol/ldap_requests/extended.py index c3967889e..1f8cca946 100644 --- a/app/ldap_protocol/ldap_requests/extended.py +++ b/app/ldap_protocol/ldap_requests/extended.py @@ -248,7 +248,7 @@ async def handle( ): try: await ctx.kadmin.create_or_update_principal_pw( - user.get_upn_prefix(), + user.sam_account_name, new_password, ) except ( diff --git a/app/ldap_protocol/ldap_requests/modify.py b/app/ldap_protocol/ldap_requests/modify.py index 676550e3e..7ab3333fb 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 sqlalchemy import Select, and_, delete, or_, select, update +from sqlalchemy import Select, and_, delete, func, or_, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload, selectinload @@ -25,6 +25,7 @@ KRBAPIForcePasswordChangeError, KRBAPILockPrincipalError, KRBAPIPrincipalNotFoundError, + KRBAPIRenamePrincipalError, ) from ldap_protocol.ldap_codes import LDAPCodes from ldap_protocol.ldap_responses import ModifyResponse, PartialAttribute @@ -37,11 +38,16 @@ from ldap_protocol.policies.password import PasswordPolicyUseCases from ldap_protocol.session_storage import SessionStorage from ldap_protocol.utils.cte import check_root_group_membership_intersection -from ldap_protocol.utils.helpers import ft_to_dt, validate_entry +from ldap_protocol.utils.helpers import ( + ft_to_dt, + is_dn_in_base_directory, + validate_entry, +) from ldap_protocol.utils.queries import ( add_lock_and_expire_attributes, clear_group_membership, extend_group_membership, + get_base_directories, get_directories, get_directory_by_rid, get_filter_from_path, @@ -71,6 +77,7 @@ class ModifyForbiddenError(Exception): PermissionError, ModifyForbiddenError, KRBAPIPrincipalNotFoundError, + KRBAPIRenamePrincipalError, KRBAPILockPrincipalError, KRBAPIForcePasswordChangeError, ) @@ -100,6 +107,11 @@ class ModifyRequest(BaseRequest): object: str changes: list[Changes] + # 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] = {} + @classmethod def from_data(cls, data: list[ASN1Row]) -> "ModifyRequest": entry, proto_changes = data @@ -184,7 +196,7 @@ async def handle( entity_type_id=directory.entity_type_id, ) - names = {change.get_name() for change in self.changes} + names = {change.l_type for change in self.changes} password_change_requested = self._is_password_change_requested(names) self_modify = directory.id == ctx.ldap_session.user.directory_id @@ -212,7 +224,7 @@ async def handle( return for change in self.changes: - if change.modification.type.lower() in Directory.ro_fields: + if change.l_type in Directory.ro_fields: continue if not ctx.attribute_value_validator.is_partial_attribute_valid( # noqa: E501 @@ -222,7 +234,7 @@ async def handle( await ctx.session.rollback() yield ModifyResponse( result_code=LDAPCodes.UNDEFINED_ATTRIBUTE_TYPE, - message="Invalid attribute value(s)", + error_message="Invalid attribute value(s)", ) return @@ -612,6 +624,18 @@ async def _validate_object_class_modification( if is_object_class_in_replaced or is_object_class_in_deleted: raise ModifyForbiddenError("ObjectClass can't be deleted.") + def _need_to_cache_samaccountname_old_value( + self, + change: Changes, + directory: Directory, + ) -> bool: + 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), + ) + async def _delete( self, change: Changes, @@ -621,9 +645,8 @@ async def _delete( name_only: bool = False, ) -> None: attrs = [] - name = change.modification.type.lower() - if name == "memberof": + if change.l_type == "memberof": await self._delete_memberof( change=change, directory=directory, @@ -632,7 +655,7 @@ async def _delete( ) return - if name == "member": + if change.l_type == "member": await self._delete_member( change=change, directory=directory, @@ -641,14 +664,16 @@ async def _delete( ) return - if name == "objectclass": + if change.l_type == "objectclass": await self._validate_object_class_modification(change, directory) if name_only or not change.modification.vals: attrs.append(qa(Attribute.name) == change.modification.type) else: for value in change.modification.vals: - if name not in (Directory.search_fields | User.search_fields): + if change.l_type not in ( + Directory.search_fields | User.search_fields + ): if isinstance(value, str): condition = qa(Attribute.value) == value elif isinstance(value, bytes): @@ -656,10 +681,15 @@ async def _delete( attrs.append( and_( - qa(Attribute.name) == change.modification.type, + func.lower(qa(Attribute.name)) == change.l_type, condition, ), - ) + ) # fmt: skip + + 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] if attrs: del_query = ( @@ -773,16 +803,15 @@ async def _add_group_attrs( directory: Directory, session: AsyncSession, ) -> None: - name = change.get_name() - if name == "primarygroupid": + if change.l_type == "primarygroupid": await self._add_primary_group_attribute( change, directory, session, ) - elif name == "memberof": + elif change.l_type == "memberof": await self._add_memberof(change, directory, session) - elif name == "member": + elif change.l_type == "member": await self._add_member(change, directory, session) async def _add( # noqa: C901 @@ -798,23 +827,22 @@ async def _add( # noqa: C901 password_utils: PasswordUtils, ) -> None: attrs = [] - name = change.get_name() - if name in {"memberof", "member", "primarygroupid"}: + 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 name == "useraccountcontrol": + if change.l_type == "useraccountcontrol": uac_val = int(value) if not UserAccountControlFlag.is_value_valid(uac_val): continue elif ( - bool( - uac_val & UserAccountControlFlag.ACCOUNTDISABLE, - ) + bool(uac_val & UserAccountControlFlag.ACCOUNTDISABLE) and directory.user ): if directory.path_dn == current_user.dn: @@ -823,7 +851,7 @@ async def _add( # noqa: C901 ) await kadmin.lock_principal( - directory.user.get_upn_prefix(), + directory.user.sam_account_name, ) await add_lock_and_expire_attributes( @@ -837,9 +865,7 @@ async def _add( # noqa: C901 ) elif ( - not bool( - uac_val & UserAccountControlFlag.ACCOUNTDISABLE, - ) + not bool(uac_val & UserAccountControlFlag.ACCOUNTDISABLE) and directory.user ): await unlock_principal( @@ -858,37 +884,87 @@ async def _add( # noqa: C901 ), ) # fmt: skip - if name == "pwdlastset" and value == "0" and directory.user: + if ( + change.l_type == "pwdlastset" + and value == "0" + and directory.user + ): await kadmin.force_princ_pw_change( - directory.user.get_upn_prefix(), + directory.user.sam_account_name, ) - if name == directory.rdname: + if change.l_type == directory.rdname: await session.execute( update(Directory) .filter(directory_table.c.id == directory.id) .values(name=value), ) - if name in Directory.search_fields: + if change.l_type in Directory.search_fields: await session.execute( update(Directory) .filter(directory_table.c.id == directory.id) - .values({name: value}), + .values({change.l_type: value}), ) - elif name in User.search_fields: - if name == "accountexpires": + elif ( + change.l_type in User.search_fields + and directory.entity_type + and directory.entity_type.name == EntityTypeNames.USER + and directory.user + ): + if change.l_type == "accountexpires": new_value = ft_to_dt(int(value)) if value != "0" else None else: new_value = value # type: ignore - await session.execute( - update(User) - .filter_by(directory=directory) - .values({name: new_value}), + if change.l_type in ("userprincipalname", "samaccountname"): + if change.l_type == "userprincipalname": + 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": + new_sam_account_name = str(new_value) + new_user_principal_name = f"{new_sam_account_name}@{base_dir.name}" # noqa: E501 # fmt: skip + + if directory.user.sam_account_name != new_sam_account_name: + await kadmin.rename_princ( + directory.user.sam_account_name, + new_sam_account_name, + ) + + directory.user.user_principal_name = new_user_principal_name # noqa: E501 # fmt: skip + directory.user.sam_account_name = new_sam_account_name + else: + await session.execute( + update(User) + .filter_by(directory=directory) + .values({change.l_type: new_value}), + ) + + elif ( + change.l_type == "samaccountname" + and directory.entity_type + and directory.entity_type.name == EntityTypeNames.COMPUTER + ): + await self._modify_computer_samaccountname( + change, + kadmin, + base_dir, + value, ) - elif name in ("userpassword", "unicodepwd") and directory.user: + attrs.append( + Attribute( + name=change.modification.type, + value=value if isinstance(value, str) else None, + bvalue=value if isinstance(value, bytes) else None, + directory_id=directory.id, + ), + ) # fmt: skip + + elif ( + change.l_type in ("userpassword", "unicodepwd") + and directory.user + ): if not settings.USE_CORE_TLS: raise PermissionError("TLS required") @@ -918,7 +994,7 @@ async def _add( # noqa: C901 directory.user, ) await kadmin.create_or_update_principal_pw( - directory.user.get_upn_prefix(), + directory.user.sam_account_name, value, ) @@ -935,3 +1011,47 @@ async def _add( # noqa: C901 ) session.add_all(attrs) + + async def _modify_computer_samaccountname( + self, + change: Changes, + kadmin: AbstractKadmin, + base_dir: Directory, + new_sam_account_name: bytes | str, + ) -> None: + 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: + raise ModifyForbiddenError("Old sAMAccountName value not found.") + + if old_sam_account_name != new_sam_account_name: + await kadmin.rename_princ( + f"host/{old_sam_account_name}", + f"host/{new_sam_account_name}", + ) + await kadmin.rename_princ( + f"host/{old_sam_account_name}.{base_dir.name}", + f"host/{new_sam_account_name}.{base_dir.name}", + ) + + async def _get_base_dir( + self, + directory: Directory, + session: AsyncSession, + ) -> Directory: + base_dir = None + + for base_directory in await get_base_directories(session): + if is_dn_in_base_directory( + base_directory, + directory.path_dn, + ): + base_dir = base_directory + break + else: + raise ModifyForbiddenError( + "Base directory for computer not found.", + ) + + return base_dir diff --git a/app/ldap_protocol/objects.py b/app/ldap_protocol/objects.py index 75effb3f0..d69301c0e 100644 --- a/app/ldap_protocol/objects.py +++ b/app/ldap_protocol/objects.py @@ -5,6 +5,7 @@ """ from enum import IntEnum, IntFlag, StrEnum, unique +from functools import cached_property from typing import Annotated import annotated_types @@ -82,8 +83,9 @@ class Changes(BaseModel): operation: Operation modification: PartialAttribute - def get_name(self) -> str: - """Get mod name.""" + @cached_property + def l_type(self) -> str: + """Get modification type (it's attribute name) in lower case.""" return self.modification.type.lower() diff --git a/app/ldap_protocol/roles/access_manager.py b/app/ldap_protocol/roles/access_manager.py index 9e7a964f4..e651e5d62 100644 --- a/app/ldap_protocol/roles/access_manager.py +++ b/app/ldap_protocol/roles/access_manager.py @@ -123,17 +123,16 @@ def check_modify_access( return False for change in changes: - attr_name = change.get_name() if change.operation == Operation.DELETE: if not cls._check_modify_access( - attr_name, + change.l_type, filtered_aces, AceType.DELETE, ): return False elif change.operation == Operation.ADD: if not cls._check_modify_access( - attr_name, + change.l_type, filtered_aces, AceType.WRITE, ): @@ -141,12 +140,12 @@ def check_modify_access( else: if not ( cls._check_modify_access( - attr_name, + change.l_type, filtered_aces, AceType.WRITE, ) and cls._check_modify_access( - attr_name, + change.l_type, filtered_aces, AceType.DELETE, ) 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/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_add.py b/tests/test_api/test_main/test_router/test_add.py index ddaf7e218..3282305f1 100644 --- a/tests/test_api/test_main/test_router/test_add.py +++ b/tests/test_api/test_main/test_router/test_add.py @@ -171,6 +171,13 @@ async def test_api_add_computer(http_client: AsyncClient) -> None: else: raise Exception("Computer without userAccountControl") + for attr in data["search_result"][0]["partial_attributes"]: + if attr["type"] == "sAMAccountName": + assert attr["vals"][0] == "PC" + break + else: + raise Exception("Computer without sAMAccountName") + @pytest.mark.asyncio @pytest.mark.usefixtures("session") diff --git a/tests/test_api/test_main/test_router/test_modify.py b/tests/test_api/test_main/test_router/test_modify.py index 82bf7248a..2d0dbd5af 100644 --- a/tests/test_api/test_main/test_router/test_modify.py +++ b/tests/test_api/test_main/test_router/test_modify.py @@ -8,6 +8,7 @@ from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession +from ldap_protocol.kerberos.base import AbstractKadmin from ldap_protocol.ldap_codes import LDAPCodes from ldap_protocol.ldap_requests.modify import Operation @@ -16,10 +17,13 @@ @pytest.mark.usefixtures("adding_test_user") @pytest.mark.usefixtures("setup_session") @pytest.mark.usefixtures("session") -async def test_api_correct_modify(http_client: AsyncClient) -> None: +async def test_api_correct_modify_user_accountexpires( + http_client: AsyncClient, +) -> None: """Test API for modify object attribute.""" entry_dn = "cn=test,dc=md,dc=test" new_value = "133632677730000000" + response = await http_client.patch( "/entry/update", json={ @@ -37,7 +41,6 @@ async def test_api_correct_modify(http_client: AsyncClient) -> None: ) data = response.json() - assert isinstance(data, dict) assert data.get("resultCode") == LDAPCodes.SUCCESS @@ -57,13 +60,232 @@ async def test_api_correct_modify(http_client: AsyncClient) -> None: ) data = response.json() - assert data["resultCode"] == LDAPCodes.SUCCESS assert data["search_result"][0]["object_name"] == entry_dn for attr in data["search_result"][0]["partial_attributes"]: if attr["type"] == "accountExpires": assert attr["vals"][0] == new_value + break + else: + raise Exception("User without accountExpires") + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("adding_test_user") +@pytest.mark.usefixtures("setup_session") +@pytest.mark.usefixtures("session") +async def test_api_correct_modify_user_samaccountname( + http_client: AsyncClient, + kadmin: AbstractKadmin, +) -> None: + """Test API for modify object attribute.""" + entry_dn = "cn=test,dc=md,dc=test" + + response = await http_client.patch( + "/entry/update", + json={ + "object": entry_dn, + "changes": [ + { + "operation": Operation.REPLACE, + "modification": { + "type": "sAMAccountName", + "vals": ["NEW user name"], + }, + }, + ], + }, + ) + + data = response.json() + assert isinstance(data, dict) + assert data.get("resultCode") == LDAPCodes.SUCCESS + assert kadmin.rename_princ.call_args.args == ("new_user", "NEW user name") # type: ignore + + response = await http_client.post( + "entry/search", + json={ + "base_object": entry_dn, + "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"] == entry_dn + + for attr in data["search_result"][0]["partial_attributes"]: + if attr["type"] == "sAMAccountName": + assert attr["vals"][0] == "NEW user name" + break + else: + raise Exception("User without sAMAccountName") + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("adding_test_user") +@pytest.mark.usefixtures("setup_session") +@pytest.mark.usefixtures("session") +async def test_api_correct_modify_user_userprincipalname( + http_client: AsyncClient, + kadmin: AbstractKadmin, +) -> None: + """Test API for modify object attribute.""" + entry_dn = "cn=test,dc=md,dc=test" + + response = await http_client.patch( + "/entry/update", + json={ + "object": entry_dn, + "changes": [ + { + "operation": Operation.REPLACE, + "modification": { + "type": "userPrincipalName", + "vals": ["newbiguser@md.test"], + }, + }, + ], + }, + ) + + data = response.json() + assert isinstance(data, dict) + assert data.get("resultCode") == LDAPCodes.SUCCESS + assert kadmin.rename_princ.call_args.args == ("new_user", "newbiguser") # type: ignore + + response = await http_client.post( + "entry/search", + json={ + "base_object": entry_dn, + "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"] == entry_dn + + for attr in data["search_result"][0]["partial_attributes"]: + if attr["type"] == "userPrincipalName": + assert attr["vals"][0] == "newbiguser@md.test" + break + else: + raise Exception("User without userPrincipalName") + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("adding_test_computer") +@pytest.mark.usefixtures("setup_session") +@pytest.mark.usefixtures("session") +async def test_api_correct_modify_computer_samaccountname_replace( + http_client: AsyncClient, + kadmin: AbstractKadmin, +) -> None: + """Test API for modify computer sAMAccountName.""" + entry_dn = "cn=mycomputer,dc=md,dc=test" + response = await http_client.patch( + "/entry/update", + json={ + "object": entry_dn, + "changes": [ + { + "operation": Operation.REPLACE, + "modification": { + "type": "sAMAccountName", + "vals": ["maincomputer"], + }, + }, + ], + }, + ) + + data = response.json() + + assert isinstance(data, dict) + assert data.get("resultCode") == LDAPCodes.SUCCESS + assert kadmin.rename_princ.call_count == 2 # type: ignore + assert kadmin.rename_princ.call_args_list[0].args == ( # type: ignore + "host/mycomputer", + "host/maincomputer", + ) + assert kadmin.rename_princ.call_args_list[1].args == ( # type: ignore + "host/mycomputer.md.test", + "host/maincomputer.md.test", + ) + + response = await http_client.post( + "entry/search", + json={ + "base_object": entry_dn, + "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"] == entry_dn + + for attr in data["search_result"][0]["partial_attributes"]: + if attr["type"] == "sAMAccountName": + assert attr["vals"][0] == "maincomputer" + break + else: + raise Exception("Computer without sAMAccountName") + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("adding_test_computer") +@pytest.mark.usefixtures("setup_session") +@pytest.mark.usefixtures("session") +async def test_api_incorrect_modify_computer_samaccountname_add( + http_client: AsyncClient, +) -> None: + """Test API for modify computer sAMAccountName.""" + entry_dn = "cn=mycomputer,dc=md,dc=test" + response = await http_client.patch( + "/entry/update", + json={ + "object": entry_dn, + "changes": [ + { + "operation": Operation.ADD, + "modification": { + "type": "sAMAccountName", + "vals": ["maincomputer"], + }, + }, + ], + }, + ) + + data = response.json() + + assert isinstance(data, dict) + assert data.get("resultCode") == LDAPCodes.OPERATIONS_ERROR @pytest.mark.asyncio From 2df633a2459f8369105a9add898a939b4f447776 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Tue, 3 Feb 2026 12:16:33 +0300 Subject: [PATCH 09/45] Add: RenameRequest for entry (LDAP object) (#918) --- app/api/main/router.py | 33 ++--- app/ldap_protocol/custom_requests/__init__.py | 9 ++ app/ldap_protocol/custom_requests/rename.py | 85 +++++++++++ .../test_main/test_router/test_rename.py | 138 ++++++++++++++++++ 4 files changed, 244 insertions(+), 21 deletions(-) create mode 100644 app/ldap_protocol/custom_requests/__init__.py create mode 100644 app/ldap_protocol/custom_requests/rename.py 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..59250708b 100644 --- a/app/api/main/router.py +++ b/app/api/main/router.py @@ -17,6 +17,7 @@ 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, @@ -37,7 +38,6 @@ translator = DomainErrorTranslator(DomainCodes.LDAP) - error_map: ERROR_MAP_TYPE = { UnauthorizedError: rule( status=status.HTTP_401_UNAUTHORIZED, @@ -54,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 @@ -73,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) @@ -103,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) @@ -123,11 +111,14 @@ 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, - 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/custom_requests/rename.py b/app/ldap_protocol/custom_requests/rename.py new file mode 100644 index 000000000..1748112f8 --- /dev/null +++ b/app/ldap_protocol/custom_requests/rename.py @@ -0,0 +1,85 @@ +"""RenameRequest for main router. + +Copyright (c) 2026 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. It's not RFC 4511. + + 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/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..e86804f39 --- /dev/null +++ b/tests/test_api/test_main/test_router/test_rename.py @@ -0,0 +1,138 @@ +"""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_user(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 + + 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": ["__invalid name for error__"], + }, + }, + { + "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 99d45e9df78fa0948270c3b55cf33ad62319e9ad Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Tue, 3 Feb 2026 15:19:07 +0300 Subject: [PATCH 10/45] Refactor: RenameRequest entry (copilot fixes) (#923) --- 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 623d8bd0ef5ac9a07c26ec01127b1ef25af278f5 Mon Sep 17 00:00:00 2001 From: Misha-Shvets <76677350+Misha-Shvets@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:37:29 +0300 Subject: [PATCH 11/45] Fix: HTTP status codes from 422 UNPROCESSABLE ENTITY to 422 UNPROCESSABLE CONTENT (#920) --- app/api/auth/router_auth.py | 4 ++-- app/api/auth/router_mfa.py | 2 +- app/api/dhcp/router.py | 6 +++--- app/api/main/dns_router.py | 2 +- app/api/network/router.py | 4 ++-- app/api/network/utils.py | 2 +- app/api/shadow/router.py | 2 +- app/ldap_protocol/dhcp/__init__.py | 4 ++-- app/ldap_protocol/dhcp/exceptions.py | 2 +- app/ldap_protocol/dns/base.py | 6 +----- interface | 2 +- tests/test_api/test_auth/test_router.py | 4 ++-- tests/test_api/test_dhcp/test_router.py | 8 ++++---- .../test_attribute_type_router_datasets.py | 2 +- .../test_api/test_ldap_schema/test_entity_type_router.py | 2 +- .../test_ldap_schema/test_object_class_router_datasets.py | 2 +- tests/test_api/test_network/test_router.py | 4 ++-- 17 files changed, 27 insertions(+), 31 deletions(-) diff --git a/app/api/auth/router_auth.py b/app/api/auth/router_auth.py index ae8df7bfd..56484a88f 100644 --- a/app/api/auth/router_auth.py +++ b/app/api/auth/router_auth.py @@ -67,7 +67,7 @@ translator=translator, ), PasswordPolicyError: rule( - status=status.HTTP_422_UNPROCESSABLE_ENTITY, + status=status.HTTP_422_UNPROCESSABLE_CONTENT, translator=translator, ), UserNotFoundError: rule( @@ -75,7 +75,7 @@ translator=translator, ), AuthValidationError: rule( - status=status.HTTP_422_UNPROCESSABLE_ENTITY, + status=status.HTTP_422_UNPROCESSABLE_CONTENT, translator=translator, ), MFARequiredError: rule( diff --git a/app/api/auth/router_mfa.py b/app/api/auth/router_mfa.py index 18424c8ca..d7a90b3b2 100644 --- a/app/api/auth/router_mfa.py +++ b/app/api/auth/router_mfa.py @@ -62,7 +62,7 @@ translator=translator, ), InvalidCredentialsError: rule( - status=status.HTTP_422_UNPROCESSABLE_ENTITY, + status=status.HTTP_422_UNPROCESSABLE_CONTENT, translator=translator, ), NotFoundError: rule( diff --git a/app/api/dhcp/router.py b/app/api/dhcp/router.py index 053241809..a41e806a0 100644 --- a/app/api/dhcp/router.py +++ b/app/api/dhcp/router.py @@ -25,7 +25,7 @@ DHCPEntryNotFoundError, DHCPEntryUpdateError, DHCPOperationError, - DHCPValidatonError, + DHCPValidationError, ) from ldap_protocol.dhcp.schemas import ( DHCPChangeStateSchemaRequest, @@ -65,8 +65,8 @@ status=status.HTTP_400_BAD_REQUEST, translator=translator, ), - DHCPValidatonError: rule( - status=status.HTTP_422_UNPROCESSABLE_ENTITY, + DHCPValidationError: rule( + status=status.HTTP_422_UNPROCESSABLE_CONTENT, translator=translator, ), DHCPOperationError: rule( diff --git a/app/api/main/dns_router.py b/app/api/main/dns_router.py index d93382512..509cb377a 100644 --- a/app/api/main/dns_router.py +++ b/app/api/main/dns_router.py @@ -43,7 +43,7 @@ error_map: ERROR_MAP_TYPE = { dns_exc.DNSSetupError: rule( - status=status.HTTP_422_UNPROCESSABLE_ENTITY, + status=status.HTTP_422_UNPROCESSABLE_CONTENT, translator=translator, ), dns_exc.DNSRecordCreateError: rule( diff --git a/app/api/network/router.py b/app/api/network/router.py index bc65ed858..f380672f2 100644 --- a/app/api/network/router.py +++ b/app/api/network/router.py @@ -38,7 +38,7 @@ error_map: ERROR_MAP_TYPE = { NetworkPolicyAlreadyExistsError: rule( - status=status.HTTP_422_UNPROCESSABLE_ENTITY, + status=status.HTTP_422_UNPROCESSABLE_CONTENT, translator=translator, ), NetworkPolicyNotFoundError: rule( @@ -46,7 +46,7 @@ translator=translator, ), LastActivePolicyError: rule( - status=status.HTTP_422_UNPROCESSABLE_ENTITY, + status=status.HTTP_422_UNPROCESSABLE_CONTENT, translator=translator, ), } diff --git a/app/api/network/utils.py b/app/api/network/utils.py index 532399eb9..a01db466d 100644 --- a/app/api/network/utils.py +++ b/app/api/network/utils.py @@ -27,6 +27,6 @@ async def check_policy_count(session: AsyncSession) -> None: if count.one() == 1: raise HTTPException( - status.HTTP_422_UNPROCESSABLE_ENTITY, + status.HTTP_422_UNPROCESSABLE_CONTENT, "At least one policy should be active", ) diff --git a/app/api/shadow/router.py b/app/api/shadow/router.py index ee8938a18..b1ebe86fb 100644 --- a/app/api/shadow/router.py +++ b/app/api/shadow/router.py @@ -46,7 +46,7 @@ translator=translator, ), PasswordPolicyError: rule( - status=status.HTTP_422_UNPROCESSABLE_ENTITY, + status=status.HTTP_422_UNPROCESSABLE_CONTENT, translator=translator, ), PermissionError: rule( diff --git a/app/ldap_protocol/dhcp/__init__.py b/app/ldap_protocol/dhcp/__init__.py index cf26f1903..d9f4c277c 100644 --- a/app/ldap_protocol/dhcp/__init__.py +++ b/app/ldap_protocol/dhcp/__init__.py @@ -8,7 +8,7 @@ DHCPEntryNotFoundError, DHCPEntryUpdateError, DHCPOperationError, - DHCPValidatonError, + DHCPValidationError, ) from .kea_dhcp_manager import KeaDHCPManager from .kea_dhcp_repository import KeaDHCPAPIRepository @@ -54,7 +54,7 @@ def get_dhcp_api_repository_class( "DHCPEntryDeleteError", "DHCPEntryAddError", "DHCPEntryUpdateError", - "DHCPValidatonError", + "DHCPValidationError", "DHCPOperationError", "DHCPAPIError", "DHCPSubnetSchemaRequest", diff --git a/app/ldap_protocol/dhcp/exceptions.py b/app/ldap_protocol/dhcp/exceptions.py index 4b29a4514..c5be2f126 100644 --- a/app/ldap_protocol/dhcp/exceptions.py +++ b/app/ldap_protocol/dhcp/exceptions.py @@ -37,7 +37,7 @@ class DHCPAPIError(DHCPError): code = ErrorCodes.DHCP_API_ERROR -class DHCPValidatonError(DHCPError): +class DHCPValidationError(DHCPError): """DHCP validation error.""" code = ErrorCodes.DHCP_VALIDATION_ERROR diff --git a/app/ldap_protocol/dns/base.py b/app/ldap_protocol/dns/base.py index 01fe71c8c..a323a7e0c 100644 --- a/app/ldap_protocol/dns/base.py +++ b/app/ldap_protocol/dns/base.py @@ -14,7 +14,7 @@ from ldap_protocol.dns.dto import DNSSettingDTO -from .exceptions import DNSSetupError +from .exceptions import DNSNotImplementedError, DNSSetupError DNS_MANAGER_STATE_NAME = "DNSManagerState" DNS_MANAGER_ZONE_NAME = "DNSManagerZoneName" @@ -46,10 +46,6 @@ class DNSForwarderServerStatus(StrEnum): NOT_FOUND = "not found" -class DNSNotImplementedError(NotImplementedError): - """API Not Implemented Error.""" - - class DNSRecordType(StrEnum): """DNS record types.""" diff --git a/interface b/interface index e1ca5656a..f31962020 160000 --- a/interface +++ b/interface @@ -1 +1 @@ -Subproject commit e1ca5656aeabc20a1862aeaf11ded72feaa97403 +Subproject commit f31962020a6689e6a4c61fb3349db5b5c7895f92 diff --git a/tests/test_api/test_auth/test_router.py b/tests/test_api/test_auth/test_router.py index ffffd6ef1..0256c0463 100644 --- a/tests/test_api/test_auth/test_router.py +++ b/tests/test_api/test_auth/test_router.py @@ -211,7 +211,7 @@ async def test_first_setup_with_invalid_domain( "/auth/setup", json=test_case, ) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT response = await unbound_http_client.get("/auth/setup") assert response.status_code == status.HTTP_200_OK @@ -384,7 +384,7 @@ async def test_update_password_with_empty_old_password( }, ) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT new_auth = await http_client.post( "auth/", diff --git a/tests/test_api/test_dhcp/test_router.py b/tests/test_api/test_dhcp/test_router.py index fbb739816..2ea89d0de 100644 --- a/tests/test_api/test_dhcp/test_router.py +++ b/tests/test_api/test_dhcp/test_router.py @@ -147,7 +147,7 @@ async def test_create_subnet_invalid_data( json=invalid_data, ) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT @pytest.mark.asyncio @@ -297,7 +297,7 @@ async def test_create_lease_invalid_data( json=invalid_data, ) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT @pytest.mark.asyncio @@ -486,7 +486,7 @@ async def test_create_reservation_invalid_data( json=invalid_data, ) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT @pytest.mark.asyncio @@ -597,7 +597,7 @@ async def test_delete_reservation_missing_params( """Test reservation deletion with missing parameters.""" response = await http_client.delete("/dhcp/reservation") - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT @pytest.mark.asyncio diff --git a/tests/test_api/test_ldap_schema/test_attribute_type_router_datasets.py b/tests/test_api/test_ldap_schema/test_attribute_type_router_datasets.py index bcfea7210..e04eecc8d 100644 --- a/tests/test_api/test_ldap_schema/test_attribute_type_router_datasets.py +++ b/tests/test_api/test_ldap_schema/test_attribute_type_router_datasets.py @@ -115,6 +115,6 @@ { "attribute_type_schemas": [], "attribute_types_deleted": [], - "status_code": status.HTTP_422_UNPROCESSABLE_ENTITY, + "status_code": status.HTTP_422_UNPROCESSABLE_CONTENT, }, ] diff --git a/tests/test_api/test_ldap_schema/test_entity_type_router.py b/tests/test_api/test_ldap_schema/test_entity_type_router.py index c130c2067..b7a40c66e 100644 --- a/tests/test_api/test_ldap_schema/test_entity_type_router.py +++ b/tests/test_api/test_ldap_schema/test_entity_type_router.py @@ -78,7 +78,7 @@ async def test_create_one_entity_type_value_422( "is_system": False, }, ) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT @pytest.mark.parametrize( diff --git a/tests/test_api/test_ldap_schema/test_object_class_router_datasets.py b/tests/test_api/test_ldap_schema/test_object_class_router_datasets.py index 3864c4035..1824cee7a 100644 --- a/tests/test_api/test_ldap_schema/test_object_class_router_datasets.py +++ b/tests/test_api/test_ldap_schema/test_object_class_router_datasets.py @@ -206,7 +206,7 @@ { "object_class_datas": [], "object_classes_deleted": [], - "status_code": status.HTTP_422_UNPROCESSABLE_ENTITY, + "status_code": status.HTTP_422_UNPROCESSABLE_CONTENT, }, { "object_class_datas": [ diff --git a/tests/test_api/test_network/test_router.py b/tests/test_api/test_network/test_router.py index 70e7f38e6..b98759bcd 100644 --- a/tests/test_api/test_network/test_router.py +++ b/tests/test_api/test_network/test_router.py @@ -260,7 +260,7 @@ async def test_delete_policy( assert response[0]["priority"] == 1 response = await http_client.delete(f"/policy/{pol_id2}") - assert response.status_code == 422 + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT assert response.json()["detail"] == "At least one policy should be active" @@ -314,7 +314,7 @@ async def test_switch_policy( assert response.json()[0]["enabled"] is False response = await http_client.patch(f"/policy/{pol_id2}") - assert response.status_code == 422 + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT assert response.json()["detail"] == "At least one policy should be active" From a0a88be0b34c529ff4639824973b5c1ee79af507 Mon Sep 17 00:00:00 2001 From: Misha-Shvets <76677350+Misha-Shvets@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:42:24 +0300 Subject: [PATCH 12/45] Add: SamAccountType (#919) --- .../f4e6cd18a01d_add_samaccounttype.py | 89 +++++++++++++++++++ app/constants.py | 20 +++-- app/enums.py | 20 +++++ app/ldap_protocol/auth/use_cases.py | 4 + app/ldap_protocol/ldap_requests/add.py | 28 +++++- app/ldap_protocol/utils/queries.py | 3 +- tests/constants.py | 35 ++++++-- .../test_main/test_router/test_add.py | 45 ++++++++++ 8 files changed, 228 insertions(+), 16 deletions(-) create mode 100644 app/alembic/versions/f4e6cd18a01d_add_samaccounttype.py diff --git a/app/alembic/versions/f4e6cd18a01d_add_samaccounttype.py b/app/alembic/versions/f4e6cd18a01d_add_samaccounttype.py new file mode 100644 index 000000000..efb2d0af5 --- /dev/null +++ b/app/alembic/versions/f4e6cd18a01d_add_samaccounttype.py @@ -0,0 +1,89 @@ +"""Add sAMAccountType to existing user/group/computer entries. + +Revision ID: f4e6cd18a01d +Revises: 379fce54fb08 +Create Date: 2026-01-30 13:08:26.299158 + +""" + +from alembic import op +from dishka import AsyncContainer, Scope +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession +from sqlalchemy.orm import joinedload + +from entities import Attribute, Directory, EntityType +from enums import EntityTypeNames, SamAccountTypeCodes +from repo.pg.tables import queryable_attr as qa + +revision: None | str = "f4e6cd18a01d" +down_revision: None | str = "379fce54fb08" +branch_labels: None | list[str] = None +depends_on: None | list[str] = None + +_SAM_ACCOUNT_TYPE_ATTR = "sAMAccountType" +_SECURITY_PRINCIPAL_TYPES = ( + EntityTypeNames.USER, + EntityTypeNames.GROUP, + EntityTypeNames.COMPUTER, +) +_ENTITY_TO_SAM: dict[str, SamAccountTypeCodes] = { + EntityTypeNames.USER: SamAccountTypeCodes.SAM_USER_OBJECT, + EntityTypeNames.GROUP: SamAccountTypeCodes.SAM_GROUP_OBJECT, + EntityTypeNames.COMPUTER: SamAccountTypeCodes.SAM_MACHINE_ACCOUNT, +} + + +def upgrade(container: AsyncContainer) -> None: + """Add sAMAccountType attributes for user/group/computer.""" + + async def _add_samaccounttype(connection: AsyncConnection) -> None: # noqa: ARG001 + async with container(scope=Scope.REQUEST) as cnt: + session = await cnt.get(AsyncSession) + + entity_types = await session.scalars( + select(EntityType) + .where(qa(EntityType.name).in_(_SECURITY_PRINCIPAL_TYPES)), + ) # fmt: skip + entity_type_ids = [et.id for et in entity_types] + if not entity_type_ids: + return + + has_sam = select( + qa(Attribute.directory_id), + ).where( + qa(Attribute.name).ilike(_SAM_ACCOUNT_TYPE_ATTR.lower()), + ) + dirs_without_sam = await session.scalars( + select(Directory) + .where( + qa(Directory.entity_type_id).in_(entity_type_ids), + ~qa(Directory.id).in_(has_sam), + ) + .options(joinedload(qa(Directory.entity_type))), + ) + + for directory in dirs_without_sam: + sam_value = ( + _ENTITY_TO_SAM.get(directory.entity_type.name) + if directory.entity_type + else None + ) + if sam_value is None: + continue + + session.add( + Attribute( + name=_SAM_ACCOUNT_TYPE_ATTR, + value=str(sam_value), + directory_id=directory.id, + ), + ) + + await session.commit() + + op.run_async(_add_samaccounttype) + + +def downgrade(container: AsyncContainer) -> None: + """Downgrade.""" diff --git a/app/constants.py b/app/constants.py index 902a79f59..8a743ed5b 100644 --- a/app/constants.py +++ b/app/constants.py @@ -6,7 +6,7 @@ from typing import TypedDict -from enums import EntityTypeNames +from enums import EntityTypeNames, SamAccountTypeCodes GROUPS_CONTAINER_NAME = "Groups" COMPUTERS_CONTAINER_NAME = "Computers" @@ -24,7 +24,7 @@ "groupType": ["-2147483646"], "instanceType": ["4"], "sAMAccountName": ["groups"], - "sAMAccountType": ["268435456"], + "sAMAccountType": [str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value)], } @@ -308,7 +308,9 @@ class EntityTypeData(TypedDict): "groupType": ["-2147483646"], "instanceType": ["4"], "sAMAccountName": [DOMAIN_ADMIN_GROUP_NAME], - "sAMAccountType": ["268435456"], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value), + ], "gidNumber": ["512"], }, "objectSid": 512, @@ -321,7 +323,9 @@ class EntityTypeData(TypedDict): "groupType": ["-2147483646"], "instanceType": ["4"], "sAMAccountName": [DOMAIN_USERS_GROUP_NAME], - "sAMAccountType": ["268435456"], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value), + ], "gidNumber": ["513"], }, "objectSid": 513, @@ -334,7 +338,9 @@ class EntityTypeData(TypedDict): "groupType": ["-2147483646"], "instanceType": ["4"], "sAMAccountName": [READ_ONLY_GROUP_NAME], - "sAMAccountType": ["268435456"], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value), + ], "gidNumber": ["521"], }, "objectSid": 521, @@ -347,7 +353,9 @@ class EntityTypeData(TypedDict): "groupType": ["-2147483646"], "instanceType": ["4"], "sAMAccountName": [DOMAIN_COMPUTERS_GROUP_NAME], - "sAMAccountType": ["268435456"], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value), + ], "gidNumber": ["515"], }, "objectSid": 515, diff --git a/app/enums.py b/app/enums.py index 264b6c16a..1f6e8f798 100644 --- a/app/enums.py +++ b/app/enums.py @@ -254,3 +254,23 @@ class DomainCodes(IntEnum): DHCP = 12 LDAP_SCHEMA = 13 SHADOW = 14 + + +class SamAccountTypeCodes(IntEnum): + """SAM Account Type values.""" + + SAM_DOMAIN_OBJECT = 0 + SAM_GROUP_OBJECT = 268435456 + SAM_NON_SECURITY_GROUP_OBJECT = 268435457 + SAM_ALIAS_OBJECT = 536870912 + SAM_NON_SECURITY_ALIAS_OBJECT = 536870913 + SAM_USER_OBJECT = 805306368 + SAM_MACHINE_ACCOUNT = 805306369 + SAM_TRUST_ACCOUNT = 805306370 + SAM_APP_BASIC_GROUP = 1073741824 + SAM_APP_QUERY_GROUP = 1073741825 + + @staticmethod + def to_hex(value: int) -> str: + """Convert decimal value to hex string.""" + return hex(value) diff --git a/app/ldap_protocol/auth/use_cases.py b/app/ldap_protocol/auth/use_cases.py index b9a53414e..136f2cf23 100644 --- a/app/ldap_protocol/auth/use_cases.py +++ b/app/ldap_protocol/auth/use_cases.py @@ -14,6 +14,7 @@ FIRST_SETUP_DATA, USERS_CONTAINER_NAME, ) +from enums import SamAccountTypeCodes from ldap_protocol.auth.dto import SetupDTO from ldap_protocol.auth.setup_gateway import SetupGateway from ldap_protocol.identity.exceptions import ( @@ -114,6 +115,9 @@ def _create_user_data(self, dto: SetupDTO) -> dict: "userAccountControl": ["512"], "primaryGroupID": ["512"], "givenName": [dto.username], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_USER_OBJECT), + ], }, "objectSid": 500, }, diff --git a/app/ldap_protocol/ldap_requests/add.py b/app/ldap_protocol/ldap_requests/add.py index a16c6b182..75a498516 100644 --- a/app/ldap_protocol/ldap_requests/add.py +++ b/app/ldap_protocol/ldap_requests/add.py @@ -13,7 +13,7 @@ from constants import DOMAIN_COMPUTERS_GROUP_NAME, DOMAIN_USERS_GROUP_NAME from entities import Attribute, Directory, Group, User -from enums import AceType, EntityTypeNames +from enums import AceType, EntityTypeNames, SamAccountTypeCodes from ldap_protocol.asn1parser import ASN1Row from ldap_protocol.kerberos.exceptions import ( KRBAPIAddPrincipalError, @@ -426,6 +426,32 @@ async def handle( # noqa: C901 ), ) + if "samaccounttype" not in self.l_attrs_dict: + if is_user: + attributes.append( + Attribute( + name="sAMAccountType", + value=str(SamAccountTypeCodes.SAM_USER_OBJECT), + directory_id=new_dir.id, + ), + ) + elif is_group: + attributes.append( + Attribute( + name="sAMAccountType", + value=str(SamAccountTypeCodes.SAM_GROUP_OBJECT), + directory_id=new_dir.id, + ), + ) + elif is_computer: + attributes.append( + Attribute( + name="sAMAccountType", + value=str(SamAccountTypeCodes.SAM_MACHINE_ACCOUNT), + directory_id=new_dir.id, + ), + ) + if not ctx.attribute_value_validator.is_directory_attributes_valid( entity_type.name if entity_type else "", attributes, diff --git a/app/ldap_protocol/utils/queries.py b/app/ldap_protocol/utils/queries.py index 368af23ec..39d93b016 100644 --- a/app/ldap_protocol/utils/queries.py +++ b/app/ldap_protocol/utils/queries.py @@ -16,6 +16,7 @@ from sqlalchemy.sql.expression import ColumnElement from entities import Attribute, Directory, Group, User +from enums import SamAccountTypeCodes from ldap_protocol.ldap_schema.attribute_value_validator import ( AttributeValueValidator, AttributeValueValidatorError, @@ -394,7 +395,7 @@ async def create_group( "instanceType": ["4"], "sAMAccountName": [dir_.name], dir_.rdname: [dir_.name], - "sAMAccountType": ["268435456"], + "sAMAccountType": [str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value)], "gidNumber": [str(create_integer_hash(dir_.name))], } diff --git a/tests/constants.py b/tests/constants.py index 5542e0742..2163898ed 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -11,6 +11,7 @@ GROUPS_CONTAINER_NAME, USERS_CONTAINER_NAME, ) +from enums import SamAccountTypeCodes from ldap_protocol.objects import UserAccountControlFlag TEST_DATA = [ @@ -30,7 +31,9 @@ "groupType": ["-2147483646"], "instanceType": ["4"], "sAMAccountName": [DOMAIN_ADMIN_GROUP_NAME], - "sAMAccountType": ["268435456"], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value), + ], }, }, { @@ -42,7 +45,9 @@ "groupType": ["-2147483646"], "instanceType": ["4"], "sAMAccountName": ["developers"], - "sAMAccountType": ["268435456"], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value), + ], }, }, { @@ -53,7 +58,9 @@ "groupType": ["-2147483646"], "instanceType": ["4"], "sAMAccountName": ["admin login only"], - "sAMAccountType": ["268435456"], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value), + ], }, }, { @@ -64,7 +71,9 @@ "groupType": ["-2147483646"], "instanceType": ["4"], "sAMAccountName": [DOMAIN_USERS_GROUP_NAME], - "sAMAccountType": ["268435456"], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value), + ], }, }, { @@ -75,7 +84,9 @@ "groupType": ["-2147483646"], "instanceType": ["4"], "sAMAccountName": [DOMAIN_COMPUTERS_GROUP_NAME], - "sAMAccountType": ["268435456"], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value), + ], }, }, ], @@ -368,7 +379,11 @@ "groupType": ["-2147483646"], "instanceType": ["4"], "sAMAccountName": ["testGroup1"], - "sAMAccountType": ["268435456"], + "sAMAccountType": [ + str( + SamAccountTypeCodes.SAM_GROUP_OBJECT.value, + ), + ], }, }, ], @@ -381,7 +396,9 @@ "groupType": ["-2147483646"], "instanceType": ["4"], "sAMAccountName": ["testGroup2"], - "sAMAccountType": ["268435456"], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value), + ], }, }, ], @@ -402,7 +419,9 @@ "groupType": ["-2147483646"], "instanceType": ["4"], "sAMAccountName": ["testGroup3"], - "sAMAccountType": ["268435456"], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value), + ], }, }, ], diff --git a/tests/test_api/test_main/test_router/test_add.py b/tests/test_api/test_main/test_router/test_add.py index 3282305f1..f20d516d2 100644 --- a/tests/test_api/test_main/test_router/test_add.py +++ b/tests/test_api/test_main/test_router/test_add.py @@ -8,6 +8,7 @@ from fastapi import status from httpx import AsyncClient +from enums import SamAccountTypeCodes from ldap_protocol.ldap_codes import LDAPCodes from ldap_protocol.objects import UserAccountControlFlag from tests.api_datasets import test_api_forbidden_chars_in_attr_value @@ -179,6 +180,50 @@ async def test_api_add_computer(http_client: AsyncClient) -> None: raise Exception("Computer without sAMAccountName") +@pytest.mark.asyncio +@pytest.mark.usefixtures("session") +async def test_add_user_samaccounttype( + http_client: AsyncClient, +) -> None: + """Add user without sAMAccountType: server sets SAM_USER_OBJECT.""" + entry = "cn=samuser,dc=md,dc=test" + await http_client.post( + "/entry/add", + json={ + "entry": entry, + "password": "P@ssw0rd", + "attributes": [ + {"type": "name", "vals": ["samuser"]}, + {"type": "cn", "vals": ["samuser"]}, + {"type": "objectClass", "vals": ["user", "top"]}, + {"type": "sAMAccountName", "vals": ["samuser"]}, + {"type": "userPrincipalName", "vals": ["samuser@md.test"]}, + ], + }, + ) + response = await http_client.post( + "entry/search", + json={ + "base_object": entry, + "scope": 0, + "deref_aliases": 0, + "size_limit": 1, + "time_limit": 10, + "types_only": True, + "filter": "(objectClass=*)", + "attributes": ["sAMAccountType"], + "page_number": 1, + }, + ) + data = response.json() + attrs = { + a["type"]: a for a in data["search_result"][0]["partial_attributes"] + } + assert attrs["sAMAccountType"]["vals"][0] == str( + SamAccountTypeCodes.SAM_USER_OBJECT, + ) + + @pytest.mark.asyncio @pytest.mark.usefixtures("session") async def test_api_correct_add_double_member_of( From b82c5b108f7e91e08e0afe79d58bf14b9d51c880 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Tue, 3 Feb 2026 17:47:30 +0300 Subject: [PATCH 13/45] refactor: ldap requests (#925) --- 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 ++- interface | 2 +- 6 files changed, 56 insertions(+), 50 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 75a498516..3747f7b64 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 "samaccounttype" not in self.l_attrs_dict: if is_user: attributes.append( @@ -503,11 +506,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..34f9788d3 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( diff --git a/interface b/interface index f31962020..e1ca5656a 160000 --- a/interface +++ b/interface @@ -1 +1 @@ -Subproject commit f31962020a6689e6a4c61fb3349db5b5c7895f92 +Subproject commit e1ca5656aeabc20a1862aeaf11ded72feaa97403 From a65fd05e02c223f3704b49fb5db2c6cd16c844b7 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Fri, 6 Feb 2026 17:26:58 +0300 Subject: [PATCH 14/45] Add postgresql READ\WRITE routing (#915) --- app/api/audit/router.py | 20 ++++- app/api/auth/router_auth.py | 4 +- app/api/auth/router_mfa.py | 7 +- app/api/ldap_schema/attribute_type_router.py | 6 +- app/api/ldap_schema/entity_type_router.py | 11 ++- app/api/ldap_schema/object_class_router.py | 11 ++- app/api/main/dns_router.py | 7 +- app/api/main/krb5_router.py | 18 ++-- app/api/main/router.py | 54 +++++++++-- app/api/network/router.py | 21 ++++- .../password_ban_word_router.py | 2 + .../password_policy/password_policy_router.py | 13 ++- .../user_password_history_router.py | 3 +- app/api/shadow/router.py | 9 +- app/api/utils.py | 22 +++++ app/config.py | 74 +++++++++++---- app/db_routing.py | 90 +++++++++++++++++++ app/enums.py | 7 ++ app/ioc.py | 43 +++++++-- app/ldap_protocol/ldap_requests/add.py | 1 + app/ldap_protocol/ldap_requests/base.py | 29 +++++- app/ldap_protocol/ldap_requests/bind.py | 9 +- app/ldap_protocol/ldap_requests/delete.py | 1 + app/ldap_protocol/ldap_requests/extended.py | 1 + app/ldap_protocol/ldap_requests/modify.py | 1 + app/ldap_protocol/ldap_requests/modify_dn.py | 1 + app/ldap_protocol/ldap_requests/search.py | 1 + app/ldap_protocol/master_check_use_case.py | 30 +++++++ .../session_storage/repository.py | 9 +- app/repo/pg/master_gateway.py | 33 +++++++ tests/conftest.py | 18 ++++ 31 files changed, 492 insertions(+), 64 deletions(-) create mode 100644 app/api/utils.py create mode 100644 app/db_routing.py create mode 100644 app/ldap_protocol/master_check_use_case.py create mode 100644 app/repo/pg/master_gateway.py diff --git a/app/api/audit/router.py b/app/api/audit/router.py index 4a328e2ef..6209d0740 100644 --- a/app/api/audit/router.py +++ b/app/api/audit/router.py @@ -15,6 +15,7 @@ DishkaErrorAwareRoute, DomainErrorTranslator, ) +from api.utils import require_master_db from enums import DomainCodes from ldap_protocol.policies.audit.exception import ( AuditAlreadyExistsError, @@ -59,7 +60,11 @@ async def get_audit_policies( return await audit_adapter.get_policies() -@audit_router.put("/policy/{policy_id}", error_map=error_map) +@audit_router.put( + "/policy/{policy_id}", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def update_audit_policy( policy_id: int, policy_data: AuditPolicySchemaRequest, @@ -81,6 +86,7 @@ async def get_audit_destinations( "/destination", status_code=status.HTTP_201_CREATED, error_map=error_map, + dependencies=[Depends(require_master_db)], ) async def create_audit_destination( destination_data: AuditDestinationSchemaRequest, @@ -90,7 +96,11 @@ async def create_audit_destination( return await audit_adapter.create_destination(destination_data) -@audit_router.delete("/destination/{destination_id}", error_map=error_map) +@audit_router.delete( + "/destination/{destination_id}", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def delete_audit_destination( destination_id: int, audit_adapter: FromDishka[AuditPoliciesAdapter], @@ -99,7 +109,11 @@ async def delete_audit_destination( await audit_adapter.delete_destination(destination_id) -@audit_router.put("/destination/{destination_id}", error_map=error_map) +@audit_router.put( + "/destination/{destination_id}", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def update_audit_destination( destination_id: int, destination_data: AuditDestinationSchemaRequest, diff --git a/app/api/auth/router_auth.py b/app/api/auth/router_auth.py index 56484a88f..a5c98911f 100644 --- a/app/api/auth/router_auth.py +++ b/app/api/auth/router_auth.py @@ -19,6 +19,7 @@ DishkaErrorAwareRoute, DomainErrorTranslator, ) +from api.utils import require_master_db from enums import DomainCodes from ldap_protocol.auth.exceptions.mfa import ( MFAAPIError, @@ -186,7 +187,7 @@ async def logout( @auth_router.patch( "/user/password", status_code=200, - dependencies=[Depends(verify_auth)], + dependencies=[Depends(verify_auth), Depends(require_master_db)], error_map=error_map, ) async def password_reset( @@ -229,6 +230,7 @@ async def check_setup( status_code=status.HTTP_200_OK, responses={423: {"detail": "Locked"}}, error_map=error_map, + dependencies=[Depends(require_master_db)], ) async def first_setup( request: SetupRequest, diff --git a/app/api/auth/router_mfa.py b/app/api/auth/router_mfa.py index d7a90b3b2..8e275b242 100644 --- a/app/api/auth/router_mfa.py +++ b/app/api/auth/router_mfa.py @@ -24,6 +24,7 @@ DishkaErrorAwareRoute, DomainErrorTranslator, ) +from api.utils import require_master_db from enums import DomainCodes from ldap_protocol.auth.exceptions.mfa import ( ForbiddenError, @@ -81,7 +82,7 @@ @mfa_router.post( "/setup", status_code=status.HTTP_201_CREATED, - dependencies=[Depends(verify_auth)], + dependencies=[Depends(verify_auth), Depends(require_master_db)], error_map=error_map, ) async def setup_mfa( @@ -100,7 +101,7 @@ async def setup_mfa( @mfa_router.delete( "/keys", - dependencies=[Depends(verify_auth)], + dependencies=[Depends(verify_auth), Depends(require_master_db)], error_map=error_map, ) async def remove_mfa( @@ -113,7 +114,7 @@ async def remove_mfa( @mfa_router.post( "/get", - dependencies=[Depends(verify_auth)], + dependencies=[Depends(verify_auth), Depends(require_master_db)], error_map=error_map, ) async def get_mfa( diff --git a/app/api/ldap_schema/attribute_type_router.py b/app/api/ldap_schema/attribute_type_router.py index 5a2f1f368..a75a1826a 100644 --- a/app/api/ldap_schema/attribute_type_router.py +++ b/app/api/ldap_schema/attribute_type_router.py @@ -7,7 +7,7 @@ from typing import Annotated from dishka.integrations.fastapi import FromDishka -from fastapi import Query, status +from fastapi import Depends, Query, status from api.ldap_schema import LimitedListType, error_map, ldap_schema_router from api.ldap_schema.adapters.attribute_type import AttributeTypeFastAPIAdapter @@ -16,6 +16,7 @@ AttributeTypeSchema, AttributeTypeUpdateSchema, ) +from api.utils import require_master_db from ldap_protocol.utils.pagination import PaginationParams @@ -23,6 +24,7 @@ "/attribute_type", status_code=status.HTTP_201_CREATED, error_map=error_map, + dependencies=[Depends(require_master_db)], ) async def create_one_attribute_type( request_data: AttributeTypeSchema[None], @@ -59,6 +61,7 @@ async def get_list_attribute_types_with_pagination( @ldap_schema_router.patch( "/attribute_type/{attribute_type_name}", error_map=error_map, + dependencies=[Depends(require_master_db)], ) async def modify_one_attribute_type( attribute_type_name: str, @@ -72,6 +75,7 @@ async def modify_one_attribute_type( @ldap_schema_router.post( "/attribute_types/delete", error_map=error_map, + dependencies=[Depends(require_master_db)], ) async def delete_bulk_attribute_types( attribute_types_names: LimitedListType, diff --git a/app/api/ldap_schema/entity_type_router.py b/app/api/ldap_schema/entity_type_router.py index 31de91616..129230b8e 100644 --- a/app/api/ldap_schema/entity_type_router.py +++ b/app/api/ldap_schema/entity_type_router.py @@ -7,7 +7,7 @@ from typing import Annotated from dishka.integrations.fastapi import FromDishka -from fastapi import Query, status +from fastapi import Depends, Query, status from api.ldap_schema import LimitedListType, error_map from api.ldap_schema.adapters.entity_type import LDAPEntityTypeFastAPIAdapter @@ -17,6 +17,7 @@ EntityTypeSchema, EntityTypeUpdateSchema, ) +from api.utils import require_master_db from ldap_protocol.utils.pagination import PaginationParams @@ -24,6 +25,7 @@ "/entity_type", status_code=status.HTTP_201_CREATED, error_map=error_map, + dependencies=[Depends(require_master_db)], ) async def create_one_entity_type( request_data: EntityTypeSchema[None], @@ -66,6 +68,7 @@ async def get_entity_type_attributes( @ldap_schema_router.patch( "/entity_type/{entity_type_name}", error_map=error_map, + dependencies=[Depends(require_master_db)], ) async def modify_one_entity_type( entity_type_name: str, @@ -76,7 +79,11 @@ async def modify_one_entity_type( await adapter.update(name=entity_type_name, data=request_data) -@ldap_schema_router.post("/entity_type/delete", error_map=error_map) +@ldap_schema_router.post( + "/entity_type/delete", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def delete_bulk_entity_types( entity_type_names: LimitedListType, adapter: FromDishka[LDAPEntityTypeFastAPIAdapter], diff --git a/app/api/ldap_schema/object_class_router.py b/app/api/ldap_schema/object_class_router.py index a351f3b33..a6baced69 100644 --- a/app/api/ldap_schema/object_class_router.py +++ b/app/api/ldap_schema/object_class_router.py @@ -7,7 +7,7 @@ from typing import Annotated from dishka.integrations.fastapi import FromDishka -from fastapi import Query, status +from fastapi import Depends, Query, status from api.ldap_schema import LimitedListType, error_map from api.ldap_schema.adapters.object_class import ObjectClassFastAPIAdapter @@ -17,6 +17,7 @@ ObjectClassSchema, ObjectClassUpdateSchema, ) +from api.utils import require_master_db from ldap_protocol.utils.pagination import PaginationParams @@ -24,6 +25,7 @@ "/object_class", status_code=status.HTTP_201_CREATED, error_map=error_map, + dependencies=[Depends(require_master_db)], ) async def create_one_object_class( request_data: ObjectClassSchema[None], @@ -57,6 +59,7 @@ async def get_list_object_classes_with_pagination( @ldap_schema_router.patch( "/object_class/{object_class_name}", error_map=error_map, + dependencies=[Depends(require_master_db)], ) async def modify_one_object_class( object_class_name: str, @@ -67,7 +70,11 @@ async def modify_one_object_class( await adapter.update(object_class_name, request_data) -@ldap_schema_router.post("/object_class/delete", error_map=error_map) +@ldap_schema_router.post( + "/object_class/delete", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def delete_bulk_object_classes( object_classes_names: LimitedListType, adapter: FromDishka[ObjectClassFastAPIAdapter], diff --git a/app/api/main/dns_router.py b/app/api/main/dns_router.py index 509cb377a..bf3e83e40 100644 --- a/app/api/main/dns_router.py +++ b/app/api/main/dns_router.py @@ -29,6 +29,7 @@ DNSServiceZoneDeleteRequest, DNSServiceZoneUpdateRequest, ) +from api.utils import require_master_db from enums import DomainCodes from ldap_protocol.dns import ( DNSForwardServerStatus, @@ -139,7 +140,11 @@ async def get_dns_status( return await adapter.get_dns_status() -@dns_router.post("/setup", error_map=error_map) +@dns_router.post( + "/setup", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def setup_dns( data: DNSServiceSetupRequest, adapter: FromDishka[DNSFastAPIAdapter], diff --git a/app/api/main/krb5_router.py b/app/api/main/krb5_router.py index 91f64a5b6..9ed36515c 100644 --- a/app/api/main/krb5_router.py +++ b/app/api/main/krb5_router.py @@ -24,6 +24,7 @@ ) from api.main.adapters.kerberos import KerberosFastAPIAdapter from api.main.schema import KerberosSetupRequest +from api.utils import require_master_db from enums import DomainCodes from ldap_protocol.dialogue import LDAPSession from ldap_protocol.kerberos import KerberosState @@ -82,7 +83,7 @@ "/setup/tree", response_class=Response, error_map=error_map, - dependencies=[Depends(verify_auth)], + dependencies=[Depends(verify_auth), Depends(require_master_db)], ) async def setup_krb_catalogue( mail: Annotated[EmailStr, Body()], @@ -106,7 +107,12 @@ async def setup_krb_catalogue( ) -@krb5_router.post("/setup", response_class=Response, error_map=error_map) +@krb5_router.post( + "/setup", + response_class=Response, + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def setup_kdc( data: KerberosSetupRequest, identity_adapter: FromDishka[AuthFastAPIAdapter], @@ -173,7 +179,7 @@ async def get_krb_status( @krb5_router.post( "/principal/add", - dependencies=[Depends(verify_auth)], + dependencies=[Depends(verify_auth), Depends(require_master_db)], error_map=error_map, ) async def add_principal( @@ -193,7 +199,7 @@ async def add_principal( @krb5_router.patch( "/principal/rename", - dependencies=[Depends(verify_auth)], + dependencies=[Depends(verify_auth), Depends(require_master_db)], error_map=error_map, ) async def rename_principal( @@ -217,7 +223,7 @@ async def rename_principal( @krb5_router.patch( "/principal/reset", - dependencies=[Depends(verify_auth)], + dependencies=[Depends(verify_auth), Depends(require_master_db)], error_map=error_map, ) async def reset_principal_pw( @@ -238,7 +244,7 @@ async def reset_principal_pw( @krb5_router.delete( "/principal/delete", - dependencies=[Depends(verify_auth)], + dependencies=[Depends(verify_auth), Depends(require_master_db)], error_map=error_map, ) async def delete_principal( diff --git a/app/api/main/router.py b/app/api/main/router.py index 59250708b..174a65afa 100644 --- a/app/api/main/router.py +++ b/app/api/main/router.py @@ -16,6 +16,7 @@ DishkaErrorAwareRoute, DomainErrorTranslator, ) +from api.utils import require_master_db from enums import DomainCodes from ldap_protocol.custom_requests.rename import RenameRequest from ldap_protocol.identity.exceptions import UnauthorizedError @@ -69,19 +70,31 @@ async def search(request: SearchRequest, req: Request) -> SearchResponse: ) -@entry_router.post("/add", error_map=error_map) +@entry_router.post( + "/add", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) 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) +@entry_router.patch( + "/update", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def modify(request: ModifyRequest, req: Request) -> LDAPResult: """LDAP MODIFY entry request.""" return await request.handle_api(req.state.dishka_container) -@entry_router.patch("/update_many", error_map=error_map) +@entry_router.patch( + "/update_many", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def modify_many( requests: list[ModifyRequest], req: Request, @@ -93,13 +106,21 @@ async def modify_many( return results -@entry_router.put("/update/dn", error_map=error_map) +@entry_router.put( + "/update/dn", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def modify_dn(request: ModifyDNRequest, req: Request) -> LDAPResult: """LDAP MODIFY entry DN request.""" return await request.handle_api(req.state.dishka_container) -@entry_router.post("/update_many/dn", error_map=error_map) +@entry_router.post( + "/update_many/dn", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def modify_dn_many( requests: list[ModifyDNRequest], req: Request, @@ -111,19 +132,31 @@ async def modify_dn_many( return results -@entry_router.put("/rename", error_map=error_map) +@entry_router.put( + "/rename", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) 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) +@entry_router.delete( + "/delete", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def delete(request: DeleteRequest, req: Request) -> LDAPResult: """LDAP DELETE entry request.""" return await request.handle_api(req.state.dishka_container) -@entry_router.post("/delete_many", error_map=error_map) +@entry_router.post( + "/delete_many", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def delete_many( requests: list[DeleteRequest], req: Request, @@ -135,7 +168,10 @@ async def delete_many( return results -@entry_router.post("/set_primary_group") +@entry_router.post( + "/set_primary_group", + dependencies=[Depends(require_master_db)], +) async def set_primary_group( request: PrimaryGroupRequest, session: FromDishka[AsyncSession], diff --git a/app/api/network/router.py b/app/api/network/router.py index f380672f2..71c87cb5b 100644 --- a/app/api/network/router.py +++ b/app/api/network/router.py @@ -18,6 +18,7 @@ DomainErrorTranslator, ) from api.network.adapters.network import NetworkPolicyFastAPIAdapter +from api.utils import require_master_db from enums import DomainCodes from ldap_protocol.policies.network.exceptions import ( LastActivePolicyError, @@ -64,6 +65,7 @@ "", status_code=status.HTTP_201_CREATED, error_map=error_map, + dependencies=[Depends(require_master_db)], ) async def add_network_policy( policy: Policy, @@ -97,6 +99,7 @@ async def get_list_network_policies( response_class=RedirectResponse, status_code=status.HTTP_303_SEE_OTHER, error_map=error_map, + dependencies=[Depends(require_master_db)], ) async def delete_network_policy( policy_id: int, @@ -114,7 +117,11 @@ async def delete_network_policy( return await adapter.delete(request, policy_id) # type: ignore -@network_router.patch("/{policy_id}", error_map=error_map) +@network_router.patch( + "/{policy_id}", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def switch_network_policy( policy_id: int, adapter: FromDishka[NetworkPolicyFastAPIAdapter], @@ -133,7 +140,11 @@ async def switch_network_policy( return await adapter.switch_network_policy(policy_id) -@network_router.put("", error_map=error_map) +@network_router.put( + "", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def update_network_policy( request: PolicyUpdate, adapter: FromDishka[NetworkPolicyFastAPIAdapter], @@ -150,7 +161,11 @@ async def update_network_policy( return await adapter.update(request) -@network_router.post("/swap", error_map=error_map) +@network_router.post( + "/swap", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def swap_network_policy( swap: SwapRequest, adapter: FromDishka[NetworkPolicyFastAPIAdapter], diff --git a/app/api/password_policy/password_ban_word_router.py b/app/api/password_policy/password_ban_word_router.py index a0c06a04e..5185124dc 100644 --- a/app/api/password_policy/password_ban_word_router.py +++ b/app/api/password_policy/password_ban_word_router.py @@ -13,6 +13,7 @@ from api.error_routing import DishkaErrorAwareRoute from api.password_policy.adapter import PasswordBanWordsFastAPIAdapter from api.password_policy.error_utils import error_map +from api.utils import require_master_db password_ban_word_router = ErrorAwareRouter( prefix="/password_ban_word", @@ -26,6 +27,7 @@ "/upload_txt", status_code=status.HTTP_201_CREATED, error_map=error_map, + dependencies=[Depends(require_master_db)], ) async def upload_ban_words_txt( file: UploadFile, diff --git a/app/api/password_policy/password_policy_router.py b/app/api/password_policy/password_policy_router.py index 812777ecd..36bd206c3 100644 --- a/app/api/password_policy/password_policy_router.py +++ b/app/api/password_policy/password_policy_router.py @@ -13,6 +13,7 @@ from api.password_policy.adapter import PasswordPolicyFastAPIAdapter from api.password_policy.error_utils import error_map from api.password_policy.schemas import PasswordPolicySchema +from api.utils import require_master_db from ldap_protocol.utils.const import GRANT_DN_STRING from .schemas import PriorityT @@ -51,7 +52,11 @@ async def get_password_policy_by_dir_path_dn( return await adapter.get_password_policy_by_dir_path_dn(path_dn) -@password_policy_router.put("/{id_}", error_map=error_map) +@password_policy_router.put( + "/{id_}", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def update( id_: int, policy: PasswordPolicySchema[PriorityT], @@ -61,7 +66,11 @@ async def update( await adapter.update(id_, policy) -@password_policy_router.put("/reset/domain_policy", error_map=error_map) +@password_policy_router.put( + "/reset/domain_policy", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def reset_domain_policy_to_default_config( adapter: FromDishka[PasswordPolicyFastAPIAdapter], ) -> None: diff --git a/app/api/password_policy/user_password_history_router.py b/app/api/password_policy/user_password_history_router.py index 2285c3cdd..9af233c12 100644 --- a/app/api/password_policy/user_password_history_router.py +++ b/app/api/password_policy/user_password_history_router.py @@ -18,6 +18,7 @@ DomainErrorTranslator, ) from api.password_policy.adapter import UserPasswordHistoryResetFastAPIAdapter +from api.utils import require_master_db from enums import DomainCodes from ldap_protocol.identity.exceptions import ( AuthorizationError, @@ -39,7 +40,7 @@ user_password_history_router = ErrorAwareRouter( prefix="/user/password_history", - dependencies=[Depends(verify_auth)], + dependencies=[Depends(verify_auth), Depends(require_master_db)], tags=["User Password history"], route_class=DishkaErrorAwareRoute, ) diff --git a/app/api/shadow/router.py b/app/api/shadow/router.py index b1ebe86fb..b708babb0 100644 --- a/app/api/shadow/router.py +++ b/app/api/shadow/router.py @@ -8,7 +8,7 @@ from typing import Annotated from dishka import FromDishka -from fastapi import Body, status +from fastapi import Body, Depends, status from fastapi_error_map.routing import ErrorAwareRouter from fastapi_error_map.rules import rule @@ -17,6 +17,7 @@ DishkaErrorAwareRoute, DomainErrorTranslator, ) +from api.utils import require_master_db from enums import DomainCodes from ldap_protocol.auth.exceptions.mfa import ( AuthenticationError, @@ -67,7 +68,11 @@ async def proxy_request( return await adapter.proxy_request(principal, ip) -@shadow_router.post("/sync/password", error_map=error_map) +@shadow_router.post( + "/sync/password", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) async def change_password( principal: Annotated[str, Body(embed=True)], new_password: Annotated[str, Body(embed=True)], diff --git a/app/api/utils.py b/app/api/utils.py new file mode 100644 index 000000000..5f94d56f6 --- /dev/null +++ b/app/api/utils.py @@ -0,0 +1,22 @@ +"""Utils with master database check. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from dishka import FromDishka +from dishka.integrations.fastapi import inject +from fastapi import HTTPException, status + +from ldap_protocol.master_check_use_case import MasterCheckUseCase + + +@inject +async def require_master_db( + master_check_use_case: FromDishka[MasterCheckUseCase], +) -> None: + if not await master_check_use_case.check_master(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Master DB is not available", + ) diff --git a/app/config.py b/app/config.py index 423eb2bf8..8637bcdd6 100644 --- a/app/config.py +++ b/app/config.py @@ -24,6 +24,8 @@ ) from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from enums import PostgresRWModeType + def _get_vendor_version() -> str: with open("/pyproject.toml", "rb") as f: @@ -49,12 +51,20 @@ class Settings(BaseModel): TCP_PACKET_SIZE: int = 1024 COROUTINES_NUM_PER_CLIENT: int = 3 + POSTGRES_RW_MODE: PostgresRWModeType = PostgresRWModeType.SINGLE POSTGRES_SCHEMA: ClassVar[str] = "postgresql+psycopg" - POSTGRES_DB: str = "postgres" + POSTGRES_REPLICA_DB: str = "" + POSTGRES_REPLICA_HOST: str = "" + POSTGRES_REPLICA_USER: str = "" + POSTGRES_REPLICA_PASSWORD: str = "" + POSTGRES_REPLICA_CONNECT_TIMEOUT: int = 4 + + POSTGRES_DB: str = "postgres" POSTGRES_HOST: str = "postgres" POSTGRES_USER: str POSTGRES_PASSWORD: str + POSTGRES_CONNECT_TIMEOUT: int = 4 SESSION_STORAGE_URL: RedisDsn = RedisDsn("redis://dragonfly:6379/1") SESSION_KEY_LENGTH: int = 16 @@ -99,6 +109,54 @@ def POSTGRES_URI(self) -> PostgresDsn: # noqa f"{self.POSTGRES_DB}", ) + @computed_field # type: ignore + @cached_property + def REPLICA_POSTGRES_URI(self) -> PostgresDsn: # noqa + """Build replica postgres DSN.""" + return PostgresDsn( + f"{self.POSTGRES_SCHEMA}://" + f"{self.POSTGRES_REPLICA_USER}:" + f"{self.POSTGRES_REPLICA_PASSWORD}@" + f"{self.POSTGRES_REPLICA_HOST}/" + f"{self.POSTGRES_REPLICA_DB}", + ) + + @cached_property + def engine(self) -> AsyncEngine: + """Get engine.""" + return create_async_engine( + str(self.POSTGRES_URI), + pool_size=self.INSTANCE_DB_POOL_SIZE, + max_overflow=self.INSTANCE_DB_POOL_OVERFLOW, + pool_timeout=self.INSTANCE_DB_POOL_TIMEOUT, + pool_recycle=self.INSTANCE_DB_POOL_RECYCLE, + pool_pre_ping=False, + future=True, + echo=False, + logging_name="master", + connect_args={"connect_timeout": self.POSTGRES_CONNECT_TIMEOUT}, + ) + + @cached_property + def replica_engine(self) -> AsyncEngine | None: + if self.POSTGRES_RW_MODE == PostgresRWModeType.SINGLE: + return None + + return create_async_engine( + str(self.REPLICA_POSTGRES_URI), + pool_size=self.INSTANCE_DB_POOL_SIZE, + max_overflow=self.INSTANCE_DB_POOL_OVERFLOW, + pool_timeout=self.INSTANCE_DB_POOL_TIMEOUT, + pool_recycle=self.INSTANCE_DB_POOL_RECYCLE, + pool_pre_ping=False, + future=True, + echo=False, + logging_name="replica", + connect_args={ + "connect_timeout": self.POSTGRES_REPLICA_CONNECT_TIMEOUT, + }, + ) + VENDOR_NAME: ClassVar[str] = "MultiFactor" VENDOR_VERSION: str = Field( default_factory=_get_vendor_version, @@ -220,20 +278,6 @@ def check_certs_exist(self) -> bool: """Check if certs exist.""" return os.path.exists(self.SSL_CERT) and os.path.exists(self.SSL_KEY) - @cached_property - def engine(self) -> AsyncEngine: - """Get engine.""" - return create_async_engine( - str(self.POSTGRES_URI), - pool_size=self.INSTANCE_DB_POOL_SIZE, - max_overflow=self.INSTANCE_DB_POOL_OVERFLOW, - pool_timeout=self.INSTANCE_DB_POOL_TIMEOUT, - pool_recycle=self.INSTANCE_DB_POOL_RECYCLE, - pool_pre_ping=False, - future=True, - echo=False, - ) - @classmethod def from_os(cls) -> "Settings": """Get cls from environ.""" diff --git a/app/db_routing.py b/app/db_routing.py new file mode 100644 index 000000000..f19ff6e1e --- /dev/null +++ b/app/db_routing.py @@ -0,0 +1,90 @@ +"""Engine registry and routing session. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from typing import Any, Sequence + +from sqlalchemy import Delete, Insert, Update, exc as sa_exc +from sqlalchemy.engine import Engine +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy.orm import Session + +from enums import PostgresRWModeType + + +class EngineRegistry: + _master_engine: AsyncEngine + _replica_engine: AsyncEngine | None + + def __init__( + self, + master_engine: AsyncEngine, + replica_engine: AsyncEngine | None, + ) -> None: + self._master_engine = master_engine + self._replica_engine = replica_engine + + def get_master_engine(self) -> AsyncEngine: + return self._master_engine + + def get_replica_engine(self) -> AsyncEngine: + if self._replica_engine is None: + raise RuntimeError("Replica engine is not configured") + return self._replica_engine + + def get_sync_master_engine(self) -> Engine: + return self._master_engine.sync_engine + + def get_sync_replica_engine(self) -> Engine: + if self._replica_engine is None: + raise RuntimeError("Replica engine is not configured") + return self._replica_engine.sync_engine + + +class RoutingSession(Session): + _force_master: bool = False + + @property + def engine_registry(self) -> EngineRegistry: + engine_registry = self.info.get("engine_registry") + if engine_registry is None: + raise RuntimeError("Engine registry is not configured") + return engine_registry + + @property + def rw_mode(self) -> PostgresRWModeType: + rw_mode = self.info.get("rw_mode") + if rw_mode is None: + raise RuntimeError("RW mode is not configured") + return rw_mode + + def set_force_master(self, value: bool) -> None: + self._force_master = value + + def get_bind(self, mapper=None, *, clause=None, **kw) -> Engine: # type: ignore # noqa: ARG002 + if self.rw_mode == PostgresRWModeType.SINGLE: + return self.engine_registry.get_sync_master_engine() + + if isinstance(clause, Update | Insert | Delete): + self._force_master = True + return self.engine_registry.get_sync_master_engine() + + if self._force_master or self._flushing: + return self.engine_registry.get_sync_master_engine() + else: + return self.engine_registry.get_sync_replica_engine() + + def flush(self, objects: Sequence[Any] | None = None) -> None: + if self._flushing: + raise sa_exc.InvalidRequestError("Session is already flushing") + + if self._is_clean(): + return + try: + self._flushing = True + self._flush(objects) + finally: + self._flushing = False + self._force_master = True diff --git a/app/enums.py b/app/enums.py index 1f6e8f798..b4ef3cde4 100644 --- a/app/enums.py +++ b/app/enums.py @@ -12,6 +12,13 @@ from typing import Iterable, Self +class PostgresRWModeType(StrEnum): + """Postgres read/write mode type.""" + + SINGLE = "single" + REPLICATION = "replication" + + class AceType(IntEnum): """ACE types.""" diff --git a/app/ioc.py b/app/ioc.py index d6489f842..81909c9c3 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -8,12 +8,12 @@ import httpx import redis.asyncio as redis +from db_routing import EngineRegistry, RoutingSession from dishka import Provider, Scope, from_context, provide from fastapi import Request from loguru import logger from sqlalchemy.ext.asyncio import ( AsyncConnection, - AsyncEngine, AsyncSession, async_sessionmaker, ) @@ -88,6 +88,10 @@ from ldap_protocol.ldap_schema.entity_type_use_case import EntityTypeUseCase from ldap_protocol.ldap_schema.object_class_dao import ObjectClassDAO from ldap_protocol.ldap_schema.object_class_use_case import ObjectClassUseCase +from ldap_protocol.master_check_use_case import ( + MasterCheckUseCase, + MasterGatewayProtocol, +) from ldap_protocol.multifactor import ( Creds, LDAPMultiFactorAPI, @@ -148,6 +152,7 @@ from ldap_protocol.session_storage import RedisSessionStorage, SessionStorage from ldap_protocol.session_storage.repository import SessionRepository from password_utils import PasswordUtils +from repo.pg.master_gateway import PGMasterGateway SessionStorageClient = NewType("SessionStorageClient", redis.Redis) KadminHTTPClient = NewType("KadminHTTPClient", httpx.AsyncClient) @@ -163,17 +168,27 @@ class MainProvider(Provider): settings = from_context(provides=Settings, scope=Scope.APP) @provide(scope=Scope.APP) - def get_engine(self, settings: Settings) -> AsyncEngine: - """Get async engine.""" - return settings.engine + def get_engine_registry(self, settings: Settings) -> EngineRegistry: + return EngineRegistry( + master_engine=settings.engine, + replica_engine=settings.replica_engine, + ) @provide(scope=Scope.APP) def get_session_factory( self, - engine: AsyncEngine, + settings: Settings, + engine_registry: EngineRegistry, ) -> async_sessionmaker[AsyncSession]: """Create session factory.""" - return async_sessionmaker(engine, expire_on_commit=False) + return async_sessionmaker( + sync_session_class=RoutingSession, + expire_on_commit=False, + info={ + "engine_registry": engine_registry, + "rw_mode": settings.POSTGRES_RW_MODE, + }, + ) @provide(scope=Scope.REQUEST) async def create_session( @@ -571,6 +586,19 @@ def get_audit_monitor( session_key=session_key, ) + @provide(scope=Scope.REQUEST, provides=MasterGatewayProtocol) + async def get_master_gateway( + self, + session: AsyncSession, + settings: Settings, + ) -> PGMasterGateway: + return PGMasterGateway(session, settings) + + master_check_use_case = provide( + MasterCheckUseCase, + scope=Scope.REQUEST, + ) + identity_provider_gateway = provide( IdentityProviderGateway, scope=Scope.REQUEST, @@ -895,8 +923,9 @@ def get_session_factory( @provide(scope=Scope.APP) async def get_conn_factory( self, - engine: AsyncEngine, + engine_registry: EngineRegistry, ) -> AsyncIterator[AsyncConnection]: """Create session factory.""" + engine = engine_registry.get_master_engine() async with engine.connect() as connection: yield connection diff --git a/app/ldap_protocol/ldap_requests/add.py b/app/ldap_protocol/ldap_requests/add.py index 3747f7b64..f3169ea13 100644 --- a/app/ldap_protocol/ldap_requests/add.py +++ b/app/ldap_protocol/ldap_requests/add.py @@ -64,6 +64,7 @@ class AddRequest(BaseRequest): ``` """ + RESPONSE_TYPE: ClassVar[type] = AddResponse PROTOCOL_OP: ClassVar[int] = ProtocolRequests.ADD CONTEXT_TYPE: ClassVar[type] = LDAPAddRequestContext diff --git a/app/ldap_protocol/ldap_requests/base.py b/app/ldap_protocol/ldap_requests/base.py index 445ce3bae..63667f034 100644 --- a/app/ldap_protocol/ldap_requests/base.py +++ b/app/ldap_protocol/ldap_requests/base.py @@ -18,11 +18,13 @@ from dishka import AsyncContainer from loguru import logger from pydantic import BaseModel +from sqlalchemy.exc import OperationalError from config import Settings from entities import Directory from ldap_protocol.dependency import resolve_deps from ldap_protocol.dialogue import LDAPSession +from ldap_protocol.ldap_codes import LDAPCodes from ldap_protocol.ldap_responses import BaseResponse, LDAPResult from ldap_protocol.objects import ProtocolRequests from ldap_protocol.policies.audit.audit_use_case import AuditUseCase @@ -63,6 +65,7 @@ class _APIProtocol: ... class BaseRequest(ABC, _APIProtocol, BaseModel): """Base request builder.""" + RESPONSE_TYPE: ClassVar[type] CONTEXT_TYPE: ClassVar[type] handle: ClassVar[handler] from_data: ClassVar[serializer] @@ -118,9 +121,17 @@ async def handle_tcp( ctx = await container.get(self.CONTEXT_TYPE) # type: ignore responses = [] - async for response in self.handle(ctx=ctx): - responses.append(response) - yield response + try: + async for response in self.handle(ctx=ctx): + responses.append(response) + yield response + except OperationalError: + if self.PROTOCOL_OP != ProtocolRequests.ABANDON: + yield self.RESPONSE_TYPE( + result_code=LDAPCodes.UNAVAILABLE, + errorMessage="Master DB is not available", + ) + return if self.PROTOCOL_OP != ProtocolRequests.SEARCH: ldap_session = await container.get(LDAPSession) @@ -172,7 +183,17 @@ async def _handle_api( else: log_api.info(f"{get_class_name(self)}[{un}]") - responses = [response async for response in self.handle(ctx=ctx)] + try: + responses = [response async for response in self.handle(ctx=ctx)] + except OperationalError: + responses = [] + if self.PROTOCOL_OP != ProtocolRequests.ABANDON: + responses.append( + self.RESPONSE_TYPE( + result_code=LDAPCodes.UNAVAILABLE, + errorMessage="Master DB is not available", + ), + ) if settings.DEBUG: for response in responses: diff --git a/app/ldap_protocol/ldap_requests/bind.py b/app/ldap_protocol/ldap_requests/bind.py index ad764649e..7f4af0e5c 100644 --- a/app/ldap_protocol/ldap_requests/bind.py +++ b/app/ldap_protocol/ldap_requests/bind.py @@ -8,6 +8,7 @@ from typing import AsyncGenerator, ClassVar from pydantic import Field +from sqlalchemy.exc import OperationalError from entities import NetworkPolicy from enums import MFAFlags @@ -42,6 +43,7 @@ class BindRequest(BaseRequest): """Bind request fields mapping.""" + RESPONSE_TYPE: ClassVar[type] = BindResponse PROTOCOL_OP: ClassVar[int] = ProtocolRequests.BIND CONTEXT_TYPE: ClassVar[type] = LDAPBindRequestContext @@ -215,7 +217,12 @@ async def handle( ) await ctx.ldap_session.set_user(user) - await set_user_logon_attrs(user, ctx.session, ctx.settings.TIMEZONE) + with contextlib.suppress(OperationalError): + await set_user_logon_attrs( + user, + ctx.session, + ctx.settings.TIMEZONE, + ) server_sasl_creds = None if isinstance(self.authentication_choice, SaslSPNEGOAuthentication): diff --git a/app/ldap_protocol/ldap_requests/delete.py b/app/ldap_protocol/ldap_requests/delete.py index 401a5b98f..70062918b 100644 --- a/app/ldap_protocol/ldap_requests/delete.py +++ b/app/ldap_protocol/ldap_requests/delete.py @@ -42,6 +42,7 @@ class DeleteRequest(BaseRequest): DelRequest ::= [APPLICATION 10] LDAPDN """ + RESPONSE_TYPE: ClassVar[type] = DeleteResponse PROTOCOL_OP: ClassVar[int] = ProtocolRequests.DELETE CONTEXT_TYPE: ClassVar[type] = LDAPDeleteRequestContext diff --git a/app/ldap_protocol/ldap_requests/extended.py b/app/ldap_protocol/ldap_requests/extended.py index 1f8cca946..a3e74ad28 100644 --- a/app/ldap_protocol/ldap_requests/extended.py +++ b/app/ldap_protocol/ldap_requests/extended.py @@ -307,6 +307,7 @@ class ExtendedRequest(BaseRequest): requestValue [1] OCTET STRING OPTIONAL } """ + RESPONSE_TYPE: ClassVar[type] = ExtendedResponse PROTOCOL_OP: ClassVar[int] = ProtocolRequests.EXTENDED CONTEXT_TYPE: ClassVar[type] = LDAPExtendedRequestContext request_name: LDAPOID diff --git a/app/ldap_protocol/ldap_requests/modify.py b/app/ldap_protocol/ldap_requests/modify.py index 2abdc1d39..9f5b8789d 100644 --- a/app/ldap_protocol/ldap_requests/modify.py +++ b/app/ldap_protocol/ldap_requests/modify.py @@ -102,6 +102,7 @@ class ModifyRequest(BaseRequest): ``` """ + RESPONSE_TYPE: ClassVar[type] = ModifyResponse PROTOCOL_OP: ClassVar[int] = ProtocolRequests.MODIFY CONTEXT_TYPE: ClassVar[type] = LDAPModifyRequestContext diff --git a/app/ldap_protocol/ldap_requests/modify_dn.py b/app/ldap_protocol/ldap_requests/modify_dn.py index cdf03ab7b..dc4421d49 100644 --- a/app/ldap_protocol/ldap_requests/modify_dn.py +++ b/app/ldap_protocol/ldap_requests/modify_dn.py @@ -67,6 +67,7 @@ class ModifyDNRequest(BaseRequest): >>> cn = main2, cn = Users, dc = multifactor, dc = dev """ + RESPONSE_TYPE: ClassVar[type] = ModifyDNResponse PROTOCOL_OP: ClassVar[int] = ProtocolRequests.MODIFY_DN CONTEXT_TYPE: ClassVar[type] = LDAPModifyDNRequestContext diff --git a/app/ldap_protocol/ldap_requests/search.py b/app/ldap_protocol/ldap_requests/search.py index c6505322a..d5ff4e679 100644 --- a/app/ldap_protocol/ldap_requests/search.py +++ b/app/ldap_protocol/ldap_requests/search.py @@ -104,6 +104,7 @@ class SearchRequest(BaseRequest): ``` """ + RESPONSE_TYPE: ClassVar[type] = SearchResultDone PROTOCOL_OP: ClassVar[int] = ProtocolRequests.SEARCH CONTEXT_TYPE: ClassVar[type] = LDAPSearchRequestContext diff --git a/app/ldap_protocol/master_check_use_case.py b/app/ldap_protocol/master_check_use_case.py new file mode 100644 index 000000000..2c010e788 --- /dev/null +++ b/app/ldap_protocol/master_check_use_case.py @@ -0,0 +1,30 @@ +"""Check Master Use Case. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from typing import ClassVar, Protocol + +from abstract_service import AbstractService +from enums import AuthorizationRules + + +class MasterGatewayProtocol(Protocol): + """Master DB Gateway Protocol.""" + + async def check_master(self) -> bool: ... + + +class MasterCheckUseCase(AbstractService): + """Check Master Use Case.""" + + _master_gateway: MasterGatewayProtocol + + def __init__(self, master_gateway: MasterGatewayProtocol) -> None: + self._master_gateway = master_gateway + + async def check_master(self) -> bool: + return await self._master_gateway.check_master() + + PERMISSIONS: ClassVar[dict[str, AuthorizationRules]] = {} diff --git a/app/ldap_protocol/session_storage/repository.py b/app/ldap_protocol/session_storage/repository.py index 84366faee..2e73dbc2d 100644 --- a/app/ldap_protocol/session_storage/repository.py +++ b/app/ldap_protocol/session_storage/repository.py @@ -1,9 +1,11 @@ """Enterprise Session Repository.""" +import contextlib from dataclasses import dataclass from ipaddress import IPv4Address, IPv6Address from typing import ClassVar, Literal +from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession from abstract_service import AbstractService @@ -87,8 +89,13 @@ async def create_session_key( }, ttl=ttl, ) + with contextlib.suppress(OperationalError): + await set_user_logon_attrs( + user, + self.session, + self.settings.TIMEZONE, + ) - await set_user_logon_attrs(user, self.session, self.settings.TIMEZONE) return key async def get_user_sessions( diff --git a/app/repo/pg/master_gateway.py b/app/repo/pg/master_gateway.py new file mode 100644 index 000000000..20476c8d3 --- /dev/null +++ b/app/repo/pg/master_gateway.py @@ -0,0 +1,33 @@ +"""Master DB Gateway. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from loguru import logger +from sqlalchemy import text +from sqlalchemy.exc import OperationalError +from sqlalchemy.ext.asyncio import AsyncSession + +from config import Settings +from enums import PostgresRWModeType + + +class PGMasterGateway: + def __init__(self, session: AsyncSession, settings: Settings) -> None: + self._session = session + self._settings = settings + + async def check_master(self) -> bool: + if self._settings.POSTGRES_RW_MODE == PostgresRWModeType.SINGLE: + return True + + try: + self._session.sync_session.set_force_master(True) # type: ignore + await self._session.execute(text("SELECT 1")) + except OperationalError as e: + logger.error(f"Master DB check failed: {e}") + return False + else: + self._session.sync_session.set_force_master(False) # type: ignore + return True diff --git a/tests/conftest.py b/tests/conftest.py index efe46fd21..eddfb7215 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -110,6 +110,10 @@ from ldap_protocol.ldap_schema.entity_type_use_case import EntityTypeUseCase from ldap_protocol.ldap_schema.object_class_dao import ObjectClassDAO from ldap_protocol.ldap_schema.object_class_use_case import ObjectClassUseCase +from ldap_protocol.master_check_use_case import ( + MasterCheckUseCase, + MasterGatewayProtocol, +) from ldap_protocol.multifactor import LDAPMultiFactorAPI, MultifactorAPI from ldap_protocol.permissions_checker import AuthorizationProvider from ldap_protocol.policies.audit.audit_use_case import AuditUseCase @@ -157,6 +161,7 @@ from ldap_protocol.session_storage.repository import SessionRepository from ldap_protocol.utils.queries import get_user from password_utils import PasswordUtils +from repo.pg.master_gateway import PGMasterGateway from tests.constants import TEST_DATA @@ -467,6 +472,19 @@ async def get_redis_for_sessions( with suppress(RuntimeError): await client.aclose() + @provide(scope=Scope.REQUEST, provides=MasterGatewayProtocol) + async def get_master_gateway( + self, + session: AsyncSession, + settings: Settings, + ) -> PGMasterGateway: + return PGMasterGateway(session, settings) + + master_check_use_case = provide( + MasterCheckUseCase, + scope=Scope.REQUEST, + ) + @provide(scope=Scope.APP) async def get_session_storage( self, From e8132afeb3a9c2bb5e35bfbcb310ab86807826cc Mon Sep 17 00:00:00 2001 From: Misha-Shvets <76677350+Misha-Shvets@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:31:53 +0300 Subject: [PATCH 15/45] Fix: loss attr displayName for Contact (#931) --- app/ldap_protocol/ldap_requests/add.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/ldap_protocol/ldap_requests/add.py b/app/ldap_protocol/ldap_requests/add.py index f3169ea13..d6e6e8078 100644 --- a/app/ldap_protocol/ldap_requests/add.py +++ b/app/ldap_protocol/ldap_requests/add.py @@ -234,7 +234,12 @@ async def handle( # noqa: C901 parent_groups: list[Group] = [] user_attributes: dict[str, str] = {} group_attributes: list[str] = [] - user_fields = User.search_fields.keys() | User.fields.keys() + is_user_like = "user" in self.object_class_names + user_fields = ( + User.search_fields.keys() | User.fields.keys() + if is_user_like + else set() + ) attributes.append( Attribute( From 319a61a1dc1f92e0fc7ddeff7836699998f34098 Mon Sep 17 00:00:00 2001 From: Nikita Ulyanov <69312634+rimu-stack@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:22:27 +0300 Subject: [PATCH 16/45] Release 2.7.0 (#916) * refactor: fix paths kadmin_api entrypoint (#903) * add: rename services (#905) * fix: replace services with system (#906) --- .kerberos/entrypoint.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.kerberos/entrypoint.sh b/.kerberos/entrypoint.sh index f063bbea4..16ddbe584 100755 --- a/.kerberos/entrypoint.sh +++ b/.kerberos/entrypoint.sh @@ -2,8 +2,9 @@ set -e -sed -i 's/ou=users/cn=users/g' /etc/kdc/krb5.d/stash.keyfile || true -sed -i 's/ou=users/cn=users/g' /etc/kdc/krb5.conf || true +sed -i 's/ou=users/cn=users/g' /etc/krb5.d/stash.keyfile || true +sed -i 's/ou=users/cn=users/g' /etc/krb5.conf || true +sed -i 's/ou=services/ou=System/g' /etc/krb5.conf || true cd /server From 73e798fdb63411a0003264d53b2e9f89b8e08ab6 Mon Sep 17 00:00:00 2001 From: Misha-Shvets <76677350+Misha-Shvets@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:58:42 +0300 Subject: [PATCH 17/45] Fix: search without base_object (#930) --- app/config.py | 6 ++++++ app/ldap_protocol/ldap_requests/search.py | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/config.py b/app/config.py index 8637bcdd6..f67bfaeaf 100644 --- a/app/config.py +++ b/app/config.py @@ -243,6 +243,12 @@ def MFA_API_URI(self) -> str: # noqa: N802 return "https://api.multifactor.dev" return "https://api.multifactor.ru" + @computed_field # type: ignore + @cached_property + def is_global_catalog(self) -> bool: + """Check if this is Global Catalog server.""" + return self.PORT in (self.GLOBAL_LDAP_PORT, self.GLOBAL_LDAP_TLS_PORT) + @computed_field # type: ignore @cached_property def KRB5_CONFIG_SERVER(self) -> HttpUrl: # noqa: N802 diff --git a/app/ldap_protocol/ldap_requests/search.py b/app/ldap_protocol/ldap_requests/search.py index d5ff4e679..b82e41ff7 100644 --- a/app/ldap_protocol/ldap_requests/search.py +++ b/app/ldap_protocol/ldap_requests/search.py @@ -304,9 +304,16 @@ async def get_result( result_code=LDAPCodes.INSUFFICIENT_ACCESS_RIGHTS, ) return + base_directories = await get_base_directories(ctx.session) + if ( + ctx.settings.is_global_catalog + and not self.base_object + and base_directories + ): + self.base_object = base_directories[0].path_dn query = self._build_query( - await get_base_directories(ctx.session), + base_directories, user, ctx.access_manager, ) From 25ed411d2e70b2d5b26d7bbb2c9899e51843fe19 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Thu, 12 Feb 2026 14:37:16 +0300 Subject: [PATCH 18/45] Fix ModifyDN access check (#932) --- app/ldap_protocol/ldap_requests/delete.py | 1 + app/ldap_protocol/ldap_requests/modify_dn.py | 173 ++++++++--- app/ldap_protocol/roles/access_manager.py | 15 +- tests/test_ldap/test_util/test_modify.py | 287 ++++++++++++++++++- 4 files changed, 439 insertions(+), 37 deletions(-) diff --git a/app/ldap_protocol/ldap_requests/delete.py b/app/ldap_protocol/ldap_requests/delete.py index 70062918b..b8ad639d8 100644 --- a/app/ldap_protocol/ldap_requests/delete.py +++ b/app/ldap_protocol/ldap_requests/delete.py @@ -75,6 +75,7 @@ async def handle( # noqa: C901 select(Directory) .options( joinedload(qa(Directory.user)), + joinedload(qa(Directory.entity_type)), selectinload(qa(Directory.groups)).selectinload( qa(Group.directory), ), diff --git a/app/ldap_protocol/ldap_requests/modify_dn.py b/app/ldap_protocol/ldap_requests/modify_dn.py index dc4421d49..7cd6d45c7 100644 --- a/app/ldap_protocol/ldap_requests/modify_dn.py +++ b/app/ldap_protocol/ldap_requests/modify_dn.py @@ -8,7 +8,8 @@ from sqlalchemy import delete, func, select, text, update from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import joinedload, selectinload +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload from entities import AccessControlEntry, Attribute, Directory from enums import AceType @@ -18,7 +19,13 @@ INVALID_ACCESS_RESPONSE, ModifyDNResponse, ) -from ldap_protocol.objects import ProtocolRequests +from ldap_protocol.objects import ( + Changes, + Operation, + PartialAttribute, + ProtocolRequests, +) +from ldap_protocol.roles.access_manager import AccessManager from ldap_protocol.utils.queries import get_filter_from_path, validate_entry from repo.pg.tables import ( ace_directory_memberships_table, @@ -86,7 +93,87 @@ def from_data(cls, data: list[ASN1Row]) -> "ModifyDNRequest": new_superior=None if len(data) < 4 else data[3].value, ) - async def handle( + def _is_move_to_new_superior(self, directory: Directory) -> bool: + return bool( + self.new_superior + and directory.parent + and self.new_superior != directory.parent.path_dn, + ) + + def _can_modify_rdn( + self, + access_manager: AccessManager, + directory: Directory, + old_dn: str, + old_name: str, + new_dn: str, + new_name: str, + ) -> bool: + change = [ + Changes( + operation=Operation.ADD, + modification=PartialAttribute(type=new_dn, vals=[new_name]), + ), + ] + if self.deleteoldrdn: + change.append( + Changes( + operation=Operation.DELETE, + modification=PartialAttribute( + type=old_dn, + vals=[old_name], + ), + ), + ) + return access_manager.check_modify_access( + changes=change, + aces=directory.access_control_entries, + entity_type_id=directory.entity_type_id, + ) + + async def _delete_old_inherited_aces( + self, + session: AsyncSession, + directory: Directory, + old_depth: int, + ) -> None: + old_inherited_aces_ids = select(qa(AccessControlEntry.id)).where( + qa(AccessControlEntry.directories).contains(directory), + qa(AccessControlEntry.depth) != old_depth, + ) + await session.execute( + delete(ace_directory_memberships_table) + .filter_by( + directory_id=directory.id, + ) + .where( + ace_directory_memberships_table.c.access_control_entry_id.in_( + old_inherited_aces_ids, + ), + ), + ) + + async def _update_explicit_aces( + self, + session: AsyncSession, + directory: Directory, + old_depth: int, + new_path: list[str], + ) -> None: + new_path_dn = ",".join(reversed(new_path)) + new_depth = len(new_path) + + explicit_aces_ids = select(qa(AccessControlEntry.id)).where( + qa(AccessControlEntry.directories).contains(directory), + qa(AccessControlEntry.depth) == old_depth, + ) + await session.execute( + update(AccessControlEntry) + .where(qa(AccessControlEntry.id).in_(explicit_aces_ids)) + .values(path=new_path_dn, depth=new_depth), + ) + + async def handle( # noqa: C901 self, ctx: LDAPModifyDNRequestContext, ) -> AsyncGenerator[ModifyDNResponse, None]: @@ -123,8 +210,8 @@ async def handle( query = ctx.access_manager.mutate_query_with_ace_load( user_role_ids=ctx.ldap_session.user.role_ids, query=query, - ace_types=[AceType.DELETE], - require_attribute_type_null=True, + ace_types=[AceType.DELETE, AceType.WRITE], + load_attribute_type=True, ) directory = await ctx.session.scalar(query) @@ -143,15 +230,27 @@ async def handle( ) return - old_name = directory.name new_dn, new_name = self.newrdn.split("=") - directory.name = new_name + is_move_to_new_superior = self._is_move_to_new_superior(directory) + old_name = directory.name old_path = directory.path old_dn = old_path[-1].split("=")[0] - old_depth = directory.depth + if not self._can_modify_rdn( + access_manager=ctx.access_manager, + directory=directory, + old_dn=old_dn, + old_name=old_name, + new_dn=new_dn, + new_name=new_name, + ): + yield ModifyDNResponse( + result_code=LDAPCodes.INSUFFICIENT_ACCESS_RIGHTS, + ) + return + if ( directory.entity_type and not ctx.attribute_value_validator.is_value_valid( @@ -167,13 +266,31 @@ async def handle( ) return - if ( - self.new_superior - and directory.parent - and self.new_superior != directory.parent.path_dn - ): + directory.name = new_name + + if is_move_to_new_superior: + delete_aces = [ + ace + for ace in directory.access_control_entries + if ( + ace.ace_type == AceType.DELETE + and ace.attribute_type is None + ) + ] + + can_delete = ctx.access_manager.check_entity_level_access( + aces=delete_aces, + entity_type_id=directory.entity_type_id, + ) + + if not can_delete: + yield ModifyDNResponse( + result_code=LDAPCodes.INSUFFICIENT_ACCESS_RIGHTS, + ) + return + new_sup_query = select(Directory).filter( - get_filter_from_path(self.new_superior), + get_filter_from_path(self.new_superior), # type: ignore ) new_sup_query = ctx.access_manager.mutate_query_with_ace_load( user_role_ids=ctx.ldap_session.user.role_ids, @@ -204,11 +321,11 @@ async def handle( try: await ctx.session.flush() - await ctx.session.execute( - delete(ace_directory_memberships_table) - .filter_by(directory_id=directory.id), - ) # fmt: skip - + await self._delete_old_inherited_aces( + ctx.session, + directory=directory, + old_depth=old_depth, + ) await ctx.role_use_case.inherit_parent_aces( parent_directory=directory.parent, directory=directory, @@ -266,20 +383,12 @@ async def handle( ) await ctx.session.flush() - explicit_aces_query = ( - select(AccessControlEntry) - .options(selectinload(qa(AccessControlEntry.directories))) - .where( - qa(AccessControlEntry.directories).any( - qa(Directory.id) == directory.id, - ), - qa(AccessControlEntry.depth) == old_depth, - ) + await self._update_explicit_aces( + ctx.session, + directory, + old_depth, + new_path, ) - for ace in await ctx.session.scalars(explicit_aces_query): - ace.directories.append(directory) - ace.path = directory.path_dn - ace.depth = directory.depth await ctx.session.flush() diff --git a/app/ldap_protocol/roles/access_manager.py b/app/ldap_protocol/roles/access_manager.py index e651e5d62..0edc74190 100644 --- a/app/ldap_protocol/roles/access_manager.py +++ b/app/ldap_protocol/roles/access_manager.py @@ -304,12 +304,19 @@ def mutate_query_with_ace_load( null attribute_type_id :return: mutated query with access control entries loaded """ - selectin_loader = selectinload( + base_loader = selectinload( qa(Directory.access_control_entries), ) + + loader_options = [ + base_loader.joinedload(qa(AccessControlEntry.entity_type)), + ] + if load_attribute_type: - selectin_loader = selectin_loader.joinedload( - qa(AccessControlEntry.attribute_type), + loader_options.append( + base_loader.joinedload( + qa(AccessControlEntry.attribute_type), + ), ) criteria_conditions = [ @@ -331,7 +338,7 @@ def mutate_query_with_ace_load( ) return query.options( - selectin_loader, + *loader_options, with_loader_criteria( AccessControlEntry, and_(*criteria_conditions), diff --git a/tests/test_ldap/test_util/test_modify.py b/tests/test_ldap/test_util/test_modify.py index eda4c1fe0..e2a55e24c 100644 --- a/tests/test_ldap/test_util/test_modify.py +++ b/tests/test_ldap/test_util/test_modify.py @@ -18,9 +18,10 @@ from config import Settings from entities import Directory, Group -from enums import AceType, RoleScope +from enums import AceType, EntityTypeNames, RoleScope from ldap_protocol.kerberos.base import AbstractKadmin from ldap_protocol.ldap_codes import LDAPCodes +from ldap_protocol.ldap_schema.entity_type_dao import EntityTypeDAO from ldap_protocol.objects import Operation from ldap_protocol.roles.ace_dao import AccessControlEntryDAO from ldap_protocol.roles.dataclasses import AccessControlEntryDTO, RoleDTO @@ -821,6 +822,49 @@ async def run_single_modify( return await proc.wait() +async def run_single_modrdn( + *, + settings: Settings, + bind_dn: str, + password: str, + dn: str, + newrdn: str, + deleteoldrdn: int = 1, + newsuperior: str | None = None, +) -> int: + with tempfile.NamedTemporaryFile("w") as file: + lines = [ + f"dn: {dn}", + "changetype: modrdn", + f"newrdn: {newrdn}", + f"deleteoldrdn: {deleteoldrdn}", + ] + if newsuperior is not None: + lines.append(f"newsuperior: {newsuperior}") + + file.write("\n".join(lines) + "\n") + file.seek(0) + + proc = await asyncio.create_subprocess_exec( + "ldapmodify", + "-vvv", + "-H", + f"ldap://{settings.HOST}:{settings.PORT}", + "-D", + bind_dn, + "-x", + "-w", + password, + "-f", + file.name, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + await proc.communicate() + return await proc.wait() + + async def fetch_directory_by_dn(session: AsyncSession, dn: str) -> Directory: """Fetch directory by DN.""" query = ( @@ -997,3 +1041,244 @@ async def test_ldap_modify_replace_memberof_primary_group_various( user_dir = await fetch_directory_by_dn(session, user_dn) group_names = {group.directory.name for group in user_dir.groups} assert group_names == expected_groups + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("setup_session") +async def test_modify_dn_rename_with_ap( + settings: Settings, + creds: TestCreds, + role_dao: RoleDAO, + access_control_entry_dao: AccessControlEntryDAO, + entity_type_dao: EntityTypeDAO, + attribute_type_dao: EntityTypeDAO, +) -> None: + dn = "cn=user0,cn=Users,dc=md,dc=test" + base_dn = "dc=md,dc=test" + + user_entity_type = await entity_type_dao.get(EntityTypeNames.USER) + assert user_entity_type + + rdn_attr = await attribute_type_dao.get("cn") + assert rdn_attr + + res = await run_single_modrdn( + settings=settings, + bind_dn="user_non_admin", + password=creds.pw, + dn=dn, + newrdn="cn=user2", + deleteoldrdn=1, + ) + + assert res == LDAPCodes.INSUFFICIENT_ACCESS_RIGHTS + + await role_dao.create( + dto=RoleDTO( + name="Modify Role", + creator_upn=None, + is_system=False, + groups=["cn=domain users,cn=Groups," + base_dn], + ), + ) + + role_id = role_dao.get_last_id() + + write_ace = AccessControlEntryDTO( + role_id=role_id, + ace_type=AceType.WRITE, + scope=RoleScope.WHOLE_SUBTREE, + base_dn=dn, + attribute_type_id=rdn_attr.id, + entity_type_id=user_entity_type.id, + is_allow=True, + ) + delete_ace = AccessControlEntryDTO( + role_id=role_id, + ace_type=AceType.DELETE, + scope=RoleScope.WHOLE_SUBTREE, + base_dn=dn, + attribute_type_id=rdn_attr.id, + entity_type_id=user_entity_type.id, + is_allow=True, + ) + + await access_control_entry_dao.create_bulk([write_ace, delete_ace]) + + aces_before = await access_control_entry_dao.get_all() + + res = await run_single_modrdn( + settings=settings, + bind_dn="user_non_admin", + password=creds.pw, + dn=dn, + newrdn="cn=user2", + deleteoldrdn=1, + ) + + assert res == LDAPCodes.SUCCESS + + aces_after = await access_control_entry_dao.get_all() + + inherited_aces_before = [ + ace for ace in aces_before if ace.base_dn == base_dn + ] + explicit_aces_before = [ + ace for ace in aces_before if ace.base_dn != base_dn + ] + + inherited_aces_after = [ + ace for ace in aces_after if ace.base_dn == base_dn + ] + explicit_aces_after = [ace for ace in aces_after if ace.base_dn != base_dn] + + assert inherited_aces_before == inherited_aces_after + assert len(explicit_aces_after) == len(explicit_aces_before) + + # NOTE: Check explicit ACEs have same properties except base_dn + for ace_before, ace_after in zip( + explicit_aces_before, + explicit_aces_after, + ): + assert ace_before.id == ace_after.id + assert ace_before.role_id == ace_after.role_id + assert ace_before.ace_type == ace_after.ace_type + assert ace_before.scope == ace_after.scope + assert ace_before.attribute_type_id == ace_after.attribute_type_id + assert ace_before.entity_type_id == ace_after.entity_type_id + assert ace_before.is_allow == ace_after.is_allow + + assert ace_after.base_dn == "cn=user2,cn=Users,dc=md,dc=test" + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("setup_session") +async def test_modify_dn_move_with_ap( + settings: Settings, + creds: TestCreds, + role_dao: RoleDAO, + access_control_entry_dao: AccessControlEntryDAO, + entity_type_dao: EntityTypeDAO, + attribute_type_dao: EntityTypeDAO, +) -> None: + dn = "cn=user0,cn=Users,dc=md,dc=test" + base_dn = "dc=md,dc=test" + + user_entity_type = await entity_type_dao.get(EntityTypeNames.USER) + assert user_entity_type + + rdn_attr = await attribute_type_dao.get("cn") + assert rdn_attr + + new_parent_dn = "cn=Groups,dc=md,dc=test" + + res = await run_single_modrdn( + settings=settings, + bind_dn="user_non_admin", + password=creds.pw, + dn=dn, + newrdn="cn=user2", + deleteoldrdn=1, + newsuperior=new_parent_dn, + ) + + assert res == LDAPCodes.INSUFFICIENT_ACCESS_RIGHTS + + await role_dao.create( + dto=RoleDTO( + name="Modify Role", + creator_upn=None, + is_system=False, + groups=["cn=domain users,cn=Groups," + base_dn], + ), + ) + + role_id = role_dao.get_last_id() + + write_ace = AccessControlEntryDTO( + role_id=role_id, + ace_type=AceType.WRITE, + scope=RoleScope.WHOLE_SUBTREE, + base_dn=dn, + attribute_type_id=rdn_attr.id, + entity_type_id=user_entity_type.id, + is_allow=True, + ) + create_ace = AccessControlEntryDTO( + role_id=role_id, + ace_type=AceType.CREATE_CHILD, + scope=RoleScope.WHOLE_SUBTREE, + base_dn=new_parent_dn, + attribute_type_id=None, + entity_type_id=user_entity_type.id, + is_allow=True, + ) + delete_ace = AccessControlEntryDTO( + role_id=role_id, + ace_type=AceType.DELETE, + scope=RoleScope.WHOLE_SUBTREE, + base_dn=dn, + attribute_type_id=None, + entity_type_id=user_entity_type.id, + is_allow=True, + ) + + await access_control_entry_dao.create_bulk( + [write_ace, create_ace, delete_ace], + ) + + aces_before = await access_control_entry_dao.get_all() + + res = await run_single_modrdn( + settings=settings, + bind_dn="user_non_admin", + password=creds.pw, + dn=dn, + newrdn="cn=user2", + deleteoldrdn=1, + newsuperior=new_parent_dn, + ) + + assert res == LDAPCodes.SUCCESS + + aces_after = await access_control_entry_dao.get_all() + + inherited_aces_before = [ + ace + for ace in aces_before + if ace.base_dn != "cn=user0,cn=Users,dc=md,dc=test" + ] + explicit_aces_before = [ + ace + for ace in aces_before + if ace.base_dn == "cn=user0,cn=Users,dc=md,dc=test" + ] + + inherited_aces_after = [ + ace + for ace in aces_after + if ace.base_dn != "cn=user2,cn=Groups,dc=md,dc=test" + ] + explicit_aces_after = [ + ace + for ace in aces_after + if ace.base_dn == "cn=user2,cn=Groups,dc=md,dc=test" + ] + + assert inherited_aces_before == inherited_aces_after + assert len(explicit_aces_after) == len(explicit_aces_before) + + # check expicit aces have same properties except base_dn + for ace_before, ace_after in zip( + explicit_aces_before, + explicit_aces_after, + ): + assert ace_before.id == ace_after.id + assert ace_before.role_id == ace_after.role_id + assert ace_before.ace_type == ace_after.ace_type + assert ace_before.scope == ace_after.scope + assert ace_before.attribute_type_id == ace_after.attribute_type_id + assert ace_before.entity_type_id == ace_after.entity_type_id + assert ace_before.is_allow == ace_after.is_allow + + assert ace_after.base_dn == "cn=user2,cn=Groups,dc=md,dc=test" From 143253ba6b560bf8e1b881e12179a91ff44c718e Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Thu, 12 Feb 2026 17:08:57 +0300 Subject: [PATCH 19/45] Add: AttributeType system_flags (#926) --- .../275222846605_initial_ldap_schema.py | 13 +- ...26a_add_system_flags_to_attribute_types.py | 172 ++++++++++++++++++ .../ldap_schema/adapters/attribute_type.py | 1 + app/api/ldap_schema/schema.py | 1 + app/entities.py | 1 + app/enums.py | 1 + app/ioc.py | 7 + .../ldap_schema/attribute_type_dao.py | 22 ++- .../attribute_type_system_flags_use_case.py | 64 +++++++ .../ldap_schema/attribute_type_use_case.py | 40 +++- app/ldap_protocol/ldap_schema/dto.py | 1 + .../ldap_schema/entity_type_dao.py | 17 +- .../ldap_schema/entity_type_use_case.py | 12 +- .../ldap_schema/object_class_dao.py | 25 +-- .../ldap_schema/object_class_use_case.py | 14 +- .../utils/raw_definition_parser.py | 1 + app/repo/pg/tables.py | 1 + interface | 2 +- tests/conftest.py | 24 +-- tests/test_ldap/test_ldap_schema/__init__.py | 5 + tests/test_ldap/test_ldap_schema/conftest.py | 23 +++ .../test_attribute_type_use_case.py | 36 ++++ 22 files changed, 407 insertions(+), 76 deletions(-) create mode 100644 app/alembic/versions/2dadf40c026a_add_system_flags_to_attribute_types.py create mode 100644 app/ldap_protocol/ldap_schema/attribute_type_system_flags_use_case.py create mode 100644 tests/test_ldap/test_ldap_schema/__init__.py create mode 100644 tests/test_ldap/test_ldap_schema/conftest.py create mode 100644 tests/test_ldap/test_ldap_schema/test_attribute_type_use_case.py diff --git a/app/alembic/versions/275222846605_initial_ldap_schema.py b/app/alembic/versions/275222846605_initial_ldap_schema.py index 226c9270b..6994b0c77 100644 --- a/app/alembic/versions/275222846605_initial_ldap_schema.py +++ b/app/alembic/versions/275222846605_initial_ldap_schema.py @@ -50,12 +50,11 @@ def upgrade(container: AsyncContainer) -> None: sa.Column("single_value", sa.Boolean(), nullable=False), sa.Column("no_user_modification", sa.Boolean(), nullable=False), sa.Column("is_system", sa.Boolean(), nullable=False), - sa.Column( - "is_included_anr", - sa.Boolean(), - nullable=True, - ), # NOTE: added in f24ed0e49df2_add_filter_anr.py sa.PrimaryKeyConstraint("id"), + # NOTE: it added in 2dadf40c026a_add_system_flags_to_attribute_types.py + sa.Column("system_flags", sa.Integer(), nullable=False), + # NOTE: it added in f24ed0e49df2_add_filter_anr.py + sa.Column("is_included_anr", sa.Boolean(), nullable=True), ) op.create_index( op.f("ix_AttributeTypes_oid"), @@ -359,6 +358,7 @@ async def _create_attribute_types(connection: AsyncConnection) -> None: # noqa: single_value=True, no_user_modification=False, is_system=True, + system_flags=0, is_included_anr=False, ), ) @@ -400,6 +400,9 @@ async def _modify_object_classes(connection: AsyncConnection) -> None: # noqa: # NOTE: it added in f24ed0e49df2_add_filter_anr.py op.drop_column("AttributeTypes", "is_included_anr") + # NOTE: it added in 2dadf40c026a_add_system_flags_to_attribute_types.py + op.drop_column("AttributeTypes", "system_flags") + session.commit() diff --git a/app/alembic/versions/2dadf40c026a_add_system_flags_to_attribute_types.py b/app/alembic/versions/2dadf40c026a_add_system_flags_to_attribute_types.py new file mode 100644 index 000000000..b819c1c86 --- /dev/null +++ b/app/alembic/versions/2dadf40c026a_add_system_flags_to_attribute_types.py @@ -0,0 +1,172 @@ +"""Add systemFlags for AttributeTypes. + +Revision ID: 2dadf40c026a +Revises: f4e6cd18a01d +Create Date: 2026-02-04 09:33:33.218126 + +""" + +import contextlib + +import sqlalchemy as sa +from alembic import op +from dishka import AsyncContainer, Scope +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession +from sqlalchemy.orm import Session + +from entities import AttributeType +from ldap_protocol.ldap_schema.attribute_type_use_case import ( + AttributeTypeUseCase, +) +from ldap_protocol.ldap_schema.exceptions import AttributeTypeNotFoundError + +# revision identifiers, used by Alembic. +revision: None | str = "2dadf40c026a" +down_revision: None | str = "f4e6cd18a01d" +branch_labels: None | list[str] = None +depends_on: None | list[str] = None + + +_NON_REPLICATED_ATTRIBUTES_TYPE_NAMES = ( + "badPasswordTime", + "badPwdCount", + "bridgeheadServerListBL", + "dSCorePropagationData", + "frsComputerReferenceBL", + "fRSMemberReferenceBL", + "isMemberOfDL", + "isPrivilegeHolder", + "lastLogoff", + "lastLogon", + "logonCount", + "managedObjects", + "masteredBy", + "modifiedCount", + "msCOMPartitionSetLink", + "msCOMUserLink", + "msDSAuthenticatedToAccountlist", + "msDSCachedMembership", + "msDSCachedMembershipTimeStamp", + "msDSEnabledFeatureBL", + "msDSExecuteScriptPassword", + "msDSHostServiceAccountBL", + "msDSMasteredBy", + "msDSOIDToGroupLinkBL", + "msDSPSOApplied", + "msDSMembersForAzRoleBL", + "msDSNCType", + "msDSNonMembersBL", + "msDSObjectReferenceBL", + "msDSOperationsForAzRoleBL", + "msDSOperationsForAzTaskBL", + "msDSNCROReplicaLocationsBL", + "msDSReplicationEpoch", + "msDSRetiredReplNCSignatures", + "msDSTasksForAzRoleBL", + "msDSTasksForAzTaskBL", + "msDSRevealedDSAs", + "msDSKrbTgtLinkBL", + "msDSIsFullReplicaFor", + "msDSIsDomainFor", + "msDSIsPartialReplicaFor", + "msDSUSNLastSyncSuccess", + "msDSValueTypeReferenceBL", + "msDSTokenGroupNames", + "msDSTokenGroupNamesGlobalAndUniversal", + "msDSTokenGroupNamesNoGCAcceptable", + "msExchOwnerBL", + "msDFSRMemberReferenceBL", + "msDFSRComputerReferenceBL", + "netbootSCPBL", + "nonSecurityMemberBL", + "objDistName", + "objectGuid", + "partialAttributeDeletionList", + "partialAttributeSet", + "pekList", + "prefixMap", + "queryPolicyBL", + "replPropertyMetaData", + "replUpToDateVector", + "reports", + "repsFrom", + "repsTo", + "rIDNextRID", + "rIDPreviousAllocationPool", + "schemaUpdate", + "serverReferenceBL", + "serverState", + "siteObjectBL", + "subRefs", + "uSNChanged", + "uSNCreated", + "uSNLastObjRem", + "whenChanged", + "msSFU30PosixMemberOf", + "msTSPrimaryDesktopBL", + "msTSSecondaryDesktopBL", + "msDSBridgeHeadServersUsed", + "msDSClaimSharesPossibleValuesWithBL", + "msDSMembersOfResourcePropertyListBL", + "msTPMTpmInformationForComputerBL", + "msAuthzMemberRulesInCentralAccessPolicyBL", + "msDSGenerationId", + "msDSIsPrimaryComputerFor", + "msDSTDOEgressBL", + "msDSTDOIngressBL", + "msDSTransformationRulesCompiled", + "msDSIsMemberOfDLTransitive", + "msDSMemberTransitive", + "msDSParentDistName", + "msDSAssignedAuthNPolicySiloBL", + "msDSAuthNPolicySiloMembersBL", + "msDSUserAuthNPolicyBL", + "msDSComputerAuthNPolicyBL", + "msDSServiceAuthNPolicyBL", + "msDSAssignedAuthNPolicyBL", + "msDSKeyPrincipalBL", + "msDSKeyCredentialLinkBL", +) + + +def upgrade(container: AsyncContainer) -> None: + """Upgrade.""" + bind = op.get_bind() + session = Session(bind=bind) + + op.add_column( + "AttributeTypes", + sa.Column( + "system_flags", + sa.Integer(), + nullable=True, + server_default=sa.text("0"), + ), + ) + + session.execute(sa.update(AttributeType).values({"system_flags": 0})) + + async def _set_attr_replication_flag(connection: AsyncConnection) -> None: # noqa: ARG001 + async with container(scope=Scope.REQUEST) as cnt: + session = await cnt.get(AsyncSession) + at_type_use_case = await cnt.get(AttributeTypeUseCase) + + for name in _NON_REPLICATED_ATTRIBUTES_TYPE_NAMES: + with contextlib.suppress(AttributeTypeNotFoundError): + await at_type_use_case.set_attr_replication_flag( + name, + need_to_replicate=False, + ) + + await session.commit() + + op.run_async(_set_attr_replication_flag) + + op.alter_column("AttributeTypes", "system_flags", nullable=False) + + session.commit() + + +def downgrade(container: AsyncContainer) -> None: # noqa: ARG001 + """Downgrade.""" + op.drop_column("AttributeTypes", "system_flags") diff --git a/app/api/ldap_schema/adapters/attribute_type.py b/app/api/ldap_schema/adapters/attribute_type.py index ad1ea6516..73e5f32bc 100644 --- a/app/api/ldap_schema/adapters/attribute_type.py +++ b/app/api/ldap_schema/adapters/attribute_type.py @@ -44,6 +44,7 @@ def _convert_update_uschema_to_dto( single_value=request.single_value, no_user_modification=request.no_user_modification, is_system=False, + system_flags=0, is_included_anr=request.is_included_anr, ) diff --git a/app/api/ldap_schema/schema.py b/app/api/ldap_schema/schema.py index 9e6453eff..b3dabefb6 100644 --- a/app/api/ldap_schema/schema.py +++ b/app/api/ldap_schema/schema.py @@ -28,6 +28,7 @@ class AttributeTypeSchema(BaseModel, Generic[_IdT]): single_value: bool no_user_modification: bool is_system: bool + system_flags: int = 0 is_included_anr: bool = False object_class_names: list[str] = Field(default_factory=list) diff --git a/app/entities.py b/app/entities.py index 807df8565..8309f510a 100644 --- a/app/entities.py +++ b/app/entities.py @@ -69,6 +69,7 @@ class AttributeType: single_value: bool = False no_user_modification: bool = False is_system: bool = False + system_flags: int = 0 # NOTE: ms-adts/cf133d47-b358-4add-81d3-15ea1cff9cd9 # see section 3.1.1.2.3 `searchFlags` (fANR) for details is_included_anr: bool = False diff --git a/app/enums.py b/app/enums.py index b4ef3cde4..b48967677 100644 --- a/app/enums.py +++ b/app/enums.py @@ -157,6 +157,7 @@ class AuthorizationRules(IntFlag): ATTRIBUTE_TYPE_GET_PAGINATOR = auto() ATTRIBUTE_TYPE_UPDATE = auto() ATTRIBUTE_TYPE_DELETE_ALL_BY_NAMES = auto() + ATTRIBUTE_TYPE_SET_ATTR_REPLICATION_FLAG = auto() ENTITY_TYPE_GET = auto() ENTITY_TYPE_CREATE = auto() diff --git a/app/ioc.py b/app/ioc.py index 81909c9c3..acd6c43d1 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -78,6 +78,9 @@ LDAPUnbindRequestContext, ) from ldap_protocol.ldap_schema.attribute_type_dao import AttributeTypeDAO +from ldap_protocol.ldap_schema.attribute_type_system_flags_use_case import ( + AttributeTypeSystemFlagsUseCase, +) from ldap_protocol.ldap_schema.attribute_type_use_case import ( AttributeTypeUseCase, ) @@ -450,6 +453,10 @@ def get_dhcp_mngr( scope=Scope.RUNTIME, ) attribute_type_dao = provide(AttributeTypeDAO, scope=Scope.REQUEST) + attribute_type_system_flags_use_case = provide( + AttributeTypeSystemFlagsUseCase, + scope=Scope.REQUEST, + ) object_class_dao = provide(ObjectClassDAO, scope=Scope.REQUEST) entity_type_dao = provide(EntityTypeDAO, scope=Scope.REQUEST) attribute_type_use_case = provide( diff --git a/app/ldap_protocol/ldap_schema/attribute_type_dao.py b/app/ldap_protocol/ldap_schema/attribute_type_dao.py index 30211f74c..63b795e0a 100644 --- a/app/ldap_protocol/ldap_schema/attribute_type_dao.py +++ b/app/ldap_protocol/ldap_schema/attribute_type_dao.py @@ -56,9 +56,9 @@ def __init__(self, session: AsyncSession) -> None: """Initialize Attribute Type DAO with session.""" self.__session = session - async def get(self, _id: str) -> AttributeTypeDTO: - """Get Attribute Type by id.""" - return _convert_model_to_dto(await self._get_one_raw_by_name(_id)) + async def get(self, name: str) -> AttributeTypeDTO: + """Get Attribute Type by name.""" + return _convert_model_to_dto(await self._get_one_raw_by_name(name)) async def get_all(self) -> list[AttributeTypeDTO]: """Get all Attribute Types.""" @@ -82,7 +82,7 @@ async def create(self, dto: AttributeTypeDTO) -> None: + f" '{dto.name}' already exists.", ) - async def update(self, _id: str, dto: AttributeTypeDTO) -> None: + async def update(self, name: str, dto: AttributeTypeDTO) -> None: """Update Attribute Type. Docs: @@ -95,7 +95,7 @@ async def update(self, _id: str, dto: AttributeTypeDTO) -> None: can only be modified for non-system attributes to preserve LDAP schema integrity. """ - obj = await self._get_one_raw_by_name(_id) + obj = await self._get_one_raw_by_name(name) obj.is_included_anr = dto.is_included_anr @@ -106,9 +106,15 @@ async def update(self, _id: str, dto: AttributeTypeDTO) -> None: await self.__session.flush() - async def delete(self, _id: str) -> None: + async def update_sys_flags(self, name: str, dto: AttributeTypeDTO) -> None: + """Update system flags of Attribute Type.""" + obj = await self._get_one_raw_by_name(name) + obj.system_flags = dto.system_flags + await self.__session.flush() + + async def delete(self, name: str) -> None: """Delete Attribute Type.""" - attribute_type = await self._get_one_raw_by_name(_id) + attribute_type = await self._get_one_raw_by_name(name) await self.__session.delete(attribute_type) await self.__session.flush() @@ -150,7 +156,7 @@ async def _get_one_raw_by_name(self, name: str) -> AttributeType: async def get_all_by_names( self, names: list[str] | set[str], - ) -> list[AttributeTypeDTO]: + ) -> list[AttributeTypeDTO[int]]: """Get list of Attribute Types by names. :param list[str] names: Attribute Type names. diff --git a/app/ldap_protocol/ldap_schema/attribute_type_system_flags_use_case.py b/app/ldap_protocol/ldap_schema/attribute_type_system_flags_use_case.py new file mode 100644 index 000000000..a903028a8 --- /dev/null +++ b/app/ldap_protocol/ldap_schema/attribute_type_system_flags_use_case.py @@ -0,0 +1,64 @@ +"""SystemFlags helpers for LDAP schema objects. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from __future__ import annotations + +from enum import IntFlag + +from ldap_protocol.ldap_schema.dto import AttributeTypeDTO + + +class AttributeTypeSystemFlags(IntFlag): + """SystemFlags for attributeSchema objects in AD. + + Bits from 7 to 25 unused. Must be zero and ignored. + ms-adts/1e38247d-8234-4273-9de3-bbf313548631 + """ + + ATTR_NOT_REPLICATED = 0x00000001 # 31 + ATTR_REQ_PARTIAL_SET_MEMBER = 0x00000002 # 30 + ATTR_IS_CONSTRUCTED = 0x00000004 # 29 + ATTR_IS_OPERATIONAL = 0x00000008 # 28 + SCHEMA_BASE_OBJECT = 0x00000010 # 27 + ATTR_IS_RDN = 0x00000020 # 26 + DISALLOW_MOVE_ON_DELETE = 0x02000000 # 6 + DOMAIN_DISALLOW_MOVE = 0x04000000 # 5 + DOMAIN_DISALLOW_RENAME = 0x08000000 # 4 + CONFIG_ALLOW_LIMITED_MOVE = 0x10000000 # 3 + CONFIG_ALLOW_MOVE = 0x20000000 # 2 + CONFIG_ALLOW_RENAME = 0x40000000 # 1 + DISALLOW_DELETE = 0x80000000 # 0 + + +class AttributeTypeSystemFlagsUseCase: + def is_attr_replicated( + self, + attribute_type_dto: AttributeTypeDTO, + ) -> bool: + """Check if attribute is replicated based on system_flags.""" + return not bool( + attribute_type_dto.system_flags + & AttributeTypeSystemFlags.ATTR_NOT_REPLICATED, + ) + + def set_attr_replication_flag( + self, + attribute_type_dto: AttributeTypeDTO, + need_to_replicate: bool, + ) -> AttributeTypeDTO: + """Set/clear replication flag in systemFlags.""" + if not need_to_replicate: + attribute_type_dto.system_flags = int( + attribute_type_dto.system_flags + | AttributeTypeSystemFlags.ATTR_NOT_REPLICATED, + ) + else: + attribute_type_dto.system_flags = int( + attribute_type_dto.system_flags + & ~AttributeTypeSystemFlags.ATTR_NOT_REPLICATED, + ) + + return attribute_type_dto diff --git a/app/ldap_protocol/ldap_schema/attribute_type_use_case.py b/app/ldap_protocol/ldap_schema/attribute_type_use_case.py index ebaf1f986..95f5425fc 100644 --- a/app/ldap_protocol/ldap_schema/attribute_type_use_case.py +++ b/app/ldap_protocol/ldap_schema/attribute_type_use_case.py @@ -9,6 +9,9 @@ from abstract_service import AbstractService from enums import AuthorizationRules from ldap_protocol.ldap_schema.attribute_type_dao import AttributeTypeDAO +from ldap_protocol.ldap_schema.attribute_type_system_flags_use_case import ( + AttributeTypeSystemFlagsUseCase, +) from ldap_protocol.ldap_schema.dto import AttributeTypeDTO from ldap_protocol.ldap_schema.object_class_dao import ObjectClassDAO from ldap_protocol.utils.pagination import PaginationParams, PaginationResult @@ -20,15 +23,19 @@ class AttributeTypeUseCase(AbstractService): def __init__( self, attribute_type_dao: AttributeTypeDAO, + attribute_type_system_flags_use_case: AttributeTypeSystemFlagsUseCase, object_class_dao: ObjectClassDAO, ) -> None: """Init AttributeTypeUseCase.""" self._attribute_type_dao = attribute_type_dao + self._attribute_type_system_flags_use_case = ( + attribute_type_system_flags_use_case + ) self._object_class_dao = object_class_dao - async def get(self, _id: str) -> AttributeTypeDTO: - """Get Attribute Type by id.""" - dto = await self._attribute_type_dao.get(_id) + async def get(self, name: str) -> AttributeTypeDTO: + """Get Attribute Type by name.""" + dto = await self._attribute_type_dao.get(name) dto.object_class_names = await self._object_class_dao.get_object_class_names_include_attribute_type( # noqa: E501 dto.name, ) @@ -42,13 +49,13 @@ async def create(self, dto: AttributeTypeDTO) -> None: """Create Attribute Type.""" await self._attribute_type_dao.create(dto) - async def update(self, _id: str, dto: AttributeTypeDTO) -> None: + async def update(self, name: str, dto: AttributeTypeDTO) -> None: """Update Attribute Type.""" - await self._attribute_type_dao.update(_id, dto) + await self._attribute_type_dao.update(name, dto) - async def delete(self, _id: str) -> None: + async def delete(self, name: str) -> None: """Delete Attribute Type.""" - await self._attribute_type_dao.delete(_id) + await self._attribute_type_dao.delete(name) async def get_paginator( self, @@ -68,10 +75,29 @@ async def delete_all_by_names(self, names: list[str]) -> None: """Delete not system Attribute Types by names.""" return await self._attribute_type_dao.delete_all_by_names(names) + async def is_attr_replicated(self, name: str) -> bool: + """Check if attribute is replicated based on systemFlags.""" + dto = await self.get(name) + return self._attribute_type_system_flags_use_case.is_attr_replicated(dto) # noqa: E501 # fmt: skip + + async def set_attr_replication_flag( + self, + name: str, + need_to_replicate: bool, + ) -> None: + """Set replication flag in systemFlags.""" + dto = await self.get(name) + dto = self._attribute_type_system_flags_use_case.set_attr_replication_flag( # noqa: E501 + dto, + need_to_replicate, + ) + await self._attribute_type_dao.update_sys_flags(dto.name, dto) + PERMISSIONS: ClassVar[dict[str, AuthorizationRules]] = { get.__name__: AuthorizationRules.ATTRIBUTE_TYPE_GET, create.__name__: AuthorizationRules.ATTRIBUTE_TYPE_CREATE, get_paginator.__name__: AuthorizationRules.ATTRIBUTE_TYPE_GET_PAGINATOR, # noqa: E501 update.__name__: AuthorizationRules.ATTRIBUTE_TYPE_UPDATE, delete_all_by_names.__name__: AuthorizationRules.ATTRIBUTE_TYPE_DELETE_ALL_BY_NAMES, # noqa: E501 + set_attr_replication_flag.__name__: AuthorizationRules.ATTRIBUTE_TYPE_SET_ATTR_REPLICATION_FLAG, # noqa: E501 } diff --git a/app/ldap_protocol/ldap_schema/dto.py b/app/ldap_protocol/ldap_schema/dto.py index 118a6e1e8..7699b6966 100644 --- a/app/ldap_protocol/ldap_schema/dto.py +++ b/app/ldap_protocol/ldap_schema/dto.py @@ -22,6 +22,7 @@ class AttributeTypeDTO(Generic[_IdT]): single_value: bool no_user_modification: bool is_system: bool + system_flags: int is_included_anr: bool id: _IdT = None # type: ignore object_class_names: set[str] = field(default_factory=set) diff --git a/app/ldap_protocol/ldap_schema/entity_type_dao.py b/app/ldap_protocol/ldap_schema/entity_type_dao.py index abfdc49d1..1a708d711 100644 --- a/app/ldap_protocol/ldap_schema/entity_type_dao.py +++ b/app/ldap_protocol/ldap_schema/entity_type_dao.py @@ -85,9 +85,9 @@ async def create(self, dto: EntityTypeDTO[None]) -> None: f"Entity Type with name '{dto.name}' already exists.", ) - async def update(self, _id: str, dto: EntityTypeDTO[int]) -> None: + async def update(self, name: str, dto: EntityTypeDTO[int]) -> None: """Update an Entity Type.""" - entity_type = await self._get_one_raw_by_name(_id) + entity_type = await self._get_one_raw_by_name(name) try: await self.__object_class_dao.is_all_object_classes_exists( @@ -153,9 +153,9 @@ async def update(self, _id: str, dto: EntityTypeDTO[int]) -> None: f"names {dto.object_class_names} already exists.", ) - async def delete(self, _id: str) -> None: + async def delete(self, name: str) -> None: """Delete an Entity Type.""" - entity_type = await self._get_one_raw_by_name(_id) + entity_type = await self._get_one_raw_by_name(name) await self.__session.delete(entity_type) await self.__session.flush() @@ -182,10 +182,7 @@ async def get_paginator( session=self.__session, ) - async def _get_one_raw_by_name( - self, - name: str, - ) -> EntityType: + async def _get_one_raw_by_name(self, name: str) -> EntityType: """Get single Entity Type by name. :param str name: Entity Type name. @@ -203,14 +200,14 @@ async def _get_one_raw_by_name( ) return entity_type - async def get(self, _id: str) -> EntityTypeDTO: + async def get(self, name: str) -> EntityTypeDTO: """Get single Entity Type by name. :param str name: Entity Type name. :raise EntityTypeNotFoundError: If Entity Type not found. :return EntityType: Instance of Entity Type. """ - return _convert(await self._get_one_raw_by_name(_id)) + return _convert(await self._get_one_raw_by_name(name)) async def get_entity_type_by_object_class_names( self, diff --git a/app/ldap_protocol/ldap_schema/entity_type_use_case.py b/app/ldap_protocol/ldap_schema/entity_type_use_case.py index 5958e6a99..e7589c3f4 100644 --- a/app/ldap_protocol/ldap_schema/entity_type_use_case.py +++ b/app/ldap_protocol/ldap_schema/entity_type_use_case.py @@ -42,10 +42,10 @@ async def create(self, dto: EntityTypeDTO) -> None: ) await self._entity_type_dao.create(dto) - async def update(self, _id: str, dto: EntityTypeDTO) -> None: + async def update(self, name: str, dto: EntityTypeDTO) -> None: """Update Entity Type.""" try: - entity_type = await self.get(_id) + entity_type = await self.get(name) except EntityTypeNotFoundError: raise EntityTypeCantModifyError @@ -53,13 +53,13 @@ async def update(self, _id: str, dto: EntityTypeDTO) -> None: raise EntityTypeCantModifyError( f"Entity Type '{dto.name}' is system and cannot be modified.", ) - if _id != dto.name: - await self._validate_name(name=_id) + if name != dto.name: + await self._validate_name(name=dto.name) await self._entity_type_dao.update(entity_type.name, dto) - async def get(self, _id: str) -> EntityTypeDTO: + async def get(self, name: str) -> EntityTypeDTO: """Get Entity Type by name.""" - return await self._entity_type_dao.get(_id) + return await self._entity_type_dao.get(name) async def _validate_name( self, diff --git a/app/ldap_protocol/ldap_schema/object_class_dao.py b/app/ldap_protocol/ldap_schema/object_class_dao.py index 9bc29644e..83bcd7eef 100644 --- a/app/ldap_protocol/ldap_schema/object_class_dao.py +++ b/app/ldap_protocol/ldap_schema/object_class_dao.py @@ -77,9 +77,9 @@ async def get_object_class_names_include_attribute_type( ) # fmt: skip return set(row[0] for row in result.fetchall()) - async def delete(self, _id: str) -> None: + async def delete(self, name: str) -> None: """Delete Object Class.""" - object_class = await self._get_one_raw_by_name(_id) + object_class = await self._get_one_raw_by_name(name) await self.__session.delete(object_class) await self.__session.flush() @@ -245,14 +245,14 @@ async def _get_one_raw_by_name(self, name: str) -> ObjectClass: ) return object_class - async def get(self, _id: str) -> ObjectClassDTO: - """Get single Object Class by id. + async def get(self, name: str) -> ObjectClassDTO: + """Get single Object Class by name. - :param str _id: Object Class name. + :param str name: Object Class name. :raise ObjectClassNotFoundError: If Object Class not found. :return ObjectClass: Instance of Object Class. """ - return _converter(await self._get_one_raw_by_name(_id)) + return _converter(await self._get_one_raw_by_name(name)) async def get_all_by_names( self, @@ -273,16 +273,9 @@ async def get_all_by_names( ) # fmt: skip return list(map(_converter, query.all())) - async def update(self, _id: str, dto: ObjectClassDTO[None, str]) -> None: - """Modify Object Class. - - :param ObjectClassDTO object_class: Object Class. - :param ObjectClassDTO dto: New statement ObjectClass - :raise ObjectClassCantModifyError: If Object Class is system,\ - it cannot be changed. - :return None. - """ - obj = await self._get_one_raw_by_name(_id) + async def update(self, name: str, dto: ObjectClassDTO[None, str]) -> None: + """Update Object Class.""" + obj = await self._get_one_raw_by_name(name) if obj.is_system: raise ObjectClassCantModifyError( "System Object Class cannot be modified.", diff --git a/app/ldap_protocol/ldap_schema/object_class_use_case.py b/app/ldap_protocol/ldap_schema/object_class_use_case.py index c35a845ac..11c171a58 100644 --- a/app/ldap_protocol/ldap_schema/object_class_use_case.py +++ b/app/ldap_protocol/ldap_schema/object_class_use_case.py @@ -30,9 +30,9 @@ async def get_all(self) -> list[ObjectClassDTO[int, AttributeTypeDTO]]: """Get all Object Classes.""" return await self._object_class_dao.get_all() - async def delete(self, _id: str) -> None: + async def delete(self, name: str) -> None: """Delete Object Class.""" - await self._object_class_dao.delete(_id) + await self._object_class_dao.delete(name) async def get_paginator( self, @@ -45,9 +45,9 @@ async def create(self, dto: ObjectClassDTO[None, str]) -> None: """Create a new Object Class.""" await self._object_class_dao.create(dto) - async def get(self, _id: str) -> ObjectClassDTO: - """Get Object Class by id.""" - dto = await self._object_class_dao.get(_id) + async def get(self, name: str) -> ObjectClassDTO: + """Get Object Class by name.""" + dto = await self._object_class_dao.get(name) dto.entity_type_names = ( await self._entity_type_dao.get_entity_type_names_include_oc_name( dto.name, @@ -62,9 +62,9 @@ async def get_all_by_names( """Get list of Object Classes by names.""" return await self._object_class_dao.get_all_by_names(names) - async def update(self, _id: str, dto: ObjectClassDTO[None, str]) -> None: + async def update(self, name: str, dto: ObjectClassDTO[None, str]) -> None: """Modify Object Class.""" - await self._object_class_dao.update(_id, dto) + await self._object_class_dao.update(name, dto) async def delete_all_by_names(self, names: list[str]) -> None: """Delete not system Object Classes by Names.""" diff --git a/app/ldap_protocol/utils/raw_definition_parser.py b/app/ldap_protocol/utils/raw_definition_parser.py index 0d3ddfa27..4fa7361e0 100644 --- a/app/ldap_protocol/utils/raw_definition_parser.py +++ b/app/ldap_protocol/utils/raw_definition_parser.py @@ -59,6 +59,7 @@ def create_attribute_type_by_raw( single_value=attribute_type_info.single_value, no_user_modification=attribute_type_info.no_user_modification, is_system=True, + system_flags=0, is_included_anr=False, ) diff --git a/app/repo/pg/tables.py b/app/repo/pg/tables.py index 5391c95d5..a13db43ae 100644 --- a/app/repo/pg/tables.py +++ b/app/repo/pg/tables.py @@ -343,6 +343,7 @@ def _compile_create_uc( Column("single_value", Boolean, nullable=False), Column("no_user_modification", Boolean, nullable=False), Column("is_system", Boolean, nullable=False), + Column("system_flags", Integer, nullable=False, server_default=text("0")), Column("is_included_anr", Boolean, nullable=False), Index("idx_attribute_types_name_gin_trgm", "name", postgresql_using="gin"), ) diff --git a/interface b/interface index e1ca5656a..3c92cf4f0 160000 --- a/interface +++ b/interface @@ -1 +1 @@ -Subproject commit e1ca5656aeabc20a1862aeaf11ded72feaa97403 +Subproject commit 3c92cf4f0fb155978a68e4bcd66241a0b799d1e0 diff --git a/tests/conftest.py b/tests/conftest.py index eddfb7215..c95ed1312 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -99,6 +99,9 @@ LDAPUnbindRequestContext, ) from ldap_protocol.ldap_schema.attribute_type_dao import AttributeTypeDAO +from ldap_protocol.ldap_schema.attribute_type_system_flags_use_case import ( + AttributeTypeSystemFlagsUseCase, +) from ldap_protocol.ldap_schema.attribute_type_use_case import ( AttributeTypeUseCase, ) @@ -296,23 +299,12 @@ async def resolve() -> str: yield await dns_state_gateway.get_dns_manager_settings(resolver) weakref.finalize(resolver, resolver.close) - @provide(scope=Scope.REQUEST, provides=AttributeTypeDAO, cache=False) - def get_attribute_type_dao( - self, - session: AsyncSession, - ) -> AttributeTypeDAO: - """Get Attribute Type DAO.""" - return AttributeTypeDAO(session) - - @provide(scope=Scope.REQUEST, provides=ObjectClassDAO, cache=False) - def get_object_class_dao(self, session: AsyncSession) -> ObjectClassDAO: - """Get Object Class DAO.""" - return ObjectClassDAO(session=session) - - get_entity_type_dao = provide( - EntityTypeDAO, + attribute_type_dao = provide(AttributeTypeDAO, scope=Scope.REQUEST) + object_class_dao = provide(ObjectClassDAO, scope=Scope.REQUEST) + entity_type_dao = provide(EntityTypeDAO, scope=Scope.REQUEST) + attribute_type_system_flags_use_case = provide( + AttributeTypeSystemFlagsUseCase, scope=Scope.REQUEST, - cache=False, ) attribute_type_use_case = provide( AttributeTypeUseCase, diff --git a/tests/test_ldap/test_ldap_schema/__init__.py b/tests/test_ldap/test_ldap_schema/__init__.py new file mode 100644 index 000000000..5134a2e61 --- /dev/null +++ b/tests/test_ldap/test_ldap_schema/__init__.py @@ -0,0 +1,5 @@ +"""Test __init__ module. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" diff --git a/tests/test_ldap/test_ldap_schema/conftest.py b/tests/test_ldap/test_ldap_schema/conftest.py new file mode 100644 index 000000000..75b13a356 --- /dev/null +++ b/tests/test_ldap/test_ldap_schema/conftest.py @@ -0,0 +1,23 @@ +"""Conftest for LDAP schema AttributeType tests. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from typing import AsyncIterator + +import pytest_asyncio +from dishka import AsyncContainer, Scope + +from ldap_protocol.ldap_schema.attribute_type_use_case import ( + AttributeTypeUseCase, +) + + +@pytest_asyncio.fixture(scope="function") +async def attribute_type_use_case( + container: AsyncContainer, +) -> AsyncIterator[AttributeTypeUseCase]: + """Get di attribute_type_use_case.""" + async with container(scope=Scope.REQUEST) as container: + yield await container.get(AttributeTypeUseCase) diff --git a/tests/test_ldap/test_ldap_schema/test_attribute_type_use_case.py b/tests/test_ldap/test_ldap_schema/test_attribute_type_use_case.py new file mode 100644 index 000000000..0c359351a --- /dev/null +++ b/tests/test_ldap/test_ldap_schema/test_attribute_type_use_case.py @@ -0,0 +1,36 @@ +"""Test AttributeTypeUseCase. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +import pytest + +from ldap_protocol.ldap_schema.attribute_type_use_case import ( + AttributeTypeUseCase, +) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("session") +@pytest.mark.usefixtures("setup_session") +async def test_attribute_type_system_flags_use_case_is_not_replicated( + attribute_type_use_case: AttributeTypeUseCase, +) -> None: + """Test AttributeType is not replicated.""" + assert not await attribute_type_use_case.is_attr_replicated("netbootSCPBL") + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("session") +@pytest.mark.usefixtures("setup_session") +async def test_attribute_type_system_flags_use_case_is_replicated( + attribute_type_use_case: AttributeTypeUseCase, +) -> None: + """Test AttributeType is replicated.""" + assert await attribute_type_use_case.is_attr_replicated("objectClass") + await attribute_type_use_case.set_attr_replication_flag( + "objectClass", + False, + ) + assert not await attribute_type_use_case.is_attr_replicated("objectClass") From d5891e0beea53bb879f13a3df669d542fcbbd3e0 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Fri, 13 Feb 2026 12:40:14 +0300 Subject: [PATCH 20/45] fix: update traefik configuration (#934) --- .package/docker-compose.yml | 9 ++++----- .package/traefik.yml | 6 ++++++ docker-compose.dev.yml | 9 ++++----- docker-compose.yml | 10 ++++------ traefik.yml | 6 ++++++ 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index 104a416bd..dc84570e6 100644 --- a/.package/docker-compose.yml +++ b/.package/docker-compose.yml @@ -119,7 +119,7 @@ services: - traefik.tcp.routers.ldap.entrypoints=ldap - traefik.tcp.routers.ldap.service=ldap - traefik.tcp.services.ldap.loadbalancer.server.port=389 - - traefik.tcp.services.ldap.loadbalancer.proxyprotocol.version=2 + - traefik.tcp.services.ldap.loadbalancer.serversTransport=ldap_transport@file - traefik.tcp.routers.ldaps.rule=HostSNI(`*`) - traefik.tcp.routers.ldaps.entrypoints=ldaps @@ -127,7 +127,7 @@ services: - traefik.tcp.routers.ldaps.tls=true - traefik.tcp.routers.ldaps.tls.certResolver=md-resolver - traefik.tcp.services.ldaps.loadbalancer.server.port=636 - - traefik.tcp.services.ldaps.loadbalancer.proxyprotocol.version=2 + - traefik.tcp.services.ldaps.loadbalancer.serversTransport=ldap_transport@file cldap_server: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} @@ -193,7 +193,7 @@ services: - traefik.tcp.routers.global_ldap.entrypoints=global_ldap - traefik.tcp.routers.global_ldap.service=global_ldap - traefik.tcp.services.global_ldap.loadbalancer.server.port=3268 - - traefik.tcp.services.global_ldap.loadbalancer.proxyprotocol.version=2 + - traefik.tcp.services.global_ldap.loadbalancer.serversTransport=ldap_transport@file - traefik.tcp.routers.global_ldap_tls.rule=HostSNI(`*`) - traefik.tcp.routers.global_ldap_tls.entrypoints=global_ldap_tls @@ -201,7 +201,7 @@ services: - traefik.tcp.routers.global_ldap_tls.tls=true - traefik.tcp.routers.global_ldap_tls.tls.certresolver=md-resolver - traefik.tcp.services.global_ldap_tls.loadbalancer.server.port=3269 - - traefik.tcp.services.global_ldap_tls.loadbalancer.proxyprotocol.version=2 + - traefik.tcp.services.global_ldap_tls.loadbalancer.serversTransport=ldap_transport@file api_server: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} @@ -230,7 +230,6 @@ services: - "traefik.http.routers.api.service=api" - "traefik.http.routers.api.middlewares=api_strip" - "traefik.http.middlewares.api_strip.stripprefix.prefixes=/api" - - "traefik.http.middlewares.api_strip.stripprefix.forceslash=false" command: python multidirectory.py --http depends_on: diff --git a/.package/traefik.yml b/.package/traefik.yml index bb8d711de..7d89de9fb 100644 --- a/.package/traefik.yml +++ b/.package/traefik.yml @@ -7,6 +7,12 @@ api: ping: entryPoint: "ping" +tcp: + serversTransports: + ldap_transport: + proxyProtocol: + version: 2 + entryPoints: ping: address: ":8800" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b07d3a899..4117f9441 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -166,7 +166,7 @@ services: - traefik.tcp.routers.global_ldap.entrypoints=global_ldap - traefik.tcp.routers.global_ldap.service=global_ldap - traefik.tcp.services.global_ldap.loadbalancer.server.port=3268 - - traefik.tcp.services.global_ldap.loadbalancer.proxyprotocol.version=2 + - traefik.tcp.services.global_ldap.loadbalancer.serversTransport=ldap_transport@file - traefik.tcp.routers.global_ldap_tls.rule=HostSNI(`*`) - traefik.tcp.routers.global_ldap_tls.entrypoints=global_ldap_tls @@ -174,7 +174,7 @@ services: - traefik.tcp.routers.global_ldap_tls.tls=true - traefik.tcp.routers.global_ldap_tls.tls.certresolver=md-resolver - traefik.tcp.services.global_ldap_tls.loadbalancer.server.port=3269 - - traefik.tcp.services.global_ldap_tls.loadbalancer.proxyprotocol.version=2 + - traefik.tcp.services.global_ldap_tls.loadbalancer.serversTransport=ldap_transport@file cert_local_check: image: multidirectory @@ -237,7 +237,7 @@ services: - traefik.tcp.routers.ldap.entrypoints=ldap - traefik.tcp.routers.ldap.service=ldap - traefik.tcp.services.ldap.loadbalancer.server.port=389 - - traefik.tcp.services.ldap.loadbalancer.proxyprotocol.version=2 + - traefik.tcp.services.ldap.loadbalancer.serversTransport=ldap_transport@file - traefik.tcp.routers.ldaps.rule=HostSNI(`*`) - traefik.tcp.routers.ldaps.entrypoints=ldaps @@ -245,7 +245,7 @@ services: - traefik.tcp.routers.ldaps.tls=true - traefik.tcp.routers.ldaps.tls.certresolver=md-resolver - traefik.tcp.services.ldaps.loadbalancer.server.port=636 - - traefik.tcp.services.ldaps.loadbalancer.proxyprotocol.version=2 + - traefik.tcp.services.ldaps.loadbalancer.serversTransport=ldap_transport@file healthcheck: test: ["CMD-SHELL", "nc -zv 127.0.0.1 389 636"] interval: 30s @@ -279,7 +279,6 @@ services: - "traefik.http.routers.api.service=api" - "traefik.http.routers.api.middlewares=api_strip" - "traefik.http.middlewares.api_strip.stripprefix.prefixes=/api" - - "traefik.http.middlewares.api_strip.stripprefix.forceslash=false" command: python multidirectory.py --http depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index f44f52f81..0516e4eb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,14 +63,14 @@ services: - traefik.tcp.routers.ldap.entrypoints=ldap - traefik.tcp.routers.ldap.service=ldap - traefik.tcp.services.ldap.loadbalancer.server.port=389 - - traefik.tcp.services.ldap.loadbalancer.proxyprotocol.version=2 + - traefik.tcp.services.ldap.loadbalancer.serversTransport=ldap_transport@file - traefik.tcp.routers.ldaps.rule=HostSNI(`*`) - traefik.tcp.routers.ldaps.entrypoints=ldaps - traefik.tcp.routers.ldaps.service=ldaps - traefik.tcp.routers.ldaps.tls=true - traefik.tcp.services.ldaps.loadbalancer.server.port=636 - - traefik.tcp.services.ldaps.loadbalancer.proxyprotocol.version=2 + - traefik.tcp.services.ldaps.loadbalancer.serversTransport=ldap_transport@file healthcheck: test: ["CMD-SHELL", "nc -zv 127.0.0.1 389 636"] interval: 30s @@ -160,15 +160,14 @@ services: - traefik.tcp.routers.global_ldap.entrypoints=global_ldap - traefik.tcp.routers.global_ldap.service=global_ldap - traefik.tcp.services.global_ldap.loadbalancer.server.port=3268 - - traefik.tcp.services.global_ldap.loadbalancer.proxyprotocol.version=2 + - traefik.tcp.services.global_ldap.loadbalancer.serversTransport=ldap_transport@file - traefik.tcp.routers.global_ldap_tls.rule=HostSNI(`*`) - traefik.tcp.routers.global_ldap_tls.entrypoints=global_ldap_tls - traefik.tcp.routers.global_ldap_tls.service=global_ldap_tls - traefik.tcp.routers.global_ldap_tls.tls=true - traefik.tcp.services.global_ldap_tls.loadbalancer.server.port=3269 - - traefik.tcp.services.global_ldap_tls.loadbalancer.proxyprotocol.version=2 - + - traefik.tcp.services.global_ldap_tls.loadbalancer.serversTransport=ldap_transport@file api: image: multidirectory container_name: multidirectory_api @@ -191,7 +190,6 @@ services: - "traefik.http.routers.api.service=api" - "traefik.http.routers.api.middlewares=api_strip" - "traefik.http.middlewares.api_strip.stripprefix.prefixes=/api" - - "traefik.http.middlewares.api_strip.stripprefix.forceslash=false" depends_on: migrations: condition: service_completed_successfully diff --git a/traefik.yml b/traefik.yml index f95bf72f3..d2cf4340f 100644 --- a/traefik.yml +++ b/traefik.yml @@ -7,6 +7,12 @@ api: ping: entryPoint: "ping" +tcp: + serversTransports: + ldap_transport: + proxyProtocol: + version: 2 + entryPoints: ping: address: ":8800" From 27db89944d54e92087bfa9d09b1ffdc8596a8af4 Mon Sep 17 00:00:00 2001 From: iyashnov <57270538+iyashnov@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:30:10 +0300 Subject: [PATCH 21/45] Add: get primary group name handle (#937) --- app/api/main/router.py | 23 ++++++++++++- app/api/main/schema.py | 6 ++++ app/ldap_protocol/utils/queries.py | 34 ++++++++++++++++++- tests/constants.py | 1 + .../test_main/test_router/test_search.py | 32 +++++++++++++++++ 5 files changed, 94 insertions(+), 2 deletions(-) diff --git a/app/api/main/router.py b/app/api/main/router.py index 174a65afa..44ce09de2 100644 --- a/app/api/main/router.py +++ b/app/api/main/router.py @@ -27,9 +27,13 @@ ModifyRequest, ) from ldap_protocol.ldap_responses import LDAPResult -from ldap_protocol.utils.queries import set_or_update_primary_group +from ldap_protocol.utils.queries import ( + get_group_path_dn_by_primary_group_id, + set_or_update_primary_group, +) from .schema import ( + PrimaryGroupPathDNResponse, PrimaryGroupRequest, SearchRequest, SearchResponse, @@ -185,3 +189,20 @@ async def set_primary_group( ) except (ValueError, IntegrityError): raise HTTPException(status_code=400, detail="Invalid request") + + +@entry_router.get("/group/primary/{primary_group_id}") +async def get_group_path_dn_by_primary_grp_id( + primary_group_id: int, + session: FromDishka[AsyncSession], +) -> PrimaryGroupPathDNResponse: + """Get group path DN by primary group ID.""" + try: + path_dn = await get_group_path_dn_by_primary_group_id( + primary_group_id, + session, + ) + except ValueError: + raise HTTPException(status_code=404, detail="Invalid primary group ID") + + return PrimaryGroupPathDNResponse(path_dn=path_dn) diff --git a/app/api/main/schema.py b/app/api/main/schema.py index 537b0af7c..a1c1c8c75 100644 --- a/app/api/main/schema.py +++ b/app/api/main/schema.py @@ -153,3 +153,9 @@ class PrimaryGroupRequest(BaseModel): directory_dn: GRANT_DN_STRING group_dn: GRANT_DN_STRING + + +class PrimaryGroupPathDNResponse(BaseModel): + """Response schema for getting group path DN by primary group ID.""" + + path_dn: str diff --git a/app/ldap_protocol/utils/queries.py b/app/ldap_protocol/utils/queries.py index 39d93b016..2e078b840 100644 --- a/app/ldap_protocol/utils/queries.py +++ b/app/ldap_protocol/utils/queries.py @@ -12,7 +12,12 @@ from sqlalchemy import Column, exists, func, insert, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import InstrumentedAttribute, joinedload, selectinload +from sqlalchemy.orm import ( + InstrumentedAttribute, + contains_eager, + joinedload, + selectinload, +) from sqlalchemy.sql.expression import ColumnElement from entities import Attribute, Directory, Group, User @@ -539,3 +544,30 @@ async def set_or_update_primary_group( ) await session.commit() + + +async def get_group_path_dn_by_primary_group_id( + primary_group_id: int, + session: AsyncSession, +) -> str: + """Get group path DN by primary group ID. + + :param int primary_group_id: primary group ID + :param AsyncSession session: db session + :return str: group path DN + :raises ValueError: if no group found with the given primaryGroupID + """ + query = ( + select(Directory) + .join(qa(Directory.group)) + .options(contains_eager(qa(Directory.group))) + .filter(qa(Directory.object_sid).endswith(f"-{primary_group_id}")) + ) + + directory = await session.scalar(query) + if directory is None: + raise ValueError( + f"No group found with primaryGroupID '{primary_group_id}'.", + ) + + return directory.path_dn diff --git a/tests/constants.py b/tests/constants.py index 2163898ed..ab5ffb954 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -35,6 +35,7 @@ str(SamAccountTypeCodes.SAM_GROUP_OBJECT.value), ], }, + "objectSid": 512, }, { "name": "developers", 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 77baea3f1..14768e5d1 100644 --- a/tests/test_api/test_main/test_router/test_search.py +++ b/tests/test_api/test_main/test_router/test_search.py @@ -602,3 +602,35 @@ async def test_api_empty_search( assert response["resultCode"] == LDAPCodes.SUCCESS assert not response["search_result"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("session") +async def test_api_get_group_name_by_primary_group_id( + http_client: AsyncClient, +) -> None: + """Test api get group path DN by primary group id.""" + primary_group_id = 512 + path_dn = "cn=domain admins,cn=Groups,dc=md,dc=test" + response = await http_client.get( + f"entry/group/primary/{primary_group_id}", + ) + + assert response.status_code == 200 + response = response.json() + + assert response["path_dn"] == path_dn + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("session") +async def test_api_get_group_path_dn_by_primary_group_id_not_found( + http_client: AsyncClient, +) -> None: + """Test api get group path DN by primary group id not found.""" + primary_group_id = 513 + response = await http_client.get( + f"entry/group/primary/{primary_group_id}", + ) + + assert response.status_code == 404 From b12f7fa1789ba172c0ee38b9de20a83d8f56f45d Mon Sep 17 00:00:00 2001 From: iyashnov <57270538+iyashnov@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:31:41 +0300 Subject: [PATCH 22/45] add: reject rdn modify (#933) --- app/entities.py | 1 + app/ldap_protocol/ldap_requests/modify.py | 14 +- .../test_main/test_router/test_add.py | 29 ---- .../test_main/test_router/test_modify.py | 65 --------- .../test_main/test_router/test_rename.py | 138 ------------------ tests/test_ldap/test_util/test_modify.py | 98 +++++++++++++ 6 files changed, 105 insertions(+), 240 deletions(-) delete mode 100644 tests/test_api/test_main/test_router/test_rename.py diff --git a/app/entities.py b/app/entities.py index 8309f510a..9b4d70e16 100644 --- a/app/entities.py +++ b/app/entities.py @@ -241,6 +241,7 @@ class Directory: "objectguid", "objectsid", "entitytypename", + "name", } def get_dn_prefix(self) -> DistinguishedNamePrefix: diff --git a/app/ldap_protocol/ldap_requests/modify.py b/app/ldap_protocol/ldap_requests/modify.py index 9f5b8789d..66ccafa8d 100644 --- a/app/ldap_protocol/ldap_requests/modify.py +++ b/app/ldap_protocol/ldap_requests/modify.py @@ -208,23 +208,21 @@ async def handle( and await ctx.password_use_cases.is_password_change_restricted( directory.id, ) + ) or ( + not can_modify and not (password_change_requested and self_modify) ): yield ModifyResponse( result_code=LDAPCodes.INSUFFICIENT_ACCESS_RIGHTS, ) return + if directory.rdname in names: + yield ModifyResponse(result_code=LDAPCodes.NOT_ALLOWED_ON_RDN) + return + before_attrs = self.get_directory_attrs(directory) entity_type = directory.entity_type try: - if not can_modify and not ( - password_change_requested and self_modify - ): - yield ModifyResponse( - result_code=LDAPCodes.INSUFFICIENT_ACCESS_RIGHTS, - ) - return - for change in self.changes: if change.l_type in Directory.ro_fields: continue diff --git a/tests/test_api/test_main/test_router/test_add.py b/tests/test_api/test_main/test_router/test_add.py index f20d516d2..c83803322 100644 --- a/tests/test_api/test_main/test_router/test_add.py +++ b/tests/test_api/test_main/test_router/test_add.py @@ -43,35 +43,6 @@ async def test_api_correct_add(http_client: AsyncClient) -> None: assert data.get("errorMessage") == "" -@pytest.mark.asyncio -@pytest.mark.usefixtures("session") -async def test_api_add_incorrect_computer_name( - http_client: AsyncClient, -) -> None: - """Test api incorrect (name) add.""" - response = await http_client.post( - "/entry/add", - json={ - "entry": "cn=test,dc=md,dc=test", - "password": None, - "attributes": [ - {"type": "name", "vals": [" test;incorrect"]}, - {"type": "cn", "vals": ["test"]}, - {"type": "objectClass", "vals": ["computer", "top"]}, - { - "type": "memberOf", - "vals": ["cn=domain admins,cn=Groups,dc=md,dc=test"], - }, - ], - }, - ) - - data = response.json() - - assert isinstance(data, dict) - assert data.get("resultCode") == LDAPCodes.UNDEFINED_ATTRIBUTE_TYPE - - @pytest.mark.asyncio @pytest.mark.usefixtures("session") async def test_api_add_incorrect_user_samaccount_with_dot( diff --git a/tests/test_api/test_main/test_router/test_modify.py b/tests/test_api/test_main/test_router/test_modify.py index 2d0dbd5af..2243dcb37 100644 --- a/tests/test_api/test_main/test_router/test_modify.py +++ b/tests/test_api/test_main/test_router/test_modify.py @@ -288,71 +288,6 @@ async def test_api_incorrect_modify_computer_samaccountname_add( assert data.get("resultCode") == LDAPCodes.OPERATIONS_ERROR -@pytest.mark.asyncio -@pytest.mark.usefixtures("setup_session") -@pytest.mark.usefixtures("session") -async def test_api_duplicate_with_spaces_modify( - http_client: AsyncClient, -) -> None: - """Test API for modify duplicated object name.""" - entry_dn = "cn=new_test,dc=md,dc=test" - response = await http_client.post( - "/entry/add", - json={ - "entry": entry_dn, - "password": None, - "attributes": [ - { - "type": "objectClass", - "vals": ["organization", "top"], - }, - ], - }, - ) - data = response.json() - assert data.get("resultCode") == LDAPCodes.SUCCESS - - response = await http_client.patch( - "/entry/update", - json={ - "object": entry_dn, - "changes": [ - { - "operation": Operation.REPLACE, - "modification": { - "type": "cn", - "vals": [" test"], - }, - }, - ], - }, - ) - - data = response.json() - - assert isinstance(data, dict) - assert data.get("resultCode") == LDAPCodes.SUCCESS - - response = await http_client.post( - "entry/search", - json={ - "base_object": entry_dn, - "scope": 0, - "deref_aliases": 0, - "size_limit": 1000, - "time_limit": 10, - "types_only": True, - "filter": "(objectClass=*)", - "attributes": [], - "page_number": 1, - }, - ) - - data = response.json() - assert isinstance(data, dict) - assert data["search_result"][0]["object_name"] == entry_dn - - @pytest.mark.asyncio @pytest.mark.usefixtures("adding_test_user") @pytest.mark.usefixtures("setup_session") diff --git a/tests/test_api/test_main/test_router/test_rename.py b/tests/test_api/test_main/test_router/test_rename.py deleted file mode 100644 index e86804f39..000000000 --- a/tests/test_api/test_main/test_router/test_rename.py +++ /dev/null @@ -1,138 +0,0 @@ -"""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_user(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 - - 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": ["__invalid name for error__"], - }, - }, - { - "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") diff --git a/tests/test_ldap/test_util/test_modify.py b/tests/test_ldap/test_util/test_modify.py index e2a55e24c..b5eadf172 100644 --- a/tests/test_ldap/test_util/test_modify.py +++ b/tests/test_ldap/test_util/test_modify.py @@ -781,6 +781,104 @@ async def try_modify() -> int: assert "posixEmail" not in attributes +@pytest.mark.asyncio +@pytest.mark.usefixtures("setup_session") +async def test_ldap_modify_rdn( + settings: Settings, + creds: TestCreds, +) -> None: + """Test modify RDN.""" + dn = "cn=user0,cn=Users,dc=md,dc=test" + + async def try_modify() -> int: + with tempfile.NamedTemporaryFile("w") as file: + file.write( + (f"dn: {dn}\nchangetype: modify\nreplace: cn\ncn: modme\n-\n"), + ) + file.seek(0) + proc = await asyncio.create_subprocess_exec( + "ldapmodify", + "-vvv", + "-H", + f"ldap://{settings.HOST}:{settings.PORT}", + "-D", + "user_admin", + "-x", + "-w", + creds.pw, + "-f", + file.name, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + await proc.communicate() + return await proc.wait() + + assert await try_modify() == LDAPCodes.NOT_ALLOWED_ON_RDN + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("setup_session") +async def test_ldap_modify_name( + session: AsyncSession, + settings: Settings, + creds: TestCreds, +) -> None: + """Test modify name.""" + dn = "cn=user0,cn=Users,dc=md,dc=test" + + query = ( + select(Directory) + .options( + subqueryload(qa(Directory.attributes)), + joinedload(qa(Directory.user)), + ) + .filter(get_filter_from_path(dn)) + ) + + old_directory = await session.scalar(query) + assert old_directory + + async def try_modify() -> int: + with tempfile.NamedTemporaryFile("w") as file: + file.write( + ( + f"dn: {dn}\n" + "changetype: modify\n" + "replace: name\n" + "name: changename\n" + "-\n" + ), + ) + file.seek(0) + proc = await asyncio.create_subprocess_exec( + "ldapmodify", + "-vvv", + "-H", + f"ldap://{settings.HOST}:{settings.PORT}", + "-D", + "user_admin", + "-x", + "-w", + creds.pw, + "-f", + file.name, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + await proc.communicate() + return await proc.wait() + + assert await try_modify() == LDAPCodes.SUCCESS + + new_directory = await session.scalar(query) + assert new_directory + + assert old_directory.name == new_directory.name + + async def run_single_modify( settings: Settings, operation: Literal["add", "delete", "replace"], From 96e344c3f7e33fa18804bc80c48d48ed9b3579a0 Mon Sep 17 00:00:00 2001 From: Misha-Shvets <76677350+Misha-Shvets@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:54:13 +0300 Subject: [PATCH 23/45] Add randkey for krb api (#924) --- .kerberos/config_server.py | 242 +++++++++++++----- app/api/main/adapters/kerberos.py | 55 ++-- app/api/main/krb5_router.py | 61 ++--- app/api/main/schema.py | 24 ++ app/enums.py | 3 +- app/extra/scripts/update_krb5_config.py | 2 +- app/ldap_protocol/kerberos/base.py | 26 +- app/ldap_protocol/kerberos/client.py | 34 ++- app/ldap_protocol/kerberos/exceptions.py | 6 +- app/ldap_protocol/kerberos/service.py | 64 +++-- app/ldap_protocol/kerberos/stub.py | 15 +- app/ldap_protocol/kerberos/utils.py | 3 +- app/ldap_protocol/ldap_requests/bind.py | 2 +- app/ldap_protocol/ldap_requests/modify.py | 12 +- interface | 2 +- tests/conftest.py | 2 +- tests/test_api/test_main/test_kadmin.py | 42 +-- .../test_main/test_router/test_modify.py | 10 +- 18 files changed, 375 insertions(+), 230 deletions(-) diff --git a/.kerberos/config_server.py b/.kerberos/config_server.py index 2806c9b86..3ce283a0a 100644 --- a/.kerberos/config_server.py +++ b/.kerberos/config_server.py @@ -31,7 +31,7 @@ ) from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse -from pydantic import BaseModel +from pydantic import BaseModel, Field from starlette.background import BackgroundTask KRB5_CONF_PATH = "/etc/krb5.conf" @@ -79,6 +79,30 @@ class PrincipalNotFoundError(Exception): """Not found error.""" +class AddPrincipalRequest(BaseModel): + """Request model for adding principal.""" + + principal_name: str + password: str | None = None + algorithms: list[str] | None = None + + +class KtaddRequest(BaseModel): + """Request model for ktadd.""" + + names: list[str] + is_rand_key: bool = Field(default=False) + + +class ModifyPrincipalRequest(BaseModel): + """Request model for modifying principal.""" + + principal_name: str + new_name: str | None = None + algorithms: list[str] | None = None + password: str | None = None + + class AbstractKRBManager(ABC): """Kadmin manager.""" @@ -95,12 +119,14 @@ async def add_princ( self, name: str, password: str | None, + algorithms: list[str] | None = None, **dbargs, ) -> None: """Create principal. :param str name: principal - :param str | None password: if empty - uses randkey. + :param str | None password: if None - uses randkey. + :param list[str] | None algorithms: encryption algorithms """ @abstractmethod @@ -135,19 +161,17 @@ async def del_princ(self, name: str) -> None: """ @abstractmethod - async def rename_princ(self, name: str, new_name: str) -> None: - """Rename principal. - - :param str name: original name - :param str new_name: new name - """ - - @abstractmethod - async def ktadd(self, names: list[str], fn: str) -> None: + async def ktadd( + self, + names: list[str], + fn: str, + is_rand_key: bool = False, + ) -> None: """Create or write to keytab. - :param str name: principal + :param list[str] names: principals :param str fn: filename + :param bool is_rand_key: generate random key """ @abstractmethod @@ -164,6 +188,23 @@ async def force_pw_principal(self, name: str, **dbargs) -> None: :param str name: principal """ + @abstractmethod + async def modify_principal( + self, + principal_name: str, + new_name: str | None = None, + algorithms: list[str] | None = None, + password: str | None = None, + **dbargs, + ) -> None: + """Modify principal (rename, change algorithms, password). + + :param str principal_name: current principal name + :param str | None new_name: new name if rename needed + :param list[str] | None algorithms: new encryption algorithms + :param str | None password: new password + """ + class KAdminLocalManager(AbstractKRBManager): """Kadmin manager.""" @@ -206,19 +247,30 @@ async def add_princ( self, name: str, password: str | None, + algorithms: list[str] | None = None, **dbargs, ) -> None: """Create principal. :param str name: principal - :param str | None password: if empty - uses randkey. + :param str | None password: if None - uses randkey. + :param list[str] | None algorithms: encryption algorithms """ - await self.loop.run_in_executor( - self.pool, - self.client.add_principal, - name, - password, - ) + if algorithms: + await self.loop.run_in_executor( + self.pool, + self.client.add_principal, + name, + password, + algorithms, + ) + else: + await self.loop.run_in_executor( + self.pool, + self.client.add_principal, + name, + password, + ) if password: # NOTE: add preauth, attributes == krbticketflags @@ -287,32 +339,58 @@ async def del_princ(self, name: str) -> None: except kadmv.UnknownPrincipalError: raise PrincipalNotFoundError - async def rename_princ(self, name: str, new_name: str) -> None: - """Rename principal. - - :param str name: original name - :param str new_name: new name - """ - await self.loop.run_in_executor( - self.pool, - self.client.rename_principal, - name, - new_name, - ) - - async def ktadd(self, names: list[str], fn: str) -> None: + async def ktadd( + self, + names: list[str], + fn: str, + is_rand_key: bool = False, + ) -> None: """Create or write to keytab. - :param str name: principal + :param list[str] names: principals :param str fn: filename - :raises self.PrincipalNotFoundError: on not found princ + :param bool is_rand_key: generate random key + :raises PrincipalNotFoundError: on not found princ """ principals = [await self._get_raw_principal(name) for name in names] if not all(principals): raise PrincipalNotFoundError("Principal not found") - for princ in principals: - await self.loop.run_in_executor(self.pool, princ.ktadd, fn) + if is_rand_key: + for princ in principals: + await self.loop.run_in_executor( + self.pool, + princ.ktadd, + fn, + True, + ) + + else: + for princ in principals: + await self.loop.run_in_executor(self.pool, princ.ktadd, fn) + + async def _ktadd_with_randkey_via_subprocess( + self, + principal_name: str, + keytab_path: str, + ) -> None: + """Execute ktadd with randkey via subprocess.""" + cmd = [ + "kadmin.local", + "-q", + f"ktadd -k {keytab_path} -randkey {principal_name}", + ] + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + stdout, stderr = await proc.communicate() + + if await proc.wait() != 0: + raise RuntimeError(f"ktadd failed: {stderr.decode()}") async def lock_princ(self, name: str, **dbargs) -> None: """Lock princ. @@ -332,6 +410,36 @@ async def force_pw_principal(self, name: str, **dbargs) -> None: princ.pwexpire = "Now" await self.loop.run_in_executor(self.pool, princ.commit) + async def modify_principal( + self, + principal_name: str, + new_name: str | None = None, + algorithms: list[str] | None = None, + password: str | None = None, + **dbargs, + ) -> None: + """Modify principal (rename, change algorithms, password). + + :param str principal_name: current principal name + :param str | None new_name: new name if rename needed + :param list[str] | None algorithms: new encryption algorithms + :param str | None password: new password + """ + args = [] + if new_name: + args.append(new_name) + if password: + args.append(password) + if algorithms: + args.append(algorithms) + + await self.loop.run_in_executor( + self.pool, + self.client.modify_principal, + principal_name, + *args, + ) + @asynccontextmanager async def kadmin_lifespan(app: FastAPI) -> AsyncIterator[None]: @@ -494,16 +602,18 @@ async def reset_setup() -> None: @principal_router.post("", response_class=Response, status_code=201) async def add_princ( kadmin: Annotated[AbstractKRBManager, Depends(get_kadmin)], - name: Annotated[str, Body()], - password: Annotated[str | None, Body(embed=True)] = None, + request: AddPrincipalRequest, ) -> None: """Add principal. :param Annotated[AbstractKRBManager, Depends kadmin: kadmin abstract - :param Annotated[str, Body name: principal name - :param Annotated[str, Body password: principal password + :param AddPrincipalRequest request: request data """ - await kadmin.add_princ(name, password) + await kadmin.add_princ( + request.principal_name, + request.password, + algorithms=request.algorithms, + ) @principal_router.get("") @@ -511,11 +621,10 @@ async def get_princ( kadmin: Annotated[AbstractKRBManager, Depends(get_kadmin)], name: str, ) -> Principal: - """Add principal. + """Get principal. :param Annotated[AbstractKRBManager, Depends kadmin: kadmin abstract - :param Annotated[str, Body name: principal name - :param Annotated[str, Body password: principal password + :param str name: principal name """ return await kadmin.get_princ(name) @@ -525,11 +634,10 @@ async def del_princ( kadmin: Annotated[AbstractKRBManager, Depends(get_kadmin)], name: str, ) -> None: - """Add principal. + """Delete principal. :param Annotated[AbstractKRBManager, Depends kadmin: kadmin abstract - :param Annotated[str, Body name: principal name - :param Annotated[str, Body password: principal password + :param str name: principal name """ await kadmin.del_princ(name) @@ -569,39 +677,49 @@ async def create_or_update_princ_password( @principal_router.put( - "", + "/modify", status_code=status.HTTP_202_ACCEPTED, response_class=Response, ) -async def rename_princ( +async def modify_princ( kadmin: Annotated[AbstractKRBManager, Depends(get_kadmin)], - name: Annotated[str, Body()], - new_name: Annotated[str, Body()], + request: ModifyPrincipalRequest, ) -> None: - """Rename principal. + """Modify principal (rename, algorithms, password). :param Annotated[AbstractKRBManager, Depends kadmin: kadmin abstract - :param Annotated[str, Body name: principal name - :param Annotated[str, Body new_name: principal new name + :param ModifyPrincipalRequest request: request data """ - """""" - await kadmin.rename_princ(name, new_name) + await kadmin.modify_principal( + principal_name=request.principal_name, + new_name=request.new_name, + algorithms=request.algorithms, + password=request.password, + ) @principal_router.post("/ktadd") async def ktadd( kadmin: Annotated[AbstractKRBManager, Depends(get_kadmin)], - names: Annotated[list[str], Body()], + request: KtaddRequest, ) -> FileResponse: """Ktadd principal. :param Annotated[AbstractKRBManager, Depends kadmin: kadmin abstract - :param Annotated[str, Body name: principal name - :param Annotated[str, Body password: principal password + :param KtaddRequest request: request data """ filename = os.path.join(gettempdir(), str(uuid.uuid1())) - await kadmin.ktadd(names, filename) - + if request.is_rand_key: + await kadmin.ktadd( + request.names, + filename, + is_rand_key=request.is_rand_key, + ) + else: + await kadmin.ktadd( + request.names, + filename, + ) return FileResponse( filename, background=BackgroundTask(os.unlink, filename), diff --git a/app/api/main/adapters/kerberos.py b/app/api/main/adapters/kerberos.py index 1bbe252e2..c140fcc55 100644 --- a/app/api/main/adapters/kerberos.py +++ b/app/api/main/adapters/kerberos.py @@ -12,7 +12,12 @@ from starlette.background import BackgroundTask from api.base_adapter import BaseAdapter -from api.main.schema import KerberosSetupRequest +from api.main.schema import ( + KerberosSetupRequest, + KtaddRequest, + ModifyPrincipalRequest, + PrincipalAddRequest, +) from ldap_protocol.dialogue import LDAPSession, UserSchema from ldap_protocol.kerberos import KerberosState from ldap_protocol.kerberos.service import KerberosService @@ -66,46 +71,29 @@ async def setup_kdc( ) return Response(background=task) - async def add_principal( - self, - primary: str, - instance: str, - ) -> None: + async def add_principal(self, request: PrincipalAddRequest) -> None: """Create principal in Kerberos with given name. :raises HTTPException: on Kerberos errors :return: None """ - return await self._service.add_principal(primary, instance) - - async def rename_principal( - self, - principal_name: str, - principal_new_name: str, - ) -> None: - """Rename principal in Kerberos. - - :raises HTTPException: on Kerberos errors - :return: None - """ - return await self._service.rename_principal( - principal_name, - principal_new_name, + return await self._service.add_principal( + request.principal_name, + password=request.password, + algorithms=request.algorithms, ) - async def reset_principal_pw( - self, - principal_name: str, - new_password: str, - ) -> None: - """Reset principal password in Kerberos. + async def modify_principal(self, request: ModifyPrincipalRequest) -> None: + """Modify principal ( password, algorithms). :raises HTTPException: on Kerberos errors :return: None """ - return await self._service.reset_principal_pw( - principal_name, - new_password, + return await self._service.modify_principal( + principal_name=request.principal_name, + new_name=request.new_name, + algorithms=request.algorithms, + password=request.password, ) async def delete_principal( @@ -121,14 +109,17 @@ async def delete_principal( async def ktadd( self, - names: list[str], + data: KtaddRequest, ) -> StreamingResponse: """Generate keytab and return as streaming response. :raises HTTPException: on Kerberos errors :return: StreamingResponse """ - aiter_bytes, task_struct = await self._service.ktadd(names) + aiter_bytes, task_struct = await self._service.ktadd( + data.names, + is_rand_key=data.is_rand_key, + ) task = BackgroundTask( task_struct.func, *task_struct.args, diff --git a/app/api/main/krb5_router.py b/app/api/main/krb5_router.py index 9ed36515c..37ed49721 100644 --- a/app/api/main/krb5_router.py +++ b/app/api/main/krb5_router.py @@ -23,7 +23,12 @@ DomainErrorTranslator, ) from api.main.adapters.kerberos import KerberosFastAPIAdapter -from api.main.schema import KerberosSetupRequest +from api.main.schema import ( + KerberosSetupRequest, + KtaddRequest, + ModifyPrincipalRequest, + PrincipalAddRequest, +) from api.utils import require_master_db from enums import DomainCodes from ldap_protocol.dialogue import LDAPSession @@ -149,15 +154,15 @@ async def setup_kdc( error_map=error_map, ) async def ktadd( - names: Annotated[LIMITED_LIST, Body()], kerberos_adapter: FromDishka[KerberosFastAPIAdapter], + request: KtaddRequest, ) -> StreamingResponse: """Create keytab from kadmin server. :param Annotated[LDAPSession, Depends ldap_session: ldap :return bytes: file """ - return await kerberos_adapter.ktadd(names) + return await kerberos_adapter.ktadd(request) @krb5_router.get( @@ -178,13 +183,12 @@ async def get_krb_status( @krb5_router.post( - "/principal/add", + "/principal", dependencies=[Depends(verify_auth), Depends(require_master_db)], error_map=error_map, ) async def add_principal( - primary: Annotated[LIMITED_STR, Body()], - instance: Annotated[LIMITED_STR, Body()], + request: PrincipalAddRequest, kerberos_adapter: FromDishka[KerberosFastAPIAdapter], ) -> None: """Create principal in kerberos with given name. @@ -194,52 +198,19 @@ async def add_principal( :param Annotated[LDAPSession, Depends ldap_session: ldap :raises HTTPException: on failed kamin request. """ - await kerberos_adapter.add_principal(primary, instance) + await kerberos_adapter.add_principal(request) -@krb5_router.patch( - "/principal/rename", +@krb5_router.put( + "/principal", dependencies=[Depends(verify_auth), Depends(require_master_db)], error_map=error_map, ) -async def rename_principal( - principal_name: Annotated[LIMITED_STR, Body()], - principal_new_name: Annotated[LIMITED_STR, Body()], +async def modify_principal( + request: ModifyPrincipalRequest, kerberos_adapter: FromDishka[KerberosFastAPIAdapter], ) -> None: - """Rename principal in kerberos with given name. - - \f - :param Annotated[str, Body principal_name: upn - :param Annotated[LIMITED_STR, Body principal_new_name: _description_ - :param Annotated[LDAPSession, Depends ldap_session: ldap - :raises HTTPException: on failed kamin request. - """ - await kerberos_adapter.rename_principal( - principal_name, - principal_new_name, - ) - - -@krb5_router.patch( - "/principal/reset", - dependencies=[Depends(verify_auth), Depends(require_master_db)], - error_map=error_map, -) -async def reset_principal_pw( - principal_name: Annotated[LIMITED_STR, Body()], - new_password: Annotated[LIMITED_STR, Body()], - kerberos_adapter: FromDishka[KerberosFastAPIAdapter], -) -> None: - """Reset principal password in kerberos with given name. - - \f - :param Annotated[str, Body principal_name: upn - :param Annotated[LIMITED_STR, Body new_password: _description_ - :param Annotated[LDAPSession, Depends ldap_session: ldap - :raises HTTPException: on failed kamin request. - """ - await kerberos_adapter.reset_principal_pw(principal_name, new_password) + await kerberos_adapter.modify_principal(request) @krb5_router.delete( diff --git a/app/api/main/schema.py b/app/api/main/schema.py index a1c1c8c75..59e103e7f 100644 --- a/app/api/main/schema.py +++ b/app/api/main/schema.py @@ -70,6 +70,30 @@ class KerberosSetupRequest(BaseModel): stash_password: SecretStr +class PrincipalAddRequest(BaseModel): + """Request schema for POST /principal/add.""" + + principal_name: str + algorithms: list[str] | None = None + password: str | None = None + + +class KtaddRequest(BaseModel): + """Request schema for POST /ktadd.""" + + names: list[str] + is_rand_key: bool = False + + +class ModifyPrincipalRequest(BaseModel): + """Request schema for PUT /principal (full modify).""" + + principal_name: str + new_name: str | None = None + algorithms: list[str] | None = None + password: str | None = None + + class DNSServiceSetupRequest(BaseModel): """DNS setup request schema.""" diff --git a/app/enums.py b/app/enums.py index b48967677..9cf7f6c61 100644 --- a/app/enums.py +++ b/app/enums.py @@ -194,8 +194,7 @@ class AuthorizationRules(IntFlag): KRB_KTADD = auto() KRB_GET_STATUS = auto() KRB_ADD_PRINCIPAL = auto() - KRB_RENAME_PRINCIPAL = auto() - KRB_RESET_PRINCIPAL_PW = auto() + KRB_MODIFY_PRINCIPAL = auto() KRB_DELETE_PRINCIPAL = auto() AUDIT_GET_POLICIES = auto() diff --git a/app/extra/scripts/update_krb5_config.py b/app/extra/scripts/update_krb5_config.py index b0ecda0f6..325e2a47c 100644 --- a/app/extra/scripts/update_krb5_config.py +++ b/app/extra/scripts/update_krb5_config.py @@ -53,7 +53,7 @@ async def update_krb5_config( base_dn_list = await get_base_directories(session) if not base_dn_list: - logger.error("No base directories found") + logger.warning("No base directories found") return base_dn = base_dn_list[0].path_dn diff --git a/app/ldap_protocol/kerberos/base.py b/app/ldap_protocol/kerberos/base.py index d70960738..31dcb355e 100644 --- a/app/ldap_protocol/kerberos/base.py +++ b/app/ldap_protocol/kerberos/base.py @@ -153,8 +153,9 @@ async def setup( @abstractmethod async def add_principal( self, - name: str, - password: str | None, + principal_name: str, + password: str | None = None, + algorithms: list[str] | None = None, timeout: int | float = 1, ) -> None: ... @@ -179,7 +180,13 @@ async def create_or_update_principal_pw( ) -> None: ... @abstractmethod - async def rename_princ(self, name: str, new_name: str) -> None: ... + async def modify_princ( + self, + name: str, + new_name: str | None, + algorithms: list[str] | None = None, + password: str | None = None, + ) -> None: ... @backoff.on_exception( backoff.constant, @@ -202,7 +209,11 @@ async def get_status(self, wait_for_positive: bool = False) -> bool: return status @abstractmethod - async def ktadd(self, names: list[str]) -> httpx.Response: ... + async def ktadd( + self, + names: list[str], + is_rand_key: bool, + ) -> httpx.Response: ... @abstractmethod async def lock_principal(self, name: str) -> None: ... @@ -221,14 +232,17 @@ async def ldap_principal_setup(self, name: str, path: str) -> None: if response.status_code == 200: return - response = await self.client.post("/principal", json={"name": name}) + response = await self.client.post( + "/principal", + json={"principal_name": name}, + ) if response.status_code != 201: log.error(f"Error creating ldap principal: {response.text}") return response = await self.client.post( "/principal/ktadd", - json=[name], + json={"names": [name], "is_rand_key": False}, ) if response.status_code != 200: log.error(f"Error getting keytab: {response.text}") diff --git a/app/ldap_protocol/kerberos/client.py b/app/ldap_protocol/kerberos/client.py index 8dfcb8a23..c85c062fc 100644 --- a/app/ldap_protocol/kerberos/client.py +++ b/app/ldap_protocol/kerberos/client.py @@ -20,12 +20,17 @@ async def add_principal( self, name: str, password: str | None, - timeout: int = 1, + algorithms: list[str] | None = None, + timeout: int | float = 1, ) -> None: """Add request.""" response = await self.client.post( "principal", - json={"name": name, "password": password}, + json={ + "principal_name": name, + "password": password, + "algorithms": algorithms, + }, timeout=timeout, ) @@ -89,17 +94,32 @@ async def create_or_update_principal_pw( raise krb_exc.KRBAPIChangePasswordError(response.text) @logger_wraps() - async def rename_princ(self, name: str, new_name: str) -> None: + async def modify_princ( + self, + name: str, + new_name: str | None, + algorithms: list[str] | None, + password: str | None, + ) -> None: """Rename request.""" response = await self.client.put( "principal", - json={"name": name, "new_name": new_name}, + json={ + "name": name, + "new_name": new_name, + "algorithms": algorithms, + "password": password, + }, ) if response.status_code != 202: - raise krb_exc.KRBAPIRenamePrincipalError(response.text) + raise krb_exc.KRBAPIModifyPrincipalError(response.text) @logger_wraps() - async def ktadd(self, names: list[str]) -> httpx.Response: + async def ktadd( + self, + names: list[str], + is_rand_key: bool, + ) -> httpx.Response: """Ktadd build request for stream and return response. :param list[str] names: principals @@ -108,7 +128,7 @@ async def ktadd(self, names: list[str]) -> httpx.Response: request = self.client.build_request( "POST", "/principal/ktadd", - json=names, + json={"names": names, "is_rand_key": is_rand_key}, ) response = await self.client.send(request, stream=True) diff --git a/app/ldap_protocol/kerberos/exceptions.py b/app/ldap_protocol/kerberos/exceptions.py index 735149eff..2008aff1c 100644 --- a/app/ldap_protocol/kerberos/exceptions.py +++ b/app/ldap_protocol/kerberos/exceptions.py @@ -31,7 +31,7 @@ class ErrorCodes(IntEnum): KERBEROS_API_GET_PRINCIPAL_ERROR = 16 KERBEROS_API_DELETE_PRINCIPAL_ERROR = 17 KERBEROS_API_CHANGE_PASSWORD_ERROR = 18 - KERBEROS_API_RENAME_PRINCIPAL_ERROR = 19 + KERBEROS_API_MODIFY_PRINCIPAL_ERROR = 19 KERBEROS_API_LOCK_PRINCIPAL_ERROR = 20 KERBEROS_API_FORCE_PASSWORD_CHANGE_ERROR = 21 KERBEROS_API_STATUS_NOT_FOUND_ERROR = 22 @@ -132,10 +132,10 @@ class KRBAPIChangePasswordError(KRBAPIError): code = ErrorCodes.KERBEROS_API_CHANGE_PASSWORD_ERROR -class KRBAPIRenamePrincipalError(KRBAPIError): +class KRBAPIModifyPrincipalError(KRBAPIError): """Rename principal error.""" - code = ErrorCodes.KERBEROS_API_RENAME_PRINCIPAL_ERROR + code = ErrorCodes.KERBEROS_API_MODIFY_PRINCIPAL_ERROR class KRBAPILockPrincipalError(KRBAPIError): diff --git a/app/ldap_protocol/kerberos/service.py b/app/ldap_protocol/kerberos/service.py index 985d8cdc1..10074a112 100644 --- a/app/ldap_protocol/kerberos/service.py +++ b/app/ldap_protocol/kerberos/service.py @@ -34,8 +34,8 @@ KRBAPIAddPrincipalError, KRBAPIConnectionError, KRBAPIDeletePrincipalError, + KRBAPIModifyPrincipalError, KRBAPIPrincipalNotFoundError, - KRBAPIRenamePrincipalError, KRBAPISetupConfigsError, KRBAPISetupStashError, KRBAPISetupTreeError, @@ -358,7 +358,12 @@ async def _schedule_principal_task( ) return TaskStruct(func=func, args=args) - async def add_principal(self, primary: str, instance: str) -> None: + async def add_principal( + self, + principal_name: str, + password: str | None, + algorithms: list[str] | None, + ) -> None: """Create principal in Kerberos with given name. :param str primary: Principal primary name. @@ -367,52 +372,42 @@ async def add_principal(self, primary: str, instance: str) -> None: :return None: None. """ try: - principal_name = f"{primary}/{instance}" - await self._kadmin.add_principal(principal_name, None) + await self._kadmin.add_principal( + principal_name, + password, + algorithms, + ) except KRBAPIAddPrincipalError as exc: raise KerberosDependencyError( f"Error adding principal: {exc}", ) from exc - async def rename_principal( + async def modify_principal( self, principal_name: str, - principal_new_name: str, + new_name: str | None, + algorithms: list[str] | None, + password: str | None, ) -> None: - """Rename principal in Kerberos with given name. + """Modify principal in Kerberos with given name. :param str principal_name: Current principal name. - :param str principal_new_name: New principal name. + :param str new_name: New principal name. + :param list[str] | None algorithms: Algorithms. + :param str | None password: Password. :raises KerberosDependencyError: On failed kadmin request. :return None: None. """ try: - await self._kadmin.rename_princ(principal_name, principal_new_name) - except KRBAPIRenamePrincipalError as exc: - raise KerberosDependencyError( - f"Error renaming principal: {exc}", - ) from exc - - async def reset_principal_pw( - self, - principal_name: str, - new_password: str, - ) -> None: - """Reset principal password in Kerberos with given name. - - :param str principal_name: Principal name. - :param str new_password: New password. - :raises KerberosDependencyError: On failed kadmin request. - :return None: None. - """ - try: - await self._kadmin.change_principal_password( + await self._kadmin.modify_princ( principal_name, - new_password, + new_name, + algorithms, + password, ) - except Exception as exc: + except KRBAPIModifyPrincipalError as exc: raise KerberosDependencyError( - f"Error resetting principal password: {exc}", + f"Error renaming principal: {exc}", ) from exc async def delete_principal(self, principal_name: str) -> None: @@ -432,15 +427,17 @@ async def delete_principal(self, principal_name: str) -> None: async def ktadd( self, names: list[str], + is_rand_key: bool, ) -> tuple[AsyncIterator[bytes], TaskStruct]: """Generate keytab and return (aiter_bytes, TaskStruct). :param list[str] names: List of principal names. + :param bool is_rand_key: If True, generate random key. :raises KerberosNotFoundError: If principal not found. :return tuple: (aiter_bytes, (func, args, kwargs)). """ try: - response = await self._kadmin.ktadd(names) + response = await self._kadmin.ktadd(names, is_rand_key) except KRBAPIPrincipalNotFoundError: raise KerberosNotFoundError("Principal not found") aiter_bytes = response.aiter_bytes() @@ -469,7 +466,6 @@ async def get_status(self) -> KerberosState: ktadd.__name__: AuthorizationRules.KRB_KTADD, get_status.__name__: AuthorizationRules.KRB_GET_STATUS, add_principal.__name__: AuthorizationRules.KRB_ADD_PRINCIPAL, - rename_principal.__name__: AuthorizationRules.KRB_RENAME_PRINCIPAL, - reset_principal_pw.__name__: AuthorizationRules.KRB_RESET_PRINCIPAL_PW, + modify_principal.__name__: AuthorizationRules.KRB_MODIFY_PRINCIPAL, delete_principal.__name__: AuthorizationRules.KRB_DELETE_PRINCIPAL, } diff --git a/app/ldap_protocol/kerberos/stub.py b/app/ldap_protocol/kerberos/stub.py index 889583c16..5e50efdcf 100644 --- a/app/ldap_protocol/kerberos/stub.py +++ b/app/ldap_protocol/kerberos/stub.py @@ -19,8 +19,9 @@ async def setup(self, *args, **kwargs) -> None: # type: ignore @logger_wraps(is_stub=True) async def add_principal( self, - name: str, - password: str | None, + principal_name: str, + password: str | None = None, + algorithms: list[str] | None = None, timeout: int = 1, ) -> None: ... @@ -45,10 +46,16 @@ async def create_or_update_principal_pw( ) -> None: ... @logger_wraps(is_stub=True) - async def rename_princ(self, name: str, new_name: str) -> None: ... + async def modify_princ( + self, + name: str, + new_name: str | None, + algorithms: list[str] | None = None, + password: str | None = None, + ) -> None: ... @logger_wraps(is_stub=True) - async def ktadd(self, names: list[str]) -> NoReturn: # noqa: ARG002 + async def ktadd(self, names: list[str], is_rand_key: bool) -> NoReturn: # noqa: ARG002 raise KRBAPIPrincipalNotFoundError @logger_wraps(is_stub=True) diff --git a/app/ldap_protocol/kerberos/utils.py b/app/ldap_protocol/kerberos/utils.py index c6278ed95..026b71a1d 100644 --- a/app/ldap_protocol/kerberos/utils.py +++ b/app/ldap_protocol/kerberos/utils.py @@ -63,8 +63,7 @@ async def wrapped(*args: str, **kwargs: str) -> Any: except Exception as err: if isinstance(err, KRBAPIError): logger.error(f"{name} call raised: {err}") - raise - + raise else: if not is_stub: logger.success(f"Executed {name}") diff --git a/app/ldap_protocol/ldap_requests/bind.py b/app/ldap_protocol/ldap_requests/bind.py index 7f4af0e5c..a747c26f6 100644 --- a/app/ldap_protocol/ldap_requests/bind.py +++ b/app/ldap_protocol/ldap_requests/bind.py @@ -213,7 +213,7 @@ async def handle( await ctx.kadmin.add_principal( user.sam_account_name, self.authentication_choice.password.get_secret_value(), - 0.1, + timeout=0.1, ) await ctx.ldap_session.set_user(user) diff --git a/app/ldap_protocol/ldap_requests/modify.py b/app/ldap_protocol/ldap_requests/modify.py index 66ccafa8d..9b1b03edf 100644 --- a/app/ldap_protocol/ldap_requests/modify.py +++ b/app/ldap_protocol/ldap_requests/modify.py @@ -25,8 +25,8 @@ KRBAPIConnectionError, KRBAPIForcePasswordChangeError, KRBAPILockPrincipalError, + KRBAPIModifyPrincipalError, KRBAPIPrincipalNotFoundError, - KRBAPIRenamePrincipalError, ) from ldap_protocol.ldap_codes import LDAPCodes from ldap_protocol.ldap_responses import ModifyResponse, PartialAttribute @@ -78,7 +78,7 @@ class ModifyForbiddenError(Exception): PermissionError, ModifyForbiddenError, KRBAPIPrincipalNotFoundError, - KRBAPIRenamePrincipalError, + KRBAPIModifyPrincipalError, KRBAPILockPrincipalError, KRBAPIForcePasswordChangeError, ) @@ -333,7 +333,7 @@ def _match_bad_response(self, err: BaseException) -> tuple[LDAPCodes, str]: case ModifyForbiddenError(): return LDAPCodes.OPERATIONS_ERROR, str(err) - case KRBAPIRenamePrincipalError(): + case KRBAPIModifyPrincipalError(): return LDAPCodes.UNAVAILABLE, "Kerberos error" case KRBAPIPrincipalNotFoundError(): @@ -935,7 +935,7 @@ async def _add( # noqa: C901 new_user_principal_name = f"{new_sam_account_name}@{base_dir.name}" # noqa: E501 # fmt: skip if directory.user.sam_account_name != new_sam_account_name: - await kadmin.rename_princ( + await kadmin.modify_princ( directory.user.sam_account_name, new_sam_account_name, ) @@ -1041,11 +1041,11 @@ async def _modify_computer_samaccountname( raise ModifyForbiddenError("Old sAMAccountName value not found.") if old_sam_account_name != new_sam_account_name: - await kadmin.rename_princ( + await kadmin.modify_princ( f"host/{old_sam_account_name}", f"host/{new_sam_account_name}", ) - await kadmin.rename_princ( + await kadmin.modify_princ( f"host/{old_sam_account_name}.{base_dir.name}", f"host/{new_sam_account_name}.{base_dir.name}", ) diff --git a/interface b/interface index 3c92cf4f0..e1ca5656a 160000 --- a/interface +++ b/interface @@ -1 +1 @@ -Subproject commit 3c92cf4f0fb155978a68e4bcd66241a0b799d1e0 +Subproject commit e1ca5656aeabc20a1862aeaf11ded72feaa97403 diff --git a/tests/conftest.py b/tests/conftest.py index c95ed1312..51c8a9dc7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -196,7 +196,7 @@ async def get_kadmin(self) -> AsyncIterator[AsyncMock]: kadmin.get_status = AsyncMock(return_value=False) kadmin.add_principal = AsyncMock() kadmin.del_principal = AsyncMock() - kadmin.rename_princ = AsyncMock() + kadmin.modify_princ = AsyncMock() kadmin.create_or_update_principal_pw = AsyncMock() kadmin.change_principal_password = AsyncMock() kadmin.lock_principal = AsyncMock() diff --git a/tests/test_api/test_main/test_kadmin.py b/tests/test_api/test_main/test_kadmin.py index b96d6c6c5..b13909357 100644 --- a/tests/test_api/test_main/test_kadmin.py +++ b/tests/test_api/test_main/test_kadmin.py @@ -212,7 +212,10 @@ async def test_ktadd( :param LDAPSession ldap_session: ldap """ names = ["test1", "test2"] - response = await http_client.post("/kerberos/ktadd", json=names) + response = await http_client.post( + "/kerberos/ktadd", + json={"names": names, "is_rand_key": False}, + ) kadmin.ktadd.assert_called() # type: ignore assert kadmin.ktadd.call_args.args[0] == names # type: ignore @@ -240,7 +243,10 @@ async def test_ktadd_400( kadmin.ktadd.side_effect = KRBAPIPrincipalNotFoundError() # type: ignore names = ["test1", "test2"] - response = await http_client.post("/kerberos/ktadd", json=names) + response = await http_client.post( + "/kerberos/ktadd", + json={"names": names, "is_rand_key": False}, + ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -349,7 +355,7 @@ async def test_bind_create_user( assert await proc.wait() == 0 kadmin_args = kadmin.add_principal.call_args.args # type: ignore - assert kadmin_args == (san, pw, 0.1) + assert kadmin_args == (san, pw) @pytest.mark.asyncio @@ -389,20 +395,20 @@ async def test_add_princ( :param LDAPSession ldap_session: ldap """ response = await http_client.post( - "/kerberos/principal/add", + "/kerberos/principal", json={ - "primary": "host", - "instance": "12345", + "principal_name": "host/12345", + "password": None, }, ) kadmin_args = kadmin.add_principal.call_args.args # type: ignore assert response.status_code == status.HTTP_200_OK - assert kadmin_args == ("host/12345", None) + assert kadmin_args == ("host/12345", None, None) @pytest.mark.asyncio @pytest.mark.usefixtures("session") -async def test_rename_princ( +async def test_modify_princ( http_client: AsyncClient, kadmin: AbstractKadmin, ) -> None: @@ -411,16 +417,16 @@ async def test_rename_princ( :param AsyncClient http_client: http cl :param LDAPSession ldap_session: ldap """ - response = await http_client.patch( - "/kerberos/principal/rename", + response = await http_client.put( + "/kerberos/principal", json={ "principal_name": "name", - "principal_new_name": "nname", + "new_name": "nname", }, ) - kadmin_args = kadmin.rename_princ.call_args.args # type: ignore + kadmin_args = kadmin.modify_princ.call_args.args # type: ignore assert response.status_code == status.HTTP_200_OK - assert kadmin_args == ("name", "nname") + assert kadmin_args == ("name", "nname", None, None) @pytest.mark.asyncio @@ -434,16 +440,16 @@ async def test_change_princ( :param AsyncClient http_client: http cl :param LDAPSession ldap_session: ldap """ - response = await http_client.patch( - "/kerberos/principal/reset", + response = await http_client.put( + "/kerberos/principal", json={ "principal_name": "name", - "new_password": "pw123", + "password": "pw123", }, ) - kadmin_args = kadmin.change_principal_password.call_args.args # type: ignore + kadmin_args = kadmin.modify_princ.call_args.args # type: ignore assert response.status_code == status.HTTP_200_OK - assert kadmin_args == ("name", "pw123") + assert kadmin_args == ("name", None, None, "pw123") @pytest.mark.asyncio diff --git a/tests/test_api/test_main/test_router/test_modify.py b/tests/test_api/test_main/test_router/test_modify.py index 2243dcb37..321c38795 100644 --- a/tests/test_api/test_main/test_router/test_modify.py +++ b/tests/test_api/test_main/test_router/test_modify.py @@ -101,7 +101,7 @@ async def test_api_correct_modify_user_samaccountname( data = response.json() assert isinstance(data, dict) assert data.get("resultCode") == LDAPCodes.SUCCESS - assert kadmin.rename_princ.call_args.args == ("new_user", "NEW user name") # type: ignore + assert kadmin.modify_princ.call_args.args == ("new_user", "NEW user name") # type: ignore response = await http_client.post( "entry/search", @@ -160,7 +160,7 @@ async def test_api_correct_modify_user_userprincipalname( data = response.json() assert isinstance(data, dict) assert data.get("resultCode") == LDAPCodes.SUCCESS - assert kadmin.rename_princ.call_args.args == ("new_user", "newbiguser") # type: ignore + assert kadmin.modify_princ.call_args.args == ("new_user", "newbiguser") # type: ignore response = await http_client.post( "entry/search", @@ -219,12 +219,12 @@ async def test_api_correct_modify_computer_samaccountname_replace( assert isinstance(data, dict) assert data.get("resultCode") == LDAPCodes.SUCCESS - assert kadmin.rename_princ.call_count == 2 # type: ignore - assert kadmin.rename_princ.call_args_list[0].args == ( # type: ignore + assert kadmin.modify_princ.call_count == 2 # type: ignore + assert kadmin.modify_princ.call_args_list[0].args == ( # type: ignore "host/mycomputer", "host/maincomputer", ) - assert kadmin.rename_princ.call_args_list[1].args == ( # type: ignore + assert kadmin.modify_princ.call_args_list[1].args == ( # type: ignore "host/mycomputer.md.test", "host/maincomputer.md.test", ) From 16c7b2d0bb205114cb3906accdddbbf13a8449ca Mon Sep 17 00:00:00 2001 From: Ruslan Date: Thu, 19 Feb 2026 18:13:32 +0300 Subject: [PATCH 24/45] Add Domain Controller (#938) --- .package/setup.bat | 7 + .package/setup.sh | 6 + .../ebf19750805e_add_domain_controllers_ou.py | 155 ++++++++++++++++++ app/config.py | 2 + app/constants.py | 1 + app/extra/scripts/add_domain_controller.py | 147 +++++++++++++++++ app/ldap_protocol/auth/setup_gateway.py | 2 +- app/ldap_protocol/auth/use_cases.py | 34 ++++ app/ldap_protocol/utils/async_cache.py | 7 +- app/schedule.py | 2 + docker-compose.remote.test.yml | 2 + docker-compose.test.yml | 2 + local.env | 2 + tests/test_shedule.py | 21 +++ 14 files changed, 387 insertions(+), 3 deletions(-) create mode 100644 app/alembic/versions/ebf19750805e_add_domain_controllers_ou.py create mode 100644 app/extra/scripts/add_domain_controller.py diff --git a/.package/setup.bat b/.package/setup.bat index 53e08d9e7..dec9e7cc0 100644 --- a/.package/setup.bat +++ b/.package/setup.bat @@ -115,3 +115,10 @@ if not exist "certs" ( ) else ( echo Directory already exists: certs ) + +:: 9. HOST_MACHINE_NAME +findstr /b /i /c:"HOST_MACHINE_NAME=" .env >nul +if errorlevel 1 ( + set "host_machine_name=%COMPUTERNAME%" + echo HOST_MACHINE_NAME=!host_machine_name!>> .env +) diff --git a/.package/setup.sh b/.package/setup.sh index 3e510b402..8d6cfefbf 100755 --- a/.package/setup.sh +++ b/.package/setup.sh @@ -79,3 +79,9 @@ if [ ! -d "certs" ]; then else echo "Directory already exists: certs" fi + +# HOST_MACHINE_NAME +if ! get_env_var "HOST_MACHINE_NAME"; then + host_machine_name=$(hostname) + add_env_var "HOST_MACHINE_NAME" "$host_machine_name" +fi diff --git a/app/alembic/versions/ebf19750805e_add_domain_controllers_ou.py b/app/alembic/versions/ebf19750805e_add_domain_controllers_ou.py new file mode 100644 index 000000000..5f708272a --- /dev/null +++ b/app/alembic/versions/ebf19750805e_add_domain_controllers_ou.py @@ -0,0 +1,155 @@ +"""Add OU 'Domain Controllers' if it does not exist. + +Revision ID: ebf19750805e +Revises: 2dadf40c026a +Create Date: 2026-02-17 08:52:28.048004 + +""" + +from typing import Any + +from alembic import op +from dishka import AsyncContainer, Scope +from sqlalchemy import delete, exists, select +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession + +from config import Settings +from constants import DOMAIN_CONTROLLERS_OU_NAME +from entities import Directory +from enums import SamAccountTypeCodes +from ldap_protocol.auth.setup_gateway import SetupGateway +from ldap_protocol.objects import UserAccountControlFlag +from ldap_protocol.roles.role_use_case import RoleUseCase +from ldap_protocol.utils.queries import get_base_directories +from repo.pg.tables import queryable_attr as qa + +# revision identifiers, used by Alembic. +revision: None | str = "ebf19750805e" +down_revision: None | str = "2dadf40c026a" +branch_labels: None | list[str] = None +depends_on: None | list[str] = None + + +_OU_DOMAIN_CONTROLLERS_DATA: dict[str, Any] = { + "name": DOMAIN_CONTROLLERS_OU_NAME, + "object_class": "organizationalUnit", + "attributes": {"objectClass": ["top", "container"]}, +} + + +def upgrade(container: AsyncContainer) -> None: + """Upgrade.""" + + async def _create_domain_controllers_ou( + connection: AsyncConnection, # noqa: ARG001 + ) -> None: + async with container(scope=Scope.REQUEST) as cnt: + settings = await cnt.get(Settings) + session = await cnt.get(AsyncSession) + setup_gateway = await cnt.get(SetupGateway) + role_use_case = await cnt.get(RoleUseCase) + + base_directories = await get_base_directories(session) + if not base_directories: + return + domain_dir = base_directories[0] + + exists_dc_ou = await session.scalar( + select( + exists(Directory) + .where(qa(Directory.name) == DOMAIN_CONTROLLERS_OU_NAME), + ), + ) # fmt: skip + if exists_dc_ou: + return + + domain_controller_data = [ + { + "name": settings.HOST_MACHINE_NAME, + "object_class": "computer", + "attributes": { + "objectClass": ["top"], + "userAccountControl": [ + str( + UserAccountControlFlag.SERVER_TRUST_ACCOUNT.value, + ), + ], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_MACHINE_ACCOUNT), + ], + "sAMAccountName": [settings.HOST_MACHINE_NAME], + "ipHostNumber": [settings.DEFAULT_NAMESERVER], + }, + }, + ] + _OU_DOMAIN_CONTROLLERS_DATA["children"] = domain_controller_data + + await setup_gateway.create_dir( + _OU_DOMAIN_CONTROLLERS_DATA, + is_system=True, + domain=domain_dir, + parent=domain_dir, + ) + + dc_ou = await session.scalar( + select(Directory).where( + qa(Directory.name) == DOMAIN_CONTROLLERS_OU_NAME, + ), + ) + if not dc_ou: + raise Exception("Domain Controllers OU was not created") + + dc = await session.scalar( + select(Directory).where( + qa(Directory.name) == settings.HOST_MACHINE_NAME, + ), + ) + if not dc: + raise Exception("Domain Controller was not created") + + await role_use_case.inherit_parent_aces( + parent_directory=domain_dir, + directory=dc_ou, + ) + await role_use_case.inherit_parent_aces( + parent_directory=dc_ou, + directory=dc, + ) + + await session.commit() + + op.run_async(_create_domain_controllers_ou) + + +def downgrade(container: AsyncContainer) -> None: + """Downgrade.""" + + async def _delete_domain_controllers_ou( + connection: AsyncConnection, # noqa: ARG001 + ) -> None: + async with container(scope=Scope.REQUEST) as cnt: + session = await cnt.get(AsyncSession) + + domain_controller_ou = await session.scalar( + select(Directory).where( + qa(Directory.name) == DOMAIN_CONTROLLERS_OU_NAME, + ), + ) + + if not domain_controller_ou: + return + + await session.execute( + delete(Directory).where( + qa(Directory.parent_id) == domain_controller_ou.id, + ), + ) + + await session.execute( + delete(Directory).where( + qa(Directory.id) == domain_controller_ou.id, + ), + ) + await session.commit() + + op.run_async(_delete_domain_controllers_ou) diff --git a/app/config.py b/app/config.py index f67bfaeaf..16f710b1b 100644 --- a/app/config.py +++ b/app/config.py @@ -36,6 +36,7 @@ class Settings(BaseModel): """Settigns with database dsn.""" DOMAIN: str + HOST_MACHINE_NAME: str DEBUG: bool = False AUTO_RELOAD: bool = False @@ -47,6 +48,7 @@ class Settings(BaseModel): GLOBAL_LDAP_TLS_PORT: int = 3269 USE_CORE_TLS: bool = False LDAP_LOAD_SSL_CERT: bool = False + DEFAULT_NAMESERVER: str TCP_PACKET_SIZE: int = 1024 COROUTINES_NUM_PER_CLIENT: int = 3 diff --git a/app/constants.py b/app/constants.py index 8a743ed5b..5086dfad1 100644 --- a/app/constants.py +++ b/app/constants.py @@ -11,6 +11,7 @@ GROUPS_CONTAINER_NAME = "Groups" COMPUTERS_CONTAINER_NAME = "Computers" USERS_CONTAINER_NAME = "Users" +DOMAIN_CONTROLLERS_OU_NAME = "Domain Controllers" READ_ONLY_GROUP_NAME = "read-only" diff --git a/app/extra/scripts/add_domain_controller.py b/app/extra/scripts/add_domain_controller.py new file mode 100644 index 000000000..dbfc087a0 --- /dev/null +++ b/app/extra/scripts/add_domain_controller.py @@ -0,0 +1,147 @@ +"""Add domain controller. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from loguru import logger +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import Settings +from constants import DOMAIN_CONTROLLERS_OU_NAME +from entities import Attribute, Directory +from enums import SamAccountTypeCodes +from ldap_protocol.ldap_schema.entity_type_dao import EntityTypeDAO +from ldap_protocol.objects import UserAccountControlFlag +from ldap_protocol.roles.role_use_case import RoleUseCase +from ldap_protocol.utils.helpers import create_object_sid +from ldap_protocol.utils.queries import get_base_directories +from repo.pg.tables import queryable_attr as qa + + +async def _add_domain_controller( + session: AsyncSession, + role_use_case: RoleUseCase, + entity_type_dao: EntityTypeDAO, + settings: Settings, + domain: Directory, + dc_ou_dir: Directory, +) -> None: + dc_directory = Directory( + object_class="", + name=settings.HOST_MACHINE_NAME, + is_system=True, + ) + dc_directory.create_path(dc_ou_dir) + session.add(dc_directory) + await session.flush() + + dc_directory.parent_id = dc_ou_dir.id + dc_directory.object_sid = create_object_sid(domain, dc_directory.id) + await session.flush() + + attributes = [ + Attribute( + name="objectClass", + value="top", + directory_id=dc_directory.id, + ), + Attribute( + name="objectClass", + value="computer", + directory_id=dc_directory.id, + ), + Attribute( + name="sAMAccountName", + value=settings.HOST_MACHINE_NAME, + directory_id=dc_directory.id, + ), + Attribute( + name="userAccountControl", + value=str( + UserAccountControlFlag.SERVER_TRUST_ACCOUNT, + ), + directory_id=dc_directory.id, + ), + Attribute( + name="sAMAccountType", + value=str(SamAccountTypeCodes.SAM_MACHINE_ACCOUNT), + directory_id=dc_directory.id, + ), + Attribute( + name="ipHostNumber", + value=settings.DEFAULT_NAMESERVER, + directory_id=dc_directory.id, + ), + Attribute( + name="cn", + value=settings.HOST_MACHINE_NAME, + directory_id=dc_directory.id, + ), + ] + + session.add_all(attributes) + await session.flush() + + await role_use_case.inherit_parent_aces( + parent_directory=dc_ou_dir, + directory=dc_directory, + ) + await entity_type_dao.attach_entity_type_to_directory( + directory=dc_directory, + is_system_entity_type=False, + object_class_names={"top", "computer"}, + ) + await session.flush() + + +async def add_domain_controller( + session: AsyncSession, + settings: Settings, + role_use_case: RoleUseCase, + entity_type_dao: EntityTypeDAO, +) -> None: + logger.info("Adding domain controller.") + + domains = await get_base_directories(session) + if not domains: + logger.debug("Cannot get base directory") + return + + domain_controllers_ou = await session.scalar( + select(Directory).where( + qa(Directory.name) == DOMAIN_CONTROLLERS_OU_NAME, + ), + ) + + if not domain_controllers_ou: + logger.debug("Domain controllers OU does not exist.") + return + + domain_controller = await session.scalar( + select(qa(Directory.id).distinct()) + .join(qa(Directory.attributes)) + .where( + qa(Directory.parent_id) == domain_controllers_ou.id, + qa(Attribute.name) == "ipHostNumber", + qa(Attribute.value) == settings.DEFAULT_NAMESERVER, + ), + ) + + if domain_controller: + logger.debug("Domain controllers already exists") + return + + await _add_domain_controller( + session=session, + role_use_case=role_use_case, + entity_type_dao=entity_type_dao, + settings=settings, + domain=domains[0], + dc_ou_dir=domain_controllers_ou, + ) + + logger.debug("Domain controller added.") + + await session.commit() diff --git a/app/ldap_protocol/auth/setup_gateway.py b/app/ldap_protocol/auth/setup_gateway.py index 4294066c5..6cbad0ea1 100644 --- a/app/ldap_protocol/auth/setup_gateway.py +++ b/app/ldap_protocol/auth/setup_gateway.py @@ -60,7 +60,7 @@ async def setup_enviroment( self, *, data: list, - is_system: bool, + is_system: bool = True, dn: str = "multifactor.dev", ) -> None: """Create directories and users for enviroment.""" diff --git a/app/ldap_protocol/auth/use_cases.py b/app/ldap_protocol/auth/use_cases.py index 136f2cf23..370467e4f 100644 --- a/app/ldap_protocol/auth/use_cases.py +++ b/app/ldap_protocol/auth/use_cases.py @@ -9,8 +9,10 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from config import Settings from constants import ( DOMAIN_ADMIN_GROUP_NAME, + DOMAIN_CONTROLLERS_OU_NAME, FIRST_SETUP_DATA, USERS_CONTAINER_NAME, ) @@ -22,6 +24,7 @@ ForbiddenError, ) from ldap_protocol.ldap_schema.entity_type_use_case import EntityTypeUseCase +from ldap_protocol.objects import UserAccountControlFlag from ldap_protocol.policies.audit.audit_use_case import AuditUseCase from ldap_protocol.policies.password import PasswordPolicyUseCases from ldap_protocol.roles.role_use_case import RoleUseCase @@ -39,6 +42,7 @@ def __init__( role_use_case: RoleUseCase, audit_use_case: AuditUseCase, session: AsyncSession, + settings: Settings, ) -> None: """Initialize Setup manager. @@ -52,6 +56,7 @@ def __init__( self._role_use_case = role_use_case self._audit_use_case = audit_use_case self._session = session + self._settings = settings async def setup(self, dto: SetupDTO) -> None: """Perform the initial setup of structure and policies. @@ -67,6 +72,7 @@ async def setup(self, dto: SetupDTO) -> None: data = copy.deepcopy(FIRST_SETUP_DATA) data.append(self._create_user_data(dto)) + data.append(self._create_domain_controller_data()) await self._create(dto, data) @@ -77,6 +83,34 @@ async def is_setup(self) -> bool: """ return await self._setup_gateway.is_setup() + def _create_domain_controller_data(self) -> dict: + return { + "name": DOMAIN_CONTROLLERS_OU_NAME, + "object_class": "organizationalUnit", + "attributes": { + "objectClass": ["top", "container"], + }, + "children": [ + { + "name": self._settings.HOST_MACHINE_NAME, + "object_class": "computer", + "attributes": { + "objectClass": ["top"], + "userAccountControl": [ + str( + UserAccountControlFlag.SERVER_TRUST_ACCOUNT.value, + ), + ], + "sAMAccountType": [ + str(SamAccountTypeCodes.SAM_MACHINE_ACCOUNT), + ], + "sAMAccountName": [self._settings.HOST_MACHINE_NAME], + "ipHostNumber": [self._settings.DEFAULT_NAMESERVER], + }, + }, + ], + } + def _create_user_data(self, dto: SetupDTO) -> dict: """Create user data by request. diff --git a/app/ldap_protocol/utils/async_cache.py b/app/ldap_protocol/utils/async_cache.py index f66f45cf3..f723440a6 100644 --- a/app/ldap_protocol/utils/async_cache.py +++ b/app/ldap_protocol/utils/async_cache.py @@ -2,7 +2,7 @@ import time from functools import wraps -from typing import Callable, Generic, TypeVar +from typing import Awaitable, Callable, Generic, TypeVar from entities import Directory @@ -20,7 +20,10 @@ def clear(self) -> None: self._value = None self._expires_at = None - def __call__(self, func: Callable) -> Callable: + def __call__( + self, + func: Callable[..., Awaitable[T]], + ) -> Callable[..., Awaitable[T]]: @wraps(func) async def wrapper(*args: tuple, **kwargs: dict) -> T: if self._value is not None: diff --git a/app/schedule.py b/app/schedule.py index 35e59a85d..22fc26cd4 100644 --- a/app/schedule.py +++ b/app/schedule.py @@ -7,6 +7,7 @@ from loguru import logger from config import Settings +from extra.scripts.add_domain_controller import add_domain_controller from extra.scripts.check_ldap_principal import check_ldap_principal from extra.scripts.principal_block_user_sync import principal_block_sync from extra.scripts.uac_sync import disable_accounts @@ -27,6 +28,7 @@ (update_krb5_config, -1.0), (update_admin_permissions, -1.0), (update_status_process_events, 300.0), + (add_domain_controller, 600.0), } diff --git a/docker-compose.remote.test.yml b/docker-compose.remote.test.yml index bd5659b02..7628ad3e3 100644 --- a/docker-compose.remote.test.yml +++ b/docker-compose.remote.test.yml @@ -5,6 +5,8 @@ services: environment: DEBUG: 1 DOMAIN: md.test + DEFAULT_NAMESERVER: 127.0.0.1 + HOST_MACHINE_NAME: DC1 POSTGRES_USER: user1 POSTGRES_PASSWORD: password123 SECRET_KEY: 6a0452ae20cab4e21b6e9d18fa4b7bf397dd66ec3968b2d7407694278fd84cce diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 96076b657..120a894f6 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -14,6 +14,8 @@ services: environment: DEBUG: 1 DOMAIN: md.test + HOST_MACHINE_NAME: DC1 + DEFAULT_NAMESERVER: 127.0.0.1 POSTGRES_USER: user1 POSTGRES_PASSWORD: password123 SECRET_KEY: 6a0452ae20cab4e21b6e9d18fa4b7bf397dd66ec3968b2d7407694278fd84cce diff --git a/local.env b/local.env index 8eb377378..9a6b9d9b1 100644 --- a/local.env +++ b/local.env @@ -1,8 +1,10 @@ DEBUG=1 AUTO_RELOAD=1 DOMAIN=md.localhost +HOST_MACHINE_NAME=DC1 POSTGRES_USER=user1 POSTGRES_PASSWORD=password123 SECRET_KEY=6a0452ae20cab4e21b6e9d18fa4b7bf397dd66ec3968b2d7407694278fd84cce MFA_API_SOURCE=dev ACCESS_TOKEN_EXPIRE_MINUTES=180 +DEFAULT_NAMESERVER=127.0.0.1 diff --git a/tests/test_shedule.py b/tests/test_shedule.py index dc5aaaf01..fa293902a 100644 --- a/tests/test_shedule.py +++ b/tests/test_shedule.py @@ -8,11 +8,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from config import Settings +from extra.scripts.add_domain_controller import add_domain_controller from extra.scripts.check_ldap_principal import check_ldap_principal from extra.scripts.principal_block_user_sync import principal_block_sync from extra.scripts.uac_sync import disable_accounts from extra.scripts.update_krb5_config import update_krb5_config from ldap_protocol.kerberos import AbstractKadmin +from ldap_protocol.ldap_schema.entity_type_dao import EntityTypeDAO +from ldap_protocol.roles.role_use_case import RoleUseCase @pytest.mark.asyncio @@ -73,3 +76,21 @@ async def test_update_krb5_config( session=session, settings=settings, ) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("session") +@pytest.mark.usefixtures("setup_session") +async def test_add_domain_controller( + session: AsyncSession, + settings: Settings, + role_use_case: RoleUseCase, + entity_type_dao: EntityTypeDAO, +) -> None: + """Test add domain controller.""" + await add_domain_controller( + settings=settings, + session=session, + role_use_case=role_use_case, + entity_type_dao=entity_type_dao, + ) From 8ac623ab1c7b2e324d84692219b51bad3231fe83 Mon Sep 17 00:00:00 2001 From: Nikita Ulyanov <69312634+rimu-stack@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:47:31 +0300 Subject: [PATCH 25/45] Fix exception in CLDAP service (#939) --- app/ldap_protocol/rootdse/netlogon.py | 19 +++++++++++++------ tests/test_ldap/test_netlogon.py | 2 ++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/ldap_protocol/rootdse/netlogon.py b/app/ldap_protocol/rootdse/netlogon.py index 8efb2b78c..5dc225b0b 100644 --- a/app/ldap_protocol/rootdse/netlogon.py +++ b/app/ldap_protocol/rootdse/netlogon.py @@ -9,6 +9,7 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ +import codecs import ipaddress import struct import uuid @@ -180,13 +181,17 @@ def set_info(self) -> None: ) @staticmethod - def _convert_little_endian_string_to_int(value: str) -> int: + def _convert_little_endian_string_to_int(value: str | bytes) -> int: """Convert little-endian string to int.""" - return int.from_bytes( - value.encode().decode("unicode_escape").encode(), - byteorder="little", - signed=False, - ) + if isinstance(value, bytes): + return int.from_bytes(value, "little", signed=False) + + if "\\x" in value: + value = codecs.decode(value, "unicode_escape").encode("latin-1") + else: + value = value.encode("latin-1", errors="strict") + + return int.from_bytes(value, "little", signed=False) def get_attr(self) -> bytes: """Get NetLogon response.""" @@ -291,6 +296,8 @@ def _get_netlogon_response_5_ex(self) -> bytes: DSFlag.CLOSEST_FLAG, DSFlag.WRITABLE_FLAG, DSFlag.GOOD_TIMESERV_FLAG, + DSFlag.KDC_FLAG, + DSFlag.WS_FLAG, ]: ds_flags |= flag diff --git a/tests/test_ldap/test_netlogon.py b/tests/test_ldap/test_netlogon.py index c12764df5..fa22188e0 100644 --- a/tests/test_ldap/test_netlogon.py +++ b/tests/test_ldap/test_netlogon.py @@ -315,6 +315,8 @@ def test_ds_flags_combination() -> None: | DSFlag.CLOSEST_FLAG | DSFlag.WRITABLE_FLAG | DSFlag.GOOD_TIMESERV_FLAG + | DSFlag.KDC_FLAG + | DSFlag.WS_FLAG ) assert ds_flags == expected_flags From 9c49f48c795afa3acd0e45005ca0bb8a85f6db9f Mon Sep 17 00:00:00 2001 From: iyashnov <57270538+iyashnov@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:16:29 +0300 Subject: [PATCH 26/45] add: PowerDNS (#876) --- .dns/dns_api.py | 1395 ----------------- .dns/entrypoint.sh | 19 - .dns/templates/zone.template | 11 - .dns/templates/zone_options.template | 10 - .docker/Dockerfile | 2 +- .docker/bind9.Dockerfile | 45 - .docker/dev.Dockerfile | 2 +- .docker/pdns_auth.Dockerfile | 66 + .docker/test.Dockerfile | 2 +- .github/workflows/build-beta.yml | 6 +- .github/workflows/build-dev.yml | 6 +- .github/workflows/build-docker-image.yml | 6 +- .gitignore | 1 + .package/dnsdist.conf | 6 + .package/docker-compose.yml | 114 +- .package/pdns.conf | 11 + .package/recursor.conf | 10 + .package/resolv.conf | 16 + .package/setup.bat | 22 + .package/setup.sh | 15 + .package/traefik.yml | 2 - app/api/__init__.py | 2 +- app/api/dns/__init__.py | 0 app/api/dns/adapter.py | 202 +++ app/api/{main/dns_router.py => dns/router.py} | 183 ++- app/api/dns/schema.py | 79 + app/api/main/adapters/dns.py | 138 -- app/api/main/schema.py | 80 - app/config.py | 14 +- app/enums.py | 17 +- app/ioc.py | 109 +- app/ldap_protocol/dns/__init__.py | 83 +- app/ldap_protocol/dns/base.py | 302 ---- app/ldap_protocol/dns/clients/__init__.py | 17 + .../dns/clients/abstract_client.py | 110 ++ .../dns/clients/power_dns_http_clients.py | 112 ++ .../dns/clients/power_dnsdist_client.py | 264 ++++ app/ldap_protocol/dns/constants.py | 62 + app/ldap_protocol/dns/dns_gateway.py | 119 +- app/ldap_protocol/dns/dto.py | 114 +- app/ldap_protocol/dns/enums.py | 63 + app/ldap_protocol/dns/exceptions.py | 67 +- app/ldap_protocol/dns/managers/__init__.py | 11 + .../dns/managers/abstract_dns_manager.py | 117 ++ .../dns/managers/power_dns_manager.py | 331 ++++ .../dns/managers/remote_dns_manager.py | 191 +++ .../dns/managers/stub_dns_manager.py | 105 ++ app/ldap_protocol/dns/remote.py | 125 -- app/ldap_protocol/dns/selfhosted.py | 286 ---- app/ldap_protocol/dns/stub.py | 109 -- app/ldap_protocol/dns/use_cases.py | 244 ++- app/ldap_protocol/dns/utils.py | 74 +- dnsdist.conf | 6 + docker-compose.dev.yml | 139 +- docker-compose.remote.test.yml | 2 + docker-compose.test.yml | 2 + docker-compose.yml | 134 +- local.env | 4 +- pyproject.toml | 1 + tests/conftest.py | 77 +- tests/test_api/test_main/test_dns.py | 245 ++- traefik.yml | 2 - uv.lock | 36 + 63 files changed, 2992 insertions(+), 3153 deletions(-) delete mode 100644 .dns/dns_api.py delete mode 100755 .dns/entrypoint.sh delete mode 100644 .dns/templates/zone.template delete mode 100644 .dns/templates/zone_options.template delete mode 100644 .docker/bind9.Dockerfile create mode 100644 .docker/pdns_auth.Dockerfile create mode 100644 .package/dnsdist.conf create mode 100644 .package/pdns.conf create mode 100644 .package/recursor.conf create mode 100644 .package/resolv.conf create mode 100644 app/api/dns/__init__.py create mode 100644 app/api/dns/adapter.py rename app/api/{main/dns_router.py => dns/router.py} (65%) create mode 100644 app/api/dns/schema.py delete mode 100644 app/api/main/adapters/dns.py delete mode 100644 app/ldap_protocol/dns/base.py create mode 100644 app/ldap_protocol/dns/clients/__init__.py create mode 100644 app/ldap_protocol/dns/clients/abstract_client.py create mode 100644 app/ldap_protocol/dns/clients/power_dns_http_clients.py create mode 100644 app/ldap_protocol/dns/clients/power_dnsdist_client.py create mode 100644 app/ldap_protocol/dns/constants.py create mode 100644 app/ldap_protocol/dns/enums.py create mode 100644 app/ldap_protocol/dns/managers/__init__.py create mode 100644 app/ldap_protocol/dns/managers/abstract_dns_manager.py create mode 100644 app/ldap_protocol/dns/managers/power_dns_manager.py create mode 100644 app/ldap_protocol/dns/managers/remote_dns_manager.py create mode 100644 app/ldap_protocol/dns/managers/stub_dns_manager.py delete mode 100644 app/ldap_protocol/dns/remote.py delete mode 100644 app/ldap_protocol/dns/selfhosted.py delete mode 100644 app/ldap_protocol/dns/stub.py create mode 100644 dnsdist.conf diff --git a/.dns/dns_api.py b/.dns/dns_api.py deleted file mode 100644 index f57f23bed..000000000 --- a/.dns/dns_api.py +++ /dev/null @@ -1,1395 +0,0 @@ -"""API for managing Bind9 DNS server. - -Copyright (c) 2025 MultiFactor -License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE -""" - -import contextlib -import logging -import os -import re -import subprocess -import tempfile -from collections import defaultdict -from dataclasses import dataclass -from enum import StrEnum -from typing import Annotated, NoReturn - -import dns -import dns.zone -import jinja2 -from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status -from pydantic import BaseModel - -logging.basicConfig(level=logging.INFO) - -TEMPLATES: jinja2.Environment = jinja2.Environment( - loader=jinja2.FileSystemLoader("templates/"), - autoescape=True, - keep_trailing_newline=True, -) - -ZONE_FILES_DIR = "/opt" -NAMED_CONF = "/etc/bind/named.conf" -NAMED_LOCAL = "/etc/bind/named.conf.local" -NAMED_OPTIONS = "/etc/bind/named.conf.options" - -FIRST_SETUP_RECORDS = [ - {"name": "_ldap._tcp.", "value": "0 0 389 ", "type": "SRV"}, - {"name": "_ldaps._tcp.", "value": "0 0 636 ", "type": "SRV"}, - {"name": "_kerberos._tcp.", "value": "0 0 88 ", "type": "SRV"}, - {"name": "_kerberos._udp.", "value": "0 0 88 ", "type": "SRV"}, - {"name": "_kdc._tcp.", "value": "0 0 88 ", "type": "SRV"}, - {"name": "_kdc._udp.", "value": "0 0 88 ", "type": "SRV"}, - {"name": "_kpasswd._tcp.", "value": "0 0 464 ", "type": "SRV"}, - {"name": "_kpasswd._udp.", "value": "0 0 464 ", "type": "SRV"}, - # Record for PDC Emulator - { - "name": "_ldap._tcp.pdc._msdcs.", - "value": "0 100 389 ", - "type": "SRV", - }, - # Records for DC Locator (for trusts) - { - "name": "_kerberos._tcp.dc._msdcs.", - "value": "0 100 88 ", - "type": "SRV", - }, - { - "name": "_kerberos._tcp.Default-First-Site-Name._sites.dc._msdcs.", - "value": "0 100 88 ", - "type": "SRV", - }, - { - "name": "_ldap._tcp.dc._msdcs.", - "value": "0 100 389 ", - "type": "SRV", - }, - { - "name": "_ldap._tcp.Default-First-Site-Name._sites.dc._msdcs.", - "value": "0 100 389 ", - "type": "SRV", - }, - # Records for Global Catalog - {"name": "_gc._tcp.", "value": "0 100 3268 ", "type": "SRV"}, - { - "name": "_ldap._tcp.Default-First-Site-Name._sites.gc._msdcs.", - "value": "0 100 3268 ", - "type": "SRV", - }, - { - "name": "_ldap._tcp.gc._msdcs.", - "value": "0 100 3268 ", - "type": "SRV", - }, -] - - -class DNSError(Exception): - """Base class for DNS exceptions.""" - - -class DNSZoneCreateError(DNSError): - """DNS zone create error.""" - - -class DNSDomainNotFoundError(DNSError): - """DNS domain not found error.""" - - -class DNSZoneValidationError(DNSError): - """DNS validation error.""" - - -class DNSZoneConfigError(DNSError): - """DNS zone config error.""" - - -class DNSZoneNotFoundError(DNSError): - """DNS zone not found error.""" - - -class DNSZoneType(StrEnum): - """DNS zone types.""" - - MASTER = "master" - FORWARD = "forward" - - -class DNSRecordType(StrEnum): - """DNS record types.""" - - A = "A" - AAAA = "AAAA" - CNAME = "CNAME" - MX = "MX" - NS = "NS" - TXT = "TXT" - SOA = "SOA" - PTR = "PTR" - SRV = "SRV" - - -@dataclass -class DNSRecord: - """Single DNS record.""" - - name: str - value: str - ttl: int - - -@dataclass -class DNSRecords: - """List of DNS records grouped by type.""" - - type: DNSRecordType - records: list[DNSRecord] - - -@dataclass -class DNSZone: - """DNS zone.""" - - name: str - type: DNSZoneType - records: list[DNSRecords] - - -@dataclass -class DNSForwardZone: - """DNS forward zone.""" - - name: str - type: DNSZoneType - forwarders: list[str] - - -class DNSZoneParamName(StrEnum): - """Possible DNS zone option names.""" - - acl = "acl" - forwarders = "forwarders" - ttl = "ttl" - - -class DNSServerParamName(StrEnum): - """Possible DNS server option names.""" - - dnssec = "dnssec-validation" - - -@dataclass -class DNSZoneParam: - """DNS zone parameter.""" - - name: DNSZoneParamName - value: str | list[str] | None - - -class DNSZoneCreateRequest(BaseModel): - """DNS zone create request scheme.""" - - zone_name: str - zone_type: DNSZoneType - nameserver: str | None - params: list[DNSZoneParam] - - -class DNSZoneUpdateRequest(BaseModel): - """DNS zone update request scheme.""" - - zone_name: str - params: list[DNSZoneParam] - - -class DNSZoneDeleteRequest(BaseModel): - """DNS zone delete request scheme.""" - - zone_name: str - - -class DNSRecordCreateRequest(BaseModel): - """DNS record create request scheme.""" - - zone_name: str - record_name: str - record_value: str - record_type: str - ttl: int - - -class DNSRecordUpdateRequest(BaseModel): - """DNS record update request scheme.""" - - zone_name: str - record_name: str - record_value: str - record_type: DNSRecordType - ttl: int - - -class DNSRecordDeleteRequest(BaseModel): - """DNS record delete request schem.""" - - zone_name: str - record_name: str - record_value: str - record_type: DNSRecordType - - -class DNSServerSetupRequest(BaseModel): - """DNS server setup request schem.""" - - zone_name: str - - -@dataclass -class DNSServerParam: - """DNS zone parameter.""" - - name: DNSServerParamName - value: str | list[str] - - -class BindDNSServerManager: - """Bind9 DNS server manager.""" - - @staticmethod - def _get_zone_obj_by_zone_name(zone_name) -> dns.zone.Zone: - """Get DNS zone object by zone name. - - Algorithm: - 1. Build the path to the zone file using the zone name. - 2. Load the zone object using dns.zone.from_file. - - Args: - zone_name (str): Name of the DNS zone. - - Returns: - dns.zone.Zone: Zone object. - - """ - zone_file = os.path.join(ZONE_FILES_DIR, f"{zone_name}.zone") - return dns.zone.from_file( - zone_file, - relativize=False, - origin=zone_name, - ) - - def _write_zone_data_to_file( - self, - zone_name: str, - zone: dns.zone.Zone, - ) -> None: - """Write zone data to file and reload the zone. - - Algorithm: - 1. Save the zone object to a file. - 2. Call reload to apply changes. - - Args: - zone_name (str): Name of the DNS zone. - zone (dns.zone.Zone): Zone object. - - """ - error = self._check_zone(zone.to_text(), zone_name) - if error: - raise DNSZoneCreateError( - f"Error while writing zone data to file {zone_name}: {error}", - ) - - zone.to_file(os.path.join(ZONE_FILES_DIR, f"{zone_name}.zone")) - self.reload(zone_name) - - def _check_config(self, config: str) -> str | None: - with tempfile.NamedTemporaryFile(mode="w") as tf: - tf.write(config) - tmp_path = tf.name - - result = subprocess.run( # noqa: S603 - ["/usr/bin/named-checkconf", tmp_path], - capture_output=True, - text=True, - ) - - return result.stderr - - def _check_zone(self, zonefile: str, zone_name: str) -> str | None: - with tempfile.NamedTemporaryFile(mode="w") as zf: - zf.write(zonefile) - tmp_path = zf.name - - result = subprocess.run( # noqa: S603 - [ - "/usr/bin/named-checkzone", - "-i", - "none", - zone_name, - tmp_path, - ], - capture_output=True, - text=True, - ) - - return result.stderr - - def _get_base_domain(self) -> str: - """Get base domain. - - Algorithm: - 1. Open named.conf.local. - 2. Get first domain. - - """ - named_local = None - - with open(NAMED_LOCAL) as file: - named_local = file.read() - - pattern = r""" - zone\s+"([^"]+)"\s*{[^}]*? - type\s+master\b[^}]*? - """ - - matches = re.search(pattern, named_local, re.DOTALL | re.VERBOSE) - - if not matches: - raise DNSDomainNotFoundError("Base domain not found") - - return matches.group(1) - - def add_zone( - self, - zone_name: str, - zone_type: str, - nameserver_ip: str | None, - params: list[DNSZoneParam], - ) -> None: - """Add a new DNS zone. - - Algorithm: - 1. Build a dictionary of zone parameters. - 2. Render the zone file and zone options templates. - 3. Process parameters (acl, forwarders, ttl, etc.) and add them - to the zone options. - 4. Write the zone options to named.conf.local. - 5. Restart the server. - - Args: - zone_name (str): Name of the DNS zone. - zone_type (str): Type of the DNS zone. - nameserver_ip (str | None): Nameserver IP address. - params (list[DNSZoneParam]): List of zone parameters. - - """ - params_dict = {param.name: param.value for param in params} - - if zone_type != DNSZoneType.FORWARD: - nameserver_ip = ( - nameserver_ip - if nameserver_ip is not None - else os.getenv("DEFAULT_NAMESERVER") - ) - nameserver = ( - self._get_base_domain() - if "in-addr.arpa" in zone_name - else zone_name - ) - - zf_template = TEMPLATES.get_template("zone.template") - zone_file = zf_template.render( - domain=zone_name, - nameserver=nameserver, - ttl=params_dict.get("ttl", 604800), - ) - - zone_error = self._check_zone(zone_file, zone_name) - if zone_error: - raise DNSZoneValidationError( - f"Error in zonefile during adding zone: {zone_error}", - ) - - with open( - os.path.join(ZONE_FILES_DIR, f"{zone_name}.zone"), - "w", - ) as file: - file.write(zone_file) - - if "in-addr.arpa" not in zone_name: - for record in [ - DNSRecord( - name=zone_name, - value=nameserver_ip, - ttl=604800, - ), - DNSRecord( - name=f"ns1.{zone_name}", - value=nameserver_ip, - ttl=604800, - ), - DNSRecord( - name=f"ns2.{zone_name}", - value="127.0.0.1", - ttl=604800, - ), - ]: - self.add_record( - record, - DNSRecordType.A, - zone_name=zone_name, - ) - - zo_template = TEMPLATES.get_template("zone_options.template") - zone_options = zo_template.render( - zone_name=zone_name, - zone_type=zone_type, - forwarders=params_dict.get("forwarders"), - ) - - for param in params: - param_name = param.name if param.name != "acl" else "allow-query" - if ( - param_name == "allow-query" - and zone_type == DNSZoneType.FORWARD - ): - continue - if isinstance(param.value, list): - param_value = "{ " + f"{'; '.join(param.value)};" + " }" - else: - param_value = param.value - - zone_options = self._add_zone_param( - zone_options, - zone_name, - param_name, - param_value, - ) - - config_error = self._check_config(zone_options) - if config_error: - raise DNSError( - f"Error with config during adding zone: {config_error}", - ) - - with open(NAMED_LOCAL, "a") as file: - file.write(zone_options) - - self.restart() - - @staticmethod - def _add_zone_param( - named_local: str, - zone_name: str, - param_name: str, - param_value: str, - ) -> str: - """Add a zone parameter to named.conf.local. - - Regex explanation: - - (zone\\s+"{zone_name}"\\s*{{[^}}]*?) - Captures the start of the zone block for the given zone_name, - including all content up to the closing '};'. - - (\\s*}};) - Captures the closing of the zone block - (with optional whitespace). - The regex is used to insert a new parameter - just before the end of the zone block. - - Algorithm: - 1. Use re.sub to add the parameter line inside the zone block. - 2. Return the modified text. - - Args: - named_local (str): Contents of named.conf.local. - zone_name (str): Name of the DNS zone. - param_name (str): Parameter name. - param_value (str): Parameter value. - - Returns: - str: Modified named.conf.local content. - - """ - pattern = rf'(zone\s+"{re.escape(zone_name)}"\s*{{[^}}]*?)(\s*}};)' - replacement = rf"\1\n {param_name} {param_value};\2" - return re.sub(pattern, replacement, named_local, flags=re.DOTALL) - - @staticmethod - def _delete_zone_param( - named_local: str, - zone_name: str, - param_name: str, - ) -> str: - """Delete a zone parameter from named.conf.local. - - Regex explanation: - - (zone\\s+"{zone_name}"\\s*{{) - Captures the start of the zone block for the given zone_name. - - (.*?) - Non-greedy match for any content up to the parameter line. - - (^\\s*{param_name}\\s+(?:[^{{;\\n}}]+|{{[^}}]+}})\\s*;\\s*\\n) - Matches the parameter line (with possible value in braces - or not), including the trailing semicolon and newline. - - (.*?}}) - Matches the rest of the zone block up to the closing brace. - The regex is used to remove the parameter line from the zone block. - - Algorithm: - 1. Use re.sub to remove the parameter line from the zone block. - 2. Return the modified text. - - Args: - named_local (str): Contents of named.conf.local. - zone_name (str): Name of the DNS zone. - param_name (str): Parameter name. - - Returns: - str: Modified named.conf.local content. - - """ - pattern = rf""" - (zone\s+"{re.escape(zone_name)}"\s*{{) - (.*?) - ^\s*{re.escape(param_name)}\s+ - (?:[^{{;\n}}]+|{{[^}}]+}}) - \s*;\s*\n - (.*?}}) - """ - - return re.sub( - pattern, - r"\1\2\3", - named_local, - flags=re.DOTALL | re.VERBOSE | re.MULTILINE, - ) - - def _update_zone_param( - self, - named_local: str, - zone_name: str, - param_name: str, - param_value: str, - ) -> str: - """Update a zone parameter in named.conf.local. - - Algorithm: - 1. Remove the old parameter value using _delete_zone_param. - 2. Add the new value using _add_zone_param. - 3. Return the modified text. - - Args: - named_local (str): Contents of named.conf.local. - zone_name (str): Name of the DNS zone. - param_name (str): Parameter name. - param_value (str): Parameter value. - - Returns: - str: Modified named.conf.local content. - - """ - new_named_local = self._delete_zone_param( - named_local, - zone_name, - param_name, - ) - return self._add_zone_param( - new_named_local, - zone_name, - param_name, - param_value, - ) - - def update_zone(self, zone_name: str, params: list[DNSZoneParam]) -> None: - """Update zone parameters. - - Regex explanation: - - ^zone\\s+"{zone_name}"\\s*{{ - Matches the start of the zone block for the given zone_name. - - [^}}]*? - Non-greedy match for any content inside the block up - to the parameter. - - \\s{param_name}\\b - Matches the parameter name as a whole word. - - \\s+(?:[^{{;\\n}}]+|{{[^}}]+}})\\s*; - Matches the parameter value (either a simple value or a block - in braces), followed by a semicolon. - This regex is used to check if the parameter exists in the zone - block. - - Algorithm: - 1. Read named.conf.local content. - 2. For each parameter, check if it exists in the zone block - using regex. - 3. If value is None, remove the parameter; otherwise, update or - add it. - 4. Write the modified config back to the file. - - Args: - zone_name (str): Name of the DNS zone. - params (list[DNSZoneParam]): List of zone parameters. - - """ - named_local = None - with open(NAMED_LOCAL) as file: - named_local = file.read() - - for param in params: - param_name = param.name if param.name != "acl" else "allow-query" - pattern = rf""" - ^zone\s+"{re.escape(zone_name)}"\s*{{ - [^}}]*? - \s{re.escape(param_name)}\b - \s+(?:[^{{;\n}}]+|{{[^}}]+}}) - \s*; - """ - has_param = bool( - re.search( - pattern, - named_local, - flags=re.MULTILINE | re.VERBOSE | re.DOTALL, - ), - ) - - if param.value is None: - named_local = self._delete_zone_param( - named_local, - zone_name, - param_name, - ) - continue - - if isinstance(param.value, list): - param_value = "{ " + f"{'; '.join(param.value)};" + " }" - else: - param_value = param.value - - if has_param: - named_local = self._update_zone_param( - named_local, - zone_name, - param_name, - param_value, - ) - else: - named_local = self._add_zone_param( - named_local, - zone_name, - param_name, - param_value, - ) - - error = self._check_config(named_local) - if error: - raise DNSZoneConfigError( - f"Error while updating zone {zone_name}: {error}", - ) - - with open(NAMED_LOCAL, "w") as file: - file.write(named_local) - - self.restart() - - def delete_zone(self, zone_name: str) -> None: - """Delete an existing zone. - - Regex explanation: - - ^\\s*zone\\s+"{zone_name}"\\s*{{ - Matches the start of the zone block for the given zone_name. - - (?:[^{{}}]|{{(?:[^{{}}]|{{[^}}]*}})*}})*? - Non-greedy match for any content inside the block, including - nested braces. - - \\s*}};\\s* - Matches the closing of the zone block (with optional - whitespace). - This regex is used to remove the entire zone block from the config. - - Algorithm: - 1. Read named.conf.local content. - 2. Determine the zone type. - 3. Remove the zone block using regex. - 4. If not a forward zone, remove the zone file. - 5. Restart the server. - - Args: - zone_name (str): Name of the DNS zone. - - """ - named_local = None - with open(NAMED_LOCAL) as file: - named_local = file.read() - - zone_type = self.get_zone_type_by_zone_name(zone_name) - - pattern = rf""" - ^\s*zone\s+"{re.escape(zone_name)}"\s*{{ - (?: - [^{{}}] - | - {{(?:[^{{}}]|{{[^}}]*}})*}} - )*? - \s*}};\s* - """ - named_local = re.sub( - pattern, - "", - named_local, - flags=re.MULTILINE | re.VERBOSE | re.DOTALL, - ) - - error = self._check_config(named_local) - if error: - raise DNSZoneConfigError( - f"Error while deleting zone {zone_name}: {error}", - ) - - with open(NAMED_LOCAL, "w") as file: - file.write(named_local) - - if zone_type != DNSZoneType.FORWARD: - with contextlib.suppress(FileNotFoundError): - os.remove(os.path.join(ZONE_FILES_DIR, f"{zone_name}.zone")) - - self.restart() - - def reload(self, zone_name: str | None = None) -> None: - """Reload a zone by name or all zones if no name is provided. - - Algorithm: - 1. Call rndc reload with the zone name or without it. - - Args: - zone_name (str | None): Name of the DNS zone or None. - - """ - subprocess.run( # noqa: S603 - [ - "/usr/sbin/rndc", - "reload", - zone_name if zone_name else "", - ], - ) - - def restart(self) -> None: - """Restart the Bind9 server (reconfig). - - Algorithm: - 1. Call rndc reconfig. - """ - subprocess.run( # noqa: S603 - [ - "/usr/sbin/rndc", - "reconfig", - ], - ) - - def first_setup(self, zone_name: str) -> str: - """Perform initial setup of the Bind9 server. - - Algorithm: - 1. Create a master zone. - 2. Add standard SRV records for services (ldap, kerberos, etc.). - - Args: - zone_name (str): Name of the DNS zone. - - """ - self.add_zone( - zone_name, - "master", - None, - params=[], - ) - - self.add_record( - DNSRecord( - name=f"gc._msdcs.{zone_name}", - value=os.getenv("DEFAULT_NAMESERVER"), - ttl=604800, - ), - DNSRecordType.A, - zone_name, - ) - - for record in FIRST_SETUP_RECORDS: - self.add_record( - DNSRecord( - name=f"{record.get('name')}{zone_name}", - value=f"{record.get('value')}{zone_name}.", - ttl=604800, - ), - record.get("type"), - zone_name, - ) - - @staticmethod - def get_zone_type_by_zone_name(zone_name: str) -> DNSZoneType: - """Get the zone type by zone name. - - Regex explanation: - - zone\\s+"{zone_name}"\\s*{{\\s*type\\s*([^;]+); - Matches the zone block for the given zone_name and captures - the type value after 'type'. - The first capturing group contains the zone type - (e.g., master, forward). - - Algorithm: - 1. Read named.conf.local content. - 2. Use regex to find the zone block and extract the type. - - Args: - zone_name (str): Name of the DNS zone. - - Returns: - DNSZoneType: Zone type. - - """ - with open(NAMED_LOCAL) as file: - named_local_settings = file.read() - - pattern = rf'zone\s*"{re.escape(zone_name)}"\s*{{\s*type\s*([^;]+);' - match = re.search(pattern, named_local_settings) - if not match: - raise DNSZoneNotFoundError(f"Zone not found: {zone_name}") - return DNSZoneType(match.group(1).strip()) - - def get_all_records_from_zone( - self, - zone_name: str, - ) -> DNSRecords: - """Get all records from a zone by name. - - Algorithm: - 1. Load the zone object. - 2. Iterate over all rdata and group by type. - 3. Return a list of DNSRecords by type. - - Args: - zone_name (str): Name of the DNS zone. - - Returns: - list[DNSRecords]: List of DNSRecords grouped by type. - - """ - result: defaultdict[str, list] = defaultdict(list) - - zone = self._get_zone_obj_by_zone_name(zone_name) - for name, ttl, rdata in zone.iterate_rdatas(): - record_type = rdata.rdtype.name - - result[record_type].append( - DNSRecord( - name=name.to_text(), - value=rdata.to_text(), - ttl=ttl, - ), - ) - - return [ - DNSRecords(type=record_type, records=records) - for record_type, records in result.items() - ] - - def get_all_records(self) -> list[DNSZone]: - """Get all records from all zones. - - Algorithm: - 1. Scan the directory for zone files. - 2. For each file, determine the zone name and type. - 3. Get all records for the zone. - 4. Return a list of DNSZone objects. - - Returns: - list[DNSZone]: List of DNSZone objects. - - """ - zone_files = os.listdir(ZONE_FILES_DIR) - - result: list[DNSZone] = [] - for file in zone_files: - if file.split(".")[-1] != "zone": - continue - zone_name = ".".join(file.split(".")[:-1]) - zone_type = self.get_zone_type_by_zone_name(zone_name) - zone_records = self.get_all_records_from_zone( - zone_name, - ) - result.append( - DNSZone( - name=zone_name, - type=zone_type, - records=zone_records, - ), - ) - - return result - - async def get_forward_zones(self) -> list[DNSForwardZone]: - """Get all forward DNS zones. - - Regex explanation: - - zone\\s+"([^"]+)"\\s*{{ - Captures the zone name. - - [^}}]*?type\\s+forward\\b[^}}]*? - Matches content up to the 'type forward' declaration. - - forwarders\\s*{{([^}}]+)}} - Captures the content inside the forwarders block - (list of forwarder IPs). - The first group is the zone name, - the second group is the forwarders list. - - Algorithm: - 1. Read named.conf.local content. - 2. Use regex to find forward zone blocks and their forwarders. - 3. Return a list of DNSForwardZone objects. - - Returns: - list[DNSForwardZone]: List of forward zones. - - """ - named_local = None - with open(NAMED_LOCAL) as file: - named_local = file.read() - - pattern = r""" - zone\s+"([^"]+)"\s*{[^}]*? - type\s+forward\b[^}]*? - forwarders\s*{([^}]+)} - """ - - matches = re.findall(pattern, named_local, re.DOTALL | re.VERBOSE) - - result = [] - for zone_name, forwarders in matches: - clean_forwarders = [ - forwarder.strip() - for forwarder in forwarders.split(";") - if forwarder.strip() - ] - result.append( - DNSForwardZone( - zone_name, - DNSZoneType.FORWARD, - clean_forwarders, - ), - ) - - return result - - def add_record( - self, - record: DNSRecord, - record_type: DNSRecordType, - zone_name: str, - ) -> None: - """Add a DNS record to a zone. - - Algorithm: - 1. Load the zone object. - 2. Build rdata by type and value. - 3. Add rdata to the rdataset. - 4. Save changes to the zone file and reload the zone. - - Args: - record (DNSRecord): DNS record to add. - record_type (DNSRecordType): Type of the DNS record. - zone_name (str): Name of the DNS zone. - - """ - zone = self._get_zone_obj_by_zone_name(zone_name) - - record_name = dns.name.from_text(record.name) - rdata = dns.rdata.from_text( - dns.rdataclass.IN, - dns.rdatatype.from_text(record_type), - record.value, - ) - - zone.find_rdataset(record_name, rdata.rdtype, create=True).add( - rdata, - ttl=record.ttl, - ) - - self._write_zone_data_to_file(zone_name, zone) - - def delete_record( - self, - record: DNSRecord, - record_type: DNSRecordType, - zone_name: str, - ) -> None: - """Delete a record from a zone. - - Algorithm: - 1. Load the zone object. - 2. Find the rdataset by name and type. - 3. If rdata is present, remove it from the rdataset. - 4. Save changes to the zone file and reload the zone. - - Args: - record (DNSRecord): DNS record to delete. - record_type (DNSRecordType): Type of the DNS record. - zone_name (str): Name of the DNS zone. - - """ - zone = self._get_zone_obj_by_zone_name(zone_name) - name = dns.name.from_text(record.name) - rdatatype = dns.rdatatype.from_text(record_type) - rdata = dns.rdata.from_text( - dns.rdataclass.IN, - rdatatype, - record.value, - ) - - if name in zone.nodes: - node = zone.nodes[name] - rdataset = node.get_rdataset(dns.rdataclass.IN, rdatatype) - if rdataset and rdata in rdataset: - rdataset.remove(rdata) - - self._write_zone_data_to_file(zone_name, zone) - - def update_record( - self, - old_record: DNSRecord, - new_record: DNSRecord, - record_type, - zone_name, - ) -> None: - """Update a record in a zone (value or TTL). - - Algorithm: - 1. Delete the old record. - 2. Add the new record with updated values. - - Args: - old_record (DNSRecord): Old DNS record. - new_record (DNSRecord): New DNS record. - record_type: Type of the DNS record. - zone_name (str): Name of the DNS zone. - - """ - self.delete_record(old_record, record_type, zone_name) - self.add_record(new_record, record_type, zone_name) - - @staticmethod - def _add_new_server_param( - named_options: str, - param_name: str, - param_value: str, - ) -> str: - """Add a new parameter to the options block in named.conf.options. - - Regex explanation: - - (options\\s*\\{{[\\s\\S]*?) - Captures the start of the options block and all its content - up to the closing '};'. - - (\\s*\\}};) - Captures the closing of the options block - (with optional whitespace). - The regex is used to insert a new parameter just before the end of - the options block. - - Algorithm: - 1. Use re.sub to add the parameter line inside the options block. - 2. Return the modified text. - - Args: - named_options (str): Contents of named.conf.options. - param_name (str): Parameter name. - param_value (str): Parameter value. - - Returns: - str: Modified named.conf.options content. - - """ - return re.sub( - r"(options\s*\{[\s\S]*?)(\s*\};)", - rf"\1 {param_name} {param_value};\2", - named_options, - flags=re.DOTALL, - ) - - def update_dns_settings(self, settings: list[DNSServerParam]) -> None: - """Update or add DNS server parameters. - - Regex explanation: - - \\b{param_name}\\s+ - Matches the parameter name as a whole word, - followed by whitespace. - - ([^;\\n{{]+|{{[^}}]+}}) - Captures the parameter value, which can be a simple value or - a block in braces. - The first capturing group contains the parameter value. - - Algorithm: - 1. Read named.conf.options content. - 2. For each parameter, search for it using regex. - 3. If not found, add it; otherwise, update it. - 4. Write the modified config back to the file. - - Args: - settings (list[DNSServerParam]): List of server parameters. - - """ - named_options = None - - with open(NAMED_OPTIONS) as file: - named_options = file.read() - - for param in settings: - if isinstance(param.value, list): - param_value = "{ " + f"{'; '.join(param.value)};" + " }" - else: - param_value = param.value - pattern = rf"\b{re.escape(param.name)}\s+([^;\n{{]+|{{[^}}]+}})" - matched_param = re.search( - pattern, - named_options, - flags=re.MULTILINE, - ) - if matched_param is None: - named_options = self._add_new_server_param( - named_options, - param.name, - param_value, - ) - else: - named_options = re.sub( - pattern, - f"{param.name} {param_value}", - named_options, - ) - - error = self._check_config(named_options) - if error: - raise DNSZoneConfigError( - f"Error while updating DNS settings: {error}", - ) - - with open(NAMED_OPTIONS, "w") as file: - file.write(named_options) - - self.restart() - - @staticmethod - def get_server_settings() -> list[DNSServerParam]: - """Get a list of modifiable DNS server settings. - - Regex explanation: - - \\b{param_name}\\s+ - Matches the parameter name as a whole word, - followed by whitespace. - - ([^;\\n{{]+|{{[^}}]+}}) - Captures the parameter value, which can be a simple value or - a block in braces. - The first capturing group contains the parameter value. - - Algorithm: - 1. Read named.conf.options content. - 2. For each parameter in DNSServerParamName, - search for its value using regex. - 3. Return a list of DNSServerParam objects. - - Returns: - list[DNSServerParam]: List of server parameters. - - """ - named_options = None - with open(NAMED_OPTIONS) as file: - named_options = file.read() - - result = [] - for param_name in DNSServerParamName: - pattern = rf"\b{re.escape(param_name)}\s+([^;\n{{]+|{{[^}}]+}})" - matched_param_value = re.search(pattern, named_options) - if not matched_param_value: - continue - result.append( - DNSServerParam( - name=param_name, - value=matched_param_value.group(1).strip(), - ), - ) - - return result - - -async def get_dns_manager() -> type[BindDNSServerManager]: - """Get DNS server manager client.""" - return BindDNSServerManager() - - -zone_router = APIRouter(prefix="/zone", tags=["zone"]) -record_router = APIRouter(prefix="/record", tags=["record"]) -server_router = APIRouter(prefix="/server", tags=["server"]) - - -@zone_router.post("") -def create_zone( - data: DNSZoneCreateRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Create DNS zone.""" - dns_manager.add_zone( - data.zone_name, - data.zone_type, - data.nameserver, - data.params, - ) - - -@zone_router.patch("") -def update_zone( - data: DNSZoneUpdateRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Update DNS zone settings.""" - dns_manager.update_zone(data.zone_name, data.params) - - -@zone_router.delete("") -def delete_zone( - data: DNSZoneDeleteRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Delete DNS zone.""" - dns_manager.delete_zone(data.zone_name) - - -@zone_router.get("") -async def get_all_records_by_zone( - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> list[DNSZone]: - """Get all DNS records grouped by zone.""" - return dns_manager.get_all_records() - - -@zone_router.get("/forward") -async def get_forward_zones( - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> list[DNSForwardZone]: - """Get all forward DNS zones.""" - return await dns_manager.get_forward_zones() - - -@record_router.post("") -def create_record( - data: DNSRecordCreateRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Create DNS record in given zone.""" - dns_manager.add_record( - DNSRecord( - data.record_name, - data.record_value, - data.ttl, - ), - data.record_type, - data.zone_name, - ) - - -@record_router.patch("") -def update_record( - data: DNSRecordUpdateRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Update existing DNS record.""" - dns_manager.update_record( - old_record=DNSRecord( - data.record_name, - data.record_value, - 0, - ), - new_record=DNSRecord( - data.record_name, - data.record_value, - data.ttl, - ), - record_type=data.record_type, - zone_name=data.zone_name, - ) - - -@record_router.delete("") -def delete_record( - data: DNSRecordDeleteRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Delete existing DNS record.""" - dns_manager.delete_record( - DNSRecord( - data.record_name, - data.record_value, - 0, - ), - data.record_type, - data.zone_name, - ) - - -@server_router.get("/restart") -def restart_dns_server( - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Restart DNS server via reconfig.""" - dns_manager.restart() - - -@zone_router.get("/reload/{zone_name}") -def reload_zone( - zone_name: str, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Force reload DNS zone from zone file.""" - dns_manager.reload(zone_name) - - -@server_router.patch("/settings") -def update_dns_server_settings( - settings: list[DNSServerParam], - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Update settings of DNS server.""" - dns_manager.update_dns_settings(settings) - - -@server_router.get("/settings") -async def get_server_settings( - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> list[DNSServerParam]: - """Get list of modifiable server settings.""" - return dns_manager.get_server_settings() - - -@server_router.post("/setup") -def setup_server( - data: DNSServerSetupRequest, - dns_manager: Annotated[BindDNSServerManager, Depends(get_dns_manager)], -) -> None: - """Init setup of DNS server.""" - dns_manager.first_setup(data.zone_name) - - -async def handle_dns_error( - request: Request, # noqa: ARG001 - exc: Exception, -) -> NoReturn: - """Handle DNS API error.""" - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) - - -def create_app() -> FastAPI: - """Create FastAPI app.""" - app = FastAPI( - name="DNSServerManager", - title="DNSServerManager", - ) - - app.include_router(record_router) - app.include_router(zone_router) - app.include_router(server_router) - - app.add_exception_handler(DNSError, handler=handle_dns_error) - - return app diff --git a/.dns/entrypoint.sh b/.dns/entrypoint.sh deleted file mode 100755 index 25e0891d9..000000000 --- a/.dns/entrypoint.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -fix_rndc_key() { - local rndc_key="/etc/bind/rndc.key" - if [ -f "$rndc_key" ]; then - chown bind:bind "$rndc_key" 2>/dev/null || chown 100:101 "$rndc_key" 2>/dev/null || true - chmod 640 "$rndc_key" 2>/dev/null || true - fi -} - -/usr/local/bin/docker-entrypoint.sh & - -fix_rndc_key - -/venvs/.venv/bin/python3.13 -m uvicorn --factory dns_api:create_app --host 0.0.0.0 --reload & - -wait -n - -exit $? diff --git a/.dns/templates/zone.template b/.dns/templates/zone.template deleted file mode 100644 index 249ebc349..000000000 --- a/.dns/templates/zone.template +++ /dev/null @@ -1,11 +0,0 @@ -$ORIGIN . -$TTL {{ ttl }} -{{ domain }} IN SOA ns1.{{ nameserver }}. support.md.ru. ( - {{ today }}01 - 10800 - 3600 - 604800 - 21600 - ) - IN NS ns1.{{ nameserver }}. - IN NS ns2.{{ nameserver }}. diff --git a/.dns/templates/zone_options.template b/.dns/templates/zone_options.template deleted file mode 100644 index 22f20a3f3..000000000 --- a/.dns/templates/zone_options.template +++ /dev/null @@ -1,10 +0,0 @@ -zone "{{ zone_name }}" { - type {{ zone_type }}; - {%- if zone_type == "master" %} - file "/opt/{{ zone_name }}.zone"; - notify no; - {%- endif %} - {%- if zone_type == "forward" %} - forward only; - {%- endif %} -}; diff --git a/.docker/Dockerfile b/.docker/Dockerfile index b7942c3c8..253269161 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -35,7 +35,7 @@ ENV VIRTUAL_ENV=/venvs/.venv \ VERSION=${VERSION:-beta} -RUN set -eux; apk add --no-cache krb5-libs curl openssl netcat-openbsd +RUN set -eux; apk add --no-cache krb5-libs curl openssl netcat-openbsd libsodium-dev COPY app /app COPY pyproject.toml / diff --git a/.docker/bind9.Dockerfile b/.docker/bind9.Dockerfile deleted file mode 100644 index d5b8154a8..000000000 --- a/.docker/bind9.Dockerfile +++ /dev/null @@ -1,45 +0,0 @@ -FROM python:3.13-bookworm AS builder - -ENV VIRTUAL_ENV=/venvs/.venv \ - PATH="/venvs/.venv/bin:$PATH" - -WORKDIR /venvs - -RUN python -m venv .venv -RUN pip install \ - fastapi==0.115.12 \ - uvicorn==0.34.2 \ - pydantic==2.10.6 \ - jinja2==3.1.6 \ - dnspython==2.7.0 - -FROM ubuntu/bind9:latest AS runtime - -ENV LANG=C.UTF-8 \ - DEBIAN_FRONTEND=noninteractive \ - VIRTUAL_ENV=/venvs/.venv \ - PATH="/venvs/.venv/bin:$PATH" \ - PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -RUN apt update -RUN apt install -y python3.13 - -COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} - -RUN ln -sf /usr/bin/python3.13 /venvs/.venv/bin/python - -COPY .dns/ /server/ -WORKDIR /server - -RUN chown bind:bind /opt - -RUN mkdir /var/log/named && \ - touch /var/log/named/bind.log && \ - chown bind:bind /var/log/named && \ - chmod 755 /var/log/named && \ - chmod 644 /var/log/named/bind.log - -EXPOSE 8000 - -ENTRYPOINT [ "./entrypoint.sh" ] diff --git a/.docker/dev.Dockerfile b/.docker/dev.Dockerfile index 0e89ebe96..ca3ac7295 100644 --- a/.docker/dev.Dockerfile +++ b/.docker/dev.Dockerfile @@ -33,7 +33,7 @@ ENV VIRTUAL_ENV=/venvs/.venv \ PATH="/venvs/.venv/bin:$PATH" \ VERSION=${VERSION:-beta} -RUN set -eux; apk add --no-cache krb5-libs curl openssl netcat-openbsd +RUN set -eux; apk add --no-cache krb5-libs curl openssl netcat-openbsd libsodium-dev COPY app /app COPY pyproject.toml / diff --git a/.docker/pdns_auth.Dockerfile b/.docker/pdns_auth.Dockerfile new file mode 100644 index 000000000..6298932ff --- /dev/null +++ b/.docker/pdns_auth.Dockerfile @@ -0,0 +1,66 @@ +FROM alpine:3.20 AS builder + +RUN apk add --no-cache --virtual .build-deps \ + build-base \ + lmdb-dev \ + openssl-dev \ + boost-dev \ + autoconf automake libtool \ + git ragel bison flex \ + lua5.4-dev \ + curl-dev + +RUN apk add --no-cache \ + lua \ + lua-dev \ + lmdb \ + boost-libs \ + openssl-libs-static \ + curl \ + libstdc++ + +RUN git clone https://github.com/PowerDNS/pdns.git /pdns +WORKDIR /pdns + +RUN git submodule init &&\ + git submodule update &&\ + git checkout auth-5.0.1 + +RUN autoreconf -vi + +RUN mkdir /build && \ + ./configure \ + --sysconfdir=/etc/powerdns \ + --enable-option-checking=fatal \ + --with-dynmodules='lmdb' \ + --with-modules='' \ + --with-unixodbc-lib=/usr/lib/$(dpkg-architecture -q DEB_BUILD_GNU_TYPE) && \ + make clean && \ + make $MAKEFLAGS -C ext &&\ + make $MAKEFLAGS -C modules &&\ + make $MAKEFLAGS -C pdns && \ + make -C pdns install DESTDIR=/build &&\ + make -C modules install DESTDIR=/build &&\ + make clean && \ + strip /build/usr/local/bin/* /build/usr/local/sbin/* /build/usr/local/lib/pdns/*.so + +FROM alpine:3.20 AS runtime + +COPY --from=builder /build / + +RUN apk add --no-cache \ + lua \ + lua-dev \ + lmdb \ + boost-libs \ + openssl-libs-static \ + curl \ + libstdc++ + +RUN mkdir -p /etc/powerdns/pdns.d /var/run/pdns /var/lib/powerdns /etc/powerdns/templates.d /var/lib/pdns-lmdb + +COPY ./.package/pdns.conf /etc/powerdns/pdns.conf + +EXPOSE 8082/tcp + +CMD ["/usr/local/sbin/pdns_server"] \ No newline at end of file diff --git a/.docker/test.Dockerfile b/.docker/test.Dockerfile index da4c9461e..288e59eea 100644 --- a/.docker/test.Dockerfile +++ b/.docker/test.Dockerfile @@ -27,7 +27,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ FROM python:3.13.7-alpine3.21 AS runtime WORKDIR /app -RUN set -eux; apk add --no-cache openldap-clients openssl curl krb5-libs +RUN set -eux; apk add --no-cache openldap-clients openssl curl krb5-libs libsodium-dev ENV VIRTUAL_ENV=/venvs/.venv \ PATH="/venvs/.venv/bin:$PATH" \ diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index 8e2f0b4a9..de070e1c7 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -156,7 +156,7 @@ jobs: --build-arg BUILDKIT_INLINE_CACHE=1 \ --build-arg VERSION=beta - build-bind9: + build-pdns_auth: runs-on: ubuntu-latest needs: [build-tests, run-ssh-test, run-tests] steps: @@ -173,14 +173,14 @@ jobs: - name: Build docker image env: - TAG: ghcr.io/${{ env.REPO }}_bind9:beta + TAG: ghcr.io/${{ env.REPO }}_pdns_auth:beta DOCKER_BUILDKIT: '1' run: | echo $TAG docker build \ --push \ --target=runtime \ - -f .docker/bind9.Dockerfile . \ + -f .docker/pdns_auth.Dockerfile . \ -t $TAG \ --cache-to type=gha,mode=max \ --cache-from $TAG \ diff --git a/.github/workflows/build-dev.yml b/.github/workflows/build-dev.yml index 6bd6a2ce6..a02d3d70a 100644 --- a/.github/workflows/build-dev.yml +++ b/.github/workflows/build-dev.yml @@ -155,7 +155,7 @@ jobs: --build-arg BUILDKIT_INLINE_CACHE=1 \ --build-arg VERSION=dev - build-bind9: + build-pdns_auth: runs-on: ubuntu-latest needs: [build-tests, run-ssh-test, run-tests] steps: @@ -172,14 +172,14 @@ jobs: - name: Build docker image env: - TAG: ghcr.io/${{ env.REPO }}_bind9:dev + TAG: ghcr.io/${{ env.REPO }}_pdns_auth:dev DOCKER_BUILDKIT: '1' run: | echo $TAG docker build \ --push \ --target=runtime \ - -f .docker/bind9.Dockerfile . \ + -f .docker/pdns_auth.Dockerfile . \ -t $TAG \ --cache-to type=gha,mode=max \ --cache-from $TAG \ diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index a607b3c7a..367f4e0bc 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -176,7 +176,7 @@ jobs: --build-arg BUILDKIT_INLINE_CACHE=1 \ --build-arg VERSION=latest - build-bind9: + build-pdns_auth: runs-on: ubuntu-latest needs: [build-tests, run-ssh-test, run-tests] steps: @@ -193,14 +193,14 @@ jobs: - name: Build docker image env: - TAG: ghcr.io/${{ env.REPO }}_bind9:latest + TAG: ghcr.io/${{ env.REPO }}_pdns_auth:latest DOCKER_BUILDKIT: '1' run: | echo $TAG docker build \ --push \ --target=runtime \ - -f .docker/bind9.Dockerfile . \ + -f .docker/pdns_auth.Dockerfile . \ -t $TAG \ --cache-to type=gha,mode=max \ --cache-from $TAG \ diff --git a/.gitignore b/.gitignore index 9c09001ca..11596e8f9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST +resolve.conf # PyInstaller # Usually these files are written by a python script from a template diff --git a/.package/dnsdist.conf b/.package/dnsdist.conf new file mode 100644 index 000000000..c74ab876e --- /dev/null +++ b/.package/dnsdist.conf @@ -0,0 +1,6 @@ +setLocal('0.0.0.0:53') +controlSocket('0.0.0.0:8084') +setKey('supersecretapikey') +addConsoleACL('172.20.0.0/24') +includeDirectory('/etc/dnsdist/conf.d/') +setACL('0.0.0.0/0') diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index dc84570e6..3a21fb2a3 100644 --- a/.package/docker-compose.yml +++ b/.package/docker-compose.yml @@ -5,6 +5,8 @@ services: traefik: image: "mirror.gcr.io/traefik:v3.6.1" container_name: traefik + networks: + md_net: restart: unless-stopped command: - "--providers.file.filename=/traefik.yml" @@ -42,6 +44,8 @@ services: traefik_certs_dumper: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} container_name: traefik_certs_dumper + networks: + md_net: restart: "on-failure" env_file: .env @@ -56,6 +60,8 @@ services: interface: image: ghcr.io/multidirectorylab/multidirectory-web-admin:${VERSION:-latest} container_name: multidirectory_interface + networks: + md_net: restart: unless-stopped hostname: interface environment: @@ -83,6 +89,8 @@ services: ldap_server: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} + networks: + md_net: restart: unless-stopped hostname: multidirectory-ldap env_file: @@ -131,6 +139,8 @@ services: cldap_server: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} + networks: + md_net: restart: unless-stopped environment: - SERVICE_NAME=cldap_server @@ -166,6 +176,8 @@ services: cpus: "0.25" memory: 100M image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} + networks: + md_net: restart: unless-stopped environment: - SERVICE_NAME=global_ldap_server @@ -206,6 +218,8 @@ services: api_server: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} container_name: multidirectory_api + networks: + md_net: restart: unless-stopped env_file: .env @@ -238,6 +252,8 @@ services: postgres: container_name: MD-postgres + networks: + md_net: image: mirror.gcr.io/postgres:16 restart: unless-stopped env_file: @@ -259,6 +275,8 @@ services: cert_check: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} container_name: multidirectory_certs_check + networks: + md_net: restart: "no" volumes: - ./certs:/certs @@ -267,6 +285,8 @@ services: maintence: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} container_name: md_maintence + networks: + md_net: restart: unless-stopped volumes: - ./certs:/certs @@ -291,6 +311,8 @@ services: kdc: container_name: kdc + networks: + md_net: restart: unless-stopped hostname: kerberos volumes: @@ -307,6 +329,8 @@ services: kadmin_api: image: ghcr.io/multidirectorylab/multidirectory_kerberos:${VERSION:-latest} container_name: kadmin_api + networks: + md_net: restart: unless-stopped volumes: - ./certs:/certs @@ -321,9 +345,12 @@ services: condition: service_started working_dir: /server command: ./entrypoint.sh + kadmind: image: ghcr.io/multidirectorylab/multidirectory_kerberos:${VERSION:-latest} container_name: kadmind + networks: + md_net: restart: unless-stopped hostname: kerberos volumes: @@ -346,27 +373,52 @@ services: - traefik.tcp.routers.kpasswd.service=kpasswd - traefik.tcp.services.kpasswd.loadbalancer.server.port=464 - bind_dns: - image: ghcr.io/multidirectorylab/multidirectory_bind9:${VERSION:-latest} - container_name: bind9 - hostname: bind9 - restart: unless-stopped + pdns_auth: + image: ghcr.io/multidirectorylab/multidirectory_pdns_auth:${VERSION:-latest} + container_name: pdns_auth + networks: + default: + md_net: + ipv4_address: 172.20.0.202 + expose: + - 8082 + - 53/udp + - 53/tcp volumes: - - dns_server_file:/opt/ - - dns_server_config:/etc/bind/ - tty: true - env_file: - - .env - environment: - - USE_CONFIG_FILE_LOGGING=true - depends_on: - ldap_server: - condition: service_healthy - restart: true - labels: - - traefik.enable=true - - traefik.udp.routers.bind_dns_udp.entrypoints=bind_dns_udp - - traefik.udp.services.bind_dns_udp.loadbalancer.server.port=53 + - dns_lmdb:/var/lib/pdns-lmdb + - dns_config:/etc/powerdns + + + pdns_recursor: + image: powerdns/pdns-recursor-51:5.1.7 + container_name: pdns_recursor + networks: + default: + md_net: + ipv4_address: 172.20.0.200 + expose: + - 8083 + - 53/udp + - 53/tcp + volumes: + - ./recursor.conf:/etc/powerdns/recursor.conf + - forward_zones:/etc/powerdns/recursor.d/ + + pdnsdist: + image: powerdns/dnsdist-19:1.9.11 + container_name: pdnsdist + networks: + default: + md_net: + ipv4_address: 172.20.0.201 + expose: + - 8084 + ports: + - "53:53/tcp" + - "53:53/udp" + volumes: + - ./dnsdist.conf:/etc/dnsdist/dnsdist.conf + - dnsdist_confd:/etc/dnsdist/conf.d kea_dhcp4: image: ghcr.io/multidirectorylab/multidirectory_dhcp4:${VERSION:-latest} @@ -388,6 +440,8 @@ services: kea_ctrl_agent: image: jonasal/kea-ctrl-agent:3.1.2-alpine container_name: kea_ctrl_agent + networks: + md_net: restart: unless-stopped command: -c /kea/config/kea-ctrl-agent.conf tty: true @@ -402,6 +456,8 @@ services: dragonfly_mem: image: 'docker.dragonflydb.io/dragonflydb/dragonfly' container_name: dragonfly + networks: + md_net: restart: unless-stopped volumes: - dragonflydata:/data @@ -423,6 +479,8 @@ services: shadow_api: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} container_name: shadow_api + networks: + md_net: restart: unless-stopped tty: true volumes: @@ -439,6 +497,8 @@ services: event_handler: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} container_name: event_handler + networks: + md_net: restart: unless-stopped tty: true env_file: @@ -453,6 +513,8 @@ services: event_sender: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} container_name: event_sender + networks: + md_net: restart: unless-stopped tty: true depends_on: @@ -466,6 +528,14 @@ services: environment: HANDLER_NAME: event_sender-1 +networks: + md_net: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/24 + gateway: 172.20.0.1 + volumes: postgres: kdc: @@ -477,3 +547,7 @@ volumes: leases: sockets: dhcp: + dns_lmdb: + dns_config: + forward_zones: + dnsdist_confd: diff --git a/.package/pdns.conf b/.package/pdns.conf new file mode 100644 index 000000000..80635dc2b --- /dev/null +++ b/.package/pdns.conf @@ -0,0 +1,11 @@ +launch=lmdb +lmdb-filename=/var/lib/pdns-lmdb/pdns.lmdb +daemon=no +local-address=0.0.0.0 +local-port=53 +api=yes +api-key=supersecretapikey +webserver-allow-from=0.0.0.0/0 +webserver=yes +webserver-address=0.0.0.0 +webserver-port=8082 diff --git a/.package/recursor.conf b/.package/recursor.conf new file mode 100644 index 000000000..a47cacc50 --- /dev/null +++ b/.package/recursor.conf @@ -0,0 +1,10 @@ +local-address=0.0.0.0 +webserver-allow-from=0.0.0.0/0 +forward-zones-recurse=.=1.1.1.1;8.8.8.8 +forward-zones= +api-config-dir=/etc/powerdns/recursor.d/ +include-dir=/etc/powerdns/recursor.d/ +webserver=yes +webserver-address=0.0.0.0 +webserver-port=8083 +api-key=supersecretapikey diff --git a/.package/resolv.conf b/.package/resolv.conf new file mode 100644 index 000000000..a151b09ed --- /dev/null +++ b/.package/resolv.conf @@ -0,0 +1,16 @@ +# +# macOS Notice +# +# This file is not consulted for DNS hostname resolution, address +# resolution, or the DNS query routing mechanism used by most +# processes on this system. +# +# To view the DNS configuration used by this system, use: +# scutil --dns +# +# SEE ALSO +# dns-sd(1), scutil(8) +# +# This file is automatically generated. +# +nameserver 192.168.68.1 diff --git a/.package/setup.bat b/.package/setup.bat index dec9e7cc0..d11fa32ec 100644 --- a/.package/setup.bat +++ b/.package/setup.bat @@ -116,6 +116,28 @@ if not exist "certs" ( echo Directory already exists: certs ) +:: 9. DNS_API_KEY +findstr /b /i /c:"PDNS_API_KEY=" .env >nul +if errorlevel 1 ( + set "chars=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + set "pdns_key=" + for /L %%i in (1,1,16) do ( + set /a "rand=!random! %% 62" + for %%j in (!rand!) do set "pdns_key=!pdns_key!!chars:~%%j,1!" + ) + powershell -Command "(gc .\\pdns.conf) -replace supersecretapikey, %pdns_key% | sc .\\pdns.conf -Enc UTF8" + powershell -Command "(gc .\\recursor.conf) -replace supersecretapikey, %pdns_key% | sc .\\recursor.conf -Enc UTF8" + echo PDNS_API_KEY=!pdns_key!>> .env +) + +:: 10. DNSDIST_API_KEY +findstr /b /i /c:"PDNS_DIST_KEY=" .env >nul +if errorlevel 1 ( + for /f %%i in ('powershell -command "[Convert]::ToBase64String((1..32|%%{[byte](Get-Random -Max 256)}))"') do set "randkey=%%i" + powershell -Command "(gc .\\dnsdist.conf) -replace supersecretapikey, %randkey% | sc .\\dnsdist.conf -Enc UTF8" + echo PDNS_DIST_KEY=!randkey!>> .env +) + :: 9. HOST_MACHINE_NAME findstr /b /i /c:"HOST_MACHINE_NAME=" .env >nul if errorlevel 1 ( diff --git a/.package/setup.sh b/.package/setup.sh index 8d6cfefbf..7f6545a19 100755 --- a/.package/setup.sh +++ b/.package/setup.sh @@ -80,6 +80,21 @@ else echo "Directory already exists: certs" fi +# DNS_API_KEY +if ! get_env_var "PDNS_API_KEY"; then + dns_api_key=$(openssl rand -hex 16) + sed -i "s|supersecretapikey|${dns_api_key}|g" recursor.conf + sed -i "s|supersecretapikey|${dns_api_key}|g" pdns.conf + add_env_var "PDNS_API_KEY" "$dns_api_key" +fi + +# DNSDIST_API_KEY +if ! get_env_var "PDNS_DIST_KEY"; then + dnsdist_key=$(openssl rand -base64 32) + sed -i "s|supersecretapikey|${dnsdist_key}|g" dnsdist.conf + add_env_var "PDNS_DIST_KEY" "$dnsdist_key" +fi + # HOST_MACHINE_NAME if ! get_env_var "HOST_MACHINE_NAME"; then host_machine_name=$(hostname) diff --git a/.package/traefik.yml b/.package/traefik.yml index 7d89de9fb..e672aafd7 100644 --- a/.package/traefik.yml +++ b/.package/traefik.yml @@ -46,8 +46,6 @@ entryPoints: address: ":749" kpasswd: address: ":464" - bind_dns_udp: - address: ":53/udp" websecure: address: ":443" http: diff --git a/app/api/__init__.py b/app/api/__init__.py index 69f1e8f37..ab5235198 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -9,8 +9,8 @@ from .auth.router_mfa import mfa_router from .auth.session_router import session_router from .dhcp.router import dhcp_router +from .dns.router import dns_router from .ldap_schema.entity_type_router import ldap_schema_router -from .main.dns_router import dns_router from .main.krb5_router import krb5_router from .main.router import entry_router from .network.router import network_router diff --git a/app/api/dns/__init__.py b/app/api/dns/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/api/dns/adapter.py b/app/api/dns/adapter.py new file mode 100644 index 000000000..3514e9a80 --- /dev/null +++ b/app/api/dns/adapter.py @@ -0,0 +1,202 @@ +"""DNS adapter. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from api.base_adapter import BaseAdapter +from api.dns.schema import ( + DNSServiceForwardZoneCheckRequest, + DNSServiceForwardZoneRequest, + DNSServiceMasterZoneRequest, + DNSServiceRecordCreateRequest, + DNSServiceRecordDeleteRequest, + DNSServiceRecordUpdateRequest, + DNSServiceSetStateRequest, + DNSServiceSetupRequest, + DNSServiceZoneDeleteRequest, +) +from ldap_protocol.dns.dto import ( + DNSForwardServerStatus, + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRecordDTO, + DNSRRSetDTO, + DNSSettingsDTO, +) +from ldap_protocol.dns.enums import DNSRecordType +from ldap_protocol.dns.use_cases import DNSUseCase + + +class DNSFastAPIAdapter(BaseAdapter[DNSUseCase]): + """DNS adapter.""" + + async def create_record( + self, + zone_id: str, + data: DNSServiceRecordCreateRequest, + ) -> None: + """Create DNS record.""" + await self._service.create_record( + zone_id, + DNSRRSetDTO( + name=data.record_name, + type=DNSRecordType(data.record_type), + records=[ + DNSRecordDTO( + content=data.record_value, + disabled=False, + ), + ], + ttl=data.ttl, + ), + ) + + async def delete_record( + self, + zone_id: str, + data: DNSServiceRecordDeleteRequest, + ) -> None: + """Delete DNS record.""" + await self._service.delete_record( + zone_id, + DNSRRSetDTO( + name=data.record_name, + type=data.record_type, + records=[ + DNSRecordDTO( + content=data.record_value, + disabled=False, + ), + ], + ), + ) + + async def update_record( + self, + zone_id: str, + data: DNSServiceRecordUpdateRequest, + ) -> None: + """Update DNS record.""" + await self._service.update_record( + zone_id, + DNSRRSetDTO( + name=data.record_name, + type=data.record_type, + records=[ + DNSRecordDTO( + content=data.record_value, + disabled=False, + ), + ], + ttl=data.ttl, + ), + ) + + async def get_records(self, zone_id: str) -> list[DNSRRSetDTO]: + """Get all DNS records of current zone.""" + return await self._service.get_records(zone_id) + + async def get_status(self) -> dict[str, str | None]: + """Get DNS service status.""" + return await self._service.get_status() + + async def set_state( + self, + data: DNSServiceSetStateRequest, + ) -> None: + """Set DNS manager state.""" + await self._service.set_state(data.state) + + async def setup(self, data: DNSServiceSetupRequest | None) -> None: + await self._service.setup( + DNSSettingsDTO( + dns_server_ip=data.dns_ip_address, + tsig_key=data.tsig_key, + domain=data.domain, + default_nameserver=str(data.dns_ip_address), + ) + if data is not None + else data, + ) + + async def create_forward_zone( + self, + data: DNSServiceForwardZoneRequest, + ) -> None: + """Create new DNS forward zone.""" + await self._service.create_forward_zone( + DNSForwardZoneDTO( + id=data.zone_name, + name=data.zone_name, + servers=data.servers, + ), + ) + + async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: + """Get list of DNS forward zones with forwarders.""" + return await self._service.get_forward_zones() + + async def update_forward_zone( + self, + data: DNSServiceForwardZoneRequest, + ) -> None: + """Update DNS forward zone with given params.""" + await self._service.update_forward_zone( + DNSForwardZoneDTO( + id=data.zone_name, + name=data.zone_name, + servers=data.servers, + ), + ) + + async def delete_forward_zones( + self, + data: DNSServiceZoneDeleteRequest, + ) -> None: + """Delete DNS forward zones.""" + await self._service.delete_forward_zones(data.zone_ids) + + async def create_master_zone( + self, + data: DNSServiceMasterZoneRequest, + ) -> None: + """Create new DNS zone.""" + await self._service.create_master_zone( + DNSMasterZoneDTO( + id=data.zone_name, + name=data.zone_name, + dnssec=data.dnssec, + ), + ) + + async def get_master_zones(self) -> list[DNSMasterZoneDTO]: + """Get all DNS master zones.""" + return await self._service.get_master_zones() + + async def update_master_zone( + self, + data: DNSServiceMasterZoneRequest, + ) -> None: + """Update DNS zone with given params.""" + await self._service.update_master_zone( + DNSMasterZoneDTO( + id=data.zone_name, + name=data.zone_name, + dnssec=data.dnssec, + ), + ) + + async def delete_master_zones( + self, + data: DNSServiceZoneDeleteRequest, + ) -> None: + """Delete DNS zones.""" + await self._service.delete_master_zones(data.zone_ids) + + async def check_forward_zone( + self, + data: DNSServiceForwardZoneCheckRequest, + ) -> list[DNSForwardServerStatus]: + """Check DNS forward zone for availability.""" + return await self._service.check_forward_zone(data.dns_server_ips) diff --git a/app/api/main/dns_router.py b/app/api/dns/router.py similarity index 65% rename from app/api/main/dns_router.py rename to app/api/dns/router.py index bf3e83e40..7f4bad5cb 100644 --- a/app/api/main/dns_router.py +++ b/app/api/dns/router.py @@ -12,31 +12,30 @@ import ldap_protocol.dns.exceptions as dns_exc from api.auth.utils import verify_auth -from api.error_routing import ( - ERROR_MAP_TYPE, - DishkaErrorAwareRoute, - DomainErrorTranslator, -) -from api.main.adapters.dns import DNSFastAPIAdapter -from api.main.schema import ( +from api.dns.adapter import DNSFastAPIAdapter +from api.dns.schema import ( DNSServiceForwardZoneCheckRequest, + DNSServiceForwardZoneRequest, + DNSServiceMasterZoneRequest, DNSServiceRecordCreateRequest, DNSServiceRecordDeleteRequest, DNSServiceRecordUpdateRequest, - DNSServiceReloadZoneRequest, + DNSServiceSetStateRequest, DNSServiceSetupRequest, - DNSServiceZoneCreateRequest, DNSServiceZoneDeleteRequest, - DNSServiceZoneUpdateRequest, +) +from api.error_routing import ( + ERROR_MAP_TYPE, + DishkaErrorAwareRoute, + DomainErrorTranslator, ) from api.utils import require_master_db from enums import DomainCodes from ldap_protocol.dns import ( DNSForwardServerStatus, - DNSForwardZone, - DNSRecords, - DNSServerParam, - DNSZone, + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRRSetDTO, ) translator = DomainErrorTranslator(DomainCodes.DNS) @@ -51,6 +50,10 @@ status=status.HTTP_400_BAD_REQUEST, translator=translator, ), + dns_exc.DNSRecordGetError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), dns_exc.DNSRecordUpdateError: rule( status=status.HTTP_400_BAD_REQUEST, translator=translator, @@ -63,6 +66,10 @@ status=status.HTTP_400_BAD_REQUEST, translator=translator, ), + dns_exc.DNSZoneGetError: rule( + status=status.HTTP_400_BAD_REQUEST, + translator=translator, + ), dns_exc.DNSZoneUpdateError: rule( status=status.HTTP_400_BAD_REQUEST, translator=translator, @@ -91,45 +98,49 @@ dns_router = ErrorAwareRouter( prefix="/dns", - tags=["DNS_SERVICE"], + tags=["DNS Service"], dependencies=[Depends(verify_auth)], route_class=DishkaErrorAwareRoute, ) -@dns_router.post("/record", error_map=error_map) +@dns_router.post("/record/{zone_id}", error_map=error_map) async def create_record( + zone_id: str, data: DNSServiceRecordCreateRequest, adapter: FromDishka[DNSFastAPIAdapter], ) -> None: """Create DNS record with given params.""" - await adapter.create_record(data) + await adapter.create_record(zone_id, data) -@dns_router.delete("/record", error_map=error_map) -async def delete_single_record( - data: DNSServiceRecordDeleteRequest, +@dns_router.get("/record/{zone_id}", error_map=error_map) +async def get_all_records( + zone_id: str, adapter: FromDishka[DNSFastAPIAdapter], -) -> None: - """Delete DNS record with given params.""" - await adapter.delete_record(data) +) -> list[DNSRRSetDTO]: + """Get all DNS records of current zone.""" + return await adapter.get_records(zone_id) -@dns_router.patch("/record", error_map=error_map) +@dns_router.patch("/record/{zone_id}", error_map=error_map) async def update_record( + zone_id: str, data: DNSServiceRecordUpdateRequest, adapter: FromDishka[DNSFastAPIAdapter], ) -> None: """Update DNS record with given params.""" - await adapter.update_record(data) + await adapter.update_record(zone_id, data) -@dns_router.get("/record", error_map=error_map) -async def get_all_records( +@dns_router.delete("/record/{zone_id}", error_map=error_map) +async def delete_single_record( + zone_id: str, + data: DNSServiceRecordDeleteRequest, adapter: FromDishka[DNSFastAPIAdapter], -) -> list[DNSRecords]: - """Get all DNS records of current zone.""" - return await adapter.get_all_records() +) -> None: + """Delete DNS record with given params.""" + await adapter.delete_record(zone_id, data) @dns_router.get("/status", error_map=error_map) @@ -137,7 +148,7 @@ async def get_dns_status( adapter: FromDishka[DNSFastAPIAdapter], ) -> dict[str, str | None]: """Get DNS service status.""" - return await adapter.get_dns_status() + return await adapter.get_status() @dns_router.post( @@ -146,27 +157,59 @@ async def get_dns_status( dependencies=[Depends(require_master_db)], ) async def setup_dns( - data: DNSServiceSetupRequest, adapter: FromDishka[DNSFastAPIAdapter], + data: DNSServiceSetupRequest | None = None, ) -> None: """Set up DNS service.""" - await adapter.setup_dns(data) + await adapter.setup(data) -@dns_router.get("/zone", error_map=error_map) -async def get_dns_zone( +@dns_router.post( + "/state", + error_map=error_map, + dependencies=[Depends(require_master_db)], +) +async def set_dns_state( + data: DNSServiceSetStateRequest, adapter: FromDishka[DNSFastAPIAdapter], -) -> list[DNSZone]: - """Get all DNS records of all zones.""" - return await adapter.get_dns_zone() +) -> None: + """Set DNS manager state.""" + await adapter.set_state(data) + + +@dns_router.post("/zone/forward", error_map=error_map) +async def create_forward_zone( + data: DNSServiceForwardZoneRequest, + adapter: FromDishka[DNSFastAPIAdapter], +) -> None: + """Create new forward DNS zone.""" + return await adapter.create_forward_zone(data) @dns_router.get("/zone/forward", error_map=error_map) async def get_forward_dns_zones( adapter: FromDishka[DNSFastAPIAdapter], -) -> list[DNSForwardZone]: +) -> list[DNSForwardZoneDTO]: """Get list of DNS forward zones with forwarders.""" - return await adapter.get_forward_dns_zones() + return await adapter.get_forward_zones() + + +@dns_router.patch("/zone/forward", error_map=error_map) +async def update_forward_zone( + data: DNSServiceForwardZoneRequest, + adapter: FromDishka[DNSFastAPIAdapter], +) -> None: + """Update forward DNS zone with given params.""" + await adapter.update_forward_zone(data) + + +@dns_router.delete("/zone/forward", error_map=error_map) +async def delete_forward_zone( + data: DNSServiceZoneDeleteRequest, + adapter: FromDishka[DNSFastAPIAdapter], +) -> None: + """Delete DNS forward zone.""" + await adapter.delete_forward_zones(data) @dns_router.post( @@ -175,30 +218,38 @@ async def get_forward_dns_zones( warn_on_unmapped=False, default_client_error_translator=translator, ) -async def create_zone( - data: DNSServiceZoneCreateRequest, +async def create_master_zone( + data: DNSServiceMasterZoneRequest, adapter: FromDishka[DNSFastAPIAdapter], ) -> None: """Create new DNS zone.""" - await adapter.create_zone(data) + await adapter.create_master_zone(data) + + +@dns_router.get("/zone", error_map=error_map) +async def get_dns_zones( + adapter: FromDishka[DNSFastAPIAdapter], +) -> list[DNSMasterZoneDTO]: + """Get all DNS records of all zones.""" + return await adapter.get_master_zones() @dns_router.patch("/zone", error_map=error_map) -async def update_zone( - data: DNSServiceZoneUpdateRequest, +async def update_master_zone( + data: DNSServiceMasterZoneRequest, adapter: FromDishka[DNSFastAPIAdapter], ) -> None: """Update DNS zone with given params.""" - await adapter.update_zone(data) + await adapter.update_master_zone(data) @dns_router.delete("/zone", error_map=error_map) -async def delete_zone( +async def delete_master_zone( data: DNSServiceZoneDeleteRequest, adapter: FromDishka[DNSFastAPIAdapter], ) -> None: """Delete DNS zone.""" - await adapter.delete_zone(data) + await adapter.delete_master_zones(data) @dns_router.post("/forward_check", error_map=error_map) @@ -207,38 +258,4 @@ async def check_dns_forward_zone( adapter: FromDishka[DNSFastAPIAdapter], ) -> list[DNSForwardServerStatus]: """Check given DNS forward zone for availability.""" - return await adapter.check_dns_forward_zone(data) - - -@dns_router.get("/zone/reload/", error_map=error_map) -async def reload_zone( - data: DNSServiceReloadZoneRequest, - adapter: FromDishka[DNSFastAPIAdapter], -) -> None: - """Reload given DNS zone.""" - await adapter.reload_zone(data) - - -@dns_router.patch("/server/options") -async def update_server_options( - data: list[DNSServerParam], - adapter: FromDishka[DNSFastAPIAdapter], -) -> None: - """Update DNS server options.""" - await adapter.update_server_options(data) - - -@dns_router.get("/server/options") -async def get_server_options( - adapter: FromDishka[DNSFastAPIAdapter], -) -> list[DNSServerParam]: - """Get list of modifiable DNS server params.""" - return await adapter.get_server_options() - - -@dns_router.get("/server/restart") -async def restart_server( - adapter: FromDishka[DNSFastAPIAdapter], -) -> None: - """Restart entire DNS server.""" - await adapter.restart_server() + return await adapter.check_forward_zone(data) diff --git a/app/api/dns/schema.py b/app/api/dns/schema.py new file mode 100644 index 000000000..1cc595580 --- /dev/null +++ b/app/api/dns/schema.py @@ -0,0 +1,79 @@ +"""Schemas for DNS router. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from ipaddress import IPv4Address, IPv6Address + +from pydantic import BaseModel + +from ldap_protocol.dns import DNSManagerState, DNSRecordType + + +class DNSServiceSetStateRequest(BaseModel): + """DNS set state request schema.""" + + state: DNSManagerState + + +class DNSServiceSetupRequest(BaseModel): + """DNS setup request schema.""" + + domain: str + dns_ip_address: IPv4Address | IPv6Address | None = None + tsig_key: str | None = None + + +class DNSServiceRecordBaseRequest(BaseModel): + """DNS setup base schema.""" + + record_name: str + record_type: DNSRecordType + + +class DNSServiceRecordCreateRequest(DNSServiceRecordBaseRequest): + """DNS create request schema.""" + + record_value: str + ttl: int | None = None + + +class DNSServiceRecordDeleteRequest(DNSServiceRecordBaseRequest): + """DNS delete request schema.""" + + record_value: str + + +class DNSServiceRecordUpdateRequest(DNSServiceRecordBaseRequest): + """DNS update request schema.""" + + record_value: str + ttl: int | None = None + + +class DNSServiceForwardZoneRequest(BaseModel): + """DNS zone create request scheme.""" + + zone_name: str + servers: list[str] + + +class DNSServiceMasterZoneRequest(BaseModel): + """DNS zone create request scheme.""" + + zone_name: str + nameserver_ip: str + dnssec: bool = False + + +class DNSServiceZoneDeleteRequest(BaseModel): + """DNS zone delete request scheme.""" + + zone_ids: list[str] + + +class DNSServiceForwardZoneCheckRequest(BaseModel): + """Forwarder DNS server check request scheme.""" + + dns_server_ips: list[IPv4Address | IPv6Address] diff --git a/app/api/main/adapters/dns.py b/app/api/main/adapters/dns.py deleted file mode 100644 index 352099fad..000000000 --- a/app/api/main/adapters/dns.py +++ /dev/null @@ -1,138 +0,0 @@ -"""DNS adapter. - -Copyright (c) 2025 MultiFactor -License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE -""" - -from api.base_adapter import BaseAdapter -from api.main.schema import ( - DNSServiceForwardZoneCheckRequest, - DNSServiceRecordCreateRequest, - DNSServiceRecordDeleteRequest, - DNSServiceRecordUpdateRequest, - DNSServiceReloadZoneRequest, - DNSServiceSetupRequest, - DNSServiceZoneCreateRequest, - DNSServiceZoneDeleteRequest, - DNSServiceZoneUpdateRequest, -) -from ldap_protocol.dns.base import ( - DNSForwardServerStatus, - DNSForwardZone, - DNSRecords, - DNSServerParam, - DNSZone, -) -from ldap_protocol.dns.use_cases import DNSUseCase - - -class DNSFastAPIAdapter(BaseAdapter[DNSUseCase]): - """DNS adapter.""" - - async def create_record( - self, - data: DNSServiceRecordCreateRequest, - ) -> None: - """Create DNS record.""" - await self._service.create_record( - data.record_name, - data.record_value, - data.record_type, - data.ttl, - data.zone_name, - ) - - async def delete_record( - self, - data: DNSServiceRecordDeleteRequest, - ) -> None: - """Delete DNS record.""" - await self._service.delete_record( - data.record_name, - data.record_value, - data.record_type, - data.zone_name, - ) - - async def update_record( - self, - data: DNSServiceRecordUpdateRequest, - ) -> None: - """Update DNS record.""" - await self._service.update_record( - data.record_name, - data.record_value, - data.record_type, - data.ttl, - data.zone_name, - ) - - async def get_all_records(self) -> list[DNSRecords]: - """Get all DNS records of current zone.""" - return await self._service.get_all_records() - - async def get_dns_status(self) -> dict[str, str | None]: - """Get DNS service status.""" - return await self._service.get_dns_status() - - async def setup_dns(self, data: DNSServiceSetupRequest) -> None: - await self._service.setup_dns( - dns_status=data.dns_status, - domain=data.domain, - dns_ip_address=data.dns_ip_address, - tsig_key=data.tsig_key, - ) - - async def get_dns_zone(self) -> list[DNSZone]: - """Get all DNS zones.""" - return await self._service.get_all_zones_records() - - async def get_forward_dns_zones(self) -> list[DNSForwardZone]: - """Get list of DNS forward zones with forwarders.""" - return await self._service.get_forward_zones() - - async def create_zone(self, data: DNSServiceZoneCreateRequest) -> None: - """Create new DNS zone.""" - await self._service.create_zone( - data.zone_name, - data.zone_type, - data.nameserver, - data.params, - ) - - async def update_zone(self, data: DNSServiceZoneUpdateRequest) -> None: - """Update DNS zone with given params.""" - await self._service.update_zone( - data.zone_name, - data.params, - ) - - async def delete_zone(self, data: DNSServiceZoneDeleteRequest) -> None: - """Delete DNS zone.""" - await self._service.delete_zone(data.zone_names) - - async def check_dns_forward_zone( - self, - data: DNSServiceForwardZoneCheckRequest, - ) -> list[DNSForwardServerStatus]: - """Check DNS forward zone for availability.""" - return await self._service.check_dns_forward_zone(data.dns_server_ips) - - async def reload_zone(self, data: DNSServiceReloadZoneRequest) -> None: - """Reload DNS zone.""" - await self._service.reload_zone(data.zone_name) - - async def update_server_options( - self, - data: list[DNSServerParam], - ) -> None: - """Update DNS server options.""" - await self._service.update_server_options(data) - - async def get_server_options(self) -> list[DNSServerParam]: - """Get list of modifiable DNS server params.""" - return await self._service.get_server_options() - - async def restart_server(self) -> None: - """Restart DNS server.""" - await self._service.restart_server() diff --git a/app/api/main/schema.py b/app/api/main/schema.py index 59e103e7f..5ea6545a8 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 ipaddress import IPv4Address, IPv6Address from typing import final from dishka import AsyncContainer @@ -12,7 +11,6 @@ from sqlalchemy.sql.elements import ColumnElement, UnaryExpression from entities import Directory -from ldap_protocol.dns import DNSManagerState, DNSZoneParam, DNSZoneType from ldap_protocol.filter_interpreter import ( Filter, FilterInterpreterProtocol, @@ -94,84 +92,6 @@ class ModifyPrincipalRequest(BaseModel): password: str | None = None -class DNSServiceSetupRequest(BaseModel): - """DNS setup request schema.""" - - dns_status: DNSManagerState - domain: str - dns_ip_address: IPv4Address | IPv6Address | None = None - tsig_key: str | None = None - - -class DNSServiceRecordBaseRequest(BaseModel): - """DNS setup base schema.""" - - record_name: str - record_type: str - zone_name: str | None = None - - -class DNSServiceRecordCreateRequest(DNSServiceRecordBaseRequest): - """DNS create request schema.""" - - record_value: str - ttl: int | None = None - - -class DNSServiceRecordDeleteRequest(DNSServiceRecordBaseRequest): - """DNS delete request schema.""" - - record_value: str - - -class DNSServiceRecordUpdateRequest(DNSServiceRecordBaseRequest): - """DNS update request schema.""" - - record_value: str | None = None - ttl: int | None = None - - -class DNSServiceZoneCreateRequest(BaseModel): - """DNS zone create request scheme.""" - - zone_name: str - zone_type: DNSZoneType - nameserver: str | None = None - params: list[DNSZoneParam] - - -class DNSServiceZoneUpdateRequest(BaseModel): - """DNS zone update request scheme.""" - - zone_name: str - params: list[DNSZoneParam] - - -class DNSServiceZoneDeleteRequest(BaseModel): - """DNS zone delete request scheme.""" - - zone_names: list[str] - - -class DNSServiceReloadZoneRequest(BaseModel): - """DNS zone reload request scheme.""" - - zone_name: str - - -class DNSServiceForwardZoneCheckRequest(BaseModel): - """Forwarder DNS server check request scheme.""" - - dns_server_ips: list[IPv4Address | IPv6Address] - - -class DNSServiceOptionsUpdateRequest(BaseModel): - """DNS server options update request scheme.""" - - name: str - value: str | list[str] = "" - - class PrimaryGroupRequest(BaseModel): """Request schema for setting primary group.""" diff --git a/app/config.py b/app/config.py index 16f710b1b..67550187e 100644 --- a/app/config.py +++ b/app/config.py @@ -48,7 +48,6 @@ class Settings(BaseModel): GLOBAL_LDAP_TLS_PORT: int = 3269 USE_CORE_TLS: bool = False LDAP_LOAD_SSL_CERT: bool = False - DEFAULT_NAMESERVER: str TCP_PACKET_SIZE: int = 1024 COROUTINES_NUM_PER_CLIENT: int = 3 @@ -190,7 +189,18 @@ def replica_engine(self) -> AsyncEngine | None: autoescape=True, ) - DNS_BIND_HOST: str = "bind_dns" + PDNS_AUTH_SERVER_HOST: str = "pdns_auth" + PDNS_AUTH_SERVER_IP: str = "172.20.0.202" + PDNS_AUTH_SERVER_PORT: int = 8082 + PDNS_RECURSOR_SERVER_HOST: str = "pdns_recursor" + PDNS_RECURSOR_SERVER_IP: str = "172.20.0.200" + PDNS_RECURSOR_SERVER_PORT: int = 8083 + PDNS_DIST_IP: str = "172.20.0.201" + PDNS_DIST_PORT: int = 8084 + PDNS_DIST_CONFIG_PATH: str = "/dnsdist/delta.conf" + PDNS_DIST_KEY: str + PDNS_API_KEY: str + DEFAULT_NAMESERVER: str ENABLE_SQLALCHEMY_LOGGING: bool = False PYTEST_XDIST_WORKER: str = "master" diff --git a/app/enums.py b/app/enums.py index 9cf7f6c61..2c991d9f4 100644 --- a/app/enums.py +++ b/app/enums.py @@ -178,16 +178,15 @@ class AuthorizationRules(IntFlag): DNS_UPDATE_RECORD = auto() DNS_GET_ALL_RECORDS = auto() DNS_GET_DNS_STATUS = auto() - DNS_GET_ALL_ZONES_RECORDS = auto() - DNS_GET_FORWARD_ZONES = auto() - DNS_CREATE_ZONE = auto() - DNS_UPDATE_ZONE = auto() - DNS_DELETE_ZONE = auto() + DNS_GET_MASTER_ZONES = auto() + DNS_GET_FWD_ZONES = auto() + DNS_DELETE_MASTER_ZONES = auto() + DNS_DELETE_FWD_ZONES = auto() + DNS_CREATE_MASTER_ZONE = auto() + DNS_CREATE_FWD_ZONE = auto() + DNS_UPDATE_MASTER_ZONE = auto() + DNS_UPDATE_FWD_ZONE = auto() DNS_CHECK_DNS_FORWARD_ZONE = auto() - DNS_RELOAD_ZONE = auto() - DNS_UPDATE_SERVER_OPTIONS = auto() - DNS_GET_SERVER_OPTIONS = auto() - DNS_RESTART_SERVER = auto() KRB_SETUP_CATALOGUE = auto() KRB_SETUP_KDC = auto() diff --git a/app/ioc.py b/app/ioc.py index acd6c43d1..5673a37ba 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -26,10 +26,10 @@ ) from api.auth.utils import get_ip_from_request, get_user_agent_from_request from api.dhcp.adapter import DHCPAdapter +from api.dns.adapter import DNSFastAPIAdapter from api.ldap_schema.adapters.attribute_type import AttributeTypeFastAPIAdapter from api.ldap_schema.adapters.entity_type import LDAPEntityTypeFastAPIAdapter from api.ldap_schema.adapters.object_class import ObjectClassFastAPIAdapter -from api.main.adapters.dns import DNSFastAPIAdapter from api.main.adapters.kerberos import KerberosFastAPIAdapter from api.network.adapters.network import NetworkPolicyFastAPIAdapter from api.password_policy.adapter import ( @@ -54,12 +54,17 @@ from ldap_protocol.dialogue import LDAPSession from ldap_protocol.dns import ( AbstractDNSManager, - DNSManagerSettings, - get_dns_manager_class, + DNSManagerState, + DNSSettingsDTO, + DNSStateGateway, + DNSUseCase, + PowerDNSAuthHTTPClient, + PowerDNSDistClient, + PowerDNSManager, + PowerDNSRecursorHTTPClient, + RemoteDNSManager, + StubDNSManager, ) -from ldap_protocol.dns.dns_gateway import DNSStateGateway -from ldap_protocol.dns.use_cases import DNSUseCase -from ldap_protocol.dns.utils import resolve_dns_server_ip from ldap_protocol.identity import IdentityProvider from ldap_protocol.identity.provider_gateway import IdentityProviderGateway from ldap_protocol.kerberos import AbstractKadmin, get_kerberos_class @@ -159,7 +164,6 @@ SessionStorageClient = NewType("SessionStorageClient", redis.Redis) KadminHTTPClient = NewType("KadminHTTPClient", httpx.AsyncClient) -DNSManagerHTTPClient = NewType("DNSManagerHTTPClient", httpx.AsyncClient) MFAHTTPClient = NewType("MFAHTTPClient", httpx.AsyncClient) DHCPManagerHTTPClient = NewType("DHCPManagerHTTPClient", httpx.AsyncClient) @@ -251,14 +255,6 @@ def get_kadmin( """ return kadmin_class(client) - @provide(scope=Scope.REQUEST) - async def get_dns_mngr_class( - self, - dns_state_gateway: DNSStateGateway, - ) -> type[AbstractDNSManager]: - """Get DNS manager type.""" - return await get_dns_manager_class(dns_state_gateway) - @provide(scope=Scope.REQUEST, provides=AuthorizationProviderProtocol) async def get_auth_provider_class( self, @@ -266,40 +262,77 @@ async def get_auth_provider_class( """Get AuthorizationProvider.""" return None - @provide(scope=Scope.REQUEST) - async def get_dns_mngr_settings( + @provide(scope=Scope.APP) + async def get_power_dns_auth_http_client( self, settings: Settings, - dns_state_gateway: DNSStateGateway, - ) -> DNSManagerSettings: - """Get DNS manager's settings.""" - resolve_coro = resolve_dns_server_ip( - settings.DNS_BIND_HOST, - ) - return await dns_state_gateway.get_dns_manager_settings( - resolve_coro, - ) + ) -> AsyncIterator[PowerDNSAuthHTTPClient]: + """Get PowerDNS Auth server client.""" + async with httpx.AsyncClient( + base_url=f"http://{settings.PDNS_AUTH_SERVER_HOST}:{settings.PDNS_AUTH_SERVER_PORT}/api/v1/servers/localhost", + headers={"X-API-Key": settings.PDNS_API_KEY}, + ) as client: + yield PowerDNSAuthHTTPClient(http_client=client) @provide(scope=Scope.APP) - async def get_dns_http_client( + async def get_power_dns_recursor_http_client( self, settings: Settings, - ) -> AsyncIterator[DNSManagerHTTPClient]: - """Get async client for DNS manager.""" + ) -> AsyncIterator[PowerDNSRecursorHTTPClient]: + """Get PowerDNS Auth server client.""" async with httpx.AsyncClient( - base_url=f"http://{settings.DNS_BIND_HOST}:8000", + base_url=f"http://{settings.PDNS_RECURSOR_SERVER_HOST}:{settings.PDNS_RECURSOR_SERVER_PORT}/api/v1/servers/localhost", + headers={"X-API-Key": settings.PDNS_API_KEY}, ) as client: - yield DNSManagerHTTPClient(client) + yield PowerDNSRecursorHTTPClient(http_client=client) + + @provide(scope=Scope.APP) + def get_power_dns_dist_client( + self, + settings: Settings, + ) -> PowerDNSDistClient: + """Get PowerDNS dist client.""" + return PowerDNSDistClient( + dnsdist_host=settings.PDNS_DIST_IP, + dnsdist_port=settings.PDNS_DIST_PORT, + dnsdist_key=settings.PDNS_DIST_KEY, + config_path=settings.PDNS_DIST_CONFIG_PATH, + ) @provide(scope=Scope.REQUEST) - def get_dns_mngr( + async def get_dns_mngr_settings( self, - settings: DNSManagerSettings, - dns_manager_class: type[AbstractDNSManager], - http_client: DNSManagerHTTPClient, - ) -> AbstractDNSManager: + dns_state_gateway: DNSStateGateway, + settings: Settings, + ) -> AsyncIterator[DNSSettingsDTO]: + """Get DNS manager's settings.""" + dns_settings = await dns_state_gateway.get_dns_manager_settings( + settings, + ) + yield dns_settings + + @provide(scope=Scope.REQUEST) + async def get_dns_mngr( + self, + dns_settings: DNSSettingsDTO, + dns_state_gateway: DNSStateGateway, + power_dns_auth_client: PowerDNSAuthHTTPClient, + power_dns_recursor_client: PowerDNSRecursorHTTPClient, + power_dns_dist_client: PowerDNSDistClient, + ) -> AsyncIterator[AbstractDNSManager]: """Get DNSManager class.""" - return dns_manager_class(settings=settings, http_client=http_client) + state = await dns_state_gateway.get_state() + if state == DNSManagerState.SELFHOSTED: + yield PowerDNSManager( + settings=dns_settings, + power_dns_auth_client=power_dns_auth_client, + power_dns_recursor_client=power_dns_recursor_client, + dnsdist_client=power_dns_dist_client, + ) + elif state == DNSManagerState.HOSTED: + yield RemoteDNSManager(settings=dns_settings) + else: + yield StubDNSManager(settings=dns_settings) @provide(scope=Scope.APP) async def get_redis_for_sessions( @@ -494,10 +527,10 @@ def get_dhcp_mngr( ace_dao = provide(AccessControlEntryDAO, scope=Scope.REQUEST) role_use_case = provide(RoleUseCase, scope=Scope.REQUEST) session_repository = provide(SessionRepository, scope=Scope.REQUEST) - entity_type_use_case = provide(EntityTypeUseCase, scope=Scope.REQUEST) dns_use_case = provide(DNSUseCase, scope=Scope.REQUEST) dns_state_gateway = provide(DNSStateGateway, scope=Scope.REQUEST) + rootdse_gw = provide( SADomainGateway, provides=DomainReadProtocol, diff --git a/app/ldap_protocol/dns/__init__.py b/app/ldap_protocol/dns/__init__.py index f9c97fba7..f647092a3 100644 --- a/app/ldap_protocol/dns/__init__.py +++ b/app/ldap_protocol/dns/__init__.py @@ -1,61 +1,64 @@ -from .base import ( +from ldap_protocol.dns.clients import ( + PowerDNSAuthHTTPClient, + PowerDNSDistClient, + PowerDNSRecursorHTTPClient, +) +from ldap_protocol.dns.constants import ( DNS_MANAGER_IP_ADDRESS_NAME, DNS_MANAGER_STATE_NAME, DNS_MANAGER_ZONE_NAME, - AbstractDNSManager, +) +from ldap_protocol.dns.dns_gateway import DNSStateGateway +from ldap_protocol.dns.dto import ( DNSForwardServerStatus, - DNSForwardZone, - DNSManagerSettings, + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRRSetDTO, + DNSSettingsDTO, + PowerDNSSettingsDTO, +) +from ldap_protocol.dns.enums import ( DNSManagerState, + DNSRecordType, + PowerDNSZoneType, +) +from ldap_protocol.dns.exceptions import ( + DNSConnectionError, + DNSError, DNSNotImplementedError, - DNSRecords, - DNSServerParam, - DNSServerParamName, - DNSZone, - DNSZoneParam, - DNSZoneParamName, - DNSZoneType, ) -from .dns_gateway import DNSStateGateway -from .exceptions import DNSConnectionError, DNSError -from .remote import RemoteDNSManager -from .selfhosted import SelfHostedDNSManager -from .stub import StubDNSManager - - -async def get_dns_manager_class( - dns_state_gateway: DNSStateGateway, -) -> type[AbstractDNSManager]: - """Get DNS manager class.""" - dns_state = await dns_state_gateway.get_dns_state() - if dns_state == DNSManagerState.SELFHOSTED: - return SelfHostedDNSManager - elif dns_state == DNSManagerState.HOSTED: - return RemoteDNSManager - return StubDNSManager - +from ldap_protocol.dns.managers import ( + AbstractDNSManager, + PowerDNSManager, + RemoteDNSManager, + StubDNSManager, +) +from ldap_protocol.dns.use_cases import DNSUseCase __all__ = [ "get_dns_manager_class", + "DNSUseCase", "AbstractDNSManager", + "PowerDNSManager", + "PowerDNSAuthHTTPClient", + "PowerDNSRecursorHTTPClient", + "PowerDNSDistClient", "RemoteDNSManager", - "SelfHostedDNSManager", "StubDNSManager", "DNSStateGateway", "DNSForwardServerStatus", - "DNSForwardZone", - "DNSManagerSettings", - "DNSRecords", - "DNSServerParam", - "DNSZone", - "DNSZoneParam", - "DNSZoneType", - "DNSServerParamName", - "DNSZoneParamName", - "DNSConnectionError", + "DNSForwardZoneDTO", + "DNSSettingsDTO", + "PowerDNSSettingsDTO", + "DNSRRSetDTO", + "DNSMasterZoneDTO", + "PowerDNSZoneType", + "DNSRecordType", + "DNSManagerState", "DNS_MANAGER_IP_ADDRESS_NAME", "DNS_MANAGER_ZONE_NAME", "DNS_MANAGER_STATE_NAME", "DNSNotImplementedError", "DNSError", + "DNSConnectionError", ] diff --git a/app/ldap_protocol/dns/base.py b/app/ldap_protocol/dns/base.py deleted file mode 100644 index a323a7e0c..000000000 --- a/app/ldap_protocol/dns/base.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Abstract DNS service for DNS server managing. - -Copyright (c) 2024 MultiFactor -License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE -""" - -from abc import abstractmethod -from dataclasses import dataclass -from enum import StrEnum -from ipaddress import IPv4Address, IPv6Address - -import httpx -from loguru import logger as loguru_logger - -from ldap_protocol.dns.dto import DNSSettingDTO - -from .exceptions import DNSNotImplementedError, DNSSetupError - -DNS_MANAGER_STATE_NAME = "DNSManagerState" -DNS_MANAGER_ZONE_NAME = "DNSManagerZoneName" -DNS_MANAGER_IP_ADDRESS_NAME = "DNSManagerIpAddress" -DNS_MANAGER_TSIG_KEY_NAME = "DNSManagerTSIGKey" -log = loguru_logger.bind(name="DNSManager") - -log.add( - "logs/dnsmanager_{time:DD-MM-YYYY}.log", - filter=lambda rec: rec["extra"].get("name") == "dnsmanager", - retention="10 days", - rotation="1d", - colorize=False, -) - - -class DNSZoneType(StrEnum): - """DNS zone types.""" - - MASTER = "master" - FORWARD = "forward" - - -class DNSForwarderServerStatus(StrEnum): - """Forwarder DNS server statuses.""" - - VALIDATED = "validated" - NOT_VALIDATED = "not validated" - NOT_FOUND = "not found" - - -class DNSRecordType(StrEnum): - """DNS record types.""" - - a = "A" - aaaa = "AAAA" - cname = "CNAME" - mx = "MX" - ns = "NS" - txt = "TXT" - soa = "SOA" - ptr = "PTR" - srv = "SRV" - - -class DNSZoneParamName(StrEnum): - """Possible DNS zone option names.""" - - acl = "acl" - forwarders = "forwarders" - ttl = "ttl" - - -class DNSServerParamName(StrEnum): - """Possible DNS server option names.""" - - dnssec = "dnssec-validation" - - -class DNSManagerState(StrEnum): - """DNSManager state enum.""" - - NOT_CONFIGURED = "0" - SELFHOSTED = "1" - HOSTED = "2" - - -@dataclass -class DNSZoneParam: - """DNS zone parameter.""" - - name: DNSZoneParamName - value: str | list[str] | None - - -@dataclass -class DNSServerParam: - """DNS zone parameter.""" - - name: DNSServerParamName - value: str | list[str] - - -@dataclass -class DNSForwardServerStatus: - """Forward DNS server status.""" - - ip: str - status: DNSForwarderServerStatus - FQDN: str | None - - -@dataclass -class DNSRecord: - """Single dns record.""" - - name: str - value: str - ttl: int - - -@dataclass -class DNSRecords: - """Grouped dns records.""" - - type: str - records: list[DNSRecord] - - -@dataclass -class DNSZone: - """DNS zone.""" - - name: str - type: DNSZoneType - records: list[DNSRecords] - - -@dataclass -class DNSForwardZone: - """DNS forward zone.""" - - name: str - type: DNSZoneType - forwarders: list[str] - - -class DNSManagerSettings: - """DNS Manager settings.""" - - zone_name: str | None - domain: str | None - dns_server_ip: str | None - tsig_key: str | None - - def __init__( - self, - zone_name: str | None, - dns_server_ip: str | None, - tsig_key: str | None, - ) -> None: - """Set settings.""" - self.zone_name = zone_name - self.domain = zone_name + "." if zone_name is not None else None - self.dns_server_ip = dns_server_ip - self.tsig_key = tsig_key - - -class AbstractDNSManager: - """Abstract DNS manager class.""" - - _dns_settings: DNSManagerSettings - _http_client: httpx.AsyncClient - - def __init__( - self, - settings: DNSManagerSettings, - http_client: httpx.AsyncClient, - ) -> None: - """Set up DNS manager.""" - self._dns_settings = settings - self._http_client = http_client - - async def setup( - self, - dns_status: str, - domain: str, - dns_ip_address: str | IPv4Address | IPv6Address | None, - tsig_key: str | None, - ) -> DNSSettingDTO: - """Set up DNS server and DNS manager.""" - try: - if ( - dns_status == DNSManagerState.SELFHOSTED - and self._http_client is not None - ): - await self._http_client.post( - "/server/setup", - json={"zone_name": domain}, - ) - tsig_key = None - return DNSSettingDTO( - zone_name=domain, - dns_server_ip=dns_ip_address, - tsig_key=tsig_key, - ) - - except Exception as e: - raise DNSSetupError(e) - - @abstractmethod - async def create_record( - self, - hostname: str, - ip: str, - record_type: str, - ttl: int | None, - zone_name: str | None = None, - ) -> None: ... - - @abstractmethod - async def update_record( - self, - hostname: str, - ip: str | None, - record_type: str, - ttl: int | None, - zone_name: str | None = None, - ) -> None: ... - - @abstractmethod - async def delete_record( - self, - hostname: str, - ip: str, - record_type: str, - zone_name: str | None = None, - ) -> None: ... - - @abstractmethod - async def get_all_records(self) -> list[DNSRecords]: ... - - @abstractmethod - async def get_all_zones_records(self) -> list[DNSZone]: - raise DNSNotImplementedError - - @abstractmethod - async def get_forward_zones(self) -> list[DNSForwardZone]: - raise DNSNotImplementedError - - @abstractmethod - async def create_zone( - self, - zone_name: str, - zone_type: DNSZoneType, - nameserver: str | None, - params: list[DNSZoneParam], - ) -> None: - raise DNSNotImplementedError - - @abstractmethod - async def update_zone( - self, - zone_name: str, - params: list[DNSZoneParam] | None, - ) -> None: - raise DNSNotImplementedError - - @abstractmethod - async def delete_zone( - self, - zone_names: list[str], - ) -> None: - raise DNSNotImplementedError - - @abstractmethod - async def check_forward_dns_server( - self, - dns_server_ip: IPv4Address | IPv6Address, - host_dns_servers: list[str], - ) -> DNSForwardServerStatus: - raise DNSNotImplementedError - - @abstractmethod - async def update_server_options( - self, - params: list[DNSServerParam], - ) -> None: - raise DNSNotImplementedError - - @abstractmethod - async def get_server_options(self) -> list[DNSServerParam]: ... - - @abstractmethod - async def restart_server( - self, - ) -> None: - raise DNSNotImplementedError - - @abstractmethod - async def reload_zone( - self, - zone_name: str, - ) -> None: - raise DNSNotImplementedError diff --git a/app/ldap_protocol/dns/clients/__init__.py b/app/ldap_protocol/dns/clients/__init__.py new file mode 100644 index 000000000..64a731025 --- /dev/null +++ b/app/ldap_protocol/dns/clients/__init__.py @@ -0,0 +1,17 @@ +from ldap_protocol.dns.clients.abstract_client import ( + AbstractDNSForwardHTTPClient, + AbstractDNSMasterHTTPClient, +) +from ldap_protocol.dns.clients.power_dns_http_clients import ( + PowerDNSAuthHTTPClient, + PowerDNSRecursorHTTPClient, +) +from ldap_protocol.dns.clients.power_dnsdist_client import PowerDNSDistClient + +__all__ = [ + "PowerDNSDistClient", + "PowerDNSAuthHTTPClient", + "PowerDNSRecursorHTTPClient", + "AbstractDNSMasterHTTPClient", + "AbstractDNSForwardHTTPClient", +] diff --git a/app/ldap_protocol/dns/clients/abstract_client.py b/app/ldap_protocol/dns/clients/abstract_client.py new file mode 100644 index 000000000..fa05a95c4 --- /dev/null +++ b/app/ldap_protocol/dns/clients/abstract_client.py @@ -0,0 +1,110 @@ +"""Abstract DNS client for DNS server managing. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from abc import abstractmethod + +import httpx +from fastapi import status + +from ldap_protocol.dns.dto import ( + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRRSetDTO, +) +from ldap_protocol.dns.exceptions import ( + DNSEntryNotFoundError, + DNSNotImplementedError, + DNSNotSupportedError, + DNSUnavailableError, + DNSValidationError, +) + + +class AbstractDNSHTTPClient: + """Abstract DNS client class.""" + + def __init__( + self, + http_client: httpx.AsyncClient, + ) -> None: + """Initialize the PowerDNS HTTP client.""" + self._http_client = http_client + + async def _validate_response(self, response: httpx.Response) -> None: + """Validate the API response.""" + match response.status_code: + case status.HTTP_400_BAD_REQUEST: + raise DNSNotSupportedError(response.text or "Bad Request") + case status.HTTP_404_NOT_FOUND: + raise DNSEntryNotFoundError(response.text or "Not Found") + case status.HTTP_422_UNPROCESSABLE_ENTITY: + raise DNSValidationError( + response.text or "Unprocessable Entity", + ) + case status.HTTP_500_INTERNAL_SERVER_ERROR: + raise DNSUnavailableError( + response.text or "Internal Server Error", + ) + + +class AbstractDNSMasterHTTPClient(AbstractDNSHTTPClient): + """Abstract DNS client for master server.""" + + @abstractmethod + async def create_record(self, record: DNSRRSetDTO) -> None: + raise DNSNotImplementedError + + @abstractmethod + async def get_records(self, zone_id: str) -> list[DNSRRSetDTO]: + raise DNSNotImplementedError + + @abstractmethod + async def update_record(self, record: DNSRRSetDTO) -> None: + raise DNSNotImplementedError + + @abstractmethod + async def delete_record(self, zone_id: str, record: DNSRRSetDTO) -> None: + raise DNSNotImplementedError + + @abstractmethod + async def create_master_zone(self, zone: DNSMasterZoneDTO) -> None: + raise DNSNotImplementedError + + @abstractmethod + async def get_master_zones(self) -> list[DNSMasterZoneDTO]: + raise DNSNotImplementedError + + @abstractmethod + async def get_master_zone_by_id(self, zone_id: str) -> DNSMasterZoneDTO: + raise DNSNotImplementedError + + @abstractmethod + async def update_master_zone(self, zone: DNSMasterZoneDTO) -> None: + raise DNSNotImplementedError + + @abstractmethod + async def delete_master_zone(self, zone_id: str) -> None: + raise DNSNotImplementedError + + +class AbstractDNSForwardHTTPClient(AbstractDNSHTTPClient): + """Abstract DNS slient for forward server.""" + + @abstractmethod + async def create_forward_zone(self, zone: DNSForwardZoneDTO) -> None: + raise DNSNotImplementedError + + @abstractmethod + async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: + raise DNSNotImplementedError + + @abstractmethod + async def update_forward_zone(self, zone: DNSForwardZoneDTO) -> None: + raise DNSNotImplementedError + + @abstractmethod + async def delete_forward_zone(self, zone_id: str) -> None: + raise DNSNotImplementedError diff --git a/app/ldap_protocol/dns/clients/power_dns_http_clients.py b/app/ldap_protocol/dns/clients/power_dns_http_clients.py new file mode 100644 index 000000000..82fc345b2 --- /dev/null +++ b/app/ldap_protocol/dns/clients/power_dns_http_clients.py @@ -0,0 +1,112 @@ +"""HTTP Client for PowerDNS servers. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from adaptix import Retort + +from ldap_protocol.dns.clients.abstract_client import AbstractDNSHTTPClient +from ldap_protocol.dns.dto import ( + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRRSetDTO, +) + +base_retort = Retort() + + +class PowerDNSAuthHTTPClient(AbstractDNSHTTPClient): + """HTTP client for PowerDNS Auth server.""" + + async def record_action(self, zone_id: str, record: DNSRRSetDTO) -> None: + """Send request to perform action on DNS record in given zone.""" + response = await self._http_client.patch( + f"/zones/{zone_id}", + json={"rrsets": [base_retort.dump(record)]}, + ) + + await self._validate_response(response) + + async def get_records(self, zone_id: str) -> list[DNSRRSetDTO]: + """Send request to get all records of given zone.""" + response = await self._http_client.get(f"/zones/{zone_id}") + await self._validate_response(response) + + zone = base_retort.load(response.json(), DNSMasterZoneDTO) + return zone.rrsets + + async def create_master_zone(self, zone: DNSMasterZoneDTO) -> None: + """Send request to create new master zone.""" + response = await self._http_client.post( + "/zones", + json=base_retort.dump(zone), + ) + await self._validate_response(response) + + async def get_master_zones(self) -> list[DNSMasterZoneDTO]: + """Send request to get all master zones.""" + response = await self._http_client.get("/zones") + await self._validate_response(response) + + return base_retort.load(response.json(), list[DNSMasterZoneDTO]) + + async def get_master_zone_by_id(self, zone_id: str) -> DNSMasterZoneDTO: + """Send request to get master zone by ID.""" + response = await self._http_client.get(f"/zones/{zone_id}") + await self._validate_response(response) + + return base_retort.load(response.json(), DNSMasterZoneDTO) + + async def update_master_zone( + self, + zone_id: str, + zone: DNSMasterZoneDTO, + ) -> None: + """Send request to update master zone with given ID.""" + response = await self._http_client.put( + f"/zones/{zone_id}", + json=base_retort.dump(zone), + ) + await self._validate_response(response) + + async def delete_master_zone(self, zone_id: str) -> None: + """Send request to delete master zone with given ID.""" + response = await self._http_client.delete(f"/zones/{zone_id}") + await self._validate_response(response) + + +class PowerDNSRecursorHTTPClient(AbstractDNSHTTPClient): + """HTTP client for PowerDNS Recursor server.""" + + async def create_forward_zone(self, zone: DNSForwardZoneDTO) -> None: + """Send request to create forward zone.""" + response = await self._http_client.post( + "/zones", + json=base_retort.dump(zone), + ) + await self._validate_response(response) + + async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: + """Send request to get all forward zones.""" + response = await self._http_client.get("/zones") + await self._validate_response(response) + + return base_retort.load(response.json(), list[DNSForwardZoneDTO]) + + async def update_forward_zone( + self, + zone_id: str, + zone: DNSForwardZoneDTO, + ) -> None: + """Send request to update forward zone with given ID.""" + response = await self._http_client.put( + f"/zones/{zone_id}", + json=base_retort.dump(zone), + ) + await self._validate_response(response) + + async def delete_forward_zone(self, zone_id: str) -> None: + """Send request to delete forward zone with given ID.""" + response = await self._http_client.delete(f"/zones/{zone_id}") + await self._validate_response(response) diff --git a/app/ldap_protocol/dns/clients/power_dnsdist_client.py b/app/ldap_protocol/dns/clients/power_dnsdist_client.py new file mode 100644 index 000000000..4e869527b --- /dev/null +++ b/app/ldap_protocol/dns/clients/power_dnsdist_client.py @@ -0,0 +1,264 @@ +"""Clinet for Power dnsdist service. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +import re +from ipaddress import IPv4Address +from typing import Literal, overload + +from dnsdist_console import Console + +from ldap_protocol.dns.dto import ( + CommandResponse, + DNSdistCommand, + DNSdistCommandsDelta, + DNSdistRulesTable, + RuleEntry, +) +from ldap_protocol.dns.enums import DNSdistCommandTypes +from ldap_protocol.dns.exceptions import DNSdistError + + +class PowerDNSDistClient: + """Client for dnsdist.""" + + def __init__( + self, + dnsdist_host: str, + dnsdist_port: int, + dnsdist_key: str, + config_path: str, + ) -> None: + self._console = Console( + host=dnsdist_host, + port=dnsdist_port, + key=dnsdist_key, + ) + self._config_path = config_path + + @overload + def _send_command( + self, + command: str, + *, + expected: Literal[DNSdistCommandTypes.GENERIC], + ) -> CommandResponse: ... + + @overload + def _send_command( + self, + command: str, + *, + expected: Literal[DNSdistCommandTypes.SHOW_RULES], + ) -> DNSdistRulesTable: ... + + @overload + def _send_command( + self, + command: str, + *, + expected: Literal[DNSdistCommandTypes.COMMANDS_DELTA], + ) -> DNSdistCommandsDelta: ... + + def _send_command( + self, + command: str, + *, + expected: DNSdistCommandTypes = DNSdistCommandTypes.GENERIC, + ) -> CommandResponse | DNSdistRulesTable | DNSdistCommandsDelta: + """Send command to dnsdist console.""" + raw: str = self._console.send_command(command) + + if expected is DNSdistCommandTypes.GENERIC: + if "error" in raw.lower() or "fail" in raw.lower(): + raise DNSdistError(f"dnsdist command error: {raw.strip()}") + return CommandResponse(message=raw.strip() or "OK") + + if expected is DNSdistCommandTypes.SHOW_RULES: + rules = [] + pattern = re.compile(r"^(\d+)\s+\d+\s+(.+?)\s{2,}(to .+)$") + for line in raw.strip().split("\n"): + if "to pool" in line and (matches := pattern.match(line)): + rules.append( + RuleEntry( + id=int(matches.group(1)), + match=matches.group(2).strip(), + action=matches.group(3).strip(), + ), + ) + return DNSdistRulesTable(rules=rules, count=len(rules)) + + if expected is DNSdistCommandTypes.COMMANDS_DELTA: + commands = [] + for command in raw.split("\n"): + commands.append(DNSdistCommand(command=command)) + return DNSdistCommandsDelta(delta=commands, count=len(commands)) + + def _get_all_rules(self) -> DNSdistRulesTable: + """Get list of all rules.""" + command = "showRules()" + return self._send_command( + command, + expected=DNSdistCommandTypes.SHOW_RULES, + ) + + def get_rule_by_match(self, match: str) -> RuleEntry | None: + """Get rule by rule match.""" + rules = self._get_all_rules() + for rule in rules.rules: + if rule.match == match: + return rule + + return None + + def add_server( + self, + server_host: str | IPv4Address, + pool: str, + ) -> None: + """Add server to dnsdist config.""" + command = f""" + newServer({{ + address = "{server_host}:53", + pool = "{pool}" + }}) + """ + self._send_command( + command, + expected=DNSdistCommandTypes.GENERIC, + ) + + self._persist_config() + + def setup_dnsdist(self, recursor_ip: str) -> None: + """Set up dnsdist with initial configuration.""" + command = f""" + newServer({{ + address = "{recursor_ip}:53", + pool = "recursor" + }}) + """ + self._send_command( + command, + expected=DNSdistCommandTypes.GENERIC, + ) + + command = """ + addAction( + AllRule(), + PoolAction("recursor") + ) + """ + self._send_command( + command, + expected=DNSdistCommandTypes.GENERIC, + ) + + def add_zone_rule(self, domain: str) -> None: + """Add rule to redirect master zone DNS requests to auth server.""" + command = f""" + addAction( + QNameRule("*.{domain}"), + PoolAction("master") + ) + """ + self._send_command( + command, + expected=DNSdistCommandTypes.GENERIC, + ) + + command = f""" + addAction( + QNameRule("{domain}"), + PoolAction("master") + ) + """ + self._send_command( + command, + expected=DNSdistCommandTypes.GENERIC, + ) + + self._deprioritize_all_match_rule() + + self._persist_config() + + def remove_zone_rule(self, domain: str) -> None: + """Remove redirect rule from dnsdist.""" + rule_matches = [ + f"qname=={domain}", + f"qname==*.{domain}", + ] + for rule_match in rule_matches: + rules = self._get_all_rules() + if not rules.count: + DNSdistError( + "Failed to delete existing rule in dnsdist: Not Found", + ) + + for rule in rules.rules: + if rule.match == rule_match: + command = f"rmRule({rule.id})" + self._send_command( + command, + expected=DNSdistCommandTypes.GENERIC, + ) + + self._persist_config() + + def _deprioritize_all_match_rule(self) -> None: + """Remove and add all matching rule to depriortitize it.""" + rule = self.get_rule_by_match("All") + if rule is not None: + command = f"rmRule({rule.id})" + self._send_command( + command, + expected=DNSdistCommandTypes.GENERIC, + ) + + command = """ + addAction( + AllRule(), + PoolAction("recursor") + ) + """ + self._send_command( + command, + expected=DNSdistCommandTypes.GENERIC, + ) + + self._persist_config() + + def _get_commands_delta(self) -> DNSdistCommandsDelta: + """Get list of commands that have not been persisted yet.""" + command = "delta()" + return self._send_command( + command, + expected=DNSdistCommandTypes.COMMANDS_DELTA, + ) + + def _save_commands_delta( + self, + commands_delta: DNSdistCommandsDelta, + ) -> None: + """Save commands delta to dnsdist config file.""" + with open(self._config_path, "a+", encoding="utf-8") as config_file: + for command in commands_delta.delta: + config_file.write(f"{command.command}\n") + + def _clear_console_history(self) -> None: + """Clear console history to delete written delta.""" + command = "clearConsoleHistory()" + self._send_command( + command, + expected=DNSdistCommandTypes.GENERIC, + ) + + def _persist_config(self) -> None: + """Persist dnsdist config to file.""" + commands_delta = self._get_commands_delta() + if commands_delta.count: + self._save_commands_delta(commands_delta) + + self._clear_console_history() diff --git a/app/ldap_protocol/dns/constants.py b/app/ldap_protocol/dns/constants.py new file mode 100644 index 000000000..45c6e1073 --- /dev/null +++ b/app/ldap_protocol/dns/constants.py @@ -0,0 +1,62 @@ +"""Constants for DNS module. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from ldap_protocol.dns.enums import DNSRecordType + +DNS_MANAGER_STATE_NAME = "DNSManagerState" +DNS_MANAGER_ZONE_NAME = "DNSManagerZoneName" +DNS_MANAGER_IP_ADDRESS_NAME = "DNSManagerIpAddress" +DNS_MANAGER_TSIG_KEY_NAME = "DNSManagerTSIGKey" + +DNS_FIRST_SETUP_RECORDS: list[dict[str, str | DNSRecordType]] = [ + {"name": "_ldap._tcp.", "value": "0 0 389 ", "type": DNSRecordType.SRV}, + {"name": "_ldaps._tcp.", "value": "0 0 636 ", "type": DNSRecordType.SRV}, + {"name": "_kerberos._tcp.", "value": "0 0 88 ", "type": DNSRecordType.SRV}, + {"name": "_kerberos._udp.", "value": "0 0 88 ", "type": DNSRecordType.SRV}, + {"name": "_kdc._tcp.", "value": "0 0 88 ", "type": DNSRecordType.SRV}, + {"name": "_kdc._udp.", "value": "0 0 88 ", "type": DNSRecordType.SRV}, + {"name": "_kpasswd._tcp.", "value": "0 0 464 ", "type": DNSRecordType.SRV}, + {"name": "_kpasswd._udp.", "value": "0 0 464 ", "type": DNSRecordType.SRV}, + # Record for PDC Emulator + { + "name": "_ldap._tcp.pdc._msdcs.", + "value": "0 100 389 ", + "type": DNSRecordType.SRV, + }, + # Records for DC Locator (for trusts) + { + "name": "_kerberos._tcp.dc._msdcs.", + "value": "0 100 88 ", + "type": DNSRecordType.SRV, + }, + { + "name": "_kerberos._tcp.Default-First-Site-Name._sites.dc._msdcs.", + "value": "0 100 88 ", + "type": DNSRecordType.SRV, + }, + { + "name": "_ldap._tcp.dc._msdcs.", + "value": "0 100 389 ", + "type": DNSRecordType.SRV, + }, + { + "name": "_ldap._tcp.Default-First-Site-Name._sites.dc._msdcs.", + "value": "0 100 389 ", + "type": DNSRecordType.SRV, + }, + # Records for Global Catalog + {"name": "_gc._tcp.", "value": "0 100 3268 ", "type": DNSRecordType.SRV}, + { + "name": "_ldap._tcp.Default-First-Site-Name._sites.gc._msdcs.", + "value": "0 100 3268 ", + "type": DNSRecordType.SRV, + }, + { + "name": "_ldap._tcp.gc._msdcs.", + "value": "0 100 3268 ", + "type": DNSRecordType.SRV, + }, +] diff --git a/app/ldap_protocol/dns/dns_gateway.py b/app/ldap_protocol/dns/dns_gateway.py index c5a525f35..068ae4c1e 100644 --- a/app/ldap_protocol/dns/dns_gateway.py +++ b/app/ldap_protocol/dns/dns_gateway.py @@ -4,21 +4,21 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from typing import Awaitable +from ipaddress import IPv4Address from sqlalchemy import case, select, update from sqlalchemy.ext.asyncio import AsyncSession +from config import Settings from entities import CatalogueSetting -from ldap_protocol.dns.base import ( +from ldap_protocol.dns.constants import ( DNS_MANAGER_IP_ADDRESS_NAME, DNS_MANAGER_STATE_NAME, DNS_MANAGER_TSIG_KEY_NAME, DNS_MANAGER_ZONE_NAME, - DNSManagerSettings, - DNSManagerState, ) -from ldap_protocol.dns.dto import DNSSettingDTO +from ldap_protocol.dns.dto import DNSSettingsDTO, PowerDNSSettingsDTO +from ldap_protocol.dns.enums import DNSManagerState from repo.pg.tables import queryable_attr as qa @@ -29,54 +29,44 @@ def __init__(self, session: AsyncSession) -> None: """Initialize DNS gateway.""" self._session = session - async def setup_dns_state( - self, - state: DNSManagerState | str, - ) -> None: - """Set up DNS server and DNS manager.""" - await self._session.execute( - update(CatalogueSetting) - .values({"value": state}) - .filter_by(name=DNS_MANAGER_STATE_NAME), - ) - async def get(self, name: str) -> CatalogueSetting | None: """Get DNS by name.""" return await self._session.scalar( - select(CatalogueSetting).filter_by(name=name), - ) + select(CatalogueSetting) + .filter_by(name=name), + ) # fmt: skip async def create(self, data: CatalogueSetting) -> None: """Create DNS.""" self._session.add(data) await self._session.commit() - async def get_dns_settings(self) -> dict[str, str]: + async def get_settings_from_db(self) -> dict[str, str]: """Get DNS managers.""" - return { - setting.name: setting.value - for setting in await self._session.scalars( - select(CatalogueSetting).filter( - qa(CatalogueSetting.name).in_( - [ - DNS_MANAGER_ZONE_NAME, - DNS_MANAGER_IP_ADDRESS_NAME, - DNS_MANAGER_TSIG_KEY_NAME, - ], + settings = await self._session.scalars( + select(CatalogueSetting) + .filter( + qa(CatalogueSetting.name).in_(( + DNS_MANAGER_ZONE_NAME, + DNS_MANAGER_IP_ADDRESS_NAME, + DNS_MANAGER_TSIG_KEY_NAME, ), ), - ) - } + ), + ) # fmt: skip + result = {setting.name: setting.value for setting in settings} + + return result async def update_settings( self, - data: DNSSettingDTO, + data: DNSSettingsDTO, ) -> None: """Update DNS settings.""" settings = [ ( qa(CatalogueSetting.name) == DNS_MANAGER_ZONE_NAME, - data.zone_name, + data.domain, ), ( qa(CatalogueSetting.name) == DNS_MANAGER_IP_ADDRESS_NAME, @@ -111,14 +101,14 @@ async def update_settings( async def create_settings( self, - data: DNSSettingDTO, + data: DNSSettingsDTO, ) -> None: """Create DNS settings.""" self._session.add_all( [ CatalogueSetting( name=DNS_MANAGER_ZONE_NAME, - value=data.zone_name or "", + value=data.domain or "", ), CatalogueSetting( name=DNS_MANAGER_IP_ADDRESS_NAME, @@ -134,22 +124,37 @@ async def create_settings( async def get_dns_manager_settings( self, - resolve_coro: Awaitable[str], - ) -> DNSManagerSettings: + app_settings: Settings, + ) -> DNSSettingsDTO: """Get DNS manager settings.""" - settings = await self.get_dns_settings() - dns_server_ip = settings.get(DNS_MANAGER_IP_ADDRESS_NAME) + power_dns_settings = PowerDNSSettingsDTO( + auth_server_ip=app_settings.PDNS_AUTH_SERVER_IP, + recursor_server_ip=app_settings.PDNS_RECURSOR_SERVER_IP, + ) + dns_settings = DNSSettingsDTO( + domain=app_settings.DOMAIN, + dns_server_ip=None, + tsig_key=None, + default_nameserver=app_settings.DEFAULT_NAMESERVER, + power_dns_settings=power_dns_settings, + ) - if await self.get_dns_state() == DNSManagerState.SELFHOSTED: - dns_server_ip = await resolve_coro + if await self.get_state() == DNSManagerState.HOSTED: + settings_from_db = await self.get_settings_from_db() + dns_settings.domain = settings_from_db.get( + DNS_MANAGER_ZONE_NAME, + "", + ) + dns_settings.dns_server_ip = IPv4Address( + settings_from_db.get(DNS_MANAGER_IP_ADDRESS_NAME), + ) + dns_settings.tsig_key = settings_from_db.get( + DNS_MANAGER_TSIG_KEY_NAME, + ) - return DNSManagerSettings( - zone_name=settings.get(DNS_MANAGER_ZONE_NAME), - dns_server_ip=dns_server_ip, - tsig_key=settings.get(DNS_MANAGER_TSIG_KEY_NAME), - ) + return dns_settings - async def get_dns_state(self) -> DNSManagerState: + async def get_state(self) -> DNSManagerState: """Get DNS state.""" state = await self.get(DNS_MANAGER_STATE_NAME) if state is None: @@ -161,3 +166,23 @@ async def get_dns_state(self) -> DNSManagerState: ) return DNSManagerState.NOT_CONFIGURED return DNSManagerState(state.value) + + async def set_state( + self, + state: DNSManagerState, + ) -> None: + """Set DNS state.""" + existing_state = await self.get(DNS_MANAGER_STATE_NAME) + if existing_state is None: + await self.create( + CatalogueSetting( + name=DNS_MANAGER_STATE_NAME, + value=state, + ), + ) + else: + await self._session.execute( + update(CatalogueSetting) + .values({"value": state}) + .filter_by(name=DNS_MANAGER_STATE_NAME), + ) diff --git a/app/ldap_protocol/dns/dto.py b/app/ldap_protocol/dns/dto.py index 8edd4d781..20596be16 100644 --- a/app/ldap_protocol/dns/dto.py +++ b/app/ldap_protocol/dns/dto.py @@ -4,14 +4,118 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from dataclasses import dataclass +from dataclasses import dataclass, field from ipaddress import IPv4Address, IPv6Address +from ldap_protocol.dns.enums import ( + DNSForwarderServerStatus, + DNSRecordType, + PowerDNSRecordChangeType, + PowerDNSZoneType, +) + + +@dataclass +class CommandResponse: + success: bool = True + message: str = " " + + +@dataclass +class RuleEntry: + id: int + match: str + action: str + + +@dataclass +class DNSdistRulesTable: + rules: list[RuleEntry] + count: int + + +@dataclass +class DNSdistCommand: + command: str + + +@dataclass +class DNSdistCommandsDelta: + delta: list[DNSdistCommand] + count: int + @dataclass -class DNSSettingDTO: - """DNS settings entity.""" +class PowerDNSSettingsDTO: + """PowerDNS related settings.""" + + auth_server_ip: str + recursor_server_ip: str - zone_name: str | None - dns_server_ip: str | IPv4Address | IPv6Address | None + +@dataclass +class DNSSettingsDTO: + """DNS settings DTO.""" + + domain: str + dns_server_ip: IPv4Address | IPv6Address | None tsig_key: str | None + default_nameserver: str + power_dns_settings: PowerDNSSettingsDTO | None = field(default=None) + + +@dataclass +class DNSRecordDTO: + """DNS record DTO.""" + + content: str + disabled: bool + modified_at: int | None = None + + +@dataclass +class DNSRRSetDTO: + """DNS RRSet(Resource Record Set) DTO.""" + + name: str + type: DNSRecordType + records: list[DNSRecordDTO] + changetype: PowerDNSRecordChangeType | None = None + ttl: int | None = None + + +@dataclass +class DNSZoneBaseDTO: + """DNS zone DTO.""" + + id: str + name: str + rrsets: list[DNSRRSetDTO] = field(default_factory=list) + type: str = "zone" + + +@dataclass +class DNSMasterZoneDTO(DNSZoneBaseDTO): + """DNS master zone DTO.""" + + dnssec: bool = field(default=False) + nameservers: list[str] = field(default_factory=list) + kind: PowerDNSZoneType = PowerDNSZoneType.MASTER + + +@dataclass +class DNSForwardZoneDTO(DNSZoneBaseDTO): + """DNS forward zone DTO.""" + + servers: list[str] = field(default_factory=list) + recursion_desired: bool = field(default=False) + kind: PowerDNSZoneType = PowerDNSZoneType.FORWARDED + + +@dataclass +class DNSForwardServerStatus: + """Forward DNS server status.""" + + ip: str + status: DNSForwarderServerStatus + FQDN: str | None diff --git a/app/ldap_protocol/dns/enums.py b/app/ldap_protocol/dns/enums.py new file mode 100644 index 000000000..218f125be --- /dev/null +++ b/app/ldap_protocol/dns/enums.py @@ -0,0 +1,63 @@ +"""Enums for DNS module. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from enum import Enum, StrEnum + + +class DNSdistCommandTypes(Enum): + """PDNSdist command types.""" + + GENERIC = "generic" + SHOW_RULES = "show_rules" + COMMANDS_DELTA = "commands_delta" + + +class DNSRecordType(StrEnum): + """PowerDNS Record Types.""" + + A = "A" + AAAA = "AAAA" + CNAME = "CNAME" + MX = "MX" + TXT = "TXT" + NS = "NS" + SOA = "SOA" + SRV = "SRV" + PTR = "PTR" + + +class PowerDNSZoneType(StrEnum): + """PowerDNS Zone Types.""" + + MASTER = "Master" + FORWARDED = "Forwarded" + NATIVE = "Native" + PRIMARY = "Primary" + + +class PowerDNSRecordChangeType(StrEnum): + """PowerDNS Record Change Types.""" + + REPLACE = "REPLACE" + DELETE = "DELETE" + EXTEND = "EXTEND" + PRUNE = "PRUNE" + + +class DNSForwarderServerStatus(StrEnum): + """Forwarder DNS server statuses.""" + + VALIDATED = "validated" + NOT_VALIDATED = "not validated" + NOT_FOUND = "not found" + + +class DNSManagerState(StrEnum): + """DNSManager state enum.""" + + NOT_CONFIGURED = "0" + SELFHOSTED = "1" + HOSTED = "2" diff --git a/app/ldap_protocol/dns/exceptions.py b/app/ldap_protocol/dns/exceptions.py index 5b9da9f5e..b4320cf89 100644 --- a/app/ldap_protocol/dns/exceptions.py +++ b/app/ldap_protocol/dns/exceptions.py @@ -14,15 +14,18 @@ class ErrorCodes(IntEnum): BASE_ERROR = 0 DNS_SETUP_ERROR = 1 - DNS_RECORD_CREATE_ERROR = 2 - DNS_RECORD_UPDATE_ERROR = 3 - DNS_RECORD_DELETE_ERROR = 4 - DNS_ZONE_CREATE_ERROR = 5 - DNS_ZONE_UPDATE_ERROR = 6 - DNS_ZONE_DELETE_ERROR = 7 - DNS_UPDATE_SERVER_OPTIONS_ERROR = 8 - DNS_CONNECTION_ERROR = 9 - DNS_NOT_IMPLEMENTED_ERROR = 10 + DNS_RECORD_GET_ERROR = 2 + DNS_RECORD_CREATE_ERROR = 3 + DNS_RECORD_UPDATE_ERROR = 4 + DNS_RECORD_DELETE_ERROR = 5 + DNS_ZONE_GET_ERROR = 6 + DNS_ZONE_CREATE_ERROR = 7 + DNS_ZONE_UPDATE_ERROR = 8 + DNS_ZONE_DELETE_ERROR = 9 + DNS_UPDATE_SERVER_OPTIONS_ERROR = 10 + DNS_CONNECTION_ERROR = 11 + DNS_NOT_IMPLEMENTED_ERROR = 12 + DNS_UNAVAILABLE_ERROR = 13 class DNSError(BaseDomainException): @@ -43,6 +46,12 @@ class DNSRecordCreateError(DNSError): code = ErrorCodes.DNS_RECORD_CREATE_ERROR +class DNSRecordGetError(DNSError): + """DNS record get error.""" + + code = ErrorCodes.DNS_RECORD_GET_ERROR + + class DNSRecordUpdateError(DNSError): """DNS record update error.""" @@ -61,6 +70,12 @@ class DNSZoneCreateError(DNSError): code = ErrorCodes.DNS_ZONE_CREATE_ERROR +class DNSZoneGetError(DNSError): + """DNS zone get error.""" + + code = ErrorCodes.DNS_ZONE_GET_ERROR + + class DNSZoneUpdateError(DNSError): """DNS zone update error.""" @@ -89,3 +104,37 @@ class DNSNotImplementedError(DNSError): """DNS not implemented error.""" code = ErrorCodes.DNS_NOT_IMPLEMENTED_ERROR + + +class DNSUnavailableError(DNSError): + """DNS server is unavailable.""" + + code = ErrorCodes.DNS_UNAVAILABLE_ERROR + + +class DNSCreateEntryError(DNSError): + """DNS create entry error.""" + + +class DNSDeleteEntryError(DNSError): + """DNS delete entry error.""" + + +class DNSUpdateEntryError(DNSError): + """DNS update entry error.""" + + +class DNSEntryNotFoundError(DNSError): + """DNS entry not found error.""" + + +class DNSValidationError(DNSError): + """DNS validation error.""" + + +class DNSNotSupportedError(DNSError): + """DNS not supported error.""" + + +class DNSdistError(DNSError): + """DNS dist error.""" diff --git a/app/ldap_protocol/dns/managers/__init__.py b/app/ldap_protocol/dns/managers/__init__.py new file mode 100644 index 000000000..5422d2de7 --- /dev/null +++ b/app/ldap_protocol/dns/managers/__init__.py @@ -0,0 +1,11 @@ +from ldap_protocol.dns.managers.abstract_dns_manager import AbstractDNSManager +from ldap_protocol.dns.managers.power_dns_manager import PowerDNSManager +from ldap_protocol.dns.managers.remote_dns_manager import RemoteDNSManager +from ldap_protocol.dns.managers.stub_dns_manager import StubDNSManager + +__all__ = [ + "PowerDNSManager", + "RemoteDNSManager", + "AbstractDNSManager", + "StubDNSManager", +] diff --git a/app/ldap_protocol/dns/managers/abstract_dns_manager.py b/app/ldap_protocol/dns/managers/abstract_dns_manager.py new file mode 100644 index 000000000..bfe7a79d6 --- /dev/null +++ b/app/ldap_protocol/dns/managers/abstract_dns_manager.py @@ -0,0 +1,117 @@ +"""Abstract DNS manager for DNS server managing. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from abc import abstractmethod +from ipaddress import IPv4Address, IPv6Address + +from ldap_protocol.dns.clients.abstract_client import ( + AbstractDNSForwardHTTPClient, + AbstractDNSMasterHTTPClient, +) +from ldap_protocol.dns.dto import ( + DNSForwardServerStatus, + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRRSetDTO, + DNSSettingsDTO, +) + + +class AbstractDNSManager: + """Abstract DNS manager class.""" + + _dns_settings: DNSSettingsDTO + _dns_master_client: AbstractDNSMasterHTTPClient | None = None + _dns_forward_client: AbstractDNSForwardHTTPClient | None = None + + def __init__( + self, + settings: DNSSettingsDTO, + ) -> None: + """Set up DNS manager.""" + self._dns_settings = settings + + @abstractmethod + async def setup( + self, + dns_settings: DNSSettingsDTO, + ) -> None: ... + + @abstractmethod + async def create_record( + self, + zone_id: str, + record: DNSRRSetDTO, + ) -> None: ... + + @abstractmethod + async def update_record( + self, + zone_id: str, + record: DNSRRSetDTO, + ) -> None: ... + + @abstractmethod + async def delete_record( + self, + zone_id: str, + record: DNSRRSetDTO, + ) -> None: ... + + @abstractmethod + async def get_records( + self, + zone_id: str, + ) -> list[DNSRRSetDTO]: ... + + @abstractmethod + async def get_master_zones(self) -> list[DNSMasterZoneDTO]: ... + + @abstractmethod + async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: ... + + @abstractmethod + async def create_master_zone( + self, + zone: DNSMasterZoneDTO, + ) -> None: ... + + @abstractmethod + async def create_forward_zone( + self, + zone: DNSForwardZoneDTO, + ) -> None: ... + + @abstractmethod + async def update_master_zone( + self, + zone: DNSMasterZoneDTO, + ) -> None: ... + + @abstractmethod + async def update_forward_zone( + self, + zone: DNSForwardZoneDTO, + ) -> None: ... + + @abstractmethod + async def delete_master_zone( + self, + zone_id: str, + ) -> None: ... + + @abstractmethod + async def delete_forward_zone( + self, + zone_id: str, + ) -> None: ... + + @abstractmethod + async def check_forward_dns_server( + self, + dns_server_ip: IPv4Address | IPv6Address, + host_dns_servers: list[str], + ) -> DNSForwardServerStatus: ... diff --git a/app/ldap_protocol/dns/managers/power_dns_manager.py b/app/ldap_protocol/dns/managers/power_dns_manager.py new file mode 100644 index 000000000..551efbdb9 --- /dev/null +++ b/app/ldap_protocol/dns/managers/power_dns_manager.py @@ -0,0 +1,331 @@ +"""PowerDNS API manager module. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +import asyncio +from ipaddress import IPv4Address, IPv6Address + +import dns.asyncresolver + +from ldap_protocol.dns.clients import ( + PowerDNSAuthHTTPClient, + PowerDNSDistClient, + PowerDNSRecursorHTTPClient, +) +from ldap_protocol.dns.constants import DNS_FIRST_SETUP_RECORDS +from ldap_protocol.dns.dto import ( + DNSForwardServerStatus, + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRecordDTO, + DNSRRSetDTO, + DNSSettingsDTO, +) +from ldap_protocol.dns.enums import ( + DNSForwarderServerStatus, + DNSRecordType, + PowerDNSRecordChangeType, +) +from ldap_protocol.dns.exceptions import ( + DNSError, + DNSRecordCreateError, + DNSRecordDeleteError, + DNSRecordGetError, + DNSRecordUpdateError, + DNSSetupError, + DNSZoneCreateError, + DNSZoneDeleteError, + DNSZoneGetError, + DNSZoneUpdateError, +) +from ldap_protocol.dns.managers.abstract_dns_manager import AbstractDNSManager +from ldap_protocol.dns.utils import create_initial_zone_records, logger_wraps + + +class PowerDNSManager(AbstractDNSManager): + """Manager for interacting with the PowerDNS API.""" + + _power_dns_auth_client: PowerDNSAuthHTTPClient + _power_dns_recursor_client: PowerDNSRecursorHTTPClient + _dnsdist_client: PowerDNSDistClient + + def __init__( + self, + settings: DNSSettingsDTO, + power_dns_auth_client: PowerDNSAuthHTTPClient, + power_dns_recursor_client: PowerDNSRecursorHTTPClient, + dnsdist_client: PowerDNSDistClient, + ) -> None: + """Initialize the PowerDNS API repository.""" + super().__init__(settings) + self._power_dns_auth_client = power_dns_auth_client + self._power_dns_recursor_client = power_dns_recursor_client + self._dnsdist_client = dnsdist_client + + @staticmethod + def _normalize_dns_name(name: str) -> str: + """Normalize DNS name by ensuring it ends with a dot.""" + return name if name.endswith(".") else f"{name}." + + @logger_wraps() + async def setup(self, dns_settings: DNSSettingsDTO) -> None: + """Set up DNS server and DNS manager.""" + records = [] + if dns_settings.power_dns_settings is None: + raise DNSSetupError("PowerDNS settings is not set.") + + for record in DNS_FIRST_SETUP_RECORDS: + records.append( + DNSRRSetDTO( + name=f"{record['name']}{self._dns_settings.domain}.", + type=DNSRecordType(record["type"]), + records=[ + DNSRecordDTO( + content=f"{record['value']}{self._dns_settings.domain}.", + disabled=False, + modified_at=None, + ), + ], + changetype=PowerDNSRecordChangeType.EXTEND, + ttl=3600, + ), + ) + + try: + self._dnsdist_client.setup_dnsdist( + dns_settings.power_dns_settings.recursor_server_ip, + ) + self._dnsdist_client.add_server( + dns_settings.power_dns_settings.auth_server_ip, + "master", + ) + await self.create_master_zone( + DNSMasterZoneDTO( + id=self._dns_settings.domain, + name=self._dns_settings.domain, + dnssec=False, + rrsets=records, + ), + ) + except DNSZoneCreateError as e: + raise DNSSetupError(f"Failed to set up DNS: {e}") + + @logger_wraps() + async def create_record(self, zone_id: str, record: DNSRRSetDTO) -> None: + """Create a DNS record in the specified zone.""" + record.name = self._normalize_dns_name(record.name) + + record.changetype = PowerDNSRecordChangeType.REPLACE + + try: + await self._power_dns_auth_client.record_action(zone_id, record) + except DNSError as e: + raise DNSRecordCreateError(f"Failed to create DNS record: {e}") + + @logger_wraps() + async def get_records(self, zone_id: str) -> list[DNSRRSetDTO]: + """Retrieve all DNS records for the specified zone.""" + try: + return await self._power_dns_auth_client.get_records(zone_id) + except DNSError as e: + raise DNSRecordGetError(f"Failed to get DNS records: {e}") + + @logger_wraps() + async def update_record(self, zone_id: str, record: DNSRRSetDTO) -> None: + """Update a DNS record in the specified zone.""" + record.name = self._normalize_dns_name(record.name) + + record.changetype = PowerDNSRecordChangeType.REPLACE + + try: + await self._power_dns_auth_client.record_action(zone_id, record) + except DNSError as e: + raise DNSRecordUpdateError(f"Failed to update DNS record: {e}") + + @logger_wraps() + async def delete_record(self, zone_id: str, record: DNSRRSetDTO) -> None: + """Delete a DNS record from the specified zone.""" + record.name = self._normalize_dns_name(record.name) + + record.changetype = PowerDNSRecordChangeType.DELETE + + try: + await self._power_dns_auth_client.record_action(zone_id, record) + except DNSError as e: + raise DNSRecordDeleteError(f"Failed to delete DNS record: {e}") + + @logger_wraps() + async def create_master_zone(self, zone: DNSMasterZoneDTO) -> None: + """Create a master DNS zone.""" + zone.name = self._normalize_dns_name(zone.name) + + zone.nameservers.append(f"ns1.{zone.name}") + + records = await create_initial_zone_records( + zone.name, + self._dns_settings.default_nameserver, + ) + zone.rrsets.extend(records) + + try: + await self._power_dns_auth_client.create_master_zone(zone) + self._dnsdist_client.add_zone_rule( + zone.name if not zone.name.endswith(".") else zone.name[:-1], + ) + except DNSError as e: + raise DNSZoneCreateError(f"Failed to create DNS zone: {e}") + + @logger_wraps() + async def create_forward_zone(self, zone: DNSForwardZoneDTO) -> None: + """Create a forward DNS zone.""" + zone.name = self._normalize_dns_name(zone.name) + + try: + await self._power_dns_recursor_client.create_forward_zone(zone) + except DNSError as e: + raise DNSZoneCreateError(f"Failed to create DNS zone: {e}") + + @logger_wraps() + async def get_master_zones(self) -> list[DNSMasterZoneDTO]: + """Retrieve all DNS zones.""" + try: + zones = await self._power_dns_auth_client.get_master_zones() + except DNSError as e: + raise DNSZoneGetError(f"Failed to get DNS zones: {e}") + + for zone in zones: + zone.rrsets = await self.get_records(zone.id) + + return zones + + @logger_wraps() + async def get_master_zone_by_id(self, zone_id: str) -> DNSMasterZoneDTO: + """Get master DNS zone by ID.""" + try: + return await self._power_dns_auth_client.get_master_zone_by_id( + zone_id, + ) + except DNSError as e: + raise DNSZoneGetError(f"Failed to get DNS zones: {e}") + + @logger_wraps() + async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: + """Retrieve all forward DNS zones.""" + try: + return await self._power_dns_recursor_client.get_forward_zones() + except DNSError as e: + raise DNSZoneGetError(f"Failed to get DNS zones: {e}") + + @logger_wraps() + async def update_master_zone(self, zone: DNSMasterZoneDTO) -> None: + """Update a master DNS zone.""" + zone.name = self._normalize_dns_name(zone.name) + try: + await self._power_dns_auth_client.update_master_zone(zone.id, zone) + except DNSError as e: + raise DNSZoneUpdateError(f"Failed to update DNS zone: {e}") + + @logger_wraps() + async def update_forward_zone(self, zone: DNSForwardZoneDTO) -> None: + """Update a forward DNS zone.""" + zone.name = self._normalize_dns_name(zone.name) + + try: + await self._power_dns_recursor_client.update_forward_zone( + zone.id, + zone, + ) + except DNSError as e: + raise DNSZoneUpdateError(f"Failed to update DNS zone: {e}") + + @logger_wraps() + async def delete_master_zone(self, zone_id: str) -> None: + """Delete a DNS zone.""" + zone = await self.get_master_zone_by_id(zone_id) + + try: + await self._power_dns_auth_client.delete_master_zone(zone_id) + self._dnsdist_client.remove_zone_rule(zone.name[:-1]) + except DNSError as e: + raise DNSZoneDeleteError(f"Failed to delete DNS zone: {e}") + + @logger_wraps() + async def delete_forward_zone(self, zone_id: str) -> None: + """Delete a DNS forward zone.""" + try: + await self._power_dns_recursor_client.delete_forward_zone(zone_id) + except DNSError as e: + raise DNSZoneDeleteError(f"Failed to delete DNS zone: {e}") + + @logger_wraps() + async def find_forward_dns_fqdn( + self, + dns_server_ip: IPv4Address | IPv6Address, + host_dns_servers: list[str], + ) -> str | None: + """Find forward DNS FQDN.""" + reversed_ip = ( + ".".join(reversed((str(dns_server_ip)).split("."))) + + ".in-addr.arpa" + ) + + async def get_fqdn_and_latency( + server: str, + ) -> tuple[float, str | None]: + resolver = dns.asyncresolver.Resolver() + resolver.nameservers = [server] + resolver.timeout = 10 + + try: + event_loop = asyncio.get_running_loop() + start_time = event_loop.time() + fqdn = await resolver.resolve(reversed_ip, DNSRecordType.PTR) + latency = event_loop.time() - start_time + + return (latency, fqdn[0].to_text()) + except ( + dns.asyncresolver.NoAnswer, + dns.asyncresolver.NXDOMAIN, + ): + return (float("inf"), None) + + fqdn_list = await asyncio.gather( + *(get_fqdn_and_latency(server) for server in host_dns_servers), + ) + fqdn_list.sort(key=lambda x: x[0]) + return fqdn_list[0][1] if fqdn_list else None + + @logger_wraps() + async def check_forward_dns_server( + self, + dns_server_ip: IPv4Address | IPv6Address, + host_dns_servers: list[str], + ) -> DNSForwardServerStatus: + str_dns_server_ip = str(dns_server_ip) + + try: + fqdn = await self.find_forward_dns_fqdn( + dns_server_ip, + host_dns_servers, + ) + except (dns.asyncresolver.NoAnswer, dns.asyncresolver.NXDOMAIN): + return DNSForwardServerStatus( + str_dns_server_ip, + DNSForwarderServerStatus.NOT_VALIDATED, + None, + ) + + if not fqdn: + return DNSForwardServerStatus( + str_dns_server_ip, + DNSForwarderServerStatus.NOT_FOUND, + None, + ) + + return DNSForwardServerStatus( + str_dns_server_ip, + DNSForwarderServerStatus.VALIDATED, + fqdn, + ) diff --git a/app/ldap_protocol/dns/managers/remote_dns_manager.py b/app/ldap_protocol/dns/managers/remote_dns_manager.py new file mode 100644 index 000000000..73f4aae00 --- /dev/null +++ b/app/ldap_protocol/dns/managers/remote_dns_manager.py @@ -0,0 +1,191 @@ +"""DNS service for remote DNS server managing. + +Copyright (c) 2024 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from ipaddress import IPv4Address, IPv6Address + +from dns.asyncquery import inbound_xfr as make_inbound_xfr, tcp as asynctcp +from dns.message import Message, make_query as make_dns_query +from dns.name import from_text +from dns.rdataclass import IN +from dns.rdatatype import AXFR +from dns.tsig import Key as TsigKey +from dns.update import Update +from dns.zone import Zone + +from ldap_protocol.dns.dto import ( + DNSForwardServerStatus, + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRecordDTO, + DNSRRSetDTO, + DNSSettingsDTO, +) +from ldap_protocol.dns.exceptions import ( + DNSConnectionError, + DNSNotImplementedError, +) +from ldap_protocol.dns.managers.abstract_dns_manager import AbstractDNSManager +from ldap_protocol.dns.utils import logger_wraps + + +class RemoteDNSManager(AbstractDNSManager): + """DNS server manager.""" + + async def _send(self, action: Message) -> None: + """Send request to DNS server.""" + if self._dns_settings.tsig_key is not None: + action.use_tsig( + keyring=TsigKey("zone.", self._dns_settings.tsig_key), + keyname="zone.", + ) + + if self._dns_settings.dns_server_ip is None: + raise DNSConnectionError + + await asynctcp(action, str(self._dns_settings.dns_server_ip)) + + async def setup( + self, + dns_settings: DNSSettingsDTO, # noqa: ARG002 + ) -> None: + """Set up DNS server and DNS manager.""" + raise DNSNotImplementedError + + @logger_wraps() + async def create_record( + self, + zone_id: str, + record: DNSRRSetDTO, + ) -> None: + """Create DNS record.""" + action = Update(self._dns_settings.domain or zone_id) + action.add( + record.name, + record.ttl, + record.type, + record.records[0].content, + ) + + await self._send(action) + + @logger_wraps() + async def get_records(self, zone_id: str) -> list[DNSRRSetDTO]: + """Get all DNS records.""" + if ( + self._dns_settings.dns_server_ip is None + or self._dns_settings.domain is None + ): + raise DNSConnectionError + + zone = from_text(self._dns_settings.domain or zone_id) + zone_tm = Zone(zone) + query = make_dns_query(zone, AXFR, IN) + + if self._dns_settings.tsig_key is not None: + query.use_tsig( + keyring=TsigKey("zone.", self._dns_settings.tsig_key), + keyname="zone.", + ) + + await make_inbound_xfr( + str(self._dns_settings.dns_server_ip), + zone_tm, + ) + + return [ + DNSRRSetDTO( + name=name.to_text() + f".{self._dns_settings.domain}.", + type=rdata.rdtype.name, + records=[ + DNSRecordDTO( + content=rdata.to_text(), + disabled=False, + ), + ], + ttl=ttl, + ) + for name, ttl, rdata in zone_tm.iterate_rdatas() + ] + + @logger_wraps() + async def update_record( + self, + zone_id: str, + record: DNSRRSetDTO, + ) -> None: + """Update DNS record.""" + action = Update(self._dns_settings.domain or zone_id) + action.replace( + record.name, + record.ttl, + record.type, + record.records[0].content, + ) + await self._send(action) + + @logger_wraps() + async def delete_record( + self, + zone_id: str, + record: DNSRRSetDTO, + ) -> None: + """Delete DNS record.""" + action = Update(self._dns_settings.domain or zone_id) + action.delete( + record.name, + record.type, + record.records[0].content, + ) + await self._send(action) + + async def get_master_zones(self) -> list[DNSMasterZoneDTO]: + raise DNSNotImplementedError + + async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: + raise DNSNotImplementedError + + async def create_master_zone( + self, + zone: DNSMasterZoneDTO, # noqa: ARG002 + ) -> None: + raise DNSNotImplementedError + + async def create_forward_zone( + self, + zone: DNSForwardZoneDTO, # noqa: ARG002 + ) -> None: + raise DNSNotImplementedError + + async def update_master_zone( + self, + zone: DNSMasterZoneDTO, # noqa: ARG002 + ) -> None: + raise DNSNotImplementedError + + async def update_forward_zone( + self, + zone: DNSForwardZoneDTO, # noqa: ARG002 + ) -> None: + raise DNSNotImplementedError + + async def delete_master_zone( + self, + zone_id: str, # noqa: ARG002 + ) -> None: + raise DNSNotImplementedError + + async def delete_forward_zone( + self, + zone_id: str, # noqa: ARG002 + ) -> None: + raise DNSNotImplementedError + + async def check_forward_dns_server( + self, + dns_server_ip: IPv4Address | IPv6Address, # noqa: ARG002 + host_dns_servers: list[str], # noqa: ARG002 + ) -> DNSForwardServerStatus: + raise DNSNotImplementedError diff --git a/app/ldap_protocol/dns/managers/stub_dns_manager.py b/app/ldap_protocol/dns/managers/stub_dns_manager.py new file mode 100644 index 000000000..f07ae1e4c --- /dev/null +++ b/app/ldap_protocol/dns/managers/stub_dns_manager.py @@ -0,0 +1,105 @@ +"""Stub calls for DNS server API. + +Copyright (c) 2024 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from ipaddress import IPv4Address, IPv6Address + +from ldap_protocol.dns.dto import ( + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRRSetDTO, + DNSSettingsDTO, +) +from ldap_protocol.dns.managers.abstract_dns_manager import AbstractDNSManager +from ldap_protocol.dns.utils import logger_wraps + + +class StubDNSManager(AbstractDNSManager): + """Stub client.""" + + @logger_wraps(is_stub=True) + async def setup( + self, + dns_settings: DNSSettingsDTO, + ) -> None: ... + + @logger_wraps(is_stub=True) + async def create_record( + self, + zone_id: str, + record: DNSRRSetDTO, + ) -> None: ... + + @logger_wraps(is_stub=True) + async def update_record( + self, + zone_id: str, + record: DNSRRSetDTO, + ) -> None: ... + + @logger_wraps(is_stub=True) + async def delete_record( + self, + zone_id: str, + record: DNSRRSetDTO, + ) -> None: ... + + @logger_wraps(is_stub=True) + async def get_records( + self, + zone_id: str, # noqa: ARG002 + ) -> list[DNSRRSetDTO]: + return [] + + @logger_wraps(is_stub=True) + async def get_master_zones(self) -> list[DNSMasterZoneDTO]: + return [] + + @logger_wraps(is_stub=True) + async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: + return [] + + @logger_wraps(is_stub=True) + async def create_master_zone( + self, + zone: DNSMasterZoneDTO, + ) -> None: ... + + @logger_wraps(is_stub=True) + async def create_forward_zone( + self, + zone: DNSForwardZoneDTO, + ) -> None: ... + + @logger_wraps(is_stub=True) + async def update_master_zone( + self, + zone: DNSMasterZoneDTO, + ) -> None: ... + + @logger_wraps(is_stub=True) + async def update_forward_zone( + self, + zone: DNSForwardZoneDTO, + ) -> None: ... + + @logger_wraps(is_stub=True) + async def delete_master_zone( + self, + zone_id: str, + ) -> None: ... + + @logger_wraps(is_stub=True) + async def delete_forward_zone( + self, + zone_id: str, + ) -> None: ... + + @logger_wraps(is_stub=True) + async def check_forward_dns_server( + self, + dns_server_ip: IPv4Address | IPv6Address, + host_dns_servers: list[str], + ) -> None: ... diff --git a/app/ldap_protocol/dns/remote.py b/app/ldap_protocol/dns/remote.py deleted file mode 100644 index 1c2cb25fd..000000000 --- a/app/ldap_protocol/dns/remote.py +++ /dev/null @@ -1,125 +0,0 @@ -"""DNS service for remote DNS server managing. - -Copyright (c) 2024 MultiFactor -License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE -""" - -from collections import defaultdict - -from dns.asyncquery import inbound_xfr as make_inbound_xfr, tcp as asynctcp -from dns.message import Message, make_query as make_dns_query -from dns.name import from_text -from dns.rdataclass import IN -from dns.rdatatype import AXFR -from dns.tsig import Key as TsigKey -from dns.update import Update -from dns.zone import Zone - -from .base import AbstractDNSManager, DNSRecord, DNSRecords -from .exceptions import DNSConnectionError -from .utils import logger_wraps - - -class RemoteDNSManager(AbstractDNSManager): - """DNS server manager.""" - - async def _send(self, action: Message) -> None: - """Send request to DNS server.""" - if self._dns_settings.tsig_key is not None: - action.use_tsig( - keyring=TsigKey("zone.", self._dns_settings.tsig_key), - keyname="zone.", - ) - - if self._dns_settings.dns_server_ip is None: - raise DNSConnectionError - - await asynctcp(action, self._dns_settings.dns_server_ip) - - @logger_wraps() - async def create_record( - self, - hostname: str, - ip: str, - record_type: str, - ttl: int | None, - zone_name: str | None = None, - ) -> None: - """Create DNS record.""" - action = Update(self._dns_settings.zone_name or zone_name) - action.add(hostname, ttl, record_type, ip) - - await self._send(action) - - @logger_wraps() - async def get_all_records(self) -> list[DNSRecords]: - """Get all DNS records.""" - if ( - self._dns_settings.dns_server_ip is None - or self._dns_settings.zone_name is None - ): - raise DNSConnectionError - - zone = from_text(self._dns_settings.zone_name) - zone_tm = Zone(zone) - query = make_dns_query(zone, AXFR, IN) - - if self._dns_settings.tsig_key is not None: - query.use_tsig( - keyring=TsigKey("zone.", self._dns_settings.tsig_key), - keyname="zone.", - ) - - await make_inbound_xfr( - self._dns_settings.dns_server_ip, - zone_tm, - ) - - result: defaultdict[str, list] = defaultdict(list) - for name, ttl, rdata in zone_tm.iterate_rdatas(): - record_type = rdata.rdtype.name - - if record_type == "SOA": - continue - - result[record_type].append( - DNSRecord( - name=(name.to_text() + f".{self._dns_settings.zone_name}"), - value=rdata.to_text(), - ttl=ttl, - ), - ) - - return [ - DNSRecords(type=record_type, records=records) - for record_type, records in result.items() - ] - - @logger_wraps() - async def update_record( - self, - hostname: str, - ip: str | None, - record_type: str, - ttl: int | None, - zone_name: str | None = None, - ) -> None: - """Update DNS record.""" - action = Update(self._dns_settings.zone_name or zone_name) - action.replace(hostname, ttl, record_type, ip) - - await self._send(action) - - @logger_wraps() - async def delete_record( - self, - hostname: str, - ip: str, - record_type: str, - zone_name: str | None = None, - ) -> None: - """Delete DNS record.""" - action = Update(self._dns_settings.zone_name or zone_name) - action.delete(hostname, record_type, ip) - - await self._send(action) diff --git a/app/ldap_protocol/dns/selfhosted.py b/app/ldap_protocol/dns/selfhosted.py deleted file mode 100644 index 4870e2e70..000000000 --- a/app/ldap_protocol/dns/selfhosted.py +++ /dev/null @@ -1,286 +0,0 @@ -"""DNS service for SelfHosted DNS server managing. - -Copyright (c) 2024 MultiFactor -License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE -""" - -import asyncio -from dataclasses import asdict -from ipaddress import IPv4Address, IPv6Address - -import dns.asyncresolver - -import ldap_protocol.dns.exceptions as dns_exc - -from .base import ( - AbstractDNSManager, - DNSForwarderServerStatus, - DNSForwardServerStatus, - DNSForwardZone, - DNSRecords, - DNSRecordType, - DNSServerParam, - DNSZone, - DNSZoneParam, - DNSZoneType, -) -from .utils import logger_wraps - - -class SelfHostedDNSManager(AbstractDNSManager): - """Manager for selfhosted Bind9 DNS server.""" - - @logger_wraps() - async def create_record( - self, - hostname: str, - ip: str, - record_type: DNSRecordType, - ttl: int, - zone_name: str | None = None, - ) -> None: - """Create DNS record.""" - response = await self._http_client.post( - "/record", - json={ - "zone_name": zone_name, - "record_name": hostname, - "record_type": record_type, - "record_value": ip, - "ttl": ttl, - }, - ) - - if response.status_code != 200: - raise dns_exc.DNSRecordCreateError(response.text) - - @logger_wraps() - async def update_record( - self, - hostname: str, - ip: str | None, - record_type: str, - ttl: int | None, - zone_name: str | None = None, - ) -> None: - response = await self._http_client.patch( - "/record", - json={ - "zone_name": zone_name, - "record_name": hostname, - "record_type": record_type, - "record_value": ip, - "ttl": ttl, - }, - ) - - if response.status_code != 200: - raise dns_exc.DNSRecordUpdateError(response.text) - - @logger_wraps() - async def delete_record( - self, - hostname: str, - ip: str, - record_type: str, - zone_name: str | None = None, - ) -> None: - response = await self._http_client.request( - "delete", - "/record", - json={ - "zone_name": zone_name, - "record_name": hostname, - "record_type": record_type, - "record_value": ip, - }, - ) - - if response.status_code != 200: - raise dns_exc.DNSRecordDeleteError(response.text) - - @logger_wraps() - async def get_all_records(self) -> list[DNSRecords]: - response = await self._http_client.get("/zone") - - response_data = response.json() - - if ( - isinstance(response_data, list) - and len(response_data) > 0 - and "records" in response_data[0] - ): - return response_data[0]["records"] - else: - return [] - - @logger_wraps() - async def get_all_zones_records(self) -> list[DNSZone]: - response = await self._http_client.get("/zone") - - return response.json() - - @logger_wraps() - async def get_forward_zones(self) -> list[DNSForwardZone]: - response = await self._http_client.get("/zone/forward") - - return response.json() - - @logger_wraps() - async def create_zone( - self, - zone_name: str, - zone_type: DNSZoneType, - nameserver: str | None, - params: list[DNSZoneParam], - ) -> None: - response = await self._http_client.post( - "/zone", - json={ - "zone_name": zone_name, - "zone_type": zone_type, - "nameserver": nameserver, - "params": [asdict(param) for param in params], - }, - ) - - if response.status_code != 200: - raise dns_exc.DNSZoneCreateError(response.text) - - @logger_wraps() - async def update_zone( - self, - zone_name: str, - params: list[DNSZoneParam], - ) -> None: - response = await self._http_client.patch( - "/zone", - json={ - "zone_name": zone_name, - "params": [asdict(param) for param in params], - }, - ) - - if response.status_code != 200: - raise dns_exc.DNSZoneUpdateError(response.text) - - @logger_wraps() - async def delete_zone( - self, - zone_names: list[str], - ) -> None: - for zone_name in zone_names: - response = await self._http_client.request( - "delete", - "/zone", - json={"zone_name": zone_name}, - ) - - if response.status_code != 200: - raise dns_exc.DNSZoneDeleteError(response.text) - - @logger_wraps() - async def find_forward_dns_fqdn( - self, - dns_server_ip: IPv4Address | IPv6Address, - host_dns_servers: list[str], - ) -> str | None: - """Find forward DNS FQDN.""" - reversed_ip = ( - ".".join(reversed((str(dns_server_ip)).split("."))) - + ".in-addr.arpa" - ) - - async def get_fqdn_and_latency( - server: str, - ) -> tuple[float, str | None]: - resolver = dns.asyncresolver.Resolver() - resolver.nameservers = [server] - resolver.timeout = 10 - - try: - event_loop = asyncio.get_running_loop() - start_time = event_loop.time() - fqdn = await resolver.resolve( - reversed_ip, - "PTR", - ) - latency = event_loop.time() - start_time - - return (latency, fqdn[0].to_text()) - except ( - dns.asyncresolver.NoAnswer, - dns.asyncresolver.NXDOMAIN, - ): - return (float("inf"), None) - - fqdn_list = await asyncio.gather( - *(get_fqdn_and_latency(server) for server in host_dns_servers), - ) - fqdn_list.sort(key=lambda x: x[0]) - return fqdn_list[0][1] if fqdn_list else None - - @logger_wraps() - async def check_forward_dns_server( - self, - dns_server_ip: IPv4Address | IPv6Address, - host_dns_servers: list[str], - ) -> DNSForwardServerStatus: - str_dns_server_ip = str(dns_server_ip) - - try: - fqdn = await self.find_forward_dns_fqdn( - str_dns_server_ip, - host_dns_servers, - ) - except (dns.asyncresolver.NoAnswer, dns.asyncresolver.NXDOMAIN): - return DNSForwardServerStatus( - str_dns_server_ip, - DNSForwarderServerStatus.NOT_VALIDATED, - None, - ) - - if not fqdn: - return DNSForwardServerStatus( - str_dns_server_ip, - DNSForwarderServerStatus.NOT_FOUND, - None, - ) - - return DNSForwardServerStatus( - str_dns_server_ip, - DNSForwarderServerStatus.VALIDATED, - fqdn, - ) - - @logger_wraps() - async def update_server_options( - self, - params: list[DNSServerParam], - ) -> None: - response = await self._http_client.patch( - "/server/settings", - json=[asdict(param) for param in params], - ) - - if response.status_code != 200: - raise dns_exc.DNSUpdateServerOptionsError(response.text) - - @logger_wraps() - async def get_server_options(self) -> list[DNSServerParam]: - response = await self._http_client.get("/server/settings") - - return response.json() - - @logger_wraps() - async def restart_server( - self, - ) -> None: - await self._http_client.get("/server/restart") - - @logger_wraps() - async def reload_zone( - self, - zone_name: str, - ) -> None: - await self._http_client.get(f"/zone/{zone_name}") diff --git a/app/ldap_protocol/dns/stub.py b/app/ldap_protocol/dns/stub.py deleted file mode 100644 index 836a98a62..000000000 --- a/app/ldap_protocol/dns/stub.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Stub calls for DNS server API. - -Copyright (c) 2024 MultiFactor -License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE -""" - -from .base import ( - AbstractDNSManager, - DNSForwardZone, - DNSRecords, - DNSServerParam, - DNSZoneParam, - DNSZoneType, -) -from .utils import logger_wraps - - -class StubDNSManager(AbstractDNSManager): - """Stub client.""" - - @logger_wraps(is_stub=True) - async def create_record( - self, - hostname: str, - ip: str, - record_type: str, - ttl: int | None, - zone_name: str | None = None, - ) -> None: ... - - @logger_wraps(is_stub=True) - async def update_record( - self, - hostname: str, - ip: str, - record_type: str, - ttl: int, - zone_name: str | None = None, - ) -> None: ... - - @logger_wraps(is_stub=True) - async def delete_record( - self, - hostname: str, - ip: str, - record_type: str, - zone_name: str | None = None, - ) -> None: ... - - @logger_wraps(is_stub=True) - async def get_all_zones_records(self) -> None: ... - - @logger_wraps(is_stub=True) - async def get_forward_zones(self) -> list[DNSForwardZone]: - return [] - - @logger_wraps(is_stub=True) - async def create_zone( - self, - zone_name: str, - zone_type: DNSZoneType, - nameserver: str | None, - params: list[DNSZoneParam], - ) -> None: ... - - @logger_wraps(is_stub=True) - async def update_zone( - self, - zone_name: str, - params: list[DNSZoneParam] | None, - ) -> None: ... - - @logger_wraps(is_stub=True) - async def delete_zone( - self, - zone_names: list[str], - ) -> None: ... - - @logger_wraps(is_stub=True) - async def check_forward_dns_server( - self, - dns_server_ip: str, - ) -> None: ... - - @logger_wraps(is_stub=True) - async def update_server_options( - self, - params: list[DNSServerParam], - ) -> None: ... - - @logger_wraps(is_stub=True) - async def get_server_options(self) -> list[DNSServerParam]: - return [] - - @logger_wraps(is_stub=True) - async def restart_server( - self, - ) -> None: ... - - @logger_wraps(is_stub=True) - async def reload_zone( - self, - zone_name: str, - ) -> None: ... - - @logger_wraps(is_stub=True) - async def get_all_records(self) -> list[DNSRecords]: - """Stub DNS manager get all records.""" - return [] diff --git a/app/ldap_protocol/dns/use_cases.py b/app/ldap_protocol/dns/use_cases.py index 0b5f32291..78dfb2092 100644 --- a/app/ldap_protocol/dns/use_cases.py +++ b/app/ldap_protocol/dns/use_cases.py @@ -10,18 +10,17 @@ from abstract_service import AbstractService from config import Settings from enums import AuthorizationRules -from ldap_protocol.dns.base import ( - AbstractDNSManager, +from ldap_protocol.dns.dns_gateway import DNSStateGateway +from ldap_protocol.dns.dto import ( DNSForwardServerStatus, - DNSForwardZone, - DNSManagerSettings, - DNSRecords, - DNSServerParam, - DNSZone, - DNSZoneParam, - DNSZoneType, + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRRSetDTO, + DNSSettingsDTO, ) -from ldap_protocol.dns.dns_gateway import DNSStateGateway +from ldap_protocol.dns.enums import DNSManagerState +from ldap_protocol.dns.exceptions import DNSError, DNSSetupError +from ldap_protocol.dns.managers.abstract_dns_manager import AbstractDNSManager class DNSUseCase(AbstractService): @@ -31,7 +30,7 @@ def __init__( self, dns_manager: AbstractDNSManager, dns_gateway: DNSStateGateway, - dns_settings: DNSManagerSettings, + dns_settings: DNSSettingsDTO, settings: Settings, ) -> None: """Initialize DNS use case.""" @@ -40,116 +39,94 @@ def __init__( self._dns_settings = dns_settings self._dns_gateway = dns_gateway - async def setup_dns( + async def setup( self, - dns_status: str, - domain: str, - dns_ip_address: str | IPv4Address | IPv6Address | None, - tsig_key: str | None, + dns_settings: DNSSettingsDTO | None, ) -> None: """Set up DNS server and DNS manager.""" - setup_data = await self._dns_manager.setup( - dns_status, - domain, - dns_ip_address or self._settings.DNS_BIND_HOST, - tsig_key, - ) - if self._dns_settings.domain is not None: - await self._dns_gateway.update_settings(setup_data) - else: - await self._dns_gateway.create_settings(setup_data) + state = await self._dns_gateway.get_state() - await self._dns_gateway.setup_dns_state(dns_status) + if state == DNSManagerState.SELFHOSTED: + await self._dns_manager.setup( + self._dns_settings, + ) + elif state == DNSManagerState.HOSTED: + if dns_settings is None: + raise DNSSetupError() + if self._dns_settings.dns_server_ip is None: + await self._dns_gateway.create_settings(dns_settings) + else: + await self._dns_gateway.update_settings(dns_settings) + else: + raise DNSSetupError() async def create_record( self, - hostname: str, - ip: str, - record_type: str, - ttl: int | None, - zone_name: str | None = None, + zone_id: str, + record: DNSRRSetDTO, ) -> None: """Create DNS record.""" - await self._dns_manager.create_record( - hostname, - ip, - record_type, - ttl, - zone_name, - ) + await self._dns_manager.create_record(zone_id, record) - async def delete_record( - self, - hostname: str, - ip: str, - record_type: str, - zone_name: str | None = None, - ) -> None: - """Delete DNS record.""" - await self._dns_manager.delete_record( - hostname, - ip, - record_type, - zone_name, - ) + async def get_records(self, zone_id: str) -> list[DNSRRSetDTO]: + """Get all DNS records.""" + return await self._dns_manager.get_records(zone_id) - async def update_record( - self, - hostname: str, - ip: str | None, - record_type: str, - ttl: int | None, - zone_name: str | None = None, - ) -> None: + async def update_record(self, zone_id: str, record: DNSRRSetDTO) -> None: """Update DNS record.""" - await self._dns_manager.update_record( - hostname, - ip, - record_type, - ttl, - zone_name, - ) + await self._dns_manager.update_record(zone_id, record) - async def get_all_records(self) -> list[DNSRecords]: - """Get all DNS records.""" - return await self._dns_manager.get_all_records() + async def delete_record(self, zone_id: str, record: DNSRRSetDTO) -> None: + """Delete DNS record.""" + await self._dns_manager.delete_record(zone_id, record) - async def get_all_zones_records(self) -> list[DNSZone]: + async def create_master_zone(self, zone: DNSMasterZoneDTO) -> None: + """Create DNS master zone.""" + await self._dns_manager.create_master_zone(zone) + + async def create_forward_zone(self, zone: DNSForwardZoneDTO) -> None: + """Create DNS forward zone.""" + await self._dns_manager.create_forward_zone(zone) + + async def get_master_zones(self) -> list[DNSMasterZoneDTO]: """Get all DNS zones.""" - return await self._dns_manager.get_all_zones_records() + return await self._dns_manager.get_master_zones() - async def get_forward_zones(self) -> list[DNSForwardZone]: + async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: """Get all forward zones.""" return await self._dns_manager.get_forward_zones() - async def create_zone( - self, - zone_name: str, - zone_type: DNSZoneType, - nameserver: str | None, - params: list[DNSZoneParam], - ) -> None: - """Create DNS zone.""" - await self._dns_manager.create_zone( - zone_name, - zone_type, - nameserver, - params, - ) - - async def update_zone( - self, - zone_name: str, - params: list[DNSZoneParam] | None, - ) -> None: - """Update DNS zone.""" - await self._dns_manager.update_zone(zone_name, params) - - async def delete_zone(self, zone_names: list[str]) -> None: - """Delete DNS zone.""" - await self._dns_manager.delete_zone(zone_names) - - async def check_forward_dns_server( + async def update_master_zone(self, zone: DNSMasterZoneDTO) -> None: + """Update DNS master zone.""" + await self._dns_manager.update_master_zone(zone) + + async def update_forward_zone(self, zone: DNSForwardZoneDTO) -> None: + """Update DNS forward zone.""" + await self._dns_manager.update_forward_zone(zone) + + async def delete_master_zones(self, zone_ids: list[str]) -> None: + """Delete DNS master zones.""" + last_error = None + try: + for zone_id in zone_ids: + await self._dns_manager.delete_master_zone(zone_id) + except DNSError as e: + last_error = e + if last_error: + raise last_error + + async def delete_forward_zones(self, zone_ids: list[str]) -> None: + """Delete DNS forward zones.""" + last_error = None + try: + for zone_id in zone_ids: + await self._dns_manager.delete_forward_zone(zone_id) + except DNSError as e: + last_error = e + if last_error: + raise last_error + + async def check_forward_server( self, dns_server_ip: IPv4Address | IPv6Address, host_dns_servers: list[str], @@ -160,40 +137,27 @@ async def check_forward_dns_server( host_dns_servers, ) - async def update_server_options( - self, - params: list[DNSServerParam], - ) -> None: - """Update DNS server options.""" - await self._dns_manager.update_server_options(params) - - async def restart_server(self) -> None: - """Restart DNS server.""" - await self._dns_manager.restart_server() - - async def reload_zone(self, zone_name: str) -> None: - """Reload DNS zone.""" - await self._dns_manager.reload_zone(zone_name) - - async def get_server_options(self) -> list[DNSServerParam]: - """Get DNS server options.""" - return await self._dns_manager.get_server_options() - - async def get_dns_status(self) -> dict[str, str | None]: + async def get_status(self) -> dict[str, str | None]: """Get DNS status.""" return { - "dns_status": await self._dns_gateway.get_dns_state(), - "zone_name": self._dns_settings.zone_name, - "dns_server_ip": self._dns_settings.dns_server_ip, + "dns_status": await self._dns_gateway.get_state(), + "zone_name": self._dns_settings.domain, + "dns_server_ip": str(self._dns_settings.dns_server_ip) + if self._dns_settings.dns_server_ip is not None + else None, } - async def check_dns_forward_zone( + async def set_state(self, state: DNSManagerState) -> None: + """Set DNS manager state.""" + await self._dns_gateway.set_state(state) + + async def check_forward_zone( self, data: list[IPv4Address | IPv6Address], ) -> list[DNSForwardServerStatus]: """Check DNS forward zone for availability.""" return [ - await self.check_forward_dns_server( + await self.check_forward_server( dns_server_ip, self._settings.HOST_DNS_SERVERS, ) @@ -201,20 +165,20 @@ async def check_dns_forward_zone( ] PERMISSIONS: ClassVar[dict[str, AuthorizationRules]] = { - setup_dns.__name__: AuthorizationRules.DNS_SETUP_DNS, + setup.__name__: AuthorizationRules.DNS_SETUP_DNS, create_record.__name__: AuthorizationRules.DNS_CREATE_RECORD, delete_record.__name__: AuthorizationRules.DNS_DELETE_RECORD, update_record.__name__: AuthorizationRules.DNS_UPDATE_RECORD, - get_all_records.__name__: AuthorizationRules.DNS_GET_ALL_RECORDS, - get_dns_status.__name__: AuthorizationRules.DNS_GET_DNS_STATUS, - get_all_zones_records.__name__: AuthorizationRules.DNS_GET_ALL_ZONES_RECORDS, # noqa: E501 - get_forward_zones.__name__: AuthorizationRules.DNS_GET_FORWARD_ZONES, - create_zone.__name__: AuthorizationRules.DNS_CREATE_ZONE, - update_zone.__name__: AuthorizationRules.DNS_UPDATE_ZONE, - delete_zone.__name__: AuthorizationRules.DNS_DELETE_ZONE, - check_dns_forward_zone.__name__: AuthorizationRules.DNS_CHECK_DNS_FORWARD_ZONE, # noqa: E501 - reload_zone.__name__: AuthorizationRules.DNS_RELOAD_ZONE, - update_server_options.__name__: AuthorizationRules.DNS_UPDATE_SERVER_OPTIONS, # noqa: E501 - get_server_options.__name__: AuthorizationRules.DNS_GET_SERVER_OPTIONS, - restart_server.__name__: AuthorizationRules.DNS_RESTART_SERVER, + get_records.__name__: AuthorizationRules.DNS_GET_ALL_RECORDS, + get_status.__name__: AuthorizationRules.DNS_GET_DNS_STATUS, + delete_forward_zones.__name__: AuthorizationRules.DNS_DELETE_FWD_ZONES, + get_master_zones.__name__: AuthorizationRules.DNS_GET_MASTER_ZONES, + get_forward_zones.__name__: AuthorizationRules.DNS_GET_FWD_ZONES, + create_master_zone.__name__: AuthorizationRules.DNS_CREATE_MASTER_ZONE, + create_forward_zone.__name__: AuthorizationRules.DNS_CREATE_FWD_ZONE, + update_master_zone.__name__: AuthorizationRules.DNS_UPDATE_MASTER_ZONE, + update_forward_zone.__name__: AuthorizationRules.DNS_UPDATE_FWD_ZONE, + delete_master_zones.__name__: AuthorizationRules.DNS_DELETE_MASTER_ZONES, # noqa: E501 + delete_forward_zones.__name__: AuthorizationRules.DNS_DELETE_FWD_ZONES, + check_forward_zone.__name__: AuthorizationRules.DNS_CHECK_DNS_FORWARD_ZONE, # noqa: E501 } diff --git a/app/ldap_protocol/dns/utils.py b/app/ldap_protocol/dns/utils.py index 9adc21fe9..feccf9eae 100644 --- a/app/ldap_protocol/dns/utils.py +++ b/app/ldap_protocol/dns/utils.py @@ -8,9 +8,21 @@ from typing import Any, Callable from dns.asyncresolver import Resolver as AsyncResolver +from loguru import logger -from .base import log -from .exceptions import DNSConnectionError +from ldap_protocol.dns.dto import DNSRecordDTO, DNSRRSetDTO +from ldap_protocol.dns.enums import DNSRecordType, PowerDNSRecordChangeType +from ldap_protocol.dns.exceptions import DNSConnectionError, DNSError + +log = logger.bind(name="DNSManager") + +log.add( + "logs/dnsmanager_{time:DD-MM-YYYY}.log", + filter=lambda rec: rec["extra"].get("name") == "DNSManager", + retention="10 days", + rotation="1d", + colorize=False, +) def logger_wraps(is_stub: bool = False) -> Callable: @@ -23,17 +35,12 @@ def wrapper(func: Callable) -> Callable: @functools.wraps(func) async def wrapped(*args: str, **kwargs: str) -> Any: logger = log.opt(depth=1) - - logger.info(f"Calling{bus_type}'{name}'") try: result = await func(*args, **kwargs) - except DNSConnectionError as err: - logger.error(f"{name} call raised: {err}") + except DNSError as err: + logger.error(f"{name} call in {bus_type} raised: {err}") raise - else: - if not is_stub: - logger.success(f"Executed {name}") return result return wrapped @@ -48,3 +55,52 @@ async def resolve_dns_server_ip(host: str) -> str: if dns_server_ip_resolve is None or dns_server_ip_resolve.rrset is None: raise DNSConnectionError return dns_server_ip_resolve.rrset[0].address + + +async def create_initial_zone_records( + domain: str, + nameserver: str, +) -> list[DNSRRSetDTO]: + """Get initial records for new zone.""" + return [ + DNSRRSetDTO( + name=f"{domain}", + type=DNSRecordType.A, + records=[ + DNSRecordDTO( + content=nameserver, + disabled=False, + modified_at=None, + ), + ], + changetype=PowerDNSRecordChangeType.EXTEND, + ttl=3600, + ), + DNSRRSetDTO( + name=f"ns1.{domain}", + type=DNSRecordType.A, + records=[ + DNSRecordDTO( + content=nameserver, + disabled=False, + modified_at=None, + ), + ], + changetype=PowerDNSRecordChangeType.EXTEND, + ttl=3600, + ), + DNSRRSetDTO( + name=f"{domain}", + type=DNSRecordType.SOA, + records=[ + DNSRecordDTO( + content=f"ns1.{domain} hostmaster.{domain}" + + " 1 10800 3600 604800 3600", + disabled=False, + modified_at=None, + ), + ], + changetype=PowerDNSRecordChangeType.EXTEND, + ttl=3600, + ), + ] diff --git a/dnsdist.conf b/dnsdist.conf new file mode 100644 index 000000000..7446a4db5 --- /dev/null +++ b/dnsdist.conf @@ -0,0 +1,6 @@ +setLocal('0.0.0.0:53') +controlSocket('0.0.0.0:8084') +setKey('PSAag0AEziPZuBB7kdcfIEkVJOyQInRcBRAhadWDpU0=') +addConsoleACL('172.20.0.0/24') +includeDirectory('/etc/dnsdist/conf.d/') +setACL('0.0.0.0/0') diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 4117f9441..a911892b5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,6 +2,8 @@ services: traefik: image: "traefik:v3.6.1" container_name: traefik + networks: + md_net: restart: unless-stopped command: # - --metrics @@ -42,6 +44,8 @@ services: traefik_certs_dumper: image: multidirectory container_name: traefik_certs_dumper + networks: + md_net: restart: "on-failure" volumes: - ./certs:/certs @@ -54,6 +58,8 @@ services: interface: container_name: multidirectory_interface + networks: + md_net: # image: ghcr.io/multidirectorylab/multidirectory-web-admin::beta restart: unless-stopped build: @@ -79,6 +85,8 @@ services: cert_check: image: multidirectory container_name: multidirectory_certs_check + networks: + md_net: restart: "no" volumes: - ./certs:/certs @@ -93,6 +101,8 @@ services: DOCKER_BUILDKIT: 1 target: runtime image: multidirectory + networks: + md_net: restart: unless-stopped environment: - SERVICE_NAME=cldap_server @@ -129,6 +139,8 @@ services: DOCKER_BUILDKIT: 1 target: runtime image: multidirectory + networks: + md_net: restart: unless-stopped deploy: mode: replicated @@ -179,6 +191,8 @@ services: cert_local_check: image: multidirectory container_name: multidirectory_local_certs_check + networks: + md_net: restart: "no" volumes: - ./certs:/certs @@ -187,6 +201,8 @@ services: migrations: image: multidirectory container_name: multidirectory_migrations + networks: + md_net: restart: "no" command: python multidirectory.py --migrate env_file: @@ -206,6 +222,8 @@ services: DOCKER_BUILDKIT: 1 target: runtime image: multidirectory + networks: + md_net: restart: unless-stopped hostname: multidirectory volumes: @@ -261,6 +279,8 @@ services: USE_CORE_TLS: 1 SERVICE_NAME: multidirectory_api KRB5_LDAP_URI: ldap://ldap_server + networks: + md_net: hostname: api_server env_file: local.env @@ -288,6 +308,8 @@ services: shadow_api: image: multidirectory container_name: shadow_api + networks: + md_net: restart: unless-stopped tty: true depends_on: @@ -308,6 +330,8 @@ services: maintence: image: multidirectory container_name: md_maintence + networks: + md_net: restart: unless-stopped volumes: - ./certs:/certs @@ -332,6 +356,8 @@ services: postgres: container_name: MD-postgres + networks: + md_net: image: postgres:16 restart: unless-stopped environment: @@ -356,6 +382,8 @@ services: args: VERSION: beta container_name: kdc + networks: + md_net: restart: unless-stopped hostname: kerberos volumes: @@ -375,6 +403,8 @@ services: kadmin_api: image: krb5md container_name: kadmin-api + networks: + md_net: restart: unless-stopped volumes: - ./certs:/certs @@ -395,6 +425,8 @@ services: kadmind: container_name: kadmind + networks: + md_net: restart: unless-stopped hostname: kerberos volumes: @@ -421,6 +453,8 @@ services: dragonfly: image: 'docker.dragonflydb.io/dragonflydb/dragonfly' container_name: dragonfly + networks: + md_net: restart: always expose: - 6379 @@ -433,30 +467,99 @@ services: cpus: '0.25' memory: 0.5GiB - bind_dns: + kea_dhcp4: + image: kea_image:0.1 + network_mode: host + cap_add: + - NET_ADMIN build: context: . - dockerfile: ./.docker/bind9.Dockerfile - image: bind9md - container_name: bind9 - hostname: bind9 + dockerfile: ./.docker/kea.Dockerfile + container_name: kea_dhcp4 + tty: true restart: unless-stopped - environment: - - DEFAULT_NAMESERVER=192.168.69.241 - - USE_CONFIG_FILE_LOGGING=true + command: -c /kea/config/kea-dhcp4.conf volumes: - - dns_server_file:/opt/ - - dns_server_config:/etc/bind/ - - .dns/:/server/ + - dhcp:/kea/config + - sockets:/kea/sockets + - leases:/kea/leases + + kea_ctrl_agent: + image: jonasal/kea-ctrl-agent:3.1.2-alpine + container_name: kea_ctrl_agent + networks: + md_net: + restart: unless-stopped + command: -c /kea/config/kea-ctrl-agent.conf tty: true depends_on: - ldap_server: - condition: service_healthy - restart: true - labels: - - traefik.enable=true - - traefik.udp.routers.bind_dns_udp.entrypoints=bind_dns_udp - - traefik.udp.services.bind_dns_udp.loadbalancer.server.port=53 + kea_dhcp4: + condition: service_started + volumes: + - ./.package/kea-ctrl-agent.conf:/kea/config/kea-ctrl-agent.conf + - sockets:/kea/sockets + - leases:/kea/leases + + pdns_auth: + build: + context: . + dockerfile: ./.docker/pdns_auth.Dockerfile + args: + DOCKER_BUILDKIT: 1 + image: pdns_auth_md + container_name: pdns_auth + networks: + default: + md_net: + ipv4_address: 172.20.0.202 + expose: + - 8082 + - 53/udp + - 53/tcp + volumes: + - dns_lmdb:/var/lib/pdns-lmdb + - dns_config:/etc/powerdns + + + pdnsdist: + image: powerdns/dnsdist-19:1.9.11 + container_name: pdnsdist + networks: + default: + md_net: + ipv4_address: 172.20.0.201 + expose: + - 8084 + ports: + - "53:53/tcp" + - "53:53/udp" + volumes: + - ./dnsdist.conf:/etc/dnsdist/dnsdist.conf + - dnsdist_confd:/etc/dnsdist/conf.d + + + pdns_recursor: + image: powerdns/pdns-recursor-51:5.1.7 + container_name: pdns_recursor + networks: + default: + md_net: + ipv4_address: 172.20.0.200 + expose: + - 8083 + - 53/udp + - 53/tcp + volumes: + - ./.package/recursor.conf:/etc/powerdns/recursor.conf + - forward_zones:/etc/powerdns/recursor.d/ + +networks: + md_net: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/24 + gateway: 172.20.0.1 event_handler: image: multidirectory diff --git a/docker-compose.remote.test.yml b/docker-compose.remote.test.yml index 7628ad3e3..4c4556cee 100644 --- a/docker-compose.remote.test.yml +++ b/docker-compose.remote.test.yml @@ -5,6 +5,8 @@ services: environment: DEBUG: 1 DOMAIN: md.test + PDNS_API_KEY: testkey123 + PDNS_DIST_KEY: testkey123 DEFAULT_NAMESERVER: 127.0.0.1 HOST_MACHINE_NAME: DC1 POSTGRES_USER: user1 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 120a894f6..2c0ac63d2 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -18,6 +18,8 @@ services: DEFAULT_NAMESERVER: 127.0.0.1 POSTGRES_USER: user1 POSTGRES_PASSWORD: password123 + PDNS_API_KEY: testkey123 + PDNS_DIST_KEY: testkey123 SECRET_KEY: 6a0452ae20cab4e21b6e9d18fa4b7bf397dd66ec3968b2d7407694278fd84cce POSTGRES_HOST: postgres # PYTHONTRACEMALLOC: 1 diff --git a/docker-compose.yml b/docker-compose.yml index 0516e4eb4..47dc60214 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,8 @@ services: traefik: image: "traefik:v3.6.1" container_name: "traefik" + networks: + md_net: command: - "--providers.file.filename=/traefik.yml" ports: @@ -17,8 +19,6 @@ services: - "636:636" - "749:749" - "464:464" - - "53:53" - - "53:53/udp" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" - "./certs:/certs" @@ -32,6 +32,8 @@ services: DOCKER_BUILDKIT: 1 target: runtime image: multidirectory + networks: + md_net: restart: unless-stopped environment: - SERVICE_NAME=ldap_server @@ -86,6 +88,8 @@ services: DOCKER_BUILDKIT: 1 target: runtime image: multidirectory + networks: + md_net: user: root restart: unless-stopped environment: @@ -123,6 +127,8 @@ services: DOCKER_BUILDKIT: 1 target: runtime image: multidirectory + networks: + md_net: restart: unless-stopped deploy: mode: replicated @@ -171,12 +177,15 @@ services: api: image: multidirectory container_name: multidirectory_api + networks: + md_net: volumes: - ./app:/app - ./certs:/certs - dns_server_file:/DNS_server_file/ - dns_server_config:/DNS_server_configs/ - ldap_keytab:/LDAP_keytab/ + - dnsdist_confd:/dnsdist env_file: local.env command: python multidirectory.py --http environment: @@ -204,6 +213,8 @@ services: migrations: image: multidirectory container_name: multidirectory_migrations + networks: + md_net: restart: "no" volumes: - ./app:/app @@ -217,6 +228,8 @@ services: cert_check: image: multidirectory container_name: multidirectory_certs_check + networks: + md_net: restart: "no" volumes: - ./certs:/certs @@ -226,6 +239,8 @@ services: cert_local_check: image: multidirectory container_name: multidirectory_local_certs_check + networks: + md_net: restart: "no" volumes: - ./certs:/certs @@ -233,6 +248,8 @@ services: postgres: container_name: MD-postgres + networks: + md_net: image: postgres:16 restart: unless-stopped ports: @@ -252,6 +269,8 @@ services: pgadmin: container_name: pgadmin_container + networks: + md_net: image: dpage/pgadmin4 environment: PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-pgadmin4@pgadmin.org} @@ -271,6 +290,8 @@ services: kadmin_api: image: krb5md container_name: kadmin_api + networks: + md_net: restart: unless-stopped volumes: - ./certs:/certs @@ -290,31 +311,6 @@ services: working_dir: /server command: ./entrypoint.sh - bind_dns: - build: - context: . - dockerfile: ./.docker/bind9.Dockerfile - image: bind9md - container_name: bind9 - hostname: bind9 - restart: unless-stopped - environment: - - DEFAULT_NAMESERVER=127.0.0.2 - - USE_CONFIG_FILE_LOGGING=true - volumes: - - dns_server_file:/opt/ - - dns_server_config:/etc/bind/ - - .dns/:/server/ - tty: true - depends_on: - ldap_server: - condition: service_healthy - restart: true - labels: - - traefik.enable=true - - traefik.udp.routers.bind_dns_udp.entrypoints=bind_dns_udp - - traefik.udp.services.bind_dns_udp.loadbalancer.server.port=53 - kea_dhcp4: image: kea_image:0.1 network_mode: host @@ -335,6 +331,8 @@ services: kea_ctrl_agent: image: jonasal/kea-ctrl-agent:3.1.2-alpine container_name: kea_ctrl_agent + networks: + md_net: restart: unless-stopped command: -c /kea/config/kea-ctrl-agent.conf tty: true @@ -353,6 +351,8 @@ services: args: VERSION: beta container_name: kdc + networks: + md_net: hostname: kerberos restart: unless-stopped volumes: @@ -371,6 +371,8 @@ services: kadmind: container_name: kadmind + networks: + md_net: restart: unless-stopped hostname: kerberos volumes: @@ -399,6 +401,8 @@ services: shadow_api: image: multidirectory container_name: shadow_api + networks: + md_net: restart: unless-stopped tty: true depends_on: @@ -420,6 +424,8 @@ services: maintence: image: multidirectory container_name: md_maintence + networks: + md_net: restart: unless-stopped volumes: - ./certs:/certs @@ -443,6 +449,8 @@ services: interface: container_name: multidirectory_interface + networks: + md_net: build: context: ./interface dockerfile: configurations/docker/Dockerfile.dev @@ -465,6 +473,8 @@ services: dragonfly: image: "docker.dragonflydb.io/dragonflydb/dragonfly" container_name: dragonfly + networks: + md_net: expose: - 6379 deploy: @@ -478,6 +488,8 @@ services: redis-commander: container_name: redis-commander + networks: + md_net: hostname: redis-commander image: ghcr.io/joeferner/redis-commander:latest restart: always @@ -494,6 +506,8 @@ services: event_handler: image: multidirectory container_name: event_handler + networks: + md_net: restart: unless-stopped tty: true env_file: local.env @@ -509,6 +523,8 @@ services: event_sender: image: multidirectory container_name: event_sender + networks: + md_net: restart: unless-stopped tty: true depends_on: @@ -525,12 +541,72 @@ services: syslog: image: balabit/syslog-ng:latest container_name: syslog-server + networks: + md_net: volumes: - ./syslog:/var/log - ./syslog-ng.conf:/etc/syslog-ng/syslog-ng.conf privileged: true restart: always + pdns_auth: + build: + context: . + dockerfile: ./.docker/pdns_auth.Dockerfile + args: + DOCKER_BUILDKIT: 1 + image: pdns_auth_md + container_name: pdns_auth + networks: + md_net: + ipv4_address: 172.20.0.202 + expose: + - 8082 + - 53/udp + - 53/tcp + volumes: + - dns_lmdb:/var/lib/pdns-lmdb + - dns_config:/etc/powerdns + + + pdnsdist: + image: powerdns/dnsdist-19:1.9.11 + container_name: pdnsdist + networks: + md_net: + ipv4_address: 172.20.0.201 + expose: + - 8084 + ports: + - "53:53/tcp" + - "53:53/udp" + volumes: + - ./dnsdist.conf:/etc/dnsdist/dnsdist.conf + - dnsdist_confd:/etc/dnsdist/conf.d + + + pdns_recursor: + image: powerdns/pdns-recursor-51:5.1.7 + container_name: pdns_recursor + networks: + md_net: + ipv4_address: 172.20.0.200 + expose: + - 8083 + - 53/udp + - 53/tcp + volumes: + - ./.package/recursor.conf:/etc/powerdns/recursor.conf + - forward_zones:/etc/powerdns/recursor.d/ + +networks: + md_net: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/24 + gateway: 172.20.0.1 + volumes: postgres: pgadmin: @@ -543,3 +619,7 @@ volumes: leases: sockets: dhcp: + dns_lmdb: + dns_config: + forward_zones: + dnsdist_confd: diff --git a/local.env b/local.env index 9a6b9d9b1..eb0aa533e 100644 --- a/local.env +++ b/local.env @@ -7,4 +7,6 @@ POSTGRES_PASSWORD=password123 SECRET_KEY=6a0452ae20cab4e21b6e9d18fa4b7bf397dd66ec3968b2d7407694278fd84cce MFA_API_SOURCE=dev ACCESS_TOKEN_EXPIRE_MINUTES=180 -DEFAULT_NAMESERVER=127.0.0.1 +DEFAULT_NAMESERVER=172.20.0.4 +PDNS_API_KEY=supersecretapikey +PDNS_DIST_KEY=PSAag0AEziPZuBB7kdcfIEkVJOyQInRcBRAhadWDpU0= diff --git a/pyproject.toml b/pyproject.toml index f7adf0e26..a2ccd8416 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "bcrypt==4.0.1", "cryptography>=44.0.1", "dishka>=1.6.0", + "dnsdist-console>=1.6.0", "dnspython>=2.7.0", "fastapi>=0.115.0", "fastapi-error-map>=0.9.8", diff --git a/tests/conftest.py b/tests/conftest.py index 51c8a9dc7..364f086de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,6 @@ import asyncio import os import uuid -import weakref from contextlib import suppress from dataclasses import dataclass from typing import AsyncGenerator, AsyncIterator, Generator, Iterator @@ -50,10 +49,10 @@ ) from api.auth.utils import get_ip_from_request, get_user_agent_from_request from api.dhcp.adapter import DHCPAdapter +from api.dns.adapter import DNSFastAPIAdapter from api.ldap_schema.adapters.attribute_type import AttributeTypeFastAPIAdapter from api.ldap_schema.adapters.entity_type import LDAPEntityTypeFastAPIAdapter from api.ldap_schema.adapters.object_class import ObjectClassFastAPIAdapter -from api.main.adapters.dns import DNSFastAPIAdapter from api.main.adapters.kerberos import KerberosFastAPIAdapter from api.network.adapters.network import NetworkPolicyFastAPIAdapter from api.password_policy.adapter import ( @@ -74,11 +73,10 @@ from ldap_protocol.dialogue import LDAPSession from ldap_protocol.dns import ( AbstractDNSManager, - DNSManagerSettings, + DNSSettingsDTO, StubDNSManager, ) from ldap_protocol.dns.dns_gateway import DNSStateGateway -from ldap_protocol.dns.dto import DNSSettingDTO from ldap_protocol.dns.use_cases import DNSUseCase from ldap_protocol.identity import IdentityProvider from ldap_protocol.identity.provider_gateway import IdentityProviderGateway @@ -209,7 +207,7 @@ async def get_kadmin(self) -> AsyncIterator[AsyncMock]: self._cached_kadmin = None - @provide(scope=Scope.REQUEST, provides=AbstractDHCPManager) + @provide(scope=Scope.APP, provides=AbstractDHCPManager) async def get_dhcp_mngr(self) -> AsyncIterator[AsyncMock]: """Get mock DHCP manager.""" dhcp_manager = AsyncMock(spec=StubDHCPManager) @@ -221,60 +219,58 @@ async def get_dhcp_mngr(self) -> AsyncIterator[AsyncMock]: self._cached_dhcp_manager = None - @provide(scope=Scope.REQUEST, provides=AbstractDNSManager) + @provide(scope=Scope.APP, provides=AbstractDNSManager) async def get_dns_mngr(self) -> AsyncIterator[AsyncMock]: """Get mock DNS manager.""" dns_manager = AsyncMock(spec=StubDNSManager) - dns_manager.setup.return_value = DNSSettingDTO( - zone_name="example.com", - dns_server_ip="127.0.0.1", - tsig_key=None, - ) - dns_manager.get_all_records.return_value = [ + dns_manager.get_records.return_value = [ { + "name": "example.com", "type": "A", "records": [ { - "name": "example.com", - "value": "127.0.0.1", - "ttl": 3600, + "content": "127.0.0.1", + "disabled": False, + "modified_at": None, }, ], - }, - ] - dns_manager.get_server_options.return_value = [ - { - "name": "dnssec-validation", - "value": "no", + "ttl": 3600, }, ] dns_manager.get_forward_zones.return_value = [ { - "name": "test.local", - "type": "forward", - "forwarders": [ - "127.0.0.1", - "127.0.0.2", - ], + "id": "forward1", + "name": "forward1.", + "rrsets": [], + "kind": "Forwarded", + "type": "zone", + "servers": ["127.0.0.1"], + "recursion_desired": False, }, ] - dns_manager.get_all_zones_records.return_value = [ + dns_manager.get_master_zones.return_value = [ { - "name": "test.local", - "type": "master", - "records": [ + "id": "zone1", + "name": "example.com.", + "rrsets": [ { + "name": "example.com", "type": "A", "records": [ { - "name": "example.com", - "value": "127.0.0.1", - "ttl": 3600, + "content": "127.0.0.1", + "disabled": False, + "modified_at": None, }, ], + "ttl": 3600, }, ], + "dnssec": False, + "nameservers": ["ns1.example.com."], + "kind": "Master", + "type": "zone", }, ] @@ -285,19 +281,14 @@ async def get_dns_mngr(self) -> AsyncIterator[AsyncMock]: self._cached_dns_manager = None - @provide(scope=Scope.REQUEST, provides=DNSManagerSettings, cache=False) + @provide(scope=Scope.REQUEST, provides=DNSSettingsDTO, cache=False) async def get_dns_mngr_settings( self, dns_state_gateway: DNSStateGateway, - ) -> AsyncIterator["DNSManagerSettings"]: + settings: Settings, + ) -> AsyncIterator["DNSSettingsDTO"]: """Get DNS manager's settings.""" - - async def resolve() -> str: - return "127.0.0.1" - - resolver = resolve() - yield await dns_state_gateway.get_dns_manager_settings(resolver) - weakref.finalize(resolver, resolver.close) + yield await dns_state_gateway.get_dns_manager_settings(settings) attribute_type_dao = provide(AttributeTypeDAO, scope=Scope.REQUEST) object_class_dao = provide(ObjectClassDAO, scope=Scope.REQUEST) diff --git a/tests/test_api/test_main/test_dns.py b/tests/test_api/test_main/test_dns.py index 6d521408c..2838166f4 100644 --- a/tests/test_api/test_main/test_dns.py +++ b/tests/test_api/test_main/test_dns.py @@ -1,19 +1,12 @@ """Test DNS service.""" -from dataclasses import asdict - import pytest from httpx import AsyncClient from starlette import status -from ldap_protocol.dns import ( - AbstractDNSManager, - DNSManagerState, - DNSServerParam, - DNSServerParamName, - DNSZoneParam, - DNSZoneParamName, -) +from ldap_protocol.dns import AbstractDNSManager +from ldap_protocol.dns.dto import DNSMasterZoneDTO, DNSRecordDTO, DNSRRSetDTO +from ldap_protocol.dns.enums import DNSRecordType, PowerDNSZoneType @pytest.mark.asyncio @@ -26,12 +19,11 @@ async def test_dns_create_record( zone_name = "hello.zone" hostname = "hello" ip = "127.0.0.1" - record_type = "A" + record_type = DNSRecordType.A ttl = 3600 response = await http_client.post( - "/dns/record", + f"/dns/record/{zone_name}", json={ - "zone_name": zone_name, "record_name": hostname, "record_value": ip, "record_type": record_type, @@ -42,7 +34,20 @@ async def test_dns_create_record( dns_manager.create_record.assert_called() # type: ignore assert ( dns_manager.create_record.call_args.args # type: ignore - ) == (hostname, ip, record_type, int(ttl), zone_name) + ) == ( + zone_name, + DNSRRSetDTO( + name=hostname, + type=record_type, + records=[ + DNSRecordDTO( + content=ip, + disabled=False, + ), + ], + ttl=ttl, + ), + ) assert response.status_code == status.HTTP_200_OK @@ -57,12 +62,11 @@ async def test_dns_delete_record( zone_name = "hello.zone" hostname = "hello" ip = "127.0.0.1" - record_type = "A" + record_type = DNSRecordType.A response = await http_client.request( "DELETE", - "/dns/record", + f"/dns/record/{zone_name}", json={ - "zone_name": zone_name, "record_name": hostname, "record_value": ip, "record_type": record_type, @@ -72,7 +76,19 @@ async def test_dns_delete_record( dns_manager.delete_record.assert_called() # type: ignore assert ( dns_manager.delete_record.call_args.args # type: ignore - ) == (hostname, ip, record_type, zone_name) + ) == ( + zone_name, + DNSRRSetDTO( + name=hostname, + type=record_type, + records=[ + DNSRecordDTO( + content=ip, + disabled=False, + ), + ], + ), + ) assert response.status_code == status.HTTP_200_OK @@ -87,13 +103,12 @@ async def test_dns_update_record( zone_name = "hello.zone" hostname = "hello" ip = "127.0.0.1" - record_type = "A" + record_type = DNSRecordType.A ttl = 3600 response = await http_client.request( "PATCH", - "/dns/record", + f"/dns/record/{zone_name}", json={ - "zone_name": zone_name, "record_name": hostname, "record_value": ip, "record_type": record_type, @@ -104,7 +119,20 @@ async def test_dns_update_record( dns_manager.update_record.assert_called() # type: ignore assert ( dns_manager.update_record.call_args.args # type: ignore - ) == (hostname, ip, record_type, int(ttl), zone_name) + ) == ( + zone_name, + DNSRRSetDTO( + name=hostname, + type=record_type, + records=[ + DNSRecordDTO( + content=ip, + disabled=False, + ), + ], + ttl=ttl, + ), + ) assert response.status_code == status.HTTP_200_OK @@ -113,21 +141,25 @@ async def test_dns_update_record( @pytest.mark.usefixtures("session") async def test_dns_get_all_records(http_client: AsyncClient) -> None: """DNS Manager get all records test.""" - response = await http_client.get("/dns/record") + zone_name = "hello.zone" + response = await http_client.get(f"/dns/record/{zone_name}") assert response.status_code == status.HTTP_200_OK data = response.json() assert data == [ { + "name": "example.com", "type": "A", + "changetype": None, "records": [ { - "name": "example.com", - "value": "127.0.0.1", - "ttl": 3600, + "content": "127.0.0.1", + "disabled": False, + "modified_at": None, }, ], + "ttl": 3600, }, ] @@ -139,18 +171,12 @@ async def test_dns_setup_selfhosted( dns_manager: AbstractDNSManager, ) -> None: """DNS Manager setup test.""" - dns_status = DNSManagerState.SELFHOSTED - domain = "example.com" - tsig_key = None - dns_ip_address = "127.0.0.1" + response = await http_client.post("/dns/state", json={"state": "1"}) + + assert response.status_code == status.HTTP_200_OK + response = await http_client.post( "/dns/setup", - json={ - "dns_status": dns_status, - "domain": domain, - "dns_ip_address": dns_ip_address, - "tsig_key": tsig_key, - }, ) assert response.status_code == status.HTTP_200_OK @@ -182,28 +208,31 @@ async def test_dns_create_zone( ) -> None: """DNS Manager create zone test.""" zone_name = "hello" - zone_type = "master" - nameserver = None - params = [ - DNSZoneParam( - DNSZoneParamName.acl, - ["127.0.0.1"], - ), - ] + nameserver = "192.168.1.1" response = await http_client.post( "/dns/zone", json={ "zone_name": zone_name, - "zone_type": zone_type, - "params": [asdict(param) for param in params], + "nameserver_ip": nameserver, + "dnssec": False, }, ) assert response.status_code == status.HTTP_200_OK - dns_manager.create_zone.assert_called() # type: ignore + dns_manager.create_master_zone.assert_called() # type: ignore assert ( - dns_manager.create_zone.call_args.args # type: ignore - ) == (zone_name, zone_type, nameserver, params) + dns_manager.create_master_zone.call_args.args # type: ignore + ) == ( + DNSMasterZoneDTO( + id=zone_name, + rrsets=[], + name=zone_name, + dnssec=False, + type="zone", + nameservers=[], + kind=PowerDNSZoneType.MASTER, + ), + ) @pytest.mark.asyncio @@ -215,25 +244,31 @@ async def test_dns_update_zone( ) -> None: """DNS Manager update zone test.""" zone_name = "hello" - params = [ - DNSZoneParam( - DNSZoneParamName.acl, - ["127.0.0.1"], - ), - ] + nameserver = "192.168.1.1" response = await http_client.patch( "/dns/zone", json={ "zone_name": zone_name, - "params": [asdict(param) for param in params], + "nameserver_ip": nameserver, + "dnssec": False, }, ) assert response.status_code == status.HTTP_200_OK - dns_manager.update_zone.assert_called() # type: ignore + dns_manager.update_master_zone.assert_called() # type: ignore assert ( - dns_manager.update_zone.call_args.args # type: ignore - ) == (zone_name, params) + dns_manager.update_master_zone.call_args.args # type: ignore + ) == ( + DNSMasterZoneDTO( + id=zone_name, + rrsets=[], + name=zone_name, + dnssec=False, + type="zone", + nameservers=[], + kind=PowerDNSZoneType.MASTER, + ), + ) @pytest.mark.asyncio @@ -244,67 +279,19 @@ async def test_dns_delete_zone( dns_manager: AbstractDNSManager, ) -> None: """DNS Manager delete zone test.""" - zone_names = ["hello"] + zone_ids = ["hello"] response = await http_client.request( "DELETE", "/dns/zone", - json={"zone_names": zone_names}, - ) - - assert response.status_code == status.HTTP_200_OK - dns_manager.delete_zone.assert_called() # type: ignore - assert ( - dns_manager.delete_zone.call_args.args # type: ignore - ) == (zone_names,) - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("add_dns_settings") -@pytest.mark.usefixtures("session") -async def test_dns_update_server_options( - http_client: AsyncClient, - dns_manager: AbstractDNSManager, -) -> None: - """DNS Manager update DNS server options test.""" - params = [ - DNSServerParam( - DNSServerParamName.dnssec, - ["127.0.0.1"], - ), - ] - response = await http_client.patch( - "/dns/server/options", - json=[asdict(param) for param in params], + json={"zone_ids": zone_ids}, ) assert response.status_code == status.HTTP_200_OK - dns_manager.update_server_options.assert_called() # type: ignore + dns_manager.delete_master_zone.assert_called() # type: ignore assert ( - dns_manager.update_server_options.call_args.args # type: ignore - ) == (params,) - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("add_dns_settings") -@pytest.mark.usefixtures("session") -async def test_dns_get_server_options( - http_client: AsyncClient, - dns_manager: AbstractDNSManager, -) -> None: - """DNS Manager get DNS server options test.""" - response = await http_client.get("/dns/server/options") - - assert response.status_code == status.HTTP_200_OK - dns_manager.get_server_options.assert_called() # type: ignore - - data = response.json() - assert data == [ - { - "name": "dnssec-validation", - "value": "no", - }, - ] + dns_manager.delete_master_zone.call_args.args # type: ignore + ) == (zone_ids[0],) @pytest.mark.asyncio @@ -318,25 +305,32 @@ async def test_dns_get_all_zones_with_records( response = await http_client.get("/dns/zone") assert response.status_code == status.HTTP_200_OK - dns_manager.get_all_zones_records.assert_called() # type: ignore + dns_manager.get_master_zones.assert_called() # type: ignore data = response.json() assert data == [ { - "name": "test.local", - "type": "master", - "records": [ + "id": "zone1", + "name": "example.com.", + "rrsets": [ { + "name": "example.com", "type": "A", + "changetype": None, "records": [ { - "name": "example.com", - "value": "127.0.0.1", - "ttl": 3600, + "content": "127.0.0.1", + "disabled": False, + "modified_at": None, }, ], + "ttl": 3600, }, ], + "dnssec": False, + "nameservers": ["ns1.example.com."], + "kind": "Master", + "type": "zone", }, ] @@ -357,11 +351,12 @@ async def test_dns_get_all_forward_zones( data = response.json() assert data == [ { - "name": "test.local", - "type": "forward", - "forwarders": [ - "127.0.0.1", - "127.0.0.2", - ], + "id": "forward1", + "name": "forward1.", + "rrsets": [], + "kind": "Forwarded", + "type": "zone", + "servers": ["127.0.0.1"], + "recursion_desired": False, }, ] diff --git a/traefik.yml b/traefik.yml index d2cf4340f..cdb9a6ee3 100644 --- a/traefik.yml +++ b/traefik.yml @@ -40,8 +40,6 @@ entryPoints: address: ":749" kpasswd: address: ":464" - bind_dns_udp: - address: ":53/udp" tls: stores: diff --git a/uv.lock b/uv.lock index 85838a5a8..8154ccc0e 100644 --- a/uv.lock +++ b/uv.lock @@ -251,6 +251,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/89381173b4f336e986d72471198614806cd313e0f85c143ccb677c310223/dishka-1.7.2-py3-none-any.whl", hash = "sha256:f6faa6ab321903926b825b3337d77172ee693450279b314434864978d01fbad3", size = 94774, upload-time = "2025-09-24T21:23:03.246Z" }, ] +[[package]] +name = "dnsdist-console" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "libnacl" }, + { name = "scrypt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/f9/1d3eb92c2a94af1fd970b42e48584f544e832a3d40c86d14340f1def78db/dnsdist_console-1.6.0.tar.gz", hash = "sha256:4afb35b52640db5c4865aa6458147651c757907465204497e6c74f36f5a7eb0a", size = 10337, upload-time = "2025-04-02T10:29:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/54/5005c1355e3d97ee4cf7e5d118836d898d5ed58470ab8a63139dead16b14/dnsdist_console-1.6.0-py3-none-any.whl", hash = "sha256:074056c0364d6450636051bb1d49a0d07620ae9cf14b1b9ce17f130b8585adce", size = 11336, upload-time = "2025-04-02T10:29:52.132Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -474,6 +487,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192, upload-time = "2021-07-18T06:34:12.905Z" }, ] +[[package]] +name = "libnacl" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/fc/65daa1a3fd7dd939133c30c6d393ea47e32317d2195619923b67daa29d60/libnacl-2.1.0.tar.gz", hash = "sha256:f3418da7df29e6d9b11fd7d990289d16397dc1020e4e35192e11aee826922860", size = 42189, upload-time = "2023-08-06T21:23:56.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/ce/85fa0276de7303b44fef63e07c14d618b8630bbe41c7dd7e34db246eab8d/libnacl-2.1.0-py3-none-any.whl", hash = "sha256:a8546b221afe8b72b6a9f298cd92a4c1f90570d7b5baa295acb1913644e230a5", size = 21870, upload-time = "2023-08-06T21:23:55.12Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -543,6 +565,7 @@ dependencies = [ { name = "bcrypt" }, { name = "cryptography" }, { name = "dishka" }, + { name = "dnsdist-console" }, { name = "dnspython" }, { name = "fastapi" }, { name = "fastapi-error-map" }, @@ -597,6 +620,7 @@ requires-dist = [ { name = "bcrypt", specifier = "==4.0.1" }, { name = "cryptography", specifier = ">=44.0.1" }, { name = "dishka", specifier = ">=1.6.0" }, + { name = "dnsdist-console", specifier = ">=1.6.0" }, { name = "dnspython", specifier = ">=2.7.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "fastapi-error-map", specifier = ">=0.9.8" }, @@ -1013,6 +1037,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/f7/70aad26e5877c8f7ee5b161c4c9fa0100e63fc4c944dc6d97b9c7e871417/ruff-0.11.9-py3-none-win_arm64.whl", hash = "sha256:bcf42689c22f2e240f496d0c183ef2c6f7b35e809f12c1db58f75d9aa8d630ca", size = 10741080, upload-time = "2025-05-09T16:19:39.605Z" }, ] +[[package]] +name = "scrypt" +version = "0.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/38/c9b79f61c04fa79b8fae28213111a6f70d8249d4d789ca7030453326ab62/scrypt-0.9.4.tar.gz", hash = "sha256:0d212010ba8c2e55475ba6258f30cee4da0432017514d8f6e855b7f1f8c55c77", size = 84526, upload-time = "2025-08-05T05:54:37.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/22/98e17e1ea6461a5c51c866192304182846fd004852f789dfece9f44c6553/scrypt-0.9.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:58f424ac1656d342b2651bf5577f1b2aad9959c2e41ebbadf591035b372368a9", size = 2293992, upload-time = "2025-08-05T06:00:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/076f90cb1086e32ebab30123952f4f162c80c53b8814e35bedb4aa720241/scrypt-0.9.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efaa359fab4682215d8826c5ff6ecda525d37eabfc0da4ab197a3fd95e6d5f87", size = 1508819, upload-time = "2025-08-05T06:04:54.445Z" }, + { url = "https://files.pythonhosted.org/packages/87/c0/d59f086fc8a589db06eac0b3829e2956de7a4c34d7c99c20b3cf6a858627/scrypt-0.9.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee29481f0751eb4e91c4fca8895d44822d523225c796e0ed016a550e7f20f582", size = 2015695, upload-time = "2025-08-05T06:04:55.477Z" }, + { url = "https://files.pythonhosted.org/packages/98/0d/f62590144acf914a2eb4c3689cc6a2ca737a379962b0358f00dd0a1445cb/scrypt-0.9.4-cp313-cp313-win_amd64.whl", hash = "sha256:6ae6a0f7ccf7df9f0612b9166abbff5b6dacc41661044a0d090111aeb5e0bcf2", size = 47267, upload-time = "2025-08-05T06:03:57.035Z" }, +] + [[package]] name = "setuptools" version = "80.9.0" From bc737959e1680b9c066f72fba88a7213dc2de3ef Mon Sep 17 00:00:00 2001 From: Ruslan Date: Fri, 20 Feb 2026 16:04:06 +0300 Subject: [PATCH 27/45] Fix krbadmin read access (#940) --- .../19d86e660cf2_fix_krbadmin_access.py | 52 +++++++++++++++++++ app/ldap_protocol/kerberos/ldap_structure.py | 1 + app/ldap_protocol/roles/ace_dao.py | 4 +- app/ldap_protocol/roles/role_use_case.py | 25 +++++++++ 4 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 app/alembic/versions/19d86e660cf2_fix_krbadmin_access.py diff --git a/app/alembic/versions/19d86e660cf2_fix_krbadmin_access.py b/app/alembic/versions/19d86e660cf2_fix_krbadmin_access.py new file mode 100644 index 000000000..87ed5c90d --- /dev/null +++ b/app/alembic/versions/19d86e660cf2_fix_krbadmin_access.py @@ -0,0 +1,52 @@ +"""Fix krbadmin access. + +Revision ID: 19d86e660cf2 +Revises: ebf19750805e +Create Date: 2026-02-19 11:40:15.805997 + +""" + +from alembic import op +from dishka import AsyncContainer, Scope +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession + +from enums import RoleConstants +from ldap_protocol.roles.exceptions import RoleNotFoundError +from ldap_protocol.roles.role_dao import RoleDAO +from ldap_protocol.roles.role_use_case import RoleUseCase +from ldap_protocol.utils.queries import get_base_directories + +# revision identifiers, used by Alembic. +revision: None | str = "19d86e660cf2" +down_revision: None | str = "ebf19750805e" +branch_labels: None | list[str] = None +depends_on: None | list[str] = None + + +def upgrade(container: AsyncContainer) -> None: + """Upgrade.""" + + async def _fix_krbadmin_role(connection: AsyncConnection) -> None: # noqa: ARG001 + async with container(scope=Scope.REQUEST) as cnt: + session = await cnt.get(AsyncSession) + role_dao = await cnt.get(RoleDAO) + role_use_case = await cnt.get(RoleUseCase) + + base_dn_list = await get_base_directories(session) + if not base_dn_list: + return + + try: + await role_dao.get_by_name(RoleConstants.KERBEROS_ROLE_NAME) + except RoleNotFoundError: + return + else: + await role_use_case.add_read_only_role_to_krbadmin_group() + + await session.commit() + + op.run_async(_fix_krbadmin_role) + + +def downgrade(container: AsyncContainer) -> None: + """Downgrade.""" diff --git a/app/ldap_protocol/kerberos/ldap_structure.py b/app/ldap_protocol/kerberos/ldap_structure.py index 45228a3c8..fec8741c0 100644 --- a/app/ldap_protocol/kerberos/ldap_structure.py +++ b/app/ldap_protocol/kerberos/ldap_structure.py @@ -68,6 +68,7 @@ async def create_kerberos_structure( async with self._session.begin_nested(): await self._role_use_case.create_kerberos_system_role() + await self._role_use_case.add_read_only_role_to_krbadmin_group() user_result = await anext(krb_user.handle(ctx)) if user_result.result_code != 0: raise KerberosConflictError("User error") diff --git a/app/ldap_protocol/roles/ace_dao.py b/app/ldap_protocol/roles/ace_dao.py index 679268cfd..202060115 100644 --- a/app/ldap_protocol/roles/ace_dao.py +++ b/app/ldap_protocol/roles/ace_dao.py @@ -192,7 +192,6 @@ async def create_bulk(self, dtos: list[AccessControlEntryDTO]) -> None: objects to create. """ directory_cache = {} - new_aces = [] for ace in dtos: cache_key = (ace.base_dn, ace.scope) if cache_key not in directory_cache: @@ -219,9 +218,8 @@ async def create_bulk(self, dtos: list[AccessControlEntryDTO]) -> None: is_allow=ace.is_allow, directories=directory_cache[cache_key], ) - new_aces.append(new_ace) + self._session.add(new_ace) - self._session.add_all(new_aces) try: await self._session.flush() except IntegrityError: diff --git a/app/ldap_protocol/roles/role_use_case.py b/app/ldap_protocol/roles/role_use_case.py index d9c2921e0..75a1339f1 100644 --- a/app/ldap_protocol/roles/role_use_case.py +++ b/app/ldap_protocol/roles/role_use_case.py @@ -216,6 +216,31 @@ async def create_kerberos_system_role(self) -> None: ) await self._access_control_entry_dao.create_bulk(aces) + async def add_read_only_role_to_krbadmin_group(self) -> None: + """Add Read Only role to krbadmin group.""" + base_dn_list = await get_base_directories(self._role_dao._session) # noqa: SLF001 + if not base_dn_list: + return + + try: + read_only_role = await self._role_dao.get_by_name( + RoleConstants.READ_ONLY_ROLE_NAME, + ) + except RoleNotFoundError: + return + else: + new_groups_dn = [ + RoleConstants.KERBEROS_GROUP_CN + base_dn_list[0].path_dn, + RoleConstants.READONLY_GROUP_CN + base_dn_list[0].path_dn, + ] + + read_only_role.groups = new_groups_dn + + await self._role_dao.update( + read_only_role.get_id(), + read_only_role, + ) + async def delete_kerberos_system_role(self) -> None: """Delete the Kerberos system role.""" try: From 29fda0ca8b9dc6536d69897772e09fcc6ffb7f96 Mon Sep 17 00:00:00 2001 From: Nikita Ulyanov <69312634+rimu-stack@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:49:34 +0300 Subject: [PATCH 28/45] add: custom python-kadmin (#942) --- .docker/krb.Dockerfile | 3 +- .kerberos/config_server.py | 45 +++++----------------------- .kerberos/kadmin_local-0.1.1.tar.gz | Bin 0 -> 71240 bytes 3 files changed, 9 insertions(+), 39 deletions(-) create mode 100644 .kerberos/kadmin_local-0.1.1.tar.gz diff --git a/.docker/krb.Dockerfile b/.docker/krb.Dockerfile index afbee2892..5533b1b2a 100644 --- a/.docker/krb.Dockerfile +++ b/.docker/krb.Dockerfile @@ -7,12 +7,13 @@ ENV VIRTUAL_ENV=/venvs/.venv \ PATH="/venvs/.venv/bin:$PATH" WORKDIR /venvs +COPY .kerberos/kadmin_local-0.1.1.tar.gz / RUN python -m venv .venv RUN pip install \ fastapi \ uvicorn \ - https://github.com/xianglei/python-kadmv/releases/download/0.1.7/python-kadmV-0.1.7.tar.gz + /kadmin_local-0.1.1.tar.gz FROM ghcr.io/multidirectorylab/krb5_base:${VERSION} AS runtime diff --git a/.kerberos/config_server.py b/.kerberos/config_server.py index 3ce283a0a..5f3945a68 100644 --- a/.kerberos/config_server.py +++ b/.kerberos/config_server.py @@ -256,21 +256,13 @@ async def add_princ( :param str | None password: if None - uses randkey. :param list[str] | None algorithms: encryption algorithms """ - if algorithms: - await self.loop.run_in_executor( - self.pool, - self.client.add_principal, - name, - password, - algorithms, - ) - else: - await self.loop.run_in_executor( - self.pool, - self.client.add_principal, - name, - password, - ) + await self.loop.run_in_executor( + self.pool, + self.client.add_principal, + name, + password, + algorithms, + ) if password: # NOTE: add preauth, attributes == krbticketflags @@ -369,29 +361,6 @@ async def ktadd( for princ in principals: await self.loop.run_in_executor(self.pool, princ.ktadd, fn) - async def _ktadd_with_randkey_via_subprocess( - self, - principal_name: str, - keytab_path: str, - ) -> None: - """Execute ktadd with randkey via subprocess.""" - cmd = [ - "kadmin.local", - "-q", - f"ktadd -k {keytab_path} -randkey {principal_name}", - ] - - proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - stdout, stderr = await proc.communicate() - - if await proc.wait() != 0: - raise RuntimeError(f"ktadd failed: {stderr.decode()}") - async def lock_princ(self, name: str, **dbargs) -> None: """Lock princ. diff --git a/.kerberos/kadmin_local-0.1.1.tar.gz b/.kerberos/kadmin_local-0.1.1.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..18cb791d1fbd1aa506176d2594bafb4d7208a611 GIT binary patch literal 71240 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 z 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 literal 0 HcmV?d00001 From 6e394b531b0ddee2d008105bc2a5ca31bda15c0b Mon Sep 17 00:00:00 2001 From: Nikita Ulyanov <69312634+rimu-stack@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:02:09 +0300 Subject: [PATCH 29/45] fix: dublicate port and permisions pdns services (#943) --- .package/docker-compose.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index 3a21fb2a3..bee6517ba 100644 --- a/.package/docker-compose.yml +++ b/.package/docker-compose.yml @@ -11,8 +11,6 @@ services: command: - "--providers.file.filename=/traefik.yml" ports: - - "53:53" - - "53:53/udp" - "80:80" - "389:389" - "389:389/udp" @@ -80,6 +78,8 @@ services: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} container_name: multidirectory_migrations restart: "no" + networks: + md_net: env_file: .env command: python multidirectory.py --migrate @@ -376,6 +376,8 @@ services: pdns_auth: image: ghcr.io/multidirectorylab/multidirectory_pdns_auth:${VERSION:-latest} container_name: pdns_auth + cap_add: + - NET_ADMIN networks: default: md_net: @@ -392,6 +394,8 @@ services: pdns_recursor: image: powerdns/pdns-recursor-51:5.1.7 container_name: pdns_recursor + cap_add: + - NET_ADMIN networks: default: md_net: @@ -407,6 +411,8 @@ services: pdnsdist: image: powerdns/dnsdist-19:1.9.11 container_name: pdnsdist + cap_add: + - NET_ADMIN networks: default: md_net: From bede66d15d6d959e127716d7fe2cbbba618186a0 Mon Sep 17 00:00:00 2001 From: Nikita Ulyanov <69312634+rimu-stack@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:09:38 +0300 Subject: [PATCH 30/45] fix: networks and volumes (#945) --- docker-compose.dev.yml | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a911892b5..31c8dd8bc 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -17,10 +17,10 @@ services: - "443:443" - "389:389" - "389:389/udp" + - "3268:3268" + - "3269:3269" - "636:636" - "749:749" - - "53:53" - - "53:53/udp" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" - ./certs:/letsencrypt @@ -299,6 +299,7 @@ services: - "traefik.http.routers.api.service=api" - "traefik.http.routers.api.middlewares=api_strip" - "traefik.http.middlewares.api_strip.stripprefix.prefixes=/api" + - "traefik.http.middlewares.api_strip.stripprefix.forceslash=false" command: python multidirectory.py --http depends_on: @@ -553,14 +554,6 @@ services: - ./.package/recursor.conf:/etc/powerdns/recursor.conf - forward_zones:/etc/powerdns/recursor.d/ -networks: - md_net: - driver: bridge - ipam: - config: - - subnet: 172.20.0.0/24 - gateway: 172.20.0.1 - event_handler: image: multidirectory container_name: event_handler @@ -594,7 +587,13 @@ networks: environment: HANDLER_NAME: event_sender-1 - +networks: + md_net: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/24 + gateway: 172.20.0.1 volumes: postgres: @@ -603,5 +602,13 @@ volumes: kdc: dns_server_file: dns_server_config: + kea_ctrl_agent: ldap_keytab: dragonflydata: + dnsdist_confd: + dns_lmdb: + dns_config: + dhcp: + sockets: + leases: + forward_zones: From 4d5ac2ab041eb24893536cfa1ac5371b579f8091 Mon Sep 17 00:00:00 2001 From: Nikita Ulyanov <69312634+rimu-stack@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:48:43 +0300 Subject: [PATCH 31/45] add: randkey keytab (#946) --- .kerberos/config_server.py | 42 ++++++++++---------------- .kerberos/kadmin_local-0.1.1.tar.gz | Bin 71240 -> 71331 bytes .package/docker-compose.yml | 1 + app/ldap_protocol/kerberos/service.py | 2 +- 4 files changed, 18 insertions(+), 27 deletions(-) diff --git a/.kerberos/config_server.py b/.kerberos/config_server.py index 5f3945a68..d3cc24dfb 100644 --- a/.kerberos/config_server.py +++ b/.kerberos/config_server.py @@ -171,7 +171,7 @@ async def ktadd( :param list[str] names: principals :param str fn: filename - :param bool is_rand_key: generate random key + :param bool is_rand_key: generate new principal keys """ @abstractmethod @@ -335,31 +335,26 @@ async def ktadd( self, names: list[str], fn: str, - is_rand_key: bool = False, + is_rand_key: bool = True, ) -> None: """Create or write to keytab. :param list[str] names: principals :param str fn: filename - :param bool is_rand_key: generate random key + :param bool is_rand_key: generate new principal keys :raises PrincipalNotFoundError: on not found princ """ principals = [await self._get_raw_principal(name) for name in names] if not all(principals): raise PrincipalNotFoundError("Principal not found") - if is_rand_key: - for princ in principals: - await self.loop.run_in_executor( - self.pool, - princ.ktadd, - fn, - True, - ) - - else: - for princ in principals: - await self.loop.run_in_executor(self.pool, princ.ktadd, fn) + for princ in principals: + await self.loop.run_in_executor( + self.pool, + princ.ktadd, + fn, + is_rand_key, + ) async def lock_princ(self, name: str, **dbargs) -> None: """Lock princ. @@ -678,17 +673,12 @@ async def ktadd( :param KtaddRequest request: request data """ filename = os.path.join(gettempdir(), str(uuid.uuid1())) - if request.is_rand_key: - await kadmin.ktadd( - request.names, - filename, - is_rand_key=request.is_rand_key, - ) - else: - await kadmin.ktadd( - request.names, - filename, - ) + await kadmin.ktadd( + request.names, + filename, + request.is_rand_key, + ) + return FileResponse( filename, background=BackgroundTask(os.unlink, filename), diff --git a/.kerberos/kadmin_local-0.1.1.tar.gz b/.kerberos/kadmin_local-0.1.1.tar.gz index 18cb791d1fbd1aa506176d2594bafb4d7208a611..b4cba90559f3ed95665605b7f3ef92ab2edd38ac 100644 GIT binary patch literal 71331 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 literal 71240 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 z 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 diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index bee6517ba..44415d5e9 100644 --- a/.package/docker-compose.yml +++ b/.package/docker-compose.yml @@ -230,6 +230,7 @@ services: - dns_server_config:/DNS_server_configs/ - ldap_keytab:/LDAP_keytab/ - ./resolv.conf:/resolv.conf + - dnsdist_confd:/dnsdist hostname: api_server environment: USE_CORE_TLS: 1 diff --git a/app/ldap_protocol/kerberos/service.py b/app/ldap_protocol/kerberos/service.py index 10074a112..ec630f778 100644 --- a/app/ldap_protocol/kerberos/service.py +++ b/app/ldap_protocol/kerberos/service.py @@ -432,7 +432,7 @@ async def ktadd( """Generate keytab and return (aiter_bytes, TaskStruct). :param list[str] names: List of principal names. - :param bool is_rand_key: If True, generate random key. + :param bool is_rand_key: If True, generate new principal keys. :raises KerberosNotFoundError: If principal not found. :return tuple: (aiter_bytes, (func, args, kwargs)). """ From 222dd285540a18f930ed7d195a761fdc48962589 Mon Sep 17 00:00:00 2001 From: Misha-Shvets <76677350+Misha-Shvets@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:35:19 +0300 Subject: [PATCH 32/45] Refactor ldap protocol schema (#952) --- app/api/audit/adapter.py | 14 +++--- app/api/audit/router.py | 12 ++--- .../policies => api}/audit/schemas.py | 10 ++-- app/api/auth/adapters/auth.py | 22 +++++---- app/api/auth/adapters/mfa.py | 24 +++++++-- app/api/auth/router_auth.py | 6 +-- app/api/auth/router_mfa.py | 2 +- app/{ldap_protocol => api}/auth/schemas.py | 35 +------------ app/api/dhcp/adapter.py | 4 +- app/api/dhcp/router.py | 22 ++++----- app/{ldap_protocol => api}/dhcp/schemas.py | 45 +---------------- app/ldap_protocol/auth/auth_manager.py | 21 +++++--- app/ldap_protocol/auth/dto.py | 47 ++++++++++++++++++ app/ldap_protocol/auth/mfa_manager.py | 32 ++++++------ app/ldap_protocol/dhcp/__init__.py | 20 -------- app/ldap_protocol/dhcp/dtos.py | 49 +++++++++++++++++++ app/ldap_protocol/dhcp/kea_dhcp_repository.py | 12 ++--- app/ldap_protocol/dhcp/retorts.py | 2 +- .../kerberos/{schemas.py => dtos.py} | 10 ++-- app/ldap_protocol/kerberos/service.py | 46 ++++++++++------- app/ldap_protocol/kerberos/template_render.py | 10 ++-- app/ldap_protocol/policies/audit/monitor.py | 4 +- interface | 2 +- tests/test_api/test_audit/test_router.py | 8 +-- tests/test_api/test_dhcp/test_adapter.py | 10 ++-- 25 files changed, 250 insertions(+), 219 deletions(-) rename app/{ldap_protocol/policies => api}/audit/schemas.py (86%) rename app/{ldap_protocol => api}/auth/schemas.py (71%) rename app/{ldap_protocol => api}/dhcp/schemas.py (71%) create mode 100644 app/ldap_protocol/dhcp/dtos.py rename app/ldap_protocol/kerberos/{schemas.py => dtos.py} (85%) diff --git a/app/api/audit/adapter.py b/app/api/audit/adapter.py index e7a39665d..9d438beb5 100644 --- a/app/api/audit/adapter.py +++ b/app/api/audit/adapter.py @@ -4,17 +4,17 @@ License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from api.base_adapter import BaseAdapter -from ldap_protocol.policies.audit.dataclasses import ( - AuditDestinationDTO, - AuditPolicyDTO, -) -from ldap_protocol.policies.audit.schemas import ( +from api.audit.schemas import ( AuditDestinationResponse, AuditDestinationSchemaRequest, AuditPolicyResponse, AuditPolicySchemaRequest, ) +from api.base_adapter import BaseAdapter +from ldap_protocol.policies.audit.dataclasses import ( + AuditDestinationDTO, + AuditPolicyDTO, +) from ldap_protocol.policies.audit.service import AuditService @@ -49,7 +49,7 @@ async def get_destinations(self) -> list[AuditDestinationResponse]: """Get all audit destinations.""" return [ AuditDestinationResponse( - id=destination.id, # type: ignore + id=destination.id, name=destination.name, service_type=destination.service_type.name.lower(), host=destination.host, diff --git a/app/api/audit/router.py b/app/api/audit/router.py index 6209d0740..24ccbe3c9 100644 --- a/app/api/audit/router.py +++ b/app/api/audit/router.py @@ -9,6 +9,12 @@ from fastapi_error_map.routing import ErrorAwareRouter from fastapi_error_map.rules import rule +from api.audit.schemas import ( + AuditDestinationResponse, + AuditDestinationSchemaRequest, + AuditPolicyResponse, + AuditPolicySchemaRequest, +) from api.auth.utils import verify_auth from api.error_routing import ( ERROR_MAP_TYPE, @@ -21,12 +27,6 @@ AuditAlreadyExistsError, AuditNotFoundError, ) -from ldap_protocol.policies.audit.schemas import ( - AuditDestinationResponse, - AuditDestinationSchemaRequest, - AuditPolicyResponse, - AuditPolicySchemaRequest, -) from .adapter import AuditPoliciesAdapter diff --git a/app/ldap_protocol/policies/audit/schemas.py b/app/api/audit/schemas.py similarity index 86% rename from app/ldap_protocol/policies/audit/schemas.py rename to app/api/audit/schemas.py index a23387467..40bbf52e9 100644 --- a/app/ldap_protocol/policies/audit/schemas.py +++ b/app/api/audit/schemas.py @@ -1,11 +1,9 @@ -"""Audit policies schemas module. +"""Audit schemas. Copyright (c) 2025 MultiFactor License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from dataclasses import dataclass - from pydantic import BaseModel, Field from enums import AuditDestinationProtocolType, AuditDestinationServiceType @@ -20,8 +18,7 @@ class AuditPolicySchemaRequest(BaseModel): severity: str -@dataclass -class AuditPolicyResponse: +class AuditPolicyResponse(BaseModel): """Audit policy schema.""" id: int @@ -44,8 +41,7 @@ class Config: # noqa: D106 use_enum_values = True -@dataclass -class AuditDestinationResponse: +class AuditDestinationResponse(BaseModel): """Audit destination schema.""" id: int diff --git a/app/api/auth/adapters/auth.py b/app/api/auth/adapters/auth.py index 50ed85ee7..bb7f766fb 100644 --- a/app/api/auth/adapters/auth.py +++ b/app/api/auth/adapters/auth.py @@ -9,14 +9,10 @@ from adaptix.conversion import get_converter from fastapi import Request +from api.auth.schemas import MFAChallengeResponse, OAuth2Form, SetupRequest from api.base_adapter import BaseAdapter from ldap_protocol.auth import AuthManager -from ldap_protocol.auth.dto import SetupDTO -from ldap_protocol.auth.schemas import ( - MFAChallengeResponse, - OAuth2Form, - SetupRequest, -) +from ldap_protocol.auth.dto import LoginRequestDTO, SetupDTO from ldap_protocol.dialogue import UserSchema _convert_request_to_dto = get_converter(SetupRequest, SetupDTO) @@ -42,10 +38,13 @@ async def login( :raises HTTPException: 403 if access is forbidden (e.g. not in admins, disabled, expired, or policy failed) :raises HTTPException: 426 if MFA is required - :return: None + :return: MFAChallengeResponse | None """ login_dto = await self._service.login( - form=form, + form=LoginRequestDTO( + username=form.username, + password=form.password, + ), url=request.url_for("callback_mfa"), ip=ip, user_agent=user_agent, @@ -54,7 +53,12 @@ async def login( self._service.set_new_session_key( login_dto.session_key, ) - return login_dto.mfa_challenge + if login_dto.mfa_challenge is not None: + return MFAChallengeResponse( + status=login_dto.mfa_challenge.status, + message=login_dto.mfa_challenge.message, + ) + return None async def reset_password( self, diff --git a/app/api/auth/adapters/mfa.py b/app/api/auth/adapters/mfa.py index 9fa3b4a02..163858ba6 100644 --- a/app/api/auth/adapters/mfa.py +++ b/app/api/auth/adapters/mfa.py @@ -9,10 +9,11 @@ from fastapi import status from fastapi.responses import RedirectResponse +from api.auth.schemas import MFACreateRequest, MFAGetResponse from api.base_adapter import BaseAdapter from ldap_protocol.auth import MFAManager +from ldap_protocol.auth.dto import MFACreateRequestDTO from ldap_protocol.auth.exceptions.mfa import MFATokenError -from ldap_protocol.auth.schemas import MFACreateRequest, MFAGetResponse from ldap_protocol.multifactor import MFA_HTTP_Creds, MFA_LDAP_Creds @@ -25,7 +26,15 @@ async def setup_mfa(self, mfa: MFACreateRequest) -> bool: :param mfa: MFACreateRequest :return: bool """ - return await self._service.setup_mfa(mfa) + return await self._service.setup_mfa( + MFACreateRequestDTO( + mfa_key=mfa.mfa_key, + mfa_secret=mfa.mfa_secret, + is_ldap_scope=mfa.is_ldap_scope, + key_name=mfa.key_name, + secret_name=mfa.secret_name, + ), + ) async def remove_mfa(self, scope: str) -> None: """Delete MFA keys by scope. @@ -46,7 +55,16 @@ async def get_mfa( :param mfa_creds_ldap: MFA_LDAP_Creds :return: MFAGetResponse """ - return await self._service.get_mfa(mfa_creds, mfa_creds_ldap) + mfa_get_response = await self._service.get_mfa( + mfa_creds, + mfa_creds_ldap, + ) + return MFAGetResponse( + mfa_key=mfa_get_response.mfa_key, + mfa_secret=mfa_get_response.mfa_secret, + mfa_key_ldap=mfa_get_response.mfa_key_ldap, + mfa_secret_ldap=mfa_get_response.mfa_secret_ldap, + ) async def callback_mfa( self, diff --git a/app/api/auth/router_auth.py b/app/api/auth/router_auth.py index a5c98911f..004f8c0c0 100644 --- a/app/api/auth/router_auth.py +++ b/app/api/auth/router_auth.py @@ -13,6 +13,7 @@ from fastapi_error_map.rules import rule from api.auth.adapters import AuthFastAPIAdapter +from api.auth.schemas import MFAChallengeResponse, OAuth2Form, SetupRequest from api.auth.utils import get_ip_from_request, get_user_agent_from_request from api.error_routing import ( ERROR_MAP_TYPE, @@ -27,11 +28,6 @@ MFARequiredError, MissingMFACredentialsError, ) -from ldap_protocol.auth.schemas import ( - MFAChallengeResponse, - OAuth2Form, - SetupRequest, -) from ldap_protocol.dialogue import UserSchema from ldap_protocol.identity.exceptions import ( AlreadyConfiguredError, diff --git a/app/api/auth/router_mfa.py b/app/api/auth/router_mfa.py index 8e275b242..a003f7a52 100644 --- a/app/api/auth/router_mfa.py +++ b/app/api/auth/router_mfa.py @@ -14,6 +14,7 @@ from fastapi_error_map.rules import rule from api.auth.adapters import MFAFastAPIAdapter +from api.auth.schemas import MFACreateRequest, MFAGetResponse from api.auth.utils import ( get_ip_from_request, get_user_agent_from_request, @@ -35,7 +36,6 @@ NetworkPolicyError, NotFoundError, ) -from ldap_protocol.auth.schemas import MFACreateRequest, MFAGetResponse from ldap_protocol.multifactor import MFA_HTTP_Creds, MFA_LDAP_Creds translator = DomainErrorTranslator(DomainCodes.MFA) diff --git a/app/ldap_protocol/auth/schemas.py b/app/api/auth/schemas.py similarity index 71% rename from app/ldap_protocol/auth/schemas.py rename to app/api/auth/schemas.py index fe786189c..3102f2b37 100644 --- a/app/ldap_protocol/auth/schemas.py +++ b/app/api/auth/schemas.py @@ -1,25 +1,14 @@ -"""Schemas for auth module. +"""Auth schemas. Copyright (c) 2025 MultiFactor License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ import re -from dataclasses import dataclass -from datetime import datetime -from ipaddress import IPv4Address, IPv6Address -from typing import Literal from fastapi.param_functions import Form from fastapi.security import OAuth2PasswordRequestForm -from pydantic import ( - BaseModel, - ConfigDict, - Field, - SecretStr, - computed_field, - field_validator, -) +from pydantic import BaseModel, SecretStr, computed_field, field_validator from ldap_protocol.utils.const import EmailStr @@ -96,23 +85,3 @@ class MFAChallengeResponse(BaseModel): status: str message: str - - -@dataclass -class LoginDTO: - """Login Data Transfer Object.""" - - session_key: str | None - mfa_challenge: MFAChallengeResponse | None - - -class SessionContentSchema(BaseModel): - """Session content schema.""" - - model_config = ConfigDict(extra="allow") - - id: int - sign: str = Field("", description="Session signature") - issued: datetime - ip: IPv4Address | IPv6Address - protocol: Literal["ldap", "http"] = "http" diff --git a/app/api/dhcp/adapter.py b/app/api/dhcp/adapter.py index d063ad144..2a680e137 100644 --- a/app/api/dhcp/adapter.py +++ b/app/api/dhcp/adapter.py @@ -7,8 +7,7 @@ from ipaddress import IPv4Address from api.base_adapter import BaseAdapter -from ldap_protocol.dhcp import ( - AbstractDHCPManager, +from api.dhcp.schemas import ( DHCPChangeStateSchemaRequest, DHCPLeaseSchemaRequest, DHCPLeaseSchemaResponse, @@ -19,6 +18,7 @@ DHCPSubnetSchemaAddRequest, DHCPSubnetSchemaResponse, ) +from ldap_protocol.dhcp import AbstractDHCPManager from ldap_protocol.dhcp.dataclasses import ( DHCPLease, DHCPOptionData, diff --git a/app/api/dhcp/router.py b/app/api/dhcp/router.py index a41e806a0..d1eb9b77b 100644 --- a/app/api/dhcp/router.py +++ b/app/api/dhcp/router.py @@ -12,6 +12,17 @@ from fastapi_error_map.rules import rule from api.auth.utils import verify_auth +from api.dhcp.schemas import ( + DHCPChangeStateSchemaRequest, + DHCPLeaseSchemaRequest, + DHCPLeaseSchemaResponse, + DHCPLeaseToReservationErrorResponse, + DHCPReservationSchemaRequest, + DHCPReservationSchemaResponse, + DHCPStateSchemaResponse, + DHCPSubnetSchemaAddRequest, + DHCPSubnetSchemaResponse, +) from api.error_routing import ( ERROR_MAP_TYPE, DishkaErrorAwareRoute, @@ -27,17 +38,6 @@ DHCPOperationError, DHCPValidationError, ) -from ldap_protocol.dhcp.schemas import ( - DHCPChangeStateSchemaRequest, - DHCPLeaseSchemaRequest, - DHCPLeaseSchemaResponse, - DHCPLeaseToReservationErrorResponse, - DHCPReservationSchemaRequest, - DHCPReservationSchemaResponse, - DHCPStateSchemaResponse, - DHCPSubnetSchemaAddRequest, - DHCPSubnetSchemaResponse, -) from .adapter import DHCPAdapter diff --git a/app/ldap_protocol/dhcp/schemas.py b/app/api/dhcp/schemas.py similarity index 71% rename from app/ldap_protocol/dhcp/schemas.py rename to app/api/dhcp/schemas.py index 8f3b0a2c6..c3e10dde8 100644 --- a/app/ldap_protocol/dhcp/schemas.py +++ b/app/api/dhcp/schemas.py @@ -1,56 +1,15 @@ -"""Schemas for DHCP manager. +"""DHCP schemas. Copyright (c) 2025 MultiFactor License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ -from dataclasses import dataclass, field from datetime import datetime from ipaddress import IPv4Address, IPv4Network from pydantic import BaseModel, field_serializer -from .dataclasses import DHCPLease, DHCPReservation, DHCPSubnet -from .enums import DHCPManagerState, KeaDHCPCommands - - -@dataclass -class KeaDHCPCommandRequest: - """Single command request.""" - - command: KeaDHCPCommands - - -@dataclass -class KeaDHCPBaseAPIRequest(KeaDHCPCommandRequest): - """Base request for Kea DHCP API.""" - - arguments: list[int] | dict[str, str] | None = None - service: list[str] = field(default_factory=lambda: ["dhcp4"]) - - -@dataclass -class KeaDHCPAPISubnetRequest(KeaDHCPCommandRequest): - """Request for Kea DHCP API to manage subnets.""" - - subnet4: DHCPSubnet | list[DHCPSubnet] - service: list[str] = field(default_factory=lambda: ["dhcp4"]) - - -@dataclass -class KeaDHCPAPILeaseRequest(KeaDHCPCommandRequest): - """Request for Kea DHCP API to manage leases.""" - - lease: DHCPLease - service: list[str] = field(default_factory=lambda: ["dhcp4"]) - - -@dataclass -class KeaDHCPAPIReservationRequest(KeaDHCPCommandRequest): - """Request for Kea DHCP API to manage reservations.""" - - arguments: DHCPReservation - service: list[str] = field(default_factory=lambda: ["dhcp4"]) +from ldap_protocol.dhcp.enums import DHCPManagerState class DHCPSubnetSchemaAddRequest(BaseModel): diff --git a/app/ldap_protocol/auth/auth_manager.py b/app/ldap_protocol/auth/auth_manager.py index d2e9073fd..6dff8b69e 100644 --- a/app/ldap_protocol/auth/auth_manager.py +++ b/app/ldap_protocol/auth/auth_manager.py @@ -14,9 +14,8 @@ from config import Settings from entities import User from enums import AuthorizationRules, MFAFlags -from ldap_protocol.auth.dto import SetupDTO +from ldap_protocol.auth.dto import LoginRequestDTO, LoginResponseDTO, SetupDTO from ldap_protocol.auth.mfa_manager import MFAManager -from ldap_protocol.auth.schemas import LoginDTO, OAuth2Form from ldap_protocol.auth.use_cases import SetupUseCase from ldap_protocol.auth.utils import authenticate_user from ldap_protocol.dialogue import UserSchema @@ -100,11 +99,11 @@ def __getattribute__(self, name: str) -> object: async def login( self, - form: OAuth2Form, + form: LoginRequestDTO, url: URL, ip: IPv4Address | IPv6Address, user_agent: str, - ) -> LoginDTO: + ) -> LoginResponseDTO: """Log in a user. :param form: OAuth2Form with username and password @@ -169,8 +168,8 @@ async def login( ) if request_2fa: ( - mfa_challenge, - key, + mfa_challenge_dto, + session_key, ) = await self._mfa_manager.two_factor_protocol( user=user, network_policy=network_policy, @@ -178,7 +177,10 @@ async def login( ip=ip, user_agent=user_agent, ) - return LoginDTO(key, mfa_challenge) + return LoginResponseDTO( + session_key=session_key, + mfa_challenge=mfa_challenge_dto, + ) session_key = await self._repository.create_session_key( user, @@ -186,7 +188,10 @@ async def login( user_agent, self.key_ttl, ) - return LoginDTO(session_key, None) + return LoginResponseDTO( + session_key=session_key, + mfa_challenge=None, + ) async def _update_password( self, diff --git a/app/ldap_protocol/auth/dto.py b/app/ldap_protocol/auth/dto.py index 909c0f35e..80ac483d0 100644 --- a/app/ldap_protocol/auth/dto.py +++ b/app/ldap_protocol/auth/dto.py @@ -6,6 +6,16 @@ from dataclasses import dataclass +from enums import MFAChallengeStatuses + + +@dataclass +class LoginRequestDTO: + """Login request DTO.""" + + username: str + password: str + @dataclass class SetupDTO: @@ -17,3 +27,40 @@ class SetupDTO: display_name: str mail: str password: str + + +@dataclass +class MFAChallengeResponseDTO: + """MFA challenge response DTO.""" + + status: MFAChallengeStatuses + message: str + + +@dataclass +class LoginResponseDTO: + """Login response DTO.""" + + session_key: str | None + mfa_challenge: MFAChallengeResponseDTO | None + + +@dataclass +class MFACreateRequestDTO: + """MFA create request DTO.""" + + mfa_key: str + mfa_secret: str + is_ldap_scope: bool + secret_name: str + key_name: str + + +@dataclass +class MFAGetResponseDTO: + """MFA get response DTO.""" + + mfa_key: str | None + mfa_secret: str | None + mfa_key_ldap: str | None + mfa_secret_ldap: str | None diff --git a/app/ldap_protocol/auth/mfa_manager.py b/app/ldap_protocol/auth/mfa_manager.py index 334a66a44..0e24f2285 100644 --- a/app/ldap_protocol/auth/mfa_manager.py +++ b/app/ldap_protocol/auth/mfa_manager.py @@ -21,6 +21,11 @@ from config import Settings from entities import CatalogueSetting, NetworkPolicy, User from enums import AuthorizationRules, MFAChallengeStatuses, MFAFlags +from ldap_protocol.auth.dto import ( + MFAChallengeResponseDTO, + MFACreateRequestDTO, + MFAGetResponseDTO, +) from ldap_protocol.auth.exceptions.mfa import ( AuthenticationError, ForbiddenError, @@ -31,11 +36,6 @@ MissingMFACredentialsError, NetworkPolicyError, ) -from ldap_protocol.auth.schemas import ( - MFAChallengeResponse, - MFACreateRequest, - MFAGetResponse, -) from ldap_protocol.auth.utils import get_user from ldap_protocol.identity import IdentityProvider from ldap_protocol.multifactor import ( @@ -102,10 +102,10 @@ def __getattribute__(self, name: str) -> object: return self._monitor.wrap_proxy_request(attr) return attr - async def setup_mfa(self, mfa: MFACreateRequest) -> bool: + async def setup_mfa(self, mfa: MFACreateRequestDTO) -> bool: """Create or update MFA keys. - :param mfa: MFACreateRequest + :param mfa: MFACreateRequestDTO :return: bool """ async with self._session.begin_nested(): @@ -151,12 +151,12 @@ async def get_mfa( self, mfa_creds: MFA_HTTP_Creds | None, mfa_creds_ldap: MFA_LDAP_Creds | None, - ) -> MFAGetResponse: + ) -> MFAGetResponseDTO: """Get MFA keys for http and ldap. :param mfa_creds: MFA_HTTP_Creds or None :param mfa_creds_ldap: MFA_LDAP_Creds or None - :return: MFAGetResponse + :return: MFAGetResponseDTO """ if not mfa_creds: mfa_creds = MFA_HTTP_Creds(Creds(None, None)) @@ -164,7 +164,7 @@ async def get_mfa( if not mfa_creds_ldap: mfa_creds_ldap = MFA_LDAP_Creds(Creds(None, None)) - return MFAGetResponse( + return MFAGetResponseDTO( mfa_key=mfa_creds.key, mfa_secret=mfa_creds.secret, mfa_key_ldap=mfa_creds_ldap.key, @@ -219,14 +219,14 @@ async def _create_bypass_data( message: str, ip: IPv4Address | IPv6Address, user_agent: str, - ) -> tuple[MFAChallengeResponse, str | None]: + ) -> tuple[MFAChallengeResponseDTO, str | None]: """Create session key and response. :param user: User :param message: str :param ip: IPv4Address | IPv6Address :param user_agent: str - :return: tuple[MFAChallengeResponse, str | None] + :return: tuple[MFAChallengeResponseDTO, str | None] """ key = await self._repository.create_session_key( user, @@ -235,7 +235,7 @@ async def _create_bypass_data( self.key_ttl, ) return ( - MFAChallengeResponse( + MFAChallengeResponseDTO( status=MFAChallengeStatuses.BYPASS, message=message, ), @@ -249,7 +249,7 @@ async def two_factor_protocol( url: URL, ip: IPv4Address | IPv6Address, user_agent: str, - ) -> tuple[MFAChallengeResponse, str | None]: + ) -> tuple[MFAChallengeResponseDTO, str | None]: """Initiate two-factor protocol with application. :param user: User @@ -258,7 +258,7 @@ async def two_factor_protocol( :param ip: IP address :param user_agent: User-Agent string :return: - tuple[MFAChallengeResponse, str | None] (session key | None) + tuple[MFAChallengeResponseDTO, str | None] (session key | None) :raises MissingMFACredentialsError: if MFA is not initialized :raises InvalidCredentialsError: if credentials are invalid :raises NetworkPolicyError: if network policy is not passed @@ -300,7 +300,7 @@ async def two_factor_protocol( weakref.finalize(bypass_coro, bypass_coro.close) return ( - MFAChallengeResponse( + MFAChallengeResponseDTO( status=MFAChallengeStatuses.PENDING, message=redirect_url, ), diff --git a/app/ldap_protocol/dhcp/__init__.py b/app/ldap_protocol/dhcp/__init__.py index d9f4c277c..a813b3d03 100644 --- a/app/ldap_protocol/dhcp/__init__.py +++ b/app/ldap_protocol/dhcp/__init__.py @@ -12,17 +12,6 @@ ) from .kea_dhcp_manager import KeaDHCPManager from .kea_dhcp_repository import KeaDHCPAPIRepository -from .schemas import ( - DHCPChangeStateSchemaRequest, - DHCPLeaseSchemaRequest, - DHCPLeaseSchemaResponse, - DHCPLeaseToReservationErrorResponse, - DHCPReservationSchemaRequest, - DHCPReservationSchemaResponse, - DHCPStateSchemaResponse, - DHCPSubnetSchemaAddRequest, - DHCPSubnetSchemaResponse, -) from .stub import StubDHCPAPIRepository, StubDHCPManager @@ -58,13 +47,4 @@ def get_dhcp_api_repository_class( "DHCPOperationError", "DHCPAPIError", "DHCPSubnetSchemaRequest", - "DHCPSubnetSchemaAddRequest", - "DHCPReservationSchemaRequest", - "DHCPSubnetSchemaResponse", - "DHCPLeaseSchemaRequest", - "DHCPLeaseSchemaResponse", - "DHCPReservationSchemaResponse", - "DHCPChangeStateSchemaRequest", - "DHCPStateSchemaResponse", - "DHCPLeaseToReservationErrorResponse", ] diff --git a/app/ldap_protocol/dhcp/dtos.py b/app/ldap_protocol/dhcp/dtos.py new file mode 100644 index 000000000..2b7c097a0 --- /dev/null +++ b/app/ldap_protocol/dhcp/dtos.py @@ -0,0 +1,49 @@ +"""DTOs for DHCP manager. + +Copyright (c) 2025 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +from dataclasses import dataclass, field + +from .dataclasses import DHCPLease, DHCPReservation, DHCPSubnet +from .enums import KeaDHCPCommands + + +@dataclass +class KeaDHCPCommandRequest: + """Single command request.""" + + command: KeaDHCPCommands + + +@dataclass +class KeaDHCPBaseAPIRequest(KeaDHCPCommandRequest): + """Base request for Kea DHCP API.""" + + arguments: list[int] | dict[str, str] | None = None + service: list[str] = field(default_factory=lambda: ["dhcp4"]) + + +@dataclass +class KeaDHCPAPISubnetRequest(KeaDHCPCommandRequest): + """Request for Kea DHCP API to manage subnets.""" + + subnet4: DHCPSubnet | list[DHCPSubnet] + service: list[str] = field(default_factory=lambda: ["dhcp4"]) + + +@dataclass +class KeaDHCPAPILeaseRequest(KeaDHCPCommandRequest): + """Request for Kea DHCP API to manage leases.""" + + lease: DHCPLease + service: list[str] = field(default_factory=lambda: ["dhcp4"]) + + +@dataclass +class KeaDHCPAPIReservationRequest(KeaDHCPCommandRequest): + """Request for Kea DHCP API to manage reservations.""" + + arguments: DHCPReservation + service: list[str] = field(default_factory=lambda: ["dhcp4"]) diff --git a/app/ldap_protocol/dhcp/kea_dhcp_repository.py b/app/ldap_protocol/dhcp/kea_dhcp_repository.py index a41c8e82c..849523bec 100644 --- a/app/ldap_protocol/dhcp/kea_dhcp_repository.py +++ b/app/ldap_protocol/dhcp/kea_dhcp_repository.py @@ -17,6 +17,12 @@ DHCPReservation, DHCPSubnet, ) +from .dtos import ( + KeaDHCPAPILeaseRequest, + KeaDHCPAPIReservationRequest, + KeaDHCPAPISubnetRequest, + KeaDHCPBaseAPIRequest, +) from .enums import KeaDHCPCommands, KeaDHCPResultCodes from .exceptions import ( DHCPAPIError, @@ -40,12 +46,6 @@ release_lease_retort, update_subnet_retort, ) -from .schemas import ( - KeaDHCPAPILeaseRequest, - KeaDHCPAPIReservationRequest, - KeaDHCPAPISubnetRequest, - KeaDHCPBaseAPIRequest, -) class KeaDHCPAPIRepository(DHCPAPIRepository): diff --git a/app/ldap_protocol/dhcp/retorts.py b/app/ldap_protocol/dhcp/retorts.py index 2a0d4c11d..023b800a1 100644 --- a/app/ldap_protocol/dhcp/retorts.py +++ b/app/ldap_protocol/dhcp/retorts.py @@ -7,7 +7,7 @@ from adaptix import Retort, name_mapping from .dataclasses import DHCPLease, DHCPReservation, DHCPSubnet -from .schemas import ( +from .dtos import ( KeaDHCPAPILeaseRequest, KeaDHCPAPISubnetRequest, KeaDHCPBaseAPIRequest, diff --git a/app/ldap_protocol/kerberos/schemas.py b/app/ldap_protocol/kerberos/dtos.py similarity index 85% rename from app/ldap_protocol/kerberos/schemas.py rename to app/ldap_protocol/kerberos/dtos.py index b3be3abb5..d01775aee 100644 --- a/app/ldap_protocol/kerberos/schemas.py +++ b/app/ldap_protocol/kerberos/dtos.py @@ -11,7 +11,7 @@ @dataclass -class KerberosAdminDnGroup: +class KerberosAdminDnGroupDTO: """Kerberos admin, services container, and admin group DNs.""" krbadmin_dn: str @@ -20,8 +20,8 @@ class KerberosAdminDnGroup: @dataclass -class AddRequests: - """AddRequests for Kerberos admin structure: group, services, krb_user.""" +class AddRequestsDTO: + """AddRequestsDTO for Kerberos admin structure.""" group: AddRequest services: AddRequest @@ -29,7 +29,7 @@ class AddRequests: @dataclass -class KDCContext: +class KDCContextDTO: """Kerberos KDC configuration context.""" base_dn: str @@ -43,7 +43,7 @@ class KDCContext: @dataclass -class TaskStruct: +class TaskStructDTO: """Structure for background task: function, args, kwargs.""" func: Callable[..., Any] diff --git a/app/ldap_protocol/kerberos/service.py b/app/ldap_protocol/kerberos/service.py index ec630f778..fa838abb9 100644 --- a/app/ldap_protocol/kerberos/service.py +++ b/app/ldap_protocol/kerberos/service.py @@ -30,6 +30,12 @@ from password_utils import PasswordUtils from .base import AbstractKadmin +from .dtos import ( + AddRequestsDTO, + KDCContextDTO, + KerberosAdminDnGroupDTO, + TaskStructDTO, +) from .exceptions import ( KRBAPIAddPrincipalError, KRBAPIConnectionError, @@ -42,7 +48,6 @@ KRBAPIStatusNotFoundError, ) from .ldap_structure import KRBLDAPStructureManager -from .schemas import AddRequests, KDCContext, KerberosAdminDnGroup, TaskStruct from .template_render import KRBTemplateRenderer from .utils import ( KerberosState, @@ -138,17 +143,20 @@ async def _get_base_dn(self) -> tuple[str, str]: ) return base_dn_list[0].path_dn, base_dn_list[0].name - def _build_kerberos_admin_dns(self, base_dn: str) -> KerberosAdminDnGroup: + def _build_kerberos_admin_dns( + self, + base_dn: str, + ) -> KerberosAdminDnGroupDTO: """Build DN strings for Kerberos admin, services, and group. :param str base_dn: Base DN. - :return KerberosAdminDnGroup: + :return KerberosAdminDnGroupDTO: dataclass with DN for krbadmin, services_container, krbadmin_group. """ krbadmin = f"cn=krbadmin,cn=Users,{base_dn}" services_container = get_system_container_dn(base_dn) krbgroup = f"cn=krbadmin,cn=Groups,{base_dn}" - return KerberosAdminDnGroup( + return KerberosAdminDnGroupDTO( krbadmin_dn=krbadmin, services_container_dn=services_container, krbadmin_group_dn=krbgroup, @@ -156,17 +164,17 @@ def _build_kerberos_admin_dns(self, base_dn: str) -> KerberosAdminDnGroup: def _build_add_requests( self, - dns: KerberosAdminDnGroup, + dns: KerberosAdminDnGroupDTO, mail: str, krbadmin_password: SecretStr, - ) -> AddRequests: + ) -> AddRequestsDTO: """Build AddRequest objects for group, services, and admin user. - :param KerberosAdminDnGroup dns: + :param KerberosAdminDnGroupDTO dns: DNs for krbadmin, services container, and group. :param str mail: Email for krbadmin. :param SecretStr krbadmin_password: Password for krbadmin. - :return AddRequests: + :return AddRequestsDTO: dataclass of AddRequest for group, services, and user. """ group = AddRequest.from_dict( @@ -219,7 +227,7 @@ def _build_add_requests( }, is_system=True, ) - return AddRequests( + return AddRequestsDTO( group=group, services=services, krb_user=krb_user, @@ -232,8 +240,8 @@ async def setup_kdc( stash_password: str, user: UserSchema, request: Request, - ) -> TaskStruct: - """Set up KDC, generate configs, and return TaskStruct. + ) -> TaskStructDTO: + """Set up KDC, generate configs, and return TaskStructDTO. Args: krbadmin_password (str): Password for krbadmin. @@ -289,17 +297,17 @@ async def setup_kdc( admin_password, ) - async def _get_kdc_context(self) -> KDCContext: + async def _get_kdc_context(self) -> KDCContextDTO: """Build and return context for KDC setup/config rendering. :raises Exception: If base DN cannot be retrieved. - :return KDCContext: dataclass with all required KDC context fields. + :return KDCContextDTO: dataclass with all required KDC context fields. """ base_dn, domain = await self._get_base_dn() krbadmin = f"cn=krbadmin,cn=users,{base_dn}" krbgroup = f"cn=krbadmin,cn=groups,{base_dn}" services_container = get_system_container_dn(base_dn) - return KDCContext( + return KDCContextDTO( base_dn=base_dn, domain=domain, krbadmin=krbadmin, @@ -335,7 +343,7 @@ async def _schedule_principal_task( request: Request, user: UserSchema, password: str, - ) -> TaskStruct: + ) -> TaskStructDTO: """Schedule background task for principal creation after KDC setup. :param Request request: FastAPI request (for DI container). @@ -356,7 +364,7 @@ async def _schedule_principal_task( user.user_principal_name.split("@")[0], password, ) - return TaskStruct(func=func, args=args) + return TaskStructDTO(func=func, args=args) async def add_principal( self, @@ -428,8 +436,8 @@ async def ktadd( self, names: list[str], is_rand_key: bool, - ) -> tuple[AsyncIterator[bytes], TaskStruct]: - """Generate keytab and return (aiter_bytes, TaskStruct). + ) -> tuple[AsyncIterator[bytes], TaskStructDTO]: + """Generate keytab and return (aiter_bytes, TaskStructDTO). :param list[str] names: List of principal names. :param bool is_rand_key: If True, generate new principal keys. @@ -442,7 +450,7 @@ async def ktadd( raise KerberosNotFoundError("Principal not found") aiter_bytes = response.aiter_bytes() func = response.aclose - return aiter_bytes, TaskStruct(func=func) + return aiter_bytes, TaskStructDTO(func=func) async def get_status(self) -> KerberosState: """Get Kerberos server state (db + actual server). diff --git a/app/ldap_protocol/kerberos/template_render.py b/app/ldap_protocol/kerberos/template_render.py index 0df7b36a7..d4428a596 100644 --- a/app/ldap_protocol/kerberos/template_render.py +++ b/app/ldap_protocol/kerberos/template_render.py @@ -6,7 +6,7 @@ import jinja2 -from .schemas import KDCContext +from .dtos import KDCContextDTO class KRBTemplateRenderer: @@ -23,11 +23,11 @@ def __init__(self, templates: jinja2.Environment) -> None: """ self._templates = templates - async def render_krb5(self, context: KDCContext) -> str: + async def render_krb5(self, context: KDCContextDTO) -> str: """Render the krb5.conf configuration file using the provided context. :param context: - KDCContext dataclass with Kerberos configuration parameters. + KDCContextDTO dataclass with Kerberos configuration parameters. :return: Rendered krb5.conf as a string. """ krb5_template = self._templates.get_template("krb5.conf") @@ -40,11 +40,11 @@ async def render_krb5(self, context: KDCContext) -> str: sync_password_url=context.sync_password_url, ) - async def render_kdc(self, context: KDCContext) -> str: + async def render_kdc(self, context: KDCContextDTO) -> str: """Render the kdc.conf configuration file using the provided context. :param context: - KDCContext dataclass with Kerberos configuration parameters. + KDCContextDTO dataclass with Kerberos configuration parameters. :return: Rendered kdc.conf as a string. """ kdc_template = self._templates.get_template("kdc.conf") diff --git a/app/ldap_protocol/policies/audit/monitor.py b/app/ldap_protocol/policies/audit/monitor.py index 5ce08d0ab..6258daf49 100644 --- a/app/ldap_protocol/policies/audit/monitor.py +++ b/app/ldap_protocol/policies/audit/monitor.py @@ -14,6 +14,7 @@ from config import Settings from entities import User +from ldap_protocol.auth.dto import LoginRequestDTO from ldap_protocol.auth.exceptions.mfa import ( AuthenticationError, ForbiddenError, @@ -22,7 +23,6 @@ MFATokenError, NetworkPolicyError, ) -from ldap_protocol.auth.schemas import OAuth2Form from ldap_protocol.identity.exceptions import ( AuthorizationError, AuthValidationError, @@ -224,7 +224,7 @@ async def wrapped_proxy_request( def wrap_login(self, attr: _T) -> _T: @wraps(attr) async def wrapped_login( - form: OAuth2Form, + form: LoginRequestDTO, url: URL, ip: IPv4Address | IPv6Address, user_agent: str, diff --git a/interface b/interface index e1ca5656a..3732b6958 160000 --- a/interface +++ b/interface @@ -1 +1 @@ -Subproject commit e1ca5656aeabc20a1862aeaf11ded72feaa97403 +Subproject commit 3732b695844e95e1692ae83e1b2e1de70e68b380 diff --git a/tests/test_api/test_audit/test_router.py b/tests/test_api/test_audit/test_router.py index 2abc682b6..bd201d23f 100644 --- a/tests/test_api/test_audit/test_router.py +++ b/tests/test_api/test_audit/test_router.py @@ -10,15 +10,15 @@ from fastapi import status from httpx import AsyncClient +from api.audit.schemas import ( + AuditDestinationSchemaRequest, + AuditPolicySchemaRequest, +) from enums import AuditDestinationProtocolType, AuditDestinationServiceType from ldap_protocol.policies.audit.dataclasses import ( AuditDestinationDTO, AuditPolicyDTO, ) -from ldap_protocol.policies.audit.schemas import ( - AuditDestinationSchemaRequest, - AuditPolicySchemaRequest, -) @pytest.mark.asyncio diff --git a/tests/test_api/test_dhcp/test_adapter.py b/tests/test_api/test_dhcp/test_adapter.py index 5d2dd4b26..f67b03016 100644 --- a/tests/test_api/test_dhcp/test_adapter.py +++ b/tests/test_api/test_dhcp/test_adapter.py @@ -10,6 +10,11 @@ import pytest from api.dhcp.adapter import DHCPAdapter +from api.dhcp.schemas import ( + DHCPLeaseSchemaRequest, + DHCPReservationSchemaRequest, + DHCPSubnetSchemaAddRequest, +) from authorization_provider_protocol import AuthorizationProviderProtocol from ldap_protocol.dhcp.dataclasses import ( DHCPLease, @@ -18,11 +23,6 @@ DHCPReservation, DHCPSubnet, ) -from ldap_protocol.dhcp.schemas import ( - DHCPLeaseSchemaRequest, - DHCPReservationSchemaRequest, - DHCPSubnetSchemaAddRequest, -) @pytest.fixture From 5549b4e716d3511425346251d084aa978afa4ec6 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Tue, 3 Mar 2026 17:03:24 +0300 Subject: [PATCH 33/45] Fix RootDSE Search request (#947) --- app/ldap_protocol/ldap_requests/search.py | 6 +- app/ldap_protocol/rootdse/reader.py | 112 +++++++++--------- .../test_main/test_router/test_search.py | 33 ++++++ 3 files changed, 95 insertions(+), 56 deletions(-) diff --git a/app/ldap_protocol/ldap_requests/search.py b/app/ldap_protocol/ldap_requests/search.py index b82e41ff7..c9ab0bd57 100644 --- a/app/ldap_protocol/ldap_requests/search.py +++ b/app/ldap_protocol/ldap_requests/search.py @@ -167,8 +167,8 @@ def all_attrs(self) -> bool: return "*" in self.requested_attrs or not self.requested_attrs @cached_property - def requested_attrs(self) -> list[str]: - return [attr.lower() for attr in self.attributes] + def requested_attrs(self) -> set[str]: + return {attr.lower() for attr in self.attributes} @classmethod def from_data(cls, data: dict[str, list[ASN1Row]]) -> "SearchRequest": @@ -253,7 +253,7 @@ def check_netlogon_filter(self) -> bool: return "netlogon" in self.requested_attrs async def _get_netlogon(self, ctx: LDAPSearchRequestContext) -> bytes: - rootdse = await ctx.rootdse_rd.get(self.requested_attrs) + rootdse = await ctx.rootdse_rd.get(set()) nl = NetLogonAttributeHandler.from_filter(rootdse, self.filter) return nl.get_attr() diff --git a/app/ldap_protocol/rootdse/reader.py b/app/ldap_protocol/rootdse/reader.py index 5f128fb9c..b3b9d71a0 100644 --- a/app/ldap_protocol/rootdse/reader.py +++ b/app/ldap_protocol/rootdse/reader.py @@ -21,62 +21,68 @@ def __init__(self, settings: Settings, gw: DomainReadProtocol) -> None: async def get( self, - requested_attrs: list[str], + requested_attrs: set[str], ) -> defaultdict[str, list[str]]: domain = await self._gw.get_domain() - data = defaultdict(list) schema = "CN=Schema" - if requested_attrs == ["subschemasubentry"]: - data["subschemaSubentry"].append(schema) - return data - - data["dnsHostName"].append(domain.name) - data["serverName"].append(domain.name) - data["serviceName"].append(domain.name) - data["dsServiceName"].append(domain.name) - data["LDAPServiceName"].append(domain.name) - data["dnsForestName"].append(domain.name) - data["dnsDomainName"].append(domain.name) - data["domainGuid"].append(str(domain.object_guid)) - data["vendorName"].append(self._settings.VENDOR_NAME) - data["vendorVersion"].append(self._settings.VENDOR_VERSION) - data["namingContexts"].append(domain.path_dn) - data["namingContexts"].append(schema) - data["rootDomainNamingContext"].append(domain.path_dn) - data["supportedLDAPVersion"].append("3") - data["defaultNamingContext"].append(domain.path_dn) - data["currentTime"].append( - get_generalized_now(self._settings.TIMEZONE), - ) - data["subschemaSubentry"].append(schema) - data["schemaNamingContext"].append(schema) - data["supportedSASLMechanisms"] = [ - "ANONYMOUS", - "PLAIN", - "GSSAPI", - "GSS-SPNEGO", - ] - data["highestCommittedUSN"].append("126991") - data["supportedExtension"] = [ - "1.3.6.1.4.1.4203.1.11.3", # whoami - "1.3.6.1.4.1.4203.1.11.1", # password modify - ] - data["supportedControl"] = [ - "2.16.840.1.113730.3.4.4", # password expire policy - ] - data["domainFunctionality"].append("0") - data["supportedLDAPPolicies"] = [ - "MaxConnIdleTime", - "MaxPageSize", - "MaxValRange", - ] - data["supportedCapabilities"] = [ - "1.2.840.113556.1.4.800", # ACTIVE_DIRECTORY_OID - "1.2.840.113556.1.4.1670", # ACTIVE_DIRECTORY_V51_OID - "1.2.840.113556.1.4.1791", # ACTIVE_DIRECTORY_LDAP_INTEG_OID - ] - - return data + + all_attrs: dict[str, list[str]] = { + "dnsHostName": [domain.name], + "serverName": [domain.name], + "serviceName": [domain.name], + "dsServiceName": [domain.name], + "LDAPServiceName": [domain.name], + "dnsForestName": [domain.name], + "dnsDomainName": [domain.name], + "domainGuid": [str(domain.object_guid)], + "vendorName": [self._settings.VENDOR_NAME], + "vendorVersion": [self._settings.VENDOR_VERSION], + "namingContexts": [domain.path_dn, schema], + "rootDomainNamingContext": [domain.path_dn], + "supportedLDAPVersion": ["3"], + "defaultNamingContext": [domain.path_dn], + "currentTime": [ + get_generalized_now(self._settings.TIMEZONE), + ], + "subschemaSubentry": [schema], + "schemaNamingContext": [schema], + "supportedSASLMechanisms": [ + "ANONYMOUS", + "PLAIN", + "GSSAPI", + "GSS-SPNEGO", + ], + "highestCommittedUSN": ["126991"], + "supportedExtension": [ + "1.3.6.1.4.1.4203.1.11.3", # whoami + "1.3.6.1.4.1.4203.1.11.1", # password modify + ], + "supportedControl": [ + "2.16.840.1.113730.3.4.4", # password expire policy + ], + "domainFunctionality": ["0"], + "supportedLDAPPolicies": [ + "MaxConnIdleTime", + "MaxPageSize", + "MaxValRange", + ], + "supportedCapabilities": [ + "1.2.840.113556.1.4.800", # ACTIVE_DIRECTORY_OID + "1.2.840.113556.1.4.1670", # ACTIVE_DIRECTORY_V51_OID + "1.2.840.113556.1.4.1791", # ACTIVE_DIRECTORY_LDAP_INTEG_OID + ], + } + + if not requested_attrs or "*" in requested_attrs: + return defaultdict(list, all_attrs) + + result = defaultdict(list) + + for attr_name, values in all_attrs.items(): + if attr_name.lower() in requested_attrs: + result[attr_name].extend(values) + + return result class DCInfoReader: 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 14768e5d1..1c591bd17 100644 --- a/tests/test_api/test_main/test_router/test_search.py +++ b/tests/test_api/test_main/test_router/test_search.py @@ -73,6 +73,39 @@ async def test_api_root_dse(http_client: AsyncClient) -> None: assert all(attr in aquired_attrs for attr in root_attrs) +@pytest.mark.asyncio +@pytest.mark.usefixtures("session") +async def test_api_root_dse_return_one_attr(http_client: AsyncClient) -> None: + """Test api root dse.""" + response = await http_client.post( + "entry/search", + json={ + "base_object": "", + "scope": 0, + "deref_aliases": 0, + "size_limit": 1000, + "time_limit": 10, + "types_only": True, + "filter": "(objectClass=*)", + "attributes": ["namingContexts"], + "page_number": 1, + }, + ) + + data = response.json() + + attrs = sorted( + data["search_result"][0]["partial_attributes"], + key=lambda x: x["type"], + ) + + aquired_attrs = {attr["type"] for attr in attrs} + root_attrs = {"namingContexts"} + + assert data["search_result"][0]["object_name"] == "" + assert aquired_attrs == root_attrs + + @pytest.mark.asyncio @pytest.mark.usefixtures("session") async def test_api_search(http_client: AsyncClient) -> None: From 5594f43259817d8fa612d3a50f52f5e429984181 Mon Sep 17 00:00:00 2001 From: iyashnov <57270538+iyashnov@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:18:50 +0300 Subject: [PATCH 34/45] Feature: Bind9 to PowerDNS migration (#954) --- .package/docker-compose.yml | 22 +++ app/ioc.py | 22 +++ .../dns/bind_to_pdns_migration_use_case.py | 172 ++++++++++++++++++ .../dns/managers/abstract_dns_manager.py | 2 + .../bind_to_pdns_migrations_manager.py | 162 +++++++++++++++++ .../dns/managers/power_dns_manager.py | 75 ++++---- .../dns/managers/remote_dns_manager.py | 2 + .../dns/managers/stub_dns_manager.py | 2 + app/multidirectory.py | 23 +++ 9 files changed, 450 insertions(+), 32 deletions(-) create mode 100644 app/ldap_protocol/dns/bind_to_pdns_migration_use_case.py create mode 100644 app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index 44415d5e9..64f8b4a7d 100644 --- a/.package/docker-compose.yml +++ b/.package/docker-compose.yml @@ -87,6 +87,28 @@ services: postgres: condition: service_healthy + dns_migration: + image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} + container_name: multidirectory_dns_migration + networks: + md_net: + restart: "no" + volumes: + - dns_server_file:/opt/ + - dns_server_config:/etc/bind/ + - dnsdist_confd:/dnsdist + env_file: .env + command: python multidirectory.py --migrate_dns + depends_on: + migrations: + condition: service_completed_successfully + pdns_auth: + condition: service_started + pdns_recursor: + condition: service_started + pdnsdist: + condition: service_started + ldap_server: image: ghcr.io/multidirectorylab/multidirectory:${VERSION:-latest} networks: diff --git a/app/ioc.py b/app/ioc.py index 5673a37ba..fa3b85ee3 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -65,6 +65,9 @@ RemoteDNSManager, StubDNSManager, ) +from ldap_protocol.dns.bind_to_pdns_migration_use_case import ( + BindToPDNSMigrationUseCase, +) from ldap_protocol.identity import IdentityProvider from ldap_protocol.identity.provider_gateway import IdentityProviderGateway from ldap_protocol.kerberos import AbstractKadmin, get_kerberos_class @@ -334,6 +337,25 @@ async def get_dns_mngr( else: yield StubDNSManager(settings=dns_settings) + @provide(scope=Scope.REQUEST) + async def get_dns_migration_usecase( + self, + dns_settings: DNSSettingsDTO, + power_dns_auth_client: PowerDNSAuthHTTPClient, + power_dns_recursor_client: PowerDNSRecursorHTTPClient, + power_dns_dist_client: PowerDNSDistClient, + ) -> AsyncIterator[BindToPDNSMigrationUseCase]: + """Get DNS migration manager class.""" + yield BindToPDNSMigrationUseCase( + PowerDNSManager( + settings=dns_settings, + power_dns_auth_client=power_dns_auth_client, + power_dns_recursor_client=power_dns_recursor_client, + dnsdist_client=power_dns_dist_client, + ), + dns_settings=dns_settings, + ) + @provide(scope=Scope.APP) async def get_redis_for_sessions( self, diff --git a/app/ldap_protocol/dns/bind_to_pdns_migration_use_case.py b/app/ldap_protocol/dns/bind_to_pdns_migration_use_case.py new file mode 100644 index 000000000..07824ac2a --- /dev/null +++ b/app/ldap_protocol/dns/bind_to_pdns_migration_use_case.py @@ -0,0 +1,172 @@ +"""Manager for migrating from BIND to PowerDNS. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +import os + +import dns.zone +from loguru import logger + +from ldap_protocol.dns.dto import ( + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRecordDTO, + DNSRRSetDTO, + DNSSettingsDTO, +) +from ldap_protocol.dns.enums import DNSRecordType +from ldap_protocol.dns.managers.power_dns_manager import PowerDNSManager + + +class BindToPDNSMigrationUseCase: + bind_zone_file_dir: str = "/opt/" + bind_config_files_dir: str = "/etc/bind/" + + def __init__( + self, + pdns_manager: PowerDNSManager, + dns_settings: DNSSettingsDTO, + ) -> None: + self.pdns_manager = pdns_manager + self.dns_settings = dns_settings + + def parse_bind_config_file( + self, + ) -> tuple[list[DNSMasterZoneDTO], list[DNSForwardZoneDTO]]: + """Parse BIND configuration files to extract zone information.""" + master_zones: list[DNSMasterZoneDTO] = [] + forward_zones: list[DNSForwardZoneDTO] = [] + + with open( + os.path.join(self.bind_config_files_dir, "named.conf.local"), + ) as f: + for line in f: + line = line.strip() + if line.startswith("zone"): + parts = line.split() + if len(parts) >= 2: + zone_name = parts[1].strip('"') + continue + + if "type master" in line: + master_zones.append( + DNSMasterZoneDTO( + id=zone_name, + name=zone_name, + ), + ) + elif "type forward" in line: + forward_zones.append( + DNSForwardZoneDTO( + id=zone_name, + name=zone_name, + ), + ) + + return master_zones, forward_zones + + def parse_zones_records( + self, + master_zones: list[DNSMasterZoneDTO], + ) -> list[DNSMasterZoneDTO]: + """Parse zone files to extract DNS records.""" + zones_with_records: list[DNSMasterZoneDTO] = [] + + for zone in master_zones: + zone_rrsets: list[DNSRRSetDTO] = [] + zone_file_path = os.path.join( + self.bind_zone_file_dir, + f"{zone.name}.zone", + ) + try: + zone_obj = dns.zone.from_file( + zone_file_path, + origin=zone.name, + relativize=False, + ) + except FileNotFoundError: + logger.error( + f"Zone file for zone {zone.name} not found, skipping...", + ) + continue + + for name, ttl, rdata in zone_obj.iterate_rdatas(): + try: + DNSRecordType(rdata.rdtype.name) + except ValueError: + logger.warning( + f"Unsupported DNS record type {rdata.rdtype.name} in zone '{zone.name}'", # noqa: E501 + ) + continue + + zone_rrsets.append( + DNSRRSetDTO( + name=name.to_text(), + type=DNSRecordType(rdata.rdtype.name), + records=[ + DNSRecordDTO( + content=rdata.to_text(), + disabled=False, + ), + ], + ttl=ttl, + ), + ) + zone.rrsets = zone_rrsets + zones_with_records.append(zone) + + return zones_with_records + + async def get_bind_zones( + self, + ) -> tuple[list[DNSMasterZoneDTO], list[DNSForwardZoneDTO]]: + """Get zones from BIND.""" + master_zones, forward_zones = self.parse_bind_config_file() + master_zones = self.parse_zones_records(master_zones) + + return master_zones, forward_zones + + async def migrate_from_bind(self) -> None: + """Migrate from BIND to PowerDNS.""" + master_zones, forward_zones = await self.get_bind_zones() + + for master_zone in master_zones: + await self.pdns_manager.create_master_zone( + master_zone, + is_empty=True, + ) + for rrset in master_zone.rrsets: + await self.pdns_manager.create_record( + master_zone.name, + rrset, + ) + + for forward_zone in forward_zones: + await self.pdns_manager.create_forward_zone(forward_zone) + + open(os.path.join(self.bind_zone_file_dir, "migrated"), "a").close() + open(os.path.join(self.bind_config_files_dir, "migrated"), "a").close() + + def is_migration_needed(self) -> bool: + """Check if migration is needed.""" + return not ( + os.path.exists(os.path.join(self.bind_zone_file_dir, "migrated")) + and os.path.exists( + os.path.join(self.bind_config_files_dir, "migrated"), + ) + ) and bool(os.listdir(self.bind_zone_file_dir)) + + async def migrate(self) -> None: + """Migrate from BIND to PowerDNS.""" + if not self.is_migration_needed(): + logger.info("BIND to PowerDNS migration is not needed, exiting...") + return + + logger.info("Starting BIND to PowerDNS migration...") + await self.pdns_manager.setup(self.dns_settings, is_migration=True) + + await self.migrate_from_bind() + logger.info("Migration successful") + return diff --git a/app/ldap_protocol/dns/managers/abstract_dns_manager.py b/app/ldap_protocol/dns/managers/abstract_dns_manager.py index bfe7a79d6..90cf0a14e 100644 --- a/app/ldap_protocol/dns/managers/abstract_dns_manager.py +++ b/app/ldap_protocol/dns/managers/abstract_dns_manager.py @@ -38,6 +38,7 @@ def __init__( async def setup( self, dns_settings: DNSSettingsDTO, + is_migration: bool = False, ) -> None: ... @abstractmethod @@ -77,6 +78,7 @@ async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: ... async def create_master_zone( self, zone: DNSMasterZoneDTO, + is_empty: bool = False, ) -> None: ... @abstractmethod diff --git a/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py b/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py new file mode 100644 index 000000000..1d02531df --- /dev/null +++ b/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py @@ -0,0 +1,162 @@ +"""Manager for migrating from BIND to PowerDNS. + +Copyright (c) 2026 MultiFactor +License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE +""" + +import os + +import dns.zone +from loguru import logger + +from ldap_protocol.dns.dto import ( + DNSForwardZoneDTO, + DNSMasterZoneDTO, + DNSRecordDTO, + DNSRRSetDTO, + DNSSettingsDTO, +) +from ldap_protocol.dns.enums import DNSRecordType +from ldap_protocol.dns.managers.power_dns_manager import PowerDNSManager + + +class BindToPDNSMigrationManager: + bind_zone_file_dir: str = "/opt/" + bind_config_files_dir: str = "/etc/bind/" + + def __init__( + self, + pdns_manager: PowerDNSManager, + dns_settings: DNSSettingsDTO, + ) -> None: + self.pdns_manager = pdns_manager + self.dns_settings = dns_settings + + def parse_bind_config_file( + self, + ) -> tuple[list[DNSMasterZoneDTO], list[DNSForwardZoneDTO]]: + """Parse BIND configuration files to extract zone information.""" + master_zones: list[DNSMasterZoneDTO] = [] + forward_zones: list[DNSForwardZoneDTO] = [] + + with open( + os.path.join(self.bind_config_files_dir, "named.conf.local"), + ) as f: + for line in f: + line = line.strip() + if line.startswith("zone"): + parts = line.split() + if len(parts) >= 2: + zone_name = parts[1].strip('"') + continue + + if "type master" in line: + master_zones.append( + DNSMasterZoneDTO( + id=zone_name, + name=zone_name, + ), + ) + elif "type forward" in line: + forward_zones.append( + DNSForwardZoneDTO( + id=zone_name, + name=zone_name, + ), + ) + + return master_zones, forward_zones + + def parse_zones_records( + self, + master_zones: list[DNSMasterZoneDTO], + ) -> list[DNSMasterZoneDTO]: + """Parse zone files to extract DNS records.""" + for zone in master_zones: + zone_rrsets: list[DNSRRSetDTO] = [] + zone_file_path = os.path.join( + self.bind_zone_file_dir, + f"{zone.name}.zone", + ) + zone_obj = dns.zone.from_file( + zone_file_path, + origin=zone.name, + relativize=False, + ) + for name, ttl, rdata in zone_obj.iterate_rdatas(): + try: + DNSRecordType(rdata.rdtype.name) + except ValueError: + logger.warning( + f"Unsupported DNS record type {rdata.rdtype.name} in zone '{zone.name}'", # noqa: E501 + ) + continue + + zone_rrsets.append( + DNSRRSetDTO( + name=name.to_text(), + type=DNSRecordType(rdata.rdtype.name), + records=[ + DNSRecordDTO( + content=rdata.to_text(), + disabled=False, + ), + ], + ttl=ttl, + ), + ) + zone.rrsets = zone_rrsets + + return master_zones + + async def get_bind_zones( + self, + ) -> tuple[list[DNSMasterZoneDTO], list[DNSForwardZoneDTO]]: + """Get zones from BIND.""" + master_zones, forward_zones = self.parse_bind_config_file() + master_zones = self.parse_zones_records(master_zones) + + return master_zones, forward_zones + + async def migrate_from_bind(self) -> None: + """Migrate from BIND to PowerDNS.""" + master_zones, forward_zones = await self.get_bind_zones() + + for master_zone in master_zones: + await self.pdns_manager.create_master_zone( + master_zone, + is_empty=True, + ) + for rrset in master_zone.rrsets: + await self.pdns_manager.create_record( + master_zone.name, + rrset, + ) + + for forward_zone in forward_zones: + await self.pdns_manager.create_forward_zone(forward_zone) + + open(os.path.join(self.bind_zone_file_dir, "migrated"), "a").close() + open(os.path.join(self.bind_config_files_dir, "migrated"), "a").close() + + def is_migration_needed(self) -> bool: + """Check if migration is needed.""" + return not ( + os.path.exists(os.path.join(self.bind_zone_file_dir, "migrated")) + and os.path.exists( + os.path.join(self.bind_config_files_dir, "migrated"), + ) + ) and bool(os.listdir(self.bind_zone_file_dir)) + + async def migrate(self) -> None: + """Migrate from BIND to PowerDNS.""" + if not self.is_migration_needed(): + logger.info("BIND to PowerDNS migration is not needed, exiting...") + return + + logger.info("Starting BIND to PowerDNS migration...") + await self.pdns_manager.setup(self.dns_settings, is_migration=True) + + await self.migrate_from_bind() + logger.info("Migration successful") + return diff --git a/app/ldap_protocol/dns/managers/power_dns_manager.py b/app/ldap_protocol/dns/managers/power_dns_manager.py index 551efbdb9..500c69d40 100644 --- a/app/ldap_protocol/dns/managers/power_dns_manager.py +++ b/app/ldap_protocol/dns/managers/power_dns_manager.py @@ -70,28 +70,33 @@ def _normalize_dns_name(name: str) -> str: return name if name.endswith(".") else f"{name}." @logger_wraps() - async def setup(self, dns_settings: DNSSettingsDTO) -> None: + async def setup( + self, + dns_settings: DNSSettingsDTO, + is_migration: bool = False, + ) -> None: """Set up DNS server and DNS manager.""" records = [] if dns_settings.power_dns_settings is None: raise DNSSetupError("PowerDNS settings is not set.") - for record in DNS_FIRST_SETUP_RECORDS: - records.append( - DNSRRSetDTO( - name=f"{record['name']}{self._dns_settings.domain}.", - type=DNSRecordType(record["type"]), - records=[ - DNSRecordDTO( - content=f"{record['value']}{self._dns_settings.domain}.", - disabled=False, - modified_at=None, - ), - ], - changetype=PowerDNSRecordChangeType.EXTEND, - ttl=3600, - ), - ) + if not is_migration: + for record in DNS_FIRST_SETUP_RECORDS: + records.append( + DNSRRSetDTO( + name=f"{record['name']}{self._dns_settings.domain}.", + type=DNSRecordType(record["type"]), + records=[ + DNSRecordDTO( + content=f"{record['value']}{self._dns_settings.domain}.", + disabled=False, + modified_at=None, + ), + ], + changetype=PowerDNSRecordChangeType.EXTEND, + ttl=3600, + ), + ) try: self._dnsdist_client.setup_dnsdist( @@ -101,14 +106,15 @@ async def setup(self, dns_settings: DNSSettingsDTO) -> None: dns_settings.power_dns_settings.auth_server_ip, "master", ) - await self.create_master_zone( - DNSMasterZoneDTO( - id=self._dns_settings.domain, - name=self._dns_settings.domain, - dnssec=False, - rrsets=records, - ), - ) + if not is_migration: + await self.create_master_zone( + DNSMasterZoneDTO( + id=self._dns_settings.domain, + name=self._dns_settings.domain, + dnssec=False, + rrsets=records, + ), + ) except DNSZoneCreateError as e: raise DNSSetupError(f"Failed to set up DNS: {e}") @@ -157,17 +163,22 @@ async def delete_record(self, zone_id: str, record: DNSRRSetDTO) -> None: raise DNSRecordDeleteError(f"Failed to delete DNS record: {e}") @logger_wraps() - async def create_master_zone(self, zone: DNSMasterZoneDTO) -> None: + async def create_master_zone( + self, + zone: DNSMasterZoneDTO, + is_empty: bool = False, + ) -> None: """Create a master DNS zone.""" zone.name = self._normalize_dns_name(zone.name) - zone.nameservers.append(f"ns1.{zone.name}") + if not is_empty: + zone.nameservers.append(f"ns1.{zone.name}") - records = await create_initial_zone_records( - zone.name, - self._dns_settings.default_nameserver, - ) - zone.rrsets.extend(records) + records = await create_initial_zone_records( + zone.name, + self._dns_settings.default_nameserver, + ) + zone.rrsets.extend(records) try: await self._power_dns_auth_client.create_master_zone(zone) diff --git a/app/ldap_protocol/dns/managers/remote_dns_manager.py b/app/ldap_protocol/dns/managers/remote_dns_manager.py index 73f4aae00..c41f844bc 100644 --- a/app/ldap_protocol/dns/managers/remote_dns_manager.py +++ b/app/ldap_protocol/dns/managers/remote_dns_manager.py @@ -50,6 +50,7 @@ async def _send(self, action: Message) -> None: async def setup( self, dns_settings: DNSSettingsDTO, # noqa: ARG002 + is_migration: bool = False, # noqa: ARG002 ) -> None: """Set up DNS server and DNS manager.""" raise DNSNotImplementedError @@ -150,6 +151,7 @@ async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: async def create_master_zone( self, zone: DNSMasterZoneDTO, # noqa: ARG002 + is_empty: bool = False, # noqa: ARG002 ) -> None: raise DNSNotImplementedError diff --git a/app/ldap_protocol/dns/managers/stub_dns_manager.py b/app/ldap_protocol/dns/managers/stub_dns_manager.py index f07ae1e4c..2dceeb626 100644 --- a/app/ldap_protocol/dns/managers/stub_dns_manager.py +++ b/app/ldap_protocol/dns/managers/stub_dns_manager.py @@ -23,6 +23,7 @@ class StubDNSManager(AbstractDNSManager): async def setup( self, dns_settings: DNSSettingsDTO, + is_migration: bool = False, ) -> None: ... @logger_wraps(is_stub=True) @@ -65,6 +66,7 @@ async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: async def create_master_zone( self, zone: DNSMasterZoneDTO, + is_empty: bool = False, ) -> None: ... @logger_wraps(is_stub=True) diff --git a/app/multidirectory.py b/app/multidirectory.py index 22a19259d..7e7c58862 100644 --- a/app/multidirectory.py +++ b/app/multidirectory.py @@ -50,6 +50,9 @@ MFAProvider, ) from ldap_protocol.dependency import resolve_deps +from ldap_protocol.dns.bind_to_pdns_migration_use_case import ( + BindToPDNSMigrationUseCase, +) from ldap_protocol.identity.exceptions import UnauthorizedError from ldap_protocol.policies.audit.events.handler import AuditEventHandler from ldap_protocol.policies.audit.events.sender import AuditEventSenderManager @@ -287,6 +290,18 @@ async def event_sender_factory(settings: Settings) -> None: await asyncio.gather(manager.run()) +async def migrate_dns_factory(settings: Settings) -> None: + """Run DNS migration.""" + main_container = make_async_container( + MainProvider(), + context={Settings: settings}, + ) + + async with main_container(scope=Scope.REQUEST) as container: + usecase = await container.get(BindToPDNSMigrationUseCase) + await usecase.migrate() + + ldap = partial(run_entrypoint, factory=ldap_factory) cldap = partial(run_entrypoint, factory=cldap_factory) global_ldap_server = partial( @@ -297,6 +312,7 @@ async def event_sender_factory(settings: Settings) -> None: create_shadow_app = partial(create_prod_app, factory=_create_shadow_app) event_handler = partial(run_entrypoint, factory=event_handler_factory) event_sender = partial(run_entrypoint, factory=event_sender_factory) +dns_migration = partial(run_entrypoint, factory=migrate_dns_factory) if __name__ == "__main__": @@ -334,6 +350,11 @@ async def event_sender_factory(settings: Settings) -> None: action="store_true", help="Make migrations", ) + group.add_argument( + "--migrate_dns", + action="store_true", + help="Migrate DNS from BIND to PowerDNS", + ) args = parser.parse_args() @@ -376,3 +397,5 @@ async def event_sender_factory(settings: Settings) -> None: dump_acme_cert() elif args.migrate: command.upgrade(Config("alembic.ini"), "head") + elif args.migrate_dns: + dns_migration(settings=settings) From ad4742efd243ab141890124290f589d870f473f1 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Wed, 4 Mar 2026 16:15:09 +0300 Subject: [PATCH 35/45] add: forestFunctionality and update domainFunctionality attrs (#955) --- app/ldap_protocol/rootdse/reader.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/ldap_protocol/rootdse/reader.py b/app/ldap_protocol/rootdse/reader.py index b3b9d71a0..065be0a54 100644 --- a/app/ldap_protocol/rootdse/reader.py +++ b/app/ldap_protocol/rootdse/reader.py @@ -60,7 +60,8 @@ async def get( "supportedControl": [ "2.16.840.1.113730.3.4.4", # password expire policy ], - "domainFunctionality": ["0"], + "domainFunctionality": ["7"], + "forestFunctionality": ["7"], "supportedLDAPPolicies": [ "MaxConnIdleTime", "MaxPageSize", From 0df22cbaaf9130ba7cdb68d9d16184057ae5add1 Mon Sep 17 00:00:00 2001 From: iyashnov <57270538+iyashnov@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:32:32 +0300 Subject: [PATCH 36/45] Bugfix: PowerDNS bugfixes (#956) --- .package/docker-compose.yml | 2 +- app/ioc.py | 3 + app/ldap_protocol/dns/constants.py | 36 ++++ app/ldap_protocol/dns/dns_gateway.py | 3 +- .../bind_to_pdns_migrations_manager.py | 162 ------------------ .../dns/managers/power_dns_manager.py | 14 +- tests/conftest.py | 7 +- 7 files changed, 60 insertions(+), 167 deletions(-) delete mode 100644 app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index 64f8b4a7d..dc7db924b 100644 --- a/.package/docker-compose.yml +++ b/.package/docker-compose.yml @@ -411,7 +411,7 @@ services: - 53/tcp volumes: - dns_lmdb:/var/lib/pdns-lmdb - - dns_config:/etc/powerdns + - ./pdns.conf:/etc/powerdns/pdns.conf pdns_recursor: diff --git a/app/ioc.py b/app/ioc.py index fa3b85ee3..1a87389d4 100644 --- a/app/ioc.py +++ b/app/ioc.py @@ -307,10 +307,13 @@ async def get_dns_mngr_settings( self, dns_state_gateway: DNSStateGateway, settings: Settings, + root_dse_gw: DomainReadProtocol, ) -> AsyncIterator[DNSSettingsDTO]: """Get DNS manager's settings.""" + domain = await root_dse_gw.get_domain() dns_settings = await dns_state_gateway.get_dns_manager_settings( settings, + domain.name, ) yield dns_settings diff --git a/app/ldap_protocol/dns/constants.py b/app/ldap_protocol/dns/constants.py index 45c6e1073..3e7a33171 100644 --- a/app/ldap_protocol/dns/constants.py +++ b/app/ldap_protocol/dns/constants.py @@ -11,6 +11,42 @@ DNS_MANAGER_IP_ADDRESS_NAME = "DNSManagerIpAddress" DNS_MANAGER_TSIG_KEY_NAME = "DNSManagerTSIGKey" +DEFAULT_FORWARD_ZONE_NAMES: list[str] = [ + ".", + "b.e.f.ip6.arpa.", + "a.e.f.ip6.arpa.", + "23.172.in-addr.arpa.", + "21.172.in-addr.arpa.", + "254.169.in-addr.arpa.", + "20.172.in-addr.arpa.", + "17.172.in-addr.arpa.", + "31.172.in-addr.arpa.", + "22.172.in-addr.arpa.", + "16.172.in-addr.arpa.", + "19.172.in-addr.arpa.", + "24.172.in-addr.arpa.", + "168.192.in-addr.arpa.", + "10.in-addr.arpa.", + "8.e.f.ip6.arpa.", + "127.in-addr.arpa.", + "113.0.203.in-addr.arpa.", + "26.172.in-addr.arpa.", + "27.172.in-addr.arpa.", + "8.b.d.0.1.0.0.2.ip6.arpa.", + "28.172.in-addr.arpa.", + "d.f.ip6.arpa.", + "18.172.in-addr.arpa.", + "30.172.in-addr.arpa.", + "9.e.f.ip6.arpa.", + "100.51.198.in-addr.arpa.", + "255.255.255.255.in-addr.arpa.", + "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa.", + "29.172.in-addr.arpa.", + "0.in-addr.arpa.", + "25.172.in-addr.arpa.", + "2.0.192.in-addr.arpa.", +] + DNS_FIRST_SETUP_RECORDS: list[dict[str, str | DNSRecordType]] = [ {"name": "_ldap._tcp.", "value": "0 0 389 ", "type": DNSRecordType.SRV}, {"name": "_ldaps._tcp.", "value": "0 0 636 ", "type": DNSRecordType.SRV}, diff --git a/app/ldap_protocol/dns/dns_gateway.py b/app/ldap_protocol/dns/dns_gateway.py index 068ae4c1e..f5fa6802d 100644 --- a/app/ldap_protocol/dns/dns_gateway.py +++ b/app/ldap_protocol/dns/dns_gateway.py @@ -125,6 +125,7 @@ async def create_settings( async def get_dns_manager_settings( self, app_settings: Settings, + domain: str, ) -> DNSSettingsDTO: """Get DNS manager settings.""" power_dns_settings = PowerDNSSettingsDTO( @@ -132,7 +133,7 @@ async def get_dns_manager_settings( recursor_server_ip=app_settings.PDNS_RECURSOR_SERVER_IP, ) dns_settings = DNSSettingsDTO( - domain=app_settings.DOMAIN, + domain=domain, dns_server_ip=None, tsig_key=None, default_nameserver=app_settings.DEFAULT_NAMESERVER, diff --git a/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py b/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py deleted file mode 100644 index 1d02531df..000000000 --- a/app/ldap_protocol/dns/managers/bind_to_pdns_migrations_manager.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Manager for migrating from BIND to PowerDNS. - -Copyright (c) 2026 MultiFactor -License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE -""" - -import os - -import dns.zone -from loguru import logger - -from ldap_protocol.dns.dto import ( - DNSForwardZoneDTO, - DNSMasterZoneDTO, - DNSRecordDTO, - DNSRRSetDTO, - DNSSettingsDTO, -) -from ldap_protocol.dns.enums import DNSRecordType -from ldap_protocol.dns.managers.power_dns_manager import PowerDNSManager - - -class BindToPDNSMigrationManager: - bind_zone_file_dir: str = "/opt/" - bind_config_files_dir: str = "/etc/bind/" - - def __init__( - self, - pdns_manager: PowerDNSManager, - dns_settings: DNSSettingsDTO, - ) -> None: - self.pdns_manager = pdns_manager - self.dns_settings = dns_settings - - def parse_bind_config_file( - self, - ) -> tuple[list[DNSMasterZoneDTO], list[DNSForwardZoneDTO]]: - """Parse BIND configuration files to extract zone information.""" - master_zones: list[DNSMasterZoneDTO] = [] - forward_zones: list[DNSForwardZoneDTO] = [] - - with open( - os.path.join(self.bind_config_files_dir, "named.conf.local"), - ) as f: - for line in f: - line = line.strip() - if line.startswith("zone"): - parts = line.split() - if len(parts) >= 2: - zone_name = parts[1].strip('"') - continue - - if "type master" in line: - master_zones.append( - DNSMasterZoneDTO( - id=zone_name, - name=zone_name, - ), - ) - elif "type forward" in line: - forward_zones.append( - DNSForwardZoneDTO( - id=zone_name, - name=zone_name, - ), - ) - - return master_zones, forward_zones - - def parse_zones_records( - self, - master_zones: list[DNSMasterZoneDTO], - ) -> list[DNSMasterZoneDTO]: - """Parse zone files to extract DNS records.""" - for zone in master_zones: - zone_rrsets: list[DNSRRSetDTO] = [] - zone_file_path = os.path.join( - self.bind_zone_file_dir, - f"{zone.name}.zone", - ) - zone_obj = dns.zone.from_file( - zone_file_path, - origin=zone.name, - relativize=False, - ) - for name, ttl, rdata in zone_obj.iterate_rdatas(): - try: - DNSRecordType(rdata.rdtype.name) - except ValueError: - logger.warning( - f"Unsupported DNS record type {rdata.rdtype.name} in zone '{zone.name}'", # noqa: E501 - ) - continue - - zone_rrsets.append( - DNSRRSetDTO( - name=name.to_text(), - type=DNSRecordType(rdata.rdtype.name), - records=[ - DNSRecordDTO( - content=rdata.to_text(), - disabled=False, - ), - ], - ttl=ttl, - ), - ) - zone.rrsets = zone_rrsets - - return master_zones - - async def get_bind_zones( - self, - ) -> tuple[list[DNSMasterZoneDTO], list[DNSForwardZoneDTO]]: - """Get zones from BIND.""" - master_zones, forward_zones = self.parse_bind_config_file() - master_zones = self.parse_zones_records(master_zones) - - return master_zones, forward_zones - - async def migrate_from_bind(self) -> None: - """Migrate from BIND to PowerDNS.""" - master_zones, forward_zones = await self.get_bind_zones() - - for master_zone in master_zones: - await self.pdns_manager.create_master_zone( - master_zone, - is_empty=True, - ) - for rrset in master_zone.rrsets: - await self.pdns_manager.create_record( - master_zone.name, - rrset, - ) - - for forward_zone in forward_zones: - await self.pdns_manager.create_forward_zone(forward_zone) - - open(os.path.join(self.bind_zone_file_dir, "migrated"), "a").close() - open(os.path.join(self.bind_config_files_dir, "migrated"), "a").close() - - def is_migration_needed(self) -> bool: - """Check if migration is needed.""" - return not ( - os.path.exists(os.path.join(self.bind_zone_file_dir, "migrated")) - and os.path.exists( - os.path.join(self.bind_config_files_dir, "migrated"), - ) - ) and bool(os.listdir(self.bind_zone_file_dir)) - - async def migrate(self) -> None: - """Migrate from BIND to PowerDNS.""" - if not self.is_migration_needed(): - logger.info("BIND to PowerDNS migration is not needed, exiting...") - return - - logger.info("Starting BIND to PowerDNS migration...") - await self.pdns_manager.setup(self.dns_settings, is_migration=True) - - await self.migrate_from_bind() - logger.info("Migration successful") - return diff --git a/app/ldap_protocol/dns/managers/power_dns_manager.py b/app/ldap_protocol/dns/managers/power_dns_manager.py index 500c69d40..d42d478b7 100644 --- a/app/ldap_protocol/dns/managers/power_dns_manager.py +++ b/app/ldap_protocol/dns/managers/power_dns_manager.py @@ -14,7 +14,10 @@ PowerDNSDistClient, PowerDNSRecursorHTTPClient, ) -from ldap_protocol.dns.constants import DNS_FIRST_SETUP_RECORDS +from ldap_protocol.dns.constants import ( + DEFAULT_FORWARD_ZONE_NAMES, + DNS_FIRST_SETUP_RECORDS, +) from ldap_protocol.dns.dto import ( DNSForwardServerStatus, DNSForwardZoneDTO, @@ -225,7 +228,14 @@ async def get_master_zone_by_id(self, zone_id: str) -> DNSMasterZoneDTO: async def get_forward_zones(self) -> list[DNSForwardZoneDTO]: """Retrieve all forward DNS zones.""" try: - return await self._power_dns_recursor_client.get_forward_zones() + forward_zones = ( + await self._power_dns_recursor_client.get_forward_zones() + ) + return [ + zone + for zone in forward_zones + if zone.name not in DEFAULT_FORWARD_ZONE_NAMES + ] except DNSError as e: raise DNSZoneGetError(f"Failed to get DNS zones: {e}") diff --git a/tests/conftest.py b/tests/conftest.py index 364f086de..9be038db5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -286,9 +286,14 @@ async def get_dns_mngr_settings( self, dns_state_gateway: DNSStateGateway, settings: Settings, + root_dse_gw: DomainReadProtocol, ) -> AsyncIterator["DNSSettingsDTO"]: """Get DNS manager's settings.""" - yield await dns_state_gateway.get_dns_manager_settings(settings) + domain = await root_dse_gw.get_domain() + yield await dns_state_gateway.get_dns_manager_settings( + settings, + domain.name, + ) attribute_type_dao = provide(AttributeTypeDAO, scope=Scope.REQUEST) object_class_dao = provide(ObjectClassDAO, scope=Scope.REQUEST) From 6b4e8d5b41adc3566d446416d796b54446b3363a Mon Sep 17 00:00:00 2001 From: Ruslan Date: Thu, 5 Mar 2026 13:58:52 +0300 Subject: [PATCH 37/45] fix: update domain controller name references to use HOST_MACHINE_SHORT_NAME (#957) --- .../versions/ebf19750805e_add_domain_controllers_ou.py | 6 +++--- app/config.py | 9 +++++++++ app/extra/scripts/add_domain_controller.py | 6 +++--- app/ldap_protocol/auth/use_cases.py | 6 ++++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/app/alembic/versions/ebf19750805e_add_domain_controllers_ou.py b/app/alembic/versions/ebf19750805e_add_domain_controllers_ou.py index 5f708272a..2ad84def9 100644 --- a/app/alembic/versions/ebf19750805e_add_domain_controllers_ou.py +++ b/app/alembic/versions/ebf19750805e_add_domain_controllers_ou.py @@ -65,7 +65,7 @@ async def _create_domain_controllers_ou( domain_controller_data = [ { - "name": settings.HOST_MACHINE_NAME, + "name": settings.HOST_MACHINE_SHORT_NAME, "object_class": "computer", "attributes": { "objectClass": ["top"], @@ -77,7 +77,7 @@ async def _create_domain_controllers_ou( "sAMAccountType": [ str(SamAccountTypeCodes.SAM_MACHINE_ACCOUNT), ], - "sAMAccountName": [settings.HOST_MACHINE_NAME], + "sAMAccountName": [settings.HOST_MACHINE_SHORT_NAME], "ipHostNumber": [settings.DEFAULT_NAMESERVER], }, }, @@ -101,7 +101,7 @@ async def _create_domain_controllers_ou( dc = await session.scalar( select(Directory).where( - qa(Directory.name) == settings.HOST_MACHINE_NAME, + qa(Directory.name) == settings.HOST_MACHINE_SHORT_NAME, ), ) if not dc: diff --git a/app/config.py b/app/config.py index 67550187e..dcf689f9f 100644 --- a/app/config.py +++ b/app/config.py @@ -98,6 +98,15 @@ class Settings(BaseModel): AUDIT_SECOND_RETRY_TIME: int = 60 AUDIT_THIRD_RETRY_TIME: int = 1440 + @computed_field # type: ignore + @cached_property + def HOST_MACHINE_SHORT_NAME(self) -> str: # noqa: N802 + """Host machine name part before the first dot.""" + value = self.HOST_MACHINE_NAME.strip() + if not value: + raise ValueError("HOST_MACHINE_NAME is not set or empty") + return value.split(".", 1)[0] + @computed_field # type: ignore @cached_property def POSTGRES_URI(self) -> PostgresDsn: # noqa diff --git a/app/extra/scripts/add_domain_controller.py b/app/extra/scripts/add_domain_controller.py index dbfc087a0..3f700328a 100644 --- a/app/extra/scripts/add_domain_controller.py +++ b/app/extra/scripts/add_domain_controller.py @@ -30,7 +30,7 @@ async def _add_domain_controller( ) -> None: dc_directory = Directory( object_class="", - name=settings.HOST_MACHINE_NAME, + name=settings.HOST_MACHINE_SHORT_NAME, is_system=True, ) dc_directory.create_path(dc_ou_dir) @@ -54,7 +54,7 @@ async def _add_domain_controller( ), Attribute( name="sAMAccountName", - value=settings.HOST_MACHINE_NAME, + value=settings.HOST_MACHINE_SHORT_NAME, directory_id=dc_directory.id, ), Attribute( @@ -76,7 +76,7 @@ async def _add_domain_controller( ), Attribute( name="cn", - value=settings.HOST_MACHINE_NAME, + value=settings.HOST_MACHINE_SHORT_NAME, directory_id=dc_directory.id, ), ] diff --git a/app/ldap_protocol/auth/use_cases.py b/app/ldap_protocol/auth/use_cases.py index 370467e4f..ca063bcd7 100644 --- a/app/ldap_protocol/auth/use_cases.py +++ b/app/ldap_protocol/auth/use_cases.py @@ -92,7 +92,7 @@ def _create_domain_controller_data(self) -> dict: }, "children": [ { - "name": self._settings.HOST_MACHINE_NAME, + "name": self._settings.HOST_MACHINE_SHORT_NAME, "object_class": "computer", "attributes": { "objectClass": ["top"], @@ -104,7 +104,9 @@ def _create_domain_controller_data(self) -> dict: "sAMAccountType": [ str(SamAccountTypeCodes.SAM_MACHINE_ACCOUNT), ], - "sAMAccountName": [self._settings.HOST_MACHINE_NAME], + "sAMAccountName": [ + self._settings.HOST_MACHINE_SHORT_NAME, + ], "ipHostNumber": [self._settings.DEFAULT_NAMESERVER], }, }, From 50346e3e3487f038f746503db2e006448227ebb7 Mon Sep 17 00:00:00 2001 From: iyashnov <57270538+iyashnov@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:34:08 +0300 Subject: [PATCH 38/45] Bugfix: dnsdist change rule type (#959) --- .../dns/bind_to_pdns_migration_use_case.py | 44 +++++++++++++++---- .../dns/clients/power_dnsdist_client.py | 44 +++++++------------ 2 files changed, 51 insertions(+), 37 deletions(-) diff --git a/app/ldap_protocol/dns/bind_to_pdns_migration_use_case.py b/app/ldap_protocol/dns/bind_to_pdns_migration_use_case.py index 07824ac2a..be208d12e 100644 --- a/app/ldap_protocol/dns/bind_to_pdns_migration_use_case.py +++ b/app/ldap_protocol/dns/bind_to_pdns_migration_use_case.py @@ -32,6 +32,19 @@ def __init__( self.pdns_manager = pdns_manager self.dns_settings = dns_settings + def _strip_record_name(self, record_name: str, zone_name: str) -> str: + """Strip trash from record name.""" + logger.debug( + f"Stripping record name '{record_name}' for zone '{zone_name}'", + ) + if record_name.startswith(("\\032", "\\@")) and record_name != "\\@": + record_name = record_name.removeprefix("\\032").removeprefix("\\@") + elif record_name == "\\@": + record_name = zone_name + return ( + record_name if not record_name.startswith(".") else record_name[1:] + ) + def parse_bind_config_file( self, ) -> tuple[list[DNSMasterZoneDTO], list[DNSForwardZoneDTO]]: @@ -58,12 +71,24 @@ def parse_bind_config_file( ), ) elif "type forward" in line: - forward_zones.append( - DNSForwardZoneDTO( - id=zone_name, - name=zone_name, - ), + forward_zone = DNSForwardZoneDTO( + id=zone_name, + name=zone_name, ) + elif "forwarders" in line and forward_zone: + forwarders_part = line.split("forwarders")[1] + forwarders = [ + f + for f in forwarders_part.strip(";") + .strip(" ") + .strip("{") + .strip("}") + .strip(" ") + .split(";")[:-1] + ] + forward_zone.servers = forwarders + forward_zones.append(forward_zone) + forward_zone = None return master_zones, forward_zones @@ -94,7 +119,7 @@ def parse_zones_records( for name, ttl, rdata in zone_obj.iterate_rdatas(): try: - DNSRecordType(rdata.rdtype.name) + record_type = DNSRecordType(rdata.rdtype.name) except ValueError: logger.warning( f"Unsupported DNS record type {rdata.rdtype.name} in zone '{zone.name}'", # noqa: E501 @@ -103,8 +128,11 @@ def parse_zones_records( zone_rrsets.append( DNSRRSetDTO( - name=name.to_text(), - type=DNSRecordType(rdata.rdtype.name), + name=self._strip_record_name( + name.to_text(), + zone.name, + ), + type=record_type, records=[ DNSRecordDTO( content=rdata.to_text(), diff --git a/app/ldap_protocol/dns/clients/power_dnsdist_client.py b/app/ldap_protocol/dns/clients/power_dnsdist_client.py index 4e869527b..1e13899f4 100644 --- a/app/ldap_protocol/dns/clients/power_dnsdist_client.py +++ b/app/ldap_protocol/dns/clients/power_dnsdist_client.py @@ -160,18 +160,7 @@ def add_zone_rule(self, domain: str) -> None: """Add rule to redirect master zone DNS requests to auth server.""" command = f""" addAction( - QNameRule("*.{domain}"), - PoolAction("master") - ) - """ - self._send_command( - command, - expected=DNSdistCommandTypes.GENERIC, - ) - - command = f""" - addAction( - QNameRule("{domain}"), + QNameSuffixRule("{domain}"), PoolAction("master") ) """ @@ -186,24 +175,21 @@ def add_zone_rule(self, domain: str) -> None: def remove_zone_rule(self, domain: str) -> None: """Remove redirect rule from dnsdist.""" - rule_matches = [ - f"qname=={domain}", - f"qname==*.{domain}", - ] - for rule_match in rule_matches: - rules = self._get_all_rules() - if not rules.count: - DNSdistError( - "Failed to delete existing rule in dnsdist: Not Found", - ) + rules = self._get_all_rules() + if not rules.count: + raise DNSdistError( + "Failed to delete existing rule in dnsdist: Not Found", + ) - for rule in rules.rules: - if rule.match == rule_match: - command = f"rmRule({rule.id})" - self._send_command( - command, - expected=DNSdistCommandTypes.GENERIC, - ) + for rule in rules.rules: + rule_match = rule.match.split(" ")[-1] + domain_match = domain if domain.endswith(".") else f"{domain}." + if domain_match == rule_match: + command = f"rmRule({rule.id})" + self._send_command( + command, + expected=DNSdistCommandTypes.GENERIC, + ) self._persist_config() From 734a541485f03fe251017c96409836441d7c2e71 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Tue, 10 Mar 2026 10:56:23 +0300 Subject: [PATCH 39/45] fix: add missing env vars (#965) --- integration_tests/ssh/docker-compose.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/integration_tests/ssh/docker-compose.yml b/integration_tests/ssh/docker-compose.yml index e853bfd0f..aa34fb3a9 100644 --- a/integration_tests/ssh/docker-compose.yml +++ b/integration_tests/ssh/docker-compose.yml @@ -13,6 +13,11 @@ services: POSTGRES_USER: user POSTGRES_PASSWORD: test_pwd SECRET_KEY: 6a0452ae20cab4e21b6e9d18fa4b7bf397dd66ec3968b2d74 + PDNS_API_KEY: testkey123 + PDNS_DIST_KEY: testkey123 + DEFAULT_NAMESERVER: 127.0.0.1 + HOST_MACHINE_NAME: DC1 + POSTGRES_HOST: postgres command: python multidirectory.py --migrate depends_on: postgres: @@ -39,6 +44,11 @@ services: POSTGRES_PASSWORD: test_pwd SECRET_KEY: 6a0452ae20cab4e21b6e9d18fa4b7bf397dd66ec3968b2d74 MFA_API_SOURCE: dev + PDNS_API_KEY: testkey123 + PDNS_DIST_KEY: testkey123 + DEFAULT_NAMESERVER: 127.0.0.1 + HOST_MACHINE_NAME: DC1 + POSTGRES_HOST: postgres hostname: api_server depends_on: migrations: @@ -73,6 +83,11 @@ services: POSTGRES_PASSWORD: test_pwd SECRET_KEY: 6a0452ae20cab4e21b6e9d18fa4b7bf397dd66ec3968b2d74 MFA_API_SOURCE: dev + PDNS_API_KEY: testkey123 + PDNS_DIST_KEY: testkey123 + DEFAULT_NAMESERVER: 127.0.0.1 + HOST_MACHINE_NAME: DC1 + POSTGRES_HOST: postgres expose: - 389 depends_on: From 147857610fa6eb4460d933000ce8eeb0645bff82 Mon Sep 17 00:00:00 2001 From: Milov Dmitriy Date: Tue, 10 Mar 2026 14:50:00 +0300 Subject: [PATCH 40/45] Add: migration: add sAMAccountName to Computers (#966) --- ...7898910_add_samaccountname_to_computers.py | 88 +++++++++++++++++++ app/ldap_protocol/ldap_requests/modify.py | 6 ++ interface | 2 +- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 app/alembic/versions/df4287898910_add_samaccountname_to_computers.py diff --git a/app/alembic/versions/df4287898910_add_samaccountname_to_computers.py b/app/alembic/versions/df4287898910_add_samaccountname_to_computers.py new file mode 100644 index 000000000..c1635b6c0 --- /dev/null +++ b/app/alembic/versions/df4287898910_add_samaccountname_to_computers.py @@ -0,0 +1,88 @@ +"""Add sAMAccountName attribute to Computer directories. + +Revision ID: df4287898910 +Revises: 19d86e660cf2 +Create Date: 2026-03-10 07:33:43.493288 + +""" + +from alembic import op +from dishka import AsyncContainer, Scope +from sqlalchemy import delete, exists, select +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession + +from entities import Attribute, Directory, EntityType +from enums import EntityTypeNames +from repo.pg.tables import queryable_attr as qa + +# revision identifiers, used by Alembic. +revision: None | str = "df4287898910" +down_revision: None | str = "19d86e660cf2" +branch_labels: None | list[str] = None +depends_on: None | list[str] = None + +_ATTR_NAME_SAMACCOUNTNAME = "sAMAccountName" + + +def upgrade(container: AsyncContainer) -> None: + """Upgrade.""" + + async def _add_samaccountname_attr_to_computers( + connection: AsyncConnection, # noqa: ARG001 + ) -> None: + async with container(scope=Scope.REQUEST) as cnt: + session = await cnt.get(AsyncSession) + + computer_dirs = await session.scalars( + select(Directory) + .join(qa(Directory.entity_type)) + .where( + qa(EntityType.name) == EntityTypeNames.COMPUTER, + ~exists( + select(qa(Attribute.id)) + .where( + qa(Attribute.directory_id) == qa(Directory.id), + qa(Attribute.name) == _ATTR_NAME_SAMACCOUNTNAME, + ), + ), + ), + ) # fmt: skip + + for directory in computer_dirs: + session.add( + Attribute( + name=_ATTR_NAME_SAMACCOUNTNAME, + value=directory.name, + directory_id=directory.id, + ), + ) + + await session.commit() + + op.run_async(_add_samaccountname_attr_to_computers) + + +def downgrade(container: AsyncContainer) -> None: + """Downgrade.""" + + async def _remove_samaccountname_attr_from_computers( + connection: AsyncConnection, # noqa: ARG001 + ) -> None: + async with container(scope=Scope.REQUEST) as cnt: + session = await cnt.get(AsyncSession) + + computer_dir_ids = ( + select(qa(Directory.id)) + .join(qa(Directory.entity_type)) + .where(qa(EntityType.name) == EntityTypeNames.COMPUTER) + ) + await session.execute( + delete(Attribute).where( + qa(Attribute.name) == _ATTR_NAME_SAMACCOUNTNAME, + qa(Attribute.directory_id).in_(computer_dir_ids), + ), + ) + + await session.commit() + + op.run_async(_remove_samaccountname_attr_from_computers) diff --git a/app/ldap_protocol/ldap_requests/modify.py b/app/ldap_protocol/ldap_requests/modify.py index 9b1b03edf..ce10596e9 100644 --- a/app/ldap_protocol/ldap_requests/modify.py +++ b/app/ldap_protocol/ldap_requests/modify.py @@ -938,6 +938,8 @@ async def _add( # noqa: C901 await kadmin.modify_princ( directory.user.sam_account_name, new_sam_account_name, + algorithms=None, + password=None, ) directory.user.user_principal_name = new_user_principal_name # noqa: E501 # fmt: skip @@ -1044,10 +1046,14 @@ async def _modify_computer_samaccountname( await kadmin.modify_princ( f"host/{old_sam_account_name}", f"host/{new_sam_account_name}", + algorithms=None, + password=None, ) await kadmin.modify_princ( f"host/{old_sam_account_name}.{base_dir.name}", f"host/{new_sam_account_name}.{base_dir.name}", + algorithms=None, + password=None, ) async def _get_base_dir( diff --git a/interface b/interface index 3732b6958..5d5a80ee7 160000 --- a/interface +++ b/interface @@ -1 +1 @@ -Subproject commit 3732b695844e95e1692ae83e1b2e1de70e68b380 +Subproject commit 5d5a80ee7e9ea073338cac26a57be5f91a8d47f7 From 13d738d8c82d5a0bd531a6f19d84afd998a9d867 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Tue, 10 Mar 2026 17:13:23 +0300 Subject: [PATCH 41/45] Fix modify samaccountname (#969) --- .kerberos/config_server.py | 40 +++++++++++++++++++ app/ldap_protocol/kerberos/base.py | 7 ++++ app/ldap_protocol/kerberos/client.py | 18 ++++++++- app/ldap_protocol/kerberos/stub.py | 7 ++++ app/ldap_protocol/ldap_requests/modify.py | 17 ++++---- tests/conftest.py | 1 + .../test_main/test_router/test_modify.py | 10 ++--- 7 files changed, 83 insertions(+), 17 deletions(-) diff --git a/.kerberos/config_server.py b/.kerberos/config_server.py index d3cc24dfb..f725b9d99 100644 --- a/.kerberos/config_server.py +++ b/.kerberos/config_server.py @@ -188,6 +188,14 @@ async def force_pw_principal(self, name: str, **dbargs) -> None: :param str name: principal """ + @abstractmethod + async def rename_princ(self, name: str, new_name: str) -> None: + """Rename principal. + + :param str name: original name + :param str new_name: new name + """ + @abstractmethod async def modify_principal( self, @@ -272,6 +280,19 @@ async def add_princ( partial(princ.modify, attributes=128), ) + async def rename_princ(self, name: str, new_name: str) -> None: + """Rename principal. + + :param str name: original name + :param str new_name: new name + """ + await self.loop.run_in_executor( + self.pool, + self.client.rename_principal, + name, + new_name, + ) + async def _get_raw_principal(self, name: str) -> PrincipalProtocol: principal = await self.loop.run_in_executor( self.pool, @@ -640,6 +661,25 @@ async def create_or_update_princ_password( await kadmin.create_or_update_princ_pw(name, password) +@principal_router.put( + "/rename", + status_code=status.HTTP_202_ACCEPTED, + response_class=Response, +) +async def rename_princ( + kadmin: Annotated[AbstractKRBManager, Depends(get_kadmin)], + name: Annotated[str, Body()], + new_name: Annotated[str, Body()], +) -> None: + """Rename principal. + + :param Annotated[AbstractKRBManager, Depends kadmin: kadmin abstract + :param Annotated[str, Body name: principal name + :param Annotated[str, Body new_name: principal new name + """ + await kadmin.rename_princ(name, new_name) + + @principal_router.put( "/modify", status_code=status.HTTP_202_ACCEPTED, diff --git a/app/ldap_protocol/kerberos/base.py b/app/ldap_protocol/kerberos/base.py index 31dcb355e..930e9f22d 100644 --- a/app/ldap_protocol/kerberos/base.py +++ b/app/ldap_protocol/kerberos/base.py @@ -188,6 +188,13 @@ async def modify_princ( password: str | None = None, ) -> None: ... + @abstractmethod + async def rename_princ( + self, + name: str, + new_name: str, + ) -> None: ... + @backoff.on_exception( backoff.constant, ( diff --git a/app/ldap_protocol/kerberos/client.py b/app/ldap_protocol/kerberos/client.py index c85c062fc..0098d6281 100644 --- a/app/ldap_protocol/kerberos/client.py +++ b/app/ldap_protocol/kerberos/client.py @@ -103,9 +103,9 @@ async def modify_princ( ) -> None: """Rename request.""" response = await self.client.put( - "principal", + "principal/modify", json={ - "name": name, + "principal_name": name, "new_name": new_name, "algorithms": algorithms, "password": password, @@ -114,6 +114,20 @@ async def modify_princ( if response.status_code != 202: raise krb_exc.KRBAPIModifyPrincipalError(response.text) + @logger_wraps() + async def rename_princ( + self, + name: str, + new_name: str, + ) -> None: + """Rename request.""" + response = await self.client.put( + "principal/rename", + json={"name": name, "new_name": new_name}, + ) + if response.status_code != 202: + raise krb_exc.KRBAPIModifyPrincipalError(response.text) + @logger_wraps() async def ktadd( self, diff --git a/app/ldap_protocol/kerberos/stub.py b/app/ldap_protocol/kerberos/stub.py index 5e50efdcf..5d853c886 100644 --- a/app/ldap_protocol/kerberos/stub.py +++ b/app/ldap_protocol/kerberos/stub.py @@ -54,6 +54,13 @@ async def modify_princ( password: str | None = None, ) -> None: ... + @logger_wraps(is_stub=True) + async def rename_princ( + self, + name: str, + new_name: str, + ) -> None: ... + @logger_wraps(is_stub=True) async def ktadd(self, names: list[str], is_rand_key: bool) -> NoReturn: # noqa: ARG002 raise KRBAPIPrincipalNotFoundError diff --git a/app/ldap_protocol/ldap_requests/modify.py b/app/ldap_protocol/ldap_requests/modify.py index ce10596e9..e6ccb3b89 100644 --- a/app/ldap_protocol/ldap_requests/modify.py +++ b/app/ldap_protocol/ldap_requests/modify.py @@ -216,7 +216,10 @@ async def handle( ) return - if directory.rdname in names: + if ( + directory.rdname != "krbprincipalname" + and directory.rdname in names + ): yield ModifyResponse(result_code=LDAPCodes.NOT_ALLOWED_ON_RDN) return @@ -935,11 +938,9 @@ async def _add( # noqa: C901 new_user_principal_name = f"{new_sam_account_name}@{base_dir.name}" # noqa: E501 # fmt: skip if directory.user.sam_account_name != new_sam_account_name: - await kadmin.modify_princ( + await kadmin.rename_princ( directory.user.sam_account_name, new_sam_account_name, - algorithms=None, - password=None, ) directory.user.user_principal_name = new_user_principal_name # noqa: E501 # fmt: skip @@ -1043,17 +1044,13 @@ async def _modify_computer_samaccountname( raise ModifyForbiddenError("Old sAMAccountName value not found.") if old_sam_account_name != new_sam_account_name: - await kadmin.modify_princ( + await kadmin.rename_princ( f"host/{old_sam_account_name}", f"host/{new_sam_account_name}", - algorithms=None, - password=None, ) - await kadmin.modify_princ( + await kadmin.rename_princ( f"host/{old_sam_account_name}.{base_dir.name}", f"host/{new_sam_account_name}.{base_dir.name}", - algorithms=None, - password=None, ) async def _get_base_dir( diff --git a/tests/conftest.py b/tests/conftest.py index 9be038db5..9c4337450 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -195,6 +195,7 @@ async def get_kadmin(self) -> AsyncIterator[AsyncMock]: kadmin.add_principal = AsyncMock() kadmin.del_principal = AsyncMock() kadmin.modify_princ = AsyncMock() + kadmin.rename_princ = AsyncMock() kadmin.create_or_update_principal_pw = AsyncMock() kadmin.change_principal_password = AsyncMock() kadmin.lock_principal = AsyncMock() diff --git a/tests/test_api/test_main/test_router/test_modify.py b/tests/test_api/test_main/test_router/test_modify.py index 321c38795..2243dcb37 100644 --- a/tests/test_api/test_main/test_router/test_modify.py +++ b/tests/test_api/test_main/test_router/test_modify.py @@ -101,7 +101,7 @@ async def test_api_correct_modify_user_samaccountname( data = response.json() assert isinstance(data, dict) assert data.get("resultCode") == LDAPCodes.SUCCESS - assert kadmin.modify_princ.call_args.args == ("new_user", "NEW user name") # type: ignore + assert kadmin.rename_princ.call_args.args == ("new_user", "NEW user name") # type: ignore response = await http_client.post( "entry/search", @@ -160,7 +160,7 @@ async def test_api_correct_modify_user_userprincipalname( data = response.json() assert isinstance(data, dict) assert data.get("resultCode") == LDAPCodes.SUCCESS - assert kadmin.modify_princ.call_args.args == ("new_user", "newbiguser") # type: ignore + assert kadmin.rename_princ.call_args.args == ("new_user", "newbiguser") # type: ignore response = await http_client.post( "entry/search", @@ -219,12 +219,12 @@ async def test_api_correct_modify_computer_samaccountname_replace( assert isinstance(data, dict) assert data.get("resultCode") == LDAPCodes.SUCCESS - assert kadmin.modify_princ.call_count == 2 # type: ignore - assert kadmin.modify_princ.call_args_list[0].args == ( # type: ignore + assert kadmin.rename_princ.call_count == 2 # type: ignore + assert kadmin.rename_princ.call_args_list[0].args == ( # type: ignore "host/mycomputer", "host/maincomputer", ) - assert kadmin.modify_princ.call_args_list[1].args == ( # type: ignore + assert kadmin.rename_princ.call_args_list[1].args == ( # type: ignore "host/mycomputer.md.test", "host/maincomputer.md.test", ) From 31ea3e255e237ff4eb384d51f9c9728e8a4a0086 Mon Sep 17 00:00:00 2001 From: iyashnov <57270538+iyashnov@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:16:45 +0300 Subject: [PATCH 42/45] Fix prod docker compose (#970) --- .package/docker-compose.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index dc7db924b..c05e76d69 100644 --- a/.package/docker-compose.yml +++ b/.package/docker-compose.yml @@ -402,7 +402,6 @@ services: cap_add: - NET_ADMIN networks: - default: md_net: ipv4_address: 172.20.0.202 expose: @@ -420,7 +419,6 @@ services: cap_add: - NET_ADMIN networks: - default: md_net: ipv4_address: 172.20.0.200 expose: @@ -434,10 +432,10 @@ services: pdnsdist: image: powerdns/dnsdist-19:1.9.11 container_name: pdnsdist + user: "0:0" cap_add: - NET_ADMIN networks: - default: md_net: ipv4_address: 172.20.0.201 expose: From 81797d53b15be063d892fe3dd89915366b84cec5 Mon Sep 17 00:00:00 2001 From: iyashnov <57270538+iyashnov@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:29:49 +0300 Subject: [PATCH 43/45] fix: add user to pdns_recursor service in docker-compose (#971) --- .package/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index c05e76d69..d4c34b2ea 100644 --- a/.package/docker-compose.yml +++ b/.package/docker-compose.yml @@ -416,6 +416,7 @@ services: pdns_recursor: image: powerdns/pdns-recursor-51:5.1.7 container_name: pdns_recursor + user: "0:0" cap_add: - NET_ADMIN networks: From ce095fe4485eeb630502b995c3b07fd819ff0f9b Mon Sep 17 00:00:00 2001 From: iyashnov <57270538+iyashnov@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:57:20 +0300 Subject: [PATCH 44/45] fix: fixed permissions in pDNS container (#972) --- .package/docker-compose.yml | 2 ++ docker-compose.yml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.package/docker-compose.yml b/.package/docker-compose.yml index d4c34b2ea..6fea259d1 100644 --- a/.package/docker-compose.yml +++ b/.package/docker-compose.yml @@ -426,6 +426,8 @@ services: - 8083 - 53/udp - 53/tcp + entrypoint: sh -c "chown 0:0 /etc/powerdns/recursor.d && + exec /usr/bin/tini -- /usr/local/sbin/pdns_recursor-startup" volumes: - ./recursor.conf:/etc/powerdns/recursor.conf - forward_zones:/etc/powerdns/recursor.d/ diff --git a/docker-compose.yml b/docker-compose.yml index 47dc60214..c745aa9d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -572,6 +572,7 @@ services: pdnsdist: image: powerdns/dnsdist-19:1.9.11 container_name: pdnsdist + user: "0:0" networks: md_net: ipv4_address: 172.20.0.201 @@ -588,6 +589,7 @@ services: pdns_recursor: image: powerdns/pdns-recursor-51:5.1.7 container_name: pdns_recursor + user: "0:0" networks: md_net: ipv4_address: 172.20.0.200 @@ -595,6 +597,8 @@ services: - 8083 - 53/udp - 53/tcp + entrypoint: sh -c "chown 0:0 /etc/powerdns/recursor.d && + exec /usr/bin/tini -- /usr/local/sbin/pdns_recursor-startup" volumes: - ./.package/recursor.conf:/etc/powerdns/recursor.conf - forward_zones:/etc/powerdns/recursor.d/ From 88a481b46f123ad9dd4b1c6114bde401fdc18381 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Wed, 11 Mar 2026 17:34:00 +0300 Subject: [PATCH 45/45] Return old krb api (#973) --- app/api/main/krb5_router.py | 11 ++++++++--- tests/test_api/test_main/test_kadmin.py | 10 +++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/api/main/krb5_router.py b/app/api/main/krb5_router.py index 37ed49721..5dfc8937a 100644 --- a/app/api/main/krb5_router.py +++ b/app/api/main/krb5_router.py @@ -155,13 +155,14 @@ async def setup_kdc( ) async def ktadd( kerberos_adapter: FromDishka[KerberosFastAPIAdapter], - request: KtaddRequest, + names: Annotated[LIMITED_LIST, Body()], ) -> StreamingResponse: """Create keytab from kadmin server. :param Annotated[LDAPSession, Depends ldap_session: ldap :return bytes: file """ + request = KtaddRequest(names=names) return await kerberos_adapter.ktadd(request) @@ -183,12 +184,13 @@ async def get_krb_status( @krb5_router.post( - "/principal", + "/principal/add", dependencies=[Depends(verify_auth), Depends(require_master_db)], error_map=error_map, ) async def add_principal( - request: PrincipalAddRequest, + primary: Annotated[LIMITED_STR, Body()], + instance: Annotated[LIMITED_STR, Body()], kerberos_adapter: FromDishka[KerberosFastAPIAdapter], ) -> None: """Create principal in kerberos with given name. @@ -198,6 +200,9 @@ async def add_principal( :param Annotated[LDAPSession, Depends ldap_session: ldap :raises HTTPException: on failed kamin request. """ + request = PrincipalAddRequest( + principal_name=f"{primary}/{instance}", + ) await kerberos_adapter.add_principal(request) diff --git a/tests/test_api/test_main/test_kadmin.py b/tests/test_api/test_main/test_kadmin.py index b13909357..9ed7244fb 100644 --- a/tests/test_api/test_main/test_kadmin.py +++ b/tests/test_api/test_main/test_kadmin.py @@ -214,7 +214,7 @@ async def test_ktadd( names = ["test1", "test2"] response = await http_client.post( "/kerberos/ktadd", - json={"names": names, "is_rand_key": False}, + json=names, ) kadmin.ktadd.assert_called() # type: ignore @@ -245,7 +245,7 @@ async def test_ktadd_400( names = ["test1", "test2"] response = await http_client.post( "/kerberos/ktadd", - json={"names": names, "is_rand_key": False}, + json=names, ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -395,10 +395,10 @@ async def test_add_princ( :param LDAPSession ldap_session: ldap """ response = await http_client.post( - "/kerberos/principal", + "/kerberos/principal/add", json={ - "principal_name": "host/12345", - "password": None, + "primary": "host", + "instance": "12345", }, ) kadmin_args = kadmin.add_principal.call_args.args # type: ignore