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), ), diff --git a/app/ldap_protocol/ldap_requests/modify_dn.py b/app/ldap_protocol/ldap_requests/modify_dn.py index cdf03ab7b..5543e6a3f 100644 --- a/app/ldap_protocol/ldap_requests/modify_dn.py +++ b/app/ldap_protocol/ldap_requests/modify_dn.py @@ -8,6 +8,7 @@ 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 @@ -18,7 +19,13 @@ INVALID_ACCESS_RESPONSE, ModifyDNResponse, ) -from ldap_protocol.objects import ProtocolRequests +from ldap_protocol.objects import ( + Changes, + Operation, + PartialAttribute, + ProtocolRequests, +) +from ldap_protocol.roles.access_manager import AccessManager from ldap_protocol.utils.queries import get_filter_from_path, validate_entry from repo.pg.tables import ( ace_directory_memberships_table, @@ -85,7 +92,75 @@ def from_data(cls, data: list[ASN1Row]) -> "ModifyDNRequest": new_superior=None if len(data) < 4 else data[3].value, ) - async def handle( + def _is_move_to_new_superior(self, directory: Directory) -> bool: + return bool( + self.new_superior + and directory.parent + and self.new_superior != directory.parent.path_dn, + ) + + def _can_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: Directory, + old_depth: int, + ) -> None: + old_inherited_aces_query = ( + select(qa(AccessControlEntry.id)) + .options(selectinload(qa(AccessControlEntry.directories))) + .where( + qa(AccessControlEntry.directories).contains(directory), + qa(AccessControlEntry.depth) != old_depth, + ) + ) + await session.execute( + delete(ace_directory_memberships_table) + .filter_by( + directory_id=directory.id, + ) + .where( + ace_directory_memberships_table.c.access_control_entry_id.in_( + old_inherited_aces_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).contains(directory), + qa(AccessControlEntry.depth) == old_depth, + ) + ) + for ace in await session.scalars(explicit_aces_query): + ace.path = directory.path_dn + ace.depth = directory.depth + + async def handle( # noqa: C901 self, ctx: LDAPModifyDNRequestContext, ) -> AsyncGenerator[ModifyDNResponse, None]: @@ -122,8 +197,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) @@ -142,8 +217,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 @@ -166,13 +253,29 @@ async def handle( ) return - if ( - self.new_superior - and directory.parent - and self.new_superior != directory.parent.path_dn - ): + 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, @@ -203,11 +306,11 @@ async def handle( try: await ctx.session.flush() - await ctx.session.execute( - delete(ace_directory_memberships_table) - .filter_by(directory_id=directory.id), - ) # fmt: skip - + await self._delete_old_inherited_aces( + ctx.session, + directory=directory, + old_depth=old_depth, + ) await ctx.role_use_case.inherit_parent_aces( parent_directory=directory.parent, directory=directory, @@ -265,20 +368,11 @@ async def handle( ) await ctx.session.flush() - explicit_aces_query = ( - select(AccessControlEntry) - .options(selectinload(qa(AccessControlEntry.directories))) - .where( - qa(AccessControlEntry.directories).any( - qa(Directory.id) == directory.id, - ), - qa(AccessControlEntry.depth) == old_depth, - ) + await self._update_explicit_aces( + ctx.session, + directory, + old_depth, ) - for ace in await ctx.session.scalars(explicit_aces_query): - ace.directories.append(directory) - ace.path = directory.path_dn - ace.depth = directory.depth await ctx.session.flush() diff --git a/app/ldap_protocol/roles/access_manager.py b/app/ldap_protocol/roles/access_manager.py index e651e5d62..0edc74190 100644 --- a/app/ldap_protocol/roles/access_manager.py +++ b/app/ldap_protocol/roles/access_manager.py @@ -304,12 +304,19 @@ def mutate_query_with_ace_load( null attribute_type_id :return: mutated query with access control entries loaded """ - selectin_loader = selectinload( + base_loader = selectinload( qa(Directory.access_control_entries), ) + + loader_options = [ + base_loader.joinedload(qa(AccessControlEntry.entity_type)), + ] + if load_attribute_type: - selectin_loader = selectin_loader.joinedload( - qa(AccessControlEntry.attribute_type), + loader_options.append( + base_loader.joinedload( + qa(AccessControlEntry.attribute_type), + ), ) criteria_conditions = [ @@ -331,7 +338,7 @@ def mutate_query_with_ace_load( ) return query.options( - selectin_loader, + *loader_options, with_loader_criteria( AccessControlEntry, and_(*criteria_conditions), diff --git a/tests/test_ldap/test_util/test_modify.py b/tests/test_ldap/test_util/test_modify.py index eda4c1fe0..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"