diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/__snapshots__/api_keys_grid_page.test.tsx.snap b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/__snapshots__/api_keys_grid_page.test.tsx.snap new file mode 100644 index 0000000000000..350e28123c29b --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/__snapshots__/api_keys_grid_page.test.tsx.snap @@ -0,0 +1,250 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ApiKeysGridPage renders a callout when API keys are not enabled 1`] = ` + + + } + > + + + + + + + + + + + + API keys not enabled in Elasticsearch + + + + + + + + , + } + } + > + Contact your system administrator and refer to the + + + + docs + + + + to enable API keys. + + + + + + +`; + +exports[`ApiKeysGridPage renders permission denied if user does not have required permissions 1`] = ` + + + + + + + + + + } + iconType="securityApp" + title={ + + + + } + > + + + + + + + + + + + + + + + + + + You need permission to manage API keys + + + + + + + + + + + Contact your system administrator. + + + + + + + + + + + + + + +`; diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.test.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.test.tsx new file mode 100644 index 0000000000000..19ac3881f78d9 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.test.tsx @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +let mockSimulate403 = false; +let mockSimulate500 = false; +let mockAreApiKeysEnabled = true; +let mockIsAdmin = true; + +const mock403 = () => ({ body: { statusCode: 403 } }); +const mock500 = () => ({ body: { error: 'Internal Server Error', message: '', statusCode: 500 } }); + +jest.mock('../../../../lib/api_keys_api', () => { + return { + ApiKeysApi: { + async checkPrivileges() { + if (mockSimulate403) { + throw mock403(); + } + + return { + isAdmin: mockIsAdmin, + areApiKeysEnabled: mockAreApiKeysEnabled, + }; + }, + async getApiKeys() { + if (mockSimulate500) { + throw mock500(); + } + + return { + apiKeys: [ + { + creation: 1571322182082, + expiration: 1571408582082, + id: '0QQZ2m0BO2XZwgJFuWTT', + invalidated: false, + name: 'my-api-key', + realm: 'reserved', + username: 'elastic', + }, + ], + }; + }, + }, + }; +}); + +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { ApiKeysGridPage } from './api_keys_grid_page'; +import React from 'react'; +import { ReactWrapper } from 'enzyme'; +import { EuiCallOut } from '@elastic/eui'; + +import { NotEnabled } from './not_enabled'; +import { PermissionDenied } from './permission_denied'; + +const waitForRender = async ( + wrapper: ReactWrapper, + condition: (wrapper: ReactWrapper) => boolean +) => { + return new Promise((resolve, reject) => { + const interval = setInterval(async () => { + await Promise.resolve(); + wrapper.update(); + if (condition(wrapper)) { + resolve(); + } + }, 10); + + setTimeout(() => { + clearInterval(interval); + reject(new Error('waitForRender timeout after 2000ms')); + }, 2000); + }); +}; + +describe('ApiKeysGridPage', () => { + beforeEach(() => { + mockSimulate403 = false; + mockSimulate500 = false; + mockAreApiKeysEnabled = true; + mockIsAdmin = true; + }); + + it('renders a loading state when fetching API keys', async () => { + const wrapper = mountWithIntl(); + + expect(wrapper.find('[data-test-subj="apiKeysSectionLoading"]')).toHaveLength(1); + }); + + it('renders a callout when API keys are not enabled', async () => { + mockAreApiKeysEnabled = false; + const wrapper = mountWithIntl(); + + await waitForRender(wrapper, updatedWrapper => { + return updatedWrapper.find(NotEnabled).length > 0; + }); + + expect(wrapper.find(NotEnabled)).toMatchSnapshot(); + }); + + it('renders permission denied if user does not have required permissions', async () => { + mockSimulate403 = true; + const wrapper = mountWithIntl(); + + await waitForRender(wrapper, updatedWrapper => { + return updatedWrapper.find(PermissionDenied).length > 0; + }); + + expect(wrapper.find(PermissionDenied)).toMatchSnapshot(); + }); + + it('renders error callout if error fetching API keys', async () => { + mockSimulate500 = true; + const wrapper = mountWithIntl(); + + await waitForRender(wrapper, updatedWrapper => { + return updatedWrapper.find(EuiCallOut).length > 0; + }); + + expect(wrapper.find('EuiCallOut[data-test-subj="apiKeysError"]')).toHaveLength(1); + }); + + describe('Admin view', () => { + const wrapper = mountWithIntl(); + + it('renders a callout indicating the user is an administrator', async () => { + const calloutEl = 'EuiCallOut[data-test-subj="apiKeyAdminDescriptionCallOut"]'; + + await waitForRender(wrapper, updatedWrapper => { + return updatedWrapper.find(calloutEl).length > 0; + }); + + expect(wrapper.find(calloutEl).text()).toEqual('You are an API Key administrator.'); + }); + + it('renders the correct description text', async () => { + const descriptionEl = 'EuiText[data-test-subj="apiKeysDescriptionText"]'; + + await waitForRender(wrapper, updatedWrapper => { + return updatedWrapper.find(descriptionEl).length > 0; + }); + + expect(wrapper.find(descriptionEl).text()).toEqual( + 'View and invalidate API keys. An API key sends requests on behalf of a user.' + ); + }); + }); + + describe('Non-admin view', () => { + mockIsAdmin = false; + const wrapper = mountWithIntl(); + + it('does NOT render a callout indicating the user is an administrator', async () => { + const descriptionEl = 'EuiText[data-test-subj="apiKeysDescriptionText"]'; + const calloutEl = 'EuiCallOut[data-test-subj="apiKeyAdminDescriptionCallOut"]'; + + await waitForRender(wrapper, updatedWrapper => { + return updatedWrapper.find(descriptionEl).length > 0; + }); + + expect(wrapper.find(calloutEl).length).toEqual(0); + }); + + it('renders the correct description text', async () => { + const descriptionEl = 'EuiText[data-test-subj="apiKeysDescriptionText"]'; + + await waitForRender(wrapper, updatedWrapper => { + return updatedWrapper.find(descriptionEl).length > 0; + }); + + expect(wrapper.find(descriptionEl).text()).toEqual( + 'View and invalidate your API keys. An API key sends requests on your behalf.' + ); + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx index 6bebf17c943a4..37838cfdb950d 100644 --- a/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx +++ b/x-pack/legacy/plugins/security/public/views/management/api_keys_grid/components/api_keys_grid_page.tsx @@ -86,7 +86,7 @@ export class ApiKeysGridPage extends Component { if (isLoadingApp) { return ( - + { } color="danger" iconType="alert" + data-test-subj="apiKeysError" > {statusCode}: {errorTitle} - {message} @@ -136,7 +137,7 @@ export class ApiKeysGridPage extends Component { } const description = ( - + {isAdmin ? ( { color="success" iconType="user" size="s" + data-test-subj="apiKeyAdminDescriptionCallOut" /> diff --git a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/server.ts b/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/server.ts index 82f91483dc60d..55b6f735cfced 100644 --- a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/server.ts +++ b/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/server.ts @@ -37,6 +37,9 @@ export function serverFixture() { getUser: stub(), authenticate: stub(), deauthenticate: stub(), + authorization: { + application: stub(), + }, }, xpack_main: { diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.test.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.test.js new file mode 100644 index 0000000000000..400e5b705aeb2 --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.test.js @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Hapi from 'hapi'; +import Boom from 'boom'; + +import { initGetApiKeysApi } from './get'; +import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; + +const createMockServer = () => new Hapi.Server({ debug: false, port: 8080 }); + +describe('GET API keys', () => { + const getApiKeysTest = ( + description, + { + preCheckLicenseImpl = () => null, + callWithRequestImpl, + asserts, + isAdmin = true, + } + ) => { + test(description, async () => { + const mockServer = createMockServer(); + const pre = jest.fn().mockImplementation(preCheckLicenseImpl); + const mockCallWithRequest = jest.fn(); + + if (callWithRequestImpl) { + mockCallWithRequest.mockImplementation(callWithRequestImpl); + } + + initGetApiKeysApi(mockServer, mockCallWithRequest, pre); + + const headers = { + authorization: 'foo', + }; + + const request = { + method: 'GET', + url: `${INTERNAL_API_BASE_PATH}/api_key?isAdmin=${isAdmin}`, + headers, + }; + + const { result, statusCode } = await mockServer.inject(request); + + expect(pre).toHaveBeenCalled(); + + if (callWithRequestImpl) { + expect(mockCallWithRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: headers.authorization, + }), + }), + 'shield.getAPIKeys', + { + owner: !isAdmin, + }, + ); + } else { + expect(mockCallWithRequest).not.toHaveBeenCalled(); + } + + expect(statusCode).toBe(asserts.statusCode); + expect(result).toEqual(asserts.result); + }); + }; + + describe('failure', () => { + getApiKeysTest('returns result of routePreCheckLicense', { + preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), + asserts: { + statusCode: 403, + result: { + error: 'Forbidden', + statusCode: 403, + message: 'test forbidden message', + }, + }, + }); + + getApiKeysTest('returns error from callWithRequest', { + callWithRequestImpl: async () => { + throw Boom.notAcceptable('test not acceptable message'); + }, + asserts: { + statusCode: 406, + result: { + error: 'Not Acceptable', + statusCode: 406, + message: 'test not acceptable message', + }, + }, + }); + }); + + describe('success', () => { + getApiKeysTest('returns API keys', { + callWithRequestImpl: async () => ({ + api_keys: + [{ + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: false, + username: 'elastic', + realm: 'reserved' + }] + }), + asserts: { + statusCode: 200, + result: { + apiKeys: + [{ + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: false, + username: 'elastic', + realm: 'reserved' + }] + }, + }, + }); + getApiKeysTest('returns only valid API keys', { + callWithRequestImpl: async () => ({ + api_keys: + [{ + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key1', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: true, + username: 'elastic', + realm: 'reserved' + }, { + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key2', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: false, + username: 'elastic', + realm: 'reserved' + }], + }), + asserts: { + statusCode: 200, + result: { + apiKeys: + [{ + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key2', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: false, + username: 'elastic', + realm: 'reserved' + }] + }, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.test.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.test.js new file mode 100644 index 0000000000000..3ed7ca94eb782 --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.test.js @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Hapi from 'hapi'; +import Boom from 'boom'; + +import { initInvalidateApiKeysApi } from './invalidate'; +import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; + +const createMockServer = () => new Hapi.Server({ debug: false, port: 8080 }); + +describe('POST invalidate', () => { + const postInvalidateTest = ( + description, + { + preCheckLicenseImpl = () => null, + callWithRequestImpls = [], + asserts, + payload, + } + ) => { + test(description, async () => { + const mockServer = createMockServer(); + const pre = jest.fn().mockImplementation(preCheckLicenseImpl); + const mockCallWithRequest = jest.fn(); + + for (const impl of callWithRequestImpls) { + mockCallWithRequest.mockImplementationOnce(impl); + } + + initInvalidateApiKeysApi(mockServer, mockCallWithRequest, pre); + + const headers = { + authorization: 'foo', + }; + + const request = { + method: 'POST', + url: `${INTERNAL_API_BASE_PATH}/api_key/invalidate`, + headers, + payload, + }; + + const { result, statusCode } = await mockServer.inject(request); + + expect(pre).toHaveBeenCalled(); + + if (asserts.callWithRequests) { + for (const args of asserts.callWithRequests) { + expect(mockCallWithRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: headers.authorization, + }), + }), + ...args + ); + } + } else { + expect(mockCallWithRequest).not.toHaveBeenCalled(); + } + + expect(statusCode).toBe(asserts.statusCode); + expect(result).toEqual(asserts.result); + }); + }; + + describe('failure', () => { + postInvalidateTest('returns result of routePreCheckLicense', { + preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), + payload: { + apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }], + isAdmin: true + }, + asserts: { + statusCode: 403, + result: { + error: 'Forbidden', + statusCode: 403, + message: 'test forbidden message', + }, + }, + }); + + postInvalidateTest('returns errors array from callWithRequest', { + callWithRequestImpls: [async () => { + throw Boom.notAcceptable('test not acceptable message'); + }], + payload: { + apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }], + isAdmin: true + }, + asserts: { + callWithRequests: [ + ['shield.invalidateAPIKey', { + body: { + id: 'si8If24B1bKsmSLTAhJV', + }, + }], + ], + statusCode: 200, + result: { + itemsInvalidated: [], + errors: [{ + id: 'si8If24B1bKsmSLTAhJV', + name: 'my-api-key', + error: Boom.notAcceptable('test not acceptable message'), + }] + }, + }, + }); + }); + + describe('success', () => { + postInvalidateTest('invalidates API keys', { + callWithRequestImpls: [async () => null], + payload: { + apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }], + isAdmin: true + }, + asserts: { + callWithRequests: [ + ['shield.invalidateAPIKey', { + body: { + id: 'si8If24B1bKsmSLTAhJV', + }, + }], + ], + statusCode: 200, + result: { + itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }], + errors: [], + }, + }, + }); + + postInvalidateTest('adds "owner" to body if isAdmin=false', { + callWithRequestImpls: [async () => null], + payload: { + apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }], + isAdmin: false + }, + asserts: { + callWithRequests: [ + ['shield.invalidateAPIKey', { + body: { + id: 'si8If24B1bKsmSLTAhJV', + owner: true, + }, + }], + ], + statusCode: 200, + result: { + itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }], + errors: [], + }, + }, + }); + + postInvalidateTest('returns only successful invalidation requests', { + callWithRequestImpls: [ + async () => null, + async () => { + throw Boom.notAcceptable('test not acceptable message'); + }], + payload: { + apiKeys: [ + { id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key1' }, + { id: 'ab8If24B1bKsmSLTAhNC', name: 'my-api-key2' } + ], + isAdmin: true + }, + asserts: { + callWithRequests: [ + ['shield.invalidateAPIKey', { + body: { + id: 'si8If24B1bKsmSLTAhJV', + }, + }], + ['shield.invalidateAPIKey', { + body: { + id: 'ab8If24B1bKsmSLTAhNC', + }, + }], + ], + statusCode: 200, + result: { + itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key1' }], + errors: [{ + id: 'ab8If24B1bKsmSLTAhNC', + name: 'my-api-key2', + error: Boom.notAcceptable('test not acceptable message'), + }] + }, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.test.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.test.js new file mode 100644 index 0000000000000..2a6f935e00595 --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.test.js @@ -0,0 +1,254 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Hapi from 'hapi'; +import Boom from 'boom'; + +import { initCheckPrivilegesApi } from './privileges'; +import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; + +const createMockServer = () => new Hapi.Server({ debug: false, port: 8080 }); + +describe('GET privileges', () => { + const getPrivilegesTest = ( + description, + { + preCheckLicenseImpl = () => null, + callWithRequestImpls = [], + asserts, + } + ) => { + test(description, async () => { + const mockServer = createMockServer(); + const pre = jest.fn().mockImplementation(preCheckLicenseImpl); + const mockCallWithRequest = jest.fn(); + + for (const impl of callWithRequestImpls) { + mockCallWithRequest.mockImplementationOnce(impl); + } + + initCheckPrivilegesApi(mockServer, mockCallWithRequest, pre); + + const headers = { + authorization: 'foo', + }; + + const request = { + method: 'GET', + url: `${INTERNAL_API_BASE_PATH}/api_key/privileges`, + headers, + }; + + const { result, statusCode } = await mockServer.inject(request); + + expect(pre).toHaveBeenCalled(); + + if (asserts.callWithRequests) { + for (const args of asserts.callWithRequests) { + expect(mockCallWithRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: headers.authorization, + }), + }), + ...args + ); + } + } else { + expect(mockCallWithRequest).not.toHaveBeenCalled(); + } + + expect(statusCode).toBe(asserts.statusCode); + expect(result).toEqual(asserts.result); + }); + }; + + describe('failure', () => { + getPrivilegesTest('returns result of routePreCheckLicense', { + preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), + asserts: { + statusCode: 403, + result: { + error: 'Forbidden', + statusCode: 403, + message: 'test forbidden message', + }, + }, + }); + + getPrivilegesTest('returns error from first callWithRequest', { + callWithRequestImpls: [async () => { + throw Boom.notAcceptable('test not acceptable message'); + }, async () => { }], + asserts: { + callWithRequests: [ + ['shield.hasPrivileges', { + body: { + cluster: [ + 'manage_security', + 'manage_api_key', + ], + }, + }], + ['shield.getAPIKeys', { owner: true }], + ], + statusCode: 406, + result: { + error: 'Not Acceptable', + statusCode: 406, + message: 'test not acceptable message', + }, + }, + }); + + getPrivilegesTest('returns error from second callWithRequest', { + callWithRequestImpls: [async () => { }, async () => { + throw Boom.notAcceptable('test not acceptable message'); + }], + asserts: { + callWithRequests: [ + ['shield.hasPrivileges', { + body: { + cluster: [ + 'manage_security', + 'manage_api_key', + ], + }, + }], + ['shield.getAPIKeys', { owner: true }], + ], + statusCode: 406, + result: { + error: 'Not Acceptable', + statusCode: 406, + message: 'test not acceptable message', + }, + }, + }); + }); + + describe('success', () => { + getPrivilegesTest('returns areApiKeysEnabled and isAdmin', { + callWithRequestImpls: [ + async () => ({ + username: 'elastic', + has_all_requested: true, + cluster: { manage_api_key: true, manage_security: true }, + index: {}, + application: {} + }), + async () => ( + { + api_keys: + [{ + id: 'si8If24B1bKsmSLTAhJV', + name: 'my-api-key', + creation: 1574089261632, + expiration: 1574175661632, + invalidated: false, + username: 'elastic', + realm: 'reserved' + }] + } + ), + ], + asserts: { + callWithRequests: [ + ['shield.getAPIKeys', { owner: true }], + ['shield.hasPrivileges', { + body: { + cluster: [ + 'manage_security', + 'manage_api_key', + ], + }, + }], + ], + statusCode: 200, + result: { + areApiKeysEnabled: true, + isAdmin: true, + }, + }, + }); + + getPrivilegesTest('returns areApiKeysEnabled=false when getAPIKeys error message includes "api keys are not enabled"', { + callWithRequestImpls: [ + async () => ({ + username: 'elastic', + has_all_requested: true, + cluster: { manage_api_key: true, manage_security: true }, + index: {}, + application: {} + }), + async () => { + throw Boom.unauthorized('api keys are not enabled'); + }, + ], + asserts: { + callWithRequests: [ + ['shield.getAPIKeys', { owner: true }], + ['shield.hasPrivileges', { + body: { + cluster: [ + 'manage_security', + 'manage_api_key', + ], + }, + }], + ], + statusCode: 200, + result: { + areApiKeysEnabled: false, + isAdmin: true, + }, + }, + }); + + getPrivilegesTest('returns isAdmin=false when user has insufficient privileges', { + callWithRequestImpls: [ + async () => ({ + username: 'elastic', + has_all_requested: true, + cluster: { manage_api_key: false, manage_security: false }, + index: {}, + application: {} + }), + async () => ( + { + api_keys: + [{ + id: 'si8If24B1bKsmSLTAhJV', + name: 'my-api-key', + creation: 1574089261632, + expiration: 1574175661632, + invalidated: false, + username: 'elastic', + realm: 'reserved' + }] + } + ), + ], + asserts: { + callWithRequests: [ + ['shield.getAPIKeys', { owner: true }], + ['shield.hasPrivileges', { + body: { + cluster: [ + 'manage_security', + 'manage_api_key', + ], + }, + }], + ], + statusCode: 200, + result: { + areApiKeysEnabled: true, + isAdmin: false, + }, + }, + }); + }); +});
+ + Contact your system administrator. + +
{isAdmin ? ( { color="success" iconType="user" size="s" + data-test-subj="apiKeyAdminDescriptionCallOut" /> diff --git a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/server.ts b/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/server.ts index 82f91483dc60d..55b6f735cfced 100644 --- a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/server.ts +++ b/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/server.ts @@ -37,6 +37,9 @@ export function serverFixture() { getUser: stub(), authenticate: stub(), deauthenticate: stub(), + authorization: { + application: stub(), + }, }, xpack_main: { diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.test.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.test.js new file mode 100644 index 0000000000000..400e5b705aeb2 --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/get.test.js @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Hapi from 'hapi'; +import Boom from 'boom'; + +import { initGetApiKeysApi } from './get'; +import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; + +const createMockServer = () => new Hapi.Server({ debug: false, port: 8080 }); + +describe('GET API keys', () => { + const getApiKeysTest = ( + description, + { + preCheckLicenseImpl = () => null, + callWithRequestImpl, + asserts, + isAdmin = true, + } + ) => { + test(description, async () => { + const mockServer = createMockServer(); + const pre = jest.fn().mockImplementation(preCheckLicenseImpl); + const mockCallWithRequest = jest.fn(); + + if (callWithRequestImpl) { + mockCallWithRequest.mockImplementation(callWithRequestImpl); + } + + initGetApiKeysApi(mockServer, mockCallWithRequest, pre); + + const headers = { + authorization: 'foo', + }; + + const request = { + method: 'GET', + url: `${INTERNAL_API_BASE_PATH}/api_key?isAdmin=${isAdmin}`, + headers, + }; + + const { result, statusCode } = await mockServer.inject(request); + + expect(pre).toHaveBeenCalled(); + + if (callWithRequestImpl) { + expect(mockCallWithRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: headers.authorization, + }), + }), + 'shield.getAPIKeys', + { + owner: !isAdmin, + }, + ); + } else { + expect(mockCallWithRequest).not.toHaveBeenCalled(); + } + + expect(statusCode).toBe(asserts.statusCode); + expect(result).toEqual(asserts.result); + }); + }; + + describe('failure', () => { + getApiKeysTest('returns result of routePreCheckLicense', { + preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), + asserts: { + statusCode: 403, + result: { + error: 'Forbidden', + statusCode: 403, + message: 'test forbidden message', + }, + }, + }); + + getApiKeysTest('returns error from callWithRequest', { + callWithRequestImpl: async () => { + throw Boom.notAcceptable('test not acceptable message'); + }, + asserts: { + statusCode: 406, + result: { + error: 'Not Acceptable', + statusCode: 406, + message: 'test not acceptable message', + }, + }, + }); + }); + + describe('success', () => { + getApiKeysTest('returns API keys', { + callWithRequestImpl: async () => ({ + api_keys: + [{ + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: false, + username: 'elastic', + realm: 'reserved' + }] + }), + asserts: { + statusCode: 200, + result: { + apiKeys: + [{ + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: false, + username: 'elastic', + realm: 'reserved' + }] + }, + }, + }); + getApiKeysTest('returns only valid API keys', { + callWithRequestImpl: async () => ({ + api_keys: + [{ + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key1', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: true, + username: 'elastic', + realm: 'reserved' + }, { + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key2', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: false, + username: 'elastic', + realm: 'reserved' + }], + }), + asserts: { + statusCode: 200, + result: { + apiKeys: + [{ + id: 'YCLV7m0BJ3xI4hhWB648', + name: 'test-api-key2', + creation: 1571670001452, + expiration: 1571756401452, + invalidated: false, + username: 'elastic', + realm: 'reserved' + }] + }, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.test.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.test.js new file mode 100644 index 0000000000000..3ed7ca94eb782 --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/invalidate.test.js @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Hapi from 'hapi'; +import Boom from 'boom'; + +import { initInvalidateApiKeysApi } from './invalidate'; +import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; + +const createMockServer = () => new Hapi.Server({ debug: false, port: 8080 }); + +describe('POST invalidate', () => { + const postInvalidateTest = ( + description, + { + preCheckLicenseImpl = () => null, + callWithRequestImpls = [], + asserts, + payload, + } + ) => { + test(description, async () => { + const mockServer = createMockServer(); + const pre = jest.fn().mockImplementation(preCheckLicenseImpl); + const mockCallWithRequest = jest.fn(); + + for (const impl of callWithRequestImpls) { + mockCallWithRequest.mockImplementationOnce(impl); + } + + initInvalidateApiKeysApi(mockServer, mockCallWithRequest, pre); + + const headers = { + authorization: 'foo', + }; + + const request = { + method: 'POST', + url: `${INTERNAL_API_BASE_PATH}/api_key/invalidate`, + headers, + payload, + }; + + const { result, statusCode } = await mockServer.inject(request); + + expect(pre).toHaveBeenCalled(); + + if (asserts.callWithRequests) { + for (const args of asserts.callWithRequests) { + expect(mockCallWithRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: headers.authorization, + }), + }), + ...args + ); + } + } else { + expect(mockCallWithRequest).not.toHaveBeenCalled(); + } + + expect(statusCode).toBe(asserts.statusCode); + expect(result).toEqual(asserts.result); + }); + }; + + describe('failure', () => { + postInvalidateTest('returns result of routePreCheckLicense', { + preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), + payload: { + apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }], + isAdmin: true + }, + asserts: { + statusCode: 403, + result: { + error: 'Forbidden', + statusCode: 403, + message: 'test forbidden message', + }, + }, + }); + + postInvalidateTest('returns errors array from callWithRequest', { + callWithRequestImpls: [async () => { + throw Boom.notAcceptable('test not acceptable message'); + }], + payload: { + apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }], + isAdmin: true + }, + asserts: { + callWithRequests: [ + ['shield.invalidateAPIKey', { + body: { + id: 'si8If24B1bKsmSLTAhJV', + }, + }], + ], + statusCode: 200, + result: { + itemsInvalidated: [], + errors: [{ + id: 'si8If24B1bKsmSLTAhJV', + name: 'my-api-key', + error: Boom.notAcceptable('test not acceptable message'), + }] + }, + }, + }); + }); + + describe('success', () => { + postInvalidateTest('invalidates API keys', { + callWithRequestImpls: [async () => null], + payload: { + apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }], + isAdmin: true + }, + asserts: { + callWithRequests: [ + ['shield.invalidateAPIKey', { + body: { + id: 'si8If24B1bKsmSLTAhJV', + }, + }], + ], + statusCode: 200, + result: { + itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }], + errors: [], + }, + }, + }); + + postInvalidateTest('adds "owner" to body if isAdmin=false', { + callWithRequestImpls: [async () => null], + payload: { + apiKeys: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key', }], + isAdmin: false + }, + asserts: { + callWithRequests: [ + ['shield.invalidateAPIKey', { + body: { + id: 'si8If24B1bKsmSLTAhJV', + owner: true, + }, + }], + ], + statusCode: 200, + result: { + itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key' }], + errors: [], + }, + }, + }); + + postInvalidateTest('returns only successful invalidation requests', { + callWithRequestImpls: [ + async () => null, + async () => { + throw Boom.notAcceptable('test not acceptable message'); + }], + payload: { + apiKeys: [ + { id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key1' }, + { id: 'ab8If24B1bKsmSLTAhNC', name: 'my-api-key2' } + ], + isAdmin: true + }, + asserts: { + callWithRequests: [ + ['shield.invalidateAPIKey', { + body: { + id: 'si8If24B1bKsmSLTAhJV', + }, + }], + ['shield.invalidateAPIKey', { + body: { + id: 'ab8If24B1bKsmSLTAhNC', + }, + }], + ], + statusCode: 200, + result: { + itemsInvalidated: [{ id: 'si8If24B1bKsmSLTAhJV', name: 'my-api-key1' }], + errors: [{ + id: 'ab8If24B1bKsmSLTAhNC', + name: 'my-api-key2', + error: Boom.notAcceptable('test not acceptable message'), + }] + }, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.test.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.test.js new file mode 100644 index 0000000000000..2a6f935e00595 --- /dev/null +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/privileges.test.js @@ -0,0 +1,254 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Hapi from 'hapi'; +import Boom from 'boom'; + +import { initCheckPrivilegesApi } from './privileges'; +import { INTERNAL_API_BASE_PATH } from '../../../../../common/constants'; + +const createMockServer = () => new Hapi.Server({ debug: false, port: 8080 }); + +describe('GET privileges', () => { + const getPrivilegesTest = ( + description, + { + preCheckLicenseImpl = () => null, + callWithRequestImpls = [], + asserts, + } + ) => { + test(description, async () => { + const mockServer = createMockServer(); + const pre = jest.fn().mockImplementation(preCheckLicenseImpl); + const mockCallWithRequest = jest.fn(); + + for (const impl of callWithRequestImpls) { + mockCallWithRequest.mockImplementationOnce(impl); + } + + initCheckPrivilegesApi(mockServer, mockCallWithRequest, pre); + + const headers = { + authorization: 'foo', + }; + + const request = { + method: 'GET', + url: `${INTERNAL_API_BASE_PATH}/api_key/privileges`, + headers, + }; + + const { result, statusCode } = await mockServer.inject(request); + + expect(pre).toHaveBeenCalled(); + + if (asserts.callWithRequests) { + for (const args of asserts.callWithRequests) { + expect(mockCallWithRequest).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: headers.authorization, + }), + }), + ...args + ); + } + } else { + expect(mockCallWithRequest).not.toHaveBeenCalled(); + } + + expect(statusCode).toBe(asserts.statusCode); + expect(result).toEqual(asserts.result); + }); + }; + + describe('failure', () => { + getPrivilegesTest('returns result of routePreCheckLicense', { + preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), + asserts: { + statusCode: 403, + result: { + error: 'Forbidden', + statusCode: 403, + message: 'test forbidden message', + }, + }, + }); + + getPrivilegesTest('returns error from first callWithRequest', { + callWithRequestImpls: [async () => { + throw Boom.notAcceptable('test not acceptable message'); + }, async () => { }], + asserts: { + callWithRequests: [ + ['shield.hasPrivileges', { + body: { + cluster: [ + 'manage_security', + 'manage_api_key', + ], + }, + }], + ['shield.getAPIKeys', { owner: true }], + ], + statusCode: 406, + result: { + error: 'Not Acceptable', + statusCode: 406, + message: 'test not acceptable message', + }, + }, + }); + + getPrivilegesTest('returns error from second callWithRequest', { + callWithRequestImpls: [async () => { }, async () => { + throw Boom.notAcceptable('test not acceptable message'); + }], + asserts: { + callWithRequests: [ + ['shield.hasPrivileges', { + body: { + cluster: [ + 'manage_security', + 'manage_api_key', + ], + }, + }], + ['shield.getAPIKeys', { owner: true }], + ], + statusCode: 406, + result: { + error: 'Not Acceptable', + statusCode: 406, + message: 'test not acceptable message', + }, + }, + }); + }); + + describe('success', () => { + getPrivilegesTest('returns areApiKeysEnabled and isAdmin', { + callWithRequestImpls: [ + async () => ({ + username: 'elastic', + has_all_requested: true, + cluster: { manage_api_key: true, manage_security: true }, + index: {}, + application: {} + }), + async () => ( + { + api_keys: + [{ + id: 'si8If24B1bKsmSLTAhJV', + name: 'my-api-key', + creation: 1574089261632, + expiration: 1574175661632, + invalidated: false, + username: 'elastic', + realm: 'reserved' + }] + } + ), + ], + asserts: { + callWithRequests: [ + ['shield.getAPIKeys', { owner: true }], + ['shield.hasPrivileges', { + body: { + cluster: [ + 'manage_security', + 'manage_api_key', + ], + }, + }], + ], + statusCode: 200, + result: { + areApiKeysEnabled: true, + isAdmin: true, + }, + }, + }); + + getPrivilegesTest('returns areApiKeysEnabled=false when getAPIKeys error message includes "api keys are not enabled"', { + callWithRequestImpls: [ + async () => ({ + username: 'elastic', + has_all_requested: true, + cluster: { manage_api_key: true, manage_security: true }, + index: {}, + application: {} + }), + async () => { + throw Boom.unauthorized('api keys are not enabled'); + }, + ], + asserts: { + callWithRequests: [ + ['shield.getAPIKeys', { owner: true }], + ['shield.hasPrivileges', { + body: { + cluster: [ + 'manage_security', + 'manage_api_key', + ], + }, + }], + ], + statusCode: 200, + result: { + areApiKeysEnabled: false, + isAdmin: true, + }, + }, + }); + + getPrivilegesTest('returns isAdmin=false when user has insufficient privileges', { + callWithRequestImpls: [ + async () => ({ + username: 'elastic', + has_all_requested: true, + cluster: { manage_api_key: false, manage_security: false }, + index: {}, + application: {} + }), + async () => ( + { + api_keys: + [{ + id: 'si8If24B1bKsmSLTAhJV', + name: 'my-api-key', + creation: 1574089261632, + expiration: 1574175661632, + invalidated: false, + username: 'elastic', + realm: 'reserved' + }] + } + ), + ], + asserts: { + callWithRequests: [ + ['shield.getAPIKeys', { owner: true }], + ['shield.hasPrivileges', { + body: { + cluster: [ + 'manage_security', + 'manage_api_key', + ], + }, + }], + ], + statusCode: 200, + result: { + areApiKeysEnabled: true, + isAdmin: false, + }, + }, + }); + }); +});