1487 extensibility look into public domains to identify workspace (#14456)

- identify workspace based on public domains
- add cron job to validate public domains
- add endpoint to validate a public domain
This commit is contained in:
martmull
2025-09-15 15:14:11 +02:00
committed by GitHub
parent c481fda0bd
commit 57ff06f47c
23 changed files with 466 additions and 226 deletions
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { DnsCloudflareController } from 'src/engine/core-modules/cloudflare/controllers/dns-cloudflare.controller';
@Module({
imports: [
NestjsQueryTypeOrmModule.forFeature([PublicDomain, Workspace]),
WorkspaceModule,
PublicDomainModule,
],
controllers: [DnsCloudflareController],
})
export class CloudflareModule {}
@@ -0,0 +1,62 @@
/* @license Enterprise */
import { Controller, Post, Req, UseFilters, UseGuards } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Request } from 'express';
import { Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { DnsManagerExceptionFilter } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { PublicDomainService } from 'src/engine/core-modules/public-domain/public-domain.service';
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/cloudflare/guards/cloudflare-secret.guard';
@Controller()
@UseFilters(AuthRestApiExceptionFilter, DnsManagerExceptionFilter)
export class DnsCloudflareController {
constructor(
@InjectRepository(Workspace)
private readonly workspaceRepository: Repository<Workspace>,
protected readonly workspaceService: WorkspaceService,
@InjectRepository(PublicDomain)
private readonly publicDomainRepository: Repository<PublicDomain>,
protected readonly publicDomainService: PublicDomainService,
) {}
@Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare'])
@UseGuards(CloudflareSecretMatchGuard, PublicEndpointGuard)
async customHostnameWebhooks(@Req() req: Request) {
const hostname = req.body?.data?.data?.hostname;
if (!hostname) {
return;
}
try {
const workspace = await this.workspaceRepository.findOneBy({
customDomain: hostname,
});
if (isDefined(workspace)) {
await this.workspaceService.checkCustomDomainValidRecords(workspace);
}
const publicDomain = await this.publicDomainRepository.findOneBy({
domain: hostname,
});
if (isDefined(publicDomain)) {
await this.publicDomainService.checkPublicDomainValidRecords(
publicDomain,
);
}
} catch {
return;
}
}
}
@@ -0,0 +1,44 @@
/* @license Enterprise */
import {
type CanActivate,
type ExecutionContext,
Injectable,
} from '@nestjs/common';
import { timingSafeEqual } from 'crypto';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class CloudflareSecretMatchGuard implements CanActivate {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
canActivate(context: ExecutionContext): boolean {
try {
const request = context.switchToHttp().getRequest<Request>();
const cloudflareWebhookSecret = this.twentyConfigService.get(
'CLOUDFLARE_WEBHOOK_SECRET',
);
if (
!cloudflareWebhookSecret ||
(cloudflareWebhookSecret &&
// @ts-expect-error legacy noImplicitAny
(typeof request.headers['cf-webhook-auth'] === 'string' ||
timingSafeEqual(
// @ts-expect-error legacy noImplicitAny
Buffer.from(request.headers['cf-webhook-auth']),
Buffer.from(cloudflareWebhookSecret),
)))
) {
return true;
}
return false;
} catch {
return false;
}
}
}
@@ -0,0 +1,64 @@
import { type ExecutionContext } from '@nestjs/common';
import * as crypto from 'crypto';
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/cloudflare/guards/cloudflare-secret.guard';
describe('CloudflareSecretMatchGuard.canActivate', () => {
let guard: CloudflareSecretMatchGuard;
let twentyConfigService: TwentyConfigService;
beforeEach(() => {
twentyConfigService = {
get: jest.fn(),
} as unknown as TwentyConfigService;
guard = new CloudflareSecretMatchGuard(twentyConfigService);
});
it('should return true when the webhook secret matches', () => {
const mockRequest = { headers: { 'cf-webhook-auth': 'valid-secret' } };
jest.spyOn(twentyConfigService, 'get').mockReturnValue('valid-secret');
const mockContext = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as unknown as ExecutionContext;
jest.spyOn(crypto, 'timingSafeEqual').mockReturnValue(true);
expect(guard.canActivate(mockContext)).toBe(true);
});
it('should return true when env is not set', () => {
const mockRequest = { headers: { 'cf-webhook-auth': 'valid-secret' } };
jest.spyOn(twentyConfigService, 'get').mockReturnValue(undefined);
const mockContext = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as unknown as ExecutionContext;
jest.spyOn(crypto, 'timingSafeEqual').mockReturnValue(true);
expect(guard.canActivate(mockContext)).toBe(true);
});
it('should return false if an error occurs', () => {
const mockRequest = { headers: {} };
jest.spyOn(twentyConfigService, 'get').mockReturnValue('valid-secret');
const mockContext = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as unknown as ExecutionContext;
expect(guard.canActivate(mockContext)).toBe(false);
});
});