diff --git a/x-pack/solutions/observability/plugins/synthetics/common/constants/synthetics/rest_api.ts b/x-pack/solutions/observability/plugins/synthetics/common/constants/synthetics/rest_api.ts index 838b363992f8a..2725c7e852e80 100644 --- a/x-pack/solutions/observability/plugins/synthetics/common/constants/synthetics/rest_api.ts +++ b/x-pack/solutions/observability/plugins/synthetics/common/constants/synthetics/rest_api.ts @@ -14,6 +14,7 @@ export enum SYNTHETICS_API_URLS { // public apis SYNTHETICS_MONITORS = '/api/synthetics/monitors', GET_SYNTHETICS_MONITOR = '/api/synthetics/monitors/{monitorId}', + SYNTHETICS_MONITORS_BULK_UPDATE = '/api/synthetics/monitors/_bulk_update', PRIVATE_LOCATIONS = `/api/synthetics/private_locations`, PARAMS = `/api/synthetics/params`, LATEST_TEST_RUN = '/api/synthetics/latest_test_run', diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts index 5d72e75c70480..6d241b3bb5b56 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts @@ -14,6 +14,7 @@ import { syntheticsInspectTLSRuleRoute } from './rules/inspect_tls_rule'; import { syntheticsGetLatestTestRunRoute } from './pings/get_latest_test_run'; import { deleteSyntheticsParamsBulkRoute } from './settings/params/delete_params_bulk'; import { deleteSyntheticsMonitorBulkRoute } from './monitor_cruds/bulk_cruds/delete_monitor_bulk'; +import { updateSyntheticsMonitorBulkRoute } from './monitor_cruds/bulk_cruds/update_monitor_bulk'; import { createGetDynamicSettingsRoute, createPostDynamicSettingsRoute, @@ -149,6 +150,7 @@ export const syntheticsAppPublicRestApiRoutes: SyntheticsRestApiRouteFactory[] = editSyntheticsMonitorRoute, deleteSyntheticsMonitorRoute, deleteSyntheticsMonitorBulkRoute, + updateSyntheticsMonitorBulkRoute, deleteSyntheticsParamsBulkRoute, syntheticsGetLatestTestRunRoute, testNowMonitorRoute, diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/update_monitor_bulk.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/update_monitor_bulk.test.ts new file mode 100644 index 0000000000000..6b4490d956f2c --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/update_monitor_bulk.test.ts @@ -0,0 +1,381 @@ +/* + * 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 { Type } from '@kbn/config-schema'; +import { SYNTHETICS_API_URLS } from '../../../../common/constants'; +import { updateSyntheticsMonitorBulkRoute } from './update_monitor_bulk'; + +jest.mock('../services/update_monitor_api', () => ({ + UpdateMonitorAPI: jest.fn(), +})); + +jest.mock('./edit_monitor_bulk', () => ({ + syncEditedMonitorBulk: jest.fn(), +})); + +jest.mock('../../../synthetics_service/get_private_locations', () => ({ + getPrivateLocationsForNamespaces: jest.fn().mockResolvedValue([]), +})); + +const mockResponse = () => { + const ok = jest.fn((opts: any) => ({ status: 200, ...opts })); + const badRequest = jest.fn((opts: any) => ({ status: 400, ...opts })); + const customError = jest.fn((opts: any) => ({ status: opts.statusCode, ...opts })); + return { ok, badRequest, customError }; +}; + +const mockRouteContext = (response = mockResponse()) => + ({ + request: { body: { ids: [], attributes: {} } } as any, + response, + spaceId: 'default', + server: { + logger: { error: jest.fn() }, + coreStart: { savedObjects: { createInternalRepository: jest.fn().mockReturnValue({}) } }, + } as any, + savedObjectsClient: {} as any, + monitorConfigRepository: {} as any, + syntheticsMonitorClient: {} as any, + } as any); + +const installPreprocessResult = (preprocess: any) => { + const { UpdateMonitorAPI } = jest.requireMock('../services/update_monitor_api'); + const execute = jest.fn().mockResolvedValue(preprocess); + UpdateMonitorAPI.mockImplementation(() => ({ execute, result: preprocess })); + return { execute }; +}; + +const installSyncResult = (syncResult: unknown) => { + const { syncEditedMonitorBulk } = jest.requireMock('./edit_monitor_bulk'); + syncEditedMonitorBulk.mockReset(); + if (syncResult instanceof Error) { + syncEditedMonitorBulk.mockRejectedValue(syncResult); + } else { + syncEditedMonitorBulk.mockResolvedValue(syncResult); + } + return syncEditedMonitorBulk; +}; + +describe('updateSyntheticsMonitorBulkRoute', () => { + const route = updateSyntheticsMonitorBulkRoute(); + + beforeEach(() => { + jest.clearAllMocks(); + const { getPrivateLocationsForNamespaces } = jest.requireMock( + '../../../synthetics_service/get_private_locations' + ); + getPrivateLocationsForNamespaces.mockResolvedValue([]); + }); + + describe('route shape', () => { + it('uses PUT on the bulk update path', () => { + expect(route.method).toBe('PUT'); + expect(route.path).toBe(SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_BULK_UPDATE); + }); + }); + + describe('body schema', () => { + const bodySchema = (route.validation as { request: { body: Type } }).request.body; + + it('accepts a non-empty ids array and an attributes object', () => { + expect(() => + bodySchema.validate({ + ids: ['monitor-id-1', 'monitor-id-2'], + attributes: { enabled: false }, + }) + ).not.toThrow(); + }); + + it('allows unknown keys inside attributes (treated as Partial)', () => { + expect(() => + bodySchema.validate({ + ids: ['monitor-id-1'], + attributes: { enabled: false, tags: ['critical'], schedule: { number: '5', unit: 'm' } }, + }) + ).not.toThrow(); + }); + + it('rejects an empty ids array', () => { + expect(() => bodySchema.validate({ ids: [], attributes: {} })).toThrow( + /array size is \[0\], but cannot be smaller than \[1\]/ + ); + }); + + it('rejects a missing ids field', () => { + expect(() => bodySchema.validate({ attributes: {} })).toThrow( + /\[ids\]: expected value of type \[array\] but got \[undefined\]/ + ); + }); + + it('treats a missing attributes field as an empty update — handler enforces non-empty', () => { + const value = bodySchema.validate({ ids: ['monitor-id-1'] }) as { + ids: string[]; + attributes: Record; + }; + expect(value.attributes).toEqual({}); + }); + + it('rejects non-string ids', () => { + expect(() => bodySchema.validate({ ids: [1, 2, 3], attributes: {} })).toThrow( + /\[ids\.0\]: expected value of type \[string\]/ + ); + }); + }); + + describe('handler', () => { + it('returns 400 when attributes is empty', async () => { + const ctx = mockRouteContext(); + ctx.request.body = { ids: ['mon-1'], attributes: {} }; + + const result: any = await route.handler(ctx); + + expect(ctx.response.badRequest).toHaveBeenCalledTimes(1); + expect(result.body.message).toMatch(/`attributes` is required/); + }); + + it('skips the sync when every id pre-failed', async () => { + const { execute } = installPreprocessResult({ + survivors: [], + perIdErrors: { + 'mon-1': { code: 'not_found', message: 'Monitor id mon-1 not found!' }, + 'mon-2': { code: 'invalid_origin', message: 'Origin "project" rejected' }, + }, + }); + const sync = installSyncResult({}); + + const ctx = mockRouteContext(); + ctx.request.body = { ids: ['mon-1', 'mon-2'], attributes: { enabled: false } }; + + const result: any = await route.handler(ctx); + + expect(execute).toHaveBeenCalledWith({ + ids: ['mon-1', 'mon-2'], + attributes: { enabled: false }, + }); + expect(sync).not.toHaveBeenCalled(); + expect(result.body).toEqual({ + result: [ + { id: 'mon-1', updated: false, error: 'Monitor id mon-1 not found!' }, + { id: 'mon-2', updated: false, error: 'Origin "project" rejected' }, + ], + }); + }); + + it('marks every survivor that round-trips through the sync as updated', async () => { + const survivor = (id: string) => ({ + normalizedMonitor: { id, locations: [], spaces: [] }, + monitorWithRevision: { id }, + decryptedPreviousMonitor: { id, attributes: {} }, + }); + installPreprocessResult({ + survivors: [survivor('mon-1'), survivor('mon-2')], + perIdErrors: {}, + }); + const sync = installSyncResult({ + editedMonitors: [{ id: 'mon-1' }, { id: 'mon-2' }], + failedConfigs: undefined, + errors: [], + }); + + const ctx = mockRouteContext(); + ctx.request.body = { ids: ['mon-1', 'mon-2'], attributes: { enabled: false } }; + + const result: any = await route.handler(ctx); + + expect(sync).toHaveBeenCalledTimes(1); + expect(result.body).toEqual({ + result: [ + { id: 'mon-1', updated: true }, + { id: 'mon-2', updated: true }, + ], + }); + }); + + it('preserves the request id order in the result array (mixed outcomes)', async () => { + const survivor = (id: string) => ({ + normalizedMonitor: { id, locations: [], spaces: [] }, + monitorWithRevision: { id }, + decryptedPreviousMonitor: { id, attributes: {} }, + }); + installPreprocessResult({ + survivors: [survivor('mon-ok'), survivor('mon-fleet')], + perIdErrors: { + 'mon-bad': { code: 'validation_failed', message: 'Schedule invalid' }, + 'mon-missing': { code: 'not_found', message: 'Monitor id mon-missing not found!' }, + }, + }); + installSyncResult({ + editedMonitors: [{ id: 'mon-ok' }], + failedConfigs: { + 'mon-fleet': { config: { config_id: 'mon-fleet' }, error: new Error('Fleet timeout') }, + }, + errors: [], + }); + + const ctx = mockRouteContext(); + ctx.request.body = { + // Deliberately interleaved: tests both order preservation and routing. + ids: ['mon-bad', 'mon-ok', 'mon-missing', 'mon-fleet'], + attributes: { enabled: false }, + }; + + const result: any = await route.handler(ctx); + + expect(result.body.result).toEqual([ + { id: 'mon-bad', updated: false, error: 'Schedule invalid' }, + { id: 'mon-ok', updated: true }, + { id: 'mon-missing', updated: false, error: 'Monitor id mon-missing not found!' }, + { id: 'mon-fleet', updated: false, error: 'Fleet timeout' }, + ]); + }); + + it('surfaces public sync errors as a top-level `errors` array', async () => { + const survivor = (id: string) => ({ + normalizedMonitor: { id, locations: [], spaces: [] }, + monitorWithRevision: { id }, + decryptedPreviousMonitor: { id, attributes: {} }, + }); + installPreprocessResult({ + survivors: [survivor('mon-1')], + perIdErrors: {}, + }); + installSyncResult({ + editedMonitors: [{ id: 'mon-1' }], + errors: [{ locationId: 'us_central', error: { reason: 'Timeout' } }], + }); + + const ctx = mockRouteContext(); + ctx.request.body = { ids: ['mon-1'], attributes: { enabled: false } }; + + const result: any = await route.handler(ctx); + + expect(result.body.result).toEqual([{ id: 'mon-1', updated: true }]); + expect(result.body.errors).toEqual([ + { locationId: 'us_central', error: { reason: 'Timeout' } }, + ]); + }); + + it('omits `errors` when sync reports no public location errors', async () => { + const survivor = (id: string) => ({ + normalizedMonitor: { id, locations: [], spaces: [] }, + monitorWithRevision: { id }, + decryptedPreviousMonitor: { id, attributes: {} }, + }); + installPreprocessResult({ + survivors: [survivor('mon-1')], + perIdErrors: {}, + }); + installSyncResult({ editedMonitors: [{ id: 'mon-1' }], errors: [] }); + + const ctx = mockRouteContext(); + ctx.request.body = { ids: ['mon-1'], attributes: { enabled: false } }; + + const result: any = await route.handler(ctx); + + expect(result.body).not.toHaveProperty('errors'); + }); + + it('routes per-monitor SO bulkUpdate errors to the matching id', async () => { + const survivor = (id: string) => ({ + normalizedMonitor: { id, locations: [], spaces: [] }, + monitorWithRevision: { id }, + decryptedPreviousMonitor: { id, attributes: {} }, + }); + installPreprocessResult({ + survivors: [survivor('mon-1'), survivor('mon-2')], + perIdErrors: {}, + }); + installSyncResult({ + editedMonitors: [ + { id: 'mon-1' }, + { id: 'mon-2', error: { message: 'version_conflict_engine_exception' } }, + ], + errors: [], + }); + + const ctx = mockRouteContext(); + ctx.request.body = { ids: ['mon-1', 'mon-2'], attributes: { enabled: false } }; + + const result: any = await route.handler(ctx); + + expect(result.body.result).toEqual([ + { id: 'mon-1', updated: true }, + { id: 'mon-2', updated: false, error: 'version_conflict_engine_exception' }, + ]); + }); + + it('returns 500 with the original message when sync throws (post-rollback)', async () => { + const survivor = (id: string) => ({ + normalizedMonitor: { id, locations: [], spaces: [] }, + monitorWithRevision: { id }, + decryptedPreviousMonitor: { id, attributes: {} }, + }); + installPreprocessResult({ + survivors: [survivor('mon-1')], + perIdErrors: {}, + }); + installSyncResult(new Error('ES connection refused')); + + const ctx = mockRouteContext(); + ctx.request.body = { ids: ['mon-1'], attributes: { enabled: false } }; + + const result: any = await route.handler(ctx); + + expect(ctx.response.customError).toHaveBeenCalledWith({ + statusCode: 500, + body: { message: 'ES connection refused' }, + }); + expect(result.status).toBe(500); + expect(ctx.server.logger.error).toHaveBeenCalled(); + }); + }); + + describe('private location lookup', () => { + it('passes the union of request space and survivor spaces to getPrivateLocationsForNamespaces', async () => { + const { getPrivateLocationsForNamespaces } = jest.requireMock( + '../../../synthetics_service/get_private_locations' + ); + const survivor = (id: string, spaces: string[]) => ({ + normalizedMonitor: { id, locations: [], spaces }, + monitorWithRevision: { id }, + decryptedPreviousMonitor: { id, attributes: {} }, + }); + installPreprocessResult({ + survivors: [survivor('mon-1', ['default']), survivor('mon-2', ['team-a', 'team-b'])], + perIdErrors: {}, + }); + installSyncResult({ editedMonitors: [{ id: 'mon-1' }, { id: 'mon-2' }], errors: [] }); + + const ctx = mockRouteContext(); + ctx.request.body = { ids: ['mon-1', 'mon-2'], attributes: { enabled: false } }; + + await route.handler(ctx); + + expect(getPrivateLocationsForNamespaces).toHaveBeenCalledTimes(1); + const namespacesArg = getPrivateLocationsForNamespaces.mock.calls[0][1]; + expect(new Set(namespacesArg)).toEqual(new Set(['default', 'team-a', 'team-b'])); + }); + + it('does not call getPrivateLocationsForNamespaces when every id pre-failed', async () => { + const { getPrivateLocationsForNamespaces } = jest.requireMock( + '../../../synthetics_service/get_private_locations' + ); + installPreprocessResult({ + survivors: [], + perIdErrors: { 'mon-1': { code: 'not_found', message: 'gone' } }, + }); + installSyncResult({}); + + const ctx = mockRouteContext(); + ctx.request.body = { ids: ['mon-1'], attributes: { enabled: false } }; + + await route.handler(ctx); + + expect(getPrivateLocationsForNamespaces).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/update_monitor_bulk.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/update_monitor_bulk.ts new file mode 100644 index 0000000000000..108ac084c32a5 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/update_monitor_bulk.ts @@ -0,0 +1,202 @@ +/* + * 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. + */ + +/** + * Bulk update (PUT) endpoint for Synthetics monitors. + * + * Named `update_monitor_bulk` to mirror the URL path word `_bulk_update`, + * matching the file→path convention used by `delete_monitor_bulk.ts` / + * `reset_monitor_bulk.ts`. The plugin's internal vocabulary uses `edit` + * (e.g. `editSyntheticsMonitorRoute`, `syncEditedMonitorBulk`); we keep + * `update` here to (a) match the URL path and (b) avoid colliding with + * `bulk_cruds/edit_monitor_bulk.ts`, which is the helper module that exports + * `syncEditedMonitorBulk` (the orchestrator this route reuses). + * + * Pipeline (see kibana-34 v3 diagram): + * 1. `UpdateMonitorAPI.execute` — decrypt, merge, re-encrypt; produces + * `MonitorConfigUpdate` survivors and per-id pre-errors. + * 2. If any survivors, fetch private locations covering every namespace + * involved in the patch, then call `syncEditedMonitorBulk` to write + * to ES + sync Fleet/Synthetics service in one shot. + * 3. Merge per-id pre-errors with rolled-back-on-Fleet-failure ids and + * with successful SO write ids into one ordered `result` array. Top- + * level `errors` carries service-level (not per-id) sync failures. + */ + +import { schema } from '@kbn/config-schema'; +import { isEmpty } from 'lodash'; +import type { SavedObjectError } from '@kbn/core-saved-objects-common'; +import { SYNTHETICS_API_URLS } from '../../../../common/constants'; +import type { RouteContext, SyntheticsRestApiRouteFactory } from '../../types'; +import { ConfigKey, type MonitorFields } from '../../../../common/runtime_types'; +import { UpdateMonitorAPI } from '../services/update_monitor_api'; +import type { + UpdateMonitorPerIdError, + UpdateMonitorPreprocessResult, +} from '../services/update_monitor_api'; +import { syncEditedMonitorBulk } from './edit_monitor_bulk'; +import { getPrivateLocationsForNamespaces } from '../../../synthetics_service/get_private_locations'; + +export interface UpdateMonitorBulkResultEntry { + id: string; + updated: boolean; + error?: string; +} + +export interface UpdateMonitorBulkResponse { + result: UpdateMonitorBulkResultEntry[]; + errors?: unknown[]; +} + +export const updateSyntheticsMonitorBulkRoute: SyntheticsRestApiRouteFactory< + UpdateMonitorBulkResponse, + Record, + Record, + { ids: string[]; attributes: Record } +> = () => ({ + method: 'PUT', + path: SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_BULK_UPDATE, + validate: {}, + validation: { + request: { + body: schema.object({ + ids: schema.arrayOf(schema.string(), { minSize: 1 }), + attributes: schema.object({}, { unknowns: 'allow' }), + }), + }, + }, + handler: async (routeContext) => { + const { request, response, server, spaceId } = routeContext; + const { ids, attributes } = request.body || {}; + + if (isEmpty(attributes)) { + return response.badRequest({ + body: { + message: '`attributes` is required and must contain at least one field to update.', + }, + }); + } + + const updateAPI = new UpdateMonitorAPI(routeContext); + const preprocess = await updateAPI.execute({ ids, attributes }); + + if (preprocess.survivors.length === 0) { + /* + * Every requested id failed pre-processing. Skip the sync call and + * its private-location fetch entirely — there is nothing to write. + */ + return response.ok({ + body: buildResponseBody(ids, preprocess), + }); + } + + try { + const privateLocations = await loadPrivateLocationsForSurvivors(routeContext, preprocess); + const sync = await syncEditedMonitorBulk({ + routeContext, + spaceId, + monitorsToUpdate: preprocess.survivors, + privateLocations, + }); + + return response.ok({ + body: buildResponseBody(ids, preprocess, sync), + }); + } catch (error) { + /* + * `syncEditedMonitorBulk` already attempted a complete rollback before + * rethrowing, so the SO state is back to pre-call. Surface a 500 with + * the original message — this is a true server error, not a per-id + * failure (those are reported in `result[].error`). + */ + server.logger.error(`Bulk update failed during sync: ${error.message}`, { error }); + return response.customError({ + statusCode: 500, + body: { message: error.message }, + }); + } + }, +}); + +/** + * Compute the namespace set the sync needs to cover: the request space + * plus every space any survivor monitor is shared to. Mirrors the + * single-PUT flow at `edit_monitor.ts` (post-merge spaces, not pre). + */ +const loadPrivateLocationsForSurvivors = async ( + routeContext: RouteContext, + preprocess: UpdateMonitorPreprocessResult +) => { + const { server, spaceId } = routeContext; + const namespaces = new Set([spaceId]); + for (const { normalizedMonitor } of preprocess.survivors) { + const spaces = (normalizedMonitor as MonitorFields)[ConfigKey.KIBANA_SPACES] ?? []; + for (const s of spaces) { + if (s) namespaces.add(s); + } + } + const internalClient = server.coreStart.savedObjects.createInternalRepository(); + return getPrivateLocationsForNamespaces(internalClient, [...namespaces]); +}; + +const buildResponseBody = ( + requestedIds: string[], + preprocess: UpdateMonitorPreprocessResult, + sync?: Awaited> +): UpdateMonitorBulkResponse => { + const result: UpdateMonitorBulkResultEntry[] = requestedIds.map((id) => + classifyId(id, preprocess.perIdErrors[id], sync) + ); + const errors = sync?.errors && sync.errors.length > 0 ? sync.errors : undefined; + return errors !== undefined ? { result, errors } : { result }; +}; + +const classifyId = ( + id: string, + preError: UpdateMonitorPerIdError | undefined, + sync: Awaited> | undefined +): UpdateMonitorBulkResultEntry => { + if (preError) { + return { id, updated: false, error: preError.message }; + } + + /* + * `failedConfigs` only contains entries where `syncEditedMonitorBulk` + * already rolled the SO back to its previous attributes, so `updated` + * is correctly `false` for these. + */ + const fleetFailure = sync?.failedConfigs?.[id]; + if (fleetFailure) { + return { + id, + updated: false, + error: + extractErrorMessage(fleetFailure.error) ?? 'Failed to sync monitor to private location', + }; + } + + const editedMonitorSO = sync?.editedMonitors?.find((m) => m.id === id); + if (editedMonitorSO?.error) { + return { id, updated: false, error: editedMonitorSO.error.message }; + } + if (editedMonitorSO) { + return { id, updated: true }; + } + + /* + * Survivor that does not appear in either bucket: should be unreachable, + * but keep the response shape predictable rather than silently dropping + * the id from the result array. + */ + return { id, updated: false, error: 'Monitor was not processed' }; +}; + +const extractErrorMessage = (err: Error | SavedObjectError | undefined): string | undefined => { + if (!err) return undefined; + if (typeof (err as Error).message === 'string') return (err as Error).message; + return undefined; +}; diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/update_monitor_api.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/update_monitor_api.test.ts new file mode 100644 index 0000000000000..9a1530c1d3677 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/update_monitor_api.test.ts @@ -0,0 +1,490 @@ +/* + * 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 { ConfigKey, ScheduleUnit } from '../../../../common/runtime_types'; +import { UpdateMonitorAPI } from './update_monitor_api'; + +jest.mock('../../../synthetics_service/get_private_locations', () => ({ + getPrivateLocations: jest.fn().mockResolvedValue([]), +})); + +jest.mock('../edit_monitor', () => ({ + validateLocationPermissions: jest + .fn() + .mockResolvedValue({ elasticManagedLocationsEnabled: true, canManagePrivateLocations: true }), +})); + +jest.mock('../project_monitor/add_monitor_project', () => ({ + ELASTIC_MANAGED_LOCATIONS_DISABLED: 'Elastic managed locations are disabled', +})); + +jest.mock('../monitor_locations_utils', () => ({ + assertCanUpdateMonitorInAllSpaces: jest.fn().mockResolvedValue(undefined), + validateMonitorPrivateLocationSpaces: jest.fn().mockReturnValue(null), +})); + +jest.mock('../monitor_validation', () => ({ + validateMonitor: jest.fn(), +})); + +jest.mock('../../../synthetics_service/utils/secrets', () => ({ + /* + * Pass-through mocks: tests assert structural changes (revision bump, hash + * reset, AAD attribute carry-over) on the output of `formatSecrets`, so we + * keep the input shape intact rather than wrap secrets into JSON. The real + * functions are exercised in `edit_monitor_bulk.test.ts`. + */ + formatSecrets: jest.fn((monitor: any) => ({ ...monitor })), + normalizeSecrets: jest.fn((so: any) => ({ ...so, attributes: { ...so.attributes } })), +})); + +jest.mock('../../common', () => ({ + getSavedObjectKqlFilter: jest.fn(() => 'mock-filter'), +})); + +const mockDecryptedMonitor = (overrides: Partial> = {}) => { + const id = (overrides.id as string) ?? 'mon-1'; + const attributes: Record = { + id, + type: 'http', + name: 'My Monitor', + enabled: true, + locations: [{ id: 'us_central', isServiceManaged: true }], + schedule: { number: '5', unit: 'm' }, + tags: ['tag-a'], + [ConfigKey.MONITOR_SOURCE_TYPE]: 'ui', + [ConfigKey.REVISION]: 3, + [ConfigKey.CONFIG_HASH]: 'old-hash', + secrets: '{}', + ...(overrides.attributes as Record), + }; + return { + id, + type: 'synthetics-monitor-multi-space', + references: [], + score: 1, + namespaces: ['default'], + attributes, + }; +}; + +const mockValidationResultFor = (attributes: Record) => ({ + valid: true, + reason: '', + details: '', + payload: attributes, + decodedMonitor: { ...attributes }, +}); + +const createMockRouteContext = () => { + const findDecryptedMonitors = jest.fn().mockResolvedValue([]); + return { + routeContext: { + request: {} as any, + response: { forbidden: jest.fn((opts: any) => opts) } as any, + spaceId: 'default', + server: { logger: { error: jest.fn() } } as any, + savedObjectsClient: {} as any, + monitorConfigRepository: { findDecryptedMonitors } as any, + } as any, + mocks: { findDecryptedMonitors }, + }; +}; + +describe('UpdateMonitorAPI', () => { + beforeEach(() => { + /* + * Clear call history first, then re-install default implementations. + * Without `clearAllMocks` here, leftover calls from earlier tests leak + * into `toHaveBeenCalledTimes` assertions in this file. + */ + jest.clearAllMocks(); + + const { validateMonitor } = jest.requireMock('../monitor_validation'); + validateMonitor.mockImplementation((m: Record) => mockValidationResultFor(m)); + + const { validateLocationPermissions } = jest.requireMock('../edit_monitor'); + validateLocationPermissions.mockResolvedValue({ + elasticManagedLocationsEnabled: true, + canManagePrivateLocations: true, + }); + + const { assertCanUpdateMonitorInAllSpaces, validateMonitorPrivateLocationSpaces } = + jest.requireMock('../monitor_locations_utils'); + assertCanUpdateMonitorInAllSpaces.mockResolvedValue(undefined); + validateMonitorPrivateLocationSpaces.mockReturnValue(null); + + const { getPrivateLocations } = jest.requireMock( + '../../../synthetics_service/get_private_locations' + ); + getPrivateLocations.mockResolvedValue([]); + }); + + describe('happy path', () => { + it('produces a single survivor with bumped revision and reset hash', async () => { + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([mockDecryptedMonitor()]); + + const api = new UpdateMonitorAPI(routeContext); + const result = await api.execute({ ids: ['mon-1'], attributes: { enabled: false } }); + + expect(result.perIdErrors).toEqual({}); + expect(result.survivors).toHaveLength(1); + const survivor = result.survivors[0]; + expect(survivor.decryptedPreviousMonitor.id).toBe('mon-1'); + expect(survivor.monitorWithRevision[ConfigKey.REVISION]).toBe(4); + expect(survivor.monitorWithRevision[ConfigKey.CONFIG_HASH]).toBe(''); + }); + + it('preserves AAD attributes not present in the patch (regression test)', async () => { + /* + * This is the headline guarantee of this endpoint: a partial patch must + * not strip AAD-bound attributes from the encrypted SO. If + * `mergeSourceMonitor` ever stops carrying over previous fields, the + * decrypt-merge-encrypt cycle will produce a monitor that fails to + * decrypt on next read. Test asserts every previous AAD attribute we + * did not patch is still present in the survivor's monitorWithRevision. + */ + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([mockDecryptedMonitor()]); + + const api = new UpdateMonitorAPI(routeContext); + const result = await api.execute({ ids: ['mon-1'], attributes: { enabled: false } }); + + const attrs = result.survivors[0].monitorWithRevision as Record; + expect(attrs.name).toBe('My Monitor'); + expect(attrs.tags).toEqual(['tag-a']); + expect(attrs.schedule).toEqual({ number: '5', unit: 'm' }); + expect(attrs.locations).toEqual([{ id: 'us_central', isServiceManaged: true }]); + expect(attrs.enabled).toBe(false); + }); + + it('processes multiple monitors with a single decrypt round-trip', async () => { + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([ + mockDecryptedMonitor({ id: 'mon-1' }), + mockDecryptedMonitor({ id: 'mon-2' }), + ]); + + const api = new UpdateMonitorAPI(routeContext); + const result = await api.execute({ ids: ['mon-1', 'mon-2'], attributes: { enabled: false } }); + + expect(mocks.findDecryptedMonitors).toHaveBeenCalledTimes(1); + expect(result.survivors).toHaveLength(2); + expect(result.survivors.map((s) => s.decryptedPreviousMonitor.id).sort()).toEqual([ + 'mon-1', + 'mon-2', + ]); + }); + }); + + describe('not_found', () => { + it('records every requested id missing from the result set', async () => { + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([mockDecryptedMonitor({ id: 'mon-1' })]); + + const api = new UpdateMonitorAPI(routeContext); + const result = await api.execute({ + ids: ['mon-1', 'missing-1', 'missing-2'], + attributes: { enabled: false }, + }); + + expect(result.survivors).toHaveLength(1); + expect(result.perIdErrors['missing-1'].code).toBe('not_found'); + expect(result.perIdErrors['missing-2'].code).toBe('not_found'); + expect(result.perIdErrors['missing-1'].message).toContain('missing-1'); + }); + }); + + describe('invalid_origin', () => { + it.each(['project', 'agent'])('rejects origin %s', async (origin) => { + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([ + mockDecryptedMonitor({ attributes: { [ConfigKey.MONITOR_SOURCE_TYPE]: origin } }), + ]); + + const api = new UpdateMonitorAPI(routeContext); + const result = await api.execute({ ids: ['mon-1'], attributes: { enabled: false } }); + + expect(result.survivors).toHaveLength(0); + expect(result.perIdErrors['mon-1'].code).toBe('invalid_origin'); + expect(result.perIdErrors['mon-1'].message).toContain(origin); + }); + + it('does not call validateMonitor for rejected origins (short-circuit)', async () => { + const { validateMonitor } = jest.requireMock('../monitor_validation'); + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([ + mockDecryptedMonitor({ attributes: { [ConfigKey.MONITOR_SOURCE_TYPE]: 'project' } }), + ]); + + const api = new UpdateMonitorAPI(routeContext); + await api.execute({ ids: ['mon-1'], attributes: { enabled: false } }); + + expect(validateMonitor).not.toHaveBeenCalled(); + }); + }); + + describe('validation_failed', () => { + it('records the io-ts failure reason and details', async () => { + const { validateMonitor } = jest.requireMock('../monitor_validation'); + validateMonitor.mockReturnValue({ + valid: false, + reason: 'Monitor schedule is invalid', + details: 'Invalid schedule 7 minutes...', + payload: {}, + }); + + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([mockDecryptedMonitor()]); + + const api = new UpdateMonitorAPI(routeContext); + const result = await api.execute({ + ids: ['mon-1'], + attributes: { schedule: { number: '7', unit: ScheduleUnit.MINUTES } }, + }); + + expect(result.survivors).toHaveLength(0); + expect(result.perIdErrors['mon-1']).toEqual({ + code: 'validation_failed', + message: 'Monitor schedule is invalid', + details: 'Invalid schedule 7 minutes...', + }); + }); + }); + + describe('forbidden', () => { + it('records elastic-managed-locations permission failures', async () => { + const { validateLocationPermissions } = jest.requireMock('../edit_monitor'); + validateLocationPermissions.mockResolvedValue({ elasticManagedLocationsEnabled: false }); + + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([mockDecryptedMonitor()]); + + const api = new UpdateMonitorAPI(routeContext); + const result = await api.execute({ ids: ['mon-1'], attributes: { enabled: false } }); + + expect(result.survivors).toHaveLength(0); + expect(result.perIdErrors['mon-1']).toEqual({ + code: 'forbidden', + message: 'Elastic managed locations are disabled', + }); + }); + + it('records multi-space privilege failures (without leaking the response object)', async () => { + const { assertCanUpdateMonitorInAllSpaces } = jest.requireMock('../monitor_locations_utils'); + assertCanUpdateMonitorInAllSpaces.mockResolvedValue({ status: 403 }); + + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([ + mockDecryptedMonitor({ + attributes: { [ConfigKey.KIBANA_SPACES]: ['default', 'other-space'] }, + }), + ]); + + const api = new UpdateMonitorAPI(routeContext); + const result = await api.execute({ ids: ['mon-1'], attributes: { enabled: false } }); + + expect(result.survivors).toHaveLength(0); + expect(result.perIdErrors['mon-1'].code).toBe('forbidden'); + expect(result.perIdErrors['mon-1'].message).not.toMatch(/\[object Object\]/); + }); + + it('records private-location-space coverage failures', async () => { + const { getPrivateLocations } = jest.requireMock( + '../../../synthetics_service/get_private_locations' + ); + getPrivateLocations.mockResolvedValue([{ id: 'pl-1', label: 'PL', spaces: ['default'] }]); + + const { validateMonitorPrivateLocationSpaces } = jest.requireMock( + '../monitor_locations_utils' + ); + validateMonitorPrivateLocationSpaces.mockReturnValue({ + message: 'PL is not available in space "team-b"', + attributes: { errors: [] }, + }); + + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([ + mockDecryptedMonitor({ + attributes: { locations: [{ id: 'pl-1', isServiceManaged: false }] }, + }), + ]); + + const api = new UpdateMonitorAPI(routeContext); + /* + * Patch must touch `locations` or `spaces` to trigger the + * private-locations fetch — see `maybeLoadPrivateLocations`. + */ + const result = await api.execute({ + ids: ['mon-1'], + attributes: { [ConfigKey.KIBANA_SPACES]: ['default', 'team-b'] }, + }); + + expect(result.survivors).toHaveLength(0); + expect(result.perIdErrors['mon-1']).toEqual({ + code: 'forbidden', + message: 'PL is not available in space "team-b"', + }); + }); + }); + + describe('mixed batch', () => { + it('routes each id to the correct slot and lets survivors through', async () => { + const { validateMonitor } = jest.requireMock('../monitor_validation'); + validateMonitor.mockImplementation((m: Record) => { + if ((m.id as string) === 'mon-bad') { + return { valid: false, reason: 'bad', details: 'bad', payload: m }; + } + return mockValidationResultFor(m); + }); + + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([ + mockDecryptedMonitor({ id: 'mon-ok' }), + mockDecryptedMonitor({ + id: 'mon-project', + attributes: { [ConfigKey.MONITOR_SOURCE_TYPE]: 'project' }, + }), + mockDecryptedMonitor({ id: 'mon-bad' }), + ]); + + const api = new UpdateMonitorAPI(routeContext); + const result = await api.execute({ + ids: ['mon-ok', 'mon-project', 'mon-bad', 'mon-missing'], + attributes: { enabled: false }, + }); + + expect(result.survivors).toHaveLength(1); + expect(result.survivors[0].decryptedPreviousMonitor.id).toBe('mon-ok'); + expect(result.perIdErrors['mon-project'].code).toBe('invalid_origin'); + expect(result.perIdErrors['mon-bad'].code).toBe('validation_failed'); + expect(result.perIdErrors['mon-missing'].code).toBe('not_found'); + }); + }); + + describe('permission checks resolve once per request', () => { + it('resolves the elastic-managed-locations capability once for N public-location monitors', async () => { + const { validateLocationPermissions } = jest.requireMock('../edit_monitor'); + + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([ + mockDecryptedMonitor({ id: 'mon-1' }), + mockDecryptedMonitor({ id: 'mon-2' }), + mockDecryptedMonitor({ id: 'mon-3' }), + ]); + + const api = new UpdateMonitorAPI(routeContext); + const result = await api.execute({ + ids: ['mon-1', 'mon-2', 'mon-3'], + attributes: { enabled: false }, + }); + + expect(result.survivors).toHaveLength(3); + expect(validateLocationPermissions).toHaveBeenCalledTimes(1); + }); + + it('skips the location-capability check when no monitor has a public location', async () => { + const { validateLocationPermissions } = jest.requireMock('../edit_monitor'); + + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([ + mockDecryptedMonitor({ + id: 'mon-1', + attributes: { locations: [{ id: 'pl-1', isServiceManaged: false }] }, + }), + ]); + + const api = new UpdateMonitorAPI(routeContext); + await api.execute({ ids: ['mon-1'], attributes: { enabled: false } }); + + expect(validateLocationPermissions).not.toHaveBeenCalled(); + }); + + it('checks bulk_update space privileges once per unique space set', async () => { + const { assertCanUpdateMonitorInAllSpaces } = jest.requireMock('../monitor_locations_utils'); + + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([ + mockDecryptedMonitor({ + id: 'mon-1', + attributes: { [ConfigKey.KIBANA_SPACES]: ['default', 'team-b'] }, + }), + mockDecryptedMonitor({ + id: 'mon-2', + // same set, different order -> same cache key, no extra call + attributes: { [ConfigKey.KIBANA_SPACES]: ['team-b', 'default'] }, + }), + mockDecryptedMonitor({ + id: 'mon-3', + attributes: { [ConfigKey.KIBANA_SPACES]: ['default', 'team-c'] }, + }), + ]); + + const api = new UpdateMonitorAPI(routeContext); + const result = await api.execute({ + ids: ['mon-1', 'mon-2', 'mon-3'], + attributes: { enabled: false }, + }); + + expect(result.survivors).toHaveLength(3); + // two distinct space sets -> two privilege checks, not three + expect(assertCanUpdateMonitorInAllSpaces).toHaveBeenCalledTimes(2); + }); + }); + + describe('private locations fetch', () => { + it('skips the SO read when the patch does not touch locations or spaces', async () => { + const { getPrivateLocations } = jest.requireMock( + '../../../synthetics_service/get_private_locations' + ); + + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([mockDecryptedMonitor()]); + + const api = new UpdateMonitorAPI(routeContext); + await api.execute({ ids: ['mon-1'], attributes: { enabled: false, tags: ['x'] } }); + + expect(getPrivateLocations).not.toHaveBeenCalled(); + }); + + it('fetches once per execute when the patch touches locations', async () => { + const { getPrivateLocations } = jest.requireMock( + '../../../synthetics_service/get_private_locations' + ); + + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([ + mockDecryptedMonitor({ id: 'mon-1' }), + mockDecryptedMonitor({ id: 'mon-2' }), + ]); + + const api = new UpdateMonitorAPI(routeContext); + await api.execute({ + ids: ['mon-1', 'mon-2'], + attributes: { + locations: [{ id: 'us_central', label: 'US Central', isServiceManaged: true }], + }, + }); + + expect(getPrivateLocations).toHaveBeenCalledTimes(1); + }); + }); + + describe('empty input', () => { + it('returns empty result for empty ids without calling SO client', async () => { + const { routeContext, mocks } = createMockRouteContext(); + mocks.findDecryptedMonitors.mockResolvedValue([]); + + const api = new UpdateMonitorAPI(routeContext); + const result = await api.execute({ ids: [], attributes: { enabled: false } }); + + expect(result.survivors).toEqual([]); + expect(result.perIdErrors).toEqual({}); + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/update_monitor_api.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/update_monitor_api.ts new file mode 100644 index 0000000000000..aef52db9e7780 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/update_monitor_api.ts @@ -0,0 +1,389 @@ +/* + * 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. + */ + +/** + * Pre-processing service for the bulk update endpoint + * (`PUT /api/synthetics/monitors/_bulk_update`). + * + * Mirrors the per-monitor pipeline that `editSyntheticsMonitorRoute` runs + * for a single PUT, but produces a list of "survivors" and a per-id error + * map so the route handler can hand survivors to `syncEditedMonitorBulk` + * in one batch (Step 3 wires that up). + * + * Pipeline per monitor id: + * 1. Bulk decrypt (single round-trip via `findDecryptedMonitors`) + * 2. `not_found` diff for ids missing from the result set + * 3. Reject project-origin monitors (Option A — see kibana-34 bead) + * 4. `mergeSourceMonitor` (deep-merge METADATA, shallow-merge ALERT_CONFIG, + * everything else overwrites) — this is what re-builds the AAD-bound + * attribute set so `syncEditedMonitorBulk` can re-encrypt safely + * 5. io-ts validation via `validateMonitor` on the merged payload + * 6. Per-monitor permission checks (Elastic-managed locations + multi-space + * bulk_update privilege) + * 7. Bump revision, reset CONFIG_HASH, run `formatSecrets` to produce the + * `monitorWithRevision` shape `syncEditedMonitorBulk` expects + * + * Survivors are the input shape for `syncEditedMonitorBulk` (see + * `MonitorConfigUpdate` in `bulk_cruds/edit_monitor_bulk.ts`). + */ + +import type { SavedObjectsFindResult } from '@kbn/core-saved-objects-api-server'; +import { i18n } from '@kbn/i18n'; +import { getSavedObjectKqlFilter } from '../../common'; +import type { MonitorConfigUpdate } from '../bulk_cruds/edit_monitor_bulk'; +import { mergeSourceMonitor } from '../formatters/saved_object_to_monitor'; +import { + assertCanUpdateMonitorInAllSpaces, + validateMonitorPrivateLocationSpaces, +} from '../monitor_locations_utils'; +import { validateMonitor } from '../monitor_validation'; +import { validateLocationPermissions } from '../edit_monitor'; +import { ELASTIC_MANAGED_LOCATIONS_DISABLED } from '../project_monitor/add_monitor_project'; +import type { RouteContext } from '../../types'; +import { + ConfigKey, + type EncryptedSyntheticsMonitor, + type MonitorFields, + type SyntheticsMonitor, + type SyntheticsMonitorWithSecretsAttributes, + type SyntheticsPrivateLocations, +} from '../../../../common/runtime_types'; +import { formatSecrets, normalizeSecrets } from '../../../synthetics_service/utils/secrets'; +import { getPrivateLocations } from '../../../synthetics_service/get_private_locations'; + +export type UpdateMonitorErrorCode = + | 'not_found' + | 'invalid_origin' + | 'validation_failed' + | 'forbidden'; + +export interface UpdateMonitorPerIdError { + code: UpdateMonitorErrorCode; + message: string; + details?: string; +} + +export interface UpdateMonitorPreprocessResult { + survivors: MonitorConfigUpdate[]; + perIdErrors: Record; +} + +interface ExecuteParams { + ids: string[]; + attributes: Partial; +} + +export class UpdateMonitorAPI { + routeContext: RouteContext; + result: UpdateMonitorPreprocessResult = { survivors: [], perIdErrors: {} }; + + /* + * Request-scoped permission caches. A new instance is created per request, + * so these turn the previous per-monitor permission round-trips into a + * single resolution (capabilities) / one-per-distinct-space-set (privileges). + */ + private locationPermissionsPromise?: ReturnType; + private readonly spacePermissionCache = new Map< + string, + ReturnType + >(); + + constructor(routeContext: RouteContext) { + this.routeContext = routeContext; + } + + async execute({ ids, attributes }: ExecuteParams): Promise { + const decryptedMonitors = await this.findDecryptedMonitors(ids); + this.markNotFound(ids, decryptedMonitors); + + /* + * Fetched once per `execute()` to avoid an SO read per monitor inside the + * per-id loop. Skipped entirely if the patch has no chance of touching + * private locations — most patches (e.g. `{ enabled: false }`) won't. + */ + const allPrivateLocations = await this.maybeLoadPrivateLocations(attributes); + + for (const decryptedMonitor of decryptedMonitors) { + await this.processMonitor(decryptedMonitor, attributes, allPrivateLocations); + } + + return this.result; + } + + /** + * Single decrypt round-trip. Uses a CONFIG_ID-based KQL filter so the SO + * client can fetch every requested monitor in one call (vs. the per-id + * loop that `ResetMonitorAPI` and `DeleteMonitorAPI` use). This is the + * main reason this endpoint scales better than calling PUT N times. + */ + private async findDecryptedMonitors( + ids: string[] + ): Promise>> { + const { monitorConfigRepository, spaceId } = this.routeContext; + const filter = getSavedObjectKqlFilter({ + field: ConfigKey.CONFIG_ID, + values: ids, + }); + return monitorConfigRepository.findDecryptedMonitors({ spaceId, filter }); + } + + private markNotFound( + ids: string[], + decryptedMonitors: Array> + ) { + const foundIds = new Set(decryptedMonitors.map((m) => m.id)); + for (const id of ids) { + if (!foundIds.has(id)) { + this.result.perIdErrors[id] = { + code: 'not_found', + message: notFoundMessage(id), + }; + } + } + } + + private async processMonitor( + decryptedMonitor: SavedObjectsFindResult, + patch: Partial, + allPrivateLocations: SyntheticsPrivateLocations + ) { + const monitorId = decryptedMonitor.id; + + if (this.shouldRejectProjectMonitor(decryptedMonitor.attributes)) { + this.result.perIdErrors[monitorId] = { + code: 'invalid_origin', + message: invalidOriginMessage(decryptedMonitor.attributes[ConfigKey.MONITOR_SOURCE_TYPE]), + }; + return; + } + + const { prevAttrs, merged } = this.mergePatch(decryptedMonitor, patch); + + const validation = validateMonitor(merged as MonitorFields, this.routeContext.spaceId); + if (!validation.valid || !validation.decodedMonitor) { + this.result.perIdErrors[monitorId] = { + code: 'validation_failed', + message: validation.reason, + details: validation.details, + }; + return; + } + const decodedMonitor = validation.decodedMonitor; + + const forbidden = await this.checkPermissions(decodedMonitor, decryptedMonitor.type); + if (forbidden) { + this.result.perIdErrors[monitorId] = forbidden; + return; + } + + const plSpaceError = this.checkPrivateLocationSpaces(decodedMonitor, allPrivateLocations); + if (plSpaceError) { + this.result.perIdErrors[monitorId] = plSpaceError; + return; + } + + this.result.survivors.push(this.buildSurvivor(decryptedMonitor, decodedMonitor, prevAttrs)); + } + + /** + * Origin policy (Option A from the kibana-34 plan): reject anything whose + * existing `origin` is not `'ui'`. Mirrors the single-PUT precedent at + * `edit_monitor.ts` line 103. If/when we flip to Option B (allow patches + * limited to "sticky" project-monitor fields like `enabled`), this is the + * single seam to extend — accept the patch on the same predicate that + * `ProjectMonitorFormatter` uses to skip overwriting on the next CLI push. + */ + private shouldRejectProjectMonitor(prevAttrs: SyntheticsMonitorWithSecretsAttributes): boolean { + return prevAttrs[ConfigKey.MONITOR_SOURCE_TYPE] !== 'ui'; + } + + /** + * Decrypt the previous attributes back into monitor shape (so secrets are + * re-merged), strip REVISION (we own that), then run `mergeSourceMonitor` + * which knows to deep-merge METADATA / shallow-merge ALERT_CONFIG. + * + * Important: we do NOT call `formatSecrets` here. Validation runs on the + * decrypted shape; `formatSecrets` happens once at the end, only for + * survivors, in `buildSurvivor`. + */ + private mergePatch( + decryptedMonitor: SavedObjectsFindResult, + patch: Partial + ): { prevAttrs: SyntheticsMonitor; merged: EncryptedSyntheticsMonitor } { + const { attributes: prevAttrs } = normalizeSecrets(decryptedMonitor); + const { [ConfigKey.REVISION]: _, ...prevAttrsForMerge } = prevAttrs; + const merged = mergeSourceMonitor( + prevAttrsForMerge as EncryptedSyntheticsMonitor, + patch as EncryptedSyntheticsMonitor + ); + return { prevAttrs, merged }; + } + + /** + * Per-monitor permission gate. Both underlying checks are request-scoped + * (they depend on the caller and the target spaces, not on the individual + * monitor), so each resolves at most once per bulk request: + * - Elastic-managed-locations capability: cached for the whole request + * via `getLocationPermissions` (the first survivor with a public + * location pays for `resolveCapabilities`; the rest reuse it). + * - Saved-objects `bulk_update` privilege: cached per unique space set + * via `assertCanUpdateInSpaces`. + * This turns the previous O(N) capability/privilege calls into O(1) / + * O(distinct space sets). + */ + private async checkPermissions( + decodedMonitor: SyntheticsMonitor, + savedObjectType: string + ): Promise { + const hasPublicLocations = (decodedMonitor.locations ?? []).some((loc) => loc.isServiceManaged); + if (hasPublicLocations) { + const { elasticManagedLocationsEnabled } = await this.getLocationPermissions(); + if (!elasticManagedLocationsEnabled) { + return { code: 'forbidden', message: ELASTIC_MANAGED_LOCATIONS_DISABLED }; + } + } + + const editedMonitorSpaces = (decodedMonitor as MonitorFields)[ConfigKey.KIBANA_SPACES] ?? []; + if (editedMonitorSpaces.length === 0) { + return undefined; + } + + /* + * `assertCanUpdateMonitorInAllSpaces` returns either `undefined` (OK) or + * a Kibana `forbidden` response object (used by single-PUT for early + * return). We can't propagate the response object to a per-id slot, so + * we collapse the failure to a generic "missing space privileges" + * message. The privilege itself was already audit-logged by core when + * `checkSavedObjectsPrivileges` ran inside the assertion. + */ + const spaceAuthError = await this.assertCanUpdateInSpaces(editedMonitorSpaces, savedObjectType); + if (spaceAuthError) { + return { code: 'forbidden', message: insufficientSpacePermissionsMessage() }; + } + return undefined; + } + + /** + * Resolve (and cache) the caller's Elastic-managed-locations capability. + * The answer is request-scoped, so we only pay for the underlying + * `resolveCapabilities` round-trip once per bulk request. + */ + private getLocationPermissions(): ReturnType { + if (!this.locationPermissionsPromise) { + this.locationPermissionsPromise = validateLocationPermissions(this.routeContext); + } + return this.locationPermissionsPromise; + } + + /** + * Memoize the saved-objects `bulk_update` privilege check per unique + * `(savedObjectType, sorted space set)` key, so monitors shared to the + * same spaces resolve to a single `checkSavedObjectsPrivileges` call. + */ + private assertCanUpdateInSpaces( + spaceIds: string[], + savedObjectType: string + ): ReturnType { + const key = `${savedObjectType}::${[...new Set(spaceIds)].sort().join(',')}`; + let cached = this.spacePermissionCache.get(key); + if (!cached) { + cached = assertCanUpdateMonitorInAllSpaces(this.routeContext, spaceIds, savedObjectType); + this.spacePermissionCache.set(key, cached); + } + return cached; + } + + /** + * Pre-fetch the private location SOs only when the patch could plausibly + * affect private-location-space coverage: either the patch references + * `locations` directly, or it changes the spaces the monitor is shared + * to (because that broadens the set of spaces the existing private + * locations must already cover). + */ + private async maybeLoadPrivateLocations( + patch: Partial + ): Promise { + const touchesLocationsOrSpaces = + patch[ConfigKey.LOCATIONS] !== undefined || patch[ConfigKey.KIBANA_SPACES] !== undefined; + if (!touchesLocationsOrSpaces) { + return []; + } + return getPrivateLocations(this.routeContext.savedObjectsClient); + } + + private checkPrivateLocationSpaces( + decodedMonitor: SyntheticsMonitor, + allPrivateLocations: SyntheticsPrivateLocations + ): UpdateMonitorPerIdError | undefined { + if (allPrivateLocations.length === 0) { + return undefined; + } + + const monitorPrivateLocations = (decodedMonitor.locations ?? []).filter( + (loc) => !loc.isServiceManaged + ); + if (monitorPrivateLocations.length === 0) { + return undefined; + } + + const plSpaceError = validateMonitorPrivateLocationSpaces( + decodedMonitor as MonitorFields, + allPrivateLocations + ); + if (!plSpaceError) { + return undefined; + } + return { code: 'forbidden', message: plSpaceError.message }; + } + + /** + * Build the input shape `syncEditedMonitorBulk` expects: + * - `normalizedMonitor`: io-ts-decoded merged monitor (no secret rewrap) + * - `monitorWithRevision`: same shape with `revision` bumped, `hash` + * reset to `''` (so the next CLI `push` re-evaluates the AAD set) + * and `formatSecrets` applied so secrets are wrapped back into the + * encrypted-attribute envelope. + * - `decryptedPreviousMonitor`: original decrypted SO, kept verbatim + * for `bulk_cruds/edit_monitor_bulk.ts` to use as rollback source. + */ + private buildSurvivor( + decryptedMonitor: SavedObjectsFindResult, + decodedMonitor: SyntheticsMonitor, + prevAttrs: SyntheticsMonitor + ): MonitorConfigUpdate { + const monitorWithRevision = formatSecrets({ + ...decodedMonitor, + [ConfigKey.CONFIG_HASH]: '', + [ConfigKey.REVISION]: (prevAttrs[ConfigKey.REVISION] || 0) + 1, + }); + + return { + normalizedMonitor: decodedMonitor, + monitorWithRevision, + decryptedPreviousMonitor: decryptedMonitor, + }; + } +} + +const notFoundMessage = (id: string) => + i18n.translate('xpack.synthetics.server.bulkUpdate.notFound', { + defaultMessage: 'Monitor id {id} not found!', + values: { id }, + }); + +const invalidOriginMessage = (origin: string | undefined) => + i18n.translate('xpack.synthetics.server.bulkUpdate.invalidOrigin', { + defaultMessage: + 'Monitors of origin "{origin}" cannot be edited via the bulk update API. Use the dedicated workflow for that origin instead.', + values: { origin: origin ?? 'unknown' }, + }); + +const insufficientSpacePermissionsMessage = () => + i18n.translate('xpack.synthetics.server.bulkUpdate.forbiddenSpacePermissions', { + defaultMessage: + 'Insufficient permissions to update this monitor in all spaces it is shared to.', + }); diff --git a/x-pack/solutions/observability/test/api_integration/apis/synthetics/index.ts b/x-pack/solutions/observability/test/api_integration/apis/synthetics/index.ts index 9a38ec25a4e38..5d0ab43c3ea86 100644 --- a/x-pack/solutions/observability/test/api_integration/apis/synthetics/index.ts +++ b/x-pack/solutions/observability/test/api_integration/apis/synthetics/index.ts @@ -30,6 +30,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./add_monitor_project')); loadTestFile(require.resolve('./add_monitor_private_location')); loadTestFile(require.resolve('./edit_monitor')); + loadTestFile(require.resolve('./update_monitor_bulk')); loadTestFile(require.resolve('./sync_global_params')); loadTestFile(require.resolve('./sync_global_params_spaces')); loadTestFile(require.resolve('./add_edit_params')); diff --git a/x-pack/solutions/observability/test/api_integration/apis/synthetics/update_monitor_bulk.ts b/x-pack/solutions/observability/test/api_integration/apis/synthetics/update_monitor_bulk.ts new file mode 100644 index 0000000000000..1bfba377da245 --- /dev/null +++ b/x-pack/solutions/observability/test/api_integration/apis/synthetics/update_monitor_bulk.ts @@ -0,0 +1,283 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import expect from '@kbn/expect'; +import type { HTTPFields } from '@kbn/synthetics-plugin/common/runtime_types'; +import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants'; +import { syntheticsMonitorSavedObjectType } from '@kbn/synthetics-plugin/common/types/saved_objects'; +import type { FtrProviderContext } from '../../ftr_provider_context'; +import { getFixtureJson } from './helper/get_fixture_json'; +import { + PrivateLocationTestService, + cleanSyntheticsTestData, +} from './services/private_location_test_service'; +import { SyntheticsMonitorTestService } from './services/synthetics_monitor_test_service'; + +/** + * api_integration coverage for `PUT /api/synthetics/monitors/_bulk_update`. + * + * The unit tests in `update_monitor_bulk.test.ts` and + * `update_monitor_api.test.ts` cover the merge / classification logic with + * mocks. Here we exercise the full stack: + * - real Encrypted Saved Objects round-trip (the AAD regression test + * reads the monitor back via the GET endpoint, which decrypts; if + * `mergeSourceMonitor` ever stops carrying prev AAD attrs this test + * turns the GET into a 500) + * - real Fleet integration for private-location monitors + * - real project-monitor rejection path through the registered route + */ +export default function ({ getService }: FtrProviderContext) { + describe('UpdateMonitorBulkAPI', function () { + this.tags('skipCloud'); + + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + + const testPrivateLocations = new PrivateLocationTestService(getService); + const monitorTestService = new SyntheticsMonitorTestService(getService); + + let httpMonitorJson: HTTPFields; + let privateLocationId: string; + + const bulkUpdate = (body: { ids: string[]; attributes: Record }) => + supertest + .put(SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_BULK_UPDATE) + .set('kbn-xsrf', 'true') + .send(body); + + const createUiMonitor = async (overrides: Partial = {}) => { + const monitor = { + ...httpMonitorJson, + name: `bulk-patch-${uuidv4()}`, + ...overrides, + }; + const { rawBody } = await monitorTestService.createMonitor({ monitor }); + return rawBody; + }; + + before(async () => { + await cleanSyntheticsTestData(kibanaServer); + await supertest + .put(SYNTHETICS_API_URLS.SYNTHETICS_ENABLEMENT) + .set('kbn-xsrf', 'true') + .expect(200); + + const loc = await testPrivateLocations.createPrivateLocation(); + privateLocationId = loc.id; + + httpMonitorJson = getFixtureJson('http_monitor'); + }); + + after(async () => { + await cleanSyntheticsTestData(kibanaServer); + }); + + describe('happy path', () => { + it('partially patches a single monitor with a public location', async () => { + const monitor = await createUiMonitor({ enabled: true } as Partial); + + const res = await bulkUpdate({ + ids: [monitor.config_id], + attributes: { enabled: false }, + }).expect(200); + + expect(res.body.result).to.eql([{ id: monitor.config_id, updated: true }]); + expect(res.body.errors).to.be(undefined); + + const { body: refreshed } = await monitorTestService.getMonitor(monitor.config_id); + expect(refreshed.enabled).to.be(false); + // Untouched AAD-bound fields must round-trip unchanged. + expect(refreshed.name).to.eql(monitor.name); + expect(refreshed.tags).to.eql(monitor.tags); + expect(refreshed.schedule).to.eql(monitor.schedule); + }); + + it('partially patches a monitor with a private location', async () => { + const monitor = await createUiMonitor({ + locations: [ + { id: privateLocationId, label: 'Test private location 0', isServiceManaged: false }, + ], + } as Partial); + + const res = await bulkUpdate({ + ids: [monitor.config_id], + attributes: { enabled: false }, + }).expect(200); + + expect(res.body.result).to.eql([{ id: monitor.config_id, updated: true }]); + + const { body: refreshed } = await monitorTestService.getMonitor(monitor.config_id); + expect(refreshed.enabled).to.be(false); + expect(refreshed.locations[0].id).to.eql(privateLocationId); + }); + + it('patches multiple monitors in one request', async () => { + const m1 = await createUiMonitor(); + const m2 = await createUiMonitor(); + + const res = await bulkUpdate({ + ids: [m1.config_id, m2.config_id], + attributes: { tags: ['bulk-patched'] }, + }).expect(200); + + expect(res.body.result).to.eql([ + { id: m1.config_id, updated: true }, + { id: m2.config_id, updated: true }, + ]); + + const refreshedA = await monitorTestService.getMonitor(m1.config_id); + const refreshedB = await monitorTestService.getMonitor(m2.config_id); + expect(refreshedA.body.tags).to.eql(['bulk-patched']); + expect(refreshedB.body.tags).to.eql(['bulk-patched']); + }); + }); + + describe('AAD regression (decrypt-merge-encrypt safety)', () => { + it('keeps the monitor decryptable after a partial patch on an AAD field', async () => { + /* + * Headline guarantee of this endpoint. Create a monitor that has + * encrypted secrets (`username`, `password`) bound by AAD to a set + * of plaintext attributes. Patch only `enabled` (which is itself + * in the AAD set). Re-fetch the monitor: the GET endpoint goes + * through `monitorConfigRepository.getDecrypted`, which will + * throw a 500 if the AAD ever drifts from the encrypted value + * during our merge. + * + * If `mergeSourceMonitor` ever stops carrying prev AAD attrs + * through, this test turns the GET below into a 500. + */ + const monitor = await createUiMonitor({ + username: 'aad-test-user', + password: 'aad-test-pass', + tags: ['aad', 'regression'], + } as Partial); + + await bulkUpdate({ + ids: [monitor.config_id], + attributes: { enabled: false }, + }).expect(200); + + const refreshed = await monitorTestService.getMonitor(monitor.config_id, { + internal: true, + }); + + expect(refreshed.body.enabled).to.be(false); + expect(refreshed.body.username).to.eql('aad-test-user'); + expect(refreshed.body.password).to.eql('aad-test-pass'); + expect(refreshed.body.tags).to.eql(['aad', 'regression']); + expect(refreshed.body.urls).to.eql(monitor.urls); + }); + }); + + describe('per-id error reporting', () => { + it('returns mixed updated/error entries when some ids do not exist', async () => { + const monitor = await createUiMonitor(); + const missingId = uuidv4(); + + const res = await bulkUpdate({ + ids: [monitor.config_id, missingId], + attributes: { enabled: false }, + }).expect(200); + + expect(res.body.result).to.have.length(2); + + const updatedEntry = res.body.result.find((r: any) => r.id === monitor.config_id); + const missingEntry = res.body.result.find((r: any) => r.id === missingId); + + expect(updatedEntry).to.eql({ id: monitor.config_id, updated: true }); + expect(missingEntry.updated).to.be(false); + expect(missingEntry.error).to.match(/not found/i); + }); + + it('rejects project-origin monitors with `invalid_origin`', async () => { + const projectName = `bulk-patch-project-${uuidv4()}`; + const journeyId = `bulk-patch-journey-${uuidv4()}`; + + await supertest + .put( + SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_PROJECT_UPDATE.replace( + '{projectName}', + projectName + ) + ) + .set('kbn-xsrf', 'true') + .send({ + monitors: [ + { + type: 'http', + id: journeyId, + name: 'project monitor for bulk patch test', + urls: ['https://elastic.co'], + schedule: 5, + locations: ['dev'], + privateLocations: [], + }, + ], + }) + .expect(200); + + const list = await supertest + .get(SYNTHETICS_API_URLS.SYNTHETICS_MONITORS) + .query({ + filter: `${syntheticsMonitorSavedObjectType}.attributes.journey_id: "${journeyId}"`, + }) + .set('kbn-xsrf', 'true') + .expect(200); + const projectMonitorId = list.body.monitors[0].config_id as string; + + const res = await bulkUpdate({ + ids: [projectMonitorId], + attributes: { enabled: false }, + }).expect(200); + + expect(res.body.result).to.have.length(1); + expect(res.body.result[0].updated).to.be(false); + expect(res.body.result[0].error).to.match(/origin/i); + + // Project monitor must remain unchanged. + const refreshed = await monitorTestService.getMonitor(projectMonitorId); + expect(refreshed.body.enabled).to.be(true); + + await monitorTestService.deleteMonitor(projectMonitorId); + }); + + it('rejects schedules outside the allowed set with `validation_failed`', async () => { + const monitor = await createUiMonitor(); + + const res = await bulkUpdate({ + ids: [monitor.config_id], + attributes: { schedule: { number: '7', unit: 'm' } }, + }).expect(200); + + expect(res.body.result).to.have.length(1); + expect(res.body.result[0].updated).to.be(false); + expect(res.body.result[0].error).to.match(/schedule/i); + + const refreshed = await monitorTestService.getMonitor(monitor.config_id); + expect(refreshed.body.schedule).to.eql({ number: '5', unit: 'm' }); + }); + }); + + describe('input validation', () => { + it('returns 400 when attributes is empty', async () => { + const monitor = await createUiMonitor(); + + const res = await bulkUpdate({ + ids: [monitor.config_id], + attributes: {}, + }).expect(400); + + expect(res.body.message).to.match(/attributes/i); + }); + + it('returns 400 when ids is empty (schema-level rejection)', async () => { + await bulkUpdate({ ids: [], attributes: { enabled: false } }).expect(400); + }); + }); + }); +}