@kysera/rls 0.7.3 → 0.8.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.
@@ -13,19 +13,19 @@ import type {
13
13
  PolicyDefinition,
14
14
  PolicyCondition,
15
15
  FilterCondition,
16
- PolicyHints,
17
- } from './types.js';
16
+ PolicyHints
17
+ } from './types.js'
18
18
 
19
19
  /**
20
20
  * Options for policy definitions
21
21
  */
22
22
  export interface PolicyOptions {
23
23
  /** Policy name for debugging and identification */
24
- name?: string;
24
+ name?: string
25
25
  /** Priority (higher runs first, deny policies default to 100) */
26
- priority?: number;
26
+ priority?: number
27
27
  /** Performance optimization hints */
28
- hints?: PolicyHints;
28
+ hints?: PolicyHints
29
29
  }
30
30
 
31
31
  /**
@@ -58,19 +58,19 @@ export function allow(
58
58
  const policy: PolicyDefinition = {
59
59
  type: 'allow',
60
60
  operation,
61
- condition: condition as PolicyCondition,
62
- priority: options?.priority ?? 0,
63
- };
61
+ condition: condition,
62
+ priority: options?.priority ?? 0
63
+ }
64
64
 
65
65
  if (options?.name !== undefined) {
66
- policy.name = options.name;
66
+ policy.name = options.name
67
67
  }
68
68
 
69
69
  if (options?.hints !== undefined) {
70
- policy.hints = options.hints;
70
+ policy.hints = options.hints
71
71
  }
72
72
 
73
- return policy;
73
+ return policy
74
74
  }
75
75
 
76
76
  /**
@@ -104,36 +104,51 @@ export function deny(
104
104
  const policy: PolicyDefinition = {
105
105
  type: 'deny',
106
106
  operation,
107
- condition: (condition ?? (() => true)) as PolicyCondition,
108
- priority: options?.priority ?? 100, // Deny policies run first by default
109
- };
107
+ condition: condition ?? (() => true),
108
+ priority: options?.priority ?? 100 // Deny policies run first by default
109
+ }
110
110
 
111
111
  if (options?.name !== undefined) {
112
- policy.name = options.name;
112
+ policy.name = options.name
113
113
  }
114
114
 
115
115
  if (options?.hints !== undefined) {
116
- policy.hints = options.hints;
116
+ policy.hints = options.hints
117
117
  }
118
118
 
119
- return policy;
119
+ return policy
120
120
  }
121
121
 
122
122
  /**
123
123
  * Create a filter policy
124
124
  * Adds WHERE conditions to SELECT queries
125
125
  *
126
+ * **IMPORTANT**: Filter conditions must be synchronous functions.
127
+ * Async filter policies are not currently supported because filters are applied
128
+ * directly to query builders at query construction time.
129
+ *
126
130
  * @example
127
131
  * ```typescript
128
- * // Filter by tenant
132
+ * // ✅ CORRECT: Filter by tenant (synchronous)
129
133
  * filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
130
134
  *
131
- * // Filter by organization with soft delete
135
+ * // ✅ CORRECT: Filter by organization with soft delete
132
136
  * filter('read', ctx => ({
133
137
  * organization_id: ctx.auth.organizationIds?.[0],
134
138
  * deleted_at: null
135
139
  * }))
136
140
  *
141
+ * // ❌ WRONG: Async filter (not supported)
142
+ * // filter('read', async ctx => {
143
+ * // const tenantId = await fetchTenantId(ctx.auth.userId)
144
+ * // return { tenant_id: tenantId }
145
+ * // })
146
+ *
147
+ * // ✅ WORKAROUND: Fetch data before creating context
148
+ * // const tenantId = await fetchTenantId(userId)
149
+ * // const ctx = createRLSContext({ auth: { userId, tenantId, roles: [] } })
150
+ * // filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
151
+ *
137
152
  * // Named filter
138
153
  * filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }), {
139
154
  * name: 'tenant-filter'
@@ -149,18 +164,18 @@ export function filter(
149
164
  type: 'filter',
150
165
  operation: operation === 'all' ? 'read' : operation,
151
166
  condition: condition as unknown as PolicyCondition,
152
- priority: options?.priority ?? 0,
153
- };
167
+ priority: options?.priority ?? 0
168
+ }
154
169
 
155
170
  if (options?.name !== undefined) {
156
- policy.name = options.name;
171
+ policy.name = options.name
157
172
  }
158
173
 
159
174
  if (options?.hints !== undefined) {
160
- policy.hints = options.hints;
175
+ policy.hints = options.hints
161
176
  }
162
177
 
163
- return policy;
178
+ return policy
164
179
  }
165
180
 
166
181
  /**
@@ -192,24 +207,22 @@ export function validate(
192
207
  condition: PolicyCondition,
193
208
  options?: PolicyOptions
194
209
  ): PolicyDefinition {
195
- const ops: Operation[] = operation === 'all'
196
- ? ['create', 'update']
197
- : [operation];
210
+ const ops: Operation[] = operation === 'all' ? ['create', 'update'] : [operation]
198
211
 
199
212
  const policy: PolicyDefinition = {
200
213
  type: 'validate',
201
214
  operation: ops,
202
- condition: condition as PolicyCondition,
203
- priority: options?.priority ?? 0,
204
- };
215
+ condition: condition,
216
+ priority: options?.priority ?? 0
217
+ }
205
218
 
206
219
  if (options?.name !== undefined) {
207
- policy.name = options.name;
220
+ policy.name = options.name
208
221
  }
209
222
 
210
223
  if (options?.hints !== undefined) {
211
- policy.hints = options.hints;
224
+ policy.hints = options.hints
212
225
  }
213
226
 
214
- return policy;
227
+ return policy
215
228
  }
@@ -4,7 +4,7 @@
4
4
  * Exports all policy-related types, builders, and schema functions.
5
5
  */
6
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';
7
+ export type * 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'
@@ -15,31 +15,32 @@ import type {
15
15
  TableRLSConfig,
16
16
  CompiledPolicy,
17
17
  CompiledFilterPolicy,
18
- } from './types.js';
19
- import { RLSSchemaError } from '../errors.js';
20
- import { silentLogger, type KyseraLogger } from '@kysera/core';
18
+ PolicyEvaluationContext
19
+ } from './types.js'
20
+ import { RLSSchemaError } from '../errors.js'
21
+ import { silentLogger, type KyseraLogger } from '@kysera/core'
21
22
 
22
23
  /**
23
24
  * Internal compiled policy with operations as Set for efficient lookup
24
25
  */
25
26
  interface InternalCompiledPolicy {
26
- name: string;
27
- operations: Set<Operation>;
28
- type: 'allow' | 'deny' | 'validate';
29
- evaluate: (ctx: any) => boolean | Promise<boolean>;
30
- priority: number;
27
+ name: string
28
+ operations: Set<Operation>
29
+ type: 'allow' | 'deny' | 'validate'
30
+ evaluate: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>
31
+ priority: number
31
32
  }
32
33
 
33
34
  /**
34
35
  * Table policy configuration
35
36
  */
36
37
  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;
38
+ allows: InternalCompiledPolicy[]
39
+ denies: InternalCompiledPolicy[]
40
+ filters: CompiledFilterPolicy[]
41
+ validates: InternalCompiledPolicy[]
42
+ skipFor: string[] // Role names that bypass RLS
43
+ defaultDeny: boolean
43
44
  }
44
45
 
45
46
  /**
@@ -47,14 +48,14 @@ interface TablePolicyConfig {
47
48
  * Manages and provides access to RLS policies
48
49
  */
49
50
  export class PolicyRegistry<DB = unknown> {
50
- private tables = new Map<string, TablePolicyConfig>();
51
- private compiled = false;
52
- private logger: KyseraLogger;
51
+ private tables = new Map<string, TablePolicyConfig>()
52
+ private compiled = false
53
+ private logger: KyseraLogger
53
54
 
54
55
  constructor(schema?: RLSSchema<DB>, options?: { logger?: KyseraLogger }) {
55
- this.logger = options?.logger ?? silentLogger;
56
+ this.logger = options?.logger ?? silentLogger
56
57
  if (schema) {
57
- this.loadSchema(schema);
58
+ this.loadSchema(schema)
58
59
  }
59
60
  }
60
61
 
@@ -77,10 +78,10 @@ export class PolicyRegistry<DB = unknown> {
77
78
  */
78
79
  loadSchema(schema: RLSSchema<DB>): void {
79
80
  for (const [table, config] of Object.entries(schema)) {
80
- if (!config) continue;
81
- this.registerTable(table, config as TableRLSConfig);
81
+ if (!config) continue
82
+ this.registerTable(table, config as TableRLSConfig)
82
83
  }
83
- this.compiled = true;
84
+ this.compiled = true
84
85
  }
85
86
 
86
87
  /**
@@ -96,49 +97,49 @@ export class PolicyRegistry<DB = unknown> {
96
97
  filters: [],
97
98
  validates: [],
98
99
  skipFor: config.skipFor ?? [],
99
- defaultDeny: config.defaultDeny ?? true,
100
- };
100
+ defaultDeny: config.defaultDeny ?? true
101
+ }
101
102
 
102
103
  // Compile and categorize policies
103
104
  for (let i = 0; i < config.policies.length; i++) {
104
- const policy = config.policies[i];
105
- if (!policy) continue;
105
+ const policy = config.policies[i]
106
+ if (!policy) continue
106
107
 
107
- const policyName = policy.name ?? `${table}_policy_${i}`;
108
+ const policyName = policy.name ?? `${table}_policy_${i}`
108
109
 
109
110
  try {
110
111
  if (policy.type === 'filter') {
111
- const compiled = this.compileFilterPolicy(policy, policyName);
112
- tableConfig.filters.push(compiled);
112
+ const compiled = this.compileFilterPolicy(policy, policyName)
113
+ tableConfig.filters.push(compiled)
113
114
  } else {
114
- const compiled = this.compilePolicy(policy, policyName);
115
+ const compiled = this.compilePolicy(policy, policyName)
115
116
 
116
117
  switch (policy.type) {
117
118
  case 'allow':
118
- tableConfig.allows.push(compiled);
119
- break;
119
+ tableConfig.allows.push(compiled)
120
+ break
120
121
  case 'deny':
121
- tableConfig.denies.push(compiled);
122
- break;
122
+ tableConfig.denies.push(compiled)
123
+ break
123
124
  case 'validate':
124
- tableConfig.validates.push(compiled);
125
- break;
125
+ tableConfig.validates.push(compiled)
126
+ break
126
127
  }
127
128
  }
128
129
  } catch (error) {
129
130
  throw new RLSSchemaError(
130
131
  `Failed to compile policy "${policyName}" for table "${table}": ${error instanceof Error ? error.message : String(error)}`,
131
132
  { table, policy: policyName }
132
- );
133
+ )
133
134
  }
134
135
  }
135
136
 
136
137
  // 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);
138
+ tableConfig.allows.sort((a, b) => b.priority - a.priority)
139
+ tableConfig.denies.sort((a, b) => b.priority - a.priority)
140
+ tableConfig.validates.sort((a, b) => b.priority - a.priority)
140
141
 
141
- this.tables.set(table, tableConfig);
142
+ this.tables.set(table, tableConfig)
142
143
  }
143
144
 
144
145
  /**
@@ -147,48 +148,48 @@ export class PolicyRegistry<DB = unknown> {
147
148
  * @overload Register a full schema
148
149
  * @overload Register policies for a single table (deprecated)
149
150
  */
150
- register(schemaOrTable: RLSSchema<DB>): void;
151
+ register(schemaOrTable: RLSSchema<DB>): void
151
152
  register(
152
153
  schemaOrTable: keyof DB & string,
153
154
  policies: PolicyDefinition[],
154
155
  options?: {
155
- skipFor?: string[];
156
- defaultDeny?: boolean;
156
+ skipFor?: string[]
157
+ defaultDeny?: boolean
157
158
  }
158
- ): void;
159
+ ): void
159
160
  register(
160
161
  schemaOrTable: RLSSchema<DB> | (keyof DB & string),
161
162
  policies?: PolicyDefinition[],
162
163
  options?: {
163
- skipFor?: string[]; // Role names that bypass RLS
164
- defaultDeny?: boolean;
164
+ skipFor?: string[] // Role names that bypass RLS
165
+ defaultDeny?: boolean
165
166
  }
166
167
  ): void {
167
168
  // If first argument is an object with policies, treat as schema
168
169
  if (typeof schemaOrTable === 'object' && schemaOrTable !== null) {
169
- this.loadSchema(schemaOrTable);
170
- return;
170
+ this.loadSchema(schemaOrTable)
171
+ return
171
172
  }
172
173
 
173
174
  // Otherwise, treat as table-based registration
174
- const table = schemaOrTable as keyof DB & string;
175
+ const table = schemaOrTable
175
176
  if (!policies) {
176
- throw new RLSSchemaError('Policies are required when registering by table name', { table });
177
+ throw new RLSSchemaError('Policies are required when registering by table name', { table })
177
178
  }
178
179
 
179
180
  const config: TableRLSConfig = {
180
- policies,
181
- };
181
+ policies
182
+ }
182
183
 
183
184
  if (options?.skipFor !== undefined) {
184
- config.skipFor = options.skipFor;
185
+ config.skipFor = options.skipFor
185
186
  }
186
187
 
187
188
  if (options?.defaultDeny !== undefined) {
188
- config.defaultDeny = options.defaultDeny;
189
+ config.defaultDeny = options.defaultDeny
189
190
  }
190
191
 
191
- this.registerTable(table, config);
192
+ this.registerTable(table, config)
192
193
  }
193
194
 
194
195
  /**
@@ -199,22 +200,20 @@ export class PolicyRegistry<DB = unknown> {
199
200
  * @returns Compiled policy ready for evaluation
200
201
  */
201
202
  private compilePolicy(policy: PolicyDefinition, name: string): InternalCompiledPolicy {
202
- const operations = Array.isArray(policy.operation)
203
- ? policy.operation
204
- : [policy.operation];
203
+ const operations = Array.isArray(policy.operation) ? policy.operation : [policy.operation]
205
204
 
206
205
  // Expand 'all' to all operations
207
206
  const expandedOps = operations.flatMap(op =>
208
207
  op === 'all' ? (['read', 'create', 'update', 'delete'] as const) : [op]
209
- ) as Operation[];
208
+ ) as Operation[]
210
209
 
211
210
  return {
212
211
  name,
213
212
  operations: new Set(expandedOps),
214
213
  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
- };
214
+ evaluate: policy.condition as (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>,
215
+ priority: policy.priority ?? (policy.type === 'deny' ? 100 : 0)
216
+ }
218
217
  }
219
218
 
220
219
  /**
@@ -225,13 +224,13 @@ export class PolicyRegistry<DB = unknown> {
225
224
  * @returns Compiled filter policy
226
225
  */
227
226
  private compileFilterPolicy(policy: PolicyDefinition, name: string): CompiledFilterPolicy {
228
- const condition = policy.condition as unknown as FilterCondition;
227
+ const condition = policy.condition as unknown as FilterCondition
229
228
 
230
229
  return {
231
230
  operation: 'read',
232
- getConditions: condition as (ctx: any) => Record<string, unknown>,
233
- name,
234
- };
231
+ getConditions: condition as (ctx: PolicyEvaluationContext) => Record<string, unknown>,
232
+ name
233
+ }
235
234
  }
236
235
 
237
236
  /**
@@ -243,89 +242,85 @@ export class PolicyRegistry<DB = unknown> {
243
242
  type: internal.type,
244
243
  operation: Array.from(internal.operations),
245
244
  evaluate: internal.evaluate,
246
- priority: internal.priority,
247
- };
245
+ priority: internal.priority
246
+ }
248
247
  }
249
248
 
250
249
  /**
251
250
  * Get allow policies for a table and operation
252
251
  */
253
252
  getAllows(table: string, operation: Operation): CompiledPolicy[] {
254
- const config = this.tables.get(table);
255
- if (!config) return [];
253
+ const config = this.tables.get(table)
254
+ if (!config) return []
256
255
 
257
- return config.allows
258
- .filter(p => p.operations.has(operation))
259
- .map(p => this.toCompiledPolicy(p));
256
+ return config.allows.filter(p => p.operations.has(operation)).map(p => this.toCompiledPolicy(p))
260
257
  }
261
258
 
262
259
  /**
263
260
  * Get deny policies for a table and operation
264
261
  */
265
262
  getDenies(table: string, operation: Operation): CompiledPolicy[] {
266
- const config = this.tables.get(table);
267
- if (!config) return [];
263
+ const config = this.tables.get(table)
264
+ if (!config) return []
268
265
 
269
- return config.denies
270
- .filter(p => p.operations.has(operation))
271
- .map(p => this.toCompiledPolicy(p));
266
+ return config.denies.filter(p => p.operations.has(operation)).map(p => this.toCompiledPolicy(p))
272
267
  }
273
268
 
274
269
  /**
275
270
  * Get validate policies for a table and operation
276
271
  */
277
272
  getValidates(table: string, operation: Operation): CompiledPolicy[] {
278
- const config = this.tables.get(table);
279
- if (!config) return [];
273
+ const config = this.tables.get(table)
274
+ if (!config) return []
280
275
 
281
276
  return config.validates
282
277
  .filter(p => p.operations.has(operation))
283
- .map(p => this.toCompiledPolicy(p));
278
+ .map(p => this.toCompiledPolicy(p))
284
279
  }
285
280
 
286
281
  /**
287
282
  * Get filter policies for a table
288
283
  */
289
284
  getFilters(table: string): CompiledFilterPolicy[] {
290
- const config = this.tables.get(table);
291
- return config?.filters ?? [];
285
+ const config = this.tables.get(table)
286
+ return config?.filters ?? []
292
287
  }
293
288
 
294
289
  /**
295
290
  * Get roles that skip RLS for a table
296
291
  */
297
292
  getSkipFor(table: string): string[] {
298
- const config = this.tables.get(table);
299
- return config?.skipFor ?? [];
293
+ const config = this.tables.get(table)
294
+ return config?.skipFor ?? []
300
295
  }
301
296
 
302
297
  /**
303
298
  * Check if table has default deny
304
299
  */
305
300
  hasDefaultDeny(table: string): boolean {
306
- const config = this.tables.get(table);
307
- return config?.defaultDeny ?? true;
301
+ const config = this.tables.get(table)
302
+ return config?.defaultDeny ?? true
308
303
  }
309
304
 
310
305
  /**
311
306
  * Check if a table is registered
312
307
  */
313
308
  hasTable(table: string): boolean {
314
- return this.tables.has(table);
309
+ return this.tables.has(table)
315
310
  }
316
311
 
317
312
  /**
318
313
  * Get all registered table names
319
314
  */
320
315
  getTables(): string[] {
321
- return Array.from(this.tables.keys());
316
+ return Array.from(this.tables.keys())
322
317
  }
323
318
 
324
319
  /**
325
320
  * Check if registry is compiled
326
321
  */
327
322
  isCompiled(): boolean {
328
- return this.compiled;
323
+ return this.compiled
329
324
  }
330
325
 
331
326
  /**
@@ -342,45 +337,45 @@ export class PolicyRegistry<DB = unknown> {
342
337
  config.allows.length > 0 ||
343
338
  config.denies.length > 0 ||
344
339
  config.filters.length > 0 ||
345
- config.validates.length > 0;
340
+ config.validates.length > 0
346
341
 
347
342
  if (!hasPolicy && !config.defaultDeny) {
348
343
  // Warning: table has no policies and defaultDeny is false
349
344
  this.logger.warn?.(
350
345
  `[RLS] Table "${table}" has no policies and defaultDeny is false. ` +
351
346
  `All operations will be allowed.`
352
- );
347
+ )
353
348
  }
354
349
 
355
350
  // Warn if skipFor includes operations that have policies
356
351
  if (config.skipFor.length > 0) {
357
- const opsWithPolicies = new Set<Operation>();
352
+ const opsWithPolicies = new Set<Operation>()
358
353
 
359
354
  for (const allow of config.allows) {
360
- allow.operations.forEach(op => opsWithPolicies.add(op));
355
+ allow.operations.forEach(op => opsWithPolicies.add(op))
361
356
  }
362
357
  for (const deny of config.denies) {
363
- deny.operations.forEach(op => opsWithPolicies.add(op));
358
+ deny.operations.forEach(op => opsWithPolicies.add(op))
364
359
  }
365
360
  for (const validate of config.validates) {
366
- validate.operations.forEach(op => opsWithPolicies.add(op));
361
+ validate.operations.forEach(op => opsWithPolicies.add(op))
367
362
  }
368
363
  if (config.filters.length > 0) {
369
- opsWithPolicies.add('read');
364
+ opsWithPolicies.add('read')
370
365
  }
371
366
 
372
367
  const skippedOpsWithPolicies = config.skipFor.filter(op => {
373
368
  // 'all' means skip all operations
374
- if (op === 'all') return opsWithPolicies.size > 0;
369
+ if (op === 'all') return opsWithPolicies.size > 0
375
370
  // Check if this is an operation name (for backwards compatibility)
376
- return opsWithPolicies.has(op as Operation);
377
- });
371
+ return opsWithPolicies.has(op as Operation)
372
+ })
378
373
 
379
374
  if (skippedOpsWithPolicies.length > 0) {
380
375
  this.logger.warn?.(
381
376
  `[RLS] Table "${table}" has skipFor operations that also have policies: ${skippedOpsWithPolicies.join(', ')}. ` +
382
377
  `The policies will be ignored for these operations.`
383
- );
378
+ )
384
379
  }
385
380
  }
386
381
  }
@@ -390,14 +385,14 @@ export class PolicyRegistry<DB = unknown> {
390
385
  * Clear all policies
391
386
  */
392
387
  clear(): void {
393
- this.tables.clear();
394
- this.compiled = false;
388
+ this.tables.clear()
389
+ this.compiled = false
395
390
  }
396
391
 
397
392
  /**
398
393
  * Remove policies for a specific table
399
394
  */
400
395
  remove(table: string): void {
401
- this.tables.delete(table);
396
+ this.tables.delete(table)
402
397
  }
403
398
  }