From a6409ad00d8034931873accedc2884abf556e23e Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 10 Aug 2022 13:40:50 +0200 Subject: [PATCH] feat: Create AcpReader --- package-lock.json | 11 ++ package.json | 1 + src/authorization/AcpReader.ts | 164 +++++++++++++++++++++ src/authorization/AcpUtil.ts | 101 +++++++++++++ src/index.ts | 2 + src/util/Vocabularies.ts | 23 +++ test/unit/authorization/AcpReader.test.ts | 170 ++++++++++++++++++++++ test/unit/authorization/AcpUtil.test.ts | 122 ++++++++++++++++ 8 files changed, 594 insertions(+) create mode 100644 src/authorization/AcpReader.ts create mode 100644 src/authorization/AcpUtil.ts create mode 100644 test/unit/authorization/AcpReader.test.ts create mode 100644 test/unit/authorization/AcpUtil.test.ts diff --git a/package-lock.json b/package-lock.json index 661e0be3c..e5b5bef01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@comunica/context-entries": "^2.2.0", "@comunica/query-sparql": "^2.2.1", "@rdfjs/types": "^1.1.0", + "@solid/access-control-policy": "^0.1.2", "@solid/access-token-verifier": "^2.0.3", "@types/async-lock": "^1.1.5", "@types/bcryptjs": "^2.4.2", @@ -3832,6 +3833,11 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@solid/access-control-policy": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@solid/access-control-policy/-/access-control-policy-0.1.2.tgz", + "integrity": "sha512-zviquBk05id837Ff3dJTGwlt0y+ocWtHuLEuZenramh7qVXUoJuFQ6BnxiMDxUJY/rCpNEmjyE8rn+dT/NpMqA==" + }, "node_modules/@solid/access-token-verifier": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@solid/access-token-verifier/-/access-token-verifier-2.0.3.tgz", @@ -18582,6 +18588,11 @@ "@sinonjs/commons": "^1.7.0" } }, + "@solid/access-control-policy": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@solid/access-control-policy/-/access-control-policy-0.1.2.tgz", + "integrity": "sha512-zviquBk05id837Ff3dJTGwlt0y+ocWtHuLEuZenramh7qVXUoJuFQ6BnxiMDxUJY/rCpNEmjyE8rn+dT/NpMqA==" + }, "@solid/access-token-verifier": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@solid/access-token-verifier/-/access-token-verifier-2.0.3.tgz", diff --git a/package.json b/package.json index 5004de2e1..98a3e631d 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "@comunica/context-entries": "^2.2.0", "@comunica/query-sparql": "^2.2.1", "@rdfjs/types": "^1.1.0", + "@solid/access-control-policy": "^0.1.2", "@solid/access-token-verifier": "^2.0.3", "@types/async-lock": "^1.1.5", "@types/bcryptjs": "^2.4.2", diff --git a/src/authorization/AcpReader.ts b/src/authorization/AcpReader.ts new file mode 100644 index 000000000..d34311fd8 --- /dev/null +++ b/src/authorization/AcpReader.ts @@ -0,0 +1,164 @@ +import { Readable } from 'stream'; +import { allowAccessModes } from '@solid/access-control-policy/dist/algorithm/allow_access_modes'; +import type { IAccessControlledResource } from '@solid/access-control-policy/dist/type/i_access_controlled_resource'; +import type { IContext } from '@solid/access-control-policy/dist/type/i_context'; +import type { IPolicy } from '@solid/access-control-policy/dist/type/i_policy'; +import type { Store } from 'n3'; +import type { CredentialSet } from '../authentication/Credentials'; +import type { AuxiliaryStrategy } from '../http/auxiliary/AuxiliaryStrategy'; +import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; +import { getLoggerFor } from '../logging/LogUtil'; +import type { ResourceStore } from '../storage/ResourceStore'; +import { INTERNAL_QUADS } from '../util/ContentTypes'; +import { createErrorMessage } from '../util/errors/ErrorUtil'; +import { InternalServerError } from '../util/errors/InternalServerError'; +import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; +import type { IdentifierStrategy } from '../util/identifiers/IdentifierStrategy'; +import { IdentifierMap } from '../util/map/IdentifierMap'; +import { readableToQuads } from '../util/StreamUtil'; +import { ACL } from '../util/Vocabularies'; +import { getAccessControlledResources } from './AcpUtil'; +import type { PermissionReaderInput } from './PermissionReader'; +import { PermissionReader } from './PermissionReader'; +import type { AclPermission } from './permissions/AclPermission'; +import { AclMode } from './permissions/AclPermission'; +import { AccessMode } from './permissions/Permissions'; +import type { PermissionMap, PermissionSet } from './permissions/Permissions'; + +const modesMap: Record> = { + [ACL.Read]: [ AccessMode.read ], + [ACL.Write]: [ AccessMode.append, AccessMode.write ], + [ACL.Append]: [ AccessMode.append ], + [ACL.Control]: [ AclMode.control ], +} as const; + +/** + * Finds the permissions of a resource as defined in the corresponding ACRs. + * Implementation based on https://solid.github.io/authorization-panel/acp-specification/. + * + * Caches data so no duplicate calls are made to the {@link ResourceStore} for a single request. + */ +export class AcpReader extends PermissionReader { + protected readonly logger = getLoggerFor(this); + + private readonly acrStrategy: AuxiliaryStrategy; + private readonly acrStore: ResourceStore; + private readonly identifierStrategy: IdentifierStrategy; + + public constructor(acrStrategy: AuxiliaryStrategy, acrStore: ResourceStore, identifierStrategy: IdentifierStrategy) { + super(); + this.acrStrategy = acrStrategy; + this.acrStore = acrStore; + this.identifierStrategy = identifierStrategy; + } + + public async handle({ credentials, requestedModes }: PermissionReaderInput): Promise { + this.logger.debug(`Retrieving permissions of ${JSON.stringify(credentials)}`); + const resourceCache = new IdentifierMap(); + const permissionMap: PermissionMap = new IdentifierMap(); + + // Resolves the targets sequentially so the `resourceCache` can be filled and reused + for (const target of requestedModes.keys()) { + permissionMap.set(target, await this.extractPermissions(target, credentials, resourceCache)); + } + return permissionMap; + } + + /** + * Generates the allowed permissions. + * @param target - Target to generate permissions for. + * @param credentials - Credentials that are trying to access the resource. + * @param resourceCache - Cache used to store ACR data. + */ + private async extractPermissions(target: ResourceIdentifier, credentials: CredentialSet, + resourceCache: IdentifierMap): Promise { + const context = this.createContext(target, credentials); + const policies: IPolicy[] = []; + + // Extract all the policies relevant for the target + const identifiers = this.getAncestorIdentifiers(target); + for (const identifier of identifiers) { + let acrs = resourceCache.get(identifier); + if (!acrs) { + const data = await this.readAcrData(identifier); + acrs = [ ...getAccessControlledResources(data) ]; + resourceCache.set(identifier, acrs); + } + const size = policies.length; + policies.push(...this.getEffectivePolicies(target, acrs)); + this.logger.debug(`Found ${policies.length - size} policies relevant for ${target.path} in ${identifier.path}`); + } + const modes = allowAccessModes(policies, context); + + // We don't do a separate ACP run for public and agent credentials + // as that is only relevant for the WAC-Allow header. + // All permissions are put in the `agent` field of the PermissionSet, + // as the actual field used does not matter for authorization. + const permissionSet: PermissionSet = { agent: {}}; + for (const mode of modes) { + if (mode in modesMap) { + for (const permission of modesMap[mode]) { + permissionSet.agent![permission as AccessMode] = true; + } + } + } + return permissionSet; + } + + /** + * Creates an ACP context targeting the given identifier with the provided credentials. + */ + private createContext(target: ResourceIdentifier, credentials: CredentialSet): IContext { + return { + target: target.path, + agent: credentials.agent?.webId, + }; + } + + /** + * Returns all {@link IPolicy} found in `resources` that apply to the target identifier. + * https://solidproject.org/TR/2022/acp-20220518#effective-policies + */ + private* getEffectivePolicies(target: ResourceIdentifier, resources: Iterable): + Iterable { + for (const { iri, accessControlResource } of resources) { + // Use the `accessControl` entries if the `target` corresponds to the `iri` used in the ACR. + // If not, this means this is an ACR of a parent resource, and we need to use the `memberAccessControl` field. + const accessControlField = iri === target.path ? 'accessControl' : 'memberAccessControl'; + yield* accessControlResource[accessControlField].flatMap((ac): IPolicy[] => ac.policy); + } + } + + /** + * Returns the given identifier and all its ancestors. + * These are all the identifiers that are relevant for determining the effective policies. + */ + private* getAncestorIdentifiers(identifier: ResourceIdentifier): Iterable { + yield identifier; + while (!this.identifierStrategy.isRootContainer(identifier)) { + identifier = this.identifierStrategy.getParentContainer(identifier); + yield identifier; + } + } + + /** + * Returns the data found in the ACR corresponding to the given identifier. + */ + private async readAcrData(identifier: ResourceIdentifier): Promise { + const acrIdentifier = this.acrStrategy.getAuxiliaryIdentifier(identifier); + let data: Readable; + try { + this.logger.debug(`Reading ACR document ${acrIdentifier.path}`); + ({ data } = await this.acrStore.getRepresentation(acrIdentifier, { type: { [INTERNAL_QUADS]: 1 }})); + } catch (error: unknown) { + if (!NotFoundHttpError.isInstance(error)) { + const message = `Error reading ACR ${acrIdentifier.path}: ${createErrorMessage(error)}`; + this.logger.error(message); + throw new InternalServerError(message, { cause: error }); + } + this.logger.debug(`No direct ACR document found for ${identifier.path}`); + data = Readable.from([]); + } + return readableToQuads(data); + } +} diff --git a/src/authorization/AcpUtil.ts b/src/authorization/AcpUtil.ts new file mode 100644 index 000000000..0fbaaef47 --- /dev/null +++ b/src/authorization/AcpUtil.ts @@ -0,0 +1,101 @@ +import type { IAccessControl } from '@solid/access-control-policy/dist/type/i_access_control'; +import type { IAccessControlResource } from '@solid/access-control-policy/dist/type/i_access_control_resource'; +import type { IAccessControlledResource } from '@solid/access-control-policy/dist/type/i_access_controlled_resource'; +import type { IAccessMode } from '@solid/access-control-policy/dist/type/i_access_mode'; +import type { IMatcher } from '@solid/access-control-policy/dist/type/i_matcher'; +import type { IPolicy } from '@solid/access-control-policy/dist/type/i_policy'; +import type { Store } from 'n3'; +import type { NamedNode, Term } from 'rdf-js'; +import { ACP } from '../util/Vocabularies'; + +/** + * Returns all objects found using the given subject and predicate, mapped with the given function. + */ +function mapObjects(data: Store, subject: Term, predicate: Term, fn: (data: Store, term: Term) => T): T[] { + return data.getObjects(subject, predicate, null) + .map((term): T => fn(data, term)); +} + +/** + * Returns the string values of all objects found using the given subject and predicate. + */ +function getObjectValues(data: Store, subject: Term, predicate: NamedNode): string[] { + return mapObjects(data, subject, predicate, (unused, term): string => term.value); +} + +/** + * Finds the {@link IMatcher} with the given identifier in the given dataset. + * @param data - Dataset to look in. + * @param matcher - Identifier of the matcher. + */ +export function getMatcher(data: Store, matcher: Term): IMatcher { + return { + iri: matcher.value, + agent: getObjectValues(data, matcher, ACP.terms.agent), + client: getObjectValues(data, matcher, ACP.terms.client), + issuer: getObjectValues(data, matcher, ACP.terms.issuer), + vc: getObjectValues(data, matcher, ACP.terms.vc), + }; +} + +/** + * Finds the {@link IPolicy} with the given identifier in the given dataset. + * @param data - Dataset to look in. + * @param policy - Identifier of the policy. + */ +export function getPolicy(data: Store, policy: Term): IPolicy { + return { + iri: policy.value, + allow: new Set(getObjectValues(data, policy, ACP.terms.allow) as IAccessMode[]), + deny: new Set(getObjectValues(data, policy, ACP.terms.deny) as IAccessMode[]), + allOf: mapObjects(data, policy, ACP.terms.allOf, getMatcher), + anyOf: mapObjects(data, policy, ACP.terms.anyOf, getMatcher), + noneOf: mapObjects(data, policy, ACP.terms.noneOf, getMatcher), + }; +} + +/** + * Finds the {@link IAccessControl} with the given identifier in the given dataset. + * @param data - Dataset to look in. + * @param accessControl - Identifier of the access control. + */ +export function getAccessControl(data: Store, accessControl: Term): IAccessControl { + const policy = mapObjects(data, accessControl, ACP.terms.apply, getPolicy); + return { + iri: accessControl.value, + policy, + }; +} + +/** + * Finds the {@link IAccessControlResource} with the given identifier in the given dataset. + * @param data - Dataset to look in. + * @param acr - Identifier of the access control resource. + */ +export function getAccessControlResource(data: Store, acr: Term): IAccessControlResource { + const accessControl = data.getObjects(acr, ACP.terms.accessControl, null) + .map((term): IAccessControl => getAccessControl(data, term)); + const memberAccessControl = data.getObjects(acr, ACP.terms.memberAccessControl, null) + .map((term): IAccessControl => getAccessControl(data, term)); + return { + iri: acr.value, + accessControl, + memberAccessControl, + }; +} + +/** + * Finds all {@link IAccessControlledResource} in the given dataset. + * @param data - Dataset to look in. + */ +export function* getAccessControlledResources(data: Store): Iterable { + const acrQuads = data.getQuads(null, ACP.terms.resource, null, null); + + for (const quad of acrQuads) { + const accessControlResource = getAccessControlResource(data, quad.subject); + yield { + iri: quad.object.value, + accessControlResource, + }; + } +} diff --git a/src/index.ts b/src/index.ts index b8e93eb70..0ca8ff2bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,8 @@ export * from './authorization/permissions/Permissions'; export * from './authorization/permissions/SparqlUpdateModesExtractor'; // Authorization +export * from './authorization/AcpReader'; +export * from './authorization/AcpUtil'; export * from './authorization/AllStaticReader'; export * from './authorization/Authorizer'; export * from './authorization/AuxiliaryReader'; diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index 93363a659..ffeeb0efb 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -117,6 +117,29 @@ export const ACL = createVocabulary('http://www.w3.org/ns/auth/acl#', 'Control', ); +export const ACP = createVocabulary('http://www.w3.org/ns/solid/acp#', + // Access Control Resource + 'resource', + 'accessControl', + 'memberAccessControl', + + // Access Control, + 'apply', + + // Policy + 'allow', + 'deny', + 'allOf', + 'anyOf', + 'noneOf', + + // Matcher + 'agent', + 'client', + 'issuer', + 'vc', +); + export const AS = createVocabulary('https://www.w3.org/ns/activitystreams#', 'Create', 'Delete', diff --git a/test/unit/authorization/AcpReader.test.ts b/test/unit/authorization/AcpReader.test.ts new file mode 100644 index 000000000..087755236 --- /dev/null +++ b/test/unit/authorization/AcpReader.test.ts @@ -0,0 +1,170 @@ +import { Parser } from 'n3'; +import type { Quad } from 'rdf-js'; +import type { CredentialSet } from '../../../src/authentication/Credentials'; +import { AcpReader } from '../../../src/authorization/AcpReader'; +import { AccessMode } from '../../../src/authorization/permissions/Permissions'; +import type { AuxiliaryStrategy } from '../../../src/http/auxiliary/AuxiliaryStrategy'; +import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation'; +import type { Representation } from '../../../src/http/representation/Representation'; +import type { ResourceStore } from '../../../src/storage/ResourceStore'; +import { INTERNAL_QUADS } from '../../../src/util/ContentTypes'; +import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; +import type { IdentifierStrategy } from '../../../src/util/identifiers/IdentifierStrategy'; +import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; +import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap'; +import { joinUrl } from '../../../src/util/PathUtil'; +import { SimpleSuffixStrategy } from '../../util/SimpleSuffixStrategy'; +import { compareMaps } from '../../util/Util'; + +const acrSuffix = '.acr'; + +function toQuads(turtle: string, baseIRI: string): Quad[] { + baseIRI = `${baseIRI}${acrSuffix}`; + turtle = ` + @prefix acp: . + @prefix acl: . + ${turtle} + `; + return new Parser({ format: 'Turtle', baseIRI }).parse(turtle); +} + +describe('An AcpReader', (): void => { + const baseUrl = 'http://example.com/'; + let credentials: CredentialSet; + // Subject identifiers are used as keys, values are the output of their corresponding ACR resource + let dataMap: Record; + let acrStrategy: AuxiliaryStrategy; + let acrStore: jest.Mocked; + let identifierStrategy: IdentifierStrategy; + let acpReader: AcpReader; + + beforeEach(async(): Promise => { + credentials = { public: {}}; + dataMap = {}; + + acrStrategy = new SimpleSuffixStrategy(acrSuffix); + + acrStore = { + getRepresentation: jest.fn((identifier): Representation => { + const subjectIdentifier = acrStrategy.getSubjectIdentifier(identifier); + if (!dataMap[subjectIdentifier.path]) { + throw new NotFoundHttpError(); + } + return new BasicRepresentation(dataMap[subjectIdentifier.path], subjectIdentifier, INTERNAL_QUADS, false); + }), + } as any; + + identifierStrategy = new SingleRootIdentifierStrategy(baseUrl); + + acpReader = new AcpReader(acrStrategy, acrStore, identifierStrategy); + }); + + it('can check permissions on the root container.', async(): Promise => { + const target = { path: joinUrl(baseUrl, 'foo') }; + dataMap[baseUrl] = toQuads(` + [] + acp:resource <./> ; + acp:accessControl [ acp:apply _:policy ]. + _:policy + acp:allow acl:Read; + acp:allOf _:matcher. + _:matcher acp:agent acp:PublicAgent. + `, baseUrl); + const requestedModes = new IdentifierSetMultiMap([ + [{ path: baseUrl }, AccessMode.read ], + [ target, AccessMode.read ]]); + const expectedPermissions = new IdentifierMap([ + [{ path: baseUrl }, { agent: { read: true }}], + [ target, { agent: {}}]]); + compareMaps(await acpReader.handle({ credentials, requestedModes }), expectedPermissions); + }); + + it('throws an error if something goes wrong reading data.', async(): Promise => { + acrStore.getRepresentation.mockRejectedValueOnce(new Error('bad request')); + const requestedModes = new IdentifierSetMultiMap([[{ path: baseUrl }, AccessMode.read ]]); + await expect(acpReader.handle({ credentials, requestedModes })).rejects.toThrow('bad request'); + }); + + it('allows for permission inheritance.', async(): Promise => { + const target = { path: joinUrl(baseUrl, 'foo') }; + dataMap[baseUrl] = toQuads(` + [] + acp:resource <./> ; + acp:memberAccessControl [ acp:apply _:policy ]. + _:policy + acp:allow acl:Read; + acp:allOf _:matcher. + _:matcher acp:agent acp:PublicAgent. + `, baseUrl); + const requestedModes = new IdentifierSetMultiMap([ + [{ path: baseUrl }, AccessMode.read ], + [ target, AccessMode.read ]]); + const expectedPermissions = new IdentifierMap([ + [{ path: baseUrl }, { agent: {}}], + [ target, { agent: { read: true }}]]); + compareMaps(await acpReader.handle({ credentials, requestedModes }), expectedPermissions); + }); + + it('combines all relevant ACRs.', async(): Promise => { + const target = { path: joinUrl(baseUrl, 'foo') }; + dataMap[baseUrl] = toQuads(` + [] + acp:resource <./> ; + acp:accessControl [ acp:apply _:controlPolicy ]; + acp:memberAccessControl [ acp:apply _:readPolicy ]. + _:readPolicy + acp:allow acl:Read; + acp:allOf _:matcher. + _:controlPolicy + acp:allow acl:Control; + acp:allOf _:matcher. + _:matcher acp:agent acp:PublicAgent. + `, baseUrl); + dataMap[target.path] = toQuads(` + [] + acp:resource <./foo> ; + acp:accessControl [ acp:apply _:appendPolicy ]. + _:appendPolicy + acp:allow acl:Append; + acp:allOf _:matcher. + _:matcher acp:agent acp:PublicAgent. + `, target.path); + const requestedModes = new IdentifierSetMultiMap([ + [{ path: baseUrl }, AccessMode.read ], + [ target, AccessMode.read ]]); + const expectedPermissions = new IdentifierMap([ + [{ path: baseUrl }, { agent: { control: true }}], + [ target, { agent: { read: true, append: true }}]]); + compareMaps(await acpReader.handle({ credentials, requestedModes }), expectedPermissions); + }); + + it('caches data to prevent duplicate ResourceStore calls.', async(): Promise => { + const target1 = { path: joinUrl(baseUrl, 'foo/') }; + const target2 = { path: joinUrl(baseUrl, 'foo/bar') }; + dataMap[baseUrl] = toQuads(` + [] + acp:resource <./> ; + acp:memberAccessControl [ acp:apply _:policy ]. + _:policy + acp:allow acl:Read; + acp:allOf _:matcher. + _:matcher acp:agent acp:PublicAgent. + `, baseUrl); + const requestedModes = new IdentifierSetMultiMap([ + [{ path: baseUrl }, AccessMode.read ], + [ target1, AccessMode.read ], + [ target2, AccessMode.read ]]); + const expectedPermissions = new IdentifierMap([ + [{ path: baseUrl }, { agent: {}}], + [ target1, { agent: { read: true }}], + [ target2, { agent: { read: true }}]]); + compareMaps(await acpReader.handle({ credentials, requestedModes }), expectedPermissions); + expect(acrStore.getRepresentation).toHaveBeenCalledTimes(3); + expect(acrStore.getRepresentation) + .toHaveBeenCalledWith(acrStrategy.getAuxiliaryIdentifier(target1), { type: { [INTERNAL_QUADS]: 1 }}); + expect(acrStore.getRepresentation) + .toHaveBeenCalledWith(acrStrategy.getAuxiliaryIdentifier(target2), { type: { [INTERNAL_QUADS]: 1 }}); + expect(acrStore.getRepresentation) + .toHaveBeenCalledWith(acrStrategy.getAuxiliaryIdentifier({ path: baseUrl }), { type: { [INTERNAL_QUADS]: 1 }}); + }); +}); diff --git a/test/unit/authorization/AcpUtil.test.ts b/test/unit/authorization/AcpUtil.test.ts new file mode 100644 index 000000000..989f0dd5f --- /dev/null +++ b/test/unit/authorization/AcpUtil.test.ts @@ -0,0 +1,122 @@ +import { ACL } from '@solid/access-control-policy/dist/constant/acl'; +import { DataFactory, Parser, Store } from 'n3'; +import { + getAccessControl, + getAccessControlledResources, + getAccessControlResource, + getMatcher, + getPolicy, +} from '../../../src/authorization/AcpUtil'; +import { joinUrl } from '../../../src/util/PathUtil'; +import { ACP } from '../../../src/util/Vocabularies'; +import namedNode = DataFactory.namedNode; + +describe('AcpUtil', (): void => { + const baseUrl = 'http://example.com/'; + const data = new Store(new Parser({ format: 'Turtle', baseIRI: baseUrl }).parse(` + @prefix acp: . + @prefix acl: . + @prefix ex: . + + ex:acr + acp:resource <./foo>; + acp:accessControl ex:ac; + acp:memberAccessControl ex:ac. + ex:ac acp:apply ex:policy. + ex:policy + acp:allow acl:Read, acl:Append; + acp:deny acl:Write; + acp:allOf ex:matcher; + acp:anyOf ex:matcher; + acp:noneOf ex:matcher. + ex:matcher acp:agent acp:PublicAgent, ex:agent; + acp:client ex:client; + acp:issuer ex:issuer; + acp:vc ex:vc. + `)); + + describe('#getMatcher', (): void => { + it('returns the relevant matcher.', async(): Promise => { + expect(getMatcher(data, namedNode(`${baseUrl}matcher`))).toEqual({ + iri: joinUrl(baseUrl, 'matcher'), + agent: [ `${ACP.namespace}PublicAgent`, `${baseUrl}agent` ], + client: [ `${baseUrl}client` ], + issuer: [ `${baseUrl}issuer` ], + vc: [ `${baseUrl}vc` ], + }); + }); + it('returns an empty matcher if no data is found.', async(): Promise => { + expect(getMatcher(data, namedNode(`${baseUrl}unknown`))).toEqual({ + iri: `${baseUrl}unknown`, + agent: [], + client: [], + issuer: [], + vc: [], + }); + }); + }); + + describe('#getPolicy', (): void => { + it('returns the relevant policy.', async(): Promise => { + expect(getPolicy(data, namedNode(`${baseUrl}policy`))).toEqual({ + iri: `${baseUrl}policy`, + allow: new Set([ ACL.Read, ACL.Append ]), + deny: new Set([ ACL.Write ]), + allOf: [ expect.objectContaining({ iri: `${baseUrl}matcher` }) ], + anyOf: [ expect.objectContaining({ iri: `${baseUrl}matcher` }) ], + noneOf: [ expect.objectContaining({ iri: `${baseUrl}matcher` }) ], + }); + }); + it('returns an empty policy if no data is found.', async(): Promise => { + expect(getPolicy(data, namedNode(`${baseUrl}unknown`))).toEqual({ + iri: `${baseUrl}unknown`, + allow: new Set(), + deny: new Set(), + allOf: [], + anyOf: [], + noneOf: [], + }); + }); + }); + + describe('#getAccessControl', (): void => { + it('returns the relevant access control.', async(): Promise => { + expect(getAccessControl(data, namedNode(`${baseUrl}ac`))).toEqual({ + iri: `${baseUrl}ac`, + policy: [ expect.objectContaining({ iri: `${baseUrl}policy` }) ], + }); + }); + it('returns an empty access control if no data is found.', async(): Promise => { + expect(getAccessControl(data, namedNode(`${baseUrl}unknown`))).toEqual({ + iri: `${baseUrl}unknown`, + policy: [], + }); + }); + }); + + describe('#getAccessControlResource', (): void => { + it('returns the relevant access control resource.', async(): Promise => { + expect(getAccessControlResource(data, namedNode(`${baseUrl}acr`))).toEqual({ + iri: `${baseUrl}acr`, + accessControl: [ expect.objectContaining({ iri: `${baseUrl}ac` }) ], + memberAccessControl: [ expect.objectContaining({ iri: `${baseUrl}ac` }) ], + }); + }); + it('returns an empty access control resource if no data is found.', async(): Promise => { + expect(getAccessControlResource(data, namedNode(`${baseUrl}unknown`))).toEqual({ + iri: `${baseUrl}unknown`, + accessControl: [], + memberAccessControl: [], + }); + }); + }); + + describe('#getAccessControlledResources', (): void => { + it('returns all access controlled resources found in the dataset.', async(): Promise => { + expect([ ...getAccessControlledResources(data) ]).toEqual([{ + iri: `${baseUrl}foo`, + accessControlResource: expect.objectContaining({ iri: `${baseUrl}acr` }), + }]); + }); + }); +});