From 7f34fe6ae35c184ed05d65e674bf9b46dc3d9978 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 26 Jan 2021 13:50:52 +0100 Subject: [PATCH] feat: Create separate authorizer for auxiliary functions --- config/presets/acl.json | 22 +++++- config/presets/ldp.json | 2 +- config/presets/storage-wrapper.json | 10 +++ src/authorization/AuxiliaryAuthorizer.ts | 50 ++++++++++++ src/authorization/WebAclAuthorizer.ts | 15 ++-- src/index.ts | 1 + .../authorization/AuxiliaryAuthorizer.test.ts | 76 +++++++++++++++++++ .../authorization/WebAclAuthorizer.test.ts | 36 ++++----- 8 files changed, 182 insertions(+), 30 deletions(-) create mode 100644 src/authorization/AuxiliaryAuthorizer.ts create mode 100644 test/unit/authorization/AuxiliaryAuthorizer.test.ts diff --git a/config/presets/acl.json b/config/presets/acl.json index 5776c40f3..e4c7d568c 100644 --- a/config/presets/acl.json +++ b/config/presets/acl.json @@ -26,7 +26,27 @@ }, { - "@id": "urn:solid-server:default:AclAuthorizer", + "@id": "urn:solid-server:default:AclBasedAuthorizer", + "@type": "WaterfallHandler", + "WaterfallHandler:_handlers": [ + { + "comment": "This authorizer makes sure that for auxiliary resources, the main authorizer gets called with the associated identifier.", + "@type": "AuxiliaryAuthorizer", + "AuxiliaryAuthorizer:_resourceAuthorizer": { + "@id": "urn:solid-server:default:WebAclAuthorizer" + }, + "AuxiliaryAuthorizer:_auxStrategy": { + "@id": "urn:solid-server:default:AuxiliaryStrategy" + } + }, + { + "@id": "urn:solid-server:default:WebAclAuthorizer" + } + ] + }, + + { + "@id": "urn:solid-server:default:WebAclAuthorizer", "@type": "WebAclAuthorizer", "WebAclAuthorizer:_aclStrategy": { "@id": "urn:solid-server:default:AclIdentifierStrategy" diff --git a/config/presets/ldp.json b/config/presets/ldp.json index 3dd906c24..cbd65f07a 100644 --- a/config/presets/ldp.json +++ b/config/presets/ldp.json @@ -14,7 +14,7 @@ "@id": "urn:solid-server:default:PermissionsExtractor" }, "AuthenticatedLdpHandler:_args_authorizer": { - "@id": "urn:solid-server:default:AclAuthorizer" + "@id": "urn:solid-server:default:AclBasedAuthorizer" }, "AuthenticatedLdpHandler:_args_operationHandler": { "@id": "urn:solid-server:default:OperationHandler" diff --git a/config/presets/storage-wrapper.json b/config/presets/storage-wrapper.json index 11adf5bfc..4205515d0 100644 --- a/config/presets/storage-wrapper.json +++ b/config/presets/storage-wrapper.json @@ -20,6 +20,16 @@ } }, + { + "@id": "urn:solid-server:default:AuxiliaryStrategy", + "@type": "RoutingAuxiliaryStrategy", + "RoutingAuxiliaryStrategy:_sources": [ + { + "@id": "urn:solid-server:default:AclStrategy" + } + ] + }, + { "@id": "urn:solid-server:default:ResourceStore_Locking", "@type": "LockingResourceStore", diff --git a/src/authorization/AuxiliaryAuthorizer.ts b/src/authorization/AuxiliaryAuthorizer.ts new file mode 100644 index 000000000..f385aff6d --- /dev/null +++ b/src/authorization/AuxiliaryAuthorizer.ts @@ -0,0 +1,50 @@ +import type { AuxiliaryIdentifierStrategy } from '../ldp/auxiliary/AuxiliaryIdentifierStrategy'; +import { getLoggerFor } from '../logging/LogUtil'; +import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; +import type { AuthorizerArgs } from './Authorizer'; +import { Authorizer } from './Authorizer'; + +/** + * An authorizer for auxiliary resources such as acl or shape resources. + * The access permissions of an auxiliary resource depend on those of the resource it is associated with. + * This authorizer calls the source authorizer with the identifier of the associated resource. + */ +export class AuxiliaryAuthorizer extends Authorizer { + protected readonly logger = getLoggerFor(this); + + private readonly resourceAuthorizer: Authorizer; + private readonly auxStrategy: AuxiliaryIdentifierStrategy; + + public constructor(resourceAuthorizer: Authorizer, auxStrategy: AuxiliaryIdentifierStrategy) { + super(); + this.resourceAuthorizer = resourceAuthorizer; + this.auxStrategy = auxStrategy; + } + + public async canHandle(auxiliaryAuth: AuthorizerArgs): Promise { + const resourceAuth = this.getRequiredAuthorization(auxiliaryAuth); + return this.resourceAuthorizer.canHandle(resourceAuth); + } + + public async handle(auxiliaryAuth: AuthorizerArgs): Promise { + const resourceAuth = this.getRequiredAuthorization(auxiliaryAuth); + this.logger.debug(`Checking auth request for ${auxiliaryAuth.identifier.path} on ${resourceAuth.identifier.path}`); + return this.resourceAuthorizer.handle(resourceAuth); + } + + public async handleSafe(auxiliaryAuth: AuthorizerArgs): Promise { + const resourceAuth = this.getRequiredAuthorization(auxiliaryAuth); + this.logger.debug(`Checking auth request for ${auxiliaryAuth.identifier.path} to ${resourceAuth.identifier.path}`); + return this.resourceAuthorizer.handleSafe(resourceAuth); + } + + private getRequiredAuthorization(auxiliaryAuth: AuthorizerArgs): AuthorizerArgs { + if (!this.auxStrategy.isAuxiliaryIdentifier(auxiliaryAuth.identifier)) { + throw new NotImplementedHttpError('AuxiliaryAuthorizer only supports auxiliary resources.'); + } + return { + ...auxiliaryAuth, + identifier: this.auxStrategy.getAssociatedIdentifier(auxiliaryAuth.identifier), + }; + } +} diff --git a/src/authorization/WebAclAuthorizer.ts b/src/authorization/WebAclAuthorizer.ts index 5de04e1ef..04329829b 100644 --- a/src/authorization/WebAclAuthorizer.ts +++ b/src/authorization/WebAclAuthorizer.ts @@ -10,6 +10,7 @@ import type { ResourceStore } from '../storage/ResourceStore'; import { INTERNAL_QUADS } from '../util/ContentTypes'; import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError'; import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; +import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; import { UnauthorizedHttpError } from '../util/errors/UnauthorizedHttpError'; import type { IdentifierStrategy } from '../util/identifiers/IdentifierStrategy'; import { ACL, FOAF } from '../util/Vocabularies'; @@ -36,6 +37,12 @@ export class WebAclAuthorizer extends Authorizer { this.identifierStrategy = identifierStrategy; } + public async canHandle({ identifier }: AuthorizerArgs): Promise { + if (this.aclStrategy.isAuxiliaryIdentifier(identifier)) { + throw new NotImplementedHttpError('WebAclAuthorizer does not support permissions on acl files.'); + } + } + /** * Checks if an agent is allowed to execute the requested actions. * Will throw an error if this is not the case. @@ -128,7 +135,7 @@ export class WebAclAuthorizer extends Authorizer { /** * Returns the acl triples that are relevant for the given identifier. * These can either be from a corresponding acl file or an acl file higher up with defaults. - * Rethrows any non-NotFoundHttpErrors thrown by the AclManager or ResourceStore. + * Rethrows any non-NotFoundHttpErrors thrown by the ResourceStore. * @param id - ResourceIdentifier of which we need the acl triples. * @param recurse - Only used internally for recursion. * @@ -137,14 +144,12 @@ export class WebAclAuthorizer extends Authorizer { private async getAclRecursive(id: ResourceIdentifier, recurse?: boolean): Promise { this.logger.debug(`Trying to read the direct ACL document of ${id.path}`); try { - const isAcl = this.aclStrategy.isAuxiliaryIdentifier(id); - const acl = isAcl ? id : this.aclStrategy.getAuxiliaryIdentifier(id); + const acl = this.aclStrategy.getAuxiliaryIdentifier(id); this.logger.debug(`Trying to read the ACL document ${acl.path}`); const data = await this.resourceStore.getRepresentation(acl, { type: { [INTERNAL_QUADS]: 1 }}); this.logger.info(`Reading ACL statements from ${acl.path}`); - const resourceId = isAcl ? this.aclStrategy.getAssociatedIdentifier(id) : id; - return this.filterData(data, recurse ? ACL.default : ACL.accessTo, resourceId.path); + return this.filterData(data, recurse ? ACL.default : ACL.accessTo, id.path); } catch (error: unknown) { if (NotFoundHttpError.isInstance(error)) { this.logger.debug(`No direct ACL document found for ${id.path}`); diff --git a/src/index.ts b/src/index.ts index e83a69cf2..9e417bff8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export * from './authentication/UnsecureWebIdExtractor'; // Authorization export * from './authorization/AllowEverythingAuthorizer'; export * from './authorization/Authorizer'; +export * from './authorization/AuxiliaryAuthorizer'; export * from './authorization/WebAclAuthorizer'; // Init diff --git a/test/unit/authorization/AuxiliaryAuthorizer.test.ts b/test/unit/authorization/AuxiliaryAuthorizer.test.ts new file mode 100644 index 000000000..8b430e420 --- /dev/null +++ b/test/unit/authorization/AuxiliaryAuthorizer.test.ts @@ -0,0 +1,76 @@ +import type { Authorizer } from '../../../src/authorization/Authorizer'; +import { AuxiliaryAuthorizer } from '../../../src/authorization/AuxiliaryAuthorizer'; +import type { AuxiliaryIdentifierStrategy } from '../../../src/ldp/auxiliary/AuxiliaryIdentifierStrategy'; +import type { PermissionSet } from '../../../src/ldp/permissions/PermissionSet'; +import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier'; +import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError'; + +describe('An AuxiliaryAuthorizer', (): void => { + const suffix = '.dummy'; + const credentials = {}; + const associatedIdentifier = { path: 'http://test.com/foo' }; + const auxiliaryIdentifier = { path: 'http://test.com/foo.dummy' }; + let permissions: PermissionSet; + let source: Authorizer; + let strategy: AuxiliaryIdentifierStrategy; + let authorizer: AuxiliaryAuthorizer; + + beforeEach(async(): Promise => { + permissions = { + read: true, + write: true, + append: true, + control: false, + }; + + source = { + canHandle: jest.fn(), + handle: jest.fn(), + handleSafe: jest.fn(), + }; + + strategy = { + isAuxiliaryIdentifier: jest.fn((identifier: ResourceIdentifier): boolean => identifier.path.endsWith(suffix)), + getAssociatedIdentifier: jest.fn((identifier: ResourceIdentifier): ResourceIdentifier => + ({ path: identifier.path.slice(0, -suffix.length) })), + } as any; + authorizer = new AuxiliaryAuthorizer(source, strategy); + }); + + it('can handle auxiliary resources if the source supports the associated resource.', async(): Promise => { + await expect(authorizer.canHandle({ identifier: auxiliaryIdentifier, credentials, permissions })) + .resolves.toBeUndefined(); + expect(source.canHandle).toHaveBeenLastCalledWith( + { identifier: associatedIdentifier, credentials, permissions }, + ); + await expect(authorizer.canHandle({ identifier: associatedIdentifier, credentials, permissions })) + .rejects.toThrow(NotImplementedHttpError); + source.canHandle = jest.fn().mockRejectedValue(new Error('no source support')); + await expect(authorizer.canHandle({ identifier: auxiliaryIdentifier, credentials, permissions })) + .rejects.toThrow('no source support'); + }); + + it('handles resources by sending the updated parameters to the source.', async(): Promise => { + await expect(authorizer.handle({ identifier: auxiliaryIdentifier, credentials, permissions })) + .resolves.toBeUndefined(); + expect(source.handle).toHaveBeenLastCalledWith( + { identifier: associatedIdentifier, credentials, permissions }, + ); + // Safety checks are not present when calling `handle` + await expect(authorizer.handle({ identifier: associatedIdentifier, credentials, permissions })) + .rejects.toThrow(NotImplementedHttpError); + }); + + it('combines both checking and handling when calling handleSafe.', async(): Promise => { + await expect(authorizer.handleSafe({ identifier: auxiliaryIdentifier, credentials, permissions })) + .resolves.toBeUndefined(); + expect(source.handleSafe).toHaveBeenLastCalledWith( + { identifier: associatedIdentifier, credentials, permissions }, + ); + await expect(authorizer.handleSafe({ identifier: associatedIdentifier, credentials, permissions })) + .rejects.toThrow(NotImplementedHttpError); + source.handleSafe = jest.fn().mockRejectedValue(new Error('no source support')); + await expect(authorizer.handleSafe({ identifier: auxiliaryIdentifier, credentials, permissions })) + .rejects.toThrow('no source support'); + }); +}); diff --git a/test/unit/authorization/WebAclAuthorizer.test.ts b/test/unit/authorization/WebAclAuthorizer.test.ts index 90b8c16d2..350496620 100644 --- a/test/unit/authorization/WebAclAuthorizer.test.ts +++ b/test/unit/authorization/WebAclAuthorizer.test.ts @@ -1,5 +1,4 @@ import { namedNode, quad } from '@rdfjs/data-model'; -import streamifyArray from 'streamify-array'; import type { Credentials } from '../../../src/authentication/Credentials'; import { WebAclAuthorizer } from '../../../src/authorization/WebAclAuthorizer'; import type { AuxiliaryIdentifierStrategy } from '../../../src/ldp/auxiliary/AuxiliaryIdentifierStrategy'; @@ -9,8 +8,10 @@ import type { ResourceIdentifier } from '../../../src/ldp/representation/Resourc import type { ResourceStore } from '../../../src/storage/ResourceStore'; import { ForbiddenHttpError } from '../../../src/util/errors/ForbiddenHttpError'; import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; +import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError'; import { UnauthorizedHttpError } from '../../../src/util/errors/UnauthorizedHttpError'; import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; +import { guardedStreamFrom } from '../../../src/util/StreamUtil'; const nn = namedNode; @@ -45,13 +46,15 @@ describe('A WebAclAuthorizer', (): void => { authorizer = new WebAclAuthorizer(aclStrategy, store, identifierStrategy); }); - it('handles all inputs.', async(): Promise => { + it('handles all non-acl inputs.', async(): Promise => { authorizer = new WebAclAuthorizer(aclStrategy, null as any, identifierStrategy); - await expect(authorizer.canHandle({} as any)).resolves.toBeUndefined(); + await expect(authorizer.canHandle({ identifier } as any)).resolves.toBeUndefined(); + await expect(authorizer.canHandle({ identifier: aclStrategy.getAuxiliaryIdentifier(identifier) } as any)) + .rejects.toThrow(NotImplementedHttpError); }); it('allows access if the acl file allows all agents.', async(): Promise => { - store.getRepresentation = async(): Promise => ({ data: streamifyArray([ + store.getRepresentation = async(): Promise => ({ data: guardedStreamFrom([ quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')), quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), @@ -66,7 +69,7 @@ describe('A WebAclAuthorizer', (): void => { throw new NotFoundHttpError(); } return { - data: streamifyArray([ + data: guardedStreamFrom([ 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`)), @@ -78,7 +81,7 @@ describe('A WebAclAuthorizer', (): void => { }); it('allows access to authorized agents if the acl files allows all authorized users.', async(): Promise => { - store.getRepresentation = async(): Promise => ({ data: streamifyArray([ + store.getRepresentation = async(): Promise => ({ data: guardedStreamFrom([ quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/AuthenticatedAgent')), quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), @@ -89,7 +92,7 @@ describe('A WebAclAuthorizer', (): void => { }); it('errors if authorization is required but the agent is not authorized.', async(): Promise => { - store.getRepresentation = async(): Promise => ({ data: streamifyArray([ + store.getRepresentation = async(): Promise => ({ data: guardedStreamFrom([ quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/AuthenticatedAgent')), quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), @@ -100,7 +103,7 @@ describe('A WebAclAuthorizer', (): void => { it('allows access to specific agents if the acl files identifies them.', async(): Promise => { credentials.webId = 'http://test.com/user'; - store.getRepresentation = async(): Promise => ({ data: streamifyArray([ + store.getRepresentation = async(): Promise => ({ data: guardedStreamFrom([ quad(nn('auth'), nn(`${acl}agent`), nn(credentials.webId!)), quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), @@ -111,7 +114,7 @@ describe('A WebAclAuthorizer', (): void => { it('errors if a specific agents wants to access files not assigned to them.', async(): Promise => { credentials.webId = 'http://test.com/user'; - store.getRepresentation = async(): Promise => ({ data: streamifyArray([ + store.getRepresentation = async(): Promise => ({ data: guardedStreamFrom([ quad(nn('auth'), nn(`${acl}agent`), nn('http://test.com/differentUser')), quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), @@ -120,19 +123,6 @@ describe('A WebAclAuthorizer', (): void => { await expect(authorizer.handle({ identifier, permissions, credentials })).rejects.toThrow(ForbiddenHttpError); }); - it('errors if an agent tries to edit the acl file without control permissions.', async(): Promise => { - credentials.webId = 'http://test.com/user'; - identifier.path = 'http://test.com/foo'; - store.getRepresentation = async(): Promise => ({ data: streamifyArray([ - quad(nn('auth'), nn(`${acl}agent`), nn(credentials.webId!)), - 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}Write`)), - ]) } as Representation); - identifier = aclStrategy.getAuxiliaryIdentifier(identifier); - await expect(authorizer.handle({ identifier, permissions, credentials })).rejects.toThrow(ForbiddenHttpError); - }); - it('passes errors of the ResourceStore along.', async(): Promise => { store.getRepresentation = async(): Promise => { throw new Error('TEST!'); @@ -158,7 +148,7 @@ describe('A WebAclAuthorizer', (): void => { append: true, control: false, }; - store.getRepresentation = async(): Promise => ({ data: streamifyArray([ + store.getRepresentation = async(): Promise => ({ data: guardedStreamFrom([ quad(nn('auth'), nn(`${acl}agent`), nn(credentials.webId!)), quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Write`)),