Skip to content

Commit 95d844b

Browse files
authored
Disallow duplicate project members (#2945)
* Dissalow adding member twice * Add UI check for duplicate members
1 parent 667b5ff commit 95d844b

File tree

3 files changed

+89
-5
lines changed

3 files changed

+89
-5
lines changed

frontend/src/pages/Project/Members/index.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
Table,
1515
} from 'components';
1616

17-
import { useAppSelector, useCollection } from 'hooks';
17+
import { useAppSelector, useCollection, useNotifications } from 'hooks';
1818
import { ROUTES } from 'routes';
1919
import { useGetUserListQuery } from 'services/user';
2020

@@ -35,6 +35,7 @@ export const ProjectMembers: React.FC<IProps> = ({ members, loading, onChange, r
3535
const { data: usersData } = useGetUserListQuery();
3636
const userData = useAppSelector(selectUserData);
3737
const { handleJoinProject, handleLeaveProject, isMemberActionLoading } = useProjectMemberActions();
38+
const [pushNotification] = useNotifications();
3839

3940
const { handleSubmit, control, getValues, setValue } = useForm<TFormValues>({
4041
defaultValues: { members: members ?? [] },
@@ -84,6 +85,17 @@ export const ProjectMembers: React.FC<IProps> = ({ members, loading, onChange, r
8485
];
8586

8687
const addMemberHandler = (username: string) => {
88+
const existingMembers = getValues('members');
89+
const isDuplicate = existingMembers.some((member) => member.user.username === username);
90+
91+
if (isDuplicate) {
92+
pushNotification({
93+
type: 'error',
94+
content: `User "${username}" is already a member of this project`,
95+
});
96+
return;
97+
}
98+
8799
const selectedUser = usersData?.find((u) => u.username === username);
88100

89101
if (selectedUser) {

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,10 @@ async def set_project_members(
197197
project: ProjectModel,
198198
members: List[MemberSetting],
199199
):
200+
usernames = {m.username for m in members}
201+
if len(usernames) != len(members):
202+
raise ServerClientError("Cannot add same user multiple times")
203+
200204
project = await get_project_model_by_name_or_error(
201205
session=session,
202206
project_name=project.name,
@@ -245,6 +249,10 @@ async def add_project_members(
245249
members: List[MemberSetting],
246250
):
247251
"""Add multiple members to a project."""
252+
usernames = {m.username for m in members}
253+
if len(usernames) != len(members):
254+
raise ServerClientError("Cannot add same user multiple times")
255+
248256
project = await get_project_model_by_name_or_error(
249257
session=session,
250258
project_name=project.name,
@@ -259,7 +267,10 @@ async def add_project_members(
259267
)
260268

261269
if not is_self_join_to_public:
262-
if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]:
270+
if user.global_role != GlobalRole.ADMIN and requesting_user_role not in [
271+
ProjectRole.ADMIN,
272+
ProjectRole.MANAGER,
273+
]:
263274
raise ForbiddenError("Access denied: insufficient permissions to add members")
264275

265276
if user.global_role != GlobalRole.ADMIN and requesting_user_role == ProjectRole.MANAGER:
@@ -272,8 +283,6 @@ async def add_project_members(
272283
if members[0].project_role != ProjectRole.USER:
273284
raise ForbiddenError("Access denied: can only join public projects as user role")
274285

275-
usernames = [member.username for member in members]
276-
277286
res = await session.execute(
278287
select(UserModel).where((UserModel.name.in_(usernames)) | (UserModel.email.in_(usernames)))
279288
)
@@ -628,7 +637,10 @@ async def remove_project_members(
628637
)
629638

630639
if not is_self_leave:
631-
if requesting_user_role not in [ProjectRole.ADMIN, ProjectRole.MANAGER]:
640+
if user.global_role != GlobalRole.ADMIN and requesting_user_role not in [
641+
ProjectRole.ADMIN,
642+
ProjectRole.MANAGER,
643+
]:
632644
raise ForbiddenError("Access denied: insufficient permissions to remove members")
633645

634646
res = await session.execute(

src/tests/_internal/server/routers/test_projects.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,37 @@ async def test_global_admin_manager_can_set_project_admins(
989989
members = res.scalars().all()
990990
assert len(members) == 2
991991

992+
@pytest.mark.asyncio
993+
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
994+
async def test_cannot_set_same_user_twice(
995+
self, test_db, session: AsyncSession, client: AsyncClient
996+
):
997+
project = await create_project(session=session)
998+
user = await create_user(session=session, global_role=GlobalRole.ADMIN)
999+
user1 = await create_user(session=session, name="user1")
1000+
members = [
1001+
{
1002+
"username": user1.name,
1003+
"project_role": ProjectRole.ADMIN,
1004+
},
1005+
{
1006+
"username": user1.name,
1007+
"project_role": ProjectRole.ADMIN,
1008+
},
1009+
]
1010+
body = {"members": members}
1011+
response = await client.post(
1012+
f"/api/projects/{project.name}/set_members",
1013+
headers=get_auth_headers(user.token),
1014+
json=body,
1015+
)
1016+
assert response.status_code == 400
1017+
res = await session.execute(select(MemberModel))
1018+
members = res.scalars().all()
1019+
assert len(members) == 0
1020+
1021+
1022+
class TestAddProjectMembers:
9921023
@pytest.mark.asyncio
9931024
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
9941025
async def test_add_member_errors_on_nonexistent_user(
@@ -1053,6 +1084,35 @@ async def test_add_member_manager_cannot_add_admin_without_global_admin(
10531084

10541085
assert response.status_code == 403
10551086

1087+
@pytest.mark.asyncio
1088+
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
1089+
async def test_cannot_add_same_user_twice(
1090+
self, test_db, session: AsyncSession, client: AsyncClient
1091+
):
1092+
project = await create_project(session=session)
1093+
user = await create_user(session=session, global_role=GlobalRole.ADMIN)
1094+
user1 = await create_user(session=session, name="user1")
1095+
members = [
1096+
{
1097+
"username": user1.name,
1098+
"project_role": ProjectRole.ADMIN,
1099+
},
1100+
{
1101+
"username": user1.name,
1102+
"project_role": ProjectRole.ADMIN,
1103+
},
1104+
]
1105+
body = {"members": members}
1106+
response = await client.post(
1107+
f"/api/projects/{project.name}/add_members",
1108+
headers=get_auth_headers(user.token),
1109+
json=body,
1110+
)
1111+
assert response.status_code == 400, response.json()
1112+
res = await session.execute(select(MemberModel))
1113+
members = res.scalars().all()
1114+
assert len(members) == 0
1115+
10561116

10571117
class TestUpdateProjectVisibility:
10581118
@pytest.mark.asyncio

0 commit comments

Comments
 (0)