MODULES / SECURITY

Audit Log

Free · MITv0.1.1

Append-only, tamper-resistant record of who did what and when: every module event is captured automatically, and your own code can record entries with the request's IP and user agent.

terminalnpx ajolla add audit-log

What you get

  • Records every Ajolla event automatically, exactly once
  • Append-only table: updates and deletes are blocked in the database
  • Record entries from your own code with IP and user agent captured
  • Searchable admin screen with filters and fast keyset pagination
  • CSV export of up to 50,000 rows, protected against spreadsheet formula injection
  • Org-scoped access: auditors only see their organization's entries
  • Configurable retention with a cron-driven purge
  • Secrets are stripped from event payloads before they are stored

Routes it adds

/admin/audit

/api/ajolla/audit-log/export/api/ajolla/audit-log/purge

Plus 1 Supabase migration with row-level security, and an AGENTS.md your coding agent reads after install.

Installs with

Kernel (added automatically).

Configuration

AUDIT_RETENTION_DAYS
Delete entries older than this many days when the purge cron runs (default 365; 0 keeps entries forever).
AUDIT_IGNORE_EVENTS
Comma-separated event types not to record, e.g. "analytics.tracked,search.*".

Use it from any stack

Ajolla Hosted runs the same code as a multi-tenant API. Create an app in your dashboard, enable Audit Log, and call /api/m/audit-log/v1 with your app key.

OpenAPI document — feed it to Ajolla to generate SDKs in 7 languages and an MCP server.

AGENTS.md

Read the instructions installed with this module
markdown# Ajolla Audit Log

An append-only record of who did what, and when. Every event any Ajolla module emits is recorded automatically; your own server code can record entries too.

## What was installed

- **Admin page** `/admin/audit` (permission `audit.read`): filters (action prefix, actor, actor id, target, organization, source, date range), newest-first table with expandable details and metadata, keyset pagination, "Export CSV".
  - Organization auditors open it with `?orgId=<uuid>`; the permission is checked in that organization.
- **Route** `GET /api/ajolla/audit-log/export` — the same filters as a CSV download (max 50,000 rows, `audit.read` required). Cells that would run as spreadsheet formulas are prefixed with `'`.
- **Route** `GET /api/ajolla/audit-log/purge` — retention cron, protected by `CRON_SECRET`.
- **Table** `ajolla_audit_log_entries` — `id, occurred_at, actor_id, actor_label, action, target_type, target_id, org_id, ip, user_agent, metadata, source ('event' | 'api' | 'app'), event_id`.
  - Append-only: a trigger blocks `update`, `delete` and `truncate` for every role, including the service role. Only `ajolla_audit_log_purge(before)` (service role only) may delete.
  - RLS: reading requires `ajolla_has_permission('audit.read', org_id)`. Signed-in users may insert entries only as themselves (`actor_id = auth.uid()`, `source = 'app'`, no `event_id`, not backdated). Anonymous visitors get nothing.
  - `actor_id` has no foreign key on purpose: the trail outlives deleted accounts, and `actor_label` keeps a readable snapshot.
- **Subscriber** `lib/ajolla/subscribers/audit-log.ts` (`"*"`) — records every event (action = event type) with credential-like payload keys (`/password|secret|token|key/i`) removed. Idempotent through the `event_id` unique key.
- **Code**
  - `lib/ajolla/audit-log/core.ts` — `recordAudit`, `recordAuditBatch`, `listAuditEntries`, `getAuditEntry`, `iterateAuditEntries`, `toCsv`, `sanitizeMetadata`, `purgeOlderThan`.
  - `lib/ajolla/audit-log/record.ts` — `recordAuditFromRequest(entry)` for server actions and route handlers.
  - `lib/ajolla/admin/audit-log.ts` — admin page registration.
- **Events**: emits none; consumes every event (`"*"`).
- **Env**
  - `AUDIT_RETENTION_DAYS` — default 365; `0` keeps entries forever.
  - `AUDIT_IGNORE_EVENTS` — comma-separated event types not to record; `module.*` skips a whole module (e.g. `analytics.tracked,search.*`).

## Setup

1. Apply migrations: `supabase db push`.
2. Give someone `audit.read`. Until Roles & Permissions is installed, admins (`app_metadata.role = "admin"`) hold it.
3. Schedule the retention purge daily. In `vercel.json`, next to the kernel's events cron:
   ```json
   { "crons": [{ "path": "/api/ajolla/audit-log/purge", "schedule": "30 3 * * *" }] }
   ```
   Vercel sends `Authorization: Bearer $CRON_SECRET`. Any other scheduler must send the same header.

## Recording your own entries

```ts
"use server";
import { recordAuditFromRequest } from "@/lib/ajolla/audit-log/record";

await recordAuditFromRequest({
  action: "invoices.sent",          // "<module>.<verb>", lowercase
  targetType: "invoice",
  targetId: invoice.id,
  orgId: invoice.orgId,             // optional: makes the entry visible to that org's auditors
  metadata: { amount: invoice.total }, // JSON object, at most 16 KB
});
```

It records the signed-in user as the actor (their email as the label), the user agent, and the client IP from the first `X-Forwarded-For` hop. Only trust that header behind a proxy that sets it (Vercel, Netlify, Cloudflare); otherwise clients can spoof it.

For system jobs without a signed-in user, call `recordAudit({ db: createAjollaAdminClient(), appId: null }, { action, source: "app", … })` from server code.

## Customising

- Columns and filters: `app/admin/audit/page.tsx` and `components/ajolla/audit-log/*`.
- What counts as sensitive in event payloads: `SENSITIVE_KEY` in `core.ts`.
- Export cap: `MAX_ROWS` in `app/api/ajolla/audit-log/export/route.ts`.
- The purge refuses cutoffs less than a day old, so a misconfiguration can't wipe the log.