feat: tightening up saved views permissions (#5239)

* updated auth policies

* added auth checks to resolvers

* tests for single view resolvers
This commit is contained in:
Kristaps Fabians Geikins
2025-08-14 12:45:08 +03:00
committed by GitHub
parent a7bebb0882
commit d013fe1dd7
19 changed files with 962 additions and 211 deletions
@@ -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<string>, isHomeView: boolean, visibility: SavedViewVisibility, viewerState: Record<string, unknown>, 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<string>, isHomeView: boolean, visibility: SavedViewVisibility, viewerState: Record<string, unknown>, 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<GetProjectSavedViewGroupQuery, GetProjectSavedViewGroupQueryVariables>;
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<GetProjectUngroupedViewGroupQuery, GetProjectUngroupedViewGroupQueryVariables>;
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<GetProjectSavedViewQuery, GetProjectSavedViewQueryVariables>;
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<GetProjectSavedViewIfExistsQuery, GetProjectSavedViewIfExistsQueryVariables>;
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<DeleteSavedViewMutation, DeleteSavedViewMutationVariables>;
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<CanCreateSavedViewQuery, CanCreateSavedViewQueryVariables>;
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<CanUpdateSavedViewQuery, CanUpdateSavedViewQueryVariables>;
@@ -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, {
@@ -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)
}
@@ -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)
@@ -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,
@@ -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 {
@@ -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<ViewerState.SerializedViewerSta
options?: ExecuteOperationOptions
) => 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<ViewerState.SerializedViewerSta
const data = res.data?.project.savedView.permissions.canUpdate
expect(data?.authorized).to.be.false
expect(data?.code).to.equal(ProjectNotEnoughPermissionsError.code)
expect(data?.code).to.equal(SavedViewNoAccessError.code)
})
describe('of groups', async () => {
@@ -1502,6 +1510,7 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
const modelIds: string[] = []
let readTestProject: BasicTestStream
let otherReader: BasicTestUser
let otherReaderAdmin: BasicTestUser
const getAllReadModelResourceIds = () =>
ViewerRoute.resourceBuilder().addResources(
@@ -1509,13 +1518,27 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
)
before(async () => {
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<ViewerState.SerializedViewerSta
me
)
await addToStream(readTestProject, otherReader, Roles.Stream.Contributor, {
owner: me
})
await Promise.all([
addToStream(readTestProject, otherReader, Roles.Stream.Contributor, {
owner: me
}),
addToStream(readTestProject, otherReaderAdmin, Roles.Stream.Owner, {
owner: me
})
])
// Create a bunch of groups (views w/ groupNames), each w/ a different model
let includedSearchString = 0
@@ -1911,27 +1939,80 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
}
})
it('should successfully read specific view', async () => {
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<ViewerState.SerializedViewerSta
expect(res.data?.project.savedView).to.not.be.ok
})
it('should not get errors if trying to get nonexistant view through savedViewIfExists', async () => {
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(
{
@@ -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'
@@ -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 }
@@ -1,9 +1,15 @@
import { StringEnum, StringEnumValues } from '../../../core/helpers/utility.js'
export const SavedViewVisibility = StringEnum(['public', 'authorOnly'])
export type SavedViewVisibility = StringEnumValues<typeof SavedViewVisibility>
export type SavedView = {
id: string
name: string
authorId: string | null
groupId: string | null
projectId: string
visibility: SavedViewVisibility
}
export type SavedViewGroup = {
@@ -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<typeof ensureCanAccessSavedViewFragment>) =>
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(<const>['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<typeof ensureCanAccessSavedViewFragment>
) =>
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(<const>[
{ 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(<const>[
{ 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(<const>['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(<const>['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<typeof ensureCanAccessSavedViewGroupFragment>
) =>
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(<const>['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<typeof ensureCanAccessSavedViewGroupFragment>
) =>
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(<const>[
{ 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(<const>[
{ 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(<const>['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(<const>['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
})
}
)
})
})
@@ -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()
}
+3 -1
View File
@@ -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),
@@ -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'
})
}
@@ -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<typeof canUpdateSavedViewPolicy>) =>
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({
@@ -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()
}
@@ -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()
@@ -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()
}
+7 -2
View File
@@ -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 =
<T extends Record<string, unknown>>(defaults: () => T) =>
@@ -81,7 +85,8 @@ export const getSavedViewFake = fakeGetFactory<SavedView>(() => ({
name: nanoid(10),
authorId: nanoid(10),
projectId: nanoid(10),
groupId: null
groupId: null,
visibility: SavedViewVisibility.public
}))
export const getSavedViewGroupFake = fakeGetFactory<SavedViewGroup>(() => ({