diff --git a/migrations/20260729_create_audit_log.sql b/migrations/20260729_create_audit_log.sql new file mode 100644 index 00000000..895de194 --- /dev/null +++ b/migrations/20260729_create_audit_log.sql @@ -0,0 +1,31 @@ +-- Migration: Create audit_log table with immutability trigger +CREATE TABLE IF NOT EXISTS audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(255), + action VARCHAR(255) NOT NULL, + resource VARCHAR(255) NOT NULL, + resource_id VARCHAR(255), + old_value JSONB, + new_value JSONB, + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource, resource_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at); + +-- Immutability trigger: prevent UPDATE or DELETE operations on audit_log table +CREATE OR REPLACE FUNCTION prevent_audit_log_modification() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'Audit logs are immutable and cannot be updated or deleted.'; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_prevent_audit_log_modification ON audit_log; +CREATE TRIGGER trg_prevent_audit_log_modification +BEFORE UPDATE OR DELETE ON audit_log +FOR EACH ROW +EXECUTE FUNCTION prevent_audit_log_modification(); diff --git a/src/config/env.ts b/src/config/env.ts index f7edbb24..dc261489 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -134,6 +134,18 @@ export const env = cleanEnv(process.env, { default: "http://localhost:3000/api/accounting/xero/callback", desc: "Xero OAuth 2.0 Redirect URI", }), + WHITELISTED_IP_CIDRS: str({ + default: "", + desc: "Comma-separated list of whitelisted IP CIDR blocks (supports IPv4 and IPv6)", + }), + RATE_LIMIT_BYPASS_ENABLED: bool({ + default: true, + desc: "Whether rate limiting bypass is enabled for whitelisted IP ranges", + }), + KEY_ROTATION_CRON: str({ + default: "0 4 * * 0", + desc: "Cron schedule for periodic PII encryption key rotation job", + }), }); // Re-export specific values for convenience @@ -162,4 +174,8 @@ export const { XERO_CLIENT_ID, XERO_CLIENT_SECRET, XERO_REDIRECT_URI, + WHITELISTED_IP_CIDRS, + RATE_LIMIT_BYPASS_ENABLED, + KEY_ROTATION_CRON, } = env; + diff --git a/src/jobs/keyRotationJob.ts b/src/jobs/keyRotationJob.ts new file mode 100644 index 00000000..376e6e90 --- /dev/null +++ b/src/jobs/keyRotationJob.ts @@ -0,0 +1,85 @@ +import { pool } from "../config/database"; +import { encryptField, decryptField } from "../utils/encryption"; +import logger from "../utils/logger"; + +const BATCH_SIZE = 50; +const BATCH_DELAY_MS = 100; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Non-blocking PII encryption key rotation background job. + * Processes encrypted PII fields in batches, re-encrypting with the active key version + * while keeping old keys readable via dual-key fallback support. + */ +export async function runKeyRotationJob(): Promise { + const activeVersion = process.env.ACTIVE_ENCRYPTION_KEY_VERSION || "v1"; + logger.info({ activeVersion }, "[KeyRotation] Starting PII key rotation job"); + + let totalProcessed = 0; + let totalReencrypted = 0; + + try { + // 1. Rotate Users table PII (e.g. phone_number, email) + let offset = 0; + while (true) { + const { rows } = await pool.query( + `SELECT id, phone_number, email FROM users ORDER BY id LIMIT $1 OFFSET $2`, + [BATCH_SIZE, offset], + ); + + if (rows.length === 0) break; + + for (const row of rows) { + totalProcessed++; + let updated = false; + + let newPhone = row.phone_number; + if (row.phone_number) { + const decrypted = decryptField(row.phone_number); + if (decrypted) { + const reencrypted = encryptField(decrypted); + if (reencrypted !== row.phone_number) { + newPhone = reencrypted; + updated = true; + } + } + } + + let newEmail = row.email; + if (row.email) { + const decrypted = decryptField(row.email); + if (decrypted) { + const reencrypted = encryptField(decrypted); + if (reencrypted !== row.email) { + newEmail = reencrypted; + updated = true; + } + } + } + + if (updated) { + await pool.query( + `UPDATE users SET phone_number = $1, email = $2 WHERE id = $3`, + [newPhone, newEmail, row.id], + ); + totalReencrypted++; + } + } + + offset += BATCH_SIZE; + await delay(BATCH_DELAY_MS); // Non-blocking yield to allow other DB operations + } + + logger.info( + { totalProcessed, totalReencrypted, activeVersion }, + "[KeyRotation] PII key rotation job completed successfully", + ); + } catch (error) { + logger.error( + { error, totalProcessed, totalReencrypted }, + "[KeyRotation] Error during PII key rotation job execution", + ); + throw error; + } +} diff --git a/src/jobs/scheduler.ts b/src/jobs/scheduler.ts index a6c63cbb..f5d84154 100644 --- a/src/jobs/scheduler.ts +++ b/src/jobs/scheduler.ts @@ -23,11 +23,14 @@ import { runDatabaseBackupVerifyJob } from "./databaseBackupVerifyJob"; import { INDEX_REINDEX_CRON, INDEX_REINDEX_JOB_ENABLED, + KEY_ROTATION_CRON, } from "../config/env"; import { runIndexReindexJob } from "./indexReindexJob"; import { runSanctionSyncJob } from "./sanctionSyncJob"; +import { runKeyRotationJob } from "./keyRotationJob"; import { startNotificationWorker } from "../workers/notificationWorker"; + interface JobConfig { name: string; schedule: string; @@ -150,8 +153,15 @@ const JOBS: JobConfig[] = [ schedule: process.env.DATABASE_BACKUP_VERIFY_CRON || "0 3 * * *", handler: runDatabaseBackupVerifyJob, }, + { + name: "key-rotation", + // Periodic key rotation for AES-256-GCM encrypted PII data at rest + schedule: KEY_ROTATION_CRON || "0 4 * * 0", + handler: runKeyRotationJob, + }, ]; + async function runJob(job: JobConfig): Promise { console.log(`[${job.name}] Starting job`); try { diff --git a/src/middleware/auditInterceptor.ts b/src/middleware/auditInterceptor.ts index 20cf701e..1b1f3860 100644 --- a/src/middleware/auditInterceptor.ts +++ b/src/middleware/auditInterceptor.ts @@ -1,60 +1,78 @@ -import { Request, Response, NextFunction } from 'express'; -import { Pool } from 'pg'; - -export const auditInterceptor = (db: Pool) => { - return (req: Request, res: Response, next: NextFunction) => { - // Only track mutation requests; ignore read-only methods - if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { - return next(); - } - - // Extract admin identification (adjust based on your JWT/session shape) - const adminId = req.jwtUser?.userId || req.user?.id || 'unknown_admin'; - const action = `${req.method} ${req.originalUrl}`; - - // Attempt to parse resource and identifier from the path - const pathParts = req.originalUrl.split('?')[0].split('/').filter(Boolean); - const resource = pathParts[1] || 'system'; - const resourceId = req.params.id || req.body.id || req.query.id || null; - - // Capture the inbound state - const payloadBefore = { ...req.body }; - - // Override res.json to capture the outbound state (the "after" diff) - const originalJson = res.json; - res.json = function (body) { - res.json = originalJson; // Restore original function to prevent memory leaks - - // Save log asynchronously to avoid blocking the HTTP response - setImmediate(async () => { - try { - const diff = { - request_payload: payloadBefore, - response_payload: body, - }; - - const query = ` - INSERT INTO audit_logs (admin_id, action, resource, resource_id, diff, ip_address, user_agent) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `; - - await db.query(query, [ - adminId, - action, - resource, - resourceId, - JSON.stringify(diff), - req.ip, - req.get('user-agent') || null - ]); - } catch (error) { - console.error('[Audit Log] Failed to save admin audit log event:', error); - } - }); - - return res.json(body); - }; - - next(); - }; -}; \ No newline at end of file +import { Request, Response, NextFunction } from 'express'; +import { Pool } from 'pg'; + +export const auditInterceptor = (db: Pool) => { + return (req: Request, res: Response, next: NextFunction) => { + // Only track mutation requests; ignore read-only methods + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { + return next(); + } + + // Extract admin identification + const adminId = (req as any).jwtUser?.userId || (req as any).user?.id || 'unknown_admin'; + const action = `${req.method} ${req.originalUrl}`; + + // Attempt to parse resource and identifier from the path + const pathParts = req.originalUrl.split('?')[0].split('/').filter(Boolean); + const resource = pathParts[1] || 'system'; + const resourceId = req.params?.id || req.body?.id || req.query?.id || null; + + // Capture the inbound state (before value) + const payloadBefore = req.body ? { ...req.body } : null; + + // Override res.json to capture the outbound state (the "after" value) + const originalJson = res.json; + res.json = function (body) { + res.json = originalJson; // Restore original function to prevent memory leaks + + // Save log asynchronously to avoid blocking the HTTP response + setImmediate(async () => { + try { + const diff = { + request_payload: payloadBefore, + response_payload: body, + }; + + // Insert into legacy audit_logs + await db.query( + `INSERT INTO audit_logs (admin_id, action, resource, resource_id, diff, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + adminId, + action, + resource, + resourceId, + JSON.stringify(diff), + req.ip, + req.get('user-agent') || null, + ], + ).catch(() => {}); + + // Insert into immutable audit_log table with before/after values for compliance + await db.query( + `INSERT INTO audit_log (user_id, action, resource, resource_id, old_value, new_value, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + adminId, + action, + resource, + resourceId, + payloadBefore ? JSON.stringify(payloadBefore) : null, + body ? JSON.stringify(body) : null, + req.ip, + req.get('user-agent') || null, + ], + ).catch((err) => { + console.error('[Audit Log] Failed to insert into audit_log:', err); + }); + } catch (error) { + console.error('[Audit Log] Failed to save admin audit log event:', error); + } + }); + + return res.json(body); + }; + + next(); + }; +}; \ No newline at end of file diff --git a/src/middleware/ipWhitelist.ts b/src/middleware/ipWhitelist.ts index 29210d0d..5a0aa552 100644 --- a/src/middleware/ipWhitelist.ts +++ b/src/middleware/ipWhitelist.ts @@ -2,6 +2,7 @@ import { NextFunction, Request, Response } from "express"; import ipaddr from "ipaddr.js"; import { geolocationService } from "../services/geolocation"; import { redisClient } from "../config/redis"; +import { WHITELISTED_IP_CIDRS } from "../config/env"; const ALLOWED_PROVIDER_CIDRS = [ "41.134.0.0/16", // MTN example block @@ -11,39 +12,72 @@ const ALLOWED_PROVIDER_CIDRS = [ // Geofencing: Allowed ISO 3166-1 alpha-2 country codes for providers const ALLOWED_PROVIDER_COUNTRIES = ["CM", "UG", "RW", "GH", "KE", "ZA", "NG"]; -const allowedNetworks = ALLOWED_PROVIDER_CIDRS.map((cidr) => - ipaddr.parseCIDR(cidr), -); - -const resolveClientIp = (req: Request): string | null => { +export const resolveClientIp = (req: Request): string | null => { const forwarded = req.headers["x-forwarded-for"]; if (typeof forwarded === "string" && forwarded.length > 0) { const first = forwarded.split(",")[0].trim(); if (first) return first; } - return req.ip || null; + return req.ip || req.socket?.remoteAddress || null; }; -const isIpAllowed = (rawIp: string): boolean => { +/** + * Checks if a given IP matches a single CIDR range or array of CIDR ranges. + * Supports IPv4 (e.g. 192.168.1.0/24) and IPv6 (e.g. 2001:db8::/32). + */ +export const isIpInCidrRange = (rawIp: string, cidrInput?: string | string[]): boolean => { + if (!rawIp) return false; + + let cidrsToMatch: string[] = []; + if (Array.isArray(cidrInput)) { + cidrsToMatch = cidrInput; + } else if (typeof cidrInput === "string" && cidrInput.trim().length > 0) { + cidrsToMatch = cidrInput.split(",").map((c) => c.trim()).filter(Boolean); + } else if (WHITELISTED_IP_CIDRS) { + cidrsToMatch = WHITELISTED_IP_CIDRS.split(",").map((c) => c.trim()).filter(Boolean); + } + + if (cidrsToMatch.length === 0) return false; + try { const parsed = ipaddr.process(rawIp); - return allowedNetworks.some(([network, prefix]) => { - if (parsed.kind() !== network.kind()) { + return cidrsToMatch.some((cidrStr) => { + try { + // If cidrStr is just a single IP address without mask, normalize it with default subnet prefix + const formattedCidr = cidrStr.includes("/") + ? cidrStr + : parsed.kind() === "ipv6" + ? `${cidrStr}/128` + : `${cidrStr}/32`; + + const [network, prefix] = ipaddr.parseCIDR(formattedCidr); + if (parsed.kind() !== network.kind()) { + return false; + } + + const matchable = parsed as unknown as { + match(candidate: unknown, bits: number): boolean; + }; + return matchable.match(network, prefix); + } catch { return false; } - - const matchable = parsed as unknown as { - match(candidate: unknown, bits: number): boolean; - }; - return matchable.match(network, prefix); }); } catch { return false; } }; +const isIpAllowed = (rawIp: string): boolean => { + if (isIpInCidrRange(rawIp, ALLOWED_PROVIDER_CIDRS)) { + return true; + } + return isIpInCidrRange(rawIp); +}; + + // Haversine formula to calculate distance between two coordinates in km function calculateDistanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371; // Earth's radius in km diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index 0f9f4186..7954f32b 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -1,5 +1,22 @@ import { Request, Response, NextFunction } from "express"; import { redisClient } from "../config/redis"; +import { rateLimitBypassTotal } from "../utils/metrics"; +import { isIpInCidrRange, resolveClientIp } from "./ipWhitelist"; +import { RATE_LIMIT_BYPASS_ENABLED } from "../config/env"; + +/** + * Checks if the request client IP is whitelisted and bypasses rate limiting. + * Emits Prometheus metric tracking bypassed requests. + */ +export function isRateLimitBypassed(req: Request, endpoint: string): boolean { + if (!RATE_LIMIT_BYPASS_ENABLED) return false; + const clientIp = resolveClientIp(req); + if (clientIp && isIpInCidrRange(clientIp)) { + rateLimitBypassTotal.inc({ ip: clientIp, endpoint }); + return true; + } + return false; +} /** * Rate Limit Configuration @@ -130,8 +147,13 @@ const generateRateLimitKey = (userId: string, endpoint: string): string => { * Limit: 10 requests per minute per user */ export const sep24RateLimiter = async (req: Request, res: Response, next: NextFunction) => { + if (isRateLimitBypassed(req, "SEP24")) { + return next(); + } + const userId = (req as any).user?.id; + if (!userId) { return res.status(401).json({ message: "Unauthorized" }); } @@ -269,6 +291,10 @@ export const cancelTransactionRateLimiter = async ( res: Response, next: NextFunction, ) => { + if (isRateLimitBypassed(req, "CANCELLATION")) { + return next(); + } + const userId = req.jwtUser?.userId; if (!userId) { @@ -310,6 +336,10 @@ export const cancelTransactionRateLimiter = async ( * Limit: 5 requests per minute per user */ export const sep31RateLimiter = async (req: Request, res: Response, next: NextFunction) => { + if (isRateLimitBypassed(req, "SEP31")) { + return next(); + } + const userId = (req as any).user?.id; if (!userId) { @@ -354,6 +384,10 @@ export const sep31RateLimiter = async (req: Request, res: Response, next: NextFu * Limit: 20 requests per hour per user */ export const sep12RateLimiter = async (req: Request, res: Response, next: NextFunction) => { + if (isRateLimitBypassed(req, "SEP12")) { + return next(); + } + const userId = (req as any).user?.id; if (!userId) { @@ -403,8 +437,13 @@ export const rateLimitExport = async ( res: Response, next: NextFunction, ) => { + if (isRateLimitBypassed(req, "EXPORT")) { + return next(); + } + const userId = (req as any).user?.id; + if (!userId) { return res.status(401).json({ message: "Unauthorized" }); } diff --git a/src/middleware/rateLimitRedis.ts b/src/middleware/rateLimitRedis.ts index 19e22b79..2c8468f8 100644 --- a/src/middleware/rateLimitRedis.ts +++ b/src/middleware/rateLimitRedis.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { RateLimiterRedis } from "rate-limiter-flexible"; import { redisClient } from "../config/redis"; +import { isRateLimitBypassed } from "./rateLimit"; // Define tiers const freeTier = { @@ -32,6 +33,10 @@ function getTier(req: Request) { } export async function rateLimitMiddleware(req: Request, res: Response, next: NextFunction) { + if (isRateLimitBypassed(req, "GENERAL")) { + return next(); + } + const ip = req.ip; const userId = req.jwtUser?.userId || req.user?.id; const tier = getTier(req); @@ -41,8 +46,8 @@ export async function rateLimitMiddleware(req: Request, res: Response, next: Nex try { await limiter.consume(key); next(); - } catch (rejRes) { - const retrySecs = Math.round(rejRes.msBeforeNext / 1000) || 1; + } catch (rejRes: any) { + const retrySecs = Math.round((rejRes?.msBeforeNext || 1000) / 1000) || 1; res.set("Retry-After", String(retrySecs)); res.status(429).json({ error: "Too Many Requests", @@ -50,3 +55,4 @@ export async function rateLimitMiddleware(req: Request, res: Response, next: Nex }); } } + diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 9b902285..f572c386 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -49,6 +49,7 @@ import { providerSettingsService } from "../services/providerSettingsService"; import { resetCircuitBreakerForProvider } from "../utils/circuitBreaker"; import { ERROR_CODES } from "../constants/errorCodes"; import { createError } from "../middleware/errorHandler"; +import { auditService } from "../services/auditlogService"; const router = Router(); const IMPERSONATION_TOKEN_EXPIRES_IN = "15m"; @@ -57,7 +58,42 @@ const READ_ONLY_IMPERSONATION_MESSAGE = "Read-only mode active"; router.use(auditInterceptor(pool)); +// GET /api/admin/audit-logs/export +router.get( + "/audit-logs/export", + requireAdmin, + rateLimitExport, + async (req: Request, res: Response) => { + try { + const format = req.query.format === "csv" ? "csv" : "json"; + const resource = typeof req.query.resource === "string" ? req.query.resource : undefined; + const userId = typeof req.query.userId === "string" ? req.query.userId : undefined; + + const content = await auditService.exportAuditLogs(format, { resource, userId }); + const filename = `audit-logs-${new Date().toISOString().slice(0, 10)}.${format}`; + + res.setHeader( + "Content-Type", + format === "json" ? "application/json" : "text/csv; charset=utf-8", + ); + res.setHeader( + "Content-Disposition", + `attachment; filename="${filename}"`, + ); + res.status(200).send(content); + } catch (err) { + console.error("Error exporting audit logs:", err); + throw createError( + ERROR_CODES.INTERNAL_ERROR, + "Failed to export audit logs", + { message: err instanceof Error ? err.message : "Unknown error" } + ); + } + } +); + // Multer configuration for CSV uploads + const csvUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit diff --git a/src/services/auditlogService.ts b/src/services/auditlogService.ts index 9c901fe9..78e698fe 100644 --- a/src/services/auditlogService.ts +++ b/src/services/auditlogService.ts @@ -58,40 +58,110 @@ export const auditService = { }, /** - * Log PII (Personally Identifiable Information) access for compliance - * @param data - PII access details including admin ID, target ID, and metadata + * Log administrative configuration change into audit_log table with before/after values. */ - logPIIAccess: async (data: { - adminId: string; - targetId: string; + logConfigChange: async (data: { + userId?: string; + action: string; resource: string; + resourceId?: string; + oldValue?: any; + newValue?: any; ipAddress?: string; userAgent?: string; - metadata?: any; }): Promise => { try { const query = ` - INSERT INTO pii_access_audit_logs (admin_id, target_id, resource, ip_address, user_agent, metadata) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO audit_log (user_id, action, resource, resource_id, old_value, new_value, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `; await pool.query(query, [ - data.adminId, - data.targetId, + data.userId || null, + data.action, data.resource, - data.ipAddress, - data.userAgent, - JSON.stringify(data.metadata || {}), + data.resourceId || null, + data.oldValue ? JSON.stringify(data.oldValue) : null, + data.newValue ? JSON.stringify(data.newValue) : null, + data.ipAddress || null, + data.userAgent || null, ]); - logger.info( - { - adminId: data.adminId, - resource: data.resource, - targetId: data.targetId - }, - 'PII access logged' - ); + logger.info({ userId: data.userId, resource: data.resource, action: data.action }, 'Config change logged'); } catch (error) { - logger.error({ error, adminId: data.adminId, resource: data.resource }, 'Failed to log PII access'); + logger.error({ error, userId: data.userId, resource: data.resource }, 'Failed to log config change'); + } + }, + + /** + * Fetch configuration audit logs with optional filters. + */ + fetchConfigAuditLogs: async (filters: { + resource?: string; + userId?: string; + limit?: number; + offset?: number; + }): Promise => { + try { + const conditions: string[] = []; + const values: any[] = []; + + if (filters.resource) { + values.push(filters.resource); + conditions.push(`resource = $${values.length}`); + } + if (filters.userId) { + values.push(filters.userId); + conditions.push(`user_id = $${values.length}`); + } + + const limit = filters.limit || 100; + const offset = filters.offset || 0; + + values.push(limit, offset); + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const query = ` + SELECT id, user_id as "userId", action, resource, resource_id as "resourceId", old_value as "oldValue", new_value as "newValue", ip_address as "ipAddress", user_agent as "userAgent", created_at as timestamp + FROM audit_log + ${whereClause} + ORDER BY created_at DESC + LIMIT $${values.length - 1} OFFSET $${values.length} + `; + const result = await pool.query(query, values); + return result.rows; + } catch (error) { + logger.error({ error }, 'Failed to fetch config audit logs'); + return []; + } + }, + + /** + * Export audit logs for regulatory compliance audits in CSV or JSON format. + */ + exportAuditLogs: async (format: "csv" | "json" = "json", filters?: { resource?: string; userId?: string }): Promise => { + try { + const logs = await auditService.fetchConfigAuditLogs({ ...filters, limit: 5000, offset: 0 }); + if (format === "json") { + return JSON.stringify(logs, null, 2); + } + + const headers = ["id", "userId", "action", "resource", "resourceId", "oldValue", "newValue", "ipAddress", "timestamp"]; + const rows = logs.map((log) => [ + log.id, + log.userId || "", + log.action, + log.resource, + log.resourceId || "", + JSON.stringify(log.oldValue || {}).replace(/"/g, '""'), + JSON.stringify(log.newValue || {}).replace(/"/g, '""'), + log.ipAddress || "", + log.timestamp, + ].map((val) => `"${val}"`).join(",")); + + return [headers.join(","), ...rows].join("\n"); + } catch (error) { + logger.error({ error }, 'Failed to export audit logs'); + throw new Error("Failed to export audit logs"); } }, }; + diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts index 5d5cf5f5..b3ce4acf 100644 --- a/src/utils/metrics.ts +++ b/src/utils/metrics.ts @@ -217,3 +217,12 @@ export const systemHeartbeat = new Gauge({ labelNames: ["service"], registers: [register], }); + +// Rate Limit Bypass Metric (Issue #230) +export const rateLimitBypassTotal = new Counter({ + name: "rate_limit_bypass_total", + help: "Total number of rate limit bypasses for whitelisted IPs", + labelNames: ["ip", "endpoint"], + registers: [register], +}); +