Audit log and SOC 2 readiness

Every state-mutating action in VerifyAI — rotating an API key, inviting a teammate, editing a policy, resolving an exception, firing an integration — writes a row to verify_ai_audit_log. The table is append-only at the application level: the only INSERT path is the service role via lib/audit/log.ts, and no UPDATE / DELETE policies exist. Rows are read-scoped to the customer they belong to via RLS.

This is one of the pillars of our SOC 2 Type II audit, currently in progress. We do not claim certification yet — see /security for the live status of our report.

1. Schema

Defined in migration 20260521_verify_ai_audit_log.sql:

sql
CREATE TABLE verify_ai_audit_log (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id  UUID REFERENCES billing_customers(id) ON DELETE CASCADE,
  actor_id     UUID,
  actor_email  TEXT,
  action       TEXT NOT NULL,
  resource_type TEXT,
  resource_id  TEXT,
  metadata     JSONB NOT NULL DEFAULT '{}'::jsonb,
  ip           INET,
  user_agent   TEXT,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Indexed by (customer_id, created_at DESC), action, and (actor_id, created_at DESC). RLS is enabled with a single SELECT-only policy that scopes rows to customers the caller is a member of — there is no INSERT / UPDATE / DELETE policy, so only the service role can write. Rows are never deleted by the application. A v2 cron-based archival job may move very old rows to cold storage.

2. Writing an event

All writes go through logAuditEvent() (see lib/audit/log.ts):

ts
import { logAuditEvent } from "@/lib/audit/log";
 
await logAuditEvent({
  customerId: customer.id,
  actorId: user.id,
  actorEmail: user.email,
  action: "api_key.rotate",
  resourceType: "api_key",
  resourceId: keyId,
  metadata: { previous_prefix: prevPrefix.slice(0, 8) },
  request, // optional — fills in ip + user_agent automatically
});

Two contracts to remember:

  1. It never throws. Failures are sent to Sentry and console.error, never bubbled up. You can await the call and treat the result as informational only — never gate user-visible behavior on whether the audit log wrote.
  2. No secrets in metadata. The shape is JSONB and free-form, so it's tempting to dump everything. Don't — redact tokens, image bytes, and PII.

3. Where it's already wired

You don't need to call this yourself for any of the built-in flows. Every state-changing path already does:

| Action prefix | Wired in | | ---------------------- | ----------------------------------------------------------------------------- | | api_key.* | api_key.regenerate, api_key.revoke in app/actions/dashboard.ts | | webhook.* | webhook.create, webhook.delete in app/actions/dashboard.ts | | policy.* | policy.create, policy.update, policy.delete in app/actions/dashboard.ts| | site.* | site.create, site.update, site.archive in app/actions/operations.ts | | site_assignment.* | site_assignment.create, site_assignment.delete in app/actions/operations.ts | | exception.* | exception.assign, exception.resolve, exception.dismiss | | bi_export.* | bi_export.create in requestBIExport() | | inspection_session.* | Create / finalize / resend / PDF download / shot submit in inspection-session routes | | verification.* | verification.pdf_download in the PDF download route | | vehicle_id.* | vehicle_id.scan in the vehicle-ID route |

Member invites / removes, integration CRUD, and bulk-replay events are not yet wired — they'll land alongside their respective server actions. When you add a new state-mutating action, add a matching audit event. It's a one-line call.

4. Searching the log (UI)

Dashboard → Operations → Audit log is the human-facing viewer. You can filter by:

  • Action contains — substring match, e.g. site. or policy.update.
  • Actor email — case-insensitive substring match.
  • Resource type — exact match (e.g. verify_ai_site).

searchAuditLog() in app/actions/operations.ts also accepts since / until ISO timestamps for date-range queries from code, and paginates via limit (max 200) and offset. The viewer surface today is search-first; CSV export is handled via the BI export pipeline — see Exporting verification data for BI tools. The audit_log export scope writes a bi_export.create audit row of its own.

5. Querying the log (SQL)

For investigators, raw SQL is fine. The RLS policy ensures customer members only see their own rows; the service role sees everything.

sql
-- Every action a single user took in the last 30 days
select created_at, action, resource_type, resource_id, metadata
from verify_ai_audit_log
where customer_id = '...'
  and actor_email = 'eric@example.com'
  and created_at > now() - interval '30 days'
order by created_at desc;
 
-- Rate of policy changes per week
select date_trunc('week', created_at) as week, count(*)
from verify_ai_audit_log
where action like 'policy.%'
group by 1
order by 1 desc;

6. SOC 2 status (honest)

Our SOC 2 Type II audit is in progress. That means:

  • Controls are designed and operating.
  • Every wired state-mutating action writes to the audit log (see the table in section 3 for current coverage).
  • A third-party auditor is collecting evidence over the observation window.
  • We do not have a Type II report yet — do not say "SOC 2 certified" in marketing or sales copy.

Track the status at /security. Type I and Type II reports will be posted there as they're finalized.

What's next

Get in Touch

Questions about pricing, integrations, or custom deployments? We'd love to hear from you.