From ee2810281ecaf46ad6dbb32a832cfcf86fa961ac Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Fri, 21 Feb 2025 17:26:01 +0100 Subject: [PATCH] [permissions] Add permission gates on workspace-invitations (#10394) Adding permission gates on all workspace-invitations endpoints: sendInvitation, resendInvitation, deleteWorkspaceInvitation, findWorkspaceInvitations (the latter being from my understanding only used to list the invitations to then re-send them or detee them). + tests on Api & webhooks permission gates --- .../workspace-invitation.module.ts | 4 + .../workspace-invitation.resolver.ts | 12 +- .../api-key-webhooks.integration-spec.ts | 58 +++++ .../data-model.integration-spec.ts | 245 ++++++++++++++++++ .../workspace-invitation.integration-spec.ts | 134 ++++++++++ .../workspace.integration-spec.ts | 2 +- ...adata-api-request-with-member-role.util.ts | 21 ++ 7 files changed, 473 insertions(+), 3 deletions(-) create mode 100644 packages/twenty-server/test/integration/graphql/suites/settings-permissions/api-key-webhooks.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/settings-permissions/data-model.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace-invitation.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/utils/make-metadata-api-request-with-member-role.util.ts diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts index 3ed083947..7c96164e2 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.module.ts @@ -4,12 +4,14 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { WorkspaceInvitationResolver } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.resolver'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @Module({ imports: [ @@ -20,6 +22,8 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; ), FileModule, OnboardingModule, + PermissionsModule, + FeatureFlagModule, ], exports: [WorkspaceInvitationService], providers: [WorkspaceInvitationService, WorkspaceInvitationResolver], diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.resolver.ts index 9ea3eb23f..bf9e958be 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/workspace-invitation.resolver.ts @@ -1,6 +1,8 @@ -import { UseGuards } from '@nestjs/common'; +import { UseFilters, UseGuards } from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { SettingsFeatures } from 'twenty-shared'; + import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { SendInvitationsOutput } from 'src/engine/core-modules/workspace-invitation/dtos/send-invitations.output'; @@ -9,12 +11,18 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { SendInvitationsInput } from './dtos/send-invitations.input'; -@UseGuards(WorkspaceAuthGuard) +@UseGuards( + WorkspaceAuthGuard, + SettingsPermissionsGuard(SettingsFeatures.WORKSPACE_USERS), +) +@UseFilters(PermissionsGraphqlApiExceptionFilter) @Resolver() export class WorkspaceInvitationResolver { constructor( diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/api-key-webhooks.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/api-key-webhooks.integration-spec.ts new file mode 100644 index 000000000..c38d4dc04 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/api-key-webhooks.integration-spec.ts @@ -0,0 +1,58 @@ +import request from 'supertest'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; + +const client = request(`http://localhost:${APP_PORT}`); + +describe('api key and webhooks permissions', () => { + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + }); + + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + describe('generateApiKeyToken', () => { + it('should throw a permission error when user does not have permission (member role)', async () => { + const queryData = { + query: ` + mutation generateApiKeyToken { + generateApiKeyToken(apiKeyId: "test-api-key-id", expiresAt: "2025-01-01T00:00:00Z") { + token + } + } + `, + }; + + await client + .post('/graphql') + .set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeNull(); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/data-model.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/data-model.integration-spec.ts new file mode 100644 index 000000000..5311cd63a --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/data-model.integration-spec.ts @@ -0,0 +1,245 @@ +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util'; +import { createCustomTextFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-custom-text-field-metadata.util'; +import { createOneFieldMetadataFactory } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata-factory.util'; +import { deleteOneFieldMetadataItemFactory } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata-factory.util'; +import { deleteFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util'; +import { updateOneFieldMetadataFactory } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata-factory.util'; +import { createOneObjectMetadataFactory } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata-factory.util'; +import { createListingCustomObject } from 'test/integration/metadata/suites/object-metadata/utils/create-test-object-metadata.util'; +import { deleteOneObjectMetadataItemFactory } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata-factory.util'; +import { deleteOneObjectMetadataItem } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { updateOneObjectMetadataItemFactory } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata-factory.util'; +import { makeMetadataAPIRequestWithMemberRole } from 'test/integration/metadata/suites/utils/make-metadata-api-request-with-member-role.util'; +import { FieldMetadataType } from 'twenty-shared'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; + +describe('datamodel permissions', () => { + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + }); + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + describe('fieldMetadata', () => { + let listingObjectId = ''; + let testFieldId = ''; + + beforeAll(async () => { + const { objectMetadataId: createdObjectId } = + await createListingCustomObject(); + + listingObjectId = createdObjectId; + + const { fieldMetadataId: createdFieldMetadaId } = + await createCustomTextFieldMetadata(createdObjectId); + + testFieldId = createdFieldMetadaId; + }); + afterAll(async () => { + await deleteFieldMetadata(testFieldId); + await deleteOneObjectMetadataItem(listingObjectId); + }); + describe('createOne', () => { + it('should throw a permission error when user does not have permission (member role)', async () => { + // Arrange + const FIELD_NAME = 'testFieldForCreateOne'; + const createFieldInput = { + name: FIELD_NAME, + label: 'Test Field For CreateOne', + type: FieldMetadataType.TEXT, + objectMetadataId: listingObjectId, + }; + + // Act + const graphqlOperation = createOneFieldMetadataFactory({ + input: { field: createFieldInput }, + gqlFields: ` + id + name + `, + }); + + const response = + await makeMetadataAPIRequestWithMemberRole(graphqlOperation); + + // Assert + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.FORBIDDEN, + ); + }); + }); + + describe('updateOne', () => { + it('should throw a permission error when user does not have permission (member role)', async () => { + // Arrange + const updateFieldInput = { + name: 'updatedName', + label: 'Updated Name', + }; + + const graphqlOperation = updateOneFieldMetadataFactory({ + input: { id: testFieldId, update: updateFieldInput }, + gqlFields: ` + id + name + `, + }); + + const response = + await makeMetadataAPIRequestWithMemberRole(graphqlOperation); + + // Assert + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.FORBIDDEN, + ); + }); + }); + + describe('deleteOne', () => { + it('should throw a permission error when user does not have permission (member role)', async () => { + // Arrange + const graphqlOperation = deleteOneFieldMetadataItemFactory({ + idToDelete: testFieldId, + }); + + const response = + await makeMetadataAPIRequestWithMemberRole(graphqlOperation); + + // Assert + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.FORBIDDEN, + ); + }); + }); + }); + + describe('objectMetadata', () => { + describe('createOne', () => { + it('should throw a permission error when user does not have permission (member role)', async () => { + // Arrange + const graphqlOperation = createOneObjectMetadataFactory({ + gqlFields: ` + id + `, + input: { + object: { + labelPlural: 'Test Objects', + labelSingular: 'Test Object', + namePlural: 'testObjects', + nameSingular: 'testObject', + }, + }, + }); + + const response = + await makeMetadataAPIRequestWithMemberRole(graphqlOperation); + + // Assert + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.FORBIDDEN, + ); + }); + }); + + describe('update and delete a custom object', () => { + let listingObjectId = ''; + + beforeAll(async () => { + const { objectMetadataId: createdObjectId } = + await createListingCustomObject(); + + listingObjectId = createdObjectId; + }); + afterAll(async () => { + await deleteOneObjectMetadataItem(listingObjectId); + }); + describe('updateOne', () => { + it('should throw a permission error when user does not have permission (member role)', async () => { + // Arrange + const graphqlOperation = updateOneObjectMetadataItemFactory({ + gqlFields: ` + id + `, + input: { + idToUpdate: listingObjectId, + updatePayload: { + labelPlural: 'Updated Test Objects', + labelSingular: 'Updated Test Object', + }, + }, + }); + + const response = + await makeMetadataAPIRequestWithMemberRole(graphqlOperation); + + // Assert + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.FORBIDDEN, + ); + }); + }); + describe('deleteOne', () => { + it('should throw a permission error when user does not have permission (member role)', async () => { + // Arrange + const graphqlOperation = deleteOneObjectMetadataItemFactory({ + idToDelete: listingObjectId, + }); + + const response = + await makeMetadataAPIRequestWithMemberRole(graphqlOperation); + + // Assert + expect(response.body.data).toBeNull(); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe( + ErrorCode.FORBIDDEN, + ); + }); + }); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace-invitation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace-invitation.integration-spec.ts new file mode 100644 index 000000000..8a18a22a6 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace-invitation.integration-spec.ts @@ -0,0 +1,134 @@ +import request from 'supertest'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces'; +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; + +const client = request(`http://localhost:${APP_PORT}`); + +describe('workspace invitation permissions', () => { + beforeAll(async () => { + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + }); + + afterAll(async () => { + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IsPermissionsEnabled', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + }); + + it('should throw a permission error when user does not have permission to send invitation', async () => { + const queryData = { + query: ` + mutation sendWorkspaceInvitation { + sendInvitations(emails: ["test@example.com"]) { + success + } + } + `, + }; + + await client + .post('/graphql') + .set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeNull(); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + }); + + it('should throw a permission error when user does not have permission to resend invitation', async () => { + const queryData = { + query: ` + mutation resendWorkspaceInvitation { + resendWorkspaceInvitation(appTokenId: "test-invitation-id") { + success + } + } + `, + }; + + await client + .post('/graphql') + .set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeNull(); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + }); + + it('should throw a permission error when user does not have permission to find invitations', async () => { + const queryData = { + query: ` + query findWorkspaceInvitations { + findWorkspaceInvitations { + id + email + } + } + `, + }; + + await client + .post('/graphql') + .set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeNull(); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + }); + + it('should throw a permission error when user does not have permission to delete invitation', async () => { + const queryData = { + query: ` + mutation deleteWorkspaceInvitation { + deleteWorkspaceInvitation(appTokenId: "test-invitation-id") + } + `, + }; + + await client + .post('/graphql') + .set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeNull(); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(res.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts index 0bade6509..34e7db383 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/workspace.integration-spec.ts @@ -10,7 +10,7 @@ import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permiss const client = request(`http://localhost:${APP_PORT}`); -describe('WorkspaceResolver', () => { +describe('workspace permissions', () => { let originalWorkspaceState; beforeAll(async () => { diff --git a/packages/twenty-server/test/integration/metadata/suites/utils/make-metadata-api-request-with-member-role.util.ts b/packages/twenty-server/test/integration/metadata/suites/utils/make-metadata-api-request-with-member-role.util.ts new file mode 100644 index 000000000..79dfa3d37 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/utils/make-metadata-api-request-with-member-role.util.ts @@ -0,0 +1,21 @@ +import { ASTNode, print } from 'graphql'; +import request from 'supertest'; + +type GraphqlOperation = { + query: ASTNode; + variables?: Record; +}; + +export const makeMetadataAPIRequestWithMemberRole = ( + graphqlOperation: GraphqlOperation, +) => { + const client = request(`http://localhost:${APP_PORT}`); + + return client + .post('/metadata') + .set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`) + .send({ + query: print(graphqlOperation.query), + variables: graphqlOperation.variables || {}, + }); +};