Server setup
Keep the audit log in Postgres, record events, and let the viewer read them with Next.js server actions.
@runqora/audit-actions adds two files:
lib/audit/server.tsconnects the log to your database and hasrecordAuditEvent(), which you call wherever something worth recording happens.lib/audit/actions.tshas the server actions the viewer reads the log with, and your authorization check.
The events are kept in a Postgres table by lib/audit/postgres.ts, in plain
SQL. It works with any Postgres library or ORM, and adds no dependencies.
npx shadcn@latest add @runqora/audit-actions1. Create the table
Add the table where your app keeps its schema, so your migrations know about it.
create table if not exists audit_events (
id uuid primary key default gen_random_uuid(),
occurred_at timestamptz(3) not null default now(),
tenant_id text,
action text not null,
outcome text not null default 'success' check (outcome in ('success', 'failure')),
actor_type text not null default 'user',
actor_id text,
actor_name text,
actor_email text,
target_type text,
target_id text,
target_name text,
ip_address text,
user_agent text,
changes jsonb not null default '[]',
metadata jsonb not null default '{}'
);
create index if not exists audit_events_time on audit_events (occurred_at desc, id desc);
create index if not exists audit_events_tenant_time on audit_events (tenant_id, occurred_at desc, id desc);auditTableSql() from lib/audit/postgres.ts returns the same SQL. It's
three statements: some libraries, such as Prisma's $executeRawUnsafe, run
one at a time.
Keep occurred_at to the millisecond, as above: the viewer's pages follow
the time order, and JavaScript dates have no finer precision.
2. Connect your database
The store runs its queries through one function, query in
lib/audit/server.ts. It refuses every query until you connect it to the
library you already use:
import { pool } from "@/lib/db"; // A pg Pool.
function query(text: string, params: unknown[]) {
return pool.query(text, params).then((result) => result.rows);
}With other libraries, the body is:
| Library | Body of query |
|---|---|
| pg | pool.query(text, params).then((result) => result.rows) |
| postgres.js | sql.unsafe(text, params) |
| Neon serverless | sql.query(text, params) |
| Prisma | prisma.$queryRawUnsafe(text, ...params) |
| Drizzle | The line for the library under it, through db.$client |
| Bun | sql.unsafe(text, params), with Bun.SQL |
Every value goes in as a parameter, never into the SQL text.
3. Add your authorization check
Every server action is a public endpoint that anyone can call. The actions
refuse every request until you write authorize() in lib/audit/actions.ts.
It also returns which events the request may see, and the store applies that
scope to every query, whatever the browser asks for.
For example, to let the owners and admins of an organization see its log with Better Auth and its organization plugin:
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
async function authorize(): Promise<AuditScope> {
const requestHeaders = await headers();
const session = await auth.api.getSession({ headers: requestHeaders });
const tenantId = session?.session.activeOrganizationId;
const member = tenantId
? await auth.api
.getActiveMember({ headers: requestHeaders })
.catch(() => null)
: null;
// A member can have several roles, such as "admin,member".
const roles = member?.role.split(",") ?? [];
if (!(tenantId && roles.some((role) => role === "owner" || role === "admin"))) {
throw new AuditActionError("You are not allowed to see the audit log.");
}
return { tenantId };
}| Scope | Shows |
|---|---|
{ tenantId } | The events of one organization, workspace or account. |
{ actorId } | One person's own activity. |
{ tenantId, actorId } | One person's activity in one organization. |
{ all: true } | Every event. Only for people who may see everything, such as the admins of a single-tenant app. |
4. Record events
Call recordAuditEvent() on the server once something has happened, or
failed, such as in the server action that changes a member's role:
import { recordAuditEvent } from "@/lib/audit/server";
await recordAuditEvent({
action: "member.role_changed",
actor: { id: user.id, name: user.name, email: user.email },
target: { type: "member", id: member.id, name: member.user.name },
changes: [{ field: "role", before: "member", after: "admin" }],
tenantId: organization.id,
});The address and browser of the current request are filled in unless the
event has its own. Clients can send address headers themselves, so set
clientAddress() in lib/audit/server.ts to read the one that what sits in
front of your app sets:
| In front of your app | Header |
|---|---|
| Cloudflare | cf-connecting-ip |
| Vercel | x-real-ip |
| Your own nginx or Traefik | The header you set there from the address it saw |
By default it reads the last x-forwarded-for entry, which the nearest proxy
added. Only valid IP addresses are recorded.
Don't export recordAuditEvent() from a file with "use server": it would
let anyone write to your audit log.
Settings
The top of lib/audit/actions.ts has the settings you may want to change:
| Setting | Default | What it does |
|---|---|---|
PAGE_SIZE | 50 | Events per page. |
MAX_ACTIONS | 50 | Most actions one filter can pick. |
To use another table name, such as audit.events, pass table to
createPostgresAuditStore() in lib/audit/server.ts.
What the actions check
- Every filter field is checked and normalized, and unknown fields are dropped. Dates must be valid, and the start before the end.
- The scope from
authorize()is applied to every query. A scope with an empty id is refused rather than ignored. - Page cursors are checked before they reach the database.
- An event outside the request's scope reads as missing, so its existence doesn't leak.
- Errors are turned into messages that are safe to show. Anything unexpected is logged on the server and shown as a generic error.
Source
"use server";
// Server actions that let the audit log viewer read the log. Pass them to
// <AuditLogViewer source={{ list: listAuditEvents, getEvent: getAuditEvent }} />.
//
// Every server action is a public endpoint that anyone can call, so each one
// starts with `authorize()`, which also decides which events the request may
// see. Write that check before you use the viewer.
import { auditStore } from "@/lib/audit/server";
import type {
AuditCursor,
AuditEvent,
AuditLogFilter,
AuditLogPage,
AuditOutcome,
AuditResult,
AuditScope,
} from "@/lib/audit/types";
const PAGE_SIZE = 50;
const MAX_ACTIONS = 50;
const MAX_VALUE_LENGTH = 320;
const CONTROL_CHARACTERS = /\p{Cc}/u;
/** Dates before 1970 or after 9999 are refused, as no event has them. */
const LAST_DATE = Date.UTC(9999, 11, 31, 23, 59, 59, 999);
/** An error whose message is safe to show to the person using the viewer. */
class AuditActionError extends Error {}
/**
* Rejects when the person making the request may not see the audit log, and
* otherwise returns the events they may see. For example, with Better Auth
* and its organization plugin, for the admins of the active organization:
*
* async function authorize(): Promise<AuditScope> {
* const requestHeaders = await headers();
* const session = await auth.api.getSession({ headers: requestHeaders });
* const tenantId = session?.session.activeOrganizationId;
* const member = tenantId
* ? await auth.api
* .getActiveMember({ headers: requestHeaders })
* .catch(() => null)
* : null;
* // A member can have several roles, such as "admin,member".
* const roles = member?.role.split(",") ?? [];
* if (!(tenantId && roles.some((role) => role === "owner" || role === "admin"))) {
* throw new AuditActionError("You are not allowed to see the audit log.");
* }
* return { tenantId };
* }
*
* Return `{ actorId: session.user.id }` to show people their own activity,
* and `{ all: true }` only to those who may see every event.
*/
function authorize(): Promise<AuditScope> {
return Promise.reject(
new AuditActionError(
"Add an authorization check to lib/audit/actions.ts to see the audit log."
)
);
}
async function run<T>(
task: (scope: AuditScope) => Promise<T>
): Promise<AuditResult<T>> {
try {
const scope = await authorize();
return { data: await task(scope), ok: true };
} catch (error) {
if (error instanceof AuditActionError) {
return { error: error.message, ok: false };
}
console.error(error);
return { error: "Something went wrong. Try again.", ok: false };
}
}
function isText(value: unknown): value is string {
return (
typeof value === "string" &&
value.length > 0 &&
value.length <= MAX_VALUE_LENGTH &&
!CONTROL_CHARACTERS.test(value)
);
}
function readText(value: unknown, message: string): string | undefined {
if (value === undefined || value === null || value === "") {
return;
}
if (!isText(value)) {
throw new AuditActionError(message);
}
return value;
}
function readDate(value: unknown): string | undefined {
if (value === undefined || value === null || value === "") {
return;
}
const time = typeof value === "string" ? Date.parse(value) : Number.NaN;
if (Number.isNaN(time) || time < 0 || time > LAST_DATE) {
throw new AuditActionError("Choose a valid date.");
}
return new Date(time).toISOString();
}
function readActions(value: unknown): string[] | undefined {
if (value === undefined || value === null) {
return;
}
const invalid = new AuditActionError("That action filter can't be used.");
if (!Array.isArray(value) || value.length > MAX_ACTIONS) {
throw invalid;
}
// A new array, checked item by item: `every` would skip the holes of a
// sparse array.
const actions: string[] = [];
for (const action of value) {
if (!isText(action)) {
throw invalid;
}
actions.push(action);
}
return actions.length > 0 ? actions : undefined;
}
function readOutcome(value: unknown): AuditOutcome | undefined {
if (value === undefined || value === null || value === "") {
return;
}
if (value !== "success" && value !== "failure") {
throw new AuditActionError("That outcome can't be used.");
}
return value;
}
/** The filter the browser sent, checked. Unknown fields are dropped. */
function readFilter(value: unknown): AuditLogFilter {
if (value === undefined || value === null) {
return {};
}
if (typeof value !== "object") {
throw new AuditActionError("That filter can't be used.");
}
const input = value as Record<string, unknown>;
const filter: AuditLogFilter = {
actions: readActions(input.actions),
actor: readText(input.actor, "Enter an id or an email."),
from: readDate(input.from),
outcome: readOutcome(input.outcome),
target: readText(input.target, "Enter the id of what was changed."),
to: readDate(input.to),
};
if (filter.from && filter.to && filter.from >= filter.to) {
throw new AuditActionError("The start date must be before the end date.");
}
return filter;
}
function encodeCursor(cursor: AuditCursor | null): string | null {
return cursor
? Buffer.from(JSON.stringify([cursor.occurredAt, cursor.id])).toString(
"base64url"
)
: null;
}
function decodeCursor(value: unknown): AuditCursor | null {
if (value === undefined || value === null || value === "") {
return null;
}
const message = "The next page can't be loaded. Reload the log.";
if (typeof value !== "string" || value.length > MAX_VALUE_LENGTH) {
throw new AuditActionError(message);
}
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
} catch (error) {
throw new AuditActionError(message, { cause: error });
}
if (
!Array.isArray(parsed) ||
parsed.length !== 2 ||
typeof parsed[0] !== "string" ||
Number.isNaN(Date.parse(parsed[0])) ||
!isText(parsed[1])
) {
throw new AuditActionError(message);
}
return { id: parsed[1], occurredAt: new Date(parsed[0]).toISOString() };
}
export async function listAuditEvents(input: {
cursor?: string | null;
filter: AuditLogFilter;
}): Promise<AuditResult<AuditLogPage>> {
// A server action gets whatever the browser sends, whatever its types say.
const sent: unknown = input;
return await run(async (scope) => {
const fields =
typeof sent === "object" && sent !== null
? (sent as Record<string, unknown>)
: {};
const filter = readFilter(fields.filter);
const cursor = decodeCursor(fields.cursor);
const page = await auditStore.list({
cursor,
filter,
limit: PAGE_SIZE,
scope,
});
return { events: page.events, nextCursor: encodeCursor(page.next) };
});
}
export async function getAuditEvent(
id: string
): Promise<AuditResult<AuditEvent>> {
return await run(async (scope) => {
if (!isText(id)) {
throw new AuditActionError("That event doesn't exist.");
}
const event = await auditStore.get({ id, scope });
if (!event) {
// Also for events outside the scope, so their existence doesn't leak.
throw new AuditActionError("That event doesn't exist.");
}
return event;
});
}// Connects the audit log to your database, and records events. Import it only
// from server code, such as server actions and route handlers.
//
// It uses the Postgres store. On another database, replace `auditStore` with
// your own `AuditStore`, and keep the rest.
import { isIP } from "node:net";
import { headers } from "next/headers";
import { createPostgresAuditStore } from "@/lib/audit/postgres";
import type { AuditEvent, NewAuditEvent } from "@/lib/audit/types";
/**
* Runs one query on your Postgres database and returns its rows. Replace the
* body with the library you use:
*
* pg: pool.query(text, params).then((result) => result.rows)
* postgres.js: sql.unsafe(text, params)
* Neon serverless: sql.query(text, params)
* Prisma: prisma.$queryRawUnsafe(text, ...params)
* Drizzle: the library under it, through `db.$client`
*/
function query(
_text: string,
_params: unknown[]
): Promise<Record<string, unknown>[]> {
return Promise.reject(
new Error(
"Connect lib/audit/server.ts to your database to use the audit log."
)
);
}
export const auditStore = createPostgresAuditStore({ query });
/**
* The address of the person making the request. Clients can send any of these
* headers themselves, so read the one that what sits in front of your app sets:
*
* Cloudflare: list.get("cf-connecting-ip")
* Vercel: list.get("x-real-ip")
* nginx, Traefik: the header you set there, from the address it saw
*
* By default it's the last `x-forwarded-for` entry, which the nearest proxy
* added. Behind more than one proxy, that is the address of the one before it.
*/
function clientAddress(list: Pick<Headers, "get">): string | null {
const address = list.get("x-forwarded-for")?.split(",").at(-1)?.trim();
return address && isIP(address) ? address : null;
}
/** The address and browser of the request being handled, if any. */
async function requestDetails(): Promise<{
ipAddress: string | null;
userAgent: string | null;
}> {
try {
const list = await headers();
return {
ipAddress: clientAddress(list),
userAgent: list.get("user-agent"),
};
} catch {
// Called outside a request, such as from a scheduled job.
return { ipAddress: null, userAgent: null };
}
}
/**
* Writes one event to the audit log. Call it on the server once an action has
* happened, or failed. The address and browser of the current request are
* filled in unless the event has its own.
*
* await recordAuditEvent({
* action: "member.role_changed",
* actor: { id: user.id, name: user.name, email: user.email },
* target: { type: "member", id: member.id, name: member.name },
* changes: [{ field: "role", before: "member", after: "admin" }],
* tenantId: organization.id,
* });
*/
export async function recordAuditEvent(
event: NewAuditEvent
): Promise<AuditEvent> {
const request = await requestDetails();
return await auditStore.insert({
...event,
ipAddress:
event.ipAddress === undefined ? request.ipAddress : event.ipAddress,
userAgent:
event.userAgent === undefined ? request.userAgent : event.userAgent,
});
}// An audit log store for Postgres, in plain SQL. It works with any Postgres
// library through one `query` function, so it adds no dependencies and doesn't
// care which ORM, if any, the rest of the app uses. Every value goes in as a
// query parameter.
//
// Create the table with the SQL from `auditTableSql()`, or add the equivalent to
// your ORM's schema so its migrations know about the table.
import type {
AuditCursor,
AuditEvent,
AuditLogFilter,
AuditScope,
AuditStore,
NewAuditEvent,
} from "@/lib/audit/types";
/**
* Runs one query with `$1`, `$2`… placeholders and returns its rows. With
* `pg`: `(text, params) => pool.query(text, params).then((r) => r.rows)`.
*/
export type PostgresQuery = (
text: string,
params: unknown[]
) => Promise<Record<string, unknown>[]>;
export interface PostgresAuditStoreConfig {
query: PostgresQuery;
/** Table name, optionally with a schema, such as "audit.events". */
table?: string;
}
const TABLE_NAME = /^[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?$/u;
function checkTable(table: string): string {
if (!TABLE_NAME.test(table)) {
throw new Error(`"${table}" can't be used as an audit table name.`);
}
return table;
}
/**
* The SQL that creates the table and its indexes. Times are kept to the
* millisecond, as JavaScript dates are, so that page cursors match rows
* exactly.
*/
export function auditTableSql(table = "audit_events"): string {
const name = checkTable(table);
const index = name.replace(".", "_");
return `create table if not exists ${name} (
id uuid primary key default gen_random_uuid(),
occurred_at timestamptz(3) not null default now(),
tenant_id text,
action text not null,
outcome text not null default 'success' check (outcome in ('success', 'failure')),
actor_type text not null default 'user',
actor_id text,
actor_name text,
actor_email text,
target_type text,
target_id text,
target_name text,
ip_address text,
user_agent text,
changes jsonb not null default '[]',
metadata jsonb not null default '{}'
);
create index if not exists ${index}_time on ${name} (occurred_at desc, id desc);
create index if not exists ${index}_tenant_time on ${name} (tenant_id, occurred_at desc, id desc);`;
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
const COLUMNS =
"id, occurred_at, action, outcome, actor_type, actor_id, actor_name, actor_email, target_type, target_id, target_name, ip_address, user_agent, changes, metadata";
/** Collects the conditions and parameters of one query. */
class Where {
readonly conditions: string[] = [];
readonly params: unknown[] = [];
/** Adds a parameter and returns its placeholder. */
param(value: unknown): string {
this.params.push(value);
return `$${this.params.length}`;
}
add(condition: string) {
this.conditions.push(condition);
}
toString(): string {
return this.conditions.length > 0
? `where ${this.conditions.join(" and ")}`
: "";
}
}
function applyScope(where: Where, scope: AuditScope) {
if ("all" in scope) {
if (scope.all !== true) {
throw new Error("Invalid audit scope.");
}
return;
}
const { actorId, tenantId } = scope;
// An empty id would otherwise drop out of the query and widen the scope.
for (const id of [actorId, tenantId]) {
if (id !== undefined && (typeof id !== "string" || id === "")) {
throw new Error("An audit scope's ids must be non-empty strings.");
}
}
if (!(tenantId || actorId)) {
throw new Error("An audit scope needs a tenantId or an actorId.");
}
if (tenantId) {
where.add(`tenant_id = ${where.param(tenantId)}`);
}
if (actorId) {
where.add(`actor_id = ${where.param(actorId)}`);
}
}
function applyFilter(where: Where, filter: AuditLogFilter) {
if (filter.actions && filter.actions.length > 0) {
const list = filter.actions.map((action) => where.param(action));
where.add(`action in (${list.join(", ")})`);
}
if (filter.actor) {
const actor = where.param(filter.actor);
where.add(`(actor_id = ${actor} or lower(actor_email) = lower(${actor}))`);
}
if (filter.target) {
where.add(`target_id = ${where.param(filter.target)}`);
}
if (filter.outcome) {
where.add(`outcome = ${where.param(filter.outcome)}`);
}
if (filter.from) {
where.add(`occurred_at >= ${where.param(filter.from)}::timestamptz`);
}
if (filter.to) {
where.add(`occurred_at < ${where.param(filter.to)}::timestamptz`);
}
}
function text(value: unknown): string | null {
return value === null || value === undefined ? null : String(value);
}
/** Libraries return `jsonb` parsed or as a string, depending on their settings. */
function json(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value;
}
/** Libraries return `timestamptz` as a Date or a string. */
function isoDate(value: unknown): string {
return new Date(value instanceof Date ? value : String(value)).toISOString();
}
function toEvent(row: Record<string, unknown>): AuditEvent {
const changes = json(row.changes);
const metadata = json(row.metadata);
const targetId = text(row.target_id);
return {
action: String(row.action),
actor: {
email: text(row.actor_email),
id: text(row.actor_id),
name: text(row.actor_name),
type: text(row.actor_type) ?? "user",
},
changes: Array.isArray(changes) ? changes : [],
id: String(row.id),
ipAddress: text(row.ip_address),
metadata:
metadata && typeof metadata === "object" && !Array.isArray(metadata)
? (metadata as Record<string, unknown>)
: {},
occurredAt: isoDate(row.occurred_at),
outcome: row.outcome === "failure" ? "failure" : "success",
target:
targetId === null
? null
: {
id: targetId,
name: text(row.target_name),
type: text(row.target_type) ?? "",
},
userAgent: text(row.user_agent),
};
}
/** Keeps and reads audit events in a Postgres table. */
export function createPostgresAuditStore({
query,
table = "audit_events",
}: PostgresAuditStoreConfig): AuditStore {
checkTable(table);
return {
async get({ id, scope }) {
if (!UUID.test(id)) {
return null;
}
const where = new Where();
applyScope(where, scope);
where.add(`id = ${where.param(id)}::uuid`);
const rows = await query(
`select ${COLUMNS} from ${table} ${where} limit 1`,
where.params
);
return rows[0] ? toEvent(rows[0]) : null;
},
async insert(event: NewAuditEvent) {
if (typeof event.action !== "string" || event.action === "") {
throw new Error("An audit event needs an action.");
}
const values = [
event.occurredAt ?? null,
event.tenantId ?? null,
event.action,
event.outcome === "failure" ? "failure" : "success",
event.actor.type ?? "user",
event.actor.id ?? null,
event.actor.name ?? null,
event.actor.email ?? null,
event.target?.type ?? null,
event.target?.id ?? null,
event.target?.name ?? null,
event.ipAddress ?? null,
event.userAgent ?? null,
JSON.stringify(event.changes ?? []),
JSON.stringify(event.metadata ?? {}),
];
const rows = await query(
`insert into ${table} (occurred_at, tenant_id, action, outcome, actor_type, actor_id, actor_name, actor_email, target_type, target_id, target_name, ip_address, user_agent, changes, metadata)
values (coalesce($1::timestamptz, now()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::text::jsonb, $15::text::jsonb)
returning ${COLUMNS}`,
values
);
if (!rows[0]) {
throw new Error("The audit event wasn't saved.");
}
return toEvent(rows[0]);
},
async list({ cursor, filter, limit, scope }) {
if (!Number.isSafeInteger(limit) || limit < 1) {
throw new Error("The page size must be a positive whole number.");
}
if (cursor && !UUID.test(cursor.id)) {
return { events: [], next: null };
}
const where = new Where();
applyScope(where, scope);
applyFilter(where, filter);
if (cursor) {
const time = where.param(cursor.occurredAt);
const id = where.param(cursor.id);
where.add(`(occurred_at, id) < (${time}::timestamptz, ${id}::uuid)`);
}
// One extra row tells whether there is a next page.
const rows = await query(
`select ${COLUMNS} from ${table} ${where} order by occurred_at desc, id desc limit ${limit + 1}`,
where.params
);
const events = rows.slice(0, limit).map(toEvent);
const last = events.at(-1);
return {
events,
next:
rows.length > limit && last
? ({
id: last.id,
occurredAt: last.occurredAt,
} satisfies AuditCursor)
: null,
};
},
};
}