From 18391ec414df7b78185e2670327abfa06f3c285d Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 29 Jun 2022 10:57:33 +0200 Subject: [PATCH] feat: Introduce ModesExtractor for intermediate containers --- .../IntermediateCreateExtractor.ts | 55 +++++++++++ src/index.ts | 1 + .../IntermediateCreateExtractor.test.ts | 91 +++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 src/authorization/permissions/IntermediateCreateExtractor.ts create mode 100644 test/unit/authorization/permissions/IntermediateCreateExtractor.test.ts diff --git a/src/authorization/permissions/IntermediateCreateExtractor.ts b/src/authorization/permissions/IntermediateCreateExtractor.ts new file mode 100644 index 000000000..646fa229a --- /dev/null +++ b/src/authorization/permissions/IntermediateCreateExtractor.ts @@ -0,0 +1,55 @@ +import type { Operation } from '../../http/Operation'; +import type { ResourceSet } from '../../storage/ResourceSet'; +import type { IdentifierStrategy } from '../../util/identifiers/IdentifierStrategy'; +import { ModesExtractor } from './ModesExtractor'; +import type { AccessMap } from './Permissions'; +import { AccessMode } from './Permissions'; + +/** + * Returns the required access modes from the source {@link ModesExtractor}. + * In case create permissions are required, + * verifies if any of the containers permissions also need to be created + * and adds the corresponding identifier/mode combinations. + */ +export class IntermediateCreateExtractor extends ModesExtractor { + private readonly resourceSet: ResourceSet; + private readonly strategy: IdentifierStrategy; + private readonly source: ModesExtractor; + + /** + * Certain permissions depend on the existence of the target resource. + * The provided {@link ResourceSet} will be used for that. + * @param resourceSet - {@link ResourceSet} that can verify the target resource existence. + * @param strategy - {@link IdentifierStrategy} that will be used to determine parent containers. + * @param source - The source {@link ModesExtractor}. + */ + public constructor(resourceSet: ResourceSet, strategy: IdentifierStrategy, source: ModesExtractor) { + super(); + this.resourceSet = resourceSet; + this.strategy = strategy; + this.source = source; + } + + public async canHandle(input: Operation): Promise { + return this.source.canHandle(input); + } + + public async handle(input: Operation): Promise { + const requestedModes = await this.source.handle(input); + + for (const key of requestedModes.distinctKeys()) { + if (requestedModes.hasEntry(key, AccessMode.create)) { + // Add the `create` mode if the parent does not exist yet + const parent = this.strategy.getParentContainer(key); + if (!await this.resourceSet.hasResource(parent)) { + // It is not completely clear at this point which permissions need to be available + // on intermediate containers to create them, + // so we stick with `create` for now. + requestedModes.add(parent, AccessMode.create); + } + } + } + + return requestedModes; + } +} diff --git a/src/index.ts b/src/index.ts index b651a8b45..079e89400 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ export * from './authorization/access/AgentGroupAccessChecker'; // Authorization/Permissions export * from './authorization/permissions/AclPermission'; +export * from './authorization/permissions/IntermediateCreateExtractor'; export * from './authorization/permissions/ModesExtractor'; export * from './authorization/permissions/MethodModesExtractor'; export * from './authorization/permissions/N3PatchModesExtractor'; diff --git a/test/unit/authorization/permissions/IntermediateCreateExtractor.test.ts b/test/unit/authorization/permissions/IntermediateCreateExtractor.test.ts new file mode 100644 index 000000000..2f5840f67 --- /dev/null +++ b/test/unit/authorization/permissions/IntermediateCreateExtractor.test.ts @@ -0,0 +1,91 @@ +import { IntermediateCreateExtractor } from '../../../../src/authorization/permissions/IntermediateCreateExtractor'; +import type { ModesExtractor } from '../../../../src/authorization/permissions/ModesExtractor'; +import type { AccessMap } from '../../../../src/authorization/permissions/Permissions'; +import { AccessMode } from '../../../../src/authorization/permissions/Permissions'; +import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { ResourceSet } from '../../../../src/storage/ResourceSet'; +import { SingleRootIdentifierStrategy } from '../../../../src/util/identifiers/SingleRootIdentifierStrategy'; +import { IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap'; +import { joinUrl } from '../../../../src/util/PathUtil'; +import { compareMaps } from '../../../util/Util'; + +describe('An IntermediateCreateExtractor', (): void => { + const baseUrl = 'http://example.com/'; + let operation: Operation; + const strategy = new SingleRootIdentifierStrategy(baseUrl); + let resourceSet: jest.Mocked; + let source: jest.Mocked; + let sourceMap: AccessMap; + let extractor: IntermediateCreateExtractor; + + beforeEach(async(): Promise => { + operation = { + target: { path: joinUrl(baseUrl, 'foo') }, + preferences: {}, + method: 'PUT', + body: new BasicRepresentation(), + }; + + resourceSet = { + hasResource: jest.fn().mockResolvedValue(true), + }; + + sourceMap = new IdentifierSetMultiMap(); + source = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue(sourceMap), + } as any; + + extractor = new IntermediateCreateExtractor(resourceSet, strategy, source); + }); + + it('can handle everything its source can handle.', async(): Promise => { + await expect(extractor.canHandle(operation)).resolves.toBeUndefined(); + expect(source.canHandle).toHaveBeenCalledTimes(1); + expect(source.canHandle).toHaveBeenLastCalledWith(operation); + + jest.resetAllMocks(); + source.canHandle.mockRejectedValueOnce(new Error('bad input')); + await expect(extractor.canHandle(operation)).rejects.toThrow('bad input'); + expect(source.canHandle).toHaveBeenCalledTimes(1); + expect(source.canHandle).toHaveBeenLastCalledWith(operation); + }); + + it('returns the source output if no create permissions are needed.', async(): Promise => { + const identifier = { path: joinUrl(baseUrl, 'foo') }; + sourceMap.set(identifier, new Set([ AccessMode.read ])); + + const resultMap = new IdentifierSetMultiMap([[ identifier, AccessMode.read ]]); + + compareMaps(await extractor.handle(operation), resultMap); + expect(resourceSet.hasResource).toHaveBeenCalledTimes(0); + }); + + it('requests create permissions for all parent containers that do not exist.', async(): Promise => { + const idA = { path: joinUrl(baseUrl, 'a/') }; + const idAB = { path: joinUrl(baseUrl, 'a/b/') }; + const idABC = { path: joinUrl(baseUrl, 'a/b/c/') }; + const idD = { path: joinUrl(baseUrl, 'd/') }; + const idDE = { path: joinUrl(baseUrl, 'd/e/') }; + + sourceMap.set(idABC, new Set([ AccessMode.create, AccessMode.write ])); + sourceMap.set(idDE, new Set([ AccessMode.create, AccessMode.append ])); + sourceMap.set(idD, new Set([ AccessMode.read ])); + + resourceSet.hasResource.mockImplementation(async(id): Promise => id.path === baseUrl); + + const resultMap = new IdentifierSetMultiMap([ + [ idA, AccessMode.create ], + [ idAB, AccessMode.create ], + [ idABC, AccessMode.create ], + [ idABC, AccessMode.write ], + [ idD, AccessMode.create ], + [ idD, AccessMode.read ], + [ idDE, AccessMode.create ], + [ idDE, AccessMode.append ], + ]); + + compareMaps(await extractor.handle(operation), resultMap); + }); +});