From d3f0cebe76fdc26fbc23179945787b19a9b24a62 Mon Sep 17 00:00:00 2001 From: Ying Date: Tue, 2 Dec 2025 12:20:33 -0500 Subject: [PATCH 1/3] Adding invalidate API key task for TM --- docs/extend/plugin-list.md | 2 +- .../current_fields.json | 4 + .../current_mappings.json | 11 + .../server-internal/src/object_types/index.ts | 2 +- .../check_registered_types.test.ts | 8 + .../registration/type_registrations.test.ts | 1 + .../resources/base/bin/kibana-docker | 2 + .../task_manager_dependencies/README.md | 2 +- .../task_manager_dependencies/kibana.jsonc | 3 + .../task_manager_dependencies/moon.yml | 1 + .../server/plugin.ts | 9 +- .../task_manager_dependencies/tsconfig.json | 1 + .../invalidate_pending_api_keys/task.test.ts | 1525 ----------------- .../invalidate_pending_api_keys/task.ts | 248 +-- .../plugins/shared/task_manager/moon.yml | 1 + .../shared/task_manager/server/config.test.ts | 12 + .../shared/task_manager/server/config.ts | 11 + .../shared/task_manager/server/index.ts | 1 + .../managed_configuration.test.ts | 4 + .../invalidate_api_keys_task.ts | 124 ++ .../invalidate_api_keys/lib/constants.ts | 8 + .../lib/get_api_key_ids_to_invalidate.test.ts | 519 ++++++ .../lib/get_api_key_ids_to_invalidate.ts | 89 + .../lib/get_find_filter.test.ts | 67 + .../lib/get_find_filter.ts | 32 + .../server/invalidate_api_keys/lib/index.ts | 8 + .../lib/invalidate_api_keys.ts | 34 + .../invalidate_api_keys_and_delete_so.test.ts | 148 ++ .../lib/invalidate_api_keys_and_delete_so.ts | 51 + .../lib/query_for_api_keys_in_use.ts | 49 + .../lib/run_invalidate.test.ts | 839 +++++++++ .../invalidate_api_keys/lib/run_invalidate.ts | 85 + ...ulk_mark_api_keys_for_invalidation.test.ts | 71 + .../bulk_mark_api_keys_for_invalidation.ts | 35 + .../lib/calculate_health_status.test.ts | 4 + .../task_manager/server/lib/intervals.ts | 7 + .../server/metrics/create_aggregator.test.ts | 4 + .../shared/task_manager/server/mocks.ts | 1 + .../configuration_statistics.test.ts | 4 + .../shared/task_manager/server/plugin.test.ts | 4 + .../shared/task_manager/server/plugin.ts | 40 +- .../server/polling_lifecycle.test.ts | 4 + .../server/saved_objects/index.ts | 24 +- .../server/saved_objects/mappings.ts | 12 + .../api_key_to_invalidate_model_versions.ts | 19 + .../saved_objects/model_versions/index.ts | 1 + .../schemas/api_key_to_invalidate.ts | 16 + .../task_manager/server/task_store.test.ts | 29 +- .../shared/task_manager/server/task_store.ts | 13 +- .../plugins/shared/task_manager/tsconfig.json | 1 + .../test/plugin_api_integration/config.ts | 1 + .../sample_task_plugin/server/init_routes.ts | 26 + .../task_manager/task_management.ts | 45 +- 53 files changed, 2476 insertions(+), 1786 deletions(-) delete mode 100644 x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.test.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/invalidate_api_keys_task.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/constants.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.test.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_find_filter.test.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_find_filter.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/index.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.test.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/query_for_api_keys_in_use.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.test.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/lib/bulk_mark_api_keys_for_invalidation.test.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/lib/bulk_mark_api_keys_for_invalidation.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/api_key_to_invalidate_model_versions.ts create mode 100644 x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/api_key_to_invalidate.ts diff --git a/docs/extend/plugin-list.md b/docs/extend/plugin-list.md index ec3ee2230f904..f3da99efb4b73 100644 --- a/docs/extend/plugin-list.md +++ b/docs/extend/plugin-list.md @@ -240,7 +240,7 @@ mapped_pages: | [streamsApp](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/streams_app/README.md) | Home of the Streams app plugin, which allows users to manage Streams via the UI. | | [synthetics](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/synthetics/README.md) | The purpose of this plugin is to provide users of Heartbeat more visibility of what's happening in their infrastructure. | | [taskManager](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/task_manager/README.md) | The task manager is a generic system for running background tasks. | -| [taskManagerDependencies](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/task_manager_dependencies/README.md) | This plugin is used as a temporary sidecar plugin to enable the task manager plugin access to the encrypted saved objects client as there is a circular dependency if the task manager were to require the encrypted saved objects plugin directly. | +| [taskManagerDependencies](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/task_manager_dependencies/README.md) | This plugin is used as a temporary sidecar plugin to enable the task manager plugin access to the encrypted saved objects client and the security plugin start contract as there is a circular dependency if the task manager were to require the encrypted saved objects plugin directly. | | [telemetryCollectionXpack](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/telemetry_collection_xpack/README.md) | Gathers all usage collection, retrieving them from both: OSS and X-Pack plugins. | | [timelines](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/timelines/README.md) | Timelines is a plugin that provides a grid component with accompanying server side apis to help users identify events of interest and perform root cause analysis within Kibana. | | [transform](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/transform/README.md) | This plugin provides access to the transforms features provided by Elasticsearch. It follows Kibana's standard plugin architecture, originally the plugin boilerplate code was taken from the snapshot/restore plugin. | diff --git a/packages/kbn-check-saved-objects-cli/current_fields.json b/packages/kbn-check-saved-objects-cli/current_fields.json index fd34bc7369f72..a8bf699500d03 100644 --- a/packages/kbn-check-saved-objects-cli/current_fields.json +++ b/packages/kbn-check-saved-objects-cli/current_fields.json @@ -97,6 +97,10 @@ "apiKeyId", "createdAt" ], + "api_key_to_invalidate": [ + "apiKeyId", + "createdAt" + ], "apm-custom-dashboards": [ "dashboardSavedObjectId", "kuery", diff --git a/packages/kbn-check-saved-objects-cli/current_mappings.json b/packages/kbn-check-saved-objects-cli/current_mappings.json index 4501675a5745b..974660c8b3243 100644 --- a/packages/kbn-check-saved-objects-cli/current_mappings.json +++ b/packages/kbn-check-saved-objects-cli/current_mappings.json @@ -325,6 +325,17 @@ } } }, + "api_key_to_invalidate": { + "dynamic": false, + "properties": { + "apiKeyId": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + } + } + }, "apm-custom-dashboards": { "properties": { "dashboardSavedObjectId": { diff --git a/src/core/packages/saved-objects/server-internal/src/object_types/index.ts b/src/core/packages/saved-objects/server-internal/src/object_types/index.ts index 1fca5f9436b59..7eb3862850ae4 100644 --- a/src/core/packages/saved-objects/server-internal/src/object_types/index.ts +++ b/src/core/packages/saved-objects/server-internal/src/object_types/index.ts @@ -11,4 +11,4 @@ export { registerCoreObjectTypes } from './registration'; // set minimum number of registered saved objects to ensure no object types are removed after 8.8 // declared in internal implementation explicitly to prevent unintended changes. -export const SAVED_OBJECT_TYPES_COUNT = 138 as const; +export const SAVED_OBJECT_TYPES_COUNT = 139 as const; diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 66b120f5bc9d6..57b57db2bb5e2 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -67,6 +67,7 @@ describe('checking migration metadata changes on all registered SO types', () => "alert": "25c6ceada17bfbe3027062c87e8eec6207d210f69638e53dd8110cba9735973b", "alerting_rule_template": "7c0ce40abc7416e49e3729b5189623be396c31b6c7ce2f915d9f4908405eca74", "api_key_pending_invalidation": "19ece0ac908352a86624e7c487f452077db878a9bf15286892c6da8e76bbb479", + "api_key_to_invalidate": "556a42e1ae119c25e4935058aad011cbc6ae2f9cfaee12f6936118ad07ac3edd", "apm-custom-dashboards": "30647691e2f67ccb9530d4c99d5723800872b4e5291a033de1116357603e4f27", "apm-indices": "9d3b6f5d29647738edb718b38e0eab712ec705f707b47572cf0bb37e011f3a8e", "apm-server-schema": "a58389bb3de41d987a2f0e53fd3360e90586656c981a9adaf862f4a06b7ab873", @@ -299,6 +300,11 @@ describe('checking migration metadata changes on all registered SO types', () => "api_key_pending_invalidation|mappings: 6690f4f2a071feda5eec8353cdb23c0f1624910a", "api_key_pending_invalidation|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", "api_key_pending_invalidation|10.1.0: 61265eecca1fe876ad48410cd295efdd61a593911dd6ab530b36bd8943c7c102", + "===============================================================================================", + "api_key_to_invalidate|global: d1a20b058bd48891c1c958a20a8e1bac37c25b87", + "api_key_to_invalidate|mappings: fa87c3b4528dcec61462709d2575ac13ba86397e", + "api_key_to_invalidate|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", + "api_key_to_invalidate|10.1.0: 61265eecca1fe876ad48410cd295efdd61a593911dd6ab530b36bd8943c7c102", "=====================================================================================================", "apm-custom-dashboards|global: f72017061cda1a43792af034d019b3a766eacd7a", "apm-custom-dashboards|mappings: 72a467c41818fc3a8f88c40a885e835485752e73", @@ -1264,6 +1270,7 @@ describe('checking migration metadata changes on all registered SO types', () => "alert": "10.7.0", "alerting_rule_template": "10.2.0", "api_key_pending_invalidation": "10.1.0", + "api_key_to_invalidate": "10.1.0", "apm-custom-dashboards": "10.1.0", "apm-indices": "10.1.0", "apm-server-schema": "10.1.0", @@ -1412,6 +1419,7 @@ describe('checking migration metadata changes on all registered SO types', () => "alert": "10.7.0", "alerting_rule_template": "10.2.0", "api_key_pending_invalidation": "10.1.0", + "api_key_to_invalidate": "10.1.0", "apm-custom-dashboards": "10.1.0", "apm-indices": "10.1.0", "apm-server-schema": "10.1.0", diff --git a/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts index 28f0277783c7e..f70abfbb4dc6e 100644 --- a/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts @@ -18,6 +18,7 @@ const previouslyRegisteredTypes = [ 'alert', 'alerting_rule_template', 'api_key_pending_invalidation', + 'api_key_to_invalidate', 'apm-custom-dashboards', 'apm-indices', 'apm-server-schema', diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 8ca73c50e62b0..b1ac6c5d60881 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -435,6 +435,8 @@ kibana_vars=( xpack.task_manager.auto_calculate_default_ech_capacity xpack.task_manager.discovery.active_nodes_lookback xpack.task_manager.discovery.interval + xpack.task_manager.invalidate_api_key_task.interval + xpack.task_manager.invalidate_api_key_task.removalDelay xpack.task_manager.kibanas_per_partition xpack.task_manager.max_attempts xpack.task_manager.max_workers diff --git a/x-pack/platform/plugins/private/task_manager_dependencies/README.md b/x-pack/platform/plugins/private/task_manager_dependencies/README.md index c9eefc1421c2a..fb812dd8b5c67 100755 --- a/x-pack/platform/plugins/private/task_manager_dependencies/README.md +++ b/x-pack/platform/plugins/private/task_manager_dependencies/README.md @@ -1,7 +1,7 @@ # Task Manager Dependencies This plugin is used as a temporary sidecar plugin to enable the task manager plugin access to -the encrypted saved objects client as there is a circular dependency if the task manager were to +the encrypted saved objects client and the security plugin start contract as there is a circular dependency if the task manager were to require the encrypted saved objects plugin directly. This is because the encrypted saved objects plugin has a dependency on the security plugin, which diff --git a/x-pack/platform/plugins/private/task_manager_dependencies/kibana.jsonc b/x-pack/platform/plugins/private/task_manager_dependencies/kibana.jsonc index 31067c66e41e7..181a2f0ca7058 100644 --- a/x-pack/platform/plugins/private/task_manager_dependencies/kibana.jsonc +++ b/x-pack/platform/plugins/private/task_manager_dependencies/kibana.jsonc @@ -19,5 +19,8 @@ "taskManager", "encryptedSavedObjects", ], + "optionalPlugins": [ + "security", + ] }, } diff --git a/x-pack/platform/plugins/private/task_manager_dependencies/moon.yml b/x-pack/platform/plugins/private/task_manager_dependencies/moon.yml index 1456096aef526..49f74737f9104 100644 --- a/x-pack/platform/plugins/private/task_manager_dependencies/moon.yml +++ b/x-pack/platform/plugins/private/task_manager_dependencies/moon.yml @@ -20,6 +20,7 @@ project: dependsOn: - '@kbn/core' - '@kbn/encrypted-saved-objects-plugin' + - '@kbn/security-plugin' - '@kbn/task-manager-plugin' tags: - plugin diff --git a/x-pack/platform/plugins/private/task_manager_dependencies/server/plugin.ts b/x-pack/platform/plugins/private/task_manager_dependencies/server/plugin.ts index 83b3fdee44ac5..4a4462add1c2c 100644 --- a/x-pack/platform/plugins/private/task_manager_dependencies/server/plugin.ts +++ b/x-pack/platform/plugins/private/task_manager_dependencies/server/plugin.ts @@ -6,6 +6,7 @@ */ import type { CoreSetup, CoreStart } from '@kbn/core/server'; +import type { SecurityPluginStart } from '@kbn/security-plugin/server'; import type { EncryptedSavedObjectsPluginSetup, EncryptedSavedObjectsPluginStart, @@ -22,11 +23,12 @@ export interface TaskManagerDependenciesPluginSetup { export interface TaskManagerDependenciesPluginStart { encryptedSavedObjects: EncryptedSavedObjectsPluginStart; + security?: SecurityPluginStart; taskManager: TaskManagerStartContract; } export class TaskManagerDependenciesPlugin { - public setup(core: CoreSetup, plugin: TaskManagerDependenciesPluginSetup) { + public setup(_: CoreSetup, plugin: TaskManagerDependenciesPluginSetup) { plugin.encryptedSavedObjects.registerType({ type: 'task', attributesToEncrypt: new Set(['apiKey']), @@ -37,11 +39,14 @@ export class TaskManagerDependenciesPlugin { plugin.taskManager.registerCanEncryptedSavedObjects(plugin.encryptedSavedObjects.canEncrypt); } - public start(core: CoreStart, plugin: TaskManagerDependenciesPluginStart) { + public start(_: CoreStart, plugin: TaskManagerDependenciesPluginStart) { plugin.taskManager.registerEncryptedSavedObjectsClient( plugin.encryptedSavedObjects.getClient({ includedHiddenTypes: ['task'], }) ); + plugin.taskManager.registerApiKeyInvalidateFn( + plugin.security?.authc.apiKeys.invalidateAsInternalUser + ); } } diff --git a/x-pack/platform/plugins/private/task_manager_dependencies/tsconfig.json b/x-pack/platform/plugins/private/task_manager_dependencies/tsconfig.json index 2700b858d8c55..f32e20f3a112e 100644 --- a/x-pack/platform/plugins/private/task_manager_dependencies/tsconfig.json +++ b/x-pack/platform/plugins/private/task_manager_dependencies/tsconfig.json @@ -9,6 +9,7 @@ "kbn_references": [ "@kbn/core", "@kbn/encrypted-saved-objects-plugin", + "@kbn/security-plugin", "@kbn/task-manager-plugin", ], "exclude": [ diff --git a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.test.ts b/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.test.ts deleted file mode 100644 index 3c70c6fd314d4..0000000000000 --- a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.test.ts +++ /dev/null @@ -1,1525 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import sinon from 'sinon'; -import { loggingSystemMock, savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; -import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; -import { securityMock } from '@kbn/security-plugin/server/mocks'; -import { API_KEY_PENDING_INVALIDATION_TYPE } from '..'; -import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../saved_objects'; -import { - getFindFilter, - getApiKeyIdsToInvalidate, - invalidateApiKeysAndDeletePendingApiKeySavedObject, - runInvalidate, -} from './task'; -import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '@kbn/actions-plugin/server/constants/saved_objects'; - -let fakeTimer: sinon.SinonFakeTimers; -const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); -const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); -const logger: ReturnType = loggingSystemMock.createLogger(); -const securityMockStart = securityMock.createStart(); - -const mockInvalidatePendingApiKeyObject1 = { - id: '1', - type: API_KEY_PENDING_INVALIDATION_TYPE, - attributes: { - apiKeyId: 'abcd====!', - createdAt: '2024-04-11T17:08:44.035Z', - }, - references: [], -}; -const mockInvalidatePendingApiKeyObject2 = { - id: '2', - type: API_KEY_PENDING_INVALIDATION_TYPE, - attributes: { - apiKeyId: 'xyz!==!', - createdAt: '2024-04-11T17:08:44.035Z', - }, - references: [], -}; - -describe('Invalidate API Keys Task', () => { - beforeAll(() => { - fakeTimer = sinon.useFakeTimers(); - }); - beforeEach(() => { - jest.resetAllMocks(); - }); - afterAll(() => fakeTimer.restore()); - - describe('getFindFilter', () => { - test('should build find filter with just delay', () => { - expect(getFindFilter('2024-04-11T18:40:52.197Z')).toEqual( - `api_key_pending_invalidation.attributes.createdAt <= "2024-04-11T18:40:52.197Z"` - ); - }); - - test('should build find filter with delay and empty excluded SO id array', () => { - expect(getFindFilter('2024-04-11T18:40:52.197Z', [])).toEqual( - `api_key_pending_invalidation.attributes.createdAt <= "2024-04-11T18:40:52.197Z"` - ); - }); - - test('should build find filter with delay and one excluded SO id', () => { - expect(getFindFilter('2024-04-11T18:40:52.197Z', ['abc'])).toEqual( - `api_key_pending_invalidation.attributes.createdAt <= "2024-04-11T18:40:52.197Z" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:abc"` - ); - }); - - test('should build find filter with delay and multiple excluded SO ids', () => { - expect(getFindFilter('2024-04-11T18:40:52.197Z', ['abc', 'def'])).toEqual( - `api_key_pending_invalidation.attributes.createdAt <= "2024-04-11T18:40:52.197Z" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:abc" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:def"` - ); - }); - - test('should handle duplicate excluded SO ids', () => { - expect(getFindFilter('2024-04-11T18:40:52.197Z', ['abc', 'abc', 'abc', 'def'])).toEqual( - `api_key_pending_invalidation.attributes.createdAt <= "2024-04-11T18:40:52.197Z" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:abc" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:def"` - ); - }); - }); - - describe('getApiKeyIdsToInvalidate', () => { - test('should get decrypted api key pending invalidation saved object', async () => { - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject1 - ); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject2 - ); - // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 10, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 1, - page: 1, - per_page: 10, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - - const result = await getApiKeyIdsToInvalidate({ - apiKeySOsPendingInvalidation: { - saved_objects: [ - { - id: '1', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - { - id: '2', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - ], - total: 2, - per_page: 10, - page: 1, - }, - encryptedSavedObjectsClient, - savedObjectsClient: internalSavedObjectsRepository, - }); - - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `ad_hoc_run_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(result).toEqual({ - apiKeyIdsToInvalidate: [ - { id: '1', apiKeyId: 'abcd====!' }, - { id: '2', apiKeyId: 'xyz!==!' }, - ], - apiKeyIdsToExclude: [], - }); - }); - - test('should get decrypted api key pending invalidation saved object when some api keys are still in use by AD_HOC_RUN_SAVED_OBJECT_TYPE', async () => { - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject1 - ); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject2 - ); - // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 10, - aggregations: { - apiKeyId: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'abcd====!', doc_count: 1 }], - }, - }, - }); - // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 1, - page: 1, - per_page: 10, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - - const result = await getApiKeyIdsToInvalidate({ - apiKeySOsPendingInvalidation: { - saved_objects: [ - { - id: '1', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - { - id: '2', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - ], - total: 2, - per_page: 10, - page: 1, - }, - encryptedSavedObjectsClient, - savedObjectsClient: internalSavedObjectsRepository, - }); - - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `ad_hoc_run_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(result).toEqual({ - apiKeyIdsToInvalidate: [{ id: '2', apiKeyId: 'xyz!==!' }], - apiKeyIdsToExclude: [{ id: '1', apiKeyId: 'abcd====!' }], - }); - }); - - test('should get decrypted api key pending invalidation saved object when some api keys are still in use by ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE', async () => { - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject1 - ); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject2 - ); - // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 1, - page: 1, - per_page: 10, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - - // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 10, - aggregations: { - apiKeyId: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'abcd====!', doc_count: 1 }], - }, - }, - }); - - const result = await getApiKeyIdsToInvalidate({ - apiKeySOsPendingInvalidation: { - saved_objects: [ - { - id: '1', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - { - id: '2', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - ], - total: 2, - per_page: 10, - page: 1, - }, - encryptedSavedObjectsClient, - savedObjectsClient: internalSavedObjectsRepository, - }); - - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `ad_hoc_run_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(result).toEqual({ - apiKeyIdsToInvalidate: [{ id: '2', apiKeyId: 'xyz!==!' }], - apiKeyIdsToExclude: [{ id: '1', apiKeyId: 'abcd====!' }], - }); - }); - - test('should throw error if encryptedSavedObjectsClient.getDecryptedAsInternalUser throws error', async () => { - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject1 - ); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockImplementationOnce(() => { - throw new Error('failfail'); - }); - - await expect( - getApiKeyIdsToInvalidate({ - apiKeySOsPendingInvalidation: { - saved_objects: [ - { - id: '1', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { - apiKeyId: 'encryptedencrypted', - createdAt: '2024-04-11T17:08:44.035Z', - }, - references: [], - }, - { - id: '2', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { - apiKeyId: 'encryptedencrypted', - createdAt: '2024-04-11T17:08:44.035Z', - }, - references: [], - }, - ], - total: 2, - per_page: 10, - page: 1, - }, - encryptedSavedObjectsClient, - savedObjectsClient: internalSavedObjectsRepository, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"failfail"`); - }); - - test('should throw error if malformed savedObjectsClient.find response', async () => { - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject1 - ); - - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 10, - // missing aggregations - }); - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 1, - page: 1, - per_page: 10, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - - const result = await getApiKeyIdsToInvalidate({ - apiKeySOsPendingInvalidation: { - saved_objects: [ - { - id: '1', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { - apiKeyId: 'encryptedencrypted', - createdAt: '2024-04-11T17:08:44.035Z', - }, - references: [], - }, - ], - total: 2, - per_page: 10, - page: 1, - }, - encryptedSavedObjectsClient, - savedObjectsClient: internalSavedObjectsRepository, - }); - - expect(result).toEqual({ - apiKeyIdsToInvalidate: [{ id: '1', apiKeyId: 'abcd====!' }], - apiKeyIdsToExclude: [], - }); - }); - - test('should throw error if savedObjectsClient.find throws error', async () => { - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject1 - ); - internalSavedObjectsRepository.find.mockImplementationOnce(() => { - throw new Error('failfail'); - }); - await expect( - getApiKeyIdsToInvalidate({ - apiKeySOsPendingInvalidation: { - saved_objects: [ - { - id: '1', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { - apiKeyId: 'encryptedencrypted', - createdAt: '2024-04-11T17:08:44.035Z', - }, - references: [], - }, - ], - total: 2, - per_page: 10, - page: 1, - }, - encryptedSavedObjectsClient, - savedObjectsClient: internalSavedObjectsRepository, - }) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"failfail"`); - }); - }); - - describe('invalidateApiKeysAndDeletePendingApiKeySavedObject', () => { - test('should succeed when there are no api keys to invalidate', async () => { - const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ - apiKeyIdsToInvalidate: [], - logger, - savedObjectsClient: internalSavedObjectsRepository, - securityPluginStart: securityMockStart, - }); - expect(total).toEqual(0); - expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "0"`); - }); - - test('should succeed when there are api keys to invalidate', async () => { - securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ - invalidated_api_keys: ['1', '2'], - previously_invalidated_api_keys: [], - error_count: 0, - }); - const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ - apiKeyIdsToInvalidate: [ - { id: '1', apiKeyId: 'abcd====!' }, - { id: '2', apiKeyId: 'xyz!==!' }, - ], - logger, - savedObjectsClient: internalSavedObjectsRepository, - securityPluginStart: securityMockStart, - }); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ - ids: ['abcd====!', 'xyz!==!'], - }); - expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); - expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(total).toEqual(2); - expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "2"`); - }); - - test('should handle errors during invalidation', async () => { - securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValueOnce({ - invalidated_api_keys: ['1'], - previously_invalidated_api_keys: [], - error_count: 1, - }); - const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ - apiKeyIdsToInvalidate: [ - { id: '1', apiKeyId: 'abcd====!' }, - { id: '2', apiKeyId: 'xyz!==!' }, - ], - logger, - savedObjectsClient: internalSavedObjectsRepository, - securityPluginStart: securityMockStart, - }); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ - ids: ['abcd====!', 'xyz!==!'], - }); - expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledWith( - `Failed to invalidate API Keys [ids=\"abcd====!, xyz!==!\"]` - ); - expect(total).toEqual(0); - expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "0"`); - }); - - test('should handle null security plugin', async () => { - const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ - apiKeyIdsToInvalidate: [ - { id: '1', apiKeyId: 'abcd====!' }, - { id: '2', apiKeyId: 'xyz!==!' }, - ], - logger, - savedObjectsClient: internalSavedObjectsRepository, - }); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).not.toHaveBeenCalled(); - expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); - expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(total).toEqual(2); - expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "2"`); - }); - - test('should handle null result from invalidateAsInternalUser', async () => { - securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValueOnce(null); - const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ - apiKeyIdsToInvalidate: [ - { id: '1', apiKeyId: 'abcd====!' }, - { id: '2', apiKeyId: 'xyz!==!' }, - ], - logger, - savedObjectsClient: internalSavedObjectsRepository, - securityPluginStart: securityMockStart, - }); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ - ids: ['abcd====!', 'xyz!==!'], - }); - expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); - expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(total).toEqual(2); - expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "2"`); - }); - }); - - describe('runInvalidate', () => { - test('should succeed when there are no API keys to invalidate', async () => { - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 0, - page: 1, - per_page: 100, - }); - const result = await runInvalidate({ - // @ts-expect-error - config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, - encryptedSavedObjectsClient, - logger, - savedObjectsClient: internalSavedObjectsRepository, - security: securityMockStart, - }); - expect(result).toEqual(0); - - expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(1); - expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ - type: API_KEY_PENDING_INVALIDATION_TYPE, - filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, - page: 1, - sortField: 'createdAt', - sortOrder: 'asc', - perPage: 100, - }); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).not.toHaveBeenCalled(); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).not.toHaveBeenCalled(); - expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); - }); - - test('should succeed when there are API keys to invalidate', async () => { - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - { - id: '2', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - ], - total: 2, - per_page: 100, - page: 1, - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject1 - ); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject2 - ); - // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 1, - page: 1, - per_page: 10, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - - // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 10, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - - securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ - invalidated_api_keys: ['1', '2'], - previously_invalidated_api_keys: [], - error_count: 0, - }); - - const result = await runInvalidate({ - // @ts-expect-error - config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, - encryptedSavedObjectsClient, - logger, - savedObjectsClient: internalSavedObjectsRepository, - security: securityMockStart, - }); - expect(result).toEqual(2); - - expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(3); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { - type: API_KEY_PENDING_INVALIDATION_TYPE, - filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, - page: 1, - sortField: 'createdAt', - sortOrder: 'asc', - perPage: 100, - }); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(2, { - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `ad_hoc_run_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { - type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `action_task_params.attributes.apiKeyId: "abcd====!" OR action_task_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `action_task_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(1); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ - ids: ['abcd====!', 'xyz!==!'], - }); - expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); - expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "2"`); - }); - - test('should succeed when there are API keys to invalidate and API keys to exclude (AD_HOC_RUN_SAVED_OBJECT_TYPE using apiKeyId)', async () => { - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - { - id: '2', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - ], - total: 2, - per_page: 100, - page: 1, - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject1 - ); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject2 - ); - - // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 0, - aggregations: { - apiKeyId: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'abcd====!', doc_count: 1 }], - }, - }, - }); - - // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 10, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - - securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ - invalidated_api_keys: ['1'], - previously_invalidated_api_keys: [], - error_count: 0, - }); - - const result = await runInvalidate({ - // @ts-expect-error - config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, - encryptedSavedObjectsClient, - logger, - savedObjectsClient: internalSavedObjectsRepository, - security: securityMockStart, - }); - expect(result).toEqual(1); - - expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(3); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { - type: API_KEY_PENDING_INVALIDATION_TYPE, - filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, - page: 1, - sortField: 'createdAt', - sortOrder: 'asc', - perPage: 100, - }); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(2, { - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `ad_hoc_run_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { - type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `action_task_params.attributes.apiKeyId: "abcd====!" OR action_task_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `action_task_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(1); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ - ids: ['xyz!==!'], - }); - expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(1); - expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "1"`); - }); - - test('should succeed when there are API keys to invalidate and API keys to exclude (ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE using apiKeyId)', async () => { - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - { - id: '2', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - ], - total: 2, - per_page: 100, - page: 1, - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject1 - ); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject2 - ); - - // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 10, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - - // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 0, - aggregations: { - apiKeyId: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'abcd====!', doc_count: 1 }], - }, - }, - }); - - securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ - invalidated_api_keys: ['1'], - previously_invalidated_api_keys: [], - error_count: 0, - }); - - const result = await runInvalidate({ - // @ts-expect-error - config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, - encryptedSavedObjectsClient, - logger, - savedObjectsClient: internalSavedObjectsRepository, - security: securityMockStart, - }); - expect(result).toEqual(1); - - expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(3); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { - type: API_KEY_PENDING_INVALIDATION_TYPE, - filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, - page: 1, - sortField: 'createdAt', - sortOrder: 'asc', - perPage: 100, - }); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(2, { - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `ad_hoc_run_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { - type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `action_task_params.attributes.apiKeyId: "abcd====!" OR action_task_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `action_task_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(1); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ - ids: ['xyz!==!'], - }); - expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(1); - expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "1"`); - }); - - test('should succeed when there are only API keys to exclude', async () => { - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - { - id: '2', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - ], - total: 2, - per_page: 100, - page: 1, - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject1 - ); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject2 - ); - - // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 0, - aggregations: { - apiKeyId: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'abcd====!', doc_count: 1 }, - { key: 'xyz!==!', doc_count: 2 }, - ], - }, - }, - }); - - // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 0, - aggregations: { - apiKeyId: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'abcd====!', doc_count: 3 }], - }, - }, - }); - - const result = await runInvalidate({ - // @ts-expect-error - config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, - encryptedSavedObjectsClient, - logger, - savedObjectsClient: internalSavedObjectsRepository, - security: securityMockStart, - }); - expect(result).toEqual(0); - - expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(3); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { - type: API_KEY_PENDING_INVALIDATION_TYPE, - filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, - page: 1, - sortField: 'createdAt', - sortOrder: 'asc', - perPage: 100, - }); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(2, { - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `ad_hoc_run_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { - type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `action_task_params.attributes.apiKeyId: "abcd====!" OR action_task_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `action_task_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).not.toHaveBeenCalled(); - expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); - }); - - test('should succeed when there are more than PAGE_SIZE API keys to invalidate', async () => { - // first iteration - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - ], - total: 105, - per_page: 100, - page: 1, - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject1 - ); - - // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 0, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - - // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 0, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - - securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ - invalidated_api_keys: ['1'], - previously_invalidated_api_keys: [], - error_count: 0, - }); - // second iteration - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [ - { - id: '2', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - ], - total: 5, - per_page: 100, - page: 1, - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject2 - ); - // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 0, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - - // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 0, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ - invalidated_api_keys: ['2'], - previously_invalidated_api_keys: [], - error_count: 0, - }); - - const result = await runInvalidate({ - // @ts-expect-error - config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, - encryptedSavedObjectsClient, - logger, - savedObjectsClient: internalSavedObjectsRepository, - security: securityMockStart, - }); - expect(result).toEqual(2); - - expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(6); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(2); - expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); - expect(logger.debug).toHaveBeenCalledTimes(2); - - // first iteration - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { - type: API_KEY_PENDING_INVALIDATION_TYPE, - filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, - page: 1, - sortField: 'createdAt', - sortOrder: 'asc', - perPage: 100, - }); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(2, { - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `ad_hoc_run_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { - type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `action_task_params.attributes.apiKeyId: "abcd====!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `action_task_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenNthCalledWith(1, { - ids: ['abcd====!'], - }); - expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(logger.debug).toHaveBeenNthCalledWith(1, `Total invalidated API keys "1"`); - - // second iteration - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(4, { - type: API_KEY_PENDING_INVALIDATION_TYPE, - filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, - page: 1, - sortField: 'createdAt', - sortOrder: 'asc', - perPage: 100, - }); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(5, { - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `ad_hoc_run_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(6, { - type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `action_task_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `action_task_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenNthCalledWith(2, { - ids: ['xyz!==!'], - }); - expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(logger.debug).toHaveBeenNthCalledWith(2, `Total invalidated API keys "1"`); - }); - - test('should succeed when there are more than PAGE_SIZE API keys to invalidate and API keys to exclude', async () => { - // first iteration - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [ - { - id: '1', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - { - id: '2', - type: API_KEY_PENDING_INVALIDATION_TYPE, - score: 0, - attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, - references: [], - }, - ], - total: 105, - per_page: 100, - page: 1, - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject1 - ); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( - mockInvalidatePendingApiKeyObject2 - ); - // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 0, - aggregations: { - apiKeyId: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'abcd====!', doc_count: 1 }], - }, - }, - }); - - // second call to find aggregates any ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE SOs by apiKeyId - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 2, - page: 1, - per_page: 0, - aggregations: { - apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, - }, - }); - - securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ - invalidated_api_keys: ['1'], - previously_invalidated_api_keys: [], - error_count: 0, - }); - // second iteration - internalSavedObjectsRepository.find.mockResolvedValueOnce({ - saved_objects: [], - total: 0, - per_page: 100, - page: 1, - }); - - const result = await runInvalidate({ - // @ts-expect-error - config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, - encryptedSavedObjectsClient, - logger, - savedObjectsClient: internalSavedObjectsRepository, - security: securityMockStart, - }); - expect(result).toEqual(1); - - expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(4); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(1); - expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(1); - expect(logger.debug).toHaveBeenCalledTimes(1); - - // first iteration - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { - type: API_KEY_PENDING_INVALIDATION_TYPE, - filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, - page: 1, - sortField: 'createdAt', - sortOrder: 'asc', - perPage: 100, - }); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '1' - ); - expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( - 2, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(2, { - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `ad_hoc_run_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { - type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, - perPage: 0, - filter: `action_task_params.attributes.apiKeyId: "abcd====!" OR action_task_params.attributes.apiKeyId: "xyz!==!"`, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `action_task_params.attributes.apiKeyId`, - size: 100, - }, - }, - }, - }); - expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenNthCalledWith(1, { - ids: ['xyz!==!'], - }); - expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( - 1, - API_KEY_PENDING_INVALIDATION_TYPE, - '2' - ); - expect(logger.debug).toHaveBeenNthCalledWith(1, `Total invalidated API keys "1"`); - - // second iteration - expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(4, { - type: API_KEY_PENDING_INVALIDATION_TYPE, - filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:1"`, - page: 1, - sortField: 'createdAt', - sortOrder: 'asc', - perPage: 100, - }); - }); - }); -}); diff --git a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.ts b/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.ts index 916f3db49f600..228c5794808c4 100644 --- a/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.ts +++ b/x-pack/platform/plugins/shared/alerting/server/invalidate_pending_api_keys/task.ts @@ -5,58 +5,23 @@ * 2.0. */ -import type { - Logger, - CoreStart, - SavedObjectsFindResponse, - SavedObjectsClientContract, -} from '@kbn/core/server'; -import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; -import type { InvalidateAPIKeysParams, SecurityPluginStart } from '@kbn/security-plugin/server'; +import type { Logger, CoreStart } from '@kbn/core/server'; import type { RunContext, TaskManagerSetupContract, TaskManagerStartContract, } from '@kbn/task-manager-plugin/server'; -import type { - AggregationsStringTermsBucketKeys, - AggregationsTermsAggregateBase, -} from '@elastic/elasticsearch/lib/api/types'; +import { runInvalidate } from '@kbn/task-manager-plugin/server'; import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '@kbn/actions-plugin/server/constants/saved_objects'; -import type { InvalidateAPIKeyResult } from '../rules_client'; import type { AlertingConfig } from '../config'; -import { timePeriodBeforeDate } from '../lib/get_cadence'; import type { AlertingPluginsStart } from '../plugin'; -import type { InvalidatePendingApiKey } from '../types'; import { stateSchemaByVersion, emptyState, type LatestTaskStateSchema } from './task_state'; import { API_KEY_PENDING_INVALIDATION_TYPE } from '..'; import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../saved_objects'; -import type { AdHocRunSO } from '../data/ad_hoc_run/types'; const TASK_TYPE = 'alerts_invalidate_api_keys'; -const PAGE_SIZE = 100; export const TASK_ID = `Alerts-${TASK_TYPE}`; -const invalidateAPIKeys = async ( - params: InvalidateAPIKeysParams, - securityPluginStart?: SecurityPluginStart -): Promise => { - if (!securityPluginStart) { - return { apiKeysEnabled: false }; - } - const invalidateAPIKeyResult = await securityPluginStart.authc.apiKeys.invalidateAsInternalUser( - params - ); - // Null when Elasticsearch security is disabled - if (!invalidateAPIKeyResult) { - return { apiKeysEnabled: false }; - } - return { - apiKeysEnabled: true, - result: invalidateAPIKeyResult, - }; -}; - export function initializeApiKeyInvalidator( logger: Logger, coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]>, @@ -124,11 +89,22 @@ export function taskRunner( }); totalInvalidated = await runInvalidate({ - config, encryptedSavedObjectsClient, + invalidateApiKeyFn: security?.authc.apiKeys.invalidateAsInternalUser, logger, + removalDelay: config.invalidateApiKeysTask.removalDelay, savedObjectsClient, - security, + savedObjectType: API_KEY_PENDING_INVALIDATION_TYPE, + savedObjectTypesToQuery: [ + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + apiKeyAttributePath: `${AD_HOC_RUN_SAVED_OBJECT_TYPE}.attributes.apiKeyId`, + }, + { + type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + apiKeyAttributePath: `${ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE}.attributes.apiKeyId`, + }, + ], }); const updatedState: LatestTaskStateSchema = { @@ -158,197 +134,3 @@ export function taskRunner( }; }; } - -interface ApiKeyIdAndSOId { - id: string; - apiKeyId: string; -} - -interface RunInvalidateOpts { - config: AlertingConfig; - encryptedSavedObjectsClient: EncryptedSavedObjectsClient; - logger: Logger; - savedObjectsClient: SavedObjectsClientContract; - security?: SecurityPluginStart; -} -export async function runInvalidate({ - config, - encryptedSavedObjectsClient, - logger, - savedObjectsClient, - security, -}: RunInvalidateOpts) { - const configuredDelay = config.invalidateApiKeysTask.removalDelay; - const delay: string = timePeriodBeforeDate(new Date(), configuredDelay).toISOString(); - - let hasMoreApiKeysPendingInvalidation = true; - let totalInvalidated = 0; - const excludedSOIds = new Set(); - - do { - // Query for PAGE_SIZE api keys to invalidate at a time. At the end of each iteration, - // we should have deleted the deletable keys and added keys still in use to the excluded list - const filter = getFindFilter(delay, [...excludedSOIds]); - const apiKeysToInvalidate = await savedObjectsClient.find({ - type: API_KEY_PENDING_INVALIDATION_TYPE, - filter, - page: 1, - sortField: 'createdAt', - sortOrder: 'asc', - perPage: PAGE_SIZE, - }); - - if (apiKeysToInvalidate.total > 0) { - const { apiKeyIdsToExclude, apiKeyIdsToInvalidate } = await getApiKeyIdsToInvalidate({ - apiKeySOsPendingInvalidation: apiKeysToInvalidate, - encryptedSavedObjectsClient, - savedObjectsClient, - }); - apiKeyIdsToExclude.forEach(({ id }) => excludedSOIds.add(id)); - totalInvalidated += await invalidateApiKeysAndDeletePendingApiKeySavedObject({ - apiKeyIdsToInvalidate, - logger, - savedObjectsClient, - securityPluginStart: security, - }); - } - - hasMoreApiKeysPendingInvalidation = apiKeysToInvalidate.total > PAGE_SIZE; - } while (hasMoreApiKeysPendingInvalidation); - - return totalInvalidated; -} -interface GetApiKeyIdsToInvalidateOpts { - apiKeySOsPendingInvalidation: SavedObjectsFindResponse; - encryptedSavedObjectsClient: EncryptedSavedObjectsClient; - savedObjectsClient: SavedObjectsClientContract; -} - -interface GetApiKeysToInvalidateResult { - apiKeyIdsToInvalidate: ApiKeyIdAndSOId[]; - apiKeyIdsToExclude: ApiKeyIdAndSOId[]; -} - -export async function getApiKeyIdsToInvalidate({ - apiKeySOsPendingInvalidation, - encryptedSavedObjectsClient, - savedObjectsClient, -}: GetApiKeyIdsToInvalidateOpts): Promise { - // Decrypt the apiKeyId for each pending invalidation SO - const apiKeyIds = await Promise.all( - apiKeySOsPendingInvalidation.saved_objects.map(async (apiKeyPendingInvalidationSO) => { - const decryptedApiKeyPendingInvalidationObject = - await encryptedSavedObjectsClient.getDecryptedAsInternalUser( - API_KEY_PENDING_INVALIDATION_TYPE, - apiKeyPendingInvalidationSO.id - ); - return { - id: decryptedApiKeyPendingInvalidationObject.id, - apiKeyId: decryptedApiKeyPendingInvalidationObject.attributes.apiKeyId, - }; - }) - ); - - // Query saved objects index to see if any API keys are in use - const apiKeyIdStrings = apiKeyIds.map(({ apiKeyId }) => apiKeyId); - let apiKeyIdsInUseBuckets: AggregationsStringTermsBucketKeys[] = []; - - for (const soType of [AD_HOC_RUN_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE]) { - apiKeyIdsInUseBuckets = apiKeyIdsInUseBuckets.concat( - await queryForApiKeysInUse(apiKeyIdStrings, soType, savedObjectsClient) - ); - } - - const apiKeyIdsToInvalidate: ApiKeyIdAndSOId[] = []; - const apiKeyIdsToExclude: ApiKeyIdAndSOId[] = []; - apiKeyIds.forEach(({ id, apiKeyId }) => { - if (apiKeyIdsInUseBuckets.find((bucket) => bucket.key === apiKeyId)) { - apiKeyIdsToExclude.push({ id, apiKeyId }); - } else { - apiKeyIdsToInvalidate.push({ id, apiKeyId }); - } - }); - - return { apiKeyIdsToInvalidate, apiKeyIdsToExclude }; -} - -export function getFindFilter(delay: string, excludedSOIds: string[] = []): string { - let filter = `${API_KEY_PENDING_INVALIDATION_TYPE}.attributes.createdAt <= "${delay}"`; - if (excludedSOIds.length > 0) { - const excluded = [...new Set(excludedSOIds)]; - const excludedSOIdFilter = (excluded ?? []).map( - (id: string) => - `NOT ${API_KEY_PENDING_INVALIDATION_TYPE}.id: "${API_KEY_PENDING_INVALIDATION_TYPE}:${id}"` - ); - filter += ` AND ${excludedSOIdFilter.join(' AND ')}`; - } - return filter; -} - -interface InvalidateApiKeysAndDeleteSO { - apiKeyIdsToInvalidate: ApiKeyIdAndSOId[]; - logger: Logger; - savedObjectsClient: SavedObjectsClientContract; - securityPluginStart?: SecurityPluginStart; -} - -export async function invalidateApiKeysAndDeletePendingApiKeySavedObject({ - apiKeyIdsToInvalidate, - logger, - savedObjectsClient, - securityPluginStart, -}: InvalidateApiKeysAndDeleteSO) { - let totalInvalidated = 0; - if (apiKeyIdsToInvalidate.length > 0) { - const ids = apiKeyIdsToInvalidate.map(({ apiKeyId }) => apiKeyId); - const response = await invalidateAPIKeys({ ids }, securityPluginStart); - if (response.apiKeysEnabled === true && response.result.error_count > 0) { - logger.error(`Failed to invalidate API Keys [ids="${ids.join(', ')}"]`); - } else { - await Promise.all( - apiKeyIdsToInvalidate.map(async ({ id, apiKeyId }) => { - try { - await savedObjectsClient.delete(API_KEY_PENDING_INVALIDATION_TYPE, id); - totalInvalidated++; - } catch (err) { - logger.error( - `Failed to delete invalidated API key "${apiKeyId}". Error: ${err.message}` - ); - } - }) - ); - } - } - logger.debug(`Total invalidated API keys "${totalInvalidated}"`); - return totalInvalidated; -} - -async function queryForApiKeysInUse( - apiKeyIds: string[], - savedObjectType: string, - savedObjectsClient: SavedObjectsClientContract -): Promise { - const filter = `${apiKeyIds - .map((apiKeyId) => `${savedObjectType}.attributes.apiKeyId: "${apiKeyId}"`) - .join(' OR ')}`; - - const { aggregations } = await savedObjectsClient.find< - AdHocRunSO, - { apiKeyId: AggregationsTermsAggregateBase } - >({ - type: savedObjectType, - filter, - perPage: 0, - namespaces: ['*'], - aggs: { - apiKeyId: { - terms: { - field: `${savedObjectType}.attributes.apiKeyId`, - size: PAGE_SIZE, - }, - }, - }, - }); - - return (aggregations?.apiKeyId?.buckets as AggregationsStringTermsBucketKeys[]) ?? []; -} diff --git a/x-pack/platform/plugins/shared/task_manager/moon.yml b/x-pack/platform/plugins/shared/task_manager/moon.yml index b48d633ec63a8..b8d03154d29df 100644 --- a/x-pack/platform/plugins/shared/task_manager/moon.yml +++ b/x-pack/platform/plugins/shared/task_manager/moon.yml @@ -48,6 +48,7 @@ dependsOn: - '@kbn/spaces-utils' - '@kbn/licensing-types' - '@kbn/lazy-object' + - '@kbn/security-plugin-types-server' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/task_manager/server/config.test.ts b/x-pack/platform/plugins/shared/task_manager/server/config.test.ts index d8526ac346d79..1244efea4644e 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/config.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/config.test.ts @@ -23,6 +23,10 @@ describe('config validation', () => { "monitor": true, "warn_threshold": 5000, }, + "invalidate_api_key_task": Object { + "interval": "5m", + "removalDelay": "1h", + }, "kibanas_per_partition": 2, "max_attempts": 3, "metrics_reset_interval": 30000, @@ -81,6 +85,10 @@ describe('config validation', () => { "monitor": true, "warn_threshold": 5000, }, + "invalidate_api_key_task": Object { + "interval": "5m", + "removalDelay": "1h", + }, "kibanas_per_partition": 2, "max_attempts": 3, "metrics_reset_interval": 30000, @@ -137,6 +145,10 @@ describe('config validation', () => { "monitor": true, "warn_threshold": 5000, }, + "invalidate_api_key_task": Object { + "interval": "5m", + "removalDelay": "1h", + }, "kibanas_per_partition": 2, "max_attempts": 3, "metrics_reset_interval": 30000, diff --git a/x-pack/platform/plugins/shared/task_manager/server/config.ts b/x-pack/platform/plugins/shared/task_manager/server/config.ts index 942158716fad4..cab8587f09a4e 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/config.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/config.ts @@ -78,6 +78,13 @@ const requestTimeoutsConfig = schema.object({ update_by_query: schema.number({ defaultValue: 1000 * 30, min: 1000 * 10, max: 1000 * 60 * 10 }), }); +const validateDuration = (duration: string) => { + try { + parseIntervalAsMillisecond(duration); + } catch (err) { + return `string is not a valid duration: ${duration}`; + } +}; export const configSchema = schema.object( { allow_reading_invalid_state: schema.boolean({ defaultValue: true }), @@ -106,6 +113,10 @@ export const configSchema = schema.object( /* Allows for old kibana config to start kibana without crashing since ephemeral tasks are deprecated*/ ephemeral_tasks: schema.maybe(schema.any()), event_loop_delay: eventLoopDelaySchema, + invalidate_api_key_task: schema.object({ + interval: schema.string({ validate: validateDuration, defaultValue: '5m' }), + removalDelay: schema.string({ validate: validateDuration, defaultValue: '1h' }), + }), kibanas_per_partition: schema.number({ defaultValue: DEFAULT_KIBANAS_PER_PARTITION, min: 1, diff --git a/x-pack/platform/plugins/shared/task_manager/server/index.ts b/x-pack/platform/plugins/shared/task_manager/server/index.ts index 1371f62ec26a7..5bbcf8d7ae481 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/index.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/index.ts @@ -54,6 +54,7 @@ export { } from './queries/mark_available_tasks_as_claimed'; export { aggregateTaskOverduePercentilesForType } from './queries/aggregate_task_overdue_percentiles_for_type'; +export { runInvalidate } from './invalidate_api_keys/lib'; export type { TaskManagerPlugin as TaskManager, TaskManagerSetupContract, diff --git a/x-pack/platform/plugins/shared/task_manager/server/integration_tests/managed_configuration.test.ts b/x-pack/platform/plugins/shared/task_manager/server/integration_tests/managed_configuration.test.ts index 4732c40602707..83c1bf9166b6f 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/integration_tests/managed_configuration.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/integration_tests/managed_configuration.test.ts @@ -68,6 +68,10 @@ describe('managed configuration', () => { kibanas_per_partition: 2, capacity: 10, max_attempts: 9, + invalidate_api_key_task: { + interval: '5m', + removalDelay: '1h', + }, poll_interval: 3000, allow_reading_invalid_state: false, version_conflict_threshold: 80, diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/invalidate_api_keys_task.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/invalidate_api_keys_task.ts new file mode 100644 index 0000000000000..d30e7a6006b6e --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/invalidate_api_keys_task.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/logging'; +import type { CoreStart } from '@kbn/core-lifecycle-server'; +import type { + InvalidateAPIKeyResult, + InvalidateAPIKeysParams, +} from '@kbn/security-plugin-types-server'; +import type { TaskScheduling } from '../task_scheduling'; +import type { TaskTypeDictionary } from '../task_type_dictionary'; +import { INVALIDATE_API_KEY_SO_NAME, TASK_SO_NAME } from '../saved_objects'; +import type { TaskManagerStartContract } from '..'; +import type { TaskManagerPluginsStart } from '../plugin'; +import { runInvalidate } from './lib'; + +export const TASK_ID = 'invalidate_api_keys'; +const TASK_TYPE = `task_manager:${TASK_ID}`; + +export type ApiKeyInvalidationFn = ( + params: InvalidateAPIKeysParams +) => Promise | undefined; + +export async function scheduleInvalidateApiKeyTask( + logger: Logger, + taskScheduling: TaskScheduling, + interval: string +) { + try { + await taskScheduling.ensureScheduled({ + id: TASK_ID, + taskType: TASK_TYPE, + schedule: { interval }, + state: {}, + params: {}, + }); + } catch (e) { + logger.error(`Error scheduling ${TASK_ID} task, received ${e.message}`); + } +} + +interface RegisterInvalidateApiKeyTaskOpts { + configInterval: string; + coreStartServices: () => Promise<[CoreStart, TaskManagerPluginsStart, TaskManagerStartContract]>; + invalidateApiKeyFn?: ApiKeyInvalidationFn; + logger: Logger; + removalDelay: string; + taskTypeDictionary: TaskTypeDictionary; +} + +export function registerInvalidateApiKeyTask(opts: RegisterInvalidateApiKeyTaskOpts) { + const { + logger, + configInterval, + coreStartServices, + invalidateApiKeyFn, + removalDelay, + taskTypeDictionary, + } = opts; + taskTypeDictionary.registerTaskDefinitions({ + [TASK_TYPE]: { + title: 'Invalidate task manager API keys', + createTaskRunner: taskRunner({ + logger, + configInterval, + coreStartServices, + invalidateApiKeyFn, + removalDelay, + }), + }, + }); +} + +type InvalidateApiKeysTaskRunnerOpts = Pick< + RegisterInvalidateApiKeyTaskOpts, + 'logger' | 'configInterval' | 'coreStartServices' | 'invalidateApiKeyFn' | 'removalDelay' +>; + +export function taskRunner(opts: InvalidateApiKeysTaskRunnerOpts) { + const { logger, configInterval, coreStartServices, invalidateApiKeyFn, removalDelay } = opts; + return () => { + return { + async run() { + try { + const [{ savedObjects }] = await coreStartServices(); + const savedObjectsClient = savedObjects.createInternalRepository([ + INVALIDATE_API_KEY_SO_NAME, + ]); + + const totalInvalidated = await runInvalidate({ + invalidateApiKeyFn, + logger, + removalDelay, + savedObjectsClient, + savedObjectType: INVALIDATE_API_KEY_SO_NAME, + savedObjectTypesToQuery: [ + { + type: TASK_SO_NAME, + apiKeyAttributePath: `${TASK_SO_NAME}.attributes.userScope.apiKeyId`, + }, + ], + }); + + logger.debug(`Invalidated a total of ${totalInvalidated} API keys.`); + + return { + state: {}, + schedule: { interval: configInterval }, + }; + } catch (e) { + logger.error(`Error invalidating task manager API keys - ${e.message} `); + return { + state: {}, + schedule: { interval: configInterval }, + }; + } + }, + }; + }; +} diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/constants.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/constants.ts new file mode 100644 index 0000000000000..1d8135638881a --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const PAGE_SIZE = 100; diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.test.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.test.ts new file mode 100644 index 0000000000000..e5a3de46081d1 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.test.ts @@ -0,0 +1,519 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; +import { getApiKeyIdsToInvalidate } from './get_api_key_ids_to_invalidate'; +import type { + EncryptedSavedObjectsClient, + EncryptedSavedObjectsClientOptions, +} from '@kbn/encrypted-saved-objects-shared'; +import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; + +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + +const mockInvalidatePendingApiKeyObject1 = { + id: '1', + type: 'api_key_pending_invalidation', + attributes: { + apiKeyId: 'abcd====!', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], +}; +const mockInvalidatePendingApiKeyObject2 = { + id: '2', + type: 'api_key_pending_invalidation', + attributes: { + apiKeyId: 'xyz!==!', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], +}; + +function createEncryptedSavedObjectsClientMock(opts?: EncryptedSavedObjectsClientOptions) { + return { + getDecryptedAsInternalUser: jest.fn(), + createPointInTimeFinderDecryptedAsInternalUser: jest.fn((findOptions, deps) => + savedObjectsClientMock.create().createPointInTimeFinder(findOptions, deps) + ), + } as unknown as jest.Mocked; +} + +describe('getApiKeyIdsToInvalidate', () => { + describe('with encryptedSavedObjectsClient', () => { + const encryptedSavedObjectsClient = createEncryptedSavedObjectsClientMock(); + + test('should get decrypted api key pending invalidation saved object', async () => { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + + const result = await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: 'api_key_pending_invalidation', + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type: 'api_key_pending_invalidation', + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + encryptedSavedObjectsClient, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_pending_invalidation', + savedObjectTypesToQuery: [], + }); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + 'api_key_pending_invalidation', + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + 'api_key_pending_invalidation', + '2' + ); + expect(internalSavedObjectsRepository.find).not.toHaveBeenCalled(); + expect(result).toEqual({ + apiKeyIdsToInvalidate: [ + { id: '1', apiKeyId: 'abcd====!' }, + { id: '2', apiKeyId: 'xyz!==!' }, + ], + apiKeyIdsToExclude: [], + }); + }); + }); + + describe('without encryptedSavedObjectsClient', () => { + test('should use saved object information', async () => { + const result = await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '3', + type: 'api_key_to_invalidate', + score: 0, + attributes: { apiKeyId: 'abc', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '4', + type: 'api_key_to_invalidate', + score: 0, + attributes: { apiKeyId: 'def', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_to_invalidate', + savedObjectTypesToQuery: [], + }); + + expect(internalSavedObjectsRepository.find).not.toHaveBeenCalled(); + expect(result).toEqual({ + apiKeyIdsToInvalidate: [ + { id: '3', apiKeyId: 'abc' }, + { id: '4', apiKeyId: 'def' }, + ], + apiKeyIdsToExclude: [], + }); + }); + }); + + test('should exclude some api key IDs when they are still in use by AD_HOC_RUN_SAVED_OBJECT_TYPE', async () => { + const encryptedSavedObjectsClient = createEncryptedSavedObjectsClientMock(); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + // first call to find aggregates any AD_HOC_RUN_SAVED_OBJECT_TYPE SOs by apiKeyId + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'abcd====!', doc_count: 1 }], + }, + }, + }); + + const result = await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: 'api_key_pending_invalidation', + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type: 'api_key_pending_invalidation', + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + encryptedSavedObjectsClient, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_pending_invalidation', + savedObjectTypesToQuery: [ + { type: 'ad_hoc_run_params', apiKeyAttributePath: 'ad_hoc_run_params.attributes.apiKeyId' }, + ], + }); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + 'api_key_pending_invalidation', + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + 'api_key_pending_invalidation', + '2' + ); + expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + type: 'ad_hoc_run_params', + perPage: 0, + filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `ad_hoc_run_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(result).toEqual({ + apiKeyIdsToInvalidate: [{ id: '2', apiKeyId: 'xyz!==!' }], + apiKeyIdsToExclude: [{ id: '1', apiKeyId: 'abcd====!' }], + }); + }); + + test('should exclude some api key IDs when they are still in use by ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE', async () => { + const encryptedSavedObjectsClient = createEncryptedSavedObjectsClientMock(); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'abcd====!', doc_count: 1 }], + }, + }, + }); + + const result = await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: 'api_key_pending_invalidation', + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type: 'api_key_pending_invalidation', + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + encryptedSavedObjectsClient, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_pending_invalidation', + savedObjectTypesToQuery: [ + { + type: 'action_task_params', + apiKeyAttributePath: 'action_task_params.attributes.apiKeyId', + }, + ], + }); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + 'api_key_pending_invalidation', + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + 'api_key_pending_invalidation', + '2' + ); + expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + type: 'action_task_params', + perPage: 0, + filter: `action_task_params.attributes.apiKeyId: "abcd====!" OR action_task_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `action_task_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(result).toEqual({ + apiKeyIdsToInvalidate: [{ id: '2', apiKeyId: 'xyz!==!' }], + apiKeyIdsToExclude: [{ id: '1', apiKeyId: 'abcd====!' }], + }); + }); + + test('should exclude some api key IDs when they are still in use by TASK_SAVED_OBJECT_TYPE', async () => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'abcdefghijklmnop', doc_count: 1 }], + }, + }, + }); + + const result = await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: 'api_key_to_invalidate', + score: 0, + attributes: { apiKeyId: 'xyzaaaakkk', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type: 'api_key_to_invalidate', + score: 0, + attributes: { apiKeyId: 'abcdefghijklmnop', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_pending_invalidation', + savedObjectTypesToQuery: [ + { + type: 'task', + apiKeyAttributePath: 'task.attributes.userScope.apiKeyId', + }, + ], + }); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + type: 'task', + perPage: 0, + filter: `task.attributes.userScope.apiKeyId: "xyzaaaakkk" OR task.attributes.userScope.apiKeyId: "abcdefghijklmnop"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `task.attributes.userScope.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(result).toEqual({ + apiKeyIdsToInvalidate: [{ id: '1', apiKeyId: 'xyzaaaakkk' }], + apiKeyIdsToExclude: [{ id: '2', apiKeyId: 'abcdefghijklmnop' }], + }); + }); + + test('should throw error if encryptedSavedObjectsClient.getDecryptedAsInternalUser throws error', async () => { + const encryptedSavedObjectsClient = createEncryptedSavedObjectsClientMock(); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockImplementationOnce(() => { + throw new Error('failfail'); + }); + + await expect( + getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: 'api_key_pending_invalidation', + score: 0, + attributes: { + apiKeyId: 'encryptedencrypted', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], + }, + { + id: '2', + type: 'api_key_pending_invalidation', + score: 0, + attributes: { + apiKeyId: 'encryptedencrypted', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + encryptedSavedObjectsClient, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_pending_invalidation', + savedObjectTypesToQuery: [ + { + type: 'ad_hoc_run_params', + apiKeyAttributePath: 'ad_hoc_run_params.attributes.apiKeyId', + }, + ], + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"failfail"`); + }); + + test('should throw error if malformed savedObjectsClient.find response', async () => { + const encryptedSavedObjectsClient = createEncryptedSavedObjectsClientMock(); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 10, + // missing aggregations + }); + + const result = await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: 'api_key_pending_invalidation', + score: 0, + attributes: { + apiKeyId: 'encryptedencrypted', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + encryptedSavedObjectsClient, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_pending_invalidation', + savedObjectTypesToQuery: [ + { + type: 'task', + apiKeyAttributePath: 'task.attributes.userScope.apiKeyId', + }, + ], + }); + + expect(result).toEqual({ + apiKeyIdsToInvalidate: [{ id: '1', apiKeyId: 'abcd====!' }], + apiKeyIdsToExclude: [], + }); + }); + + test('should throw error if savedObjectsClient.find throws error', async () => { + internalSavedObjectsRepository.find.mockImplementationOnce(() => { + throw new Error('failfail'); + }); + await expect( + getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: 'api_key_to_invalidate', + score: 0, + attributes: { + apiKeyId: 'abdedfasd', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_pending_invalidation', + savedObjectTypesToQuery: [ + { + type: 'task', + apiKeyAttributePath: 'task.attributes.userScope.apiKeyId', + }, + ], + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"failfail"`); + }); +}); diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.ts new file mode 100644 index 0000000000000..78ce642f442d2 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_api_key_ids_to_invalidate.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsFindResponse, SavedObjectsClientContract } from '@kbn/core/server'; +import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-shared'; +import type { AggregationsStringTermsBucketKeys } from '@elastic/elasticsearch/lib/api/types'; +import type { ApiKeyToInvalidate } from '../../saved_objects/schemas/api_key_to_invalidate'; +import type { SavedObjectTypesToQuery } from './run_invalidate'; +import { queryForApiKeysInUse } from './query_for_api_keys_in_use'; + +export interface ApiKeyIdAndSOId { + id: string; + apiKeyId: string; +} + +interface GetApiKeyIdsToInvalidateOpts { + apiKeySOsPendingInvalidation: SavedObjectsFindResponse; + encryptedSavedObjectsClient?: EncryptedSavedObjectsClient; + savedObjectsClient: SavedObjectsClientContract; + savedObjectType: string; + savedObjectTypesToQuery: SavedObjectTypesToQuery[]; +} + +interface GetApiKeysToInvalidateResult { + apiKeyIdsToInvalidate: ApiKeyIdAndSOId[]; + apiKeyIdsToExclude: ApiKeyIdAndSOId[]; +} + +export async function getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation, + encryptedSavedObjectsClient, + savedObjectsClient, + savedObjectType, + savedObjectTypesToQuery, +}: GetApiKeyIdsToInvalidateOpts): Promise { + let apiKeyIds: ApiKeyIdAndSOId[] = []; + if (encryptedSavedObjectsClient) { + // Decrypt the apiKeyId for each pending invalidation SO + apiKeyIds = await Promise.all( + apiKeySOsPendingInvalidation.saved_objects.map(async (apiKeyPendingInvalidationSO) => { + const decryptedApiKeyPendingInvalidationObject = + await encryptedSavedObjectsClient.getDecryptedAsInternalUser( + savedObjectType, + apiKeyPendingInvalidationSO.id + ); + return { + id: decryptedApiKeyPendingInvalidationObject.id, + apiKeyId: decryptedApiKeyPendingInvalidationObject.attributes.apiKeyId, + }; + }) + ); + } else { + // No decryption needed, return the apiKeyId as-is + apiKeyIds = apiKeySOsPendingInvalidation.saved_objects.map((apiKeyPendingInvalidationSO) => ({ + id: apiKeyPendingInvalidationSO.id, + apiKeyId: apiKeyPendingInvalidationSO.attributes.apiKeyId, + })); + } + + // Query saved objects index to see if any API keys are in use + const apiKeyIdStrings = apiKeyIds.map(({ apiKeyId }) => apiKeyId); + let apiKeyIdsInUseBuckets: AggregationsStringTermsBucketKeys[] = []; + + for (const type of savedObjectTypesToQuery) { + apiKeyIdsInUseBuckets = apiKeyIdsInUseBuckets.concat( + await queryForApiKeysInUse({ + apiKeyIds: apiKeyIdStrings, + savedObjectTypeToQuery: type, + savedObjectsClient, + }) + ); + } + + const apiKeyIdsToInvalidate: ApiKeyIdAndSOId[] = []; + const apiKeyIdsToExclude: ApiKeyIdAndSOId[] = []; + apiKeyIds.forEach(({ id, apiKeyId }) => { + if (apiKeyIdsInUseBuckets.find((bucket) => bucket.key === apiKeyId)) { + apiKeyIdsToExclude.push({ id, apiKeyId }); + } else { + apiKeyIdsToInvalidate.push({ id, apiKeyId }); + } + }); + + return { apiKeyIdsToInvalidate, apiKeyIdsToExclude }; +} diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_find_filter.test.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_find_filter.test.ts new file mode 100644 index 0000000000000..1e00e32abbef6 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_find_filter.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import sinon from 'sinon'; +import { getFindFilter } from './get_find_filter'; + +let clock: sinon.SinonFakeTimers; +describe('getFindFilter', () => { + beforeAll(() => { + clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z')); + }); + afterAll(() => clock.restore()); + beforeEach(() => { + jest.clearAllMocks(); + clock.reset(); + }); + + test('should build find filter with delay and empty excluded SO id array', () => { + expect( + getFindFilter({ + removalDelay: '1h', + excludedSOIds: [], + savedObjectType: 'api_key_pending_invalidation', + }) + ).toEqual(`api_key_pending_invalidation.attributes.createdAt <= "2021-01-01T11:00:00.000Z"`); + }); + + test('should build find filter with delay and one excluded SO id array', () => { + expect( + getFindFilter({ + removalDelay: '1h', + excludedSOIds: ['abc'], + savedObjectType: 'api_key_pending_invalidation', + }) + ).toEqual( + `api_key_pending_invalidation.attributes.createdAt <= "2021-01-01T11:00:00.000Z" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:abc"` + ); + }); + + test('should build find filter with delay and multiple excluded SO id array', () => { + expect( + getFindFilter({ + removalDelay: '1h', + excludedSOIds: ['abc', 'def'], + savedObjectType: 'api_key_pending_invalidation', + }) + ).toEqual( + `api_key_pending_invalidation.attributes.createdAt <= "2021-01-01T11:00:00.000Z" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:abc" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:def"` + ); + }); + + test('should handle duplicate excluded SO ids', () => { + expect( + getFindFilter({ + excludedSOIds: ['abc', 'abc', 'abc', 'def'], + removalDelay: '1h', + savedObjectType: 'api_key_pending_invalidation', + }) + ).toEqual( + `api_key_pending_invalidation.attributes.createdAt <= "2021-01-01T11:00:00.000Z" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:abc" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:def"` + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_find_filter.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_find_filter.ts new file mode 100644 index 0000000000000..3d0cfb22b5139 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/get_find_filter.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { timePeriodBeforeDate } from '../../lib/intervals'; + +interface GetFindFilterOpts { + removalDelay: string; + excludedSOIds?: string[]; + savedObjectType: string; +} +export function getFindFilter(opts: GetFindFilterOpts): string { + const { removalDelay, excludedSOIds = [], savedObjectType } = opts; + const delay: string = timePeriodBeforeDate(new Date(), removalDelay).toISOString(); + let filter = `${savedObjectType}.attributes.createdAt <= "${delay}"`; + + if (excludedSOIds.length > 0) { + const excluded = [...new Set(excludedSOIds)]; + const excludedSOIdFilter = (excluded ?? []).map( + (id: string) => `NOT ${savedObjectType}.id: "${savedObjectType}:${id}"` + ); + if (filter.length > 0) { + filter += ` AND ${excludedSOIdFilter.join(' AND ')}`; + } else { + filter += excludedSOIdFilter.join(' AND '); + } + } + return filter; +} diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/index.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/index.ts new file mode 100644 index 0000000000000..c19e568e1c517 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { runInvalidate } from './run_invalidate'; diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys.ts new file mode 100644 index 0000000000000..38d9deb59652b --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + InvalidateAPIKeysParams, + InvalidateAPIKeyResult as SecurityPluginInvalidateAPIKeyResult, +} from '@kbn/security-plugin-types-server'; +import type { ApiKeyInvalidationFn } from '../invalidate_api_keys_task'; + +export type InvalidateAPIKeyResult = + | { apiKeysEnabled: false } + | { apiKeysEnabled: true; result: SecurityPluginInvalidateAPIKeyResult }; + +export async function invalidateAPIKeys( + params: InvalidateAPIKeysParams, + invalidateApiKeyFn?: ApiKeyInvalidationFn +): Promise { + if (!invalidateApiKeyFn) { + return { apiKeysEnabled: false }; + } + const invalidateAPIKeyResult = await invalidateApiKeyFn(params); + // Null when Elasticsearch security is disabled + if (!invalidateAPIKeyResult) { + return { apiKeysEnabled: false }; + } + return { + apiKeysEnabled: true, + result: invalidateAPIKeyResult, + }; +} diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.test.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.test.ts new file mode 100644 index 0000000000000..19a7fddca6b99 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock, savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; +import { invalidateApiKeysAndDeletePendingApiKeySavedObject } from './invalidate_api_keys_and_delete_so'; + +const logger: ReturnType = loggingSystemMock.createLogger(); +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const invalidateApiKeyFn = jest.fn(); + +describe('invalidateApiKeysAndDeletePendingApiKeySavedObject', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should succeed when there are no api keys to invalidate', async () => { + const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate: [], + invalidateApiKeyFn, + logger, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_pending_invalidation', + }); + expect(total).toEqual(0); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "0"`); + }); + + test('should succeed when there are api keys to invalidate', async () => { + invalidateApiKeyFn.mockResolvedValue({ + invalidated_api_keys: ['1', '2'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate: [ + { id: '1', apiKeyId: 'abcd====!' }, + { id: '2', apiKeyId: 'xyz!==!' }, + ], + invalidateApiKeyFn, + logger, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_to_invalidate', + }); + expect(invalidateApiKeyFn).toHaveBeenCalledWith({ + ids: ['abcd====!', 'xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 1, + 'api_key_to_invalidate', + '1' + ); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 2, + 'api_key_to_invalidate', + '2' + ); + expect(total).toEqual(2); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "2"`); + }); + + test('should handle errors during invalidation', async () => { + invalidateApiKeyFn.mockResolvedValueOnce({ + invalidated_api_keys: ['1'], + previously_invalidated_api_keys: [], + error_count: 1, + }); + const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate: [ + { id: '1', apiKeyId: 'abcd====!' }, + { id: '2', apiKeyId: 'xyz!==!' }, + ], + invalidateApiKeyFn, + logger, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_pending_invalidation', + }); + expect(invalidateApiKeyFn).toHaveBeenCalledWith({ + ids: ['abcd====!', 'xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + `Failed to invalidate API Keys [ids=\"abcd====!, xyz!==!\"]` + ); + expect(total).toEqual(0); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "0"`); + }); + + test('should handle null security plugin', async () => { + const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate: [ + { id: '1', apiKeyId: 'abcd====!' }, + { id: '2', apiKeyId: 'xyz!==!' }, + ], + logger, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_pending_invalidation', + }); + expect(invalidateApiKeyFn).not.toHaveBeenCalled(); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 1, + 'api_key_pending_invalidation', + '1' + ); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 2, + 'api_key_pending_invalidation', + '2' + ); + expect(total).toEqual(2); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "2"`); + }); + + test('should handle null result from invalidateAsInternalUser', async () => { + invalidateApiKeyFn.mockResolvedValueOnce(null); + const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate: [ + { id: '1', apiKeyId: 'abcd====!' }, + { id: '2', apiKeyId: 'xyz!==!' }, + ], + invalidateApiKeyFn, + logger, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: 'api_key_to_invalidate', + }); + expect(invalidateApiKeyFn).toHaveBeenCalledWith({ + ids: ['abcd====!', 'xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 1, + 'api_key_to_invalidate', + '1' + ); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 2, + 'api_key_to_invalidate', + '2' + ); + expect(total).toEqual(2); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "2"`); + }); +}); diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.ts new file mode 100644 index 0000000000000..523079d303b66 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import type { ApiKeyIdAndSOId } from './get_api_key_ids_to_invalidate'; +import { invalidateAPIKeys } from './invalidate_api_keys'; +import type { ApiKeyInvalidationFn } from '../invalidate_api_keys_task'; + +interface InvalidateApiKeysAndDeleteSO { + apiKeyIdsToInvalidate: ApiKeyIdAndSOId[]; + invalidateApiKeyFn?: ApiKeyInvalidationFn; + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; + savedObjectType: string; +} + +export async function invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate, + invalidateApiKeyFn, + logger, + savedObjectsClient, + savedObjectType, +}: InvalidateApiKeysAndDeleteSO) { + let totalInvalidated = 0; + if (apiKeyIdsToInvalidate.length > 0) { + const ids = apiKeyIdsToInvalidate.map(({ apiKeyId }) => apiKeyId); + const response = await invalidateAPIKeys({ ids }, invalidateApiKeyFn); + if (response.apiKeysEnabled === true && response.result.error_count > 0) { + logger.error(`Failed to invalidate API Keys [ids="${ids.join(', ')}"]`); + } else { + await Promise.all( + apiKeyIdsToInvalidate.map(async ({ id, apiKeyId }) => { + try { + await savedObjectsClient.delete(savedObjectType, id); + totalInvalidated++; + } catch (err) { + logger.error( + `Failed to delete invalidated API key "${apiKeyId}". Error: ${err.message}` + ); + } + }) + ); + } + } + logger.debug(`Total invalidated API keys "${totalInvalidated}"`); + return totalInvalidated; +} diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/query_for_api_keys_in_use.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/query_for_api_keys_in_use.ts new file mode 100644 index 0000000000000..e5885cb34cbb3 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/query_for_api_keys_in_use.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + AggregationsStringTermsBucketKeys, + AggregationsTermsAggregateBase, +} from '@elastic/elasticsearch/lib/api/types'; +import type { SavedObjectsClientContract } from '@kbn/core/server'; +import { PAGE_SIZE } from './constants'; +import type { SavedObjectTypesToQuery } from './run_invalidate'; + +interface QueryForApiKeysInUseOpts { + apiKeyIds: string[]; + savedObjectTypeToQuery: SavedObjectTypesToQuery; + savedObjectsClient: SavedObjectsClientContract; +} + +export async function queryForApiKeysInUse( + opts: QueryForApiKeysInUseOpts +): Promise { + const { apiKeyIds, savedObjectTypeToQuery, savedObjectsClient } = opts; + const filter = `${apiKeyIds + .map((apiKeyId) => `${savedObjectTypeToQuery.apiKeyAttributePath}: "${apiKeyId}"`) + .join(' OR ')}`; + + const { aggregations } = await savedObjectsClient.find< + unknown, + { apiKeyId: AggregationsTermsAggregateBase } + >({ + type: savedObjectTypeToQuery.type, + filter, + perPage: 0, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `${savedObjectTypeToQuery.apiKeyAttributePath}`, + size: PAGE_SIZE, + }, + }, + }, + }); + + return (aggregations?.apiKeyId?.buckets as AggregationsStringTermsBucketKeys[]) ?? []; +} diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.test.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.test.ts new file mode 100644 index 0000000000000..816bceaccaa3a --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.test.ts @@ -0,0 +1,839 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import sinon from 'sinon'; +import { loggingSystemMock, savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; +import type { + EncryptedSavedObjectsClient, + EncryptedSavedObjectsClientOptions, +} from '@kbn/encrypted-saved-objects-shared'; +import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; +import { runInvalidate } from './run_invalidate'; + +let clock: sinon.SinonFakeTimers; +const logger: ReturnType = loggingSystemMock.createLogger(); +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const invalidateApiKeyFn = jest.fn(); + +const mockInvalidatePendingApiKeyObject1 = { + id: '1', + type: 'api_key_pending_invalidation', + attributes: { + apiKeyId: 'abcd====!', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], +}; +const mockInvalidatePendingApiKeyObject2 = { + id: '2', + type: 'api_key_pending_invalidation', + attributes: { + apiKeyId: 'xyz!==!', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], +}; + +function createEncryptedSavedObjectsClientMock(opts?: EncryptedSavedObjectsClientOptions) { + return { + getDecryptedAsInternalUser: jest.fn(), + createPointInTimeFinderDecryptedAsInternalUser: jest.fn((findOptions, deps) => + savedObjectsClientMock.create().createPointInTimeFinder(findOptions, deps) + ), + } as unknown as jest.Mocked; +} +const encryptedSavedObjectsClient = createEncryptedSavedObjectsClientMock(); + +describe('runInvalidate', () => { + beforeAll(() => { + clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z')); + }); + afterAll(() => clock.restore()); + beforeEach(() => { + jest.resetAllMocks(); + clock.reset(); + }); + + [ + { + type: 'api_key_pending_invalidation', + encrypted: true, + savedObjectTypes: [ + { + type: 'ad_hoc_run_params', + apiKeyAttributePath: `ad_hoc_run_params.attributes.apiKeyId`, + }, + { + type: 'action_task_params', + apiKeyAttributePath: `action_task_params.attributes.apiKeyId`, + }, + ], + }, + { + type: 'api_key_to_invalidate', + encrypted: false, + savedObjectTypes: [ + { type: 'task', apiKeyAttributePath: 'task.attributes.userScope.apiKeyId' }, + ], + }, + ].forEach( + ({ + type, + encrypted, + savedObjectTypes, + }: { + type: string; + encrypted: boolean; + savedObjectTypes: { type: string; apiKeyAttributePath: string }[]; + }) => { + const label = `SO ${type} encrypted=${encrypted}`; + const findApiKeyId1 = encrypted ? 'encryptedencrypted' : 'abcd====!'; + const findApiKeyId2 = encrypted ? 'encryptedencrypted' : 'xyz!==!'; + + const expectedFilter = `${type}.attributes.createdAt <= "2021-01-01T11:00:00.000Z"`; + + test(`${label} - should succeed when there are no API keys to invalidate`, async () => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 0, + page: 1, + per_page: 100, + }); + const result = await runInvalidate({ + ...(encrypted ? { encryptedSavedObjectsClient } : {}), + removalDelay: '1h', + invalidateApiKeyFn, + logger, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: type, + savedObjectTypesToQuery: savedObjectTypes, + }); + expect(result).toEqual(0); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(1); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + type, + filter: expectedFilter, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).not.toHaveBeenCalled(); + expect(invalidateApiKeyFn).not.toHaveBeenCalled(); + expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); + }); + + test(`${label} - should succeed when there are API keys to invalidate`, async () => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type, + score: 0, + attributes: { apiKeyId: findApiKeyId1, createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type, + score: 0, + attributes: { apiKeyId: findApiKeyId2, createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 100, + page: 1, + }); + + if (encrypted) { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + } + + savedObjectTypes.forEach(() => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 1, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + }); + + invalidateApiKeyFn.mockResolvedValue({ + invalidated_api_keys: ['1', '2'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + + const result = await runInvalidate({ + ...(encrypted ? { encryptedSavedObjectsClient } : {}), + removalDelay: '1h', + invalidateApiKeyFn, + logger, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: type, + savedObjectTypesToQuery: savedObjectTypes, + }); + + expect(result).toEqual(2); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes( + 1 + savedObjectTypes.length + ); + + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { + type, + filter: expectedFilter, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + + if (encrypted) { + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + type, + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + type, + '2' + ); + } else { + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).not.toHaveBeenCalled(); + } + + savedObjectTypes.forEach((t) => { + expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + type: t.type, + perPage: 0, + filter: `${t.apiKeyAttributePath}: "abcd====!" OR ${t.apiKeyAttributePath}: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: t.apiKeyAttributePath, + size: 100, + }, + }, + }, + }); + }); + + expect(invalidateApiKeyFn).toHaveBeenCalledTimes(1); + expect(invalidateApiKeyFn).toHaveBeenCalledWith({ + ids: ['abcd====!', 'xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith(1, type, '1'); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith(2, type, '2'); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "2"`); + }); + + test(`${label} - should succeed when there are API keys to invalidate and API keys to exclude`, async () => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type, + score: 0, + attributes: { apiKeyId: findApiKeyId1, createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type, + score: 0, + attributes: { apiKeyId: findApiKeyId2, createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 100, + page: 1, + }); + + if (encrypted) { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + } + + savedObjectTypes.forEach((t) => { + if (t.type === 'ad_hoc_run_params' || t.type === 'task') { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'abcd====!', doc_count: 1 }], + }, + }, + }); + } else { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + } + }); + + invalidateApiKeyFn.mockResolvedValue({ + invalidated_api_keys: ['1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + + const result = await runInvalidate({ + ...(encrypted ? { encryptedSavedObjectsClient } : {}), + removalDelay: '1h', + invalidateApiKeyFn, + logger, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: type, + savedObjectTypesToQuery: savedObjectTypes, + }); + expect(result).toEqual(1); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes( + 1 + savedObjectTypes.length + ); + + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { + type, + filter: expectedFilter, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + + if (encrypted) { + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + type, + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + type, + '2' + ); + } + + savedObjectTypes.forEach((t) => { + expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + type: t.type, + perPage: 0, + filter: `${t.apiKeyAttributePath}: "abcd====!" OR ${t.apiKeyAttributePath}: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: t.apiKeyAttributePath, + size: 100, + }, + }, + }, + }); + }); + + expect(invalidateApiKeyFn).toHaveBeenCalledTimes(1); + expect(invalidateApiKeyFn).toHaveBeenCalledWith({ + ids: ['xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(1); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith(1, type, '2'); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "1"`); + }); + + test(`${label} - should succeed when there are only API keys to exclude`, async () => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type, + score: 0, + attributes: { apiKeyId: findApiKeyId1, createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type, + score: 0, + attributes: { apiKeyId: findApiKeyId2, createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 100, + page: 1, + }); + + if (encrypted) { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + } + + savedObjectTypes.forEach((t) => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 1, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'abcd====!', doc_count: 1 }, + { key: 'xyz!==!', doc_count: 2 }, + ], + }, + }, + }); + }); + + const result = await runInvalidate({ + ...(encrypted ? { encryptedSavedObjectsClient } : {}), + removalDelay: '1h', + invalidateApiKeyFn, + logger, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: type, + savedObjectTypesToQuery: savedObjectTypes, + }); + expect(result).toEqual(0); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes( + 1 + savedObjectTypes.length + ); + + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { + type, + filter: expectedFilter, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + if (encrypted) { + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + type, + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + type, + '2' + ); + } + + let N = 2; + savedObjectTypes.forEach((t) => { + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(N, { + type: t.type, + perPage: 0, + filter: `${t.apiKeyAttributePath}: "abcd====!" OR ${t.apiKeyAttributePath}: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: t.apiKeyAttributePath, + size: 100, + }, + }, + }, + }); + N = N + 1; + }); + expect(invalidateApiKeyFn).not.toHaveBeenCalled(); + expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); + }); + + test(`${label} - should succeed when there are more than PAGE_SIZE API keys to invalidate`, async () => { + // first iteration + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type, + score: 0, + attributes: { apiKeyId: findApiKeyId1, createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 105, + per_page: 100, + page: 1, + }); + + if (encrypted) { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + } + + savedObjectTypes.forEach(() => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 1, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + }); + + invalidateApiKeyFn.mockResolvedValue({ + invalidated_api_keys: ['1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + + // second iteration + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: '2', + type, + score: 0, + attributes: { apiKeyId: findApiKeyId2, createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 5, + per_page: 100, + page: 1, + }); + + if (encrypted) { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + } + savedObjectTypes.forEach(() => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 1, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + }); + + invalidateApiKeyFn.mockResolvedValue({ + invalidated_api_keys: ['2'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + + const result = await runInvalidate({ + ...(encrypted ? { encryptedSavedObjectsClient } : {}), + removalDelay: '1h', + invalidateApiKeyFn, + logger, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: type, + savedObjectTypesToQuery: savedObjectTypes, + }); + expect(result).toEqual(2); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes( + 2 * (1 + savedObjectTypes.length) + ); + if (encrypted) { + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + } + expect(invalidateApiKeyFn).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); + expect(logger.debug).toHaveBeenCalledTimes(2); + + // first iteration + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { + type, + filter: expectedFilter, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + if (encrypted) { + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + type, + '1' + ); + } else { + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).not.toHaveBeenCalled(); + } + + let N = 2; + savedObjectTypes.forEach((t) => { + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(N, { + type: t.type, + perPage: 0, + filter: `${t.apiKeyAttributePath}: "abcd====!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: t.apiKeyAttributePath, + size: 100, + }, + }, + }, + }); + N = N + 1; + }); + + expect(invalidateApiKeyFn).toHaveBeenNthCalledWith(1, { + ids: ['abcd====!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith(1, type, '1'); + expect(logger.debug).toHaveBeenNthCalledWith(1, `Total invalidated API keys "1"`); + + // second iteration + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(N, { + type, + filter: expectedFilter, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + if (encrypted) { + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + type, + '2' + ); + } else { + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).not.toHaveBeenCalled(); + } + + N = N + 1; + savedObjectTypes.forEach((t) => { + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(N, { + type: t.type, + perPage: 0, + filter: `${t.apiKeyAttributePath}: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: t.apiKeyAttributePath, + size: 100, + }, + }, + }, + }); + N = N + 1; + }); + expect(invalidateApiKeyFn).toHaveBeenNthCalledWith(2, { + ids: ['xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith(2, type, '2'); + expect(logger.debug).toHaveBeenNthCalledWith(2, `Total invalidated API keys "1"`); + }); + + test(`${label} - should succeed when there are more than PAGE_SIZE API keys to invalidate and API keys to exclude`, async () => { + // first iteration + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type, + score: 0, + attributes: { apiKeyId: findApiKeyId1, createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type, + score: 0, + attributes: { apiKeyId: findApiKeyId2, createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 105, + per_page: 100, + page: 1, + }); + + if (encrypted) { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + } + + savedObjectTypes.forEach((t) => { + if (t.type === 'ad_hoc_run_params' || t.type === 'task') { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 1, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'abcd====!', doc_count: 1 }], + }, + }, + }); + } else { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 1, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] }, + }, + }); + } + }); + + invalidateApiKeyFn.mockResolvedValue({ + invalidated_api_keys: ['1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + // second iteration + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 0, + per_page: 100, + page: 1, + }); + + const result = await runInvalidate({ + ...(encrypted ? { encryptedSavedObjectsClient } : {}), + removalDelay: '1h', + invalidateApiKeyFn, + logger, + savedObjectsClient: internalSavedObjectsRepository, + savedObjectType: type, + savedObjectTypesToQuery: savedObjectTypes, + }); + expect(result).toEqual(1); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes( + 2 + savedObjectTypes.length + ); + if (encrypted) { + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + } else { + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).not.toHaveBeenCalled(); + } + expect(invalidateApiKeyFn).toHaveBeenCalledTimes(1); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledTimes(1); + + // first iteration + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { + type, + filter: expectedFilter, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + if (encrypted) { + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + type, + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + type, + '2' + ); + } else { + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).not.toHaveBeenCalled(); + } + + let N = 2; + savedObjectTypes.forEach((t) => { + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(N, { + type: t.type, + perPage: 0, + filter: `${t.apiKeyAttributePath}: "abcd====!" OR ${t.apiKeyAttributePath}: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: t.apiKeyAttributePath, + size: 100, + }, + }, + }, + }); + N = N + 1; + }); + expect(invalidateApiKeyFn).toHaveBeenNthCalledWith(1, { + ids: ['xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith(1, type, '2'); + expect(logger.debug).toHaveBeenNthCalledWith(1, `Total invalidated API keys "1"`); + + // second iteration + const filter = expectedFilter + ? `${expectedFilter} AND NOT ${type}.id: "${type}:1"` + : `NOT ${type}.id: "${type}:1"`; + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(N, { + type, + filter, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + }); + } + ); +}); diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.ts new file mode 100644 index 0000000000000..4168c05df2da4 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-shared'; +import type { ApiKeyToInvalidate } from '../../saved_objects/schemas/api_key_to_invalidate'; +import { getFindFilter } from './get_find_filter'; +import { getApiKeyIdsToInvalidate } from './get_api_key_ids_to_invalidate'; +import { PAGE_SIZE } from './constants'; +import { invalidateApiKeysAndDeletePendingApiKeySavedObject } from './invalidate_api_keys_and_delete_so'; +import type { ApiKeyInvalidationFn } from '../invalidate_api_keys_task'; + +export interface SavedObjectTypesToQuery { + type: string; + apiKeyAttributePath: string; +} + +interface RunInvalidateOpts { + encryptedSavedObjectsClient?: EncryptedSavedObjectsClient; + invalidateApiKeyFn?: ApiKeyInvalidationFn; + logger: Logger; + removalDelay: string; + savedObjectsClient: SavedObjectsClientContract; + savedObjectType: string; + savedObjectTypesToQuery: SavedObjectTypesToQuery[]; +} + +export async function runInvalidate(opts: RunInvalidateOpts) { + const { + encryptedSavedObjectsClient, + invalidateApiKeyFn, + logger, + removalDelay, + savedObjectsClient, + savedObjectType, + } = opts; + + let hasMoreApiKeysPendingInvalidation = true; + let totalInvalidated = 0; + const excludedSOIds = new Set(); + + do { + // Query for PAGE_SIZE api keys to invalidate at a time. At the end of each iteration, + // we should have deleted the deletable keys and added keys still in use to the excluded list + const filter = getFindFilter({ + removalDelay, + excludedSOIds: [...excludedSOIds], + savedObjectType, + }); + const apiKeysToInvalidate = await savedObjectsClient.find({ + type: savedObjectType, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: PAGE_SIZE, + ...(filter.length > 0 ? { filter } : {}), + }); + + if (apiKeysToInvalidate.total > 0) { + const { apiKeyIdsToExclude, apiKeyIdsToInvalidate } = await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: apiKeysToInvalidate, + encryptedSavedObjectsClient, + savedObjectsClient, + savedObjectType, + savedObjectTypesToQuery: opts.savedObjectTypesToQuery, + }); + apiKeyIdsToExclude.forEach(({ id }) => excludedSOIds.add(id)); + totalInvalidated += await invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate, + invalidateApiKeyFn, + logger, + savedObjectsClient, + savedObjectType, + }); + } + + hasMoreApiKeysPendingInvalidation = apiKeysToInvalidate.total > PAGE_SIZE; + } while (hasMoreApiKeysPendingInvalidation); + + return totalInvalidated; +} diff --git a/x-pack/platform/plugins/shared/task_manager/server/lib/bulk_mark_api_keys_for_invalidation.test.ts b/x-pack/platform/plugins/shared/task_manager/server/lib/bulk_mark_api_keys_for_invalidation.test.ts new file mode 100644 index 0000000000000..dbc978d1ba83a --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/lib/bulk_mark_api_keys_for_invalidation.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { INVALIDATE_API_KEY_SO_NAME } from '../saved_objects'; +import { bulkMarkApiKeysForInvalidation } from './bulk_mark_api_keys_for_invalidation'; + +const logger = loggingSystemMock.create().get(); + +describe('bulkMarkApiKeysForInvalidation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should call savedObjectsClient bulkCreate with the proper params', async () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); + + await bulkMarkApiKeysForInvalidation({ + apiKeyIds: ['123', '456'], + logger, + savedObjectsClient: unsecuredSavedObjectsClient, + }); + + const bulkCreateCallMock = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]; + const savedObjects = bulkCreateCallMock[0]; + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(bulkCreateCallMock).toHaveLength(1); + + expect(savedObjects).toHaveLength(2); + expect(savedObjects[0]).toHaveProperty('type', INVALIDATE_API_KEY_SO_NAME); + expect(savedObjects[0]).toHaveProperty('attributes.apiKeyId', '123'); + expect(savedObjects[0]).toHaveProperty('attributes.createdAt', expect.any(String)); + expect(savedObjects[1]).toHaveProperty('type', INVALIDATE_API_KEY_SO_NAME); + expect(savedObjects[1]).toHaveProperty('attributes.apiKeyId', '456'); + expect(savedObjects[1]).toHaveProperty('attributes.createdAt', expect.any(String)); + }); + + test('should log the proper error when savedObjectsClient create failed', async () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + unsecuredSavedObjectsClient.bulkCreate.mockRejectedValueOnce(new Error('Fail')); + + await bulkMarkApiKeysForInvalidation({ + apiKeyIds: ['123', '456'], + logger, + savedObjectsClient: unsecuredSavedObjectsClient, + }); + + expect(logger.error).toHaveBeenCalledWith( + 'Failed to bulk mark 2 API keys for invalidation: Fail' + ); + }); + + test('should not call savedObjectsClient bulkCreate if list of apiKeys empty', async () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); + + await bulkMarkApiKeysForInvalidation({ + apiKeyIds: [], + logger, + savedObjectsClient: unsecuredSavedObjectsClient, + }); + + expect(unsecuredSavedObjectsClient.bulkCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/task_manager/server/lib/bulk_mark_api_keys_for_invalidation.ts b/x-pack/platform/plugins/shared/task_manager/server/lib/bulk_mark_api_keys_for_invalidation.ts new file mode 100644 index 0000000000000..9140233c60b0f --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/lib/bulk_mark_api_keys_for_invalidation.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import { INVALIDATE_API_KEY_SO_NAME } from '../saved_objects'; + +export interface BulkMarkApiKeysForInvalidationOpts { + apiKeyIds: string[]; + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; +} +export const bulkMarkApiKeysForInvalidation = async (opts: BulkMarkApiKeysForInvalidationOpts) => { + const { apiKeyIds, logger, savedObjectsClient } = opts; + if (apiKeyIds.length === 0) { + return; + } + + try { + await savedObjectsClient.bulkCreate( + apiKeyIds.map((apiKeyId) => ({ + attributes: { + apiKeyId, + createdAt: new Date().toISOString(), + }, + type: INVALIDATE_API_KEY_SO_NAME, + })) + ); + } catch (e) { + logger.error(`Failed to bulk mark ${apiKeyIds.length} API keys for invalidation: ${e.message}`); + } +}; diff --git a/x-pack/platform/plugins/shared/task_manager/server/lib/calculate_health_status.test.ts b/x-pack/platform/plugins/shared/task_manager/server/lib/calculate_health_status.test.ts index 5a58881fba8fa..c5be3b986f8d6 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/lib/calculate_health_status.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/lib/calculate_health_status.test.ts @@ -24,6 +24,10 @@ const config = { enabled: true, index: 'foo', max_attempts: 9, + invalidate_api_key_task: { + interval: '5m', + removalDelay: '1h', + }, poll_interval: 3000, version_conflict_threshold: 80, request_capacity: 1000, diff --git a/x-pack/platform/plugins/shared/task_manager/server/lib/intervals.ts b/x-pack/platform/plugins/shared/task_manager/server/lib/intervals.ts index f876c60fe5435..49471e4074b07 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/lib/intervals.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/lib/intervals.ts @@ -127,3 +127,10 @@ export const parseIntervalAsMillisecond = memoize((interval: Interval): number = }); const isNumeric = (numAsStr: string) => /^\d+$/.test(numAsStr); + +export const timePeriodBeforeDate = (date: Date, timePeriod: string): Date => { + const result = new Date(date.valueOf()); + const milisecFromTime = parseIntervalAsMillisecond(timePeriod); + result.setMilliseconds(result.getMilliseconds() - milisecFromTime); + return result; +}; diff --git a/x-pack/platform/plugins/shared/task_manager/server/metrics/create_aggregator.test.ts b/x-pack/platform/plugins/shared/task_manager/server/metrics/create_aggregator.test.ts index 7e94ccd16c03c..32f810fc99146 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/metrics/create_aggregator.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/metrics/create_aggregator.test.ts @@ -43,6 +43,10 @@ const config: TaskManagerConfig = { interval: 10000, }, kibanas_per_partition: 2, + invalidate_api_key_task: { + interval: '5m', + removalDelay: '1h', + }, allow_reading_invalid_state: false, event_loop_delay: { monitor: true, diff --git a/x-pack/platform/plugins/shared/task_manager/server/mocks.ts b/x-pack/platform/plugins/shared/task_manager/server/mocks.ts index e25873ab04ad7..b39fda686249b 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/mocks.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/mocks.ts @@ -41,6 +41,7 @@ const createStartMock = () => { getRegisteredTypes: jest.fn(), bulkUpdateState: jest.fn(), registerEncryptedSavedObjectsClient: jest.fn(), + registerApiKeyInvalidateFn: jest.fn(), }); return mock; diff --git a/x-pack/platform/plugins/shared/task_manager/server/monitoring/configuration_statistics.test.ts b/x-pack/platform/plugins/shared/task_manager/server/monitoring/configuration_statistics.test.ts index bfde1252dfb88..98506c5bf7fd5 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/monitoring/configuration_statistics.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/monitoring/configuration_statistics.test.ts @@ -30,6 +30,10 @@ describe('Configuration Statistics Aggregator', () => { interval: 10000, }, kibanas_per_partition: 2, + invalidate_api_key_task: { + interval: '5m', + removalDelay: '1h', + }, max_attempts: 9, poll_interval: 6000000, allow_reading_invalid_state: false, diff --git a/x-pack/platform/plugins/shared/task_manager/server/plugin.test.ts b/x-pack/platform/plugins/shared/task_manager/server/plugin.test.ts index 4e9735a44c661..f9aa51342f198 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/plugin.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/plugin.test.ts @@ -33,6 +33,10 @@ const pluginInitializerContextParams = { max_attempts: 9, poll_interval: 3000, version_conflict_threshold: 80, + invalidate_api_key_task: { + interval: '5m', + removalDelay: '1h', + }, request_capacity: 1000, allow_reading_invalid_state: false, discovery: { diff --git a/x-pack/platform/plugins/shared/task_manager/server/plugin.ts b/x-pack/platform/plugins/shared/task_manager/server/plugin.ts index 8db80ab965f22..e87d03b800b14 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/plugin.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/plugin.ts @@ -25,6 +25,7 @@ import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/server'; import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-shared'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/server'; import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { InvalidateAPIKeysParams } from '@kbn/security-plugin-types-server'; import { registerDeleteInactiveNodesTaskDefinition, scheduleDeleteInactiveNodesTaskDefinition, @@ -35,7 +36,12 @@ import type { TaskManagerConfig } from './config'; import type { Middleware } from './lib/middleware'; import { createInitialMiddleware, addMiddlewareToChain } from './lib/middleware'; import { removeIfExists } from './lib/remove_if_exists'; -import { setupSavedObjects, BACKGROUND_TASK_NODE_SO_NAME, TASK_SO_NAME } from './saved_objects'; +import { + setupSavedObjects, + BACKGROUND_TASK_NODE_SO_NAME, + TASK_SO_NAME, + INVALIDATE_API_KEY_SO_NAME, +} from './saved_objects'; import type { TaskDefinitionRegistry } from './task_type_dictionary'; import { TaskTypeDictionary } from './task_type_dictionary'; import type { AggregationOpts, FetchResult, SearchOpts } from './task_store'; @@ -61,6 +67,11 @@ import { } from './removed_tasks/mark_removed_tasks_as_unrecognized'; import { getElasticsearchAndSOAvailability } from './lib/get_es_and_so_availability'; import { LicenseSubscriber } from './license_subscriber'; +import type { ApiKeyInvalidationFn } from './invalidate_api_keys/invalidate_api_keys_task'; +import { + registerInvalidateApiKeyTask, + scheduleInvalidateApiKeyTask, +} from './invalidate_api_keys/invalidate_api_keys_task'; export interface TaskManagerSetupContract { /** @@ -92,6 +103,7 @@ export type TaskManagerStartContract = Pick< } & { getRegisteredTypes: () => string[]; registerEncryptedSavedObjectsClient: (client: EncryptedSavedObjectsClient) => void; + registerApiKeyInvalidateFn: (fn?: ApiKeyInvalidationFn) => void; }; export interface TaskManagerPluginsStart { @@ -137,6 +149,7 @@ export class TaskManagerPlugin private numOfKibanaInstances$: Subject = new BehaviorSubject(1); private canEncryptSavedObjects: boolean; private licenseSubscriber?: PublicMethodsOf; + private invalidateApiKeyFn?: ApiKeyInvalidationFn; constructor(private readonly initContext: PluginInitializerContext) { this.initContext = initContext; @@ -155,6 +168,12 @@ export class TaskManagerPlugin return backgroundTasks && !migrator && !ui; } + private invalidateApiKey(params: InvalidateAPIKeysParams) { + if (this.invalidateApiKeyFn) { + return this.invalidateApiKeyFn(params); + } + } + public setup( core: CoreSetup, plugins: TaskManagerPluginsSetup @@ -177,7 +196,7 @@ export class TaskManagerPlugin this.heapSizeLimit = metrics.process.memory.heap.size_limit; }); - setupSavedObjects(core.savedObjects, this.config); + setupSavedObjects(core.savedObjects); this.taskManagerId = this.initContext.env.instanceUuid; @@ -254,6 +273,14 @@ export class TaskManagerPlugin } registerDeleteInactiveNodesTaskDefinition(this.logger, core.getStartServices, this.definitions); + registerInvalidateApiKeyTask({ + configInterval: this.config.invalidate_api_key_task.interval, + coreStartServices: core.getStartServices, + invalidateApiKeyFn: this.invalidateApiKey.bind(this), + logger: this.logger, + removalDelay: this.config.invalidate_api_key_task.removalDelay, + taskTypeDictionary: this.definitions, + }); registerMarkRemovedTasksAsUnrecognizedDefinition( this.logger, core.getStartServices, @@ -298,6 +325,7 @@ export class TaskManagerPlugin const savedObjectsRepository = savedObjects.createInternalRepository([ TASK_SO_NAME, BACKGROUND_TASK_NODE_SO_NAME, + INVALIDATE_API_KEY_SO_NAME, ]); this.kibanaDiscoveryService = new KibanaDiscoveryService({ @@ -415,6 +443,11 @@ export class TaskManagerPlugin }); scheduleDeleteInactiveNodesTaskDefinition(this.logger, taskScheduling).catch(() => {}); + scheduleInvalidateApiKeyTask( + this.logger, + taskScheduling, + this.config.invalidate_api_key_task.interval + ).catch(() => {}); scheduleMarkRemovedTasksAsUnrecognizedDefinition(this.logger, taskScheduling).catch(() => {}); return { @@ -438,6 +471,9 @@ export class TaskManagerPlugin registerEncryptedSavedObjectsClient: (client: EncryptedSavedObjectsClient) => { taskStore.registerEncryptedSavedObjectsClient(client); }, + registerApiKeyInvalidateFn: (fn?: ApiKeyInvalidationFn) => { + this.invalidateApiKeyFn = fn; + }, }; } diff --git a/x-pack/platform/plugins/shared/task_manager/server/polling_lifecycle.test.ts b/x-pack/platform/plugins/shared/task_manager/server/polling_lifecycle.test.ts index ef6b32a3960ef..00e937cb1c969 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/polling_lifecycle.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/polling_lifecycle.test.ts @@ -67,6 +67,10 @@ describe('TaskPollingLifecycle', () => { interval: 10000, }, kibanas_per_partition: 2, + invalidate_api_key_task: { + interval: '5m', + removalDelay: '1h', + }, enabled: true, index: 'foo', max_attempts: 9, diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/index.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/index.ts index 6cda854c4d112..48d59f2fbc4fc 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/index.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/index.ts @@ -7,12 +7,15 @@ import type { SavedObjectsServiceSetup } from '@kbn/core/server'; import type { estypes } from '@elastic/elasticsearch'; -import { backgroundTaskNodeMapping, taskMappings } from './mappings'; +import { backgroundTaskNodeMapping, taskMappings, apiKeyToInvalidateMappings } from './mappings'; import { getMigrations } from './migrations'; -import type { TaskManagerConfig } from '../config'; import { getOldestIdleActionTask } from '../queries/oldest_idle_action_task'; import { TASK_MANAGER_INDEX } from '../constants'; -import { backgroundTaskNodeModelVersions, taskModelVersions } from './model_versions'; +import { + backgroundTaskNodeModelVersions, + taskModelVersions, + apiKeyToInvalidateModelVersions, +} from './model_versions'; export { scheduleRruleSchemaV1, @@ -22,11 +25,9 @@ export { export const TASK_SO_NAME = 'task'; export const BACKGROUND_TASK_NODE_SO_NAME = 'background-task-node'; +export const INVALIDATE_API_KEY_SO_NAME = 'api_key_to_invalidate'; -export function setupSavedObjects( - savedObjects: SavedObjectsServiceSetup, - config: TaskManagerConfig -) { +export function setupSavedObjects(savedObjects: SavedObjectsServiceSetup) { savedObjects.registerType({ name: TASK_SO_NAME, namespaceType: 'agnostic', @@ -93,4 +94,13 @@ export function setupSavedObjects( indexPattern: TASK_MANAGER_INDEX, modelVersions: backgroundTaskNodeModelVersions, }); + + savedObjects.registerType({ + name: INVALIDATE_API_KEY_SO_NAME, + namespaceType: 'agnostic', + hidden: true, + mappings: apiKeyToInvalidateMappings, + indexPattern: TASK_MANAGER_INDEX, + modelVersions: apiKeyToInvalidateModelVersions, + }); } diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/mappings.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/mappings.ts index d8981fab4511b..ce0f3f7f01332 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/mappings.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/mappings.ts @@ -101,3 +101,15 @@ export const backgroundTaskNodeMapping: SavedObjectsTypeMappingDefinition = { }, }, }; + +export const apiKeyToInvalidateMappings: SavedObjectsTypeMappingDefinition = { + dynamic: false, + properties: { + apiKeyId: { + type: 'keyword', + }, + createdAt: { + type: 'date', + }, + }, +}; diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/api_key_to_invalidate_model_versions.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/api_key_to_invalidate_model_versions.ts new file mode 100644 index 0000000000000..40ace54a4b56a --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/api_key_to_invalidate_model_versions.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; +import { apiKeyToInvalidateSchemaV1 } from '../schemas/api_key_to_invalidate'; + +export const apiKeyToInvalidateModelVersions: SavedObjectsModelVersionMap = { + '1': { + changes: [], + schemas: { + forwardCompatibility: apiKeyToInvalidateSchemaV1.extends({}, { unknowns: 'ignore' }), + create: apiKeyToInvalidateSchemaV1, + }, + }, +}; diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/index.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/index.ts index 237c15a53349f..ecff467affca9 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/index.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/index.ts @@ -7,3 +7,4 @@ export { taskModelVersions } from './task_model_versions'; export { backgroundTaskNodeModelVersions } from './background_task_node_model_versions'; +export { apiKeyToInvalidateModelVersions } from './api_key_to_invalidate_model_versions'; diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/api_key_to_invalidate.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/api_key_to_invalidate.ts new file mode 100644 index 0000000000000..48fc5b367c87e --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/api_key_to_invalidate.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; + +export const apiKeyToInvalidateSchemaV1 = schema.object({ + apiKeyId: schema.string(), + createdAt: schema.string(), +}); + +export type ApiKeyToInvalidate = TypeOf; diff --git a/x-pack/platform/plugins/shared/task_manager/server/task_store.test.ts b/x-pack/platform/plugins/shared/task_manager/server/task_store.test.ts index a47c0c4323a8f..525fe032a2a1b 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task_store.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task_store.test.ts @@ -37,6 +37,7 @@ import type { EncryptedSavedObjectsClientOptions, } from '@kbn/encrypted-saved-objects-shared'; import { TaskValidator } from './task_validator'; +import { bulkMarkApiKeysForInvalidation } from './lib/bulk_mark_api_keys_for_invalidation'; let mockGetValidatedTaskInstanceFromReading: jest.SpyInstance; let mockGetValidatedTaskInstanceForUpdating: jest.SpyInstance; @@ -45,6 +46,12 @@ jest.mock('./lib/api_key_utils', () => ({ getApiKeyAndUserScope: jest.fn(), })); +jest.mock('./lib/bulk_mark_api_keys_for_invalidation', () => ({ + bulkMarkApiKeysForInvalidation: jest.fn(), +})); + +(bulkMarkApiKeysForInvalidation as jest.Mock).mockResolvedValue(void 0); + function createEncryptedSavedObjectsClientMock(opts?: EncryptedSavedObjectsClientOptions) { return { getDecryptedAsInternalUser: jest.fn(), @@ -1928,7 +1935,7 @@ describe('TaskStore', () => { describe('remove', () => { let store: TaskStore; const mockApiKey = Buffer.from('apiKeyId:apiKey').toString('base64'); - + const logger = mockLogger(); const mockTask = { id: 'task1', type: 'test', @@ -1958,7 +1965,7 @@ describe('TaskStore', () => { beforeEach(() => { store = new TaskStore({ - logger: mockLogger(), + logger, index: 'tasky', taskManagerId: '', serializer, @@ -2001,8 +2008,10 @@ describe('TaskStore', () => { const result = await store.remove(id); expect(result).toBeUndefined(); expect(savedObjectsClient.delete).toHaveBeenCalledWith('task', id, { refresh: false }); - expect(coreStart.security.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ - ids: ['apiKeyId'], + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith({ + apiKeyIds: ['apiKeyId'], + logger, + savedObjectsClient, }); }); @@ -2021,7 +2030,7 @@ describe('TaskStore', () => { let store: TaskStore; const mockApiKey1 = Buffer.from('apiKeyId1:apiKey').toString('base64'); const mockApiKey2 = Buffer.from('apiKeyId2:apiKey').toString('base64'); - + const logger = mockLogger(); const mockTask1 = { id: 'task1', type: 'test', @@ -2080,7 +2089,7 @@ describe('TaskStore', () => { beforeEach(() => { store = new TaskStore({ - logger: mockLogger(), + logger, index: 'tasky', taskManagerId: '', serializer, @@ -2124,14 +2133,16 @@ describe('TaskStore', () => { ); }); - test('bulk invalidates API key of tasks with API keys', async () => { + test('bulk marks API keys for invalidation for tasks with API keys', async () => { savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockTask1, mockTask2], }); const result = await store.bulkRemove(['task1', 'task2']); expect(result).toBeUndefined(); - expect(coreStart.security.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ - ids: ['apiKeyId1', 'apiKeyId2'], + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith({ + apiKeyIds: ['apiKeyId1', 'apiKeyId2'], + logger, + savedObjectsClient, }); }); diff --git a/x-pack/platform/plugins/shared/task_manager/server/task_store.ts b/x-pack/platform/plugins/shared/task_manager/server/task_store.ts index 82d997937f847..5b520535b7818 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task_store.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task_store.ts @@ -65,6 +65,7 @@ import { TASK_SO_NAME } from './saved_objects'; import { getApiKeyAndUserScope } from './lib/api_key_utils'; import { getFirstRunAt } from './lib/get_first_run_at'; import { isInterval } from './lib/intervals'; +import { bulkMarkApiKeysForInvalidation } from './lib/bulk_mark_api_keys_for_invalidation'; export interface StoreOpts { esClient: ElasticsearchClient; @@ -674,7 +675,11 @@ export class TaskStore { if (apiKey && userScope) { if (!userScope.apiKeyCreatedByUser) { - await this.security.authc.apiKeys.invalidateAsInternalUser({ ids: [userScope.apiKeyId] }); + await bulkMarkApiKeysForInvalidation({ + apiKeyIds: [userScope.apiKeyId], + logger: this.logger, + savedObjectsClient: this.savedObjectsRepository, + }); } } @@ -707,8 +712,10 @@ export class TaskStore { }); if (apiKeyIdsToRemove.length) { - await this.security.authc.apiKeys.invalidateAsInternalUser({ - ids: [...new Set(apiKeyIdsToRemove)], + await bulkMarkApiKeysForInvalidation({ + apiKeyIds: apiKeyIdsToRemove, + logger: this.logger, + savedObjectsClient: this.savedObjectsRepository, }); } diff --git a/x-pack/platform/plugins/shared/task_manager/tsconfig.json b/x-pack/platform/plugins/shared/task_manager/tsconfig.json index 7059d06423f28..df282ab5bc4ba 100644 --- a/x-pack/platform/plugins/shared/task_manager/tsconfig.json +++ b/x-pack/platform/plugins/shared/task_manager/tsconfig.json @@ -40,6 +40,7 @@ "@kbn/spaces-utils", "@kbn/licensing-types", "@kbn/lazy-object", + "@kbn/security-plugin-types-server", ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/test/plugin_api_integration/config.ts b/x-pack/platform/test/plugin_api_integration/config.ts index 085e7e5546853..e58a6e7fbd095 100644 --- a/x-pack/platform/test/plugin_api_integration/config.ts +++ b/x-pack/platform/test/plugin_api_integration/config.ts @@ -37,6 +37,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { '--xpack.eventLog.logEntries=true', '--xpack.eventLog.indexEntries=true', '--xpack.task_manager.monitored_aggregated_stats_refresh_rate=5000', + '--xpack.task_manager.invalidate_api_key_task.removalDelay="1s"', `--xpack.stack_connectors.enableExperimental=${JSON.stringify([ 'crowdstrikeConnectorOn', 'microsoftDefenderEndpointOn', diff --git a/x-pack/platform/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts b/x-pack/platform/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts index 6054120b7386a..7f42976827cad 100644 --- a/x-pack/platform/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts +++ b/x-pack/platform/test/plugin_api_integration/plugins/sample_task_plugin/server/init_routes.ts @@ -621,4 +621,30 @@ export function initRoutes( }); } ); + + router.post( + { + path: `/api/invalidate_api_key_task/run_soon`, + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization because it is used only for testing', + }, + }, + validate: {}, + }, + async function ( + _: RequestHandlerContext, + __: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + const taskId = 'invalidate_api_keys'; + try { + const taskManager = await taskManagerStart; + return res.ok({ body: await taskManager.runSoon(taskId) }); + } catch (err) { + return res.ok({ body: { id: taskId, error: `${err}` } }); + } + } + ); } diff --git a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/task_management.ts b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/task_management.ts index 918e4e0b54b96..4a7e327b6e5b3 100644 --- a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/task_management.ts +++ b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/task_management.ts @@ -652,13 +652,54 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .expect(200); + // api key should still exist expect( queryResult.body.apiKeys.filter((apiKey: { id: string }) => { return apiKey.id === result.userScope?.apiKeyId; }).length - ).eql(0); + ).eql(1); + + // api_key_to_invalidate saved object should be created + await retry.try(async () => { + const response = await es.search({ + index: '.kibana_task_manager', + size: 100, + query: { + term: { + type: 'api_key_to_invalidate', + }, + }, + }); + + expect(response.hits.hits.length).to.eql(1); + expect((response.hits?.hits?.[0]._source as any).api_key_to_invalidate?.apiKeyId).to.eql( + result.userScope?.apiKeyId + ); + }); - expect(queryResult.body.apiKeys.length).eql(apiKeysLength); + // run the api key invalidation task + await supertest + .post('/api/invalidate_api_key_task/run_soon') + .send({}) + .set('kbn-xsrf', 'xxx') + .expect(200); + + // api key should be invalidated + await retry.try(async () => { + queryResult = await supertest + .post('/internal/security/api_key/_query') + .send({}) + .set('kbn-xsrf', 'xxx') + .expect(200); + + expect( + queryResult.body.apiKeys.filter((apiKey: { id: string }) => { + return apiKey.id === result.userScope?.apiKeyId; + }).length + ).eql(0); + + expect(queryResult.body.apiKeys.length).eql(apiKeysLength); + }); }); it('should schedule tasks with fake request if request is provided', async () => { From e65e69c8acbceaf66c1e3f7a79ce735382e2ed7e Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 2 Dec 2025 20:32:40 +0000 Subject: [PATCH 2/3] Changes from node scripts/jest_integration -u src/core/server/integration_tests/ci_checks --- .../saved_objects/check_registered_types.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 57b57db2bb5e2..f54c5d8d92aa6 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -67,7 +67,7 @@ describe('checking migration metadata changes on all registered SO types', () => "alert": "25c6ceada17bfbe3027062c87e8eec6207d210f69638e53dd8110cba9735973b", "alerting_rule_template": "7c0ce40abc7416e49e3729b5189623be396c31b6c7ce2f915d9f4908405eca74", "api_key_pending_invalidation": "19ece0ac908352a86624e7c487f452077db878a9bf15286892c6da8e76bbb479", - "api_key_to_invalidate": "556a42e1ae119c25e4935058aad011cbc6ae2f9cfaee12f6936118ad07ac3edd", + "api_key_to_invalidate": "2e202e95f580920dd23c8e39659817f1d210f15d8a55af5b3ae9469a6e98a2d7", "apm-custom-dashboards": "30647691e2f67ccb9530d4c99d5723800872b4e5291a033de1116357603e4f27", "apm-indices": "9d3b6f5d29647738edb718b38e0eab712ec705f707b47572cf0bb37e011f3a8e", "apm-server-schema": "a58389bb3de41d987a2f0e53fd3360e90586656c981a9adaf862f4a06b7ab873", @@ -300,12 +300,12 @@ describe('checking migration metadata changes on all registered SO types', () => "api_key_pending_invalidation|mappings: 6690f4f2a071feda5eec8353cdb23c0f1624910a", "api_key_pending_invalidation|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", "api_key_pending_invalidation|10.1.0: 61265eecca1fe876ad48410cd295efdd61a593911dd6ab530b36bd8943c7c102", - "===============================================================================================", - "api_key_to_invalidate|global: d1a20b058bd48891c1c958a20a8e1bac37c25b87", + "=====================================================================================================", + "api_key_to_invalidate|global: 32dfc316c06e96b4edefee819aa3e0bf7365241d", "api_key_to_invalidate|mappings: fa87c3b4528dcec61462709d2575ac13ba86397e", "api_key_to_invalidate|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", "api_key_to_invalidate|10.1.0: 61265eecca1fe876ad48410cd295efdd61a593911dd6ab530b36bd8943c7c102", - "=====================================================================================================", + "==============================================================================================", "apm-custom-dashboards|global: f72017061cda1a43792af034d019b3a766eacd7a", "apm-custom-dashboards|mappings: 72a467c41818fc3a8f88c40a885e835485752e73", "apm-custom-dashboards|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", From 1b438c8a66f08990d221e120d40a9263e483fef6 Mon Sep 17 00:00:00 2001 From: Ying Date: Tue, 2 Dec 2025 16:58:24 -0500 Subject: [PATCH 3/3] Adding to registered task types --- .../test_suites/task_manager/check_registered_task_types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index f001fbf754f45..3cf6829d5f083 100644 --- a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -215,6 +215,7 @@ export default function ({ getService }: FtrProviderContext) { 'slo:bulk-delete-task', 'slo:temp-summary-cleanup-task', 'task_manager:delete_inactive_background_task_nodes', + 'task_manager:invalidate_api_keys', 'task_manager:mark_removed_tasks_as_unrecognized', 'unusedUrlsCleanupTask', 'workflow:resume',