From 162c6bcaa3035610157221eaef4148ced93af0b2 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Fri, 11 Apr 2025 17:34:02 +0200 Subject: [PATCH] [permissions] Implement object-records permissions in query builders (#11458) In this PR we are - (if permissionsV2 is enabled) executing permission checks at query builder level. To do so we want to override the query builders methods that are performing db calls (.execute(), .getMany(), ... etc.) For now I have just overriden some of the query builders methods for the poc. To do so I created custom query builder classes that extend typeorm's query builder (selectQueryBuilder and updateQueryBuilder, for now and later I will tackle softDeleteQueryBuilder, etc.). - adding a notion of roles permissions version and roles permissions object to datasources. We will now use one datasource per roleId and rolePermissionVersion. Both rolesPermissionsVersion and rolesPermissions objects are stored in redis and recomputed at role update or if queried and found empty. Unlike for metadata version we don't need to store a version in the db that stands for the source of truth. We also don't need to destroy and recreate the datasource if the rolesPermissions version changes, but only to update the value for rolesPermissions and rolesPermissionsVersions on the existing datasource. What this PR misses - computing of roles permissions should take into account objectPermissions table (for now it only looks at what's on the roles table) - pursue extension of query builder classes and overriding of their db calling-methods - what should the behaviour be for calls from twentyOrmGlobalManager that don't have a roleId? --- ...y-find-duplicates-resolver.service.spec.ts | 4 + .../graphql-query-runner.module.ts | 7 +- .../process-nested-relations-v2.helper.ts | 15 +- .../process-nested-relations.helper.ts | 24 +- .../interfaces/base-resolver-service.ts | 40 +-- ...phql-query-create-many-resolver.service.ts | 5 + ...aphql-query-create-one-resolver.service.ts | 3 + ...phql-query-delete-many-resolver.service.ts | 3 + ...aphql-query-delete-one-resolver.service.ts | 3 + ...hql-query-destroy-many-resolver.service.ts | 3 + ...phql-query-destroy-one-resolver.service.ts | 3 + ...raphql-query-find-many-resolver.service.ts | 3 + ...graphql-query-find-one-resolver.service.ts | 3 + ...hql-query-restore-many-resolver.service.ts | 3 + ...phql-query-restore-one-resolver.service.ts | 3 + ...phql-query-update-many-resolver.service.ts | 3 + ...aphql-query-update-one-resolver.service.ts | 3 + .../src/engine/core-modules/lab/lab.module.ts | 7 +- .../engine/core-modules/lab/lab.resolver.ts | 16 +- .../object-permission.module.ts | 2 + .../object-permission.service.ts | 8 + .../metadata-modules/role/role.module.ts | 2 + .../metadata-modules/role/role.resolver.ts | 24 +- .../metadata-modules/role/role.service.ts | 24 +- .../user-role/user-role.service.ts | 18 ++ ...orkspace-feature-flag-map-cache.service.ts | 47 ++++ ...ace-roles-feature-flag-map-cache.module.ts | 18 ++ ...orkspace-roles-permissions-cache.module.ts | 20 ++ ...rkspace-roles-permissions-cache.service.ts | 128 ++++++++++ .../datasource/workspace.datasource.ts | 35 +++ .../entity-manager/entity.manager.ts | 23 +- .../exceptions/twenty-orm.exception.ts | 2 + .../scoped-workspace-context.factory.ts | 2 + .../factories/workspace-datasource.factory.ts | 227 +++++++++++++++++- .../twenty-orm/repository/permissions.util.ts | 69 ++++++ .../repository/workspace-query-builder.ts | 25 ++ .../workspace-select-query-builder.ts | 56 +++++ .../workspace-update-query-builder.ts | 26 ++ .../repository/workspace.repository.ts | 223 +++++++++-------- .../twenty-orm/twenty-orm-global.manager.ts | 12 +- .../engine/twenty-orm/twenty-orm.manager.ts | 24 +- .../engine/twenty-orm/twenty-orm.module.ts | 14 +- .../workspace-cache-storage.service.ts | 170 +++++++++++++ .../src/types/ObjectRecordsPermissions.ts | 8 + .../types/ObjectRecordsPermissionsByRoleId.ts | 5 + packages/twenty-shared/src/types/index.ts | 2 + 46 files changed, 1211 insertions(+), 154 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-feature-flag-map-cache.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/workspace-feature-flag-map-cache.service.ts/workspace-roles-feature-flag-map-cache.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/workspace-roles-permissions-cache/workspace-roles-permissions-cache.service.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/repository/permissions.util.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/repository/workspace-query-builder.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts create mode 100644 packages/twenty-shared/src/types/ObjectRecordsPermissions.ts create mode 100644 packages/twenty-shared/src/types/ObjectRecordsPermissionsByRoleId.ts 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 fb5694f913..c4793b3289 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 8342959b1f..236a24834c 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 2192d278e7..7806f7437f 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 52e3d7ac8e..de0d6d49e0 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 569d7b0a1a..d7e3d24530 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 4694775a2f..0f8f18a1c0 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 9ae8e3ae08..2cece26ebe 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 89878fd5ad..51b1e67e1b 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 e8ade85afd..a673ebbe48 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 4cf46a6da3..aab236a45d 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 7c58da6e2e..7008ce93d9 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 fe4e172606..4ccd0dab8c 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 cc44c3e545..3414235b8d 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 2ba9ec2115..5c7fd967e9 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 9bc1df3f10..9c694dd7e0 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 74f63c6fde..703c573058 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 c0cbb26cf8..d6d803ae0f 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 67dea4a5e1..1153689f89 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 a036db0a64..a6a0c39fbb 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 addf997bc6..f7139d051f 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 af844d7785..2920f10d27 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 52636242af..e84fdeec0c 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 e60e08f22b..59bdf5ac8c 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 c6d10ed32d..30ab55bd61 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 c9a2a08486..34eeb5786a 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 0000000000..6e8cf473de --- /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 0000000000..f44911b076 --- /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 0000000000..051c369f89 --- /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 0000000000..4d6ce7d498 --- /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 076ef7f7e2..44385334a5 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 66f03915fe..5cadf97925 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 2b65d9ef31..32ba899a19 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 3a638665f7..50e8db2ebb 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 baf4808e96..44a82a6c20 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 0000000000..896c596ba6 --- /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 0000000000..e4594d786e --- /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 0000000000..c8d94774f5 --- /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 0000000000..fd0ed37e03 --- /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 05e6eed8a9..4b848a75da 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 00dd38532d..abb0708d43 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 6855c70567..fc18a2ffe2 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 7e514fb646..53043aa00a 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 3108ee446f..40c7c9d7be 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 0000000000..0fc63f1e0c --- /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 0000000000..dd13ed8bf2 --- /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 5cd74c8a9c..ad4ca11abe 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';