diff --git a/bin/server.ts b/bin/server.ts index ae4bdc2fb..213e8e0db 100644 --- a/bin/server.ts +++ b/bin/server.ts @@ -1,3 +1,6 @@ +#!/usr/bin/env node +import { DATA_TYPE_BINARY } from '../src/util/ContentTypes'; +import streamifyArray from 'streamify-array'; import yargs from 'yargs'; import { AcceptPreferenceParser, @@ -9,14 +12,16 @@ import { QuadToTurtleConverter, Representation, RepresentationConvertingStore, - SimpleAuthorizer, + SimpleAclAuthorizer, SimpleBodyParser, SimpleCredentialsExtractor, SimpleDeleteOperationHandler, + SimpleExtensionAclManager, SimpleGetOperationHandler, SimplePatchOperationHandler, SimplePermissionsExtractor, SimplePostOperationHandler, + SimplePutOperationHandler, SimpleRequestParser, SimpleResourceStore, SimpleResponseWriter, @@ -25,6 +30,7 @@ import { SimpleTargetExtractor, SingleThreadedResourceLocker, TurtleToQuadConverter, + UrlContainerManager, } from '..'; const { argv } = yargs @@ -36,6 +42,8 @@ const { argv } = yargs const { port } = argv; +const base = `http://localhost:${port}/`; + // This is instead of the dependency injection that still needs to be added const bodyParser = new CompositeAsyncHandler([ new SimpleSparqlUpdateBodyParser(), @@ -49,10 +57,9 @@ const requestParser = new SimpleRequestParser({ const credentialsExtractor = new SimpleCredentialsExtractor(); const permissionsExtractor = new SimplePermissionsExtractor(); -const authorizer = new SimpleAuthorizer(); // Will have to see how to best handle this -const store = new SimpleResourceStore(`http://localhost:${port}/`); +const store = new SimpleResourceStore(base); const converter = new CompositeAsyncHandler([ new TurtleToQuadConverter(), new QuadToTurtleConverter(), @@ -62,11 +69,16 @@ const locker = new SingleThreadedResourceLocker(); const patcher = new SimpleSparqlUpdatePatchHandler(convertingStore, locker); const patchingStore = new PatchingStore(convertingStore, patcher); +const aclManager = new SimpleExtensionAclManager(); +const containerManager = new UrlContainerManager(base); +const authorizer = new SimpleAclAuthorizer(aclManager, containerManager, patchingStore); + const operationHandler = new CompositeAsyncHandler([ new SimpleDeleteOperationHandler(patchingStore), new SimpleGetOperationHandler(patchingStore), new SimplePatchOperationHandler(patchingStore), new SimplePostOperationHandler(patchingStore), + new SimplePutOperationHandler(patchingStore), ]); const responseWriter = new SimpleResponseWriter(); @@ -82,6 +94,40 @@ const httpHandler = new AuthenticatedLdpHandler({ const httpServer = new ExpressHttpServer(httpHandler); -httpServer.listen(port); +// Set up acl so everything can still be done by default +// Note that this will need to be adapted to go through all the correct channels later on +const aclSetup = async(): Promise => { + const acl = `@prefix acl: . +@prefix foaf: . -process.stdout.write(`Running at http://localhost:${port}/\n`); +<#authorization> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:mode acl:Read; + acl:mode acl:Write; + acl:mode acl:Append; + acl:mode acl:Delete; + acl:mode acl:Control; + acl:accessTo <${base}>; + acl:default <${base}>.`; + await store.setRepresentation( + await aclManager.getAcl({ path: base }), + { + dataType: DATA_TYPE_BINARY, + data: streamifyArray([ acl ]), + metadata: { + raw: [], + profiles: [], + contentType: 'text/turtle', + }, + }, + ); +}; +aclSetup().then((): void => { + httpServer.listen(port); + + process.stdout.write(`Running at ${base}\n`); +}).catch((error): void => { + process.stderr.write(`${error}\n`); + process.exit(1); +}); diff --git a/index.ts b/index.ts index 766877812..cbae5a621 100644 --- a/index.ts +++ b/index.ts @@ -4,8 +4,11 @@ export * from './src/authentication/CredentialsExtractor'; export * from './src/authentication/SimpleCredentialsExtractor'; // Authorization +export * from './src/authorization/AclManager'; export * from './src/authorization/Authorizer'; +export * from './src/authorization/SimpleAclAuthorizer'; export * from './src/authorization/SimpleAuthorizer'; +export * from './src/authorization/SimpleExtensionAclManager'; // LDP/HTTP export * from './src/ldp/http/AcceptPreferenceParser'; @@ -30,6 +33,7 @@ export * from './src/ldp/operations/SimpleDeleteOperationHandler'; export * from './src/ldp/operations/SimpleGetOperationHandler'; export * from './src/ldp/operations/SimplePatchOperationHandler'; export * from './src/ldp/operations/SimplePostOperationHandler'; +export * from './src/ldp/operations/SimplePutOperationHandler'; // LDP/Permissions export * from './src/ldp/permissions/PermissionSet'; @@ -67,6 +71,7 @@ export * from './src/storage/patch/SimpleSparqlUpdatePatchHandler'; // Storage export * from './src/storage/AtomicResourceStore'; export * from './src/storage/Conditions'; +export * from './src/storage/ContainerManager'; export * from './src/storage/Lock'; export * from './src/storage/LockingResourceStore'; export * from './src/storage/PassthroughStore'; @@ -77,10 +82,13 @@ export * from './src/storage/ResourceMapper'; export * from './src/storage/ResourceStore'; export * from './src/storage/SingleThreadedResourceLocker'; export * from './src/storage/SimpleResourceStore'; +export * from './src/storage/UrlContainerManager'; // Util/Errors +export * from './src/util/errors/ForbiddenHttpError'; export * from './src/util/errors/HttpError'; export * from './src/util/errors/NotFoundHttpError'; +export * from './src/util/errors/UnauthorizedHttpError'; export * from './src/util/errors/UnsupportedHttpError'; export * from './src/util/errors/UnsupportedMediaTypeHttpError'; diff --git a/test/index.ts b/test/index.ts deleted file mode 100644 index dca32b868..000000000 --- a/test/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('A basic test', (): void => { - it('to have something pass.', async(): Promise => { - expect(true).toBeTruthy(); - }); -}); diff --git a/test/integration/AuthenticatedLdpHandler.test.ts b/test/integration/AuthenticatedLdpHandler.test.ts index b5018b78d..04e527def 100644 --- a/test/integration/AuthenticatedLdpHandler.test.ts +++ b/test/integration/AuthenticatedLdpHandler.test.ts @@ -1,11 +1,10 @@ import { AcceptPreferenceParser } from '../../src/ldp/http/AcceptPreferenceParser'; import { AuthenticatedLdpHandler } from '../../src/ldp/AuthenticatedLdpHandler'; import { BodyParser } from '../../src/ldp/http/BodyParser'; +import { call } from '../util/Util'; import { CompositeAsyncHandler } from '../../src/util/CompositeAsyncHandler'; -import { EventEmitter } from 'events'; -import { HttpHandler } from '../../src/server/HttpHandler'; import { HttpRequest } from '../../src/server/HttpRequest'; -import { IncomingHttpHeaders } from 'http'; +import { MockResponse } from 'node-mocks-http'; import { Operation } from '../../src/ldp/operations/Operation'; import { Parser } from 'n3'; import { PatchingStore } from '../../src/storage/PatchingStore'; @@ -28,35 +27,11 @@ import { SimpleSparqlUpdateBodyParser } from '../../src/ldp/http/SimpleSparqlUpd import { SimpleSparqlUpdatePatchHandler } from '../../src/storage/patch/SimpleSparqlUpdatePatchHandler'; import { SimpleTargetExtractor } from '../../src/ldp/http/SimpleTargetExtractor'; import { SingleThreadedResourceLocker } from '../../src/storage/SingleThreadedResourceLocker'; -import streamifyArray from 'streamify-array'; import { TurtleToQuadConverter } from '../../src/storage/conversion/TurtleToQuadConverter'; -import { createResponse, MockResponse } from 'node-mocks-http'; import { namedNode, quad } from '@rdfjs/data-model'; import * as url from 'url'; -const call = async(handler: HttpHandler, requestUrl: url.URL, method: string, - headers: IncomingHttpHeaders, data: string[]): Promise> => { - const request = streamifyArray(data) as HttpRequest; - request.url = requestUrl.pathname; - request.method = method; - request.headers = headers; - request.headers.host = requestUrl.host; - const response: MockResponse = createResponse({ eventEmitter: EventEmitter }); - - const endPromise = new Promise((resolve): void => { - response.on('end', (): void => { - expect(response._isEndCalled()).toBeTruthy(); - resolve(); - }); - }); - - await handler.handleSafe({ request, response }); - await endPromise; - - return response; -}; - -describe('An AuthenticatedLdpHandler', (): void => { +describe('An integrated AuthenticatedLdpHandler', (): void => { describe('with simple handlers', (): void => { const requestParser = new SimpleRequestParser({ targetExtractor: new SimpleTargetExtractor(), @@ -88,7 +63,7 @@ describe('An AuthenticatedLdpHandler', (): void => { it('can add, read and delete data based on incoming requests.', async(): Promise => { // POST - let requestUrl = new url.URL('http://test.com/'); + let requestUrl = new URL('http://test.com/'); let response: MockResponse = await call( handler, requestUrl, @@ -102,7 +77,7 @@ describe('An AuthenticatedLdpHandler', (): void => { expect(id).toContain(url.format(requestUrl)); // GET - requestUrl = new url.URL(id); + requestUrl = new URL(id); response = await call(handler, requestUrl, 'GET', { accept: 'text/turtle' }, []); expect(response.statusCode).toBe(200); expect(response._getData()).toContain(' .'); @@ -166,7 +141,7 @@ describe('An AuthenticatedLdpHandler', (): void => { it('can handle simple SPARQL updates.', async(): Promise => { // POST - let requestUrl = new url.URL('http://test.com/'); + let requestUrl = new URL('http://test.com/'); let response: MockResponse = await call( handler, requestUrl, @@ -181,7 +156,7 @@ describe('An AuthenticatedLdpHandler', (): void => { expect(id).toContain(url.format(requestUrl)); // PATCH - requestUrl = new url.URL(id); + requestUrl = new URL(id); response = await call( handler, requestUrl, @@ -196,7 +171,7 @@ describe('An AuthenticatedLdpHandler', (): void => { expect(response._getHeaders().location).toBe(id); // GET - requestUrl = new url.URL(id); + requestUrl = new URL(id); response = await call(handler, requestUrl, 'GET', { accept: 'text/turtle' }, []); expect(response.statusCode).toBe(200); expect(response._getData()).toContain(' .'); diff --git a/test/integration/Authorization.test.ts b/test/integration/Authorization.test.ts new file mode 100644 index 000000000..8cfec2e89 --- /dev/null +++ b/test/integration/Authorization.test.ts @@ -0,0 +1,181 @@ +import { AcceptPreferenceParser } from '../../src/ldp/http/AcceptPreferenceParser'; +import { AuthenticatedLdpHandler } from '../../src/ldp/AuthenticatedLdpHandler'; +import { BodyParser } from '../../src/ldp/http/BodyParser'; +import { call } from '../util/Util'; +import { CompositeAsyncHandler } from '../../src/util/CompositeAsyncHandler'; +import { DATA_TYPE_BINARY } from '../../src/util/ContentTypes'; +import { MockResponse } from 'node-mocks-http'; +import { Operation } from '../../src/ldp/operations/Operation'; +import { PermissionSet } from '../../src/ldp/permissions/PermissionSet'; +import { QuadToTurtleConverter } from '../../src/storage/conversion/QuadToTurtleConverter'; +import { RepresentationConvertingStore } from '../../src/storage/RepresentationConvertingStore'; +import { ResourceStore } from '../../src/storage/ResourceStore'; +import { ResponseDescription } from '../../src/ldp/operations/ResponseDescription'; +import { SimpleAclAuthorizer } from '../../src/authorization/SimpleAclAuthorizer'; +import { SimpleBodyParser } from '../../src/ldp/http/SimpleBodyParser'; +import { SimpleCredentialsExtractor } from '../../src/authentication/SimpleCredentialsExtractor'; +import { SimpleDeleteOperationHandler } from '../../src/ldp/operations/SimpleDeleteOperationHandler'; +import { SimpleExtensionAclManager } from '../../src/authorization/SimpleExtensionAclManager'; +import { SimpleGetOperationHandler } from '../../src/ldp/operations/SimpleGetOperationHandler'; +import { SimplePermissionsExtractor } from '../../src/ldp/permissions/SimplePermissionsExtractor'; +import { SimplePostOperationHandler } from '../../src/ldp/operations/SimplePostOperationHandler'; +import { SimplePutOperationHandler } from '../../src/ldp/operations/SimplePutOperationHandler'; +import { SimpleRequestParser } from '../../src/ldp/http/SimpleRequestParser'; +import { SimpleResourceStore } from '../../src/storage/SimpleResourceStore'; +import { SimpleResponseWriter } from '../../src/ldp/http/SimpleResponseWriter'; +import { SimpleTargetExtractor } from '../../src/ldp/http/SimpleTargetExtractor'; +import streamifyArray from 'streamify-array'; +import { TurtleToQuadConverter } from '../../src/storage/conversion/TurtleToQuadConverter'; +import { UrlContainerManager } from '../../src/storage/UrlContainerManager'; + +const setAcl = async(store: ResourceStore, id: string, permissions: PermissionSet, control: boolean, + access: boolean, def: boolean, agent?: string, agentClass?: 'agent' | 'authenticated'): Promise => { + const acl: string[] = [ + '@prefix acl: .\n', + '@prefix foaf: .\n', + ' a acl:Authorization', + ]; + + for (const perm of [ 'Read', 'Append', 'Write', 'Delete' ]) { + if (permissions[perm.toLowerCase() as keyof PermissionSet]) { + acl.push(`;\n acl:mode acl:${perm}`); + } + } + if (control) { + acl.push(';\n acl:mode acl:Control'); + } + if (access) { + acl.push(`;\n acl:accessTo <${id}>`); + } + if (def) { + acl.push(`;\n acl:default <${id}>`); + } + if (agent) { + acl.push(`;\n acl:agent <${agent}>`); + } + if (agentClass) { + acl.push(`;\n acl:agentClass ${agentClass === 'agent' ? 'foaf:Agent' : 'foaf:AuthenticatedAgent'}`); + } + + acl.push('.'); + + const representation = { + data: streamifyArray(acl), + dataType: DATA_TYPE_BINARY, + metadata: { + raw: [], + profiles: [], + contentType: 'text/turtle', + }, + }; + + return store.setRepresentation({ path: `${id}.acl` }, representation); +}; + +describe('A server with authorization', (): void => { + const bodyParser: BodyParser = new SimpleBodyParser(); + const requestParser = new SimpleRequestParser({ + targetExtractor: new SimpleTargetExtractor(), + preferenceParser: new AcceptPreferenceParser(), + bodyParser, + }); + + const store = new SimpleResourceStore('http://test.com/'); + const converter = new CompositeAsyncHandler([ + new QuadToTurtleConverter(), + new TurtleToQuadConverter(), + ]); + const convertingStore = new RepresentationConvertingStore(store, converter); + + const credentialsExtractor = new SimpleCredentialsExtractor(); + const permissionsExtractor = new SimplePermissionsExtractor(); + const authorizer = new SimpleAclAuthorizer( + new SimpleExtensionAclManager(), + new UrlContainerManager('http://test.com/'), + convertingStore, + ); + + const operationHandler = new CompositeAsyncHandler([ + new SimpleGetOperationHandler(convertingStore), + new SimplePostOperationHandler(convertingStore), + new SimpleDeleteOperationHandler(convertingStore), + new SimplePutOperationHandler(convertingStore), + ]); + + const responseWriter = new SimpleResponseWriter(); + + const handler = new AuthenticatedLdpHandler({ + requestParser, + credentialsExtractor, + permissionsExtractor, + authorizer, + operationHandler, + responseWriter, + }); + + it('can create new entries.', async(): Promise => { + await setAcl(convertingStore, + 'http://test.com/', + { read: true, write: true, append: true }, + true, + true, + true, + undefined, + 'agent'); + + // POST + let requestUrl = new URL('http://test.com/'); + let response: MockResponse = await call( + handler, + requestUrl, + 'POST', + { 'content-type': 'text/turtle' }, + [ ' .' ], + ); + expect(response.statusCode).toBe(200); + + // PUT + requestUrl = new URL('http://test.com/foo/bar'); + response = await call( + handler, + requestUrl, + 'PUT', + { 'content-type': 'text/turtle' }, + [ ' .' ], + ); + expect(response.statusCode).toBe(200); + }); + + it('can not create new entries if not allowed.', async(): Promise => { + await setAcl(convertingStore, + 'http://test.com/', + { read: true, write: true, append: true }, + true, + true, + true, + undefined, + 'authenticated'); + + // POST + let requestUrl = new URL('http://test.com/'); + let response: MockResponse = await call( + handler, + requestUrl, + 'POST', + { 'content-type': 'text/turtle' }, + [ ' .' ], + ); + expect(response.statusCode).toBe(401); + + // PUT + requestUrl = new URL('http://test.com/foo/bar'); + response = await call( + handler, + requestUrl, + 'PUT', + { 'content-type': 'text/turtle' }, + [ ' .' ], + ); + expect(response.statusCode).toBe(401); + }); +}); diff --git a/test/unit/server/ExpressHttpServer.test.ts b/test/unit/server/ExpressHttpServer.test.ts index 3dbfda19b..d84f7eccc 100644 --- a/test/unit/server/ExpressHttpServer.test.ts +++ b/test/unit/server/ExpressHttpServer.test.ts @@ -81,6 +81,10 @@ describe('ExpressHttpServer', (): void => { handler.handle = async(): Promise => { throw new Error('dummyError'); }; + + // Prevent test from writing to stderr + jest.spyOn(process.stderr, 'write').mockImplementation((): boolean => true); + const res = await request(server).get('/').expect(500); expect(res.text).toContain('dummyError'); }); diff --git a/test/util/Util.ts b/test/util/Util.ts new file mode 100644 index 000000000..a3ad93d1f --- /dev/null +++ b/test/util/Util.ts @@ -0,0 +1,28 @@ +import { EventEmitter } from 'events'; +import { HttpHandler } from '../../src/server/HttpHandler'; +import { HttpRequest } from '../../src/server/HttpRequest'; +import { IncomingHttpHeaders } from 'http'; +import streamifyArray from 'streamify-array'; +import { createResponse, MockResponse } from 'node-mocks-http'; + +export const call = async(handler: HttpHandler, requestUrl: URL, method: string, + headers: IncomingHttpHeaders, data: string[]): Promise> => { + const request = streamifyArray(data) as HttpRequest; + request.url = requestUrl.pathname; + request.method = method; + request.headers = headers; + request.headers.host = requestUrl.host; + const response: MockResponse = createResponse({ eventEmitter: EventEmitter }); + + const endPromise = new Promise((resolve): void => { + response.on('end', (): void => { + expect(response._isEndCalled()).toBeTruthy(); + resolve(); + }); + }); + + await handler.handleSafe({ request, response }); + await endPromise; + + return response; +};