From cb7243cfbea64efb5454e90da2d5aed8fa3ae0c6 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Tue, 8 Apr 2025 15:29:12 +0100 Subject: [PATCH] feat(authz): `Project.canCreateModel` and `Project.canMoveToWorkspace` policies (#4342) * feat(authz): Project.canCreateModel policy * feat(authz): Project.canMoveToWorkspace policy * fix(authz): expose policies as permissions objects * chore(authz): actually use the policies lol * chore(authz): add tests for new policies * fix(authz): skip affected test * fix(authz): pr comments * fix(authz): better errors, better tests * chore(authz): remove references to deleted error --- .../assets/core/typedefs/permissions.graphql | 2 + .../typedefs/permissions.graphql | 1 + .../modules/core/graph/generated/graphql.ts | 16 ++ .../modules/core/graph/resolvers/models.ts | 16 +- .../core/graph/resolvers/permissions.ts | 15 ++ .../graph/generated/graphql.ts | 13 ++ .../gatekeeper/graph/resolvers/index.ts | 34 ++-- .../modules/shared/helpers/errorHelper.ts | 1 + .../modules/workspaces/authz/loaders/index.ts | 20 +++ .../modules/workspaces/domain/operations.ts | 4 + .../workspaces/graph/resolvers/permissions.ts | 9 ++ .../workspaces/graph/resolvers/workspaces.ts | 26 ++- .../workspaces/services/workspaceLimits.ts | 29 ++++ .../workspacesCore/authz/loaders/index.ts | 3 + .../modules/workspacesCore/errors/index.ts | 6 + .../server/test/graphql/generated/graphql.ts | 13 ++ .../src/authz/checks/workspaceRole.spec.ts | 10 +- .../shared/src/authz/checks/workspaceRole.ts | 2 +- .../shared/src/authz/domain/authErrors.ts | 5 + packages/shared/src/authz/domain/loaders.ts | 3 + .../src/authz/domain/workspaces/operations.ts | 2 + .../shared/src/authz/fragments/workspaces.ts | 20 +-- packages/shared/src/authz/policies/index.ts | 4 + .../policies/project/canCreateModel.spec.ts | 148 ++++++++++++++++++ .../authz/policies/project/canCreateModel.ts | 102 ++++++++++++ .../project/canMoveToWorkspace.spec.ts | 142 +++++++++++++++++ .../policies/project/canMoveToWorkspace.ts | 111 +++++++++++++ .../workspace/canCreateWorkspaceProject.ts | 4 +- 28 files changed, 701 insertions(+), 60 deletions(-) create mode 100644 packages/server/modules/workspaces/services/workspaceLimits.ts create mode 100644 packages/shared/src/authz/policies/project/canCreateModel.spec.ts create mode 100644 packages/shared/src/authz/policies/project/canCreateModel.ts create mode 100644 packages/shared/src/authz/policies/project/canMoveToWorkspace.spec.ts create mode 100644 packages/shared/src/authz/policies/project/canMoveToWorkspace.ts diff --git a/packages/server/assets/core/typedefs/permissions.graphql b/packages/server/assets/core/typedefs/permissions.graphql index 4b2a4b9d5..e61c9fcb3 100644 --- a/packages/server/assets/core/typedefs/permissions.graphql +++ b/packages/server/assets/core/typedefs/permissions.graphql @@ -3,6 +3,8 @@ extend type Project { } type ProjectPermissionChecks { + canCreateModel: PermissionCheckResult! + canMoveToWorkspace(workspaceId: String!): PermissionCheckResult! canRead: PermissionCheckResult! } diff --git a/packages/server/assets/workspacesCore/typedefs/permissions.graphql b/packages/server/assets/workspacesCore/typedefs/permissions.graphql index 9ae487f24..3c690514f 100644 --- a/packages/server/assets/workspacesCore/typedefs/permissions.graphql +++ b/packages/server/assets/workspacesCore/typedefs/permissions.graphql @@ -4,4 +4,5 @@ extend type Workspace { type WorkspacePermissionChecks { canCreateProject: PermissionCheckResult! + canMoveProjectToWorkspace(projectId: String!): PermissionCheckResult! } diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 864a91806..cbd367085 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -2560,9 +2560,16 @@ export const ProjectPendingVersionsUpdatedMessageType = { export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVersionsUpdatedMessageType[keyof typeof ProjectPendingVersionsUpdatedMessageType]; export type ProjectPermissionChecks = { __typename?: 'ProjectPermissionChecks'; + canCreateModel: PermissionCheckResult; + canMoveToWorkspace: PermissionCheckResult; canRead: PermissionCheckResult; }; + +export type ProjectPermissionChecksCanMoveToWorkspaceArgs = { + workspaceId: Scalars['String']['input']; +}; + export type ProjectRole = { __typename?: 'ProjectRole'; project: Project; @@ -4764,6 +4771,12 @@ export type WorkspacePaymentMethod = typeof WorkspacePaymentMethod[keyof typeof export type WorkspacePermissionChecks = { __typename?: 'WorkspacePermissionChecks'; canCreateProject: PermissionCheckResult; + canMoveProjectToWorkspace: PermissionCheckResult; +}; + + +export type WorkspacePermissionChecksCanMoveProjectToWorkspaceArgs = { + projectId: Scalars['String']['input']; }; export type WorkspacePlan = { @@ -6641,6 +6654,8 @@ export type ProjectPendingVersionsUpdatedMessageResolvers = { + canCreateModel?: Resolver; + canMoveToWorkspace?: Resolver>; canRead?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; @@ -7350,6 +7365,7 @@ export type WorkspaceMutationsResolvers = { canCreateProject?: Resolver; + canMoveProjectToWorkspace?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/server/modules/core/graph/resolvers/models.ts b/packages/server/modules/core/graph/resolvers/models.ts index 91c40dee9..f96919bee 100644 --- a/packages/server/modules/core/graph/resolvers/models.ts +++ b/packages/server/modules/core/graph/resolvers/models.ts @@ -58,6 +58,7 @@ import { getRegisteredRegionClients } from '@/modules/multiregion/utils/dbSelector' import { getEventBus } from '@/modules/shared/services/eventBus' +import { mapAuthToServerError } from '@/modules/shared/helpers/errorHelper' export = { User: { @@ -296,12 +297,15 @@ export = { }, ModelMutations: { async create(_parent, args, ctx) { - await authorizeResolver( - ctx.userId, - args.input.projectId, - Roles.Stream.Contributor, - ctx.resourceAccessRules - ) + const canCreate = await ctx.authPolicies.project.canCreateModel({ + userId: ctx.userId, + projectId: args.input.projectId + }) + + if (!canCreate.isOk) { + throw mapAuthToServerError(canCreate.error) + } + const projectDB = await getProjectDbClient({ projectId: args.input.projectId }) // Sanitize model name by trimming spaces around slashes diff --git a/packages/server/modules/core/graph/resolvers/permissions.ts b/packages/server/modules/core/graph/resolvers/permissions.ts index 30338baa8..ec680444a 100644 --- a/packages/server/modules/core/graph/resolvers/permissions.ts +++ b/packages/server/modules/core/graph/resolvers/permissions.ts @@ -9,6 +9,21 @@ export default { permissions: () => ({}) }, ProjectPermissionChecks: { + canCreateModel: async (parent, _args, ctx) => { + const canCreateModel = await ctx.authPolicies.project.canCreateModel({ + userId: ctx.userId, + projectId: parent.projectId + }) + return Authz.toGraphqlResult(canCreateModel) + }, + canMoveToWorkspace: async (parent, args, ctx) => { + const canMoveToWorkspace = await ctx.authPolicies.project.canMoveToWorkspace({ + userId: ctx.userId, + projectId: parent.projectId, + workspaceId: args.workspaceId + }) + return Authz.toGraphqlResult(canMoveToWorkspace) + }, canRead: async (parent, _args, ctx) => { const canRead = await ctx.authPolicies.project.canRead({ projectId: parent.projectId, diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index 083ede612..de727c7f7 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -2540,9 +2540,16 @@ export const ProjectPendingVersionsUpdatedMessageType = { export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVersionsUpdatedMessageType[keyof typeof ProjectPendingVersionsUpdatedMessageType]; export type ProjectPermissionChecks = { __typename?: 'ProjectPermissionChecks'; + canCreateModel: PermissionCheckResult; + canMoveToWorkspace: PermissionCheckResult; canRead: PermissionCheckResult; }; + +export type ProjectPermissionChecksCanMoveToWorkspaceArgs = { + workspaceId: Scalars['String']['input']; +}; + export type ProjectRole = { __typename?: 'ProjectRole'; project: Project; @@ -4744,6 +4751,12 @@ export type WorkspacePaymentMethod = typeof WorkspacePaymentMethod[keyof typeof export type WorkspacePermissionChecks = { __typename?: 'WorkspacePermissionChecks'; canCreateProject: PermissionCheckResult; + canMoveProjectToWorkspace: PermissionCheckResult; +}; + + +export type WorkspacePermissionChecksCanMoveProjectToWorkspaceArgs = { + projectId: Scalars['String']['input']; }; export type WorkspacePlan = { diff --git a/packages/server/modules/gatekeeper/graph/resolvers/index.ts b/packages/server/modules/gatekeeper/graph/resolvers/index.ts index 8c4bb456f..c4cc0a37e 100644 --- a/packages/server/modules/gatekeeper/graph/resolvers/index.ts +++ b/packages/server/modules/gatekeeper/graph/resolvers/index.ts @@ -65,6 +65,7 @@ import { getEventBus } from '@/modules/shared/services/eventBus' import { getTotalSeatsCountByPlanFactory } from '@/modules/gatekeeper/services/subscriptions' import { queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects' import { legacyGetStreamsFactory } from '@/modules/core/repositories/streams' +import { getWorkspaceModelCountFactory } from '@/modules/workspaces/services/workspaceLimits' import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' import { getPaginatedProjectModelsTotalCountFactory } from '@/modules/core/repositories/branches' @@ -184,29 +185,18 @@ export = FF_GATEKEEPER_MODULE_ENABLED modelCount: async (parent) => { const { workspaceId } = parent - let modelCount = 0 - - const queryAllWorkspaceProjects = queryAllWorkspaceProjectsFactory({ - getStreams: legacyGetStreamsFactory({ db }) - }) - - for await (const projects of queryAllWorkspaceProjects({ workspaceId })) { - for (const project of projects) { - const regionDb = await getProjectDbClient({ projectId: project.id }) - const projectModelCount = - await getPaginatedProjectModelsTotalCountFactory({ db: regionDb })( - project.id, - { - filter: { - onlyWithVersions: true - } - } - ) - modelCount = modelCount + projectModelCount + return await getWorkspaceModelCountFactory({ + queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ + getStreams: legacyGetStreamsFactory({ db }) + }), + getPaginatedProjectModelsTotalCount: async (projectId, params) => { + const regionDb = await getProjectDbClient({ projectId }) + return await getPaginatedProjectModelsTotalCountFactory({ db: regionDb })( + projectId, + params + ) } - } - - return modelCount + })({ workspaceId }) } }, WorkspaceSubscription: { diff --git a/packages/server/modules/shared/helpers/errorHelper.ts b/packages/server/modules/shared/helpers/errorHelper.ts index 5fcfc722b..91a33c8ba 100644 --- a/packages/server/modules/shared/helpers/errorHelper.ts +++ b/packages/server/modules/shared/helpers/errorHelper.ts @@ -33,6 +33,7 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => { case Authz.WorkspaceReadOnlyError.code: case Authz.WorkspaceLimitsReachedError.code: case Authz.WorkspaceNoEditorSeatError.code: + case Authz.WorkspaceProjectMoveInvalidError.code: return new ForbiddenError(e.message) case Authz.WorkspaceSsoSessionNoAccessError.code: throw new SsoSessionMissingOrExpiredError(e.message, { diff --git a/packages/server/modules/workspaces/authz/loaders/index.ts b/packages/server/modules/workspaces/authz/loaders/index.ts index 0ba8065fb..0c26f1d94 100644 --- a/packages/server/modules/workspaces/authz/loaders/index.ts +++ b/packages/server/modules/workspaces/authz/loaders/index.ts @@ -1,11 +1,16 @@ import { db } from '@/db/knex' +import { getPaginatedProjectModelsTotalCountFactory } from '@/modules/core/repositories/branches' +import { legacyGetStreamsFactory } from '@/modules/core/repositories/streams' import { getWorkspacePlanFactory } from '@/modules/gatekeeper/repositories/billing' import { defineModuleLoaders } from '@/modules/loaders' +import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector' import { getUserSsoSessionFactory, getWorkspaceSsoProviderRecordFactory } from '@/modules/workspaces/repositories/sso' import { getWorkspaceRoleForUserFactory } from '@/modules/workspaces/repositories/workspaces' +import { queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects' +import { getWorkspaceModelCountFactory } from '@/modules/workspaces/services/workspaceLimits' import { WorkspacePaidPlanConfigs, WorkspaceUnpaidPlanConfigs } from '@speckle/shared' // TODO: Move everything to use dataLoaders @@ -46,6 +51,21 @@ export default defineModuleLoaders(async () => { )?.type || null ) }, + getWorkspaceModelCount: async ({ workspaceId }) => { + // TODO: Dataloader that has to dynamically pick regional dbs? + return await getWorkspaceModelCountFactory({ + queryAllWorkspaceProjects: queryAllWorkspaceProjectsFactory({ + getStreams: legacyGetStreamsFactory({ db }) + }), + getPaginatedProjectModelsTotalCount: async (projectId, params) => { + const regionDb = await getProjectDbClient({ projectId }) + return await getPaginatedProjectModelsTotalCountFactory({ db: regionDb })( + projectId, + params + ) + } + })({ workspaceId }) + }, getWorkspaceProjectCount: async ({ workspaceId }, { dataLoaders }) => { return await dataLoaders.workspaces!.getProjectCount.load(workspaceId) }, diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index faeeb16a5..fc11e280d 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -241,6 +241,10 @@ export type GetWorkspacesProjectsCounts = (params: { [workspaceId: string]: number }> +export type GetWorkspaceModelCount = (params: { + workspaceId: string +}) => Promise + export type GetPaginatedWorkspaceProjectsArgs = { workspaceId: string /** diff --git a/packages/server/modules/workspaces/graph/resolvers/permissions.ts b/packages/server/modules/workspaces/graph/resolvers/permissions.ts index 024ff241b..a4ed2f8bc 100644 --- a/packages/server/modules/workspaces/graph/resolvers/permissions.ts +++ b/packages/server/modules/workspaces/graph/resolvers/permissions.ts @@ -14,6 +14,15 @@ export default { userId: ctx.userId }) return Authz.toGraphqlResult(canCreateProject) + }, + canMoveProjectToWorkspace: async (parent, args, ctx) => { + const canMoveProjectToWorkspace = + await ctx.authPolicies.project.canMoveToWorkspace({ + userId: ctx.userId, + projectId: args.projectId, + workspaceId: parent.workspaceId + }) + return Authz.toGraphqlResult(canMoveProjectToWorkspace) } } } as Resolvers diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index 8863480cc..04ca4cfc4 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -193,7 +193,6 @@ import { getProjectFactory } from '@/modules/core/repositories/projects' import { getProjectRegionKey } from '@/modules/multiregion/utils/regionSelector' import { scheduleJob } from '@/modules/multiregion/services/queue' import { updateWorkspacePlanFactory } from '@/modules/gatekeeper/services/workspacePlans' -import { OperationTypeNode } from 'graphql' import { GetWorkspaceCollaboratorsArgs } from '@/modules/workspaces/domain/operations' import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types' import { UsersMeta } from '@/modules/core/dbSchema' @@ -209,6 +208,7 @@ import { getWorkspaceRolesAndSeatsFactory, getWorkspaceUserSeatFactory } from '@/modules/gatekeeper/repositories/workspaceSeat' +import { mapAuthToServerError } from '@/modules/shared/helpers/errorHelper' const eventBus = getEventBus() const getServerInfo = getServerInfoFactory({ db }) @@ -1057,20 +1057,16 @@ export = FF_WORKSPACES_MODULE_ENABLED moveToWorkspace: async (_parent, args, context) => { const { projectId, workspaceId } = args - await authorizeResolver( - context.userId, - projectId, - Roles.Stream.Owner, - context.resourceAccessRules, - OperationTypeNode.MUTATION - ) - await authorizeResolver( - context.userId, - workspaceId, - Roles.Workspace.Admin, - context.resourceAccessRules, - OperationTypeNode.MUTATION - ) + const canMoveToWorkspace = + await context.authPolicies.project.canMoveToWorkspace({ + userId: context.userId, + projectId, + workspaceId + }) + + if (!canMoveToWorkspace.isOk) { + throw mapAuthToServerError(canMoveToWorkspace.error) + } const moveProjectToWorkspace = commandFactory({ db, diff --git a/packages/server/modules/workspaces/services/workspaceLimits.ts b/packages/server/modules/workspaces/services/workspaceLimits.ts new file mode 100644 index 000000000..7e204e10e --- /dev/null +++ b/packages/server/modules/workspaces/services/workspaceLimits.ts @@ -0,0 +1,29 @@ +import { GetPaginatedProjectModelsTotalCount } from '@/modules/core/domain/branches/operations' +import { + GetWorkspaceModelCount, + QueryAllWorkspaceProjects +} from '@/modules/workspaces/domain/operations' + +// TODO: Optimize with single model count query per regional db +export const getWorkspaceModelCountFactory = + (deps: { + queryAllWorkspaceProjects: QueryAllWorkspaceProjects + getPaginatedProjectModelsTotalCount: GetPaginatedProjectModelsTotalCount + }): GetWorkspaceModelCount => + async ({ workspaceId }) => { + let modelCount = 0 + + for await (const projects of deps.queryAllWorkspaceProjects({ workspaceId })) { + for (const project of projects) { + modelCount = + modelCount + + (await deps.getPaginatedProjectModelsTotalCount(project.id, { + filter: { + onlyWithVersions: true + } + })) + } + } + + return modelCount + } diff --git a/packages/server/modules/workspacesCore/authz/loaders/index.ts b/packages/server/modules/workspacesCore/authz/loaders/index.ts index 30f9a85e1..8514991c8 100644 --- a/packages/server/modules/workspacesCore/authz/loaders/index.ts +++ b/packages/server/modules/workspacesCore/authz/loaders/index.ts @@ -17,6 +17,9 @@ export default defineModuleLoaders(() => ({ getWorkspaceSeat: async () => { throw new LoaderUnsupportedError() }, + getWorkspaceModelCount: async () => { + throw new LoaderUnsupportedError() + }, getWorkspaceProjectCount: async () => { throw new LoaderUnsupportedError() }, diff --git a/packages/server/modules/workspacesCore/errors/index.ts b/packages/server/modules/workspacesCore/errors/index.ts index 222dd9c34..02d58fe20 100644 --- a/packages/server/modules/workspacesCore/errors/index.ts +++ b/packages/server/modules/workspacesCore/errors/index.ts @@ -8,3 +8,9 @@ export class SsoSessionMissingOrExpiredError extends BaseError<{ static code = 'SSO_SESSION_MISSING_OR_EXPIRED_ERROR' static statusCode = 401 } + +export class WorkspaceRequiredError extends BaseError { + static defaultMessage = 'This action requires a workspace.' + static code = 'WORKSPACE_REQUIRED_ERROR' + static statusCode = 400 +} diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index cece0595f..6cb9198a2 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -2541,9 +2541,16 @@ export const ProjectPendingVersionsUpdatedMessageType = { export type ProjectPendingVersionsUpdatedMessageType = typeof ProjectPendingVersionsUpdatedMessageType[keyof typeof ProjectPendingVersionsUpdatedMessageType]; export type ProjectPermissionChecks = { __typename?: 'ProjectPermissionChecks'; + canCreateModel: PermissionCheckResult; + canMoveToWorkspace: PermissionCheckResult; canRead: PermissionCheckResult; }; + +export type ProjectPermissionChecksCanMoveToWorkspaceArgs = { + workspaceId: Scalars['String']['input']; +}; + export type ProjectRole = { __typename?: 'ProjectRole'; project: Project; @@ -4745,6 +4752,12 @@ export type WorkspacePaymentMethod = typeof WorkspacePaymentMethod[keyof typeof export type WorkspacePermissionChecks = { __typename?: 'WorkspacePermissionChecks'; canCreateProject: PermissionCheckResult; + canMoveProjectToWorkspace: PermissionCheckResult; +}; + + +export type WorkspacePermissionChecksCanMoveProjectToWorkspaceArgs = { + projectId: Scalars['String']['input']; }; export type WorkspacePlan = { diff --git a/packages/shared/src/authz/checks/workspaceRole.spec.ts b/packages/shared/src/authz/checks/workspaceRole.spec.ts index ddb7bf777..46ca8f4a8 100644 --- a/packages/shared/src/authz/checks/workspaceRole.spec.ts +++ b/packages/shared/src/authz/checks/workspaceRole.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { hasAnyWorkspaceRole, requireMinimumWorkspaceRole } from './workspaceRole.js' +import { hasAnyWorkspaceRole, hasMinimumWorkspaceRole } from './workspaceRole.js' import cryptoRandomString from 'crypto-random-string' describe('hasAnyWorkspaceRole returns a function, that', () => { @@ -25,7 +25,7 @@ describe('hasAnyWorkspaceRole returns a function, that', () => { describe('requireMinimumWorkspaceRole returns a function, that', () => { it('turns non existing workspace role into false ', async () => { - const result = await requireMinimumWorkspaceRole({ + const result = await hasMinimumWorkspaceRole({ getWorkspaceRole: async () => null })({ userId: cryptoRandomString({ length: 10 }), @@ -35,7 +35,7 @@ describe('requireMinimumWorkspaceRole returns a function, that', () => { expect(result).toEqual(false) }) it('returns false if user is below target role', async () => { - const result = await requireMinimumWorkspaceRole({ + const result = await hasMinimumWorkspaceRole({ getWorkspaceRole: async () => 'workspace:member' })({ userId: cryptoRandomString({ length: 10 }), @@ -45,7 +45,7 @@ describe('requireMinimumWorkspaceRole returns a function, that', () => { expect(result).toEqual(false) }) it('returns true if user matches target role', async () => { - const result = await requireMinimumWorkspaceRole({ + const result = await hasMinimumWorkspaceRole({ getWorkspaceRole: async () => 'workspace:member' })({ userId: cryptoRandomString({ length: 10 }), @@ -55,7 +55,7 @@ describe('requireMinimumWorkspaceRole returns a function, that', () => { expect(result).toEqual(true) }) it('returns true if user exceeds target role', async () => { - const result = await requireMinimumWorkspaceRole({ + const result = await hasMinimumWorkspaceRole({ getWorkspaceRole: async () => 'workspace:admin' })({ userId: cryptoRandomString({ length: 10 }), diff --git a/packages/shared/src/authz/checks/workspaceRole.ts b/packages/shared/src/authz/checks/workspaceRole.ts index d93eab400..0e8451fe7 100644 --- a/packages/shared/src/authz/checks/workspaceRole.ts +++ b/packages/shared/src/authz/checks/workspaceRole.ts @@ -3,7 +3,7 @@ import { UserContext, WorkspaceContext } from '../domain/context.js' import { isMinimumWorkspaceRole } from '../domain/logic/roles.js' import { AuthPolicyCheck } from '../domain/policies.js' -export const requireMinimumWorkspaceRole: AuthPolicyCheck< +export const hasMinimumWorkspaceRole: AuthPolicyCheck< 'getWorkspaceRole', UserContext & WorkspaceContext & { role: WorkspaceRoles } > = diff --git a/packages/shared/src/authz/domain/authErrors.ts b/packages/shared/src/authz/domain/authErrors.ts index 4eb307b33..569ac2d5e 100644 --- a/packages/shared/src/authz/domain/authErrors.ts +++ b/packages/shared/src/authz/domain/authErrors.ts @@ -90,6 +90,11 @@ export const WorkspaceLimitsReachedError = defineAuthError< message: 'Workspace limits have been reached' }) +export const WorkspaceProjectMoveInvalidError = defineAuthError({ + code: 'WorkspaceProjectMoveInvalid', + message: 'Projects already in a workspace cannot be moved to another workspace.' +}) + export const WorkspaceSsoSessionNoAccessError = defineAuthError< 'WorkspaceSsoSessionNoAccess', { diff --git a/packages/shared/src/authz/domain/loaders.ts b/packages/shared/src/authz/domain/loaders.ts index 12ff1907b..3da46f7e7 100644 --- a/packages/shared/src/authz/domain/loaders.ts +++ b/packages/shared/src/authz/domain/loaders.ts @@ -7,6 +7,7 @@ import type { GetEnv, GetWorkspace, GetWorkspaceLimits, + GetWorkspaceModelCount, GetWorkspacePlan, GetWorkspaceProjectCount, GetWorkspaceRole, @@ -51,6 +52,7 @@ export const AuthCheckContextLoaderKeys = { getWorkspace: 'getWorkspace', getWorkspaceRole: 'getWorkspaceRole', getWorkspaceSeat: 'getWorkspaceSeat', + getWorkspaceModelCount: 'getWorkspaceModelCount', getWorkspaceProjectCount: 'getWorkspaceProjectCount', getWorkspacePlan: 'getWorkspacePlan', getWorkspaceLimits: 'getWorkspaceLimits', @@ -76,6 +78,7 @@ export type AllAuthCheckContextLoaders = AuthContextLoaderMappingDefinition<{ getWorkspacePlan: GetWorkspacePlan getWorkspaceSeat: GetWorkspaceSeat getWorkspaceProjectCount: GetWorkspaceProjectCount + getWorkspaceModelCount: GetWorkspaceModelCount getWorkspaceSsoProvider: GetWorkspaceSsoProvider getWorkspaceSsoSession: GetWorkspaceSsoSession }> diff --git a/packages/shared/src/authz/domain/workspaces/operations.ts b/packages/shared/src/authz/domain/workspaces/operations.ts index 1b49b1920..b7b4feed1 100644 --- a/packages/shared/src/authz/domain/workspaces/operations.ts +++ b/packages/shared/src/authz/domain/workspaces/operations.ts @@ -21,6 +21,8 @@ export type GetWorkspaceProjectCount = ( args: WorkspaceContext ) => Promise +export type GetWorkspaceModelCount = (args: WorkspaceContext) => Promise + export type GetWorkspaceSeat = ( args: UserContext & WorkspaceContext ) => Promise diff --git a/packages/shared/src/authz/fragments/workspaces.ts b/packages/shared/src/authz/fragments/workspaces.ts index 45544f944..c254a6d35 100644 --- a/packages/shared/src/authz/fragments/workspaces.ts +++ b/packages/shared/src/authz/fragments/workspaces.ts @@ -1,15 +1,13 @@ import { err, ok } from 'true-myth/result' import { AuthPolicyEnsureFragment } from '../domain/policies.js' -import { - hasAnyWorkspaceRole, - requireMinimumWorkspaceRole -} from '../checks/workspaceRole.js' +import { hasMinimumWorkspaceRole } from '../checks/workspaceRole.js' import { WorkspaceNoAccessError, WorkspacesNotEnabledError, WorkspaceSsoSessionNoAccessError } from '../domain/authErrors.js' import { Loaders } from '../domain/loaders.js' +import { Roles, WorkspaceRoles } from '../../core/constants.js' /** * Ensure user has a workspace role, and a valid SSO session (if SSO is configured) @@ -19,20 +17,24 @@ export const ensureWorkspaceRoleAndSessionFragment: AuthPolicyEnsureFragment< | 'getWorkspaceSsoProvider' | 'getWorkspaceSsoSession' | 'getWorkspace', - { userId: string; workspaceId: string }, + { userId: string; workspaceId: string; role?: WorkspaceRoles }, InstanceType > = (loaders) => - async ({ userId, workspaceId }) => { + async ({ userId, workspaceId, role }) => { // Get workspace, so we can resolve its slug for error scenarios const workspace = await loaders.getWorkspace({ workspaceId }) // hides the fact, that the workspace does not exist if (!workspace) return err(new WorkspaceNoAccessError()) - const hasAnyRole = await hasAnyWorkspaceRole(loaders)({ userId, workspaceId }) - if (!hasAnyRole) return err(new WorkspaceNoAccessError()) + const hasMinimumRole = await hasMinimumWorkspaceRole(loaders)({ + userId, + workspaceId, + role: role ?? Roles.Workspace.Guest + }) + if (!hasMinimumRole) return err(new WorkspaceNoAccessError()) - const hasMinimumMemberRole = await requireMinimumWorkspaceRole(loaders)({ + const hasMinimumMemberRole = await hasMinimumWorkspaceRole(loaders)({ userId, workspaceId, role: 'workspace:member' diff --git a/packages/shared/src/authz/policies/index.ts b/packages/shared/src/authz/policies/index.ts index c826f9444..96fafba5d 100644 --- a/packages/shared/src/authz/policies/index.ts +++ b/packages/shared/src/authz/policies/index.ts @@ -1,11 +1,15 @@ import { AllAuthCheckContextLoaders } from '../domain/loaders.js' import { canCreateWorkspaceProjectPolicy } from './workspace/canCreateWorkspaceProject.js' import { canReadProjectPolicy } from './project/canReadProject.js' +import { canCreateModelPolicy } from './project/canCreateModel.js' +import { canMoveToWorkspacePolicy } from './project/canMoveToWorkspace.js' import { canCreatePersonalProjectPolicy } from './project/canCreatePersonal.js' export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({ project: { canRead: canReadProjectPolicy(loaders), + canCreateModel: canCreateModelPolicy(loaders), + canMoveToWorkspace: canMoveToWorkspacePolicy(loaders), canCreatePersonal: canCreatePersonalProjectPolicy(loaders) }, workspace: { diff --git a/packages/shared/src/authz/policies/project/canCreateModel.spec.ts b/packages/shared/src/authz/policies/project/canCreateModel.spec.ts new file mode 100644 index 000000000..3ef1299d2 --- /dev/null +++ b/packages/shared/src/authz/policies/project/canCreateModel.spec.ts @@ -0,0 +1,148 @@ +import cryptoRandomString from 'crypto-random-string' +import { assert, describe, expect, it } from 'vitest' +import { canCreateModelPolicy } from './canCreateModel.js' +import { parseFeatureFlags } from '../../../environment/index.js' +import { Roles } from '../../../core/constants.js' +import { Workspace } from '../../domain/workspaces/types.js' +import { WorkspacePlan } from '../../../workspaces/index.js' +import { Project } from '../../domain/projects/types.js' +import { + ProjectNoAccessError, + ServerNoAccessError, + ServerNoSessionError, + WorkspaceLimitsReachedError, + WorkspaceNoAccessError +} from '../../domain/authErrors.js' + +const buildCanCreateModelPolicy = ( + overrides?: Partial[0]> +) => + canCreateModelPolicy({ + getEnv: async () => parseFeatureFlags({}), + getProject: async () => { + return { + id: cryptoRandomString({ length: 9 }), + isPublic: false, + isDiscoverable: false, + workspaceId: cryptoRandomString({ length: 9 }) + } + }, + getProjectRole: async () => { + return Roles.Stream.Contributor + }, + getServerRole: async () => { + return Roles.Server.User + }, + getWorkspace: async () => { + return {} as Workspace + }, + getWorkspaceRole: async () => { + return Roles.Workspace.Guest + }, + getWorkspaceSsoProvider: async () => { + assert.fail() + }, + getWorkspaceSsoSession: async () => { + assert.fail() + }, + getWorkspacePlan: async () => { + return { + status: 'valid' + } as WorkspacePlan + }, + getWorkspaceLimits: async () => { + return { + modelCount: 5, + projectCount: 1, + versionsHistory: null + } + }, + getWorkspaceModelCount: async () => { + return 0 + }, + ...overrides + }) + +const canCreateArgs = () => ({ + userId: cryptoRandomString({ length: 9 }), + projectId: cryptoRandomString({ length: 9 }) +}) + +describe('canCreateModelPolicy returns a function, that', () => { + it('forbids unauthenticated users', async () => { + const result = await buildCanCreateModelPolicy({})({ + userId: undefined, + projectId: '' + }) + + expect(result).toBeAuthErrorResult({ + code: ServerNoSessionError.code + }) + }) + it('forbids users without server roles', async () => { + const result = await buildCanCreateModelPolicy({ + getServerRole: async () => { + return null + } + })(canCreateArgs()) + + expect(result).toBeAuthErrorResult({ + code: ServerNoAccessError.code + }) + }) + it('forbids users that are not at least stream contributors', async () => { + const result = await buildCanCreateModelPolicy({ + getProjectRole: async () => { + return Roles.Stream.Reviewer + } + })(canCreateArgs()) + + expect(result).toBeAuthErrorResult({ + code: ProjectNoAccessError.code + }) + }) + it('allows stream contributors to create personal projects when project is not in a workspace', async () => { + const result = await buildCanCreateModelPolicy({ + getProject: async () => { + return {} as Project + } + })(canCreateArgs()) + + expect(result).toBeAuthOKResult() + }) + // Hold the workspace to a higher standard than myself + it('requires the workspace to have a plan', async () => { + const result = await buildCanCreateModelPolicy({ + getWorkspacePlan: async () => { + return null + } + })(canCreateArgs()) + + expect(result).toBeAuthErrorResult({ + code: WorkspaceNoAccessError.code + }) + }) + it('forbids new model creation if workspace has reached limit', async () => { + const result = await buildCanCreateModelPolicy({ + getWorkspaceLimits: async () => { + return { + projectCount: 1, + modelCount: 5, + versionsHistory: null + } + }, + getWorkspaceModelCount: async () => { + return 5 + } + })(canCreateArgs()) + + expect(result).toBeAuthErrorResult({ + code: WorkspaceLimitsReachedError.code, + payload: { limit: 'modelCount' } + }) + }) + it('allows new model creation if workspace is within limits', async () => { + const result = await buildCanCreateModelPolicy({})(canCreateArgs()) + expect(result).toBeAuthOKResult() + }) +}) diff --git a/packages/shared/src/authz/policies/project/canCreateModel.ts b/packages/shared/src/authz/policies/project/canCreateModel.ts new file mode 100644 index 000000000..933db2d7d --- /dev/null +++ b/packages/shared/src/authz/policies/project/canCreateModel.ts @@ -0,0 +1,102 @@ +import { err, ok } from 'true-myth/result' +import { + ProjectNotFoundError, + ProjectNoAccessError, + WorkspaceNoAccessError, + WorkspaceSsoSessionNoAccessError, + WorkspaceLimitsReachedError, + ServerNoSessionError, + ServerNoAccessError, + WorkspaceReadOnlyError +} from '../../domain/authErrors.js' +import { MaybeUserContext, ProjectContext } from '../../domain/context.js' +import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js' +import { AuthPolicy } from '../../domain/policies.js' +import { Roles } from '../../../core/constants.js' +import { isWorkspacePlanStatusReadOnly } from '../../../workspaces/index.js' +import { ensureMinimumServerRoleFragment } from '../../fragments/server.js' +import { + ensureMinimumProjectRoleFragment, + ensureProjectWorkspaceAccessFragment +} from '../../fragments/projects.js' + +type PolicyLoaderKeys = + | typeof AuthCheckContextLoaderKeys.getEnv + | typeof AuthCheckContextLoaderKeys.getProject + | typeof AuthCheckContextLoaderKeys.getProjectRole + | typeof AuthCheckContextLoaderKeys.getServerRole + | typeof AuthCheckContextLoaderKeys.getWorkspace + | typeof AuthCheckContextLoaderKeys.getWorkspaceRole + | typeof AuthCheckContextLoaderKeys.getWorkspaceSsoProvider + | typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession + | typeof AuthCheckContextLoaderKeys.getWorkspacePlan + | typeof AuthCheckContextLoaderKeys.getWorkspaceLimits + | typeof AuthCheckContextLoaderKeys.getWorkspaceModelCount + +type PolicyArgs = MaybeUserContext & ProjectContext + +type PolicyErrors = + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + +export const canCreateModelPolicy: AuthPolicy< + PolicyLoaderKeys, + PolicyArgs, + PolicyErrors +> = + (loaders) => + async ({ userId, projectId }) => { + const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({ + userId, + role: Roles.Server.Guest + }) + if (ensuredServerRole.isErr) return err(ensuredServerRole.error) + + const ensuredProjectRole = await ensureMinimumProjectRoleFragment(loaders)({ + userId: userId!, + projectId, + role: Roles.Stream.Contributor + }) + if (ensuredProjectRole.isErr) return err(ensuredProjectRole.error) + + const project = await loaders.getProject({ projectId }) + + // Projects outside of a workspace do not need to check workspace limits + if (!project?.workspaceId) { + return ok() + } + + const { workspaceId } = project + + const ensuredWorkspaceAccess = await ensureProjectWorkspaceAccessFragment(loaders)({ + userId: userId!, + projectId + }) + if (ensuredWorkspaceAccess.isErr) { + return err(ensuredWorkspaceAccess.error) + } + + const workspacePlan = await loaders.getWorkspacePlan({ workspaceId }) + if (!workspacePlan) return err(new WorkspaceNoAccessError()) + if (isWorkspacePlanStatusReadOnly(workspacePlan.status)) + return err(new WorkspaceReadOnlyError()) + + const workspaceLimits = await loaders.getWorkspaceLimits({ workspaceId }) + if (!workspaceLimits) return err(new WorkspaceNoAccessError()) + + if (workspaceLimits.modelCount === null) return ok() + + const currentModelCount = await loaders.getWorkspaceModelCount({ workspaceId }) + + if (currentModelCount === null) return err(new WorkspaceNoAccessError()) + + return currentModelCount < workspaceLimits.modelCount + ? ok() + : err(new WorkspaceLimitsReachedError({ payload: { limit: 'modelCount' } })) + } diff --git a/packages/shared/src/authz/policies/project/canMoveToWorkspace.spec.ts b/packages/shared/src/authz/policies/project/canMoveToWorkspace.spec.ts new file mode 100644 index 000000000..67424e03a --- /dev/null +++ b/packages/shared/src/authz/policies/project/canMoveToWorkspace.spec.ts @@ -0,0 +1,142 @@ +import cryptoRandomString from 'crypto-random-string' +import { assert, describe, expect, it } from 'vitest' +import { canMoveToWorkspacePolicy } from './canMoveToWorkspace.js' +import { parseFeatureFlags } from '../../../environment/index.js' +import { Project } from '../../domain/projects/types.js' +import { Roles } from '../../../core/constants.js' +import { Workspace } from '../../domain/workspaces/types.js' +import { WorkspacePlan } from '../../../workspaces/index.js' + +const buildCanMoveToWorkspace = ( + overrides?: Partial[0]> +) => + canMoveToWorkspacePolicy({ + getEnv: async () => parseFeatureFlags({}), + getProject: async () => { + return {} as Project + }, + getProjectRole: async () => { + return Roles.Stream.Owner + }, + getServerRole: async () => { + return Roles.Server.User + }, + getWorkspace: async () => { + return {} as Workspace + }, + getWorkspaceRole: async () => { + return Roles.Workspace.Admin + }, + getWorkspaceSsoProvider: async () => { + return null + }, + getWorkspaceSsoSession: async () => { + assert.fail() + }, + getWorkspacePlan: async () => { + return { + status: 'valid' + } as WorkspacePlan + }, + getWorkspaceLimits: async () => { + return { + modelCount: 5, + projectCount: 5, + versionsHistory: null + } + }, + getWorkspaceProjectCount: async () => { + return 0 + }, + ...overrides + }) + +const canMoveToWorkspaceArgs = () => ({ + userId: cryptoRandomString({ length: 9 }), + projectId: cryptoRandomString({ length: 9 }), + workspaceId: cryptoRandomString({ length: 9 }) +}) + +describe('canMoveToWorkspacePolicy returns a function, that', () => { + it('requires workspaces to be enabled', async () => { + const result = await buildCanMoveToWorkspace({ + getEnv: async () => + parseFeatureFlags({ + FF_WORKSPACES_MODULE_ENABLED: 'false' + }) + })(canMoveToWorkspaceArgs()) + + expect(result).toBeAuthErrorResult({ + code: 'WorkspacesNotEnabled' + }) + }) + it('requires the project to not be in a workspace', async () => { + const result = await buildCanMoveToWorkspace({ + getProject: async () => { + return { + workspaceId: cryptoRandomString({ length: 9 }) + } as Project + } + })(canMoveToWorkspaceArgs()) + + expect(result).toBeAuthErrorResult({ + code: 'WorkspaceProjectMoveInvalid' + }) + }) + it('requires user to be a server user', async () => { + const result = await buildCanMoveToWorkspace({ + getServerRole: async () => { + return Roles.Server.Guest + } + })(canMoveToWorkspaceArgs()) + + expect(result).toBeAuthErrorResult({ + code: 'ServerNoAccess' + }) + }) + it('requires user to be project owner', async () => { + const result = await buildCanMoveToWorkspace({ + getProjectRole: async () => { + return Roles.Stream.Contributor + } + })(canMoveToWorkspaceArgs()) + + expect(result).toBeAuthErrorResult({ + code: 'ProjectNoAccess' + }) + }) + it('requires user to be target workspace admin', async () => { + const result = await buildCanMoveToWorkspace({ + getWorkspaceRole: async () => { + return Roles.Workspace.Member + } + })(canMoveToWorkspaceArgs()) + + expect(result).toBeAuthErrorResult({ + code: 'WorkspaceNoAccess' + }) + }) + it('forbids move if target workspace will exceed plan limits', async () => { + const result = await buildCanMoveToWorkspace({ + getWorkspaceLimits: async () => { + return { + projectCount: 1, + modelCount: 5, + versionsHistory: null + } + }, + getWorkspaceProjectCount: async () => { + return 1 + } + })(canMoveToWorkspaceArgs()) + + expect(result).toBeAuthErrorResult({ + code: 'WorkspaceLimitsReached', + payload: { limit: 'projectCount' } + }) + }) + it('allows move project if target workspace will be within limits', async () => { + const result = await buildCanMoveToWorkspace({})(canMoveToWorkspaceArgs()) + expect(result).toBeAuthOKResult() + }) +}) diff --git a/packages/shared/src/authz/policies/project/canMoveToWorkspace.ts b/packages/shared/src/authz/policies/project/canMoveToWorkspace.ts new file mode 100644 index 000000000..0cb5f0f88 --- /dev/null +++ b/packages/shared/src/authz/policies/project/canMoveToWorkspace.ts @@ -0,0 +1,111 @@ +import { err, ok } from 'true-myth/result' +import { + ProjectNoAccessError, + ProjectNotFoundError, + ServerNoAccessError, + ServerNoSessionError, + WorkspaceLimitsReachedError, + WorkspaceNoAccessError, + WorkspaceProjectMoveInvalidError, + WorkspaceReadOnlyError, + WorkspacesNotEnabledError, + WorkspaceSsoSessionNoAccessError +} from '../../domain/authErrors.js' +import { + MaybeUserContext, + ProjectContext, + WorkspaceContext +} from '../../domain/context.js' +import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js' +import { AuthPolicy } from '../../domain/policies.js' +import { Roles } from '../../../core/constants.js' +import { isWorkspacePlanStatusReadOnly } from '../../../workspaces/index.js' +import { + ensureWorkspaceRoleAndSessionFragment, + ensureWorkspacesEnabledFragment +} from '../../fragments/workspaces.js' +import { ensureMinimumServerRoleFragment } from '../../fragments/server.js' +import { ensureMinimumProjectRoleFragment } from '../../fragments/projects.js' + +type PolicyLoaderKeys = + | typeof AuthCheckContextLoaderKeys.getEnv + | typeof AuthCheckContextLoaderKeys.getProject + | typeof AuthCheckContextLoaderKeys.getProjectRole + | typeof AuthCheckContextLoaderKeys.getServerRole + | typeof AuthCheckContextLoaderKeys.getWorkspace + | typeof AuthCheckContextLoaderKeys.getWorkspaceRole + | typeof AuthCheckContextLoaderKeys.getWorkspaceSsoProvider + | typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession + | typeof AuthCheckContextLoaderKeys.getWorkspacePlan + | typeof AuthCheckContextLoaderKeys.getWorkspaceLimits + | typeof AuthCheckContextLoaderKeys.getWorkspaceProjectCount + +type PolicyArgs = MaybeUserContext & ProjectContext & WorkspaceContext + +type PolicyErrors = + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + +export const canMoveToWorkspacePolicy: AuthPolicy< + PolicyLoaderKeys, + PolicyArgs, + PolicyErrors +> = + (loaders) => + async ({ userId, projectId, workspaceId }) => { + const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({}) + if (ensuredWorkspacesEnabled.isErr) return err(ensuredWorkspacesEnabled.error) + + // We do not support moving projects that are already in a workspace + const project = await loaders.getProject({ projectId }) + if (!project) return err(new ProjectNotFoundError()) + if (!!project.workspaceId) return err(new WorkspaceProjectMoveInvalidError()) + + const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({ + userId, + role: Roles.Server.User + }) + if (ensuredServerRole.isErr) return err(ensuredServerRole.error) + + const ensuredProjectRole = await ensureMinimumProjectRoleFragment(loaders)({ + userId: userId!, + projectId, + role: Roles.Stream.Owner + }) + if (ensuredProjectRole.isErr) return err(ensuredProjectRole.error) + + const ensuredWorkspaceAccess = await ensureWorkspaceRoleAndSessionFragment(loaders)( + { + userId: userId!, + workspaceId, + role: Roles.Workspace.Admin + } + ) + if (ensuredWorkspaceAccess.isErr) return err(ensuredWorkspaceAccess.error) + + const workspacePlan = await loaders.getWorkspacePlan({ workspaceId }) + if (!workspacePlan) return err(new WorkspaceNoAccessError()) + if (isWorkspacePlanStatusReadOnly(workspacePlan.status)) + return err(new WorkspaceReadOnlyError()) + + const workspaceLimits = await loaders.getWorkspaceLimits({ workspaceId }) + if (!workspaceLimits) return err(new WorkspaceNoAccessError()) + + if (workspaceLimits.projectCount === null) return ok() + + const currentProjectCount = await loaders.getWorkspaceProjectCount({ workspaceId }) + + if (currentProjectCount === null) return err(new WorkspaceNoAccessError()) + + return currentProjectCount < workspaceLimits.projectCount + ? ok() + : err(new WorkspaceLimitsReachedError({ payload: { limit: 'projectCount' } })) + } diff --git a/packages/shared/src/authz/policies/workspace/canCreateWorkspaceProject.ts b/packages/shared/src/authz/policies/workspace/canCreateWorkspaceProject.ts index ae7755c95..8bd1439c6 100644 --- a/packages/shared/src/authz/policies/workspace/canCreateWorkspaceProject.ts +++ b/packages/shared/src/authz/policies/workspace/canCreateWorkspaceProject.ts @@ -23,7 +23,7 @@ import { isWorkspacePlanStatusReadOnly } from '../../../workspaces/index.js' import { ensureMinimumServerRoleFragment } from '../../fragments/server.js' -import { requireMinimumWorkspaceRole } from '../../checks/workspaceRole.js' +import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js' export const canCreateWorkspaceProjectPolicy: AuthPolicy< | 'getEnv' @@ -69,7 +69,7 @@ export const canCreateWorkspaceProjectPolicy: AuthPolicy< } // guests cannot create projects in the workspace - const isNotGuest = await requireMinimumWorkspaceRole(loaders)({ + const isNotGuest = await hasMinimumWorkspaceRole(loaders)({ userId: userId!, workspaceId, role: Roles.Workspace.Member