@kysera/rls 0.8.1 → 0.8.3

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.
@@ -1,5 +1,5 @@
1
1
  import { Kysely } from 'kysely';
2
- import { R as RLSSchema } from '../types-Dowjd6zG.js';
2
+ import { R as RLSSchema } from '../types-CyqksFKU.js';
3
3
 
4
4
  /**
5
5
  * Options for PostgreSQL RLS generation
@@ -638,6 +638,77 @@ interface PolicyHints {
638
638
  * Stable policies can be cached during a query execution
639
639
  */
640
640
  stable?: boolean;
641
+ /**
642
+ * Whether the policy condition can be async
643
+ * @internal Used by validation and allow/deny policies
644
+ */
645
+ async?: boolean;
646
+ /**
647
+ * Whether the policy result is cacheable
648
+ */
649
+ cacheable?: boolean;
650
+ /**
651
+ * Cache TTL in seconds if cacheable
652
+ */
653
+ cacheTTL?: number;
654
+ }
655
+ /**
656
+ * Context for evaluating policy activation conditions
657
+ *
658
+ * Contains metadata about the current environment that determines
659
+ * whether a policy should be active.
660
+ */
661
+ interface PolicyActivationContext {
662
+ /**
663
+ * Current environment (development, staging, production)
664
+ */
665
+ environment?: string;
666
+ /**
667
+ * Feature flags that are enabled
668
+ * Can be a Set, array, or object with boolean/truthy values
669
+ */
670
+ features?: Set<string> | string[] | Record<string, unknown>;
671
+ /**
672
+ * Current timestamp (for time-based policies)
673
+ */
674
+ timestamp?: Date;
675
+ /**
676
+ * Custom metadata for activation decisions
677
+ */
678
+ meta?: Record<string, unknown>;
679
+ }
680
+ /**
681
+ * Condition function for policy activation
682
+ *
683
+ * Returns true if the policy should be active, false otherwise.
684
+ *
685
+ * @example
686
+ * ```typescript
687
+ * // Only active in production
688
+ * const productionOnly: PolicyActivationCondition = ctx =>
689
+ * ctx.environment === 'production';
690
+ *
691
+ * // Only active when feature flag is enabled
692
+ * const featureGated: PolicyActivationCondition = ctx =>
693
+ * ctx.features?.includes('new_security_policy') ?? false;
694
+ *
695
+ * // Time-based activation (active during business hours)
696
+ * const businessHours: PolicyActivationCondition = ctx => {
697
+ * const hour = (ctx.timestamp ?? new Date()).getHours();
698
+ * return hour >= 9 && hour < 17;
699
+ * };
700
+ * ```
701
+ */
702
+ type PolicyActivationCondition = (ctx: PolicyActivationContext) => boolean;
703
+ /**
704
+ * Extended policy definition with activation condition
705
+ */
706
+ interface ConditionalPolicyDefinition extends PolicyDefinition {
707
+ /**
708
+ * Condition that determines if this policy is active
709
+ * If undefined, the policy is always active
710
+ */
711
+ activationCondition?: PolicyActivationCondition;
641
712
  }
642
713
 
643
- export type { CompiledPolicy as C, FilterCondition as F, Operation as O, PolicyCondition as P, RLSSchema as R, TableRLSConfig as T, PolicyHints as a, PolicyDefinition as b, CompiledFilterPolicy as c, RLSContext as d, RLSAuthContext as e, RLSRequestContext as f, PolicyEvaluationContext as g, PolicyType as h };
714
+ export type { ConditionalPolicyDefinition as C, FilterCondition as F, Operation as O, PolicyCondition as P, RLSSchema as R, TableRLSConfig as T, PolicyHints as a, PolicyActivationCondition as b, PolicyDefinition as c, CompiledPolicy as d, CompiledFilterPolicy as e, RLSContext as f, RLSAuthContext as g, RLSRequestContext as h, PolicyEvaluationContext as i, PolicyType as j, PolicyActivationContext as k };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kysera/rls",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Row-Level Security plugin for Kysely - declarative policies, query transformation, native RLS support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,8 +21,9 @@
21
21
  ],
22
22
  "peerDependencies": {
23
23
  "kysely": ">=0.28.8",
24
- "@kysera/executor": "0.8.1",
25
- "@kysera/repository": "0.8.1"
24
+ "zod": ">=3.0.0",
25
+ "@kysera/executor": "0.8.3",
26
+ "@kysera/repository": "0.8.3"
26
27
  },
27
28
  "peerDependenciesMeta": {
28
29
  "@kysera/executor": {
@@ -30,10 +31,13 @@
30
31
  },
31
32
  "@kysera/repository": {
32
33
  "optional": true
34
+ },
35
+ "zod": {
36
+ "optional": false
33
37
  }
34
38
  },
35
39
  "dependencies": {
36
- "@kysera/core": "0.8.1"
40
+ "@kysera/core": "0.8.3"
37
41
  },
38
42
  "devDependencies": {
39
43
  "@types/better-sqlite3": "^7.6.13",
@@ -47,8 +51,9 @@
47
51
  "tsup": "^8.5.1",
48
52
  "typescript": "^5.9.3",
49
53
  "vitest": "^4.0.16",
50
- "@kysera/executor": "0.8.1",
51
- "@kysera/repository": "0.8.1"
54
+ "zod": "^3.24.2",
55
+ "@kysera/executor": "0.8.3",
56
+ "@kysera/repository": "0.8.3"
52
57
  },
53
58
  "keywords": [
54
59
  "kysely",
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Audit Trail Module
3
+ *
4
+ * Provides audit logging for RLS policy decisions.
5
+ *
6
+ * @module @kysera/rls/audit
7
+ */
8
+
9
+ // Types
10
+ export type {
11
+ AuditDecision,
12
+ RLSAuditEvent,
13
+ RLSAuditAdapter,
14
+ TableAuditConfig,
15
+ AuditConfig,
16
+ AuditQueryParams,
17
+ AuditStats,
18
+ ConsoleAuditAdapterOptions
19
+ } from './types.js'
20
+
21
+ // Adapters
22
+ export { ConsoleAuditAdapter, InMemoryAuditAdapter } from './types.js'
23
+
24
+ // Logger
25
+ export { AuditLogger, createAuditLogger } from './logger.js'
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Audit Logger
3
+ *
4
+ * Manages audit event logging with buffering and filtering.
5
+ *
6
+ * @module @kysera/rls/audit/logger
7
+ */
8
+
9
+ import type {
10
+ RLSAuditEvent,
11
+ RLSAuditAdapter,
12
+ AuditConfig,
13
+ TableAuditConfig,
14
+ AuditDecision
15
+ } from './types.js'
16
+ import type { Operation, RLSContext } from '../policy/types.js'
17
+ import { rlsContext } from '../context/manager.js'
18
+
19
+ // ============================================================================
20
+ // Audit Logger
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Audit Logger
25
+ *
26
+ * Manages RLS audit event logging with buffering, filtering, and sampling.
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * const logger = new AuditLogger({
31
+ * adapter: new DatabaseAuditAdapter(db),
32
+ * bufferSize: 50,
33
+ * flushInterval: 5000,
34
+ * defaults: {
35
+ * logAllowed: false,
36
+ * logDenied: true,
37
+ * logFilters: false
38
+ * },
39
+ * tables: {
40
+ * sensitive_data: {
41
+ * logAllowed: true,
42
+ * includeContext: ['requestId', 'ipAddress']
43
+ * }
44
+ * }
45
+ * });
46
+ *
47
+ * // Log an event
48
+ * await logger.logDecision('update', 'posts', 'allow', 'ownership-allow');
49
+ *
50
+ * // Ensure all events are flushed
51
+ * await logger.flush();
52
+ * ```
53
+ */
54
+ export class AuditLogger {
55
+ private adapter: RLSAuditAdapter
56
+ private config: Required<Omit<AuditConfig, 'adapter' | 'tables' | 'onError'>> & {
57
+ tables: Record<string, TableAuditConfig>
58
+ onError?: (error: Error, events: RLSAuditEvent[]) => void
59
+ }
60
+ private buffer: RLSAuditEvent[] = []
61
+ private flushTimer: NodeJS.Timeout | null = null
62
+ private isShuttingDown = false
63
+
64
+ constructor(config: AuditConfig) {
65
+ this.adapter = config.adapter
66
+ const baseConfig = {
67
+ enabled: config.enabled ?? true,
68
+ defaults: config.defaults ?? {
69
+ logAllowed: false,
70
+ logDenied: true,
71
+ logFilters: false
72
+ },
73
+ tables: config.tables ?? {},
74
+ bufferSize: config.bufferSize ?? 100,
75
+ flushInterval: config.flushInterval ?? 5000,
76
+ async: config.async ?? true,
77
+ sampleRate: config.sampleRate ?? 1.0
78
+ }
79
+
80
+ this.config = config.onError !== undefined
81
+ ? { ...baseConfig, onError: config.onError }
82
+ : baseConfig
83
+
84
+ // Start flush timer
85
+ if (this.config.flushInterval > 0) {
86
+ this.startFlushTimer()
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Log a policy decision
92
+ *
93
+ * @param operation - Database operation
94
+ * @param table - Table name
95
+ * @param decision - Decision result
96
+ * @param policyName - Name of the policy
97
+ * @param options - Additional options
98
+ */
99
+ async logDecision(
100
+ operation: Operation,
101
+ table: string,
102
+ decision: AuditDecision,
103
+ policyName?: string,
104
+ options?: {
105
+ reason?: string
106
+ rowIds?: (string | number)[]
107
+ queryHash?: string
108
+ durationMs?: number
109
+ context?: Record<string, unknown>
110
+ }
111
+ ): Promise<void> {
112
+ if (!this.config.enabled || this.isShuttingDown) {
113
+ return
114
+ }
115
+
116
+ // Check sampling
117
+ if (this.config.sampleRate < 1.0 && Math.random() > this.config.sampleRate) {
118
+ return
119
+ }
120
+
121
+ // Get table config
122
+ const tableConfig = this.getTableConfig(table)
123
+
124
+ // Check if this decision type should be logged
125
+ if (!this.shouldLog(decision, tableConfig)) {
126
+ return
127
+ }
128
+
129
+ // Get current RLS context
130
+ const ctx = rlsContext.getContextOrNull()
131
+
132
+ // Build event
133
+ const event = this.buildEvent(operation, table, decision, policyName, ctx, tableConfig, options)
134
+
135
+ // Apply custom filter if present
136
+ if (tableConfig.filter && !tableConfig.filter(event)) {
137
+ event.filtered = true
138
+ return
139
+ }
140
+
141
+ // Log the event
142
+ await this.logEvent(event)
143
+ }
144
+
145
+ /**
146
+ * Log an allow decision
147
+ */
148
+ async logAllow(
149
+ operation: Operation,
150
+ table: string,
151
+ policyName?: string,
152
+ options?: {
153
+ reason?: string
154
+ rowIds?: (string | number)[]
155
+ context?: Record<string, unknown>
156
+ }
157
+ ): Promise<void> {
158
+ await this.logDecision(operation, table, 'allow', policyName, options)
159
+ }
160
+
161
+ /**
162
+ * Log a deny decision
163
+ */
164
+ async logDeny(
165
+ operation: Operation,
166
+ table: string,
167
+ policyName?: string,
168
+ options?: {
169
+ reason?: string
170
+ rowIds?: (string | number)[]
171
+ context?: Record<string, unknown>
172
+ }
173
+ ): Promise<void> {
174
+ await this.logDecision(operation, table, 'deny', policyName, options)
175
+ }
176
+
177
+ /**
178
+ * Log a filter application
179
+ */
180
+ async logFilter(
181
+ table: string,
182
+ policyName?: string,
183
+ options?: {
184
+ context?: Record<string, unknown>
185
+ }
186
+ ): Promise<void> {
187
+ await this.logDecision('read', table, 'filter', policyName, options)
188
+ }
189
+
190
+ /**
191
+ * Flush buffered events
192
+ */
193
+ async flush(): Promise<void> {
194
+ if (this.buffer.length === 0) {
195
+ return
196
+ }
197
+
198
+ const eventsToFlush = [...this.buffer]
199
+ this.buffer = []
200
+
201
+ try {
202
+ if (this.adapter.logBatch) {
203
+ await this.adapter.logBatch(eventsToFlush)
204
+ } else {
205
+ for (const event of eventsToFlush) {
206
+ await this.adapter.log(event)
207
+ }
208
+ }
209
+ } catch (error) {
210
+ this.config.onError?.(error instanceof Error ? error : new Error(String(error)), eventsToFlush)
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Close the logger
216
+ */
217
+ async close(): Promise<void> {
218
+ this.isShuttingDown = true
219
+
220
+ if (this.flushTimer) {
221
+ clearInterval(this.flushTimer)
222
+ this.flushTimer = null
223
+ }
224
+
225
+ await this.flush()
226
+ await this.adapter.flush?.()
227
+ await this.adapter.close?.()
228
+ }
229
+
230
+ /**
231
+ * Get buffer size
232
+ */
233
+ get bufferSize(): number {
234
+ return this.buffer.length
235
+ }
236
+
237
+ /**
238
+ * Check if logger is enabled
239
+ */
240
+ get enabled(): boolean {
241
+ return this.config.enabled
242
+ }
243
+
244
+ /**
245
+ * Enable or disable logging
246
+ */
247
+ setEnabled(enabled: boolean): void {
248
+ this.config.enabled = enabled
249
+ }
250
+
251
+ // ============================================================================
252
+ // Private Methods
253
+ // ============================================================================
254
+
255
+ /**
256
+ * Get table-specific config with defaults
257
+ */
258
+ private getTableConfig(table: string): TableAuditConfig {
259
+ const tableOverride = this.config.tables[table]
260
+ return {
261
+ ...this.config.defaults,
262
+ ...tableOverride,
263
+ enabled: tableOverride?.enabled ?? true
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Check if decision should be logged
269
+ */
270
+ private shouldLog(decision: AuditDecision, tableConfig: TableAuditConfig): boolean {
271
+ if (!tableConfig.enabled) {
272
+ return false
273
+ }
274
+
275
+ switch (decision) {
276
+ case 'allow':
277
+ return tableConfig.logAllowed ?? false
278
+ case 'deny':
279
+ return tableConfig.logDenied ?? true
280
+ case 'filter':
281
+ return tableConfig.logFilters ?? false
282
+ default:
283
+ return false
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Build audit event
289
+ */
290
+ private buildEvent(
291
+ operation: Operation,
292
+ table: string,
293
+ decision: AuditDecision,
294
+ policyName: string | undefined,
295
+ ctx: RLSContext | null,
296
+ tableConfig: TableAuditConfig,
297
+ options?: {
298
+ reason?: string
299
+ rowIds?: (string | number)[]
300
+ queryHash?: string
301
+ durationMs?: number
302
+ context?: Record<string, unknown>
303
+ }
304
+ ): RLSAuditEvent {
305
+ const event: RLSAuditEvent = {
306
+ timestamp: new Date(),
307
+ userId: ctx?.auth.userId ?? 'anonymous',
308
+ operation,
309
+ table,
310
+ decision
311
+ }
312
+
313
+ // Add tenant ID if present
314
+ if (ctx?.auth.tenantId !== undefined) {
315
+ event.tenantId = ctx.auth.tenantId
316
+ }
317
+
318
+ // Add policy name
319
+ if (policyName) {
320
+ event.policyName = policyName
321
+ }
322
+
323
+ // Add options
324
+ if (options?.reason) {
325
+ event.reason = options.reason
326
+ }
327
+ if (options?.rowIds && options.rowIds.length > 0) {
328
+ event.rowIds = options.rowIds
329
+ }
330
+ if (options?.queryHash) {
331
+ event.queryHash = options.queryHash
332
+ }
333
+ if (options?.durationMs !== undefined) {
334
+ event.durationMs = options.durationMs
335
+ }
336
+
337
+ // Add request context
338
+ if (ctx?.request) {
339
+ if (ctx.request.requestId) {
340
+ event.requestId = ctx.request.requestId
341
+ }
342
+ if (ctx.request.ipAddress) {
343
+ event.ipAddress = ctx.request.ipAddress
344
+ }
345
+ if (ctx.request.userAgent) {
346
+ event.userAgent = ctx.request.userAgent
347
+ }
348
+ }
349
+
350
+ // Build context
351
+ const context = this.buildContext(ctx, tableConfig, options?.context)
352
+ if (context !== undefined) {
353
+ event.context = context
354
+ }
355
+
356
+ return event
357
+ }
358
+
359
+ /**
360
+ * Build context object with filtering
361
+ */
362
+ private buildContext(
363
+ ctx: RLSContext | null,
364
+ tableConfig: TableAuditConfig,
365
+ additionalContext?: Record<string, unknown>
366
+ ): Record<string, unknown> | undefined {
367
+ const context: Record<string, unknown> = {}
368
+
369
+ // Add roles
370
+ if (ctx?.auth.roles && ctx.auth.roles.length > 0) {
371
+ context['roles'] = ctx.auth.roles
372
+ }
373
+
374
+ // Add organization IDs if present
375
+ if (ctx?.auth.organizationIds && ctx.auth.organizationIds.length > 0) {
376
+ context['organizationIds'] = ctx.auth.organizationIds
377
+ }
378
+
379
+ // Add meta if present
380
+ if (ctx?.meta && typeof ctx.meta === 'object') {
381
+ Object.assign(context, ctx.meta)
382
+ }
383
+
384
+ // Add additional context
385
+ if (additionalContext) {
386
+ Object.assign(context, additionalContext)
387
+ }
388
+
389
+ // Apply include/exclude filters
390
+ let filteredContext = context
391
+
392
+ if (tableConfig.includeContext && tableConfig.includeContext.length > 0) {
393
+ filteredContext = {}
394
+ for (const key of tableConfig.includeContext) {
395
+ if (key in context) {
396
+ filteredContext[key] = context[key]
397
+ }
398
+ }
399
+ }
400
+
401
+ if (tableConfig.excludeContext && tableConfig.excludeContext.length > 0) {
402
+ for (const key of tableConfig.excludeContext) {
403
+ // Use destructuring to avoid dynamic delete
404
+ const { [key]: _, ...rest } = filteredContext
405
+ filteredContext = rest
406
+ }
407
+ }
408
+
409
+ return Object.keys(filteredContext).length > 0 ? filteredContext : undefined
410
+ }
411
+
412
+ /**
413
+ * Log event to buffer or directly
414
+ */
415
+ private async logEvent(event: RLSAuditEvent): Promise<void> {
416
+ if (this.config.async && this.config.bufferSize > 0) {
417
+ // Buffered async logging
418
+ this.buffer.push(event)
419
+
420
+ if (this.buffer.length >= this.config.bufferSize) {
421
+ // Buffer full, flush now
422
+ await this.flush()
423
+ }
424
+ } else if (this.config.async) {
425
+ // Async fire-and-forget
426
+ this.adapter.log(event).catch((error: unknown) => {
427
+ this.config.onError?.(error instanceof Error ? error : new Error(String(error)), [event])
428
+ })
429
+ } else {
430
+ // Synchronous logging
431
+ try {
432
+ await this.adapter.log(event)
433
+ } catch (error) {
434
+ this.config.onError?.(error instanceof Error ? error : new Error(String(error)), [event])
435
+ }
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Start the flush timer
441
+ */
442
+ private startFlushTimer(): void {
443
+ this.flushTimer = setInterval(() => {
444
+ this.flush().catch((error: unknown) => {
445
+ this.config.onError?.(error instanceof Error ? error : new Error(String(error)), [...this.buffer])
446
+ })
447
+ }, this.config.flushInterval)
448
+
449
+ // Don't block process exit
450
+ if (this.flushTimer.unref) {
451
+ this.flushTimer.unref()
452
+ }
453
+ }
454
+ }
455
+
456
+ // ============================================================================
457
+ // Factory Function
458
+ // ============================================================================
459
+
460
+ /**
461
+ * Create an audit logger
462
+ */
463
+ export function createAuditLogger(config: AuditConfig): AuditLogger {
464
+ return new AuditLogger(config)
465
+ }