@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.
@@ -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
- ): PolicyDefinition {
58
- const policy: PolicyDefinition = {
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
- ): PolicyDefinition {
104
- const policy: PolicyDefinition = {
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
- ): PolicyDefinition {
163
- const policy: PolicyDefinition = {
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
- ): PolicyDefinition {
240
+ ): ConditionalPolicyDefinition {
210
241
  const ops: Operation[] = operation === 'all' ? ['create', 'update'] : [operation]
211
242
 
212
- const policy: PolicyDefinition = {
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
  }
@@ -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
+ }