Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
9 changes: 6 additions & 3 deletions src/dstack/_internal/server/routers/gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions src/dstack/_internal/server/routers/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
12 changes: 12 additions & 0 deletions src/dstack/_internal/server/schemas/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ class CreateProjectRequest(CoreModel):
is_public: bool = False


class UpdateProjectRequest(CoreModel):
is_public: bool


class DeleteProjectsRequest(CoreModel):
projects_names: List[str]

Expand All @@ -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]
77 changes: 75 additions & 2 deletions src/dstack/_internal/server/security/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -68,6 +68,10 @@ async def __call__(


class ProjectManager:
"""
Allows project admins and managers to manage projects.
"""

async def __call__(
self,
project_name: str,
Expand All @@ -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()


Expand Down Expand Up @@ -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
Expand Down
Loading