diff --git a/README.md b/README.md
index 34292256..082bc7b3 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,9 @@ You can then execute the following flows:
- `yarn script:public`: `GET` the public `/alice/profile/card` without redirection to the UMA server;
- `yarn script:private`: `PUT` some text to the private `/alice/private/resource.txt`, protected by a simple WebID check;
- `yarn script:uma-ucp`: `PUT` some text to the private `/alice/other/resource.txt`, protected by a UCP enforcer checking WebIDs according to policies in `packages/uma/config/rules/policy/`.
-- `yarn script:registration`: `POST`, `GET` and `DELETE` some text to/from `/alice/public/resource.txt` to test the correct creation and deletion of resource registrations on the UNA server.
+- `yarn script:collection`: `POST`, `GET` and `DELETE` some text to/from `/alice/public/resource.txt` to test the correct creation and deletion of resource registrations on the UMA server.
+ An AssetCollection policy is used to create `/alice/public/`.
+ More information on the collection implementation can be found in [documentation/collections.md](documentation/collections.md).
`yarn script:flow` runs all flows in sequence.
diff --git a/demo/flow-test.ts b/demo/flow-test.ts
index c33689d5..af75f584 100644
--- a/demo/flow-test.ts
+++ b/demo/flow-test.ts
@@ -40,7 +40,7 @@ const terms = {
}
}
-const policyContainer = 'http://localhost:3000/ruben/settings/policies/';
+const policyContainer = 'http://localhost:3000/settings/policies/';
async function main() {
diff --git a/demo/flow.ts b/demo/flow.ts
index da379a4c..2534d2db 100644
--- a/demo/flow.ts
+++ b/demo/flow.ts
@@ -40,7 +40,7 @@ const terms = {
}
}
-const policyContainer = 'http://localhost:3000/ruben/settings/policies/';
+const policyContainer = 'http://localhost:3000/settings/policies/';
async function main() {
diff --git a/documentation/collections.md b/documentation/collections.md
new file mode 100644
index 00000000..d8da4f94
--- /dev/null
+++ b/documentation/collections.md
@@ -0,0 +1,188 @@
+# 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.
+
+## WAC / ACP
+
+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.
+
+## New resource description fields
+
+To support collections, the RS now includes two additional fields when registering a resource,
+in addition to those defined in the UMA specification.
+
+* `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,
+ and the values are the scopes that the Authorization Server should support for the corresponding collections.
+* `resource_relations`: A key/value map linking this resource to others through relations.
+ 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,
+but reverses how the relations should be interpreted.
+E.g., in the case of `resource_defaults`,
+the resource would be the object instead of the subject of those relations.
+
+An example of such an extended resource description:
+```json
+{
+ "resource_scopes": [ "read", "write" ],
+ "resource_defaults": {
+ "http://www.w3.org/ns/ldp#contains": [ "read" ]
+ },
+ "resource_relations": {
+ "http://www.w3.org/ns/ldp#contains": [ "assets:1234" ],
+ "@reverse": { "my:other:relation": [ "assets:5678" ] }
+ }
+}
+```
+
+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_relations` field indicates that this resource
+has the `http://www.w3.org/ns/ldp#contains` relation with as target `assets:5678`,
+while the other entry indicates it is the target of the `my:other:relation` with `assets:5678` as subject.
+
+## 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.
+With the example above, the following triples would be generated:
+
+```ttl
+@prefix odrl: .
+@prefix odrl_p: .
+
+ a odrl:AssetCollection ;
+ odrl:source ;
+ odrl_p:relation .
+
+ odrl:partOf ;
+ odrl:partOf .
+```
+This assumes that the collection IDs used above, `collection:12345` and `collection:5678:reverse`, already exist.
+If these collections were not yet generated,
+the registration request would fail with an error.
+All these triples then get passed to the ODRL evaluator when policies need to be processed.
+Any policy that targets a collection ID will apply to all resources that are part of that collection.
+If the relation was reversed, the relation object would be `[ owl:inverseOf ]`.
+
+## 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,
+the relevant `odrl:partOf` triples will be removed.
+If entries are removed from `resource_defaults`,
+the triples that define the corresponding asset collection are removed.
+The latter can only happen if the asset collection is empty.
+In case there are still `odrl:partOf` triples linking to it,
+the update will fail with an error.
+
+It is possible to generate the same relation in two different ways:
+in the description of the source, and in the description of the target.
+Since updates to one resource can remove relations,
+this can potentially cause some confusion and/or inconsistencies.
+E.g., if resource A is registered with relation L to resource B,
+and B is registered with the reverse of L to resource A.
+Both these statements apply to the same relation.
+If resource A is then updated without that relation,
+it would be removed while the description of B still contains it.
+For this reason it is advised to always describe relations in only one of the two resources.
+
+## Known issues/workarounds
+
+Below are some of the issues encountered while implementing this,
+that might need more robust solutions.
+
+### UMA identifiers
+
+The UMA server is only aware of the UMA identifiers;
+it does not know the resource identifiers.
+Those are also the identifiers that need to be used when writing policies.
+Eventually, there should be an API an interface so users know which identifiers they need to use.
+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
+
+For asset collections, there is a similar problem where the user doesn't know which identifiers to use.
+To work around this,
+users can create their own asset collections and add them to policies.
+Take the following policy for example:
+
+```ttl
+@prefix ex: .
+@prefix ldp: .
+@prefix odrl: .
+@prefix odrl_p: .
+
+ a odrl:Set ;
+ odrl:uid ;
+ odrl:permission .
+
+ a odrl:Permission ;
+ odrl:assignee ex:alice ;
+ odrl:action odrl:read ;
+ odrl:target ex:assetCollection .
+
+ex:assetCollection a odrl:AssetCollection ;
+ odrl:source ;
+ odrl_p:relation ldp:contains .
+```
+
+The above policy gives Alice read permission on all resources in `http://localhost:3000/container/`.
+Here the user chose the identifier `ex:assetCollection` for the collection with the given parameters.
+When new resources are registered,
+the UMA server will detect that this collection already exists,
+and use that identifier for the new metadata triples.
+It is important that this definition already exists in the policies before any resources get registered to it,
+so this solution is better for static policy solutions,
+where all policies are already defined on server initialization.
+The server will error if there are multiple asset collections with the same parameters,
+so make sure to only define identifier per combination.
+
+### Parent containers not yet registered
+
+Resource registration happens asynchronously.
+As a consequence, it is possible when registering a resource,
+that the registration of its parent container was not yet completed.
+This is a problem since the UMA ID of this parent is necessary to link to the correct relation.
+To work around this, resources get updated when the relevant information becomes available.
+If the parent is not yet registered, a resource will be registered without the relevant relation fields.
+Then, when the parent is registered, an event will trigger a registration update for the child resource,
+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,
+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.
+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 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.
+The current implementation instead generates a ticket targeting the first existing (grand)parent container,
+and requests the `create` scope.
diff --git a/package.json b/package.json
index c3dc9136..72f505a3 100644
--- a/package.json
+++ b/package.json
@@ -62,10 +62,10 @@
"script:demo-test": "yarn exec tsx ./demo/flow-test.ts",
"script:public": "yarn exec tsx ./scripts/test-public.ts",
"script:private": "yarn exec tsx ./scripts/test-private.ts",
- "script:registration": "yarn exec tsx ./scripts/test-registration.ts",
+ "script:collection": "yarn exec tsx ./scripts/test-collection.ts",
"script:uma-ucp": "yarn exec tsx ./scripts/test-uma-ucp.ts",
"script:uma-odrl": "yarn exec tsx ./scripts/test-uma-ODRL.ts",
- "script:flow": "yarn run script:public && yarn run script:private && yarn run script:uma-ucp && yarn run script:registration",
+ "script:flow": "yarn run script:public && yarn run script:private && yarn run script:collection && yarn run script:uma-ucp",
"sync:list": "syncpack list-mismatches",
"sync:fix": "syncpack fix-mismatches"
},
diff --git a/packages/css/.componentsignore b/packages/css/.componentsignore
index 5a8b8213..3dd67c7e 100644
--- a/packages/css/.componentsignore
+++ b/packages/css/.componentsignore
@@ -1,6 +1,6 @@
[
"UmaVerificationOptions",
-
+
"AccessMap",
"Adapter",
"AlgJwk",
@@ -34,6 +34,7 @@
"Readonly",
"RegExp",
"Server",
+ "Set",
"SetMultiMap",
"Shorthand",
"Template",
diff --git a/packages/css/config/demo.json b/packages/css/config/demo.json
index 711a8cf8..bc5d211b 100644
--- a/packages/css/config/demo.json
+++ b/packages/css/config/demo.json
@@ -5,6 +5,7 @@
],
"import": [
"uma-css:config/default.json",
+ "uma-css:config/uma/demo.json",
"css:config/storage/backend/data-accessors/file.json"
],
"@graph": [
diff --git a/packages/css/config/uma/demo.json b/packages/css/config/uma/demo.json
new file mode 100644
index 00000000..4b557af7
--- /dev/null
+++ b/packages/css/config/uma/demo.json
@@ -0,0 +1,40 @@
+{
+ "@context": [
+ "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld",
+ "https://linkedsoftwaredependencies.org/bundles/npm/@solidlab/uma-css/^0.0.0/components/context.jsonld"
+ ],
+ "@graph": [
+ {
+ "comment": "Initializes the policy container. Preferably this would be a variable/CLI param. Eventually a more secure solution is needed.",
+ "@id": "urn:solid-server:uma:PolicyContainerInitializer",
+ "@type": "EmptyContainerInitializer",
+ "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
+ "container": "/settings/policies/",
+ "store": { "@id": "urn:solid-server:default:ResourceStore" }
+ },
+
+ {
+ "comment": "Should be in the PrimaryParallelInitializer, but that one actually has an issue where it stops working if one handler fails. Should be fixed in CSS.",
+ "@id": "urn:solid-server:default:PrimarySequenceInitializer",
+ "@type": "SequenceHandler",
+ "handlers": [
+ { "@id": "urn:solid-server:uma:PolicyContainerInitializer" }
+ ]
+ },
+
+ {
+ "comment": "Allow full access to the policies container.",
+ "@id": "urn:solid-server:default:PathBasedReader",
+ "@type": "PathBasedReader",
+ "paths": [
+ {
+ "PathBasedReader:_paths_key": "^/settings/policies/",
+ "PathBasedReader:_paths_value": {
+ "@type": "AllStaticReader",
+ "allow": true
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/packages/css/config/uma/overrides/modes.json b/packages/css/config/uma/overrides/modes.json
index d85e4df8..b0af248f 100644
--- a/packages/css/config/uma/overrides/modes.json
+++ b/packages/css/config/uma/overrides/modes.json
@@ -5,7 +5,14 @@
],
"@graph": [
{
- "comment": "Replace the account seeder with the UMA version so the AS is taken into account.",
+ "comment": "Moves create permission requests of non-existent resources to the first existing parent.",
+ "@id": "urn:uma:default:ParentCreateExtractor",
+ "@type": "ParentCreateExtractor",
+ "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" },
+ "resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" },
+ "source": { "@id": "urn:solid-server:default:HttpModesExtractor" }
+ },
+ {
"@id": "urn:solid-server:override:ModesExtractor",
"@type": "Override",
"overrideInstance": {
@@ -21,7 +28,7 @@
"@type": "IntermediateCreateExtractor",
"resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" },
"strategy": { "@id": "urn:solid-server:default:IdentifierStrategy" },
- "source": { "@id": "urn:solid-server:default:HttpModesExtractor" }
+ "source": { "@id": "urn:uma:default:ParentCreateExtractor" }
},
"auxiliaryStrategy": { "@id": "urn:solid-server:default:AuxiliaryStrategy" }
}
diff --git a/packages/css/config/uma/parts/client.json b/packages/css/config/uma/parts/client.json
index 8946838a..21646ac0 100644
--- a/packages/css/config/uma/parts/client.json
+++ b/packages/css/config/uma/parts/client.json
@@ -14,7 +14,9 @@
},
"fetcher": {
"@id": "urn:solid-server:default:UmaFetcher"
- }
+ },
+ "identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" },
+ "resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" }
}
]
}
diff --git a/packages/css/package.json b/packages/css/package.json
index b6213d3f..20dea497 100644
--- a/packages/css/package.json
+++ b/packages/css/package.json
@@ -63,7 +63,7 @@
"start": "yarn run community-solid-server -m . -c ./config/default.json --seedConfig ./config/seed.json -a http://localhost:4000/",
"demo": "yarn run demo:setup && yarn run demo:start",
"demo:setup": "yarn run -T shx rm -rf ./tmp && yarn run -T shx cp -R ../../demo/data ./tmp",
- "demo:start": "yarn run community-solid-server -m . -c ./config/demo.json -f ./tmp -a http://localhost:4000/ -l debug"
+ "demo:start": "yarn run community-solid-server -m . -c ./config/demo.json -f ./tmp -a http://localhost:4000/"
},
"dependencies": {
"@solid/community-server": "^7.1.7",
diff --git a/packages/css/src/authorization/ParentCreateExtractor.ts b/packages/css/src/authorization/ParentCreateExtractor.ts
new file mode 100644
index 00000000..bc206b5d
--- /dev/null
+++ b/packages/css/src/authorization/ParentCreateExtractor.ts
@@ -0,0 +1,61 @@
+import {
+ AccessMap,
+ AccessMode,
+ IdentifierSetMultiMap,
+ IdentifierStrategy,
+ InternalServerError,
+ ModesExtractor,
+ Operation,
+ ResourceIdentifier,
+ ResourceSet
+} from '@solid/community-server';
+
+/**
+ * Transforms the result of the wrapped {@link ModesExtractor} to only return modes for existing resources.
+ * In case a non-existent resource requires the `create` access mode;
+ * instead, this class will return the first existing parent container with the `create` access mode instead.
+ * This is because UMA only has identifiers for existing resources,
+ * so we let the server interpret the `create` permission as
+ * "Is the user allowed to create resources in this container?".
+ *
+ * A disadvantage of this solution is that the server ignores other permissions on the non-existent resource.
+ * This can be relevant if you have a server that needs to return 401/403 when accessing a resource that does not exist,
+ * instead of a 404.
+ */
+export class ParentCreateExtractor extends ModesExtractor {
+ public constructor(
+ protected readonly source: ModesExtractor,
+ protected readonly identifierStrategy: IdentifierStrategy,
+ protected readonly resourceSet: ResourceSet,
+ ) {
+ super();
+ }
+
+ public async canHandle(input: Operation): Promise {
+ return this.source.canHandle(input);
+ }
+
+ public async handle(input: Operation): Promise {
+ const result = await this.source.handle(input);
+ const updatedResult = new IdentifierSetMultiMap();
+ for (const [ id, modes ] of result.entrySets()) {
+ if (modes.has(AccessMode.create)) {
+ const parent = await this.findFirstExistingParent(id);
+ updatedResult.add(parent, AccessMode.create);
+ } else {
+ updatedResult.add(id, modes);
+ }
+ }
+ return updatedResult;
+ }
+
+ protected async findFirstExistingParent(id: ResourceIdentifier): Promise {
+ if (await this.resourceSet.hasResource(id)) {
+ return id;
+ }
+ if (this.identifierStrategy.isRootContainer(id)) {
+ throw new InternalServerError(`Root container ${id.path} does not exist`);
+ }
+ return this.findFirstExistingParent(this.identifierStrategy.getParentContainer(id));
+ }
+}
diff --git a/packages/css/src/authorization/UmaAuthorizer.ts b/packages/css/src/authorization/UmaAuthorizer.ts
index db035a74..d931f215 100644
--- a/packages/css/src/authorization/UmaAuthorizer.ts
+++ b/packages/css/src/authorization/UmaAuthorizer.ts
@@ -1,5 +1,5 @@
-import {
- Authorizer, createErrorMessage, ForbiddenHttpError, getLoggerFor, UnauthorizedHttpError
+import {
+ Authorizer, createErrorMessage, ForbiddenHttpError, getLoggerFor, InternalServerError, UnauthorizedHttpError
} from '@solid/community-server';
import type { AccessMap, AuthorizerInput } from '@solid/community-server';
import { OwnerUtil } from '../util/OwnerUtil';
@@ -30,7 +30,7 @@ export class UmaAuthorizer extends Authorizer {
*/
public constructor(
protected authorizer: Authorizer,
- protected ownerUtil: OwnerUtil,
+ protected ownerUtil: OwnerUtil,
protected umaClient: UmaClient,
) {
super();
@@ -38,17 +38,17 @@ export class UmaAuthorizer extends Authorizer {
public async handle(input: AuthorizerInput): Promise {
try {
-
+
// Try authorizer
await this.authorizer.handleSafe(input);
} catch (error: unknown) {
// Unless 403/403 throw original error
if (!UnauthorizedHttpError.isInstance(error) && !ForbiddenHttpError.isInstance(error)) throw error;
-
+
// Request UMA ticket
const authHeader = await this.requestTicket(input.requestedModes);
-
+
// Add auth header to error metadata if private
if (authHeader) {
error.metadata.add(WWW_AUTH, literal(authHeader));
@@ -65,14 +65,14 @@ export class UmaAuthorizer extends Authorizer {
const owner = await this.ownerUtil.findCommonOwner(requestedModes.keys());
const issuer = await this.ownerUtil.findIssuer(owner);
- if (!issuer) throw new Error(`No UMA authorization server found for ${owner}.`);
+ if (!issuer) throw new InternalServerError(`No UMA authorization server found for ${owner}.`);
try {
const ticket = await this.umaClient.fetchTicket(requestedModes, issuer);
return ticket ? `UMA realm="solid", as_uri="${issuer}", ticket="${ticket}"` : undefined;
} catch (e) {
this.logger.error(`Error while requesting UMA header: ${(e as Error).message}`);
- throw new Error('Error while requesting UMA header.');
+ throw new InternalServerError(`Error while requesting UMA header: ${(e as Error).message}.`);
}
}
}
diff --git a/packages/css/src/index.ts b/packages/css/src/index.ts
index 86784098..b69147c0 100644
--- a/packages/css/src/index.ts
+++ b/packages/css/src/index.ts
@@ -1,6 +1,7 @@
export * from './authentication/UmaTokenExtractor';
export * from './authorization/AuxiliaryModesExtractor';
+export * from './authorization/ParentCreateExtractor';
export * from './authorization/UmaAuthorizer';
export * from './authorization/UmaPermissionReader';
@@ -9,6 +10,7 @@ export * from './http/output/metadata/UmaTicketMetadataWriter';
export * from './identity/interaction/account/util/AccountSettings';
export * from './identity/interaction/account/util/UmaAccountStore';
+export * from './init/EmptyContainerInitializer';
export * from './init/UmaSeededAccountInitializer';
export * from './server/middleware/JwksHandler';
diff --git a/packages/css/src/init/EmptyContainerInitializer.ts b/packages/css/src/init/EmptyContainerInitializer.ts
new file mode 100644
index 00000000..71945dcf
--- /dev/null
+++ b/packages/css/src/init/EmptyContainerInitializer.ts
@@ -0,0 +1,38 @@
+import {
+ BasicRepresentation,
+ ensureTrailingSlash,
+ getLoggerFor,
+ Initializer,
+ joinUrl,
+ ResourceIdentifier,
+ ResourceStore
+} from '@solid/community-server';
+
+/**
+ * Creates an empty container with the given identifier.
+ */
+export class EmptyContainerInitializer extends Initializer {
+ protected readonly logger = getLoggerFor(this);
+
+ protected readonly containerId: ResourceIdentifier;
+
+ public constructor(
+ protected readonly baseUrl: string,
+ protected readonly container: string,
+ protected readonly store: ResourceStore,
+ ) {
+ super();
+ if (!container.endsWith('/')) {
+ throw new Error(`Container paths should end with a slash, instead got ${container}`);
+ }
+ this.containerId = { path: ensureTrailingSlash(joinUrl(baseUrl, container)) };
+ }
+
+ public async handle(): Promise {
+ if (await this.store.hasResource(this.containerId)) {
+ return;
+ }
+ this.logger.info(`Initializing container ${this.containerId.path}`);
+ await this.store.setRepresentation(this.containerId, new BasicRepresentation());
+ }
+}
diff --git a/packages/css/src/uma/ResourceRegistrar.ts b/packages/css/src/uma/ResourceRegistrar.ts
index d6def313..321d46bd 100644
--- a/packages/css/src/uma/ResourceRegistrar.ts
+++ b/packages/css/src/uma/ResourceRegistrar.ts
@@ -1,8 +1,11 @@
-import type { UmaClient } from '../uma/UmaClient';
-import type { ResourceIdentifier, MonitoringStore } from '@solid/community-server';
+import type { UmaClient } from './UmaClient';
+import { ResourceIdentifier, MonitoringStore, createErrorMessage } from '@solid/community-server';
import { AS, getLoggerFor, StaticHandler } from '@solid/community-server';
import { OwnerUtil } from '../util/OwnerUtil';
+/**
+ * Updates the UMA resource registrations when resources are added/removed.
+ */
export class ResourceRegistrar extends StaticHandler {
protected readonly logger = getLoggerFor(this);
@@ -15,13 +18,17 @@ export class ResourceRegistrar extends StaticHandler {
store.on(AS.Create, async (resource: ResourceIdentifier): Promise => {
for (const owner of await this.findOwners(resource)) {
- this.umaClient.createResource(resource, await this.findIssuer(owner));
+ this.umaClient.registerResource(resource, await this.findIssuer(owner)).catch((err: Error) => {
+ this.logger.error(`Unable to register resource ${resource.path}: ${createErrorMessage(err)}`);
+ });
}
});
store.on(AS.Delete, async (resource: ResourceIdentifier): Promise => {
for (const owner of await this.findOwners(resource)) {
- this.umaClient.deleteResource(resource, await this.findIssuer(owner));
+ this.umaClient.deleteResource(resource, await this.findIssuer(owner)).catch((err: Error) => {
+ this.logger.error(`Unable to remove resource registration ${resource.path}: ${createErrorMessage(err)}`);
+ });
}
});
}
diff --git a/packages/css/src/uma/UmaClient.ts b/packages/css/src/uma/UmaClient.ts
index 3eca01f5..cd1670d0 100644
--- a/packages/css/src/uma/UmaClient.ts
+++ b/packages/css/src/uma/UmaClient.ts
@@ -1,7 +1,21 @@
-import { type KeyValueStorage, type ResourceIdentifier, AccessMap, getLoggerFor } from "@solid/community-server";
-import type { Fetcher } from "../util/fetch/Fetcher";
+import {
+ AccessMap,
+ getLoggerFor,
+ IdentifierStrategy,
+ InternalServerError,
+ isContainerIdentifier,
+ joinUrl,
+ KeyValueStorage,
+ NotFoundHttpError,
+ ResourceIdentifier,
+ ResourceSet,
+ SingleThreaded
+} from '@solid/community-server';
import type { ResourceDescription } from '@solidlab/uma';
-import { JWTPayload, decodeJwt, createRemoteJWKSet, jwtVerify, JWTVerifyOptions } from "jose";
+import { EventEmitter, once } from 'events';
+import { createRemoteJWKSet, decodeJwt, JWTPayload, jwtVerify, JWTVerifyOptions } from 'jose';
+import { promises } from 'node:timers';
+import type { Fetcher } from '../util/fetch/Fetcher';
export interface Claims {
[key: string]: unknown;
@@ -33,10 +47,10 @@ export type UmaVerificationOptions = Omit = 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();
+
/**
- * @param {UmaVerificationOptions} options - options for JWT verification
+ * @param umaIdStore - Key/value store containing the resource path -> UMA ID bindings.
+ * @param fetcher - Used to perform requests targeting the AS.
+ * @param identifierStrategy - Utility functions based on the path configuration of the server.
+ * @param resourceSet - Will be used to verify existence of resources.
+ * @param options - JWT verification options.
*/
constructor(
- protected umaIdStore: KeyValueStorage,
- protected fetcher: Fetcher,
- protected options: UmaVerificationOptions = {},
- ) {}
+ protected readonly umaIdStore: KeyValueStorage,
+ protected readonly fetcher: Fetcher,
+ protected readonly identifierStrategy: IdentifierStrategy,
+ protected readonly resourceSet: ResourceSet,
+ protected readonly options: UmaVerificationOptions = {},
+ ) {
+ // 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);
+ }
/**
* Method to fetch a ticket from the Permission Registration endpoint of the UMA Authorization Service.
@@ -89,10 +121,33 @@ export class UmaClient {
const body = [];
for (const [ target, modes ] of permissions.entrySets()) {
- // const umaId = await this.umaIdStore.get(target.path);
- // if (!umaId) throw new NotFoundHttpError();
+ let umaId = await this.umaIdStore.get(target.path);
+ if (!umaId && this.inProgressResources.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, async () => {
+ throw new InternalServerError(`Unable to finish registration for ${target.path}.`)
+ });
+ await Promise.race([timeoutPromise, once(this.registerEmitter, target.path)]);
+ umaId = await this.umaIdStore.get(target.path);
+ }
+ if (!umaId) {
+ // Somehow, this resource was not registered yet while it does exist.
+ // This can be a consequence of adding resources in the wrong way (e.g., copying files),
+ // or other special resources, such as derived resources.
+ if (await this.resourceSet.hasResource(target)) {
+ await this.registerResource(target, issuer);
+ umaId = await this.umaIdStore.get(target.path);
+ } else {
+ throw new NotFoundHttpError();
+ }
+ }
+ // If at this point, there is still no registered ID, there is probably an issue with the resource.
+ if (!umaId) {
+ throw new InternalServerError(`Unable to request ticket: no UMA ID found for ${target.path}`);
+ }
body.push({
- resource_id: target.path, // TODO: map to umaId ? (but raises problems on creation, discovery ...)
+ resource_id: umaId,
resource_scopes: Array.from(modes).map(mode => `urn:example:css:modes:${mode}`)
});
}
@@ -134,7 +189,7 @@ export class UmaClient {
for (const permission of Array.isArray(payload.permissions) ? payload.permissions : []) {
if (!(
- 'resource_id' in permission &&
+ 'resource_id' in permission &&
typeof permission.resource_id === 'string' &&
'resource_scopes' in permission &&
Array.isArray(permission.resource_scopes) &&
@@ -154,11 +209,11 @@ export class UmaClient {
*/
public async verifyJwtToken(token: string, validIssuers: string[]): Promise {
let config: UmaConfig;
-
+
try {
const issuer = decodeJwt(token).iss;
if (!issuer) throw new Error('The JWT does not contain an "iss" parameter.');
- if (!validIssuers.includes(issuer))
+ if (!validIssuers.includes(issuer))
throw new Error(`The JWT wasn't issued by one of the target owners' issuers.`);
config = await this.fetchUmaConfig(issuer);
} catch (error: unknown) {
@@ -177,7 +232,7 @@ export class UmaClient {
*/
public async verifyOpaqueToken(token: string, issuer: string): Promise {
let config: UmaConfig;
-
+
try {
config = await this.fetchUmaConfig(issuer);
} catch (error: unknown) {
@@ -233,24 +288,77 @@ export class UmaClient {
return configuration;
}
- public async createResource(resource: ResourceIdentifier, issuer: string): Promise {
- const { resource_registration_endpoint: endpoint } = await this.fetchUmaConfig(issuer);
+ /**
+ * Updates the UMA registration for the given resource on the given issuer.
+ * This either registers a new UMA identifier or updates an existing one,
+ * depending on if it already exists.
+ * For containers, the resource_defaults will be registered,
+ * for all resources, the resource_relations with the parent container will be registered.
+ * For the latter, it is possible that the parent container is not registered yet,
+ * for example, in the case of seeding multiple resources simultaneously.
+ * In that case the registration will be done immediately,
+ * and updated with the relations once the parent registration is finished.
+ */
+ public async registerResource(resource: ResourceIdentifier, issuer: string): Promise {
+ if (this.inProgressResources.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);
+ return this.registerResource(resource, issuer);
+ }
+ this.inProgressResources.add(resource.path);
+ let { resource_registration_endpoint: endpoint } = await this.fetchUmaConfig(issuer);
+ const knownUmaId = await this.umaIdStore.get(resource.path);
+ if (knownUmaId) {
+ endpoint = joinUrl(endpoint, knownUmaId);
+ }
const description: ResourceDescription = {
+ name: resource.path,
resource_scopes: [
'urn:example:css:modes:read',
'urn:example:css:modes:append',
'urn:example:css:modes:create',
'urn:example:css:modes:delete',
'urn:example:css:modes:write',
- ]
+ ],
};
- this.logger.info(`Creating resource registration for <${resource.path}> at <${endpoint}>`);
+ if (isContainerIdentifier(resource)) {
+ description.resource_defaults = { 'http://www.w3.org/ns/ldp#contains': description.resource_scopes };
+ }
- const request = {
- url: endpoint,
- method: 'POST',
+ // This function can potentially cause multiple asynchronous calls to be required.
+ // These will be stored in this array so they can be executed simultaneously.
+ const promises: Promise[] = [];
+ if (!this.identifierStrategy.isRootContainer(resource)) {
+ const parentIdentifier = this.identifierStrategy.getParentContainer(resource);
+ const parentId = await this.umaIdStore.get(parentIdentifier.path);
+ if (parentId) {
+ description.resource_relations = { '@reverse': { 'http://www.w3.org/ns/ldp#contains': [ parentId ] } };
+ } else {
+ this.logger.warn(`Unable to register parent relationship of ${
+ resource.path} due to missing parent ID. Waiting for parent registration.`);
+
+ promises.push(
+ once(this.registerEmitter, parentIdentifier.path)
+ .then(() => this.registerResource(resource, issuer)),
+ );
+ // 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)) {
+ promises.push(this.registerResource(parentIdentifier, issuer));
+ }
+ }
+ }
+
+ this.logger.info(
+ `${knownUmaId ? 'Updating' : 'Creating'} resource registration for <${resource.path}> at <${endpoint}>`,
+ );
+
+ const request: RequestInit = {
+ method: knownUmaId ? 'PUT' : 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
@@ -258,52 +366,51 @@ export class UmaClient {
body: JSON.stringify(description),
};
- // do not await - registration happens in background to cope with errors etc.
- this.fetcher.fetch(endpoint, request).then(async resp => {
- if (resp.status !== 201) {
- throw new Error (`Resource registration request failed. ${await resp.text()}`);
- }
+ const fetchPromise = this.fetcher.fetch(endpoint, request).then(async resp => {
+ if (knownUmaId) {
+ if (resp.status !== 200) {
+ throw new InternalServerError(`Resource update request failed. ${await resp.text()}`);
+ }
+ } else {
+ if (resp.status !== 201) {
+ throw new InternalServerError(`Resource registration request failed. ${await resp.text()}`);
+ }
+
+ const { _id: umaId } = await resp.json();
- const { _id: umaId } = await resp.json();
-
- if (!umaId || typeof umaId !== 'string') {
- throw new Error ('Unexpected response from UMA server; no UMA id received.');
+ if (!isString(umaId)) {
+ throw new InternalServerError('Unexpected response from UMA server; no UMA id received.');
+ }
+
+ await this.umaIdStore.set(resource.path, umaId);
+ this.logger.info(`Registered resource ${resource.path} with UMA ID ${umaId}`);
}
-
- this.umaIdStore.set(resource.path, umaId);
- }).catch(error => {
- // TODO: Do something useful on error
- this.logger.warn(
- `Something went wrong during UMA resource registration to create ${resource.path}: ${(error as Error).message}`
- );
+ // Indicate this resource finished registration
+ this.inProgressResources.delete(resource.path);
+ this.registerEmitter.emit(resource.path);
});
+
+ // Execute all the required promises.
+ promises.push(fetchPromise);
+ await Promise.all(promises);
}
- public async deleteResource(resource: ResourceIdentifier, issuer: string): Promise {
+ /**
+ * Deletes the UMA registration for the given resource from the given issuer.
+ */
+ public async deleteResource(resource: ResourceIdentifier, issuer: string): Promise {
const { resource_registration_endpoint: endpoint } = await this.fetchUmaConfig(issuer);
- this.logger.info(`Deleting resource registration for <${resource.path}> at <${endpoint}>`);
-
const umaId = await this.umaIdStore.get(resource.path);
- const url = `${endpoint}/${umaId}`;
-
- const request = {
- url,
- method: 'DELETE',
- headers: {}
- };
+ if (!umaId) {
+ console.error('Trying to remove UMA registration that is not known:', resource.path);
+ return;
+ }
+ const url = joinUrl(endpoint, umaId);
- // do not await - registration happens in background to cope with errors etc.
- this.fetcher.fetch(endpoint, request).then(async _resp => {
- if (!umaId) throw new Error('Trying to delete unknown/unregistered resource; no UMA id found.');
+ this.logger.info(`Deleting resource registration for <${resource.path}> at <${url}>`);
- await this.fetcher.fetch(url, request);
- }).catch(error => {
- // TODO: Do something useful on error
- this.logger.warn(
- `Something went wrong during UMA resource registration to delete ${resource.path}: ${(error as Error).message}`
- );
- });
+ await this.fetcher.fetch(url, { method: 'DELETE' });
}
}
diff --git a/packages/ucp/src/storage/ContainerUCRulesStorage.ts b/packages/ucp/src/storage/ContainerUCRulesStorage.ts
index 3866b512..84edca86 100644
--- a/packages/ucp/src/storage/ContainerUCRulesStorage.ts
+++ b/packages/ucp/src/storage/ContainerUCRulesStorage.ts
@@ -1,92 +1,144 @@
-import { Store } from "n3";
-import { UCRulesStorage } from "./UCRulesStorage";
-import { isRDFContentType, rdfToStore, storeToString, turtleStringToStore } from "../util/Conversion";
-import { extractQuadsRecursive } from "../util/Util";
+import type { Quad } from '@rdfjs/types';
+import { Store, Writer } from 'n3';
+import { randomUUID } from 'node:crypto';
+import path from 'node:path';
+import { storeToString, turtleStringToStore } from '../util/Conversion';
+import { extractQuadsRecursive } from '../util/Util';
+import { UCRulesStorage } from './UCRulesStorage';
export type RequestInfo = string | Request;
export class ContainerUCRulesStorage implements UCRulesStorage {
- private containerURL: string;
- private fetch: (input: RequestInfo, init?: RequestInit | undefined) => Promise;
+ protected readonly containerURL: string;
+ protected readonly fetch: (input: RequestInfo, init?: RequestInit | undefined) => Promise;
+ // The resource that will be used to store the additional triples that will be added through this store
+ protected readonly extraDataUrl: string;
/**
- *
+ *
* @param containerURL The URL to an LDP container
*/
- public constructor(containerURL: string, customFetch?: (input: RequestInfo, init?: RequestInit | undefined) => Promise) {
- this.containerURL = containerURL
- console.log(`[${new Date().toISOString()}] - ContainerUCRulesStore: LDP Container that will be used as source for the Usage Control Rules`, this.containerURL);
+ public constructor(
+ containerURL: string,
+ customFetch?: (input: RequestInfo, init?: RequestInit | undefined) => Promise
+ ) {
+ this.containerURL = containerURL;
this.fetch = customFetch ?? fetch;
+ this.extraDataUrl = path.posix.join(containerURL, randomUUID());
}
public async getStore(): Promise {
+ // TODO: can use last-modified date/etag or something to cache store?
const store = new Store()
- const container = await readLdpRDFResource(this.fetch, this.containerURL);
- const children = container.getObjects(this.containerURL, "http://www.w3.org/ns/ldp#contains", null).map(value => value.value)
- for (const childURL of children) {
- try {
- const childStore = await readLdpRDFResource(this.fetch, childURL);
- store.addQuads(childStore.getQuads(null, null, null, null))
- } catch (e) {
- console.log(`${childURL} is not an RDF resource`);
-
- }
-
+ const documents = await this.getDocuments();
+ for (const childStore of Object.values(documents)) {
+ store.addQuads(childStore.getQuads(null, null, null, null));
}
return store;
}
public async addRule(rule: Store): Promise {
+ if (rule.size === 0) {
+ return;
+ }
const ruleString = storeToString(rule);
- const response = await this.fetch(this.containerURL,{
- method: "POST",
- headers: { 'content-type': 'text/turtle' },
- body: ruleString
- })
- if (response.status !== 201) {
- console.log(ruleString);
- throw Error("Above rule could not be added to the store")
+
+ let response = await fetch(this.extraDataUrl, {
+ method: 'PATCH',
+ headers: { 'content-type': 'text/n3' },
+ body: `
+ @prefix solid: .
+
+ _:rename a solid:InsertDeletePatch;
+ solid:inserts {
+ ${ruleString}
+ }.`,
+ });
+ if (response.status >= 400) {
+ throw Error(`Could not add rule to the storage ${response.status} ${await response.text()}`);
}
}
+
public async getRule(identifier: string): Promise {
// would be better if there was a cache
const allRules = await this.getStore()
const rule = extractQuadsRecursive(allRules, identifier);
return rule
}
-
+
public async deleteRule(identifier: string): Promise {
// would really benefit from a cache
throw Error('not implemented');
}
-}
-export async function readLdpRDFResource(fetch: (input: RequestInfo, init?: RequestInit | undefined) => Promise, resourceURL: string): Promise {
- const containerResponse = await fetch(resourceURL);
+ public async removeData(data: Store): Promise {
+ if (data.size === 0) {
+ return;
+ }
+ const documents = await this.getDocuments();
+ // Remove matches from documents that contain them
+ for (const [ url, store ] of Object.entries(documents)) {
+ const matches: Quad[] = [];
+ for (const quad of data) {
+ if (store.has(quad)) {
+ matches.push(quad);
+ }
+ }
+ if (matches.length > 0) {
+ const response = await this.fetch(url, {
+ method: 'PUT',
+ headers: { 'content-type': 'text/n3' },
+ body: `
+ @prefix solid: .
+
+ _:rename a solid:InsertDeletePatch;
+ solid:deletes {
+ ${new Writer().quadsToString(matches)}
+ }.`,
+ });
- if (containerResponse.status !== 200) {
- throw new Error(`Resource not found: ${resourceURL}`);
+ if (response.status >= 400) {
+ throw Error(`Could not update rule resource ${url}: ${response.status} - ${await response.text()}`);
+ }
+ }
+ }
}
-
- if (containerResponse.headers.get('content-type') !== 'text/turtle') { // note: should be all kinds of RDF, not only turtle
- throw new Error('Works only on rdf data');
+
+ /**
+ * Returns all documents containing triples in the stored container.
+ */
+ protected async getDocuments(): Promise> {
+ const result: Record = {};
+ const container = await this.readLdpRDFResource(this.containerURL);
+ const children = container.getObjects(this.containerURL, "http://www.w3.org/ns/ldp#contains", null).map(value => value.value)
+ for (const childURL of children) {
+ try {
+ result[childURL] = await this.readLdpRDFResource(childURL);
+ } catch (e) {
+ console.log(`${childURL} is not an RDF resource`);
+ }
+ }
+ return result;
}
- const text = await containerResponse.text();
- return await turtleStringToStore(text, resourceURL);
-}
+ protected async readLdpRDFResource(resourceURL: string): Promise {
+ const containerResponse = await this.fetch(resourceURL, { headers: { 'accept': 'text/turtle' } });
-// export async function readLdpRDFResource(fetch: (input: RequestInfo, init?: RequestInit | undefined) => Promise, resourceURL: string): Promise {
-// const response = await fetch(resourceURL);
+ if (containerResponse.status === 404) {
+ return new Store();
+ }
-// if (response.status !== 200) {
-// throw new Error(`Resource not found: ${resourceURL}`);
-// }
-
-// const contentType = response.headers.get('content-type')
-// if (!contentType || !await isRDFContentType(contentType)) { // note: should be all kinds of RDF, not only turtle
-// throw new Error('Works only on rdf data');
-// }
-
-// return await rdfToStore(response, resourceURL);
-// }
+ if (containerResponse.status !== 200) {
+ throw new Error(`Unable to acces policy container ${resourceURL}: ${
+ containerResponse.status} - ${await containerResponse.text()}`);
+ }
+
+ const contentType = containerResponse.headers.get('content-type');
+ // TODO: support non-turtle formats
+ if (contentType !== 'text/turtle') {
+ throw new Error(`Only turtle serialization is supported, received ${contentType}`);
+ }
+ const text = await containerResponse.text();
+ return await turtleStringToStore(text, resourceURL);
+ }
+}
diff --git a/packages/ucp/src/storage/DirectoryUCRulesStorage.ts b/packages/ucp/src/storage/DirectoryUCRulesStorage.ts
index 05e7ca53..c313c40a 100644
--- a/packages/ucp/src/storage/DirectoryUCRulesStorage.ts
+++ b/packages/ucp/src/storage/DirectoryUCRulesStorage.ts
@@ -3,16 +3,23 @@ import * as path from 'path'
import * as fs from 'fs'
import { Parser, Store } from 'n3';
+/**
+ * Reads rules from files on disk and caches them in memory.
+ * The read only happens once, after which the data will be retained in memory.
+ */
export class DirectoryUCRulesStorage implements UCRulesStorage {
- protected directoryPath: string;
- protected readonly baseIRI: string;
+ protected readonly store: Store = new Store();
+ protected filesRead: boolean = false;
/**
*
* @param directoryPath The absolute path to a directory
* @param baseIRI The base to use when parsing RDF documents.
*/
- public constructor(directoryPath: string, baseIRI: string) {
+ public constructor(
+ protected readonly directoryPath: string,
+ protected readonly baseIRI: string,
+ ) {
this.directoryPath = path.resolve(directoryPath);
if (!fs.lstatSync(directoryPath).isDirectory()) {
throw Error(`${directoryPath} does not resolve to a directory`)
@@ -21,21 +28,30 @@ export class DirectoryUCRulesStorage implements UCRulesStorage {
}
public async getStore(): Promise {
- const store = new Store()
+ if (this.filesRead) {
+ return new Store(this.store);
+ }
const parser = new Parser({ baseIRI: this.baseIRI });
const files = fs.readdirSync(this.directoryPath).map(file => path.join(this.directoryPath, file))
for (const file of files) {
const quads = parser.parse((await fs.promises.readFile(file)).toString());
- store.addQuads(quads);
+ this.store.addQuads(quads);
}
- return store;
+ this.filesRead = true;
+ return new Store(this.store);
}
public async addRule(rule: Store): Promise {
- throw Error('not implemented');
+ this.store.addQuads(rule.getQuads(null, null, null, null));
+ }
+
+
+ public async removeData(data: Store): Promise {
+ this.store.removeQuads(data.getQuads(null, null, null, null));
}
+
public async getRule(identifier: string): Promise {
throw Error('not implemented');
}
diff --git a/packages/ucp/src/storage/MemoryUCRulesStorage.ts b/packages/ucp/src/storage/MemoryUCRulesStorage.ts
index e0b8b62d..a7a19228 100644
--- a/packages/ucp/src/storage/MemoryUCRulesStorage.ts
+++ b/packages/ucp/src/storage/MemoryUCRulesStorage.ts
@@ -1,16 +1,16 @@
-import { Store } from "n3";
-import { extractQuadsRecursive } from "../util/Util";
-import { UCRulesStorage } from "./UCRulesStorage";
+import { Store } from 'n3';
+import { extractQuadsRecursive } from '../util/Util';
+import { UCRulesStorage } from './UCRulesStorage';
export class MemoryUCRulesStorage implements UCRulesStorage {
- private store: Store;
+ protected store: Store;
public constructor() {
this.store = new Store();
}
public async getStore(): Promise {
- return this.store;
+ return new Store(this.store);
}
@@ -27,4 +27,8 @@ export class MemoryUCRulesStorage implements UCRulesStorage {
const store = await this.getRule(identifier)
this.store.removeQuads(store.getQuads(null, null, null, null));
}
-}
\ No newline at end of file
+
+ public async removeData(data: Store): Promise {
+ this.store.removeQuads(data.getQuads(null, null, null, null));
+ }
+}
diff --git a/packages/ucp/src/storage/UCRulesStorage.ts b/packages/ucp/src/storage/UCRulesStorage.ts
index e0a30c7f..f684a8fb 100644
--- a/packages/ucp/src/storage/UCRulesStorage.ts
+++ b/packages/ucp/src/storage/UCRulesStorage.ts
@@ -4,20 +4,25 @@ export interface UCRulesStorage {
getStore: () => Promise;
/**
* Add a single Usage Control Rule to the storage
- * @param rule
- * @returns
+ * @param rule
+ * @returns
*/
addRule: (rule: Store) => Promise;
/**
* Get a Usage Control Rule from the storage
- * @param identifier
- * @returns
+ * @param identifier
+ * @returns
*/
getRule: (identifier: string) => Promise;
/**
* Delete a Usage Control Rule from the storage
- * @param identifier
- * @returns
+ * @param identifier
+ * @returns
*/
deleteRule: (identifier: string) => Promise;
-}
\ No newline at end of file
+ /**
+ * Removes specific triples from the storage.
+ * @param data
+ */
+ removeData: (data: Store) => Promise;
+}
diff --git a/packages/ucp/src/util/Vocabularies.ts b/packages/ucp/src/util/Vocabularies.ts
index 96479282..eb1ef1df 100644
--- a/packages/ucp/src/util/Vocabularies.ts
+++ b/packages/ucp/src/util/Vocabularies.ts
@@ -106,15 +106,18 @@ export function extendVocabulary {
variables['urn:uma:variables:baseUrl'] = baseUrl;
// variables['urn:uma:variables:policyDir'] = path.join(rootDir, './config/rules/policy');
- variables['urn:uma:variables:policyContainer'] = 'http://localhost:3000/ruben/settings/policies/';
+ variables['urn:uma:variables:policyContainer'] = 'http://localhost:3000/settings/policies/';
variables['urn:uma:variables:eyePath'] = 'eye';
const configPath = path.join(rootDir, './config/demo.json');
diff --git a/packages/uma/config/demo.json b/packages/uma/config/demo.json
index 07ae53a4..47de4ef2 100644
--- a/packages/uma/config/demo.json
+++ b/packages/uma/config/demo.json
@@ -33,7 +33,8 @@
"@id": "urn:uma:default:AllAuthorizer"
}
}
- ]
+ ],
+ "resourceStore": { "@id": "urn:uma:default:ResourceRegistrationStore" }
}
},
{
diff --git a/packages/uma/config/policies/authorizers/default.json b/packages/uma/config/policies/authorizers/default.json
index 34fd3b48..f7eb55ea 100644
--- a/packages/uma/config/policies/authorizers/default.json
+++ b/packages/uma/config/policies/authorizers/default.json
@@ -53,15 +53,19 @@
"@type": "OdrlAuthorizer",
"eyePath": { "@id": "urn:uma:variables:eyePath" },
"policies": {
- "@id": "urn:uma:default:RulesStorage",
- "@type": "DirectoryUCRulesStorage",
- "directoryPath": {
- "@id": "urn:uma:variables:policyDir"
- },
- "baseIRI": {
- "@id": "urn:uma:variables:policyBaseIRI"
- }
+ "@id": "urn:uma:default:RulesStorage"
}
+ },
+ "resourceStore": { "@id": "urn:uma:default:ResourceRegistrationStore" }
+ },
+ {
+ "@id": "urn:uma:default:RulesStorage",
+ "@type": "DirectoryUCRulesStorage",
+ "directoryPath": {
+ "@id": "urn:uma:variables:policyDir"
+ },
+ "baseIRI": {
+ "@id": "urn:uma:variables:policyBaseIRI"
}
}
]
diff --git a/packages/uma/config/routes/resources.json b/packages/uma/config/routes/resources.json
index 49af99c1..5a8dd458 100644
--- a/packages/uma/config/routes/resources.json
+++ b/packages/uma/config/routes/resources.json
@@ -6,19 +6,20 @@
{
"@id": "urn:uma:default:ResourceRegistrationHandler",
"@type": "ResourceRegistrationRequestHandler",
- "resourceStore": { "@id": "urn:uma:default:ResourceRegistrationStore" }
+ "resourceStore": { "@id": "urn:uma:default:ResourceRegistrationStore" },
+ "policies": { "@id": "urn:uma:default:RulesStorage" }
},
{
"@id": "urn:uma:default:ResourceRegistrationRoute",
"@type": "HttpHandlerRoute",
"methods": [ "POST" ],
"handler": { "@id": "urn:uma:default:ResourceRegistrationHandler" },
- "path": "/uma/resources"
+ "path": "/uma/resources/"
},
{
"@id": "urn:uma:default:ResourceRegistrationOpsRoute",
"@type": "HttpHandlerRoute",
- "methods": [ "DELETE" ],
+ "methods": [ "PUT", "DELETE" ],
"handler": { "@id": "urn:uma:default:ResourceRegistrationHandler" },
"path": "/uma/resources/{id}"
}
diff --git a/packages/uma/config/rules/odrl/policy0.ttl b/packages/uma/config/rules/odrl/policy0.ttl
index 6d103668..0fc22be8 100644
--- a/packages/uma/config/rules/odrl/policy0.ttl
+++ b/packages/uma/config/rules/odrl/policy0.ttl
@@ -29,7 +29,7 @@ ex:usagePolicy2a a odrl:Agreement .
ex:usagePolicy2a odrl:permission ex:permission2 .
ex:permission2a a odrl:Permission .
ex:permission2a odrl:action odrl:create .
-ex:permission2a odrl:target .
+ex:permission2a odrl:target , .
ex:permission2a odrl:assignee .
ex:permission2a odrl:assigner .
diff --git a/packages/uma/config/rules/policy/policy0.ttl b/packages/uma/config/rules/policy/policy0.ttl
index 971ed60f..027046fe 100644
--- a/packages/uma/config/rules/policy/policy0.ttl
+++ b/packages/uma/config/rules/policy/policy0.ttl
@@ -1,5 +1,6 @@
@prefix ex: .
@prefix odrl: .
+@prefix odrl_p: .
ex:usagePolicy a odrl:Agreement .
ex:usagePolicy odrl:permission ex:permission .
@@ -13,6 +14,18 @@ ex:usagePolicy2 a odrl:Agreement .
ex:usagePolicy2 odrl:permission ex:permission2 .
ex:permission2 a odrl:Permission .
ex:permission2 odrl:action odrl:create , odrl:modify .
-ex:permission2 odrl:target , .
+ex:permission2 odrl:target , , .
ex:permission2 odrl:assignee .
ex:permission2 odrl:assigner .
+
+ex:usagePolicy3 a odrl:Agreement .
+ex:usagePolicy3 odrl:permission ex:permission3 .
+ex:permission3 a odrl:Permission .
+ex:permission3 odrl:action odrl:read .
+ex:permission3 odrl:target .
+ex:permission3 odrl:assignee .
+ex:permission3 odrl:assigner .
+
+ a odrl:AssetCollection ;
+ odrl:source ;
+ odrl_p:relation .
diff --git a/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts b/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts
index 9236e4f3..69d3831f 100644
--- a/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts
+++ b/packages/uma/src/policies/authorizers/NamespacedAuthorizer.ts
@@ -1,4 +1,5 @@
-import { getLoggerFor } from '@solid/community-server';
+import { getLoggerFor, KeyValueStorage } from '@solid/community-server';
+import { ResourceDescription } from '../../views/ResourceDescription';
import { Authorizer } from './Authorizer';
import { Permission } from '../../views/Permission';
import { Requirements, type ClaimVerifier } from '../../credentials/Requirements';
@@ -16,11 +17,15 @@ export class NamespacedAuthorizer implements Authorizer {
/**
* Creates a NamespacedAuthorizer with the given namespaces.
*
- * @param config - A list of objects refering a list of namespaces to a specific Authorizer.
+ * @param authorizers - A key/value map with the key being the relevant namespace
+ * and the value being the corresponding authorizer to use for that namespace.
+ * @param fallback - Authorizer to use if there is no namespace match.
+ * @param resourceStore - The key/value store containing the resource registrations.
*/
constructor(
protected authorizers: Record,
protected fallback: Authorizer,
+ protected resourceStore: KeyValueStorage,
) {}
/** @inheritdoc */
@@ -31,7 +36,7 @@ export class NamespacedAuthorizer implements Authorizer {
if (!query || query.length === 0) return [];
// Base namespace on first resource
- const ns = query[0].resource_id ? namespace(query[0].resource_id) : undefined;
+ const ns = query[0].resource_id ? await this.findNamespace(query[0].resource_id) : undefined;
// Check namespaces of other resources
for (const permission of query) {
@@ -56,7 +61,7 @@ export class NamespacedAuthorizer implements Authorizer {
if (!permissions || permissions.length === 0) return [];
// Base namespace on first resource
- const ns = namespace(permissions[0].resource_id);
+ const ns = await this.findNamespace(permissions[0].resource_id);
// Check namespaces of other resources
for (const permission of permissions) {
@@ -67,8 +72,31 @@ export class NamespacedAuthorizer implements Authorizer {
}
// Find applicable authorizer
- const authorizer = this.authorizers[ns] ?? this.fallback;
+ const authorizer = (typeof ns === 'string' && this.authorizers[ns]) || this.fallback;
return authorizer.credentials(permissions, query);
}
+
+ /**
+ * Finds the applicable authorizer to use based on the input query.
+ */
+ protected async findNamespace(resourceId?: string): Promise {
+ if (!resourceId) {
+ return;
+ }
+
+ const description = await this.resourceStore.get(resourceId);
+ if (!description) {
+ this.logger.warn(`Cannot find a registered resource with id ${resourceId}`);
+ return;
+ }
+
+ const resourceIdentifier = description.name;
+ if (!resourceIdentifier) {
+ this.logger.warn(`Resource ${resourceId} has no registered name.`);
+ return
+ }
+
+ return namespace(resourceIdentifier);
+ }
}
diff --git a/packages/uma/src/routes/Config.ts b/packages/uma/src/routes/Config.ts
index f0c172ce..cefeafb9 100644
--- a/packages/uma/src/routes/Config.ts
+++ b/packages/uma/src/routes/Config.ts
@@ -65,7 +65,7 @@ export class ConfigRequestHandler extends HttpHandler {
issuer: `${this.baseUrl}`,
permission_endpoint: `${this.baseUrl}/ticket`,
introspection_endpoint: `${this.baseUrl}/introspect`,
- resource_registration_endpoint: `${this.baseUrl}/resources`,
+ resource_registration_endpoint: `${this.baseUrl}/resources/`,
uma_profiles_supported: ['http://openid.net/specs/openid-connect-core-1_0.html#IDToken'],
dpop_signing_alg_values_supported: [...ASYMMETRIC_CRYPTOGRAPHIC_ALGORITHM],
response_types_supported: [ResponseType.Token],
diff --git a/packages/uma/src/routes/ResourceRegistration.ts b/packages/uma/src/routes/ResourceRegistration.ts
index 01d53e39..af5430f3 100644
--- a/packages/uma/src/routes/ResourceRegistration.ts
+++ b/packages/uma/src/routes/ResourceRegistration.ts
@@ -1,11 +1,17 @@
import {
BadRequestHttpError,
+ ConflictHttpError,
createErrorMessage,
getLoggerFor,
KeyValueStorage,
- MethodNotAllowedHttpError, NotFoundHttpError,
- UnauthorizedHttpError
+ MethodNotAllowedHttpError,
+ NotFoundHttpError,
+ UnauthorizedHttpError,
+ UnsupportedMediaTypeHttpError,
+ XSD,
} from '@solid/community-server';
+import { ODRL, ODRL_P, OWL, RDF, UCRulesStorage } from '@solidlab/ucp';
+import { DataFactory as DF, NamedNode, Quad, Quad_Object, Quad_Subject, Store, Writer } from 'n3';
import { randomUUID } from 'node:crypto';
import {
HttpHandler,
@@ -17,6 +23,11 @@ import { extractRequestSigner, verifyRequest } from '../util/HttpMessageSignatur
import { reType } from '../util/ReType';
import { ResourceDescription } from '../views/ResourceDescription';
+/**
+ * The necessary metadata to describe an asset collection based on a relation.
+ */
+export type CollectionMetadata = { relation: NamedNode, source: NamedNode, reverse: boolean };
+
/**
* A ResourceRegistrationRequestHandler is tasked with implementing
* section 3.2 from the User-Managed Access (UMA) Federated Auth 2.0.
@@ -26,13 +37,18 @@ import { ResourceDescription } from '../views/ResourceDescription';
export class ResourceRegistrationRequestHandler extends HttpHandler {
protected readonly logger = getLoggerFor(this);
+ /**
+ * @param resourceStore - Key/value store containing the {@link ResourceDescription}s.
+ * @param policies - Policy store to contain the asset relation triples.
+ */
constructor(
private readonly resourceStore: KeyValueStorage,
+ private readonly policies: UCRulesStorage,
) {
super();
}
- async handle({ request }: HttpHandlerContext): Promise> {
+ public async handle({ request }: HttpHandlerContext): Promise> {
const signer = await extractRequestSigner(request);
// TODO: check if signer is actually the correct one
@@ -43,12 +59,13 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
switch (request.method) {
case 'POST': return this.handlePost(request);
+ case 'PUT': return this.handlePut(request);
case 'DELETE': return this.handleDelete(request);
default: throw new MethodNotAllowedHttpError();
}
}
- private async handlePost(request: HttpHandlerRequest): Promise> {
+ protected async handlePost(request: HttpHandlerRequest): Promise {
const { body } = request;
try {
@@ -58,10 +75,22 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
throw new BadRequestHttpError(`Request has bad syntax: ${createErrorMessage(e)}`);
}
- const resource = randomUUID();
- await this.resourceStore.set(resource, body);
+ // We are using the name as the UMA identifier for now.
+ // Reason being that there is not yet a good way to determine what the identifier would be when writing policies.
+ let resource = body.name;
+ if (resource) {
+ if (await this.resourceStore.has(resource)) {
+ throw new ConflictHttpError(
+ `A resource with name ${resource} is already registered. Use PUT to update existing registrations.`,
+ );
+ }
+ } else {
+ resource = randomUUID();
+ this.logger.warn('No resource name was provided so a random identifier was generated.');
+ }
- this.logger.info(`Registered resource ${resource}.`);
+ // Set the resource metadata
+ await this.setResourceMetadata(resource, body);
return ({
status: 201,
@@ -69,18 +98,305 @@ export class ResourceRegistrationRequestHandler extends HttpHandler {
_id: resource,
user_access_policy_uri: 'TODO: implement policy UI',
},
- })
+ });
+ }
+
+ protected async handlePut({ body, headers, parameters }: HttpHandlerRequest): Promise {
+ if (typeof parameters?.id !== 'string') throw new Error('URI for PUT operation should include an id.');
+
+ if (!await this.resourceStore.has(parameters.id)) {
+ throw new NotFoundHttpError();
+ }
+
+ if (headers['content-type'] !== 'application/json') {
+ throw new UnsupportedMediaTypeHttpError('Only Media Type "application/json" is supported for this route.');
+ }
+
+ try {
+ reType(body, ResourceDescription);
+ } catch (e) {
+ this.logger.warn(`Syntax error: ${createErrorMessage(e)}, ${body}`);
+ throw new BadRequestHttpError(`Request has bad syntax: ${createErrorMessage(e)}`);
+ }
+
+ // Update the resource metadata
+ await this.setResourceMetadata(parameters.id, body);
+
+ return ({
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ _id: parameters.id,
+ user_access_policy_uri: 'TODO: implement policy UI',
+ }),
+ });
}
- private async handleDelete({ parameters }: HttpHandlerRequest): Promise> {
+ protected async handleDelete({ parameters }: HttpHandlerRequest): Promise {
if (typeof parameters?.id !== 'string') throw new Error('URI for DELETE operation should include an id.');
if (!await this.resourceStore.delete(parameters.id)) {
throw new NotFoundHttpError('Registration to be deleted does not exist (id unknown).');
}
+ await this.resourceStore.delete(parameters.id);
this.logger.info(`Deleted resource ${parameters.id}.`);
- return { status: 204 };
+ return ({ status: 204 });
+ }
+
+ /**
+ * Updates all asset collection and relation metadata for the given resource based on an updated description.
+ * @param id - The identifier of the resource.
+ * @param description - The new {@link ResourceDescription} for the resource.
+ */
+ protected async setResourceMetadata(id: string, description: ResourceDescription): Promise {
+ const policyStore = await this.policies.getStore();
+ const collectionQuads = await this.updateCollections(policyStore, id, description);
+ const relationQuads = await this.updateRelations(policyStore, id, description);
+ const addQuads = [ ...collectionQuads.add, ...relationQuads.add ];
+ if (addQuads.length > 0) {
+ await this.policies.addRule(new Store([...collectionQuads.add, ...relationQuads.add]));
+ }
+ const removeQuads = [ ...collectionQuads.remove, ...relationQuads.remove ];
+ if (removeQuads.length > 0) {
+ await this.policies.removeData(new Store([...collectionQuads.remove, ...relationQuads.remove]));
+ }
+
+ // Store the new UMA ID (or update the contents of the existing one)
+ // Note that we only do this after generating and updating the relation metadata,
+ // as errors could be thrown there.
+ await this.resourceStore.set(id, description);
+ this.logger.info(`Updated registration for ${id}.`);
+ }
+
+ /**
+ * Updates the existing asset collection metadata, based on the new resource description.
+ *
+ * @param policyStore - RDF store that contains all the know collection metadata.
+ * @param id - The identifier 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,
+ description: ResourceDescription,
+ previous?: ResourceDescription
+ ): Promise<{ add: Quad[], remove: Quad[] }> {
+ const add: Record = this.getCollectionMetadata('resource_defaults', description, id);
+ const remove: Record = this.getCollectionMetadata('resource_defaults', previous, id);
+ this.filterRelationEntries(add, remove);
+
+ // Add new collection triples
+ const addQuads: Quad[] = [];
+ for (const [ key, entry ] of Object.entries(add)) {
+ const collections = this.findCollectionIds(entry, policyStore);
+ if (collections.length > 1) {
+ this.logger.error(
+ `Found multiple collections for ${JSON.stringify(entry)}: ${collections.map((col) => col.value)}`
+ );
+ }
+ // Ignore collections that already exist
+ if (collections.length > 0) {
+ delete add[key];
+ } else {
+ addQuads.push(...this.generateCollectionTriples(entry));
+ }
+ }
+
+ // Remove old collection triples if the collections are empty.
+ const removeQuads: Quad[] = [];
+ for (const entry of Object.values(remove)) {
+ const collections = this.findCollectionIds(entry, policyStore);
+ for (const collection of collections) {
+ // Make sure that collections that need to be removed are empty
+ 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));
+ }
+ }
+
+ return {
+ add: addQuads,
+ remove: removeQuads,
+ };
+ }
+
+ /**
+ * Updates the relations to asset collections for the given resource.
+ *
+ * @param policyStore - RDF store that contains all the know collection metadata.
+ * @param id - The identifier 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 updateRelations(
+ policyStore: Store,
+ id: string,
+ description: ResourceDescription,
+ previous?: ResourceDescription
+ ): Promise<{ add: Quad[], remove: Quad[] }> {
+ const add: Record = this.getCollectionMetadata('resource_relations', description, id);
+ const remove: Record = this.getCollectionMetadata('resource_relations', previous, id);
+ this.filterRelationEntries(add, remove);
+
+ const part = DF.namedNode(id);
+ return {
+ add: this.generatePartOfTriples(part, Object.values(add), policyStore),
+ remove: this.generatePartOfTriples(part, Object.values(remove), policyStore),
+ };
+ }
+
+ /**
+ * Extract the relation metadata found in a resource description for the given field.
+ * @param field - One of the two fields that can contain relation metadata.
+ * @param description - The description to extract the info from.
+ * @param id - The identifier of the resource. This is only relevant for the `resource_defaults` field.
+ */
+ protected getCollectionMetadata(
+ field: 'resource_defaults' | 'resource_relations',
+ description?: ResourceDescription,
+ id?: string,
+ ): Record {
+ if (!description?.[field]) {
+ return {};
+ }
+
+ const result: { normal: NodeJS.Dict, reverse: NodeJS.Dict } = {
+ normal: { ...description[field] } as NodeJS.Dict,
+ reverse: description[field]['@reverse'] as NodeJS.Dict ?? {}
+ }
+ delete result.normal['@reverse'];
+
+ const sourceId = field === 'resource_defaults' ? id : undefined;
+ return {
+ // Note that for resource_relations, we want to find the collections this resource is part of,
+ // so we need to find the collection metadata defining those collections.
+ // E.g., if this resource has relation `L` to resource `R`,
+ // we need the collection metadata with source `R` and relation `reverse(L)`.
+ ...this.entriesToCollectionMetadata(result.normal, field === 'resource_relations', sourceId),
+ ...this.entriesToCollectionMetadata(result.reverse, field === 'resource_defaults', sourceId),
+ };
+ }
+
+ /**
+ * Converts resource_defaults/resource_relations entries to {@link CollectionMetadata entries}.
+ * @param entries - The key/value object as described for the corresponding field.
+ * @param reverse - If these are reverse relations (aka, found in the @reverse block of the description).
+ * @param id - The identifier of the resource.
+ * Only add this for `resource_defaults` entries as this will be used as the source when present.
+ */
+ protected entriesToCollectionMetadata(
+ entries: NodeJS.Dict,
+ reverse: boolean,
+ id?: string
+ ): Record {
+ const result: Record = {};
+ for (const [ relation, value ] of Object.entries(entries)) {
+ if (!value || value.length === 0) {
+ continue;
+ }
+ const relationNode = DF.namedNode(relation);
+ for (const source of id ? [ id ] : value) {
+ const entry: CollectionMetadata = {
+ relation: relationNode,
+ source: DF.namedNode(source),
+ reverse,
+ };
+ result[this.getRelationKey(entry)] = entry;
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Creates a unique key based on the {@link CollectionMetadata} values.
+ */
+ protected getRelationKey(entry: CollectionMetadata): string {
+ return `${entry.source.value}-${entry.relation.value}-${entry.reverse}`;
+ }
+
+ /**
+ */
+ /**
+ * Removes entries that are present in both maps.
+ * These are the entries that remain unchanged.
+ * It is assumed that matching values have the same keys.
+ */
+ protected filterRelationEntries(
+ record1: Record = {},
+ record2: Record = {},
+ ): void {
+ for (const key of Object.keys(record1)) {
+ if (record2[key]) {
+ delete record1[key];
+ delete record2[key];
+ }
+ }
+ }
+
+ /**
+ * Converts the given entries into triples to add or remove to/from the policy store.
+ * @param part - The identifier of the part that needs to be added to collections.
+ * @param entries - {@link CollectionMetadata} objects to parse.
+ * @param policyStore - {@link Store} with the relevant triples to update.
+ */
+ protected generatePartOfTriples(part: NamedNode, entries: CollectionMetadata[], policyStore: Store): Quad[] {
+ const quads: Quad[] = [];
+ for (const entry of entries) {
+ const collectionIds = this.findCollectionIds(entry, policyStore);
+ if (collectionIds.length === 0) {
+ throw new BadRequestHttpError(`Registering resource with relation ${entry.relation.value} to ${
+ entry.source.value} while there is no matching collection.`);
+ }
+
+ // for (const collectionId of collectionIds) {
+ // quads.push(DF.quad(part, ODRL.terms.partOf, collectionId));
+ // }
+ // TODO: the above code is correct, but the code below is currently needed because of a bug in the ODRL evaluator
+ // https://github.com/SolidLabResearch/ODRL-Evaluator/issues/8
+ quads.push(DF.quad(part, ODRL.terms.partOf, entry.source));
+ }
+ return quads;
+ }
+
+ /**
+ * Finds the identifiers of the collection(s) in the given {@link Store}
+ * that match the requirements of the given {@link CollectionMetadata}.
+ * @param entry - Relevant {@link CollectionMetadata}.
+ * @param data - {@link Store} in which to find the matching triples.
+ */
+ protected findCollectionIds(entry: CollectionMetadata, data: Store): Quad_Subject[] {
+ const sourceMatches = data.getSubjects(ODRL.terms.source, entry.source, null);
+ if (entry.reverse) {
+ const blanks = sourceMatches.flatMap((subject): Quad_Object[] =>
+ data.getObjects(subject, ODRL_P.terms.relation, null)) as Quad_Subject[];
+ return blanks.filter((subject): boolean =>
+ data.has(DF.quad(subject, OWL.terms.inverseOf, entry.relation)));
+ } else {
+ return sourceMatches.filter((subject): boolean =>
+ data.has(DF.quad(subject, ODRL_P.terms.relation, entry.relation)));
+ }
+ }
+
+ /**
+ * 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[] {
+ 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));
+ if (entry.reverse) {
+ const blank = DF.blankNode();
+ result.push(DF.quad(collectionId, ODRL_P.terms.relation, blank));
+ result.push(DF.quad(blank, OWL.terms.inverseOf, entry.relation));
+ } else {
+ result.push(DF.quad(collectionId, ODRL_P.terms.relation, entry.relation));
+ }
+ return result;
}
}
diff --git a/packages/uma/src/views/ResourceDescription.ts b/packages/uma/src/views/ResourceDescription.ts
index 6f526b64..713a91a5 100644
--- a/packages/uma/src/views/ResourceDescription.ts
+++ b/packages/uma/src/views/ResourceDescription.ts
@@ -1,8 +1,9 @@
-import { Type, array, optional as $, string } from "../util/ReType";
-import { ScopeDescription } from "./ScopeDescription";
+import { Type, array, optional as $, string, dict, union } from '../util/ReType';
export const ResourceDescription = {
resource_scopes: array(string),
+ resource_defaults: $(union({ '@reverse': dict(array(string)) }, dict(array(string)))),
+ resource_relations: $(union({ '@reverse': dict(array(string)) }, dict(array(string)))),
type: $(string),
name: $(string),
icon_uri: $(string),
diff --git a/scripts/test-collection.ts b/scripts/test-collection.ts
new file mode 100644
index 00000000..dffecabb
--- /dev/null
+++ b/scripts/test-collection.ts
@@ -0,0 +1,115 @@
+#!/usr/bin/env ts-node
+
+import { fetch } from 'cross-fetch'
+
+const collectedResource = "http://localhost:3000/alice/public/"
+const slug = "resource.txt";
+const body = "This is a resource.";
+
+function parseJwt (token:string) {
+ return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
+}
+
+const request: RequestInit = {
+ method: "PUT",
+ headers: {},
+};
+
+// Same as the private script but making use of a policy targeting a collection
+async function main() {
+
+ console.log('\n\n');
+
+ console.log(`=== Trying to create <${collectedResource}> without access token.\n`);
+
+ const noTokenResponse = await fetch(collectedResource, request);
+
+ const wwwAuthenticateHeader = noTokenResponse.headers.get("WWW-Authenticate")!
+
+ console.log(`= Status: ${noTokenResponse.status}\n`);
+ console.log(`= Www-Authenticate header: ${wwwAuthenticateHeader}\n`);
+ console.log('');
+
+ const { as_uri, ticket } = Object.fromEntries(wwwAuthenticateHeader.replace(/^UMA /,'').split(', ').map(
+ param => param.split('=').map(s => s.replace(/"/g,''))
+ ));
+
+ const tokenEndpoint = as_uri + "/token" // should normally be retrieved from .well-known/uma2-configuration
+
+ const claim_token = "https://woslabbi.pod.knows.idlab.ugent.be/profile/card#me"
+
+ const content = {
+ grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket',
+ ticket,
+ claim_token: encodeURIComponent(claim_token),
+ claim_token_format: 'urn:solidlab:uma:claims:formats:webid',
+ };
+
+
+ console.log(`=== Requesting token at ${tokenEndpoint} with ticket body:\n`);
+ console.log(content);
+ console.log('');
+
+ const asRequestResponse = await fetch(tokenEndpoint, {
+ method: "POST",
+ headers: {
+ "content-type":"application/json"
+ },
+ body: JSON.stringify(content),
+ })
+
+ // For debugging:
+ // console.log("Authorization Server response:", await asRequestResponse.text());
+ // throw 'stop'
+
+ const asResponse = await asRequestResponse.json();
+
+ console.log(`= Status: ${asRequestResponse.status}\n`);
+ console.log(`= Body (decoded):\n`);
+ console.log({ ...asResponse, access_token: asResponse.access_token.slice(0,10).concat('...') });
+ console.log('\n');
+
+ // for (const permission of decodedToken.permissions) {
+ // console.log(`Permissioned scopes for resource ${permission.resource_id}:`, permission.resource_scopes)
+ // }
+
+ console.log(`=== Trying to create collected resource <${collectedResource}> WITH access token.\n`);
+
+ request.headers = { 'Authorization': `${asResponse.token_type} ${asResponse.access_token}` };
+
+ const tokenResponse = await fetch(collectedResource, request);
+
+ console.log(`= Status: ${tokenResponse.status}\n`);
+
+ // Stuff below copied from old registration script as that one would not work anymore
+ console.log(`=== POST to <${collectedResource}> with slug '${slug}': "${body}"\n`)
+
+ const createResponse = await fetch(collectedResource, {
+ method: "POST",
+ headers: { slug },
+ body
+ })
+
+ console.log(`= Status: ${createResponse.status}\n`);
+ console.log('\n');
+
+ console.log(`=== GET <${collectedResource + slug}>\n`);
+
+ const readResponse = await fetch(collectedResource + slug, {
+ method: "GET",
+ })
+
+ console.log(`= Status: ${readResponse.status}\n`);
+ console.log(`= Body: "${await readResponse.text()}"\n`);
+ console.log('\n');
+
+ console.log(`=== DELETE <${collectedResource + slug}>\n`);
+
+ const deleteResponse = await fetch(collectedResource + slug, {
+ method: "DELETE",
+ })
+
+ console.log(`= Status: ${deleteResponse.status}\n`);
+}
+
+main();
diff --git a/scripts/test-registration.ts b/scripts/test-registration.ts
deleted file mode 100644
index c58338a6..00000000
--- a/scripts/test-registration.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env -S npx tsx
-
-const container = "http://localhost:3000/alice/public/";
-const slug = "resource.txt";
-const body = "This is a resource.";
-
-async function main() {
-
- console.log(`=== PUT container <${container}>\n`);
-
- const containerResponse = await fetch(container, {
- method: "PUT",
- })
-
- console.log(`= Status: ${containerResponse.status}\n`);
- console.log('\n');
-
- console.log(`=== POST to <${container}> with slug '${slug}': "${body}"\n`)
-
- const createResponse = await fetch(container, {
- method: "POST",
- headers: { slug },
- body
- })
-
- console.log(`= Status: ${createResponse.status}\n`);
- console.log('\n');
-
- console.log(`=== GET <${container + slug}>\n`);
-
- const readResponse = await fetch(container + slug, {
- method: "GET",
- })
-
- console.log(`= Status: ${readResponse.status}\n`);
- console.log(`= Body: "${await readResponse.text()}"\n`);
- console.log('\n');
-
- console.log(`=== DELETE <${container + slug}>\n`);
-
- const deleteResponse = await fetch(container + slug, {
- method: "DELETE",
- })
-
- console.log(`= Status: ${deleteResponse.status}\n`);
-}
-
-main();
diff --git a/test/integration/Base.test.ts b/test/integration/Base.test.ts
index 2a5248a8..821b59b7 100644
--- a/test/integration/Base.test.ts
+++ b/test/integration/Base.test.ts
@@ -42,10 +42,6 @@ describe('A server setup', (): void => {
});
describe('using public namespace authorization', (): void => {
- const container = `http://localhost:${cssPort}/alice/public/`;
- const slug = 'resource.txt';
- const body = 'This is a resource.';
-
it('RS: provides immediate read access.', async(): Promise => {
const publicResource = `http://localhost:${cssPort}/alice/profile/card`;
@@ -54,40 +50,6 @@ describe('A server setup', (): void => {
expect(publicResponse.status).toBe(200);
expect(publicResponse.headers.get('content-type')).toBe('text/turtle');
});
-
- it('RS: provides immediate create access to the container', async(): Promise => {
- const containerResponse = await fetch(container, {
- method: 'PUT',
- });
- expect(containerResponse.status).toBe(201);
- expect(containerResponse.headers.get('location')).toBe(container);
- });
-
- it('RS: provides immediate create access to the contents', async(): Promise => {
- const createResponse = await fetch(container, {
- method: 'POST',
- headers: { slug },
- body
- });
- expect(createResponse.status).toBe(201);
- expect(createResponse.headers.get('location')).toBe(`${container}${slug}`);
- });
-
- it('RS: provides immediate read access to the contents', async(): Promise => {
- const readResponse = await fetch(`${container}${slug}`);
- expect(readResponse.status).toBe(200);
- await expect(readResponse.text()).resolves.toBe(body);
- });
-
- it('RS: provides immediate delete access to the contents', async(): Promise => {
- const deleteResponse = await fetch(`${container}${slug}`, {
- method: 'DELETE',
- })
- expect(deleteResponse.status).toBe(205);
-
- const readResponse = await fetch(`${container}${slug}`);
- expect(readResponse.status).toBe(404);
- });
});
describe('using ODRL authorization', (): void => {
@@ -150,13 +112,10 @@ describe('A server setup', (): void => {
expect(jsonResponse.token_type).toBe('Bearer');
const token = JSON.parse(Buffer.from(jsonResponse.access_token.split('.')[1], 'base64').toString());
expect(Array.isArray(token.permissions)).toBe(true);
- expect(token.permissions).toHaveLength(2);
- expect(token.permissions).toContainEqual({
- resource_id: `http://localhost:${cssPort}/alice/private/resource.txt`,
- resource_scopes: [ 'urn:example:css:modes:append', 'urn:example:css:modes:create' ]
- });
+ expect(token.permissions).toHaveLength(1);
expect(token.permissions).toContainEqual({
- resource_id: `http://localhost:${cssPort}/alice/private/`,
+ // This is the first container on the path that already exists
+ resource_id: `http://localhost:${cssPort}/alice/`,
resource_scopes: [ 'urn:example:css:modes:create' ]
}
);
diff --git a/test/integration/Demo.test.ts b/test/integration/Demo.test.ts
index 15ca1f48..aa276124 100644
--- a/test/integration/Demo.test.ts
+++ b/test/integration/Demo.test.ts
@@ -105,7 +105,7 @@ async function umaFetch(input: string | URL | globalThis.Request, init?: Request
describe('A demo server setup', (): void => {
let umaApp: App;
let cssApp: App;
- const policyContainer = `http://localhost:${cssPort}/ruben/settings/policies/`;
+ const policyContainer = `http://localhost:${cssPort}/settings/policies/`;
beforeAll(async(): Promise => {
setGlobalLoggerFactory(new WinstonLoggerFactory('off'));
@@ -124,7 +124,10 @@ describe('A demo server setup', (): void => {
cssApp = await instantiateFromConfig(
'urn:solid-server:default:App',
// Not using the demo config as that one writes to disk, this is the same but in memory
- path.join(__dirname, '../../packages/css/config/default.json'),
+ [
+ path.join(__dirname, '../../packages/css/config/default.json'),
+ path.join(__dirname, '../../packages/css/config/uma/demo.json'),
+ ],
{
...getDefaultCssVariables(cssPort),
'urn:solid-server:uma:variable:AuthorizationServer': `http://localhost:${umaPort}/`,
@@ -149,7 +152,8 @@ describe('A demo server setup', (): void => {
odrl:permission ex:permission .
ex:permission a odrl:Permission ;
odrl:action odrl:create, odrl:append ;
- odrl:target ,
+ odrl:target ,
+ ,
,
,
;
@@ -157,8 +161,8 @@ describe('A demo server setup', (): void => {
odrl:assigner <${terms.agents.ruben}> .
`;
- // Create policies container
- let response = await fetch(`http://localhost:${cssPort}/ruben/settings/policies/policy`, {
+ // Create policy
+ let response = await fetch(`http://localhost:${cssPort}/settings/policies/policy`, {
method: 'PUT',
headers: { 'content-type': 'text/turtle' },
body: policy,
diff --git a/test/integration/Odrl.test.ts b/test/integration/Odrl.test.ts
index 6c47280b..83a0b3a7 100644
--- a/test/integration/Odrl.test.ts
+++ b/test/integration/Odrl.test.ts
@@ -98,16 +98,12 @@ describe('An ODRL server setup', (): void => {
expect(jsonResponse.token_type).toBe('Bearer');
const token = JSON.parse(Buffer.from(jsonResponse.access_token.split('.')[1], 'base64').toString());
expect(Array.isArray(token.permissions)).toBe(true);
- expect(token.permissions).toHaveLength(2);
+ expect(token.permissions).toHaveLength(1);
expect(token.permissions).toContainEqual({
- resource_id: resource,
- resource_scopes: [ 'urn:example:css:modes:append', 'urn:example:css:modes:create' ]
+ // This is the first container on the path that already exists
+ resource_id: `http://localhost:${cssPort}/alice/`,
+ resource_scopes: [ 'urn:example:css:modes:create' ]
});
- expect(token.permissions).toContainEqual({
- resource_id: `http://localhost:${cssPort}/alice/other/`,
- resource_scopes: [ 'urn:example:css:modes:create' ]
- }
- );
});
it('RS: provides access when receiving a valid token.', async(): Promise => {