Enable roles on api keys (#13334)

This commit is contained in:
nitin
2025-08-03 03:24:10 +05:30
committed by GitHub
parent 2bda929984
commit 4cd2a87833
143 changed files with 3355 additions and 671 deletions
@@ -0,0 +1,546 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
import { In } from 'typeorm';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import {
ApiKeyException,
ApiKeyExceptionCode,
} from 'src/engine/core-modules/api-key/api-key.exception';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
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';
import { ApiKeyRoleService } from './api-key-role.service';
describe('ApiKeyRoleService', () => {
let service: ApiKeyRoleService;
let mockRoleTargetsRepository: any;
let mockRoleRepository: any;
let mockWorkspaceRepository: any;
let mockApiKeyRepository: any;
let mockDataSource: any;
let mockWorkspacePermissionsCacheService: any;
const mockWorkspaceId = 'workspace-123';
const mockApiKeyId = 'api-key-456';
const mockRoleId = 'role-789';
const mockNewRoleId = 'role-999';
const mockApiKey: ApiKey = {
id: mockApiKeyId,
name: 'Test API Key',
expiresAt: new Date('2025-12-31'),
revokedAt: undefined,
workspaceId: mockWorkspaceId,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
workspace: {} as any,
};
const mockRole: Partial<RoleEntity> = {
id: mockRoleId,
label: 'Admin',
icon: 'admin-icon',
description: 'Admin role',
isEditable: true,
workspaceId: mockWorkspaceId,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
canUpdateAllSettings: true,
canAccessAllTools: true,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
};
const mockNewRole: Partial<RoleEntity> = {
...mockRole,
id: mockNewRoleId,
label: 'Member',
};
const mockRoleTarget = {
id: 'role-target-123',
roleId: mockRoleId,
apiKeyId: mockApiKeyId,
workspaceId: mockWorkspaceId,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
role: mockRole,
apiKey: mockApiKey,
} as RoleTargetsEntity;
const mockNewRoleTarget = {
...mockRoleTarget,
id: 'role-target-456',
roleId: mockNewRoleId,
} as RoleTargetsEntity;
beforeEach(async () => {
mockRoleTargetsRepository = {
save: jest.fn(),
delete: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
};
mockRoleRepository = {
findOne: jest.fn(),
};
mockWorkspaceRepository = {
findOne: jest.fn(),
};
mockApiKeyRepository = {
findOne: jest.fn(),
};
mockDataSource = {
transaction: jest.fn(),
};
mockWorkspacePermissionsCacheService = {
recomputeApiKeyRoleMapCache: jest.fn(),
getApiKeyRoleMapFromCache: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApiKeyRoleService,
{
provide: getRepositoryToken(RoleTargetsEntity, 'core'),
useValue: mockRoleTargetsRepository,
},
{
provide: getRepositoryToken(RoleEntity, 'core'),
useValue: mockRoleRepository,
},
{
provide: getRepositoryToken(Workspace, 'core'),
useValue: mockWorkspaceRepository,
},
{
provide: getRepositoryToken(ApiKey, 'core'),
useValue: mockApiKeyRepository,
},
{
provide: getDataSourceToken('core'),
useValue: mockDataSource,
},
{
provide: WorkspacePermissionsCacheService,
useValue: mockWorkspacePermissionsCacheService,
},
],
}).compile();
service = module.get<ApiKeyRoleService>(ApiKeyRoleService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
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 () => {
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);
},
);
await service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: mockNewRoleId,
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({
workspaceId: mockWorkspaceId,
});
});
it('should skip assignment if role is already assigned', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(mockRole);
mockRoleTargetsRepository.findOne.mockResolvedValue(mockRoleTarget);
await service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: mockRoleId,
workspaceId: mockWorkspaceId,
});
expect(mockDataSource.transaction).not.toHaveBeenCalled();
expect(
mockWorkspacePermissionsCacheService.recomputeApiKeyRoleMapCache,
).not.toHaveBeenCalled();
});
it('should throw exception if API key not found', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(null);
await expect(
service.assignRoleToApiKey({
apiKeyId: 'non-existent',
roleId: mockRoleId,
workspaceId: mockWorkspaceId,
}),
).rejects.toThrow(ApiKeyException);
await expect(
service.assignRoleToApiKey({
apiKeyId: 'non-existent',
roleId: mockRoleId,
workspaceId: mockWorkspaceId,
}),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.API_KEY_NOT_FOUND,
});
});
it('should throw exception if role not found', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(null);
await expect(
service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: 'non-existent-role',
workspaceId: mockWorkspaceId,
}),
).rejects.toThrow(ApiKeyException);
await expect(
service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: 'non-existent-role',
workspaceId: mockWorkspaceId,
}),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.API_KEY_NOT_FOUND,
});
});
});
describe('getRoleIdForApiKey', () => {
it('should return role ID from cache', async () => {
const mockCacheData = {
data: {
[mockApiKeyId]: mockRoleId,
},
};
mockWorkspacePermissionsCacheService.getApiKeyRoleMapFromCache.mockResolvedValue(
mockCacheData,
);
const result = await service.getRoleIdForApiKey(
mockApiKeyId,
mockWorkspaceId,
);
expect(
mockWorkspacePermissionsCacheService.getApiKeyRoleMapFromCache,
).toHaveBeenCalledWith({
workspaceId: mockWorkspaceId,
});
expect(result).toBe(mockRoleId);
});
it('should throw exception if API key has no role in cache', async () => {
const mockCacheData = {
data: {},
};
mockWorkspacePermissionsCacheService.getApiKeyRoleMapFromCache.mockResolvedValue(
mockCacheData,
);
await expect(
service.getRoleIdForApiKey(mockApiKeyId, mockWorkspaceId),
).rejects.toThrow(ApiKeyException);
await expect(
service.getRoleIdForApiKey(mockApiKeyId, mockWorkspaceId),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.API_KEY_NO_ROLE_ASSIGNED,
});
});
});
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({
apiKeyIds: [],
workspaceId: mockWorkspaceId,
});
expect(result).toEqual(new Map());
expect(mockRoleTargetsRepository.find).not.toHaveBeenCalled();
});
it('should return roles map for given API key IDs', async () => {
const mockApiKeyIds = [mockApiKeyId, 'another-api-key'];
const mockRoleTargets = [
{
apiKeyId: mockApiKeyId,
role: mockRole,
},
{
apiKeyId: 'another-api-key',
role: mockNewRole,
},
];
mockRoleTargetsRepository.find.mockResolvedValue(mockRoleTargets);
const result = await service.getRolesByApiKeys({
apiKeyIds: mockApiKeyIds,
workspaceId: mockWorkspaceId,
});
expect(mockRoleTargetsRepository.find).toHaveBeenCalledWith({
where: {
apiKeyId: In(mockApiKeyIds),
workspaceId: mockWorkspaceId,
},
relations: ['role'],
});
expect(result.size).toBe(2);
expect(result.get(mockApiKeyId)).toEqual({
id: mockRole.id,
label: mockRole.label,
icon: mockRole.icon,
description: mockRole.description,
isEditable: mockRole.isEditable,
roleTargets: mockRole.roleTargets,
canUpdateAllSettings: true,
canAccessAllTools: true,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
});
});
it('should handle role targets with missing apiKeyId or role gracefully', async () => {
const mockRoleTargets = [
{
apiKeyId: null,
role: mockRole,
},
{
apiKeyId: mockApiKeyId,
role: null,
},
{
apiKeyId: 'valid-api-key',
role: mockRole,
},
];
mockRoleTargetsRepository.find.mockResolvedValue(mockRoleTargets);
const result = await service.getRolesByApiKeys({
apiKeyIds: [mockApiKeyId, 'valid-api-key'],
workspaceId: mockWorkspaceId,
});
expect(result.size).toBe(1);
expect(result.has('valid-api-key')).toBe(true);
});
});
describe('validateAssignRoleInput', () => {
it('should validate successful role assignment inputs', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(mockNewRole);
mockRoleTargetsRepository.findOne.mockResolvedValue(null);
const validateMethod = (service as any).validateAssignRoleInput;
const result = await validateMethod.call(service, {
apiKeyId: mockApiKeyId,
workspaceId: mockWorkspaceId,
roleId: mockNewRoleId,
});
expect(result.roleToAssignIsSameAsCurrentRole).toBe(false);
});
it('should detect same role assignment', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(mockRole);
mockRoleTargetsRepository.findOne.mockResolvedValue(mockRoleTarget);
const validateMethod = (service as any).validateAssignRoleInput;
const result = await validateMethod.call(service, {
apiKeyId: mockApiKeyId,
workspaceId: mockWorkspaceId,
roleId: mockRoleId,
});
expect(result.roleToAssignIsSameAsCurrentRole).toBe(true);
});
});
describe('error handling', () => {
it('should handle transaction failures gracefully', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(mockNewRole);
mockRoleTargetsRepository.findOne.mockResolvedValue(null);
mockDataSource.transaction.mockRejectedValue(
new Error('Transaction failed'),
);
await expect(
service.assignRoleToApiKey({
apiKeyId: mockApiKeyId,
roleId: mockNewRoleId,
workspaceId: mockWorkspaceId,
}),
).rejects.toThrow('Transaction failed');
expect(
mockWorkspacePermissionsCacheService.recomputeApiKeyRoleMapCache,
).not.toHaveBeenCalled();
});
it('should handle cache service failures gracefully', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
mockRoleRepository.findOne.mockResolvedValue(mockNewRole);
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');
});
});
});
@@ -0,0 +1,195 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, In, Repository } from 'typeorm';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import {
ApiKeyException,
ApiKeyExceptionCode,
} from 'src/engine/core-modules/api-key/api-key.exception';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { 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';
import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
@Injectable()
export class ApiKeyRoleService {
constructor(
@InjectRepository(RoleTargetsEntity, 'core')
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
@InjectRepository(RoleEntity, 'core')
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(ApiKey, 'core')
private readonly apiKeyRepository: Repository<ApiKey>,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
@InjectDataSource('core')
private readonly dataSource: DataSource,
) {}
public async assignRoleToApiKey({
apiKeyId,
roleId,
workspaceId,
}: {
apiKeyId: string;
roleId: string;
workspaceId: string;
}): Promise<void> {
const validationResult = await this.validateAssignRoleInput({
apiKeyId,
workspaceId,
roleId,
});
if (validationResult?.roleToAssignIsSameAsCurrentRole) {
return;
}
await this.dataSource.transaction(async (manager) => {
await this.assignRoleToApiKeyWithManager(manager, {
apiKeyId,
roleId,
workspaceId,
});
});
await this.workspacePermissionsCacheService.recomputeApiKeyRoleMapCache({
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,
): Promise<string> {
const apiKeyRoleMap =
await this.workspacePermissionsCacheService.getApiKeyRoleMapFromCache({
workspaceId,
});
const roleId = apiKeyRoleMap.data[apiKeyId];
if (!roleId) {
throw new ApiKeyException(
`API key ${apiKeyId} has no role assigned`,
ApiKeyExceptionCode.API_KEY_NO_ROLE_ASSIGNED,
);
}
return roleId;
}
async recomputeCache(workspaceId: string): Promise<void> {
await this.workspacePermissionsCacheService.recomputeApiKeyRoleMapCache({
workspaceId,
});
}
private async validateAssignRoleInput({
apiKeyId,
workspaceId,
roleId,
}: {
apiKeyId: string;
workspaceId: string;
roleId: string;
}) {
const apiKey = await this.apiKeyRepository.findOne({
where: { id: apiKeyId, workspaceId },
});
if (!apiKey) {
throw new ApiKeyException(
`API Key with id ${apiKeyId} not found in workspace`,
ApiKeyExceptionCode.API_KEY_NOT_FOUND,
);
}
const role = await this.roleRepository.findOne({
where: { id: roleId, workspaceId },
});
if (!role) {
throw new ApiKeyException(
`Role with id ${roleId} not found in workspace`,
ApiKeyExceptionCode.API_KEY_NOT_FOUND,
);
}
const existingRoleTarget = await this.roleTargetsRepository.findOne({
where: {
apiKeyId,
roleId,
workspaceId,
},
});
return {
roleToAssignIsSameAsCurrentRole: Boolean(existingRoleTarget),
};
}
public async getRolesByApiKeys({
apiKeyIds,
workspaceId,
}: {
apiKeyIds: string[];
workspaceId: string;
}): Promise<Map<string, RoleDTO>> {
if (!apiKeyIds.length) {
return new Map();
}
const roleTargets = await this.roleTargetsRepository.find({
where: {
apiKeyId: In(apiKeyIds),
workspaceId,
},
relations: ['role'],
});
const rolesMap = new Map<string, RoleDTO>();
for (const roleTarget of roleTargets) {
if (roleTarget.apiKeyId && roleTarget.role) {
rolesMap.set(
roleTarget.apiKeyId,
fromRoleEntityToRoleDto(roleTarget.role),
);
}
}
return rolesMap;
}
}
@@ -6,4 +6,5 @@ export enum ApiKeyExceptionCode {
API_KEY_NOT_FOUND = 'API_KEY_NOT_FOUND',
API_KEY_REVOKED = 'API_KEY_REVOKED',
API_KEY_EXPIRED = 'API_KEY_EXPIRED',
API_KEY_NO_ROLE_ASSIGNED = 'API_KEY_NO_ROLE_ASSIGNED',
}
@@ -1,24 +1,37 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyResolver } from 'src/engine/core-modules/api-key/api-key.resolver';
import { ApiKeyService } from 'src/engine/core-modules/api-key/api-key.service';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.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';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { ApiKeyController } from './controllers/api-key.controller';
@Module({
imports: [
TypeOrmModule.forFeature([ApiKey], 'core'),
TypeOrmModule.forFeature(
[ApiKey, RoleTargetsEntity, RoleEntity, Workspace],
'core',
),
JwtModule,
AuthModule,
TokenModule,
WorkspacePermissionsCacheModule,
WorkspaceCacheStorageModule,
PermissionsModule,
FeatureFlagModule,
],
providers: [ApiKeyService, ApiKeyResolver],
providers: [ApiKeyService, ApiKeyResolver, ApiKeyRoleService],
controllers: [ApiKeyController],
exports: [ApiKeyService, TypeOrmModule],
exports: [ApiKeyService, ApiKeyRoleService, TypeOrmModule],
})
export class ApiKeyModule {}
@@ -1,22 +1,46 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import {
Args,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import {
ApiKeyException,
ApiKeyExceptionCode,
} from 'src/engine/core-modules/api-key/api-key.exception';
import { CreateApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/create-api-key.dto';
import { GetApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/get-api-key.dto';
import { RevokeApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/revoke-api-key.dto';
import { UpdateApiKeyDTO } from 'src/engine/core-modules/api-key/dtos/update-api-key.dto';
import { apiKeyGraphqlApiExceptionHandler } from 'src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util';
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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { ApiKeyRoleService } from './api-key-role.service';
import { ApiKey } from './api-key.entity';
import { ApiKeyService } from './api-key.service';
@Resolver(() => ApiKey)
@UseGuards(WorkspaceAuthGuard)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionsGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
export class ApiKeyResolver {
constructor(private readonly apiKeyService: ApiKeyService) {}
constructor(
private readonly apiKeyService: ApiKeyService,
private readonly apiKeyRoleService: ApiKeyRoleService,
private readonly featureFlagService: FeatureFlagService,
) {}
@Query(() => [ApiKey])
async apiKeys(@AuthWorkspace() workspace: Workspace): Promise<ApiKey[]> {
@@ -52,6 +76,7 @@ export class ApiKeyResolver {
expiresAt: new Date(input.expiresAt),
revokedAt: input.revokedAt ? new Date(input.revokedAt) : undefined,
workspaceId: workspace.id,
roleId: input.roleId,
});
}
@@ -79,4 +104,55 @@ export class ApiKeyResolver {
): Promise<ApiKey | null> {
return this.apiKeyService.revoke(input.id, workspace.id);
}
@Mutation(() => Boolean)
async assignRoleToApiKey(
@AuthWorkspace() workspace: Workspace,
@Args('apiKeyId') apiKeyId: string,
@Args('roleId') roleId: string,
): Promise<boolean> {
try {
await this.apiKeyRoleService.assignRoleToApiKey({
apiKeyId,
roleId,
workspaceId: workspace.id,
});
return true;
} catch (error) {
apiKeyGraphqlApiExceptionHandler(error);
throw error;
}
}
@ResolveField(() => RoleDTO, { nullable: true })
async role(
@Parent() apiKey: ApiKey,
@AuthWorkspace() workspace: Workspace,
): Promise<RoleDTO | null> {
const isApiKeyRolesEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_API_KEY_ROLES_ENABLED,
workspace.id,
);
if (!isApiKeyRolesEnabled) {
return null;
}
const rolesMap = await this.apiKeyRoleService.getRolesByApiKeys({
apiKeyIds: [apiKey.id],
workspaceId: workspace.id,
});
const role = rolesMap.get(apiKey.id);
if (!role) {
throw new ApiKeyException(
`API key ${apiKey.id} has no role assigned`,
ApiKeyExceptionCode.API_KEY_NO_ROLE_ASSIGNED,
);
}
return role;
}
}
@@ -1,14 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
import { IsNull } from 'typeorm';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service';
import {
ApiKeyException,
ApiKeyExceptionCode,
} 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 { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { ApiKey } from './api-key.entity';
import { ApiKeyService } from './api-key.service';
@@ -16,7 +18,10 @@ import { ApiKeyService } from './api-key.service';
describe('ApiKeyService', () => {
let service: ApiKeyService;
let mockApiKeyRepository: any;
let mockRoleTargetsRepository: any;
let mockJwtWrapperService: any;
let mockApiKeyRoleService: any;
let mockDataSource: any;
const mockWorkspaceId = 'workspace-123';
const mockApiKeyId = 'api-key-456';
@@ -53,11 +58,27 @@ describe('ApiKeyService', () => {
update: jest.fn(),
};
mockRoleTargetsRepository = {
delete: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
};
mockJwtWrapperService = {
generateAppSecret: jest.fn(),
sign: jest.fn(),
};
mockApiKeyRoleService = {
recomputeCache: jest.fn(),
assignRoleToApiKey: jest.fn(),
assignRoleToApiKeyWithManager: jest.fn(),
};
mockDataSource = {
transaction: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApiKeyService,
@@ -69,6 +90,18 @@ describe('ApiKeyService', () => {
provide: JwtWrapperService,
useValue: mockJwtWrapperService,
},
{
provide: getRepositoryToken(RoleTargetsEntity, 'core'),
useValue: mockRoleTargetsRepository,
},
{
provide: ApiKeyRoleService,
useValue: mockApiKeyRoleService,
},
{
provide: getDataSourceToken('core'),
useValue: mockDataSource,
},
],
}).compile();
@@ -84,22 +117,124 @@ describe('ApiKeyService', () => {
});
describe('create', () => {
it('should create and save an API key', async () => {
it('should create and save an API key using transaction', async () => {
const apiKeyData = {
name: 'New API Key',
expiresAt: new Date('2025-12-31'),
workspaceId: mockWorkspaceId,
roleId: 'mock-role-id',
};
const expectedApiKeyFields = {
name: 'New API Key',
expiresAt: new Date('2025-12-31'),
workspaceId: mockWorkspaceId,
};
mockApiKeyRepository.create.mockReturnValue(mockApiKey);
mockApiKeyRepository.save.mockResolvedValue(mockApiKey);
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);
},
);
const result = await service.create(apiKeyData);
expect(mockApiKeyRepository.create).toHaveBeenCalledWith(apiKeyData);
expect(mockApiKeyRepository.save).toHaveBeenCalledWith(mockApiKey);
expect(mockDataSource.transaction).toHaveBeenCalled();
expect(mockManagerCreate).toHaveBeenCalledWith(
ApiKey,
expectedApiKeyFields,
);
expect(mockManagerSave).toHaveBeenCalledWith(mockApiKey);
expect(
mockApiKeyRoleService.assignRoleToApiKeyWithManager,
).toHaveBeenCalledWith(
expect.any(Object), // manager
{
apiKeyId: mockApiKey.id,
roleId: 'mock-role-id',
workspaceId: mockWorkspaceId,
},
);
expect(mockApiKeyRoleService.recomputeCache).toHaveBeenCalledWith(
mockWorkspaceId,
);
expect(result).toEqual(mockApiKey);
});
it('should handle role assignment failures within transaction', async () => {
const apiKeyData = {
name: 'New API Key',
expiresAt: new Date('2025-12-31'),
workspaceId: mockWorkspaceId,
roleId: 'mock-role-id',
};
const mockManagerCreate = jest.fn().mockReturnValue(mockApiKey);
const mockManagerSave = jest.fn().mockResolvedValue(mockApiKey);
mockApiKeyRoleService.assignRoleToApiKeyWithManager.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();
});
it('should handle transaction failures gracefully', async () => {
const apiKeyData = {
name: 'New API Key',
expiresAt: new Date('2025-12-31'),
workspaceId: mockWorkspaceId,
roleId: 'mock-role-id',
};
mockDataSource.transaction.mockRejectedValue(
new Error('Transaction failed'),
);
await expect(service.create(apiKeyData)).rejects.toThrow(
'Transaction failed',
);
expect(mockDataSource.transaction).toHaveBeenCalled();
expect(
mockApiKeyRoleService.assignRoleToApiKeyWithManager,
).not.toHaveBeenCalled();
expect(mockApiKeyRoleService.recomputeCache).not.toHaveBeenCalled();
});
});
describe('findById', () => {
@@ -329,6 +464,30 @@ describe('ApiKeyService', () => {
}),
);
});
it('should use custom expiration time if provided', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
const expiresAt = new Date(Date.now() + 3600000); // 1 hour from now
await service.generateApiKeyToken(
mockWorkspaceId,
mockApiKeyId,
expiresAt,
);
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
{
sub: mockWorkspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId: mockWorkspaceId,
},
expect.objectContaining({
secret: mockSecret,
expiresIn: expect.any(Number),
jwtid: mockApiKeyId,
}),
);
});
});
describe('utility methods', () => {
@@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { DataSource, IsNull, Repository } from 'typeorm';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import {
ApiKeyException,
@@ -18,12 +19,34 @@ export class ApiKeyService {
@InjectRepository(ApiKey, 'core')
private readonly apiKeyRepository: Repository<ApiKey>,
private readonly jwtWrapperService: JwtWrapperService,
private readonly apiKeyRoleService: ApiKeyRoleService,
@InjectDataSource('core')
private readonly dataSource: DataSource,
) {}
async create(apiKeyData: Partial<ApiKey>): Promise<ApiKey> {
const apiKey = this.apiKeyRepository.create(apiKeyData);
async create(
apiKeyData: Partial<ApiKey> & { roleId: string },
): Promise<ApiKey> {
const { roleId, ...apiKeyFields } = apiKeyData;
return await this.apiKeyRepository.save(apiKey);
return await this.dataSource
.transaction(async (manager) => {
const apiKey = manager.create(ApiKey, apiKeyFields);
const savedApiKey = await manager.save(apiKey);
await this.apiKeyRoleService.assignRoleToApiKeyWithManager(manager, {
apiKeyId: savedApiKey.id,
roleId,
workspaceId: savedApiKey.workspaceId,
});
return savedApiKey;
})
.then(async (savedApiKey) => {
await this.apiKeyRoleService.recomputeCache(savedApiKey.workspaceId);
return savedApiKey;
});
}
async findById(id: string, workspaceId: string): Promise<ApiKey | null> {
@@ -55,6 +55,7 @@ export class ApiKeyController {
? new Date(createApiKeyDto.revokedAt)
: undefined,
workspaceId: workspace.id,
roleId: createApiKeyDto.roleId,
});
}
@@ -5,6 +5,7 @@ import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
@InputType()
@@ -22,4 +23,9 @@ export class CreateApiKeyDTO {
@IsOptional()
@IsDateString()
revokedAt?: string;
@Field()
@IsNotEmpty()
@IsUUID()
roleId: string;
}
@@ -21,6 +21,10 @@ export const apiKeyGraphqlApiExceptionHandler = (error: Error) => {
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
case ApiKeyExceptionCode.API_KEY_NO_ROLE_ASSIGNED:
throw new ForbiddenError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
const _exhaustiveCheck: never = error.code;