From 4a46b9e5d4603fca14690e8485a203559a235c0b Mon Sep 17 00:00:00 2001 From: spalger Date: Fri, 16 Oct 2020 08:20:04 -0700 Subject: [PATCH 01/11] [keystore_cli] parse values as JSON before adding to keystore --- src/cli_keystore/add.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/cli_keystore/add.js b/src/cli_keystore/add.js index 232392f34c63..d88256da1aa5 100644 --- a/src/cli_keystore/add.js +++ b/src/cli_keystore/add.js @@ -59,7 +59,15 @@ export async function add(keystore, key, options = {}) { value = await question(`Enter value for ${key}`, { mask: '*' }); } - keystore.add(key, value.trim()); + const parsedValue = value.trim(); + let parsedJsonValue; + try { + parsedJsonValue = JSON.parse(parsedValue); + } catch { + // noop, only treat value as json if it parses as JSON + } + + keystore.add(key, parsedJsonValue ?? parsedValue); keystore.save(); } From 403c4dac5ee11a571ff2de0d5e853b0cfeccb71d Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Fri, 16 Oct 2020 15:24:05 -0400 Subject: [PATCH 02/11] [Uptime] Add client-side unit tests for remaining synthetics code (#80215) * Test remaining branches in synthetics components. * Fix TS errors. * PR feedback. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__tests__/browser_expanded_row.test.tsx | 25 +- .../__tests__/console_event.test.tsx | 107 ++++++++ .../console_output_event_list.test.tsx | 150 ++++++++++ .../__tests__/empty_journey.test.tsx | 94 +++++++ .../__tests__/executed_journey.test.tsx | 259 ++++++++++++++++++ .../synthetics/browser_expanded_row.tsx | 4 +- .../synthetics/console_output_event_list.tsx | 10 +- .../monitor/synthetics/empty_journey.tsx | 4 +- .../monitor/synthetics/executed_journey.tsx | 16 +- 9 files changed, 655 insertions(+), 14 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/console_event.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/console_output_event_list.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/empty_journey.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_journey.test.tsx diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/browser_expanded_row.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/browser_expanded_row.test.tsx index 191632d6ab71..07c3afdf50ee 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/browser_expanded_row.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/browser_expanded_row.test.tsx @@ -28,7 +28,7 @@ describe('BrowserExpandedRowComponent', () => { it('returns empty step state when no journey', () => { expect(shallowWithIntl()).toMatchInlineSnapshot( - `` + `` ); }); @@ -43,7 +43,7 @@ describe('BrowserExpandedRowComponent', () => { }} /> ) - ).toMatchInlineSnapshot(``); + ).toMatchInlineSnapshot(``); }); it('displays loading spinner when loading', () => { @@ -111,6 +111,27 @@ describe('BrowserExpandedRowComponent', () => { `); }); + it('handles case where synth type is somehow missing', () => { + expect( + shallowWithIntl( + + ) + ).toMatchInlineSnapshot(`""`); + }); + it('renders console output step list when only console steps are present', () => { expect( shallowWithIntl( diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/console_event.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/console_event.test.tsx new file mode 100644 index 000000000000..ad905076a06c --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/console_event.test.tsx @@ -0,0 +1,107 @@ +/* + * 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 { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { ConsoleEvent } from '../console_event'; + +describe('ConsoleEvent component', () => { + it('renders danger color for errors', () => { + expect( + shallowWithIntl( + + ) + ).toMatchInlineSnapshot(` + + + 123 + + + stderr + + + catastrophic error + + + `); + }); + + it('uses default color for non-errors', () => { + expect( + shallowWithIntl( + + ) + ).toMatchInlineSnapshot(` + + + 123 + + + cmd/status + + + not a catastrophic error + + + `); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/console_output_event_list.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/console_output_event_list.test.tsx new file mode 100644 index 000000000000..776fd0a5fb94 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/console_output_event_list.test.tsx @@ -0,0 +1,150 @@ +/* + * 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 { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { ConsoleOutputEventList } from '../console_output_event_list'; + +describe('ConsoleOutputEventList component', () => { + it('renders a component per console event', () => { + expect( + shallowWithIntl( + + ).find('EuiCodeBlock') + ).toMatchInlineSnapshot(` + + + + + + `); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/empty_journey.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/empty_journey.test.tsx new file mode 100644 index 000000000000..0157229b3c21 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/empty_journey.test.tsx @@ -0,0 +1,94 @@ +/* + * 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 { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { EmptyJourney } from '../empty_journey'; + +describe('EmptyJourney component', () => { + it('omits check group element when undefined', () => { + expect(shallowWithIntl()).toMatchInlineSnapshot(` + +

+ +

+

+ +

+ + } + iconType="cross" + title={ +

+ +

+ } + /> + `); + }); + + it('includes check group element when present', () => { + expect(shallowWithIntl()).toMatchInlineSnapshot(` + +

+ +

+

+ + check_group + , + } + } + /> +

+

+ +

+ + } + iconType="cross" + title={ +

+ +

+ } + /> + `); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_journey.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_journey.test.tsx new file mode 100644 index 000000000000..5ab815a3c0b5 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_journey.test.tsx @@ -0,0 +1,259 @@ +/* + * 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 { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { ExecutedJourney } from '../executed_journey'; +import { Ping } from '../../../../../common/runtime_types'; + +const MONITOR_BOILERPLATE = { + id: 'MON_ID', + duration: { + us: 10, + }, + status: 'down', + type: 'browser', +}; + +describe('ExecutedJourney component', () => { + let steps: Ping[]; + + beforeEach(() => { + steps = [ + { + docId: '1', + timestamp: '123', + monitor: MONITOR_BOILERPLATE, + synthetics: { + payload: { + status: 'failed', + }, + type: 'step/end', + }, + }, + { + docId: '2', + timestamp: '124', + monitor: MONITOR_BOILERPLATE, + synthetics: { + payload: { + status: 'failed', + }, + type: 'step/end', + }, + }, + ]; + }); + + it('creates expected message for all failed', () => { + const wrapper = shallowWithIntl( + + ); + expect(wrapper.find('EuiText')).toMatchInlineSnapshot(` + +

+ +

+

+ 2 Steps - all failed or skipped +

+
+ `); + }); + + it('creates expected message for all succeeded', () => { + steps[0].synthetics!.payload!.status = 'succeeded'; + steps[1].synthetics!.payload!.status = 'succeeded'; + const wrapper = shallowWithIntl( + + ); + expect(wrapper.find('EuiText')).toMatchInlineSnapshot(` + +

+ +

+

+ 2 Steps - all succeeded +

+
+ `); + }); + + it('creates appropriate message for mixed results', () => { + steps[0].synthetics!.payload!.status = 'succeeded'; + const wrapper = shallowWithIntl( + + ); + expect(wrapper.find('EuiText')).toMatchInlineSnapshot(` + +

+ +

+

+ 2 Steps - 1 succeeded +

+
+ `); + }); + + it('tallies skipped steps', () => { + steps[0].synthetics!.payload!.status = 'succeeded'; + steps[1].synthetics!.payload!.status = 'skipped'; + const wrapper = shallowWithIntl( + + ); + expect(wrapper.find('EuiText')).toMatchInlineSnapshot(` + +

+ +

+

+ 2 Steps - 1 succeeded +

+
+ `); + }); + + it('uses appropriate count when non-step/end steps are included', () => { + steps[0].synthetics!.payload!.status = 'succeeded'; + steps.push({ + docId: '3', + timestamp: '125', + monitor: MONITOR_BOILERPLATE, + synthetics: { + type: 'stderr', + error: { + message: `there was an error, that's all we know`, + stack: 'your.error.happened.here', + }, + }, + }); + const wrapper = shallowWithIntl( + + ); + expect(wrapper.find('EuiText')).toMatchInlineSnapshot(` + +

+ +

+

+ 2 Steps - 1 succeeded +

+
+ `); + }); + + it('renders a component per step', () => { + expect( + shallowWithIntl( + + ).find('EuiFlexGroup') + ).toMatchInlineSnapshot(` + + + + + `); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx index 2546c5fb9a5d..4b7461604b30 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx @@ -11,7 +11,7 @@ import { Ping } from '../../../../common/runtime_types'; import { getJourneySteps } from '../../../state/actions/journey'; import { JourneyState } from '../../../state/reducers/journey'; import { journeySelector } from '../../../state/selectors'; -import { EmptyStepState } from './empty_journey'; +import { EmptyJourney } from './empty_journey'; import { ExecutedJourney } from './executed_journey'; import { ConsoleOutputEventList } from './console_output_event_list'; @@ -51,7 +51,7 @@ export const BrowserExpandedRowComponent: FC = ({ checkGroup, jo } if (!journey || journey.steps.length === 0) { - return ; + return ; } if (journey.steps.some(stepEnd)) return ; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx index 9159c61532f1..8f3d6cec9932 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx @@ -7,6 +7,7 @@ import { EuiCodeBlock, EuiSpacer, EuiTitle } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { FC } from 'react'; +import { Ping } from '../../../../common/runtime_types'; import { JourneyState } from '../../../state/reducers/journey'; import { ConsoleEvent } from './console_event'; @@ -14,6 +15,11 @@ interface Props { journey: JourneyState; } +const isConsoleStep = (step: Ping) => + step.synthetics?.type === 'stderr' || + step.synthetics?.type === 'stdout' || + step.synthetics?.type === 'cmd/status'; + export const ConsoleOutputEventList: FC = ({ journey }) => (
@@ -33,8 +39,8 @@ export const ConsoleOutputEventList: FC = ({ journey }) => (

- {journey.steps.map((consoleEvent) => ( - + {journey.steps.filter(isConsoleStep).map((consoleEvent) => ( + ))}
diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.tsx index b6fead2bbbe0..4076d9ff7dfd 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.tsx @@ -8,11 +8,11 @@ import { EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { FC } from 'react'; -interface EmptyStepStateProps { +interface Props { checkGroup?: string; } -export const EmptyStepState: FC = ({ checkGroup }) => ( +export const EmptyJourney: FC = ({ checkGroup }) => ( = ({ journey }) => (

{statusMessage( - journey.steps.reduce(reduceStepStatus, { failed: 0, skipped: 0, succeeded: 0 }) + journey.steps + .filter(isStepEnd) + .reduce(reduceStepStatus, { failed: 0, skipped: 0, succeeded: 0 }) )}

- {journey.steps - .filter((step) => step.synthetics?.type === 'step/end') - .map((step, index) => ( - - ))} + {journey.steps.filter(isStepEnd).map((step, index) => ( + + ))} ); From bc8a1dac99af3ea16bbcf5f2825d0f90724fa3b3 Mon Sep 17 00:00:00 2001 From: Thom Heymann <190132+thomheymann@users.noreply.github.com> Date: Fri, 16 Oct 2020 20:40:38 +0100 Subject: [PATCH 03/11] ECS audit logging (#74640) * ECS audit logging * Apply suggestions from code review Co-authored-by: Larry Gregory * Update x-pack/plugins/security/server/authentication/audit_events.ts Co-authored-by: Larry Gregory * Update docs/settings/security-settings.asciidoc Co-authored-by: Larry Gregory * remove audit trail service from core * fix test * Updated docs and added beta warning * Added dev docs * Tweaks * Plugin list changes * Apply suggestions from technical writers Co-authored-by: Kaarina Tungseth * Added docs suggestion * Added api integration tests * Added suggestions from platform team * Update x-pack/plugins/security/server/audit/audit_service.test.ts Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> * Update x-pack/plugins/security/server/audit/audit_service.test.ts Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> * Update x-pack/plugins/security/server/audit/audit_service.test.ts Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> * Update docs/user/security/audit-logging.asciidoc Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> * Update docs/settings/security-settings.asciidoc Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> * Update x-pack/plugins/security/server/config.ts Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> * Added suggestions from PR * Grouped events table * Update x-pack/plugins/security/server/audit/audit_events.ts Co-authored-by: Larry Gregory * Update x-pack/plugins/security/server/audit/audit_events.ts Co-authored-by: Larry Gregory * Fixed ECS version number in docs Co-authored-by: Larry Gregory * Added suggestions from code review * Removed beta * Added suggestions from code review Co-authored-by: Larry Gregory Co-authored-by: Kaarina Tungseth Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> --- docs/developer/plugin-list.asciidoc | 7 +- ...ibana-plugin-core-server.auditableevent.md | 25 - ...ugin-core-server.auditableevent.message.md | 11 - ...-plugin-core-server.auditableevent.type.md | 11 - .../kibana-plugin-core-server.auditor.add.md | 36 -- .../kibana-plugin-core-server.auditor.md | 21 - ...ugin-core-server.auditor.withauditscope.md | 24 - ...gin-core-server.auditorfactory.asscoped.md | 22 - ...ibana-plugin-core-server.auditorfactory.md | 20 - ...bana-plugin-core-server.audittrailsetup.md | 18 - ...in-core-server.audittrailsetup.register.md | 24 - ...bana-plugin-core-server.audittrailstart.md | 11 - ...plugin-core-server.coresetup.audittrail.md | 13 - .../kibana-plugin-core-server.coresetup.md | 1 - ...plugin-core-server.corestart.audittrail.md | 13 - .../kibana-plugin-core-server.corestart.md | 1 - ...erver.legacyclusterclient._constructor_.md | 3 +- ...-plugin-core-server.legacyclusterclient.md | 2 +- ...legacyscopedclusterclient._constructor_.md | 3 +- ...n-core-server.legacyscopedclusterclient.md | 2 +- .../core/server/kibana-plugin-core-server.md | 7 +- ...-core-server.requesthandlercontext.core.md | 1 - ...lugin-core-server.requesthandlercontext.md | 4 +- docs/settings/security-settings.asciidoc | 113 +++- docs/user/security/audit-logging.asciidoc | 137 ++++- .../audit_trail/audit_trail_service.mock.ts | 58 -- .../audit_trail/audit_trail_service.test.ts | 99 ---- .../server/audit_trail/audit_trail_service.ts | 69 --- src/core/server/audit_trail/index.ts | 21 - src/core/server/audit_trail/types.ts | 76 --- .../server/core_route_handler_context.test.ts | 35 -- src/core/server/core_route_handler_context.ts | 10 - .../elasticsearch_service.test.ts | 17 +- .../elasticsearch/elasticsearch_service.ts | 17 +- .../legacy/cluster_client.test.ts | 143 +---- .../elasticsearch/legacy/cluster_client.ts | 16 +- .../legacy/scoped_cluster_client.test.ts | 45 -- .../legacy/scoped_cluster_client.ts | 18 +- src/core/server/index.ts | 9 - src/core/server/internal_types.ts | 3 - src/core/server/legacy/legacy_service.test.ts | 2 - src/core/server/legacy/legacy_service.ts | 2 - src/core/server/mocks.ts | 6 - src/core/server/plugins/plugin_context.ts | 2 - src/core/server/server.api.md | 41 +- src/core/server/server.test.mocks.ts | 6 - src/core/server/server.test.ts | 8 - src/core/server/server.ts | 14 +- .../server/authorization/audit_logger.ts | 6 +- .../server/alerts_client_factory.test.ts | 4 +- .../server/authorization/audit_logger.ts | 6 +- x-pack/plugins/audit_trail/kibana.json | 10 - .../server/client/audit_trail_client.test.ts | 65 --- .../server/client/audit_trail_client.ts | 47 -- .../plugins/audit_trail/server/config.test.ts | 56 -- x-pack/plugins/audit_trail/server/config.ts | 22 - x-pack/plugins/audit_trail/server/index.ts | 13 - .../plugins/audit_trail/server/plugin.test.ts | 125 ---- x-pack/plugins/audit_trail/server/plugin.ts | 97 ---- x-pack/plugins/audit_trail/server/types.ts | 17 - .../server/audit/audit_logger.ts | 4 +- x-pack/plugins/security/README.md | 91 ++- .../server/audit/audit_events.test.ts | 204 +++++++ .../security/server/audit/audit_events.ts | 244 ++++++++ .../server/audit/audit_service.test.ts | 545 ++++++++++++++---- .../security/server/audit/audit_service.ts | 209 ++++++- .../security/server/audit/index.mock.ts | 3 + x-pack/plugins/security/server/audit/index.ts | 12 +- .../server/audit/security_audit_logger.ts | 16 +- .../authentication/authenticator.test.ts | 67 ++- .../server/authentication/authenticator.ts | 21 +- .../server/authentication/index.test.ts | 10 +- .../security/server/authentication/index.ts | 11 +- x-pack/plugins/security/server/config.test.ts | 228 ++++---- x-pack/plugins/security/server/config.ts | 29 +- x-pack/plugins/security/server/index.ts | 2 +- x-pack/plugins/security/server/plugin.test.ts | 1 + x-pack/plugins/security/server/plugin.ts | 41 +- .../security/server/saved_objects/index.ts | 11 +- ...ecure_saved_objects_client_wrapper.test.ts | 277 +++++++-- .../secure_saved_objects_client_wrapper.ts | 334 +++++++++-- .../plugins/spaces/server/lib/audit_logger.ts | 6 +- x-pack/scripts/functional_tests.js | 1 + x-pack/test/plugin_functional/config.ts | 7 - .../audit_trail_test/server/.gitignore | 1 - .../plugins/audit_trail_test/server/plugin.ts | 65 --- .../test_suites/audit_trail/index.ts | 129 ----- .../security_api_integration/audit.config.ts | 37 ++ .../fixtures/audit/audit_log}/kibana.json | 4 +- .../fixtures/audit/audit_log}/server/index.ts | 0 .../fixtures/audit/audit_log/server/plugin.ts | 20 + .../tests/audit/audit_log.ts | 118 ++++ .../tests/audit/index.ts | 14 + 93 files changed, 2452 insertions(+), 2025 deletions(-) delete mode 100644 docs/development/core/server/kibana-plugin-core-server.auditableevent.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.auditableevent.message.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.auditableevent.type.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.auditor.add.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.auditor.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.auditor.withauditscope.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.auditorfactory.asscoped.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.auditorfactory.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.audittrailsetup.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.audittrailsetup.register.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.audittrailstart.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.coresetup.audittrail.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.corestart.audittrail.md delete mode 100644 src/core/server/audit_trail/audit_trail_service.mock.ts delete mode 100644 src/core/server/audit_trail/audit_trail_service.test.ts delete mode 100644 src/core/server/audit_trail/audit_trail_service.ts delete mode 100644 src/core/server/audit_trail/index.ts delete mode 100644 src/core/server/audit_trail/types.ts delete mode 100644 x-pack/plugins/audit_trail/kibana.json delete mode 100644 x-pack/plugins/audit_trail/server/client/audit_trail_client.test.ts delete mode 100644 x-pack/plugins/audit_trail/server/client/audit_trail_client.ts delete mode 100644 x-pack/plugins/audit_trail/server/config.test.ts delete mode 100644 x-pack/plugins/audit_trail/server/config.ts delete mode 100644 x-pack/plugins/audit_trail/server/index.ts delete mode 100644 x-pack/plugins/audit_trail/server/plugin.test.ts delete mode 100644 x-pack/plugins/audit_trail/server/plugin.ts delete mode 100644 x-pack/plugins/audit_trail/server/types.ts create mode 100644 x-pack/plugins/security/server/audit/audit_events.test.ts create mode 100644 x-pack/plugins/security/server/audit/audit_events.ts delete mode 100644 x-pack/test/plugin_functional/plugins/audit_trail_test/server/.gitignore delete mode 100644 x-pack/test/plugin_functional/plugins/audit_trail_test/server/plugin.ts delete mode 100644 x-pack/test/plugin_functional/test_suites/audit_trail/index.ts create mode 100644 x-pack/test/security_api_integration/audit.config.ts rename x-pack/test/{plugin_functional/plugins/audit_trail_test => security_api_integration/fixtures/audit/audit_log}/kibana.json (62%) rename x-pack/test/{plugin_functional/plugins/audit_trail_test => security_api_integration/fixtures/audit/audit_log}/server/index.ts (100%) create mode 100644 x-pack/test/security_api_integration/fixtures/audit/audit_log/server/plugin.ts create mode 100644 x-pack/test/security_api_integration/tests/audit/audit_log.ts create mode 100644 x-pack/test/security_api_integration/tests/audit/index.ts diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 3e849ca80db7..8e08c3806446 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -286,10 +286,6 @@ which will load the visualization's editor. |To access an elasticsearch instance that has live data you have two options: -|{kib-repo}blob/{branch}/x-pack/plugins/audit_trail[auditTrail] -|WARNING: Missing README. - - |{kib-repo}blob/{branch}/x-pack/plugins/beats_management/readme.md[beatsManagement] |Notes: Failure to have auth enabled in Kibana will make for a broken UI. UI-based errors not yet in place @@ -469,7 +465,8 @@ Elastic. |{kib-repo}blob/{branch}/x-pack/plugins/security/README.md[security] -|See Configuring security in Kibana. +|See Configuring security in +Kibana. |{kib-repo}blob/{branch}/x-pack/plugins/security_solution/README.md[securitySolution] diff --git a/docs/development/core/server/kibana-plugin-core-server.auditableevent.md b/docs/development/core/server/kibana-plugin-core-server.auditableevent.md deleted file mode 100644 index aa109c506488..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.auditableevent.md +++ /dev/null @@ -1,25 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AuditableEvent](./kibana-plugin-core-server.auditableevent.md) - -## AuditableEvent interface - -Event to audit. - -Signature: - -```typescript -export interface AuditableEvent -``` - -## Remarks - -Not a complete interface. - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [message](./kibana-plugin-core-server.auditableevent.message.md) | string | | -| [type](./kibana-plugin-core-server.auditableevent.type.md) | string | | - diff --git a/docs/development/core/server/kibana-plugin-core-server.auditableevent.message.md b/docs/development/core/server/kibana-plugin-core-server.auditableevent.message.md deleted file mode 100644 index 3ac4167c6998..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.auditableevent.message.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AuditableEvent](./kibana-plugin-core-server.auditableevent.md) > [message](./kibana-plugin-core-server.auditableevent.message.md) - -## AuditableEvent.message property - -Signature: - -```typescript -message: string; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.auditableevent.type.md b/docs/development/core/server/kibana-plugin-core-server.auditableevent.type.md deleted file mode 100644 index 374874836668..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.auditableevent.type.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AuditableEvent](./kibana-plugin-core-server.auditableevent.md) > [type](./kibana-plugin-core-server.auditableevent.type.md) - -## AuditableEvent.type property - -Signature: - -```typescript -type: string; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.auditor.add.md b/docs/development/core/server/kibana-plugin-core-server.auditor.add.md deleted file mode 100644 index 40245a93753f..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.auditor.add.md +++ /dev/null @@ -1,36 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [Auditor](./kibana-plugin-core-server.auditor.md) > [add](./kibana-plugin-core-server.auditor.add.md) - -## Auditor.add() method - -Add a record to audit log. Service attaches to a log record: - metadata about an end-user initiating an operation - scope name, if presents - -Signature: - -```typescript -add(event: AuditableEvent): void; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| event | AuditableEvent | | - -Returns: - -`void` - -## Example - -How to add a record in audit log: - -```typescript -router.get({ path: '/my_endpoint', validate: false }, async (context, request, response) => { - context.core.auditor.withAuditScope('my_plugin_operation'); - const value = await context.core.elasticsearch.legacy.client.callAsCurrentUser('...'); - context.core.add({ type: 'operation.type', message: 'perform an operation in ... endpoint' }); - -``` - diff --git a/docs/development/core/server/kibana-plugin-core-server.auditor.md b/docs/development/core/server/kibana-plugin-core-server.auditor.md deleted file mode 100644 index 191a34df647a..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.auditor.md +++ /dev/null @@ -1,21 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [Auditor](./kibana-plugin-core-server.auditor.md) - -## Auditor interface - -Provides methods to log user actions and access events. - -Signature: - -```typescript -export interface Auditor -``` - -## Methods - -| Method | Description | -| --- | --- | -| [add(event)](./kibana-plugin-core-server.auditor.add.md) | Add a record to audit log. Service attaches to a log record: - metadata about an end-user initiating an operation - scope name, if presents | -| [withAuditScope(name)](./kibana-plugin-core-server.auditor.withauditscope.md) | Add a high-level scope name for logged events. It helps to identify the root cause of low-level events. | - diff --git a/docs/development/core/server/kibana-plugin-core-server.auditor.withauditscope.md b/docs/development/core/server/kibana-plugin-core-server.auditor.withauditscope.md deleted file mode 100644 index 0ae0c48ab92f..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.auditor.withauditscope.md +++ /dev/null @@ -1,24 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [Auditor](./kibana-plugin-core-server.auditor.md) > [withAuditScope](./kibana-plugin-core-server.auditor.withauditscope.md) - -## Auditor.withAuditScope() method - -Add a high-level scope name for logged events. It helps to identify the root cause of low-level events. - -Signature: - -```typescript -withAuditScope(name: string): void; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| name | string | | - -Returns: - -`void` - diff --git a/docs/development/core/server/kibana-plugin-core-server.auditorfactory.asscoped.md b/docs/development/core/server/kibana-plugin-core-server.auditorfactory.asscoped.md deleted file mode 100644 index 4a60931e6094..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.auditorfactory.asscoped.md +++ /dev/null @@ -1,22 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AuditorFactory](./kibana-plugin-core-server.auditorfactory.md) > [asScoped](./kibana-plugin-core-server.auditorfactory.asscoped.md) - -## AuditorFactory.asScoped() method - -Signature: - -```typescript -asScoped(request: KibanaRequest): Auditor; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| request | KibanaRequest | | - -Returns: - -`Auditor` - diff --git a/docs/development/core/server/kibana-plugin-core-server.auditorfactory.md b/docs/development/core/server/kibana-plugin-core-server.auditorfactory.md deleted file mode 100644 index fd4760caa355..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.auditorfactory.md +++ /dev/null @@ -1,20 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AuditorFactory](./kibana-plugin-core-server.auditorfactory.md) - -## AuditorFactory interface - -Creates [Auditor](./kibana-plugin-core-server.auditor.md) instance bound to the current user credentials. - -Signature: - -```typescript -export interface AuditorFactory -``` - -## Methods - -| Method | Description | -| --- | --- | -| [asScoped(request)](./kibana-plugin-core-server.auditorfactory.asscoped.md) | | - diff --git a/docs/development/core/server/kibana-plugin-core-server.audittrailsetup.md b/docs/development/core/server/kibana-plugin-core-server.audittrailsetup.md deleted file mode 100644 index 50885232a088..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.audittrailsetup.md +++ /dev/null @@ -1,18 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md) - -## AuditTrailSetup interface - -Signature: - -```typescript -export interface AuditTrailSetup -``` - -## Methods - -| Method | Description | -| --- | --- | -| [register(auditor)](./kibana-plugin-core-server.audittrailsetup.register.md) | Register a custom [AuditorFactory](./kibana-plugin-core-server.auditorfactory.md) implementation. | - diff --git a/docs/development/core/server/kibana-plugin-core-server.audittrailsetup.register.md b/docs/development/core/server/kibana-plugin-core-server.audittrailsetup.register.md deleted file mode 100644 index 36695844ced7..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.audittrailsetup.register.md +++ /dev/null @@ -1,24 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md) > [register](./kibana-plugin-core-server.audittrailsetup.register.md) - -## AuditTrailSetup.register() method - -Register a custom [AuditorFactory](./kibana-plugin-core-server.auditorfactory.md) implementation. - -Signature: - -```typescript -register(auditor: AuditorFactory): void; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| auditor | AuditorFactory | | - -Returns: - -`void` - diff --git a/docs/development/core/server/kibana-plugin-core-server.audittrailstart.md b/docs/development/core/server/kibana-plugin-core-server.audittrailstart.md deleted file mode 100644 index 4fb9f5cb9354..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.audittrailstart.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AuditTrailStart](./kibana-plugin-core-server.audittrailstart.md) - -## AuditTrailStart type - -Signature: - -```typescript -export declare type AuditTrailStart = AuditorFactory; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.audittrail.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.audittrail.md deleted file mode 100644 index 1aa7a75b7a08..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.audittrail.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CoreSetup](./kibana-plugin-core-server.coresetup.md) > [auditTrail](./kibana-plugin-core-server.coresetup.audittrail.md) - -## CoreSetup.auditTrail property - -[AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md) - -Signature: - -```typescript -auditTrail: AuditTrailSetup; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.md index 75da8df2ae15..7a733cc34dac 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.md @@ -16,7 +16,6 @@ export interface CoreSetupAuditTrailSetup | [AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md) | | [capabilities](./kibana-plugin-core-server.coresetup.capabilities.md) | CapabilitiesSetup | [CapabilitiesSetup](./kibana-plugin-core-server.capabilitiessetup.md) | | [context](./kibana-plugin-core-server.coresetup.context.md) | ContextSetup | [ContextSetup](./kibana-plugin-core-server.contextsetup.md) | | [elasticsearch](./kibana-plugin-core-server.coresetup.elasticsearch.md) | ElasticsearchServiceSetup | [ElasticsearchServiceSetup](./kibana-plugin-core-server.elasticsearchservicesetup.md) | diff --git a/docs/development/core/server/kibana-plugin-core-server.corestart.audittrail.md b/docs/development/core/server/kibana-plugin-core-server.corestart.audittrail.md deleted file mode 100644 index 879e0df83619..000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.corestart.audittrail.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CoreStart](./kibana-plugin-core-server.corestart.md) > [auditTrail](./kibana-plugin-core-server.corestart.audittrail.md) - -## CoreStart.auditTrail property - -[AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md) - -Signature: - -```typescript -auditTrail: AuditTrailStart; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.corestart.md b/docs/development/core/server/kibana-plugin-core-server.corestart.md index 0d5474fae5e1..f98088648689 100644 --- a/docs/development/core/server/kibana-plugin-core-server.corestart.md +++ b/docs/development/core/server/kibana-plugin-core-server.corestart.md @@ -16,7 +16,6 @@ export interface CoreStart | Property | Type | Description | | --- | --- | --- | -| [auditTrail](./kibana-plugin-core-server.corestart.audittrail.md) | AuditTrailStart | [AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md) | | [capabilities](./kibana-plugin-core-server.corestart.capabilities.md) | CapabilitiesStart | [CapabilitiesStart](./kibana-plugin-core-server.capabilitiesstart.md) | | [elasticsearch](./kibana-plugin-core-server.corestart.elasticsearch.md) | ElasticsearchServiceStart | [ElasticsearchServiceStart](./kibana-plugin-core-server.elasticsearchservicestart.md) | | [http](./kibana-plugin-core-server.corestart.http.md) | HttpServiceStart | [HttpServiceStart](./kibana-plugin-core-server.httpservicestart.md) | diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient._constructor_.md b/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient._constructor_.md index 6a56d31bbd55..823f34bd7dd2 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient._constructor_.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `LegacyClusterClient` class Signature: ```typescript -constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuditorFactory: () => AuditorFactory, getAuthHeaders?: GetAuthHeaders); +constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders); ``` ## Parameters @@ -18,6 +18,5 @@ constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuditorFact | --- | --- | --- | | config | LegacyElasticsearchClientConfig | | | log | Logger | | -| getAuditorFactory | () => AuditorFactory | | | getAuthHeaders | GetAuthHeaders | | diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient.md b/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient.md index 668d0b2866a2..d24aeb44ca86 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient.md @@ -21,7 +21,7 @@ export declare class LegacyClusterClient implements ILegacyClusterClient | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(config, log, getAuditorFactory, getAuthHeaders)](./kibana-plugin-core-server.legacyclusterclient._constructor_.md) | | Constructs a new instance of the LegacyClusterClient class | +| [(constructor)(config, log, getAuthHeaders)](./kibana-plugin-core-server.legacyclusterclient._constructor_.md) | | Constructs a new instance of the LegacyClusterClient class | ## Properties diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyscopedclusterclient._constructor_.md b/docs/development/core/server/kibana-plugin-core-server.legacyscopedclusterclient._constructor_.md index ffadab765660..bd1cd1e9f3d9 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacyscopedclusterclient._constructor_.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacyscopedclusterclient._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `LegacyScopedClusterClient` class Signature: ```typescript -constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller, headers?: Headers | undefined, auditor?: Auditor | undefined); +constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller, headers?: Headers | undefined); ``` ## Parameters @@ -19,5 +19,4 @@ constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller | internalAPICaller | LegacyAPICaller | | | scopedAPICaller | LegacyAPICaller | | | headers | Headers | undefined | | -| auditor | Auditor | undefined | | diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyscopedclusterclient.md b/docs/development/core/server/kibana-plugin-core-server.legacyscopedclusterclient.md index 7f752d70921b..6b6649e833a9 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacyscopedclusterclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacyscopedclusterclient.md @@ -21,7 +21,7 @@ export declare class LegacyScopedClusterClient implements ILegacyScopedClusterCl | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(internalAPICaller, scopedAPICaller, headers, auditor)](./kibana-plugin-core-server.legacyscopedclusterclient._constructor_.md) | | Constructs a new instance of the LegacyScopedClusterClient class | +| [(constructor)(internalAPICaller, scopedAPICaller, headers)](./kibana-plugin-core-server.legacyscopedclusterclient._constructor_.md) | | Constructs a new instance of the LegacyScopedClusterClient class | ## Methods diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index a484c856ec01..29f522079491 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -53,10 +53,6 @@ The plugin integrates with the core system via lifecycle events: `setup` | [AppCategory](./kibana-plugin-core-server.appcategory.md) | A category definition for nav links to know where to sort them in the left hand nav | | [AssistanceAPIResponse](./kibana-plugin-core-server.assistanceapiresponse.md) | | | [AssistantAPIClientParams](./kibana-plugin-core-server.assistantapiclientparams.md) | | -| [AuditableEvent](./kibana-plugin-core-server.auditableevent.md) | Event to audit. | -| [Auditor](./kibana-plugin-core-server.auditor.md) | Provides methods to log user actions and access events. | -| [AuditorFactory](./kibana-plugin-core-server.auditorfactory.md) | Creates [Auditor](./kibana-plugin-core-server.auditor.md) instance bound to the current user credentials. | -| [AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md) | | | [Authenticated](./kibana-plugin-core-server.authenticated.md) | | | [AuthNotHandled](./kibana-plugin-core-server.authnothandled.md) | | | [AuthRedirected](./kibana-plugin-core-server.authredirected.md) | | @@ -132,7 +128,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [PluginConfigDescriptor](./kibana-plugin-core-server.pluginconfigdescriptor.md) | Describes a plugin configuration properties. | | [PluginInitializerContext](./kibana-plugin-core-server.plugininitializercontext.md) | Context that's available to plugins during initialization stage. | | [PluginManifest](./kibana-plugin-core-server.pluginmanifest.md) | Describes the set of required and optional properties plugin can define in its mandatory JSON manifest file. | -| [RequestHandlerContext](./kibana-plugin-core-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.legacy.client](./kibana-plugin-core-server.legacyscopedclusterclient.md) - The legacy Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request - [uiSettings.auditor](./kibana-plugin-core-server.auditor.md) - AuditTrail client scoped to the incoming request | +| [RequestHandlerContext](./kibana-plugin-core-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.legacy.client](./kibana-plugin-core-server.legacyscopedclusterclient.md) - The legacy Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request | | [RouteConfig](./kibana-plugin-core-server.routeconfig.md) | Route specific configuration. | | [RouteConfigOptions](./kibana-plugin-core-server.routeconfigoptions.md) | Additional route options. | | [RouteConfigOptionsBody](./kibana-plugin-core-server.routeconfigoptionsbody.md) | Additional body options for a route | @@ -223,7 +219,6 @@ The plugin integrates with the core system via lifecycle events: `setup` | Type Alias | Description | | --- | --- | | [AppenderConfigType](./kibana-plugin-core-server.appenderconfigtype.md) | | -| [AuditTrailStart](./kibana-plugin-core-server.audittrailstart.md) | | | [AuthenticationHandler](./kibana-plugin-core-server.authenticationhandler.md) | See [AuthToolkit](./kibana-plugin-core-server.authtoolkit.md). | | [AuthHeaders](./kibana-plugin-core-server.authheaders.md) | Auth Headers map | | [AuthResult](./kibana-plugin-core-server.authresult.md) | | diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md index 5b8492ec5ece..b195e9798916 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md @@ -21,6 +21,5 @@ core: { uiSettings: { client: IUiSettingsClient; }; - auditor: Auditor; }; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md index 4e530973f9d5..1de7313f2c40 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md @@ -6,7 +6,7 @@ Plugin specific context passed to a route handler. -Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.legacy.client](./kibana-plugin-core-server.legacyscopedclusterclient.md) - The legacy Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request - [uiSettings.auditor](./kibana-plugin-core-server.auditor.md) - AuditTrail client scoped to the incoming request +Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.legacy.client](./kibana-plugin-core-server.legacyscopedclusterclient.md) - The legacy Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request Signature: @@ -18,5 +18,5 @@ export interface RequestHandlerContext | Property | Type | Description | | --- | --- | --- | -| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
};
elasticsearch: {
client: IScopedClusterClient;
legacy: {
client: ILegacyScopedClusterClient;
};
};
uiSettings: {
client: IUiSettingsClient;
};
auditor: Auditor;
} | | +| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
};
elasticsearch: {
client: IScopedClusterClient;
legacy: {
client: ILegacyScopedClusterClient;
};
};
uiSettings: {
client: IUiSettingsClient;
};
} | | diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index c743aa43fab0..6b01094f7248 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -155,7 +155,7 @@ There is a very limited set of cases when you'd want to change these settings. F | `xpack.security.authc.http.autoSchemesEnabled` | Determines if HTTP authentication schemes used by the enabled authentication providers should be automatically supported during HTTP authentication. By default, this setting is set to `true`. -| `xpack.security.authc.http.schemes` +| `xpack.security.authc.http.schemes[]` | List of HTTP authentication schemes that {kib} HTTP authentication should support. By default, this setting is set to `['apikey']` to support HTTP authentication with <> scheme. |=== @@ -240,7 +240,6 @@ The format is a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', |=== -[float] [[security-encrypted-saved-objects-settings]] ==== Encrypted saved objects settings @@ -261,4 +260,112 @@ In high-availability deployments, make sure you use the same encryption and decr `keyRotation.decryptionOnlyKeys` | An optional list of previously used encryption keys. Like <>, these must be at least 32 characters in length. {kib} doesn't use these keys for encryption, but may still require them to decrypt some existing saved objects. Use this setting if you wish to change your encryption key, but don't want to lose access to saved objects that were previously encrypted with a different key. -|=== \ No newline at end of file +|=== + +[float] +[[audit-logging-settings]] +===== Audit logging settings + +You can enable audit logging to support compliance, accountability, and security. When enabled, {kib} will capture: + +- Who performed an action +- What action was performed +- When the action occurred + +For more details and a reference of audit events, refer to <>. + +[cols="2*<"] +|=== +| `xpack.security.audit.enabled` +| Set to `true` to enable audit logging for security events. *Default:* `false` +|=== + +[float] +[[ecs-audit-logging-settings]] +===== ECS audit logging settings + +To enable the <>, specify where you want to write the audit events using `xpack.security.audit.appender`. + +[cols="2*<"] +|=== +| `xpack.security.audit.appender` +| Optional. Specifies where audit logs should be written to and how they should be formatted. + +2+a| For example: + +[source,yaml] +---------------------------------------- +xpack.security.audit.appender: + kind: file + path: /path/to/audit.log + layout: + kind: json +---------------------------------------- + +| `xpack.security.audit.appender.kind` +| Required. Specifies where audit logs should be written to. Allowed values are `console` or `file`. +|=== + +[float] +[[audit-logging-file-appender]] +===== File appender + +The file appender can be configured using the following settings: + +[cols="2*<"] +|=== +| `xpack.security.audit.appender.path` +| Required. Full file path the log file should be written to. + +| `xpack.security.audit.appender.layout.kind` +| Required. Specifies how audit logs should be formatted. Allowed values are `json` or `pattern`. +|=== + +[float] +[[audit-logging-pattern-layout]] +===== Pattern layout + +The pattern layout can be configured using the following settings: + +[cols="2*<"] +|=== +| `xpack.security.audit.appender.layout.highlight` +| Optional. Set to `true` to enable highlighting log messages with colors. + +| `xpack.security.audit.appender.layout.pattern` +| Optional. Specifies how the log line should be formatted. *Default:* `[%date][%level][%logger]%meta %message` +|=== + +[float] +[[audit-logging-ignore-filters]] +===== Ignore filters + +[cols="2*<"] +|=== +| `xpack.security.audit.ignore_filters[]` +| List of filters that determine which events should be excluded from the audit log. An event will get filtered out if at least one of the provided filters matches. + +2+a| For example: + +[source,yaml] +---------------------------------------- +xpack.security.audit.ignore_filters: +- actions: [http_request] <1> +- categories: [database] + types: [creation, change, deletion] <2> +---------------------------------------- +<1> Filters out HTTP request events +<2> Filters out any data write events + +| `xpack.security.audit.ignore_filters[].actions[]` +| List of values matched against the `event.action` field of an audit event. Refer to <> for a list of available events. + +| `xpack.security.audit.ignore_filters[].categories[]` +| List of values matched against the `event.category` field of an audit event. Refer to https://www.elastic.co/guide/en/ecs/1.5/ecs-allowed-values-event-category.html[ECS categorization field] for allowed values. + +| `xpack.security.audit.ignore_filters[].types[]` +| List of values matched against the `event.type` field of an audit event. Refer to https://www.elastic.co/guide/en/ecs/1.5/ecs-allowed-values-event-type.html[ECS type field] for allowed values. + +| `xpack.security.audit.ignore_filters[].outcomes[]` +| List of values matched against the `event.outcome` field of an audit event. Refer to https://www.elastic.co/guide/en/ecs/1.5/ecs-allowed-values-event-outcome.html[ECS outcome field] for allowed values. +|=== diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index a7359af38c1c..d4370c4d840c 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -3,30 +3,30 @@ === Audit logs You can enable auditing to keep track of security-related events such as -authorization success and failures. Logging these events enables you -to monitor {kib} for suspicious activity and provides evidence in the -event of an attack. +authorization success and failures. Logging these events enables you to monitor +{kib} for suspicious activity and provides evidence in the event of an attack. -Use the {kib} audit logs in conjunction with {es}'s -audit logging to get a holistic view of all security related events. -{kib} defers to {es}'s security model for authentication, data -index authorization, and features that are driven by cluster-wide privileges. -For more information on enabling audit logging in {es}, see -{ref}/auditing.html[Auditing security events]. +Use the {kib} audit logs in conjunction with {ref}/enable-audit-logging.html[{es} audit logging] to get a +holistic view of all security related events. {kib} defers to the {es} security +model for authentication, data index authorization, and features that are driven +by cluster-wide privileges. For more information on enabling audit logging in +{es}, refer to {ref}/auditing.html[Auditing security events]. [IMPORTANT] ============================================================================ -Audit logs are **disabled** by default. To enable this functionality, you -must set `xpack.security.audit.enabled` to `true` in `kibana.yml`. +Audit logs are **disabled** by default. To enable this functionality, you must +set `xpack.security.audit.enabled` to `true` in `kibana.yml`. ============================================================================ -Audit logging uses the standard {kib} logging output, which can be configured -in the `kibana.yml` and is discussed in <>. +The current version of the audit logger uses the standard {kib} logging output, +which can be configured in `kibana.yml`. For more information, refer to <>. +The audit logger uses a separate logger and can be configured using +the options in <>. ==== Audit event types -When you are auditing security events, each request can generate -multiple audit events. The following is a list of the events that can be generated: +When you are auditing security events, each request can generate multiple audit +events. The following is a list of the events that can be generated: |====== | `saved_objects_authorization_success` | Logged when a user is authorized to access a saved @@ -34,3 +34,110 @@ multiple audit events. The following is a list of the events that can be generat | `saved_objects_authorization_failure` | Logged when a user isn't authorized to access a saved objects when using a role with <> |====== + +[[xpack-security-ecs-audit-logging]] +==== ECS audit events + +[IMPORTANT] +============================================================================ +The following events are only logged if the ECS audit logger is enabled. +For information on how to configure `xpack.security.audit.appender`, refer to +<>. +============================================================================ + +Refer to the table of events that can be logged for auditing purposes. + +Each event is broken down into `category`, `type`, `action` and `outcome` fields +to make it easy to filter, query and aggregate the resulting logs. + +[NOTE] +============================================================================ +To ensure that a record of every operation is persisted even in case of an +unexpected error, asynchronous write operations are logged immediately after all +authorization checks have passed, but before the response from {es} is received. +Refer to the corresponding {es} logs for potential write errors. +============================================================================ + + +[cols="3*<"] +|====== +3+a| +===== Category: authentication + +| *Action* +| *Outcome* +| *Description* + +.2+| `user_login` +| `success` | User has logged in successfully. +| `failure` | Failed login attempt (e.g. due to invalid credentials). + +3+a| +===== Category: database +====== Type: creation + +| *Action* +| *Outcome* +| *Description* + +.2+| `saved_object_create` +| `unknown` | User is creating a saved object. +| `failure` | User is not authorized to create a saved object. + + +3+a| +====== Type: change + +| *Action* +| *Outcome* +| *Description* + +.2+| `saved_object_update` +| `unknown` | User is updating a saved object. +| `failure` | User is not authorized to update a saved object. + +.2+| `saved_object_add_to_spaces` +| `unknown` | User is adding a saved object to other spaces. +| `failure` | User is not authorized to add a saved object to other spaces. + +.2+| `saved_object_delete_from_spaces` +| `unknown` | User is removing a saved object from other spaces. +| `failure` | User is not authorized to remove a saved object from other spaces. + +3+a| +====== Type: deletion + +| *Action* +| *Outcome* +| *Description* + +.2+| `saved_object_delete` +| `unknown` | User is deleting a saved object. +| `failure` | User is not authorized to delete a saved object. + +3+a| +====== Type: access + +| *Action* +| *Outcome* +| *Description* + +.2+| `saved_object_get` +| `success` | User has accessed a saved object. +| `failure` | User is not authorized to access a saved object. + +.2+| `saved_object_find` +| `success` | User has accessed a saved object as part of a search operation. +| `failure` | User is not authorized to search for saved objects. + + +3+a| +===== Category: web + +| *Action* +| *Outcome* +| *Description* + +| `http_request` +| `unknown` | User is making an HTTP request. +|====== diff --git a/src/core/server/audit_trail/audit_trail_service.mock.ts b/src/core/server/audit_trail/audit_trail_service.mock.ts deleted file mode 100644 index 4c9c06484075..000000000000 --- a/src/core/server/audit_trail/audit_trail_service.mock.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import type { PublicMethodsOf } from '@kbn/utility-types'; -import { AuditTrailSetup, AuditTrailStart, Auditor } from './types'; -import { AuditTrailService } from './audit_trail_service'; - -const createSetupContractMock = () => { - const mocked: jest.Mocked = { - register: jest.fn(), - }; - return mocked; -}; - -const createAuditorMock = () => { - const mocked: jest.Mocked = { - add: jest.fn(), - withAuditScope: jest.fn(), - }; - return mocked; -}; - -const createStartContractMock = () => { - const mocked: jest.Mocked = { - asScoped: jest.fn(), - }; - mocked.asScoped.mockReturnValue(createAuditorMock()); - return mocked; -}; - -const createServiceMock = (): jest.Mocked> => ({ - setup: jest.fn().mockResolvedValue(createSetupContractMock()), - start: jest.fn().mockResolvedValue(createStartContractMock()), - stop: jest.fn(), -}); - -export const auditTrailServiceMock = { - create: createServiceMock, - createSetupContract: createSetupContractMock, - createStartContract: createStartContractMock, - createAuditorFactory: createStartContractMock, - createAuditor: createAuditorMock, -}; diff --git a/src/core/server/audit_trail/audit_trail_service.test.ts b/src/core/server/audit_trail/audit_trail_service.test.ts deleted file mode 100644 index 63b45b62275b..000000000000 --- a/src/core/server/audit_trail/audit_trail_service.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { AuditTrailService } from './audit_trail_service'; -import { AuditorFactory } from './types'; -import { mockCoreContext } from '../core_context.mock'; -import { httpServerMock } from '../http/http_server.mocks'; - -describe('AuditTrailService', () => { - const coreContext = mockCoreContext.create(); - - describe('#setup', () => { - describe('register', () => { - it('throws if registered the same auditor factory twice', () => { - const auditTrail = new AuditTrailService(coreContext); - const { register } = auditTrail.setup(); - const auditorFactory: AuditorFactory = { - asScoped() { - return { add: () => undefined, withAuditScope: (() => {}) as any }; - }, - }; - register(auditorFactory); - expect(() => register(auditorFactory)).toThrowErrorMatchingInlineSnapshot( - `"An auditor factory has been already registered"` - ); - }); - }); - }); - - describe('#start', () => { - describe('asScoped', () => { - it('initialize every auditor with a request', () => { - const scopedMock = jest.fn(() => ({ add: jest.fn(), withAuditScope: jest.fn() })); - const auditorFactory = { asScoped: scopedMock }; - - const auditTrail = new AuditTrailService(coreContext); - const { register } = auditTrail.setup(); - register(auditorFactory); - - const { asScoped } = auditTrail.start(); - const kibanaRequest = httpServerMock.createKibanaRequest(); - asScoped(kibanaRequest); - - expect(scopedMock).toHaveBeenCalledWith(kibanaRequest); - }); - - it('passes auditable event to an auditor', () => { - const addEventMock = jest.fn(); - const auditorFactory = { - asScoped() { - return { add: addEventMock, withAuditScope: jest.fn() }; - }, - }; - - const auditTrail = new AuditTrailService(coreContext); - const { register } = auditTrail.setup(); - register(auditorFactory); - - const { asScoped } = auditTrail.start(); - const kibanaRequest = httpServerMock.createKibanaRequest(); - const auditor = asScoped(kibanaRequest); - const message = { - type: 'foo', - message: 'bar', - }; - auditor.add(message); - - expect(addEventMock).toHaveBeenLastCalledWith(message); - }); - - describe('return the same auditor instance for the same KibanaRequest', () => { - const auditTrail = new AuditTrailService(coreContext); - auditTrail.setup(); - const { asScoped } = auditTrail.start(); - - const rawRequest1 = httpServerMock.createKibanaRequest(); - const rawRequest2 = httpServerMock.createKibanaRequest(); - expect(asScoped(rawRequest1)).toBe(asScoped(rawRequest1)); - expect(asScoped(rawRequest1)).not.toBe(asScoped(rawRequest2)); - }); - }); - }); -}); diff --git a/src/core/server/audit_trail/audit_trail_service.ts b/src/core/server/audit_trail/audit_trail_service.ts deleted file mode 100644 index f1841858dbc9..000000000000 --- a/src/core/server/audit_trail/audit_trail_service.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { CoreService } from '../../types'; -import { CoreContext } from '../core_context'; -import { Logger } from '../logging'; -import { KibanaRequest, LegacyRequest } from '../http'; -import { ensureRawRequest } from '../http/router'; -import { Auditor, AuditorFactory, AuditTrailSetup, AuditTrailStart } from './types'; - -const defaultAuditorFactory: AuditorFactory = { - asScoped() { - return { - add() {}, - withAuditScope() {}, - }; - }, -}; - -export class AuditTrailService implements CoreService { - private readonly log: Logger; - private auditor: AuditorFactory = defaultAuditorFactory; - private readonly auditors = new WeakMap(); - - constructor(core: CoreContext) { - this.log = core.logger.get('audit_trail'); - } - - setup() { - return { - register: (auditor: AuditorFactory) => { - if (this.auditor !== defaultAuditorFactory) { - throw new Error('An auditor factory has been already registered'); - } - this.auditor = auditor; - this.log.debug('An auditor factory has been registered'); - }, - }; - } - - start() { - return { - asScoped: (request: KibanaRequest) => { - const key = ensureRawRequest(request); - if (!this.auditors.has(key)) { - this.auditors.set(key, this.auditor!.asScoped(request)); - } - return this.auditors.get(key)!; - }, - }; - } - - stop() {} -} diff --git a/src/core/server/audit_trail/index.ts b/src/core/server/audit_trail/index.ts deleted file mode 100644 index 3f01e6fa3582..000000000000 --- a/src/core/server/audit_trail/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export { AuditTrailService } from './audit_trail_service'; -export { AuditableEvent, Auditor, AuditorFactory, AuditTrailSetup, AuditTrailStart } from './types'; diff --git a/src/core/server/audit_trail/types.ts b/src/core/server/audit_trail/types.ts deleted file mode 100644 index b3c1fc3c222f..000000000000 --- a/src/core/server/audit_trail/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { KibanaRequest } from '../http'; - -/** - * Event to audit. - * @public - * - * @remarks - * Not a complete interface. - */ -export interface AuditableEvent { - message: string; - type: string; -} - -/** - * Provides methods to log user actions and access events. - * @public - */ -export interface Auditor { - /** - * Add a record to audit log. - * Service attaches to a log record: - * - metadata about an end-user initiating an operation - * - scope name, if presents - * - * @example - * How to add a record in audit log: - * ```typescript - * router.get({ path: '/my_endpoint', validate: false }, async (context, request, response) => { - * context.core.auditor.withAuditScope('my_plugin_operation'); - * const value = await context.core.elasticsearch.legacy.client.callAsCurrentUser('...'); - * context.core.add({ type: 'operation.type', message: 'perform an operation in ... endpoint' }); - * ``` - */ - add(event: AuditableEvent): void; - /** - * Add a high-level scope name for logged events. - * It helps to identify the root cause of low-level events. - */ - withAuditScope(name: string): void; -} - -/** - * Creates {@link Auditor} instance bound to the current user credentials. - * @public - */ -export interface AuditorFactory { - asScoped(request: KibanaRequest): Auditor; -} - -export interface AuditTrailSetup { - /** - * Register a custom {@link AuditorFactory} implementation. - */ - register(auditor: AuditorFactory): void; -} - -export type AuditTrailStart = AuditorFactory; diff --git a/src/core/server/core_route_handler_context.test.ts b/src/core/server/core_route_handler_context.test.ts index 563e337e6c7e..d4599d91c1b9 100644 --- a/src/core/server/core_route_handler_context.test.ts +++ b/src/core/server/core_route_handler_context.test.ts @@ -19,41 +19,6 @@ import { CoreRouteHandlerContext } from './core_route_handler_context'; import { coreMock, httpServerMock } from './mocks'; -describe('#auditor', () => { - test('returns the results of coreStart.audiTrail.asScoped', () => { - const request = httpServerMock.createKibanaRequest(); - const coreStart = coreMock.createInternalStart(); - const context = new CoreRouteHandlerContext(coreStart, request); - - const auditor = context.auditor; - expect(auditor).toBe(coreStart.auditTrail.asScoped.mock.results[0].value); - }); - - test('lazily created', () => { - const request = httpServerMock.createKibanaRequest(); - const coreStart = coreMock.createInternalStart(); - const context = new CoreRouteHandlerContext(coreStart, request); - - expect(coreStart.auditTrail.asScoped).not.toHaveBeenCalled(); - const auditor = context.auditor; - expect(coreStart.auditTrail.asScoped).toHaveBeenCalled(); - expect(auditor).toBeDefined(); - }); - - test('only creates one instance', () => { - const request = httpServerMock.createKibanaRequest(); - const coreStart = coreMock.createInternalStart(); - const context = new CoreRouteHandlerContext(coreStart, request); - - const auditor1 = context.auditor; - const auditor2 = context.auditor; - expect(coreStart.auditTrail.asScoped.mock.calls.length).toBe(1); - const mockResult = coreStart.auditTrail.asScoped.mock.results[0].value; - expect(auditor1).toBe(mockResult); - expect(auditor2).toBe(mockResult); - }); -}); - describe('#elasticsearch', () => { describe('#client', () => { test('returns the results of coreStart.elasticsearch.client.asScoped', () => { diff --git a/src/core/server/core_route_handler_context.ts b/src/core/server/core_route_handler_context.ts index 8a182a523f52..520c5bd3f685 100644 --- a/src/core/server/core_route_handler_context.ts +++ b/src/core/server/core_route_handler_context.ts @@ -27,7 +27,6 @@ import { IScopedClusterClient, LegacyScopedClusterClient, } from './elasticsearch'; -import { Auditor } from './audit_trail'; import { InternalUiSettingsServiceStart, IUiSettingsClient } from './ui_settings'; class CoreElasticsearchRouteHandlerContext { @@ -99,8 +98,6 @@ class CoreUiSettingsRouteHandlerContext { } export class CoreRouteHandlerContext { - #auditor?: Auditor; - readonly elasticsearch: CoreElasticsearchRouteHandlerContext; readonly savedObjects: CoreSavedObjectsRouteHandlerContext; readonly uiSettings: CoreUiSettingsRouteHandlerContext; @@ -122,11 +119,4 @@ export class CoreRouteHandlerContext { this.savedObjects ); } - - public get auditor() { - if (this.#auditor == null) { - this.#auditor = this.coreStart.auditTrail.asScoped(this.request); - } - return this.#auditor; - } } diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index ce82410f6061..e527fdb91597 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -26,7 +26,6 @@ import { configServiceMock, getEnvOptions } from '../config/mocks'; import { CoreContext } from '../core_context'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { httpServiceMock } from '../http/http_service.mock'; -import { auditTrailServiceMock } from '../audit_trail/audit_trail_service.mock'; import { ElasticsearchConfig } from './elasticsearch_config'; import { ElasticsearchService } from './elasticsearch_service'; import { elasticsearchServiceMock } from './elasticsearch_service.mock'; @@ -41,9 +40,6 @@ const configService = configServiceMock.create(); const setupDeps = { http: httpServiceMock.createInternalSetupContract(), }; -const startDeps = { - auditTrail: auditTrailServiceMock.createStartContract(), -}; configService.atPath.mockReturnValue( new BehaviorSubject({ hosts: ['http://1.2.3.4'], @@ -113,7 +109,6 @@ describe('#setup', () => { expect(MockLegacyClusterClient).toHaveBeenCalledWith( expect.objectContaining(customConfig), expect.objectContaining({ context: ['elasticsearch', 'some-custom-type'] }), - expect.any(Function), expect.any(Function) ); }); @@ -260,14 +255,14 @@ describe('#setup', () => { describe('#start', () => { it('throws if called before `setup`', async () => { - expect(() => elasticsearchService.start(startDeps)).rejects.toMatchInlineSnapshot( + expect(() => elasticsearchService.start()).rejects.toMatchInlineSnapshot( `[Error: ElasticsearchService needs to be setup before calling start]` ); }); it('returns elasticsearch client as a part of the contract', async () => { await elasticsearchService.setup(setupDeps); - const startContract = await elasticsearchService.start(startDeps); + const startContract = await elasticsearchService.start(); const client = startContract.client; expect(client.asInternalUser).toBe(mockClusterClientInstance.asInternalUser); @@ -276,7 +271,7 @@ describe('#start', () => { describe('#createClient', () => { it('allows to specify config properties', async () => { await elasticsearchService.setup(setupDeps); - const startContract = await elasticsearchService.start(startDeps); + const startContract = await elasticsearchService.start(); // reset all mocks called during setup phase MockClusterClient.mockClear(); @@ -295,7 +290,7 @@ describe('#start', () => { }); it('creates a new client on each call', async () => { await elasticsearchService.setup(setupDeps); - const startContract = await elasticsearchService.start(startDeps); + const startContract = await elasticsearchService.start(); // reset all mocks called during setup phase MockClusterClient.mockClear(); @@ -310,7 +305,7 @@ describe('#start', () => { it('falls back to elasticsearch default config values if property not specified', async () => { await elasticsearchService.setup(setupDeps); - const startContract = await elasticsearchService.start(startDeps); + const startContract = await elasticsearchService.start(); // reset all mocks called during setup phase MockClusterClient.mockClear(); @@ -347,7 +342,7 @@ describe('#start', () => { describe('#stop', () => { it('stops both legacy and new clients', async () => { await elasticsearchService.setup(setupDeps); - await elasticsearchService.start(startDeps); + await elasticsearchService.start(); await elasticsearchService.stop(); expect(mockLegacyClusterClientInstance.close).toHaveBeenCalledTimes(1); diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index 5d07840e8bda..a0b9e8c6f2bf 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -32,7 +32,6 @@ import { import { ClusterClient, ICustomClusterClient, ElasticsearchClientConfig } from './client'; import { ElasticsearchConfig, ElasticsearchConfigType } from './elasticsearch_config'; import { InternalHttpServiceSetup, GetAuthHeaders } from '../http/'; -import { AuditTrailStart, AuditorFactory } from '../audit_trail'; import { InternalElasticsearchServiceSetup, InternalElasticsearchServiceStart } from './types'; import { pollEsNodesVersion } from './version_check/ensure_es_version'; import { calculateStatus$ } from './status'; @@ -41,16 +40,11 @@ interface SetupDeps { http: InternalHttpServiceSetup; } -interface StartDeps { - auditTrail: AuditTrailStart; -} - /** @internal */ export class ElasticsearchService implements CoreService { private readonly log: Logger; private readonly config$: Observable; - private auditorFactory?: AuditorFactory; private stop$ = new Subject(); private kibanaVersion: string; private getAuthHeaders?: GetAuthHeaders; @@ -103,8 +97,7 @@ export class ElasticsearchService status$: calculateStatus$(esNodesCompatibility$), }; } - public async start({ auditTrail }: StartDeps): Promise { - this.auditorFactory = auditTrail; + public async start(): Promise { if (!this.legacyClient || !this.createLegacyCustomClient) { throw new Error('ElasticsearchService needs to be setup before calling start'); } @@ -153,15 +146,7 @@ export class ElasticsearchService return new LegacyClusterClient( config, this.coreContext.logger.get('elasticsearch', type), - this.getAuditorFactory, this.getAuthHeaders ); } - - private getAuditorFactory = () => { - if (!this.auditorFactory) { - throw new Error('auditTrail has not been initialized'); - } - return this.auditorFactory; - }; } diff --git a/src/core/server/elasticsearch/legacy/cluster_client.test.ts b/src/core/server/elasticsearch/legacy/cluster_client.test.ts index 745ef4304d0b..812f81a1affd 100644 --- a/src/core/server/elasticsearch/legacy/cluster_client.test.ts +++ b/src/core/server/elasticsearch/legacy/cluster_client.test.ts @@ -27,7 +27,6 @@ import { import { errors } from 'elasticsearch'; import { get } from 'lodash'; -import { auditTrailServiceMock } from '../../audit_trail/audit_trail_service.mock'; import { Logger } from '../../logging'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { httpServerMock } from '../../http/http_server.mocks'; @@ -43,11 +42,7 @@ test('#constructor creates client with parsed config', () => { const mockEsConfig = { apiVersion: 'es-version' } as any; const mockLogger = logger.get(); - const clusterClient = new LegacyClusterClient( - mockEsConfig, - mockLogger, - auditTrailServiceMock.createAuditorFactory - ); + const clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); expect(clusterClient).toBeDefined(); expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1); @@ -73,11 +68,7 @@ describe('#callAsInternalUser', () => { }; MockClient.mockImplementation(() => mockEsClientInstance); - clusterClient = new LegacyClusterClient( - { apiVersion: 'es-version' } as any, - logger.get(), - auditTrailServiceMock.createAuditorFactory - ); + clusterClient = new LegacyClusterClient({ apiVersion: 'es-version' } as any, logger.get()); }); test('fails if cluster client is closed', async () => { @@ -246,11 +237,7 @@ describe('#asScoped', () => { requestHeadersWhitelist: ['one', 'two'], } as any; - clusterClient = new LegacyClusterClient( - mockEsConfig, - mockLogger, - auditTrailServiceMock.createAuditorFactory - ); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); jest.clearAllMocks(); }); @@ -285,11 +272,7 @@ describe('#asScoped', () => { test('properly configures `ignoreCertAndKey` for various configurations', () => { // Config without SSL. - clusterClient = new LegacyClusterClient( - mockEsConfig, - mockLogger, - auditTrailServiceMock.createAuditorFactory - ); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); mockParseElasticsearchClientConfig.mockClear(); clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } })); @@ -302,11 +285,7 @@ describe('#asScoped', () => { // Config ssl.alwaysPresentCertificate === false mockEsConfig = { ...mockEsConfig, ssl: { alwaysPresentCertificate: false } } as any; - clusterClient = new LegacyClusterClient( - mockEsConfig, - mockLogger, - auditTrailServiceMock.createAuditorFactory - ); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); mockParseElasticsearchClientConfig.mockClear(); clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } })); @@ -319,11 +298,7 @@ describe('#asScoped', () => { // Config ssl.alwaysPresentCertificate === true mockEsConfig = { ...mockEsConfig, ssl: { alwaysPresentCertificate: true } } as any; - clusterClient = new LegacyClusterClient( - mockEsConfig, - mockLogger, - auditTrailServiceMock.createAuditorFactory - ); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); mockParseElasticsearchClientConfig.mockClear(); clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } })); @@ -344,8 +319,7 @@ describe('#asScoped', () => { expect(MockScopedClusterClient).toHaveBeenCalledWith( expect.any(Function), expect.any(Function), - { one: '1', two: '2' }, - expect.any(Object) + { one: '1', two: '2' } ); }); @@ -360,8 +334,7 @@ describe('#asScoped', () => { expect(MockScopedClusterClient).toHaveBeenCalledWith( expect.any(Function), expect.any(Function), - { 'x-opaque-id': 'alpha' }, - expect.any(Object) + { 'x-opaque-id': 'alpha' } ); }); @@ -383,142 +356,75 @@ describe('#asScoped', () => { }); test('does not fail when scope to not defined request', async () => { - clusterClient = new LegacyClusterClient( - mockEsConfig, - mockLogger, - auditTrailServiceMock.createAuditorFactory - ); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); clusterClient.asScoped(); expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); expect(MockScopedClusterClient).toHaveBeenCalledWith( expect.any(Function), expect.any(Function), - {}, - undefined + {} ); }); test('does not fail when scope to a request without headers', async () => { - clusterClient = new LegacyClusterClient( - mockEsConfig, - mockLogger, - auditTrailServiceMock.createAuditorFactory - ); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); clusterClient.asScoped({} as any); expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); expect(MockScopedClusterClient).toHaveBeenCalledWith( expect.any(Function), expect.any(Function), - {}, - undefined + {} ); }); test('calls getAuthHeaders and filters results for a real request', async () => { - clusterClient = new LegacyClusterClient( - mockEsConfig, - mockLogger, - auditTrailServiceMock.createAuditorFactory, - () => ({ - one: '1', - three: '3', - }) - ); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, () => ({ + one: '1', + three: '3', + })); clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { two: '2' } })); expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); expect(MockScopedClusterClient).toHaveBeenCalledWith( expect.any(Function), expect.any(Function), - { one: '1', two: '2' }, - expect.any(Object) + { one: '1', two: '2' } ); }); test('getAuthHeaders results rewrite extends a request headers', async () => { - clusterClient = new LegacyClusterClient( - mockEsConfig, - mockLogger, - auditTrailServiceMock.createAuditorFactory, - () => ({ one: 'foo' }) - ); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, () => ({ one: 'foo' })); clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1', two: '2' } })); expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); expect(MockScopedClusterClient).toHaveBeenCalledWith( expect.any(Function), expect.any(Function), - { one: 'foo', two: '2' }, - expect.any(Object) + { one: 'foo', two: '2' } ); }); test("doesn't call getAuthHeaders for a fake request", async () => { - clusterClient = new LegacyClusterClient( - mockEsConfig, - mockLogger, - auditTrailServiceMock.createAuditorFactory, - () => ({}) - ); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, () => ({})); clusterClient.asScoped({ headers: { one: 'foo' } }); expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); expect(MockScopedClusterClient).toHaveBeenCalledWith( expect.any(Function), expect.any(Function), - { one: 'foo' }, - undefined + { one: 'foo' } ); }); test('filters a fake request headers', async () => { - clusterClient = new LegacyClusterClient( - mockEsConfig, - mockLogger, - auditTrailServiceMock.createAuditorFactory - ); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); clusterClient.asScoped({ headers: { one: '1', two: '2', three: '3' } }); expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); expect(MockScopedClusterClient).toHaveBeenCalledWith( expect.any(Function), expect.any(Function), - { one: '1', two: '2' }, - undefined + { one: '1', two: '2' } ); }); - - describe('Auditor', () => { - it('creates Auditor for KibanaRequest', async () => { - const auditor = auditTrailServiceMock.createAuditor(); - const auditorFactory = auditTrailServiceMock.createAuditorFactory(); - auditorFactory.asScoped.mockReturnValue(auditor); - clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, () => auditorFactory); - clusterClient.asScoped(httpServerMock.createKibanaRequest()); - - expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); - expect(MockScopedClusterClient).toHaveBeenCalledWith( - expect.any(Function), - expect.any(Function), - expect.objectContaining({ 'x-opaque-id': expect.any(String) }), - auditor - ); - }); - - it("doesn't create Auditor for a fake request", async () => { - const getAuthHeaders = jest.fn(); - clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, getAuthHeaders); - clusterClient.asScoped({ headers: { one: '1', two: '2', three: '3' } }); - - expect(getAuthHeaders).not.toHaveBeenCalled(); - }); - - it("doesn't create Auditor when no request passed", async () => { - const getAuthHeaders = jest.fn(); - clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, getAuthHeaders); - clusterClient.asScoped(); - - expect(getAuthHeaders).not.toHaveBeenCalled(); - }); - }); }); describe('#close', () => { @@ -536,8 +442,7 @@ describe('#close', () => { clusterClient = new LegacyClusterClient( { apiVersion: 'es-version', requestHeadersWhitelist: [] } as any, - logger.get(), - auditTrailServiceMock.createAuditorFactory + logger.get() ); }); diff --git a/src/core/server/elasticsearch/legacy/cluster_client.ts b/src/core/server/elasticsearch/legacy/cluster_client.ts index 81cbb5a10d7c..00417e3bef4f 100644 --- a/src/core/server/elasticsearch/legacy/cluster_client.ts +++ b/src/core/server/elasticsearch/legacy/cluster_client.ts @@ -20,8 +20,7 @@ import { Client } from 'elasticsearch'; import { get } from 'lodash'; import { LegacyElasticsearchErrorHelpers } from './errors'; -import { GetAuthHeaders, KibanaRequest, isKibanaRequest, isRealRequest } from '../../http'; -import { AuditorFactory } from '../../audit_trail'; +import { GetAuthHeaders, isKibanaRequest, isRealRequest } from '../../http'; import { filterHeaders, ensureRawRequest } from '../../http/router'; import { Logger } from '../../logging'; import { ScopeableRequest } from '../types'; @@ -132,7 +131,6 @@ export class LegacyClusterClient implements ILegacyClusterClient { constructor( private readonly config: LegacyElasticsearchClientConfig, private readonly log: Logger, - private readonly getAuditorFactory: () => AuditorFactory, private readonly getAuthHeaders: GetAuthHeaders = noop ) { this.client = new Client(parseElasticsearchClientConfig(config, log)); @@ -210,20 +208,10 @@ export class LegacyClusterClient implements ILegacyClusterClient { filterHeaders(this.getHeaders(request), [ 'x-opaque-id', ...this.config.requestHeadersWhitelist, - ]), - this.getScopedAuditor(request) + ]) ); } - private getScopedAuditor(request?: ScopeableRequest) { - // TODO: support alternative credential owners from outside of Request context in #39430 - if (request && isRealRequest(request)) { - const kibanaRequest = isKibanaRequest(request) ? request : KibanaRequest.from(request); - const auditorFactory = this.getAuditorFactory(); - return auditorFactory.asScoped(kibanaRequest); - } - } - /** * Calls specified endpoint with provided clientParams on behalf of the * user initiated request to the Kibana server (via HTTP request headers). diff --git a/src/core/server/elasticsearch/legacy/scoped_cluster_client.test.ts b/src/core/server/elasticsearch/legacy/scoped_cluster_client.test.ts index f1096d5d602f..2eb8cefb564a 100644 --- a/src/core/server/elasticsearch/legacy/scoped_cluster_client.test.ts +++ b/src/core/server/elasticsearch/legacy/scoped_cluster_client.test.ts @@ -18,7 +18,6 @@ */ import { LegacyScopedClusterClient } from './scoped_cluster_client'; -import { auditTrailServiceMock } from '../../audit_trail/audit_trail_service.mock'; let internalAPICaller: jest.Mock; let scopedAPICaller: jest.Mock; @@ -84,28 +83,6 @@ describe('#callAsInternalUser', () => { expect(scopedAPICaller).not.toHaveBeenCalled(); }); - - describe('Auditor', () => { - it('does not fail when no auditor provided', () => { - const clusterClientWithoutAuditor = new LegacyScopedClusterClient(jest.fn(), jest.fn()); - expect(() => clusterClientWithoutAuditor.callAsInternalUser('endpoint')).not.toThrow(); - }); - it('creates an audit record if auditor provided', () => { - const auditor = auditTrailServiceMock.createAuditor(); - const clusterClientWithoutAuditor = new LegacyScopedClusterClient( - jest.fn(), - jest.fn(), - {}, - auditor - ); - clusterClientWithoutAuditor.callAsInternalUser('endpoint'); - expect(auditor.add).toHaveBeenCalledTimes(1); - expect(auditor.add).toHaveBeenLastCalledWith({ - message: 'endpoint', - type: 'elasticsearch.call.internalUser', - }); - }); - }); }); describe('#callAsCurrentUser', () => { @@ -229,26 +206,4 @@ describe('#callAsCurrentUser', () => { expect(internalAPICaller).not.toHaveBeenCalled(); }); - - describe('Auditor', () => { - it('does not fail when no auditor provided', () => { - const clusterClientWithoutAuditor = new LegacyScopedClusterClient(jest.fn(), jest.fn()); - expect(() => clusterClientWithoutAuditor.callAsCurrentUser('endpoint')).not.toThrow(); - }); - it('creates an audit record if auditor provided', () => { - const auditor = auditTrailServiceMock.createAuditor(); - const clusterClientWithoutAuditor = new LegacyScopedClusterClient( - jest.fn(), - jest.fn(), - {}, - auditor - ); - clusterClientWithoutAuditor.callAsCurrentUser('endpoint'); - expect(auditor.add).toHaveBeenCalledTimes(1); - expect(auditor.add).toHaveBeenLastCalledWith({ - message: 'endpoint', - type: 'elasticsearch.call.currentUser', - }); - }); - }); }); diff --git a/src/core/server/elasticsearch/legacy/scoped_cluster_client.ts b/src/core/server/elasticsearch/legacy/scoped_cluster_client.ts index aee7a1daa816..65484f0927c9 100644 --- a/src/core/server/elasticsearch/legacy/scoped_cluster_client.ts +++ b/src/core/server/elasticsearch/legacy/scoped_cluster_client.ts @@ -18,7 +18,6 @@ */ import { intersection, isObject } from 'lodash'; -import { Auditor } from '../../audit_trail'; import { Headers } from '../../http/router'; import { LegacyAPICaller, LegacyCallAPIOptions } from './api_types'; @@ -47,8 +46,7 @@ export class LegacyScopedClusterClient implements ILegacyScopedClusterClient { constructor( private readonly internalAPICaller: LegacyAPICaller, private readonly scopedAPICaller: LegacyAPICaller, - private readonly headers?: Headers, - private readonly auditor?: Auditor + private readonly headers?: Headers ) { this.callAsCurrentUser = this.callAsCurrentUser.bind(this); this.callAsInternalUser = this.callAsInternalUser.bind(this); @@ -68,13 +66,6 @@ export class LegacyScopedClusterClient implements ILegacyScopedClusterClient { clientParams: Record = {}, options?: LegacyCallAPIOptions ) { - if (this.auditor) { - this.auditor.add({ - message: endpoint, - type: 'elasticsearch.call.internalUser', - }); - } - return this.internalAPICaller(endpoint, clientParams, options); } @@ -107,13 +98,6 @@ export class LegacyScopedClusterClient implements ILegacyScopedClusterClient { clientParams.headers = Object.assign({}, clientParams.headers, this.headers); } - if (this.auditor) { - this.auditor.add({ - message: endpoint, - type: 'elasticsearch.call.currentUser', - }); - } - return this.scopedAPICaller(endpoint, clientParams, options); } } diff --git a/src/core/server/index.ts b/src/core/server/index.ts index fc091bd17bdf..efb196590ea9 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -62,7 +62,6 @@ import { import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { MetricsServiceSetup, MetricsServiceStart } from './metrics'; import { StatusServiceSetup } from './status'; -import { Auditor, AuditTrailSetup, AuditTrailStart } from './audit_trail'; import { AppenderConfigType, appendersSchema, LoggingServiceSetup } from './logging'; import { CoreUsageDataStart } from './core_usage_data'; @@ -77,7 +76,6 @@ import { export { CoreUsageData, CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData }; -export { AuditableEvent, Auditor, AuditorFactory, AuditTrailSetup } from './audit_trail'; export { bootstrap } from './bootstrap'; export { Capabilities, CapabilitiesProvider, CapabilitiesSwitcher } from './capabilities'; export { @@ -378,7 +376,6 @@ export { CoreUsageDataStart } from './core_usage_data'; * data client which uses the credentials of the incoming request * - {@link IUiSettingsClient | uiSettings.client} - uiSettings client * which uses the credentials of the incoming request - * - {@link Auditor | uiSettings.auditor} - AuditTrail client scoped to the incoming request * * @public */ @@ -397,7 +394,6 @@ export interface RequestHandlerContext { uiSettings: { client: IUiSettingsClient; }; - auditor: Auditor; }; } @@ -434,8 +430,6 @@ export interface CoreSetup; - /** {@link AuditTrailSetup} */ - auditTrail: AuditTrailSetup; } /** @@ -469,8 +463,6 @@ export interface CoreStart { savedObjects: SavedObjectsServiceStart; /** {@link UiSettingsServiceStart} */ uiSettings: UiSettingsServiceStart; - /** {@link AuditTrailSetup} */ - auditTrail: AuditTrailStart; /** @internal {@link CoreUsageDataStart} */ coreUsageData: CoreUsageDataStart; } @@ -483,7 +475,6 @@ export { PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId, - AuditTrailStart, }; /** diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index ce58348a1415..294af5ec34c3 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -37,7 +37,6 @@ import { InternalMetricsServiceSetup, InternalMetricsServiceStart } from './metr import { InternalRenderingServiceSetup } from './rendering'; import { InternalHttpResourcesSetup } from './http_resources'; import { InternalStatusServiceSetup } from './status'; -import { AuditTrailSetup, AuditTrailStart } from './audit_trail'; import { InternalLoggingServiceSetup } from './logging'; import { CoreUsageDataStart } from './core_usage_data'; @@ -53,7 +52,6 @@ export interface InternalCoreSetup { environment: InternalEnvironmentServiceSetup; rendering: InternalRenderingServiceSetup; httpResources: InternalHttpResourcesSetup; - auditTrail: AuditTrailSetup; logging: InternalLoggingServiceSetup; metrics: InternalMetricsServiceSetup; } @@ -68,7 +66,6 @@ export interface InternalCoreStart { metrics: InternalMetricsServiceStart; savedObjects: InternalSavedObjectsServiceStart; uiSettings: InternalUiSettingsServiceStart; - auditTrail: AuditTrailStart; coreUsageData: CoreUsageDataStart; } diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index 57009f0d35c1..b8f5757f0b67 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -44,7 +44,6 @@ import { LegacyServiceSetupDeps, LegacyServiceStartDeps } from './types'; import { LegacyService } from './legacy_service'; import { coreMock } from '../mocks'; import { statusServiceMock } from '../status/status_service.mock'; -import { auditTrailServiceMock } from '../audit_trail/audit_trail_service.mock'; import { loggingServiceMock } from '../logging/logging_service.mock'; import { metricsServiceMock } from '../metrics/metrics_service.mock'; @@ -92,7 +91,6 @@ beforeEach(() => { rendering: renderingServiceMock, environment: environmentSetup, status: statusServiceMock.createInternalSetupContract(), - auditTrail: auditTrailServiceMock.createSetupContract(), logging: loggingServiceMock.createInternalSetupContract(), metrics: metricsServiceMock.createInternalSetupContract(), }, diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 75e8ae652492..c42771179aba 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -216,7 +216,6 @@ export class LegacyService implements CoreService { getOpsMetrics$: startDeps.core.metrics.getOpsMetrics$, }, uiSettings: { asScopedToClient: startDeps.core.uiSettings.asScopedToClient }, - auditTrail: startDeps.core.auditTrail, coreUsageData: { getCoreUsageData: () => { throw new Error('core.start.coreUsageData.getCoreUsageData is unsupported in legacy'); @@ -284,7 +283,6 @@ export class LegacyService implements CoreService { uiSettings: { register: setupDeps.core.uiSettings.register, }, - auditTrail: setupDeps.core.auditTrail, getStartServices: () => Promise.resolve([coreStart, startDeps.plugins, {}]), }; diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 34e85920efb2..e47d06409894 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -36,7 +36,6 @@ import { capabilitiesServiceMock } from './capabilities/capabilities_service.moc import { metricsServiceMock } from './metrics/metrics_service.mock'; import { environmentServiceMock } from './environment/environment_service.mock'; import { statusServiceMock } from './status/status_service.mock'; -import { auditTrailServiceMock } from './audit_trail/audit_trail_service.mock'; import { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_service.mock'; export { configServiceMock } from './config/mocks'; @@ -139,7 +138,6 @@ function createCoreSetupMock({ savedObjects: savedObjectsServiceMock.createInternalSetupContract(), status: statusServiceMock.createSetupContract(), uiSettings: uiSettingsMock, - auditTrail: auditTrailServiceMock.createSetupContract(), logging: loggingServiceMock.createSetupContract(), metrics: metricsServiceMock.createSetupContract(), getStartServices: jest @@ -152,7 +150,6 @@ function createCoreSetupMock({ function createCoreStartMock() { const mock: MockedKeys = { - auditTrail: auditTrailServiceMock.createStartContract(), capabilities: capabilitiesServiceMock.createStartContract(), elasticsearch: elasticsearchServiceMock.createStart(), http: httpServiceMock.createStartContract(), @@ -177,7 +174,6 @@ function createInternalCoreSetupMock() { httpResources: httpResourcesMock.createSetupContract(), rendering: renderingMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), - auditTrail: auditTrailServiceMock.createSetupContract(), logging: loggingServiceMock.createInternalSetupContract(), metrics: metricsServiceMock.createInternalSetupContract(), }; @@ -192,7 +188,6 @@ function createInternalCoreStartMock() { metrics: metricsServiceMock.createInternalStartContract(), savedObjects: savedObjectsServiceMock.createInternalStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), - auditTrail: auditTrailServiceMock.createStartContract(), coreUsageData: coreUsageDataServiceMock.createStartContract(), }; return startDeps; @@ -213,7 +208,6 @@ function createCoreRequestHandlerContextMock() { uiSettings: { client: uiSettingsServiceMock.createClient(), }, - auditor: auditTrailServiceMock.createAuditor(), }; } diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index a8249ed7e321..22e79741e854 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -201,7 +201,6 @@ export function createPluginSetupContext( register: deps.uiSettings.register, }, getStartServices: () => plugin.startDependencies, - auditTrail: deps.auditTrail, }; } @@ -250,7 +249,6 @@ export function createPluginStartContext( uiSettings: { asScopedToClient: deps.uiSettings.asScopedToClient, }, - auditTrail: deps.auditTrail, coreUsageData: deps.coreUsageData, }; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 20bd102e6f50..7cd8682050e6 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -198,38 +198,6 @@ export interface AssistantAPIClientParams extends GenericParams { path: '/_migration/assistance'; } -// @public -export interface AuditableEvent { - // (undocumented) - message: string; - // (undocumented) - type: string; -} - -// @public -export interface Auditor { - add(event: AuditableEvent): void; - withAuditScope(name: string): void; -} - -// @public -export interface AuditorFactory { - // (undocumented) - asScoped(request: KibanaRequest): Auditor; -} - -// Warning: (ae-missing-release-tag) "AuditTrailSetup" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export interface AuditTrailSetup { - register(auditor: AuditorFactory): void; -} - -// Warning: (ae-missing-release-tag) "AuditTrailStart" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type AuditTrailStart = AuditorFactory; - // @public (undocumented) export interface Authenticated extends AuthResultParams { // (undocumented) @@ -499,8 +467,6 @@ export interface CoreServicesUsageData { // @public export interface CoreSetup { - // (undocumented) - auditTrail: AuditTrailSetup; // (undocumented) capabilities: CapabilitiesSetup; // (undocumented) @@ -527,8 +493,6 @@ export interface CoreSetup AuditorFactory, getAuthHeaders?: GetAuthHeaders); + constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders); asScoped(request?: ScopeableRequest): ILegacyScopedClusterClient; callAsInternalUser: LegacyAPICaller; close(): void; @@ -1396,7 +1360,7 @@ export interface LegacyRequest extends Request { // @public @deprecated export class LegacyScopedClusterClient implements ILegacyScopedClusterClient { - constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller, headers?: Headers | undefined, auditor?: Auditor | undefined); + constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller, headers?: Headers | undefined); callAsCurrentUser(endpoint: string, clientParams?: Record, options?: LegacyCallAPIOptions): Promise; callAsInternalUser(endpoint: string, clientParams?: Record, options?: LegacyCallAPIOptions): Promise; } @@ -1738,7 +1702,6 @@ export interface RequestHandlerContext { uiSettings: { client: IUiSettingsClient; }; - auditor: Auditor; }; } diff --git a/src/core/server/server.test.mocks.ts b/src/core/server/server.test.mocks.ts index 77f2787b7541..fe299c6d1167 100644 --- a/src/core/server/server.test.mocks.ts +++ b/src/core/server/server.test.mocks.ts @@ -100,9 +100,3 @@ export const mockLoggingService = loggingServiceMock.create(); jest.doMock('./logging/logging_service', () => ({ LoggingService: jest.fn(() => mockLoggingService), })); - -import { auditTrailServiceMock } from './audit_trail/audit_trail_service.mock'; -export const mockAuditTrailService = auditTrailServiceMock.create(); -jest.doMock('./audit_trail/audit_trail_service', () => ({ - AuditTrailService: jest.fn(() => mockAuditTrailService), -})); diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index 51defb7d0392..78703ceeec7a 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -31,7 +31,6 @@ import { mockMetricsService, mockStatusService, mockLoggingService, - mockAuditTrailService, } from './server.test.mocks'; import { BehaviorSubject } from 'rxjs'; @@ -71,7 +70,6 @@ test('sets up services on "setup"', async () => { expect(mockMetricsService.setup).not.toHaveBeenCalled(); expect(mockStatusService.setup).not.toHaveBeenCalled(); expect(mockLoggingService.setup).not.toHaveBeenCalled(); - expect(mockAuditTrailService.setup).not.toHaveBeenCalled(); await server.setup(); @@ -85,7 +83,6 @@ test('sets up services on "setup"', async () => { expect(mockMetricsService.setup).toHaveBeenCalledTimes(1); expect(mockStatusService.setup).toHaveBeenCalledTimes(1); expect(mockLoggingService.setup).toHaveBeenCalledTimes(1); - expect(mockAuditTrailService.setup).toHaveBeenCalledTimes(1); }); test('injects legacy dependency to context#setup()', async () => { @@ -126,7 +123,6 @@ test('runs services on "start"', async () => { expect(mockSavedObjectsService.start).not.toHaveBeenCalled(); expect(mockUiSettingsService.start).not.toHaveBeenCalled(); expect(mockMetricsService.start).not.toHaveBeenCalled(); - expect(mockAuditTrailService.start).not.toHaveBeenCalled(); await server.start(); @@ -135,7 +131,6 @@ test('runs services on "start"', async () => { expect(mockSavedObjectsService.start).toHaveBeenCalledTimes(1); expect(mockUiSettingsService.start).toHaveBeenCalledTimes(1); expect(mockMetricsService.start).toHaveBeenCalledTimes(1); - expect(mockAuditTrailService.start).toHaveBeenCalledTimes(1); }); test('does not fail on "setup" if there are unused paths detected', async () => { @@ -160,7 +155,6 @@ test('stops services on "stop"', async () => { expect(mockMetricsService.stop).not.toHaveBeenCalled(); expect(mockStatusService.stop).not.toHaveBeenCalled(); expect(mockLoggingService.stop).not.toHaveBeenCalled(); - expect(mockAuditTrailService.stop).not.toHaveBeenCalled(); await server.stop(); @@ -173,7 +167,6 @@ test('stops services on "stop"', async () => { expect(mockMetricsService.stop).toHaveBeenCalledTimes(1); expect(mockStatusService.stop).toHaveBeenCalledTimes(1); expect(mockLoggingService.stop).toHaveBeenCalledTimes(1); - expect(mockAuditTrailService.stop).toHaveBeenCalledTimes(1); }); test(`doesn't setup core services if config validation fails`, async () => { @@ -227,7 +220,6 @@ test(`doesn't validate config if env.isDevClusterMaster is true`, async () => { expect(mockEnsureValidConfiguration).not.toHaveBeenCalled(); expect(mockContextService.setup).toHaveBeenCalled(); - expect(mockAuditTrailService.setup).toHaveBeenCalled(); expect(mockHttpService.setup).toHaveBeenCalled(); expect(mockElasticsearchService.setup).toHaveBeenCalled(); expect(mockSavedObjectsService.setup).toHaveBeenCalled(); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index f38cac4f4376..eaa03d11cab9 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -21,7 +21,6 @@ import { config as pathConfig } from '@kbn/utils'; import { mapToObject } from '@kbn/std'; import { ConfigService, Env, RawConfigurationProvider, coreDeprecationProvider } from './config'; import { CoreApp } from './core_app'; -import { AuditTrailService } from './audit_trail'; import { ElasticsearchService } from './elasticsearch'; import { HttpService } from './http'; import { HttpResourcesService } from './http_resources'; @@ -72,7 +71,6 @@ export class Server { private readonly status: StatusService; private readonly logging: LoggingService; private readonly coreApp: CoreApp; - private readonly auditTrail: AuditTrailService; private readonly coreUsageData: CoreUsageDataService; #pluginsInitialized?: boolean; @@ -103,7 +101,6 @@ export class Server { this.status = new StatusService(core); this.coreApp = new CoreApp(core); this.httpResources = new HttpResourcesService(core); - this.auditTrail = new AuditTrailService(core); this.logging = new LoggingService(core); this.coreUsageData = new CoreUsageDataService(core); } @@ -139,8 +136,6 @@ export class Server { ]), }); - const auditTrailSetup = this.auditTrail.setup(); - const httpSetup = await this.http.setup({ context: contextServiceSetup, }); @@ -200,7 +195,6 @@ export class Server { uiSettings: uiSettingsSetup, rendering: renderingSetup, httpResources: httpResourcesSetup, - auditTrail: auditTrailSetup, logging: loggingSetup, metrics: metricsSetup, }; @@ -225,11 +219,7 @@ export class Server { this.log.debug('starting server'); const startTransaction = apm.startTransaction('server_start', 'kibana_platform'); - const auditTrailStart = this.auditTrail.start(); - - const elasticsearchStart = await this.elasticsearch.start({ - auditTrail: auditTrailStart, - }); + const elasticsearchStart = await this.elasticsearch.start(); const soStartSpan = startTransaction?.startSpan('saved_objects.migration', 'migration'); const savedObjectsStart = await this.savedObjects.start({ elasticsearch: elasticsearchStart, @@ -252,7 +242,6 @@ export class Server { metrics: metricsStart, savedObjects: savedObjectsStart, uiSettings: uiSettingsStart, - auditTrail: auditTrailStart, coreUsageData: coreUsageDataStart, }; @@ -285,7 +274,6 @@ export class Server { await this.metrics.stop(); await this.status.stop(); await this.logging.stop(); - await this.auditTrail.stop(); } private registerCoreContext(coreSetup: InternalCoreSetup) { diff --git a/x-pack/plugins/actions/server/authorization/audit_logger.ts b/x-pack/plugins/actions/server/authorization/audit_logger.ts index 7e0adc920665..3bbf60b0b3ed 100644 --- a/x-pack/plugins/actions/server/authorization/audit_logger.ts +++ b/x-pack/plugins/actions/server/authorization/audit_logger.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AuditLogger } from '../../../security/server'; +import { LegacyAuditLogger } from '../../../security/server'; export enum AuthorizationResult { Unauthorized = 'Unauthorized', @@ -12,9 +12,9 @@ export enum AuthorizationResult { } export class ActionsAuthorizationAuditLogger { - private readonly auditLogger: AuditLogger; + private readonly auditLogger: LegacyAuditLogger; - constructor(auditLogger: AuditLogger = { log() {} }) { + constructor(auditLogger: LegacyAuditLogger = { log() {} }) { this.auditLogger = auditLogger; } diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts index ac91d689798c..d747efbb959d 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts @@ -20,7 +20,7 @@ import { securityMock } from '../../security/server/mocks'; import { PluginStartContract as ActionsStartContract } from '../../actions/server'; import { actionsMock, actionsAuthorizationMock } from '../../actions/server/mocks'; import { featuresPluginMock } from '../../features/server/mocks'; -import { AuditLogger } from '../../security/server'; +import { LegacyAuditLogger } from '../../security/server'; import { ALERTS_FEATURE_ID } from '../common'; import { eventLogMock } from '../../event_log/server/mocks'; @@ -85,7 +85,7 @@ test('creates an alerts client with proper constructor arguments when security i const logger = { log: jest.fn(), - } as jest.Mocked; + } as jest.Mocked; securityPluginSetup.audit.getLogger.mockReturnValue(logger); factory.create(request, savedObjectsService); diff --git a/x-pack/plugins/alerts/server/authorization/audit_logger.ts b/x-pack/plugins/alerts/server/authorization/audit_logger.ts index f930da2ce428..7f259df71746 100644 --- a/x-pack/plugins/alerts/server/authorization/audit_logger.ts +++ b/x-pack/plugins/alerts/server/authorization/audit_logger.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AuditLogger } from '../../../security/server'; +import { LegacyAuditLogger } from '../../../security/server'; export enum ScopeType { Consumer, @@ -17,9 +17,9 @@ export enum AuthorizationResult { } export class AlertsAuthorizationAuditLogger { - private readonly auditLogger: AuditLogger; + private readonly auditLogger: LegacyAuditLogger; - constructor(auditLogger: AuditLogger = { log() {} }) { + constructor(auditLogger: LegacyAuditLogger = { log() {} }) { this.auditLogger = auditLogger; } diff --git a/x-pack/plugins/audit_trail/kibana.json b/x-pack/plugins/audit_trail/kibana.json deleted file mode 100644 index ce92e232ec13..000000000000 --- a/x-pack/plugins/audit_trail/kibana.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "auditTrail", - "version": "8.0.0", - "kibanaVersion": "kibana", - "configPath": ["xpack", "audit_trail"], - "server": true, - "ui": false, - "requiredPlugins": ["licensing", "security"], - "optionalPlugins": ["spaces"] -} diff --git a/x-pack/plugins/audit_trail/server/client/audit_trail_client.test.ts b/x-pack/plugins/audit_trail/server/client/audit_trail_client.test.ts deleted file mode 100644 index 76ca3e56fe83..000000000000 --- a/x-pack/plugins/audit_trail/server/client/audit_trail_client.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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 { Subject } from 'rxjs'; - -import { AuditTrailClient } from './audit_trail_client'; -import { AuditEvent } from '../types'; - -import { httpServerMock } from '../../../../../src/core/server/mocks'; -import { securityMock } from '../../../security/server/mocks'; -import { spacesMock } from '../../../spaces/server/mocks'; - -describe('AuditTrailClient', () => { - let client: AuditTrailClient; - let event$: Subject; - const deps = { - getCurrentUser: securityMock.createSetup().authc.getCurrentUser, - getSpaceId: spacesMock.createSetup().spacesService.getSpaceId, - }; - - beforeEach(() => { - event$ = new Subject(); - client = new AuditTrailClient( - httpServerMock.createKibanaRequest({ - kibanaRequestState: { requestId: 'request id alpha', requestUuid: 'ignore-me' }, - }), - event$, - deps - ); - }); - - afterEach(() => { - event$.complete(); - }); - - describe('#withAuditScope', () => { - it('registers upper level scope', (done) => { - client.withAuditScope('scope_name'); - event$.subscribe((event) => { - expect(event.scope).toBe('scope_name'); - done(); - }); - client.add({ message: 'message', type: 'type' }); - }); - - it('populates requestId', (done) => { - client.withAuditScope('scope_name'); - event$.subscribe((event) => { - expect(event.requestId).toBe('request id alpha'); - done(); - }); - client.add({ message: 'message', type: 'type' }); - }); - - it('throws an exception if tries to re-write a scope', () => { - client.withAuditScope('scope_name'); - expect(() => client.withAuditScope('another_scope_name')).toThrowErrorMatchingInlineSnapshot( - `"Audit scope is already set to: scope_name"` - ); - }); - }); -}); diff --git a/x-pack/plugins/audit_trail/server/client/audit_trail_client.ts b/x-pack/plugins/audit_trail/server/client/audit_trail_client.ts deleted file mode 100644 index e5022234af9d..000000000000 --- a/x-pack/plugins/audit_trail/server/client/audit_trail_client.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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 { Subject } from 'rxjs'; -import { KibanaRequest, Auditor, AuditableEvent } from 'src/core/server'; -import { AuditEvent } from '../types'; - -import { SecurityPluginSetup } from '../../../security/server'; -import { SpacesPluginSetup } from '../../../spaces/server'; - -interface Deps { - getCurrentUser: SecurityPluginSetup['authc']['getCurrentUser']; - getSpaceId?: SpacesPluginSetup['spacesService']['getSpaceId']; -} - -export class AuditTrailClient implements Auditor { - private scope?: string; - constructor( - private readonly request: KibanaRequest, - private readonly event$: Subject, - private readonly deps: Deps - ) {} - - public withAuditScope(name: string) { - if (this.scope !== undefined) { - throw new Error(`Audit scope is already set to: ${this.scope}`); - } - this.scope = name; - } - - public add(event: AuditableEvent) { - const user = this.deps.getCurrentUser(this.request); - // doesn't use getSpace since it's async operation calling ES - const spaceId = this.deps.getSpaceId ? this.deps.getSpaceId(this.request) : undefined; - - this.event$.next({ - message: event.message, - type: event.type, - user: user?.username, - space: spaceId, - scope: this.scope, - requestId: this.request.id, - }); - } -} diff --git a/x-pack/plugins/audit_trail/server/config.test.ts b/x-pack/plugins/audit_trail/server/config.test.ts deleted file mode 100644 index 65dfc9f589ec..000000000000 --- a/x-pack/plugins/audit_trail/server/config.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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 { config } from './config'; - -describe('config schema', () => { - it('generates proper defaults', () => { - expect(config.schema.validate({})).toEqual({ - enabled: false, - logger: { - enabled: false, - }, - }); - }); - - it('accepts an appender', () => { - const appender = config.schema.validate({ - appender: { - kind: 'file', - path: '/path/to/file.txt', - layout: { - kind: 'json', - }, - }, - logger: { - enabled: false, - }, - }).appender; - - expect(appender).toEqual({ - kind: 'file', - path: '/path/to/file.txt', - layout: { - kind: 'json', - }, - }); - }); - - it('rejects an appender if not fully configured', () => { - expect(() => - config.schema.validate({ - // no layout configured - appender: { - kind: 'file', - path: '/path/to/file.txt', - }, - logger: { - enabled: false, - }, - }) - ).toThrow(); - }); -}); diff --git a/x-pack/plugins/audit_trail/server/config.ts b/x-pack/plugins/audit_trail/server/config.ts deleted file mode 100644 index 7b05c04c2236..000000000000 --- a/x-pack/plugins/audit_trail/server/config.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * 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 { schema, TypeOf } from '@kbn/config-schema'; -import { PluginConfigDescriptor, config as coreConfig } from '../../../../src/core/server'; - -const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: false }), - appender: schema.maybe(coreConfig.logging.appenders), - logger: schema.object({ - enabled: schema.boolean({ defaultValue: false }), - }), -}); - -export type AuditTrailConfigType = TypeOf; - -export const config: PluginConfigDescriptor = { - schema: configSchema, -}; diff --git a/x-pack/plugins/audit_trail/server/index.ts b/x-pack/plugins/audit_trail/server/index.ts deleted file mode 100644 index 7db48823a0e2..000000000000 --- a/x-pack/plugins/audit_trail/server/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * 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 { PluginInitializerContext } from 'src/core/server'; -import { AuditTrailPlugin } from './plugin'; - -export { config } from './config'; -export const plugin = (initializerContext: PluginInitializerContext) => { - return new AuditTrailPlugin(initializerContext); -}; diff --git a/x-pack/plugins/audit_trail/server/plugin.test.ts b/x-pack/plugins/audit_trail/server/plugin.test.ts deleted file mode 100644 index fa5fd1bcc1e1..000000000000 --- a/x-pack/plugins/audit_trail/server/plugin.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -/* - * 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 { first } from 'rxjs/operators'; -import { AuditTrailPlugin } from './plugin'; -import { coreMock } from '../../../../src/core/server/mocks'; - -import { securityMock } from '../../security/server/mocks'; -import { spacesMock } from '../../spaces/server/mocks'; - -describe('AuditTrail plugin', () => { - describe('#setup', () => { - let plugin: AuditTrailPlugin; - let pluginInitContextMock: ReturnType; - let coreSetup: ReturnType; - - const deps = { - security: securityMock.createSetup(), - spaces: spacesMock.createSetup(), - }; - - beforeEach(() => { - pluginInitContextMock = coreMock.createPluginInitializerContext(); - plugin = new AuditTrailPlugin(pluginInitContextMock); - coreSetup = coreMock.createSetup(); - }); - - afterEach(async () => { - await plugin.stop(); - }); - - it('registers AuditTrail factory', async () => { - pluginInitContextMock = coreMock.createPluginInitializerContext(); - plugin = new AuditTrailPlugin(pluginInitContextMock); - plugin.setup(coreSetup, deps); - expect(coreSetup.auditTrail.register).toHaveBeenCalledTimes(1); - }); - - describe('logger', () => { - it('registers a custom logger', async () => { - pluginInitContextMock = coreMock.createPluginInitializerContext(); - plugin = new AuditTrailPlugin(pluginInitContextMock); - plugin.setup(coreSetup, deps); - - expect(coreSetup.logging.configure).toHaveBeenCalledTimes(1); - }); - - it('disables logging if config.logger.enabled: false', async () => { - const config = { - logger: { - enabled: false, - }, - }; - pluginInitContextMock = coreMock.createPluginInitializerContext(config); - - plugin = new AuditTrailPlugin(pluginInitContextMock); - plugin.setup(coreSetup, deps); - - const args = coreSetup.logging.configure.mock.calls[0][0]; - const value = await args.pipe(first()).toPromise(); - expect(value.loggers?.every((l) => l.level === 'off')).toBe(true); - }); - it('logs with DEBUG level if config.logger.enabled: true', async () => { - const config = { - logger: { - enabled: true, - }, - }; - pluginInitContextMock = coreMock.createPluginInitializerContext(config); - - plugin = new AuditTrailPlugin(pluginInitContextMock); - plugin.setup(coreSetup, deps); - - const args = coreSetup.logging.configure.mock.calls[0][0]; - const value = await args.pipe(first()).toPromise(); - expect(value.loggers?.every((l) => l.level === 'debug')).toBe(true); - }); - it('uses appender adjusted via config', async () => { - const config = { - appender: { - kind: 'file', - path: '/path/to/file.txt', - }, - logger: { - enabled: true, - }, - }; - pluginInitContextMock = coreMock.createPluginInitializerContext(config); - - plugin = new AuditTrailPlugin(pluginInitContextMock); - plugin.setup(coreSetup, deps); - - const args = coreSetup.logging.configure.mock.calls[0][0]; - const value = await args.pipe(first()).toPromise(); - expect(value.appenders).toEqual({ auditTrailAppender: config.appender }); - }); - it('falls back to the default appender if not configured', async () => { - const config = { - logger: { - enabled: true, - }, - }; - pluginInitContextMock = coreMock.createPluginInitializerContext(config); - - plugin = new AuditTrailPlugin(pluginInitContextMock); - plugin.setup(coreSetup, deps); - - const args = coreSetup.logging.configure.mock.calls[0][0]; - const value = await args.pipe(first()).toPromise(); - expect(value.appenders).toEqual({ - auditTrailAppender: { - kind: 'console', - layout: { - kind: 'pattern', - highlight: true, - }, - }, - }); - }); - }); - }); -}); diff --git a/x-pack/plugins/audit_trail/server/plugin.ts b/x-pack/plugins/audit_trail/server/plugin.ts deleted file mode 100644 index cf423f230aef..000000000000 --- a/x-pack/plugins/audit_trail/server/plugin.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * 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 { Observable, Subject } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { - AppenderConfigType, - CoreSetup, - CoreStart, - KibanaRequest, - Logger, - LoggerContextConfigInput, - Plugin, - PluginInitializerContext, -} from 'src/core/server'; - -import { AuditEvent } from './types'; -import { AuditTrailClient } from './client/audit_trail_client'; -import { AuditTrailConfigType } from './config'; - -import { SecurityPluginSetup } from '../../security/server'; -import { SpacesPluginSetup } from '../../spaces/server'; -import { LicensingPluginStart } from '../../licensing/server'; - -interface DepsSetup { - security: SecurityPluginSetup; - spaces?: SpacesPluginSetup; -} - -interface DepStart { - licensing: LicensingPluginStart; -} - -export class AuditTrailPlugin implements Plugin { - private readonly logger: Logger; - private readonly config$: Observable; - private readonly event$ = new Subject(); - - constructor(private readonly context: PluginInitializerContext) { - this.logger = this.context.logger.get(); - this.config$ = this.context.config.create(); - } - - public setup(core: CoreSetup, deps: DepsSetup) { - const depsApi = { - getCurrentUser: deps.security.authc.getCurrentUser, - getSpaceId: deps.spaces?.spacesService.getSpaceId, - }; - - this.event$.subscribe(({ message, ...other }) => this.logger.debug(message, other)); - - core.auditTrail.register({ - asScoped: (request: KibanaRequest) => { - return new AuditTrailClient(request, this.event$, depsApi); - }, - }); - - core.logging.configure( - this.config$.pipe( - map((config) => ({ - appenders: { - auditTrailAppender: this.getAppender(config), - }, - loggers: [ - { - // plugins.auditTrail prepended automatically - context: '', - // do not pipe in root log if disabled - level: config.logger.enabled ? 'debug' : 'off', - appenders: ['auditTrailAppender'], - }, - ], - })) - ) - ); - } - - private getAppender(config: AuditTrailConfigType): AppenderConfigType { - return ( - config.appender ?? { - kind: 'console', - layout: { - kind: 'pattern', - highlight: true, - }, - } - ); - } - - public start(core: CoreStart, deps: DepStart) {} - public stop() { - this.event$.complete(); - } -} diff --git a/x-pack/plugins/audit_trail/server/types.ts b/x-pack/plugins/audit_trail/server/types.ts deleted file mode 100644 index 1b7afb09f062..000000000000 --- a/x-pack/plugins/audit_trail/server/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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. - */ -/** - * Event enhanced with request context data. Provided to an external consumer. - * @public - */ -export interface AuditEvent { - message: string; - type: string; - scope?: string; - user?: string; - space?: string; - requestId?: string; -} diff --git a/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.ts b/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.ts index de14a79dd0dd..4f3e7e9f2b5a 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.ts @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AuditLogger, AuthenticatedUser } from '../../../security/server'; +import { LegacyAuditLogger, AuthenticatedUser } from '../../../security/server'; import { SavedObjectDescriptor, descriptorToArray } from '../crypto'; /** * Represents all audit events the plugin can log. */ export class EncryptedSavedObjectsAuditLogger { - constructor(private readonly logger: AuditLogger = { log() {} }) {} + constructor(private readonly logger: LegacyAuditLogger = { log() {} }) {} public encryptAttributeFailure( attributeName: string, diff --git a/x-pack/plugins/security/README.md b/x-pack/plugins/security/README.md index 068f19ba9482..b93be0269536 100644 --- a/x-pack/plugins/security/README.md +++ b/x-pack/plugins/security/README.md @@ -1,3 +1,92 @@ # Kibana Security Plugin -See [Configuring security in Kibana](https://www.elastic.co/guide/en/kibana/current/using-kibana-with-security.html). +See [Configuring security in +Kibana](https://www.elastic.co/guide/en/kibana/current/using-kibana-with-security.html). + +## Audit logging + +### Example + +```typescript +const auditLogger = securitySetup.audit.asScoped(request); +auditLogger.log({ + message: 'User is updating dashboard [id=123]', + event: { + action: 'saved_object_update', + category: EventCategory.DATABASE, + type: EventType.CHANGE, + outcome: EventOutcome.UNKNOWN, + }, + kibana: { + saved_object: { type: 'dashboard', id: '123' }, + }, +}); +``` + +### What events should be logged? + +The purpose of an audit log is to support compliance, accountability and +security by capturing who performed an action, what action was performed and +when it occurred. It is not the purpose of an audit log to aid with debugging +the system or provide usage statistics. + +**Kibana guidelines:** + +Each API call to Kibana will result in a record in the audit log that captures +general information about the request (`http_request` event). + +In addition to that, any operation that is performed on a resource owned by +Kibana (e.g. saved objects) and that falls in the following categories, should +be included in the audit log: + +- System access (incl. failed attempts due to authentication errors) +- Data reads (incl. failed attempts due to authorisation errors) +- Data writes (incl. failed attempts due to authorisation errors) + +If Kibana does not own the resource (e.g. when running queries against user +indices), then auditing responsibilities are deferred to Elasticsearch and no +additional events will be logged. + +**Examples:** + +For a list of audit events that Kibana currently logs see: +`docs/user/security/audit-logging.asciidoc` + +### When should an event be logged? + +Due to the asynchronous nature of most operations in Kibana, there is an +inherent tradeoff between the following logging approaches: + +- Logging the **intention** before performing an operation, leading to false + positives if the operation fails downstream. +- Logging the **outcome** after completing an operation, leading to missing + records if Kibana crashes before the response is received. +- Logging **both**, intention and outcome, leading to unnecessary duplication + and noisy/difficult to analyse logs. + +**Kibana guidelines:** + +- **Write operations** should be logged immediately after all authorisation + checks have passed, but before the response is received (logging the + intention). This ensures that a record of every operation is persisted even in + case of an unexpected error. +- **Read operations**, on the other hand, should be logged after the operation + completed (logging the outcome) since we won't know what resources were + accessed before receiving the response. +- Be explicit about the timing and outcome of an action in your messaging. (e.g. + "User has logged in" vs. "User is creating dashboard") + +### Can an action trigger multiple events? + +- A request to Kibana can perform a combination of different operations, each of + which should be captured as separate events. +- Operations that are performed on multiple resources (**bulk operations**) + should be logged as separate events, one for each resource. +- Actions that kick off **background tasks** should be logged as separate + events, one for creating the task and another one for executing it. +- **Internal checks**, which have been carried out in order to perform an + operation, or **errors** that occured as a result of an operation should be + logged as an outcome of the operation itself, using the ECS `event.outcome` + and `error` fields, instead of logging a separate event. +- Multiple events that were part of the same request can be correlated in the + audit log using the ECS `trace.id` property. diff --git a/x-pack/plugins/security/server/audit/audit_events.test.ts b/x-pack/plugins/security/server/audit/audit_events.test.ts new file mode 100644 index 000000000000..ae40429eea1b --- /dev/null +++ b/x-pack/plugins/security/server/audit/audit_events.test.ts @@ -0,0 +1,204 @@ +/* + * 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 { + EventOutcome, + SavedObjectAction, + savedObjectEvent, + userLoginEvent, + httpRequestEvent, +} from './audit_events'; +import { AuthenticationResult } from '../authentication'; +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('#savedObjectEvent', () => { + test('creates event with `unknown` outcome', () => { + expect( + savedObjectEvent({ + action: SavedObjectAction.CREATE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "saved_object_create", + "category": "database", + "outcome": "unknown", + "type": "creation", + }, + "kibana": Object { + "add_to_spaces": undefined, + "delete_from_spaces": undefined, + "saved_object": Object { + "id": "SAVED_OBJECT_ID", + "type": "dashboard", + }, + }, + "message": "User is creating dashboard [id=SAVED_OBJECT_ID]", + } + `); + }); + + test('creates event with `success` outcome', () => { + expect( + savedObjectEvent({ + action: SavedObjectAction.CREATE, + savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "saved_object_create", + "category": "database", + "outcome": "success", + "type": "creation", + }, + "kibana": Object { + "add_to_spaces": undefined, + "delete_from_spaces": undefined, + "saved_object": Object { + "id": "SAVED_OBJECT_ID", + "type": "dashboard", + }, + }, + "message": "User has created dashboard [id=SAVED_OBJECT_ID]", + } + `); + }); + + test('creates event with `failure` outcome', () => { + expect( + savedObjectEvent({ + action: SavedObjectAction.CREATE, + savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, + error: new Error('ERROR_MESSAGE'), + }) + ).toMatchInlineSnapshot(` + Object { + "error": Object { + "code": "Error", + "message": "ERROR_MESSAGE", + }, + "event": Object { + "action": "saved_object_create", + "category": "database", + "outcome": "failure", + "type": "creation", + }, + "kibana": Object { + "add_to_spaces": undefined, + "delete_from_spaces": undefined, + "saved_object": Object { + "id": "SAVED_OBJECT_ID", + "type": "dashboard", + }, + }, + "message": "Failed attempt to create dashboard [id=SAVED_OBJECT_ID]", + } + `); + }); +}); + +describe('#userLoginEvent', () => { + test('creates event with `success` outcome', () => { + expect( + userLoginEvent({ + authenticationResult: AuthenticationResult.succeeded(mockAuthenticatedUser()), + authenticationProvider: 'basic1', + authenticationType: 'basic', + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "user_login", + "category": "authentication", + "outcome": "success", + }, + "kibana": Object { + "authentication_provider": "basic1", + "authentication_realm": "native1", + "authentication_type": "basic", + "lookup_realm": "native1", + "space_id": undefined, + }, + "message": "User [user] has logged in using basic provider [name=basic1]", + "user": Object { + "name": "user", + "roles": Array [ + "user-role", + ], + }, + } + `); + }); + + test('creates event with `failure` outcome', () => { + expect( + userLoginEvent({ + authenticationResult: AuthenticationResult.failed(new Error('Not Authorized')), + authenticationProvider: 'basic1', + authenticationType: 'basic', + }) + ).toMatchInlineSnapshot(` + Object { + "error": Object { + "code": "Error", + "message": "Not Authorized", + }, + "event": Object { + "action": "user_login", + "category": "authentication", + "outcome": "failure", + }, + "kibana": Object { + "authentication_provider": "basic1", + "authentication_realm": undefined, + "authentication_type": "basic", + "lookup_realm": undefined, + "space_id": undefined, + }, + "message": "Failed attempt to login using basic provider [name=basic1]", + "user": undefined, + } + `); + }); +}); + +describe('#httpRequestEvent', () => { + test('creates event with `unknown` outcome', () => { + expect( + httpRequestEvent({ + request: httpServerMock.createKibanaRequest(), + }) + ).toMatchInlineSnapshot(` + Object { + "event": Object { + "action": "http_request", + "category": "web", + "outcome": "unknown", + }, + "http": Object { + "request": Object { + "method": "get", + }, + }, + "message": "User is requesting [/path] endpoint", + "url": Object { + "domain": undefined, + "path": "/path", + "port": undefined, + "query": undefined, + "scheme": undefined, + }, + } + `); + }); +}); diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts new file mode 100644 index 000000000000..48a3b1e7e85b --- /dev/null +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -0,0 +1,244 @@ +/* + * 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 { KibanaRequest } from 'src/core/server'; +import { AuthenticationResult } from '../authentication/authentication_result'; + +/** + * Audit event schema using ECS format. + * https://www.elastic.co/guide/en/ecs/1.5/index.html + * @public + */ +export interface AuditEvent { + /** + * Human readable message describing action, outcome and user. + * + * @example + * User [jdoe] logged in using basic provider [name=basic1] + */ + message: string; + event: { + action: string; + category?: EventCategory; + type?: EventType; + outcome?: EventOutcome; + module?: string; + dataset?: string; + }; + user?: { + name: string; + email?: string; + full_name?: string; + hash?: string; + roles?: readonly string[]; + }; + kibana?: { + /** + * Current space id of the request. + */ + space_id?: string; + /** + * Saved object that was created, changed, deleted or accessed as part of the action. + */ + saved_object?: { + type: string; + id?: string; + }; + /** + * Any additional event specific fields. + */ + [x: string]: any; + }; + error?: { + code?: string; + message?: string; + }; + http?: { + request?: { + method?: string; + body?: { + content: string; + }; + }; + response?: { + status_code?: number; + }; + }; + url?: { + domain?: string; + full?: string; + path?: string; + port?: number; + query?: string; + scheme?: string; + }; +} + +export enum EventCategory { + DATABASE = 'database', + WEB = 'web', + IAM = 'iam', + AUTHENTICATION = 'authentication', + PROCESS = 'process', +} + +export enum EventType { + USER = 'user', + GROUP = 'group', + CREATION = 'creation', + ACCESS = 'access', + CHANGE = 'change', + DELETION = 'deletion', +} + +export enum EventOutcome { + SUCCESS = 'success', + FAILURE = 'failure', + UNKNOWN = 'unknown', +} + +export interface HttpRequestParams { + request: KibanaRequest; +} + +export function httpRequestEvent({ request }: HttpRequestParams): AuditEvent { + const { pathname, search } = request.url; + + return { + message: `User is requesting [${pathname}] endpoint`, + event: { + action: 'http_request', + category: EventCategory.WEB, + outcome: EventOutcome.UNKNOWN, + }, + http: { + request: { + method: request.route.method, + }, + }, + url: { + domain: request.url.hostname, + path: pathname, + port: request.url.port ? parseInt(request.url.port, 10) : undefined, + query: search?.slice(1) || undefined, + scheme: request.url.protocol, + }, + }; +} + +export interface UserLoginParams { + authenticationResult: AuthenticationResult; + authenticationProvider?: string; + authenticationType?: string; +} + +export function userLoginEvent({ + authenticationResult, + authenticationProvider, + authenticationType, +}: UserLoginParams): AuditEvent { + return { + message: authenticationResult.user + ? `User [${authenticationResult.user.username}] has logged in using ${authenticationType} provider [name=${authenticationProvider}]` + : `Failed attempt to login using ${authenticationType} provider [name=${authenticationProvider}]`, + event: { + action: 'user_login', + category: EventCategory.AUTHENTICATION, + outcome: authenticationResult.user ? EventOutcome.SUCCESS : EventOutcome.FAILURE, + }, + user: authenticationResult.user && { + name: authenticationResult.user.username, + roles: authenticationResult.user.roles, + }, + kibana: { + space_id: undefined, // Ensure this does not get populated by audit service + authentication_provider: authenticationProvider, + authentication_type: authenticationType, + authentication_realm: authenticationResult.user?.authentication_realm.name, + lookup_realm: authenticationResult.user?.lookup_realm.name, + }, + error: authenticationResult.error && { + code: authenticationResult.error.name, + message: authenticationResult.error.message, + }, + }; +} + +export enum SavedObjectAction { + CREATE = 'saved_object_create', + GET = 'saved_object_get', + UPDATE = 'saved_object_update', + DELETE = 'saved_object_delete', + FIND = 'saved_object_find', + ADD_TO_SPACES = 'saved_object_add_to_spaces', + DELETE_FROM_SPACES = 'saved_object_delete_from_spaces', +} + +const eventVerbs = { + saved_object_create: ['create', 'creating', 'created'], + saved_object_get: ['access', 'accessing', 'accessed'], + saved_object_update: ['update', 'updating', 'updated'], + saved_object_delete: ['delete', 'deleting', 'deleted'], + saved_object_find: ['access', 'accessing', 'accessed'], + saved_object_add_to_spaces: ['update', 'updating', 'updated'], + saved_object_delete_from_spaces: ['update', 'updating', 'updated'], +}; + +const eventTypes = { + saved_object_create: EventType.CREATION, + saved_object_get: EventType.ACCESS, + saved_object_update: EventType.CHANGE, + saved_object_delete: EventType.DELETION, + saved_object_find: EventType.ACCESS, + saved_object_add_to_spaces: EventType.CHANGE, + saved_object_delete_from_spaces: EventType.CHANGE, +}; + +export interface SavedObjectParams { + action: SavedObjectAction; + outcome?: EventOutcome; + savedObject?: Required['kibana']>['saved_object']; + addToSpaces?: readonly string[]; + deleteFromSpaces?: readonly string[]; + error?: Error; +} + +export function savedObjectEvent({ + action, + savedObject, + addToSpaces, + deleteFromSpaces, + outcome, + error, +}: SavedObjectParams): AuditEvent { + const doc = savedObject ? `${savedObject.type} [id=${savedObject.id}]` : 'saved objects'; + const [present, progressive, past] = eventVerbs[action]; + const message = error + ? `Failed attempt to ${present} ${doc}` + : outcome === 'unknown' + ? `User is ${progressive} ${doc}` + : `User has ${past} ${doc}`; + const type = eventTypes[action]; + + return { + message, + event: { + action, + category: EventCategory.DATABASE, + type, + outcome: outcome ?? (error ? EventOutcome.FAILURE : EventOutcome.SUCCESS), + }, + kibana: { + saved_object: savedObject, + add_to_spaces: addToSpaces, + delete_from_spaces: deleteFromSpaces, + }, + error: error && { + code: error.name, + message: error.message, + }, + }; +} diff --git a/x-pack/plugins/security/server/audit/audit_service.test.ts b/x-pack/plugins/security/server/audit/audit_service.test.ts index b2d866d07ff8..60dbe341dc4b 100644 --- a/x-pack/plugins/security/server/audit/audit_service.test.ts +++ b/x-pack/plugins/security/server/audit/audit_service.test.ts @@ -3,163 +3,506 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { AuditService } from './audit_service'; -import { loggingSystemMock } from 'src/core/server/mocks'; +import { AuditService, filterEvent, createLoggingConfig } from './audit_service'; +import { AuditEvent, EventCategory, EventType, EventOutcome } from './audit_events'; +import { + coreMock, + loggingSystemMock, + httpServiceMock, + httpServerMock, +} from 'src/core/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; import { ConfigSchema, ConfigType } from '../config'; import { SecurityLicenseFeatures } from '../../common/licensing'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, Observable, of } from 'rxjs'; const createConfig = (settings: Partial) => { return ConfigSchema.validate(settings); }; -const config = createConfig({ - enabled: true, +const logger = loggingSystemMock.createLogger(); +const license = licenseMock.create(); +const config = createConfig({ enabled: true }); +const { logging } = coreMock.createSetup(); +const http = httpServiceMock.createSetupContract(); +const getCurrentUser = jest.fn().mockReturnValue({ username: 'jdoe', roles: ['admin'] }); +const getSpaceId = jest.fn().mockReturnValue('default'); + +beforeEach(() => { + logger.info.mockClear(); + logging.configure.mockClear(); + http.registerOnPostAuth.mockClear(); }); describe('#setup', () => { it('returns the expected contract', () => { - const logger = loggingSystemMock.createLogger(); const auditService = new AuditService(logger); - const license = licenseMock.create(); - expect(auditService.setup({ license, config })).toMatchInlineSnapshot(` + expect( + auditService.setup({ + license, + config, + logging, + http, + getCurrentUser, + getSpaceId, + }) + ).toMatchInlineSnapshot(` Object { + "asScoped": [Function], "getLogger": [Function], } `); }); -}); - -test(`calls the underlying logger with the provided message and requisite tags`, () => { - const pluginId = 'foo'; - const logger = loggingSystemMock.createLogger(); - const license = licenseMock.create(); - license.features$ = new BehaviorSubject({ - allowAuditLogging: true, - } as SecurityLicenseFeatures).asObservable(); + it('configures logging correctly when using ecs logger', async () => { + new AuditService(logger).setup({ + license, + config: { + enabled: true, + appender: { + kind: 'console', + layout: { + kind: 'pattern', + }, + }, + }, + logging, + http, + getCurrentUser, + getSpaceId, + }); + expect(logging.configure).toHaveBeenCalledWith(expect.any(Observable)); + }); - const auditService = new AuditService(logger).setup({ license, config }); + it('does not configure logging when using legacy logger', async () => { + new AuditService(logger).setup({ + license, + config: { + enabled: true, + }, + logging, + http, + getCurrentUser, + getSpaceId, + }); + expect(logging.configure).not.toHaveBeenCalled(); + }); - const auditLogger = auditService.getLogger(pluginId); + it('registers post auth hook', () => { + new AuditService(logger).setup({ + license, + config, + logging, + http, + getCurrentUser, + getSpaceId, + }); + expect(http.registerOnPostAuth).toHaveBeenCalledWith(expect.any(Function)); + }); +}); - const eventType = 'bar'; - const message = 'this is my audit message'; - auditLogger.log(eventType, message); +describe('#asScoped', () => { + it('logs event enriched with meta data', async () => { + const audit = new AuditService(logger).setup({ + license, + config, + logging, + http, + getCurrentUser, + getSpaceId, + }); + const request = httpServerMock.createKibanaRequest({ + kibanaRequestState: { requestId: 'REQUEST_ID', requestUuid: 'REQUEST_UUID' }, + }); + + audit.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } }); + expect(logger.info).toHaveBeenCalledWith('MESSAGE', { + event: { action: 'ACTION' }, + kibana: { space_id: 'default' }, + message: 'MESSAGE', + trace: { id: 'REQUEST_ID' }, + user: { name: 'jdoe', roles: ['admin'] }, + }); + }); - expect(logger.info).toHaveBeenCalledTimes(1); - expect(logger.info).toHaveBeenCalledWith(message, { - eventType, - tags: [pluginId, eventType], + it('does not log to audit logger if event matches ignore filter', async () => { + const audit = new AuditService(logger).setup({ + license, + config: { + enabled: true, + ignore_filters: [{ actions: ['ACTION'] }], + }, + logging, + http, + getCurrentUser, + getSpaceId, + }); + const request = httpServerMock.createKibanaRequest({ + kibanaRequestState: { requestId: 'REQUEST_ID', requestUuid: 'REQUEST_UUID' }, + }); + + audit.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } }); + expect(logger.info).not.toHaveBeenCalled(); }); }); -test(`calls the underlying logger with the provided metadata`, () => { - const pluginId = 'foo'; +describe('#createLoggingConfig', () => { + test('sets log level to `info` when audit logging is enabled and appender is defined', async () => { + const features$ = of({ + allowAuditLogging: true, + }); + + const loggingConfig = await features$ + .pipe( + createLoggingConfig({ + enabled: true, + appender: { + kind: 'console', + layout: { + kind: 'pattern', + }, + }, + }) + ) + .toPromise(); + + expect(loggingConfig).toMatchInlineSnapshot(` + Object { + "appenders": Object { + "auditTrailAppender": Object { + "kind": "console", + "layout": Object { + "kind": "pattern", + }, + }, + }, + "loggers": Array [ + Object { + "appenders": Array [ + "auditTrailAppender", + ], + "context": "audit", + "level": "info", + }, + ], + } + `); + }); - const logger = loggingSystemMock.createLogger(); - const license = licenseMock.create(); - license.features$ = new BehaviorSubject({ - allowAuditLogging: true, - } as SecurityLicenseFeatures).asObservable(); + test('sets log level to `off` when audit logging is disabled', async () => { + const features$ = of({ + allowAuditLogging: true, + }); + + const loggingConfig = await features$ + .pipe( + createLoggingConfig({ + enabled: false, + appender: { + kind: 'console', + layout: { + kind: 'pattern', + }, + }, + }) + ) + .toPromise(); + + expect(loggingConfig.loggers![0].level).toEqual('off'); + }); - const auditService = new AuditService(logger).setup({ license, config }); + test('sets log level to `off` when appender is not defined', async () => { + const features$ = of({ + allowAuditLogging: true, + }); - const auditLogger = auditService.getLogger(pluginId); + const loggingConfig = await features$ + .pipe( + createLoggingConfig({ + enabled: true, + }) + ) + .toPromise(); - const eventType = 'bar'; - const message = 'this is my audit message'; - const metadata = Object.freeze({ - property1: 'value1', - property2: false, - property3: 123, + expect(loggingConfig.loggers![0].level).toEqual('off'); }); - auditLogger.log(eventType, message, metadata); - expect(logger.info).toHaveBeenCalledTimes(1); - expect(logger.info).toHaveBeenCalledWith(message, { - eventType, - tags: [pluginId, eventType], - property1: 'value1', - property2: false, - property3: 123, + test('sets log level to `off` when license does not allow audit logging', async () => { + const features$ = of({ + allowAuditLogging: false, + }); + + const loggingConfig = await features$ + .pipe( + createLoggingConfig({ + enabled: true, + appender: { + kind: 'console', + layout: { + kind: 'pattern', + }, + }, + }) + ) + .toPromise(); + + expect(loggingConfig.loggers![0].level).toEqual('off'); }); }); -test(`does not call the underlying logger if license does not support audit logging`, () => { - const pluginId = 'foo'; +describe('#filterEvent', () => { + const event: AuditEvent = { + message: 'this is my audit message', + event: { + action: 'http_request', + category: EventCategory.WEB, + type: EventType.ACCESS, + outcome: EventOutcome.SUCCESS, + }, + user: { + name: 'jdoe', + }, + kibana: { + space_id: 'default', + }, + }; + + test('keeps event when ignore filters are undefined or empty', () => { + expect(filterEvent(event, undefined)).toBeTruthy(); + expect(filterEvent(event, [])).toBeTruthy(); + }); - const logger = loggingSystemMock.createLogger(); - const license = licenseMock.create(); - license.features$ = new BehaviorSubject({ - allowAuditLogging: false, - } as SecurityLicenseFeatures).asObservable(); + test('filters event correctly when a single match is found per criteria', () => { + expect(filterEvent(event, [{ actions: ['NO_MATCH'] }])).toBeTruthy(); + expect(filterEvent(event, [{ actions: ['NO_MATCH', 'http_request'] }])).toBeFalsy(); + expect(filterEvent(event, [{ categories: ['NO_MATCH', 'web'] }])).toBeFalsy(); + expect(filterEvent(event, [{ types: ['NO_MATCH', 'access'] }])).toBeFalsy(); + expect(filterEvent(event, [{ outcomes: ['NO_MATCH', 'success'] }])).toBeFalsy(); + expect(filterEvent(event, [{ spaces: ['NO_MATCH', 'default'] }])).toBeFalsy(); + }); - const auditService = new AuditService(logger).setup({ license, config }); + test('keeps event when one criteria per rule does not match', () => { + expect( + filterEvent(event, [ + { + actions: ['NO_MATCH'], + categories: ['web'], + types: ['access'], + outcomes: ['success'], + spaces: ['default'], + }, + { + actions: ['http_request'], + categories: ['NO_MATCH'], + types: ['access'], + outcomes: ['success'], + spaces: ['default'], + }, + { + actions: ['http_request'], + categories: ['web'], + types: ['NO_MATCH'], + outcomes: ['success'], + spaces: ['default'], + }, + { + actions: ['http_request'], + categories: ['web'], + types: ['access'], + outcomes: ['NO_MATCH'], + spaces: ['default'], + }, + { + actions: ['http_request'], + categories: ['web'], + types: ['access'], + outcomes: ['success'], + spaces: ['NO_MATCH'], + }, + ]) + ).toBeTruthy(); + }); - const auditLogger = auditService.getLogger(pluginId); + test('filters out event when all criteria in a single rule match', () => { + expect( + filterEvent(event, [ + { + actions: ['NO_MATCH'], + categories: ['NO_MATCH'], + types: ['NO_MATCH'], + outcomes: ['NO_MATCH'], + spaces: ['NO_MATCH'], + }, + { + actions: ['http_request'], + categories: ['web'], + types: ['access'], + outcomes: ['success'], + spaces: ['default'], + }, + ]) + ).toBeFalsy(); + }); +}); - const eventType = 'bar'; - const message = 'this is my audit message'; - auditLogger.log(eventType, message); +describe('#getLogger', () => { + test('calls the underlying logger with the provided message and requisite tags', () => { + const pluginId = 'foo'; + + const licenseWithFeatures = licenseMock.create(); + licenseWithFeatures.features$ = new BehaviorSubject({ + allowAuditLogging: true, + } as SecurityLicenseFeatures).asObservable(); + + const auditService = new AuditService(logger).setup({ + license: licenseWithFeatures, + config, + logging, + http, + getCurrentUser, + getSpaceId, + }); + + const auditLogger = auditService.getLogger(pluginId); + + const eventType = 'bar'; + const message = 'this is my audit message'; + auditLogger.log(eventType, message); + + expect(logger.info).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith(message, { + eventType, + tags: [pluginId, eventType], + }); + }); - expect(logger.info).not.toHaveBeenCalled(); -}); + test('calls the underlying logger with the provided metadata', () => { + const pluginId = 'foo'; + + const licenseWithFeatures = licenseMock.create(); + licenseWithFeatures.features$ = new BehaviorSubject({ + allowAuditLogging: true, + } as SecurityLicenseFeatures).asObservable(); + + const auditService = new AuditService(logger).setup({ + license: licenseWithFeatures, + config, + logging, + http, + getCurrentUser, + getSpaceId, + }); + + const auditLogger = auditService.getLogger(pluginId); + + const eventType = 'bar'; + const message = 'this is my audit message'; + const metadata = Object.freeze({ + property1: 'value1', + property2: false, + property3: 123, + }); + auditLogger.log(eventType, message, metadata); + + expect(logger.info).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith(message, { + eventType, + tags: [pluginId, eventType], + property1: 'value1', + property2: false, + property3: 123, + }); + }); + + test('does not call the underlying logger if license does not support audit logging', () => { + const pluginId = 'foo'; + + const licenseWithFeatures = licenseMock.create(); + licenseWithFeatures.features$ = new BehaviorSubject({ + allowAuditLogging: false, + } as SecurityLicenseFeatures).asObservable(); -test(`does not call the underlying logger if security audit logging is not enabled`, () => { - const pluginId = 'foo'; + const auditService = new AuditService(logger).setup({ + license: licenseWithFeatures, + config, + logging, + http, + getCurrentUser, + getSpaceId, + }); - const logger = loggingSystemMock.createLogger(); - const license = licenseMock.create(); - license.features$ = new BehaviorSubject({ - allowAuditLogging: true, - } as SecurityLicenseFeatures).asObservable(); + const auditLogger = auditService.getLogger(pluginId); - const auditService = new AuditService(logger).setup({ - license, - config: createConfig({ - enabled: false, - }), + const eventType = 'bar'; + const message = 'this is my audit message'; + auditLogger.log(eventType, message); + + expect(logger.info).not.toHaveBeenCalled(); }); - const auditLogger = auditService.getLogger(pluginId); + test('does not call the underlying logger if security audit logging is not enabled', () => { + const pluginId = 'foo'; - const eventType = 'bar'; - const message = 'this is my audit message'; - auditLogger.log(eventType, message); + const licenseWithFeatures = licenseMock.create(); + licenseWithFeatures.features$ = new BehaviorSubject({ + allowAuditLogging: true, + } as SecurityLicenseFeatures).asObservable(); - expect(logger.info).not.toHaveBeenCalled(); -}); + const auditService = new AuditService(logger).setup({ + license: licenseWithFeatures, + config: createConfig({ + enabled: false, + }), + logging, + http, + getCurrentUser, + getSpaceId, + }); -test(`calls the underlying logger after license upgrade`, () => { - const pluginId = 'foo'; + const auditLogger = auditService.getLogger(pluginId); - const logger = loggingSystemMock.createLogger(); - const license = licenseMock.create(); + const eventType = 'bar'; + const message = 'this is my audit message'; + auditLogger.log(eventType, message); + + expect(logger.info).not.toHaveBeenCalled(); + }); - const features$ = new BehaviorSubject({ - allowAuditLogging: false, - } as SecurityLicenseFeatures); + test('calls the underlying logger after license upgrade', () => { + const pluginId = 'foo'; - license.features$ = features$.asObservable(); + const licenseWithFeatures = licenseMock.create(); - const auditService = new AuditService(logger).setup({ license, config }); + const features$ = new BehaviorSubject({ + allowAuditLogging: false, + } as SecurityLicenseFeatures); - const auditLogger = auditService.getLogger(pluginId); + licenseWithFeatures.features$ = features$.asObservable(); - const eventType = 'bar'; - const message = 'this is my audit message'; - auditLogger.log(eventType, message); + const auditService = new AuditService(logger).setup({ + license: licenseWithFeatures, + config, + logging, + http, + getCurrentUser, + getSpaceId, + }); - expect(logger.info).not.toHaveBeenCalled(); + const auditLogger = auditService.getLogger(pluginId); - // perform license upgrade - features$.next({ - allowAuditLogging: true, - } as SecurityLicenseFeatures); + const eventType = 'bar'; + const message = 'this is my audit message'; + auditLogger.log(eventType, message); - auditLogger.log(eventType, message); + expect(logger.info).not.toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalledTimes(1); + // perform license upgrade + features$.next({ + allowAuditLogging: true, + } as SecurityLicenseFeatures); + + auditLogger.log(eventType, message); + + expect(logger.info).toHaveBeenCalledTimes(1); + }); }); diff --git a/x-pack/plugins/security/server/audit/audit_service.ts b/x-pack/plugins/security/server/audit/audit_service.ts index 93e69fd2601e..b84ad37332b8 100644 --- a/x-pack/plugins/security/server/audit/audit_service.ts +++ b/x-pack/plugins/security/server/audit/audit_service.ts @@ -5,53 +5,181 @@ */ import { Subscription } from 'rxjs'; -import { Logger } from '../../../../../src/core/server'; -import { SecurityLicense } from '../../common/licensing'; +import { map, distinctUntilKeyChanged } from 'rxjs/operators'; +import { + Logger, + LoggingServiceSetup, + KibanaRequest, + HttpServiceSetup, + LoggerContextConfigInput, +} from '../../../../../src/core/server'; +import { SecurityLicense, SecurityLicenseFeatures } from '../../common/licensing'; import { ConfigType } from '../config'; +import { SpacesPluginSetup } from '../../../spaces/server'; +import { AuditEvent, httpRequestEvent } from './audit_events'; +import { SecurityPluginSetup } from '..'; -export interface AuditLogger { +/** + * @deprecated + */ +export interface LegacyAuditLogger { log: (eventType: string, message: string, data?: Record) => void; } +export interface AuditLogger { + log: (event: AuditEvent) => void; +} + +interface AuditLogMeta extends AuditEvent { + session?: { + id: string; + }; + trace: { + id: string; + }; +} + export interface AuditServiceSetup { - getLogger: (id?: string) => AuditLogger; + asScoped: (request: KibanaRequest) => AuditLogger; + getLogger: (id?: string) => LegacyAuditLogger; } interface AuditServiceSetupParams { license: SecurityLicense; config: ConfigType['audit']; + logging: Pick; + http: Pick; + getCurrentUser( + request: KibanaRequest + ): ReturnType | undefined; + getSpaceId( + request: KibanaRequest + ): ReturnType | undefined; } export class AuditService { + /** + * @deprecated + */ private licenseFeaturesSubscription?: Subscription; - private auditLoggingEnabled = false; + /** + * @deprecated + */ + private allowAuditLogging = false; constructor(private readonly logger: Logger) {} - setup({ license, config }: AuditServiceSetupParams): AuditServiceSetup { - if (config.enabled) { + setup({ + license, + config, + logging, + http, + getCurrentUser, + getSpaceId, + }: AuditServiceSetupParams): AuditServiceSetup { + if (config.enabled && !config.appender) { this.licenseFeaturesSubscription = license.features$.subscribe(({ allowAuditLogging }) => { - this.auditLoggingEnabled = allowAuditLogging; + this.allowAuditLogging = allowAuditLogging; }); } - return { - getLogger: (id?: string): AuditLogger => { - return { - log: (eventType: string, message: string, data?: Record) => { - if (!this.auditLoggingEnabled) { - return; - } - - this.logger.info(message, { - tags: id ? [id, eventType] : [eventType], - eventType, - ...data, - }); + // Do not change logging for legacy logger + if (config.appender) { + // Configure logging during setup and when license changes + logging.configure( + license.features$.pipe( + distinctUntilKeyChanged('allowAuditLogging'), + createLoggingConfig(config) + ) + ); + } + + /** + * Creates an {@link AuditLogger} scoped to the current request. + * + * @example + * ```typescript + * const auditLogger = securitySetup.audit.asScoped(request); + * auditLogger.log(event); + * ``` + */ + const asScoped = (request: KibanaRequest): AuditLogger => { + /** + * Logs an {@link AuditEvent} and automatically adds meta data about the + * current user, space and correlation id. + * + * Guidelines around what events should be logged and how they should be + * structured can be found in: `/x-pack/plugins/security/README.md` + * + * @example + * ```typescript + * const auditLogger = securitySetup.audit.asScoped(request); + * auditLogger.log({ + * message: 'User is updating dashboard [id=123]', + * event: { + * action: 'saved_object_update', + * outcome: 'unknown' + * }, + * kibana: { + * saved_object: { type: 'dashboard', id: '123' } + * }, + * }); + * ``` + */ + const log = (event: AuditEvent) => { + const user = getCurrentUser(request); + const spaceId = getSpaceId(request); + const meta: AuditLogMeta = { + ...event, + user: + (user && { + name: user.username, + roles: user.roles, + }) || + event.user, + kibana: { + space_id: spaceId, + ...event.kibana, + }, + trace: { + id: request.id, }, }; - }, + if (filterEvent(meta, config.ignore_filters)) { + this.logger.info(event.message!, meta); + } + }; + return { log }; }; + + /** + * @deprecated + * Use `audit.asScoped(request)` method instead to create an audit logger + */ + const getLogger = (id?: string): LegacyAuditLogger => { + return { + log: (eventType: string, message: string, data?: Record) => { + if (!this.allowAuditLogging) { + return; + } + + this.logger.info(message, { + tags: id ? [id, eventType] : [eventType], + eventType, + ...data, + }); + }, + }; + }; + + http.registerOnPostAuth((request, response, t) => { + if (request.auth.isAuthenticated) { + asScoped(request).log(httpRequestEvent({ request })); + } + return t.next(); + }); + + return { asScoped, getLogger }; } stop() { @@ -61,3 +189,40 @@ export class AuditService { } } } + +export const createLoggingConfig = (config: ConfigType['audit']) => + map, LoggerContextConfigInput>((features) => ({ + appenders: { + auditTrailAppender: config.appender ?? { + kind: 'console', + layout: { + kind: 'pattern', + highlight: true, + }, + }, + }, + loggers: [ + { + context: 'audit', + level: config.enabled && config.appender && features.allowAuditLogging ? 'info' : 'off', + appenders: ['auditTrailAppender'], + }, + ], + })); + +export function filterEvent( + event: AuditEvent, + ignoreFilters: ConfigType['audit']['ignore_filters'] +) { + if (ignoreFilters) { + return !ignoreFilters.some( + (rule) => + (!rule.actions || rule.actions.includes(event.event.action)) && + (!rule.categories || rule.categories.includes(event.event.category!)) && + (!rule.types || rule.types.includes(event.event.type!)) && + (!rule.outcomes || rule.outcomes.includes(event.event.outcome!)) && + (!rule.spaces || rule.spaces.includes(event.kibana?.space_id!)) + ); + } + return true; +} diff --git a/x-pack/plugins/security/server/audit/index.mock.ts b/x-pack/plugins/security/server/audit/index.mock.ts index 07341cc06e88..cf95fbbffa96 100644 --- a/x-pack/plugins/security/server/audit/index.mock.ts +++ b/x-pack/plugins/security/server/audit/index.mock.ts @@ -21,6 +21,9 @@ export const auditServiceMock = { create() { return { getLogger: jest.fn(), + asScoped: jest.fn().mockReturnValue({ + log: jest.fn(), + }), } as jest.Mocked>; }, }; diff --git a/x-pack/plugins/security/server/audit/index.ts b/x-pack/plugins/security/server/audit/index.ts index 3db160c703e3..09f3df8b310e 100644 --- a/x-pack/plugins/security/server/audit/index.ts +++ b/x-pack/plugins/security/server/audit/index.ts @@ -4,5 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -export { AuditService, AuditServiceSetup, AuditLogger } from './audit_service'; +export { AuditService, AuditServiceSetup, AuditLogger, LegacyAuditLogger } from './audit_service'; +export { + AuditEvent, + EventCategory, + EventType, + EventOutcome, + userLoginEvent, + httpRequestEvent, + savedObjectEvent, + SavedObjectAction, +} from './audit_events'; export { SecurityAuditLogger } from './security_audit_logger'; diff --git a/x-pack/plugins/security/server/audit/security_audit_logger.ts b/x-pack/plugins/security/server/audit/security_audit_logger.ts index 87f7201f8566..ee81f5f330f4 100644 --- a/x-pack/plugins/security/server/audit/security_audit_logger.ts +++ b/x-pack/plugins/security/server/audit/security_audit_logger.ts @@ -5,11 +5,17 @@ */ import { AuthenticationProvider } from '../../common/types'; -import { AuditLogger } from './audit_service'; +import { LegacyAuditLogger } from './audit_service'; +/** + * @deprecated + */ export class SecurityAuditLogger { - constructor(private readonly logger: AuditLogger) {} + constructor(private readonly logger: LegacyAuditLogger) {} + /** + * @deprecated + */ savedObjectsAuthorizationFailure( username: string, action: string, @@ -37,6 +43,9 @@ export class SecurityAuditLogger { ); } + /** + * @deprecated + */ savedObjectsAuthorizationSuccess( username: string, action: string, @@ -59,6 +68,9 @@ export class SecurityAuditLogger { ); } + /** + * @deprecated + */ accessAgreementAcknowledged(username: string, provider: AuthenticationProvider) { this.logger.log( 'access_agreement_acknowledged', diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 4f52ebe3065a..e5bb00cdc056 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -19,7 +19,7 @@ import { } from '../../../../../src/core/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; -import { securityAuditLoggerMock } from '../audit/index.mock'; +import { auditServiceMock, securityAuditLoggerMock } from '../audit/index.mock'; import { sessionMock } from '../session_management/index.mock'; import { SecurityLicenseFeatures } from '../../common/licensing'; import { ConfigSchema, createConfig } from '../config'; @@ -40,7 +40,8 @@ function getMockOptions({ selector?: AuthenticatorOptions['config']['authc']['selector']; } = {}) { return { - auditLogger: securityAuditLoggerMock.create(), + legacyAuditLogger: securityAuditLoggerMock.create(), + audit: auditServiceMock.create(), getCurrentUser: jest.fn(), clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), basePath: httpServiceMock.createSetupContract().basePath, @@ -215,9 +216,15 @@ describe('Authenticator', () => { let authenticator: Authenticator; let mockOptions: ReturnType; let mockSessVal: SessionValue; + const auditLogger = { + log: jest.fn(), + }; + beforeEach(() => { + auditLogger.log.mockClear(); mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockOptions.session.get.mockResolvedValue(null); + mockOptions.audit.asScoped.mockReturnValue(auditLogger); mockSessVal = sessionMock.createValue({ state: { authorization: 'Basic xxx' } }); authenticator = new Authenticator(mockOptions); @@ -280,6 +287,49 @@ describe('Authenticator', () => { ); }); + it('adds audit event when successful.', async () => { + const request = httpServerMock.createKibanaRequest(); + const user = mockAuthenticatedUser(); + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) + ); + await authenticator.login(request, { provider: { type: 'basic' }, value: {} }); + + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: { action: 'user_login', category: 'authentication', outcome: 'success' }, + }) + ); + }); + + it('adds audit event when not successful.', async () => { + const request = httpServerMock.createKibanaRequest(); + const failureReason = new Error('Not Authorized'); + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.failed(failureReason) + ); + await authenticator.login(request, { provider: { type: 'basic' }, value: {} }); + + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: { action: 'user_login', category: 'authentication', outcome: 'failure' }, + }) + ); + }); + + it('does not add audit event when not handled.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect( + authenticator.login(request, { provider: { type: 'token' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + await authenticator.login(request, { provider: { name: 'basic2' }, value: {} }); + + expect(auditLogger.log).not.toHaveBeenCalled(); + }); + it('creates session whenever authentication provider returns state', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); @@ -1859,11 +1909,14 @@ describe('Authenticator', () => { accessAgreementAcknowledged: true, }); - expect(mockOptions.auditLogger.accessAgreementAcknowledged).toHaveBeenCalledTimes(1); - expect(mockOptions.auditLogger.accessAgreementAcknowledged).toHaveBeenCalledWith('user', { - type: 'basic', - name: 'basic1', - }); + expect(mockOptions.legacyAuditLogger.accessAgreementAcknowledged).toHaveBeenCalledTimes(1); + expect(mockOptions.legacyAuditLogger.accessAgreementAcknowledged).toHaveBeenCalledWith( + 'user', + { + type: 'basic', + name: 'basic1', + } + ); expect( mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index b8ec6258eb0d..0523ebaffb9d 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -13,7 +13,7 @@ import { import { SecurityLicense } from '../../common/licensing'; import { AuthenticatedUser } from '../../common/model'; import { AuthenticationProvider } from '../../common/types'; -import { SecurityAuditLogger } from '../audit'; +import { SecurityAuditLogger, AuditServiceSetup, userLoginEvent } from '../audit'; import { ConfigType } from '../config'; import { getErrorStatusCode } from '../errors'; import { SecurityFeatureUsageServiceStart } from '../feature_usage'; @@ -59,7 +59,8 @@ export interface ProviderLoginAttempt { } export interface AuthenticatorOptions { - auditLogger: SecurityAuditLogger; + legacyAuditLogger: SecurityAuditLogger; + audit: AuditServiceSetup; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null; config: Pick; @@ -293,6 +294,20 @@ export class Authenticator { existingSessionValue, }); + // Checking for presence of `user` object to determine success state rather than + // `success()` method since that indicates a successful authentication and `redirect()` + // could also (but does not always) authenticate a user successfully (e.g. SAML flow) + if (authenticationResult.user || authenticationResult.failed()) { + const auditLogger = this.options.audit.asScoped(request); + auditLogger.log( + userLoginEvent({ + authenticationResult, + authenticationProvider: providerName, + authenticationType: provider.type, + }) + ); + } + return this.handlePreAccessRedirects( request, authenticationResult, @@ -421,7 +436,7 @@ export class Authenticator { accessAgreementAcknowledged: true, }); - this.options.auditLogger.accessAgreementAcknowledged( + this.options.legacyAuditLogger.accessAgreementAcknowledged( currentUser.username, existingSessionValue.provider ); diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 263ea5c4e504..6f8f17a0a3c7 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -18,7 +18,7 @@ import { } from '../../../../../src/core/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; -import { securityAuditLoggerMock } from '../audit/index.mock'; +import { auditServiceMock, securityAuditLoggerMock } from '../audit/index.mock'; import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; import { sessionMock } from '../session_management/session.mock'; @@ -42,13 +42,14 @@ import { InvalidateAPIKeyParams, } from './api_keys'; import { SecurityLicense } from '../../common/licensing'; -import { SecurityAuditLogger } from '../audit'; +import { AuditServiceSetup, SecurityAuditLogger } from '../audit'; import { SecurityFeatureUsageServiceStart } from '../feature_usage'; import { Session } from '../session_management'; describe('setupAuthentication()', () => { let mockSetupAuthenticationParams: { - auditLogger: jest.Mocked; + legacyAuditLogger: jest.Mocked; + audit: jest.Mocked; config: ConfigType; loggers: LoggerFactory; http: jest.Mocked; @@ -60,7 +61,8 @@ describe('setupAuthentication()', () => { let mockScopedClusterClient: jest.Mocked>; beforeEach(() => { mockSetupAuthenticationParams = { - auditLogger: securityAuditLoggerMock.create(), + legacyAuditLogger: securityAuditLoggerMock.create(), + audit: auditServiceMock.create(), http: coreMock.createSetup().http, config: createConfig( ConfigSchema.validate({ diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 431c82fb28a6..ab8e42a6a72d 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -12,7 +12,7 @@ import { } from '../../../../../src/core/server'; import { SecurityLicense } from '../../common/licensing'; import { AuthenticatedUser } from '../../common/model'; -import { SecurityAuditLogger } from '../audit'; +import { SecurityAuditLogger, AuditServiceSetup } from '../audit'; import { ConfigType } from '../config'; import { getErrorStatusCode } from '../errors'; import { SecurityFeatureUsageServiceStart } from '../feature_usage'; @@ -45,7 +45,8 @@ export { } from './http_authentication'; interface SetupAuthenticationParams { - auditLogger: SecurityAuditLogger; + legacyAuditLogger: SecurityAuditLogger; + audit: AuditServiceSetup; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; http: HttpServiceSetup; clusterClient: ILegacyClusterClient; @@ -58,7 +59,8 @@ interface SetupAuthenticationParams { export type Authentication = UnwrapPromise>; export async function setupAuthentication({ - auditLogger, + legacyAuditLogger: auditLogger, + audit, getFeatureUsageService, http, clusterClient, @@ -82,7 +84,8 @@ export async function setupAuthentication({ }; const authenticator = new Authenticator({ - auditLogger, + legacyAuditLogger: auditLogger, + audit, loggers, clusterClient, basePath: http.basePath, diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 093a7643fbf6..32b8708d2b38 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -jest.mock('crypto', () => ({ randomBytes: jest.fn() })); +jest.mock('crypto', () => ({ + randomBytes: jest.fn(), + constants: jest.requireActual('crypto').constants, +})); import { loggingSystemMock } from '../../../../src/core/server/mocks'; import { createConfig, ConfigSchema } from './config'; @@ -150,31 +153,23 @@ describe('config schema', () => { }); it('should throw error if xpack.security.encryptionKey is less than 32 characters', () => { - expect(() => - ConfigSchema.validate({ encryptionKey: 'foo' }) - ).toThrowErrorMatchingInlineSnapshot( - `"[encryptionKey]: value has length [3] but it must have a minimum length of [32]."` + expect(() => ConfigSchema.validate({ encryptionKey: 'foo' })).toThrow( + '[encryptionKey]: value has length [3] but it must have a minimum length of [32].' ); - expect(() => - ConfigSchema.validate({ encryptionKey: 'foo' }, { dist: true }) - ).toThrowErrorMatchingInlineSnapshot( - `"[encryptionKey]: value has length [3] but it must have a minimum length of [32]."` + expect(() => ConfigSchema.validate({ encryptionKey: 'foo' }, { dist: true })).toThrow( + '[encryptionKey]: value has length [3] but it must have a minimum length of [32].' ); }); describe('authc.oidc', () => { it(`returns a validation error when authc.providers is "['oidc']" and realm is unspecified`, async () => { - expect(() => - ConfigSchema.validate({ authc: { providers: ['oidc'] } }) - ).toThrowErrorMatchingInlineSnapshot( - `"[authc.oidc.realm]: expected value of type [string] but got [undefined]"` + expect(() => ConfigSchema.validate({ authc: { providers: ['oidc'] } })).toThrow( + '[authc.oidc.realm]: expected value of type [string] but got [undefined]' ); - expect(() => - ConfigSchema.validate({ authc: { providers: ['oidc'], oidc: {} } }) - ).toThrowErrorMatchingInlineSnapshot( - `"[authc.oidc.realm]: expected value of type [string] but got [undefined]"` + expect(() => ConfigSchema.validate({ authc: { providers: ['oidc'], oidc: {} } })).toThrow( + '[authc.oidc.realm]: expected value of type [string] but got [undefined]' ); }); @@ -204,10 +199,8 @@ describe('config schema', () => { }); it(`returns a validation error when authc.providers is "['oidc', 'basic']" and realm is unspecified`, async () => { - expect(() => - ConfigSchema.validate({ authc: { providers: ['oidc', 'basic'] } }) - ).toThrowErrorMatchingInlineSnapshot( - `"[authc.oidc.realm]: expected value of type [string] but got [undefined]"` + expect(() => ConfigSchema.validate({ authc: { providers: ['oidc', 'basic'] } })).toThrow( + '[authc.oidc.realm]: expected value of type [string] but got [undefined]' ); }); @@ -240,22 +233,18 @@ describe('config schema', () => { it(`realm is not allowed when authc.providers is "['basic']"`, async () => { expect(() => ConfigSchema.validate({ authc: { providers: ['basic'], oidc: { realm: 'realm-1' } } }) - ).toThrowErrorMatchingInlineSnapshot(`"[authc.oidc]: a value wasn't expected to be present"`); + ).toThrow("[authc.oidc]: a value wasn't expected to be present"); }); }); describe('authc.saml', () => { it('fails if authc.providers includes `saml`, but `saml.realm` is not specified', async () => { - expect(() => - ConfigSchema.validate({ authc: { providers: ['saml'] } }) - ).toThrowErrorMatchingInlineSnapshot( - `"[authc.saml.realm]: expected value of type [string] but got [undefined]"` + expect(() => ConfigSchema.validate({ authc: { providers: ['saml'] } })).toThrow( + '[authc.saml.realm]: expected value of type [string] but got [undefined]' ); - expect(() => - ConfigSchema.validate({ authc: { providers: ['saml'], saml: {} } }) - ).toThrowErrorMatchingInlineSnapshot( - `"[authc.saml.realm]: expected value of type [string] but got [undefined]"` + expect(() => ConfigSchema.validate({ authc: { providers: ['saml'], saml: {} } })).toThrow( + '[authc.saml.realm]: expected value of type [string] but got [undefined]' ); expect( @@ -285,7 +274,7 @@ describe('config schema', () => { it('`realm` is not allowed if saml provider is not enabled', async () => { expect(() => ConfigSchema.validate({ authc: { providers: ['basic'], saml: { realm: 'realm-1' } } }) - ).toThrowErrorMatchingInlineSnapshot(`"[authc.saml]: a value wasn't expected to be present"`); + ).toThrow("[authc.saml]: a value wasn't expected to be present"); }); it('`maxRedirectURLSize` accepts any positive value that can coerce to `ByteSizeValue`', async () => { @@ -360,11 +349,9 @@ describe('config schema', () => { ConfigSchema.validate({ authc: { providers: { basic: { basic1: { enabled: true } } } }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.basic.basic1.order]: expected value of type [number] but got [undefined]" -`); + ).toThrow( + '[authc.providers.1.basic.basic1.order]: expected value of type [number] but got [undefined]' + ); }); it('cannot be hidden from selector', () => { @@ -374,11 +361,9 @@ describe('config schema', () => { providers: { basic: { basic1: { order: 0, showInSelector: false } } }, }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.basic.basic1.showInSelector]: \`basic\` provider only supports \`true\` in \`showInSelector\`." -`); + ).toThrow( + '[authc.providers.1.basic.basic1.showInSelector]: `basic` provider only supports `true` in `showInSelector`.' + ); }); it('can have only provider of this type', () => { @@ -386,11 +371,7 @@ describe('config schema', () => { ConfigSchema.validate({ authc: { providers: { basic: { basic1: { order: 0 }, basic2: { order: 1 } } } }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.basic]: Only one \\"basic\\" provider can be configured." -`); + ).toThrow('[authc.providers.1.basic]: Only one "basic" provider can be configured'); }); it('can be successfully validated', () => { @@ -420,11 +401,9 @@ describe('config schema', () => { ConfigSchema.validate({ authc: { providers: { token: { token1: { enabled: true } } } }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.token.token1.order]: expected value of type [number] but got [undefined]" -`); + ).toThrow( + '[authc.providers.1.token.token1.order]: expected value of type [number] but got [undefined]' + ); }); it('cannot be hidden from selector', () => { @@ -434,11 +413,9 @@ describe('config schema', () => { providers: { token: { token1: { order: 0, showInSelector: false } } }, }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.token.token1.showInSelector]: \`token\` provider only supports \`true\` in \`showInSelector\`." -`); + ).toThrow( + '[authc.providers.1.token.token1.showInSelector]: `token` provider only supports `true` in `showInSelector`.' + ); }); it('can have only provider of this type', () => { @@ -446,11 +423,7 @@ describe('config schema', () => { ConfigSchema.validate({ authc: { providers: { token: { token1: { order: 0 }, token2: { order: 1 } } } }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.token]: Only one \\"token\\" provider can be configured." -`); + ).toThrow('[authc.providers.1.token]: Only one "token" provider can be configured'); }); it('can be successfully validated', () => { @@ -480,11 +453,9 @@ describe('config schema', () => { ConfigSchema.validate({ authc: { providers: { pki: { pki1: { enabled: true } } } }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.pki.pki1.order]: expected value of type [number] but got [undefined]" -`); + ).toThrow( + '[authc.providers.1.pki.pki1.order]: expected value of type [number] but got [undefined]' + ); }); it('can have only provider of this type', () => { @@ -492,11 +463,7 @@ describe('config schema', () => { ConfigSchema.validate({ authc: { providers: { pki: { pki1: { order: 0 }, pki2: { order: 1 } } } }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.pki]: Only one \\"pki\\" provider can be configured." -`); + ).toThrow('[authc.providers.1.pki]: Only one "pki" provider can be configured'); }); it('can be successfully validated', () => { @@ -524,11 +491,9 @@ describe('config schema', () => { ConfigSchema.validate({ authc: { providers: { kerberos: { kerberos1: { enabled: true } } } }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.kerberos.kerberos1.order]: expected value of type [number] but got [undefined]" -`); + ).toThrow( + '[authc.providers.1.kerberos.kerberos1.order]: expected value of type [number] but got [undefined]' + ); }); it('can have only provider of this type', () => { @@ -538,11 +503,7 @@ describe('config schema', () => { providers: { kerberos: { kerberos1: { order: 0 }, kerberos2: { order: 1 } } }, }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.kerberos]: Only one \\"kerberos\\" provider can be configured." -`); + ).toThrow('[authc.providers.1.kerberos]: Only one "kerberos" provider can be configured'); }); it('can be successfully validated', () => { @@ -570,11 +531,9 @@ describe('config schema', () => { ConfigSchema.validate({ authc: { providers: { oidc: { oidc1: { enabled: true } } } }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.oidc.oidc1.order]: expected value of type [number] but got [undefined]" -`); + ).toThrow( + '[authc.providers.1.oidc.oidc1.order]: expected value of type [number] but got [undefined]' + ); }); it('requires `realm`', () => { @@ -582,11 +541,9 @@ describe('config schema', () => { ConfigSchema.validate({ authc: { providers: { oidc: { oidc1: { order: 0 } } } }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.oidc.oidc1.realm]: expected value of type [string] but got [undefined]" -`); + ).toThrow( + '[authc.providers.1.oidc.oidc1.realm]: expected value of type [string] but got [undefined]' + ); }); it('can be successfully validated', () => { @@ -625,11 +582,9 @@ describe('config schema', () => { ConfigSchema.validate({ authc: { providers: { saml: { saml1: { enabled: true } } } }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.saml.saml1.order]: expected value of type [number] but got [undefined]" -`); + ).toThrow( + '[authc.providers.1.saml.saml1.order]: expected value of type [number] but got [undefined]' + ); }); it('requires `realm`', () => { @@ -637,11 +592,9 @@ describe('config schema', () => { ConfigSchema.validate({ authc: { providers: { saml: { saml1: { order: 0 } } } }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.saml.saml1.realm]: expected value of type [string] but got [undefined]" -`); + ).toThrow( + '[authc.providers.1.saml.saml1.realm]: expected value of type [string] but got [undefined]' + ); }); it('can be successfully validated', () => { @@ -703,11 +656,9 @@ describe('config schema', () => { }, }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1]: Found multiple providers configured with the same name \\"provider1\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider1]" -`); + ).toThrow( + '[authc.providers.1]: Found multiple providers configured with the same name "provider1": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider1]' + ); }); it('`order` should be unique across all provider types', () => { @@ -723,11 +674,9 @@ describe('config schema', () => { }, }, }) - ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1]: Found multiple providers configured with the same order \\"0\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider2]" -`); + ).toThrow( + '[authc.providers.1]: Found multiple providers configured with the same order "0": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider2]' + ); }); it('can be successfully validated with multiple providers ignoring uniqueness violations in disabled ones', () => { @@ -792,10 +741,8 @@ describe('config schema', () => { describe('session', () => { it('should throw error if xpack.security.session.cleanupInterval is less than 10 seconds', () => { - expect(() => - ConfigSchema.validate({ session: { cleanupInterval: '9s' } }) - ).toThrowErrorMatchingInlineSnapshot( - `"[session.cleanupInterval]: the value must be greater or equal to 10 seconds."` + expect(() => ConfigSchema.validate({ session: { cleanupInterval: '9s' } })).toThrow( + '[session.cleanupInterval]: the value must be greater or equal to 10 seconds.' ); }); }); @@ -1091,4 +1038,55 @@ describe('createConfig()', () => { ] `); }); + + it('accepts an audit appender', () => { + expect( + ConfigSchema.validate({ + audit: { + appender: { + kind: 'file', + path: '/path/to/file.txt', + layout: { + kind: 'json', + }, + }, + }, + }).audit.appender + ).toMatchInlineSnapshot(` + Object { + "kind": "file", + "layout": Object { + "kind": "json", + }, + "path": "/path/to/file.txt", + } + `); + }); + + it('rejects an appender if not fully configured', () => { + expect(() => + ConfigSchema.validate({ + audit: { + // no layout configured + appender: { + kind: 'file', + path: '/path/to/file.txt', + }, + }, + }) + ).toThrow('[audit.appender.2.kind]: expected value to equal [legacy-appender]'); + }); + + it('rejects an ignore_filter when no appender is configured', () => { + expect(() => + ConfigSchema.validate({ + audit: { + enabled: true, + ignore_filters: [{ actions: ['some_action'] }], + }, + }) + ).toThrow( + '[audit]: xpack.security.audit.ignore_filters can only be used with the ECS audit logger. To enable the ECS audit logger, specify where you want to write the audit events using xpack.security.audit.appender.' + ); + }); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 9ccbdac5e09f..80b46a67ce01 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -7,7 +7,7 @@ import crypto from 'crypto'; import { schema, Type, TypeOf } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; -import { Logger } from '../../../../src/core/server'; +import { Logger, config as coreConfig } from '../../../../src/core/server'; export type ConfigType = ReturnType; @@ -198,9 +198,30 @@ export const ConfigSchema = schema.object({ schemes: schema.arrayOf(schema.string(), { defaultValue: ['apikey'] }), }), }), - audit: schema.object({ - enabled: schema.boolean({ defaultValue: false }), - }), + audit: schema.object( + { + enabled: schema.boolean({ defaultValue: false }), + appender: schema.maybe(coreConfig.logging.appenders), + ignore_filters: schema.maybe( + schema.arrayOf( + schema.object({ + actions: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), + categories: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), + types: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), + outcomes: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), + spaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), + }) + ) + ), + }, + { + validate: (auditConfig) => { + if (auditConfig.ignore_filters && !auditConfig.appender) { + return 'xpack.security.audit.ignore_filters can only be used with the ECS audit logger. To enable the ECS audit logger, specify where you want to write the audit events using xpack.security.audit.appender.'; + } + }, + } + ), }); export function createConfig( diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 00ad96211590..04db65f88cda 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -27,7 +27,7 @@ export { SAMLLogin, OIDCLogin, } from './authentication'; -export { AuditLogger } from './audit'; +export { LegacyAuditLogger } from './audit'; export { SecurityPluginSetup }; export { AuthenticatedUser } from '../common/model'; diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 9088d4f08d0e..9b08ba8c275f 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -55,6 +55,7 @@ describe('Security Plugin', () => { await expect(plugin.setup(mockCoreSetup, mockDependencies)).resolves.toMatchInlineSnapshot(` Object { "audit": Object { + "asScoped": [Function], "getLogger": [Function], }, "authc": Object { diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 5edc4c235727..52283290ba7b 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -67,7 +67,7 @@ export interface SecurityPluginSetup { 'actions' | 'checkPrivilegesDynamicallyWithRequest' | 'checkPrivilegesWithRequest' | 'mode' >; license: SecurityLicense; - audit: Pick; + audit: AuditServiceSetup; /** * If Spaces plugin is available it's supposed to register its SpacesService with Security plugin @@ -101,6 +101,7 @@ export class Plugin { private readonly logger: Logger; private spacesService?: SpacesService | symbol = Symbol('not accessed'); private securityLicenseService?: SecurityLicenseService; + private authc?: Authentication; private readonly featureUsageService = new SecurityFeatureUsageService(); private featureUsageServiceStart?: SecurityFeatureUsageServiceStart; @@ -176,8 +177,15 @@ export class Plugin { registerSecurityUsageCollector({ usageCollection, config, license }); - const audit = this.auditService.setup({ license, config: config.audit }); - const auditLogger = new SecurityAuditLogger(audit.getLogger()); + const audit = this.auditService.setup({ + license, + config: config.audit, + logging: core.logging, + http: core.http, + getSpaceId: (request) => this.getSpacesService()?.getSpaceId(request), + getCurrentUser: (request) => this.authc?.getCurrentUser(request), + }); + const legacyAuditLogger = new SecurityAuditLogger(audit.getLogger()); const { session } = this.sessionManagementService.setup({ config, @@ -187,8 +195,9 @@ export class Plugin { taskManager, }); - const authc = await setupAuthentication({ - auditLogger, + this.authc = await setupAuthentication({ + legacyAuditLogger, + audit, getFeatureUsageService: this.getFeatureUsageService, http: core.http, clusterClient, @@ -209,11 +218,12 @@ export class Plugin { buildNumber: this.initializerContext.env.packageInfo.buildNum, getSpacesService: this.getSpacesService, features, - getCurrentUser: authc.getCurrentUser, + getCurrentUser: this.authc.getCurrentUser, }); setupSavedObjects({ - auditLogger, + legacyAuditLogger, + audit, authz, savedObjects: core.savedObjects, getSpacesService: this.getSpacesService, @@ -226,7 +236,7 @@ export class Plugin { logger: this.initializerContext.logger.get('routes'), clusterClient, config, - authc, + authc: this.authc, authz, license, session, @@ -239,17 +249,18 @@ export class Plugin { return deepFreeze({ audit: { + asScoped: audit.asScoped, getLogger: audit.getLogger, }, authc: { - isAuthenticated: authc.isAuthenticated, - getCurrentUser: authc.getCurrentUser, - areAPIKeysEnabled: authc.areAPIKeysEnabled, - createAPIKey: authc.createAPIKey, - invalidateAPIKey: authc.invalidateAPIKey, - grantAPIKeyAsInternalUser: authc.grantAPIKeyAsInternalUser, - invalidateAPIKeyAsInternalUser: authc.invalidateAPIKeyAsInternalUser, + isAuthenticated: this.authc.isAuthenticated, + getCurrentUser: this.authc.getCurrentUser, + areAPIKeysEnabled: this.authc.areAPIKeysEnabled, + createAPIKey: this.authc.createAPIKey, + invalidateAPIKey: this.authc.invalidateAPIKey, + grantAPIKeyAsInternalUser: this.authc.grantAPIKeyAsInternalUser, + invalidateAPIKeyAsInternalUser: this.authc.invalidateAPIKeyAsInternalUser, }, authz: { diff --git a/x-pack/plugins/security/server/saved_objects/index.ts b/x-pack/plugins/security/server/saved_objects/index.ts index 6acfd06a0309..16c935e04893 100644 --- a/x-pack/plugins/security/server/saved_objects/index.ts +++ b/x-pack/plugins/security/server/saved_objects/index.ts @@ -12,11 +12,12 @@ import { } from '../../../../../src/core/server'; import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; import { AuthorizationServiceSetup } from '../authorization'; -import { SecurityAuditLogger } from '../audit'; +import { SecurityAuditLogger, AuditServiceSetup } from '../audit'; import { SpacesService } from '../plugin'; interface SetupSavedObjectsParams { - auditLogger: SecurityAuditLogger; + legacyAuditLogger: SecurityAuditLogger; + audit: AuditServiceSetup; authz: Pick< AuthorizationServiceSetup, 'mode' | 'actions' | 'checkSavedObjectsPrivilegesWithRequest' @@ -26,7 +27,8 @@ interface SetupSavedObjectsParams { } export function setupSavedObjects({ - auditLogger, + legacyAuditLogger, + audit, authz, savedObjects, getSpacesService, @@ -50,7 +52,8 @@ export function setupSavedObjects({ return authz.mode.useRbacForRequest(kibanaRequest) ? new SecureSavedObjectsClientWrapper({ actions: authz.actions, - auditLogger, + legacyAuditLogger, + auditLogger: audit.asScoped(kibanaRequest), baseClient: client, checkSavedObjectsPrivilegesAsCurrentUser: authz.checkSavedObjectsPrivilegesWithRequest( kibanaRequest diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index af1aaf16f7fe..8136553e4a62 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -6,10 +6,11 @@ import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; import { Actions } from '../authorization'; -import { securityAuditLoggerMock } from '../audit/index.mock'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { securityAuditLoggerMock, auditServiceMock } from '../audit/index.mock'; +import { savedObjectsClientMock, httpServerMock } from '../../../../../src/core/server/mocks'; import { SavedObjectsClientContract } from 'kibana/server'; import { SavedObjectActions } from '../authorization/actions/saved_object'; +import { AuditEvent, EventOutcome } from '../audit'; let clientOpts: ReturnType; let client: SecureSavedObjectsClientWrapper; @@ -38,7 +39,8 @@ const createSecureSavedObjectsClientWrapperOptions = () => { checkSavedObjectsPrivilegesAsCurrentUser: jest.fn(), errors, getSpacesService, - auditLogger: securityAuditLoggerMock.create(), + legacyAuditLogger: securityAuditLoggerMock.create(), + auditLogger: auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()), forbiddenError, generalError, }; @@ -53,8 +55,8 @@ const expectGeneralError = async (fn: Function, args: Record) => { clientOpts.generalError ); expect(clientOpts.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }; /** @@ -84,8 +86,8 @@ const expectForbiddenError = async (fn: Function, args: Record, act const spaceIds = [spaceId]; expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( USERNAME, action ?? ACTION, types, @@ -93,7 +95,7 @@ const expectForbiddenError = async (fn: Function, args: Record, act missing, args ); - expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }; const expectSuccess = async (fn: Function, args: Record, action?: string) => { @@ -105,9 +107,9 @@ const expectSuccess = async (fn: Function, args: Record, action?: s const types = getCalls.map((x) => x[0]); const spaceIds = args.options?.namespaces || [args.options?.namespace || 'default']; - expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( USERNAME, action ?? ACTION, types, @@ -176,6 +178,26 @@ const expectObjectNamespaceFiltering = async ( ); }; +const expectAuditEvent = ( + action: AuditEvent['event']['action'], + outcome: AuditEvent['event']['outcome'], + savedObject?: Required['kibana']['saved_object'] +) => { + expect(clientOpts.auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action, + outcome, + }), + kibana: savedObject + ? expect.objectContaining({ + saved_object: savedObject, + }) + : expect.anything(), + }) + ); +}; + const expectObjectsNamespaceFiltering = async (fn: Function, args: Record) => { clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( getMockCheckPrivilegesSuccess // privilege check for authorization @@ -200,15 +222,13 @@ const expectObjectsNamespaceFiltering = async (fn: Function, args: Record { ); expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect( + clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure + ).toHaveBeenCalledWith( USERNAME, 'addToNamespacesCreate', [type], @@ -308,7 +330,7 @@ describe('#addToNamespaces', () => { [{ privilege, spaceId: newNs1 }], { id, type, namespaces, options: {} } ); - expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`throws decorated ForbiddenError when unauthorized to update in current space`, async () => { @@ -324,9 +346,9 @@ describe('#addToNamespaces', () => { ); expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); expect( - clientOpts.auditLogger.savedObjectsAuthorizationFailure + clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure ).toHaveBeenLastCalledWith( USERNAME, 'addToNamespacesUpdate', @@ -335,7 +357,7 @@ describe('#addToNamespaces', () => { [{ privilege, spaceId: currentNs }], { id, type, namespaces, options: {} } ); - expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); }); test(`returns result of baseClient.addToNamespaces when authorized`, async () => { @@ -345,9 +367,9 @@ describe('#addToNamespaces', () => { const result = await client.addToNamespaces(type, id, namespaces); expect(result).toBe(apiCallReturnValue); - expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(2); - expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(2); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( 1, USERNAME, 'addToNamespacesCreate', // action for privilege check is 'share_to_space', but auditAction is 'addToNamespacesCreate' @@ -355,7 +377,7 @@ describe('#addToNamespaces', () => { namespaces.sort(), { type, id, namespaces, options: {} } ); - expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( 2, USERNAME, 'addToNamespacesUpdate', // action for privilege check is 'share_to_space', but auditAction is 'addToNamespacesUpdate' @@ -392,12 +414,28 @@ describe('#addToNamespaces', () => { // this operation is unique because it requires two privilege checks before it executes await expectObjectNamespaceFiltering(client.addToNamespaces, { type, id, namespaces }, 2); }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.addToNamespaces.mockReturnValue(apiCallReturnValue as any); + await client.addToNamespaces(type, id, namespaces); + + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_add_to_spaces', EventOutcome.UNKNOWN, { type, id }); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.addToNamespaces(type, id, namespaces)).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_add_to_spaces', EventOutcome.FAILURE, { type, id }); + }); }); describe('#bulkCreate', () => { const attributes = { some: 'attr' }; - const obj1 = Object.freeze({ type: 'foo', otherThing: 'sup', attributes }); - const obj2 = Object.freeze({ type: 'bar', otherThing: 'everyone', attributes }); + const obj1 = Object.freeze({ type: 'foo', id: 'sup', attributes }); + const obj2 = Object.freeze({ type: 'bar', id: 'everyone', attributes }); const namespace = 'some-ns'; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { @@ -445,6 +483,25 @@ describe('#bulkCreate', () => { const options = { namespace }; await expectObjectsNamespaceFiltering(client.bulkCreate, { objects, options }); }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any); + const objects = [obj1, obj2]; + const options = { namespace }; + await expectSuccess(client.bulkCreate, { objects, options }); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); + expectAuditEvent('saved_object_create', EventOutcome.UNKNOWN, { type: obj1.type, id: obj1.id }); + expectAuditEvent('saved_object_create', EventOutcome.UNKNOWN, { type: obj2.type, id: obj2.id }); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.bulkCreate([obj1, obj2], { namespace })).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); + expectAuditEvent('saved_object_create', EventOutcome.FAILURE, { type: obj1.type, id: obj1.id }); + expectAuditEvent('saved_object_create', EventOutcome.FAILURE, { type: obj2.type, id: obj2.id }); + }); }); describe('#bulkGet', () => { @@ -484,6 +541,25 @@ describe('#bulkGet', () => { const options = { namespace }; await expectObjectsNamespaceFiltering(client.bulkGet, { objects, options }); }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); + const objects = [obj1, obj2]; + const options = { namespace }; + await expectSuccess(client.bulkGet, { objects, options }); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); + expectAuditEvent('saved_object_get', EventOutcome.SUCCESS, obj1); + expectAuditEvent('saved_object_get', EventOutcome.SUCCESS, obj2); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.bulkGet([obj1, obj2], { namespace })).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); + expectAuditEvent('saved_object_get', EventOutcome.FAILURE, obj1); + expectAuditEvent('saved_object_get', EventOutcome.FAILURE, obj2); + }); }); describe('#bulkUpdate', () => { @@ -534,6 +610,25 @@ describe('#bulkUpdate', () => { const options = { namespace }; await expectObjectsNamespaceFiltering(client.bulkUpdate, { objects, options }); }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any); + const objects = [obj1, obj2]; + const options = { namespace }; + await expectSuccess(client.bulkUpdate, { objects, options }); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); + expectAuditEvent('saved_object_update', EventOutcome.UNKNOWN, { type: obj1.type, id: obj1.id }); + expectAuditEvent('saved_object_update', EventOutcome.UNKNOWN, { type: obj2.type, id: obj2.id }); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.bulkUpdate([obj1, obj2], { namespace })).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); + expectAuditEvent('saved_object_update', EventOutcome.FAILURE, { type: obj1.type, id: obj1.id }); + expectAuditEvent('saved_object_update', EventOutcome.FAILURE, { type: obj2.type, id: obj2.id }); + }); }); describe('#checkConflicts', () => { @@ -614,6 +709,22 @@ describe('#create', () => { const options = { namespace }; await expectObjectNamespaceFiltering(client.create, { type, attributes, options }); }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any); + const options = { namespace }; + await expectSuccess(client.create, { type, attributes, options }); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_create', EventOutcome.UNKNOWN, { type }); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.create(type, attributes, { namespace })).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_create', EventOutcome.FAILURE, { type }); + }); }); describe('#delete', () => { @@ -643,6 +754,22 @@ describe('#delete', () => { const options = { namespace }; await expectPrivilegeCheck(client.delete, { type, id, options }, namespace); }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.delete.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; + await expectSuccess(client.delete, { type, id, options }); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_delete', EventOutcome.UNKNOWN, { type, id }); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.delete(type, id)).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_delete', EventOutcome.FAILURE, { type, id }); + }); }); describe('#find', () => { @@ -663,8 +790,10 @@ describe('#find', () => { const result = await client.find(options); expect(clientOpts.baseClient.find).not.toHaveBeenCalled(); - expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect( + clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure + ).toHaveBeenCalledWith( USERNAME, 'find', [type1], @@ -759,6 +888,27 @@ describe('#find', () => { const options = { type: [type1, type2], namespaces }; await expectObjectsNamespaceFiltering(client.find, { options }); }); + + test(`adds audit event when successful`, async () => { + const obj1 = { type: 'foo', id: 'sup' }; + const obj2 = { type: 'bar', id: 'everyone' }; + const apiCallReturnValue = { saved_objects: [obj1, obj2], foo: 'bar' }; + clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); + const options = Object.freeze({ type: type1, namespaces: ['some-ns'] }); + await expectSuccess(client.find, { options }); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); + expectAuditEvent('saved_object_find', EventOutcome.SUCCESS, obj1); + expectAuditEvent('saved_object_find', EventOutcome.SUCCESS, obj2); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure + ); + await client.find({ type: type1 }); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_find', EventOutcome.FAILURE); + }); }); describe('#get', () => { @@ -793,6 +943,22 @@ describe('#get', () => { const options = { namespace }; await expectObjectNamespaceFiltering(client.get, { type, id, options }); }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.get.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; + await expectSuccess(client.get, { type, id, options }); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_get', EventOutcome.SUCCESS, { type, id }); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.get(type, id, { namespace })).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_get', EventOutcome.FAILURE, { type, id }); + }); }); describe('#deleteFromNamespaces', () => { @@ -817,8 +983,8 @@ describe('#deleteFromNamespaces', () => { ); expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( USERNAME, 'deleteFromNamespaces', // action for privilege check is 'share_to_space', but auditAction is 'deleteFromNamespaces' [type], @@ -826,7 +992,7 @@ describe('#deleteFromNamespaces', () => { [{ privilege, spaceId: namespace1 }], { type, id, namespaces, options: {} } ); - expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`returns result of baseClient.deleteFromNamespaces when authorized`, async () => { @@ -836,9 +1002,9 @@ describe('#deleteFromNamespaces', () => { const result = await client.deleteFromNamespaces(type, id, namespaces); expect(result).toBe(apiCallReturnValue); - expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( USERNAME, 'deleteFromNamespaces', // action for privilege check is 'share_to_space', but auditAction is 'deleteFromNamespaces' [type], @@ -864,6 +1030,21 @@ describe('#deleteFromNamespaces', () => { test(`filters namespaces that the user doesn't have access to`, async () => { await expectObjectNamespaceFiltering(client.deleteFromNamespaces, { type, id, namespaces }); }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.deleteFromNamespaces.mockReturnValue(apiCallReturnValue as any); + await client.deleteFromNamespaces(type, id, namespaces); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_delete_from_spaces', EventOutcome.UNKNOWN, { type, id }); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.deleteFromNamespaces(type, id, namespaces)).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_delete_from_spaces', EventOutcome.FAILURE, { type, id }); + }); }); describe('#update', () => { @@ -899,6 +1080,22 @@ describe('#update', () => { const options = { namespace }; await expectObjectNamespaceFiltering(client.update, { type, id, attributes, options }); }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.update.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; + await expectSuccess(client.update, { type, id, attributes, options }); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_update', EventOutcome.UNKNOWN, { type, id }); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.update(type, id, attributes, { namespace })).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_update', EventOutcome.FAILURE, { type, id }); + }); }); describe('other', () => { diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index d94dac942845..c7a3f31cc517 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -23,10 +23,12 @@ import { SecurityAuditLogger } from '../audit'; import { Actions, CheckSavedObjectsPrivileges } from '../authorization'; import { CheckPrivilegesResponse } from '../authorization/types'; import { SpacesService } from '../plugin'; +import { AuditLogger, EventOutcome, SavedObjectAction, savedObjectEvent } from '../audit'; interface SecureSavedObjectsClientWrapperOptions { actions: Actions; - auditLogger: SecurityAuditLogger; + legacyAuditLogger: SecurityAuditLogger; + auditLogger: AuditLogger; baseClient: SavedObjectsClientContract; errors: SavedObjectsClientContract['errors']; checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; @@ -58,7 +60,8 @@ interface EnsureAuthorizedTypeResult { export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract { private readonly actions: Actions; - private readonly auditLogger: PublicMethodsOf; + private readonly legacyAuditLogger: PublicMethodsOf; + private readonly auditLogger: AuditLogger; private readonly baseClient: SavedObjectsClientContract; private readonly checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; private getSpacesService: () => SpacesService | undefined; @@ -66,6 +69,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra constructor({ actions, + legacyAuditLogger, auditLogger, baseClient, checkSavedObjectsPrivilegesAsCurrentUser, @@ -74,6 +78,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra }: SecureSavedObjectsClientWrapperOptions) { this.errors = errors; this.actions = actions; + this.legacyAuditLogger = legacyAuditLogger; this.auditLogger = auditLogger; this.baseClient = baseClient; this.checkSavedObjectsPrivilegesAsCurrentUser = checkSavedObjectsPrivilegesAsCurrentUser; @@ -85,9 +90,27 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra attributes: T = {} as T, options: SavedObjectsCreateOptions = {} ) { - const args = { type, attributes, options }; - const namespaces = [options.namespace, ...(options.initialNamespaces || [])]; - await this.ensureAuthorized(type, 'create', namespaces, { args }); + try { + const args = { type, attributes, options }; + const namespaces = [options.namespace, ...(options.initialNamespaces || [])]; + await this.ensureAuthorized(type, 'create', namespaces, { args }); + } catch (error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.CREATE, + savedObject: { type, id: options.id }, + error, + }) + ); + throw error; + } + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.CREATE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type, id: options.id }, + }) + ); const savedObject = await this.baseClient.create(type, attributes, options); return await this.redactSavedObjectNamespaces(savedObject); @@ -112,25 +135,65 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra objects: Array>, options: SavedObjectsBaseOptions = {} ) { - const args = { objects, options }; - const namespaces = objects.reduce( - (acc, { initialNamespaces = [] }) => { - return acc.concat(initialNamespaces); - }, - [options.namespace] - ); + try { + const args = { objects, options }; + const namespaces = objects.reduce( + (acc, { initialNamespaces = [] }) => { + return acc.concat(initialNamespaces); + }, + [options.namespace] + ); - await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_create', namespaces, { - args, - }); + await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_create', namespaces, { + args, + }); + } catch (error) { + objects.forEach(({ type, id }) => + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.CREATE, + savedObject: { type, id }, + error, + }) + ) + ); + throw error; + } + objects.forEach(({ type, id }) => + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.CREATE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type, id }, + }) + ) + ); const response = await this.baseClient.bulkCreate(objects, options); return await this.redactSavedObjectsNamespaces(response); } public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { - const args = { type, id, options }; - await this.ensureAuthorized(type, 'delete', options.namespace, { args }); + try { + const args = { type, id, options }; + await this.ensureAuthorized(type, 'delete', options.namespace, { args }); + } catch (error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.DELETE, + savedObject: { type, id }, + error, + }) + ); + throw error; + } + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.DELETE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type, id }, + }) + ); return await this.baseClient.delete(type, id, options); } @@ -145,6 +208,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra `_find across namespaces is not permitted when the Spaces plugin is disabled.` ); } + const args = { options }; const { status, typeMap } = await this.ensureAuthorized( options.type, @@ -155,6 +219,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra if (status === 'unauthorized') { // return empty response + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.FIND, + error: new Error(status), + }) + ); return SavedObjectsUtils.createEmptyFindResponse(options); } @@ -163,11 +233,22 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra isGloballyAuthorized ? acc.set(type, options.namespaces) : acc.set(type, authorizedSpaces), new Map() ); + const response = await this.baseClient.find({ ...options, typeToNamespacesMap: undefined, // if the user is fully authorized, use `undefined` as the typeToNamespacesMap to prevent privilege escalation ...(status === 'partially_authorized' && { typeToNamespacesMap, type: '', namespaces: [] }), // the repository requires that `type` and `namespaces` must be empty if `typeToNamespacesMap` is defined }); + + response.saved_objects.forEach(({ type, id }) => + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.FIND, + savedObject: { type, id }, + }) + ) + ); + return await this.redactSavedObjectsNamespaces(response); } @@ -175,20 +256,67 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra objects: SavedObjectsBulkGetObject[] = [], options: SavedObjectsBaseOptions = {} ) { - const args = { objects, options }; - await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_get', options.namespace, { - args, - }); + try { + const args = { objects, options }; + await this.ensureAuthorized( + this.getUniqueObjectTypes(objects), + 'bulk_get', + options.namespace, + { + args, + } + ); + } catch (error) { + objects.forEach(({ type, id }) => + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.GET, + savedObject: { type, id }, + error, + }) + ) + ); + throw error; + } const response = await this.baseClient.bulkGet(objects, options); + + objects.forEach(({ type, id }) => + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.GET, + savedObject: { type, id }, + }) + ) + ); + return await this.redactSavedObjectsNamespaces(response); } public async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { - const args = { type, id, options }; - await this.ensureAuthorized(type, 'get', options.namespace, { args }); + try { + const args = { type, id, options }; + await this.ensureAuthorized(type, 'get', options.namespace, { args }); + } catch (error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.GET, + savedObject: { type, id }, + error, + }) + ); + throw error; + } const savedObject = await this.baseClient.get(type, id, options); + + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.GET, + savedObject: { type, id }, + }) + ); + return await this.redactSavedObjectNamespaces(savedObject); } @@ -198,8 +326,26 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra attributes: Partial, options: SavedObjectsUpdateOptions = {} ) { - const args = { type, id, attributes, options }; - await this.ensureAuthorized(type, 'update', options.namespace, { args }); + try { + const args = { type, id, attributes, options }; + await this.ensureAuthorized(type, 'update', options.namespace, { args }); + } catch (error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.UPDATE, + savedObject: { type, id }, + error, + }) + ); + throw error; + } + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.UPDATE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type, id }, + }) + ); const savedObject = await this.baseClient.update(type, id, attributes, options); return await this.redactSavedObjectNamespaces(savedObject); @@ -211,25 +357,45 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra namespaces: string[], options: SavedObjectsAddToNamespacesOptions = {} ) { - const args = { type, id, namespaces, options }; - const { namespace } = options; - // To share an object, the user must have the "share_to_space" permission in each of the destination namespaces. - await this.ensureAuthorized(type, 'share_to_space', namespaces, { - args, - auditAction: 'addToNamespacesCreate', - }); - - // To share an object, the user must also have the "share_to_space" permission in one or more of the source namespaces. Because the - // `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "share_to_space" permission in - // the current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation - // will result in a 404 error. - await this.ensureAuthorized(type, 'share_to_space', namespace, { - args, - auditAction: 'addToNamespacesUpdate', - }); + try { + const args = { type, id, namespaces, options }; + const { namespace } = options; + // To share an object, the user must have the "share_to_space" permission in each of the destination namespaces. + await this.ensureAuthorized(type, 'share_to_space', namespaces, { + args, + auditAction: 'addToNamespacesCreate', + }); + + // To share an object, the user must also have the "share_to_space" permission in one or more of the source namespaces. Because the + // `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "share_to_space" permission in + // the current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation + // will result in a 404 error. + await this.ensureAuthorized(type, 'share_to_space', namespace, { + args, + auditAction: 'addToNamespacesUpdate', + }); + } catch (error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.ADD_TO_SPACES, + savedObject: { type, id }, + addToSpaces: namespaces, + error, + }) + ); + throw error; + } + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.ADD_TO_SPACES, + outcome: EventOutcome.UNKNOWN, + savedObject: { type, id }, + addToSpaces: namespaces, + }) + ); - const result = await this.baseClient.addToNamespaces(type, id, namespaces, options); - return await this.redactSavedObjectNamespaces(result); + const response = await this.baseClient.addToNamespaces(type, id, namespaces, options); + return await this.redactSavedObjectNamespaces(response); } public async deleteFromNamespaces( @@ -238,31 +404,73 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra namespaces: string[], options: SavedObjectsDeleteFromNamespacesOptions = {} ) { - const args = { type, id, namespaces, options }; - // To un-share an object, the user must have the "share_to_space" permission in each of the target namespaces. - await this.ensureAuthorized(type, 'share_to_space', namespaces, { - args, - auditAction: 'deleteFromNamespaces', - }); + try { + const args = { type, id, namespaces, options }; + // To un-share an object, the user must have the "share_to_space" permission in each of the target namespaces. + await this.ensureAuthorized(type, 'share_to_space', namespaces, { + args, + auditAction: 'deleteFromNamespaces', + }); + } catch (error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.DELETE_FROM_SPACES, + savedObject: { type, id }, + deleteFromSpaces: namespaces, + error, + }) + ); + throw error; + } + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.DELETE_FROM_SPACES, + outcome: EventOutcome.UNKNOWN, + savedObject: { type, id }, + deleteFromSpaces: namespaces, + }) + ); - const result = await this.baseClient.deleteFromNamespaces(type, id, namespaces, options); - return await this.redactSavedObjectNamespaces(result); + const response = await this.baseClient.deleteFromNamespaces(type, id, namespaces, options); + return await this.redactSavedObjectNamespaces(response); } public async bulkUpdate( objects: Array> = [], options: SavedObjectsBaseOptions = {} ) { - const objectNamespaces = objects - // The repository treats an `undefined` object namespace is treated as the absence of a namespace, falling back to options.namespace; - // in this case, filter it out here so we don't accidentally check for privileges in the Default space when we shouldn't be doing so. - .filter(({ namespace }) => namespace !== undefined) - .map(({ namespace }) => namespace!); - const namespaces = [options?.namespace, ...objectNamespaces]; - const args = { objects, options }; - await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_update', namespaces, { - args, - }); + try { + const objectNamespaces = objects + // The repository treats an `undefined` object namespace is treated as the absence of a namespace, falling back to options.namespace; + // in this case, filter it out here so we don't accidentally check for privileges in the Default space when we shouldn't be doing so. + .filter(({ namespace }) => namespace !== undefined) + .map(({ namespace }) => namespace!); + const namespaces = [options?.namespace, ...objectNamespaces]; + const args = { objects, options }; + await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_update', namespaces, { + args, + }); + } catch (error) { + objects.forEach(({ type, id }) => + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.UPDATE, + savedObject: { type, id }, + error, + }) + ) + ); + throw error; + } + objects.forEach(({ type, id }) => + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.UPDATE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type, id }, + }) + ) + ); const response = await this.baseClient.bulkUpdate(objects, options); return await this.redactSavedObjectsNamespaces(response); @@ -316,7 +524,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ); const logAuthorizationFailure = () => { - this.auditLogger.savedObjectsAuthorizationFailure( + this.legacyAuditLogger.savedObjectsAuthorizationFailure( username, auditAction, types, @@ -326,7 +534,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ); }; const logAuthorizationSuccess = (typeArray: string[], spaceIdArray: string[]) => { - this.auditLogger.savedObjectsAuthorizationSuccess( + this.legacyAuditLogger.savedObjectsAuthorizationSuccess( username, auditAction, typeArray, diff --git a/x-pack/plugins/spaces/server/lib/audit_logger.ts b/x-pack/plugins/spaces/server/lib/audit_logger.ts index da7c3886277c..8110e3fbc662 100644 --- a/x-pack/plugins/spaces/server/lib/audit_logger.ts +++ b/x-pack/plugins/spaces/server/lib/audit_logger.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AuditLogger } from '../../../security/server'; +import { LegacyAuditLogger } from '../../../security/server'; export class SpacesAuditLogger { - private readonly auditLogger: AuditLogger; + private readonly auditLogger: LegacyAuditLogger; - constructor(auditLogger: AuditLogger = { log() {} }) { + constructor(auditLogger: LegacyAuditLogger = { log() {} }) { this.auditLogger = auditLogger; } public spacesAuthorizationFailure(username: string, action: string, spaceIds?: string[]) { diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 6c0edd904b0e..b15a2cf8d1f1 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -35,6 +35,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/security_api_integration/session_idle.config.ts'), require.resolve('../test/security_api_integration/session_lifespan.config.ts'), require.resolve('../test/security_api_integration/login_selector.config.ts'), + require.resolve('../test/security_api_integration/audit.config.ts'), require.resolve('../test/token_api_integration/config.js'), require.resolve('../test/oidc_api_integration/config.ts'), require.resolve('../test/oidc_api_integration/implicit_flow.config.ts'), diff --git a/x-pack/test/plugin_functional/config.ts b/x-pack/test/plugin_functional/config.ts index 40a3b3cf1877..e7d96023f365 100644 --- a/x-pack/test/plugin_functional/config.ts +++ b/x-pack/test/plugin_functional/config.ts @@ -27,7 +27,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { // list paths to the files that contain your plugins tests testFiles: [ - resolve(__dirname, './test_suites/audit_trail'), resolve(__dirname, './test_suites/resolver'), resolve(__dirname, './test_suites/global_search'), ], @@ -50,12 +49,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { )}`, // Required to load new platform plugins via `--plugin-path` flag. '--env.name=development', - - '--xpack.audit_trail.enabled=true', - '--xpack.audit_trail.logger.enabled=true', - '--xpack.audit_trail.appender.kind=file', - '--xpack.audit_trail.appender.path=x-pack/test/plugin_functional/plugins/audit_trail_test/server/pattern_debug.log', - '--xpack.audit_trail.appender.layout.kind=json', ], }, uiSettings: xpackFunctionalConfig.get('uiSettings'), diff --git a/x-pack/test/plugin_functional/plugins/audit_trail_test/server/.gitignore b/x-pack/test/plugin_functional/plugins/audit_trail_test/server/.gitignore deleted file mode 100644 index 9a3d28117919..000000000000 --- a/x-pack/test/plugin_functional/plugins/audit_trail_test/server/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/*debug.log diff --git a/x-pack/test/plugin_functional/plugins/audit_trail_test/server/plugin.ts b/x-pack/test/plugin_functional/plugins/audit_trail_test/server/plugin.ts deleted file mode 100644 index 264f436fb1dc..000000000000 --- a/x-pack/test/plugin_functional/plugins/audit_trail_test/server/plugin.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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 { Plugin, CoreSetup } from 'src/core/server'; - -export class AuditTrailTestPlugin implements Plugin { - public setup(core: CoreSetup) { - core.savedObjects.registerType({ - name: 'audit_trail_test', - hidden: false, - namespaceType: 'agnostic', - mappings: { - properties: {}, - }, - }); - - const router = core.http.createRouter(); - router.get( - { path: '/audit_trail_test/context/as_current_user', validate: false }, - async (context, request, response) => { - context.core.auditor.withAuditScope('audit_trail_test/context/as_current_user'); - await context.core.elasticsearch.legacy.client.callAsCurrentUser('ping'); - return response.noContent(); - } - ); - - router.get( - { path: '/audit_trail_test/context/as_internal_user', validate: false }, - async (context, request, response) => { - context.core.auditor.withAuditScope('audit_trail_test/context/as_internal_user'); - await context.core.elasticsearch.legacy.client.callAsInternalUser('ping'); - return response.noContent(); - } - ); - - router.get( - { path: '/audit_trail_test/contract/as_current_user', validate: false }, - async (context, request, response) => { - const [coreStart] = await core.getStartServices(); - const auditor = coreStart.auditTrail.asScoped(request); - auditor.withAuditScope('audit_trail_test/contract/as_current_user'); - - await context.core.elasticsearch.legacy.client.callAsCurrentUser('ping'); - return response.noContent(); - } - ); - - router.get( - { path: '/audit_trail_test/contract/as_internal_user', validate: false }, - async (context, request, response) => { - const [coreStart] = await core.getStartServices(); - const auditor = coreStart.auditTrail.asScoped(request); - auditor.withAuditScope('audit_trail_test/contract/as_internal_user'); - - await context.core.elasticsearch.legacy.client.callAsInternalUser('ping'); - return response.noContent(); - } - ); - } - - public start() {} -} diff --git a/x-pack/test/plugin_functional/test_suites/audit_trail/index.ts b/x-pack/test/plugin_functional/test_suites/audit_trail/index.ts deleted file mode 100644 index fb66f0dffc12..000000000000 --- a/x-pack/test/plugin_functional/test_suites/audit_trail/index.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* - * 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 Path from 'path'; -import Fs from 'fs'; -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -class FileWrapper { - constructor(private readonly path: string) {} - async reset() { - // "touch" each file to ensure it exists and is empty before each test - await Fs.promises.writeFile(this.path, ''); - } - async read() { - const content = await Fs.promises.readFile(this.path, { encoding: 'utf8' }); - return content.trim().split('\n'); - } - async readJSON() { - const content = await this.read(); - return content.map((l) => JSON.parse(l)); - } - // writing in a file is an async operation. we use this method to make sure logs have been written. - async isNotEmpty() { - const content = await this.read(); - const line = content[0]; - return line.length > 0; - } -} - -export default function ({ getPageObjects, getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const retry = getService('retry'); - - describe('Audit trail service', function () { - this.tags('ciGroup7'); - const logFilePath = Path.resolve( - __dirname, - '../../plugins/audit_trail_test/server/pattern_debug.log' - ); - const logFile = new FileWrapper(logFilePath); - - beforeEach(async () => { - await logFile.reset(); - }); - - it('logs current user access to elasticsearch via RequestHandlerContext', async () => { - await supertest - .get('/audit_trail_test/context/as_current_user') - .set('kbn-xsrf', 'foo') - .expect(204); - - await retry.waitFor('logs event in the dest file', async () => { - return await logFile.isNotEmpty(); - }); - - const content = await logFile.readJSON(); - const pingCall = content.find( - (c) => c.meta.scope === 'audit_trail_test/context/as_current_user' - ); - expect(pingCall).to.be.ok(); - expect(pingCall.meta.type).to.be('elasticsearch.call.currentUser'); - expect(pingCall.meta.user).to.be('elastic'); - expect(pingCall.meta.space).to.be('default'); - }); - - it('logs internal user access to elasticsearch via RequestHandlerContext', async () => { - await supertest - .get('/audit_trail_test/context/as_internal_user') - .set('kbn-xsrf', 'foo') - .expect(204); - - await retry.waitFor('logs event in the dest file', async () => { - return await logFile.isNotEmpty(); - }); - - const content = await logFile.readJSON(); - const pingCall = content.find( - (c) => c.meta.scope === 'audit_trail_test/context/as_internal_user' - ); - expect(pingCall).to.be.ok(); - expect(pingCall.meta.type).to.be('elasticsearch.call.internalUser'); - expect(pingCall.meta.user).to.be('elastic'); - expect(pingCall.meta.space).to.be('default'); - }); - - it('logs current user access to elasticsearch via coreStart contract', async () => { - await supertest - .get('/audit_trail_test/contract/as_current_user') - .set('kbn-xsrf', 'foo') - .expect(204); - - await retry.waitFor('logs event in the dest file', async () => { - return await logFile.isNotEmpty(); - }); - - const content = await logFile.readJSON(); - const pingCall = content.find( - (c) => c.meta.scope === 'audit_trail_test/contract/as_current_user' - ); - expect(pingCall).to.be.ok(); - expect(pingCall.meta.type).to.be('elasticsearch.call.currentUser'); - expect(pingCall.meta.user).to.be('elastic'); - expect(pingCall.meta.space).to.be('default'); - }); - - it('logs internal user access to elasticsearch via coreStart contract', async () => { - await supertest - .get('/audit_trail_test/contract/as_internal_user') - .set('kbn-xsrf', 'foo') - .expect(204); - - await retry.waitFor('logs event in the dest file', async () => { - return await logFile.isNotEmpty(); - }); - - const content = await logFile.readJSON(); - const pingCall = content.find( - (c) => c.meta.scope === 'audit_trail_test/contract/as_internal_user' - ); - expect(pingCall).to.be.ok(); - expect(pingCall.meta.type).to.be('elasticsearch.call.internalUser'); - expect(pingCall.meta.user).to.be('elastic'); - expect(pingCall.meta.space).to.be('default'); - }); - }); -} diff --git a/x-pack/test/security_api_integration/audit.config.ts b/x-pack/test/security_api_integration/audit.config.ts new file mode 100644 index 000000000000..c2011fafd1c9 --- /dev/null +++ b/x-pack/test/security_api_integration/audit.config.ts @@ -0,0 +1,37 @@ +/* + * 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 { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + + const auditLogPlugin = resolve(__dirname, './fixtures/audit/audit_log'); + const auditLogPath = resolve(__dirname, './fixtures/audit/audit.log'); + + return { + testFiles: [require.resolve('./tests/audit')], + servers: xPackAPITestsConfig.get('servers'), + security: { disableTestUser: true }, + services: xPackAPITestsConfig.get('services'), + junit: { + reportName: 'X-Pack Security API Integration Tests (Audit Log)', + }, + esTestCluster: xPackAPITestsConfig.get('esTestCluster'), + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${auditLogPlugin}`, + '--xpack.security.audit.enabled=true', + '--xpack.security.audit.appender.kind=file', + `--xpack.security.audit.appender.path=${auditLogPath}`, + '--xpack.security.audit.appender.layout.kind=json', + ], + }, + }; +} diff --git a/x-pack/test/plugin_functional/plugins/audit_trail_test/kibana.json b/x-pack/test/security_api_integration/fixtures/audit/audit_log/kibana.json similarity index 62% rename from x-pack/test/plugin_functional/plugins/audit_trail_test/kibana.json rename to x-pack/test/security_api_integration/fixtures/audit/audit_log/kibana.json index f53aa57ad670..fbec5108ee48 100644 --- a/x-pack/test/plugin_functional/plugins/audit_trail_test/kibana.json +++ b/x-pack/test/security_api_integration/fixtures/audit/audit_log/kibana.json @@ -1,9 +1,9 @@ { - "id": "audit_trail_test", + "id": "auditLog", "version": "1.0.0", "kibanaVersion": "kibana", "configPath": [], - "requiredPlugins": ["auditTrail"], + "requiredPlugins": [], "server": true, "ui": false } diff --git a/x-pack/test/plugin_functional/plugins/audit_trail_test/server/index.ts b/x-pack/test/security_api_integration/fixtures/audit/audit_log/server/index.ts similarity index 100% rename from x-pack/test/plugin_functional/plugins/audit_trail_test/server/index.ts rename to x-pack/test/security_api_integration/fixtures/audit/audit_log/server/index.ts diff --git a/x-pack/test/security_api_integration/fixtures/audit/audit_log/server/plugin.ts b/x-pack/test/security_api_integration/fixtures/audit/audit_log/server/plugin.ts new file mode 100644 index 000000000000..9f594cd5889b --- /dev/null +++ b/x-pack/test/security_api_integration/fixtures/audit/audit_log/server/plugin.ts @@ -0,0 +1,20 @@ +/* + * 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 { Plugin, CoreSetup } from 'src/core/server'; + +export class AuditTrailTestPlugin implements Plugin { + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + router.get({ path: '/audit_log', validate: false }, async (context, request, response) => { + await context.core.savedObjects.client.create('dashboard', {}); + await context.core.savedObjects.client.find({ type: 'dashboard' }); + return response.noContent(); + }); + } + + public start() {} +} diff --git a/x-pack/test/security_api_integration/tests/audit/audit_log.ts b/x-pack/test/security_api_integration/tests/audit/audit_log.ts new file mode 100644 index 000000000000..136854eab286 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/audit/audit_log.ts @@ -0,0 +1,118 @@ +/* + * 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 Path from 'path'; +import Fs from 'fs'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +class FileWrapper { + constructor(private readonly path: string) {} + async reset() { + // "touch" each file to ensure it exists and is empty before each test + await Fs.promises.writeFile(this.path, ''); + } + async read() { + const content = await Fs.promises.readFile(this.path, { encoding: 'utf8' }); + return content.trim().split('\n'); + } + async readJSON() { + const content = await this.read(); + return content.map((l) => JSON.parse(l)); + } + // writing in a file is an async operation. we use this method to make sure logs have been written. + async isNotEmpty() { + const content = await this.read(); + const line = content[0]; + return line.length > 0; + } +} + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + const { username, password } = getService('config').get('servers.kibana'); + + describe('Audit Log', function () { + const logFilePath = Path.resolve(__dirname, '../../fixtures/audit/audit.log'); + const logFile = new FileWrapper(logFilePath); + + beforeEach(async () => { + await logFile.reset(); + }); + + it('logs audit events when reading and writing saved objects', async () => { + await supertest.get('/audit_log?query=param').set('kbn-xsrf', 'foo').expect(204); + await retry.waitFor('logs event in the dest file', async () => await logFile.isNotEmpty()); + + const content = await logFile.readJSON(); + + const httpEvent = content.find((c) => c.event.action === 'http_request'); + expect(httpEvent).to.be.ok(); + expect(httpEvent.trace.id).to.be.ok(); + expect(httpEvent.user.name).to.be(username); + expect(httpEvent.kibana.space_id).to.be('default'); + expect(httpEvent.http.request.method).to.be('get'); + expect(httpEvent.url.path).to.be('/audit_log'); + expect(httpEvent.url.query).to.be('query=param'); + + const createEvent = content.find((c) => c.event.action === 'saved_object_create'); + expect(createEvent).to.be.ok(); + expect(createEvent.trace.id).to.be.ok(); + expect(createEvent.user.name).to.be(username); + expect(createEvent.kibana.space_id).to.be('default'); + + const findEvent = content.find((c) => c.event.action === 'saved_object_find'); + expect(findEvent).to.be.ok(); + expect(findEvent.trace.id).to.be.ok(); + expect(findEvent.user.name).to.be(username); + expect(findEvent.kibana.space_id).to.be('default'); + }); + + it('logs audit events when logging in successfully', async () => { + await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + await retry.waitFor('logs event in the dest file', async () => await logFile.isNotEmpty()); + + const content = await logFile.readJSON(); + + const loginEvent = content.find((c) => c.event.action === 'user_login'); + expect(loginEvent).to.be.ok(); + expect(loginEvent.event.outcome).to.be('success'); + expect(loginEvent.trace.id).to.be.ok(); + expect(loginEvent.user.name).to.be(username); + }); + + it('logs audit events when failing to log in', async () => { + await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password: 'invalid_password' }, + }) + .expect(401); + await retry.waitFor('logs event in the dest file', async () => await logFile.isNotEmpty()); + + const content = await logFile.readJSON(); + + const loginEvent = content.find((c) => c.event.action === 'user_login'); + expect(loginEvent).to.be.ok(); + expect(loginEvent.event.outcome).to.be('failure'); + expect(loginEvent.trace.id).to.be.ok(); + expect(loginEvent.user).not.to.be.ok(); + }); + }); +} diff --git a/x-pack/test/security_api_integration/tests/audit/index.ts b/x-pack/test/security_api_integration/tests/audit/index.ts new file mode 100644 index 000000000000..e4bec88ba490 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/audit/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('security APIs - Audit Log', function () { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./audit_log')); + }); +} From 5da0cb3f909bbbb9c7140c11342ed1d21470b092 Mon Sep 17 00:00:00 2001 From: Sandra Gonzales Date: Fri, 16 Oct 2020 15:45:19 -0400 Subject: [PATCH 04/11] [Ingest Manager] Fix for comparing versions with -SNAPSHOT suffix (#80742) * remove -SNAPSHOT from kibana version * add integration tests with -SNAPSHOT version of kibana * update isAgentUpgradeable to compare version numbers only * continue to send the kibana version with snapshot suffix to agent * cleanup code into one function * fix test to check for snapshot before adding suffix --- .../services/is_agent_upgradeable.test.ts | 31 ++++++++++++++ .../common/services/is_agent_upgradeable.ts | 11 +++-- .../server/routes/agent/upgrade_handler.ts | 32 +++++++++++---- .../apis/fleet/agents/upgrade.ts | 41 +++++++++++++++++++ 4 files changed, 103 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts index ca0fcd3c52c9..dc61f4898478 100644 --- a/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts +++ b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.test.ts @@ -135,4 +135,35 @@ describe('Ingest Manager - isAgentUpgradeable', () => { true ); }); + it('returns false if agent reports upgradeable, with agent snapshot version === kibana version', () => { + expect( + isAgentUpgradeable(getAgent({ version: '7.9.0-SNAPSHOT', upgradeable: true }), '7.9.0') + ).toBe(false); + }); + it('returns false if agent reports upgradeable, with agent version === kibana snapshot version', () => { + expect( + isAgentUpgradeable(getAgent({ version: '7.9.0', upgradeable: true }), '7.9.0-SNAPSHOT') + ).toBe(false); + }); + it('returns true if agent reports upgradeable, with agent snapshot version < kibana snapshot version', () => { + expect( + isAgentUpgradeable( + getAgent({ version: '7.9.0-SNAPSHOT', upgradeable: true }), + '8.0.0-SNAPSHOT' + ) + ).toBe(true); + }); + it('returns false if agent reports upgradeable, with agent snapshot version === kibana snapshot version', () => { + expect( + isAgentUpgradeable( + getAgent({ version: '8.0.0-SNAPSHOT', upgradeable: true }), + '8.0.0-SNAPSHOT' + ) + ).toBe(false); + }); + it('returns true if agent reports upgradeable, with agent version < kibana snapshot version', () => { + expect( + isAgentUpgradeable(getAgent({ version: '7.9.0', upgradeable: true }), '8.0.0-SNAPSHOT') + ).toBe(true); + }); }); diff --git a/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts index 7b59fb7b2282..b93e5d99543f 100644 --- a/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts +++ b/x-pack/plugins/ingest_manager/common/services/is_agent_upgradeable.ts @@ -14,9 +14,12 @@ export function isAgentUpgradeable(agent: Agent, kibanaVersion: string) { return false; } if (agent.unenrollment_started_at || agent.unenrolled_at) return false; - const kibanaVersionParsed = semver.parse(kibanaVersion); - const agentVersionParsed = semver.parse(agentVersion); - if (!agentVersionParsed || !kibanaVersionParsed) return false; if (!agent.local_metadata.elastic.agent.upgradeable) return false; - return semver.lt(agentVersionParsed, kibanaVersionParsed); + + // make sure versions are only the number before comparison + const agentVersionNumber = semver.coerce(agentVersion); + if (!agentVersionNumber) throw new Error('agent version is invalid'); + const kibanaVersionNumber = semver.coerce(kibanaVersion); + if (!kibanaVersionNumber) throw new Error('kibana version is invalid'); + return semver.lt(agentVersionNumber, kibanaVersionNumber); } diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts b/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts index 9c6b50b6d8f0..60dc7c6ee5f2 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/upgrade_handler.ts @@ -6,6 +6,7 @@ import { RequestHandler } from 'src/core/server'; import { TypeOf } from '@kbn/config-schema'; +import semver from 'semver'; import { AgentSOAttributes, PostAgentUpgradeResponse, @@ -26,17 +27,18 @@ export const postAgentUpgradeHandler: RequestHandler< > = async (context, request, response) => { const soClient = context.core.savedObjects.client; const { version, source_uri: sourceUri } = request.body; - - // temporarily only allow upgrading to the same version as the installed kibana version const kibanaVersion = appContextService.getKibanaVersion(); - if (kibanaVersion !== version) { + try { + checkVersionIsSame(version, kibanaVersion); + } catch (err) { return response.customError({ statusCode: 400, body: { - message: `cannot upgrade agent to ${version} because it is different than the installed kibana version ${kibanaVersion}`, + message: err.message, }, }); } + const agentSO = await soClient.get( AGENT_SAVED_OBJECT_TYPE, request.params.agentId @@ -82,14 +84,14 @@ export const postBulkAgentsUpgradeHandler: RequestHandler< > = async (context, request, response) => { const soClient = context.core.savedObjects.client; const { version, source_uri: sourceUri, agents } = request.body; - - // temporarily only allow upgrading to the same version as the installed kibana version const kibanaVersion = appContextService.getKibanaVersion(); - if (kibanaVersion !== version) { + try { + checkVersionIsSame(version, kibanaVersion); + } catch (err) { return response.customError({ statusCode: 400, body: { - message: `cannot upgrade agent to ${version} because it is different than the installed kibana version ${kibanaVersion}`, + message: err.message, }, }); } @@ -115,3 +117,17 @@ export const postBulkAgentsUpgradeHandler: RequestHandler< return defaultIngestErrorHandler({ error, response }); } }; + +export const checkVersionIsSame = (version: string, kibanaVersion: string) => { + // get version number only in case "-SNAPSHOT" is in it + const kibanaVersionNumber = semver.coerce(kibanaVersion)?.version; + if (!kibanaVersionNumber) throw new Error(`kibanaVersion ${kibanaVersionNumber} is not valid`); + const versionToUpgradeNumber = semver.coerce(version)?.version; + if (!versionToUpgradeNumber) + throw new Error(`version to upgrade ${versionToUpgradeNumber} is not valid`); + // temporarily only allow upgrading to the same version as the installed kibana version + if (kibanaVersionNumber !== versionToUpgradeNumber) + throw new Error( + `cannot upgrade agent to ${versionToUpgradeNumber} because it is different than the installed kibana version ${kibanaVersionNumber}` + ); +}; diff --git a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts index 055877c19c82..c5426168eb78 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/upgrade.ts @@ -11,6 +11,10 @@ import { setupIngest } from './services'; import { skipIfNoDockerRegistry } from '../../../helpers'; import { AGENT_SAVED_OBJECT_TYPE } from '../../../../../plugins/ingest_manager/common'; +const makeSnapshotVersion = (version: string) => { + return version.endsWith('-SNAPSHOT') ? version : `${version}-SNAPSHOT`; +}; + export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; const supertest = getService('supertest'); @@ -48,6 +52,43 @@ export default function (providerContext: FtrProviderContext) { const res = await supertest.get(`/api/fleet/agents/agent1`).set('kbn-xsrf', 'xxx'); expect(typeof res.body.item.upgrade_started_at).to.be('string'); }); + it('should respond 400 if upgrading agent with version the same as snapshot version', async () => { + const kibanaVersion = await kibanaServer.version.get(); + const kibanaVersionSnapshot = makeSnapshotVersion(kibanaVersion); + await kibanaServer.savedObjects.update({ + id: 'agent1', + type: AGENT_SAVED_OBJECT_TYPE, + attributes: { + local_metadata: { elastic: { agent: { upgradeable: true, version: kibanaVersion } } }, + }, + }); + await supertest + .post(`/api/fleet/agents/agent1/upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + version: kibanaVersionSnapshot, + }) + .expect(400); + }); + it('should respond 200 if upgrading agent with version less than kibana snapshot version', async () => { + const kibanaVersion = await kibanaServer.version.get(); + const kibanaVersionSnapshot = makeSnapshotVersion(kibanaVersion); + + await kibanaServer.savedObjects.update({ + id: 'agent1', + type: AGENT_SAVED_OBJECT_TYPE, + attributes: { + local_metadata: { elastic: { agent: { upgradeable: true, version: '0.0.0' } } }, + }, + }); + await supertest + .post(`/api/fleet/agents/agent1/upgrade`) + .set('kbn-xsrf', 'xxx') + .send({ + version: kibanaVersionSnapshot, + }) + .expect(200); + }); it('should respond 200 to upgrade agent and update the agent SO without source_uri', async () => { const kibanaVersion = await kibanaServer.version.get(); await kibanaServer.savedObjects.update({ From 7adfcd8f70fa5ce53bc2aa64bf64488c42f39a3c Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 16 Oct 2020 13:41:39 -0700 Subject: [PATCH 05/11] [keystore_cli] parse values as JSON before adding to keystore (#80848) Co-authored-by: spalger --- src/cli_keystore/add.js | 10 +++++++++- src/cli_keystore/add.test.js | 11 +++++++++++ src/legacy/server/keystore/keystore.test.js | 2 ++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/cli_keystore/add.js b/src/cli_keystore/add.js index 232392f34c63..d88256da1aa5 100644 --- a/src/cli_keystore/add.js +++ b/src/cli_keystore/add.js @@ -59,7 +59,15 @@ export async function add(keystore, key, options = {}) { value = await question(`Enter value for ${key}`, { mask: '*' }); } - keystore.add(key, value.trim()); + const parsedValue = value.trim(); + let parsedJsonValue; + try { + parsedJsonValue = JSON.parse(parsedValue); + } catch { + // noop, only treat value as json if it parses as JSON + } + + keystore.add(key, parsedJsonValue ?? parsedValue); keystore.save(); } diff --git a/src/cli_keystore/add.test.js b/src/cli_keystore/add.test.js index f1adee8879bc..ba381ca2f3e1 100644 --- a/src/cli_keystore/add.test.js +++ b/src/cli_keystore/add.test.js @@ -129,6 +129,17 @@ describe('Kibana keystore', () => { expect(keystore.data.foo).toEqual('bar'); }); + it('parses JSON values', async () => { + prompt.question.returns(Promise.resolve('["bar"]\n')); + + const keystore = new Keystore('/data/test.keystore'); + sandbox.stub(keystore, 'save'); + + await add(keystore, 'foo'); + + expect(keystore.data.foo).toEqual(['bar']); + }); + it('persists updated keystore', async () => { prompt.question.returns(Promise.resolve('bar\n')); diff --git a/src/legacy/server/keystore/keystore.test.js b/src/legacy/server/keystore/keystore.test.js index 0897ce55d086..e35edd185948 100644 --- a/src/legacy/server/keystore/keystore.test.js +++ b/src/legacy/server/keystore/keystore.test.js @@ -157,11 +157,13 @@ describe('Keystore', () => { it('adds a key/value pair', () => { const keystore = new Keystore('/data/unprotected.keystore'); keystore.add('a3', 'baz'); + keystore.add('a4', [1, 'a', 2, 'b']); expect(keystore.data).toEqual({ 'a1.b2.c3': 'foo', a2: 'bar', a3: 'baz', + a4: [1, 'a', 2, 'b'], }); }); }); From 7e47e3935ea58cc69f566b8970f8f503e3fcbe36 Mon Sep 17 00:00:00 2001 From: spalger Date: Fri, 16 Oct 2020 13:53:58 -0700 Subject: [PATCH 06/11] skip flaky suite (#80914) --- test/functional/apps/discover/_sidebar.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/discover/_sidebar.js b/test/functional/apps/discover/_sidebar.js index ce7ebff9cce7..f7784b739336 100644 --- a/test/functional/apps/discover/_sidebar.js +++ b/test/functional/apps/discover/_sidebar.js @@ -25,7 +25,8 @@ export default function ({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); - describe('discover sidebar', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/80914 + describe.skip('discover sidebar', function describeIndexTests() { before(async function () { // delete .kibana index and update configDoc await kibanaServer.uiSettings.replace({ From 958d36e2f47539101c3416891e602b355a4a1a9f Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Fri, 16 Oct 2020 18:12:26 -0400 Subject: [PATCH 07/11] Added Enterprise Search config to kibana-docker (#80872) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../os_packages/docker_generator/resources/bin/kibana-docker | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index 0039debe383b..f5cf6c85fcbe 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -47,6 +47,10 @@ kibana_vars=( elasticsearch.ssl.truststore.password elasticsearch.ssl.verificationMode elasticsearch.username + enterpriseSearch.accessCheckTimeout + enterpriseSearch.accessCheckTimeoutWarning + enterpriseSearch.enabled + enterpriseSearch.host i18n.locale interpreter.enableInVisualize kibana.autocompleteTerminateAfter From 71f4c085b72034b1fc5e00c1c8914500da321f87 Mon Sep 17 00:00:00 2001 From: spalger Date: Fri, 16 Oct 2020 15:25:26 -0700 Subject: [PATCH 08/11] skip flaky suite (#80929) --- x-pack/test/accessibility/apps/home.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/accessibility/apps/home.ts b/x-pack/test/accessibility/apps/home.ts index 110201674b39..280769bc09bc 100644 --- a/x-pack/test/accessibility/apps/home.ts +++ b/x-pack/test/accessibility/apps/home.ts @@ -13,7 +13,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const globalNav = getService('globalNav'); const testSubjects = getService('testSubjects'); - describe('Kibana Home', () => { + // FLAKY: https://github.com/elastic/kibana/issues/80929 + describe.skip('Kibana Home', () => { before(async () => { await PageObjects.common.navigateToApp('home'); }); From 72fa61ba71dee3b01da4c14db0aad3c9c13922b7 Mon Sep 17 00:00:00 2001 From: Thom Heymann <190132+thomheymann@users.noreply.github.com> Date: Sat, 17 Oct 2020 00:48:56 +0100 Subject: [PATCH 09/11] Fix audit logger logging to console even when disabled (#80928) --- .../server/audit/audit_service.test.ts | 16 +---------- .../security/server/audit/audit_service.ts | 27 ++++++++++--------- 2 files changed, 15 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/security/server/audit/audit_service.test.ts b/x-pack/plugins/security/server/audit/audit_service.test.ts index 60dbe341dc4b..e0dd98c7de63 100644 --- a/x-pack/plugins/security/server/audit/audit_service.test.ts +++ b/x-pack/plugins/security/server/audit/audit_service.test.ts @@ -74,20 +74,6 @@ describe('#setup', () => { expect(logging.configure).toHaveBeenCalledWith(expect.any(Observable)); }); - it('does not configure logging when using legacy logger', async () => { - new AuditService(logger).setup({ - license, - config: { - enabled: true, - }, - logging, - http, - getCurrentUser, - getSpaceId, - }); - expect(logging.configure).not.toHaveBeenCalled(); - }); - it('registers post auth hook', () => { new AuditService(logger).setup({ license, @@ -181,7 +167,7 @@ describe('#createLoggingConfig', () => { "appenders": Array [ "auditTrailAppender", ], - "context": "audit", + "context": "audit.ecs", "level": "info", }, ], diff --git a/x-pack/plugins/security/server/audit/audit_service.ts b/x-pack/plugins/security/server/audit/audit_service.ts index b84ad37332b8..31c7e28be3b8 100644 --- a/x-pack/plugins/security/server/audit/audit_service.ts +++ b/x-pack/plugins/security/server/audit/audit_service.ts @@ -67,7 +67,11 @@ export class AuditService { */ private allowAuditLogging = false; - constructor(private readonly logger: Logger) {} + private ecsLogger: Logger; + + constructor(private readonly logger: Logger) { + this.ecsLogger = logger.get('ecs'); + } setup({ license, @@ -83,16 +87,13 @@ export class AuditService { }); } - // Do not change logging for legacy logger - if (config.appender) { - // Configure logging during setup and when license changes - logging.configure( - license.features$.pipe( - distinctUntilKeyChanged('allowAuditLogging'), - createLoggingConfig(config) - ) - ); - } + // Configure logging during setup and when license changes + logging.configure( + license.features$.pipe( + distinctUntilKeyChanged('allowAuditLogging'), + createLoggingConfig(config) + ) + ); /** * Creates an {@link AuditLogger} scoped to the current request. @@ -146,7 +147,7 @@ export class AuditService { }, }; if (filterEvent(meta, config.ignore_filters)) { - this.logger.info(event.message!, meta); + this.ecsLogger.info(event.message!, meta); } }; return { log }; @@ -203,7 +204,7 @@ export const createLoggingConfig = (config: ConfigType['audit']) => }, loggers: [ { - context: 'audit', + context: 'audit.ecs', level: config.enabled && config.appender && features.allowAuditLogging ? 'info' : 'off', appenders: ['auditTrailAppender'], }, From ef2be2c7252c0105fa7392a57be9ace101067607 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Sat, 17 Oct 2020 10:48:58 +0300 Subject: [PATCH 10/11] server logs config paths to use for runner (#52980) * server logs config paths to use for runner * fix eslint issue * do not log config path for default config * update snapshots * fix other tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: spalger --- .../cli/start_servers/__snapshots__/args.test.js.snap | 10 ++++++++++ .../src/functional_tests/cli/start_servers/args.js | 4 +++- packages/kbn-test/src/functional_tests/tasks.js | 9 ++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/kbn-test/src/functional_tests/cli/start_servers/__snapshots__/args.test.js.snap b/packages/kbn-test/src/functional_tests/cli/start_servers/__snapshots__/args.test.js.snap index 809b635369a3..cd3174d13c3e 100644 --- a/packages/kbn-test/src/functional_tests/cli/start_servers/__snapshots__/args.test.js.snap +++ b/packages/kbn-test/src/functional_tests/cli/start_servers/__snapshots__/args.test.js.snap @@ -26,6 +26,7 @@ Object { "debug": true, "esFrom": "snapshot", "extraKbnOpts": undefined, + "useDefaultConfig": true, } `; @@ -35,6 +36,7 @@ Object { "createLogger": [Function], "esFrom": "snapshot", "extraKbnOpts": undefined, + "useDefaultConfig": true, } `; @@ -49,6 +51,7 @@ Object { "extraKbnOpts": Object { "server.foo": "bar", }, + "useDefaultConfig": true, } `; @@ -59,6 +62,7 @@ Object { "esFrom": "snapshot", "extraKbnOpts": undefined, "quiet": true, + "useDefaultConfig": true, } `; @@ -69,6 +73,7 @@ Object { "esFrom": "snapshot", "extraKbnOpts": undefined, "silent": true, + "useDefaultConfig": true, } `; @@ -78,6 +83,7 @@ Object { "createLogger": [Function], "esFrom": "source", "extraKbnOpts": undefined, + "useDefaultConfig": true, } `; @@ -87,6 +93,7 @@ Object { "createLogger": [Function], "esFrom": "source", "extraKbnOpts": undefined, + "useDefaultConfig": true, } `; @@ -97,6 +104,7 @@ Object { "esFrom": "snapshot", "extraKbnOpts": undefined, "installDir": "foo", + "useDefaultConfig": true, } `; @@ -106,6 +114,7 @@ Object { "createLogger": [Function], "esFrom": "snapshot", "extraKbnOpts": undefined, + "useDefaultConfig": true, "verbose": true, } `; @@ -116,5 +125,6 @@ Object { "createLogger": [Function], "esFrom": "snapshot", "extraKbnOpts": undefined, + "useDefaultConfig": true, } `; diff --git a/packages/kbn-test/src/functional_tests/cli/start_servers/args.js b/packages/kbn-test/src/functional_tests/cli/start_servers/args.js index e604e86de8b3..2b32726557ba 100644 --- a/packages/kbn-test/src/functional_tests/cli/start_servers/args.js +++ b/packages/kbn-test/src/functional_tests/cli/start_servers/args.js @@ -75,7 +75,8 @@ export function displayHelp() { export function processOptions(userOptions, defaultConfigPath) { validateOptions(userOptions); - const config = userOptions.config || defaultConfigPath; + const useDefaultConfig = !userOptions.config; + const config = useDefaultConfig ? defaultConfigPath : userOptions.config; if (!config) { throw new Error(`functional_tests_server: config is required`); @@ -100,6 +101,7 @@ export function processOptions(userOptions, defaultConfigPath) { return { ...userOptions, config: resolve(config), + useDefaultConfig, createLogger, extraKbnOpts: userOptions._, }; diff --git a/packages/kbn-test/src/functional_tests/tasks.js b/packages/kbn-test/src/functional_tests/tasks.js index 7d4fc84d47bd..c2833cbbda33 100644 --- a/packages/kbn-test/src/functional_tests/tasks.js +++ b/packages/kbn-test/src/functional_tests/tasks.js @@ -36,6 +36,13 @@ import { readConfigFile } from '../functional_test_runner/lib'; const makeSuccessMessage = (options) => { const installDirFlag = options.installDir ? ` --kibana-install-dir=${options.installDir}` : ''; + const configPaths = Array.isArray(options.config) ? options.config : [options.config]; + const pathsMessage = options.useDefaultConfig + ? '' + : configPaths + .map((path) => relative(process.cwd(), path)) + .map((path) => ` --config ${path}`) + .join(''); return ( '\n\n' + @@ -43,7 +50,7 @@ const makeSuccessMessage = (options) => { Elasticsearch and Kibana are ready for functional testing. Start the functional tests in another terminal session by running this command from this directory: - node ${relative(process.cwd(), KIBANA_FTR_SCRIPT)}${installDirFlag} + node ${relative(process.cwd(), KIBANA_FTR_SCRIPT)}${installDirFlag}${pathsMessage} ` + '\n\n' ); From 5858dd8f98153ea46f6350a5b04799541de65e1d Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Sat, 17 Oct 2020 12:25:31 +0200 Subject: [PATCH 11/11] [ML] Transforms: Fix tab ids for expanded row. (#80666) We based the IDs to identify expanded row tabs on the transform ID. This could break the page because the transform ID could include characters (e.g. dots) not supported by EUI's tabbed content component. This fixes the issue by using the stringHash() utility to create the IDs. --- .../transform_list/expanded_row.tsx | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx index 4478edab0dba..de45322d0498 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx @@ -26,6 +26,23 @@ function getItemDescription(value: any) { return value.toString(); } +/** + * Creates a deterministic number based hash out of a string. + */ +export function stringHash(str: string): number { + let hash = 0; + let chr = 0; + if (str.length === 0) { + return hash; + } + for (let i = 0; i < str.length; i++) { + chr = str.charCodeAt(i); + hash = (hash << 5) - hash + chr; // eslint-disable-line no-bitwise + hash |= 0; // eslint-disable-line no-bitwise + } + return hash < 0 ? hash * -2 : hash; +} + interface Item { title: string; description: any; @@ -162,9 +179,11 @@ export const ExpandedRow: FC = ({ item }) => { position: 'left', }; + const tabId = stringHash(item.id); + const tabs = [ { - id: `transform-details-tab-${item.id}`, + id: `transform-details-tab-${tabId}`, 'data-test-subj': 'transformDetailsTab', name: i18n.translate( 'xpack.transform.transformList.transformDetails.tabs.transformDetailsLabel', @@ -175,7 +194,7 @@ export const ExpandedRow: FC = ({ item }) => { content: , }, { - id: `transform-stats-tab-${item.id}`, + id: `transform-stats-tab-${tabId}`, 'data-test-subj': 'transformStatsTab', name: i18n.translate( 'xpack.transform.transformList.transformDetails.tabs.transformStatsLabel', @@ -186,13 +205,13 @@ export const ExpandedRow: FC = ({ item }) => { content: , }, { - id: `transform-json-tab-${item.id}`, + id: `transform-json-tab-${tabId}`, 'data-test-subj': 'transformJsonTab', name: 'JSON', content: , }, { - id: `transform-messages-tab-${item.id}`, + id: `transform-messages-tab-${tabId}`, 'data-test-subj': 'transformMessagesTab', name: i18n.translate( 'xpack.transform.transformList.transformDetails.tabs.transformMessagesLabel', @@ -203,7 +222,7 @@ export const ExpandedRow: FC = ({ item }) => { content: , }, { - id: `transform-preview-tab-${item.id}`, + id: `transform-preview-tab-${tabId}`, 'data-test-subj': 'transformPreviewTab', name: i18n.translate( 'xpack.transform.transformList.transformDetails.tabs.transformPreviewLabel',