@kysera/rls 0.8.1 → 0.8.2
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/README.md +819 -3
- package/dist/index.d.ts +2824 -8
- package/dist/index.js +2451 -1
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +1 -1
- package/dist/{types-Dowjd6zG.d.ts → types-CyqksFKU.d.ts} +72 -1
- package/package.json +6 -6
- package/src/audit/index.ts +25 -0
- package/src/audit/logger.ts +465 -0
- package/src/audit/types.ts +625 -0
- package/src/composition/builder.ts +556 -0
- package/src/composition/index.ts +43 -0
- package/src/composition/types.ts +415 -0
- package/src/field-access/index.ts +38 -0
- package/src/field-access/processor.ts +442 -0
- package/src/field-access/registry.ts +259 -0
- package/src/field-access/types.ts +453 -0
- package/src/index.ts +180 -2
- package/src/policy/builder.ts +187 -10
- package/src/policy/types.ts +84 -0
- package/src/rebac/index.ts +30 -0
- package/src/rebac/registry.ts +303 -0
- package/src/rebac/transformer.ts +391 -0
- package/src/rebac/types.ts +412 -0
- package/src/resolvers/index.ts +30 -0
- package/src/resolvers/manager.ts +507 -0
- package/src/resolvers/types.ts +447 -0
- package/src/testing/index.ts +554 -0
package/src/policy/builder.ts
CHANGED
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
import type {
|
|
12
12
|
Operation,
|
|
13
|
-
PolicyDefinition,
|
|
14
13
|
PolicyCondition,
|
|
15
14
|
FilterCondition,
|
|
16
|
-
PolicyHints
|
|
15
|
+
PolicyHints,
|
|
16
|
+
PolicyActivationCondition,
|
|
17
|
+
ConditionalPolicyDefinition
|
|
17
18
|
} from './types.js'
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -26,6 +27,24 @@ export interface PolicyOptions {
|
|
|
26
27
|
priority?: number
|
|
27
28
|
/** Performance optimization hints */
|
|
28
29
|
hints?: PolicyHints
|
|
30
|
+
/**
|
|
31
|
+
* Condition that determines if this policy is active
|
|
32
|
+
* The policy will only be evaluated if this returns true
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* // Only apply in production
|
|
37
|
+
* allow('read', () => true, {
|
|
38
|
+
* condition: ctx => ctx.meta?.environment === 'production'
|
|
39
|
+
* })
|
|
40
|
+
*
|
|
41
|
+
* // Feature-gated policy
|
|
42
|
+
* filter('read', ctx => ({ strict: true }), {
|
|
43
|
+
* condition: ctx => ctx.meta?.features?.strictMode
|
|
44
|
+
* })
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
condition?: PolicyActivationCondition
|
|
29
48
|
}
|
|
30
49
|
|
|
31
50
|
/**
|
|
@@ -54,8 +73,8 @@ export function allow(
|
|
|
54
73
|
operation: Operation | Operation[],
|
|
55
74
|
condition: PolicyCondition,
|
|
56
75
|
options?: PolicyOptions
|
|
57
|
-
):
|
|
58
|
-
const policy:
|
|
76
|
+
): ConditionalPolicyDefinition {
|
|
77
|
+
const policy: ConditionalPolicyDefinition = {
|
|
59
78
|
type: 'allow',
|
|
60
79
|
operation,
|
|
61
80
|
condition: condition,
|
|
@@ -70,6 +89,10 @@ export function allow(
|
|
|
70
89
|
policy.hints = options.hints
|
|
71
90
|
}
|
|
72
91
|
|
|
92
|
+
if (options?.condition !== undefined) {
|
|
93
|
+
policy.activationCondition = options.condition
|
|
94
|
+
}
|
|
95
|
+
|
|
73
96
|
return policy
|
|
74
97
|
}
|
|
75
98
|
|
|
@@ -100,8 +123,8 @@ export function deny(
|
|
|
100
123
|
operation: Operation | Operation[],
|
|
101
124
|
condition?: PolicyCondition,
|
|
102
125
|
options?: PolicyOptions
|
|
103
|
-
):
|
|
104
|
-
const policy:
|
|
126
|
+
): ConditionalPolicyDefinition {
|
|
127
|
+
const policy: ConditionalPolicyDefinition = {
|
|
105
128
|
type: 'deny',
|
|
106
129
|
operation,
|
|
107
130
|
condition: condition ?? (() => true),
|
|
@@ -116,6 +139,10 @@ export function deny(
|
|
|
116
139
|
policy.hints = options.hints
|
|
117
140
|
}
|
|
118
141
|
|
|
142
|
+
if (options?.condition !== undefined) {
|
|
143
|
+
policy.activationCondition = options.condition
|
|
144
|
+
}
|
|
145
|
+
|
|
119
146
|
return policy
|
|
120
147
|
}
|
|
121
148
|
|
|
@@ -159,8 +186,8 @@ export function filter(
|
|
|
159
186
|
operation: 'read' | 'all',
|
|
160
187
|
condition: FilterCondition,
|
|
161
188
|
options?: PolicyOptions
|
|
162
|
-
):
|
|
163
|
-
const policy:
|
|
189
|
+
): ConditionalPolicyDefinition {
|
|
190
|
+
const policy: ConditionalPolicyDefinition = {
|
|
164
191
|
type: 'filter',
|
|
165
192
|
operation: operation === 'all' ? 'read' : operation,
|
|
166
193
|
condition: condition as unknown as PolicyCondition,
|
|
@@ -175,6 +202,10 @@ export function filter(
|
|
|
175
202
|
policy.hints = options.hints
|
|
176
203
|
}
|
|
177
204
|
|
|
205
|
+
if (options?.condition !== undefined) {
|
|
206
|
+
policy.activationCondition = options.condition
|
|
207
|
+
}
|
|
208
|
+
|
|
178
209
|
return policy
|
|
179
210
|
}
|
|
180
211
|
|
|
@@ -206,10 +237,10 @@ export function validate(
|
|
|
206
237
|
operation: 'create' | 'update' | 'all',
|
|
207
238
|
condition: PolicyCondition,
|
|
208
239
|
options?: PolicyOptions
|
|
209
|
-
):
|
|
240
|
+
): ConditionalPolicyDefinition {
|
|
210
241
|
const ops: Operation[] = operation === 'all' ? ['create', 'update'] : [operation]
|
|
211
242
|
|
|
212
|
-
const policy:
|
|
243
|
+
const policy: ConditionalPolicyDefinition = {
|
|
213
244
|
type: 'validate',
|
|
214
245
|
operation: ops,
|
|
215
246
|
condition: condition,
|
|
@@ -224,5 +255,151 @@ export function validate(
|
|
|
224
255
|
policy.hints = options.hints
|
|
225
256
|
}
|
|
226
257
|
|
|
258
|
+
if (options?.condition !== undefined) {
|
|
259
|
+
policy.activationCondition = options.condition
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return policy
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ============================================================================
|
|
266
|
+
// Conditional Policy Helpers
|
|
267
|
+
// ============================================================================
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Create a policy that is only active in specific environments
|
|
271
|
+
*
|
|
272
|
+
* @param environments - Environments where the policy is active
|
|
273
|
+
* @param policyFn - Function that creates the policy
|
|
274
|
+
* @returns Policy with environment condition
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* ```typescript
|
|
278
|
+
* // Policy only active in production
|
|
279
|
+
* const prodPolicy = whenEnvironment(['production'], () =>
|
|
280
|
+
* allow('read', () => true, { name: 'prod-read' })
|
|
281
|
+
* );
|
|
282
|
+
*
|
|
283
|
+
* // Policy active in staging and production
|
|
284
|
+
* const nonDevPolicy = whenEnvironment(['staging', 'production'], () =>
|
|
285
|
+
* filter('read', ctx => ({ tenant_id: ctx.auth.tenantId }))
|
|
286
|
+
* );
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
export function whenEnvironment(
|
|
290
|
+
environments: string[],
|
|
291
|
+
policyFn: () => ConditionalPolicyDefinition
|
|
292
|
+
): ConditionalPolicyDefinition {
|
|
293
|
+
const policy = policyFn()
|
|
294
|
+
const existingCondition = policy.activationCondition
|
|
295
|
+
policy.activationCondition = ctx => {
|
|
296
|
+
const envMatch = environments.includes(ctx.environment ?? '')
|
|
297
|
+
if (!envMatch) return false
|
|
298
|
+
// If there was an existing condition (from inner wrapper), it must also pass
|
|
299
|
+
return existingCondition ? existingCondition(ctx) : true
|
|
300
|
+
}
|
|
301
|
+
return policy
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Create a policy that is only active when a feature flag is enabled
|
|
306
|
+
*
|
|
307
|
+
* @param feature - Feature flag name
|
|
308
|
+
* @param policyFn - Function that creates the policy
|
|
309
|
+
* @returns Policy with feature flag condition
|
|
310
|
+
*
|
|
311
|
+
* @example
|
|
312
|
+
* ```typescript
|
|
313
|
+
* // Policy only active when 'strict_rls' feature is enabled
|
|
314
|
+
* const strictPolicy = whenFeature('strict_rls', () =>
|
|
315
|
+
* deny('delete', () => true, { name: 'strict-no-delete' })
|
|
316
|
+
* );
|
|
317
|
+
* ```
|
|
318
|
+
*/
|
|
319
|
+
export function whenFeature(
|
|
320
|
+
feature: string,
|
|
321
|
+
policyFn: () => ConditionalPolicyDefinition
|
|
322
|
+
): ConditionalPolicyDefinition {
|
|
323
|
+
const policy = policyFn()
|
|
324
|
+
const existingCondition = policy.activationCondition
|
|
325
|
+
policy.activationCondition = ctx => {
|
|
326
|
+
let featureEnabled = false
|
|
327
|
+
if (Array.isArray(ctx.features)) {
|
|
328
|
+
featureEnabled = ctx.features.includes(feature)
|
|
329
|
+
} else if (ctx.features && typeof ctx.features === 'object' && 'has' in ctx.features) {
|
|
330
|
+
// Support Set<string>
|
|
331
|
+
featureEnabled = (ctx.features as Set<string>).has(feature)
|
|
332
|
+
} else if (ctx.features && typeof ctx.features === 'object') {
|
|
333
|
+
// Support object-style features: { feature_name: boolean }
|
|
334
|
+
featureEnabled = !!(ctx.features as Record<string, unknown>)[feature]
|
|
335
|
+
}
|
|
336
|
+
if (!featureEnabled) return false
|
|
337
|
+
// If there was an existing condition (from inner wrapper), it must also pass
|
|
338
|
+
return existingCondition ? existingCondition(ctx) : true
|
|
339
|
+
}
|
|
340
|
+
return policy
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Create a policy that is only active during specific hours
|
|
345
|
+
*
|
|
346
|
+
* @param startHour - Start hour (0-23)
|
|
347
|
+
* @param endHour - End hour (0-23)
|
|
348
|
+
* @param policyFn - Function that creates the policy
|
|
349
|
+
* @returns Policy with time-based condition
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* ```typescript
|
|
353
|
+
* // Policy only active during business hours (9 AM - 5 PM)
|
|
354
|
+
* const businessHoursPolicy = whenTimeRange(9, 17, () =>
|
|
355
|
+
* allow('update', () => true, { name: 'business-hours-update' })
|
|
356
|
+
* );
|
|
357
|
+
* ```
|
|
358
|
+
*/
|
|
359
|
+
export function whenTimeRange(
|
|
360
|
+
startHour: number,
|
|
361
|
+
endHour: number,
|
|
362
|
+
policyFn: () => ConditionalPolicyDefinition
|
|
363
|
+
): ConditionalPolicyDefinition {
|
|
364
|
+
const policy = policyFn()
|
|
365
|
+
const existingCondition = policy.activationCondition
|
|
366
|
+
policy.activationCondition = ctx => {
|
|
367
|
+
const hour = (ctx.timestamp ?? new Date()).getHours()
|
|
368
|
+
let inRange: boolean
|
|
369
|
+
// Handle midnight crossing (e.g., 22:00 to 06:00)
|
|
370
|
+
if (startHour > endHour) {
|
|
371
|
+
inRange = hour >= startHour || hour < endHour
|
|
372
|
+
} else {
|
|
373
|
+
inRange = hour >= startHour && hour < endHour
|
|
374
|
+
}
|
|
375
|
+
if (!inRange) return false
|
|
376
|
+
// If there was an existing condition (from inner wrapper), it must also pass
|
|
377
|
+
return existingCondition ? existingCondition(ctx) : true
|
|
378
|
+
}
|
|
379
|
+
return policy
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Create a policy that is only active when a custom condition is met
|
|
384
|
+
*
|
|
385
|
+
* @param condition - Custom activation condition
|
|
386
|
+
* @param policyFn - Function that creates the policy
|
|
387
|
+
* @returns Policy with custom condition
|
|
388
|
+
*
|
|
389
|
+
* @example
|
|
390
|
+
* ```typescript
|
|
391
|
+
* // Policy only active when user is in beta program
|
|
392
|
+
* const betaPolicy = whenCondition(
|
|
393
|
+
* ctx => ctx.meta?.betaUser === true,
|
|
394
|
+
* () => allow('read', () => true, { name: 'beta-read' })
|
|
395
|
+
* );
|
|
396
|
+
* ```
|
|
397
|
+
*/
|
|
398
|
+
export function whenCondition(
|
|
399
|
+
condition: PolicyActivationCondition,
|
|
400
|
+
policyFn: () => ConditionalPolicyDefinition
|
|
401
|
+
): ConditionalPolicyDefinition {
|
|
402
|
+
const policy = policyFn()
|
|
403
|
+
policy.activationCondition = condition
|
|
227
404
|
return policy
|
|
228
405
|
}
|
package/src/policy/types.ts
CHANGED
|
@@ -751,4 +751,88 @@ export interface PolicyHints {
|
|
|
751
751
|
* Stable policies can be cached during a query execution
|
|
752
752
|
*/
|
|
753
753
|
stable?: boolean
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Whether the policy condition can be async
|
|
757
|
+
* @internal Used by validation and allow/deny policies
|
|
758
|
+
*/
|
|
759
|
+
async?: boolean
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Whether the policy result is cacheable
|
|
763
|
+
*/
|
|
764
|
+
cacheable?: boolean
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Cache TTL in seconds if cacheable
|
|
768
|
+
*/
|
|
769
|
+
cacheTTL?: number
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// ============================================================================
|
|
773
|
+
// Conditional Policy Activation
|
|
774
|
+
// ============================================================================
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Context for evaluating policy activation conditions
|
|
778
|
+
*
|
|
779
|
+
* Contains metadata about the current environment that determines
|
|
780
|
+
* whether a policy should be active.
|
|
781
|
+
*/
|
|
782
|
+
export interface PolicyActivationContext {
|
|
783
|
+
/**
|
|
784
|
+
* Current environment (development, staging, production)
|
|
785
|
+
*/
|
|
786
|
+
environment?: string
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Feature flags that are enabled
|
|
790
|
+
* Can be a Set, array, or object with boolean/truthy values
|
|
791
|
+
*/
|
|
792
|
+
features?: Set<string> | string[] | Record<string, unknown>
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Current timestamp (for time-based policies)
|
|
796
|
+
*/
|
|
797
|
+
timestamp?: Date
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Custom metadata for activation decisions
|
|
801
|
+
*/
|
|
802
|
+
meta?: Record<string, unknown>
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Condition function for policy activation
|
|
807
|
+
*
|
|
808
|
+
* Returns true if the policy should be active, false otherwise.
|
|
809
|
+
*
|
|
810
|
+
* @example
|
|
811
|
+
* ```typescript
|
|
812
|
+
* // Only active in production
|
|
813
|
+
* const productionOnly: PolicyActivationCondition = ctx =>
|
|
814
|
+
* ctx.environment === 'production';
|
|
815
|
+
*
|
|
816
|
+
* // Only active when feature flag is enabled
|
|
817
|
+
* const featureGated: PolicyActivationCondition = ctx =>
|
|
818
|
+
* ctx.features?.includes('new_security_policy') ?? false;
|
|
819
|
+
*
|
|
820
|
+
* // Time-based activation (active during business hours)
|
|
821
|
+
* const businessHours: PolicyActivationCondition = ctx => {
|
|
822
|
+
* const hour = (ctx.timestamp ?? new Date()).getHours();
|
|
823
|
+
* return hour >= 9 && hour < 17;
|
|
824
|
+
* };
|
|
825
|
+
* ```
|
|
826
|
+
*/
|
|
827
|
+
export type PolicyActivationCondition = (ctx: PolicyActivationContext) => boolean
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Extended policy definition with activation condition
|
|
831
|
+
*/
|
|
832
|
+
export interface ConditionalPolicyDefinition extends PolicyDefinition {
|
|
833
|
+
/**
|
|
834
|
+
* Condition that determines if this policy is active
|
|
835
|
+
* If undefined, the policy is always active
|
|
836
|
+
*/
|
|
837
|
+
activationCondition?: PolicyActivationCondition
|
|
754
838
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReBAC (Relationship-Based Access Control) Module
|
|
3
|
+
*
|
|
4
|
+
* Provides relationship-based filtering for RLS policies.
|
|
5
|
+
*
|
|
6
|
+
* @module @kysera/rls/rebac
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Types
|
|
10
|
+
export type {
|
|
11
|
+
RelationshipStep,
|
|
12
|
+
RelationshipPath,
|
|
13
|
+
RelationshipCondition,
|
|
14
|
+
ReBAcPolicyDefinition,
|
|
15
|
+
TableReBAcConfig,
|
|
16
|
+
ReBAcSchema,
|
|
17
|
+
CompiledRelationshipPath,
|
|
18
|
+
CompiledReBAcPolicy,
|
|
19
|
+
ReBAcSubquery,
|
|
20
|
+
ReBAcQueryOptions
|
|
21
|
+
} from './types.js'
|
|
22
|
+
|
|
23
|
+
// Predefined path patterns
|
|
24
|
+
export { orgMembershipPath, shopOrgMembershipPath, teamHierarchyPath } from './types.js'
|
|
25
|
+
|
|
26
|
+
// Registry
|
|
27
|
+
export { ReBAcRegistry, createReBAcRegistry } from './registry.js'
|
|
28
|
+
|
|
29
|
+
// Transformer
|
|
30
|
+
export { ReBAcTransformer, createReBAcTransformer, allowRelation, denyRelation } from './transformer.js'
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReBAC Policy Registry
|
|
3
|
+
*
|
|
4
|
+
* Manages relationship definitions and ReBAC policies for RLS.
|
|
5
|
+
*
|
|
6
|
+
* @module @kysera/rls/rebac/registry
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
RelationshipPath,
|
|
11
|
+
RelationshipStep,
|
|
12
|
+
ReBAcPolicyDefinition,
|
|
13
|
+
ReBAcSchema,
|
|
14
|
+
TableReBAcConfig,
|
|
15
|
+
CompiledRelationshipPath,
|
|
16
|
+
CompiledReBAcPolicy
|
|
17
|
+
} from './types.js'
|
|
18
|
+
import type { PolicyEvaluationContext, Operation } from '../policy/types.js'
|
|
19
|
+
import { RLSSchemaError } from '../errors.js'
|
|
20
|
+
import { silentLogger, type KyseraLogger } from '@kysera/core'
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// ReBAC Registry
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Internal compiled table configuration
|
|
28
|
+
*/
|
|
29
|
+
interface TableReBAcCompiled {
|
|
30
|
+
relationships: Map<string, CompiledRelationshipPath>
|
|
31
|
+
policies: CompiledReBAcPolicy[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* ReBAC Registry
|
|
36
|
+
*
|
|
37
|
+
* Manages relationship paths and ReBAC policies across tables.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* const registry = new ReBAcRegistry();
|
|
42
|
+
*
|
|
43
|
+
* // Register relationship paths and policies
|
|
44
|
+
* registry.loadSchema({
|
|
45
|
+
* products: {
|
|
46
|
+
* relationships: [
|
|
47
|
+
* shopOrgMembershipPath('products', 'shop_id')
|
|
48
|
+
* ],
|
|
49
|
+
* policies: [
|
|
50
|
+
* {
|
|
51
|
+
* type: 'filter',
|
|
52
|
+
* operation: 'read',
|
|
53
|
+
* relationshipPath: 'products_shop_org_membership',
|
|
54
|
+
* endCondition: ctx => ({
|
|
55
|
+
* user_id: ctx.auth.userId,
|
|
56
|
+
* status: 'active'
|
|
57
|
+
* })
|
|
58
|
+
* }
|
|
59
|
+
* ]
|
|
60
|
+
* }
|
|
61
|
+
* });
|
|
62
|
+
*
|
|
63
|
+
* // Get policies for a table
|
|
64
|
+
* const policies = registry.getPolicies('products', 'read');
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export class ReBAcRegistry<DB = unknown> {
|
|
68
|
+
private tables = new Map<string, TableReBAcCompiled>()
|
|
69
|
+
private globalRelationships = new Map<string, CompiledRelationshipPath>()
|
|
70
|
+
private logger: KyseraLogger
|
|
71
|
+
|
|
72
|
+
constructor(schema?: ReBAcSchema<DB>, options?: { logger?: KyseraLogger }) {
|
|
73
|
+
this.logger = options?.logger ?? silentLogger
|
|
74
|
+
if (schema) {
|
|
75
|
+
this.loadSchema(schema)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Load ReBAC schema
|
|
81
|
+
*/
|
|
82
|
+
loadSchema(schema: ReBAcSchema<DB>): void {
|
|
83
|
+
for (const [table, config] of Object.entries(schema)) {
|
|
84
|
+
if (!config) continue
|
|
85
|
+
this.registerTable(table, config as TableReBAcConfig)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Register ReBAC configuration for a single table
|
|
91
|
+
*/
|
|
92
|
+
registerTable(table: string, config: TableReBAcConfig): void {
|
|
93
|
+
const compiled: TableReBAcCompiled = {
|
|
94
|
+
relationships: new Map(),
|
|
95
|
+
policies: []
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Compile relationships
|
|
99
|
+
for (const rel of config.relationships) {
|
|
100
|
+
const compiledPath = this.compileRelationshipPath(rel, table)
|
|
101
|
+
compiled.relationships.set(rel.name, compiledPath)
|
|
102
|
+
// Also register globally for cross-table references
|
|
103
|
+
this.globalRelationships.set(rel.name, compiledPath)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Compile policies
|
|
107
|
+
for (let i = 0; i < config.policies.length; i++) {
|
|
108
|
+
const policy = config.policies[i]
|
|
109
|
+
if (!policy) continue
|
|
110
|
+
|
|
111
|
+
const policyName = policy.name ?? `${table}_rebac_policy_${i}`
|
|
112
|
+
const compiledPolicy = this.compilePolicy(policy, policyName, table, compiled.relationships)
|
|
113
|
+
compiled.policies.push(compiledPolicy)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Sort by priority
|
|
117
|
+
compiled.policies.sort((a, b) => b.priority - a.priority)
|
|
118
|
+
|
|
119
|
+
this.tables.set(table, compiled)
|
|
120
|
+
this.logger.info?.(`[ReBAC] Registered table: ${table}`, {
|
|
121
|
+
relationships: config.relationships.length,
|
|
122
|
+
policies: config.policies.length
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Register a global relationship path (available to all tables)
|
|
128
|
+
*/
|
|
129
|
+
registerRelationship(path: RelationshipPath): void {
|
|
130
|
+
if (!path.steps.length) {
|
|
131
|
+
throw new RLSSchemaError(`Relationship path "${path.name}" has no steps`, {
|
|
132
|
+
path: path.name
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const compiled = this.compileRelationshipPath(path, path.steps[0]!.from)
|
|
137
|
+
this.globalRelationships.set(path.name, compiled)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get ReBAC policies for a table and operation
|
|
142
|
+
*/
|
|
143
|
+
getPolicies(table: string, operation: Operation): CompiledReBAcPolicy[] {
|
|
144
|
+
const config = this.tables.get(table)
|
|
145
|
+
if (!config) return []
|
|
146
|
+
|
|
147
|
+
return config.policies.filter(p => p.operations.has(operation) || p.operations.has('all'))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get a specific relationship path
|
|
152
|
+
*/
|
|
153
|
+
getRelationship(name: string, table?: string): CompiledRelationshipPath | undefined {
|
|
154
|
+
// Check table-specific first
|
|
155
|
+
if (table) {
|
|
156
|
+
const tableConfig = this.tables.get(table)
|
|
157
|
+
const tablePath = tableConfig?.relationships.get(name)
|
|
158
|
+
if (tablePath) return tablePath
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Fall back to global
|
|
162
|
+
return this.globalRelationships.get(name)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check if table has ReBAC configuration
|
|
167
|
+
*/
|
|
168
|
+
hasTable(table: string): boolean {
|
|
169
|
+
return this.tables.has(table)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get all registered table names
|
|
174
|
+
*/
|
|
175
|
+
getTables(): string[] {
|
|
176
|
+
return Array.from(this.tables.keys())
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Clear all registrations
|
|
181
|
+
*/
|
|
182
|
+
clear(): void {
|
|
183
|
+
this.tables.clear()
|
|
184
|
+
this.globalRelationships.clear()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ============================================================================
|
|
188
|
+
// Private Methods
|
|
189
|
+
// ============================================================================
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Compile a relationship path definition
|
|
193
|
+
*/
|
|
194
|
+
private compileRelationshipPath(path: RelationshipPath, sourceTable: string): CompiledRelationshipPath {
|
|
195
|
+
if (path.steps.length === 0) {
|
|
196
|
+
throw new RLSSchemaError(`Relationship path "${path.name}" must have at least one step`, {
|
|
197
|
+
path: path.name
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const compiledSteps: Required<RelationshipStep>[] = path.steps.map((step, index) => {
|
|
202
|
+
// Validate step
|
|
203
|
+
if (!step.from || !step.to) {
|
|
204
|
+
throw new RLSSchemaError(
|
|
205
|
+
`Relationship step ${index} in "${path.name}" must have 'from' and 'to' tables`,
|
|
206
|
+
{ path: path.name, step: index }
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Fill defaults
|
|
211
|
+
return {
|
|
212
|
+
from: step.from,
|
|
213
|
+
to: step.to,
|
|
214
|
+
fromColumn: step.fromColumn ?? `${step.to}_id`,
|
|
215
|
+
toColumn: step.toColumn ?? 'id',
|
|
216
|
+
alias: step.alias ?? step.to,
|
|
217
|
+
joinType: step.joinType ?? 'inner',
|
|
218
|
+
additionalConditions: step.additionalConditions ?? {}
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// Validate chain continuity
|
|
223
|
+
for (let i = 1; i < compiledSteps.length; i++) {
|
|
224
|
+
const prevStep = compiledSteps[i - 1]!
|
|
225
|
+
const currentStep = compiledSteps[i]!
|
|
226
|
+
|
|
227
|
+
// Each step's 'from' should match previous step's 'to'
|
|
228
|
+
if (currentStep.from !== prevStep.to && currentStep.from !== prevStep.alias) {
|
|
229
|
+
throw new RLSSchemaError(
|
|
230
|
+
`Relationship path "${path.name}" has broken chain at step ${i}: ` +
|
|
231
|
+
`expected '${prevStep.to}' but got '${currentStep.from}'`,
|
|
232
|
+
{ path: path.name, step: i }
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const lastStep = compiledSteps[compiledSteps.length - 1]!
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
name: path.name,
|
|
241
|
+
steps: compiledSteps,
|
|
242
|
+
sourceTable,
|
|
243
|
+
targetTable: lastStep.to
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Compile a ReBAC policy definition
|
|
249
|
+
*/
|
|
250
|
+
private compilePolicy(
|
|
251
|
+
policy: ReBAcPolicyDefinition,
|
|
252
|
+
name: string,
|
|
253
|
+
table: string,
|
|
254
|
+
tableRelationships: Map<string, CompiledRelationshipPath>
|
|
255
|
+
): CompiledReBAcPolicy {
|
|
256
|
+
// Get relationship path
|
|
257
|
+
const relationshipPath =
|
|
258
|
+
tableRelationships.get(policy.relationshipPath) ??
|
|
259
|
+
this.globalRelationships.get(policy.relationshipPath)
|
|
260
|
+
|
|
261
|
+
if (!relationshipPath) {
|
|
262
|
+
throw new RLSSchemaError(
|
|
263
|
+
`ReBAC policy "${name}" references unknown relationship path "${policy.relationshipPath}"`,
|
|
264
|
+
{ policy: name, table, relationshipPath: policy.relationshipPath }
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Normalize operations
|
|
269
|
+
const ops = Array.isArray(policy.operation) ? policy.operation : [policy.operation]
|
|
270
|
+
const expandedOps = ops.flatMap(op =>
|
|
271
|
+
op === 'all' ? ['read', 'create', 'update', 'delete'] : [op]
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
// Compile end condition
|
|
275
|
+
const getEndConditions =
|
|
276
|
+
typeof policy.endCondition === 'function'
|
|
277
|
+
? policy.endCondition
|
|
278
|
+
: () => policy.endCondition as Record<string, unknown>
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
name,
|
|
282
|
+
type: policy.policyType ?? 'allow',
|
|
283
|
+
operations: new Set(expandedOps),
|
|
284
|
+
relationshipPath,
|
|
285
|
+
getEndConditions: getEndConditions as (ctx: PolicyEvaluationContext) => Record<string, unknown>,
|
|
286
|
+
priority: policy.priority ?? 0
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ============================================================================
|
|
292
|
+
// Factory Functions
|
|
293
|
+
// ============================================================================
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Create a ReBAC registry
|
|
297
|
+
*/
|
|
298
|
+
export function createReBAcRegistry<DB = unknown>(
|
|
299
|
+
schema?: ReBAcSchema<DB>,
|
|
300
|
+
options?: { logger?: KyseraLogger }
|
|
301
|
+
): ReBAcRegistry<DB> {
|
|
302
|
+
return new ReBAcRegistry<DB>(schema, options)
|
|
303
|
+
}
|