@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,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
|
+
}
|