Skip to content

FRD: Audit Log Library

FieldValue
IDFRD-014
OwnerDavid Holmes
StatusDraft
Last Updated2026-05-26
Open Source Librarieszod, pino (via Logging Library)
Target Releasev2.0.0
TypeLibrary
ComplexityM
Related LinksADR-027 (Default Tech Stack)

Document Summary

This FRD defines a canonical audit-event schema and write helpers that standardise how audit logs are created across all platform applications. The library provides a Zod-validated event schema, actor/resource/action normalization, write helpers for common mutation patterns, and ensures the existing audit viewer widget can consume the same schema. It builds on top of the logging library (FRD-013) for transport and extends the AuditEvent type defined there into a richer, write-side contract.


Introduction

The platform has an audit viewer widget that displays security-relevant events. However, the write side — the code that creates audit entries — has no shared implementation. Each application invents its own audit log format, actor identification, resource naming, and action taxonomy. This makes cross-app audit queries unreliable and compliance reporting fragile. This library standardises the write side so that every application produces audit events in the same shape, with the same actor/resource/action normalization, and the same validation guarantees.


Scope

In scope

  • Canonical AuditEvent Zod schema with required fields: event, actor, resource, action, timestamp, requestId, tenantId.
  • Actor normalization: { id, type, ip?, userAgent? } where type is user, system, api_key, or service.
  • Resource normalization: { id, type, tenantId? } with a consistent taxonomy (e.g., user, order, document, role).
  • Action normalization: a fixed set of action verbs (create, read, update, delete, grant, revoke, login, logout, export, import).
  • Write helpers: auditCreate, auditUpdate, auditDelete, auditGrant, auditRevoke, auditLogin, auditLogout that produce validated audit events and write them via the logging library.
  • createAuditWriter(logger) factory that binds an audit writer to a specific logger instance.
  • Integration examples for mutation flows (e.g., updating a user role, deleting an order).
  • Audit viewer compatibility: the schema must be a superset of what the existing audit viewer widget expects.
  • Tests validating schema enforcement, required fields, and action normalization.

Out of scope

  • Audit event storage or persistence layer (events are written to the logging pipeline).
  • Audit viewer widget modifications (it already exists and will consume this schema).
  • Tamper-proofing or cryptographic signing of audit entries (a future enhancement).
  • Querying or searching audit logs (handled by the aggregation layer).
  • Direct write to the logging pipeline as the sole audit store (see ADR note below).

Users and Pain Points

UserPain Point
App developerInvents a new audit log shape for each mutation, with inconsistent field names and missing context.
App developerForgets to include the actor’s IP address or user agent, which are required for compliance.
Security reviewerCannot write reliable cross-app audit queries because event shapes vary.
Compliance officerCannot generate a consistent audit trail for SOC 2 or GDPR data-access reporting.
Platform maintainerThe audit viewer widget receives inconsistently-shaped events and must handle missing fields defensively.

Definitions

TermDefinition
Audit eventA structured log entry documenting a security-relevant action: who (actor) did what (action) to which thing (resource) and when.
ActorThe entity that performed the action. Can be a user, a system process, an API key, or an internal service.
ResourceThe entity that was affected by the action. Identified by type and ID.
ActionA normalized verb describing the operation: create, read, update, delete, grant, revoke, login, logout, export, import.
Write helperA convenience function that constructs a validated audit event for a specific action and writes it to the logger.

Current State

  • An audit viewer widget exists in the component layer for displaying audit events.
  • The logging library (FRD-013) defines a basic AuditEvent type with event, actor, resource, action, and details.
  • No shared write-side helpers exist. Applications log audit events as ad-hoc JSON objects with inconsistent shapes.
  • No canonical action taxonomy. Some apps use verbs like modified, others use updated, others use changed.
  • No files exist under src/lib/audit/.

6.5 ADR-027 Alignment Note

ADR-027 §5 states: “Audit events: Separate immutable store. Do not use debug logs as audit logs.”

This library’s write helpers will:

  1. Write audit events to a dedicated audit_events PostgreSQL table (the immutable store).
  2. Also emit a structured Pino log entry via the logging library (FRD-013) for correlation — but the log is supplementary, not the primary audit record.

audit_events table

CREATE TABLE audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
event TEXT NOT NULL, -- e.g. "user.role.updated"
action TEXT NOT NULL, -- create | read | update | delete | grant | revoke | login | logout | export | import
actor_id UUID NOT NULL,
actor_type TEXT NOT NULL, -- user | system | api_key | service
actor_ip INET,
actor_ua TEXT,
resource_id UUID,
resource_type TEXT NOT NULL,
request_id UUID,
details JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Immutability: no UPDATE or DELETE permissions granted to application role
CREATE INDEX idx_audit_events_tenant_created
ON audit_events (tenant_id, created_at DESC);
CREATE INDEX idx_audit_events_actor
ON audit_events (actor_id, created_at DESC);
CREATE INDEX idx_audit_events_resource
ON audit_events (resource_id, resource_type, created_at DESC);

Migration tool: Atlas per ADR-027 §4. The application role must have INSERT only (no UPDATE, no DELETE) on this table.


Proposed Solution

7.1 Canonical schema

const auditEventSchema = z.object({
event: z.string().min(1), // e.g., "user.role_changed"
actor: auditActorSchema,
resource: auditResourceSchema,
action: auditActionSchema,
timestamp: z.string().datetime(),
requestId: z.string().min(1),
tenantId: z.string().min(1),
details: z.record(z.unknown()).optional(),
outcome: z.enum(["success", "failure", "denied"]).default("success"),
});

7.2 Normalization schemas

const auditActorSchema = z.object({
id: z.string().min(1),
type: z.enum(["user", "system", "api_key", "service"]),
ip: z.string().optional(),
userAgent: z.string().optional(),
});
const auditResourceSchema = z.object({
id: z.string().min(1),
type: z.string().min(1),
tenantId: z.string().optional(),
});
const auditActionSchema = z.enum([
"create", "read", "update", "delete",
"grant", "revoke",
"login", "logout",
"export", "import",
]);

7.3 Audit writer

const audit = createAuditWriter(logger);
audit.create({
event: "order.created",
actor: { id: user.id, type: "user", ip: request.ip },
resource: { id: order.id, type: "order" },
tenantId: user.tenantId,
requestId: ctx.requestId,
details: { items: order.items.length },
});

7.4 Convenience write helpers

// Each helper pre-fills the action field
audit.create(event) // action: "create"
audit.update(event) // action: "update"
audit.delete(event) // action: "delete"
audit.grant(event) // action: "grant"
audit.revoke(event) // action: "revoke"
audit.login(event) // action: "login"
audit.logout(event) // action: "logout"

Each helper validates the event with the canonical schema, sets the timestamp to new Date().toISOString() (overridable via a now option), and writes it to the logger via logger.audit().


Requirements

IDPriorityRequirement
AUDIT-01P0Canonical auditEventSchema with all required fields validated by Zod.
AUDIT-02P0Actor normalization with id, type, optional ip and userAgent.
AUDIT-03P0Resource normalization with id, type, optional tenantId.
AUDIT-04P0Fixed action taxonomy: create, read, update, delete, grant, revoke, login, logout, export, import.
AUDIT-05P0createAuditWriter(logger) factory producing a writer with convenience methods.
AUDIT-06P0Convenience write helpers for each action verb.
AUDIT-07P0Schema validation on every write; invalid events throw with descriptive Zod errors.
AUDIT-08P1outcome field (success, failure, denied) with default success.
AUDIT-09P0Tests verifying required fields, action normalization, and validation rejection of invalid events.
AUDIT-10P1Integration example showing audit writes in a mutation flow.

Functional Requirements

  1. Schema validation: Every call to a write helper validates the event against auditEventSchema before writing. If validation fails, the write helper throws a ZodError. The event is never written to the logger in an invalid state.
  2. Timestamp: If timestamp is not provided in the event, the writer sets it to new Date().toISOString(). If provided, it must be a valid ISO 8601 string.
  3. Action pre-fill: audit.create(event) sets event.action = "create" before validation. If the caller also provides action, it must match — a mismatch throws.
  4. Logger integration: The writer calls logger.audit(validatedEvent) from the logging library (FRD-013). The logger handles transport (stdout, remote drain).
  5. Actor from AuthenticatedUser: Provide a utility actorFromUser(user, request?) that extracts { id: user.id, type: "user", ip?, userAgent? } from an AuthenticatedUser and optional request headers.
  6. System actor: Provide a constant SYSTEM_ACTOR = { id: "system", type: "system" } for background jobs and scheduled tasks.
  7. Audit viewer compatibility: The exported AuditEvent type and schema must be importable by the audit viewer widget for display rendering. The viewer should not need to transform or normalize events.

Non-Functional Requirements

CategoryRequirement
PerformanceAudit writes add less than 1ms overhead above the underlying logger call. Zod validation is the primary cost.
ReliabilityAudit write failures (validation errors) must not crash the calling mutation. The writer catches, logs an error to the logger, and re-throws so the caller can decide whether to proceed.
TestabilitycreateAuditWriter accepts a logger, so tests can provide a mock or a captured-output logger.
ComplianceThe schema satisfies SOC 2 CC7.2 (system change logging) and GDPR Article 30 (records of processing activities) field requirements: who, what, when, to which resource.
Zero runtime dependenciesOnly depends on zod (existing) and the logging library (FRD-013).

API/Interface Requirements

Schemas

const auditActorSchema: z.ZodObject<...>;
const auditResourceSchema: z.ZodObject<...>;
const auditActionSchema: z.ZodEnum<[...]>;
const auditEventSchema: z.ZodObject<...>;
type AuditActor = z.infer<typeof auditActorSchema>;
type AuditResource = z.infer<typeof auditResourceSchema>;
type AuditAction = z.infer<typeof auditActionSchema>;
type AuditEvent = z.infer<typeof auditEventSchema>;

Writer factory

interface AuditWriter {
create(event: Omit<AuditEvent, "action">): void;
update(event: Omit<AuditEvent, "action">): void;
delete(event: Omit<AuditEvent, "action">): void;
grant(event: Omit<AuditEvent, "action">): void;
revoke(event: Omit<AuditEvent, "action">): void;
login(event: Omit<AuditEvent, "action">): void;
logout(event: Omit<AuditEvent, "action">): void;
write(event: AuditEvent): void; // generic, caller provides action
}
function createAuditWriter(logger: Logger, options?: { now?: () => Date }): AuditWriter;

Utilities

function actorFromUser(user: AuthenticatedUser, request?: Request): AuditActor;
const SYSTEM_ACTOR: AuditActor;

Accessibility Requirements

This is a headless library with no UI. The audit viewer widget (which consumes this schema) handles its own accessibility. The event and action field values should be human-readable strings suitable for display in the viewer without transformation.


Content and Documentation Requirements

  • TSDoc on every exported function, type, and schema.
  • A docs/best-practices/audit-logging.mdx guide covering: schema overview, writer setup, convenience helpers, actor construction, integration with mutation flows, and testing audit writes.
  • Storybook docs page under Docs/Best Practices/Audit Logging.
  • At least two integration examples: (1) auditing a user role change in an API route, (2) auditing a record deletion with the denied outcome.

Dependencies

DependencyTypeNotes
src/lib/logging/ (FRD-013)InternalAudit events are written via the logging library’s logger.audit() method.
src/lib/auth/InternalactorFromUser consumes AuthenticatedUser.
zodExistingSchema validation.
Audit viewer widgetInternal (consumer)The widget imports AuditEvent type/schema for rendering.

Risks and Tradeoffs

RiskLikelihoodImpactMitigation
Fixed action taxonomy may not cover all future use cases.MediumLowThe taxonomy covers standard CRUD, access control, and auth actions. The write() method accepts any action from the enum. New actions can be added to the enum in a non-breaking minor release.
Zod validation on every audit write adds latency in hot mutation paths.LowLowValidation of a small, flat object is sub-millisecond. Profile and cache schemas if this becomes measurable.
Audit writes that throw on validation errors could disrupt mutation flows.MediumMediumDocument that callers should catch audit write errors and decide whether to proceed or abort. The writer logs the validation error before re-throwing.
Schema evolution may break the audit viewer if fields are renamed.LowHighThe viewer imports the schema from this library. Both sides evolve together. Breaking changes require a major version bump.

Open Questions

  1. Should the library support batch audit writes for operations that affect multiple resources (e.g., bulk delete)? Leaning toward individual writes per resource with a shared requestId for correlation.
  2. Should outcome: "denied" audit events be logged at warn level instead of info? This would make denial events stand out in log aggregation.
  3. Should the schema include a previousValue / newValue field for change-tracking, or is that too complex for v2.0? Leaning toward deferring structured diffs to v2.1 and using the free-form details field for now.
  4. Should the AuditAction enum be extensible per-app, or is the fixed set sufficient?

Acceptance Criteria

  • auditEventSchema validates all required fields: event, actor, resource, action, timestamp, requestId, tenantId.
  • auditActorSchema requires id and type; ip and userAgent are optional.
  • auditActionSchema accepts exactly: create, read, update, delete, grant, revoke, login, logout, export, import.
  • createAuditWriter(logger) returns a writer with convenience methods for each action.
  • Convenience methods pre-fill the action field and validate the event before writing.
  • Invalid events (missing required fields, unknown action) are rejected with descriptive Zod errors.
  • actorFromUser(user, request) correctly extracts actor fields from AuthenticatedUser and request headers.
  • SYSTEM_ACTOR constant is exported for background job audit events.
  • Audit events are written to the logger via logger.audit().
  • The exported AuditEvent type is compatible with the audit viewer widget’s expected input.
  • Tests cover: valid event creation, missing required fields, invalid action, actor construction, and timestamp auto-fill.

LLM Handoff Instructions

When implementing this FRD:

  1. Create src/lib/audit/ as a new module. Files: index.ts, schema.ts (Zod schemas and types), writer.ts (AuditWriter factory and convenience methods), actor.ts (actorFromUser and SYSTEM_ACTOR), types.ts (re-exports from schema for external consumers).
  2. The auditEventSchema in schema.ts should import and extend the basic AuditEvent type from the logging library (FRD-013) src/lib/logging/audit.ts. If FRD-013 is not yet implemented, define the full schema here and mark the logging integration as a follow-up.
  3. The AuditWriter convenience methods are thin wrappers: create(event) { this.write({ ...event, action: "create" }) }.
  4. actorFromUser extracts id from user.id, sets type: "user", and optionally reads x-forwarded-for (or request.headers.get("x-forwarded-for")) for ip and user-agent for userAgent.
  5. Tests go in src/lib/audit/*.test.ts. Use a mock logger that captures calls to logger.audit() and asserts the event shape.
  6. Export the schemas and types from index.ts so the audit viewer widget can import them directly.
  7. Run pnpm typecheck and pnpm vitest run --project unit before declaring complete.

Decision Log

DateDecisionRationale
2026-05-26Fixed action taxonomy with 10 verbs.Covers CRUD, access control, and auth actions. Keeps queries predictable and avoids synonym proliferation (e.g., modified vs. updated).
2026-05-26outcome field with success/failure/denied.Enables filtering for security-relevant denials without parsing free-text details.
2026-05-26Audit events flow through the logging library, not a separate transport.Single pipeline. Aggregation layer filters on audit: true. Avoids a second log infrastructure.
2026-05-26Schema shared between writer and viewer.Single source of truth prevents schema drift.

Document History

VersionDateAuthorChanges
0.12026-05-26David HolmesInitial draft.