From a5f7fba9d0bd60509220be416f193153e847dc21 Mon Sep 17 00:00:00 2001 From: Naksen Date: Fri, 6 Feb 2026 15:23:36 +0300 Subject: [PATCH 1/7] add: is_move_to_new_superior --- app/ldap_protocol/ldap_requests/modify_dn.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/app/ldap_protocol/ldap_requests/modify_dn.py b/app/ldap_protocol/ldap_requests/modify_dn.py index cdf03ab7b..d8e15060c 100644 --- a/app/ldap_protocol/ldap_requests/modify_dn.py +++ b/app/ldap_protocol/ldap_requests/modify_dn.py @@ -6,6 +6,7 @@ from typing import AsyncGenerator, ClassVar +from loguru import logger from sqlalchemy import delete, func, select, text, update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import joinedload, selectinload @@ -85,6 +86,14 @@ def from_data(cls, data: list[ASN1Row]) -> "ModifyDNRequest": new_superior=None if len(data) < 4 else data[3].value, ) + def is_move_to_new_superior(self, directory: Directory) -> bool: + """Check if the request is a move operation.""" + return bool( + self.new_superior + and directory.parent + and self.new_superior != directory.parent.path_dn, + ) + async def handle( self, ctx: LDAPModifyDNRequestContext, @@ -166,11 +175,7 @@ async def handle( ) return - if ( - self.new_superior - and directory.parent - and self.new_superior != directory.parent.path_dn - ): + if self.is_move_to_new_superior(directory) and self.new_superior: new_sup_query = select(Directory).filter( get_filter_from_path(self.new_superior), ) @@ -220,6 +225,10 @@ async def handle( ) return + logger.critical(self.entry) + logger.critical(self.newrdn) + logger.critical(self.new_superior) + async with ctx.session.begin_nested(): if self.deleteoldrdn: await ctx.session.execute( From b025dbb30339f5cdd50fdd92045e0ad66dc01a16 Mon Sep 17 00:00:00 2001 From: Naksen Date: Fri, 6 Feb 2026 17:28:29 +0300 Subject: [PATCH 2/7] fix: enhance access check for Modify DN request --- app/ldap_protocol/ldap_requests/modify_dn.py | 28 +++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/app/ldap_protocol/ldap_requests/modify_dn.py b/app/ldap_protocol/ldap_requests/modify_dn.py index d8e15060c..0a1130de1 100644 --- a/app/ldap_protocol/ldap_requests/modify_dn.py +++ b/app/ldap_protocol/ldap_requests/modify_dn.py @@ -19,7 +19,12 @@ INVALID_ACCESS_RESPONSE, ModifyDNResponse, ) -from ldap_protocol.objects import ProtocolRequests +from ldap_protocol.objects import ( + Changes, + Operation, + PartialAttribute, + ProtocolRequests, +) from ldap_protocol.utils.queries import get_filter_from_path, validate_entry from repo.pg.tables import ( ace_directory_memberships_table, @@ -131,8 +136,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) @@ -224,6 +229,23 @@ async def handle( result_code=LDAPCodes.ENTRY_ALREADY_EXISTS, ) return + else: + can_modify = ctx.access_manager.check_modify_access( + changes=[ + Changes( + operation=Operation.DELETE, + modification=PartialAttribute(type="cn", vals="value"), + ), + ], + aces=directory.access_control_entries, + entity_type_id=directory.entity_type_id, + ) + logger.critical(f"Can modify: {can_modify}") + if not can_modify: + yield ModifyDNResponse( + result_code=LDAPCodes.INSUFFICIENT_ACCESS_RIGHTS, + ) + return logger.critical(self.entry) logger.critical(self.newrdn) From b686e690b4b45d78f75082198c9f6a83b10be7c8 Mon Sep 17 00:00:00 2001 From: Naksen Date: Tue, 10 Feb 2026 13:06:40 +0300 Subject: [PATCH 3/7] fix: add loading of entity_type in DeleteRequest --- app/ldap_protocol/ldap_requests/delete.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/ldap_protocol/ldap_requests/delete.py b/app/ldap_protocol/ldap_requests/delete.py index 401a5b98f..46f0d3cf5 100644 --- a/app/ldap_protocol/ldap_requests/delete.py +++ b/app/ldap_protocol/ldap_requests/delete.py @@ -74,6 +74,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), ), From e5a50cb6f39980c4c39d903062bb44d5d075818c Mon Sep 17 00:00:00 2001 From: Naksen Date: Tue, 10 Feb 2026 13:07:22 +0300 Subject: [PATCH 4/7] fix: improve access checks and refactor Modify DN handling --- app/ldap_protocol/ldap_requests/modify_dn.py | 164 +++++++++++++------ 1 file changed, 118 insertions(+), 46 deletions(-) diff --git a/app/ldap_protocol/ldap_requests/modify_dn.py b/app/ldap_protocol/ldap_requests/modify_dn.py index 0a1130de1..637b7dc54 100644 --- a/app/ldap_protocol/ldap_requests/modify_dn.py +++ b/app/ldap_protocol/ldap_requests/modify_dn.py @@ -6,9 +6,9 @@ from typing import AsyncGenerator, ClassVar -from loguru import logger from sqlalchemy import delete, func, select, text, update from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload, selectinload from entities import AccessControlEntry, Attribute, Directory @@ -25,6 +25,7 @@ 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, @@ -91,15 +92,81 @@ def from_data(cls, data: list[ASN1Row]) -> "ModifyDNRequest": new_superior=None if len(data) < 4 else data[3].value, ) - def is_move_to_new_superior(self, directory: Directory) -> bool: - """Check if the request is a move operation.""" + 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, ) - async def handle( + def _can_rename( + self, + access_manager: AccessManager, + directory: Directory, + name: str, + ) -> bool: + return access_manager.check_modify_access( + changes=[ + Changes( + operation=Operation.REPLACE, + modification=PartialAttribute(type="name", vals=[name]), + ), + ], + aces=directory.access_control_entries, + entity_type_id=directory.entity_type_id, + ) + + async def _delete_old_inherited_aces( + self, + session: AsyncSession, + directory_id: int, + old_depth: int, + ) -> None: + old_inherited_aces_query = ( + select(qa(AccessControlEntry.id)) + .options(selectinload(qa(AccessControlEntry.directories))) + .where( + qa(AccessControlEntry.directories).any( + qa(Directory.id) == directory_id, + ), + 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_query, + ), + ), + ) + + async def _update_explicit_aces( + self, + session: AsyncSession, + directory: Directory, + old_depth: int, + ) -> None: + 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, + ) + ) + for ace in await session.scalars(explicit_aces_query): + ace.directories.append(directory) + ace.path = directory.path_dn + ace.depth = directory.depth + + + async def handle( # noqa: C901 self, ctx: LDAPModifyDNRequestContext, ) -> AsyncGenerator[ModifyDNResponse, None]: @@ -156,8 +223,20 @@ async def handle( ) return - old_name = directory.name new_dn, new_name = self.newrdn.split("=") + is_move_to_new_superior = self._is_move_to_new_superior(directory) + + if not is_move_to_new_superior and not self._can_rename( + ctx.access_manager, + directory, + new_name, + ): + yield ModifyDNResponse( + result_code=LDAPCodes.INSUFFICIENT_ACCESS_RIGHTS, + ) + return + + old_name = directory.name directory.name = new_name old_path = directory.path @@ -180,9 +259,28 @@ async def handle( ) return - if self.is_move_to_new_superior(directory) and self.new_superior: + 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, @@ -213,11 +311,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_id=directory.id, + old_depth=old_depth, + ) await ctx.role_use_case.inherit_parent_aces( parent_directory=directory.parent, directory=directory, @@ -229,27 +327,6 @@ async def handle( result_code=LDAPCodes.ENTRY_ALREADY_EXISTS, ) return - else: - can_modify = ctx.access_manager.check_modify_access( - changes=[ - Changes( - operation=Operation.DELETE, - modification=PartialAttribute(type="cn", vals="value"), - ), - ], - aces=directory.access_control_entries, - entity_type_id=directory.entity_type_id, - ) - logger.critical(f"Can modify: {can_modify}") - if not can_modify: - yield ModifyDNResponse( - result_code=LDAPCodes.INSUFFICIENT_ACCESS_RIGHTS, - ) - return - - logger.critical(self.entry) - logger.critical(self.newrdn) - logger.critical(self.new_superior) async with ctx.session.begin_nested(): if self.deleteoldrdn: @@ -296,20 +373,15 @@ 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, - ) + new_depth = old_depth + if is_move_to_new_superior: + new_depth = directory.depth + + await self._update_explicit_aces( + ctx.session, + directory, + new_depth, ) - 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() From d5df47f88a61d6d7edaf65383610bca1e4a13d7b Mon Sep 17 00:00:00 2001 From: Naksen Date: Tue, 10 Feb 2026 13:07:29 +0300 Subject: [PATCH 5/7] fix: update access control entry loading to use joined loading for entity type --- app/ldap_protocol/roles/access_manager.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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), From d2041914616cd8aff379efdcf0070aabf8d1bc3c Mon Sep 17 00:00:00 2001 From: Naksen Date: Tue, 10 Feb 2026 14:57:31 +0300 Subject: [PATCH 6/7] fix: refactor _delete_old_inherited_aces to use Directory object instead of directory_id --- app/ldap_protocol/ldap_requests/modify_dn.py | 25 +++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/app/ldap_protocol/ldap_requests/modify_dn.py b/app/ldap_protocol/ldap_requests/modify_dn.py index 637b7dc54..5543e6a3f 100644 --- a/app/ldap_protocol/ldap_requests/modify_dn.py +++ b/app/ldap_protocol/ldap_requests/modify_dn.py @@ -119,23 +119,21 @@ def _can_rename( async def _delete_old_inherited_aces( self, session: AsyncSession, - directory_id: int, + directory: Directory, old_depth: int, ) -> None: old_inherited_aces_query = ( select(qa(AccessControlEntry.id)) .options(selectinload(qa(AccessControlEntry.directories))) .where( - qa(AccessControlEntry.directories).any( - qa(Directory.id) == directory_id, - ), + qa(AccessControlEntry.directories).contains(directory), qa(AccessControlEntry.depth) != old_depth, ) ) await session.execute( delete(ace_directory_memberships_table) .filter_by( - directory_id=directory_id, + directory_id=directory.id, ) .where( ace_directory_memberships_table.c.access_control_entry_id.in_( @@ -154,18 +152,14 @@ async def _update_explicit_aces( select(AccessControlEntry) .options(selectinload(qa(AccessControlEntry.directories))) .where( - qa(AccessControlEntry.directories).any( - qa(Directory.id) == directory.id, - ), + qa(AccessControlEntry.directories).contains(directory), qa(AccessControlEntry.depth) == old_depth, ) ) for ace in await session.scalars(explicit_aces_query): - ace.directories.append(directory) ace.path = directory.path_dn ace.depth = directory.depth - async def handle( # noqa: C901 self, ctx: LDAPModifyDNRequestContext, @@ -261,7 +255,8 @@ async def handle( # noqa: C901 if is_move_to_new_superior: delete_aces = [ - ace for ace in directory.access_control_entries + ace + for ace in directory.access_control_entries if ( ace.ace_type == AceType.DELETE and ace.attribute_type is None @@ -313,7 +308,7 @@ async def handle( # noqa: C901 await ctx.session.flush() await self._delete_old_inherited_aces( ctx.session, - directory_id=directory.id, + directory=directory, old_depth=old_depth, ) await ctx.role_use_case.inherit_parent_aces( @@ -373,14 +368,10 @@ async def handle( # noqa: C901 ) await ctx.session.flush() - new_depth = old_depth - if is_move_to_new_superior: - new_depth = directory.depth - await self._update_explicit_aces( ctx.session, directory, - new_depth, + old_depth, ) await ctx.session.flush() From e98ca059ba79b262897a04e58f0e0e5c41506309 Mon Sep 17 00:00:00 2001 From: Naksen Date: Tue, 10 Feb 2026 14:57:37 +0300 Subject: [PATCH 7/7] test: add tests for Modify DN operations with access control checks --- tests/test_ldap/test_util/test_modify.py | 256 ++++++++++++++++++++++- 1 file changed, 255 insertions(+), 1 deletion(-) diff --git a/tests/test_ldap/test_util/test_modify.py b/tests/test_ldap/test_util/test_modify.py index eda4c1fe0..e3f4939e5 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 @@ -997,3 +998,256 @@ 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 + + name_attr = await attribute_type_dao.get("name") + assert name_attr + + async def try_modify() -> int: + with tempfile.NamedTemporaryFile("w") as file: + file.write( + ( + f"dn: {dn}\n" + "changetype: modrdn\n" + "newrdn: cn=user2\n" + "deleteoldrdn: 1\n" + ), + ) + file.seek(0) + proc = await asyncio.create_subprocess_exec( + "ldapmodify", + "-vvv", + "-H", + f"ldap://{settings.HOST}:{settings.PORT}", + "-D", + "user_non_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.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=name_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=name_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() + + assert await try_modify() == 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) + + # 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=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 + + name_attr = await attribute_type_dao.get("name") + assert name_attr + + new_parent_dn = "cn=Groups,dc=md,dc=test" + + async def try_modify() -> int: + with tempfile.NamedTemporaryFile("w") as file: + file.write( + ( + f"dn: {dn}\n" + "changetype: modrdn\n" + "newrdn: cn=user2\n" + "deleteoldrdn: 1\n" + f"newsuperior: {new_parent_dn}\n" + ), + ) + file.seek(0) + proc = await asyncio.create_subprocess_exec( + "ldapmodify", + "-vvv", + "-H", + f"ldap://{settings.HOST}:{settings.PORT}", + "-D", + "user_non_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.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() + + 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([create_ace, delete_ace]) + + aces_before = await access_control_entry_dao.get_all() + + assert await try_modify() == 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"