@geekmidas/audit 9.0.2 → 10.0.0-alpha.0
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/src/index.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
// Core types
|
|
2
|
-
|
|
3
|
-
// Auditor interface
|
|
4
|
-
export type { Auditor } from './Auditor';
|
|
5
|
-
export type { DefaultAuditorConfig } from './DefaultAuditor';
|
|
6
|
-
|
|
7
|
-
// Default implementation
|
|
8
|
-
export { DefaultAuditor } from './DefaultAuditor';
|
|
9
|
-
// Storage interface
|
|
10
|
-
export type { AuditQueryOptions, AuditStorage } from './storage';
|
|
11
|
-
export type {
|
|
12
|
-
AuditActor,
|
|
13
|
-
AuditableAction,
|
|
14
|
-
AuditMetadata,
|
|
15
|
-
AuditOperation,
|
|
16
|
-
AuditOptions,
|
|
17
|
-
AuditRecord,
|
|
18
|
-
ExtractAuditorAction,
|
|
19
|
-
ExtractAuditPayload,
|
|
20
|
-
ExtractAuditType,
|
|
21
|
-
ExtractStorageAuditAction,
|
|
22
|
-
MappedAudit,
|
|
23
|
-
} from './types';
|
package/src/knex.ts
DELETED
|
@@ -1,430 +0,0 @@
|
|
|
1
|
-
import type { Knex } from 'knex';
|
|
2
|
-
import { nanoid } from 'nanoid';
|
|
3
|
-
import type {
|
|
4
|
-
AuditQueryOptions,
|
|
5
|
-
AuditStorage,
|
|
6
|
-
TransactionAwareAuditor,
|
|
7
|
-
} from './storage';
|
|
8
|
-
import type { AuditRecord } from './types';
|
|
9
|
-
|
|
10
|
-
export type { TransactionAwareAuditor };
|
|
11
|
-
|
|
12
|
-
export interface TransactionSettings {
|
|
13
|
-
isolationLevel?: Knex.IsolationLevels;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type DatabaseConnection = Knex | Knex.Transaction;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Type guard for an active Knex transaction.
|
|
20
|
-
*
|
|
21
|
-
* Knex marks transaction instances with `isTransaction`, and a completed
|
|
22
|
-
* transaction can no longer be used, so a settled transaction is treated as a
|
|
23
|
-
* plain connection.
|
|
24
|
-
*/
|
|
25
|
-
function isActiveTransaction(
|
|
26
|
-
connection: DatabaseConnection,
|
|
27
|
-
): connection is Knex.Transaction {
|
|
28
|
-
return (
|
|
29
|
-
connection.isTransaction === true &&
|
|
30
|
-
!(connection as Knex.Transaction).isCompleted()
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Execute a callback within a database transaction with automatic audit handling.
|
|
36
|
-
*
|
|
37
|
-
* This wrapper ensures that:
|
|
38
|
-
* 1. The transaction is automatically registered with the auditor
|
|
39
|
-
* 2. Manual audits (via `auditor.audit()`) are flushed BEFORE the transaction commits
|
|
40
|
-
* 3. If audit flush fails, the entire transaction rolls back
|
|
41
|
-
* 4. If the callback fails, audits are NOT written (atomic consistency)
|
|
42
|
-
*
|
|
43
|
-
* **Note:** Declarative audits (defined via `.audit([...])` on the endpoint builder)
|
|
44
|
-
* are processed AFTER the handler returns, so they run outside this transaction.
|
|
45
|
-
* If you need all audits to be atomic with your database operations, use manual
|
|
46
|
-
* audits via `auditor.audit()` inside this wrapper.
|
|
47
|
-
*
|
|
48
|
-
* @param db - Database connection (Knex instance or Transaction)
|
|
49
|
-
* @param auditor - Auditor instance that will receive the transaction
|
|
50
|
-
* @param cb - Callback to execute within the transaction
|
|
51
|
-
* @param settings - Optional transaction settings (isolation level)
|
|
52
|
-
* @returns The result of the callback
|
|
53
|
-
*
|
|
54
|
-
* @example
|
|
55
|
-
* ```typescript
|
|
56
|
-
* import { withAuditableTransaction } from '@geekmidas/audit/knex';
|
|
57
|
-
*
|
|
58
|
-
* const result = await withAuditableTransaction(
|
|
59
|
-
* services.database,
|
|
60
|
-
* auditor,
|
|
61
|
-
* async (trx) => {
|
|
62
|
-
* const [user] = await trx('users').insert(data).returning('*');
|
|
63
|
-
*
|
|
64
|
-
* // Manual audits are atomic with the transaction
|
|
65
|
-
* auditor.audit('user.created', { userId: user.id, email: user.email });
|
|
66
|
-
*
|
|
67
|
-
* return user;
|
|
68
|
-
* },
|
|
69
|
-
* );
|
|
70
|
-
* // Audits are automatically flushed inside the transaction before commit
|
|
71
|
-
* ```
|
|
72
|
-
*/
|
|
73
|
-
export async function withAuditableTransaction<T>(
|
|
74
|
-
db: DatabaseConnection,
|
|
75
|
-
auditor: TransactionAwareAuditor<Knex.Transaction>,
|
|
76
|
-
cb: (trx: Knex.Transaction) => Promise<T>,
|
|
77
|
-
settings?: TransactionSettings,
|
|
78
|
-
): Promise<T> {
|
|
79
|
-
const execute = async (trx: Knex.Transaction): Promise<T> => {
|
|
80
|
-
// Register transaction with auditor
|
|
81
|
-
auditor.setTransaction(trx);
|
|
82
|
-
|
|
83
|
-
// Execute the callback
|
|
84
|
-
const result = await cb(trx);
|
|
85
|
-
|
|
86
|
-
// Flush audits BEFORE transaction commits
|
|
87
|
-
// If this fails, the transaction will roll back
|
|
88
|
-
await auditor.flush(trx);
|
|
89
|
-
|
|
90
|
-
return result;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
// If already in a transaction, just run with it.
|
|
94
|
-
// Knex would otherwise open a savepoint, which commits independently.
|
|
95
|
-
if (isActiveTransaction(db)) {
|
|
96
|
-
return execute(db);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return db.transaction(execute, settings);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Shape of a row in the audit log table.
|
|
104
|
-
* Use this to describe your audit table when declaring Knex table types.
|
|
105
|
-
*
|
|
106
|
-
* Property names are camelCase to match the Kysely storage. Pair this with
|
|
107
|
-
* `knexSnakeCaseMappers()` if your database columns are snake_case.
|
|
108
|
-
*
|
|
109
|
-
* @example
|
|
110
|
-
* ```typescript
|
|
111
|
-
* import { knexSnakeCaseMappers } from 'objection';
|
|
112
|
-
*
|
|
113
|
-
* const db = knex({
|
|
114
|
-
* client: 'pg',
|
|
115
|
-
* connection: process.env.DATABASE_URL,
|
|
116
|
-
* ...knexSnakeCaseMappers(),
|
|
117
|
-
* });
|
|
118
|
-
*
|
|
119
|
-
* declare module 'knex/types/tables' {
|
|
120
|
-
* interface Tables {
|
|
121
|
-
* audit_logs: AuditLogTable;
|
|
122
|
-
* }
|
|
123
|
-
* }
|
|
124
|
-
* ```
|
|
125
|
-
*/
|
|
126
|
-
export interface AuditLogTable {
|
|
127
|
-
id: string;
|
|
128
|
-
type: string;
|
|
129
|
-
operation: string;
|
|
130
|
-
table: string | null;
|
|
131
|
-
entityId: string | null;
|
|
132
|
-
oldValues: unknown | null;
|
|
133
|
-
newValues: unknown | null;
|
|
134
|
-
payload: unknown | null;
|
|
135
|
-
timestamp: Date;
|
|
136
|
-
actorId: string | null;
|
|
137
|
-
actorType: string | null;
|
|
138
|
-
actorData: unknown | null;
|
|
139
|
-
metadata: unknown | null;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Insertable version of AuditLogTable where id is optional.
|
|
144
|
-
* Use this when your database auto-generates IDs or when using the autoId option.
|
|
145
|
-
*/
|
|
146
|
-
export type InsertableAuditLogTable = Omit<AuditLogTable, 'id'> & {
|
|
147
|
-
id?: string;
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Configuration for KnexAuditStorage.
|
|
152
|
-
*/
|
|
153
|
-
export interface KnexAuditStorageConfig {
|
|
154
|
-
/** Knex database instance */
|
|
155
|
-
db: Knex;
|
|
156
|
-
/** Table name for audit logs */
|
|
157
|
-
tableName: string;
|
|
158
|
-
/**
|
|
159
|
-
* Service name of the database service.
|
|
160
|
-
* When set, endpoint adaptors will automatically use the audit transaction as `db`
|
|
161
|
-
* in the handler context if the endpoint's database service has the same name.
|
|
162
|
-
*/
|
|
163
|
-
databaseServiceName?: string;
|
|
164
|
-
/**
|
|
165
|
-
* Let the database auto-generate IDs (e.g., via DEFAULT gen_random_uuid()).
|
|
166
|
-
* When true, the ID field is omitted from inserts if not provided.
|
|
167
|
-
* When false (default), IDs are generated using nanoid if not provided.
|
|
168
|
-
* @default false
|
|
169
|
-
*/
|
|
170
|
-
autoId?: boolean;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Knex-based audit storage implementation.
|
|
175
|
-
* Stores audit records in a database table using Knex.
|
|
176
|
-
*
|
|
177
|
-
* @example
|
|
178
|
-
* ```typescript
|
|
179
|
-
* const storage = new KnexAuditStorage({
|
|
180
|
-
* db: knexDb,
|
|
181
|
-
* tableName: 'audit_logs',
|
|
182
|
-
* });
|
|
183
|
-
*
|
|
184
|
-
* const auditor = new DefaultAuditor({
|
|
185
|
-
* actor: { id: 'user-123', type: 'user' },
|
|
186
|
-
* storage,
|
|
187
|
-
* });
|
|
188
|
-
* ```
|
|
189
|
-
*/
|
|
190
|
-
export class KnexAuditStorage implements AuditStorage {
|
|
191
|
-
private readonly db: Knex;
|
|
192
|
-
private readonly tableName: string;
|
|
193
|
-
private readonly autoId: boolean;
|
|
194
|
-
readonly databaseServiceName?: string;
|
|
195
|
-
|
|
196
|
-
constructor(config: KnexAuditStorageConfig) {
|
|
197
|
-
this.db = config.db;
|
|
198
|
-
this.tableName = config.tableName;
|
|
199
|
-
this.databaseServiceName = config.databaseServiceName;
|
|
200
|
-
this.autoId = config.autoId ?? false;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
async write(records: AuditRecord[], trx?: unknown): Promise<void> {
|
|
204
|
-
if (records.length === 0) {
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const db = (trx as Knex.Transaction) ?? this.db;
|
|
209
|
-
const rows = records.map((record) => this.toRow(record));
|
|
210
|
-
|
|
211
|
-
await db(this.tableName).insert(rows);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
async query(options: AuditQueryOptions): Promise<AuditRecord[]> {
|
|
215
|
-
let query = this.db(this.tableName).select('*');
|
|
216
|
-
|
|
217
|
-
query = this.applyFilters(query, options);
|
|
218
|
-
|
|
219
|
-
// Ordering
|
|
220
|
-
const orderBy = options.orderBy ?? 'timestamp';
|
|
221
|
-
const orderDirection = options.orderDirection ?? 'desc';
|
|
222
|
-
query = query.orderBy(
|
|
223
|
-
orderBy === 'timestamp' ? 'timestamp' : 'type',
|
|
224
|
-
orderDirection,
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
// Pagination
|
|
228
|
-
if (options.limit !== undefined) {
|
|
229
|
-
query = query.limit(options.limit);
|
|
230
|
-
}
|
|
231
|
-
if (options.offset !== undefined) {
|
|
232
|
-
query = query.offset(options.offset);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const rows = await query;
|
|
236
|
-
return rows.map((row: AuditLogTable) => this.fromRow(row));
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async count(
|
|
240
|
-
options: Omit<AuditQueryOptions, 'limit' | 'offset'>,
|
|
241
|
-
): Promise<number> {
|
|
242
|
-
let query = this.db(this.tableName).count({ count: 'id' });
|
|
243
|
-
|
|
244
|
-
query = this.applyFilters(query, options);
|
|
245
|
-
|
|
246
|
-
const result = await query.first();
|
|
247
|
-
return Number(result?.count ?? 0);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Get the Knex database instance for transactional operations.
|
|
252
|
-
* Used by endpoint adaptors to automatically wrap handlers in transactions.
|
|
253
|
-
*/
|
|
254
|
-
getDatabase(): Knex {
|
|
255
|
-
return this.db;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Execute a callback within a Knex transaction with automatic audit handling.
|
|
260
|
-
* The auditor is registered with the transaction and audits are flushed
|
|
261
|
-
* before the transaction commits.
|
|
262
|
-
*
|
|
263
|
-
* If the provided db connection is already a transaction, it will be reused
|
|
264
|
-
* instead of opening a savepoint.
|
|
265
|
-
*/
|
|
266
|
-
async withTransaction<T>(
|
|
267
|
-
auditor: TransactionAwareAuditor<Knex.Transaction>,
|
|
268
|
-
callback: () => Promise<T>,
|
|
269
|
-
db?: DatabaseConnection,
|
|
270
|
-
): Promise<T> {
|
|
271
|
-
const connection = db ?? this.db;
|
|
272
|
-
|
|
273
|
-
// If already in a transaction, reuse it
|
|
274
|
-
if (isActiveTransaction(connection)) {
|
|
275
|
-
auditor.setTransaction(connection);
|
|
276
|
-
const result = await callback();
|
|
277
|
-
await auditor.flush(connection);
|
|
278
|
-
return result;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Create new transaction
|
|
282
|
-
return connection.transaction(async (trx) => {
|
|
283
|
-
auditor.setTransaction(trx);
|
|
284
|
-
const result = await callback();
|
|
285
|
-
await auditor.flush(trx);
|
|
286
|
-
return result;
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
private applyFilters(query: any, options: AuditQueryOptions): any {
|
|
291
|
-
// Type filter
|
|
292
|
-
if (options.type !== undefined) {
|
|
293
|
-
if (Array.isArray(options.type)) {
|
|
294
|
-
query = query.whereIn('type', options.type);
|
|
295
|
-
} else {
|
|
296
|
-
query = query.where('type', options.type);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Entity ID filter
|
|
301
|
-
if (options.entityId !== undefined) {
|
|
302
|
-
const entityId =
|
|
303
|
-
typeof options.entityId === 'string'
|
|
304
|
-
? options.entityId
|
|
305
|
-
: JSON.stringify(options.entityId);
|
|
306
|
-
query = query.where('entityId', entityId);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Table filter
|
|
310
|
-
if (options.table !== undefined) {
|
|
311
|
-
query = query.where('table', options.table);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Actor ID filter
|
|
315
|
-
if (options.actorId !== undefined) {
|
|
316
|
-
query = query.where('actorId', options.actorId);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Date range filters
|
|
320
|
-
if (options.from !== undefined) {
|
|
321
|
-
query = query.where('timestamp', '>=', options.from);
|
|
322
|
-
}
|
|
323
|
-
if (options.to !== undefined) {
|
|
324
|
-
query = query.where('timestamp', '<=', options.to);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return query;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
private toRow(record: AuditRecord): AuditLogTable {
|
|
331
|
-
// If autoId is true, let database generate ID (ignore record.id)
|
|
332
|
-
// If autoId is false (default), use record.id or generate with nanoid
|
|
333
|
-
const id = this.autoId ? undefined : record.id || nanoid();
|
|
334
|
-
|
|
335
|
-
return {
|
|
336
|
-
...(id && { id }),
|
|
337
|
-
type: record.type,
|
|
338
|
-
operation: record.operation,
|
|
339
|
-
table: record.table ?? null,
|
|
340
|
-
entityId:
|
|
341
|
-
record.entityId === undefined
|
|
342
|
-
? null
|
|
343
|
-
: typeof record.entityId === 'string'
|
|
344
|
-
? record.entityId
|
|
345
|
-
: JSON.stringify(record.entityId),
|
|
346
|
-
oldValues: this.toJsonColumn(record.oldValues),
|
|
347
|
-
newValues: this.toJsonColumn(record.newValues),
|
|
348
|
-
payload: this.toJsonColumn(record.payload),
|
|
349
|
-
timestamp: record.timestamp,
|
|
350
|
-
actorId: record.actor?.id ?? null,
|
|
351
|
-
actorType: record.actor?.type ?? null,
|
|
352
|
-
actorData:
|
|
353
|
-
record.actor !== undefined
|
|
354
|
-
? this.toJsonColumn(this.getActorData(record.actor))
|
|
355
|
-
: null,
|
|
356
|
-
metadata: this.toJsonColumn(record.metadata),
|
|
357
|
-
} as AuditLogTable;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
private fromRow(row: AuditLogTable): AuditRecord {
|
|
361
|
-
const actor =
|
|
362
|
-
row.actorId !== null || row.actorType !== null
|
|
363
|
-
? {
|
|
364
|
-
id: row.actorId ?? undefined,
|
|
365
|
-
type: row.actorType ?? undefined,
|
|
366
|
-
...(row.actorData ? this.parseJson(row.actorData) : {}),
|
|
367
|
-
}
|
|
368
|
-
: undefined;
|
|
369
|
-
|
|
370
|
-
return {
|
|
371
|
-
id: row.id,
|
|
372
|
-
type: row.type,
|
|
373
|
-
operation: row.operation as AuditRecord['operation'],
|
|
374
|
-
table: row.table ?? undefined,
|
|
375
|
-
entityId: row.entityId ? this.parseEntityId(row.entityId) : undefined,
|
|
376
|
-
oldValues: row.oldValues ? this.parseJson(row.oldValues) : undefined,
|
|
377
|
-
newValues: row.newValues ? this.parseJson(row.newValues) : undefined,
|
|
378
|
-
payload: row.payload ? this.parseJson(row.payload) : undefined,
|
|
379
|
-
timestamp: row.timestamp,
|
|
380
|
-
actor,
|
|
381
|
-
metadata: row.metadata ? this.parseJson(row.metadata) : undefined,
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Serialize a JSON value for insertion.
|
|
387
|
-
*
|
|
388
|
-
* Unlike Kysely, Knex has no per-column type information to tell a json/jsonb
|
|
389
|
-
* column from an object it should bind as a composite value, so plain objects
|
|
390
|
-
* are stringified before they reach the driver.
|
|
391
|
-
*/
|
|
392
|
-
private toJsonColumn(value: unknown): string | null {
|
|
393
|
-
if (value === undefined || value === null) {
|
|
394
|
-
return null;
|
|
395
|
-
}
|
|
396
|
-
return JSON.stringify(value);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Parse a JSON value that may already be parsed (e.g., from jsonb columns).
|
|
401
|
-
*/
|
|
402
|
-
private parseJson(value: unknown): Record<string, unknown> {
|
|
403
|
-
if (typeof value === 'object' && value !== null) {
|
|
404
|
-
return value as Record<string, unknown>;
|
|
405
|
-
}
|
|
406
|
-
if (typeof value === 'string') {
|
|
407
|
-
return JSON.parse(value);
|
|
408
|
-
}
|
|
409
|
-
return {};
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
private getActorData(
|
|
413
|
-
actor: NonNullable<AuditRecord['actor']>,
|
|
414
|
-
): Record<string, unknown> {
|
|
415
|
-
const { id, type, ...rest } = actor;
|
|
416
|
-
return rest;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
private parseEntityId(entityId: string): string | Record<string, unknown> {
|
|
420
|
-
try {
|
|
421
|
-
const parsed = JSON.parse(entityId);
|
|
422
|
-
if (typeof parsed === 'object' && parsed !== null) {
|
|
423
|
-
return parsed;
|
|
424
|
-
}
|
|
425
|
-
return entityId;
|
|
426
|
-
} catch {
|
|
427
|
-
return entityId;
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
}
|