Skip to content

Commit e89a648

Browse files
fix: retrack PR2 based on PR1
1 parent fdd03c6 commit e89a648

File tree

6 files changed

+936
-323
lines changed

6 files changed

+936
-323
lines changed

src/dstack/_internal/server/routers/projects.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@
77
from dstack._internal.server.db import get_session
88
from dstack._internal.server.models import ProjectModel, UserModel
99
from dstack._internal.server.schemas.projects import (
10+
AddProjectMemberRequest,
1011
CreateProjectRequest,
1112
DeleteProjectsRequest,
13+
RemoveProjectMemberRequest,
1214
SetProjectMembersRequest,
1315
)
1416
from dstack._internal.server.security.permissions import (
1517
Authenticated,
1618
ProjectManager,
19+
ProjectManagerOrPublicJoin,
20+
ProjectManagerOrSelfLeave,
1721
ProjectMember,
1822
)
1923
from dstack._internal.server.services import projects
@@ -92,3 +96,41 @@ async def set_project_members(
9296
)
9397
await session.refresh(project)
9498
return projects.project_model_to_project(project)
99+
100+
101+
@router.post(
102+
"/{project_name}/add_members",
103+
)
104+
async def add_project_members(
105+
body: AddProjectMemberRequest,
106+
session: AsyncSession = Depends(get_session),
107+
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManagerOrPublicJoin()),
108+
) -> Project:
109+
user, project = user_project
110+
await projects.add_project_members(
111+
session=session,
112+
user=user,
113+
project=project,
114+
members=body.members,
115+
)
116+
await session.refresh(project)
117+
return projects.project_model_to_project(project)
118+
119+
120+
@router.post(
121+
"/{project_name}/remove_members",
122+
)
123+
async def remove_project_members(
124+
body: RemoveProjectMemberRequest,
125+
session: AsyncSession = Depends(get_session),
126+
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManagerOrSelfLeave()),
127+
) -> Project:
128+
user, project = user_project
129+
await projects.remove_project_members(
130+
session=session,
131+
user=user,
132+
project=project,
133+
usernames=body.usernames,
134+
)
135+
await session.refresh(project)
136+
return projects.project_model_to_project(project)

src/dstack/_internal/server/schemas/projects.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,13 @@ class MemberSetting(CoreModel):
2525

2626
class SetProjectMembersRequest(CoreModel):
2727
members: List[MemberSetting]
28+
29+
30+
class AddProjectMemberRequest(CoreModel):
31+
# Always accept a list of members for cleaner API design
32+
members: List[MemberSetting]
33+
34+
35+
class RemoveProjectMemberRequest(CoreModel):
36+
# Always accept a list of usernames for cleaner API design
37+
usernames: List[str]

src/dstack/_internal/server/security/permissions.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,76 @@ async def __call__(
9999
return await get_project_member(session, project_name, token.credentials)
100100

101101

102+
class ProjectManagerOrPublicJoin:
103+
"""
104+
Allows:
105+
1. Project managers to add any members
106+
2. Any authenticated user to join public projects themselves
107+
"""
108+
async def __call__(
109+
self,
110+
project_name: str,
111+
session: AsyncSession = Depends(get_session),
112+
token: HTTPAuthorizationCredentials = Security(HTTPBearer()),
113+
) -> Tuple[UserModel, ProjectModel]:
114+
user = await log_in_with_token(session=session, token=token.credentials)
115+
if user is None:
116+
raise error_invalid_token()
117+
project = await get_project_model_by_name(session=session, project_name=project_name)
118+
if project is None:
119+
raise error_not_found()
120+
121+
# Global admin can always manage projects
122+
if user.global_role == GlobalRole.ADMIN:
123+
return user, project
124+
125+
# Project managers can add members
126+
project_role = get_user_project_role(user=user, project=project)
127+
if project_role in [ProjectRole.ADMIN, ProjectRole.MANAGER]:
128+
return user, project
129+
130+
# For public projects, any authenticated user can join (will be validated in service layer)
131+
if project.is_public:
132+
return user, project
133+
134+
raise error_forbidden()
135+
136+
137+
class ProjectManagerOrSelfLeave:
138+
"""
139+
Allows:
140+
1. Project managers to remove any members
141+
2. Any project member to leave (remove themselves)
142+
"""
143+
async def __call__(
144+
self,
145+
project_name: str,
146+
session: AsyncSession = Depends(get_session),
147+
token: HTTPAuthorizationCredentials = Security(HTTPBearer()),
148+
) -> Tuple[UserModel, ProjectModel]:
149+
user = await log_in_with_token(session=session, token=token.credentials)
150+
if user is None:
151+
raise error_invalid_token()
152+
project = await get_project_model_by_name(session=session, project_name=project_name)
153+
if project is None:
154+
raise error_not_found()
155+
156+
# Global admin can always manage projects
157+
if user.global_role == GlobalRole.ADMIN:
158+
return user, project
159+
160+
# Project managers can remove members
161+
project_role = get_user_project_role(user=user, project=project)
162+
if project_role in [ProjectRole.ADMIN, ProjectRole.MANAGER]:
163+
return user, project
164+
165+
# Any project member can leave (will be validated in service layer)
166+
if project_role is not None:
167+
return user, project
168+
169+
raise error_forbidden()
170+
171+
102172
class OptionalServiceAccount:
103173
def __init__(self, token: Optional[str]) -> None:
104174
self._token = token

src/dstack/_internal/server/services/projects.py

Lines changed: 185 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import uuid
22
from datetime import timezone
3-
from typing import Awaitable, Callable, List, Optional, Tuple
3+
from typing import Awaitable, Callable, List, Optional, Tuple, Union
44

55
from sqlalchemy import delete, select, update
66
from sqlalchemy import func as safunc
@@ -75,8 +75,8 @@ async def list_user_accessible_projects(
7575
) -> List[Project]:
7676
"""
7777
Returns all projects accessible to the user:
78-
- For global admins: ALL projects in the system
79-
- For regular users: Projects where user is a member + public projects where user is NOT a member
78+
- Projects where user is a member (public or private)
79+
- Public projects where user is NOT a member
8080
"""
8181
if user.global_role == GlobalRole.ADMIN:
8282
projects = await list_project_models(session=session)
@@ -231,6 +231,99 @@ async def set_project_members(
231231
await session.commit()
232232

233233

234+
async def add_project_members(
235+
session: AsyncSession,
236+
user: UserModel,
237+
project: ProjectModel,
238+
members: List[MemberSetting],
239+
):
240+
"""Add multiple members to a project."""
241+
# reload with members
242+
project = await get_project_model_by_name_or_error(
243+
session=session,
244+
project_name=project.name,
245+
)
246+
requesting_user_role = get_user_project_role(user=user, project=project)
247+
248+
# Check if this is a self-join to public project
249+
is_self_join_to_public = (
250+
len(members) == 1 and
251+
project.is_public and
252+
(members[0].username == user.name or members[0].username == user.email) and
253+
requesting_user_role is None # User is not already a member
254+
)
255+
256+
# Check permissions: only managers/admins can add members, EXCEPT for self-join to public projects
257+
if not is_self_join_to_public:
258+
if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]:
259+
raise ForbiddenError("Access denied: insufficient permissions to add members")
260+
261+
# For project managers, check if they're trying to add admins
262+
if user.global_role != GlobalRole.ADMIN and requesting_user_role == ProjectRole.MANAGER:
263+
for member in members:
264+
if member.project_role == ProjectRole.ADMIN:
265+
raise ForbiddenError("Access denied: only global admins can add project admins")
266+
else:
267+
# For self-join to public project, only allow USER role
268+
if members[0].project_role != ProjectRole.USER:
269+
raise ForbiddenError("Access denied: can only join public projects as user role")
270+
271+
# Collect all usernames to query
272+
usernames = [member.username for member in members]
273+
274+
# Find all users (by username or email)
275+
res = await session.execute(
276+
select(UserModel).where(
277+
(UserModel.name.in_(usernames)) | (UserModel.email.in_(usernames))
278+
)
279+
)
280+
users_found = res.scalars().all()
281+
282+
# Create lookup maps for both username and email
283+
username_to_user = {user.name: user for user in users_found}
284+
email_to_user = {user.email: user for user in users_found if user.email}
285+
286+
# Build a set of current member IDs so we can quickly check if users are already members
287+
member_ids = {m.user_id for m in project.members}
288+
# Build a map from user_id to member for efficient existing member updates
289+
member_by_user_id = {m.user_id: m for m in project.members}
290+
291+
# Get current max member_num
292+
max_member_num = 0
293+
for member in project.members:
294+
if member.member_num is not None and member.member_num > max_member_num:
295+
max_member_num = member.member_num
296+
297+
# Process each member to add
298+
for member_setting in members:
299+
user_to_add = username_to_user.get(member_setting.username) or email_to_user.get(member_setting.username)
300+
if user_to_add is None:
301+
# Error on adding non-existing users instead of silently skipping
302+
raise ServerClientError(f"User not found: {member_setting.username}")
303+
304+
# Check if user is already a member using our fast lookup set
305+
if user_to_add.id in member_ids:
306+
# Update existing member role if different using our efficient lookup
307+
existing_member = member_by_user_id[user_to_add.id]
308+
if existing_member.project_role != member_setting.project_role:
309+
existing_member.project_role = member_setting.project_role
310+
else:
311+
# Add new member
312+
max_member_num += 1
313+
await add_project_member(
314+
session=session,
315+
project=project,
316+
user=user_to_add,
317+
project_role=member_setting.project_role,
318+
member_num=max_member_num,
319+
commit=False,
320+
)
321+
# Keep track of newly added members for subsequent iterations
322+
member_ids.add(user_to_add.id)
323+
324+
await session.commit()
325+
326+
234327
async def add_project_member(
235328
session: AsyncSession,
236329
project: ProjectModel,
@@ -511,3 +604,92 @@ def _is_project_admin(
511604
if m.project_role == ProjectRole.ADMIN:
512605
return True
513606
return False
607+
608+
609+
async def remove_project_members(
610+
session: AsyncSession,
611+
user: UserModel,
612+
project: ProjectModel,
613+
usernames: List[str],
614+
):
615+
"""Remove multiple members from a project."""
616+
# reload with members
617+
project = await get_project_model_by_name_or_error(
618+
session=session,
619+
project_name=project.name,
620+
)
621+
requesting_user_role = get_user_project_role(user=user, project=project)
622+
623+
# Check if this is a self-leave (user removing themselves)
624+
is_self_leave = (
625+
len(usernames) == 1 and
626+
(usernames[0] == user.name or usernames[0] == user.email) and
627+
requesting_user_role is not None # User is actually a member
628+
)
629+
630+
# Check basic permissions: only managers/admins can remove members, EXCEPT for self-leave
631+
if not is_self_leave:
632+
if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]:
633+
raise ForbiddenError("Access denied: insufficient permissions to remove members")
634+
635+
# Find all users to remove (by username or email)
636+
res = await session.execute(
637+
select(UserModel).where(
638+
(UserModel.name.in_(usernames)) | (UserModel.email.in_(usernames))
639+
)
640+
)
641+
users_found = res.scalars().all()
642+
643+
# Create lookup maps
644+
username_to_user = {user.name: user for user in users_found}
645+
email_to_user = {user.email: user for user in users_found if user.email}
646+
647+
# Build a set of member IDs for faster membership checks
648+
member_user_ids = {m.user_id for m in project.members}
649+
# Build a map from user_id to member for efficient member lookups
650+
member_by_user_id = {m.user_id: m for m in project.members}
651+
652+
# Find members to remove and validate permissions
653+
members_to_remove = []
654+
admin_removals = 0
655+
656+
for username in usernames:
657+
user_to_remove = username_to_user.get(username) or email_to_user.get(username)
658+
if user_to_remove is None:
659+
# Error on removing non-existing users instead of silently skipping
660+
raise ServerClientError(f"User not found: {username}")
661+
662+
# Check if user is actually a member before trying to remove them
663+
if user_to_remove.id not in member_user_ids:
664+
# Error on removing non-members instead of silently skipping
665+
raise ServerClientError(f"User is not a member of this project: {username}")
666+
667+
# Get the member to remove using our efficient lookup
668+
member_to_remove = member_by_user_id[user_to_remove.id]
669+
670+
# Check if trying to remove project admin
671+
if member_to_remove.project_role == ProjectRole.ADMIN:
672+
if is_self_leave:
673+
# For self-leave, check if user is the last admin
674+
total_admins = sum(1 for member in project.members if member.project_role == ProjectRole.ADMIN)
675+
if total_admins <= 1:
676+
raise ServerClientError("Cannot leave project: you are the last admin")
677+
else:
678+
# For manager/admin removing other admins, only global admins can do this
679+
if user.global_role != GlobalRole.ADMIN:
680+
raise ForbiddenError(f"Access denied: only global admins can remove project admins (user: {username})")
681+
admin_removals += 1
682+
683+
members_to_remove.append(member_to_remove)
684+
685+
# Check we're not removing all admins (for non-self-leave operations)
686+
if not is_self_leave:
687+
total_admins = sum(1 for member in project.members if member.project_role == ProjectRole.ADMIN)
688+
if admin_removals >= total_admins:
689+
raise ServerClientError("Cannot remove all project admins")
690+
691+
# Remove all members
692+
for member in members_to_remove:
693+
await session.delete(member)
694+
695+
await session.commit()

0 commit comments

Comments
 (0)