From d6ef014b163724e7fdf9daaeb730058714f07bde Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Mon, 1 Dec 2025 17:05:11 +0100 Subject: [PATCH 01/19] add check in patch user endpoint --- fractal_server/app/routes/auth/users.py | 48 +++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/fractal_server/app/routes/auth/users.py b/fractal_server/app/routes/auth/users.py index b2c6d2b8a1..a423b543b9 100644 --- a/fractal_server/app/routes/auth/users.py +++ b/fractal_server/app/routes/auth/users.py @@ -2,6 +2,8 @@ Definition of `/auth/users/` routes """ +from pathlib import Path + from fastapi import APIRouter from fastapi import Depends from fastapi import HTTPException @@ -16,7 +18,10 @@ from fractal_server.app.models import LinkUserGroup from fractal_server.app.models import UserGroup from fractal_server.app.models import UserOAuth +from fractal_server.app.models.linkuserproject import LinkUserProjectV2 from fractal_server.app.models.v2 import Profile +from fractal_server.app.models.v2.dataset import DatasetV2 +from fractal_server.app.models.v2.project import ProjectV2 from fractal_server.app.routes.auth._aux_auth import _user_or_404 from fractal_server.app.schemas.user import UserRead from fractal_server.app.schemas.user import UserUpdate @@ -74,6 +79,49 @@ async def patch_user( detail=f"Profile {user_update.profile_id} not found.", ) + if user_update.project_dirs is not None: + less_privileged = { + path: [ + new_path + for new_path in user_update.project_dirs + if Path(new_path).is_relative_to(path) + ] + for path in user_to_patch.project_dirs + if not any( + Path(path).is_relative_to(new_path) + for new_path in user_update.project_dirs + ) + } + # E.g. + # user_to_patch.project_dirs = ["/a", "/b", "/c/d", "/e/f"] + # user_update.project_dirs = ["/a", "/c", "/e/f/g1", "/e/f/g2"] + # less_privileged == {"/b": [], "/e/f": ["/e/f/g1", "/e/f/g2"]} + if less_privileged: + res = await db.execute( + select(DatasetV2.zarr_dir) + .join(ProjectV2, ProjectV2.id == DatasetV2.project_id) + .join( + LinkUserProjectV2, + LinkUserProjectV2.project_id == ProjectV2.id, + ) + .where(LinkUserProjectV2.user_id == user_id) + ) + if any( + ( + Path(zarr_dir).is_relative_to(key) + and all( + not Path(zarr_dir).is_relative_to(value) + for value in value_list + ) + ) + for zarr_dir in res.scalars().all() + for key, value_list in less_privileged.items() + ): + raise HTTPException( + status_code=422, + detail="Project dir in use in some Dataset.", + ) + # Modify user attributes try: user = await user_manager.update( From 54ec55a23f070736290ca91bb6551f1bf8b9da4d Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Mon, 1 Dec 2025 17:05:23 +0100 Subject: [PATCH 02/19] test --- tests/no_version/test_api_users.py | 89 ++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/no_version/test_api_users.py b/tests/no_version/test_api_users.py index cd12643d80..a586c29ed1 100644 --- a/tests/no_version/test_api_users.py +++ b/tests/no_version/test_api_users.py @@ -1,6 +1,12 @@ from devtools import debug +from sqlmodel import select +from fractal_server.app.models.linkuserproject import LinkUserProjectV2 from fractal_server.app.models.security import OAuthAccount +from fractal_server.app.models.security import UserOAuth +from fractal_server.app.models.v2.dataset import DatasetV2 +from fractal_server.app.models.v2.project import ProjectV2 +from fractal_server.app.schemas.v2.sharing import ProjectPermissions from tests.fixtures_server import PROJECT_DIR_PLACEHOLDER PREFIX = "/auth" @@ -292,6 +298,89 @@ async def test_edit_users_as_superuser( assert len(users) == 0 +async def test_edit_user_project_dirs( + registered_superuser_client, local_resource_profile_db, db +): + resource, profile = local_resource_profile_db + + user = UserOAuth( + email="user@example.org", + hashed_password="12345", + project_dirs=["/a", "/b", "/c/d", "/e/f"], + is_active=True, + is_superuser=False, + is_verified=True, + profile_id=profile.id, + slurm_accounts=[], + ) + db.add(user) + await db.commit() + await db.refresh(user) + + project = ProjectV2(name="Project", resource_id=resource.id) + db.add(project) + await db.commit() + await db.refresh(project) + db.add( + LinkUserProjectV2( + project_id=project.id, + user_id=user.id, + is_owner=True, + is_verified=True, + permissions=ProjectPermissions.EXECUTE, + ) + ) + await db.commit() + + db.add_all( + [ + DatasetV2( + name=f"Dataset {i}", + project_id=project.id, + zarr_dir=f"{project_dir}/x", + images=[], + ) + for i, project_dir in enumerate(user.project_dirs) + ] + ) + await db.commit() + + update = dict(project_dirs=["/a", "/c", "/e/f/x", "/e/f/y"]) + res = await registered_superuser_client.patch( + f"{PREFIX}/users/{user.id}/", + json=update, + ) + assert res.status_code == 422 + assert res.json()["detail"] == "Project dir in use in some Dataset." + + update = dict(project_dirs=["/a", "/b", "/c", "/e/f/x", "/e/f/y"]) + res = await registered_superuser_client.patch( + f"{PREFIX}/users/{user.id}/", + json=update, + ) + assert res.status_code == 200 + + update = dict(project_dirs=["/a", "/c", "/e/f/x"]) + res = await registered_superuser_client.patch( + f"{PREFIX}/users/{user.id}/", + json=update, + ) + assert res.status_code == 422 + # delete the dataset + res = await db.execute( + select(DatasetV2).where(DatasetV2.zarr_dir == "/b/x") + ) + dataset = res.scalars().one() + await db.delete(dataset) + await db.commit() + # now it works + res = await registered_superuser_client.patch( + f"{PREFIX}/users/{user.id}/", + json=update, + ) + assert res.status_code == 200 + + async def test_add_superuser(registered_superuser_client): # Create non-superuser user res = await registered_superuser_client.post( From 08adcdcaf88c251ab2a50c60ea7efa4d0ddd70c9 Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Mon, 1 Dec 2025 17:13:40 +0100 Subject: [PATCH 03/19] restrict query result --- fractal_server/app/routes/auth/users.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/fractal_server/app/routes/auth/users.py b/fractal_server/app/routes/auth/users.py index a423b543b9..f9e20a6bea 100644 --- a/fractal_server/app/routes/auth/users.py +++ b/fractal_server/app/routes/auth/users.py @@ -12,6 +12,7 @@ from fastapi_users.router.common import ErrorCode from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import func +from sqlmodel import or_ from sqlmodel import select from fractal_server.app.db import get_async_db @@ -105,7 +106,16 @@ async def patch_user( LinkUserProjectV2.project_id == ProjectV2.id, ) .where(LinkUserProjectV2.user_id == user_id) + .where( + or_( + *[ + DatasetV2.zarr_dir.startswith(path) + for path in less_privileged.keys() + ] + ) + ) ) + if any( ( Path(zarr_dir).is_relative_to(key) From c4f60141b14001ffb9a6caee3412176559c90b32 Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Mon, 1 Dec 2025 17:27:25 +0100 Subject: [PATCH 04/19] use fastapi status --- fractal_server/app/routes/auth/users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fractal_server/app/routes/auth/users.py b/fractal_server/app/routes/auth/users.py index f9e20a6bea..681fb9ff82 100644 --- a/fractal_server/app/routes/auth/users.py +++ b/fractal_server/app/routes/auth/users.py @@ -128,7 +128,7 @@ async def patch_user( for key, value_list in less_privileged.items() ): raise HTTPException( - status_code=422, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Project dir in use in some Dataset.", ) From e7348c4caaa932d9560b59ea2d3d83c7cfdca44f Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Mon, 1 Dec 2025 17:34:50 +0100 Subject: [PATCH 05/19] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdafb08760..4c7c97b8be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The main contents of this release are the introduction of the project sharing an * Reduce API logging level for some endpoints (\#3010). * Modify `GET /auth/current-user/allowed-viewer-paths/` logic, with `include_shared_projects` query parameter (\#3031). * Add validator for paths to forbid parent-directory references (\#3031). + * Add check to `PATCH /auth/users/{user_id}/` when patching `project_dirs` (\#3043). * App: * Add `SlowResponseMiddleware` middleware (\#3035). * Settings: From 74dd042f2063e047294910eeb6655bb1937af20e Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 11:38:55 +0100 Subject: [PATCH 06/19] aux function --- fractal_server/app/routes/auth/_aux_auth.py | 63 +++++++++++++++++++++ fractal_server/app/routes/auth/users.py | 63 +++------------------ 2 files changed, 70 insertions(+), 56 deletions(-) diff --git a/fractal_server/app/routes/auth/_aux_auth.py b/fractal_server/app/routes/auth/_aux_auth.py index 3d03a6420d..87abee08b5 100644 --- a/fractal_server/app/routes/auth/_aux_auth.py +++ b/fractal_server/app/routes/auth/_aux_auth.py @@ -1,12 +1,18 @@ +from pathlib import Path + from fastapi import HTTPException from fastapi import status from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import asc +from sqlmodel import or_ from sqlmodel import select from fractal_server.app.models.linkusergroup import LinkUserGroup +from fractal_server.app.models.linkuserproject import LinkUserProjectV2 from fractal_server.app.models.security import UserGroup from fractal_server.app.models.security import UserOAuth +from fractal_server.app.models.v2.dataset import DatasetV2 +from fractal_server.app.models.v2.project import ProjectV2 from fractal_server.app.schemas.user import UserRead from fractal_server.app.schemas.user_group import UserGroupRead from fractal_server.config import get_settings @@ -178,3 +184,60 @@ async def _verify_user_belongs_to_group( f"to UserGroup {user_group_id}" ), ) + + +async def _check_project_dirs_update( + old_project_dirs: list[str], + new_project_dirs: list[str], + user_id: int, + db: AsyncSession, +) -> None: + less_privileged = { + path: [ + new_path + for new_path in new_project_dirs + if Path(new_path).is_relative_to(path) + ] + for path in old_project_dirs + if not any( + Path(path).is_relative_to(new_path) for new_path in new_project_dirs + ) + } + # E.g. + # user_to_patch.project_dirs = ["/a", "/b", "/c/d", "/e/f"] + # user_update.project_dirs = ["/a", "/c", "/e/f/g1", "/e/f/g2"] + # less_privileged == {"/b": [], "/e/f": ["/e/f/g1", "/e/f/g2"]} + if less_privileged: + res = await db.execute( + select(DatasetV2.zarr_dir) + .join(ProjectV2, ProjectV2.id == DatasetV2.project_id) + .join( + LinkUserProjectV2, + LinkUserProjectV2.project_id == ProjectV2.id, + ) + .where(LinkUserProjectV2.user_id == user_id) + .where( + or_( + *[ + DatasetV2.zarr_dir.startswith(path) + for path in less_privileged.keys() + ] + ) + ) + ) + + if any( + ( + Path(zarr_dir).is_relative_to(key) + and all( + not Path(zarr_dir).is_relative_to(value) + for value in value_list + ) + ) + for zarr_dir in res.scalars().all() + for key, value_list in less_privileged.items() + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Project dir in use in some Dataset.", + ) diff --git a/fractal_server/app/routes/auth/users.py b/fractal_server/app/routes/auth/users.py index 681fb9ff82..dfcae76927 100644 --- a/fractal_server/app/routes/auth/users.py +++ b/fractal_server/app/routes/auth/users.py @@ -2,8 +2,6 @@ Definition of `/auth/users/` routes """ -from pathlib import Path - from fastapi import APIRouter from fastapi import Depends from fastapi import HTTPException @@ -12,17 +10,13 @@ from fastapi_users.router.common import ErrorCode from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import func -from sqlmodel import or_ from sqlmodel import select from fractal_server.app.db import get_async_db from fractal_server.app.models import LinkUserGroup from fractal_server.app.models import UserGroup from fractal_server.app.models import UserOAuth -from fractal_server.app.models.linkuserproject import LinkUserProjectV2 from fractal_server.app.models.v2 import Profile -from fractal_server.app.models.v2.dataset import DatasetV2 -from fractal_server.app.models.v2.project import ProjectV2 from fractal_server.app.routes.auth._aux_auth import _user_or_404 from fractal_server.app.schemas.user import UserRead from fractal_server.app.schemas.user import UserUpdate @@ -34,6 +28,7 @@ from fractal_server.syringe import Inject from . import current_superuser_act +from ._aux_auth import _check_project_dirs_update from ._aux_auth import _get_default_usergroup_id_or_none from ._aux_auth import _get_single_user_with_groups @@ -81,56 +76,12 @@ async def patch_user( ) if user_update.project_dirs is not None: - less_privileged = { - path: [ - new_path - for new_path in user_update.project_dirs - if Path(new_path).is_relative_to(path) - ] - for path in user_to_patch.project_dirs - if not any( - Path(path).is_relative_to(new_path) - for new_path in user_update.project_dirs - ) - } - # E.g. - # user_to_patch.project_dirs = ["/a", "/b", "/c/d", "/e/f"] - # user_update.project_dirs = ["/a", "/c", "/e/f/g1", "/e/f/g2"] - # less_privileged == {"/b": [], "/e/f": ["/e/f/g1", "/e/f/g2"]} - if less_privileged: - res = await db.execute( - select(DatasetV2.zarr_dir) - .join(ProjectV2, ProjectV2.id == DatasetV2.project_id) - .join( - LinkUserProjectV2, - LinkUserProjectV2.project_id == ProjectV2.id, - ) - .where(LinkUserProjectV2.user_id == user_id) - .where( - or_( - *[ - DatasetV2.zarr_dir.startswith(path) - for path in less_privileged.keys() - ] - ) - ) - ) - - if any( - ( - Path(zarr_dir).is_relative_to(key) - and all( - not Path(zarr_dir).is_relative_to(value) - for value in value_list - ) - ) - for zarr_dir in res.scalars().all() - for key, value_list in less_privileged.items() - ): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail="Project dir in use in some Dataset.", - ) + await _check_project_dirs_update( + old_project_dirs=user_to_patch.project_dirs, + new_project_dirs=user_update.project_dirs, + user_id=user_id, + db=db, + ) # Modify user attributes try: From 88b6294c6761d014bdb40d21e6ae2b1654df40e1 Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 11:43:39 +0100 Subject: [PATCH 07/19] rename --- fractal_server/app/routes/auth/_aux_auth.py | 25 +++++++++++---------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/fractal_server/app/routes/auth/_aux_auth.py b/fractal_server/app/routes/auth/_aux_auth.py index 87abee08b5..567354fe66 100644 --- a/fractal_server/app/routes/auth/_aux_auth.py +++ b/fractal_server/app/routes/auth/_aux_auth.py @@ -193,19 +193,20 @@ async def _check_project_dirs_update( db: AsyncSession, ) -> None: less_privileged = { - path: [ - new_path - for new_path in new_project_dirs - if Path(new_path).is_relative_to(path) + old_project_dir: [ + new_project_dir + for new_project_dir in new_project_dirs + if Path(new_project_dir).is_relative_to(old_project_dir) ] - for path in old_project_dirs + for old_project_dir in old_project_dirs if not any( - Path(path).is_relative_to(new_path) for new_path in new_project_dirs + Path(old_project_dir).is_relative_to(new_project_dir) + for new_project_dir in new_project_dirs ) } # E.g. - # user_to_patch.project_dirs = ["/a", "/b", "/c/d", "/e/f"] - # user_update.project_dirs = ["/a", "/c", "/e/f/g1", "/e/f/g2"] + # old_project_dirs = ["/a", "/b", "/c/d", "/e/f"] + # new_project_dirs = ["/a", "/c", "/e/f/g1", "/e/f/g2"] # less_privileged == {"/b": [], "/e/f": ["/e/f/g1", "/e/f/g2"]} if less_privileged: res = await db.execute( @@ -228,14 +229,14 @@ async def _check_project_dirs_update( if any( ( - Path(zarr_dir).is_relative_to(key) + Path(zarr_dir).is_relative_to(old_project_dir) and all( - not Path(zarr_dir).is_relative_to(value) - for value in value_list + not Path(zarr_dir).is_relative_to(new_project_dir) + for new_project_dir in new_project_dirs ) ) for zarr_dir in res.scalars().all() - for key, value_list in less_privileged.items() + for old_project_dir, new_project_dirs in less_privileged.items() ): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, From 42f0451685d8f19d94f5dfb8c75760a697d4b800 Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 12:46:01 +0100 Subject: [PATCH 08/19] docstring and comment --- fractal_server/app/routes/auth/_aux_auth.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/fractal_server/app/routes/auth/_aux_auth.py b/fractal_server/app/routes/auth/_aux_auth.py index 567354fe66..3ec4ded4e0 100644 --- a/fractal_server/app/routes/auth/_aux_auth.py +++ b/fractal_server/app/routes/auth/_aux_auth.py @@ -192,6 +192,15 @@ async def _check_project_dirs_update( user_id: int, db: AsyncSession, ) -> None: + """ + Raises 422 if by replacing user's `project_dirs` with new ones we are + removing the access to a `zarr_dir` used by some dataset. + """ + # Create a dictionary describing which old project directories have *lost* + # privilege, e.g.: + # old_project_dirs = ["/a", "/b", "/c/d", "/e/f"] + # new_project_dirs = ["/a", "/c", "/e/f/g1", "/e/f/g2"] + # less_privileged == {"/b": [], "/e/f": ["/e/f/g1", "/e/f/g2"]} less_privileged = { old_project_dir: [ new_project_dir @@ -204,11 +213,9 @@ async def _check_project_dirs_update( for new_project_dir in new_project_dirs ) } - # E.g. - # old_project_dirs = ["/a", "/b", "/c/d", "/e/f"] - # new_project_dirs = ["/a", "/c", "/e/f/g1", "/e/f/g2"] - # less_privileged == {"/b": [], "/e/f": ["/e/f/g1", "/e/f/g2"]} if less_privileged: + # Query all `zarr_dir` linked to the user such that `zarr_dir` starts + # with one of the keys of `less_privileged`. res = await db.execute( select(DatasetV2.zarr_dir) .join(ProjectV2, ProjectV2.id == DatasetV2.project_id) @@ -227,6 +234,9 @@ async def _check_project_dirs_update( ) ) + # Raise 422 if one of the query results is relative to a key `K` of + # `less_privileged` but its not relative to one of the paths in + # `less_privileged[K]`. if any( ( Path(zarr_dir).is_relative_to(old_project_dir) From f816a5afa18a9060b5dfccad43c54ef9c41eb766 Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 12:46:56 +0100 Subject: [PATCH 09/19] all kwargs in aux function --- fractal_server/app/routes/auth/_aux_auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fractal_server/app/routes/auth/_aux_auth.py b/fractal_server/app/routes/auth/_aux_auth.py index 3ec4ded4e0..549a8b4865 100644 --- a/fractal_server/app/routes/auth/_aux_auth.py +++ b/fractal_server/app/routes/auth/_aux_auth.py @@ -187,6 +187,7 @@ async def _verify_user_belongs_to_group( async def _check_project_dirs_update( + *, old_project_dirs: list[str], new_project_dirs: list[str], user_id: int, From 980dd1ea26c2b615638c135fa54e429b4348d7b9 Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 12:48:06 +0100 Subject: [PATCH 10/19] rename --- fractal_server/app/routes/auth/_aux_auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fractal_server/app/routes/auth/_aux_auth.py b/fractal_server/app/routes/auth/_aux_auth.py index 549a8b4865..a7897583af 100644 --- a/fractal_server/app/routes/auth/_aux_auth.py +++ b/fractal_server/app/routes/auth/_aux_auth.py @@ -228,8 +228,8 @@ async def _check_project_dirs_update( .where( or_( *[ - DatasetV2.zarr_dir.startswith(path) - for path in less_privileged.keys() + DatasetV2.zarr_dir.startswith(old_project_dir) + for old_project_dir in less_privileged.keys() ] ) ) From 329ef37e9bcae60eabe8faea8c9762bdad5c4c35 Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 14:45:23 +0100 Subject: [PATCH 11/19] make `less_privileged` a list --- fractal_server/app/routes/auth/_aux_auth.py | 35 +++++++++------------ 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/fractal_server/app/routes/auth/_aux_auth.py b/fractal_server/app/routes/auth/_aux_auth.py index a7897583af..2a8efc801f 100644 --- a/fractal_server/app/routes/auth/_aux_auth.py +++ b/fractal_server/app/routes/auth/_aux_auth.py @@ -197,26 +197,22 @@ async def _check_project_dirs_update( Raises 422 if by replacing user's `project_dirs` with new ones we are removing the access to a `zarr_dir` used by some dataset. """ - # Create a dictionary describing which old project directories have *lost* - # privilege, e.g.: + # Create a list of all the old project dirs that will lose privileges. + # E.g.: # old_project_dirs = ["/a", "/b", "/c/d", "/e/f"] # new_project_dirs = ["/a", "/c", "/e/f/g1", "/e/f/g2"] - # less_privileged == {"/b": [], "/e/f": ["/e/f/g1", "/e/f/g2"]} - less_privileged = { - old_project_dir: [ - new_project_dir - for new_project_dir in new_project_dirs - if Path(new_project_dir).is_relative_to(old_project_dir) - ] + # less_privileged == ["/b", "/e/f"] + less_privileged = [ + old_project_dir for old_project_dir in old_project_dirs - if not any( - Path(old_project_dir).is_relative_to(new_project_dir) + if all( + not Path(old_project_dir).is_relative_to(new_project_dir) for new_project_dir in new_project_dirs ) - } + ] if less_privileged: - # Query all `zarr_dir` linked to the user such that `zarr_dir` starts - # with one of the keys of `less_privileged`. + # Query all the `zarr_dir`s linked to the user such that `zarr_dir` + # starts with one of the project dirs in `less_privileged`. res = await db.execute( select(DatasetV2.zarr_dir) .join(ProjectV2, ProjectV2.id == DatasetV2.project_id) @@ -229,15 +225,14 @@ async def _check_project_dirs_update( or_( *[ DatasetV2.zarr_dir.startswith(old_project_dir) - for old_project_dir in less_privileged.keys() + for old_project_dir in less_privileged ] ) ) ) - - # Raise 422 if one of the query results is relative to a key `K` of - # `less_privileged` but its not relative to one of the paths in - # `less_privileged[K]`. + # Raise 422 if one of the query results is relative to a path in + # `less_privileged`, but its not relative to any path in + # `new_project_dirs`. if any( ( Path(zarr_dir).is_relative_to(old_project_dir) @@ -247,7 +242,7 @@ async def _check_project_dirs_update( ) ) for zarr_dir in res.scalars().all() - for old_project_dir, new_project_dirs in less_privileged.items() + for old_project_dir in less_privileged ): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, From 832538ba99bd2c864b491bed3a5aabad133fab88 Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 15:32:41 +0100 Subject: [PATCH 12/19] unit test --- tests/no_version/test_unit_auth.py | 140 +++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/tests/no_version/test_unit_auth.py b/tests/no_version/test_unit_auth.py index 1800b584bd..e59d14510a 100644 --- a/tests/no_version/test_unit_auth.py +++ b/tests/no_version/test_unit_auth.py @@ -3,7 +3,11 @@ from fastapi import HTTPException from sqlmodel import select +from fractal_server.app.models import DatasetV2 +from fractal_server.app.models import LinkUserProjectV2 +from fractal_server.app.models import ProjectV2 from fractal_server.app.models import UserOAuth +from fractal_server.app.routes.auth._aux_auth import _check_project_dirs_update from fractal_server.app.routes.auth._aux_auth import ( _get_single_user_with_groups, ) @@ -14,6 +18,7 @@ from fractal_server.app.routes.auth._aux_auth import ( _verify_user_belongs_to_group, ) +from fractal_server.app.schemas.v2 import ProjectPermissions from fractal_server.app.security import _create_first_user @@ -55,3 +60,138 @@ async def test_verify_user_belongs_to_group(db): with pytest.raises(HTTPException) as exc_info: await _verify_user_belongs_to_group(user_id=1, user_group_id=42, db=db) debug(exc_info.value) + + +async def test_check_project_dirs_update(local_resource_profile_db, db): + # Setup + + resource, _ = local_resource_profile_db + # Add User + user = UserOAuth( + email="user@example.org", + hashed_password="12345", + project_dirs=[ + "/example", + "/foo/bar", # dataset1 + "/test", + "/test/data", # dataset2 + "/test-1", # dataset3 + ], + ) + db.add(user) + await db.commit() + await db.refresh(user) + # Add Project + project = ProjectV2(name="Project", resource_id=resource.id) + db.add(project) + await db.commit() + await db.refresh(project) + db.add( + LinkUserProjectV2( + project_id=project.id, + user_id=user.id, + is_owner=True, + is_verified=True, + permissions=ProjectPermissions.EXECUTE, + ) + ) + await db.commit() + # Add Datasets + dataset1 = DatasetV2( + name="Dataset 3", + project_id=project.id, + zarr_dir="/foo/bar/dataset/zarr", + ) + dataset2 = DatasetV2( + name="Dataset 1", + project_id=project.id, + zarr_dir="/test/data/dataset/zarr", + ) + dataset3 = DatasetV2( + name="Dataset 2", + project_id=project.id, + zarr_dir="/test-1/dataset/zarr", + ) + db.add_all([dataset1, dataset2, dataset3]) + await db.commit() + await db.refresh(dataset1) + await db.refresh(dataset2) + await db.refresh(dataset3) + + kwargs = dict( + old_project_dirs=user.project_dirs, + user_id=user.id, + db=db, + ) + + # Test + + # Removing "/example" is OK + await _check_project_dirs_update( + new_project_dirs=[ + "/foo/bar", # dataset1 + "/test", + "/test/data", # dataset2 + "/test-1", # dataset3 + ], + **kwargs, + ) + # Removing "/test" is OK + await _check_project_dirs_update( + new_project_dirs=[ + "/exmple", + "/foo/bar", # dataset1 + "/test/data", # dataset2 + "/test-1", # dataset3 + ], + **kwargs, + ) + # Removing both "/example" and "/test" is OK + await _check_project_dirs_update( + new_project_dirs=[ + "/foo/bar", # dataset1 + "/test/data", # dataset2 + "/test-1", # dataset3 + ], + **kwargs, + ) + # Removing "/foo/bar" can be done after removing dataset1 + with pytest.raises(HTTPException): + await _check_project_dirs_update( + new_project_dirs=[ + "/test/data", # dataset2 + "/test-1", # dataset3 + ], + **kwargs, + ) + await db.delete(dataset1) + await _check_project_dirs_update( + new_project_dirs=[ + "/test/data", # dataset2 + "/test-1", # dataset3 + ], + **kwargs, + ) + # Changing "/test/data" into something more specific is OK + await _check_project_dirs_update( + new_project_dirs=[ + "/test/data/dataset/zarr/", # dataset2 + "/test-1", # dataset3 + ], + **kwargs, + ) + # Removing "/test/data" is OK, as long as we have "/test" + await _check_project_dirs_update( + new_project_dirs=[ + "/test", # dataset2 + "/test-1", # dataset3 + ], + **kwargs, + ) + with pytest.raises(HTTPException): + await _check_project_dirs_update( + new_project_dirs=[ + "/test-1", # dataset3 + ], + **kwargs, + ) From 028081ea54744f49a971bd9fb3c6a426e2416c8c Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 15:33:09 +0100 Subject: [PATCH 13/19] improve query --- fractal_server/app/routes/auth/_aux_auth.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/fractal_server/app/routes/auth/_aux_auth.py b/fractal_server/app/routes/auth/_aux_auth.py index 2a8efc801f..f074074368 100644 --- a/fractal_server/app/routes/auth/_aux_auth.py +++ b/fractal_server/app/routes/auth/_aux_auth.py @@ -3,7 +3,9 @@ from fastapi import HTTPException from fastapi import status from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import and_ from sqlmodel import asc +from sqlmodel import not_ from sqlmodel import or_ from sqlmodel import select @@ -229,6 +231,14 @@ async def _check_project_dirs_update( ] ) ) + .where( + and_( + *[ + not_(DatasetV2.zarr_dir.startswith(new_project_dir)) + for new_project_dir in new_project_dirs + ] + ) + ) ) # Raise 422 if one of the query results is relative to a path in # `less_privileged`, but its not relative to any path in From a41bccf279aa6d76e05a6ed1c987791a6a1e2801 Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 15:51:41 +0100 Subject: [PATCH 14/19] improve if condition --- fractal_server/app/routes/auth/_aux_auth.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/fractal_server/app/routes/auth/_aux_auth.py b/fractal_server/app/routes/auth/_aux_auth.py index f074074368..95e69e9bbd 100644 --- a/fractal_server/app/routes/auth/_aux_auth.py +++ b/fractal_server/app/routes/auth/_aux_auth.py @@ -245,14 +245,16 @@ async def _check_project_dirs_update( # `new_project_dirs`. if any( ( - Path(zarr_dir).is_relative_to(old_project_dir) - and all( - not Path(zarr_dir).is_relative_to(new_project_dir) + any( + Path(zarr_dir).is_relative_to(old_project_dir) + for old_project_dir in less_privileged + ) + and not any( + Path(zarr_dir).is_relative_to(new_project_dir) for new_project_dir in new_project_dirs ) ) for zarr_dir in res.scalars().all() - for old_project_dir in less_privileged ): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, From 41d5dfa11ba9528b7a34b16a37731e5e37674fd8 Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 15:53:23 +0100 Subject: [PATCH 15/19] fix typo --- tests/no_version/test_unit_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/no_version/test_unit_auth.py b/tests/no_version/test_unit_auth.py index e59d14510a..98609d3fc9 100644 --- a/tests/no_version/test_unit_auth.py +++ b/tests/no_version/test_unit_auth.py @@ -139,7 +139,7 @@ async def test_check_project_dirs_update(local_resource_profile_db, db): # Removing "/test" is OK await _check_project_dirs_update( new_project_dirs=[ - "/exmple", + "/example", "/foo/bar", # dataset1 "/test/data", # dataset2 "/test-1", # dataset3 From f70e8c4cc6ecd492e1870ecbfce4b35dd05e5601 Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 15:57:14 +0100 Subject: [PATCH 16/19] make test more unit --- tests/no_version/test_unit_auth.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/no_version/test_unit_auth.py b/tests/no_version/test_unit_auth.py index 98609d3fc9..161b52f39d 100644 --- a/tests/no_version/test_unit_auth.py +++ b/tests/no_version/test_unit_auth.py @@ -159,6 +159,8 @@ async def test_check_project_dirs_update(local_resource_profile_db, db): with pytest.raises(HTTPException): await _check_project_dirs_update( new_project_dirs=[ + "/example", + "/test", "/test/data", # dataset2 "/test-1", # dataset3 ], @@ -167,6 +169,8 @@ async def test_check_project_dirs_update(local_resource_profile_db, db): await db.delete(dataset1) await _check_project_dirs_update( new_project_dirs=[ + "/example", + "/test", "/test/data", # dataset2 "/test-1", # dataset3 ], @@ -175,6 +179,9 @@ async def test_check_project_dirs_update(local_resource_profile_db, db): # Changing "/test/data" into something more specific is OK await _check_project_dirs_update( new_project_dirs=[ + "/example", + "/foo/bar", + "/test", "/test/data/dataset/zarr/", # dataset2 "/test-1", # dataset3 ], @@ -183,6 +190,8 @@ async def test_check_project_dirs_update(local_resource_profile_db, db): # Removing "/test/data" is OK, as long as we have "/test" await _check_project_dirs_update( new_project_dirs=[ + "/example", + "/foo/bar", "/test", # dataset2 "/test-1", # dataset3 ], @@ -191,6 +200,8 @@ async def test_check_project_dirs_update(local_resource_profile_db, db): with pytest.raises(HTTPException): await _check_project_dirs_update( new_project_dirs=[ + "/example", + "/foo/bar", "/test-1", # dataset3 ], **kwargs, From 6404b1f29616476faa3e9e67d7e7c25f6f7c532d Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 15:59:39 +0100 Subject: [PATCH 17/19] all not -> not any --- fractal_server/app/routes/auth/_aux_auth.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/fractal_server/app/routes/auth/_aux_auth.py b/fractal_server/app/routes/auth/_aux_auth.py index 95e69e9bbd..fccb28e2bd 100644 --- a/fractal_server/app/routes/auth/_aux_auth.py +++ b/fractal_server/app/routes/auth/_aux_auth.py @@ -203,18 +203,18 @@ async def _check_project_dirs_update( # E.g.: # old_project_dirs = ["/a", "/b", "/c/d", "/e/f"] # new_project_dirs = ["/a", "/c", "/e/f/g1", "/e/f/g2"] - # less_privileged == ["/b", "/e/f"] - less_privileged = [ + # removed_project_dirs == ["/b", "/e/f"] + removed_project_dirs = [ old_project_dir for old_project_dir in old_project_dirs - if all( - not Path(old_project_dir).is_relative_to(new_project_dir) + if not any( + Path(old_project_dir).is_relative_to(new_project_dir) for new_project_dir in new_project_dirs ) ] - if less_privileged: + if removed_project_dirs: # Query all the `zarr_dir`s linked to the user such that `zarr_dir` - # starts with one of the project dirs in `less_privileged`. + # starts with one of the project dirs in `removed_project_dirs`. res = await db.execute( select(DatasetV2.zarr_dir) .join(ProjectV2, ProjectV2.id == DatasetV2.project_id) @@ -227,7 +227,7 @@ async def _check_project_dirs_update( or_( *[ DatasetV2.zarr_dir.startswith(old_project_dir) - for old_project_dir in less_privileged + for old_project_dir in removed_project_dirs ] ) ) @@ -241,13 +241,13 @@ async def _check_project_dirs_update( ) ) # Raise 422 if one of the query results is relative to a path in - # `less_privileged`, but its not relative to any path in + # `removed_project_dirs`, but its not relative to any path in # `new_project_dirs`. if any( ( any( Path(zarr_dir).is_relative_to(old_project_dir) - for old_project_dir in less_privileged + for old_project_dir in removed_project_dirs ) and not any( Path(zarr_dir).is_relative_to(new_project_dir) From a120cf9cc207b556ae3b1820e0e21bca94c49469 Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 16:11:38 +0100 Subject: [PATCH 18/19] simplify test api --- tests/no_version/test_api_users.py | 60 +++++++++++------------------- 1 file changed, 21 insertions(+), 39 deletions(-) diff --git a/tests/no_version/test_api_users.py b/tests/no_version/test_api_users.py index a586c29ed1..ed47b2ee4f 100644 --- a/tests/no_version/test_api_users.py +++ b/tests/no_version/test_api_users.py @@ -1,5 +1,4 @@ from devtools import debug -from sqlmodel import select from fractal_server.app.models.linkuserproject import LinkUserProjectV2 from fractal_server.app.models.security import OAuthAccount @@ -306,21 +305,13 @@ async def test_edit_user_project_dirs( user = UserOAuth( email="user@example.org", hashed_password="12345", - project_dirs=["/a", "/b", "/c/d", "/e/f"], - is_active=True, - is_superuser=False, - is_verified=True, - profile_id=profile.id, - slurm_accounts=[], + project_dirs=["/a", "/b", "/b/c"], ) db.add(user) - await db.commit() - await db.refresh(user) - + await db.flush() project = ProjectV2(name="Project", resource_id=resource.id) db.add(project) - await db.commit() - await db.refresh(project) + await db.flush() db.add( LinkUserProjectV2( project_id=project.id, @@ -330,50 +321,41 @@ async def test_edit_user_project_dirs( permissions=ProjectPermissions.EXECUTE, ) ) - await db.commit() - - db.add_all( - [ - DatasetV2( - name=f"Dataset {i}", - project_id=project.id, - zarr_dir=f"{project_dir}/x", - images=[], - ) - for i, project_dir in enumerate(user.project_dirs) - ] + await db.flush() + dataset = DatasetV2( + name="Dataset", + project_id=project.id, + zarr_dir="/b/c/data/zarr", + images=[], ) + db.add(dataset) await db.commit() + await db.refresh(user) + await db.refresh(dataset) - update = dict(project_dirs=["/a", "/c", "/e/f/x", "/e/f/y"]) - res = await registered_superuser_client.patch( - f"{PREFIX}/users/{user.id}/", - json=update, - ) - assert res.status_code == 422 - assert res.json()["detail"] == "Project dir in use in some Dataset." + # Update 1 - update = dict(project_dirs=["/a", "/b", "/c", "/e/f/x", "/e/f/y"]) + update = dict(project_dirs=["/a", "/b/c/data/"]) res = await registered_superuser_client.patch( f"{PREFIX}/users/{user.id}/", json=update, ) assert res.status_code == 200 + assert res.json()["project_dirs"] == update["project_dirs"] - update = dict(project_dirs=["/a", "/c", "/e/f/x"]) + # Update 2 + + update = dict(project_dirs=["/a"]) res = await registered_superuser_client.patch( f"{PREFIX}/users/{user.id}/", json=update, ) assert res.status_code == 422 - # delete the dataset - res = await db.execute( - select(DatasetV2).where(DatasetV2.zarr_dir == "/b/x") - ) - dataset = res.scalars().one() + assert res.json()["detail"] == "Project dir in use in some Dataset." + await db.delete(dataset) await db.commit() - # now it works + res = await registered_superuser_client.patch( f"{PREFIX}/users/{user.id}/", json=update, From f646c334b2f693a38bad70771c1f68aad1265bac Mon Sep 17 00:00:00 2001 From: Yuri Chiucconi Date: Tue, 2 Dec 2025 16:28:22 +0100 Subject: [PATCH 19/19] error message --- fractal_server/app/routes/auth/_aux_auth.py | 7 ++++++- tests/no_version/test_api_users.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/fractal_server/app/routes/auth/_aux_auth.py b/fractal_server/app/routes/auth/_aux_auth.py index fccb28e2bd..1fc1702073 100644 --- a/fractal_server/app/routes/auth/_aux_auth.py +++ b/fractal_server/app/routes/auth/_aux_auth.py @@ -258,5 +258,10 @@ async def _check_project_dirs_update( ): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail="Project dir in use in some Dataset.", + detail=( + "You tried updating the user project_dirs, removing " + f"{removed_project_dirs}. This operation is not possible, " + "because it would make the user loose access to some of " + "their dataset zarr directories." + ), ) diff --git a/tests/no_version/test_api_users.py b/tests/no_version/test_api_users.py index ed47b2ee4f..88a7dc4bf2 100644 --- a/tests/no_version/test_api_users.py +++ b/tests/no_version/test_api_users.py @@ -351,7 +351,7 @@ async def test_edit_user_project_dirs( json=update, ) assert res.status_code == 422 - assert res.json()["detail"] == "Project dir in use in some Dataset." + assert "loose access" in res.json()["detail"] await db.delete(dataset) await db.commit()