fix: project settings options not checking permissions (#4472)

This commit is contained in:
Kristaps Fabians Geikins
2025-04-17 12:53:17 +03:00
committed by GitHub
parent bb68ef6a95
commit 2de4fef006
12 changed files with 257 additions and 30 deletions
@@ -76,7 +76,7 @@
<template #actions="{ item }">
<LayoutMenu
v-model:open="showActionsMenu[item.id]"
:items="actionItems"
:items="actionItems[item.id]"
mount-menu-on-body
:menu-position="HorizontalDirection.Left"
@chosen="({ item: actionItem }) => onActionChosen(actionItem, item)"
@@ -140,10 +140,21 @@ graphql(`
avatar
}
}
permissions {
canDelete {
...FullPermissionCheckResult
}
canReadSettings {
...FullPermissionCheckResult
}
canRead {
...FullPermissionCheckResult
}
}
}
`)
defineProps<{
const props = defineProps<{
projects?: SettingsSharedProjects_ProjectFragment[]
workspaceId?: string
disableCreate?: boolean
@@ -174,13 +185,37 @@ enum ActionTypes {
const showActionsMenu = ref<Record<string, boolean>>({})
const actionItems: LayoutMenuItem[][] = [
[
{ title: 'View project', id: ActionTypes.ViewProject },
{ title: 'Edit members', id: ActionTypes.EditMembers },
{ title: 'Delete project...', id: ActionTypes.DeleteProject }
]
]
const actionItems = computed((): { [projectId: string]: LayoutMenuItem[][] } =>
(props.projects || []).reduce((ret, project) => {
const canRead = project.permissions.canRead
const canDelete = project.permissions.canDelete
const canReadSettings = project.permissions.canReadSettings
ret[project.id] = [
[
{
title: 'View project',
id: ActionTypes.ViewProject,
disabled: !canRead?.authorized,
disabledTooltip: canRead?.message
},
{
title: 'Edit members',
id: ActionTypes.EditMembers,
disabled: !canReadSettings?.authorized,
disabledTooltip: canReadSettings?.message
},
{
title: 'Delete project...',
id: ActionTypes.DeleteProject,
disabled: !canDelete?.authorized,
disabledTooltip: canDelete?.message
}
]
]
return ret
}, {} as { [projectId: string]: LayoutMenuItem[][] })
)
const onActionChosen = (
actionItem: LayoutMenuItem,
@@ -107,7 +107,7 @@ type Documents = {
"\n fragment SettingsSidebar_User on User {\n id\n workspaces {\n items {\n ...SettingsSidebar_Workspace\n }\n }\n }\n": typeof types.SettingsSidebar_UserFragmentDoc,
"\n fragment SettingsServerRegionsAddEditDialog_ServerRegionItem on ServerRegionItem {\n id\n name\n description\n key\n }\n": typeof types.SettingsServerRegionsAddEditDialog_ServerRegionItemFragmentDoc,
"\n fragment SettingsServerRegionsTable_ServerRegionItem on ServerRegionItem {\n id\n name\n key\n description\n }\n": typeof types.SettingsServerRegionsTable_ServerRegionItemFragmentDoc,
"\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n": typeof types.SettingsSharedProjects_ProjectFragmentDoc,
"\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n permissions {\n canDelete {\n ...FullPermissionCheckResult\n }\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canRead {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.SettingsSharedProjects_ProjectFragmentDoc,
"\n fragment SettingsUserProfileChangePassword_User on User {\n id\n email\n }\n": typeof types.SettingsUserProfileChangePassword_UserFragmentDoc,
"\n fragment SettingsUserProfileDeleteAccount_User on User {\n id\n email\n }\n": typeof types.SettingsUserProfileDeleteAccount_UserFragmentDoc,
"\n fragment SettingsUserProfileDetails_User on User {\n id\n name\n company\n ...UserProfileEditDialogAvatar_User\n }\n": typeof types.SettingsUserProfileDetails_UserFragmentDoc,
@@ -529,7 +529,7 @@ const documents: Documents = {
"\n fragment SettingsSidebar_User on User {\n id\n workspaces {\n items {\n ...SettingsSidebar_Workspace\n }\n }\n }\n": types.SettingsSidebar_UserFragmentDoc,
"\n fragment SettingsServerRegionsAddEditDialog_ServerRegionItem on ServerRegionItem {\n id\n name\n description\n key\n }\n": types.SettingsServerRegionsAddEditDialog_ServerRegionItemFragmentDoc,
"\n fragment SettingsServerRegionsTable_ServerRegionItem on ServerRegionItem {\n id\n name\n key\n description\n }\n": types.SettingsServerRegionsTable_ServerRegionItemFragmentDoc,
"\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n": types.SettingsSharedProjects_ProjectFragmentDoc,
"\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n permissions {\n canDelete {\n ...FullPermissionCheckResult\n }\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canRead {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.SettingsSharedProjects_ProjectFragmentDoc,
"\n fragment SettingsUserProfileChangePassword_User on User {\n id\n email\n }\n": types.SettingsUserProfileChangePassword_UserFragmentDoc,
"\n fragment SettingsUserProfileDeleteAccount_User on User {\n id\n email\n }\n": types.SettingsUserProfileDeleteAccount_UserFragmentDoc,
"\n fragment SettingsUserProfileDetails_User on User {\n id\n name\n company\n ...UserProfileEditDialogAvatar_User\n }\n": types.SettingsUserProfileDetails_UserFragmentDoc,
@@ -1247,7 +1247,7 @@ export function graphql(source: "\n fragment SettingsServerRegionsTable_ServerR
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n }\n"];
export function graphql(source: "\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n permissions {\n canDelete {\n ...FullPermissionCheckResult\n }\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canRead {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment SettingsSharedProjects_Project on Project {\n ...ProjectsDeleteDialog_Project\n id\n name\n visibility\n createdAt\n updatedAt\n models(limit: 0) {\n totalCount\n }\n versions(limit: 0) {\n totalCount\n }\n team {\n id\n user {\n name\n id\n avatar\n }\n }\n permissions {\n canDelete {\n ...FullPermissionCheckResult\n }\n canReadSettings {\n ...FullPermissionCheckResult\n }\n canRead {\n ...FullPermissionCheckResult\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@ type ProjectPermissionChecks {
canMoveToWorkspace(workspaceId: String): PermissionCheckResult!
canRead: PermissionCheckResult!
canUpdate: PermissionCheckResult!
canDelete: PermissionCheckResult!
canUpdateAllowPublicComments: PermissionCheckResult!
canReadSettings: PermissionCheckResult!
canReadWebhooks: PermissionCheckResult!
@@ -2591,6 +2591,7 @@ export type ProjectPermissionChecks = {
canBroadcastActivity: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canDelete: PermissionCheckResult;
canLeave: PermissionCheckResult;
canMoveToWorkspace: PermissionCheckResult;
canRead: PermissionCheckResult;
@@ -6769,6 +6770,7 @@ export type ProjectPermissionChecksResolvers<ContextType = GraphQLContext, Paren
canBroadcastActivity?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateComment?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canCreateModel?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canDelete?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canLeave?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canMoveToWorkspace?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType, Partial<ProjectPermissionChecksCanMoveToWorkspaceArgs>>;
canRead?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
@@ -50,6 +50,13 @@ export default {
})
return Authz.toGraphqlResult(canUpdate)
},
canDelete: async (parent, _args, ctx) => {
const canDelete = await ctx.authPolicies.project.canDelete({
projectId: parent.projectId,
userId: ctx.userId
})
return Authz.toGraphqlResult(canDelete)
},
canUpdateAllowPublicComments: async (parent, _args, ctx) => {
const canUpdateAllowPublicComments =
await ctx.authPolicies.project.canUpdateAllowPublicComments({
@@ -203,13 +203,21 @@ export = {
},
ProjectMutations: {
async batchDelete(_parent, args, ctx) {
args.ids.forEach((id) => {
throwIfResourceAccessNotAllowed({
resourceId: id,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
await Promise.all(
args.ids.map(async (id) => {
throwIfResourceAccessNotAllowed({
resourceId: id,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canDelete = await ctx.authPolicies.project.canDelete({
projectId: id,
userId: ctx.userId
})
throwIfAuthNotOk(canDelete)
})
})
)
const results = await withOperationLogging(
async () =>
@@ -251,11 +259,11 @@ export = {
streamId: projectId //legacy
})
const canUpdate = await authPolicies.project.canUpdate({
const canDelete = await authPolicies.project.canDelete({
projectId,
userId
})
throwIfAuthNotOk(canUpdate)
throwIfAuthNotOk(canDelete)
const projectDb = await getProjectDbClient({ projectId })
const deleteStreamAndNotify = deleteStreamAndNotifyFactory({
@@ -2571,6 +2571,7 @@ export type ProjectPermissionChecks = {
canBroadcastActivity: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canDelete: PermissionCheckResult;
canLeave: PermissionCheckResult;
canMoveToWorkspace: PermissionCheckResult;
canRead: PermissionCheckResult;
@@ -2572,6 +2572,7 @@ export type ProjectPermissionChecks = {
canBroadcastActivity: PermissionCheckResult;
canCreateComment: PermissionCheckResult;
canCreateModel: PermissionCheckResult;
canDelete: PermissionCheckResult;
canLeave: PermissionCheckResult;
canMoveToWorkspace: PermissionCheckResult;
canRead: PermissionCheckResult;
@@ -22,6 +22,7 @@ import { canUpdateProjectVersionPolicy } from './project/version/canUpdate.js'
import { canReceiveProjectVersionPolicy } from './project/version/canReceive.js'
import { canRequestProjectVersionRenderPolicy } from './project/version/canRequestRender.js'
import { canReceiveWorkspaceProjectsUpdatedMessagePolicy } from './workspace/canReceiveProjectsUpdatedMessage.js'
import { canDeleteProjectPolicy } from './project/canDelete.js'
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
project: {
@@ -46,6 +47,7 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
canMoveToWorkspace: canMoveToWorkspacePolicy(loaders),
canCreatePersonal: canCreatePersonalProjectPolicy(loaders),
canUpdate: canUpdateProjectPolicy(loaders),
canDelete: canDeleteProjectPolicy(loaders),
canUpdateAllowPublicComments: canUpdateProjectAllowPublicCommentsPolicy(loaders),
canReadSettings: canReadProjectSettingsPolicy(loaders),
canReadWebhooks: canReadProjectWebhooksPolicy(loaders),
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { canDeleteProjectPolicy } from './canDelete.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { getProjectFake } from '../../../tests/fakes.js'
import { Roles } from '../../../core/constants.js'
import { TIME_MS } from '../../../core/index.js'
describe('canDeleteProjectPolicy', () => {
const buildSUT = (
overrides?: Partial<Parameters<typeof canDeleteProjectPolicy>[0]>
) =>
canDeleteProjectPolicy({
getEnv: async () => parseFeatureFlags({}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
isDiscoverable: false,
isPublic: false
}),
getProjectRole: async () => Roles.Stream.Owner,
getServerRole: async () => Roles.Server.User,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
const buildWorkspaceSUT = (
overrides?: Partial<Parameters<typeof canDeleteProjectPolicy>[0]>
) =>
buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id',
isDiscoverable: false,
isPublic: false
}),
getWorkspace: async () => ({
id: 'workspace-id',
slug: 'workspace-slug'
}),
getProjectRole: async () => null,
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
}),
getWorkspaceSsoSession: async () => ({
userId: 'user-id',
providerId: 'provider-id',
validUntil: new Date(Date.now() + TIME_MS.day)
}),
...overrides
})
it('works for project owner', async () => {
const sut = buildSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('it works for server admin', async () => {
const sut = buildSUT({
getServerRole: async () => Roles.Server.Admin,
getProjectRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
describe('with workspace project', () => {
it('works for workspace admin', async () => {
const sut = buildWorkspaceSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
it('it works for server admin', async () => {
const sut = buildWorkspaceSUT({
getServerRole: async () => Roles.Server.Admin,
getProjectRole: async () => null,
getWorkspaceRole: async () => null
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthOKResult()
})
})
})
@@ -0,0 +1,60 @@
import { ok } from 'true-myth/result'
import { MaybeUserContext, ProjectContext } from '../../domain/context.js'
import { AuthPolicy } from '../../domain/policies.js'
import { Roles } from '../../../core/constants.js'
import { Loaders } from '../../domain/loaders.js'
import {
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ProjectNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceNotEnoughPermissionsError,
WorkspaceSsoSessionNoAccessError
} from '../../domain/authErrors.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
import { canUpdateProjectPolicy } from './canUpdate.js'
export const canDeleteProjectPolicy: AuthPolicy<
| typeof Loaders.getProject
| typeof Loaders.getServerRole
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole,
ProjectContext & MaybeUserContext,
InstanceType<
| typeof ProjectNoAccessError
| typeof ProjectNotFoundError
| typeof WorkspaceNoAccessError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof ServerNotEnoughPermissionsError
| typeof WorkspaceSsoSessionNoAccessError
| typeof WorkspaceNotEnoughPermissionsError
| typeof ProjectNotEnoughPermissionsError
>
> =
(loaders) =>
async ({ userId, projectId }) => {
// Server admins can also delete projects
const ensuredAdminAccess = await ensureMinimumServerRoleFragment(loaders)({
userId,
role: Roles.Server.Admin
})
if (ensuredAdminAccess.isOk) {
return ok()
}
// Otherwise follow standard canUpdate check
const ensuredCanUpdate = await canUpdateProjectPolicy(loaders)({
userId,
projectId
})
return ensuredCanUpdate
}