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 bcf2b6e..0000000 Binary files a/documentation/figures/access_grants_requests_fsm.png and /dev/null differ 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"