diff --git a/src/authorization/ParentContainerReader.ts b/src/authorization/ParentContainerReader.ts new file mode 100644 index 000000000..6a015512d --- /dev/null +++ b/src/authorization/ParentContainerReader.ts @@ -0,0 +1,115 @@ +import type { CredentialGroup } from '../authentication/Credentials'; +import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; +import { getLoggerFor } from '../logging/LogUtil'; +import type { IdentifierStrategy } from '../util/identifiers/IdentifierStrategy'; +import { IdentifierMap, IdentifierSetMultiMap } from '../util/map/IdentifierMap'; +import type { MapEntry } from '../util/map/MapUtil'; +import { modify } from '../util/map/MapUtil'; +import type { PermissionReaderInput } from './PermissionReader'; +import { PermissionReader } from './PermissionReader'; +import type { PermissionMap, Permission, PermissionSet, AccessMap } from './permissions/Permissions'; +import { AccessMode } from './permissions/Permissions'; + +/** + * Determines `delete` and `create` permissions for those resources that need it + * by making sure the parent container has the required permissions. + * + * Create requires `append` permissions on the parent container. + * Delete requires `write` permissions on both the parent container and the resource itself. + */ +export class ParentContainerReader extends PermissionReader { + protected readonly logger = getLoggerFor(this); + + private readonly reader: PermissionReader; + private readonly identifierStrategy: IdentifierStrategy; + + public constructor(reader: PermissionReader, identifierStrategy: IdentifierStrategy) { + super(); + this.reader = reader; + this.identifierStrategy = identifierStrategy; + } + + public async handle({ requestedModes, credentials }: PermissionReaderInput): Promise { + // Finds the entries for which we require parent container permissions + const containerMap = this.findParents(requestedModes); + + // Merges the necessary parent container modes with the already requested modes + const combinedModes = modify(new IdentifierSetMultiMap(requestedModes), { add: containerMap.values() }); + const result = await this.reader.handleSafe({ requestedModes: combinedModes, credentials }); + + // Updates the create/delete permissions based on the parent container permissions + for (const [ identifier, [ container ]] of containerMap) { + this.logger.debug(`Determining ${identifier.path} create and delete permissions based on ${container.path}`); + result.set(identifier, this.addContainerPermissions(result.get(identifier), result.get(container))); + } + return result; + } + + /** + * Finds the identifiers for which we need parent permissions. + * Values are the parent identifier and the permissions they need. + */ + private findParents(requestedModes: AccessMap): IdentifierMap> { + const containerMap = new IdentifierMap<[ResourceIdentifier, Set]>(); + for (const [ identifier, modes ] of requestedModes.entrySets()) { + if (modes.has(AccessMode.create) || modes.has(AccessMode.delete)) { + const container = this.identifierStrategy.getParentContainer(identifier); + containerMap.set(identifier, [ container, this.getParentModes(modes) ]); + } + } + return containerMap; + } + + /** + * Determines which permissions are required on the parent container. + */ + private getParentModes(modes: ReadonlySet): Set { + const containerModes: Set = new Set(); + if (modes.has(AccessMode.create)) { + containerModes.add(AccessMode.append); + } + if (modes.has(AccessMode.delete)) { + containerModes.add(AccessMode.write); + } + return containerModes; + } + + /** + * Merges the container permission set into the resource permission set + * based on the parent container rules for create/delete permissions. + */ + private addContainerPermissions(resourceSet?: PermissionSet, containerSet?: PermissionSet): PermissionSet { + resourceSet = resourceSet ?? {}; + containerSet = containerSet ?? {}; + // Already copying the `permissionSet` here since the loop only iterates over the container entries. + // It is possible `resourceSet` contains a key that `containerSet` does not contain. + const resultSet: PermissionSet = { ...resourceSet }; + for (const [ group, containerPermission ] of Object.entries(containerSet) as [ CredentialGroup, Permission ][]) { + resultSet[group] = this.interpretContainerPermission(resourceSet[group] ?? {}, containerPermission); + } + return resultSet; + } + + /** + * Determines the create and delete permissions for the given resource permissions + * based on those of its parent container. + */ + private interpretContainerPermission(resourcePermission: Permission, containerPermission: Permission): Permission { + const mergedPermission = { ...resourcePermission }; + + // https://solidproject.org/TR/2021/wac-20210711: + // When an operation requests to create a resource as a member of a container resource, + // the server MUST match an Authorization allowing the acl:Append or acl:Write access privilege + // on the container for new members. + mergedPermission.create = containerPermission.append && resourcePermission.create !== false; + + // https://solidproject.org/TR/2021/wac-20210711: + // When an operation requests to delete a resource, + // the server MUST match Authorizations allowing the acl:Write access privilege + // on the resource and the containing container. + mergedPermission.delete = resourcePermission.write && containerPermission.write && + resourcePermission.delete !== false; + + return mergedPermission; + } +} diff --git a/src/authorization/WebAclAuxiliaryReader.ts b/src/authorization/WebAclAuxiliaryReader.ts new file mode 100644 index 000000000..e6b53b321 --- /dev/null +++ b/src/authorization/WebAclAuxiliaryReader.ts @@ -0,0 +1,76 @@ +import type { CredentialGroup } from '../authentication/Credentials'; +import type { AuxiliaryStrategy } from '../http/auxiliary/AuxiliaryStrategy'; +import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; +import { getLoggerFor } from '../logging/LogUtil'; +import { IdentifierSetMultiMap } from '../util/map/IdentifierMap'; +import type { MapEntry } from '../util/map/MapUtil'; +import { modify } from '../util/map/MapUtil'; +import type { PermissionReaderInput } from './PermissionReader'; +import { PermissionReader } from './PermissionReader'; +import { AclMode } from './permissions/AclPermission'; +import type { AclPermission } from './permissions/AclPermission'; +import type { AccessMap, AccessMode, PermissionMap, PermissionSet } from './permissions/Permissions'; + +/** + * Determines the permission for ACL auxiliary resources. + * This is done by looking for control permissions on the subject resource. + */ +export class WebAclAuxiliaryReader extends PermissionReader { + protected readonly logger = getLoggerFor(this); + + private readonly reader: PermissionReader; + private readonly aclStrategy: AuxiliaryStrategy; + + public constructor(reader: PermissionReader, aclStrategy: AuxiliaryStrategy) { + super(); + this.reader = reader; + this.aclStrategy = aclStrategy; + } + + public async handle({ requestedModes, credentials }: PermissionReaderInput): Promise { + // Finds all the ACL identifiers + const aclMap = new Map(this.findAcl(requestedModes)); + + // Replaces the ACL identifies with the corresponding subject identifiers + const updatedMap = modify(new IdentifierSetMultiMap(requestedModes), + { add: aclMap.values(), remove: aclMap.keys() }); + const result = await this.reader.handleSafe({ requestedModes: updatedMap, credentials }); + + // Extracts the ACL permissions based on the subject control permissions + for (const [ identifier, [ subject ]] of aclMap) { + this.logger.debug(`Mapping ${subject.path} control permission to all permissions for ${identifier.path}`); + result.set(identifier, this.interpretControl(identifier, result.get(subject))); + } + return result; + } + + /** + * Finds all ACL identifiers and maps them to their subject identifier and the requested modes. + */ + private* findAcl(accessMap: AccessMap): Iterable<[ResourceIdentifier, MapEntry]> { + for (const [ identifier ] of accessMap) { + if (this.aclStrategy.isAuxiliaryIdentifier(identifier)) { + const subject = this.aclStrategy.getSubjectIdentifier(identifier); + // Unfortunately there is no enum inheritance so we have to cast like this + yield [ identifier, [ subject, new Set([ AclMode.control ] as unknown as AccessMode[]) ]]; + } + } + } + + /** + * Updates the permissions for an ACL resource by interpreting the Control access mode as allowing full access. + */ + protected interpretControl(identifier: ResourceIdentifier, permissionSet: PermissionSet = {}): PermissionSet { + const aclSet: PermissionSet = {}; + for (const [ group, permissions ] of Object.entries(permissionSet) as [ CredentialGroup, AclPermission ][]) { + const { control } = permissions; + aclSet[group] = { + read: control, + append: control, + write: control, + control, + } as AclPermission; + } + return aclSet; + } +} diff --git a/src/authorization/WebAclReader.ts b/src/authorization/WebAclReader.ts index f2ef65b4a..e1960386c 100644 --- a/src/authorization/WebAclReader.ts +++ b/src/authorization/WebAclReader.ts @@ -4,13 +4,14 @@ import { CredentialGroup } from '../authentication/Credentials'; import type { AuxiliaryIdentifierStrategy } from '../http/auxiliary/AuxiliaryIdentifierStrategy'; import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import { getLoggerFor } from '../logging/LogUtil'; +import type { ResourceSet } from '../storage/ResourceSet'; import type { ResourceStore } from '../storage/ResourceStore'; import { INTERNAL_QUADS } from '../util/ContentTypes'; import { createErrorMessage } from '../util/errors/ErrorUtil'; import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError'; import { InternalServerError } from '../util/errors/InternalServerError'; -import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; import type { IdentifierStrategy } from '../util/identifiers/IdentifierStrategy'; +import { IdentifierMap, IdentifierSetMultiMap } from '../util/map/IdentifierMap'; import { readableToQuads } from '../util/StreamUtil'; import { ACL, RDF } from '../util/Vocabularies'; import type { AccessChecker } from './access/AccessChecker'; @@ -18,7 +19,7 @@ import type { PermissionReaderInput } from './PermissionReader'; import { PermissionReader } from './PermissionReader'; import type { AclPermission } from './permissions/AclPermission'; import { AclMode } from './permissions/AclPermission'; -import type { PermissionSet } from './permissions/Permissions'; +import type { PermissionMap } from './permissions/Permissions'; import { AccessMode } from './permissions/Permissions'; // Maps WebACL-specific modes to generic access modes. @@ -29,24 +30,27 @@ const modesMap: Record> = { [ACL.Control]: [ AclMode.control ], } as const; -type AclSet = { targetAcl: Store; parentAcl?: Store }; - /** - * Handles permissions according to the WAC specification. + * Finds the permissions of a resource as defined in the corresponding ACL resource. + * Does not make any deductions such as checking parent containers for create permissions + * or applying control permissions for ACL resources. + * * Specific access checks are done by the provided {@link AccessChecker}. */ export class WebAclReader extends PermissionReader { protected readonly logger = getLoggerFor(this); private readonly aclStrategy: AuxiliaryIdentifierStrategy; + private readonly resourceSet: ResourceSet; private readonly aclStore: ResourceStore; private readonly identifierStrategy: IdentifierStrategy; private readonly accessChecker: AccessChecker; - public constructor(aclStrategy: AuxiliaryIdentifierStrategy, aclStore: ResourceStore, + public constructor(aclStrategy: AuxiliaryIdentifierStrategy, resourceSet: ResourceSet, aclStore: ResourceStore, identifierStrategy: IdentifierStrategy, accessChecker: AccessChecker) { super(); this.aclStrategy = aclStrategy; + this.resourceSet = resourceSet; this.aclStore = aclStore; this.identifierStrategy = identifierStrategy; this.accessChecker = accessChecker; @@ -57,79 +61,57 @@ export class WebAclReader extends PermissionReader { * Will throw an error if this is not the case. * @param input - Relevant data needed to check if access can be granted. */ - public async handle({ identifier, credentials, modes }: PermissionReaderInput): - Promise { + public async handle({ credentials, requestedModes }: PermissionReaderInput): Promise { // Determine the required access modes - this.logger.debug(`Retrieving permissions of ${credentials.agent?.webId} for ${identifier.path}`); - - const isAclResource = this.aclStrategy.isAuxiliaryIdentifier(identifier); - const mainIdentifier = isAclResource ? this.aclStrategy.getSubjectIdentifier(identifier) : identifier; - - // Adding or removing resources changes the container listing - const requiresContainerCheck = modes.has(AccessMode.create) || modes.has(AccessMode.delete); - - // Rather than restricting the search to only the required modes, - // we collect all modes in order to have complete metadata (for instance, for the WAC-Allow header). - const acl = await this.getAcl(mainIdentifier, requiresContainerCheck); - const permissions = await this.findPermissions(acl.targetAcl, credentials, isAclResource); - - if (requiresContainerCheck) { - this.logger.debug(`Determining ${identifier.path} permissions requires verifying parent container permissions`); - const parentPermissions = acl.targetAcl === acl.parentAcl ? - permissions : - await this.findPermissions(acl.parentAcl!, credentials, false); - - // https://solidproject.org/TR/2021/wac-20210711: - // When an operation requests to create a resource as a member of a container resource, - // the server MUST match an Authorization allowing the acl:Append or acl:Write access privilege - // on the container for new members. - permissions[CredentialGroup.agent]!.create = parentPermissions[CredentialGroup.agent]!.append; - permissions[CredentialGroup.public]!.create = parentPermissions[CredentialGroup.public]!.append; - - // https://solidproject.org/TR/2021/wac-20210711: - // When an operation requests to delete a resource, - // the server MUST match Authorizations allowing the acl:Write access privilege - // on the resource and the containing container. - permissions[CredentialGroup.agent]!.delete = - permissions[CredentialGroup.agent]!.write && parentPermissions[CredentialGroup.agent]!.write; - permissions[CredentialGroup.public]!.delete = - permissions[CredentialGroup.public]!.write && parentPermissions[CredentialGroup.public]!.write; - } - return permissions; + this.logger.debug(`Retrieving permissions of ${credentials.agent?.webId ?? 'an unknown agent'}`); + const aclMap = await this.getAclMatches(requestedModes.distinctKeys()); + const storeMap = await this.findAuthorizationStatements(aclMap); + return await this.findPermissions(storeMap, credentials); } /** * Finds the permissions in the provided WebACL quads. - * @param acl - Store containing all relevant authorization triples. + * + * Rather than restricting the search to only the required modes, + * we collect all modes in order to have complete metadata (for instance, for the WAC-Allow header). + * + * @param aclMap - A map containing stores of ACL data linked to their relevant identifiers. * @param credentials - Credentials to check permissions for. - * @param isAcl - If the target resource is an acl document. */ - private async findPermissions(acl: Store, credentials: CredentialSet, isAcl: boolean): Promise { - const publicPermissions = await this.determinePermissions(acl, credentials.public); - const agentPermissions = await this.determinePermissions(acl, credentials.agent); + private async findPermissions(aclMap: Map, credentials: CredentialSet): + Promise { + const result: PermissionMap = new IdentifierMap(); + for (const [ store, aclIdentifiers ] of aclMap) { + // WebACL only supports public and agent permissions + const publicPermissions = await this.determinePermissions(store, credentials.public); + const agentPermissions = await this.determinePermissions(store, credentials.agent); + for (const identifier of aclIdentifiers) { + result.set(identifier, { + [CredentialGroup.public]: publicPermissions, + [CredentialGroup.agent]: agentPermissions, + }); + } + } - return { - [CredentialGroup.agent]: this.updateAclPermissions(agentPermissions, isAcl), - [CredentialGroup.public]: this.updateAclPermissions(publicPermissions, isAcl), - }; + return result; } /** * Determines the available permissions for the given credentials. * Will deny all permissions if credentials are not defined * @param acl - Store containing all relevant authorization triples. - * @param credentials - Credentials to find the permissions for. + * @param credential - Credentials to find the permissions for. */ - private async determinePermissions(acl: Store, credentials?: Credential): Promise { + private async determinePermissions(acl: Store, credential?: Credential): Promise { const aclPermissions: AclPermission = {}; - if (!credentials) { + if (!credential) { return aclPermissions; } // Apply all ACL rules const aclRules = acl.getSubjects(RDF.type, ACL.Authorization, null); for (const rule of aclRules) { - const hasAccess = await this.accessChecker.handleSafe({ acl, rule, credential: credentials }); + const hasAccess = await this.accessChecker.handleSafe({ acl, rule, credential }); if (hasAccess) { // Set all allowed modes to true const modes = acl.getObjects(rule, ACL.mode, null); @@ -147,122 +129,101 @@ export class WebAclReader extends PermissionReader { } /** - * Sets the correct values for non-acl permissions such as create and delete. - * Also adds the correct values to indicate that having control permission - * implies having read/write/etc. on the acl resource. + * Finds the ACL data relevant for all the given resources. + * The input set will be modified in place. * - * The main reason for keeping the control value is so we can correctly set the WAC-Allow header later. + * @param targets - Targets to find ACL data for. + * + * @returns A map linking ACL resources to the relevant identifiers. */ - private updateAclPermissions(aclPermissions: AclPermission, isAcl: boolean): AclPermission { - if (isAcl) { - return { - read: aclPermissions.control, - append: aclPermissions.control, - write: aclPermissions.control, - create: aclPermissions.control, - delete: aclPermissions.control, - control: aclPermissions.control, - }; + private async getAclMatches(targets: Iterable): + Promise> { + const aclMap = new IdentifierSetMultiMap(); + + for (const target of targets) { + this.logger.debug(`Searching ACL data for ${target.path}`); + const aclIdentifier = await this.getAclRecursive(target); + aclMap.add(aclIdentifier, target); } - return { - ...aclPermissions, - create: aclPermissions.write, - delete: aclPermissions.write, - }; + + return aclMap; } /** - * Finds the ACL data relevant for its resource, and potentially its parent if required. - * All quads in the resulting store(s) can be interpreted as being relevant ACL rules for their target. + * Finds the ACL document relevant for the given identifier, + * following the steps defined in https://solidproject.org/TR/2021/wac-20210711#effective-acl-resource. * - * @param target - Target to find ACL data for. - * @param includeParent - If parent ACL data is also needed. + * @param identifier - {@link ResourceIdentifier} of which we need the ACL document. * - * @returns The relevant triples. + * @returns The {@link ResourceIdentifier} of the relevant ACL document. */ - private async getAcl(target: ResourceIdentifier, includeParent: boolean): Promise { - this.logger.debug(`Searching ACL data for ${target.path}${includeParent ? 'and its parent' : ''}`); - const to = includeParent ? this.identifierStrategy.getParentContainer(target) : target; - const acl = await this.getAclRecursive(target, to); - - // The only possible case where `acl` has 2 values instead of 1 - // is when the `target` has an acl, and `includeParent` is true. - const keys = Object.keys(acl); - if (keys.length === 2) { - const result: AclSet = { targetAcl: await this.filterStore(acl[target.path], target.path, true) }; - // The other key will be the parent - const parentKey = keys.find((key): boolean => key !== target.path)!; - result.parentAcl = await this.filterStore(acl[parentKey], parentKey, parentKey === to.path); - - return result; - } - - // Only 1 key: no parent was requested, target had no direct acl resource, or both - const [ path, store ] = Object.entries(acl)[0]; - const result: AclSet = { targetAcl: await this.filterStore(store, path, path === target.path) }; - if (includeParent) { - // In case the path is not the parent, it will also just use the defaults just like the target - result.parentAcl = path === to.path ? await this.filterStore(store, path, true) : result.targetAcl; - } - - return result; - } - - /** - * Finds the ACL resources from all resources in the path between the two (inclusive) identifiers. - * It is important that `from` is a child path of `to`, otherwise behaviour is undefined. - * - * The result is a key/value object with the keys being the identifiers of resources in the path - * that had a corresponding ACL resource, and the value being the contents of that ACL resource. - * - * The function stops after it finds an ACL resource relevant for the `to` identifier. - * This is either its corresponding ACL resource, or one if its parent containers if such a resource does not exist. - * - * Rethrows any non-NotFoundHttpErrors thrown by the ResourceStore. - * @param from - First resource in the path for which ACL data is needed. - * @param to - Last resource in the path for which ACL data is needed. - * - * @returns A map with the key being the actual identifier of which the ACL was found - * and a list of all data found within. - */ - private async getAclRecursive(from: ResourceIdentifier, to: ResourceIdentifier): Promise> { + private async getAclRecursive(identifier: ResourceIdentifier): Promise { // Obtain the direct ACL document for the resource, if it exists - this.logger.debug(`Trying to read the direct ACL document of ${from.path}`); - const result: Record = {}; - try { - const acl = this.aclStrategy.getAuxiliaryIdentifier(from); - this.logger.debug(`Trying to read the ACL document ${acl.path}`); - const data = await this.aclStore.getRepresentation(acl, { type: { [INTERNAL_QUADS]: 1 }}); - this.logger.info(`Reading ACL statements from ${acl.path}`); + this.logger.debug(`Trying to read the direct ACL document of ${identifier.path}`); - result[from.path] = await readableToQuads(data.data); + const acl = this.aclStrategy.getAuxiliaryIdentifier(identifier); + this.logger.debug(`Determining existence of ${acl.path}`); + if (await this.resourceSet.hasResource(acl)) { + this.logger.info(`Found applicable ACL document ${acl.path}`); + return acl; + } + this.logger.debug(`No direct ACL document found for ${identifier.path}`); - if (from.path.length <= to.path.length) { - return result; - } - } catch (error: unknown) { - if (NotFoundHttpError.isInstance(error)) { - this.logger.debug(`No direct ACL document found for ${from.path}`); - } else { - const message = `Error reading ACL for ${from.path}: ${createErrorMessage(error)}`; + // Find the applicable ACL document of the parent container + this.logger.debug(`Traversing to the parent of ${identifier.path}`); + if (this.identifierStrategy.isRootContainer(identifier)) { + this.logger.error(`No ACL document found for root container ${identifier.path}`); + // https://solidproject.org/TR/2021/wac-20210711#acl-resource-representation + // The root container MUST have an ACL resource with a representation. + throw new ForbiddenHttpError('No ACL document found for root container'); + } + const parent = this.identifierStrategy.getParentContainer(identifier); + return this.getAclRecursive(parent); + } + + /** + * For every ACL/identifier combination it finds the relevant ACL triples for that identifier. + * This is done in such a way that store results are reused for all matching identifiers. + * The split is based on the `acl:accessTo` and `acl:default` triples. + * + * @param map - Map of matches that need to be filtered. + */ + private async findAuthorizationStatements(map: IdentifierSetMultiMap): + Promise> { + // For every found ACL document, filter out triples that match for specific identifiers + const result = new Map(); + for (const [ aclIdentifier, matchedTargets ] of map.entrySets()) { + const subject = this.aclStrategy.getSubjectIdentifier(aclIdentifier); + this.logger.debug(`Trying to read the ACL document ${aclIdentifier.path}`); + let contents: Store; + try { + const data = await this.aclStore.getRepresentation(aclIdentifier, { type: { [INTERNAL_QUADS]: 1 }}); + contents = await readableToQuads(data.data); + } catch (error: unknown) { + // Something is wrong with the server if we can't read the resource + const message = `Error reading ACL resource ${aclIdentifier.path}: ${createErrorMessage(error)}`; this.logger.error(message); throw new InternalServerError(message, { cause: error }); } - } - // Obtain the applicable ACL of the parent container - this.logger.debug(`Traversing to the parent of ${from.path}`); - if (this.identifierStrategy.isRootContainer(from)) { - this.logger.error(`No ACL document found for root container ${from.path}`); - // Solid, §10.1: "In the event that a server can’t apply an ACL to a resource, it MUST deny access." - // https://solid.github.io/specification/protocol#web-access-control - throw new ForbiddenHttpError('No ACL document found for root container'); + // SubjectIdentifiers are those that match the subject identifier of the found ACL document (so max 1). + // Due to how the effective ACL document is found, all other identifiers must be (transitive) children. + // This has impact on whether the `acl:accessTo` or `acl:default` predicate needs to be checked. + const subjectIdentifiers: ResourceIdentifier[] = []; + const childIdentifiers: ResourceIdentifier[] = []; + for (const target of matchedTargets) { + (target.path === subject.path ? subjectIdentifiers : childIdentifiers).push(target); + } + if (subjectIdentifiers.length > 0) { + const subjectStore = await this.filterStore(contents, subject.path, true); + result.set(subjectStore, subjectIdentifiers); + } + if (childIdentifiers.length > 0) { + const childStore = await this.filterStore(contents, subject.path, false); + result.set(childStore, childIdentifiers); + } } - const parent = this.identifierStrategy.getParentContainer(from); - return { - ...result, - ...await this.getAclRecursive(parent, to), - }; + return result; } /** @@ -279,7 +240,9 @@ export class WebAclReader extends PermissionReader { // Find subjects that occur with a given predicate/object, and collect all their triples const subjectData = new Store(); const subjects = store.getSubjects(directAcl ? ACL.terms.accessTo : ACL.terms.default, target, null); - subjects.forEach((subject): any => subjectData.addQuads(store.getQuads(subject, null, null, null))); + for (const subject of subjects) { + subjectData.addQuads(store.getQuads(subject, null, null, null)); + } return subjectData; } } diff --git a/src/index.ts b/src/index.ts index 866dc4c05..325b391ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,10 +28,12 @@ export * from './authorization/AllStaticReader'; export * from './authorization/Authorizer'; export * from './authorization/AuxiliaryReader'; export * from './authorization/OwnerPermissionReader'; +export * from './authorization/ParentContainerReader'; export * from './authorization/PathBasedReader'; export * from './authorization/PermissionBasedAuthorizer'; export * from './authorization/PermissionReader'; export * from './authorization/UnionPermissionReader'; +export * from './authorization/WebAclAuxiliaryReader'; export * from './authorization/WebAclReader'; // HTTP/Auxiliary diff --git a/test/unit/authorization/ParentContainerReader.test.ts b/test/unit/authorization/ParentContainerReader.test.ts new file mode 100644 index 000000000..6f14a5816 --- /dev/null +++ b/test/unit/authorization/ParentContainerReader.test.ts @@ -0,0 +1,114 @@ +import type { CredentialSet } from '../../../src/authentication/Credentials'; +import { ParentContainerReader } from '../../../src/authorization/ParentContainerReader'; +import type { PermissionReader } from '../../../src/authorization/PermissionReader'; +import type { AccessMap, PermissionMap } from '../../../src/authorization/permissions/Permissions'; +import { AccessMode } from '../../../src/authorization/permissions/Permissions'; +import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; +import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap'; +import { joinUrl } from '../../../src/util/PathUtil'; +import { compareMaps } from '../../util/Util'; + +describe('A ParentContainerReader', (): void => { + const baseUrl = 'http://example.com/'; + const parent1 = { path: joinUrl(baseUrl, 'foo/') }; + const target1 = { path: joinUrl(parent1.path, 'foo') }; + const parent2 = { path: joinUrl(baseUrl, 'bar/') }; + const target2 = { path: joinUrl(parent2.path, 'bar') }; + const parent3 = { path: joinUrl(baseUrl, 'baz/') }; + const target3 = { path: joinUrl(parent3.path, 'baz') }; + const credentials: CredentialSet = { public: {}}; + let requestedModes: AccessMap; + let sourceResult: PermissionMap; + const identifierStrategy = new SingleRootIdentifierStrategy(baseUrl); + let source: jest.Mocked; + let reader: ParentContainerReader; + + beforeEach(async(): Promise => { + requestedModes = new IdentifierSetMultiMap(); + + sourceResult = new IdentifierMap([[{ path: joinUrl(baseUrl, 'test') }, { public: { read: true }}]]); + + source = { handleSafe: jest.fn().mockResolvedValue(sourceResult) } as any; + reader = new ParentContainerReader(source, identifierStrategy); + }); + + it('requires parent append permissions to create resources.', async(): Promise => { + requestedModes.set(target1, new Set([ AccessMode.create ])); + requestedModes.set(target2, new Set([ AccessMode.create ])); + sourceResult.set(parent1, { public: { append: true }}); + + const result = await reader.handle({ requestedModes, credentials }); + expect(result.get(target1)).toEqual({ public: { create: true }}); + expect(result.get(target2)).toEqual({ }); + + const updatedMap = new IdentifierSetMultiMap(requestedModes); + updatedMap.set(parent1, AccessMode.append); + updatedMap.set(parent2, AccessMode.append); + expect(source.handleSafe).toHaveBeenCalledTimes(1); + expect(source.handleSafe.mock.calls[0][0].credentials).toBe(credentials); + compareMaps(source.handleSafe.mock.calls[0][0].requestedModes, updatedMap); + expect(source.handleSafe.mock.calls[0][0].requestedModes).not.toEqual(requestedModes); + }); + + it('requires write and parent write permissions to delete resources.', async(): Promise => { + requestedModes.set(target1, new Set([ AccessMode.delete ])); + requestedModes.set(target2, new Set([ AccessMode.delete ])); + requestedModes.set(target3, new Set([ AccessMode.delete ])); + sourceResult.set(parent1, { public: { write: true }}); + sourceResult.set(parent2, { public: { write: true }}); + sourceResult.set(target1, { public: { write: true }}); + sourceResult.set(target3, { public: { write: true }}); + + const result = await reader.handle({ requestedModes, credentials }); + expect(result.get(target1)).toEqual({ public: { delete: true, write: true }}); + expect(result.get(target2)).toEqual({ public: {}}); + expect(result.get(target3)).toEqual({ public: { write: true }}); + + const updatedMap = new IdentifierSetMultiMap(requestedModes); + updatedMap.set(parent1, AccessMode.write); + updatedMap.set(parent2, AccessMode.write); + updatedMap.set(parent3, AccessMode.write); + expect(source.handleSafe).toHaveBeenCalledTimes(1); + expect(source.handleSafe.mock.calls[0][0].credentials).toBe(credentials); + compareMaps(source.handleSafe.mock.calls[0][0].requestedModes, updatedMap); + }); + + it('does not allow create/delete if the source explicitly forbids it.', async(): Promise => { + requestedModes.set(target1, new Set([ AccessMode.create, AccessMode.delete ])); + requestedModes.set(target2, new Set([ AccessMode.create, AccessMode.delete ])); + sourceResult.set(parent1, { public: { write: true, append: true }}); + sourceResult.set(parent2, { public: { write: true, append: true }}); + sourceResult.set(target1, { public: { write: true }}); + sourceResult.set(target2, { public: { write: true, create: false, delete: false }}); + + const result = await reader.handle({ requestedModes, credentials }); + expect(result.get(target1)).toEqual({ public: { write: true, create: true, delete: true }}); + expect(result.get(target2)).toEqual({ public: { write: true, create: false, delete: false }}); + + const updatedMap = new IdentifierSetMultiMap(requestedModes); + updatedMap.set(parent1, new Set([ AccessMode.write, AccessMode.append ])); + updatedMap.set(parent2, new Set([ AccessMode.write, AccessMode.append ])); + expect(source.handleSafe).toHaveBeenCalledTimes(1); + expect(source.handleSafe.mock.calls[0][0].credentials).toBe(credentials); + compareMaps(source.handleSafe.mock.calls[0][0].requestedModes, updatedMap); + }); + + it('combines the modes with the parent resource if it is also being requested.', async(): Promise => { + requestedModes.set(target1, AccessMode.create); + requestedModes.set(parent1, AccessMode.write); + sourceResult.set(parent1, { public: { write: true, append: true }}); + sourceResult.set(target1, { public: { write: true }}); + + const result = await reader.handle({ requestedModes, credentials }); + expect(result.get(target1)).toEqual({ public: { write: true, create: true, delete: true }}); + expect(result.get(parent1)).toEqual({ public: { write: true, append: true }}); + + const updatedMap = new IdentifierSetMultiMap(requestedModes); + updatedMap.set(parent1, new Set([ AccessMode.write, AccessMode.append ])); + expect(source.handleSafe).toHaveBeenCalledTimes(1); + expect(source.handleSafe.mock.calls[0][0].credentials).toBe(credentials); + compareMaps(source.handleSafe.mock.calls[0][0].requestedModes, updatedMap); + expect(source.handleSafe.mock.calls[0][0].requestedModes.get(parent1)) + .toEqual(new Set([ AccessMode.write, AccessMode.append ])); + }); +}); diff --git a/test/unit/authorization/WebAclAuxiliaryReader.test.ts b/test/unit/authorization/WebAclAuxiliaryReader.test.ts new file mode 100644 index 000000000..379c1327a --- /dev/null +++ b/test/unit/authorization/WebAclAuxiliaryReader.test.ts @@ -0,0 +1,72 @@ +import type { CredentialSet } from '../../../src/authentication/Credentials'; +import type { PermissionReader } from '../../../src/authorization/PermissionReader'; +import { AclMode } from '../../../src/authorization/permissions/AclPermission'; +import type { AccessMap, PermissionMap, PermissionSet } from '../../../src/authorization/permissions/Permissions'; +import { AccessMode } from '../../../src/authorization/permissions/Permissions'; +import { WebAclAuxiliaryReader } from '../../../src/authorization/WebAclAuxiliaryReader'; +import type { AuxiliaryStrategy } from '../../../src/http/auxiliary/AuxiliaryStrategy'; +import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; +import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap'; +import { joinUrl } from '../../../src/util/PathUtil'; +import { compareMaps } from '../../util/Util'; + +describe('A WebAclAuxiliaryReader', (): void => { + const baseUrl = 'http://example.com/'; + const subject1 = { path: joinUrl(baseUrl, 'foo/') }; + const acl1 = { path: joinUrl(subject1.path, '.acl') }; + const subject2 = { path: joinUrl(baseUrl, 'bar/') }; + const acl2 = { path: joinUrl(subject2.path, '.acl') }; + const credentials: CredentialSet = { public: {}}; + let requestedModes: AccessMap; + let sourceResult: PermissionMap; + let aclStrategy: jest.Mocked; + let source: jest.Mocked; + let reader: WebAclAuxiliaryReader; + + beforeEach(async(): Promise => { + requestedModes = new IdentifierSetMultiMap(); + + sourceResult = new IdentifierMap(); + + aclStrategy = { + isAuxiliaryIdentifier: jest.fn((identifier): boolean => identifier.path.endsWith('.acl')), + getSubjectIdentifier: jest.fn((identifier): ResourceIdentifier => ({ path: identifier.path.slice(0, -4) })), + } as any; + + source = { handleSafe: jest.fn().mockResolvedValue(sourceResult) } as any; + reader = new WebAclAuxiliaryReader(source, aclStrategy); + }); + + it('requires control permissions on the subject resource to do everything.', async(): Promise => { + requestedModes.set(acl1, AccessMode.read); + requestedModes.set(acl2, AccessMode.read); + sourceResult.set(subject1, { public: { control: true }} as PermissionSet); + + const result = await reader.handle({ requestedModes, credentials }); + expect(result.get(acl1)).toEqual({ public: { read: true, append: true, write: true, control: true }}); + expect(result.get(acl2)).toEqual({ }); + + const updatedMap = new IdentifierMap(); + updatedMap.set(subject1, new Set([ AclMode.control ])); + updatedMap.set(subject2, new Set([ AclMode.control ])); + expect(source.handleSafe).toHaveBeenCalledTimes(1); + expect(source.handleSafe.mock.calls[0][0].credentials).toBe(credentials); + compareMaps(source.handleSafe.mock.calls[0][0].requestedModes, updatedMap); + expect(source.handleSafe.mock.calls[0][0].requestedModes).not.toEqual(requestedModes); + }); + + it('combines the modes with the subject resource if it is also being requested.', async(): Promise => { + requestedModes.set(acl1, AccessMode.read); + requestedModes.set(subject1, AccessMode.write); + + const resultSet = { public: { read: true, write: true, control: true }} as PermissionSet; + sourceResult.set(subject1, resultSet); + const resultMap: PermissionMap = new IdentifierMap([ + [ acl1, { public: { read: true, write: true, control: true, append: true }} as PermissionSet ], + [ subject1, resultSet ], + ]); + compareMaps(await reader.handle({ credentials, requestedModes }), resultMap); + expect(source.handleSafe.mock.calls[0][0].requestedModes.get(subject1)) + .toEqual(new Set([ AccessMode.write, AclMode.control ])); + }); +}); diff --git a/test/unit/authorization/WebAclReader.test.ts b/test/unit/authorization/WebAclReader.test.ts index f1d88a36b..b5fa83d25 100644 --- a/test/unit/authorization/WebAclReader.test.ts +++ b/test/unit/authorization/WebAclReader.test.ts @@ -4,20 +4,22 @@ import { CredentialGroup } from '../../../src/authentication/Credentials'; import type { AccessChecker } from '../../../src/authorization/access/AccessChecker'; import type { PermissionReaderInput } from '../../../src/authorization/PermissionReader'; import { AclMode } from '../../../src/authorization/permissions/AclPermission'; +import type { AccessMap, PermissionSet } from '../../../src/authorization/permissions/Permissions'; import { AccessMode } from '../../../src/authorization/permissions/Permissions'; import { WebAclReader } from '../../../src/authorization/WebAclReader'; import type { AuxiliaryIdentifierStrategy } from '../../../src/http/auxiliary/AuxiliaryIdentifierStrategy'; import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation'; import type { Representation } from '../../../src/http/representation/Representation'; import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; +import type { ResourceSet } from '../../../src/storage/ResourceSet'; import type { ResourceStore } from '../../../src/storage/ResourceStore'; import { INTERNAL_QUADS } from '../../../src/util/ContentTypes'; import { ForbiddenHttpError } from '../../../src/util/errors/ForbiddenHttpError'; import { InternalServerError } from '../../../src/util/errors/InternalServerError'; import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; -import { ensureTrailingSlash } from '../../../src/util/PathUtil'; -import { guardedStreamFrom } from '../../../src/util/StreamUtil'; +import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap'; +import { compareMaps } from '../../util/Util'; const { namedNode: nn, quad } = DataFactory; @@ -31,23 +33,31 @@ describe('A WebAclReader', (): void => { isAuxiliaryIdentifier: (id: ResourceIdentifier): boolean => id.path.endsWith('.acl'), getSubjectIdentifier: (id: ResourceIdentifier): ResourceIdentifier => ({ path: id.path.slice(0, -4) }), } as any; + let resourceSet: jest.Mocked; let store: jest.Mocked; - const identifierStrategy = new SingleRootIdentifierStrategy('http://test.com/'); + const identifierStrategy = new SingleRootIdentifierStrategy('http://example.com/'); let credentials: CredentialSet; let identifier: ResourceIdentifier; - let modes: Set; + let accessMap: AccessMap; let input: PermissionReaderInput; let accessChecker: jest.Mocked; beforeEach(async(): Promise => { credentials = { [CredentialGroup.public]: {}, [CredentialGroup.agent]: {}}; - identifier = { path: 'http://test.com/foo' }; + identifier = { path: 'http://example.com/foo' }; - modes = new Set([ - AccessMode.read, AccessMode.write, AccessMode.append, AclMode.control, - ]) as Set; + accessMap = new IdentifierSetMultiMap([ + [ identifier, AccessMode.read ], + [ identifier, AccessMode.write ], + [ identifier, AccessMode.append ], + [ identifier, AclMode.control ] as any, + ]); - input = { credentials, identifier, modes }; + input = { credentials, requestedModes: accessMap }; + + resourceSet = { + hasResource: jest.fn().mockResolvedValue(true), + }; store = { getRepresentation: jest.fn().mockResolvedValue(new BasicRepresentation([ @@ -59,7 +69,7 @@ describe('A WebAclReader', (): void => { handleSafe: jest.fn().mockResolvedValue(true), } as any; - reader = new WebAclReader(aclStrategy, store, identifierStrategy, accessChecker); + reader = new WebAclReader(aclStrategy, resourceSet, store, identifierStrategy, accessChecker); }); it('handles all input.', async(): Promise => { @@ -68,258 +78,158 @@ describe('A WebAclReader', (): void => { it('returns undefined permissions for undefined credentials.', async(): Promise => { input.credentials = {}; - await expect(reader.handle(input)).resolves.toEqual({ + compareMaps(await reader.handle(input), new IdentifierMap([[ identifier, { [CredentialGroup.public]: {}, [CredentialGroup.agent]: {}, - }); + }]])); }); it('reads the accessTo value of the acl resource.', async(): Promise => { credentials.agent = { webId: 'http://test.com/user' }; - store.getRepresentation.mockResolvedValue({ data: guardedStreamFrom([ + store.getRepresentation.mockResolvedValue(new BasicRepresentation([ quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), - ]) } as Representation); - await expect(reader.handle(input)).resolves.toEqual({ + ], INTERNAL_QUADS)); + compareMaps(await reader.handle(input), new IdentifierMap([[ identifier, { [CredentialGroup.public]: { read: true }, [CredentialGroup.agent]: { read: true }, - }); + }]])); }); it('ignores accessTo fields pointing to different resources.', async(): Promise => { credentials.agent = { webId: 'http://test.com/user' }; - store.getRepresentation.mockResolvedValue({ data: guardedStreamFrom([ + store.getRepresentation.mockResolvedValue(new BasicRepresentation([ quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), quad(nn('auth'), nn(`${acl}accessTo`), nn('somewhereElse')), quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), - ]) } as Representation); - await expect(reader.handle(input)).resolves.toEqual({ + ], INTERNAL_QUADS)); + compareMaps(await reader.handle(input), new IdentifierMap([[ identifier, { [CredentialGroup.public]: {}, [CredentialGroup.agent]: {}, - }); + }]])); }); it('handles all valid modes and ignores other ones.', async(): Promise => { credentials.agent = { webId: 'http://test.com/user' }; - store.getRepresentation.mockResolvedValue({ data: guardedStreamFrom([ + store.getRepresentation.mockResolvedValue(new BasicRepresentation([ quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}fakeMode1`)), - ]) } as Representation); - await expect(reader.handle(input)).resolves.toEqual({ + ], INTERNAL_QUADS)); + compareMaps(await reader.handle(input), new IdentifierMap([[ identifier, { [CredentialGroup.public]: { read: true }, [CredentialGroup.agent]: { read: true }, - }); + }]])); }); it('reads the default value of a parent if there is no direct acl resource.', async(): Promise => { - store.getRepresentation.mockImplementation(async(id: ResourceIdentifier): Promise => { - if (id.path.endsWith('foo.acl')) { - throw new NotFoundHttpError(); - } - return new BasicRepresentation([ - quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')), - quad(nn('auth'), nn(`${acl}default`), nn(identifierStrategy.getParentContainer(identifier).path)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), - ], INTERNAL_QUADS); - }); - await expect(reader.handle(input)).resolves.toEqual({ + resourceSet.hasResource.mockImplementation(async(id): Promise => !id.path.endsWith('foo.acl')); + store.getRepresentation.mockResolvedValue(new BasicRepresentation([ + quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), + quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')), + quad(nn('auth'), nn(`${acl}default`), nn(identifierStrategy.getParentContainer(identifier).path)), + quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), + ], INTERNAL_QUADS)); + compareMaps(await reader.handle(input), new IdentifierMap([[ identifier, { [CredentialGroup.public]: { read: true }, [CredentialGroup.agent]: { read: true }, - }); + }]])); }); it('does not use default authorizations for the resource itself.', async(): Promise => { - input.identifier = { path: ensureTrailingSlash(input.identifier.path) }; - store.getRepresentation.mockImplementation(async(): Promise => - new BasicRepresentation([ - quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')), - quad(nn('auth'), nn(`${acl}default`), nn(input.identifier.path)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), - quad(nn('auth2'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth2'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')), - quad(nn('auth2'), nn(`${acl}accessTo`), nn(input.identifier.path)), - quad(nn('auth2'), nn(`${acl}mode`), nn(`${acl}Append`)), - ], INTERNAL_QUADS)); - await expect(reader.handle(input)).resolves.toEqual({ + store.getRepresentation.mockResolvedValue(new BasicRepresentation([ + quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), + quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')), + quad(nn('auth'), nn(`${acl}default`), nn(identifier.path)), + quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), + quad(nn('auth2'), nn(`${rdf}type`), nn(`${acl}Authorization`)), + quad(nn('auth2'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')), + quad(nn('auth2'), nn(`${acl}accessTo`), nn(identifier.path)), + quad(nn('auth2'), nn(`${acl}mode`), nn(`${acl}Append`)), + ], INTERNAL_QUADS)); + compareMaps(await reader.handle(input), new IdentifierMap([[ identifier, { [CredentialGroup.public]: { append: true }, [CredentialGroup.agent]: { append: true }, - }); + }]])); }); it('re-throws ResourceStore errors as internal errors.', async(): Promise => { store.getRepresentation.mockRejectedValue(new Error('TEST!')); const promise = reader.handle(input); - await expect(promise).rejects.toThrow(`Error reading ACL for ${identifier.path}: TEST!`); + await expect(promise).rejects.toThrow(`Error reading ACL resource ${identifier.path}.acl: TEST!`); await expect(promise).rejects.toThrow(InternalServerError); }); it('errors if the root container has no corresponding acl document.', async(): Promise => { - store.getRepresentation.mockRejectedValue(new NotFoundHttpError()); + resourceSet.hasResource.mockResolvedValue(false); const promise = reader.handle(input); await expect(promise).rejects.toThrow('No ACL document found for root container'); await expect(promise).rejects.toThrow(ForbiddenHttpError); }); - it('allows an agent to append/create/delete if they have write access.', async(): Promise => { - store.getRepresentation.mockResolvedValue({ data: guardedStreamFrom([ - quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Write`)), - ]) } as Representation); - await expect(reader.handle(input)).resolves.toEqual({ - [CredentialGroup.public]: { write: true, append: true, create: true, delete: true }, - [CredentialGroup.agent]: { write: true, append: true, create: true, delete: true }, - }); - }); - - it('allows everything on an acl resource if control permissions are granted.', async(): Promise => { - store.getRepresentation.mockResolvedValue({ data: guardedStreamFrom([ - quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Control`)), - ]) } as Representation); - input.identifier = { path: `${identifier.path}.acl` }; - await expect(reader.handle(input)).resolves.toEqual({ - [CredentialGroup.public]: { read: true, write: true, append: true, create: true, delete: true, control: true }, - [CredentialGroup.agent]: { read: true, write: true, append: true, create: true, delete: true, control: true }, - }); - }); - - it('rejects everything on an acl resource if there are no control permissions.', async(): Promise => { - store.getRepresentation.mockResolvedValue({ data: guardedStreamFrom([ - quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), - ]) } as Representation); - input.identifier = { path: `${identifier.path}.acl` }; - await expect(reader.handle(input)).resolves.toEqual({ - [CredentialGroup.public]: {}, - [CredentialGroup.agent]: {}, - }); - }); - it('ignores rules where no access is granted.', async(): Promise => { credentials.agent = { webId: 'http://test.com/user' }; // CredentialGroup.public gets true on auth1, CredentialGroup.agent on auth2 accessChecker.handleSafe.mockImplementation(async({ rule, credential: cred }): Promise => (rule.value === 'auth1') === !cred.webId); - store.getRepresentation.mockResolvedValue({ data: guardedStreamFrom([ + store.getRepresentation.mockResolvedValue(new BasicRepresentation([ quad(nn('auth1'), nn(`${rdf}type`), nn(`${acl}Authorization`)), quad(nn('auth1'), nn(`${acl}accessTo`), nn(identifier.path)), quad(nn('auth1'), nn(`${acl}mode`), nn(`${acl}Read`)), quad(nn('auth2'), nn(`${rdf}type`), nn(`${acl}Authorization`)), quad(nn('auth2'), nn(`${acl}accessTo`), nn(identifier.path)), - quad(nn('auth2'), nn(`${acl}mode`), nn(`${acl}Control`)), - ]) } as Representation); + quad(nn('auth2'), nn(`${acl}mode`), nn(`${acl}Append`)), + ], INTERNAL_QUADS)); - await expect(reader.handle(input)).resolves.toEqual({ + compareMaps(await reader.handle(input), new IdentifierMap([[ identifier, { [CredentialGroup.public]: { read: true }, - [CredentialGroup.agent]: { control: true }, - }); + [CredentialGroup.agent]: { append: true }, + }]])); }); - it('requires append permissions on the parent container to create resources.', async(): Promise => { - store.getRepresentation.mockImplementation(async(id): Promise => { - const subject = id.path.slice(0, -4); - if (subject === input.identifier.path) { - throw new NotFoundHttpError(); + it('combines ACL representation requests for resources when possible.', async(): Promise => { + const identifier2 = { path: 'http://example.com/bar/' }; + const identifier3 = { path: 'http://example.com/bar/baz' }; + + resourceSet.hasResource.mockImplementation(async(id): Promise => + id.path === 'http://example.com/.acl' || id.path === 'http://example.com/bar/.acl'); + + store.getRepresentation.mockImplementation(async(id: ResourceIdentifier): Promise => { + if (id.path === 'http://example.com/.acl') { + return new BasicRepresentation([ + quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), + quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')), + quad(nn('auth'), nn(`${acl}default`), nn('http://example.com/')), + quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), + ], INTERNAL_QUADS); } - return new BasicRepresentation([ - quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth'), nn(`${acl}accessTo`), nn(subject)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Append`)), - ], 'internal/quads'); - }); - input.modes.add(AccessMode.create); - - await expect(reader.handle(input)).resolves.toEqual({ - [CredentialGroup.public]: { create: true }, - [CredentialGroup.agent]: { create: true }, - }); - }); - - it('requires write permissions on the parent container to delete resources.', async(): Promise => { - store.getRepresentation.mockImplementation(async(id): Promise => new BasicRepresentation([ - quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth'), nn(`${acl}accessTo`), nn(id.path.slice(0, -4))), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Write`)), - ], 'internal/quads')); - input.modes.add(AccessMode.delete); - - await expect(reader.handle(input)).resolves.toEqual({ - [CredentialGroup.public]: { append: true, write: true, delete: true, create: true }, - [CredentialGroup.agent]: { append: true, write: true, delete: true, create: true }, - }); - }); - - it('can use the same acl resource for both target and parent.', async(): Promise => { - store.getRepresentation.mockImplementation(async(id): Promise => { - const subject = id.path.slice(0, -4); - if (subject === input.identifier.path) { - throw new NotFoundHttpError(); + if (id.path === 'http://example.com/bar/.acl') { + return new BasicRepresentation([ + quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), + quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')), + quad(nn('auth'), nn(`${acl}default`), nn(identifier2.path)), + quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Append`)), + quad(nn('auth2'), nn(`${rdf}type`), nn(`${acl}Authorization`)), + quad(nn('auth2'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')), + quad(nn('auth2'), nn(`${acl}accessTo`), nn(identifier2.path)), + quad(nn('auth2'), nn(`${acl}mode`), nn(`${acl}Read`)), + ], INTERNAL_QUADS); } - return new BasicRepresentation([ - quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth'), nn(`${acl}accessTo`), nn(subject)), - quad(nn('auth'), nn(`${acl}default`), nn(subject)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Write`)), - ], 'internal/quads'); + throw new NotFoundHttpError(); }); - input.modes.add(AccessMode.create); - await expect(reader.handle(input)).resolves.toEqual({ - [CredentialGroup.public]: { append: true, write: true, delete: true, create: true }, - [CredentialGroup.agent]: { append: true, write: true, delete: true, create: true }, - }); - }); + input.requestedModes.set(identifier2, new Set([ AccessMode.read ])); + input.requestedModes.set(identifier3, new Set([ AccessMode.append ])); - it('does not grant create permission if the parent does not have append rights.', async(): Promise => { - store.getRepresentation.mockImplementation(async(id): Promise => { - const subject = id.path.slice(0, -4); - if (subject === input.identifier.path) { - throw new NotFoundHttpError(); - } - return new BasicRepresentation([ - quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth'), nn(`${acl}default`), nn(subject)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Write`)), - quad(nn('auth2'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth2'), nn(`${acl}accessTo`), nn(subject)), - quad(nn('auth2'), nn(`${acl}mode`), nn(`${acl}Read`)), - ], 'internal/quads'); - }); - input.modes.add(AccessMode.create); - - await expect(reader.handle(input)).resolves.toEqual({ - [CredentialGroup.public]: { append: true, write: true }, - [CredentialGroup.agent]: { append: true, write: true }, - }); - }); - - it('can use a grandparent acl resource for both target and parent.', async(): Promise => { - input.identifier = { path: 'http://test.com/foo/bar/' }; - store.getRepresentation.mockImplementation(async(id): Promise => { - const subject = id.path.slice(0, -4); - if (subject !== 'http://test.com/') { - throw new NotFoundHttpError(); - } - return new BasicRepresentation([ - quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth'), nn(`${acl}default`), nn(subject)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Write`)), - ], 'internal/quads'); - }); - input.modes.add(AccessMode.create); - - await expect(reader.handle(input)).resolves.toEqual({ - [CredentialGroup.public]: { append: true, write: true, delete: true, create: true }, - [CredentialGroup.agent]: { append: true, write: true, delete: true, create: true }, - }); + compareMaps(await reader.handle(input), new IdentifierMap([ + [ identifier, { [CredentialGroup.public]: { read: true }, [CredentialGroup.agent]: { read: true }}], + [ identifier2, { [CredentialGroup.public]: { read: true }, [CredentialGroup.agent]: { read: true }}], + [ identifier3, { [CredentialGroup.public]: { append: true }, [CredentialGroup.agent]: { append: true }}], + ])); + // http://example.com/.acl and http://example.com/bar/.acl + expect(store.getRepresentation).toHaveBeenCalledTimes(2); }); });