brick.ts

Audit Trail Plugin Recipe

Record immutable audit trails for resource mutations using CRUD lifecycle hooks.

llms.txt

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:

src/plugins/audit.ts
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

FieldTypeDescription
idtextRow identifier. PK Default: crypto.randomUUID() (application)
resource_nametextName of the mutated resource.
record_idtextIdentifier of the mutated row.
operationtextMutation action: create, update, or delete.
user_idtextAuthenticated user identifier, or null for anonymous writes. Optional
timestampintegerUnix 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:

src/services/accounts.ts
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 via ctx.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.