Initial Commit
This commit is contained in:
88
packages/api-stack/api/api-plugin-audit/README.md
Normal file
88
packages/api-stack/api/api-plugin-audit/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# @suaveplan/api-plugin-audit - Hono plugin wrapping @suaveplan/audit-logging as a write-through audit trail for @suaveplan/api. Installs a global middleware recording method/path/status/duration/response-size/request-id/principal-id/tenant-id per request, exposes auditService in ServiceRegistry, and surfaces c.get("audit") for handler-level events. Configurable redaction, sampling, and exclusion. Sink is caller-supplied (any AuditStorageAdapter); default is in-memory for dev. Server-tier — Bun + Node.
|
||||
|
||||
Hono plugin that wraps `@suaveplan/audit-logging` as a write-through audit trail for `@suaveplan/api`. Installs a global per-request audit middleware (method / path / status / duration / response size / request id / principal id / tenant id), exposes `auditService` in the host `ServiceRegistry`, and surfaces `c.get("audit")` for handler-level semantic events. Sink is caller-supplied; default is an in-memory adapter for development.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [audit-context](./src/context/audit-context.md) — Builds the per-request `AuditContext` handle the audit middleware attaches to `c.set("audit", context)` before invoking…
|
||||
- [context-augmentation](./src/context/context-augmentation.md) — TypeScript module augmentation that adds the `audit: AuditContext` slot to Hono's `ContextVariableMap`. Without it, `c.…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/api-plugin-audit` and the concrete `AuditPluginError` class used…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/api-plugin-audit` and the concrete `AuditPluginError` class used…
|
||||
- [middleware](./src/middleware/audit-middleware.md) — Builds the global Hono middleware that records one audit event per non-excluded request through the underlying `AuditLo…
|
||||
- [audit-middleware](./src/middleware/audit-middleware.md) — Builds the global Hono middleware that records one audit event per non-excluded request through the underlying `AuditLo…
|
||||
- [otel](./src/otel/instruments.md) — Owns the OpenTelemetry instrument bundle the audit plugin emits to every request lifecycle. Resolving the instruments l…
|
||||
- [instruments](./src/otel/instruments.md) — Owns the OpenTelemetry instrument bundle the audit plugin emits to every request lifecycle. Resolving the instruments l…
|
||||
- [plugin](./src/plugin/plugin.md) — Top-level factory for the audit plugin. `createAuditPlugin(options)` Zod-validates the caller's options at the boundary…
|
||||
- [plugin](./src/plugin/plugin.md) — Top-level factory for the audit plugin. `createAuditPlugin(options)` Zod-validates the caller's options at the boundary…
|
||||
- [redact](./src/redact/redact.md) — Lightweight, allocation-free-ish payload redactor used by the audit middleware to scrub secret-bearing fields from per-…
|
||||
- [redact](./src/redact/redact.md) — Lightweight, allocation-free-ish payload redactor used by the audit middleware to scrub secret-bearing fields from per-…
|
||||
- [schemas](./src/schemas/options.md) — Zod schema that validates `AuditPluginOptions` at the `createAuditPlugin` factory boundary and produces the resolved `A…
|
||||
- [options](./src/schemas/options.md) — Zod schema that validates `AuditPluginOptions` at the `createAuditPlugin` factory boundary and produces the resolved `A…
|
||||
- [audit-service](./src/services/audit-service.md) — Constructs the `AuditService` adapter the plugin registers in `ServiceRegistry` under the `auditService` slot. The adap…
|
||||
- [registry-augmentation](./src/services/registry-augmentation.md) — Declares the `auditService` slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface via TypeScri…
|
||||
- [types](./src/types/types.md) — Central, runtime-free declaration of the interfaces the plugin's lifecycle, middleware, services, and context helpers d…
|
||||
- [types](./src/types/types.md) — Central, runtime-free declaration of the interfaces the plugin's lifecycle, middleware, services, and context helpers d…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-plugin-audit
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { MemoryAuditAdapter } from "@suaveplan/audit-logging";
|
||||
import { createAuditPlugin } from "@suaveplan/api-plugin-audit";
|
||||
|
||||
const plugin = createAuditPlugin({
|
||||
sink: new MemoryAuditAdapter(),
|
||||
secretKey: process.env.AUDIT_SECRET_KEY,
|
||||
samplingRate: 1, // record every request
|
||||
excludePaths: ["/health", "/openapi.json"],
|
||||
});
|
||||
|
||||
// Register on your @suaveplan/api PluginRegistry alongside auth/tenant/etc.
|
||||
```
|
||||
|
||||
Inside a route handler:
|
||||
|
||||
```ts
|
||||
app.post("/users/:id/delete", async (c) => {
|
||||
const audit = c.get("audit");
|
||||
await audit.log({
|
||||
type: "user.deleted",
|
||||
severity: "warning",
|
||||
actor: { userId: c.get("user")?.id },
|
||||
target: { type: "user", id: c.req.param("id") },
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [plugin/plugin](./src/plugin/plugin.md) — `createAuditPlugin` factory.
|
||||
- [middleware/audit-middleware](./src/middleware/audit-middleware.md) — per-request middleware.
|
||||
- [services/audit-service](./src/services/audit-service.md) — `auditService` adapter.
|
||||
- [services/registry-augmentation](./src/services/registry-augmentation.md) — `ServiceRegistry` slot.
|
||||
- [context/audit-context](./src/context/audit-context.md) — handler-level `c.get("audit")`.
|
||||
- [context/context-augmentation](./src/context/context-augmentation.md) — Hono `ContextVariableMap` slot.
|
||||
- [redact/redact](./src/redact/redact.md) — payload redactor.
|
||||
- [otel/instruments](./src/otel/instruments.md) — OpenTelemetry counters + histogram.
|
||||
- [schemas/options](./src/schemas/options.md) — Zod options schema + defaults.
|
||||
- [error-codes/error-codes](./src/error-codes/error-codes.md) — `AUDIT_PLUGIN_*` registry.
|
||||
|
||||
## Features
|
||||
|
||||
- One audit event per non-excluded request, recorded through the underlying `AuditLogger` (chain-integrity, GDPR export, retention all inherited).
|
||||
- Configurable redaction (case-insensitive headers, exact query / body field names).
|
||||
- Head-end sampling gate with caller-supplied (or default `Math.random`) RNG.
|
||||
- Excluded paths skip recording but still install the `audit` context slot.
|
||||
- Middleware NEVER throws into the request response — sink failures are logged at warn and counted via `audit.sink_failures_total`.
|
||||
- `auditService` registered with `ctx.registerService("auditService", ...)` and exposed through the typed `ServiceRegistry` slot.
|
||||
- Hono `ContextVariableMap` augmented with `audit: AuditContext` so handlers fire explicit events without import boilerplate.
|
||||
- Four OpenTelemetry instruments: `audit.requests_recorded_total{outcome,sampled}`, `audit.requests_excluded_total`, `audit.sink_failures_total`, `audit.middleware_duration_ms`.
|
||||
|
||||
118
packages/api-stack/api/api-plugin-audit/package.json
Normal file
118
packages/api-stack/api/api-plugin-audit/package.json
Normal file
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"name": "@suaveplan/api-plugin-audit",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Hono plugin wrapping @suaveplan/audit-logging as a write-through audit trail for @suaveplan/api. Installs a global middleware recording method/path/status/duration/response-size/request-id/principal-id/tenant-id per request, exposes auditService in ServiceRegistry, and surfaces c.get(\"audit\") for handler-level events. Configurable redaction, sampling, and exclusion. Sink is caller-supplied (any AuditStorageAdapter); default is in-memory for dev. Server-tier — Bun + Node.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./plugin/plugin": {
|
||||
"import": {
|
||||
"types": "./dist/plugin/plugin.d.ts",
|
||||
"default": "./dist/plugin/plugin.js"
|
||||
}
|
||||
},
|
||||
"./middleware/audit-middleware": {
|
||||
"import": {
|
||||
"types": "./dist/middleware/audit-middleware.d.ts",
|
||||
"default": "./dist/middleware/audit-middleware.js"
|
||||
}
|
||||
},
|
||||
"./services/audit-service": {
|
||||
"import": {
|
||||
"types": "./dist/services/audit-service.d.ts",
|
||||
"default": "./dist/services/audit-service.js"
|
||||
}
|
||||
},
|
||||
"./context/audit-context": {
|
||||
"import": {
|
||||
"types": "./dist/context/audit-context.d.ts",
|
||||
"default": "./dist/context/audit-context.js"
|
||||
}
|
||||
},
|
||||
"./redact/redact": {
|
||||
"import": {
|
||||
"types": "./dist/redact/redact.d.ts",
|
||||
"default": "./dist/redact/redact.js"
|
||||
}
|
||||
},
|
||||
"./schemas/options": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/options.d.ts",
|
||||
"default": "./dist/schemas/options.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./otel/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
},
|
||||
"./types/types": {
|
||||
"import": {
|
||||
"types": "./dist/types/types.d.ts",
|
||||
"default": "./dist/types/types.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-plugin": "workspace:^",
|
||||
"@suaveplan/audit-logging": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"hono": "catalog:",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hono/zod-openapi": "catalog:server",
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUDIT_PLUGIN_CODES } from "./error-codes/error-codes.js";
|
||||
|
||||
const SRC = join(__dirname);
|
||||
|
||||
const SRC_FILES_CONTAINING_RAISES: readonly string[] = [
|
||||
"plugin/plugin.ts",
|
||||
"middleware/audit-middleware.ts",
|
||||
];
|
||||
|
||||
function readSrc(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
describe("api-plugin-audit spec conformance", () => {
|
||||
it("every AUDIT_PLUGIN_* code is thrown from a production path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codes = Object.values(AUDIT_PLUGIN_CODES);
|
||||
const corpus = SRC_FILES_CONTAINING_RAISES.map(readSrc).join("\n");
|
||||
for (const code of codes) {
|
||||
// Each code MUST appear in a `new AuditPluginError(...)` call
|
||||
// site OR be produced via `AUDIT_PLUGIN_CODES.<NAME>`.
|
||||
const short = code.replace(/^AUDIT_PLUGIN\./, "");
|
||||
expect(
|
||||
corpus.includes(`AUDIT_PLUGIN_CODES.${short}`),
|
||||
`code ${code} not raised in any production source file`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers exactly four error codes", { timeout: 5000 }, () => {
|
||||
const codes = Object.keys(AUDIT_PLUGIN_CODES).sort();
|
||||
expect(codes).toEqual([
|
||||
"INVALID_INPUT",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"SINK_INIT_FAILED",
|
||||
"SINK_WRITE_FAILED",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
# context/audit-context
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds the per-request `AuditContext` handle the audit middleware attaches to `c.set("audit", context)` before invoking `next()`. Handlers call `c.get("audit").log({...})` to fire explicit, semantic audit events alongside the implicit per-request audit that the middleware writes. This is the public extension point for application code — instead of importing `auditService` and threading it through every route, handlers reach into the Hono context.
|
||||
|
||||
The two-method surface (`log` + `getLogger`) is intentionally small so handler code stays focused on the event payload rather than the plumbing. The same underlying `AuditLogger` instance is reused across every request — there is one logger per app, not one per request — so chain-integrity links hold across handler-level and middleware-level events.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable two-method surface (`log`, `getLogger`).
|
||||
- Hono `ContextVariableMap` augmentation declared in `./context-augmentation.ts` so `c.get("audit")` types as `AuditContext` (not `unknown`).
|
||||
- Zero per-request allocation beyond the lightweight wrapper object (no closures over the request, so the object can be cached if needed).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// In a route handler:
|
||||
app.post("/users/:id/delete", async (c) => {
|
||||
const audit = c.get("audit");
|
||||
await audit.log({
|
||||
type: "user.deleted",
|
||||
severity: "warning",
|
||||
actor: { userId: c.get("user")?.id },
|
||||
target: { type: "user", id: c.req.param("id") },
|
||||
});
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Reach the underlying logger for advanced ops:
|
||||
app.get("/admin/audit-chain/verify", async (c) => {
|
||||
const logger = c.get("audit").getLogger();
|
||||
const events = await logger.query({ limit: 1000, order: "asc" });
|
||||
const verdict = await logger.verifyChainIntegrity(events);
|
||||
return c.json(verdict);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createAuditContext(deps: { logger: AuditLogger }): AuditContext`
|
||||
- `log(event)` → `Promise<AuditEvent>`
|
||||
- `getLogger()` → `AuditLogger`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`AuditContext` does not carry the request, the principal, or the tenant. Handlers fill `actor.userId`, `actor.sessionId`, `tenantId`, and so on from `c.get("user")` / `c.get("tenant")` themselves. The middleware's per-request event populates those fields automatically; handler-level events stay explicit because the handler knows the semantic of the event (which user is the actor versus the target) better than the middleware does.
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
AuditLogger,
|
||||
AuditSeverity,
|
||||
MemoryAuditAdapter,
|
||||
} from "@suaveplan/audit-logging";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createAuditContext } from "./audit-context.js";
|
||||
|
||||
describe("createAuditContext", () => {
|
||||
it("log writes through to the underlying AuditLogger", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const logger = new AuditLogger({
|
||||
storage,
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
const ctx = createAuditContext({ logger });
|
||||
await ctx.log({
|
||||
type: "thing.created",
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
});
|
||||
expect(storage.snapshot()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("getLogger returns the underlying AuditLogger instance", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const logger = new AuditLogger({
|
||||
storage: new MemoryAuditAdapter(),
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
const ctx = createAuditContext({ logger });
|
||||
expect(ctx.getLogger()).toBe(logger);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AuditEvent, AuditLogger } from "@suaveplan/audit-logging";
|
||||
import type { AuditContext } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Creates an `AuditContext` bound to the given underlying logger.
|
||||
* The middleware sets this on `c.set("audit", context)` before
|
||||
* calling `next()` so handlers can fire explicit, semantic events
|
||||
* alongside the implicit per-request audit.
|
||||
*/
|
||||
export function createAuditContext(deps: {
|
||||
logger: AuditLogger;
|
||||
}): AuditContext {
|
||||
const { logger } = deps;
|
||||
return {
|
||||
getLogger(): AuditLogger {
|
||||
return logger;
|
||||
},
|
||||
log(
|
||||
event: Omit<
|
||||
AuditEvent,
|
||||
"id" | "timestamp" | "hash" | "previousHash"
|
||||
>
|
||||
): Promise<AuditEvent> {
|
||||
return logger.log(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# context/context-augmentation
|
||||
|
||||
## Purpose
|
||||
|
||||
TypeScript module augmentation that adds the `audit: AuditContext` slot to Hono's `ContextVariableMap`. Without it, `c.get("audit")` types as `unknown` and every handler call site needs an `as AuditContext` cast — defeating the point of the per-request helper. Importing the package barrel of `@suaveplan/api-plugin-audit` pulls this file in as a side effect, so handlers and downstream middlewares see the typed slot automatically.
|
||||
|
||||
The augmentation is intentionally narrow: it declares only `audit`. The `user`, `tenant`, and `requestId` slots that the middleware reads are owned by sibling plugins (`@suaveplan/api-plugin-auth`, `@suaveplan/api-tenant`, `@suaveplan/api`). TypeScript merges declarations across modules, so coexistence is safe — every plugin contributes its own slot to the same map.
|
||||
|
||||
## Features
|
||||
|
||||
- Declares `audit: AuditContext` on Hono's `ContextVariableMap`.
|
||||
- Zero runtime cost — pure TypeScript.
|
||||
- Side-effect imported by the package barrel.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// Anywhere in the consuming app:
|
||||
import "@suaveplan/api-plugin-audit";
|
||||
|
||||
app.post("/things", async (c) => {
|
||||
const audit = c.get("audit"); // typed AuditContext
|
||||
await audit.log({ type: "thing.created", severity: "info", actor: {} });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// In a handler that combines `user` (auth plugin) and `audit`:
|
||||
import "@suaveplan/api-plugin-auth";
|
||||
import "@suaveplan/api-plugin-audit";
|
||||
|
||||
app.delete("/vaults/:id", async (c) => {
|
||||
const principal = c.get("user");
|
||||
const audit = c.get("audit");
|
||||
if (principal === null) return c.text("unauthenticated", 401);
|
||||
|
||||
await audit.log({
|
||||
type: "vault.deleted",
|
||||
severity: "warning",
|
||||
actor: { userId: principal.id },
|
||||
target: { type: "vault", id: c.req.param("id") },
|
||||
});
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This file contributes one TypeScript `declare module "hono"` block adding:
|
||||
|
||||
- `audit: AuditContext` on `interface ContextVariableMap`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The augmentation declares `audit` as a non-optional slot. The middleware always sets it before calling `next()` (even for excluded paths — the slot is populated so handlers that opt into auditing on a normally-excluded path can still call `c.get("audit").log(...)`). If you mount the plugin AFTER user handlers in your middleware chain, `c.get("audit")` will be `undefined` at runtime even though TypeScript types it as `AuditContext`. The recommended fix is to register the audit plugin first; the `definePlugin` priority field can enforce this.
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Hono `ContextVariableMap` augmentation — declares the `audit` slot
|
||||
* populated by the per-request audit middleware. Handlers read
|
||||
* `c.get("audit")` typed as `AuditContext` thanks to this declaration;
|
||||
* without it the value would type as `unknown`.
|
||||
*
|
||||
* The `user`, `tenant`, and `requestId` slots are declared as
|
||||
* `unknown`-tolerant optionals because this package consumes them
|
||||
* (read-only) without owning their precise shape. The auth plugin
|
||||
* narrows `user` to `Principal | null`; the tenant plugin narrows
|
||||
* `tenant`. TypeScript merges duplicate declarations, so coexistence
|
||||
* with those plugins is safe.
|
||||
*/
|
||||
import type { AuditContext } from "../types/types.js";
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
audit: AuditContext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# error-codes/error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable error-code registry for `@suaveplan/api-plugin-audit` and the concrete `AuditPluginError` class used by every production code path. The registry is shared with `@suaveplan/error`'s singleton via `defineCodes("AUDIT_PLUGIN", [...])`, so every code surfaces in the unified `ErrorCodes` table consumed by logging, metrics, and translation layers.
|
||||
|
||||
Every code documented here corresponds to a real failure mode the plugin can surface at the call site. Consumers branch on the string code (`error.code === AUDIT_PLUGIN_CODES.INVALID_INPUT`) rather than `instanceof` checks against private subclasses.
|
||||
|
||||
## Features
|
||||
|
||||
- Four stable codes: `INVALID_INPUT`, `SERVICE_NOT_AVAILABLE`, `SINK_INIT_FAILED`, `SINK_WRITE_FAILED`.
|
||||
- `AuditPluginError` carries `cause`, `context`, and the `code` discriminant via the `SuaveplanError` base.
|
||||
- Every code is raised by at least one production code path and pinned by `src/api-plugin-audit.spec-conformance.test.ts`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIT_PLUGIN_CODES,
|
||||
AuditPluginError,
|
||||
} from "@suaveplan/api-plugin-audit/error-codes/error-codes";
|
||||
|
||||
throw new AuditPluginError(
|
||||
AUDIT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"audit plugin requires the `app` service in ServiceRegistry",
|
||||
{ context: { missing: ["app"], statusCode: 500 } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIT_PLUGIN_CODES,
|
||||
AuditPluginError,
|
||||
} from "@suaveplan/api-plugin-audit/error-codes/error-codes";
|
||||
|
||||
try {
|
||||
await sink.save(event);
|
||||
} catch (cause) {
|
||||
const err = new AuditPluginError(
|
||||
AUDIT_PLUGIN_CODES.SINK_WRITE_FAILED,
|
||||
"audit sink rejected save()",
|
||||
{ cause, context: { eventId: event.id } }
|
||||
);
|
||||
logger.warn(err.message, { code: err.code, cause: err.cause });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AUDIT_PLUGIN_CODES` — frozen record from `defineCodes("AUDIT_PLUGIN", [...])`.
|
||||
- `AuditPluginErrorCode` — string-literal union of the registered codes.
|
||||
- `class AuditPluginError extends SuaveplanError<AuditPluginErrorCode>` — public constructor accepts `(code, message, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is small on purpose. Validation failures map to `INVALID_INPUT`; structural prerequisite failures map to `SERVICE_NOT_AVAILABLE`; sink construction failures map to `SINK_INIT_FAILED`; per-request sink rejections map to `SINK_WRITE_FAILED`. The middleware path never re-throws `SINK_WRITE_FAILED` — it logs and counts so the request response is unaffected (telemetry must not 500 the caller).
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-plugin-audit`.
|
||||
*
|
||||
* - `INVALID_INPUT` — `createAuditPlugin(options)` received options that
|
||||
* failed `AuditPluginOptionsSchema` validation. `cause` holds the
|
||||
* `ZodError`.
|
||||
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot (e.g.
|
||||
* `app`) was absent during `onInit`. `context.missing` lists the
|
||||
* missing slot names.
|
||||
* - `SINK_INIT_FAILED` — constructing the underlying `AuditLogger`
|
||||
* threw (e.g. missing secret key when chain-integrity is enabled).
|
||||
* - `SINK_WRITE_FAILED` — the configured sink rejected `.save(...)` from
|
||||
* inside the per-request middleware. The middleware never propagates
|
||||
* this to the request response; the error is reported through this
|
||||
* code via the logger and the `audit.sink_failures_total` counter.
|
||||
*/
|
||||
export const AUDIT_PLUGIN_CODES = defineCodes("AUDIT_PLUGIN", [
|
||||
"INVALID_INPUT",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"SINK_INIT_FAILED",
|
||||
"SINK_WRITE_FAILED",
|
||||
] as const);
|
||||
|
||||
export type AuditPluginErrorCode =
|
||||
(typeof AUDIT_PLUGIN_CODES)[keyof typeof AUDIT_PLUGIN_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class. Extends {@link SuaveplanError} so
|
||||
* `instanceof AuditPluginError` narrows `code` to
|
||||
* {@link AuditPluginErrorCode}.
|
||||
*/
|
||||
export class AuditPluginError extends SuaveplanError<AuditPluginErrorCode> {
|
||||
public readonly auditPluginCode: AuditPluginErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: AuditPluginErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.auditPluginCode = code;
|
||||
}
|
||||
}
|
||||
51
packages/api-stack/api/api-plugin-audit/src/index.ts
Normal file
51
packages/api-stack/api/api-plugin-audit/src/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Side-effect imports — surface the registry + context augmentations
|
||||
// to every consumer importing the package barrel.
|
||||
import "./services/registry-augmentation.js";
|
||||
import "./context/context-augmentation.js";
|
||||
|
||||
// Context
|
||||
export { createAuditContext } from "./context/audit-context.js";
|
||||
// Error codes
|
||||
export {
|
||||
AUDIT_PLUGIN_CODES,
|
||||
AuditPluginError,
|
||||
type AuditPluginErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// Middleware
|
||||
export { createAuditMiddleware } from "./middleware/audit-middleware.js";
|
||||
// OTel
|
||||
export {
|
||||
type AuditPluginInstruments,
|
||||
getAuditPluginInstruments,
|
||||
resetAuditPluginInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
// Plugin factory
|
||||
export { createAuditPlugin } from "./plugin/plugin.js";
|
||||
// Redaction
|
||||
export {
|
||||
DEFAULT_REDACTION_RULES,
|
||||
REDACTED,
|
||||
redactPayload,
|
||||
} from "./redact/redact.js";
|
||||
// Schemas
|
||||
export {
|
||||
type AuditPluginOptionsInput,
|
||||
type AuditPluginOptionsResolved,
|
||||
AuditPluginOptionsSchema,
|
||||
DEFAULT_EXCLUDE_PATHS,
|
||||
DEFAULT_REDACT_BODY,
|
||||
DEFAULT_REDACT_HEADERS,
|
||||
DEFAULT_REDACT_QUERY,
|
||||
DEV_DEFAULT_SECRET_KEY,
|
||||
} from "./schemas/options.js";
|
||||
// Service
|
||||
export { createAuditService } from "./services/audit-service.js";
|
||||
// Types
|
||||
export type {
|
||||
AuditContext,
|
||||
AuditPluginConfig,
|
||||
AuditPluginOptions,
|
||||
AuditService,
|
||||
RedactablePayload,
|
||||
RedactionRules,
|
||||
} from "./types/types.js";
|
||||
@@ -0,0 +1,102 @@
|
||||
# middleware/audit-middleware
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds the global Hono middleware that records one audit event per non-excluded request through the underlying `AuditLogger`. The middleware also installs the `c.set("audit", ...)` slot before invoking `next()` so handlers can fire explicit, semantic events through `c.get("audit").log(...)` regardless of whether the request itself was recorded. This file is the per-request hot path; everything else in the plugin exists to support it.
|
||||
|
||||
The middleware never throws into the request response — sink failures are caught, logged at warn, and surfaced via the `audit.sink_failures_total` counter. Telemetry must not 500 the caller.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-request event capture: method, path, status, duration (ms), request id, principal id, tenant id, response size, severity (info < 400, warning < 500, error otherwise).
|
||||
- Configurable redaction of query, headers, and (opt-in) JSON body via `redactPayload`.
|
||||
- Head-end sampling gate via `samplingRate` and caller-supplied (or default `Math.random`) RNG.
|
||||
- Exclude paths (exact match) skip recording while still installing the `audit` context slot.
|
||||
- Never throws — sink failures logged at warn, counted, propagated through `audit.requests_recorded_total{outcome="error",sampled="true"}`.
|
||||
- Re-throws any error the downstream handler raised AFTER recording the audit (so the host error-handler still runs).
|
||||
- Records four OTel observations: recorded counter (with `outcome` + `sampled` labels), excluded counter, sink-failures counter, duration histogram.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AuditLogger, MemoryAuditAdapter } from "@suaveplan/audit-logging";
|
||||
import { createAuditMiddleware } from "@suaveplan/api-plugin-audit/middleware/audit-middleware";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const app = new Hono();
|
||||
const logger = new AuditLogger({
|
||||
storage: new MemoryAuditAdapter(),
|
||||
secretKey: process.env.AUDIT_SECRET_KEY!,
|
||||
});
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
createAuditMiddleware({
|
||||
logger,
|
||||
config: {
|
||||
sink: logger.getLogger ? logger : (undefined as never),
|
||||
secretKey: "",
|
||||
samplingRate: 1,
|
||||
excludePaths: ["/health"],
|
||||
redactQueryParams: ["token"],
|
||||
redactHeaders: ["authorization"],
|
||||
redactBodyFields: ["password"],
|
||||
recordResponseSize: true,
|
||||
recordRequestBody: false,
|
||||
enableChainIntegrity: false,
|
||||
rng: Math.random,
|
||||
},
|
||||
hostLogger: console as never,
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AuditLogger, MemoryAuditAdapter } from "@suaveplan/audit-logging";
|
||||
import { createAuditMiddleware } from "@suaveplan/api-plugin-audit/middleware/audit-middleware";
|
||||
|
||||
// Deterministic sampling for tests:
|
||||
let i = 0;
|
||||
const seq = [0.1, 0.9, 0.4, 0.8];
|
||||
const rng = () => seq[i++ % seq.length] as number;
|
||||
|
||||
const middleware = createAuditMiddleware({
|
||||
logger: new AuditLogger({
|
||||
storage: new MemoryAuditAdapter(),
|
||||
secretKey: "x".repeat(32),
|
||||
}),
|
||||
config: {
|
||||
sink: new MemoryAuditAdapter(),
|
||||
secretKey: "x".repeat(32),
|
||||
samplingRate: 0.5,
|
||||
excludePaths: [],
|
||||
redactQueryParams: ["secret"],
|
||||
redactHeaders: ["authorization"],
|
||||
redactBodyFields: ["password"],
|
||||
recordResponseSize: true,
|
||||
recordRequestBody: true, // includes JSON bodies in the audit detail
|
||||
enableChainIntegrity: true,
|
||||
rng,
|
||||
},
|
||||
hostLogger: console as never,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createAuditMiddleware(deps: { logger: AuditLogger; config: AuditPluginConfig; hostLogger: LoggerLike }): MiddlewareHandler`
|
||||
- `logger` — the underlying `AuditLogger` constructed in `onInit`.
|
||||
- `config` — the resolved `AuditPluginConfig`.
|
||||
- `hostLogger` — the logger used to surface sink-write failures at warn.
|
||||
|
||||
The returned middleware is a standard Hono `MiddlewareHandler` you can register via `app.use("*", ...)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`c.set("audit", ...)` is set BEFORE the excluded-path / sampling gates so handlers on excluded paths can still emit explicit events. The middleware reads the response status from `c.res?.status` after `next()`; when the downstream handler throws, the middleware captures the throw, sets status to 500 in the audit record, then re-throws so the host error handler still produces the actual response. JSON body capture clones `c.req.raw` to avoid consuming the body the handler needs to read; non-JSON bodies are not captured (binary uploads would balloon the audit volume). Sink-write failures never propagate to the request — they update three OTel counters and a warn-level log instead.
|
||||
@@ -0,0 +1,573 @@
|
||||
import {
|
||||
type AuditEvent,
|
||||
AuditLogger,
|
||||
AuditSeverity,
|
||||
type AuditStorageAdapter,
|
||||
MemoryAuditAdapter,
|
||||
} from "@suaveplan/audit-logging";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { resetAuditPluginInstruments } from "../otel/instruments.js";
|
||||
import type { AuditPluginConfig } from "../types/types.js";
|
||||
import { createAuditMiddleware } from "./audit-middleware.js";
|
||||
|
||||
const noopHostLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function makeConfig(
|
||||
overrides: Partial<AuditPluginConfig> = {},
|
||||
sink: AuditStorageAdapter = new MemoryAuditAdapter()
|
||||
): AuditPluginConfig {
|
||||
return {
|
||||
sink,
|
||||
secretKey: "x".repeat(32),
|
||||
samplingRate: 1,
|
||||
excludePaths: ["/health"],
|
||||
redactQueryParams: ["token"],
|
||||
redactHeaders: ["authorization"],
|
||||
redactBodyFields: ["password"],
|
||||
recordResponseSize: true,
|
||||
recordRequestBody: false,
|
||||
enableChainIntegrity: false,
|
||||
rng: () => 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeApp(
|
||||
cfg: AuditPluginConfig,
|
||||
storage: AuditStorageAdapter,
|
||||
setupRoutes: (app: Hono) => void
|
||||
): Hono {
|
||||
const logger = new AuditLogger({
|
||||
storage,
|
||||
secretKey: cfg.secretKey,
|
||||
enableChainIntegrity: cfg.enableChainIntegrity,
|
||||
});
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
createAuditMiddleware({
|
||||
logger,
|
||||
config: { ...cfg, sink: storage },
|
||||
hostLogger: noopHostLogger,
|
||||
})
|
||||
);
|
||||
setupRoutes(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetAuditPluginInstruments();
|
||||
});
|
||||
|
||||
describe("createAuditMiddleware", () => {
|
||||
it("records a single event per request with principal and tenant", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({}, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u1" });
|
||||
c.set("tenant", { id: "t1" });
|
||||
await next();
|
||||
});
|
||||
a.get("/things", (c) => c.json({ ok: true }));
|
||||
});
|
||||
|
||||
const res = await app.request("/things");
|
||||
expect(res.status).toBe(200);
|
||||
const events = storage.snapshot();
|
||||
expect(events).toHaveLength(1);
|
||||
const evt = events[0] as AuditEvent;
|
||||
expect(evt.actor.userId).toBe("u1");
|
||||
expect(evt.tenantId).toBe("t1");
|
||||
expect((evt.details as { method: string }).method).toBe("GET");
|
||||
expect((evt.details as { path: string }).path).toBe("/things");
|
||||
expect((evt.details as { status: number }).status).toBe(200);
|
||||
expect(
|
||||
typeof (evt.details as { duration_ms: number }).duration_ms
|
||||
).toBe("number");
|
||||
expect(evt.severity).toBe(AuditSeverity.INFO);
|
||||
});
|
||||
|
||||
it("redacts sensitive query headers and body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({ recordRequestBody: true }, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.post("/login", async (c) => c.json({ ok: true }));
|
||||
});
|
||||
|
||||
const res = await app.request("/login?token=abc&safe=v", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer secret",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ password: "x", email: "a@b" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
const det = evt.details as {
|
||||
headers: Record<string, string>;
|
||||
query: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
};
|
||||
expect(det.headers["authorization"]).toBe("[REDACTED]");
|
||||
expect(det.query["token"]).toBe("[REDACTED]");
|
||||
expect(det.query["safe"]).toBe("v");
|
||||
expect(det.body?.["password"]).toBe("[REDACTED]");
|
||||
expect(det.body?.["email"]).toBe("a@b");
|
||||
});
|
||||
|
||||
it("excludePaths skip recording", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({}, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.get("/health", (c) => c.text("ok"));
|
||||
});
|
||||
|
||||
await app.request("/health");
|
||||
expect(storage.snapshot()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never throws when sink fails", { timeout: 5000 }, async () => {
|
||||
const failingSink: AuditStorageAdapter = {
|
||||
save: async () => {
|
||||
throw new Error("sink down");
|
||||
},
|
||||
query: async () => [],
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => 0,
|
||||
count: async () => 0,
|
||||
};
|
||||
let warnSeen = false;
|
||||
const hostLogger = {
|
||||
...noopHostLogger,
|
||||
warn: () => {
|
||||
warnSeen = true;
|
||||
},
|
||||
};
|
||||
const logger = new AuditLogger({
|
||||
storage: failingSink,
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
createAuditMiddleware({
|
||||
logger,
|
||||
config: makeConfig({}, failingSink),
|
||||
hostLogger,
|
||||
})
|
||||
);
|
||||
app.get("/things", (c) => c.json({ ok: true }));
|
||||
|
||||
const res = await app.request("/things");
|
||||
expect(res.status).toBe(200);
|
||||
expect(warnSeen).toBe(true);
|
||||
});
|
||||
|
||||
it("severity classification by status", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({}, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.get("/notfound", (c) => c.text("missing", 404));
|
||||
a.get("/boom", (c) => c.text("err", 500));
|
||||
});
|
||||
|
||||
await app.request("/notfound");
|
||||
await app.request("/boom");
|
||||
const evts = storage.snapshot();
|
||||
const not = evts.find(
|
||||
(e) => (e.details as { path: string }).path === "/notfound"
|
||||
) as AuditEvent;
|
||||
const boom = evts.find(
|
||||
(e) => (e.details as { path: string }).path === "/boom"
|
||||
) as AuditEvent;
|
||||
expect(not.severity).toBe(AuditSeverity.WARNING);
|
||||
expect(boom.severity).toBe(AuditSeverity.ERROR);
|
||||
});
|
||||
|
||||
it("sampling miss skips recording", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
// rng() returns 0.9; samplingRate 0.5 → miss
|
||||
const cfg = makeConfig({ samplingRate: 0.5, rng: () => 0.9 }, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.get("/x", (c) => c.text("ok"));
|
||||
});
|
||||
|
||||
await app.request("/x");
|
||||
expect(storage.snapshot()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("installs c.get(audit) before next()", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({}, storage);
|
||||
let seenAudit: unknown;
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.get("/check", (c) => {
|
||||
seenAudit = c.get("audit");
|
||||
return c.text("ok");
|
||||
});
|
||||
});
|
||||
await app.request("/check");
|
||||
expect(seenAudit).toBeDefined();
|
||||
const audit = seenAudit as { log: unknown; getLogger: unknown };
|
||||
expect(typeof audit.log).toBe("function");
|
||||
expect(typeof audit.getLogger).toBe("function");
|
||||
});
|
||||
|
||||
it("captures request id from x-request-id header", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({}, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.get("/x", (c) => c.text("ok"));
|
||||
});
|
||||
await app.request("/x", {
|
||||
headers: { "x-request-id": "req-123" },
|
||||
});
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
expect((evt.details as { request_id: string }).request_id).toBe(
|
||||
"req-123"
|
||||
);
|
||||
expect(evt.correlationId).toBe("req-123");
|
||||
});
|
||||
|
||||
it("captures request id from context when header absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({}, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.use("*", async (c, next) => {
|
||||
c.set("requestId", "ctx-456");
|
||||
await next();
|
||||
});
|
||||
a.get("/x", (c) => c.text("ok"));
|
||||
});
|
||||
await app.request("/x");
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
expect((evt.details as { request_id: string }).request_id).toBe(
|
||||
"ctx-456"
|
||||
);
|
||||
});
|
||||
|
||||
it("generates a request id when neither header nor context is set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({}, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.get("/x", (c) => c.text("ok"));
|
||||
});
|
||||
await app.request("/x");
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
expect(
|
||||
(evt.details as { request_id: string }).request_id.length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("records response size from content-length header", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({}, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.get("/x", (c) => {
|
||||
const body = "hello-world";
|
||||
return c.body(body, 200, {
|
||||
"Content-Length": String(body.length),
|
||||
});
|
||||
});
|
||||
});
|
||||
await app.request("/x");
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
const sz = (evt.details as { response_size?: number }).response_size;
|
||||
expect(typeof sz).toBe("number");
|
||||
expect(sz).toBe(11);
|
||||
});
|
||||
|
||||
it("omits response_size when Content-Length header is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({}, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.get("/x", (c) => c.text("ok"));
|
||||
});
|
||||
await app.request("/x");
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
expect(
|
||||
(evt.details as { response_size?: number }).response_size
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults to status 200 when no response was assigned", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({}, storage);
|
||||
const logger = new AuditLogger({
|
||||
storage,
|
||||
secretKey: cfg.secretKey,
|
||||
});
|
||||
const middleware = createAuditMiddleware({
|
||||
logger,
|
||||
config: { ...cfg, sink: storage },
|
||||
hostLogger: noopHostLogger,
|
||||
});
|
||||
const fakeCtx = {
|
||||
req: {
|
||||
url: "http://x/x",
|
||||
method: "GET",
|
||||
raw: new Request("http://x/x"),
|
||||
header: () => undefined,
|
||||
},
|
||||
res: undefined,
|
||||
_store: new Map<string, unknown>(),
|
||||
set(this: { _store: Map<string, unknown> }, k: string, v: unknown) {
|
||||
this._store.set(k, v);
|
||||
},
|
||||
get(this: { _store: Map<string, unknown> }, k: string) {
|
||||
return this._store.get(k);
|
||||
},
|
||||
} as unknown as Parameters<typeof middleware>[0];
|
||||
await middleware(fakeCtx, async () => undefined);
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
expect((evt.details as { status: number }).status).toBe(200);
|
||||
});
|
||||
|
||||
it("treats non-Error sink rejection as cause-less", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Use a real AuditLogger but override `log` so a non-Error throw
|
||||
// reaches the middleware's catch unwrapped.
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const logger = new AuditLogger({
|
||||
storage,
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
(logger as unknown as { log: () => Promise<never> }).log = async () => {
|
||||
const lit: unknown = "literal-string-rejection";
|
||||
throw lit;
|
||||
};
|
||||
let warnSeen = false;
|
||||
const hostLogger = {
|
||||
...noopHostLogger,
|
||||
warn: () => {
|
||||
warnSeen = true;
|
||||
},
|
||||
};
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
createAuditMiddleware({
|
||||
logger,
|
||||
config: makeConfig({}, storage),
|
||||
hostLogger,
|
||||
})
|
||||
);
|
||||
app.get("/x", (c) => c.text("ok"));
|
||||
const res = await app.request("/x");
|
||||
expect(res.status).toBe(200);
|
||||
expect(warnSeen).toBe(true);
|
||||
});
|
||||
|
||||
it("re-throws handler errors after recording", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({}, storage);
|
||||
const logger = new AuditLogger({
|
||||
storage,
|
||||
secretKey: cfg.secretKey,
|
||||
});
|
||||
// Invoke the middleware directly so the throw bubbles into
|
||||
// the audit middleware's try/catch (Hono's compose.js
|
||||
// intercepts errors before they reach app.fetch's caller).
|
||||
const middleware = createAuditMiddleware({
|
||||
logger,
|
||||
config: { ...cfg, sink: storage },
|
||||
hostLogger: noopHostLogger,
|
||||
});
|
||||
const fakeCtx = {
|
||||
req: {
|
||||
url: "http://x/boom",
|
||||
method: "GET",
|
||||
raw: new Request("http://x/boom"),
|
||||
header: () => undefined,
|
||||
},
|
||||
res: { status: 200, headers: new Headers() },
|
||||
// biome-safe shim — we only need set/get for the audit context
|
||||
// and requestId slots.
|
||||
_store: new Map<string, unknown>(),
|
||||
set(this: { _store: Map<string, unknown> }, k: string, v: unknown) {
|
||||
this._store.set(k, v);
|
||||
},
|
||||
get(this: { _store: Map<string, unknown> }, k: string) {
|
||||
return this._store.get(k);
|
||||
},
|
||||
} as unknown as Parameters<typeof middleware>[0];
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await middleware(fakeCtx, async () => {
|
||||
throw new Error("chain-fail");
|
||||
});
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect((thrown as Error).message).toBe("chain-fail");
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
expect((evt.details as { status: number }).status).toBe(500);
|
||||
expect(evt.severity).toBe(AuditSeverity.ERROR);
|
||||
});
|
||||
|
||||
it("ignores body capture when content-type is not JSON", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({ recordRequestBody: true }, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.post("/x", (c) => c.text("ok"));
|
||||
});
|
||||
await app.request("/x", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: "raw-body",
|
||||
});
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
expect((evt.details as { body?: unknown }).body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores malformed JSON body", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({ recordRequestBody: true }, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.post("/x", (c) => c.text("ok"));
|
||||
});
|
||||
await app.request("/x", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{not-json",
|
||||
});
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
expect((evt.details as { body?: unknown }).body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores non-object JSON body", { timeout: 5000 }, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({ recordRequestBody: true }, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.post("/x", (c) => c.text("ok"));
|
||||
});
|
||||
await app.request("/x", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify("plain-string"),
|
||||
});
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
expect((evt.details as { body?: unknown }).body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses default redaction rules when caller passes empty lists", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig(
|
||||
{
|
||||
redactQueryParams: [],
|
||||
redactHeaders: [],
|
||||
redactBodyFields: [],
|
||||
recordRequestBody: true,
|
||||
},
|
||||
storage
|
||||
);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.post("/x", (c) => c.json({ ok: true }));
|
||||
});
|
||||
await app.request("/x?token=t", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer s",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ password: "p" }),
|
||||
});
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
const det = evt.details as {
|
||||
headers: Record<string, string>;
|
||||
query: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
};
|
||||
expect(det.query["token"]).toBe("[REDACTED]");
|
||||
expect(det.headers["authorization"]).toBe("[REDACTED]");
|
||||
expect(det.body?.["password"]).toBe("[REDACTED]");
|
||||
});
|
||||
|
||||
it("treats empty x-request-id header as absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({}, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.use("*", async (c, next) => {
|
||||
c.set("requestId", "ctx-id");
|
||||
await next();
|
||||
});
|
||||
a.get("/x", (c) => c.text("ok"));
|
||||
});
|
||||
await app.request("/x", { headers: { "x-request-id": "" } });
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
expect((evt.details as { request_id: string }).request_id).toBe(
|
||||
"ctx-id"
|
||||
);
|
||||
});
|
||||
|
||||
it("treats empty context requestId as absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({}, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.use("*", async (c, next) => {
|
||||
c.set("requestId", "");
|
||||
await next();
|
||||
});
|
||||
a.get("/x", (c) => c.text("ok"));
|
||||
});
|
||||
await app.request("/x");
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
expect(
|
||||
(evt.details as { request_id: string }).request_id.length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("skips response_size when recordResponseSize is false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const cfg = makeConfig({ recordResponseSize: false }, storage);
|
||||
const app = makeApp(cfg, storage, (a) => {
|
||||
a.get("/x", (c) => c.json({ ok: true }));
|
||||
});
|
||||
await app.request("/x");
|
||||
const evt = storage.snapshot()[0] as AuditEvent;
|
||||
expect(
|
||||
(evt.details as { response_size?: number }).response_size
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { type AuditLogger, AuditSeverity } from "@suaveplan/audit-logging";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
import { createAuditContext } from "../context/audit-context.js";
|
||||
import {
|
||||
AUDIT_PLUGIN_CODES,
|
||||
AuditPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { getAuditPluginInstruments } from "../otel/instruments.js";
|
||||
import { DEFAULT_REDACTION_RULES, redactPayload } from "../redact/redact.js";
|
||||
import type {
|
||||
AuditPluginConfig,
|
||||
RedactablePayload,
|
||||
RedactionRules,
|
||||
} from "../types/types.js";
|
||||
|
||||
interface AuditMiddlewareDeps {
|
||||
readonly logger: AuditLogger;
|
||||
readonly config: AuditPluginConfig;
|
||||
readonly hostLogger: LoggerLike;
|
||||
}
|
||||
|
||||
interface PrincipalLike {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface TenantLike {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
function readPrincipalId(c: Context): string | undefined {
|
||||
const user = c.get("user") as PrincipalLike | null | undefined;
|
||||
return user?.id;
|
||||
}
|
||||
|
||||
function readTenantId(c: Context): string | undefined {
|
||||
const tenant = c.get("tenant") as TenantLike | null | undefined;
|
||||
return tenant?.id;
|
||||
}
|
||||
|
||||
function readRequestId(c: Context): string {
|
||||
const headerId = c.req.header("x-request-id");
|
||||
if (typeof headerId === "string" && headerId.length > 0) return headerId;
|
||||
const ctxId = c.get("requestId") as string | undefined;
|
||||
if (typeof ctxId === "string" && ctxId.length > 0) return ctxId;
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function classifySeverity(status: number): AuditSeverity {
|
||||
if (status < 400) return AuditSeverity.INFO;
|
||||
if (status < 500) return AuditSeverity.WARNING;
|
||||
return AuditSeverity.ERROR;
|
||||
}
|
||||
|
||||
function readQuery(c: Context): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
const url = new URL(c.req.url);
|
||||
for (const [k, v] of url.searchParams.entries()) {
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readHeaders(c: Context): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of c.req.raw.headers.entries()) {
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readBodyIfJson(
|
||||
c: Context
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
const ct = c.req.header("content-type");
|
||||
if (ct === undefined || !ct.toLowerCase().includes("application/json")) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const cloned = c.req.raw.clone();
|
||||
const parsed = (await cloned.json()) as unknown;
|
||||
if (parsed !== null && typeof parsed === "object") {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRules(config: AuditPluginConfig): RedactionRules {
|
||||
return {
|
||||
query:
|
||||
config.redactQueryParams.length > 0
|
||||
? config.redactQueryParams
|
||||
: DEFAULT_REDACTION_RULES.query,
|
||||
headers:
|
||||
config.redactHeaders.length > 0
|
||||
? config.redactHeaders
|
||||
: DEFAULT_REDACTION_RULES.headers,
|
||||
body:
|
||||
config.redactBodyFields.length > 0
|
||||
? config.redactBodyFields
|
||||
: DEFAULT_REDACTION_RULES.body,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the global per-request audit middleware. Records one
|
||||
* `AuditEvent` per non-excluded request and exposes the `audit` slot
|
||||
* on the Hono context so handlers can fire explicit events.
|
||||
*
|
||||
* Behaviour contract:
|
||||
*
|
||||
* - NEVER throws into the request response path — sink failures are
|
||||
* logged at warn and counted via `audit.sink_failures_total`.
|
||||
* - Honors `excludePaths` (exact match) and `samplingRate` (head-end
|
||||
* gate via the supplied `rng`).
|
||||
* - Sets `c.set("audit", createAuditContext({ logger }))` BEFORE
|
||||
* awaiting `next()` so handlers can call `c.get("audit").log(...)`
|
||||
* regardless of whether the request itself is recorded.
|
||||
*/
|
||||
export function createAuditMiddleware(
|
||||
deps: AuditMiddlewareDeps
|
||||
): MiddlewareHandler {
|
||||
const { logger, config, hostLogger } = deps;
|
||||
const rules = buildRules(config);
|
||||
const instruments = getAuditPluginInstruments();
|
||||
const excludeSet = new Set(config.excludePaths);
|
||||
const auditContext = createAuditContext({ logger });
|
||||
|
||||
return async (c, next) => {
|
||||
c.set("audit", auditContext);
|
||||
|
||||
const path = new URL(c.req.url).pathname;
|
||||
if (excludeSet.has(path)) {
|
||||
instruments.requestsExcluded.add(1, { path });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const sampled = config.rng() < config.samplingRate;
|
||||
if (!sampled) {
|
||||
await next();
|
||||
instruments.requestsRecorded.add(1, {
|
||||
outcome: "success",
|
||||
sampled: "false",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const method = c.req.method;
|
||||
const rawQuery = readQuery(c);
|
||||
const rawHeaders = readHeaders(c);
|
||||
const rawBody = config.recordRequestBody
|
||||
? await readBodyIfJson(c)
|
||||
: undefined;
|
||||
const preflightPayload: RedactablePayload = {
|
||||
query: rawQuery,
|
||||
headers: rawHeaders,
|
||||
...(rawBody !== undefined ? { body: rawBody } : {}),
|
||||
};
|
||||
const redacted = redactPayload(preflightPayload, rules);
|
||||
|
||||
let nextErr: unknown;
|
||||
try {
|
||||
await next();
|
||||
} catch (err) {
|
||||
nextErr = err;
|
||||
}
|
||||
|
||||
const status = nextErr !== undefined ? 500 : (c.res?.status ?? 200);
|
||||
const durationMs = Date.now() - t0;
|
||||
const requestId = readRequestId(c);
|
||||
const principalId = readPrincipalId(c);
|
||||
const tenantId = readTenantId(c);
|
||||
const contentLengthRaw = c.res?.headers.get("content-length");
|
||||
const responseSize =
|
||||
config.recordResponseSize && contentLengthRaw !== null
|
||||
? Number(contentLengthRaw)
|
||||
: undefined;
|
||||
|
||||
const details: Record<string, unknown> = {
|
||||
method,
|
||||
path,
|
||||
status,
|
||||
duration_ms: durationMs,
|
||||
request_id: requestId,
|
||||
query: redacted.query,
|
||||
headers: redacted.headers,
|
||||
};
|
||||
if (redacted.body !== undefined) details["body"] = redacted.body;
|
||||
if (responseSize !== undefined && !Number.isNaN(responseSize)) {
|
||||
details["response_size"] = responseSize;
|
||||
}
|
||||
|
||||
try {
|
||||
await logger.log({
|
||||
type: `http.${method.toLowerCase()}`,
|
||||
severity: classifySeverity(status),
|
||||
actor: {
|
||||
...(principalId !== undefined
|
||||
? { userId: principalId }
|
||||
: {}),
|
||||
...(tenantId !== undefined ? { tenantId } : {}),
|
||||
},
|
||||
...(tenantId !== undefined ? { tenantId } : {}),
|
||||
correlationId: requestId,
|
||||
details,
|
||||
});
|
||||
instruments.requestsRecorded.add(1, {
|
||||
outcome: "success",
|
||||
sampled: "true",
|
||||
});
|
||||
} catch (sinkErr) {
|
||||
instruments.sinkFailures.add(1);
|
||||
const err = new AuditPluginError(
|
||||
AUDIT_PLUGIN_CODES.SINK_WRITE_FAILED,
|
||||
"audit sink rejected save()",
|
||||
{
|
||||
...(sinkErr instanceof Error ? { cause: sinkErr } : {}),
|
||||
context: { requestId, status },
|
||||
}
|
||||
);
|
||||
hostLogger.warn(err.message, {
|
||||
code: err.code,
|
||||
requestId,
|
||||
status,
|
||||
});
|
||||
instruments.requestsRecorded.add(1, {
|
||||
outcome: "error",
|
||||
sampled: "true",
|
||||
});
|
||||
} finally {
|
||||
instruments.middlewareDuration.record(Date.now() - t0);
|
||||
}
|
||||
|
||||
if (nextErr !== undefined) throw nextErr;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# otel/instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the OpenTelemetry instrument bundle the audit plugin emits to every request lifecycle. Resolving the instruments lazily on first call lets test harnesses install a custom `MeterProvider` BEFORE the production module loads, so the in-memory exporter captures every observation. Without this lazy resolution, the meter would bind to the no-op default provider at import time and tests would see no metrics.
|
||||
|
||||
The four instruments cover the entire surface needed to operate the plugin in production: per-request recorded counter (labelled by outcome and sampled flag), excluded-path counter, sink-failure counter, and a duration histogram.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy, cached resolution — first call constructs the meter + four instruments; subsequent calls return the same bundle.
|
||||
- `resetAuditPluginInstruments()` test hook drops the cache so a new `MeterProvider` can be picked up between tests.
|
||||
- Counter and histogram surfaces follow the OTel semantic-conventions naming style (`audit.<noun>_<unit>` for histograms, `<noun>_total` for counters).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getAuditPluginInstruments } from "@suaveplan/api-plugin-audit/otel/instruments";
|
||||
|
||||
const instruments = getAuditPluginInstruments();
|
||||
instruments.requestsRecorded.add(1, { outcome: "success", sampled: "true" });
|
||||
instruments.middlewareDuration.record(12.4);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
getAuditPluginInstruments,
|
||||
resetAuditPluginInstruments,
|
||||
} from "@suaveplan/api-plugin-audit/otel/instruments";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetAuditPluginInstruments();
|
||||
|
||||
const instruments = getAuditPluginInstruments();
|
||||
instruments.sinkFailures.add(1);
|
||||
await provider.forceFlush();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface AuditPluginInstruments { meter, requestsRecorded, requestsExcluded, sinkFailures, middlewareDuration }`.
|
||||
- `getAuditPluginInstruments(): AuditPluginInstruments` — cached resolver.
|
||||
- `resetAuditPluginInstruments(): void` — drops the cache.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The lazy cache is keyed on a single module-level `let` binding; concurrent first-callers see the same instance because the resolver is synchronous. Histogram unit is explicitly declared `ms` so OTel collectors don't have to infer it from the metric name. The labels on `requestsRecorded` are kept low-cardinality (`outcome ∈ {success, error}` × `sampled ∈ {true, false}`) so the cardinality budget for downstream backends stays bounded.
|
||||
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
AuditLogger,
|
||||
type AuditStorageAdapter,
|
||||
MemoryAuditAdapter,
|
||||
} from "@suaveplan/audit-logging";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import { createAuditMiddleware } from "../middleware/audit-middleware.js";
|
||||
import type { AuditPluginConfig } from "../types/types.js";
|
||||
import {
|
||||
getAuditPluginInstruments,
|
||||
resetAuditPluginInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
const noopHostLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function makeConfig(
|
||||
overrides: Partial<AuditPluginConfig> = {}
|
||||
): AuditPluginConfig {
|
||||
return {
|
||||
sink: new MemoryAuditAdapter(),
|
||||
secretKey: "x".repeat(32),
|
||||
samplingRate: 1,
|
||||
excludePaths: ["/health"],
|
||||
redactQueryParams: [],
|
||||
redactHeaders: [],
|
||||
redactBodyFields: [],
|
||||
recordResponseSize: true,
|
||||
recordRequestBody: false,
|
||||
enableChainIntegrity: false,
|
||||
rng: () => 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetAuditPluginInstruments();
|
||||
});
|
||||
|
||||
function collectCounterTotal(
|
||||
metrics: Awaited<ReturnType<typeof flushTestMetrics>>,
|
||||
name: string
|
||||
): number {
|
||||
let total = 0;
|
||||
for (const rm of metrics) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name === name) {
|
||||
for (const dp of m.dataPoints) {
|
||||
total += Number(dp.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function hasMetric(
|
||||
metrics: Awaited<ReturnType<typeof flushTestMetrics>>,
|
||||
name: string
|
||||
): boolean {
|
||||
for (const rm of metrics) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name === name) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe("otel/instruments", () => {
|
||||
it("getAuditPluginInstruments caches the instrument bundle", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getAuditPluginInstruments();
|
||||
const b = getAuditPluginInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("counter and histogram emit per recorded request", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const logger = new AuditLogger({
|
||||
storage,
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
createAuditMiddleware({
|
||||
logger,
|
||||
config: makeConfig({ sink: storage }),
|
||||
hostLogger: noopHostLogger,
|
||||
})
|
||||
);
|
||||
app.get("/x", (c) => c.text("ok"));
|
||||
await app.request("/x");
|
||||
const m = await flushTestMetrics();
|
||||
expect(
|
||||
collectCounterTotal(m, "audit.requests_recorded_total")
|
||||
).toBeGreaterThan(0);
|
||||
expect(hasMetric(m, "audit.middleware_duration_ms")).toBe(true);
|
||||
});
|
||||
|
||||
it("excluded counter increments for health checks", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const logger = new AuditLogger({
|
||||
storage,
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
createAuditMiddleware({
|
||||
logger,
|
||||
config: makeConfig({ sink: storage }),
|
||||
hostLogger: noopHostLogger,
|
||||
})
|
||||
);
|
||||
app.get("/health", (c) => c.text("ok"));
|
||||
await app.request("/health");
|
||||
const m = await flushTestMetrics();
|
||||
expect(
|
||||
collectCounterTotal(m, "audit.requests_excluded_total")
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("sink failures counter increments on sink rejection", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failingSink: AuditStorageAdapter = {
|
||||
save: async () => {
|
||||
throw new Error("down");
|
||||
},
|
||||
query: async () => [],
|
||||
getLatestEvent: async () => null,
|
||||
deleteExpired: async () => 0,
|
||||
archive: async () => 0,
|
||||
count: async () => 0,
|
||||
};
|
||||
const logger = new AuditLogger({
|
||||
storage: failingSink,
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
createAuditMiddleware({
|
||||
logger,
|
||||
config: makeConfig({ sink: failingSink }),
|
||||
hostLogger: noopHostLogger,
|
||||
})
|
||||
);
|
||||
app.get("/x", (c) => c.text("ok"));
|
||||
await app.request("/x");
|
||||
const m = await flushTestMetrics();
|
||||
expect(
|
||||
collectCounterTotal(m, "audit.sink_failures_total")
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resetAuditPluginInstruments drops the cache", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getAuditPluginInstruments();
|
||||
resetAuditPluginInstruments();
|
||||
const b = getAuditPluginInstruments();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("sampled=false counter increments when rng misses", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const logger = new AuditLogger({
|
||||
storage,
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
createAuditMiddleware({
|
||||
logger,
|
||||
config: makeConfig({
|
||||
sink: storage,
|
||||
samplingRate: 0.5,
|
||||
rng: () => 0.9,
|
||||
}),
|
||||
hostLogger: noopHostLogger,
|
||||
})
|
||||
);
|
||||
app.get("/x", (c) => c.text("ok"));
|
||||
await app.request("/x");
|
||||
const m = await flushTestMetrics();
|
||||
expect(
|
||||
collectCounterTotal(m, "audit.requests_recorded_total")
|
||||
).toBeGreaterThan(0);
|
||||
// and no event saved
|
||||
expect(storage.snapshot()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* OpenTelemetry instruments owned by `@suaveplan/api-plugin-audit`.
|
||||
*
|
||||
* The instruments are resolved lazily on first access so test
|
||||
* harnesses that install a custom `MeterProvider` BEFORE importing
|
||||
* any production module (see `tests/setup/polyfills.ts`) capture
|
||||
* every observation.
|
||||
*/
|
||||
export interface AuditPluginInstruments {
|
||||
readonly meter: Meter;
|
||||
readonly requestsRecorded: Counter;
|
||||
readonly requestsExcluded: Counter;
|
||||
readonly sinkFailures: Counter;
|
||||
readonly middlewareDuration: Histogram;
|
||||
}
|
||||
|
||||
let cached: AuditPluginInstruments | undefined;
|
||||
|
||||
/** Returns the cached instrument bundle, creating it on first call. */
|
||||
export function getAuditPluginInstruments(): AuditPluginInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-plugin-audit");
|
||||
cached = {
|
||||
meter,
|
||||
requestsRecorded: meter.createCounter("audit.requests_recorded_total", {
|
||||
description:
|
||||
"Audit events recorded per request, labelled by outcome and sampled flag.",
|
||||
}),
|
||||
requestsExcluded: meter.createCounter("audit.requests_excluded_total", {
|
||||
description:
|
||||
"Requests that matched `excludePaths` and were skipped by the audit middleware.",
|
||||
}),
|
||||
sinkFailures: meter.createCounter("audit.sink_failures_total", {
|
||||
description:
|
||||
"Sink rejections caught by the audit middleware (request response is unaffected).",
|
||||
}),
|
||||
middlewareDuration: meter.createHistogram(
|
||||
"audit.middleware_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Wall-clock duration of the audit middleware (preflight + sink write) in milliseconds.",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only — drop the cached instrument bundle so the next access
|
||||
* binds to whichever `MeterProvider` is currently global. Not part
|
||||
* of the runtime contract.
|
||||
*/
|
||||
export function resetAuditPluginInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { MemoryAuditAdapter } from "@suaveplan/audit-logging";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
AUDIT_PLUGIN_CODES,
|
||||
type AuditPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { AuditPluginConfig } from "../types/types.js";
|
||||
import { __setAuditLoggerFactory, createAuditPlugin } from "./plugin.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function makeCtx(services: Map<string, unknown>, config: AuditPluginConfig) {
|
||||
return createPluginContext<AuditPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config,
|
||||
services,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
__setAuditLoggerFactory(undefined);
|
||||
});
|
||||
|
||||
describe("onInit", () => {
|
||||
it("raises AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE when app missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createAuditPlugin({
|
||||
sink: new MemoryAuditAdapter(),
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
const ctx = makeCtx(services, plugin.defaults as AuditPluginConfig);
|
||||
let caught: AuditPluginError | undefined;
|
||||
try {
|
||||
await plugin.onInit?.(ctx);
|
||||
} catch (err) {
|
||||
caught = err as AuditPluginError;
|
||||
}
|
||||
expect(caught?.code).toBe(AUDIT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE);
|
||||
expect(
|
||||
(caught?.context as { missing: string[] } | undefined)?.missing
|
||||
).toContain("app");
|
||||
});
|
||||
|
||||
it("registers auditService and installs middleware", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const sink = new MemoryAuditAdapter();
|
||||
const plugin = createAuditPlugin({
|
||||
sink,
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
const app = new Hono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
const ctx = makeCtx(services, plugin.defaults as AuditPluginConfig);
|
||||
await plugin.onInit?.(ctx);
|
||||
expect(services.get("auditService")).toBeDefined();
|
||||
|
||||
app.get("/x", (c) => c.text("ok"));
|
||||
await app.request("/x");
|
||||
expect(sink.snapshot()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("sink construction failure raises AUDIT_PLUGIN_SINK_INIT_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
__setAuditLoggerFactory(() => {
|
||||
throw new Error("simulated AuditLogger construction failure");
|
||||
});
|
||||
const plugin = createAuditPlugin({
|
||||
sink: new MemoryAuditAdapter(),
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
const app = new Hono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
const ctx = makeCtx(services, plugin.defaults as AuditPluginConfig);
|
||||
let caught: AuditPluginError | undefined;
|
||||
try {
|
||||
await plugin.onInit?.(ctx);
|
||||
} catch (err) {
|
||||
caught = err as AuditPluginError;
|
||||
}
|
||||
expect(caught?.code).toBe(AUDIT_PLUGIN_CODES.SINK_INIT_FAILED);
|
||||
expect(caught?.cause).toBeDefined();
|
||||
});
|
||||
|
||||
it("warns when dev-default secret key is in use", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let warnSeen = false;
|
||||
const hostLogger = {
|
||||
...noopLogger,
|
||||
warn: () => {
|
||||
warnSeen = true;
|
||||
},
|
||||
};
|
||||
const plugin = createAuditPlugin({
|
||||
sink: new MemoryAuditAdapter(),
|
||||
logger: hostLogger,
|
||||
// no secretKey → dev default + warn
|
||||
});
|
||||
const app = new Hono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
const ctx = makeCtx(services, plugin.defaults as AuditPluginConfig);
|
||||
await plugin.onInit?.(ctx);
|
||||
expect(warnSeen).toBe(true);
|
||||
});
|
||||
|
||||
it("onClose clears internal state without error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createAuditPlugin({
|
||||
sink: new MemoryAuditAdapter(),
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
const app = new Hono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
const ctx = makeCtx(services, plugin.defaults as AuditPluginConfig);
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onClose?.(ctx);
|
||||
});
|
||||
|
||||
it("uses ctx.logger when no custom logger supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let warnFromCtx = false;
|
||||
const plugin = createAuditPlugin({
|
||||
sink: new MemoryAuditAdapter(),
|
||||
// no secretKey → dev default + warn
|
||||
});
|
||||
const app = new Hono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
const ctx = createPluginContext<AuditPluginConfig>({
|
||||
logger: {
|
||||
...noopLogger,
|
||||
warn: () => {
|
||||
warnFromCtx = true;
|
||||
},
|
||||
},
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as AuditPluginConfig,
|
||||
services,
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
expect(warnFromCtx).toBe(true);
|
||||
});
|
||||
});
|
||||
62
packages/api-stack/api/api-plugin-audit/src/plugin/plugin.md
Normal file
62
packages/api-stack/api/api-plugin-audit/src/plugin/plugin.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# plugin/plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Top-level factory for the audit plugin. `createAuditPlugin(options)` Zod-validates the caller's options at the boundary, builds the resolved `AuditPluginConfig`, and returns a typed `PluginDescriptor<AuditPluginConfig>` whose `onInit` hook performs the canonical 4-step lifecycle: resolve `app`, construct `AuditLogger`, register `auditService`, install the global middleware. The descriptor's `onClose` clears the cached logger reference.
|
||||
|
||||
This is the only entry point operators interact with. Everything else in the package is reachable through the descriptor or the ServiceRegistry slot the descriptor installs.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated options via `AuditPluginOptionsSchema`; failures throw `AuditPluginError(AUDIT_PLUGIN_INVALID_INPUT)` with the `ZodError` as `cause`.
|
||||
- Defaults supply `MemoryAuditAdapter` when no sink is injected (dev fallback).
|
||||
- Warns when the dev-default secret key is used so production operators notice.
|
||||
- `onInit` raises `AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE` when `app` is missing, listing `["app"]` in `context.missing`.
|
||||
- `onInit` raises `AUDIT_PLUGIN_SINK_INIT_FAILED` when `new AuditLogger(...)` throws (e.g. missing/invalid secret key).
|
||||
- `onInit` is idempotent in the sense that re-invocation rebuilds the logger and re-installs the middleware (Hono `app.use("*", ...)` chaining is additive, so callers should treat the descriptor as one-shot per registry).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MemoryAuditAdapter } from "@suaveplan/audit-logging";
|
||||
import { createAuditPlugin } from "@suaveplan/api-plugin-audit";
|
||||
|
||||
const plugin = createAuditPlugin({
|
||||
sink: new MemoryAuditAdapter(),
|
||||
secretKey: process.env.AUDIT_SECRET_KEY,
|
||||
});
|
||||
// register on your PluginRegistry alongside auth, tenant, etc.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createAuditPlugin } from "@suaveplan/api-plugin-audit";
|
||||
import { MemoryAuditAdapter } from "@suaveplan/audit-logging";
|
||||
|
||||
const plugin = createAuditPlugin({
|
||||
sink: new MemoryAuditAdapter(),
|
||||
secretKey: process.env.AUDIT_SECRET_KEY,
|
||||
samplingRate: 0.1, // record 10% of requests
|
||||
excludePaths: ["/health", "/metrics", "/openapi.json"],
|
||||
redactBodyFields: ["password", "ssn", "card_number"],
|
||||
redactHeaders: ["authorization", "cookie", "x-internal-key"],
|
||||
redactQueryParams: ["token", "code", "state", "session"],
|
||||
recordRequestBody: true,
|
||||
enableChainIntegrity: true,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createAuditPlugin(options: AuditPluginOptions): PluginDescriptor<AuditPluginConfig>`
|
||||
- Validates `options` via `AuditPluginOptionsSchema`.
|
||||
- Returns a descriptor with `name: "audit"`, `version: "0.1.0"`, resolved `defaults`, `onInit`, `onClose`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory captures the resolved `config` and a small `AuditPluginState` (currently just the cached logger) in a closure shared between `onInit` and `onClose`. The dev-default secret-key warning fires every time `onInit` runs — operators see it once per boot, which is the right cadence (loud enough to notice, quiet enough not to spam). The plugin does NOT depend on `@suaveplan/api`; it depends only on `@suaveplan/api-plugin` (for `definePlugin` + the context types) plus `@suaveplan/audit-logging` (for the runtime), `@suaveplan/error` / `@suaveplan/types`, `hono`, and `zod`. This keeps the dep graph small and lets the plugin attach to any Hono-based host.
|
||||
@@ -0,0 +1,71 @@
|
||||
import { MemoryAuditAdapter } from "@suaveplan/audit-logging";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIT_PLUGIN_CODES,
|
||||
type AuditPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { AuditPluginConfig } from "../types/types.js";
|
||||
import { createAuditPlugin } from "./plugin.js";
|
||||
|
||||
describe("createAuditPlugin", () => {
|
||||
it("createAuditPlugin returns valid PluginDescriptor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createAuditPlugin({
|
||||
sink: new MemoryAuditAdapter(),
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
expect(plugin.name).toBe("audit");
|
||||
expect(plugin.version).toBe("0.1.0");
|
||||
expect(typeof plugin.onInit).toBe("function");
|
||||
expect(typeof plugin.onClose).toBe("function");
|
||||
});
|
||||
|
||||
it("invalid options raise AUDIT_PLUGIN_INVALID_INPUT", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: AuditPluginError | undefined;
|
||||
try {
|
||||
createAuditPlugin({ samplingRate: 2 } as never);
|
||||
} catch (err) {
|
||||
caught = err as AuditPluginError;
|
||||
}
|
||||
expect(caught?.code).toBe(AUDIT_PLUGIN_CODES.INVALID_INPUT);
|
||||
expect(caught?.cause).toBeDefined();
|
||||
});
|
||||
|
||||
it("descriptor exposes resolved defaults", { timeout: 5000 }, () => {
|
||||
const plugin = createAuditPlugin({
|
||||
sink: new MemoryAuditAdapter(),
|
||||
secretKey: "x".repeat(32),
|
||||
samplingRate: 0.25,
|
||||
});
|
||||
expect((plugin.defaults as AuditPluginConfig).samplingRate).toBe(0.25);
|
||||
expect((plugin.defaults as AuditPluginConfig).recordResponseSize).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to MemoryAuditAdapter when no sink supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createAuditPlugin({ secretKey: "x".repeat(32) });
|
||||
expect((plugin.defaults as AuditPluginConfig).sink).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses Math.random when no rng supplied", { timeout: 5000 }, () => {
|
||||
const plugin = createAuditPlugin({ secretKey: "x".repeat(32) });
|
||||
expect(typeof (plugin.defaults as AuditPluginConfig).rng).toBe(
|
||||
"function"
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts caller-supplied rng", { timeout: 5000 }, () => {
|
||||
const rng = () => 0.5;
|
||||
const plugin = createAuditPlugin({
|
||||
secretKey: "x".repeat(32),
|
||||
rng,
|
||||
});
|
||||
expect((plugin.defaults as AuditPluginConfig).rng).toBe(rng);
|
||||
});
|
||||
});
|
||||
164
packages/api-stack/api/api-plugin-audit/src/plugin/plugin.ts
Normal file
164
packages/api-stack/api/api-plugin-audit/src/plugin/plugin.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
definePlugin,
|
||||
type PluginContext,
|
||||
type PluginDescriptor,
|
||||
} from "@suaveplan/api-plugin";
|
||||
import {
|
||||
AuditLogger,
|
||||
type AuditStorageAdapter,
|
||||
MemoryAuditAdapter,
|
||||
} from "@suaveplan/audit-logging";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import {
|
||||
AUDIT_PLUGIN_CODES,
|
||||
AuditPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { createAuditMiddleware } from "../middleware/audit-middleware.js";
|
||||
import {
|
||||
AuditPluginOptionsSchema,
|
||||
DEV_DEFAULT_SECRET_KEY,
|
||||
} from "../schemas/options.js";
|
||||
import { createAuditService } from "../services/audit-service.js";
|
||||
import "../services/registry-augmentation.js";
|
||||
import "../context/context-augmentation.js";
|
||||
import type { AuditPluginConfig, AuditPluginOptions } from "../types/types.js";
|
||||
|
||||
interface AuditPluginState {
|
||||
logger: AuditLogger | null;
|
||||
}
|
||||
|
||||
type AuditLoggerFactory = (
|
||||
opts: ConstructorParameters<typeof AuditLogger>[0]
|
||||
) => AuditLogger;
|
||||
|
||||
const DEFAULT_AUDIT_LOGGER_FACTORY: AuditLoggerFactory = (opts) =>
|
||||
new AuditLogger(opts);
|
||||
|
||||
let resolveAuditLoggerFactory: AuditLoggerFactory =
|
||||
DEFAULT_AUDIT_LOGGER_FACTORY;
|
||||
|
||||
/**
|
||||
* Test-only — swap the `AuditLogger` constructor used by `onInit` so
|
||||
* unit tests can exercise the `SINK_INIT_FAILED` branch without
|
||||
* relying on the underlying library to throw. Pass `undefined` to
|
||||
* restore the default.
|
||||
*/
|
||||
export function __setAuditLoggerFactory(
|
||||
factory: AuditLoggerFactory | undefined
|
||||
): void {
|
||||
resolveAuditLoggerFactory = factory ?? DEFAULT_AUDIT_LOGGER_FACTORY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory — Zod-validates `options` and returns a typed
|
||||
* `PluginDescriptor` for the audit plugin. Lifecycle:
|
||||
*
|
||||
* 1. Resolve `app` from the host `ServiceRegistry`. Missing → throw
|
||||
* `AuditPluginError(AUDIT_PLUGIN_SERVICE_NOT_AVAILABLE)`.
|
||||
* 2. Construct the `AuditLogger` with the supplied (or default
|
||||
* in-memory) sink. Failures → throw
|
||||
* `AuditPluginError(AUDIT_PLUGIN_SINK_INIT_FAILED)`.
|
||||
* 3. Register `auditService` in the `ServiceRegistry`.
|
||||
* 4. Install the global per-request audit middleware on `ctx.app`.
|
||||
*/
|
||||
export function createAuditPlugin(
|
||||
options: AuditPluginOptions
|
||||
): PluginDescriptor<AuditPluginConfig> {
|
||||
const parsed = AuditPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new AuditPluginError(
|
||||
AUDIT_PLUGIN_CODES.INVALID_INPUT,
|
||||
"createAuditPlugin received invalid options",
|
||||
{ cause: parsed.error, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
|
||||
const sink: AuditStorageAdapter =
|
||||
(options.sink as AuditStorageAdapter | undefined) ??
|
||||
new MemoryAuditAdapter();
|
||||
const rng = typeof options.rng === "function" ? options.rng : Math.random;
|
||||
const hostLoggerInput = options.logger as LoggerLike | undefined;
|
||||
|
||||
const config: AuditPluginConfig = {
|
||||
...(hostLoggerInput !== undefined ? { logger: hostLoggerInput } : {}),
|
||||
sink,
|
||||
secretKey: parsed.data.secretKey,
|
||||
samplingRate: parsed.data.samplingRate,
|
||||
excludePaths: parsed.data.excludePaths,
|
||||
redactQueryParams: parsed.data.redactQueryParams,
|
||||
redactHeaders: parsed.data.redactHeaders,
|
||||
redactBodyFields: parsed.data.redactBodyFields,
|
||||
recordResponseSize: parsed.data.recordResponseSize,
|
||||
recordRequestBody: parsed.data.recordRequestBody,
|
||||
enableChainIntegrity: parsed.data.enableChainIntegrity,
|
||||
rng,
|
||||
};
|
||||
|
||||
const state: AuditPluginState = { logger: null };
|
||||
|
||||
return definePlugin<AuditPluginConfig>({
|
||||
name: "audit",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Write-through audit trail for @suaveplan/api: global Hono middleware over @suaveplan/audit-logging.",
|
||||
defaults: config,
|
||||
tags: ["audit", "observability", "compliance"],
|
||||
onInit: async (ctx) => runOnInit(ctx, config, state),
|
||||
onClose: async () => {
|
||||
state.logger = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runOnInit(
|
||||
ctx: PluginContext<AuditPluginConfig>,
|
||||
config: AuditPluginConfig,
|
||||
state: AuditPluginState
|
||||
): Promise<void> {
|
||||
const app = ctx.getService("app");
|
||||
if (app === undefined) {
|
||||
throw new AuditPluginError(
|
||||
AUDIT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"audit plugin requires the `app` service in ServiceRegistry",
|
||||
{ context: { missing: ["app"], statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
|
||||
const hostLogger = config.logger ?? ctx.logger;
|
||||
if (config.secretKey === DEV_DEFAULT_SECRET_KEY) {
|
||||
hostLogger.warn(
|
||||
"audit plugin: using dev-default secret key — set `secretKey` in production"
|
||||
);
|
||||
}
|
||||
|
||||
let auditLogger: AuditLogger;
|
||||
try {
|
||||
auditLogger = resolveAuditLoggerFactory({
|
||||
storage: config.sink,
|
||||
secretKey: config.secretKey,
|
||||
enableChainIntegrity: config.enableChainIntegrity,
|
||||
logger: hostLogger,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new AuditPluginError(
|
||||
AUDIT_PLUGIN_CODES.SINK_INIT_FAILED,
|
||||
"AuditLogger construction failed",
|
||||
{ cause: err, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
state.logger = auditLogger;
|
||||
|
||||
ctx.registerService(
|
||||
"auditService",
|
||||
createAuditService({ logger: auditLogger })
|
||||
);
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
createAuditMiddleware({
|
||||
logger: auditLogger,
|
||||
config,
|
||||
hostLogger,
|
||||
})
|
||||
);
|
||||
}
|
||||
70
packages/api-stack/api/api-plugin-audit/src/redact/redact.md
Normal file
70
packages/api-stack/api/api-plugin-audit/src/redact/redact.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# redact/redact
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Lightweight, allocation-free-ish payload redactor used by the audit middleware to scrub secret-bearing fields from per-request audit events. The middleware runs on every non-excluded request, so the redactor is on the hot path; it intentionally avoids pulling in `@suaveplan/data-masking`'s rule-engine machinery for the common case (named-field substitution). Operators that need format-preserving masking, tokenisation, or path-pattern matching call `maskObject` from `@suaveplan/data-masking` inside their handler-level `c.get("audit").log(...)` flows.
|
||||
|
||||
The default rule set covers the canonical secret-bearing names (`authorization`, `cookie`, `set-cookie`, `x-api-key` for headers; `token`, `code`, `state` for query; `password`, `secret`, `token` for body) and is composable: callers append their own field names per the plugin options.
|
||||
|
||||
## Features
|
||||
|
||||
- Case-insensitive header redaction (HTTP headers are case-insensitive per RFC 7230).
|
||||
- Exact-match query and body redaction.
|
||||
- Shallow clone of the payload — original objects are never mutated.
|
||||
- Stable replacement marker (`"[REDACTED]"`).
|
||||
- Defaults exported as a typed `RedactionRules` constant.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_REDACTION_RULES,
|
||||
redactPayload,
|
||||
} from "@suaveplan/api-plugin-audit/redact/redact";
|
||||
|
||||
const safe = redactPayload(
|
||||
{
|
||||
headers: { Authorization: "Bearer abc" },
|
||||
query: { token: "xyz" },
|
||||
body: { password: "p", email: "a@b" },
|
||||
},
|
||||
DEFAULT_REDACTION_RULES
|
||||
);
|
||||
|
||||
// safe.headers.Authorization === "[REDACTED]"
|
||||
// safe.query.token === "[REDACTED]"
|
||||
// safe.body.password === "[REDACTED]"
|
||||
// safe.body.email === "a@b"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { redactPayload, REDACTED } from "@suaveplan/api-plugin-audit/redact/redact";
|
||||
|
||||
const orgRules = {
|
||||
headers: ["authorization", "cookie", "x-internal-token"],
|
||||
query: ["token", "session"],
|
||||
body: ["password", "ssn", "credit_card"],
|
||||
};
|
||||
|
||||
const safe = redactPayload(
|
||||
{ headers: { "X-Internal-Token": "secret" } },
|
||||
orgRules
|
||||
);
|
||||
console.assert(safe.headers?.["X-Internal-Token"] === REDACTED);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REDACTED: "[REDACTED]"` — substitution marker.
|
||||
- `DEFAULT_REDACTION_RULES: RedactionRules` — frozen default lists.
|
||||
- `redactPayload(payload: RedactablePayload, rules: RedactionRules): RedactablePayload` — returns a shallow-redacted clone. Header keys preserve original casing; only the comparison is case-insensitive.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Set` lookup is `O(1)`; header lookup builds a single lowercased set once and compares each header key lowercased. The function does not recurse — body objects are matched at the top level only. This is sufficient for the request-audit path (where the body is typically a flat JSON object); deep masking is the masking-rule engine's job and lives in `@suaveplan/data-masking`. The redactor is pure and synchronous so it can run inside the middleware's preflight without an `await`.
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DEFAULT_REDACTION_RULES, REDACTED, redactPayload } from "./redact.js";
|
||||
|
||||
describe("redactPayload", () => {
|
||||
it("case-insensitive header redaction", { timeout: 5000 }, () => {
|
||||
const out = redactPayload(
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer x",
|
||||
"X-Api-Key": "k",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
DEFAULT_REDACTION_RULES
|
||||
);
|
||||
expect(out.headers?.["Authorization"]).toBe(REDACTED);
|
||||
expect(out.headers?.["X-Api-Key"]).toBe(REDACTED);
|
||||
expect(out.headers?.["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("query and body redaction by exact field name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = redactPayload(
|
||||
{
|
||||
query: { token: "a", safe: "v" },
|
||||
body: { password: "p", email: "e" },
|
||||
},
|
||||
DEFAULT_REDACTION_RULES
|
||||
);
|
||||
expect(out.query?.["token"]).toBe(REDACTED);
|
||||
expect(out.query?.["safe"]).toBe("v");
|
||||
expect(out.body?.["password"]).toBe(REDACTED);
|
||||
expect(out.body?.["email"]).toBe("e");
|
||||
});
|
||||
|
||||
it("default rules cover the canonical secret-bearing names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(DEFAULT_REDACTION_RULES.headers).toContain("authorization");
|
||||
expect(DEFAULT_REDACTION_RULES.headers).toContain("cookie");
|
||||
expect(DEFAULT_REDACTION_RULES.query).toContain("token");
|
||||
expect(DEFAULT_REDACTION_RULES.body).toContain("password");
|
||||
});
|
||||
|
||||
it("returns empty result when payload is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = redactPayload({}, DEFAULT_REDACTION_RULES);
|
||||
expect(out.headers).toBeUndefined();
|
||||
expect(out.query).toBeUndefined();
|
||||
expect(out.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
71
packages/api-stack/api/api-plugin-audit/src/redact/redact.ts
Normal file
71
packages/api-stack/api/api-plugin-audit/src/redact/redact.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
DEFAULT_REDACT_BODY,
|
||||
DEFAULT_REDACT_HEADERS,
|
||||
DEFAULT_REDACT_QUERY,
|
||||
} from "../schemas/options.js";
|
||||
import type { RedactablePayload, RedactionRules } from "../types/types.js";
|
||||
|
||||
/** Replacement marker substituted for matched secret-bearing values. */
|
||||
export const REDACTED = "[REDACTED]";
|
||||
|
||||
/**
|
||||
* Default redaction rules used by `createAuditPlugin` when the caller
|
||||
* does not override any of the per-category lists.
|
||||
*/
|
||||
export const DEFAULT_REDACTION_RULES: RedactionRules = {
|
||||
query: DEFAULT_REDACT_QUERY,
|
||||
headers: DEFAULT_REDACT_HEADERS,
|
||||
body: DEFAULT_REDACT_BODY,
|
||||
};
|
||||
|
||||
function redactKeyValueExact(
|
||||
record: Record<string, unknown>,
|
||||
names: readonly string[]
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
const matchSet = new Set(names);
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
out[k] = matchSet.has(k) ? REDACTED : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function redactHeadersCaseInsensitive(
|
||||
headers: Record<string, string>,
|
||||
names: readonly string[]
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
const lower = new Set(names.map((n) => n.toLowerCase()));
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
out[k] = lower.has(k.toLowerCase()) ? REDACTED : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a shallow clone of `payload` with the configured
|
||||
* secret-bearing fields replaced by `"[REDACTED]"`. Header matches
|
||||
* are case-insensitive; query and body matches are exact.
|
||||
*/
|
||||
export function redactPayload(
|
||||
payload: RedactablePayload,
|
||||
rules: RedactionRules
|
||||
): RedactablePayload {
|
||||
const out: RedactablePayload = {};
|
||||
if (payload.query !== undefined) {
|
||||
out.query = redactKeyValueExact(
|
||||
payload.query as Record<string, unknown>,
|
||||
rules.query
|
||||
) as Record<string, string>;
|
||||
}
|
||||
if (payload.headers !== undefined) {
|
||||
out.headers = redactHeadersCaseInsensitive(
|
||||
payload.headers,
|
||||
rules.headers
|
||||
);
|
||||
}
|
||||
if (payload.body !== undefined) {
|
||||
out.body = redactKeyValueExact(payload.body, rules.body);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# schemas/options
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schema that validates `AuditPluginOptions` at the `createAuditPlugin` factory boundary and produces the resolved `AuditPluginConfig` shape consumed by the lifecycle hooks. Centralises every default value the plugin ships with (sampling rate, exclude paths, redaction rules, dev-only secret key) so the rest of the code can rely on a fully-populated config object.
|
||||
|
||||
This is the single source of truth for option defaults; anything not here is either a runtime decision (sink injection, logger choice) or a per-request decision (sampling RNG draw).
|
||||
|
||||
## Features
|
||||
|
||||
- Zod 4 schema with `.transform(...)` returning a strongly-typed resolved config.
|
||||
- Defaults for `samplingRate` (1), `excludePaths` (health probes + openapi doc), redaction lists (Authorization/Cookie/Token/Password/etc), `recordResponseSize` (true), `recordRequestBody` (false), `enableChainIntegrity` (false).
|
||||
- `samplingRate` clamped to `[0, 1]`.
|
||||
- Optional `sink`, `logger`, `rng` slots remain `unknown` here; the factory narrows them at the call site with concrete imports to avoid pulling `@suaveplan/audit-logging` into the schemas barrel as a value dep.
|
||||
- Exposes `DEV_DEFAULT_SECRET_KEY` constant so callers and tests can inspect the fallback.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AuditPluginOptionsSchema } from "@suaveplan/api-plugin-audit/schemas/options";
|
||||
|
||||
const config = AuditPluginOptionsSchema.parse({
|
||||
secretKey: process.env.AUDIT_SECRET_KEY,
|
||||
});
|
||||
|
||||
console.log(config.samplingRate); // 1
|
||||
console.log(config.excludePaths); // ["/health", "/healthz", "/ready", "/openapi.json"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AuditPluginOptionsSchema } from "@suaveplan/api-plugin-audit/schemas/options";
|
||||
|
||||
const result = AuditPluginOptionsSchema.safeParse({
|
||||
samplingRate: 0.25,
|
||||
redactBodyFields: ["password", "ssn", "credit_card"],
|
||||
enableChainIntegrity: true,
|
||||
secretKey: process.env.AUDIT_SECRET_KEY,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
// ZodError carrying every boundary violation
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const resolved = result.data;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AuditPluginOptionsSchema` — Zod schema returning `AuditPluginOptionsResolved`.
|
||||
- `AuditPluginOptionsInput` — `z.input<typeof AuditPluginOptionsSchema>`.
|
||||
- `AuditPluginOptionsResolved` — `z.output<typeof AuditPluginOptionsSchema>`.
|
||||
- `DEFAULT_REDACT_HEADERS`, `DEFAULT_REDACT_QUERY`, `DEFAULT_REDACT_BODY`, `DEFAULT_EXCLUDE_PATHS` — `readonly string[]` defaults.
|
||||
- `DEV_DEFAULT_SECRET_KEY` — string fallback used when `secretKey` is omitted.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema keeps the `sink` and `logger` slots as `unknown` rather than typed Zod schemas because both interfaces are runtime objects and Zod cannot validate their shape without arbitrary `z.custom` predicates. The factory passes them straight through to `new AuditLogger({ ... })`, which already validates `secretKey` and the storage adapter contract. The `secretKey` default exists so the dev path works without env wiring; the factory MUST log a warn when the default is used so operators do not ship it to production.
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AuditPluginOptionsSchema,
|
||||
DEFAULT_EXCLUDE_PATHS,
|
||||
DEFAULT_REDACT_BODY,
|
||||
DEFAULT_REDACT_HEADERS,
|
||||
DEFAULT_REDACT_QUERY,
|
||||
DEV_DEFAULT_SECRET_KEY,
|
||||
} from "./options.js";
|
||||
|
||||
describe("AuditPluginOptionsSchema", () => {
|
||||
it("applies all defaults for empty input", { timeout: 5000 }, () => {
|
||||
const parsed = AuditPluginOptionsSchema.parse({});
|
||||
expect(parsed.samplingRate).toBe(1);
|
||||
expect(parsed.excludePaths).toEqual([...DEFAULT_EXCLUDE_PATHS]);
|
||||
expect(parsed.redactQueryParams).toEqual([...DEFAULT_REDACT_QUERY]);
|
||||
expect(parsed.redactHeaders).toEqual([...DEFAULT_REDACT_HEADERS]);
|
||||
expect(parsed.redactBodyFields).toEqual([...DEFAULT_REDACT_BODY]);
|
||||
expect(parsed.recordResponseSize).toBe(true);
|
||||
expect(parsed.recordRequestBody).toBe(false);
|
||||
expect(parsed.enableChainIntegrity).toBe(false);
|
||||
expect(parsed.secretKey).toBe(DEV_DEFAULT_SECRET_KEY);
|
||||
});
|
||||
|
||||
it("rejects samplingRate out of [0,1]", { timeout: 5000 }, () => {
|
||||
const result = AuditPluginOptionsSchema.safeParse({
|
||||
samplingRate: 2,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects negative samplingRate", { timeout: 5000 }, () => {
|
||||
const result = AuditPluginOptionsSchema.safeParse({
|
||||
samplingRate: -0.1,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("honors caller-supplied secretKey", { timeout: 5000 }, () => {
|
||||
const parsed = AuditPluginOptionsSchema.parse({
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
expect(parsed.secretKey).toBe("x".repeat(32));
|
||||
});
|
||||
|
||||
it("honors caller-supplied excludePaths and redaction lists", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = AuditPluginOptionsSchema.parse({
|
||||
excludePaths: ["/foo"],
|
||||
redactQueryParams: ["custom"],
|
||||
redactHeaders: ["x-secret"],
|
||||
redactBodyFields: ["ssn"],
|
||||
});
|
||||
expect(parsed.excludePaths).toEqual(["/foo"]);
|
||||
expect(parsed.redactQueryParams).toEqual(["custom"]);
|
||||
expect(parsed.redactHeaders).toEqual(["x-secret"]);
|
||||
expect(parsed.redactBodyFields).toEqual(["ssn"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import z from "zod";
|
||||
import type { AuditPluginConfig } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Default secret-bearing header / query / body names redacted by the
|
||||
* audit middleware. Header matches are case-insensitive.
|
||||
*/
|
||||
export const DEFAULT_REDACT_HEADERS = [
|
||||
"authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"x-api-key",
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_REDACT_QUERY = ["token", "code", "state"] as const;
|
||||
|
||||
export const DEFAULT_REDACT_BODY = ["password", "secret", "token"] as const;
|
||||
|
||||
export const DEFAULT_EXCLUDE_PATHS = [
|
||||
"/health",
|
||||
"/healthz",
|
||||
"/ready",
|
||||
"/openapi.json",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 32-byte hex placeholder secret used when the operator did not supply
|
||||
* one. The factory logs a warn (via the resolved logger) when it
|
||||
* falls back to this value; suitable for dev only.
|
||||
*/
|
||||
const DEV_DEFAULT_SECRET_KEY = "dev-audit-secret-please-override";
|
||||
|
||||
/**
|
||||
* Zod schema validating `AuditPluginOptions` at the factory boundary.
|
||||
* Returns the resolved `AuditPluginConfig` shape with defaults filled.
|
||||
*/
|
||||
export const AuditPluginOptionsSchema = z
|
||||
.object({
|
||||
logger: z.unknown().optional(),
|
||||
sink: z.unknown().optional(),
|
||||
secretKey: z.string().min(1).optional(),
|
||||
samplingRate: z.number().min(0).max(1).default(1),
|
||||
excludePaths: z.array(z.string()).default([...DEFAULT_EXCLUDE_PATHS]),
|
||||
redactQueryParams: z
|
||||
.array(z.string())
|
||||
.default([...DEFAULT_REDACT_QUERY]),
|
||||
redactHeaders: z.array(z.string()).default([...DEFAULT_REDACT_HEADERS]),
|
||||
redactBodyFields: z.array(z.string()).default([...DEFAULT_REDACT_BODY]),
|
||||
recordResponseSize: z.boolean().default(true),
|
||||
recordRequestBody: z.boolean().default(false),
|
||||
enableChainIntegrity: z.boolean().default(false),
|
||||
rng: z.unknown().optional(),
|
||||
})
|
||||
.transform((parsed) => {
|
||||
const resolved = {
|
||||
...(parsed.logger !== undefined ? { logger: parsed.logger } : {}),
|
||||
...(parsed.sink !== undefined ? { sink: parsed.sink } : {}),
|
||||
secretKey: parsed.secretKey ?? DEV_DEFAULT_SECRET_KEY,
|
||||
samplingRate: parsed.samplingRate,
|
||||
excludePaths: parsed.excludePaths,
|
||||
redactQueryParams: parsed.redactQueryParams,
|
||||
redactHeaders: parsed.redactHeaders,
|
||||
redactBodyFields: parsed.redactBodyFields,
|
||||
recordResponseSize: parsed.recordResponseSize,
|
||||
recordRequestBody: parsed.recordRequestBody,
|
||||
enableChainIntegrity: parsed.enableChainIntegrity,
|
||||
...(parsed.rng !== undefined ? { rng: parsed.rng } : {}),
|
||||
};
|
||||
return resolved as unknown as Partial<AuditPluginConfig> & {
|
||||
secretKey: string;
|
||||
samplingRate: number;
|
||||
excludePaths: readonly string[];
|
||||
redactQueryParams: readonly string[];
|
||||
redactHeaders: readonly string[];
|
||||
redactBodyFields: readonly string[];
|
||||
recordResponseSize: boolean;
|
||||
recordRequestBody: boolean;
|
||||
enableChainIntegrity: boolean;
|
||||
};
|
||||
});
|
||||
|
||||
export type AuditPluginOptionsInput = z.input<typeof AuditPluginOptionsSchema>;
|
||||
|
||||
export type AuditPluginOptionsResolved = z.output<
|
||||
typeof AuditPluginOptionsSchema
|
||||
>;
|
||||
|
||||
export { DEV_DEFAULT_SECRET_KEY };
|
||||
@@ -0,0 +1,72 @@
|
||||
# services/audit-service
|
||||
|
||||
## Purpose
|
||||
|
||||
Constructs the `AuditService` adapter the plugin registers in `ServiceRegistry` under the `auditService` slot. The adapter wraps a constructed `AuditLogger` from `@suaveplan/audit-logging` so downstream code depends on the stable interface declared in `../types/types.ts` instead of binding to whatever the universal library exposes today. If the library renames or removes a method later, only this adapter file changes.
|
||||
|
||||
The wrapper is intentionally thin — every method delegates to the underlying logger one-for-one — but it gives the plugin a clean seam to add cross-cutting behaviour (tenancy enforcement, OTel correlation, idempotency) without touching every call site.
|
||||
|
||||
## Features
|
||||
|
||||
- 1:1 proxy over `AuditLogger.log / query / verifyChainIntegrity / exportGDPRData / applyRetentionPolicy`.
|
||||
- `getLogger()` escape hatch for callers that need direct access (the underlying logger exposes operations not in the stable surface, e.g. event-bus introspection).
|
||||
- Constructor takes a plain `{ logger }` dependency bundle — easy to swap or stub at test time.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AuditLogger, MemoryAuditAdapter } from "@suaveplan/audit-logging";
|
||||
import { createAuditService } from "@suaveplan/api-plugin-audit/services/audit-service";
|
||||
|
||||
const logger = new AuditLogger({
|
||||
storage: new MemoryAuditAdapter(),
|
||||
secretKey: process.env.AUDIT_SECRET_KEY!,
|
||||
});
|
||||
|
||||
const auditService = createAuditService({ logger });
|
||||
await auditService.log({
|
||||
type: "user.created",
|
||||
severity: "info",
|
||||
actor: { userId: "u1" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
AuditLogger,
|
||||
MemoryAuditAdapter,
|
||||
type AuditEvent,
|
||||
} from "@suaveplan/audit-logging";
|
||||
import { createAuditService } from "@suaveplan/api-plugin-audit/services/audit-service";
|
||||
|
||||
const auditService = createAuditService({
|
||||
logger: new AuditLogger({
|
||||
storage: new MemoryAuditAdapter(),
|
||||
secretKey: process.env.AUDIT_SECRET_KEY!,
|
||||
enableChainIntegrity: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const events: AuditEvent[] = await auditService.query({
|
||||
userId: "u1",
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
});
|
||||
|
||||
const verdict = await auditService.verifyChainIntegrity(events);
|
||||
if (!verdict.valid) {
|
||||
console.error("chain broken at", verdict.brokenAt, verdict.reason);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createAuditService(deps: { logger: AuditLogger }): AuditService`
|
||||
- Returns an object satisfying the `AuditService` interface from `../types/types.ts`.
|
||||
- Methods: `getLogger()`, `log(event)`, `query(opts)`, `verifyChainIntegrity(events)`, `exportGDPRData(userId, signal?)`, `applyRetentionPolicy(signal?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every method returns the underlying `Promise` directly — no wrapping, no try/catch — because the plugin's middleware path handles sink failures explicitly via `audit.sink_failures_total`. Consumers that call `auditService.log(...)` from a handler get the raw `AuditError` from `@suaveplan/audit-logging` and can branch on its codes. This separation keeps the middleware (which must never throw into the request response) and the handler-level helper (which can surface failures) consistent.
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AuditLogger, MemoryAuditAdapter } from "@suaveplan/audit-logging";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import "./registry-augmentation.js";
|
||||
import type { ServiceRegistry } from "@suaveplan/api-plugin";
|
||||
import type { AuditService } from "../types/types.js";
|
||||
import { createAuditService } from "./audit-service.js";
|
||||
|
||||
describe("services/audit-service spec-conformance", () => {
|
||||
it("ServiceRegistry slot typed as AuditService", { timeout: 5000 }, () => {
|
||||
type Slot = ServiceRegistry["auditService"];
|
||||
expectTypeOf<Slot>().toEqualTypeOf<AuditService>();
|
||||
});
|
||||
|
||||
it("exposes the 6 declared methods at runtime", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const service = createAuditService({
|
||||
logger: new AuditLogger({
|
||||
storage: new MemoryAuditAdapter(),
|
||||
secretKey: "x".repeat(32),
|
||||
}),
|
||||
});
|
||||
expect(typeof service.log).toBe("function");
|
||||
expect(typeof service.query).toBe("function");
|
||||
expect(typeof service.getLogger).toBe("function");
|
||||
expect(typeof service.verifyChainIntegrity).toBe("function");
|
||||
expect(typeof service.exportGDPRData).toBe("function");
|
||||
expect(typeof service.applyRetentionPolicy).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
AuditLogger,
|
||||
AuditSeverity,
|
||||
MemoryAuditAdapter,
|
||||
} from "@suaveplan/audit-logging";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createAuditService } from "./audit-service.js";
|
||||
|
||||
function makeService() {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const logger = new AuditLogger({
|
||||
storage,
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
return { service: createAuditService({ logger }), storage, logger };
|
||||
}
|
||||
|
||||
describe("createAuditService", () => {
|
||||
it("log proxies to underlying AuditLogger", { timeout: 5000 }, async () => {
|
||||
const { service, storage } = makeService();
|
||||
await service.log({
|
||||
type: "test.event",
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
});
|
||||
const all = storage.snapshot();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]?.actor.userId).toBe("u1");
|
||||
});
|
||||
|
||||
it("query proxies to underlying AuditLogger", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
await service.log({
|
||||
type: "test.event",
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
});
|
||||
const results = await service.query({ userId: "u1" });
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("getLogger returns the underlying AuditLogger", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { service, logger } = makeService();
|
||||
expect(service.getLogger()).toBe(logger);
|
||||
});
|
||||
|
||||
it("verifyChainIntegrity proxies to underlying AuditLogger", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
const evt = await service.log({
|
||||
type: "x",
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: {},
|
||||
});
|
||||
const verdict = await service.verifyChainIntegrity([evt]);
|
||||
expect(verdict.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("exportGDPRData proxies to underlying AuditLogger", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
await service.log({
|
||||
type: "x",
|
||||
severity: AuditSeverity.INFO,
|
||||
actor: { userId: "u1" },
|
||||
});
|
||||
const ex = await service.exportGDPRData("u1");
|
||||
expect(ex.userId).toBe("u1");
|
||||
expect(ex.summary.totalEvents).toBe(1);
|
||||
});
|
||||
|
||||
it("applyRetentionPolicy proxies to underlying AuditLogger", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { service } = makeService();
|
||||
const result = await service.applyRetentionPolicy();
|
||||
expect(result.eventsDeleted).toBe(0);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
AuditEvent,
|
||||
AuditLogger,
|
||||
AuditQueryOptions,
|
||||
ChainVerificationResult,
|
||||
GDPRDataExport,
|
||||
RetentionResult,
|
||||
} from "@suaveplan/audit-logging";
|
||||
import type { AuditService } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Constructs the `AuditService` adapter that wraps a constructed
|
||||
* `AuditLogger` from `@suaveplan/audit-logging`. The wrapper exists
|
||||
* so consumers depend on a stable interface (declared in
|
||||
* `../types/types.ts`) regardless of evolution in the underlying
|
||||
* library.
|
||||
*/
|
||||
export function createAuditService(deps: {
|
||||
logger: AuditLogger;
|
||||
}): AuditService {
|
||||
const { logger } = deps;
|
||||
return {
|
||||
getLogger(): AuditLogger {
|
||||
return logger;
|
||||
},
|
||||
log(
|
||||
event: Omit<
|
||||
AuditEvent,
|
||||
"id" | "timestamp" | "hash" | "previousHash"
|
||||
>
|
||||
): Promise<AuditEvent> {
|
||||
return logger.log(event);
|
||||
},
|
||||
query(opts: AuditQueryOptions): Promise<AuditEvent[]> {
|
||||
return logger.query(opts);
|
||||
},
|
||||
verifyChainIntegrity(
|
||||
events: AuditEvent[]
|
||||
): Promise<ChainVerificationResult> {
|
||||
return logger.verifyChainIntegrity(events);
|
||||
},
|
||||
exportGDPRData(
|
||||
userId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<GDPRDataExport> {
|
||||
return logger.exportGDPRData(userId, signal);
|
||||
},
|
||||
applyRetentionPolicy(signal?: AbortSignal): Promise<RetentionResult> {
|
||||
return logger.applyRetentionPolicy(signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# services/registry-augmentation
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the `auditService` slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface via TypeScript module augmentation. Importing this module for its side effects (which the package root barrel does automatically) is sufficient — every consumer of `@suaveplan/api-plugin-audit` gets typed `ctx.getService("auditService")` access with the return type narrowed to `AuditService | undefined`, no casts.
|
||||
|
||||
The file also cumulatively re-declares the host-side `app` slot so the audit plugin's `onInit(ctx)` resolves `ctx.getService("app")` to `Hono` even when the consumer has not explicitly imported `@suaveplan/api`'s registry-augmentation. TypeScript merges duplicate declarations across modules, so the cumulative augmentation is safe.
|
||||
|
||||
## Features
|
||||
|
||||
- TypeScript module augmentation — zero runtime cost.
|
||||
- Cumulative — does not collide with `@suaveplan/api-plugin-auth`'s or `@suaveplan/api`'s own augmentations.
|
||||
- Side-effect imported by the package barrel so consumers do not need an explicit subpath import.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// In your app entry:
|
||||
import "@suaveplan/api-plugin-audit"; // side-effect: registers ServiceRegistry slot
|
||||
|
||||
// In a sibling plugin's onInit:
|
||||
const audit = ctx.getService("auditService"); // typed as AuditService | undefined
|
||||
await audit?.log({
|
||||
type: "user.created",
|
||||
severity: "info",
|
||||
actor: { userId: "u1" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// In a custom test harness that constructs PluginContext directly:
|
||||
import "@suaveplan/api-plugin-audit/services/audit-service"; // also surfaces the augmentation
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
|
||||
const ctx = createPluginContext({
|
||||
/* ... */
|
||||
});
|
||||
const audit = ctx.getService("auditService"); // AuditService | undefined
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
This file has no runtime exports; it contributes a single TypeScript `declare module "@suaveplan/api-plugin/service-registry"` block adding:
|
||||
|
||||
- `auditService: AuditService`
|
||||
- `app: Hono`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The augmentation must live in a module that is reachable from the consumer's TypeScript program (either via a direct import or via a transitive dependency that pulls it in). Importing the package barrel of `@suaveplan/api-plugin-audit` covers the common case. If the augmentation is missing at compile time, `ctx.getService("auditService")` types as `never` per the `ServiceRegistry` defaults and fails the build with `Argument of type '"auditService"' is not assignable…`. That failure is the intended UX — it tells the operator the plugin barrel was not loaded.
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* ServiceRegistry module augmentation — declares the `auditService`
|
||||
* slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
|
||||
* interface so downstream plugins typed against the augmented registry
|
||||
* see `ctx.getService("auditService")` resolve to `AuditService |
|
||||
* undefined` without any cast.
|
||||
*
|
||||
* Also re-declares the host-side slots the plugin reads (`app`) as a
|
||||
* cumulative augmentation so the audit plugin's `onInit(ctx)` can
|
||||
* resolve them with full typing even when the consumer has not
|
||||
* explicitly imported `@suaveplan/api` (e.g. during isolated
|
||||
* unit-tests of the plugin). TypeScript merges duplicate declarations,
|
||||
* so this is safe alongside `@suaveplan/api`'s declarations.
|
||||
*/
|
||||
import type { Hono } from "hono";
|
||||
import type { AuditService } from "../types/types.js";
|
||||
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
auditService: AuditService;
|
||||
app: Hono;
|
||||
}
|
||||
}
|
||||
65
packages/api-stack/api/api-plugin-audit/src/types/types.md
Normal file
65
packages/api-stack/api/api-plugin-audit/src/types/types.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# types/types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Central, runtime-free declaration of the interfaces the plugin's lifecycle, middleware, services, and context helpers depend on. Pulling these out of the implementation files lets the package barrel re-export the public type surface without dragging implementation modules into the type graph, and lets sibling packages (e.g. `@suaveplan/api-plugin-vault`) refer to `AuditService` without taking a runtime dep on this package's implementation files.
|
||||
|
||||
Everything here is structural: `AuditPluginOptions` is the caller-supplied shape (validated by `AuditPluginOptionsSchema`); `AuditPluginConfig` is the resolved shape consumed by the middleware; `AuditService` is the ServiceRegistry surface; `AuditContext` is the per-request handler-level helper. `RedactablePayload` and `RedactionRules` are the redactor's input contracts.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero runtime — every export is `interface` / `type`.
|
||||
- `AuditPluginOptions` makes every field optional (defaults are applied by the schema).
|
||||
- `AuditPluginConfig` is the fully-resolved post-validation shape; `sink`, `secretKey`, sampling, redaction lists, booleans, and the RNG are all required.
|
||||
- `AuditService` mirrors the methods consumers can call on the registered slot.
|
||||
- `AuditContext` is intentionally minimal (`log` + `getLogger`) so handlers stay focused.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AuditPluginOptions,
|
||||
AuditService,
|
||||
} from "@suaveplan/api-plugin-audit/types/types";
|
||||
|
||||
const options: AuditPluginOptions = { secretKey: process.env.AUDIT_SECRET_KEY };
|
||||
function withAuditService(svc: AuditService) {
|
||||
return svc.log({ type: "thing.created", severity: "info", actor: {} });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AuditPluginConfig,
|
||||
RedactionRules,
|
||||
} from "@suaveplan/api-plugin-audit/types/types";
|
||||
|
||||
const rules: RedactionRules = {
|
||||
headers: ["authorization", "x-internal-key"],
|
||||
query: ["token", "session"],
|
||||
body: ["password", "ssn"],
|
||||
};
|
||||
|
||||
function tweakSamplingRate(cfg: AuditPluginConfig, rate: number): AuditPluginConfig {
|
||||
return { ...cfg, samplingRate: rate };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AuditPluginOptions` — caller-supplied shape; every field optional.
|
||||
- `AuditPluginConfig` — resolved shape; required fields are populated by `AuditPluginOptionsSchema`.
|
||||
- `AuditService` — 6-method surface registered as `auditService` in `ServiceRegistry`.
|
||||
- `AuditContext` — 2-method per-request helper attached to `c.set("audit", ...)`.
|
||||
- `RedactablePayload` — `{ query?, headers?, body? }`.
|
||||
- `RedactionRules` — `{ query: readonly string[]; headers: readonly string[]; body: readonly string[] }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema file (`schemas/options.ts`) exposes `AuditPluginOptionsInput` / `AuditPluginOptionsResolved` derived from the Zod schema; the interfaces here are the canonical TypeScript shapes the rest of the package consumes. The two are kept in sync by hand because Zod's `infer` on a `.transform(...)` schema produces a less-friendly type than the hand-written interface. The narrowing (and any drift) is exercised by `src/schemas/options.test.ts`.
|
||||
101
packages/api-stack/api/api-plugin-audit/src/types/types.ts
Normal file
101
packages/api-stack/api/api-plugin-audit/src/types/types.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
AuditEvent,
|
||||
AuditLogger,
|
||||
AuditQueryOptions,
|
||||
AuditStorageAdapter,
|
||||
ChainVerificationResult,
|
||||
GDPRDataExport,
|
||||
RetentionResult,
|
||||
} from "@suaveplan/audit-logging";
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Caller-supplied options for `createAuditPlugin`. Validated at the
|
||||
* factory boundary by `AuditPluginOptionsSchema`.
|
||||
*/
|
||||
export interface AuditPluginOptions {
|
||||
logger?: LoggerLike;
|
||||
sink?: AuditStorageAdapter;
|
||||
secretKey?: string;
|
||||
samplingRate?: number;
|
||||
excludePaths?: readonly string[];
|
||||
redactQueryParams?: readonly string[];
|
||||
redactHeaders?: readonly string[];
|
||||
redactBodyFields?: readonly string[];
|
||||
recordResponseSize?: boolean;
|
||||
recordRequestBody?: boolean;
|
||||
enableChainIntegrity?: boolean;
|
||||
rng?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved (post-validation) plugin config. All optional fields from
|
||||
* the input are defaulted; identity-bearing fields stay optional.
|
||||
*/
|
||||
export interface AuditPluginConfig {
|
||||
logger?: LoggerLike;
|
||||
sink: AuditStorageAdapter;
|
||||
secretKey: string;
|
||||
samplingRate: number;
|
||||
excludePaths: readonly string[];
|
||||
redactQueryParams: readonly string[];
|
||||
redactHeaders: readonly string[];
|
||||
redactBodyFields: readonly string[];
|
||||
recordResponseSize: boolean;
|
||||
recordRequestBody: boolean;
|
||||
enableChainIntegrity: boolean;
|
||||
rng: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload shape passed to `redactPayload` — every field is optional,
|
||||
* so callers can redact only what they have.
|
||||
*/
|
||||
export interface RedactablePayload {
|
||||
query?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules supplied to `redactPayload`. Field names are matched exactly
|
||||
* for `query` and `body`; case-insensitively for `headers`.
|
||||
*/
|
||||
export interface RedactionRules {
|
||||
query: readonly string[];
|
||||
headers: readonly string[];
|
||||
body: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Public surface registered in `ServiceRegistry` under `auditService`.
|
||||
* Proxies the underlying `AuditLogger` with a stable surface that
|
||||
* survives changes in `@suaveplan/audit-logging`.
|
||||
*/
|
||||
export interface AuditService {
|
||||
getLogger(): AuditLogger;
|
||||
log(
|
||||
event: Omit<AuditEvent, "id" | "timestamp" | "hash" | "previousHash">
|
||||
): Promise<AuditEvent>;
|
||||
query(opts: AuditQueryOptions): Promise<AuditEvent[]>;
|
||||
verifyChainIntegrity(
|
||||
events: AuditEvent[]
|
||||
): Promise<ChainVerificationResult>;
|
||||
exportGDPRData(
|
||||
userId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<GDPRDataExport>;
|
||||
applyRetentionPolicy(signal?: AbortSignal): Promise<RetentionResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-request audit context attached to `c.set("audit", ...)` by the
|
||||
* middleware. Handlers call `c.get("audit").log(...)` to record
|
||||
* explicit, semantic events alongside the implicit request audit.
|
||||
*/
|
||||
export interface AuditContext {
|
||||
log(
|
||||
event: Omit<AuditEvent, "id" | "timestamp" | "hash" | "previousHash">
|
||||
): Promise<AuditEvent>;
|
||||
getLogger(): AuditLogger;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
|
||||
const ROOT = resolve(__dirname, "..");
|
||||
const SRC = join(ROOT, "src");
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) walk(full, out);
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
|
||||
const missing: string[] = [];
|
||||
for (const f of walk(SRC)) {
|
||||
if (!f.endsWith(".ts")) continue;
|
||||
if (f.endsWith(".test.ts")) continue;
|
||||
if (f.endsWith(".spec-conformance.test.ts")) continue;
|
||||
if (f.endsWith(".types.ts")) continue;
|
||||
if (f.endsWith("/index.ts")) continue;
|
||||
const md = f.replace(/\.ts$/, ".md");
|
||||
if (!existsSync(md)) missing.push(relative(ROOT, md));
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("every subpath export resolves to a real source file", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entries = pkg.exports as Record<string, unknown>;
|
||||
const missing: string[] = [];
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (key === "./package.json") continue;
|
||||
// The value is either string OR { import: { default: "..." } }
|
||||
const distPath =
|
||||
typeof value === "object" && value !== null
|
||||
? (
|
||||
value as {
|
||||
import?: { default?: string };
|
||||
}
|
||||
).import?.default
|
||||
: (value as string);
|
||||
if (typeof distPath !== "string") continue;
|
||||
const srcPath = distPath
|
||||
.replace(/^\.\/dist\//, "./src/")
|
||||
.replace(/\.js$/, ".ts");
|
||||
const abs = join(ROOT, srcPath);
|
||||
if (!existsSync(abs)) missing.push(`${key} → ${srcPath}`);
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import {
|
||||
type AuditEvent,
|
||||
AuditSeverity,
|
||||
MemoryAuditAdapter,
|
||||
} from "@suaveplan/audit-logging";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { createAuditPlugin } from "../../src/plugin/plugin.js";
|
||||
import type { AuditPluginConfig } from "../../src/types/types.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
describe("full-boot integration", () => {
|
||||
it("real api + plugin + sample route records redacted audit with principal and tenant", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const sink = new MemoryAuditAdapter();
|
||||
const plugin = createAuditPlugin({
|
||||
sink,
|
||||
secretKey: "x".repeat(32),
|
||||
redactQueryParams: ["token"],
|
||||
redactHeaders: ["authorization"],
|
||||
redactBodyFields: ["password"],
|
||||
recordRequestBody: true,
|
||||
});
|
||||
const app = new Hono();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
|
||||
// Drive lifecycle hooks the way @suaveplan/api would.
|
||||
const ctx = createPluginContext<AuditPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as AuditPluginConfig,
|
||||
services,
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
|
||||
// Pretend auth + tenant plugins have already populated the context.
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", { id: "u1" });
|
||||
c.set("tenant", { id: "t1" });
|
||||
await next();
|
||||
});
|
||||
|
||||
// Sample route that ALSO fires an explicit semantic event via
|
||||
// c.get("audit") to prove the handler-level helper is wired.
|
||||
app.post("/things/:id", async (c) => {
|
||||
const audit = c.get("audit");
|
||||
await audit.log({
|
||||
type: "thing.updated",
|
||||
severity: AuditSeverity.WARNING,
|
||||
actor: { userId: "u1" },
|
||||
target: { type: "thing", id: c.req.param("id") },
|
||||
});
|
||||
return c.json({ updated: true });
|
||||
});
|
||||
|
||||
const res = await app.request("/things/42?token=secret&safe=v", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer top-secret",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ password: "p", note: "hi" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const events = sink.snapshot();
|
||||
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const requestEvt = events.find((e) =>
|
||||
String(e.type).startsWith("http.")
|
||||
) as AuditEvent | undefined;
|
||||
expect(requestEvt).toBeDefined();
|
||||
const det = requestEvt?.details as {
|
||||
method: string;
|
||||
path: string;
|
||||
status: number;
|
||||
duration_ms: number;
|
||||
query: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
};
|
||||
expect(det.method).toBe("POST");
|
||||
expect(det.path).toBe("/things/42");
|
||||
expect(det.status).toBe(200);
|
||||
expect(det.duration_ms).toBeGreaterThanOrEqual(0);
|
||||
expect(det.query["token"]).toBe("[REDACTED]");
|
||||
expect(det.query["safe"]).toBe("v");
|
||||
expect(det.headers["authorization"]).toBe("[REDACTED]");
|
||||
expect(det.body?.["password"]).toBe("[REDACTED]");
|
||||
expect(det.body?.["note"]).toBe("hi");
|
||||
expect(requestEvt?.actor.userId).toBe("u1");
|
||||
expect(requestEvt?.tenantId).toBe("t1");
|
||||
|
||||
const semanticEvt = events.find((e) => e.type === "thing.updated") as
|
||||
| AuditEvent
|
||||
| undefined;
|
||||
expect(semanticEvt).toBeDefined();
|
||||
expect(semanticEvt?.target?.id).toBe("42");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { AuditLogger, MemoryAuditAdapter } from "@suaveplan/audit-logging";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { Hono } from "hono";
|
||||
import { createAuditMiddleware } from "../../src/middleware/audit-middleware.js";
|
||||
import type { AuditPluginConfig } from "../../src/types/types.js";
|
||||
|
||||
const noopHostLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
* Deterministic Mulberry32 RNG so the test is reproducible across
|
||||
* Bun + Node runtime engines.
|
||||
*/
|
||||
function mulberry32(seed: number): () => number {
|
||||
let t = seed >>> 0;
|
||||
return () => {
|
||||
t = (t + 0x6d2b79f5) >>> 0;
|
||||
let r = t;
|
||||
r = Math.imul(r ^ (r >>> 15), r | 1);
|
||||
r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
|
||||
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
describe("sampling integration", () => {
|
||||
it("50% sample rate over 1000 requests sits in [400, 600] band", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const storage = new MemoryAuditAdapter();
|
||||
const logger = new AuditLogger({
|
||||
storage,
|
||||
secretKey: "x".repeat(32),
|
||||
});
|
||||
const config: AuditPluginConfig = {
|
||||
sink: storage,
|
||||
secretKey: "x".repeat(32),
|
||||
samplingRate: 0.5,
|
||||
excludePaths: [],
|
||||
redactQueryParams: [],
|
||||
redactHeaders: [],
|
||||
redactBodyFields: [],
|
||||
recordResponseSize: false,
|
||||
recordRequestBody: false,
|
||||
enableChainIntegrity: false,
|
||||
rng: mulberry32(1234567),
|
||||
};
|
||||
|
||||
const app = new Hono();
|
||||
app.use(
|
||||
"*",
|
||||
createAuditMiddleware({
|
||||
logger,
|
||||
config,
|
||||
hostLogger: noopHostLogger,
|
||||
})
|
||||
);
|
||||
app.get("/x", (c) => c.text("ok"));
|
||||
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
await app.request(`/x?i=${i}`);
|
||||
}
|
||||
|
||||
const count = storage.snapshot().length;
|
||||
expect(count).toBeGreaterThanOrEqual(400);
|
||||
expect(count).toBeLessThanOrEqual(600);
|
||||
});
|
||||
});
|
||||
14
packages/api-stack/api/api-plugin-audit/tests/setup/hono-shim.d.ts
vendored
Normal file
14
packages/api-stack/api/api-plugin-audit/tests/setup/hono-shim.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
// Test-only Hono ContextVariableMap shim — adds the slots normally
|
||||
// contributed by sibling plugins (`@suaveplan/api-plugin-auth`,
|
||||
// `@suaveplan/api-tenant`, `@suaveplan/api`) so test files can call
|
||||
// `c.set("user", ...)`, `c.set("tenant", ...)`, `c.set("requestId", ...)`
|
||||
// without ts2769 errors when the sibling packages are not direct deps.
|
||||
import "hono";
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
user: { id?: string } | null | undefined;
|
||||
tenant: { id?: string } | null | undefined;
|
||||
requestId: string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-audit.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-audit/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-audit/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts", "tests/**/*.d.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-audit/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin-audit/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
37
packages/api-stack/api/api-plugin-audit/vite.config.ts
Normal file
37
packages/api-stack/api/api-plugin-audit/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-plugin-audit",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
/^@hono\//,
|
||||
"hono",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/api-stack/api/api-plugin-audit/vitest.config.ts
Normal file
33
packages/api-stack/api/api-plugin-audit/vitest.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 120000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user