diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f5d910ed71..226d5edde3 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -56,6 +56,9 @@ export const API = { DETAILS: (name: IProject['project_name']) => `${API.PROJECTS.BASE()}/${name}`, DETAILS_INFO: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/get`, SET_MEMBERS: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/set_members`, + ADD_MEMBERS: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/add_members`, + REMOVE_MEMBERS: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/remove_members`, + UPDATE: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/update`, // Repos REPOS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/repos`, diff --git a/src/dstack/_internal/server/routers/gateways.py b/src/dstack/_internal/server/routers/gateways.py index 604519af0c..e0e0ad37d1 100644 --- a/src/dstack/_internal/server/routers/gateways.py +++ b/src/dstack/_internal/server/routers/gateways.py @@ -9,7 +9,10 @@ from dstack._internal.core.errors import ResourceNotExistsError from dstack._internal.server.db import get_session from dstack._internal.server.models import ProjectModel, UserModel -from dstack._internal.server.security.permissions import ProjectAdmin, ProjectMember +from dstack._internal.server.security.permissions import ( + ProjectAdmin, + ProjectMemberOrPublicAccess, +) from dstack._internal.server.utils.routers import get_base_api_additional_responses router = APIRouter( @@ -22,7 +25,7 @@ @router.post("/list") async def list_gateways( session: AsyncSession = Depends(get_session), - user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()), + user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMemberOrPublicAccess()), ) -> List[models.Gateway]: _, project = user_project return await gateways.list_project_gateways(session=session, project=project) @@ -32,7 +35,7 @@ async def list_gateways( async def get_gateway( body: schemas.GetGatewayRequest, session: AsyncSession = Depends(get_session), - user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()), + user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMemberOrPublicAccess()), ) -> models.Gateway: _, project = user_project gateway = await gateways.get_gateway_by_name(session=session, project=project, name=body.name) diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index 8f5455a2f7..1d967c6c8d 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -7,13 +7,19 @@ from dstack._internal.server.db import get_session from dstack._internal.server.models import ProjectModel, UserModel from dstack._internal.server.schemas.projects import ( + AddProjectMemberRequest, CreateProjectRequest, DeleteProjectsRequest, + RemoveProjectMemberRequest, SetProjectMembersRequest, + UpdateProjectRequest, ) from dstack._internal.server.security.permissions import ( Authenticated, + ProjectAdmin, ProjectManager, + ProjectManagerOrPublicProject, + ProjectManagerOrSelfLeave, ProjectMemberOrPublicAccess, ) from dstack._internal.server.services import projects @@ -92,3 +98,60 @@ async def set_project_members( ) await session.refresh(project) return projects.project_model_to_project(project) + + +@router.post( + "/{project_name}/add_members", +) +async def add_project_members( + body: AddProjectMemberRequest, + session: AsyncSession = Depends(get_session), + user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManagerOrPublicProject()), +) -> Project: + user, project = user_project + await projects.add_project_members( + session=session, + user=user, + project=project, + members=body.members, + ) + await session.refresh(project) + return projects.project_model_to_project(project) + + +@router.post( + "/{project_name}/remove_members", +) +async def remove_project_members( + body: RemoveProjectMemberRequest, + session: AsyncSession = Depends(get_session), + user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManagerOrSelfLeave()), +) -> Project: + user, project = user_project + await projects.remove_project_members( + session=session, + user=user, + project=project, + usernames=body.usernames, + ) + await session.refresh(project) + return projects.project_model_to_project(project) + + +@router.post( + "/{project_name}/update", +) +async def update_project( + body: UpdateProjectRequest, + session: AsyncSession = Depends(get_session), + user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectAdmin()), +) -> Project: + user, project = user_project + await projects.update_project( + session=session, + user=user, + project=project, + is_public=body.is_public, + ) + await session.refresh(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 3e5b99b772..355bb3a770 100644 --- a/src/dstack/_internal/server/schemas/projects.py +++ b/src/dstack/_internal/server/schemas/projects.py @@ -11,6 +11,10 @@ class CreateProjectRequest(CoreModel): is_public: bool = False +class UpdateProjectRequest(CoreModel): + is_public: bool + + class DeleteProjectsRequest(CoreModel): projects_names: List[str] @@ -25,3 +29,11 @@ class MemberSetting(CoreModel): class SetProjectMembersRequest(CoreModel): members: List[MemberSetting] + + +class AddProjectMemberRequest(CoreModel): + members: List[MemberSetting] + + +class RemoveProjectMemberRequest(CoreModel): + usernames: List[str] diff --git a/src/dstack/_internal/server/security/permissions.py b/src/dstack/_internal/server/security/permissions.py index 79080aa7bd..0ecddf1d9e 100644 --- a/src/dstack/_internal/server/security/permissions.py +++ b/src/dstack/_internal/server/security/permissions.py @@ -58,7 +58,7 @@ async def __call__( raise error_invalid_token() project = await get_project_model_by_name(session=session, project_name=project_name) if project is None: - raise error_forbidden() + raise error_not_found() if user.global_role == GlobalRole.ADMIN: return user, project project_role = get_user_project_role(user=user, project=project) @@ -68,6 +68,10 @@ async def __call__( class ProjectManager: + """ + Allows project admins and managers to manage projects. + """ + async def __call__( self, project_name: str, @@ -79,12 +83,15 @@ async def __call__( raise error_invalid_token() project = await get_project_model_by_name(session=session, project_name=project_name) if project is None: - raise error_forbidden() + 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 in [ProjectRole.ADMIN, ProjectRole.MANAGER]: return user, project + raise error_forbidden() @@ -135,6 +142,72 @@ async def __call__( raise error_forbidden() +class ProjectManagerOrPublicProject: + """ + Allows: + 1. Project managers to perform member management operations + 2. Access to public projects for any authenticated user + """ + + def __init__(self): + self.project_manager = ProjectManager() + + async def __call__( + self, + project_name: str, + session: AsyncSession = Depends(get_session), + 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 in [ProjectRole.ADMIN, ProjectRole.MANAGER]: + return user, project + + if project.is_public: + return user, project + + raise error_forbidden() + + +class ProjectManagerOrSelfLeave: + """ + Allows: + 1. Project managers to remove any members + 2. Any project member to leave (remove themselves) + """ + + async def __call__( + self, + project_name: str, + session: AsyncSession = Depends(get_session), + 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 + + 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 48fa3614eb..1e46979dcf 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -74,8 +74,8 @@ async def list_user_accessible_projects( ) -> 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 + - 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) @@ -150,6 +150,17 @@ async def create_project( return project_model_to_project(project_model) +async def update_project( + session: AsyncSession, + user: UserModel, + project: ProjectModel, + is_public: bool, +): + """Update project visibility (public/private).""" + project.is_public = is_public + await session.commit() + + async def delete_projects( session: AsyncSession, user: UserModel, @@ -163,7 +174,8 @@ async def delete_projects( for project_name in projects_names: if project_name not in user_project_names: raise ForbiddenError() - for project in user_projects: + projects_to_delete = [p for p in user_projects if p.name in projects_names] + for project in projects_to_delete: if not _is_project_admin(user=user, project=project): raise ForbiddenError() if all(name in projects_names for name in user_project_names): @@ -187,7 +199,6 @@ async def set_project_members( project: ProjectModel, members: List[MemberSetting], ): - # reload with members project = await get_project_model_by_name_or_error( session=session, project_name=project.name, @@ -212,7 +223,6 @@ async def set_project_members( select(UserModel).where((UserModel.name.in_(names)) | (UserModel.email.in_(names))) ) users = res.scalars().all() - # Create lookup maps for both username and email username_to_user = {user.name: user for user in users} email_to_user = {user.email: user for user in users if user.email} for i, member in enumerate(members): @@ -230,6 +240,77 @@ async def set_project_members( await session.commit() +async def add_project_members( + session: AsyncSession, + user: UserModel, + project: ProjectModel, + members: List[MemberSetting], +): + """Add multiple members to a project.""" + project = await get_project_model_by_name_or_error( + session=session, + project_name=project.name, + ) + requesting_user_role = get_user_project_role(user=user, project=project) + + is_self_join_to_public = ( + len(members) == 1 + and project.is_public + and (members[0].username == user.name or members[0].username == user.email) + and requesting_user_role is None + ) + + if not is_self_join_to_public: + if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]: + raise ForbiddenError("Access denied: insufficient permissions to add members") + + if user.global_role != GlobalRole.ADMIN and requesting_user_role == ProjectRole.MANAGER: + for member in members: + if member.project_role == ProjectRole.ADMIN: + raise ForbiddenError( + "Access denied: only global admins can add project admins" + ) + else: + if members[0].project_role != ProjectRole.USER: + raise ForbiddenError("Access denied: can only join public projects as user role") + + usernames = [member.username for member in members] + + res = await session.execute( + select(UserModel).where((UserModel.name.in_(usernames)) | (UserModel.email.in_(usernames))) + ) + users_found = res.scalars().all() + + username_to_user = {user.name: user for user in users_found} + email_to_user = {user.email: user for user in users_found if user.email} + + member_by_user_id = {m.user_id: m for m in project.members} + + for member_setting in members: + user_to_add = username_to_user.get(member_setting.username) or email_to_user.get( + member_setting.username + ) + if user_to_add is None: + raise ServerClientError(f"User not found: {member_setting.username}") + + if user_to_add.id in member_by_user_id: + existing_member = member_by_user_id[user_to_add.id] + if existing_member.project_role != member_setting.project_role: + existing_member.project_role = member_setting.project_role + else: + await add_project_member( + session=session, + project=project, + user=user_to_add, + project_role=member_setting.project_role, + member_num=None, + commit=False, + ) + member_by_user_id[user_to_add.id] = None + + await session.commit() + + async def add_project_member( session: AsyncSession, project: ProjectModel, @@ -497,8 +578,86 @@ def _is_project_admin( user: UserModel, project: ProjectModel, ) -> bool: + if user.id == project.owner_id: + return True + for m in project.members: if user.id == m.user_id: if m.project_role == ProjectRole.ADMIN: return True return False + + +async def remove_project_members( + session: AsyncSession, + user: UserModel, + project: ProjectModel, + usernames: List[str], +): + """Remove multiple members from a project.""" + project = await get_project_model_by_name_or_error( + session=session, + project_name=project.name, + ) + requesting_user_role = get_user_project_role(user=user, project=project) + + is_self_leave = ( + len(usernames) == 1 + and (usernames[0] == user.name or usernames[0] == user.email) + and requesting_user_role is not None + ) + + if not is_self_leave: + if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]: + raise ForbiddenError("Access denied: insufficient permissions to remove members") + + res = await session.execute( + select(UserModel).where((UserModel.name.in_(usernames)) | (UserModel.email.in_(usernames))) + ) + users_found = res.scalars().all() + + username_to_user = {user.name: user for user in users_found} + email_to_user = {user.email: user for user in users_found if user.email} + + member_by_user_id = {m.user_id: m for m in project.members} + + members_to_remove = [] + admin_removals = 0 + + for username in usernames: + user_to_remove = username_to_user.get(username) or email_to_user.get(username) + if user_to_remove is None: + raise ServerClientError(f"User not found: {username}") + + if user_to_remove.id not in member_by_user_id: + raise ServerClientError(f"User is not a member of this project: {username}") + + member_to_remove = member_by_user_id[user_to_remove.id] + + if member_to_remove.project_role == ProjectRole.ADMIN: + if is_self_leave: + total_admins = sum( + 1 for member in project.members if member.project_role == ProjectRole.ADMIN + ) + if total_admins <= 1: + raise ServerClientError("Cannot leave project: you are the last admin") + else: + if user.global_role != GlobalRole.ADMIN: + raise ForbiddenError( + f"Access denied: only global admins can remove project admins (user: {username})" + ) + admin_removals += 1 + + members_to_remove.append(member_to_remove) + + if not is_self_leave: + total_admins = sum( + 1 for member in project.members if member.project_role == ProjectRole.ADMIN + ) + if admin_removals >= total_admins: + raise ServerClientError("Cannot remove all project admins") + + for member in members_to_remove: + await session.delete(member) + + await session.commit() diff --git a/src/dstack/_internal/server/services/users.py b/src/dstack/_internal/server/services/users.py index 8504f2af75..7aaf4b9799 100644 --- a/src/dstack/_internal/server/services/users.py +++ b/src/dstack/_internal/server/services/users.py @@ -44,9 +44,7 @@ async def list_users_for_user( session: AsyncSession, user: UserModel, ) -> List[User]: - if user.global_role == GlobalRole.ADMIN: - return await list_all_users(session=session) - return [user_model_to_user(user)] + return await list_all_users(session=session) async def list_all_users( diff --git a/src/dstack/api/server/_projects.py b/src/dstack/api/server/_projects.py index da6eccc4f4..0fb47c9ab5 100644 --- a/src/dstack/api/server/_projects.py +++ b/src/dstack/api/server/_projects.py @@ -3,10 +3,13 @@ from pydantic import parse_obj_as from dstack._internal.core.models.projects import Project +from dstack._internal.core.models.users import ProjectRole from dstack._internal.server.schemas.projects import ( + AddProjectMemberRequest, CreateProjectRequest, DeleteProjectsRequest, MemberSetting, + RemoveProjectMemberRequest, SetProjectMembersRequest, ) from dstack.api.server._group import APIClientGroup @@ -34,3 +37,24 @@ def set_members(self, project_name: str, members: List[MemberSetting]) -> Projec body = SetProjectMembersRequest(members=members) resp = self._request(f"/api/projects/{project_name}/set_members", body=body.json()) return parse_obj_as(Project.__response__, resp.json()) + + def add_member(self, project_name: str, username: str, project_role: ProjectRole) -> Project: + member_setting = MemberSetting(username=username, project_role=project_role) + body = AddProjectMemberRequest(members=[member_setting]) + resp = self._request(f"/api/projects/{project_name}/add_members", body=body.json()) + return parse_obj_as(Project.__response__, resp.json()) + + def add_members(self, project_name: str, members: List[MemberSetting]) -> Project: + body = AddProjectMemberRequest(members=members) + resp = self._request(f"/api/projects/{project_name}/add_members", body=body.json()) + return parse_obj_as(Project.__response__, resp.json()) + + def remove_member(self, project_name: str, username: str) -> Project: + body = RemoveProjectMemberRequest(usernames=[username]) + resp = self._request(f"/api/projects/{project_name}/remove_members", body=body.json()) + return parse_obj_as(Project.__response__, resp.json()) + + def remove_members(self, project_name: str, usernames: List[str]) -> Project: + body = RemoveProjectMemberRequest(usernames=usernames) + resp = self._request(f"/api/projects/{project_name}/remove_members", 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 620c2d1eec..b0364b4235 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -336,19 +336,16 @@ 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, name="owner", global_role=GlobalRole.USER) + user = await create_user(session=session, global_role=GlobalRole.USER) with default_permissions_context( - DefaultPermissions( - allow_non_admins_create_projects=False, - allow_non_admins_manage_ssh_fleets=True, - ) + DefaultPermissions(allow_non_admins_create_projects=False) ): response = await client.post( "/api/projects/create", headers=get_auth_headers(user.token), - json={"project_name": "test_project"}, + json={"project_name": "new_project"}, ) - assert response.status_code == 403 + assert response.status_code == 403 @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) @@ -994,143 +991,188 @@ async def test_global_admin_manager_can_set_project_admins( @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_non_manager_cannot_set_project_members( + async def test_add_member_errors_on_nonexistent_user( self, test_db, session: AsyncSession, client: AsyncClient ): - project = await create_project(session=session) - user = await create_user(session=session, global_role=GlobalRole.USER) + # Setup project and admin + project = await create_project( + session=session, created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc) + ) + admin = await create_user( + session=session, created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc) + ) await add_project_member( - session=session, - project=project, - user=user, - project_role=ProjectRole.USER, + session=session, project=project, user=admin, project_role=ProjectRole.ADMIN ) - user1 = await create_user(session=session, name="user1") - members = [ - { - "username": user.name, - "project_role": ProjectRole.ADMIN, - }, - { - "username": user1.name, - "project_role": ProjectRole.ADMIN, - }, - ] - body = {"members": members} + + # Try to add non-existent user - should now error instead of silently skipping + body = {"members": [{"username": "nonexistent", "project_role": "user"}]} response = await client.post( - f"/api/projects/{project.name}/set_members", - headers=get_auth_headers(user.token), + f"/api/projects/{project.name}/add_members", + headers=get_auth_headers(admin.token), json=body, ) - assert response.status_code == 403 - -class TestListUserProjectsService: - """Test the service-level functions for backward compatibility""" + # Operation should fail with 400 error for non-existent user + assert response.status_code == 400 + response_json = response.json() + assert "User not found: nonexistent" in str(response_json) @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_list_user_projects_only_returns_member_projects( + async def test_add_member_manager_cannot_add_admin_without_global_admin( 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, + # Setup project with manager (not global admin) + project = await create_project( + session=session, created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc) ) - - # Create a different user who is not a member - non_member = await create_user( + manager = await create_user( session=session, - name="non_member", - created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), global_role=GlobalRole.USER, + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project, user=manager, project_role=ProjectRole.MANAGER ) - # Create a public project - public_project = await create_project( + # Create user to add + _new_user = await create_user( session=session, - owner=owner, - name="public_project", + name="newuser", 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 + # Try to add admin + body = {"members": [{"username": "newuser", "project_role": "admin"}]} + response = await client.post( + f"/api/projects/{project.name}/add_members", + headers=get_auth_headers(manager.token), + json=body, ) - # Test: list_user_projects should NOT return public projects for non-members - from dstack._internal.server.services.projects import list_user_projects + assert response.status_code == 403 - 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" +class TestUpdateProjectVisibility: + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_returns_40x_if_not_authenticated(self, test_db, client: AsyncClient): + response = await client.post("/api/projects/test/update") + assert response.status_code in [401, 403] @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_list_user_accessible_projects_returns_member_and_public_projects( + async def test_returns_404_if_project_does_not_exist( 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, + user = await create_user(session=session) + response = await client.post( + "/api/projects/nonexistent/update", + headers=get_auth_headers(user.token), + json={"is_public": True}, ) + assert response.status_code == 404 - # 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, + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_project_admin_can_update_visibility( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Setup project with admin + admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) + project = await create_project(session=session, owner=admin_user, is_public=False) + await add_project_member( + session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN ) - # 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, + # Admin should be able to make project public + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(admin_user.token), + json={"is_public": True}, ) + assert response.status_code == 200 + assert response.json()["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, + # Admin should be able to make project private again + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(admin_user.token), + json={"is_public": False}, ) + assert response.status_code == 200 + assert response.json()["is_public"] == False - # Add owner as admin to both projects + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_regular_user_cannot_update_visibility( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Setup project with admin and regular user + admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) + regular_user = await create_user(session=session, name="user", global_role=GlobalRole.USER) + project = await create_project(session=session, owner=admin_user, is_public=False) await add_project_member( - session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN + session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN ) await add_project_member( - session=session, project=private_project, user=owner, project_role=ProjectRole.ADMIN + session=session, project=project, user=regular_user, project_role=ProjectRole.USER ) - # Test: list_user_accessible_projects should return public projects for non-members - from dstack._internal.server.services.projects import list_user_accessible_projects + # Regular user should not be able to update visibility + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(regular_user.token), + json={"is_public": True}, + ) + assert response.status_code == 403 - 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" + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_non_member_cannot_update_visibility( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Setup project with admin and separate non-member user + admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) + non_member_user = await create_user( + session=session, name="nonmember", global_role=GlobalRole.USER + ) + project = await create_project(session=session, owner=admin_user, is_public=False) + await add_project_member( + session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN + ) - # 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 + # Non-member should not be able to update visibility + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(non_member_user.token), + json={"is_public": True}, + ) + assert response.status_code == 403 + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_global_admin_can_update_any_project_visibility( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Setup project with regular owner and global admin + project_owner = await create_user( + session=session, name="owner", global_role=GlobalRole.USER + ) + global_admin = await create_user( + session=session, name="admin", global_role=GlobalRole.ADMIN + ) + project = await create_project(session=session, owner=project_owner, is_public=False) + await add_project_member( + session=session, project=project, user=project_owner, project_role=ProjectRole.ADMIN + ) + + # Global admin should be able to update any project's visibility + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(global_admin.token), + json={"is_public": True}, + ) + assert response.status_code == 200 + assert response.json()["is_public"] == True