From 25d415dd8a2301967d4cbaf8f6b176d94921b4b9 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 8 Sep 2026 11:28:29 +0200 Subject: [PATCH] feat: Support a list of values as an access request right operand --- documentation/access-request-management.md | 12 +++ .../src/controller/AccessRequestController.ts | 62 ++++++++++---- .../ucp/storage/FileBackupUCRulesStorage.ts | 2 - packages/uma/src/ucp/util/Vocabularies.ts | 8 +- .../AccessRequestController.test.ts | 83 ++++++++++++++++--- test/integration/AccessRequests.test.ts | 32 +++++-- 6 files changed, 160 insertions(+), 39 deletions(-) diff --git a/documentation/access-request-management.md b/documentation/access-request-management.md index 5feb4b46..111eaac2 100644 --- a/documentation/access-request-management.md +++ b/documentation/access-request-management.md @@ -42,6 +42,18 @@ these can be added in an additional field of the request: } ``` +Multiple values can also be provided for the right operand of a constraint request. +For example: +```json +{ + "resource_id": "http://example.org/document", + "resource_scopes": [ "http://www.w3.org/ns/odrl/2/read" ], + "constraints": [ + [ "http://www.w3.org/ns/odrl/2/purpose", "http://www.w3.org/ns/odrl/2/isAnyOf", [ "http://example.org/purpose-1", "http://example.org/purpose-2" ] ] + ] +} +``` + ## Viewing requests By performing a GET request to the endpoint, a user can see all requests they have created, diff --git a/packages/uma/src/controller/AccessRequestController.ts b/packages/uma/src/controller/AccessRequestController.ts index b1d6bac5..571567d8 100644 --- a/packages/uma/src/controller/AccessRequestController.ts +++ b/packages/uma/src/controller/AccessRequestController.ts @@ -1,5 +1,4 @@ import { QueryEngine } from '@comunica/query-sparql'; -import { Quad } from '@rdfjs/types'; import { BadRequestHttpError, ConflictHttpError, @@ -8,26 +7,24 @@ import { InternalServerError, KeyValueStorage, NotFoundHttpError, - RDF } from '@solid/community-server'; import { getLoggerFor } from 'global-logger-factory'; -import { DataFactory as DF, Parser, Quad_Object, Quad_Subject, Store } from 'n3'; +import { DataFactory as DF, Quad, Quad_Object, Quad_Subject, Store } from 'n3'; import { randomUUID } from 'node:crypto'; import { ODRL } from 'odrl-evaluator'; import { stringToTerm, termToString } from 'rdf-string'; import { UCRulesStorage } from '../ucp/storage/UCRulesStorage'; -import { SOTW } from '../ucp/util/Vocabularies'; -import { array, optional as $, reType, string, tuple, Type } from '../util/ReType'; -import { Permission } from '../views/Permission'; +import { RDF, SOTW } from '../ucp/util/Vocabularies'; +import { array, optional as $, reType, string, tuple, Type, union } from '../util/ReType'; import { BaseController } from './BaseController'; export const AccessRequest = { resource_id: string, resource_scopes: array(string), - constraints: $(array(tuple(string, string, string))), + constraints: $(array(tuple(string, string, union(string, array(string))))), }; -export type AccessRequest = Type; +export type AccessRequest = Type; /** * Controller for routes concerning access requests @@ -93,15 +90,28 @@ export class AccessRequestController extends BaseController { ]); let constraintIdx = 0; for (const constraint of json.constraints ?? []) { - const terms = constraint.map((str) => stringToTerm(str)) as Quad_Object[]; const constraintSubject = DF.namedNode(subject.value + `-constraint-${++constraintIdx}`); request.addQuads([ DF.quad(subject, ODRL.terms.constraint, constraintSubject), DF.quad(constraintSubject, RDF.terms.type, ODRL.terms.Constraint), - DF.quad(constraintSubject, ODRL.terms.leftOperand, terms[0]), - DF.quad(constraintSubject, ODRL.terms.operator, terms[1]), - DF.quad(constraintSubject, ODRL.terms.rightOperand, terms[2]), + DF.quad(constraintSubject, ODRL.terms.leftOperand, stringToTerm(constraint[0]) as Quad_Object), + DF.quad(constraintSubject, ODRL.terms.operator, stringToTerm(constraint[1]) as Quad_Object), ]); + if (Array.isArray(constraint[2])) { + if (constraint[2].length === 0) { + throw new BadRequestHttpError('Constraint right operand cannot be an empty list'); + } + const nodes = constraint[2].map(() => DF.blankNode()); + request.addQuad(constraintSubject, ODRL.terms.rightOperand, nodes[0]); + for (let i = 0; i < nodes.length; i++) { + request.addQuad(nodes[i], RDF.terms.first, stringToTerm(constraint[2][i]) as Quad_Object); + request.addQuad(nodes[i], RDF.terms.rest, i === nodes.length - 1 ? RDF.terms.nil : nodes[i + 1]); + } + } else { + request.addQuad( + DF.quad(constraintSubject, ODRL.terms.rightOperand, stringToTerm(constraint[2]) as Quad_Object)); + } + } await this.store.addRule(request); @@ -219,9 +229,9 @@ export class AccessRequestController extends BaseController { DF.quad(permissionNode, ODRL.terms.target, targets[0]), DF.quad(permissionNode, ODRL.terms.assignee, parties[0]), DF.quad(permissionNode, ODRL.terms.assigner, DF.namedNode(clientID)), - ...store.getObjects(requestNode, ODRL.terms.constraint, null).flatMap((constraint) => [ + ...[...this.getConstraintQuads(store, requestNode).entries()].flatMap(([constraint, quads]) => [ DF.quad(permissionNode, ODRL.terms.constraint, constraint), - ...store.getQuads(constraint, null, null, null), + ...quads, ]), ]); this.logger.info( @@ -234,11 +244,27 @@ export class AccessRequestController extends BaseController { */ protected getRequestQuads(store: Store, subject: Quad_Subject): Quad[] { const quads = store.getQuads(subject, null, null, null); + // Constraints go a level deeper - const constraints = store.getObjects(subject, ODRL.terms.constraint, null); - for (const constraint of constraints) { - quads.push(...store.getQuads(constraint, null, null, null)); + const constraints = this.getConstraintQuads(store, subject); + return quads.concat(Array.from(constraints.values()).flat()); + } + + // Accounts for the fact constraint right operands can be lists + protected getConstraintQuads(store: Store, subject: Quad_Subject): Map { + const quads = new Map(); + const constraints = store.getObjects(subject, ODRL.terms.constraint, null); + for (const constraint of constraints) { + quads.set(constraint, store.getQuads(constraint, null, null, null)); + const potentialLists = store.getObjects(constraint, ODRL.terms.rightOperand, null); + for (const potentialList of potentialLists) { + let listNode = potentialList; + while (listNode && !listNode.equals(RDF.terms.nil)) { + quads.get(constraint)!.push(...store.getQuads(listNode as Quad_Subject, null, null, null)); + listNode = store.getObjects(listNode as Quad_Subject, RDF.terms.rest, null)[0] as Quad_Object; + } } - return quads; + } + return quads; } } diff --git a/packages/uma/src/ucp/storage/FileBackupUCRulesStorage.ts b/packages/uma/src/ucp/storage/FileBackupUCRulesStorage.ts index edcf359f..ac04ed9c 100644 --- a/packages/uma/src/ucp/storage/FileBackupUCRulesStorage.ts +++ b/packages/uma/src/ucp/storage/FileBackupUCRulesStorage.ts @@ -18,7 +18,6 @@ export class FileBackupUCRulesStorage extends MemoryUCRulesStorage implements In public constructor(protected readonly filePath?: string, interval = 5 * 60) { super(); - this.logger.info(`STARTING ${filePath}`); if (filePath) { const timer = setSafeInterval( this.logger, @@ -30,7 +29,6 @@ export class FileBackupUCRulesStorage extends MemoryUCRulesStorage implements In } public async initialize(): Promise { - this.logger.info('CALLING INITIALIZE'); if (!this.filePath) { return; } diff --git a/packages/uma/src/ucp/util/Vocabularies.ts b/packages/uma/src/ucp/util/Vocabularies.ts index c37d80f7..41e5d0af 100644 --- a/packages/uma/src/ucp/util/Vocabularies.ts +++ b/packages/uma/src/ucp/util/Vocabularies.ts @@ -1,4 +1,4 @@ -import { DC as DC_CSS } from '@solid/community-server'; +import { DC as DC_CSS, RDF as RDF_CSS } from '@solid/community-server'; import { createVocabulary, extendVocabulary } from 'rdf-vocabulary'; export const DC = extendVocabulary(DC_CSS,'creator'); @@ -52,6 +52,12 @@ export const OWL = createVocabulary( 'inverseOf', ); +export const RDF = extendVocabulary(RDF_CSS, + 'first', + 'nil', + 'rest', +); + export const SOTW = createVocabulary( 'https://w3id.org/force/sotw#', 'EvaluationRequest', diff --git a/packages/uma/test/unit/controller/AccessRequestController.test.ts b/packages/uma/test/unit/controller/AccessRequestController.test.ts index a1e01742..3af1573c 100644 --- a/packages/uma/test/unit/controller/AccessRequestController.test.ts +++ b/packages/uma/test/unit/controller/AccessRequestController.test.ts @@ -4,14 +4,13 @@ import { ForbiddenHttpError, KeyValueStorage, NotFoundHttpError, - RDF } from '@solid/community-server'; import { Parser, Store } from 'n3'; import { ODRL } from 'odrl-evaluator'; import { Mocked } from 'vitest'; import { AccessRequestController } from '../../../src/controller/AccessRequestController'; import { UCRulesStorage } from '../../../src/ucp/storage/UCRulesStorage'; -import { SOTW } from '../../../src/ucp/util/Vocabularies'; +import { RDF, SOTW } from '../../../src/ucp/util/Vocabularies'; describe('AccessRequestController', (): void => { const target = 'http://example.org/resource_id'; @@ -40,10 +39,15 @@ describe('AccessRequestController', (): void => { sotw:requestStatus sotw:requested ; sotw:requestedAction <${scopes[1]}> ; odrl:constraint [ + a odrl:Constraint ; + odrl:leftOperand odrl:dateTime ; + odrl:operator odrl:gt ; + odrl:rightOperand "2026-01-01T00:00:00Z"^^ + ], [ a odrl:Constraint ; odrl:leftOperand odrl:purpose ; - odrl:operator odrl:eq ; - odrl:rightOperand + odrl:operator odrl:isAnyOf ; + odrl:rightOperand ( ) ] . `)); @@ -118,6 +122,38 @@ describe('AccessRequestController', (): void => { `); expect(request).toBeRdfIsomorphic(expected); }); + + it('can add a request with a list of right operand values.', async(): Promise => { + const purposes = [ 'http://example.org/purpose1', 'http://example.org/purpose2' ]; + const data = JSON.stringify({ + resource_id: target, + resource_scopes: scopes, + constraints: [ + [ 'http://www.w3.org/ns/odrl/2/purpose', 'http://www.w3.org/ns/odrl/2/isAnyOf', purposes ], + ], + }); + + const response = await controller.addEntity(data, client); + expect(response.status).toBe(201); + expect(store.addRule).toHaveBeenCalledTimes(1); + + const request = store.addRule.mock.calls[0][0]; + const expected = new Parser().parse(` + @prefix sotw: . + @prefix odrl: . + <${response.id}> a sotw:EvaluationRequest ; + sotw:requestedTarget <${target}> ; + sotw:requestingParty <${client}> ; + sotw:requestStatus sotw:requested ; + sotw:requestedAction <${scopes[0]}> , <${scopes[1]}> ; + odrl:constraint <${response.id}-constraint-1> . + <${response.id}-constraint-1> a odrl:Constraint ; + odrl:leftOperand odrl:purpose ; + odrl:operator odrl:isAnyOf ; + odrl:rightOperand ( <${purposes[0]}> <${purposes[1]}> ) . + `); + expect(request).toBeRdfIsomorphic(expected); + }); }); describe('#getEntities', (): void => { @@ -149,10 +185,15 @@ describe('AccessRequestController', (): void => { sotw:requestStatus sotw:requested ; sotw:requestedAction <${scopes[1]}> ; odrl:constraint [ + a odrl:Constraint ; + odrl:leftOperand odrl:dateTime ; + odrl:operator odrl:gt ; + odrl:rightOperand "2026-01-01T00:00:00Z"^^ + ], [ a odrl:Constraint ; odrl:leftOperand odrl:purpose ; - odrl:operator odrl:eq ; - odrl:rightOperand + odrl:operator odrl:isAnyOf ; + odrl:rightOperand ( ) ] . `)); expect(ownershipStore.get).toHaveBeenCalledExactlyOnceWith(owner); @@ -191,10 +232,15 @@ describe('AccessRequestController', (): void => { sotw:requestStatus sotw:requested ; sotw:requestedAction <${scopes[1]}> ; odrl:constraint [ + a odrl:Constraint ; + odrl:leftOperand odrl:dateTime ; + odrl:operator odrl:gt ; + odrl:rightOperand "2026-01-01T00:00:00Z"^^ + ], [ a odrl:Constraint ; odrl:leftOperand odrl:purpose ; - odrl:operator odrl:eq ; - odrl:rightOperand + odrl:operator odrl:isAnyOf ; + odrl:rightOperand ( ) ] . `)); }); @@ -227,7 +273,7 @@ describe('AccessRequestController', (): void => { const permissions = quads.getObjects(policies[0], ODRL.terms.permission, null); expect(permissions).toHaveLength(1); const constraints = quads.getObjects(permissions[0], ODRL.terms.constraint, null); - expect(constraints).toHaveLength(1); + expect(constraints).toHaveLength(2); expect(quads.getQuads(policies[0], null, null, null)).toBeRdfIsomorphic(new Parser().parse(` @prefix odrl: . <${policies[0].value}> a odrl:Agreement ; @@ -241,14 +287,27 @@ describe('AccessRequestController', (): void => { odrl:action ; odrl:assignee ; odrl:assigner <${owner}> ; - odrl:constraint _:${constraints[0].value} . + odrl:constraint _:${constraints[0].value}, _:${constraints[1].value} . `)); expect(quads.getQuads(constraints[0], null, null, null)).toBeRdfIsomorphic(new Parser().parse(` @prefix odrl: . _:${constraints[0].value} a odrl:Constraint ; + odrl:leftOperand odrl:dateTime ; + odrl:operator odrl:gt ; + odrl:rightOperand "2026-01-01T00:00:00Z"^^ . + `)); + const listConstraintQuads = [ ...quads.getQuads(constraints[1], null, null, null) ]; + let listNode = quads.getObjects(constraints[1], ODRL.terms.rightOperand, null)[0]; + while (listNode && !listNode.equals(RDF.terms.nil)) { + listConstraintQuads.push(...quads.getQuads(listNode as any, null, null, null)); + listNode = quads.getObjects(listNode as any, RDF.terms.rest, null)[0]; + } + expect(listConstraintQuads).toBeRdfIsomorphic(new Parser().parse(` + @prefix odrl: . + _:${constraints[1].value} a odrl:Constraint ; odrl:leftOperand odrl:purpose ; - odrl:operator odrl:eq ; - odrl:rightOperand . + odrl:operator odrl:isAnyOf ; + odrl:rightOperand ( ) . `)); }); diff --git a/test/integration/AccessRequests.test.ts b/test/integration/AccessRequests.test.ts index 4bb4c560..12827a25 100644 --- a/test/integration/AccessRequests.test.ts +++ b/test/integration/AccessRequests.test.ts @@ -1,8 +1,8 @@ -import { App, joinUrl } from '@solid/community-server'; -import { ODRL } from '@solidlab/uma'; +import { App } from '@solid/community-server'; import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory'; -import { Parser, Store, DataFactory as DF } from 'n3'; +import { DataFactory as DF, Parser, Store } from 'n3'; import path from 'node:path'; +import { ODRL } from 'odrl-evaluator'; import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil'; import { generateCredentials } from '../util/UmaUtil'; @@ -222,7 +222,10 @@ describe('An access request server setup', (): void => { }); it('can add constraints to requests.', async(): Promise => { - const purpose = 'http://example.com/purpose'; + const dateTimeLiteral = '"2026-01-01T00:00:00Z"^^http://www.w3.org/2001/XMLSchema#dateTime'; + const dateTimeTerm = DF.literal('2026-01-01T00:00:00Z', DF.namedNode('http://www.w3.org/2001/XMLSchema#dateTime')); + const purposeA = 'http://example.com/purpose-a'; + const purposeB = 'http://example.com/purpose-b'; let response = await fetch(accessRequestEndpoint, { method: 'POST', headers: { @@ -232,7 +235,10 @@ describe('An access request server setup', (): void => { body: JSON.stringify({ resource_id: target, resource_scopes: [ 'http://www.w3.org/ns/odrl/2/create' ], - constraints: [[ 'http://www.w3.org/ns/odrl/2/purpose', 'http://www.w3.org/ns/odrl/2/eq', purpose ]], + constraints: [ + [ ODRL.dateTime, ODRL.gt, dateTimeLiteral ], + [ ODRL.purpose, ODRL.isAnyOf, [ purposeA, purposeB ] ] + ], }), }); @@ -243,7 +249,20 @@ describe('An access request server setup', (): void => { // Can see the constraints in the request response = await fetch(requestLocation, { headers: { authorization: `WebID ${encodeURIComponent(owner)}` }}); const requestQuads = new Store(new Parser().parse(await response.text())); - expect(requestQuads.countQuads(null, ODRL.terms.leftOperand, ODRL.terms.purpose, null)).toBe(1); + const constraints = requestQuads.getObjects(null, ODRL.terms.constraint, null); + expect(constraints.length).toBe(2); + expect(requestQuads.countQuads(constraints[0], ODRL.terms.leftOperand, ODRL.dateTime, null)).toBe(1); + expect(requestQuads.countQuads(constraints[0], ODRL.terms.operator, ODRL.gt, null)).toBe(1); + expect(requestQuads.countQuads(constraints[0], ODRL.terms.rightOperand, dateTimeTerm, null)).toBe(1); + expect(requestQuads.countQuads(constraints[1], ODRL.terms.leftOperand, ODRL.terms.purpose, null)).toBe(1); + expect(requestQuads.countQuads(constraints[1], ODRL.terms.operator, ODRL.terms.isAnyOf, null)).toBe(1); + const lists = Object.entries(requestQuads.extractLists()); + expect(lists.length).toBe(1); + expect(requestQuads.countQuads(constraints[1], ODRL.terms.rightOperand, DF.blankNode(lists[0][0]), null)).toBe(1); + const list = lists[0][1]; + expect(list.length).toBe(2); + expect(list[0].value).toBe(purposeA); + expect(list[1].value).toBe(purposeB); response = await fetch(requestLocation, { method: 'PATCH', @@ -260,5 +279,6 @@ describe('An access request server setup', (): void => { const policyQuads = new Store(new Parser().parse(await response.text())); expect(policyQuads.countQuads(null, ODRL.terms.action, 'http://www.w3.org/ns/odrl/2/create', null)).toBe(1); expect(policyQuads.countQuads(null, ODRL.terms.leftOperand, ODRL.terms.purpose, null)).toBe(1); + expect(policyQuads.countQuads(null, ODRL.terms.leftOperand, ODRL.dateTime, null)).toBe(1); }); });