Enhance Salesforce order fulfillment process and security measures
- Updated PLESK_DEPLOYMENT.md to include new Salesforce credentials and webhook security configurations. - Refactored order fulfillment controller to streamline the process and improve readability. - Introduced EnhancedWebhookSignatureGuard for improved HMAC signature validation and nonce management. - Updated various documentation files to reflect changes in endpoint naming from `/provision` to `/fulfill` for clarity and consistency. - Enhanced Redis integration for nonce storage to prevent replay attacks. - Removed deprecated WebhookSignatureGuard in favor of the new enhanced guard.
This commit is contained in:
parent
33e3963fcf
commit
ece6821766
@ -90,6 +90,12 @@ customer-portal/
|
||||
- `DATABASE_URL` should use `database:5432`
|
||||
- `REDIS_URL` should use `cache:6379`
|
||||
- Set `JWT_SECRET` to a strong value
|
||||
- Salesforce credentials: `SF_LOGIN_URL`, `SF_CLIENT_ID`, `SF_USERNAME`
|
||||
- Salesforce private key: set `SF_PRIVATE_KEY_PATH=/app/secrets/sf-private.key` and mount `/app/secrets`
|
||||
- Webhook secrets: `SF_WEBHOOK_SECRET` (Salesforce), `WHMCS_WEBHOOK_SECRET` (if using WHMCS webhooks)
|
||||
- Webhook tolerances: `WEBHOOK_TIMESTAMP_TOLERANCE=300000` (ms; optional)
|
||||
- Optional IP allowlists: `SF_WEBHOOK_IP_ALLOWLIST`, `WHMCS_WEBHOOK_IP_ALLOWLIST` (CSV of IP/CIDR)
|
||||
- Pricebook: `PORTAL_PRICEBOOK_ID`
|
||||
|
||||
### Image Build and Upload
|
||||
|
||||
@ -123,6 +129,17 @@ In Plesk → Docker → Images, upload both tar files. Then use `compose-plesk.y
|
||||
- `/` → `portal-frontend` port `3000`
|
||||
- `/api` → `portal-backend` port `4000`
|
||||
|
||||
### Webhook Security (Plesk)
|
||||
|
||||
- Endpoint for Salesforce Quick Action:
|
||||
- `POST /api/orders/{sfOrderId}/fulfill`
|
||||
- Required backend env (see above). Ensure the same HMAC secret is configured in Salesforce.
|
||||
- The backend guard enforces:
|
||||
- HMAC for all webhooks
|
||||
- Salesforce: timestamp + nonce with Redis-backed replay protection
|
||||
- WHMCS: timestamp/nonce optional (validated if present)
|
||||
- Health check `/health` includes `integrations.redis` to verify nonce storage.
|
||||
|
||||
Alternatively, load via SSH on the Plesk host:
|
||||
|
||||
```bash
|
||||
|
||||
@ -247,6 +247,7 @@ When running `pnpm dev:tools`, you get access to:
|
||||
|
||||
### Webhooks
|
||||
|
||||
- `POST /api/orders/:sfOrderId/fulfill` - Secure Salesforce-initiated order fulfillment
|
||||
- `POST /api/webhooks/whmcs` - WHMCS action hooks → update mirrors + bust cache
|
||||
|
||||
## Frontend Pages
|
||||
@ -411,3 +412,4 @@ rm -rf node_modules && pnpm install
|
||||
## License
|
||||
|
||||
[Your License Here]
|
||||
See `docs/RUNBOOK_PROVISIONING.md` for the provisioning runbook.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { Controller, Get, Inject } from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { PrismaService } from "../common/prisma/prisma.service";
|
||||
import { getErrorMessage } from "../common/utils/error.util";
|
||||
@ -6,6 +6,7 @@ import { InjectQueue } from "@nestjs/bullmq";
|
||||
import { Queue } from "bullmq";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Public } from "../auth/decorators/public.decorator";
|
||||
import { CacheService } from "../common/cache/cache.service";
|
||||
|
||||
@ApiTags("Health")
|
||||
@Controller("health")
|
||||
@ -14,7 +15,8 @@ export class HealthController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
@InjectQueue("email") private readonly emailQueue: Queue
|
||||
@InjectQueue("email") private readonly emailQueue: Queue,
|
||||
private readonly cache: CacheService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -45,6 +47,15 @@ export class HealthController {
|
||||
"delayed"
|
||||
);
|
||||
|
||||
// Check Redis availability by a simple set/get on a volatile key
|
||||
const nonceProbeKey = "health:nonce:probe";
|
||||
let redisStatus: "connected" | "degraded" | "unavailable" = "connected";
|
||||
try {
|
||||
await this.cache.set(nonceProbeKey, 1, 5);
|
||||
} catch {
|
||||
redisStatus = "unavailable";
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
@ -55,6 +66,9 @@ export class HealthController {
|
||||
queues: {
|
||||
email: emailQueueInfo,
|
||||
},
|
||||
integrations: {
|
||||
redis: redisStatus,
|
||||
},
|
||||
features: {
|
||||
emailEnabled: this.config.get("EMAIL_ENABLED", "true") === "true",
|
||||
emailQueued: this.config.get("EMAIL_USE_QUEUE", "true") === "true",
|
||||
|
||||
@ -145,4 +145,6 @@ export class OrderFulfillmentController {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Removed /provision alias to avoid confusion — use /fulfill only
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { OrderFulfillmentController } from "./controllers/order-fulfillment.cont
|
||||
import { VendorsModule } from "../vendors/vendors.module";
|
||||
import { MappingsModule } from "../mappings/mappings.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { WebhooksModule } from "../webhooks/webhooks.module";
|
||||
|
||||
// Clean modular order services
|
||||
import { OrderValidator } from "./services/order-validator.service";
|
||||
@ -19,7 +20,7 @@ import { OrderFulfillmentOrchestrator } from "./services/order-fulfillment-orche
|
||||
import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service";
|
||||
|
||||
@Module({
|
||||
imports: [VendorsModule, MappingsModule, UsersModule],
|
||||
imports: [VendorsModule, MappingsModule, UsersModule, WebhooksModule],
|
||||
controllers: [OrdersController, OrderFulfillmentController],
|
||||
providers: [
|
||||
// Order creation services (modular)
|
||||
|
||||
@ -5,6 +5,7 @@ import { WhmcsOrderService, WhmcsOrderResult } from "../../vendors/whmcs/service
|
||||
import { OrderOrchestrator } from "./order-orchestrator.service";
|
||||
import { OrderFulfillmentValidator, OrderFulfillmentValidationResult } from "./order-fulfillment-validator.service";
|
||||
import { OrderWhmcsMapper, OrderItemMappingResult } from "./order-whmcs-mapper.service";
|
||||
import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service";
|
||||
import { getErrorMessage } from "../../common/utils/error.util";
|
||||
|
||||
|
||||
@ -40,7 +41,8 @@ export class OrderFulfillmentOrchestrator {
|
||||
private readonly whmcsOrderService: WhmcsOrderService,
|
||||
private readonly orderOrchestrator: OrderOrchestrator,
|
||||
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
|
||||
private readonly orderWhmcsMapper: OrderWhmcsMapper
|
||||
private readonly orderWhmcsMapper: OrderWhmcsMapper,
|
||||
private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -276,7 +278,7 @@ export class OrderFulfillmentOrchestrator {
|
||||
context: OrderFulfillmentContext,
|
||||
error: Error
|
||||
): Promise<void> {
|
||||
const errorCode = this.determineErrorCode(error);
|
||||
const errorCode = this.orderFulfillmentErrorService.determineErrorCode(error);
|
||||
const userMessage = error.message;
|
||||
|
||||
this.logger.error("Fulfillment orchestration failed", {
|
||||
@ -309,25 +311,6 @@ export class OrderFulfillmentOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine error code based on error type
|
||||
*/
|
||||
private determineErrorCode(error: Error): string {
|
||||
if (error.message.includes("Payment method missing")) {
|
||||
return "PAYMENT_METHOD_MISSING";
|
||||
}
|
||||
if (error.message.includes("not found")) {
|
||||
return "ORDER_NOT_FOUND";
|
||||
}
|
||||
if (error.message.includes("WHMCS")) {
|
||||
return "WHMCS_ERROR";
|
||||
}
|
||||
if (error.message.includes("mapping")) {
|
||||
return "MAPPING_ERROR";
|
||||
}
|
||||
return "FULFILLMENT_ERROR";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fulfillment summary from context
|
||||
*/
|
||||
|
||||
@ -5,6 +5,7 @@ import { WhmcsOrderService } from "../../vendors/whmcs/services/whmcs-order.serv
|
||||
import { MappingsService } from "../../mappings/mappings.service";
|
||||
import { getErrorMessage } from "../../common/utils/error.util";
|
||||
import { SalesforceOrder } from "../types/salesforce-order.types";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
export interface OrderFulfillmentValidationResult {
|
||||
sfOrder: SalesforceOrder;
|
||||
@ -23,7 +24,8 @@ export class OrderFulfillmentValidator {
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
private readonly whmcsOrderService: WhmcsOrderService,
|
||||
private readonly mappingsService: MappingsService
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -198,7 +200,8 @@ export class OrderFulfillmentValidator {
|
||||
try {
|
||||
const requestTime = new Date(timestamp).getTime();
|
||||
const now = Date.now();
|
||||
const maxAge = 5 * 60 * 1000; // 5 minutes
|
||||
const maxAge =
|
||||
this.configService.get<number>("WEBHOOK_TIMESTAMP_TOLERANCE") ?? 5 * 60 * 1000; // default 5m
|
||||
|
||||
if (Math.abs(now - requestTime) > maxAge) {
|
||||
throw new BadRequestException("Request timestamp is too old");
|
||||
|
||||
@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { SalesforceConnection } from "./salesforce-connection.service";
|
||||
import { SalesforceQueryResult as SfQueryResult } from "../../../orders/types/salesforce-order.types";
|
||||
|
||||
export interface AccountData {
|
||||
name: string;
|
||||
@ -20,22 +21,12 @@ export interface UpsertResult {
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
interface SalesforceQueryResult {
|
||||
records: SalesforceAccount[];
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
interface SalesforceAccount {
|
||||
Id: string;
|
||||
Name: string;
|
||||
WH_Account__c?: string;
|
||||
}
|
||||
|
||||
interface _SalesforceCreateResult {
|
||||
id: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SalesforceAccountService {
|
||||
constructor(
|
||||
@ -49,7 +40,7 @@ export class SalesforceAccountService {
|
||||
try {
|
||||
const result = (await this.connection.query(
|
||||
`SELECT Id FROM Account WHERE SF_Account_No__c = '${this.safeSoql(customerNumber.trim())}'`
|
||||
)) as SalesforceQueryResult;
|
||||
)) as SfQueryResult<SalesforceAccount>;
|
||||
return result.totalSize > 0 ? { id: result.records[0].Id } : null;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to find account by customer number", {
|
||||
@ -67,7 +58,7 @@ export class SalesforceAccountService {
|
||||
try {
|
||||
const result = (await this.connection.query(
|
||||
`SELECT Id, Name, WH_Account__c FROM Account WHERE Id = '${this.safeSoql(accountId.trim())}'`
|
||||
)) as SalesforceQueryResult;
|
||||
)) as SfQueryResult<SalesforceAccount>;
|
||||
|
||||
if (result.totalSize === 0) {
|
||||
return null;
|
||||
@ -120,7 +111,7 @@ export class SalesforceAccountService {
|
||||
try {
|
||||
const existingAccount = (await this.connection.query(
|
||||
`SELECT Id FROM Account WHERE Name = '${this.safeSoql(accountData.name.trim())}'`
|
||||
)) as SalesforceQueryResult;
|
||||
)) as SfQueryResult<SalesforceAccount>;
|
||||
|
||||
const sfData = {
|
||||
Name: accountData.name.trim(),
|
||||
@ -168,7 +159,7 @@ export class SalesforceAccountService {
|
||||
SELECT Id, Name
|
||||
FROM Account
|
||||
WHERE Id = '${this.validateId(accountId)}'
|
||||
`)) as SalesforceQueryResult;
|
||||
`)) as SfQueryResult<SalesforceAccount>;
|
||||
|
||||
return result.totalSize > 0 ? result.records[0] : null;
|
||||
} catch (error) {
|
||||
|
||||
@ -4,6 +4,10 @@ import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { SalesforceConnection } from "./salesforce-connection.service";
|
||||
import { SupportCase, CreateCaseRequest, CaseType } from "@customer-portal/shared";
|
||||
import { CaseStatus, CasePriority, CASE_STATUS, CASE_PRIORITY } from "@customer-portal/shared";
|
||||
import {
|
||||
SalesforceQueryResult as SfQueryResult,
|
||||
SalesforceCreateResult as SfCreateResult,
|
||||
} from "../../../orders/types/salesforce-order.types";
|
||||
|
||||
export interface CaseQueryParams {
|
||||
status?: string;
|
||||
@ -27,11 +31,6 @@ interface CaseData {
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
interface SalesforceQueryResult {
|
||||
records: SalesforceCase[];
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
interface SalesforceCase {
|
||||
Id: string;
|
||||
CaseNumber: string;
|
||||
@ -52,11 +51,6 @@ interface SalesforceCase {
|
||||
};
|
||||
}
|
||||
|
||||
interface SalesforceCreateResult {
|
||||
id: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SalesforceCaseService {
|
||||
constructor(
|
||||
@ -92,7 +86,7 @@ export class SalesforceCaseService {
|
||||
query += ` OFFSET ${params.offset}`;
|
||||
}
|
||||
|
||||
const result = (await this.connection.query(query)) as SalesforceQueryResult;
|
||||
const result = (await this.connection.query(query)) as SfQueryResult<SalesforceCase>;
|
||||
|
||||
const cases = result.records.map(record => this.transformCase(record));
|
||||
|
||||
@ -157,7 +151,7 @@ export class SalesforceCaseService {
|
||||
WHERE Email = '${this.safeSoql(userData.email)}'
|
||||
AND AccountId = '${userData.accountId}'
|
||||
LIMIT 1
|
||||
`)) as SalesforceQueryResult;
|
||||
`)) as SfQueryResult<SalesforceCase>;
|
||||
|
||||
if (existingContact.totalSize > 0) {
|
||||
return existingContact.records[0].Id;
|
||||
@ -172,7 +166,7 @@ export class SalesforceCaseService {
|
||||
};
|
||||
|
||||
const sobject = this.connection.sobject("Contact") as unknown as {
|
||||
create: (data: Record<string, unknown>) => Promise<SalesforceCreateResult>;
|
||||
create: (data: Record<string, unknown>) => Promise<SfCreateResult>;
|
||||
};
|
||||
const result = await sobject.create(contactData);
|
||||
return result.id;
|
||||
@ -201,7 +195,7 @@ export class SalesforceCaseService {
|
||||
};
|
||||
|
||||
const sobject = this.connection.sobject("Case") as unknown as {
|
||||
create: (data: Record<string, unknown>) => Promise<SalesforceCreateResult>;
|
||||
create: (data: Record<string, unknown>) => Promise<SfCreateResult>;
|
||||
};
|
||||
const result = await sobject.create(sfData);
|
||||
|
||||
@ -211,7 +205,7 @@ export class SalesforceCaseService {
|
||||
CreatedDate, LastModifiedDate, ClosedDate, ContactId, AccountId, OwnerId, Owner.Name
|
||||
FROM Case
|
||||
WHERE Id = '${result.id}'
|
||||
`)) as SalesforceQueryResult;
|
||||
`)) as SfQueryResult<SalesforceCase>;
|
||||
|
||||
return createdCase.records[0];
|
||||
}
|
||||
|
||||
@ -12,11 +12,6 @@ export interface SalesforceSObjectApi {
|
||||
update?: (data: Record<string, unknown> & { Id: string }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface _SalesforceRetryableSObjectApi extends SalesforceSObjectApi {
|
||||
create: (data: Record<string, unknown>) => Promise<{ id?: string }>;
|
||||
update?: (data: Record<string, unknown> & { Id: string }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SalesforceConnection {
|
||||
private connection: jsforce.Connection;
|
||||
@ -48,18 +43,26 @@ export class SalesforceConnection {
|
||||
throw new Error(isProd ? "Salesforce configuration is missing" : devMessage);
|
||||
}
|
||||
|
||||
// Resolve private key strictly relative to repo root and enforce secrets directory
|
||||
// Use monorepo layout assumption: apps/bff -> repo root is two levels up
|
||||
const appDir = process.cwd();
|
||||
const repoRoot = path.resolve(appDir, "../../");
|
||||
const secretsDir = path.resolve(repoRoot, "secrets");
|
||||
const resolvedKeyPath = path.resolve(repoRoot, privateKeyPath);
|
||||
// Resolve private key and enforce allowed secrets directories
|
||||
// Supports both local dev (./secrets) and container prod (/app/secrets)
|
||||
const isAbsolute = path.isAbsolute(privateKeyPath);
|
||||
const resolvedKeyPath = isAbsolute
|
||||
? privateKeyPath
|
||||
: path.resolve(process.cwd(), privateKeyPath);
|
||||
|
||||
const allowedBases = [
|
||||
path.resolve(process.cwd(), "secrets"),
|
||||
"/app/secrets",
|
||||
].map((p) => path.normalize(p) + path.sep);
|
||||
|
||||
// Enforce the key to be under repo-root/secrets
|
||||
const normalizedKeyPath = path.normalize(resolvedKeyPath);
|
||||
const normalizedSecretsDir = path.normalize(secretsDir) + path.sep;
|
||||
if (!(normalizedKeyPath + path.sep).startsWith(normalizedSecretsDir)) {
|
||||
const devMsg = `Salesforce private key must be located under the root secrets directory: ${secretsDir}`;
|
||||
const isUnderAllowedBase = allowedBases.some((base) =>
|
||||
(normalizedKeyPath + path.sep).startsWith(base)
|
||||
);
|
||||
if (!isUnderAllowedBase) {
|
||||
const devMsg = `Salesforce private key must be under one of the allowed secrets directories: ${allowedBases
|
||||
.map((b) => b.replace(/\\$/, ""))
|
||||
.join(", ")}. Got: ${normalizedKeyPath}`;
|
||||
throw new Error(isProd ? "Invalid Salesforce private key path" : devMsg);
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, Inject } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Request } from "express";
|
||||
import crypto from "node:crypto";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Inject } from "@nestjs/common";
|
||||
import { CacheService } from "../../common/cache/cache.service";
|
||||
|
||||
interface WebhookRequest extends Request {
|
||||
webhookMetadata?: {
|
||||
@ -16,36 +16,48 @@ interface WebhookRequest extends Request {
|
||||
|
||||
@Injectable()
|
||||
export class EnhancedWebhookSignatureGuard implements CanActivate {
|
||||
private readonly nonceStore = new Set<string>(); // In production, use Redis
|
||||
private readonly maxNonceAge = 5 * 60 * 1000; // 5 minutes
|
||||
private readonly allowedIps: string[];
|
||||
// Fallback in-memory nonce store for local/dev only
|
||||
private readonly nonceStore = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {
|
||||
// Parse IP allowlist from environment
|
||||
const ipAllowlist = this.configService.get<string>("SF_WEBHOOK_IP_ALLOWLIST");
|
||||
this.allowedIps = ipAllowlist ? ipAllowlist.split(",").map(ip => ip.trim()) : [];
|
||||
}
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly cache: CacheService
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<WebhookRequest>();
|
||||
|
||||
try {
|
||||
// 1. Verify source IP if allowlist is configured
|
||||
if (this.allowedIps.length > 0) {
|
||||
this.verifySourceIp(request);
|
||||
// Determine webhook type by signature header
|
||||
const isWhmcs = Boolean(request.headers["x-whmcs-signature"]);
|
||||
const isSalesforce = Boolean(request.headers["x-sf-signature"]);
|
||||
if (!isWhmcs && !isSalesforce) {
|
||||
throw new UnauthorizedException("Webhook signature is required");
|
||||
}
|
||||
|
||||
// 1. Verify source IP if allowlist is configured (per vendor)
|
||||
const ipAllowlistStr = this.configService.get<string>(
|
||||
isWhmcs ? "WHMCS_WEBHOOK_IP_ALLOWLIST" : "SF_WEBHOOK_IP_ALLOWLIST"
|
||||
);
|
||||
const allowedIps = ipAllowlistStr
|
||||
? ipAllowlistStr
|
||||
.split(",")
|
||||
.map((ip) => ip.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
if (allowedIps.length > 0) {
|
||||
this.verifySourceIp(request, allowedIps);
|
||||
}
|
||||
|
||||
// 2. Extract and verify required headers
|
||||
const headers = this.extractHeaders(request);
|
||||
const headers = this.extractHeaders(request, isSalesforce ? "salesforce" : "whmcs");
|
||||
|
||||
// 3. Verify timestamp (prevent replay attacks)
|
||||
this.verifyTimestamp(headers.timestamp);
|
||||
this.verifyTimestamp(headers.timestamp, isSalesforce);
|
||||
|
||||
// 4. Verify nonce (prevent duplicate processing)
|
||||
this.verifyNonce(headers.nonce);
|
||||
await this.verifyNonce(headers.nonce, isSalesforce);
|
||||
|
||||
// 5. Verify HMAC signature
|
||||
this.verifyHmacSignature(request, headers.signature);
|
||||
@ -53,8 +65,8 @@ export class EnhancedWebhookSignatureGuard implements CanActivate {
|
||||
// Store metadata for logging/monitoring
|
||||
request.webhookMetadata = {
|
||||
sourceIp: request.ip || "unknown",
|
||||
timestamp: new Date(headers.timestamp),
|
||||
nonce: headers.nonce,
|
||||
timestamp: headers.timestamp ? new Date(headers.timestamp) : (undefined as any),
|
||||
nonce: headers.nonce as any,
|
||||
signature: headers.signature,
|
||||
};
|
||||
|
||||
@ -75,11 +87,11 @@ export class EnhancedWebhookSignatureGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
|
||||
private verifySourceIp(request: Request): void {
|
||||
private verifySourceIp(request: Request, allowedIps: string[]): void {
|
||||
const clientIp = request.ip || request.connection.remoteAddress || "unknown";
|
||||
|
||||
// Check if IP is in allowlist (simplified - in production use proper CIDR matching)
|
||||
const isAllowed = this.allowedIps.some(allowedIp => {
|
||||
const isAllowed = allowedIps.some((allowedIp) => {
|
||||
if (allowedIp.includes("/")) {
|
||||
// CIDR notation - implement proper CIDR matching
|
||||
return this.isIpInCidr(clientIp, allowedIp);
|
||||
@ -92,28 +104,33 @@ export class EnhancedWebhookSignatureGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
|
||||
private extractHeaders(request: Request) {
|
||||
const signature =
|
||||
(request.headers["x-sf-signature"] as string) ||
|
||||
(request.headers["x-whmcs-signature"] as string);
|
||||
|
||||
const timestamp = request.headers["x-sf-timestamp"] as string;
|
||||
const nonce = request.headers["x-sf-nonce"] as string;
|
||||
private extractHeaders(request: Request, vendor: "salesforce" | "whmcs") {
|
||||
const signature = (request.headers[
|
||||
vendor === "salesforce" ? "x-sf-signature" : "x-whmcs-signature"
|
||||
] as string) as string;
|
||||
const timestamp = (request.headers[
|
||||
vendor === "salesforce" ? "x-sf-timestamp" : ("x-whmcs-timestamp" as any)
|
||||
] as string) as string | undefined;
|
||||
const nonce = (request.headers[
|
||||
vendor === "salesforce" ? "x-sf-nonce" : ("x-whmcs-nonce" as any)
|
||||
] as string) as string | undefined;
|
||||
|
||||
if (!signature) {
|
||||
throw new UnauthorizedException("Webhook signature is required");
|
||||
}
|
||||
if (!timestamp) {
|
||||
throw new UnauthorizedException("Webhook timestamp is required");
|
||||
}
|
||||
if (!nonce) {
|
||||
throw new UnauthorizedException("Webhook nonce is required");
|
||||
if (vendor === "salesforce") {
|
||||
if (!timestamp) throw new UnauthorizedException("Webhook timestamp is required");
|
||||
if (!nonce) throw new UnauthorizedException("Webhook nonce is required");
|
||||
}
|
||||
|
||||
return { signature, timestamp, nonce };
|
||||
}
|
||||
|
||||
private verifyTimestamp(timestamp: string): void {
|
||||
private verifyTimestamp(timestamp: string | undefined, required: boolean): void {
|
||||
if (!timestamp) {
|
||||
if (required) throw new UnauthorizedException("Invalid timestamp format");
|
||||
return; // optional for WHMCS
|
||||
}
|
||||
const requestTime = new Date(timestamp).getTime();
|
||||
const now = Date.now();
|
||||
const tolerance = this.configService.get<number>("WEBHOOK_TIMESTAMP_TOLERANCE") || 300000; // 5 minutes
|
||||
@ -127,17 +144,35 @@ export class EnhancedWebhookSignatureGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
|
||||
private verifyNonce(nonce: string): void {
|
||||
// Check if nonce was already used
|
||||
if (this.nonceStore.has(nonce)) {
|
||||
throw new UnauthorizedException("Nonce already used (replay attack detected)");
|
||||
private async verifyNonce(nonce: string | undefined, required: boolean): Promise<void> {
|
||||
if (!nonce) {
|
||||
if (required) throw new UnauthorizedException("Webhook nonce is required");
|
||||
return; // optional for WHMCS
|
||||
}
|
||||
// Prefer Redis-backed nonce storage for distributed replay protection
|
||||
const ttlMs = this.configService.get<number>("WEBHOOK_TIMESTAMP_TOLERANCE") ?? 300000; // 5m
|
||||
const ttlSec = Math.max(1, Math.ceil(ttlMs / 1000));
|
||||
const key = `webhook:nonce:${nonce}`;
|
||||
|
||||
// Add nonce to store
|
||||
this.nonceStore.add(nonce);
|
||||
|
||||
// Clean up old nonces (in production, implement proper TTL with Redis)
|
||||
this.cleanupOldNonces();
|
||||
// If Redis is reachable, prefer it
|
||||
try {
|
||||
const exists = await this.cache.exists(key);
|
||||
if (exists) {
|
||||
throw new UnauthorizedException("Nonce already used (replay attack detected)");
|
||||
}
|
||||
await this.cache.set(key, 1, ttlSec);
|
||||
return;
|
||||
} catch (err) {
|
||||
// If Redis fails, fall back to in-memory in dev
|
||||
this.logger.warn("Redis unavailable for nonce storage, using in-memory fallback", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
if (this.nonceStore.has(nonce)) {
|
||||
throw new UnauthorizedException("Nonce already used (replay attack detected)");
|
||||
}
|
||||
this.nonceStore.add(nonce);
|
||||
this.cleanupOldNonces();
|
||||
}
|
||||
}
|
||||
|
||||
private verifyHmacSignature(request: Request, signature: string): void {
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Request } from "express";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
@Injectable()
|
||||
export class WebhookSignatureGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const signatureHeader =
|
||||
(request.headers["x-whmcs-signature"] as string | undefined) ||
|
||||
(request.headers["x-sf-signature"] as string | undefined);
|
||||
|
||||
if (!signatureHeader) {
|
||||
throw new UnauthorizedException("Webhook signature is required");
|
||||
}
|
||||
|
||||
// Get the appropriate secret based on the webhook type
|
||||
const isWhmcs = Boolean(request.headers["x-whmcs-signature"]);
|
||||
const secret = isWhmcs
|
||||
? this.configService.get<string>("WHMCS_WEBHOOK_SECRET")
|
||||
: this.configService.get<string>("SF_WEBHOOK_SECRET");
|
||||
|
||||
if (!secret) {
|
||||
throw new UnauthorizedException("Webhook secret not configured");
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const payload = Buffer.from(JSON.stringify(request.body), "utf8");
|
||||
const key = Buffer.from(secret, "utf8");
|
||||
const expectedSignature = crypto.createHmac("sha256", key).update(payload).digest("hex");
|
||||
|
||||
if (signatureHeader !== expectedSignature) {
|
||||
throw new UnauthorizedException("Invalid webhook signature");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,7 @@ import {
|
||||
import { WebhooksService } from "./webhooks.service";
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from "@nestjs/swagger";
|
||||
import { ThrottlerGuard } from "@nestjs/throttler";
|
||||
import { WebhookSignatureGuard } from "./guards/webhook-signature.guard";
|
||||
import { EnhancedWebhookSignatureGuard } from "./guards/enhanced-webhook-signature.guard";
|
||||
import { Public } from "../auth/decorators/public.decorator";
|
||||
|
||||
@ApiTags("webhooks")
|
||||
@ -23,7 +23,7 @@ export class WebhooksController {
|
||||
|
||||
@Post("whmcs")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(WebhookSignatureGuard)
|
||||
@UseGuards(EnhancedWebhookSignatureGuard)
|
||||
@ApiOperation({ summary: "WHMCS webhook endpoint" })
|
||||
@ApiResponse({ status: 200, description: "Webhook processed successfully" })
|
||||
@ApiResponse({ status: 400, description: "Invalid webhook data" })
|
||||
@ -40,7 +40,7 @@ export class WebhooksController {
|
||||
|
||||
@Post("salesforce")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(WebhookSignatureGuard)
|
||||
@UseGuards(EnhancedWebhookSignatureGuard)
|
||||
@ApiOperation({ summary: "Salesforce webhook endpoint" })
|
||||
@ApiResponse({ status: 200, description: "Webhook processed successfully" })
|
||||
@ApiResponse({ status: 400, description: "Invalid webhook data" })
|
||||
|
||||
@ -3,10 +3,12 @@ import { WebhooksController } from "./webhooks.controller";
|
||||
import { WebhooksService } from "./webhooks.service";
|
||||
import { VendorsModule } from "../vendors/vendors.module";
|
||||
import { JobsModule } from "../jobs/jobs.module";
|
||||
import { EnhancedWebhookSignatureGuard } from "./guards/enhanced-webhook-signature.guard";
|
||||
|
||||
@Module({
|
||||
imports: [VendorsModule, JobsModule],
|
||||
controllers: [WebhooksController],
|
||||
providers: [WebhooksService],
|
||||
providers: [WebhooksService, EnhancedWebhookSignatureGuard],
|
||||
exports: [EnhancedWebhookSignatureGuard],
|
||||
})
|
||||
export class WebhooksModule {}
|
||||
|
||||
@ -24,7 +24,7 @@ hasPaymentMethod(clientId: number): Promise<boolean>
|
||||
```
|
||||
|
||||
### **2. Order Provisioning Service**
|
||||
**File**: `/apps/bff/src/orders/services/order-provisioning.service.ts`
|
||||
**File**: `/apps/bff/src/orders/services/order-fulfillment.service.ts`
|
||||
|
||||
- **Purpose**: Orchestrates the complete provisioning flow
|
||||
- **Features**:
|
||||
@ -38,7 +38,7 @@ hasPaymentMethod(clientId: number): Promise<boolean>
|
||||
1. Validate SF Order → 2. Check Payment Method → 3. Map OrderItems → 4. Create WHMCS Order → 5. Accept WHMCS Order → 6. Update Salesforce
|
||||
|
||||
### **3. Separate Salesforce Provisioning Controller**
|
||||
**File**: `/apps/bff/src/orders/controllers/salesforce-provisioning.controller.ts`
|
||||
**File**: `/apps/bff/src/orders/controllers/order-fulfillment.controller.ts`
|
||||
|
||||
- **Purpose**: Dedicated controller for Salesforce webhook calls
|
||||
- **Features**:
|
||||
@ -64,7 +64,7 @@ hasPaymentMethod(clientId: number): Promise<boolean>
|
||||
## 🔄 **The Complete Flow**
|
||||
|
||||
```
|
||||
1. Salesforce Quick Action → POST /orders/{sfOrderId}/provision
|
||||
1. Salesforce Quick Action → POST /orders/{sfOrderId}/fulfill
|
||||
↓
|
||||
2. SalesforceProvisioningController (security validation)
|
||||
↓
|
||||
@ -108,7 +108,7 @@ The system now properly handles the Salesforce → WHMCS mapping as specified in
|
||||
- ✅ **AcceptOrder**: Provisions services and creates subscriptions
|
||||
- ✅ **Payment validation**: Checks client has payment method
|
||||
- ✅ **Error handling**: Updates Salesforce on failures
|
||||
- ✅ **Idempotency**: Prevents duplicate provisioning
|
||||
- ✅ **Idempotency**: Prevents duplicate fulfillment
|
||||
|
||||
## 🎯 **Benefits of New Architecture**
|
||||
|
||||
@ -116,7 +116,7 @@ The system now properly handles the Salesforce → WHMCS mapping as specified in
|
||||
- **Single Responsibility**: Each service has one clear purpose
|
||||
- **Separation of Concerns**: WHMCS logic separate from Salesforce logic
|
||||
- **Testability**: Each service can be tested independently
|
||||
- **Extensibility**: Easy to add new provisioning steps
|
||||
- **Extensibility**: Easy to add new fulfillment steps
|
||||
|
||||
### **Security**:
|
||||
- **Dedicated Controller**: Focused security for Salesforce webhooks
|
||||
@ -124,7 +124,7 @@ The system now properly handles the Salesforce → WHMCS mapping as specified in
|
||||
- **Clean Error Handling**: No sensitive data exposure
|
||||
|
||||
### **Reliability**:
|
||||
- **Idempotency**: Safe retries for provisioning
|
||||
- **Idempotency**: Safe retries for fulfillment
|
||||
- **Comprehensive Logging**: Full audit trail
|
||||
- **Error Recovery**: Proper Salesforce status updates on failures
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ I've cleanly integrated secure Salesforce-to-Portal communication into your exis
|
||||
- **Integration**: Works with your existing Salesforce connection
|
||||
|
||||
### 2. **Secured Orders Controller**
|
||||
- **Enhanced**: Existing `/orders/:sfOrderId/provision` endpoint
|
||||
- **Enhanced**: `/orders/:sfOrderId/fulfill` endpoint
|
||||
- **Added**: `EnhancedWebhookSignatureGuard` for HMAC signature validation
|
||||
- **Added**: Proper API documentation and error handling
|
||||
- **Security**: Timestamp, nonce, and idempotency key validation
|
||||
@ -24,7 +24,7 @@ I've cleanly integrated secure Salesforce-to-Portal communication into your exis
|
||||
## 🔄 The Simple Flow
|
||||
|
||||
```
|
||||
1. Salesforce Quick Action → POST /orders/{sfOrderId}/provision (with HMAC security)
|
||||
1. Salesforce Quick Action → POST /orders/{sfOrderId}/fulfill (with HMAC security)
|
||||
2. Portal BFF validates → Provisions in WHMCS → DIRECTLY updates Salesforce Order
|
||||
3. Customer polls Portal → Gets updated order status
|
||||
```
|
||||
@ -62,7 +62,7 @@ public class OrderProvisioningService {
|
||||
String signature = generateHMACSignature(jsonPayload, WEBHOOK_SECRET);
|
||||
|
||||
HttpRequest req = new HttpRequest();
|
||||
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/provision');
|
||||
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/fulfill');
|
||||
req.setMethod('POST');
|
||||
req.setHeader('Content-Type', 'application/json');
|
||||
req.setHeader('X-SF-Signature', signature);
|
||||
|
||||
@ -52,7 +52,7 @@ We require a Customer Number (SF Number) at signup and gate checkout on the pres
|
||||
- `POST /orders` creates a Salesforce Order (Pending Review) and stores orchestration state in BFF. Portal shows “Awaiting review”.
|
||||
|
||||
5. Review & Provision (operator in Salesforce)
|
||||
- Operator reviews/approves. Quick Action “Provision in WHMCS” invokes BFF `POST /orders/{sfOrderId}/provision`.
|
||||
- Operator reviews/approves. Quick Action “Provision in WHMCS” invokes BFF `POST /orders/{sfOrderId}/fulfill`.
|
||||
- BFF validates payment method, (for eSIM) calls activation API, then `AddOrder` and `AcceptOrder` in WHMCS, updates Salesforce Order fields/status.
|
||||
|
||||
6. Completion
|
||||
@ -188,7 +188,7 @@ Endpoints (BFF)
|
||||
- `GET /orders/:sfOrderId` (new)
|
||||
- Returns orchestration status and relevant IDs; portal polls for updates.
|
||||
|
||||
- `POST /orders/:sfOrderId/provision` (new; invoked from Salesforce only)
|
||||
- `POST /orders/:sfOrderId/fulfill` (Salesforce only)
|
||||
- Auth: Named Credentials + signed headers (HMAC with timestamp/nonce) + IP allowlisting; require `Idempotency-Key`.
|
||||
- Steps:
|
||||
- Re-check payment method; if missing: set SF `Provisioning_Status__c=Failed`, `Error=Payment Method Missing`; return 409.
|
||||
@ -277,7 +277,7 @@ We will build the BFF payload for WHMCS from these line records plus the Order h
|
||||
|
||||
### 3.3 Quick Action / Flow
|
||||
|
||||
- Quick Action “Provision in WHMCS” calls BFF `POST /orders/{sfOrderId}/provision` with headers:
|
||||
- Quick Action “Provision in WHMCS” calls BFF `POST /orders/{sfOrderId}/fulfill` with headers:
|
||||
- `Authorization` (Named Credentials)
|
||||
- `Idempotency-Key` (UUID)
|
||||
- `X-Timestamp`, `X-Nonce`, `X-Signature` (HMAC of method+path+timestamp+nonce+body)
|
||||
@ -367,7 +367,7 @@ Prerequisites for WHMCS provisioning
|
||||
1. Auth: require `sfNumber` in `SignupDto` and signup flow; lookup SF Account by Customer Number; align WHMCS custom field.
|
||||
2. Billing: add `GET /billing/payment-methods/summary` and frontend gating.
|
||||
3. Catalog UI: `/catalog` + product details pages.
|
||||
4. Orders API: implement `POST /orders`, `GET /orders/:sfOrderId`, `POST /orders/:sfOrderId/provision`.
|
||||
4. Orders API: implement `POST /orders`, `GET /orders/:sfOrderId`, `POST /orders/:sfOrderId/fulfill`.
|
||||
5. Salesforce: fields, Quick Action/Flow, Named Credential + signing; LWC for status.
|
||||
6. WHMCS: add wrappers for `AddOrder`, `AcceptOrder`, `GetPayMethods` (if not already exposed).
|
||||
7. Observability: correlation IDs, metrics, alerts; webhook processing for cache busting (optional).
|
||||
@ -421,7 +421,7 @@ Prerequisites for WHMCS provisioning
|
||||
- `GET /orders/:sfOrderId`
|
||||
- Response: `{ sfOrderId, status, whmcsOrderId?, whmcsServiceIds?: number[], lastUpdatedAt }`
|
||||
|
||||
- `POST /orders/:sfOrderId/provision` (SF only)
|
||||
- `POST /orders/:sfOrderId/fulfill` (SF only)
|
||||
- Request headers: `Authorization`, `Idempotency-Key`, `X-Timestamp`, `X-Nonce`, `X-Signature`
|
||||
- Response: `{ status: 'Provisioned' | 'Failed', whmcsOrderId?, whmcsServiceIds?: number[], errorCode?, errorMessage? }`
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar
|
||||
8. BFF: Orders API
|
||||
- `POST /orders`: create SF Order + OrderItems (snapshots: Quantity, UnitPrice, Billing_Cycle, ConfigOptions), status Pending Review; return `sfOrderId`.
|
||||
- `GET /orders/:sfOrderId`: return orchestration status.
|
||||
- `POST /orders/:sfOrderId/provision`: SF-only; recheck payment method; (eSIM) activate; WHMCS AddOrder → AcceptOrder; update SF with IDs/status; send emails.
|
||||
- `POST /orders/:sfOrderId/fulfill`: SF-only; recheck payment method; (eSIM) activate; WHMCS AddOrder → AcceptOrder; update SF with IDs/status; send emails.
|
||||
|
||||
9. Salesforce: Quick Action/Flow
|
||||
- Implement button action to call BFF with Named Credentials + HMAC; pass Idempotency-Key.
|
||||
@ -78,7 +78,7 @@ This roadmap references `PORTAL-ORDERING-PROVISIONING.md` (complete flows and ar
|
||||
14. Idempotency & resilience
|
||||
|
||||
- Cart hash idempotency for `POST /orders`.
|
||||
- Idempotency-Key for `POST /orders/:sfOrderId/provision`.
|
||||
- Idempotency-Key for `POST /orders/:sfOrderId/fulfill`.
|
||||
- Include `sfOrderId` in WHMCS `notes` for duplicate protection.
|
||||
|
||||
15. Security reviews
|
||||
|
||||
79
docs/RUNBOOK_PROVISIONING.md
Normal file
79
docs/RUNBOOK_PROVISIONING.md
Normal file
@ -0,0 +1,79 @@
|
||||
# Provisioning Runbook (Salesforce → Portal → WHMCS)
|
||||
|
||||
This runbook helps operators diagnose issues in the order fulfillment path.
|
||||
|
||||
## Endpoints & Paths
|
||||
|
||||
- Salesforce Quick Action: POST `.../orders/{sfOrderId}/fulfill`
|
||||
- Backend health: GET `/health`
|
||||
|
||||
## Required Env (Backend)
|
||||
|
||||
- `SF_LOGIN_URL`, `SF_CLIENT_ID`, `SF_USERNAME`
|
||||
- `SF_PRIVATE_KEY_PATH` (prod: `/app/secrets/sf-private.key`)
|
||||
- `SF_WEBHOOK_SECRET`
|
||||
- `PORTAL_PRICEBOOK_ID`
|
||||
- Optional: `WEBHOOK_TIMESTAMP_TOLERANCE` (ms)
|
||||
|
||||
## Common Symptoms and Fixes
|
||||
|
||||
- 401 Invalid signature
|
||||
- Verify `SF_WEBHOOK_SECRET` matches Salesforce Named/External Credential
|
||||
- Confirm Apex computes HMAC-SHA256 over raw JSON body
|
||||
- Clocks skewed: adjust `WEBHOOK_TIMESTAMP_TOLERANCE` or fix server time
|
||||
|
||||
- 401 Nonce already used
|
||||
- Replay blocked by Redis-backed nonce store. Ensure the Quick Action does not retry with identical nonce.
|
||||
- If Redis is down, the system falls back to in-memory (dev only); restore Redis for cluster safety.
|
||||
|
||||
- 400 Missing fields (orderId/timestamp/nonce)
|
||||
- Inspect Apex payload construction and headers
|
||||
- Ensure `Idempotency-Key` header is unique per attempt
|
||||
|
||||
- 409 Payment method missing
|
||||
- Customer has no WHMCS payment method
|
||||
- Ask customer to add a payment method; retry fulfill
|
||||
|
||||
- WHMCS Add/Accept errors
|
||||
- Check product mappings: `Product2.WH_Product_ID__c` and `Billing_Cycle__c`
|
||||
- Backend logs show the item mapping report; fix missing mappings
|
||||
|
||||
- Salesforce status not updated
|
||||
- Backend updates `Provisioning_Status__c` and `WHMCS_Order_ID__c` on success, `Provisioning_Error_*` on failure
|
||||
- Verify connected app JWT config and that the API user has Order update permissions
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. In SF, create an Order with OrderItems
|
||||
2. Trigger Quick Action; note `Idempotency-Key`
|
||||
3. Check `/health`: database connected, environment correct
|
||||
4. Tail logs; confirm steps: Activating → WHMCS add → WHMCS accept → Provisioned
|
||||
5. Verify SF fields updated and WHMCS order/service IDs exist
|
||||
|
||||
## Logging Cheatsheet
|
||||
|
||||
- "Salesforce order fulfillment request received" — controller entry
|
||||
- "Starting fulfillment orchestration" — orchestrator start
|
||||
- Step logs: `validation`, `sf_status_update`, `order_details`, `mapping`, `whmcs_create`, `whmcs_accept`, `sf_success_update`
|
||||
- On error: orchestrator updates SF with `Provisioning_Status__c='Failed'` and error code
|
||||
|
||||
## Security Notes
|
||||
|
||||
- HMAC and headers
|
||||
- All inbound calls must include an HMAC signature.
|
||||
- Salesforce must include `X-SF-Timestamp` and `X-SF-Nonce` headers.
|
||||
- WHMCS timestamp/nonce are optional (validated if present).
|
||||
|
||||
- Env variables (backend)
|
||||
- `SF_WEBHOOK_SECRET` (required)
|
||||
- `WHMCS_WEBHOOK_SECRET` (required if WHMCS webhooks are enabled)
|
||||
- `WEBHOOK_TIMESTAMP_TOLERANCE` (ms; default 300000)
|
||||
- `SF_WEBHOOK_IP_ALLOWLIST` (CSV of IP/CIDR; optional)
|
||||
- `WHMCS_WEBHOOK_IP_ALLOWLIST` (CSV of IP/CIDR; optional)
|
||||
|
||||
- Replay protection
|
||||
- Redis-backed nonce store blocks replays (Salesforce required; WHMCS optional).
|
||||
- If Redis is down, a local in-memory fallback is used (dev only). Restore Redis in prod.
|
||||
|
||||
- Health endpoint
|
||||
- `/health` includes `integrations.redis` probe to confirm nonce store availability.
|
||||
@ -9,7 +9,7 @@ This guide focuses specifically on **secure communication between Salesforce and
|
||||
```
|
||||
1. Customer places order → Portal creates Salesforce Order (Status: "Pending Review")
|
||||
2. Salesforce operator reviews → Clicks "Provision in WHMCS" Quick Action
|
||||
3. Salesforce calls Portal BFF → POST /orders/{sfOrderId}/provision
|
||||
3. Salesforce calls Portal BFF → POST /orders/{sfOrderId}/fulfill
|
||||
4. Portal BFF provisions in WHMCS → Updates Salesforce Order status
|
||||
5. Customer sees updated status in Portal
|
||||
```
|
||||
@ -20,7 +20,7 @@ This guide focuses specifically on **secure communication between Salesforce and
|
||||
|
||||
Your existing architecture already handles this securely via the **Quick Action** that calls your BFF endpoint:
|
||||
|
||||
- **Endpoint**: `POST /orders/{sfOrderId}/provision`
|
||||
- **Endpoint**: `POST /orders/{sfOrderId}/fulfill`
|
||||
- **Authentication**: Named Credentials + HMAC signature
|
||||
- **Security**: IP allowlisting, idempotency keys, signed headers
|
||||
|
||||
@ -29,19 +29,19 @@ Your existing architecture already handles this securely via the **Quick Action*
|
||||
Use your existing `EnhancedWebhookSignatureGuard` for the provisioning endpoint:
|
||||
|
||||
```typescript
|
||||
// apps/bff/src/orders/orders.controller.ts
|
||||
@Post(':sfOrderId/provision')
|
||||
// apps/bff/src/orders/controllers/order-fulfillment.controller.ts
|
||||
@Post(':sfOrderId/fulfill')
|
||||
@UseGuards(EnhancedWebhookSignatureGuard)
|
||||
@ApiHeader({ name: "X-SF-Signature", description: "Salesforce HMAC signature" })
|
||||
@ApiHeader({ name: "X-SF-Timestamp", description: "Request timestamp" })
|
||||
@ApiHeader({ name: "X-SF-Nonce", description: "Unique nonce" })
|
||||
@ApiHeader({ name: "Idempotency-Key", description: "Idempotency key" })
|
||||
async provisionOrder(
|
||||
async fulfillOrder(
|
||||
@Param('sfOrderId') sfOrderId: string,
|
||||
@Body() payload: ProvisionOrderRequest,
|
||||
@Body() payload: { orderId: string; timestamp: string; nonce: string },
|
||||
@Headers('idempotency-key') idempotencyKey: string
|
||||
) {
|
||||
return await this.orderOrchestrator.provisionOrder(sfOrderId, payload, idempotencyKey);
|
||||
return await this.orderFulfillmentService.fulfillOrder(sfOrderId, payload, idempotencyKey);
|
||||
}
|
||||
```
|
||||
|
||||
@ -66,7 +66,7 @@ public class OrderProvisioningService {
|
||||
|
||||
// Make secure HTTP call
|
||||
HttpRequest req = new HttpRequest();
|
||||
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/provision');
|
||||
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/fulfill');
|
||||
req.setMethod('POST');
|
||||
req.setHeader('Content-Type', 'application/json');
|
||||
req.setHeader('X-SF-Signature', signature);
|
||||
@ -177,7 +177,7 @@ export class OrderStatusUpdateService {
|
||||
|
||||
```typescript
|
||||
// In your existing OrderOrchestrator service
|
||||
async provisionOrder(sfOrderId: string, payload: any, idempotencyKey: string) {
|
||||
async fulfillOrder(sfOrderId: string, payload: any, idempotencyKey: string) {
|
||||
try {
|
||||
// Update status to "Activating"
|
||||
await this.orderStatusUpdateService.updateOrderStatus(sfOrderId, 'Activating');
|
||||
@ -317,7 +317,7 @@ export class OrderProvisioningMonitoringService {
|
||||
describe('Order Provisioning Security', () => {
|
||||
it('should reject requests without valid HMAC signature', async () => {
|
||||
const response = await request(app)
|
||||
.post('/orders/test-order-id/provision')
|
||||
.post('/orders/test-order-id/fulfill')
|
||||
.send({ orderId: 'test-order-id' })
|
||||
.expect(401);
|
||||
});
|
||||
@ -328,7 +328,7 @@ describe('Order Provisioning Security', () => {
|
||||
const signature = generateHmacSignature(JSON.stringify(payload));
|
||||
|
||||
const response = await request(app)
|
||||
.post('/orders/test-order-id/provision')
|
||||
.post('/orders/test-order-id/fulfill')
|
||||
.set('X-SF-Signature', signature)
|
||||
.set('X-SF-Timestamp', oldTimestamp)
|
||||
.send(payload)
|
||||
|
||||
@ -11,7 +11,7 @@ Portal Customer → Places Order → Salesforce Order (Pending Review)
|
||||
↓
|
||||
Salesforce Operator → Reviews → Clicks "Provision in WHMCS" Quick Action
|
||||
↓
|
||||
Salesforce → Calls Portal BFF → `/orders/{sfOrderId}/provision`
|
||||
Salesforce → Calls Portal BFF → `/orders/{sfOrderId}/fulfill`
|
||||
↓
|
||||
Portal BFF → Provisions in WHMCS → Updates Salesforce Order Status
|
||||
↓
|
||||
@ -26,7 +26,7 @@ Based on your architecture, the **order provisioning flow** uses direct HTTPS ca
|
||||
|
||||
**Salesforce → Portal BFF Flow:**
|
||||
|
||||
1. **Salesforce Quick Action** calls `POST /orders/{sfOrderId}/provision`
|
||||
1. **Salesforce Quick Action** calls `POST /orders/{sfOrderId}/fulfill`
|
||||
2. **Portal BFF** processes the provisioning request
|
||||
3. **Optional: Portal → Salesforce** status updates via webhook
|
||||
|
||||
@ -67,7 +67,7 @@ public class PortalWebhookService {
|
||||
|
||||
// Make secure HTTP call
|
||||
HttpRequest req = new HttpRequest();
|
||||
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/provision');
|
||||
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/fulfill');
|
||||
req.setMethod('POST');
|
||||
req.setHeader('Content-Type', 'application/json');
|
||||
req.setHeader('X-SF-Signature', signature);
|
||||
@ -131,19 +131,19 @@ export class SalesforceStatusUpdateService {
|
||||
|
||||
### Enhanced Order Provisioning Endpoint
|
||||
|
||||
Your portal BFF should implement the `/orders/{sfOrderId}/provision` endpoint with these security measures:
|
||||
Your portal BFF should implement the `/orders/{sfOrderId}/fulfill` endpoint with these security measures:
|
||||
|
||||
```typescript
|
||||
// Enhanced order provisioning endpoint
|
||||
@Post('orders/:sfOrderId/provision')
|
||||
// Enhanced order fulfillment endpoint
|
||||
@Post('orders/:sfOrderId/fulfill')
|
||||
@UseGuards(EnhancedWebhookSignatureGuard)
|
||||
async provisionOrder(
|
||||
async fulfillOrder(
|
||||
@Param('sfOrderId') sfOrderId: string,
|
||||
@Body() payload: ProvisionOrderRequest,
|
||||
@Headers('idempotency-key') idempotencyKey: string
|
||||
) {
|
||||
// Your existing provisioning logic
|
||||
return await this.orderOrchestrator.provisionOrder(sfOrderId, payload, idempotencyKey);
|
||||
// Your existing fulfillment logic
|
||||
return await this.orderFulfillmentService.fulfillOrder(sfOrderId, payload, idempotencyKey);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
```
|
||||
1. Customer places order → Portal creates Salesforce Order (Pending Review)
|
||||
2. Salesforce operator → Clicks "Provision in WHMCS" Quick Action
|
||||
3. Salesforce → Calls Portal BFF → POST /orders/{sfOrderId}/provision
|
||||
3. Salesforce → Calls Portal BFF → POST /orders/{sfOrderId}/fulfill
|
||||
4. Portal BFF → Provisions in WHMCS → DIRECTLY updates Salesforce Order (via existing SF API)
|
||||
5. Customer → Polls Portal for status updates
|
||||
```
|
||||
@ -35,7 +35,8 @@ public class OrderProvisioningService {
|
||||
|
||||
// Call Portal BFF
|
||||
HttpRequest req = new HttpRequest();
|
||||
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/provision');
|
||||
// Use the single canonical path '/fulfill'
|
||||
req.setEndpoint('callout:Portal_BFF/orders/' + orderId + '/fulfill');
|
||||
req.setMethod('POST');
|
||||
req.setHeader('Content-Type', 'application/json');
|
||||
req.setHeader('X-SF-Signature', signature);
|
||||
@ -84,19 +85,28 @@ public class OrderProvisioningService {
|
||||
### Enhanced Security for Provisioning Endpoint
|
||||
|
||||
```typescript
|
||||
// apps/bff/src/orders/orders.controller.ts
|
||||
@Post(':sfOrderId/provision')
|
||||
// apps/bff/src/orders/controllers/order-fulfillment.controller.ts
|
||||
@Post(':sfOrderId/fulfill')
|
||||
@UseGuards(EnhancedWebhookSignatureGuard) // Your existing guard
|
||||
@ApiOperation({ summary: "Provision order from Salesforce" })
|
||||
@ApiOperation({ summary: "Fulfill order from Salesforce" })
|
||||
async provisionOrder(
|
||||
@Param('sfOrderId') sfOrderId: string,
|
||||
@Body() payload: { orderId: string; timestamp: string; nonce: string },
|
||||
@Headers('idempotency-key') idempotencyKey: string
|
||||
) {
|
||||
return await this.orderOrchestrator.provisionOrder(sfOrderId, payload, idempotencyKey);
|
||||
return await this.orderFulfillmentService.fulfillOrder(sfOrderId, payload, idempotencyKey);
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Production Env Notes (Plesk)
|
||||
|
||||
- Backend reads environment from the Plesk env file, not from repo `.env`:
|
||||
- `compose-plesk.yaml` → `env_file: /var/www/vhosts/.../env/portal-backend.env`
|
||||
- Mount secrets inside the container at `/app/secrets` and set:
|
||||
- `SF_PRIVATE_KEY_PATH=/app/secrets/sf-private.key`
|
||||
- `SF_LOGIN_URL`, `SF_CLIENT_ID`, `SF_USERNAME`, `SF_WEBHOOK_SECRET`
|
||||
- The backend validates the private key path to be under `./secrets` (dev) or `/app/secrets` (prod).
|
||||
|
||||
### Order Orchestrator (Direct Salesforce Updates)
|
||||
|
||||
```typescript
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user