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)carryingZodErrorascause. Descriptor declaresname: "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:
- Captures
Date.now()start, request id (fromc.get("requestId")orc.req.header("x-request-id")or generated ULID-like), redacted query, redacted headers, optionally redacted body. await next().- Records:
method,path,status(fromc.res.status),duration_ms,response_size(fromContent-Lengthheader when present),request_id,principal_id(c.get("user")?.id),tenant_id(c.get("tenant")?.id),severity(info < 400, warning < 500, error otherwise). - Honors
samplingRate(Math.random gate) — when skipped, neither record nor sink call happens. - 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)whereauditService: AuditServiceexposesgetLogger(),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 bodypassword,secret,token, plus querytoken,code,state).- Uses
@suaveplan/data-masking'smaskObjectfor body-field traversal when caller supplies custom rules expressed asMaskRule[]; 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}, counteraudit.sink_failures_total, counteraudit.requests_excluded_total, histogramaudit.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-processauditService+ sink-of-choice already cover query; a follow-upapi-plugin-audit-routescould publish authenticated read endpoints (must depend onapi-plugin-auth). - Real durable sinks (SurrealDB / HTTP / SIEM forwarder) — the package exposes the
AuditStorageAdaptercontract from@suaveplan/audit-logging; siblings ship the concrete sinks. - Hash-chain verification CLI / scheduler-driven retention —
auditService.verifyChainIntegrity/applyRetentionPolicyare exposed; orchestrating them on a cron belongs in@suaveplan/job-schedulerconsumers, not this plugin. - Frontend UI — backend only.
- PII pseudonymisation via
@suaveplan/data-maskingMaskRules in the request path — the redactor uses a fast[REDACTED]substitution for the request-audit path; full mask-rule machinery is exposed for handler-levelc.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 presencesrc/plugin/on-init.test.ts— lifecycle: service resolution, sink wiring, middleware installsrc/middleware/audit-middleware.test.ts— per-request behavior: capture, redact, sample, exclude, never-throwsrc/services/audit-service.test.ts— log/query/export/retention proxiessrc/services/audit-service.spec-conformance.test.ts— ServiceRegistry slot typingsrc/context/audit-context.test.ts—c.get("audit").log(...)src/redact/redact.test.ts— case-insensitive headers, query, body fieldssrc/schemas/options.test.ts— Zod boundariessrc/otel/instruments.test.ts— counters/histogram emitsrc/api-plugin-audit.spec-conformance.test.ts— everyAUDIT_PLUGIN_*code thrown from prodtests/integration/full-boot.test.ts— real api + plugin + sample route → sink receives event with redacted secrets, principal/tenant/status/duration correcttests/integration/sampling.test.ts— 50% sampling over 1000 requests in[400, 600]tests/dep-graph.test.ts— every.tshas 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.