@kysera/rls 0.8.1 → 0.8.3

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,556 @@
1
+ /**
2
+ * Policy Composition Builder
3
+ *
4
+ * Factory functions for creating reusable, composable RLS policies.
5
+ *
6
+ * @module @kysera/rls/composition/builder
7
+ */
8
+
9
+ import type {
10
+ ReusablePolicy,
11
+ ReusablePolicyConfig,
12
+ TenantIsolationConfig,
13
+ OwnershipConfig,
14
+ SoftDeleteConfig,
15
+ StatusAccessConfig
16
+ } from './types.js'
17
+ import type { PolicyDefinition, PolicyEvaluationContext, Operation } from '../policy/types.js'
18
+ import { allow, deny, filter, validate } from '../policy/builder.js'
19
+
20
+ // ============================================================================
21
+ // Core Policy Builder
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Create a reusable policy template
26
+ *
27
+ * @param config - Policy configuration
28
+ * @param policies - Array of policy definitions
29
+ * @returns Reusable policy template
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * const tenantPolicy = definePolicy(
34
+ * {
35
+ * name: 'tenantIsolation',
36
+ * description: 'Filter by tenant_id',
37
+ * tags: ['multi-tenant']
38
+ * },
39
+ * [
40
+ * filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }), {
41
+ * priority: 1000,
42
+ * name: 'tenant-filter'
43
+ * }),
44
+ * validate('create', ctx => ctx.data?.tenant_id === ctx.auth.tenantId, {
45
+ * name: 'tenant-validate'
46
+ * })
47
+ * ]
48
+ * );
49
+ * ```
50
+ */
51
+ export function definePolicy(
52
+ config: ReusablePolicyConfig,
53
+ policies: PolicyDefinition[]
54
+ ): ReusablePolicy {
55
+ const result: ReusablePolicy = {
56
+ name: config.name,
57
+ policies
58
+ }
59
+
60
+ if (config.description !== undefined) {
61
+ result.description = config.description
62
+ }
63
+
64
+ if (config.tags !== undefined) {
65
+ result.tags = config.tags
66
+ }
67
+
68
+ return result
69
+ }
70
+
71
+ /**
72
+ * Create a filter-only policy
73
+ *
74
+ * @param name - Policy name
75
+ * @param filterFn - Filter condition
76
+ * @param options - Additional options
77
+ * @returns Reusable filter policy
78
+ */
79
+ export function defineFilterPolicy(
80
+ name: string,
81
+ filterFn: (ctx: PolicyEvaluationContext) => Record<string, unknown>,
82
+ options?: { priority?: number }
83
+ ): ReusablePolicy {
84
+ return {
85
+ name,
86
+ policies: [
87
+ filter('read', filterFn, {
88
+ name: `${name}-filter`,
89
+ ...(options?.priority !== undefined && { priority: options.priority })
90
+ })
91
+ ]
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Create an allow-based policy
97
+ *
98
+ * @param name - Policy name
99
+ * @param operation - Operations to allow
100
+ * @param condition - Allow condition
101
+ * @param options - Additional options
102
+ * @returns Reusable allow policy
103
+ */
104
+ export function defineAllowPolicy(
105
+ name: string,
106
+ operation: Operation | Operation[],
107
+ condition: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>,
108
+ options?: { priority?: number }
109
+ ): ReusablePolicy {
110
+ return {
111
+ name,
112
+ policies: [
113
+ allow(operation, condition, {
114
+ name: `${name}-allow`,
115
+ ...(options?.priority !== undefined && { priority: options.priority })
116
+ })
117
+ ]
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Create a deny-based policy
123
+ *
124
+ * @param name - Policy name
125
+ * @param operation - Operations to deny
126
+ * @param condition - Deny condition (optional - if not provided, always denies)
127
+ * @param options - Additional options
128
+ * @returns Reusable deny policy
129
+ */
130
+ export function defineDenyPolicy(
131
+ name: string,
132
+ operation: Operation | Operation[],
133
+ condition?: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>,
134
+ options?: { priority?: number }
135
+ ): ReusablePolicy {
136
+ return {
137
+ name,
138
+ policies: [
139
+ deny(operation, condition, {
140
+ name: `${name}-deny`,
141
+ priority: options?.priority ?? 100
142
+ })
143
+ ]
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Create a validation policy
149
+ *
150
+ * @param name - Policy name
151
+ * @param operation - Operations to validate
152
+ * @param condition - Validation condition
153
+ * @param options - Additional options
154
+ * @returns Reusable validate policy
155
+ */
156
+ export function defineValidatePolicy(
157
+ name: string,
158
+ operation: 'create' | 'update' | 'all',
159
+ condition: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>,
160
+ options?: { priority?: number }
161
+ ): ReusablePolicy {
162
+ return {
163
+ name,
164
+ policies: [
165
+ validate(operation, condition, {
166
+ name: `${name}-validate`,
167
+ ...(options?.priority !== undefined && { priority: options.priority })
168
+ })
169
+ ]
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Create a combined policy with multiple types
175
+ *
176
+ * @param name - Policy name
177
+ * @param config - Policy configurations
178
+ * @returns Reusable combined policy
179
+ */
180
+ export function defineCombinedPolicy(
181
+ name: string,
182
+ config: {
183
+ filter?: (ctx: PolicyEvaluationContext) => Record<string, unknown>
184
+ allow?: Record<string, (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>>
185
+ deny?: Record<string, (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>>
186
+ validate?: {
187
+ create?: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>
188
+ update?: (ctx: PolicyEvaluationContext) => boolean | Promise<boolean>
189
+ }
190
+ }
191
+ ): ReusablePolicy {
192
+ const policies: PolicyDefinition[] = []
193
+
194
+ // Add filter policy
195
+ if (config.filter) {
196
+ policies.push(
197
+ filter('read', config.filter, {
198
+ name: `${name}-filter`
199
+ })
200
+ )
201
+ }
202
+
203
+ // Add allow policies
204
+ if (config.allow) {
205
+ for (const [op, condition] of Object.entries(config.allow)) {
206
+ if (condition) {
207
+ policies.push(
208
+ allow(op as Operation, condition, {
209
+ name: `${name}-allow-${op}`
210
+ })
211
+ )
212
+ }
213
+ }
214
+ }
215
+
216
+ // Add deny policies
217
+ if (config.deny) {
218
+ for (const [op, condition] of Object.entries(config.deny)) {
219
+ if (condition) {
220
+ policies.push(
221
+ deny(op as Operation, condition, {
222
+ name: `${name}-deny-${op}`,
223
+ priority: 100
224
+ })
225
+ )
226
+ }
227
+ }
228
+ }
229
+
230
+ // Add validate policies
231
+ if (config.validate) {
232
+ if (config.validate.create) {
233
+ policies.push(
234
+ validate('create', config.validate.create, {
235
+ name: `${name}-validate-create`
236
+ })
237
+ )
238
+ }
239
+ if (config.validate.update) {
240
+ policies.push(
241
+ validate('update', config.validate.update, {
242
+ name: `${name}-validate-update`
243
+ })
244
+ )
245
+ }
246
+ }
247
+
248
+ return {
249
+ name,
250
+ policies
251
+ }
252
+ }
253
+
254
+ // ============================================================================
255
+ // Common Policy Patterns
256
+ // ============================================================================
257
+
258
+ /**
259
+ * Create a tenant isolation policy
260
+ *
261
+ * Automatically filters by tenant_id and validates mutations.
262
+ *
263
+ * @param config - Tenant isolation configuration
264
+ * @returns Reusable tenant isolation policy
265
+ */
266
+ export function createTenantIsolationPolicy(config: TenantIsolationConfig = {}): ReusablePolicy {
267
+ const { tenantColumn = 'tenant_id', validateOnMutation = true } = config
268
+
269
+ const policies: PolicyDefinition[] = [
270
+ // Filter reads by tenant
271
+ filter('read', ctx => ({ [tenantColumn]: ctx.auth.tenantId }), {
272
+ name: 'tenant-isolation-filter',
273
+ priority: 1000
274
+ })
275
+ ]
276
+
277
+ // Validate tenant on mutations
278
+ if (validateOnMutation) {
279
+ policies.push(
280
+ validate('create', ctx => {
281
+ const data = ctx.data as Record<string, unknown> | undefined
282
+ return data?.[tenantColumn] === ctx.auth.tenantId
283
+ }, {
284
+ name: 'tenant-isolation-validate-create'
285
+ }),
286
+ validate('update', ctx => {
287
+ const data = ctx.data as Record<string, unknown> | undefined
288
+ // Cannot change tenant on update
289
+ if (data?.[tenantColumn] !== undefined) {
290
+ return data[tenantColumn] === ctx.auth.tenantId
291
+ }
292
+ return true
293
+ }, {
294
+ name: 'tenant-isolation-validate-update'
295
+ })
296
+ )
297
+ }
298
+
299
+ return {
300
+ name: 'tenantIsolation',
301
+ description: `Filter by ${tenantColumn} for multi-tenancy`,
302
+ policies,
303
+ tags: ['multi-tenant', 'isolation']
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Create an ownership policy
309
+ *
310
+ * Allows owners to read/update/delete their own resources.
311
+ *
312
+ * @param config - Ownership configuration
313
+ * @returns Reusable ownership policy
314
+ */
315
+ export function createOwnershipPolicy(config: OwnershipConfig = {}): ReusablePolicy {
316
+ const { ownerColumn = 'owner_id', ownerOperations = ['read', 'update', 'delete'], canDelete = true } = config
317
+
318
+ const policies: PolicyDefinition[] = []
319
+
320
+ // Filter ops to only those allowed
321
+ const ops = ownerOperations.filter(op => op !== 'delete' || canDelete)
322
+
323
+ if (ops.length > 0) {
324
+ policies.push(
325
+ allow(ops, ctx => {
326
+ const row = ctx.row as Record<string, unknown> | undefined
327
+ return ctx.auth.userId === row?.[ownerColumn]
328
+ }, {
329
+ name: 'ownership-allow'
330
+ })
331
+ )
332
+ }
333
+
334
+ // Explicit deny for delete if not allowed
335
+ if (!canDelete && ownerOperations.includes('delete')) {
336
+ policies.push(
337
+ deny('delete', () => true, {
338
+ name: 'ownership-no-delete',
339
+ priority: 150
340
+ })
341
+ )
342
+ }
343
+
344
+ return {
345
+ name: 'ownership',
346
+ description: `Owner access via ${ownerColumn}`,
347
+ policies,
348
+ tags: ['ownership']
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Create a soft delete policy
354
+ *
355
+ * Filters out soft-deleted rows and optionally prevents hard deletes.
356
+ *
357
+ * @param config - Soft delete configuration
358
+ * @returns Reusable soft delete policy
359
+ */
360
+ export function createSoftDeletePolicy(config: SoftDeleteConfig = {}): ReusablePolicy {
361
+ const { deletedColumn = 'deleted_at', filterOnRead = true, preventHardDelete = true } = config
362
+
363
+ const policies: PolicyDefinition[] = []
364
+
365
+ // Filter soft-deleted rows
366
+ if (filterOnRead) {
367
+ policies.push(
368
+ filter('read', () => ({ [deletedColumn]: null }), {
369
+ name: 'soft-delete-filter',
370
+ priority: 900
371
+ })
372
+ )
373
+ }
374
+
375
+ // Prevent hard deletes
376
+ if (preventHardDelete) {
377
+ policies.push(
378
+ deny('delete', () => true, {
379
+ name: 'soft-delete-no-hard-delete',
380
+ priority: 150
381
+ })
382
+ )
383
+ }
384
+
385
+ return {
386
+ name: 'softDelete',
387
+ description: `Soft delete via ${deletedColumn}`,
388
+ policies,
389
+ tags: ['soft-delete']
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Create a status-based access policy
395
+ *
396
+ * Controls access based on resource status.
397
+ *
398
+ * @param config - Status access configuration
399
+ * @returns Reusable status policy
400
+ */
401
+ export function createStatusAccessPolicy(config: StatusAccessConfig): ReusablePolicy {
402
+ const { statusColumn = 'status', publicStatuses = [], editableStatuses = [], deletableStatuses = [] } = config
403
+
404
+ const policies: PolicyDefinition[] = []
405
+
406
+ // Allow public read for certain statuses
407
+ if (publicStatuses.length > 0) {
408
+ policies.push(
409
+ allow('read', ctx => {
410
+ const row = ctx.row as Record<string, unknown> | undefined
411
+ return publicStatuses.includes(row?.[statusColumn] as string)
412
+ }, {
413
+ name: 'status-public-read'
414
+ })
415
+ )
416
+ }
417
+
418
+ // Restrict updates to certain statuses
419
+ if (editableStatuses.length > 0) {
420
+ policies.push(
421
+ deny('update', ctx => {
422
+ const row = ctx.row as Record<string, unknown> | undefined
423
+ return !editableStatuses.includes(row?.[statusColumn] as string)
424
+ }, {
425
+ name: 'status-restrict-update',
426
+ priority: 100
427
+ })
428
+ )
429
+ }
430
+
431
+ // Restrict deletes to certain statuses
432
+ if (deletableStatuses.length > 0) {
433
+ policies.push(
434
+ deny('delete', ctx => {
435
+ const row = ctx.row as Record<string, unknown> | undefined
436
+ return !deletableStatuses.includes(row?.[statusColumn] as string)
437
+ }, {
438
+ name: 'status-restrict-delete',
439
+ priority: 100
440
+ })
441
+ )
442
+ }
443
+
444
+ return {
445
+ name: 'statusAccess',
446
+ description: `Status-based access via ${statusColumn}`,
447
+ policies,
448
+ tags: ['status']
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Create an admin bypass policy
454
+ *
455
+ * Allows admin roles to perform all operations.
456
+ *
457
+ * @param roles - Roles that have admin access
458
+ * @returns Reusable admin policy
459
+ */
460
+ export function createAdminPolicy(roles: string[]): ReusablePolicy {
461
+ return {
462
+ name: 'adminBypass',
463
+ description: `Admin access for roles: ${roles.join(', ')}`,
464
+ policies: [
465
+ allow('all', ctx => roles.some(r => ctx.auth.roles.includes(r)), {
466
+ name: 'admin-bypass',
467
+ priority: 500
468
+ })
469
+ ],
470
+ tags: ['admin']
471
+ }
472
+ }
473
+
474
+ // ============================================================================
475
+ // Policy Composition Functions
476
+ // ============================================================================
477
+
478
+ /**
479
+ * Compose multiple reusable policies into one
480
+ *
481
+ * @param name - Name for the composed policy
482
+ * @param policies - Policies to compose
483
+ * @returns Composed policy
484
+ */
485
+ export function composePolicies(name: string, policies: ReusablePolicy[]): ReusablePolicy {
486
+ const allPolicies: PolicyDefinition[] = []
487
+ const allTags = new Set<string>()
488
+
489
+ for (const policy of policies) {
490
+ allPolicies.push(...policy.policies)
491
+ policy.tags?.forEach(tag => allTags.add(tag))
492
+ }
493
+
494
+ return {
495
+ name,
496
+ description: `Composed from: ${policies.map(p => p.name).join(', ')}`,
497
+ policies: allPolicies,
498
+ tags: Array.from(allTags)
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Extend a reusable policy with additional policies
504
+ *
505
+ * @param base - Base policy to extend
506
+ * @param additional - Additional policies to add
507
+ * @returns Extended policy
508
+ */
509
+ export function extendPolicy(base: ReusablePolicy, additional: PolicyDefinition[]): ReusablePolicy {
510
+ const result: ReusablePolicy = {
511
+ name: `${base.name}_extended`,
512
+ policies: [...base.policies, ...additional]
513
+ }
514
+
515
+ if (base.description !== undefined) {
516
+ result.description = base.description
517
+ }
518
+
519
+ if (base.tags !== undefined) {
520
+ result.tags = base.tags
521
+ }
522
+
523
+ return result
524
+ }
525
+
526
+ /**
527
+ * Override policies from a base with new conditions
528
+ *
529
+ * @param base - Base policy
530
+ * @param overrides - Policy name to new policy mapping
531
+ * @returns Policy with overrides applied
532
+ */
533
+ export function overridePolicy(
534
+ base: ReusablePolicy,
535
+ overrides: Record<string, PolicyDefinition>
536
+ ): ReusablePolicy {
537
+ const newPolicies = base.policies.map(policy => {
538
+ const override = policy.name ? overrides[policy.name] : undefined
539
+ return override ?? policy
540
+ })
541
+
542
+ const result: ReusablePolicy = {
543
+ name: `${base.name}_overridden`,
544
+ policies: newPolicies
545
+ }
546
+
547
+ if (base.description !== undefined) {
548
+ result.description = base.description
549
+ }
550
+
551
+ if (base.tags !== undefined) {
552
+ result.tags = base.tags
553
+ }
554
+
555
+ return result
556
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Policy Composition Module
3
+ *
4
+ * Provides tools for creating reusable, composable RLS policies.
5
+ *
6
+ * @module @kysera/rls/composition
7
+ */
8
+
9
+ // Types
10
+ export type {
11
+ ReusablePolicy,
12
+ ReusablePolicyConfig,
13
+ ComposableTableConfig,
14
+ ComposableRLSSchema,
15
+ BasePolicyDefinition,
16
+ ResolvedInheritance,
17
+ TenantIsolationConfig,
18
+ OwnershipConfig,
19
+ SoftDeleteConfig,
20
+ StatusAccessConfig
21
+ } from './types.js'
22
+
23
+ // Builders
24
+ export {
25
+ definePolicy,
26
+ defineFilterPolicy,
27
+ defineAllowPolicy,
28
+ defineDenyPolicy,
29
+ defineValidatePolicy,
30
+ defineCombinedPolicy
31
+ } from './builder.js'
32
+
33
+ // Common patterns
34
+ export {
35
+ createTenantIsolationPolicy,
36
+ createOwnershipPolicy,
37
+ createSoftDeletePolicy,
38
+ createStatusAccessPolicy,
39
+ createAdminPolicy
40
+ } from './builder.js'
41
+
42
+ // Composition functions
43
+ export { composePolicies, extendPolicy, overridePolicy } from './builder.js'