@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,403 @@
1
+ /**
2
+ * Policy Registry
3
+ * Central registry for managing RLS policies across all tables
4
+ *
5
+ * The PolicyRegistry compiles and stores RLS policies for efficient runtime lookup.
6
+ * It categorizes policies by type (allow/deny/filter/validate) and operation,
7
+ * and provides methods to query policies for specific tables and operations.
8
+ */
9
+
10
+ import type {
11
+ Operation,
12
+ PolicyDefinition,
13
+ FilterCondition,
14
+ RLSSchema,
15
+ TableRLSConfig,
16
+ CompiledPolicy,
17
+ CompiledFilterPolicy,
18
+ } from './types.js';
19
+ import { RLSSchemaError } from '../errors.js';
20
+ import { silentLogger, type KyseraLogger } from '@kysera/core';
21
+
22
+ /**
23
+ * Internal compiled policy with operations as Set for efficient lookup
24
+ */
25
+ interface InternalCompiledPolicy {
26
+ name: string;
27
+ operations: Set<Operation>;
28
+ type: 'allow' | 'deny' | 'validate';
29
+ evaluate: (ctx: any) => boolean | Promise<boolean>;
30
+ priority: number;
31
+ }
32
+
33
+ /**
34
+ * Table policy configuration
35
+ */
36
+ interface TablePolicyConfig {
37
+ allows: InternalCompiledPolicy[];
38
+ denies: InternalCompiledPolicy[];
39
+ filters: CompiledFilterPolicy[];
40
+ validates: InternalCompiledPolicy[];
41
+ skipFor: string[]; // Role names that bypass RLS
42
+ defaultDeny: boolean;
43
+ }
44
+
45
+ /**
46
+ * Policy Registry
47
+ * Manages and provides access to RLS policies
48
+ */
49
+ export class PolicyRegistry<DB = unknown> {
50
+ private tables = new Map<string, TablePolicyConfig>();
51
+ private compiled = false;
52
+ private logger: KyseraLogger;
53
+
54
+ constructor(schema?: RLSSchema<DB>, options?: { logger?: KyseraLogger }) {
55
+ this.logger = options?.logger ?? silentLogger;
56
+ if (schema) {
57
+ this.loadSchema(schema);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Load and compile policies from schema
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * const registry = new PolicyRegistry<Database>();
67
+ * registry.loadSchema({
68
+ * users: {
69
+ * policies: [
70
+ * allow('read', ctx => ctx.auth.userId === ctx.row.id),
71
+ * filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
72
+ * ],
73
+ * defaultDeny: true,
74
+ * },
75
+ * });
76
+ * ```
77
+ */
78
+ loadSchema(schema: RLSSchema<DB>): void {
79
+ for (const [table, config] of Object.entries(schema)) {
80
+ if (!config) continue;
81
+ this.registerTable(table, config as TableRLSConfig);
82
+ }
83
+ this.compiled = true;
84
+ }
85
+
86
+ /**
87
+ * Register policies for a single table
88
+ *
89
+ * @param table - Table name
90
+ * @param config - Table RLS configuration
91
+ */
92
+ registerTable(table: string, config: TableRLSConfig): void {
93
+ const tableConfig: TablePolicyConfig = {
94
+ allows: [],
95
+ denies: [],
96
+ filters: [],
97
+ validates: [],
98
+ skipFor: config.skipFor ?? [],
99
+ defaultDeny: config.defaultDeny ?? true,
100
+ };
101
+
102
+ // Compile and categorize policies
103
+ for (let i = 0; i < config.policies.length; i++) {
104
+ const policy = config.policies[i];
105
+ if (!policy) continue;
106
+
107
+ const policyName = policy.name ?? `${table}_policy_${i}`;
108
+
109
+ try {
110
+ if (policy.type === 'filter') {
111
+ const compiled = this.compileFilterPolicy(policy, policyName);
112
+ tableConfig.filters.push(compiled);
113
+ } else {
114
+ const compiled = this.compilePolicy(policy, policyName);
115
+
116
+ switch (policy.type) {
117
+ case 'allow':
118
+ tableConfig.allows.push(compiled);
119
+ break;
120
+ case 'deny':
121
+ tableConfig.denies.push(compiled);
122
+ break;
123
+ case 'validate':
124
+ tableConfig.validates.push(compiled);
125
+ break;
126
+ }
127
+ }
128
+ } catch (error) {
129
+ throw new RLSSchemaError(
130
+ `Failed to compile policy "${policyName}" for table "${table}": ${error instanceof Error ? error.message : String(error)}`,
131
+ { table, policy: policyName }
132
+ );
133
+ }
134
+ }
135
+
136
+ // Sort by priority (higher priority first)
137
+ tableConfig.allows.sort((a, b) => b.priority - a.priority);
138
+ tableConfig.denies.sort((a, b) => b.priority - a.priority);
139
+ tableConfig.validates.sort((a, b) => b.priority - a.priority);
140
+
141
+ this.tables.set(table, tableConfig);
142
+ }
143
+
144
+ /**
145
+ * Register policies - supports both schema and table-based registration
146
+ *
147
+ * @overload Register a full schema
148
+ * @overload Register policies for a single table (deprecated)
149
+ */
150
+ register(schemaOrTable: RLSSchema<DB>): void;
151
+ register(
152
+ schemaOrTable: keyof DB & string,
153
+ policies: PolicyDefinition[],
154
+ options?: {
155
+ skipFor?: string[];
156
+ defaultDeny?: boolean;
157
+ }
158
+ ): void;
159
+ register(
160
+ schemaOrTable: RLSSchema<DB> | (keyof DB & string),
161
+ policies?: PolicyDefinition[],
162
+ options?: {
163
+ skipFor?: string[]; // Role names that bypass RLS
164
+ defaultDeny?: boolean;
165
+ }
166
+ ): void {
167
+ // If first argument is an object with policies, treat as schema
168
+ if (typeof schemaOrTable === 'object' && schemaOrTable !== null) {
169
+ this.loadSchema(schemaOrTable);
170
+ return;
171
+ }
172
+
173
+ // Otherwise, treat as table-based registration
174
+ const table = schemaOrTable as keyof DB & string;
175
+ if (!policies) {
176
+ throw new RLSSchemaError('Policies are required when registering by table name', { table });
177
+ }
178
+
179
+ const config: TableRLSConfig = {
180
+ policies,
181
+ };
182
+
183
+ if (options?.skipFor !== undefined) {
184
+ config.skipFor = options.skipFor;
185
+ }
186
+
187
+ if (options?.defaultDeny !== undefined) {
188
+ config.defaultDeny = options.defaultDeny;
189
+ }
190
+
191
+ this.registerTable(table, config);
192
+ }
193
+
194
+ /**
195
+ * Compile a policy definition into an internal compiled policy
196
+ *
197
+ * @param policy - Policy definition to compile
198
+ * @param name - Policy name for debugging
199
+ * @returns Compiled policy ready for evaluation
200
+ */
201
+ private compilePolicy(policy: PolicyDefinition, name: string): InternalCompiledPolicy {
202
+ const operations = Array.isArray(policy.operation)
203
+ ? policy.operation
204
+ : [policy.operation];
205
+
206
+ // Expand 'all' to all operations
207
+ const expandedOps = operations.flatMap(op =>
208
+ op === 'all' ? (['read', 'create', 'update', 'delete'] as const) : [op]
209
+ ) as Operation[];
210
+
211
+ return {
212
+ name,
213
+ operations: new Set(expandedOps),
214
+ type: policy.type as 'allow' | 'deny' | 'validate',
215
+ evaluate: policy.condition as (ctx: any) => boolean | Promise<boolean>,
216
+ priority: policy.priority ?? (policy.type === 'deny' ? 100 : 0),
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Compile a filter policy
222
+ *
223
+ * @param policy - Filter policy definition
224
+ * @param name - Policy name for debugging
225
+ * @returns Compiled filter policy
226
+ */
227
+ private compileFilterPolicy(policy: PolicyDefinition, name: string): CompiledFilterPolicy {
228
+ const condition = policy.condition as unknown as FilterCondition;
229
+
230
+ return {
231
+ operation: 'read',
232
+ getConditions: condition as (ctx: any) => Record<string, unknown>,
233
+ name,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Convert internal compiled policy to public CompiledPolicy
239
+ */
240
+ private toCompiledPolicy(internal: InternalCompiledPolicy): CompiledPolicy {
241
+ return {
242
+ name: internal.name,
243
+ type: internal.type,
244
+ operation: Array.from(internal.operations),
245
+ evaluate: internal.evaluate,
246
+ priority: internal.priority,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Get allow policies for a table and operation
252
+ */
253
+ getAllows(table: string, operation: Operation): CompiledPolicy[] {
254
+ const config = this.tables.get(table);
255
+ if (!config) return [];
256
+
257
+ return config.allows
258
+ .filter(p => p.operations.has(operation))
259
+ .map(p => this.toCompiledPolicy(p));
260
+ }
261
+
262
+ /**
263
+ * Get deny policies for a table and operation
264
+ */
265
+ getDenies(table: string, operation: Operation): CompiledPolicy[] {
266
+ const config = this.tables.get(table);
267
+ if (!config) return [];
268
+
269
+ return config.denies
270
+ .filter(p => p.operations.has(operation))
271
+ .map(p => this.toCompiledPolicy(p));
272
+ }
273
+
274
+ /**
275
+ * Get validate policies for a table and operation
276
+ */
277
+ getValidates(table: string, operation: Operation): CompiledPolicy[] {
278
+ const config = this.tables.get(table);
279
+ if (!config) return [];
280
+
281
+ return config.validates
282
+ .filter(p => p.operations.has(operation))
283
+ .map(p => this.toCompiledPolicy(p));
284
+ }
285
+
286
+ /**
287
+ * Get filter policies for a table
288
+ */
289
+ getFilters(table: string): CompiledFilterPolicy[] {
290
+ const config = this.tables.get(table);
291
+ return config?.filters ?? [];
292
+ }
293
+
294
+ /**
295
+ * Get roles that skip RLS for a table
296
+ */
297
+ getSkipFor(table: string): string[] {
298
+ const config = this.tables.get(table);
299
+ return config?.skipFor ?? [];
300
+ }
301
+
302
+ /**
303
+ * Check if table has default deny
304
+ */
305
+ hasDefaultDeny(table: string): boolean {
306
+ const config = this.tables.get(table);
307
+ return config?.defaultDeny ?? true;
308
+ }
309
+
310
+ /**
311
+ * Check if a table is registered
312
+ */
313
+ hasTable(table: string): boolean {
314
+ return this.tables.has(table);
315
+ }
316
+
317
+ /**
318
+ * Get all registered table names
319
+ */
320
+ getTables(): string[] {
321
+ return Array.from(this.tables.keys());
322
+ }
323
+
324
+ /**
325
+ * Check if registry is compiled
326
+ */
327
+ isCompiled(): boolean {
328
+ return this.compiled;
329
+ }
330
+
331
+ /**
332
+ * Validate that all policies are properly defined
333
+ *
334
+ * This method checks for common issues:
335
+ * - Tables with no policies and defaultDeny=false (warns)
336
+ * - Tables with skipFor operations but no corresponding policies
337
+ */
338
+ validate(): void {
339
+ for (const [table, config] of this.tables) {
340
+ // Check that at least one operation has policies
341
+ const hasPolicy =
342
+ config.allows.length > 0 ||
343
+ config.denies.length > 0 ||
344
+ config.filters.length > 0 ||
345
+ config.validates.length > 0;
346
+
347
+ if (!hasPolicy && !config.defaultDeny) {
348
+ // Warning: table has no policies and defaultDeny is false
349
+ this.logger.warn?.(
350
+ `[RLS] Table "${table}" has no policies and defaultDeny is false. ` +
351
+ `All operations will be allowed.`
352
+ );
353
+ }
354
+
355
+ // Warn if skipFor includes operations that have policies
356
+ if (config.skipFor.length > 0) {
357
+ const opsWithPolicies = new Set<Operation>();
358
+
359
+ for (const allow of config.allows) {
360
+ allow.operations.forEach(op => opsWithPolicies.add(op));
361
+ }
362
+ for (const deny of config.denies) {
363
+ deny.operations.forEach(op => opsWithPolicies.add(op));
364
+ }
365
+ for (const validate of config.validates) {
366
+ validate.operations.forEach(op => opsWithPolicies.add(op));
367
+ }
368
+ if (config.filters.length > 0) {
369
+ opsWithPolicies.add('read');
370
+ }
371
+
372
+ const skippedOpsWithPolicies = config.skipFor.filter(op => {
373
+ // 'all' means skip all operations
374
+ if (op === 'all') return opsWithPolicies.size > 0;
375
+ // Check if this is an operation name (for backwards compatibility)
376
+ return opsWithPolicies.has(op as Operation);
377
+ });
378
+
379
+ if (skippedOpsWithPolicies.length > 0) {
380
+ this.logger.warn?.(
381
+ `[RLS] Table "${table}" has skipFor operations that also have policies: ${skippedOpsWithPolicies.join(', ')}. ` +
382
+ `The policies will be ignored for these operations.`
383
+ );
384
+ }
385
+ }
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Clear all policies
391
+ */
392
+ clear(): void {
393
+ this.tables.clear();
394
+ this.compiled = false;
395
+ }
396
+
397
+ /**
398
+ * Remove policies for a specific table
399
+ */
400
+ remove(table: string): void {
401
+ this.tables.delete(table);
402
+ }
403
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * RLS schema definition and validation
3
+ *
4
+ * Provides functions to define, validate, and merge RLS schemas.
5
+ */
6
+
7
+ import type { RLSSchema, TableRLSConfig, PolicyDefinition } from './types.js';
8
+ import { RLSSchemaError } from '../errors.js';
9
+
10
+ /**
11
+ * Define RLS schema with full type safety
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * interface Database {
16
+ * users: { id: number; email: string; tenant_id: number };
17
+ * posts: { id: number; user_id: number; tenant_id: number };
18
+ * }
19
+ *
20
+ * const schema = defineRLSSchema<Database>({
21
+ * users: {
22
+ * policies: [
23
+ * // Users can read their own records
24
+ * allow('read', ctx => ctx.auth.userId === ctx.row.id),
25
+ * // Filter by tenant
26
+ * filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
27
+ * // Admins bypass all checks
28
+ * allow('all', ctx => ctx.auth.roles.includes('admin')),
29
+ * ],
30
+ * },
31
+ * posts: {
32
+ * policies: [
33
+ * // Filter posts by tenant
34
+ * filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
35
+ * // Users can only edit their own posts
36
+ * allow(['update', 'delete'], ctx => ctx.auth.userId === ctx.row.user_id),
37
+ * // Validate new posts belong to user's tenant
38
+ * validate('create', ctx => ctx.data.tenant_id === ctx.auth.tenantId),
39
+ * ],
40
+ * defaultDeny: true, // Require explicit allow
41
+ * },
42
+ * });
43
+ * ```
44
+ */
45
+ export function defineRLSSchema<DB>(
46
+ schema: RLSSchema<DB>
47
+ ): RLSSchema<DB> {
48
+ // Validate schema
49
+ validateSchema(schema);
50
+ return schema;
51
+ }
52
+
53
+ /**
54
+ * Validate RLS schema
55
+ * Throws RLSSchemaError if validation fails
56
+ *
57
+ * @internal
58
+ */
59
+ function validateSchema<DB>(schema: RLSSchema<DB>): void {
60
+ for (const [table, config] of Object.entries(schema)) {
61
+ if (!config) continue;
62
+
63
+ const tableConfig = config as TableRLSConfig;
64
+
65
+ if (!Array.isArray(tableConfig.policies)) {
66
+ throw new RLSSchemaError(
67
+ `Invalid policies for table "${table}": must be an array`,
68
+ { table }
69
+ );
70
+ }
71
+
72
+ // Validate each policy
73
+ for (let i = 0; i < tableConfig.policies.length; i++) {
74
+ const policy = tableConfig.policies[i];
75
+ if (policy !== undefined) {
76
+ validatePolicy(policy, table, i);
77
+ }
78
+ }
79
+
80
+ // Validate skipFor if present (array of role names that bypass RLS)
81
+ if (tableConfig.skipFor !== undefined) {
82
+ if (!Array.isArray(tableConfig.skipFor)) {
83
+ throw new RLSSchemaError(
84
+ `Invalid skipFor for table "${table}": must be an array of role names`,
85
+ { table }
86
+ );
87
+ }
88
+
89
+ // skipFor contains role names (strings), not operations
90
+ for (const role of tableConfig.skipFor) {
91
+ if (typeof role !== 'string' || role.trim() === '') {
92
+ throw new RLSSchemaError(
93
+ `Invalid role in skipFor for table "${table}": must be a non-empty string`,
94
+ { table }
95
+ );
96
+ }
97
+ }
98
+ }
99
+
100
+ // Validate defaultDeny if present
101
+ if (tableConfig.defaultDeny !== undefined && typeof tableConfig.defaultDeny !== 'boolean') {
102
+ throw new RLSSchemaError(
103
+ `Invalid defaultDeny for table "${table}": must be a boolean`,
104
+ { table }
105
+ );
106
+ }
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Validate a single policy
112
+ * Throws RLSSchemaError if validation fails
113
+ *
114
+ * @internal
115
+ */
116
+ function validatePolicy(
117
+ policy: PolicyDefinition,
118
+ table: string,
119
+ index: number
120
+ ): void {
121
+ if (!policy.type) {
122
+ throw new RLSSchemaError(
123
+ `Policy ${index} for table "${table}" missing type`,
124
+ { table, index }
125
+ );
126
+ }
127
+
128
+ const validTypes = ['allow', 'deny', 'filter', 'validate'];
129
+ if (!validTypes.includes(policy.type)) {
130
+ throw new RLSSchemaError(
131
+ `Policy ${index} for table "${table}" has invalid type: ${policy.type}`,
132
+ { table, index, type: policy.type }
133
+ );
134
+ }
135
+
136
+ if (!policy.operation) {
137
+ throw new RLSSchemaError(
138
+ `Policy ${index} for table "${table}" missing operation`,
139
+ { table, index }
140
+ );
141
+ }
142
+
143
+ const validOps = ['read', 'create', 'update', 'delete', 'all'];
144
+ const ops = Array.isArray(policy.operation) ? policy.operation : [policy.operation];
145
+
146
+ for (const op of ops) {
147
+ if (!validOps.includes(op)) {
148
+ throw new RLSSchemaError(
149
+ `Policy ${index} for table "${table}" has invalid operation: ${op}`,
150
+ { table, index, operation: op }
151
+ );
152
+ }
153
+ }
154
+
155
+ if (policy.condition === undefined || policy.condition === null) {
156
+ throw new RLSSchemaError(
157
+ `Policy ${index} for table "${table}" missing condition`,
158
+ { table, index }
159
+ );
160
+ }
161
+
162
+ if (typeof policy.condition !== 'function' && typeof policy.condition !== 'string') {
163
+ throw new RLSSchemaError(
164
+ `Policy ${index} for table "${table}" condition must be a function or string`,
165
+ { table, index }
166
+ );
167
+ }
168
+
169
+ // Validate priority if present
170
+ if (policy.priority !== undefined && typeof policy.priority !== 'number') {
171
+ throw new RLSSchemaError(
172
+ `Policy ${index} for table "${table}" priority must be a number`,
173
+ { table, index }
174
+ );
175
+ }
176
+
177
+ // Validate name if present
178
+ if (policy.name !== undefined && typeof policy.name !== 'string') {
179
+ throw new RLSSchemaError(
180
+ `Policy ${index} for table "${table}" name must be a string`,
181
+ { table, index }
182
+ );
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Merge multiple RLS schemas
188
+ * Later schemas override earlier ones for the same table
189
+ *
190
+ * @example
191
+ * ```typescript
192
+ * const baseSchema = defineRLSSchema<Database>({
193
+ * users: {
194
+ * policies: [
195
+ * filter('read', ctx => ({ tenant_id: ctx.auth.tenantId })),
196
+ * ],
197
+ * },
198
+ * });
199
+ *
200
+ * const adminSchema = defineRLSSchema<Database>({
201
+ * users: {
202
+ * policies: [
203
+ * allow('all', ctx => ctx.auth.roles.includes('admin')),
204
+ * ],
205
+ * },
206
+ * });
207
+ *
208
+ * // Merged schema will have both filters and admin allow
209
+ * const merged = mergeRLSSchemas(baseSchema, adminSchema);
210
+ * ```
211
+ */
212
+ export function mergeRLSSchemas<DB>(
213
+ ...schemas: RLSSchema<DB>[]
214
+ ): RLSSchema<DB> {
215
+ const merged: RLSSchema<DB> = {};
216
+
217
+ for (const schema of schemas) {
218
+ for (const [table, config] of Object.entries(schema)) {
219
+ if (!config) continue;
220
+
221
+ const existingConfig = merged[table as keyof DB] as TableRLSConfig | undefined;
222
+ const newConfig = config as TableRLSConfig;
223
+
224
+ if (existingConfig) {
225
+ // Merge policies (append new policies)
226
+ existingConfig.policies = [
227
+ ...existingConfig.policies,
228
+ ...newConfig.policies,
229
+ ];
230
+
231
+ // Merge skipFor (combine arrays and deduplicate)
232
+ if (newConfig.skipFor) {
233
+ const existingSkipFor = existingConfig.skipFor ?? [];
234
+ const combinedSkipFor = [...existingSkipFor, ...newConfig.skipFor];
235
+ existingConfig.skipFor = Array.from(new Set(combinedSkipFor));
236
+ }
237
+
238
+ // Override defaultDeny if explicitly set in new config
239
+ if (newConfig.defaultDeny !== undefined) {
240
+ existingConfig.defaultDeny = newConfig.defaultDeny;
241
+ }
242
+ } else {
243
+ // Deep copy the config to avoid mutation
244
+ merged[table as keyof DB] = {
245
+ policies: [...newConfig.policies],
246
+ skipFor: newConfig.skipFor ? [...newConfig.skipFor] : undefined,
247
+ defaultDeny: newConfig.defaultDeny,
248
+ } as any;
249
+ }
250
+ }
251
+ }
252
+
253
+ // Validate merged schema
254
+ validateSchema(merged);
255
+
256
+ return merged;
257
+ }