Skip to content

Commit 8e0590c

Browse files
fix: permissions and tests for public project access
1 parent 7ff0899 commit 8e0590c

File tree

4 files changed

+47
-53
lines changed

4 files changed

+47
-53
lines changed

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@
1616
)
1717
from dstack._internal.server.security.permissions import (
1818
Authenticated,
19+
ProjectAdmin,
1920
ProjectManager,
20-
ProjectManagerOrPublicJoin,
21+
ProjectManagerOrPublicProject,
2122
ProjectManagerOrSelfLeave,
2223
ProjectMemberOrPublicAccess,
2324
)
@@ -105,7 +106,7 @@ async def set_project_members(
105106
async def add_project_members(
106107
body: AddProjectMemberRequest,
107108
session: AsyncSession = Depends(get_session),
108-
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManagerOrPublicJoin()),
109+
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManagerOrPublicProject()),
109110
) -> Project:
110111
user, project = user_project
111112
await projects.add_project_members(
@@ -143,7 +144,7 @@ async def remove_project_members(
143144
async def update_project_visibility(
144145
body: UpdateProjectVisibilityRequest,
145146
session: AsyncSession = Depends(get_session),
146-
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManager()),
147+
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectAdmin()),
147148
) -> Project:
148149
user, project = user_project
149150
await projects.update_project_visibility(

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Annotated, List, Optional
1+
from typing import Annotated, List
22

33
from pydantic import Field
44

@@ -8,7 +8,7 @@
88

99
class CreateProjectRequest(CoreModel):
1010
project_name: str
11-
is_public: Optional[bool] = False
11+
is_public: bool = False
1212

1313

1414
class UpdateProjectVisibilityRequest(CoreModel):
@@ -32,7 +32,6 @@ class SetProjectMembersRequest(CoreModel):
3232

3333

3434
class AddProjectMemberRequest(CoreModel):
35-
# Always accept a list of members for cleaner API design
3635
members: List[MemberSetting]
3736

3837

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

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ async def __call__(
5858
raise error_invalid_token()
5959
project = await get_project_model_by_name(session=session, project_name=project_name)
6060
if project is None:
61-
raise error_forbidden()
61+
raise error_not_found()
6262
if user.global_role == GlobalRole.ADMIN:
6363
return user, project
6464
project_role = get_user_project_role(user=user, project=project)
@@ -110,9 +110,10 @@ async def __call__(
110110

111111
class ProjectMemberOrPublicAccess:
112112
"""
113-
Allows access to project details for:
114-
1. Project members (existing behavior)
115-
2. Any authenticated user if the project is public
113+
Allows access to project for:
114+
- Global admins
115+
- Project members
116+
- Any authenticated user if the project is public
116117
"""
117118

118119
async def __call__(
@@ -130,28 +131,25 @@ async def __call__(
130131
if project is None:
131132
raise error_not_found()
132133

133-
# Global admins always have access
134134
if user.global_role == GlobalRole.ADMIN:
135135
return user, project
136136

137-
# Check if user is a project member
138137
project_role = get_user_project_role(user=user, project=project)
139138
if project_role is not None:
140139
return user, project
141140

142-
# If not a member, check if project is public
143141
if project.is_public:
144142
return user, project
145143

146-
# Neither member nor public project
147144
raise error_forbidden()
148145

149146

150-
class ProjectManagerOrPublicJoin:
147+
class ProjectManagerOrPublicProject:
151148
"""
152-
Allows:
153-
1. Project managers to add any members
154-
2. Any authenticated user to join public projects themselves
149+
Allows access to project for:
150+
- Global admins
151+
- Project managers and admins
152+
- Any authenticated user if the project is public
155153
"""
156154

157155
async def __call__(
@@ -167,16 +165,13 @@ async def __call__(
167165
if project is None:
168166
raise error_not_found()
169167

170-
# Global admin can always manage projects
171168
if user.global_role == GlobalRole.ADMIN:
172169
return user, project
173170

174-
# Project managers can add members
175171
project_role = get_user_project_role(user=user, project=project)
176172
if project_role in [ProjectRole.ADMIN, ProjectRole.MANAGER]:
177173
return user, project
178174

179-
# For public projects, any authenticated user can join (will be validated in service layer)
180175
if project.is_public:
181176
return user, project
182177

@@ -185,9 +180,10 @@ async def __call__(
185180

186181
class ProjectManagerOrSelfLeave:
187182
"""
188-
Allows:
189-
1. Project managers to remove any members
190-
2. Any project member to leave (remove themselves)
183+
Allows access to project for:
184+
- Global admins
185+
- Project managers (can remove any members)
186+
- Project members (can remove themselves)
191187
"""
192188

193189
async def __call__(
@@ -199,15 +195,14 @@ async def __call__(
199195
user = await log_in_with_token(session=session, token=token.credentials)
200196
if user is None:
201197
raise error_invalid_token()
198+
202199
project = await get_project_model_by_name(session=session, project_name=project_name)
203200
if project is None:
204201
raise error_not_found()
205202

206-
# Global admin can always manage projects
207203
if user.global_role == GlobalRole.ADMIN:
208204
return user, project
209205

210-
# Any project member can access (managers can remove others, members can leave)
211206
project_role = get_user_project_role(user=user, project=project)
212207
if project_role is not None:
213208
return user, project

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

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1801,33 +1801,6 @@ async def test_project_admin_can_update_visibility(
18011801
assert response.status_code == 200
18021802
assert response.json()["is_public"] == False
18031803

1804-
@pytest.mark.asyncio
1805-
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
1806-
async def test_project_manager_can_update_visibility(
1807-
self, test_db, session: AsyncSession, client: AsyncClient
1808-
):
1809-
# Setup project with admin and manager
1810-
admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER)
1811-
manager_user = await create_user(
1812-
session=session, name="manager", global_role=GlobalRole.USER
1813-
)
1814-
project = await create_project(session=session, owner=admin_user, is_public=False)
1815-
await add_project_member(
1816-
session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN
1817-
)
1818-
await add_project_member(
1819-
session=session, project=project, user=manager_user, project_role=ProjectRole.MANAGER
1820-
)
1821-
1822-
# Manager should be able to update visibility
1823-
response = await client.post(
1824-
f"/api/projects/{project.name}/update_visibility",
1825-
headers=get_auth_headers(manager_user.token),
1826-
json={"is_public": True},
1827-
)
1828-
assert response.status_code == 200
1829-
assert response.json()["is_public"] == True
1830-
18311804
@pytest.mark.asyncio
18321805
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
18331806
async def test_regular_user_cannot_update_visibility(
@@ -1900,3 +1873,29 @@ async def test_global_admin_can_update_any_project_visibility(
19001873
)
19011874
assert response.status_code == 200
19021875
assert response.json()["is_public"] == True
1876+
1877+
@pytest.mark.asyncio
1878+
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
1879+
async def test_project_manager_cannot_update_visibility(
1880+
self, test_db, session: AsyncSession, client: AsyncClient
1881+
):
1882+
# Setup project with admin and manager
1883+
admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER)
1884+
manager_user = await create_user(
1885+
session=session, name="manager", global_role=GlobalRole.USER
1886+
)
1887+
project = await create_project(session=session, owner=admin_user, is_public=False)
1888+
await add_project_member(
1889+
session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN
1890+
)
1891+
await add_project_member(
1892+
session=session, project=project, user=manager_user, project_role=ProjectRole.MANAGER
1893+
)
1894+
1895+
# Manager should not be able to update visibility
1896+
response = await client.post(
1897+
f"/api/projects/{project.name}/update_visibility",
1898+
headers=get_auth_headers(manager_user.token),
1899+
json={"is_public": True},
1900+
)
1901+
assert response.status_code == 403

0 commit comments

Comments
 (0)