@kysera/rls 0.5.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/LICENSE +21 -0
- package/README.md +1341 -0
- package/dist/index.d.ts +705 -0
- package/dist/index.js +1471 -0
- package/dist/index.js.map +1 -0
- package/dist/native/index.d.ts +91 -0
- package/dist/native/index.js +253 -0
- package/dist/native/index.js.map +1 -0
- package/dist/types-Dtg6Lt1k.d.ts +633 -0
- package/package.json +93 -0
- package/src/context/index.ts +9 -0
- package/src/context/manager.ts +203 -0
- package/src/context/storage.ts +8 -0
- package/src/context/types.ts +5 -0
- package/src/errors.ts +280 -0
- package/src/index.ts +95 -0
- package/src/native/README.md +315 -0
- package/src/native/index.ts +11 -0
- package/src/native/migration.ts +92 -0
- package/src/native/postgres.ts +263 -0
- package/src/plugin.ts +464 -0
- package/src/policy/builder.ts +215 -0
- package/src/policy/index.ts +10 -0
- package/src/policy/registry.ts +403 -0
- package/src/policy/schema.ts +257 -0
- package/src/policy/types.ts +742 -0
- package/src/transformer/index.ts +2 -0
- package/src/transformer/mutation.ts +372 -0
- package/src/transformer/select.ts +150 -0
- package/src/utils/helpers.ts +139 -0
- package/src/utils/index.ts +12 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutation Guard
|
|
3
|
+
* Validates CREATE, UPDATE, DELETE operations against RLS policies
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Kysely } from 'kysely';
|
|
7
|
+
import type { PolicyRegistry } from '../policy/registry.js';
|
|
8
|
+
import type { PolicyEvaluationContext, Operation } from '../policy/types.js';
|
|
9
|
+
import type { RLSContext } from '../context/types.js';
|
|
10
|
+
import { rlsContext } from '../context/manager.js';
|
|
11
|
+
import { RLSPolicyViolation } from '../errors.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mutation guard
|
|
15
|
+
* Validates mutations (CREATE, UPDATE, DELETE) against allow/deny/validate policies
|
|
16
|
+
*/
|
|
17
|
+
export class MutationGuard<DB = unknown> {
|
|
18
|
+
constructor(
|
|
19
|
+
private registry: PolicyRegistry<DB>,
|
|
20
|
+
private executor?: Kysely<DB>
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if CREATE operation is allowed
|
|
25
|
+
*
|
|
26
|
+
* @param table - Table name
|
|
27
|
+
* @param data - Data being inserted
|
|
28
|
+
* @throws RLSPolicyViolation if access is denied
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* const guard = new MutationGuard(registry, db);
|
|
33
|
+
* await guard.checkCreate('posts', { title: 'Hello', tenant_id: 1 });
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
async checkCreate(
|
|
37
|
+
table: string,
|
|
38
|
+
data: Record<string, unknown>
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
await this.checkMutation(table, 'create', undefined, data);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if UPDATE operation is allowed
|
|
45
|
+
*
|
|
46
|
+
* @param table - Table name
|
|
47
|
+
* @param existingRow - Current row data
|
|
48
|
+
* @param data - Data being updated
|
|
49
|
+
* @throws RLSPolicyViolation if access is denied
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* const guard = new MutationGuard(registry, db);
|
|
54
|
+
* const existingPost = await db.selectFrom('posts').where('id', '=', 1).selectAll().executeTakeFirst();
|
|
55
|
+
* await guard.checkUpdate('posts', existingPost, { title: 'Updated' });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
async checkUpdate(
|
|
59
|
+
table: string,
|
|
60
|
+
existingRow: Record<string, unknown>,
|
|
61
|
+
data: Record<string, unknown>
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
await this.checkMutation(table, 'update', existingRow, data);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if DELETE operation is allowed
|
|
68
|
+
*
|
|
69
|
+
* @param table - Table name
|
|
70
|
+
* @param existingRow - Row to be deleted
|
|
71
|
+
* @throws RLSPolicyViolation if access is denied
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```typescript
|
|
75
|
+
* const guard = new MutationGuard(registry, db);
|
|
76
|
+
* const existingPost = await db.selectFrom('posts').where('id', '=', 1).selectAll().executeTakeFirst();
|
|
77
|
+
* await guard.checkDelete('posts', existingPost);
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
async checkDelete(
|
|
81
|
+
table: string,
|
|
82
|
+
existingRow: Record<string, unknown>
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
await this.checkMutation(table, 'delete', existingRow);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if READ operation is allowed on a specific row
|
|
89
|
+
*
|
|
90
|
+
* @param table - Table name
|
|
91
|
+
* @param row - Row to check access for
|
|
92
|
+
* @returns true if access is allowed
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```typescript
|
|
96
|
+
* const guard = new MutationGuard(registry, db);
|
|
97
|
+
* const post = await db.selectFrom('posts').where('id', '=', 1).selectAll().executeTakeFirst();
|
|
98
|
+
* const canRead = await guard.checkRead('posts', post);
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
async checkRead(
|
|
102
|
+
table: string,
|
|
103
|
+
row: Record<string, unknown>
|
|
104
|
+
): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
await this.checkMutation(table, 'read', row);
|
|
107
|
+
return true;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof RLSPolicyViolation) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Generic check for any operation (helper for testing)
|
|
118
|
+
*
|
|
119
|
+
* @param operation - Operation type
|
|
120
|
+
* @param table - Table name
|
|
121
|
+
* @param row - Existing row (for UPDATE/DELETE/READ)
|
|
122
|
+
* @param data - New data (for CREATE/UPDATE)
|
|
123
|
+
* @returns true if access is allowed, false otherwise
|
|
124
|
+
*/
|
|
125
|
+
async canMutate(
|
|
126
|
+
operation: Operation,
|
|
127
|
+
table: string,
|
|
128
|
+
row?: Record<string, unknown>,
|
|
129
|
+
data?: Record<string, unknown>
|
|
130
|
+
): Promise<boolean> {
|
|
131
|
+
try {
|
|
132
|
+
await this.checkMutation(table, operation, row, data);
|
|
133
|
+
return true;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error instanceof RLSPolicyViolation) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Validate mutation data (for validate policies)
|
|
144
|
+
*
|
|
145
|
+
* @param operation - Operation type
|
|
146
|
+
* @param table - Table name
|
|
147
|
+
* @param data - New data (for CREATE/UPDATE)
|
|
148
|
+
* @param row - Existing row (for UPDATE)
|
|
149
|
+
* @returns true if valid, false otherwise
|
|
150
|
+
*/
|
|
151
|
+
async validateMutation(
|
|
152
|
+
operation: Operation,
|
|
153
|
+
table: string,
|
|
154
|
+
data: Record<string, unknown>,
|
|
155
|
+
row?: Record<string, unknown>
|
|
156
|
+
): Promise<boolean> {
|
|
157
|
+
const ctx = rlsContext.getContextOrNull();
|
|
158
|
+
if (!ctx) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// System users bypass validation
|
|
163
|
+
if (ctx.auth.isSystem) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check skipFor roles
|
|
168
|
+
const skipFor = this.registry.getSkipFor(table);
|
|
169
|
+
if (skipFor.some(role => ctx.auth.roles.includes(role))) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Get validate policies for this operation
|
|
174
|
+
const validates = this.registry.getValidates(table, operation);
|
|
175
|
+
if (validates.length === 0) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Build evaluation context
|
|
180
|
+
const evalCtx: PolicyEvaluationContext = {
|
|
181
|
+
auth: ctx.auth,
|
|
182
|
+
row: row,
|
|
183
|
+
data: data,
|
|
184
|
+
table: table,
|
|
185
|
+
operation: operation,
|
|
186
|
+
...(ctx.meta !== undefined && { meta: ctx.meta as Record<string, unknown> }),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// All validate policies must pass
|
|
190
|
+
for (const validate of validates) {
|
|
191
|
+
const result = await this.evaluatePolicy(validate.evaluate, evalCtx);
|
|
192
|
+
if (!result) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Check mutation against RLS policies
|
|
202
|
+
*
|
|
203
|
+
* @param table - Table name
|
|
204
|
+
* @param operation - Operation type
|
|
205
|
+
* @param row - Existing row (for UPDATE/DELETE/READ)
|
|
206
|
+
* @param data - New data (for CREATE/UPDATE)
|
|
207
|
+
* @throws RLSPolicyViolation if access is denied
|
|
208
|
+
*/
|
|
209
|
+
private async checkMutation(
|
|
210
|
+
table: string,
|
|
211
|
+
operation: Operation,
|
|
212
|
+
row?: Record<string, unknown>,
|
|
213
|
+
data?: Record<string, unknown>
|
|
214
|
+
): Promise<void> {
|
|
215
|
+
const ctx = rlsContext.getContextOrNull();
|
|
216
|
+
if (!ctx) {
|
|
217
|
+
throw new RLSPolicyViolation(
|
|
218
|
+
operation,
|
|
219
|
+
table,
|
|
220
|
+
'No RLS context available'
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// System users bypass all checks
|
|
225
|
+
if (ctx.auth.isSystem) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Check if user role should skip RLS
|
|
230
|
+
const skipFor = this.registry.getSkipFor(table);
|
|
231
|
+
if (skipFor.some(role => ctx.auth.roles.includes(role))) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Evaluate deny policies first (they override allows)
|
|
236
|
+
const denies = this.registry.getDenies(table, operation);
|
|
237
|
+
for (const deny of denies) {
|
|
238
|
+
const evalCtx = this.createEvalContext(ctx, table, operation, row, data);
|
|
239
|
+
const result = await this.evaluatePolicy(deny.evaluate, evalCtx);
|
|
240
|
+
|
|
241
|
+
if (result) {
|
|
242
|
+
throw new RLSPolicyViolation(
|
|
243
|
+
operation,
|
|
244
|
+
table,
|
|
245
|
+
`Denied by policy: ${deny.name}`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Evaluate validate policies (for CREATE/UPDATE)
|
|
251
|
+
if ((operation === 'create' || operation === 'update') && data) {
|
|
252
|
+
const validates = this.registry.getValidates(table, operation);
|
|
253
|
+
for (const validate of validates) {
|
|
254
|
+
const evalCtx = this.createEvalContext(ctx, table, operation, row, data);
|
|
255
|
+
const result = await this.evaluatePolicy(validate.evaluate, evalCtx);
|
|
256
|
+
|
|
257
|
+
if (!result) {
|
|
258
|
+
throw new RLSPolicyViolation(
|
|
259
|
+
operation,
|
|
260
|
+
table,
|
|
261
|
+
`Validation failed: ${validate.name}`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Evaluate allow policies
|
|
268
|
+
const allows = this.registry.getAllows(table, operation);
|
|
269
|
+
const defaultDeny = this.registry.hasDefaultDeny(table);
|
|
270
|
+
|
|
271
|
+
if (defaultDeny && allows.length === 0) {
|
|
272
|
+
throw new RLSPolicyViolation(
|
|
273
|
+
operation,
|
|
274
|
+
table,
|
|
275
|
+
'No allow policies defined (default deny)'
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (allows.length > 0) {
|
|
280
|
+
// At least one allow policy must pass
|
|
281
|
+
let allowed = false;
|
|
282
|
+
|
|
283
|
+
for (const allow of allows) {
|
|
284
|
+
const evalCtx = this.createEvalContext(ctx, table, operation, row, data);
|
|
285
|
+
const result = await this.evaluatePolicy(allow.evaluate, evalCtx);
|
|
286
|
+
|
|
287
|
+
if (result) {
|
|
288
|
+
allowed = true;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!allowed) {
|
|
294
|
+
throw new RLSPolicyViolation(
|
|
295
|
+
operation,
|
|
296
|
+
table,
|
|
297
|
+
'No allow policies matched'
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Create policy evaluation context
|
|
305
|
+
*/
|
|
306
|
+
private createEvalContext(
|
|
307
|
+
ctx: RLSContext,
|
|
308
|
+
table: string,
|
|
309
|
+
operation: Operation,
|
|
310
|
+
row?: Record<string, unknown>,
|
|
311
|
+
data?: Record<string, unknown>
|
|
312
|
+
): PolicyEvaluationContext {
|
|
313
|
+
return {
|
|
314
|
+
auth: ctx.auth,
|
|
315
|
+
row,
|
|
316
|
+
data,
|
|
317
|
+
table,
|
|
318
|
+
operation,
|
|
319
|
+
metadata: ctx.meta,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Evaluate a policy condition
|
|
325
|
+
*/
|
|
326
|
+
private async evaluatePolicy(
|
|
327
|
+
condition: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>,
|
|
328
|
+
evalCtx: PolicyEvaluationContext
|
|
329
|
+
): Promise<boolean> {
|
|
330
|
+
try {
|
|
331
|
+
const result = condition(evalCtx);
|
|
332
|
+
return result instanceof Promise ? await result : result;
|
|
333
|
+
} 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
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Filter an array of rows, keeping only accessible ones
|
|
345
|
+
* Useful for post-query filtering when query-level filtering is not possible
|
|
346
|
+
*
|
|
347
|
+
* @param table - Table name
|
|
348
|
+
* @param rows - Array of rows to filter
|
|
349
|
+
* @returns Filtered array containing only accessible rows
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* ```typescript
|
|
353
|
+
* const guard = new MutationGuard(registry, db);
|
|
354
|
+
* const allPosts = await db.selectFrom('posts').selectAll().execute();
|
|
355
|
+
* const accessiblePosts = await guard.filterRows('posts', allPosts);
|
|
356
|
+
* ```
|
|
357
|
+
*/
|
|
358
|
+
async filterRows<T extends Record<string, unknown>>(
|
|
359
|
+
table: string,
|
|
360
|
+
rows: T[]
|
|
361
|
+
): Promise<T[]> {
|
|
362
|
+
const results: T[] = [];
|
|
363
|
+
|
|
364
|
+
for (const row of rows) {
|
|
365
|
+
if (await this.checkRead(table, row)) {
|
|
366
|
+
results.push(row);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return results;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SELECT Query Transformer
|
|
3
|
+
* Applies filter policies to SELECT queries by adding WHERE conditions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SelectQueryBuilder } from 'kysely';
|
|
7
|
+
import type { PolicyRegistry } from '../policy/registry.js';
|
|
8
|
+
import type { PolicyEvaluationContext } from '../policy/types.js';
|
|
9
|
+
import type { RLSContext } from '../context/types.js';
|
|
10
|
+
import { rlsContext } from '../context/manager.js';
|
|
11
|
+
import { RLSError, RLSErrorCodes } from '../errors.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* SELECT query transformer
|
|
15
|
+
* Applies filter policies to SELECT queries by adding WHERE conditions
|
|
16
|
+
*/
|
|
17
|
+
export class SelectTransformer<DB = unknown> {
|
|
18
|
+
constructor(private registry: PolicyRegistry<DB>) {}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Transform a SELECT query by applying filter policies
|
|
22
|
+
*
|
|
23
|
+
* @param qb - The query builder to transform
|
|
24
|
+
* @param table - Table name being queried
|
|
25
|
+
* @returns Transformed query builder with RLS filters applied
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const transformer = new SelectTransformer(registry);
|
|
30
|
+
* let query = db.selectFrom('posts').selectAll();
|
|
31
|
+
* query = transformer.transform(query, 'posts');
|
|
32
|
+
* // Query now includes WHERE conditions from RLS policies
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
transform<TB extends keyof DB & string, O>(
|
|
36
|
+
qb: SelectQueryBuilder<DB, TB, O>,
|
|
37
|
+
table: string
|
|
38
|
+
): SelectQueryBuilder<DB, TB, O> {
|
|
39
|
+
// Check for context
|
|
40
|
+
const ctx = rlsContext.getContextOrNull();
|
|
41
|
+
if (!ctx) {
|
|
42
|
+
// No context - return original query
|
|
43
|
+
// In production, you might want to throw an error here
|
|
44
|
+
return qb;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check if system user (bypass RLS)
|
|
48
|
+
if (ctx.auth.isSystem) {
|
|
49
|
+
return qb;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check if user role should skip RLS
|
|
53
|
+
const skipFor = this.registry.getSkipFor(table);
|
|
54
|
+
if (skipFor.some(role => ctx.auth.roles.includes(role))) {
|
|
55
|
+
return qb;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Get filter policies for this table
|
|
59
|
+
const filters = this.registry.getFilters(table);
|
|
60
|
+
if (filters.length === 0) {
|
|
61
|
+
return qb;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Apply each filter as WHERE condition
|
|
65
|
+
let result = qb;
|
|
66
|
+
for (const filter of filters) {
|
|
67
|
+
const conditions = this.evaluateFilter(filter, ctx, table);
|
|
68
|
+
result = this.applyConditions(result, conditions, table);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Evaluate a filter policy to get WHERE conditions
|
|
76
|
+
*
|
|
77
|
+
* @param filter - The filter policy to evaluate
|
|
78
|
+
* @param ctx - RLS context
|
|
79
|
+
* @param table - Table name
|
|
80
|
+
* @returns WHERE clause conditions as key-value pairs
|
|
81
|
+
*/
|
|
82
|
+
private evaluateFilter(
|
|
83
|
+
filter: { name: string; getConditions: (ctx: PolicyEvaluationContext) => Record<string, unknown> | Promise<Record<string, unknown>> },
|
|
84
|
+
ctx: RLSContext,
|
|
85
|
+
table: string
|
|
86
|
+
): Record<string, unknown> {
|
|
87
|
+
const evalCtx: PolicyEvaluationContext = {
|
|
88
|
+
auth: ctx.auth,
|
|
89
|
+
...(ctx.meta !== undefined && { meta: ctx.meta as Record<string, unknown> }),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const result = filter.getConditions(evalCtx);
|
|
93
|
+
|
|
94
|
+
// Note: If async filters are needed, this method signature would need to change
|
|
95
|
+
// For now, we assume synchronous filter evaluation
|
|
96
|
+
if (result instanceof Promise) {
|
|
97
|
+
throw new RLSError(
|
|
98
|
+
`Async filter policies are not supported in SELECT transformers. ` +
|
|
99
|
+
`Filter '${filter.name}' on table '${table}' returned a Promise. ` +
|
|
100
|
+
`Use synchronous conditions for filter policies.`,
|
|
101
|
+
RLSErrorCodes.RLS_POLICY_INVALID
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Apply filter conditions to query builder
|
|
110
|
+
*
|
|
111
|
+
* @param qb - Query builder to modify
|
|
112
|
+
* @param conditions - WHERE clause conditions
|
|
113
|
+
* @param table - Table name (for qualified column names)
|
|
114
|
+
* @returns Modified query builder
|
|
115
|
+
*/
|
|
116
|
+
private applyConditions<TB extends keyof DB & string, O>(
|
|
117
|
+
qb: SelectQueryBuilder<DB, TB, O>,
|
|
118
|
+
conditions: Record<string, unknown>,
|
|
119
|
+
table: string
|
|
120
|
+
): SelectQueryBuilder<DB, TB, O> {
|
|
121
|
+
let result = qb;
|
|
122
|
+
|
|
123
|
+
for (const [column, value] of Object.entries(conditions)) {
|
|
124
|
+
// Use table-qualified column name to avoid ambiguity in joins
|
|
125
|
+
const qualifiedColumn = `${table}.${column}` as any;
|
|
126
|
+
|
|
127
|
+
if (value === null) {
|
|
128
|
+
// NULL check
|
|
129
|
+
result = result.where(qualifiedColumn, 'is', null);
|
|
130
|
+
} else if (value === undefined) {
|
|
131
|
+
// Skip undefined values
|
|
132
|
+
continue;
|
|
133
|
+
} else if (Array.isArray(value)) {
|
|
134
|
+
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);
|
|
138
|
+
} else {
|
|
139
|
+
// IN clause for array values
|
|
140
|
+
result = result.where(qualifiedColumn, 'in', value as any);
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
// Equality check
|
|
144
|
+
result = result.where(qualifiedColumn, '=', value as any);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility helper functions for RLS
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
RLSContext,
|
|
7
|
+
PolicyEvaluationContext,
|
|
8
|
+
Operation,
|
|
9
|
+
} from '../policy/types.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a policy evaluation context from RLS context
|
|
13
|
+
*/
|
|
14
|
+
export function createEvaluationContext<
|
|
15
|
+
TRow = unknown,
|
|
16
|
+
TData = unknown
|
|
17
|
+
>(
|
|
18
|
+
rlsCtx: RLSContext,
|
|
19
|
+
options?: {
|
|
20
|
+
row?: TRow;
|
|
21
|
+
data?: TData;
|
|
22
|
+
}
|
|
23
|
+
): PolicyEvaluationContext<unknown, TRow, TData> {
|
|
24
|
+
const ctx: PolicyEvaluationContext<unknown, TRow, TData> = {
|
|
25
|
+
auth: rlsCtx.auth,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (options?.row !== undefined) {
|
|
29
|
+
ctx.row = options.row;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (options?.data !== undefined) {
|
|
33
|
+
ctx.data = options.data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (rlsCtx.request !== undefined) {
|
|
37
|
+
ctx.request = rlsCtx.request;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (rlsCtx.meta !== undefined) {
|
|
41
|
+
ctx.meta = rlsCtx.meta as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return ctx;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if a condition function is async
|
|
49
|
+
*/
|
|
50
|
+
export function isAsyncFunction(fn: unknown): fn is (...args: unknown[]) => Promise<unknown> {
|
|
51
|
+
return fn instanceof Function && fn.constructor.name === 'AsyncFunction';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Safely evaluate a policy condition
|
|
56
|
+
*/
|
|
57
|
+
export async function safeEvaluate<T>(
|
|
58
|
+
fn: () => T | Promise<T>,
|
|
59
|
+
defaultValue: T
|
|
60
|
+
): Promise<T> {
|
|
61
|
+
try {
|
|
62
|
+
const result = fn();
|
|
63
|
+
if (result instanceof Promise) {
|
|
64
|
+
return await result;
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// Expected failure during policy evaluation - return default value
|
|
69
|
+
// Logger not available in this utility function, error is handled gracefully
|
|
70
|
+
return defaultValue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Deep merge two objects
|
|
76
|
+
*/
|
|
77
|
+
export function deepMerge<T extends Record<string, unknown>>(
|
|
78
|
+
target: T,
|
|
79
|
+
source: Partial<T>
|
|
80
|
+
): T {
|
|
81
|
+
const result = { ...target };
|
|
82
|
+
|
|
83
|
+
for (const key of Object.keys(source) as (keyof T)[]) {
|
|
84
|
+
const sourceValue = source[key];
|
|
85
|
+
const targetValue = result[key];
|
|
86
|
+
|
|
87
|
+
if (
|
|
88
|
+
sourceValue !== undefined &&
|
|
89
|
+
typeof sourceValue === 'object' &&
|
|
90
|
+
sourceValue !== null &&
|
|
91
|
+
!Array.isArray(sourceValue) &&
|
|
92
|
+
typeof targetValue === 'object' &&
|
|
93
|
+
targetValue !== null &&
|
|
94
|
+
!Array.isArray(targetValue)
|
|
95
|
+
) {
|
|
96
|
+
result[key] = deepMerge(
|
|
97
|
+
targetValue as Record<string, unknown>,
|
|
98
|
+
sourceValue as Record<string, unknown>
|
|
99
|
+
) as T[keyof T];
|
|
100
|
+
} else if (sourceValue !== undefined) {
|
|
101
|
+
result[key] = sourceValue as T[keyof T];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create a simple hash for cache keys
|
|
110
|
+
*/
|
|
111
|
+
export function hashString(str: string): string {
|
|
112
|
+
let hash = 0;
|
|
113
|
+
for (let i = 0; i < str.length; i++) {
|
|
114
|
+
const char = str.charCodeAt(i);
|
|
115
|
+
hash = ((hash << 5) - hash) + char;
|
|
116
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
117
|
+
}
|
|
118
|
+
return hash.toString(36);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Normalize operations to array format
|
|
123
|
+
*/
|
|
124
|
+
export function normalizeOperations(
|
|
125
|
+
operation: Operation | Operation[]
|
|
126
|
+
): Operation[] {
|
|
127
|
+
if (Array.isArray(operation)) {
|
|
128
|
+
if (operation.includes('all')) {
|
|
129
|
+
return ['read', 'create', 'update', 'delete'];
|
|
130
|
+
}
|
|
131
|
+
return operation;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (operation === 'all') {
|
|
135
|
+
return ['read', 'create', 'update', 'delete'];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return [operation];
|
|
139
|
+
}
|