From e4ae76ac2076562f87cbd0f9dcb153a92bc49364 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Wed, 12 Feb 2025 10:40:26 +0100 Subject: [PATCH] [permissions] Add permission gates on API & Webhooks + Security settings (#10133) Closes https://github.com/twentyhq/core-team-issues/issues/312 Closes https://github.com/twentyhq/core-team-issues/issues/315 --- .../twenty-front/src/generated/graphql.tsx | 25 +------ .../graphql-config/graphql-config.service.ts | 9 ++- ...jects-permissions-requirements.constant.ts | 6 ++ .../graphql-query-runner.module.ts | 2 + .../interfaces/base-resolver-service.ts | 67 +++++++++++++++++- .../engine/core-modules/auth/auth.module.ts | 2 + .../core-modules/auth/auth.resolver.spec.ts | 10 +++ .../engine/core-modules/auth/auth.resolver.ts | 11 ++- .../src/engine/core-modules/sso/sso.module.ts | 9 ++- .../engine/core-modules/sso/sso.resolver.ts | 7 +- .../services/workspace.service.spec.ts | 7 +- .../workspace/services/workspace.service.ts | 70 +++++++++++++++++-- .../workspace/workspace.module.ts | 2 + .../workspace/workspace.resolver.ts | 19 +++-- .../permissions/permissions.exception.ts | 14 +++- .../src/constants/SettingsFeatures.ts | 4 +- 16 files changed, 220 insertions(+), 44 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/system-objects-permissions-requirements.constant.ts diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 29d5f6917..2b007e112 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1,5 +1,5 @@ -import * as Apollo from '@apollo/client'; import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = { [K in keyof T]: T[K] }; @@ -488,7 +488,6 @@ export type Field = { label: Scalars['String']; name: Scalars['String']; object?: Maybe; - objectMetadataId: Scalars['UUID']; options?: Maybe; relation?: Maybe; relationDefinition?: Maybe; @@ -520,7 +519,6 @@ export type FieldFilter = { isActive?: InputMaybe; isCustom?: InputMaybe; isSystem?: InputMaybe; - objectMetadataId?: InputMaybe; or?: InputMaybe>; }; @@ -1073,6 +1071,7 @@ export type Object = { dataSourceId: Scalars['String']; description?: Maybe; fields: ObjectFieldsConnection; + fieldsList: Array; icon?: Maybe; id: Scalars['UUID']; imageIdentifierFieldMetadataId?: Maybe; @@ -1535,7 +1534,7 @@ export enum SettingsFeatures { API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS', DATA_MODEL = 'DATA_MODEL', ROLES = 'ROLES', - SECURITY_SETTINGS = 'SECURITY_SETTINGS', + SECURITY = 'SECURITY', WORKSPACE_SETTINGS = 'WORKSPACE_SETTINGS', WORKSPACE_USERS = 'WORKSPACE_USERS' } @@ -1571,23 +1570,6 @@ export type SignUpOutput = { workspace: WorkspaceUrlsAndId; }; -export type StringFieldComparison = { - eq?: InputMaybe; - gt?: InputMaybe; - gte?: InputMaybe; - iLike?: InputMaybe; - in?: InputMaybe>; - is?: InputMaybe; - isNot?: InputMaybe; - like?: InputMaybe; - lt?: InputMaybe; - lte?: InputMaybe; - neq?: InputMaybe; - notILike?: InputMaybe; - notIn?: InputMaybe>; - notLike?: InputMaybe; -}; - export enum SubscriptionInterval { Day = 'Day', Month = 'Month', @@ -1709,7 +1691,6 @@ export type UpdateFieldInput = { isUnique?: InputMaybe; label?: InputMaybe; name?: InputMaybe; - objectMetadataId?: InputMaybe; options?: InputMaybe; settings?: InputMaybe; }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts index a7e2b5d60..6e8593561 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts @@ -70,7 +70,13 @@ export class GraphQLConfigService let workspace: Workspace | undefined; try { - const { user, workspace, apiKey, workspaceMemberId } = context.req; + const { + user, + workspace, + apiKey, + workspaceMemberId, + userWorkspaceId, + } = context.req; if (!workspace) { return new GraphQLSchema({}); @@ -81,6 +87,7 @@ export class GraphQLConfigService workspace, apiKey, workspaceMemberId, + userWorkspaceId, }); } catch (error) { if (error instanceof UnauthorizedException) { diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/system-objects-permissions-requirements.constant.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/system-objects-permissions-requirements.constant.ts new file mode 100644 index 000000000..8833cee77 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/constants/system-objects-permissions-requirements.constant.ts @@ -0,0 +1,6 @@ +import { SettingsFeatures } from 'twenty-shared'; + +export const SYSTEM_OBJECTS_PERMISSIONS_REQUIREMENTS = { + apiKey: SettingsFeatures.API_KEYS_AND_WEBHOOKS, + webhook: SettingsFeatures.API_KEYS_AND_WEBHOOKS, +} as const; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts index 6ac2d94ac..53fa7f76d 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts @@ -21,6 +21,7 @@ import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-run import { WorkspaceQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module'; import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; const graphqlQueryResolvers = [ GraphqlQueryCreateManyResolverService, @@ -44,6 +45,7 @@ const graphqlQueryResolvers = [ WorkspaceQueryHookModule, WorkspaceQueryRunnerModule, FeatureFlagModule, + PermissionsModule, ], providers: [ ApiEventEmitterService, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts index ba3e5d5ae..00976986c 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import graphqlFields from 'graphql-fields'; -import { capitalize } from 'twenty-shared'; +import { capitalize, SettingsFeatures } from 'twenty-shared'; import { DataSource, ObjectLiteral } from 'typeorm'; import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; @@ -14,6 +14,7 @@ import { WorkspaceResolverBuilderMethodNames, } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; +import { SYSTEM_OBJECTS_PERMISSIONS_REQUIREMENTS } from 'src/engine/api/graphql/graphql-query-runner/constants/system-objects-permissions-requirements.constant'; import { GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; @@ -22,7 +23,18 @@ import { QueryResultGettersFactory } from 'src/engine/api/graphql/workspace-quer import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { + PermissionsException, + PermissionsExceptionCode, + PermissionsExceptionMessage, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; @@ -58,6 +70,8 @@ export abstract class GraphqlQueryBaseResolverService< protected readonly processNestedRelationsHelper: ProcessNestedRelationsHelper; @Inject() protected readonly featureFlagService: FeatureFlagService; + @Inject() + protected readonly permissionsService: PermissionsService; public async execute( args: Input, @@ -69,6 +83,18 @@ export abstract class GraphqlQueryBaseResolverService< await this.validate(args, options); + const permissionsEnabled = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsPermissionsEnabled, + authContext.workspace.id, + ); + + if ( + permissionsEnabled === true && + objectMetadataItemWithFieldMaps.isSystem === true + ) { + await this.validateSystemObjectPermissions(options); + } + const hookedArgs = await this.workspaceQueryHookService.executePreQueryHooks( authContext, @@ -146,6 +172,45 @@ export abstract class GraphqlQueryBaseResolverService< } } + private async validateSystemObjectPermissions( + options: WorkspaceQueryRunnerOptions, + ) { + const { authContext, objectMetadataItemWithFieldMaps } = options; + + if ( + Object.keys(SYSTEM_OBJECTS_PERMISSIONS_REQUIREMENTS).includes( + objectMetadataItemWithFieldMaps.nameSingular, + ) + ) { + if (!authContext.apiKey) { + if (!authContext.userWorkspaceId) { + throw new AuthException( + 'Missing userWorkspaceId in authContext', + AuthExceptionCode.USER_WORKSPACE_NOT_FOUND, + ); + } + + const permissionRequired: SettingsFeatures = + SYSTEM_OBJECTS_PERMISSIONS_REQUIREMENTS[ + objectMetadataItemWithFieldMaps.nameSingular + ]; + + const userHasPermission = + await this.permissionsService.userHasWorkspaceSettingPermission({ + userWorkspaceId: authContext.userWorkspaceId, + _setting: permissionRequired, + }); + + if (!userHasPermission) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSION_DENIED, + PermissionsExceptionCode.PERMISSION_DENIED, + ); + } + } + } + } + protected abstract resolve( executionArgs: GraphqlQueryResolverExecutionArgs, ): Promise; diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 8f1176d14..5bbc57e9c 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -45,6 +45,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; @@ -89,6 +90,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; EmailVerificationModule, GuardRedirectModule, HealthModule, + PermissionsModule, ], controllers: [ GoogleAuthController, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts index ae46def3d..1d4300920 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts @@ -5,10 +5,12 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { AuthResolver } from './auth.resolver'; @@ -85,6 +87,14 @@ describe('AuthResolver', () => { provide: EmailVerificationTokenService, useValue: {}, }, + { + provide: PermissionsService, + useValue: {}, + }, + { + provide: FeatureFlagService, + useValue: {}, + }, // { // provide: OAuthService, // useValue: {}, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 0edc04893..76edf27bd 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -2,7 +2,7 @@ import { UseFilters, UseGuards } from '@nestjs/common'; import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql'; import { InjectRepository } from '@nestjs/typeorm'; -import { SOURCE_LOCALE } from 'twenty-shared'; +import { SettingsFeatures, SOURCE_LOCALE } from 'twenty-shared'; import { Repository } from 'typeorm'; import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input'; @@ -43,8 +43,10 @@ import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace. import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { OriginHeader } from 'src/engine/decorators/auth/origin-header.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 { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input'; import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input'; @@ -58,7 +60,7 @@ import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input import { AuthService } from './services/auth.service'; @Resolver() -@UseFilters(AuthGraphqlApiExceptionFilter) +@UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter) export class AuthResolver { constructor( @InjectRepository(User, 'core') @@ -323,7 +325,10 @@ export class AuthResolver { return { tokens: tokens }; } - @UseGuards(WorkspaceAuthGuard) + @UseGuards( + WorkspaceAuthGuard, + SettingsPermissionsGuard(SettingsFeatures.API_KEYS_AND_WEBHOOKS), + ) @Mutation(() => ApiKeyToken) async generateApiKeyToken( @Args() args: ApiKeyTokenInput, diff --git a/packages/twenty-server/src/engine/core-modules/sso/sso.module.ts b/packages/twenty-server/src/engine/core-modules/sso/sso.module.ts index a7ba6a016..e7e7f68d3 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/sso.module.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/sso.module.ts @@ -6,14 +6,15 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; +import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; 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 { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { SSOResolver } from 'src/engine/core-modules/sso/sso.resolver'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module'; -import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module'; - +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @Module({ imports: [ NestjsQueryTypeOrmModule.forFeature( @@ -23,6 +24,8 @@ import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guar BillingModule, DomainManagerModule, GuardRedirectModule, + PermissionsModule, + FeatureFlagModule, ], exports: [SSOService], providers: [SSOService, SSOResolver], diff --git a/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts b/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts index d79a5130b..34d69e379 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/sso.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/sso.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 omit from 'lodash.omit'; +import { SettingsFeatures } from 'twenty-shared'; import { EnterpriseFeaturesEnabledGuard } from 'src/engine/core-modules/auth/guards/enterprise-features-enabled.guard'; import { DeleteSsoInput } from 'src/engine/core-modules/sso/dtos/delete-sso.input'; @@ -22,9 +23,13 @@ import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { SSOException } from 'src/engine/core-modules/sso/sso.exception'; 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(PermissionsGraphqlApiExceptionFilter) +@UseGuards(SettingsPermissionsGuard(SettingsFeatures.SECURITY)) export class SSOResolver { constructor(private readonly sSOService: SSOService) {} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts index d031f0f1b..d1d433f54 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.spec.ts @@ -6,6 +6,7 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; @@ -14,8 +15,8 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service' import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; -import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { WorkspaceService } from './workspace.service'; @@ -86,6 +87,10 @@ describe('WorkspaceService', () => { provide: ExceptionHandlerService, useValue: {}, }, + { + provide: PermissionsService, + useValue: {}, + }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 43c048bb9..1f0f50b45 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -4,13 +4,20 @@ import { InjectRepository } from '@nestjs/typeorm'; import assert from 'assert'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; -import { isDefined, WorkspaceActivationStatus } from 'twenty-shared'; +import { + isDefined, + SettingsFeatures, + WorkspaceActivationStatus, +} from 'twenty-shared'; import { Repository } from 'typeorm'; +import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; +import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; @@ -22,10 +29,14 @@ import { WorkspaceExceptionCode, } from 'src/engine/core-modules/workspace/workspace.exception'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { + PermissionsException, + PermissionsExceptionCode, + PermissionsExceptionMessage, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service'; import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags'; -import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; -import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -47,6 +58,7 @@ export class WorkspaceService extends TypeOrmQueryService { private readonly environmentService: EnvironmentService, private readonly domainManagerService: DomainManagerService, private readonly exceptionHandlerService: ExceptionHandlerService, + private readonly permissionsService: PermissionsService, ) { super(workspaceRepository); } @@ -114,7 +126,13 @@ export class WorkspaceService extends TypeOrmQueryService { } } - async updateWorkspaceById(payload: Partial & { id: string }) { + async updateWorkspaceById({ + payload, + userWorkspaceId, + }: { + payload: Partial & { id: string }; + userWorkspaceId?: string; + }) { const workspace = await this.workspaceRepository.findOneBy({ id: payload.id, }); @@ -141,6 +159,18 @@ export class WorkspaceService extends TypeOrmQueryService { customDomainRegistered = true; } + const permissionsEnabled = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IsPermissionsEnabled, + workspace.id, + ); + + if (permissionsEnabled) { + await this.validateSecurityPermissions({ + payload, + userWorkspaceId, + }); + } + try { return await this.workspaceRepository.save({ ...workspace, @@ -258,4 +288,36 @@ export class WorkspaceService extends TypeOrmQueryService { return !existingWorkspace; } + + private async validateSecurityPermissions({ + payload, + userWorkspaceId, + }: { + payload: Partial; + userWorkspaceId?: string; + }) { + if ( + isDefined(payload.isGoogleAuthEnabled) || + isDefined(payload.isMicrosoftAuthEnabled) || + isDefined(payload.isPasswordAuthEnabled) || + isDefined(payload.isPublicInviteLinkEnabled) + ) { + if (!userWorkspaceId) { + throw new Error('Missing userWorkspaceId in authContext'); + } + + const userHasPermission = + await this.permissionsService.userHasWorkspaceSettingPermission({ + userWorkspaceId, + _setting: SettingsFeatures.SECURITY, + }); + + if (!userHasPermission) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSION_DENIED, + PermissionsExceptionCode.PERMISSION_DENIED, + ); + } + } + } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index 1e8cb58b6..f6bdda9d2 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -19,6 +19,7 @@ import { User } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/workspace/workspace-workspace-member.listener'; import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; @@ -49,6 +50,7 @@ import { WorkspaceService } from './services/workspace.service'; DataSourceModule, OnboardingModule, TypeORMModule, + PermissionsModule, ], services: [WorkspaceService], resolvers: workspaceAutoResolverOpts, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index a0d9c2f60..e031526e8 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -17,6 +17,7 @@ import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder. import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; +import { CustomDomainDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-details'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; @@ -32,26 +33,30 @@ import { PublicWorkspaceDataOutput, } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output'; import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input'; +import { workspaceUrls } from 'src/engine/core-modules/workspace/dtos/workspace-urls.dto'; import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util'; import { workspaceGraphqlApiExceptionHandler } from 'src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator'; 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 { GraphqlValidationExceptionFilter } from 'src/filters/graphql-validation-exception.filter'; import { assert } from 'src/utils/assert'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; -import { CustomDomainDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-details'; -import { workspaceUrls } from 'src/engine/core-modules/workspace/dtos/workspace-urls.dto'; import { Workspace } from './workspace.entity'; import { WorkspaceService } from './services/workspace.service'; @Resolver(() => Workspace) -@UseFilters(GraphqlValidationExceptionFilter) +@UseFilters( + GraphqlValidationExceptionFilter, + PermissionsGraphqlApiExceptionFilter, +) export class WorkspaceResolver { constructor( private readonly workspaceService: WorkspaceService, @@ -98,11 +103,15 @@ export class WorkspaceResolver { async updateWorkspace( @Args('data') data: UpdateWorkspaceInput, @AuthWorkspace() workspace: Workspace, + @AuthUserWorkspaceId() userWorkspaceId: string, ) { try { return await this.workspaceService.updateWorkspaceById({ - ...data, - id: workspace.id, + payload: { + ...data, + id: workspace.id, + }, + userWorkspaceId, }); } catch (error) { workspaceGraphqlApiExceptionHandler(error); diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts index 5467f9d89..4fb1f4574 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts @@ -8,13 +8,25 @@ export class PermissionsException extends CustomException { } export enum PermissionsExceptionCode { + PERMISSION_DENIED = 'PERMISSION_DENIED', ADMIN_ROLE_NOT_FOUND = 'ADMIN_ROLE_NOT_FOUND', USER_WORKSPACE_NOT_FOUND = 'USER_WORKSPACE_NOT_FOUND', WORKSPACE_ID_ROLE_USER_WORKSPACE_MISMATCH = 'WORKSPACE_ID_ROLE_USER_WORKSPACE_MISMATCH', TOO_MANY_ADMIN_CANDIDATES = 'TOO_MANY_ADMIN_CANDIDATES', USER_WORKSPACE_ALREADY_HAS_ROLE = 'USER_WORKSPACE_ALREADY_HAS_ROLE', - PERMISSION_DENIED = 'PERMISSION_DENIED', WORKSPACE_MEMBER_NOT_FOUND = 'WORKSPACE_MEMBER_NOT_FOUND', ROLE_NOT_FOUND = 'ROLE_NOT_FOUND', CANNOT_UNASSIGN_LAST_ADMIN = 'CANNOT_UNASSIGN_LAST_ADMIN', } + +export enum PermissionsExceptionMessage { + PERMISSION_DENIED = 'User does not have permission', + ADMIN_ROLE_NOT_FOUND = 'Admin role not found', + USER_WORKSPACE_NOT_FOUND = 'User workspace not found', + WORKSPACE_ID_ROLE_USER_WORKSPACE_MISMATCH = 'Workspace id role user workspace mismatch', + TOO_MANY_ADMIN_CANDIDATES = 'Too many admin candidates', + USER_WORKSPACE_ALREADY_HAS_ROLE = 'User workspace already has role', + WORKSPACE_MEMBER_NOT_FOUND = 'Workspace member not found', + ROLE_NOT_FOUND = 'Role not found', + CANNOT_UNASSIGN_LAST_ADMIN = 'Cannot unassign last admin', +} diff --git a/packages/twenty-shared/src/constants/SettingsFeatures.ts b/packages/twenty-shared/src/constants/SettingsFeatures.ts index d2804190f..ebb31874f 100644 --- a/packages/twenty-shared/src/constants/SettingsFeatures.ts +++ b/packages/twenty-shared/src/constants/SettingsFeatures.ts @@ -1,9 +1,9 @@ export enum SettingsFeatures { API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS', - WORKSPACE_SETTINGS = 'WORKSPACE_SETTINGS', + WORKSPACE = 'WORKSPACE', WORKSPACE_USERS = 'WORKSPACE_USERS', ROLES = 'ROLES', DATA_MODEL = 'DATA_MODEL', ADMIN_PANEL = 'ADMIN_PANEL', - SECURITY_SETTINGS = 'SECURITY_SETTINGS', + SECURITY = 'SECURITY', }