@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/README.md +650 -780
- package/dist/index.d.ts +38 -3
- package/dist/index.js +119 -26
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
- package/src/errors.ts +82 -0
- package/src/index.ts +2 -1
- package/src/plugin.ts +48 -10
- package/src/transformer/mutation.ts +30 -22
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @kysera/rls - Row-Level Security Plugin for Kysera
|
|
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
|
|
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
|
|
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.
|
|
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>(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
336
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|