From 2a78d4f4132a34f670039d218b6406cdecb14770 Mon Sep 17 00:00:00 2001 From: Alexander Chan Date: Fri, 23 Apr 2021 12:10:56 -0700 Subject: [PATCH] ZENKO-3368: add auth chain backend --- lib/auth/auth.js | 6 + lib/auth/backends/ChainBackend.js | 187 +++++++++++++++++ lib/auth/backends/base.js | 8 + tests/unit/auth/chainBackend.js | 326 ++++++++++++++++++++++++++++++ 4 files changed, 527 insertions(+) create mode 100644 lib/auth/backends/ChainBackend.js create mode 100644 tests/unit/auth/chainBackend.js diff --git a/lib/auth/auth.js b/lib/auth/auth.js index 2a3664c0b..390dc14a1 100644 --- a/lib/auth/auth.js +++ b/lib/auth/auth.js @@ -15,6 +15,8 @@ const inMemoryBackend = require('./backends/in_memory/Backend'); const validateAuthConfig = require('./backends/in_memory/validateAuthConfig'); const AuthLoader = require('./backends/in_memory/AuthLoader'); const Vault = require('./Vault'); +const baseBackend = require('./backends/base'); +const chainBackend = require('./backends/ChainBackend'); let vault = null; const auth = {}; @@ -219,6 +221,10 @@ module.exports = { validateAuthConfig, AuthLoader, }, + backends: { + baseBackend, + chainBackend, + }, AuthInfo, Vault, }; diff --git a/lib/auth/backends/ChainBackend.js b/lib/auth/backends/ChainBackend.js new file mode 100644 index 000000000..2637f098b --- /dev/null +++ b/lib/auth/backends/ChainBackend.js @@ -0,0 +1,187 @@ +'use strict'; // eslint-disable-line strict + +const assert = require('assert'); +const async = require('async'); + +const errors = require('../../errors'); +const BaseBackend = require('./base'); + +/** + * Class that provides an authentication backend that will verify signatures + * and retrieve emails and canonical ids associated with an account using a + * given list of authentication backends and vault clients. + * + * @class ChainBackend + */ +class ChainBackend extends BaseBackend { + /** + * @constructor + * @param {string} service - service id + * @param {object[]} clients - list of authentication backends or vault clients + */ + constructor(service, clients) { + super(service); + + assert(Array.isArray(clients) && clients.length > 0, 'invalid client list'); + assert(clients.every(client => + typeof client.verifySignatureV4 === 'function' && + typeof client.verifySignatureV2 === 'function' && + typeof client.getCanonicalIds === 'function' && + typeof client.getEmailAddresses === 'function' && + typeof client.checkPolicies === 'function' && + typeof client.healthcheck === 'function' + ), 'invalid client: missing required auth backend methods'); + this._clients = clients; + } + + + /* + * try task against each client for one to be successful + */ + _tryEachClient(task, cb) { + async.tryEach(this._clients.map(client => done => task(client, done)), cb); + } + + /* + * apply task to all clients + */ + _forEachClient(task, cb) { + async.map(this._clients, task, cb); + } + + verifySignatureV2(stringToSign, signatureFromRequest, accessKey, options, callback) { + this._tryEachClient((client, done) => client.verifySignatureV2( + stringToSign, + signatureFromRequest, + accessKey, + options, + done + ), callback); + } + + verifySignatureV4(stringToSign, signatureFromRequest, accessKey, region, scopeDate, options, callback) { + this._tryEachClient((client, done) => client.verifySignatureV4( + stringToSign, + signatureFromRequest, + accessKey, + region, + scopeDate, + options, + done + ), callback); + } + + static _mergeObjects(objectResponses) { + return objectResponses.reduce( + (retObj, resObj) => Object.assign(retObj, resObj.message.body), + {}); + } + + getCanonicalIds(emailAddresses, options, callback) { + this._forEachClient( + (client, done) => client.getCanonicalIds(emailAddresses, options, done), + (err, res) => { + if (err) { + return callback(err); + } + // TODO: atm naive merge, better handling of conflicting email results + return callback(null, { + message: { + body: ChainBackend._mergeObjects(res), + }, + }); + }); + } + + getEmailAddresses(canonicalIDs, options, callback) { + this._forEachClient( + (client, done) => client.getEmailAddresses(canonicalIDs, options, done), + (err, res) => { + if (err) { + return callback(err); + } + return callback(null, { + message: { + body: ChainBackend._mergeObjects(res), + }, + }); + }); + } + + /* + * merge policy responses into a single message + */ + static _mergePolicies(policyResponses) { + const policyMap = {}; + + policyResponses.forEach(resp => { + if (!resp.message || !Array.isArray(resp.message.body)) { + return; + } + + resp.message.body.forEach(policy => { + const arn = policy.arn || ''; + if (!policyMap[arn]) { + policyMap[arn] = policy.isAllowed; + } + // else is duplicate policy + }); + }); + + const policyList = Object.keys(policyMap).map(arn => { + if (arn === '') { + return { isAllowed: policyMap[arn] }; + } + return { isAllowed: policyMap[arn], arn }; + }); + + return policyList; + } + + /* + response format: + { message: { + body: [{}], + code: number, + message: string, + } } + */ + checkPolicies(requestContextParams, userArn, options, callback) { + this._forEachClient((client, done) => client.checkPolicies( + requestContextParams, + userArn, + options, + done + ), (err, res) => { + if (err) { + return callback(err); + } + return callback(null, { + message: { + body: ChainBackend._mergePolicies(res), + }, + }); + }); + } + + healthcheck(reqUid, callback) { + this._forEachClient((client, done) => + client.healthcheck(reqUid, (err, res) => done(null, { + error: !!err ? err : null, + status: res, + }) + ), (err, res) => { + if (err) { + return callback(err); + } + + const isError = res.some(results => !!results.error); + if (isError) { + return callback(errors.InternalError, res); + } + return callback(null, res); + }); + } +} + +module.exports = ChainBackend; diff --git a/lib/auth/backends/base.js b/lib/auth/backends/base.js index 2a68c6af9..fede00ed6 100644 --- a/lib/auth/backends/base.js +++ b/lib/auth/backends/base.js @@ -73,6 +73,14 @@ class BaseBackend { getEmailAddresses(canonicalIDs, options, callback) { return callback(errors.AuthMethodNotImplemented); } + + checkPolicies(requestContextParams, userArn, options, callback) { + return callback(null, { message: { body: [] } }); + } + + healthcheck(reqUid, callback) { + return callback(null, { code: 200, message: 'OK' }); + } } module.exports = BaseBackend; diff --git a/tests/unit/auth/chainBackend.js b/tests/unit/auth/chainBackend.js new file mode 100644 index 000000000..38ad7ae4a --- /dev/null +++ b/tests/unit/auth/chainBackend.js @@ -0,0 +1,326 @@ +const assert = require('assert'); + +const ChainBackend = require('../../../lib/auth/auth').backends.chainBackend; +const BaseBackend = require('../../../lib/auth/auth').backends.baseBackend; +const errors = require('../../../lib/errors'); + + +const testError = new Error('backend error'); + +const backendWithAllMethods = { + verifySignatureV2: () => {}, + verifySignatureV4: () => {}, + getCanonicalIds: () => {}, + getEmailAddresses: () => {}, + checkPolicies: () => {}, + healthcheck: () => {}, +}; + +function getBackendWithMissingMethod(methodName) { + const backend = Object.assign({}, backendWithAllMethods); + delete backend[methodName]; + return backend; +} + +class TestBackend extends BaseBackend { + constructor(service, error, result) { + super(service); + this._error = error; + this._result = result; + } + + verifySignatureV2(stringToSign, signatureFromRequest, accessKey, options, callback) { + return callback(this._error, this._result); + } + + verifySignatureV4(stringToSign, signatureFromRequest, accessKey, region, scopeDate, options, callback) { + return callback(this._error, this._result); + } + + getCanonicalIds(emailAddresses, options, callback) { + return callback(this._error, this._result); + } + + getEmailAddresses(canonicalIDs, options, callback) { + return callback(this._error, this._result); + } + + checkPolicies(requestContextParams, userArn, options, callback) { + return callback(this._error, this._result); + } + + healthcheck(reqUid, callback) { + return callback(this._error, this._result); + } +} + +describe('Auth Backend: Chain Backend', () => { + [ + ['should throw an error if client list is not an array', null], + ['should throw an error if client list empty', []], + ['should throw an error if a client is missing the verifySignatureV2 method', [ + new TestBackend(), + getBackendWithMissingMethod('verifySignatureV2'), + ]], + ['should throw an error if a client is missing the verifySignatureV4 auth method', [ + new TestBackend(), + getBackendWithMissingMethod('verifySignatureV4'), + ]], + ['should throw an error if a client is missing the getCanonicalId method', [ + new TestBackend(), + getBackendWithMissingMethod('getCanonicalIds'), + ]], + ['should throw an error if a client is missing the getEmailAddresses method', [ + new TestBackend(), + getBackendWithMissingMethod('getEmailAddresses'), + ]], + ['should throw an error if a client is missing the checkPolicies method', [ + new TestBackend(), + getBackendWithMissingMethod('checkPolicies'), + ]], + ['should throw an error if a client is missing the healthcheck method', [ + new TestBackend(), + getBackendWithMissingMethod('healthcheck'), + ]], + ].forEach(([msg, input]) => it(msg, () => { + assert.throws(() => { + new ChainBackend('chain', input); // eslint-disable-line no-new + }); + })); + + [ + // function name, function args + ['verifySignatureV2', [null, null, null, null]], + ['verifySignatureV4', [null, null, null, null, null, null]], + ].forEach(([fn, fnArgs]) => + describe(`::${fn}`, () => { + it('should return an error if none of the clients returns a result', done => { + const backend = new ChainBackend('chain', [ + new TestBackend('test1', testError, null), + new TestBackend('test2', testError, null), + new TestBackend('test3', testError, null), + ]); + + backend[fn](...fnArgs, err => { + assert.deepStrictEqual(err, testError); + done(); + }); + }); + + [ + [ + 'should return result of the first successful client (multiple successful client)', + 'expectedResult', + // backend constructor args + [ + ['test1', null, 'expectedResult'], + ['test2', null, 'test2'], + ['test3', testError, null], + ], + ], + [ + 'should return result of successful client', + 'expectedResult', + // backend constructor args + [ + ['test1', testError, null], + ['test2', null, 'expectedResult'], + ['test3', testError, null], + ], + ], + [ + 'should return result of successful client', + 'expectedResult', + // backend constructor args + [ + ['test1', testError, null], + ['test1', testError, null], + ['test3', null, 'expectedResult'], + ], + ], + ].forEach(([msg, expected, backendArgs]) => { + it(msg, done => { + const backend = new ChainBackend('chain', + backendArgs.map((args) => new TestBackend(...args))); + backend[fn](...fnArgs, (err, res) => { + assert.ifError(err); + assert.strictEqual(res, expected); + done(); + }); + }); + }); + })); + + [ + // function name, function args + ['getCanonicalIds', [null, null]], + ['getEmailAddresses', [null, null]], + ].forEach(([fn, fnArgs]) => + describe(`::${fn}`, () => { + it('should return an error if any of the clients fails', done => { + const backend = new ChainBackend('chain', [ + new TestBackend('test1', null, { message: { body: { test1: 'aaa' } } }), + new TestBackend('test2', testError, null), + new TestBackend('test3', null, { message: { body: { test2: 'bbb' } } }), + ]); + + backend[fn](...fnArgs, err => { + assert.deepStrictEqual(err, testError); + done(); + }); + }); + + it('should merge results from clients into a single response object', done => { + const backend = new ChainBackend('chain', [ + new TestBackend('test1', null, { message: { body: { test1: 'aaa' } } }), + new TestBackend('test2', null, { message: { body: { test2: 'bbb' } } }), + ]); + + backend[fn](...fnArgs, (err, res) => { + assert.ifError(err); + assert.deepStrictEqual(res, { + message: { body: { + test1: 'aaa', + test2: 'bbb', + } }, + }); + done(); + }); + }); + })); + + describe('::checkPolicies', () => { + it('should return an error if any of the clients fails', done => { + const backend = new ChainBackend('chain', [ + new TestBackend('test1', null, { + message: { body: [{ isAllowed: false, arn: 'arn:aws:s3:::policybucket/obj1' }] }, + }), + new TestBackend('test2', testError, null), + new TestBackend('test3', null, { + message: { body: [{ isAllowed: true, arn: 'arn:aws:s3:::policybucket/obj1' }] }, + }), + ]); + + backend.checkPolicies(null, null, null, err => { + assert.deepStrictEqual(err, testError); + done(); + }); + }); + + it('should merge results from clients into a single response object', done => { + const backend = new ChainBackend('chain', [ + new TestBackend('test1', null, { + message: { body: [{ isAllowed: false, arn: 'arn:aws:s3:::policybucket/obj1' }] }, + }), + new TestBackend('test2', null, { + message: { body: [{ isAllowed: true, arn: 'arn:aws:s3:::policybucket/obj2' }] }, + }), + new TestBackend('test3', null, { + message: { body: [{ isAllowed: true, arn: 'arn:aws:s3:::policybucket/obj1' }] }, + }), + ]); + + backend.checkPolicies(null, null, null, (err, res) => { + assert.ifError(err); + assert.deepStrictEqual(res, { + message: { body: [ + { isAllowed: true, arn: 'arn:aws:s3:::policybucket/obj1' }, + { isAllowed: true, arn: 'arn:aws:s3:::policybucket/obj2' }, + ] }, + }); + done(); + }); + }); + }); + + + describe('::_mergeObject', () => { + it('should correctly merge reponses', () => { + const objectResps = [ + { message: { body: { + id1: 'email1@test.com', + wrongformatcanid: 'WrongFormat', + id4: 'email4@test.com', + } } }, + { message: { body: { + id2: 'NotFound', + id3: 'email3@test.com', + id4: 'email5@test.com', + } } }, + ]; + assert.deepStrictEqual( + ChainBackend._mergeObjects(objectResps), + { + id1: 'email1@test.com', + wrongformatcanid: 'WrongFormat', + id2: 'NotFound', + id3: 'email3@test.com', + // id4 should be overwritten + id4: 'email5@test.com', + } + ); + }); + }); + + describe('::_mergePolicies', () => { + it('should correctly merge policies', () => { + const policyResps = [ + { message: { body: [ + { isAllowed: false, arn: 'arn:aws:s3:::policybucket/true1' }, + { isAllowed: true, arn: 'arn:aws:s3:::policybucket/true2' }, + { isAllowed: false, arn: 'arn:aws:s3:::policybucket/false1' }, + ] } }, + { message: { body: [ + { isAllowed: true, arn: 'arn:aws:s3:::policybucket/true1' }, + { isAllowed: false, arn: 'arn:aws:s3:::policybucket/true2' }, + { isAllowed: false, arn: 'arn:aws:s3:::policybucket/false2' }, + ] } }, + ]; + assert.deepStrictEqual( + ChainBackend._mergePolicies(policyResps), + [ + { isAllowed: true, arn: 'arn:aws:s3:::policybucket/true1' }, + { isAllowed: true, arn: 'arn:aws:s3:::policybucket/true2' }, + { isAllowed: false, arn: 'arn:aws:s3:::policybucket/false1' }, + { isAllowed: false, arn: 'arn:aws:s3:::policybucket/false2' }, + ], + ); + }); + }); + + describe('::checkhealth', () => { + it('should return error if a single client is unhealthy', done => { + const backend = new ChainBackend('chain', [ + new TestBackend('test1', null, { code: 200 }), + new TestBackend('test2', testError, { code: 503 }), + new TestBackend('test3', null, { code: 200 }), + ]); + backend.healthcheck(null, (err, res) => { + assert.deepStrictEqual(err, errors.InternalError); + assert.deepStrictEqual(res, [ + { error: null, status: { code: 200 } }, + { error: testError, status: { code: 503 } }, + { error: null, status: { code: 200 } }, + ]); + done(); + }); + }); + + it('should return result if all clients are healthy', done => { + const backend = new ChainBackend('chain', [ + new TestBackend('test1', null, { msg: 'test1', code: 200 }), + new TestBackend('test2', null, { msg: 'test2', code: 200 }), + new TestBackend('test3', null, { msg: 'test3', code: 200 }), + ]); + backend.healthcheck(null, (err, res) => { + assert.ifError(err); + assert.deepStrictEqual(res, [ + { error: null, status: { msg: 'test1', code: 200 } }, + { error: null, status: { msg: 'test2', code: 200 } }, + { error: null, status: { msg: 'test3', code: 200 } }, + ]); + done(); + }); + }); + }); +});