diff --git a/packages/kbn-check-saved-objects-cli/current_fields.json b/packages/kbn-check-saved-objects-cli/current_fields.json index cf2dcc93f2b48..dba26920792bc 100644 --- a/packages/kbn-check-saved-objects-cli/current_fields.json +++ b/packages/kbn-check-saved-objects-cli/current_fields.json @@ -1364,7 +1364,8 @@ "status", "taskType", "userScope", - "userScope.apiKeyId" + "userScope.apiKeyId", + "userScope.uiamApiKeyId" ], "telemetry": [], "threshold-explorer-view": [], diff --git a/packages/kbn-check-saved-objects-cli/current_mappings.json b/packages/kbn-check-saved-objects-cli/current_mappings.json index da32549b0817f..dc4349f0594fd 100644 --- a/packages/kbn-check-saved-objects-cli/current_mappings.json +++ b/packages/kbn-check-saved-objects-cli/current_mappings.json @@ -4545,6 +4545,9 @@ "properties": { "apiKeyId": { "type": "keyword" + }, + "uiamApiKeyId": { + "type": "keyword" } } } diff --git a/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/task/10.9.0.json b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/task/10.9.0.json new file mode 100644 index 0000000000000..f0096a816c279 --- /dev/null +++ b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/task/10.9.0.json @@ -0,0 +1,59 @@ +{ + "10.8.0": [ + { + "taskType": "string", + "scheduledAt": "string", + "startedAt": "string?|null", + "retryAt": "string?|null", + "runAt": "string", + "params": "string", + "state": "string", + "stateVersion": "number?", + "traceparent": "string", + "user": "string?", + "scope": "array?", + "ownerId": "string?|null", + "enabled": "boolean?", + "timeoutOverride": "string?", + "attempts": "number", + "status": "idle|claiming|running|failed|unrecognized|dead_letter", + "version": "string?", + "partition": "number?", + "priority": "number?", + "interval": "string", + "apiKey": "string?", + "userScope": "object?", + "schedule": "object?", + "cost": "tiny?|normal?|extralarge?" + } + ], + "10.9.0": [ + { + "taskType": "string", + "scheduledAt": "string", + "startedAt": "string?|null", + "retryAt": "string?|null", + "runAt": "string", + "params": "string", + "state": "string", + "stateVersion": "number?", + "traceparent": "string", + "user": "string?", + "scope": "array?", + "ownerId": "string?|null", + "enabled": "boolean?", + "timeoutOverride": "string?", + "attempts": "number", + "status": "idle|claiming|running|failed|unrecognized|dead_letter", + "version": "string?", + "partition": "number?", + "priority": "number?", + "interval": "string", + "apiKey": "string?", + "uiamApiKey": "string?", + "userScope": "object?", + "schedule": "object?", + "cost": "tiny?|normal?|extralarge?" + } + ] +} 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 b753578d29680..61fa4ed7da036 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 @@ -194,7 +194,7 @@ describe('checking migration metadata changes on all registered SO types', () => "synthetics-private-location": "f5efabeefafbb12ed0809db3cd04f893ff9099ead8f526be82a9b0348e444f65", "synthetics-privates-locations": "42aebb3aa4f3710a3e270d54bf33718a4d1d7a983556a51f75bd96b1e4fdf048", "tag": "03a522e92aed789a4bbf1a5dd19159c3ec061cb052337df9270728def4b3bbe0", - "task": "52bb9355724d6546a8e485c161c7f039493acb79e913b31ce1d0b9839fe38117", + "task": "fe51599351dcfeee24deba348992b20f7ebe9bfcd89c7c301fd76c09dad275fe", "telemetry": "fb5e3ce0b2955f10aa8cd75fdafdd0559bf5d77eaf6e2c228079684f01f28fbd", "threshold-explorer-view": "9b0a770f5444531f92dd50832dcf655cb0c9cd7f18af205338e0c9d73c6df6a6", "trial-companion-nba-milestone": "83f29f99e2ffaf00ed8e05f3366ed0df1fb36a77193aeb151e13bae8b1d9692f", @@ -1272,8 +1272,9 @@ describe('checking migration metadata changes on all registered SO types', () => "tag|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", "=====================================================", "task|global: 8277e4031824bb161fa73897294701786c15eb9a", - "task|mappings: 02ff4224787d1516899101bacf1c411fa0149383", + "task|mappings: a4616c952ab46648bba6f807a339d76b1388078e", "task|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", + "task|10.9.0: f3b8db51d31abd44fa578686d0b2227de056323ae7cff62a48c335b6d9846507", "task|10.8.0: deed2eb105aa3f19fa1827868c6b5569523624614fb73a8fcb8600d86c0dface", "task|10.7.0: 6afacb50669e4a3ebd48d5790d1677c138885b1540acf5e832dbe8dc82e7cd5c", "task|10.6.0: a554a701424daf84a260b61390464deb9296c7372ac3438301c2fb046ded11f9", @@ -1530,7 +1531,7 @@ describe('checking migration metadata changes on all registered SO types', () => "synthetics-private-location": "10.0.0", "synthetics-privates-locations": "10.1.0", "tag": "10.0.0", - "task": "10.8.0", + "task": "10.9.0", "telemetry": "10.0.0", "threshold-explorer-view": "10.0.0", "trial-companion-nba-milestone": "10.1.0", @@ -1694,7 +1695,7 @@ describe('checking migration metadata changes on all registered SO types', () => "synthetics-private-location": "0.0.0", "synthetics-privates-locations": "10.1.0", "tag": "0.0.0", - "task": "10.8.0", + "task": "10.9.0", "telemetry": "0.0.0", "threshold-explorer-view": "0.0.0", "trial-companion-nba-milestone": "10.1.0", 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 4a4462add1c2c..49f483f64a589 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 @@ -31,7 +31,7 @@ export class TaskManagerDependenciesPlugin { public setup(_: CoreSetup, plugin: TaskManagerDependenciesPluginSetup) { plugin.encryptedSavedObjects.registerType({ type: 'task', - attributesToEncrypt: new Set(['apiKey']), + attributesToEncrypt: new Set(['apiKey', 'uiamApiKey']), attributesToIncludeInAAD: new Set(['id', 'taskType']), enforceRandomId: false, }); @@ -39,7 +39,7 @@ export class TaskManagerDependenciesPlugin { plugin.taskManager.registerCanEncryptedSavedObjects(plugin.encryptedSavedObjects.canEncrypt); } - public start(_: CoreStart, plugin: TaskManagerDependenciesPluginStart) { + public start(core: CoreStart, plugin: TaskManagerDependenciesPluginStart) { plugin.taskManager.registerEncryptedSavedObjectsClient( plugin.encryptedSavedObjects.getClient({ includedHiddenTypes: ['task'], @@ -48,5 +48,6 @@ export class TaskManagerDependenciesPlugin { plugin.taskManager.registerApiKeyInvalidateFn( plugin.security?.authc.apiKeys.invalidateAsInternalUser ); + plugin.taskManager.registerUiamApiKeyInvalidateFn(core.security.authc.apiKeys.uiam?.invalidate); } } diff --git a/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts b/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts index e7f77de3cb979..3c5614ea5f6a5 100644 --- a/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts +++ b/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts @@ -83,7 +83,7 @@ describe('checking changes on all registered encrypted SO types', () => { "synthetics-monitor": "f1c060b7be3b30187c4adcb35d74f1fa8a4290bd7faf04fec869de2aa387e21b", "synthetics-monitor-multi-space": "39c4c6abd28c4173f77c1c89306e92b6b92492c0029274e10620a170be4d4a67", "synthetics-param": "747ba9d1b7addf5b131713abe7868bd767af6ce0cf8b6b0f335f4ef34b280c7e", - "task": "2d8e9bf532f469805b82051f545b915785d99eabfa050cb1aefbc715c6096b97", + "task": "d6cc30871dc78caf3f451de1275a3803879ec9935b4a2e34076dee56878c228f", "uptime-synthetics-api-key": "5ca81f180763e85397fa8c6508adcd60efd0f916e29bac6dcd5b4564f1db7375", "user_connector_token": "b443b022b46b79c0ff9fa674aecc64176a5fcbd09c2db2d9f050a6a88435732e", } @@ -150,6 +150,7 @@ describe('checking changes on all registered encrypted SO types', () => { "oauth_state|1", "synthetics-monitor|2", "synthetics-monitor|1", + "task|9", "task|8", "task|7", "task|6", diff --git a/x-pack/platform/plugins/shared/task_manager/moon.yml b/x-pack/platform/plugins/shared/task_manager/moon.yml index 1e2ef5e651ff2..0da05f13d9495 100644 --- a/x-pack/platform/plugins/shared/task_manager/moon.yml +++ b/x-pack/platform/plugins/shared/task_manager/moon.yml @@ -49,6 +49,7 @@ dependsOn: - '@kbn/licensing-types' - '@kbn/lazy-object' - '@kbn/security-plugin-types-server' + - '@kbn/core-security-server' - '@kbn/connector-specs' - '@kbn/es-errors' - '@kbn/core-execution-context-server-mocks' 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 index 24f31e090d019..35268d3ff7ea1 100644 --- 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 @@ -30,7 +30,7 @@ export type ApiKeyInvalidationFn = ( export type UiamApiKeyInvalidationFn = ( request: KibanaRequest, params: InvalidateUiamAPIKeyParams -) => Promise; +) => Promise | undefined; export async function scheduleInvalidateApiKeyTask( logger: Logger, @@ -54,6 +54,7 @@ interface RegisterInvalidateApiKeyTaskOpts { configInterval: string; coreStartServices: () => Promise<[CoreStart, TaskManagerPluginsStart, TaskManagerStartContract]>; invalidateApiKeyFn?: ApiKeyInvalidationFn; + invalidateUiamApiKeyFn?: UiamApiKeyInvalidationFn; logger: Logger; removalDelay: string; taskTypeDictionary: TaskTypeDictionary; @@ -65,6 +66,7 @@ export function registerInvalidateApiKeyTask(opts: RegisterInvalidateApiKeyTaskO configInterval, coreStartServices, invalidateApiKeyFn, + invalidateUiamApiKeyFn, removalDelay, taskTypeDictionary, } = opts; @@ -76,6 +78,7 @@ export function registerInvalidateApiKeyTask(opts: RegisterInvalidateApiKeyTaskO configInterval, coreStartServices, invalidateApiKeyFn, + invalidateUiamApiKeyFn, removalDelay, }), }, @@ -84,11 +87,23 @@ export function registerInvalidateApiKeyTask(opts: RegisterInvalidateApiKeyTaskO type InvalidateApiKeysTaskRunnerOpts = Pick< RegisterInvalidateApiKeyTaskOpts, - 'logger' | 'configInterval' | 'coreStartServices' | 'invalidateApiKeyFn' | 'removalDelay' + | 'logger' + | 'configInterval' + | 'coreStartServices' + | 'invalidateApiKeyFn' + | 'invalidateUiamApiKeyFn' + | 'removalDelay' >; export function taskRunner(opts: InvalidateApiKeysTaskRunnerOpts) { - const { logger, configInterval, coreStartServices, invalidateApiKeyFn, removalDelay } = opts; + const { + logger, + configInterval, + coreStartServices, + invalidateApiKeyFn, + invalidateUiamApiKeyFn, + removalDelay, + } = opts; return () => { return { async run() { @@ -100,6 +115,7 @@ export function taskRunner(opts: InvalidateApiKeysTaskRunnerOpts) { const totalInvalidated = await runInvalidate({ invalidateApiKeyFn, + invalidateUiamApiKeyFn, logger, removalDelay, savedObjectsClient, @@ -109,6 +125,10 @@ export function taskRunner(opts: InvalidateApiKeysTaskRunnerOpts) { type: TASK_SO_NAME, apiKeyAttributePath: `${TASK_SO_NAME}.attributes.userScope.apiKeyId`, }, + { + type: TASK_SO_NAME, + apiKeyAttributePath: `${TASK_SO_NAME}.attributes.userScope.uiamApiKeyId`, + }, ], }); diff --git a/x-pack/platform/plugins/shared/task_manager/server/lib/api_key_utils.test.ts b/x-pack/platform/plugins/shared/task_manager/server/lib/api_key_utils.test.ts index 35f16e9dec818..14c975e883c45 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/lib/api_key_utils.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/lib/api_key_utils.test.ts @@ -5,12 +5,18 @@ * 2.0. */ +jest.mock('@kbn/core-security-server', () => ({ + isUiamCredential: jest.fn(() => false), +})); + +import { isUiamCredential } from '@kbn/core-security-server'; import { isRequestApiKeyType, getApiKeyFromRequest, createApiKey, getApiKeyAndUserScope, } from './api_key_utils'; +import type { ApiKeyAndUserScopeBoth, EncodedApiKeyResultBoth } from './api_key_utils'; import { coreMock } from '@kbn/core/server/mocks'; import { httpServerMock } from '@kbn/core-http-server-mocks'; import type { AuthenticatedUser, FakeRawRequest } from '@kbn/core/server'; @@ -76,9 +82,15 @@ describe('api_key_utils', () => { api_key: 'apiKey', }); - const result = await createApiKey([mockTask], request, coreStart.security); - const apiKeyResult = result.get('task'); - const decodedApiKey = Buffer.from(apiKeyResult!.apiKey, 'base64').toString(); + const result = await createApiKey([mockTask], request, coreStart.security, { + shouldGrantUiam: false, + }); + const apiKeyResult = result.get('task')!; + expect('apiKey' in apiKeyResult && apiKeyResult.apiKey).toBeDefined(); + const decodedApiKey = Buffer.from( + 'apiKey' in apiKeyResult ? apiKeyResult.apiKey : '', + 'base64' + ).toString(); expect(decodedApiKey).toEqual('apiKeyId:apiKey'); expect(coreStart.security.authc.apiKeys.areAPIKeysEnabled).toHaveBeenCalled(); @@ -107,9 +119,15 @@ describe('api_key_utils', () => { coreStart.security.authc.apiKeys.areAPIKeysEnabled = jest.fn().mockReturnValueOnce(true); coreStart.security.authc.getCurrentUser = jest.fn().mockReturnValue(mockUser); - const result = await createApiKey([mockTask], request, coreStart.security); - const apiKeyResult = result.get('task'); - const decodedApiKey = Buffer.from(apiKeyResult!.apiKey, 'base64').toString(); + const result = await createApiKey([mockTask], request, coreStart.security, { + shouldGrantUiam: false, + }); + const apiKeyResult = result.get('task')!; + expect('apiKey' in apiKeyResult && apiKeyResult.apiKey).toBeDefined(); + const decodedApiKey = Buffer.from( + 'apiKey' in apiKeyResult ? apiKeyResult.apiKey : '', + 'base64' + ).toString(); expect(decodedApiKey).toEqual('apiKeyId:apiKey'); expect(coreStart.security.authc.apiKeys.areAPIKeysEnabled).toHaveBeenCalled(); @@ -133,9 +151,15 @@ describe('api_key_utils', () => { coreStart.security.authc.apiKeys.areAPIKeysEnabled = jest.fn().mockReturnValueOnce(true); coreStart.security.authc.getCurrentUser = jest.fn().mockReturnValue(null); - const result = await createApiKey([mockTask], fakeRequest, coreStart.security); - const apiKeyResult = result.get('task'); - const decodedApiKey = Buffer.from(apiKeyResult!.apiKey, 'base64').toString(); + const result = await createApiKey([mockTask], fakeRequest, coreStart.security, { + shouldGrantUiam: false, + }); + const apiKeyResult = result.get('task')!; + expect('apiKey' in apiKeyResult && apiKeyResult.apiKey).toBeDefined(); + const decodedApiKey = Buffer.from( + 'apiKey' in apiKeyResult ? apiKeyResult.apiKey : '', + 'base64' + ).toString(); expect(decodedApiKey).toEqual('apiKeyId:my-fake-apiKey'); expect(coreStart.security.authc.apiKeys.areAPIKeysEnabled).toHaveBeenCalled(); @@ -149,7 +173,9 @@ describe('api_key_utils', () => { coreStart.security.authc.apiKeys.areAPIKeysEnabled = jest.fn().mockReturnValueOnce(false); coreStart.security.authc.getCurrentUser = jest.fn().mockReturnValue(null); - await expect(createApiKey([mockTask], request, coreStart.security)).rejects.toMatchObject({ + await expect( + createApiKey([mockTask], request, coreStart.security, { shouldGrantUiam: false }) + ).rejects.toMatchObject({ message: 'API keys are not enabled, cannot create API key.', }); }); @@ -171,7 +197,9 @@ describe('api_key_utils', () => { coreStart.security.authc.apiKeys.areAPIKeysEnabled = jest.fn().mockReturnValueOnce(true); coreStart.security.authc.getCurrentUser = jest.fn().mockReturnValue(mockUser); - await expect(createApiKey([mockTask], request, coreStart.security)).rejects.toMatchObject({ + await expect( + createApiKey([mockTask], request, coreStart.security, { shouldGrantUiam: false }) + ).rejects.toMatchObject({ message: 'Could not extract API key from user request header.', }); @@ -195,11 +223,11 @@ describe('api_key_utils', () => { coreStart.security.authc.apiKeys.areAPIKeysEnabled = jest.fn().mockReturnValueOnce(true); coreStart.security.authc.getCurrentUser = jest.fn().mockReturnValue(null); - await expect(createApiKey([mockTask], fakeRequest, coreStart.security)).rejects.toMatchObject( - { - message: 'Could not extract API key from fake request header.', - } - ); + await expect( + createApiKey([mockTask], fakeRequest, coreStart.security, { shouldGrantUiam: false }) + ).rejects.toMatchObject({ + message: 'Could not extract API key from fake request header.', + }); expect(coreStart.security.authc.apiKeys.areAPIKeysEnabled).toHaveBeenCalled(); expect(coreStart.security.authc.getCurrentUser).toHaveBeenCalledWith(fakeRequest); @@ -216,13 +244,155 @@ describe('api_key_utils', () => { coreStart.security.authc.apiKeys.areAPIKeysEnabled = jest.fn().mockReturnValueOnce(true); coreStart.security.authc.getCurrentUser = jest.fn().mockReturnValueOnce(mockUser); coreStart.security.authc.apiKeys.grantAsInternalUser = jest.fn().mockResolvedValueOnce(null); - await expect(createApiKey([mockTask], request, coreStart.security)).rejects.toMatchObject({ + await expect( + createApiKey([mockTask], request, coreStart.security, { shouldGrantUiam: false }) + ).rejects.toMatchObject({ message: 'Could not create API key.', }); }); + + test('should create both ES and UIAM API keys when shouldGrantUiam true and uiam is available', async () => { + const request = httpServerMock.createKibanaRequest(); + const coreStart = coreMock.createStart(); + const mockUser = { + authentication_type: 'basic', + username: 'testUser', + }; + + coreStart.security.authc.apiKeys.areAPIKeysEnabled = jest.fn().mockReturnValueOnce(true); + coreStart.security.authc.getCurrentUser = jest.fn().mockReturnValueOnce(mockUser); + coreStart.security.authc.apiKeys.uiam = { + grant: jest.fn().mockResolvedValueOnce({ + id: 'uiamKeyId', + api_key: 'uiamKey', + }), + invalidate: jest.fn(), + } as unknown as typeof coreStart.security.authc.apiKeys.uiam; + + coreStart.security.authc.apiKeys.grantAsInternalUser = jest.fn().mockResolvedValueOnce({ + id: 'apiKeyId', + name: 'TaskManager: testUser', + api_key: 'apiKey', + }); + + const result = await createApiKey([mockTask], request, coreStart.security, { + shouldGrantUiam: true, + }); + + const apiKeyResult = result.get('task')! as EncodedApiKeyResultBoth; + expect(apiKeyResult).toHaveProperty('apiKey'); + expect(apiKeyResult).toHaveProperty('apiKeyId', 'apiKeyId'); + expect(apiKeyResult).toHaveProperty('uiamApiKey'); + expect(apiKeyResult).toHaveProperty('uiamApiKeyId', 'uiamKeyId'); + expect(Buffer.from(apiKeyResult.apiKey, 'base64').toString()).toEqual('apiKeyId:apiKey'); + expect(Buffer.from(apiKeyResult.uiamApiKey, 'base64').toString()).toEqual( + 'uiamKeyId:uiamKey' + ); + expect(coreStart.security.authc.apiKeys.uiam!.grant).toHaveBeenCalledWith(request, { + name: expect.stringContaining('uiam - TaskManager: report'), + }); + }); + + test('should create ES API key only when shouldGrantUiam true but uiam is not available', async () => { + const request = httpServerMock.createKibanaRequest(); + const coreStart = coreMock.createStart(); + const mockUser = { + authentication_type: 'basic', + username: 'testUser', + }; + + coreStart.security.authc.apiKeys.areAPIKeysEnabled = jest.fn().mockReturnValueOnce(true); + coreStart.security.authc.getCurrentUser = jest.fn().mockReturnValueOnce(mockUser); + const uiamOriginal = coreStart.security.authc.apiKeys.uiam; + (coreStart.security.authc.apiKeys as { uiam?: unknown }).uiam = undefined; + + coreStart.security.authc.apiKeys.grantAsInternalUser = jest.fn().mockResolvedValueOnce({ + id: 'apiKeyId', + name: 'TaskManager: testUser', + api_key: 'apiKey', + }); + + const result = await createApiKey([mockTask], request, coreStart.security, { + shouldGrantUiam: true, + }); + + const apiKeyResult = result.get('task')!; + expect(apiKeyResult).toHaveProperty('apiKey'); + expect(apiKeyResult).toHaveProperty('apiKeyId', 'apiKeyId'); + expect(apiKeyResult).not.toHaveProperty('uiamApiKey'); + expect(apiKeyResult).not.toHaveProperty('uiamApiKeyId'); + + (coreStart.security.authc.apiKeys as { uiam?: unknown }).uiam = uiamOriginal; + }); + + test('should create ES API key only when shouldGrantUiam true but uiam.grant returns null', async () => { + const request = httpServerMock.createKibanaRequest(); + const coreStart = coreMock.createStart(); + const mockUser = { + authentication_type: 'basic', + username: 'testUser', + }; + + coreStart.security.authc.apiKeys.areAPIKeysEnabled = jest.fn().mockReturnValueOnce(true); + coreStart.security.authc.getCurrentUser = jest.fn().mockReturnValueOnce(mockUser); + coreStart.security.authc.apiKeys.uiam = { + grant: jest.fn().mockResolvedValueOnce(null), + invalidate: jest.fn(), + } as unknown as typeof coreStart.security.authc.apiKeys.uiam; + + coreStart.security.authc.apiKeys.grantAsInternalUser = jest.fn().mockResolvedValueOnce({ + id: 'apiKeyId', + name: 'TaskManager: testUser', + api_key: 'apiKey', + }); + + const result = await createApiKey([mockTask], request, coreStart.security, { + shouldGrantUiam: true, + }); + + const apiKeyResult = result.get('task')!; + expect(apiKeyResult).toHaveProperty('apiKey'); + expect(apiKeyResult).toHaveProperty('apiKeyId', 'apiKeyId'); + expect(apiKeyResult).not.toHaveProperty('uiamApiKey'); + expect(apiKeyResult).not.toHaveProperty('uiamApiKeyId'); + expect(coreStart.security.authc.apiKeys.uiam!.grant).toHaveBeenCalled(); + }); + + test('should return uiamApiKey only when request has API key, shouldGrantUiam true, and isUiamCredential true', async () => { + const mockApiKey = Buffer.from('uiamKeyId:uiamKey').toString('base64'); + (isUiamCredential as jest.Mock).mockReturnValueOnce(true); + + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: `ApiKey ${mockApiKey}` }, + }); + const coreStart = coreMock.createStart(); + const mockUser = { + authentication_type: 'api_key', + username: 'testUser', + }; + + coreStart.security.authc.apiKeys.areAPIKeysEnabled = jest.fn().mockReturnValueOnce(true); + coreStart.security.authc.getCurrentUser = jest.fn().mockReturnValue(mockUser); + + const result = await createApiKey([mockTask], request, coreStart.security, { + shouldGrantUiam: true, + }); + + const apiKeyResult = result.get('task')!; + expect(apiKeyResult).not.toHaveProperty('apiKey'); + expect(apiKeyResult).toHaveProperty('uiamApiKey'); + expect(apiKeyResult).toHaveProperty('uiamApiKeyId', 'uiamKeyId'); + expect( + Buffer.from( + 'uiamApiKey' in apiKeyResult ? apiKeyResult.uiamApiKey : '', + 'base64' + ).toString() + ).toEqual('uiamKeyId:uiamKey'); + expect(coreStart.security.authc.apiKeys.grantAsInternalUser).not.toHaveBeenCalled(); + }); }); - describe('getUserScope', () => { + describe('getApiKeyAndUserScope', () => { test('should return the users scope based on their request', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/test-space' }); const coreStart = coreMock.createStart(); @@ -250,7 +420,8 @@ describe('api_key_utils', () => { [mockTask], request, coreStart.security, - basePathMock + basePathMock, + { shouldGrantUiam: false } ); expect(result.get('task')).toEqual({ @@ -290,7 +461,8 @@ describe('api_key_utils', () => { [mockTask], request, coreStart.security, - basePathMock + basePathMock, + { shouldGrantUiam: false } ); expect(result.get('task')).toEqual({ @@ -330,7 +502,8 @@ describe('api_key_utils', () => { [mockTask], request, coreStart.security, - basePathMock + basePathMock, + { shouldGrantUiam: false } ); expect(result.get('task')).toEqual({ @@ -369,7 +542,8 @@ describe('api_key_utils', () => { [mockTask], request, coreStart.security, - basePathMock + basePathMock, + { shouldGrantUiam: false } ); expect(result.get('task')).toEqual({ @@ -405,7 +579,8 @@ describe('api_key_utils', () => { [mockTask], fakeRequest, coreStart.security, - basePathMock + basePathMock, + { shouldGrantUiam: false } ); expect(result.get('task')).toEqual({ @@ -417,5 +592,97 @@ describe('api_key_utils', () => { }, }); }); + + test('should return both apiKey and uiamApiKey with uiamApiKeyId in userScope when shouldGrantUiam and uiam grant succeed', async () => { + const request = httpServerMock.createKibanaRequest({ path: '/s/test-space' }); + const coreStart = coreMock.createStart(); + const mockUser = { + authentication_type: 'basic', + username: 'testUser', + }; + + coreStart.security.authc.apiKeys.areAPIKeysEnabled = jest.fn().mockReturnValueOnce(true); + coreStart.security.authc.getCurrentUser = jest.fn().mockReturnValueOnce(mockUser); + coreStart.security.authc.apiKeys.uiam = { + grant: jest.fn().mockResolvedValueOnce({ + id: 'uiamKeyId', + api_key: 'uiamKey', + }), + invalidate: jest.fn(), + } as unknown as typeof coreStart.security.authc.apiKeys.uiam; + + coreStart.security.authc.apiKeys.grantAsInternalUser = jest.fn().mockResolvedValueOnce({ + id: 'apiKeyId', + name: 'TaskManager: testUser', + api_key: 'apiKey', + }); + + const basePathMock = { + get: jest.fn(() => '/s/test-space'), + serverBasePath: '/', + } as unknown as IBasePath; + + const result = await getApiKeyAndUserScope( + [mockTask], + request, + coreStart.security, + basePathMock, + { shouldGrantUiam: true } + ); + + const entry = result.get('task')! as ApiKeyAndUserScopeBoth; + expect(entry).toHaveProperty('apiKey'); + expect(entry).toHaveProperty('uiamApiKey'); + expect(entry.userScope).toEqual({ + apiKeyId: 'apiKeyId', + uiamApiKeyId: 'uiamKeyId', + spaceId: 'test-space', + apiKeyCreatedByUser: false, + }); + expect(Buffer.from(entry.apiKey, 'base64').toString()).toEqual('apiKeyId:apiKey'); + expect(Buffer.from(entry.uiamApiKey, 'base64').toString()).toEqual('uiamKeyId:uiamKey'); + }); + + test('should return uiamApiKey only when request has API key, shouldGrantUiam true, and isUiamCredential true', async () => { + const mockApiKey = Buffer.from('uiamKeyId:uiamKey').toString('base64'); + (isUiamCredential as jest.Mock).mockReturnValueOnce(true); + + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: `ApiKey ${mockApiKey}` }, + }); + const coreStart = coreMock.createStart(); + const mockUser = { + authentication_type: 'api_key', + username: 'testUser', + }; + + coreStart.security.authc.apiKeys.areAPIKeysEnabled = jest.fn().mockReturnValueOnce(true); + coreStart.security.authc.getCurrentUser = jest.fn().mockReturnValue(mockUser); + + const basePathMock = { + get: jest.fn(() => '/'), + serverBasePath: '/', + } as unknown as IBasePath; + + const result = await getApiKeyAndUserScope( + [mockTask], + request, + coreStart.security, + basePathMock, + { shouldGrantUiam: true } + ); + + const entry = result.get('task')!; + expect(entry).not.toHaveProperty('apiKey'); + expect(entry).toHaveProperty('uiamApiKey'); + expect(entry.userScope).toEqual({ + uiamApiKeyId: 'uiamKeyId', + spaceId: 'default', + apiKeyCreatedByUser: true, + }); + expect( + Buffer.from('uiamApiKey' in entry ? entry.uiamApiKey : '', 'base64').toString() + ).toEqual('uiamKeyId:uiamKey'); + }); }); }); diff --git a/x-pack/platform/plugins/shared/task_manager/server/lib/api_key_utils.ts b/x-pack/platform/plugins/shared/task_manager/server/lib/api_key_utils.ts index bec62328e8299..3bf45c0927021 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/lib/api_key_utils.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/lib/api_key_utils.ts @@ -5,10 +5,11 @@ * 2.0. */ -import type { AuthenticatedUser, SecurityServiceStart, IBasePath } from '@kbn/core/server'; +import type { AuthenticatedUser, Logger, SecurityServiceStart, IBasePath } from '@kbn/core/server'; import type { KibanaRequest } from '@kbn/core/server'; import { truncate } from 'lodash'; import { getSpaceIdFromPath } from '@kbn/spaces-utils'; +import { isUiamCredential } from '@kbn/core-security-server'; import type { TaskInstance, TaskUserScope } from '../task'; export interface APIKeyResult { @@ -16,16 +17,61 @@ export interface APIKeyResult { api_key: string; } -export interface EncodedApiKeyResult { +/** ES API key only (header or system-created without UIAM) */ +export interface EncodedApiKeyResultEsOnly { apiKey: string; apiKeyId: string; } -export interface ApiKeyAndUserScope { +/** UIAM API key only (header with UIAM credential) */ +export interface EncodedApiKeyResultUiamOnly { + uiamApiKey: string; + uiamApiKeyId: string; +} + +/** Both ES and UIAM API keys (system-created when shouldGrantUiam) */ +export interface EncodedApiKeyResultBoth { + apiKey: string; + apiKeyId: string; + uiamApiKey: string; + uiamApiKeyId: string; +} + +export type EncodedApiKeyResult = + | EncodedApiKeyResultEsOnly + | EncodedApiKeyResultUiamOnly + | EncodedApiKeyResultBoth; + +/** ES API key only */ +export interface ApiKeyAndUserScopeEsOnly { + apiKey: string; + userScope: TaskUserScope; +} + +/** UIAM API key only */ +export interface ApiKeyAndUserScopeUiamOnly { + uiamApiKey: string; + userScope: TaskUserScope; +} + +/** Both ES and UIAM API keys */ +export interface ApiKeyAndUserScopeBoth { apiKey: string; + uiamApiKey: string; userScope: TaskUserScope; } +/** At least one of apiKey or uiamApiKey is always present */ +export type ApiKeyAndUserScope = + | ApiKeyAndUserScopeEsOnly + | ApiKeyAndUserScopeUiamOnly + | ApiKeyAndUserScopeBoth; + +export interface CreateApiKeyOptions { + shouldGrantUiam: boolean; + logger?: Logger; +} + const getCredentialsFromRequest = (request: KibanaRequest) => { const authorizationHeaderValue = request.headers.authorization; if (!authorizationHeaderValue || typeof authorizationHeaderValue !== 'string') { @@ -57,15 +103,32 @@ export const getApiKeyFromRequest = (request: KibanaRequest) => { return null; }; +const invalidateUiamApiKey = async ( + security: SecurityServiceStart, + request: KibanaRequest, + uiamApiKeyId: string, + logger?: Logger +) => { + const result = await security.authc.apiKeys.uiam?.invalidate(request, { id: uiamApiKeyId }); + if (result && result.error_count > 0 && logger) { + const details = result.error_details?.length ? `: ${JSON.stringify(result.error_details)}` : ''; + logger.error( + `Failed to invalidate UIAM API key ${uiamApiKeyId} (error_count=${result.error_count})${details}` + ); + } +}; + export const createApiKey = async ( taskInstances: TaskInstance[], request: KibanaRequest, - security: SecurityServiceStart -) => { + security: SecurityServiceStart, + options: CreateApiKeyOptions +): Promise> => { if (!(await security.authc.apiKeys.areAPIKeysEnabled())) { throw Error('API keys are not enabled, cannot create API key.'); } + const { shouldGrantUiam, logger } = options; const user = security.authc.getCurrentUser(request); const apiKeyByTaskIdMap = new Map(); @@ -81,24 +144,43 @@ export const createApiKey = async ( } const { id, api_key: apiKey } = apiKeyCreateResult; + const isUiam = shouldGrantUiam && isUiamCredential(apiKey); taskInstances.forEach((task) => { - apiKeyByTaskIdMap.set(task.id!, { - apiKey: Buffer.from(`${id}:${apiKey}`).toString('base64'), - apiKeyId: apiKeyCreateResult.id, - }); + if (isUiam) { + apiKeyByTaskIdMap.set(task.id!, { + uiamApiKey: Buffer.from(`${id}:${apiKey}`).toString('base64'), + uiamApiKeyId: id, + }); + } else { + apiKeyByTaskIdMap.set(task.id!, { + apiKey: Buffer.from(`${id}:${apiKey}`).toString('base64'), + apiKeyId: id, + }); + } }); return apiKeyByTaskIdMap; } - // If the user did not pass in their own API key, we need to create 1 key per task - // type (due to naming requirements). + + // System-created keys: when shouldGrantUiam we grant both ES and UIAM per task type const taskTypes = [...new Set(taskInstances.map((task) => task.taskType))]; const apiKeyByTaskTypeMap = new Map(); for (const taskType of taskTypes) { const apiKeyNamePrefix = `TaskManager: ${taskType}`; const apiKeyName = user ? `${apiKeyNamePrefix} - ${user.username}` : apiKeyNamePrefix; + + let uiamResult: APIKeyResult | null = null; + if (shouldGrantUiam && security.authc.apiKeys.uiam) { + uiamResult = await security.authc.apiKeys.uiam.grant(request, { + name: truncate(`uiam - ${apiKeyName}`, { length: 256 }), + }); + if (!uiamResult) { + logger?.error(`Failed to create UIAM API key for task type ${taskType}`); + } + } + const apiKeyCreateResult = await security.authc.apiKeys.grantAsInternalUser(request, { name: truncate(apiKeyName, { length: 256 }), role_descriptors: {}, @@ -106,18 +188,34 @@ export const createApiKey = async ( }); if (!apiKeyCreateResult) { + if (uiamResult) { + await invalidateUiamApiKey(security, request, uiamResult.id, logger); + } throw Error('Could not create API key.'); } - const { id, api_key: apiKey } = apiKeyCreateResult; - - apiKeyByTaskTypeMap.set(taskType, { - apiKey: Buffer.from(`${id}:${apiKey}`).toString('base64'), - apiKeyId: apiKeyCreateResult.id, - }); + try { + const { id, api_key: apiKey } = apiKeyCreateResult; + const encoded: EncodedApiKeyResult = uiamResult + ? { + apiKey: Buffer.from(`${id}:${apiKey}`).toString('base64'), + apiKeyId: id, + uiamApiKey: Buffer.from(`${uiamResult.id}:${uiamResult.api_key}`).toString('base64'), + uiamApiKeyId: uiamResult.id, + } + : { + apiKey: Buffer.from(`${id}:${apiKey}`).toString('base64'), + apiKeyId: id, + }; + apiKeyByTaskTypeMap.set(taskType, encoded); + } catch (err) { + if (uiamResult) { + await invalidateUiamApiKey(security, request, uiamResult.id, logger); + } + throw err; + } } - // Assign each of the created API keys to the task ID taskInstances.forEach((task) => { const encodedApiKeyResult = apiKeyByTaskTypeMap.get(task.taskType); if (encodedApiKeyResult) { @@ -132,9 +230,10 @@ export const getApiKeyAndUserScope = async ( taskInstances: TaskInstance[], request: KibanaRequest, security: SecurityServiceStart, - basePath: IBasePath + basePath: IBasePath, + options: CreateApiKeyOptions ): Promise> => { - const apiKeyByTaskIdMap = await createApiKey(taskInstances, request, security); + const apiKeyByTaskIdMap = await createApiKey(taskInstances, request, security, options); const requestBasePath = basePath.get(request); const space = getSpaceIdFromPath(requestBasePath, basePath.serverBasePath); @@ -142,19 +241,42 @@ export const getApiKeyAndUserScope = async ( const apiKeyAndUserScopeByTaskId = new Map(); taskInstances.forEach((task) => { - const encodedApiKeyResult = apiKeyByTaskIdMap.get(task.id!); - if (encodedApiKeyResult) { - apiKeyAndUserScopeByTaskId.set(task.id!, { - apiKey: encodedApiKeyResult.apiKey, - userScope: { - apiKeyId: encodedApiKeyResult.apiKeyId, - spaceId: space?.spaceId || 'default', - // Set apiKeyCreatedByUser to true if the request includes its own API key, since we do - // not want to invalidate a specific API key that was not created by the task manager - apiKeyCreatedByUser: requestHasApiKey(security, request), - }, - }); + const encoded = apiKeyByTaskIdMap.get(task.id!); + if (!encoded) return; + + const hasEs = 'apiKey' in encoded && encoded.apiKey; + const hasUiam = 'uiamApiKey' in encoded && encoded.uiamApiKey; + if (!hasEs && !hasUiam) { + throw new Error(`Invalid encoded API key result: ${JSON.stringify(encoded)}`); + } + + const spaceId = space?.spaceId || 'default'; + let entry: ApiKeyAndUserScope; + if (hasEs && hasUiam) { + const userScope: TaskUserScope = { + apiKeyId: encoded.apiKeyId, + uiamApiKeyId: encoded.uiamApiKeyId, + spaceId, + apiKeyCreatedByUser: false, + }; + entry = { apiKey: encoded.apiKey, uiamApiKey: encoded.uiamApiKey, userScope }; + } else if (hasUiam) { + const userScope: TaskUserScope = { + uiamApiKeyId: encoded.uiamApiKeyId, + spaceId, + apiKeyCreatedByUser: requestHasApiKey(security, request), + }; + entry = { uiamApiKey: encoded.uiamApiKey, userScope }; + } else { + const esOnly = encoded as EncodedApiKeyResultEsOnly; + const userScope: TaskUserScope = { + apiKeyId: esOnly.apiKeyId, + spaceId, + apiKeyCreatedByUser: requestHasApiKey(security, request), + }; + entry = { apiKey: esOnly.apiKey, userScope }; } + apiKeyAndUserScopeByTaskId.set(task.id!, entry); }); return apiKeyAndUserScopeByTaskId; 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 index dbc978d1ba83a..93da703d0dc7d 100644 --- 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 @@ -21,7 +21,7 @@ describe('bulkMarkApiKeysForInvalidation', () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); await bulkMarkApiKeysForInvalidation({ - apiKeyIds: ['123', '456'], + apiKeysToInvalidate: [{ apiKeyId: '123' }, { apiKeyId: '456' }], logger, savedObjectsClient: unsecuredSavedObjectsClient, }); @@ -46,7 +46,7 @@ describe('bulkMarkApiKeysForInvalidation', () => { unsecuredSavedObjectsClient.bulkCreate.mockRejectedValueOnce(new Error('Fail')); await bulkMarkApiKeysForInvalidation({ - apiKeyIds: ['123', '456'], + apiKeysToInvalidate: [{ apiKeyId: '123' }, { apiKeyId: '456' }], logger, savedObjectsClient: unsecuredSavedObjectsClient, }); @@ -61,11 +61,70 @@ describe('bulkMarkApiKeysForInvalidation', () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); await bulkMarkApiKeysForInvalidation({ - apiKeyIds: [], + apiKeysToInvalidate: [], logger, savedObjectsClient: unsecuredSavedObjectsClient, }); expect(unsecuredSavedObjectsClient.bulkCreate).not.toHaveBeenCalled(); }); + + test('should pass only uiamApiKeyValue (after colon) to bulkCreate when uiamApiKey is provided', async () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); + + const encodedIdAndValue = Buffer.from('uiamKeyId:uiamKeySecret').toString('base64'); + + await bulkMarkApiKeysForInvalidation({ + apiKeysToInvalidate: [{ apiKeyId: 'uiamKeyId', uiamApiKey: encodedIdAndValue }], + logger, + savedObjectsClient: unsecuredSavedObjectsClient, + }); + + const savedObjects = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0] as Array<{ + attributes: Record; + }>; + expect(savedObjects).toHaveLength(1); + expect(savedObjects[0].attributes).toMatchObject({ + apiKeyId: 'uiamKeyId', + uiamApiKey: 'uiamKeySecret', + }); + expect(savedObjects[0].attributes.createdAt).toBeDefined(); + }); + + test('should not add uiamApiKey to attributes when only apiKeyId is provided', async () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); + + await bulkMarkApiKeysForInvalidation({ + apiKeysToInvalidate: [{ apiKeyId: '123' }], + logger, + savedObjectsClient: unsecuredSavedObjectsClient, + }); + + const savedObjects = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0] as Array<{ + attributes: Record; + }>; + expect(savedObjects[0].attributes).not.toHaveProperty('uiamApiKey'); + expect(savedObjects[0].attributes).toMatchObject({ apiKeyId: '123' }); + }); + + test('should not add uiamApiKey to attributes when encoded value has no colon', async () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); + + const invalidEncoded = Buffer.from('no-colon-here').toString('base64'); + + await bulkMarkApiKeysForInvalidation({ + apiKeysToInvalidate: [{ apiKeyId: 'id1', uiamApiKey: invalidEncoded }], + logger, + savedObjectsClient: unsecuredSavedObjectsClient, + }); + + const savedObjects = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0] as Array<{ + attributes: Record; + }>; + expect(savedObjects[0].attributes).not.toHaveProperty('uiamApiKey'); + expect(savedObjects[0].attributes).toMatchObject({ apiKeyId: 'id1' }); + }); }); 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 index 9140233c60b0f..344df4703c88b 100644 --- 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 @@ -8,28 +8,56 @@ import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; import { INVALIDATE_API_KEY_SO_NAME } from '../saved_objects'; +export interface ApiKeyToMarkForInvalidation { + apiKeyId: string; + uiamApiKey?: string; +} + export interface BulkMarkApiKeysForInvalidationOpts { - apiKeyIds: string[]; + /** List of API keys to mark (ES and/or UIAM; include uiamApiKey when invalidating a UIAM key) */ + apiKeysToInvalidate: ApiKeyToMarkForInvalidation[]; logger: Logger; savedObjectsClient: SavedObjectsClientContract; } + +/** + * Extracts the API key value (secret) from an encoded "id:value" string. + * uiamApiKey is base64-encoded "id:uiamApiKeyValue"; we store only the value part. + */ +function getUiamApiKeyValueOnly(encodedUiamApiKey: string): string | undefined { + try { + const decoded = Buffer.from(encodedUiamApiKey, 'base64').toString(); + const colonIndex = decoded.indexOf(':'); + return colonIndex === -1 ? undefined : decoded.slice(colonIndex + 1); + } catch { + return undefined; + } +} + export const bulkMarkApiKeysForInvalidation = async (opts: BulkMarkApiKeysForInvalidationOpts) => { - const { apiKeyIds, logger, savedObjectsClient } = opts; - if (apiKeyIds.length === 0) { + const { apiKeysToInvalidate, logger, savedObjectsClient } = opts; + if (apiKeysToInvalidate.length === 0) { return; } try { await savedObjectsClient.bulkCreate( - apiKeyIds.map((apiKeyId) => ({ - attributes: { - apiKeyId, - createdAt: new Date().toISOString(), - }, - type: INVALIDATE_API_KEY_SO_NAME, - })) + apiKeysToInvalidate.map(({ apiKeyId, uiamApiKey }) => { + const uiamApiKeyValue = + uiamApiKey !== undefined ? getUiamApiKeyValueOnly(uiamApiKey) : undefined; + return { + attributes: { + apiKeyId, + createdAt: new Date().toISOString(), + ...(uiamApiKeyValue !== undefined ? { uiamApiKey: uiamApiKeyValue } : {}), + }, + type: INVALIDATE_API_KEY_SO_NAME, + }; + }) ); } catch (e) { - logger.error(`Failed to bulk mark ${apiKeyIds.length} API keys for invalidation: ${e.message}`); + logger.error( + `Failed to bulk mark ${apiKeysToInvalidate.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 2bb0ee3429d99..fafbe92a7011d 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 @@ -8,9 +8,9 @@ import { set } from '@kbn/safer-lodash-set'; import type { RawMonitoringStats } from '../monitoring'; import { HealthStatus } from '../monitoring'; import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { ApiKeyType } from '../config'; import { calculateHealthStatus } from './calculate_health_status'; import { cloneDeep } from 'lodash'; -import { ApiKeyType } from '../config'; const now = '2023-05-09T13:00:00.000Z'; Date.now = jest.fn().mockReturnValue(new Date(now)); 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 b39fda686249b..034d591358f58 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/mocks.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/mocks.ts @@ -42,6 +42,7 @@ const createStartMock = () => { bulkUpdateState: jest.fn(), registerEncryptedSavedObjectsClient: jest.fn(), registerApiKeyInvalidateFn: jest.fn(), + registerUiamApiKeyInvalidateFn: jest.fn(), }); return mock; 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 88a6856e56e1a..a6bc38eaa6693 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/plugin.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/plugin.ts @@ -20,12 +20,16 @@ import type { CoreSetup, Logger, CoreStart, + KibanaRequest, } from '@kbn/core/server'; 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 type { + InvalidateAPIKeysParams, + InvalidateUiamAPIKeyParams, +} from '@kbn/security-plugin-types-server'; import { registerDeleteInactiveNodesTaskDefinition, scheduleDeleteInactiveNodesTaskDefinition, @@ -67,7 +71,10 @@ 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 type { + ApiKeyInvalidationFn, + UiamApiKeyInvalidationFn, +} from './invalidate_api_keys/invalidate_api_keys_task'; import { registerInvalidateApiKeyTask, scheduleInvalidateApiKeyTask, @@ -104,6 +111,7 @@ export type TaskManagerStartContract = Pick< getRegisteredTypes: () => string[]; registerEncryptedSavedObjectsClient: (client: EncryptedSavedObjectsClient) => void; registerApiKeyInvalidateFn: (fn?: ApiKeyInvalidationFn) => void; + registerUiamApiKeyInvalidateFn: (fn?: UiamApiKeyInvalidationFn) => void; }; export interface TaskManagerPluginsStart { @@ -150,6 +158,7 @@ export class TaskManagerPlugin private canEncryptSavedObjects: boolean; private licenseSubscriber?: PublicMethodsOf; private invalidateApiKeyFn?: ApiKeyInvalidationFn; + private invalidateUiamApiKeyFn?: UiamApiKeyInvalidationFn; constructor(private readonly initContext: PluginInitializerContext) { this.initContext = initContext; @@ -174,6 +183,12 @@ export class TaskManagerPlugin } } + private invalidateUiamApiKey = (request: KibanaRequest, params: InvalidateUiamAPIKeyParams) => { + if (this.invalidateUiamApiKeyFn) { + return this.invalidateUiamApiKeyFn(request, params); + } + }; + public setup( core: CoreSetup, plugins: TaskManagerPluginsSetup @@ -277,6 +292,7 @@ export class TaskManagerPlugin configInterval: this.config.invalidate_api_key_task.interval, coreStartServices: core.getStartServices, invalidateApiKeyFn: this.invalidateApiKey.bind(this), + invalidateUiamApiKeyFn: this.invalidateUiamApiKey.bind(this), logger: this.logger, removalDelay: this.config.invalidate_api_key_task.removalDelay, taskTypeDictionary: this.definitions, @@ -341,6 +357,7 @@ export class TaskManagerPlugin } const serializer = savedObjects.createSerializer(); + const shouldGrantUiam = security.authc?.apiKeys?.uiam != null; const taskStore = new TaskStore({ serializer, savedObjectsRepository, @@ -358,6 +375,7 @@ export class TaskManagerPlugin getIsSecurityEnabled: this.licenseSubscriber?.getIsSecurityEnabled, basePath: http.basePath, executionContext, + shouldGrantUiam, }); const isServerless = this.initContext.env.packageInfo.buildFlavor === 'serverless'; @@ -475,6 +493,9 @@ export class TaskManagerPlugin registerApiKeyInvalidateFn: (fn?: ApiKeyInvalidationFn) => { this.invalidateApiKeyFn = fn; }, + registerUiamApiKeyInvalidateFn: (fn?: UiamApiKeyInvalidationFn) => { + this.invalidateUiamApiKeyFn = fn; + }, }; } 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 d03671b359272..17c22ae93ff8e 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 @@ -81,6 +81,9 @@ export const taskMappings: SavedObjectsTypeMappingDefinition = { apiKeyId: { type: 'keyword', }, + uiamApiKeyId: { + type: 'keyword', + }, // NO NEED TO BE INDEXED // apiKeyCreatedByUser: { // type: 'boolean', diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/task_model_versions.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/task_model_versions.ts index f12eb58b07e21..d2fb1d777a149 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/task_model_versions.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/model_versions/task_model_versions.ts @@ -15,6 +15,7 @@ import { taskSchemaV6, taskSchemaV7, taskSchemaV8, + taskSchemaV9, } from '../schemas/task'; // IMPORTANT!!! @@ -115,4 +116,22 @@ export const taskModelVersions: SavedObjectsModelVersionMap = { create: taskSchemaV8, }, }, + '9': { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + userScope: { + properties: { + uiamApiKeyId: { type: 'keyword' }, + }, + }, + }, + }, + ], + schemas: { + forwardCompatibility: taskSchemaV9.extends({}, { unknowns: 'ignore' }), + create: taskSchemaV9, + }, + }, }; diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.ts index 39fe89c54bfc7..aaa1180b66677 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.ts @@ -87,3 +87,16 @@ export const taskSchemaV8 = taskSchemaV7.extends({ schema.oneOf([schema.literal('tiny'), schema.literal('normal'), schema.literal('extralarge')]) ), }); + +/** userScope: at least one of apiKeyId or uiamApiKeyId; when both present, apiKeyCreatedByUser is false */ +const userScopeSchemaV9 = schema.object({ + apiKeyId: schema.maybe(schema.string()), + uiamApiKeyId: schema.maybe(schema.string()), + spaceId: schema.string(), + apiKeyCreatedByUser: schema.boolean(), +}); + +export const taskSchemaV9 = taskSchemaV8.extends({ + uiamApiKey: schema.maybe(schema.string()), + userScope: schema.maybe(userScopeSchemaV9), +}); 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 9e7bda8ec21ab..33ca483ae0922 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task.ts @@ -257,8 +257,14 @@ export type TaskLifecycle = TaskStatus | TaskLifecycleResult; export type { IntervalSchedule, Rrule, RruleSchedule } from '@kbn/response-ops-scheduling-types'; +/** + * User scope for task API keys. + * At least one of apiKeyId (ES) or uiamApiKeyId (UIAM) must be set. + * When both are set (system-created keys), apiKeyCreatedByUser is always false. + */ export interface TaskUserScope { - apiKeyId: string; + apiKeyId?: string; + uiamApiKeyId?: string; spaceId?: string; apiKeyCreatedByUser: boolean; } @@ -374,6 +380,11 @@ export interface TaskInstance { */ apiKey?: string; + /** + * UIAM API key when UIAM is supported (encrypted at rest) + */ + uiamApiKey?: string; + /** * Meta data related to the API key associated with this task */ @@ -516,6 +527,7 @@ export type SerializedConcreteTaskInstance = Omit< runAt: string; partition?: number; apiKey?: string; + uiamApiKey?: string; userScope?: TaskUserScope; }; diff --git a/x-pack/platform/plugins/shared/task_manager/server/task_running/task_runner.test.ts b/x-pack/platform/plugins/shared/task_manager/server/task_running/task_runner.test.ts index 8f1ae2eddc466..b4d09ac50336c 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task_running/task_runner.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task_running/task_runner.test.ts @@ -41,7 +41,8 @@ import { TASK_MANAGER_TRANSACTION_TYPE_MARK_AS_RUNNING, } from './task_runner'; import { schema } from '@kbn/config-schema'; -import { CLAIM_STRATEGY_MGET, CLAIM_STRATEGY_UPDATE_BY_QUERY } from '../config'; +import { ApiKeyType, CLAIM_STRATEGY_MGET, CLAIM_STRATEGY_UPDATE_BY_QUERY } from '../config'; +import type { TaskManagerConfig } from '../config'; import * as nextRunAtUtils from '../lib/get_next_run_at'; import { configMock } from '../config.mock'; @@ -3030,12 +3031,121 @@ describe('TaskManagerRunner', () => { }); }); + describe('getFakeKibanaRequest', () => { + const esApiKey = Buffer.from('esKeyId:esKeySecret').toString('base64'); + const uiamApiKey = Buffer.from('uiamKeyId:uiamKeySecret').toString('base64'); + + test('returns undefined when task has no apiKey and no uiamApiKey', async () => { + const createTaskRunnerFn = jest.fn(); + const { runner } = await readyToRunStageSetup({ + instance: mockInstance(), + definitions: { + bar: { title: 'Bar!', createTaskRunner: createTaskRunnerFn }, + }, + }); + await runner.run(); + expect(createTaskRunnerFn.mock.calls[0][0].fakeRequest).toBeUndefined(); + }); + + test('uses ES apiKey when api_key_type is ES (default)', async () => { + const createTaskRunnerFn = jest.fn(); + const { runner } = await readyToRunStageSetup({ + instance: mockInstance({ + apiKey: esApiKey, + userScope: { apiKeyId: 'esKeyId', spaceId: 'my-space', apiKeyCreatedByUser: false }, + }), + definitions: { + bar: { title: 'Bar!', createTaskRunner: createTaskRunnerFn }, + }, + }); + await runner.run(); + const { fakeRequest } = createTaskRunnerFn.mock.calls[0][0]; + expect(fakeRequest).toBeDefined(); + expect(fakeRequest.headers.authorization).toBe(`ApiKey ${esApiKey}`); + }); + + test('uses uiamApiKey when api_key_type is UIAM and task has uiamApiKey', async () => { + const createTaskRunnerFn = jest.fn(); + const { runner } = await readyToRunStageSetup({ + instance: mockInstance({ + apiKey: esApiKey, + uiamApiKey, + userScope: { + apiKeyId: 'esKeyId', + uiamApiKeyId: 'uiamKeyId', + spaceId: 'default', + apiKeyCreatedByUser: false, + }, + }), + definitions: { + bar: { title: 'Bar!', createTaskRunner: createTaskRunnerFn }, + }, + config: { api_key_type: ApiKeyType.UIAM }, + }); + await runner.run(); + const { fakeRequest } = createTaskRunnerFn.mock.calls[0][0]; + expect(fakeRequest).toBeDefined(); + expect(fakeRequest.headers.authorization).toBe(`ApiKey ${uiamApiKey}`); + }); + + test('falls back to ES apiKey with warning when api_key_type is UIAM but task has no uiamApiKey', async () => { + const createTaskRunnerFn = jest.fn(); + const { runner, logger } = await readyToRunStageSetup({ + instance: mockInstance({ + apiKey: esApiKey, + userScope: { apiKeyId: 'esKeyId', spaceId: 'default', apiKeyCreatedByUser: false }, + }), + definitions: { + bar: { title: 'Bar!', createTaskRunner: createTaskRunnerFn }, + }, + config: { api_key_type: ApiKeyType.UIAM }, + }); + await runner.run(); + const { fakeRequest } = createTaskRunnerFn.mock.calls[0][0]; + expect(fakeRequest).toBeDefined(); + expect(fakeRequest.headers.authorization).toBe(`ApiKey ${esApiKey}`); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('is configured to use UIAM API key but has no uiamApiKey') + ); + }); + + test('sets basePath using userScope.spaceId', async () => { + const createTaskRunnerFn = jest.fn(); + const { runner, basePathService } = await readyToRunStageSetup({ + instance: mockInstance({ + apiKey: esApiKey, + userScope: { apiKeyId: 'esKeyId', spaceId: 'custom-space', apiKeyCreatedByUser: false }, + }), + definitions: { + bar: { title: 'Bar!', createTaskRunner: createTaskRunnerFn }, + }, + }); + await runner.run(); + expect(basePathService.set).toHaveBeenCalledWith(expect.anything(), '/s/custom-space'); + }); + + test('defaults spaceId to "default" when userScope is missing', async () => { + const createTaskRunnerFn = jest.fn(); + const { runner, basePathService } = await readyToRunStageSetup({ + instance: mockInstance({ + apiKey: esApiKey, + }), + definitions: { + bar: { title: 'Bar!', createTaskRunner: createTaskRunnerFn }, + }, + }); + await runner.run(); + expect(basePathService.set).toHaveBeenCalledWith(expect.anything(), '/'); + }); + }); + interface TestOpts { instance?: Partial; definitions?: TaskDefinitionRegistry; onTaskEvent?: jest.Mock<(event: TaskEvent) => void>; allowReadingInvalidState?: boolean; strategy?: string; + config?: Partial; } function withAnyTiming(taskRun: TaskRun) { @@ -3095,8 +3205,9 @@ describe('TaskManagerRunner', () => { definitions.registerTaskDefinitions(opts.definitions); } + const basePathService = httpServiceMock.createBasePath(); const runner = new TaskManagerRunner({ - basePathService: httpServiceMock.createBasePath(), + basePathService, defaultMaxAttempts: 5, beforeRun: (context) => Promise.resolve(context), beforeMarkRunning: (context) => Promise.resolve(context), @@ -3112,6 +3223,7 @@ describe('TaskManagerRunner', () => { monitor: true, warn_threshold: 5000, }, + ...opts.config, }), allowReadingInvalidState: opts.allowReadingInvalidState || false, strategy: opts.strategy ?? CLAIM_STRATEGY_UPDATE_BY_QUERY, @@ -3133,6 +3245,7 @@ describe('TaskManagerRunner', () => { store, instance, usageCounter, + basePathService, }; } }); diff --git a/x-pack/platform/plugins/shared/task_manager/server/task_running/task_runner.ts b/x-pack/platform/plugins/shared/task_manager/server/task_running/task_runner.ts index 953ca6de51ad5..25903eb932689 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task_running/task_runner.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task_running/task_runner.ts @@ -65,7 +65,7 @@ import type { import { isFailedRunResult, TaskStatus } from '../task'; import type { TaskTypeDictionary } from '../task_type_dictionary'; import { isUnrecoverableError, isUserError } from './errors'; -import { CLAIM_STRATEGY_MGET, type TaskManagerConfig } from '../config'; +import { ApiKeyType, CLAIM_STRATEGY_MGET, type TaskManagerConfig } from '../config'; import { TaskValidator } from '../task_validator'; import { getRetryAt, getRetryDate, getTimeout } from '../lib/get_retry_at'; import { getNextRunAt } from '../lib/get_next_run_at'; @@ -395,11 +395,12 @@ export class TaskManagerRunner implements TaskRunner { const stopUpdatingLongRunningTasks = this.updateRetryAtOnIntervalForLongRunningTasks(); try { - const sanitizedTaskInstance = omit(modifiedContext.taskInstance, ['apiKey', 'userScope']); - const fakeRequest = this.getFakeKibanaRequest( - modifiedContext.taskInstance.apiKey, - modifiedContext.taskInstance.userScope?.spaceId - ); + const sanitizedTaskInstance = omit(modifiedContext.taskInstance, [ + 'apiKey', + 'userScope', + 'uiamApiKey', + ]); + const fakeRequest = this.getFakeKibanaRequest(modifiedContext.taskInstance); const abortController = new AbortController(); @@ -935,12 +936,23 @@ export class TaskManagerRunner implements TaskRunner { return this.definition?.maxAttempts ?? this.defaultMaxAttempts; } - private getFakeKibanaRequest(apiKey?: string, spaceId?: string): KibanaRequest | undefined { - if (apiKey) { - const requestHeaders: Headers = {}; + private getFakeKibanaRequest(taskInstance: ConcreteTaskInstance): KibanaRequest | undefined { + const { api_key_type: apiKeyType } = this.config; + const apiKeyToUse = + apiKeyType === ApiKeyType.UIAM && taskInstance.uiamApiKey + ? taskInstance.uiamApiKey + : taskInstance.apiKey; - requestHeaders.authorization = `ApiKey ${apiKey}`; - const path = addSpaceIdToPath('/', spaceId || 'default'); + if (apiKeyType === ApiKeyType.UIAM && taskInstance.apiKey && !taskInstance.uiamApiKey) { + this.logger.warn( + `Task ${taskInstance.id} (${this.taskType}) is configured to use UIAM API key but has no uiamApiKey; falling back to ES API key` + ); + } + + if (apiKeyToUse) { + const requestHeaders: Headers = {}; + requestHeaders.authorization = `ApiKey ${apiKeyToUse}`; + const path = addSpaceIdToPath('/', taskInstance.userScope?.spaceId || 'default'); const fakeRawRequest: FakeRawRequest = { headers: requestHeaders, 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 6c6b0e73e9fc2..198399872921f 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 @@ -23,7 +23,12 @@ import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks import type { SearchOpts, AggregationOpts } from './task_store'; import { TaskStore, taskInstanceToAttributes } from './task_store'; import { savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; -import type { SavedObjectAttributes, IBasePath, SavedObjectsServiceStart } from '@kbn/core/server'; +import type { + SavedObjectAttributes, + IBasePath, + SavedObjectsServiceStart, + SavedObject, +} from '@kbn/core/server'; import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { executionContextServiceMock } from '@kbn/core-execution-context-server-mocks'; @@ -154,6 +159,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); store.registerEncryptedSavedObjectsClient(esoClient); @@ -284,6 +290,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => false, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); store.registerEncryptedSavedObjectsClient(esoClient); @@ -343,7 +350,7 @@ describe('TaskStore', () => { const mockUserScope = { apiKeyId: 'apiKeyId', - apiKeyCreatedBy: 'testUser', + apiKeyCreatedByUser: false, spaceId: 'testSpace', }; @@ -395,7 +402,8 @@ describe('TaskStore', () => { [task], request, coreStart.security, - basePathMock + basePathMock, + expect.objectContaining({ shouldGrantUiam: false }) ); expect(savedObjectsClient.create).not.toHaveBeenCalled(); @@ -439,6 +447,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); const task = { @@ -562,6 +571,7 @@ describe('TaskStore', () => { security: coreStart.security, getIsSecurityEnabled: () => true, basePath: basePathMock, + shouldGrantUiam: false, executionContext: mockExecutionContextStart, }); }); @@ -691,6 +701,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); esoClient.createPointInTimeFinderDecryptedAsInternalUser = jest.fn().mockResolvedValue({ @@ -860,6 +871,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); let getApiKeysCallCount = 0; @@ -965,6 +977,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); let getApiKeysCallCount = 0; @@ -1064,6 +1077,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); let getApiKeysCallCount = 0; @@ -1182,6 +1196,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); }); @@ -1311,6 +1326,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); }); @@ -1541,6 +1557,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); store.registerEncryptedSavedObjectsClient(esoClient); }); @@ -1864,7 +1881,7 @@ describe('TaskStore', () => { }); expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith({ - apiKeyIds: ['apiKeyId'], + apiKeysToInvalidate: [{ apiKeyId: 'apiKeyId' }], logger, savedObjectsClient, }); @@ -1872,7 +1889,8 @@ describe('TaskStore', () => { [{ ...bulkUpdateTask, apiKey: mockApiKey, userScope: mockUserScope }], mockRequest, coreStart.security, - basePathMock + basePathMock, + expect.objectContaining({ shouldGrantUiam: false }) ); expect(mockScopedClient.bulkUpdate).toHaveBeenCalledWith( @@ -1896,7 +1914,8 @@ describe('TaskStore', () => { 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 () => { + test('bulk update with regenerated API key marks both ES and UIAM keys for invalidation when task has both', async () => { + const mockUiamApiKey = Buffer.from('uiamKeyId:uiamKey').toString('base64'); const mockScopedClient = { bulkUpdate: jest.fn().mockResolvedValue({ saved_objects: [ @@ -1917,65 +1936,43 @@ describe('TaskStore', () => { mockGetScopedClient.mockReturnValue(mockScopedClient); const mockUpdatedApiKey = Buffer.from('apiKeyIdUpdated:apiKey').toString('base64'); - + const mockUpdatedUiamApiKey = Buffer.from('uiamKeyIdUpdated:uiamKey').toString('base64'); const mockUpdatedUserScope = { apiKeyId: 'apiKeyIdUpdated', - apiKeyCreatedByUser: true, + uiamApiKeyId: 'uiamKeyIdUpdated', + apiKeyCreatedByUser: false, spaceId: 'testSpace', }; const apiKeyAndUserScopeMap = new Map(); apiKeyAndUserScopeMap.set('task:324242', { apiKey: mockUpdatedApiKey, + uiamApiKey: mockUpdatedUiamApiKey, 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 }, + const docWithBothKeys = { + ...bulkUpdateTask, + apiKey: mockApiKey, + uiamApiKey: mockUiamApiKey, + userScope: { + ...mockUserScope, + uiamApiKeyId: 'uiamKeyId', }, - { - validate: false, - } - ); + }; - expect(mockGetScopedClient).toHaveBeenCalledWith(mockRequest, { - includedHiddenTypes: ['task'], - excludedExtensions: ['security', 'spaces'], + await store.bulkUpdate([docWithBothKeys], { + validate: false, + mergeAttributes: false, + options: { request: mockRequest, regenerateApiKey: true }, }); - expect(bulkMarkApiKeysForInvalidation).not.toHaveBeenCalled(); - expect(getApiKeyAndUserScope).toHaveBeenCalledWith( - [ - { - ...bulkUpdateTask, - apiKey: mockApiKey, - userScope: { ...mockUserScope, apiKeyCreatedByUser: true }, - }, - ], - mockRequest, - coreStart.security, - basePathMock - ); - + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith({ + apiKeysToInvalidate: [{ apiKeyId: 'apiKeyId', uiamApiKey: mockUiamApiKey }], + logger, + savedObjectsClient, + }); expect(mockScopedClient.bulkUpdate).toHaveBeenCalledWith( [ { @@ -1986,17 +1983,14 @@ describe('TaskStore', () => { attributes: { ...taskInstanceToAttributes(bulkUpdateTask, bulkUpdateTask.id), apiKey: mockUpdatedApiKey, + uiamApiKey: mockUpdatedUiamApiKey, 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({ @@ -2081,7 +2075,8 @@ describe('TaskStore', () => { ], mockRequest, coreStart.security, - basePathMock + basePathMock, + expect.objectContaining({ shouldGrantUiam: false }) ); expect(mockScopedClient.bulkUpdate).toHaveBeenCalledWith( @@ -2303,6 +2298,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => false, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); savedObjectsClient.bulkUpdate.mockResolvedValue({ @@ -2378,6 +2374,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); }); @@ -2903,7 +2900,7 @@ describe('TaskStore', () => { apiKey: mockApiKey, userScope: { apiKeyId: 'apiKeyId', - apiKeyCreatedBy: 'testUser', + apiKeyCreatedByUser: false, spaceId: 'testSpace', }, }, @@ -2931,6 +2928,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); esoClient.createPointInTimeFinderDecryptedAsInternalUser = jest.fn().mockResolvedValue({ @@ -2958,7 +2956,36 @@ describe('TaskStore', () => { expect(result).toBeUndefined(); expect(savedObjectsClient.delete).toHaveBeenCalledWith('task', id, { refresh: false }); expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith({ - apiKeyIds: ['apiKeyId'], + apiKeysToInvalidate: [{ apiKeyId: 'apiKeyId' }], + logger, + savedObjectsClient, + }); + }); + + test('invalidates both ES and UIAM API keys when task has both', async () => { + const mockUiamApiKey = Buffer.from('uiamKeyId:uiamKey').toString('base64'); + const taskWithBothKeys = { + ...mockTask, + attributes: { + ...mockTask.attributes, + apiKey: mockTask.attributes.apiKey, + uiamApiKey: mockUiamApiKey, + userScope: { + apiKeyId: 'apiKeyId', + uiamApiKeyId: 'uiamKeyId', + apiKeyCreatedByUser: false, + spaceId: 'testSpace', + }, + }, + }; + savedObjectsClient.get.mockResolvedValueOnce(taskWithBothKeys); + const id = 'task1'; + await store.remove(id); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith({ + apiKeysToInvalidate: [ + { apiKeyId: 'apiKeyId' }, + { apiKeyId: 'uiamKeyId', uiamApiKey: mockUiamApiKey }, + ], logger, savedObjectsClient, }); @@ -2999,7 +3026,7 @@ describe('TaskStore', () => { apiKey: mockApiKey1, userScope: { apiKeyId: 'apiKeyId1', - apiKeyCreatedBy: 'testUser', + apiKeyCreatedByUser: false, spaceId: 'testSpace', }, }, @@ -3026,7 +3053,7 @@ describe('TaskStore', () => { apiKey: mockApiKey2, userScope: { apiKeyId: 'apiKeyId2', - apiKeyCreatedBy: 'testUser', + apiKeyCreatedByUser: false, spaceId: 'testSpace', }, }, @@ -3056,6 +3083,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); esoClient.createPointInTimeFinderDecryptedAsInternalUser = jest.fn().mockResolvedValue({ @@ -3090,7 +3118,35 @@ describe('TaskStore', () => { const result = await store.bulkRemove(['task1', 'task2']); expect(result).toBeUndefined(); expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith({ - apiKeyIds: ['apiKeyId1', 'apiKeyId2'], + apiKeysToInvalidate: [{ apiKeyId: 'apiKeyId1' }, { apiKeyId: 'apiKeyId2' }], + logger, + savedObjectsClient, + }); + }); + + test('bulk marks both ES and UIAM API keys for invalidation when tasks have uiamApiKey', async () => { + const mockUiamApiKey1 = Buffer.from('uiamKeyId1:uiamKey').toString('base64'); + const task1WithUiam = { + ...mockTask1, + attributes: { + ...mockTask1.attributes, + uiamApiKey: mockUiamApiKey1, + userScope: { + ...mockTask1.attributes.userScope, + uiamApiKeyId: 'uiamKeyId1', + }, + }, + }; + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [task1WithUiam, mockTask2], + }); + await store.bulkRemove(['task1', 'task2']); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith({ + apiKeysToInvalidate: [ + { apiKeyId: 'apiKeyId1' }, + { apiKeyId: 'uiamKeyId1', uiamApiKey: mockUiamApiKey1 }, + { apiKeyId: 'apiKeyId2' }, + ], logger, savedObjectsClient, }); @@ -3131,6 +3187,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); }); @@ -3199,6 +3256,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); }); @@ -3302,6 +3360,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); expect(await store.getLifecycle(task.id)).toEqual(status); @@ -3332,6 +3391,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); expect(await store.getLifecycle(randomId())).toEqual(TaskLifecycleResult.NotFound); @@ -3360,6 +3420,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); return expect(store.getLifecycle(randomId())).rejects.toThrow('Bad Request'); @@ -3390,6 +3451,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); store.registerEncryptedSavedObjectsClient(esoClient); @@ -3526,7 +3588,7 @@ describe('TaskStore', () => { const mockUserScope = { apiKeyId: 'apiKeyId', - apiKeyCreatedBy: 'testUser', + apiKeyCreatedByUser: false, spaceId: 'testSpace', }; @@ -3599,7 +3661,8 @@ describe('TaskStore', () => { [task1, task2], request, coreStart.security, - basePathMock + basePathMock, + expect.objectContaining({ shouldGrantUiam: false }) ); expect(savedObjectsClient.create).not.toHaveBeenCalled(); @@ -3664,6 +3727,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); const task1 = { @@ -3700,6 +3764,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => false, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); store.registerEncryptedSavedObjectsClient(esoClient); @@ -3825,6 +3890,186 @@ describe('TaskStore', () => { expect(getApiKeyAndUserScope).toHaveBeenCalled(); }); + test('when bulkCreate returns partial success, invalidates unused UIAM API keys for failed items only', async () => { + store = new TaskStore({ + logger: mockLogger(), + index: 'tasky', + taskManagerId: '', + serializer, + esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, + definitions: taskDefinitions, + savedObjectsRepository: savedObjectsClient, + adHocTaskCounter, + allowReadingInvalidState: false, + requestTimeouts: { update_by_query: 1000 }, + savedObjectsService: coreStart.savedObjects, + security: coreStart.security, + canEncryptSavedObjects: true, + getIsSecurityEnabled: () => true, + basePath: basePathMock, + shouldGrantUiam: true, + executionContext: mockExecutionContextStart, + }); + store.registerEncryptedSavedObjectsClient(esoClient); + + const uiamInvalidate = jest.fn().mockResolvedValue({ error_count: 0 }); + (coreStart.security.authc.apiKeys as unknown as { uiam?: { invalidate: jest.Mock } }).uiam = { + invalidate: uiamInvalidate, + }; + + const task1 = { + id: 'task1', + params: { hello: 'world' }, + state: { foo: 'bar' }, + taskType: 'report', + }; + const task2 = { + id: 'task2', + params: { hello: 'world' }, + state: { foo: 'bar' }, + taskType: 'report', + }; + + const mockApiKey = Buffer.from('apiKeyId:apiKey').toString('base64'); + const mockUiamKeyId1 = 'uiamKeyId1'; + const mockUiamKeyId2 = 'uiamKeyId2'; + const apiKeyAndUserScopeMap = new Map(); + apiKeyAndUserScopeMap.set('task1', { + apiKey: mockApiKey, + userScope: { + apiKeyId: 'apiKeyId', + uiamApiKeyId: mockUiamKeyId1, + apiKeyCreatedByUser: false, + spaceId: 'default', + }, + }); + apiKeyAndUserScopeMap.set('task2', { + apiKey: mockApiKey, + userScope: { + apiKeyId: 'apiKeyId', + uiamApiKeyId: mockUiamKeyId2, + apiKeyCreatedByUser: false, + spaceId: 'default', + }, + }); + (getApiKeyAndUserScope as jest.Mock).mockResolvedValueOnce(apiKeyAndUserScopeMap); + + coreStart.savedObjects.getScopedClient.mockReturnValueOnce(scopedSavedObjectsClient); + + scopedSavedObjectsClient.bulkCreate.mockImplementationOnce(async () => ({ + saved_objects: [ + { + id: 'task1', + type: 'task', + attributes: { + attempts: 0, + params: '{}', + retryAt: null, + runAt: '2019-02-12T21:01:22.479Z', + scheduledAt: '2019-02-12T21:01:22.479Z', + startedAt: null, + state: '{}', + stateVersion: 1, + status: 'idle', + taskType: 'report', + partition: 225, + }, + references: [], + version: '123', + }, + { + id: 'task2', + type: 'task', + error: { error: 'Conflict', message: 'conflict', statusCode: 409 }, + }, + ] as SavedObject[], + })); + + const request = httpServerMock.createKibanaRequest(); + + await expect(store.bulkSchedule([task1, task2], { request })).rejects.toThrow(); + + expect(uiamInvalidate).toHaveBeenCalledTimes(1); + expect(uiamInvalidate).toHaveBeenCalledWith(request, { id: mockUiamKeyId2 }); + }); + + test('when bulkCreate throws, invalidates all UIAM API keys for the batch', async () => { + store = new TaskStore({ + logger: mockLogger(), + index: 'tasky', + taskManagerId: '', + serializer, + esClient: elasticsearchServiceMock.createClusterClient().asInternalUser, + definitions: taskDefinitions, + savedObjectsRepository: savedObjectsClient, + adHocTaskCounter, + allowReadingInvalidState: false, + requestTimeouts: { update_by_query: 1000 }, + savedObjectsService: coreStart.savedObjects, + security: coreStart.security, + canEncryptSavedObjects: true, + getIsSecurityEnabled: () => true, + basePath: basePathMock, + shouldGrantUiam: true, + executionContext: mockExecutionContextStart, + }); + store.registerEncryptedSavedObjectsClient(esoClient); + + const uiamInvalidate = jest.fn().mockResolvedValue({ error_count: 0 }); + (coreStart.security.authc.apiKeys as unknown as { uiam?: { invalidate: jest.Mock } }).uiam = { + invalidate: uiamInvalidate, + }; + + const task1 = { + id: 'task1', + params: { hello: 'world' }, + state: { foo: 'bar' }, + taskType: 'report', + }; + const task2 = { + id: 'task2', + params: { hello: 'world' }, + state: { foo: 'bar' }, + taskType: 'report', + }; + + const mockUiamKeyId1 = 'uiamKeyId1'; + const mockUiamKeyId2 = 'uiamKeyId2'; + const apiKeyAndUserScopeMap = new Map(); + apiKeyAndUserScopeMap.set('task1', { + apiKey: Buffer.from('id:key').toString('base64'), + userScope: { + apiKeyId: 'apiKeyId', + uiamApiKeyId: mockUiamKeyId1, + apiKeyCreatedByUser: false, + spaceId: 'default', + }, + }); + apiKeyAndUserScopeMap.set('task2', { + apiKey: Buffer.from('id:key').toString('base64'), + userScope: { + apiKeyId: 'apiKeyId', + uiamApiKeyId: mockUiamKeyId2, + apiKeyCreatedByUser: false, + spaceId: 'default', + }, + }); + (getApiKeyAndUserScope as jest.Mock).mockResolvedValueOnce(apiKeyAndUserScopeMap); + + coreStart.savedObjects.getScopedClient.mockReturnValueOnce(scopedSavedObjectsClient); + scopedSavedObjectsClient.bulkCreate.mockRejectedValueOnce(new Error('Bulk create failed')); + + const request = httpServerMock.createKibanaRequest(); + + await expect(store.bulkSchedule([task1, task2], { request })).rejects.toThrow( + 'Bulk create failed' + ); + + expect(uiamInvalidate).toHaveBeenCalledTimes(2); + expect(uiamInvalidate).toHaveBeenNthCalledWith(1, request, { id: mockUiamKeyId1 }); + expect(uiamInvalidate).toHaveBeenNthCalledWith(2, request, { id: mockUiamKeyId2 }); + }); + test('errors if the task type is unknown', async () => { await expect(testBulkSchedule([{ taskType: 'nope', params: {}, state: {} }])).rejects.toThrow( /Unsupported task type "nope"/i @@ -3959,6 +4204,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); savedObjectsClient.create.mockImplementation(async (type: string, attributes: unknown) => ({ @@ -4012,6 +4258,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); savedObjectsClient.create.mockImplementation(async (type: string, attributes: unknown) => ({ @@ -4061,6 +4308,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); }); test('should pass requestTimeout and retryOnTimeout', async () => { @@ -4099,6 +4347,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); }); @@ -4217,6 +4466,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + shouldGrantUiam: false, }); }); 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 fdfa720ec58c8..18debdebfe6b9 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 @@ -67,6 +67,7 @@ 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 type { ApiKeyToMarkForInvalidation } from './lib/bulk_mark_api_keys_for_invalidation'; import { getFirstRunAt } from './lib/get_first_run_at'; import { isInterval } from './lib/intervals'; import { bulkMarkApiKeysForInvalidation } from './lib/bulk_mark_api_keys_for_invalidation'; @@ -89,6 +90,7 @@ export interface StoreOpts { getIsSecurityEnabled: () => boolean; basePath: IBasePath; executionContext: ExecutionContextStart; + shouldGrantUiam: boolean; } export interface SearchOpts { @@ -163,6 +165,7 @@ export class TaskStore { private logger: Logger; private basePath: IBasePath; private executionContextRunner: ExecutionContextRunner; + private shouldGrantUiam: boolean; /** * Constructs a new TaskStore. @@ -198,6 +201,7 @@ export class TaskStore { name: 'taskStore', // individual executions can be specialized with an `id` property ... }); + this.shouldGrantUiam = opts.shouldGrantUiam; } public registerEncryptedSavedObjectsClient(client: EncryptedSavedObjectsClient) { @@ -230,8 +234,8 @@ export class TaskStore { } private async regenerateApiKeyFromRequest(docs: ConcreteTaskInstance[], options?: ApiKeyOptions) { - const hasEncryptedFields = docs.some((doc) => doc.apiKey && doc.userScope); - const apiKeyIdsToRemoveMap = new Map(); + const hasEncryptedFields = docs.some((doc) => (doc.apiKey || doc.uiamApiKey) && doc.userScope); + const apiKeyIdsToRemoveMap = new Map(); let apiKeyAndUserScopeMap: Map | null = null; // If a task with an API key is updated with a request @@ -239,11 +243,19 @@ export class TaskStore { const docsWithApiKeys: ConcreteTaskInstance[] = []; docs.forEach((taskInstance) => { - const { apiKey, userScope } = taskInstance; - if (apiKey && userScope) { + const { apiKey, uiamApiKey, userScope } = taskInstance; + if ((apiKey || uiamApiKey) && userScope) { docsWithApiKeys.push(taskInstance); - if (!userScope.apiKeyCreatedByUser) { - apiKeyIdsToRemoveMap.set(taskInstance.id, userScope.apiKeyId); + if (apiKey && userScope.apiKeyId) { + apiKeyIdsToRemoveMap.set(taskInstance.id, { + apiKeyId: userScope.apiKeyId, + ...(uiamApiKey && userScope.uiamApiKeyId ? { uiamApiKey } : {}), + }); + } else if (uiamApiKey && userScope.uiamApiKeyId) { + apiKeyIdsToRemoveMap.set(taskInstance.id, { + apiKeyId: userScope.uiamApiKeyId, + uiamApiKey, + }); } } }); @@ -258,7 +270,7 @@ export class TaskStore { } private getSoClientForUpdate(docs: ConcreteTaskInstance[], options?: ApiKeyOptions) { - const hasEncryptedFields = docs.some((doc) => doc.apiKey && doc.userScope); + const hasEncryptedFields = docs.some((doc) => (doc.apiKey || doc.uiamApiKey) && doc.userScope); // If a task with an API key is updated without a request, throw an error. if (hasEncryptedFields && !options?.request) { @@ -299,7 +311,8 @@ export class TaskStore { taskInstances, request, this.security, - this.basePath + this.basePath, + { shouldGrantUiam: this.shouldGrantUiam, logger: this.logger } ); } catch (e) { this.errors$.next(e); @@ -309,11 +322,44 @@ export class TaskStore { return userScopeAndApiKey; } + /** + * Invalidates UIAM API keys that were created but not used (e.g. after schedule failure). + * No-op if security.uiam is unavailable or no ids are provided. + */ + private async invalidateUnusedUiamApiKeys( + request: KibanaRequest, + uiamApiKeyIds: string[], + context: string + ): Promise { + if (!this.security.authc.apiKeys.uiam || uiamApiKeyIds.length === 0) { + return; + } + for (const id of uiamApiKeyIds) { + try { + const invalidateResult = await this.security.authc.apiKeys.uiam.invalidate(request, { id }); + if (invalidateResult && invalidateResult.error_count > 0) { + const details = invalidateResult.error_details?.length + ? `: ${JSON.stringify(invalidateResult.error_details)}` + : ''; + this.logger.error( + `Failed to invalidate unused UIAM API key ${context} (error_count=${invalidateResult.error_count})${details}` + ); + } + } catch (invalidateErr) { + this.logger.warn( + `Failed to invalidate unused UIAM API key ${context}: ${ + invalidateErr instanceof Error ? invalidateErr.message : String(invalidateErr) + }` + ); + } + } + } + private async bulkGetDecryptedTaskApiKeys( taskIds: string[] - ): Promise> { + ): Promise> { if (!this.canEncryptSo() || !taskIds.length) { - return new Map(); + return new Map(); } const result = await this.getApiKeys(taskIds); @@ -356,7 +402,7 @@ export class TaskStore { }) ); - const result = new Map(); + const result = new Map(); const finder = await this.esoClient!.createPointInTimeFinderDecryptedAsInternalUser( { @@ -367,7 +413,11 @@ export class TaskStore { for await (const response of finder.find()) { response.saved_objects.forEach((savedObject) => { - result.set(savedObject.id, savedObject.attributes.apiKey); + const attrs = savedObject.attributes; + result.set(savedObject.id, { + ...(attrs.apiKey ? { apiKey: attrs.apiKey } : {}), + ...(attrs.uiamApiKey ? { uiamApiKey: attrs.uiamApiKey } : {}), + }); }); } @@ -379,7 +429,7 @@ export class TaskStore { const ids: string[] = []; tasks.forEach((task) => { - if (task.apiKey) { + if (task.apiKey || task.uiamApiKey) { ids.push(task.id); } }); @@ -388,14 +438,17 @@ export class TaskStore { return tasks; } - const decryptedTaskApiKeysMap = await this.bulkGetDecryptedTaskApiKeys(ids); + const decryptedMap = await this.bulkGetDecryptedTaskApiKeys(ids); - const tasksWithDecryptedApiKeys = tasks.map((task) => ({ - ...task, - ...(decryptedTaskApiKeysMap.get(task.id) - ? { apiKey: decryptedTaskApiKeysMap.get(task.id) } - : {}), - })); + const tasksWithDecryptedApiKeys = tasks.map((task) => { + const decrypted = decryptedMap.get(task.id); + if (!decrypted) return task; + return { + ...task, + ...(decrypted.apiKey ? { apiKey: decrypted.apiKey } : {}), + ...(decrypted.uiamApiKey ? { uiamApiKey: decrypted.uiamApiKey } : {}), + }; + }); return tasksWithDecryptedApiKeys; } @@ -438,7 +491,7 @@ export class TaskStore { const apiKeyAndUserScopeMap = (await this.getApiKeyFromRequest([taskInstance], options?.request)) || new Map(); - const { apiKey, userScope } = apiKeyAndUserScopeMap.get(taskInstance.id) || {}; + const { apiKey, uiamApiKey, userScope } = apiKeyAndUserScopeMap.get(taskInstance.id!) || {}; const soClient = this.getSoClientForCreate(options || {}); @@ -454,6 +507,7 @@ export class TaskStore { ...taskInstanceToAttributes(validatedTaskInstance, id), ...(userScope ? { userScope } : {}), ...(apiKey ? { apiKey } : {}), + ...(uiamApiKey ? { uiamApiKey } : {}), runAt: getFirstRunAt({ taskInstance: validatedTaskInstance, logger: this.logger }), }, { id, refresh: false } @@ -465,6 +519,13 @@ export class TaskStore { this.adHocTaskCounter.increment(); } } catch (e) { + if (userScope?.uiamApiKeyId && options?.request) { + await this.invalidateUnusedUiamApiKeys( + options.request, + [userScope.uiamApiKeyId], + 'after schedule failure' + ); + } this.errors$.next(e); throw e; } @@ -511,7 +572,7 @@ export class TaskStore { const objects = taskInstances.reduce( (acc: Array>, taskInstance) => { - const { apiKey, userScope } = apiKeyAndUserScopeMap.get(taskInstance.id) || {}; + const { apiKey, uiamApiKey, userScope } = apiKeyAndUserScopeMap.get(taskInstance.id!) || {}; const id = taskInstance.id || v4(); this.definitions.ensureHas(taskInstance.taskType); @@ -526,6 +587,7 @@ export class TaskStore { attributes: { ...taskInstanceToAttributes(validatedTaskInstance, id), ...(apiKey ? { apiKey } : {}), + ...(uiamApiKey ? { uiamApiKey } : {}), ...(userScope ? { userScope } : {}), runAt: getFirstRunAt({ taskInstance: validatedTaskInstance, logger: this.logger }), }, @@ -553,7 +615,34 @@ export class TaskStore { return get(task, 'schedule.interval', null) == null; }).length ); + // bulkCreate can return partial success; invalidate UIAM keys for any failed items. + // so.id matches the id we passed (taskInstance.id || v4()), and the map is keyed by task id. + if (options?.request) { + const failedUiamApiKeyIds = savedObjects.saved_objects + .filter((so) => so.error) + .map((so) => apiKeyAndUserScopeMap.get(so.id)?.userScope?.uiamApiKeyId) + .filter((id) => id != null); + if (failedUiamApiKeyIds.length) { + await this.invalidateUnusedUiamApiKeys( + options.request, + failedUiamApiKeyIds, + 'after bulkSchedule partial failure' + ); + } + } } catch (e) { + if (options?.request) { + const uiamApiKeyIds = taskInstances + .map( + (taskInstance) => apiKeyAndUserScopeMap.get(taskInstance.id!)?.userScope?.uiamApiKeyId + ) + .filter((id): id is string => id != null); + await this.invalidateUnusedUiamApiKeys( + options.request, + uiamApiKeyIds, + 'after bulkSchedule failure' + ); + } this.errors$.next(e); throw e; } @@ -669,10 +758,14 @@ 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; + const { + apiKey: updatedApiKey, + uiamApiKey: updatedUiamApiKey, + userScope: updatedUserScope, + } = apiKeyAndUserScopeMap.get(taskInstance.id) || {}; + const apiKey = updatedApiKey ?? doc?.apiKey; + const uiamApiKey = updatedUiamApiKey ?? doc?.uiamApiKey; + const userScope = updatedUserScope ?? doc?.userScope; acc.set(doc.id, { type: 'task', @@ -681,6 +774,7 @@ export class TaskStore { attributes: { ...taskInstanceToAttributes(taskInstance, doc.id), ...(apiKey ? { apiKey } : {}), + ...(uiamApiKey ? { uiamApiKey } : {}), ...(userScope ? { userScope } : {}), }, mergeAttributes, @@ -709,7 +803,7 @@ export class TaskStore { throw e; } - const apiKeyIdsToRemove: string[] = []; + const apiKeysToInvalidate: ApiKeyToMarkForInvalidation[] = []; const updates = updatedSavedObjects.map((updatedSavedObject) => { if (updatedSavedObject.error !== undefined) { return asErr({ @@ -729,17 +823,18 @@ export class TaskStore { const result = this.taskValidator.getValidatedTaskInstanceFromReading(taskInstance, { validate, }); + const oldApiKey = apiKeyIdsToRemoveMap.get(updatedSavedObject.id); if (oldApiKey) { - apiKeyIdsToRemove.push(oldApiKey); + apiKeysToInvalidate.push(oldApiKey); } + return asOk(result); }); - // after successful updates we should invalidate the old API keys - if (apiKeyIdsToRemove.length) { + if (apiKeysToInvalidate.length > 0) { await bulkMarkApiKeysForInvalidation({ - apiKeyIds: apiKeyIdsToRemove, + apiKeysToInvalidate, logger: this.logger, savedObjectsClient: this.savedObjectsRepository, }); @@ -852,12 +947,19 @@ export class TaskStore { private async _remove(id: string): Promise { const taskInstance = await this._get(id); - const { apiKey, userScope } = taskInstance; + const { apiKey, uiamApiKey, userScope } = taskInstance; - if (apiKey && userScope) { - if (!userScope.apiKeyCreatedByUser) { + if ((apiKey || uiamApiKey) && userScope && !userScope.apiKeyCreatedByUser) { + const apiKeysToInvalidate: ApiKeyToMarkForInvalidation[] = []; + if (apiKey && userScope.apiKeyId) { + apiKeysToInvalidate.push({ apiKeyId: userScope.apiKeyId }); + } + if (uiamApiKey && userScope.uiamApiKeyId) { + apiKeysToInvalidate.push({ apiKeyId: userScope.uiamApiKeyId, uiamApiKey }); + } + if (apiKeysToInvalidate.length) { await bulkMarkApiKeysForInvalidation({ - apiKeyIds: [userScope.apiKeyId], + apiKeysToInvalidate, logger: this.logger, savedObjectsClient: this.savedObjectsRepository, }); @@ -886,21 +988,24 @@ export class TaskStore { private async _bulkRemove(taskIds: string[]): Promise { const taskInstances = await this._bulkGet(taskIds); - const apiKeyIdsToRemove: string[] = []; + const apiKeysToInvalidate: ApiKeyToMarkForInvalidation[] = []; taskInstances.forEach((taskInstance) => { const unwrappedTaskInstance = unwrap(taskInstance) as ConcreteTaskInstance; - const { apiKey, userScope } = unwrappedTaskInstance; - if (apiKey && userScope) { - if (!userScope.apiKeyCreatedByUser) { - apiKeyIdsToRemove.push(userScope.apiKeyId); + const { apiKey, uiamApiKey, userScope } = unwrappedTaskInstance; + if ((apiKey || uiamApiKey) && userScope && !userScope.apiKeyCreatedByUser) { + if (apiKey && userScope.apiKeyId) { + apiKeysToInvalidate.push({ apiKeyId: userScope.apiKeyId }); + } + if (uiamApiKey && userScope.uiamApiKeyId) { + apiKeysToInvalidate.push({ apiKeyId: userScope.uiamApiKeyId, uiamApiKey }); } } }); - if (apiKeyIdsToRemove.length) { + if (apiKeysToInvalidate.length) { await bulkMarkApiKeysForInvalidation({ - apiKeyIds: apiKeyIdsToRemove, + apiKeysToInvalidate, logger: this.logger, savedObjectsClient: this.savedObjectsRepository, }); @@ -1313,7 +1418,7 @@ export function taskInstanceToAttributes( id: string ): SerializedConcreteTaskInstance { return { - ...omit(doc, 'id', 'version', 'userScope', 'apiKey'), + ...omit(doc, 'id', 'version', 'userScope', 'apiKey', 'uiamApiKey'), params: JSON.stringify(doc.params || {}), state: JSON.stringify(doc.state || {}), attempts: (doc as ConcreteTaskInstance).attempts || 0, @@ -1330,7 +1435,7 @@ export function partialTaskInstanceToAttributes( doc: PartialConcreteTaskInstance ): PartialSerializedConcreteTaskInstance { return { - ...omit(doc, 'id', 'version', 'userScope', 'apiKey'), + ...omit(doc, 'id', 'version', 'userScope', 'apiKey', 'uiamApiKey'), ...(doc.params ? { params: JSON.stringify(doc.params) } : {}), ...(doc.state ? { state: JSON.stringify(doc.state) } : {}), ...(doc.scheduledAt ? { scheduledAt: doc.scheduledAt.toISOString() } : {}), diff --git a/x-pack/platform/plugins/shared/task_manager/tsconfig.json b/x-pack/platform/plugins/shared/task_manager/tsconfig.json index 40b8983a8e6da..ac6a9121ff8eb 100644 --- a/x-pack/platform/plugins/shared/task_manager/tsconfig.json +++ b/x-pack/platform/plugins/shared/task_manager/tsconfig.json @@ -42,6 +42,7 @@ "@kbn/licensing-types", "@kbn/lazy-object", "@kbn/security-plugin-types-server", + "@kbn/core-security-server", "@kbn/connector-specs", "@kbn/es-errors", "@kbn/core-execution-context-server-mocks",