@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/src/plugin.ts ADDED
@@ -0,0 +1,464 @@
1
+ /**
2
+ * RLS Plugin for Kysera Repository
3
+ *
4
+ * Implements Row-Level Security as a Kysera plugin, providing:
5
+ * - Automatic query filtering for SELECT operations
6
+ * - Policy enforcement for CREATE, UPDATE, DELETE operations
7
+ * - Repository method extensions for RLS-aware operations
8
+ * - System context bypass for privileged operations
9
+ *
10
+ * @module @kysera/rls
11
+ */
12
+
13
+ import type { Plugin, QueryBuilderContext, AnyQueryBuilder } from '@kysera/repository';
14
+ import type { Kysely } from 'kysely';
15
+ import type { RLSSchema, Operation } from './policy/types.js';
16
+ import { PolicyRegistry } from './policy/registry.js';
17
+ import { SelectTransformer } from './transformer/select.js';
18
+ import { MutationGuard } from './transformer/mutation.js';
19
+ import { rlsContext } from './context/manager.js';
20
+ import { RLSContextError, RLSPolicyViolation, RLSError, RLSErrorCodes } from './errors.js';
21
+ import { silentLogger, type KyseraLogger } from '@kysera/core';
22
+
23
+ /**
24
+ * RLS Plugin configuration options
25
+ */
26
+ export interface RLSPluginOptions<DB = unknown> {
27
+ /** RLS policy schema */
28
+ schema: RLSSchema<DB>;
29
+
30
+ /** Tables to skip RLS for (always bypass policies) */
31
+ skipTables?: string[];
32
+
33
+ /** Roles that bypass RLS entirely (e.g., ['admin', 'superuser']) */
34
+ bypassRoles?: string[];
35
+
36
+ /** Logger instance for RLS operations */
37
+ logger?: KyseraLogger;
38
+
39
+ /** Require RLS context for all operations (throws if missing) */
40
+ requireContext?: boolean;
41
+
42
+ /** Enable audit logging of policy decisions */
43
+ auditDecisions?: boolean;
44
+
45
+ /** Custom error handler for policy violations */
46
+ onViolation?: (violation: RLSPolicyViolation) => void;
47
+ }
48
+
49
+ /**
50
+ * Base repository interface for type safety
51
+ * @internal
52
+ */
53
+ interface BaseRepository {
54
+ tableName: string;
55
+ executor: Kysely<Record<string, unknown>>;
56
+ findById?: (id: unknown) => Promise<unknown>;
57
+ create?: (data: unknown) => Promise<unknown>;
58
+ update?: (id: unknown, data: unknown) => Promise<unknown>;
59
+ delete?: (id: unknown) => Promise<unknown>;
60
+ }
61
+
62
+ /**
63
+ * Create RLS plugin for Kysera
64
+ *
65
+ * The RLS plugin provides declarative row-level security for your database operations.
66
+ * It automatically filters SELECT queries and validates mutations (CREATE, UPDATE, DELETE)
67
+ * against your policy schema.
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * import { rlsPlugin, defineRLSSchema, allow, filter } from '@kysera/rls';
72
+ * import { createORM } from '@kysera/repository';
73
+ *
74
+ * // Define your RLS schema
75
+ * const schema = defineRLSSchema<Database>({
76
+ * resources: {
77
+ * policies: [
78
+ * // Filter reads by tenant
79
+ * filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
80
+ * // Allow updates for resource owners
81
+ * allow('update', ctx => ctx.auth.userId === ctx.row.owner_id),
82
+ * // Validate creates belong to user's tenant
83
+ * validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId),
84
+ * ],
85
+ * },
86
+ * });
87
+ *
88
+ * // Create ORM with RLS plugin
89
+ * const orm = await createORM(db, [
90
+ * rlsPlugin({ schema }),
91
+ * ]);
92
+ *
93
+ * // Use within RLS context
94
+ * await rlsContext.runAsync(
95
+ * {
96
+ * auth: { userId: 1, tenantId: 100, roles: ['user'], isSystem: false },
97
+ * timestamp: new Date(),
98
+ * },
99
+ * async () => {
100
+ * // All queries automatically filtered by tenant_id
101
+ * const resources = await orm.resources.findAll();
102
+ * }
103
+ * );
104
+ * ```
105
+ *
106
+ * @param options - Plugin configuration options
107
+ * @returns Kysera plugin instance
108
+ */
109
+ export function rlsPlugin<DB>(options: RLSPluginOptions<DB>): Plugin {
110
+ const {
111
+ schema,
112
+ skipTables = [],
113
+ bypassRoles = [],
114
+ logger = silentLogger,
115
+ requireContext = false,
116
+ auditDecisions = false,
117
+ onViolation,
118
+ } = options;
119
+
120
+ // Registry and transformers (initialized in onInit)
121
+ let registry: PolicyRegistry<DB>;
122
+ let selectTransformer: SelectTransformer<DB>;
123
+ let mutationGuard: MutationGuard<DB>;
124
+
125
+ return {
126
+ name: '@kysera/rls',
127
+ version: '0.5.1',
128
+
129
+ // Run after soft-delete (priority 0), before audit
130
+ priority: 50,
131
+
132
+ // No dependencies by default
133
+ dependencies: [],
134
+
135
+ /**
136
+ * Initialize plugin - compile policies
137
+ */
138
+ async onInit<TDB>(executor: Kysely<TDB>): Promise<void> {
139
+ logger.info?.('[RLS] Initializing RLS plugin', {
140
+ tables: Object.keys(schema).length,
141
+ skipTables: skipTables.length,
142
+ bypassRoles: bypassRoles.length,
143
+ });
144
+
145
+ // Create and compile registry
146
+ registry = new PolicyRegistry<DB>(schema);
147
+ registry.validate();
148
+
149
+ // Create transformers
150
+ selectTransformer = new SelectTransformer<DB>(registry);
151
+ mutationGuard = new MutationGuard<DB>(registry, executor as unknown as Kysely<DB>);
152
+
153
+ logger.info?.('[RLS] RLS plugin initialized successfully');
154
+ },
155
+
156
+ /**
157
+ * Intercept queries to apply RLS filtering
158
+ *
159
+ * This hook is called for every query builder operation. For SELECT queries,
160
+ * it applies filter policies as WHERE conditions. For mutations, it marks
161
+ * that RLS validation is required (performed in extendRepository).
162
+ */
163
+ interceptQuery<QB extends AnyQueryBuilder>(
164
+ qb: QB,
165
+ context: QueryBuilderContext
166
+ ): QB {
167
+ const { operation, table, metadata } = context;
168
+
169
+ // Skip if table is excluded
170
+ if (skipTables.includes(table)) {
171
+ logger.debug?.(`[RLS] Skipping RLS for excluded table: ${table}`);
172
+ return qb;
173
+ }
174
+
175
+ // Skip if explicitly disabled via metadata
176
+ if (metadata['skipRLS'] === true) {
177
+ logger.debug?.(`[RLS] Skipping RLS (explicit skip): ${table}`);
178
+ return qb;
179
+ }
180
+
181
+ // Check for context
182
+ const ctx = rlsContext.getContextOrNull();
183
+
184
+ if (!ctx) {
185
+ if (requireContext) {
186
+ throw new RLSContextError('RLS context required but not found');
187
+ }
188
+ logger.warn?.(`[RLS] No context for ${operation} on ${table}`);
189
+ return qb;
190
+ }
191
+
192
+ // Check if system user (bypass RLS)
193
+ if (ctx.auth.isSystem) {
194
+ logger.debug?.(`[RLS] Bypassing RLS (system user): ${table}`);
195
+ return qb;
196
+ }
197
+
198
+ // Check bypass roles
199
+ if (bypassRoles.some(role => ctx.auth.roles.includes(role))) {
200
+ logger.debug?.(`[RLS] Bypassing RLS (bypass role): ${table}`);
201
+ return qb;
202
+ }
203
+
204
+ // Apply SELECT filtering
205
+ if (operation === 'select') {
206
+ try {
207
+ const transformed = selectTransformer.transform(qb as any, table);
208
+
209
+ if (auditDecisions) {
210
+ logger.info?.('[RLS] Filter applied', {
211
+ table,
212
+ operation,
213
+ userId: ctx.auth.userId,
214
+ });
215
+ }
216
+
217
+ return transformed as QB;
218
+ } catch (error) {
219
+ logger.error?.('[RLS] Error applying filter', { table, error });
220
+ throw error;
221
+ }
222
+ }
223
+
224
+ // For mutations, mark that RLS check is needed (done in extendRepository)
225
+ if (operation === 'insert' || operation === 'update' || operation === 'delete') {
226
+ metadata['__rlsRequired'] = true;
227
+ metadata['__rlsTable'] = table;
228
+ }
229
+
230
+ return qb;
231
+ },
232
+
233
+ /**
234
+ * Extend repository with RLS-aware methods
235
+ *
236
+ * Wraps create, update, and delete methods to enforce RLS policies.
237
+ * Also adds utility methods for bypassing RLS and checking access.
238
+ */
239
+ extendRepository<T extends object>(repo: T): T {
240
+ const baseRepo = repo as unknown as BaseRepository;
241
+
242
+ // Check if it's a valid repository
243
+ if (!('tableName' in baseRepo) || !('executor' in baseRepo)) {
244
+ return repo;
245
+ }
246
+
247
+ const table = baseRepo.tableName;
248
+
249
+ // Skip excluded tables
250
+ if (skipTables.includes(table)) {
251
+ return repo;
252
+ }
253
+
254
+ // Skip if table not in schema
255
+ if (!registry.hasTable(table)) {
256
+ logger.debug?.(`[RLS] Table "${table}" not in RLS schema, skipping`);
257
+ return repo;
258
+ }
259
+
260
+ logger.debug?.(`[RLS] Extending repository for table: ${table}`);
261
+
262
+ // Store original methods
263
+ const originalCreate = baseRepo.create?.bind(baseRepo);
264
+ const originalUpdate = baseRepo.update?.bind(baseRepo);
265
+ const originalDelete = baseRepo.delete?.bind(baseRepo);
266
+ const originalFindById = baseRepo.findById?.bind(baseRepo);
267
+
268
+ const extendedRepo = {
269
+ ...baseRepo,
270
+
271
+ /**
272
+ * Wrapped create with RLS check
273
+ */
274
+ async create(data: unknown): Promise<unknown> {
275
+ if (!originalCreate) {
276
+ throw new RLSError('Repository does not support create operation', RLSErrorCodes.RLS_POLICY_INVALID);
277
+ }
278
+
279
+ const ctx = rlsContext.getContextOrNull();
280
+
281
+ // Check RLS if context exists and not system/bypass
282
+ if (ctx && !ctx.auth.isSystem &&
283
+ !bypassRoles.some(role => ctx.auth.roles.includes(role))) {
284
+ try {
285
+ await mutationGuard.checkCreate(table, data as Record<string, unknown>);
286
+
287
+ if (auditDecisions) {
288
+ logger.info?.('[RLS] Create allowed', { table, userId: ctx.auth.userId });
289
+ }
290
+ } catch (error) {
291
+ if (error instanceof RLSPolicyViolation) {
292
+ onViolation?.(error);
293
+ if (auditDecisions) {
294
+ logger.warn?.('[RLS] Create denied', {
295
+ table,
296
+ userId: ctx.auth.userId,
297
+ reason: error.reason
298
+ });
299
+ }
300
+ }
301
+ throw error;
302
+ }
303
+ }
304
+
305
+ return originalCreate(data);
306
+ },
307
+
308
+ /**
309
+ * Wrapped update with RLS check
310
+ */
311
+ async update(id: unknown, data: unknown): Promise<unknown> {
312
+ if (!originalUpdate || !originalFindById) {
313
+ throw new RLSError('Repository does not support update operation', RLSErrorCodes.RLS_POLICY_INVALID);
314
+ }
315
+
316
+ const ctx = rlsContext.getContextOrNull();
317
+
318
+ if (ctx && !ctx.auth.isSystem &&
319
+ !bypassRoles.some(role => ctx.auth.roles.includes(role))) {
320
+ // Fetch existing row for policy evaluation
321
+ const existingRow = await originalFindById(id);
322
+ if (!existingRow) {
323
+ // Let the original method handle not found
324
+ return originalUpdate(id, data);
325
+ }
326
+
327
+ try {
328
+ await mutationGuard.checkUpdate(
329
+ table,
330
+ existingRow as Record<string, unknown>,
331
+ data as Record<string, unknown>
332
+ );
333
+
334
+ if (auditDecisions) {
335
+ logger.info?.('[RLS] Update allowed', { table, id, userId: ctx.auth.userId });
336
+ }
337
+ } catch (error) {
338
+ if (error instanceof RLSPolicyViolation) {
339
+ onViolation?.(error);
340
+ if (auditDecisions) {
341
+ logger.warn?.('[RLS] Update denied', {
342
+ table,
343
+ id,
344
+ userId: ctx.auth.userId,
345
+ reason: error.reason
346
+ });
347
+ }
348
+ }
349
+ throw error;
350
+ }
351
+ }
352
+
353
+ return originalUpdate(id, data);
354
+ },
355
+
356
+ /**
357
+ * Wrapped delete with RLS check
358
+ */
359
+ async delete(id: unknown): Promise<unknown> {
360
+ if (!originalDelete || !originalFindById) {
361
+ throw new RLSError('Repository does not support delete operation', RLSErrorCodes.RLS_POLICY_INVALID);
362
+ }
363
+
364
+ const ctx = rlsContext.getContextOrNull();
365
+
366
+ if (ctx && !ctx.auth.isSystem &&
367
+ !bypassRoles.some(role => ctx.auth.roles.includes(role))) {
368
+ // Fetch existing row for policy evaluation
369
+ const existingRow = await originalFindById(id);
370
+ if (!existingRow) {
371
+ // Let the original method handle not found
372
+ return originalDelete(id);
373
+ }
374
+
375
+ try {
376
+ await mutationGuard.checkDelete(table, existingRow as Record<string, unknown>);
377
+
378
+ if (auditDecisions) {
379
+ logger.info?.('[RLS] Delete allowed', { table, id, userId: ctx.auth.userId });
380
+ }
381
+ } catch (error) {
382
+ if (error instanceof RLSPolicyViolation) {
383
+ onViolation?.(error);
384
+ if (auditDecisions) {
385
+ logger.warn?.('[RLS] Delete denied', {
386
+ table,
387
+ id,
388
+ userId: ctx.auth.userId,
389
+ reason: error.reason
390
+ });
391
+ }
392
+ }
393
+ throw error;
394
+ }
395
+ }
396
+
397
+ return originalDelete(id);
398
+ },
399
+
400
+ /**
401
+ * Bypass RLS for specific operation
402
+ * Requires existing context
403
+ *
404
+ * @example
405
+ * ```typescript
406
+ * // Perform operation as system user
407
+ * const result = await repo.withoutRLS(async () => {
408
+ * return repo.findAll(); // No RLS filtering
409
+ * });
410
+ * ```
411
+ */
412
+ async withoutRLS<R>(fn: () => Promise<R>): Promise<R> {
413
+ return rlsContext.asSystemAsync(fn);
414
+ },
415
+
416
+ /**
417
+ * Check if current user can perform operation on a row
418
+ *
419
+ * @example
420
+ * ```typescript
421
+ * const post = await repo.findById(1);
422
+ * const canUpdate = await repo.canAccess('update', post);
423
+ * if (canUpdate) {
424
+ * await repo.update(1, { title: 'New title' });
425
+ * }
426
+ * ```
427
+ */
428
+ async canAccess(operation: Operation, row: Record<string, unknown>): Promise<boolean> {
429
+ const ctx = rlsContext.getContextOrNull();
430
+ if (!ctx) return false;
431
+ if (ctx.auth.isSystem) return true;
432
+ if (bypassRoles.some(role => ctx.auth.roles.includes(role))) return true;
433
+
434
+ try {
435
+ switch (operation) {
436
+ case 'read':
437
+ return await mutationGuard.checkRead(table, row);
438
+ case 'create':
439
+ await mutationGuard.checkCreate(table, row);
440
+ return true;
441
+ case 'update':
442
+ await mutationGuard.checkUpdate(table, row, {});
443
+ return true;
444
+ case 'delete':
445
+ await mutationGuard.checkDelete(table, row);
446
+ return true;
447
+ default:
448
+ return false;
449
+ }
450
+ } catch (error) {
451
+ logger.debug?.('[RLS] Access check failed', {
452
+ table,
453
+ operation,
454
+ error: error instanceof Error ? error.message : String(error)
455
+ });
456
+ return false;
457
+ }
458
+ },
459
+ };
460
+
461
+ return extendedRepo as T;
462
+ },
463
+ };
464
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Fluent policy builders for Row-Level Security
3
+ *
4
+ * Provides intuitive builder functions for creating RLS policies:
5
+ * - allow: Grants access when condition is true
6
+ * - deny: Blocks access when condition is true (overrides allow)
7
+ * - filter: Adds WHERE conditions to SELECT queries
8
+ * - validate: Validates mutation data before execution
9
+ */
10
+
11
+ import type {
12
+ Operation,
13
+ PolicyDefinition,
14
+ PolicyCondition,
15
+ FilterCondition,
16
+ PolicyHints,
17
+ } from './types.js';
18
+
19
+ /**
20
+ * Options for policy definitions
21
+ */
22
+ export interface PolicyOptions {
23
+ /** Policy name for debugging and identification */
24
+ name?: string;
25
+ /** Priority (higher runs first, deny policies default to 100) */
26
+ priority?: number;
27
+ /** Performance optimization hints */
28
+ hints?: PolicyHints;
29
+ }
30
+
31
+ /**
32
+ * Create an allow policy
33
+ * Grants access when condition evaluates to true
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * // Allow users to read their own records
38
+ * allow('read', ctx => ctx.auth.userId === ctx.row.userId)
39
+ *
40
+ * // Allow admins to do everything
41
+ * allow('all', ctx => ctx.auth.roles.includes('admin'))
42
+ *
43
+ * // Allow with multiple operations
44
+ * allow(['read', 'update'], ctx => ctx.auth.userId === ctx.row.userId)
45
+ *
46
+ * // Named policy with priority
47
+ * allow('read', ctx => ctx.auth.roles.includes('verified'), {
48
+ * name: 'verified-users-only',
49
+ * priority: 10
50
+ * })
51
+ * ```
52
+ */
53
+ export function allow(
54
+ operation: Operation | Operation[],
55
+ condition: PolicyCondition,
56
+ options?: PolicyOptions
57
+ ): PolicyDefinition {
58
+ const policy: PolicyDefinition = {
59
+ type: 'allow',
60
+ operation,
61
+ condition: condition as PolicyCondition,
62
+ priority: options?.priority ?? 0,
63
+ };
64
+
65
+ if (options?.name !== undefined) {
66
+ policy.name = options.name;
67
+ }
68
+
69
+ if (options?.hints !== undefined) {
70
+ policy.hints = options.hints;
71
+ }
72
+
73
+ return policy;
74
+ }
75
+
76
+ /**
77
+ * Create a deny policy
78
+ * Blocks access when condition evaluates to true (overrides allow)
79
+ * If no condition is provided, always denies
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * // Deny access to banned users
84
+ * deny('all', ctx => ctx.auth.attributes?.banned === true)
85
+ *
86
+ * // Deny deletions on archived records
87
+ * deny('delete', ctx => ctx.row.archived === true)
88
+ *
89
+ * // Deny all access to sensitive table
90
+ * deny('all')
91
+ *
92
+ * // Named deny with high priority
93
+ * deny('all', ctx => ctx.auth.attributes?.suspended === true, {
94
+ * name: 'block-suspended-users',
95
+ * priority: 200
96
+ * })
97
+ * ```
98
+ */
99
+ export function deny(
100
+ operation: Operation | Operation[],
101
+ condition?: PolicyCondition,
102
+ options?: PolicyOptions
103
+ ): PolicyDefinition {
104
+ const policy: PolicyDefinition = {
105
+ type: 'deny',
106
+ operation,
107
+ condition: (condition ?? (() => true)) as PolicyCondition,
108
+ priority: options?.priority ?? 100, // Deny policies run first by default
109
+ };
110
+
111
+ if (options?.name !== undefined) {
112
+ policy.name = options.name;
113
+ }
114
+
115
+ if (options?.hints !== undefined) {
116
+ policy.hints = options.hints;
117
+ }
118
+
119
+ return policy;
120
+ }
121
+
122
+ /**
123
+ * Create a filter policy
124
+ * Adds WHERE conditions to SELECT queries
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * // Filter by tenant
129
+ * filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
130
+ *
131
+ * // Filter by organization with soft delete
132
+ * filter('read', ctx => ({
133
+ * organization_id: ctx.auth.organizationIds?.[0],
134
+ * deleted_at: null
135
+ * }))
136
+ *
137
+ * // Named filter
138
+ * filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }), {
139
+ * name: 'tenant-filter'
140
+ * })
141
+ * ```
142
+ */
143
+ export function filter(
144
+ operation: 'read' | 'all',
145
+ condition: FilterCondition,
146
+ options?: PolicyOptions
147
+ ): PolicyDefinition {
148
+ const policy: PolicyDefinition = {
149
+ type: 'filter',
150
+ operation: operation === 'all' ? 'read' : operation,
151
+ condition: condition as unknown as PolicyCondition,
152
+ priority: options?.priority ?? 0,
153
+ };
154
+
155
+ if (options?.name !== undefined) {
156
+ policy.name = options.name;
157
+ }
158
+
159
+ if (options?.hints !== undefined) {
160
+ policy.hints = options.hints;
161
+ }
162
+
163
+ return policy;
164
+ }
165
+
166
+ /**
167
+ * Create a validate policy
168
+ * Validates mutation data before execution
169
+ *
170
+ * @example
171
+ * ```typescript
172
+ * // Validate user can only set their own user_id
173
+ * validate('create', ctx => ctx.data.userId === ctx.auth.userId)
174
+ *
175
+ * // Validate status transitions
176
+ * validate('update', ctx => {
177
+ * const { status } = ctx.data;
178
+ * return !status || ['draft', 'published'].includes(status);
179
+ * })
180
+ *
181
+ * // Apply to both create and update
182
+ * validate('all', ctx => ctx.data.price >= 0)
183
+ *
184
+ * // Named validation
185
+ * validate('create', ctx => validateEmail(ctx.data.email), {
186
+ * name: 'validate-email'
187
+ * })
188
+ * ```
189
+ */
190
+ export function validate(
191
+ operation: 'create' | 'update' | 'all',
192
+ condition: PolicyCondition,
193
+ options?: PolicyOptions
194
+ ): PolicyDefinition {
195
+ const ops: Operation[] = operation === 'all'
196
+ ? ['create', 'update']
197
+ : [operation];
198
+
199
+ const policy: PolicyDefinition = {
200
+ type: 'validate',
201
+ operation: ops,
202
+ condition: condition as PolicyCondition,
203
+ priority: options?.priority ?? 0,
204
+ };
205
+
206
+ if (options?.name !== undefined) {
207
+ policy.name = options.name;
208
+ }
209
+
210
+ if (options?.hints !== undefined) {
211
+ policy.hints = options.hints;
212
+ }
213
+
214
+ return policy;
215
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Row-Level Security policy module
3
+ *
4
+ * Exports all policy-related types, builders, and schema functions.
5
+ */
6
+
7
+ export * from './types.js';
8
+ export { allow, deny, filter, validate, type PolicyOptions } from './builder.js';
9
+ export { defineRLSSchema, mergeRLSSchemas } from './schema.js';
10
+ export { PolicyRegistry } from './registry.js';