179 lines
13 KiB
Markdown
179 lines
13 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec Delta: api-plugin-audit (add-api-plugin-audit)
|
|
|
|
**Package**: `@suaveplan/api-plugin-audit`
|
|
**Status**: PROPOSED
|
|
|
|
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: createAuditPlugin returns a Zod-validated PluginDescriptor
|
|
|
|
The package SHALL export `createAuditPlugin(options): PluginDescriptor<AuditPluginConfig>` that validates `options` against `AuditPluginOptionsSchema` (Zod) at the factory boundary. Invalid input SHALL raise `AuditPluginError(AUDIT_PLUGIN_INVALID_INPUT)` carrying the `ZodError` as `cause`. The descriptor SHALL declare `name: "audit"`, `version: "0.1.0"`, resolved `defaults`, and at minimum `onInit` and `onClose` lifecycle hooks.
|
|
|
|
#### Scenario: factory produces a valid descriptor
|
|
|
|
- **WHEN** `createAuditPlugin({ secretKey: "x".repeat(32) })` is called with defaults
|
|
- **THEN** the returned descriptor has `name === "audit"`, `onInit` and `onClose` are functions
|
|
|
|
#### Scenario: invalid options raise AUDIT_PLUGIN_INVALID_INPUT
|
|
|
|
- **WHEN** `createAuditPlugin({ samplingRate: 2 } as never)` is called (samplingRate out of `[0,1]`)
|
|
- **THEN** `AuditPluginError(AUDIT_PLUGIN_INVALID_INPUT)` is thrown with `cause instanceof ZodError`
|
|
|
|
**Verified by:** `src/plugin/plugin.test.ts::"createAuditPlugin returns valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise AUDIT_PLUGIN_INVALID_INPUT"`
|
|
|
|
---
|
|
|
|
### Requirement: onInit wires the sink, registers auditService, installs middleware
|
|
|
|
`onInit(ctx)` SHALL resolve the host `app` (OpenAPIHono) from `ServiceRegistry`. When `app` is absent, it SHALL throw `AuditPluginError(AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE)` listing `"app"` in `context.missing`. When `app` is present, it SHALL: (a) construct an `AuditLogger` using the supplied (or default in-memory) `sink`, secretKey, and chain-integrity flag; failures wrap the cause as `AuditPluginError(AUDIT_PLUGIN_SINK_INIT_FAILED)`; (b) register `auditService` in `ServiceRegistry`; (c) install a single `app.use("*", auditMiddleware)` that records one audit event per non-excluded request.
|
|
|
|
#### Scenario: missing app service raises AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE
|
|
|
|
- **WHEN** `onInit` runs against a context whose ServiceRegistry has no `app`
|
|
- **THEN** `AuditPluginError(AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE)` is thrown with `context.missing` containing `"app"`
|
|
|
|
#### Scenario: onInit registers auditService and installs middleware
|
|
|
|
- **WHEN** `onInit` runs with `app` present and a `MemoryAuditAdapter` sink
|
|
- **THEN** `ctx.getService("auditService")` returns a non-undefined service AND a subsequent request through `app.fetch(new Request(...))` results in exactly one audit event saved to the sink
|
|
|
|
**Verified by:** `src/plugin/on-init.test.ts::"raises AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE when app missing"` AND `src/plugin/on-init.test.ts::"registers auditService and installs middleware"` AND `src/plugin/on-init.test.ts::"sink construction failure raises AUDIT_PLUGIN_SINK_INIT_FAILED"`
|
|
|
|
---
|
|
|
|
### Requirement: Global middleware records request audit per non-excluded request
|
|
|
|
The package SHALL export `createAuditMiddleware(deps)` returning a Hono middleware that, for every request whose path is NOT in `excludePaths` AND that passes the `samplingRate` head-end gate: captures `method`, `path`, `request_id` (from `c.req.header("x-request-id")` or `c.get("requestId")` or a generated ULID-like id), redacted `query`, redacted `headers`, optionally redacted body when `recordRequestBody === true`; awaits `next()`; records `status` (from `c.res.status`), `duration_ms`, `response_size` (`Content-Length` header when present and `recordResponseSize === true`), `principal_id` (`c.get("user")?.id`), `tenant_id` (`c.get("tenant")?.id`), and a severity derived from the status (`< 400 → info`, `< 500 → warning`, otherwise `error`). The middleware SHALL never throw into the request path — sink write failures SHALL log at warn AND increment `audit.sink_failures_total` without disturbing the response.
|
|
|
|
#### Scenario: records a single event per request with principal + tenant
|
|
|
|
- **WHEN** a request with `c.set("user", { id: "u1" })` and `c.set("tenant", { id: "t1" })` reaches a handler that returns 200
|
|
- **THEN** exactly one audit event is saved with `actor.userId === "u1"`, `tenantId === "t1"`, `details.method`, `details.path`, `details.status === 200`, `details.duration_ms` is a number, `severity === info`
|
|
|
|
#### Scenario: redacts sensitive query, headers, and (when enabled) body
|
|
|
|
- **WHEN** a request carries `Authorization: Bearer secret`, query `?token=abc&safe=v`, and `recordRequestBody: true` with body `{ password: "x", email: "a@b" }`
|
|
- **THEN** the saved event's `details.headers.authorization === "[REDACTED]"`, `details.query.token === "[REDACTED]"`, `details.query.safe === "v"`, `details.body.password === "[REDACTED]"`, `details.body.email === "a@b"`
|
|
|
|
#### Scenario: excludePaths skip recording
|
|
|
|
- **WHEN** a request to `/health` arrives with default `excludePaths`
|
|
- **THEN** no event is saved AND `audit.requests_excluded_total` increments by 1
|
|
|
|
#### Scenario: never throws when sink fails
|
|
|
|
- **WHEN** the configured sink rejects in `.save(...)`
|
|
- **THEN** the request still completes with the handler's response AND `audit.sink_failures_total` increments AND the logger receives a `warn` message
|
|
|
|
#### Scenario: severity classification by status
|
|
|
|
- **WHEN** the handler returns 404
|
|
- **THEN** the saved event's `severity === warning`
|
|
|
|
- **WHEN** the handler returns 500
|
|
- **THEN** the saved event's `severity === error`
|
|
|
|
**Verified by:** `src/middleware/audit-middleware.test.ts::"records a single event per request with principal and tenant"` AND `src/middleware/audit-middleware.test.ts::"redacts sensitive query headers and body"` AND `src/middleware/audit-middleware.test.ts::"excludePaths skip recording"` AND `src/middleware/audit-middleware.test.ts::"never throws when sink fails"` AND `src/middleware/audit-middleware.test.ts::"severity classification by status"`
|
|
|
|
---
|
|
|
|
### Requirement: Sampling rate gates recording at the head end
|
|
|
|
When `samplingRate < 1`, the middleware SHALL evaluate `rng() < samplingRate` per request; on miss, no event is saved and the request still completes normally. With `samplingRate === 0.5` over 1000 requests using a deterministic RNG, the saved-event count SHALL fall in `[400, 600]`.
|
|
|
|
#### Scenario: 50% sampling band
|
|
|
|
- **WHEN** 1000 requests flow through a plugin configured with `samplingRate: 0.5`
|
|
- **THEN** the sink contains between 400 and 600 events inclusive
|
|
|
|
**Verified by:** `tests/integration/sampling.test.ts::"50% sample rate over 1000 requests sits in [400, 600] band"`
|
|
|
|
---
|
|
|
|
### Requirement: auditService registered in ServiceRegistry with typed slot
|
|
|
|
`onInit` SHALL call `ctx.registerService("auditService", auditService)`. The package SHALL augment `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` with `auditService: AuditService`. The service surface SHALL expose `getLogger()`, `log(event)`, `query(opts)`, `verifyChainIntegrity(events)`, `exportGDPRData(userId, signal?)`, `applyRetentionPolicy(signal?)`.
|
|
|
|
#### Scenario: downstream plugin reads auditService with type safety
|
|
|
|
- **WHEN** another plugin's hook calls `ctx.getService("auditService")`
|
|
- **THEN** the return type is `AuditService | undefined` (no cast) AND the runtime value exposes the 6 declared methods
|
|
|
|
**Verified by:** `src/services/audit-service.spec-conformance.test.ts::"ServiceRegistry slot typed as AuditService"` AND `src/services/audit-service.test.ts::"log proxies to underlying AuditLogger"` AND `src/services/audit-service.test.ts::"query proxies to underlying AuditLogger"`
|
|
|
|
---
|
|
|
|
### Requirement: Handler-level c.get("audit") exposes log + getLogger
|
|
|
|
The package SHALL augment Hono's `ContextVariableMap` with `audit: AuditContext`. The middleware SHALL set `c.set("audit", context)` BEFORE calling `next()` so handlers can call `c.get("audit").log({ type, severity, target, details })` to record explicit, semantic events alongside the implicit request audit. Handler-level events SHALL be saved via the same sink and counted by the same OTel counter.
|
|
|
|
#### Scenario: handler logs an explicit event
|
|
|
|
- **WHEN** a handler calls `await c.get("audit").log({ type: "user.deleted", severity: AuditSeverity.WARNING, actor: { userId: "u1" }, target: { type: "user", id: "u2" } })`
|
|
- **THEN** that event appears in the sink in addition to the per-request event
|
|
|
|
**Verified by:** `src/context/audit-context.test.ts::"log writes through to the underlying AuditLogger"` AND `src/context/audit-context.test.ts::"getLogger returns the underlying AuditLogger instance"` AND `src/middleware/audit-middleware.test.ts::"installs c.get(audit) before next()"`
|
|
|
|
---
|
|
|
|
### Requirement: Redaction helper handles query, headers (case-insensitive), and body
|
|
|
|
The package SHALL export `redactPayload(payload, rules)` returning a shallow-redacted clone where header-name matches are case-insensitive, query and body field-name matches are case-sensitive, and matched values are replaced with the literal string `"[REDACTED]"`. Defaults SHALL cover `authorization`, `cookie`, `set-cookie`, `x-api-key` (headers); `token`, `code`, `state` (query); `password`, `secret`, `token` (body).
|
|
|
|
#### Scenario: case-insensitive header redaction
|
|
|
|
- **WHEN** `redactPayload({ headers: { Authorization: "Bearer x", "X-Api-Key": "k" } }, defaultRules)` is called
|
|
- **THEN** both values are `"[REDACTED]"`
|
|
|
|
#### Scenario: query / body redaction by exact field name
|
|
|
|
- **WHEN** `redactPayload({ query: { token: "a", safe: "v" }, body: { password: "p", email: "e" } }, defaultRules)` is called
|
|
- **THEN** `query.token === "[REDACTED]"`, `query.safe === "v"`, `body.password === "[REDACTED]"`, `body.email === "e"`
|
|
|
|
**Verified by:** `src/redact/redact.test.ts::"case-insensitive header redaction"` AND `src/redact/redact.test.ts::"query and body redaction by exact field name"` AND `src/redact/redact.test.ts::"default rules cover the canonical secret-bearing names"`
|
|
|
|
---
|
|
|
|
### Requirement: Every AUDIT_PLUGIN_* code is thrown from a production path
|
|
|
|
`AUDIT_PLUGIN_CODES` SHALL define exactly four codes (`INVALID_INPUT`, `SERVICE_NOT_AVAILABLE`, `SINK_INIT_FAILED`, `SINK_WRITE_FAILED`). Every code SHALL be raised by at least one production call site.
|
|
|
|
**Verified by:** `src/api-plugin-audit.spec-conformance.test.ts::"every AUDIT_PLUGIN_* code is thrown from a production path"`
|
|
|
|
---
|
|
|
|
### Requirement: OpenTelemetry instruments emit per middleware operation
|
|
|
|
The package SHALL register: counter `audit.requests_recorded_total{outcome,sampled}`, counter `audit.requests_excluded_total`, counter `audit.sink_failures_total`, histogram `audit.middleware_duration_ms`. Every middleware execution SHALL emit either the recorded counter (with the `sampled` flag) or the excluded counter, plus the histogram observation.
|
|
|
|
#### Scenario: counter and histogram emit per recorded request
|
|
|
|
- **WHEN** a request flows through the middleware against a real in-memory `MeterProvider`
|
|
- **THEN** `audit.requests_recorded_total{outcome="success",sampled="true"}` has at least one observation AND `audit.middleware_duration_ms` has at least one observation
|
|
|
|
#### Scenario: excluded counter increments for health checks
|
|
|
|
- **WHEN** a request to `/health` flows through the middleware
|
|
- **THEN** `audit.requests_excluded_total` has at least one observation AND `audit.requests_recorded_total` does not increment for that request
|
|
|
|
**Verified by:** `src/otel/instruments.test.ts::"counter and histogram emit per recorded request"` AND `src/otel/instruments.test.ts::"excluded counter increments for health checks"` AND `src/otel/instruments.test.ts::"sink failures counter increments on sink rejection"`
|
|
|
|
---
|
|
|
|
### Requirement: Server-tier package shape, full plug-and-play
|
|
|
|
The package SHALL run in Bun + Node. `package.json` SHALL declare subpath exports per feature module (10 subpaths). Every implementation `.ts` SHALL have a sibling `.md` ≥200 words. No `export *` in feature barrels. 100/100/100/100 coverage. An integration test SHALL exercise: real `@suaveplan/api`-style host + this plugin + a sample route, asserting (a) the sink receives an audit event with redacted secrets, (b) `principal_id`, `tenant_id`, `status`, `duration_ms` are populated correctly, (c) explicit `c.get("audit").log(...)` flows through.
|
|
|
|
#### Scenario: full integration boot
|
|
|
|
- **WHEN** `tests/integration/full-boot.test.ts` boots a real `OpenAPIHono` + `PluginRegistry` + `createAuditPlugin({ sink: memoryAdapter, secretKey, redactQueryParams: ["token"] })` + a sample route that sets `user`/`tenant` and calls `c.get("audit").log(...)`
|
|
- **THEN** the memory sink contains the implicit request event AND the explicit handler event, with `details.query.token === "[REDACTED]"`, `actor.userId === "u1"`, `tenantId === "t1"`, `details.status === 200`, `details.duration_ms >= 0`
|
|
|
|
**Verified by:** `tests/integration/full-boot.test.ts::"real api + plugin + sample route records redacted audit with principal and tenant"` AND `tests/dep-graph.test.ts::"every subpath export resolves"` AND `tests/dep-graph.test.ts::"every impl .ts has a sibling .md"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|