@kysera/rls 0.5.1 → 0.6.1

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
@@ -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
@@ -135,7 +135,7 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
135
135
  /**
136
136
  * Initialize plugin - compile policies
137
137
  */
138
- async onInit<TDB>(executor: Kysely<TDB>): Promise<void> {
138
+ async onInit<TDB>(_executor: Kysely<TDB>): Promise<void> {
139
139
  logger.info?.('[RLS] Initializing RLS plugin', {
140
140
  tables: Object.keys(schema).length,
141
141
  skipTables: skipTables.length,
@@ -148,7 +148,7 @@ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
148
148
 
149
149
  // Create transformers
150
150
  selectTransformer = new SelectTransformer<DB>(registry);
151
- mutationGuard = new MutationGuard<DB>(registry, executor as unknown as Kysely<DB>);
151
+ mutationGuard = new MutationGuard<DB>(registry);
152
152
 
153
153
  logger.info?.('[RLS] RLS plugin initialized successfully');
154
154
  },
@@ -299,6 +299,18 @@ export interface PolicyEvaluationContext<
299
299
  * Can contain any additional context needed for policy evaluation
300
300
  */
301
301
  meta?: Record<string, unknown>;
302
+
303
+ /**
304
+ * Table name being accessed (optional)
305
+ * Available during mutation operations
306
+ */
307
+ table?: string;
308
+
309
+ /**
310
+ * Operation being performed (optional)
311
+ * E.g., 'create', 'update', 'delete'
312
+ */
313
+ operation?: string;
302
314
  }
303
315
 
304
316
  // ============================================================================
@@ -3,22 +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
- constructor(
19
- private registry: PolicyRegistry<DB>,
20
- private executor?: Kysely<DB>
21
- ) {}
17
+ constructor(private registry: PolicyRegistry<DB>) {}
22
18
 
23
19
  /**
24
20
  * Check if CREATE operation is allowed
@@ -29,7 +25,7 @@ export class MutationGuard<DB = unknown> {
29
25
  *
30
26
  * @example
31
27
  * ```typescript
32
- * const guard = new MutationGuard(registry, db);
28
+ * const guard = new MutationGuard(registry);
33
29
  * await guard.checkCreate('posts', { title: 'Hello', tenant_id: 1 });
34
30
  * ```
35
31
  */
@@ -50,7 +46,7 @@ export class MutationGuard<DB = unknown> {
50
46
  *
51
47
  * @example
52
48
  * ```typescript
53
- * const guard = new MutationGuard(registry, db);
49
+ * const guard = new MutationGuard(registry);
54
50
  * const existingPost = await db.selectFrom('posts').where('id', '=', 1).selectAll().executeTakeFirst();
55
51
  * await guard.checkUpdate('posts', existingPost, { title: 'Updated' });
56
52
  * ```
@@ -72,7 +68,7 @@ export class MutationGuard<DB = unknown> {
72
68
  *
73
69
  * @example
74
70
  * ```typescript
75
- * const guard = new MutationGuard(registry, db);
71
+ * const guard = new MutationGuard(registry);
76
72
  * const existingPost = await db.selectFrom('posts').where('id', '=', 1).selectAll().executeTakeFirst();
77
73
  * await guard.checkDelete('posts', existingPost);
78
74
  * ```
@@ -93,7 +89,7 @@ export class MutationGuard<DB = unknown> {
93
89
  *
94
90
  * @example
95
91
  * ```typescript
96
- * const guard = new MutationGuard(registry, db);
92
+ * const guard = new MutationGuard(registry);
97
93
  * const post = await db.selectFrom('posts').where('id', '=', 1).selectAll().executeTakeFirst();
98
94
  * const canRead = await guard.checkRead('posts', post);
99
95
  * ```
@@ -106,7 +102,8 @@ export class MutationGuard<DB = unknown> {
106
102
  await this.checkMutation(table, 'read', row);
107
103
  return true;
108
104
  } catch (error) {
109
- if (error instanceof RLSPolicyViolation) {
105
+ // Both policy violations and evaluation errors result in denial
106
+ if (error instanceof RLSPolicyViolation || error instanceof RLSPolicyEvaluationError) {
110
107
  return false;
111
108
  }
112
109
  throw error;
@@ -132,7 +129,8 @@ export class MutationGuard<DB = unknown> {
132
129
  await this.checkMutation(table, operation, row, data);
133
130
  return true;
134
131
  } catch (error) {
135
- if (error instanceof RLSPolicyViolation) {
132
+ // Both policy violations and evaluation errors result in denial
133
+ if (error instanceof RLSPolicyViolation || error instanceof RLSPolicyEvaluationError) {
136
134
  return false;
137
135
  }
138
136
  throw error;
@@ -188,7 +186,7 @@ export class MutationGuard<DB = unknown> {
188
186
 
189
187
  // All validate policies must pass
190
188
  for (const validate of validates) {
191
- const result = await this.evaluatePolicy(validate.evaluate, evalCtx);
189
+ const result = await this.evaluatePolicy(validate.evaluate, evalCtx, validate.name);
192
190
  if (!result) {
193
191
  return false;
194
192
  }
@@ -236,7 +234,7 @@ export class MutationGuard<DB = unknown> {
236
234
  const denies = this.registry.getDenies(table, operation);
237
235
  for (const deny of denies) {
238
236
  const evalCtx = this.createEvalContext(ctx, table, operation, row, data);
239
- const result = await this.evaluatePolicy(deny.evaluate, evalCtx);
237
+ const result = await this.evaluatePolicy(deny.evaluate, evalCtx, deny.name);
240
238
 
241
239
  if (result) {
242
240
  throw new RLSPolicyViolation(
@@ -252,7 +250,7 @@ export class MutationGuard<DB = unknown> {
252
250
  const validates = this.registry.getValidates(table, operation);
253
251
  for (const validate of validates) {
254
252
  const evalCtx = this.createEvalContext(ctx, table, operation, row, data);
255
- const result = await this.evaluatePolicy(validate.evaluate, evalCtx);
253
+ const result = await this.evaluatePolicy(validate.evaluate, evalCtx, validate.name);
256
254
 
257
255
  if (!result) {
258
256
  throw new RLSPolicyViolation(
@@ -282,7 +280,7 @@ export class MutationGuard<DB = unknown> {
282
280
 
283
281
  for (const allow of allows) {
284
282
  const evalCtx = this.createEvalContext(ctx, table, operation, row, data);
285
- const result = await this.evaluatePolicy(allow.evaluate, evalCtx);
283
+ const result = await this.evaluatePolicy(allow.evaluate, evalCtx, allow.name);
286
284
 
287
285
  if (result) {
288
286
  allowed = true;
@@ -316,26 +314,37 @@ export class MutationGuard<DB = unknown> {
316
314
  data,
317
315
  table,
318
316
  operation,
319
- metadata: ctx.meta,
317
+ ...(ctx.meta !== undefined && { meta: ctx.meta as Record<string, unknown> }),
320
318
  };
321
319
  }
322
320
 
323
321
  /**
324
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
325
329
  */
326
330
  private async evaluatePolicy(
327
331
  condition: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>,
328
- evalCtx: PolicyEvaluationContext
332
+ evalCtx: PolicyEvaluationContext,
333
+ policyName?: string
329
334
  ): Promise<boolean> {
330
335
  try {
331
336
  const result = condition(evalCtx);
332
337
  return result instanceof Promise ? await result : result;
333
338
  } catch (error) {
334
- // Policy evaluation errors are treated as denial
335
- throw new RLSPolicyViolation(
336
- evalCtx.operation,
337
- evalCtx.table,
338
- `Policy evaluation error: ${error instanceof Error ? error.message : 'Unknown error'}`
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(
343
+ evalCtx.operation ?? 'unknown',
344
+ evalCtx.table ?? 'unknown',
345
+ error instanceof Error ? error.message : 'Unknown error',
346
+ policyName,
347
+ originalError
339
348
  );
340
349
  }
341
350
  }
@@ -350,7 +359,7 @@ export class MutationGuard<DB = unknown> {
350
359
  *
351
360
  * @example
352
361
  * ```typescript
353
- * const guard = new MutationGuard(registry, db);
362
+ * const guard = new MutationGuard(registry);
354
363
  * const allPosts = await db.selectFrom('posts').selectAll().execute();
355
364
  * const accessiblePosts = await guard.filterRows('posts', allPosts);
356
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);