# Audit trail events for SaaS ops: append-only who-did-what you can query

> Own an append-only audit event store — actor, action, resource, correlation — so support and agents answer what changed after sandbox logs.
> By Dave · 2026-09-20
> Source: https://otf-kit.dev/blog/audit-trail-events-saas-ops

When support asks “who changed this plan?”, noisy app logs rarely answer. They show request latency, stack traces, and correlation IDs — useful for triage, weak for accountability. After you leave a Lovable or Bolt sandbox and run SaaS ops on an app you own, you need an **append-only audit trail**: immutable events that record actor, action, resource, outcome, and correlation so humans and agents can reconstruct what changed without guessing.

This post is about a queryable **audit event store**, not a logging tutorial. Pair it with [structured production logs](/blog/production-structured-logging-for-agents) for runtime triage; keep the two stores separate. It is also not Cursor hooks (`cursor-agent-hooks`), webhook idempotency, or API client timeouts ([outbound AI HTTP timeouts](/blog/api-timeouts-retries-ai-backends)). The claim is narrow: write business-significant who-did-what events once, forbid mutation, and query them when ops, security, or an agent needs a factual timeline.

## Why sandbox builders skip a real audit store

Sandbox hosts often give you dashboards, request logs, and a database you did not design. “Activity” in the UI is usually a filtered view of those logs — fine while the product lives on their platform. Export the repo and you inherit tables, auth, and billing logic without an intentional event trail. The first disputed refund, privilege grant, or settings wipe exposes the gap: logs rotate, formats drift, and nobody agreed which fields are evidence.

OWASP’s logging guidance treats audit trails as a distinct purpose from security or debug logging — chronological records of addition, modification, deletion, and export that support reconstruction of attributable transactions ([Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html)). OWASP Top 10:2025 A09 goes further: high-value transactions need an audit trail with integrity controls against tampering or deletion, such as append-only tables ([A09 Security Logging and Alerting Failures](https://owasp.org/Top10/2025/A09_2025-Security_Logging_and_Alerting_Failures/)). Own that store early; do not hope log retention doubles as compliance evidence.

![Noisy app logs vs append-only audit events](https://cdn.otf-kit.dev/blog/audit-trail-events-saas-ops/inbody1-20260920b.png)

## Logs answer “what broke?” — audit events answer “what changed?”

| Concern | Structured logs | Audit events |
| --- | --- | --- |
| Primary question | Why did this request fail? | Who did what to which resource? |
| Mutability | Append-mostly files/streams; retention windows | Insert-only store; updates/deletes forbidden |
| Shape | Level, message, fields for triage | Actor + action + resource + outcome + correlation |
| Consumers | On-call, agents debugging latency/errors | Support, security, compliance, agents reconstructing history |
| Volume | High; sampled or rotated | Lower; every significant business action |

Logs stay the place for timeouts, retries, and stack context. Audit events stay the place for “admin `user_42` set `plan` on `org_9` to `pro` with `correlation_id=…`.” Mixing them produces either unreadable log floods or an “audit table” that still accepts `UPDATE` and `DELETE`.

## Shape every event as actor, action, resource, correlation

OWASP’s event attributes boil down to when, where, who, and what — plus action, object, result, and an interaction identifier that links related steps ([Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html)). Map that to a small schema your product owns:

- **actor** — authenticated user id, service principal, or `system:` job name (never a raw session secret).
- **action** — stable verb namespaced by domain (`billing.plan.updated`, `member.role.granted`).
- **resource** — type + id (`org`, `subscription`, `api_key`).
- **outcome** — `success` / `denied` / `failed` with a short reason code.
- **correlation_id** — same id your request logs already carry so agents can jump from a log line to the audit row.
- **occurred_at** / **recorded_at** — when the business action happened vs when the row was inserted (imports need both).

Vendor event APIs illustrate the same idea at product scale. Stripe creates an `Event` when API resource state changes, includes the affected object, and exposes `previous_attributes` when fields change; you can list or retrieve events for a limited window ([Stripe Events](https://docs.stripe.com/api/events)). Your owned SaaS ops trail is the same pattern for *your* resources — not a substitute for Stripe’s billing events, and not a dump of every HTTP access log line.

```ts
// audit-events.ts — append-only write path (path-only; store via env)
type AuditEvent = {
  actorId: string;
  action: string;
  resourceType: string;
  resourceId: string;
  outcome: "success" | "denied" | "failed";
  reasonCode?: string;
  correlationId: string;
  metadata?: Record<string, string | number | boolean | null>;
  occurredAt: string; // ISO-8601
};

const AUDIT_STORE_URL = process.env.AUDIT_STORE_URL!; // inject origin; never hardcode hosts

export async function appendAuditEvent(event: AuditEvent): Promise<void> {
  const res = await fetch(`${AUDIT_STORE_URL}/v1/audit-events`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${process.env.AUDIT_STORE_TOKEN}`,
    },
    body: JSON.stringify(event),
  });
  if (!res.ok) {
    throw new Error(`audit_append_failed status=${res.status}`);
  }
}
```

Prefer path-only routes (`/v1/audit-events`) and `$AUDIT_STORE_URL` / `$DATABASE_URL` in application code. Exclude passwords, tokens, full card data, and other secrets — OWASP lists those as data that should be masked or never logged ([Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html)).

## Enforce append-only below the service method

Application code that “promises” not to update history is not enough. Grant the runtime role `INSERT` (and `SELECT` if the product reads its own timeline); revoke `UPDATE`, `DELETE`, and `TRUNCATE` on the audit table. Add a database trigger that rejects mutation even if privileges drift.

PostgreSQL’s community audit-trigger pattern records old/new row data, the acting database user, and a timestamp via an `AFTER INSERT OR UPDATE OR DELETE` trigger into a dedicated audit schema, and recommends locking down grants on that table ([Audit trigger](https://wiki.postgresql.org/wiki/Audit_trigger)). Use that pattern for table-level change capture *or* write business events from the application — but keep the immutability rules either way.

```sql
-- enforce immutability on the audit event table
CREATE OR REPLACE FUNCTION reject_audit_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
  RAISE EXCEPTION 'audit events are append-only';
END;
$$;

CREATE TRIGGER audit_events_reject_mutation
BEFORE UPDATE OR DELETE ON audit_events
FOR EACH ROW
EXECUTE FUNCTION reject_audit_mutation();

-- runtime role: insert (+ optional select), never rewrite
REVOKE UPDATE, DELETE, TRUNCATE ON audit_events FROM app_runtime;
GRANT INSERT, SELECT ON audit_events TO app_runtime;
```

Corrections are new events that reference the prior event id (`correction_of`), not silent edits. Same rule for late imports: keep both timestamps so Monday’s action imported on Wednesday still reads as two facts.

![Actor action resource write to queryable audit store](https://cdn.otf-kit.dev/blog/audit-trail-events-saas-ops/inbody2-20260920b.png)

## What to emit on day one of owned SaaS ops

Start with actions that change money, access, or customer-visible state. Skip chatty UI telemetry.

1. Authentication outcomes that matter for abuse — success and failure with actor and source context (OWASP flags inconsistent login logging as an A09 failure mode).
2. Authorization denials on admin routes and role changes (`member.role.granted`, `member.role.revoked`).
3. Billing and plan mutations, seats, and refunds — with resource ids, not full payment payloads.
4. API key create/revoke, webhook endpoint create/delete, and secrets rotation milestones (ids only).
5. Destructive data ops — export requests, bulk deletes, GDPR erase jobs — as first-class actions with correlation to the job id.
6. Config that changes security posture — SSO toggle, MFA requirement, IP allowlist edits.

Commit the audit write in the same transaction as the state change when both live in one database. If the store is remote, treat append failure as a failed mutation for high-value paths (fail closed), or buffer with a durable outbox — never “best effort” silence on privilege grants.

## Query patterns support and agents actually use

Index for the questions you will ask:

```sql
CREATE INDEX audit_events_resource_time
  ON audit_events (resource_type, resource_id, occurred_at DESC);
CREATE INDEX audit_events_actor_time
  ON audit_events (actor_id, occurred_at DESC);
CREATE INDEX audit_events_correlation
  ON audit_events (correlation_id);
CREATE INDEX audit_events_action_time
  ON audit_events (action, occurred_at DESC);
```

Support pulls “everything on `org_9` last 7 days.” Security pulls “all `*.role.*` and `api_key.*` for actor `user_42`.” Agents that already triage with correlation IDs in [structured logs](/blog/production-structured-logging-for-agents) should resolve the matching audit rows before proposing a fix. Document the event vocabulary on a citation-ready page so answer engines and teammates cite the same verbs ([citation-ready product docs](/blog/ai-citation-ready-product-docs)). Fold “audit store live + immutability verified” into your [launch checklist](/blog/launch-checklist-ai-built-app) before strangers hit admin tools.

When you want production-shaped kits instead of rebuilding ownership plumbing from a blank sandbox export, browse [OTF templates](https://otf-kit.dev/templates).

Append-only audit events turn “what changed?” into a query — actor, action, resource, outcome, correlation — while logs keep answering “what broke?” Own both stores after sandbox, enforce immutability at the database, and let support, security, and agents read the same trail.

## Sources

- [OWASP Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) — audit trails vs other log purposes; when/where/who/what; data to exclude
- [OWASP Top 10:2025 A09 Security Logging and Alerting Failures](https://owasp.org/Top10/2025/A09_2025-Security_Logging_and_Alerting_Failures/) — append-only (or similar) integrity controls for transaction audit trails
- [Stripe API: Events](https://docs.stripe.com/api/events) — snapshot events for resource state changes; retrieve/list window
- [PostgreSQL wiki: Audit trigger](https://wiki.postgresql.org/wiki/Audit_trigger) — AFTER trigger pattern recording old/new values, actor, timestamp; grant lockdown notes
