feat(authz): Workspace.canInvite and Project.canInvite (#4419)

This commit is contained in:
Chuck Driesler
2025-04-16 09:01:53 +01:00
committed by GitHub
parent e31f4c5a47
commit cebae959ae
10 changed files with 283 additions and 54 deletions
@@ -80,13 +80,11 @@ type ProjectInviteMutations {
"""
create(projectId: ID!, input: ProjectInviteCreateInput!): Project!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
"""
Batch invite to project
"""
batchCreate(projectId: ID!, input: [ProjectInviteCreateInput!]!): Project!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
"""
Accept or decline a project invite
@@ -96,9 +94,7 @@ type ProjectInviteMutations {
"""
Cancel a pending stream invite. Can only be invoked by a project owner.
"""
cancel(projectId: ID!, inviteId: String!): Project!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
cancel(projectId: ID!, inviteId: String!): Project! @hasScope(scope: "users:invite")
}
type ProjectMutations {
@@ -120,7 +120,7 @@ extend type ProjectInviteMutations {
createForWorkspace(
projectId: ID!
inputs: [WorkspaceProjectInviteCreateInput!]!
): Project! @hasScope(scope: "users:invite") @hasServerRole(role: SERVER_USER)
): Project! @hasScope(scope: "users:invite")
}
extend type Mutation {
@@ -229,14 +229,10 @@ input WorkspaceInviteResendInput {
type WorkspaceInviteMutations {
create(workspaceId: String!, input: WorkspaceInviteCreateInput!): Workspace!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
batchCreate(workspaceId: String!, input: [WorkspaceInviteCreateInput!]!): Workspace!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
use(input: WorkspaceInviteUseInput!): Boolean!
resend(input: WorkspaceInviteResendInput!): Boolean!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
resend(input: WorkspaceInviteResendInput!): Boolean! @hasScope(scope: "users:invite")
cancel(workspaceId: String!, inviteId: String!): Workspace!
@hasScope(scope: "users:invite")
@hasServerRole(role: SERVER_USER)
@@ -18,7 +18,10 @@ import {
} from '@/modules/serverinvites/services/retrieval'
import { authorizeResolver } from '@/modules/shared'
import { chunk } from 'lodash'
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import {
Resolvers,
TokenResourceIdentifierType
} from '@/modules/core/graph/generated/graphql'
import db from '@/db/knex'
import { ServerRoles } from '@speckle/shared'
import {
@@ -76,6 +79,8 @@ import {
} from '@/modules/core/services/streams/access'
import { getUserFactory, getUsersFactory } from '@/modules/core/repositories/users'
import { getServerInfoFactory } from '@/modules/core/repositories/server'
import { throwIfResourceAccessNotAllowed } from '@/modules/core/helpers/token'
import { mapAuthToServerError } from '@/modules/shared/helpers/errorHelper'
const validateStreamAccess = validateStreamAccessFactory({ authorizeResolver })
@@ -390,6 +395,22 @@ export = {
},
ProjectInviteMutations: {
async create(_parent, args, ctx) {
const { projectId } = args
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canInvite = await ctx.authPolicies.project.canInvite({
userId: ctx.userId,
projectId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const createProjectInvite = createProjectInviteFactory({
createAndSendInvite: buildCreateAndSendServerOrProjectInvite(),
getStream
@@ -406,12 +427,7 @@ export = {
return ctx.loaders.streams.getStream.load(args.projectId)
},
async batchCreate(_parent, args, ctx) {
await authorizeResolver(
ctx.userId,
args.projectId,
Roles.Stream.Owner,
ctx.resourceAccessRules
)
const { projectId } = args
const inviteCount = args.input.length
if (inviteCount > 10 && ctx.role !== Roles.Server.Admin) {
@@ -420,6 +436,20 @@ export = {
)
}
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canInvite = await ctx.authPolicies.project.canInvite({
userId: ctx.userId,
projectId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const createProjectInvite = createProjectInviteFactory({
createAndSendInvite: buildCreateAndSendServerOrProjectInvite(),
getStream
@@ -477,12 +507,21 @@ export = {
return true
},
async cancel(_parent, args, ctx) {
await authorizeResolver(
ctx.userId,
args.projectId,
Roles.Stream.Owner,
ctx.resourceAccessRules
)
const { projectId } = args
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canInvite = await ctx.authPolicies.project.canInvite({
userId: ctx.userId,
projectId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const cancelInvite = cancelResourceInviteFactory({
findInvite: findInviteFactory({ db }),
@@ -392,7 +392,7 @@ describe('[Stream & Server Invites]', () => {
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'Invalid project ID'
projectInvite ? 'Project not found' : 'Invalid project ID specified'
)
})
@@ -418,7 +418,9 @@ describe('[Stream & Server Invites]', () => {
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'You are not authorized to access this resource'
projectInvite
? 'You do not have access to the project'
: "Inviter doesn't have owner access to"
)
})
@@ -1,5 +1,8 @@
import { db } from '@/db/knex'
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import {
Resolvers,
TokenResourceIdentifierType
} from '@/modules/core/graph/generated/graphql'
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
import {
updateProjectFactory,
@@ -205,6 +208,7 @@ import {
getWorkspaceRolesAndSeatsFactory,
getWorkspaceUserSeatFactory
} from '@/modules/gatekeeper/repositories/workspaceSeat'
import { throwIfResourceAccessNotAllowed } from '@/modules/core/helpers/token'
import {
mapAuthToServerError,
throwIfAuthNotOk
@@ -368,12 +372,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
},
ProjectInviteMutations: {
async createForWorkspace(_parent, args, ctx) {
await authorizeResolver(
ctx.userId,
args.projectId,
Roles.Stream.Owner,
ctx.resourceAccessRules
)
const { projectId } = args
const inviteCount = args.inputs.length
if (inviteCount > 10 && ctx.role !== Roles.Server.Admin) {
@@ -382,6 +381,20 @@ export = FF_WORKSPACES_MODULE_ENABLED
)
}
throwIfResourceAccessNotAllowed({
resourceId: args.projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canInvite = await ctx.authPolicies.project.canInvite({
userId: ctx.userId,
projectId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const createProjectInvite = createProjectInviteFactory({
createAndSendInvite: buildCreateAndSendServerOrProjectInvite(),
getStream
@@ -838,12 +851,19 @@ export = FF_WORKSPACES_MODULE_ENABLED
input: { inviteId, workspaceId }
} = args
await authorizeResolver(
ctx.userId!,
workspaceId,
Roles.Workspace.Admin,
ctx.resourceAccessRules
)
throwIfResourceAccessNotAllowed({
resourceId: workspaceId,
resourceType: TokenResourceIdentifierType.Workspace,
resourceAccessRules: ctx.resourceAccessRules
})
const canInvite = await ctx.authPolicies.workspace.canInvite({
userId: ctx.userId,
workspaceId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const resendInviteEmail = resendInviteEmailFactory({
buildInviteEmailContents: buildWorkspaceInviteEmailContentsFactory({
@@ -871,6 +891,22 @@ export = FF_WORKSPACES_MODULE_ENABLED
return true
},
create: async (_parent, args, ctx) => {
const { workspaceId } = args
throwIfResourceAccessNotAllowed({
resourceId: workspaceId,
resourceType: TokenResourceIdentifierType.Workspace,
resourceAccessRules: ctx.resourceAccessRules
})
const canInvite = await ctx.authPolicies.workspace.canInvite({
userId: ctx.userId,
workspaceId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const createInvite = createWorkspaceInviteFactory({
createAndSendInvite: buildCreateAndSendWorkspaceInvite()
})
@@ -884,6 +920,8 @@ export = FF_WORKSPACES_MODULE_ENABLED
return ctx.loaders.workspaces!.getWorkspace.load(args.workspaceId)
},
batchCreate: async (_parent, args, ctx) => {
const { workspaceId } = args
const inviteCount = args.input.length
if (inviteCount > 10 && ctx.role !== Roles.Server.Admin) {
throw new InviteCreateValidationError(
@@ -891,6 +929,20 @@ export = FF_WORKSPACES_MODULE_ENABLED
)
}
throwIfResourceAccessNotAllowed({
resourceId: workspaceId,
resourceType: TokenResourceIdentifierType.Workspace,
resourceAccessRules: ctx.resourceAccessRules
})
const canInvite = await ctx.authPolicies.workspace.canInvite({
userId: ctx.userId,
workspaceId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const createInvite = createWorkspaceInviteFactory({
createAndSendInvite: buildCreateAndSendWorkspaceInvite()
})
@@ -970,12 +1022,21 @@ export = FF_WORKSPACES_MODULE_ENABLED
return true
},
cancel: async (_parent, args, ctx) => {
await authorizeResolver(
ctx.userId,
args.workspaceId,
Roles.Workspace.Admin,
ctx.resourceAccessRules
)
const { workspaceId } = args
throwIfResourceAccessNotAllowed({
resourceId: workspaceId,
resourceType: TokenResourceIdentifierType.Workspace,
resourceAccessRules: ctx.resourceAccessRules
})
const canInvite = await ctx.authPolicies.workspace.canInvite({
userId: ctx.userId,
workspaceId
})
if (!canInvite.isOk) {
throw mapAuthToServerError(canInvite.error)
}
const cancelInvite = cancelResourceInviteFactory({
findInvite: findInviteFactory({
@@ -186,9 +186,7 @@ describe('Workspaces Invites GQL', () => {
}
})
expect(res).to.haveGraphQLErrors(
'Attempting to invite into a non-existant workspace'
)
expect(res).to.haveGraphQLErrors('You do not have access to the workspace')
expect(res.data?.workspaceMutations?.invites?.create).to.not.be.ok
})
@@ -226,7 +224,9 @@ describe('Workspaces Invites GQL', () => {
}
})
expect(res).to.haveGraphQLErrors('You are not authorized')
expect(res).to.haveGraphQLErrors(
'You do not have enough permissions in the workspace to perform this action'
)
expect(res.data?.workspaceMutations?.invites?.create).to.not.be.ok
})
@@ -269,7 +269,9 @@ describe('Workspaces Invites GQL', () => {
}))
})
expect(res).to.haveGraphQLErrors('You are not authorized')
expect(res).to.haveGraphQLErrors(
'You do not have enough permissions in the workspace to perform this action'
)
expect(res.data?.workspaceMutations?.invites?.batchCreate).to.not.be.ok
})
@@ -739,7 +741,9 @@ describe('Workspaces Invites GQL', () => {
}
})
expect(res).to.haveGraphQLErrors('You are not authorized')
expect(res).to.haveGraphQLErrors(
'You do not have enough permissions in the workspace to perform this action'
)
expect(res.data?.workspaceMutations?.invites?.cancel).to.not.be.ok
const invite = await findInviteFactory({ db })({
@@ -1082,7 +1082,9 @@ describe('Workspaces GQL CRUD', () => {
})
expect(deleteRes).to.not.haveGraphQLErrors()
expect(getRes).to.haveGraphQLErrors('Workspace not found')
expect(getRes).to.haveGraphQLErrors(
'You are not authorized to access this resource'
)
})
it('should throw if non-workspace-admin triggers delete', async () => {
+6 -2
View File
@@ -9,6 +9,8 @@ import { canReadProjectSettingsPolicy } from './project/canReadSettings.js'
import { canReadProjectWebhooksPolicy } from './project/canReadWebhooks.js'
import { canUpdateProjectAllowPublicCommentsPolicy } from './project/canUpdateAllowPublicComments.js'
import { canLeaveProjectPolicy } from './project/canLeave.js'
import { canInvitePolicy as canInviteToWorkspacePolicy } from './workspace/canInvite.js'
import { canInvitePolicy as canInviteToProjectPolicy } from './project/canInvite.js'
import { canBroadcastProjectActivityPolicy } from './project/canBroadcastActivity.js'
import { canCreateProjectCommentPolicy } from './project/comment/canCreate.js'
import { canArchiveProjectCommentPolicy } from './project/comment/canArchive.js'
@@ -46,10 +48,12 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
canUpdateAllowPublicComments: canUpdateProjectAllowPublicCommentsPolicy(loaders),
canReadSettings: canReadProjectSettingsPolicy(loaders),
canReadWebhooks: canReadProjectWebhooksPolicy(loaders),
canLeave: canLeaveProjectPolicy(loaders)
canLeave: canLeaveProjectPolicy(loaders),
canInvite: canInviteToProjectPolicy(loaders)
},
workspace: {
canCreateProject: canCreateWorkspaceProjectPolicy(loaders)
canCreateProject: canCreateWorkspaceProjectPolicy(loaders),
canInvite: canInviteToWorkspacePolicy(loaders)
}
})
@@ -0,0 +1,63 @@
import { err, ok } from 'true-myth/result'
import { MaybeUserContext, ProjectContext } from '../../domain/context.js'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ServerNoAccessError,
ServerNoSessionError,
WorkspaceSsoSessionNoAccessError,
WorkspaceNoAccessError,
ProjectNoAccessError,
ProjectNotFoundError,
ServerNotEnoughPermissionsError,
ProjectNotEnoughPermissionsError,
WorkspaceNotEnoughPermissionsError
} from '../../domain/authErrors.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
import { Roles } from '../../../core/constants.js'
import { ensureImplicitProjectMemberWithWriteAccessFragment } from '../../fragments/projects.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getServerRole
| typeof AuthCheckContextLoaderKeys.getProject
| typeof AuthCheckContextLoaderKeys.getProjectRole
| typeof AuthCheckContextLoaderKeys.getWorkspace
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoProvider
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession
type PolicyArgs = MaybeUserContext & ProjectContext
type PolicyErrors =
| InstanceType<typeof ServerNoAccessError>
| InstanceType<typeof ServerNoSessionError>
| InstanceType<typeof ServerNotEnoughPermissionsError>
| InstanceType<typeof ProjectNoAccessError>
| InstanceType<typeof ProjectNotFoundError>
| InstanceType<typeof ProjectNotEnoughPermissionsError>
| InstanceType<typeof WorkspaceSsoSessionNoAccessError>
| InstanceType<typeof WorkspaceNoAccessError>
| InstanceType<typeof WorkspaceNotEnoughPermissionsError>
export const canInvitePolicy: AuthPolicy<PolicyLoaderKeys, PolicyArgs, PolicyErrors> =
(loaders) =>
async ({ userId, projectId }) => {
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId,
role: Roles.Server.User
})
if (ensuredServerRole.isErr) return err(ensuredServerRole.error)
const ensuredProjectRole = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId: userId!,
projectId,
role: Roles.Stream.Owner
})
if (ensuredProjectRole.isErr) return err(ensuredProjectRole.error)
return ok()
}
@@ -0,0 +1,62 @@
import { err, ok } from 'true-myth/result'
import { MaybeUserContext, WorkspaceContext } from '../../domain/context.js'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureWorkspaceRoleAndSessionFragment,
ensureWorkspacesEnabledFragment
} from '../../fragments/workspaces.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
import { Roles } from '../../../core/constants.js'
import {
WorkspacesNotEnabledError,
ServerNoAccessError,
WorkspaceSsoSessionNoAccessError,
WorkspaceNoAccessError,
ServerNoSessionError,
WorkspaceNotEnoughPermissionsError,
ServerNotEnoughPermissionsError
} from '../../domain/authErrors.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getServerRole
| typeof AuthCheckContextLoaderKeys.getWorkspace
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoProvider
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession
type PolicyArgs = MaybeUserContext & WorkspaceContext
type PolicyErrors =
| InstanceType<typeof WorkspacesNotEnabledError>
| InstanceType<typeof ServerNoAccessError>
| InstanceType<typeof ServerNoSessionError>
| InstanceType<typeof ServerNotEnoughPermissionsError>
| InstanceType<typeof WorkspaceSsoSessionNoAccessError>
| InstanceType<typeof WorkspaceNoAccessError>
| InstanceType<typeof WorkspaceNotEnoughPermissionsError>
export const canInvitePolicy: AuthPolicy<PolicyLoaderKeys, PolicyArgs, PolicyErrors> =
(loaders) =>
async ({ userId, workspaceId }) => {
const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({})
if (ensuredWorkspacesEnabled.isErr) return err(ensuredWorkspacesEnabled.error)
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
userId,
role: Roles.Server.User
})
if (ensuredServerRole.isErr) return err(ensuredServerRole.error)
const ensuredWorkspaceAccess = await ensureWorkspaceRoleAndSessionFragment(loaders)(
{
userId: userId!,
workspaceId,
role: Roles.Workspace.Admin
}
)
if (ensuredWorkspaceAccess.isErr) return err(ensuredWorkspaceAccess.error)
return ok()
}