From d013fe1dd7b8a08ee6ef60b747a8fe866598f6af Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Thu, 14 Aug 2025 12:45:08 +0300 Subject: [PATCH] feat: tightening up saved views permissions (#5239) * updated auth policies * added auth checks to resolvers * tests for single view resolvers --- .../modules/core/graph/generated/graphql.ts | 9 + .../modules/shared/helpers/errorHelper.ts | 1 + .../viewer/graph/resolvers/permissions.ts | 2 +- .../viewer/graph/resolvers/savedViews.ts | 31 +- .../viewer/graph/resolvers/viewerResources.ts | 15 +- .../modules/viewer/tests/helpers/graphql.ts | 11 + .../integration/savedViewsCrud.graph.spec.ts | 139 +++++- .../shared/src/authz/domain/authErrors.ts | 5 + packages/shared/src/authz/domain/context.ts | 4 + .../src/authz/domain/savedViews/types.ts | 6 + .../src/authz/fragments/savedViews.spec.ts | 438 ++++++++++++++++++ .../shared/src/authz/fragments/savedViews.ts | 208 +++++++++ packages/shared/src/authz/policies/index.ts | 4 +- .../policies/project/savedViews/canRead.ts | 63 +++ .../project/savedViews/canUpdate.spec.ts | 41 +- .../policies/project/savedViews/canUpdate.ts | 81 +--- .../project/savedViews/canUpdateGroup.spec.ts | 18 +- .../project/savedViews/canUpdateGroup.ts | 88 +--- packages/shared/src/tests/fakes.ts | 9 +- 19 files changed, 962 insertions(+), 211 deletions(-) create mode 100644 packages/shared/src/authz/fragments/savedViews.spec.ts create mode 100644 packages/shared/src/authz/fragments/savedViews.ts create mode 100644 packages/shared/src/authz/policies/project/savedViews/canRead.ts diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 1fc4320b1..ae4806840 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -9134,6 +9134,14 @@ export type GetProjectSavedViewQueryVariables = Exact<{ export type GetProjectSavedViewQuery = { __typename?: 'Query', project: { __typename?: 'Project', savedView: { __typename?: 'SavedView', id: string, name: string, description?: string | null, groupId?: string | null, createdAt: Date, updatedAt: Date, resourceIdString: string, resourceIds: Array, isHomeView: boolean, visibility: SavedViewVisibility, viewerState: Record, screenshot: string, position: number, projectId: string, author?: { __typename?: 'LimitedUser', id: string } | null, group: { __typename?: 'SavedViewGroup', id: string, title: string, isUngroupedViewsGroup: boolean } } } }; +export type GetProjectSavedViewIfExistsQueryVariables = Exact<{ + projectId: Scalars['String']['input']; + viewId: Scalars['ID']['input']; +}>; + + +export type GetProjectSavedViewIfExistsQuery = { __typename?: 'Query', project: { __typename?: 'Project', savedViewIfExists?: { __typename?: 'SavedView', id: string, name: string, description?: string | null, groupId?: string | null, createdAt: Date, updatedAt: Date, resourceIdString: string, resourceIds: Array, isHomeView: boolean, visibility: SavedViewVisibility, viewerState: Record, screenshot: string, position: number, projectId: string, author?: { __typename?: 'LimitedUser', id: string } | null, group: { __typename?: 'SavedViewGroup', id: string, title: string, isUngroupedViewsGroup: boolean } } | null } }; + export type DeleteSavedViewMutationVariables = Exact<{ input: DeleteSavedViewInput; }>; @@ -10313,6 +10321,7 @@ export const GetProjectSavedViewGroupsDocument = {"kind":"Document","definitions export const GetProjectSavedViewGroupDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectSavedViewGroup"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"groupId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewsInput"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SavedViewGroupViewsInput"}}},"defaultValue":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"10"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedViewGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"groupId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedViewGroup"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicSavedView"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SavedView"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"groupId"}},{"kind":"Field","name":{"kind":"Name","value":"group"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isUngroupedViewsGroup"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIdString"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}},{"kind":"Field","name":{"kind":"Name","value":"isHomeView"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"viewerState"}},{"kind":"Field","name":{"kind":"Name","value":"screenshot"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicSavedViewGroup"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SavedViewGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isUngroupedViewsGroup"}},{"kind":"Field","name":{"kind":"Name","value":"views"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewsInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedView"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetProjectUngroupedViewGroupDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectUngroupedViewGroup"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"GetUngroupedViewGroupInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewsInput"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SavedViewGroupViewsInput"}}},"defaultValue":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"10"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ungroupedViewGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedViewGroup"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicSavedView"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SavedView"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"groupId"}},{"kind":"Field","name":{"kind":"Name","value":"group"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isUngroupedViewsGroup"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIdString"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}},{"kind":"Field","name":{"kind":"Name","value":"isHomeView"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"viewerState"}},{"kind":"Field","name":{"kind":"Name","value":"screenshot"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicSavedViewGroup"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SavedViewGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isUngroupedViewsGroup"}},{"kind":"Field","name":{"kind":"Name","value":"views"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewsInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedView"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetProjectSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedView"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicSavedView"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SavedView"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"groupId"}},{"kind":"Field","name":{"kind":"Name","value":"group"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isUngroupedViewsGroup"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIdString"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}},{"kind":"Field","name":{"kind":"Name","value":"isHomeView"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"viewerState"}},{"kind":"Field","name":{"kind":"Name","value":"screenshot"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}}]}}]} as unknown as DocumentNode; +export const GetProjectSavedViewIfExistsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectSavedViewIfExists"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedViewIfExists"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedView"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicSavedView"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SavedView"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"groupId"}},{"kind":"Field","name":{"kind":"Name","value":"group"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isUngroupedViewsGroup"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIdString"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}},{"kind":"Field","name":{"kind":"Name","value":"isHomeView"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"viewerState"}},{"kind":"Field","name":{"kind":"Name","value":"screenshot"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}}]}}]} as unknown as DocumentNode; export const DeleteSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteSavedViewInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedViewMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode; export const CanCreateSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CanCreateSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canCreateSavedView"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorized"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"payload"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const CanUpdateSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CanUpdateSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"savedView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canUpdate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorized"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"payload"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/server/modules/shared/helpers/errorHelper.ts b/packages/server/modules/shared/helpers/errorHelper.ts index b9d34f8d0..7597366c5 100644 --- a/packages/server/modules/shared/helpers/errorHelper.ts +++ b/packages/server/modules/shared/helpers/errorHelper.ts @@ -42,6 +42,7 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => { case Authz.WorkspacePlanNoFeatureAccessError.code: case Authz.EligibleForExclusiveWorkspaceError.code: case Authz.AutomateFunctionNotCreatorError.code: + case Authz.SavedViewNoAccessError.code: return new ForbiddenError(e.message) case Authz.WorkspaceSsoSessionNoAccessError.code: throw new SsoSessionMissingOrExpiredError(e.message, { diff --git a/packages/server/modules/viewer/graph/resolvers/permissions.ts b/packages/server/modules/viewer/graph/resolvers/permissions.ts index 3cbd9fe2b..2b15ed9ee 100644 --- a/packages/server/modules/viewer/graph/resolvers/permissions.ts +++ b/packages/server/modules/viewer/graph/resolvers/permissions.ts @@ -40,7 +40,7 @@ const resolvers: Resolvers = { const canUpdate = await ctx.authPolicies.project.savedViews.canUpdateGroup({ userId: ctx.userId, projectId: parent.savedViewGroup.projectId, - groupId: savedViewGroupId + savedViewGroupId }) return toGraphqlResult(canUpdate) } diff --git a/packages/server/modules/viewer/graph/resolvers/savedViews.ts b/packages/server/modules/viewer/graph/resolvers/savedViews.ts index 6d169b350..e6bb8623a 100644 --- a/packages/server/modules/viewer/graph/resolvers/savedViews.ts +++ b/packages/server/modules/viewer/graph/resolvers/savedViews.ts @@ -122,12 +122,20 @@ const resolvers: Resolvers = { return group }, savedView: async (parent, args, ctx) => { - const projectDb = await getProjectDbClient({ projectId: parent.id }) + const projectId = parent.id + const canRead = await ctx.authPolicies.project.savedViews.canRead({ + userId: ctx.userId, + projectId, + savedViewId: args.id + }) + throwIfAuthNotOk(canRead) + + const projectDb = await getProjectDbClient({ projectId }) const view = await ctx.loaders .forRegion({ db: projectDb }) .savedViews.getSavedView.load({ viewId: args.id, - projectId: parent.id + projectId }) if (!view) { throw new NotFoundError( @@ -138,16 +146,27 @@ const resolvers: Resolvers = { return view }, savedViewIfExists: async (parent, args, ctx) => { + const projectId = parent.id if (!args.id?.length) return null - const projectDb = await getProjectDbClient({ projectId: parent.id }) + const projectDb = await getProjectDbClient({ projectId }) const view = await ctx.loaders .forRegion({ db: projectDb }) .savedViews.getSavedView.load({ viewId: args.id, - projectId: parent.id + projectId }) + if (view) { + // Only access check if found + const canRead = await ctx.authPolicies.project.savedViews.canRead({ + userId: ctx.userId, + projectId, + savedViewId: args.id + }) + throwIfAuthNotOk(canRead) + } + return view } }, @@ -385,7 +404,7 @@ const resolvers: Resolvers = { const canDelete = await ctx.authPolicies.project.savedViews.canUpdateGroup({ userId: ctx.userId, projectId, - groupId: args.input.groupId + savedViewGroupId: args.input.groupId }) throwIfAuthNotOk(canDelete) @@ -417,7 +436,7 @@ const resolvers: Resolvers = { const canUpdate = await ctx.authPolicies.project.savedViews.canUpdateGroup({ userId: ctx.userId, projectId, - groupId: args.input.groupId + savedViewGroupId: args.input.groupId }) throwIfAuthNotOk(canUpdate) diff --git a/packages/server/modules/viewer/graph/resolvers/viewerResources.ts b/packages/server/modules/viewer/graph/resolvers/viewerResources.ts index 1351d52a9..0d8df838f 100644 --- a/packages/server/modules/viewer/graph/resolvers/viewerResources.ts +++ b/packages/server/modules/viewer/graph/resolvers/viewerResources.ts @@ -10,6 +10,7 @@ import { } from '@/modules/core/repositories/commits' import { getStreamObjectsFactory } from '@/modules/core/repositories/objects' import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' +import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper' import { getSavedViewFactory } from '@/modules/viewer/repositories/dataLoaders/savedViews' import { getViewerResourceGroupsFactory } from '@/modules/viewer/services/viewerResources' @@ -20,7 +21,19 @@ const resolvers: Resolvers = { { resourceIdString, loadedVersionsOnly, savedViewId, savedViewSettings }, ctx ) { - const projectDB = await getProjectDbClient({ projectId: parent.id }) + const projectId = parent.id + const projectDB = await getProjectDbClient({ projectId }) + + // If savedViewId set, check for access + if (savedViewId) { + const canRead = await ctx.authPolicies.project.savedViews.canRead({ + userId: ctx.userId, + projectId, + savedViewId + }) + throwIfAuthNotOk(canRead) + } + const getStreamObjects = getStreamObjectsFactory({ db: projectDB }) const getViewerResourceGroups = getViewerResourceGroupsFactory({ getStreamObjects, diff --git a/packages/server/modules/viewer/tests/helpers/graphql.ts b/packages/server/modules/viewer/tests/helpers/graphql.ts index 5d4c8635e..2c6fb5375 100644 --- a/packages/server/modules/viewer/tests/helpers/graphql.ts +++ b/packages/server/modules/viewer/tests/helpers/graphql.ts @@ -134,6 +134,17 @@ export const getProjectSavedViewQuery = gql` ${basicSavedViewFragment} ` +export const getProjectSavedViewIfExistsQuery = gql` + query GetProjectSavedViewIfExists($projectId: String!, $viewId: ID!) { + project(id: $projectId) { + savedViewIfExists(id: $viewId) { + ...BasicSavedView + } + } + } + ${basicSavedViewFragment} +` + export const deleteSavedViewMutation = gql` mutation DeleteSavedView($input: DeleteSavedViewInput!) { projectMutations { diff --git a/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts b/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts index 921ec12de..33be87a53 100644 --- a/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts +++ b/packages/server/modules/viewer/tests/integration/savedViewsCrud.graph.spec.ts @@ -10,6 +10,7 @@ import type { DeleteSavedViewMutationVariables, GetProjectSavedViewGroupQueryVariables, GetProjectSavedViewGroupsQueryVariables, + GetProjectSavedViewIfExistsQueryVariables, GetProjectSavedViewQueryVariables, GetProjectUngroupedViewGroupQueryVariables, UpdateSavedViewGroupMutationVariables, @@ -27,6 +28,7 @@ import { GetProjectSavedViewDocument, GetProjectSavedViewGroupDocument, GetProjectSavedViewGroupsDocument, + GetProjectSavedViewIfExistsDocument, GetProjectUngroupedViewGroupDocument, UpdateSavedViewDocument, UpdateSavedViewGroupDocument @@ -64,6 +66,7 @@ import { addToStream, createTestStream } from '@/test/speckle-helpers/streamHelp import { Roles, WorkspacePlans } from '@speckle/shared' import { ProjectNotEnoughPermissionsError, + SavedViewNoAccessError, WorkspacePlanNoFeatureAccessError } from '@speckle/shared/authz' import * as ViewerRoute from '@speckle/shared/viewer/route' @@ -168,6 +171,11 @@ const fakeViewerState = (overrides?: PartialDeep apollo.execute(GetProjectSavedViewDocument, input, options) + const getViewIfExists = ( + input: GetProjectSavedViewIfExistsQueryVariables, + options?: ExecuteOperationOptions + ) => apollo.execute(GetProjectSavedViewIfExistsDocument, input, options) + const deleteView = ( input: DeleteSavedViewMutationVariables, options?: ExecuteOperationOptions @@ -1414,7 +1422,7 @@ const fakeViewerState = (overrides?: PartialDeep { @@ -1502,6 +1510,7 @@ const fakeViewerState = (overrides?: PartialDeep ViewerRoute.resourceBuilder().addResources( @@ -1509,13 +1518,27 @@ const fakeViewerState = (overrides?: PartialDeep { - otherReader = await createTestUser(buildBasicTestUser({ name: 'other-reader' })) - await assignToWorkspace( - myProjectWorkspace, - otherReader, - Roles.Workspace.Member, - WorkspaceSeatType.Editor - ) + const otherReaders = await Promise.all([ + createTestUser(buildBasicTestUser({ name: 'other-reader' })), + createTestUser(buildBasicTestUser({ name: 'other-reader-admin' })) + ]) + otherReader = otherReaders[0] + otherReaderAdmin = otherReaders[1] + + await Promise.all([ + assignToWorkspace( + myProjectWorkspace, + otherReader, + Roles.Workspace.Member, + WorkspaceSeatType.Editor + ), + assignToWorkspace( + myProjectWorkspace, + otherReaderAdmin, + Roles.Workspace.Admin, + WorkspaceSeatType.Editor + ) + ]) readTestProject = await createTestStream( buildBasicTestProject({ @@ -1525,9 +1548,14 @@ const fakeViewerState = (overrides?: PartialDeep { - const view = myFirstGroupViews[0] + it('should fail to read private view, even as workspace admin/project owner', async () => { + const view = myFirstGroupViews.find( + (v) => + v.author?.id !== otherReaderAdmin.id && + v.visibility === SavedViewVisibility.authorOnly + ) + expect(view).to.be.ok + const res = await getView( { projectId: readTestProject.id, - viewId: view.id + viewId: view!.id }, - { assertNoErrors: true } + { authUserId: otherReaderAdmin.id } ) - const data = res.data?.project.savedView - expect(data).to.be.ok - expect(data!.id).to.equal(view.id) - expect(data!.name).to.equal(view.name) - expect(data!.description).to.equal(view.description) - expect(data!.author?.id).to.equal(view.author?.id) - expect(data!.groupId).to.equal(view.groupId) - expect(data!.createdAt.toISOString()).to.equal(view.createdAt.toISOString()) - expect(data!.group.id).to.equal(myFirstGroup.id) + expect(res).to.haveGraphQLErrors({ code: ForbiddenError.code }) + expect(res.data?.project.savedView).to.not.be.ok + + const res2 = await getViewIfExists( + { + projectId: readTestProject.id, + viewId: view!.id + }, + { authUserId: otherReaderAdmin.id } + ) + + expect(res2).to.haveGraphQLErrors({ code: ForbiddenError.code }) + expect(res2.data?.project.savedViewIfExists).to.not.be.ok }) + itEach( + [{ savedViewIfExists: false }, { savedViewIfExists: true }], + ({ savedViewIfExists }) => + `should successfully read specific view (w/ ${ + savedViewIfExists ? 'savedViewIfExists' : 'savedView ' + })`, + async ({ savedViewIfExists }) => { + const view = myFirstGroupViews.find((v) => v.author?.id === me.id)! + + let data: BasicSavedViewFragment | undefined = undefined + if (savedViewIfExists) { + const res = await getViewIfExists( + { + projectId: readTestProject.id, + viewId: view.id + }, + { assertNoErrors: true } + ) + + data = res.data?.project.savedViewIfExists || undefined + } else { + const res = await getView( + { + projectId: readTestProject.id, + viewId: view.id + }, + { assertNoErrors: true } + ) + + data = res.data?.project.savedView + } + + expect(data).to.be.ok + expect(data!.id).to.equal(view.id) + expect(data!.name).to.equal(view.name) + expect(data!.description).to.equal(view.description) + expect(data!.author?.id).to.equal(view.author?.id) + expect(data!.groupId).to.equal(view.groupId) + expect(data!.createdAt.toISOString()).to.equal(view.createdAt.toISOString()) + expect(data!.group.id).to.equal(myFirstGroup.id) + } + ) + it('should get NotFoundError if trying to get nonexistant view', async () => { const res = await getView({ projectId: readTestProject.id, @@ -1942,6 +2023,16 @@ const fakeViewerState = (overrides?: PartialDeep { + const res = await getViewIfExists({ + projectId: readTestProject.id, + viewId: 'zabababababababa' + }) + + expect(res).to.not.haveGraphQLErrors() + expect(res.data?.project.savedViewIfExists).to.eq(null) + }) + it('should successfully read a group with its views', async () => { const res = await getGroup( { diff --git a/packages/shared/src/authz/domain/authErrors.ts b/packages/shared/src/authz/domain/authErrors.ts index 91d96c5bc..69f7c9e74 100644 --- a/packages/shared/src/authz/domain/authErrors.ts +++ b/packages/shared/src/authz/domain/authErrors.ts @@ -206,6 +206,11 @@ export const SavedViewNotFoundError = defineAuthError({ message: 'Saved view not found' }) +export const SavedViewNoAccessError = defineAuthError({ + code: 'SavedViewNoAccess', + message: 'You do not have access to this saved view' +}) + export const SavedViewGroupNotFoundError = defineAuthError({ code: 'SavedViewGroupNotFound', message: 'Saved view group not found' diff --git a/packages/shared/src/authz/domain/context.ts b/packages/shared/src/authz/domain/context.ts index 9f42c0325..149b449e5 100644 --- a/packages/shared/src/authz/domain/context.ts +++ b/packages/shared/src/authz/domain/context.ts @@ -14,3 +14,7 @@ export type ModelContext = { modelId: string } export type VersionContext = { versionId: string } export type AutomateFunctionContext = { functionId: string } + +export type SavedViewContext = { savedViewId: string } + +export type SavedViewGroupContext = { savedViewGroupId: string } diff --git a/packages/shared/src/authz/domain/savedViews/types.ts b/packages/shared/src/authz/domain/savedViews/types.ts index 38131bdc1..bcd967825 100644 --- a/packages/shared/src/authz/domain/savedViews/types.ts +++ b/packages/shared/src/authz/domain/savedViews/types.ts @@ -1,9 +1,15 @@ +import { StringEnum, StringEnumValues } from '../../../core/helpers/utility.js' + +export const SavedViewVisibility = StringEnum(['public', 'authorOnly']) +export type SavedViewVisibility = StringEnumValues + export type SavedView = { id: string name: string authorId: string | null groupId: string | null projectId: string + visibility: SavedViewVisibility } export type SavedViewGroup = { diff --git a/packages/shared/src/authz/fragments/savedViews.spec.ts b/packages/shared/src/authz/fragments/savedViews.spec.ts new file mode 100644 index 000000000..111ddd0a0 --- /dev/null +++ b/packages/shared/src/authz/fragments/savedViews.spec.ts @@ -0,0 +1,438 @@ +import { describe, expect, it } from 'vitest' +import { OverridesOf } from '../../tests/helpers/types.js' +import { + ensureCanAccessSavedViewFragment, + ensureCanAccessSavedViewGroupFragment +} from './savedViews.js' +import { + getEnvFake, + getProjectFake, + getSavedViewFake, + getSavedViewGroupFake, + getWorkspaceFake, + getWorkspacePlanFake +} from '../../tests/fakes.js' +import { SavedViewVisibility } from '../domain/savedViews/types.js' +import { Roles } from '../../core/constants.js' +import { + ProjectNotEnoughPermissionsError, + SavedViewGroupNotFoundError, + SavedViewNoAccessError, + SavedViewNotFoundError, + UngroupedSavedViewGroupLockError, + WorkspaceNoAccessError, + WorkspacePlanNoFeatureAccessError +} from '../domain/authErrors.js' +import { nanoid } from 'nanoid' + +const userId = 'user-id' +const savedViewId = 'saved-view-id' +const projectId = 'project-id' +const workspaceId = 'workspace-id' +const savedViewGroupId = 'saved-view-group-id' + +describe('ensureCanAccessSavedViewFragment', () => { + const buildSUT = (overrides?: OverridesOf) => + ensureCanAccessSavedViewFragment({ + getSavedView: getSavedViewFake({ + id: savedViewId, + projectId, + visibility: SavedViewVisibility.public + }), + getProject: getProjectFake({ + id: projectId, + workspaceId: null + }), + getProjectRole: async () => Roles.Stream.Contributor, + getEnv: getEnvFake({ + FF_WORKSPACES_MODULE_ENABLED: true, + FF_SAVED_VIEWS_ENABLED: true + }), + getServerRole: async () => Roles.Server.User, + getWorkspace: async () => null, + getWorkspacePlan: async () => null, + getWorkspaceRole: async () => null, + getWorkspaceSsoProvider: async () => null, + getWorkspaceSsoSession: async () => null, + ...overrides + }) + + it.each(['read', 'write'])( + 'fails when not workspaced project (%s)', + async (access) => { + const sut = buildSUT() + + const result = await sut({ + userId, + projectId, + savedViewId, + access + }) + expect(result).toBeAuthErrorResult({ + code: WorkspaceNoAccessError.code + }) + } + ) + + describe('w/ workspaced project', () => { + const buildWorkspaceSUT = ( + overrides?: OverridesOf + ) => + buildSUT({ + getProject: getProjectFake({ + id: projectId, + workspaceId + }), + getWorkspace: getWorkspaceFake({ + id: workspaceId + }), + getWorkspacePlan: getWorkspacePlanFake({ + name: 'pro' + }), + getWorkspaceRole: async () => Roles.Workspace.Member, + getWorkspaceSsoProvider: async () => null, + getWorkspaceSsoSession: async () => null, + ...overrides + }) + + it.each([ + { author: 'author', success: 'succeeds' }, + { author: 'not author', success: 'succeeds' }, + { + author: 'not author and view is private', + success: 'fails', + error: SavedViewNoAccessError.code + } + ])( + '$success if asking for read access (as $author)', + async ({ author, success, error }) => { + const isAuthor = author.startsWith('author') + const isPrivate = author.includes('view is private') + const expectSuccess = success === 'succeeds' + + const sut = buildWorkspaceSUT({ + getProjectRole: async () => null, + getSavedView: getSavedViewFake({ + id: savedViewId, + projectId, + visibility: isPrivate + ? SavedViewVisibility.authorOnly + : SavedViewVisibility.public, + authorId: isAuthor ? userId : nanoid() + }) + }) + + const result = await sut({ + userId, + projectId, + savedViewId, + access: 'read' + }) + + if (expectSuccess) { + expect(result).toBeAuthOKResult() + } else { + expect(result).toBeAuthErrorResult({ + code: error + }) + } + } + ) + + it.each([ + { author: 'author', success: 'succeeds' }, + { author: 'not author', success: 'fails', error: SavedViewNoAccessError.code }, + { + author: 'author but no longer contributor', + success: 'fails', + error: ProjectNotEnoughPermissionsError.code + }, + { + author: 'not author but is workspace admin', + success: 'fails', + error: SavedViewNoAccessError.code + } + ])( + '$success if asking for write access to private (as $author)', + async ({ author, success, error }) => { + const isAuthor = author.startsWith('author') + const isNotContributor = author.includes('no longer contributor') + const isWorkspaceAdmin = author.includes('is workspace admin') + const expectSuccess = success === 'succeeds' + + const sut = buildWorkspaceSUT({ + getSavedView: getSavedViewFake({ + id: savedViewId, + projectId, + visibility: SavedViewVisibility.public, + authorId: isAuthor ? userId : nanoid() + }), + getProjectRole: async () => + isNotContributor ? Roles.Stream.Reviewer : Roles.Stream.Contributor, + getWorkspaceRole: async () => + isWorkspaceAdmin ? Roles.Workspace.Admin : Roles.Workspace.Member + }) + + const result = await sut({ + userId, + projectId, + savedViewId, + access: 'write' + }) + + if (expectSuccess) { + expect(result).toBeAuthOKResult() + } else { + expect(result).toBeAuthErrorResult({ + code: error + }) + } + } + ) + + it.each(['read', 'write'])( + 'fails when workspace plan is too cheap (%s)', + async (access) => { + const sut = buildWorkspaceSUT({ + getWorkspacePlan: getWorkspacePlanFake({ + name: 'team' + }) + }) + + const result = await sut({ + userId, + projectId, + savedViewId, + access + }) + expect(result).toBeAuthErrorResult({ + code: WorkspacePlanNoFeatureAccessError.code + }) + } + ) + + it.each(['read', 'write'])( + 'fails if view doesnt exist (%s)', + async (access) => { + const sut = buildWorkspaceSUT({ + getSavedView: async () => null + }) + + const result = await sut({ + userId, + projectId, + savedViewId, + access + }) + expect(result).toBeAuthErrorResult({ + code: SavedViewNotFoundError.code + }) + } + ) + }) +}) + +describe('ensureCanAccessSavedViewGroupFragment', () => { + const buildSUT = ( + overrides?: OverridesOf + ) => + ensureCanAccessSavedViewGroupFragment({ + getSavedViewGroup: getSavedViewGroupFake({ + projectId, + id: savedViewGroupId + }), + getProject: getProjectFake({ + id: projectId, + workspaceId: null + }), + getProjectRole: async () => Roles.Stream.Contributor, + getEnv: getEnvFake({ + FF_WORKSPACES_MODULE_ENABLED: true, + FF_SAVED_VIEWS_ENABLED: true + }), + getServerRole: async () => Roles.Server.User, + getWorkspace: async () => null, + getWorkspacePlan: async () => null, + getWorkspaceRole: async () => null, + getWorkspaceSsoProvider: async () => null, + getWorkspaceSsoSession: async () => null, + ...overrides + }) + + it.each(['read', 'write'])( + 'fails when not workspaced project (%s)', + async (access) => { + const sut = buildSUT() + + const result = await sut({ + userId, + projectId, + savedViewGroupId, + access + }) + expect(result).toBeAuthErrorResult({ + code: WorkspaceNoAccessError.code + }) + } + ) + + describe('w/ workspaced project', () => { + const buildWorkspaceSUT = ( + overrides?: OverridesOf + ) => + buildSUT({ + getProject: getProjectFake({ + id: projectId, + workspaceId + }), + getWorkspace: getWorkspaceFake({ + id: workspaceId + }), + getWorkspacePlan: getWorkspacePlanFake({ + name: 'pro' + }), + getWorkspaceRole: async () => Roles.Workspace.Member, + getWorkspaceSsoProvider: async () => null, + getWorkspaceSsoSession: async () => null, + ...overrides + }) + + it.each([ + { author: 'author', success: 'succeeds' }, + { author: 'not author', success: 'succeeds' } + ])('$success if asking for read access (as $author)', async ({ author }) => { + const isAuthor = author.startsWith('author') + + const sut = buildWorkspaceSUT({ + getProjectRole: async () => null, + getSavedViewGroup: getSavedViewGroupFake({ + id: savedViewGroupId, + projectId, + authorId: isAuthor ? userId : nanoid() + }) + }) + + const result = await sut({ + userId, + projectId, + savedViewGroupId, + access: 'read' + }) + + expect(result).toBeAuthOKResult() + }) + + it.each([ + { author: 'author', success: 'succeeds' }, + { + author: 'not author', + success: 'fails', + error: ProjectNotEnoughPermissionsError.code + }, + { + author: 'author but no longer contributor', + success: 'fails', + error: ProjectNotEnoughPermissionsError.code + }, + { + author: 'not author but is workspace admin', + success: 'succeeds' + } + ])( + '$success if asking for write access to private (as $author)', + async ({ author, success, error }) => { + const isAuthor = author.startsWith('author') + const isNotContributor = author.includes('no longer contributor') + const isWorkspaceAdmin = author.includes('is workspace admin') + const expectSuccess = success === 'succeeds' + + const sut = buildWorkspaceSUT({ + getSavedViewGroup: getSavedViewGroupFake({ + id: savedViewId, + projectId, + authorId: isAuthor ? userId : nanoid() + }), + getProjectRole: async () => + isNotContributor ? Roles.Stream.Reviewer : Roles.Stream.Contributor, + getWorkspaceRole: async () => + isWorkspaceAdmin ? Roles.Workspace.Admin : Roles.Workspace.Member + }) + + const result = await sut({ + userId, + projectId, + savedViewGroupId, + access: 'write' + }) + + if (expectSuccess) { + expect(result).toBeAuthOKResult() + } else { + expect(result).toBeAuthErrorResult({ + code: error + }) + } + } + ) + + it('fails if writing to default group', async () => { + const sut = buildWorkspaceSUT({ + getSavedViewGroup: getSavedViewGroupFake({ + projectId: 'project-id', + id: 'default-XXX' + }) + }) + + const result = await sut({ + userId: 'user-id', + projectId: 'project-id', + savedViewGroupId: 'default-XXX', + access: 'write' + }) + + expect(result).toBeAuthErrorResult({ + code: UngroupedSavedViewGroupLockError.code + }) + }) + + it.each(['read', 'write'])( + 'fails when workspace plan is too cheap (%s)', + async (access) => { + const sut = buildWorkspaceSUT({ + getWorkspacePlan: getWorkspacePlanFake({ + name: 'team' + }) + }) + + const result = await sut({ + userId, + projectId, + savedViewGroupId, + access + }) + expect(result).toBeAuthErrorResult({ + code: WorkspacePlanNoFeatureAccessError.code + }) + } + ) + + it.each(['read', 'write'])( + 'fails if view doesnt exist (%s)', + async (access) => { + const sut = buildWorkspaceSUT({ + getSavedViewGroup: async () => null + }) + + const result = await sut({ + userId, + projectId, + savedViewGroupId, + access + }) + expect(result).toBeAuthErrorResult({ + code: SavedViewGroupNotFoundError.code + }) + } + ) + }) +}) diff --git a/packages/shared/src/authz/fragments/savedViews.ts b/packages/shared/src/authz/fragments/savedViews.ts new file mode 100644 index 000000000..e8a93dd22 --- /dev/null +++ b/packages/shared/src/authz/fragments/savedViews.ts @@ -0,0 +1,208 @@ +import { err, ok } from 'true-myth/result' +import { + ProjectNoAccessError, + ProjectNotEnoughPermissionsError, + ProjectNotFoundError, + SavedViewGroupNotFoundError, + SavedViewNoAccessError, + SavedViewNotFoundError, + ServerNoAccessError, + ServerNoSessionError, + ServerNotEnoughPermissionsError, + UngroupedSavedViewGroupLockError, + WorkspaceNoAccessError, + WorkspaceNotEnoughPermissionsError, + WorkspacePlanNoFeatureAccessError, + WorkspaceReadOnlyError, + WorkspacesNotEnabledError, + WorkspaceSsoSessionNoAccessError +} from '../domain/authErrors.js' +import { + MaybeUserContext, + ProjectContext, + SavedViewContext, + SavedViewGroupContext +} from '../domain/context.js' +import { Loaders } from '../domain/loaders.js' +import { AuthPolicyEnsureFragment } from '../domain/policies.js' +import { SavedViewVisibility } from '../domain/savedViews/types.js' +import { + ensureCanUseProjectWorkspacePlanFeatureFragment, + ensureImplicitProjectMemberWithWriteAccessFragment +} from './projects.js' +import { Roles } from '../../core/constants.js' +import { WorkspacePlanFeatures } from '../../workspaces/index.js' +import { isUngroupedGroup } from '../../saved-views/index.js' + +/** + * Ensure the user can access the view + */ +export const ensureCanAccessSavedViewFragment: AuthPolicyEnsureFragment< + | typeof Loaders.getSavedView + | typeof Loaders.getProject + | typeof Loaders.getEnv + | typeof Loaders.getServerRole + | typeof Loaders.getWorkspaceRole + | typeof Loaders.getWorkspace + | typeof Loaders.getWorkspacePlan + | typeof Loaders.getWorkspaceSsoProvider + | typeof Loaders.getWorkspaceSsoSession + | typeof Loaders.getProjectRole, + MaybeUserContext & + ProjectContext & + SavedViewContext & { + access: 'read' | 'write' + }, + InstanceType< + | typeof SavedViewNotFoundError + | typeof SavedViewNoAccessError + | typeof ProjectNotFoundError + | typeof ServerNoAccessError + | typeof ServerNoSessionError + | typeof ProjectNoAccessError + | typeof WorkspaceNoAccessError + | typeof WorkspaceSsoSessionNoAccessError + | typeof ServerNotEnoughPermissionsError + | typeof ProjectNotEnoughPermissionsError + | typeof WorkspaceNotEnoughPermissionsError + | typeof WorkspacesNotEnabledError + | typeof WorkspaceReadOnlyError + | typeof WorkspacePlanNoFeatureAccessError + > +> = + (loaders) => + async ({ userId, projectId, savedViewId, access }) => { + const canUseSavedViews = await ensureCanUseProjectWorkspacePlanFeatureFragment( + loaders + )({ + projectId, + feature: WorkspacePlanFeatures.SavedViews + }) + if (canUseSavedViews.isErr) return err(canUseSavedViews.error) + + const savedView = await loaders.getSavedView({ projectId, savedViewId }) + if (!savedView) return err(new SavedViewNotFoundError()) + + const isPublic = savedView.visibility === SavedViewVisibility.public + if (isPublic && access === 'read') { + return ok() + } + + const isAuthor = savedView.authorId === userId + if (isAuthor) { + if (access === 'write') { + // Check for write access to project first + const ensuredWriteAccess = + await ensureImplicitProjectMemberWithWriteAccessFragment(loaders)({ + userId, + projectId + }) + if (ensuredWriteAccess.isErr) { + if (ensuredWriteAccess.error.code === ProjectNotEnoughPermissionsError.code) + return err( + new ProjectNotEnoughPermissionsError({ + message: + "Your role on this project doesn't give you permission to update saved views." + }) + ) + return err(ensuredWriteAccess.error) + } + } + return ok() + } + + return err( + new SavedViewNoAccessError({ + message: + access === 'write' + ? 'You do not have write access for this saved view' + : 'You do not have read access for this saved view' + }) + ) + } + +/** + * Ensure the user can access the view group + */ +export const ensureCanAccessSavedViewGroupFragment: AuthPolicyEnsureFragment< + | typeof Loaders.getSavedViewGroup + | typeof Loaders.getProject + | typeof Loaders.getEnv + | typeof Loaders.getServerRole + | typeof Loaders.getWorkspaceRole + | typeof Loaders.getWorkspace + | typeof Loaders.getWorkspacePlan + | typeof Loaders.getWorkspaceSsoProvider + | typeof Loaders.getWorkspaceSsoSession + | typeof Loaders.getProjectRole, + MaybeUserContext & + ProjectContext & + SavedViewGroupContext & { + access: 'read' | 'write' + }, + InstanceType< + | typeof SavedViewGroupNotFoundError + | typeof ProjectNotFoundError + | typeof ServerNoAccessError + | typeof ServerNoSessionError + | typeof ProjectNoAccessError + | typeof WorkspaceNoAccessError + | typeof WorkspaceSsoSessionNoAccessError + | typeof ServerNotEnoughPermissionsError + | typeof ProjectNotEnoughPermissionsError + | typeof WorkspaceNotEnoughPermissionsError + | typeof WorkspacesNotEnabledError + | typeof WorkspaceReadOnlyError + | typeof WorkspacePlanNoFeatureAccessError + | typeof UngroupedSavedViewGroupLockError + > +> = + (loaders) => + async ({ userId, projectId, savedViewGroupId, access }) => { + const canUseSavedViews = await ensureCanUseProjectWorkspacePlanFeatureFragment( + loaders + )({ + projectId, + feature: WorkspacePlanFeatures.SavedViews + }) + if (canUseSavedViews.isErr) return err(canUseSavedViews.error) + + const savedViewGroup = await loaders.getSavedViewGroup({ + projectId, + groupId: savedViewGroupId + }) + if (!savedViewGroup) return err(new SavedViewGroupNotFoundError()) + + if (access === 'read') { + return ok() // read access available to everyone who has access to project + } + + // Prevent default group updates (as it doesnt exist) + if (isUngroupedGroup(savedViewGroup.id)) { + return err(new UngroupedSavedViewGroupLockError()) + } + + // groups have no visibility (yet), so authors AND project owners can mutate + const isAuthor = savedViewGroup.authorId === userId + const expectedProjectRole = isAuthor ? Roles.Stream.Contributor : Roles.Stream.Owner + + const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment( + loaders + )({ + userId, + projectId, + role: expectedProjectRole + }) + if (ensuredWriteAccess.isErr) { + if (ensuredWriteAccess.error.code === ProjectNotEnoughPermissionsError.code) + return err( + new ProjectNotEnoughPermissionsError({ + message: + "Your role on this project doesn't give you permission to update saved view groups." + }) + ) + return err(ensuredWriteAccess.error) + } + + return ok() + } diff --git a/packages/shared/src/authz/policies/index.ts b/packages/shared/src/authz/policies/index.ts index 6f2318b04..ef4fef4e2 100644 --- a/packages/shared/src/authz/policies/index.ts +++ b/packages/shared/src/authz/policies/index.ts @@ -37,6 +37,7 @@ import { canReadAccIntegrationSettingsPolicy } from './project/canReadAccIntegra import { canCreateSavedViewPolicy } from './project/savedViews/canCreate.js' import { canUpdateSavedViewPolicy } from './project/savedViews/canUpdate.js' import { canUpdateSavedViewGroupPolicy } from './project/savedViews/canUpdateGroup.js' +import { canReadSavedViewPolicy } from './project/savedViews/canRead.js' export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ automate: { @@ -70,7 +71,8 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ savedViews: { canCreate: canCreateSavedViewPolicy(loaders), canUpdate: canUpdateSavedViewPolicy(loaders), - canUpdateGroup: canUpdateSavedViewGroupPolicy(loaders) + canUpdateGroup: canUpdateSavedViewGroupPolicy(loaders), + canRead: canReadSavedViewPolicy(loaders) }, canBroadcastActivity: canBroadcastProjectActivityPolicy(loaders), canRead: canReadProjectPolicy(loaders), diff --git a/packages/shared/src/authz/policies/project/savedViews/canRead.ts b/packages/shared/src/authz/policies/project/savedViews/canRead.ts new file mode 100644 index 000000000..f4e1d9f07 --- /dev/null +++ b/packages/shared/src/authz/policies/project/savedViews/canRead.ts @@ -0,0 +1,63 @@ +import { + ProjectNoAccessError, + ProjectNotEnoughPermissionsError, + ProjectNotFoundError, + SavedViewNoAccessError, + SavedViewNotFoundError, + ServerNoAccessError, + ServerNoSessionError, + ServerNotEnoughPermissionsError, + WorkspaceNoAccessError, + WorkspaceNotEnoughPermissionsError, + WorkspacePlanNoFeatureAccessError, + WorkspaceReadOnlyError, + WorkspacesNotEnabledError, + WorkspaceSsoSessionNoAccessError +} from '../../../domain/authErrors.js' +import { + MaybeUserContext, + ProjectContext, + SavedViewContext +} from '../../../domain/context.js' +import { Loaders } from '../../../domain/loaders.js' +import { AuthPolicy } from '../../../domain/policies.js' +import { ensureCanAccessSavedViewFragment } from '../../../fragments/savedViews.js' + +export const canReadSavedViewPolicy: AuthPolicy< + | typeof Loaders.getSavedView + | typeof Loaders.getProject + | typeof Loaders.getEnv + | typeof Loaders.getServerRole + | typeof Loaders.getWorkspaceRole + | typeof Loaders.getWorkspace + | typeof Loaders.getWorkspacePlan + | typeof Loaders.getWorkspaceSsoProvider + | typeof Loaders.getWorkspaceSsoSession + | typeof Loaders.getProjectRole, + MaybeUserContext & ProjectContext & SavedViewContext, + InstanceType< + | typeof SavedViewNotFoundError + | typeof SavedViewNoAccessError + | typeof ProjectNotFoundError + | typeof ServerNoAccessError + | typeof ServerNoSessionError + | typeof ProjectNoAccessError + | typeof WorkspaceNoAccessError + | typeof WorkspaceSsoSessionNoAccessError + | typeof ServerNotEnoughPermissionsError + | typeof ProjectNotEnoughPermissionsError + | typeof WorkspaceNotEnoughPermissionsError + | typeof WorkspacesNotEnabledError + | typeof WorkspaceReadOnlyError + | typeof WorkspacePlanNoFeatureAccessError + > +> = + (loaders) => + async ({ userId, projectId, savedViewId }) => { + return await ensureCanAccessSavedViewFragment(loaders)({ + userId, + projectId, + savedViewId, + access: 'read' + }) + } diff --git a/packages/shared/src/authz/policies/project/savedViews/canUpdate.spec.ts b/packages/shared/src/authz/policies/project/savedViews/canUpdate.spec.ts index bf616fa9e..4e446d158 100644 --- a/packages/shared/src/authz/policies/project/savedViews/canUpdate.spec.ts +++ b/packages/shared/src/authz/policies/project/savedViews/canUpdate.spec.ts @@ -13,6 +13,7 @@ import { import { Roles } from '../../../../core/constants.js' import { ProjectNotEnoughPermissionsError, + SavedViewNoAccessError, ServerNoAccessError, WorkspaceNoAccessError, WorkspacePlanNoFeatureAccessError, @@ -23,7 +24,8 @@ describe('canUpdateSavedViewPolicy', () => { const buildSUT = (overrides?: OverridesOf) => canUpdateSavedViewPolicy({ getSavedView: getSavedViewFake({ - projectId: 'project-id' + projectId: 'project-id', + authorId: 'user-id' }), getProject: getProjectFake({ id: 'project-id', @@ -83,16 +85,18 @@ describe('canUpdateSavedViewPolicy', () => { ...overrides }) - it('works if user is project owner', async () => { - const sut = buildWorkspacedSUT() + it('doesnt work for non-author even if user is project owner', async () => { + const sut = buildWorkspacedSUT({ + getWorkspaceRole: async () => Roles.Workspace.Admin + }) const result = await sut({ - userId: 'user-id', + userId: 'user-idx', projectId: 'project-id', savedViewId: 'saved-view-id' }) - expect(result).toBeOKResult() + expect(result).toBeAuthErrorResult({ code: SavedViewNoAccessError.code }) }) it('fails if workspaces disabled', async () => { @@ -157,7 +161,7 @@ describe('canUpdateSavedViewPolicy', () => { }) const result = await sut({ - userId: 'aaa', + userId: 'user-id', projectId: 'project-id', savedViewId: 'saved-view-id' }) @@ -166,33 +170,12 @@ describe('canUpdateSavedViewPolicy', () => { }) }) - it('fails if not owner and not the author', async () => { - const sut = buildWorkspacedSUT({ - getSavedView: getSavedViewFake({ - projectId: 'project-id', - authorId: 'another-user-id' - }), - getProjectRole: async () => Roles.Stream.Contributor - }) - - const result = await sut({ - userId: 'user-id', - projectId: 'project-id', - savedViewId: 'saved-view-id' - }) - - expect(result).toBeAuthErrorResult({ - code: ProjectNotEnoughPermissionsError.code - }) - }) - - it('succeeds if not owner but author', async () => { + it('succeeds if view author', async () => { const sut = buildWorkspacedSUT({ getSavedView: getSavedViewFake({ projectId: 'project-id', authorId: 'user-id' - }), - getProjectRole: async () => Roles.Stream.Contributor + }) }) const result = await sut({ diff --git a/packages/shared/src/authz/policies/project/savedViews/canUpdate.ts b/packages/shared/src/authz/policies/project/savedViews/canUpdate.ts index 5225b896d..3df7e6f17 100644 --- a/packages/shared/src/authz/policies/project/savedViews/canUpdate.ts +++ b/packages/shared/src/authz/policies/project/savedViews/canUpdate.ts @@ -1,9 +1,8 @@ -import { Roles } from '../../../../core/constants.js' -import { WorkspacePlanFeatures } from '../../../../workspaces/index.js' import { ProjectNoAccessError, ProjectNotEnoughPermissionsError, ProjectNotFoundError, + SavedViewNoAccessError, SavedViewNotFoundError, ServerNoAccessError, ServerNoSessionError, @@ -15,14 +14,14 @@ import { WorkspacesNotEnabledError, WorkspaceSsoSessionNoAccessError } from '../../../domain/authErrors.js' -import { MaybeUserContext, ProjectContext } from '../../../domain/context.js' +import { + MaybeUserContext, + ProjectContext, + SavedViewContext +} from '../../../domain/context.js' import { Loaders } from '../../../domain/loaders.js' import { AuthPolicy } from '../../../domain/policies.js' -import { - ensureCanUseProjectWorkspacePlanFeatureFragment, - ensureImplicitProjectMemberWithWriteAccessFragment -} from '../../../fragments/projects.js' -import { err, ok } from 'true-myth/result' +import { ensureCanAccessSavedViewFragment } from '../../../fragments/savedViews.js' export const canUpdateSavedViewPolicy: AuthPolicy< | typeof Loaders.getSavedView @@ -31,15 +30,14 @@ export const canUpdateSavedViewPolicy: AuthPolicy< | typeof Loaders.getServerRole | typeof Loaders.getWorkspaceRole | typeof Loaders.getWorkspace - | typeof Loaders.getWorkspaceSsoProvider | typeof Loaders.getWorkspacePlan + | typeof Loaders.getWorkspaceSsoProvider | typeof Loaders.getWorkspaceSsoSession | typeof Loaders.getProjectRole, - MaybeUserContext & - ProjectContext & { - savedViewId: string - }, + MaybeUserContext & ProjectContext & SavedViewContext, InstanceType< + | typeof SavedViewNotFoundError + | typeof SavedViewNoAccessError | typeof ProjectNotFoundError | typeof ServerNoAccessError | typeof ServerNoSessionError @@ -49,64 +47,17 @@ export const canUpdateSavedViewPolicy: AuthPolicy< | typeof ServerNotEnoughPermissionsError | typeof ProjectNotEnoughPermissionsError | typeof WorkspaceNotEnoughPermissionsError - | typeof WorkspacePlanNoFeatureAccessError - | typeof WorkspaceReadOnlyError | typeof WorkspacesNotEnabledError - | typeof SavedViewNotFoundError + | typeof WorkspaceReadOnlyError + | typeof WorkspacePlanNoFeatureAccessError > > = (loaders) => async ({ userId, projectId, savedViewId }) => { - const canUseSavedViews = await ensureCanUseProjectWorkspacePlanFeatureFragment( - loaders - )({ - projectId, - feature: WorkspacePlanFeatures.SavedViews - }) - if (canUseSavedViews.isErr) return err(canUseSavedViews.error) - - const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment( - loaders - )({ + return await ensureCanAccessSavedViewFragment(loaders)({ userId, projectId, - role: Roles.Stream.Contributor + savedViewId, + access: 'write' }) - if (ensuredWriteAccess.isErr) { - if (ensuredWriteAccess.error.code === ProjectNotEnoughPermissionsError.code) - return err( - new ProjectNotEnoughPermissionsError({ - message: - "Your role on this project doesn't give you permission to update saved views. You need to be the author of the view or the Project owner." - }) - ) - return err(ensuredWriteAccess.error) - } - - // Even if user has access to project - must be author OR project admin - const savedView = await loaders.getSavedView({ - projectId, - savedViewId - }) - if (!savedView) return err(new SavedViewNotFoundError()) - - if (savedView.authorId !== userId) { - const ensuredWriteAccess = - await ensureImplicitProjectMemberWithWriteAccessFragment(loaders)({ - userId, - projectId, - role: Roles.Stream.Owner - }) - if (ensuredWriteAccess.isErr) { - if (ensuredWriteAccess.error.code === ProjectNotEnoughPermissionsError.code) - return err( - new ProjectNotEnoughPermissionsError({ - message: "Only project owners can update saved views they don't own." - }) - ) - return err(ensuredWriteAccess.error) - } - } - - return ok() } diff --git a/packages/shared/src/authz/policies/project/savedViews/canUpdateGroup.spec.ts b/packages/shared/src/authz/policies/project/savedViews/canUpdateGroup.spec.ts index 4a9262262..fff278d63 100644 --- a/packages/shared/src/authz/policies/project/savedViews/canUpdateGroup.spec.ts +++ b/packages/shared/src/authz/policies/project/savedViews/canUpdateGroup.spec.ts @@ -50,7 +50,7 @@ describe('canUpdateSavedViewGroupPolicy', () => { const result = await policy({ userId: 'user-id', projectId: 'project-id', - groupId: 'saved-group-id' + savedViewGroupId: 'saved-group-id' }) expect(result).toBeAuthErrorResult({ @@ -90,7 +90,7 @@ describe('canUpdateSavedViewGroupPolicy', () => { const result = await sut({ userId: 'user-id', projectId: 'project-id', - groupId: 'saved-group-id' + savedViewGroupId: 'saved-group-id' }) expect(result).toBeOKResult() @@ -107,7 +107,7 @@ describe('canUpdateSavedViewGroupPolicy', () => { const result = await sut({ userId: 'user-id', projectId: 'project-id', - groupId: 'saved-group-id' + savedViewGroupId: 'saved-group-id' }) expect(result).toBeAuthErrorResult({ @@ -126,7 +126,7 @@ describe('canUpdateSavedViewGroupPolicy', () => { const result = await sut({ userId: 'user-id', projectId: 'project-id', - groupId: 'saved-group-id' + savedViewGroupId: 'saved-group-id' }) expect(result).toBeAuthErrorResult({ @@ -142,7 +142,7 @@ describe('canUpdateSavedViewGroupPolicy', () => { const result = await sut({ userId: 'user-id', projectId: 'project-id', - groupId: 'saved-group-id' + savedViewGroupId: 'saved-group-id' }) expect(result).toBeAuthErrorResult({ @@ -160,7 +160,7 @@ describe('canUpdateSavedViewGroupPolicy', () => { const result = await sut({ userId: 'aaa', projectId: 'project-id', - groupId: 'saved-group-id' + savedViewGroupId: 'saved-group-id' }) expect(result).toBeAuthErrorResult({ code: ServerNoAccessError.code @@ -179,7 +179,7 @@ describe('canUpdateSavedViewGroupPolicy', () => { const result = await sut({ userId: 'user-id', projectId: 'project-id', - groupId: 'saved-group-id' + savedViewGroupId: 'saved-group-id' }) expect(result).toBeAuthErrorResult({ @@ -198,7 +198,7 @@ describe('canUpdateSavedViewGroupPolicy', () => { const result = await sut({ userId: 'user-id', projectId: 'project-id', - groupId: 'default-XXX' + savedViewGroupId: 'default-XXX' }) expect(result).toBeAuthErrorResult({ @@ -218,7 +218,7 @@ describe('canUpdateSavedViewGroupPolicy', () => { const result = await sut({ userId: 'user-id', projectId: 'project-id', - groupId: 'saved-group-id' + savedViewGroupId: 'saved-group-id' }) expect(result).toBeOKResult() diff --git a/packages/shared/src/authz/policies/project/savedViews/canUpdateGroup.ts b/packages/shared/src/authz/policies/project/savedViews/canUpdateGroup.ts index 7a2a115df..478b45619 100644 --- a/packages/shared/src/authz/policies/project/savedViews/canUpdateGroup.ts +++ b/packages/shared/src/authz/policies/project/savedViews/canUpdateGroup.ts @@ -1,6 +1,3 @@ -import { Roles } from '../../../../core/constants.js' -import { isUngroupedGroup } from '../../../../saved-views/index.js' -import { WorkspacePlanFeatures } from '../../../../workspaces/index.js' import { ProjectNoAccessError, ProjectNotEnoughPermissionsError, @@ -17,14 +14,14 @@ import { WorkspacesNotEnabledError, WorkspaceSsoSessionNoAccessError } from '../../../domain/authErrors.js' -import { MaybeUserContext, ProjectContext } from '../../../domain/context.js' +import { + MaybeUserContext, + ProjectContext, + SavedViewGroupContext +} from '../../../domain/context.js' import { Loaders } from '../../../domain/loaders.js' import { AuthPolicy } from '../../../domain/policies.js' -import { - ensureCanUseProjectWorkspacePlanFeatureFragment, - ensureImplicitProjectMemberWithWriteAccessFragment -} from '../../../fragments/projects.js' -import { err, ok } from 'true-myth/result' +import { ensureCanAccessSavedViewGroupFragment } from '../../../fragments/savedViews.js' export const canUpdateSavedViewGroupPolicy: AuthPolicy< | typeof Loaders.getSavedViewGroup @@ -33,15 +30,13 @@ export const canUpdateSavedViewGroupPolicy: AuthPolicy< | typeof Loaders.getServerRole | typeof Loaders.getWorkspaceRole | typeof Loaders.getWorkspace - | typeof Loaders.getWorkspaceSsoProvider | typeof Loaders.getWorkspacePlan + | typeof Loaders.getWorkspaceSsoProvider | typeof Loaders.getWorkspaceSsoSession | typeof Loaders.getProjectRole, - MaybeUserContext & - ProjectContext & { - groupId: string - }, + MaybeUserContext & ProjectContext & SavedViewGroupContext, InstanceType< + | typeof SavedViewGroupNotFoundError | typeof ProjectNotFoundError | typeof ServerNoAccessError | typeof ServerNoSessionError @@ -51,71 +46,18 @@ export const canUpdateSavedViewGroupPolicy: AuthPolicy< | typeof ServerNotEnoughPermissionsError | typeof ProjectNotEnoughPermissionsError | typeof WorkspaceNotEnoughPermissionsError - | typeof WorkspacePlanNoFeatureAccessError - | typeof WorkspaceReadOnlyError | typeof WorkspacesNotEnabledError - | typeof SavedViewGroupNotFoundError + | typeof WorkspaceReadOnlyError + | typeof WorkspacePlanNoFeatureAccessError | typeof UngroupedSavedViewGroupLockError > > = (loaders) => - async ({ userId, projectId, groupId }) => { - const canUseSavedViews = await ensureCanUseProjectWorkspacePlanFeatureFragment( - loaders - )({ - projectId, - feature: WorkspacePlanFeatures.SavedViews - }) - if (canUseSavedViews.isErr) return err(canUseSavedViews.error) - - const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment( - loaders - )({ + async ({ userId, projectId, savedViewGroupId }) => { + return await ensureCanAccessSavedViewGroupFragment(loaders)({ userId, projectId, - role: Roles.Stream.Contributor + savedViewGroupId, + access: 'write' }) - if (ensuredWriteAccess.isErr) { - if (ensuredWriteAccess.error.code === ProjectNotEnoughPermissionsError.code) - return err( - new ProjectNotEnoughPermissionsError({ - message: - "Your role on this project doesn't give you permission to update saved views. You need to be the author of the view or the Project owner." - }) - ) - return err(ensuredWriteAccess.error) - } - - // Even if user has access to project - must be author OR project admin - const savedViewGroup = await loaders.getSavedViewGroup({ - projectId, - groupId - }) - if (!savedViewGroup) return err(new SavedViewGroupNotFoundError()) - - // Prevent default group updates (as it doesnt exist) - if (isUngroupedGroup(savedViewGroup.id)) { - return err(new UngroupedSavedViewGroupLockError()) - } - - if (savedViewGroup.authorId !== userId) { - const ensuredWriteAccess = - await ensureImplicitProjectMemberWithWriteAccessFragment(loaders)({ - userId, - projectId, - role: Roles.Stream.Owner - }) - if (ensuredWriteAccess.isErr) { - if (ensuredWriteAccess.error.code === ProjectNotEnoughPermissionsError.code) - return err( - new ProjectNotEnoughPermissionsError({ - message: - "Only project owners can update saved view groups they don't own." - }) - ) - return err(ensuredWriteAccess.error) - } - } - - return ok() } diff --git a/packages/shared/src/tests/fakes.ts b/packages/shared/src/tests/fakes.ts index 5827ba201..e4edccc35 100644 --- a/packages/shared/src/tests/fakes.ts +++ b/packages/shared/src/tests/fakes.ts @@ -13,7 +13,11 @@ import { FeatureFlags, parseFeatureFlags } from '../environment/index.js' import { mapValues } from 'lodash' import { WorkspacePlan } from '../workspaces/index.js' import { TIME_MS } from '../core/index.js' -import { SavedView, SavedViewGroup } from '../authz/domain/savedViews/types.js' +import { + SavedView, + SavedViewGroup, + SavedViewVisibility +} from '../authz/domain/savedViews/types.js' export const fakeGetFactory = >(defaults: () => T) => @@ -81,7 +85,8 @@ export const getSavedViewFake = fakeGetFactory(() => ({ name: nanoid(10), authorId: nanoid(10), projectId: nanoid(10), - groupId: null + groupId: null, + visibility: SavedViewVisibility.public })) export const getSavedViewGroupFake = fakeGetFactory(() => ({