diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__tests__/graphql-query-find-duplicates-resolver.service.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__tests__/graphql-query-find-duplicates-resolver.service.spec.ts index fb5694f91..c4793b328 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__tests__/graphql-query-find-duplicates-resolver.service.spec.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__tests__/graphql-query-find-duplicates-resolver.service.spec.ts @@ -10,6 +10,7 @@ import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-r import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; describe('GraphqlQueryFindDuplicatesResolverService', () => { @@ -27,6 +28,7 @@ describe('GraphqlQueryFindDuplicatesResolverService', () => { ProcessNestedRelationsHelper, FeatureFlagService, PermissionsService, + UserRoleService, ], }) .overrideProvider(WorkspaceQueryHookService) @@ -45,6 +47,8 @@ describe('GraphqlQueryFindDuplicatesResolverService', () => { .useValue({}) .overrideProvider(PermissionsService) .useValue({}) + .overrideProvider(UserRoleService) + .useValue({}) .compile(); service = module.get( 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 8342959b1..236a24834 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 @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; import { ProcessNestedRelationsV2Helper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper'; @@ -19,8 +20,9 @@ import { GraphqlQueryUpdateOneResolverService } from 'src/engine/api/graphql/gra import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service'; 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'; +import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity'; +import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; const graphqlQueryResolvers = [ GraphqlQueryCreateManyResolverService, @@ -42,8 +44,9 @@ const graphqlQueryResolvers = [ imports: [ WorkspaceQueryHookModule, WorkspaceQueryRunnerModule, - FeatureFlagModule, PermissionsModule, + TypeOrmModule.forFeature([UserWorkspaceRoleEntity], 'metadata'), + UserRoleModule, ], providers: [ ApiEventEmitterService, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts index 2192d278e..7806f7437 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations-v2.helper.ts @@ -1,12 +1,11 @@ import { Injectable } from '@nestjs/common'; +import { FieldMetadataType } from 'twenty-shared/types'; import { - DataSource, FindOptionsRelations, ObjectLiteral, SelectQueryBuilder, } from 'typeorm'; -import { FieldMetadataType } from 'twenty-shared/types'; import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; @@ -22,6 +21,7 @@ import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.typ import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; +import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; import { isFieldMetadataOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; @@ -41,6 +41,7 @@ export class ProcessNestedRelationsV2Helper { limit, authContext, dataSource, + roleId, }: { objectMetadataMaps: ObjectMetadataMaps; parentObjectMetadataItem: ObjectMetadataItemWithFieldMaps; @@ -50,7 +51,8 @@ export class ProcessNestedRelationsV2Helper { aggregate?: Record; limit: number; authContext: AuthContext; - dataSource: DataSource; + dataSource: WorkspaceDataSource; + roleId?: string; }): Promise { const processRelationTasks = Object.entries(relations).map( ([sourceFieldName, nestedRelations]) => @@ -65,6 +67,7 @@ export class ProcessNestedRelationsV2Helper { limit, authContext, dataSource, + roleId, }), ); @@ -82,6 +85,7 @@ export class ProcessNestedRelationsV2Helper { limit, authContext, dataSource, + roleId, }: { objectMetadataMaps: ObjectMetadataMaps; parentObjectMetadataItem: ObjectMetadataItemWithFieldMaps; @@ -92,7 +96,8 @@ export class ProcessNestedRelationsV2Helper { aggregate: Record; limit: number; authContext: AuthContext; - dataSource: DataSource; + dataSource: WorkspaceDataSource; + roleId?: string; }): Promise { const sourceFieldMetadata = parentObjectMetadataItem.fieldsByName[sourceFieldName]; @@ -121,7 +126,9 @@ export class ProcessNestedRelationsV2Helper { const targetObjectRepository = dataSource.getRepository( targetObjectMetadata.nameSingular, + roleId, ); + const targetObjectQueryBuilder = targetObjectRepository.createQueryBuilder( targetObjectMetadata.nameSingular, ); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts index 52e3d7ac8..de0d6d49e 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts @@ -24,6 +24,7 @@ import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.typ import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; +import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; import { deduceRelationDirection } from 'src/engine/utils/deduce-relation-direction.util'; @@ -45,6 +46,7 @@ export class ProcessNestedRelationsHelper { authContext, dataSource, isNewRelationEnabled, + roleId, }: { objectMetadataMaps: ObjectMetadataMaps; parentObjectMetadataItem: ObjectMetadataItemWithFieldMaps; @@ -54,8 +56,9 @@ export class ProcessNestedRelationsHelper { aggregate?: Record; limit: number; authContext: AuthContext; - dataSource: DataSource; + dataSource: WorkspaceDataSource; isNewRelationEnabled: boolean; + roleId?: string; }): Promise { if (isNewRelationEnabled) { return this.processNestedRelationsV2Helper.processNestedRelations({ @@ -68,6 +71,7 @@ export class ProcessNestedRelationsHelper { limit, authContext, dataSource, + roleId, }); } @@ -85,6 +89,7 @@ export class ProcessNestedRelationsHelper { authContext, dataSource, isNewRelationEnabled, + roleId, }), ); @@ -103,6 +108,7 @@ export class ProcessNestedRelationsHelper { authContext, dataSource, isNewRelationEnabled, + roleId, }: { objectMetadataMaps: ObjectMetadataMaps; parentObjectMetadataItem: ObjectMetadataItemWithFieldMaps; @@ -115,6 +121,7 @@ export class ProcessNestedRelationsHelper { authContext: any; dataSource: DataSource; isNewRelationEnabled: boolean; + roleId?: string; }): Promise { const relationFieldMetadata = parentObjectMetadataItem.fieldsByName[relationName]; @@ -141,6 +148,7 @@ export class ProcessNestedRelationsHelper { authContext, dataSource, isNewRelationEnabled, + roleId, }); } @@ -156,6 +164,7 @@ export class ProcessNestedRelationsHelper { authContext, dataSource, isNewRelationEnabled, + roleId, }: { objectMetadataMaps: ObjectMetadataMaps; parentObjectMetadataItem: ObjectMetadataItemWithFieldMaps; @@ -165,9 +174,10 @@ export class ProcessNestedRelationsHelper { nestedRelations: any; aggregate: Record; limit: number; - authContext: any; - dataSource: DataSource; + authContext: AuthContext; + dataSource: WorkspaceDataSource; isNewRelationEnabled: boolean; + roleId?: string; }): Promise { const { inverseRelationName, referenceObjectMetadata } = this.getRelationMetadata({ @@ -175,8 +185,10 @@ export class ProcessNestedRelationsHelper { parentObjectMetadataItem, relationName, }); + const relationRepository = dataSource.getRepository( referenceObjectMetadata.nameSingular, + roleId, ); const referenceQueryBuilder = relationRepository.createQueryBuilder( @@ -252,6 +264,7 @@ export class ProcessNestedRelationsHelper { authContext, dataSource, isNewRelationEnabled, + roleId, }: { objectMetadataMaps: ObjectMetadataMaps; parentObjectMetadataItem: ObjectMetadataItemWithFieldMaps; @@ -262,16 +275,19 @@ export class ProcessNestedRelationsHelper { aggregate: Record; limit: number; authContext: any; - dataSource: DataSource; + dataSource: WorkspaceDataSource; isNewRelationEnabled: boolean; + roleId?: string; }): Promise { const { referenceObjectMetadata } = this.getRelationMetadata({ objectMetadataMaps, parentObjectMetadataItem, relationName, }); + const relationRepository = dataSource.getRepository( referenceObjectMetadata.nameSingular, + roleId, ); const referenceQueryBuilder = relationRepository.createQueryBuilder( 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 569d7b0a1..d7e3d2453 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 @@ -3,7 +3,7 @@ import { Inject, Injectable } from '@nestjs/common'; import graphqlFields from 'graphql-fields'; import { PermissionsOnAllObjectRecords } from 'twenty-shared/constants'; import { capitalize, isDefined } from 'twenty-shared/utils'; -import { DataSource, ObjectLiteral } from 'typeorm'; +import { ObjectLiteral } from 'typeorm'; import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface'; @@ -26,7 +26,6 @@ import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/g import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service'; import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; 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 { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { PermissionsException, @@ -34,16 +33,19 @@ import { PermissionsExceptionMessage, } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; +import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; export type GraphqlQueryResolverExecutionArgs = { args: Input; options: WorkspaceQueryRunnerOptions; - dataSource: DataSource; + dataSource: WorkspaceDataSource; repository: WorkspaceRepository; graphqlQueryParser: GraphqlQueryParser; graphqlQuerySelectedFieldsResult: GraphqlQuerySelectedFieldsResult; + roleId?: string; }; @Injectable() @@ -68,9 +70,9 @@ export abstract class GraphqlQueryBaseResolverService< @Inject() protected readonly processNestedRelationsHelper: ProcessNestedRelationsHelper; @Inject() - protected readonly featureFlagService: FeatureFlagService; - @Inject() protected readonly permissionsService: PermissionsService; + @Inject() + protected readonly userRoleService: UserRoleService; public async execute( args: Input, @@ -82,18 +84,24 @@ export abstract class GraphqlQueryBaseResolverService< await this.validate(args, options); - const featureFlagsMap = - await this.featureFlagService.getWorkspaceFeatureFlagsMap( + const dataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace( authContext.workspace.id, ); + const featureFlagsMap = dataSource.featureFlagMap; + + const isPermissionsV2Enabled = + featureFlagsMap[FeatureFlagKey.IsPermissionsV2Enabled]; + if (objectMetadataItemWithFieldMaps.isSystem === true) { await this.validateSystemObjectPermissionsOrThrow(options); } else { - await this.validateObjectRecordPermissionsOrThrow({ - operationName, - options, - }); + if (!isPermissionsV2Enabled) + await this.validateObjectRecordPermissionsOrThrow({ + operationName, + options, + }); } const hookedArgs = @@ -110,13 +118,14 @@ export abstract class GraphqlQueryBaseResolverService< ResolverArgsType[capitalize(operationName)], )) as Input; - const dataSource = - await this.twentyORMGlobalManager.getDataSourceForWorkspace( - authContext.workspace.id, - ); + const roleId = await this.userRoleService.getRoleIdForUserWorkspace({ + userWorkspaceId: authContext.userWorkspaceId, + workspaceId: authContext.workspace.id, + }); const repository = dataSource.getRepository( objectMetadataItemWithFieldMaps.nameSingular, + roleId, ); const graphqlQueryParser = new GraphqlQueryParser( @@ -140,6 +149,7 @@ export abstract class GraphqlQueryBaseResolverService< repository, graphqlQueryParser, graphqlQuerySelectedFieldsResult, + roleId, }; const results = await this.resolve( diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts index 4694775a2..0f8f18a1c 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts @@ -35,6 +35,8 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } = executionArgs.options; + const { roleId } = executionArgs; + const objectRecords = await this.insertOrUpsertRecords(executionArgs); const upsertedRecords = await this.fetchUpsertedRecords( @@ -56,6 +58,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol objectMetadataItemWithFieldMaps, objectMetadataMaps, featureFlagsMap, + roleId, ); return this.formatRecordsForResponse( @@ -323,6 +326,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, objectMetadataMaps: ObjectMetadataMaps, featureFlagsMap: Record, + roleId?: string, ): Promise { if (!executionArgs.graphqlQuerySelectedFieldsResult.relations) { return; @@ -338,6 +342,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol dataSource: executionArgs.dataSource, isNewRelationEnabled: featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled], + roleId, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service.ts index 9ae8e3ae0..2cece26eb 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service.ts @@ -29,6 +29,8 @@ export class GraphqlQueryCreateOneResolverService extends GraphqlQueryBaseResolv const { authContext, objectMetadataMaps, objectMetadataItemWithFieldMaps } = executionArgs.options; + const { roleId } = executionArgs; + const objectRecords: InsertResult = !executionArgs.args.upsert ? await executionArgs.repository.insert(executionArgs.args.data) : await executionArgs.repository.upsert(executionArgs.args.data, { @@ -70,6 +72,7 @@ export class GraphqlQueryCreateOneResolverService extends GraphqlQueryBaseResolv dataSource: executionArgs.dataSource, isNewRelationEnabled: featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled], + roleId, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-many-resolver.service.ts index 89878fd5a..51b1e67e1 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-many-resolver.service.ts @@ -28,6 +28,8 @@ export class GraphqlQueryDeleteManyResolverService extends GraphqlQueryBaseResol const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } = executionArgs.options; + const { roleId } = executionArgs; + const queryBuilder = executionArgs.repository.createQueryBuilder( objectMetadataItemWithFieldMaps.nameSingular, ); @@ -71,6 +73,7 @@ export class GraphqlQueryDeleteManyResolverService extends GraphqlQueryBaseResol dataSource: executionArgs.dataSource, isNewRelationEnabled: featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled], + roleId, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-one-resolver.service.ts index e8ade85af..a673ebbe4 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-delete-one-resolver.service.ts @@ -31,6 +31,8 @@ export class GraphqlQueryDeleteOneResolverService extends GraphqlQueryBaseResolv const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } = executionArgs.options; + const { roleId } = executionArgs; + const queryBuilder = executionArgs.repository.createQueryBuilder( objectMetadataItemWithFieldMaps.nameSingular, ); @@ -73,6 +75,7 @@ export class GraphqlQueryDeleteOneResolverService extends GraphqlQueryBaseResolv dataSource: executionArgs.dataSource, isNewRelationEnabled: featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled], + roleId, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-many-resolver.service.ts index 4cf46a6da..aab236a45 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-many-resolver.service.ts @@ -26,6 +26,8 @@ export class GraphqlQueryDestroyManyResolverService extends GraphqlQueryBaseReso const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } = executionArgs.options; + const { roleId } = executionArgs; + const queryBuilder = executionArgs.repository.createQueryBuilder( objectMetadataItemWithFieldMaps.nameSingular, ); @@ -69,6 +71,7 @@ export class GraphqlQueryDestroyManyResolverService extends GraphqlQueryBaseReso dataSource: executionArgs.dataSource, isNewRelationEnabled: featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled], + roleId, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts index 7c58da6e2..7008ce93d 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts @@ -29,6 +29,8 @@ export class GraphqlQueryDestroyOneResolverService extends GraphqlQueryBaseResol const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } = executionArgs.options; + const { roleId } = executionArgs; + const queryBuilder = executionArgs.repository.createQueryBuilder( objectMetadataItemWithFieldMaps.nameSingular, ); @@ -69,6 +71,7 @@ export class GraphqlQueryDestroyOneResolverService extends GraphqlQueryBaseResol dataSource: executionArgs.dataSource, isNewRelationEnabled: featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled], + roleId, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts index fe4e17260..4ccd0dab8 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts @@ -47,6 +47,8 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } = executionArgs.options; + const { roleId } = executionArgs; + const queryBuilder = executionArgs.repository.createQueryBuilder( objectMetadataItemWithFieldMaps.nameSingular, ); @@ -156,6 +158,7 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve dataSource: executionArgs.dataSource, isNewRelationEnabled: featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled], + roleId, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts index cc44c3e54..3414235b8 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts @@ -36,6 +36,8 @@ export class GraphqlQueryFindOneResolverService extends GraphqlQueryBaseResolver const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } = executionArgs.options; + const { roleId } = executionArgs; + const queryBuilder = executionArgs.repository.createQueryBuilder( objectMetadataItemWithFieldMaps.nameSingular, ); @@ -79,6 +81,7 @@ export class GraphqlQueryFindOneResolverService extends GraphqlQueryBaseResolver dataSource: executionArgs.dataSource, isNewRelationEnabled: featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled], + roleId, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service.ts index 2ba9ec211..5c7fd967e 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service.ts @@ -28,6 +28,8 @@ export class GraphqlQueryRestoreManyResolverService extends GraphqlQueryBaseReso const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } = executionArgs.options; + const { roleId } = executionArgs; + const queryBuilder = executionArgs.repository.createQueryBuilder( objectMetadataItemWithFieldMaps.nameSingular, ); @@ -71,6 +73,7 @@ export class GraphqlQueryRestoreManyResolverService extends GraphqlQueryBaseReso dataSource: executionArgs.dataSource, isNewRelationEnabled: featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled], + roleId, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service.ts index 9bc1df3f1..9c694dd7e 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service.ts @@ -31,6 +31,8 @@ export class GraphqlQueryRestoreOneResolverService extends GraphqlQueryBaseResol const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } = executionArgs.options; + const { roleId } = executionArgs; + const queryBuilder = executionArgs.repository.createQueryBuilder( objectMetadataItemWithFieldMaps.nameSingular, ); @@ -73,6 +75,7 @@ export class GraphqlQueryRestoreOneResolverService extends GraphqlQueryBaseResol dataSource: executionArgs.dataSource, isNewRelationEnabled: featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled], + roleId, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts index 74f63c6fd..703c57305 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts @@ -35,6 +35,8 @@ export class GraphqlQueryUpdateManyResolverService extends GraphqlQueryBaseResol const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } = executionArgs.options; + const { roleId } = executionArgs; + const queryBuilder = executionArgs.repository.createQueryBuilder( objectMetadataItemWithFieldMaps.nameSingular, ); @@ -108,6 +110,7 @@ export class GraphqlQueryUpdateManyResolverService extends GraphqlQueryBaseResol dataSource: executionArgs.dataSource, isNewRelationEnabled: featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled], + roleId, }); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts index c0cbb26cf..d6d803ae0 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts @@ -34,6 +34,8 @@ export class GraphqlQueryUpdateOneResolverService extends GraphqlQueryBaseResolv const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } = executionArgs.options; + const { roleId } = executionArgs; + const queryBuilder = executionArgs.repository.createQueryBuilder( objectMetadataItemWithFieldMaps.nameSingular, ); @@ -102,6 +104,7 @@ export class GraphqlQueryUpdateOneResolverService extends GraphqlQueryBaseResolv dataSource: executionArgs.dataSource, isNewRelationEnabled: featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled], + roleId, }); } 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 67dea4a5e..1153689f8 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,11 +2,16 @@ import { Module } from '@nestjs/common'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { WorkspaceFeatureFlagMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module'; import { LabResolver } from './lab.resolver'; @Module({ - imports: [FeatureFlagModule, PermissionsModule], + imports: [ + FeatureFlagModule, + PermissionsModule, + WorkspaceFeatureFlagMapCacheModule, + ], providers: [LabResolver], exports: [], }) 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 a036db0a6..a6a0c39fb 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 @@ -13,12 +13,16 @@ import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; +import { WorkspaceFeatureFlagMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service'; @Resolver() @UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter) @UseGuards(SettingsPermissionsGuard(SettingPermissionType.WORKSPACE)) export class LabResolver { - constructor(private featureFlagService: FeatureFlagService) {} + constructor( + private featureFlagService: FeatureFlagService, + private workspaceFeatureFlagMapCacheService: WorkspaceFeatureFlagMapCacheService, + ) {} @UseGuards(WorkspaceAuthGuard) @Mutation(() => FeatureFlag) @@ -27,12 +31,20 @@ export class LabResolver { @AuthWorkspace() workspace: Workspace, ): Promise { try { - return await this.featureFlagService.upsertWorkspaceFeatureFlag({ + const result = await this.featureFlagService.upsertWorkspaceFeatureFlag({ workspaceId: workspace.id, featureFlag: input.publicFeatureFlag, value: input.value, shouldBePublic: true, }); + + await this.workspaceFeatureFlagMapCacheService.recomputeFeatureFlagMapCache( + { + workspaceId: workspace.id, + }, + ); + + return result; } catch (error) { if (error instanceof FeatureFlagException) { throw new UserInputError(error.message); diff --git a/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.module.ts b/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.module.ts index addf997bc..f7139d051 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.module.ts @@ -5,6 +5,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity'; import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { WorkspaceRolesPermissionsCacheModule } from 'src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.module'; @Module({ imports: [ @@ -12,6 +13,7 @@ import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; [ObjectPermissionEntity, RoleEntity, ObjectMetadataEntity], 'metadata', ), + WorkspaceRolesPermissionsCacheModule, ], providers: [ObjectPermissionService], exports: [ObjectPermissionService], diff --git a/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.service.ts index af844d778..2920f10d2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.service.ts @@ -12,6 +12,7 @@ import { PermissionsExceptionMessage, } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { WorkspaceRolesPermissionsCacheService } from 'src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.service'; export class ObjectPermissionService { constructor( @@ -21,6 +22,7 @@ export class ObjectPermissionService { private readonly roleRepository: Repository, @InjectRepository(ObjectMetadataEntity, 'metadata') private readonly objectMetadataRepository: Repository, + private readonly workspaceRolesPermissionsCacheService: WorkspaceRolesPermissionsCacheService, ) {} public async upsertObjectPermission({ @@ -52,6 +54,12 @@ export class ObjectPermissionService { throw new Error('Failed to upsert object permission'); } + await this.workspaceRolesPermissionsCacheService.recomputeRolesPermissionsCache( + { + workspaceId, + }, + ); + return this.objectPermissionRepository.findOne({ where: { id: objectPermissionId, diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts index 52636242a..e84fdeec0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts @@ -13,6 +13,7 @@ import { RoleService } from 'src/engine/metadata-modules/role/role.service'; import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity'; import { SettingPermissionModule } from 'src/engine/metadata-modules/setting-permission/setting-permission.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; +import { WorkspaceRolesPermissionsCacheModule } from 'src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role. FeatureFlagModule, ObjectPermissionModule, SettingPermissionModule, + WorkspaceRolesPermissionsCacheModule, ], providers: [RoleService, RoleResolver], exports: [RoleService], diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts index e60e08f22..59bdf5ac8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts @@ -111,7 +111,7 @@ export class RoleResolver { ): Promise { await this.validatePermissionsV2EnabledOrThrow(workspace); - return this.roleService.createRole({ + return await this.roleService.createRole({ workspaceId: workspace.id, input: createRoleInput, }); @@ -124,10 +124,12 @@ export class RoleResolver { ): Promise { await this.validatePermissionsV2EnabledOrThrow(workspace); - return this.roleService.updateRole({ + const role = await this.roleService.updateRole({ input: updateRoleInput, workspaceId: workspace.id, }); + + return role; } @Mutation(() => String) @@ -137,7 +139,12 @@ export class RoleResolver { ): Promise { await this.validatePermissionsV2EnabledOrThrow(workspace); - return this.roleService.deleteRole(roleId, workspace.id); + const deletedRoleId = await this.roleService.deleteRole( + roleId, + workspace.id, + ); + + return deletedRoleId; } @Mutation(() => ObjectPermissionDTO) @@ -148,10 +155,13 @@ export class RoleResolver { ) { await this.validatePermissionsV2EnabledOrThrow(workspace); - return this.objectPermissionService.upsertObjectPermission({ - workspaceId: workspace.id, - input: upsertObjectPermissionInput, - }); + const objectPermission = + await this.objectPermissionService.upsertObjectPermission({ + workspaceId: workspace.id, + input: upsertObjectPermissionInput, + }); + + return objectPermission; } @Mutation(() => [SettingPermissionDTO]) diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts index c6d10ed32..30ab55bd6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts @@ -20,6 +20,7 @@ import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { isArgDefinedIfProvidedOrThrow } from 'src/engine/metadata-modules/utils/is-arg-defined-if-provided-or-throw.util'; +import { WorkspaceRolesPermissionsCacheService } from 'src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.service'; export class RoleService { constructor( @@ -30,6 +31,7 @@ export class RoleService { @InjectRepository(UserWorkspaceRoleEntity, 'metadata') private readonly userWorkspaceRoleRepository: Repository, private readonly userRoleService: UserRoleService, + private readonly workspaceRolesPermissionsCacheService: WorkspaceRolesPermissionsCacheService, ) {} public async getWorkspaceRoles(workspaceId: string): Promise { @@ -63,7 +65,7 @@ export class RoleService { }): Promise { await this.validateRoleInput({ input, workspaceId }); - return this.roleRepository.save({ + const role = this.roleRepository.save({ label: input.label, description: input.description, icon: input.icon, @@ -75,6 +77,14 @@ export class RoleService { isEditable: true, workspaceId, }); + + await this.workspaceRolesPermissionsCacheService.recomputeRolesPermissionsCache( + { + workspaceId, + }, + ); + + return role; } public async updateRole({ @@ -114,6 +124,12 @@ export class RoleService { ...input.update, }); + await this.workspaceRolesPermissionsCacheService.recomputeRolesPermissionsCache( + { + workspaceId, + }, + ); + return { ...existingRole, ...updatedRole }; } @@ -176,6 +192,12 @@ export class RoleService { workspaceId, }); + await this.workspaceRolesPermissionsCacheService.recomputeRolesPermissionsCache( + { + workspaceId, + }, + ); + return roleId; } diff --git a/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts index c9a2a0848..34eeb5786 100644 --- a/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts @@ -58,6 +58,24 @@ export class UserRoleService { }); } + public async getRoleIdForUserWorkspace({ + workspaceId, + userWorkspaceId, + }: { + workspaceId: string; + userWorkspaceId?: string; + }): Promise { + if (!isDefined(userWorkspaceId)) { + return; + } + + const userWorkspaceRole = await this.userWorkspaceRoleRepository.findOne({ + where: { userWorkspaceId, workspaceId }, + }); + + return userWorkspaceRole?.roleId; + } + public async getRolesByUserWorkspaces({ userWorkspaceIds, workspaceId, diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service.ts new file mode 100644 index 000000000..6e8cf473d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service.ts @@ -0,0 +1,47 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; + +@Injectable() +export class WorkspaceFeatureFlagMapCacheService { + logger = new Logger(WorkspaceFeatureFlagMapCacheService.name); + + constructor( + private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, + private readonly featureFlagService: FeatureFlagService, + ) {} + + async recomputeFeatureFlagMapCache({ + workspaceId, + ignoreLock = false, + }: { + workspaceId: string; + ignoreLock?: boolean; + }): Promise { + const isAlreadyCaching = + await this.workspaceCacheStorageService.getFeatureFlagMapOngoingCachingLock( + workspaceId, + ); + + if (!ignoreLock && isAlreadyCaching) { + return; + } + + await this.workspaceCacheStorageService.addFeatureFlagMapOngoingCachingLock( + workspaceId, + ); + + const freshFeatureFlagMap = + await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspaceId); + + await this.workspaceCacheStorageService.setFeatureFlagMap( + workspaceId, + freshFeatureFlagMap, + ); + + await this.workspaceCacheStorageService.removeFeatureFlagMapOngoingCachingLock( + workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module.ts new file mode 100644 index 000000000..f44911b07 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceFeatureFlagMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service'; +import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Workspace], 'core'), + WorkspaceCacheStorageModule, + FeatureFlagModule, + ], + providers: [WorkspaceFeatureFlagMapCacheService], + exports: [WorkspaceFeatureFlagMapCacheService], +}) +export class WorkspaceFeatureFlagMapCacheModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.module.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.module.ts new file mode 100644 index 000000000..051c369f8 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; + +import { WorkspaceRolesPermissionsCacheService } from './workspace-roles-permissions-cache.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Workspace], 'core'), + TypeOrmModule.forFeature([ObjectMetadataEntity, RoleEntity], 'metadata'), + WorkspaceCacheStorageModule, + ], + providers: [WorkspaceRolesPermissionsCacheService], + exports: [WorkspaceRolesPermissionsCacheService], +}) +export class WorkspaceRolesPermissionsCacheModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.service.ts new file mode 100644 index 000000000..4d6ce7d49 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.service.ts @@ -0,0 +1,128 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { + ObjectRecordsPermissions, + ObjectRecordsPermissionsByRoleId, +} from 'twenty-shared/types'; +import { Repository } from 'typeorm'; + +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; + +@Injectable() +export class WorkspaceRolesPermissionsCacheService { + logger = new Logger(WorkspaceRolesPermissionsCacheService.name); + + constructor( + @InjectRepository(ObjectMetadataEntity, 'metadata') + private readonly objectMetadataRepository: Repository, + @InjectRepository(RoleEntity, 'metadata') + private readonly roleRepository: Repository, + private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, + ) {} + + async recomputeRolesPermissionsCache({ + workspaceId, + ignoreLock = false, + }: { + workspaceId: string; + ignoreLock?: boolean; + }): Promise { + const isAlreadyCaching = + await this.workspaceCacheStorageService.getRolesPermissionsOngoingCachingLock( + workspaceId, + ); + + if (!ignoreLock && isAlreadyCaching) { + return; + } + + await this.workspaceCacheStorageService.addRolesPermissionsOngoingCachingLock( + workspaceId, + ); + + const freshObjectRecordsPermissionsByRoleId = + await this.getObjectRecordPermissionsForRoles({ + workspaceId, + }); + + await this.workspaceCacheStorageService.setRolesPermissions( + workspaceId, + freshObjectRecordsPermissionsByRoleId, + ); + + await this.workspaceCacheStorageService.removeRolesPermissionsOngoingCachingLock( + workspaceId, + ); + } + + private async getObjectRecordPermissionsForRoles({ + workspaceId, + }: { + workspaceId: string; + }): Promise { + const roles = await this.roleRepository.find({ + where: { + workspaceId, + }, + }); + + const workspaceObjectMetadataNames = + await this.getWorkspaceObjectMetadataNames(workspaceId); + + const permissionsByRoleId: ObjectRecordsPermissionsByRoleId = {}; + + for (const role of roles) { + const objectRecordsPermissions: ObjectRecordsPermissions = {}; + + for (const objectMetadataNameSingular of workspaceObjectMetadataNames) { + objectRecordsPermissions[objectMetadataNameSingular] = { + canRead: role.canReadAllObjectRecords, + canUpdate: role.canUpdateAllObjectRecords, + canSoftDelete: role.canSoftDeleteAllObjectRecords, + canDestroy: role.canDestroyAllObjectRecords, + }; + } + + permissionsByRoleId[role.id] = objectRecordsPermissions; + } + + return permissionsByRoleId; + } + + private async getWorkspaceObjectMetadataNames( + workspaceId: string, + ): Promise { + let workspaceObjectMetadataNames: string[] = []; + const metadataVersion = + await this.workspaceCacheStorageService.getMetadataVersion(workspaceId); + + if (metadataVersion) { + const objectMetadataMaps = + await this.workspaceCacheStorageService.getObjectMetadataMaps( + workspaceId, + metadataVersion, + ); + + workspaceObjectMetadataNames = Object.values( + objectMetadataMaps?.byId ?? {}, + ).map((objectMetadata) => objectMetadata.nameSingular); + } + + if (!metadataVersion || workspaceObjectMetadataNames.length === 0) { + const workspaceObjectMetadata = await this.objectMetadataRepository.find({ + where: { + workspaceId, + }, + }); + + workspaceObjectMetadataNames = workspaceObjectMetadata.map( + (objectMetadata) => objectMetadata.nameSingular, + ); + } + + return workspaceObjectMetadataNames; + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/datasource/workspace.datasource.ts b/packages/twenty-server/src/engine/twenty-orm/datasource/workspace.datasource.ts index 076ef7f7e..44385334a 100644 --- a/packages/twenty-server/src/engine/twenty-orm/datasource/workspace.datasource.ts +++ b/packages/twenty-server/src/engine/twenty-orm/datasource/workspace.datasource.ts @@ -1,3 +1,4 @@ +import { ObjectRecordsPermissionsByRoleId } from 'twenty-shared/types'; import { DataSource, DataSourceOptions, @@ -6,6 +7,7 @@ import { QueryRunner, } from 'typeorm'; +import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/entity.manager'; @@ -14,20 +16,37 @@ import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace. export class WorkspaceDataSource extends DataSource { readonly internalContext: WorkspaceInternalContext; readonly manager: WorkspaceEntityManager; + featureFlagMapVersion: string; + featureFlagMap: FeatureFlagMap; + rolesPermissionsVersion?: string; + permissionsPerRoleId?: ObjectRecordsPermissionsByRoleId; constructor( internalContext: WorkspaceInternalContext, options: DataSourceOptions, + featureFlagMapVersion: string, + featureFlagMap: FeatureFlagMap, + rolesPermissionsVersion?: string, + permissionsPerRoleId?: ObjectRecordsPermissionsByRoleId, ) { super(options); this.internalContext = internalContext; // Recreate manager after internalContext has been initialized this.manager = this.createEntityManager(); + this.featureFlagMap = featureFlagMap; + this.featureFlagMapVersion = featureFlagMapVersion; + this.rolesPermissionsVersion = rolesPermissionsVersion; + this.permissionsPerRoleId = permissionsPerRoleId; } override getRepository( target: EntityTarget, + roleId?: string, ): WorkspaceRepository { + if (roleId) { + return this.manager.getRepository(target, roleId); + } + return this.manager.getRepository(target); } @@ -36,4 +55,20 @@ export class WorkspaceDataSource extends DataSource { ): WorkspaceEntityManager { return new WorkspaceEntityManager(this.internalContext, this, queryRunner); } + + setRolesPermissionsVersion(rolesPermissionsVersion: string) { + this.rolesPermissionsVersion = rolesPermissionsVersion; + } + + setRolesPermissions(permissionsPerRoleId: ObjectRecordsPermissionsByRoleId) { + this.permissionsPerRoleId = permissionsPerRoleId; + } + + setFeatureFlagMap(featureFlagMap: FeatureFlagMap) { + this.featureFlagMap = featureFlagMap; + } + + setFeatureFlagMapVersion(featureFlagMapVersion: string) { + this.featureFlagMapVersion = featureFlagMapVersion; + } } diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/entity.manager.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/entity.manager.ts index 66f03915f..5cadf9792 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/entity.manager.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/entity.manager.ts @@ -4,14 +4,17 @@ import { EntityTarget, ObjectLiteral, QueryRunner, + Repository, } from 'typeorm'; import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; +import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; export class WorkspaceEntityManager extends EntityManager { private readonly internalContext: WorkspaceInternalContext; + readonly repositories: Map>; constructor( internalContext: WorkspaceInternalContext, @@ -20,27 +23,39 @@ export class WorkspaceEntityManager extends EntityManager { ) { super(connection, queryRunner); this.internalContext = internalContext; + this.repositories = new Map(); } override getRepository( target: EntityTarget, + roleId?: string, ): WorkspaceRepository { - // find already created repository instance and return it if found - - const repoFromMap = this.repositories.get(target); + const dataSource = this.connection as WorkspaceDataSource; + const repositoryKey = `${dataSource.getMetadata(target).name}_${roleId ?? 'default'}${dataSource.rolesPermissionsVersion ? `_${dataSource.rolesPermissionsVersion}` : ''}${dataSource.featureFlagMapVersion ? `_${dataSource.featureFlagMapVersion}` : ''}`; + const repoFromMap = this.repositories.get(repositoryKey); if (repoFromMap) { return repoFromMap as WorkspaceRepository; } + let objectPermissions = {}; + + if (roleId) { + const objectPermissionsByRoleId = dataSource.permissionsPerRoleId; + + objectPermissions = objectPermissionsByRoleId?.[roleId] ?? {}; + } + const newRepository = new WorkspaceRepository( this.internalContext, target, this, + dataSource.featureFlagMap, this.queryRunner, + objectPermissions, ); - this.repositories.set(target, newRepository); + this.repositories.set(repositoryKey, newRepository); return newRepository; } diff --git a/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts b/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts index 2b65d9ef3..32ba899a1 100644 --- a/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts +++ b/packages/twenty-server/src/engine/twenty-orm/exceptions/twenty-orm.exception.ts @@ -11,4 +11,6 @@ export enum TwentyORMExceptionCode { METADATA_VERSION_MISMATCH = 'METADATA_VERSION_MISMATCH', METADATA_COLLECTION_NOT_FOUND = 'METADATA_COLLECTION_NOT_FOUND', WORKSPACE_SCHEMA_NOT_FOUND = 'WORKSPACE_SCHEMA_NOT_FOUND', + ROLES_PERMISSIONS_VERSION_NOT_FOUND = 'ROLES_PERMISSIONS_VERSION_NOT_FOUND', + FEATURE_FLAG_MAP_VERSION_NOT_FOUND = 'FEATURE_FLAG_MAP_VERSION_NOT_FOUND', } diff --git a/packages/twenty-server/src/engine/twenty-orm/factories/scoped-workspace-context.factory.ts b/packages/twenty-server/src/engine/twenty-orm/factories/scoped-workspace-context.factory.ts index 3a638665f..50e8db2eb 100644 --- a/packages/twenty-server/src/engine/twenty-orm/factories/scoped-workspace-context.factory.ts +++ b/packages/twenty-server/src/engine/twenty-orm/factories/scoped-workspace-context.factory.ts @@ -12,6 +12,7 @@ export class ScopedWorkspaceContextFactory { public create(): { workspaceId: string | null; workspaceMetadataVersion: number | null; + userWorkspaceId: string | null; } { const workspaceId: string | undefined = this.request?.['req']?.['workspaceId'] || @@ -22,6 +23,7 @@ export class ScopedWorkspaceContextFactory { return { workspaceId: workspaceId ?? null, workspaceMetadataVersion: workspaceMetadataVersion ?? null, + userWorkspaceId: this.request?.['req']?.['userWorkspaceId'] ?? null, }; } } diff --git a/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts b/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts index baf4808e9..44a82a6c2 100644 --- a/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts +++ b/packages/twenty-server/src/engine/twenty-orm/factories/workspace-datasource.factory.ts @@ -1,12 +1,18 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ObjectRecordsPermissionsByRoleId } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { EntitySchema } from 'typeorm'; +import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { WorkspaceFeatureFlagMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service'; import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; +import { WorkspaceRolesPermissionsCacheService } from 'src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.service'; import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; import { TwentyORMException, @@ -17,6 +23,11 @@ import { PromiseMemoizer } from 'src/engine/twenty-orm/storage/promise-memoizer. import { CacheKey } from 'src/engine/twenty-orm/storage/types/cache-key.type'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +type CacheResult = { + version: T; + data: U; +}; + @Injectable() export class WorkspaceDatasourceFactory { private readonly logger = new Logger(WorkspaceDatasourceFactory.name); @@ -28,19 +39,35 @@ export class WorkspaceDatasourceFactory { private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService, private readonly entitySchemaFactory: EntitySchemaFactory, + private readonly workspaceRolesPermissionsCacheService: WorkspaceRolesPermissionsCacheService, + private readonly workspaceFeatureFlagMapCacheService: WorkspaceFeatureFlagMapCacheService, ) {} public async create( workspaceId: string, workspaceMetadataVersion: number | null, - failOnMetadataCacheMiss = true, + shouldFailIfMetadataNotFound = true, ): Promise { const cachedWorkspaceMetadataVersion = await this.getWorkspaceMetadataVersionFromCache( workspaceId, - failOnMetadataCacheMiss, + shouldFailIfMetadataNotFound, ); + const { data: cachedFeatureFlagMap, version: cachedFeatureFlagMapVersion } = + await this.getFeatureFlagMapFromCache({ workspaceId }); + + const isPermissionsV2Enabled = + cachedFeatureFlagMap[FeatureFlagKey.IsPermissionsV2Enabled]; + + const { + data: cachedRolesPermissions, + version: cachedRolesPermissionsVersion, + } = await this.getRolesPermissionsFromCache({ + workspaceId, + isPermissionsV2Enabled, + }); + if ( workspaceMetadataVersion !== null && cachedWorkspaceMetadataVersion !== workspaceMetadataVersion @@ -139,6 +166,10 @@ export class WorkspaceDatasourceFactory { } : undefined, }, + cachedFeatureFlagMapVersion, + cachedFeatureFlagMap, + cachedRolesPermissionsVersion, + cachedRolesPermissions, ); await workspaceDataSource.initialize(); @@ -162,28 +193,206 @@ export class WorkspaceDatasourceFactory { throw new Error(`Failed to create WorkspaceDataSource for ${cacheKey}`); } + if (isPermissionsV2Enabled) { + await this.updateWorkspaceDataSourceRolesPermissionsIfNeeded({ + workspaceDataSource, + cachedRolesPermissionsVersion, + cachedRolesPermissions, + }); + } + + await this.updateWorkspaceDataSourceFeatureFlagMapIfNeeded({ + workspaceDataSource, + cachedFeatureFlagMapVersion, + cachedFeatureFlagMap, + }); + return workspaceDataSource; } + private async getFromCacheWithRecompute({ + workspaceId, + getCacheData, + getCacheVersion, + recomputeCache, + cachedEntityName, + exceptionCode, + }: { + workspaceId: string; + getCacheData: (workspaceId: string) => Promise; + getCacheVersion: (workspaceId: string) => Promise; + recomputeCache: (params: { workspaceId: string }) => Promise; + cachedEntityName: string; + exceptionCode: TwentyORMExceptionCode; + }): Promise> { + let cachedVersion: T | undefined; + let cachedData: U | undefined; + + cachedVersion = await getCacheVersion(workspaceId); + cachedData = await getCacheData(workspaceId); + + if (!isDefined(cachedData) || !isDefined(cachedVersion)) { + await recomputeCache({ workspaceId }); + + cachedData = await getCacheData(workspaceId); + cachedVersion = await getCacheVersion(workspaceId); + + if (!isDefined(cachedData) || !isDefined(cachedVersion)) { + throw new TwentyORMException( + `${cachedEntityName} not found after recompute for workspace ${workspaceId}`, + exceptionCode, + ); + } + } + + return { + version: cachedVersion, + data: cachedData, + }; + } + + private async getRolesPermissionsFromCache({ + workspaceId, + isPermissionsV2Enabled, + }: { + workspaceId: string; + isPermissionsV2Enabled?: boolean; + }): Promise< + CacheResult< + string | undefined, + ObjectRecordsPermissionsByRoleId | undefined + > + > { + if (!isPermissionsV2Enabled) { + return { version: undefined, data: undefined }; + } + + return this.getFromCacheWithRecompute< + string | undefined, + ObjectRecordsPermissionsByRoleId | undefined + >({ + workspaceId, + getCacheData: () => + this.workspaceCacheStorageService.getRolesPermissions(workspaceId), + getCacheVersion: () => + this.workspaceCacheStorageService.getRolesPermissionsVersionFromCache( + workspaceId, + ), + recomputeCache: (params) => + this.workspaceRolesPermissionsCacheService.recomputeRolesPermissionsCache( + params, + ), + cachedEntityName: 'Roles permissions', + exceptionCode: TwentyORMExceptionCode.ROLES_PERMISSIONS_VERSION_NOT_FOUND, + }); + } + + private async getFeatureFlagMapFromCache({ + workspaceId, + }: { + workspaceId: string; + }): Promise> { + return this.getFromCacheWithRecompute({ + workspaceId, + getCacheData: () => + this.workspaceCacheStorageService.getFeatureFlagMap(workspaceId), + getCacheVersion: () => + this.workspaceCacheStorageService.getFeatureFlagMapVersionFromCache( + workspaceId, + ), + recomputeCache: (params) => + this.workspaceFeatureFlagMapCacheService.recomputeFeatureFlagMapCache( + params, + ), + cachedEntityName: 'Feature flag map', + exceptionCode: TwentyORMExceptionCode.FEATURE_FLAG_MAP_VERSION_NOT_FOUND, + }); + } + + private updateWorkspaceDataSourceIfNeeded({ + workspaceDataSource, + currentVersion, + newVersion, + newData, + setData, + setVersion, + }: { + workspaceDataSource: WorkspaceDataSource; + currentVersion: string | undefined; + newVersion: string | undefined; + newData: T | undefined; + setData: (data: T) => void; + setVersion: (version: string) => void; + }): void { + if ( + isDefined(newVersion) && + isDefined(newData) && + currentVersion !== newVersion + ) { + workspaceDataSource.manager.repositories.clear(); + setData(newData); + setVersion(newVersion); + } + } + + private async updateWorkspaceDataSourceRolesPermissionsIfNeeded({ + workspaceDataSource, + cachedRolesPermissionsVersion, + cachedRolesPermissions, + }: { + workspaceDataSource: WorkspaceDataSource; + cachedRolesPermissionsVersion: string | undefined; + cachedRolesPermissions: ObjectRecordsPermissionsByRoleId | undefined; + }): Promise { + this.updateWorkspaceDataSourceIfNeeded({ + workspaceDataSource, + currentVersion: workspaceDataSource.rolesPermissionsVersion, + newVersion: cachedRolesPermissionsVersion, + newData: cachedRolesPermissions, + setData: (data) => workspaceDataSource.setRolesPermissions(data), + setVersion: (version) => + workspaceDataSource.setRolesPermissionsVersion(version), + }); + } + + private async updateWorkspaceDataSourceFeatureFlagMapIfNeeded({ + workspaceDataSource, + cachedFeatureFlagMapVersion, + cachedFeatureFlagMap, + }: { + workspaceDataSource: WorkspaceDataSource; + cachedFeatureFlagMapVersion: string | undefined; + cachedFeatureFlagMap: FeatureFlagMap | undefined; + }): Promise { + this.updateWorkspaceDataSourceIfNeeded({ + workspaceDataSource, + currentVersion: workspaceDataSource.featureFlagMapVersion, + newVersion: cachedFeatureFlagMapVersion, + newData: cachedFeatureFlagMap, + setData: (data) => workspaceDataSource.setFeatureFlagMap(data), + setVersion: (version) => + workspaceDataSource.setFeatureFlagMapVersion(version), + }); + } + private async getWorkspaceMetadataVersionFromCache( workspaceId: string, - failOnMetadataCacheMiss = true, + shouldFailIfMetadataNotFound = true, ): Promise { let latestWorkspaceMetadataVersion = await this.workspaceCacheStorageService.getMetadataVersion(workspaceId); if (latestWorkspaceMetadataVersion === undefined) { - await this.workspaceMetadataCacheService.recomputeMetadataCache({ - workspaceId, - ignoreLock: !failOnMetadataCacheMiss, - }); - - if (failOnMetadataCacheMiss) { + if (shouldFailIfMetadataNotFound) { throw new TwentyORMException( `Metadata version not found for workspace ${workspaceId}`, TwentyORMExceptionCode.METADATA_VERSION_NOT_FOUND, ); } else { + await this.workspaceMetadataCacheService.recomputeMetadataCache({ + workspaceId, + ignoreLock: !shouldFailIfMetadataNotFound, + }); latestWorkspaceMetadataVersion = await this.workspaceCacheStorageService.getMetadataVersion( workspaceId, diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.util.ts b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.util.ts new file mode 100644 index 000000000..896c596ba --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.util.ts @@ -0,0 +1,69 @@ +import { ObjectRecordsPermissions } from 'twenty-shared/types'; +import { QueryExpressionMap } from 'typeorm/query-builder/QueryExpressionMap'; + +import { + PermissionsException, + PermissionsExceptionCode, + PermissionsExceptionMessage, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; + +const getTargetEntityAndOperationType = (expressionMap: QueryExpressionMap) => { + const mainEntity = expressionMap.aliases[0].metadata.name; + const operationType = expressionMap.queryType; + + return { + mainEntity, + operationType, + }; +}; + +export const validateQueryIsPermittedOrThrow = ( + expressionMap: QueryExpressionMap, + objectRecordsPermissions: ObjectRecordsPermissions, +) => { + const { mainEntity, operationType } = + getTargetEntityAndOperationType(expressionMap); + + const permissionsForEntity = objectRecordsPermissions[mainEntity]; + + switch (operationType) { + case 'select': + if (!permissionsForEntity?.canRead) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSION_DENIED, + PermissionsExceptionCode.PERMISSION_DENIED, + ); + } + break; + case 'insert': + case 'update': + if (!permissionsForEntity?.canUpdate) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSION_DENIED, + PermissionsExceptionCode.PERMISSION_DENIED, + ); + } + break; + case 'delete': + if (!permissionsForEntity?.canDestroy) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSION_DENIED, + PermissionsExceptionCode.PERMISSION_DENIED, + ); + } + break; + case 'soft-delete': + if (!permissionsForEntity?.canSoftDelete) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSION_DENIED, + PermissionsExceptionCode.PERMISSION_DENIED, + ); + } + break; + default: + throw new PermissionsException( + PermissionsExceptionMessage.UNKNOWN_OPERATION_NAME, + PermissionsExceptionCode.UNKNOWN_OPERATION_NAME, + ); + } +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-query-builder.ts new file mode 100644 index 000000000..e4594d786 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-query-builder.ts @@ -0,0 +1,25 @@ +import { ObjectRecordsPermissions } from 'twenty-shared/types'; +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; + +export class WorkspaceQueryBuilder< + T extends ObjectLiteral, +> extends WorkspaceSelectQueryBuilder { + constructor( + queryBuilder: SelectQueryBuilder, + objectRecordsPermissions: ObjectRecordsPermissions, + ) { + super(queryBuilder, objectRecordsPermissions); + this.objectRecordsPermissions = objectRecordsPermissions; + } + + override clone(): this { + const clonedQueryBuilder = super.clone(); + + return new WorkspaceQueryBuilder( + clonedQueryBuilder, + this.objectRecordsPermissions, + ) as this; + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts new file mode 100644 index 000000000..c8d94774f --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts @@ -0,0 +1,56 @@ +import { ObjectRecordsPermissions } from 'twenty-shared/types'; +import { ObjectLiteral, SelectQueryBuilder, UpdateQueryBuilder } from 'typeorm'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; + +import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.util'; +import { WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder'; + +export class WorkspaceSelectQueryBuilder< + T extends ObjectLiteral, +> extends SelectQueryBuilder { + objectRecordsPermissions: ObjectRecordsPermissions; + constructor( + queryBuilder: SelectQueryBuilder, + objectRecordsPermissions: ObjectRecordsPermissions, + ) { + super(queryBuilder); + this.objectRecordsPermissions = objectRecordsPermissions; + } + + override update(): WorkspaceUpdateQueryBuilder; + + override update( + updateSet: QueryDeepPartialEntity, + ): WorkspaceUpdateQueryBuilder; + + override update( + updateSet?: QueryDeepPartialEntity, + ): UpdateQueryBuilder { + const updateQueryBuilder = updateSet + ? super.update(updateSet) + : super.update(); + + return new WorkspaceUpdateQueryBuilder( + updateQueryBuilder, + this.objectRecordsPermissions, + ); + } + + override execute(): Promise { + validateQueryIsPermittedOrThrow( + this.expressionMap, + this.objectRecordsPermissions, + ); + + return super.execute(); + } + + override getMany(): Promise { + validateQueryIsPermittedOrThrow( + this.expressionMap, + this.objectRecordsPermissions, + ); + + return super.getMany(); + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts new file mode 100644 index 000000000..fd0ed37e0 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts @@ -0,0 +1,26 @@ +import { ObjectRecordsPermissions } from 'twenty-shared/types'; +import { ObjectLiteral, UpdateQueryBuilder, UpdateResult } from 'typeorm'; + +import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.util'; + +export class WorkspaceUpdateQueryBuilder< + Entity extends ObjectLiteral, +> extends UpdateQueryBuilder { + private objectRecordsPermissions: ObjectRecordsPermissions; + constructor( + queryBuilder: UpdateQueryBuilder, + objectRecordsPermissions: ObjectRecordsPermissions, + ) { + super(queryBuilder); + this.objectRecordsPermissions = objectRecordsPermissions; + } + + override execute(): Promise { + validateQueryIsPermittedOrThrow( + this.expressionMap, + this.objectRecordsPermissions, + ); + + return super.execute(); + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts index 05e6eed8a..4b848a75d 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts @@ -1,3 +1,4 @@ +import { ObjectRecordsPermissions } from 'twenty-shared/types'; import { DeepPartial, DeleteResult, @@ -20,36 +21,70 @@ import { PickKeysByType } from 'typeorm/common/PickKeysByType'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { UpsertOptions } from 'typeorm/repository/UpsertOptions'; +import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; +import { WorkspaceQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-query-builder'; import { WorkspaceEntitiesStorage } from 'src/engine/twenty-orm/storage/workspace-entities.storage'; import { formatData } from 'src/engine/twenty-orm/utils/format-data.util'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; export class WorkspaceRepository< - Entity extends ObjectLiteral, -> extends Repository { + T extends ObjectLiteral, +> extends Repository { private readonly internalContext: WorkspaceInternalContext; + private featureFlagMap: FeatureFlagMap; + private objectRecordsPermissions?: ObjectRecordsPermissions; constructor( internalContext: WorkspaceInternalContext, - target: EntityTarget, + target: EntityTarget, manager: EntityManager, + featureFlagMap: FeatureFlagMap, queryRunner?: QueryRunner, + objectRecordsPermissions?: ObjectRecordsPermissions, ) { super(target, manager, queryRunner); this.internalContext = internalContext; + this.featureFlagMap = featureFlagMap; + this.objectRecordsPermissions = objectRecordsPermissions; + } + + override createQueryBuilder( + alias?: string, + queryRunner?: QueryRunner, + ): WorkspaceQueryBuilder { + const queryBuilder = super.createQueryBuilder( + alias, + queryRunner, + ) as unknown as WorkspaceQueryBuilder; + const isPermissionsV2Enabled = + this.featureFlagMap[FeatureFlagKey.IsPermissionsV2Enabled]; + + if (!isPermissionsV2Enabled) { + return queryBuilder; + } else { + if (!this.objectRecordsPermissions) { + throw new Error('Object records permissions are required'); + } + + return new WorkspaceQueryBuilder( + queryBuilder, + this.objectRecordsPermissions, + ); + } } /** * FIND METHODS */ override async find( - options?: FindManyOptions, + options?: FindManyOptions, entityManager?: EntityManager, - ): Promise { + ): Promise { const manager = entityManager || this.manager; const computedOptions = await this.transformOptions(options); const result = await manager.find(this.target, computedOptions); @@ -59,9 +94,9 @@ export class WorkspaceRepository< } override async findBy( - where: FindOptionsWhere | FindOptionsWhere[], + where: FindOptionsWhere | FindOptionsWhere[], entityManager?: EntityManager, - ): Promise { + ): Promise { const manager = entityManager || this.manager; const computedOptions = await this.transformOptions({ where }); const result = await manager.findBy(this.target, computedOptions.where); @@ -71,9 +106,9 @@ export class WorkspaceRepository< } override async findAndCount( - options?: FindManyOptions, + options?: FindManyOptions, entityManager?: EntityManager, - ): Promise<[Entity[], number]> { + ): Promise<[T[], number]> { const manager = entityManager || this.manager; const computedOptions = await this.transformOptions(options); const result = await manager.findAndCount(this.target, computedOptions); @@ -83,9 +118,9 @@ export class WorkspaceRepository< } override async findAndCountBy( - where: FindOptionsWhere | FindOptionsWhere[], + where: FindOptionsWhere | FindOptionsWhere[], entityManager?: EntityManager, - ): Promise<[Entity[], number]> { + ): Promise<[T[], number]> { const manager = entityManager || this.manager; const computedOptions = await this.transformOptions({ where }); const result = await manager.findAndCountBy( @@ -98,9 +133,9 @@ export class WorkspaceRepository< } override async findOne( - options: FindOneOptions, + options: FindOneOptions, entityManager?: EntityManager, - ): Promise { + ): Promise { const manager = entityManager || this.manager; const computedOptions = await this.transformOptions(options); const result = await manager.findOne(this.target, computedOptions); @@ -110,9 +145,9 @@ export class WorkspaceRepository< } override async findOneBy( - where: FindOptionsWhere | FindOptionsWhere[], + where: FindOptionsWhere | FindOptionsWhere[], entityManager?: EntityManager, - ): Promise { + ): Promise { const manager = entityManager || this.manager; const computedOptions = await this.transformOptions({ where }); const result = await manager.findOneBy(this.target, computedOptions.where); @@ -122,9 +157,9 @@ export class WorkspaceRepository< } override async findOneOrFail( - options: FindOneOptions, + options: FindOneOptions, entityManager?: EntityManager, - ): Promise { + ): Promise { const manager = entityManager || this.manager; const computedOptions = await this.transformOptions(options); const result = await manager.findOneOrFail(this.target, computedOptions); @@ -134,9 +169,9 @@ export class WorkspaceRepository< } override async findOneByOrFail( - where: FindOptionsWhere | FindOptionsWhere[], + where: FindOptionsWhere | FindOptionsWhere[], entityManager?: EntityManager, - ): Promise { + ): Promise { const manager = entityManager || this.manager; const computedOptions = await this.transformOptions({ where }); const result = await manager.findOneByOrFail( @@ -151,38 +186,38 @@ export class WorkspaceRepository< /** * SAVE METHODS */ - override save>( - entities: T[], + override save>( + entities: U[], options: SaveOptions & { reload: false }, entityManager?: EntityManager, ): Promise; - override save>( - entities: T[], + override save>( + entities: U[], options?: SaveOptions, entityManager?: EntityManager, - ): Promise<(T & Entity)[]>; + ): Promise<(U & T)[]>; - override save>( - entity: T, + override save>( + entity: U, options: SaveOptions & { reload: false }, entityManager?: EntityManager, ): Promise; - override save>( - entity: T, + override save>( + entity: U, options?: SaveOptions, entityManager?: EntityManager, - ): Promise; + ): Promise; - override async save>( - entityOrEntities: T | T[], + override async save>( + entityOrEntities: U | U[], options?: SaveOptions, entityManager?: EntityManager, - ): Promise { + ): Promise { const manager = entityManager || this.manager; const formattedEntityOrEntities = await this.formatData(entityOrEntities); - let result: T | T[]; + let result: U | U[]; // Needed becasuse save method has multiple signature, otherwise we will need to do a type assertion if (Array.isArray(formattedEntityOrEntities)) { @@ -208,22 +243,22 @@ export class WorkspaceRepository< * REMOVE METHODS */ override remove( - entities: Entity[], + entities: T[], options?: RemoveOptions, entityManager?: EntityManager, - ): Promise; + ): Promise; override remove( - entity: Entity, + entity: T, options?: RemoveOptions, entityManager?: EntityManager, - ): Promise; + ): Promise; override async remove( - entityOrEntities: Entity | Entity[], + entityOrEntities: T | T[], options?: RemoveOptions, entityManager?: EntityManager, - ): Promise { + ): Promise { const manager = entityManager || this.manager; const formattedEntityOrEntities = await this.formatData(entityOrEntities); const result = await manager.remove( @@ -247,7 +282,7 @@ export class WorkspaceRepository< | Date[] | ObjectId | ObjectId[] - | FindOptionsWhere, + | FindOptionsWhere, entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -259,38 +294,38 @@ export class WorkspaceRepository< return manager.delete(this.target, criteria); } - override softRemove>( - entities: T[], + override softRemove>( + entities: U[], options: SaveOptions & { reload: false }, entityManager?: EntityManager, ): Promise; - override softRemove>( - entities: T[], + override softRemove>( + entities: U[], options?: SaveOptions, entityManager?: EntityManager, - ): Promise<(T & Entity)[]>; + ): Promise<(U & T)[]>; - override softRemove>( - entity: T, + override softRemove>( + entity: U, options: SaveOptions & { reload: false }, entityManager?: EntityManager, - ): Promise; + ): Promise; - override softRemove>( + override softRemove>( entity: T, options?: SaveOptions, entityManager?: EntityManager, - ): Promise; + ): Promise; - override async softRemove>( - entityOrEntities: T | T[], + override async softRemove>( + entityOrEntities: U | U[], options?: SaveOptions, entityManager?: EntityManager, - ): Promise { + ): Promise { const manager = entityManager || this.manager; const formattedEntityOrEntities = await this.formatData(entityOrEntities); - let result: T | T[]; + let result: U | U[]; // Needed becasuse save method has multiple signature, otherwise we will need to do a type assertion if (Array.isArray(formattedEntityOrEntities)) { @@ -322,7 +357,7 @@ export class WorkspaceRepository< | Date[] | ObjectId | ObjectId[] - | FindOptionsWhere, + | FindOptionsWhere, entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -337,38 +372,38 @@ export class WorkspaceRepository< /** * RECOVERY METHODS */ - override recover>( - entities: T[], + override recover>( + entities: U, options: SaveOptions & { reload: false }, entityManager?: EntityManager, - ): Promise; + ): Promise; - override recover>( - entities: T[], + override recover>( + entities: U, options?: SaveOptions, entityManager?: EntityManager, - ): Promise<(T & Entity)[]>; + ): Promise<(U & T)[]>; - override recover>( - entity: T, + override recover>( + entity: U, options: SaveOptions & { reload: false }, entityManager?: EntityManager, - ): Promise; + ): Promise; - override recover>( - entity: T, + override recover>( + entity: U, options?: SaveOptions, entityManager?: EntityManager, - ): Promise; + ): Promise; - override async recover>( - entityOrEntities: T | T[], + override async recover>( + entityOrEntities: U | U[], options?: SaveOptions, entityManager?: EntityManager, - ): Promise { + ): Promise { const manager = entityManager || this.manager; const formattedEntityOrEntities = await this.formatData(entityOrEntities); - let result: T | T[]; + let result: U | U[]; // Needed becasuse save method has multiple signature, otherwise we will need to do a type assertion if (Array.isArray(formattedEntityOrEntities)) { @@ -400,7 +435,7 @@ export class WorkspaceRepository< | Date[] | ObjectId | ObjectId[] - | FindOptionsWhere, + | FindOptionsWhere, entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -416,7 +451,7 @@ export class WorkspaceRepository< * INSERT METHODS */ override async insert( - entity: QueryDeepPartialEntity | QueryDeepPartialEntity[], + entity: QueryDeepPartialEntity | QueryDeepPartialEntity[], entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -445,8 +480,8 @@ export class WorkspaceRepository< | Date[] | ObjectId | ObjectId[] - | FindOptionsWhere, - partialEntity: QueryDeepPartialEntity, + | FindOptionsWhere, + partialEntity: QueryDeepPartialEntity, entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -459,10 +494,8 @@ export class WorkspaceRepository< } override async upsert( - entityOrEntities: - | QueryDeepPartialEntity - | QueryDeepPartialEntity[], - conflictPathsOrOptions: string[] | UpsertOptions, + entityOrEntities: QueryDeepPartialEntity | QueryDeepPartialEntity[], + conflictPathsOrOptions: string[] | UpsertOptions, entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -488,7 +521,7 @@ export class WorkspaceRepository< * EXIST METHODS */ override async exists( - options?: FindManyOptions, + options?: FindManyOptions, entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -498,7 +531,7 @@ export class WorkspaceRepository< } override async existsBy( - where: FindOptionsWhere | FindOptionsWhere[], + where: FindOptionsWhere | FindOptionsWhere[], entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -511,7 +544,7 @@ export class WorkspaceRepository< * COUNT METHODS */ override async count( - options?: FindManyOptions, + options?: FindManyOptions, entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -521,7 +554,7 @@ export class WorkspaceRepository< } override async countBy( - where: FindOptionsWhere | FindOptionsWhere[], + where: FindOptionsWhere | FindOptionsWhere[], entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -534,8 +567,8 @@ export class WorkspaceRepository< * MATH METHODS */ override async sum( - columnName: PickKeysByType, - where?: FindOptionsWhere | FindOptionsWhere[], + columnName: PickKeysByType, + where?: FindOptionsWhere | FindOptionsWhere[], entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -545,8 +578,8 @@ export class WorkspaceRepository< } override async average( - columnName: PickKeysByType, - where?: FindOptionsWhere | FindOptionsWhere[], + columnName: PickKeysByType, + where?: FindOptionsWhere | FindOptionsWhere[], entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -556,8 +589,8 @@ export class WorkspaceRepository< } override async minimum( - columnName: PickKeysByType, - where?: FindOptionsWhere | FindOptionsWhere[], + columnName: PickKeysByType, + where?: FindOptionsWhere | FindOptionsWhere[], entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -567,8 +600,8 @@ export class WorkspaceRepository< } override async maximum( - columnName: PickKeysByType, - where?: FindOptionsWhere | FindOptionsWhere[], + columnName: PickKeysByType, + where?: FindOptionsWhere | FindOptionsWhere[], entityManager?: EntityManager, ): Promise { const manager = entityManager || this.manager; @@ -578,7 +611,7 @@ export class WorkspaceRepository< } override async increment( - conditions: FindOptionsWhere, + conditions: FindOptionsWhere, propertyPath: string, value: number | string, entityManager?: EntityManager, @@ -597,7 +630,7 @@ export class WorkspaceRepository< } override async decrement( - conditions: FindOptionsWhere, + conditions: FindOptionsWhere, propertyPath: string, value: number | string, entityManager?: EntityManager, @@ -652,8 +685,8 @@ export class WorkspaceRepository< } private async transformOptions< - T extends FindManyOptions | FindOneOptions | undefined, - >(options: T): Promise { + U extends FindManyOptions | FindOneOptions | undefined, + >(options: U): Promise { if (!options) { return options; } diff --git a/packages/twenty-server/src/engine/twenty-orm/twenty-orm-global.manager.ts b/packages/twenty-server/src/engine/twenty-orm/twenty-orm-global.manager.ts index 00dd38532..abb0708d4 100644 --- a/packages/twenty-server/src/engine/twenty-orm/twenty-orm-global.manager.ts +++ b/packages/twenty-server/src/engine/twenty-orm/twenty-orm-global.manager.ts @@ -15,19 +15,19 @@ export class TwentyORMGlobalManager { async getRepositoryForWorkspace( workspaceId: string, workspaceEntity: Type, - failOnMetadataCacheMiss?: boolean, + shouldFailIfMetadataNotFound?: boolean, ): Promise>; async getRepositoryForWorkspace( workspaceId: string, objectMetadataName: string, - failOnMetadataCacheMiss?: boolean, + shouldFailIfMetadataNotFound?: boolean, ): Promise>; async getRepositoryForWorkspace( workspaceId: string, workspaceEntityOrobjectMetadataName: Type | string, - failOnMetadataCacheMiss = true, + shouldFailIfMetadataNotFound = true, ): Promise> { let objectMetadataName: string; @@ -42,7 +42,7 @@ export class TwentyORMGlobalManager { const workspaceDataSource = await this.workspaceDataSourceFactory.create( workspaceId, null, - failOnMetadataCacheMiss, + shouldFailIfMetadataNotFound, ); const repository = workspaceDataSource.getRepository(objectMetadataName); @@ -52,12 +52,12 @@ export class TwentyORMGlobalManager { async getDataSourceForWorkspace( workspaceId: string, - failOnMetadataCacheMiss = true, + shouldFailIfMetadataNotFound = true, ) { return await this.workspaceDataSourceFactory.create( workspaceId, null, - failOnMetadataCacheMiss, + shouldFailIfMetadataNotFound, ); } diff --git a/packages/twenty-server/src/engine/twenty-orm/twenty-orm.manager.ts b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.manager.ts index 6855c7056..fc18a2ffe 100644 --- a/packages/twenty-server/src/engine/twenty-orm/twenty-orm.manager.ts +++ b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.manager.ts @@ -1,7 +1,10 @@ import { Injectable, Type } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; -import { ObjectLiteral } from 'typeorm'; +import { isDefined } from 'twenty-shared/utils'; +import { ObjectLiteral, Repository } from 'typeorm'; +import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { WorkspaceDatasourceFactory } from 'src/engine/twenty-orm/factories/workspace-datasource.factory'; import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; @@ -10,6 +13,8 @@ import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manag @Injectable() export class TwentyORMManager { constructor( + @InjectRepository(UserWorkspaceRoleEntity, 'metadata') + private readonly userWorkspaceRoleRepository: Repository, private readonly workspaceDataSourceFactory: WorkspaceDatasourceFactory, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, ) {} @@ -25,7 +30,7 @@ export class TwentyORMManager { async getRepository( workspaceEntityOrobjectMetadataName: Type | string, ): Promise> { - const { workspaceId, workspaceMetadataVersion } = + const { workspaceId, workspaceMetadataVersion, userWorkspaceId } = this.scopedWorkspaceContextFactory.create(); let objectMetadataName: string; @@ -47,7 +52,20 @@ export class TwentyORMManager { workspaceMetadataVersion, ); - return workspaceDataSource.getRepository(objectMetadataName); + let roleId: string | undefined; + + if (isDefined(userWorkspaceId)) { + const userWorkspaceRole = await this.userWorkspaceRoleRepository.findOne({ + where: { + userWorkspaceId, + workspaceId: workspaceId, + }, + }); + + roleId = userWorkspaceRole?.roleId; + } + + return workspaceDataSource.getRepository(objectMetadataName, roleId); } async getDatasource() { diff --git a/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts index 7e514fb64..53043aa00 100644 --- a/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts +++ b/packages/twenty-server/src/engine/twenty-orm/twenty-orm.module.ts @@ -1,9 +1,14 @@ import { Global, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.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 { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity'; +import { WorkspaceFeatureFlagMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module'; import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module'; +import { WorkspaceRolesPermissionsCacheModule } from 'src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.module'; import { entitySchemaFactories } from 'src/engine/twenty-orm/factories'; import { EntitySchemaFactory } from 'src/engine/twenty-orm/factories/entity-schema.factory'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; @@ -13,10 +18,17 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ @Global() @Module({ imports: [ - TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'), + TypeOrmModule.forFeature( + [ObjectMetadataEntity, UserWorkspaceRoleEntity], + 'metadata', + ), DataSourceModule, WorkspaceCacheStorageModule, WorkspaceMetadataCacheModule, + PermissionsModule, + WorkspaceRolesPermissionsCacheModule, + WorkspaceFeatureFlagMapCacheModule, + FeatureFlagModule, ], providers: [ ...entitySchemaFactories, diff --git a/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts b/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts index 3108ee446..40c7c9d7b 100644 --- a/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts +++ b/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts @@ -1,6 +1,12 @@ import { Injectable } from '@nestjs/common'; +import crypto from 'crypto'; + +import { ObjectRecordsPermissionsByRoleId } from 'twenty-shared/types'; import { EntitySchemaOptions } from 'typeorm'; +import { v4 } from 'uuid'; + +import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service'; @@ -17,6 +23,12 @@ export enum WorkspaceCacheKeys { MetadataObjectMetadataMaps = 'metadata:object-metadata-maps', MetadataObjectMetadataOngoingCachingLock = 'metadata:object-metadata-ongoing-caching-lock', MetadataVersion = 'metadata:workspace-metadata-version', + MetadataRolesPermissions = 'metadata:roles-permissions', + MetadataRolesPermissionsVersion = 'metadata:roles-permissions-version', + MetadataRolesPermissionsOngoingCachingLock = 'metadata:roles-permissions-ongoing-caching-lock', + MetadataFeatureFlagMap = 'metadata:feature-flag-map', + MetadataFeatureFlagMapVersion = 'metadata:feature-flag-map-version', + MetadataFeatureFlagMapOngoingCachingLock = 'metadata:feature-flag-map-ongoing-caching-lock', } const TTL_INFINITE = 0; @@ -174,6 +186,140 @@ export class WorkspaceCacheStorageService { ); } + getRolesPermissionsVersionFromCache( + workspaceId: string, + ): Promise { + return this.cacheStorageService.get( + `${WorkspaceCacheKeys.MetadataRolesPermissionsVersion}:${workspaceId}`, + ); + } + + async setRolesPermissionsVersion(workspaceId: string): Promise { + const rolesPermissionsVersion = v4(); + + await this.cacheStorageService.set( + `${WorkspaceCacheKeys.MetadataRolesPermissionsVersion}:${workspaceId}`, + rolesPermissionsVersion, + TTL_INFINITE, + ); + + return rolesPermissionsVersion; + } + + async setRolesPermissions( + workspaceId: string, + permissions: ObjectRecordsPermissionsByRoleId, + ): Promise<{ + newRolesPermissionsVersion: string; + }> { + const [, newRolesPermissionsVersion] = await Promise.all([ + this.cacheStorageService.set( + `${WorkspaceCacheKeys.MetadataRolesPermissions}:${workspaceId}`, + permissions, + TTL_INFINITE, + ), + this.setRolesPermissionsVersion(workspaceId), + ]); + + return { newRolesPermissionsVersion }; + } + + getRolesPermissions( + workspaceId: string, + ): Promise { + return this.cacheStorageService.get( + `${WorkspaceCacheKeys.MetadataRolesPermissions}:${workspaceId}`, + ); + } + + addRolesPermissionsOngoingCachingLock(workspaceId: string) { + return this.cacheStorageService.set( + `${WorkspaceCacheKeys.MetadataRolesPermissionsOngoingCachingLock}:${workspaceId}`, + true, + 1_000 * 60, // 1 minute + ); + } + + removeRolesPermissionsOngoingCachingLock(workspaceId: string) { + return this.cacheStorageService.del( + `${WorkspaceCacheKeys.MetadataRolesPermissionsOngoingCachingLock}:${workspaceId}`, + ); + } + + getRolesPermissionsOngoingCachingLock( + workspaceId: string, + ): Promise { + return this.cacheStorageService.get( + `${WorkspaceCacheKeys.MetadataRolesPermissionsOngoingCachingLock}:${workspaceId}`, + ); + } + + getFeatureFlagMapVersionFromCache( + workspaceId: string, + ): Promise { + return this.cacheStorageService.get( + `${WorkspaceCacheKeys.MetadataFeatureFlagMapVersion}:${workspaceId}`, + ); + } + + async setFeatureFlagMapVersion(workspaceId: string): Promise { + const featureFlagMapVersion = crypto.randomUUID(); + + await this.cacheStorageService.set( + `${WorkspaceCacheKeys.MetadataFeatureFlagMapVersion}:${workspaceId}`, + featureFlagMapVersion, + TTL_INFINITE, + ); + + return featureFlagMapVersion; + } + + async setFeatureFlagMap( + workspaceId: string, + featureFlagMap: FeatureFlagMap, + ): Promise<{ + newFeatureFlagMapVersion: string; + }> { + const [, newFeatureFlagMapVersion] = await Promise.all([ + this.cacheStorageService.set( + `${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`, + featureFlagMap, + TTL_INFINITE, + ), + this.setFeatureFlagMapVersion(workspaceId), + ]); + + return { newFeatureFlagMapVersion }; + } + + getFeatureFlagMap(workspaceId: string): Promise { + return this.cacheStorageService.get( + `${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`, + ); + } + + addFeatureFlagMapOngoingCachingLock(workspaceId: string) { + return this.cacheStorageService.set( + `${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`, + true, + 1_000 * 60, // 1 minute + ); + } + + removeFeatureFlagMapOngoingCachingLock(workspaceId: string) { + return this.cacheStorageService.del( + `${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`, + ); + } + + getFeatureFlagMapOngoingCachingLock( + workspaceId: string, + ): Promise { + return this.cacheStorageService.get( + `${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`, + ); + } + async flush(workspaceId: string, metadataVersion: number): Promise { await this.cacheStorageService.del( `${WorkspaceCacheKeys.MetadataObjectMetadataMaps}:${workspaceId}:${metadataVersion}`, @@ -194,6 +340,30 @@ export class WorkspaceCacheStorageService { `${WorkspaceCacheKeys.MetadataObjectMetadataOngoingCachingLock}:${workspaceId}:${metadataVersion}`, ); + await this.cacheStorageService.del( + `${WorkspaceCacheKeys.MetadataRolesPermissions}:${workspaceId}`, + ); + + await this.cacheStorageService.del( + `${WorkspaceCacheKeys.MetadataRolesPermissionsVersion}:${workspaceId}`, + ); + + await this.cacheStorageService.del( + `${WorkspaceCacheKeys.MetadataRolesPermissionsOngoingCachingLock}:${workspaceId}`, + ); + + await this.cacheStorageService.del( + `${WorkspaceCacheKeys.MetadataFeatureFlagMap}:${workspaceId}`, + ); + + await this.cacheStorageService.del( + `${WorkspaceCacheKeys.MetadataFeatureFlagMapVersion}:${workspaceId}`, + ); + + await this.cacheStorageService.del( + `${WorkspaceCacheKeys.MetadataFeatureFlagMapOngoingCachingLock}:${workspaceId}`, + ); + // TODO: remove this after the feature flag is droped await this.cacheStorageService.del( `${FeatureFlagKey.IsNewRelationEnabled}:${workspaceId}`, diff --git a/packages/twenty-shared/src/types/ObjectRecordsPermissions.ts b/packages/twenty-shared/src/types/ObjectRecordsPermissions.ts new file mode 100644 index 000000000..0fc63f1e0 --- /dev/null +++ b/packages/twenty-shared/src/types/ObjectRecordsPermissions.ts @@ -0,0 +1,8 @@ +export type ObjectRecordsPermissions = { + [objectName: string]: { + canRead: boolean; + canUpdate: boolean; + canSoftDelete: boolean; + canDestroy: boolean; + }; +}; diff --git a/packages/twenty-shared/src/types/ObjectRecordsPermissionsByRoleId.ts b/packages/twenty-shared/src/types/ObjectRecordsPermissionsByRoleId.ts new file mode 100644 index 000000000..dd13ed8bf --- /dev/null +++ b/packages/twenty-shared/src/types/ObjectRecordsPermissionsByRoleId.ts @@ -0,0 +1,5 @@ +import { ObjectRecordsPermissions } from '@/types'; + +export type ObjectRecordsPermissionsByRoleId = { + [roleId: string]: ObjectRecordsPermissions; +}; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 5cd74c8a9..ad4ca11ab 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -10,3 +10,5 @@ export { ConnectedAccountProvider } from './ConnectedAccountProvider'; export { FieldMetadataType } from './FieldMetadataType'; export type { IsExactly } from './IsExactly'; +export type { ObjectRecordsPermissions } from './ObjectRecordsPermissions'; +export type { ObjectRecordsPermissionsByRoleId } from './ObjectRecordsPermissionsByRoleId';