diff --git a/documentation/collections.md b/documentation/collections.md
index 73e9e14..d44e9c8 100644
--- a/documentation/collections.md
+++ b/documentation/collections.md
@@ -1,21 +1,104 @@
# ODRL policies targeting collections
This document describes how this UMA server supports ODRL collections.
-The implementation is based on the [A4DS specification](https://spec.knows.idlab.ugent.be/A4DS/L1/latest/).
-Much of the information in this document can also be found there.
+There are 2 main kinds of collections:
+custom collections created by a user through the collection API,
+and automatically generated collections based on registering resource relations.
-## WAC / ACP
+## Collection API
-The initial idea for implementing collections is that we want to be able
-to create policies that target the contents of a container,
-similar to how WAC and ACP do this.
-We do not want the UMA server to be tied to the LDP interface though,
-so the goal is to have a generic solution that can handle any kind of relationship between resources.
+The UMA server exposes the collection API through the `/collections` URl.
+Individual collections can be accessed through `/collections/{id}`.
+All CRUD operations are supported for both asset and party collections.
-## New resource description fields
+All requests require authentication as
+described in the [getting started documentation](getting-started.md#authenticating-as-resource-owner).
-To support collections, the RS now includes two additional fields when registering a resource,
-in addition to those defined in the UMA specification.
+### Create
+
+New collections are with POST requests to the collection API.
+The request body should be JSON, with the contents depending on the type of collection.
+The description field is always optional.
+
+If successful, the response will have status code 201
+with the identifier of the new collection in the location header.
+This identifier is the full URL to be used when sending requests to update or read this specific collection.
+
+#### Asset collections
+
+```json
+{
+ "description": "My asset collection",
+ "type": "asset",
+ "parts": [ "http://example.com/my-resource", "http://example.com/my-other-resource" ]
+}
+```
+
+The identifiers in the `parts` array need to be the UMA identifiers of the resources.
+The client performing the request needs to be authenticated as the Resource Owner of all resources in the array,
+or the request will be rejected.
+
+#### Party collections
+
+```json
+{
+ "description": "My asset collection",
+ "type": "party",
+ "owners": [ "http://example.com/alice/card#me" ],
+ "parts": [ "http://example.com/alice/card#me", "http://example.com/bob/card#me" ]
+}
+```
+
+The `owners` array defines who will be allowed to modify the collection,
+and can not be empty.
+The client performing the request needs to be one of the owners.
+
+### Update
+
+A collection can be updated by performing a put request to `/policies/{id}`.
+This full URL is also the URL that will be returned in the location header when performing a POST request.
+The body should be the same as above, and will replace the existing collection.
+A collection can only be modified by one of the owners,
+in case of an asset collection, this is the owner of the collected resources.
+
+### Read
+
+All owned collections can be seen by performing a GET request to the collection API.
+A single collection can be seen by performing a GET to its specific URL.
+A result would look as follows:
+
+```ttl
+@prefix dc: .
+@prefix odrl: .
+
+ a odrl:AssetCollection ;
+ dc:description "My assets" ;
+ dc:creator .
+ odrl:partOf .
+ odrl:partOf .
+
+ a odrl:PartyCollection ;
+ dc:description "My party" ;
+ dc:creator .
+ odrl:partOf .
+ odrl:partOf .
+```
+
+### Delete
+
+A collection can be removed by performing a DELETE request to the URL of the collection.
+Similar to updates, this can only be done by one of the owners.
+
+## Relation collections
+
+The UMA server will automatically generate collections,
+based on [relation metadata](https://spec.knows.idlab.ugent.be/A4DS/L1/latest/#dom-resourcedescription-resource_relations)
+provided during resource registration.
+
+
+### New resource description fields
+
+To support collections, the RS can include two additional fields when registering a resource.
* `resource_defaults`: A key/value map describing the scopes of collections having the registered resource as a source.
The keys are the relations where the resource is the subject,
@@ -24,7 +107,6 @@ in addition to those defined in the UMA specification.
The keys are the relations and the values are the UMA IDs of the relation targets.
The resource itself is the object of the relations,
and the values in the arrays are the subject.
- Note that this is the reverse of the `resource_defaults` fields.
For both of the above, one of the keys can be `@reverse`,
which takes as value a similar key/value object,
@@ -48,20 +130,19 @@ An example of such an extended resource description:
The above example tells the UMA server that the available scopes for this new resource are `read` and `write`,
as defined in the UMA specification.
-The new field `resource_defaults` tells the server that all containers for
-the `http://www.w3.org/ns/ldp#contains` relation
-that have this resource as the source,
-have `read` as an available scope.
+The `resource_defaults` field indicates that the collection corresponding to all resources
+this resource has the `http://www.w3.org/ns/ldp#contains` relation to, have the `read` scope.
+
The `resource_relations` field indicates that this resource
-has the `http://www.w3.org/ns/ldp#contains` relation with as target `assets:5678`,
+has the `http://www.w3.org/ns/ldp#contains` relation with as target `assets:1234`,
while the other entry indicates it is the target of the `my:other:relation` with `assets:5678` as subject.
-## Generating collection triples
+### Generating collection triples
When registering a resource,
the UMA server immediately generates all necessary triples to keep track of all collections a resource is part of.
First it generates the necessary asset collections based on the `resource_defaults` field,
-and then generate the relation triples based on the `resource_relations` field.
+and then generates the relation triples based on the `resource_relations` field.
Assuming a resource `my:parent:resource` is registered with a `http://www.w3.org/ns/ldp#contains` `resource_default`,
the following triples would be generated:
@@ -75,8 +156,7 @@ the following triples would be generated:
```
If the relation was reversed, the relation object would be `[ owl:inverseOf ]`.
-
-Then, if another resource, `my:new:resource` is registered,
+Then, if another resource, `my:new:resource`, is registered
with a reverse `http://www.w3.org/ns/ldp#contains` relation targeting `my:parent:resource`,
the following additional triple would be generated:
```ttl
@@ -89,15 +169,16 @@ Any policy that targets a collection ID will apply to all resources that are par
### Finding collection identifiers
-Currently, there is no API yet to request a list of all the automatically registered collections described above.
-As a workaround, the generated collection identifiers are fixed, based on the relevant identifiers.
+Collection identifiers can be found through the policy API, described above.
+For now, the generated collection identifiers are fixed, based on the relevant identifiers,
+but it should be assumed that these can change in the future.
A collection with source `http://example.com/container/` and relation `http://www.w3.org/ns/ldp#contains`,
would have as collection identifier `collection:http://example.com/container/:http://www.w3.org/ns/ldp#contains`.
In case of a reverse relationship, this would instead be
`collection:http://www.w3.org/ns/ldp#contains:http://example.com/container/`.
These are the identifiers to then use as targets in a policy.
-## Updating collection triples
+### Updating collection triples
Every time a resource is updated, the corresponding collection triples are updated accordingly.
If an update removes some of the `resource_relations` entries,
@@ -134,11 +215,6 @@ To make things easier until that is resolved,
the servers are configured so the generated UMA identifiers correspond to the actual resource identifiers.
The Resource Server informs the UMA server of the identifiers by using the `name` field when registering a resource.
-### Asset Collection identifiers
-
-[As mentioned above](#finding-collection-identifiers), there is no API yet for accessing collections,
-so a fixed URI format is used for automatically generated collections.
-
### Parent containers not yet registered
Resource registration happens asynchronously in the CSS RS implementation.
@@ -152,19 +228,18 @@ where the registration is updated with the now available parent UMA ID.
### Accessing resources before they are registered
-An additional consequence of asynchronous resource registration in the CSS RS,
+An additional consequence of asynchronous resource registration in the CSS RS implementation,
is that a client might try to access a resource before its registration is finished.
This would cause an error as the Resource Server needs the UMA ID to request a ticket,
but doesn't know it yet.
-To prevent issues, the RS will wait until registration of the corresponding resource is finished,
-or even start registration should it not have happened yet for some reason.
+To prevent issues, the RS will wait until registration of the corresponding resource is finished.
A timeout is added to prevent the connection from getting stuck should something go wrong.
### Policies for resources that do not yet exist
When creating a new resource on the CSS RS, using PUT for example,
it is necessary to know if that action is allowed.
-It is not possible to generate a ticket with this potentially new resource as a target though,
-as it does not have an UMA ID yet.
+It is not possible to generate a ticket with this new resource as a target though,
+as it does not have a UMA ID yet.
The current implementation instead generates a ticket targeting the first existing (grand)parent container,
and requests the `create` scope.
diff --git a/packages/css/src/uma/UmaClient.ts b/packages/css/src/uma/UmaClient.ts
index f8feacb..7cea621 100644
--- a/packages/css/src/uma/UmaClient.ts
+++ b/packages/css/src/uma/UmaClient.ts
@@ -57,6 +57,7 @@ interface TokenResponse {
export type UmaVerificationOptions = Omit;
const UMA_DISCOVERY = '/.well-known/uma2-configuration';
+const PAT_EVENT = 'PAT_EVENT';
const REQUIRED_METADATA = [
'issuer',
@@ -75,10 +76,12 @@ const REQUIRED_METADATA = [
export class UmaClient implements SingleThreaded {
protected readonly logger = getLoggerFor(this);
- // Keeps track of resources that are being registered to prevent duplicate registration calls.
- protected readonly inProgressResources: Set = new Set();
- // Used to notify when registration finished for a resource. The event will be the identifier of the resource.
- protected readonly registerEmitter: EventEmitter = new EventEmitter();
+ /* Keeps track of resources that are being registered to prevent duplicate registration calls.
+ * Also keeps track if a PAT registration is going on. */
+ protected readonly inProgress: Set = new Set();
+ /* Used to notify when registration finished for a resource. The event will be the identifier of the resource.
+ * Also used to notify when a PAT was acquired. */
+ protected readonly emitter: EventEmitter = new EventEmitter();
protected readonly configCache: NodeJS.Dict<{ config: UmaConfig, expiration: number }> = {};
protected readonly patStorage: NodeJS.Dict<{ pat: string, expiration: number }> = {};
@@ -101,7 +104,7 @@ export class UmaClient implements SingleThreaded {
) {
// This number can potentially get very big when seeding a bunch of pods.
// This is not really an issue, but it is still preferable to not have a warning printed.
- this.registerEmitter.setMaxListeners(20);
+ this.emitter.setMaxListeners(20);
}
public async getPat(issuer: string, credentials: string): Promise {
@@ -109,6 +112,11 @@ export class UmaClient implements SingleThreaded {
if (cached && cached.expiration > Date.now()) {
return cached.pat;
}
+ if (this.inProgress.has(PAT_EVENT)) {
+ await once(this.emitter, PAT_EVENT);
+ return this.getPat(issuer, credentials);
+ }
+ this.inProgress.add(PAT_EVENT);
const config = await this.fetchUmaConfig(issuer);
const response = await this.fetcher.fetch(config.token_endpoint, {
@@ -129,6 +137,9 @@ export class UmaClient implements SingleThreaded {
const expiration = Date.now() + expires_in * 1000;
this.patStorage[credentials] = { pat, expiration };
+ this.inProgress.delete(PAT_EVENT);
+ this.emitter.emit(PAT_EVENT);
+
return pat;
}
@@ -170,13 +181,13 @@ export class UmaClient implements SingleThreaded {
const body = [];
for (const [ target, modes ] of permissions.entrySets()) {
let umaId = await this.umaIdStore.get(target.path);
- if (!umaId && this.inProgressResources.has(target.path)) {
+ if (!umaId && this.inProgress.has(target.path)) {
// Wait for the resource to finish registration if it is still being registered, and there is no UMA ID yet.
// Time out after 2s to prevent getting stuck in case something goes wrong during registration.
const timeoutPromise = promises.setTimeout(2000, '').then(() => {
throw new InternalServerError(`Unable to finish registration for ${target.path}.`)
});
- await Promise.race([timeoutPromise, once(this.registerEmitter, target.path)]);
+ await Promise.race([timeoutPromise, once(this.emitter, target.path)]);
umaId = await this.umaIdStore.get(target.path);
}
if (!umaId) {
@@ -350,14 +361,14 @@ export class UmaClient implements SingleThreaded {
* and updated with the relations once the parent registration is finished.
*/
public async registerResource(resource: ResourceIdentifier, issuer: string, credentials: string): Promise {
- if (this.inProgressResources.has(resource.path)) {
+ if (this.inProgress.has(resource.path)) {
// It is possible a resource is still being registered when an updated registration is already requested.
// To prevent duplicate registrations of the same resource,
// the next call will only happen when the first one is finished.
- await once(this.registerEmitter, resource.path);
+ await once(this.emitter, resource.path);
return this.registerResource(resource, issuer, credentials);
}
- this.inProgressResources.add(resource.path);
+ this.inProgress.add(resource.path);
let { resource_registration_endpoint: endpoint } = await this.fetchUmaConfig(issuer);
const knownUmaId = await this.umaIdStore.get(resource.path);
if (knownUmaId) {
@@ -394,12 +405,12 @@ export class UmaClient implements SingleThreaded {
resource.path} due to missing parent ID. Waiting for parent registration.`);
promises.push(
- once(this.registerEmitter, parentIdentifier.path)
+ once(this.emitter, parentIdentifier.path)
.then(() => this.registerResource(resource, issuer, credentials)),
);
// It is possible the parent is not yet being registered.
// We need to force a registration in such a case, otherwise the above event will never be fired.
- if (!this.inProgressResources.has(parentIdentifier.path)) {
+ if (!this.inProgress.has(parentIdentifier.path)) {
promises.push(this.registerResource(parentIdentifier, issuer, credentials));
}
}
@@ -439,8 +450,8 @@ export class UmaClient implements SingleThreaded {
this.logger.info(`Registered resource ${resource.path} with UMA ID ${umaId}`);
}
// Indicate this resource finished registration
- this.inProgressResources.delete(resource.path);
- this.registerEmitter.emit(resource.path);
+ this.inProgress.delete(resource.path);
+ this.emitter.emit(resource.path);
});
// Execute all the required promises.
diff --git a/packages/css/test/unit/uma/UmaClient.test.ts b/packages/css/test/unit/uma/UmaClient.test.ts
index 45e5ba9..bfb9b39 100644
--- a/packages/css/test/unit/uma/UmaClient.test.ts
+++ b/packages/css/test/unit/uma/UmaClient.test.ts
@@ -17,8 +17,8 @@ import { Fetcher } from '../../../src/util/fetch/Fetcher';
type Writeable = { -readonly [P in keyof T]: T[P] };
class PublicUmaClient extends UmaClient {
- public inProgressResources: Set = new Set();
- public registerEmitter: EventEmitter = new EventEmitter();
+ public inProgress: Set = new Set();
+ public emitter: EventEmitter = new EventEmitter();
public configCache: NodeJS.Dict<{ config: UmaConfig, expiration: number }> = {};
public patStorage: NodeJS.Dict<{ pat: string, expiration: number }> = {};
}
@@ -257,11 +257,11 @@ describe('UmaClient', (): void => {
umaIdStore.get.mockResolvedValueOnce('uma2');
const publicClient = new PublicUmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet, baseUrl);
- publicClient.inProgressResources.add('target1');
+ publicClient.inProgress.add('target1');
const prom = publicClient.fetchTicket(permissions, issuer, credentials);
await flushPromises();
vi.advanceTimersByTime(1000);
- publicClient.registerEmitter.emit('target1');
+ publicClient.emitter.emit('target1');
await expect(prom).resolves.toBeUndefined();
expect(fetcher.fetch).toHaveBeenNthCalledWith(3, umaConfig.permission_endpoint, {
@@ -287,7 +287,7 @@ describe('UmaClient', (): void => {
umaIdStore.get.mockResolvedValueOnce('uma2');
const publicClient = new PublicUmaClient(umaIdStore, fetcher, identifierStrategy, resourceSet, baseUrl);
- publicClient.inProgressResources.add('target1');
+ publicClient.inProgress.add('target1');
const prom = publicClient.fetchTicket(permissions, issuer, credentials);
await flushPromises();
vi.advanceTimersByTime(3000);
diff --git a/packages/uma/config/default.json b/packages/uma/config/default.json
index e0a0491..85990d3 100644
--- a/packages/uma/config/default.json
+++ b/packages/uma/config/default.json
@@ -14,6 +14,7 @@
"sai-uma:config/routes/discovery.json",
"sai-uma:config/routes/introspection.json",
"sai-uma:config/routes/client-registration.json",
+ "sai-uma:config/routes/collections.json",
"sai-uma:config/routes/keys.json",
"sai-uma:config/routes/resources.json",
"sai-uma:config/routes/tickets.json",
@@ -142,7 +143,9 @@
{ "@id": "urn:uma:default:ResourceRegistrationOpsRoute" },
{ "@id": "urn:uma:default:IntrospectionRoute" },
{ "@id": "urn:uma:default:ClientRegistrationRoute" },
- { "@id": "urn:uma:default:ClientRegistrationIdRoute" }
+ { "@id": "urn:uma:default:ClientRegistrationIdRoute" },
+ { "@id": "urn:uma:default:CollectionRoute" },
+ { "@id": "urn:uma:default:CollectionIdRoute" }
]
}
},
diff --git a/packages/uma/config/routes/collections.json b/packages/uma/config/routes/collections.json
new file mode 100644
index 0000000..f0943b2
--- /dev/null
+++ b/packages/uma/config/routes/collections.json
@@ -0,0 +1,29 @@
+{
+ "@context": [
+ "https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma/^0.0.0/components/context.jsonld"
+ ],
+ "@graph": [
+ {
+ "@id": "urn:uma:default:CollectionHandler",
+ "@type": "CollectionRequestHandler",
+ "credentialParser": { "@id": "urn:uma:default:CredentialParser" },
+ "verifier": { "@id": "urn:uma:default:Verifier" },
+ "ownershipStore": { "@id": "urn:uma:default:OwnershipStore" },
+ "policies": { "@id": "urn:uma:default:RulesStorage" }
+ },
+ {
+ "@id": "urn:uma:default:CollectionRoute",
+ "@type": "HttpHandlerRoute",
+ "methods": [ "GET", "POST" ],
+ "handler": { "@id": "urn:uma:default:CollectionHandler" },
+ "path": "/uma/collections"
+ },
+ {
+ "@id": "urn:uma:default:CollectionIdRoute",
+ "@type": "HttpHandlerRoute",
+ "methods": [ "GET", "PUT", "DELETE" ],
+ "handler": { "@id": "urn:uma:default:CollectionHandler" },
+ "path": "/uma/collections/{id}"
+ }
+ ]
+}
diff --git a/packages/uma/src/index.ts b/packages/uma/src/index.ts
index 090b23a..c8819b4 100644
--- a/packages/uma/src/index.ts
+++ b/packages/uma/src/index.ts
@@ -52,6 +52,7 @@ export * from './routes/VC';
export * from './routes/Contract';
export * from './routes/BaseHandler';
export * from './routes/ClientRegistration';
+export * from './routes/Collection';
// Tickets
export * from './ticketing/Ticket';
diff --git a/packages/uma/src/routes/Collection.ts b/packages/uma/src/routes/Collection.ts
new file mode 100644
index 0000000..1226def
--- /dev/null
+++ b/packages/uma/src/routes/Collection.ts
@@ -0,0 +1,229 @@
+import { Quad } from '@rdfjs/types';
+import {
+ BadRequestHttpError,
+ createErrorMessage,
+ ForbiddenHttpError,
+ joinUrl,
+ KeyValueStorage,
+ MethodNotAllowedHttpError,
+ NotFoundHttpError,
+ RDF
+} from '@solid/community-server';
+import { getLoggerFor } from 'global-logger-factory';
+import { DataFactory as DF, NamedNode, Store } from 'n3';
+import { randomUUID } from 'node:crypto';
+import { ODRL } from 'odrl-evaluator';
+import { WEBID } from '../credentials/Claims';
+import { CredentialParser } from '../credentials/CredentialParser';
+import { Verifier } from '../credentials/verify/Verifier';
+import { UCRulesStorage } from '../ucp/storage/UCRulesStorage';
+import { DC, ODRL_P, OWL } from '../ucp/util/Vocabularies';
+import { writeStore } from '../util/ConvertUtil';
+import {
+ HttpHandler,
+ HttpHandlerContext,
+ HttpHandlerRequest,
+ HttpHandlerResponse
+} from '../util/http/models/HttpHandler';
+import { array, optional as $, reType, string, Type, union } from '../util/ReType';
+
+export const AssetCollectionDescription = {
+ description: $(string),
+ type: 'asset' as const,
+ parts: array(string),
+}
+export type AssetCollectionDescription = Type;
+
+export const PartyCollectionDescription = {
+ description: $(string),
+ type: 'party' as const,
+ owners: array(string),
+ parts: array(string),
+}
+export type PartyCollectionDescription = Type;
+
+export const CollectionDescription = union(AssetCollectionDescription, PartyCollectionDescription);
+export type CollectionDescription = Type;
+
+/**
+ * Handles all CRUD interactions for collections.
+ */
+export class CollectionRequestHandler extends HttpHandler {
+ protected readonly logger = getLoggerFor(this);
+
+ public constructor(
+ protected readonly credentialParser: CredentialParser,
+ protected readonly verifier: Verifier,
+ protected readonly ownershipStore: KeyValueStorage,
+ protected readonly policies: UCRulesStorage,
+ ) {
+ super();
+ }
+
+ public async handle({ request }: HttpHandlerContext): Promise> {
+ const credential = await this.credentialParser.handleSafe(request);
+ const claims = await this.verifier.verify(credential);
+ const userId = claims[WEBID];
+ if (typeof userId !== 'string') {
+ throw new ForbiddenHttpError(`Missing claim ${WEBID}.`);
+ }
+
+ switch (request.method) {
+ case 'GET': return this.handleGet(request, userId);
+ case 'POST': return this.handlePost(request, userId);
+ case 'PUT': return this.handlePut(request, userId);
+ case 'DELETE': return this.handleDelete(request, userId);
+ default: throw new MethodNotAllowedHttpError([ request.method ]);
+ }
+ }
+
+ protected async handleGet(request: HttpHandlerRequest, userId: string): Promise {
+ const collections: NamedNode[] = [];
+ const userNode = DF.namedNode(userId);
+ const store = await this.policies.getStore();
+ // A parsed ID indicates that this is not the root collection URL, so a specific collection is being targeted
+ if (request.parameters?.id) {
+ // Verify ownership
+ const subject = DF.namedNode(request.url.href);
+ await this.verifyOwnership(subject, userId, store);
+ collections.push(subject);
+ } else {
+ collections.push(...store.getSubjects(DC.terms.creator, userNode, null) as NamedNode[]);
+ }
+
+ const result = new Store();
+ for (const collection of collections) {
+ result.addQuads(store.getQuads(collection, null, null, null));
+ result.addQuads(store.getQuads(null, ODRL.terms.partOf, collection, null));
+ // Inverse relations triples are not covered by the above and need to additionally be added
+ const relations = result.getObjects(collection, ODRL_P.terms.relation, null);
+ for (const relation of relations) {
+ result.addQuads(store.getQuads(relation, OWL.terms.inverseOf, null, null));
+ }
+ }
+
+ return {
+ status: 200,
+ headers: { 'content-type': 'text/turtle' },
+ body: await writeStore(result, { dc: DC.terms.namespace, odrl: ODRL.terms.namespace }),
+ }
+ }
+
+ protected async handlePost(request: HttpHandlerRequest, userId: string): Promise {
+ const subject = DF.namedNode(joinUrl(request.url.href, randomUUID()));
+ const quads = await this.descriptionToQuads(request.body, userId, subject);
+ const store = new Store(quads);
+
+ // Store collection triples
+ await this.policies.addRule(store);
+
+ return {
+ status: 201,
+ headers: { location: subject.value },
+ };
+ }
+
+ protected async handlePut(request: HttpHandlerRequest, userId: string): Promise {
+ if (!request.parameters?.id) {
+ throw new MethodNotAllowedHttpError([ 'PUT' ]);
+ }
+
+ const subject = DF.namedNode(request.url.href);
+ const policyStore = await this.policies.getStore();
+ if (policyStore.getObjects(subject, ODRL_P.terms.relation, null).length > 0) {
+ throw new ForbiddenHttpError(`Relation collections can not be modified`);
+ }
+ await this.verifyOwnership(subject, userId, policyStore);
+
+ const newStore = new Store(await this.descriptionToQuads(request.body, userId, subject));
+ const oldStore = new Store([
+ ...policyStore.getQuads(subject, null, null, null),
+ ...policyStore.getQuads(null, ODRL.terms.partOf, subject, null)
+ ]);
+
+ const add = newStore.difference(oldStore);
+ const remove = oldStore.difference(newStore);
+
+ if (add.size > 0) {
+ await this.policies.addRule(add as Store);
+ }
+ if (remove.size > 0) {
+ await this.policies.removeData(remove as Store);
+ }
+
+ return { status: 204 };
+ }
+
+ protected async handleDelete(request: HttpHandlerRequest, userId: string): Promise {
+ if (!request.parameters?.id) {
+ throw new MethodNotAllowedHttpError([ 'DELETE' ]);
+ }
+ const subject = DF.namedNode(request.url.href);
+ const policyStore = await this.policies.getStore();
+ if (policyStore.getObjects(subject, ODRL_P.terms.relation, null).length > 0) {
+ throw new ForbiddenHttpError(`Relation collections can not be modified`);
+ }
+ await this.verifyOwnership(subject, userId, policyStore);
+
+ const collectionQuads = new Store([
+ ...policyStore.getQuads(subject, null, null, null),
+ ...policyStore.getQuads(null, ODRL.terms.partOf, subject, null)
+ ]);
+
+ await this.policies.removeData(collectionQuads);
+
+ return { status: 204 };
+ }
+
+ /**
+ * Verifies if the user is allowed to modify the given collection.
+ */
+ protected async verifyOwnership(subject: NamedNode, userId: string, store?: Store): Promise {
+ const userNode = DF.namedNode(userId);
+ store = store ?? await this.policies.getStore();
+
+ const owners = store.getObjects(subject, DC.terms.creator, null);
+ if (owners.length === 0) {
+ throw new NotFoundHttpError();
+ }
+ if (!owners.some(owner => owner.equals(userNode))) {
+ throw new ForbiddenHttpError(`${userId} is not an owner of this collection`);
+ }
+ }
+
+ protected async descriptionToQuads(body: unknown, userId: string, subject: NamedNode): Promise {
+ try {
+ reType(body, CollectionDescription);
+ } catch (e) {
+ this.logger.warn(`Syntax error: ${createErrorMessage(e)}, ${body}`);
+ throw new BadRequestHttpError(`Request has bad syntax: ${createErrorMessage(e)}`);
+ }
+
+ if (body.type === 'party') {
+ // Make sure the user is in the owners array
+ if (!body.owners.includes(userId)) {
+ this.logger.warn(`Trying to make a collection where the requester, ${userId}, is not an owner`);
+ throw new BadRequestHttpError(
+ 'To prevent being locked out, the identity performing this request needs to be an owner');
+ }
+ } else {
+ const owned = await this.ownershipStore.get(userId) ?? [];
+ for (const asset of body.parts) {
+ if (!owned.includes(asset)) {
+ this.logger.warn(`Creating asset collection with unowned resource ${asset}`);
+ throw new ForbiddenHttpError(`Creating asset collection with unowned resource ${asset}`);
+ }
+ }
+ }
+
+ // Generate all necessary collection triples
+ return [
+ DF.quad(subject, RDF.terms.type, body.type === 'asset' ? ODRL.terms.AssetCollection : ODRL.terms.PartyCollection),
+ ...body.description ? [ DF.quad(subject, DC.terms.description, DF.literal(body.description)) ] : [],
+ ...body.type === 'party' ?
+ body.owners.map(owner => DF.quad(subject, DC.terms.creator, DF.namedNode(owner))) :
+ [ DF.quad(subject, DC.terms.creator, DF.namedNode(userId)) ],
+ ...body.parts.map(part => DF.quad(DF.namedNode(part), ODRL.terms.partOf, subject)),
+ ];
+ }
+}
diff --git a/packages/uma/src/routes/ResourceRegistration.ts b/packages/uma/src/routes/ResourceRegistration.ts
index e3c4dd9..eaf50d4 100644
--- a/packages/uma/src/routes/ResourceRegistration.ts
+++ b/packages/uma/src/routes/ResourceRegistration.ts
@@ -14,7 +14,7 @@ import { getLoggerFor } from 'global-logger-factory';
import { DataFactory as DF, NamedNode, Quad, Quad_Subject, Store } from 'n3';
import { randomUUID } from 'node:crypto';
import { UCRulesStorage } from '../ucp/storage/UCRulesStorage';
-import { ODRL, ODRL_P, OWL } from '../ucp/util/Vocabularies';
+import { DC, ODRL, ODRL_P, OWL } from '../ucp/util/Vocabularies';
import {
HttpHandler,
HttpHandlerContext,
@@ -175,9 +175,11 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
throw new ForbiddenHttpError(`${owner} is not the owner of this resource.`);
}
+ // Remove registration
await this.registrationStore.delete(parameters.id);
this.logger.info(`Deleted resource ${parameters.id}.`);
+ // Remove references from ownership store
const ownedResources = await this.ownershipStore.get(owner) ?? [];
const idx = ownedResources.indexOf(parameters.id);
if (idx >= 0) {
@@ -188,6 +190,12 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
await this.ownershipStore.set(owner, ownedResources);
}
}
+
+ // Remove from collections
+ const store = await this.policies.getStore()
+ const remove = new Store(store.getQuads(parameters.id, ODRL.terms.partOf, null, null));
+ await this.policies.removeData(remove);
+
return ({ status: 204 });
}
@@ -201,7 +209,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
protected async setResourceMetadata(id: string, description: ResourceDescription, owner: string,
previous?: ResourceDescription): Promise {
const policyStore = await this.policies.getStore();
- const collectionQuads = await this.updateCollections(policyStore, id, description, previous);
+ const collectionQuads = await this.updateCollections(policyStore, id, owner, description, previous);
const relationQuads = await this.updateRelations(policyStore, id, description, previous);
const addQuads = [ ...collectionQuads.add, ...relationQuads.add ];
if (addQuads.length > 0) {
@@ -235,12 +243,14 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
*
* @param policyStore - RDF store that contains all the know collection metadata.
* @param id - The identifier of the resource.
+ * @param owner - The owner of the resource.
* @param description - The new {@link ResourceDescription} for the resource.
* @param previous - The previous {@link ResourceDescription}, in case this is an update.
*/
protected async updateCollections(
policyStore: Store,
id: string,
+ owner: string,
description: ResourceDescription,
previous?: ResourceDescription
): Promise<{ add: Quad[], remove: Quad[] }> {
@@ -262,7 +272,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
delete add[key];
} else {
// TODO: currently generating fixed collection ID as there is no API to get them
- addQuads.push(...this.generateCollectionTriples(entry, DF.namedNode(`collection:${entry.reverse ?
+ addQuads.push(...this.generateCollectionTriples(entry, owner, DF.namedNode(`collection:${entry.reverse ?
entry.relation.value + ':' + entry.source.value :
entry.source.value + ':' + entry.relation.value
}`)));
@@ -278,7 +288,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
if (policyStore.countQuads(null, ODRL.terms.partOf, collection, null) > 0) {
throw new ConflictHttpError(`Unable to remove collection ${collection.value} as it is not empty.`);
}
- removeQuads.push(...this.generateCollectionTriples(entry, collection));
+ removeQuads.push(...this.generateCollectionTriples(entry, owner, collection));
}
}
@@ -447,11 +457,12 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
* Generates all the triples necessary for an asset collection based on a relation.
* If no ID is provided for the collection, a new one will be minted.
*/
- protected generateCollectionTriples(entry: CollectionMetadata, id?: Quad_Subject): Quad[] {
+ protected generateCollectionTriples(entry: CollectionMetadata, owner: string, id?: Quad_Subject): Quad[] {
const result: Quad[] = [];
const collectionId = id ?? DF.namedNode(`collection:${randomUUID()}`);
result.push(DF.quad(collectionId, RDF.terms.type, ODRL.terms.AssetCollection));
result.push(DF.quad(collectionId, ODRL.terms.source, entry.source));
+ result.push(DF.quad(collectionId, DC.terms.creator, DF.namedNode(owner)));
if (entry.reverse) {
const blank = DF.blankNode();
result.push(DF.quad(collectionId, ODRL_P.terms.relation, blank));
diff --git a/packages/uma/src/ucp/util/Vocabularies.ts b/packages/uma/src/ucp/util/Vocabularies.ts
index d5ff690..2226bed 100644
--- a/packages/uma/src/ucp/util/Vocabularies.ts
+++ b/packages/uma/src/ucp/util/Vocabularies.ts
@@ -1,108 +1,7 @@
-import { DataFactory } from 'n3';
-import type { NamedNode } from '@rdfjs/types';
-
-// shameless copy of the Community Solid Server Vocabularies
-/**
- * A `Record` in which each value is a concatenation of the baseUrl and its key.
- */
-type ExpandedRecord = {[K in TLocal]: `${TBase}${K}` };
-
-/**
- * Has a base URL as `namespace` value and each key has as value the concatenation with that base URL.
- */
-type ValueVocabulary =
- { namespace: TBase } & ExpandedRecord;
-/**
- * A {@link ValueVocabulary} where the URI values are {@link NamedNode}s.
- */
-type TermVocabulary = T extends ValueVocabulary ? {[K in keyof T]: NamedNode } : never;
-
-/**
- * Contains a namespace and keys linking to the entries in this namespace.
- * The `terms` field contains the same values but as {@link NamedNode} instead of string.
- */
-export type Vocabulary =
- ValueVocabulary & { terms: TermVocabulary> };
-
-/**
- * A {@link Vocabulary} where all the non-namespace fields are of unknown value.
- * This is a fallback in case {@link createVocabulary} gets called with a non-strict string array.
- */
-export type PartialVocabulary =
- { namespace: TBase } &
- Partial> &
- { terms: { namespace: NamedNode } & Partial> };
-
-/**
- * A local name of a {@link Vocabulary}.
- */
-export type VocabularyLocal = T extends Vocabulary ? TKey : never;
-/**
- * A URI string entry of a {@link Vocabulary}.
- */
-export type VocabularyValue = T extends Vocabulary ? T[TKey] : never;
-/**
- * A {@link NamedNode} entry of a {@link Vocabulary}.
- */
-export type VocabularyTerm = T extends Vocabulary ? T['terms'][TKey] : never;
-
-/**
- * Creates a {@link ValueVocabulary} with the given `baseUri` as namespace and all `localNames` as entries.
- */
-function createValueVocabulary(baseUri: TBase, localNames: TLocal[]):
-ValueVocabulary {
- const expanded: Partial> = {};
- // Expose the listed local names as properties
- for (const localName of localNames) {
- expanded[localName] = `${baseUri}${localName}`;
- }
- return {
- namespace: baseUri,
- ...expanded as ExpandedRecord,
- };
-}
-
-/**
- * Creates a {@link TermVocabulary} based on the provided {@link ValueVocabulary}.
- */
-function createTermVocabulary(values: ValueVocabulary):
-TermVocabulary> {
- // Need to cast since `fromEntries` typings aren't strict enough
- return Object.fromEntries(
- Object.entries(values).map(([ key, value ]): [string, NamedNode] => [ key, DataFactory.namedNode(value) ]),
- ) as TermVocabulary>;
-}
-
-/**
- * Creates a {@link Vocabulary} with the given `baseUri` as namespace and all `localNames` as entries.
- * The values are the local names expanded from the given base URI as strings.
- * The `terms` field contains all the same values but as {@link NamedNode} instead.
- */
-export function createVocabulary(baseUri: TBase, ...localNames: TLocal[]):
-string extends TLocal ? PartialVocabulary : Vocabulary {
- const values = createValueVocabulary(baseUri, localNames);
- return {
- ...values,
- terms: createTermVocabulary(values),
- };
-}
-
-/**
- * Creates a new {@link Vocabulary} that extends an existing one by adding new local names.
- * @param vocabulary - The {@link Vocabulary} to extend.
- * @param newNames - The new local names that need to be added.
- */
-export function extendVocabulary(
- vocabulary: Vocabulary,
- ...newNames: TNew[]
-):
- ReturnType> {
- const localNames = Object.keys(vocabulary)
- .filter((key): boolean => key !== 'terms' && key !== 'namespace') as TLocal[];
- const allNames = [ ...localNames, ...newNames ];
- return createVocabulary(vocabulary.namespace, ...allNames);
-}
+import { DC as DC_CSS } from '@solid/community-server';
+import { createVocabulary, extendVocabulary } from 'rdf-vocabulary';
+export const DC = extendVocabulary(DC_CSS,'creator');
export const ODRL = createVocabulary(
'http://www.w3.org/ns/odrl/2/',
diff --git a/packages/uma/src/util/ConvertUtil.ts b/packages/uma/src/util/ConvertUtil.ts
index 00f05d6..ea4c2b6 100644
--- a/packages/uma/src/util/ConvertUtil.ts
+++ b/packages/uma/src/util/ConvertUtil.ts
@@ -1,5 +1,6 @@
-import { Store, Writer } from 'n3';
+import { Prefixes, Store, Writer } from 'n3';
import { parse, stringify } from 'node:querystring';
+import { NamedNode } from '@rdfjs/types';
/**
* Converts a x-www-form-urlencoded string to a JSON object.
@@ -48,8 +49,8 @@ export function isIri(input: string): boolean {
/**
* Write an N3 store to a string (in turtle format)
*/
-export async function writeStore(store: Store): Promise {
- const writer = new Writer({ format: 'text/turtle' });
+export async function writeStore(store: Store, prefixes: Prefixes = {}): Promise {
+ const writer = new Writer({ format: 'text/turtle', prefixes });
writer.addQuads(store.getQuads(null, null, null, null));
return new Promise((resolve, reject) => {
diff --git a/packages/uma/test/unit/routes/Collection.test.ts b/packages/uma/test/unit/routes/Collection.test.ts
new file mode 100644
index 0000000..1ef6c36
--- /dev/null
+++ b/packages/uma/test/unit/routes/Collection.test.ts
@@ -0,0 +1,421 @@
+import 'jest-rdf';
+import { ForbiddenHttpError, KeyValueStorage, NotFoundHttpError } from '@solid/community-server';
+import { Parser, Store } from 'n3';
+import { ODRL } from 'odrl-evaluator';
+import { Mocked } from 'vitest';
+import { WEBID } from '../../../src/credentials/Claims';
+import { CredentialParser } from '../../../src/credentials/CredentialParser';
+import { Verifier } from '../../../src/credentials/verify/Verifier';
+import {
+ AssetCollectionDescription,
+ CollectionRequestHandler,
+ PartyCollectionDescription
+} from '../../../src/routes/Collection';
+import { UCRulesStorage } from '../../../src/ucp/storage/UCRulesStorage';
+import { HttpHandlerRequest } from '../../../src/util/http/models/HttpHandler';
+
+describe('Collection', (): void => {
+ const userId = 'user';
+ const otherUserId = 'otherUserId';
+ const ownedResource = 'http://example.com/ownedResource';
+ const otherOwnedResource = 'http://example.com/otherOwnedResource';
+ const unownedResource = 'http://example.com/unownedResource';
+ let request: HttpHandlerRequest;
+ let policyStore = new Store();
+
+ let credentialParser: Mocked;
+ let verifier: Mocked;
+ let ownershipStore: Mocked>;
+ let policies: Mocked;
+ let handler: CollectionRequestHandler;
+
+ beforeEach(async(): Promise => {
+ request = {
+ url: new URL('http://example.com/collections'),
+ method: 'GET',
+ headers: {},
+ };
+
+ credentialParser = {
+ handleSafe: vi.fn().mockResolvedValue({}),
+ } satisfies Partial as any;
+
+ verifier = {
+ verify: vi.fn().mockResolvedValue({ [WEBID]: userId }),
+ } satisfies Partial as any;
+
+ ownershipStore = {
+ get: vi.fn().mockResolvedValue([ ownedResource, otherOwnedResource ]),
+ } satisfies Partial> as any;
+
+ policyStore = new Store();
+ policies = {
+ getStore: vi.fn().mockResolvedValue(policyStore),
+ addRule: vi.fn(async(store) => policyStore.addQuads([...store])),
+ removeData: vi.fn(async(store) => policyStore.removeQuads([...store])),
+ } satisfies Partial as any;
+
+ handler = new CollectionRequestHandler(credentialParser, verifier, ownershipStore, policies);
+ });
+
+ describe('GET', (): void => {
+ beforeEach(async(): Promise => {
+ request.method = 'GET';
+
+ const collectionTurtle = `
+ @prefix dc: .
+ @prefix odrl: .
+ @prefix odrl_p: .
+ @prefix owl: .
+ a odrl:AssetCollection ;
+ dc:description "My assets" ;
+ dc:creator <${userId}> .
+ odrl:partOf .
+ odrl:partOf .
+
+ a odrl:AssetCollection ;
+ odrl:source ;
+ dc:description "My relation assets" ;
+ odrl_p:relation [ owl:inverseOf ] ;
+ dc:creator <${userId}> .
+ odrl:partOf .
+ odrl:partOf .
+
+ a odrl:AssetCollection ;
+ dc:description "My assets" ;
+ dc:creator <${otherUserId}> .
+ odrl:partOf .
+
+ a odrl:PartyCollection ;
+ dc:description "My party" ;
+ dc:creator <${userId}> .
+ <${userId}> odrl:partOf .
+ <${otherUserId}> odrl:partOf .
+ `;
+
+ policies.getStore.mockResolvedValue(new Store(new Parser().parse(collectionTurtle)));
+ });
+
+ it('returns all collections owned by the user.', async(): Promise => {
+ const response = await handler.handle({ request });
+ expect(response.status).toBe(200);
+ expect(response.headers?.['content-type']).toBe('text/turtle');
+ expect(new Parser().parse(response.body)).toBeRdfIsomorphic(new Parser().parse(`
+ @prefix dc: .
+ @prefix odrl: .
+ @prefix odrl_p: .
+ @prefix owl: .
+ a odrl:AssetCollection ;
+ dc:description "My assets" ;
+ dc:creator <${userId}> .
+ odrl:partOf .
+ odrl:partOf .
+
+ a odrl:AssetCollection ;
+ odrl:source ;
+ dc:description "My relation assets" ;
+ odrl_p:relation [ owl:inverseOf ] ;
+ dc:creator <${userId}> .
+ odrl:partOf .
+ odrl:partOf .
+
+ a odrl:PartyCollection ;
+ dc:description "My party" ;
+ dc:creator <${userId}> .
+ <${userId}> odrl:partOf .
+ <${otherUserId}> odrl:partOf .
+ `));
+ });
+
+ it('returns a single collection when requested.', async(): Promise => {
+ request.parameters = { id: 'assets' };
+ request.url = new URL('http://example.com/assets');
+ const response = await handler.handle({ request });
+ expect(response.status).toBe(200);
+ expect(response.headers?.['content-type']).toBe('text/turtle');
+ expect(new Parser().parse(response.body)).toBeRdfIsomorphic(new Parser().parse(`
+ @prefix dc: .
+ @prefix odrl: .
+ a odrl:AssetCollection ;
+ dc:description "My assets" ;
+ dc:creator <${userId}> .
+ odrl:partOf .
+ odrl:partOf .
+ `));
+ });
+
+ it('returns 404 for unknown collections.', async(): Promise => {
+ request.parameters = { id: 'unknown' };
+ request.url = new URL('http://example.com/unknown');
+ await expect(handler.handle({ request })).rejects.toThrow(NotFoundHttpError);
+ });
+
+ it('returns 403 when the user is not an owner of the collection.', async(): Promise => {
+ request.parameters = { id: 'assetsOther' };
+ request.url = new URL('http://example.com/assetsOther');
+ await expect(handler.handle({ request })).rejects.toThrow(ForbiddenHttpError);
+ });
+ });
+
+ describe('POST', (): void => {
+ beforeEach(async(): Promise => {
+ request.method = 'POST';
+ });
+
+ it('creates a new party collection.', async(): Promise => {
+ request.body = {
+ description: "My party",
+ type: "party",
+ owners: [ userId ],
+ parts: [ userId, otherUserId ],
+ } satisfies PartyCollectionDescription;
+
+ const response = await handler.handle({ request });
+ expect(response.status).toBe(201);
+ expect(response.headers?.location).toMatch(/^http:\/\/example\.com\/collections\/.+/);
+ expect(policyStore).toBeRdfIsomorphic(new Parser().parse(`
+ @prefix dc: .
+ @prefix odrl: .
+ <${response.headers?.location}> a odrl:PartyCollection ;
+ dc:description "My party" ;
+ dc:creator <${userId}> .
+ <${userId}> odrl:partOf <${response.headers?.location}> .
+ <${otherUserId}> odrl:partOf <${response.headers?.location}> .
+ `));
+ });
+
+ it('creates a new asset collection.', async(): Promise => {
+ request.body = {
+ description: "My assets",
+ type: "asset",
+ parts: [ ownedResource ],
+ } satisfies AssetCollectionDescription;
+
+ const response = await handler.handle({ request });
+ expect(response.status).toBe(201);
+ expect(response.headers?.location).toMatch(/^http:\/\/example\.com\/collections\/.+/);
+ expect(policyStore).toBeRdfIsomorphic(new Parser().parse(`
+ @prefix dc: .
+ @prefix odrl: .
+ <${response.headers?.location}> a odrl:AssetCollection ;
+ dc:description "My assets" ;
+ dc:creator <${userId}> .
+ <${ownedResource}> odrl:partOf <${response.headers?.location}> .
+ `));
+ });
+
+ it('requires the user to be the owner of a party collection.', async(): Promise => {
+ request.body = {
+ description: "My party",
+ type: "party",
+ owners: [ otherUserId ],
+ parts: [ userId, otherUserId ],
+ } satisfies PartyCollectionDescription;
+
+ await expect(handler.handle({ request })).rejects
+ .toThrow('To prevent being locked out, the identity performing this request needs to be an owner');
+ });
+
+ it('requires the user to be the resource owner for party collections.', async(): Promise => {
+ request.body = {
+ description: "My assets",
+ type: "asset",
+ parts: [ ownedResource, unownedResource ],
+ } satisfies AssetCollectionDescription;
+
+ await expect(handler.handle({ request })).rejects
+ .toThrow(`Creating asset collection with unowned resource ${unownedResource}`);
+ });
+ });
+
+ describe('PUT', (): void => {
+ beforeEach(async(): Promise => {
+ request.method = 'PUT';
+
+ const collectionTurtle = `
+ @prefix dc: .
+ @prefix odrl: .
+ @prefix odrl_p: .
+ @prefix owl: .
+ a odrl:AssetCollection ;
+ dc:description "My assets" ;
+ dc:creator <${userId}> .
+ odrl:partOf .
+ odrl:partOf .
+
+ a odrl:AssetCollection ;
+ odrl:source ;
+ dc:description "My relation assets" ;
+ odrl_p:relation [ owl:inverseOf ] ;
+ dc:creator <${userId}> .
+ odrl:partOf .
+ odrl:partOf .
+
+ a odrl:AssetCollection ;
+ dc:description "My assets" ;
+ dc:creator <${otherUserId}> .
+ odrl:partOf .
+
+ a odrl:PartyCollection ;
+ dc:description "My party" ;
+ dc:creator <${userId}> .
+ <${userId}> odrl:partOf .
+ <${otherUserId}> odrl:partOf .
+ `;
+ policyStore.addQuads(new Parser().parse(collectionTurtle));
+ });
+
+ it('can update an existing party collection.', async(): Promise => {
+ request.parameters = { id: 'party' };
+ request.url = new URL('http://example.com/party');
+ request.body = {
+ description: "My other party",
+ type: "party",
+ owners: [ userId, otherUserId ],
+ parts: [ userId ],
+ } satisfies PartyCollectionDescription;
+
+ const response = await handler.handle({ request });
+ expect(response.status).toBe(204);
+ expect(policyStore.getQuads('http://example.com/party', null, null, null)).toBeRdfIsomorphic(new Parser().parse(`
+ @prefix dc: .
+ @prefix odrl: .
+ a odrl:PartyCollection ;
+ dc:description "My other party" ;
+ dc:creator <${userId}>, <${otherUserId}> .
+ `));
+ expect(policyStore.countQuads(userId, ODRL.terms.partOf, 'http://example.com/party', null)).toBe(1);
+ expect(policyStore.countQuads(otherUserId, ODRL.terms.partOf, 'http://example.com/party', null)).toBe(0);
+ });
+
+ it('can update an existing asset collection.', async(): Promise => {
+ request.parameters = { id: 'assets' };
+ request.url = new URL('http://example.com/assets');
+ request.body = {
+ description: "My other assets",
+ type: "asset",
+ parts: [ ownedResource, otherOwnedResource ],
+ } satisfies AssetCollectionDescription;
+
+ const response = await handler.handle({ request });
+ expect(response.status).toBe(204);
+ expect(policyStore.getQuads('http://example.com/assets', null, null, null)).toBeRdfIsomorphic(new Parser().parse(`
+ @prefix dc: .
+ @prefix odrl: .
+ a odrl:AssetCollection ;
+ dc:description "My other assets" ;
+ dc:creator <${userId}> .
+ `));
+ expect(policyStore.countQuads(ownedResource, ODRL.terms.partOf, 'http://example.com/assets', null)).toBe(1);
+ expect(policyStore.countQuads(otherOwnedResource, ODRL.terms.partOf, 'http://example.com/assets', null)).toBe(1);
+ });
+
+ it('requires the user to be the owner of a party collection.', async(): Promise => {
+ request.parameters = { id: 'party' };
+ request.url = new URL('http://example.com/party');
+ request.body = {
+ description: "My party",
+ type: "party",
+ owners: [ otherUserId ],
+ parts: [ userId, otherUserId ],
+ } satisfies PartyCollectionDescription;
+
+ await expect(handler.handle({ request })).rejects
+ .toThrow('To prevent being locked out, the identity performing this request needs to be an owner');
+ });
+
+ it('requires the user to be the resource owner for party collections.', async(): Promise => {
+ request.parameters = { id: 'assets' };
+ request.url = new URL('http://example.com/assets');
+ request.body = {
+ description: "My assets",
+ type: "asset",
+ parts: [ ownedResource, unownedResource ],
+ } satisfies AssetCollectionDescription;
+
+ await expect(handler.handle({ request })).rejects
+ .toThrow(`Creating asset collection with unowned resource ${unownedResource}`);
+ });
+
+ it('can not modify relation collections.', async(): Promise => {
+ request.parameters = { id: 'relation' };
+ request.url = new URL('http://example.com/relation');
+ request.body = {
+ description: "My assets",
+ type: "asset",
+ parts: [ ownedResource ],
+ } satisfies AssetCollectionDescription;
+
+ await expect(handler.handle({ request })).rejects.toThrow(`Relation collections can not be modified`);
+ });
+ });
+
+ describe('DELETE', (): void => {
+ beforeEach(async(): Promise => {
+ request.method = 'DELETE';
+
+ const collectionTurtle = `
+ @prefix dc: .
+ @prefix odrl: .
+ @prefix odrl_p: .
+ @prefix owl: .
+ a odrl:AssetCollection ;
+ dc:description "My assets" ;
+ dc:creator <${userId}> .
+ odrl:partOf .
+ odrl:partOf .
+
+ a odrl:AssetCollection ;
+ odrl:source ;
+ dc:description "My relation assets" ;
+ odrl_p:relation [ owl:inverseOf ] ;
+ dc:creator <${userId}> .
+ odrl:partOf .
+ odrl:partOf .
+
+ a odrl:AssetCollection ;
+ dc:description "My assets" ;
+ dc:creator <${otherUserId}> .
+ odrl:partOf .
+
+ a odrl:PartyCollection ;
+ dc:description "My party" ;
+ dc:creator <${userId}> .
+ <${userId}> odrl:partOf .
+ <${otherUserId}> odrl:partOf .
+ `;
+ policyStore.addQuads(new Parser().parse(collectionTurtle));
+ });
+
+ it('can delete collections.', async(): Promise => {
+ request.parameters = { id: 'party' };
+ request.url = new URL('http://example.com/party');
+
+ const response = await handler.handle({ request });
+ expect(response.status).toBe(204);
+ expect(policyStore.countQuads('http://example.com/party', null, null, null)).toBe(0);
+ expect(policyStore.countQuads(null, null, null, 'http://example.com/party')).toBe(0);
+ });
+
+ it('returns a 404 if the collection does not exist.', async(): Promise => {
+ request.parameters = { id: 'unknown' };
+ request.url = new URL('http://example.com/unknown');
+
+ await expect(handler.handle({ request })).rejects.toThrow(NotFoundHttpError);
+ });
+
+ it('returns a 403 if the user is not an owner of the collection.', async(): Promise => {
+ request.parameters = { id: 'assetsOther' };
+ request.url = new URL('http://example.com/assetsOther');
+
+ await expect(handler.handle({ request })).rejects.toThrow(ForbiddenHttpError);
+ });
+
+ it('can not delete relation collections.', async(): Promise => {
+ request.parameters = { id: 'relation' };
+ request.url = new URL('http://example.com/relation');
+
+ await expect(handler.handle({ request })).rejects.toThrow(ForbiddenHttpError);
+ });
+ });
+});
diff --git a/packages/uma/test/unit/routes/ResourceRegistration.test.ts b/packages/uma/test/unit/routes/ResourceRegistration.test.ts
index f3ea388..6bfc5db 100644
--- a/packages/uma/test/unit/routes/ResourceRegistration.test.ts
+++ b/packages/uma/test/unit/routes/ResourceRegistration.test.ts
@@ -11,7 +11,7 @@ import { ODRL } from 'odrl-evaluator';
import { Mocked } from 'vitest';
import { ResourceRegistrationRequestHandler } from '../../../src/routes/ResourceRegistration';
import { UCRulesStorage } from '../../../src/ucp/storage/UCRulesStorage';
-import { ODRL_P, OWL } from '../../../src/ucp/util/Vocabularies';
+import { DC, ODRL_P, OWL } from '../../../src/ucp/util/Vocabularies';
import { HttpHandlerContext } from '../../../src/util/http/models/HttpHandler';
import { RequestValidator } from '../../../src/util/http/validate/RequestValidator';
import { RegistrationStore } from '../../../src/util/RegistrationStore';
@@ -157,9 +157,11 @@ describe('ResourceRegistration', (): void => {
const newStore = policies.addRule.mock.calls[0][0];
expect(newStore).toBeRdfIsomorphic([
DF.quad(DF.namedNode('collection:name:pred'), RDF.terms.type, ODRL.terms.AssetCollection),
+ DF.quad(DF.namedNode('collection:name:pred'), DC.terms.creator, DF.namedNode(owner)),
DF.quad(DF.namedNode('collection:name:pred'), ODRL.terms.source, DF.namedNode('name')),
DF.quad(DF.namedNode('collection:name:pred'), ODRL_P.terms.relation, DF.namedNode('pred')),
DF.quad(DF.namedNode('collection:rPred:name'), RDF.terms.type, ODRL.terms.AssetCollection),
+ DF.quad(DF.namedNode('collection:rPred:name'), DC.terms.creator, DF.namedNode(owner)),
DF.quad(DF.namedNode('collection:rPred:name'), ODRL.terms.source, DF.namedNode('name')),
DF.quad(DF.namedNode('collection:rPred:name'), ODRL_P.terms.relation, DF.blankNode('n3-0')),
DF.quad(DF.blankNode('n3-0'), OWL.terms.inverseOf, DF.namedNode('rPred')),
@@ -265,9 +267,11 @@ describe('ResourceRegistration', (): void => {
const newStore = policies.addRule.mock.calls[0][0];
expect(newStore).toBeRdfIsomorphic([
DF.quad(DF.namedNode('collection:name:pred'), RDF.terms.type, ODRL.terms.AssetCollection),
+ DF.quad(DF.namedNode('collection:name:pred'), DC.terms.creator, DF.namedNode(owner)),
DF.quad(DF.namedNode('collection:name:pred'), ODRL.terms.source, DF.namedNode('name')),
DF.quad(DF.namedNode('collection:name:pred'), ODRL_P.terms.relation, DF.namedNode('pred')),
DF.quad(DF.namedNode('collection:rPred:name'), RDF.terms.type, ODRL.terms.AssetCollection),
+ DF.quad(DF.namedNode('collection:rPred:name'), DC.terms.creator, DF.namedNode(owner)),
DF.quad(DF.namedNode('collection:rPred:name'), ODRL.terms.source, DF.namedNode('name')),
DF.quad(DF.namedNode('collection:rPred:name'), ODRL_P.terms.relation, DF.blankNode('n3-0')),
DF.quad(DF.blankNode('n3-0'), OWL.terms.inverseOf, DF.namedNode('rPred')),
diff --git a/test/integration/Collections.test.ts b/test/integration/Collections.test.ts
index b6e88db..1af3123 100644
--- a/test/integration/Collections.test.ts
+++ b/test/integration/Collections.test.ts
@@ -1,5 +1,8 @@
-import { App, joinUrl } from '@solid/community-server';
+import 'jest-rdf';
+import { App, RDF } from '@solid/community-server';
+import { DC, ODRL, ODRL_P } from '@solidlab/uma';
import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory';
+import { Parser, Quad_Subject, Store } from 'n3';
import path from 'node:path';
import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil';
import { generateCredentials, umaFetch } from '../util/UmaUtil';
@@ -9,6 +12,9 @@ const [ cssPort, umaPort ] = getPorts('Collections');
describe('A server with collections', (): void => {
const owner = `http://localhost:${cssPort}/alice/profile/card#me`;
const user = `http://example.com/bob`;
+ const user2 = `http://example.com/carol`;
+ let assetCollection: string;
+ let partyCollection: string;
let umaApp: App;
let cssApp: App;
@@ -52,13 +58,12 @@ describe('A server with collections', (): void => {
});
});
- it('can create a policy targeting an asset collection.', async(): Promise => {
- // TODO: hardcoded collection identifier due to lack of collection API
+ it('can create a policy targeting an automatically generated asset collection.', async(): Promise => {
+ // TODO: using the hardcoded identifier here
const policy = `
@prefix ex: .
@prefix ldp: .
@prefix odrl: .
- @prefix odrl_p: .
ex:policy a odrl:Set ;
odrl:uid ex:policy ;
@@ -77,15 +82,181 @@ describe('A server with collections', (): void => {
body: policy,
});
expect(response.status).toBe(201);
+ });
+
+ it('can access a resource in the asset collection.', async(): Promise => {
+ const response = await umaFetch(`http://localhost:${cssPort}/alice/README`, {}, user);
+ expect(response.status).toBe(200);
+ });
+
+ it('can create a custom asset collection.', async(): Promise => {
+ const response = await fetch(`http://localhost:${umaPort}/uma/collections`, {
+ method: 'POST',
+ headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'application/json' },
+ body: JSON.stringify({
+ description: 'My assets',
+ type: 'asset',
+ owners: [ owner ],
+ parts: [ `http://localhost:${cssPort}/alice/`, `http://localhost:${cssPort}/alice/README` ],
+ }),
+ });
+ expect(response.status).toBe(201);
+ expect(response.headers.get('location')).toBeDefined();
+ assetCollection = response.headers.get('location')!;
+ });
- response = await fetch(joinUrl(url, encodeURIComponent('http://example.org/policy')), {
+ it('can only create a custom asset collection over owned resources.', async(): Promise => {
+ const response = await fetch(`http://localhost:${umaPort}/uma/collections`, {
+ method: 'POST',
+ headers: { authorization: `WebID ${encodeURIComponent(user)}`, 'content-type': 'application/json' },
+ body: JSON.stringify({
+ description: 'My assets',
+ type: 'asset',
+ owners: [ owner ],
+ parts: [ `http://localhost:${cssPort}/alice/`, `http://localhost:${cssPort}/alice/README` ],
+ }),
+ });
+ expect(response.status).toBe(403);
+ });
+
+ it('can create a custom party collection.', async(): Promise => {
+ const response = await fetch(`http://localhost:${umaPort}/uma/collections`, {
+ method: 'POST',
+ headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'application/json' },
+ body: JSON.stringify({
+ description: 'My party',
+ type: 'party',
+ owners: [ owner ],
+ parts: [ owner, user ],
+ }),
+ });
+ expect(response.status).toBe(201);
+ expect(response.headers.get('location')).toBeDefined();
+ partyCollection = response.headers.get('location')!;
+ });
+
+ it('can read owned collections.', async(): Promise => {
+ const response = await fetch(`http://localhost:${umaPort}/uma/collections`, {
headers: { authorization: `WebID ${encodeURIComponent(owner)}` },
});
- console.log(await response.text());
+ expect(response.status).toBe(200);
+ const expectedTurtle = `
+ @prefix dc: .
+ @prefix odrl: .
+ <${assetCollection}> a odrl:AssetCollection ;
+ dc:description "My assets" ;
+ dc:creator <${owner}> .
+ odrl:partOf <${assetCollection}> .
+ odrl:partOf <${assetCollection}> .
+ <${partyCollection}> a odrl:PartyCollection ;
+ dc:description "My party" ;
+ dc:creator <${owner}> .
+ <${owner}> odrl:partOf <${partyCollection}> .
+ <${user}> odrl:partOf <${partyCollection}> .
+ `;
+ const responseStore = new Store(new Parser().parse(await response.text()));
+ for (const quad of new Parser().parse(expectedTurtle)) {
+ expect(responseStore.has(quad)).toBe(true);
+ }
+
+ // Check auto-generated collections
+ const expectedContainers = [
+ `http://localhost:${cssPort}/alice/`,
+ `http://localhost:${cssPort}/alice/profile/`,
+ ];
+ const subjectMap: Record = {};
+ for (const resource of expectedContainers) {
+ const subjects = responseStore.getSubjects(ODRL.terms.source, resource, null);
+ expect(subjects).toHaveLength(1);
+ subjectMap[resource] = subjects[0];
+ expect(responseStore.countQuads(subjects[0], RDF.terms.type, ODRL.terms.AssetCollection, null)).toBe(1);
+ expect(responseStore.countQuads(subjects[0], ODRL_P.terms.relation, 'http://www.w3.org/ns/ldp#contains', null)).toBe(1);
+ }
+ expect(responseStore.countQuads(`http://localhost:${cssPort}/alice/README`,
+ ODRL.terms.partOf, subjectMap[`http://localhost:${cssPort}/alice/`], null)).toBe(1);
+ expect(responseStore.countQuads(`http://localhost:${cssPort}/alice/profile/`,
+ ODRL.terms.partOf, subjectMap[`http://localhost:${cssPort}/alice/`], null)).toBe(1);
+ expect(responseStore.countQuads(`http://localhost:${cssPort}/alice/profile/card`,
+ ODRL.terms.partOf, subjectMap[`http://localhost:${cssPort}/alice/profile/`], null)).toBe(1);
});
- it('can access a resource in the asset collection.', async(): Promise => {
- const response = await umaFetch(`http://localhost:${cssPort}/alice/README`, {}, user);
+ it('can read single collections.', async(): Promise => {
+ const response = await fetch(partyCollection, {
+ headers: { authorization: `WebID ${encodeURIComponent(owner)}` },
+ });
+ expect(response.status).toBe(200);
+ const expectedTurtle = `
+ @prefix dc: .
+ @prefix odrl: .
+ <${partyCollection}> a odrl:PartyCollection ;
+ dc:description "My party" ;
+ dc:creator <${owner}> .
+ <${owner}> odrl:partOf <${partyCollection}> .
+ <${user}> odrl:partOf <${partyCollection}> .
+ `;
+ expect(new Parser().parse(await response.text())).toBeRdfIsomorphic(new Parser().parse(expectedTurtle));
+ });
+
+ it('can replace collections.', async(): Promise => {
+ let response = await fetch(partyCollection, {
+ method: 'PUT',
+ headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'application/json' },
+ body: JSON.stringify({
+ description: 'My updated party',
+ type: 'party',
+ owners: [ owner, user ],
+ parts: [ user, user2 ],
+ }),
+ });
+ expect(response.status).toBe(204);
+
+
+ response = await fetch(`http://localhost:${umaPort}/uma/collections`, {
+ headers: { authorization: `WebID ${encodeURIComponent(owner)}` },
+ });
+ expect(response.status).toBe(200);
+ const expectedTurtle = `
+ @prefix dc: .
+ @prefix odrl: .
+ <${partyCollection}> a odrl:PartyCollection ;
+ dc:description "My updated party" ;
+ dc:creator <${owner}> , <${user}> .
+ <${user}> odrl:partOf <${partyCollection}> .
+ <${user2}> odrl:partOf <${partyCollection}> .
+ `;
+ const responseStore = new Store(new Parser().parse(await response.text()));
+ for (const quad of new Parser().parse(expectedTurtle)) {
+ expect(responseStore.has(quad)).toBe(true);
+ }
+ expect(responseStore.countQuads(partyCollection, DC.terms.description, 'My party', null)).toBe(0);
+ expect(responseStore.countQuads(owner, ODRL.terms.partOf, partyCollection, null)).toBe(0);
+ });
+
+ it('can access resources using these collections.', async(): Promise => {
+ const policy = `
+ @prefix ex: .
+ @prefix ldp: .
+ @prefix odrl: .
+
+ ex:policy2 a odrl:Set ;
+ odrl:uid ex:policy2 ;
+ odrl:permission ex:permission2 .
+
+ ex:permission2 a odrl:Permission ;
+ odrl:assignee <${partyCollection}> ;
+ odrl:assigner <${owner}> ;
+ odrl:action odrl:read ;
+ odrl:target <${assetCollection}> .`
+
+ const url = `http://localhost:${umaPort}/uma/policies`;
+ let response = await fetch(url, {
+ method: 'POST',
+ headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'text/turtle' },
+ body: policy,
+ });
+ expect(response.status).toBe(201);
+
+ response = await umaFetch(`http://localhost:${cssPort}/alice/README`, {}, user2);
expect(response.status).toBe(200);
});
});