Audit Trail Plugin Recipe
Record immutable audit trails for resource mutations using CRUD lifecycle hooks.
This recipe demonstrates how to create a reusable audit trail plugin that logs resource mutations to an application-owned database table using CRUD after* hooks.
Minimal Example
Create an audit logging resource plugin and attach it to sensitive entity resources:
import {
defineResourcePlugin,
integer,
sqliteTable,
text,
} from "@brickkit/core";
// 1. Declare the audit log storage table
export const auditLogs = sqliteTable("audit_logs", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
resourceName: text("resource_name").notNull(),
recordId: text("record_id").notNull(),
operation: text("operation").notNull(), // "create" | "update" | "delete"
userId: text("user_id"),
timestamp: integer("timestamp").notNull(),
});
// 2. Define the resource plugin
export function auditTrail() {
return defineResourcePlugin({
name: "auditTrail",
setup({ resource, service }) {
const recordMutation = async (
operation: string,
recordId: string,
ctx: any,
) => {
if (!ctx.db) return;
await ctx.db.insert(auditLogs).values({
resourceName: resource.name,
recordId: String(recordId),
operation,
userId: ctx.user?.id ?? null,
timestamp: Date.now(),
});
};
return {
recordMutation,
};
},
});
}Schema
These columns match the Drizzle tables in the example. Defaults marked as application defaults run in Drizzle, rather than in SQL.
Table Name: audit_logs
| Field | Type | Description |
|---|---|---|
id | text | Row identifier. PK Default: crypto.randomUUID() (application) |
resource_name | text | Name of the mutated resource. |
record_id | text | Identifier of the mutated row. |
operation | text | Mutation action: create, update, or delete. |
user_id | text | Authenticated user identifier, or null for anonymous writes. Optional |
timestamp | integer | Unix epoch timestamp in milliseconds, set by the audit logger. |
Wiring into CRUD Lifecycle Hooks
Attach the audit logger to your resource's crud() lifecycle hooks:
import { crud } from "@brickkit/crud";
import { accountsTable, accountsService } from "./schema";
import { auditTrail } from "../plugins/audit";
// 1. Install audit plugin onto resource
const accountResource = accountsService
.resource({ name: "account", table: accountsTable, id: accountsTable.id })
.use(auditTrail());
// 2. Wire mutation records in crud() hooks
accountResource.use(
crud({
access: {
authorize: ({ ctx }) => !!ctx.user,
},
fields: {
id: { read: true },
name: { read: true, create: true, update: true },
},
hooks: {
afterCreate: async ({ row, ctx }) => {
await accountResource.recordMutation("create", String(row.id), ctx);
},
afterUpdate: async ({ id, ctx }) => {
await accountResource.recordMutation("update", String(id), ctx);
},
afterDelete: async ({ id, ctx }) => {
await accountResource.recordMutation("delete", String(id), ctx);
},
},
}),
);Architectural Considerations
- Asynchronous Execution:
after*hooks run after the write commits to the primary table. If audit row insertions must commit atomically with the mutation, run both queries inside an application transaction viactx.db.transaction(). - Masking & Security: Never log unredacted passwords, payment details, or personal data to audit tables.
- Dedicated Read Policy: Expose audit logs through a separate administrative resource with restricted access controls.