diff --git a/.kerberos/entrypoint.sh b/.kerberos/entrypoint.sh index a50bd3ec7..f063bbea4 100755 --- a/.kerberos/entrypoint.sh +++ b/.kerberos/entrypoint.sh @@ -4,6 +4,7 @@ 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 + cd /server uvicorn --factory config_server:create_app \ diff --git a/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py new file mode 100644 index 000000000..eeba2a64c --- /dev/null +++ b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py @@ -0,0 +1,179 @@ +"""Rename services container to System for AD compatibility. + +Revision ID: a1b2c3d4e5f6 +Revises: 6c858cc05da7 +Create Date: 2026-01-13 12:00:00.000000 + +""" + +from alembic import op +from dishka import AsyncContainer, Scope +from sqlalchemy import and_, exists, select +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession + +from entities import Attribute, Directory +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 = "a1b2c3d4e5f6" +down_revision: None | str = "71e642808369" +branch_labels: None | list[str] = None +depends_on: None | list[str] = None + + +def upgrade(container: AsyncContainer) -> None: + """Upgrade: Rename 'services' container to 'System'.""" + + async def _update_descendants( + session: AsyncSession, + parent_id: int, + ) -> None: + """Recursively update paths of all descendants.""" + child_dirs = await session.scalars( + select(Directory) + .where(qa(Directory.parent_id) == parent_id), + ) # fmt: skip + + for child_dir in child_dirs: + child_dir.path = [ + "ou=System" if p == "ou=services" else p + for p in child_dir.path + ] + await session.flush() + await _update_descendants(session, child_dir.id) + + async def _update_attributes( + session: AsyncSession, + old_value: str, + new_value: str, + ) -> None: + """Update attribute values containing old DN.""" + result = await session.execute( + select(Attribute) + .where( + Attribute.value.ilike(f"%{old_value}%"), # type: ignore + ), + ) # fmt: skip + 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_services_to_system(connection: AsyncConnection) -> None: # noqa: ARG001 + async with container(scope=Scope.REQUEST) as cnt: + session = await cnt.get(AsyncSession) + + base_directories = await get_base_directories(session) + if not base_directories: + await session.commit() + return + + service_dirs = await session.scalars( + select(Directory).where(qa(Directory.name) == "services"), + ) + + for service_dir in service_dirs: + system_exists = await session.scalar( + select(exists(Directory)) + .where( + and_( + qa(Directory.name) == "System", + qa(Directory.parent_id) == service_dir.parent_id, + ), + ), + ) # fmt: skip + + if system_exists: + continue + + service_dir.name = "System" + service_dir.path = [ + "ou=System" if p == "ou=services" else p + for p in service_dir.path + ] + + await session.flush() + await _update_descendants(session, service_dir.id) + + await _update_attributes(session, "ou=services", "ou=System") + await session.commit() + + op.run_async(_rename_services_to_system) + + +def downgrade(container: AsyncContainer) -> None: + """Downgrade: Rename 'System' container back to 'services'.""" + + async def _update_descendants_downgrade( + session: AsyncSession, + parent_id: int, + ) -> None: + """Recursively update paths of all descendants.""" + child_dirs = await session.scalars( + select(Directory) + .where(qa(Directory.parent_id) == parent_id), + ) # fmt: skip + + for child_dir in child_dirs: + child_dir.path = [ + "ou=services" if p == "ou=System" else p + for p in child_dir.path + ] + await session.flush() + await _update_descendants_downgrade(session, child_dir.id) + + async def _update_attributes_downgrade( + session: AsyncSession, + old_value: str, + new_value: str, + ) -> None: + """Update attribute values during downgrade.""" + result = await session.execute( + select(Attribute) + .where( + Attribute.value.ilike(f"%{old_value}%"), # type: ignore + ), + ) # fmt: skip + 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_system_to_services(connection: AsyncConnection) -> None: # noqa ARG001 + async with container(scope=Scope.REQUEST) as cnt: + session = await cnt.get(AsyncSession) + + base_directories = await get_base_directories(session) + if not base_directories: + await session.commit() + return + + system_dirs = await session.scalars( + select(Directory).where(qa(Directory.name) == "System"), + ) + + for system_dir in system_dirs: + system_dir.name = "services" + system_dir.path = [ + "ou=services" if p == "ou=System" else p + for p in system_dir.path + ] + + await session.flush() + await _update_descendants_downgrade(session, system_dir.id) + + await _update_attributes_downgrade( + session, + "ou=System", + "ou=services", + ) + await session.commit() + + op.run_async(_rename_system_to_services) diff --git a/app/extra/scripts/update_krb5_config.py b/app/extra/scripts/update_krb5_config.py index c83b6ef07..b0ecda0f6 100644 --- a/app/extra/scripts/update_krb5_config.py +++ b/app/extra/scripts/update_krb5_config.py @@ -1,40 +1,69 @@ -"""Kerberos update config. +"""Kerberos configuration update script. Copyright (c) 2025 MultiFactor License: https://github.com/MultiDirectoryLab/MultiDirectory/blob/main/LICENSE """ +from pathlib import Path + from loguru import logger from sqlalchemy.ext.asyncio import AsyncSession from config import Settings -from ldap_protocol.kerberos import AbstractKadmin +from ldap_protocol.kerberos.utils import get_system_container_dn from ldap_protocol.utils.queries import get_base_directories +KRB5_CONF_PATH = Path("/etc/krb5kdc/krb5.conf") +KDC_CONF_PATH = Path("/etc/krb5kdc/kdc.conf") +STASH_FILE_PATH = Path("/etc/krb5kdc/krb5.d/stash.keyfile") + + +def _migrate_legacy_dns(content: str) -> str: + """Replace legacy DN formats with current ones. + + :param content: File content to migrate. + :return: Migrated content. + """ + return content.replace("ou=services", "ou=System").replace( + "ou=users", + "cn=users", + ) + async def update_krb5_config( - kadmin: AbstractKadmin, session: AsyncSession, settings: Settings, ) -> None: - """Update kerberos config.""" - if not (await kadmin.get_status(wait_for_positive=True)): - logger.error("kadmin_api is not running") - return + """Update Kerberos configuration files via direct write to shared volume. - base_dn_list = await get_base_directories(session) - base_dn = base_dn_list[0].path_dn - domain: str = base_dn_list[0].name + Renders krb5.conf and kdc.conf from templates and writes them directly + to the shared volume. Also migrates legacy DN formats in stash.keyfile + if present (ou=services -> ou=System, ou=users -> cn=users). - krbadmin = "cn=krbadmin,cn=users," + base_dn - services_container = "ou=services," + base_dn + :param session: Database session for fetching base directories. + :param settings: Application settings with template environment. + :raises Exception: If config rendering or writing fails. + """ + if not KRB5_CONF_PATH.parent.exists(): + logger.error( + f"Config directory {KRB5_CONF_PATH.parent} not found, " + "kerberos volume not mounted", + ) + return - krb5_template = settings.TEMPLATES.get_template("krb5.conf") - kdc_template = settings.TEMPLATES.get_template("kdc.conf") + base_dn_list = await get_base_directories(session) + if not base_dn_list: + logger.error("No base directories found") + return - kdc_config = await kdc_template.render_async(domain=domain) + base_dn = base_dn_list[0].path_dn + domain = base_dn_list[0].name + krbadmin = f"cn=krbadmin,cn=users,{base_dn}" + services_container = get_system_container_dn(base_dn) - krb5_config = await krb5_template.render_async( + krb5_config = await settings.TEMPLATES.get_template( + "krb5.conf", + ).render_async( domain=domain, krbadmin=krbadmin, services_container=services_container, @@ -42,5 +71,19 @@ async def update_krb5_config( mfa_push_url=settings.KRB5_MFA_PUSH_URL, sync_password_url=settings.KRB5_SYNC_PASSWORD_URL, ) + kdc_config = await settings.TEMPLATES.get_template( + "kdc.conf", + ).render_async( + domain=domain, + ) + + KRB5_CONF_PATH.write_text(krb5_config, encoding="utf-8") + KDC_CONF_PATH.write_text(kdc_config, encoding="utf-8") - await kadmin.setup_configs(krb5_config, kdc_config) + if STASH_FILE_PATH.exists(): + stash_content = STASH_FILE_PATH.read_text(encoding="utf-8") + if "ou=services" in stash_content or "ou=users" in stash_content: + STASH_FILE_PATH.write_text( + _migrate_legacy_dns(stash_content), + encoding="utf-8", + ) diff --git a/app/ldap_protocol/kerberos/service.py b/app/ldap_protocol/kerberos/service.py index ae6f6b090..f6a0aae05 100644 --- a/app/ldap_protocol/kerberos/service.py +++ b/app/ldap_protocol/kerberos/service.py @@ -44,7 +44,12 @@ from .ldap_structure import KRBLDAPStructureManager from .schemas import AddRequests, KDCContext, KerberosAdminDnGroup, TaskStruct from .template_render import KRBTemplateRenderer -from .utils import KerberosState, get_krb_server_state, set_state +from .utils import ( + KerberosState, + get_krb_server_state, + get_system_container_dn, + set_state, +) class KerberosService(AbstractService): @@ -141,7 +146,7 @@ def _build_kerberos_admin_dns(self, base_dn: str) -> KerberosAdminDnGroup: dataclass with DN for krbadmin, services_container, krbadmin_group. """ krbadmin = f"cn=krbadmin,cn=users,{base_dn}" - services_container = f"ou=services,{base_dn}" + services_container = get_system_container_dn(base_dn) krbgroup = f"cn=krbadmin,cn=groups,{base_dn}" return KerberosAdminDnGroup( krbadmin_dn=krbadmin, @@ -293,7 +298,7 @@ async def _get_kdc_context(self) -> KDCContext: 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 = f"ou=services,{base_dn}" + services_container = get_system_container_dn(base_dn) return KDCContext( base_dn=base_dn, domain=domain, diff --git a/app/ldap_protocol/kerberos/utils.py b/app/ldap_protocol/kerberos/utils.py index 518169475..c6278ed95 100644 --- a/app/ldap_protocol/kerberos/utils.py +++ b/app/ldap_protocol/kerberos/utils.py @@ -131,3 +131,8 @@ async def unlock_principal(name: str, session: AsyncSession) -> None: .filter_by(directory_id=subquery, name="krbprincipalexpiration") .execution_options(synchronize_session=False), ) + + +def get_system_container_dn(base_dn: str) -> str: + """Get System container DN for services.""" + return f"ou=System,{base_dn}" diff --git a/app/ldap_protocol/roles/role_use_case.py b/app/ldap_protocol/roles/role_use_case.py index 1e978a3f1..d9c2921e0 100644 --- a/app/ldap_protocol/roles/role_use_case.py +++ b/app/ldap_protocol/roles/role_use_case.py @@ -8,6 +8,7 @@ from entities import AccessControlEntry, AceType, Directory, Role from enums import AuthorizationRules, RoleConstants, RoleScope +from ldap_protocol.kerberos.utils import get_system_container_dn from ldap_protocol.utils.queries import get_base_directories from repo.pg.tables import ( access_control_entries_table, @@ -211,7 +212,7 @@ async def create_kerberos_system_role(self) -> None: aces = self._get_full_access_aces( role_id=self._role_dao.get_last_id(), - base_dn="ou=services," + base_dn_list[0].path_dn, + base_dn=get_system_container_dn(base_dn_list[0].path_dn), ) await self._access_control_entry_dao.create_bulk(aces) diff --git a/docker-compose.yml b/docker-compose.yml index 2bc4dce87..637a53105 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -424,6 +424,7 @@ services: - ./certs:/certs - ./app:/app - ldap_keytab:/LDAP_keytab/ + - kdc:/etc/krb5kdc/ env_file: local.env command: python multidirectory.py --scheduler tty: true diff --git a/interface b/interface index 97bbc08dd..f31962020 160000 --- a/interface +++ b/interface @@ -1 +1 @@ -Subproject commit 97bbc08dda7584f579f756d8b09abe60db67b47b +Subproject commit f31962020a6689e6a4c61fb3349db5b5c7895f92 diff --git a/tests/test_api/test_main/test_kadmin.py b/tests/test_api/test_main/test_kadmin.py index b5b06d096..aabfcf14f 100644 --- a/tests/test_api/test_main/test_kadmin.py +++ b/tests/test_api/test_main/test_kadmin.py @@ -77,7 +77,7 @@ async def test_tree_creation( response = await http_client.post( "entry/search", json={ - "base_object": "ou=services,dc=md,dc=test", + "base_object": "ou=System,dc=md,dc=test", "scope": 0, "deref_aliases": 0, "size_limit": 1000, @@ -90,7 +90,7 @@ async def test_tree_creation( ) assert ( response.json()["search_result"][0]["object_name"] - == "ou=services,dc=md,dc=test" + == "ou=System,dc=md,dc=test" ) bind = MutePolicyBindRequest( @@ -157,13 +157,13 @@ async def test_setup_call( kdc_doc = kadmin.setup.call_args.kwargs.pop("kdc_config").encode() # NOTE: Asserting documents integrity, tests template rendering - assert blake2b(krb_doc, digest_size=8).hexdigest() == "f433bbc7df5a236b" + assert blake2b(krb_doc, digest_size=8).hexdigest() == "0567ec28b8ccca51" assert blake2b(kdc_doc, digest_size=8).hexdigest() == "79e43649d34fe577" assert kadmin.setup.call_args.kwargs == { "domain": "md.test", "admin_dn": "cn=user0,cn=users,dc=md,dc=test", - "services_dn": "ou=services,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", "ldap_keytab_path": "/LDAP_keytab/ldap.keytab", diff --git a/tests/test_shedule.py b/tests/test_shedule.py index fde3d7a4a..dc5aaaf01 100644 --- a/tests/test_shedule.py +++ b/tests/test_shedule.py @@ -67,11 +67,9 @@ async def test_check_ldap_principal( async def test_update_krb5_config( session: AsyncSession, settings: Settings, - kadmin: AbstractKadmin, ) -> None: """Test update_krb5_config.""" await update_krb5_config( session=session, - kadmin=kadmin, settings=settings, )