@kysera/rls 0.8.0 → 0.8.2

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.
@@ -0,0 +1,625 @@
1
+ /**
2
+ * Audit Trail Types
3
+ *
4
+ * Provides type definitions for auditing RLS policy decisions.
5
+ *
6
+ * @module @kysera/rls/audit/types
7
+ */
8
+
9
+ import type { Operation } from '../policy/types.js'
10
+
11
+ // ============================================================================
12
+ // Audit Event Types
13
+ // ============================================================================
14
+
15
+ /**
16
+ * RLS policy decision result
17
+ */
18
+ export type AuditDecision = 'allow' | 'deny' | 'filter'
19
+
20
+ /**
21
+ * RLS audit event
22
+ *
23
+ * Represents a single policy evaluation event for audit logging.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * const event: RLSAuditEvent = {
28
+ * timestamp: new Date(),
29
+ * userId: '123',
30
+ * operation: 'update',
31
+ * table: 'posts',
32
+ * policyName: 'ownership-allow',
33
+ * decision: 'allow',
34
+ * context: { rowId: '456', tenantId: 'acme' }
35
+ * };
36
+ * ```
37
+ */
38
+ export interface RLSAuditEvent {
39
+ /**
40
+ * Timestamp of the event
41
+ */
42
+ timestamp: Date
43
+
44
+ /**
45
+ * User ID who performed the action
46
+ */
47
+ userId: string | number
48
+
49
+ /**
50
+ * Tenant ID (if multi-tenant)
51
+ */
52
+ tenantId?: string | number
53
+
54
+ /**
55
+ * Database operation
56
+ */
57
+ operation: Operation
58
+
59
+ /**
60
+ * Table name
61
+ */
62
+ table: string
63
+
64
+ /**
65
+ * Name of the policy that made the decision
66
+ */
67
+ policyName?: string
68
+
69
+ /**
70
+ * Decision result
71
+ */
72
+ decision: AuditDecision
73
+
74
+ /**
75
+ * Reason for the decision (especially for denials)
76
+ */
77
+ reason?: string
78
+
79
+ /**
80
+ * Additional context about the event
81
+ */
82
+ context?: Record<string, unknown>
83
+
84
+ /**
85
+ * Row ID(s) affected
86
+ */
87
+ rowIds?: (string | number)[]
88
+
89
+ /**
90
+ * Hash of the query (for grouping similar queries)
91
+ */
92
+ queryHash?: string
93
+
94
+ /**
95
+ * Request ID for tracing
96
+ */
97
+ requestId?: string
98
+
99
+ /**
100
+ * IP address of the requester
101
+ */
102
+ ipAddress?: string
103
+
104
+ /**
105
+ * User agent string
106
+ */
107
+ userAgent?: string
108
+
109
+ /**
110
+ * Duration of policy evaluation in milliseconds
111
+ */
112
+ durationMs?: number
113
+
114
+ /**
115
+ * Whether this event was filtered from logging
116
+ * (set by filtering rules but still available for debugging)
117
+ */
118
+ filtered?: boolean
119
+ }
120
+
121
+ // ============================================================================
122
+ // Audit Adapter Interface
123
+ // ============================================================================
124
+
125
+ /**
126
+ * Adapter for persisting audit events
127
+ *
128
+ * Implement this interface to store audit events in your preferred backend.
129
+ *
130
+ * @example
131
+ * ```typescript
132
+ * class DatabaseAuditAdapter implements RLSAuditAdapter {
133
+ * constructor(private db: Kysely<AuditDB>) {}
134
+ *
135
+ * async log(event: RLSAuditEvent): Promise<void> {
136
+ * await this.db.insertInto('rls_audit_log')
137
+ * .values({
138
+ * user_id: event.userId,
139
+ * operation: event.operation,
140
+ * table_name: event.table,
141
+ * decision: event.decision,
142
+ * context: JSON.stringify(event.context),
143
+ * created_at: event.timestamp
144
+ * })
145
+ * .execute();
146
+ * }
147
+ *
148
+ * async logBatch(events: RLSAuditEvent[]): Promise<void> {
149
+ * await this.db.insertInto('rls_audit_log')
150
+ * .values(events.map(e => ({
151
+ * user_id: e.userId,
152
+ * operation: e.operation,
153
+ * table_name: e.table,
154
+ * decision: e.decision,
155
+ * context: JSON.stringify(e.context),
156
+ * created_at: e.timestamp
157
+ * })))
158
+ * .execute();
159
+ * }
160
+ * }
161
+ * ```
162
+ */
163
+ export interface RLSAuditAdapter {
164
+ /**
165
+ * Log a single audit event
166
+ *
167
+ * @param event - Event to log
168
+ */
169
+ log(event: RLSAuditEvent): Promise<void>
170
+
171
+ /**
172
+ * Log multiple audit events (for batch processing)
173
+ *
174
+ * @param events - Events to log
175
+ */
176
+ logBatch?(events: RLSAuditEvent[]): Promise<void>
177
+
178
+ /**
179
+ * Flush any buffered events
180
+ */
181
+ flush?(): Promise<void>
182
+
183
+ /**
184
+ * Close the adapter and release resources
185
+ */
186
+ close?(): Promise<void>
187
+ }
188
+
189
+ // ============================================================================
190
+ // Audit Configuration
191
+ // ============================================================================
192
+
193
+ /**
194
+ * Configuration for table-specific audit settings
195
+ */
196
+ export interface TableAuditConfig {
197
+ /**
198
+ * Whether audit is enabled for this table
199
+ * @default true (if audit is globally enabled)
200
+ */
201
+ enabled?: boolean
202
+
203
+ /**
204
+ * Log allowed decisions
205
+ * @default false
206
+ */
207
+ logAllowed?: boolean
208
+
209
+ /**
210
+ * Log denied decisions
211
+ * @default true
212
+ */
213
+ logDenied?: boolean
214
+
215
+ /**
216
+ * Log filter applications
217
+ * @default false
218
+ */
219
+ logFilters?: boolean
220
+
221
+ /**
222
+ * Context fields to include in audit logs
223
+ * If empty, includes all available context
224
+ */
225
+ includeContext?: string[]
226
+
227
+ /**
228
+ * Context fields to exclude from audit logs
229
+ */
230
+ excludeContext?: string[]
231
+
232
+ /**
233
+ * Whether to include row data in audit logs
234
+ * @default false (for privacy)
235
+ */
236
+ includeRowData?: boolean
237
+
238
+ /**
239
+ * Whether to include mutation data in audit logs
240
+ * @default false (for privacy)
241
+ */
242
+ includeMutationData?: boolean
243
+
244
+ /**
245
+ * Custom filter function to determine if an event should be logged
246
+ */
247
+ filter?: (event: RLSAuditEvent) => boolean
248
+ }
249
+
250
+ /**
251
+ * Global audit configuration
252
+ */
253
+ export interface AuditConfig {
254
+ /**
255
+ * Audit adapter for persisting events
256
+ */
257
+ adapter: RLSAuditAdapter
258
+
259
+ /**
260
+ * Whether audit is enabled globally
261
+ * @default true
262
+ */
263
+ enabled?: boolean
264
+
265
+ /**
266
+ * Default settings for all tables
267
+ */
268
+ defaults?: Omit<TableAuditConfig, 'enabled'>
269
+
270
+ /**
271
+ * Table-specific audit configurations
272
+ */
273
+ tables?: Record<string, TableAuditConfig>
274
+
275
+ /**
276
+ * Buffer size for batch logging
277
+ * Events are batched until this size is reached
278
+ * @default 100
279
+ */
280
+ bufferSize?: number
281
+
282
+ /**
283
+ * Maximum time to buffer events before flushing (ms)
284
+ * @default 5000 (5 seconds)
285
+ */
286
+ flushInterval?: number
287
+
288
+ /**
289
+ * Whether to log asynchronously (fire-and-forget)
290
+ * @default true (for performance)
291
+ */
292
+ async?: boolean
293
+
294
+ /**
295
+ * Error handler for audit failures
296
+ */
297
+ onError?: (error: Error, events: RLSAuditEvent[]) => void
298
+
299
+ /**
300
+ * Sample rate for audit logging (0.0 to 1.0)
301
+ * Use for high-traffic systems to reduce log volume
302
+ * @default 1.0 (log all)
303
+ */
304
+ sampleRate?: number
305
+ }
306
+
307
+ // ============================================================================
308
+ // Audit Query Types
309
+ // ============================================================================
310
+
311
+ /**
312
+ * Query parameters for retrieving audit events
313
+ */
314
+ export interface AuditQueryParams {
315
+ /**
316
+ * Filter by user ID
317
+ */
318
+ userId?: string | number
319
+
320
+ /**
321
+ * Filter by tenant ID
322
+ */
323
+ tenantId?: string | number
324
+
325
+ /**
326
+ * Filter by table name
327
+ */
328
+ table?: string
329
+
330
+ /**
331
+ * Filter by operation
332
+ */
333
+ operation?: Operation
334
+
335
+ /**
336
+ * Filter by decision
337
+ */
338
+ decision?: AuditDecision
339
+
340
+ /**
341
+ * Start timestamp (inclusive)
342
+ */
343
+ startTime?: Date
344
+
345
+ /**
346
+ * End timestamp (exclusive)
347
+ */
348
+ endTime?: Date
349
+
350
+ /**
351
+ * Filter by request ID
352
+ */
353
+ requestId?: string
354
+
355
+ /**
356
+ * Maximum results to return
357
+ */
358
+ limit?: number
359
+
360
+ /**
361
+ * Offset for pagination
362
+ */
363
+ offset?: number
364
+ }
365
+
366
+ /**
367
+ * Aggregated audit statistics
368
+ */
369
+ export interface AuditStats {
370
+ /**
371
+ * Total number of events
372
+ */
373
+ totalEvents: number
374
+
375
+ /**
376
+ * Events by decision type
377
+ */
378
+ byDecision: Record<AuditDecision, number>
379
+
380
+ /**
381
+ * Events by operation
382
+ */
383
+ byOperation: Record<Operation, number>
384
+
385
+ /**
386
+ * Events by table
387
+ */
388
+ byTable: Record<string, number>
389
+
390
+ /**
391
+ * Top denied users
392
+ */
393
+ topDeniedUsers?: { userId: string | number; count: number }[]
394
+
395
+ /**
396
+ * Time range of stats
397
+ */
398
+ timeRange: {
399
+ start: Date
400
+ end: Date
401
+ }
402
+ }
403
+
404
+ // ============================================================================
405
+ // Console Audit Adapter
406
+ // ============================================================================
407
+
408
+ /**
409
+ * Simple console-based audit adapter for development/testing
410
+ *
411
+ * @example
412
+ * ```typescript
413
+ * const adapter = new ConsoleAuditAdapter({
414
+ * format: 'json',
415
+ * colors: true
416
+ * });
417
+ * ```
418
+ */
419
+ export interface ConsoleAuditAdapterOptions {
420
+ /**
421
+ * Output format
422
+ * @default 'text'
423
+ */
424
+ format?: 'text' | 'json'
425
+
426
+ /**
427
+ * Use colors in output (for text format)
428
+ * @default true
429
+ */
430
+ colors?: boolean
431
+
432
+ /**
433
+ * Include timestamp in output
434
+ * @default true
435
+ */
436
+ includeTimestamp?: boolean
437
+ }
438
+
439
+ /**
440
+ * Console audit adapter implementation
441
+ */
442
+ export class ConsoleAuditAdapter implements RLSAuditAdapter {
443
+ private options: Required<ConsoleAuditAdapterOptions>
444
+
445
+ constructor(options: ConsoleAuditAdapterOptions = {}) {
446
+ this.options = {
447
+ format: options.format ?? 'text',
448
+ colors: options.colors ?? true,
449
+ includeTimestamp: options.includeTimestamp ?? true
450
+ }
451
+ }
452
+
453
+ log(event: RLSAuditEvent): Promise<void> {
454
+ if (this.options.format === 'json') {
455
+ // eslint-disable-next-line no-console
456
+ console.log(JSON.stringify(event))
457
+ } else {
458
+ const prefix = this.getPrefix(event.decision)
459
+ const timestamp = this.options.includeTimestamp ? `[${event.timestamp.toISOString()}] ` : ''
460
+ // eslint-disable-next-line no-console
461
+ console.log(
462
+ `${timestamp}${prefix} RLS ${event.decision.toUpperCase()}: ${event.operation} on ${event.table}` +
463
+ (event.policyName ? ` (policy: ${event.policyName})` : '') +
464
+ (event.reason ? ` - ${event.reason}` : '') +
465
+ (event.userId ? ` [user: ${event.userId}]` : '')
466
+ )
467
+ }
468
+ return Promise.resolve()
469
+ }
470
+
471
+ async logBatch(events: RLSAuditEvent[]): Promise<void> {
472
+ for (const event of events) {
473
+ await this.log(event)
474
+ }
475
+ }
476
+
477
+ private getPrefix(decision: AuditDecision): string {
478
+ if (!this.options.colors) {
479
+ return decision === 'allow' ? '✓' : decision === 'deny' ? '✗' : '~'
480
+ }
481
+
482
+ switch (decision) {
483
+ case 'allow':
484
+ return '\x1b[32m✓\x1b[0m' // Green
485
+ case 'deny':
486
+ return '\x1b[31m✗\x1b[0m' // Red
487
+ case 'filter':
488
+ return '\x1b[33m~\x1b[0m' // Yellow
489
+ default:
490
+ return '?'
491
+ }
492
+ }
493
+ }
494
+
495
+ // ============================================================================
496
+ // In-Memory Audit Adapter
497
+ // ============================================================================
498
+
499
+ /**
500
+ * In-memory audit adapter for testing
501
+ *
502
+ * Stores events in memory for later retrieval and assertion.
503
+ */
504
+ export class InMemoryAuditAdapter implements RLSAuditAdapter {
505
+ private events: RLSAuditEvent[] = []
506
+ private maxSize: number
507
+
508
+ constructor(maxSize = 10000) {
509
+ this.maxSize = maxSize
510
+ }
511
+
512
+ log(event: RLSAuditEvent): Promise<void> {
513
+ this.events.push(event)
514
+ // Trim if exceeds max size
515
+ if (this.events.length > this.maxSize) {
516
+ this.events = this.events.slice(-this.maxSize)
517
+ }
518
+ return Promise.resolve()
519
+ }
520
+
521
+ logBatch(events: RLSAuditEvent[]): Promise<void> {
522
+ this.events.push(...events)
523
+ if (this.events.length > this.maxSize) {
524
+ this.events = this.events.slice(-this.maxSize)
525
+ }
526
+ return Promise.resolve()
527
+ }
528
+
529
+ /**
530
+ * Get all logged events
531
+ */
532
+ getEvents(): RLSAuditEvent[] {
533
+ return [...this.events]
534
+ }
535
+
536
+ /**
537
+ * Query events
538
+ */
539
+ query(params: AuditQueryParams): RLSAuditEvent[] {
540
+ let results = [...this.events]
541
+
542
+ if (params.userId !== undefined) {
543
+ results = results.filter(e => e.userId === params.userId)
544
+ }
545
+ if (params.tenantId !== undefined) {
546
+ results = results.filter(e => e.tenantId === params.tenantId)
547
+ }
548
+ if (params.table) {
549
+ results = results.filter(e => e.table === params.table)
550
+ }
551
+ if (params.operation) {
552
+ results = results.filter(e => e.operation === params.operation)
553
+ }
554
+ if (params.decision) {
555
+ results = results.filter(e => e.decision === params.decision)
556
+ }
557
+ if (params.startTime) {
558
+ results = results.filter(e => e.timestamp >= params.startTime!)
559
+ }
560
+ if (params.endTime) {
561
+ results = results.filter(e => e.timestamp < params.endTime!)
562
+ }
563
+ if (params.requestId) {
564
+ results = results.filter(e => e.requestId === params.requestId)
565
+ }
566
+
567
+ if (params.offset) {
568
+ results = results.slice(params.offset)
569
+ }
570
+ if (params.limit) {
571
+ results = results.slice(0, params.limit)
572
+ }
573
+
574
+ return results
575
+ }
576
+
577
+ /**
578
+ * Get statistics
579
+ */
580
+ getStats(params?: Pick<AuditQueryParams, 'startTime' | 'endTime'>): AuditStats {
581
+ let events = this.events
582
+
583
+ if (params?.startTime) {
584
+ events = events.filter(e => e.timestamp >= params.startTime!)
585
+ }
586
+ if (params?.endTime) {
587
+ events = events.filter(e => e.timestamp < params.endTime!)
588
+ }
589
+
590
+ const byDecision: Record<AuditDecision, number> = { allow: 0, deny: 0, filter: 0 }
591
+ const byOperation: Record<Operation, number> = { read: 0, create: 0, update: 0, delete: 0, all: 0 }
592
+ const byTable: Record<string, number> = {}
593
+
594
+ for (const event of events) {
595
+ byDecision[event.decision]++
596
+ byOperation[event.operation]++
597
+ byTable[event.table] = (byTable[event.table] ?? 0) + 1
598
+ }
599
+
600
+ return {
601
+ totalEvents: events.length,
602
+ byDecision,
603
+ byOperation,
604
+ byTable,
605
+ timeRange: {
606
+ start: events[0]?.timestamp ?? new Date(),
607
+ end: events[events.length - 1]?.timestamp ?? new Date()
608
+ }
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Clear all events
614
+ */
615
+ clear(): void {
616
+ this.events = []
617
+ }
618
+
619
+ /**
620
+ * Get event count
621
+ */
622
+ get size(): number {
623
+ return this.events.length
624
+ }
625
+ }