Files
2026-05-27 23:29:53 +01:00

11 KiB

Add @suaveplan/api-plugin-audit — write-through audit trail for @suaveplan/api

Package: @suaveplan/api-plugin-audit (NEW, server-tier) Status: PROPOSED Type: new-package

  • Spec delta: ./specs/api-plugin-audit/spec.md

Why

Genesis ships @suaveplan/api (Hono-based orchestrator), @suaveplan/api-plugin (definePlugin foundation + ServiceRegistry), and @suaveplan/audit-logging (universal AuditLogger with HMAC-SHA256 chain-integrity, pluggable AuditStorageAdapter, retention, GDPR export). What is missing is the glue: a definePlugin consumer that installs a global Hono middleware to record an audit event for every request — method, path, status, duration, response size, request id, principal id (from c.get("user")?.id), tenant id (from c.get("tenant")?.id) — and exposes auditService in ServiceRegistry plus an audit slot on the Hono ContextVariableMap for handler-level events.

This is the compliance/audit half of the api-plugin trio (auth → audit → rate-limit). The upstream enterprise/api-plugin-audit.md (126 lines) originally described an in-memory AuditService; this genesis package replaces that with the canonical write-through over @suaveplan/audit-logging, picking up its chain-integrity, GDPR export, retention, and pluggable sinks for free.

What changes

Net-new package at 0.1.0. ADDED:

Plugin factory + descriptor

  • createAuditPlugin(options: AuditPluginOptions): PluginDescriptor<AuditPluginConfig> — Zod-validated factory. Invalid input → AuditError(AUDIT_PLUGIN_INVALID_INPUT) carrying ZodError as cause. Descriptor declares name: "audit", version, defaults, lifecycle hooks.
  • AuditPluginOptions (Zod-validated): { logger?: LoggerLike; sink?: AuditStorageAdapter (default: MemoryAuditAdapter); secretKey?: string (default: generated dev key with warning); routePrefix?: string (default "/"); samplingRate?: number 0..1 (default 1); excludePaths?: readonly string[] (default ["/health","/healthz","/ready","/openapi.json"]); redactQueryParams?: readonly string[]; redactHeaders?: readonly string[]; redactBodyFields?: readonly string[]; recordResponseSize?: boolean (default true); recordRequestBody?: boolean (default false); enableChainIntegrity?: boolean (default false); }.

Sink injection

Caller supplies a real AuditStorageAdapter (file/HTTP/DB/SurrealDB/S3). Default is MemoryAuditAdapter from @suaveplan/audit-logging for dev. In production, operators wire a durable sink. The plugin owns the AuditLogger lifecycle (constructed in onInit, retained in plugin state, exposed via auditService and the audit context slot).

Global request-audit middleware

onInit installs a single app.use("*", ...) middleware that, for every non-excluded request:

  1. Captures Date.now() start, request id (from c.get("requestId") or c.req.header("x-request-id") or generated ULID-like), redacted query, redacted headers, optionally redacted body.
  2. await next().
  3. Records: method, path, status (from c.res.status), duration_ms, response_size (from Content-Length header when present), request_id, principal_id (c.get("user")?.id), tenant_id (c.get("tenant")?.id), severity (info < 400, warning < 500, error otherwise).
  4. Honors samplingRate (Math.random gate) — when skipped, neither record nor sink call happens.
  5. Honors excludePaths (exact match — health probes, openapi doc).

The middleware never throws into the request path — a sink failure logs at warn via LoggerLike and increments audit.sink_failures_total but the request response is unaffected.

Per-handler audit helper

c.get("audit") returns an AuditContext instance exposing log(event: Partial<AuditEvent>) and getLogger(): AuditLogger. Handlers fire explicit, semantic events (e.g. user-deleted, vault-unlock) alongside the implicit request audit. The package augments Hono's ContextVariableMap with audit: AuditContext.

Service registry slot

  • ctx.registerService("auditService", auditService) where auditService: AuditService exposes getLogger(), log(event), query(opts), verifyChainIntegrity(events), exportGDPRData(userId, signal?), applyRetentionPolicy(signal?). ServiceRegistry module augmentation declares the slot.

Redaction

  • redactPayload(payload, rules) — accepts query/header/body field-name lists, returns a shallow-redacted clone replacing matched values with [REDACTED]. Header matching is case-insensitive. Defaults cover common secret-bearing names (authorization, cookie, set-cookie, x-api-key, plus body password, secret, token, plus query token, code, state).
  • Uses @suaveplan/data-masking's maskObject for body-field traversal when caller supplies custom rules expressed as MaskRule[]; otherwise the bundled fast-path covers the default name list without pulling the masking-rule machinery.

Errors + OTel

  • AUDIT_PLUGIN_CODES (defineCodes("AUDIT_PLUGIN", [...])): INVALID_INPUT, SERVICE_NOT_AVAILABLE, SINK_INIT_FAILED, SINK_WRITE_FAILED. Every code raised by a production code path.
  • OTel: counter audit.requests_recorded_total{outcome,sampled}, counter audit.sink_failures_total, counter audit.requests_excluded_total, histogram audit.middleware_duration_ms.

Subpath exports

., ./plugin/plugin, ./middleware/audit-middleware, ./services/audit-service, ./context/audit-context, ./redact/redact, ./schemas/options, ./error-codes/error-codes, ./otel/instruments, ./types/types.

Out of scope

  • HTTP routes for querying audit (e.g. GET /audit?...) — the in-process auditService + sink-of-choice already cover query; a follow-up api-plugin-audit-routes could publish authenticated read endpoints (must depend on api-plugin-auth).
  • Real durable sinks (SurrealDB / HTTP / SIEM forwarder) — the package exposes the AuditStorageAdapter contract from @suaveplan/audit-logging; siblings ship the concrete sinks.
  • Hash-chain verification CLI / scheduler-driven retention — auditService.verifyChainIntegrity / applyRetentionPolicy are exposed; orchestrating them on a cron belongs in @suaveplan/job-scheduler consumers, not this plugin.
  • Frontend UI — backend only.
  • PII pseudonymisation via @suaveplan/data-masking MaskRules in the request path — the redactor uses a fast [REDACTED] substitution for the request-audit path; full mask-rule machinery is exposed for handler-level c.get("audit").log({ details: maskObject(payload, rules) }) flows.

Source mapping

This change derives from ../suaveplan/enterprise/api-plugin-audit.md (canonical, 126 lines).

Requirement Source lines Notes
Hono plugin wrapper (definePlugin) L44-L57 Upstream flagged the gap that this package fills
Dependency on @suaveplan/api L46 Verbatim — package must definePlugin not be a standalone service
AuditEntry records with severity + action L12, L22 Replaced upstream's bespoke types with @suaveplan/audit-logging's AuditEvent + AuditSeverity (L51 explicitly notes the overlap)
Wrap @suaveplan/audit-logging L51, L56 Upstream identified the overlap with the universal package; this change resolves it by making api-plugin-audit a wrapper
HTTP routes for query/export L62 Scoped out per "Out of scope"; surfaced through auditService for now
Auth-gated read endpoints L63 Scoped out — gated behind future api-plugin-audit-routes
Retention enforcement via scheduler L64 auditService.applyRetentionPolicy exposed; orchestration is consumer responsibility
PII masking via data-masking L65 Implemented via redactPayload + handler-level maskObject integration
Append-only / hash-chain integrity L61, L84 Inherited from @suaveplan/audit-logging's enableChainIntegrity option
Globally-unique IDs (ULID/UUID) L60 Inherited — AuditLogger uses crypto.randomUUID() by default
OTel correlation L92 Counters + histogram emitted from middleware
100% test + docs coverage L69, L70 Genesis-wide policy enforced via gates

Requirements with no source line

Requirement Justification
ServiceRegistry typed slot for auditService Genesis @suaveplan/api-plugin augmentation pattern (mirror of api-plugin-auth authService slot)
Hono ContextVariableMap augmentation for audit slot Genesis context-augmentation pattern (mirror of api-plugin-auth user/session)
Sampling-rate gate Genesis-wide policy: production audit volumes require head-end sampling
excludePaths for health probes Avoids audit-noise on liveness/readiness traffic
Middleware never throws into request path Standard observability rule — telemetry failures must not 500

Verification approach

Every requirement in the spec delta has a **Verified by:** line. Test files that will exist after this change ships:

  • src/plugin/plugin.test.ts — factory shape, Zod validation, lifecycle hook presence
  • src/plugin/on-init.test.ts — lifecycle: service resolution, sink wiring, middleware install
  • src/middleware/audit-middleware.test.ts — per-request behavior: capture, redact, sample, exclude, never-throw
  • src/services/audit-service.test.ts — log/query/export/retention proxies
  • src/services/audit-service.spec-conformance.test.ts — ServiceRegistry slot typing
  • src/context/audit-context.test.ts — c.get("audit").log(...)
  • src/redact/redact.test.ts — case-insensitive headers, query, body fields
  • src/schemas/options.test.ts — Zod boundaries
  • src/otel/instruments.test.ts — counters/histogram emit
  • src/api-plugin-audit.spec-conformance.test.ts — every AUDIT_PLUGIN_* code thrown from prod
  • tests/integration/full-boot.test.ts — real api + plugin + sample route → sink receives event with redacted secrets, principal/tenant/status/duration correct
  • tests/integration/sampling.test.ts — 50% sampling over 1000 requests in [400, 600]
  • tests/dep-graph.test.ts — every .ts has sibling .md; every subpath resolves

Migration impact

None — additive only. The legacy upstream @suaveplan/api-plugin-audit shipped a different surface (in-memory AuditService with no plugin wrapper). Genesis ships zero existing consumers of the legacy surface, so this is a clean rewrite at 0.1.0.

Impact

Affected dependents

No dependents (verified by bunx turbo ls --filter=...@suaveplan/api-plugin-audit — package does not yet exist).

Catalog additions required

None — all dependencies (@suaveplan/api, @suaveplan/api-plugin, @suaveplan/audit-logging, @suaveplan/data-masking, @suaveplan/error, @suaveplan/types, hono, zod, @opentelemetry/api, tslib) already exist in catalog/workspace.