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
This commit is contained in:
Chuck Driesler
2025-04-08 15:29:12 +01:00
committed by GitHub
parent f217f5b17f
commit cb7243cfbe
28 changed files with 701 additions and 60 deletions
@@ -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)
},
@@ -241,6 +241,10 @@ export type GetWorkspacesProjectsCounts = (params: {
[workspaceId: string]: number
}>
export type GetWorkspaceModelCount = (params: {
workspaceId: string
}) => Promise<number>
export type GetPaginatedWorkspaceProjectsArgs = {
workspaceId: string
/**
@@ -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
@@ -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,
@@ -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
}