From 139342470ee013a66466a79a868b8dbf52e9c969 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 17 Feb 2021 09:42:49 +0100 Subject: [PATCH] feat: Add WAC-Allow header when required --- src/index.ts | 1 + src/ldp/AuthenticatedLdpHandler.ts | 4 +- .../http/metadata/WacAllowMetadataWriter.ts | 40 +++++++++++++++++ src/ldp/operations/GetOperationHandler.ts | 3 ++ src/ldp/operations/HeadOperationHandler.ts | 2 + src/ldp/operations/Operation.ts | 5 +++ .../metadata/WacAllowMetadataWriter.test.ts | 43 +++++++++++++++++++ .../operations/GetOperationHandler.test.ts | 9 ++++ .../operations/HeadOperationHandler.test.ts | 9 ++++ 9 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 src/ldp/http/metadata/WacAllowMetadataWriter.ts create mode 100644 test/unit/ldp/http/metadata/WacAllowMetadataWriter.test.ts diff --git a/src/index.ts b/src/index.ts index 0f5d56f8d..a4284ce4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ export * from './ldp/http/metadata/MetadataExtractor'; export * from './ldp/http/metadata/MetadataParser'; export * from './ldp/http/metadata/MetadataWriter'; export * from './ldp/http/metadata/SlugParser'; +export * from './ldp/http/metadata/WacAllowMetadataWriter'; // LDP/HTTP/Response export * from './ldp/http/response/CreatedResponseDescription'; diff --git a/src/ldp/AuthenticatedLdpHandler.ts b/src/ldp/AuthenticatedLdpHandler.ts index a3bb3e8a6..2c6f47002 100644 --- a/src/ldp/AuthenticatedLdpHandler.ts +++ b/src/ldp/AuthenticatedLdpHandler.ts @@ -127,7 +127,9 @@ export class AuthenticatedLdpHandler extends HttpHandler { this.logger.verbose(`Required permissions are read: ${read}, write: ${write}, append: ${append}`); try { - await this.authorizer.handleSafe({ credentials, identifier: operation.target, permissions }); + const authorization = await this.authorizer + .handleSafe({ credentials, identifier: operation.target, permissions }); + operation.authorization = authorization; } catch (error: unknown) { this.logger.verbose(`Authorization failed: ${(error as any).message}`); throw error; diff --git a/src/ldp/http/metadata/WacAllowMetadataWriter.ts b/src/ldp/http/metadata/WacAllowMetadataWriter.ts new file mode 100644 index 000000000..92c20e306 --- /dev/null +++ b/src/ldp/http/metadata/WacAllowMetadataWriter.ts @@ -0,0 +1,40 @@ +import type { Term } from 'rdf-js'; +import type { HttpResponse } from '../../../server/HttpResponse'; +import { addHeader } from '../../../util/HeaderUtil'; +import { ACL, AUTH } from '../../../util/Vocabularies'; +import type { RepresentationMetadata } from '../../representation/RepresentationMetadata'; +import { MetadataWriter } from './MetadataWriter'; + +/** + * Add the necessary WAC-Allow header values. + * Solid, §10.1: "Servers exposing client’s access privileges on a resource URL MUST advertise + * by including the WAC-Allow HTTP header in the response of HTTP HEAD and GET requests." + * https://solid.github.io/specification/protocol#web-access-control + */ +export class WacAllowMetadataWriter extends MetadataWriter { + public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise { + const userModes = input.metadata.getAll(AUTH.terms.userMode).map(this.aclToPermission); + const publicModes = input.metadata.getAll(AUTH.terms.publicMode).map(this.aclToPermission); + + const headerStrings: string[] = []; + if (userModes.length > 0) { + headerStrings.push(this.createAccessParam('user', userModes)); + } + if (publicModes.length > 0) { + headerStrings.push(this.createAccessParam('public', publicModes)); + } + + // Only add the header if there are permissions to show + if (headerStrings.length > 0) { + addHeader(input.response, 'WAC-Allow', headerStrings.join(',')); + } + } + + private aclToPermission(aclTerm: Term): string { + return aclTerm.value.slice(ACL.namespace.length).toLowerCase(); + } + + private createAccessParam(name: string, modes: string[]): string { + return `${name}="${modes.join(' ')}"`; + } +} diff --git a/src/ldp/operations/GetOperationHandler.ts b/src/ldp/operations/GetOperationHandler.ts index 3055a58c7..13a2123bb 100644 --- a/src/ldp/operations/GetOperationHandler.ts +++ b/src/ldp/operations/GetOperationHandler.ts @@ -25,6 +25,9 @@ export class GetOperationHandler extends OperationHandler { public async handle(input: Operation): Promise { const body = await this.store.getRepresentation(input.target, input.preferences); + + input.authorization?.addMetadata(body.metadata); + return new OkResponseDescription(body.metadata, body.data); } } diff --git a/src/ldp/operations/HeadOperationHandler.ts b/src/ldp/operations/HeadOperationHandler.ts index 7a49393b7..1a29fbaa8 100644 --- a/src/ldp/operations/HeadOperationHandler.ts +++ b/src/ldp/operations/HeadOperationHandler.ts @@ -29,6 +29,8 @@ export class HeadOperationHandler extends OperationHandler { // Close the Readable as we will not return it. body.data.destroy(); + input.authorization?.addMetadata(body.metadata); + return new OkResponseDescription(body.metadata); } } diff --git a/src/ldp/operations/Operation.ts b/src/ldp/operations/Operation.ts index 18b0484bf..75b7ed26b 100644 --- a/src/ldp/operations/Operation.ts +++ b/src/ldp/operations/Operation.ts @@ -1,3 +1,4 @@ +import type { Authorization } from '../../authorization/Authorization'; import type { Representation } from '../representation/Representation'; import type { RepresentationPreferences } from '../representation/RepresentationPreferences'; import type { ResourceIdentifier } from '../representation/ResourceIdentifier'; @@ -18,6 +19,10 @@ export interface Operation { * Representation preferences of the response. Will be empty if there are none. */ preferences: RepresentationPreferences; + /** + * This value will be set if the Operation was authorized by an Authorizer. + */ + authorization?: Authorization; /** * Optional representation of the body. */ diff --git a/test/unit/ldp/http/metadata/WacAllowMetadataWriter.test.ts b/test/unit/ldp/http/metadata/WacAllowMetadataWriter.test.ts new file mode 100644 index 000000000..9788bdbf1 --- /dev/null +++ b/test/unit/ldp/http/metadata/WacAllowMetadataWriter.test.ts @@ -0,0 +1,43 @@ +import { createResponse } from 'node-mocks-http'; +import { WacAllowMetadataWriter } from '../../../../../src/ldp/http/metadata/WacAllowMetadataWriter'; +import { RepresentationMetadata } from '../../../../../src/ldp/representation/RepresentationMetadata'; +import type { HttpResponse } from '../../../../../src/server/HttpResponse'; +import { ACL, AUTH } from '../../../../../src/util/Vocabularies'; + +describe('WacAllowMetadataWriter', (): void => { + const writer = new WacAllowMetadataWriter(); + let response: HttpResponse; + + beforeEach(async(): Promise => { + response = createResponse(); + }); + + it('adds no header if there is no relevant metadata.', async(): Promise => { + const metadata = new RepresentationMetadata(); + await expect(writer.handle({ response, metadata })).resolves.toBeUndefined(); + expect(response.getHeaders()).toEqual({ }); + }); + + it('adds a WAC-Allow header if there is relevant metadata.', async(): Promise => { + const metadata = new RepresentationMetadata({ + [AUTH.userMode]: [ ACL.terms.Read, ACL.terms.Write ], + [AUTH.publicMode]: [ ACL.terms.Read ], + }); + await expect(writer.handle({ response, metadata })).resolves.toBeUndefined(); + + expect(response.getHeaders()).toEqual({ + 'wac-allow': 'user="read write",public="read"', + }); + }); + + it('only adds a header value for entries with at least 1 permission.', async(): Promise => { + const metadata = new RepresentationMetadata({ + [AUTH.userMode]: [ ACL.terms.Read, ACL.terms.Write ], + }); + await expect(writer.handle({ response, metadata })).resolves.toBeUndefined(); + + expect(response.getHeaders()).toEqual({ + 'wac-allow': 'user="read write"', + }); + }); +}); diff --git a/test/unit/ldp/operations/GetOperationHandler.test.ts b/test/unit/ldp/operations/GetOperationHandler.test.ts index 17de59a2c..92409489f 100644 --- a/test/unit/ldp/operations/GetOperationHandler.test.ts +++ b/test/unit/ldp/operations/GetOperationHandler.test.ts @@ -1,3 +1,4 @@ +import type { Authorization } from '../../../../src/authorization/Authorization'; import { GetOperationHandler } from '../../../../src/ldp/operations/GetOperationHandler'; import type { Operation } from '../../../../src/ldp/operations/Operation'; import type { Representation } from '../../../../src/ldp/representation/Representation'; @@ -22,4 +23,12 @@ describe('A GetOperationHandler', (): void => { expect(result.metadata).toBe('metadata'); expect(result.data).toBe('data'); }); + + it('adds authorization metadata in case the operation is an AuthorizedOperation.', async(): Promise => { + const authorization: Authorization = { addMetadata: jest.fn() }; + const result = await handler.handle({ target: { path: 'url' }, authorization } as Operation); + expect(result.statusCode).toBe(200); + expect(authorization.addMetadata).toHaveBeenCalledTimes(1); + expect(authorization.addMetadata).toHaveBeenLastCalledWith('metadata'); + }); }); diff --git a/test/unit/ldp/operations/HeadOperationHandler.test.ts b/test/unit/ldp/operations/HeadOperationHandler.test.ts index 2bdb8e892..cd4343dbf 100644 --- a/test/unit/ldp/operations/HeadOperationHandler.test.ts +++ b/test/unit/ldp/operations/HeadOperationHandler.test.ts @@ -1,4 +1,5 @@ import type { Readable } from 'stream'; +import type { Authorization } from '../../../../src/authorization/Authorization'; import { HeadOperationHandler } from '../../../../src/ldp/operations/HeadOperationHandler'; import type { Operation } from '../../../../src/ldp/operations/Operation'; import type { Representation } from '../../../../src/ldp/representation/Representation'; @@ -32,4 +33,12 @@ describe('A HeadOperationHandler', (): void => { expect(result.data).toBeUndefined(); expect(data.destroy).toHaveBeenCalledTimes(1); }); + + it('adds authorization metadata in case the operation is an AuthorizedOperation.', async(): Promise => { + const authorization: Authorization = { addMetadata: jest.fn() }; + const result = await handler.handle({ target: { path: 'url' }, authorization } as Operation); + expect(result.statusCode).toBe(200); + expect(authorization.addMetadata).toHaveBeenCalledTimes(1); + expect(authorization.addMetadata).toHaveBeenLastCalledWith('metadata'); + }); });