From 590b47467f4c0727c8b7c38bfb52a370075eb62d Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Mon, 12 May 2025 14:31:57 +0200 Subject: [PATCH 01/10] feat: Support PUT requests to update UMA registration --- packages/uma/config/routes/resources.json | 4 +- packages/uma/src/routes/Config.ts | 2 +- .../uma/src/routes/ResourceRegistration.ts | 45 ++++++++++++++++--- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/uma/config/routes/resources.json b/packages/uma/config/routes/resources.json index 49af99c1..b16fa5ee 100644 --- a/packages/uma/config/routes/resources.json +++ b/packages/uma/config/routes/resources.json @@ -13,12 +13,12 @@ "@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/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..a5e378cf 100644 --- a/packages/uma/src/routes/ResourceRegistration.ts +++ b/packages/uma/src/routes/ResourceRegistration.ts @@ -3,8 +3,10 @@ import { createErrorMessage, getLoggerFor, KeyValueStorage, - MethodNotAllowedHttpError, NotFoundHttpError, - UnauthorizedHttpError + MethodNotAllowedHttpError, + NotFoundHttpError, + UnauthorizedHttpError, + UnsupportedMediaTypeHttpError, } from '@solid/community-server'; import { randomUUID } from 'node:crypto'; import { @@ -43,12 +45,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> { + private async handlePost(request: HttpHandlerRequest): Promise { const { body } = request; try { @@ -69,16 +72,48 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { _id: resource, user_access_policy_uri: 'TODO: implement policy UI', }, - }) + }); } - private async handleDelete({ parameters }: HttpHandlerRequest): Promise> { + private 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)}`); + } + + await this.resourceStore.set(parameters.id, body); + this.logger.info(`Updated resource ${parameters.id}.`); + + 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 { 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 }; From af4c7170601198795c7bc1e4dbd8af7e8a0ec1e3 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 16 May 2025 09:40:37 +0200 Subject: [PATCH 02/10] feat: Use resource identifier as UMA ID --- packages/css/src/uma/UmaClient.ts | 26 ++++++++++--------- .../uma/src/routes/ResourceRegistration.ts | 13 +++++++++- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/css/src/uma/UmaClient.ts b/packages/css/src/uma/UmaClient.ts index 3eca01f5..c88edfd8 100644 --- a/packages/css/src/uma/UmaClient.ts +++ b/packages/css/src/uma/UmaClient.ts @@ -33,10 +33,10 @@ export type UmaVerificationOptions = Omit { 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 +177,7 @@ export class UmaClient { */ public async verifyOpaqueToken(token: string, issuer: string): Promise { let config: UmaConfig; - + try { config = await this.fetchUmaConfig(issuer); } catch (error: unknown) { @@ -237,6 +237,7 @@ export class UmaClient { const { resource_registration_endpoint: endpoint } = await this.fetchUmaConfig(issuer); const description: ResourceDescription = { + name: resource.path, resource_scopes: [ 'urn:example:css:modes:read', 'urn:example:css:modes:append', @@ -265,12 +266,13 @@ export class UmaClient { } const { _id: umaId } = await resp.json(); - + if (!umaId || typeof umaId !== 'string') { throw new Error ('Unexpected response from UMA server; no UMA id received.'); } - - this.umaIdStore.set(resource.path, umaId); + + await this.umaIdStore.set(resource.path, umaId); + this.logger.info(`Registered resource ${resource.path} with UMA ID ${umaId}`); }).catch(error => { // TODO: Do something useful on error this.logger.warn( @@ -279,7 +281,7 @@ export class UmaClient { }); } - public async deleteResource(resource: ResourceIdentifier, issuer: string): Promise { + 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}>`); diff --git a/packages/uma/src/routes/ResourceRegistration.ts b/packages/uma/src/routes/ResourceRegistration.ts index a5e378cf..6c515c5a 100644 --- a/packages/uma/src/routes/ResourceRegistration.ts +++ b/packages/uma/src/routes/ResourceRegistration.ts @@ -1,5 +1,6 @@ import { BadRequestHttpError, + ConflictHttpError, createErrorMessage, getLoggerFor, KeyValueStorage, @@ -61,7 +62,17 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { throw new BadRequestHttpError(`Request has bad syntax: ${createErrorMessage(e)}`); } - const resource = randomUUID(); + // 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(`${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.'); + } await this.resourceStore.set(resource, body); this.logger.info(`Registered resource ${resource}.`); From 65bbaefa355ed5a5de816a2c927fa297c3d68ea9 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 16 May 2025 10:57:14 +0200 Subject: [PATCH 03/10] feat: Check parent permissions when creating new resources --- packages/css/config/uma/overrides/modes.json | 11 +++- .../authorization/ParentCreateExtractor.ts | 61 +++++++++++++++++++ packages/css/src/index.ts | 1 + packages/uma/config/rules/odrl/policy0.ttl | 2 +- packages/uma/config/rules/policy/policy0.ttl | 2 +- test/integration/Base.test.ts | 47 +------------- test/integration/Odrl.test.ts | 12 ++-- 7 files changed, 80 insertions(+), 56 deletions(-) create mode 100644 packages/css/src/authorization/ParentCreateExtractor.ts 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/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/index.ts b/packages/css/src/index.ts index 86784098..a06b13f7 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'; 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..d9715969 100644 --- a/packages/uma/config/rules/policy/policy0.ttl +++ b/packages/uma/config/rules/policy/policy0.ttl @@ -13,6 +13,6 @@ 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 . 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/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 => { From 1137ff14ebc6a028d2e6ee7d417132429a8cf3ee Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 24 Jul 2025 14:17:12 +0200 Subject: [PATCH 04/10] feat: Initialize empty container for policies in demo setup --- demo/flow-test.ts | 2 +- demo/flow.ts | 2 +- packages/css/config/demo.json | 1 + packages/css/config/uma/demo.json | 40 +++++++++++++++++++ packages/css/package.json | 2 +- packages/css/src/index.ts | 1 + .../css/src/init/EmptyContainerInitializer.ts | 38 ++++++++++++++++++ packages/uma/bin/demo.js | 2 +- test/integration/Demo.test.ts | 14 ++++--- 9 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 packages/css/config/uma/demo.json create mode 100644 packages/css/src/init/EmptyContainerInitializer.ts 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/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/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/index.ts b/packages/css/src/index.ts index a06b13f7..b69147c0 100644 --- a/packages/css/src/index.ts +++ b/packages/css/src/index.ts @@ -10,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/uma/bin/demo.js b/packages/uma/bin/demo.js index 4bde38ec..7e2d0e2a 100644 --- a/packages/uma/bin/demo.js +++ b/packages/uma/bin/demo.js @@ -16,7 +16,7 @@ const launch = async () => { 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/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, From e25fdd32b55d482741a22cf5e3c751e301fb89d1 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 16 May 2025 10:56:38 +0200 Subject: [PATCH 05/10] feat: Use UMA IDs during communication --- packages/css/.componentsignore | 3 +- packages/css/config/uma/parts/client.json | 3 +- packages/css/src/uma/ResourceRegistrar.ts | 15 ++- packages/css/src/uma/UmaClient.ts | 112 ++++++++++++------ packages/uma/config/demo.json | 3 +- .../config/policies/authorizers/default.json | 3 +- .../authorizers/NamespacedAuthorizer.ts | 38 +++++- 7 files changed, 126 insertions(+), 51 deletions(-) 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/uma/parts/client.json b/packages/css/config/uma/parts/client.json index 8946838a..1a27c7e6 100644 --- a/packages/css/config/uma/parts/client.json +++ b/packages/css/config/uma/parts/client.json @@ -14,7 +14,8 @@ }, "fetcher": { "@id": "urn:solid-server:default:UmaFetcher" - } + }, + "resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" } } ] } diff --git a/packages/css/src/uma/ResourceRegistrar.ts b/packages/css/src/uma/ResourceRegistrar.ts index d6def313..7f3253d2 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.createResource(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 c88edfd8..80511cfd 100644 --- a/packages/css/src/uma/UmaClient.ts +++ b/packages/css/src/uma/UmaClient.ts @@ -1,7 +1,18 @@ -import { type KeyValueStorage, type ResourceIdentifier, AccessMap, getLoggerFor } from "@solid/community-server"; -import type { Fetcher } from "../util/fetch/Fetcher"; +import { + AccessMap, + getLoggerFor, + InternalServerError, 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; @@ -58,17 +69,30 @@ const algMap = { /** * Client interface for the UMA AS */ -export class UmaClient { +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(); + /** - * @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 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 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 +113,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.createResource(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}`) }); } @@ -234,6 +281,7 @@ export class UmaClient { } public async createResource(resource: ResourceIdentifier, issuer: string): Promise { + this.inProgressResources.add(resource.path); const { resource_registration_endpoint: endpoint } = await this.fetchUmaConfig(issuer); const description: ResourceDescription = { @@ -259,8 +307,7 @@ 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 => { + return this.fetcher.fetch(endpoint, request).then(async resp => { if (resp.status !== 201) { throw new Error (`Resource registration request failed. ${await resp.text()}`); } @@ -273,39 +320,28 @@ export class UmaClient { await this.umaIdStore.set(resource.path, umaId); this.logger.info(`Registered resource ${resource.path} with UMA ID ${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); }); } + /** + * 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/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..3f4e05c9 100644 --- a/packages/uma/config/policies/authorizers/default.json +++ b/packages/uma/config/policies/authorizers/default.json @@ -62,7 +62,8 @@ "@id": "urn:uma:variables:policyBaseIRI" } } - } + }, + "resourceStore": { "@id": "urn:uma:default:ResourceRegistrationStore" } } ] } 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); + } } From 99bb5f23469f9faa48eb271f49140c49f69193da Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 20 May 2025 13:56:42 +0200 Subject: [PATCH 06/10] feat: Update UCRulesStorage interface to allow data removal --- .../src/storage/ContainerUCRulesStorage.ts | 160 ++++++++++++------ .../src/storage/DirectoryUCRulesStorage.ts | 30 +++- .../ucp/src/storage/MemoryUCRulesStorage.ts | 16 +- packages/ucp/src/storage/UCRulesStorage.ts | 19 ++- 4 files changed, 151 insertions(+), 74 deletions(-) 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; +} From b73927aeb3ca9c0292129d4912e96f479c5f494d Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 24 Jul 2025 13:42:02 +0200 Subject: [PATCH 07/10] feat: Register ldp:relations during UMA registration --- packages/css/config/uma/parts/client.json | 1 + packages/css/src/uma/ResourceRegistrar.ts | 2 +- packages/css/src/uma/UmaClient.ts | 109 ++++++++++++++---- packages/uma/src/views/ResourceDescription.ts | 5 +- 4 files changed, 94 insertions(+), 23 deletions(-) diff --git a/packages/css/config/uma/parts/client.json b/packages/css/config/uma/parts/client.json index 1a27c7e6..21646ac0 100644 --- a/packages/css/config/uma/parts/client.json +++ b/packages/css/config/uma/parts/client.json @@ -15,6 +15,7 @@ "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/src/uma/ResourceRegistrar.ts b/packages/css/src/uma/ResourceRegistrar.ts index 7f3253d2..321d46bd 100644 --- a/packages/css/src/uma/ResourceRegistrar.ts +++ b/packages/css/src/uma/ResourceRegistrar.ts @@ -18,7 +18,7 @@ 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)).catch((err: Error) => { + this.umaClient.registerResource(resource, await this.findIssuer(owner)).catch((err: Error) => { this.logger.error(`Unable to register resource ${resource.path}: ${createErrorMessage(err)}`); }); } diff --git a/packages/css/src/uma/UmaClient.ts b/packages/css/src/uma/UmaClient.ts index 80511cfd..cd1670d0 100644 --- a/packages/css/src/uma/UmaClient.ts +++ b/packages/css/src/uma/UmaClient.ts @@ -1,7 +1,10 @@ import { AccessMap, getLoggerFor, - InternalServerError, joinUrl, + IdentifierStrategy, + InternalServerError, + isContainerIdentifier, + joinUrl, KeyValueStorage, NotFoundHttpError, ResourceIdentifier, @@ -67,7 +70,10 @@ const algMap = { } /** - * Client interface for the UMA AS + * Client interface for the UMA AS. + * + * This class uses an EventEmitter and an in-memory map to keep track of registration progress, + * so does not work with worker threads. */ export class UmaClient implements SingleThreaded { protected readonly logger = getLoggerFor(this); @@ -80,12 +86,14 @@ export class UmaClient implements SingleThreaded { /** * @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 readonly umaIdStore: KeyValueStorage, protected readonly fetcher: Fetcher, + protected readonly identifierStrategy: IdentifierStrategy, protected readonly resourceSet: ResourceSet, protected readonly options: UmaVerificationOptions = {}, ) { @@ -128,7 +136,7 @@ export class UmaClient implements SingleThreaded { // 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.createResource(target, issuer); + await this.registerResource(target, issuer); umaId = await this.umaIdStore.get(target.path); } else { throw new NotFoundHttpError(); @@ -280,9 +288,31 @@ export class UmaClient implements SingleThreaded { return configuration; } - public async createResource(resource: ResourceIdentifier, issuer: string): Promise { + /** + * 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); - const { resource_registration_endpoint: endpoint } = await this.fetchUmaConfig(issuer); + 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, @@ -292,14 +322,43 @@ export class UmaClient implements SingleThreaded { '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', @@ -307,23 +366,33 @@ export class UmaClient implements SingleThreaded { body: JSON.stringify(description), }; - return 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}`); + await this.umaIdStore.set(resource.path, umaId); + 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); }); + + // Execute all the required promises. + promises.push(fetchPromise); + await Promise.all(promises); } /** 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), From f1638014f3738422e72b13a7501d2b6ad40c2cdc Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 20 May 2025 13:57:48 +0200 Subject: [PATCH 08/10] feat: Store collection metadata as triples when registering --- README.md | 3 +- package.json | 4 +- packages/ucp/src/util/Vocabularies.ts | 19 +- .../config/policies/authorizers/default.json | 19 +- packages/uma/config/routes/resources.json | 3 +- packages/uma/config/rules/policy/policy0.ttl | 13 + .../uma/src/routes/ResourceRegistration.ts | 290 +++++++++++++++++- scripts/test-collection.ts | 115 +++++++ scripts/test-registration.ts | 48 --- 9 files changed, 441 insertions(+), 73 deletions(-) create mode 100644 scripts/test-collection.ts delete mode 100644 scripts/test-registration.ts diff --git a/README.md b/README.md index 34292256..89aa0301 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ 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/`. `yarn script:flow` runs all flows in sequence. 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/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 . @prefix odrl: . +@prefix odrl_p: . ex:usagePolicy a odrl:Agreement . ex:usagePolicy odrl:permission ex:permission . @@ -16,3 +17,15 @@ ex:permission2 odrl:action odrl:create , odrl:modify . 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/routes/ResourceRegistration.ts b/packages/uma/src/routes/ResourceRegistration.ts index 6c515c5a..af5430f3 100644 --- a/packages/uma/src/routes/ResourceRegistration.ts +++ b/packages/uma/src/routes/ResourceRegistration.ts @@ -8,7 +8,10 @@ import { 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, @@ -20,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. @@ -29,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 @@ -52,7 +65,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { } } - private async handlePost(request: HttpHandlerRequest): Promise { + protected async handlePost(request: HttpHandlerRequest): Promise { const { body } = request; try { @@ -67,15 +80,17 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { let resource = body.name; if (resource) { if (await this.resourceStore.has(resource)) { - throw new ConflictHttpError(`${resource} is already registered. Use PUT to update existing registrations.`); + 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.'); } - await this.resourceStore.set(resource, body); - this.logger.info(`Registered resource ${resource}.`); + // Set the resource metadata + await this.setResourceMetadata(resource, body); return ({ status: 201, @@ -86,7 +101,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { }); } - private async handlePut({ body, headers, parameters }: HttpHandlerRequest): Promise { + 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)) { @@ -104,8 +119,8 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { throw new BadRequestHttpError(`Request has bad syntax: ${createErrorMessage(e)}`); } - await this.resourceStore.set(parameters.id, body); - this.logger.info(`Updated resource ${parameters.id}.`); + // Update the resource metadata + await this.setResourceMetadata(parameters.id, body); return ({ status: 200, @@ -117,7 +132,7 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { }); } - 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)) { @@ -127,6 +142,261 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { 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/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(); From d48a87208a734c37ef9b5b915b44141e85024a26 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 21 May 2025 09:48:33 +0200 Subject: [PATCH 09/10] docs: Explain asset collection implementation --- README.md | 1 + documentation/collections.md | 188 +++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 documentation/collections.md diff --git a/README.md b/README.md index 89aa0301..082bc7b3 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ You can then execute the following flows: - `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: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/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. From dff38233842b0e15c4829b9dc508ca7c36c38e45 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 24 Jul 2025 11:09:54 +0200 Subject: [PATCH 10/10] fix: Throw HttpError in UmaAuthorizer --- packages/css/src/authorization/UmaAuthorizer.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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}.`); } } }