diff --git a/.buildkite/scout_ci_config.yml b/.buildkite/scout_ci_config.yml index 35ce8882d0ba9..591907f5ba12a 100644 --- a/.buildkite/scout_ci_config.yml +++ b/.buildkite/scout_ci_config.yml @@ -45,6 +45,7 @@ plugins: - streams - streams_app - synthetics + - task_manager - transform - triggers_actions_ui - uptime diff --git a/packages/kbn-check-saved-objects-cli/current_fields.json b/packages/kbn-check-saved-objects-cli/current_fields.json index 3d4e524bbed3f..659ecffa61d16 100644 --- a/packages/kbn-check-saved-objects-cli/current_fields.json +++ b/packages/kbn-check-saved-objects-cli/current_fields.json @@ -1428,7 +1428,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 39f36b3cf8d2c..2c5660b81631c 100644 --- a/packages/kbn-check-saved-objects-cli/current_mappings.json +++ b/packages/kbn-check-saved-objects-cli/current_mappings.json @@ -4749,6 +4749,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 85e8c6d93d8a1..b35c61b66253c 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 @@ -184,7 +184,7 @@ describe('checking migration metadata changes on all registered SO types', () => "synthetics-private-location": "f5efabeefafbb12ed0809db3cd04f893ff9099ead8f526be82a9b0348e444f65", "synthetics-privates-locations": "42aebb3aa4f3710a3e270d54bf33718a4d1d7a983556a51f75bd96b1e4fdf048", "tag": "03a522e92aed789a4bbf1a5dd19159c3ec061cb052337df9270728def4b3bbe0", - "task": "52bb9355724d6546a8e485c161c7f039493acb79e913b31ce1d0b9839fe38117", + "task": "4e6f0a8e825e3159959f1dc0fd4d6c5d9b15707863a741905b183dc6d1144f44", "telemetry": "fb5e3ce0b2955f10aa8cd75fdafdd0559bf5d77eaf6e2c228079684f01f28fbd", "threshold-explorer-view": "9b0a770f5444531f92dd50832dcf655cb0c9cd7f18af205338e0c9d73c6df6a6", "trial-companion-nba-milestone": "83f29f99e2ffaf00ed8e05f3366ed0df1fb36a77193aeb151e13bae8b1d9692f", @@ -1281,8 +1281,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: 2c8f74c9a9d07540f767ad8e99174474da9bec93ab858ae1e005a301f620c39b", "task|10.8.0: deed2eb105aa3f19fa1827868c6b5569523624614fb73a8fcb8600d86c0dface", "task|10.7.0: 6afacb50669e4a3ebd48d5790d1677c138885b1540acf5e832dbe8dc82e7cd5c", "task|10.6.0: a554a701424daf84a260b61390464deb9296c7372ac3438301c2fb046ded11f9", @@ -1545,7 +1546,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", @@ -1711,7 +1712,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/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/task_manager_uiam/serverless/observability_complete.serverless.config.ts b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/task_manager_uiam/serverless/observability_complete.serverless.config.ts new file mode 100644 index 0000000000000..771d44a87caf4 --- /dev/null +++ b/src/platform/packages/shared/kbn-scout/src/servers/configs/config_sets/task_manager_uiam/serverless/observability_complete.serverless.config.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { servers as defaultConfig } from '../../default/serverless/observability_complete.serverless.config'; +import type { ScoutServerConfig } from '../../../../../types'; + +// Enables the Task Manager UIAM rollout flag so the EsAndUiamApiKeyStrategy is +// exercised. The flag is not yet enabled on MKI, so these tests run only on +// Kibana CI (not on MKI). +export const servers: ScoutServerConfig = { + ...defaultConfig, + kbnTestServer: { + ...defaultConfig.kbnTestServer, + serverArgs: [ + ...defaultConfig.kbnTestServer.serverArgs, + '--xpack.task_manager.grant_uiam_api_keys=true', + ], + }, +}; diff --git a/src/platform/plugins/private/ftr_apis/kibana.jsonc b/src/platform/plugins/private/ftr_apis/kibana.jsonc index 75663274a1f3a..1aee44689544e 100644 --- a/src/platform/plugins/private/ftr_apis/kibana.jsonc +++ b/src/platform/plugins/private/ftr_apis/kibana.jsonc @@ -12,6 +12,9 @@ "server": true, "configPath": [ "ftr_apis" + ], + "requiredPlugins": [ + "taskManager" ] } } \ No newline at end of file diff --git a/src/platform/plugins/private/ftr_apis/moon.yml b/src/platform/plugins/private/ftr_apis/moon.yml index 3b0d465fe6f2d..b5adcbe505c7a 100644 --- a/src/platform/plugins/private/ftr_apis/moon.yml +++ b/src/platform/plugins/private/ftr_apis/moon.yml @@ -19,6 +19,8 @@ project: dependsOn: - '@kbn/core' - '@kbn/config-schema' + - '@kbn/task-manager-plugin' + - '@kbn/response-ops-scheduling-types' tags: - plugin - prod diff --git a/src/platform/plugins/private/ftr_apis/server/plugin.ts b/src/platform/plugins/private/ftr_apis/server/plugin.ts index 393d627587f32..6e6c9aaf29aad 100644 --- a/src/platform/plugins/private/ftr_apis/server/plugin.ts +++ b/src/platform/plugins/private/ftr_apis/server/plugin.ts @@ -7,23 +7,29 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/server'; +import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/server'; +import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; import { registerRoutes } from './routes'; import type { ConfigType } from './config'; -export class FtrApisPlugin implements Plugin { +export class FtrApisPlugin + implements Plugin +{ private readonly config: ConfigType; + private taskManagerStart?: TaskManagerStartContract; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); } - public setup({ http, savedObjects }: CoreSetup) { + public setup({ http }: CoreSetup) { const router = http.createRouter(); if (!this.config.disableApis) { - registerRoutes(router); + registerRoutes(router, () => this.taskManagerStart); } } - public start() {} + public start(_core: CoreStart, { taskManager }: { taskManager: TaskManagerStartContract }) { + this.taskManagerStart = taskManager; + } } diff --git a/src/platform/plugins/private/ftr_apis/server/routes/index.ts b/src/platform/plugins/private/ftr_apis/server/routes/index.ts index 7ddbf7a2850ec..683ecfd8b34f2 100644 --- a/src/platform/plugins/private/ftr_apis/server/routes/index.ts +++ b/src/platform/plugins/private/ftr_apis/server/routes/index.ts @@ -8,8 +8,14 @@ */ import type { IRouter } from '@kbn/core/server'; +import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; import { registerKbnClientSoRoutes } from './kbn_client_so'; +import { registerTaskManagerRoutes } from './task_manager'; -export const registerRoutes = (router: IRouter) => { +export const registerRoutes = ( + router: IRouter, + getTaskManagerStart: () => TaskManagerStartContract | undefined +) => { registerKbnClientSoRoutes(router); + registerTaskManagerRoutes(router, getTaskManagerStart); }; diff --git a/src/platform/plugins/private/ftr_apis/server/routes/task_manager/delete.ts b/src/platform/plugins/private/ftr_apis/server/routes/task_manager/delete.ts new file mode 100644 index 0000000000000..dd0567335e502 --- /dev/null +++ b/src/platform/plugins/private/ftr_apis/server/routes/task_manager/delete.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { schema } from '@kbn/config-schema'; +import type { + IRouter, + KibanaRequest, + KibanaResponseFactory, + RequestHandlerContext, +} from '@kbn/core/server'; +import { SavedObjectsErrorHelpers } from '@kbn/core/server'; +import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; + +export const registerTaskManagerDeleteRoute = ( + router: IRouter, + getStartContract: () => TaskManagerStartContract | undefined +) => { + router.delete( + { + path: '/internal/task_manager/tasks/{taskId}', + security: { + authz: { + requiredPrivileges: ['ftrApis'], + }, + }, + validate: { + params: schema.object({ + taskId: schema.string(), + }), + }, + }, + async (_context: RequestHandlerContext, req: KibanaRequest, res: KibanaResponseFactory) => { + const startContract = getStartContract(); + if (!startContract) { + return res.customError({ + statusCode: 503, + body: { message: 'Task Manager has not started yet' }, + }); + } + + const { taskId } = req.params as { taskId: string }; + + try { + await startContract.remove(taskId); + } catch (err) { + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + return res.notFound({ body: { message: `Task ${taskId} not found` } }); + } + throw err; + } + + return res.ok({ body: { deleted: true } }); + } + ); +}; diff --git a/src/platform/plugins/private/ftr_apis/server/routes/task_manager/index.ts b/src/platform/plugins/private/ftr_apis/server/routes/task_manager/index.ts new file mode 100644 index 0000000000000..5c87bd6d5dab1 --- /dev/null +++ b/src/platform/plugins/private/ftr_apis/server/routes/task_manager/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { IRouter } from '@kbn/core/server'; +import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; +import { registerTaskManagerDeleteRoute } from './delete'; +import { registerTaskManagerScheduleRoute } from './schedule'; + +export const registerTaskManagerRoutes = ( + router: IRouter, + getStartContract: () => TaskManagerStartContract | undefined +) => { + registerTaskManagerScheduleRoute(router, getStartContract); + registerTaskManagerDeleteRoute(router, getStartContract); +}; diff --git a/src/platform/plugins/private/ftr_apis/server/routes/task_manager/schedule.ts b/src/platform/plugins/private/ftr_apis/server/routes/task_manager/schedule.ts new file mode 100644 index 0000000000000..fdac09b25b3ec --- /dev/null +++ b/src/platform/plugins/private/ftr_apis/server/routes/task_manager/schedule.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { schema } from '@kbn/config-schema'; +import type { + IRouter, + KibanaRequest, + KibanaResponseFactory, + RequestHandlerContext, +} from '@kbn/core/server'; +import type { IntervalSchedule, RruleSchedule } from '@kbn/response-ops-scheduling-types'; +import type { InstanceTaskCost, TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; + +const taskSchema = schema.object({ + task: schema.object({ + taskType: schema.string({ minLength: 1, maxLength: 200 }), + id: schema.maybe(schema.string({ maxLength: 200 })), + enabled: schema.boolean({ defaultValue: true }), + params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), + state: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), + scope: schema.maybe( + schema.arrayOf(schema.string({ minLength: 1, maxLength: 200 }), { maxSize: 10 }) + ), + schedule: schema.maybe( + schema.oneOf([ + schema.object({ + interval: schema.string(), + }), + schema.object({ + rrule: schema.object({ + dtstart: schema.maybe(schema.string()), + freq: schema.number({ min: 0, max: 3 }), + interval: schema.number({ min: 1 }), + tzid: schema.string({ defaultValue: 'UTC' }), + byhour: schema.maybe( + schema.arrayOf(schema.number({ min: 0, max: 23 }), { maxSize: 24 }) + ), + byminute: schema.maybe( + schema.arrayOf(schema.number({ min: 0, max: 59 }), { maxSize: 60 }) + ), + byweekday: schema.maybe( + schema.arrayOf(schema.number({ min: 1, max: 7 }), { maxSize: 7 }) + ), + bymonthday: schema.maybe( + schema.arrayOf(schema.number({ min: 1, max: 31 }), { maxSize: 31 }) + ), + }), + }), + ]) + ), + timeoutOverride: schema.maybe(schema.string({ maxLength: 50 })), + cost: schema.maybe( + schema.oneOf([schema.literal('tiny'), schema.literal('normal'), schema.literal('extralarge')]) + ), + }), +}); + +export const registerTaskManagerScheduleRoute = ( + router: IRouter, + getStartContract: () => TaskManagerStartContract | undefined +) => { + router.post( + { + path: '/internal/task_manager/schedule', + security: { + authz: { + requiredPrivileges: ['ftrApis'], + }, + }, + validate: { + body: taskSchema, + }, + }, + async (_context: RequestHandlerContext, req: KibanaRequest, res: KibanaResponseFactory) => { + const startContract = getStartContract(); + if (!startContract) { + return res.customError({ + statusCode: 503, + body: { message: 'Task Manager has not started yet' }, + }); + } + + const { task } = req.body as { + task: { + taskType: string; + id?: string; + enabled?: boolean; + params: Record; + state: Record; + scope?: string[]; + schedule?: IntervalSchedule | RruleSchedule; + timeoutOverride?: string; + cost?: InstanceTaskCost; + }; + }; + + const taskResult = await startContract.schedule(task, { request: req }); + + return res.ok({ body: taskResult }); + } + ); +}; diff --git a/src/platform/plugins/private/ftr_apis/tsconfig.json b/src/platform/plugins/private/ftr_apis/tsconfig.json index 52fad3b447f30..7f704df728e31 100644 --- a/src/platform/plugins/private/ftr_apis/tsconfig.json +++ b/src/platform/plugins/private/ftr_apis/tsconfig.json @@ -15,5 +15,7 @@ "kbn_references": [ "@kbn/core", "@kbn/config-schema", + "@kbn/task-manager-plugin", + "@kbn/response-ops-scheduling-types" ] } 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 0a736b69c52ea..dc9844d334f84 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 @@ -34,11 +34,17 @@ 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, }); + plugin.encryptedSavedObjects.registerType({ + type: 'api_key_to_invalidate', + attributesToEncrypt: new Set(['uiamApiKey']), + attributesToIncludeInAAD: new Set(['apiKeyId', 'createdAt']), + }); + plugin.taskManager.registerCanEncryptedSavedObjects(plugin.encryptedSavedObjects.canEncrypt); plugin.eventLog.registerProviderActions(EVENT_LOG_PROVIDER, Object.values(EVENT_LOG_ACTIONS)); @@ -47,14 +53,15 @@ export class TaskManagerDependenciesPlugin { ); } - public start(_: CoreStart, plugin: TaskManagerDependenciesPluginStart) { + public start(core: CoreStart, plugin: TaskManagerDependenciesPluginStart) { plugin.taskManager.registerEncryptedSavedObjectsClient( plugin.encryptedSavedObjects.getClient({ - includedHiddenTypes: ['task'], + includedHiddenTypes: ['task', 'api_key_to_invalidate'], }) ); 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 288b95d2f7f62..f5a041a8c20cb 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 @@ -20,7 +20,7 @@ import type { EncryptedSavedObjectsService } from '../../server/crypto'; import * as EncryptedSavedObjectsModule from '../../server/saved_objects'; // This will only change if new ESOs are introduced. This number should never get smaller. -export const ESO_TYPES_COUNT = 22 as const; +export const ESO_TYPES_COUNT = 23 as const; describe('checking changes on all registered encrypted SO types', () => { let esServer: TestElasticsearchUtils; @@ -70,6 +70,7 @@ describe('checking changes on all registered encrypted SO types', () => { "alert": "878a3b83179bbf2ad9d3862fcba539b7066429869b14c120a1dc7a8d39f4a7fa", "anonymization-salt": "1e5ff6ba241b27bbfc6901898b0ece9327ba63fdaea1f2f6cba6344d4a425b43", "api_key_pending_invalidation": "4dafadadaaca2f2f3f6038ee8363b71b2d101371ca98c34d2b6aa2a96f7e71c5", + "api_key_to_invalidate": "d7a3423a74032bb5ecce9a0975e8ea1d5d5171348f99173df54b2fa0dfd3de43", "cloud-connect-api-key": "8c0ae7a780c411145ae4aaf7a70235672c9ccfb56d011c322da3c4eeb258f32d", "connector_token": "e446f5ff0fbf516f63398e474f126332b4c31e316daa613c6cb8c863400110c5", "entity-discovery-api-key": "cd3b5230a513d2d3503583223e48362fbbbc7812aa4710579a62acfa5bbc30e6", @@ -83,7 +84,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", } @@ -133,6 +134,8 @@ describe('checking changes on all registered encrypted SO types', () => { "anonymization-salt|1", "api_key_pending_invalidation|2", "api_key_pending_invalidation|1", + "api_key_to_invalidate|2", + "api_key_to_invalidate|1", "cloud-connect-api-key|1", "connector_token|2", "connector_token|1", @@ -153,6 +156,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 ab582a6f8c3f9..69a990da5c8fc 100644 --- a/x-pack/platform/plugins/shared/task_manager/moon.yml +++ b/x-pack/platform/plugins/shared/task_manager/moon.yml @@ -56,6 +56,7 @@ dependsOn: - '@kbn/core-execution-context-common' - '@kbn/core-http-server' - '@kbn/core-security-server' + - '@kbn/scout' tags: - plugin - prod @@ -67,6 +68,8 @@ fileGroups: - server/**/* - server/**/*.json - common/**/* + - test/scout/**/* + - test/scout_task_manager_uiam/**/* - '!target/**/*' jest-config: - jest.config.js diff --git a/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/api_key_strategy.ts b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/api_key_strategy.ts new file mode 100644 index 0000000000000..ca05e63d27a78 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/api_key_strategy.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + Logger, + SecurityServiceStart, + IBasePath, + KibanaRequest, + SavedObjectsClientContract, +} from '@kbn/core/server'; +import type { ApiKeyType } from '../config'; +import type { ConcreteTaskInstance, TaskInstance, TaskUserScope } from '../task'; +import { INVALIDATE_API_KEY_SO_NAME } from '../saved_objects'; + +export type { ApiKeyType } from '../config'; + +export interface ApiKeySOFields { + apiKey: string; + uiamApiKey?: string; + userScope: TaskUserScope; +} + +export interface InvalidationTarget { + apiKeyId: string; + uiamApiKey?: string; +} + +export interface ApiKeyStrategy { + readonly shouldGrantUiam: boolean; + readonly typeToUse: ApiKeyType; + + grantApiKeys( + taskInstances: TaskInstance[], + request: KibanaRequest, + security: SecurityServiceStart, + basePath: IBasePath + ): Promise>; + + getApiKeyForFakeRequest(taskInstance: ConcreteTaskInstance): string | undefined; + + getApiKeyIdsForInvalidation(taskInstance: ConcreteTaskInstance): InvalidationTarget[]; + + markForInvalidation( + targets: InvalidationTarget[], + logger: Logger, + savedObjectsClient: SavedObjectsClientContract + ): Promise; +} + +export const markApiKeysForInvalidation = async ( + targets: InvalidationTarget[], + logger: Logger, + savedObjectsClient: SavedObjectsClientContract +): Promise => { + if (targets.length === 0) { + return; + } + + try { + await savedObjectsClient.bulkCreate( + targets.map((target) => ({ + attributes: { + apiKeyId: target.apiKeyId, + createdAt: new Date().toISOString(), + ...(target.uiamApiKey ? { uiamApiKey: target.uiamApiKey } : {}), + }, + type: INVALIDATE_API_KEY_SO_NAME, + })) + ); + } catch (e) { + logger.error(`Failed to bulk mark ${targets.length} API keys for invalidation: ${e.message}`); + } +}; diff --git a/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/create_api_key_strategy.test.ts b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/create_api_key_strategy.test.ts new file mode 100644 index 0000000000000..afbc84b756d8e --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/create_api_key_strategy.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { coreMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { ApiKeyType } from '../config'; +import { createApiKeyStrategy } from './create_api_key_strategy'; +import { EsApiKeyStrategy } from './es_api_key_strategy'; +import { EsAndUiamApiKeyStrategy } from './es_and_uiam_api_key_strategy'; + +describe('createApiKeyStrategy', () => { + const logger = loggingSystemMock.createLogger(); + + const uiamAvailable = () => { + const coreStart = coreMock.createStart(); + coreStart.security.authc.apiKeys.uiam = { + grant: jest.fn(), + invalidate: jest.fn(), + convert: jest.fn(), + } as never; + return coreStart; + }; + + test('returns EsApiKeyStrategy when UIAM is not available', () => { + const coreStart = coreMock.createStart(); + coreStart.security.authc.apiKeys.uiam = null as never; + + const strategy = createApiKeyStrategy(ApiKeyType.ES, true, coreStart.security, logger); + expect(strategy).toBeInstanceOf(EsApiKeyStrategy); + }); + + test('returns EsApiKeyStrategy when UIAM is available but grantUiamApiKeys is false', () => { + const coreStart = uiamAvailable(); + + const strategy = createApiKeyStrategy(ApiKeyType.ES, false, coreStart.security, logger); + expect(strategy).toBeInstanceOf(EsApiKeyStrategy); + }); + + test('returns EsApiKeyStrategy when UIAM is available, grantUiamApiKeys is false, even if api_key_type is uiam', () => { + const coreStart = uiamAvailable(); + + const strategy = createApiKeyStrategy(ApiKeyType.UIAM, false, coreStart.security, logger); + expect(strategy).toBeInstanceOf(EsApiKeyStrategy); + }); + + test('returns EsAndUiamApiKeyStrategy when UIAM is available and grantUiamApiKeys is true', () => { + const coreStart = uiamAvailable(); + + const strategy = createApiKeyStrategy(ApiKeyType.ES, true, coreStart.security, logger); + expect(strategy).toBeInstanceOf(EsAndUiamApiKeyStrategy); + }); + + test('passes apiKeyType to EsAndUiamApiKeyStrategy', () => { + const coreStart = uiamAvailable(); + + const strategy = createApiKeyStrategy(ApiKeyType.UIAM, true, coreStart.security, logger); + expect(strategy.typeToUse).toBe(ApiKeyType.UIAM); + }); +}); diff --git a/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/create_api_key_strategy.ts b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/create_api_key_strategy.ts new file mode 100644 index 0000000000000..08537d379bee8 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/create_api_key_strategy.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger, SecurityServiceStart } from '@kbn/core/server'; +import type { ApiKeyType } from '../config'; +import type { ApiKeyStrategy } from './api_key_strategy'; +import { EsApiKeyStrategy } from './es_api_key_strategy'; +import { EsAndUiamApiKeyStrategy } from './es_and_uiam_api_key_strategy'; + +export const createApiKeyStrategy = ( + apiKeyType: ApiKeyType, + grantUiamApiKeys: boolean, + security: SecurityServiceStart, + logger: Logger +): ApiKeyStrategy => { + if (grantUiamApiKeys && security.authc.apiKeys.uiam) { + return new EsAndUiamApiKeyStrategy(apiKeyType, security, logger); + } + return new EsApiKeyStrategy(); +}; diff --git a/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/es_and_uiam_api_key_strategy.test.ts b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/es_and_uiam_api_key_strategy.test.ts new file mode 100644 index 0000000000000..34a983f969d66 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/es_and_uiam_api_key_strategy.test.ts @@ -0,0 +1,364 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { coreMock, loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { httpServerMock } from '@kbn/core-http-server-mocks'; +import { ApiKeyType } from '../config'; +import type { ConcreteTaskInstance } from '../task'; +import { TaskStatus } from '../task'; +import { EsAndUiamApiKeyStrategy } from './es_and_uiam_api_key_strategy'; + +import { createApiKey, requestHasApiKey, getApiKeyFromRequest } from '../lib/api_key_utils'; + +jest.mock('../lib/api_key_utils'); +const createApiKeyMock = createApiKey as jest.MockedFunction; +const requestHasApiKeyMock = requestHasApiKey as jest.MockedFunction; +const getApiKeyFromRequestMock = getApiKeyFromRequest as jest.MockedFunction< + typeof getApiKeyFromRequest +>; + +const mockTaskInstance = (overrides: Partial = {}): ConcreteTaskInstance => ({ + id: 'task-1', + taskType: 'report', + params: {}, + state: {}, + scheduledAt: new Date(), + attempts: 0, + status: TaskStatus.Running, + runAt: new Date(), + startedAt: new Date(), + retryAt: null, + ownerId: null, + ...overrides, +}); + +describe('EsAndUiamApiKeyStrategy', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createStrategy = (typeToUse: ApiKeyType = ApiKeyType.UIAM) => { + const coreStart = coreMock.createStart(); + const logger = loggingSystemMock.createLogger(); + const mockUiam = { + grant: jest.fn(), + invalidate: jest.fn(), + convert: jest.fn(), + }; + coreStart.security.authc.apiKeys.uiam = mockUiam as never; + + const strategy = new EsAndUiamApiKeyStrategy(typeToUse, coreStart.security, logger); + return { strategy, coreStart, mockUiam, logger }; + }; + + test('shouldGrantUiam is true', () => { + const { strategy } = createStrategy(); + expect(strategy.shouldGrantUiam).toBe(true); + }); + + test('typeToUse reflects the config value', () => { + const { strategy: uiamStrategy } = createStrategy(ApiKeyType.UIAM); + expect(uiamStrategy.typeToUse).toBe(ApiKeyType.UIAM); + + const { strategy: esStrategy } = createStrategy(ApiKeyType.ES); + expect(esStrategy.typeToUse).toBe(ApiKeyType.ES); + }); + + describe('getApiKeyForFakeRequest', () => { + test('returns uiamApiKey when typeToUse is UIAM and uiamApiKey exists', () => { + const { strategy } = createStrategy(ApiKeyType.UIAM); + const task = mockTaskInstance({ apiKey: 'es-key', uiamApiKey: 'essu_uiam-key' }); + + expect(strategy.getApiKeyForFakeRequest(task)).toBe('essu_uiam-key'); + }); + + test('falls back to apiKey and warns when typeToUse is UIAM but uiamApiKey is missing and apiKeyCreatedByUser is false', () => { + const { strategy, logger } = createStrategy(ApiKeyType.UIAM); + const task = mockTaskInstance({ + apiKey: 'es-key', + userScope: { + apiKeyId: 'es-key-id', + apiKeyCreatedByUser: false, + spaceId: 'default', + }, + }); + + expect(strategy.getApiKeyForFakeRequest(task)).toBe('es-key'); + expect(logger.warn).toHaveBeenCalledWith( + 'UIAM API key is not provided to create a fake request, falling back to regular API key.', + expect.objectContaining({ tags: expect.any(Array) }) + ); + expect(logger.debug).not.toHaveBeenCalled(); + }); + + test('falls back to apiKey and warns when typeToUse is UIAM but uiamApiKey is missing and userScope is absent', () => { + const { strategy, logger } = createStrategy(ApiKeyType.UIAM); + const task = mockTaskInstance({ apiKey: 'es-key' }); + + expect(strategy.getApiKeyForFakeRequest(task)).toBe('es-key'); + expect(logger.warn).toHaveBeenCalledWith( + 'UIAM API key is not provided to create a fake request, falling back to regular API key.', + expect.objectContaining({ tags: expect.any(Array) }) + ); + expect(logger.debug).not.toHaveBeenCalled(); + }); + + test('falls back to apiKey with a debug log when uiamApiKey is missing but apiKeyCreatedByUser is true', () => { + const { strategy, logger } = createStrategy(ApiKeyType.UIAM); + const task = mockTaskInstance({ + apiKey: 'es-key', + userScope: { + apiKeyId: 'es-key-id', + apiKeyCreatedByUser: true, + spaceId: 'default', + }, + }); + + expect(strategy.getApiKeyForFakeRequest(task)).toBe('es-key'); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + 'UIAM API key is not provided to create a fake request, falling back to ES API key created by the user.', + expect.objectContaining({ tags: expect.any(Array) }) + ); + }); + + test('returns apiKey when typeToUse is ES even if uiamApiKey exists', () => { + const { strategy, logger } = createStrategy(ApiKeyType.ES); + const task = mockTaskInstance({ apiKey: 'es-key', uiamApiKey: 'essu_uiam-key' }); + + expect(strategy.getApiKeyForFakeRequest(task)).toBe('es-key'); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + }); + + test('returns undefined and does not log when task has no keys', () => { + const { strategy, logger } = createStrategy(ApiKeyType.UIAM); + const task = mockTaskInstance(); + + expect(strategy.getApiKeyForFakeRequest(task)).toBeUndefined(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + }); + }); + + describe('getApiKeyIdsForInvalidation', () => { + test('returns both ES and UIAM targets when both exist', () => { + const { strategy } = createStrategy(); + const task = mockTaskInstance({ + apiKey: 'es-key', + uiamApiKey: 'essu_uiam-key', + userScope: { + apiKeyId: 'es-key-id', + uiamApiKeyId: 'uiam-key-id', + spaceId: 'default', + apiKeyCreatedByUser: false, + }, + }); + + expect(strategy.getApiKeyIdsForInvalidation(task)).toEqual([ + { apiKeyId: 'es-key-id' }, + { apiKeyId: 'uiam-key-id', uiamApiKey: 'essu_uiam-key' }, + ]); + }); + + test('returns only ES target when UIAM key is missing', () => { + const { strategy } = createStrategy(); + const task = mockTaskInstance({ + apiKey: 'es-key', + userScope: { + apiKeyId: 'es-key-id', + spaceId: 'default', + apiKeyCreatedByUser: false, + }, + }); + + expect(strategy.getApiKeyIdsForInvalidation(task)).toEqual([{ apiKeyId: 'es-key-id' }]); + }); + + test('returns empty array when userScope is missing', () => { + const { strategy } = createStrategy(); + const task = mockTaskInstance({ apiKey: 'es-key' }); + + expect(strategy.getApiKeyIdsForInvalidation(task)).toEqual([]); + }); + + test('returns empty array when apiKeyCreatedByUser is true', () => { + const { strategy } = createStrategy(); + const task = mockTaskInstance({ + apiKey: 'es-key', + uiamApiKey: 'essu_uiam-key', + userScope: { + apiKeyId: 'es-key-id', + uiamApiKeyId: 'uiam-key-id', + spaceId: 'default', + apiKeyCreatedByUser: true, + }, + }); + + expect(strategy.getApiKeyIdsForInvalidation(task)).toEqual([]); + }); + + test('returns only ES target when uiamApiKeyId exists but uiamApiKey is missing', () => { + const { strategy } = createStrategy(); + const task = mockTaskInstance({ + apiKey: 'es-key', + userScope: { + apiKeyId: 'es-key-id', + uiamApiKeyId: 'uiam-key-id', + spaceId: 'default', + apiKeyCreatedByUser: false, + }, + }); + + expect(strategy.getApiKeyIdsForInvalidation(task)).toEqual([{ apiKeyId: 'es-key-id' }]); + }); + }); + + describe('grantApiKeys', () => { + test('grants both ES and UIAM keys when request has UIAM credential', async () => { + const { strategy, coreStart, mockUiam } = createStrategy(); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'ApiKey essu_uiam-credential' }, + }); + const basePath = coreStart.http.basePath; + + const esKeyMap = new Map(); + esKeyMap.set('task-1', { + apiKey: Buffer.from('esId:esSecret').toString('base64'), + apiKeyId: 'esId', + }); + createApiKeyMock.mockResolvedValueOnce(esKeyMap); + requestHasApiKeyMock.mockReturnValue(false); + (coreStart.security.authc.getCurrentUser as jest.Mock).mockReturnValue({ + username: 'testuser', + }); + + mockUiam.grant.mockResolvedValueOnce({ + id: 'uiamId', + name: 'test', + api_key: 'essu_uiam-secret', + }); + + const tasks = [{ id: 'task-1', taskType: 'report', params: {}, state: {} }]; + const result = await strategy.grantApiKeys(tasks, request, coreStart.security, basePath); + + const fields = result.get('task-1'); + expect(fields?.apiKey).toBe(Buffer.from('esId:esSecret').toString('base64')); + expect(fields?.uiamApiKey).toBe('essu_uiam-secret'); + expect(fields?.userScope.apiKeyId).toBe('esId'); + expect(fields?.userScope.uiamApiKeyId).toBe('uiamId'); + }); + + test('grants only ES keys when request credential is not UIAM-compatible', async () => { + const { strategy, coreStart, mockUiam, logger } = createStrategy(); + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic dXNlcjpwYXNz' }, + }); + const basePath = coreStart.http.basePath; + + const esKeyMap = new Map(); + esKeyMap.set('task-1', { + apiKey: Buffer.from('esId:esSecret').toString('base64'), + apiKeyId: 'esId', + }); + createApiKeyMock.mockResolvedValueOnce(esKeyMap); + requestHasApiKeyMock.mockReturnValue(false); + + const tasks = [{ id: 'task-1', taskType: 'report', params: {}, state: {} }]; + const result = await strategy.grantApiKeys(tasks, request, coreStart.security, basePath); + + const fields = result.get('task-1'); + expect(fields?.apiKey).toBe(Buffer.from('esId:esSecret').toString('base64')); + expect(fields?.uiamApiKey).toBeUndefined(); + expect(fields?.userScope.uiamApiKeyId).toBeUndefined(); + expect(mockUiam.grant).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + 'Request credential is not UIAM-compatible, skipping UIAM API key grant. Only ES API keys will be used.', + { tags: ['serverless', 'task-manager', 'uiam', 'uiam-api-key-invalid-credentials'] } + ); + }); + + test('extracts UIAM key from request when user provides UIAM credential', async () => { + const { strategy, coreStart } = createStrategy(); + const request = httpServerMock.createKibanaRequest(); + const basePath = coreStart.http.basePath; + + const esKeyMap = new Map(); + esKeyMap.set('task-1', { + apiKey: Buffer.from('esId:esSecret').toString('base64'), + apiKeyId: 'esId', + }); + createApiKeyMock.mockResolvedValueOnce(esKeyMap); + requestHasApiKeyMock.mockReturnValue(true); + getApiKeyFromRequestMock.mockReturnValue({ + id: 'uiam-req-id', + api_key: 'essu_from-request', + }); + + const tasks = [{ id: 'task-1', taskType: 'report', params: {}, state: {} }]; + const result = await strategy.grantApiKeys(tasks, request, coreStart.security, basePath); + + const fields = result.get('task-1'); + expect(fields?.uiamApiKey).toBe('essu_from-request'); + expect(fields?.userScope.uiamApiKeyId).toBe('uiam-req-id'); + }); + + test('does not set uiamApiKey when request has non-UIAM api key', async () => { + const { strategy, coreStart } = createStrategy(); + const request = httpServerMock.createKibanaRequest(); + const basePath = coreStart.http.basePath; + + const esKeyMap = new Map(); + esKeyMap.set('task-1', { + apiKey: Buffer.from('esId:esSecret').toString('base64'), + apiKeyId: 'esId', + }); + createApiKeyMock.mockResolvedValueOnce(esKeyMap); + requestHasApiKeyMock.mockReturnValue(true); + getApiKeyFromRequestMock.mockReturnValue({ + id: 'es-req-id', + api_key: 'regular-es-secret', + }); + + const tasks = [{ id: 'task-1', taskType: 'report', params: {}, state: {} }]; + const result = await strategy.grantApiKeys(tasks, request, coreStart.security, basePath); + + const fields = result.get('task-1'); + expect(fields?.uiamApiKey).toBeUndefined(); + expect(fields?.userScope.uiamApiKeyId).toBeUndefined(); + }); + }); + + describe('markForInvalidation', () => { + test('creates invalidation SOs with uiamApiKey for UIAM targets', async () => { + const { strategy } = createStrategy(); + const logger = loggingSystemMock.createLogger(); + const soClient = savedObjectsClientMock.create(); + + await strategy.markForInvalidation( + [{ apiKeyId: 'es-key-id' }, { apiKeyId: 'uiam-key-id', uiamApiKey: 'essu_uiam-key' }], + logger, + soClient + ); + + expect(soClient.bulkCreate).toHaveBeenCalledWith([ + { + attributes: { apiKeyId: 'es-key-id', createdAt: expect.any(String) }, + type: 'api_key_to_invalidate', + }, + { + attributes: { + apiKeyId: 'uiam-key-id', + createdAt: expect.any(String), + uiamApiKey: 'essu_uiam-key', + }, + type: 'api_key_to_invalidate', + }, + ]); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/es_and_uiam_api_key_strategy.ts b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/es_and_uiam_api_key_strategy.ts new file mode 100644 index 0000000000000..eb4e7cd10c852 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/es_and_uiam_api_key_strategy.ts @@ -0,0 +1,232 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + Logger, + SecurityServiceStart, + IBasePath, + KibanaRequest, + SavedObjectsClientContract, +} from '@kbn/core/server'; +import { HTTPAuthorizationHeader, isUiamCredential } from '@kbn/core-security-server'; +import { truncate } from 'lodash'; +import { getSpaceIdFromPath } from '@kbn/spaces-utils'; +import { ApiKeyType } from '../config'; +import type { ConcreteTaskInstance, TaskInstance } from '../task'; +import { createApiKey, requestHasApiKey, getApiKeyFromRequest } from '../lib/api_key_utils'; +import type { ApiKeySOFields, ApiKeyStrategy, InvalidationTarget } from './api_key_strategy'; +import { markApiKeysForInvalidation } from './api_key_strategy'; +import { + UIAM_LOGS_CREDENTIALS_TAGS, + UIAM_LOGS_GRANT_TAGS, + UIAM_LOGS_USAGE_TAGS, +} from '../constants'; + +interface UiamApiKeyResult { + apiKey: string; + apiKeyId: string; +} + +export class EsAndUiamApiKeyStrategy implements ApiKeyStrategy { + public readonly shouldGrantUiam = true; + public readonly typeToUse: ApiKeyType; + private readonly security: SecurityServiceStart; + private readonly logger: Logger; + + constructor(apiKeyType: ApiKeyType, security: SecurityServiceStart, logger: Logger) { + this.typeToUse = apiKeyType; + this.security = security; + this.logger = logger; + } + + async grantApiKeys( + taskInstances: TaskInstance[], + request: KibanaRequest, + security: SecurityServiceStart, + basePath: IBasePath + ): Promise> { + const esKeys = await createApiKey(taskInstances, request, security); + const uiamKeys = await this.grantUiamApiKeys(taskInstances, request, security); + + const requestBasePath = basePath.get(request); + const space = getSpaceIdFromPath(requestBasePath, basePath.serverBasePath); + // `apiKeyCreatedByUser` is derived from whether the incoming request is + // authenticated with an API key (ES or UIAM). It is stored on `userScope` + // and is used by `getApiKeyIdsForInvalidation` to short-circuit invalidation + // of BOTH the ES and UIAM keys associated with this task. + // + // Invariant: when this flag is true, the same flag must govern invalidation + // for every credential (ES and UIAM) that this strategy persists on the task. + // This is safe today because we only attach a UIAM key when the request is + // either UIAM-authenticated (reused as-is) or credential-less (granted anew), + // and in both cases `apiKeyCreatedByUser` correctly reflects ownership for + // both credentials. If future changes allow the ES and UIAM credentials to + // have different ownership (e.g., mint a new UIAM key while reusing a + // caller-supplied ES key), this invariant breaks and both fields must become + // independent flags on `userScope` (e.g., `esApiKeyCreatedByUser` / + // `uiamApiKeyCreatedByUser`) with matching per-credential checks in + // `getApiKeyIdsForInvalidation`. + const apiKeyCreatedByUser = requestHasApiKey(security, request); + + const result = new Map(); + taskInstances.forEach((task) => { + const esKey = esKeys.get(task.id!); + if (esKey) { + const uiamKey = uiamKeys.get(task.id!); + result.set(task.id!, { + apiKey: esKey.apiKey, + ...(uiamKey ? { uiamApiKey: uiamKey.apiKey } : {}), + userScope: { + apiKeyId: esKey.apiKeyId, + ...(uiamKey ? { uiamApiKeyId: uiamKey.apiKeyId } : {}), + spaceId: space?.spaceId || 'default', + apiKeyCreatedByUser, + }, + }); + } + }); + + return result; + } + + private async grantUiamApiKeys( + taskInstances: TaskInstance[], + request: KibanaRequest, + security: SecurityServiceStart + ): Promise> { + const uiam = this.security.authc.apiKeys.uiam; + const uiamKeyByTaskIdMap = new Map(); + + if (!uiam) { + return uiamKeyByTaskIdMap; + } + + if (requestHasApiKey(security, request)) { + const apiKeyResult = getApiKeyFromRequest(request); + if (apiKeyResult && isUiamCredential(apiKeyResult.api_key)) { + taskInstances.forEach((task) => { + uiamKeyByTaskIdMap.set(task.id!, { + apiKey: apiKeyResult.api_key, + apiKeyId: apiKeyResult.id, + }); + }); + } + return uiamKeyByTaskIdMap; + } + + const authorizationHeader = HTTPAuthorizationHeader.parseFromRequest(request); + if (!authorizationHeader || !isUiamCredential(authorizationHeader)) { + this.logger.debug( + 'Request credential is not UIAM-compatible, skipping UIAM API key grant. Only ES API keys will be used.', + { tags: UIAM_LOGS_CREDENTIALS_TAGS } + ); + return uiamKeyByTaskIdMap; + } + + const user = security.authc.getCurrentUser(request); + const taskTypes = [...new Set(taskInstances.map((task) => task.taskType))]; + const uiamKeyByTaskTypeMap = new Map(); + + for (const taskType of taskTypes) { + const apiKeyNamePrefix = `TaskManager-UIAM: ${taskType}`; + const apiKeyName = user ? `${apiKeyNamePrefix} - ${user.username}` : apiKeyNamePrefix; + + try { + const uiamResult = await uiam.grant(request, { + name: truncate(apiKeyName, { length: 256 }), + }); + + if (uiamResult) { + uiamKeyByTaskTypeMap.set(taskType, { + apiKey: uiamResult.api_key, + apiKeyId: uiamResult.id, + }); + } else { + this.logger.error(`Failed to create UIAM API key for task type: ${taskType}`, { + tags: UIAM_LOGS_GRANT_TAGS, + }); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + this.logger.error( + `Failed to create UIAM API key for task type: ${taskType}: ${errorMessage}`, + { tags: UIAM_LOGS_GRANT_TAGS } + ); + } + } + + taskInstances.forEach((task) => { + const uiamKeyResult = uiamKeyByTaskTypeMap.get(task.taskType); + if (uiamKeyResult) { + uiamKeyByTaskIdMap.set(task.id!, uiamKeyResult); + } + }); + + return uiamKeyByTaskIdMap; + } + + getApiKeyForFakeRequest(taskInstance: ConcreteTaskInstance): string | undefined { + if (this.typeToUse === ApiKeyType.UIAM) { + if (taskInstance.uiamApiKey) { + return taskInstance.uiamApiKey; + } + + // No UIAM key available even though the strategy is configured to use UIAM. + // Fall back to the ES API key so the task can still run, but emit + // observability so we can detect UIAM regressions in production: + // - If the task was scheduled with a user-supplied ES API key + // (`apiKeyCreatedByUser: true`), it is *expected* not to have a UIAM + // key attached. Emit a debug-level message. + // - Otherwise, the task should have had a UIAM key. Emit a warn-level + // message so the fallback is actioned. + // Mirrors the alerting rule loader behavior (see PR #264434). + const { userScope, apiKey } = taskInstance; + if (apiKey) { + if (userScope?.apiKeyCreatedByUser) { + this.logger.debug( + 'UIAM API key is not provided to create a fake request, falling back to ES API key created by the user.', + { tags: UIAM_LOGS_USAGE_TAGS } + ); + } else { + this.logger.warn( + 'UIAM API key is not provided to create a fake request, falling back to regular API key.', + { tags: UIAM_LOGS_USAGE_TAGS } + ); + } + } + return apiKey; + } + return taskInstance.apiKey; + } + + getApiKeyIdsForInvalidation(taskInstance: ConcreteTaskInstance): InvalidationTarget[] { + const { userScope, uiamApiKey } = taskInstance; + // `apiKeyCreatedByUser` gates invalidation for BOTH the ES and UIAM keys. + // See the invariant documented in `grantApiKeys`: both credentials are + // currently persisted with the same ownership, so a single flag is + // sufficient. Revisit if ES and UIAM credentials ever diverge in ownership. + if (!userScope || userScope.apiKeyCreatedByUser) { + return []; + } + + const targets: InvalidationTarget[] = [{ apiKeyId: userScope.apiKeyId }]; + + if (userScope.uiamApiKeyId && uiamApiKey) { + targets.push({ apiKeyId: userScope.uiamApiKeyId, uiamApiKey }); + } + + return targets; + } + + async markForInvalidation( + targets: InvalidationTarget[], + logger: Logger, + savedObjectsClient: SavedObjectsClientContract + ): Promise { + return markApiKeysForInvalidation(targets, logger, savedObjectsClient); + } +} diff --git a/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/es_api_key_strategy.test.ts b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/es_api_key_strategy.test.ts new file mode 100644 index 0000000000000..961f773a1f3f2 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/es_api_key_strategy.test.ts @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { ApiKeyType } from '../config'; +import type { ConcreteTaskInstance } from '../task'; +import { TaskStatus } from '../task'; +import { EsApiKeyStrategy } from './es_api_key_strategy'; + +const mockTaskInstance = (overrides: Partial = {}): ConcreteTaskInstance => ({ + id: 'task-1', + taskType: 'report', + params: {}, + state: {}, + scheduledAt: new Date(), + attempts: 0, + status: TaskStatus.Running, + runAt: new Date(), + startedAt: new Date(), + retryAt: null, + ownerId: null, + ...overrides, +}); + +describe('EsApiKeyStrategy', () => { + const strategy = new EsApiKeyStrategy(); + + test('shouldGrantUiam is false', () => { + expect(strategy.shouldGrantUiam).toBe(false); + }); + + test('typeToUse is ES', () => { + expect(strategy.typeToUse).toBe(ApiKeyType.ES); + }); + + describe('getApiKeyForFakeRequest', () => { + test('returns apiKey from task instance', () => { + const task = mockTaskInstance({ apiKey: 'es-encoded-key' }); + expect(strategy.getApiKeyForFakeRequest(task)).toBe('es-encoded-key'); + }); + + test('returns undefined when task has no apiKey', () => { + const task = mockTaskInstance(); + expect(strategy.getApiKeyForFakeRequest(task)).toBeUndefined(); + }); + }); + + describe('getApiKeyIdsForInvalidation', () => { + test('returns ES apiKeyId from userScope', () => { + const task = mockTaskInstance({ + apiKey: 'es-key', + userScope: { + apiKeyId: 'es-key-id', + spaceId: 'default', + apiKeyCreatedByUser: false, + }, + }); + + expect(strategy.getApiKeyIdsForInvalidation(task)).toEqual([{ apiKeyId: 'es-key-id' }]); + }); + + test('returns empty array when userScope is missing', () => { + const task = mockTaskInstance({ apiKey: 'es-key' }); + expect(strategy.getApiKeyIdsForInvalidation(task)).toEqual([]); + }); + + test('returns empty array when apiKeyCreatedByUser is true', () => { + const task = mockTaskInstance({ + apiKey: 'es-key', + userScope: { + apiKeyId: 'es-key-id', + spaceId: 'default', + apiKeyCreatedByUser: true, + }, + }); + + expect(strategy.getApiKeyIdsForInvalidation(task)).toEqual([]); + }); + + test('also emits a UIAM invalidation target when task has a residual uiamApiKey (post-rollback)', () => { + // Simulates a task scheduled under EsAndUiamApiKeyStrategy that now has + // a lingering UIAM key after the deployment rolled back to EsApiKeyStrategy. + const task = mockTaskInstance({ + apiKey: 'es-key', + uiamApiKey: 'essu_uiam-key', + userScope: { + apiKeyId: 'es-key-id', + uiamApiKeyId: 'uiam-key-id', + spaceId: 'default', + apiKeyCreatedByUser: false, + }, + }); + + expect(strategy.getApiKeyIdsForInvalidation(task)).toEqual([ + { apiKeyId: 'es-key-id' }, + { apiKeyId: 'uiam-key-id', uiamApiKey: 'essu_uiam-key' }, + ]); + }); + + test('does not emit UIAM target when userScope.uiamApiKeyId is present but uiamApiKey value is missing', () => { + const task = mockTaskInstance({ + apiKey: 'es-key', + userScope: { + apiKeyId: 'es-key-id', + uiamApiKeyId: 'uiam-key-id', + spaceId: 'default', + apiKeyCreatedByUser: false, + }, + }); + + expect(strategy.getApiKeyIdsForInvalidation(task)).toEqual([{ apiKeyId: 'es-key-id' }]); + }); + + test('returns empty array when apiKeyCreatedByUser is true even if a residual uiamApiKey is present', () => { + const task = mockTaskInstance({ + apiKey: 'es-key', + uiamApiKey: 'essu_uiam-key', + userScope: { + apiKeyId: 'es-key-id', + uiamApiKeyId: 'uiam-key-id', + spaceId: 'default', + apiKeyCreatedByUser: true, + }, + }); + + expect(strategy.getApiKeyIdsForInvalidation(task)).toEqual([]); + }); + }); + + describe('markForInvalidation', () => { + test('creates invalidation SOs for ES keys', async () => { + const logger = loggingSystemMock.createLogger(); + const soClient = savedObjectsClientMock.create(); + + await strategy.markForInvalidation( + [{ apiKeyId: 'key-1' }, { apiKeyId: 'key-2' }], + logger, + soClient + ); + + expect(soClient.bulkCreate).toHaveBeenCalledWith([ + { + attributes: { apiKeyId: 'key-1', createdAt: expect.any(String) }, + type: 'api_key_to_invalidate', + }, + { + attributes: { apiKeyId: 'key-2', createdAt: expect.any(String) }, + type: 'api_key_to_invalidate', + }, + ]); + }); + + test('does nothing when targets array is empty', async () => { + const logger = loggingSystemMock.createLogger(); + const soClient = savedObjectsClientMock.create(); + + await strategy.markForInvalidation([], logger, soClient); + + expect(soClient.bulkCreate).not.toHaveBeenCalled(); + }); + + test('logs error when bulkCreate fails', async () => { + const logger = loggingSystemMock.createLogger(); + const soClient = savedObjectsClientMock.create(); + soClient.bulkCreate.mockRejectedValueOnce(new Error('SO error')); + + await strategy.markForInvalidation([{ apiKeyId: 'key-1' }], logger, soClient); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to bulk mark 1 API keys for invalidation') + ); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/es_api_key_strategy.ts b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/es_api_key_strategy.ts new file mode 100644 index 0000000000000..c4b563af3a83b --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/es_api_key_strategy.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + Logger, + SecurityServiceStart, + IBasePath, + KibanaRequest, + SavedObjectsClientContract, +} from '@kbn/core/server'; +import { ApiKeyType } from '../config'; +import type { ConcreteTaskInstance, TaskInstance } from '../task'; +import { getApiKeyAndUserScope } from '../lib/api_key_utils'; +import type { ApiKeySOFields, ApiKeyStrategy, InvalidationTarget } from './api_key_strategy'; +import { markApiKeysForInvalidation } from './api_key_strategy'; + +export class EsApiKeyStrategy implements ApiKeyStrategy { + public readonly shouldGrantUiam = false; + public readonly typeToUse = ApiKeyType.ES; + + async grantApiKeys( + taskInstances: TaskInstance[], + request: KibanaRequest, + security: SecurityServiceStart, + basePath: IBasePath + ): Promise> { + return getApiKeyAndUserScope(taskInstances, request, security, basePath); + } + + getApiKeyForFakeRequest(taskInstance: ConcreteTaskInstance): string | undefined { + return taskInstance.apiKey; + } + + getApiKeyIdsForInvalidation(taskInstance: ConcreteTaskInstance): InvalidationTarget[] { + const { userScope, uiamApiKey } = taskInstance; + if (!userScope || userScope.apiKeyCreatedByUser) { + return []; + } + + const targets: InvalidationTarget[] = [{ apiKeyId: userScope.apiKeyId }]; + + // Defense-in-depth for rollbacks / config changes: a deployment may have + // previously run with `EsAndUiamApiKeyStrategy` and persisted a UIAM key + // alongside the ES key on existing tasks. If the deployment later falls + // back to `EsApiKeyStrategy` (e.g., `api_key_type` flipped back to `es`, + // or UIAM disabled), those UIAM keys would otherwise become orphaned and + // never be invalidated. Still emit an invalidation target for them so the + // invalidation task can clean them up. Invalidation is best-effort and + // idempotent, so it is safe to always emit when both values are present. + if (userScope.uiamApiKeyId && uiamApiKey) { + targets.push({ apiKeyId: userScope.uiamApiKeyId, uiamApiKey }); + } + + return targets; + } + + async markForInvalidation( + targets: InvalidationTarget[], + logger: Logger, + savedObjectsClient: SavedObjectsClientContract + ): Promise { + return markApiKeysForInvalidation(targets, logger, savedObjectsClient); + } +} diff --git a/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/index.ts b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/index.ts new file mode 100644 index 0000000000000..33ebe68c36447 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/server/api_key_strategy/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + ApiKeyStrategy, + ApiKeySOFields, + InvalidationTarget, + ApiKeyType, +} from './api_key_strategy'; +export { markApiKeysForInvalidation } from './api_key_strategy'; +export { EsApiKeyStrategy } from './es_api_key_strategy'; +export { EsAndUiamApiKeyStrategy } from './es_and_uiam_api_key_strategy'; +export { createApiKeyStrategy } from './create_api_key_strategy'; diff --git a/x-pack/platform/plugins/shared/task_manager/server/config.test.ts b/x-pack/platform/plugins/shared/task_manager/server/config.test.ts index ea49e62dbc63f..cade3ede1ad81 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/config.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/config.test.ts @@ -24,6 +24,7 @@ describe('config validation', () => { "monitor": true, "warn_threshold": 5000, }, + "grant_uiam_api_keys": false, "invalidate_api_key_task": Object { "interval": "5m", "removalDelay": "1h", @@ -87,6 +88,7 @@ describe('config validation', () => { "monitor": true, "warn_threshold": 5000, }, + "grant_uiam_api_keys": false, "invalidate_api_key_task": Object { "interval": "5m", "removalDelay": "1h", @@ -148,6 +150,7 @@ describe('config validation', () => { "monitor": true, "warn_threshold": 5000, }, + "grant_uiam_api_keys": false, "invalidate_api_key_task": Object { "interval": "5m", "removalDelay": "1h", diff --git a/x-pack/platform/plugins/shared/task_manager/server/config.ts b/x-pack/platform/plugins/shared/task_manager/server/config.ts index 843ad6c8cc469..bc566bd5d1500 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/config.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/config.ts @@ -97,6 +97,8 @@ export const configSchema = schema.object( api_key_type: schema.oneOf([schema.literal(ApiKeyType.ES), schema.literal(ApiKeyType.UIAM)], { defaultValue: ApiKeyType.ES, }), + /* Whether Task Manager should grant and persist UIAM API keys. Usage of granted UIAM keys is still governed by api_key_type. */ + grant_uiam_api_keys: schema.boolean({ defaultValue: false }), /* The number of normal cost tasks that this Kibana instance will run simultaneously */ capacity: schema.maybe(schema.number({ min: MIN_CAPACITY, max: MAX_CAPACITY })), discovery: schema.object({ diff --git a/x-pack/platform/plugins/shared/task_manager/server/constants.ts b/x-pack/platform/plugins/shared/task_manager/server/constants.ts index c5068ecf9f7a7..20c41b8694ce2 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/constants.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/constants.ts @@ -30,3 +30,13 @@ export enum EventLogOutcomes { success = 'success', failure = 'failure', } + +const UIAM_LOGS_COMMON_TAGS = ['serverless', 'task-manager', 'uiam']; + +export const UIAM_LOGS_GRANT_TAGS = [...UIAM_LOGS_COMMON_TAGS, 'uiam-api-key-grant']; +export const UIAM_LOGS_INVALIDATE_TAGS = [...UIAM_LOGS_COMMON_TAGS, 'uiam-api-key-invalidate']; +export const UIAM_LOGS_CREDENTIALS_TAGS = [ + ...UIAM_LOGS_COMMON_TAGS, + 'uiam-api-key-invalid-credentials', +]; +export const UIAM_LOGS_USAGE_TAGS = [...UIAM_LOGS_COMMON_TAGS, 'uiam-api-key-missing']; diff --git a/x-pack/platform/plugins/shared/task_manager/server/integration_tests/managed_configuration.test.ts b/x-pack/platform/plugins/shared/task_manager/server/integration_tests/managed_configuration.test.ts index bdb5edf4921eb..c1eb20797d1de 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/integration_tests/managed_configuration.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/integration_tests/managed_configuration.test.ts @@ -107,6 +107,7 @@ describe('managed configuration', () => { }, auto_calculate_default_ech_capacity: false, api_key_type: ApiKeyType.ES, + grant_uiam_api_keys: false, }; async function runSetTimeout0() { 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 24269cc36ddd3..767401b581d02 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 @@ -13,6 +13,7 @@ import type { InvalidateUiamAPIKeyParams, } from '@kbn/security-plugin-types-server'; import type { KibanaRequest } from '@kbn/core/server'; +import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-shared'; import type { TaskScheduling } from '../task_scheduling'; import type { TaskTypeDictionary } from '../task_type_dictionary'; import { INVALIDATE_API_KEY_SO_NAME, TASK_SO_NAME } from '../saved_objects'; @@ -53,7 +54,9 @@ export async function scheduleInvalidateApiKeyTask( interface RegisterInvalidateApiKeyTaskOpts { configInterval: string; coreStartServices: () => Promise<[CoreStart, TaskManagerPluginsStart, TaskManagerStartContract]>; + getEncryptedSavedObjectsClient: () => EncryptedSavedObjectsClient | undefined; invalidateApiKeyFn?: ApiKeyInvalidationFn; + invalidateUiamApiKeyFn?: () => UiamApiKeyInvalidationFn | undefined; logger: Logger; removalDelay: string; taskTypeDictionary: TaskTypeDictionary; @@ -64,7 +67,9 @@ export function registerInvalidateApiKeyTask(opts: RegisterInvalidateApiKeyTaskO logger, configInterval, coreStartServices, + getEncryptedSavedObjectsClient, invalidateApiKeyFn, + invalidateUiamApiKeyFn, removalDelay, taskTypeDictionary, } = opts; @@ -75,7 +80,9 @@ export function registerInvalidateApiKeyTask(opts: RegisterInvalidateApiKeyTaskO logger, configInterval, coreStartServices, + getEncryptedSavedObjectsClient, invalidateApiKeyFn, + invalidateUiamApiKeyFn, removalDelay, }), }, @@ -84,7 +91,13 @@ export function registerInvalidateApiKeyTask(opts: RegisterInvalidateApiKeyTaskO type InvalidateApiKeysTaskRunnerOpts = Pick< RegisterInvalidateApiKeyTaskOpts, - 'logger' | 'configInterval' | 'coreStartServices' | 'invalidateApiKeyFn' | 'removalDelay' + | 'logger' + | 'configInterval' + | 'coreStartServices' + | 'getEncryptedSavedObjectsClient' + | 'invalidateApiKeyFn' + | 'invalidateUiamApiKeyFn' + | 'removalDelay' >; interface InvalidateApiKeysTaskState { @@ -92,8 +105,15 @@ interface InvalidateApiKeysTaskState { } export function taskRunner(opts: InvalidateApiKeysTaskRunnerOpts) { - const { logger, configInterval, coreStartServices, invalidateApiKeyFn, removalDelay } = opts; - + const { + logger, + configInterval, + coreStartServices, + getEncryptedSavedObjectsClient, + invalidateApiKeyFn, + invalidateUiamApiKeyFn, + removalDelay, + } = opts; return ({ taskInstance }: { taskInstance: { state: InvalidateApiKeysTaskState } }) => { return { async run() { @@ -105,7 +125,9 @@ export function taskRunner(opts: InvalidateApiKeysTaskRunnerOpts) { ]); const result = await runInvalidate({ + encryptedSavedObjectsClient: getEncryptedSavedObjectsClient(), invalidateApiKeyFn, + invalidateUiamApiKeyFn: invalidateUiamApiKeyFn?.(), logger, missingApiKeyRetries, removalDelay, diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.test.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.test.ts index 56bd828a0c457..6697af0b96fb9 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.test.ts @@ -204,7 +204,8 @@ describe('invalidateApiKeysAndDeletePendingApiKeySavedObject', () => { }); expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('UIAM APIKey is already revoked') + expect.stringContaining('UIAM APIKey is already revoked'), + expect.objectContaining({ tags: expect.arrayContaining(['uiam-api-key-invalidate']) }) ); expect(internalSavedObjectsRepository.delete).toHaveBeenCalledWith( 'api_key_pending_invalidation', @@ -234,7 +235,8 @@ describe('invalidateApiKeysAndDeletePendingApiKeySavedObject', () => { }); expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('UIAM APIKey not found, will retry (1/5)') + expect.stringContaining('UIAM APIKey not found, will retry (1/5)'), + expect.objectContaining({ tags: expect.arrayContaining(['uiam-api-key-invalidate']) }) ); expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); expect(result.totalInvalidated).toEqual(0); @@ -262,7 +264,8 @@ describe('invalidateApiKeysAndDeletePendingApiKeySavedObject', () => { }); expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('UIAM APIKey not found after 5 attempts') + expect.stringContaining('UIAM APIKey not found after 5 attempts'), + expect.objectContaining({ tags: expect.arrayContaining(['uiam-api-key-invalidate']) }) ); expect(internalSavedObjectsRepository.delete).toHaveBeenCalledWith( 'api_key_pending_invalidation', @@ -343,7 +346,8 @@ describe('invalidateApiKeysAndDeletePendingApiKeySavedObject', () => { }); expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining('Failed to invalidate UIAM APIKey') + expect.stringContaining('Failed to invalidate UIAM APIKey'), + expect.objectContaining({ tags: expect.arrayContaining(['uiam-api-key-invalidate']) }) ); expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); expect(result.totalInvalidated).toEqual(0); diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.ts index ce38e7b74c984..eec9e6fe5bd38 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/invalidate_api_keys_and_delete_so.ts @@ -10,6 +10,7 @@ import { isMissingApiKey, isRevokedApiKey } from '@kbn/core-security-server'; import type { ApiKeyIdAndSOId, UiamApiKeyAndSOId } from './get_api_key_ids_to_invalidate'; import { invalidateAPIKeys, invalidateUiamAPIKeys } from './invalidate_api_keys'; import type { ApiKeyInvalidationFn, UiamApiKeyInvalidationFn } from '../invalidate_api_keys_task'; +import { UIAM_LOGS_INVALIDATE_TAGS } from '../../constants'; const MAX_MISSING_KEY_RETRIES = 5; @@ -74,7 +75,8 @@ export async function invalidateApiKeysAndDeletePendingApiKeySavedObject({ if (isRevokedApiKey(response.result)) { logger.warn( `UIAM APIKey is already revoked, removing pending invalidation. ` + - `Error: ${response.result.error_details?.map((d) => d.reason).join('; ')}` + `Error: ${response.result.error_details?.map((d) => d.reason).join('; ')}`, + { tags: UIAM_LOGS_INVALIDATE_TAGS } ); } else if (isMissingApiKey(response.result)) { const retryCount = (missingApiKeyRetries[id] ?? 0) + 1; @@ -82,19 +84,22 @@ export async function invalidateApiKeysAndDeletePendingApiKeySavedObject({ if (retryCount < MAX_MISSING_KEY_RETRIES) { logger.warn( `UIAM APIKey not found, will retry (${retryCount}/${MAX_MISSING_KEY_RETRIES}). ` + - `Error: ${response.result.error_details?.map((d) => d.reason).join('; ')}` + `Error: ${response.result.error_details?.map((d) => d.reason).join('; ')}`, + { tags: UIAM_LOGS_INVALIDATE_TAGS } ); continue; } logger.warn( `UIAM APIKey not found after ${MAX_MISSING_KEY_RETRIES} attempts, removing pending invalidation. ` + - `Error: ${response.result.error_details?.map((d) => d.reason).join('; ')}` + `Error: ${response.result.error_details?.map((d) => d.reason).join('; ')}`, + { tags: UIAM_LOGS_INVALIDATE_TAGS } ); } else { logger.error( `Failed to invalidate UIAM APIKey: ${response.result.error_details ?.map((d) => d.reason) - .join('; ')}` + .join('; ')}`, + { tags: UIAM_LOGS_INVALIDATE_TAGS } ); continue; } @@ -105,7 +110,9 @@ export async function invalidateApiKeysAndDeletePendingApiKeySavedObject({ delete missingApiKeyRetries[id]; totalInvalidated++; } catch (err) { - logger.error(`Failed to delete invalidated UIAM API key. Error: ${err.message}`); + logger.error(`Failed to delete invalidated UIAM API key. Error: ${err.message}`, { + tags: UIAM_LOGS_INVALIDATE_TAGS, + }); } } } diff --git a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.test.ts b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.test.ts index c5076306c3870..857804b5395e0 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/invalidate_api_keys/lib/run_invalidate.test.ts @@ -75,7 +75,7 @@ describe('runInvalidate', () => { }, { type: 'api_key_to_invalidate', - encrypted: false, + encrypted: true, savedObjectTypes: [ { type: 'task', apiKeyAttributePath: 'task.attributes.userScope.apiKeyId' }, ], 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 deleted file mode 100644 index dbc978d1ba83a..0000000000000 --- a/x-pack/platform/plugins/shared/task_manager/server/lib/bulk_mark_api_keys_for_invalidation.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; -import { INVALIDATE_API_KEY_SO_NAME } from '../saved_objects'; -import { bulkMarkApiKeysForInvalidation } from './bulk_mark_api_keys_for_invalidation'; - -const logger = loggingSystemMock.create().get(); - -describe('bulkMarkApiKeysForInvalidation', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test('should call savedObjectsClient bulkCreate with the proper params', async () => { - const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); - - await bulkMarkApiKeysForInvalidation({ - apiKeyIds: ['123', '456'], - logger, - savedObjectsClient: unsecuredSavedObjectsClient, - }); - - const bulkCreateCallMock = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]; - const savedObjects = bulkCreateCallMock[0]; - - expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); - expect(bulkCreateCallMock).toHaveLength(1); - - expect(savedObjects).toHaveLength(2); - expect(savedObjects[0]).toHaveProperty('type', INVALIDATE_API_KEY_SO_NAME); - expect(savedObjects[0]).toHaveProperty('attributes.apiKeyId', '123'); - expect(savedObjects[0]).toHaveProperty('attributes.createdAt', expect.any(String)); - expect(savedObjects[1]).toHaveProperty('type', INVALIDATE_API_KEY_SO_NAME); - expect(savedObjects[1]).toHaveProperty('attributes.apiKeyId', '456'); - expect(savedObjects[1]).toHaveProperty('attributes.createdAt', expect.any(String)); - }); - - test('should log the proper error when savedObjectsClient create failed', async () => { - const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - unsecuredSavedObjectsClient.bulkCreate.mockRejectedValueOnce(new Error('Fail')); - - await bulkMarkApiKeysForInvalidation({ - apiKeyIds: ['123', '456'], - logger, - savedObjectsClient: unsecuredSavedObjectsClient, - }); - - expect(logger.error).toHaveBeenCalledWith( - 'Failed to bulk mark 2 API keys for invalidation: Fail' - ); - }); - - test('should not call savedObjectsClient bulkCreate if list of apiKeys empty', async () => { - const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); - - await bulkMarkApiKeysForInvalidation({ - apiKeyIds: [], - logger, - savedObjectsClient: unsecuredSavedObjectsClient, - }); - - expect(unsecuredSavedObjectsClient.bulkCreate).not.toHaveBeenCalled(); - }); -}); diff --git a/x-pack/platform/plugins/shared/task_manager/server/lib/bulk_mark_api_keys_for_invalidation.ts b/x-pack/platform/plugins/shared/task_manager/server/lib/bulk_mark_api_keys_for_invalidation.ts deleted file mode 100644 index 9140233c60b0f..0000000000000 --- a/x-pack/platform/plugins/shared/task_manager/server/lib/bulk_mark_api_keys_for_invalidation.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; -import { INVALIDATE_API_KEY_SO_NAME } from '../saved_objects'; - -export interface BulkMarkApiKeysForInvalidationOpts { - apiKeyIds: string[]; - logger: Logger; - savedObjectsClient: SavedObjectsClientContract; -} -export const bulkMarkApiKeysForInvalidation = async (opts: BulkMarkApiKeysForInvalidationOpts) => { - const { apiKeyIds, logger, savedObjectsClient } = opts; - if (apiKeyIds.length === 0) { - return; - } - - try { - await savedObjectsClient.bulkCreate( - apiKeyIds.map((apiKeyId) => ({ - attributes: { - apiKeyId, - createdAt: new Date().toISOString(), - }, - type: INVALIDATE_API_KEY_SO_NAME, - })) - ); - } catch (e) { - logger.error(`Failed to bulk mark ${apiKeyIds.length} API keys for invalidation: ${e.message}`); - } -}; diff --git a/x-pack/platform/plugins/shared/task_manager/server/lib/calculate_health_status.test.ts b/x-pack/platform/plugins/shared/task_manager/server/lib/calculate_health_status.test.ts index 2bb0ee3429d99..ca37047a0eb31 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 @@ -64,6 +64,7 @@ const config = { }, auto_calculate_default_ech_capacity: false, api_key_type: ApiKeyType.ES, + grant_uiam_api_keys: false, }; const getStatsWithTimestamp = ({ diff --git a/x-pack/platform/plugins/shared/task_manager/server/metrics/create_aggregator.test.ts b/x-pack/platform/plugins/shared/task_manager/server/metrics/create_aggregator.test.ts index 617b9bc24e53f..e93b38b1cf161 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/metrics/create_aggregator.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/metrics/create_aggregator.test.ts @@ -83,6 +83,7 @@ const config: TaskManagerConfig = { }, auto_calculate_default_ech_capacity: false, api_key_type: ApiKeyType.ES, + grant_uiam_api_keys: false, }; describe('createAggregator', () => { 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 8e412d72c2441..dfe2470dbf32a 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/mocks.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/mocks.ts @@ -43,6 +43,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/monitoring/configuration_statistics.test.ts b/x-pack/platform/plugins/shared/task_manager/server/monitoring/configuration_statistics.test.ts index 74408a417ad5c..4b69796a215bd 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/monitoring/configuration_statistics.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/monitoring/configuration_statistics.test.ts @@ -70,6 +70,7 @@ describe('Configuration Statistics Aggregator', () => { }, auto_calculate_default_ech_capacity: false, api_key_type: ApiKeyType.ES, + grant_uiam_api_keys: false, }; return new Promise(async (resolve, reject) => { diff --git a/x-pack/platform/plugins/shared/task_manager/server/plugin.test.ts b/x-pack/platform/plugins/shared/task_manager/server/plugin.test.ts index b2c5ecc7c307d..d0ff7052eef12 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/plugin.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/plugin.test.ts @@ -75,6 +75,7 @@ const pluginInitializerContextParams = { }, auto_calculate_default_ech_capacity: false, api_key_type: ApiKeyType.ES, + grant_uiam_api_keys: false, }; describe('TaskManagerPlugin', () => { 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 5836418bcfbfc..577161cd3f21d 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/plugin.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/plugin.ts @@ -67,11 +67,15 @@ 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, } from './invalidate_api_keys/invalidate_api_keys_task'; +import { createApiKeyStrategy } from './api_key_strategy'; export interface TaskManagerSetupContract { /** @@ -105,6 +109,7 @@ export type TaskManagerStartContract = Pick< getRegisteredTypes: () => string[]; registerEncryptedSavedObjectsClient: (client: EncryptedSavedObjectsClient) => void; registerApiKeyInvalidateFn: (fn?: ApiKeyInvalidationFn) => void; + registerUiamApiKeyInvalidateFn: (fn?: UiamApiKeyInvalidationFn) => void; }; export interface TaskManagerPluginsStart { @@ -152,6 +157,9 @@ export class TaskManagerPlugin private licenseSubscriber?: PublicMethodsOf; private invalidateApiKeyFn?: ApiKeyInvalidationFn; private taskEventLogger?: TaskEventLogger; + private invalidateUiamApiKeyFn?: UiamApiKeyInvalidationFn; + private taskStore?: TaskStore; + private startContract?: TaskManagerStartContract; constructor(private readonly initContext: PluginInitializerContext) { this.initContext = initContext; @@ -176,6 +184,10 @@ export class TaskManagerPlugin } } + private get invalidateUiamApiKey(): UiamApiKeyInvalidationFn | undefined { + return this.invalidateUiamApiKeyFn; + } + public setup( core: CoreSetup, plugins: TaskManagerPluginsSetup @@ -278,7 +290,9 @@ export class TaskManagerPlugin registerInvalidateApiKeyTask({ configInterval: this.config.invalidate_api_key_task.interval, coreStartServices: core.getStartServices, + getEncryptedSavedObjectsClient: () => this.taskStore?.getEncryptedSavedObjectsClient(), invalidateApiKeyFn: this.invalidateApiKey.bind(this), + invalidateUiamApiKeyFn: () => this.invalidateUiamApiKey, logger: this.logger, removalDelay: this.config.invalidate_api_key_task.removalDelay, taskTypeDictionary: this.definitions, @@ -346,6 +360,12 @@ export class TaskManagerPlugin } const serializer = savedObjects.createSerializer(); + const apiKeyStrategy = createApiKeyStrategy( + this.config.api_key_type, + this.config.grant_uiam_api_keys, + security, + this.logger + ); const taskStore = new TaskStore({ serializer, savedObjectsRepository, @@ -363,7 +383,9 @@ export class TaskManagerPlugin getIsSecurityEnabled: this.licenseSubscriber?.getIsSecurityEnabled, basePath: http.basePath, executionContext, + apiKeyStrategy, }); + this.taskStore = taskStore; const isServerless = this.initContext.env.packageInfo.buildFlavor === 'serverless'; @@ -418,6 +440,7 @@ export class TaskManagerPlugin elasticsearchAndSOAvailability$: this.elasticsearchAndSOAvailability$!, taskPartitioner, startingCapacity, + apiKeyStrategy, eventLogger: this.taskEventLogger!, }); } @@ -457,7 +480,7 @@ export class TaskManagerPlugin ).catch(() => {}); scheduleMarkRemovedTasksAsUnrecognizedDefinition(this.logger, taskScheduling).catch(() => {}); - return { + this.startContract = { fetch: (opts: SearchOpts): Promise => taskStore.fetch(opts), aggregate: (opts: AggregationOpts): Promise> => taskStore.aggregate(opts), @@ -481,7 +504,12 @@ export class TaskManagerPlugin registerApiKeyInvalidateFn: (fn?: ApiKeyInvalidationFn) => { this.invalidateApiKeyFn = fn; }, + registerUiamApiKeyInvalidateFn: (fn?: UiamApiKeyInvalidationFn) => { + this.invalidateUiamApiKeyFn = fn; + }, }; + + return this.startContract; } public async stop() { diff --git a/x-pack/platform/plugins/shared/task_manager/server/polling_lifecycle.test.ts b/x-pack/platform/plugins/shared/task_manager/server/polling_lifecycle.test.ts index fc8550610ea57..75b22b0bab4b5 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/polling_lifecycle.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/polling_lifecycle.test.ts @@ -30,6 +30,7 @@ import { ApiKeyType, CLAIM_STRATEGY_MGET, DEFAULT_KIBANAS_PER_PARTITION } from ' import { TaskPartitioner } from './lib/task_partitioner'; import type { KibanaDiscoveryService } from './kibana_discovery_service'; import { TaskEventType } from './task_events'; +import { EsApiKeyStrategy } from './api_key_strategy'; const executionContext = executionContextServiceMock.createSetupContract(); let mockTaskClaiming = taskClaimingMock.create({}); @@ -112,6 +113,7 @@ describe('TaskPollingLifecycle', () => { }, auto_calculate_default_ech_capacity: false, api_key_type: ApiKeyType.ES, + grant_uiam_api_keys: false, }, basePathService: httpServiceMock.createBasePath(), taskStore: mockTaskStore, @@ -126,6 +128,7 @@ describe('TaskPollingLifecycle', () => { kibanaDiscoveryService: {} as KibanaDiscoveryService, kibanasPerPartition: DEFAULT_KIBANAS_PER_PARTITION, }), + apiKeyStrategy: new EsApiKeyStrategy(), eventLogger: eventLoggerMock, }; diff --git a/x-pack/platform/plugins/shared/task_manager/server/polling_lifecycle.ts b/x-pack/platform/plugins/shared/task_manager/server/polling_lifecycle.ts index 23624766686e9..8420c9b9103e0 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/polling_lifecycle.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/polling_lifecycle.ts @@ -46,6 +46,7 @@ import { TaskPool } from './task_pool'; import type { TaskRunner } from './task_running'; import { TaskManagerRunner } from './task_running'; import type { TaskStore } from './task_store'; +import type { ApiKeyStrategy } from './api_key_strategy'; import { identifyEsError, isEsCannotExecuteScriptError } from './lib/identify_es_error'; import { BufferedTaskStore } from './buffered_task_store'; import type { TaskTypeDictionary } from './task_type_dictionary'; @@ -80,6 +81,7 @@ export interface TaskPollingLifecycleOpts { usageCounter?: UsageCounter; taskPartitioner: TaskPartitioner; startingCapacity: number; + apiKeyStrategy: ApiKeyStrategy; eventLogger: TaskEventLogger; } @@ -121,6 +123,7 @@ export class TaskPollingLifecycle implements ITaskEventEmitter(0); private eventLogger: TaskEventLogger; @@ -143,6 +146,7 @@ export class TaskPollingLifecycle implements ITaskEventEmitter this.currentPollInterval, + apiKeyStrategy: this.apiKeyStrategy, eventLogger: this.eventLogger, }); }; 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..3a4759a6a0f60 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,15 @@ export const taskSchemaV8 = taskSchemaV7.extends({ schema.oneOf([schema.literal('tiny'), schema.literal('normal'), schema.literal('extralarge')]) ), }); + +export const taskSchemaV9 = taskSchemaV8.extends({ + uiamApiKey: schema.maybe(schema.string()), + userScope: schema.maybe( + schema.object({ + apiKeyId: schema.string(), + uiamApiKeyId: schema.maybe(schema.string()), + spaceId: schema.string(), + apiKeyCreatedByUser: schema.boolean(), + }) + ), +}); 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 320ad35352e30..55b885318f290 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task.ts @@ -286,6 +286,7 @@ export type { IntervalSchedule, Rrule, RruleSchedule } from '@kbn/response-ops-s export interface TaskUserScope { apiKeyId: string; + uiamApiKeyId?: string; spaceId?: string; apiKeyCreatedByUser: boolean; } @@ -397,10 +398,15 @@ export interface TaskInstance { partition?: number; /** - * Used to allow tasks to be scoped to a user via their API key + * Used to allow tasks to be scoped to a user via their ES API key */ apiKey?: string; + /** + * Used to allow tasks to be scoped to a user via their UIAM API key + */ + uiamApiKey?: string; + /** * Meta data related to the API key associated with this task */ @@ -551,6 +557,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 ec791528ff080..5fb01b03c66ed 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 @@ -46,6 +46,7 @@ import { schema } from '@kbn/config-schema'; import { CLAIM_STRATEGY_MGET, CLAIM_STRATEGY_UPDATE_BY_QUERY } from '../config'; import * as nextRunAtUtils from '../lib/get_next_run_at'; import { configMock } from '../config.mock'; +import { EsApiKeyStrategy } from '../api_key_strategy'; const baseDelay = 5 * 60 * 1000; const executionContext = executionContextServiceMock.createSetupContract(); @@ -3618,6 +3619,7 @@ describe('TaskManagerRunner', () => { allowReadingInvalidState: opts.allowReadingInvalidState || false, strategy: opts.strategy ?? CLAIM_STRATEGY_UPDATE_BY_QUERY, getPollInterval: () => 500, + apiKeyStrategy: new EsApiKeyStrategy(), eventLogger: eventLoggerMock, }); 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 28f68bf93a584..722edc4443e0e 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 @@ -68,6 +68,7 @@ import { isFailedRunResult, TaskStatus, TaskCost, getTaskCostFromInstance } from import type { TaskTypeDictionary } from '../task_type_dictionary'; import { isUnrecoverableError, isUserError, type DecoratedError } from './errors'; import { CLAIM_STRATEGY_MGET, type TaskManagerConfig } from '../config'; +import type { ApiKeyStrategy } from '../api_key_strategy'; import { TaskValidator } from '../task_validator'; import { getRetryAt, getRetryDate, getTimeout } from '../lib/get_retry_at'; import { getNextRunAt } from '../lib/get_next_run_at'; @@ -137,6 +138,7 @@ type Opts = { allowReadingInvalidState: boolean; strategy: string; getPollInterval: () => number; + apiKeyStrategy: ApiKeyStrategy; eventLogger: TaskEventLogger; } & Pick; @@ -192,6 +194,7 @@ export class TaskManagerRunner implements TaskRunner { private readonly taskValidator: TaskValidator; private readonly claimStrategy: string; private getPollInterval: () => number; + private apiKeyStrategy: ApiKeyStrategy; private eventLogger: TaskEventLogger; private isCancelled = false; @@ -221,6 +224,7 @@ export class TaskManagerRunner implements TaskRunner { allowReadingInvalidState, strategy, getPollInterval, + apiKeyStrategy, eventLogger, }: Opts) { this.basePathService = basePathService; @@ -243,6 +247,7 @@ export class TaskManagerRunner implements TaskRunner { }); this.claimStrategy = strategy; this.getPollInterval = getPollInterval; + this.apiKeyStrategy = apiKeyStrategy; this.eventLogger = eventLogger; } @@ -427,9 +432,16 @@ export class TaskManagerRunner implements TaskRunner { const stopUpdatingLongRunningTasks = this.updateRetryAtOnIntervalForLongRunningTasks(); try { - const sanitizedTaskInstance = omit(modifiedContext.taskInstance, ['apiKey', 'userScope']); + const sanitizedTaskInstance = omit(modifiedContext.taskInstance, [ + 'apiKey', + 'uiamApiKey', + 'userScope', + ]); + const apiKeyForRequest = this.apiKeyStrategy.getApiKeyForFakeRequest( + modifiedContext.taskInstance + ); const fakeRequest = this.getFakeKibanaRequest( - modifiedContext.taskInstance.apiKey, + apiKeyForRequest, modifiedContext.taskInstance.userScope?.spaceId ); 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..36c6542795273 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 @@ -10,7 +10,11 @@ import type { estypes } from '@elastic/elasticsearch'; import _ from 'lodash'; import { first } from 'rxjs'; -import type { TaskInstance, SerializedConcreteTaskInstance } from './task'; +import type { + TaskInstance, + SerializedConcreteTaskInstance, + PartialConcreteTaskInstance, +} from './task'; import { TaskStatus, TaskLifecycleResult } from './task'; import type { ElasticsearchClientMock } from '@kbn/core/server/mocks'; import { @@ -39,7 +43,7 @@ import type { EncryptedSavedObjectsClientOptions, } from '@kbn/encrypted-saved-objects-shared'; import { TaskValidator } from './task_validator'; -import { bulkMarkApiKeysForInvalidation } from './lib/bulk_mark_api_keys_for_invalidation'; +import { EsApiKeyStrategy } from './api_key_strategy'; let mockGetValidatedTaskInstanceFromReading: jest.SpyInstance; let mockGetValidatedTaskInstanceForUpdating: jest.SpyInstance; @@ -48,12 +52,6 @@ jest.mock('./lib/api_key_utils', () => ({ getApiKeyAndUserScope: jest.fn(), })); -jest.mock('./lib/bulk_mark_api_keys_for_invalidation', () => ({ - bulkMarkApiKeysForInvalidation: jest.fn(), -})); - -(bulkMarkApiKeysForInvalidation as jest.Mock).mockResolvedValue(void 0); - function createEncryptedSavedObjectsClientMock(opts?: EncryptedSavedObjectsClientOptions) { return { getDecryptedAsInternalUser: jest.fn(), @@ -66,6 +64,7 @@ function createEncryptedSavedObjectsClientMock(opts?: EncryptedSavedObjectsClien const savedObjectsClient = savedObjectsRepositoryMock.create(); const scopedSavedObjectsClient = savedObjectsRepositoryMock.create(); const esoClient = createEncryptedSavedObjectsClientMock(); +const invalidationSoClientMock = savedObjectsClientMock.create(); const serializer = savedObjectsServiceMock.createSerializer(); const adHocTaskCounter = new AdHocTaskCounter(); @@ -154,6 +153,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); store.registerEncryptedSavedObjectsClient(esoClient); @@ -284,6 +284,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => false, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); store.registerEncryptedSavedObjectsClient(esoClient); @@ -439,6 +440,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); const task = { @@ -563,6 +565,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); }); @@ -691,6 +694,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); esoClient.createPointInTimeFinderDecryptedAsInternalUser = jest.fn().mockResolvedValue({ @@ -860,6 +864,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); let getApiKeysCallCount = 0; @@ -965,6 +970,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); let getApiKeysCallCount = 0; @@ -1064,6 +1070,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); let getApiKeysCallCount = 0; @@ -1182,6 +1189,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); }); @@ -1311,6 +1319,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); }); @@ -1522,6 +1531,7 @@ describe('TaskStore', () => { mockGetScopedClient = jest.fn(); const mockSavedObjectsService = { getScopedClient: mockGetScopedClient, + getUnsafeInternalClient: jest.fn().mockReturnValue(invalidationSoClientMock), }; store = new TaskStore({ logger, @@ -1541,6 +1551,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); store.registerEncryptedSavedObjectsClient(esoClient); }); @@ -1863,11 +1874,12 @@ describe('TaskStore', () => { excludedExtensions: ['security', 'spaces'], }); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith({ - apiKeyIds: ['apiKeyId'], - logger, - savedObjectsClient, - }); + expect(invalidationSoClientMock.bulkCreate).toHaveBeenCalledWith([ + { + attributes: { apiKeyId: 'apiKeyId', createdAt: expect.any(String) }, + type: 'api_key_to_invalidate', + }, + ]); expect(getApiKeyAndUserScope).toHaveBeenCalledWith( [{ ...bulkUpdateTask, apiKey: mockApiKey, userScope: mockUserScope }], mockRequest, @@ -1962,7 +1974,7 @@ describe('TaskStore', () => { excludedExtensions: ['security', 'spaces'], }); - expect(bulkMarkApiKeysForInvalidation).not.toHaveBeenCalled(); + expect(invalidationSoClientMock.bulkCreate).not.toHaveBeenCalled(); expect(getApiKeyAndUserScope).toHaveBeenCalledWith( [ { @@ -2070,7 +2082,7 @@ describe('TaskStore', () => { excludedExtensions: ['security', 'spaces'], }); - expect(bulkMarkApiKeysForInvalidation).not.toHaveBeenCalled(); + expect(invalidationSoClientMock.bulkCreate).not.toHaveBeenCalled(); expect(getApiKeyAndUserScope).toHaveBeenCalledWith( [ { @@ -2134,7 +2146,7 @@ describe('TaskStore', () => { expect(mockGetScopedClient).not.toHaveBeenCalled(); - expect(bulkMarkApiKeysForInvalidation).not.toHaveBeenCalled(); + expect(invalidationSoClientMock.bulkCreate).not.toHaveBeenCalled(); expect(getApiKeyAndUserScope).not.toHaveBeenCalled(); expect(savedObjectsClient.bulkUpdate).toHaveBeenCalledWith( @@ -2251,6 +2263,79 @@ describe('TaskStore', () => { ); }); + test('uses scoped (encrypted) repository when docs have uiamApiKey but no apiKey', async () => { + const mockUiamApiKey = 'essu_uiam-api-key'; + const mockUiamUserScope = { + apiKeyId: 'apiKeyId', + uiamApiKeyId: 'uiamApiKeyId', + apiKeyCreatedByUser: false, + spaceId: 'testSpace', + }; + + const mockScopedClient = { + bulkUpdate: jest.fn().mockResolvedValue({ + saved_objects: [ + { + id: 'task:324242', + type: 'task', + attributes: { + ...bulkUpdateTask, + uiamApiKey: mockUiamApiKey, + userScope: mockUiamUserScope, + state: '{"foo":"bar"}', + params: '{"hello":"world"}', + }, + references: [], + version: '123', + }, + ], + }), + }; + mockGetScopedClient.mockReturnValue(mockScopedClient); + + await store.bulkUpdate( + [{ ...bulkUpdateTask, uiamApiKey: mockUiamApiKey, userScope: mockUiamUserScope }], + { + validate: false, + mergeAttributes: false, + options: { request: mockRequest }, + } + ); + + expect(mockGetScopedClient).toHaveBeenCalledWith(mockRequest, { + includedHiddenTypes: ['task'], + excludedExtensions: ['security', 'spaces'], + }); + expect(savedObjectsClient.bulkUpdate).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + }); + + test('throws an error when no request is provided but docs have uiamApiKey and userScope', async () => { + await expect( + store.bulkUpdate( + [ + { + ...bulkUpdateTask, + uiamApiKey: 'essu_uiam-api-key', + userScope: { + apiKeyId: 'apiKeyId', + uiamApiKeyId: 'uiamApiKeyId', + apiKeyCreatedByUser: false, + spaceId: 'testSpace', + }, + }, + ], + { + validate: false, + mergeAttributes: false, + options: {}, + } + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Request is not defined but some of the tasks have API key or user scope. Cannot get the encrypted saved objects repository to bulk update tasks."` + ); + }); + test('throws an error when no request is provided but docs have apiKey and userScope', async () => { savedObjectsClient.bulkUpdate.mockResolvedValue({ saved_objects: [ @@ -2303,6 +2388,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => false, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); savedObjectsClient.bulkUpdate.mockResolvedValue({ @@ -2378,6 +2464,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); }); @@ -2878,6 +2965,60 @@ describe('TaskStore', () => { '[TaskStore] Invalid interval "invalid-interval". Task task2 will not be updated.' ); }); + + test(`should strip apiKey and uiamApiKey from partial update body so they are never persisted via raw esClient.bulk`, async () => { + const task = { + id: '324242', + version: 'WzQsMV0=', + attempts: 3, + apiKey: 'should-not-be-persisted-as-plaintext', + uiamApiKey: 'essu_should-not-be-persisted-as-plaintext', + userScope: { + apiKeyId: 'api-key-id', + uiamApiKeyId: 'uiam-api-key-id', + apiKeyCreatedByUser: false, + spaceId: 'default', + }, + } as PartialConcreteTaskInstance; + + esClient.bulk.mockResolvedValue({ + errors: false, + took: 0, + items: [ + { + update: { + _index: '.kibana_task_manager_8.16.0_001', + _id: 'task:324242', + _version: 2, + result: 'updated', + _shards: { total: 1, successful: 1, failed: 0 }, + _seq_no: 84, + _primary_term: 1, + status: 200, + }, + }, + ], + }); + + await store.bulkPartialUpdate([task]); + + expect(esClient.bulk).toHaveBeenCalledWith({ + body: [ + { update: { _id: 'task:324242', if_primary_term: 1, if_seq_no: 4 } }, + { doc: { task: { attempts: 3 } } }, + ], + index: 'tasky', + refresh: false, + }); + + const [[bulkArgs]] = esClient.bulk.mock.calls; + const serialized = JSON.stringify(bulkArgs); + expect(serialized).not.toContain('should-not-be-persisted-as-plaintext'); + expect(serialized).not.toContain('essu_'); + expect(serialized).not.toContain('userScope'); + expect(serialized).not.toContain('apiKey'); + expect(serialized).not.toContain('uiamApiKey'); + }); }); describe('remove', () => { @@ -2912,6 +3053,9 @@ describe('TaskStore', () => { }; beforeEach(() => { + (coreStart.savedObjects.getUnsafeInternalClient as jest.Mock).mockReturnValue( + invalidationSoClientMock + ); store = new TaskStore({ logger, index: 'tasky', @@ -2931,6 +3075,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); esoClient.createPointInTimeFinderDecryptedAsInternalUser = jest.fn().mockResolvedValue({ @@ -2957,11 +3102,12 @@ describe('TaskStore', () => { const result = await store.remove(id); expect(result).toBeUndefined(); expect(savedObjectsClient.delete).toHaveBeenCalledWith('task', id, { refresh: false }); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith({ - apiKeyIds: ['apiKeyId'], - logger, - savedObjectsClient, - }); + expect(invalidationSoClientMock.bulkCreate).toHaveBeenCalledWith([ + { + attributes: { apiKeyId: 'apiKeyId', createdAt: expect.any(String) }, + type: 'api_key_to_invalidate', + }, + ]); }); test('pushes error from saved objects client to errors$', async () => { @@ -3037,6 +3183,9 @@ describe('TaskStore', () => { const tasksIdsToDelete = [randomId(), randomId()]; beforeEach(() => { + (coreStart.savedObjects.getUnsafeInternalClient as jest.Mock).mockReturnValue( + invalidationSoClientMock + ); store = new TaskStore({ logger, index: 'tasky', @@ -3056,6 +3205,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); esoClient.createPointInTimeFinderDecryptedAsInternalUser = jest.fn().mockResolvedValue({ @@ -3089,11 +3239,16 @@ describe('TaskStore', () => { }); const result = await store.bulkRemove(['task1', 'task2']); expect(result).toBeUndefined(); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith({ - apiKeyIds: ['apiKeyId1', 'apiKeyId2'], - logger, - savedObjectsClient, - }); + expect(invalidationSoClientMock.bulkCreate).toHaveBeenCalledWith([ + { + attributes: { apiKeyId: 'apiKeyId1', createdAt: expect.any(String) }, + type: 'api_key_to_invalidate', + }, + { + attributes: { apiKeyId: 'apiKeyId2', createdAt: expect.any(String) }, + type: 'api_key_to_invalidate', + }, + ]); }); test('pushes error from saved objects client to errors$', async () => { @@ -3107,6 +3262,100 @@ describe('TaskStore', () => { ); expect(await firstErrorPromise).toMatchInlineSnapshot(`[Error: Failure]`); }); + + test('marks API keys for invalidation when task has uiamApiKey but no apiKey', async () => { + const getApiKeyIdsForInvalidation = jest + .fn() + .mockReturnValue([{ apiKeyId: 'uiamApiKeyId', uiamApiKey: 'essu_uiam-api-key' }]); + const markForInvalidation = jest.fn().mockResolvedValue(undefined); + const spyStrategy = { + shouldGrantUiam: true, + typeToUse: 'uiam', + grantApiKeys: jest.fn(), + getApiKeyForFakeRequest: jest.fn(), + getApiKeyIdsForInvalidation, + markForInvalidation, + }; + + const uiamOnlyStore = new TaskStore({ + logger, + 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, + executionContext: mockExecutionContextStart, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + apiKeyStrategy: spyStrategy as any, + }); + + const uiamOnlyTask = { + id: 'task-uiam-only', + type: 'task', + attributes: { + attempts: 0, + params: '{"hello":"world"}', + retryAt: null, + runAt: '2019-02-12T21:01:22.479Z', + scheduledAt: '2019-02-12T21:01:22.479Z', + startedAt: null, + state: '{"foo":"bar"}', + stateVersion: 1, + status: 'idle', + taskType: 'report', + traceparent: 'apmTraceparent', + partition: 225, + uiamApiKey: 'essu_uiam-api-key', + userScope: { + apiKeyId: 'apiKeyId', + uiamApiKeyId: 'uiamApiKeyId', + apiKeyCreatedByUser: false, + spaceId: 'testSpace', + }, + }, + references: [], + version: '123', + }; + + esoClient.createPointInTimeFinderDecryptedAsInternalUser = jest.fn().mockResolvedValue({ + close: jest.fn(), + find: function* asyncGenerator() { + yield { saved_objects: [uiamOnlyTask] }; + }, + }); + + uiamOnlyStore.registerEncryptedSavedObjectsClient(esoClient); + + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [uiamOnlyTask], + }); + + await uiamOnlyStore.bulkRemove(['task-uiam-only']); + + expect(getApiKeyIdsForInvalidation).toHaveBeenCalledTimes(1); + const [[calledWith]] = getApiKeyIdsForInvalidation.mock.calls; + expect(calledWith).toMatchObject({ + id: 'task-uiam-only', + uiamApiKey: 'essu_uiam-api-key', + }); + expect(calledWith.apiKey).toBeUndefined(); + expect(markForInvalidation).toHaveBeenCalledWith( + [{ apiKeyId: 'uiamApiKeyId', uiamApiKey: 'essu_uiam-api-key' }], + expect.anything(), + expect.anything() + ); + }); }); describe('get', () => { @@ -3131,6 +3380,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); }); @@ -3199,6 +3449,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); }); @@ -3302,6 +3553,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); expect(await store.getLifecycle(task.id)).toEqual(status); @@ -3332,6 +3584,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); expect(await store.getLifecycle(randomId())).toEqual(TaskLifecycleResult.NotFound); @@ -3360,6 +3613,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); return expect(store.getLifecycle(randomId())).rejects.toThrow('Bad Request'); @@ -3390,6 +3644,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); store.registerEncryptedSavedObjectsClient(esoClient); @@ -3664,6 +3919,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); const task1 = { @@ -3700,6 +3956,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => false, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); store.registerEncryptedSavedObjectsClient(esoClient); @@ -3959,6 +4216,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); savedObjectsClient.create.mockImplementation(async (type: string, attributes: unknown) => ({ @@ -4012,6 +4270,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); savedObjectsClient.create.mockImplementation(async (type: string, attributes: unknown) => ({ @@ -4061,6 +4320,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); }); test('should pass requestTimeout and retryOnTimeout', async () => { @@ -4099,6 +4359,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); }); @@ -4217,6 +4478,7 @@ describe('TaskStore', () => { getIsSecurityEnabled: () => true, basePath: basePathMock, executionContext: mockExecutionContextStart, + apiKeyStrategy: new EsApiKeyStrategy(), }); }); 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..79cde391e6470 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 @@ -19,6 +19,7 @@ import type { SavedObjectsBulkDeleteResponse, Logger, SavedObjectsServiceStart, + SavedObjectsClientContract, SecurityServiceStart, KibanaRequest, SavedObject, @@ -64,12 +65,10 @@ import { claimSort } from './queries/mark_available_tasks_as_claimed'; import { MAX_PARTITIONS } from './lib/task_partitioner'; import type { ErrorOutput } from './lib/bulk_operation_buffer'; import { BulkUpdateError, MsearchError } from './lib/errors'; -import { TASK_SO_NAME } from './saved_objects'; -import { getApiKeyAndUserScope } from './lib/api_key_utils'; -import type { ApiKeyAndUserScope } from './lib/api_key_utils'; +import { TASK_SO_NAME, INVALIDATE_API_KEY_SO_NAME } from './saved_objects'; +import type { ApiKeyStrategy, ApiKeySOFields, InvalidationTarget } from './api_key_strategy'; import { getFirstRunAt } from './lib/get_first_run_at'; import { isInterval } from './lib/intervals'; -import { bulkMarkApiKeysForInvalidation } from './lib/bulk_mark_api_keys_for_invalidation'; export interface StoreOpts { esClient: ElasticsearchClient; @@ -89,6 +88,7 @@ export interface StoreOpts { getIsSecurityEnabled: () => boolean; basePath: IBasePath; executionContext: ExecutionContextStart; + apiKeyStrategy: ApiKeyStrategy; } export interface SearchOpts { @@ -154,6 +154,7 @@ export class TaskStore { private definitions: TaskTypeDictionary; private savedObjectsRepository: ISavedObjectsRepository; private savedObjectsService: SavedObjectsServiceStart; + private _invalidationSoClient?: SavedObjectsClientContract; private serializer: ISavedObjectsSerializer; private adHocTaskCounter: AdHocTaskCounter; private requestTimeouts: RequestTimeoutsConfig; @@ -163,6 +164,16 @@ export class TaskStore { private logger: Logger; private basePath: IBasePath; private executionContextRunner: ExecutionContextRunner; + private apiKeyStrategy: ApiKeyStrategy; + + private get invalidationSoClient(): SavedObjectsClientContract { + if (!this._invalidationSoClient) { + this._invalidationSoClient = this.savedObjectsService.getUnsafeInternalClient({ + includedHiddenTypes: [INVALIDATE_API_KEY_SO_NAME], + }); + } + return this._invalidationSoClient; + } /** * Constructs a new TaskStore. @@ -194,6 +205,7 @@ export class TaskStore { this.getIsSecurityEnabled = opts.getIsSecurityEnabled; this.logger = opts.logger; this.basePath = opts.basePath; + this.apiKeyStrategy = opts.apiKeyStrategy; this.executionContextRunner = getExecutionContextRunner(opts.executionContext, { name: 'taskStore', // individual executions can be specialized with an `id` property ... @@ -204,6 +216,10 @@ export class TaskStore { this.esoClient = client; } + public getEncryptedSavedObjectsClient(): EncryptedSavedObjectsClient | undefined { + return this.esoClient; + } + private canEncryptSo() { return !!(this.esoClient && this.canEncryptSavedObjects); } @@ -230,35 +246,38 @@ export class TaskStore { } private async regenerateApiKeyFromRequest(docs: ConcreteTaskInstance[], options?: ApiKeyOptions) { - const hasEncryptedFields = docs.some((doc) => doc.apiKey && doc.userScope); - const apiKeyIdsToRemoveMap = new Map(); - let apiKeyAndUserScopeMap: Map | null = null; + const hasEncryptedFields = docs.some(docHasEncryptedApiKey); + const invalidationTargets: Array<{ + taskId: string; + targets: InvalidationTarget[]; + }> = []; + let apiKeySOFieldsMap: Map | null = null; // If a task with an API key is updated with a request if (hasEncryptedFields && options?.request && options?.regenerateApiKey) { const docsWithApiKeys: ConcreteTaskInstance[] = []; docs.forEach((taskInstance) => { - const { apiKey, userScope } = taskInstance; - if (apiKey && userScope) { + if (docHasEncryptedApiKey(taskInstance)) { docsWithApiKeys.push(taskInstance); - if (!userScope.apiKeyCreatedByUser) { - apiKeyIdsToRemoveMap.set(taskInstance.id, userScope.apiKeyId); + const targets = this.apiKeyStrategy.getApiKeyIdsForInvalidation(taskInstance); + if (targets.length > 0) { + invalidationTargets.push({ taskId: taskInstance.id, targets }); } } }); // and create new API keys using the new request if (docsWithApiKeys.length) { - apiKeyAndUserScopeMap = await this.getApiKeyFromRequest(docsWithApiKeys, options.request); + apiKeySOFieldsMap = await this.grantApiKeysFromRequest(docsWithApiKeys, options.request); } } - return { apiKeyAndUserScopeMap, apiKeyIdsToRemoveMap }; + return { apiKeySOFieldsMap, invalidationTargets }; } private getSoClientForUpdate(docs: ConcreteTaskInstance[], options?: ApiKeyOptions) { - const hasEncryptedFields = docs.some((doc) => doc.apiKey && doc.userScope); + const hasEncryptedFields = docs.some(docHasEncryptedApiKey); // If a task with an API key is updated without a request, throw an error. if (hasEncryptedFields && !options?.request) { @@ -284,18 +303,16 @@ export class TaskStore { return this.savedObjectsRepository; } - private async getApiKeyFromRequest(taskInstances: TaskInstance[], request?: KibanaRequest) { - if (!this.getIsSecurityEnabled()) { - return null; - } - - if (!request) { + private async grantApiKeysFromRequest( + taskInstances: TaskInstance[], + request?: KibanaRequest + ): Promise | null> { + if (!this.getIsSecurityEnabled() || !request) { return null; } - let userScopeAndApiKey; try { - userScopeAndApiKey = await getApiKeyAndUserScope( + return await this.apiKeyStrategy.grantApiKeys( taskInstances, request, this.security, @@ -305,18 +322,16 @@ export class TaskStore { this.errors$.next(e); throw e; } - - return userScopeAndApiKey; } private async bulkGetDecryptedTaskApiKeys( taskIds: string[] - ): Promise> { + ): Promise> { if (!this.canEncryptSo() || !taskIds.length) { - return new Map(); + return new Map(); } - const result = await this.getApiKeys(taskIds); + const result = await this.getDecryptedApiKeys(taskIds); // the search doesn't wait for refresh, so may miss a newly created key const idsOfMissingKeys = taskIds.filter((id) => result.get(id) === undefined); @@ -335,7 +350,7 @@ export class TaskStore { } // get the missing keys, a log an error if they continue to be missing - const missingResult = await this.getApiKeys(idsOfMissingKeys); + const missingResult = await this.getDecryptedApiKeys(idsOfMissingKeys); for (const id of idsOfMissingKeys) { const foundKey = missingResult.get(id); @@ -349,14 +364,14 @@ export class TaskStore { return result; } - private async getApiKeys(taskIds: string[]) { + private async getDecryptedApiKeys(taskIds: string[]) { const kueryNode = nodeBuilder.or( taskIds.map((id) => { return nodeBuilder.is(`${TASK_SO_NAME}.id`, `${TASK_SO_NAME}:${id}`); }) ); - const result = new Map(); + const result = new Map(); const finder = await this.esoClient!.createPointInTimeFinderDecryptedAsInternalUser( { @@ -367,7 +382,10 @@ export class TaskStore { for await (const response of finder.find()) { response.saved_objects.forEach((savedObject) => { - result.set(savedObject.id, savedObject.attributes.apiKey); + result.set(savedObject.id, { + apiKey: savedObject.attributes.apiKey, + uiamApiKey: savedObject.attributes.uiamApiKey, + }); }); } @@ -379,7 +397,7 @@ export class TaskStore { const ids: string[] = []; tasks.forEach((task) => { - if (task.apiKey) { + if (task.apiKey || task.uiamApiKey) { ids.push(task.id); } }); @@ -388,14 +406,19 @@ export class TaskStore { return tasks; } - const decryptedTaskApiKeysMap = await this.bulkGetDecryptedTaskApiKeys(ids); + const decryptedKeysMap = 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 = decryptedKeysMap.get(task.id); + if (!decrypted) { + return task; + } + return { + ...task, + ...(decrypted.apiKey ? { apiKey: decrypted.apiKey } : {}), + ...(decrypted.uiamApiKey ? { uiamApiKey: decrypted.uiamApiKey } : {}), + }; + }); return tasksWithDecryptedApiKeys; } @@ -436,9 +459,9 @@ export class TaskStore { } this.definitions.ensureHas(taskInstance.taskType); - const apiKeyAndUserScopeMap = - (await this.getApiKeyFromRequest([taskInstance], options?.request)) || new Map(); - const { apiKey, userScope } = apiKeyAndUserScopeMap.get(taskInstance.id) || {}; + const apiKeySOFieldsMap = + (await this.grantApiKeysFromRequest([taskInstance], options?.request)) || new Map(); + const apiKeySOFields = apiKeySOFieldsMap.get(taskInstance.id) || {}; const soClient = this.getSoClientForCreate(options || {}); @@ -452,8 +475,7 @@ export class TaskStore { 'task', { ...taskInstanceToAttributes(validatedTaskInstance, id), - ...(userScope ? { userScope } : {}), - ...(apiKey ? { apiKey } : {}), + ...apiKeySOFields, runAt: getFirstRunAt({ taskInstance: validatedTaskInstance, logger: this.logger }), }, { id, refresh: false } @@ -504,14 +526,14 @@ export class TaskStore { this.errors$.next(e); throw e; } - const apiKeyAndUserScopeMap = - (await this.getApiKeyFromRequest(taskInstances, options?.request)) || new Map(); + const apiKeySOFieldsMap = + (await this.grantApiKeysFromRequest(taskInstances, options?.request)) || new Map(); const soClient = this.getSoClientForCreate(options || {}); const objects = taskInstances.reduce( (acc: Array>, taskInstance) => { - const { apiKey, userScope } = apiKeyAndUserScopeMap.get(taskInstance.id) || {}; + const apiKeySOFields = apiKeySOFieldsMap.get(taskInstance.id) || {}; const id = taskInstance.id || v4(); this.definitions.ensureHas(taskInstance.taskType); @@ -525,8 +547,7 @@ export class TaskStore { type: 'task', attributes: { ...taskInstanceToAttributes(validatedTaskInstance, id), - ...(apiKey ? { apiKey } : {}), - ...(userScope ? { userScope } : {}), + ...apiKeySOFields, runAt: getFirstRunAt({ taskInstance: validatedTaskInstance, logger: this.logger }), }, id, @@ -660,8 +681,8 @@ export class TaskStore { ): Promise { const soClientToUpdate = this.getSoClientForUpdate(docs, options); const regenerateResult = await this.regenerateApiKeyFromRequest(docs, options); - const apiKeyAndUserScopeMap = regenerateResult.apiKeyAndUserScopeMap || new Map(); - const apiKeyIdsToRemoveMap = regenerateResult.apiKeyIdsToRemoveMap; + const apiKeySOFieldsMap = regenerateResult.apiKeySOFieldsMap || new Map(); + const { invalidationTargets } = regenerateResult; const newDocs = docs.reduce( (acc: Map>, doc) => { @@ -669,10 +690,10 @@ 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 updatedFields = apiKeySOFieldsMap.get(taskInstance.id); + const apiKey = updatedFields?.apiKey || doc?.apiKey; + const uiamApiKey = updatedFields?.uiamApiKey || doc?.uiamApiKey; + const userScope = updatedFields?.userScope || doc?.userScope; acc.set(doc.id, { type: 'task', @@ -681,6 +702,7 @@ export class TaskStore { attributes: { ...taskInstanceToAttributes(taskInstance, doc.id), ...(apiKey ? { apiKey } : {}), + ...(uiamApiKey ? { uiamApiKey } : {}), ...(userScope ? { userScope } : {}), }, mergeAttributes, @@ -709,7 +731,7 @@ export class TaskStore { throw e; } - const apiKeyIdsToRemove: string[] = []; + const allInvalidationTargets: InvalidationTarget[] = []; const updates = updatedSavedObjects.map((updatedSavedObject) => { if (updatedSavedObject.error !== undefined) { return asErr({ @@ -729,20 +751,19 @@ export class TaskStore { const result = this.taskValidator.getValidatedTaskInstanceFromReading(taskInstance, { validate, }); - const oldApiKey = apiKeyIdsToRemoveMap.get(updatedSavedObject.id); - if (oldApiKey) { - apiKeyIdsToRemove.push(oldApiKey); + const entry = invalidationTargets.find((t) => t.taskId === updatedSavedObject.id); + if (entry) { + allInvalidationTargets.push(...entry.targets); } return asOk(result); }); - // after successful updates we should invalidate the old API keys - if (apiKeyIdsToRemove.length) { - await bulkMarkApiKeysForInvalidation({ - apiKeyIds: apiKeyIdsToRemove, - logger: this.logger, - savedObjectsClient: this.savedObjectsRepository, - }); + if (allInvalidationTargets.length) { + await this.apiKeyStrategy.markForInvalidation( + allInvalidationTargets, + this.logger, + this.invalidationSoClient + ); } return updates; @@ -852,15 +873,15 @@ export class TaskStore { private async _remove(id: string): Promise { const taskInstance = await this._get(id); - const { apiKey, userScope } = taskInstance; - - if (apiKey && userScope) { - if (!userScope.apiKeyCreatedByUser) { - await bulkMarkApiKeysForInvalidation({ - apiKeyIds: [userScope.apiKeyId], - logger: this.logger, - savedObjectsClient: this.savedObjectsRepository, - }); + + if ((taskInstance.apiKey || taskInstance.uiamApiKey) && taskInstance.userScope) { + const targets = this.apiKeyStrategy.getApiKeyIdsForInvalidation(taskInstance); + if (targets.length > 0) { + await this.apiKeyStrategy.markForInvalidation( + targets, + this.logger, + this.invalidationSoClient + ); } } @@ -886,24 +907,22 @@ export class TaskStore { private async _bulkRemove(taskIds: string[]): Promise { const taskInstances = await this._bulkGet(taskIds); - const apiKeyIdsToRemove: string[] = []; + const allInvalidationTargets: InvalidationTarget[] = []; taskInstances.forEach((taskInstance) => { const unwrappedTaskInstance = unwrap(taskInstance) as ConcreteTaskInstance; - const { apiKey, userScope } = unwrappedTaskInstance; - if (apiKey && userScope) { - if (!userScope.apiKeyCreatedByUser) { - apiKeyIdsToRemove.push(userScope.apiKeyId); - } + if (docHasEncryptedApiKey(unwrappedTaskInstance)) { + const targets = this.apiKeyStrategy.getApiKeyIdsForInvalidation(unwrappedTaskInstance); + allInvalidationTargets.push(...targets); } }); - if (apiKeyIdsToRemove.length) { - await bulkMarkApiKeysForInvalidation({ - apiKeyIds: apiKeyIdsToRemove, - logger: this.logger, - savedObjectsClient: this.savedObjectsRepository, - }); + if (allInvalidationTargets.length) { + await this.apiKeyStrategy.markForInvalidation( + allInvalidationTargets, + this.logger, + this.invalidationSoClient + ); } try { @@ -1308,12 +1327,24 @@ export function correctVersionConflictsForContinuation( return maxDocs && versionConflicts + updated > maxDocs ? maxDocs - updated : versionConflicts; } +/** + * Returns true when a task document holds an encrypted API key credential + * (either an ES API key or a UIAM API key) together with the `userScope` + * metadata required to process it. Must be kept in sync with every credential + * field registered for ESO encryption on the `task` saved object type. + */ +export function docHasEncryptedApiKey( + doc: Pick +): boolean { + return Boolean((doc.apiKey || doc.uiamApiKey) && doc.userScope); +} + export function taskInstanceToAttributes( doc: TaskInstance, 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 +1361,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/test/scout/.meta/api/standard.json b/x-pack/platform/plugins/shared/task_manager/test/scout/.meta/api/standard.json new file mode 100644 index 0000000000000..084fad43c9e53 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/test/scout/.meta/api/standard.json @@ -0,0 +1,103 @@ +{ + "sha1": "46f958969d56cdee3d12499e1c4d8eed5eeb99a7", + "tests": [ + { + "id": "61070c0c401f9ab-03c8e0a7f9c921c", + "title": "Task Manager Schedule and Delete Routes schedule: creates a task and returns it", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/task_manager/test/scout/api/tests/task_schedule_and_delete.spec.ts", + "line": 31, + "column": 12 + } + }, + { + "id": "61070c0c401f9ab-85ab00d18644f8b", + "title": "Task Manager Schedule and Delete Routes schedule: creates a task with an interval schedule", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/task_manager/test/scout/api/tests/task_schedule_and_delete.spec.ts", + "line": 56, + "column": 12 + } + }, + { + "id": "61070c0c401f9ab-d8443fc743b870a", + "title": "Task Manager Schedule and Delete Routes schedule: returns 400 when taskType is missing", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/task_manager/test/scout/api/tests/task_schedule_and_delete.spec.ts", + "line": 84, + "column": 12 + } + }, + { + "id": "61070c0c401f9ab-eaa06d146064151", + "title": "Task Manager Schedule and Delete Routes schedule: returns 403 when called by a viewer", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/task_manager/test/scout/api/tests/task_schedule_and_delete.spec.ts", + "line": 100, + "column": 12 + } + }, + { + "id": "61070c0c401f9ab-5f1d247e5ff9264", + "title": "Task Manager Schedule and Delete Routes delete: removes an existing task", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/task_manager/test/scout/api/tests/task_schedule_and_delete.spec.ts", + "line": 117, + "column": 12 + } + }, + { + "id": "61070c0c401f9ab-26cc781ecff2add", + "title": "Task Manager Schedule and Delete Routes delete: returns 404 for a non-existent task", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/task_manager/test/scout/api/tests/task_schedule_and_delete.spec.ts", + "line": 145, + "column": 12 + } + }, + { + "id": "61070c0c401f9ab-d1dae81664af2a9", + "title": "Task Manager Schedule and Delete Routes delete: returns 403 when called by a viewer", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/task_manager/test/scout/api/tests/task_schedule_and_delete.spec.ts", + "line": 155, + "column": 12 + } + } + ] +} \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/task_manager/test/scout/api/fixtures/constants.ts b/x-pack/platform/plugins/shared/task_manager/test/scout/api/fixtures/constants.ts new file mode 100644 index 0000000000000..d71df7c135592 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/test/scout/api/fixtures/constants.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const COMMON_HEADERS = { + 'kbn-xsrf': 'some-xsrf-token', + 'x-elastic-internal-origin': 'kibana', + 'Content-Type': 'application/json;charset=UTF-8', +} as const; + +// Uses an existing task type registered by Task Manager internally. +// Tasks are scheduled with enabled: false so they are never claimed or executed. +export const TEST_TASK_TYPE = 'task_manager:invalidate_api_keys'; diff --git a/x-pack/platform/plugins/shared/task_manager/test/scout/api/fixtures/index.ts b/x-pack/platform/plugins/shared/task_manager/test/scout/api/fixtures/index.ts new file mode 100644 index 0000000000000..60002b0dde856 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/test/scout/api/fixtures/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ScoutTestFixtures, ScoutWorkerFixtures } from '@kbn/scout'; +import { apiTest as baseApiTest } from '@kbn/scout'; + +export const apiTest = baseApiTest.extend({}); + +export * as testData from './constants'; diff --git a/x-pack/platform/plugins/shared/task_manager/test/scout/api/playwright.config.ts b/x-pack/platform/plugins/shared/task_manager/test/scout/api/playwright.config.ts new file mode 100644 index 0000000000000..7f7249e58c1bb --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/test/scout/api/playwright.config.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createPlaywrightConfig } from '@kbn/scout'; +export default createPlaywrightConfig({ + testDir: './tests', + workers: 1, +}); diff --git a/x-pack/platform/plugins/shared/task_manager/test/scout/api/tests/task_schedule_and_delete.spec.ts b/x-pack/platform/plugins/shared/task_manager/test/scout/api/tests/task_schedule_and_delete.spec.ts new file mode 100644 index 0000000000000..c5af7fba3122b --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/test/scout/api/tests/task_schedule_and_delete.spec.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { tags } from '@kbn/scout'; +import { expect } from '@kbn/scout/api'; +import { apiTest } from '../fixtures'; +import { COMMON_HEADERS, TEST_TASK_TYPE } from '../fixtures/constants'; + +apiTest.describe( + 'Task Manager Schedule and Delete Routes', + { tag: tags.serverless.observability.complete }, + () => { + const taskIdsToCleanup: string[] = []; + + apiTest.afterAll(async ({ apiClient, samlAuth, kbnClient }) => { + const { cookieHeader } = await samlAuth.asInteractiveUser('admin'); + for (const taskId of taskIdsToCleanup) { + await apiClient + .delete(`internal/task_manager/tasks/${taskId}`, { + headers: { ...COMMON_HEADERS, ...cookieHeader }, + }) + .catch(() => {}); + } + await kbnClient.savedObjects.clean({ types: ['api_key_to_invalidate'] }); + }); + + apiTest('schedule: creates a task and returns it', async ({ apiClient, samlAuth }) => { + const { cookieHeader } = await samlAuth.asInteractiveUser('admin'); + + const response = await apiClient.post('internal/task_manager/schedule', { + headers: { ...COMMON_HEADERS, ...cookieHeader }, + body: { + task: { + taskType: TEST_TASK_TYPE, + params: {}, + state: {}, + // enabled: false so the task is never claimed or executed by the poller + enabled: false, + }, + }, + responseType: 'json', + }); + + expect(response).toHaveStatusCode(200); + const body = response.body as Record; + expect(body.id).toBeDefined(); + expect(body.taskType).toBe(TEST_TASK_TYPE); + expect(body.enabled).toBe(false); + taskIdsToCleanup.push(body.id as string); + }); + + apiTest( + 'schedule: creates a task with an interval schedule', + async ({ apiClient, samlAuth }) => { + const { cookieHeader } = await samlAuth.asInteractiveUser('admin'); + + const response = await apiClient.post('internal/task_manager/schedule', { + headers: { ...COMMON_HEADERS, ...cookieHeader }, + body: { + task: { + taskType: TEST_TASK_TYPE, + params: {}, + state: {}, + schedule: { interval: '1h' }, + // enabled: false so the task is never claimed or executed by the poller + enabled: false, + }, + }, + responseType: 'json', + }); + + expect(response).toHaveStatusCode(200); + const body = response.body as Record; + expect(body.id).toBeDefined(); + expect((body.schedule as Record)?.interval).toBe('1h'); + taskIdsToCleanup.push(body.id as string); + } + ); + + apiTest('schedule: returns 400 when taskType is missing', async ({ apiClient, samlAuth }) => { + const { cookieHeader } = await samlAuth.asInteractiveUser('admin'); + + const response = await apiClient.post('internal/task_manager/schedule', { + headers: { ...COMMON_HEADERS, ...cookieHeader }, + body: { + task: { + params: {}, + state: {}, + }, + }, + }); + + expect(response).toHaveStatusCode(400); + }); + + apiTest('schedule: returns 403 when called by a viewer', async ({ apiClient, samlAuth }) => { + const { cookieHeader } = await samlAuth.asInteractiveUser('viewer'); + + const response = await apiClient.post('internal/task_manager/schedule', { + headers: { ...COMMON_HEADERS, ...cookieHeader }, + body: { + task: { + taskType: TEST_TASK_TYPE, + params: {}, + state: {}, + }, + }, + }); + + expect(response).toHaveStatusCode(403); + }); + + apiTest('delete: removes an existing task', async ({ apiClient, samlAuth }) => { + const { cookieHeader } = await samlAuth.asInteractiveUser('admin'); + + const scheduleResponse = await apiClient.post('internal/task_manager/schedule', { + headers: { ...COMMON_HEADERS, ...cookieHeader }, + body: { + task: { + taskType: TEST_TASK_TYPE, + params: {}, + state: {}, + // enabled: false so the task is never claimed or executed by the poller + enabled: false, + }, + }, + responseType: 'json', + }); + expect(scheduleResponse).toHaveStatusCode(200); + const taskId = (scheduleResponse.body as Record).id as string; + + const deleteResponse = await apiClient.delete(`internal/task_manager/tasks/${taskId}`, { + headers: { ...COMMON_HEADERS, ...cookieHeader }, + }); + + expect(deleteResponse).toHaveStatusCode(200); + const deleteBody = deleteResponse.body as Record; + expect(deleteBody.deleted).toBe(true); + }); + + apiTest('delete: returns 404 for a non-existent task', async ({ apiClient, samlAuth }) => { + const { cookieHeader } = await samlAuth.asInteractiveUser('admin'); + + const response = await apiClient.delete('internal/task_manager/tasks/does-not-exist', { + headers: { ...COMMON_HEADERS, ...cookieHeader }, + }); + + expect(response).toHaveStatusCode(404); + }); + + apiTest('delete: returns 403 when called by a viewer', async ({ apiClient, samlAuth }) => { + const { cookieHeader } = await samlAuth.asInteractiveUser('viewer'); + + const response = await apiClient.delete('internal/task_manager/tasks/any-task-id', { + headers: { ...COMMON_HEADERS, ...cookieHeader }, + }); + + expect(response).toHaveStatusCode(403); + }); + } +); diff --git a/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/.meta/api/standard.json b/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/.meta/api/standard.json new file mode 100644 index 0000000000000..888d1b3010a0f --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/.meta/api/standard.json @@ -0,0 +1,33 @@ +{ + "sha1": "d3a6e733685da3818d4e96db30df0c9f69f3099b", + "tests": [ + { + "id": "208b6394ca1260b-100a2f007029fdb", + "title": "Task Manager API Keys scheduled task has both apiKey and uiamApiKey", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/tests/task_api_keys.spec.ts", + "line": 51, + "column": 10 + } + }, + { + "id": "208b6394ca1260b-fcb14904c934c2b", + "title": "Task Manager API Keys when task is removed, apiKey and uiamApiKey are queued for invalidation", + "expectedStatus": "passed", + "tags": [ + "@local-serverless-observability_complete", + "@cloud-serverless-observability_complete" + ], + "location": { + "file": "x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/tests/task_api_keys.spec.ts", + "line": 64, + "column": 10 + } + } + ] +} \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/fixtures/constants.ts b/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/fixtures/constants.ts new file mode 100644 index 0000000000000..d71df7c135592 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/fixtures/constants.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const COMMON_HEADERS = { + 'kbn-xsrf': 'some-xsrf-token', + 'x-elastic-internal-origin': 'kibana', + 'Content-Type': 'application/json;charset=UTF-8', +} as const; + +// Uses an existing task type registered by Task Manager internally. +// Tasks are scheduled with enabled: false so they are never claimed or executed. +export const TEST_TASK_TYPE = 'task_manager:invalidate_api_keys'; diff --git a/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/fixtures/index.ts b/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/fixtures/index.ts new file mode 100644 index 0000000000000..60002b0dde856 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/fixtures/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ScoutTestFixtures, ScoutWorkerFixtures } from '@kbn/scout'; +import { apiTest as baseApiTest } from '@kbn/scout'; + +export const apiTest = baseApiTest.extend({}); + +export * as testData from './constants'; diff --git a/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/playwright.config.ts b/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/playwright.config.ts new file mode 100644 index 0000000000000..7f7249e58c1bb --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/playwright.config.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createPlaywrightConfig } from '@kbn/scout'; +export default createPlaywrightConfig({ + testDir: './tests', + workers: 1, +}); diff --git a/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/tests/task_api_keys.spec.ts b/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/tests/task_api_keys.spec.ts new file mode 100644 index 0000000000000..afbf26a138ad1 --- /dev/null +++ b/x-pack/platform/plugins/shared/task_manager/test/scout_task_manager_uiam/api/tests/task_api_keys.spec.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { tags } from '@kbn/scout'; +import { expect } from '@kbn/scout/api'; +import { apiTest } from '../fixtures'; +import { COMMON_HEADERS, TEST_TASK_TYPE } from '../fixtures/constants'; + +apiTest.describe('Task Manager API Keys', { tag: tags.serverless.observability.complete }, () => { + let createdTaskId: string | undefined; + + apiTest.beforeAll(async ({ apiClient, samlAuth }) => { + const { cookieHeader } = await samlAuth.asInteractiveUser('admin'); + + const scheduleResponse = await apiClient.post('internal/task_manager/schedule', { + headers: { ...COMMON_HEADERS, ...cookieHeader }, + body: { + task: { + taskType: TEST_TASK_TYPE, + params: {}, + state: {}, + // enabled: false so the task is never claimed or executed by the poller + enabled: false, + }, + }, + responseType: 'json', + }); + expect(scheduleResponse).toHaveStatusCode(200); + const body = scheduleResponse.body as { id: string }; + expect(body.id).toBeDefined(); + createdTaskId = body.id; + }); + + apiTest.afterAll(async ({ apiClient, kbnClient, samlAuth }) => { + // Safety-net cleanup: remove the task in case a test failed before it got deleted. + if (createdTaskId) { + const { cookieHeader } = await samlAuth.asInteractiveUser('admin'); + await apiClient + .delete(`internal/task_manager/tasks/${createdTaskId}`, { + headers: { ...COMMON_HEADERS, ...cookieHeader }, + }) + .catch(() => {}); + } + await kbnClient.savedObjects.clean({ types: ['api_key_to_invalidate'] }); + }); + + apiTest('scheduled task has both apiKey and uiamApiKey', async ({ esClient }) => { + const { _source } = await esClient.get({ + index: '.kibana_task_manager', + id: `task:${createdTaskId}`, + }); + + expect(_source).toBeDefined(); + const taskAttrs = (_source as Record)?.task as Record; + expect(taskAttrs).toBeDefined(); + expect(taskAttrs.apiKey).toBeDefined(); + expect(taskAttrs.uiamApiKey).toBeDefined(); + }); + + apiTest( + 'when task is removed, apiKey and uiamApiKey are queued for invalidation', + async ({ apiClient, kbnClient, samlAuth }) => { + const { cookieHeader } = await samlAuth.asInteractiveUser('admin'); + + const { saved_objects: pendingBefore } = await kbnClient.savedObjects.find({ + type: 'api_key_to_invalidate', + }); + expect(pendingBefore).toHaveLength(0); + + const deleteResponse = await apiClient.delete( + `internal/task_manager/tasks/${createdTaskId}`, + { headers: { ...COMMON_HEADERS, ...cookieHeader } } + ); + expect(deleteResponse).toHaveStatusCode(200); + createdTaskId = undefined; + + const { saved_objects: pendingAfter } = await kbnClient.savedObjects.find({ + type: 'api_key_to_invalidate', + }); + + expect(pendingAfter).toHaveLength(2); + } + ); +}); diff --git a/x-pack/platform/plugins/shared/task_manager/tsconfig.json b/x-pack/platform/plugins/shared/task_manager/tsconfig.json index e678945dd00b7..301e866a11dca 100644 --- a/x-pack/platform/plugins/shared/task_manager/tsconfig.json +++ b/x-pack/platform/plugins/shared/task_manager/tsconfig.json @@ -7,7 +7,9 @@ "server/**/*", // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 "server/**/*.json", - "common/**/*" + "common/**/*", + "test/scout/**/*", + "test/scout_task_manager_uiam/**/*" ], "kbn_references": [ "@kbn/core", @@ -49,6 +51,7 @@ "@kbn/core-execution-context-common", "@kbn/core-http-server", "@kbn/core-security-server", + "@kbn/scout", ], "exclude": ["target/**/*"] }