← 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
};