@kysera/rls 0.7.3 → 0.7.4

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/src/plugin.ts CHANGED
@@ -10,55 +10,121 @@
10
10
  * @module @kysera/rls
11
11
  */
12
12
 
13
- import type { Plugin, QueryBuilderContext } from '@kysera/executor';
14
- import { getRawDb } from '@kysera/executor';
15
- import type { Kysely } from 'kysely';
16
- import type { RLSSchema, Operation } from './policy/types.js';
17
- import { PolicyRegistry } from './policy/registry.js';
18
- import { SelectTransformer } from './transformer/select.js';
19
- import { MutationGuard } from './transformer/mutation.js';
20
- import { rlsContext } from './context/manager.js';
21
- import { RLSContextError, RLSPolicyViolation, RLSError, RLSErrorCodes } from './errors.js';
22
- import { silentLogger, type KyseraLogger } from '@kysera/core';
13
+ import type { Plugin, QueryBuilderContext, BaseRepositoryLike } from '@kysera/executor'
14
+ import { getRawDb, isRepositoryLike } from '@kysera/executor'
15
+ import type { Kysely } from 'kysely'
16
+ import { z } from 'zod'
17
+ import type { RLSSchema, Operation } from './policy/types.js'
18
+ import { PolicyRegistry } from './policy/registry.js'
19
+ import { SelectTransformer } from './transformer/select.js'
20
+ import { MutationGuard } from './transformer/mutation.js'
21
+ import { rlsContext } from './context/manager.js'
22
+ import { VERSION } from './version.js'
23
+ import { RLSContextError, RLSPolicyViolation, RLSError, RLSErrorCodes } from './errors.js'
24
+ import { silentLogger, type KyseraLogger } from '@kysera/core'
25
+ import {
26
+ transformQueryBuilder,
27
+ selectFromDynamicTable,
28
+ whereIdEquals,
29
+ hasRawDb as hasRawDbUtil,
30
+ applyWhereCondition,
31
+ createRawCondition
32
+ } from './utils/type-utils.js'
23
33
 
24
34
  /**
25
35
  * RLS Plugin configuration options
26
36
  */
27
37
  export interface RLSPluginOptions<DB = unknown> {
28
38
  /** RLS policy schema */
29
- schema: RLSSchema<DB>;
39
+ schema: RLSSchema<DB>
30
40
 
31
- /** Tables to skip RLS for (always bypass policies) */
32
- skipTables?: string[];
41
+ /**
42
+ * Tables to exclude from RLS (always bypass policies)
43
+ * Replaces the deprecated `skipTables` option for consistency with other plugins.
44
+ * @default []
45
+ */
46
+ excludeTables?: string[]
47
+
48
+ /**
49
+ * @deprecated Use `excludeTables` instead for consistency with other Kysera plugins
50
+ * @default []
51
+ */
52
+ skipTables?: string[]
33
53
 
34
54
  /** Roles that bypass RLS entirely (e.g., ['admin', 'superuser']) */
35
- bypassRoles?: string[];
55
+ bypassRoles?: string[]
36
56
 
37
57
  /** Logger instance for RLS operations */
38
- logger?: KyseraLogger;
39
-
40
- /** Require RLS context for all operations (throws if missing) */
41
- requireContext?: boolean;
58
+ logger?: KyseraLogger
59
+
60
+ /**
61
+ * Require RLS context for all operations (throws if missing)
62
+ *
63
+ * **Security**: Defaults to `true` for secure-by-default behavior.
64
+ * When `true`, missing RLS context throws RLSContextError, preventing
65
+ * unfiltered database access which could expose sensitive data.
66
+ *
67
+ * Only set to `false` if you explicitly want to allow queries without
68
+ * RLS context (not recommended in production).
69
+ *
70
+ * @default true
71
+ * @see allowUnfilteredQueries for explicit unfiltered query control
72
+ */
73
+ requireContext?: boolean
74
+
75
+ /**
76
+ * Allow unfiltered queries when RLS context is missing
77
+ *
78
+ * **SECURITY WARNING**: Setting this to `true` allows database queries
79
+ * to execute without RLS filtering when context is missing. This can
80
+ * expose sensitive data across tenant boundaries or user permissions.
81
+ *
82
+ * Only enable this if you:
83
+ * 1. Understand the security implications
84
+ * 2. Have other security controls in place
85
+ * 3. Are running background jobs or system operations that don't have user context
86
+ *
87
+ * When both `requireContext: false` and `allowUnfilteredQueries: false`:
88
+ * - Missing context logs a warning and returns empty results
89
+ *
90
+ * @default false (secure-by-default)
91
+ */
92
+ allowUnfilteredQueries?: boolean
42
93
 
43
94
  /** Enable audit logging of policy decisions */
44
- auditDecisions?: boolean;
95
+ auditDecisions?: boolean
45
96
 
46
97
  /** Custom error handler for policy violations */
47
- onViolation?: (violation: RLSPolicyViolation) => void;
98
+ onViolation?: (violation: RLSPolicyViolation) => void
99
+
100
+ /**
101
+ * Primary key column name for row lookups.
102
+ * @default 'id'
103
+ */
104
+ primaryKeyColumn?: string
48
105
  }
49
106
 
50
107
  /**
51
- * Base repository interface for type safety
108
+ * Zod schema for RLSPluginOptions
109
+ * Used for validation and configuration in the kysera-cli.
110
+ * Note: 'schema' and 'onViolation' are not included as they are complex runtime objects.
111
+ */
112
+ export const RLSPluginOptionsSchema = z.object({
113
+ excludeTables: z.array(z.string()).optional(),
114
+ skipTables: z.array(z.string()).optional(), // deprecated but kept for backward compatibility
115
+ bypassRoles: z.array(z.string()).optional(),
116
+ requireContext: z.boolean().optional(),
117
+ allowUnfilteredQueries: z.boolean().optional(),
118
+ auditDecisions: z.boolean().optional(),
119
+ primaryKeyColumn: z.string().optional()
120
+ })
121
+
122
+ /**
123
+ * Base repository interface for type safety.
124
+ * Type alias for BaseRepositoryLike from @kysera/executor with concrete DB type.
52
125
  * @internal
53
126
  */
54
- interface BaseRepository {
55
- tableName: string;
56
- executor: Kysely<Record<string, unknown>>;
57
- findById?: (id: unknown) => Promise<unknown>;
58
- create?: (data: unknown) => Promise<unknown>;
59
- update?: (id: unknown, data: unknown) => Promise<unknown>;
60
- delete?: (id: unknown) => Promise<unknown>;
61
- }
127
+ type BaseRepository = BaseRepositoryLike<Record<string, unknown>>
62
128
 
63
129
  /**
64
130
  * Create RLS plugin for Kysera
@@ -110,22 +176,36 @@ interface BaseRepository {
110
176
  export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
111
177
  const {
112
178
  schema,
113
- skipTables = [],
179
+ excludeTables: excludeTablesOption,
180
+ skipTables: skipTablesOption,
114
181
  bypassRoles = [],
115
182
  logger = silentLogger,
116
- requireContext = false,
183
+ requireContext = true, // SECURITY: Changed to true for secure-by-default (CRIT-2 fix)
184
+ allowUnfilteredQueries = false, // SECURITY: Explicit opt-in for unfiltered queries
117
185
  auditDecisions = false,
118
186
  onViolation,
119
- } = options;
187
+ primaryKeyColumn = 'id'
188
+ } = options
189
+
190
+ // Backward compatibility: support both excludeTables and skipTables
191
+ // excludeTables takes precedence if both are provided
192
+ const excludeTables = excludeTablesOption ?? skipTablesOption ?? []
193
+
194
+ // Warn if deprecated skipTables is used
195
+ if (skipTablesOption !== undefined && excludeTablesOption === undefined) {
196
+ logger.warn?.(
197
+ '[RLS] The "skipTables" option is deprecated. Use "excludeTables" instead for consistency with other Kysera plugins.'
198
+ )
199
+ }
120
200
 
121
201
  // Registry and transformers (initialized in onInit)
122
- let registry: PolicyRegistry<DB>;
123
- let selectTransformer: SelectTransformer<DB>;
124
- let mutationGuard: MutationGuard<DB>;
202
+ let registry: PolicyRegistry<DB>
203
+ let selectTransformer: SelectTransformer<DB>
204
+ let mutationGuard: MutationGuard<DB>
125
205
 
126
206
  return {
127
207
  name: '@kysera/rls',
128
- version: '0.7.0',
208
+ version: VERSION,
129
209
 
130
210
  // Run after soft-delete (priority 0), before audit
131
211
  priority: 50,
@@ -136,22 +216,33 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
136
216
  /**
137
217
  * Initialize plugin - compile policies
138
218
  */
139
- async onInit<TDB>(_executor: Kysely<TDB>): Promise<void> {
219
+ onInit<TDB>(_executor: Kysely<TDB>): void {
140
220
  logger.info?.('[RLS] Initializing RLS plugin', {
141
221
  tables: Object.keys(schema).length,
142
- skipTables: skipTables.length,
143
- bypassRoles: bypassRoles.length,
144
- });
222
+ excludeTables: excludeTables.length,
223
+ bypassRoles: bypassRoles.length
224
+ })
145
225
 
146
226
  // Create and compile registry
147
- registry = new PolicyRegistry<DB>(schema);
148
- registry.validate();
227
+ // Type assertion: The plugin is configured with a specific DB schema,
228
+ // but onInit receives a generic TDB. We use the schema's DB type.
229
+ registry = new PolicyRegistry<DB>(schema)
230
+ registry.validate()
149
231
 
150
232
  // Create transformers
151
- selectTransformer = new SelectTransformer<DB>(registry);
152
- mutationGuard = new MutationGuard<DB>(registry);
233
+ selectTransformer = new SelectTransformer<DB>(registry)
234
+ mutationGuard = new MutationGuard<DB>(registry)
153
235
 
154
- logger.info?.('[RLS] RLS plugin initialized successfully');
236
+ logger.info?.('[RLS] RLS plugin initialized successfully')
237
+ },
238
+
239
+ /**
240
+ * Cleanup resources when executor is destroyed
241
+ */
242
+ async onDestroy(): Promise<void> {
243
+ // Clear registry to free up memory
244
+ registry.clear()
245
+ logger.info?.('[RLS] RLS plugin destroyed, cleared policy registry')
155
246
  },
156
247
 
157
248
  /**
@@ -161,74 +252,111 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
161
252
  * it applies filter policies as WHERE conditions. For mutations, it marks
162
253
  * that RLS validation is required (performed in extendRepository).
163
254
  */
164
- interceptQuery<QB>(
165
- qb: QB,
166
- context: QueryBuilderContext
167
- ): QB {
168
- const { operation, table, metadata } = context;
255
+ interceptQuery<QB>(qb: QB, context: QueryBuilderContext): QB {
256
+ const { operation, table, metadata } = context
169
257
 
170
258
  // Skip if table is excluded
171
- if (skipTables.includes(table)) {
172
- logger.debug?.(`[RLS] Skipping RLS for excluded table: ${table}`);
173
- return qb;
259
+ if (excludeTables.includes(table)) {
260
+ logger.debug?.(`[RLS] Skipping RLS for excluded table: ${table}`)
261
+ return qb
174
262
  }
175
263
 
176
264
  // Skip if explicitly disabled via metadata
177
265
  if (metadata['skipRLS'] === true) {
178
- logger.debug?.(`[RLS] Skipping RLS (explicit skip): ${table}`);
179
- return qb;
266
+ logger.debug?.(`[RLS] Skipping RLS (explicit skip): ${table}`)
267
+ return qb
180
268
  }
181
269
 
182
270
  // Check for context
183
- const ctx = rlsContext.getContextOrNull();
271
+ const ctx = rlsContext.getContextOrNull()
184
272
 
185
273
  if (!ctx) {
274
+ // SECURITY FIX (CRIT-2): Secure-by-default behavior for missing context
186
275
  if (requireContext) {
187
- throw new RLSContextError('RLS context required but not found');
276
+ throw new RLSContextError(
277
+ `RLS context required but not found for ${operation} on ${table}. ` +
278
+ `This prevents unfiltered database access. ` +
279
+ `Either provide RLS context or set 'requireContext: false' with 'allowUnfilteredQueries: true' if intentional.`
280
+ )
281
+ }
282
+
283
+ if (!allowUnfilteredQueries) {
284
+ // Log warning and return safe empty result
285
+ logger.warn?.(
286
+ `[RLS] Missing context for ${operation} on ${table}. ` +
287
+ `Queries will return empty results for security. ` +
288
+ `Set 'allowUnfilteredQueries: true' to allow unfiltered access (not recommended).`
289
+ )
290
+ // For SELECT, apply impossible condition to return no rows
291
+ if (operation === 'select') {
292
+ return transformQueryBuilder(qb, operation, selectQb => {
293
+ // Apply WHERE FALSE to ensure no rows are returned
294
+ return applyWhereCondition(
295
+ selectQb,
296
+ createRawCondition('FALSE') as unknown as string,
297
+ '=',
298
+ true
299
+ ) as typeof selectQb
300
+ })
301
+ }
302
+ // For mutations, we'll let them through but log warning
303
+ // The extendRepository will handle mutation checks
304
+ return qb
188
305
  }
189
- logger.warn?.(`[RLS] No context for ${operation} on ${table}`);
190
- return qb;
306
+
307
+ // allowUnfilteredQueries is true - allow but log warning
308
+ logger.warn?.(
309
+ `[RLS] No context for ${operation} on ${table}. ` +
310
+ `Allowing unfiltered query due to 'allowUnfilteredQueries: true'. ` +
311
+ `This may expose sensitive data.`
312
+ )
313
+ return qb
191
314
  }
192
315
 
193
316
  // Check if system user (bypass RLS)
194
317
  if (ctx.auth.isSystem) {
195
- logger.debug?.(`[RLS] Bypassing RLS (system user): ${table}`);
196
- return qb;
318
+ logger.debug?.(`[RLS] Bypassing RLS (system user): ${table}`)
319
+ return qb
197
320
  }
198
321
 
199
322
  // Check bypass roles
200
323
  if (bypassRoles.some(role => ctx.auth.roles.includes(role))) {
201
- logger.debug?.(`[RLS] Bypassing RLS (bypass role): ${table}`);
202
- return qb;
324
+ logger.debug?.(`[RLS] Bypassing RLS (bypass role): ${table}`)
325
+ return qb
203
326
  }
204
327
 
205
328
  // Apply SELECT filtering
206
329
  if (operation === 'select') {
207
330
  try {
208
- const transformed = selectTransformer.transform(qb as any, table);
331
+ const transformed = transformQueryBuilder(
332
+ qb,
333
+ operation,
334
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
335
+ selectQb => selectTransformer.transform(selectQb as any, table) as any
336
+ )
209
337
 
210
338
  if (auditDecisions) {
211
339
  logger.info?.('[RLS] Filter applied', {
212
340
  table,
213
341
  operation,
214
- userId: ctx.auth.userId,
215
- });
342
+ userId: ctx.auth.userId
343
+ })
216
344
  }
217
345
 
218
- return transformed as QB;
346
+ return transformed
219
347
  } catch (error) {
220
- logger.error?.('[RLS] Error applying filter', { table, error });
221
- throw error;
348
+ logger.error?.('[RLS] Error applying filter', { table, error })
349
+ throw error
222
350
  }
223
351
  }
224
352
 
225
353
  // For mutations, mark that RLS check is needed (done in extendRepository)
226
354
  if (operation === 'insert' || operation === 'update' || operation === 'delete') {
227
- metadata['__rlsRequired'] = true;
228
- metadata['__rlsTable'] = table;
355
+ metadata['__rlsRequired'] = true
356
+ metadata['__rlsTable'] = table
229
357
  }
230
358
 
231
- return qb;
359
+ return qb
232
360
  },
233
361
 
234
362
  /**
@@ -238,38 +366,39 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
238
366
  * Also adds utility methods for bypassing RLS and checking access.
239
367
  */
240
368
  extendRepository<T extends object>(repo: T): T {
241
- const baseRepo = repo as unknown as BaseRepository;
242
-
243
- // Check if it's a valid repository
244
- if (!('tableName' in baseRepo) || !('executor' in baseRepo)) {
245
- return repo;
369
+ // Use the shared type guard from @kysera/executor
370
+ if (!isRepositoryLike(repo)) {
371
+ return repo
246
372
  }
247
373
 
248
- const table = baseRepo.tableName;
374
+ const baseRepo = repo as unknown as BaseRepository
375
+
376
+ const table = baseRepo.tableName
249
377
 
250
378
  // Skip excluded tables
251
- if (skipTables.includes(table)) {
252
- return repo;
379
+ if (excludeTables.includes(table)) {
380
+ logger.debug?.(`[RLS] Skipping repository extension for excluded table: ${table}`)
381
+ return repo
253
382
  }
254
383
 
255
384
  // Skip if table not in schema
256
385
  if (!registry.hasTable(table)) {
257
- logger.debug?.(`[RLS] Table "${table}" not in RLS schema, skipping`);
258
- return repo;
386
+ logger.debug?.(`[RLS] Table "${table}" not in RLS schema, skipping`)
387
+ return repo
259
388
  }
260
389
 
261
- logger.debug?.(`[RLS] Extending repository for table: ${table}`);
390
+ logger.debug?.(`[RLS] Extending repository for table: ${table}`)
262
391
 
263
392
  // Store original methods
264
- const originalCreate = baseRepo.create?.bind(baseRepo);
265
- const originalUpdate = baseRepo.update?.bind(baseRepo);
266
- const originalDelete = baseRepo.delete?.bind(baseRepo);
267
- const originalFindById = baseRepo.findById?.bind(baseRepo);
393
+ const originalCreate = baseRepo.create?.bind(baseRepo)
394
+ const originalUpdate = baseRepo.update?.bind(baseRepo)
395
+ const originalDelete = baseRepo.delete?.bind(baseRepo)
396
+ const originalFindById = baseRepo.findById?.bind(baseRepo)
268
397
 
269
398
  // Get raw db for internal queries that need to bypass RLS
270
399
  // If executor doesn't have __rawDb (e.g., in tests), we'll use originalFindById
271
- const rawDb = getRawDb(baseRepo.executor);
272
- const hasRawDb = (baseRepo.executor as any).__rawDb !== undefined;
400
+ const rawDb = getRawDb(baseRepo.executor)
401
+ const hasRawDbInstance = hasRawDbUtil(baseRepo.executor)
273
402
 
274
403
  const extendedRepo = {
275
404
  ...baseRepo,
@@ -279,36 +408,42 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
279
408
  */
280
409
  async create(data: unknown): Promise<unknown> {
281
410
  if (!originalCreate) {
282
- throw new RLSError('Repository does not support create operation', RLSErrorCodes.RLS_POLICY_INVALID);
411
+ throw new RLSError(
412
+ 'Repository does not support create operation',
413
+ RLSErrorCodes.RLS_POLICY_INVALID
414
+ )
283
415
  }
284
416
 
285
- const ctx = rlsContext.getContextOrNull();
417
+ const ctx = rlsContext.getContextOrNull()
286
418
 
287
419
  // Check RLS if context exists and not system/bypass
288
- if (ctx && !ctx.auth.isSystem &&
289
- !bypassRoles.some(role => ctx.auth.roles.includes(role))) {
420
+ if (
421
+ ctx &&
422
+ !ctx.auth.isSystem &&
423
+ !bypassRoles.some(role => ctx.auth.roles.includes(role))
424
+ ) {
290
425
  try {
291
- await mutationGuard.checkCreate(table, data as Record<string, unknown>);
426
+ await mutationGuard.checkCreate(table, data as Record<string, unknown>)
292
427
 
293
428
  if (auditDecisions) {
294
- logger.info?.('[RLS] Create allowed', { table, userId: ctx.auth.userId });
429
+ logger.info?.('[RLS] Create allowed', { table, userId: ctx.auth.userId })
295
430
  }
296
431
  } catch (error) {
297
432
  if (error instanceof RLSPolicyViolation) {
298
- onViolation?.(error);
433
+ onViolation?.(error)
299
434
  if (auditDecisions) {
300
435
  logger.warn?.('[RLS] Create denied', {
301
436
  table,
302
437
  userId: ctx.auth.userId,
303
438
  reason: error.reason
304
- });
439
+ })
305
440
  }
306
441
  }
307
- throw error;
442
+ throw error
308
443
  }
309
444
  }
310
445
 
311
- return originalCreate(data);
446
+ return await originalCreate(data)
312
447
  },
313
448
 
314
449
  /**
@@ -316,34 +451,40 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
316
451
  */
317
452
  async update(id: unknown, data: unknown): Promise<unknown> {
318
453
  if (!originalUpdate) {
319
- throw new RLSError('Repository does not support update operation', RLSErrorCodes.RLS_POLICY_INVALID);
454
+ throw new RLSError(
455
+ 'Repository does not support update operation',
456
+ RLSErrorCodes.RLS_POLICY_INVALID
457
+ )
320
458
  }
321
459
 
322
- const ctx = rlsContext.getContextOrNull();
460
+ const ctx = rlsContext.getContextOrNull()
323
461
 
324
- if (ctx && !ctx.auth.isSystem &&
325
- !bypassRoles.some(role => ctx.auth.roles.includes(role))) {
462
+ if (
463
+ ctx &&
464
+ !ctx.auth.isSystem &&
465
+ !bypassRoles.some(role => ctx.auth.roles.includes(role))
466
+ ) {
326
467
  // Fetch existing row for policy evaluation
327
468
  // Use raw db if available to bypass RLS filtering and prevent self-interception
328
- let existingRow: unknown;
469
+ let existingRow: unknown
329
470
 
330
- if (hasRawDb) {
471
+ if (hasRawDbInstance) {
331
472
  // Use raw db to bypass RLS filtering
332
- existingRow = await rawDb
333
- .selectFrom(table as any)
334
- .selectAll()
335
- .where('id' as any, '=', id as any)
336
- .executeTakeFirst();
473
+ const query = selectFromDynamicTable(rawDb, table)
474
+ existingRow = await whereIdEquals(query, id, primaryKeyColumn).executeTakeFirst()
337
475
  } else if (originalFindById) {
338
476
  // Fallback to originalFindById for tests/mocks
339
- existingRow = await originalFindById(id);
477
+ existingRow = await originalFindById(id)
340
478
  } else {
341
- throw new RLSError('Repository does not support update operation', RLSErrorCodes.RLS_POLICY_INVALID);
479
+ throw new RLSError(
480
+ 'Repository does not support update operation',
481
+ RLSErrorCodes.RLS_POLICY_INVALID
482
+ )
342
483
  }
343
484
 
344
485
  if (!existingRow) {
345
486
  // Let the original method handle not found
346
- return originalUpdate(id, data);
487
+ return await originalUpdate(id, data)
347
488
  }
348
489
 
349
490
  try {
@@ -351,28 +492,28 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
351
492
  table,
352
493
  existingRow as Record<string, unknown>,
353
494
  data as Record<string, unknown>
354
- );
495
+ )
355
496
 
356
497
  if (auditDecisions) {
357
- logger.info?.('[RLS] Update allowed', { table, id, userId: ctx.auth.userId });
498
+ logger.info?.('[RLS] Update allowed', { table, id, userId: ctx.auth.userId })
358
499
  }
359
500
  } catch (error) {
360
501
  if (error instanceof RLSPolicyViolation) {
361
- onViolation?.(error);
502
+ onViolation?.(error)
362
503
  if (auditDecisions) {
363
504
  logger.warn?.('[RLS] Update denied', {
364
505
  table,
365
506
  id,
366
507
  userId: ctx.auth.userId,
367
508
  reason: error.reason
368
- });
509
+ })
369
510
  }
370
511
  }
371
- throw error;
512
+ throw error
372
513
  }
373
514
  }
374
515
 
375
- return originalUpdate(id, data);
516
+ return await originalUpdate(id, data)
376
517
  },
377
518
 
378
519
  /**
@@ -380,59 +521,65 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
380
521
  */
381
522
  async delete(id: unknown): Promise<unknown> {
382
523
  if (!originalDelete) {
383
- throw new RLSError('Repository does not support delete operation', RLSErrorCodes.RLS_POLICY_INVALID);
524
+ throw new RLSError(
525
+ 'Repository does not support delete operation',
526
+ RLSErrorCodes.RLS_POLICY_INVALID
527
+ )
384
528
  }
385
529
 
386
- const ctx = rlsContext.getContextOrNull();
530
+ const ctx = rlsContext.getContextOrNull()
387
531
 
388
- if (ctx && !ctx.auth.isSystem &&
389
- !bypassRoles.some(role => ctx.auth.roles.includes(role))) {
532
+ if (
533
+ ctx &&
534
+ !ctx.auth.isSystem &&
535
+ !bypassRoles.some(role => ctx.auth.roles.includes(role))
536
+ ) {
390
537
  // Fetch existing row for policy evaluation
391
538
  // Use raw db if available to bypass RLS filtering and prevent self-interception
392
- let existingRow: unknown;
539
+ let existingRow: unknown
393
540
 
394
- if (hasRawDb) {
541
+ if (hasRawDbInstance) {
395
542
  // Use raw db to bypass RLS filtering
396
- existingRow = await rawDb
397
- .selectFrom(table as any)
398
- .selectAll()
399
- .where('id' as any, '=', id as any)
400
- .executeTakeFirst();
543
+ const query = selectFromDynamicTable(rawDb, table)
544
+ existingRow = await whereIdEquals(query, id, primaryKeyColumn).executeTakeFirst()
401
545
  } else if (originalFindById) {
402
546
  // Fallback to originalFindById for tests/mocks
403
- existingRow = await originalFindById(id);
547
+ existingRow = await originalFindById(id)
404
548
  } else {
405
- throw new RLSError('Repository does not support delete operation', RLSErrorCodes.RLS_POLICY_INVALID);
549
+ throw new RLSError(
550
+ 'Repository does not support delete operation',
551
+ RLSErrorCodes.RLS_POLICY_INVALID
552
+ )
406
553
  }
407
554
 
408
555
  if (!existingRow) {
409
556
  // Let the original method handle not found
410
- return originalDelete(id);
557
+ return await originalDelete(id)
411
558
  }
412
559
 
413
560
  try {
414
- await mutationGuard.checkDelete(table, existingRow as Record<string, unknown>);
561
+ await mutationGuard.checkDelete(table, existingRow as Record<string, unknown>)
415
562
 
416
563
  if (auditDecisions) {
417
- logger.info?.('[RLS] Delete allowed', { table, id, userId: ctx.auth.userId });
564
+ logger.info?.('[RLS] Delete allowed', { table, id, userId: ctx.auth.userId })
418
565
  }
419
566
  } catch (error) {
420
567
  if (error instanceof RLSPolicyViolation) {
421
- onViolation?.(error);
568
+ onViolation?.(error)
422
569
  if (auditDecisions) {
423
570
  logger.warn?.('[RLS] Delete denied', {
424
571
  table,
425
572
  id,
426
573
  userId: ctx.auth.userId,
427
574
  reason: error.reason
428
- });
575
+ })
429
576
  }
430
577
  }
431
- throw error;
578
+ throw error
432
579
  }
433
580
  }
434
581
 
435
- return originalDelete(id);
582
+ return await originalDelete(id)
436
583
  },
437
584
 
438
585
  /**
@@ -448,7 +595,7 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
448
595
  * ```
449
596
  */
450
597
  async withoutRLS<R>(fn: () => Promise<R>): Promise<R> {
451
- return rlsContext.asSystemAsync(fn);
598
+ return await rlsContext.asSystemAsync(fn)
452
599
  },
453
600
 
454
601
  /**
@@ -464,39 +611,39 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
464
611
  * ```
465
612
  */
466
613
  async canAccess(operation: Operation, row: Record<string, unknown>): Promise<boolean> {
467
- const ctx = rlsContext.getContextOrNull();
468
- if (!ctx) return false;
469
- if (ctx.auth.isSystem) return true;
470
- if (bypassRoles.some(role => ctx.auth.roles.includes(role))) return true;
614
+ const ctx = rlsContext.getContextOrNull()
615
+ if (!ctx) return false
616
+ if (ctx.auth.isSystem) return true
617
+ if (bypassRoles.some(role => ctx.auth.roles.includes(role))) return true
471
618
 
472
619
  try {
473
620
  switch (operation) {
474
621
  case 'read':
475
- return await mutationGuard.checkRead(table, row);
622
+ return await mutationGuard.checkRead(table, row)
476
623
  case 'create':
477
- await mutationGuard.checkCreate(table, row);
478
- return true;
624
+ await mutationGuard.checkCreate(table, row)
625
+ return true
479
626
  case 'update':
480
- await mutationGuard.checkUpdate(table, row, {});
481
- return true;
627
+ await mutationGuard.checkUpdate(table, row, {})
628
+ return true
482
629
  case 'delete':
483
- await mutationGuard.checkDelete(table, row);
484
- return true;
630
+ await mutationGuard.checkDelete(table, row)
631
+ return true
485
632
  default:
486
- return false;
633
+ return false
487
634
  }
488
635
  } catch (error) {
489
636
  logger.debug?.('[RLS] Access check failed', {
490
637
  table,
491
638
  operation,
492
639
  error: error instanceof Error ? error.message : String(error)
493
- });
494
- return false;
640
+ })
641
+ return false
495
642
  }
496
- },
497
- };
643
+ }
644
+ }
498
645
 
499
- return extendedRepo as T;
500
- },
501
- };
646
+ return extendedRepo as T
647
+ }
648
+ }
502
649
  }