diff --git a/documentation/collections.md b/documentation/collections.md index d8da4f9..096c0b1 100644 --- a/documentation/collections.md +++ b/documentation/collections.md @@ -82,6 +82,16 @@ All these triples then get passed to the ODRL evaluator when policies need to be 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 ]`. +### Finding collection identifiers + +Currently, there is no API yet to request a list of all the automatically registered collections described above. +As a workaround, the generated collection identifiers are fixed, based on the relevant identifiers. +A collection with source `http://example.com/container/` and relation `http://www.w3.org/ns/ldp#contains`, +would have as collection identifier `collection:http://example.com/container/:http://www.w3.org/ns/ldp#contains`. +In case of a reverse relationship, this would instead be +`collection:http://www.w3.org/ns/ldp#contains:http://example.com/container/`. +These are the identifiers to then use as targets in a policy. + ## Updating collection triples Every time a resource is updated, the corresponding collection triples are updated accordingly. diff --git a/packages/uma/src/routes/ResourceRegistration.ts b/packages/uma/src/routes/ResourceRegistration.ts index 75e1054..fcb2601 100644 --- a/packages/uma/src/routes/ResourceRegistration.ts +++ b/packages/uma/src/routes/ResourceRegistration.ts @@ -242,7 +242,11 @@ export class ResourceRegistrationRequestHandler extends HttpHandler { if (collections.length > 0) { delete add[key]; } else { - addQuads.push(...this.generateCollectionTriples(entry)); + // TODO: currently generating fixed collection ID as there is no API to get them + addQuads.push(...this.generateCollectionTriples(entry, DF.namedNode(`collection:${entry.reverse ? + entry.relation.value + ':' + entry.source.value : + entry.source.value + ':' + entry.relation.value + }`))); } } diff --git a/packages/uma/src/util/routeSpecific/get.ts b/packages/uma/src/util/routeSpecific/get.ts index 00f4ff4..0d99262 100644 --- a/packages/uma/src/util/routeSpecific/get.ts +++ b/packages/uma/src/util/routeSpecific/get.ts @@ -61,11 +61,12 @@ const executeGet = async ( const buildPolicyRetrievalQuery = (policyID: string, resourceOwner: string) => ` PREFIX odrl: - SELECT DISTINCT ?policy ?perm + SELECT DISTINCT ?policy ?perm ?target WHERE { ?policy odrl:uid <${policyID}> ; odrl:permission ?perm . - ?perm odrl:assigner <${resourceOwner}> . + ?perm odrl:assigner <${resourceOwner}> ; + odrl:target ?target . { ?policy a odrl:Agreement . @@ -85,7 +86,7 @@ const buildPolicyRetrievalQuery = (policyID: string, resourceOwner: string) => ` * @returns a store containing the policy and its permissions */ export const getPolicy = (store: Store, policyID: string, resourceOwner: string) => - executeGet(store, buildPolicyRetrievalQuery(policyID, resourceOwner), ['policy', 'perm']); + executeGet(store, buildPolicyRetrievalQuery(policyID, resourceOwner), ['policy', 'perm', 'target']); /** * Build a query to retrieve all policies for a given client. @@ -97,10 +98,11 @@ export const getPolicy = (store: Store, policyID: string, resourceOwner: string) const buildPoliciesRetrievalQuery = (resourceOwner: string) => ` PREFIX odrl: - SELECT DISTINCT ?policy ?perm + SELECT DISTINCT ?policy ?perm ?target WHERE { ?policy odrl:permission ?perm . - ?perm odrl:assigner <${resourceOwner}> . + ?perm odrl:assigner <${resourceOwner}> ; + odrl:target ?target . { ?policy a odrl:Agreement . @@ -118,7 +120,7 @@ const buildPoliciesRetrievalQuery = (resourceOwner: string) => ` * @returns a store containing all policies and their permissions */ export const getPolicies = (store: Store, resourceOwner: string) => - executeGet(store, buildPoliciesRetrievalQuery(resourceOwner), ['perm']); + executeGet(store, buildPoliciesRetrievalQuery(resourceOwner), ['perm', 'target']); // TODO: slight improvement over existing solution so constraints get returned but definitely not ideal yet function permissionToQuads(store: Store, permission: Quad_Subject): Quad[] { diff --git a/packages/uma/src/util/routeSpecific/post.ts b/packages/uma/src/util/routeSpecific/post.ts index 9a7f96d..fd76299 100644 --- a/packages/uma/src/util/routeSpecific/post.ts +++ b/packages/uma/src/util/routeSpecific/post.ts @@ -60,7 +60,7 @@ const executePost = async ( const buildPolicyCreationQuery = (resourceOwner: string) => ` PREFIX odrl: - SELECT DISTINCT ?p ?r + SELECT DISTINCT ?p ?r ?target WHERE { { ?p a odrl:Agreement ; diff --git a/packages/uma/test/unit/routes/ResourceRegistration.test.ts b/packages/uma/test/unit/routes/ResourceRegistration.test.ts index 314d316..fa215ca 100644 --- a/packages/uma/test/unit/routes/ResourceRegistration.test.ts +++ b/packages/uma/test/unit/routes/ResourceRegistration.test.ts @@ -113,9 +113,10 @@ describe('ResourceRegistration', (): void => { }); it('stores newly created asset collections.', async(): Promise => { - const crypto = await import('node:crypto'); - let count = 0; - vi.mocked(crypto.randomUUID).mockImplementation(() => `${++count}` as any); + // TODO: not needed while collection identifiers are fixed + // const crypto = await import('node:crypto'); + // let count = 0; + // vi.mocked(crypto.randomUUID).mockImplementation(() => `${++count}` as any); input.request.body!.resource_defaults = { pred: [ 'scope' ], '@reverse': { 'rPred': [ 'otherScope' ]}}; await expect(handler.handle(input)).resolves.toEqual({ status: 201, @@ -125,12 +126,12 @@ describe('ResourceRegistration', (): void => { expect(policies.addRule).toHaveBeenCalledTimes(1); const newStore = policies.addRule.mock.calls[0][0]; expect(newStore).toBeRdfIsomorphic([ - DF.quad(DF.namedNode('collection:1'), RDF.terms.type, ODRL.terms.AssetCollection), - DF.quad(DF.namedNode('collection:1'), ODRL.terms.source, DF.namedNode('name')), - DF.quad(DF.namedNode('collection:1'), ODRL_P.terms.relation, DF.namedNode('pred')), - DF.quad(DF.namedNode('collection:2'), RDF.terms.type, ODRL.terms.AssetCollection), - DF.quad(DF.namedNode('collection:2'), ODRL.terms.source, DF.namedNode('name')), - DF.quad(DF.namedNode('collection:2'), ODRL_P.terms.relation, DF.blankNode('n3-0')), + DF.quad(DF.namedNode('collection:name:pred'), RDF.terms.type, ODRL.terms.AssetCollection), + DF.quad(DF.namedNode('collection:name:pred'), ODRL.terms.source, DF.namedNode('name')), + DF.quad(DF.namedNode('collection:name:pred'), ODRL_P.terms.relation, DF.namedNode('pred')), + DF.quad(DF.namedNode('collection:rPred:name'), RDF.terms.type, ODRL.terms.AssetCollection), + DF.quad(DF.namedNode('collection:rPred:name'), ODRL.terms.source, DF.namedNode('name')), + DF.quad(DF.namedNode('collection:rPred:name'), ODRL_P.terms.relation, DF.blankNode('n3-0')), DF.quad(DF.blankNode('n3-0'), OWL.terms.inverseOf, DF.namedNode('rPred')), ]); }); @@ -221,9 +222,10 @@ describe('ResourceRegistration', (): void => { }); it('stores newly created asset collections.', async(): Promise => { - const crypto = await import('node:crypto'); - let count = 0; - vi.mocked(crypto.randomUUID).mockImplementation(() => `${++count}` as any); + // TODO: not needed while collection identifiers are fixed + // const crypto = await import('node:crypto'); + // let count = 0; + // vi.mocked(crypto.randomUUID).mockImplementation(() => `${++count}` as any); input.request.body!.resource_defaults = { pred: [ 'scope' ], '@reverse': { 'rPred': [ 'otherScope' ]}}; await expect(handler.handle(input)).resolves.toEqual({ status: 200, @@ -232,12 +234,12 @@ describe('ResourceRegistration', (): void => { expect(policies.addRule).toHaveBeenCalledTimes(1); const newStore = policies.addRule.mock.calls[0][0]; expect(newStore).toBeRdfIsomorphic([ - DF.quad(DF.namedNode('collection:1'), RDF.terms.type, ODRL.terms.AssetCollection), - DF.quad(DF.namedNode('collection:1'), ODRL.terms.source, DF.namedNode('name')), - DF.quad(DF.namedNode('collection:1'), ODRL_P.terms.relation, DF.namedNode('pred')), - DF.quad(DF.namedNode('collection:2'), RDF.terms.type, ODRL.terms.AssetCollection), - DF.quad(DF.namedNode('collection:2'), ODRL.terms.source, DF.namedNode('name')), - DF.quad(DF.namedNode('collection:2'), ODRL_P.terms.relation, DF.blankNode('n3-0')), + DF.quad(DF.namedNode('collection:name:pred'), RDF.terms.type, ODRL.terms.AssetCollection), + DF.quad(DF.namedNode('collection:name:pred'), ODRL.terms.source, DF.namedNode('name')), + DF.quad(DF.namedNode('collection:name:pred'), ODRL_P.terms.relation, DF.namedNode('pred')), + DF.quad(DF.namedNode('collection:rPred:name'), RDF.terms.type, ODRL.terms.AssetCollection), + DF.quad(DF.namedNode('collection:rPred:name'), ODRL.terms.source, DF.namedNode('name')), + DF.quad(DF.namedNode('collection:rPred:name'), ODRL_P.terms.relation, DF.blankNode('n3-0')), DF.quad(DF.blankNode('n3-0'), OWL.terms.inverseOf, DF.namedNode('rPred')), ]); }); diff --git a/test/integration/Collections.test.ts b/test/integration/Collections.test.ts new file mode 100644 index 0000000..b6e88db --- /dev/null +++ b/test/integration/Collections.test.ts @@ -0,0 +1,91 @@ +import { App, joinUrl } from '@solid/community-server'; +import { setGlobalLoggerFactory, WinstonLoggerFactory } from 'global-logger-factory'; +import path from 'node:path'; +import { getDefaultCssVariables, getPorts, instantiateFromConfig } from '../util/ServerUtil'; +import { generateCredentials, umaFetch } from '../util/UmaUtil'; + +const [ cssPort, umaPort ] = getPorts('Collections'); + +describe('A server with collections', (): void => { + const owner = `http://localhost:${cssPort}/alice/profile/card#me`; + const user = `http://example.com/bob`; + let umaApp: App; + let cssApp: App; + + beforeAll(async(): Promise => { + setGlobalLoggerFactory(new WinstonLoggerFactory('off')); + + umaApp = await instantiateFromConfig( + 'urn:uma:default:App', + path.join(__dirname, '../../packages/uma/config/default.json'), + { + 'urn:uma:variables:port': umaPort, + 'urn:uma:variables:baseUrl': `http://localhost:${umaPort}/uma`, + 'urn:uma:variables:eyePath': 'eye', + 'urn:uma:variables:backupFilePath': '', + } + ); + + cssApp = await instantiateFromConfig( + 'urn:solid-server:default:App', + path.join(__dirname, '../../packages/css/config/default.json'), + { + ...getDefaultCssVariables(cssPort), + 'urn:solid-server:default:variable:seedConfig': path.join(__dirname, '../../packages/css/config/seed.json'), + }, + ); + + await Promise.all([ umaApp.start(), cssApp.start() ]); + }); + + afterAll(async(): Promise => { + await Promise.all([ umaApp.stop(), cssApp.stop() ]); + }); + + it('can register client credentials for the user/RS combination.', async(): Promise => { + await generateCredentials({ + webId: owner, + authorizationServer: `http://localhost:${umaPort}/uma`, + resourceServer: `http://localhost:${cssPort}/`, + email: 'alice@example.org', + password: 'abc123' + }); + }); + + it('can create a policy targeting an asset collection.', async(): Promise => { + // TODO: hardcoded collection identifier due to lack of collection API + const policy = ` + @prefix ex: . + @prefix ldp: . + @prefix odrl: . + @prefix odrl_p: . + + ex:policy a odrl:Set ; + odrl:uid ex:policy ; + odrl:permission ex:permission . + + ex:permission a odrl:Permission ; + odrl:assignee <${user}> ; + odrl:assigner <${owner}> ; + odrl:action odrl:read ; + odrl:target .` + + const url = `http://localhost:${umaPort}/uma/policies`; + let response = await fetch(url, { + method: 'POST', + headers: { authorization: `WebID ${encodeURIComponent(owner)}`, 'content-type': 'text/turtle' }, + body: policy, + }); + expect(response.status).toBe(201); + + response = await fetch(joinUrl(url, encodeURIComponent('http://example.org/policy')), { + headers: { authorization: `WebID ${encodeURIComponent(owner)}` }, + }); + console.log(await response.text()); + }); + + it('can access a resource in the asset collection.', async(): Promise => { + const response = await umaFetch(`http://localhost:${cssPort}/alice/README`, {}, user); + expect(response.status).toBe(200); + }); +}); diff --git a/test/util/ServerUtil.ts b/test/util/ServerUtil.ts index 7316dcf..bc66601 100644 --- a/test/util/ServerUtil.ts +++ b/test/util/ServerUtil.ts @@ -8,6 +8,7 @@ const portNames = [ 'Aggregation', 'AggregationSource', 'Base', + 'Collections', 'Demo', 'ODRL', 'OIDC',