From 597304206100cacddf177c1820a851cf46408fff Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 9 Feb 2026 11:18:20 +0100 Subject: [PATCH] feat: Update access requests to use PAT --- documentation/access-request-management.md | 163 ++++------ .../figures/access_grants_requests_fsm.png | Bin 50825 -> 0 bytes documentation/getting-started.md | 6 + .../uma/config/resources/storage/default.json | 4 + .../uma/config/routes/accessrequests.json | 3 +- packages/uma/config/routes/resources.json | 1 + packages/uma/package.json | 1 + .../src/controller/AccessRequestController.ts | 248 +++++++++++++-- packages/uma/src/controller/BaseController.ts | 80 ++--- .../src/controller/PolicyRequestController.ts | 1 - packages/uma/src/routes/BaseHandler.ts | 22 +- .../uma/src/routes/ResourceRegistration.ts | 16 + packages/uma/src/ucp/util/Vocabularies.ts | 12 + packages/uma/src/util/routeSpecific/delete.ts | 58 +--- packages/uma/src/util/routeSpecific/get.ts | 102 ++---- packages/uma/src/util/routeSpecific/patch.ts | 117 +------ packages/uma/src/util/routeSpecific/post.ts | 55 +--- .../AccessRequestController.test.ts | 297 ++++++++++++++++++ .../unit/routes/ResourceRegistration.test.ts | 9 +- test/integration/AccessRequests.test.ts | 265 ++++++++++++++++ test/integration/Policies.test.ts | 3 +- test/util/ServerUtil.ts | 1 + yarn.lock | 1 + 23 files changed, 973 insertions(+), 492 deletions(-) delete mode 100644 documentation/figures/access_grants_requests_fsm.png create mode 100644 packages/uma/test/unit/controller/AccessRequestController.test.ts create mode 100644 test/integration/AccessRequests.test.ts diff --git a/documentation/access-request-management.md b/documentation/access-request-management.md index a13452b..5feb4b4 100644 --- a/documentation/access-request-management.md +++ b/documentation/access-request-management.md @@ -1,134 +1,85 @@ # Access Request Management -This document describes the *access request administration endpoint*. -It contains the methods to describe how to create, read, update and delete access requests. -Example cURL-requests are provided for ease of use. - -The general flow of access requests and grants looks like this: - -![Access requests and grants flow](./figures/access_grants_requests_fsm.png) +Access requests can be used for users to request access to certain resources. +The Resource Owner (RO) can then decide to grant or deny this access. -The document makes use of these parties and identifiers: +This API is a work in progress, and will probably change in the future. -- **Resource Owner**: `https://pod.example.com/profile/card#me` -- **Authorization Server**: `http://localhost:4000` -- **Resource Server**: `http://localhost:3000/resources` -- **Requesting Party**: `https://example.pod.knows.idlab.ugent.be/profile/card#me` +In the default configuration of the UMA server, the endpoint can be found at `/uma/requests`. -The examples provided below make use of `text/turtle` and `application/sparql-update` messages. -The access request used in the examples below looks like this: +All requests require an **Authorization** header to identify the user performing the request. +The available options are described in +the [getting started documentation](getting-started.md#authenticating-as-resource-owner). -```turtle -@prefix sotw: . -@prefix odrl: . -@prefix ex: . +## Requesting access -ex:request a sotw:EvaluationRequest ; - sotw:requestedTarget ; - sotw:requestedAction odrl:read ; - sotw:requestingParty ; - ex:requestStatus ex:requested . +A user can request access to a resource by performing a POST request to the endpoint. +The body of this request should be a JSON object, +describing the resource and the requested scopes: +```json +{ + "resource_id": "http://example.org/document", + "resource_scopes": [ "http://www.w3.org/ns/odrl/2/read" ] +} ``` -## Supported endpoints - -The current implementation supports the following requests to the `uma/requests` and `/uma/requests/:id` endpoints - -- [**GET**](#reading-access-requests) -- [**POST**](#creating-access-requests) -- [**PATCH**](#managing-access-requests) -- [**DELETE**](#deleting-access-requests) +The `resource_id` field needs to be the identifier of the resource as known by the AS. +At the time of writing this is the same identifier as the one used by the RS. -## Creating access requests +This request will generate a request to allow the user creating this request to perform those scopes. -Create an access request/multiple access requests by sending a **POST** request to `uma/requests`. -Apart from its `Authorization` header, the `Content-Type` header must be set to the RDF serialization format in which the body is written. -The accepted formats are those accepted by the [N3 Parser](https://github.com/rdfjs/N3.js/?tab=readme-ov-file#parsing), represented by the following content types: +### Constraints -- `text/turtle` -- `application/trig` -- `application/n-triples` -- `application/n-quads` -- `text/n3` - -The body is expected to represent a valid ODRL access request. -No sanitization is currently applied. -Upon success, the server responds with **status code 201**. -Bad requests, possibly due to improper access request definition, will respond with **status code 400** (to be implemented) -When the access requested has been validated (to be implemented), but the storage fails, the response will have **status code 500**. +In case the user wants to request access, but with certain constraints, +these can be added in an additional field of the request: +```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/eq", "http://example.org/purpose" ] + ] +} +``` -### Example POST request +## Viewing requests -This example creates an access request `ex:request` for the RP `https://example.pod.knows.idlab.ugent.be/profile/card#me`: +By performing a GET request to the endpoint, a user can see all requests they have created, +and all requests that target a resource they are the owner of. +This way a RO can see if there are still pending requests. -```shell-session -curl --location 'http://localhost:4000/uma/requests' \ ---header 'Authorization: https://example.pod.knows.idlab.ugent.be/profile/card#me' \ ---header 'Content-Type: text/turtle' \ ---data-raw ' +An example request would look as follows: +```turtle @prefix sotw: . @prefix odrl: . -@prefix dcterms: . -@prefix ex: . -@prefix xsd: . - -ex:request a sotw:EvaluationRequest ; - sotw:requestedTarget ; - sotw:requestedAction odrl:write ; - sotw:requestingParty ; - ex:requestStatus ex:requested .' -``` - -## Reading access requests - -To read policies, a single endpoint is currently implemented. -This endpoint currently returns the list of access requests where the WebID provided in the `Authorization` header is marked as the requesting party. -An example request to this endpoint is: - -```shell-session -curl -X GET --location 'http://localhost:4000/uma/requests' \ ---header 'Authorization: https://example.pod.knows.idlab.ugent.be/profile/card#me' -``` - -## Managing access requests - -The RO can accept or deny the access requests, which is done by updating the status triple. -Updating policies can be done through a **PATCH** request. -The body must hold the content type `application/json`. -The example below shows how to update the access request's status from `requested` to `accepted`: - -```shell-session -curl -X PATCH --location 'http://localhost:4000/uma/requests/http%3A%2F%2Fexample.org%2Frequest' \ ---header 'Authorization: https://pod.example.com/profile/card#me' \ ---header 'Content-Type: application/json' \ ---data-raw '{ "status": "accepted" }' # can be changed to `denied` too. + a sotw:EvaluationRequest ; + sotw:requestedTarget ; + sotw:requestedAction odrl:read ; + sotw:requestingParty ; + sotw:requestStatus sotw:requested . ``` -Once an access request's status has been changed from `requested` to `accepted`, the backend will automatically create a new policy including the correct rules to allow the RP access to the resource. -After this, the RP will be able to use the resource following the UMA protocol. - -## Deleting access requests +There are no notifications, so the RO has to perform this request to discover a new request was made. +Similarly, a user has to perform this request to find out if the request was granted. -Currently, access requests cannot be deleted. The reason being that it from a governance decision a decision need to be made who is allowed to delete it. +## Granting or rejecting requests -Is it the requesting party? Or is it the resource owner? -From the start. It makes more sense for the RP. However, if the RO made a decision, it does not make sense that the RP can remove this. +A RO can accept or deny a request by performing a PATCH request targeting the URL of a single request. +This URL is formed by appending the URL-encoded string of the request identifier to the endpoint. +For example, in the above case this would be `/uma/requests/http%3A%2F%2Fexample.org%2Frequest`. +The body of the PATCH request needs to be either `{ "status": "accepted" }` or `{ "status": "denied" }`. +In case the RO accepts the request, +a new policy will automatically be generated to grant the requested scopes to the requestee. -## Important Notes +## Implementation Notes -### Undefined behavior for **PATCH/DELETE** request +* Request can not be deleted, as it is not yet clear who should be responsible for this. +* Requests can not be modified once accepted or denied. +* The generated policies can be found and modified through the policy API as usual. -Upon the first **PATCH** request which changes an access request's status from `requested` to `accepted` a new policy and permission are created. -When a new **PATCH** request would change the status to denied, nothing is currently done with the policy. -Even when the access request would be deleted, the backend currently doesn't do anything to the policy. -This is undefined behavior and should be treated as such. -This works in both directions: if the policy is changed in some way, nothing is changed to the access request either. -## Future work -### Discrepancies between [earlier descriptions](https://github.com/bramcomyn/loama/blob/feat/odrl/documentation/access_grants_vs_dsnp.md) and this implementation - -This file counts as authorative resource for the access request management. -Other documentation should point to this file as the latest and correct documentation. +This document describes the *access request administration endpoint*. +It contains the methods to describe how to create, read, update and delete access requests. diff --git a/documentation/figures/access_grants_requests_fsm.png b/documentation/figures/access_grants_requests_fsm.png deleted file mode 100644 index bcf2b6efc45eb1c2f84c8bc49668cfffe1350905..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50825 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 Dz|UD2 diff --git a/documentation/getting-started.md b/documentation/getting-started.md index cc0483d..14d8576 100644 --- a/documentation/getting-started.md +++ b/documentation/getting-started.md @@ -49,6 +49,7 @@ so some information might change depending on which version and branch you're us * [Adding or changing policies](#adding-or-changing-policies) * [Policy backups](#policy-backups) * [Data aggregation](#data-aggregation) + * [Access requests](#access-requests) Table of contents generated with markdown-toc @@ -466,3 +467,8 @@ When restarting the server, the contents of that file will be read to initialize ## Data aggregation The UMA server implements the [Aggregator Specification](https://spec.knows.idlab.ugent.be/aggregator-protocol/latest/). + +## Access Requests + +A user can request access to a resource through access requests, +more information on this can be found in the [relevant documentation](./access-request-management.md). diff --git a/packages/uma/config/resources/storage/default.json b/packages/uma/config/resources/storage/default.json index 1b0ed15..ef0dde7 100644 --- a/packages/uma/config/resources/storage/default.json +++ b/packages/uma/config/resources/storage/default.json @@ -10,6 +10,10 @@ { "@id": "urn:uma:default:DerivationStore", "@type": "MemoryMapStorage" + }, + { + "@id": "urn:uma:default:OwnershipStore", + "@type": "MemoryMapStorage" } ] } diff --git a/packages/uma/config/routes/accessrequests.json b/packages/uma/config/routes/accessrequests.json index 1d10440..333e4fc 100644 --- a/packages/uma/config/routes/accessrequests.json +++ b/packages/uma/config/routes/accessrequests.json @@ -6,7 +6,8 @@ { "@id": "urn:uma:default:AccessRequestController", "@type": "AccessRequestController", - "store": { "@id": "urn:uma:default:RulesStorage" } + "store": { "@id": "urn:uma:default:RulesStorage" }, + "ownershipStore": { "@id": "urn:uma:default:OwnershipStore" } }, { "@id": "urn:uma:default:AccessRequestHandler", diff --git a/packages/uma/config/routes/resources.json b/packages/uma/config/routes/resources.json index 29044cf..51feb14 100644 --- a/packages/uma/config/routes/resources.json +++ b/packages/uma/config/routes/resources.json @@ -8,6 +8,7 @@ "@type": "ResourceRegistrationRequestHandler", "derivationStore": { "@id": "urn:uma:default:DerivationStore" }, "registrationStore": { "@id": "urn:uma:default:ResourceRegistrationStore" }, + "ownershipStore": { "@id": "urn:uma:default:OwnershipStore" }, "policies": { "@id": "urn:uma:default:RulesStorage" }, "validator": { "@id": "urn:uma:default:RequestValidator" } }, diff --git a/packages/uma/package.json b/packages/uma/package.json index 51241ac..7b52bdd 100644 --- a/packages/uma/package.json +++ b/packages/uma/package.json @@ -74,6 +74,7 @@ "ms": "^2.1.3", "n3": "^1.17.2", "odrl-evaluator": "^0.5.0", + "rdf-string": "^2.0.1", "rdf-vocabulary": "^1.0.1", "uri-template-lite": "^23.4.0", "winston": "^3.11.0", diff --git a/packages/uma/src/controller/AccessRequestController.ts b/packages/uma/src/controller/AccessRequestController.ts index 0848da3..b1d6bac 100644 --- a/packages/uma/src/controller/AccessRequestController.ts +++ b/packages/uma/src/controller/AccessRequestController.ts @@ -1,44 +1,244 @@ -import { UCRulesStorage } from "../ucp/storage/UCRulesStorage"; -import { BaseController } from "./BaseController"; +import { QueryEngine } from '@comunica/query-sparql'; +import { Quad } from '@rdfjs/types'; import { - deleteAccessRequest, - getAccessRequest, - getAccessRequests, - patchAccessRequest, - postAccessRequest -} from "../util/routeSpecific"; + BadRequestHttpError, + ConflictHttpError, + createErrorMessage, + ForbiddenHttpError, + 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 { 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 { BaseController } from './BaseController'; + +export const AccessRequest = { + resource_id: string, + resource_scopes: array(string), + constraints: $(array(tuple(string, string, string))), +}; + +export type AccessRequest = Type; /** * Controller for routes concerning access requests */ export class AccessRequestController extends BaseController { + protected readonly logger = getLoggerFor(this); + + protected readonly queryEngine = new QueryEngine(); + constructor( - store: UCRulesStorage, + protected readonly store: UCRulesStorage, + protected readonly ownershipStore: KeyValueStorage, ) { super( store, - 'Already existing requests found', - postAccessRequest, - deleteAccessRequest, - getAccessRequests, - getAccessRequest, - patchAccessRequest, + // TODO: is this horrible? yes, but this entire architecture needs a rework anyway + null as any, + null as any, + null as any, + null as any, + null as any, ); + this.sanitizeGets = this.getAccessRequests.bind(this); + this.sanitizeGet = this.getAccessRequest.bind(this); + this.sanitizePatch = this.patchAccessRequest.bind(this); } - /** - * Deletes are not allowed on access requests. - * - * @param entityID ID pointing to the policy or access request - * @param clientID ID of the resource owner (RO) or requesting party (RP) making the deletion - * @returns a status code: 403 - */ public async deleteEntity(entityID: string, clientID: string): Promise<{ status: number }> { - return { status: 403 }; + // Not defined who should be allowed to delete requests + // TODO: perhaps might make sense to allow deletion by requester when status is still "requested" + throw new ForbiddenHttpError(); } public async putEntity(data: string, entityID: string, clientID: string): Promise<{ status: number }> { - return { status: 403 }; + // Changing requests is not allowed + throw new ForbiddenHttpError(); + } + + public async addEntity(data: string, clientID: string): Promise<{ status: number, id: string }> { + let json: AccessRequest; + // TODO: assuming input is JSON here for now + try { + json = JSON.parse(data); + reType(json, AccessRequest); + } catch (e) { + this.logger.warn(`Syntax error: ${createErrorMessage(e)}, ${data}`); + throw new BadRequestHttpError(`Request has bad syntax: ${createErrorMessage(e)}`); + } + + if (json.resource_scopes.length === 0) { + throw new BadRequestHttpError('Missing scopes'); + } + + const subject = DF.namedNode(`http://example.org/${randomUUID()}`); + const request = new Store(); + request.addQuads([ + DF.quad(subject, RDF.terms.type, SOTW.terms.EvaluationRequest), + // TODO: should verify this resource exists (and also remove requests if resources no longer exist) + DF.quad(subject, SOTW.terms.requestedTarget, DF.namedNode(json.resource_id)), + DF.quad(subject, SOTW.terms.requestingParty, DF.namedNode(clientID)), + DF.quad(subject, SOTW.terms.requestStatus, SOTW.terms.requested), + ...json.resource_scopes.map((scope) => DF.quad(subject, SOTW.terms.requestedAction, DF.namedNode(scope))), + ]); + 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]), + ]); + } + + await this.store.addRule(request); + + this.logger.info(`Created request ${subject.value} for ${clientID} with values ${JSON.stringify(data)}`); + + return { status: 201, id: subject.value }; + } + + // Find access requests where clientId is the requester or the owner of the targeted resource + protected async getAccessRequests(store: Store, clientId: string): Promise { + const result = new Store(); + + // Find requested queries + const requestedSparql = ` + SELECT DISTINCT ?req + WHERE { ?req <${clientId}> }`; + const requestedStream = await this.queryEngine.queryBindings(requestedSparql, { sources: [store] }); + for await (const binding of requestedStream) { + result.addAll(this.getRequestQuads(store, binding.get('req') as Quad_Subject)); + } + + // Find queries over owned resources + const resources = await this.ownershipStore.get(clientId); + if (resources && resources.length > 0) { + // TODO: assuming resource ID is an IRI + const ownedSparql = ` + SELECT DISTINCT ?req + WHERE { + VALUES (?resource) { + ${resources.map((res) => `(<${res}>)`).join('\n')} + } + ?req ?resource . + }`; + const requestedStream = await this.queryEngine.queryBindings(ownedSparql, { sources: [store] }); + for await (const binding of requestedStream) { + result.addAll(this.getRequestQuads(store, binding.get('req') as Quad_Subject)); + } + } + + return result; } + protected async getAccessRequest(store: Store, requestID: string, clientID: string): Promise { + const requestNode = DF.namedNode(requestID); + const requesters = store.getObjects(requestNode, SOTW.terms.requestingParty, null); + if (requesters.length === 0) { + throw new NotFoundHttpError(); + } + + let allowedToSee = requesters.some((requester) => requester.value === clientID); + if (!allowedToSee) { + // Maybe the client is the owner instead of the requester + const targets = store.getObjects(requestNode, SOTW.terms.requestedTarget, null); + if (targets.length !== 1) { + throw new InternalServerError(`Unexpected amount of targets, expected 1 but got ${targets.length}`); + } + const ownedResources = await this.ownershipStore.get(clientID) ?? []; + allowedToSee = ownedResources.includes(targets[0].value); + } + + if (!allowedToSee) { + throw new ForbiddenHttpError(); + } + + return new Store(this.getRequestQuads(store, requestNode)); + } + + protected async patchAccessRequest(store: Store, requestID: string, clientID: string, patchInformation: string): + Promise { + const requestNode = DF.namedNode(requestID); + const targets = store.getObjects(requestNode, SOTW.terms.requestedTarget, null); + if (targets.length === 0) { + throw new NotFoundHttpError(); + } + + if (patchInformation !== 'accepted' && patchInformation !== 'denied') { + throw new BadRequestHttpError('Status needs to be "accepted" or "denied"'); + } + + const ownedResources = await this.ownershipStore.get(clientID); + if (!ownedResources?.includes(targets[0].value)) { + throw new ForbiddenHttpError(); + } + + const statuses = store.getObjects(requestNode, SOTW.terms.requestStatus, null); + if (statuses.length !== 1) { + throw new InternalServerError(`Expected 1 status for ${requestID} but found ${statuses.length}`); + } + if (!statuses[0].equals(SOTW.terms.requested)) { + throw new ConflictHttpError(`Request was already resolved`); + } + + const actions = store.getObjects(requestNode, SOTW.terms.requestedAction, null); + const parties = store.getObjects(requestNode, SOTW.terms.requestingParty, null); + + if (actions.length === 0 || parties.length !== 1) { + throw new InternalServerError(`Invalid actions (${actions.map(termToString)}) or parties (${ + parties.map(termToString)})`); + } + + store.removeQuad(DF.quad(requestNode, SOTW.terms.requestStatus, SOTW.terms.requested)); + store.addQuad(DF.quad(requestNode, SOTW.terms.requestStatus, SOTW.terms[patchInformation])); + + this.logger.info(`Updated status of request ${requestNode.value} to ${patchInformation}`) + if (patchInformation === 'accepted') { + const policyNode = DF.namedNode(`http://example.org/${randomUUID()}`); + const permissionNode = DF.namedNode(policyNode.value + '-permission'); + store.addQuads([ + DF.quad(policyNode, RDF.terms.type, ODRL.terms.Agreement), + DF.quad(policyNode, ODRL.terms.uid, policyNode), + DF.quad(policyNode, ODRL.terms.permission, permissionNode), + DF.quad(permissionNode, RDF.terms.type, ODRL.terms.Permission), + ...actions.map((action) => DF.quad(permissionNode, ODRL.terms.action, action)), + 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) => [ + DF.quad(permissionNode, ODRL.terms.constraint, constraint), + ...store.getQuads(constraint, null, null, null), + ]), + ]); + this.logger.info( + `Created policy ${policyNode.value} in response to request ${requestNode.value} being accepted`); + } + } + + /** + * Returns all the quads related to the given request. + */ + 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)); + } + return quads; + } } diff --git a/packages/uma/src/controller/BaseController.ts b/packages/uma/src/controller/BaseController.ts index 9cd99d4..3dcbbf3 100644 --- a/packages/uma/src/controller/BaseController.ts +++ b/packages/uma/src/controller/BaseController.ts @@ -1,7 +1,9 @@ +import { ConflictHttpError } from '@solid/community-server'; import { UCRulesStorage } from "../ucp/storage/UCRulesStorage"; import { getLoggerFor } from 'global-logger-factory'; import { Parser, Store } from 'n3'; import { writeStore } from "../util/ConvertUtil"; +import { HttpHandlerResponse } from '../util/http/models/HttpHandler'; import { noAlreadyDefinedSubjects } from "../util/routeSpecific/sanitizeUtil"; /** @@ -10,16 +12,15 @@ import { noAlreadyDefinedSubjects } from "../util/routeSpecific/sanitizeUtil"; */ export abstract class BaseController { - private readonly logger = getLoggerFor(this); + protected readonly logger = getLoggerFor(this); constructor( protected readonly store: UCRulesStorage, - protected readonly conflictMessage: string, - protected readonly sanitizePost: (store: Store, clientID: string) => Promise, - protected readonly sanitizeDelete: (store: Store, entityID: string, clientID: string) => Promise, - protected readonly sanitizeGets: (store: Store, clientID: string) => Promise, - protected readonly sanitizeGet: (store: Store, entityID: string, clientID: string) => Promise, - protected readonly sanitizePatch: (store: Store, entityID: string, clientID: string, patchInformation: string) => Promise + protected sanitizePost: (store: Store, clientID: string) => Promise<{ result: Store, id: string }>, + protected sanitizeDelete: (store: Store, entityID: string, clientID: string) => Promise, + protected sanitizeGets: (store: Store, clientID: string) => Promise, + protected sanitizeGet: (store: Store, entityID: string, clientID: string) => Promise, + protected sanitizePatch: (store: Store, entityID: string, clientID: string, patchInformation: string) => Promise ) { } /** @@ -29,25 +30,18 @@ export abstract class BaseController { * @returns results serialized in Turtle and status code 200, * or an empty body with status 404 if nothing was found */ - private async get(sanitizeGet: Function): Promise<{ message: string, status: number }> { - try { - const store = await sanitizeGet(); - - const message = store.size > 0 - ? await writeStore(store) - : ''; - - const status = 200; - return { message, status }; - } catch (_e) { - return { message: '', status: 200 } - } + private async get(sanitizeGet: () => Promise): Promise<{ message: string, status: number }> { + const store = await sanitizeGet(); + + const message = store.size > 0 ? await writeStore(store) : ''; + const status = 200; + return { message, status }; } /** * Retrieve all policies (including rules) or all access requests belonging to a given `clientID`. * - * @param clientID ID of the resource owner (RO) or requesting party (RP) + * @param clientID ID of the requesting party (RP) * @returns a Turtle-serialized store of all policies or access requests, * and an HTTP status code indicating success (200) or not found (404) */ @@ -59,7 +53,7 @@ export abstract class BaseController { * Retrieve a single policy (including its rules) or access request identified by `entityID` for a given `clientID`. * * @param entityID ID pointing to the policy or access request - * @param clientID ID pointing to the resource owner (RO) or requesting party (RP) + * @param clientID ID pointing to the requesting party (RP) * @returns a Turtle-serialized representation of the policy/access request and HTTP status code (200), * or an empty body with status 404 if not found */ @@ -72,31 +66,28 @@ export abstract class BaseController { * Ensures no duplicate subjects already exist in the store. * * @param data RDF data in Turtle/N3 format representing the new policy or access request - * @param clientID ID of the resource owner (RO) or requesting party (RP) creating the entity + * @param clientID ID of the requesting party (RP) creating the entity * @returns a status code: * - 201 if creation was successful * - 409 if a conflict occurred (duplicate subject) */ - public async addEntity(data: string, clientID: string): Promise<{ status: number, message:string }> { + public async addEntity(data: string, clientID: string): + Promise<{ status: number, message?: string, id: string }> { const store = new Store(new Parser().parse(data)); - try { - const sanitizedStore = await this.sanitizePost(store, clientID); - if (noAlreadyDefinedSubjects(await this.store.getStore(), sanitizedStore)) - this.store.addRule(sanitizedStore); - else return { status: 409, message: '' }; // conflict - } catch (e) { - return { status: e.statusCode || 500, message: e.message }; // the message of this error will contain the reason this query failed - } + const { result, id } = await this.sanitizePost(store, clientID); + if (noAlreadyDefinedSubjects(await this.store.getStore(), result)) + this.store.addRule(result); + else throw new ConflictHttpError(); - return { status: 201, message: '' }; // success + return { status: 201, id }; // success } /** * Delete a single policy (including rules) or access request identified by `entityID` for a given `clientID`. * * @param entityID ID pointing to the policy or access request - * @param clientID ID of the resource owner (RO) or requesting party (RP) making the deletion + * @param clientID ID of the requesting party (RP) making the deletion * @returns a status code: * - 204 if deletion was successful */ @@ -116,13 +107,13 @@ export abstract class BaseController { * * @param entityID ID pointing to the policy or access request * @param patchInformation information describing the patch to be applied (query or JSON, but content type of request must match this.contentType) - * @param clientID ID of the resource owner (RO) or requesting party (RP) making the patch + * @param clientID ID of the requesting party (RP) making the patch * @param isolate whether to isolate the entity's quads during patching (defaults to true) * @returns a status code: * - 204 if patching was successful */ - public async patchEntity(entityID: string, patchInformation: string, clientID: string, isolate: boolean = true): Promise<{ status: number, message: string }> { - let response = { status: 204, message: '' }; + public async patchEntity(entityID: string, patchInformation: string, clientID: string, isolate: boolean = true): Promise> { + let response: HttpHandlerResponse = { status: 204, body: '' }; let filteredStore = new Store(await this.store.getStore()); let omitStore: Store; @@ -132,11 +123,7 @@ export abstract class BaseController { omitStore.removeQuads([ ...filteredStore]); } - try { - await this.sanitizePatch(filteredStore, entityID, clientID, patchInformation); - } catch (e) { - response = { status: e.status || 500, message: e.message }; - } + await this.sanitizePatch(filteredStore, entityID, clientID, patchInformation); if (isolate) { // isolate all information about the store again, because queries could insert information @@ -151,6 +138,7 @@ export abstract class BaseController { const originalStore = await this.store.getStore(); const remove = originalStore.difference(filteredStore); const add = filteredStore.difference(originalStore); + if (remove.size > 0) { await this.store.removeData(remove as Store); } @@ -166,9 +154,9 @@ export abstract class BaseController { * * Currently, this is only implemented for policies. * - * @param data RDF data in Turtle/N3 format representing a policy or acccess request + * @param data RDF data in Turtle/N3 format representing a policy or access request * @param entityID ID pointing to the policy or access request - * @param clientID ID pointing to the resource owner (RO) or requesting party making the put + * @param clientID ID pointing to requesting party making the put * @returns a status code: * - 204 if put was successful */ @@ -179,11 +167,11 @@ export abstract class BaseController { // parse the entity through a POST request and check the results const store = new Store(new Parser().parse(data)); - const sanitizedStore = await this.sanitizePost(store, clientID); + const { result } = await this.sanitizePost(store, clientID); // delete the old rule and insert the new await this.deleteEntity(entityID, clientID); - await this.store.addRule(sanitizedStore); + await this.store.addRule(result); return { status: 204 }; } } diff --git a/packages/uma/src/controller/PolicyRequestController.ts b/packages/uma/src/controller/PolicyRequestController.ts index a6a0454..aea8dd2 100644 --- a/packages/uma/src/controller/PolicyRequestController.ts +++ b/packages/uma/src/controller/PolicyRequestController.ts @@ -17,7 +17,6 @@ export class PolicyController extends BaseController { ) { super( store, - "Already existing policies found", postPolicy, deletePolicy, getPolicies, diff --git a/packages/uma/src/routes/BaseHandler.ts b/packages/uma/src/routes/BaseHandler.ts index 0f95d74..d312bff 100644 --- a/packages/uma/src/routes/BaseHandler.ts +++ b/packages/uma/src/routes/BaseHandler.ts @@ -1,8 +1,7 @@ -import { BadRequestHttpError, ForbiddenHttpError, MethodNotAllowedHttpError } from '@solid/community-server'; +import { BadRequestHttpError, ForbiddenHttpError, joinUrl, MethodNotAllowedHttpError } from '@solid/community-server'; import { getLoggerFor } from 'global-logger-factory'; import { BaseController } from '../controller/BaseController'; import { WEBID } from '../credentials/Claims'; -import { ClaimSet } from '../credentials/ClaimSet'; import { CredentialParser } from '../credentials/CredentialParser'; import { Verifier } from '../credentials/verify/Verifier'; import { @@ -99,7 +98,7 @@ export class BaseHandler extends HttpHandler { * Retrieve a single policy (including its rules) or a single access request identified by `entityID`. * * @param entityID ID pointing to the policy or access request - * @param clientID ID pointing to the resource owner (RO) or requesting party (RP) + * @param clientID ID pointing to the requesting party (RP) * @returns a response with status (200 if found, 404 if not) and Turtle body containing the entity */ private async handleSingleGet(entityID: string, clientID: string): Promise> { @@ -117,12 +116,12 @@ export class BaseHandler extends HttpHandler { * * @param request HttpHandlerRequest containing the PATCH body * @param entityID ID of the policy or access request to patch - * @param clientID ID pointing to the resource owner (RO) or requesting party (RP) + * @param clientID ID pointing to the requesting party (RP) * @returns a response with status code 204 if successful, or error status otherwise * @throws BadRequestHttpError if request body is missing or invalid */ - private async handlePatch(request: HttpHandlerRequest, entityID: string, clientID: string): Promise> { - let response = { status: 204, message: '' }; + private async handlePatch(request: HttpHandlerRequest, entityID: string, clientID: string): Promise> { + let response: HttpHandlerResponse = { status: 204, body: '' }; if (!request.body) throw new BadRequestHttpError(); if (this.patchContentType === 'application/json') { @@ -138,7 +137,7 @@ export class BaseHandler extends HttpHandler { * Rewrite a single policy (including rules) or access request identified by `entityID`. * * @param entityID ID pointing to the policy or access request - * @param clientID ID pointing to the resource owner (RO) or requesting party (RP) + * @param clientID ID pointing to the requesting party (RP) * @returns a response with status 204 upon success */ private async handlePut(request: HttpHandlerRequest, entityID: string, clientID: string): Promise> { @@ -154,7 +153,7 @@ export class BaseHandler extends HttpHandler { * Remove a single policy (including rules) or access request identified by `entityID`. * * @param entityID ID pointing to the policy or access request - * @param clientID ID pointing to the resource owner (RO) or requesting party (RP) + * @param clientID ID pointing to the requesting party (RP) * @returns a response with status 204 if deletion was successful */ private async handleDelete(entityID: string, clientID: string): Promise> { @@ -168,7 +167,7 @@ export class BaseHandler extends HttpHandler { /** * Retrieve all policies (including rules) or all access requests for a given `clientID`. * - * @param clientID ID pointing to the resource owner (RO) or requesting party (RP) + * @param clientID ID pointing to the requesting party (RP) * @returns a response with status (200 if found, 404 if not) and Turtle body containing all entities */ private async handleGet(clientID: string): Promise> { @@ -184,16 +183,17 @@ export class BaseHandler extends HttpHandler { * Create a new policy (with at least one rule) or a new access request for a given `clientID`. * * @param request HttpHandlerRequest containing RDF body representing the entity - * @param clientID ID pointing to the resource owner (RO) or requesting party (RP) + * @param clientID ID pointing to the requesting party (RP) * @returns a response with status code 201 if successful, 409 if conflict occurred, or error otherwise * @throws BadRequestHttpError if request body is missing */ private async handlePost(request: HttpHandlerRequest, clientID: string): Promise> { if (!request.body) throw new BadRequestHttpError(); - const { status, message } = await this.controller.addEntity(request.body.toString(), clientID); + const { status, message, id } = await this.controller.addEntity(request.body.toString(), clientID); return { status: status, + headers: { location: joinUrl(request.url.href, id) }, body: message }; } diff --git a/packages/uma/src/routes/ResourceRegistration.ts b/packages/uma/src/routes/ResourceRegistration.ts index 47f1bf5..75e1054 100644 --- a/packages/uma/src/routes/ResourceRegistration.ts +++ b/packages/uma/src/routes/ResourceRegistration.ts @@ -43,12 +43,14 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { /** * @param derivationStore - Key/value store linking derivation_resource_ids to their issuer. * @param registrationStore - Key/value store containing the {@link ResourceDescription}s. + * @param ownershipStore - Key/value store that links owners to their resources. * @param policies - Policy store to contain the asset relation triples. * @param validator - Validates that the request is valid. */ constructor( protected readonly derivationStore: KeyValueStorage, protected readonly registrationStore: RegistrationStore, + protected readonly ownershipStore: KeyValueStorage, protected readonly policies: UCRulesStorage, protected readonly validator: RequestValidator, ) { @@ -93,6 +95,10 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { // Set the resource metadata await this.setResourceMetadata(resource, body, owner); + const ownedResources = await this.ownershipStore.get(owner) ?? []; + ownedResources.push(resource); + await this.ownershipStore.set(owner, ownedResources); + return ({ status: 201, headers: { location: `${joinUrl(request.url.href, encodeURIComponent(resource))}` }, @@ -153,6 +159,16 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { await this.registrationStore.delete(parameters.id); this.logger.info(`Deleted resource ${parameters.id}.`); + const ownedResources = await this.ownershipStore.get(owner) ?? []; + const idx = ownedResources.indexOf(parameters.id); + if (idx >= 0) { + ownedResources.splice(idx, 1); + if (ownedResources.length === 0) { + await this.ownershipStore.delete(owner); + } else { + await this.ownershipStore.set(owner, ownedResources); + } + } return ({ status: 204 }); } diff --git a/packages/uma/src/ucp/util/Vocabularies.ts b/packages/uma/src/ucp/util/Vocabularies.ts index c723a5e..d5ff690 100644 --- a/packages/uma/src/ucp/util/Vocabularies.ts +++ b/packages/uma/src/ucp/util/Vocabularies.ts @@ -146,6 +146,18 @@ export const OWL = createVocabulary( 'inverseOf', ); +export const SOTW = createVocabulary( + 'https://w3id.org/force/sotw#', + 'EvaluationRequest', + 'accepted', + 'denied', + 'requested', + 'requestedAction', + 'requestedTarget', + 'requestingParty', + 'requestStatus', +); + export const UMA_SCOPES = createVocabulary( 'urn:knows:uma:scopes:', 'derivation-creation', diff --git a/packages/uma/src/util/routeSpecific/delete.ts b/packages/uma/src/util/routeSpecific/delete.ts index 94e7fa0..ec78f13 100644 --- a/packages/uma/src/util/routeSpecific/delete.ts +++ b/packages/uma/src/util/routeSpecific/delete.ts @@ -3,7 +3,7 @@ import {queryEngine} from './index'; /** * Executes a DELETE query against the given store. - * + * * @param store store containing the data to be modified * @param query DELETE query string to be executed * @returns a promise resolving when the deletion is completed @@ -17,10 +17,10 @@ const executeDelete = async ( /** * Build a query that deletes a policy and its associated permissions. - * + * * The deletion only occurs if the policy has the given `policyID` and is assigned * to the provided `clientID`. - * + * * @param policyID ID of the policy to delete * @param resourceOwner ID of the resource owner (assigner) who owns the policy * @returns a DELETE query string @@ -49,7 +49,7 @@ const buildPolicyDeletionQuery = (policyID: string, resourceOwner: string) => ` /** * Delete a policy (including its associated permissions) from the store. - * + * * @param store store containing the policies * @param policyID ID of the policy to delete * @param resourceOwner ID of the resource owner (assigner) responsible for the policy @@ -57,53 +57,3 @@ const buildPolicyDeletionQuery = (policyID: string, resourceOwner: string) => ` */ export const deletePolicy = (store: Store, policyID: string, resourceOwner: string) => executeDelete(store, buildPolicyDeletionQuery(policyID, resourceOwner)); - -/** - * Build a query that deletes an access request and its related triples. - * - * The deletion is permitted if either: - * - The request has the given `requestID` and `clientID` is the requesting party, OR - * - The request targets a resource assigned to `clientID` via an ODRL agreement. - * - * @param requestID ID of the access request to delete - * @param requestingPartyOrResourceowner ID of the requesting party or resource owner - * @returns a DELETE query string - */ -const buildAccessRequestDeletionQuery = (requestID: string, requestingPartyOrResourceowner: string) => ` - PREFIX sotw: - PREFIX odrl: - - DELETE { - <${requestID}> ?p ?o - } WHERE { - <${requestID}> ?p ?o . - { - <${requestID}> sotw:requestingParty <${requestingPartyOrResourceowner}> . - } - UNION - { - <${requestID}> sotw:requestedTarget ?target . - ?pol a odrl:Agreement ; - odrl:permission ?per . - ?per odrl:target ?target ; - odrl:assigner <${requestingPartyOrResourceowner}> . - } - } -`; - -/** - * Delete an access request (including all its triples) from the store. - * - * Deletion is allowed if: - * - The given `clientID` is the requesting party, OR - * - The `clientID` is the assigner of a policy targeting the same resource as the request. - * - * @param store store containing the requests - * @param requestID ID of the request to delete - * @param requestingPartyOrResourceOwner ID of the requesting party or resource owner - * @returns a promise resolving when deletion is completed - */ -export const deleteAccessRequest = async (store: Store, requestID: string, requestingPartyOrResourceOwner: string): Promise => { - await executeDelete(store, buildAccessRequestDeletionQuery(requestID, requestingPartyOrResourceOwner)); - -} diff --git a/packages/uma/src/util/routeSpecific/get.ts b/packages/uma/src/util/routeSpecific/get.ts index a940482..00f4ff4 100644 --- a/packages/uma/src/util/routeSpecific/get.ts +++ b/packages/uma/src/util/routeSpecific/get.ts @@ -1,4 +1,6 @@ -import { Store } from "n3"; +import { Quad } from '@rdfjs/types'; +import { DataFactory as DF, Quad_Subject, Store } from 'n3'; +import { ODRL } from 'odrl-evaluator'; import {queryEngine} from './index'; /** @@ -34,7 +36,7 @@ const executeGet = async ( break; } - subStore.addQuads(store.getQuads(term, null, null, null)); + subStore.addQuads(permissionToQuads(store, term)); } if (valid) results.push(subStore) @@ -116,87 +118,21 @@ const buildPoliciesRetrievalQuery = (resourceOwner: string) => ` * @returns a store containing all policies and their permissions */ export const getPolicies = (store: Store, resourceOwner: string) => - executeGet(store, buildPoliciesRetrievalQuery(resourceOwner), ['policy', 'perm']); - -// ! There is not necessarily a link between resource owner and resource through a policy -// ! Currently, only the requests where the client is requesting party will be given, -// ! for requested targets that aren't included in some policy already. - -/** - * Build a query to retrieve a single request, - * provided that the client is either the requesting party - * or the assigner of a policy targeting the same resource. - * - * @param requestID identifier of the request - * @param requestingPartyOrResourceOwner identifier of the client - * @returns a query string - */ -const buildAccessRequestRetrievalQuery = (requestID: string, requestingPartyOrResourceOwner: string) => ` - PREFIX sotw: - PREFIX odrl: - - SELECT DISTINCT ?req - WHERE { - { - <${requestID}> sotw:requestingParty <${requestingPartyOrResourceOwner}> . - } - UNION - { - <${requestID}> sotw:requestedTarget ?target . - ?pol a odrl:Agreement ; - odrl:permission ?per . - ?per odrl:target ?target ; - odrl:assigner <${requestingPartyOrResourceOwner}> . - } + executeGet(store, buildPoliciesRetrievalQuery(resourceOwner), ['perm']); + +// TODO: slight improvement over existing solution so constraints get returned but definitely not ideal yet +function permissionToQuads(store: Store, permission: Quad_Subject): Quad[] { + const result: Quad[] = []; + const policies = store.getSubjects(ODRL.terms.permission, permission, null); + for (const policy of policies) { + result.push(...store.getQuads(policy, null, null, null)); } -`; + result.push(...store.getQuads(permission, null, null, null)); -/** - * Retrieve a single request by ID, - * if the client is the requesting party or assigner of the target. - * - * @param store the source store - * @param accessRequestID identifier of the request - * @param requestingPartyOrResourceOwner identifier of the client - * @returns a store containing the request - */ -export const getAccessRequest = (store: Store, accessRequestID: string, requestingPartyOrResourceOwner: string) => - executeGet(store, buildAccessRequestRetrievalQuery(accessRequestID, requestingPartyOrResourceOwner), ['req']); + // Constraints + result.push( + ...store.getObjects(permission, ODRL.terms.constraint, null).flatMap((constraint) => + store.getQuads(constraint, null, null, null))); -/** - * Build a query to retrieve all requests for a client, - * either as requesting party or as assigner of the requested target. - * - * @param requestingPartyOrResourceOwner identifier of the client - * @returns a query string - */ -const buildAccessRequestsRetrievalQuery = (requestingPartyOrResourceOwner: string) => ` - PREFIX sotw: - PREFIX odrl: - - SELECT DISTINCT ?req - WHERE { - { - ?req sotw:requestingParty <${requestingPartyOrResourceOwner}> . - } - UNION - { - ?req sotw:requestedTarget ?target . - ?pol a odrl:Agreement ; - odrl:permission ?per . - ?per odrl:target ?target ; - odrl:assigner <${requestingPartyOrResourceOwner}> . - } - } -`; - -/** - * Retrieve all requests for a client, - * either as requesting party or as assigner of the requested targets. - * - * @param store the source store - * @param requestingPartyOrResourceOwner identifier of the client - * @returns a store containing the requests - */ -export const getAccessRequests = (store: Store, requestingPartyOrResourceOwner: string) => - executeGet(store, buildAccessRequestsRetrievalQuery(requestingPartyOrResourceOwner), ['req']); + return result; +} diff --git a/packages/uma/src/util/routeSpecific/patch.ts b/packages/uma/src/util/routeSpecific/patch.ts index bfd9de1..ebf670f 100644 --- a/packages/uma/src/util/routeSpecific/patch.ts +++ b/packages/uma/src/util/routeSpecific/patch.ts @@ -1,4 +1,4 @@ -import { v4 as uuid } from 'uuid'; +import { ForbiddenHttpError } from '@solid/community-server'; import { Store } from "n3"; import {queryEngine} from './index'; @@ -22,117 +22,8 @@ export const patchPolicy = async ( ) => { // check ownership of resource -- is client assigner? const isOwner = store.countQuads(null, "http://www.w3.org/ns/odrl/2/assigner", resourceOwner, null) !== 0; - if (!isOwner) throw new PatchError(403, "resource owner doesn't match") ; // ? shouldn't this throw an error -- drawback would be information leakage - else await queryEngine.queryVoid(query.toString(), { sources: [store] }); -} - -// ! link between target and resource owner is not always included through a policy -// ! there should be some endpoint or link in the store that allows for this discovery -// ! currently, this patch will not work! -/** - * Construct an update for modifying the status of a request. - * - * The update replaces the request’s status with a new one, - * provided the client is the assigner of a policy targeting - * the requested resource. - * - * @param requestID identifier of the request - * @param resourceOwner identifier of the client attempting the patch - * @param patchInformation new status value ("accepted", "denied") - * @returns a query string - */ -const buildAccessRequestModificationQuery = (requestID: string, resourceOwner: string, patchInformation: string) => ` - PREFIX ex: - PREFIX sotw: - PREFIX odrl: - - DELETE { - <${requestID}> ex:requestStatus ex:requested . - } INSERT { - <${requestID}> ex:requestStatus ex:${patchInformation} . - } WHERE { - <${requestID}> a sotw:EvaluationRequest ; - sotw:requestedTarget ?target . - ?pol odrl:permission ?perm . - ?perm odrl:target ?target ; - odrl:assigner <${resourceOwner}> . - } -`; - -// ! doesn't check if there is already an existing policy between these entities for the same resource -// TODO: include that check -/** - * Construct an insertion that creates a new policy - * based on an accepted request. - * - * A new policy and permission are inserted into the store, - * linking the requesting party with the requested target and action. - * - * @param requestID identifier of the request - * @param policy identifier for the new policy - * @param permission identifier for the new permission - * @param resourceOwner identifier of the client granting the policy - * @returns a query string - */ -const buildPolicyCreationFromAccessRequestQuery = ( - requestID: string, - policy: string, - permission: string, - resourceOwner: string, -) => ` - PREFIX ex: - PREFIX sotw: - PREFIX odrl: - - INSERT { - ex:${policy} a odrl:Agreement ; - odrl:uid ex:${policy} ; - odrl:permission ex:${permission} . - ex:${permission} a odrl:Permission ; - odrl:action ?action ; - odrl:target ?target ; - odrl:assignee ?requestingParty ; - odrl:assigner <${resourceOwner}> . - } WHERE { - <${requestID}> a sotw:EvaluationRequest ; - sotw:requestingParty ?requestingParty ; - sotw:requestedTarget ?target ; - sotw:requestedAction ?action ; - ex:requestStatus ex:accepted . - } -`; - -/** - * Update the status of a request in the store, and optionally - * create a new policy if the request is accepted. - * - * Only "accepted" and "denied" statuses are allowed. - * If "accepted", a new policy and permission are inserted - * linking the client to the requested target and action. - * - * @param store the store to update - * @param accessRequestID identifier of the request - * @param resourceOwner identifier of the client performing the update - * @param patchInformation new status ("accepted" or "denied") - */ -export const patchAccessRequest = async ( - store: Store, - accessRequestID: string, - resourceOwner: string, - patchInformation: string -) => { - if (!['accepted', 'denied'].includes(patchInformation)) return ; // ? perhaps throw an error? - const patchQuery = buildAccessRequestModificationQuery(accessRequestID, resourceOwner, patchInformation); - await queryEngine.queryVoid(patchQuery, { sources: [store] }); - - if (patchInformation === 'accepted') { - const newPolicyQuery = buildPolicyCreationFromAccessRequestQuery(accessRequestID, uuid(), uuid(), resourceOwner); - await queryEngine.queryVoid(newPolicyQuery, { sources: [store] }); - } -} - -export class PatchError extends Error { - constructor(readonly status: number, message: string) { - super(message); + if (!isOwner) { + throw new ForbiddenHttpError("resource owner doesn't match") ; } + else await queryEngine.queryVoid(query.toString(), { sources: [store] }); } diff --git a/packages/uma/src/util/routeSpecific/post.ts b/packages/uma/src/util/routeSpecific/post.ts index 762bdc2..9a7f96d 100644 --- a/packages/uma/src/util/routeSpecific/post.ts +++ b/packages/uma/src/util/routeSpecific/post.ts @@ -1,4 +1,5 @@ import { Store, DataFactory } from "n3"; +import { ODRL } from 'odrl-evaluator'; import {queryEngine} from './index'; import { BadRequestHttpError, ForbiddenHttpError, RDF, XSD } from "@solid/community-server"; const {literal, namedNode} = DataFactory @@ -89,59 +90,13 @@ const buildPolicyCreationQuery = (resourceOwner: string) => ` * @param resourceOwner identifier of the client (assigner) * @returns the validated policy as a store */ -export const postPolicy = async (store: Store, resourceOwner: string): Promise => { +export const postPolicy = async (store: Store, resourceOwner: string): + Promise<{ result: Store, id: string }> => { const isOwner = store.countQuads(null, 'http://www.w3.org/ns/odrl/2/assigner', resourceOwner, null) !== 0; if (!isOwner) throw new ForbiddenHttpError(); const result = await executePost(store, buildPolicyCreationQuery(resourceOwner), ["p", "r"]); - return result; -} - -/** - * Build a query to retrieve a newly posted request, - * ensuring it has correct status and is linked to the client - * as the requesting party. - * - * @param requestingParty identifier of the client - * @returns a query string - */ -const buildAccessRequestCreationQuery = (requestingParty: string) => ` - PREFIX ex: - PREFIX sotw: - PREFIX dcterms: - PREFIX odrl: - - SELECT ?r - WHERE { - ?r a sotw:EvaluationRequest ; - dcterms:issued ?date ; - sotw:requestedTarget ?target ; - sotw:requestedAction ?action ; - sotw:requestingParty <${requestingParty}> ; - ex:requestStatus ex:requested . - } -`; - -/** - * Validate and retrieve a newly posted request. - * - * Requires that the request has correct status (`requested`), - * is issued, and is linked to the given client as requesting party. - * - * @param store the source store - * @param requestingParty identifier of the client - * @returns the validated request as a store - */ -export const postAccessRequest = async (store: Store, requestingParty: string): Promise =>{ - const hasTime = store.countQuads(null, "http://purl.org/dc/terms/issued", null, null) !== 0; - if (hasTime) throw new BadRequestHttpError("Time is managed by the server"); - - const requestIds = store.getSubjects(RDF.type, "https://w3id.org/force/sotw#EvaluationRequest", null); - if (requestIds.length !==1) { - throw new BadRequestHttpError("Expected one acces request."); - } - - store.addQuad(requestIds[0], namedNode("http://purl.org/dc/terms/issued"), literal(new Date().toISOString(), XSD.terms.dateTime)) - return await executePost(store, buildAccessRequestCreationQuery(requestingParty), ["r"]); + // TODO: at least currently it is allowed to add multiple policies in a single POST so we can't really return an ID + return { result, id: '' }; } diff --git a/packages/uma/test/unit/controller/AccessRequestController.test.ts b/packages/uma/test/unit/controller/AccessRequestController.test.ts new file mode 100644 index 0000000..a1e0174 --- /dev/null +++ b/packages/uma/test/unit/controller/AccessRequestController.test.ts @@ -0,0 +1,297 @@ +import 'jest-rdf'; +import { + BadRequestHttpError, + 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'; + +describe('AccessRequestController', (): void => { + const target = 'http://example.org/resource_id'; + const scopes = [ 'http://example.org/scope1', 'http://example.org/scope2' ]; + const entity = 'entityID'; + const client = 'http://example.org/client'; + const owner = 'http://example.org/owner'; + let quads: Store; + let store: Mocked; + let ownershipStore: Mocked>; + let controller: AccessRequestController; + + beforeEach(async(): Promise => { + quads = new Store(new Parser().parse(` + @prefix sotw: . + @prefix odrl: . + a sotw:EvaluationRequest ; + sotw:requestedTarget ; + sotw:requestingParty <${client}> ; + sotw:requestStatus sotw:requested ; + sotw:requestedAction <${scopes[0]}> , <${scopes[1]}> . + + a sotw:EvaluationRequest ; + sotw:requestedTarget ; + sotw:requestingParty ; + sotw:requestStatus sotw:requested ; + sotw:requestedAction <${scopes[1]}> ; + odrl:constraint [ + a odrl:Constraint ; + odrl:leftOperand odrl:purpose ; + odrl:operator odrl:eq ; + odrl:rightOperand + ] . + `)); + + store = { + addRule: vi.fn(async(data: Store) => quads.addQuads(data.getQuads(null, null, null, null))), + getStore: vi.fn().mockResolvedValue(new Store(quads)), + removeData: vi.fn(async(data: Store) => quads.removeQuads(data.getQuads(null, null, null, null))), + } satisfies Partial as any; + + ownershipStore = { + get: vi.fn(), + } satisfies Partial> as any; + + controller = new AccessRequestController(store, ownershipStore); + }); + + it('errors when trying to delete a request.', async(): Promise => { + await expect(controller.deleteEntity(entity, client)).rejects.toThrow(ForbiddenHttpError); + }); + + it('errors when trying to replace a request.', async(): Promise => { + await expect(controller.putEntity('data', entity, client)).rejects.toThrow(ForbiddenHttpError); + }); + + describe('#addEntity', (): void => { + it('can add a request.', async(): Promise => { + const data = JSON.stringify({ resource_id: target, resource_scopes: scopes }); + + 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: . + <${response.id}> a sotw:EvaluationRequest ; + sotw:requestedTarget <${target}> ; + sotw:requestingParty <${client}> ; + sotw:requestStatus sotw:requested ; + sotw:requestedAction <${scopes[0]}> , <${scopes[1]}> . + `); + expect(request).toBeRdfIsomorphic(expected); + }); + + it('can add a request with constraints.', async(): Promise => { + 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/eq', 'http://example.org/purpose' ], + ], + }); + + 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:eq ; + odrl:rightOperand . + `); + expect(request).toBeRdfIsomorphic(expected); + }); + }); + + describe('#getEntities', (): void => { + it('returns all requests where the user requested.', async(): Promise => { + const response = await controller.getEntities(client); + expect(response.status).toBe(200); + expect(new Parser().parse(response.message)).toBeRdfIsomorphic(new Parser().parse(` + @prefix sotw: . + @prefix odrl: . + a sotw:EvaluationRequest ; + sotw:requestedTarget ; + sotw:requestingParty <${client}> ; + sotw:requestStatus sotw:requested ; + sotw:requestedAction <${scopes[0]}> , <${scopes[1]}> . + `)); + expect(ownershipStore.get).toHaveBeenCalledExactlyOnceWith(client); + }); + + it('returns all requests where the user is the owner of the target resource.', async(): Promise => { + ownershipStore.get.mockResolvedValueOnce([ 'http://example.org/resource_id2' ]); + const response = await controller.getEntities(owner); + expect(response.status).toBe(200); + expect(new Parser().parse(response.message)).toBeRdfIsomorphic(new Parser().parse(` + @prefix sotw: . + @prefix odrl: . + a sotw:EvaluationRequest ; + sotw:requestedTarget ; + sotw:requestingParty ; + sotw:requestStatus sotw:requested ; + sotw:requestedAction <${scopes[1]}> ; + odrl:constraint [ + a odrl:Constraint ; + odrl:leftOperand odrl:purpose ; + odrl:operator odrl:eq ; + odrl:rightOperand + ] . + `)); + expect(ownershipStore.get).toHaveBeenCalledExactlyOnceWith(owner); + }); + + it('returns nothing if there is no match.', async(): Promise => { + await expect(controller.getEntities(owner)).resolves.toEqual({ status: 200, message: '' }); + }); + }); + + describe('#getEntity', (): void => { + it('returns the requested entity if the client is the requester.', async(): Promise => { + const response = await controller.getEntity('http://example.org/request1', client); + expect(response.status).toBe(200); + expect(new Parser().parse(response.message)).toBeRdfIsomorphic(new Parser().parse(` + @prefix sotw: . + @prefix odrl: . + a sotw:EvaluationRequest ; + sotw:requestedTarget ; + sotw:requestingParty <${client}> ; + sotw:requestStatus sotw:requested ; + sotw:requestedAction <${scopes[0]}> , <${scopes[1]}> . + `)); + }); + + it('returns the requested entity if the client is the owner of the target.', async(): Promise => { + ownershipStore.get.mockResolvedValueOnce([ 'http://example.org/resource_id2' ]); + const response = await controller.getEntity('http://example.org/request2', owner); + expect(response.status).toBe(200); + expect(new Parser().parse(response.message)).toBeRdfIsomorphic(new Parser().parse(` + @prefix sotw: . + @prefix odrl: . + a sotw:EvaluationRequest ; + sotw:requestedTarget ; + sotw:requestingParty ; + sotw:requestStatus sotw:requested ; + sotw:requestedAction <${scopes[1]}> ; + odrl:constraint [ + a odrl:Constraint ; + odrl:leftOperand odrl:purpose ; + odrl:operator odrl:eq ; + odrl:rightOperand + ] . + `)); + }); + + it('returns a 404 if no request is known with that identifier.', async(): Promise => { + await expect(controller.getEntity('http://example.org/unknown', client)).rejects.toThrow(NotFoundHttpError); + }); + + it('returns a 403 if the client is not allowed to see the resource.', async(): Promise => { + await expect(controller.getEntity('http://example.org/request1', owner)).rejects.toThrow(ForbiddenHttpError); + }); + }); + + // TODO: not testing with isolation as there are weird issues there + describe('#patchEntity', (): void => { + beforeEach(async(): Promise => { + // Mocking once not sufficient since the BaseController calls getEntity multiple times as well + ownershipStore.get.mockResolvedValue([ 'http://example.org/resource_id2' ]); + }); + + it('creates a policy if the request is accepted.', async(): Promise => { + await expect(controller.patchEntity('http://example.org/request2', 'accepted', owner, false)) + .resolves.toEqual({ status: 204, body: '' }); + expect(quads.countQuads('http://example.org/request2', SOTW.terms.requestStatus, SOTW.terms.requested, null)) + .toBe(0); + expect(quads.countQuads('http://example.org/request2', SOTW.terms.requestStatus, SOTW.terms.accepted, null)) + .toBe(1); + const policies = quads.getSubjects(RDF.terms.type, ODRL.terms.Agreement, null); + expect(policies).toHaveLength(1); + 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(quads.getQuads(policies[0], null, null, null)).toBeRdfIsomorphic(new Parser().parse(` + @prefix odrl: . + <${policies[0].value}> a odrl:Agreement ; + odrl:uid <${policies[0].value}> ; + odrl:permission <${permissions[0].value}> . + `)); + expect(quads.getQuads(permissions[0], null, null, null)).toBeRdfIsomorphic(new Parser().parse(` + @prefix odrl: . + <${permissions[0].value}> a odrl:Permission ; + odrl:target ; + odrl:action ; + odrl:assignee ; + odrl:assigner <${owner}> ; + odrl:constraint _:${constraints[0].value} . + `)); + expect(quads.getQuads(constraints[0], null, null, null)).toBeRdfIsomorphic(new Parser().parse(` + @prefix odrl: . + _:${constraints[0].value} a odrl:Constraint ; + odrl:leftOperand odrl:purpose ; + odrl:operator odrl:eq ; + odrl:rightOperand . + `)); + }); + + it('returns a 404 if the request is not known.', async(): Promise => { + await expect(controller.patchEntity('http://example.org/unknown', 'accepted', owner, false)) + .rejects.toThrow(NotFoundHttpError); + }); + + it('errors if the user is not the owner.', async(): Promise => { + ownershipStore.get.mockResolvedValue([]); + await expect(controller.patchEntity('http://example.org/request2', 'accepted', owner, false)) + .rejects.toThrow(ForbiddenHttpError); + }); + + it('errors if an unknown state is provided.', async(): Promise => { + await expect(controller.patchEntity('http://example.org/request2', 'unknown', owner, false)) + .rejects.toThrow(BadRequestHttpError); + }); + + it('errors if the request has no actions.', async(): Promise => { + store.getStore.mockResolvedValueOnce(new Store(new Parser().parse(` + @prefix sotw: . + @prefix odrl: . + a sotw:EvaluationRequest ; + sotw:requestedTarget ; + sotw:requestingParty <${client}> ; + sotw:requestStatus sotw:requested . + `))); + await expect(controller.patchEntity('http://example.org/request2', 'accepted', owner, false)) + .rejects.toThrow(`Invalid actions () or parties (${client})`); + }); + + it('errors if the request has no requester.', async(): Promise => { + store.getStore.mockResolvedValueOnce(new Store(new Parser().parse(` + @prefix sotw: . + @prefix odrl: . + a sotw:EvaluationRequest ; + sotw:requestedTarget ; + sotw:requestedAction <${scopes[1]}> ; + sotw:requestStatus sotw:requested . + `))); + await expect(controller.patchEntity('http://example.org/request2', 'accepted', owner, false)) + .rejects.toThrow(`Invalid actions (http://example.org/scope2) or parties ()`); + }); + }); +}); diff --git a/packages/uma/test/unit/routes/ResourceRegistration.test.ts b/packages/uma/test/unit/routes/ResourceRegistration.test.ts index 74eac55..314d316 100644 --- a/packages/uma/test/unit/routes/ResourceRegistration.test.ts +++ b/packages/uma/test/unit/routes/ResourceRegistration.test.ts @@ -28,6 +28,7 @@ describe('ResourceRegistration', (): void => { let derivationStore: Mocked>; let registrationStore: Mocked; + let ownershipStore: Mocked> let policies: Mocked; let validator: Mocked; @@ -59,6 +60,12 @@ describe('ResourceRegistration', (): void => { delete: vi.fn(), } satisfies Partial> as any; + ownershipStore = { + get: vi.fn().mockResolvedValue([]), + set: vi.fn(), + delete: vi.fn(), + } satisfies Partial> as any; + policies = { getStore: vi.fn().mockResolvedValue(policyStore), addRule: vi.fn(), @@ -69,7 +76,7 @@ describe('ResourceRegistration', (): void => { handleSafe: vi.fn().mockResolvedValue({ owner }), } satisfies Partial as any; - handler = new ResourceRegistrationRequestHandler(derivationStore, registrationStore, policies, validator); + handler = new ResourceRegistrationRequestHandler(derivationStore, registrationStore, ownershipStore, policies, validator); }); it('throws an error if the method is not allowed.', async(): Promise => { diff --git a/test/integration/AccessRequests.test.ts b/test/integration/AccessRequests.test.ts new file mode 100644 index 0000000..72cd84a --- /dev/null +++ b/test/integration/AccessRequests.test.ts @@ -0,0 +1,265 @@ +import { App, joinUrl } from '@solid/community-server'; +import { ODRL } from '@solidlab/uma'; +import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory'; +import { Parser, Store, DataFactory as DF } from 'n3'; +import path from 'node:path'; +import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil'; +import { generateCredentials } from '../util/UmaUtil'; + +const [ cssPort, umaPort ] = getPorts('AccessRequests'); + +const policyEndpoint = `http://localhost:${umaPort}/uma/policies`; +const accessRequestEndpoint = `http://localhost:${umaPort}/uma/requests`; +const owner = `http://localhost:${cssPort}/alice/profile/card#me`; +const requester = `http://example.com/bob`; +const target = `http://localhost:${cssPort}/alice/`; + +describe('An access request server setup', (): void => { + let umaApp: App; + let cssApp: App; + let requestLocation: string; + + beforeAll(async(): Promise => { + setGlobalLoggerFactory(new WinstonLoggerFactory('off')); + + umaApp = await instantiateFromConfig( + 'urn:uma:default:App', + path.join(__dirname, '../../packages/uma/config/default.json'), + { + 'urn:uma:variables:port': umaPort, + 'urn:uma:variables:baseUrl': `http://localhost:${umaPort}/uma`, + 'urn:uma:variables:eyePath': 'eye', + 'urn:uma:variables:backupFilePath': '', + } + ) as App; + + cssApp = await instantiateFromConfig( + 'urn:solid-server:default:App', + path.join(__dirname, '../../packages/css/config/default.json'), + { + ...getDefaultCssVariables(cssPort), + 'urn:solid-server:uma:variable:AuthorizationServer': `http://localhost:${umaPort}/`, + 'urn:solid-server:default:variable:seedConfig': path.join(__dirname, '../../packages/css/config/seed.json'), + }, + ) as App; + + await Promise.all([umaApp.start(), cssApp.start()]); + }); + + afterAll(async(): Promise => { + await Promise.all([umaApp.stop(), cssApp.start()]); + }); + + it('can set up the resource server.', async(): Promise => { + await generateCredentials({ + webId: owner, + authorizationServer: `http://localhost:${umaPort}/uma`, + resourceServer: `http://localhost:${cssPort}/`, + email: 'alice@example.org', + password: 'abc123' + }); + }); + + it('does not have any policies when starting.', async(): Promise => { + const response = await fetch(policyEndpoint, { + headers: { authorization: `WebID ${encodeURIComponent(owner)}` }, + }); + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe(''); + }); + + it('can request access.', async(): Promise => { + const response = await fetch(accessRequestEndpoint, { + method: 'POST', + headers: { + authorization: `WebID ${encodeURIComponent(requester)}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ resource_id: target, resource_scopes: [ 'http://www.w3.org/ns/odrl/2/read' ]}), + }); + requestLocation = response.headers.get('location')!; + expect(requestLocation.length).toBeGreaterThan(0); + await expect(response.status).toBe(201); + }); + + it('can see the access request as the requester.', async(): Promise => { + let response = await fetch(accessRequestEndpoint, { + headers: { authorization: `WebID ${encodeURIComponent(requester)}` }, + }); + expect(response.status).toBe(200); + const parser = new Parser(); + let store = new Store(parser.parse(await response.text())); + expect(store.countQuads( + null, + 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + 'https://w3id.org/force/sotw#EvaluationRequest', null)).toBe(1); + + response = await fetch(requestLocation, { + headers: { authorization: `WebID ${encodeURIComponent(requester)}` }, + }); + expect(response.status).toBe(200); + store = new Store(parser.parse(await response.text())); + expect(store.countQuads( + null, + 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + 'https://w3id.org/force/sotw#EvaluationRequest', null)).toBe(1); + }); + + it('can see the access request as the owner.', async(): Promise => { + // It's possible the target is not registered yet at this point, this fetch makes sure it is + await fetch(target); + + let response = await fetch(accessRequestEndpoint, { + headers: { authorization: `WebID ${encodeURIComponent(owner)}` }, + }); + expect(response.status).toBe(200); + const parser = new Parser(); + let store = new Store(parser.parse(await response.text())); + expect(store.countQuads( + null, + 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + 'https://w3id.org/force/sotw#EvaluationRequest', null)).toBe(1); + + response = await fetch(requestLocation, { + headers: { authorization: `WebID ${encodeURIComponent(owner)}` }, + }); + expect(response.status).toBe(200); + store = new Store(parser.parse(await response.text())); + expect(store.countQuads( + null, + 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + 'https://w3id.org/force/sotw#EvaluationRequest', null)).toBe(1); + }); + + it('can not see the access request as someone else.', async(): Promise => { + let response = await fetch(accessRequestEndpoint, { + headers: { authorization: `WebID ${encodeURIComponent('http://example.com/unknown')}` }, + }); + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe(''); + + response = await fetch(requestLocation, { + headers: { authorization: `WebID ${encodeURIComponent('http://example.com/unknown')}` }, + }); + expect(response.status).toBe(403); + }); + + it('can not accept the request as requester.', async(): Promise => { + const response = await fetch(requestLocation, { + method: 'PATCH', + headers: { authorization: `WebID ${encodeURIComponent(requester)}` }, + body: JSON.stringify({ status: 'accepted' }), + }); + expect(response.status).toBe(403); + }); + + it('can accept the request as owner.', async(): Promise => { + const response = await fetch(requestLocation, { + method: 'PATCH', + headers: { authorization: `WebID ${encodeURIComponent(owner)}` }, + body: JSON.stringify({ status: 'accepted' }), + }); + expect(response.status).toBe(204); + }); + + it('can not modify an accepted request.', async(): Promise => { + const response = await fetch(requestLocation, { + method: 'PATCH', + headers: { authorization: `WebID ${encodeURIComponent(owner)}` }, + body: JSON.stringify({ status: 'denied' }), + }); + expect(response.status).toBe(409); + }); + + it('has a policy after accepting the request.', async(): Promise => { + const response = await fetch(policyEndpoint, { + headers: { authorization: `WebID ${encodeURIComponent(owner)}` }, + }); + expect(response.status).toBe(200); + const parser = new Parser(); + const store = new Store(parser.parse(await response.text())); + expect(store.countQuads(null, ODRL.terms.action, 'http://www.w3.org/ns/odrl/2/read', null)).toBe(1); + expect(store.countQuads(null, ODRL.terms.target, target, null)).toBe(1); + expect(store.countQuads(null, ODRL.terms.assignee, requester, null)).toBe(1); + expect(store.countQuads(null, ODRL.terms.assigner, owner, null)).toBe(1); + }); + + it('can deny requests.', async(): Promise => { + let response = await fetch(accessRequestEndpoint, { + method: 'POST', + headers: { + authorization: `WebID ${encodeURIComponent(requester)}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ resource_id: target, resource_scopes: [ 'http://www.w3.org/ns/odrl/2/write' ]}), + }); + requestLocation = response.headers.get('location')!; + expect(requestLocation.length).toBeGreaterThan(0); + await expect(response.status).toBe(201); + + response = await fetch(requestLocation, { + method: 'PATCH', + headers: { authorization: `WebID ${encodeURIComponent(owner)}` }, + body: JSON.stringify({ status: 'denied' }), + }); + expect(response.status).toBe(204); + + // Can not be changed + response = await fetch(requestLocation, { + method: 'PATCH', + headers: { authorization: `WebID ${encodeURIComponent(owner)}` }, + body: JSON.stringify({ status: 'accepted' }), + }); + expect(response.status).toBe(409); + + // Did not generate a policy + response = await fetch(policyEndpoint, { + headers: { authorization: `WebID ${encodeURIComponent(owner)}` }, + }); + expect(response.status).toBe(200); + const parser = new Parser(); + const store = new Store(parser.parse(await response.text())); + expect(store.countQuads(null, ODRL.terms.action, 'http://www.w3.org/ns/odrl/2/write', null)).toBe(0); + }); + + it('can add constraints to requests.', async(): Promise => { + const purpose = 'http://example.com/purpose'; + let response = await fetch(accessRequestEndpoint, { + method: 'POST', + headers: { + authorization: `WebID ${encodeURIComponent(requester)}`, + 'content-type': 'application/json', + }, + 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 ]], + }), + }); + + expect(response.status).toBe(201); + requestLocation = response.headers.get('location')!; + expect(requestLocation.length).toBeGreaterThan(0); + + // 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); + + response = await fetch(requestLocation, { + method: 'PATCH', + headers: { authorization: `WebID ${encodeURIComponent(owner)}` }, + body: JSON.stringify({ status: 'accepted' }), + }); + expect(response.status).toBe(204); + + // Generated a policy with constraints + response = await fetch(policyEndpoint, { + headers: { authorization: `WebID ${encodeURIComponent(owner)}` }, + }); + expect(response.status).toBe(200); + 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); + }); +}); diff --git a/test/integration/Policies.test.ts b/test/integration/Policies.test.ts index 3a608b2..a413468 100644 --- a/test/integration/Policies.test.ts +++ b/test/integration/Policies.test.ts @@ -229,7 +229,7 @@ describe('A policy server setup', (): void => { expect(response.status).toBe(200); expect(await response.text()).toHaveLength(0); - response = await fetchPolicy('GET', webIds.b, 'unknown'); + response = await fetchPolicy('GET', webIds.b, 'http://example.org/unknown'); expect(response.status).toBe(200); expect(await response.text()).toHaveLength(0); }); @@ -299,7 +299,6 @@ describe('A policy server setup', (): void => { // Rules in above policy assigned by b should still be there // response = await fetchPolicy('GET', webIds.b, 'urn:uuid:95efe0e8-4fb7-496d-8f3c-4d78c97829bc'); // expect(response.status).toBe(200); - // console.log(await response.text()); // let store = new Store(new Parser().parse(await response.text())); // let policies = store.getSubjects(ODRL.terms.uid, null, null); // expect(policies.map((term) => term.value)).toEqual([ 'urn:uuid:95efe0e8-4fb7-496d-8f3c-4d78c97829bc' ]); diff --git a/test/util/ServerUtil.ts b/test/util/ServerUtil.ts index 554bb5b..7316dcf 100644 --- a/test/util/ServerUtil.ts +++ b/test/util/ServerUtil.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; const portNames = [ + 'AccessRequests', 'Aggregation', 'AggregationSource', 'Base', diff --git a/yarn.lock b/yarn.lock index ba0fd52..120a8d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5657,6 +5657,7 @@ __metadata: ms: "npm:^2.1.3" n3: "npm:^1.17.2" odrl-evaluator: "npm:^0.5.0" + rdf-string: "npm:^2.0.1" rdf-vocabulary: "npm:^1.0.1" uri-template-lite: "npm:^23.4.0" winston: "npm:^3.11.0"