diff --git a/app/ai-service/tasks.py b/app/ai-service/tasks.py index 53f21fb4..4d07b065 100644 --- a/app/ai-service/tasks.py +++ b/app/ai-service/tasks.py @@ -6,6 +6,9 @@ import logging import uuid import time +import hmac +import hashlib +import json from typing import Any, Dict, Optional from celery import Celery from celery.result import AsyncResult @@ -97,7 +100,15 @@ def update_task_status( 'error': error, 'updated_at': time.time() } - +def sign_payload(payload: dict, secret: str): + timestamp = str(int(time.time())) + payload_str = json.dumps(payload).encode('utf-8') + signature = hmac.new( + secret.encode('utf-8'), + timestamp.encode('utf-8') + b'.' + payload_str, + hashlib.sha256 + ).hexdigest() + return signature, timestamp def send_webhook_notification(task_id: str, status: str, result: Any = None, error: str = None) -> None: """ @@ -131,8 +142,33 @@ def send_webhook_notification(task_id: str, status: str, result: Any = None, err import threading def send_notification(): try: + # Prepare payload bytes for signing + payload_bytes = json.dumps(payload, separators=(',', ':')).encode('utf-8') + timestamp_str = str(int(time.time())) + + # Get the signing secret + secret = getattr(settings, 'webhook_signing_secret', '') + + # Compute signature: sha256 over raw body + # Format required: X-ChainForge-Signature: sha256= + signature = hmac.new( + secret.encode('utf-8') if secret else b'', + payload_bytes, + hashlib.sha256 + ).hexdigest() + + headers = { + 'Content-Type': 'application/json', + 'X-ChainForge-Signature': f"sha256={signature}", + 'X-ChainForge-Timestamp': timestamp_str + } + with httpx.Client(timeout=10.0) as client: - response = client.post(settings.backend_webhook_url, json=payload) + response = client.post( + settings.backend_webhook_url, + content=payload_bytes, + headers=headers + ) if response.status_code >= 400: logger.error(f"Webhook notification failed: {response.status_code} - {response.text}") else: @@ -166,8 +202,6 @@ def process_heavy_inference_impl(self, task_id: str, payload: Dict[str, Any]) -> # Extract task type from payload task_type = payload.get('type', 'inference') - start_inference = time.time() - # Simulate heavy processing (replace with actual AI inference logic) # In production, this would handle: # - Large image processing @@ -424,4 +458,4 @@ def create_task(task_type: str, payload: Dict[str, Any]) -> str: logger.info(f"Created task {task_id} of type {task_type}") - return task_id \ No newline at end of file + return task_id diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 3935b65e..b95d32b7 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -21,6 +21,7 @@ import { RequestCorrelationMiddleware } from './middleware/request-correlation.m import { SecurityModule } from './common/security/security.module'; import { CampaignsModule } from './campaigns/campaigns.module'; import { APP_GUARD } from '@nestjs/core'; +import { WebhookController } from './webhooks/webhook.controller'; import { ApiKeyGuard } from './common/guards/api-key.guard'; import { RolesGuard } from './auth/roles.guard'; import { ObservabilityModule } from './observability/observability.module'; @@ -132,7 +133,7 @@ import { SandboxModule } from './sandbox/sandbox.module'; ]), ], - controllers: [AppController], + controllers: [AppController, WebhookController], providers: [ AppService, { @@ -184,3 +185,4 @@ export class AppModule implements NestModule { ); } } + diff --git a/app/backend/src/main.ts b/app/backend/src/main.ts index 9fa25572..f1c4ee2e 100644 --- a/app/backend/src/main.ts +++ b/app/backend/src/main.ts @@ -32,7 +32,9 @@ async function bootstrap() { loadEnv({ path: envPath }); } - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { + rawBody: true, +}); // Get logger instance const logger = app.get(LoggerService); @@ -103,5 +105,5 @@ async function bootstrap() { logger.log(`📚 API Documentation: http://localhost:${port}/api/docs`); logger.log(`🔍 API Version: v1`); } - + void bootstrap(); diff --git a/app/backend/src/webhooks/webhook.controller.ts b/app/backend/src/webhooks/webhook.controller.ts new file mode 100644 index 00000000..c10ab095 --- /dev/null +++ b/app/backend/src/webhooks/webhook.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Post, Body, UseGuards, HttpCode, HttpStatus, Logger } from '@nestjs/common'; +import { WebhookGuard } from './webhook.guard'; + +@Controller('webhooks') +export class WebhookController { + private readonly logger = new Logger(WebhookController.name); + + @Post() + @UseGuards(WebhookGuard) + @HttpCode(HttpStatus.OK) + async handleWebhook(@Body() payload: any) { + this.logger.log('Received verified webhook payload successfully'); + + // Here we will handle the incoming task results + // Example: Update the claim status in the database based on the AI service task output + const { taskId, status, result } = payload; + + this.logger.log(`Task ${taskId} completed with status: ${status}`); + + // TODO: Integrate with your Claims/Campaigns service to update state + + return { received: true }; + } +} + diff --git a/app/backend/src/webhooks/webhook.guard.ts b/app/backend/src/webhooks/webhook.guard.ts new file mode 100644 index 00000000..a142db4c --- /dev/null +++ b/app/backend/src/webhooks/webhook.guard.ts @@ -0,0 +1,69 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import * as crypto from 'crypto'; + +@Injectable() +export class WebhookGuard implements CanActivate { + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + // 1. Extract headers + const signatureHeader = request.headers['x-chainforge-signature'] as string; + const timestampHeader = request.headers['x-chainforge-timestamp'] as string; + + if (!signatureHeader || !timestampHeader) { + throw new UnauthorizedException('Missing signature or timestamp headers'); + } + + // 2. Replay attack prevention: Ensure the timestamp is within a 5-minute window (300 seconds) + const currentTime = Math.floor(Date.now() / 1000); + const requestTime = parseInt(timestampHeader, 10); + + if (isNaN(requestTime) || Math.abs(currentTime - requestTime) > 300) { + throw new UnauthorizedException('Request timestamp expired or invalid'); + } + + // 3. Get signature secret + const secret = process.env.WEBHOOK_SIGNING_SECRET; + if (!secret) { + throw new UnauthorizedException('Webhook signing secret is not configured'); + } + + // 4. Retrieve raw body (NestJS raw body is required for verification) + const rawBody = request.rawBody; + if (!rawBody) { + throw new UnauthorizedException('Raw request body is missing for signature verification'); + } + + // Extract signature hash from format: sha256= + const expectedSignature = signatureHeader.startsWith('sha256=') + ? signatureHeader.slice(7) + : signatureHeader; + + // 5. Compute expected HMAC + const hmac = crypto.createHmac('sha256', secret); + hmac.update(timestampHeader + '.' + rawBody.toString()); + const computedSignature = hmac.digest('hex'); + + // 6. Prevent timing attacks using timingSafeEqual + try { + const expectedBuffer = Buffer.from(expectedSignature, 'hex'); + const computedBuffer = Buffer.from(computedSignature, 'hex'); + + if ( + expectedBuffer.length !== computedBuffer.length || + !crypto.timingSafeEqual(expectedBuffer, computedBuffer) + ) { + throw new UnauthorizedException('Invalid webhook signature'); + } + } catch (error) { + throw new UnauthorizedException('Invalid signature format'); + } + + return true; + } +} diff --git a/docs/webhooks/signing.md b/docs/webhooks/signing.md new file mode 100644 index 00000000..63fcce64 --- /dev/null +++ b/docs/webhooks/signing.md @@ -0,0 +1,6 @@ +# Webhook Signing + +All webhooks are signed using HMAC SHA256. +The secret key is stored in the environment variable `WEBHOOK_SIGNING_SECRET`. + +To verify the signature, calculate the HMAC SHA256 of the raw request body using your secret key. diff --git a/src/prisma/docs/observability/prisma-slow.json b/src/prisma/docs/observability/prisma-slow.json new file mode 100644 index 00000000..6b1328fd --- /dev/null +++ b/src/prisma/docs/observability/prisma-slow.json @@ -0,0 +1,100 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "title": "Prisma Query Duration Percentiles (p50 / p95 / p99)", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(prisma_query_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p50 - Median", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(prisma_query_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p95 - Slow Queries", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.99, sum(rate(prisma_query_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p99 - Slowest Queries", + "range": true, + "refId": "C" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth" + }, + "unit": "s", + "displayName": "${__series.name}" + } + } + } + ], + "schemaVersion": 38, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Prisma Query Performance", + "version": 1 +} diff --git a/src/prisma/prisma-metrics.ts b/src/prisma/prisma-metrics.ts new file mode 100644 index 00000000..f0daabf2 --- /dev/null +++ b/src/prisma/prisma-metrics.ts @@ -0,0 +1,29 @@ +import { Prisma } from '@prisma/client'; +import client from 'prom-client'; + +// Create the histogram metric for Prometheus +const prismaQueryDuration = new client.Histogram({ + name: 'prisma_query_duration_seconds', + help: 'Duration of Prisma queries in seconds', + labelNames: ['model', 'action'], + buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10], +}); + +export const prismaMetricsExtension = Prisma.defineExtension((prismaClient) => { + return prismaClient.$extends({ + query: { + $allOperations({ model, operation, args, query }) { + const startTime = process.hrtime(); + + return query(args).finally(() => { + const [seconds, nanoseconds] = process.hrtime(startTime); + const durationInSeconds = seconds + nanoseconds / 1e9; + + if (model) { + prismaQueryDuration.labels({ model, action: operation }).observe(durationInSeconds); + } + }); + }, + }, + }); +});