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/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/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..8f5455a2f7 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -14,7 +14,7 @@ from dstack._internal.server.security.permissions import ( Authenticated, ProjectManager, - ProjectMember, + ProjectMemberOrPublicAccess, ) from dstack._internal.server.services import projects from dstack._internal.server.utils.routers import get_base_api_additional_responses @@ -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") @@ -49,6 +49,7 @@ async def create_project( session=session, user=user, project_name=body.project_name, + is_public=body.is_public, ) @@ -68,7 +69,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/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/security/permissions.py b/src/dstack/_internal/server/security/permissions.py index f82dca0e9f..79080aa7bd 100644 --- a/src/dstack/_internal/server/security/permissions.py +++ b/src/dstack/_internal/server/security/permissions.py @@ -99,6 +99,42 @@ async def __call__( return await get_project_member(session, project_name, token.credentials) +class ProjectMemberOrPublicAccess: + """ + Allows access to project for: + - Global admins + - Project members + - 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() + + if user.global_role == GlobalRole.ADMIN: + return user, project + + project_role = get_user_project_role(user=user, project=project) + if project_role is not None: + return user, project + + if project.is_public: + return user, project + + raise error_forbidden() + + class OptionalServiceAccount: def __init__(self, token: Optional[str]) -> None: self._token = token diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index b241e266ab..48fa3614eb 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -53,10 +53,37 @@ async def list_user_projects( session: AsyncSession, user: UserModel, ) -> List[Project]: + """ + Returns projects where the user is a member. + """ 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: + - 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) + 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) + 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) @@ -86,6 +113,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 +128,7 @@ async def create_project( session=session, owner=user, project_name=project_name, + is_public=is_public, ) await add_project_member( session=session, @@ -233,6 +262,9 @@ async def list_user_project_models( user: UserModel, include_members: bool = False, ) -> List[ProjectModel]: + """ + List project models for a user where they are a member. + """ options = [] if include_members: options.append(joinedload(ProjectModel.members)) @@ -248,6 +280,25 @@ async def list_user_project_models( return list(res.scalars().unique().all()) +async def list_public_non_member_project_models( + session: AsyncSession, + user: UserModel, +) -> List[ProjectModel]: + """ + List public project models 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]: @@ -323,7 +374,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 +385,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 +459,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..620c2d1eec 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -75,9 +75,132 @@ 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 +260,7 @@ async def test_creates_project(self, test_db, session: AsyncSession, client: Asy }, } ], + "is_public": False, } @pytest.mark.asyncio @@ -212,16 +336,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} + + 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)) + 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} + + 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)) + project = res.scalar_one() + assert project.is_public is False class TestDeleteProject: @@ -388,8 +602,160 @@ 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 @@ -657,3 +1023,114 @@ 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