Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
# @suaveplan/audit-logging - Tamper-proof, GDPR-compliant audit logging: cryptographic chain-integrity, pluggable storage, retention policy, GDPR export, and a rich event taxonomy
## Summary
Tamper-proof, GDPR-compliant audit logging for Suaveplan. Provides a cryptographic chain-integrity engine, pluggable storage adapters, GDPR data export, configurable retention policies, and a rich event taxonomy spanning auth, authorization, data access, system, and GDPR events.
<!-- begin:auto-docs -->
## Documentation tree
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [audit-error](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [audit-logging.schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-error](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logging.schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-error](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logging.schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-error](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logging.schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [storage](./src/adapters/storage/storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_ - [audit-logger](./src/core/audit-logger/audit-logger.md) — <!-- begin:auto-docs --> _(no submodules)_- [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_ - [chain-integrity](./src/core/chain-integrity/chain-integrity.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [audit-error](./src/errors/audit-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [audit-logging.schemas](./src/schemas/audit-logging.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_
## Features
- **AuditLogger** — orchestrates log, query, verify, export, and retention with injectable dependencies
- **Chain integrity** — HMAC-SHA256 signed events linked in a tamper-evident chain via `previousHash`
- **MemoryAuditAdapter** — full in-memory storage with rich query filtering for tests and development
- **GDPR export** — Article 15/20 compliant `exportGDPRData()` with event summary statistics
- **Retention policy** — archive-before-delete with configurable TTL and location
- **Zod schemas** — runtime validation for all public types
- **OTel instrumentation** — counter `audit.events_logged_total` with `event_type` and `severity` labels
- **Event bus integration** — emits `audit:event:logged` after each successful log
## Installation
```bash
bun add @suaveplan/audit-logging
```
## Quick Start
```ts
import { AuditLogger, MemoryAuditAdapter, AuditEventType, AuditSeverity } from "@suaveplan/audit-logging";
const logger = new AuditLogger({
storage: new MemoryAuditAdapter(),
secretKey: process.env.AUDIT_SECRET_KEY!,
enableChainIntegrity: true,
});
const event = await logger.log({
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId: "user-123", ipAddress: "203.0.113.1" },
target: { type: "session", id: "sess-456" },
});
console.log(event.hash); // hex HMAC-SHA256
// Verify chain integrity
const events = await logger.query({ order: "asc" });
const result = await logger.verifyChainIntegrity(events);
console.log(result.valid); // true
// GDPR export
const gdprExport = await logger.exportGDPRData("user-123");
console.log(gdprExport.summary.totalEvents);
```
## Modules
| Module | Description |
|--------|-------------|
| [`src/core/audit-logger/audit-logger.md`](src/core/audit-logger/audit-logger.md) | Main logger class |
| [`src/core/chain-integrity/chain-integrity.md`](src/core/chain-integrity/chain-integrity.md) | HMAC chain signing and verification |
| [`src/adapters/memory-adapter/memory-adapter.md`](src/adapters/memory-adapter/memory-adapter.md) | In-memory storage adapter |
| [`src/adapters/storage-adapter/storage-adapter.md`](src/adapters/storage-adapter/storage-adapter.md) | Storage adapter interface |
| [`src/schemas/audit-logging.schemas.md`](src/schemas/audit-logging.schemas.md) | Zod validation schemas |
| [`src/errors/audit-error.md`](src/errors/audit-error.md) | Error codes and AuditError class |
| [`src/types/audit-logging.types.md`](src/types/audit-logging.types.md) | TypeScript type definitions |
## Event Taxonomy
| Category | Events |
|----------|--------|
| Authentication | `auth_login`, `auth_logout`, `auth_register`, `auth_password_change`, `auth_password_reset`, `auth_mfa_enable`, `auth_mfa_disable`, `auth_session_create`, `auth_session_revoke`, `auth_api_key_create`, `auth_api_key_revoke` |
| Authorization | `authz_permission_check`, `authz_permission_grant`, `authz_permission_revoke`, `authz_role_assign`, `authz_role_unassign` |
| Data Access | `data_create`, `data_read`, `data_update`, `data_delete`, `data_export`, `data_import` |
| System | `system_config_change`, `system_security_alert`, `system_rate_limit`, `system_error` |
| GDPR | `gdpr_data_export`, `gdpr_data_delete`, `gdpr_consent_change` |
## License
MIT

View File

@@ -0,0 +1,129 @@
{
"name": "@suaveplan/audit-logging",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Tamper-proof, GDPR-compliant audit logging: cryptographic chain-integrity, pluggable storage, retention policy, GDPR export, and a rich event taxonomy",
"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"
}
},
"./errors": {
"import": {
"types": "./dist/errors/audit-error.d.ts",
"default": "./dist/errors/audit-error.js"
},
"require": {
"types": "./dist/errors/audit-error.d.ts",
"default": "./dist/errors/audit-error.js"
}
},
"./adapters/storage": {
"import": {
"types": "./dist/adapters/storage/storage.d.ts",
"default": "./dist/adapters/storage/storage.js"
},
"require": {
"types": "./dist/adapters/storage/storage.d.ts",
"default": "./dist/adapters/storage/storage.js"
}
},
"./adapters/memory/memory": {
"import": {
"types": "./dist/adapters/memory/memory.d.ts",
"default": "./dist/adapters/memory/memory.js"
},
"require": {
"types": "./dist/adapters/memory/memory.d.ts",
"default": "./dist/adapters/memory/memory.js"
}
},
"./core/audit-logger/audit-logger": {
"import": {
"types": "./dist/core/audit-logger/audit-logger.d.ts",
"default": "./dist/core/audit-logger/audit-logger.js"
},
"require": {
"types": "./dist/core/audit-logger/audit-logger.d.ts",
"default": "./dist/core/audit-logger/audit-logger.js"
}
},
"./core/chain-integrity/chain-integrity": {
"import": {
"types": "./dist/core/chain-integrity/chain-integrity.d.ts",
"default": "./dist/core/chain-integrity/chain-integrity.js"
},
"require": {
"types": "./dist/core/chain-integrity/chain-integrity.d.ts",
"default": "./dist/core/chain-integrity/chain-integrity.js"
}
},
"./schemas/audit-logging.schemas": {
"import": {
"types": "./dist/schemas/audit-logging.schemas.d.ts",
"default": "./dist/schemas/audit-logging.schemas.js"
},
"require": {
"types": "./dist/schemas/audit-logging.schemas.d.ts",
"default": "./dist/schemas/audit-logging.schemas.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"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": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/crypto": "workspace:^",
"@suaveplan/crypto-hash": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/serialize": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "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"
]
}

View File

@@ -0,0 +1 @@
export { MemoryAuditAdapter } from "./memory.js";

View File

@@ -0,0 +1,116 @@
# memory-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides an in-memory implementation of `AuditStorageAdapter` backed by a `Map<string, AuditEvent>`. Designed for unit tests, integration tests, local development, and single-node scenarios where persistence across process restarts is not required. All filtering, sorting, and pagination are performed in-process without any external dependencies.
## Features
- Full `AuditStorageAdapter` implementation — save, query, getLatestEvent, deleteExpired, archive, count
- Rich query filtering: event types, userId, sessionId, severity (range-based), targetType, targetId, time range, tenantId
- Stable ASC/DESC ordering by timestamp with offset/limit pagination
- Separate archive store for events moved by `archive()`
- `snapshot()` and `archivedSnapshot()` helpers for test assertions
- `clear()` for test teardown
## Usage: Basic Example
```ts
import { MemoryAuditAdapter } from "@suaveplan/audit-logging/adapters/memory/memory-adapter";
import { AuditLogger } from "@suaveplan/audit-logging/core/audit-logger/audit-logger";
const adapter = new MemoryAuditAdapter();
const logger = new AuditLogger({
storage: adapter,
secretKey: "test-secret",
});
await logger.log({
type: "auth_login",
severity: "info",
actor: { userId: "user-1" },
hash: "",
});
const events = adapter.snapshot();
console.log(events.length); // 1
```
## Usage: Advanced Example
```ts
import { MemoryAuditAdapter } from "@suaveplan/audit-logging/adapters/memory/memory-adapter";
import { AuditEventType, AuditSeverity } from "@suaveplan/audit-logging";
const adapter = new MemoryAuditAdapter();
// Seed events
for (let i = 0; i < 20; i++) {
await adapter.save({
id: `evt-${i}`,
type: i % 2 === 0 ? AuditEventType.AUTH_LOGIN : AuditEventType.DATA_READ,
severity: AuditSeverity.INFO,
actor: { userId: `user-${i % 3}` },
timestamp: new Date(Date.now() - i * 60_000),
hash: "placeholder",
});
}
// Paginated query
const page1 = await adapter.query({ limit: 5, offset: 0, order: "desc" });
const page2 = await adapter.query({ limit: 5, offset: 5, order: "desc" });
// Count by user
const userCount = await adapter.count({ userId: "user-0" });
// Archive old events
const cutoff = new Date(Date.now() - 10 * 60_000);
const archived = await adapter.archive(cutoff, "s3://audit-archive/2024");
console.log(`Archived ${archived} events`);
// Delete remaining old events
const deleted = await adapter.deleteExpired(cutoff);
console.log(`Deleted ${deleted} expired events`);
```
## API Reference
### `MemoryAuditAdapter`
**`save(event: AuditEvent): Promise<void>`**
Stores the event by its `id`. Overwrites if `id` already exists.
**`query(options: AuditQueryOptions): Promise<AuditEvent[]>`**
Filters, sorts, and paginates the in-memory event set. All filter fields are ANDed together.
- `severity` accepts a single value or array; filters to events matching any of the provided severities
- `order` defaults to `"asc"`
- `offset` defaults to 0; `limit` defaults to unlimited
**`getLatestEvent(tenantId?: string): Promise<AuditEvent | null>`**
Returns the event with the most recent timestamp, optionally scoped to a tenant. Returns `null` if no events exist.
**`deleteExpired(before: Date): Promise<number>`**
Removes all events where `timestamp < before`. Returns the count deleted.
**`archive(before: Date, location: string): Promise<number>`**
Moves events where `timestamp < before` into the internal archive map (keyed by `location:id`). Returns the count archived.
**`count(options?: Partial<AuditQueryOptions>): Promise<number>`**
Returns the total count of events matching the filter, or total size if no options provided.
**`snapshot(): AuditEvent[]`**
Returns a copy of all currently stored (non-archived) events. For test assertions.
**`archivedSnapshot(): AuditEvent[]`**
Returns all archived events. For test assertions.
**`clear(): void`**
Empties both the main store and the archive. Use in `afterEach` test teardown.
## Implementation Notes
The severity filter applies range semantics: when multiple severities are provided it matches events whose severity order falls between the minimum and maximum provided ordinals. This is intentional — it allows callers to request `[WARNING, ERROR, CRITICAL]` without listing every intermediate level. Events are sorted using `Array.sort()` which is stable in V8 / Bun, so tie-breaking by insertion order is reliable. The internal `archived` map is keyed by `location:id` to allow the same event to be archived to multiple locations in tests without collision.

View File

@@ -0,0 +1,297 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { AuditEventType, AuditSeverity } from "../../constants/index.js";
import type { AuditEvent } from "../../types/audit-logging.types.js";
import { MemoryAuditAdapter } from "./memory.js";
function makeEvent(overrides: Partial<AuditEvent> = {}): AuditEvent {
return {
id: `evt-${Math.random().toString(36).slice(2)}`,
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId: "user-1" },
timestamp: new Date("2024-06-01T12:00:00Z"),
hash: "fakehash",
...overrides,
};
}
describe("MemoryAuditAdapter", () => {
let adapter: MemoryAuditAdapter;
beforeEach(() => {
adapter = new MemoryAuditAdapter();
});
describe("save and snapshot", () => {
it("saves and retrieves an event", { timeout: 5000 }, async () => {
const event = makeEvent({ id: "evt-1" });
await adapter.save(event);
expect(adapter.snapshot()).toEqual([event]);
});
it("overwrites event with same id", { timeout: 5000 }, async () => {
const e1 = makeEvent({ id: "evt-1", severity: AuditSeverity.INFO });
const e2 = makeEvent({
id: "evt-1",
severity: AuditSeverity.ERROR,
});
await adapter.save(e1);
await adapter.save(e2);
expect(adapter.snapshot().length).toBe(1);
expect(adapter.snapshot()[0]?.severity).toBe(AuditSeverity.ERROR);
});
});
describe("query", () => {
beforeEach(async () => {
await adapter.save(
makeEvent({
id: "a",
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId: "u1" },
timestamp: new Date("2024-01-01"),
})
);
await adapter.save(
makeEvent({
id: "b",
type: AuditEventType.DATA_READ,
severity: AuditSeverity.WARNING,
actor: { userId: "u2", sessionId: "s2" },
timestamp: new Date("2024-02-01"),
})
);
await adapter.save(
makeEvent({
id: "c",
type: AuditEventType.DATA_DELETE,
severity: AuditSeverity.ERROR,
actor: { userId: "u1" },
target: { type: "doc", id: "doc-1" },
timestamp: new Date("2024-03-01"),
tenantId: "t1",
})
);
});
it("returns all events when no filter", { timeout: 5000 }, async () => {
const result = await adapter.query({});
expect(result.length).toBe(3);
});
it("filters by eventType", { timeout: 5000 }, async () => {
const result = await adapter.query({
eventTypes: [AuditEventType.AUTH_LOGIN],
});
expect(result.length).toBe(1);
expect(result[0]?.id).toBe("a");
});
it("filters by multiple eventTypes", { timeout: 5000 }, async () => {
const result = await adapter.query({
eventTypes: [
AuditEventType.AUTH_LOGIN,
AuditEventType.DATA_READ,
],
});
expect(result.length).toBe(2);
});
it("filters by userId", { timeout: 5000 }, async () => {
const result = await adapter.query({ userId: "u1" });
expect(result.map((e) => e.id).sort()).toEqual(["a", "c"]);
});
it("filters by sessionId", { timeout: 5000 }, async () => {
const result = await adapter.query({ sessionId: "s2" });
expect(result.length).toBe(1);
expect(result[0]?.id).toBe("b");
});
it("filters by single severity", { timeout: 5000 }, async () => {
const result = await adapter.query({
severity: AuditSeverity.WARNING,
});
expect(result.length).toBe(1);
expect(result[0]?.id).toBe("b");
});
it("filters by severity array", { timeout: 5000 }, async () => {
const result = await adapter.query({
severity: [AuditSeverity.WARNING, AuditSeverity.ERROR],
});
expect(result.map((e) => e.id).sort()).toEqual(["b", "c"]);
});
it("filters by targetType", { timeout: 5000 }, async () => {
const result = await adapter.query({ targetType: "doc" });
expect(result.length).toBe(1);
expect(result[0]?.id).toBe("c");
});
it("filters by targetId", { timeout: 5000 }, async () => {
const result = await adapter.query({ targetId: "doc-1" });
expect(result.length).toBe(1);
});
it("filters by startTime", { timeout: 5000 }, async () => {
const result = await adapter.query({
startTime: new Date("2024-02-01"),
});
expect(result.length).toBe(2);
});
it("filters by endTime", { timeout: 5000 }, async () => {
const result = await adapter.query({
endTime: new Date("2024-02-01"),
});
expect(result.length).toBe(2);
});
it("filters by tenantId", { timeout: 5000 }, async () => {
const result = await adapter.query({ tenantId: "t1" });
expect(result.length).toBe(1);
expect(result[0]?.id).toBe("c");
});
it("orders asc by default", { timeout: 5000 }, async () => {
const result = await adapter.query({});
expect(result[0]?.id).toBe("a");
expect(result[2]?.id).toBe("c");
});
it("orders desc", { timeout: 5000 }, async () => {
const result = await adapter.query({ order: "desc" });
expect(result[0]?.id).toBe("c");
});
it("applies limit", { timeout: 5000 }, async () => {
const result = await adapter.query({ limit: 2 });
expect(result.length).toBe(2);
});
it("applies offset", { timeout: 5000 }, async () => {
const result = await adapter.query({ offset: 1 });
expect(result.length).toBe(2);
});
it("applies limit and offset", { timeout: 5000 }, async () => {
const result = await adapter.query({ limit: 1, offset: 1 });
expect(result.length).toBe(1);
expect(result[0]?.id).toBe("b");
});
});
describe("getLatestEvent", () => {
it("returns null when empty", { timeout: 5000 }, async () => {
expect(await adapter.getLatestEvent()).toBeNull();
});
it("returns most recent event", { timeout: 5000 }, async () => {
await adapter.save(
makeEvent({ id: "old", timestamp: new Date("2024-01-01") })
);
await adapter.save(
makeEvent({ id: "new", timestamp: new Date("2024-12-31") })
);
const latest = await adapter.getLatestEvent();
expect(latest?.id).toBe("new");
});
it("filters by tenantId", { timeout: 5000 }, async () => {
await adapter.save(
makeEvent({
id: "t1e",
timestamp: new Date("2024-12-31"),
tenantId: "t1",
})
);
await adapter.save(
makeEvent({
id: "t2e",
timestamp: new Date("2024-12-30"),
tenantId: "t2",
})
);
const latest = await adapter.getLatestEvent("t2");
expect(latest?.id).toBe("t2e");
});
it("returns null when no events for tenant", {
timeout: 5000,
}, async () => {
await adapter.save(makeEvent({ tenantId: "t1" }));
expect(await adapter.getLatestEvent("t2")).toBeNull();
});
});
describe("deleteExpired", () => {
it("deletes events before date", { timeout: 5000 }, async () => {
await adapter.save(
makeEvent({ id: "old", timestamp: new Date("2024-01-01") })
);
await adapter.save(
makeEvent({ id: "new", timestamp: new Date("2024-12-31") })
);
const deleted = await adapter.deleteExpired(new Date("2024-06-01"));
expect(deleted).toBe(1);
expect(adapter.snapshot().length).toBe(1);
});
it("returns 0 when nothing expired", { timeout: 5000 }, async () => {
await adapter.save(
makeEvent({ timestamp: new Date("2024-12-31") })
);
expect(await adapter.deleteExpired(new Date("2024-01-01"))).toBe(0);
});
});
describe("archive", () => {
it("moves events to archive store", { timeout: 5000 }, async () => {
await adapter.save(
makeEvent({ id: "old", timestamp: new Date("2024-01-01") })
);
await adapter.save(
makeEvent({ id: "new", timestamp: new Date("2024-12-31") })
);
const count = await adapter.archive(
new Date("2024-06-01"),
"s3://bucket/archive"
);
expect(count).toBe(1);
expect(adapter.snapshot().length).toBe(1);
expect(adapter.archivedSnapshot().length).toBe(1);
});
});
describe("count", () => {
it("counts all events", { timeout: 5000 }, async () => {
await adapter.save(makeEvent());
await adapter.save(makeEvent());
expect(await adapter.count()).toBe(2);
});
it("counts with filter", { timeout: 5000 }, async () => {
await adapter.save(makeEvent({ type: AuditEventType.AUTH_LOGIN }));
await adapter.save(makeEvent({ type: AuditEventType.DATA_READ }));
expect(
await adapter.count({ eventTypes: [AuditEventType.AUTH_LOGIN] })
).toBe(1);
});
it("returns 0 for empty store", { timeout: 5000 }, async () => {
expect(await adapter.count()).toBe(0);
});
});
describe("clear", () => {
it("clears all events", { timeout: 5000 }, async () => {
await adapter.save(makeEvent());
await adapter.archive(new Date("2099-01-01"), "loc");
adapter.clear();
expect(adapter.snapshot().length).toBe(0);
expect(adapter.archivedSnapshot().length).toBe(0);
});
});
});

View File

@@ -0,0 +1,178 @@
import { AuditSeverity } from "../../constants/index.js";
import type {
AuditEvent,
AuditQueryOptions,
AuditStorageAdapter,
} from "../../types/audit-logging.types.js";
const SEVERITY_ORDER: Record<AuditSeverity, number> = {
[AuditSeverity.DEBUG]: 0,
[AuditSeverity.INFO]: 1,
[AuditSeverity.WARNING]: 2,
[AuditSeverity.ERROR]: 3,
[AuditSeverity.CRITICAL]: 4,
};
/**
* In-memory implementation of `AuditStorageAdapter`.
*
* Events are stored in a `Map<string, AuditEvent>` keyed by event ID.
* Suitable for testing, development, and single-node deployments that do
* not require persistence across restarts.
*/
export class MemoryAuditAdapter implements AuditStorageAdapter {
protected readonly events: Map<string, AuditEvent> = new Map();
protected readonly archived: Map<string, AuditEvent> = new Map();
async save(event: AuditEvent): Promise<void> {
this.events.set(event.id, event);
}
async query(options: AuditQueryOptions): Promise<AuditEvent[]> {
let results = Array.from(this.events.values());
results = this.applyFilters(results, options);
const order = options.order ?? "asc";
results.sort((a, b) => {
const diff = a.timestamp.getTime() - b.timestamp.getTime();
return order === "asc" ? diff : -diff;
});
const offset = options.offset ?? 0;
results = results.slice(offset);
if (options.limit !== undefined) {
results = results.slice(0, options.limit);
}
return results;
}
async getLatestEvent(tenantId?: string): Promise<AuditEvent | null> {
let results = Array.from(this.events.values());
if (tenantId !== undefined) {
results = results.filter((e) => e.tenantId === tenantId);
}
if (results.length === 0) {
return null;
}
results.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
// results.length > 0 is guaranteed by the check above
return results[0] as AuditEvent;
}
async deleteExpired(before: Date): Promise<number> {
let count = 0;
for (const [id, event] of this.events) {
if (event.timestamp < before) {
this.events.delete(id);
count++;
}
}
return count;
}
async archive(before: Date, location: string): Promise<number> {
let count = 0;
for (const [id, event] of this.events) {
if (event.timestamp < before) {
this.archived.set(`${location}:${id}`, event);
this.events.delete(id);
count++;
}
}
return count;
}
async count(options?: Partial<AuditQueryOptions>): Promise<number> {
if (options === undefined) {
return this.events.size;
}
const all = Array.from(this.events.values());
return this.applyFilters(all, options).length;
}
/** Returns a snapshot of all stored events for testing purposes. */
snapshot(): AuditEvent[] {
return Array.from(this.events.values());
}
/** Clears all stored events. */
clear(): void {
this.events.clear();
this.archived.clear();
}
/** Returns archived events for inspection in tests. */
archivedSnapshot(): AuditEvent[] {
return Array.from(this.archived.values());
}
protected applyFilters(
events: AuditEvent[],
options: Partial<AuditQueryOptions>
): AuditEvent[] {
let results = events;
if (options.eventTypes !== undefined && options.eventTypes.length > 0) {
const types = new Set(options.eventTypes as string[]);
results = results.filter((e) => types.has(e.type as string));
}
if (options.userId !== undefined) {
const uid = options.userId;
results = results.filter((e) => e.actor.userId === uid);
}
if (options.sessionId !== undefined) {
const sid = options.sessionId;
results = results.filter((e) => e.actor.sessionId === sid);
}
if (options.severity !== undefined) {
const severities = Array.isArray(options.severity)
? options.severity
: [options.severity];
const orders = severities.map((s) => SEVERITY_ORDER[s] as number);
const minOrder = Math.min(...orders);
const maxOrder = Math.max(...orders);
results = results.filter((e) => {
const ord = SEVERITY_ORDER[
e.severity as AuditSeverity
] as number;
return ord >= minOrder && ord <= maxOrder;
});
}
if (options.targetType !== undefined) {
const tt = options.targetType;
results = results.filter((e) => e.target?.type === tt);
}
if (options.targetId !== undefined) {
const tid = options.targetId;
results = results.filter((e) => e.target?.id === tid);
}
if (options.startTime !== undefined) {
const st = options.startTime;
results = results.filter((e) => e.timestamp >= st);
}
if (options.endTime !== undefined) {
const et = options.endTime;
results = results.filter((e) => e.timestamp <= et);
}
if (options.tenantId !== undefined) {
const tid = options.tenantId;
results = results.filter((e) => e.tenantId === tid);
}
return results;
}
}

View File

@@ -0,0 +1,4 @@
export {
type AuditStorageAdapter,
BaseAuditStorageAdapter,
} from "./storage.js";

View File

@@ -0,0 +1,101 @@
# storage-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `AuditStorageAdapter` interface contract that all storage backends must implement. This adapter pattern decouples the `AuditLogger` from any specific persistence technology — in-memory, SQLite, SurrealDB, PostgreSQL, or S3 — allowing implementations to be swapped without changing logger code.
## Features
- `AuditStorageAdapter` interface with six methods covering the full CRUD + lifecycle surface
- `BaseAuditStorageAdapter` abstract class with typed abstract method signatures for concrete adapters
- Supports pagination, time-range filtering, tenant scoping, and soft-archive semantics
## Usage: Basic Example
```ts
import type { AuditStorageAdapter } from "@suaveplan/audit-logging/adapters/storage";
class MyAdapter implements AuditStorageAdapter {
async save(event) { /* ... */ }
async query(options) { return []; }
async getLatestEvent(tenantId?) { return null; }
async deleteExpired(before) { return 0; }
async archive(before, location) { return 0; }
async count(options?) { return 0; }
}
```
## Usage: Advanced Example
```ts
import { BaseAuditStorageAdapter } from "@suaveplan/audit-logging/adapters/storage";
import type { AuditEvent, AuditQueryOptions } from "@suaveplan/audit-logging";
class SqliteAuditAdapter extends BaseAuditStorageAdapter {
constructor(private readonly db: DatabaseAdapter) {
super();
}
async save(event: AuditEvent): Promise<void> {
await this.db.query("INSERT INTO audit_events VALUES (?)", [JSON.stringify(event)]);
}
async query(options: AuditQueryOptions): Promise<AuditEvent[]> {
// Build WHERE clause from options...
return [];
}
async getLatestEvent(tenantId?: string): Promise<AuditEvent | null> {
// SELECT ... ORDER BY timestamp DESC LIMIT 1
return null;
}
async deleteExpired(before: Date): Promise<number> {
const result = await this.db.query("DELETE FROM audit_events WHERE timestamp < ?", [before]);
return result.rowsAffected ?? 0;
}
async archive(before: Date, location: string): Promise<number> {
// Copy to archive, then mark deleted
return 0;
}
async count(options?: Partial<AuditQueryOptions>): Promise<number> {
const result = await this.db.query("SELECT COUNT(*) FROM audit_events");
return Number(result.rows[0][0]);
}
}
```
## API Reference
### `AuditStorageAdapter` (interface)
**`save(event: AuditEvent): Promise<void>`**
Persist a single audit event. Throws `AuditError` with `STORAGE_ERROR` on failure.
**`query(options: AuditQueryOptions): Promise<AuditEvent[]>`**
Filter and paginate events. Applies all provided filters; returns an empty array when no events match.
**`getLatestEvent(tenantId?: string): Promise<AuditEvent | null>`**
Returns the most recent event (by timestamp) for the given tenant, or globally if no tenant is specified. Used by `AuditLogger` to link HMAC chains.
**`deleteExpired(before: Date): Promise<number>`**
Hard-deletes all events with `timestamp < before`. Returns the count of deleted records.
**`archive(before: Date, location: string): Promise<number>`**
Copies events with `timestamp < before` to `location`, then marks them. Returns the count of archived records.
**`count(options?: Partial<AuditQueryOptions>): Promise<number>`**
Returns the total number of events matching the optional filter criteria.
### `BaseAuditStorageAdapter` (abstract class)
Abstract base that implements `AuditStorageAdapter` with abstract method signatures. Extend this class when building new adapters to get compile-time enforcement that all required methods are implemented.
## Implementation Notes
The interface is intentionally minimal — it does not prescribe transaction semantics, connection pooling, or retry logic, which are concerns of specific adapter implementations. The `archive` method is a two-phase operation; the exact archival format (JSON Lines, Parquet, CSV) is left to the implementation. The `getLatestEvent` method is called once per `log()` invocation when chain integrity is enabled, so implementations should ensure it is fast (indexed by timestamp).

View File

@@ -0,0 +1,21 @@
import type {
AuditEvent,
AuditQueryOptions,
AuditStorageAdapter,
} from "../../types/audit-logging.types.js";
export type { AuditStorageAdapter };
/**
* Base abstract class that provides shared validation helpers for
* `AuditStorageAdapter` implementations. Concrete adapters extend this
* and implement the abstract storage methods.
*/
export abstract class BaseAuditStorageAdapter implements AuditStorageAdapter {
abstract save(event: AuditEvent): Promise<void>;
abstract query(options: AuditQueryOptions): Promise<AuditEvent[]>;
abstract getLatestEvent(tenantId?: string): Promise<AuditEvent | null>;
abstract deleteExpired(before: Date): Promise<number>;
abstract archive(before: Date, location: string): Promise<number>;
abstract count(options?: Partial<AuditQueryOptions>): Promise<number>;
}

View File

@@ -0,0 +1,55 @@
/** Audit event type taxonomy covering auth, authorization, data access, system, and GDPR events. */
export enum AuditEventType {
// Authentication
AUTH_LOGIN = "auth_login",
AUTH_LOGOUT = "auth_logout",
AUTH_REGISTER = "auth_register",
AUTH_PASSWORD_CHANGE = "auth_password_change",
AUTH_PASSWORD_RESET = "auth_password_reset",
AUTH_MFA_ENABLE = "auth_mfa_enable",
AUTH_MFA_DISABLE = "auth_mfa_disable",
AUTH_SESSION_CREATE = "auth_session_create",
AUTH_SESSION_REVOKE = "auth_session_revoke",
AUTH_API_KEY_CREATE = "auth_api_key_create",
AUTH_API_KEY_REVOKE = "auth_api_key_revoke",
// Authorization
AUTHZ_PERMISSION_CHECK = "authz_permission_check",
AUTHZ_PERMISSION_GRANT = "authz_permission_grant",
AUTHZ_PERMISSION_REVOKE = "authz_permission_revoke",
AUTHZ_ROLE_ASSIGN = "authz_role_assign",
AUTHZ_ROLE_UNASSIGN = "authz_role_unassign",
// Data access
DATA_CREATE = "data_create",
DATA_READ = "data_read",
DATA_UPDATE = "data_update",
DATA_DELETE = "data_delete",
DATA_EXPORT = "data_export",
DATA_IMPORT = "data_import",
// System
SYSTEM_CONFIG_CHANGE = "system_config_change",
SYSTEM_SECURITY_ALERT = "system_security_alert",
SYSTEM_RATE_LIMIT = "system_rate_limit",
SYSTEM_ERROR = "system_error",
// GDPR
GDPR_DATA_EXPORT = "gdpr_data_export",
GDPR_DATA_DELETE = "gdpr_data_delete",
GDPR_CONSENT_CHANGE = "gdpr_consent_change",
}
/** Severity levels for audit events. */
export enum AuditSeverity {
DEBUG = "debug",
INFO = "info",
WARNING = "warning",
ERROR = "error",
CRITICAL = "critical",
}
/** Default table name for audit events in storage adapters. */
export const DEFAULT_AUDIT_TABLE = "audit_events";
/** Default number of days to retain audit events. */
export const DEFAULT_RETENTION_DAYS = 365;
/** OTel metric name for logged audit events. */
export const OTEL_EVENTS_LOGGED = "audit.events_logged_total";
/** Bus event emitted after each successful audit event log. */
export const BUS_AUDIT_EVENT_LOGGED = "audit:event:logged";

View File

@@ -0,0 +1,127 @@
# audit-logger
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `AuditLogger` class is the primary entry point for the `@suaveplan/audit-logging` package. It orchestrates event creation (ID generation, timestamping, HMAC signing), storage persistence, GDPR data export, and retention policy enforcement. All dependencies — storage adapter, clock, ID generator, logger, and event bus — are injected through the constructor, enabling full testability without mocks.
## Features
- `log()` — generates ID and timestamp, computes HMAC hash (with optional chain linking), saves event, emits OTel counter and bus event
- `query()` — delegates to the storage adapter with full filter support
- `verifyChainIntegrity()` — delegates to `verifyChain()` in chain-integrity module
- `exportGDPRData()` — collects all events for a user ID, builds summary statistics, respects AbortSignal
- `applyRetentionPolicy()` — archives then hard-deletes events beyond the retention window
- Injectable `Clock` and `IdGenerator` from `@suaveplan/types`
- OTel counter `audit.events_logged_total` with `event_type` and `severity` labels
- Bus event `audit:event:logged` emitted after each successful log
## Usage: Basic Example
```ts
import { AuditLogger } from "@suaveplan/audit-logging/core/audit-logger/audit-logger";
import { MemoryAuditAdapter } from "@suaveplan/audit-logging/adapters/memory/memory-adapter";
import { AuditEventType, AuditSeverity } from "@suaveplan/audit-logging";
const logger = new AuditLogger({
storage: new MemoryAuditAdapter(),
secretKey: process.env.AUDIT_SECRET_KEY!,
});
const event = await logger.log({
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId: "user-123", ipAddress: "203.0.113.1" },
target: { type: "session", id: "sess-456" },
});
console.log(event.id); // generated UUID
console.log(event.hash); // hex HMAC-SHA256
```
## Usage: Advanced Example
```ts
import { AuditLogger } from "@suaveplan/audit-logging/core/audit-logger/audit-logger";
import { MemoryAuditAdapter } from "@suaveplan/audit-logging/adapters/memory/memory-adapter";
import { AuditEventType, AuditSeverity } from "@suaveplan/audit-logging";
const adapter = new MemoryAuditAdapter();
let seq = 0;
const logger = new AuditLogger({
storage: adapter,
secretKey: "my-secret",
enableChainIntegrity: true,
retentionPolicy: {
retentionDays: 90,
archiveBeforeDelete: true,
archiveLocation: "s3://my-bucket/audit",
},
clock: () => Date.now(),
idGenerator: () => `evt-${++seq}`,
});
// Log a chain of events
await logger.log({ type: AuditEventType.AUTH_LOGIN, severity: AuditSeverity.INFO, actor: { userId: "u1" } });
await logger.log({ type: AuditEventType.DATA_READ, severity: AuditSeverity.INFO, actor: { userId: "u1" }, target: { type: "report", id: "r-1" } });
// Verify chain
const events = await logger.query({ order: "asc" });
const result = await logger.verifyChainIntegrity(events);
console.log(result.valid); // true
// GDPR export
const gdprExport = await logger.exportGDPRData("u1");
console.log(gdprExport.summary.totalEvents); // 2
// Apply retention
const retention = await logger.applyRetentionPolicy();
console.log(retention.eventsDeleted, retention.eventsArchived);
```
## API Reference
### `new AuditLogger(options: AuditLoggerOptions)`
Throws `AuditError` with `SECRET_KEY_MISSING` if `options.secretKey` is empty.
### `log(event): Promise<AuditEvent>`
Input type: `Omit<AuditEvent, 'id' | 'timestamp' | 'hash' | 'previousHash'>`.
- Generates `id` via `idGenerator()`
- Sets `timestamp` via `new Date(clock())`
- If `enableChainIntegrity`: fetches latest event hash as `previousHash`
- Computes `hash = computeEventHash(event, secretKey, previousHash)`
- Saves to storage; throws `AuditError(STORAGE_ERROR)` on failure
- Increments OTel counter `audit.events_logged_total`
- Emits `audit:event:logged` on bus
### `query(options): Promise<AuditEvent[]>`
Delegates to `storage.query(options)`. Throws `AuditError(STORAGE_ERROR)` on storage failure.
### `verifyChainIntegrity(events): Promise<ChainVerificationResult>`
Events must be in ascending chronological order. Delegates to `verifyChain`.
### `exportGDPRData(userId, signal?): Promise<GDPRDataExport>`
- Queries all events with `{ userId }`
- Builds `eventTypes` frequency map and `dateRange`
- Returns `GDPRDataExport` with summary
- Throws `AuditError(EXPORT_FAILED)` on aborted signal or storage failure
### `applyRetentionPolicy(signal?): Promise<RetentionResult>`
- Requires `retentionPolicy` option to be set (returns empty result otherwise)
- Archives events older than `retentionDays` if `archiveBeforeDelete && archiveLocation`
- Hard-deletes expired events via `storage.deleteExpired(cutoff)`
- Non-fatal errors are collected in `result.errors`
## Implementation Notes
The counter and meter are created once at module scope to avoid per-instance SDK lookups and respect OTel's singleton meter provider model. The `bus.emit` call is fire-and-forget (no await) to keep the hot path synchronous. The `previousHash` lookup uses tenant scoping when `event.tenantId` is set, so multi-tenant deployments maintain separate chains per tenant. All optional properties on `AuditEvent` are included via spread with conditional key inclusion to satisfy `exactOptionalPropertyTypes`.

View File

@@ -0,0 +1,616 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryAuditAdapter } from "../../adapters/memory/memory.js";
import { AuditEventType, AuditSeverity } from "../../constants/index.js";
import { AUDIT_CODES, AuditError } from "../../errors/audit-error.js";
import type { AuditEvent } from "../../types/audit-logging.types.js";
import { AuditLogger } from "./audit-logger.js";
function makeLogger(
overrides: Partial<ConstructorParameters<typeof AuditLogger>[0]> = {}
) {
return new AuditLogger({
storage: new MemoryAuditAdapter(),
secretKey: "test-secret-key",
...overrides,
});
}
function baseEvent(): Omit<
AuditEvent,
"id" | "timestamp" | "hash" | "previousHash"
> {
return {
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId: "user-1" },
};
}
describe("AuditLogger", () => {
describe("constructor", () => {
it("throws SECRET_KEY_MISSING when secretKey is empty", {
timeout: 5000,
}, () => {
expect(() => makeLogger({ secretKey: "" })).toThrow(AuditError);
try {
makeLogger({ secretKey: "" });
} catch (err) {
expect(err instanceof AuditError && err.code).toBe(
AUDIT_CODES.SECRET_KEY_MISSING
);
}
});
});
describe("log", () => {
it("creates an event with id, timestamp, and hash", {
timeout: 5000,
}, async () => {
const logger = makeLogger();
const event = await logger.log(baseEvent());
expect(event.id).toBeDefined();
expect(event.timestamp).toBeInstanceOf(Date);
expect(event.hash).toMatch(/^[0-9a-f]{64}$/);
});
it("uses injected idGenerator", { timeout: 5000 }, async () => {
const logger = makeLogger({ idGenerator: () => "fixed-id" });
const event = await logger.log(baseEvent());
expect(event.id).toBe("fixed-id");
});
it("uses injected clock", { timeout: 5000 }, async () => {
const ts = new Date("2024-01-01T00:00:00Z").getTime();
const logger = makeLogger({ clock: () => ts });
const event = await logger.log(baseEvent());
expect(event.timestamp.getTime()).toBe(ts);
});
it("logs with optional fields", { timeout: 5000 }, async () => {
const logger = makeLogger();
const event = await logger.log({
...baseEvent(),
tenantId: "t1",
correlationId: "corr-1",
target: { type: "doc", id: "d1" },
details: { key: "value" },
});
expect(event.tenantId).toBe("t1");
expect(event.correlationId).toBe("corr-1");
});
it("chains events when enableChainIntegrity is true", {
timeout: 5000,
}, async () => {
const logger = makeLogger({ enableChainIntegrity: true });
const e1 = await logger.log(baseEvent());
const e2 = await logger.log(baseEvent());
expect(e2.previousHash).toBe(e1.hash);
});
it("no previousHash when first event with chain integrity", {
timeout: 5000,
}, async () => {
const logger = makeLogger({ enableChainIntegrity: true });
const event = await logger.log(baseEvent());
expect(event.previousHash).toBeUndefined();
});
it("no previousHash without chain integrity", {
timeout: 5000,
}, async () => {
const logger = makeLogger({ enableChainIntegrity: false });
const event = await logger.log(baseEvent());
expect(event.previousHash).toBeUndefined();
});
it("emits bus event when bus is provided", {
timeout: 5000,
}, async () => {
const emitted: unknown[] = [];
const bus = {
on: () => () => {},
emit: (_: string, payload: unknown) => {
emitted.push(payload);
},
};
const logger = makeLogger({ bus });
await logger.log(baseEvent());
expect(emitted.length).toBe(1);
});
it("calls logger.debug when logger is provided", {
timeout: 5000,
}, async () => {
const messages: string[] = [];
const testLogger = {
debug: (msg: string) => {
messages.push(msg);
},
info: () => {},
warn: () => {},
error: () => {},
};
const logger = makeLogger({ logger: testLogger });
await logger.log(baseEvent());
expect(messages.some((m) => m.includes("logged"))).toBe(true);
});
it("throws STORAGE_ERROR when save fails", {
timeout: 5000,
}, async () => {
const badStorage = {
save: async () => {
throw new Error("DB error");
},
query: async () => [],
getLatestEvent: async () => null,
deleteExpired: async () => 0,
archive: async () => 0,
count: async () => 0,
};
const logger = makeLogger({ storage: badStorage });
await expect(logger.log(baseEvent())).rejects.toThrow(AuditError);
});
it("throws STORAGE_ERROR when save fails (non-Error)", {
timeout: 5000,
}, async () => {
const badStorage = {
save: async () => {
throw "non-error save failure";
},
query: async () => [],
getLatestEvent: async () => null,
deleteExpired: async () => 0,
archive: async () => 0,
count: async () => 0,
};
const logger = makeLogger({ storage: badStorage });
await expect(logger.log(baseEvent())).rejects.toThrow(AuditError);
});
it("throws STORAGE_ERROR when getLatestEvent fails with chain integrity (non-Error)", {
timeout: 5000,
}, async () => {
const badStorage = {
save: async () => {},
query: async () => [],
getLatestEvent: async () => {
throw "non-error";
},
deleteExpired: async () => 0,
archive: async () => 0,
count: async () => 0,
};
const logger = makeLogger({
storage: badStorage,
enableChainIntegrity: true,
});
await expect(logger.log(baseEvent())).rejects.toThrow(AuditError);
});
it("throws STORAGE_ERROR when getLatestEvent fails with chain integrity", {
timeout: 5000,
}, async () => {
const badStorage = {
save: async () => {},
query: async () => [],
getLatestEvent: async () => {
throw new Error("DB error");
},
deleteExpired: async () => 0,
archive: async () => 0,
count: async () => 0,
};
const logger = makeLogger({
storage: badStorage,
enableChainIntegrity: true,
});
await expect(logger.log(baseEvent())).rejects.toThrow(AuditError);
});
});
describe("query", () => {
it("returns empty array initially", { timeout: 5000 }, async () => {
const logger = makeLogger();
expect(await logger.query({})).toEqual([]);
});
it("returns logged events", { timeout: 5000 }, async () => {
const logger = makeLogger();
await logger.log(baseEvent());
await logger.log(baseEvent());
expect((await logger.query({})).length).toBe(2);
});
it("throws STORAGE_ERROR on query failure", {
timeout: 5000,
}, async () => {
const badStorage = {
save: async () => {},
query: async () => {
throw new Error("DB error");
},
getLatestEvent: async () => null,
deleteExpired: async () => 0,
archive: async () => 0,
count: async () => 0,
};
const logger = makeLogger({ storage: badStorage });
await expect(logger.query({})).rejects.toThrow(AuditError);
});
it("throws STORAGE_ERROR on non-Error query failure", {
timeout: 5000,
}, async () => {
const badStorage = {
save: async () => {},
query: async () => {
throw "non-error query failure";
},
getLatestEvent: async () => null,
deleteExpired: async () => 0,
archive: async () => 0,
count: async () => 0,
};
const logger = makeLogger({ storage: badStorage });
await expect(logger.query({})).rejects.toThrow(AuditError);
});
});
describe("verifyChainIntegrity", () => {
it("returns valid for empty list", { timeout: 5000 }, async () => {
const logger = makeLogger();
expect((await logger.verifyChainIntegrity([])).valid).toBe(true);
});
it("returns valid for correctly chained events", {
timeout: 5000,
}, async () => {
const logger = makeLogger({ enableChainIntegrity: true });
await logger.log(baseEvent());
await logger.log(baseEvent());
const events = await logger.query({ order: "asc" });
const result = await logger.verifyChainIntegrity(events);
expect(result.valid).toBe(true);
});
it("detects tampered event", { timeout: 5000 }, async () => {
const logger = makeLogger({ enableChainIntegrity: true });
await logger.log(baseEvent());
const events = await logger.query({});
const tampered: AuditEvent[] = events.map((e) => ({
...e,
actor: { userId: "attacker" },
}));
const result = await logger.verifyChainIntegrity(tampered);
expect(result.valid).toBe(false);
});
});
describe("exportGDPRData", () => {
it("returns empty export for unknown user", {
timeout: 5000,
}, async () => {
const logger = makeLogger();
const result = await logger.exportGDPRData("unknown-user");
expect(result.auditEvents.length).toBe(0);
expect(result.summary.totalEvents).toBe(0);
});
it("exports events for user", { timeout: 5000 }, async () => {
const logger = makeLogger();
await logger.log({ ...baseEvent(), actor: { userId: "u1" } });
await logger.log({
...baseEvent(),
actor: { userId: "u1" },
type: AuditEventType.DATA_READ,
});
await logger.log({ ...baseEvent(), actor: { userId: "u2" } });
const result = await logger.exportGDPRData("u1");
expect(result.userId).toBe("u1");
expect(result.auditEvents.length).toBe(2);
expect(result.summary.totalEvents).toBe(2);
});
it("builds eventTypes summary", { timeout: 5000 }, async () => {
const logger = makeLogger();
await logger.log({
...baseEvent(),
type: AuditEventType.AUTH_LOGIN,
});
await logger.log({
...baseEvent(),
type: AuditEventType.AUTH_LOGIN,
});
await logger.log({
...baseEvent(),
type: AuditEventType.DATA_READ,
});
const result = await logger.exportGDPRData("user-1");
expect(result.summary.eventTypes[AuditEventType.AUTH_LOGIN]).toBe(
2
);
expect(result.summary.eventTypes[AuditEventType.DATA_READ]).toBe(1);
});
it("throws EXPORT_FAILED when aborted before query", {
timeout: 5000,
}, async () => {
const logger = makeLogger();
const signal = AbortSignal.abort();
await expect(
logger.exportGDPRData("user-1", signal)
).rejects.toThrow(AuditError);
});
it("throws EXPORT_FAILED when aborted after query", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const abortingStorage = {
save: async () => {},
query: async () => {
controller.abort();
return [];
},
getLatestEvent: async () => null,
deleteExpired: async () => 0,
archive: async () => 0,
count: async () => 0,
};
const logger = makeLogger({ storage: abortingStorage });
await expect(
logger.exportGDPRData("user-1", controller.signal)
).rejects.toThrow(AuditError);
});
it("throws EXPORT_FAILED on storage error", {
timeout: 5000,
}, async () => {
const badStorage = {
save: async () => {},
query: async () => {
throw new Error("DB error");
},
getLatestEvent: async () => null,
deleteExpired: async () => 0,
archive: async () => 0,
count: async () => 0,
};
const logger = makeLogger({ storage: badStorage });
await expect(logger.exportGDPRData("u1")).rejects.toThrow(
AuditError
);
});
it("throws EXPORT_FAILED on non-Error storage failure", {
timeout: 5000,
}, async () => {
const badStorage = {
save: async () => {},
query: async () => {
throw "non-error export failure";
},
getLatestEvent: async () => null,
deleteExpired: async () => 0,
archive: async () => 0,
count: async () => 0,
};
const logger = makeLogger({ storage: badStorage });
await expect(logger.exportGDPRData("u1")).rejects.toThrow(
AuditError
);
});
});
describe("applyRetentionPolicy", () => {
it("returns empty result with no policy", {
timeout: 5000,
}, async () => {
const logger = makeLogger();
const result = await logger.applyRetentionPolicy();
expect(result.eventsDeleted).toBe(0);
expect(result.eventsArchived).toBe(0);
});
it("deletes expired events", { timeout: 5000 }, async () => {
const adapter = new MemoryAuditAdapter();
const logger = makeLogger({
storage: adapter,
retentionPolicy: {
retentionDays: 0,
archiveBeforeDelete: false,
},
clock: () => new Date("2024-12-31").getTime(),
});
// Manually save an old event
await adapter.save({
id: "old-1",
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: {},
timestamp: new Date("2024-01-01"),
hash: "h",
});
const result = await logger.applyRetentionPolicy();
expect(result.eventsDeleted).toBe(1);
});
it("archives before delete when configured", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAuditAdapter();
const logger = makeLogger({
storage: adapter,
retentionPolicy: {
retentionDays: 0,
archiveBeforeDelete: true,
archiveLocation: "s3://bucket/archive",
},
clock: () => new Date("2024-12-31").getTime(),
});
await adapter.save({
id: "old-1",
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: {},
timestamp: new Date("2024-01-01"),
hash: "h",
});
const result = await logger.applyRetentionPolicy();
expect(result.eventsArchived).toBe(1);
});
it("returns empty when aborted before start", {
timeout: 5000,
}, async () => {
const logger = makeLogger({
retentionPolicy: {
retentionDays: 30,
archiveBeforeDelete: false,
},
});
const signal = AbortSignal.abort();
const result = await logger.applyRetentionPolicy(signal);
expect(result.eventsDeleted).toBe(0);
});
it("collects errors from archive failure without throwing", {
timeout: 5000,
}, async () => {
const badStorage = {
save: async () => {},
query: async () => [],
getLatestEvent: async () => null,
deleteExpired: async () => 0,
archive: async () => {
throw new Error("archive failed");
},
count: async () => 0,
};
const logger = makeLogger({
storage: badStorage,
retentionPolicy: {
retentionDays: 0,
archiveBeforeDelete: true,
archiveLocation: "s3://bucket",
},
});
const result = await logger.applyRetentionPolicy();
expect(result.errors.length).toBeGreaterThan(0);
});
it("returns early when aborted after archive", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const archiveStorage = {
save: async () => {},
query: async () => [],
getLatestEvent: async () => null,
deleteExpired: async () => 0,
archive: async () => {
controller.abort();
return 0;
},
count: async () => 0,
};
const logger = makeLogger({
storage: archiveStorage,
retentionPolicy: {
retentionDays: 0,
archiveBeforeDelete: true,
archiveLocation: "s3://bucket",
},
});
const result = await logger.applyRetentionPolicy(controller.signal);
expect(result.eventsDeleted).toBe(0);
});
it("collects errors from delete failure without throwing", {
timeout: 5000,
}, async () => {
const badStorage = {
save: async () => {},
query: async () => [],
getLatestEvent: async () => null,
deleteExpired: async () => {
throw new Error("delete failed");
},
archive: async () => 0,
count: async () => 0,
};
const logger = makeLogger({
storage: badStorage,
retentionPolicy: {
retentionDays: 0,
archiveBeforeDelete: false,
},
});
const result = await logger.applyRetentionPolicy();
expect(result.errors.length).toBeGreaterThan(0);
});
it("handles non-Error archive throw", {
timeout: 5000,
}, async () => {
const badStorage = {
save: async () => {},
query: async () => [],
getLatestEvent: async () => null,
deleteExpired: async () => 0,
archive: async () => {
throw "non-error archive";
},
count: async () => 0,
};
const logger = makeLogger({
storage: badStorage,
retentionPolicy: {
retentionDays: 0,
archiveBeforeDelete: true,
archiveLocation: "s3://bucket",
},
});
const result = await logger.applyRetentionPolicy();
expect(result.errors[0]).toContain("non-error archive");
});
it("handles non-Error delete throw", {
timeout: 5000,
}, async () => {
const badStorage = {
save: async () => {},
query: async () => [],
getLatestEvent: async () => null,
deleteExpired: async () => {
throw "non-error delete";
},
archive: async () => 0,
count: async () => 0,
};
const logger = makeLogger({
storage: badStorage,
retentionPolicy: {
retentionDays: 0,
archiveBeforeDelete: false,
},
});
const result = await logger.applyRetentionPolicy();
expect(result.errors[0]).toContain("non-error delete");
});
});
describe("chain integrity with tenantId", () => {
it("links chain per tenant", { timeout: 5000 }, async () => {
const logger = makeLogger({ enableChainIntegrity: true });
const e1 = await logger.log({ ...baseEvent(), tenantId: "t1" });
const e2 = await logger.log({ ...baseEvent(), tenantId: "t1" });
expect(e2.previousHash).toBe(e1.hash);
});
});
});

View File

@@ -0,0 +1,290 @@
import { metrics } from "@opentelemetry/api";
import type { Clock, IdGenerator } from "@suaveplan/types";
import {
BUS_AUDIT_EVENT_LOGGED,
OTEL_EVENTS_LOGGED,
} from "../../constants/index.js";
import { AUDIT_CODES, AuditError } from "../../errors/audit-error.js";
import type {
AuditEvent,
AuditLoggerOptions,
AuditQueryOptions,
ChainVerificationResult,
GDPRDataExport,
RetentionResult,
} from "../../types/audit-logging.types.js";
import {
computeEventHash,
verifyChain,
} from "../chain-integrity/chain-integrity.js";
const meter = metrics.getMeter("@suaveplan/audit-logging");
const eventsLoggedCounter = meter.createCounter(OTEL_EVENTS_LOGGED);
const DEFAULT_CLOCK: Clock = () => Date.now();
const DEFAULT_ID_GENERATOR: IdGenerator = () => crypto.randomUUID();
/**
* Enterprise-grade audit logger with cryptographic chain integrity,
* GDPR export, and configurable retention policy.
*
* @example
* ```ts
* const logger = new AuditLogger({
* storage: new MemoryAuditAdapter(),
* secretKey: process.env.AUDIT_SECRET_KEY!,
* enableChainIntegrity: true,
* });
* await logger.log({ type: AuditEventType.AUTH_LOGIN, severity: AuditSeverity.INFO, actor: { userId: "u1" } });
* ```
*/
export class AuditLogger {
protected readonly storage: AuditLoggerOptions["storage"];
protected readonly secretKey: string;
protected readonly enableChainIntegrity: boolean;
protected readonly clock: Clock;
protected readonly idGenerator: IdGenerator;
protected readonly logger: AuditLoggerOptions["logger"];
protected readonly bus: AuditLoggerOptions["bus"];
protected readonly retentionPolicy: AuditLoggerOptions["retentionPolicy"];
public constructor(options: AuditLoggerOptions) {
if (!options.secretKey) {
throw new AuditError(
AUDIT_CODES.SECRET_KEY_MISSING,
"audit.secret_key.missing"
);
}
this.storage = options.storage;
this.secretKey = options.secretKey;
this.enableChainIntegrity = options.enableChainIntegrity ?? false;
this.clock = options.clock ?? DEFAULT_CLOCK;
this.idGenerator = options.idGenerator ?? DEFAULT_ID_GENERATOR;
this.logger = options.logger;
this.bus = options.bus;
this.retentionPolicy = options.retentionPolicy;
}
/**
* Logs an audit event, computing its ID, timestamp, and HMAC hash.
* If chain integrity is enabled, fetches the latest event hash and
* links it as `previousHash`.
*/
public async log(
event: Omit<AuditEvent, "id" | "timestamp" | "hash" | "previousHash">
): Promise<AuditEvent> {
const id = this.idGenerator();
const timestamp = new Date(this.clock());
let previousHash: string | undefined;
if (this.enableChainIntegrity) {
try {
const latest = await this.storage.getLatestEvent(
event.tenantId
);
if (latest !== null) {
previousHash = latest.hash;
}
} catch (err) {
throw new AuditError(
AUDIT_CODES.STORAGE_ERROR,
"audit.storage.get_latest.failed",
{ ...(err instanceof Error ? { cause: err } : {}) }
);
}
}
const eventBase = { id, timestamp, ...event };
const hash = computeEventHash(eventBase, this.secretKey, previousHash);
const auditEvent: AuditEvent = {
...eventBase,
hash,
...(previousHash !== undefined ? { previousHash } : {}),
};
try {
await this.storage.save(auditEvent);
} catch (err) {
throw new AuditError(
AUDIT_CODES.STORAGE_ERROR,
"audit.storage.save.failed",
{
...(err instanceof Error ? { cause: err } : {}),
context: { eventId: id },
}
);
}
eventsLoggedCounter.add(1, {
event_type: String(auditEvent.type),
severity: auditEvent.severity,
});
this.logger?.debug("Audit event logged", {
eventId: id,
eventType: String(auditEvent.type),
severity: auditEvent.severity,
});
this.bus?.emit(BUS_AUDIT_EVENT_LOGGED, auditEvent);
return auditEvent;
}
/**
* Queries audit events using the provided filter options.
*/
public async query(options: AuditQueryOptions): Promise<AuditEvent[]> {
try {
return await this.storage.query(options);
} catch (err) {
throw new AuditError(
AUDIT_CODES.STORAGE_ERROR,
"audit.storage.query.failed",
{ ...(err instanceof Error ? { cause: err } : {}) }
);
}
}
/**
* Verifies the cryptographic chain integrity of an ordered event sequence.
* Events must be provided in ascending chronological order.
*/
public async verifyChainIntegrity(
events: AuditEvent[]
): Promise<ChainVerificationResult> {
return verifyChain(events, this.secretKey);
}
/**
* Exports all audit events for a given user (GDPR Article 15/20).
* Respects the optional AbortSignal for cancellation.
*/
public async exportGDPRData(
userId: string,
signal?: AbortSignal
): Promise<GDPRDataExport> {
if (signal?.aborted) {
throw new AuditError(
AUDIT_CODES.EXPORT_FAILED,
"audit.export.aborted"
);
}
let auditEvents: AuditEvent[];
try {
auditEvents = await this.storage.query({ userId });
} catch (err) {
throw new AuditError(
AUDIT_CODES.EXPORT_FAILED,
"audit.export.query.failed",
{
...(err instanceof Error ? { cause: err } : {}),
context: { userId },
}
);
}
if (signal?.aborted) {
throw new AuditError(
AUDIT_CODES.EXPORT_FAILED,
"audit.export.aborted"
);
}
const exportedAt = new Date(this.clock());
const eventTypes: Record<string, number> = {};
let first: Date | undefined;
let last: Date | undefined;
for (const evt of auditEvents) {
const typeKey = String(evt.type);
eventTypes[typeKey] = (eventTypes[typeKey] ?? 0) + 1;
if (first === undefined || evt.timestamp < first)
first = evt.timestamp;
if (last === undefined || evt.timestamp > last)
last = evt.timestamp;
}
if (auditEvents.length === 0) {
return {
userId,
exportedAt,
auditEvents: [],
summary: {
totalEvents: 0,
eventTypes: {},
dateRange: { first: exportedAt, last: exportedAt },
},
};
}
return {
userId,
exportedAt,
auditEvents,
summary: {
totalEvents: auditEvents.length,
eventTypes,
dateRange: { first: first as Date, last: last as Date },
},
};
}
/**
* Applies the configured retention policy: archives then deletes expired events.
* Respects the optional AbortSignal for cancellation.
*/
public async applyRetentionPolicy(
signal?: AbortSignal
): Promise<RetentionResult> {
const result: RetentionResult = {
eventsDeleted: 0,
eventsArchived: 0,
errors: [],
};
if (this.retentionPolicy === undefined) {
return result;
}
if (signal?.aborted) {
return result;
}
const policy = this.retentionPolicy;
const retentionMs = policy.retentionDays * 24 * 60 * 60 * 1000;
const cutoff = new Date(this.clock() - retentionMs);
if (
policy.archiveBeforeDelete &&
policy.archiveLocation !== undefined
) {
try {
result.eventsArchived = await this.storage.archive(
cutoff,
policy.archiveLocation
);
} catch (err) {
result.errors.push(
err instanceof Error ? err.message : String(err)
);
}
}
if (signal?.aborted) {
return result;
}
try {
result.eventsDeleted = await this.storage.deleteExpired(cutoff);
} catch (err) {
result.errors.push(
err instanceof Error ? err.message : String(err)
);
}
return result;
}
}

View File

@@ -0,0 +1 @@
export { AuditLogger } from "./audit-logger.js";

View File

@@ -0,0 +1,97 @@
# chain-integrity
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the cryptographic chain-integrity subsystem for `@suaveplan/audit-logging`. Each audit event is signed with an HMAC-SHA256 digest that covers both the event's canonical JSON form and the hash of the preceding event, forming a tamper-evident linked chain. Any post-hoc modification to an event — or insertion/deletion of events — will cause `verifyChain` to detect the broken link at the exact index.
## Features
- `canonicalise` — deterministic JSON serialisation with sorted keys for cross-runtime stability
- `computeEventHash` — HMAC-SHA256 over `previousHash:canonicalJSON` using `@suaveplan/crypto-hash`
- `verifyChain` — iterates an ordered event sequence, recomputes hashes, and returns pass/fail with broken-at index
- No `node:crypto` usage — all cryptography via `@suaveplan/crypto-hash`
## Usage: Basic Example
```ts
import { computeEventHash, verifyChain } from "@suaveplan/audit-logging/core/chain-integrity/chain-integrity";
const secretKey = "super-secret-key";
const eventPayload = {
id: "evt-1",
type: "auth_login",
severity: "info",
actor: { userId: "user-1" },
timestamp: new Date("2024-01-01T00:00:00Z"),
};
const hash = computeEventHash(eventPayload, secretKey);
const event = { ...eventPayload, hash };
const result = verifyChain([event], secretKey);
console.log(result.valid); // true
```
## Usage: Advanced Example
```ts
import { computeEventHash, verifyChain } from "@suaveplan/audit-logging/core/chain-integrity/chain-integrity";
const secretKey = "my-secret";
const events = [];
// Build chain
let previousHash: string | undefined;
for (const payload of rawPayloads) {
const hash = computeEventHash(payload, secretKey, previousHash);
const event = { ...payload, hash, ...(previousHash ? { previousHash } : {}) };
events.push(event);
previousHash = hash;
}
// Verify chain
const result = verifyChain(events, secretKey);
if (!result.valid) {
console.error(`Chain broken at index ${result.brokenAt}: ${result.reason}`);
}
// Tamper detection example
const tampered = [...events];
tampered[2] = { ...tampered[2], actor: { userId: "attacker" } };
const tamperResult = verifyChain(tampered, secretKey);
console.log(tamperResult.brokenAt); // 2
```
## API Reference
### `canonicalise(event): string`
Serialises an audit event to deterministic JSON with sorted keys, excluding `hash` and `previousHash`.
- `event` — event minus hash fields (all other fields serialised)
- Returns: canonical JSON string
### `computeEventHash(event, secretKey, previousHash?): string`
Computes HMAC-SHA256 over the canonical representation.
- `event` — event without hash/previousHash
- `secretKey` — UTF-8 signing key; must be non-empty
- `previousHash?` — hex hash of preceding event; prepended as `${previousHash}:` before the canonical JSON
- Returns: lowercase hex string (64 characters for SHA-256)
### `verifyChain(events, secretKey): ChainVerificationResult`
Verifies an ordered sequence of events.
- `events` — events in ascending chronological order
- `secretKey` — the secret used when originally signing
- Returns: `{ valid: true }` or `{ valid: false, brokenAt: number, reason: string }`
## Implementation Notes
Key sort in `canonicalise` is recursive via `sortReplacer` to handle nested objects like `target.metadata`. The actor object is sorted separately before the main sort pass to avoid double-sorting overhead. HMAC uses SHA-256 (not SHA-512) for a good balance of security and performance given the expected volume of audit events. The chain model does not enforce sequential ordering by `previousHash` linkage — it trusts the caller to provide events in order and simply recomputes each event's hash to detect any tampering. This design keeps verification O(n) without requiring a full DAG traversal.

View File

@@ -0,0 +1,173 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AuditEventType, AuditSeverity } from "../../constants/index.js";
import type { AuditEvent } from "../../types/audit-logging.types.js";
import {
canonicalise,
computeEventHash,
verifyChain,
} from "./chain-integrity.js";
function makeEventBase(
overrides: Partial<Omit<AuditEvent, "hash" | "previousHash">> = {}
) {
return {
id: "evt-1",
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId: "user-1" },
timestamp: new Date("2024-01-01T00:00:00Z"),
...overrides,
};
}
function makeEvent(
overrides: Partial<AuditEvent> = {},
key = "secret"
): AuditEvent {
const base = makeEventBase(overrides);
const hash = computeEventHash(base, key, overrides.previousHash);
return {
...base,
hash,
...(overrides.previousHash !== undefined
? { previousHash: overrides.previousHash }
: {}),
};
}
describe("canonicalise", () => {
it("produces deterministic output", { timeout: 5000 }, () => {
const e = makeEventBase();
expect(canonicalise(e)).toBe(canonicalise(e));
});
it("sorts keys deterministically", { timeout: 5000 }, () => {
const e1 = makeEventBase({
actor: { userId: "u1", ipAddress: "1.2.3.4" },
});
const e2 = makeEventBase({
actor: { ipAddress: "1.2.3.4", userId: "u1" },
});
expect(canonicalise(e1)).toBe(canonicalise(e2));
});
it("includes target when present", { timeout: 5000 }, () => {
const e = makeEventBase({ target: { type: "doc", id: "d1" } });
expect(canonicalise(e)).toContain("doc");
});
it("includes details when present", { timeout: 5000 }, () => {
const e = makeEventBase({ details: { action: "view" } });
expect(canonicalise(e)).toContain("view");
});
it("includes tenantId when present", { timeout: 5000 }, () => {
const e = makeEventBase({ tenantId: "t1" });
expect(canonicalise(e)).toContain("t1");
});
it("includes correlationId when present", { timeout: 5000 }, () => {
const e = makeEventBase({ correlationId: "corr-1" });
expect(canonicalise(e)).toContain("corr-1");
});
it("excludes undefined optional fields", { timeout: 5000 }, () => {
const e = makeEventBase();
const json = canonicalise(e);
expect(json).not.toContain("target");
expect(json).not.toContain("details");
});
it("uses ISO timestamp", { timeout: 5000 }, () => {
const ts = new Date("2024-06-15T10:30:00Z");
const e = makeEventBase({ timestamp: ts });
expect(canonicalise(e)).toContain("2024-06-15T10:30:00.000Z");
});
});
describe("computeEventHash", () => {
it("returns a hex string of 64 chars", { timeout: 5000 }, () => {
const hash = computeEventHash(makeEventBase(), "secret");
expect(hash).toMatch(/^[0-9a-f]{64}$/);
});
it("is stable for same inputs", { timeout: 5000 }, () => {
const e = makeEventBase();
expect(computeEventHash(e, "secret")).toBe(
computeEventHash(e, "secret")
);
});
it("changes with different secret", { timeout: 5000 }, () => {
const e = makeEventBase();
expect(computeEventHash(e, "secret1")).not.toBe(
computeEventHash(e, "secret2")
);
});
it("changes when event changes", { timeout: 5000 }, () => {
const e1 = makeEventBase({ actor: { userId: "u1" } });
const e2 = makeEventBase({ actor: { userId: "u2" } });
expect(computeEventHash(e1, "secret")).not.toBe(
computeEventHash(e2, "secret")
);
});
it("includes previousHash when provided", { timeout: 5000 }, () => {
const e = makeEventBase();
const withPrev = computeEventHash(e, "secret", "prevhash");
const withoutPrev = computeEventHash(e, "secret");
expect(withPrev).not.toBe(withoutPrev);
});
});
describe("verifyChain", () => {
it("returns valid for empty chain", { timeout: 5000 }, () => {
expect(verifyChain([], "secret").valid).toBe(true);
});
it("returns valid for single event", { timeout: 5000 }, () => {
const event = makeEvent();
expect(verifyChain([event], "secret").valid).toBe(true);
});
it("returns valid for linked chain", { timeout: 5000 }, () => {
const e1 = makeEvent({ id: "e1" });
const e2 = makeEvent({ id: "e2", previousHash: e1.hash });
const e3 = makeEvent({ id: "e3", previousHash: e2.hash });
expect(verifyChain([e1, e2, e3], "secret").valid).toBe(true);
});
it("detects tampered event field", { timeout: 5000 }, () => {
const e1 = makeEvent({ id: "e1" });
const e2 = makeEvent({ id: "e2", previousHash: e1.hash });
const tampered: AuditEvent = { ...e2, actor: { userId: "attacker" } };
const result = verifyChain([e1, tampered], "secret");
expect(result.valid).toBe(false);
expect(result.brokenAt).toBe(1);
});
it("detects tampered hash directly", { timeout: 5000 }, () => {
const e1 = makeEvent({ id: "e1" });
const tampered: AuditEvent = {
...e1,
hash: "000000000000000000000000000000000000000000000000000000000000dead",
};
const result = verifyChain([tampered], "secret");
expect(result.valid).toBe(false);
expect(result.brokenAt).toBe(0);
});
it("detects wrong secret", { timeout: 5000 }, () => {
const event = makeEvent({}, "correct-secret");
const result = verifyChain([event], "wrong-secret");
expect(result.valid).toBe(false);
});
it("includes reason in broken result", { timeout: 5000 }, () => {
const e = makeEvent({ id: "test-id" });
const tampered: AuditEvent = { ...e, hash: "badhash" };
const result = verifyChain([tampered], "secret");
expect(result.reason).toContain("test-id");
});
});

View File

@@ -0,0 +1,124 @@
import { toHex } from "@suaveplan/crypto-hash/encoding";
import { hmac } from "@suaveplan/crypto-hash/mac";
import type {
AuditEvent,
ChainVerificationResult,
} from "../../types/audit-logging.types.js";
/** Text encoding used for HMAC key and data. */
const enc = new TextEncoder();
/**
* Computes a canonical JSON representation of an event for HMAC signing.
*
* Fields are serialised with sorted keys to guarantee determinism across
* runtimes and language boundaries. The `hash` and `previousHash` fields are
* deliberately excluded from the digest so the signed payload does not depend
* on itself.
*/
export function canonicalise(
event: Omit<AuditEvent, "hash" | "previousHash">
): string {
const obj: Record<string, unknown> = {
id: event.id,
type: event.type,
severity: event.severity,
actor: sortedActor(event.actor),
timestamp: event.timestamp.toISOString(),
};
const ev = event as Record<string, unknown>;
if (ev["target"] !== undefined) {
obj["target"] = ev["target"];
}
if (ev["details"] !== undefined) {
obj["details"] = ev["details"];
}
if (ev["tenantId"] !== undefined) {
obj["tenantId"] = ev["tenantId"];
}
if (ev["correlationId"] !== undefined) {
obj["correlationId"] = ev["correlationId"];
}
return JSON.stringify(obj, sortReplacer);
}
function sortedActor(actor: AuditEvent["actor"]): Record<string, unknown> {
const a: Record<string, unknown> = {};
for (const key of Object.keys(actor).sort()) {
const val = (actor as Record<string, unknown>)[key];
if (val !== undefined) {
a[key] = val;
}
}
return a;
}
function sortReplacer(_: string, value: unknown): unknown {
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
const sorted: Record<string, unknown> = {};
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
sorted[k] = (value as Record<string, unknown>)[k];
}
return sorted;
}
return value;
}
/**
* Computes an HMAC-SHA256 over the canonical JSON form of the event.
*
* @param event - The audit event (hash/previousHash excluded from digest)
* @param secretKey - UTF-8 key material; must be non-empty
* @param previousHash - Optional hash of the previous event to include in chain
* @returns Hex-encoded HMAC-SHA256 string
*/
export function computeEventHash(
event: Omit<AuditEvent, "hash" | "previousHash">,
secretKey: string,
previousHash?: string
): string {
const canonical = canonicalise(event);
const payload =
previousHash !== undefined ? `${previousHash}:${canonical}` : canonical;
const key = enc.encode(secretKey);
const data = enc.encode(payload);
const digest = hmac("SHA-256", key, data);
return toHex(digest);
}
/**
* Verifies that a sequence of audit events forms an intact HMAC chain.
*
* For each event at index `i`, this function recomputes the expected hash
* and compares it to the stored `event.hash`. If they differ, the chain is
* considered broken at that index.
*
* Events are expected to be provided in ascending chronological order
* (as stored / returned by `query({ order: "asc" })`).
*
* @param events - Ordered list of events to verify
* @param secretKey - The secret used during signing
* @returns `ChainVerificationResult` with `valid: true` or `brokenAt` index
*/
export function verifyChain(
events: AuditEvent[],
secretKey: string
): ChainVerificationResult {
for (let i = 0; i < events.length; i++) {
const event = events[i]!;
const previousHash = event.previousHash;
const expected = computeEventHash(event, secretKey, previousHash);
if (expected !== event.hash) {
return {
valid: false,
brokenAt: i,
reason: `Hash mismatch at index ${i}: event id=${event.id}`,
};
}
}
return { valid: true };
}

View File

@@ -0,0 +1,5 @@
export {
canonicalise,
computeEventHash,
verifyChain,
} from "./chain-integrity.js";

View File

@@ -0,0 +1,91 @@
# audit-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a stable error-code registry and typed error class for `@suaveplan/audit-logging`. All package-level failures are represented as `AuditError` instances carrying a stable `AUDIT.*` code, enabling programmatic error handling and downstream telemetry without relying on message string parsing.
## Features
- `AUDIT_CODES` — frozen registry of six stable error codes via `defineCodes`
- `AuditErrorCode` — union type of all valid AUDIT code strings
- `AuditError` — extends `SuaveplanError<AuditErrorCode>` for full cause-chain support, OTel span events, and redaction
## Usage: Basic Example
```ts
import { AuditError, AUDIT_CODES } from "@suaveplan/audit-logging/errors";
throw new AuditError(
AUDIT_CODES.STORAGE_ERROR,
"audit.storage.save.failed",
{ context: { table: "audit_events", operation: "insert" } }
);
```
## Usage: Advanced Example
```ts
import { AuditError, AUDIT_CODES } from "@suaveplan/audit-logging/errors";
import { walkCauses } from "@suaveplan/error";
try {
await adapter.save(event);
} catch (err) {
throw new AuditError(
AUDIT_CODES.STORAGE_ERROR,
"audit.storage.save.failed",
{
...(err instanceof Error ? { cause: err } : {}),
context: { eventId: event.id, eventType: event.type },
}
);
}
// Handle downstream:
try {
await logger.log(auditEvent);
} catch (err) {
if (err instanceof AuditError && err.code === AUDIT_CODES.CHAIN_BROKEN) {
console.error("Tamper detected:", err.context);
}
for (const cause of walkCauses(err)) {
console.error(" caused by:", cause.message);
}
}
```
## API Reference
### `AUDIT_CODES`
Frozen object with the following keys:
- `STORAGE_ERROR` → `"AUDIT.STORAGE_ERROR"` — storage backend operation failed
- `CHAIN_BROKEN` → `"AUDIT.CHAIN_BROKEN"` — HMAC chain verification failed
- `INVALID_EVENT` → `"AUDIT.INVALID_EVENT"` — event data failed schema validation
- `EXPORT_FAILED` → `"AUDIT.EXPORT_FAILED"` — GDPR data export failed
- `RETENTION_FAILED` → `"AUDIT.RETENTION_FAILED"` — retention policy application failed
- `SECRET_KEY_MISSING` → `"AUDIT.SECRET_KEY_MISSING"` — no secret key provided for HMAC
### `AuditErrorCode`
Type alias: `"AUDIT.STORAGE_ERROR" | "AUDIT.CHAIN_BROKEN" | "AUDIT.INVALID_EVENT" | "AUDIT.EXPORT_FAILED" | "AUDIT.RETENTION_FAILED" | "AUDIT.SECRET_KEY_MISSING"`
### `AuditError`
`class AuditError extends SuaveplanError<AuditErrorCode>`
**Constructor:**
- `code: AuditErrorCode` — stable error code from `AUDIT_CODES`
- `messageKey: string` — i18n message key (resolved by the global message resolver)
- `options?: SuaveplanErrorOptions` — optional `cause`, `context`, `clock`, `traceId`
**Inherited properties:** `code`, `context`, `timestamp`, `traceId`, `cause` (from `SuaveplanError`)
## Implementation Notes
`defineCodes` is called once at module load time with a `const` array, so TypeScript infers the exact literal union for `AuditErrorCode`. Using `as const` on the reasons array is essential — without it the inference falls back to `string[]` and the code type widens to `string`. The `AuditError` constructor delegates entirely to `SuaveplanError`; its only addition is setting `this.name = "AuditError"` for cleaner stack traces and `instanceof` checks.

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUDIT_CODES, AuditError } from "./audit-error.js";
describe("AuditError", () => {
it("creates error with correct code and name", { timeout: 5000 }, () => {
const err = new AuditError(
AUDIT_CODES.STORAGE_ERROR,
"audit.storage.failed"
);
expect(err.code).toBe("AUDIT.STORAGE_ERROR");
expect(err.name).toBe("AuditError");
expect(err).toBeInstanceOf(AuditError);
expect(err).toBeInstanceOf(Error);
});
it("supports cause chaining", { timeout: 5000 }, () => {
const cause = new Error("underlying db error");
const err = new AuditError(
AUDIT_CODES.STORAGE_ERROR,
"audit.storage.failed",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("supports context", { timeout: 5000 }, () => {
const err = new AuditError(
AUDIT_CODES.INVALID_EVENT,
"audit.event.invalid",
{
context: { eventId: "evt-123", field: "type" },
}
);
expect(err.context).toEqual({ eventId: "evt-123", field: "type" });
});
it("has all expected codes", { timeout: 5000 }, () => {
expect(AUDIT_CODES.STORAGE_ERROR).toBe("AUDIT.STORAGE_ERROR");
expect(AUDIT_CODES.CHAIN_BROKEN).toBe("AUDIT.CHAIN_BROKEN");
expect(AUDIT_CODES.INVALID_EVENT).toBe("AUDIT.INVALID_EVENT");
expect(AUDIT_CODES.EXPORT_FAILED).toBe("AUDIT.EXPORT_FAILED");
expect(AUDIT_CODES.RETENTION_FAILED).toBe("AUDIT.RETENTION_FAILED");
expect(AUDIT_CODES.SECRET_KEY_MISSING).toBe("AUDIT.SECRET_KEY_MISSING");
});
it("codes object is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(AUDIT_CODES)).toBe(true);
});
it("creates error for each code variant", { timeout: 5000 }, () => {
for (const code of Object.values(AUDIT_CODES)) {
const err = new AuditError(code, "audit.generic");
expect(err.code).toBe(code);
}
});
});

View File

@@ -0,0 +1,52 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/audit-logging`.
*
* All codes are namespaced under `AUDIT` and registered once via
* `defineCodes`, producing deterministic string identifiers that survive
* tree-shaking and minification.
*/
export const AUDIT_CODES = defineCodes("AUDIT", [
"STORAGE_ERROR",
"CHAIN_BROKEN",
"INVALID_EVENT",
"EXPORT_FAILED",
"RETENTION_FAILED",
"SECRET_KEY_MISSING",
] as const);
export type AuditErrorCode = (typeof AUDIT_CODES)[keyof typeof AUDIT_CODES];
/**
* Typed error class for all `@suaveplan/audit-logging` failures.
*
* Extends `SuaveplanError` so callers can use `instanceof AuditError`
* for narrow discrimination while the full error chain, context, and OTel
* span-event adapter remain available through the base class.
*
* @example
* ```ts
* import { AuditError, AUDIT_CODES } from "@suaveplan/audit-logging/errors";
*
* throw new AuditError(
* AUDIT_CODES.STORAGE_ERROR,
* "audit.storage.failed",
* { context: { operation: "save" } }
* );
* ```
*/
export class AuditError extends SuaveplanError<AuditErrorCode> {
public constructor(
code: AuditErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "AuditError";
}
}

View File

@@ -0,0 +1 @@
export { AUDIT_CODES, AuditError, type AuditErrorCode } from "./audit-error.js";

View File

@@ -0,0 +1,51 @@
// Constants
export { MemoryAuditAdapter } from "./adapters/memory/memory.js";
// Adapters
export { BaseAuditStorageAdapter } from "./adapters/storage/storage.js";
export {
AuditEventType,
AuditSeverity,
BUS_AUDIT_EVENT_LOGGED,
DEFAULT_AUDIT_TABLE,
DEFAULT_RETENTION_DAYS,
OTEL_EVENTS_LOGGED,
} from "./constants/index.js";
// Core
export { AuditLogger } from "./core/audit-logger/audit-logger.js";
export {
canonicalise,
computeEventHash,
verifyChain,
} from "./core/chain-integrity/chain-integrity.js";
// Errors
export {
AUDIT_CODES,
AuditError,
type AuditErrorCode,
} from "./errors/audit-error.js";
// Schemas
export {
AuditActorSchema,
AuditEventSchema,
AuditEventTypeSchema,
AuditQueryOptionsSchema,
AuditRetentionPolicySchema,
AuditSeveritySchema,
AuditTargetSchema,
ChainVerificationResultSchema,
RetentionResultSchema,
} from "./schemas/audit-logging.schemas.js";
// Types
export type {
AuditActor,
AuditEvent,
AuditLoggerOptions,
AuditQueryOptions,
AuditRetentionPolicy,
AuditStorageAdapter,
AuditTarget,
ChainVerificationResult,
GDPRDataExport,
RetentionResult,
} from "./types/audit-logging.types.js";

View File

@@ -0,0 +1,110 @@
# audit-logging.schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Zod v4 schema definitions for all public types in `@suaveplan/audit-logging`. These schemas enable runtime validation at API boundaries, wire deserialization, and property-based testing. Every schema mirrors its corresponding TypeScript interface exactly, ensuring type inference from `z.infer<>` produces the same shape as the hand-written types in `audit-logging.types.ts`.
## Features
- `AuditSeveritySchema` — enum validator for the five severity levels
- `AuditEventTypeSchema` — enum validator for all 29 built-in event types
- `AuditActorSchema` — optional-field object schema for actors
- `AuditTargetSchema` — required type/id with optional metadata record
- `AuditEventSchema` — complete event record with all fields
- `AuditQueryOptionsSchema` — query filter schema with union severity support
- `AuditRetentionPolicySchema` — policy with positive retentionDays validation
- `ChainVerificationResultSchema` — pass/fail result with optional broken-at index
- `RetentionResultSchema` — counts and errors array
## Usage: Basic Example
```ts
import { AuditEventSchema } from "@suaveplan/audit-logging/schemas/audit-logging.schemas";
const raw = JSON.parse(apiResponse);
const event = AuditEventSchema.parse({
...raw,
timestamp: new Date(raw.timestamp),
});
```
## Usage: Advanced Example
```ts
import {
AuditQueryOptionsSchema,
AuditRetentionPolicySchema,
} from "@suaveplan/audit-logging/schemas/audit-logging.schemas";
import { AuditLogger } from "@suaveplan/audit-logging/core/audit-logger/audit-logger";
import { MemoryAuditAdapter } from "@suaveplan/audit-logging/adapters/memory/memory-adapter";
// Validate query options from user input
function parseQueryOptions(input: unknown) {
return AuditQueryOptionsSchema.parse(input);
}
// Validate policy from config file
function parsePolicy(input: unknown) {
const policy = AuditRetentionPolicySchema.parse(input);
return new AuditLogger({
storage: new MemoryAuditAdapter(),
secretKey: "key",
retentionPolicy: policy,
});
}
```
## API Reference
### `AuditSeveritySchema`
`z.enum(["debug", "info", "warning", "error", "critical"])`
### `AuditEventTypeSchema`
`z.enum([...all 29 values...])`
### `AuditActorSchema`
All fields optional: `userId`, `sessionId`, `apiKeyId`, `ipAddress`, `userAgent`, `tenantId`.
### `AuditTargetSchema`
- `type: string` (required)
- `id: string` (required)
- `metadata?: Record<string, unknown>`
### `AuditEventSchema`
- All AuditEvent fields with proper Zod types
- `timestamp: z.date()` — expects a `Date` object (not a string)
- `type: z.union([AuditEventTypeSchema, z.string()])` — accepts custom types
### `AuditQueryOptionsSchema`
All fields optional. `severity` accepts a single value or array.
### `AuditRetentionPolicySchema`
- `retentionDays: z.number().int().positive()` — must be ≥ 1
- `archiveBeforeDelete: z.boolean()`
- `archiveLocation?: z.string()`
### `ChainVerificationResultSchema`
- `valid: z.boolean()`
- `brokenAt?: z.number().int().nonnegative()`
- `reason?: z.string()`
### `RetentionResultSchema`
- `eventsDeleted: z.number().int().nonnegative()`
- `eventsArchived: z.number().int().nonnegative()`
- `errors: z.array(z.string())`
## Implementation Notes
Schemas use `z.record(z.string(), z.unknown())` (two-argument form) per Zod v4 requirements. The `AuditEventTypeSchema` enumerates all values from the `AuditEventType` enum directly rather than using `z.nativeEnum()`, which is deprecated in Zod v4 in favour of `z.enum()`. The `timestamp` field expects an actual `Date` object — callers parsing JSON should convert the ISO string first with `new Date(raw.timestamp)`.

View File

@@ -0,0 +1,181 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AuditEventType, AuditSeverity } from "../constants/index.js";
import {
AuditActorSchema,
AuditEventSchema,
AuditEventTypeSchema,
AuditQueryOptionsSchema,
AuditRetentionPolicySchema,
AuditSeveritySchema,
AuditTargetSchema,
ChainVerificationResultSchema,
RetentionResultSchema,
} from "./audit-logging.schemas.js";
describe("AuditSeveritySchema", () => {
it("accepts valid values", { timeout: 5000 }, () => {
for (const v of Object.values(AuditSeverity)) {
expect(AuditSeveritySchema.parse(v)).toBe(v);
}
});
it("rejects invalid value", { timeout: 5000 }, () => {
expect(() => AuditSeveritySchema.parse("trace")).toThrow();
});
});
describe("AuditEventTypeSchema", () => {
it("accepts all built-in event types", { timeout: 5000 }, () => {
for (const v of Object.values(AuditEventType)) {
expect(AuditEventTypeSchema.parse(v)).toBe(v);
}
});
it("rejects unknown type", { timeout: 5000 }, () => {
expect(() => AuditEventTypeSchema.parse("not_a_type")).toThrow();
});
});
describe("AuditActorSchema", () => {
it("accepts empty object", { timeout: 5000 }, () => {
expect(AuditActorSchema.parse({})).toEqual({});
});
it("accepts all fields", { timeout: 5000 }, () => {
const actor = {
userId: "u1",
sessionId: "s1",
apiKeyId: "k1",
ipAddress: "1.2.3.4",
userAgent: "Mozilla",
tenantId: "t1",
};
expect(AuditActorSchema.parse(actor)).toEqual(actor);
});
});
describe("AuditTargetSchema", () => {
it("accepts required fields", { timeout: 5000 }, () => {
expect(AuditTargetSchema.parse({ type: "doc", id: "d1" })).toEqual({
type: "doc",
id: "d1",
});
});
it("accepts metadata", { timeout: 5000 }, () => {
const t = { type: "doc", id: "d1", metadata: { version: 2 } };
expect(AuditTargetSchema.parse(t)).toEqual(t);
});
it("rejects missing required fields", { timeout: 5000 }, () => {
expect(() => AuditTargetSchema.parse({ type: "doc" })).toThrow();
});
});
describe("AuditEventSchema", () => {
const base = {
id: "evt-1",
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId: "u1" },
timestamp: new Date("2024-01-01"),
hash: "deadbeef",
};
it("accepts valid event", { timeout: 5000 }, () => {
const result = AuditEventSchema.parse(base);
expect(result.id).toBe("evt-1");
});
it("accepts custom event type string via union", { timeout: 5000 }, () => {
const result = AuditEventSchema.parse({
...base,
type: "custom_event",
});
expect(result.type).toBe("custom_event");
});
it("accepts all optional fields", { timeout: 5000 }, () => {
const event = {
...base,
target: { type: "doc", id: "d1" },
details: { key: "val" },
previousHash: "prev",
tenantId: "t1",
correlationId: "corr",
};
expect(AuditEventSchema.parse(event)).toEqual(event);
});
it("rejects missing required fields", { timeout: 5000 }, () => {
expect(() =>
AuditEventSchema.parse({ ...base, id: undefined })
).toThrow();
});
});
describe("AuditQueryOptionsSchema", () => {
it("accepts empty object", { timeout: 5000 }, () => {
expect(AuditQueryOptionsSchema.parse({})).toEqual({});
});
it("accepts severity as single value", { timeout: 5000 }, () => {
const r = AuditQueryOptionsSchema.parse({
severity: AuditSeverity.ERROR,
});
expect(r.severity).toBe(AuditSeverity.ERROR);
});
it("accepts severity as array", { timeout: 5000 }, () => {
const r = AuditQueryOptionsSchema.parse({
severity: [AuditSeverity.ERROR, AuditSeverity.CRITICAL],
});
expect(Array.isArray(r.severity)).toBe(true);
});
});
describe("AuditRetentionPolicySchema", () => {
it("accepts valid policy", { timeout: 5000 }, () => {
const p = {
retentionDays: 90,
archiveBeforeDelete: true,
archiveLocation: "s3://bucket",
};
expect(AuditRetentionPolicySchema.parse(p)).toEqual(p);
});
it("rejects retentionDays = 0", { timeout: 5000 }, () => {
expect(() =>
AuditRetentionPolicySchema.parse({
retentionDays: 0,
archiveBeforeDelete: false,
})
).toThrow();
});
});
describe("ChainVerificationResultSchema", () => {
it("accepts valid result", { timeout: 5000 }, () => {
expect(ChainVerificationResultSchema.parse({ valid: true })).toEqual({
valid: true,
});
expect(
ChainVerificationResultSchema.parse({
valid: false,
brokenAt: 3,
reason: "mismatch",
})
).toBeDefined();
});
});
describe("RetentionResultSchema", () => {
it("accepts valid result", { timeout: 5000 }, () => {
const r = {
eventsDeleted: 5,
eventsArchived: 3,
errors: ["some error"],
};
expect(RetentionResultSchema.parse(r)).toEqual(r);
});
});

View File

@@ -0,0 +1,115 @@
import z from "zod";
import { AuditEventType, AuditSeverity } from "../constants/index.js";
/** Zod schema for AuditSeverity. */
export const AuditSeveritySchema = z.enum([
AuditSeverity.DEBUG,
AuditSeverity.INFO,
AuditSeverity.WARNING,
AuditSeverity.ERROR,
AuditSeverity.CRITICAL,
]);
/** Zod schema for AuditEventType. */
export const AuditEventTypeSchema = z.enum([
AuditEventType.AUTH_LOGIN,
AuditEventType.AUTH_LOGOUT,
AuditEventType.AUTH_REGISTER,
AuditEventType.AUTH_PASSWORD_CHANGE,
AuditEventType.AUTH_PASSWORD_RESET,
AuditEventType.AUTH_MFA_ENABLE,
AuditEventType.AUTH_MFA_DISABLE,
AuditEventType.AUTH_SESSION_CREATE,
AuditEventType.AUTH_SESSION_REVOKE,
AuditEventType.AUTH_API_KEY_CREATE,
AuditEventType.AUTH_API_KEY_REVOKE,
AuditEventType.AUTHZ_PERMISSION_CHECK,
AuditEventType.AUTHZ_PERMISSION_GRANT,
AuditEventType.AUTHZ_PERMISSION_REVOKE,
AuditEventType.AUTHZ_ROLE_ASSIGN,
AuditEventType.AUTHZ_ROLE_UNASSIGN,
AuditEventType.DATA_CREATE,
AuditEventType.DATA_READ,
AuditEventType.DATA_UPDATE,
AuditEventType.DATA_DELETE,
AuditEventType.DATA_EXPORT,
AuditEventType.DATA_IMPORT,
AuditEventType.SYSTEM_CONFIG_CHANGE,
AuditEventType.SYSTEM_SECURITY_ALERT,
AuditEventType.SYSTEM_RATE_LIMIT,
AuditEventType.SYSTEM_ERROR,
AuditEventType.GDPR_DATA_EXPORT,
AuditEventType.GDPR_DATA_DELETE,
AuditEventType.GDPR_CONSENT_CHANGE,
]);
/** Zod schema for AuditActor. */
export const AuditActorSchema = z.object({
userId: z.string().optional(),
sessionId: z.string().optional(),
apiKeyId: z.string().optional(),
ipAddress: z.string().optional(),
userAgent: z.string().optional(),
tenantId: z.string().optional(),
});
/** Zod schema for AuditTarget. */
export const AuditTargetSchema = z.object({
type: z.string(),
id: z.string(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
/** Zod schema for a complete AuditEvent. */
export const AuditEventSchema = z.object({
id: z.string(),
type: z.union([AuditEventTypeSchema, z.string()]),
severity: AuditSeveritySchema,
actor: AuditActorSchema,
target: AuditTargetSchema.optional(),
details: z.record(z.string(), z.unknown()).optional(),
timestamp: z.date(),
previousHash: z.string().optional(),
hash: z.string(),
tenantId: z.string().optional(),
correlationId: z.string().optional(),
});
/** Zod schema for AuditQueryOptions. */
export const AuditQueryOptionsSchema = z.object({
eventTypes: z.array(AuditEventTypeSchema).optional(),
userId: z.string().optional(),
sessionId: z.string().optional(),
severity: z
.union([AuditSeveritySchema, z.array(AuditSeveritySchema)])
.optional(),
targetType: z.string().optional(),
targetId: z.string().optional(),
startTime: z.date().optional(),
endTime: z.date().optional(),
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
order: z.enum(["asc", "desc"]).optional(),
tenantId: z.string().optional(),
});
/** Zod schema for AuditRetentionPolicy. */
export const AuditRetentionPolicySchema = z.object({
retentionDays: z.number().int().positive(),
archiveBeforeDelete: z.boolean(),
archiveLocation: z.string().optional(),
});
/** Zod schema for ChainVerificationResult. */
export const ChainVerificationResultSchema = z.object({
valid: z.boolean(),
brokenAt: z.number().int().nonnegative().optional(),
reason: z.string().optional(),
});
/** Zod schema for RetentionResult. */
export const RetentionResultSchema = z.object({
eventsDeleted: z.number().int().nonnegative(),
eventsArchived: z.number().int().nonnegative(),
errors: z.array(z.string()),
});

View File

@@ -0,0 +1,148 @@
# audit-logging.types
## Purpose
Defines the complete TypeScript type system for `@suaveplan/audit-logging`. These types form the public contract for all audit event data, query filtering, storage adapters, GDPR exports, and chain-integrity verification. Keeping them in a dedicated types file ensures zero runtime overhead — types are erased during compilation, and consumers can import only what they need.
## Features
- `AuditActor` — who triggered an event (user, session, API key, IP, tenant)
- `AuditTarget` — the resource acted upon (type, id, optional metadata)
- `AuditEvent` — complete immutable event record including HMAC chain fields
- `AuditQueryOptions` — rich filtering: event types, user/session, severity, time range, pagination
- `AuditRetentionPolicy` — configurable TTL with archive-before-delete support
- `AuditLoggerOptions` — injectable clock, ID generator, logger, event bus, and storage adapter
- `AuditStorageAdapter` — pluggable backend interface (save, query, count, archive, deleteExpired)
- `GDPRDataExport` — structured export with event list and summary statistics
- `ChainVerificationResult` — pass/fail with broken-at index and reason string
- `RetentionResult` — counts of deleted/archived events plus any errors
## Usage: Basic Example
```ts
import type { AuditEvent, AuditActor, AuditTarget } from "@suaveplan/audit-logging";
import { AuditEventType, AuditSeverity } from "@suaveplan/audit-logging";
const actor: AuditActor = {
userId: "user-123",
ipAddress: "203.0.113.1",
};
const target: AuditTarget = {
type: "document",
id: "doc-456",
};
const event: AuditEvent = {
id: "evt-789",
type: AuditEventType.DATA_READ,
severity: AuditSeverity.INFO,
actor,
target,
timestamp: new Date(),
hash: "computed-hmac",
};
```
## Usage: Advanced Example
```ts
import type { AuditQueryOptions, AuditRetentionPolicy, AuditLoggerOptions } from "@suaveplan/audit-logging";
import { AuditEventType, AuditSeverity } from "@suaveplan/audit-logging";
import { MemoryAuditAdapter } from "@suaveplan/audit-logging/adapters/memory/memory-adapter";
const query: AuditQueryOptions = {
eventTypes: [AuditEventType.AUTH_LOGIN, AuditEventType.AUTH_LOGOUT],
severity: [AuditSeverity.WARNING, AuditSeverity.ERROR, AuditSeverity.CRITICAL],
startTime: new Date("2024-01-01"),
endTime: new Date("2024-12-31"),
tenantId: "tenant-abc",
limit: 100,
offset: 0,
order: "desc",
};
const retention: AuditRetentionPolicy = {
retentionDays: 90,
archiveBeforeDelete: true,
archiveLocation: "s3://my-bucket/audit-archive",
};
const options: AuditLoggerOptions = {
storage: new MemoryAuditAdapter(),
secretKey: "my-secret-key",
enableChainIntegrity: true,
retentionPolicy: retention,
clock: () => Date.now(),
idGenerator: () => crypto.randomUUID(),
};
```
## API Reference
### `AuditActor`
All fields optional. Represents the entity that performed the action.
- `userId?: string` — authenticated user identifier
- `sessionId?: string` — active session identifier
- `apiKeyId?: string` — API key used for the request
- `ipAddress?: string` — source IP address
- `userAgent?: string` — HTTP User-Agent header
- `tenantId?: string` — tenant in multi-tenant deployments
### `AuditTarget`
- `type: string` — resource type (e.g. `"document"`, `"user"`)
- `id: string` — resource identifier
- `metadata?: Record<string, unknown>` — additional context
### `AuditEvent`
- `id: string` — unique event identifier
- `type: AuditEventType | string` — event type from the taxonomy or custom string
- `severity: AuditSeverity` — debug/info/warning/error/critical
- `actor: AuditActor` — who triggered the event
- `target?: AuditTarget` — optional target resource
- `details?: Record<string, unknown>` — arbitrary event-specific data
- `timestamp: Date` — when the event occurred
- `previousHash?: string` — HMAC of the preceding event (chain integrity)
- `hash: string` — HMAC-SHA256 of this event's canonical form
- `tenantId?: string` — tenant scoping
- `correlationId?: string` — request/trace correlation
### `AuditQueryOptions`
Filtering fields for `AuditStorageAdapter.query()`. All optional.
### `AuditStorageAdapter`
- `save(event)` — persist a single event
- `query(options)` — filter and paginate events
- `getLatestEvent(tenantId?)` — most recent event for chain linking
- `deleteExpired(before)` — remove old events; returns count deleted
- `archive(before, location)` — copy then mark events; returns count archived
- `count(options?)` — count matching events
### `GDPRDataExport`
- `userId: string` — the data subject
- `exportedAt: Date` — export timestamp
- `auditEvents: AuditEvent[]` — all events involving the user
- `summary` — `totalEvents`, `eventTypes` map, `dateRange`
### `ChainVerificationResult`
- `valid: boolean` — whether chain is intact
- `brokenAt?: number` — zero-based index of first broken link
- `reason?: string` — human-readable explanation
### `RetentionResult`
- `eventsDeleted: number` — count of hard-deleted events
- `eventsArchived: number` — count of archived events
- `errors: string[]` — non-fatal errors encountered
## Implementation Notes
Types are defined as interfaces rather than classes to allow multiple implementations of `AuditStorageAdapter` without prototype overhead. The `exactOptionalPropertyTypes` constraint means all optional fields use `?:` and callers must use spread syntax to conditionally include them. The `AuditEvent.hash` field is always a hex-encoded HMAC-SHA256 string, computed by `computeEventHash` in `chain-integrity.ts`.

View File

@@ -0,0 +1,136 @@
import type {
Clock,
EventBusLike,
IdGenerator,
LoggerLike,
} from "@suaveplan/types";
import type { AuditEventType, AuditSeverity } from "../constants/index.js";
/**
* Represents the actor who triggered an audit event.
* All fields are optional to support system-initiated actions with no human actor.
*/
export interface AuditActor {
userId?: string;
sessionId?: string;
apiKeyId?: string;
ipAddress?: string;
userAgent?: string;
tenantId?: string;
}
/**
* Represents the resource targeted by an audit event.
*/
export interface AuditTarget {
type: string;
id: string;
metadata?: Record<string, unknown>;
}
/**
* An immutable, signed audit event record.
* The `hash` field contains an HMAC-SHA256 over the canonical form of the event.
* The `previousHash` field forms a cryptographic chain linking events.
*/
export interface AuditEvent {
id: string;
type: AuditEventType | string;
severity: AuditSeverity;
actor: AuditActor;
target?: AuditTarget;
details?: Record<string, unknown>;
timestamp: Date;
previousHash?: string;
hash: string;
tenantId?: string;
correlationId?: string;
}
/**
* Options for filtering and paginating audit event queries.
*/
export interface AuditQueryOptions {
eventTypes?: AuditEventType[];
userId?: string;
sessionId?: string;
severity?: AuditSeverity | AuditSeverity[];
targetType?: string;
targetId?: string;
startTime?: Date;
endTime?: Date;
limit?: number;
offset?: number;
order?: "asc" | "desc";
tenantId?: string;
}
/**
* Policy governing how long audit events are retained and whether they
* should be archived before deletion.
*/
export interface AuditRetentionPolicy {
retentionDays: number;
archiveBeforeDelete: boolean;
archiveLocation?: string;
}
/**
* Constructor options for AuditLogger.
*/
export interface AuditLoggerOptions {
storage: AuditStorageAdapter;
secretKey: string;
tableName?: string;
retentionPolicy?: AuditRetentionPolicy;
enableChainIntegrity?: boolean;
clock?: Clock;
idGenerator?: IdGenerator;
logger?: LoggerLike;
bus?: EventBusLike;
}
/**
* Pluggable storage backend for audit events.
* Implementations include MemoryAuditAdapter, SQLite, SurrealDB, etc.
*/
export interface AuditStorageAdapter {
save(event: AuditEvent): Promise<void>;
query(options: AuditQueryOptions): Promise<AuditEvent[]>;
getLatestEvent(tenantId?: string): Promise<AuditEvent | null>;
deleteExpired(before: Date): Promise<number>;
archive(before: Date, location: string): Promise<number>;
count(options?: Partial<AuditQueryOptions>): Promise<number>;
}
/**
* Result of a GDPR data export request for a specific user.
*/
export interface GDPRDataExport {
userId: string;
exportedAt: Date;
auditEvents: AuditEvent[];
summary: {
totalEvents: number;
eventTypes: Record<string, number>;
dateRange: { first: Date; last: Date };
};
}
/**
* Result of chain integrity verification on a sequence of audit events.
*/
export interface ChainVerificationResult {
valid: boolean;
brokenAt?: number;
reason?: string;
}
/**
* Result of applying the retention policy.
*/
export interface RetentionResult {
eventsDeleted: number;
eventsArchived: number;
errors: string[];
}

View File

@@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryAuditAdapter } from "../src/adapters/memory/memory.js";
import { AuditEventType, AuditSeverity } from "../src/constants/index.js";
import { AuditLogger } from "../src/core/audit-logger/audit-logger.js";
describe("AuditLogger integration", () => {
let adapter: MemoryAuditAdapter;
let logger: AuditLogger;
beforeEach(() => {
adapter = new MemoryAuditAdapter();
logger = new AuditLogger({
storage: adapter,
secretKey: "integration-secret-key",
enableChainIntegrity: true,
});
});
it("full lifecycle: log, query, verify, export", {
timeout: 30000,
}, async () => {
// Log several events
await logger.log({
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId: "alice" },
});
await logger.log({
type: AuditEventType.DATA_READ,
severity: AuditSeverity.INFO,
actor: { userId: "alice" },
target: { type: "report", id: "r1" },
});
await logger.log({
type: AuditEventType.AUTH_LOGOUT,
severity: AuditSeverity.INFO,
actor: { userId: "alice" },
});
await logger.log({
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId: "bob" },
});
// Query all events
const all = await logger.query({});
expect(all.length).toBe(4);
// Query by user
const aliceEvents = await logger.query({ userId: "alice" });
expect(aliceEvents.length).toBe(3);
// Verify chain integrity
const ordered = await logger.query({ order: "asc" });
const verifyResult = await logger.verifyChainIntegrity(ordered);
expect(verifyResult.valid).toBe(true);
// GDPR export
const gdprExport = await logger.exportGDPRData("alice");
expect(gdprExport.userId).toBe("alice");
expect(gdprExport.summary.totalEvents).toBe(3);
expect(gdprExport.summary.eventTypes[AuditEventType.AUTH_LOGIN]).toBe(
1
);
});
it("chain links correctly across multiple events", {
timeout: 30000,
}, async () => {
let tick = 0;
const chainLogger = new AuditLogger({
storage: adapter,
secretKey: "integration-secret-key",
enableChainIntegrity: true,
clock: () => new Date("2024-01-01").getTime() + tick++ * 1000,
});
const events = [];
for (let i = 0; i < 5; i++) {
events.push(
await chainLogger.log({
type: AuditEventType.DATA_READ,
severity: AuditSeverity.INFO,
actor: { userId: `user-${i}` },
})
);
}
// Verify each event links to previous
for (let i = 1; i < events.length; i++) {
expect(events[i]?.previousHash).toBe(events[i - 1]?.hash);
}
const stored = await chainLogger.query({ order: "asc" });
const chainEvents = stored.slice(-5); // last 5 we logged
const result = await chainLogger.verifyChainIntegrity(chainEvents);
expect(result.valid).toBe(true);
});
it("retention policy lifecycle", { timeout: 30000 }, async () => {
const now = new Date("2024-12-31").getTime();
const retentionLogger = new AuditLogger({
storage: adapter,
secretKey: "secret",
enableChainIntegrity: false,
retentionPolicy: {
retentionDays: 1,
archiveBeforeDelete: true,
archiveLocation: "s3://archive",
},
clock: () => now,
});
// Add old event
await adapter.save({
id: "old-event",
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: {},
timestamp: new Date("2024-01-01"),
hash: "h",
});
const result = await retentionLogger.applyRetentionPolicy();
expect(result.eventsArchived).toBe(1);
expect(result.eventsDeleted).toBe(0); // already moved to archive
expect(adapter.snapshot().length).toBe(0);
expect(adapter.archivedSnapshot().length).toBe(1);
});
it("multi-tenant chain isolation", { timeout: 30000 }, async () => {
// Each tenant gets its own chain: getLatestEvent(tenantId) scopes the lookup
await logger.log({
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId: "u1" },
tenantId: "t1",
});
await logger.log({
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId: "u2" },
tenantId: "t2",
});
await logger.log({
type: AuditEventType.DATA_READ,
severity: AuditSeverity.INFO,
actor: { userId: "u1" },
tenantId: "t1",
});
const t1Events = await logger.query({ tenantId: "t1", order: "asc" });
const t2Events = await logger.query({ tenantId: "t2", order: "asc" });
// First t1 event has no predecessor → no previousHash
expect(t1Events[0]?.previousHash).toBeUndefined();
// Second t1 event links to first t1 event
expect(t1Events[1]?.previousHash).toBe(t1Events[0]?.hash);
// t2 only has one event, so no previousHash
expect(t2Events[0]?.previousHash).toBeUndefined();
});
it("tamper detection in integration", { timeout: 30000 }, async () => {
await logger.log({
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId: "u1" },
});
await logger.log({
type: AuditEventType.DATA_READ,
severity: AuditSeverity.INFO,
actor: { userId: "u1" },
});
const events = await logger.query({ order: "asc" });
// Tamper with first event
const tampered = [...events];
tampered[0] = { ...events[0]!, actor: { userId: "attacker" } };
const result = await logger.verifyChainIntegrity(tampered);
expect(result.valid).toBe(false);
expect(result.brokenAt).toBe(0);
});
});

View File

@@ -0,0 +1,194 @@
import { describe, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { MemoryAuditAdapter } from "../src/adapters/memory/memory.js";
import { AuditEventType, AuditSeverity } from "../src/constants/index.js";
import { AuditLogger } from "../src/core/audit-logger/audit-logger.js";
import {
computeEventHash,
verifyChain,
} from "../src/core/chain-integrity/chain-integrity.js";
import type { AuditEvent } from "../src/types/audit-logging.types.js";
const eventBaseArb = fc.record({
id: fc.uuid(),
type: fc.constantFrom(...Object.values(AuditEventType)),
severity: fc.constantFrom(...Object.values(AuditSeverity)),
actor: fc.record({
userId: fc.option(fc.uuid(), { nil: undefined }),
sessionId: fc.option(fc.uuid(), { nil: undefined }),
}),
timestamp: fc.date({
min: new Date("2020-01-01"),
max: new Date("2030-01-01"),
}),
});
describe("property: HMAC stability", () => {
it("same inputs produce same hash", { timeout: 30000 }, () => {
fc.assert(
fc.property(
eventBaseArb,
fc.string({ minLength: 1 }),
(event, secret) => {
const h1 = computeEventHash(event, secret);
const h2 = computeEventHash(event, secret);
return h1 === h2;
}
),
{ numRuns: 50 }
);
});
it("different secrets produce different hashes", { timeout: 30000 }, () => {
fc.assert(
fc.property(
eventBaseArb,
fc.string({ minLength: 1 }),
fc.string({ minLength: 1 }),
(event, s1, s2) => {
fc.pre(s1 !== s2);
return (
computeEventHash(event, s1) !==
computeEventHash(event, s2)
);
}
),
{ numRuns: 50 }
);
});
it("different events produce different hashes (same secret)", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
eventBaseArb,
eventBaseArb,
fc.string({ minLength: 1 }),
(e1, e2, secret) => {
// Only guarantee different hashes when key fields differ
fc.pre(e1.id !== e2.id);
return (
computeEventHash(e1, secret) !==
computeEventHash(e2, secret)
);
}
),
{ numRuns: 50 }
);
});
});
describe("property: chain integrity mutation detection", () => {
it("single mutation breaks chain at mutation index", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.array(eventBaseArb, { minLength: 2, maxLength: 5 }),
fc.string({ minLength: 1 }),
async (bases, secret) => {
// Build a valid chain
const events: AuditEvent[] = [];
let previousHash: string | undefined;
for (const base of bases) {
const hash = computeEventHash(
base,
secret,
previousHash
);
const event: AuditEvent = {
...base,
hash,
...(previousHash !== undefined
? { previousHash }
: {}),
};
events.push(event);
previousHash = hash;
}
const validResult = verifyChain(events, secret);
if (!validResult.valid) return false;
// Tamper with a random event (change userId)
const tamperIdx = 0;
const tampered = [...events];
tampered[tamperIdx] = {
...events[tamperIdx]!,
actor: { userId: "tampered-user" },
};
const brokenResult = verifyChain(tampered, secret);
return (
!brokenResult.valid &&
brokenResult.brokenAt === tamperIdx
);
}
),
{ numRuns: 30 }
);
});
});
describe("property: query filter correctness", () => {
it("userId filter returns only matching events", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.array(fc.uuid(), { minLength: 1, maxLength: 3 }),
async (userIds) => {
const adapter = new MemoryAuditAdapter();
const logger = new AuditLogger({
storage: adapter,
secretKey: "s",
});
for (const userId of userIds) {
await logger.log({
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId },
});
}
for (const userId of userIds) {
const results = await logger.query({ userId });
return results.every((e) => e.actor.userId === userId);
}
return true;
}
),
{ numRuns: 20 }
);
});
it("limit never exceeds requested count", { timeout: 30000 }, async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 1, max: 10 }),
fc.integer({ min: 0, max: 5 }),
async (total, limit) => {
const adapter = new MemoryAuditAdapter();
const logger = new AuditLogger({
storage: adapter,
secretKey: "s",
});
for (let i = 0; i < total; i++) {
await logger.log({
type: AuditEventType.AUTH_LOGIN,
severity: AuditSeverity.INFO,
actor: { userId: `u-${i}` },
});
}
const results = await logger.query({ limit });
return results.length <= limit;
}
),
{ numRuns: 20 }
);
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View 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"]
}

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/audit-logging",
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/error",
"@suaveplan/types",
"@suaveplan/events",
"@suaveplan/crypto",
"@suaveplan/crypto-hash",
"@suaveplan/logger",
"@suaveplan/serialize",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,29 @@
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: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"src/constants/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});