From 94b5976bd2f1123d4c5bd620e9c3b91c90f3c8f3 Mon Sep 17 00:00:00 2001 From: Dimitrie Stefanescu Date: Sun, 5 Apr 2020 20:48:45 +0100 Subject: [PATCH] feat(streams api): scaffolded routes and tests for basic stream routes --- app.js | 16 ++--- modules/core/migrations/001-streams.js | 8 +-- modules/core/streams/controllers.js | 55 +++++++++++++--- modules/core/streams/index.js | 38 +++++++++--- modules/core/streams/services.js | 14 ++--- modules/core/tests/001-streams.spec.js | 77 ++++++++++++++++++----- modules/shared/index.js | 86 ++++++++++++++++++++------ package.json | 4 +- 8 files changed, 228 insertions(+), 70 deletions(-) diff --git a/app.js b/app.js index 5005faea9..8158a8c0a 100644 --- a/app.js +++ b/app.js @@ -3,6 +3,7 @@ const express = require( 'express' ) const logger = require( 'morgan-debug' ) const bodyParser = require( 'body-parser' ) +const debug = require( 'debug' )( 'speckle:errors' ) exports.init = ( ) => { const app = express( ) @@ -15,13 +16,14 @@ exports.init = ( ) => { app.use( bodyParser.urlencoded( { extended: false } ) ) // Error responses - // app.use( ( err, req, res, next ) => { - // res.status( err.status || 500 ) - // res.json( { - // message: err.message, - // error: err - // } ) - // } ) + app.use( ( err, req, res, next ) => { + debug( err ) + res.status( err.status || 500 ) + res.json( { + message: err.message, + error: err + } ) + } ) app.get( '/', ( req, res ) => { res.send( { fantastic: 'speckle' } ) diff --git a/modules/core/migrations/001-streams.js b/modules/core/migrations/001-streams.js index 2ecb89b4b..6fb656a69 100644 --- a/modules/core/migrations/001-streams.js +++ b/modules/core/migrations/001-streams.js @@ -10,7 +10,7 @@ exports.up = async knex => { table.uuid( 'id' ).defaultTo( knex.raw( 'gen_random_uuid()' ) ).unique( ).primary( ) table.text( 'name' ) table.text( 'description' ) - table.boolean( 'public' ).defaultTo( true ) + table.boolean( 'isPublic' ).defaultTo( true ) table.uuid( 'cloned_from' ).references( 'id' ).inTable( 'streams' ) table.timestamp( 'created_at' ).defaultTo( knex.fn.now( ) ) table.timestamp( 'updated_at' ).defaultTo( knex.fn.now( ) ) @@ -29,9 +29,9 @@ exports.up = async knex => { await knex.schema.createTable( 'stream_acl', table => { table.uuid( 'user_id' ).references( 'id' ).inTable( 'users' ).notNullable( ) - table.uuid( 'stream_id' ).references( 'id' ).inTable( 'streams' ).notNullable( ) - table.primary( [ 'user_id', 'stream_id' ] ) - table.unique( [ 'user_id', 'stream_id' ] ) + table.uuid( 'resource_id' ).references( 'id' ).inTable( 'streams' ).notNullable( ) + table.primary( [ 'user_id', 'resource_id' ] ) + table.unique( [ 'user_id', 'resource_id' ] ) table.specificType( 'role', 'speckle_acl_role_type' ).defaultTo( 'write' ) } ) diff --git a/modules/core/streams/controllers.js b/modules/core/streams/controllers.js index a337ac7ad..6d96ff949 100644 --- a/modules/core/streams/controllers.js +++ b/modules/core/streams/controllers.js @@ -1,6 +1,6 @@ 'use strict' - -const { getStream, createStream, updateStream } = require( './services' ) +const debug = require( 'debug' )( 'speckle:test' ) +const { getStream, createStream, updateStream, grantPermissionsStream, revokePermissionsStream } = require( './services' ) module.exports = { @@ -10,24 +10,63 @@ module.exports = { }, getStream: async ( req, res, next ) => { - res.status( 418 ).send( "meeps" ) - next( ) + try { + let stream = await getStream( req.params.resourceId ) + res.status( 200 ).send( stream ) + next( ) + } catch ( err ) { + next( err ) + } }, createStream: async ( req, res, next ) => { try { - let id = await createStream( req.body, req.user.userId ) + let id = await createStream( req.body, req.user.id ) res.status( 201 ).send( { success: true, id: id } ) + req.eventData = { id: id, userId: req.user.userId } next( ) } catch ( err ) { - console.log( err ) next( err ) } }, updateStream: async ( req, res, next ) => { - res.send( { todo: true } ) - next( ) + try { + let id = await updateStream( req.params.resourceId, req.body ) + res.status( 200 ).send( { success: true, id: id } ) + + req.eventData = { id: id, userId: req.user.userId } + next( ) + } catch ( err ) { + next( err ) + } + }, + + grantPermissions: async ( req, res, next ) => { + try { + await grantPermissionsStream( req.params.resourceId, req.body.id, req.body.role ) + + next( ) + } catch ( err ) { + next( err ) + } + + }, + + revokePermissions: async ( req, res, next ) => { + try { + + } catch ( err ) { + next( err ) + } + }, + + getStreamUsers: async ( req, res, next ) => { + try { + + } catch ( err ) { + next( err ) + } } } \ No newline at end of file diff --git a/modules/core/streams/index.js b/modules/core/streams/index.js index cc4060162..f13bd7b7d 100644 --- a/modules/core/streams/index.js +++ b/modules/core/streams/index.js @@ -1,6 +1,6 @@ 'use strict' const root = require( 'app-root-path' ) -const { getStreams, getStream, createStream, updateStream } = require( './controllers' ) +const { getStreams, getStream, createStream, updateStream, grantPermissions, revokePermissions, getStreamUsers } = require( './controllers' ) const { authenticate, authorize, announce } = require( `${root}/modules/shared` ) const streams = require( 'express' ).Router( { mergeParams: true } ) @@ -13,21 +13,43 @@ streams.get( getStreams ) streams.get( - '/streams/:streamId', - authenticate( 'streams:read' ), - authorize, + '/streams/:resourceId', + authenticate( 'streams:read', false ), + authorize( 'stream_acl', 'streams', 'read' ), getStream ) streams.post( '/streams', authenticate( 'streams:write' ), - authorize, createStream, announce( 'stream-created', 'user' ) ) streams.put( - '/streams/:streamId', + '/streams/:resourceId', authenticate( 'streams:write' ), - authorize, + authorize( 'stream_acl', 'streams', 'write' ), updateStream, - announce( 'stream-updated', 'stream' ) ) \ No newline at end of file + announce( 'stream-updated', 'stream' ) ) + +streams.post( + '/streams/:resourceId/users', + authenticate( 'streams:write' ), + authorize( 'stream_acl', 'streams', 'owner' ), + grantPermissions, + announce( 'stream-created', 'user' ) +) + +streams.get( + '/streams/:resourceId/users', + authenticate( 'streams:read' ), + authorize( 'stream_acl', 'streams', 'read' ), + getStreamUsers +) + +streams.delete( + '/streams/:resourceId/users', + authenticate( 'streams:write' ), + authorize( 'stream_acl', 'streams', 'owner' ), + revokePermissions, + announce( 'stream-deleted', 'user' ) +) \ No newline at end of file diff --git a/modules/core/streams/services.js b/modules/core/streams/services.js index b14813f98..ea05a9fc4 100644 --- a/modules/core/streams/services.js +++ b/modules/core/streams/services.js @@ -14,7 +14,7 @@ module.exports = { stream.updated_at = knex.fn.now( ) let [ res ] = await Streams( ).returning( 'id' ).insert( stream ) - await Acl( ).insert( { user_id: ownerId, stream_id: res, role: 'owner' } ) + await Acl( ).insert( { user_id: ownerId, resource_id: res, role: 'owner' } ) return res }, @@ -32,19 +32,19 @@ module.exports = { grantPermissionsStream: async ( streamId, userId, role ) => { if ( role === 'owner' ) { - let [ ownerAcl ] = await Acl( ).where( { stream_id: streamId, role: 'owner' } ).returning( '*' ).del( ) - await Acl( ).insert( { stream_id: streamId, user_id: ownerAcl.user_id, role: 'write' } ) + let [ ownerAcl ] = await Acl( ).where( { resource_id: streamId, role: 'owner' } ).returning( '*' ).del( ) + await Acl( ).insert( { resource_id: streamId, user_id: ownerAcl.user_id, role: 'write' } ) } // upsert - let query = Acl( ).insert( { user_id: userId, stream_id: streamId, role: role } ).toString( ) + ` on conflict on constraint stream_acl_pkey do update set role=excluded.role` + let query = Acl( ).insert( { user_id: userId, resource_id: streamId, role: role } ).toString( ) + ` on conflict on constraint stream_acl_pkey do update set role=excluded.role` await knex.raw( query ) }, revokePermissionsStream: async ( streamId, userId ) => { - let streamAclEntries = Acl( ).where( { stream_id: streamId } ).select( '*' ) - let delCount = await Acl( ).where( { stream_id: streamId, user_id: userId } ).whereNot( { role: 'owner' } ).del( ) + let streamAclEntries = Acl( ).where( { resource_id: streamId } ).select( '*' ) + let delCount = await Acl( ).where( { resource_id: streamId, user_id: userId } ).whereNot( { role: 'owner' } ).del( ) if ( delCount === 0 ) throw new Error( 'Could not revoke permissions for user. Is he an owner?' ) }, @@ -67,7 +67,7 @@ module.exports = { limit = limit || 100 return Acl( ).where( { user_id: userId } ) - .rightJoin( 'streams', { 'streams.id': 'stream_acl.stream_id' } ) + .rightJoin( 'streams', { 'streams.id': 'stream_acl.resource_id' } ) .limit( limit ).offset( offset ) }, } \ No newline at end of file diff --git a/modules/core/tests/001-streams.spec.js b/modules/core/tests/001-streams.spec.js index 2650cb2ca..94d34005d 100644 --- a/modules/core/tests/001-streams.spec.js +++ b/modules/core/tests/001-streams.spec.js @@ -112,7 +112,7 @@ describe( 'Streams', ( ) => { } } ) - it( 'A stream should not have more than one owner', async ( ) => { + it( '🤔 DUBIOUS: A stream should not have more than one owner', async ( ) => { let newStream = { name: 'XXX' } newStream.id = await createStream( newStream, userOne.id ) await grantPermissionsStream( newStream.id, userTwo.id, 'owner' ) @@ -134,44 +134,89 @@ describe( 'Streams', ( ) => { // The express app let app - let token - + + let userA = { username: 'A', name: 'DimitrieA ', email: 'didimitrie+a@gmail.com', password: 'sn3aky-1337-b1m' } + let userB = { username: 'B', name: 'DimitrieB ', email: 'didimitrie+b@gmail.com', password: 'sn3aky-1337-b1m' } + + let tokenA + let tokenB + before( async ( ) => { app = init( ) - token = await createToken( userOne.id, 'Generic Token', [ 'streams:read', 'streams:write' ] ) + userA.id = await createUser( userA ) + userB.id = await createUser( userB ) + + tokenA = await createToken( userA.id, 'Generic Token', [ 'streams:read', 'streams:write' ] ) + tokenB = await createToken( userB.id, 'Generic Token', [ 'streams:read', 'streams:write' ] ) } ) - let myTestStream = { name: 'woowowo', id: 'noids', description: 'wonderful test stream' } + let privateStream = { name: 'woowowo', id: 'noids', description: 'wonderful test stream', isPublic: false } + let publicStream = { name: 'i am public', isPublic: true } it( 'Should create a stream', async ( ) => { - const res = await chai.request( app ).post( '/streams' ).set( 'Authorization', `Bearer ${token}` ).send( myTestStream ) + const res = await chai.request( app ).post( '/streams' ).set( 'Authorization', `Bearer ${tokenA}` ).send( privateStream ) expect( res ).to.have.status( 201 ) expect( res.body ).to.have.property( 'id' ) - myTestStream.id = res.body.id + privateStream.id = res.body.id + + const second = await chai.request( app ).post( '/streams' ).set( 'Authorization', `Bearer ${tokenA}` ).send( publicStream ) + expect( second ).to.have.status( 201 ) + expect( second.body ).to.have.property( 'id' ) + publicStream.id = second.body.id + } ) it( 'Should get a stream', async ( ) => { - assert.fail( 'Not implemented yet.' ) - - const res = await chai.request( app ).get( `/streams/${myTestStream.id}` ).set( 'Authorization', `Bearer ${token}` ) + const res = await chai.request( app ).get( `/streams/${privateStream.id}` ).set( 'Authorization', `Bearer ${tokenA}` ) expect( res ).to.have.status( 200 ) expect( res.body ).to.have.property( 'id' ) expect( res.body ).to.have.property( 'name' ) } ) - it( 'Should update a stream', async ( ) => { - assert.fail( 'Not implemented yet.' ) + it( 'Should get a public stream, even if user is anonymous', async ( ) => { + const res = await chai.request( app ).get( `/streams/${publicStream.id}` ) + expect( res ).to.have.status( 200 ) + expect( res.body ).to.have.property( 'id' ) + expect( res.body ).to.have.property( 'name' ) + } ) - const res = await chai.request( app ).put( `/streams/${myTestStream.id}` ).send( { name: 'new name' } ) - const resUpdated = await chai.request( app ).get( `/streams/${myTestStream.id}` ) + it( 'Should not get a private stream if user is not authenticated', async ( ) => { + const res = await chai.request( app ).get( `/streams/${privateStream.id}` ) + expect( res ).to.have.status( 401 ) + } ) + + it( 'Should not get a private stream if the user does not have access to it', async ( ) => { + const res = await chai.request( app ).get( `/streams/${privateStream.id}` ).set( 'Authorization', `Bearer ${tokenB}` ) + expect( res ).to.have.status( 401 ) + } ) + + it( 'Should update a stream', async ( ) => { + const res = await chai.request( app ).put( `/streams/${publicStream.id}` ).send( { name: 'new name' } ).set( 'Authorization', `Bearer ${tokenA}` ) + const resUpdated = await chai.request( app ).get( `/streams/${publicStream.id}` ) expect( res ).to.have.status( 200 ) expect( res.body ).to.have.property( 'id' ) expect( resUpdated ).to.have.status( 200 ) - expect( resUpdated ).to.have.property( 'name' ) - expect( resUpdated.name ).to.equal( 'new name' ) + expect( resUpdated.body ).to.have.property( 'name' ) + expect( resUpdated.body.name ).to.equal( 'new name' ) + } ) + + it( 'Should grant permissions on a stream', async ( ) => { + const shareRes = await chai.request( app ).post( `/streams/users/${privateStream.id}` ).send( { id: userB.id, role: 'read' } ).set( 'Authorization', `Bearer ${tokenA}` ) + console.log(shareRes) + expect( shareRes ).to.have.status( 200 ) + const userBRes = await chai.request( app ).get( `/streams/${privateStream.id}` ).set( 'Authorization', `Bearer ${tokenB}` ) + console.log(userBRes.status) + expect( userBRes ).to.have.status( 200 ) + expect( userBRes.body ).to.have.property( 'name' ) + expect( userBRes.body ).to.have.property( 'description' ) + } ) + + it( 'Should revoke permissions on a stream', async ( ) => { + assert.fail( ) + } ) it( 'Should delete a stream', async ( ) => { diff --git a/modules/shared/index.js b/modules/shared/index.js index d3eab7b73..2642f43b7 100644 --- a/modules/shared/index.js +++ b/modules/shared/index.js @@ -1,46 +1,96 @@ 'use strict' -const debug = require( 'debug' )( 'speckle:test' ) +const debug = require( 'debug' )( 'speckle:middleware' ) const root = require( 'app-root-path' ) +const knex = require( `${root}/db/knex` ) const { validateToken } = require( `${root}/modules/core/users/services` ) +/* + + Authentication: checks bearer token validity and scope validation + + */ + + +// TODO: Cache results function authenticate( scope, mandatory ) { - mandatory = mandatory || true + mandatory = mandatory !== false return async ( req, res, next ) => { debug( `🔑 authenticate middleware called` ) - + if ( !req.headers.authorization && mandatory ) { return res.status( 403 ).send( { error: 'No credentials provided' } ) // next (err)? } - let token = req.headers.authorization.split( ' ' )[ 1 ] - let { valid, scopes, userId } = await validateToken( token ) + if ( req.headers.authorization ) { + let token = req.headers.authorization.split( ' ' )[ 1 ] + let { valid, scopes, userId } = await validateToken( token ) - if ( !valid && mandatory ) { - return res.status( 403 ).send( { error: 'Invalid authorization' } ) + if ( !valid && mandatory ) { + return res.status( 403 ).send( { error: 'Invalid authorization' } ) + } + + if ( scopes.indexOf( scope ) === -1 && scopes.indexOf( '*' ) === -1 ) { + return res.status( 403 ).send( { error: 'Invalid scope' } ) + } + req.user = { id: userId, scopes: scopes } + return next( ) } - if ( scopes.indexOf( scope ) === -1 && scopes.indexOf( '*' ) === -1 ) { - return res.status( 403 ).send( { error: 'Invalid scope' } ) - } - - req.user = { userId: userId, scopes: scopes } - next( ) + return next( ) } } -let authorize = ( req, res, next ) => { - // TODO - // Authorizes the api call against permissions - debug( '🔑 authorization middleware called; yes by default LOL' ) - next( ) +/* + + Authorization: checks user id against access control lists + + */ + +let roles = { admin: 400, owner: 300, write: 200, read: 100 } + +// TODO: Cache results +function authorize( aclTable, resourceTable, requiredRole ) { + let ACL = ( ) => knex( aclTable ) + let Resource = ( ) => knex( resourceTable ) + + return async ( req, res, next ) => { + debug( '🔑 authorization middleware called' ) + + let { isPublic } = await Resource( ).where( { id: req.params.resourceId } ).select( 'isPublic' ).first( ) + + if ( isPublic ) return next( ) + + if ( !req.user ) return res.status( 401 ).send( { error: 'Unauthorized' } ) + + let [ entry ] = await ACL( ).where( { resource_id: req.params.resourceId, user_id: req.user.id } ).select( '*' ) + + if ( !entry ) { + return res.status( 401 ).send( { error: 'Unauthorized' } ) + } + + if ( roles[ entry.role ] >= roles[ requiredRole ] ) { + req.user.role = entry.role + return next( ) + } else { + return res.status( 401 ).send( { error: 'Unauthorized' } ) + } + } } +/* + + Announcements: orchestrates pushing out events to any subscribers. + (TODO: implement!) + + */ + function announce( eventName, eventScope ) { return async ( req, res, next ) => { debug( `📣 announce middleware called: ${eventName}:${eventScope}` ) debug( `Event data: ${JSON.stringify( req.eventData )}` ) + next( ) } } diff --git a/package.json b/package.json index 7d209abfb..2772eded4 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "index.js", "scripts": { "dev": "NODE_ENV=development DEBUG=www:server,speckle:* nodemon ./bin/www --watch . --watch ./bin/www", - "test": "DEBUG=speckle:test NODE_ENV=test mocha -s 0 --exit", - "test-watch": "DEBUG=speckle:test NODE_ENV=test mocha --watch -s 0 --exit" + "test": "DEBUG=speckle:test,speckle:errors NODE_ENV=test mocha -s 0 --exit", + "test-watch": "DEBUG=speckle:test,speckle:errors NODE_ENV=test mocha --watch -s 0 --exit" }, "author": "", "license": "MIT",