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,
+ );
+ });
+ });
+ });
+ });
});