@twin.org/rights-management-plugins 0.0.3-next.25 → 0.0.3-next.26

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.
@@ -3,7 +3,7 @@
3
3
  import { ArrayHelper, Coerce, ComponentFactory, GeneralError, Guards, Is, ObjectHelper } from "@twin.org/core";
4
4
  import { JsonPathHelper } from "@twin.org/data-json-path";
5
5
  import { OdrlPolicyHelper, PolicyDecision, PolicyObligationEnforcerFactory } from "@twin.org/rights-management-models";
6
- import { OdrlConflictStrategyType, OdrlLogicalConstraintType, OdrlOperatorType } from "@twin.org/standards-w3c-odrl";
6
+ import { OdrlConflictStrategyType, OdrlLogicalConstraintType, OdrlOperatorType, OdrlTypes } from "@twin.org/standards-w3c-odrl";
7
7
  /**
8
8
  * Default Policy Arbiter.
9
9
  */
@@ -13,25 +13,25 @@ export class DefaultPolicyArbiter {
13
13
  */
14
14
  static CLASS_NAME = "DefaultPolicyArbiter";
15
15
  /**
16
- * Type indicating the operand type is JSONPath.
16
+ * Default maximum inheritance depth.
17
17
  * @internal
18
18
  */
19
- static _JSON_PATH_TYPE = "jsonpath";
19
+ static _DEFAULT_MAX_INHERITANCE_DEPTH = 10;
20
20
  /**
21
- * Prefix indicating the operand encodes a JSONPath.
21
+ * TWIN prefix operations.
22
22
  * @internal
23
23
  */
24
- static _JSON_PATH_OPERAND_PREFIX = `twin:${DefaultPolicyArbiter._JSON_PATH_TYPE}`;
24
+ static _TWIN_PREFIX_OPERATIONS = "twin:";
25
25
  /**
26
- * Prefix indicating the permission target references an item in the information map.
26
+ * TWIN prefix JSONPath.
27
27
  * @internal
28
28
  */
29
- static _INFORMATION_TARGET_PREFIX = "twin:information:";
29
+ static _TWIN_PREFIX_JSONPATH = "jsonpath";
30
30
  /**
31
- * Default maximum inheritance depth.
31
+ * TWIN prefix information.
32
32
  * @internal
33
33
  */
34
- static _DEFAULT_MAX_INHERITANCE_DEPTH = 10;
34
+ static _TWIN_PREFIX_INFORMATION = "information";
35
35
  /**
36
36
  * The logging component.
37
37
  * @internal
@@ -75,6 +75,14 @@ export class DefaultPolicyArbiter {
75
75
  */
76
76
  async decide(agreement, information, data, action) {
77
77
  Guards.object(DefaultPolicyArbiter.CLASS_NAME, "agreement", agreement);
78
+ // ODRL policy profiles extend the vocabulary with additional semantics (e.g. custom
79
+ // operators, left operands). Without profile-aware evaluation logic the arbiter
80
+ // cannot guarantee correctness, so any policy that declares a profile is rejected.
81
+ if (Is.notEmpty(agreement.profile)) {
82
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "policyProfileNotSupported", {
83
+ policyId: OdrlPolicyHelper.getUid(agreement) ?? ""
84
+ });
85
+ }
78
86
  await this._logging.log({
79
87
  level: "info",
80
88
  source: DefaultPolicyArbiter.CLASS_NAME,
@@ -86,74 +94,251 @@ export class DefaultPolicyArbiter {
86
94
  });
87
95
  // Resolve and merge inherited policies.
88
96
  const mergedPolicy = await this.mergeInheritedPolicies(agreement);
97
+ const expandedPolicy = this.expandCompactPolicyRules(mergedPolicy);
98
+ const dataSources = {
99
+ [`${DefaultPolicyArbiter._TWIN_PREFIX_OPERATIONS}${DefaultPolicyArbiter._TWIN_PREFIX_JSONPATH}`]: data,
100
+ [`${DefaultPolicyArbiter._TWIN_PREFIX_OPERATIONS}${DefaultPolicyArbiter._TWIN_PREFIX_INFORMATION}`]: information
101
+ };
89
102
  // Extract agreement parties once for use in rule evaluation
90
103
  const agreementAssigner = OdrlPolicyHelper.getPartyIds(agreement.assigner);
91
104
  const agreementAssignee = OdrlPolicyHelper.getPartyIds(agreement.assignee);
92
- // ODRL-style rule evaluation:
93
- // - Permission rules authorize if ANY applicable permission matches (OR across permissions).
94
- // - Default to denied when there are no permissions (closed-world for access control).
95
- // - Conflict strategy controls how applicable permissions/prohibitions are resolved.
96
- const permissions = ArrayHelper.fromObjectOrArray(mergedPolicy.permission ?? []);
97
- let permissionApplies = false;
98
- if (permissions.length > 0) {
99
- for (const permission of permissions) {
100
- if (await this.evaluatePermission(agreementAssigner, agreementAssignee, mergedPolicy, permission, information, data, action)) {
101
- permissionApplies = true;
102
- break;
105
+ const obligationsFulfilled = await this.evaluatePolicyObligations(agreementAssigner, agreementAssignee, expandedPolicy, dataSources);
106
+ // ODRL-style rule evaluation grouped by decision target:
107
+ // - Permission rules authorize if ANY applicable permission on the same target matches.
108
+ // - Default to denied when no permission applies on a target (closed-world for access control).
109
+ // - Conflict strategy controls how applicable permissions/prohibitions are resolved per target.
110
+ const targetStates = Object.create(null);
111
+ const permissions = ArrayHelper.fromObjectOrArray(expandedPolicy.permission ?? []);
112
+ for (const permission of permissions) {
113
+ const decisionTargets = this.resolveRuleDecisionTargets(permission, dataSources);
114
+ for (const decisionTarget of decisionTargets) {
115
+ const state = this.getOrCreateTargetState(targetStates, decisionTarget.target);
116
+ if (await this.evaluatePermission(agreementAssigner, agreementAssignee, expandedPolicy, permission, decisionTarget.refinements, dataSources, action, decisionTarget.target)) {
117
+ state.permissionApplies = true;
103
118
  }
104
119
  }
105
120
  }
106
- const prohibitions = ArrayHelper.fromObjectOrArray(mergedPolicy.prohibition ?? []);
107
- let prohibitionApplies = false;
121
+ const prohibitions = ArrayHelper.fromObjectOrArray(expandedPolicy.prohibition ?? []);
108
122
  for (const prohibition of prohibitions) {
109
- if (this.evaluateProhibition(agreementAssigner, agreementAssignee, prohibition, data, action)) {
110
- prohibitionApplies = true;
111
- break;
123
+ const decisionTargets = this.resolveRuleDecisionTargets(prohibition, dataSources);
124
+ for (const decisionTarget of decisionTargets) {
125
+ if (await this.evaluateProhibition(expandedPolicy, agreementAssigner, agreementAssignee, prohibition, decisionTarget.refinements, dataSources, action, decisionTarget.target)) {
126
+ const state = this.getOrCreateTargetState(targetStates, decisionTarget.target);
127
+ state.prohibitionApplies = true;
128
+ }
112
129
  }
113
130
  }
114
- const conflictStrategy = mergedPolicy.conflict ?? OdrlConflictStrategyType.Invalid;
115
- let decision;
116
- if (permissionApplies && prohibitionApplies) {
117
- switch (conflictStrategy) {
118
- case OdrlConflictStrategyType.Perm:
119
- decision = PolicyDecision.Granted;
120
- break;
121
- case OdrlConflictStrategyType.Prohibit:
122
- case OdrlConflictStrategyType.Invalid:
123
- default:
124
- decision = PolicyDecision.Denied;
125
- break;
131
+ const conflictStrategy = expandedPolicy.conflict ?? OdrlConflictStrategyType.Invalid;
132
+ if (Object.keys(targetStates).length === 0) {
133
+ // Closed-world fallback when the policy has no rules at all.
134
+ return [{ decision: PolicyDecision.Denied, target: "$" }];
135
+ }
136
+ const decisions = [];
137
+ for (const [target, state] of Object.entries(targetStates)) {
138
+ let decision;
139
+ if (state.permissionApplies && state.prohibitionApplies) {
140
+ switch (conflictStrategy) {
141
+ case OdrlConflictStrategyType.Perm:
142
+ decision = PolicyDecision.Granted;
143
+ break;
144
+ case OdrlConflictStrategyType.Prohibit:
145
+ case OdrlConflictStrategyType.Invalid:
146
+ default:
147
+ decision = PolicyDecision.Denied;
148
+ break;
149
+ }
150
+ }
151
+ else {
152
+ decision = state.permissionApplies ? PolicyDecision.Granted : PolicyDecision.Denied;
126
153
  }
154
+ if (!obligationsFulfilled) {
155
+ decision = PolicyDecision.Denied;
156
+ }
157
+ decisions.push({
158
+ decision,
159
+ target
160
+ });
127
161
  }
128
- else {
129
- decision = permissionApplies ? PolicyDecision.Granted : PolicyDecision.Denied;
162
+ return decisions;
163
+ }
164
+ /**
165
+ * Expand compact/compound policy rule forms into atomic rules.
166
+ * ODRL 2.7 allows compact forms where rule properties can be arrays.
167
+ * Evaluation in this arbiter is performed on expanded atomic rules.
168
+ * @param policy The policy to expand.
169
+ * @returns A policy with expanded rule arrays.
170
+ * @internal
171
+ */
172
+ expandCompactPolicyRules(policy) {
173
+ const expandedPermissions = this.expandRules(ArrayHelper.fromObjectOrArray(policy.permission ?? []));
174
+ const expandedProhibitions = this.expandRules(ArrayHelper.fromObjectOrArray(policy.prohibition ?? []));
175
+ const expandedObligations = this.expandRules(ArrayHelper.fromObjectOrArray(policy.obligation ?? []));
176
+ return {
177
+ ...policy,
178
+ permission: expandedPermissions.length > 0 ? expandedPermissions : undefined,
179
+ prohibition: expandedProhibitions.length > 0 ? expandedProhibitions : undefined,
180
+ obligation: expandedObligations.length > 0 ? expandedObligations : undefined
181
+ };
182
+ }
183
+ /**
184
+ * Expand a rule list into atomic rules.
185
+ * @param rules The rules to expand.
186
+ * @returns Expanded atomic rules.
187
+ * @internal
188
+ */
189
+ expandRules(rules) {
190
+ const expanded = [];
191
+ for (const rule of rules) {
192
+ expanded.push(...this.expandRule(rule));
193
+ }
194
+ return expanded;
195
+ }
196
+ /**
197
+ * Expand a single compact/compound rule into atomic rules.
198
+ * @param rule The rule to expand.
199
+ * @returns Expanded atomic rules.
200
+ * @internal
201
+ */
202
+ expandRule(rule) {
203
+ const targets = this.normalizeRuleField(rule.target);
204
+ const actions = this.normalizeRuleField(rule.action);
205
+ const assigners = this.normalizeRuleField(rule.assigner);
206
+ const assignees = this.normalizeRuleField(rule.assignee);
207
+ const expanded = [];
208
+ for (const target of targets) {
209
+ for (const action of actions) {
210
+ for (const assigner of assigners) {
211
+ for (const assignee of assignees) {
212
+ const atomicRule = { ...rule };
213
+ atomicRule.target = target;
214
+ atomicRule.action = action;
215
+ atomicRule.assigner = assigner;
216
+ atomicRule.assignee = assignee;
217
+ expanded.push(atomicRule);
218
+ }
219
+ }
220
+ }
221
+ }
222
+ return expanded;
223
+ }
224
+ /**
225
+ * Normalize a potentially compact rule field into an array for expansion.
226
+ * @param value The field value.
227
+ * @returns Normalized values (or a single undefined when not provided).
228
+ * @internal
229
+ */
230
+ normalizeRuleField(value) {
231
+ if (Is.undefined(value)) {
232
+ return [undefined];
233
+ }
234
+ const values = ArrayHelper.fromObjectOrArray(value);
235
+ return values.length > 0 ? values : [undefined];
236
+ }
237
+ /**
238
+ * Get an existing target state or create an initial state if it doesn't exist.
239
+ * @param targetStates The dictionary of target states.
240
+ * @param target The target key.
241
+ * @returns The target state.
242
+ * @internal
243
+ */
244
+ getOrCreateTargetState(targetStates, target) {
245
+ let state = targetStates[target];
246
+ if (Is.undefined(state)) {
247
+ state = {
248
+ permissionApplies: false,
249
+ prohibitionApplies: false
250
+ };
251
+ targetStates[target] = state;
130
252
  }
131
- return [{ target: "$", decision }];
253
+ return state;
132
254
  }
133
255
  /**
134
256
  * Evaluate whether a prohibition applies.
257
+ * @param policy The policy containing the prohibition.
135
258
  * @param agreementAssigner The assigner ID from the agreement.
136
259
  * @param agreementAssignee The assignee ID from the agreement.
137
260
  * @param prohibition The prohibition to evaluate.
138
- * @param data The request data/context.
261
+ * @param targetRefinements Additional constraints from target refinement.
262
+ * @param dataSources The operand lookup sources.
139
263
  * @param action Optional action to check against the prohibition's applicable actions.
140
264
  * @returns True if the prohibition applies.
141
265
  * @internal
142
266
  */
143
- evaluateProhibition(agreementAssigner, agreementAssignee, prohibition, data, action) {
144
- if (!this.isRuleApplicableToParties(prohibition, agreementAssigner, agreementAssignee)) {
267
+ async evaluateProhibition(policy, agreementAssigner, agreementAssignee, prohibition, targetRefinements, dataSources, action, decisionTarget) {
268
+ if (!this.isRuleApplicableToParties(prohibition, agreementAssigner, agreementAssignee, dataSources)) {
145
269
  return false;
146
270
  }
147
271
  // Check if the prohibition's action(s) match the requested action
148
- if (!this.isActionApplicable(prohibition.action, action)) {
272
+ if (!this.isActionApplicable(prohibition.action, action, dataSources)) {
149
273
  return false;
150
274
  }
151
275
  // ODRL semantics: a rule without constraints is unconditional.
152
- const constraints = ArrayHelper.fromObjectOrArray(prohibition.constraint ?? []);
153
- if (constraints.length === 0) {
276
+ const constraints = [
277
+ ...ArrayHelper.fromObjectOrArray(prohibition.constraint ?? []),
278
+ ...targetRefinements
279
+ ];
280
+ if (constraints.length > 0 &&
281
+ !constraints.every(c => this.evaluateConstraint(c, dataSources))) {
282
+ return false;
283
+ }
284
+ const prohibitionTargetLookup = this.tryResolveTargetDataSource(this.buildRuleDataContextTargetId(this.getRuleDataContextTargetId(prohibition.target), decisionTarget), dataSources, true);
285
+ const ruleDataContext = prohibitionTargetLookup.value;
286
+ const remedies = ArrayHelper.fromObjectOrArray(prohibition.remedy ?? []);
287
+ if (remedies.length === 0) {
288
+ return true;
289
+ }
290
+ for (const remedy of remedies) {
291
+ if (!(await this.enforceDuty(policy, remedy, dataSources, ruleDataContext))) {
292
+ return true;
293
+ }
294
+ }
295
+ // Remedies satisfied: prohibition is treated as no longer infringed.
296
+ return false;
297
+ }
298
+ /**
299
+ * Evaluate all policy-level obligations.
300
+ * @param agreementAssigner The assigner ID from the agreement.
301
+ * @param agreementAssignee The assignee ID from the agreement.
302
+ * @param policy The policy containing obligations.
303
+ * @param dataSources The operand lookup sources.
304
+ * @returns True if all applicable obligations are fulfilled.
305
+ * @internal
306
+ */
307
+ async evaluatePolicyObligations(agreementAssigner, agreementAssignee, policy, dataSources) {
308
+ const obligations = ArrayHelper.fromObjectOrArray(policy.obligation ?? []);
309
+ for (const obligation of obligations) {
310
+ if (!(await this.evaluateObligation(agreementAssigner, agreementAssignee, policy, obligation, dataSources))) {
311
+ return false;
312
+ }
313
+ }
314
+ return true;
315
+ }
316
+ /**
317
+ * Evaluate whether a policy-level obligation is fulfilled.
318
+ * @param agreementAssigner The assigner ID from the agreement.
319
+ * @param agreementAssignee The assignee ID from the agreement.
320
+ * @param policy The policy containing the obligation.
321
+ * @param obligation The obligation to evaluate.
322
+ * @param dataSources The operand lookup sources.
323
+ * @returns True if the obligation is not applicable or is fulfilled.
324
+ * @internal
325
+ */
326
+ async evaluateObligation(agreementAssigner, agreementAssignee, policy, obligation, dataSources) {
327
+ if (!this.isRuleApplicableToParties(obligation, agreementAssigner, agreementAssignee, dataSources)) {
154
328
  return true;
155
329
  }
156
- return constraints.every(c => this.evaluateConstraint(c, data));
330
+ const { refinements } = this.resolveRuleTarget(obligation, dataSources);
331
+ const obligationTargetLookup = this.tryResolveTargetDataSource(this.getTargetId(obligation.target), dataSources, true);
332
+ const ruleDataContext = obligationTargetLookup.value;
333
+ const constraints = [
334
+ ...ArrayHelper.fromObjectOrArray(obligation.constraint ?? []),
335
+ ...refinements
336
+ ];
337
+ if (constraints.length > 0 &&
338
+ !constraints.every(c => this.evaluateConstraint(c, dataSources))) {
339
+ return true;
340
+ }
341
+ return this.enforceDuty(policy, obligation, dataSources, ruleDataContext);
157
342
  }
158
343
  /**
159
344
  * Merge inherited policies into the current policy.
@@ -171,9 +356,9 @@ export class DefaultPolicyArbiter {
171
356
  conflictStrategies.add(policy.conflict);
172
357
  }
173
358
  // Start with copies of the current policy's rules
174
- const mergedPermissions = ArrayHelper.fromObjectOrArray(policy.permission ?? []).map(permission => this.applyPolicyPartiesToRule(policy, permission));
175
- const mergedProhibitions = ArrayHelper.fromObjectOrArray(policy.prohibition ?? []).map(prohibition => this.applyPolicyPartiesToRule(policy, prohibition));
176
- const mergedObligations = ArrayHelper.fromObjectOrArray(policy.obligation ?? []).map(obligation => this.applyPolicyPartiesToRule(policy, obligation));
359
+ const mergedPermissions = ArrayHelper.fromObjectOrArray(policy.permission ?? []).map(permission => this.applyPolicyDefaultsToRule(policy, permission));
360
+ const mergedProhibitions = ArrayHelper.fromObjectOrArray(policy.prohibition ?? []).map(prohibition => this.applyPolicyDefaultsToRule(policy, prohibition));
361
+ const mergedObligations = ArrayHelper.fromObjectOrArray(policy.obligation ?? []).map(obligation => this.applyPolicyDefaultsToRule(policy, obligation));
177
362
  // Merge rules from each inherited policy
178
363
  for (const inheritedPolicy of inheritedPolicies) {
179
364
  if (Is.stringValue(inheritedPolicy.conflict)) {
@@ -181,15 +366,15 @@ export class DefaultPolicyArbiter {
181
366
  }
182
367
  const inheritedPermissions = ArrayHelper.fromObjectOrArray(inheritedPolicy.permission ?? []);
183
368
  if (inheritedPermissions.length > 0) {
184
- mergedPermissions.push(...inheritedPermissions.map(permission => this.applyPolicyPartiesToRule(inheritedPolicy, permission)));
369
+ mergedPermissions.push(...inheritedPermissions.map(permission => this.applyPolicyDefaultsToRule(inheritedPolicy, permission)));
185
370
  }
186
371
  const inheritedProhibitions = ArrayHelper.fromObjectOrArray(inheritedPolicy.prohibition ?? []);
187
372
  if (inheritedProhibitions.length > 0) {
188
- mergedProhibitions.push(...inheritedProhibitions.map(prohibition => this.applyPolicyPartiesToRule(inheritedPolicy, prohibition)));
373
+ mergedProhibitions.push(...inheritedProhibitions.map(prohibition => this.applyPolicyDefaultsToRule(inheritedPolicy, prohibition)));
189
374
  }
190
375
  const inheritedObligations = ArrayHelper.fromObjectOrArray(inheritedPolicy.obligation ?? []);
191
376
  if (inheritedObligations.length > 0) {
192
- mergedObligations.push(...inheritedObligations.map(obligation => this.applyPolicyPartiesToRule(inheritedPolicy, obligation)));
377
+ mergedObligations.push(...inheritedObligations.map(obligation => this.applyPolicyDefaultsToRule(inheritedPolicy, obligation)));
193
378
  }
194
379
  }
195
380
  let mergedConflict;
@@ -211,11 +396,9 @@ export class DefaultPolicyArbiter {
211
396
  /**
212
397
  * Resolve inherited policies by their UIDs from the Policy Administration Point.
213
398
  * Policies can inherit from other policies via the inheritFrom property.
214
- * Detects circular inheritance and throws an exception if detected.
215
399
  * @param policy The policy that may have inheritFrom references.
216
400
  * @param visitedPolicyIds Array of policy UIDs already visited in this inheritance chain.
217
401
  * @returns Array of inherited policies fetched from the PAP.
218
- * @throws GeneralError if a parent policy cannot be found or if circular inheritance is detected.
219
402
  * @internal
220
403
  */
221
404
  async resolveInheritedPolicies(policy, visitedPolicyIds, currentDepth) {
@@ -258,15 +441,17 @@ export class DefaultPolicyArbiter {
258
441
  return inheritedPolicies;
259
442
  }
260
443
  /**
261
- * Apply policy-level assigner/assignee values to rules that don't override them.
444
+ * Apply policy-level default values (assigner, assignee, target, action) to rules that don't override them.
262
445
  * @param policy The policy providing defaults.
263
446
  * @param rule The rule to apply defaults to.
264
- * @returns The rule with policy-level assigner/assignee applied.
447
+ * @returns The rule with policy-level defaults applied.
265
448
  * @internal
266
449
  */
267
- applyPolicyPartiesToRule(policy, rule) {
450
+ applyPolicyDefaultsToRule(policy, rule) {
268
451
  const assigner = Is.empty(rule.assigner) ? policy.assigner : rule.assigner;
269
452
  const assignee = Is.empty(rule.assignee) ? policy.assignee : rule.assignee;
453
+ const target = Is.empty(rule.target) ? policy.target : rule.target;
454
+ const action = Is.empty(rule.action) ? policy.action : rule.action;
270
455
  const assignerIds = OdrlPolicyHelper.getPartyIds(assigner);
271
456
  const ruleAssignerIds = OdrlPolicyHelper.getPartyIds(rule.assigner);
272
457
  const assigneeIds = OdrlPolicyHelper.getPartyIds(assignee);
@@ -289,13 +474,17 @@ export class DefaultPolicyArbiter {
289
474
  assigneeIds.length === ruleAssigneeIds.length &&
290
475
  assigneeIds.every(id => ruleAssigneeIds.includes(id));
291
476
  }
292
- if (assignerEqual && assigneeEqual) {
477
+ const targetEqual = target === rule.target;
478
+ const actionEqual = action === rule.action;
479
+ if (assignerEqual && assigneeEqual && targetEqual && actionEqual) {
293
480
  return rule;
294
481
  }
295
482
  return {
296
483
  ...rule,
297
484
  assigner,
298
- assignee
485
+ assignee,
486
+ target,
487
+ action
299
488
  };
300
489
  }
301
490
  /**
@@ -306,17 +495,74 @@ export class DefaultPolicyArbiter {
306
495
  * @returns True if the rule is applicable to the agreement parties.
307
496
  * @internal
308
497
  */
309
- isRuleApplicableToParties(rule, agreementAssigner, agreementAssignee) {
310
- const ruleAssigners = OdrlPolicyHelper.getPartyIds(rule.assigner);
311
- const ruleAssignees = OdrlPolicyHelper.getPartyIds(rule.assignee);
312
- if (!this.isPartyApplicable(ruleAssigners, agreementAssigner)) {
498
+ isRuleApplicableToParties(rule, agreementAssigner, agreementAssignee, dataSources) {
499
+ const assignerContext = this.resolveRulePartyContext(rule.assigner);
500
+ const assigneeContext = this.resolveRulePartyContext(rule.assignee);
501
+ if (!this.isPartyApplicable(assignerContext.partyIds, agreementAssigner)) {
502
+ return false;
503
+ }
504
+ if (assignerContext.refinements.length > 0 &&
505
+ !assignerContext.refinements.every(c => this.evaluateConstraint(c, dataSources))) {
506
+ return false;
507
+ }
508
+ if (!this.isPartyApplicable(assigneeContext.partyIds, agreementAssignee)) {
313
509
  return false;
314
510
  }
315
- if (!this.isPartyApplicable(ruleAssignees, agreementAssignee)) {
511
+ if (assigneeContext.refinements.length > 0 &&
512
+ !assigneeContext.refinements.every(c => this.evaluateConstraint(c, dataSources))) {
316
513
  return false;
317
514
  }
318
515
  return true;
319
516
  }
517
+ /**
518
+ * Resolve rule party identifiers and refinements.
519
+ * PartyCollection source values are currently not supported for party matching.
520
+ * @param party The rule party value.
521
+ * @returns Resolved party identifiers and refinement constraints.
522
+ * @throws GeneralError if PartyCollection source has a value.
523
+ * @internal
524
+ */
525
+ resolveRulePartyContext(party) {
526
+ const partyIds = [];
527
+ const refinements = [];
528
+ const parties = ArrayHelper.fromObjectOrArray(party ?? []);
529
+ for (const partyEntry of parties) {
530
+ if (Is.stringValue(partyEntry)) {
531
+ partyIds.push(partyEntry);
532
+ }
533
+ else if (Is.object(partyEntry)) {
534
+ // Guard against unsupported ODRL party properties
535
+ if (Is.notEmpty(partyEntry.assignerOf)) {
536
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "partyAssignerOfNotSupported");
537
+ }
538
+ if (Is.notEmpty(partyEntry.assigneeOf)) {
539
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "partyAssigneeOfNotSupported");
540
+ }
541
+ if (Is.notEmpty(partyEntry.partOf)) {
542
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "partyPartOfNotSupported");
543
+ }
544
+ if (OdrlPolicyHelper.getType(partyEntry) === OdrlTypes.PartyCollection) {
545
+ const partyCollectionEntry = partyEntry;
546
+ if (Is.stringValue(partyCollectionEntry.source)) {
547
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "partyCollectionSourceNotSupported", {
548
+ source: partyCollectionEntry.source ?? ""
549
+ });
550
+ }
551
+ refinements.push(...ArrayHelper.fromObjectOrArray(partyCollectionEntry.refinement ?? []));
552
+ }
553
+ else {
554
+ const partyId = OdrlPolicyHelper.getUid(partyEntry);
555
+ if (Is.stringValue(partyId)) {
556
+ partyIds.push(partyId);
557
+ }
558
+ }
559
+ }
560
+ }
561
+ return {
562
+ partyIds,
563
+ refinements
564
+ };
565
+ }
320
566
  /**
321
567
  * Determine whether a rule party constraint applies to the agreement parties.
322
568
  * Rule party is treated as a constraint: if specified, it must match at least one agreement party.
@@ -343,6 +589,7 @@ export class DefaultPolicyArbiter {
343
589
  }
344
590
  /**
345
591
  * Determine whether a rule's action(s) match the requested action.
592
+ * Supports exact match, includedIn hierarchy, and implies relationships.
346
593
  * If no action is specified in the rule, it applies to all actions (action-agnostic).
347
594
  * If an action is specified in the rule and a specific action is requested, they must match.
348
595
  * If an action is specified in the rule but no specific action is requested, the rule applies (general evaluation).
@@ -351,7 +598,7 @@ export class DefaultPolicyArbiter {
351
598
  * @returns True if the rule's action(s) apply.
352
599
  * @internal
353
600
  */
354
- isActionApplicable(ruleActions, requestedAction) {
601
+ isActionApplicable(ruleActions, requestedAction, dataSources) {
355
602
  // If the rule has no action specified, it applies to all actions
356
603
  if (Is.empty(ruleActions)) {
357
604
  return true;
@@ -361,72 +608,124 @@ export class DefaultPolicyArbiter {
361
608
  if (Is.empty(requestedAction)) {
362
609
  return true;
363
610
  }
364
- // Both rule actions and requested action are specified - check for match
611
+ const requestedActionId = Is.string(requestedAction)
612
+ ? requestedAction
613
+ : OdrlPolicyHelper.getUid(requestedAction);
614
+ if (!Is.stringValue(requestedActionId)) {
615
+ return false;
616
+ }
365
617
  const ruleActionArray = ArrayHelper.fromObjectOrArray(ruleActions) ?? [];
366
- // Check if the requested action matches any of the rule's actions
367
618
  for (const ruleAction of ruleActionArray) {
368
- const ruleActionId = Is.string(ruleAction) ? ruleAction : OdrlPolicyHelper.getUid(ruleAction);
369
- const requestedActionId = Is.string(requestedAction)
370
- ? requestedAction
371
- : OdrlPolicyHelper.getUid(requestedAction);
372
- if (Is.stringValue(ruleActionId) && Is.stringValue(requestedActionId)) {
373
- if (ruleActionId === requestedActionId) {
374
- return true;
375
- }
619
+ if (this.ruleActionCoversRequested(ruleAction, requestedActionId, dataSources)) {
620
+ return true;
376
621
  }
377
622
  }
378
623
  return false;
379
624
  }
625
+ /**
626
+ * Determine whether a single rule action covers a requested action.
627
+ * Covers exact match plus ODRL action hierarchy semantics.
628
+ * - includedIn: the rule action is a sub-action of a broader parent.
629
+ * A rule naming the narrower action also covers requests for the parent.
630
+ * E.g. rule action "print" with includedIn "reproduce" covers a request for "reproduce".
631
+ * - implies: the rule action entails another action.
632
+ * A rule granting action X also covers action Y when X implies Y.
633
+ * E.g. rule action "distribute" implying "reproduce" covers a request for "reproduce".
634
+ * @param ruleAction The action specified in the rule.
635
+ * @param requestedActionId The requested action identifier.
636
+ * @returns True if the rule action covers the requested action.
637
+ * @internal
638
+ */
639
+ ruleActionCoversRequested(ruleAction, requestedActionId, dataSources) {
640
+ // Extract the rule action ID — support both @id and rdf:value forms
641
+ let ruleActionId;
642
+ if (Is.string(ruleAction)) {
643
+ ruleActionId = ruleAction;
644
+ }
645
+ else if (Is.object(ruleAction)) {
646
+ ruleActionId = ruleAction["rdf:value"]?.["@id"] ?? OdrlPolicyHelper.getUid(ruleAction);
647
+ }
648
+ // Determine whether this rule action covers the requested action via any semantic path.
649
+ let covers = false;
650
+ if (Is.stringValue(ruleActionId) && ruleActionId === requestedActionId) {
651
+ // Exact match
652
+ covers = true;
653
+ }
654
+ else if (Is.object(ruleAction)) {
655
+ // includedIn: rule action A includedIn B means A is a sub-type of B.
656
+ // A rule that names the narrower action A with includedIn B also covers requests for B.
657
+ if (Is.stringValue(ruleAction.includedIn) && ruleAction.includedIn === requestedActionId) {
658
+ covers = true;
659
+ }
660
+ // implies: rule action A implies B means exercising A also entails B.
661
+ // A rule granting A therefore also grants each implied action.
662
+ if (!covers && (ruleAction.implies ?? []).includes(requestedActionId)) {
663
+ covers = true;
664
+ }
665
+ }
666
+ if (!covers) {
667
+ return false;
668
+ }
669
+ // If the action specifies refinements, all must be satisfied for the action to apply.
670
+ // Refinements constrain the manner in which the action is exercised (e.g. print count <= 5).
671
+ if (Is.object(ruleAction) && Is.notEmpty(ruleAction.refinement)) {
672
+ const refinements = ArrayHelper.fromObjectOrArray(ruleAction.refinement ?? []);
673
+ return refinements.every((refinement) => this.evaluateConstraint(refinement, dataSources));
674
+ }
675
+ return true;
676
+ }
380
677
  /**
381
678
  * Apply a permission and create decisions based on that information.
382
679
  * @param agreementAssigner The assigner ID from the agreement.
383
680
  * @param agreementAssignee The assignee ID from the agreement.
384
681
  * @param policy The policy containing the permission.
385
682
  * @param permission The permission to apply.
386
- * @param information Additional facts provided by the PIP.
387
- * @param data The request data/context.
683
+ * @param targetRefinements Additional constraints from target refinement.
684
+ * @param dataSources The operand lookup sources.
388
685
  * @returns True if the permission applies.
389
686
  * @internal
390
687
  */
391
- async evaluatePermission(agreementAssigner, agreementAssignee, policy, permission, information, data, action) {
392
- if (!this.isRuleApplicableToParties(permission, agreementAssigner, agreementAssignee)) {
688
+ async evaluatePermission(agreementAssigner, agreementAssignee, policy, permission, targetRefinements, dataSources, action, decisionTarget) {
689
+ if (!this.isRuleApplicableToParties(permission, agreementAssigner, agreementAssignee, dataSources)) {
393
690
  return false;
394
691
  }
395
692
  // Check if the permission's action(s) match the requested action
396
- if (!this.isActionApplicable(permission.action, action)) {
693
+ if (!this.isActionApplicable(permission.action, action, dataSources)) {
397
694
  return false;
398
695
  }
399
696
  // ODRL semantics: a Permission without constraints is unconditional.
400
- const constraints = ArrayHelper.fromObjectOrArray(permission.constraint ?? []);
697
+ const constraints = [
698
+ ...ArrayHelper.fromObjectOrArray(permission.constraint ?? []),
699
+ ...targetRefinements
700
+ ];
701
+ const permissionTargetLookup = this.tryResolveTargetDataSource(this.buildRuleDataContextTargetId(this.getRuleDataContextTargetId(permission.target), decisionTarget), dataSources, true);
702
+ const ruleDataContext = permissionTargetLookup.value;
401
703
  if (constraints.length === 0) {
402
- return this.enforcePermissionDuties(policy, permission, information, data);
704
+ return this.enforcePermissionDuties(policy, permission, dataSources, ruleDataContext);
403
705
  }
404
- // Resolve the data context for evaluating this permission.
405
- // If the target starts with twin:information:<key>, the key references an entry in the information map.
406
- const ruleDataContext = this.resolveRuleDataContext(permission, information, data);
407
706
  // All constraints must be satisfied for the permission to apply.
408
- const constraintsSatisfied = constraints.every(c => this.evaluateConstraint(c, ruleDataContext));
707
+ const constraintsSatisfied = constraints.every(c => this.evaluateConstraint(c, dataSources));
409
708
  if (!constraintsSatisfied) {
410
709
  return false;
411
710
  }
412
- return this.enforcePermissionDuties(policy, permission, information, ruleDataContext);
711
+ return this.enforcePermissionDuties(policy, permission, dataSources, ruleDataContext);
413
712
  }
414
713
  /**
415
714
  * Enforce duties attached to a permission.
416
715
  * @param policy The policy being evaluated.
417
716
  * @param permission The permission being evaluated.
418
- * @param information Additional facts provided by the PIP.
419
- * @param data The request data/context.
717
+ * @param dataSources The operand lookup sources.
718
+ * @param ruleDataContext The target-scoped data context passed to enforcers.
420
719
  * @returns True if all duties are enforced or none are present.
421
720
  * @internal
422
721
  */
423
- async enforcePermissionDuties(policy, permission, information, data) {
722
+ async enforcePermissionDuties(policy, permission, dataSources, ruleDataContext) {
424
723
  const duties = ArrayHelper.fromObjectOrArray(permission.duty ?? []);
425
724
  if (duties.length === 0) {
426
725
  return true;
427
726
  }
428
727
  for (const duty of duties) {
429
- const enforced = await this.enforceDuty(policy, duty, information, data);
728
+ const enforced = await this.enforceDuty(policy, duty, dataSources, ruleDataContext);
430
729
  if (!enforced) {
431
730
  return false;
432
731
  }
@@ -437,46 +736,87 @@ export class DefaultPolicyArbiter {
437
736
  * Enforce a single duty using registered obligation enforcers.
438
737
  * @param policy The policy being evaluated.
439
738
  * @param duty The duty to enforce.
440
- * @param information Additional facts provided by the PIP.
441
- * @param data The request data/context.
739
+ * @param dataSources The operand lookup sources.
740
+ * @param ruleDataContext The target-scoped data context passed to enforcers.
442
741
  * @returns True if any enforcer succeeds.
443
742
  * @internal
444
743
  */
445
- async enforceDuty(policy, duty, information, data) {
744
+ async enforceDuty(policy, duty, dataSources, ruleDataContext) {
446
745
  const enforcerNames = PolicyObligationEnforcerFactory.names();
746
+ const information = dataSources[`${DefaultPolicyArbiter._TWIN_PREFIX_OPERATIONS}${DefaultPolicyArbiter._TWIN_PREFIX_INFORMATION}`];
447
747
  if (enforcerNames.length === 0) {
448
748
  throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "noObligationEnforcersRegistered");
449
749
  }
450
750
  for (const enforcerName of enforcerNames) {
451
751
  const enforcer = PolicyObligationEnforcerFactory.get(enforcerName);
452
- if (await enforcer.enforce(policy, duty, information, data)) {
752
+ if (await enforcer.enforce(policy, duty, information, ruleDataContext)) {
453
753
  return true;
454
754
  }
455
755
  }
456
- return false;
756
+ const consequences = ArrayHelper.fromObjectOrArray(duty.consequence ?? []);
757
+ if (consequences.length === 0) {
758
+ return false;
759
+ }
760
+ for (const consequence of consequences) {
761
+ if (!(await this.enforceDuty(policy, consequence, dataSources, ruleDataContext))) {
762
+ return false;
763
+ }
764
+ }
765
+ return true;
457
766
  }
458
767
  /**
459
- * Resolve the data context used when evaluating a permission's constraints.
460
- * If the permission target is `twin:information:<key>`, the key references an entry in the information map.
461
- * @param rule The permission being evaluated.
462
- * @param information Additional facts provided by the PIP.
463
- * @param data The request data/context.
464
- * @returns The data context to use for JSONPath resolution.
465
- * @throws GeneralError When the information target key is missing.
768
+ * Resolve a target string to a matching datasource prefix and remaining target value.
769
+ * @param targetId The target identifier to resolve.
770
+ * @param dataSources The available lookup sources.
771
+ * @param resolveValue True to resolve an item from the target path/key.
772
+ * @returns The matching prefix, source, remaining target and optional resolved value.
466
773
  * @internal
467
774
  */
468
- resolveRuleDataContext(rule, information, data) {
469
- const targetId = this.getTargetId(rule.target);
470
- if (targetId?.startsWith(DefaultPolicyArbiter._INFORMATION_TARGET_PREFIX)) {
471
- const key = targetId.slice(DefaultPolicyArbiter._INFORMATION_TARGET_PREFIX.length);
472
- if (Is.empty(key) || Is.empty(information?.[key])) {
473
- throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "informationTargetMissing", {
474
- key
475
- });
775
+ tryResolveTargetDataSource(targetId, dataSources, resolveValue = false) {
776
+ // If there is no target id, default to the entire "twin:jsonpath" datasource
777
+ if (Is.empty(targetId)) {
778
+ return {
779
+ prefix: `${DefaultPolicyArbiter._TWIN_PREFIX_OPERATIONS}${DefaultPolicyArbiter._TWIN_PREFIX_JSONPATH}`,
780
+ source: dataSources[`${DefaultPolicyArbiter._TWIN_PREFIX_OPERATIONS}${DefaultPolicyArbiter._TWIN_PREFIX_JSONPATH}`],
781
+ target: "$",
782
+ value: dataSources[`${DefaultPolicyArbiter._TWIN_PREFIX_OPERATIONS}${DefaultPolicyArbiter._TWIN_PREFIX_JSONPATH}`]
783
+ };
784
+ }
785
+ // Otherwise lookup the target id prefix in the datasources and return the remaining suffix as the target path/key
786
+ const prefixes = Object.keys(dataSources).sort((a, b) => b.length - a.length);
787
+ for (const prefix of prefixes) {
788
+ if (targetId.startsWith(`${prefix}:`)) {
789
+ const source = dataSources[prefix];
790
+ const target = targetId.slice(prefix.length + 1);
791
+ if (!target.startsWith("$")) {
792
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "ruleTargetNotSupported", {
793
+ target: targetId
794
+ });
795
+ }
796
+ if (!resolveValue) {
797
+ return {
798
+ prefix,
799
+ source,
800
+ target
801
+ };
802
+ }
803
+ const matches = JsonPathHelper.query(target, source);
804
+ if (matches.length === 0) {
805
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "ruleTargetNotSupported", {
806
+ target: targetId
807
+ });
808
+ }
809
+ return {
810
+ prefix,
811
+ source,
812
+ target,
813
+ value: matches.length === 1 ? matches[0].value : matches.map(m => m.value)
814
+ };
476
815
  }
477
- return information[key];
478
816
  }
479
- return data;
817
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "ruleTargetNotSupported", {
818
+ target: targetId
819
+ });
480
820
  }
481
821
  /**
482
822
  * Extract the target id from the permission.
@@ -497,24 +837,267 @@ export class DefaultPolicyArbiter {
497
837
  }
498
838
  return Is.string(arr[0]) ? arr[0] : OdrlPolicyHelper.getUid(arr[0]);
499
839
  }
840
+ /**
841
+ * Resolve the target identifier used for rule data context lookup.
842
+ * For AssetCollection targets the `source` property is used because the collection has no `uid`.
843
+ * Falls back to `getTargetId` for all other target forms.
844
+ * @param target The rule target field value.
845
+ * @returns The prefixed target string for data context resolution.
846
+ * @internal
847
+ */
848
+ getRuleDataContextTargetId(target) {
849
+ const arr = ArrayHelper.fromObjectOrArray(target ?? []);
850
+ if (arr.length === 1) {
851
+ const firstTarget = arr[0];
852
+ if (Is.object(firstTarget) &&
853
+ OdrlPolicyHelper.getType(firstTarget) === OdrlTypes.AssetCollection &&
854
+ Is.stringValue(firstTarget.source)) {
855
+ return firstTarget.source;
856
+ }
857
+ }
858
+ return this.getTargetId(target);
859
+ }
860
+ /**
861
+ * Build a concrete prefixed target id for rule data-context lookup.
862
+ * @param baseTargetId The original prefixed target id from the rule.
863
+ * @param decisionTarget The concrete decision JSONPath target.
864
+ * @returns The concrete prefixed target id.
865
+ * @internal
866
+ */
867
+ buildRuleDataContextTargetId(baseTargetId, decisionTarget) {
868
+ if (!Is.stringValue(decisionTarget) || decisionTarget === "$") {
869
+ return baseTargetId;
870
+ }
871
+ if (!Is.stringValue(baseTargetId)) {
872
+ return undefined;
873
+ }
874
+ const pathStartIndex = baseTargetId.indexOf(":$");
875
+ if (pathStartIndex < 0) {
876
+ return baseTargetId;
877
+ }
878
+ return `${baseTargetId.slice(0, pathStartIndex)}:${decisionTarget}`;
879
+ }
880
+ /**
881
+ * Resolve a rule target into a policy-decision JSONPath target and extracted refinements.
882
+ * For AssetCollection targets, `source` is treated as the decision target and `refinement`
883
+ * constraints are applied as additional rule constraints.
884
+ * @param rule The rule to resolve the target for.
885
+ * @returns The decision target and target refinements.
886
+ * @throws GeneralError if target is invalid or unsupported.
887
+ * @internal
888
+ */
889
+ resolveRuleTarget(rule, dataSources) {
890
+ const arr = ArrayHelper.fromObjectOrArray(rule.target ?? []);
891
+ if (arr.length === 0) {
892
+ return {
893
+ target: "$",
894
+ refinements: []
895
+ };
896
+ }
897
+ if (arr.length > 1) {
898
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "multipleTargetsNotSupported");
899
+ }
900
+ const firstTarget = arr[0];
901
+ if (Is.object(firstTarget)) {
902
+ // Guard against unsupported ODRL asset properties
903
+ if (Is.notEmpty(firstTarget.hasPolicy)) {
904
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "assetHasPolicyNotSupported");
905
+ }
906
+ if (Is.notEmpty(firstTarget.partOf)) {
907
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "assetPartOfNotSupported");
908
+ }
909
+ if (Is.object(firstTarget) &&
910
+ OdrlPolicyHelper.getType(firstTarget) === OdrlTypes.AssetCollection) {
911
+ if (!Is.stringValue(firstTarget.source)) {
912
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "assetCollectionSourceNotSupported", {
913
+ source: firstTarget.source ?? ""
914
+ });
915
+ }
916
+ let sourceLookup;
917
+ try {
918
+ sourceLookup = this.tryResolveTargetDataSource(firstTarget.source, dataSources);
919
+ }
920
+ catch {
921
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "assetCollectionSourceNotSupported", {
922
+ source: firstTarget.source
923
+ });
924
+ }
925
+ return {
926
+ target: sourceLookup.target,
927
+ refinements: ArrayHelper.fromObjectOrArray(firstTarget.refinement ?? [])
928
+ };
929
+ }
930
+ }
931
+ const targetId = Is.string(firstTarget) ? firstTarget : OdrlPolicyHelper.getUid(firstTarget);
932
+ if (!Is.stringValue(targetId)) {
933
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "ruleTargetNotSupported", {
934
+ target: ""
935
+ });
936
+ }
937
+ const targetLookup = this.tryResolveTargetDataSource(targetId, dataSources);
938
+ return {
939
+ target: targetLookup.target,
940
+ refinements: []
941
+ };
942
+ }
943
+ /**
944
+ * Resolve decision targets for a rule.
945
+ * AssetCollection wildcard targets with refinements are expanded to per-item targets.
946
+ * @param rule The rule being evaluated.
947
+ * @param dataSources The operand lookup sources.
948
+ * @returns The decision targets and scoped refinements.
949
+ * @internal
950
+ */
951
+ resolveRuleDecisionTargets(rule, dataSources) {
952
+ const resolvedTarget = this.resolveRuleTarget(rule, dataSources);
953
+ if (!this.shouldExpandToPerItemTargets(rule, resolvedTarget)) {
954
+ return [resolvedTarget];
955
+ }
956
+ const sourceLookup = this.tryResolveTargetDataSource(this.getRuleDataContextTargetId(rule.target), dataSources);
957
+ const matches = JsonPathHelper.query(sourceLookup.target, sourceLookup.source);
958
+ if (matches.length === 0) {
959
+ return [resolvedTarget];
960
+ }
961
+ return matches.map(match => {
962
+ const itemTarget = this.normalizeDecisionTargetPath(match.path ?? resolvedTarget.target);
963
+ return {
964
+ target: itemTarget,
965
+ refinements: resolvedTarget.refinements.map(refinement => this.rewriteRefinementForDecisionTarget(refinement, resolvedTarget.target, itemTarget))
966
+ };
967
+ });
968
+ }
969
+ /**
970
+ * Determine if a rule should be expanded to per-item targets.
971
+ * @param rule The rule.
972
+ * @param resolvedTarget The resolved target details.
973
+ * @returns True if the rule should emit per-item decisions.
974
+ * @internal
975
+ */
976
+ shouldExpandToPerItemTargets(rule, resolvedTarget) {
977
+ if (resolvedTarget.refinements.length === 0 || !resolvedTarget.target.includes("[*]")) {
978
+ return false;
979
+ }
980
+ const targets = ArrayHelper.fromObjectOrArray(rule.target ?? []);
981
+ if (targets.length !== 1 || !Is.object(targets[0])) {
982
+ return false;
983
+ }
984
+ return OdrlPolicyHelper.getType(targets[0]) === OdrlTypes.AssetCollection;
985
+ }
986
+ /**
987
+ * Rewrite a refinement so wildcard paths are scoped to a concrete item target.
988
+ * @param refinement The refinement to rewrite.
989
+ * @param sourceTarget The wildcard source target.
990
+ * @param itemTarget The concrete item target.
991
+ * @returns The rewritten refinement.
992
+ * @internal
993
+ */
994
+ rewriteRefinementForDecisionTarget(refinement, sourceTarget, itemTarget) {
995
+ const logicalConstraint = this.getLogicalConstraintOperands(refinement);
996
+ if (logicalConstraint) {
997
+ return {
998
+ ...refinement,
999
+ [logicalConstraint.operator]: logicalConstraint.constraints.map(item => this.rewriteRefinementForDecisionTarget(item, sourceTarget, itemTarget))
1000
+ };
1001
+ }
1002
+ const regularConstraint = refinement;
1003
+ return {
1004
+ ...regularConstraint,
1005
+ leftOperand: this.rewriteOperandForDecisionTarget(regularConstraint.leftOperand, sourceTarget, itemTarget),
1006
+ rightOperand: this.rewriteOperandForDecisionTarget(regularConstraint.rightOperand, sourceTarget, itemTarget)
1007
+ };
1008
+ }
1009
+ /**
1010
+ * Rewrite JSONPath-based operands from wildcard source to concrete item target.
1011
+ * @param operand The operand to rewrite.
1012
+ * @param sourceTarget The wildcard source target.
1013
+ * @param itemTarget The concrete item target.
1014
+ * @returns The rewritten operand.
1015
+ * @internal
1016
+ */
1017
+ rewriteOperandForDecisionTarget(operand, sourceTarget, itemTarget) {
1018
+ if (Is.stringValue(operand)) {
1019
+ const prefix = `${DefaultPolicyArbiter._TWIN_PREFIX_OPERATIONS}${DefaultPolicyArbiter._TWIN_PREFIX_JSONPATH}:`;
1020
+ if (operand.startsWith(prefix)) {
1021
+ const valuePath = operand.slice(prefix.length);
1022
+ return `${prefix}${this.rewriteWildcardPath(valuePath, sourceTarget, itemTarget)}`;
1023
+ }
1024
+ return operand;
1025
+ }
1026
+ if (Is.object(operand)) {
1027
+ const typedOperand = { ...operand };
1028
+ if (typedOperand["@type"] ===
1029
+ `${DefaultPolicyArbiter._TWIN_PREFIX_OPERATIONS}${DefaultPolicyArbiter._TWIN_PREFIX_JSONPATH}` &&
1030
+ Is.stringValue(typedOperand["@value"])) {
1031
+ typedOperand["@value"] = this.rewriteWildcardPath(typedOperand["@value"], sourceTarget, itemTarget);
1032
+ }
1033
+ return typedOperand;
1034
+ }
1035
+ return operand;
1036
+ }
1037
+ /**
1038
+ * Rewrite wildcard source JSONPath segments to a concrete item JSONPath.
1039
+ * @param valuePath The operand path.
1040
+ * @param sourceTarget The wildcard source path.
1041
+ * @param itemTarget The concrete item path.
1042
+ * @returns The rewritten path.
1043
+ * @internal
1044
+ */
1045
+ rewriteWildcardPath(valuePath, sourceTarget, itemTarget) {
1046
+ if (!sourceTarget.includes("[*]") || !valuePath.includes("[*]")) {
1047
+ return valuePath;
1048
+ }
1049
+ if (valuePath.startsWith(sourceTarget)) {
1050
+ return `${itemTarget}${valuePath.slice(sourceTarget.length)}`;
1051
+ }
1052
+ return valuePath;
1053
+ }
1054
+ /**
1055
+ * Normalize JSONPath strings to dot notation for stable decision targets.
1056
+ * @param path The JSONPath to normalize.
1057
+ * @returns The normalized path.
1058
+ * @internal
1059
+ */
1060
+ normalizeDecisionTargetPath(path) {
1061
+ return path.replace(/\['([^']+)']/g, ".$1");
1062
+ }
500
1063
  /**
501
1064
  * Evaluate a single ODRL constraint against the available context.
502
1065
  * Supports logical constraint composition through nested refinements.
503
1066
  * @param constraint The constraint to evaluate.
504
- * @param ruleDataContext The request data/context.
1067
+ * @param dataSources The operand lookup sources.
505
1068
  * @returns True if the constraint is satisfied.
506
1069
  * @internal
507
1070
  */
508
- evaluateConstraint(constraint, ruleDataContext) {
1071
+ evaluateConstraint(constraint, dataSources) {
509
1072
  const logicalConstraint = this.getLogicalConstraintOperands(constraint);
510
1073
  if (logicalConstraint) {
511
- return this.evaluateLogicalConstraint(logicalConstraint, ruleDataContext);
1074
+ return this.evaluateLogicalConstraint(logicalConstraint, dataSources);
512
1075
  }
513
1076
  // Must be a regular constraint beyond this point
514
1077
  const regularConstraint = constraint;
1078
+ // rightOperandReference is not supported — it requires an external IRI lookup that
1079
+ // is outside the scope of the local evaluation engine.
1080
+ if (Is.notEmpty(regularConstraint.rightOperandReference)) {
1081
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "rightOperandReferenceNotSupported");
1082
+ }
1083
+ // dataType specifies how the rightOperand value should be coerced before comparison.
1084
+ // Without dataType-aware coercion logic the comparison may produce incorrect results.
1085
+ if (Is.notEmpty(regularConstraint.dataType)) {
1086
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "constraintDataTypeNotSupported");
1087
+ }
1088
+ // unit specifies the measurement unit for the right operand (e.g. currency, length).
1089
+ // Unit-aware comparison is not implemented.
1090
+ if (Is.notEmpty(regularConstraint.unit)) {
1091
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "constraintUnitNotSupported");
1092
+ }
1093
+ // status represents a state-based evaluation operand (e.g. odrl:policyUsage).
1094
+ // State-based evaluation is not implemented.
1095
+ if (Is.notEmpty(regularConstraint.status)) {
1096
+ throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "constraintStatusNotSupported");
1097
+ }
515
1098
  // Evaluate the main constraint condition
516
- const leftValue = this.calculateOperandValue(regularConstraint.leftOperand, ruleDataContext);
517
- const rightValue = this.calculateOperandValue(regularConstraint.rightOperand, ruleDataContext);
1099
+ const leftValue = this.calculateOperandValue(regularConstraint.leftOperand, dataSources);
1100
+ const rightValue = this.calculateOperandValue(regularConstraint.rightOperand, dataSources);
518
1101
  const mainSatisfied = this.evaluateOperator(regularConstraint.operator, leftValue, rightValue);
519
1102
  // If main constraint is not satisfied, the overall constraint fails
520
1103
  return mainSatisfied;
@@ -589,32 +1172,32 @@ export class DefaultPolicyArbiter {
589
1172
  /**
590
1173
  * Evaluate a logical constraint operator against its operands.
591
1174
  * @param logicalConstraint The operator and operand list.
592
- * @param ruleDataContext The request data/context.
1175
+ * @param dataSources The operand lookup sources.
593
1176
  * @returns True if the logical constraint is satisfied.
594
1177
  * @internal
595
1178
  */
596
- evaluateLogicalConstraint(logicalConstraint, ruleDataContext) {
1179
+ evaluateLogicalConstraint(logicalConstraint, dataSources) {
597
1180
  const { operator, constraints } = logicalConstraint;
598
1181
  if (constraints.length === 0) {
599
1182
  return false;
600
1183
  }
601
1184
  switch (operator) {
602
1185
  case OdrlLogicalConstraintType.And:
603
- return constraints.every(item => this.evaluateConstraint(item, ruleDataContext));
1186
+ return constraints.every(item => this.evaluateConstraint(item, dataSources));
604
1187
  case OdrlLogicalConstraintType.AndSequence: {
605
1188
  for (const item of constraints) {
606
- if (!this.evaluateConstraint(item, ruleDataContext)) {
1189
+ if (!this.evaluateConstraint(item, dataSources)) {
607
1190
  return false;
608
1191
  }
609
1192
  }
610
1193
  return true;
611
1194
  }
612
1195
  case OdrlLogicalConstraintType.Or:
613
- return constraints.some(item => this.evaluateConstraint(item, ruleDataContext));
1196
+ return constraints.some(item => this.evaluateConstraint(item, dataSources));
614
1197
  case OdrlLogicalConstraintType.Xone: {
615
1198
  let satisfied = 0;
616
1199
  for (const item of constraints) {
617
- if (this.evaluateConstraint(item, ruleDataContext)) {
1200
+ if (this.evaluateConstraint(item, dataSources)) {
618
1201
  satisfied += 1;
619
1202
  if (satisfied > 1) {
620
1203
  return false;
@@ -627,25 +1210,57 @@ export class DefaultPolicyArbiter {
627
1210
  return false;
628
1211
  }
629
1212
  }
1213
+ /**
1214
+ * Resolve a prefixed operand to its source object and JSONPath expression.
1215
+ * Prefix matching is dictionary-driven so additional operand namespaces can be
1216
+ * added in one place by extending the lookup sources map.
1217
+ * @param operandTypeOrValue The string operand value or typed operand namespace.
1218
+ * @param operandValue The JSONPath expression.
1219
+ * @param dataSources The available lookup sources.
1220
+ * @returns The resolved source and JSONPath, or undefined when not namespaced.
1221
+ * @throws GeneralError if a twin: prefixed operand doesn't resolve to any available datasource key.
1222
+ * @internal
1223
+ */
1224
+ tryResolveOperandLookup(operandTypeOrValue, operandValue, dataSources) {
1225
+ let lookupTargetId;
1226
+ if (dataSources[operandTypeOrValue]) {
1227
+ if (!Is.stringValue(operandValue)) {
1228
+ return undefined;
1229
+ }
1230
+ lookupTargetId = `${operandTypeOrValue}:${operandValue}`;
1231
+ }
1232
+ else if (operandTypeOrValue.includes(":")) {
1233
+ lookupTargetId = operandTypeOrValue;
1234
+ }
1235
+ else {
1236
+ return undefined;
1237
+ }
1238
+ // Delegate prefixed path matching to shared datasource resolver
1239
+ if (operandTypeOrValue.startsWith(DefaultPolicyArbiter._TWIN_PREFIX_OPERATIONS)) {
1240
+ const resolved = this.tryResolveTargetDataSource(lookupTargetId, dataSources);
1241
+ return {
1242
+ source: resolved.source,
1243
+ jsonPath: resolved.target
1244
+ };
1245
+ }
1246
+ }
630
1247
  /**
631
1248
  * Calculate an operand value.
632
1249
  * @param operand The operand.
633
- * @param ruleDataContext The request data/context.
1250
+ * @param dataSources The available prefixed operand sources.
634
1251
  * @returns The resolved operand value.
635
1252
  * @internal
636
1253
  */
637
- calculateOperandValue(operand, ruleDataContext) {
638
- // Treat JSONPath operands as selectors against the current rule data context.
639
- // Will be in the format twin:jsonpath:<jsonPath>
1254
+ calculateOperandValue(operand, dataSources) {
1255
+ // Treat prefixed operands as selectors against a namespaced source dictionary.
1256
+ // Examples: twin:jsonpath:$.field, twin:information:$.credentials.level
640
1257
  let jsonPath;
1258
+ let operandRoot;
641
1259
  if (Is.stringValue(operand)) {
642
- if (operand.startsWith(`${DefaultPolicyArbiter._JSON_PATH_OPERAND_PREFIX}:`)) {
643
- jsonPath = operand.slice(DefaultPolicyArbiter._JSON_PATH_OPERAND_PREFIX.length + 1);
644
- if (jsonPath.length === 0) {
645
- throw new GeneralError(DefaultPolicyArbiter.CLASS_NAME, "jsonPathOperandMissingTarget", {
646
- operand
647
- });
648
- }
1260
+ const lookup = this.tryResolveOperandLookup(operand, operand, dataSources);
1261
+ if (lookup) {
1262
+ jsonPath = lookup.jsonPath;
1263
+ operandRoot = lookup.source;
649
1264
  }
650
1265
  }
651
1266
  else if (Is.object(operand)) {
@@ -653,12 +1268,10 @@ export class DefaultPolicyArbiter {
653
1268
  const value = operand["@value"];
654
1269
  const type = operand["@type"];
655
1270
  if (Is.stringValue(type)) {
656
- // If the type is set we can try and extract the value
657
- // is it twin:jsonpath ?
658
- if (type === DefaultPolicyArbiter._JSON_PATH_OPERAND_PREFIX) {
659
- if (Is.stringValue(value)) {
660
- jsonPath = value;
661
- }
1271
+ const lookup = this.tryResolveOperandLookup(type, value, dataSources);
1272
+ if (lookup) {
1273
+ jsonPath = lookup.jsonPath;
1274
+ operandRoot = lookup.source;
662
1275
  }
663
1276
  else {
664
1277
  const xsdValue = this.coerceXsdType(value, type);
@@ -670,7 +1283,7 @@ export class DefaultPolicyArbiter {
670
1283
  }
671
1284
  // We have a JSON Path to resolve
672
1285
  if (Is.stringValue(jsonPath)) {
673
- const jsonPaths = JsonPathHelper.query(jsonPath, ruleDataContext);
1286
+ const jsonPaths = JsonPathHelper.query(jsonPath, operandRoot);
674
1287
  if (jsonPaths.length === 0) {
675
1288
  // No matches
676
1289
  return undefined;