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

View File

@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { CreatedByFromAuthContextService } from 'src/engine/core-modules/actor/services/created-by-from-auth-context.service';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@ -12,12 +13,11 @@ import {
import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
type TestingAuthContext = Omit<AuthContext, 'workspace' | 'apiKey' | 'user'> & {
workspace: Partial<Workspace>;
apiKey?: Partial<ApiKeyWorkspaceEntity>;
apiKey?: Partial<ApiKey>;
user?: Partial<User>;
};

View File

@ -1,11 +1,11 @@
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
type BuildCreatedByFromApiKeyArgs = {
apiKey: ApiKeyWorkspaceEntity;
apiKey: ApiKey;
};
export const buildCreatedByFromApiKey = ({
apiKey,

View File

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

View File

@ -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',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller';
@ -79,6 +80,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
Workspace,
User,
AppToken,
ApiKey,
FeatureFlag,
WorkspaceSSOIdentityProvider,
KeyValuePair,

View File

@ -3,235 +3,263 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { JwtAuthStrategy } from './jwt.auth.strategy';
describe('JwtAuthStrategy', () => {
let strategy: JwtAuthStrategy;
let workspaceRepository: any;
let userWorkspaceRepository: any;
let userRepository: any;
let twentyORMGlobalManager: any;
let apiKeyRepository: any;
let jwtWrapperService: any;
const jwt = {
sub: 'sub-default',
jti: 'jti-default',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
};
userRepository = {
findOne: jest.fn(async () => null),
};
userWorkspaceRepository = {
findOne: jest.fn(async () => new UserWorkspace()),
};
const jwtWrapperService: any = {
extractJwtFromRequest: jest.fn(() => () => 'token'),
};
twentyORMGlobalManager = {
getRepositoryForWorkspace: jest.fn(async () => ({
findOne: jest.fn(async () => ({ id: 'api-key-id', revokedAt: null })),
})),
};
// first we test the API_KEY case
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
beforeEach(() => {
workspaceRepository = {
findOneBy: jest.fn(async () => null),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
{} as any,
userWorkspaceRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
),
);
});
it('should throw AuthExceptionCode if type is API_KEY not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
};
twentyORMGlobalManager = {
getRepositoryForWorkspace: jest.fn(async () => ({
findOne: jest.fn(async () => null),
})),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
{} as any,
userWorkspaceRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'This API Key is revoked',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
};
twentyORMGlobalManager = {
getRepositoryForWorkspace: jest.fn(async () => ({
findOne: jest.fn(async () => ({ id: 'api-key-id', revokedAt: null })),
})),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
{} as any,
userWorkspaceRepository,
);
const result = await strategy.validate(payload as JwtPayload);
expect(result).toBeTruthy();
expect(result.apiKey?.id).toBe('api-key-id');
});
// second we test the ACCESS cases
it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
findOneBy: jest.fn(),
};
userRepository = {
findOne: jest.fn(async () => null),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
userRepository,
userWorkspaceRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspace not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
}
});
it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
};
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
};
userRepository = {
findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })),
findOne: jest.fn(),
};
userWorkspaceRepository = {
findOne: jest.fn(async () => null),
findOne: jest.fn(),
};
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
userRepository,
userWorkspaceRepository,
);
apiKeyRepository = {
findOne: jest.fn(),
};
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspace not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
}
jwtWrapperService = {
extractJwtFromRequest: jest.fn(() => () => 'token'),
};
});
it('should not throw if type is ACCESS, no jti, and user and userWorkspace exist', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
};
afterEach(() => {
jest.clearAllMocks();
});
workspaceRepository = {
findOneBy: jest.fn(async () => new Workspace()),
};
describe('API_KEY validation', () => {
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
userRepository = {
findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })),
};
workspaceRepository.findOneBy.mockResolvedValue(null);
userWorkspaceRepository = {
findOne: jest.fn(async () => ({
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
),
);
});
it('should throw AuthExceptionCode if type is API_KEY not found', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
const mockWorkspace = new Workspace();
mockWorkspace.id = 'workspace-id';
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
apiKeyRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'This API Key is revoked',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should throw AuthExceptionCode if API_KEY is revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
const mockWorkspace = new Workspace();
mockWorkspace.id = 'workspace-id';
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
apiKeyRepository.findOne.mockResolvedValue({
id: 'api-key-id',
revokedAt: new Date(),
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException(
'This API Key is revoked',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
});
it('should be truthy if type is API_KEY and API_KEY is not revoked', async () => {
const payload = {
...jwt,
type: 'API_KEY',
};
const mockWorkspace = new Workspace();
mockWorkspace.id = 'workspace-id';
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
apiKeyRepository.findOne.mockResolvedValue({
id: 'api-key-id',
revokedAt: null,
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
const result = await strategy.validate(payload as JwtPayload);
expect(result).toBeTruthy();
expect(result.apiKey?.id).toBe('api-key-id');
expect(apiKeyRepository.findOne).toHaveBeenCalledWith({
where: {
id: payload.jti,
workspaceId: mockWorkspace.id,
},
});
});
});
describe('ACCESS token validation', () => {
it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
userRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspace not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
}
});
it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' });
userWorkspaceRepository.findOne.mockResolvedValue(null);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspace not found', expect.any(String)),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
}
});
it('should not throw if type is ACCESS, no jti, and user and userWorkspace exist', async () => {
const payload = {
sub: 'sub-default',
type: 'ACCESS',
userWorkspaceId: 'userWorkspaceId',
};
workspaceRepository.findOneBy.mockResolvedValue(new Workspace());
userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' });
userWorkspaceRepository.findOne.mockResolvedValue({
id: 'userWorkspaceId',
})),
};
});
strategy = new JwtAuthStrategy(
jwtWrapperService,
twentyORMGlobalManager,
workspaceRepository,
userRepository,
userWorkspaceRepository,
);
strategy = new JwtAuthStrategy(
jwtWrapperService,
workspaceRepository,
userRepository,
userWorkspaceRepository,
apiKeyRepository,
);
const user = await strategy.validate(payload as JwtPayload);
const user = await strategy.validate(payload as JwtPayload);
expect(user.user?.lastName).toBe('lastNameDefault');
expect(user.userWorkspaceId).toBe('userWorkspaceId');
expect(user.user?.lastName).toBe('lastNameDefault');
expect(user.userWorkspaceId).toBe('userWorkspaceId');
});
});
});

View File

@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Strategy } from 'passport-jwt';
import { Repository } from 'typeorm';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import {
AuthException,
AuthExceptionCode,
@ -24,20 +25,18 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
@Injectable()
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
@InjectRepository(ApiKey, 'core')
private readonly apiKeyRepository: Repository<ApiKey>,
) {
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
// @ts-expect-error legacy noImplicitAny
@ -87,15 +86,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
),
);
const apiKeyRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ApiKeyWorkspaceEntity>(
workspace.id,
'apiKey',
);
const apiKey = await apiKeyRepository.findOne({
const apiKey = await this.apiKeyRepository.findOne({
where: {
id: payload.jti,
workspaceId: workspace.id,
},
});

View File

@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
@ -21,7 +22,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
imports: [
JwtModule,
TypeOrmModule.forFeature(
[User, AppToken, Workspace, UserWorkspace],
[User, AppToken, Workspace, UserWorkspace, ApiKey],
'core',
),
TypeORMModule,

View File

@ -1,12 +1,12 @@
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
export type AuthContext = {
user?: User | null | undefined;
apiKey?: ApiKeyWorkspaceEntity | null | undefined;
apiKey?: ApiKey | null | undefined;
workspaceMemberId?: string;
workspace?: Workspace;
userWorkspaceId?: string;

View File

@ -7,6 +7,7 @@ import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { aiModuleFactory } from 'src/engine/core-modules/ai/ai.module-factory';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
@ -42,6 +43,7 @@ import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.mod
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WebhookModule } from 'src/engine/core-modules/webhook/webhook.module';
import { WorkflowApiModule } from 'src/engine/core-modules/workflow/workflow-api.module';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
@ -116,6 +118,8 @@ import { FileModule } from './file/file.module';
inject: [TwentyConfigService, FileStorageService],
}),
SearchModule,
ApiKeyModule,
WebhookModule,
],
exports: [
AuditModule,

View File

@ -8,5 +8,7 @@ export enum FeatureFlagKey {
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED = 'IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED',
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
}

View File

@ -0,0 +1,20 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsUrl } from 'class-validator';
@InputType()
export class CreateWebhookDTO {
@Field()
@IsNotEmpty()
@IsUrl()
targetUrl: string;
@Field(() => [String])
operations: string[];
@Field({ nullable: true })
description?: string;
@Field({ nullable: true })
secret?: string;
}

View File

@ -0,0 +1,11 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class DeleteWebhookDTO {
@Field()
@IsNotEmpty()
@IsString()
id: string;
}

View File

@ -0,0 +1,11 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class GetWebhookDTO {
@Field()
@IsNotEmpty()
@IsString()
id: string;
}

View File

@ -0,0 +1,23 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class UpdateWebhookDTO {
@Field()
@IsNotEmpty()
@IsString()
id: string;
@Field({ nullable: true })
targetUrl?: string;
@Field(() => [String], { nullable: true })
operations?: string[];
@Field({ nullable: true })
description?: string;
@Field({ nullable: true })
secret?: string;
}

View File

@ -0,0 +1,28 @@
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
WebhookException,
WebhookExceptionCode,
} from 'src/engine/core-modules/webhook/webhook.exception';
export const webhookGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof WebhookException) {
switch (error.code) {
case WebhookExceptionCode.WEBHOOK_NOT_FOUND:
throw new NotFoundError(error.message);
case WebhookExceptionCode.INVALID_TARGET_URL:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
const _exhaustiveCheck: never = error.code;
throw error;
}
}
}
throw error;
};

View File

@ -0,0 +1,66 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
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_WEBHOOK_WORKSPACE_ID', ['workspaceId'])
@Entity({ name: 'webhook', schema: 'core' })
@ObjectType('Webhook')
export class Webhook {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Field()
@Column()
targetUrl: string;
@Field(() => [String])
@Column('text', { array: true, default: ['*.*'] })
operations: string[];
@Field({ nullable: true })
@Column({ nullable: true })
description?: string;
@Field()
@Column()
secret: string;
@Field()
@Column('uuid')
workspaceId: string;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@Field()
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Field({ nullable: true })
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
@Field(() => Workspace)
@ManyToOne(() => Workspace, (workspace) => workspace.webhooks, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
}

View File

@ -0,0 +1,17 @@
import { CustomException } from 'src/utils/custom-exception';
export class WebhookException extends CustomException {
declare code: WebhookExceptionCode;
constructor(
message: string,
code: WebhookExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export enum WebhookExceptionCode {
WEBHOOK_NOT_FOUND = 'WEBHOOK_NOT_FOUND',
INVALID_TARGET_URL = 'INVALID_TARGET_URL',
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Webhook } from './webhook.entity';
import { WebhookResolver } from './webhook.resolver';
import { WebhookService } from './webhook.service';
@Module({
imports: [TypeOrmModule.forFeature([Webhook], 'core')],
providers: [WebhookService, WebhookResolver],
exports: [WebhookService, TypeOrmModule],
})
export class WebhookModule {}

View File

@ -0,0 +1,88 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { CreateWebhookDTO } from 'src/engine/core-modules/webhook/dtos/create-webhook.dto';
import { DeleteWebhookDTO } from 'src/engine/core-modules/webhook/dtos/delete-webhook.dto';
import { GetWebhookDTO } from 'src/engine/core-modules/webhook/dtos/get-webhook.dto';
import { UpdateWebhookDTO } from 'src/engine/core-modules/webhook/dtos/update-webhook.dto';
import { webhookGraphqlApiExceptionHandler } from 'src/engine/core-modules/webhook/utils/webhook-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 { Webhook } from './webhook.entity';
import { WebhookService } from './webhook.service';
@Resolver(() => Webhook)
@UseGuards(WorkspaceAuthGuard)
export class WebhookResolver {
constructor(private readonly webhookService: WebhookService) {}
@Query(() => [Webhook])
async webhooks(@AuthWorkspace() workspace: Workspace): Promise<Webhook[]> {
return this.webhookService.findByWorkspaceId(workspace.id);
}
@Query(() => Webhook, { nullable: true })
async webhook(
@Args('input') input: GetWebhookDTO,
@AuthWorkspace() workspace: Workspace,
): Promise<Webhook | null> {
return this.webhookService.findById(input.id, workspace.id);
}
@Mutation(() => Webhook)
async createWebhook(
@AuthWorkspace() workspace: Workspace,
@Args('input') input: CreateWebhookDTO,
): Promise<Webhook> {
try {
return await this.webhookService.create({
targetUrl: input.targetUrl,
operations: input.operations,
description: input.description,
secret: input.secret,
workspaceId: workspace.id,
});
} catch (error) {
webhookGraphqlApiExceptionHandler(error);
throw error; // This line will never be reached but satisfies TypeScript
}
}
@Mutation(() => Webhook, { nullable: true })
async updateWebhook(
@AuthWorkspace() workspace: Workspace,
@Args('input') input: UpdateWebhookDTO,
): Promise<Webhook | null> {
try {
const updateData: Partial<Webhook> = {};
if (input.targetUrl !== undefined) updateData.targetUrl = input.targetUrl;
if (input.operations !== undefined)
updateData.operations = input.operations;
if (input.description !== undefined)
updateData.description = input.description;
if (input.secret !== undefined) updateData.secret = input.secret;
return await this.webhookService.update(
input.id,
workspace.id,
updateData,
);
} catch (error) {
webhookGraphqlApiExceptionHandler(error);
throw error; // This line will never be reached but satisfies TypeScript
}
}
@Mutation(() => Boolean)
async deleteWebhook(
@Args('input') input: DeleteWebhookDTO,
@AuthWorkspace() workspace: Workspace,
): Promise<boolean> {
const result = await this.webhookService.delete(input.id, workspace.id);
return result !== null;
}
}

View File

@ -0,0 +1,420 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ArrayContains, IsNull } from 'typeorm';
import { Webhook } from './webhook.entity';
import { WebhookException, WebhookExceptionCode } from './webhook.exception';
import { WebhookService } from './webhook.service';
describe('WebhookService', () => {
let service: WebhookService;
let mockWebhookRepository: any;
const mockWorkspaceId = 'workspace-123';
const mockWebhookId = 'webhook-456';
const mockWebhook: Webhook = {
id: mockWebhookId,
targetUrl: 'https://example.com/webhook',
secret: 'webhook-secret',
operations: ['create', 'update'],
workspaceId: mockWorkspaceId,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
deletedAt: undefined,
workspace: {} as any,
};
beforeEach(async () => {
mockWebhookRepository = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
WebhookService,
{
provide: getRepositoryToken(Webhook, 'core'),
useValue: mockWebhookRepository,
},
],
}).compile();
service = module.get<WebhookService>(WebhookService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('normalizeTargetUrl', () => {
it('should normalize valid URLs', () => {
const result = (service as any).normalizeTargetUrl(
'https://example.com/webhook',
);
expect(result).toBe('https://example.com/webhook');
});
it('should return original string if invalid URL', () => {
const invalidUrl = 'not-a-url';
const result = (service as any).normalizeTargetUrl(invalidUrl);
expect(result).toBe(invalidUrl);
});
it('should normalize URL with trailing slash', () => {
const result = (service as any).normalizeTargetUrl(
'https://example.com/webhook/',
);
expect(result).toBe('https://example.com/webhook/');
});
});
describe('validateTargetUrl', () => {
it('should validate HTTPS URLs', () => {
const result = (service as any).validateTargetUrl(
'https://example.com/webhook',
);
expect(result).toBe(true);
});
it('should validate HTTP URLs', () => {
const result = (service as any).validateTargetUrl(
'http://example.com/webhook',
);
expect(result).toBe(true);
});
it('should reject invalid URLs', () => {
const result = (service as any).validateTargetUrl('not-a-url');
expect(result).toBe(false);
});
it('should reject non-HTTP protocols', () => {
const result = (service as any).validateTargetUrl(
'ftp://example.com/webhook',
);
expect(result).toBe(false);
});
});
describe('findByWorkspaceId', () => {
it('should find all webhooks for a workspace', async () => {
const mockWebhooks = [
mockWebhook,
{ ...mockWebhook, id: 'another-webhook' },
];
mockWebhookRepository.find.mockResolvedValue(mockWebhooks);
const result = await service.findByWorkspaceId(mockWorkspaceId);
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
deletedAt: IsNull(),
},
});
expect(result).toEqual(mockWebhooks);
});
});
describe('findByOperations', () => {
it('should find webhooks by operations using ArrayContains', async () => {
const operations = ['create', 'update'];
const mockWebhooks = [mockWebhook];
mockWebhookRepository.find.mockResolvedValue(mockWebhooks);
const result = await service.findByOperations(
mockWorkspaceId,
operations,
);
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
where: operations.map((operation) => ({
workspaceId: mockWorkspaceId,
operations: ArrayContains([operation]),
deletedAt: IsNull(),
})),
});
expect(result).toEqual(mockWebhooks);
});
it('should handle single operation', async () => {
const operations = ['create'];
mockWebhookRepository.find.mockResolvedValue([mockWebhook]);
const result = await service.findByOperations(
mockWorkspaceId,
operations,
);
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
where: [
{
workspaceId: mockWorkspaceId,
operations: ArrayContains(['create']),
deletedAt: IsNull(),
},
],
});
expect(result).toEqual([mockWebhook]);
});
});
describe('findById', () => {
it('should find a webhook by ID and workspace ID', async () => {
mockWebhookRepository.findOne.mockResolvedValue(mockWebhook);
const result = await service.findById(mockWebhookId, mockWorkspaceId);
expect(mockWebhookRepository.findOne).toHaveBeenCalledWith({
where: {
id: mockWebhookId,
workspaceId: mockWorkspaceId,
deletedAt: IsNull(),
},
});
expect(result).toEqual(mockWebhook);
});
it('should return null if webhook not found', async () => {
mockWebhookRepository.findOne.mockResolvedValue(null);
const result = await service.findById('non-existent', mockWorkspaceId);
expect(result).toBeNull();
});
});
describe('create', () => {
it('should create and save a webhook with valid target URL', async () => {
const webhookData = {
targetUrl: 'https://example.com/webhook',
secret: 'webhook-secret',
operations: ['create', 'update'],
workspaceId: mockWorkspaceId,
};
mockWebhookRepository.create.mockReturnValue(mockWebhook);
mockWebhookRepository.save.mockResolvedValue(mockWebhook);
const result = await service.create(webhookData);
expect(mockWebhookRepository.create).toHaveBeenCalledWith({
...webhookData,
targetUrl: 'https://example.com/webhook',
secret: 'webhook-secret',
});
expect(mockWebhookRepository.save).toHaveBeenCalledWith(mockWebhook);
expect(result).toEqual(mockWebhook);
});
it('should throw WebhookException for invalid target URL', async () => {
const webhookData = {
targetUrl: 'invalid-url',
operations: ['create'],
workspaceId: mockWorkspaceId,
};
await expect(service.create(webhookData)).rejects.toThrow(
WebhookException,
);
await expect(service.create(webhookData)).rejects.toMatchObject({
code: WebhookExceptionCode.INVALID_TARGET_URL,
});
expect(mockWebhookRepository.create).not.toHaveBeenCalled();
expect(mockWebhookRepository.save).not.toHaveBeenCalled();
});
it('should throw WebhookException for webhook data without target URL', async () => {
const webhookData = {
operations: ['create'],
workspaceId: mockWorkspaceId,
};
await expect(service.create(webhookData)).rejects.toThrow(
WebhookException,
);
await expect(service.create(webhookData)).rejects.toMatchObject({
code: WebhookExceptionCode.INVALID_TARGET_URL,
});
});
});
describe('update', () => {
it('should update an existing webhook', async () => {
const updateData = { targetUrl: 'https://updated.example.com/webhook' };
const updatedWebhook = { ...mockWebhook, ...updateData };
mockWebhookRepository.findOne
.mockResolvedValueOnce(mockWebhook)
.mockResolvedValueOnce(updatedWebhook);
mockWebhookRepository.update.mockResolvedValue({ affected: 1 });
const result = await service.update(
mockWebhookId,
mockWorkspaceId,
updateData,
);
expect(mockWebhookRepository.update).toHaveBeenCalledWith(
mockWebhookId,
updateData,
);
expect(result).toEqual(updatedWebhook);
});
it('should return null if webhook to update does not exist', async () => {
mockWebhookRepository.findOne.mockResolvedValue(null);
const result = await service.update('non-existent', mockWorkspaceId, {
targetUrl: 'https://updated.example.com',
});
expect(mockWebhookRepository.update).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should throw WebhookException for invalid target URL during update', async () => {
const updateData = { targetUrl: 'invalid-url' };
mockWebhookRepository.findOne.mockResolvedValue(mockWebhook);
await expect(
service.update(mockWebhookId, mockWorkspaceId, updateData),
).rejects.toThrow(WebhookException);
await expect(
service.update(mockWebhookId, mockWorkspaceId, updateData),
).rejects.toMatchObject({
code: WebhookExceptionCode.INVALID_TARGET_URL,
});
expect(mockWebhookRepository.update).not.toHaveBeenCalled();
});
it('should update without target URL validation if targetUrl not in updateData', async () => {
const updateData = { operations: ['create', 'update', 'delete'] };
const updatedWebhook = { ...mockWebhook, ...updateData };
mockWebhookRepository.findOne
.mockResolvedValueOnce(mockWebhook)
.mockResolvedValueOnce(updatedWebhook);
mockWebhookRepository.update.mockResolvedValue({ affected: 1 });
const result = await service.update(
mockWebhookId,
mockWorkspaceId,
updateData,
);
expect(mockWebhookRepository.update).toHaveBeenCalledWith(
mockWebhookId,
updateData,
);
expect(result).toEqual(updatedWebhook);
});
});
describe('delete', () => {
it('should soft delete a webhook', async () => {
mockWebhookRepository.findOne.mockResolvedValue(mockWebhook);
mockWebhookRepository.softDelete.mockResolvedValue({ affected: 1 });
const result = await service.delete(mockWebhookId, mockWorkspaceId);
expect(mockWebhookRepository.findOne).toHaveBeenCalledWith({
where: {
id: mockWebhookId,
workspaceId: mockWorkspaceId,
deletedAt: IsNull(),
},
});
expect(mockWebhookRepository.softDelete).toHaveBeenCalledWith(
mockWebhookId,
);
expect(result).toEqual(mockWebhook);
});
it('should return null if webhook to delete does not exist', async () => {
mockWebhookRepository.findOne.mockResolvedValue(null);
const result = await service.delete('non-existent', mockWorkspaceId);
expect(mockWebhookRepository.softDelete).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('edge cases', () => {
it('should handle URLs with query parameters', async () => {
const webhookData = {
targetUrl: 'https://example.com/webhook?param=value',
operations: ['create'],
workspaceId: mockWorkspaceId,
};
const normalizedWebhook = {
...mockWebhook,
targetUrl: 'https://example.com/webhook?param=value',
};
mockWebhookRepository.create.mockReturnValue(normalizedWebhook);
mockWebhookRepository.save.mockResolvedValue(normalizedWebhook);
const result = await service.create(webhookData);
expect(result.targetUrl).toBe('https://example.com/webhook?param=value');
});
it('should handle URLs with fragments', async () => {
const webhookData = {
targetUrl: 'https://example.com/webhook#section',
operations: ['create'],
workspaceId: mockWorkspaceId,
};
const normalizedWebhook = {
...mockWebhook,
targetUrl: 'https://example.com/webhook#section',
};
mockWebhookRepository.create.mockReturnValue(normalizedWebhook);
mockWebhookRepository.save.mockResolvedValue(normalizedWebhook);
const result = await service.create(webhookData);
expect(result.targetUrl).toBe('https://example.com/webhook#section');
});
it('should handle empty operations array', async () => {
await service.findByOperations(mockWorkspaceId, []);
expect(mockWebhookRepository.find).toHaveBeenCalledWith({
where: [],
});
});
});
});

View File

@ -0,0 +1,134 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { ArrayContains, IsNull, Repository } from 'typeorm';
import { Webhook } from './webhook.entity';
import { WebhookException, WebhookExceptionCode } from './webhook.exception';
@Injectable()
export class WebhookService {
constructor(
@InjectRepository(Webhook, 'core')
private readonly webhookRepository: Repository<Webhook>,
) {}
private normalizeTargetUrl(targetUrl: string): string {
try {
const url = new URL(targetUrl);
return url.toString();
} catch {
return targetUrl;
}
}
private validateTargetUrl(targetUrl: string): boolean {
try {
const url = new URL(targetUrl);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
async findByWorkspaceId(workspaceId: string): Promise<Webhook[]> {
return this.webhookRepository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
});
}
async findByOperations(
workspaceId: string,
operations: string[],
): Promise<Webhook[]> {
return this.webhookRepository.find({
where: operations.map((operation) => ({
workspaceId,
operations: ArrayContains([operation]),
deletedAt: IsNull(),
})),
});
}
async findById(id: string, workspaceId: string): Promise<Webhook | null> {
const webhook = await this.webhookRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
return webhook || null;
}
async create(webhookData: Partial<Webhook>): Promise<Webhook> {
const normalizedTargetUrl = this.normalizeTargetUrl(
webhookData.targetUrl || '',
);
if (!this.validateTargetUrl(normalizedTargetUrl)) {
throw new WebhookException(
'Invalid target URL provided',
WebhookExceptionCode.INVALID_TARGET_URL,
{ userFriendlyMessage: 'Please provide a valid HTTP or HTTPS URL.' },
);
}
const webhook = this.webhookRepository.create({
...webhookData,
targetUrl: normalizedTargetUrl,
secret: webhookData.secret,
});
return this.webhookRepository.save(webhook);
}
async update(
id: string,
workspaceId: string,
updateData: Partial<Webhook>,
): Promise<Webhook | null> {
const webhook = await this.findById(id, workspaceId);
if (!webhook) {
return null;
}
if (isDefined(updateData.targetUrl)) {
const normalizedTargetUrl = this.normalizeTargetUrl(updateData.targetUrl);
if (!this.validateTargetUrl(normalizedTargetUrl)) {
throw new WebhookException(
'Invalid target URL provided',
WebhookExceptionCode.INVALID_TARGET_URL,
{ userFriendlyMessage: 'Please provide a valid HTTP or HTTPS URL.' },
);
}
updateData.targetUrl = normalizedTargetUrl;
}
await this.webhookRepository.update(id, updateData);
return this.findById(id, workspaceId);
}
async delete(id: string, workspaceId: string): Promise<Webhook | null> {
const webhook = await this.findById(id, workspaceId);
if (!webhook) {
return null;
}
await this.webhookRepository.softDelete(id);
return webhook;
}
}

View File

@ -15,6 +15,7 @@ import {
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
@ -22,6 +23,7 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Webhook } from 'src/engine/core-modules/webhook/webhook.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
@ -127,6 +129,12 @@ export class Workspace {
})
agents: Relation<AgentEntity[]>;
@OneToMany(() => Webhook, (webhook) => webhook.workspace)
webhooks: Relation<Webhook[]>;
@OneToMany(() => ApiKey, (apiKey) => apiKey.workspace)
apiKeys: Relation<ApiKey[]>;
@Field()
@Column({ default: 1 })
metadataVersion: number;