Skip to content

Commit f18939d

Browse files
authored
Support secrets management by Manager role (#3801)
* Add allow_managers_manage_secrets server setting * Fix new clients with old servers * Update frontend * Fix tests * Fix tests
1 parent fa28b7f commit f18939d

10 files changed

Lines changed: 226 additions & 19 deletions

File tree

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

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,19 @@ import { useTranslation } from 'react-i18next';
33

44
import { Button, ButtonWithConfirmation, Header, ListEmptyMessage, Modal, Pagination, SpaceBetween, Table } from 'components';
55

6-
import { useAppSelector, useCollection, useNotifications, usePermissionGuard } from 'hooks';
6+
import { useAppSelector, useCollection, useNotifications } from 'hooks';
77
import { getServerError } from 'libs';
88
import {
99
useDeleteSecretsMutation,
1010
useGetAllSecretsQuery,
1111
useLazyGetSecretQuery,
1212
useUpdateSecretMutation,
1313
} from 'services/secrets';
14-
import { GlobalUserRole, ProjectUserRole } from 'types';
14+
import { GlobalUserRole } from 'types';
1515

1616
import { selectUserData } from 'App/slice';
1717

18-
import { getProjectRoleByUserName } from '../utils';
18+
import { getMemberCanManageSecrets } from '../utils';
1919
import { SecretForm } from './Form';
2020

2121
import { IProps, TFormValues } from './types';
@@ -30,11 +30,8 @@ export const ProjectSecrets: React.FC<IProps> = ({ project, loading }) => {
3030
const projectName = project?.project_name ?? '';
3131
const [pushNotification] = useNotifications();
3232

33-
const [hasPermissionForSecretsManaging] = usePermissionGuard({
34-
allowedProjectRoles: [ProjectUserRole.ADMIN],
35-
allowedGlobalRoles: [GlobalUserRole.ADMIN],
36-
projectRole: project ? (getProjectRoleByUserName(project, userName) ?? undefined) : undefined,
37-
});
33+
const hasPermissionForSecretsManaging =
34+
userData?.global_role === GlobalUserRole.ADMIN || (project ? getMemberCanManageSecrets(project, userName) : false);
3835

3936
const { data, isLoading, isFetching } = useGetAllSecretsQuery(
4037
{ project_name: projectName },

frontend/src/pages/Project/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@ export const getProjectRoleByUserName = (
44
): TProjectRole | null => {
55
return project.members.find((m) => m.user.username === userName)?.project_role ?? null;
66
};
7+
8+
export const getMemberCanManageSecrets = (project: IProject, userName: IProjectMember['user']['username']): boolean => {
9+
const member = project.members.find((m) => m.user.username === userName);
10+
return member?.permissions?.can_manage_secrets ?? false;
11+
};

frontend/src/types/project.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,15 @@ declare interface IProject {
3333
templates_repo?: string | null;
3434
}
3535

36+
declare interface IProjectMemberPermissions {
37+
can_manage_ssh_fleets: boolean;
38+
can_manage_secrets: boolean;
39+
}
40+
3641
declare interface IProjectMember {
3742
project_role: TProjectRole;
3843
user: IUser | { username: string };
44+
permissions?: IProjectMemberPermissions;
3945
}
4046

4147
declare type TSetProjectMembersParams = {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010

1111
class MemberPermissions(CoreModel):
1212
can_manage_ssh_fleets: bool
13+
can_manage_secrets: bool = False
14+
"""Default is for client-side compatibility with older servers.
15+
Always explicitly set on the server."""
1316

1417

1518
class Member(CoreModel):

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
DeleteSecretsRequest,
1313
GetSecretRequest,
1414
)
15-
from dstack._internal.server.security.permissions import ProjectAdmin
15+
from dstack._internal.server.security.permissions import ProjectManager
1616
from dstack._internal.server.services import secrets as secrets_services
1717
from dstack._internal.server.utils.routers import CustomORJSONResponse
1818

@@ -25,13 +25,14 @@
2525
@router.post("/list", response_model=List[Secret])
2626
async def list_secrets(
2727
session: AsyncSession = Depends(get_session),
28-
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectAdmin()),
28+
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManager()),
2929
):
30-
_, project = user_project
30+
user, project = user_project
3131
return CustomORJSONResponse(
3232
await secrets_services.list_secrets(
3333
session=session,
3434
project=project,
35+
user=user,
3536
)
3637
)
3738

@@ -40,13 +41,14 @@ async def list_secrets(
4041
async def get_secret(
4142
body: GetSecretRequest,
4243
session: AsyncSession = Depends(get_session),
43-
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectAdmin()),
44+
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManager()),
4445
):
45-
_, project = user_project
46+
user, project = user_project
4647
secret = await secrets_services.get_secret(
4748
session=session,
4849
project=project,
4950
name=body.name,
51+
user=user,
5052
)
5153
if secret is None:
5254
raise ResourceNotExistsError()
@@ -57,7 +59,7 @@ async def get_secret(
5759
async def create_or_update_secret(
5860
body: CreateOrUpdateSecretRequest,
5961
session: AsyncSession = Depends(get_session),
60-
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectAdmin()),
62+
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManager()),
6163
):
6264
user, project = user_project
6365
return CustomORJSONResponse(
@@ -75,7 +77,7 @@ async def create_or_update_secret(
7577
async def delete_secrets(
7678
body: DeleteSecretsRequest,
7779
session: AsyncSession = Depends(get_session),
78-
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectAdmin()),
80+
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectManager()),
7981
):
8082
user, project = user_project
8183
await secrets_services.delete_secrets(

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ class DefaultPermissions(CoreModel):
2323
)
2424
),
2525
] = True
26+
allow_managers_manage_secrets: Annotated[
27+
bool,
28+
Field(
29+
description=("This flag controls whether project managers can manage project secrets")
30+
),
31+
] = False
2632

2733

2834
_default_permissions = DefaultPermissions()

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,8 +716,17 @@ def get_member_permissions(member_model: MemberModel) -> MemberPermissions:
716716
and member_model.project_role != ProjectRole.ADMIN
717717
):
718718
can_manage_ssh_fleets = False
719+
can_manage_secrets = (
720+
user_model.global_role == GlobalRole.ADMIN
721+
or member_model.project_role == ProjectRole.ADMIN
722+
or (
723+
member_model.project_role == ProjectRole.MANAGER
724+
and default_permissions.allow_managers_manage_secrets
725+
)
726+
)
719727
return MemberPermissions(
720728
can_manage_ssh_fleets=can_manage_ssh_fleets,
729+
can_manage_secrets=can_manage_secrets,
721730
)
722731

723732

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,18 @@
99
from sqlalchemy.ext.asyncio import AsyncSession
1010

1111
from dstack._internal.core.errors import (
12+
ForbiddenError,
1213
ResourceExistsError,
1314
ResourceNotExistsError,
1415
ServerClientError,
1516
)
1617
from dstack._internal.core.models.secrets import Secret
18+
from dstack._internal.core.models.users import GlobalRole
1719
from dstack._internal.server.db import get_db
1820
from dstack._internal.server.models import DecryptedString, ProjectModel, SecretModel, UserModel
1921
from dstack._internal.server.services import events
2022
from dstack._internal.server.services.locking import get_locker
23+
from dstack._internal.server.services.projects import get_member, get_member_permissions
2124

2225
_SECRET_NAME_REGEX = "^[A-Za-z0-9-_]{1,200}$"
2326
_SECRET_VALUE_MAX_LENGTH = 5000
@@ -26,7 +29,9 @@
2629
async def list_secrets(
2730
session: AsyncSession,
2831
project: ProjectModel,
32+
user: UserModel,
2933
) -> List[Secret]:
34+
_check_can_manage_secrets(user=user, project=project)
3035
secret_models = await list_project_secret_models(session=session, project=project)
3136
return [secret_model_to_secret(s, include_value=False) for s in secret_models]
3237

@@ -43,7 +48,9 @@ async def get_secret(
4348
session: AsyncSession,
4449
project: ProjectModel,
4550
name: str,
51+
user: UserModel,
4652
) -> Optional[Secret]:
53+
_check_can_manage_secrets(user=user, project=project)
4754
secret_model = await get_project_secret_model_by_name(
4855
session=session,
4956
project=project,
@@ -61,6 +68,7 @@ async def create_or_update_secret(
6168
value: str,
6269
user: UserModel,
6370
) -> Secret:
71+
_check_can_manage_secrets(user=user, project=project)
6472
_validate_secret(name=name, value=value)
6573
try:
6674
secret_model = await create_secret(
@@ -87,6 +95,7 @@ async def delete_secrets(
8795
names: List[str],
8896
user: UserModel,
8997
):
98+
_check_can_manage_secrets(user=user, project=project)
9099
async with get_project_secret_models_by_name_for_update(
91100
session=session, project=project, names=names
92101
) as secret_models:
@@ -243,3 +252,15 @@ def _validate_secret_name(name: str):
243252
def _validate_secret_value(value: str):
244253
if len(value) > _SECRET_VALUE_MAX_LENGTH:
245254
raise ServerClientError(f"Secret value length must not exceed {_SECRET_VALUE_MAX_LENGTH}")
255+
256+
257+
def _check_can_manage_secrets(user: UserModel, project: ProjectModel):
258+
if user.global_role == GlobalRole.ADMIN:
259+
return
260+
member = get_member(user=user, project=project)
261+
if member is None:
262+
raise ForbiddenError()
263+
permissions = get_member_permissions(member)
264+
if permissions.can_manage_secrets:
265+
return
266+
raise ForbiddenError()

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,7 @@ async def test_creates_project(self, test_db, session: AsyncSession, client: Asy
976976
"project_role": ProjectRole.ADMIN,
977977
"permissions": {
978978
"can_manage_ssh_fleets": True,
979+
"can_manage_secrets": True,
979980
},
980981
}
981982
],
@@ -1439,6 +1440,7 @@ async def test_returns_project(self, test_db, session: AsyncSession, client: Asy
14391440
"project_role": ProjectRole.ADMIN,
14401441
"permissions": {
14411442
"can_manage_ssh_fleets": True,
1443+
"can_manage_secrets": True,
14421444
},
14431445
}
14441446
],
@@ -1669,6 +1671,7 @@ async def test_sets_project_members(self, test_db, session: AsyncSession, client
16691671
"project_role": ProjectRole.ADMIN,
16701672
"permissions": {
16711673
"can_manage_ssh_fleets": True,
1674+
"can_manage_secrets": True,
16721675
},
16731676
},
16741677
{
@@ -1687,6 +1690,7 @@ async def test_sets_project_members(self, test_db, session: AsyncSession, client
16871690
"project_role": ProjectRole.ADMIN,
16881691
"permissions": {
16891692
"can_manage_ssh_fleets": True,
1693+
"can_manage_secrets": True,
16901694
},
16911695
},
16921696
{
@@ -1705,6 +1709,7 @@ async def test_sets_project_members(self, test_db, session: AsyncSession, client
17051709
"project_role": ProjectRole.USER,
17061710
"permissions": {
17071711
"can_manage_ssh_fleets": True,
1712+
"can_manage_secrets": True,
17081713
},
17091714
},
17101715
]
@@ -1762,6 +1767,7 @@ async def test_sets_project_members_by_email(
17621767
"project_role": ProjectRole.ADMIN,
17631768
"permissions": {
17641769
"can_manage_ssh_fleets": True,
1770+
"can_manage_secrets": True,
17651771
},
17661772
},
17671773
]

0 commit comments

Comments
 (0)