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
@@ -3,6 +3,8 @@ extend type Project {
}
type ProjectPermissionChecks {
canCreateModel: PermissionCheckResult!
canMoveToWorkspace(workspaceId: String!): PermissionCheckResult!
canRead: PermissionCheckResult!
}
@@ -4,4 +4,5 @@ extend type Workspace {
type WorkspacePermissionChecks {
canCreateProject: PermissionCheckResult!
canMoveProjectToWorkspace(projectId: String!): PermissionCheckResult!
}
@@ -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<ContextType = GraphQLC
};
export type ProjectPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ProjectPermissionChecks'] = ResolversParentTypes['ProjectPermissionChecks']> = {
canCreateModel?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canMoveToWorkspace?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType, RequireFields<ProjectPermissionChecksCanMoveToWorkspaceArgs, 'workspaceId'>>;
canRead?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@@ -7350,6 +7365,7 @@ export type WorkspaceMutationsResolvers<ContextType = GraphQLContext, ParentType
export type WorkspacePermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspacePermissionChecks'] = ResolversParentTypes['WorkspacePermissionChecks']> = {
canCreateProject?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
canMoveProjectToWorkspace?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType, RequireFields<WorkspacePermissionChecksCanMoveProjectToWorkspaceArgs, 'projectId'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@@ -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
@@ -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,
@@ -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 = {
@@ -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: {
@@ -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, {
@@ -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
}
@@ -17,6 +17,9 @@ export default defineModuleLoaders(() => ({
getWorkspaceSeat: async () => {
throw new LoaderUnsupportedError()
},
getWorkspaceModelCount: async () => {
throw new LoaderUnsupportedError()
},
getWorkspaceProjectCount: async () => {
throw new LoaderUnsupportedError()
},
@@ -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
}
@@ -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 = {
@@ -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 }),
@@ -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 }
> =
@@ -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',
{
@@ -7,6 +7,7 @@ import type {
GetEnv,
GetWorkspace,
GetWorkspaceLimits,
GetWorkspaceModelCount,
GetWorkspacePlan,
GetWorkspaceProjectCount,
GetWorkspaceRole,
@@ -51,6 +52,7 @@ export const AuthCheckContextLoaderKeys = <const>{
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
}>
@@ -21,6 +21,8 @@ export type GetWorkspaceProjectCount = (
args: WorkspaceContext
) => Promise<number | null>
export type GetWorkspaceModelCount = (args: WorkspaceContext) => Promise<number | null>
export type GetWorkspaceSeat = (
args: UserContext & WorkspaceContext
) => Promise<WorkspaceSeatType | null>
@@ -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<typeof WorkspaceSsoSessionNoAccessError | typeof WorkspaceNoAccessError>
> =
(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'
@@ -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: {
@@ -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<Parameters<typeof canCreateModelPolicy>[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()
})
})
@@ -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<typeof ProjectNotFoundError>
| InstanceType<typeof ProjectNoAccessError>
| InstanceType<typeof WorkspaceNoAccessError>
| InstanceType<typeof WorkspaceSsoSessionNoAccessError>
| InstanceType<typeof WorkspaceReadOnlyError>
| InstanceType<typeof WorkspaceLimitsReachedError>
| InstanceType<typeof ServerNoSessionError>
| InstanceType<typeof ServerNoAccessError>
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' } }))
}
@@ -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<Parameters<typeof canMoveToWorkspacePolicy>[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()
})
})
@@ -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<typeof ProjectNotFoundError>
| InstanceType<typeof ProjectNoAccessError>
| InstanceType<typeof WorkspaceNoAccessError>
| InstanceType<typeof WorkspaceSsoSessionNoAccessError>
| InstanceType<typeof WorkspaceReadOnlyError>
| InstanceType<typeof WorkspaceLimitsReachedError>
| InstanceType<typeof WorkspacesNotEnabledError>
| InstanceType<typeof WorkspaceProjectMoveInvalidError>
| InstanceType<typeof ServerNoSessionError>
| InstanceType<typeof ServerNoAccessError>
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' } }))
}
@@ -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