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:
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):
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:
- It never throws. Failures are sent to Sentry and
console.error, never bubbled up. You canawaitthe call and treat the result as informational only — never gate user-visible behavior on whether the audit log wrote. - No secrets in
metadata. The shape isJSONBand 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.orpolicy.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.
-- 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
- Triaging low-confidence verifications — every transition writes to the audit log.
- Organizing teams with sites and roles — member-site changes are audited too.
- Sending verifications to claims platforms — integration deliveries are recorded separately, but config changes audit normally.