From 0def0a5f302ab4d7f1b5fcbbeaafaeb69daf8417 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 13 Jan 2026 11:54:47 +0300 Subject: [PATCH 01/11] Refactor: use get_services_container_dn for service container DN retrieval --- .../a1b2c3d4e5f6_rename_services_to_system.py | 187 ++++++++++++++++++ app/extra/scripts/update_krb5_config.py | 3 +- app/ldap_protocol/kerberos/service.py | 12 +- app/ldap_protocol/kerberos/utils.py | 5 + app/ldap_protocol/roles/role_use_case.py | 3 +- tests/test_api/test_main/test_kadmin.py | 6 +- 6 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py 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..ffbe95d14 --- /dev/null +++ b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py @@ -0,0 +1,187 @@ +"""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 +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 = "6c858cc05da7" +branch_labels: None | list[str] = None +depends_on: None | list[str] = None + + +def upgrade(container: AsyncContainer) -> None: # noqa: ARG001 + """Upgrade: Rename 'services' container to 'System'.""" + + async def _rename_services_to_system(connection: AsyncConnection) -> None: + session = AsyncSession(bind=connection) + await session.begin() + + try: + base_directories = await get_base_directories(session) + if not base_directories: + await session.commit() + return + + services_dirs = await session.scalars( + select(Directory).where( + and_( + qa(Directory.name) == "services", + qa(Directory.object_class) == "organizationalUnit", + ), + ), + ) + + for services_dir in services_dirs: + system_exists = await session.scalar( + select(exists(Directory)).where( + and_( + qa(Directory.name) == "System", + qa(Directory.parent_id) == services_dir.parent_id, + qa(Directory.object_class) == "organizationalUnit", + ), + ), + ) + + if system_exists: + continue + + services_dir.name = "System" + services_dir.path = [ + "ou=System" if p == "ou=services" else p + for p in services_dir.path + ] + + await session.flush() + await _update_descendants(session, services_dir.id) + await _update_attributes(session, "ou=services", "ou=System") + + await session.commit() + + except Exception: + await session.rollback() + raise + + async def _update_descendants( + session: AsyncSession, + parent_id: int, + ) -> None: + """Recursively update paths of all descendants.""" + children = await session.scalars( + select(Directory).where(qa(Directory.parent_id) == parent_id), + ) + + for child in children: + child.path = [ + "ou=System" if p == "ou=services" else p for p in child.path + ] + await session.flush() + await _update_descendants(session, child.id) + + async def _update_attributes( + session: AsyncSession, + old_value: str, + new_value: str, + ) -> None: + """Update attribute values containing old DN.""" + attributes = await session.scalars( + select(Attribute).where(Attribute.value.contains(old_value)), # type: ignore + ) + + 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() + + op.run_async(_rename_services_to_system) + + +def downgrade(container: AsyncContainer) -> None: # noqa: ARG001 + """Downgrade: Rename 'System' container back to 'services'.""" + + async def _rename_system_to_services(connection: AsyncConnection) -> None: + session = AsyncSession(bind=connection) + await session.begin() + + try: + base_directories = await get_base_directories(session) + if not base_directories: + await session.commit() + return + + system_dirs = await session.scalars( + select(Directory).where( + and_( + qa(Directory.name) == "System", + qa(Directory.object_class) == "organizationalUnit", + ), + ), + ) + + 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() + + except Exception: + await session.rollback() + raise + + async def _update_descendants_downgrade( + session: AsyncSession, + parent_id: int, + ) -> None: + """Recursively update paths of all descendants.""" + children = await session.scalars( + select(Directory).where(qa(Directory.parent_id) == parent_id), + ) + + for child in children: + child.path = [ + "ou=services" if p == "ou=System" else p for p in child.path + ] + await session.flush() + await _update_descendants_downgrade(session, child.id) + + async def _update_attributes_downgrade( + session: AsyncSession, + old_value: str, + new_value: str, + ) -> None: + """Update attribute values during downgrade.""" + attributes = await session.scalars( + select(Attribute).where(Attribute.value.contains(old_value)), # type: ignore + ) + + 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() + + 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..d2c5fb774 100644 --- a/app/extra/scripts/update_krb5_config.py +++ b/app/extra/scripts/update_krb5_config.py @@ -9,6 +9,7 @@ from config import Settings from ldap_protocol.kerberos import AbstractKadmin +from ldap_protocol.kerberos.utils import get_services_container_dn from ldap_protocol.utils.queries import get_base_directories @@ -27,7 +28,7 @@ async def update_krb5_config( domain: str = base_dn_list[0].name krbadmin = "cn=krbadmin,cn=users," + base_dn - services_container = "ou=services," + base_dn + services_container = get_services_container_dn(base_dn) krb5_template = settings.TEMPLATES.get_template("krb5.conf") kdc_template = settings.TEMPLATES.get_template("kdc.conf") diff --git a/app/ldap_protocol/kerberos/service.py b/app/ldap_protocol/kerberos/service.py index ae6f6b090..16c4b2176 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_services_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_services_container_dn(base_dn) krbgroup = f"cn=krbadmin,cn=groups,{base_dn}" return KerberosAdminDnGroup( krbadmin_dn=krbadmin, @@ -293,7 +298,8 @@ 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}" + # Use new System container name (AD-compatible, renamed from services) + services_container = get_services_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..065a32782 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_services_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..c2f0e44bf 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_services_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_services_container_dn(base_dn_list[0].path_dn), ) await self._access_control_entry_dao.create_bulk(aces) diff --git a/tests/test_api/test_main/test_kadmin.py b/tests/test_api/test_main/test_kadmin.py index b5b06d096..cb42e697a 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( @@ -163,7 +163,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", - "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", From 297d5e5ef32f6fbc860d4eea33c37105dd6cc037 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 13 Jan 2026 12:45:40 +0300 Subject: [PATCH 02/11] Refactor: improve attribute update queries in alembic migration --- .../a1b2c3d4e5f6_rename_services_to_system.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py index ffbe95d14..204a69140 100644 --- a/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py +++ b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py @@ -66,8 +66,8 @@ async def _rename_services_to_system(connection: AsyncConnection) -> None: await session.flush() await _update_descendants(session, services_dir.id) - await _update_attributes(session, "ou=services", "ou=System") + await _update_attributes(session, "ou=services", "ou=System") await session.commit() except Exception: @@ -96,9 +96,12 @@ async def _update_attributes( new_value: str, ) -> None: """Update attribute values containing old DN.""" - attributes = await session.scalars( - select(Attribute).where(Attribute.value.contains(old_value)), # type: ignore + result = await session.execute( + select(Attribute).where( + Attribute.value.ilike(f"%{old_value}%"), # type: ignore + ), ) + attributes = result.scalars().all() for attr in attributes: if attr.value and old_value in attr.value: @@ -140,12 +143,12 @@ async def _rename_system_to_services(connection: AsyncConnection) -> None: await session.flush() await _update_descendants_downgrade(session, system_dir.id) - await _update_attributes_downgrade( - session, - "ou=System", - "ou=services", - ) + await _update_attributes_downgrade( + session, + "ou=System", + "ou=services", + ) await session.commit() except Exception: @@ -174,9 +177,12 @@ async def _update_attributes_downgrade( new_value: str, ) -> None: """Update attribute values during downgrade.""" - attributes = await session.scalars( - select(Attribute).where(Attribute.value.contains(old_value)), # type: ignore + result = await session.execute( + select(Attribute).where( + Attribute.value.ilike(f"%{old_value}%"), # type: ignore + ), ) + attributes = result.scalars().all() for attr in attributes: if attr.value and old_value in attr.value: From d40dcc03eb694a1970e2b7854e8ea843e0d6103a Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 13 Jan 2026 12:58:14 +0300 Subject: [PATCH 03/11] Refactor: simplify directory selection queries in alembic migration for services and system --- .../a1b2c3d4e5f6_rename_services_to_system.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py index 204a69140..f093e1b9d 100644 --- a/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py +++ b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py @@ -36,12 +36,7 @@ async def _rename_services_to_system(connection: AsyncConnection) -> None: return services_dirs = await session.scalars( - select(Directory).where( - and_( - qa(Directory.name) == "services", - qa(Directory.object_class) == "organizationalUnit", - ), - ), + select(Directory).where(qa(Directory.name) == "services"), ) for services_dir in services_dirs: @@ -50,7 +45,6 @@ async def _rename_services_to_system(connection: AsyncConnection) -> None: and_( qa(Directory.name) == "System", qa(Directory.parent_id) == services_dir.parent_id, - qa(Directory.object_class) == "organizationalUnit", ), ), ) @@ -126,12 +120,7 @@ async def _rename_system_to_services(connection: AsyncConnection) -> None: return system_dirs = await session.scalars( - select(Directory).where( - and_( - qa(Directory.name) == "System", - qa(Directory.object_class) == "organizationalUnit", - ), - ), + select(Directory).where(qa(Directory.name) == "System"), ) for system_dir in system_dirs: From 3db3fd80c2810dbda060936d503c9a572911a224 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 13 Jan 2026 14:47:02 +0300 Subject: [PATCH 04/11] Enhance entrypoint script: add config fix function and periodic updates for Kerberos configuration files --- .kerberos/entrypoint.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.kerberos/entrypoint.sh b/.kerberos/entrypoint.sh index a50bd3ec7..59e8ce502 100755 --- a/.kerberos/entrypoint.sh +++ b/.kerberos/entrypoint.sh @@ -4,8 +4,31 @@ 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 + +fix_configs() { + for file in /etc/krb5.conf /etc/kdc.conf /etc/krb5.d/stash.keyfile; do + if [ -f "$file" ]; then + sed -i 's/ou=services/ou=System/g' "$file" 2>/dev/null || true + sed -i 's/ou=users/cn=users/g' "$file" 2>/dev/null || true + fi + done +} + +fix_configs + +( + for i in {1..10}; do + sleep 3 + fix_configs + done +) & + + cd /server + + + uvicorn --factory config_server:create_app \ --host 0.0.0.0 \ --ssl-keyfile=/certs/krbkey.pem \ From a384c879551a12076dae0a83b4a961c554781f2b Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 13 Jan 2026 16:20:39 +0300 Subject: [PATCH 05/11] Update Kerberos configuration handling: add shared volume for KDC files, enhance config update script, and simplify test case --- .kerberos/entrypoint.sh | 22 -------- app/extra/scripts/update_krb5_config.py | 74 +++++++++++++++++++------ docker-compose.yml | 1 + tests/test_shedule.py | 2 - 4 files changed, 59 insertions(+), 40 deletions(-) diff --git a/.kerberos/entrypoint.sh b/.kerberos/entrypoint.sh index 59e8ce502..f063bbea4 100755 --- a/.kerberos/entrypoint.sh +++ b/.kerberos/entrypoint.sh @@ -5,30 +5,8 @@ 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 -fix_configs() { - for file in /etc/krb5.conf /etc/kdc.conf /etc/krb5.d/stash.keyfile; do - if [ -f "$file" ]; then - sed -i 's/ou=services/ou=System/g' "$file" 2>/dev/null || true - sed -i 's/ou=users/cn=users/g' "$file" 2>/dev/null || true - fi - done -} - -fix_configs - -( - for i in {1..10}; do - sleep 3 - fix_configs - done -) & - - cd /server - - - uvicorn --factory config_server:create_app \ --host 0.0.0.0 \ --ssl-keyfile=/certs/krbkey.pem \ diff --git a/app/extra/scripts/update_krb5_config.py b/app/extra/scripts/update_krb5_config.py index d2c5fb774..49dbb5c05 100644 --- a/app/extra/scripts/update_krb5_config.py +++ b/app/extra/scripts/update_krb5_config.py @@ -1,41 +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_services_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") + """Update Kerberos configuration files via direct write to shared volume. + + 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). + + :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 base_dn_list = await get_base_directories(session) - base_dn = base_dn_list[0].path_dn - domain: str = base_dn_list[0].name + if not base_dn_list: + logger.error("No base directories found") + return - krbadmin = "cn=krbadmin,cn=users," + base_dn + 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_services_container_dn(base_dn) - krb5_template = settings.TEMPLATES.get_template("krb5.conf") - kdc_template = settings.TEMPLATES.get_template("kdc.conf") - - kdc_config = await kdc_template.render_async(domain=domain) - - 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, @@ -43,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/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/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, ) From 4109966a79f5842e047155f8a4512530f8ac9dee Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 13 Jan 2026 16:21:57 +0300 Subject: [PATCH 06/11] Remove outdated comment regarding services container name in KerberosService class --- app/ldap_protocol/kerberos/service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/ldap_protocol/kerberos/service.py b/app/ldap_protocol/kerberos/service.py index 16c4b2176..fcbda44c7 100644 --- a/app/ldap_protocol/kerberos/service.py +++ b/app/ldap_protocol/kerberos/service.py @@ -298,7 +298,6 @@ 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}" - # Use new System container name (AD-compatible, renamed from services) services_container = get_services_container_dn(base_dn) return KDCContext( base_dn=base_dn, From 17f3d280e81bd1c9facfbf2d710a99a919227538 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Tue, 13 Jan 2026 17:40:57 +0300 Subject: [PATCH 07/11] Update test case in kadmin.py to reflect new expected hash value for krb_doc --- tests/test_api/test_main/test_kadmin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api/test_main/test_kadmin.py b/tests/test_api/test_main/test_kadmin.py index cb42e697a..aabfcf14f 100644 --- a/tests/test_api/test_main/test_kadmin.py +++ b/tests/test_api/test_main/test_kadmin.py @@ -157,7 +157,7 @@ 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 == { From 4fe21549b8b140297070cb3bf90d22d50f7b73d7 Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Fri, 16 Jan 2026 12:14:17 +0300 Subject: [PATCH 08/11] Refactor alembic migration to rename 'services' to 'System' and update related paths and attributes recursively. Implement downgrade functionality to revert changes. --- .../a1b2c3d4e5f6_rename_services_to_system.py | 133 +++++++++--------- 1 file changed, 70 insertions(+), 63 deletions(-) diff --git a/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py index f093e1b9d..70d43d0dd 100644 --- a/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py +++ b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py @@ -25,6 +25,44 @@ def upgrade(container: AsyncContainer) -> None: # noqa: ARG001 """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: session = AsyncSession(bind=connection) await session.begin() @@ -35,31 +73,32 @@ async def _rename_services_to_system(connection: AsyncConnection) -> None: await session.commit() return - services_dirs = await session.scalars( + service_dirs = await session.scalars( select(Directory).where(qa(Directory.name) == "services"), ) - for services_dir in services_dirs: + for service_dir in service_dirs: system_exists = await session.scalar( - select(exists(Directory)).where( + select(exists(Directory)) + .where( and_( qa(Directory.name) == "System", - qa(Directory.parent_id) == services_dir.parent_id, + qa(Directory.parent_id) == service_dir.parent_id, ), ), - ) + ) # fmt: skip if system_exists: continue - services_dir.name = "System" - services_dir.path = [ + service_dir.name = "System" + service_dir.path = [ "ou=System" if p == "ou=services" else p - for p in services_dir.path + for p in service_dir.path ] await session.flush() - await _update_descendants(session, services_dir.id) + await _update_descendants(session, service_dir.id) await _update_attributes(session, "ou=services", "ou=System") await session.commit() @@ -68,33 +107,42 @@ async def _rename_services_to_system(connection: AsyncConnection) -> None: await session.rollback() raise - async def _update_descendants( + op.run_async(_rename_services_to_system) + + +def downgrade(container: AsyncContainer) -> None: # noqa: ARG001 + """Downgrade: Rename 'System' container back to 'services'.""" + + async def _update_descendants_downgrade( session: AsyncSession, parent_id: int, ) -> None: """Recursively update paths of all descendants.""" - children = await session.scalars( - select(Directory).where(qa(Directory.parent_id) == parent_id), - ) - - for child in children: - child.path = [ - "ou=System" if p == "ou=services" else p for p in child.path + 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(session, child.id) + await _update_descendants_downgrade(session, child_dir.id) - async def _update_attributes( + async def _update_attributes_downgrade( session: AsyncSession, old_value: str, new_value: str, ) -> None: - """Update attribute values containing old DN.""" + """Update attribute values during downgrade.""" result = await session.execute( - select(Attribute).where( + select(Attribute) + .where( Attribute.value.ilike(f"%{old_value}%"), # type: ignore ), - ) + ) # fmt: skip attributes = result.scalars().all() for attr in attributes: @@ -103,12 +151,6 @@ async def _update_attributes( await session.flush() - op.run_async(_rename_services_to_system) - - -def downgrade(container: AsyncContainer) -> None: # noqa: ARG001 - """Downgrade: Rename 'System' container back to 'services'.""" - async def _rename_system_to_services(connection: AsyncConnection) -> None: session = AsyncSession(bind=connection) await session.begin() @@ -144,39 +186,4 @@ async def _rename_system_to_services(connection: AsyncConnection) -> None: await session.rollback() raise - async def _update_descendants_downgrade( - session: AsyncSession, - parent_id: int, - ) -> None: - """Recursively update paths of all descendants.""" - children = await session.scalars( - select(Directory).where(qa(Directory.parent_id) == parent_id), - ) - - for child in children: - child.path = [ - "ou=services" if p == "ou=System" else p for p in child.path - ] - await session.flush() - await _update_descendants_downgrade(session, child.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 - ), - ) - 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() - op.run_async(_rename_system_to_services) From 60ae9bd84b53f3dd28a4d058a6c359111f796a0f Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Fri, 16 Jan 2026 12:22:37 +0300 Subject: [PATCH 09/11] Refactor alembic migration to improve session handling and streamline the renaming process of 'services' to 'System', including updates to paths and attributes. Enhance downgrade functionality for reverting changes. --- .../a1b2c3d4e5f6_rename_services_to_system.py | 130 ++++++++---------- 1 file changed, 61 insertions(+), 69 deletions(-) diff --git a/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py index 70d43d0dd..34087424f 100644 --- a/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py +++ b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py @@ -7,7 +7,7 @@ """ from alembic import op -from dishka import AsyncContainer +from dishka import AsyncContainer, Scope from sqlalchemy import and_, exists, select from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession @@ -22,7 +22,7 @@ depends_on: None | list[str] = None -def upgrade(container: AsyncContainer) -> None: # noqa: ARG001 +def upgrade(container: AsyncContainer) -> None: """Upgrade: Rename 'services' container to 'System'.""" async def _update_descendants( @@ -63,54 +63,50 @@ async def _update_attributes( await session.flush() - async def _rename_services_to_system(connection: AsyncConnection) -> None: - session = AsyncSession(bind=connection) + async def _rename_services_to_system(connection: AsyncConnection) -> None: # noqa: ARG001 + async with container(scope=Scope.REQUEST) as cnt: + session = await cnt.get(AsyncSession) await session.begin() - try: - 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, - ), + 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 + ), + ) # fmt: skip - service_dir.name = "System" - service_dir.path = [ - "ou=System" if p == "ou=services" else p - for p in service_dir.path - ] + if system_exists: + continue - await session.flush() - await _update_descendants(session, service_dir.id) + service_dir.name = "System" + service_dir.path = [ + "ou=System" if p == "ou=services" else p + for p in service_dir.path + ] - await _update_attributes(session, "ou=services", "ou=System") - await session.commit() + await session.flush() + await _update_descendants(session, service_dir.id) - except Exception: - await session.rollback() - raise + await _update_attributes(session, "ou=services", "ou=System") + await session.commit() op.run_async(_rename_services_to_system) -def downgrade(container: AsyncContainer) -> None: # noqa: ARG001 +def downgrade(container: AsyncContainer) -> None: """Downgrade: Rename 'System' container back to 'services'.""" async def _update_descendants_downgrade( @@ -151,39 +147,35 @@ async def _update_attributes_downgrade( await session.flush() - async def _rename_system_to_services(connection: AsyncConnection) -> None: - session = AsyncSession(bind=connection) + async def _rename_system_to_services(connection: AsyncConnection) -> None: # noqa ARG001 + async with container(scope=Scope.REQUEST) as cnt: + session = await cnt.get(AsyncSession) await session.begin() - try: - 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", - ) + base_directories = await get_base_directories(session) + if not base_directories: await session.commit() + return - except Exception: - await session.rollback() - raise + 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) From 25a871554e8dd5cc108925974d6c0c5d6bc557dc Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Fri, 16 Jan 2026 12:34:39 +0300 Subject: [PATCH 10/11] Update down_revision in alembic migration to reflect the latest migration sequence for renaming 'services' to 'System'. --- app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py | 2 +- interface | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py index 34087424f..b5d7a4b26 100644 --- a/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py +++ b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py @@ -17,7 +17,7 @@ # revision identifiers, used by Alembic. revision: None | str = "a1b2c3d4e5f6" -down_revision: None | str = "6c858cc05da7" +down_revision: None | str = "71e642808369" branch_labels: None | list[str] = None depends_on: None | list[str] = None diff --git a/interface b/interface index 97bbc08dd..f31962020 160000 --- a/interface +++ b/interface @@ -1 +1 @@ -Subproject commit 97bbc08dda7584f579f756d8b09abe60db67b47b +Subproject commit f31962020a6689e6a4c61fb3349db5b5c7895f92 From 8cd59a7cee18a778d53b7d2af38d4c4c2d64c5cf Mon Sep 17 00:00:00 2001 From: "m.shvets" Date: Fri, 16 Jan 2026 18:11:38 +0300 Subject: [PATCH 11/11] Refactor: rename get_services_container_dn to get_system_container_dn across the codebase to align with updated terminology and improve clarity in Kerberos-related functionality. --- .../versions/a1b2c3d4e5f6_rename_services_to_system.py | 2 -- app/extra/scripts/update_krb5_config.py | 4 ++-- app/ldap_protocol/kerberos/service.py | 6 +++--- app/ldap_protocol/kerberos/utils.py | 2 +- app/ldap_protocol/roles/role_use_case.py | 4 ++-- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py index b5d7a4b26..eeba2a64c 100644 --- a/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py +++ b/app/alembic/versions/a1b2c3d4e5f6_rename_services_to_system.py @@ -66,7 +66,6 @@ async def _update_attributes( async def _rename_services_to_system(connection: AsyncConnection) -> None: # noqa: ARG001 async with container(scope=Scope.REQUEST) as cnt: session = await cnt.get(AsyncSession) - await session.begin() base_directories = await get_base_directories(session) if not base_directories: @@ -150,7 +149,6 @@ async def _update_attributes_downgrade( async def _rename_system_to_services(connection: AsyncConnection) -> None: # noqa ARG001 async with container(scope=Scope.REQUEST) as cnt: session = await cnt.get(AsyncSession) - await session.begin() base_directories = await get_base_directories(session) if not base_directories: diff --git a/app/extra/scripts/update_krb5_config.py b/app/extra/scripts/update_krb5_config.py index 49dbb5c05..b0ecda0f6 100644 --- a/app/extra/scripts/update_krb5_config.py +++ b/app/extra/scripts/update_krb5_config.py @@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from config import Settings -from ldap_protocol.kerberos.utils import get_services_container_dn +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") @@ -59,7 +59,7 @@ async def update_krb5_config( 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_services_container_dn(base_dn) + services_container = get_system_container_dn(base_dn) krb5_config = await settings.TEMPLATES.get_template( "krb5.conf", diff --git a/app/ldap_protocol/kerberos/service.py b/app/ldap_protocol/kerberos/service.py index fcbda44c7..f6a0aae05 100644 --- a/app/ldap_protocol/kerberos/service.py +++ b/app/ldap_protocol/kerberos/service.py @@ -47,7 +47,7 @@ from .utils import ( KerberosState, get_krb_server_state, - get_services_container_dn, + get_system_container_dn, set_state, ) @@ -146,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 = get_services_container_dn(base_dn) + services_container = get_system_container_dn(base_dn) krbgroup = f"cn=krbadmin,cn=groups,{base_dn}" return KerberosAdminDnGroup( krbadmin_dn=krbadmin, @@ -298,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 = get_services_container_dn(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 065a32782..c6278ed95 100644 --- a/app/ldap_protocol/kerberos/utils.py +++ b/app/ldap_protocol/kerberos/utils.py @@ -133,6 +133,6 @@ async def unlock_principal(name: str, session: AsyncSession) -> None: ) -def get_services_container_dn(base_dn: str) -> str: +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 c2f0e44bf..d9c2921e0 100644 --- a/app/ldap_protocol/roles/role_use_case.py +++ b/app/ldap_protocol/roles/role_use_case.py @@ -8,7 +8,7 @@ from entities import AccessControlEntry, AceType, Directory, Role from enums import AuthorizationRules, RoleConstants, RoleScope -from ldap_protocol.kerberos.utils import get_services_container_dn +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, @@ -212,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=get_services_container_dn(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)