Api keys and webhook migration to core (#13011)

TODO: check Zapier trigger records work as expected

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
nitin
2025-07-09 20:33:54 +05:30
committed by GitHub
parent 18792f9f74
commit 484c267aa6
113 changed files with 4563 additions and 1060 deletions
@@ -0,0 +1,57 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Index('IDX_API_KEY_WORKSPACE_ID', ['workspaceId'])
@Entity({ name: 'apiKey', schema: 'core' })
@ObjectType('ApiKey')
export class ApiKey {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column()
name: string;
@Field(() => Date)
@Column({ type: 'timestamptz' })
expiresAt: Date;
@Field(() => Date, { nullable: true })
@Column({ type: 'timestamptz', nullable: true })
revokedAt?: Date | null;
@Field()
@Column('uuid')
workspaceId: string;
@Field(() => Date)
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field(() => Date)
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Field(() => Workspace)
@ManyToOne(() => Workspace, (workspace) => workspace.apiKeys, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
}
@@ -0,0 +1,18 @@
import { CustomException } from 'src/utils/custom-exception';
export class ApiKeyException extends CustomException {
declare code: ApiKeyExceptionCode;
constructor(
message: string,
code: ApiKeyExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export enum ApiKeyExceptionCode {
API_KEY_NOT_FOUND = 'API_KEY_NOT_FOUND',
API_KEY_REVOKED = 'API_KEY_REVOKED',
API_KEY_EXPIRED = 'API_KEY_EXPIRED',
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
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 { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
@Module({
imports: [TypeOrmModule.forFeature([ApiKey], 'core'), JwtModule],
providers: [ApiKeyService, ApiKeyResolver],
exports: [ApiKeyService, TypeOrmModule],
})
export class ApiKeyModule {}
@@ -0,0 +1,82 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ApiKey } from './api-key.entity';
import { ApiKeyService } from './api-key.service';
@Resolver(() => ApiKey)
@UseGuards(WorkspaceAuthGuard)
export class ApiKeyResolver {
constructor(private readonly apiKeyService: ApiKeyService) {}
@Query(() => [ApiKey])
async apiKeys(@AuthWorkspace() workspace: Workspace): Promise<ApiKey[]> {
return this.apiKeyService.findActiveByWorkspaceId(workspace.id);
}
@Query(() => ApiKey, { nullable: true })
async apiKey(
@Args('input') input: GetApiKeyDTO,
@AuthWorkspace() workspace: Workspace,
): Promise<ApiKey | null> {
try {
const apiKey = await this.apiKeyService.findById(input.id, workspace.id);
if (!apiKey) {
return null;
}
return apiKey;
} catch (error) {
apiKeyGraphqlApiExceptionHandler(error);
throw error;
}
}
@Mutation(() => ApiKey)
async createApiKey(
@AuthWorkspace() workspace: Workspace,
@Args('input') input: CreateApiKeyDTO,
): Promise<ApiKey> {
return this.apiKeyService.create({
name: input.name,
expiresAt: new Date(input.expiresAt),
revokedAt: input.revokedAt ? new Date(input.revokedAt) : undefined,
workspaceId: workspace.id,
});
}
@Mutation(() => ApiKey, { nullable: true })
async updateApiKey(
@AuthWorkspace() workspace: Workspace,
@Args('input') input: UpdateApiKeyDTO,
): Promise<ApiKey | null> {
const updateData: Partial<ApiKey> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.expiresAt !== undefined)
updateData.expiresAt = new Date(input.expiresAt);
if (input.revokedAt !== undefined) {
updateData.revokedAt = input.revokedAt ? new Date(input.revokedAt) : null;
}
return this.apiKeyService.update(input.id, workspace.id, updateData);
}
@Mutation(() => ApiKey, { nullable: true })
async revokeApiKey(
@AuthWorkspace() workspace: Workspace,
@Args('input') input: RevokeApiKeyDTO,
): Promise<ApiKey | null> {
return this.apiKeyService.revoke(input.id, workspace.id);
}
}
@@ -0,0 +1,383 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { IsNull } from 'typeorm';
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 { ApiKey } from './api-key.entity';
import { ApiKeyService } from './api-key.service';
describe('ApiKeyService', () => {
let service: ApiKeyService;
let mockApiKeyRepository: any;
let mockJwtWrapperService: any;
const mockWorkspaceId = 'workspace-123';
const mockApiKeyId = 'api-key-456';
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 mockRevokedApiKey: ApiKey = {
...mockApiKey,
id: 'revoked-api-key',
revokedAt: new Date('2024-06-01'),
};
const mockExpiredApiKey: ApiKey = {
...mockApiKey,
id: 'expired-api-key',
expiresAt: new Date('2024-01-01'),
};
beforeEach(async () => {
mockApiKeyRepository = {
create: jest.fn(),
save: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
};
mockJwtWrapperService = {
generateAppSecret: jest.fn(),
sign: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApiKeyService,
{
provide: getRepositoryToken(ApiKey, 'core'),
useValue: mockApiKeyRepository,
},
{
provide: JwtWrapperService,
useValue: mockJwtWrapperService,
},
],
}).compile();
service = module.get<ApiKeyService>(ApiKeyService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should create and save an API key', async () => {
const apiKeyData = {
name: 'New API Key',
expiresAt: new Date('2025-12-31'),
workspaceId: mockWorkspaceId,
};
mockApiKeyRepository.create.mockReturnValue(mockApiKey);
mockApiKeyRepository.save.mockResolvedValue(mockApiKey);
const result = await service.create(apiKeyData);
expect(mockApiKeyRepository.create).toHaveBeenCalledWith(apiKeyData);
expect(mockApiKeyRepository.save).toHaveBeenCalledWith(mockApiKey);
expect(result).toEqual(mockApiKey);
});
});
describe('findById', () => {
it('should find an API key by ID and workspace ID', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
const result = await service.findById(mockApiKeyId, mockWorkspaceId);
expect(mockApiKeyRepository.findOne).toHaveBeenCalledWith({
where: {
id: mockApiKeyId,
workspaceId: mockWorkspaceId,
},
});
expect(result).toEqual(mockApiKey);
});
it('should return null if API key not found', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(null);
const result = await service.findById('non-existent', mockWorkspaceId);
expect(result).toBeNull();
});
});
describe('findByWorkspaceId', () => {
it('should find all API keys for a workspace', async () => {
const mockApiKeys = [mockApiKey, { ...mockApiKey, id: 'another-key' }];
mockApiKeyRepository.find.mockResolvedValue(mockApiKeys);
const result = await service.findByWorkspaceId(mockWorkspaceId);
expect(mockApiKeyRepository.find).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
},
});
expect(result).toEqual(mockApiKeys);
});
});
describe('findActiveByWorkspaceId', () => {
it('should find only active (non-revoked) API keys', async () => {
const activeApiKeys = [mockApiKey];
mockApiKeyRepository.find.mockResolvedValue(activeApiKeys);
const result = await service.findActiveByWorkspaceId(mockWorkspaceId);
expect(mockApiKeyRepository.find).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
revokedAt: IsNull(),
},
});
expect(result).toEqual(activeApiKeys);
});
});
describe('update', () => {
it('should update an existing API key', async () => {
const updateData = { name: 'Updated API Key' };
const updatedApiKey = { ...mockApiKey, ...updateData };
mockApiKeyRepository.findOne
.mockResolvedValueOnce(mockApiKey)
.mockResolvedValueOnce(updatedApiKey);
mockApiKeyRepository.update.mockResolvedValue({ affected: 1 });
const result = await service.update(
mockApiKeyId,
mockWorkspaceId,
updateData,
);
expect(mockApiKeyRepository.update).toHaveBeenCalledWith(
mockApiKeyId,
updateData,
);
expect(result).toEqual(updatedApiKey);
});
it('should return null if API key to update does not exist', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(null);
const result = await service.update('non-existent', mockWorkspaceId, {
name: 'Updated',
});
expect(mockApiKeyRepository.update).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('revoke', () => {
it('should revoke an API key by setting revokedAt', async () => {
const revokedApiKey = { ...mockApiKey, revokedAt: new Date() };
mockApiKeyRepository.findOne
.mockResolvedValueOnce(mockApiKey)
.mockResolvedValueOnce(revokedApiKey);
mockApiKeyRepository.update.mockResolvedValue({ affected: 1 });
const result = await service.revoke(mockApiKeyId, mockWorkspaceId);
expect(mockApiKeyRepository.update).toHaveBeenCalledWith(
mockApiKeyId,
expect.objectContaining({
revokedAt: expect.any(Date),
}),
);
expect(result).toEqual(revokedApiKey);
});
});
describe('validateApiKey', () => {
it('should validate an active API key', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
const result = await service.validateApiKey(
mockApiKeyId,
mockWorkspaceId,
);
expect(result).toEqual(mockApiKey);
});
it('should throw ApiKeyException if API key does not exist', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(null);
await expect(
service.validateApiKey('non-existent', mockWorkspaceId),
).rejects.toThrow(ApiKeyException);
await expect(
service.validateApiKey('non-existent', mockWorkspaceId),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.API_KEY_NOT_FOUND,
});
});
it('should throw ApiKeyException if API key is revoked', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockRevokedApiKey);
await expect(
service.validateApiKey(mockRevokedApiKey.id, mockWorkspaceId),
).rejects.toThrow(ApiKeyException);
await expect(
service.validateApiKey(mockRevokedApiKey.id, mockWorkspaceId),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.API_KEY_REVOKED,
});
});
it('should throw ApiKeyException if API key is expired', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockExpiredApiKey);
await expect(
service.validateApiKey(mockExpiredApiKey.id, mockWorkspaceId),
).rejects.toThrow(ApiKeyException);
await expect(
service.validateApiKey(mockExpiredApiKey.id, mockWorkspaceId),
).rejects.toMatchObject({
code: ApiKeyExceptionCode.API_KEY_EXPIRED,
});
});
});
describe('generateApiKeyToken', () => {
const mockSecret = 'mock-secret';
const mockToken = 'mock-jwt-token';
beforeEach(() => {
mockJwtWrapperService.generateAppSecret.mockReturnValue(mockSecret);
mockJwtWrapperService.sign.mockReturnValue(mockToken);
});
it('should generate a JWT token for a valid API key', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
const expiresAt = new Date('2025-12-31');
const result = await service.generateApiKeyToken(
mockWorkspaceId,
mockApiKeyId,
expiresAt,
);
expect(mockJwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
JwtTokenTypeEnum.ACCESS,
mockWorkspaceId,
);
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
{
sub: mockWorkspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId: mockWorkspaceId,
},
{
secret: mockSecret,
expiresIn: expect.any(Number),
jwtid: mockApiKeyId,
},
);
expect(result).toEqual({ token: mockToken });
});
it('should return undefined if no API key ID provided', async () => {
const result = await service.generateApiKeyToken(mockWorkspaceId);
expect(result).toBeUndefined();
expect(mockJwtWrapperService.generateAppSecret).not.toHaveBeenCalled();
});
it('should use default expiration if no expiresAt provided', async () => {
mockApiKeyRepository.findOne.mockResolvedValue(mockApiKey);
await service.generateApiKeyToken(mockWorkspaceId, mockApiKeyId);
expect(mockJwtWrapperService.sign).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
expiresIn: '100y',
}),
);
});
});
describe('utility methods', () => {
describe('isExpired', () => {
it('should return true for expired API key', () => {
const result = service.isExpired(mockExpiredApiKey);
expect(result).toBe(true);
});
it('should return false for non-expired API key', () => {
const result = service.isExpired(mockApiKey);
expect(result).toBe(false);
});
});
describe('isRevoked', () => {
it('should return true for revoked API key', () => {
const result = service.isRevoked(mockRevokedApiKey);
expect(result).toBe(true);
});
it('should return false for non-revoked API key', () => {
const result = service.isRevoked(mockApiKey);
expect(result).toBe(false);
});
});
describe('isActive', () => {
it('should return true for active API key', () => {
const result = service.isActive(mockApiKey);
expect(result).toBe(true);
});
it('should return false for revoked API key', () => {
const result = service.isActive(mockRevokedApiKey);
expect(result).toBe(false);
});
it('should return false for expired API key', () => {
const result = service.isActive(mockExpiredApiKey);
expect(result).toBe(false);
});
});
});
});
@@ -0,0 +1,165 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, 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 { ApiKeyToken } from 'src/engine/core-modules/auth/dto/token.entity';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@Injectable()
export class ApiKeyService {
constructor(
@InjectRepository(ApiKey, 'core')
private readonly apiKeyRepository: Repository<ApiKey>,
private readonly jwtWrapperService: JwtWrapperService,
) {}
async create(apiKeyData: Partial<ApiKey>): Promise<ApiKey> {
const apiKey = this.apiKeyRepository.create(apiKeyData);
return await this.apiKeyRepository.save(apiKey);
}
async findById(id: string, workspaceId: string): Promise<ApiKey | null> {
return await this.apiKeyRepository.findOne({
where: {
id,
workspaceId,
},
});
}
async findByWorkspaceId(workspaceId: string): Promise<ApiKey[]> {
return await this.apiKeyRepository.find({
where: {
workspaceId,
},
});
}
async findActiveByWorkspaceId(workspaceId: string): Promise<ApiKey[]> {
return await this.apiKeyRepository.find({
where: {
workspaceId,
revokedAt: IsNull(),
},
});
}
async update(
id: string,
workspaceId: string,
updateData: Partial<ApiKey>,
): Promise<ApiKey | null> {
const apiKey = await this.findById(id, workspaceId);
if (!apiKey) {
return null;
}
await this.apiKeyRepository.update(id, updateData);
return this.findById(id, workspaceId);
}
async revoke(id: string, workspaceId: string): Promise<ApiKey | null> {
return await this.update(id, workspaceId, {
revokedAt: new Date(),
});
}
async validateApiKey(id: string, workspaceId: string): Promise<ApiKey> {
const apiKey = await this.findById(id, workspaceId);
if (!apiKey) {
throw new ApiKeyException(
`API Key with id ${id} not found`,
ApiKeyExceptionCode.API_KEY_NOT_FOUND,
);
}
if (apiKey.revokedAt) {
throw new ApiKeyException(
'This API Key is revoked',
ApiKeyExceptionCode.API_KEY_REVOKED,
{
userFriendlyMessage:
'This API Key has been revoked and can no longer be used.',
},
);
}
if (new Date() > apiKey.expiresAt) {
throw new ApiKeyException(
'This API Key has expired',
ApiKeyExceptionCode.API_KEY_EXPIRED,
{
userFriendlyMessage:
'This API Key has expired. Please create a new one.',
},
);
}
return apiKey;
}
async generateApiKeyToken(
workspaceId: string,
apiKeyId?: string,
expiresAt?: Date | string,
): Promise<Pick<ApiKeyToken, 'token'> | undefined> {
if (!apiKeyId) {
return;
}
await this.validateApiKey(apiKeyId, workspaceId);
const secret = this.jwtWrapperService.generateAppSecret(
JwtTokenTypeEnum.ACCESS,
workspaceId,
);
let expiresIn: string | number;
if (expiresAt) {
expiresIn = Math.floor(
(new Date(expiresAt).getTime() - new Date().getTime()) / 1000,
);
} else {
expiresIn = '100y';
}
const token = this.jwtWrapperService.sign(
{
sub: workspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId,
},
{
secret,
expiresIn,
jwtid: apiKeyId,
},
);
return { token };
}
isExpired(apiKey: ApiKey): boolean {
return new Date() > apiKey.expiresAt;
}
isRevoked(apiKey: ApiKey): boolean {
return !!apiKey.revokedAt;
}
isActive(apiKey: ApiKey): boolean {
return !this.isRevoked(apiKey) && !this.isExpired(apiKey);
}
}
@@ -0,0 +1,25 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
@InputType()
export class CreateApiKeyDTO {
@Field()
@IsNotEmpty()
@IsString()
name: string;
@Field()
@IsDateString()
expiresAt: string;
@Field({ nullable: true })
@IsOptional()
@IsDateString()
revokedAt?: string;
}
@@ -0,0 +1,11 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class GetApiKeyDTO {
@Field()
@IsNotEmpty()
@IsString()
id: string;
}
@@ -0,0 +1,11 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class RevokeApiKeyDTO {
@Field()
@IsNotEmpty()
@IsString()
id: string;
}
@@ -0,0 +1,31 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
@InputType()
export class UpdateApiKeyDTO {
@Field()
@IsNotEmpty()
@IsString()
id: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
name?: string;
@Field({ nullable: true })
@IsDateString()
@IsOptional()
expiresAt?: string;
@Field({ nullable: true })
@IsOptional()
@IsDateString()
revokedAt?: string;
}
@@ -0,0 +1,33 @@
import {
ApiKeyException,
ApiKeyExceptionCode,
} from 'src/engine/core-modules/api-key/api-key.exception';
import {
ForbiddenError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
export const apiKeyGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof ApiKeyException) {
switch (error.code) {
case ApiKeyExceptionCode.API_KEY_NOT_FOUND:
throw new NotFoundError(error.message);
case ApiKeyExceptionCode.API_KEY_REVOKED:
throw new ForbiddenError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
case ApiKeyExceptionCode.API_KEY_EXPIRED:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
const _exhaustiveCheck: never = error.code;
throw error;
}
}
}
throw error;
};