← back to PoppyPetitions

lib/audit.ts

122 lines

// 2026-05-31 (P1-deferred): first-class audit logging for PoppyPetitions.
//
// Companion to migrations/0001_audit_events.sql. P1-E (commit b444995) recorded
// the session ↔ agent binding inside poppy.compute_usage.metadata, but that row
// only exists when a Gemini call bills compute. This module writes to the
// dedicated poppy.audit_events table so audit-worthy actions that DON'T bill
// compute — a denied agent lookup, a rejected oversized write, a login — still
// leave a forensic trail.
//
// Design rules:
//   - BEST-EFFORT, NEVER THROWS. An audit-write failure must never turn a
//     successful request into a 500. Every path is wrapped; failures are
//     console.error'd and swallowed. Callers do NOT await for correctness —
//     they may `void logAudit(...)` fire-and-forget, or await it when they
//     want ordering, but either way it cannot reject.
//   - Additive: depends only on the existing query() helper and the new table.
//   - Request provenance (ip / user-agent) is extracted defensively from the
//     NextRequest headers — both are spoofable and only useful as correlation
//     hints, never as a trust signal.

import type { NextRequest } from 'next/server';
import { query } from '@/lib/db';

/** Outcome of the audited action. */
export type AuditStatus = 'ok' | 'error' | 'denied';

export interface AuditEvent {
  /** Dotted action verb, e.g. 'petition.create', 'vote.cast', 'agent.resolve.denied'. */
  action: string;
  /** Authenticated admin session (verifyAuth() return value), if any. */
  actorSession?: string | null;
  /** Agent id the request claimed to act as, if any. */
  agentId?: string | null;
  /** What kind of thing was acted on, e.g. 'petition', 'comment', 'agent'. */
  resourceType?: string | null;
  /** Id of the acted-on resource, if known. */
  resourceId?: string | null;
  /** Outcome — defaults to 'ok'. */
  status?: AuditStatus;
  /** HTTP status code mirrored onto the row, if applicable. */
  statusCode?: number | null;
  /** Best-effort request provenance. */
  ip?: string | null;
  userAgent?: string | null;
  /** Forward-compatible extra fields (token counts, error message, etc.). */
  metadata?: Record<string, unknown>;
}

/**
 * Pull best-effort client provenance out of a NextRequest. Both fields are
 * spoofable; treat them as correlation hints only. x-forwarded-for may be a
 * comma-separated list (proxy chain) — we keep the first hop.
 */
export function requestProvenance(
  request: NextRequest,
): { ip: string | null; userAgent: string | null } {
  try {
    const xff = request.headers.get('x-forwarded-for');
    const ip = xff
      ? xff.split(',')[0]!.trim()
      : request.headers.get('x-real-ip');
    const userAgent = request.headers.get('user-agent');
    return { ip: ip || null, userAgent: userAgent || null };
  } catch {
    return { ip: null, userAgent: null };
  }
}

/**
 * Append a row to poppy.audit_events. Best-effort: returns the new row id on
 * success, or null on any failure (never throws). Safe to fire-and-forget:
 *
 *   void logAudit({ action: 'petition.create', actorSession: user,
 *                   agentId: author_id, resourceType: 'petition',
 *                   resourceId: petition.id, status: 'ok', statusCode: 201 });
 */
export async function logAudit(event: AuditEvent): Promise<string | null> {
  try {
    const r = await query<{ id: string }>(
      `INSERT INTO poppy.audit_events
         (actor_session, agent_id, action, resource_type, resource_id,
          status, status_code, ip, user_agent, metadata)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
       RETURNING id`,
      [
        event.actorSession ?? null,
        event.agentId ?? null,
        event.action,
        event.resourceType ?? null,
        event.resourceId ?? null,
        event.status ?? 'ok',
        event.statusCode ?? null,
        event.ip ?? null,
        event.userAgent ?? null,
        JSON.stringify(event.metadata ?? {}),
      ],
    );
    return r.rows[0]?.id ?? null;
  } catch (e) {
    // Swallow — an audit failure must never break the request it describes.
    console.error('[audit] logAudit failed:', (e as Error).message, 'action:', event.action);
    return null;
  }
}

/**
 * Convenience wrapper combining requestProvenance + logAudit so route code can
 * audit in one line with the request in hand:
 *
 *   await auditFromRequest(request, {
 *     action: 'vote.cast', actorSession: user, agentId,
 *     resourceType: 'petition', resourceId: id, statusCode: 200,
 *   });
 */
export async function auditFromRequest(
  request: NextRequest,
  event: Omit<AuditEvent, 'ip' | 'userAgent'>,
): Promise<string | null> {
  const { ip, userAgent } = requestProvenance(request);
  return logAudit({ ...event, ip, userAgent });
}