@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.
@@ -0,0 +1,2 @@
1
+ export { SelectTransformer } from './select.js';
2
+ export { MutationGuard } from './mutation.js';
@@ -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
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Utility functions for RLS
3
+ */
4
+
5
+ export {
6
+ createEvaluationContext,
7
+ isAsyncFunction,
8
+ safeEvaluate,
9
+ deepMerge,
10
+ hashString,
11
+ normalizeOperations,
12
+ } from './helpers.js';