From ab46dada734c3ca8b713a12d363a68e8b711a72f Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 26 Aug 2026 14:10:27 +0200 Subject: [PATCH] feat: Support neq, isAnyOf, and isNoneOf as constraint operators --- .../authorizers/SimpleOdrlAuthorizer.ts | 143 ++++++++++++------ .../authorizers/SimpleOdrlAuthorizer.test.ts | 103 ++++++++++++- 2 files changed, 196 insertions(+), 50 deletions(-) diff --git a/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts b/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts index 58866e6..7a2e500 100644 --- a/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts +++ b/packages/uma/src/policies/authorizers/SimpleOdrlAuthorizer.ts @@ -1,7 +1,7 @@ -import { NamedNode } from '@rdfjs/types'; +import { NamedNode, Term } from '@rdfjs/types'; import { getLoggerFor } from 'global-logger-factory'; import jp from 'jsonpath'; -import { DataFactory as DF, Quad_Subject } from 'n3'; +import { DataFactory as DF, Quad_Object, Quad_Subject } from 'n3'; import { ODRL } from 'odrl-evaluator'; import { CLIENTID, VC, WEBID } from '../../credentials/Claims'; import { ClaimSet } from '../../credentials/ClaimSet'; @@ -33,6 +33,12 @@ const claimOperandMap: Record = { [ODRL.deliveryChannel]: CLIENTID, } as const; +interface PolicyData { + store: ReadOnlyStore; + lists: Readonly>; + claims: Readonly; +} + /** * A simple authorizer that can handle basic ODRL policies with direct permissions and prohibitions, * without any complex constraints or inheritance. @@ -53,12 +59,15 @@ export class SimpleOdrlAuthorizer implements Authorizer { } const store = await this.policies.getStore(); + const lists = store.extractLists(); + + const data: PolicyData = { store, lists, claims }; let permissions: Permission[] = []; for (const { resource_id, resource_scopes } of query) { const allowedScopes: string[] = []; for (const scope of resource_scopes) { - const result = this.getPermissions(store, claims, resource_id, scope); + const result = this.getPermissions(data, resource_id, scope); if (!result) { // Too difficult to handle internally so need to call complete authorizer return this.authorizer.permissions(claims, query); @@ -76,11 +85,11 @@ export class SimpleOdrlAuthorizer implements Authorizer { return permissions; } - protected getPermissions(policies: ReadOnlyStore, claims: ClaimSet, resource: string, scope: string): + protected getPermissions(data: PolicyData, resource: string, scope: string): string[] | undefined { - this.logger.info(`Evaluating Request ${scope}, ${resource} with claims ${JSON.stringify(claims)}`); - const targets = [ DF.namedNode(resource), ...policies.getObjects(resource, ODRL.terms.partOf, null)]; - let rules = targets.flatMap(target => policies.getSubjects(ODRL.terms.target, target, null)); + this.logger.info(`Evaluating Request ${scope}, ${resource} with claims ${JSON.stringify(data.claims)}`); + const targets = [ DF.namedNode(resource), ...data.store.getObjects(resource, ODRL.terms.partOf, null)]; + let rules = targets.flatMap(target => data.store.getSubjects(ODRL.terms.target, target, null)); if (rules.length === 0) { this.logger.warn('Rejecting request because no rules with a matching target or asset collection were found'); return []; @@ -95,8 +104,8 @@ export class SimpleOdrlAuthorizer implements Authorizer { // Note that this only catches this specific super action const superAction = scope === ODRL.append || scope === ODRL.write ? ODRL.terms.modify : undefined; rules = rules.filter(rule => - policies.has(DF.quad(rule, ODRL.terms.action, DF.namedNode(scope))) || - (superAction !== undefined && policies.has(DF.quad(rule, ODRL.terms.action, superAction))) + data.store.has(DF.quad(rule, ODRL.terms.action, DF.namedNode(scope))) || + (superAction !== undefined && data.store.has(DF.quad(rule, ODRL.terms.action, superAction))) ); if (rules.length === 0) { this.logger.warn('Rejecting request because no rules with a matching action were found'); @@ -104,15 +113,15 @@ export class SimpleOdrlAuthorizer implements Authorizer { } let assignees: NamedNode[] = [ ANONYMOUS ]; - for (const user of claims[WEBID] ?? []) { + for (const user of data.claims[WEBID] ?? []) { if (typeof user === 'string') { const userNode = DF.namedNode(user); assignees.push(userNode); - assignees.push(...(policies.getObjects(user, ODRL.terms.partOf, null) as NamedNode[])); + assignees.push(...(data.store.getObjects(user, ODRL.terms.partOf, null) as NamedNode[])); } } rules = rules.filter(rule => { - const ruleAssignees = policies.getObjects(rule, ODRL.terms.assignee, null); + const ruleAssignees = data.store.getObjects(rule, ODRL.terms.assignee, null); if (ruleAssignees.length === 0) { // Public access return true; @@ -127,8 +136,8 @@ export class SimpleOdrlAuthorizer implements Authorizer { // Check simple constraints const validRules: Quad_Subject[] = []; for (const rule of rules) { - const constraintResponse = this.validateConstraints(rule, policies, claims); - const vcConstraintResponse = this.validateOvcConstraints(rule, policies, claims); + const constraintResponse = this.validateConstraints(rule, data); + const vcConstraintResponse = this.validateOvcConstraints(rule, data); if (constraintResponse && vcConstraintResponse) { validRules.push(rule); } else if (constraintResponse === undefined || vcConstraintResponse === undefined) { @@ -140,13 +149,13 @@ export class SimpleOdrlAuthorizer implements Authorizer { return []; } - const predicates = validRules.map(rule => policies.getPredicates(null, rule, null)); + const predicates = validRules.map(rule => data.store.getPredicates(null, rule, null)); for (const rulePredicates of predicates) { if (rulePredicates.length === 0) { return; } if (rulePredicates.some(predicate => predicate.equals(ODRL.terms.prohibition))) { - this.logger.warn('Rejecting request because only matching prohibitions were found'); + this.logger.warn('Rejecting request because matching prohibitions were found'); return []; } // This implies we have an unsupported type of rule @@ -165,11 +174,11 @@ export class SimpleOdrlAuthorizer implements Authorizer { * and undefined if any constraint is too complex to evaluate. * Only supports deliveryChannel (for client ID), purpose, and dateTime constraints. */ - protected validateConstraints(rule: Quad_Subject, policies: ReadOnlyStore, claims: ClaimSet): boolean | undefined { - const constraints = policies.getObjects(rule, ODRL.terms.constraint, null).map(constraint => ({ - leftOperand: policies.getObjects(constraint, ODRL.terms.leftOperand, null)[0], - operator: policies.getObjects(constraint, ODRL.terms.operator, null)[0], - rightOperand: policies.getObjects(constraint, ODRL.terms.rightOperand, null)[0], + protected validateConstraints(rule: Quad_Subject, data: PolicyData): boolean | undefined { + const constraints = data.store.getObjects(rule, ODRL.terms.constraint, null).map(constraint => ({ + leftOperand: data.store.getObjects(constraint, ODRL.terms.leftOperand, null)[0], + operator: data.store.getObjects(constraint, ODRL.terms.operator, null)[0], + rightOperand: data.store.getObjects(constraint, ODRL.terms.rightOperand, null)[0], })); // If any of these are undefined this is too complex to handle here if (constraints.some(({ leftOperand, operator, rightOperand }) => !leftOperand || !operator || !rightOperand)) { @@ -178,7 +187,6 @@ export class SimpleOdrlAuthorizer implements Authorizer { // TODO: would want middleware step where credentials and other stuff are already extracted into RDF values // so both ODRL authorizers don't have to bother with this for (const constraint of constraints) { - const claimValues = claims[constraint.leftOperand.value]; // Return undefined if any of these are too complex or unknown if (constraint.leftOperand.equals(ODRL.terms.dateTime)) { const comparisonDate = new Date(constraint.rightOperand.value); @@ -189,33 +197,27 @@ export class SimpleOdrlAuthorizer implements Authorizer { if (!comparator(new Date(), comparisonDate)) { return false; } - } else if (claimOperandMap[constraint.leftOperand.value]) { - const claimKey = claimOperandMap[constraint.leftOperand.value]; - if (!constraint.operator.equals(ODRL.terms.eq)) { - return false; - } - if (!claims[claimKey]?.some(claim => claim === constraint.rightOperand.value)) { - return false; - } - } else if (claimValues?.every(claim => typeof claim === 'string') && constraint.operator.equals(ODRL.terms.eq)) { - if (!claimValues?.some(claim => claim === constraint.rightOperand.value)) { - return false; - } } else { - // Unsupported constraint - return; + const claimKey = claimOperandMap[constraint.leftOperand.value] ?? constraint.leftOperand.value; + const claimValues = data.claims[claimKey]; + const rightValues = data.lists[constraint.rightOperand.value] ?? [constraint.rightOperand]; + const result = this.verifyConstraint(claimValues ?? [], constraint.operator, rightValues); + // Catches both false and undefined + if (!result) { + return result; + } } } return true; } // https://gitlab.com/gaia-x/lab/policy-reasoning/odrl-vc-profile - protected validateOvcConstraints(rule: Quad_Subject, policies: ReadOnlyStore, claims: ClaimSet): boolean | undefined { - const constraints = policies.getObjects(rule, OVC.terms.constraint, null).map(constraint => ({ - leftOperand: policies.getObjects(constraint, OVC.terms.leftOperand, null)[0], - operator: policies.getObjects(constraint, ODRL.terms.operator, null)[0], - rightOperand: policies.getObjects(constraint, ODRL.terms.rightOperand, null)[0], - credentialSubjectType: policies.getObjects(constraint, OVC.terms.credentialSubjectType, null)[0], + protected validateOvcConstraints(rule: Quad_Subject, data: PolicyData): boolean | undefined { + const constraints = data.store.getObjects(rule, OVC.terms.constraint, null).map(constraint => ({ + leftOperand: data.store.getObjects(constraint, OVC.terms.leftOperand, null)[0], + operator: data.store.getObjects(constraint, ODRL.terms.operator, null)[0], + rightOperand: data.store.getObjects(constraint, ODRL.terms.rightOperand, null)[0], + credentialSubjectType: data.store.getObjects(constraint, OVC.terms.credentialSubjectType, null)[0], })); // If any of these are undefined this is too complex to handle here (credentialSubjectType can be undefined) if (constraints.some(({ leftOperand, operator, rightOperand }) => !leftOperand || !operator || !rightOperand)) { @@ -225,20 +227,19 @@ export class SimpleOdrlAuthorizer implements Authorizer { return true; } // Can't match a VC constraint if there is no VC input - const vcs = claims[VC]; + const vcs = data.claims[VC]; if (!vcs || vcs?.length === 0) { return false; } for (const constraint of constraints) { - // Only support odrl:eq for now - if (!constraint.operator.equals(ODRL.terms.eq)) { - return; - } const foundMatchedVc = vcs.some(vc => { const results = jp.query(vc, constraint.leftOperand.value).flat(); - if (!results.some(result => result === constraint.rightOperand.value)) { - return false; + const rightValues = data.lists[constraint.rightOperand.value] ?? [constraint.rightOperand]; + const result = this.verifyConstraint(results, constraint.operator, rightValues); + // Catches both false and undefined + if (!result) { + return result; } if (constraint.credentialSubjectType) { const types = jp.query(vc, '$.type').flat(); @@ -255,4 +256,48 @@ export class SimpleOdrlAuthorizer implements Authorizer { return true; } + + protected verifyConstraint(left: unknown[], operator: Quad_Object, right: Term[]): boolean | undefined { + if (left.length === 0 || right.length === 0) { + return; + } + + const leftStrings = left.map(val => { + if (typeof val === 'string') { + return val; + } + if (typeof (val as { value: unknown }).value === 'string') { + return (val as { value: string }).value; + } + return; + }).filter((val): val is string => val !== undefined); + const rightStrings = right.map(term => term.value); + + // TODO: Not supporting more than 1 left value until we have decided on the semantics + if (leftStrings.length > 1) { + return; + } + const leftString = leftStrings[0]; + + if (operator) { + switch (operator.value) { + case ODRL.eq: + if (rightStrings.length > 1) { + return; + } + return leftString === rightStrings[0]; + case ODRL.neq: + if (rightStrings.length > 1) { + return; + } + return leftString !== rightStrings[0]; + case ODRL.isAnyOf: + return rightStrings.includes(leftString); + case ODRL.isNoneOf: + return !rightStrings.includes(leftString); + default: + return; + } + } + } } diff --git a/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts b/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts index 17a158f..09c5c9c 100644 --- a/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts +++ b/packages/uma/test/unit/policies/authorizers/SimpleOdrlAuthorizer.test.ts @@ -1,4 +1,4 @@ -import type { NamedNode } from '@rdfjs/types'; +import type { NamedNode, Quad, Quad_Subject, Quad_Object, Quad_Predicate } from '@rdfjs/types'; import { DataFactory as DF, Store } from 'n3'; import { randomUUID } from 'node:crypto'; import { ODRL } from 'odrl-evaluator'; @@ -11,6 +11,29 @@ import { UCRulesStorage } from '../../../../src/ucp/storage/UCRulesStorage'; import { OVC } from '../../../../src/ucp/util/Vocabularies'; import { Permission } from '../../../../src/views/Permission'; +function generateListTriples(subject: Quad_Subject, predicate: Quad_Predicate, objects: Quad_Object[]): Quad[] { + if (objects.length === 0) { + return []; + } + const triples: Quad[] = []; + let current = DF.blankNode(); + triples.push(DF.quad(subject, predicate, current)); + for (let i = 0; i < objects.length; i++) { + triples.push(DF.quad(current, DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#first'), objects[i])); + if (i === objects.length - 1) { + triples.push(DF.quad( + current, + DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#rest'), + DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#nil'))); + } else { + const next = DF.blankNode(); + triples.push(DF.quad(current, DF.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#rest'), next)); + current = next; + } + } + return triples; +} + describe('SimpleOdrlAuthorizer', () => { const resource = 'res'; const scope = 'urn:example:css:modes:read'; @@ -228,6 +251,84 @@ describe('SimpleOdrlAuthorizer', () => { expect(fallback.permissions).not.toHaveBeenCalled(); }); + it('supports neq constraints.', async(): Promise => { + const rule = addRule({}); + addConstraint({ + rule, + leftOperand: ODRL.terms.purpose, + operator: ODRL.terms.neq, + rightOperand: 'http://example.com/purpose-a', + }); + const claims = { [PURPOSE]: [ 'http://example.com/purpose-b' ] }; + + await expect(authorizer.permissions(claims, query)) + .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + expect(fallback.permissions).not.toHaveBeenCalled(); + + claims[PURPOSE] = [ 'http://example.com/purpose-a' ]; + await expect(authorizer.permissions(claims, query)) + .resolves.toEqual([]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('supports isAnyOf constraints.', async(): Promise => { + const rule = addRule({}); + const constraint = DF.namedNode(`constraint-${randomUUID()}`); + store.addQuad(rule, ODRL.terms.constraint, constraint); + store.addQuad(constraint, ODRL.terms.leftOperand, ODRL.terms.purpose); + store.addQuad(constraint, ODRL.terms.operator, ODRL.terms.isAnyOf); + store.addQuads(generateListTriples(constraint, ODRL.terms.rightOperand, [ + DF.namedNode('http://example.com/purpose-a'), + DF.namedNode('http://example.com/purpose-b') + ])); + const claims = { [PURPOSE]: [ 'http://example.com/purpose-b' ] }; + + await expect(authorizer.permissions(claims, query)) + .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + expect(fallback.permissions).not.toHaveBeenCalled(); + + claims[PURPOSE] = [ 'http://example.com/purpose-c' ]; + await expect(authorizer.permissions(claims, query)) + .resolves.toEqual([]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('supports isNoneOf constraints.', async(): Promise => { + const rule = addRule({}); + const constraint = DF.namedNode(`constraint-${randomUUID()}`); + store.addQuad(rule, ODRL.terms.constraint, constraint); + store.addQuad(constraint, ODRL.terms.leftOperand, ODRL.terms.purpose); + store.addQuad(constraint, ODRL.terms.operator, ODRL.terms.isNoneOf); + store.addQuads(generateListTriples(constraint, ODRL.terms.rightOperand, [ + DF.namedNode('http://example.com/purpose-a'), + DF.namedNode('http://example.com/purpose-b') + ])); + const claims = { [PURPOSE]: [ 'http://example.com/purpose-c' ] }; + + await expect(authorizer.permissions(claims, query)) + .resolves.toEqual([{ resource_id: resource, resource_scopes: [scope] }]); + expect(fallback.permissions).not.toHaveBeenCalled(); + + claims[PURPOSE] = [ 'http://example.com/purpose-b' ]; + await expect(authorizer.permissions(claims, query)) + .resolves.toEqual([]); + expect(fallback.permissions).not.toHaveBeenCalled(); + }); + + it('does not support multiple claims for the same left operand.', async(): Promise => { + const rule = addRule({}); + addConstraint({ + rule, + leftOperand: ODRL.terms.purpose, + operator: ODRL.terms.eq, + rightOperand: 'http://example.com/purpose-a', + }); + const claims = { [PURPOSE]: [ 'http://example.com/purpose-a', 'http://example.com/purpose-b' ] }; + + await expect(authorizer.permissions(claims, query)).resolves.toEqual(fallbackPermissions); + expect(fallback.permissions).toHaveBeenCalledWith(claims, query); + }); + it('delegates to fallback if OVC constraint is too complex', async () => { const rule = addRule({}); store.addQuad(rule, OVC.terms.constraint, DF.namedNode('constraint3'));