@kysera/rls 0.6.0 → 0.7.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/src/errors.ts CHANGED
@@ -31,6 +31,8 @@ export const RLSErrorCodes = {
31
31
  RLS_SCHEMA_INVALID: 'RLS_SCHEMA_INVALID' as ErrorCode,
32
32
  /** RLS context validation failed */
33
33
  RLS_CONTEXT_INVALID: 'RLS_CONTEXT_INVALID' as ErrorCode,
34
+ /** RLS policy evaluation threw an error */
35
+ RLS_POLICY_EVALUATION_ERROR: 'RLS_POLICY_EVALUATION_ERROR' as ErrorCode,
34
36
  } as const;
35
37
 
36
38
  /**
@@ -227,6 +229,86 @@ export class RLSPolicyViolation extends RLSError {
227
229
  }
228
230
  }
229
231
 
232
+ // ============================================================================
233
+ // Policy Evaluation Errors
234
+ // ============================================================================
235
+
236
+ /**
237
+ * Error thrown when a policy condition throws an error during evaluation
238
+ *
239
+ * This error is distinct from RLSPolicyViolation - it indicates a bug in the
240
+ * policy condition function itself, not a legitimate access denial.
241
+ *
242
+ * @example
243
+ * ```typescript
244
+ * // A policy with a bug
245
+ * allow('read', ctx => {
246
+ * return ctx.row.someField.value; // Throws if someField is undefined
247
+ * });
248
+ *
249
+ * // This will throw RLSPolicyEvaluationError, not RLSPolicyViolation
250
+ * ```
251
+ */
252
+ export class RLSPolicyEvaluationError extends RLSError {
253
+ public readonly operation: string;
254
+ public readonly table: string;
255
+ public readonly policyName?: string;
256
+ public readonly originalError?: Error;
257
+
258
+ /**
259
+ * Creates a new policy evaluation error
260
+ *
261
+ * @param operation - Database operation being performed
262
+ * @param table - Table name where error occurred
263
+ * @param message - Error message from the policy
264
+ * @param policyName - Name of the policy that threw
265
+ * @param originalError - The original error thrown by the policy
266
+ */
267
+ constructor(
268
+ operation: string,
269
+ table: string,
270
+ message: string,
271
+ policyName?: string,
272
+ originalError?: Error
273
+ ) {
274
+ super(
275
+ `RLS policy evaluation error during ${operation} on ${table}: ${message}`,
276
+ RLSErrorCodes.RLS_POLICY_EVALUATION_ERROR
277
+ );
278
+ this.name = 'RLSPolicyEvaluationError';
279
+ this.operation = operation;
280
+ this.table = table;
281
+ if (policyName !== undefined) {
282
+ this.policyName = policyName;
283
+ }
284
+ if (originalError !== undefined) {
285
+ this.originalError = originalError;
286
+ // Preserve the original stack trace for debugging
287
+ if (originalError.stack) {
288
+ this.stack = `${this.stack}\n\nCaused by:\n${originalError.stack}`;
289
+ }
290
+ }
291
+ }
292
+
293
+ override toJSON(): Record<string, unknown> {
294
+ const json: Record<string, unknown> = {
295
+ ...super.toJSON(),
296
+ operation: this.operation,
297
+ table: this.table,
298
+ };
299
+ if (this.policyName !== undefined) {
300
+ json['policyName'] = this.policyName;
301
+ }
302
+ if (this.originalError !== undefined) {
303
+ json['originalError'] = {
304
+ name: this.originalError.name,
305
+ message: this.originalError.message,
306
+ };
307
+ }
308
+ return json;
309
+ }
310
+ }
311
+
230
312
  // ============================================================================
231
313
  // Schema Errors
232
314
  // ============================================================================
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @kysera/rls - Row-Level Security Plugin for Kysera ORM
2
+ * @kysera/rls - Row-Level Security Plugin for Kysera
3
3
  *
4
4
  * Provides declarative policy definition, automatic query transformation,
5
5
  * and optional native PostgreSQL RLS generation.
@@ -75,6 +75,7 @@ export {
75
75
  RLSError,
76
76
  RLSContextError,
77
77
  RLSPolicyViolation,
78
+ RLSPolicyEvaluationError,
78
79
  RLSSchemaError,
79
80
  RLSContextValidationError,
80
81
  RLSErrorCodes,
package/src/plugin.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  * @module @kysera/rls
11
11
  */
12
12
 
13
- import type { Plugin, QueryBuilderContext, AnyQueryBuilder } from '@kysera/repository';
13
+ import type { Plugin, QueryBuilderContext } from '@kysera/executor';
14
+ import { getRawDb } from '@kysera/executor';
14
15
  import type { Kysely } from 'kysely';
15
16
  import type { RLSSchema, Operation } from './policy/types.js';
16
17
  import { PolicyRegistry } from './policy/registry.js';
@@ -85,7 +86,7 @@ interface BaseRepository {
85
86
  * },
86
87
  * });
87
88
  *
88
- * // Create ORM with RLS plugin
89
+ * // Create repository with RLS plugin
89
90
  * const orm = await createORM(db, [
90
91
  * rlsPlugin({ schema }),
91
92
  * ]);
@@ -124,7 +125,7 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
124
125
 
125
126
  return {
126
127
  name: '@kysera/rls',
127
- version: '0.5.1',
128
+ version: '0.7.0',
128
129
 
129
130
  // Run after soft-delete (priority 0), before audit
130
131
  priority: 50,
@@ -135,7 +136,7 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
135
136
  /**
136
137
  * Initialize plugin - compile policies
137
138
  */
138
- async onInit<TDB>(executor: Kysely<TDB>): Promise<void> {
139
+ async onInit<TDB>(_executor: Kysely<TDB>): Promise<void> {
139
140
  logger.info?.('[RLS] Initializing RLS plugin', {
140
141
  tables: Object.keys(schema).length,
141
142
  skipTables: skipTables.length,
@@ -148,7 +149,7 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
148
149
 
149
150
  // Create transformers
150
151
  selectTransformer = new SelectTransformer<DB>(registry);
151
- mutationGuard = new MutationGuard<DB>(registry, executor as unknown as Kysely<DB>);
152
+ mutationGuard = new MutationGuard<DB>(registry);
152
153
 
153
154
  logger.info?.('[RLS] RLS plugin initialized successfully');
154
155
  },
@@ -160,7 +161,7 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
160
161
  * it applies filter policies as WHERE conditions. For mutations, it marks
161
162
  * that RLS validation is required (performed in extendRepository).
162
163
  */
163
- interceptQuery<QB extends AnyQueryBuilder>(
164
+ interceptQuery<QB>(
164
165
  qb: QB,
165
166
  context: QueryBuilderContext
166
167
  ): QB {
@@ -265,6 +266,11 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
265
266
  const originalDelete = baseRepo.delete?.bind(baseRepo);
266
267
  const originalFindById = baseRepo.findById?.bind(baseRepo);
267
268
 
269
+ // Get raw db for internal queries that need to bypass RLS
270
+ // 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;
273
+
268
274
  const extendedRepo = {
269
275
  ...baseRepo,
270
276
 
@@ -309,7 +315,7 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
309
315
  * Wrapped update with RLS check
310
316
  */
311
317
  async update(id: unknown, data: unknown): Promise<unknown> {
312
- if (!originalUpdate || !originalFindById) {
318
+ if (!originalUpdate) {
313
319
  throw new RLSError('Repository does not support update operation', RLSErrorCodes.RLS_POLICY_INVALID);
314
320
  }
315
321
 
@@ -318,7 +324,23 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
318
324
  if (ctx && !ctx.auth.isSystem &&
319
325
  !bypassRoles.some(role => ctx.auth.roles.includes(role))) {
320
326
  // Fetch existing row for policy evaluation
321
- const existingRow = await originalFindById(id);
327
+ // Use raw db if available to bypass RLS filtering and prevent self-interception
328
+ let existingRow: unknown;
329
+
330
+ if (hasRawDb) {
331
+ // 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();
337
+ } else if (originalFindById) {
338
+ // Fallback to originalFindById for tests/mocks
339
+ existingRow = await originalFindById(id);
340
+ } else {
341
+ throw new RLSError('Repository does not support update operation', RLSErrorCodes.RLS_POLICY_INVALID);
342
+ }
343
+
322
344
  if (!existingRow) {
323
345
  // Let the original method handle not found
324
346
  return originalUpdate(id, data);
@@ -357,7 +379,7 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
357
379
  * Wrapped delete with RLS check
358
380
  */
359
381
  async delete(id: unknown): Promise<unknown> {
360
- if (!originalDelete || !originalFindById) {
382
+ if (!originalDelete) {
361
383
  throw new RLSError('Repository does not support delete operation', RLSErrorCodes.RLS_POLICY_INVALID);
362
384
  }
363
385
 
@@ -366,7 +388,23 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
366
388
  if (ctx && !ctx.auth.isSystem &&
367
389
  !bypassRoles.some(role => ctx.auth.roles.includes(role))) {
368
390
  // Fetch existing row for policy evaluation
369
- const existingRow = await originalFindById(id);
391
+ // Use raw db if available to bypass RLS filtering and prevent self-interception
392
+ let existingRow: unknown;
393
+
394
+ if (hasRawDb) {
395
+ // 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();
401
+ } else if (originalFindById) {
402
+ // Fallback to originalFindById for tests/mocks
403
+ existingRow = await originalFindById(id);
404
+ } else {
405
+ throw new RLSError('Repository does not support delete operation', RLSErrorCodes.RLS_POLICY_INVALID);
406
+ }
407
+
370
408
  if (!existingRow) {
371
409
  // Let the original method handle not found
372
410
  return originalDelete(id);
@@ -3,23 +3,18 @@
3
3
  * Validates CREATE, UPDATE, DELETE operations against RLS policies
4
4
  */
5
5
 
6
- import type { Kysely } from 'kysely';
7
6
  import type { PolicyRegistry } from '../policy/registry.js';
8
7
  import type { PolicyEvaluationContext, Operation } from '../policy/types.js';
9
8
  import type { RLSContext } from '../context/types.js';
10
9
  import { rlsContext } from '../context/manager.js';
11
- import { RLSPolicyViolation } from '../errors.js';
10
+ import { RLSPolicyViolation, RLSPolicyEvaluationError } from '../errors.js';
12
11
 
13
12
  /**
14
13
  * Mutation guard
15
14
  * Validates mutations (CREATE, UPDATE, DELETE) against allow/deny/validate policies
16
15
  */
17
16
  export class MutationGuard<DB = unknown> {
18
- // executor is kept for future use with database-dependent policies
19
- constructor(
20
- private registry: PolicyRegistry<DB>,
21
- _executor?: Kysely<DB>
22
- ) {}
17
+ constructor(private registry: PolicyRegistry<DB>) {}
23
18
 
24
19
  /**
25
20
  * Check if CREATE operation is allowed
@@ -30,7 +25,7 @@ export class MutationGuard<DB = unknown> {
30
25
  *
31
26
  * @example
32
27
  * ```typescript
33
- * const guard = new MutationGuard(registry, db);
28
+ * const guard = new MutationGuard(registry);
34
29
  * await guard.checkCreate('posts', { title: 'Hello', tenant_id: 1 });
35
30
  * ```
36
31
  */
@@ -51,7 +46,7 @@ export class MutationGuard<DB = unknown> {
51
46
  *
52
47
  * @example
53
48
  * ```typescript
54
- * const guard = new MutationGuard(registry, db);
49
+ * const guard = new MutationGuard(registry);
55
50
  * const existingPost = await db.selectFrom('posts').where('id', '=', 1).selectAll().executeTakeFirst();
56
51
  * await guard.checkUpdate('posts', existingPost, { title: 'Updated' });
57
52
  * ```
@@ -73,7 +68,7 @@ export class MutationGuard<DB = unknown> {
73
68
  *
74
69
  * @example
75
70
  * ```typescript
76
- * const guard = new MutationGuard(registry, db);
71
+ * const guard = new MutationGuard(registry);
77
72
  * const existingPost = await db.selectFrom('posts').where('id', '=', 1).selectAll().executeTakeFirst();
78
73
  * await guard.checkDelete('posts', existingPost);
79
74
  * ```
@@ -94,7 +89,7 @@ export class MutationGuard<DB = unknown> {
94
89
  *
95
90
  * @example
96
91
  * ```typescript
97
- * const guard = new MutationGuard(registry, db);
92
+ * const guard = new MutationGuard(registry);
98
93
  * const post = await db.selectFrom('posts').where('id', '=', 1).selectAll().executeTakeFirst();
99
94
  * const canRead = await guard.checkRead('posts', post);
100
95
  * ```
@@ -107,7 +102,8 @@ export class MutationGuard<DB = unknown> {
107
102
  await this.checkMutation(table, 'read', row);
108
103
  return true;
109
104
  } catch (error) {
110
- if (error instanceof RLSPolicyViolation) {
105
+ // Both policy violations and evaluation errors result in denial
106
+ if (error instanceof RLSPolicyViolation || error instanceof RLSPolicyEvaluationError) {
111
107
  return false;
112
108
  }
113
109
  throw error;
@@ -133,7 +129,8 @@ export class MutationGuard<DB = unknown> {
133
129
  await this.checkMutation(table, operation, row, data);
134
130
  return true;
135
131
  } catch (error) {
136
- if (error instanceof RLSPolicyViolation) {
132
+ // Both policy violations and evaluation errors result in denial
133
+ if (error instanceof RLSPolicyViolation || error instanceof RLSPolicyEvaluationError) {
137
134
  return false;
138
135
  }
139
136
  throw error;
@@ -189,7 +186,7 @@ export class MutationGuard<DB = unknown> {
189
186
 
190
187
  // All validate policies must pass
191
188
  for (const validate of validates) {
192
- const result = await this.evaluatePolicy(validate.evaluate, evalCtx);
189
+ const result = await this.evaluatePolicy(validate.evaluate, evalCtx, validate.name);
193
190
  if (!result) {
194
191
  return false;
195
192
  }
@@ -237,7 +234,7 @@ export class MutationGuard<DB = unknown> {
237
234
  const denies = this.registry.getDenies(table, operation);
238
235
  for (const deny of denies) {
239
236
  const evalCtx = this.createEvalContext(ctx, table, operation, row, data);
240
- const result = await this.evaluatePolicy(deny.evaluate, evalCtx);
237
+ const result = await this.evaluatePolicy(deny.evaluate, evalCtx, deny.name);
241
238
 
242
239
  if (result) {
243
240
  throw new RLSPolicyViolation(
@@ -253,7 +250,7 @@ export class MutationGuard<DB = unknown> {
253
250
  const validates = this.registry.getValidates(table, operation);
254
251
  for (const validate of validates) {
255
252
  const evalCtx = this.createEvalContext(ctx, table, operation, row, data);
256
- const result = await this.evaluatePolicy(validate.evaluate, evalCtx);
253
+ const result = await this.evaluatePolicy(validate.evaluate, evalCtx, validate.name);
257
254
 
258
255
  if (!result) {
259
256
  throw new RLSPolicyViolation(
@@ -283,7 +280,7 @@ export class MutationGuard<DB = unknown> {
283
280
 
284
281
  for (const allow of allows) {
285
282
  const evalCtx = this.createEvalContext(ctx, table, operation, row, data);
286
- const result = await this.evaluatePolicy(allow.evaluate, evalCtx);
283
+ const result = await this.evaluatePolicy(allow.evaluate, evalCtx, allow.name);
287
284
 
288
285
  if (result) {
289
286
  allowed = true;
@@ -323,20 +320,31 @@ export class MutationGuard<DB = unknown> {
323
320
 
324
321
  /**
325
322
  * Evaluate a policy condition
323
+ *
324
+ * @param condition - Policy condition function
325
+ * @param evalCtx - Policy evaluation context
326
+ * @param policyName - Name of the policy being evaluated (for error reporting)
327
+ * @returns Boolean result of policy evaluation
328
+ * @throws RLSPolicyEvaluationError if the policy throws an error
326
329
  */
327
330
  private async evaluatePolicy(
328
331
  condition: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>,
329
- evalCtx: PolicyEvaluationContext
332
+ evalCtx: PolicyEvaluationContext,
333
+ policyName?: string
330
334
  ): Promise<boolean> {
331
335
  try {
332
336
  const result = condition(evalCtx);
333
337
  return result instanceof Promise ? await result : result;
334
338
  } catch (error) {
335
- // Policy evaluation errors are treated as denial
336
- throw new RLSPolicyViolation(
339
+ // Distinguish between policy evaluation errors and policy violations
340
+ // RLSPolicyEvaluationError indicates a bug in the policy, not a legitimate denial
341
+ const originalError = error instanceof Error ? error : undefined;
342
+ throw new RLSPolicyEvaluationError(
337
343
  evalCtx.operation ?? 'unknown',
338
344
  evalCtx.table ?? 'unknown',
339
- `Policy evaluation error: ${error instanceof Error ? error.message : 'Unknown error'}`
345
+ error instanceof Error ? error.message : 'Unknown error',
346
+ policyName,
347
+ originalError
340
348
  );
341
349
  }
342
350
  }
@@ -351,7 +359,7 @@ export class MutationGuard<DB = unknown> {
351
359
  *
352
360
  * @example
353
361
  * ```typescript
354
- * const guard = new MutationGuard(registry, db);
362
+ * const guard = new MutationGuard(registry);
355
363
  * const allPosts = await db.selectFrom('posts').selectAll().execute();
356
364
  * const accessiblePosts = await guard.filterRows('posts', allPosts);
357
365
  * ```
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { SelectQueryBuilder } from 'kysely';
7
+ import { sql } from 'kysely';
7
8
  import type { PolicyRegistry } from '../policy/registry.js';
8
9
  import type { PolicyEvaluationContext } from '../policy/types.js';
9
10
  import type { RLSContext } from '../context/types.js';
@@ -108,6 +109,18 @@ export class SelectTransformer<DB = unknown> {
108
109
  /**
109
110
  * Apply filter conditions to query builder
110
111
  *
112
+ * NOTE ON TYPE CASTS:
113
+ * The `as any` casts in this method are intentional architectural boundaries.
114
+ * Kysely's type system is designed for static query building where column names
115
+ * are known at compile time. RLS policies work with dynamic column names at runtime.
116
+ *
117
+ * Type safety is maintained through:
118
+ * 1. Policy conditions are validated during schema registration
119
+ * 2. Column names come from policy definitions (developer-controlled)
120
+ * 3. Values are type-checked by the policy condition functions
121
+ *
122
+ * This is the same pattern used in @kysera/repository/table-operations.ts
123
+ *
111
124
  * @param qb - Query builder to modify
112
125
  * @param conditions - WHERE clause conditions
113
126
  * @param table - Table name (for qualified column names)
@@ -122,6 +135,7 @@ export class SelectTransformer<DB = unknown> {
122
135
 
123
136
  for (const [column, value] of Object.entries(conditions)) {
124
137
  // Use table-qualified column name to avoid ambiguity in joins
138
+ // Cast is necessary because Kysely expects compile-time known column names
125
139
  const qualifiedColumn = `${table}.${column}` as any;
126
140
 
127
141
  if (value === null) {
@@ -132,9 +146,10 @@ export class SelectTransformer<DB = unknown> {
132
146
  continue;
133
147
  } else if (Array.isArray(value)) {
134
148
  if (value.length === 0) {
135
- // Empty array means no matches - add impossible condition
136
- // This ensures the query returns no rows
137
- result = result.where(qualifiedColumn, '=', '__RLS_NO_MATCH__' as any);
149
+ // Empty array means no matches - add impossible condition using SQL FALSE
150
+ // This ensures the query returns no rows without using magic strings
151
+ // that could potentially match actual data
152
+ result = result.where(sql`FALSE` as any);
138
153
  } else {
139
154
  // IN clause for array values
140
155
  result = result.where(qualifiedColumn, 'in', value as any);