From 3f36a08045ad073bc6340d6b34c622971da22a66 Mon Sep 17 00:00:00 2001 From: Haydn Li Date: Thu, 5 Jun 2025 11:38:39 -0400 Subject: [PATCH 01/14] feat: Add support for public projects (#2670) --- src/dstack/_internal/core/models/projects.py | 1 + src/dstack/_internal/server/models.py | 1 + .../_internal/server/routers/projects.py | 1 + .../_internal/server/schemas/projects.py | 3 +- .../_internal/server/services/projects.py | 31 ++- src/dstack/_internal/server/testing/common.py | 2 + src/dstack/api/server/_projects.py | 6 +- .../_internal/server/routers/test_projects.py | 217 +++++++++++++++++- 8 files changed, 252 insertions(+), 10 deletions(-) diff --git a/src/dstack/_internal/core/models/projects.py b/src/dstack/_internal/core/models/projects.py index da252230ec..c6ab64c658 100644 --- a/src/dstack/_internal/core/models/projects.py +++ b/src/dstack/_internal/core/models/projects.py @@ -25,3 +25,4 @@ class Project(CoreModel): created_at: Optional[datetime] = None backends: List[BackendInfo] members: List[Member] + is_public: bool = False diff --git a/src/dstack/_internal/server/models.py b/src/dstack/_internal/server/models.py index 161e242bcb..cb39b70786 100644 --- a/src/dstack/_internal/server/models.py +++ b/src/dstack/_internal/server/models.py @@ -202,6 +202,7 @@ class ProjectModel(BaseModel): name: Mapped[str] = mapped_column(String(50), unique=True) created_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime) deleted: Mapped[bool] = mapped_column(Boolean, default=False) + is_public: Mapped[bool] = mapped_column(Boolean, default=False) owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) owner: Mapped[UserModel] = relationship(lazy="joined") diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index 0bedbc316f..a0765103e5 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -49,6 +49,7 @@ async def create_project( session=session, user=user, project_name=body.project_name, + is_public=body.is_public, ) diff --git a/src/dstack/_internal/server/schemas/projects.py b/src/dstack/_internal/server/schemas/projects.py index 18f85f15c4..5065af950e 100644 --- a/src/dstack/_internal/server/schemas/projects.py +++ b/src/dstack/_internal/server/schemas/projects.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, Optional from pydantic import Field @@ -8,6 +8,7 @@ class CreateProjectRequest(CoreModel): project_name: str + is_public: Optional[bool] = False class DeleteProjectsRequest(CoreModel): diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index b241e266ab..b8a04f35c7 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -56,7 +56,30 @@ async def list_user_projects( if user.global_role == GlobalRole.ADMIN: projects = await list_project_models(session=session) else: - projects = await list_user_project_models(session=session, user=user) + # Single query to get both: + # 1. Projects where user is a member (public or private) + # 2. Public projects where user is NOT a member + res = await session.execute( + select(ProjectModel) + .where( + ProjectModel.deleted == False, + # Either user is a member, OR project is public and user is not a member + ( + # User is a member (regardless of public/private) + ProjectModel.id.in_( + select(MemberModel.project_id).where(MemberModel.user_id == user.id) + ) + ) | ( + # OR project is public and user is not a member + (ProjectModel.is_public == True) & + ProjectModel.id.notin_( + select(MemberModel.project_id).where(MemberModel.user_id == user.id) + ) + ) + ) + ) + projects = list(res.scalars().all()) + projects = sorted(projects, key=lambda p: p.created_at) return [ project_model_to_project(p, include_backends=False, include_members=False) @@ -86,6 +109,7 @@ async def create_project( session: AsyncSession, user: UserModel, project_name: str, + is_public: bool = False, ) -> Project: user_permissions = users.get_user_permissions(user) if not user_permissions.can_create_projects: @@ -100,6 +124,7 @@ async def create_project( session=session, owner=user, project_name=project_name, + is_public=is_public, ) await add_project_member( session=session, @@ -323,7 +348,7 @@ async def get_project_model_by_id_or_error( async def create_project_model( - session: AsyncSession, owner: UserModel, project_name: str + session: AsyncSession, owner: UserModel, project_name: str, is_public: bool = False ) -> ProjectModel: private_bytes, public_bytes = await run_async( generate_rsa_key_pair_bytes, f"{project_name}@dstack" @@ -334,6 +359,7 @@ async def create_project_model( name=project_name, ssh_private_key=private_bytes.decode(), ssh_public_key=public_bytes.decode(), + is_public=is_public, ) session.add(project) await session.commit() @@ -407,6 +433,7 @@ def project_model_to_project( created_at=project_model.created_at.replace(tzinfo=timezone.utc), backends=backends, members=members, + is_public=project_model.is_public, ) diff --git a/src/dstack/_internal/server/testing/common.py b/src/dstack/_internal/server/testing/common.py index 52526394a2..15cf5ffb31 100644 --- a/src/dstack/_internal/server/testing/common.py +++ b/src/dstack/_internal/server/testing/common.py @@ -140,6 +140,7 @@ async def create_project( created_at: datetime = datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), ssh_private_key: str = "", ssh_public_key: str = "", + is_public: bool = False, ) -> ProjectModel: if owner is None: owner = await create_user(session=session, name="test_owner") @@ -149,6 +150,7 @@ async def create_project( created_at=created_at, ssh_private_key=ssh_private_key, ssh_public_key=ssh_public_key, + is_public=is_public, ) session.add(project) await session.commit() diff --git a/src/dstack/api/server/_projects.py b/src/dstack/api/server/_projects.py index 19793a1bc0..b74cff1986 100644 --- a/src/dstack/api/server/_projects.py +++ b/src/dstack/api/server/_projects.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Optional from pydantic import parse_obj_as @@ -17,8 +17,8 @@ def list(self) -> List[Project]: resp = self._request("/api/projects/list") return parse_obj_as(List[Project.__response__], resp.json()) - def create(self, project_name: str) -> Project: - body = CreateProjectRequest(project_name=project_name) + def create(self, project_name: str, is_public: Optional[bool] = False) -> Project: + body = CreateProjectRequest(project_name=project_name, is_public=is_public) resp = self._request("/api/projects/create", body=body.json()) return parse_obj_as(Project.__response__, resp.json()) diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index ab655cf402..cbe052d1db 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -75,9 +75,126 @@ async def test_returns_projects(self, test_db, session: AsyncSession, client: As "created_at": "2023-01-02T03:04:00+00:00", "backends": [], "members": [], + "is_public": False, } ] + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_returns_public_projects_to_non_members(self, test_db, session: AsyncSession, client: AsyncClient): + # Create project owner + owner = await create_user( + session=session, + name="owner", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, + ) + + # Create a different user who is not a member + non_member = await create_user( + session=session, + name="non_member", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, + ) + + # Create a public project + public_project = await create_project( + session=session, + owner=owner, + name="public_project", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + is_public=True, + ) + + # Create a private project + private_project = await create_project( + session=session, + owner=owner, + name="private_project", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + is_public=False, + ) + + # Add owner as admin to both projects + await add_project_member( + session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN + ) + await add_project_member( + session=session, project=private_project, user=owner, project_role=ProjectRole.ADMIN + ) + + # List projects as non-member - should only see public project + response = await client.post("/api/projects/list", headers=get_auth_headers(non_member.token)) + assert response.status_code == 200 + projects = response.json() + + # Should only see the public project + assert len(projects) == 1 + assert projects[0]["project_name"] == "public_project" + assert projects[0]["is_public"] is True + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_member_sees_both_public_and_private_projects(self, test_db, session: AsyncSession, client: AsyncClient): + # Create project owner + owner = await create_user( + session=session, + name="owner", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, + ) + + # Create a user who will be a member + member = await create_user( + session=session, + name="member", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, + ) + + # Create a public project + public_project = await create_project( + session=session, + owner=owner, + name="public_project", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + is_public=True, + ) + + # Create a private project + private_project = await create_project( + session=session, + owner=owner, + name="private_project", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + is_public=False, + ) + + # Add member to the private project only + await add_project_member( + session=session, project=private_project, user=member, project_role=ProjectRole.USER + ) + + # Add owner as admin to both projects + await add_project_member( + session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN + ) + await add_project_member( + session=session, project=private_project, user=owner, project_role=ProjectRole.ADMIN + ) + + # List projects as member - should see both projects + response = await client.post("/api/projects/list", headers=get_auth_headers(member.token)) + assert response.status_code == 200 + projects = response.json() + + # Should see both projects, sorted by created_at + assert len(projects) == 2 + project_names = [p["project_name"] for p in projects] + assert "public_project" in project_names + assert "private_project" in project_names + class TestCreateProject: @pytest.mark.asyncio @@ -137,6 +254,7 @@ async def test_creates_project(self, test_db, session: AsyncSession, client: Asy }, } ], + "is_public": False, } @pytest.mark.asyncio @@ -212,16 +330,106 @@ async def test_no_project_quota_for_global_admins( async def test_forbids_if_no_permission_to_create_projects( self, test_db, session: AsyncSession, client: AsyncClient ): - user = await create_user(session=session, global_role=GlobalRole.USER) + user = await create_user(session=session, name="owner", global_role=GlobalRole.USER) with default_permissions_context( - DefaultPermissions(allow_non_admins_create_projects=False) + DefaultPermissions( + allow_non_admins_create_projects=False, + allow_non_admins_manage_ssh_fleets=True, + ) ): response = await client.post( "/api/projects/create", headers=get_auth_headers(user.token), - json={"project_name": "new_project"}, + json={"project_name": "test_project"}, ) - assert response.status_code in [401, 403] + assert response.status_code == 403 + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + @freeze_time(datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc)) + async def test_creates_public_project(self, test_db, session: AsyncSession, client: AsyncClient): + user = await create_user(session=session) + project_id = UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e") + project_name = "test_public_project" + body = {"project_name": project_name, "is_public": True} + with patch("uuid.uuid4") as m: + m.return_value = project_id + response = await client.post( + "/api/projects/create", + headers=get_auth_headers(user.token), + json=body, + ) + assert response.status_code == 200, response.json() + + # Check that the response includes is_public=True + response_data = response.json() + assert "is_public" in response_data + assert response_data["is_public"] is True + + # Verify the project was created as public in the database + res = await session.execute( + select(ProjectModel).where(ProjectModel.name == project_name) + ) + project = res.scalar_one() + assert project.is_public is True + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + @freeze_time(datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc)) + async def test_creates_private_project_by_default(self, test_db, session: AsyncSession, client: AsyncClient): + user = await create_user(session=session) + project_id = UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e") + project_name = "test_private_project" + body = {"project_name": project_name} # No is_public specified + with patch("uuid.uuid4") as m: + m.return_value = project_id + response = await client.post( + "/api/projects/create", + headers=get_auth_headers(user.token), + json=body, + ) + assert response.status_code == 200, response.json() + + # Check that the response includes is_public=False (default) + response_data = response.json() + assert "is_public" in response_data + assert response_data["is_public"] is False + + # Verify the project was created as private in the database + res = await session.execute( + select(ProjectModel).where(ProjectModel.name == project_name) + ) + project = res.scalar_one() + assert project.is_public is False + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + @freeze_time(datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc)) + async def test_creates_private_project_explicitly(self, test_db, session: AsyncSession, client: AsyncClient): + user = await create_user(session=session) + project_id = UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e") + project_name = "test_explicit_private_project" + body = {"project_name": project_name, "is_public": False} # Explicitly set to false + with patch("uuid.uuid4") as m: + m.return_value = project_id + response = await client.post( + "/api/projects/create", + headers=get_auth_headers(user.token), + json=body, + ) + assert response.status_code == 200, response.json() + + # Check that the response includes is_public=False (explicit) + response_data = response.json() + assert "is_public" in response_data + assert response_data["is_public"] is False + + # Verify the project was created as private in the database + res = await session.execute( + select(ProjectModel).where(ProjectModel.name == project_name) + ) + project = res.scalar_one() + assert project.is_public is False class TestDeleteProject: @@ -388,6 +596,7 @@ async def test_returns_project(self, test_db, session: AsyncSession, client: Asy }, } ], + "is_public": False, } From 2c951f9ef70897c6e2d9f8e4d023dee68aa90575 Mon Sep 17 00:00:00 2001 From: Haydn Li Date: Thu, 5 Jun 2025 12:09:58 -0400 Subject: [PATCH 02/14] refactor: Extract complex SQL query into helper function --- .../_internal/server/services/projects.py | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index b8a04f35c7..02f56770ea 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -56,29 +56,7 @@ async def list_user_projects( if user.global_role == GlobalRole.ADMIN: projects = await list_project_models(session=session) else: - # Single query to get both: - # 1. Projects where user is a member (public or private) - # 2. Public projects where user is NOT a member - res = await session.execute( - select(ProjectModel) - .where( - ProjectModel.deleted == False, - # Either user is a member, OR project is public and user is not a member - ( - # User is a member (regardless of public/private) - ProjectModel.id.in_( - select(MemberModel.project_id).where(MemberModel.user_id == user.id) - ) - ) | ( - # OR project is public and user is not a member - (ProjectModel.is_public == True) & - ProjectModel.id.notin_( - select(MemberModel.project_id).where(MemberModel.user_id == user.id) - ) - ) - ) - ) - projects = list(res.scalars().all()) + projects = await _list_user_accessible_project_models(session=session, user=user) projects = sorted(projects, key=lambda p: p.created_at) return [ @@ -87,6 +65,37 @@ async def list_user_projects( ] +async def _list_user_accessible_project_models( + session: AsyncSession, + user: UserModel, +) -> List[ProjectModel]: + """ + Get projects that a user can access: + 1. Projects where user is a member (public or private) + 2. Public projects where user is NOT a member + """ + res = await session.execute( + select(ProjectModel) + .where( + ProjectModel.deleted == False, + # Either user is a member, OR project is public and user is not a member + ( + # User is a member (regardless of public/private) + ProjectModel.id.in_( + select(MemberModel.project_id).where(MemberModel.user_id == user.id) + ) + ) | ( + # OR project is public and user is not a member + (ProjectModel.is_public == True) & + ProjectModel.id.notin_( + select(MemberModel.project_id).where(MemberModel.user_id == user.id) + ) + ) + ) + ) + return list(res.scalars().all()) + + async def list_projects(session: AsyncSession) -> List[Project]: projects = await list_project_models(session=session) return [ From cf1458f82eb3164823ccd33e7a6a2e459487f3b2 Mon Sep 17 00:00:00 2001 From: Haydn Li Date: Thu, 5 Jun 2025 12:33:46 -0400 Subject: [PATCH 03/14] fix: Keep list_user_projects backward compatible - Dakota pointed out that changing it to include public non-member projects could break existing code that assumes membership, so split into two functions --- .../_internal/server/routers/projects.py | 2 +- .../_internal/server/services/projects.py | 29 +++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index a0765103e5..502ce139af 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -36,7 +36,7 @@ async def list_projects( `members` and `backends` are always empty - call `/api/projects/{project_name}/get` to retrieve them. """ - return await projects.list_user_projects(session=session, user=user) + return await projects.list_user_accessible_projects(session=session, user=user) @router.post("/create") diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index 02f56770ea..44fb902565 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -53,6 +53,31 @@ async def list_user_projects( session: AsyncSession, user: UserModel, ) -> List[Project]: + """ + Returns projects where the user is a member. + For backward compatibility - use list_user_accessible_projects for public project discovery. + """ + if user.global_role == GlobalRole.ADMIN: + projects = await list_project_models(session=session) + else: + projects = await list_user_project_models(session=session, user=user) + + projects = sorted(projects, key=lambda p: p.created_at) + return [ + project_model_to_project(p, include_backends=False, include_members=False) + for p in projects + ] + + +async def list_user_accessible_projects( + session: AsyncSession, + user: UserModel, +) -> List[Project]: + """ + Returns all projects accessible to the user: + - Projects where user is a member (public or private) + - Public projects where user is NOT a member + """ if user.global_role == GlobalRole.ADMIN: projects = await list_project_models(session=session) else: @@ -71,8 +96,8 @@ async def _list_user_accessible_project_models( ) -> List[ProjectModel]: """ Get projects that a user can access: - 1. Projects where user is a member (public or private) - 2. Public projects where user is NOT a member + 1. Projects where user is a member (regardless of public/private) + 2. Public projects where user is not a member """ res = await session.execute( select(ProjectModel) From 325b0c98e6313f1ab2d1c2d03d136f482242afc2 Mon Sep 17 00:00:00 2001 From: Haydn Li Date: Thu, 5 Jun 2025 12:40:45 -0400 Subject: [PATCH 04/14] test: Add service-level tests for backward compatibility --- .../_internal/server/routers/test_projects.py | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index cbe052d1db..93f25105f3 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -866,3 +866,112 @@ async def test_non_manager_cannot_set_project_members( json=body, ) assert response.status_code == 403 + + +class TestListUserProjectsService: + """Test the service-level functions for backward compatibility""" + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_list_user_projects_only_returns_member_projects( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Create project owner + owner = await create_user( + session=session, + name="owner", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, + ) + + # Create a different user who is not a member + non_member = await create_user( + session=session, + name="non_member", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, + ) + + # Create a public project + public_project = await create_project( + session=session, + owner=owner, + name="public_project", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + is_public=True, + ) + + # Add owner as admin + await add_project_member( + session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN + ) + + # Test: list_user_projects should NOT return public projects for non-members + from dstack._internal.server.services.projects import list_user_projects + projects = await list_user_projects(session=session, user=non_member) + assert len(projects) == 0 # Non-member should see NO projects + + # Test: list_user_projects should return projects where user IS a member + projects = await list_user_projects(session=session, user=owner) + assert len(projects) == 1 + assert projects[0].project_name == "public_project" + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_list_user_accessible_projects_returns_member_and_public_projects( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Create project owner + owner = await create_user( + session=session, + name="owner", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, + ) + + # Create a different user who is not a member + non_member = await create_user( + session=session, + name="non_member", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, + ) + + # Create a public project + public_project = await create_project( + session=session, + owner=owner, + name="public_project", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + is_public=True, + ) + + # Create a private project + private_project = await create_project( + session=session, + owner=owner, + name="private_project", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + is_public=False, + ) + + # Add owner as admin to both projects + await add_project_member( + session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN + ) + await add_project_member( + session=session, project=private_project, user=owner, project_role=ProjectRole.ADMIN + ) + + # Test: list_user_accessible_projects should return public projects for non-members + from dstack._internal.server.services.projects import list_user_accessible_projects + projects = await list_user_accessible_projects(session=session, user=non_member) + assert len(projects) == 1 # Should see only the public project + assert projects[0].project_name == "public_project" + + # Test: list_user_accessible_projects should return ALL projects for members + projects = await list_user_accessible_projects(session=session, user=owner) + assert len(projects) == 2 # Should see both projects + project_names = [p.project_name for p in projects] + assert "public_project" in project_names + assert "private_project" in project_names From 43228e774d440095543d959f7aa93ff7c484bfbb Mon Sep 17 00:00:00 2001 From: Haydn Li Date: Thu, 5 Jun 2025 14:06:37 -0400 Subject: [PATCH 05/14] refactor: Consolidate query logic with parameter approach --- .../_internal/server/services/projects.py | 90 ++++++++++--------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index 44fb902565..68e6914ea0 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -81,7 +81,9 @@ async def list_user_accessible_projects( if user.global_role == GlobalRole.ADMIN: projects = await list_project_models(session=session) else: - projects = await _list_user_accessible_project_models(session=session, user=user) + projects = await list_user_project_models( + session=session, user=user, include_public_non_member=True + ) projects = sorted(projects, key=lambda p: p.created_at) return [ @@ -90,37 +92,6 @@ async def list_user_accessible_projects( ] -async def _list_user_accessible_project_models( - session: AsyncSession, - user: UserModel, -) -> List[ProjectModel]: - """ - Get projects that a user can access: - 1. Projects where user is a member (regardless of public/private) - 2. Public projects where user is not a member - """ - res = await session.execute( - select(ProjectModel) - .where( - ProjectModel.deleted == False, - # Either user is a member, OR project is public and user is not a member - ( - # User is a member (regardless of public/private) - ProjectModel.id.in_( - select(MemberModel.project_id).where(MemberModel.user_id == user.id) - ) - ) | ( - # OR project is public and user is not a member - (ProjectModel.is_public == True) & - ProjectModel.id.notin_( - select(MemberModel.project_id).where(MemberModel.user_id == user.id) - ) - ) - ) - ) - return list(res.scalars().all()) - - async def list_projects(session: AsyncSession) -> List[Project]: projects = await list_project_models(session=session) return [ @@ -291,19 +262,58 @@ async def list_user_project_models( session: AsyncSession, user: UserModel, include_members: bool = False, + include_public_non_member: bool = False, ) -> List[ProjectModel]: + """ + Get projects for a user. + + Args: + session: Database session + user: User model + include_members: Whether to join and load project members + include_public_non_member: Whether to include public projects where user is not a member + + Returns: + List of ProjectModel instances + """ options = [] if include_members: options.append(joinedload(ProjectModel.members)) - res = await session.execute( - select(ProjectModel) - .where( - MemberModel.project_id == ProjectModel.id, - MemberModel.user_id == user.id, - ProjectModel.deleted == False, + + if include_public_non_member: + # Get both member projects AND public non-member projects + res = await session.execute( + select(ProjectModel) + .where( + ProjectModel.deleted == False, + # Either user is a member, OR project is public and user is not a member + ( + # User is a member (regardless of public/private) + ProjectModel.id.in_( + select(MemberModel.project_id).where(MemberModel.user_id == user.id) + ) + ) | ( + # OR project is public and user is not a member + (ProjectModel.is_public == True) & + ProjectModel.id.notin_( + select(MemberModel.project_id).where(MemberModel.user_id == user.id) + ) + ) + ) + .options(*options) ) - .options(*options) - ) + else: + # Original logic - only member projects + res = await session.execute( + select(ProjectModel) + .where( + MemberModel.project_id == ProjectModel.id, + MemberModel.user_id == user.id, + ProjectModel.deleted == False, + ) + .options(*options) + ) + return list(res.scalars().unique().all()) From c55f183021d33a82f14c0ecc4bf068f9d0665ec4 Mon Sep 17 00:00:00 2001 From: Haydn Li Date: Thu, 5 Jun 2025 14:48:15 -0400 Subject: [PATCH 06/14] Simplify project service functions for public project discovery --- .../_internal/server/services/projects.py | 79 +++++++++---------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index 68e6914ea0..dde11b1b04 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -81,9 +81,12 @@ async def list_user_accessible_projects( if user.global_role == GlobalRole.ADMIN: projects = await list_project_models(session=session) else: - projects = await list_user_project_models( - session=session, user=user, include_public_non_member=True - ) + # Get member projects + member_projects = await list_user_project_models(session=session, user=user) + # Get public non-member projects + public_projects = await _list_public_non_member_project_models(session=session, user=user) + # Combine both lists + projects = member_projects + public_projects projects = sorted(projects, key=lambda p: p.created_at) return [ @@ -262,61 +265,55 @@ async def list_user_project_models( session: AsyncSession, user: UserModel, include_members: bool = False, - include_public_non_member: bool = False, ) -> List[ProjectModel]: """ - Get projects for a user. - + Get projects for a user where they are a member. + Args: session: Database session user: User model include_members: Whether to join and load project members - include_public_non_member: Whether to include public projects where user is not a member - + Returns: - List of ProjectModel instances + List of ProjectModel instances where user is a member """ options = [] if include_members: options.append(joinedload(ProjectModel.members)) - if include_public_non_member: - # Get both member projects AND public non-member projects - res = await session.execute( - select(ProjectModel) - .where( - ProjectModel.deleted == False, - # Either user is a member, OR project is public and user is not a member - ( - # User is a member (regardless of public/private) - ProjectModel.id.in_( - select(MemberModel.project_id).where(MemberModel.user_id == user.id) - ) - ) | ( - # OR project is public and user is not a member - (ProjectModel.is_public == True) & - ProjectModel.id.notin_( - select(MemberModel.project_id).where(MemberModel.user_id == user.id) - ) - ) - ) - .options(*options) - ) - else: - # Original logic - only member projects - res = await session.execute( - select(ProjectModel) - .where( - MemberModel.project_id == ProjectModel.id, - MemberModel.user_id == user.id, - ProjectModel.deleted == False, - ) - .options(*options) + res = await session.execute( + select(ProjectModel) + .where( + MemberModel.project_id == ProjectModel.id, + MemberModel.user_id == user.id, + ProjectModel.deleted == False, ) + .options(*options) + ) return list(res.scalars().unique().all()) +async def _list_public_non_member_project_models( + session: AsyncSession, + user: UserModel, +) -> List[ProjectModel]: + """ + Get public projects where user is NOT a member. + """ + res = await session.execute( + select(ProjectModel) + .where( + ProjectModel.deleted == False, + ProjectModel.is_public == True, + ProjectModel.id.notin_( + select(MemberModel.project_id).where(MemberModel.user_id == user.id) + ) + ) + ) + return list(res.scalars().all()) + + async def list_user_owned_project_models( session: AsyncSession, user: UserModel, include_deleted: bool = False ) -> List[ProjectModel]: From 64947e1cdda983e78bdf2a061268d2a81cc3b700 Mon Sep 17 00:00:00 2001 From: Haydn Li Date: Thu, 5 Jun 2025 15:51:51 -0400 Subject: [PATCH 07/14] Clean up inline comments in project service --- src/dstack/_internal/server/services/projects.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index dde11b1b04..a85c4be472 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -81,11 +81,8 @@ async def list_user_accessible_projects( if user.global_role == GlobalRole.ADMIN: projects = await list_project_models(session=session) else: - # Get member projects member_projects = await list_user_project_models(session=session, user=user) - # Get public non-member projects public_projects = await _list_public_non_member_project_models(session=session, user=user) - # Combine both lists projects = member_projects + public_projects projects = sorted(projects, key=lambda p: p.created_at) @@ -265,18 +262,7 @@ async def list_user_project_models( session: AsyncSession, user: UserModel, include_members: bool = False, -) -> List[ProjectModel]: - """ - Get projects for a user where they are a member. - - Args: - session: Database session - user: User model - include_members: Whether to join and load project members - - Returns: - List of ProjectModel instances where user is a member - """ +) -> List[ProjectModel]: options = [] if include_members: options.append(joinedload(ProjectModel.members)) From 6e4537fa66e423e6c139326591fe36295c4814c7 Mon Sep 17 00:00:00 2001 From: Haydn Li Date: Thu, 5 Jun 2025 16:33:58 -0400 Subject: [PATCH 08/14] Fix code formatting for pre-commit compliance --- .../_internal/server/services/projects.py | 22 ++++++--- .../_internal/server/routers/test_projects.py | 48 +++++++++++-------- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index a85c4be472..1043a67346 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -262,11 +262,22 @@ async def list_user_project_models( session: AsyncSession, user: UserModel, include_members: bool = False, -) -> List[ProjectModel]: +) -> List[ProjectModel]: + """ + Get projects for a user where they are a member. + + Args: + session: Database session + user: User model + include_members: Whether to join and load project members + + Returns: + List of ProjectModel instances where user is a member + """ options = [] if include_members: options.append(joinedload(ProjectModel.members)) - + res = await session.execute( select(ProjectModel) .where( @@ -276,7 +287,7 @@ async def list_user_project_models( ) .options(*options) ) - + return list(res.scalars().unique().all()) @@ -288,13 +299,12 @@ async def _list_public_non_member_project_models( Get public projects where user is NOT a member. """ res = await session.execute( - select(ProjectModel) - .where( + select(ProjectModel).where( ProjectModel.deleted == False, ProjectModel.is_public == True, ProjectModel.id.notin_( select(MemberModel.project_id).where(MemberModel.user_id == user.id) - ) + ), ) ) return list(res.scalars().all()) diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index 93f25105f3..5997cd2057 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -81,7 +81,9 @@ async def test_returns_projects(self, test_db, session: AsyncSession, client: As @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_returns_public_projects_to_non_members(self, test_db, session: AsyncSession, client: AsyncClient): + async def test_returns_public_projects_to_non_members( + self, test_db, session: AsyncSession, client: AsyncClient + ): # Create project owner owner = await create_user( session=session, @@ -136,7 +138,9 @@ async def test_returns_public_projects_to_non_members(self, test_db, session: As @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_member_sees_both_public_and_private_projects(self, test_db, session: AsyncSession, client: AsyncClient): + async def test_member_sees_both_public_and_private_projects( + self, test_db, session: AsyncSession, client: AsyncClient + ): # Create project owner owner = await create_user( session=session, @@ -347,7 +351,9 @@ async def test_forbids_if_no_permission_to_create_projects( @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) @freeze_time(datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc)) - async def test_creates_public_project(self, test_db, session: AsyncSession, client: AsyncClient): + async def test_creates_public_project( + self, test_db, session: AsyncSession, client: AsyncClient + ): user = await create_user(session=session) project_id = UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e") project_name = "test_public_project" @@ -376,58 +382,58 @@ async def test_creates_public_project(self, test_db, session: AsyncSession, clie @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) @freeze_time(datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc)) - async def test_creates_private_project_by_default(self, test_db, session: AsyncSession, client: AsyncClient): + async def test_creates_private_project_by_default( + self, test_db, session: AsyncSession, client: AsyncClient + ): user = await create_user(session=session) project_id = UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e") project_name = "test_private_project" - body = {"project_name": project_name} # No is_public specified - with patch("uuid.uuid4") as m: - m.return_value = project_id + body = {"project_name": project_name} + + with patch("uuid.uuid4", return_value=project_id): response = await client.post( "/api/projects/create", headers=get_auth_headers(user.token), json=body, ) assert response.status_code == 200, response.json() - + # Check that the response includes is_public=False (default) response_data = response.json() assert "is_public" in response_data assert response_data["is_public"] is False - + # Verify the project was created as private in the database - res = await session.execute( - select(ProjectModel).where(ProjectModel.name == project_name) - ) + res = await session.execute(select(ProjectModel).where(ProjectModel.name == project_name)) project = res.scalar_one() assert project.is_public is False @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) @freeze_time(datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc)) - async def test_creates_private_project_explicitly(self, test_db, session: AsyncSession, client: AsyncClient): + async def test_creates_private_project_explicitly( + self, test_db, session: AsyncSession, client: AsyncClient + ): user = await create_user(session=session) project_id = UUID("1b0e1b45-2f8c-4ab6-8010-a0d1a3e44e0e") project_name = "test_explicit_private_project" - body = {"project_name": project_name, "is_public": False} # Explicitly set to false - with patch("uuid.uuid4") as m: - m.return_value = project_id + body = {"project_name": project_name, "is_public": False} + + with patch("uuid.uuid4", return_value=project_id): response = await client.post( "/api/projects/create", headers=get_auth_headers(user.token), json=body, ) assert response.status_code == 200, response.json() - + # Check that the response includes is_public=False (explicit) response_data = response.json() assert "is_public" in response_data assert response_data["is_public"] is False - + # Verify the project was created as private in the database - res = await session.execute( - select(ProjectModel).where(ProjectModel.name == project_name) - ) + res = await session.execute(select(ProjectModel).where(ProjectModel.name == project_name)) project = res.scalar_one() assert project.is_public is False From 0387e5241d0427648cdc0d0bc0d15e2720818aef Mon Sep 17 00:00:00 2001 From: Haydn Li Date: Fri, 6 Jun 2025 15:36:17 -0400 Subject: [PATCH 09/14] Add database migration for ProjectModel.is_public --- ...35f732ee4cf5_add_projectmodel_is_public.py | 39 +++++++++++++++++++ .../_internal/server/services/projects.py | 2 - 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 src/dstack/_internal/server/migrations/versions/35f732ee4cf5_add_projectmodel_is_public.py diff --git a/src/dstack/_internal/server/migrations/versions/35f732ee4cf5_add_projectmodel_is_public.py b/src/dstack/_internal/server/migrations/versions/35f732ee4cf5_add_projectmodel_is_public.py new file mode 100644 index 0000000000..ed736384e4 --- /dev/null +++ b/src/dstack/_internal/server/migrations/versions/35f732ee4cf5_add_projectmodel_is_public.py @@ -0,0 +1,39 @@ +"""Add ProjectModel.is_public + +Revision ID: 35f732ee4cf5 +Revises: bca2fdf130bf +Create Date: 2025-06-06 13:04:02.912032 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "35f732ee4cf5" +down_revision = "bca2fdf130bf" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + # Add is_public column as nullable first + with op.batch_alter_table("projects", schema=None) as batch_op: + batch_op.add_column(sa.Column("is_public", sa.Boolean(), nullable=True)) + + # Set is_public to False for existing projects + op.execute(sa.sql.text("UPDATE projects SET is_public = FALSE")) + + # Make is_public non-nullable with default value + with op.batch_alter_table("projects", schema=None) as batch_op: + batch_op.alter_column("is_public", nullable=False, server_default=sa.false()) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + # Remove is_public column + with op.batch_alter_table("projects", schema=None) as batch_op: + batch_op.drop_column("is_public") + # ### end Alembic commands ### diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index 1043a67346..ee17c4ebb9 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -277,7 +277,6 @@ async def list_user_project_models( options = [] if include_members: options.append(joinedload(ProjectModel.members)) - res = await session.execute( select(ProjectModel) .where( @@ -287,7 +286,6 @@ async def list_user_project_models( ) .options(*options) ) - return list(res.scalars().unique().all()) From 878759efd584c68a874380458ad86982a9d6aaea Mon Sep 17 00:00:00 2001 From: Haydn Li Date: Mon, 9 Jun 2025 10:09:09 -0400 Subject: [PATCH 10/14] Fix code formatting for pre-commit compliance --- .../_internal/server/services/projects.py | 4 +- .../_internal/server/routers/test_projects.py | 68 ++++++++++--------- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index ee17c4ebb9..8d18c75f11 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -61,7 +61,7 @@ async def list_user_projects( projects = await list_project_models(session=session) else: projects = await list_user_project_models(session=session, user=user) - + projects = sorted(projects, key=lambda p: p.created_at) return [ project_model_to_project(p, include_backends=False, include_members=False) @@ -84,7 +84,7 @@ async def list_user_accessible_projects( member_projects = await list_user_project_models(session=session, user=user) public_projects = await _list_public_non_member_project_models(session=session, user=user) projects = member_projects + public_projects - + projects = sorted(projects, key=lambda p: p.created_at) return [ project_model_to_project(p, include_backends=False, include_members=False) diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index 5997cd2057..297763ce32 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -91,7 +91,7 @@ async def test_returns_public_projects_to_non_members( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), global_role=GlobalRole.USER, ) - + # Create a different user who is not a member non_member = await create_user( session=session, @@ -99,7 +99,7 @@ async def test_returns_public_projects_to_non_members( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), global_role=GlobalRole.USER, ) - + # Create a public project public_project = await create_project( session=session, @@ -108,16 +108,16 @@ async def test_returns_public_projects_to_non_members( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), is_public=True, ) - - # Create a private project + + # Create a private project private_project = await create_project( session=session, owner=owner, - name="private_project", + name="private_project", created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), is_public=False, ) - + # Add owner as admin to both projects await add_project_member( session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN @@ -125,9 +125,11 @@ async def test_returns_public_projects_to_non_members( await add_project_member( session=session, project=private_project, user=owner, project_role=ProjectRole.ADMIN ) - + # List projects as non-member - should only see public project - response = await client.post("/api/projects/list", headers=get_auth_headers(non_member.token)) + response = await client.post( + "/api/projects/list", headers=get_auth_headers(non_member.token) + ) assert response.status_code == 200 projects = response.json() @@ -148,7 +150,7 @@ async def test_member_sees_both_public_and_private_projects( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), global_role=GlobalRole.USER, ) - + # Create a user who will be a member member = await create_user( session=session, @@ -156,7 +158,7 @@ async def test_member_sees_both_public_and_private_projects( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), global_role=GlobalRole.USER, ) - + # Create a public project public_project = await create_project( session=session, @@ -165,21 +167,21 @@ async def test_member_sees_both_public_and_private_projects( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), is_public=True, ) - + # Create a private project private_project = await create_project( session=session, owner=owner, name="private_project", - created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), is_public=False, ) - + # Add member to the private project only await add_project_member( session=session, project=private_project, user=member, project_role=ProjectRole.USER ) - + # Add owner as admin to both projects await add_project_member( session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN @@ -187,12 +189,12 @@ async def test_member_sees_both_public_and_private_projects( await add_project_member( session=session, project=private_project, user=owner, project_role=ProjectRole.ADMIN ) - + # List projects as member - should see both projects response = await client.post("/api/projects/list", headers=get_auth_headers(member.token)) assert response.status_code == 200 projects = response.json() - + # Should see both projects, sorted by created_at assert len(projects) == 2 project_names = [p["project_name"] for p in projects] @@ -366,16 +368,14 @@ async def test_creates_public_project( json=body, ) assert response.status_code == 200, response.json() - + # Check that the response includes is_public=True response_data = response.json() assert "is_public" in response_data assert response_data["is_public"] is True - + # Verify the project was created as public in the database - res = await session.execute( - select(ProjectModel).where(ProjectModel.name == project_name) - ) + res = await session.execute(select(ProjectModel).where(ProjectModel.name == project_name)) project = res.scalar_one() assert project.is_public is True @@ -876,7 +876,7 @@ async def test_non_manager_cannot_set_project_members( class TestListUserProjectsService: """Test the service-level functions for backward compatibility""" - + @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) async def test_list_user_projects_only_returns_member_projects( @@ -889,7 +889,7 @@ async def test_list_user_projects_only_returns_member_projects( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), global_role=GlobalRole.USER, ) - + # Create a different user who is not a member non_member = await create_user( session=session, @@ -897,7 +897,7 @@ async def test_list_user_projects_only_returns_member_projects( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), global_role=GlobalRole.USER, ) - + # Create a public project public_project = await create_project( session=session, @@ -906,17 +906,18 @@ async def test_list_user_projects_only_returns_member_projects( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), is_public=True, ) - + # Add owner as admin await add_project_member( session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN ) - + # Test: list_user_projects should NOT return public projects for non-members from dstack._internal.server.services.projects import list_user_projects + projects = await list_user_projects(session=session, user=non_member) assert len(projects) == 0 # Non-member should see NO projects - + # Test: list_user_projects should return projects where user IS a member projects = await list_user_projects(session=session, user=owner) assert len(projects) == 1 @@ -934,7 +935,7 @@ async def test_list_user_accessible_projects_returns_member_and_public_projects( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), global_role=GlobalRole.USER, ) - + # Create a different user who is not a member non_member = await create_user( session=session, @@ -942,7 +943,7 @@ async def test_list_user_accessible_projects_returns_member_and_public_projects( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), global_role=GlobalRole.USER, ) - + # Create a public project public_project = await create_project( session=session, @@ -951,7 +952,7 @@ async def test_list_user_accessible_projects_returns_member_and_public_projects( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), is_public=True, ) - + # Create a private project private_project = await create_project( session=session, @@ -960,7 +961,7 @@ async def test_list_user_accessible_projects_returns_member_and_public_projects( created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), is_public=False, ) - + # Add owner as admin to both projects await add_project_member( session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN @@ -968,13 +969,14 @@ async def test_list_user_accessible_projects_returns_member_and_public_projects( await add_project_member( session=session, project=private_project, user=owner, project_role=ProjectRole.ADMIN ) - + # Test: list_user_accessible_projects should return public projects for non-members from dstack._internal.server.services.projects import list_user_accessible_projects + projects = await list_user_accessible_projects(session=session, user=non_member) assert len(projects) == 1 # Should see only the public project assert projects[0].project_name == "public_project" - + # Test: list_user_accessible_projects should return ALL projects for members projects = await list_user_accessible_projects(session=session, user=owner) assert len(projects) == 2 # Should see both projects From 366f9c0cb8c8ee9caf128d76cba7fcbfc00f0315 Mon Sep 17 00:00:00 2001 From: Haydn Li Date: Tue, 10 Jun 2025 12:14:59 -0400 Subject: [PATCH 11/14] fix: clarify docstring for global admin behavior --- src/dstack/_internal/server/services/projects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index 8d18c75f11..844a3264fa 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -75,8 +75,8 @@ async def list_user_accessible_projects( ) -> List[Project]: """ Returns all projects accessible to the user: - - Projects where user is a member (public or private) - - Public projects where user is NOT a member + - For global admins: ALL projects in the system + - For regular users: Projects where user is a member + public projects where user is NOT a member """ if user.global_role == GlobalRole.ADMIN: projects = await list_project_models(session=session) From 761fba6c769ddca5a724fcd24429814689d04b22 Mon Sep 17 00:00:00 2001 From: Haydn Li Date: Wed, 11 Jun 2025 09:35:36 -0400 Subject: [PATCH 12/14] Fix public project access for non-members --- .../_internal/server/routers/projects.py | 3 +- .../_internal/server/security/permissions.py | 38 +++++ .../_internal/server/routers/test_projects.py | 151 ++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index 502ce139af..e9cdb9f457 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -15,6 +15,7 @@ Authenticated, ProjectManager, ProjectMember, + ProjectMemberOrPublicAccess, ) from dstack._internal.server.services import projects from dstack._internal.server.utils.routers import get_base_api_additional_responses @@ -69,7 +70,7 @@ async def delete_projects( @router.post("/{project_name}/get") async def get_project( session: AsyncSession = Depends(get_session), - user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()), + user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMemberOrPublicAccess()), ) -> Project: _, project = user_project return projects.project_model_to_project(project) diff --git a/src/dstack/_internal/server/security/permissions.py b/src/dstack/_internal/server/security/permissions.py index f82dca0e9f..bf926479f0 100644 --- a/src/dstack/_internal/server/security/permissions.py +++ b/src/dstack/_internal/server/security/permissions.py @@ -99,6 +99,44 @@ async def __call__( return await get_project_member(session, project_name, token.credentials) +class ProjectMemberOrPublicAccess: + """ + Allows access to project details for: + 1. Project members (existing behavior) + 2. Any authenticated user if the project is public + """ + async def __call__( + self, + *, + session: AsyncSession = Depends(get_session), + project_name: str, + token: HTTPAuthorizationCredentials = Security(HTTPBearer()), + ) -> Tuple[UserModel, ProjectModel]: + user = await log_in_with_token(session=session, token=token.credentials) + if user is None: + raise error_invalid_token() + + project = await get_project_model_by_name(session=session, project_name=project_name) + if project is None: + raise error_not_found() + + # Global admins always have access + if user.global_role == GlobalRole.ADMIN: + return user, project + + # Check if user is a project member + project_role = get_user_project_role(user=user, project=project) + if project_role is not None: + return user, project + + # If not a member, check if project is public + if project.is_public: + return user, project + + # Neither member nor public project + raise error_forbidden() + + class OptionalServiceAccount: def __init__(self, token: Optional[str]) -> None: self._token = token diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index 297763ce32..c3937336ec 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -605,6 +605,157 @@ async def test_returns_project(self, test_db, session: AsyncSession, client: Asy "is_public": False, } + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_non_member_can_access_public_project( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Create project owner + owner = await create_user( + session=session, + name="owner", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, # Make owner a regular user + ) + + # Create public project + project = await create_project( + session=session, + owner=owner, + name="public_project", + is_public=True, + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project, user=owner, project_role=ProjectRole.ADMIN + ) + + # Create non-member user as regular user (not global admin) + non_member = await create_user( + session=session, + name="non_member", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, # Make non_member a regular user + ) + + # Non-member should be able to access public project details + response = await client.post( + f"/api/projects/{project.name}/get", + headers=get_auth_headers(non_member.token), + ) + assert response.status_code == 200, response.json() + + # Verify response includes is_public=True + response_data = response.json() + assert response_data["is_public"] is True + assert response_data["project_name"] == "public_project" + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_non_member_cannot_access_private_project( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Create project owner + owner = await create_user( + session=session, + name="owner", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, # Make owner a regular user + ) + + # Create private project + project = await create_project( + session=session, + owner=owner, + name="private_project", + is_public=False, + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project, user=owner, project_role=ProjectRole.ADMIN + ) + + # Create non-member user as regular user (not global admin) + non_member = await create_user( + session=session, + name="non_member", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, # Make non_member a regular user + ) + + # Non-member should NOT be able to access private project details + response = await client.post( + f"/api/projects/{project.name}/get", + headers=get_auth_headers(non_member.token), + ) + assert response.status_code == 403, response.json() + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_member_can_access_both_public_and_private_projects( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Create project owner + owner = await create_user( + session=session, + name="owner", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, # Make owner a regular user + ) + + # Create public project + public_project = await create_project( + session=session, + owner=owner, + name="public_project", + is_public=True, + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN + ) + + # Create private project + private_project = await create_project( + session=session, + owner=owner, + name="private_project", + is_public=False, + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=private_project, user=owner, project_role=ProjectRole.ADMIN + ) + + # Create member user as regular user (not global admin) and add to both projects + member = await create_user( + session=session, + name="member", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + global_role=GlobalRole.USER, # Make member a regular user + ) + await add_project_member( + session=session, project=public_project, user=member, project_role=ProjectRole.USER + ) + await add_project_member( + session=session, project=private_project, user=member, project_role=ProjectRole.USER + ) + + # Member should be able to access both public and private projects + response = await client.post( + f"/api/projects/{public_project.name}/get", + headers=get_auth_headers(member.token), + ) + assert response.status_code == 200, response.json() + assert response.json()["is_public"] is True + + response = await client.post( + f"/api/projects/{private_project.name}/get", + headers=get_auth_headers(member.token), + ) + assert response.status_code == 200, response.json() + assert response.json()["is_public"] is False + class TestSetProjectMembers: @pytest.mark.asyncio From ea840a6fd92f9d5a8ed482d98dec3f30d2d6d15d Mon Sep 17 00:00:00 2001 From: Haydn Li Date: Thu, 12 Jun 2025 10:26:13 -0400 Subject: [PATCH 13/14] Apply pre-commit formatting fixes --- src/dstack/_internal/server/routers/projects.py | 1 - src/dstack/_internal/server/security/permissions.py | 11 ++++++----- src/tests/_internal/server/routers/test_projects.py | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index e9cdb9f457..8f5455a2f7 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -14,7 +14,6 @@ from dstack._internal.server.security.permissions import ( Authenticated, ProjectManager, - ProjectMember, ProjectMemberOrPublicAccess, ) from dstack._internal.server.services import projects diff --git a/src/dstack/_internal/server/security/permissions.py b/src/dstack/_internal/server/security/permissions.py index bf926479f0..4580a5f525 100644 --- a/src/dstack/_internal/server/security/permissions.py +++ b/src/dstack/_internal/server/security/permissions.py @@ -105,6 +105,7 @@ class ProjectMemberOrPublicAccess: 1. Project members (existing behavior) 2. Any authenticated user if the project is public """ + async def __call__( self, *, @@ -115,24 +116,24 @@ async def __call__( user = await log_in_with_token(session=session, token=token.credentials) if user is None: raise error_invalid_token() - + project = await get_project_model_by_name(session=session, project_name=project_name) if project is None: raise error_not_found() - + # Global admins always have access if user.global_role == GlobalRole.ADMIN: return user, project - + # Check if user is a project member project_role = get_user_project_role(user=user, project=project) if project_role is not None: return user, project - + # If not a member, check if project is public if project.is_public: return user, project - + # Neither member nor public project raise error_forbidden() diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index c3937336ec..620c2d1eec 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -617,7 +617,7 @@ async def test_non_member_can_access_public_project( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), global_role=GlobalRole.USER, # Make owner a regular user ) - + # Create public project project = await create_project( session=session, @@ -644,7 +644,7 @@ async def test_non_member_can_access_public_project( headers=get_auth_headers(non_member.token), ) assert response.status_code == 200, response.json() - + # Verify response includes is_public=True response_data = response.json() assert response_data["is_public"] is True @@ -662,7 +662,7 @@ async def test_non_member_cannot_access_private_project( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), global_role=GlobalRole.USER, # Make owner a regular user ) - + # Create private project project = await create_project( session=session, @@ -702,7 +702,7 @@ async def test_member_can_access_both_public_and_private_projects( created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), global_role=GlobalRole.USER, # Make owner a regular user ) - + # Create public project public_project = await create_project( session=session, @@ -714,12 +714,12 @@ async def test_member_can_access_both_public_and_private_projects( await add_project_member( session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN ) - + # Create private project private_project = await create_project( session=session, owner=owner, - name="private_project", + name="private_project", is_public=False, created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), ) From 24306d11666d91d22fe53ddecbcc5fffb614b40b Mon Sep 17 00:00:00 2001 From: Victor Skvortsov Date: Fri, 13 Jun 2025 12:13:58 +0500 Subject: [PATCH 14/14] Fix comments and docstrings --- .../_internal/server/security/permissions.py | 11 ++++------- .../_internal/server/services/projects.py | 17 ++++------------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/dstack/_internal/server/security/permissions.py b/src/dstack/_internal/server/security/permissions.py index 4580a5f525..79080aa7bd 100644 --- a/src/dstack/_internal/server/security/permissions.py +++ b/src/dstack/_internal/server/security/permissions.py @@ -101,9 +101,10 @@ async def __call__( class ProjectMemberOrPublicAccess: """ - Allows access to project details for: - 1. Project members (existing behavior) - 2. Any authenticated user if the project is public + Allows access to project for: + - Global admins + - Project members + - Any authenticated user if the project is public """ async def __call__( @@ -121,20 +122,16 @@ async def __call__( if project is None: raise error_not_found() - # Global admins always have access if user.global_role == GlobalRole.ADMIN: return user, project - # Check if user is a project member project_role = get_user_project_role(user=user, project=project) if project_role is not None: return user, project - # If not a member, check if project is public if project.is_public: return user, project - # Neither member nor public project raise error_forbidden() diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index 844a3264fa..48fa3614eb 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -55,7 +55,6 @@ async def list_user_projects( ) -> List[Project]: """ Returns projects where the user is a member. - For backward compatibility - use list_user_accessible_projects for public project discovery. """ if user.global_role == GlobalRole.ADMIN: projects = await list_project_models(session=session) @@ -82,7 +81,7 @@ async def list_user_accessible_projects( projects = await list_project_models(session=session) else: member_projects = await list_user_project_models(session=session, user=user) - public_projects = await _list_public_non_member_project_models(session=session, user=user) + public_projects = await list_public_non_member_project_models(session=session, user=user) projects = member_projects + public_projects projects = sorted(projects, key=lambda p: p.created_at) @@ -264,15 +263,7 @@ async def list_user_project_models( include_members: bool = False, ) -> List[ProjectModel]: """ - Get projects for a user where they are a member. - - Args: - session: Database session - user: User model - include_members: Whether to join and load project members - - Returns: - List of ProjectModel instances where user is a member + List project models for a user where they are a member. """ options = [] if include_members: @@ -289,12 +280,12 @@ async def list_user_project_models( return list(res.scalars().unique().all()) -async def _list_public_non_member_project_models( +async def list_public_non_member_project_models( session: AsyncSession, user: UserModel, ) -> List[ProjectModel]: """ - Get public projects where user is NOT a member. + List public project models where user is NOT a member. """ res = await session.execute( select(ProjectModel).where(