feat: Add WAC-Allow header when required

This commit is contained in:
Joachim Van Herwegen 2021-02-17 09:42:49 +01:00
parent f2f265c586
commit 139342470e
9 changed files with 115 additions and 1 deletions

View File

@ -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';

View File

@ -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;

View File

@ -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 clients 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<void> {
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(' ')}"`;
}
}

View File

@ -25,6 +25,9 @@ export class GetOperationHandler extends OperationHandler {
public async handle(input: Operation): Promise<ResponseDescription> {
const body = await this.store.getRepresentation(input.target, input.preferences);
input.authorization?.addMetadata(body.metadata);
return new OkResponseDescription(body.metadata, body.data);
}
}

View File

@ -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);
}
}

View File

@ -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.
*/

View File

@ -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<void> => {
response = createResponse();
});
it('adds no header if there is no relevant metadata.', async(): Promise<void> => {
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<void> => {
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<void> => {
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"',
});
});
});

View File

@ -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<void> => {
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');
});
});

View File

@ -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<void> => {
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');
});
});