Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions migrations/20260729_create_audit_log.sql
Original file line number Diff line number Diff line change
@@ -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();
16 changes: 16 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

85 changes: 85 additions & 0 deletions src/jobs/keyRotationJob.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
}
}
10 changes: 10 additions & 0 deletions src/jobs/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
console.log(`[${job.name}] Starting job`);
try {
Expand Down
138 changes: 78 additions & 60 deletions src/middleware/auditInterceptor.ts
Original file line number Diff line number Diff line change
@@ -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();
};
};
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();
};
};
Loading
Loading