Migrate role and role target to v2 (#16009)

# Introduction
close https://github.com/twentyhq/core-team-issues/issues/1930
close https://github.com/twentyhq/core-team-issues/issues/1929
Migrating role and roleTarget entities to the v2 core engine, allowing
v2 caching leverage and allow migrating agent to v2 that needs role
target in prior
After agent we should be able to pass twenty standard app totally though
workspace migration

## Role target assignation
Please note that role target have 3 creation entrypoints:
- Agent
- User workspace
- ApiKey

Refactored all 3 of them to pass through a new role-target.service.ts
that consumes the v2 under the hood.

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
Paul Rastoin
2025-11-28 18:06:11 +01:00
committed by GitHub
parent 23a7611aac
commit ea3c5d2d45
131 changed files with 6082 additions and 1319 deletions
@@ -9,6 +9,7 @@ import {
ApiKeyExceptionCode,
} from 'src/engine/core-modules/api-key/api-key.exception';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
@@ -23,6 +24,7 @@ describe('ApiKeyRoleService', () => {
let mockApiKeyRepository: any;
let mockDataSource: any;
let mockWorkspacePermissionsCacheService: any;
let mockRoleTargetService: any;
const mockWorkspaceId = 'workspace-123';
const mockApiKeyId = 'api-key-456';
@@ -77,12 +79,6 @@ describe('ApiKeyRoleService', () => {
apiKey: mockApiKey,
} as RoleTargetsEntity;
const mockNewRoleTarget = {
...mockRoleTarget,
id: 'role-target-456',
roleId: mockNewRoleId,
} as RoleTargetsEntity;
beforeEach(async () => {
mockRoleTargetsRepository = {
save: jest.fn(),
@@ -112,6 +108,11 @@ describe('ApiKeyRoleService', () => {
getApiKeyRoleMapFromCache: jest.fn(),
};
mockRoleTargetService = {
create: jest.fn(),
delete: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApiKeyRoleService,
@@ -139,6 +140,10 @@ describe('ApiKeyRoleService', () => {
provide: WorkspacePermissionsCacheService,
useValue: mockWorkspacePermissionsCacheService,
},
{
provide: RoleTargetService,
useValue: mockRoleTargetService,
},
],
}).compile();
@@ -153,84 +158,12 @@ describe('ApiKeyRoleService', () => {
expect(service).toBeDefined();
});
describe('assignRoleToApiKeyWithManager', () => {
it('should assign role using provided transaction manager', async () => {
const mockManagerDelete = jest.fn().mockResolvedValue({ affected: 1 });
const mockManagerCreate = jest.fn().mockReturnValue(mockNewRoleTarget);
const mockManagerSave = jest.fn().mockResolvedValue(mockNewRoleTarget);
const mockManager = {
delete: mockManagerDelete,
create: mockManagerCreate,
save: mockManagerSave,
};
await service.assignRoleToApiKeyWithManager(mockManager as any, {
apiKeyId: mockApiKeyId,
roleId: mockNewRoleId,
workspaceId: mockWorkspaceId,
});
expect(mockManagerDelete).toHaveBeenCalledWith(RoleTargetsEntity, {
apiKeyId: mockApiKeyId,
workspaceId: mockWorkspaceId,
});
expect(mockManagerCreate).toHaveBeenCalledWith(RoleTargetsEntity, {
apiKeyId: mockApiKeyId,
roleId: mockNewRoleId,
workspaceId: mockWorkspaceId,
});
expect(mockManagerSave).toHaveBeenCalledWith(mockNewRoleTarget);
});
it('should handle manager operation failures', async () => {
const mockManagerDelete = jest
.fn()
.mockRejectedValue(new Error('Delete failed'));
const mockManagerCreate = jest.fn();
const mockManagerSave = jest.fn();
const mockManager = {
delete: mockManagerDelete,
create: mockManagerCreate,
save: mockManagerSave,
};
await expect(
service.assignRoleToApiKeyWithManager(mockManager as any, {
apiKeyId: mockApiKeyId,
roleId: mockNewRoleId,
workspaceId: mockWorkspaceId,
}),
).rejects.toThrow('Delete failed');
expect(mockManagerDelete).toHaveBeenCalled();
expect(mockManagerCreate).not.toHaveBeenCalled();
expect(mockManagerSave).not.toHaveBeenCalled();
});
});
describe('assignRoleToApiKey', () => {
it('should assign a new role to API key using transaction', async () => {
it('should assign a new role to API key using roleTargetService', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(mockNewRole);
mockRoleTargetsRepository.findOne.mockResolvedValue(null);
const mockManagerDelete = jest.fn().mockResolvedValue({ affected: 1 });
const mockManagerCreate = jest.fn().mockReturnValue(mockNewRoleTarget);
const mockManagerSave = jest.fn().mockResolvedValue(mockNewRoleTarget);
mockDataSource.transaction.mockImplementation(
async (callback: (manager: any) => Promise<any>) => {
const mockManager = {
delete: mockManagerDelete,
create: mockManagerCreate,
save: mockManagerSave,
};
return await callback(mockManager);
},
);
mockRoleTargetService.create.mockResolvedValue(undefined);
await service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
@@ -238,20 +171,12 @@ describe('ApiKeyRoleService', () => {
workspaceId: mockWorkspaceId,
});
expect(mockDataSource.transaction).toHaveBeenCalled();
expect(mockManagerDelete).toHaveBeenCalledWith(RoleTargetsEntity, {
apiKeyId: mockApiKeyId,
workspaceId: mockWorkspaceId,
});
expect(mockManagerCreate).toHaveBeenCalledWith(RoleTargetsEntity, {
apiKeyId: mockApiKeyId,
roleId: mockNewRoleId,
workspaceId: mockWorkspaceId,
});
expect(mockManagerSave).toHaveBeenCalledWith(mockNewRoleTarget);
expect(
mockWorkspacePermissionsCacheService.recomputeApiKeyRoleMapCache,
).toHaveBeenCalledWith({
expect(mockRoleTargetService.create).toHaveBeenCalledWith({
createRoleTargetInput: {
roleId: mockNewRoleId,
targetId: mockApiKeyId,
targetMetadataForeignKey: 'apiKeyId',
},
workspaceId: mockWorkspaceId,
});
});
@@ -267,10 +192,7 @@ describe('ApiKeyRoleService', () => {
workspaceId: mockWorkspaceId,
});
expect(mockDataSource.transaction).not.toHaveBeenCalled();
expect(
mockWorkspacePermissionsCacheService.recomputeApiKeyRoleMapCache,
).not.toHaveBeenCalled();
expect(mockRoleTargetService.create).not.toHaveBeenCalled();
});
it('should throw exception if API key not found', async () => {
@@ -365,18 +287,6 @@ describe('ApiKeyRoleService', () => {
});
});
describe('recomputeCache', () => {
it('should trigger cache recomputation', async () => {
await service.recomputeCache(mockWorkspaceId);
expect(
mockWorkspacePermissionsCacheService.recomputeApiKeyRoleMapCache,
).toHaveBeenCalledWith({
workspaceId: mockWorkspaceId,
});
});
});
describe('getRolesByApiKeys', () => {
it('should return empty map for empty API key IDs', async () => {
const result = await service.getRolesByApiKeys({
@@ -498,13 +408,13 @@ describe('ApiKeyRoleService', () => {
});
describe('error handling', () => {
it('should handle transaction failures gracefully', async () => {
it('should handle roleTargetService failures gracefully', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(mockNewRole);
mockRoleTargetsRepository.findOne.mockResolvedValue(null);
mockDataSource.transaction.mockRejectedValue(
new Error('Transaction failed'),
mockRoleTargetService.create.mockRejectedValue(
new Error('Role target creation failed'),
);
await expect(
@@ -513,41 +423,36 @@ describe('ApiKeyRoleService', () => {
roleId: mockNewRoleId,
workspaceId: mockWorkspaceId,
}),
).rejects.toThrow('Transaction failed');
expect(
mockWorkspacePermissionsCacheService.recomputeApiKeyRoleMapCache,
).not.toHaveBeenCalled();
).rejects.toThrow('Role target creation failed');
});
it('should handle cache service failures gracefully', async () => {
it('should throw exception if role cannot be assigned to API keys', async () => {
const roleNotForApiKeys = {
...mockNewRole,
canBeAssignedToApiKeys: false,
};
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(mockNewRole);
mockRoleRepository.findOne.mockResolvedValue(roleNotForApiKeys);
mockRoleTargetsRepository.findOne.mockResolvedValue(null);
mockDataSource.transaction.mockImplementation(
async (callback: (manager: any) => Promise<any>) => {
const mockManager = {
delete: jest.fn().mockResolvedValue({ affected: 1 }),
create: jest.fn().mockReturnValue(mockNewRoleTarget),
save: jest.fn().mockResolvedValue(mockNewRoleTarget),
};
return await callback(mockManager);
},
);
mockWorkspacePermissionsCacheService.recomputeApiKeyRoleMapCache.mockRejectedValue(
new Error('Cache update failed'),
);
await expect(
service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: mockNewRoleId,
workspaceId: mockWorkspaceId,
}),
).rejects.toThrow('Cache update failed');
).rejects.toThrow(ApiKeyException);
await expect(
service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: mockNewRoleId,
workspaceId: mockWorkspaceId,
}),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS,
});
});
});
});
@@ -1,20 +1,14 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import {
DataSource,
type EntityManager,
In,
IsNull,
Not,
Repository,
} from 'typeorm';
import { In, IsNull, Not, Repository } from 'typeorm';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import {
ApiKeyException,
ApiKeyExceptionCode,
} from 'src/engine/core-modules/api-key/api-key.exception';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { type RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@@ -32,8 +26,7 @@ export class ApiKeyRoleService {
@InjectRepository(ApiKeyEntity)
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly roleTargetService: RoleTargetService,
) {}
public async assignRoleToApiKey({
@@ -55,45 +48,16 @@ export class ApiKeyRoleService {
return;
}
await this.dataSource.transaction(async (manager) => {
await this.assignRoleToApiKeyWithManager(manager, {
apiKeyId,
await this.roleTargetService.create({
createRoleTargetInput: {
roleId,
workspaceId,
});
});
await this.workspacePermissionsCacheService.recomputeApiKeyRoleMapCache({
targetId: apiKeyId,
targetMetadataForeignKey: 'apiKeyId',
},
workspaceId,
});
}
public async assignRoleToApiKeyWithManager(
manager: EntityManager,
{
apiKeyId,
roleId,
workspaceId,
}: {
apiKeyId: string;
roleId: string;
workspaceId: string;
},
): Promise<void> {
await manager.delete(RoleTargetsEntity, {
apiKeyId,
workspaceId,
});
const roleTarget = manager.create(RoleTargetsEntity, {
apiKeyId,
roleId,
workspaceId,
});
await manager.save(roleTarget);
}
async getRoleIdForApiKey(
apiKeyId: string,
workspaceId: string,
@@ -115,12 +79,6 @@ export class ApiKeyRoleService {
return roleId;
}
async recomputeCache(workspaceId: string): Promise<void> {
await this.workspacePermissionsCacheService.recomputeApiKeyRoleMapCache({
workspaceId,
});
}
private async validateAssignRoleInput({
apiKeyId,
workspaceId,
@@ -10,6 +10,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
@@ -26,11 +27,12 @@ import { ApiKeyController } from './controllers/api-key.controller';
WorkspaceEntity,
]),
JwtModule,
TokenModule,
WorkspacePermissionsCacheModule,
WorkspaceCacheStorageModule,
PermissionsModule,
FeatureFlagModule,
RoleTargetModule,
TokenModule,
PermissionsModule,
],
providers: [ApiKeyService, ApiKeyResolver, ApiKeyRoleService],
controllers: [ApiKeyController],
@@ -10,6 +10,7 @@ import {
} from 'src/engine/core-modules/api-key/api-key.exception';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { ApiKeyEntity } from './api-key.entity';
@@ -21,6 +22,7 @@ describe('ApiKeyService', () => {
let mockRoleTargetsRepository: any;
let mockJwtWrapperService: any;
let mockApiKeyRoleService: any;
let mockRoleTargetService: any;
let mockDataSource: any;
const mockWorkspaceId = 'workspace-123';
@@ -75,6 +77,10 @@ describe('ApiKeyService', () => {
assignRoleToApiKeyWithManager: jest.fn(),
};
mockRoleTargetService = {
create: jest.fn(),
};
mockDataSource = {
transaction: jest.fn(),
};
@@ -90,6 +96,10 @@ describe('ApiKeyService', () => {
provide: JwtWrapperService,
useValue: mockJwtWrapperService,
},
{
provide: RoleTargetService,
useValue: mockRoleTargetService,
},
{
provide: getRepositoryToken(RoleTargetsEntity),
useValue: mockRoleTargetsRepository,
@@ -117,7 +127,7 @@ describe('ApiKeyService', () => {
});
describe('create', () => {
it('should create and save an API key using transaction', async () => {
it('should create and save an API key and assign role', async () => {
const apiKeyData = {
name: 'New API Key',
expiresAt: new Date('2025-12-31'),
@@ -131,50 +141,26 @@ describe('ApiKeyService', () => {
workspaceId: mockWorkspaceId,
};
mockApiKeyRoleService.assignRoleToApiKeyWithManager.mockResolvedValue(
undefined,
);
mockApiKeyRoleService.recomputeCache.mockResolvedValue(undefined);
const mockManagerCreate = jest.fn().mockReturnValue(mockApiKey);
const mockManagerSave = jest.fn().mockResolvedValue(mockApiKey);
mockDataSource.transaction.mockImplementation(
async (callback: (manager: any) => Promise<any>) => {
const mockManager = {
create: mockManagerCreate,
save: mockManagerSave,
};
return await callback(mockManager);
},
);
mockApiKeyRepository.save.mockResolvedValue(mockApiKey);
mockRoleTargetService.create.mockResolvedValue(undefined);
const result = await service.create(apiKeyData);
expect(mockDataSource.transaction).toHaveBeenCalled();
expect(mockManagerCreate).toHaveBeenCalledWith(
ApiKeyEntity,
expect(mockApiKeyRepository.save).toHaveBeenCalledWith(
expectedApiKeyFields,
);
expect(mockManagerSave).toHaveBeenCalledWith(mockApiKey);
expect(
mockApiKeyRoleService.assignRoleToApiKeyWithManager,
).toHaveBeenCalledWith(
expect.any(Object), // manager
{
apiKeyId: mockApiKey.id,
expect(mockRoleTargetService.create).toHaveBeenCalledWith({
createRoleTargetInput: {
roleId: 'mock-role-id',
workspaceId: mockWorkspaceId,
targetId: mockApiKey.id,
targetMetadataForeignKey: 'apiKeyId',
},
);
expect(mockApiKeyRoleService.recomputeCache).toHaveBeenCalledWith(
mockWorkspaceId,
);
workspaceId: mockWorkspaceId,
});
expect(result).toEqual(mockApiKey);
});
it('should handle role assignment failures within transaction', async () => {
it('should delete API key if role assignment fails', async () => {
const apiKeyData = {
name: 'New API Key',
expiresAt: new Date('2025-12-31'),
@@ -182,38 +168,22 @@ describe('ApiKeyService', () => {
roleId: 'mock-role-id',
};
const mockManagerCreate = jest.fn().mockReturnValue(mockApiKey);
const mockManagerSave = jest.fn().mockResolvedValue(mockApiKey);
mockApiKeyRoleService.assignRoleToApiKeyWithManager.mockRejectedValue(
mockApiKeyRepository.save.mockResolvedValue(mockApiKey);
mockApiKeyRepository.delete = jest.fn().mockResolvedValue(undefined);
mockRoleTargetService.create.mockRejectedValue(
new Error('Role assignment failed'),
);
mockDataSource.transaction.mockImplementation(
async (callback: (manager: any) => Promise<any>) => {
const mockManager = {
create: mockManagerCreate,
save: mockManagerSave,
};
return await callback(mockManager);
},
);
await expect(service.create(apiKeyData)).rejects.toThrow(
'Role assignment failed',
);
expect(mockDataSource.transaction).toHaveBeenCalled();
expect(mockManagerCreate).toHaveBeenCalled();
expect(mockManagerSave).toHaveBeenCalled();
expect(
mockApiKeyRoleService.assignRoleToApiKeyWithManager,
).toHaveBeenCalled();
expect(mockApiKeyRoleService.recomputeCache).not.toHaveBeenCalled();
expect(mockApiKeyRepository.save).toHaveBeenCalled();
expect(mockRoleTargetService.create).toHaveBeenCalled();
expect(mockApiKeyRepository.delete).toHaveBeenCalledWith(mockApiKey.id);
});
it('should handle transaction failures gracefully', async () => {
it('should handle save failures gracefully', async () => {
const apiKeyData = {
name: 'New API Key',
expiresAt: new Date('2025-12-31'),
@@ -221,19 +191,12 @@ describe('ApiKeyService', () => {
roleId: 'mock-role-id',
};
mockDataSource.transaction.mockRejectedValue(
new Error('Transaction failed'),
);
mockApiKeyRepository.save.mockRejectedValue(new Error('Save failed'));
await expect(service.create(apiKeyData)).rejects.toThrow(
'Transaction failed',
);
await expect(service.create(apiKeyData)).rejects.toThrow('Save failed');
expect(mockDataSource.transaction).toHaveBeenCalled();
expect(
mockApiKeyRoleService.assignRoleToApiKeyWithManager,
).not.toHaveBeenCalled();
expect(mockApiKeyRoleService.recomputeCache).not.toHaveBeenCalled();
expect(mockApiKeyRepository.save).toHaveBeenCalled();
expect(mockRoleTargetService.create).not.toHaveBeenCalled();
});
});
@@ -1,11 +1,10 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { DataSource, IsNull, Repository } from 'typeorm';
import { IsNull, Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import {
ApiKeyException,
@@ -14,6 +13,7 @@ import {
import { type ApiKeyToken } from 'src/engine/core-modules/auth/dto/api-key-token.dto';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
@Injectable()
export class ApiKeyService {
@@ -21,34 +21,30 @@ export class ApiKeyService {
@InjectRepository(ApiKeyEntity)
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
private readonly jwtWrapperService: JwtWrapperService,
private readonly apiKeyRoleService: ApiKeyRoleService,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly roleTargetService: RoleTargetService,
) {}
async create(
apiKeyData: Partial<ApiKeyEntity> & { roleId: string },
): Promise<ApiKeyEntity> {
const { roleId, ...apiKeyFields } = apiKeyData;
const savedApiKey = await this.apiKeyRepository.save(apiKeyFields);
return await this.dataSource
.transaction(async (manager) => {
const apiKey = manager.create(ApiKeyEntity, apiKeyFields);
const savedApiKey = await manager.save(apiKey);
await this.apiKeyRoleService.assignRoleToApiKeyWithManager(manager, {
apiKeyId: savedApiKey.id,
try {
await this.roleTargetService.create({
createRoleTargetInput: {
roleId,
workspaceId: savedApiKey.workspaceId,
});
return savedApiKey;
})
.then(async (savedApiKey) => {
await this.apiKeyRoleService.recomputeCache(savedApiKey.workspaceId);
return savedApiKey;
targetId: savedApiKey.id,
targetMetadataForeignKey: 'apiKeyId',
},
workspaceId: savedApiKey.workspaceId,
});
} catch (error) {
await this.apiKeyRepository.delete(savedApiKey.id);
throw error;
}
return savedApiKey;
}
async findById(