diff --git a/src/server/__mocks__/mockDataFromES.js b/src/server/__mocks__/mockDataFromES.js index 0f48dceb..98d484e9 100644 --- a/src/server/__mocks__/mockDataFromES.js +++ b/src/server/__mocks__/mockDataFromES.js @@ -8,9 +8,19 @@ import mockNestedTermsAndMissingAggs from './mockESData/mockNestedTermsAndMissin import mockNestedAggs from './mockESData/mockNestedAggs'; const mockPing = () => { - nock(config.esConfig.host) - .head('/') - .reply(200, 'hello'); + nock(config.esConfig.host).head('/').reply(200, 'hello'); +}; + +const mockRefresh = () => { + if (config.allowRefresh) { + nock(config.esConfig.host) + .post('/_refresh') + .reply(200, '[Server] guppy refreshed successfully'); + } else { + nock(config.esConfig.host) + .post('/_refresh') + .reply(404, '[Server] guppy _refresh functionality is not enabled'); + } }; const mockResourcePath = () => { @@ -35,12 +45,8 @@ const mockResourcePath = () => { }, }, highlight: { - pre_tags: [ - '', - ], - post_tags: [ - '', - ], + pre_tags: [''], + post_tags: [''], fields: { '*.analyzed': {}, }, @@ -111,12 +117,8 @@ const mockResourcePath = () => { }, }, highlight: { - pre_tags: [ - '', - ], - post_tags: [ - '', - ], + pre_tags: [''], + post_tags: [''], fields: { '*.analyzed': {}, }, @@ -172,12 +174,8 @@ const mockResourcePath = () => { }, }, highlight: { - pre_tags: [ - '', - ], - post_tags: [ - '', - ], + pre_tags: [''], + post_tags: [''], fields: { '*.analyzed': {}, }, @@ -212,7 +210,8 @@ const mockArborist = () => { .persist() .post('/auth/mapping') .reply(200, { - 'internal-project-1': [ // accessible + 'internal-project-1': [ + // accessible { service: '*', method: 'create', @@ -234,13 +233,15 @@ const mockArborist = () => { method: 'update', }, ], - 'internal-project-2': [ // accessible + 'internal-project-2': [ + // accessible { service: '*', method: 'read', }, ], - 'internal-project-3': [ // not accessible since method does not match + 'internal-project-3': [ + // not accessible since method does not match { service: '*', method: 'create', @@ -258,19 +259,22 @@ const mockArborist = () => { method: 'update', }, ], - 'internal-project-4': [ // accessible + 'internal-project-4': [ + // accessible { service: '*', method: '*', }, ], - 'internal-project-5': [ // accessible + 'internal-project-5': [ + // accessible { service: 'guppy', method: '*', }, ], - 'internal-project-6': [ // not accessible since service does not match + 'internal-project-6': [ + // not accessible since service does not match { service: 'indexd', method: '*', @@ -376,7 +380,9 @@ const mockESMapping = () => { }; const mockArrayConfig = () => { - const arrayConfigQuery = { query: { ids: { values: ['gen3-dev-subject', 'gen3-dev-file'] } } }; + const arrayConfigQuery = { + query: { ids: { values: ['gen3-dev-subject', 'gen3-dev-file'] } }, + }; const fakeArrayConfig = { hits: { total: 1, @@ -387,10 +393,7 @@ const mockArrayConfig = () => { _id: 'gen3-dev-subject', _score: 1.0, _source: { - array: [ - 'some_array_integer_field', - 'some_array_string_field', - ], + array: ['some_array_integer_field', 'some_array_string_field'], }, }, ], @@ -405,6 +408,7 @@ const mockArrayConfig = () => { const setup = () => { mockArborist(); mockPing(); + mockRefresh(); mockResourcePath(); mockESMapping(); mockArrayConfig(); diff --git a/src/server/__tests__/config.test.js b/src/server/__tests__/config.test.js index be7f4199..4da4c934 100644 --- a/src/server/__tests__/config.test.js +++ b/src/server/__tests__/config.test.js @@ -34,7 +34,9 @@ describe('config', () => { test('should show error if invalid tier access level', async () => { process.env.TIER_ACCESS_LEVEL = 'invalid-level'; - expect(() => (require('../config'))).toThrow(new Error(`Invalid TIER_ACCESS_LEVEL "${process.env.TIER_ACCESS_LEVEL}"`)); + expect(() => require('../config')).toThrow( + new Error(`Invalid TIER_ACCESS_LEVEL "${process.env.TIER_ACCESS_LEVEL}"`), + ); }); test('should show error if invalid tier access level in guppy block', async () => { @@ -42,7 +44,9 @@ describe('config', () => { const fileName = './testConfigFiles/test-invalid-index-scoped-tier-access.json'; process.env.GUPPY_CONFIG_FILEPATH = `${__dirname}/${fileName}`; const invalidItemType = 'subject_private'; - expect(() => (require('../config'))).toThrow(new Error(`tier_access_level invalid for index ${invalidItemType}.`)); + expect(() => require('../config')).toThrow( + new Error(`tier_access_level invalid for index ${invalidItemType}.`), + ); }); test('clears out site-wide default tiered-access setting if index-scoped levels set', async () => { @@ -54,7 +58,9 @@ describe('config', () => { const { indices } = require(fileName); expect(config.tierAccessLevel).toBeUndefined(); expect(config.tierAccessLimit).toEqual(50); - expect(JSON.stringify(config.esConfig.indices)).toEqual(JSON.stringify(indices)); + expect(JSON.stringify(config.esConfig.indices)).toEqual( + JSON.stringify(indices), + ); }); /* --------------- For whitelist --------------- */ @@ -97,4 +103,11 @@ describe('config', () => { expect(config.esConfig.aggregationIncludeMissingData).toBe(true); expect(config.esConfig.missingDataAlias).toEqual(alias); }); + + /* --------------- For _refresh testing --------------- */ + test('could not access _refresh method if not in config', async () => { + process.env.GUPPY_CONFIG_FILEPATH = `${__dirname}/testConfigFiles/test-no-refresh-option-provided.json`; + const config = require('../config').default; + expect(config.allowRefresh).toBe(false); + }); }); diff --git a/src/server/__tests__/testConfigFiles/test-no-refresh-option-provided.json b/src/server/__tests__/testConfigFiles/test-no-refresh-option-provided.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/server/__tests__/testConfigFiles/test-no-refresh-option-provided.json @@ -0,0 +1 @@ +{} diff --git a/src/server/auth/__tests__/authHelper.test.js b/src/server/auth/__tests__/authHelper.test.js index 41762d7a..c3062413 100644 --- a/src/server/auth/__tests__/authHelper.test.js +++ b/src/server/auth/__tests__/authHelper.test.js @@ -1,5 +1,5 @@ // eslint-disable-next-line -import nock from 'nock'; // must import this to enable mock data by nock +import nock from 'nock'; // must import this to enable mock data by nock import getAuthHelperInstance from '../authHelper'; import esInstance from '../../es/index'; import setupMockDataEndpoint from '../../__mocks__/mockDataFromES'; @@ -12,6 +12,7 @@ setupMockDataEndpoint(); describe('AuthHelper', () => { test('could create auth helper instance', async () => { const authHelper = await getAuthHelperInstance('fake-jwt'); + expect(authHelper.getCanRefresh()).toEqual(false); expect(authHelper.getAccessibleResources()).toEqual(['internal-project-1', 'internal-project-2', 'internal-project-4', 'internal-project-5']); expect(authHelper.getAccessibleResources()).not.toContain(['internal-project-3', 'internal-project-6']); expect(authHelper.getUnaccessibleResources()).toEqual(['external-project-1', 'external-project-2']); diff --git a/src/server/auth/arboristClient.js b/src/server/auth/arboristClient.js index ac47fc92..957da124 100644 --- a/src/server/auth/arboristClient.js +++ b/src/server/auth/arboristClient.js @@ -8,10 +8,10 @@ class ArboristClient { this.baseEndpoint = arboristEndpoint; } - listAuthorizedResources(jwt) { + listAuthMapping(jwt) { // Make request to arborist for list of resources with access const resourcesEndpoint = `${this.baseEndpoint}/auth/mapping`; - log.debug('[ArboristClient] listAuthorizedResources jwt: ', jwt); + log.debug('[ArboristClient] listAuthMapping jwt: ', jwt); const headers = (jwt) ? { Authorization: `bearer ${jwt}` } : {}; return fetch( @@ -40,29 +40,6 @@ class ArboristClient { log.error(err); throw new CodedError(500, err); }, - ).then( - (result) => { - const data = { - resources: [], - }; - Object.keys(result).forEach((key) => { - // logic: you have access to a project if you have the following access: - // method 'read' (or '*' - all methods) to service 'guppy' (or '*' - all services) - // on the project resource. - if (result[key] && result[key].some((x) => ( - (x.method === 'read' || x.method === '*') - && (x.service === 'guppy' || x.service === '*') - ))) { - data.resources.push(key); - } - }); - log.debug('[ArboristClient] data: ', data); - return data; - }, - (err) => { - log.error(err); - throw new CodedError(500, err); - }, ); } } diff --git a/src/server/auth/authHelper.js b/src/server/auth/authHelper.js index b6aa798c..92f822d2 100644 --- a/src/server/auth/authHelper.js +++ b/src/server/auth/authHelper.js @@ -5,6 +5,7 @@ import { getRequestResourceListFromFilter, buildFilterWithResourceList, getAccessibleResourcesFromArboristasync, + checkIfUserCanRefreshServer, } from './utils'; import config from '../config'; @@ -15,8 +16,12 @@ export class AuthHelper { async initialize() { try { - this._accessibleResourceList = await getAccessibleResourcesFromArboristasync(this._jwt); + const [accessibleResourceList, arboristResources] = await getAccessibleResourcesFromArboristasync(this._jwt); + this._accessibleResourceList = accessibleResourceList; + this._arboristResources = arboristResources; log.debug('[AuthHelper] accessible resources:', this._accessibleResourceList); + this._canRefresh = await checkIfUserCanRefreshServer(this._arboristResources); + log.debug('[AuthHelper] can user refresh:', this._canRefresh); const promiseList = []; config.esConfig.indices.forEach(({ index, type }) => { @@ -39,6 +44,10 @@ export class AuthHelper { return this._accessibleResourceList; } + getCanRefresh() { + return this._canRefresh; + } + getUnaccessibleResources() { return this._unaccessibleResourceList; } diff --git a/src/server/auth/utils.js b/src/server/auth/utils.js index f2965315..2e06980a 100644 --- a/src/server/auth/utils.js +++ b/src/server/auth/utils.js @@ -6,6 +6,23 @@ import arboristClient from './arboristClient'; import CodedError from '../utils/error'; import config from '../config'; +export const resourcePathsWithServiceMethodCombination = (userAuthMapping, services, methods = {}) => { + const data = { + resources: [], + }; + Object.keys(userAuthMapping).forEach((key) => { + // logic: you have access to a project if you have + // access to any of the combinations made by the method and service lists + if (userAuthMapping[key] && userAuthMapping[key].some((x) => ( + methods.includes(x.method) + && services.includes(x.service) + ))) { + data.resources.push(key); + } + }); + return data; +}; + export const getAccessibleResourcesFromArboristasync = async (jwt) => { let data; if (config.internalLocalTest) { @@ -16,7 +33,7 @@ export const getAccessibleResourcesFromArboristasync = async (jwt) => { ], }; } else { - data = await arboristClient.listAuthorizedResources(jwt); + data = await arboristClient.listAuthMapping(jwt); } log.debug('[authMiddleware] list resources: ', JSON.stringify(data, null, 4)); @@ -27,8 +44,35 @@ export const getAccessibleResourcesFromArboristasync = async (jwt) => { } throw new CodedError(data.error.code, data.error.message); } - const resources = data.resources ? _.uniq(data.resources) : []; - return resources; + + const read = resourcePathsWithServiceMethodCombination(data, ['guppy', '*'], ['read', '*']); + const readResources = read.resources ? _.uniq(read.resources) : []; + return [readResources, data]; +}; + +export const checkIfUserCanRefreshServer = async (passedData) => { + let data = passedData; + if (config.internalLocalTest) { + data = { + resources: [ // these are just for testing + '/programs/DEV/projects/test', + '/programs/jnkns/projects/jenkins', + ], + }; + } + + log.debug('[authMiddleware] list resources: ', JSON.stringify(data, null, 4)); + if (data && data.error) { + // if user is not in arborist db, assume has no access to any + if (data.error.code === 404) { + return false; + } + throw new CodedError(data.error.code, data.error.message); + } + const adminAccess = resourcePathsWithServiceMethodCombination(data, ['guppy'], ['admin_access', '*']); + + // Only guppy_admin resource path can control guppy admin access + return adminAccess.resources ? adminAccess.resources.includes('/guppy_admin') : false; }; export const getRequestResourceListFromFilter = async ( diff --git a/src/server/config.js b/src/server/config.js index 894e2a10..3aceeb5e 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -5,7 +5,11 @@ let inputConfig = {}; if (process.env.GUPPY_CONFIG_FILEPATH) { const configFilepath = process.env.GUPPY_CONFIG_FILEPATH; inputConfig = JSON.parse(readFileSync(configFilepath).toString()); - log.info('[config] read guppy config from', configFilepath, JSON.stringify(inputConfig, null, 4)); + log.info( + '[config] read guppy config from', + configFilepath, + JSON.stringify(inputConfig, null, 4), + ); } const config = { @@ -21,9 +25,14 @@ const config = { type: 'file', }, ], - configIndex: (inputConfig.indices) ? inputConfig.config_index : 'gen3-dev-config', + configIndex: inputConfig.indices + ? inputConfig.config_index + : 'gen3-dev-config', authFilterField: inputConfig.auth_filter_field || 'auth_resource_path', - aggregationIncludeMissingData: typeof inputConfig.aggs_include_missing_data === 'undefined' ? true : inputConfig.aggs_include_missing_data, + aggregationIncludeMissingData: + typeof inputConfig.aggs_include_missing_data === 'undefined' + ? true + : inputConfig.aggs_include_missing_data, missingDataAlias: inputConfig.missing_data_alias || 'no data', }, port: 80, @@ -31,13 +40,23 @@ const config = { arboristEndpoint: 'http://arborist-service', tierAccessLevel: 'private', tierAccessLimit: 1000, - tierAccessSensitiveRecordExclusionField: inputConfig.tier_access_sensitive_record_exclusion_field, + tierAccessSensitiveRecordExclusionField: + inputConfig.tier_access_sensitive_record_exclusion_field, logLevel: inputConfig.log_level || 'INFO', - enableEncryptWhiteList: typeof inputConfig.enable_encrypt_whitelist === 'undefined' ? false : inputConfig.enable_encrypt_whitelist, - encryptWhitelist: inputConfig.encrypt_whitelist || ['__missing__', 'unknown', 'not reported', 'no data'], + enableEncryptWhiteList: + typeof inputConfig.enable_encrypt_whitelist === 'undefined' + ? false + : inputConfig.enable_encrypt_whitelist, + encryptWhitelist: inputConfig.encrypt_whitelist || [ + '__missing__', + 'unknown', + 'not reported', + 'no data', + ], analyzedTextFieldSuffix: '.analyzed', matchedTextHighlightTagName: 'em', allowedMinimumSearchLen: 2, + allowRefresh: inputConfig.allowRefresh || false, }; if (process.env.GEN3_ES_ENDPOINT) { @@ -59,7 +78,9 @@ const allowedTierAccessLevels = ['private', 'regular', 'libre']; if (process.env.TIER_ACCESS_LEVEL) { if (!allowedTierAccessLevels.includes(process.env.TIER_ACCESS_LEVEL)) { - throw new Error(`Invalid TIER_ACCESS_LEVEL "${process.env.TIER_ACCESS_LEVEL}"`); + throw new Error( + `Invalid TIER_ACCESS_LEVEL "${process.env.TIER_ACCESS_LEVEL}"`, + ); } config.tierAccessLevel = process.env.TIER_ACCESS_LEVEL; } @@ -86,9 +107,14 @@ if (process.env.ANALYZED_TEXT_FIELD_SUFFIX) { let allIndicesHaveTierAccessSettings = true; config.esConfig.indices.forEach((item) => { if (!item.tier_access_level && !config.tierAccessLevel) { - throw new Error('Either set all index-scoped tiered-access levels or a site-wide tiered-access level.'); + throw new Error( + 'Either set all index-scoped tiered-access levels or a site-wide tiered-access level.', + ); } - if (item.tier_access_level && !allowedTierAccessLevels.includes(item.tier_access_level)) { + if ( + item.tier_access_level + && !allowedTierAccessLevels.includes(item.tier_access_level) + ) { throw new Error(`tier_access_level invalid for index ${item.type}.`); } if (!item.tier_access_level) { @@ -112,6 +138,9 @@ if (config.enableEncryptWhiteList) { } log.setLogLevel(config.logLevel); -log.info('[config] starting server using config', JSON.stringify(config, null, 4)); +log.info( + '[config] starting server using config', + JSON.stringify(config, null, 4), +); export default config; diff --git a/src/server/es/index.js b/src/server/es/index.js index 4e708744..815c6c08 100644 --- a/src/server/es/index.js +++ b/src/server/es/index.js @@ -54,7 +54,7 @@ class ES { }; validatedQueryBody.track_total_hits = true; - var start = Date.now(); + const start = Date.now(); return this.client.search({ index: esIndex, body: validatedQueryBody, @@ -62,10 +62,10 @@ class ES { log.error(`[ES.query] error during querying: ${err.message}`); throw new Error(err.message); }).finally(() => { - var end = Date.now(); - var durationInMS = end - start; + const end = Date.now(); + const durationInMS = end - start; - log.info('[ES.query] DurationInMS:' + durationInMS + '. index, type, query body: ', esIndex, esType, JSON.stringify(validatedQueryBody)); + log.info(`[ES.query] DurationInMS:${durationInMS}. index, type, query body: `, esIndex, esType, JSON.stringify(validatedQueryBody)); }); } diff --git a/src/server/server.js b/src/server/server.js index 25c22266..5e503110 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -19,6 +19,7 @@ import downloadRouter from './download'; import CodedError from './utils/error'; import { statusRouter, versionRouter } from './endpoints'; +let server; const app = express(); app.use(cors()); app.use(helmet()); @@ -29,12 +30,9 @@ const startServer = async () => { const typeDefs = getSchema(config.esConfig, esInstance); const resolvers = getResolver(config.esConfig, esInstance); const schema = makeExecutableSchema({ typeDefs, resolvers }); - const schemaWithMiddleware = applyMiddleware( - schema, - ...middlewares, - ); - // create graphql server instance - const server = new ApolloServer({ + const schemaWithMiddleware = applyMiddleware(schema, ...middlewares); + // create graphql server instance + server = new ApolloServer({ mocks: false, schema: schemaWithMiddleware, validationRules: [depthLimit(10)], @@ -57,43 +55,79 @@ const startServer = async () => { path: config.path, }), ); + log.info(`[Server] guppy listening on port ${config.port}!`); +}; + +const initializeAndStartServer = async () => { + await esInstance.initialize(); + await startServer(); +}; - // simple health check endpoint - // eslint-disable-next-line no-unused-vars - app.get('/_status', statusRouter, (err, req, res, next) => { - if (err instanceof CodedError) { - // deepcode ignore ServerLeak: no important information exists in error - res.status(err.code).send(err.msg); +const refreshRouter = async (req, res, next) => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + try { + if (config.allowRefresh === false) { + const disabledRefresh = new CodedError(404, '[Refresh] guppy _refresh functionality is not enabled'); + throw disabledRefresh; } else { - // deepcode ignore ServerLeak: no important information exists in error - res.status(500).send(err); + log.debug('[Refresh] ', JSON.stringify(req.body, null, 4)); + const jwt = headerParser.parseJWT(req); + if (!jwt) { + const noJwtError = new CodedError(401, '[Refresh] no JWT user token provided to _refresh function'); + throw noJwtError; + } + const authHelper = await getAuthHelperInstance(jwt); + if (authHelper._canRefresh === undefined || authHelper._canRefresh === false) { + const noPermsUser = new CodedError(401, '[Refresh] User cannot refresh Guppy without a valid token that has admin_access method on guppy service for resource path /guppy_admin'); + throw noPermsUser; + } + await server.stop(); + await initializeAndStartServer(); } - }); + res.send('[Refresh] guppy refreshed successfully'); + } catch (err) { + log.error(err); + next(err); + } + return 0; +}; - // eslint-disable-next-line no-unused-vars - app.get('/_version', versionRouter); +// simple health check endpoint +// eslint-disable-next-line no-unused-vars +app.get('/_status', statusRouter, (req, res, err, next) => { + if (err instanceof CodedError) { + // deepcode ignore ServerLeak: no important information exists in error + res.status(err.code).send(err.msg); + } else { + // deepcode ignore ServerLeak: no important information exists in error + res.status(500).send(err); + } +}); - // download endpoint for fetching data directly from es - app.post( - '/download', - downloadRouter, - (err, req, res, next) => { // eslint-disable-line no-unused-vars - if (err instanceof CodedError) { - // deepcode ignore ServerLeak: no important information exists in error - res.status(err.code).send(err.msg); - } else { - // deepcode ignore ServerLeak: no important information exists in error - res.status(500).send(err); - } - }, - ); +app.get('/_version', versionRouter); - app.listen(config.port, () => { - log.info(`[Server] guppy listening on port ${config.port}!`); - }); -}; +// download endpoint for fetching data directly from es +// eslint-disable-next-line no-unused-vars +app.post('/download', downloadRouter, (err, req, res, next) => { + if (err instanceof CodedError) { + // deepcode ignore ServerLeak: no important information exists in error + res.status(err.code).send(err.msg); + } else { + // deepcode ignore ServerLeak: no important information exists in error + res.status(500).send(err); + } +}); + +// eslint-disable-next-line no-unused-vars +app.post('/_refresh', refreshRouter, (err, req, res, next) => { + if (err instanceof CodedError) { + res.status(err.code).send(err.msg); + } else { + res.status(500).send(err); + } +}); // need to connect to ES and initialize before setting up a server -esInstance.initialize().then(() => { - startServer(); +app.listen(config.port, async () => { + await initializeAndStartServer(); });