@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/README.md +48 -22
- package/dist/index.d.ts +38 -3
- package/dist/index.js +100 -24
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +1 -1
- package/dist/{types-Dtg6Lt1k.d.ts → types-6eCXh_Jd.d.ts} +10 -0
- package/package.json +3 -3
- package/src/errors.ts +82 -0
- package/src/index.ts +1 -0
- package/src/plugin.ts +2 -2
- package/src/policy/types.ts +12 -0
- package/src/transformer/mutation.ts +33 -24
- package/src/transformer/select.ts +18 -3
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
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>(
|
|
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
|
|
151
|
+
mutationGuard = new MutationGuard<DB>(registry);
|
|
152
152
|
|
|
153
153
|
logger.info?.('[RLS] RLS plugin initialized successfully');
|
|
154
154
|
},
|
package/src/policy/types.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
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
|
-
|
|
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);
|