[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?
This commit is contained in:
Marie
2025-04-11 17:34:02 +02:00
committed by GitHub
parent 82fa71c2cd
commit 162c6bcaa3
46 changed files with 1211 additions and 154 deletions

View File

@ -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],

View File

@ -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<RoleEntity>,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
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,

View File

@ -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],

View File

@ -111,7 +111,7 @@ export class RoleResolver {
): Promise<RoleDTO> {
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<RoleDTO> {
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<string> {
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])

View File

@ -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<UserWorkspaceRoleEntity>,
private readonly userRoleService: UserRoleService,
private readonly workspaceRolesPermissionsCacheService: WorkspaceRolesPermissionsCacheService,
) {}
public async getWorkspaceRoles(workspaceId: string): Promise<RoleEntity[]> {
@ -63,7 +65,7 @@ export class RoleService {
}): Promise<RoleEntity> {
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;
}

View File

@ -58,6 +58,24 @@ export class UserRoleService {
});
}
public async getRoleIdForUserWorkspace({
workspaceId,
userWorkspaceId,
}: {
workspaceId: string;
userWorkspaceId?: string;
}): Promise<string | undefined> {
if (!isDefined(userWorkspaceId)) {
return;
}
const userWorkspaceRole = await this.userWorkspaceRoleRepository.findOne({
where: { userWorkspaceId, workspaceId },
});
return userWorkspaceRole?.roleId;
}
public async getRolesByUserWorkspaces({
userWorkspaceIds,
workspaceId,

View File

@ -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<void> {
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,
);
}
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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<ObjectMetadataEntity>,
@InjectRepository(RoleEntity, 'metadata')
private readonly roleRepository: Repository<RoleEntity>,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
async recomputeRolesPermissionsCache({
workspaceId,
ignoreLock = false,
}: {
workspaceId: string;
ignoreLock?: boolean;
}): Promise<void> {
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<ObjectRecordsPermissionsByRoleId> {
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<string[]> {
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;
}
}