@geekmidas/audit 9.0.2 → 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +8 -5
- package/CHANGELOG.md +0 -101
- package/TECHNICAL.md +0 -937
- package/src/Auditor.ts +0 -157
- package/src/DefaultAuditor.ts +0 -141
- package/src/__tests__/CacheAuditStorage.spec.ts +0 -382
- package/src/__tests__/DefaultAuditor.spec.ts +0 -529
- package/src/__tests__/InMemoryAuditStorage.spec.ts +0 -317
- package/src/__tests__/KnexAuditStorage.integration.spec.ts +0 -739
- package/src/__tests__/KyselyAuditStorage.integration.spec.ts +0 -518
- package/src/__tests__/KyselyAuditStorage.spec.ts +0 -443
- package/src/cache.ts +0 -263
- package/src/index.ts +0 -23
- package/src/knex.ts +0 -430
- package/src/kysely.ts +0 -424
- package/src/memory.ts +0 -50
- package/src/storage.ts +0 -203
- package/src/types.ts +0 -154
- package/tsconfig.json +0 -9
package/TECHNICAL.md
DELETED
|
@@ -1,937 +0,0 @@
|
|
|
1
|
-
# @geekmidas/audit - Technical Documentation
|
|
2
|
-
|
|
3
|
-
## Overview
|
|
4
|
-
|
|
5
|
-
The `@geekmidas/audit` package provides a transaction-aware audit system for tracking database mutations. Unlike event publishing (which happens after a transaction commits), audits run **inside** the same transaction as the mutation, ensuring audit records are atomically committed or rolled back with the data they describe.
|
|
6
|
-
|
|
7
|
-
**Key Design Principle**: The core audit package is **database-agnostic**. Provider-specific wrappers (Kysely, Objection, Knex, MongoDB) are separate subpath exports that users opt into.
|
|
8
|
-
|
|
9
|
-
## Architecture
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
13
|
-
│ @geekmidas/audit │
|
|
14
|
-
├─────────────────────────────────────────────────────────────────┤
|
|
15
|
-
│ Core (database-agnostic) │ Provider Wrappers │
|
|
16
|
-
│ ───────────────────────── │ ────────────────── │
|
|
17
|
-
│ • Auditor interface │ • /kysely │
|
|
18
|
-
│ • DefaultAuditor │ • /objection (future) │
|
|
19
|
-
│ • AuditRecord types │ • /knex (future) │
|
|
20
|
-
│ • AuditStorage interface │ • /mongo (future) │
|
|
21
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
22
|
-
│
|
|
23
|
-
▼
|
|
24
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
25
|
-
│ Endpoint Integration │
|
|
26
|
-
│ • .auditor() builder method │
|
|
27
|
-
│ • .audit() declarative audits │
|
|
28
|
-
│ • ctx.auditor in handlers │
|
|
29
|
-
│ • Automatic flush after handler │
|
|
30
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
## Why Provider-Agnostic?
|
|
34
|
-
|
|
35
|
-
The audit system supports multiple database technologies:
|
|
36
|
-
|
|
37
|
-
| Provider | Status | Import Path |
|
|
38
|
-
|----------|--------|-------------|
|
|
39
|
-
| Kysely | Implemented | `@geekmidas/audit/kysely` |
|
|
40
|
-
| Objection.js | Future | `@geekmidas/audit/objection` |
|
|
41
|
-
| Knex | Future | `@geekmidas/audit/knex` |
|
|
42
|
-
| MongoDB | Future | `@geekmidas/audit/mongo` |
|
|
43
|
-
| Drizzle | Future | `@geekmidas/audit/drizzle` |
|
|
44
|
-
|
|
45
|
-
Users explicitly wrap their database with the appropriate provider wrapper. The endpoint adaptor **does not** automatically wrap services.
|
|
46
|
-
|
|
47
|
-
## Request Flow
|
|
48
|
-
|
|
49
|
-
```
|
|
50
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
51
|
-
│ HTTP Request │
|
|
52
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
53
|
-
│
|
|
54
|
-
▼
|
|
55
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
56
|
-
│ HonoEndpointAdaptor │
|
|
57
|
-
│ ┌───────────────────────────────────────────────────────────┐ │
|
|
58
|
-
│ │ 1. Create Auditor (if .auditor() was called) │ │
|
|
59
|
-
│ │ 2. Pass auditor to handler context │ │
|
|
60
|
-
│ │ (NO automatic database wrapping) │ │
|
|
61
|
-
│ └───────────────────────────────────────────────────────────┘ │
|
|
62
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
63
|
-
│
|
|
64
|
-
▼
|
|
65
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
66
|
-
│ Endpoint Handler │
|
|
67
|
-
│ ┌───────────────────────────────────────────────────────────┐ │
|
|
68
|
-
│ │ // User explicitly wraps database (provider-specific) │ │
|
|
69
|
-
│ │ const db = new AuditableKysely(services.database, auditor);│ │
|
|
70
|
-
│ │ │ │
|
|
71
|
-
│ │ // Auto-captured audit (via wrapper) │ │
|
|
72
|
-
│ │ await db.updateTable('users').set({ name: 'New' }).execute()│ │
|
|
73
|
-
│ │ │ │
|
|
74
|
-
│ │ // Manual audit (via context) │ │
|
|
75
|
-
│ │ auditor.audit('custom.action', { ... }); │ │
|
|
76
|
-
│ └───────────────────────────────────────────────────────────┘ │
|
|
77
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
78
|
-
│
|
|
79
|
-
▼
|
|
80
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
81
|
-
│ After Handler Execution │
|
|
82
|
-
│ ┌───────────────────────────────────────────────────────────┐ │
|
|
83
|
-
│ │ 1. Process declarative .audit() definitions │ │
|
|
84
|
-
│ │ 2. Flush all collected audits to storage │ │
|
|
85
|
-
│ │ (INSIDE the transaction) │ │
|
|
86
|
-
│ └───────────────────────────────────────────────────────────┘ │
|
|
87
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
## Core Types
|
|
91
|
-
|
|
92
|
-
### AuditRecord
|
|
93
|
-
|
|
94
|
-
The fundamental unit of audit data:
|
|
95
|
-
|
|
96
|
-
```typescript
|
|
97
|
-
type AuditOperation = 'INSERT' | 'UPDATE' | 'DELETE' | 'CUSTOM';
|
|
98
|
-
|
|
99
|
-
interface AuditRecord<TPayload = unknown> {
|
|
100
|
-
// Identity
|
|
101
|
-
id: string; // Unique identifier (nanoid)
|
|
102
|
-
type: string; // Audit type (e.g., 'user.updated')
|
|
103
|
-
|
|
104
|
-
// Operation details
|
|
105
|
-
operation: AuditOperation; // What kind of operation
|
|
106
|
-
table?: string; // Database table (for DB operations)
|
|
107
|
-
entityId?: string | Record<string, unknown>; // Primary key(s)
|
|
108
|
-
|
|
109
|
-
// Change tracking
|
|
110
|
-
oldValues?: Record<string, unknown>; // Previous state (UPDATE/DELETE)
|
|
111
|
-
newValues?: Record<string, unknown>; // New state (INSERT/UPDATE)
|
|
112
|
-
payload?: TPayload; // Custom payload (CUSTOM operations)
|
|
113
|
-
|
|
114
|
-
// Context
|
|
115
|
-
timestamp: Date; // When the audit was recorded
|
|
116
|
-
actor?: AuditActor; // Who performed the action
|
|
117
|
-
metadata?: AuditMetadata; // Request context
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
interface AuditActor {
|
|
121
|
-
id?: string; // User/service ID
|
|
122
|
-
type?: string; // 'user', 'system', 'service', etc.
|
|
123
|
-
[key: string]: unknown; // Extensible for custom fields
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
interface AuditMetadata {
|
|
127
|
-
requestId?: string; // Correlation ID
|
|
128
|
-
endpoint?: string; // Which endpoint was called
|
|
129
|
-
method?: string; // HTTP method
|
|
130
|
-
ip?: string; // Client IP
|
|
131
|
-
userAgent?: string; // Client user agent
|
|
132
|
-
[key: string]: unknown; // Extensible
|
|
133
|
-
}
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
### AuditableAction Type
|
|
137
|
-
|
|
138
|
-
Define type-safe audit actions (similar to `PublishableMessage` in events):
|
|
139
|
-
|
|
140
|
-
```typescript
|
|
141
|
-
/**
|
|
142
|
-
* Define an auditable action type with its payload structure.
|
|
143
|
-
* Use union types to define all possible audit actions for your app.
|
|
144
|
-
*/
|
|
145
|
-
type AuditableAction<TType extends string, TPayload = unknown> = {
|
|
146
|
-
type: TType;
|
|
147
|
-
payload: TPayload;
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
// Example: Define your app's audit types
|
|
151
|
-
type AppAuditAction =
|
|
152
|
-
| AuditableAction<'user.created', { userId: string; email: string }>
|
|
153
|
-
| AuditableAction<'user.updated', { userId: string; changes: string[] }>
|
|
154
|
-
| AuditableAction<'user.deleted', { userId: string; reason?: string }>
|
|
155
|
-
| AuditableAction<'order.placed', { orderId: string; total: number }>
|
|
156
|
-
| AuditableAction<'payment.processed', { orderId: string; transactionId: string }>;
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
### Type Extraction Utilities
|
|
160
|
-
|
|
161
|
-
```typescript
|
|
162
|
-
/**
|
|
163
|
-
* Extract the type string from an AuditableAction union
|
|
164
|
-
*/
|
|
165
|
-
type ExtractAuditType<T extends AuditableAction<string, unknown>> =
|
|
166
|
-
T extends AuditableAction<infer TType, unknown> ? TType : never;
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Extract the payload for a specific audit type
|
|
170
|
-
*/
|
|
171
|
-
type ExtractAuditPayload<
|
|
172
|
-
T extends AuditableAction<string, unknown>,
|
|
173
|
-
TType extends ExtractAuditType<T>,
|
|
174
|
-
> = T extends AuditableAction<TType, infer TPayload> ? TPayload : never;
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
### Auditor Interface
|
|
178
|
-
|
|
179
|
-
The core abstraction for audit collection. **Generic over audit action types** for full type safety:
|
|
180
|
-
|
|
181
|
-
```typescript
|
|
182
|
-
interface Auditor<
|
|
183
|
-
TAuditAction extends AuditableAction<string, unknown> = AuditableAction<string, unknown>,
|
|
184
|
-
> {
|
|
185
|
-
/**
|
|
186
|
-
* The actor for all audits in this context.
|
|
187
|
-
* Set at construction time, immutable.
|
|
188
|
-
*/
|
|
189
|
-
readonly actor: AuditActor;
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Record a type-safe audit entry.
|
|
193
|
-
* The payload type is inferred from the audit type.
|
|
194
|
-
*/
|
|
195
|
-
audit<TType extends ExtractAuditType<TAuditAction>>(
|
|
196
|
-
type: TType,
|
|
197
|
-
payload: ExtractAuditPayload<TAuditAction, TType>,
|
|
198
|
-
options?: AuditOptions,
|
|
199
|
-
): void;
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Record a raw audit record.
|
|
203
|
-
* Use this when you need full control over the audit structure.
|
|
204
|
-
*/
|
|
205
|
-
record(record: Omit<AuditRecord, 'id' | 'timestamp' | 'actor'>): void;
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Get all collected audit records.
|
|
209
|
-
*/
|
|
210
|
-
getRecords(): AuditRecord[];
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Flush all collected audits to storage.
|
|
214
|
-
* Called automatically by the endpoint adaptor inside the transaction.
|
|
215
|
-
*/
|
|
216
|
-
flush(trx?: unknown): Promise<void>;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
interface AuditOptions {
|
|
220
|
-
entityId?: string | Record<string, unknown>;
|
|
221
|
-
table?: string;
|
|
222
|
-
operation?: AuditOperation;
|
|
223
|
-
oldValues?: Record<string, unknown>;
|
|
224
|
-
newValues?: Record<string, unknown>;
|
|
225
|
-
}
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
### AuditStorage Interface
|
|
229
|
-
|
|
230
|
-
Pluggable storage backend:
|
|
231
|
-
|
|
232
|
-
```typescript
|
|
233
|
-
interface AuditStorage {
|
|
234
|
-
/**
|
|
235
|
-
* Write audit records to storage.
|
|
236
|
-
* @param records - The audit records to write
|
|
237
|
-
* @param trx - Transaction context (if writing to same DB)
|
|
238
|
-
*/
|
|
239
|
-
write(records: AuditRecord[], trx?: unknown): Promise<void>;
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Optional: Query audit records for retrieval.
|
|
243
|
-
*/
|
|
244
|
-
query?(options: AuditQueryOptions): Promise<AuditRecord[]>;
|
|
245
|
-
}
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
## Three Ways to Audit
|
|
249
|
-
|
|
250
|
-
### 1. Manual Auditing (via Context)
|
|
251
|
-
|
|
252
|
-
For explicit control or non-database operations:
|
|
253
|
-
|
|
254
|
-
```typescript
|
|
255
|
-
const processOrder = e
|
|
256
|
-
.post('/orders')
|
|
257
|
-
.services([databaseService, paymentService])
|
|
258
|
-
.auditor(auditStorageService)
|
|
259
|
-
.handle(async ({ body, services, auditor }) => {
|
|
260
|
-
// Database operation (not auto-audited without wrapper)
|
|
261
|
-
const order = await services.database
|
|
262
|
-
.insertInto('orders')
|
|
263
|
-
.values(body)
|
|
264
|
-
.returningAll()
|
|
265
|
-
.executeTakeFirstOrThrow();
|
|
266
|
-
|
|
267
|
-
// Manual audit for external service call
|
|
268
|
-
const paymentResult = await services.payment.charge(order.total);
|
|
269
|
-
auditor.audit('payment.charged', {
|
|
270
|
-
orderId: order.id,
|
|
271
|
-
amount: order.total,
|
|
272
|
-
transactionId: paymentResult.transactionId,
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
// Conditional audit
|
|
276
|
-
if (order.total > 10000) {
|
|
277
|
-
auditor.audit('order.high_value', {
|
|
278
|
-
orderId: order.id,
|
|
279
|
-
total: order.total,
|
|
280
|
-
requiresReview: true,
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
return order;
|
|
285
|
-
});
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
### 2. Declarative Auditing (Builder Pattern)
|
|
289
|
-
|
|
290
|
-
Similar to `.events()`, declare audits on the endpoint with `.audit()`:
|
|
291
|
-
|
|
292
|
-
```typescript
|
|
293
|
-
// Define app audit types for type safety
|
|
294
|
-
type AppAuditAction =
|
|
295
|
-
| AuditableAction<'user.created', { userId: string; email: string }>
|
|
296
|
-
| AuditableAction<'user.updated', { userId: string; changes: string[] }>;
|
|
297
|
-
|
|
298
|
-
const createUser = e
|
|
299
|
-
.post('/users')
|
|
300
|
-
.services([databaseService])
|
|
301
|
-
.auditor(auditStorageService) // Just the service
|
|
302
|
-
.actor(({ session, header }) => ({
|
|
303
|
-
id: session?.userId,
|
|
304
|
-
type: 'user',
|
|
305
|
-
ip: header('x-forwarded-for'),
|
|
306
|
-
}))
|
|
307
|
-
.body(createUserSchema)
|
|
308
|
-
.output(userSchema)
|
|
309
|
-
// Declarative audits - array like .events()
|
|
310
|
-
.audit<AppAuditAction>([
|
|
311
|
-
{
|
|
312
|
-
type: 'user.created', // ✅ Type-safe - must be valid audit type
|
|
313
|
-
payload: (response) => ({
|
|
314
|
-
userId: response.id,
|
|
315
|
-
email: response.email, // ✅ Payload shape enforced
|
|
316
|
-
}),
|
|
317
|
-
// Optional: only audit under certain conditions
|
|
318
|
-
when: (response) => response.role !== 'system',
|
|
319
|
-
// Optional: specify entity ID for easier querying
|
|
320
|
-
entityId: (response) => response.id,
|
|
321
|
-
table: 'users',
|
|
322
|
-
},
|
|
323
|
-
])
|
|
324
|
-
.handle(async ({ body, services }) => {
|
|
325
|
-
return await services.database
|
|
326
|
-
.insertInto('users')
|
|
327
|
-
.values(body)
|
|
328
|
-
.returningAll()
|
|
329
|
-
.executeTakeFirstOrThrow();
|
|
330
|
-
});
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
### 3. Automatic Database Auditing (Provider Wrappers)
|
|
334
|
-
|
|
335
|
-
User explicitly wraps their database with a provider-specific wrapper:
|
|
336
|
-
|
|
337
|
-
```typescript
|
|
338
|
-
import { AuditableKysely } from '@geekmidas/audit/kysely';
|
|
339
|
-
|
|
340
|
-
const updateUser = e
|
|
341
|
-
.put('/users/:id')
|
|
342
|
-
.services([databaseService])
|
|
343
|
-
.auditor(auditStorageService) // Just the service
|
|
344
|
-
.actor(({ session, header }) => ({
|
|
345
|
-
id: session.userId,
|
|
346
|
-
type: 'user',
|
|
347
|
-
ip: header('x-forwarded-for'),
|
|
348
|
-
}))
|
|
349
|
-
.handle(async ({ params, body, services, auditor }) => {
|
|
350
|
-
// User explicitly wraps database
|
|
351
|
-
const db = new AuditableKysely(services.database, auditor, {
|
|
352
|
-
excludeTables: ['audit_logs'],
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
// This UPDATE is automatically audited:
|
|
356
|
-
// - Old values fetched before update
|
|
357
|
-
// - New values captured after update
|
|
358
|
-
// - Audit record includes table, entityId, oldValues, newValues
|
|
359
|
-
// - Actor automatically set from session
|
|
360
|
-
return await db
|
|
361
|
-
.updateTable('users')
|
|
362
|
-
.set(body)
|
|
363
|
-
.where('id', '=', params.id)
|
|
364
|
-
.returningAll()
|
|
365
|
-
.executeTakeFirstOrThrow();
|
|
366
|
-
});
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
## Service Factory Pattern (Recommended)
|
|
370
|
-
|
|
371
|
-
Create a service that provides an auditable wrapper factory:
|
|
372
|
-
|
|
373
|
-
```typescript
|
|
374
|
-
// services/database.ts
|
|
375
|
-
import { AuditableKysely } from '@geekmidas/audit/kysely';
|
|
376
|
-
import type { Auditor } from '@geekmidas/audit';
|
|
377
|
-
|
|
378
|
-
interface DatabaseService {
|
|
379
|
-
raw: Kysely<Database>;
|
|
380
|
-
withAuditor: (auditor: Auditor) => AuditableKysely<Database>;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const databaseService = {
|
|
384
|
-
serviceName: 'database' as const,
|
|
385
|
-
async register(envParser) {
|
|
386
|
-
const db = createKyselyConnection(envParser);
|
|
387
|
-
return {
|
|
388
|
-
raw: db,
|
|
389
|
-
withAuditor: (auditor: Auditor) => new AuditableKysely(db, auditor),
|
|
390
|
-
};
|
|
391
|
-
},
|
|
392
|
-
} satisfies Service<'database', DatabaseService>;
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
Usage in endpoints:
|
|
396
|
-
|
|
397
|
-
```typescript
|
|
398
|
-
const updateUser = e
|
|
399
|
-
.put('/users/:id')
|
|
400
|
-
.services([databaseService])
|
|
401
|
-
.auditor(auditStorageService)
|
|
402
|
-
.handle(async ({ params, body, services, auditor }) => {
|
|
403
|
-
// Clean one-liner to get auditable database
|
|
404
|
-
const db = services.database.withAuditor(auditor);
|
|
405
|
-
|
|
406
|
-
return await db
|
|
407
|
-
.updateTable('users')
|
|
408
|
-
.set(body)
|
|
409
|
-
.where('id', '=', params.id)
|
|
410
|
-
.returningAll()
|
|
411
|
-
.executeTakeFirstOrThrow();
|
|
412
|
-
});
|
|
413
|
-
```
|
|
414
|
-
|
|
415
|
-
## Provider Wrapper: AuditableKysely
|
|
416
|
-
|
|
417
|
-
### Overview
|
|
418
|
-
|
|
419
|
-
`AuditableKysely` wraps a Kysely instance to intercept INSERT/UPDATE/DELETE operations:
|
|
420
|
-
|
|
421
|
-
```typescript
|
|
422
|
-
import { AuditableKysely } from '@geekmidas/audit/kysely';
|
|
423
|
-
|
|
424
|
-
const auditableDb = new AuditableKysely(db, auditor, {
|
|
425
|
-
excludeTables: ['audit_logs', 'sessions'],
|
|
426
|
-
getPrimaryKey: (table, record) => record.id,
|
|
427
|
-
});
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
### Configuration
|
|
431
|
-
|
|
432
|
-
```typescript
|
|
433
|
-
interface AuditableKyselyConfig<DB> {
|
|
434
|
-
/**
|
|
435
|
-
* Tables to exclude from automatic auditing.
|
|
436
|
-
* Use to prevent recursion or skip high-volume tables.
|
|
437
|
-
*/
|
|
438
|
-
excludeTables?: (keyof DB)[];
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* Custom primary key extractor.
|
|
442
|
-
* Default: looks for 'id' field.
|
|
443
|
-
*/
|
|
444
|
-
getPrimaryKey?: (
|
|
445
|
-
table: keyof DB,
|
|
446
|
-
record: unknown
|
|
447
|
-
) => string | Record<string, unknown>;
|
|
448
|
-
}
|
|
449
|
-
```
|
|
450
|
-
|
|
451
|
-
### How Operations Are Intercepted
|
|
452
|
-
|
|
453
|
-
**INSERT:**
|
|
454
|
-
```typescript
|
|
455
|
-
// User code
|
|
456
|
-
await db.insertInto('users').values({ name: 'John' }).execute();
|
|
457
|
-
|
|
458
|
-
// Internally:
|
|
459
|
-
// 1. Execute INSERT
|
|
460
|
-
// 2. Record audit with operation='INSERT', newValues={name:'John'}
|
|
461
|
-
```
|
|
462
|
-
|
|
463
|
-
**UPDATE:**
|
|
464
|
-
```typescript
|
|
465
|
-
// User code
|
|
466
|
-
await db.updateTable('users').set({ name: 'Jane' }).where('id', '=', '123').execute();
|
|
467
|
-
|
|
468
|
-
// Internally:
|
|
469
|
-
// 1. SELECT * FROM users WHERE id = '123' (capture old values)
|
|
470
|
-
// 2. Execute UPDATE
|
|
471
|
-
// 3. SELECT * FROM users WHERE id = '123' (capture new values)
|
|
472
|
-
// 4. Record audit with oldValues, newValues
|
|
473
|
-
```
|
|
474
|
-
|
|
475
|
-
**DELETE:**
|
|
476
|
-
```typescript
|
|
477
|
-
// User code
|
|
478
|
-
await db.deleteFrom('users').where('id', '=', '123').execute();
|
|
479
|
-
|
|
480
|
-
// Internally:
|
|
481
|
-
// 1. SELECT * FROM users WHERE id = '123' (capture old values)
|
|
482
|
-
// 2. Execute DELETE
|
|
483
|
-
// 3. Record audit with operation='DELETE', oldValues
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
### Supported Methods
|
|
487
|
-
|
|
488
|
-
```typescript
|
|
489
|
-
class AuditableKysely<DB> {
|
|
490
|
-
// Wrapped (audited)
|
|
491
|
-
insertInto<T>(table: T): AuditableInsertQueryBuilder;
|
|
492
|
-
updateTable<T>(table: T): AuditableUpdateQueryBuilder;
|
|
493
|
-
deleteFrom<T>(table: T): AuditableDeleteQueryBuilder;
|
|
494
|
-
|
|
495
|
-
// Pass-through (not audited)
|
|
496
|
-
selectFrom<T>(table: T): SelectQueryBuilder;
|
|
497
|
-
with(...): WithBuilder;
|
|
498
|
-
|
|
499
|
-
// Access raw db for edge cases
|
|
500
|
-
get raw(): Kysely<DB> | Transaction<DB>;
|
|
501
|
-
}
|
|
502
|
-
```
|
|
503
|
-
|
|
504
|
-
## Future Provider Wrappers
|
|
505
|
-
|
|
506
|
-
### Objection.js (Future)
|
|
507
|
-
|
|
508
|
-
```typescript
|
|
509
|
-
import { AuditableObjection } from '@geekmidas/audit/objection';
|
|
510
|
-
|
|
511
|
-
const User = new AuditableObjection(UserModel, auditor);
|
|
512
|
-
|
|
513
|
-
await User.query()
|
|
514
|
-
.patchAndFetchById(params.id, body);
|
|
515
|
-
```
|
|
516
|
-
|
|
517
|
-
### Knex (Future)
|
|
518
|
-
|
|
519
|
-
```typescript
|
|
520
|
-
import { AuditableKnex } from '@geekmidas/audit/knex';
|
|
521
|
-
|
|
522
|
-
const db = new AuditableKnex(knex, auditor);
|
|
523
|
-
|
|
524
|
-
await db('users')
|
|
525
|
-
.where('id', params.id)
|
|
526
|
-
.update(body);
|
|
527
|
-
```
|
|
528
|
-
|
|
529
|
-
### MongoDB (Future)
|
|
530
|
-
|
|
531
|
-
```typescript
|
|
532
|
-
import { AuditableMongo } from '@geekmidas/audit/mongo';
|
|
533
|
-
|
|
534
|
-
const db = new AuditableMongo(mongoClient, auditor);
|
|
535
|
-
|
|
536
|
-
await db.collection('users')
|
|
537
|
-
.findOneAndUpdate(
|
|
538
|
-
{ _id: params.id },
|
|
539
|
-
{ $set: body },
|
|
540
|
-
{ returnDocument: 'after' }
|
|
541
|
-
);
|
|
542
|
-
```
|
|
543
|
-
|
|
544
|
-
## Storage Implementations
|
|
545
|
-
|
|
546
|
-
### Same Database Storage (Recommended)
|
|
547
|
-
|
|
548
|
-
Store audits in the same database for transactional consistency:
|
|
549
|
-
|
|
550
|
-
```typescript
|
|
551
|
-
const auditStorageService = {
|
|
552
|
-
serviceName: 'auditStorage' as const,
|
|
553
|
-
async register(envParser) {
|
|
554
|
-
return {
|
|
555
|
-
async write(records, trx) {
|
|
556
|
-
if (records.length === 0) return;
|
|
557
|
-
|
|
558
|
-
// Use transaction if provided
|
|
559
|
-
const db = trx ?? getDatabase();
|
|
560
|
-
|
|
561
|
-
await db
|
|
562
|
-
.insertInto('audit_logs')
|
|
563
|
-
.values(records.map(r => ({
|
|
564
|
-
id: r.id,
|
|
565
|
-
type: r.type,
|
|
566
|
-
operation: r.operation,
|
|
567
|
-
table_name: r.table,
|
|
568
|
-
entity_id: JSON.stringify(r.entityId),
|
|
569
|
-
old_values: r.oldValues ? JSON.stringify(r.oldValues) : null,
|
|
570
|
-
new_values: r.newValues ? JSON.stringify(r.newValues) : null,
|
|
571
|
-
payload: r.payload ? JSON.stringify(r.payload) : null,
|
|
572
|
-
actor_id: r.actor?.id,
|
|
573
|
-
actor_type: r.actor?.type,
|
|
574
|
-
metadata: r.metadata ? JSON.stringify(r.metadata) : null,
|
|
575
|
-
created_at: r.timestamp,
|
|
576
|
-
})))
|
|
577
|
-
.execute();
|
|
578
|
-
},
|
|
579
|
-
} satisfies AuditStorage;
|
|
580
|
-
},
|
|
581
|
-
} satisfies Service<'auditStorage', AuditStorage>;
|
|
582
|
-
```
|
|
583
|
-
|
|
584
|
-
### External Audit Service
|
|
585
|
-
|
|
586
|
-
For compliance or separate audit systems:
|
|
587
|
-
|
|
588
|
-
```typescript
|
|
589
|
-
const externalAuditService = {
|
|
590
|
-
serviceName: 'auditStorage' as const,
|
|
591
|
-
async register(envParser) {
|
|
592
|
-
const config = envParser.create((get) => ({
|
|
593
|
-
auditServiceUrl: get('AUDIT_SERVICE_URL').string(),
|
|
594
|
-
apiKey: get('AUDIT_API_KEY').string(),
|
|
595
|
-
})).parse();
|
|
596
|
-
|
|
597
|
-
return {
|
|
598
|
-
async write(records, _trx) {
|
|
599
|
-
// Note: External writes can't participate in the transaction
|
|
600
|
-
// Consider using outbox pattern for guaranteed delivery
|
|
601
|
-
await fetch(`${config.auditServiceUrl}/audits`, {
|
|
602
|
-
method: 'POST',
|
|
603
|
-
headers: {
|
|
604
|
-
'Content-Type': 'application/json',
|
|
605
|
-
'Authorization': `Bearer ${config.apiKey}`,
|
|
606
|
-
},
|
|
607
|
-
body: JSON.stringify({ records }),
|
|
608
|
-
});
|
|
609
|
-
},
|
|
610
|
-
} satisfies AuditStorage;
|
|
611
|
-
},
|
|
612
|
-
};
|
|
613
|
-
```
|
|
614
|
-
|
|
615
|
-
## Custom Auditor Implementations
|
|
616
|
-
|
|
617
|
-
The `Auditor` interface is generic, allowing custom implementations:
|
|
618
|
-
|
|
619
|
-
### Filtered Auditor
|
|
620
|
-
|
|
621
|
-
Only audit certain operations:
|
|
622
|
-
|
|
623
|
-
```typescript
|
|
624
|
-
class FilteredAuditor implements Auditor {
|
|
625
|
-
constructor(
|
|
626
|
-
private readonly inner: Auditor,
|
|
627
|
-
private readonly filter: (record: AuditRecord) => boolean
|
|
628
|
-
) {}
|
|
629
|
-
|
|
630
|
-
get actor(): AuditActor {
|
|
631
|
-
return this.inner.actor;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
audit<T>(type: string, payload: T, options?: AuditOptions): void {
|
|
635
|
-
this.inner.audit(type, payload, options);
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
record(record: Omit<AuditRecord, 'id' | 'timestamp' | 'actor'>): void {
|
|
639
|
-
this.inner.record(record);
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
getRecords(): AuditRecord[] {
|
|
643
|
-
return this.inner.getRecords().filter(this.filter);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
async flush(trx?: unknown): Promise<void> {
|
|
647
|
-
// Only flush filtered records
|
|
648
|
-
// ...
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
```
|
|
652
|
-
|
|
653
|
-
### Enriched Auditor
|
|
654
|
-
|
|
655
|
-
Add extra context to all audits:
|
|
656
|
-
|
|
657
|
-
```typescript
|
|
658
|
-
class EnrichedAuditor implements Auditor {
|
|
659
|
-
constructor(
|
|
660
|
-
private readonly inner: Auditor,
|
|
661
|
-
private readonly enrichment: Record<string, unknown>
|
|
662
|
-
) {}
|
|
663
|
-
|
|
664
|
-
get actor(): AuditActor {
|
|
665
|
-
return this.inner.actor;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
audit<T>(type: string, payload: T, options?: AuditOptions): void {
|
|
669
|
-
this.inner.audit(type, { ...payload, ...this.enrichment }, options);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// ... delegate other methods
|
|
673
|
-
}
|
|
674
|
-
```
|
|
675
|
-
|
|
676
|
-
## Transaction Integration
|
|
677
|
-
|
|
678
|
-
### Key Principle
|
|
679
|
-
|
|
680
|
-
Audits are flushed **inside** the transaction, ensuring atomicity:
|
|
681
|
-
|
|
682
|
-
```typescript
|
|
683
|
-
// In HonoEndpointAdaptor
|
|
684
|
-
const response = await endpoint.handler({ services, auditor }, responseBuilder);
|
|
685
|
-
|
|
686
|
-
// Process declarative audits
|
|
687
|
-
for (const audit of endpoint.audits) {
|
|
688
|
-
if (!audit.when || audit.when(response)) {
|
|
689
|
-
auditor.audit(audit.type, audit.payload(response), {
|
|
690
|
-
entityId: audit.entityId?.(response),
|
|
691
|
-
table: audit.table
|
|
692
|
-
});
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// CRITICAL: Flush inside transaction
|
|
697
|
-
await auditor.flush(transaction);
|
|
698
|
-
// If flush fails → entire transaction rolls back
|
|
699
|
-
// If mutation fails → audits never written
|
|
700
|
-
```
|
|
701
|
-
|
|
702
|
-
### Explicit Transaction Handling
|
|
703
|
-
|
|
704
|
-
When you manage transactions explicitly:
|
|
705
|
-
|
|
706
|
-
```typescript
|
|
707
|
-
const transferFunds = e
|
|
708
|
-
.post('/transfers')
|
|
709
|
-
.services([databaseService])
|
|
710
|
-
.auditor(auditStorageService)
|
|
711
|
-
.handle(async ({ body, services, auditor }) => {
|
|
712
|
-
return await withTransaction(services.database.raw, async (trx) => {
|
|
713
|
-
// Wrap transaction with auditable layer
|
|
714
|
-
const db = new AuditableKysely(trx, auditor);
|
|
715
|
-
|
|
716
|
-
// Debit
|
|
717
|
-
await db
|
|
718
|
-
.updateTable('accounts')
|
|
719
|
-
.set({ balance: sql`balance - ${body.amount}` })
|
|
720
|
-
.where('id', '=', body.fromAccountId)
|
|
721
|
-
.execute();
|
|
722
|
-
|
|
723
|
-
// Credit
|
|
724
|
-
await db
|
|
725
|
-
.updateTable('accounts')
|
|
726
|
-
.set({ balance: sql`balance + ${body.amount}` })
|
|
727
|
-
.where('id', '=', body.toAccountId)
|
|
728
|
-
.execute();
|
|
729
|
-
|
|
730
|
-
// Manual audit for the transfer as a whole
|
|
731
|
-
auditor.audit('transfer.completed', {
|
|
732
|
-
fromAccountId: body.fromAccountId,
|
|
733
|
-
toAccountId: body.toAccountId,
|
|
734
|
-
amount: body.amount,
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
// Flush audits INSIDE the transaction
|
|
738
|
-
await auditor.flush(trx);
|
|
739
|
-
|
|
740
|
-
return { success: true };
|
|
741
|
-
});
|
|
742
|
-
});
|
|
743
|
-
```
|
|
744
|
-
|
|
745
|
-
## Endpoint Integration
|
|
746
|
-
|
|
747
|
-
### EndpointBuilder Methods
|
|
748
|
-
|
|
749
|
-
The audit builder pattern mirrors `.publisherService()` and `.events()`:
|
|
750
|
-
|
|
751
|
-
```typescript
|
|
752
|
-
// Pattern comparison:
|
|
753
|
-
// Events: .publisherService(service).events([...])
|
|
754
|
-
// Audits: .auditor(service).audit([...])
|
|
755
|
-
|
|
756
|
-
// Add auditor service (just the service, no actor extractor)
|
|
757
|
-
.auditor(auditStorageService)
|
|
758
|
-
|
|
759
|
-
// Optional: Set actor extractor separately
|
|
760
|
-
.actor(({ session, header }) => ({
|
|
761
|
-
id: session?.userId,
|
|
762
|
-
type: session ? 'user' : 'anonymous',
|
|
763
|
-
ip: header('x-forwarded-for'),
|
|
764
|
-
}))
|
|
765
|
-
|
|
766
|
-
// Add declarative audits (array, like .events())
|
|
767
|
-
.audit<AppAuditAction>([
|
|
768
|
-
{
|
|
769
|
-
type: 'user.created',
|
|
770
|
-
payload: (response) => ({ userId: response.id, email: response.email }),
|
|
771
|
-
when: (response) => response.active,
|
|
772
|
-
entityId: (response) => response.id,
|
|
773
|
-
table: 'users',
|
|
774
|
-
},
|
|
775
|
-
])
|
|
776
|
-
```
|
|
777
|
-
|
|
778
|
-
### MappedAudit Type
|
|
779
|
-
|
|
780
|
-
Similar to `MappedEvent`, defines how to map response to audit:
|
|
781
|
-
|
|
782
|
-
```typescript
|
|
783
|
-
interface MappedAudit<
|
|
784
|
-
TAuditAction extends AuditableAction<string, unknown>,
|
|
785
|
-
TOutput extends StandardSchemaV1,
|
|
786
|
-
> {
|
|
787
|
-
type: ExtractAuditType<TAuditAction>;
|
|
788
|
-
payload: (
|
|
789
|
-
response: InferStandardSchema<TOutput>,
|
|
790
|
-
ctx: EndpointContext,
|
|
791
|
-
) => ExtractAuditPayload<TAuditAction, typeof type>;
|
|
792
|
-
when?: (response: InferStandardSchema<TOutput>) => boolean;
|
|
793
|
-
entityId?: (response: InferStandardSchema<TOutput>) => string | Record<string, unknown>;
|
|
794
|
-
table?: string;
|
|
795
|
-
}
|
|
796
|
-
```
|
|
797
|
-
|
|
798
|
-
### Actor Extractor Type
|
|
799
|
-
|
|
800
|
-
The actor extractor is **optional** and set via a separate `.actor()` method:
|
|
801
|
-
|
|
802
|
-
```typescript
|
|
803
|
-
type ActorExtractor<TServices, TSession, TLogger> = (ctx: {
|
|
804
|
-
services: ServiceRecord<TServices>;
|
|
805
|
-
session: TSession;
|
|
806
|
-
header: HeaderFn;
|
|
807
|
-
cookie: CookieFn;
|
|
808
|
-
logger: TLogger;
|
|
809
|
-
}) => AuditActor | Promise<AuditActor>;
|
|
810
|
-
|
|
811
|
-
// Usage - actor is optional, defaults to empty actor if not set
|
|
812
|
-
.auditor(auditStorageService)
|
|
813
|
-
.actor(({ session, header }) => ({
|
|
814
|
-
id: session.userId,
|
|
815
|
-
type: 'user',
|
|
816
|
-
ip: header('x-forwarded-for'),
|
|
817
|
-
}))
|
|
818
|
-
```
|
|
819
|
-
|
|
820
|
-
### EndpointContext
|
|
821
|
-
|
|
822
|
-
When `.auditor()` is called, `auditor` is **guaranteed** to exist (not optional):
|
|
823
|
-
|
|
824
|
-
```typescript
|
|
825
|
-
// Without .auditor() - auditor is undefined
|
|
826
|
-
.handle(async ({ services }) => {
|
|
827
|
-
// auditor not available
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
// With .auditor() - auditor is guaranteed and typed
|
|
831
|
-
.handle(async ({ services, auditor }) => {
|
|
832
|
-
// auditor: Auditor<AppAuditAction> (not optional!)
|
|
833
|
-
|
|
834
|
-
// ✅ Type-safe - payload inferred from 'user.created'
|
|
835
|
-
auditor.audit('user.created', { userId: '123', email: 'test@example.com' });
|
|
836
|
-
|
|
837
|
-
// ❌ Type error - wrong payload
|
|
838
|
-
auditor.audit('user.created', { orderId: '123' });
|
|
839
|
-
|
|
840
|
-
// ❌ Type error - unknown audit type
|
|
841
|
-
auditor.audit('unknown.type', {});
|
|
842
|
-
});
|
|
843
|
-
```
|
|
844
|
-
|
|
845
|
-
## Performance Considerations
|
|
846
|
-
|
|
847
|
-
### Extra SELECT for Old Values
|
|
848
|
-
|
|
849
|
-
UPDATE/DELETE require fetching old values:
|
|
850
|
-
|
|
851
|
-
```
|
|
852
|
-
UPDATE users SET name = 'New' WHERE id = '123'
|
|
853
|
-
|
|
854
|
-
Becomes:
|
|
855
|
-
1. SELECT * FROM users WHERE id = '123' ← Extra query
|
|
856
|
-
2. UPDATE users SET name = 'New' WHERE id = '123'
|
|
857
|
-
3. SELECT * FROM users WHERE id = '123' ← For new values (or use RETURNING)
|
|
858
|
-
```
|
|
859
|
-
|
|
860
|
-
**Mitigations:**
|
|
861
|
-
1. **Use RETURNING**: Avoid extra SELECT for new values
|
|
862
|
-
2. **Exclude high-volume tables**: `excludeTables: ['logs', 'metrics']`
|
|
863
|
-
3. **Index WHERE columns**: Ensure efficient lookups
|
|
864
|
-
4. **Use declarative audits**: For simple cases, skip auto-capture
|
|
865
|
-
|
|
866
|
-
### Memory Usage
|
|
867
|
-
|
|
868
|
-
Records held in memory until flush:
|
|
869
|
-
|
|
870
|
-
```typescript
|
|
871
|
-
// 1000 inserts = ~1000 AuditRecord objects
|
|
872
|
-
// Each ~200-500 bytes
|
|
873
|
-
// Total: ~200KB-500KB per request
|
|
874
|
-
|
|
875
|
-
// For bulk operations, consider:
|
|
876
|
-
// 1. Periodic flushing during operation
|
|
877
|
-
// 2. Batch-level auditing instead of row-level
|
|
878
|
-
```
|
|
879
|
-
|
|
880
|
-
## Package Exports
|
|
881
|
-
|
|
882
|
-
```typescript
|
|
883
|
-
// Core (database-agnostic)
|
|
884
|
-
import {
|
|
885
|
-
Auditor,
|
|
886
|
-
DefaultAuditor,
|
|
887
|
-
AuditRecord,
|
|
888
|
-
AuditStorage,
|
|
889
|
-
AuditActor,
|
|
890
|
-
AuditMetadata,
|
|
891
|
-
} from '@geekmidas/audit';
|
|
892
|
-
|
|
893
|
-
// Kysely provider
|
|
894
|
-
import { AuditableKysely } from '@geekmidas/audit/kysely';
|
|
895
|
-
|
|
896
|
-
// Future providers
|
|
897
|
-
import { AuditableObjection } from '@geekmidas/audit/objection';
|
|
898
|
-
import { AuditableKnex } from '@geekmidas/audit/knex';
|
|
899
|
-
import { AuditableMongo } from '@geekmidas/audit/mongo';
|
|
900
|
-
```
|
|
901
|
-
|
|
902
|
-
## File Structure
|
|
903
|
-
|
|
904
|
-
```
|
|
905
|
-
packages/audit/
|
|
906
|
-
├── src/
|
|
907
|
-
│ ├── index.ts # Core exports
|
|
908
|
-
│ ├── types.ts # AuditRecord, AuditActor, etc.
|
|
909
|
-
│ ├── Auditor.ts # Auditor interface
|
|
910
|
-
│ ├── DefaultAuditor.ts # Default implementation
|
|
911
|
-
│ ├── storage.ts # AuditStorage interface
|
|
912
|
-
│ ├── kysely/ # Kysely provider
|
|
913
|
-
│ │ ├── index.ts
|
|
914
|
-
│ │ ├── AuditableKysely.ts
|
|
915
|
-
│ │ └── builders/
|
|
916
|
-
│ │ ├── AuditableInsertBuilder.ts
|
|
917
|
-
│ │ ├── AuditableUpdateBuilder.ts
|
|
918
|
-
│ │ └── AuditableDeleteBuilder.ts
|
|
919
|
-
│ └── __tests__/
|
|
920
|
-
├── package.json # With subpath exports
|
|
921
|
-
├── tsconfig.json
|
|
922
|
-
└── TECHNICAL.md # This file
|
|
923
|
-
```
|
|
924
|
-
|
|
925
|
-
## Summary
|
|
926
|
-
|
|
927
|
-
The `@geekmidas/audit` package provides:
|
|
928
|
-
|
|
929
|
-
1. **Transaction-safe auditing** - Audits commit/rollback with mutations
|
|
930
|
-
2. **Database-agnostic core** - Provider wrappers are separate subpath imports
|
|
931
|
-
3. **Three audit methods** - Manual (context), declarative (builder), automatic (wrapper)
|
|
932
|
-
4. **Type-safe audit types** - `AuditableAction<TType, TPayload>` like `PublishableMessage`
|
|
933
|
-
5. **Consistent API pattern** - `.auditor()` + `.audit()` mirrors `.publisherService()` + `.events()`
|
|
934
|
-
6. **Optional actor extraction** - Separate `.actor()` method for flexibility
|
|
935
|
-
7. **Full change tracking** - Old values, new values, actor, metadata
|
|
936
|
-
8. **Pluggable storage** - Same DB, external service, or custom
|
|
937
|
-
9. **Generic Auditor interface** - Extensible for custom implementations
|