Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions documentation/access-request-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
62 changes: 44 additions & 18 deletions packages/uma/src/controller/AccessRequestController.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { QueryEngine } from '@comunica/query-sparql';
import { Quad } from '@rdfjs/types';
import {
BadRequestHttpError,
ConflictHttpError,
Expand All @@ -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<typeof Permission>;
export type AccessRequest = Type<typeof AccessRequest>;

/**
* Controller for routes concerning access requests
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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<Quad_Object, Quad[]> {
const quads = new Map<Quad_Object, Quad[]>();
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;
}
}
2 changes: 0 additions & 2 deletions packages/uma/src/ucp/storage/FileBackupUCRulesStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,7 +29,6 @@ export class FileBackupUCRulesStorage extends MemoryUCRulesStorage implements In
}

public async initialize(): Promise<void> {
this.logger.info('CALLING INITIALIZE');
if (!this.filePath) {
return;
}
Expand Down
8 changes: 7 additions & 1 deletion packages/uma/src/ucp/util/Vocabularies.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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',
Expand Down
83 changes: 71 additions & 12 deletions packages/uma/test/unit/controller/AccessRequestController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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"^^<http://www.w3.org/2001/XMLSchema#dateTime>
], [
a odrl:Constraint ;
odrl:leftOperand odrl:purpose ;
odrl:operator odrl:eq ;
odrl:rightOperand <http://example.org/purpose>
odrl:operator odrl:isAnyOf ;
odrl:rightOperand ( <http://example.com/purpose-a> <http://example.com/purpose-b> )
] .
`));

Expand Down Expand Up @@ -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<void> => {
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: <https://w3id.org/force/sotw#> .
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
<${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 => {
Expand Down Expand Up @@ -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"^^<http://www.w3.org/2001/XMLSchema#dateTime>
], [
a odrl:Constraint ;
odrl:leftOperand odrl:purpose ;
odrl:operator odrl:eq ;
odrl:rightOperand <http://example.org/purpose>
odrl:operator odrl:isAnyOf ;
odrl:rightOperand ( <http://example.com/purpose-a> <http://example.com/purpose-b> )
] .
`));
expect(ownershipStore.get).toHaveBeenCalledExactlyOnceWith(owner);
Expand Down Expand Up @@ -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"^^<http://www.w3.org/2001/XMLSchema#dateTime>
], [
a odrl:Constraint ;
odrl:leftOperand odrl:purpose ;
odrl:operator odrl:eq ;
odrl:rightOperand <http://example.org/purpose>
odrl:operator odrl:isAnyOf ;
odrl:rightOperand ( <http://example.com/purpose-a> <http://example.com/purpose-b> )
] .
`));
});
Expand Down Expand Up @@ -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: <http://www.w3.org/ns/odrl/2/> .
<${policies[0].value}> a odrl:Agreement ;
Expand All @@ -241,14 +287,27 @@ describe('AccessRequestController', (): void => {
odrl:action <http://example.org/scope2> ;
odrl:assignee <http://example.org/unknown> ;
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: <http://www.w3.org/ns/odrl/2/> .
_:${constraints[0].value} a odrl:Constraint ;
odrl:leftOperand odrl:dateTime ;
odrl:operator odrl:gt ;
odrl:rightOperand "2026-01-01T00:00:00Z"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
`));
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: <http://www.w3.org/ns/odrl/2/> .
_:${constraints[1].value} a odrl:Constraint ;
odrl:leftOperand odrl:purpose ;
odrl:operator odrl:eq ;
odrl:rightOperand <http://example.org/purpose> .
odrl:operator odrl:isAnyOf ;
odrl:rightOperand ( <http://example.com/purpose-a> <http://example.com/purpose-b> ) .
`));
});

Expand Down
32 changes: 26 additions & 6 deletions test/integration/AccessRequests.test.ts
Original file line number Diff line number Diff line change
@@ -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';

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

it('can add constraints to requests.', async(): Promise<void> => {
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: {
Expand All @@ -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 ] ]
],
}),
});

Expand All @@ -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',
Expand All @@ -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);
});
});
Loading