← back to Lifestyle Asset Intel
lib/audit.js
166 lines
// audit.js — fire-and-forget writer for valuation_calls.
//
// Express middleware that records every /api/* request after the
// response is sent (no added user-visible latency). Skips /api/health
// to keep monitoring traffic out of the audit table.
//
// Token redaction: if Authorization: Bearer <token> is presented, we
// store only the sha256 hex digest. Raw tokens never touch the DB.
const crypto = require('crypto');
const { randomUUID } = require('crypto');
const { pool } = require('./db');
const { METHODOLOGY_VERSION } = require('./valuation');
const SKIP_PATHS = new Set(['/health']); // relative to /api mount
function hashToken(authHeader) {
if (!authHeader || typeof authHeader !== 'string') return null;
const m = /^Bearer\s+(.+)$/i.exec(authHeader);
if (!m) return null;
return crypto.createHash('sha256').update(m[1]).digest('hex');
}
function extractAssetSlug(req) {
// Routes like '/api/valuation/:slug' set req.params.slug after matching.
if (req.params && req.params.slug) return String(req.params.slug);
// For sub-resources like /portfolio?owner=… we don't know the slug.
return null;
}
// recordCall — direct write, used by the middleware AND by tests that
// want to assert the row landed.
async function recordCall(row) {
return pool.query(
`INSERT INTO valuation_calls
(called_at, caller_ip, caller_token_hash, route, method,
asset_slug, response_status, methodology_version,
latency_ms, user_agent, request_id)
VALUES (now(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
row.caller_ip || null,
row.caller_token_hash || null,
row.route,
row.method,
row.asset_slug || null,
row.response_status,
row.methodology_version || METHODOLOGY_VERSION,
row.latency_ms == null ? null : row.latency_ms,
row.user_agent || null,
row.request_id || null
]
);
}
// auditMiddleware — Express middleware. Mount under the /api router.
// The route string used is `req.baseUrl + req.route?.path` so the table
// holds the route TEMPLATE, not the resolved URL with the slug
// substituted. That keeps the asset_slug query useful (filter by slug
// AND group by template).
function auditMiddleware(req, res, next) {
const start = process.hrtime.bigint();
const requestId = randomUUID();
res.setHeader('x-request-id', requestId);
res.on('finish', () => {
if (SKIP_PATHS.has(req.path)) return;
const route = req.baseUrl + (req.route ? req.route.path : req.path);
const elapsedNs = Number(process.hrtime.bigint() - start);
const latencyMs = Math.round(elapsedNs / 1e6);
setImmediate(() => {
recordCall({
caller_ip: req.ip,
caller_token_hash: hashToken(req.get('authorization')),
route,
method: req.method,
asset_slug: extractAssetSlug(req),
response_status: res.statusCode,
methodology_version: METHODOLOGY_VERSION,
latency_ms: latencyMs,
user_agent: req.get('user-agent') || null,
request_id: requestId
}).catch((e) => {
// Swallow audit-write errors — never let logging break a request.
console.error('[audit] write failed:', e.message);
});
});
});
next();
}
async function recentCalls({
limit = 100, asset_slug = null, route = null, method = null,
status = null, since = null, until = null
} = {}) {
const lim = Math.max(1, Math.min(1000, parseInt(limit, 10) || 100));
const where = [];
const params = [];
if (asset_slug) {
params.push(asset_slug);
where.push(`asset_slug = $${params.length}`);
}
if (route && typeof route === 'string' && route.trim()) {
params.push(`%${route.trim().toLowerCase()}%`);
where.push(`LOWER(route) LIKE $${params.length}`);
}
if (method && typeof method === 'string' && method.trim()) {
params.push(method.trim().toUpperCase());
where.push(`UPPER(method) = $${params.length}`);
}
// status: accept either an integer code (200, 404, …) or a 1-digit
// class prefix ("2", "4", "5"). Out-of-range / non-numeric input is
// silently ignored (mirrors how empty-string route/method are treated).
if (status != null && status !== '') {
const s = String(status).trim();
if (/^[1-5]$/.test(s)) {
params.push(parseInt(s, 10) * 100);
const lo = `$${params.length}`;
params.push(parseInt(s, 10) * 100 + 99);
const hi = `$${params.length}`;
where.push(`response_status BETWEEN ${lo} AND ${hi}`);
} else if (/^[1-5][0-9]{2}$/.test(s)) {
params.push(parseInt(s, 10));
where.push(`response_status = $${params.length}`);
}
}
// since: ISO-8601 timestamp lower bound (called_at >= since). Anything
// that doesn't parse to a valid Date is silently ignored.
if (since != null && since !== '') {
const d = new Date(String(since).trim());
if (!Number.isNaN(d.getTime())) {
params.push(d.toISOString());
where.push(`called_at >= $${params.length}`);
}
}
// until: ISO-8601 timestamp upper bound (called_at <= until). Mirrors
// since; composes with it to bracket a window. Unparseable input ignored.
if (until != null && until !== '') {
const d = new Date(String(until).trim());
if (!Number.isNaN(d.getTime())) {
params.push(d.toISOString());
where.push(`called_at <= $${params.length}`);
}
}
params.push(lim);
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const r = await pool.query(
`SELECT id, called_at, caller_ip::text AS caller_ip,
route, method, asset_slug, response_status,
methodology_version, latency_ms, user_agent, request_id
FROM valuation_calls
${whereSql}
ORDER BY called_at DESC
LIMIT $${params.length}`,
params
);
return r.rows;
}
module.exports = {
hashToken,
recordCall,
auditMiddleware,
recentCalls
};