diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index d3e6448e1..2488a6c9b 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -421,7 +421,15 @@ export const SettingsRoutes = ({ /> )} - } /> + + } + > + } /> + ); diff --git a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx index 3dbc4e606..1e6e8a497 100644 --- a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx +++ b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx @@ -117,7 +117,8 @@ export const useSettingsNavigationItems = (): SettingsNavigationSection[] => { label: t`Billing`, path: SettingsPath.Billing, Icon: IconCurrencyDollar, - isHidden: !isBillingEnabled, + isHidden: + !isBillingEnabled || !permissionMap[SettingsFeatures.WORKSPACE], }, { label: t`Roles`, @@ -181,7 +182,9 @@ export const useSettingsNavigationItems = (): SettingsNavigationSection[] => { label: t`Lab`, path: SettingsPath.Lab, Icon: IconFlask, - isHidden: !labPublicFeatureFlags.length, + isHidden: + !labPublicFeatureFlags.length || + !permissionMap[SettingsFeatures.WORKSPACE], }, { label: t`Releases`, diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts index a7d63f73d..d0a7b3e4d 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts @@ -34,6 +34,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature- import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @Module({ imports: [ @@ -41,6 +42,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; StripeModule, DomainManagerModule, MessageQueueModule, + PermissionsModule, TypeOrmModule.forFeature( [ BillingSubscription, diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts index 0d3d6f9ed..208d69165 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts @@ -1,9 +1,10 @@ /* @license Enterprise */ -import { UseGuards } from '@nestjs/common'; +import { UseFilters, UseGuards } from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { GraphQLError } from 'graphql'; +import { SettingsFeatures } from 'twenty-shared'; import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input'; import { BillingProductInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-product.input'; @@ -26,10 +27,13 @@ import { User } from 'src/engine/core-modules/user/user.entity'; 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'; @Resolver() +@UseFilters(PermissionsGraphqlApiExceptionFilter) export class BillingResolver { constructor( private readonly billingSubscriptionService: BillingSubscriptionService, @@ -55,7 +59,10 @@ export class BillingResolver { } @Query(() => BillingSessionOutput) - @UseGuards(WorkspaceAuthGuard) + @UseGuards( + WorkspaceAuthGuard, + SettingsPermissionsGuard(SettingsFeatures.WORKSPACE), + ) async billingPortalSession( @AuthWorkspace() workspace: Workspace, @Args() { returnUrlPath }: BillingSessionInput, @@ -134,7 +141,10 @@ export class BillingResolver { } @Mutation(() => BillingUpdateOutput) - @UseGuards(WorkspaceAuthGuard) + @UseGuards( + WorkspaceAuthGuard, + SettingsPermissionsGuard(SettingsFeatures.WORKSPACE), + ) async updateBillingSubscription(@AuthWorkspace() workspace: Workspace) { await this.billingSubscriptionService.applyBillingSubscription(workspace); diff --git a/packages/twenty-server/src/engine/core-modules/lab/lab.module.ts b/packages/twenty-server/src/engine/core-modules/lab/lab.module.ts index 5b236ba4f..ae4fb2fd5 100644 --- a/packages/twenty-server/src/engine/core-modules/lab/lab.module.ts +++ b/packages/twenty-server/src/engine/core-modules/lab/lab.module.ts @@ -2,14 +2,20 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { LabResolver } from './lab.resolver'; import { LabService } from './services/lab.service'; @Module({ - imports: [TypeOrmModule.forFeature([FeatureFlag, Workspace], 'core')], + imports: [ + TypeOrmModule.forFeature([FeatureFlag, Workspace], 'core'), + FeatureFlagModule, + PermissionsModule, + ], providers: [LabService, LabResolver], exports: [LabService], }) diff --git a/packages/twenty-server/src/engine/core-modules/lab/lab.resolver.ts b/packages/twenty-server/src/engine/core-modules/lab/lab.resolver.ts index 1b435b89f..45c1ced71 100644 --- a/packages/twenty-server/src/engine/core-modules/lab/lab.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/lab/lab.resolver.ts @@ -1,16 +1,21 @@ import { UseFilters, UseGuards } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { SettingsFeatures } from 'twenty-shared'; + import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { UpdateLabPublicFeatureFlagInput } from 'src/engine/core-modules/lab/dtos/update-lab-public-feature-flag.input'; import { LabService } from 'src/engine/core-modules/lab/services/lab.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; @Resolver() -@UseFilters(AuthGraphqlApiExceptionFilter) +@UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter) +@UseGuards(SettingsPermissionsGuard(SettingsFeatures.WORKSPACE)) export class LabResolver { constructor(private labService: LabService) {} diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/security.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/security.integration-spec.ts new file mode 100644 index 000000000..44b36bff6 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/security.integration-spec.ts @@ -0,0 +1,586 @@ +import { gql } from 'graphql-tag'; +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('Security permissions', () => { + let originalWorkspaceState; + + beforeAll(async () => { + // Store original workspace state + const query = gql` + query getWorkspace { + currentWorkspace { + displayName + isGoogleAuthEnabled + isMicrosoftAuthEnabled + isPasswordAuthEnabled + logo + isPublicInviteLinkEnabled + subdomain + isCustomDomainEnabled + } + } + `; + + const response = await makeGraphqlAPIRequest({ query }); + + originalWorkspaceState = response.body.data.currentWorkspace; + + 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); + + // Restore workspace state + const restoreQuery = gql` + mutation updateWorkspace { + updateWorkspace(data: { + displayName: "${originalWorkspaceState.displayName}", + subdomain: "${originalWorkspaceState.subdomain}", + logo: "${originalWorkspaceState.logo}", + isGoogleAuthEnabled: ${originalWorkspaceState.isGoogleAuthEnabled}, + isMicrosoftAuthEnabled: ${originalWorkspaceState.isMicrosoftAuthEnabled}, + isPasswordAuthEnabled: ${originalWorkspaceState.isPasswordAuthEnabled} + isPublicInviteLinkEnabled: ${originalWorkspaceState.isPublicInviteLinkEnabled} + }) { + id + } + } + `; + + await makeGraphqlAPIRequest({ query: restoreQuery }); + }); + + describe('security permissions', () => { + describe('microsoft auth', () => { + it('should update workspace when user has permission (admin role)', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { isMicrosoftAuthEnabled: false }) { + id + isMicrosoftAuthEnabled + } + } + `, + }; + + return client + .post('/graphql') + .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeDefined(); + expect(res.body.errors).toBeUndefined(); + }) + .expect((res) => { + const data = res.body.data.updateWorkspace; + + expect(data).toBeDefined(); + expect(data.isMicrosoftAuthEnabled).toBe(false); + }); + }); + + it('should throw a permission error when user does not have permission (member role)', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { isMicrosoftAuthEnabled: true }) { + id + isMicrosoftAuthEnabled + } + } + `, + }; + + 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, + ); + }); + }); + }); + + describe('google auth', () => { + it('should update workspace when user has permission (admin role)', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { isGoogleAuthEnabled: false }) { + id + isGoogleAuthEnabled + } + } + `, + }; + + return client + .post('/graphql') + .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeDefined(); + expect(res.body.errors).toBeUndefined(); + }) + .expect((res) => { + const data = res.body.data.updateWorkspace; + + expect(data).toBeDefined(); + expect(data.isGoogleAuthEnabled).toBe(false); + }); + }); + + it('should throw a permission error when user does not have permission (member role)', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { isGoogleAuthEnabled: true }) { + id + isGoogleAuthEnabled + } + } + `, + }; + + 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, + ); + }); + }); + }); + + describe('password auth', () => { + it('should update workspace when user has permission (admin role)', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { isPasswordAuthEnabled: false }) { + id + isPasswordAuthEnabled + } + } + `, + }; + + return client + .post('/graphql') + .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeDefined(); + expect(res.body.errors).toBeUndefined(); + }) + .expect((res) => { + const data = res.body.data.updateWorkspace; + + expect(data).toBeDefined(); + expect(data.isPasswordAuthEnabled).toBe(false); + }); + }); + + it('should throw a permission error when user does not have permission (member role)', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { isPasswordAuthEnabled: true }) { + id + isPasswordAuthEnabled + } + } + `, + }; + + 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, + ); + }); + }); + }); + describe('public invite link', () => { + it('should update isPublicInviteLinkEnabled when user has permission (admin role)', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { isPublicInviteLinkEnabled: false }) { + id + isPublicInviteLinkEnabled + } + } + `, + }; + + return client + .post('/graphql') + .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeDefined(); + expect(res.body.errors).toBeUndefined(); + }) + .expect((res) => { + const data = res.body.data.updateWorkspace; + + expect(data).toBeDefined(); + expect(data.isPublicInviteLinkEnabled).toBe(false); + }); + }); + + it('should throw a permission error when user does not have permission (member role)', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { isPublicInviteLinkEnabled: true }) { + id + isPublicInviteLinkEnabled + } + } + `, + }; + + 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, + ); + }); + }); + }); + }); + + describe('workspace permissions', () => { + describe('delete workspace', () => { + it('should throw a permission error when user does not have permission (member role)', async () => { + const queryData = { + query: ` + mutation DeleteCurrentWorkspace { + deleteCurrentWorkspace { + id + __typename + } + } + `, + }; + + 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, + ); + }); + }); + }); + describe('display name update', () => { + it('should update workspace display name when user has workspace settings permission', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { displayName: "New Workspace Name" }) { + id + displayName + } + } + `, + }; + + return client + .post('/graphql') + .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeDefined(); + expect(res.body.errors).toBeUndefined(); + }) + .expect((res) => { + const data = res.body.data.updateWorkspace; + + expect(data).toBeDefined(); + expect(data.displayName).toBe('New Workspace Name'); + }); + }); + + it('should throw a permission error when user does not have permission (member role)', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { displayName: "Another New Workspace Name" }) { + id + displayName + } + } + `, + }; + + 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, + ); + }); + }); + }); + + describe('subdomain update', () => { + it('should update workspace subdomain when user has workspace settings permission', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { subdomain: "new-subdomain" }) { + id + subdomain + } + } + `, + }; + + return client + .post('/graphql') + .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeDefined(); + expect(res.body.errors).toBeUndefined(); + }) + .expect((res) => { + const data = res.body.data.updateWorkspace; + + expect(data).toBeDefined(); + expect(data.subdomain).toBe('new-subdomain'); + }); + }); + + it('should throw a permission error when user does not have permission (member role)', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { subdomain: "another-new-subdomain" }) { + id + subdomain + } + } + `, + }; + + 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, + ); + }); + }); + }); + + describe('custom domain update', () => { + it('should update workspace custom domain when user has workspace settings permission', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { customDomain: null }) { + id + customDomain + } + } + `, + }; + + return client + .post('/graphql') + .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeDefined(); + expect(res.body.errors).toBeUndefined(); + }) + .expect((res) => { + const data = res.body.data.updateWorkspace; + + expect(data).toBeDefined(); + expect(data.customDomain).toBe(null); + }); + }); + + it('should throw a permission error when user does not have permission (member role)', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { customDomain: "another-new-custom-domain" }) { + id + customDomain + } + } + `, + }; + + 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, + ); + }); + }); + }); + + describe('logo update', () => { + it('should update workspace logo when user has workspace settings permission', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { logo: "new-logo" }) { + id + logo + } + } + `, + }; + + return client + .post('/graphql') + .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) + .send(queryData) + .expect(200) + .expect((res) => { + expect(res.body.data).toBeDefined(); + expect(res.body.errors).toBeUndefined(); + }) + .expect((res) => { + const data = res.body.data.updateWorkspace; + + expect(data).toBeDefined(); + expect(data.logo).toContain('new-logo'); + }); + }); + + it('should throw a permission error when user does not have permission (member role)', async () => { + const queryData = { + query: ` + mutation updateWorkspace { + updateWorkspace(data: { logo: "another-new-logo" }) { + id + logo + } + } + `, + }; + + 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 81c9bd6f5..17434de22 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 @@ -71,247 +71,6 @@ describe('WorkspaceResolver', () => { await makeGraphqlAPIRequest({ query: restoreQuery }); }); - describe('security permissions', () => { - describe('microsoft auth', () => { - it('should update workspace when user has permission (admin role)', async () => { - const queryData = { - query: ` - mutation updateWorkspace { - updateWorkspace(data: { isMicrosoftAuthEnabled: false }) { - id - isMicrosoftAuthEnabled - } - } - `, - }; - - return client - .post('/graphql') - .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.updateWorkspace; - - expect(data).toBeDefined(); - expect(data.isMicrosoftAuthEnabled).toBe(false); - }); - }); - - it('should throw a permission error when user does not have permission (member role)', async () => { - const queryData = { - query: ` - mutation updateWorkspace { - updateWorkspace(data: { isMicrosoftAuthEnabled: true }) { - id - isMicrosoftAuthEnabled - } - } - `, - }; - - 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, - ); - }); - }); - }); - - describe('google auth', () => { - it('should update workspace when user has permission (admin role)', async () => { - const queryData = { - query: ` - mutation updateWorkspace { - updateWorkspace(data: { isGoogleAuthEnabled: false }) { - id - isGoogleAuthEnabled - } - } - `, - }; - - return client - .post('/graphql') - .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.updateWorkspace; - - expect(data).toBeDefined(); - expect(data.isGoogleAuthEnabled).toBe(false); - }); - }); - - it('should throw a permission error when user does not have permission (member role)', async () => { - const queryData = { - query: ` - mutation updateWorkspace { - updateWorkspace(data: { isGoogleAuthEnabled: true }) { - id - isGoogleAuthEnabled - } - } - `, - }; - - 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, - ); - }); - }); - }); - - describe('password auth', () => { - it('should update workspace when user has permission (admin role)', async () => { - const queryData = { - query: ` - mutation updateWorkspace { - updateWorkspace(data: { isPasswordAuthEnabled: false }) { - id - isPasswordAuthEnabled - } - } - `, - }; - - return client - .post('/graphql') - .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.updateWorkspace; - - expect(data).toBeDefined(); - expect(data.isPasswordAuthEnabled).toBe(false); - }); - }); - - it('should throw a permission error when user does not have permission (member role)', async () => { - const queryData = { - query: ` - mutation updateWorkspace { - updateWorkspace(data: { isPasswordAuthEnabled: true }) { - id - isPasswordAuthEnabled - } - } - `, - }; - - 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, - ); - }); - }); - }); - describe('public invite link', () => { - it('should update isPublicInviteLinkEnabled when user has permission (admin role)', async () => { - const queryData = { - query: ` - mutation updateWorkspace { - updateWorkspace(data: { isPublicInviteLinkEnabled: false }) { - id - isPublicInviteLinkEnabled - } - } - `, - }; - - return client - .post('/graphql') - .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) - .send(queryData) - .expect(200) - .expect((res) => { - expect(res.body.data).toBeDefined(); - expect(res.body.errors).toBeUndefined(); - }) - .expect((res) => { - const data = res.body.data.updateWorkspace; - - expect(data).toBeDefined(); - expect(data.isPublicInviteLinkEnabled).toBe(false); - }); - }); - - it('should throw a permission error when user does not have permission (member role)', async () => { - const queryData = { - query: ` - mutation updateWorkspace { - updateWorkspace(data: { isPublicInviteLinkEnabled: true }) { - id - isPublicInviteLinkEnabled - } - } - `, - }; - - 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, - ); - }); - }); - }); - }); - describe('workspace permissions', () => { describe('delete workspace', () => { it('should throw a permission error when user does not have permission (member role)', async () => { @@ -583,4 +342,143 @@ describe('WorkspaceResolver', () => { }); }); }); + + describe('billing', () => { + describe('updateBillingSubscription', () => { + it('should throw a permission error when user does not have permission (member role)', async () => { + const queryData = { + query: ` + mutation UpdateBillingSubscription { + updateBillingSubscription { + 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, + ); + }); + }); + }); + + describe('billingPortalSession', () => { + it('should throw a permission error when user does not have permission (member role)', async () => { + const queryData = { + query: ` + query BillingPortalSession($returnUrlPath: String!) { + billingPortalSession(returnUrlPath: $returnUrlPath) { + url + } + } + `, + variables: { + returnUrlPath: '/settings/billing', + }, + }; + + 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, + ); + }); + }); + }); + }); + + describe('lab', () => { + describe('updateLabPublicFeatureFlag', () => { + it('should update feature flag when user has workspace settings permission', async () => { + const queryData = { + query: ` + mutation UpdateLabPublicFeatureFlag( + $input: UpdateLabPublicFeatureFlagInput! + ) { + updateLabPublicFeatureFlag(input: $input) { + id + key + value + } + } + `, + variables: { + input: { + publicFeatureFlag: 'TestFeature', + value: true, + }, + }, + }; + + await client + .post('/graphql') + .set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`) + .send(queryData) + .expect((res) => { + expect(res.body.data).toBeDefined(); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors[0].message).toBe('Invalid feature flag key'); // this error shows that update has been attempted after the permission check + }); + }); + + it('should throw a permission error when user does not have permission (member role)', async () => { + const queryData = { + query: ` + mutation UpdateLabPublicFeatureFlag( + $input: UpdateLabPublicFeatureFlagInput! + ) { + updateLabPublicFeatureFlag(input: $input) { + id + key + value + } + } + `, + variables: { + input: { + publicFeatureFlag: 'TestFeature', + value: false, + }, + }, + }; + + 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, + ); + }); + }); + }); + }); });