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, + )