Skip to content

Commit 25d415d

Browse files
committed
feat: Support a list of values as an access request right operand
1 parent 7cea1b9 commit 25d415d

6 files changed

Lines changed: 160 additions & 39 deletions

File tree

documentation/access-request-management.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ these can be added in an additional field of the request:
4242
}
4343
```
4444

45+
Multiple values can also be provided for the right operand of a constraint request.
46+
For example:
47+
```json
48+
{
49+
"resource_id": "http://example.org/document",
50+
"resource_scopes": [ "http://www.w3.org/ns/odrl/2/read" ],
51+
"constraints": [
52+
[ "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" ] ]
53+
]
54+
}
55+
```
56+
4557
## Viewing requests
4658

4759
By performing a GET request to the endpoint, a user can see all requests they have created,

packages/uma/src/controller/AccessRequestController.ts

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { QueryEngine } from '@comunica/query-sparql';
2-
import { Quad } from '@rdfjs/types';
32
import {
43
BadRequestHttpError,
54
ConflictHttpError,
@@ -8,26 +7,24 @@ import {
87
InternalServerError,
98
KeyValueStorage,
109
NotFoundHttpError,
11-
RDF
1210
} from '@solid/community-server';
1311
import { getLoggerFor } from 'global-logger-factory';
14-
import { DataFactory as DF, Parser, Quad_Object, Quad_Subject, Store } from 'n3';
12+
import { DataFactory as DF, Quad, Quad_Object, Quad_Subject, Store } from 'n3';
1513
import { randomUUID } from 'node:crypto';
1614
import { ODRL } from 'odrl-evaluator';
1715
import { stringToTerm, termToString } from 'rdf-string';
1816
import { UCRulesStorage } from '../ucp/storage/UCRulesStorage';
19-
import { SOTW } from '../ucp/util/Vocabularies';
20-
import { array, optional as $, reType, string, tuple, Type } from '../util/ReType';
21-
import { Permission } from '../views/Permission';
17+
import { RDF, SOTW } from '../ucp/util/Vocabularies';
18+
import { array, optional as $, reType, string, tuple, Type, union } from '../util/ReType';
2219
import { BaseController } from './BaseController';
2320

2421
export const AccessRequest = {
2522
resource_id: string,
2623
resource_scopes: array(string),
27-
constraints: $(array(tuple(string, string, string))),
24+
constraints: $(array(tuple(string, string, union(string, array(string))))),
2825
};
2926

30-
export type AccessRequest = Type<typeof Permission>;
27+
export type AccessRequest = Type<typeof AccessRequest>;
3128

3229
/**
3330
* Controller for routes concerning access requests
@@ -93,15 +90,28 @@ export class AccessRequestController extends BaseController {
9390
]);
9491
let constraintIdx = 0;
9592
for (const constraint of json.constraints ?? []) {
96-
const terms = constraint.map((str) => stringToTerm(str)) as Quad_Object[];
9793
const constraintSubject = DF.namedNode(subject.value + `-constraint-${++constraintIdx}`);
9894
request.addQuads([
9995
DF.quad(subject, ODRL.terms.constraint, constraintSubject),
10096
DF.quad(constraintSubject, RDF.terms.type, ODRL.terms.Constraint),
101-
DF.quad(constraintSubject, ODRL.terms.leftOperand, terms[0]),
102-
DF.quad(constraintSubject, ODRL.terms.operator, terms[1]),
103-
DF.quad(constraintSubject, ODRL.terms.rightOperand, terms[2]),
97+
DF.quad(constraintSubject, ODRL.terms.leftOperand, stringToTerm(constraint[0]) as Quad_Object),
98+
DF.quad(constraintSubject, ODRL.terms.operator, stringToTerm(constraint[1]) as Quad_Object),
10499
]);
100+
if (Array.isArray(constraint[2])) {
101+
if (constraint[2].length === 0) {
102+
throw new BadRequestHttpError('Constraint right operand cannot be an empty list');
103+
}
104+
const nodes = constraint[2].map(() => DF.blankNode());
105+
request.addQuad(constraintSubject, ODRL.terms.rightOperand, nodes[0]);
106+
for (let i = 0; i < nodes.length; i++) {
107+
request.addQuad(nodes[i], RDF.terms.first, stringToTerm(constraint[2][i]) as Quad_Object);
108+
request.addQuad(nodes[i], RDF.terms.rest, i === nodes.length - 1 ? RDF.terms.nil : nodes[i + 1]);
109+
}
110+
} else {
111+
request.addQuad(
112+
DF.quad(constraintSubject, ODRL.terms.rightOperand, stringToTerm(constraint[2]) as Quad_Object));
113+
}
114+
105115
}
106116

107117
await this.store.addRule(request);
@@ -219,9 +229,9 @@ export class AccessRequestController extends BaseController {
219229
DF.quad(permissionNode, ODRL.terms.target, targets[0]),
220230
DF.quad(permissionNode, ODRL.terms.assignee, parties[0]),
221231
DF.quad(permissionNode, ODRL.terms.assigner, DF.namedNode(clientID)),
222-
...store.getObjects(requestNode, ODRL.terms.constraint, null).flatMap((constraint) => [
232+
...[...this.getConstraintQuads(store, requestNode).entries()].flatMap(([constraint, quads]) => [
223233
DF.quad(permissionNode, ODRL.terms.constraint, constraint),
224-
...store.getQuads(constraint, null, null, null),
234+
...quads,
225235
]),
226236
]);
227237
this.logger.info(
@@ -234,11 +244,27 @@ export class AccessRequestController extends BaseController {
234244
*/
235245
protected getRequestQuads(store: Store, subject: Quad_Subject): Quad[] {
236246
const quads = store.getQuads(subject, null, null, null);
247+
237248
// Constraints go a level deeper
238-
const constraints = store.getObjects(subject, ODRL.terms.constraint, null);
239-
for (const constraint of constraints) {
240-
quads.push(...store.getQuads(constraint, null, null, null));
249+
const constraints = this.getConstraintQuads(store, subject);
250+
return quads.concat(Array.from(constraints.values()).flat());
251+
}
252+
253+
// Accounts for the fact constraint right operands can be lists
254+
protected getConstraintQuads(store: Store, subject: Quad_Subject): Map<Quad_Object, Quad[]> {
255+
const quads = new Map<Quad_Object, Quad[]>();
256+
const constraints = store.getObjects(subject, ODRL.terms.constraint, null);
257+
for (const constraint of constraints) {
258+
quads.set(constraint, store.getQuads(constraint, null, null, null));
259+
const potentialLists = store.getObjects(constraint, ODRL.terms.rightOperand, null);
260+
for (const potentialList of potentialLists) {
261+
let listNode = potentialList;
262+
while (listNode && !listNode.equals(RDF.terms.nil)) {
263+
quads.get(constraint)!.push(...store.getQuads(listNode as Quad_Subject, null, null, null));
264+
listNode = store.getObjects(listNode as Quad_Subject, RDF.terms.rest, null)[0] as Quad_Object;
265+
}
241266
}
242-
return quads;
267+
}
268+
return quads;
243269
}
244270
}

packages/uma/src/ucp/storage/FileBackupUCRulesStorage.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ export class FileBackupUCRulesStorage extends MemoryUCRulesStorage implements In
1818

1919
public constructor(protected readonly filePath?: string, interval = 5 * 60) {
2020
super();
21-
this.logger.info(`STARTING ${filePath}`);
2221
if (filePath) {
2322
const timer = setSafeInterval(
2423
this.logger,
@@ -30,7 +29,6 @@ export class FileBackupUCRulesStorage extends MemoryUCRulesStorage implements In
3029
}
3130

3231
public async initialize(): Promise<void> {
33-
this.logger.info('CALLING INITIALIZE');
3432
if (!this.filePath) {
3533
return;
3634
}

packages/uma/src/ucp/util/Vocabularies.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DC as DC_CSS } from '@solid/community-server';
1+
import { DC as DC_CSS, RDF as RDF_CSS } from '@solid/community-server';
22
import { createVocabulary, extendVocabulary } from 'rdf-vocabulary';
33

44
export const DC = extendVocabulary(DC_CSS,'creator');
@@ -52,6 +52,12 @@ export const OWL = createVocabulary(
5252
'inverseOf',
5353
);
5454

55+
export const RDF = extendVocabulary(RDF_CSS,
56+
'first',
57+
'nil',
58+
'rest',
59+
);
60+
5561
export const SOTW = createVocabulary(
5662
'https://w3id.org/force/sotw#',
5763
'EvaluationRequest',

packages/uma/test/unit/controller/AccessRequestController.test.ts

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@ import {
44
ForbiddenHttpError,
55
KeyValueStorage,
66
NotFoundHttpError,
7-
RDF
87
} from '@solid/community-server';
98
import { Parser, Store } from 'n3';
109
import { ODRL } from 'odrl-evaluator';
1110
import { Mocked } from 'vitest';
1211
import { AccessRequestController } from '../../../src/controller/AccessRequestController';
1312
import { UCRulesStorage } from '../../../src/ucp/storage/UCRulesStorage';
14-
import { SOTW } from '../../../src/ucp/util/Vocabularies';
13+
import { RDF, SOTW } from '../../../src/ucp/util/Vocabularies';
1514

1615
describe('AccessRequestController', (): void => {
1716
const target = 'http://example.org/resource_id';
@@ -40,10 +39,15 @@ describe('AccessRequestController', (): void => {
4039
sotw:requestStatus sotw:requested ;
4140
sotw:requestedAction <${scopes[1]}> ;
4241
odrl:constraint [
42+
a odrl:Constraint ;
43+
odrl:leftOperand odrl:dateTime ;
44+
odrl:operator odrl:gt ;
45+
odrl:rightOperand "2026-01-01T00:00:00Z"^^<http://www.w3.org/2001/XMLSchema#dateTime>
46+
], [
4347
a odrl:Constraint ;
4448
odrl:leftOperand odrl:purpose ;
45-
odrl:operator odrl:eq ;
46-
odrl:rightOperand <http://example.org/purpose>
49+
odrl:operator odrl:isAnyOf ;
50+
odrl:rightOperand ( <http://example.com/purpose-a> <http://example.com/purpose-b> )
4751
] .
4852
`));
4953

@@ -118,6 +122,38 @@ describe('AccessRequestController', (): void => {
118122
`);
119123
expect(request).toBeRdfIsomorphic(expected);
120124
});
125+
126+
it('can add a request with a list of right operand values.', async(): Promise<void> => {
127+
const purposes = [ 'http://example.org/purpose1', 'http://example.org/purpose2' ];
128+
const data = JSON.stringify({
129+
resource_id: target,
130+
resource_scopes: scopes,
131+
constraints: [
132+
[ 'http://www.w3.org/ns/odrl/2/purpose', 'http://www.w3.org/ns/odrl/2/isAnyOf', purposes ],
133+
],
134+
});
135+
136+
const response = await controller.addEntity(data, client);
137+
expect(response.status).toBe(201);
138+
expect(store.addRule).toHaveBeenCalledTimes(1);
139+
140+
const request = store.addRule.mock.calls[0][0];
141+
const expected = new Parser().parse(`
142+
@prefix sotw: <https://w3id.org/force/sotw#> .
143+
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
144+
<${response.id}> a sotw:EvaluationRequest ;
145+
sotw:requestedTarget <${target}> ;
146+
sotw:requestingParty <${client}> ;
147+
sotw:requestStatus sotw:requested ;
148+
sotw:requestedAction <${scopes[0]}> , <${scopes[1]}> ;
149+
odrl:constraint <${response.id}-constraint-1> .
150+
<${response.id}-constraint-1> a odrl:Constraint ;
151+
odrl:leftOperand odrl:purpose ;
152+
odrl:operator odrl:isAnyOf ;
153+
odrl:rightOperand ( <${purposes[0]}> <${purposes[1]}> ) .
154+
`);
155+
expect(request).toBeRdfIsomorphic(expected);
156+
});
121157
});
122158

123159
describe('#getEntities', (): void => {
@@ -149,10 +185,15 @@ describe('AccessRequestController', (): void => {
149185
sotw:requestStatus sotw:requested ;
150186
sotw:requestedAction <${scopes[1]}> ;
151187
odrl:constraint [
188+
a odrl:Constraint ;
189+
odrl:leftOperand odrl:dateTime ;
190+
odrl:operator odrl:gt ;
191+
odrl:rightOperand "2026-01-01T00:00:00Z"^^<http://www.w3.org/2001/XMLSchema#dateTime>
192+
], [
152193
a odrl:Constraint ;
153194
odrl:leftOperand odrl:purpose ;
154-
odrl:operator odrl:eq ;
155-
odrl:rightOperand <http://example.org/purpose>
195+
odrl:operator odrl:isAnyOf ;
196+
odrl:rightOperand ( <http://example.com/purpose-a> <http://example.com/purpose-b> )
156197
] .
157198
`));
158199
expect(ownershipStore.get).toHaveBeenCalledExactlyOnceWith(owner);
@@ -191,10 +232,15 @@ describe('AccessRequestController', (): void => {
191232
sotw:requestStatus sotw:requested ;
192233
sotw:requestedAction <${scopes[1]}> ;
193234
odrl:constraint [
235+
a odrl:Constraint ;
236+
odrl:leftOperand odrl:dateTime ;
237+
odrl:operator odrl:gt ;
238+
odrl:rightOperand "2026-01-01T00:00:00Z"^^<http://www.w3.org/2001/XMLSchema#dateTime>
239+
], [
194240
a odrl:Constraint ;
195241
odrl:leftOperand odrl:purpose ;
196-
odrl:operator odrl:eq ;
197-
odrl:rightOperand <http://example.org/purpose>
242+
odrl:operator odrl:isAnyOf ;
243+
odrl:rightOperand ( <http://example.com/purpose-a> <http://example.com/purpose-b> )
198244
] .
199245
`));
200246
});
@@ -227,7 +273,7 @@ describe('AccessRequestController', (): void => {
227273
const permissions = quads.getObjects(policies[0], ODRL.terms.permission, null);
228274
expect(permissions).toHaveLength(1);
229275
const constraints = quads.getObjects(permissions[0], ODRL.terms.constraint, null);
230-
expect(constraints).toHaveLength(1);
276+
expect(constraints).toHaveLength(2);
231277
expect(quads.getQuads(policies[0], null, null, null)).toBeRdfIsomorphic(new Parser().parse(`
232278
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
233279
<${policies[0].value}> a odrl:Agreement ;
@@ -241,14 +287,27 @@ describe('AccessRequestController', (): void => {
241287
odrl:action <http://example.org/scope2> ;
242288
odrl:assignee <http://example.org/unknown> ;
243289
odrl:assigner <${owner}> ;
244-
odrl:constraint _:${constraints[0].value} .
290+
odrl:constraint _:${constraints[0].value}, _:${constraints[1].value} .
245291
`));
246292
expect(quads.getQuads(constraints[0], null, null, null)).toBeRdfIsomorphic(new Parser().parse(`
247293
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
248294
_:${constraints[0].value} a odrl:Constraint ;
295+
odrl:leftOperand odrl:dateTime ;
296+
odrl:operator odrl:gt ;
297+
odrl:rightOperand "2026-01-01T00:00:00Z"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
298+
`));
299+
const listConstraintQuads = [ ...quads.getQuads(constraints[1], null, null, null) ];
300+
let listNode = quads.getObjects(constraints[1], ODRL.terms.rightOperand, null)[0];
301+
while (listNode && !listNode.equals(RDF.terms.nil)) {
302+
listConstraintQuads.push(...quads.getQuads(listNode as any, null, null, null));
303+
listNode = quads.getObjects(listNode as any, RDF.terms.rest, null)[0];
304+
}
305+
expect(listConstraintQuads).toBeRdfIsomorphic(new Parser().parse(`
306+
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
307+
_:${constraints[1].value} a odrl:Constraint ;
249308
odrl:leftOperand odrl:purpose ;
250-
odrl:operator odrl:eq ;
251-
odrl:rightOperand <http://example.org/purpose> .
309+
odrl:operator odrl:isAnyOf ;
310+
odrl:rightOperand ( <http://example.com/purpose-a> <http://example.com/purpose-b> ) .
252311
`));
253312
});
254313

test/integration/AccessRequests.test.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { App, joinUrl } from '@solid/community-server';
2-
import { ODRL } from '@solidlab/uma';
1+
import { App } from '@solid/community-server';
32
import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory';
4-
import { Parser, Store, DataFactory as DF } from 'n3';
3+
import { DataFactory as DF, Parser, Store } from 'n3';
54
import path from 'node:path';
5+
import { ODRL } from 'odrl-evaluator';
66
import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil';
77
import { generateCredentials } from '../util/UmaUtil';
88

@@ -222,7 +222,10 @@ describe('An access request server setup', (): void => {
222222
});
223223

224224
it('can add constraints to requests.', async(): Promise<void> => {
225-
const purpose = 'http://example.com/purpose';
225+
const dateTimeLiteral = '"2026-01-01T00:00:00Z"^^http://www.w3.org/2001/XMLSchema#dateTime';
226+
const dateTimeTerm = DF.literal('2026-01-01T00:00:00Z', DF.namedNode('http://www.w3.org/2001/XMLSchema#dateTime'));
227+
const purposeA = 'http://example.com/purpose-a';
228+
const purposeB = 'http://example.com/purpose-b';
226229
let response = await fetch(accessRequestEndpoint, {
227230
method: 'POST',
228231
headers: {
@@ -232,7 +235,10 @@ describe('An access request server setup', (): void => {
232235
body: JSON.stringify({
233236
resource_id: target,
234237
resource_scopes: [ 'http://www.w3.org/ns/odrl/2/create' ],
235-
constraints: [[ 'http://www.w3.org/ns/odrl/2/purpose', 'http://www.w3.org/ns/odrl/2/eq', purpose ]],
238+
constraints: [
239+
[ ODRL.dateTime, ODRL.gt, dateTimeLiteral ],
240+
[ ODRL.purpose, ODRL.isAnyOf, [ purposeA, purposeB ] ]
241+
],
236242
}),
237243
});
238244

@@ -243,7 +249,20 @@ describe('An access request server setup', (): void => {
243249
// Can see the constraints in the request
244250
response = await fetch(requestLocation, { headers: { authorization: `WebID ${encodeURIComponent(owner)}` }});
245251
const requestQuads = new Store(new Parser().parse(await response.text()));
246-
expect(requestQuads.countQuads(null, ODRL.terms.leftOperand, ODRL.terms.purpose, null)).toBe(1);
252+
const constraints = requestQuads.getObjects(null, ODRL.terms.constraint, null);
253+
expect(constraints.length).toBe(2);
254+
expect(requestQuads.countQuads(constraints[0], ODRL.terms.leftOperand, ODRL.dateTime, null)).toBe(1);
255+
expect(requestQuads.countQuads(constraints[0], ODRL.terms.operator, ODRL.gt, null)).toBe(1);
256+
expect(requestQuads.countQuads(constraints[0], ODRL.terms.rightOperand, dateTimeTerm, null)).toBe(1);
257+
expect(requestQuads.countQuads(constraints[1], ODRL.terms.leftOperand, ODRL.terms.purpose, null)).toBe(1);
258+
expect(requestQuads.countQuads(constraints[1], ODRL.terms.operator, ODRL.terms.isAnyOf, null)).toBe(1);
259+
const lists = Object.entries(requestQuads.extractLists());
260+
expect(lists.length).toBe(1);
261+
expect(requestQuads.countQuads(constraints[1], ODRL.terms.rightOperand, DF.blankNode(lists[0][0]), null)).toBe(1);
262+
const list = lists[0][1];
263+
expect(list.length).toBe(2);
264+
expect(list[0].value).toBe(purposeA);
265+
expect(list[1].value).toBe(purposeB);
247266

248267
response = await fetch(requestLocation, {
249268
method: 'PATCH',
@@ -260,5 +279,6 @@ describe('An access request server setup', (): void => {
260279
const policyQuads = new Store(new Parser().parse(await response.text()));
261280
expect(policyQuads.countQuads(null, ODRL.terms.action, 'http://www.w3.org/ns/odrl/2/create', null)).toBe(1);
262281
expect(policyQuads.countQuads(null, ODRL.terms.leftOperand, ODRL.terms.purpose, null)).toBe(1);
282+
expect(policyQuads.countQuads(null, ODRL.terms.leftOperand, ODRL.dateTime, null)).toBe(1);
263283
});
264284
});

0 commit comments

Comments
 (0)