Skip to content

Commit 2e75105

Browse files
Add public projects (#2759)
* feat: Add support for public projects (#2670) * refactor: Extract complex SQL query into helper function * fix: Keep list_user_projects backward compatible - Dakota pointed out that changing it to include public non-member projects could break existing code that assumes membership, so split into two functions * test: Add service-level tests for backward compatibility * refactor: Consolidate query logic with parameter approach * Simplify project service functions for public project discovery * Clean up inline comments in project service * Fix code formatting for pre-commit compliance * Add database migration for ProjectModel.is_public * Fix code formatting for pre-commit compliance * fix: clarify docstring for global admin behavior * Fix public project access for non-members * Apply pre-commit formatting fixes * Fix comments and docstrings --------- Co-authored-by: Victor Skvortsov <vds003@gmail.com>
1 parent 34654de commit 2e75105

10 files changed

Lines changed: 623 additions & 12 deletions

File tree

src/dstack/_internal/core/models/projects.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ class Project(CoreModel):
2525
created_at: Optional[datetime] = None
2626
backends: List[BackendInfo]
2727
members: List[Member]
28+
is_public: bool = False
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Add ProjectModel.is_public
2+
3+
Revision ID: 35f732ee4cf5
4+
Revises: bca2fdf130bf
5+
Create Date: 2025-06-06 13:04:02.912032
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "35f732ee4cf5"
14+
down_revision = "bca2fdf130bf"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
# Add is_public column as nullable first
22+
with op.batch_alter_table("projects", schema=None) as batch_op:
23+
batch_op.add_column(sa.Column("is_public", sa.Boolean(), nullable=True))
24+
25+
# Set is_public to False for existing projects
26+
op.execute(sa.sql.text("UPDATE projects SET is_public = FALSE"))
27+
28+
# Make is_public non-nullable with default value
29+
with op.batch_alter_table("projects", schema=None) as batch_op:
30+
batch_op.alter_column("is_public", nullable=False, server_default=sa.false())
31+
# ### end Alembic commands ###
32+
33+
34+
def downgrade() -> None:
35+
# ### commands auto generated by Alembic - please adjust! ###
36+
# Remove is_public column
37+
with op.batch_alter_table("projects", schema=None) as batch_op:
38+
batch_op.drop_column("is_public")
39+
# ### end Alembic commands ###

src/dstack/_internal/server/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ class ProjectModel(BaseModel):
202202
name: Mapped[str] = mapped_column(String(50), unique=True)
203203
created_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime)
204204
deleted: Mapped[bool] = mapped_column(Boolean, default=False)
205+
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
205206

206207
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
207208
owner: Mapped[UserModel] = relationship(lazy="joined")

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from dstack._internal.server.security.permissions import (
1515
Authenticated,
1616
ProjectManager,
17-
ProjectMember,
17+
ProjectMemberOrPublicAccess,
1818
)
1919
from dstack._internal.server.services import projects
2020
from dstack._internal.server.utils.routers import get_base_api_additional_responses
@@ -36,7 +36,7 @@ async def list_projects(
3636
3737
`members` and `backends` are always empty - call `/api/projects/{project_name}/get` to retrieve them.
3838
"""
39-
return await projects.list_user_projects(session=session, user=user)
39+
return await projects.list_user_accessible_projects(session=session, user=user)
4040

4141

4242
@router.post("/create")
@@ -49,6 +49,7 @@ async def create_project(
4949
session=session,
5050
user=user,
5151
project_name=body.project_name,
52+
is_public=body.is_public,
5253
)
5354

5455

@@ -68,7 +69,7 @@ async def delete_projects(
6869
@router.post("/{project_name}/get")
6970
async def get_project(
7071
session: AsyncSession = Depends(get_session),
71-
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()),
72+
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMemberOrPublicAccess()),
7273
) -> Project:
7374
_, project = user_project
7475
return projects.project_model_to_project(project)

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

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

33
from pydantic import Field
44

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

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

1213

1314
class DeleteProjectsRequest(CoreModel):

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

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

101101

102+
class ProjectMemberOrPublicAccess:
103+
"""
104+
Allows access to project for:
105+
- Global admins
106+
- Project members
107+
- Any authenticated user if the project is public
108+
"""
109+
110+
async def __call__(
111+
self,
112+
*,
113+
session: AsyncSession = Depends(get_session),
114+
project_name: str,
115+
token: HTTPAuthorizationCredentials = Security(HTTPBearer()),
116+
) -> Tuple[UserModel, ProjectModel]:
117+
user = await log_in_with_token(session=session, token=token.credentials)
118+
if user is None:
119+
raise error_invalid_token()
120+
121+
project = await get_project_model_by_name(session=session, project_name=project_name)
122+
if project is None:
123+
raise error_not_found()
124+
125+
if user.global_role == GlobalRole.ADMIN:
126+
return user, project
127+
128+
project_role = get_user_project_role(user=user, project=project)
129+
if project_role is not None:
130+
return user, project
131+
132+
if project.is_public:
133+
return user, project
134+
135+
raise error_forbidden()
136+
137+
102138
class OptionalServiceAccount:
103139
def __init__(self, token: Optional[str]) -> None:
104140
self._token = token

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,37 @@ async def list_user_projects(
5353
session: AsyncSession,
5454
user: UserModel,
5555
) -> List[Project]:
56+
"""
57+
Returns projects where the user is a member.
58+
"""
5659
if user.global_role == GlobalRole.ADMIN:
5760
projects = await list_project_models(session=session)
5861
else:
5962
projects = await list_user_project_models(session=session, user=user)
63+
64+
projects = sorted(projects, key=lambda p: p.created_at)
65+
return [
66+
project_model_to_project(p, include_backends=False, include_members=False)
67+
for p in projects
68+
]
69+
70+
71+
async def list_user_accessible_projects(
72+
session: AsyncSession,
73+
user: UserModel,
74+
) -> List[Project]:
75+
"""
76+
Returns all projects accessible to the user:
77+
- For global admins: ALL projects in the system
78+
- For regular users: Projects where user is a member + public projects where user is NOT a member
79+
"""
80+
if user.global_role == GlobalRole.ADMIN:
81+
projects = await list_project_models(session=session)
82+
else:
83+
member_projects = await list_user_project_models(session=session, user=user)
84+
public_projects = await list_public_non_member_project_models(session=session, user=user)
85+
projects = member_projects + public_projects
86+
6087
projects = sorted(projects, key=lambda p: p.created_at)
6188
return [
6289
project_model_to_project(p, include_backends=False, include_members=False)
@@ -86,6 +113,7 @@ async def create_project(
86113
session: AsyncSession,
87114
user: UserModel,
88115
project_name: str,
116+
is_public: bool = False,
89117
) -> Project:
90118
user_permissions = users.get_user_permissions(user)
91119
if not user_permissions.can_create_projects:
@@ -100,6 +128,7 @@ async def create_project(
100128
session=session,
101129
owner=user,
102130
project_name=project_name,
131+
is_public=is_public,
103132
)
104133
await add_project_member(
105134
session=session,
@@ -233,6 +262,9 @@ async def list_user_project_models(
233262
user: UserModel,
234263
include_members: bool = False,
235264
) -> List[ProjectModel]:
265+
"""
266+
List project models for a user where they are a member.
267+
"""
236268
options = []
237269
if include_members:
238270
options.append(joinedload(ProjectModel.members))
@@ -248,6 +280,25 @@ async def list_user_project_models(
248280
return list(res.scalars().unique().all())
249281

250282

283+
async def list_public_non_member_project_models(
284+
session: AsyncSession,
285+
user: UserModel,
286+
) -> List[ProjectModel]:
287+
"""
288+
List public project models where user is NOT a member.
289+
"""
290+
res = await session.execute(
291+
select(ProjectModel).where(
292+
ProjectModel.deleted == False,
293+
ProjectModel.is_public == True,
294+
ProjectModel.id.notin_(
295+
select(MemberModel.project_id).where(MemberModel.user_id == user.id)
296+
),
297+
)
298+
)
299+
return list(res.scalars().all())
300+
301+
251302
async def list_user_owned_project_models(
252303
session: AsyncSession, user: UserModel, include_deleted: bool = False
253304
) -> List[ProjectModel]:
@@ -323,7 +374,7 @@ async def get_project_model_by_id_or_error(
323374

324375

325376
async def create_project_model(
326-
session: AsyncSession, owner: UserModel, project_name: str
377+
session: AsyncSession, owner: UserModel, project_name: str, is_public: bool = False
327378
) -> ProjectModel:
328379
private_bytes, public_bytes = await run_async(
329380
generate_rsa_key_pair_bytes, f"{project_name}@dstack"
@@ -334,6 +385,7 @@ async def create_project_model(
334385
name=project_name,
335386
ssh_private_key=private_bytes.decode(),
336387
ssh_public_key=public_bytes.decode(),
388+
is_public=is_public,
337389
)
338390
session.add(project)
339391
await session.commit()
@@ -407,6 +459,7 @@ def project_model_to_project(
407459
created_at=project_model.created_at.replace(tzinfo=timezone.utc),
408460
backends=backends,
409461
members=members,
462+
is_public=project_model.is_public,
410463
)
411464

412465

src/dstack/_internal/server/testing/common.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ async def create_project(
140140
created_at: datetime = datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc),
141141
ssh_private_key: str = "",
142142
ssh_public_key: str = "",
143+
is_public: bool = False,
143144
) -> ProjectModel:
144145
if owner is None:
145146
owner = await create_user(session=session, name="test_owner")
@@ -149,6 +150,7 @@ async def create_project(
149150
created_at=created_at,
150151
ssh_private_key=ssh_private_key,
151152
ssh_public_key=ssh_public_key,
153+
is_public=is_public,
152154
)
153155
session.add(project)
154156
await session.commit()

src/dstack/api/server/_projects.py

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

33
from pydantic import parse_obj_as
44

@@ -17,8 +17,8 @@ def list(self) -> List[Project]:
1717
resp = self._request("/api/projects/list")
1818
return parse_obj_as(List[Project.__response__], resp.json())
1919

20-
def create(self, project_name: str) -> Project:
21-
body = CreateProjectRequest(project_name=project_name)
20+
def create(self, project_name: str, is_public: Optional[bool] = False) -> Project:
21+
body = CreateProjectRequest(project_name=project_name, is_public=is_public)
2222
resp = self._request("/api/projects/create", body=body.json())
2323
return parse_obj_as(Project.__response__, resp.json())
2424

0 commit comments

Comments
 (0)