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
@@ -1,106 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { type Request } from 'express';
import { DnsCloudflareController } from 'src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
describe('DnsCloudflareController - customHostnameWebhooks', () => {
let controller: DnsCloudflareController;
let dnsManagerService: DnsManagerService;
let domainManagerService: DomainManagerService;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
controllers: [DnsCloudflareController],
providers: [
{
provide: DomainManagerService,
useValue: {
handleCustomDomainActivation: jest.fn(),
},
},
{
provide: DnsManagerService,
useValue: {
isHostnameWorking: jest.fn(),
},
},
{
provide: HttpExceptionHandlerService,
useValue: {
handleError: jest.fn(),
},
},
{
provide: ExceptionHandlerService,
useValue: {
captureExceptions: jest.fn(),
},
},
{
provide: TwentyConfigService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
controller = module.get<DnsCloudflareController>(DnsCloudflareController);
dnsManagerService = module.get<DnsManagerService>(DnsManagerService);
domainManagerService =
module.get<DomainManagerService>(DomainManagerService);
});
it('should return if hostname is missing', async () => {
const req = {
headers: { 'cf-webhook-auth': 'correct-secret' },
body: { data: { data: {} } },
} as unknown as Request;
await controller.customHostnameWebhooks(req);
expect(dnsManagerService.isHostnameWorking).not.toHaveBeenCalled();
});
it('should return if wrong alert_type', async () => {
const req = {
headers: { 'cf-webhook-auth': 'correct-secret' },
body: { alert_type: 'wrong_alert_type', data: { data: {} } },
} as unknown as Request;
await controller.customHostnameWebhooks(req);
expect(dnsManagerService.isHostnameWorking).not.toHaveBeenCalled();
});
it('should update workspace for a valid hostname and save changes', async () => {
const req = {
headers: { 'cf-webhook-auth': 'correct-secret' },
body: {
alert_type: 'custom_ssl_certificate_event_type',
data: { data: { hostname: 'example.com' } },
},
} as unknown as Request;
jest.spyOn(dnsManagerService, 'isHostnameWorking').mockResolvedValue(true);
await controller.customHostnameWebhooks(req);
expect(dnsManagerService.isHostnameWorking).toHaveBeenCalled();
expect(
domainManagerService.handleCustomDomainActivation,
).toHaveBeenCalledWith({
customDomain: 'example.com',
isCustomDomainWorking: true,
});
});
});
@@ -1,45 +0,0 @@
/* @license Enterprise */
import { Controller, Post, Req, UseFilters, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/dns-manager/guards/cloudflare-secret.guard';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { DnsManagerExceptionFilter } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter';
@Controller()
@UseFilters(AuthRestApiExceptionFilter, DnsManagerExceptionFilter)
export class DnsCloudflareController {
constructor(
protected readonly domainManagerService: DomainManagerService,
protected readonly dnsManagerService: DnsManagerService,
) {}
@Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare'])
@UseGuards(CloudflareSecretMatchGuard, PublicEndpointGuard)
async customHostnameWebhooks(@Req() req: Request) {
const alertType = req.body?.alert_type;
const hostname = req.body?.data?.data?.hostname;
if (alertType !== 'custom_ssl_certificate_event_type' || !hostname) {
return;
}
try {
const isCustomDomainWorking =
await this.dnsManagerService.isHostnameWorking(hostname);
await this.domainManagerService.handleCustomDomainActivation({
customDomain: hostname,
isCustomDomainWorking,
});
} catch {
return;
}
}
}
@@ -2,12 +2,10 @@ import { Module } from '@nestjs/common';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { DnsCloudflareController } from 'src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller';
@Module({
imports: [DomainManagerModule],
providers: [DnsManagerService],
controllers: [DnsCloudflareController],
exports: [DnsManagerService],
})
export class DnsManagerModule {}
@@ -1,44 +0,0 @@
/* @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;
}
}
}
@@ -1,64 +0,0 @@
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/dns-manager/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);
});
});
@@ -13,7 +13,7 @@ import {
DnsManagerExceptionCode,
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { dnsManagerValidator } from 'src/engine/core-modules/dns-manager/validator/cloudflare.validate';
import { dnsManagerValidator } from 'src/engine/core-modules/dns-manager/validator/dns-manager.validate';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';