diff --git a/packages/server/assets/core/typedefs/projects.graphql b/packages/server/assets/core/typedefs/projects.graphql index 316442e06..92bf75f75 100644 --- a/packages/server/assets/core/typedefs/projects.graphql +++ b/packages/server/assets/core/typedefs/projects.graphql @@ -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 { diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index 79f281ad8..a63fa8a9a 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -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) diff --git a/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts b/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts index 87025b35b..d2c1ec7e2 100644 --- a/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts +++ b/packages/server/modules/serverinvites/graph/resolvers/serverInvites.ts @@ -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 }), diff --git a/packages/server/modules/serverinvites/tests/invites.spec.ts b/packages/server/modules/serverinvites/tests/invites.spec.ts index 1cf8172d0..e9fb9ddaf 100644 --- a/packages/server/modules/serverinvites/tests/invites.spec.ts +++ b/packages/server/modules/serverinvites/tests/invites.spec.ts @@ -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" ) }) diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index da872b495..b4ddcc236 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -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({ diff --git a/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts index 38229b754..9a63b2c4a 100644 --- a/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/invites.graph.spec.ts @@ -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 })({ diff --git a/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts b/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts index a125438a9..e1a167715 100644 --- a/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/workspaces.graph.spec.ts @@ -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 () => { diff --git a/packages/shared/src/authz/policies/index.ts b/packages/shared/src/authz/policies/index.ts index bae656478..7fe206205 100644 --- a/packages/shared/src/authz/policies/index.ts +++ b/packages/shared/src/authz/policies/index.ts @@ -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) } }) diff --git a/packages/shared/src/authz/policies/project/canInvite.ts b/packages/shared/src/authz/policies/project/canInvite.ts new file mode 100644 index 000000000..f9d86ff8e --- /dev/null +++ b/packages/shared/src/authz/policies/project/canInvite.ts @@ -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 + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + +export const canInvitePolicy: AuthPolicy = + (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() + } diff --git a/packages/shared/src/authz/policies/workspace/canInvite.ts b/packages/shared/src/authz/policies/workspace/canInvite.ts new file mode 100644 index 000000000..4fd34e471 --- /dev/null +++ b/packages/shared/src/authz/policies/workspace/canInvite.ts @@ -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 + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + | InstanceType + +export const canInvitePolicy: AuthPolicy = + (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() + }