feat(roles): storing & enforcing roles

This commit is contained in:
Dimitrie Stefanescu
2020-05-20 18:30:04 +01:00
parent eb240fdd43
commit bd5d7436f6
13 changed files with 95 additions and 91 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ let http = require( 'http' )
const express = require( 'express' )
const logger = require( 'morgan-debug' )
const bodyParser = require( 'body-parser' )
const debug = require( 'debug' )( 'speckle:errors' )
const debug = require( 'debug' )( 'speckle:generic' )
const { ApolloServer } = require( 'apollo-server-express' )
const { contextApiTokenHelper } = require( './modules/shared' )
@@ -60,7 +60,7 @@ exports.startHttp = async ( app ) => {
let server = http.createServer( app )
server.on( 'listening', ( ) => {
console.log( `Listening on ${server.address().port}` )
debug( `Listening on ${server.address().port}` )
} )
server.listen( port )
+3 -3
View File
@@ -2,7 +2,7 @@
const root = require( 'app-root-path' )
const { AuthorizationError, ApolloError } = require( 'apollo-server-express' )
const { createToken, revokeToken, revokeTokenById, validateToken, getUserTokens } = require( '../../services/users' )
const { createToken, revokeToken, revokeTokenById, validateToken, getUserTokens } = require( '../../services/tokens' )
const { validateScopes, authorizeResolver } = require( `${root}/modules/shared` )
module.exports = {
@@ -13,11 +13,11 @@ module.exports = {
},
Mutation: {
async apiTokenCreate( parent, args, context, info ) {
await validateScopes( context.scopes, 'tokens:create' )
await validateScopes( context.scopes, 'tokens:write' )
return await createToken( context.userId, args.name, args.scopes, args.lifespan )
},
async apiTokenRevoke( parent, args, context, info ) {
await validateScopes( context.scopes, 'tokens:delete' )
await validateScopes( context.scopes, 'tokens:write' )
await revokeToken( args.token.split( ' ' )[ 1 ], context.userId ) // let's not revoke other people's tokens
return true
}
+8 -8
View File
@@ -69,56 +69,56 @@ module.exports = {
Mutation: {
async objectCreate( parent, args, context, info ) {
await validateScopes( context.scopes, 'streams:write' )
await authorizeResolver( context.userId, args.streamId, 'stream_acl', 'streams', 'write' )
await authorizeResolver( context.userId, args.streamId, 'stream:contributor' )
let ids = await createObjects( args.objects )
return ids
},
async commitCreate( parent, args, context, info ) {
await validateScopes( context.scopes, 'streams:write' )
await authorizeResolver( context.userId, args.streamId, 'stream_acl', 'streams', 'write' )
await authorizeResolver( context.userId, args.streamId, 'stream:contributor' )
let id = await createCommit( args.streamId, context.userId, args.commit )
return id
},
async branchCreate( parent, args, context, info ) {
await validateScopes( context.scopes, 'streams:write' )
await authorizeResolver( context.userId, args.streamId, 'stream_acl', 'streams', 'write' )
await authorizeResolver( context.userId, args.streamId, 'stream:contributor' )
let id = await createBranch( args.branch, args.streamId, context.userId )
return id
},
async branchUpdate( parent, args, context, info ) {
await validateScopes( context.scopes, 'streams:write' )
await authorizeResolver( context.userId, args.streamId, 'stream_acl', 'streams', 'write' )
await authorizeResolver( context.userId, args.streamId, 'stream:contributor' )
await updateBranch( args.branch )
return true
},
async branchDelete( parent, args, context, info ) {
await validateScopes( context.scopes, 'streams:write' )
await authorizeResolver( context.userId, args.streamId, 'stream_acl', 'streams', 'write' )
await authorizeResolver( context.userId, args.streamId, 'stream:contributor' )
await deleteBranchById( args.branchId )
return true
},
async tagCreate( parent, args, context, info ) {
await validateScopes( context.scopes, 'streams:write' )
await authorizeResolver( context.userId, args.streamId, 'stream_acl', 'streams', 'write' )
await authorizeResolver( context.userId, args.streamId, 'stream:contributor' )
let id = await createTag( args.tag, args.streamId, context.userId )
return id
},
async tagUpdate( parent, args, context, info ) {
await validateScopes( context.scopes, 'streams:write' )
await authorizeResolver( context.userId, args.streamId, 'stream_acl', 'streams', 'write' )
await authorizeResolver( context.userId, args.streamId, 'stream:contributor' )
await updateTag( args.tag )
return true
},
async tagDelete( parent, args, context, info ) {
await validateScopes( context.scopes, 'streams:write' )
await authorizeResolver( context.userId, args.streamId, 'stream_acl', 'streams', 'write' )
await authorizeResolver( context.userId, args.streamId, 'stream:contributor' )
await deleteTagById( args.tagId )
return true
+5 -5
View File
@@ -8,7 +8,7 @@ module.exports = {
Query: {
async stream( parent, args, context, info ) {
await validateScopes( context.scopes, 'streams:read' )
await authorizeResolver( context.userId, args.id, 'stream_acl', 'streams', 'read' )
await authorizeResolver( context.userId, args.id, 'stream:reviewer' )
let stream = await getStream( args.id, context.userId )
return stream
@@ -38,13 +38,13 @@ module.exports = {
},
async streamUpdate( parent, args, context, info ) {
await validateScopes( context.scopes, 'streams:write' )
await authorizeResolver( context.userId, args.stream.id, 'stream_acl', 'streams', 'owner' )
await authorizeResolver( context.userId, args.stream.id, 'stream:owner' )
await updateStream( args.stream )
return true
},
async streamDelete( parent, args, context, info ) {
await validateScopes( context.scopes, 'streams:write' )
await authorizeResolver( context.userId, args.id, 'stream_acl', 'streams', 'owner' )
await authorizeResolver( context.userId, args.id, 'stream:owner' )
await deleteStream( args.id )
return true
@@ -54,13 +54,13 @@ module.exports = {
},
async streamGrantPermission( parent, args, context, info ) {
await validateScopes( context.scopes, 'streams:write' )
await authorizeResolver( context.userId, args.streamId, 'stream_acl', 'streams', 'owner' )
await authorizeResolver( context.userId, args.streamId, 'stream:owner' )
if ( context.userId === args.userId ) throw new AuthorizationError( 'You cannot set roles for yourself.' )
return await grantPermissionsStream( args.streamId, args.userId, args.role.toLowerCase( ) || 'read' )
},
async streamRevokePermission( parent, args, context, info ) {
await validateScopes( context.scopes, 'streams:write' )
await authorizeResolver( context.userId, args.streamId, 'stream_acl', 'streams', 'owner' )
await authorizeResolver( context.userId, args.streamId, 'stream:owner' )
return await revokePermissionsStream( args.streamId, args.userId )
}
+13 -22
View File
@@ -26,7 +26,6 @@ exports.up = async knex => {
table.string( 'owner', 10 ).references( 'id' ).inTable( 'users' ).notNullable( )
table.string( 'name' )
table.string( 'lastChars', 6 )
// table.specificType( 'scopes', 'text[]' )
table.boolean( 'revoked' ).defaultTo( false )
table.bigint( 'lifespan' ).defaultTo( 3.154e+12 ) // defaults to a lifespan of 100 years
table.timestamp( 'createdAt' ).defaultTo( knex.fn.now( ) )
@@ -46,13 +45,6 @@ exports.up = async knex => {
table.index( [ 'tokenId', 'scopeName' ], 'token_scope_combined_idx' )
} )
await knex.schema.createTable( 'user_roles', table => {
table.string( 'name' ).notNullable( )
table.text( 'description' ).notNullable( )
table.string( 'resourceTarget' ).notNullable( )
table.string( 'aclTableName' ).notNullable( )
table.integer( 'weight' ).defaultTo( 100 ).notNullable( )
} )
// Streams Table
await knex.schema.createTable( 'streams', table => {
@@ -65,23 +57,22 @@ exports.up = async knex => {
table.timestamp( 'updatedAt' ).defaultTo( knex.fn.now( ) )
} )
// creates an enum type for stream acl roles.
await knex.raw( `
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'speckle_acl_role_type') THEN
CREATE TYPE speckle_acl_role_type AS ENUM( 'owner', 'admin', 'write', 'read' );
END IF;
END$$;
` )
// Roles
await knex.schema.createTable( 'user_roles', table => {
table.string( 'name' ).primary()
table.text( 'description' ).notNullable( )
table.string( 'resourceTarget' ).notNullable( )
table.string( 'aclTableName' ).notNullable( )
table.integer( 'weight' ).defaultTo( 100 ).notNullable( )
} )
// Stream-users access control list.
await knex.schema.createTable( 'stream_acl', table => {
table.string( 'userId', 10 ).references( 'id' ).inTable( 'users' ).notNullable( ).onDelete( 'cascade' )
table.string( 'resourceId', 10 ).references( 'id' ).inTable( 'streams' ).notNullable( ).onDelete( 'cascade' )
table.string( 'role' ).references( 'name' ).inTable( 'user_roles' ).notNullable( ).onDelete( 'cascade' )
table.primary( [ 'userId', 'resourceId' ] )
table.unique( [ 'userId', 'resourceId' ] )
table.specificType( 'role', 'speckle_acl_role_type' ).defaultTo( 'write' )
} )
// Objects Table.
@@ -159,21 +150,21 @@ exports.up = async knex => {
exports.down = async knex => {
await knex.schema.dropTableIfExists( 'stream_acl' )
await knex.schema.dropTableIfExists( 'user_roles' )
await knex.schema.dropTableIfExists( 'stream_commits' )
await knex.schema.dropTableIfExists( 'branch_commits' )
await knex.schema.dropTableIfExists( 'user_commits' )
await knex.schema.dropTableIfExists( 'references' )
await knex.schema.dropTableIfExists( 'object_children_closure' )
await knex.schema.dropTableIfExists( 'objects' )
await knex.schema.dropTableIfExists( 'streams' )
await knex.schema.dropTableIfExists( 'token_scopes' )
await knex.schema.dropTableIfExists( 'app_scopes' )
await knex.schema.dropTableIfExists( 'api_tokens' )
await knex.schema.dropTableIfExists( 'users' )
await knex.raw( `DROP TYPE IF EXISTS speckle_reference_type ` )
await knex.raw( `DROP TYPE IF EXISTS speckle_acl_role_type ` )
}
@@ -5,19 +5,19 @@ exports.up = async knex => {
debug( 'Setting up core module scopes.' )
let streamRoles = [ {
name: 'owner',
name: 'stream:owner',
description: 'Has full access, including deletion rights & access control.',
resourceTarget: 'streams',
aclTableName: 'stream_acl',
weight: 1000
}, {
name: 'contributor',
name: 'stream:contributor',
description: 'Can edit, push and pull.',
resourceTarget: 'streams',
aclTableName: 'stream_acl',
weight: 200
weight: 500
}, {
name: 'reviewer',
name: 'stream:reviewer',
description: 'Can only view.',
resourceTarget: 'streams',
aclTableName: 'stream_acl',
+21 -14
View File
@@ -14,7 +14,7 @@ module.exports = {
stream.id = crs( { length: 10 } )
let [ res ] = await Streams( ).returning( 'id' ).insert( stream )
await Acl( ).insert( { userId: ownerId, resourceId: res, role: 'owner' } )
await Acl( ).insert( { userId: ownerId, resourceId: res, role: 'stream:owner' } )
return res
},
@@ -37,13 +37,6 @@ module.exports = {
},
async grantPermissionsStream( streamId, userId, role ) {
// NOTE: allow streams to have more than one owner.
// That's why the code below is commented out.
// if ( role === 'owner' ) {
// let [ ownerAcl ] = await Acl( ).where( { resourceId: streamId, role: 'owner' } ).returning( '*' ).del( )
// await Acl( ).insert( { resourceId: streamId, userId: ownerAcl.userId, role: 'write' } )
// }
// upsert
let query = Acl( ).insert( { userId: userId, resourceId: streamId, role: role } ).toString( ) + ` on conflict on constraint stream_acl_pkey do update set role=excluded.role`
@@ -54,17 +47,31 @@ module.exports = {
async revokePermissionsStream( streamId, userId ) {
let streamAclEntriesCount = Acl( ).count( { resourceId: streamId } )
// TODO: check if streamAclEntriesCount === 1 then throw big boo-boo (can't delete last ownership link)
if( streamAclEntriesCount === 1 )
if ( streamAclEntriesCount === 1 )
throw new Error( 'Stream has only one ownership link left - cannot revoke permissions.' )
// TODO: below behaviour not correct. Flow:
// Count owners
// If owner count > 1, then proceed to delete, otherwise throw an error (can't delete last owner - delete stream)
let delCount = await Acl( ).where( { resourceId: streamId, userId: userId } ).whereNot( { role: 'owner' } ).del( )
let aclEntry = await Acl( ).where( { resourceId: streamId, userId: userId } ).select( '*' ).first( )
if ( aclEntry.role === 'stream:owner' ) {
let ownersCount = Acl( ).count( { resourceId: streamId, role: 'stream:owner' } )
if ( ownersCount === 1 )
throw new Error( 'Could not revoke permissions for user' )
else {
await Acl( ).where( { resourceId: streamId, userId: userId } ).del()
return true
}
}
let delCount = await Acl( ).where( { resourceId: streamId, userId: userId } ).del( )
if ( delCount === 0 )
throw new Error( 'Could not revoke permissions for user. Is he an owner?' )
throw new Error( 'Could not revoke permissions for user' )
return true
},
@@ -84,9 +91,9 @@ module.exports = {
offset = offset || 0
limit = limit || 100
publicOnly = publicOnly !== false //defaults to true if not provided
let query = { userId: userId }
if ( publicOnly ) query.isPublic = true
return Acl( ).where( query )
+2 -2
View File
@@ -57,7 +57,8 @@ module.exports = {
if ( valid ) {
await Keys( ).where( { id: tokenId } ).update( { lastUsed: knex.fn.now( ) } )
return { valid: true, userId: token.owner, scopes: token.scopes }
let scopes = await TokenScopes( ).select( 'scopeName' ).where( { tokenId: tokenId } )
return { valid: true, userId: token.owner, scopes: scopes.map( s => s.scopeName ) }
} else
return { valid: false }
},
@@ -85,6 +86,5 @@ module.exports = {
WHERE t."owner" = ?
`, [ userId ] )
return rows
// return Keys( ).where( { owner: userId } ).select( 'id', 'name', 'lastChars', 'createdAt', 'lastUsed' ).rightJoin( 'token_scopes', 'id', '=', 'token_scopes.tokenId' )
}
}
+12 -12
View File
@@ -11,7 +11,8 @@ chai.use( chaiHttp )
const knex = require( `${root}/db/knex` )
const { createUser, createToken } = require( '../services/users' )
const { createUser } = require( '../services/users' )
const { createToken } = require( '../services/tokens' )
const { createObject, createObjects } = require( '../services/objects' )
let addr
@@ -31,11 +32,11 @@ describe( 'GraphQL API Core', ( ) => {
testServer = server
userA.id = await createUser( userA )
userA.token = `Bearer ${(await createToken( userA.id, 'test token user A', [ 'streams:read', 'streams:write', 'users:read', 'users:email', 'tokens:create', 'tokens:read', 'tokens:delete' ] ))}`
userA.token = `Bearer ${(await createToken( userA.id, 'test token user A', [ 'streams:read', 'streams:write', 'users:read', 'users:email', 'tokens:write', 'tokens:read' ] ))}`
userB.id = await createUser( userB )
userB.token = `Bearer ${(await createToken( userB.id, 'test token user B', [ 'streams:read', 'streams:write', 'users:read', 'users:email', 'tokens:create', 'tokens:read', 'tokens:delete' ] ))}`
userB.token = `Bearer ${(await createToken( userB.id, 'test token user B', [ 'streams:read', 'streams:write', 'users:read', 'users:email', 'tokens:write', 'tokens:read' ] ))}`
userC.id = await createUser( userC )
userC.token = `Bearer ${(await createToken( userC.id, 'test token user B', [ 'streams:read', 'streams:write', 'users:read', 'users:email', 'tokens:create', 'tokens:read', 'tokens:delete' ] ))}`
userC.token = `Bearer ${(await createToken( userC.id, 'test token user B', [ 'streams:read', 'streams:write', 'users:read', 'users:email', 'tokens:write', 'tokens:read' ] ))}`
addr = `http://localhost:${process.env.PORT || 3000}`
} )
@@ -148,33 +149,32 @@ describe( 'GraphQL API Core', ( ) => {
} )
it( 'Should grant some permissions', async ( ) => {
const res = await sendRequest( userA.token, { query: `mutation{ streamGrantPermission( streamId: "${ts1}", userId: "${userB.id}" role: WRITE) }` } )
const res = await sendRequest( userA.token, { query: `mutation{ streamGrantPermission( streamId: "${ts1}", userId: "${userB.id}" role: "stream:owner") }` } )
expect( res ).to.be.json
expect( res.body.errors ).to.not.exist
expect( res.body.data.streamGrantPermission ).to.equal( true )
const res2 = await sendRequest( userB.token, { query: `mutation{ streamGrantPermission( streamId: "${ts5}", userId: "${userA.id}" role: WRITE) }` } )
const res3 = await sendRequest( userB.token, { query: `mutation{ streamGrantPermission( streamId: "${ts3}", userId: "${userC.id}" role: WRITE) }` } )
const res2 = await sendRequest( userB.token, { query: `mutation{ streamGrantPermission( streamId: "${ts5}", userId: "${userA.id}" role: "stream:owner") }` } )
const res3 = await sendRequest( userB.token, { query: `mutation{ streamGrantPermission( streamId: "${ts3}", userId: "${userC.id}" role: "stream:owner") }` } )
} )
it( 'Should fail to grant permissions if not owner', async ( ) => {
const res = await sendRequest( userB.token, { query: `mutation{ streamGrantPermission( streamId: "${ts1}", userId: "${userB.id}" role: WRITE) }` } )
const res = await sendRequest( userB.token, { query: `mutation{ streamGrantPermission( streamId: "${ts1}", userId: "${userB.id}" role: "stream:owner") }` } )
expect( res ).to.be.json
expect( res.body.errors ).to.exist
} )
it( 'Should fail to grant myself permissions', async ( ) => {
const res = await sendRequest( userA.token, { query: `mutation{ streamGrantPermission( streamId: "${ts1}", userId: "${userA.id}" role: WRITE) }` } )
const res = await sendRequest( userA.token, { query: `mutation{ streamGrantPermission( streamId: "${ts1}", userId: "${userA.id}" role: "stream:owner") }` } )
expect( res ).to.be.json
expect( res.body.errors ).to.exist
} )
it( 'Should update permissions', async ( ) => {
const res = await sendRequest( userA.token, { query: `mutation{ streamGrantPermission( streamId: "${ts1}", userId: "${userB.id}" role: READ) }` } )
const res = await sendRequest( userA.token, { query: `mutation{ streamGrantPermission( streamId: "${ts1}", userId: "${userB.id}" role: "stream:reviewer") }` } )
expect( res ).to.be.json
expect( res.body.errors ).to.not.exist
expect( res.body.data.streamGrantPermission ).to.equal( true )
+3 -3
View File
@@ -93,15 +93,15 @@ describe( 'Streams', ( ) => {
} )
it( 'Should share a stream with a user', async ( ) => {
await grantPermissionsStream( testStream.id, userTwo.id, 'read' )
await grantPermissionsStream( testStream.id, userTwo.id, 'write' ) // change perms
await grantPermissionsStream( testStream.id, userTwo.id, 'stream:reviewer' )
await grantPermissionsStream( testStream.id, userTwo.id, 'stream:contributor' ) // change perms
} )
it( 'Stream should show up in the other users` list', async ( ) => {
let userTwoStreams = await getUserStreams( userTwo.id )
expect( userTwoStreams ).to.have.lengthOf( 1 )
expect( userTwoStreams[ 0 ] ).to.have.property( 'role' )
expect( userTwoStreams[ 0 ].role ).to.equal( 'write' )
expect( userTwoStreams[ 0 ].role ).to.equal( 'stream:contributor' )
} )
it( 'Should get the users with access to a stream', async ( ) => {
-1
View File
@@ -138,7 +138,6 @@ describe( 'Actors & Tokens', ( ) => {
it( 'Should get the tokens of an user', async ( ) => {
let userTokens = await getUserTokens( myTestActor.id )
console.log( userTokens )
expect( userTokens ).to.be.an( 'array' )
expect( userTokens ).to.have.lengthOf( 2 )
} )
+20 -15
View File
@@ -3,7 +3,7 @@ const { ForbiddenError, ApolloError } = require( 'apollo-server-express' )
const debug = require( 'debug' )( 'speckle:middleware' )
const root = require( 'app-root-path' )
const knex = require( `${root}/db/knex` )
const { validateToken } = require( `${root}/modules/core/services/users` )
const { validateToken } = require( `${root}/modules/core/services/tokens` )
/*
@@ -46,31 +46,36 @@ async function validateScopes( scopes, scope ) {
*/
let roles = { admin: 1000, owner: 300, write: 200, read: 100 }
let roles
async function authorizeResolver( userId, resourceId, aclTable, resourceTable, requiredRole ) {
let ACL = ( ) => knex( aclTable )
let Resource = ( ) => knex( resourceTable )
async function authorizeResolver( userId, resourceId, requiredRole ) {
if ( !roles )
roles = await knex( 'user_roles' ).select( '*' )
// TODO: Cache these results with a TTL of 1 mins or so, it's pointless to query the db every time we get a ping.
let role = roles.find( r => r.name === requiredRole )
if ( role === undefined || role === null ) throw new ApolloError( 'Unknown role: ' + requiredRole )
try {
let { isPublic } = await Resource( ).where( { id: resourceId } ).select( 'isPublic' ).first( )
// only return here if it's a read operation: weight < 200.
let { isPublic } = await knex( role.resourceTarget ).select( 'isPublic' ).where( { id: resourceId } ).first( )
if ( isPublic && roles[ requiredRole ] < 200 ) return true
} catch ( e ) {
throw new ApolloError( `Resource of type ${resourceTable} with ${resourceId} not found.` )
throw new ApolloError( `Resource of type ${resourceTable} with ${resourceId} not found` )
}
if ( !userId ) throw new AuthenticationError( 'No user id found.' )
let entry = await knex( role.aclTableName ).select( '*' ).where( { resourceId: resourceId, userId: userId } ).first( )
let [ entry ] = await ACL( ).where( { resourceId: resourceId, userId: userId } ).select( '*' )
if ( !entry ) throw new ForbiddenError( 'You are not authorized' )
if ( !entry )
throw new ForbiddenError( 'You are not authorized.' )
entry.role = roles.find( r => r.name === entry.role )
if ( roles[ entry.role ] >= roles[ requiredRole ] ) {
if ( entry.role.weight >= role.weight )
return true
}
throw new ForbiddenError( 'You are not authorized.' )
else
throw new ForbiddenError( 'You are not authorized' )
}
module.exports = {
+2
View File
@@ -9,6 +9,8 @@
"test-watch": "PORT=3001 DEBUG=speckle:test,speckle:errors NODE_ENV=test mocha --watch -s 0 --exit",
"test-graph": "PORT=3001 DEBUG=speckle:test,speckle:errors NODE_ENV=test mocha ./modules/core/tests/graph.spec.js --watch -s 0 --exit --no-config",
"test-objects": "PORT=3001 DEBUG=speckle:test,speckle:errors NODE_ENV=test mocha ./modules/core/tests/objects.spec.js --watch -s 0 --exit --no-config",
"test-streams": "PORT=3001 DEBUG=speckle:test,speckle:errors NODE_ENV=test mocha ./modules/core/tests/streams.spec.js --watch -s 0 --exit --no-config",
"test-references": "PORT=3001 DEBUG=speckle:test,speckle:errors NODE_ENV=test mocha ./modules/core/tests/references.spec.js --watch -s 0 --exit --no-config",
"test-users": "PORT=3001 DEBUG=speckle:test,speckle:errors NODE_ENV=test mocha ./modules/core/tests/users.spec.js --watch -s 0 --exit --no-config"
},
"author": "",