[permissions] Improve performances using a cache for userWorkspaces roles (#11587)

In this PR we are 

- introducing a cached map `{ userworkspaceId: roleId } `to reduce calls
to get a userWorkspace's role (we were having N+1 around that with
combinedFindMany queries and generally having a lot of avoidable
queries)
- using the roles permissions cache (`{ roleId: { objectNameSingular:
{ canRead: bool, canUpdate: bool, ...} } `) in Permissions V1's
userHasObjectPermission, in order to 1) improve performances to avoid
calls to get roles 2) start using our permissions cache
This commit is contained in:
Marie
2025-04-16 17:07:43 +02:00
committed by GitHub
parent ab277476a8
commit 4d78f5f97f
20 changed files with 692 additions and 350 deletions

View File

@ -0,0 +1 @@
export type UserWorkspaceRoleMap = Record<string, string>;

View File

@ -0,0 +1,156 @@
import { Injectable, Logger } from '@nestjs/common';
import { ObjectRecordsPermissionsByRoleId } from 'twenty-shared/types';
import { v4 } from 'uuid';
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';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/workspace-permissions-cache/types/user-workspace-role-map.type';
import { WorkspaceCacheKeys } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
const TTL_INFINITE = 0;
@Injectable()
export class WorkspacePermissionsCacheStorageService {
logger = new Logger(WorkspacePermissionsCacheStorageService.name);
constructor(
@InjectCacheStorage(CacheStorageNamespace.EngineWorkspace)
private readonly cacheStorageService: CacheStorageService,
) {}
async setRolesPermissionsVersion(workspaceId: string): Promise<string> {
const rolesPermissionsVersion = v4();
await this.cacheStorageService.set<string>(
`${WorkspaceCacheKeys.MetadataPermissionsRolesPermissionsVersion}:${workspaceId}`,
rolesPermissionsVersion,
TTL_INFINITE,
);
return rolesPermissionsVersion;
}
async setRolesPermissions(
workspaceId: string,
permissions: ObjectRecordsPermissionsByRoleId,
): Promise<{
newRolesPermissionsVersion: string;
}> {
const [, newRolesPermissionsVersion] = await Promise.all([
this.cacheStorageService.set<ObjectRecordsPermissionsByRoleId>(
`${WorkspaceCacheKeys.MetadataPermissionsRolesPermissions}:${workspaceId}`,
permissions,
TTL_INFINITE,
),
this.setRolesPermissionsVersion(workspaceId),
]);
return { newRolesPermissionsVersion };
}
getRolesPermissions(
workspaceId: string,
): Promise<ObjectRecordsPermissionsByRoleId | undefined> {
return this.cacheStorageService.get<ObjectRecordsPermissionsByRoleId>(
`${WorkspaceCacheKeys.MetadataPermissionsRolesPermissions}:${workspaceId}`,
);
}
getRolesPermissionsVersion(workspaceId: string): Promise<string | undefined> {
return this.cacheStorageService.get<string>(
`${WorkspaceCacheKeys.MetadataPermissionsRolesPermissionsVersion}:${workspaceId}`,
);
}
addRolesPermissionsOngoingCachingLock(workspaceId: string) {
return this.cacheStorageService.set<boolean>(
`${WorkspaceCacheKeys.MetadataPermissionsRolesPermissionsOngoingCachingLock}:${workspaceId}`,
true,
1_000 * 60, // 1 minute
);
}
removeRolesPermissionsOngoingCachingLock(workspaceId: string) {
return this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataPermissionsRolesPermissionsOngoingCachingLock}:${workspaceId}`,
);
}
getRolesPermissionsOngoingCachingLock(
workspaceId: string,
): Promise<boolean | undefined> {
return this.cacheStorageService.get<boolean>(
`${WorkspaceCacheKeys.MetadataPermissionsRolesPermissionsOngoingCachingLock}:${workspaceId}`,
);
}
async setUserWorkspaceRoleMap(
workspaceId: string,
userWorkspaceRoleMap: UserWorkspaceRoleMap,
): Promise<{
newUserWorkspaceRoleMapVersion: string;
}> {
const [, newUserWorkspaceRoleMapVersion] = await Promise.all([
this.cacheStorageService.set<UserWorkspaceRoleMap>(
`${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMap}:${workspaceId}`,
userWorkspaceRoleMap,
TTL_INFINITE,
),
this.setUserWorkspaceRoleMapVersion(workspaceId),
]);
return { newUserWorkspaceRoleMapVersion };
}
async setUserWorkspaceRoleMapVersion(workspaceId: string) {
const userWorkspaceRoleMapVersion = v4();
await this.cacheStorageService.set<string>(
`${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMapVersion}:${workspaceId}`,
userWorkspaceRoleMapVersion,
TTL_INFINITE,
);
return userWorkspaceRoleMapVersion;
}
getUserWorkspaceRoleMap(
workspaceId: string,
): Promise<Record<string, string> | undefined> {
return this.cacheStorageService.get<Record<string, string>>(
`${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMap}:${workspaceId}`,
);
}
getUserWorkspaceRoleMapVersion(
workspaceId: string,
): Promise<string | undefined> {
return this.cacheStorageService.get<string>(
`${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMapVersion}:${workspaceId}`,
);
}
addUserWorkspaceRoleMapOngoingCachingLock(workspaceId: string) {
return this.cacheStorageService.set<boolean>(
`${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMapOngoingCachingLock}:${workspaceId}`,
true,
1_000 * 60, // 1 minute
);
}
removeUserWorkspaceRoleMapOngoingCachingLock(workspaceId: string) {
return this.cacheStorageService.del(
`${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMapOngoingCachingLock}:${workspaceId}`,
);
}
getUserWorkspaceRoleMapOngoingCachingLock(
workspaceId: string,
): Promise<boolean | undefined> {
return this.cacheStorageService.get<boolean>(
`${WorkspaceCacheKeys.MetadataPermissionsUserWorkspaceRoleMapOngoingCachingLock}:${workspaceId}`,
);
}
}

View File

@ -0,0 +1,33 @@
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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { WorkspacePermissionsCacheStorageService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspacePermissionsCacheService } from './workspace-permissions-cache.service';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
TypeOrmModule.forFeature(
[ObjectMetadataEntity, RoleEntity, UserWorkspaceRoleEntity],
'metadata',
),
WorkspaceCacheStorageModule,
FeatureFlagModule,
],
providers: [
WorkspacePermissionsCacheService,
WorkspacePermissionsCacheStorageService,
],
exports: [
WorkspacePermissionsCacheService,
WorkspacePermissionsCacheStorageService,
],
})
export class WorkspacePermissionsCacheModule {}

View File

@ -0,0 +1,318 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
ObjectRecordsPermissions,
ObjectRecordsPermissionsByRoleId,
} from 'twenty-shared/types';
import { In, Repository } from 'typeorm';
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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/workspace-permissions-cache/types/user-workspace-role-map.type';
import { WorkspacePermissionsCacheStorageService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service';
import { TwentyORMExceptionCode } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { getFromCacheWithRecompute } from 'src/engine/utils/get-data-from-cache-with-recompute.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
type CacheResult<T, U> = {
version: T;
data: U;
};
const USER_WORKSPACE_ROLE_MAP = 'User workspace role map';
const ROLES_PERMISSIONS = 'Roles permissions';
@Injectable()
export class WorkspacePermissionsCacheService {
logger = new Logger(WorkspacePermissionsCacheService.name);
constructor(
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(RoleEntity, 'metadata')
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(UserWorkspaceRoleEntity, 'metadata')
private readonly userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>,
private readonly workspacePermissionsCacheStorageService: WorkspacePermissionsCacheStorageService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly featureFlagService: FeatureFlagService,
) {}
async recomputeRolesPermissionsCache({
workspaceId,
ignoreLock = false,
roleIds,
}: {
workspaceId: string;
ignoreLock?: boolean;
roleIds?: string[];
}): Promise<void> {
const isPermissionsV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsPermissionsV2Enabled,
workspaceId,
);
const isAlreadyCaching =
await this.workspacePermissionsCacheStorageService.getRolesPermissionsOngoingCachingLock(
workspaceId,
);
if (!ignoreLock && isAlreadyCaching) {
return;
}
await this.workspacePermissionsCacheStorageService.addRolesPermissionsOngoingCachingLock(
workspaceId,
);
let currentRolesPermissions: ObjectRecordsPermissionsByRoleId | undefined =
undefined;
if (roleIds) {
currentRolesPermissions =
await this.workspacePermissionsCacheStorageService.getRolesPermissions(
workspaceId,
);
}
const recomputedRolesPermissions =
await this.getObjectRecordPermissionsForRoles({
workspaceId,
isPermissionsV2Enabled,
roleIds,
});
const freshObjectRecordsPermissionsByRoleId = roleIds
? { ...currentRolesPermissions, ...recomputedRolesPermissions }
: recomputedRolesPermissions;
await this.workspacePermissionsCacheStorageService.setRolesPermissions(
workspaceId,
freshObjectRecordsPermissionsByRoleId,
);
await this.workspacePermissionsCacheStorageService.removeRolesPermissionsOngoingCachingLock(
workspaceId,
);
}
async recomputeUserWorkspaceRoleMapCache({
workspaceId,
ignoreLock = false,
}: {
workspaceId: string;
ignoreLock?: boolean;
}): Promise<void> {
const isAlreadyCaching =
await this.workspacePermissionsCacheStorageService.getUserWorkspaceRoleMapOngoingCachingLock(
workspaceId,
);
if (!ignoreLock && isAlreadyCaching) {
return;
}
await this.workspacePermissionsCacheStorageService.addUserWorkspaceRoleMapOngoingCachingLock(
workspaceId,
);
const freshUserWorkspaceRoleMap =
await this.getUserWorkspaceRoleMapFromDatabase({
workspaceId,
});
await this.workspacePermissionsCacheStorageService.setUserWorkspaceRoleMap(
workspaceId,
freshUserWorkspaceRoleMap,
);
await this.workspacePermissionsCacheStorageService.removeUserWorkspaceRoleMapOngoingCachingLock(
workspaceId,
);
}
async getRolesPermissionsFromCache({
workspaceId,
}: {
workspaceId: string;
}): Promise<CacheResult<string, ObjectRecordsPermissionsByRoleId>> {
return getFromCacheWithRecompute<string, ObjectRecordsPermissionsByRoleId>({
workspaceId,
getCacheData: () =>
this.workspacePermissionsCacheStorageService.getRolesPermissions(
workspaceId,
),
getCacheVersion: () =>
this.workspacePermissionsCacheStorageService.getRolesPermissionsVersion(
workspaceId,
),
recomputeCache: (params) => this.recomputeRolesPermissionsCache(params),
cachedEntityName: ROLES_PERMISSIONS,
exceptionCode: TwentyORMExceptionCode.ROLES_PERMISSIONS_VERSION_NOT_FOUND,
});
}
async getUserWorkspaceRoleMapFromCache({
workspaceId,
}: {
workspaceId: string;
}): Promise<CacheResult<string, UserWorkspaceRoleMap>> {
return getFromCacheWithRecompute<string, UserWorkspaceRoleMap>({
workspaceId,
getCacheData: () =>
this.workspacePermissionsCacheStorageService.getUserWorkspaceRoleMap(
workspaceId,
),
getCacheVersion: () =>
this.workspacePermissionsCacheStorageService.getUserWorkspaceRoleMapVersion(
workspaceId,
),
recomputeCache: (params) =>
this.recomputeUserWorkspaceRoleMapCache(params),
cachedEntityName: USER_WORKSPACE_ROLE_MAP,
exceptionCode:
TwentyORMExceptionCode.USER_WORKSPACE_ROLE_MAP_VERSION_NOT_FOUND,
});
}
private async getObjectRecordPermissionsForRoles({
workspaceId,
isPermissionsV2Enabled,
roleIds,
}: {
workspaceId: string;
isPermissionsV2Enabled: boolean;
roleIds?: string[];
}): Promise<ObjectRecordsPermissionsByRoleId> {
let roles: RoleEntity[] = [];
roles = await this.roleRepository.find({
where: {
workspaceId,
...(roleIds ? { id: In(roleIds) } : {}),
},
relations: ['objectPermissions'],
});
const workspaceObjectMetadataNameIdMap =
await this.getWorkspaceObjectMetadataNameIdMap(workspaceId);
const permissionsByRoleId: ObjectRecordsPermissionsByRoleId = {};
for (const role of roles) {
const objectRecordsPermissions: ObjectRecordsPermissions = {};
for (const objectMetadataNameSingular of Object.keys(
workspaceObjectMetadataNameIdMap,
)) {
let canRead = role.canReadAllObjectRecords;
let canUpdate = role.canUpdateAllObjectRecords;
let canSoftDelete = role.canSoftDeleteAllObjectRecords;
let canDestroy = role.canDestroyAllObjectRecords;
if (isPermissionsV2Enabled) {
const objectRecordPermissionsOverride = role.objectPermissions.find(
(objectPermission) =>
objectPermission.objectMetadataId ===
workspaceObjectMetadataNameIdMap[objectMetadataNameSingular],
);
canRead =
objectRecordPermissionsOverride?.canReadObjectRecords ?? canRead;
canUpdate =
objectRecordPermissionsOverride?.canUpdateObjectRecords ??
canUpdate;
canSoftDelete =
objectRecordPermissionsOverride?.canSoftDeleteObjectRecords ??
canSoftDelete;
canDestroy =
objectRecordPermissionsOverride?.canDestroyObjectRecords ??
canDestroy;
}
objectRecordsPermissions[objectMetadataNameSingular] = {
canRead,
canUpdate,
canSoftDelete,
canDestroy,
};
}
permissionsByRoleId[role.id] = objectRecordsPermissions;
}
return permissionsByRoleId;
}
private async getWorkspaceObjectMetadataNameIdMap(
workspaceId: string,
): Promise<Record<string, string>> {
let workspaceObjectMetadataMap: Record<string, string> = {};
const metadataVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspaceId);
if (metadataVersion) {
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspaceId,
metadataVersion,
);
workspaceObjectMetadataMap = Object.values(
objectMetadataMaps?.byId ?? {},
).reduce(
(acc, objectMetadata) => {
acc[objectMetadata.nameSingular] = objectMetadata.id;
return acc;
},
{} as Record<string, string>,
);
}
if (
!metadataVersion ||
Object.keys(workspaceObjectMetadataMap).length === 0
) {
const workspaceObjectMetadata = await this.objectMetadataRepository.find({
where: {
workspaceId,
},
});
workspaceObjectMetadataMap = workspaceObjectMetadata.reduce(
(acc, objectMetadata) => {
acc[objectMetadata.nameSingular] = objectMetadata.id;
return acc;
},
{} as Record<string, string>,
);
}
return workspaceObjectMetadataMap;
}
private async getUserWorkspaceRoleMapFromDatabase({
workspaceId,
}: {
workspaceId: string;
}): Promise<UserWorkspaceRoleMap> {
const userWorkspaceRoleMap = await this.userWorkspaceRoleRepository.find({
where: {
workspaceId,
},
});
return userWorkspaceRoleMap.reduce((acc, userWorkspaceRole) => {
acc[userWorkspaceRole.userWorkspaceId] = userWorkspaceRole.roleId;
return acc;
}, {} as UserWorkspaceRoleMap);
}
}