@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
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';
|