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:
@@ -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 {}
|
||||
+62
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user