diff --git a/x-pack/platform/plugins/shared/task_manager/server/task.ts b/x-pack/platform/plugins/shared/task_manager/server/task.ts index 9bb3dbb0d1fcc..9c70165eeedf2 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task.ts @@ -571,6 +571,7 @@ export type PartialSerializedConcreteTaskInstance = Partial & ApiKeyOptions; 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 009b31644a4d4..f7eb857285917 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 @@ -1430,7 +1430,7 @@ describe('TaskStore', () => { bulkUpdate: jest.fn().mockResolvedValue({ saved_objects: [ { - id: '324242', + id: 'task:324242', type: 'task', attributes: { ...bulkUpdateTask, @@ -1489,6 +1489,354 @@ describe('TaskStore', () => { expect(savedObjectsClient.bulkUpdate).not.toHaveBeenCalled(); }); + test('bulk update task with regenerated API key when api key, user scope, request, and regenerate api key flag are available', async () => { + const mockScopedClient = { + bulkUpdate: jest.fn().mockResolvedValue({ + saved_objects: [ + { + id: 'task:324242', + type: 'task', + attributes: { + ...bulkUpdateTask, + state: '{"foo":"bar"}', + params: '{"hello":"world"}', + }, + references: [], + version: '123', + }, + ], + }), + }; + mockGetScopedClient.mockReturnValue(mockScopedClient); + + const mockUpdatedApiKey = Buffer.from('apiKeyIdUpdated:apiKey').toString('base64'); + + const mockUpdatedUserScope = { + apiKeyId: 'apiKeyIdUpdated', + apiKeyCreatedByUser: false, + spaceId: 'testSpace', + }; + + const apiKeyAndUserScopeMap = new Map(); + apiKeyAndUserScopeMap.set('task:324242', { + apiKey: mockUpdatedApiKey, + userScope: mockUpdatedUserScope, + }); + (getApiKeyAndUserScope as jest.Mock).mockResolvedValueOnce(apiKeyAndUserScopeMap); + + await store.bulkUpdate( + [{ ...bulkUpdateTask, apiKey: mockApiKey, userScope: mockUserScope }], + { + validate: false, + mergeAttributes: false, + options: { request: mockRequest, regenerateApiKey: true }, + } + ); + + expect(mockGetValidatedTaskInstanceForUpdating).toHaveBeenCalledWith( + { ...bulkUpdateTask, apiKey: mockApiKey, userScope: mockUserScope }, + { + validate: false, + } + ); + + expect(mockGetScopedClient).toHaveBeenCalledWith(mockRequest, { + includedHiddenTypes: ['task'], + excludedExtensions: ['security', 'spaces'], + }); + + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith({ + apiKeyIds: ['apiKeyId'], + logger, + savedObjectsClient, + }); + expect(getApiKeyAndUserScope).toHaveBeenCalledWith( + [{ ...bulkUpdateTask, apiKey: mockApiKey, userScope: mockUserScope }], + mockRequest, + coreStart.security, + basePathMock + ); + + expect(mockScopedClient.bulkUpdate).toHaveBeenCalledWith( + [ + { + id: bulkUpdateTask.id, + mergeAttributes: false, + type: 'task', + version: bulkUpdateTask.version, + attributes: { + ...taskInstanceToAttributes(bulkUpdateTask, bulkUpdateTask.id), + apiKey: mockUpdatedApiKey, + userScope: mockUpdatedUserScope, + }, + }, + ], + { refresh: false } + ); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(savedObjectsClient.bulkUpdate).not.toHaveBeenCalled(); + }); + + test('bulk update task with regenerated API key when api key but do not invalidate user created api keys', async () => { + const mockScopedClient = { + bulkUpdate: jest.fn().mockResolvedValue({ + saved_objects: [ + { + id: 'task:324242', + type: 'task', + attributes: { + ...bulkUpdateTask, + state: '{"foo":"bar"}', + params: '{"hello":"world"}', + }, + references: [], + version: '123', + }, + ], + }), + }; + mockGetScopedClient.mockReturnValue(mockScopedClient); + + const mockUpdatedApiKey = Buffer.from('apiKeyIdUpdated:apiKey').toString('base64'); + + const mockUpdatedUserScope = { + apiKeyId: 'apiKeyIdUpdated', + apiKeyCreatedByUser: true, + spaceId: 'testSpace', + }; + + const apiKeyAndUserScopeMap = new Map(); + apiKeyAndUserScopeMap.set('task:324242', { + apiKey: mockUpdatedApiKey, + userScope: mockUpdatedUserScope, + }); + (getApiKeyAndUserScope as jest.Mock).mockResolvedValueOnce(apiKeyAndUserScopeMap); + + await store.bulkUpdate( + [ + { + ...bulkUpdateTask, + apiKey: mockApiKey, + userScope: { ...mockUserScope, apiKeyCreatedByUser: true }, + }, + ], + { + validate: false, + mergeAttributes: false, + options: { request: mockRequest, regenerateApiKey: true }, + } + ); + + expect(mockGetValidatedTaskInstanceForUpdating).toHaveBeenCalledWith( + { + ...bulkUpdateTask, + apiKey: mockApiKey, + userScope: { ...mockUserScope, apiKeyCreatedByUser: true }, + }, + { + validate: false, + } + ); + + expect(mockGetScopedClient).toHaveBeenCalledWith(mockRequest, { + includedHiddenTypes: ['task'], + excludedExtensions: ['security', 'spaces'], + }); + + expect(bulkMarkApiKeysForInvalidation).not.toHaveBeenCalled(); + expect(getApiKeyAndUserScope).toHaveBeenCalledWith( + [ + { + ...bulkUpdateTask, + apiKey: mockApiKey, + userScope: { ...mockUserScope, apiKeyCreatedByUser: true }, + }, + ], + mockRequest, + coreStart.security, + basePathMock + ); + + expect(mockScopedClient.bulkUpdate).toHaveBeenCalledWith( + [ + { + id: bulkUpdateTask.id, + mergeAttributes: false, + type: 'task', + version: bulkUpdateTask.version, + attributes: { + ...taskInstanceToAttributes(bulkUpdateTask, bulkUpdateTask.id), + apiKey: mockUpdatedApiKey, + userScope: mockUpdatedUserScope, + }, + }, + ], + { refresh: false } + ); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(savedObjectsClient.bulkUpdate).not.toHaveBeenCalled(); + }); + + test('bulk update task with regenerated API key when api key but do not invalidate api key if the update fails', async () => { + const mockScopedClient = { + bulkUpdate: jest.fn().mockResolvedValue({ + saved_objects: [ + { + id: 'task:324242', + type: 'task', + attributes: { + ...bulkUpdateTask, + state: '{"foo":"bar"}', + params: '{"hello":"world"}', + }, + references: [], + version: '123', + error: { + type: 'document_missing_exception', + reason: '[5]: document missing', + index_uuid: 'aAsFqTI0Tc2W0LCWgPNrOA', + shard: '0', + index: '.kibana_task_manager_8.16.0_001', + }, + }, + ], + }), + }; + mockGetScopedClient.mockReturnValue(mockScopedClient); + + const mockUpdatedApiKey = Buffer.from('apiKeyIdUpdated:apiKey').toString('base64'); + + const mockUpdatedUserScope = { + apiKeyId: 'apiKeyIdUpdated', + apiKeyCreatedByUser: false, + spaceId: 'testSpace', + }; + + const apiKeyAndUserScopeMap = new Map(); + apiKeyAndUserScopeMap.set('task:324242', { + apiKey: mockUpdatedApiKey, + userScope: mockUpdatedUserScope, + }); + (getApiKeyAndUserScope as jest.Mock).mockResolvedValueOnce(apiKeyAndUserScopeMap); + + await store.bulkUpdate( + [ + { + ...bulkUpdateTask, + apiKey: mockApiKey, + userScope: mockUserScope, + }, + ], + { + validate: false, + mergeAttributes: false, + options: { request: mockRequest, regenerateApiKey: true }, + } + ); + + expect(mockGetValidatedTaskInstanceForUpdating).toHaveBeenCalledWith( + { + ...bulkUpdateTask, + apiKey: mockApiKey, + userScope: mockUserScope, + }, + { + validate: false, + } + ); + + expect(mockGetScopedClient).toHaveBeenCalledWith(mockRequest, { + includedHiddenTypes: ['task'], + excludedExtensions: ['security', 'spaces'], + }); + + expect(bulkMarkApiKeysForInvalidation).not.toHaveBeenCalled(); + expect(getApiKeyAndUserScope).toHaveBeenCalledWith( + [ + { + ...bulkUpdateTask, + apiKey: mockApiKey, + userScope: mockUserScope, + }, + ], + mockRequest, + coreStart.security, + basePathMock + ); + + expect(mockScopedClient.bulkUpdate).toHaveBeenCalledWith( + [ + { + id: bulkUpdateTask.id, + mergeAttributes: false, + type: 'task', + version: bulkUpdateTask.version, + attributes: { + ...taskInstanceToAttributes(bulkUpdateTask, bulkUpdateTask.id), + apiKey: mockUpdatedApiKey, + userScope: mockUpdatedUserScope, + }, + }, + ], + { refresh: false } + ); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(savedObjectsClient.bulkUpdate).not.toHaveBeenCalled(); + }); + + test('bulk update task with no API key changes when api key, user scope are not available and request and regenerate api key flag are available', async () => { + savedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + id: 'task:324242', + type: 'task', + attributes: { + ...bulkUpdateTask, + state: '{"foo":"bar"}', + params: '{"hello":"world"}', + }, + references: [], + version: '123', + }, + ], + }); + + await store.bulkUpdate([bulkUpdateTask], { + validate: false, + mergeAttributes: false, + options: { request: mockRequest, regenerateApiKey: true }, + }); + + expect(mockGetValidatedTaskInstanceForUpdating).toHaveBeenCalledWith(bulkUpdateTask, { + validate: false, + }); + + expect(mockGetScopedClient).not.toHaveBeenCalled(); + + expect(bulkMarkApiKeysForInvalidation).not.toHaveBeenCalled(); + expect(getApiKeyAndUserScope).not.toHaveBeenCalled(); + + expect(savedObjectsClient.bulkUpdate).toHaveBeenCalledWith( + [ + { + id: bulkUpdateTask.id, + mergeAttributes: false, + type: 'task', + version: bulkUpdateTask.version, + attributes: taskInstanceToAttributes(bulkUpdateTask, bulkUpdateTask.id), + }, + ], + { refresh: false } + ); + + expect(logger.debug).toHaveBeenCalledWith( + 'Request is defined but none of the tasks have API key or user scope. Using regular saved objects repository to bulk update tasks.' + ); + }); + test('uses regular repository when request is provided but docs have no apiKey', async () => { savedObjectsClient.bulkUpdate.mockResolvedValue({ saved_objects: [ @@ -1585,7 +1933,7 @@ describe('TaskStore', () => { ); }); - test('uses regular repository when no request is provided even if docs have apiKey and userScope', async () => { + test('throws an error when no request is provided but docs have apiKey and userScope', async () => { savedObjectsClient.bulkUpdate.mockResolvedValue({ saved_objects: [ { @@ -1604,34 +1952,14 @@ describe('TaskStore', () => { ], }); - await store.bulkUpdate( - [{ ...bulkUpdateTask, apiKey: mockApiKey, userScope: mockUserScope }], - { + await expect( + store.bulkUpdate([{ ...bulkUpdateTask, apiKey: mockApiKey, userScope: mockUserScope }], { validate: false, mergeAttributes: false, options: {}, - } - ); - - expect(logger.debug).not.toHaveBeenCalled(); - - expect(mockGetScopedClient).not.toHaveBeenCalled(); - - expect(savedObjectsClient.bulkUpdate).toHaveBeenCalledWith( - [ - { - id: bulkUpdateTask.id, - mergeAttributes: false, - type: 'task', - version: bulkUpdateTask.version, - attributes: { - ...taskInstanceToAttributes(bulkUpdateTask, bulkUpdateTask.id), - apiKey: mockApiKey, - userScope: mockUserScope, - }, - }, - ], - { refresh: false } + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Request is not defined but some of the tasks have API key or user scope. Cannot get the encrypted saved objects repository to bulk update tasks."` ); }); 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 cf5c76403f2fd..7cbe33b8f0801 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 @@ -63,6 +63,7 @@ import type { ErrorOutput } from './lib/bulk_operation_buffer'; import { BulkUpdateError, MsearchError } from './lib/errors'; import { TASK_SO_NAME } from './saved_objects'; import { getApiKeyAndUserScope } from './lib/api_key_utils'; +import type { ApiKeyAndUserScope } 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'; @@ -219,8 +220,44 @@ export class TaskStore { return this.savedObjectsRepository; } + private async regenerateApiKeyFromRequest(docs: ConcreteTaskInstance[], options?: ApiKeyOptions) { + const hasEncryptedFields = docs.some((doc) => doc.apiKey && doc.userScope); + const apiKeyIdsToRemoveMap = new Map(); + let apiKeyAndUserScopeMap: Map | null = null; + + // If a task with an API key is updated with a request + if (hasEncryptedFields && options?.request && options?.regenerateApiKey) { + const docsWithApiKeys: ConcreteTaskInstance[] = []; + + docs.forEach((taskInstance) => { + const { apiKey, userScope } = taskInstance; + if (apiKey && userScope) { + docsWithApiKeys.push(taskInstance); + if (!userScope.apiKeyCreatedByUser) { + apiKeyIdsToRemoveMap.set(taskInstance.id, userScope.apiKeyId); + } + } + }); + + // and create new API keys using the new request + if (docsWithApiKeys.length) { + apiKeyAndUserScopeMap = await this.getApiKeyFromRequest(docsWithApiKeys, options.request); + } + } + + return { apiKeyAndUserScopeMap, apiKeyIdsToRemoveMap }; + } + private getSoClientForUpdate(docs: ConcreteTaskInstance[], options?: ApiKeyOptions) { const hasEncryptedFields = docs.some((doc) => doc.apiKey && doc.userScope); + + // If a task with an API key is updated without a request, throw an error. + if (hasEncryptedFields && !options?.request) { + throw new Error( + 'Request is not defined but some of the tasks have API key or user scope. Cannot get the encrypted saved objects repository to bulk update tasks.' + ); + } + if (options?.request && !hasEncryptedFields) { this.logger.debug( 'Request is defined but none of the tasks have API key or user scope. Using regular saved objects repository to bulk update tasks.' @@ -542,6 +579,9 @@ export class TaskStore { { validate, mergeAttributes = true, options }: BulkUpdateOpts ): Promise { const soClientToUpdate = this.getSoClientForUpdate(docs, options); + const regenerateResult = await this.regenerateApiKeyFromRequest(docs, options); + const apiKeyAndUserScopeMap = regenerateResult.apiKeyAndUserScopeMap || new Map(); + const apiKeyIdsToRemoveMap = regenerateResult.apiKeyIdsToRemoveMap; const newDocs = docs.reduce( (acc: Map>, doc) => { @@ -549,14 +589,19 @@ export class TaskStore { const taskInstance = this.taskValidator.getValidatedTaskInstanceForUpdating(doc, { validate, }); + const { apiKey: updatedApiKey, userScope: updatedUserScope } = + apiKeyAndUserScopeMap.get(taskInstance.id) || {}; + const apiKey = updatedApiKey || doc?.apiKey; + const userScope = updatedUserScope || doc?.userScope; + acc.set(doc.id, { type: 'task', id: doc.id, version: doc.version, attributes: { ...taskInstanceToAttributes(taskInstance, doc.id), - ...(doc?.apiKey ? { apiKey: doc.apiKey } : {}), - ...(doc?.userScope ? { userScope: doc.userScope } : {}), + ...(apiKey ? { apiKey } : {}), + ...(userScope ? { userScope } : {}), }, mergeAttributes, }); @@ -584,7 +629,8 @@ export class TaskStore { throw e; } - return updatedSavedObjects.map((updatedSavedObject) => { + const apiKeyIdsToRemove: string[] = []; + const updates = updatedSavedObjects.map((updatedSavedObject) => { if (updatedSavedObject.error !== undefined) { return asErr({ type: 'task', @@ -603,8 +649,23 @@ export class TaskStore { const result = this.taskValidator.getValidatedTaskInstanceFromReading(taskInstance, { validate, }); + const oldApiKey = apiKeyIdsToRemoveMap.get(updatedSavedObject.id); + if (oldApiKey) { + apiKeyIdsToRemove.push(oldApiKey); + } return asOk(result); }); + + // after successful updates we should invalidate the old API keys + if (apiKeyIdsToRemove.length) { + await bulkMarkApiKeysForInvalidation({ + apiKeyIds: apiKeyIdsToRemove, + logger: this.logger, + savedObjectsClient: this.savedObjectsRepository, + }); + } + + return updates; } public async bulkPartialUpdate( 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 e6d0e6b7f2d90..373b211e19391 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 @@ -215,6 +215,7 @@ export function initRoutes( }), }), ]), + regenerateApiKey: schema.maybe(schema.boolean({ defaultValue: false })), }), }, }, @@ -224,10 +225,11 @@ export function initRoutes( res: KibanaResponseFactory ): Promise> { const taskManager = await taskManagerStart; - const { taskIds, schedule } = req.body; + const { taskIds, schedule, regenerateApiKey } = req.body; const taskResult = await taskManager.bulkUpdateSchedules(taskIds, schedule, { request: req, + regenerateApiKey, }); return res.ok({ body: taskResult }); 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 75d88ef6a9d6c..2e2d9cc610012 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 @@ -50,8 +50,7 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const testHistoryIndex = '.kibana_task_manager_test_result'; - // Failing: See https://github.com/elastic/kibana/issues/247560 - describe.skip('scheduling and running tasks', () => { + describe('scheduling and running tasks', () => { beforeEach(async () => { // clean up before each test await supertest.delete('/api/sample_tasks').set('kbn-xsrf', 'xxx').expect(200); @@ -244,12 +243,13 @@ export default function ({ getService }: FtrProviderContext) { interval: number; tzid: string; }; - } + }, + regenerateApiKey: boolean = false ) { return supertest .post('/api/sample_tasks/bulk_update_schedules_with_api_key') .set('kbn-xsrf', 'xxx') - .send({ taskIds, schedule }) + .send({ taskIds, schedule, regenerateApiKey }) .expect(200) .then((response: { body: BulkUpdateTaskResult }) => response.body); } @@ -370,6 +370,7 @@ export default function ({ getService }: FtrProviderContext) { const now = new Date(); const todayDay = now.getUTCDate(); const todayMonth = now.getUTCMonth(); + const todayYear = now.getUTCFullYear(); // set a start date for 2 days from now const startDate = moment(now).add(2, 'days').toDate(); const dailyTask = await scheduleTask({ @@ -395,9 +396,10 @@ export default function ({ getService }: FtrProviderContext) { const runAtDay = runAt.getUTCDate(); const runAtMonth = runAt.getUTCMonth(); + const runAtYear = runAt.getUTCFullYear(); if (todayMonth === runAtMonth) { expect(runAtDay >= todayDay + 2).to.be(true); - } else if (todayMonth < runAtMonth) { + } else if (todayMonth < runAtMonth || todayYear < runAtYear) { log.info(`todayMonth: ${todayMonth}, runAtMonth: ${runAtMonth}`); } else { throw new Error( @@ -1421,6 +1423,103 @@ export default function ({ getService }: FtrProviderContext) { }); }); + it('should bulk update schedules tasks with regenerated API keys if request and regenerate api key flag are provided', async () => { + let queryResult = await supertest + .post('/internal/security/api_key/_query') + .send({}) + .set('kbn-xsrf', 'xxx') + .expect(200); + + const apiKeysLength = queryResult.body.apiKeys.length; + + const scheduledTask = await scheduleTaskWithApiKey({ + id: 'test-task-for-sample-task-plugin-to-test-task-api-key', + taskType: 'sampleTask', + params: {}, + schedule: { interval: '1d' }, + }); + + // wait for the task to run once + const result = await retry.try(async () => { + const res = await currentTask<{ count: number }>( + 'test-task-for-sample-task-plugin-to-test-task-api-key' + ); + expect(res.apiKey).not.empty(); + expect(res.schedule).to.eql({ interval: '1d' }); + expect(res.state.count).to.be(1); + return res; + }); + + // test that a new api key was created and matches the api key id for this task + 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(1); + expect(queryResult.body.apiKeys.length).eql(apiKeysLength + 1); + + // update the schedule for this task with a request + const updates = await bulkUpdateSchedulesWithApiKey( + [scheduledTask.id], + { interval: '5s' }, + true // regenerateApiKey + ); + expect(updates.tasks.length).to.be(1); + expect(updates.errors.length).to.be(0); + + // verify the task runs successfully with the new schedule + let updatedApiKey: string | undefined; + await retry.try(async () => { + const task = await currentTask<{ count: number }>( + 'test-task-for-sample-task-plugin-to-test-task-api-key' + ); + + expect(task.state.count).to.be(2); + expect(task.schedule).to.eql({ interval: '5s' }); + updatedApiKey = task.userScope?.apiKeyId; + }); + + // api_key_to_invalidate saved object should be created for the old api key + 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?.filter((hit: any) => { + return hit._source.api_key_to_invalidate?.apiKeyId === result.userScope?.apiKeyId; + }).length + ).eql(1); + }); + + // test that a new api key was created on update and matches the api key id for this task + const updatedQueryResult = await supertest + .post('/internal/security/api_key/_query') + .send({}) + .set('kbn-xsrf', 'xxx') + .expect(200); + + // test that the api key for the task is updated + expect( + updatedQueryResult.body.apiKeys.filter((apiKey: { id: string }) => { + return apiKey.id === updatedApiKey; + }).length + ).eql(1); + expect(updatedQueryResult.body.apiKeys.length).eql(apiKeysLength + 2); + }); + it('should bulk update schedules tasks with fake request if request is provided', async () => { const tasks = await Promise.all([ scheduleTaskWithFakeRequest({