From 05bba283dcf9f9dbe6256b95896ca02e653da725 Mon Sep 17 00:00:00 2001 From: Panagiota Mitsopoulou Date: Thu, 7 May 2026 13:20:44 +0300 Subject: [PATCH 1/7] [Synthetics] Add PATCH /monitors/_bulk_update route skeleton (1/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal endpoint at PATCH /internal/synthetics/monitors/_bulk_update. Lands the route surface only — schema validation, route registration, and a 501 placeholder. Behaviour (decrypt -> merge -> re-encrypt -> bulk write via syncEditedMonitorBulk) is wired in subsequent commits. The Synthetics route type system was limited to GET/POST/PUT/DELETE; extended to include PATCH so the new route can register correctly. --- .../common/constants/synthetics/rest_api.ts | 1 + .../routes/create_route_with_auth.test.ts | 2 +- .../plugins/synthetics/server/routes/index.ts | 2 + .../bulk_cruds/update_monitor_bulk.test.ts | 88 +++++++++++++++++++ .../bulk_cruds/update_monitor_bulk.ts | 50 +++++++++++ .../plugins/synthetics/server/routes/types.ts | 2 +- .../plugins/synthetics/server/server.ts | 3 + 7 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/update_monitor_bulk.test.ts create mode 100644 x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/update_monitor_bulk.ts 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 701d5211caabe..691d3bce4a78a 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 @@ -22,6 +22,7 @@ export enum SYNTHETICS_API_URLS { // Service end points SYNTHETICS_MONITOR_RESET = '/internal/synthetics/monitors/{monitorId}/_reset', SYNTHETICS_MONITORS_BULK_RESET = '/internal/synthetics/monitors/_bulk_reset', + SYNTHETICS_MONITORS_BULK_UPDATE = '/internal/synthetics/monitors/_bulk_update', INDEX_TEMPLATES = '/internal/synthetics/service/index_templates', SERVICE_LOCATIONS = '/internal/uptime/service/locations', SYNTHETICS_MONITOR_INSPECT = '/internal/synthetics/service/monitor/inspect', diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/create_route_with_auth.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/create_route_with_auth.test.ts index 3b90b98a8835e..a166c570a343a 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/create_route_with_auth.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/create_route_with_auth.test.ts @@ -8,7 +8,7 @@ import { createSyntheticsRouteWithAuth } from './create_route_with_auth'; import type { SupportedMethod } from './types'; -const methods: SupportedMethod[][] = [['GET'], ['POST'], ['PUT'], ['DELETE']]; +const methods: SupportedMethod[][] = [['GET'], ['POST'], ['PUT'], ['PATCH'], ['DELETE']]; describe('createSyntheticsRouteWithAuth', () => { it.each( 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 ca756955fe0ed..0a57e4ecc36ca 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, @@ -114,6 +115,7 @@ export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ getSyntheticsTriggerTaskRun, resetSyntheticsMonitorRoute, resetSyntheticsMonitorBulkRoute, + updateSyntheticsMonitorBulkRoute, cleanupPrivateLocationRoute, syncParamsSyntheticsParamsRoute, syncParamsSettingsParamsRoute, 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..f052d2685fd2b --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/update_monitor_bulk.test.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 type { Type } from '@kbn/config-schema'; +import { SYNTHETICS_API_URLS } from '../../../../common/constants'; +import { updateSyntheticsMonitorBulkRoute } from './update_monitor_bulk'; + +describe('updateSyntheticsMonitorBulkRoute', () => { + const route = updateSyntheticsMonitorBulkRoute(); + + it('uses PATCH on the bulk update path', () => { + expect(route.method).toBe('PATCH'); + 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 patch (config-schema defaults empty object schemas)', () => { + // A semantically empty patch is a runtime concern — the route handler in + // step 3 is responsible for short-circuiting on it. The HTTP schema + // layer just normalises the input. + 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('placeholder handler', () => { + it('returns 501 Not Implemented in step 1', async () => { + const customError = jest.fn(); + const result = await route.handler({ + // Only `response` is consulted by the placeholder; the rest of the + // RouteContext is unused, so a partial mock is fine. + response: { customError } as never, + } as never); + + expect(customError).toHaveBeenCalledWith({ + statusCode: 501, + body: { message: 'Bulk update endpoint not implemented yet' }, + }); + // The mock returns undefined; the handler returns whatever customError returned. + expect(result).toBeUndefined(); + }); + }); +}); 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..989d2ddf89229 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/bulk_cruds/update_monitor_bulk.ts @@ -0,0 +1,50 @@ +/* + * 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 PATCH 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 will reuse). + * + * Step 1 ships the route surface only — schema, registration, 501 placeholder. + * Behaviour is wired in subsequent commits. + */ + +import { schema } from '@kbn/config-schema'; +import { SYNTHETICS_API_URLS } from '../../../../common/constants'; +import type { SyntheticsRestApiRouteFactory } from '../../types'; + +export const updateSyntheticsMonitorBulkRoute: SyntheticsRestApiRouteFactory< + Record, + Record, + Record, + { ids: string[]; attributes: Record } +> = () => ({ + method: 'PATCH', + 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 ({ response }) => { + return response.customError({ + statusCode: 501, + body: { message: 'Bulk update endpoint not implemented yet' }, + }); + }, +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/types.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/types.ts index ca804dddceb2d..fb3727840e33b 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/types.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/types.ts @@ -33,7 +33,7 @@ export type SyntheticsRequest = KibanaRequest< Record >; -export type SupportedMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; +export type SupportedMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; /** * Defines the basic properties employed by Uptime routes. diff --git a/x-pack/solutions/observability/plugins/synthetics/server/server.ts b/x-pack/solutions/observability/plugins/synthetics/server/server.ts index ace252cd18ab9..1537316001501 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/server.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/server.ts @@ -44,6 +44,9 @@ export const initSyntheticsServer = ( case 'PUT': router.put(routeDefinition, handler); break; + case 'PATCH': + router.patch(routeDefinition, handler); + break; case 'DELETE': router.delete(routeDefinition, handler); break; From 71f8c45943aa3a7c8806834067c1daf3f5b97ec8 Mon Sep 17 00:00:00 2001 From: Panagiota Mitsopoulou Date: Thu, 7 May 2026 13:38:16 +0300 Subject: [PATCH 2/7] [Synthetics] Add UpdateMonitorAPI preprocessing service (2/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AAD-safe per-id pipeline for the bulk PATCH endpoint, mirroring the DeleteMonitorAPI / ResetMonitorAPI pattern: decrypt (one SO round-trip via CONFIG_ID filter) → not_found diff → reject non-'ui' origins → mergeSourceMonitor (keeps prev AAD attrs the patch didn't touch) → validateMonitor (io-ts) → permissions + multi-space + private-location-spaces → bump revision, reset CONFIG_HASH, formatSecrets Output is the MonitorConfigUpdate shape syncEditedMonitorBulk expects. Route handler still returns 501; wiring comes next. Tests cover each per-id error path, mixed batches, the conditional private-locations fetch, and an AAD-preservation regression check. --- .../services/update_monitor_api.test.ts | 408 ++++++++++++++++++ .../services/update_monitor_api.ts | 339 +++++++++++++++ 2 files changed, 747 insertions(+) create mode 100644 x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/update_monitor_api.test.ts create mode 100644 x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/update_monitor_api.ts 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..6073c4152abff --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/update_monitor_api.test.ts @@ -0,0 +1,408 @@ +/* + * 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 } 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', () => ({ + validatePermissions: jest.fn().mockResolvedValue(undefined), +})); + +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', + [ConfigKey.MONITOR_TYPE]: 'http', + 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 { validatePermissions } = jest.requireMock('../edit_monitor'); + validatePermissions.mockResolvedValue(undefined); + + 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: 'm' } }, + }); + + 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 { validatePermissions } = jest.requireMock('../edit_monitor'); + validatePermissions.mockResolvedValue('Elastic managed locations are disabled'); + + 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('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', 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..ac866b6e7d044 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/services/update_monitor_api.ts @@ -0,0 +1,339 @@ +/* + * 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 PATCH endpoint + * (`PATCH /internal/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 { validatePermissions } from '../edit_monitor'; +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: {} }; + + 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 }; + } + + private async checkPermissions( + decodedMonitor: SyntheticsMonitor, + savedObjectType: string + ): Promise { + const elasticManagedError = await validatePermissions( + this.routeContext, + decodedMonitor.locations + ); + if (elasticManagedError) { + return { code: 'forbidden', message: elasticManagedError }; + } + + 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 assertCanUpdateMonitorInAllSpaces( + this.routeContext, + editedMonitorSpaces, + savedObjectType + ); + if (spaceAuthError) { + return { code: 'forbidden', message: insufficientSpacePermissionsMessage() }; + } + return undefined; + } + + /** + * 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.', + }); From 09c58e8ed6a1a09d31ef12cf6fa826eb7b1ccb74 Mon Sep 17 00:00:00 2001 From: Panagiota Mitsopoulou Date: Thu, 7 May 2026 14:07:03 +0300 Subject: [PATCH 3/7] [Synthetics] Wire bulk PATCH handler to UpdateMonitorAPI + sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler returns 200 with `{ result, errors? }` where each entry of `result` reports `{ id, updated, error? }` per requested id, in input order. Entries are produced by: UpdateMonitorAPI.execute → { survivors, perIdErrors } → fetch private locations for the union of request space and every survivor's KIBANA_SPACES → syncEditedMonitorBulk → merge perIdErrors, failedConfigs (Fleet-rolled-back), per-SO bulkUpdate errors, and successful editedMonitors into one result array; publicSyncErrors become top-level `errors` Edge cases: - empty `attributes` → 400 - no survivors after preprocess → 200 with all-errors result, sync skipped - syncEditedMonitorBulk throws → 500 (orchestrator already rolled back) --- .../bulk_cruds/update_monitor_bulk.test.ts | 335 ++++++++++++++++-- .../bulk_cruds/update_monitor_bulk.ts | 171 ++++++++- 2 files changed, 474 insertions(+), 32 deletions(-) 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 index f052d2685fd2b..d85b551c2f148 100644 --- 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 @@ -9,12 +9,73 @@ 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(); - it('uses PATCH on the bulk update path', () => { - expect(route.method).toBe('PATCH'); - expect(route.path).toBe(SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_BULK_UPDATE); + beforeEach(() => { + jest.clearAllMocks(); + const { getPrivateLocationsForNamespaces } = jest.requireMock( + '../../../synthetics_service/get_private_locations' + ); + getPrivateLocationsForNamespaces.mockResolvedValue([]); + }); + + describe('route shape', () => { + it('uses PATCH on the bulk update path', () => { + expect(route.method).toBe('PATCH'); + expect(route.path).toBe(SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_BULK_UPDATE); + }); }); describe('body schema', () => { @@ -50,10 +111,7 @@ describe('updateSyntheticsMonitorBulkRoute', () => { ); }); - it('treats a missing attributes field as an empty patch (config-schema defaults empty object schemas)', () => { - // A semantically empty patch is a runtime concern — the route handler in - // step 3 is responsible for short-circuiting on it. The HTTP schema - // layer just normalises the input. + it('treats a missing attributes field as an empty patch — handler enforces non-empty', () => { const value = bodySchema.validate({ ids: ['monitor-id-1'] }) as { ids: string[]; attributes: Record; @@ -68,21 +126,254 @@ describe('updateSyntheticsMonitorBulkRoute', () => { }); }); - describe('placeholder handler', () => { - it('returns 501 Not Implemented in step 1', async () => { - const customError = jest.fn(); - const result = await route.handler({ - // Only `response` is consulted by the placeholder; the rest of the - // RouteContext is unused, so a partial mock is fine. - response: { customError } as never, - } as never); - - expect(customError).toHaveBeenCalledWith({ - statusCode: 501, - body: { message: 'Bulk update endpoint not implemented yet' }, - }); - // The mock returns undefined; the handler returns whatever customError returned. - expect(result).toBeUndefined(); + 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 index 989d2ddf89229..eb578de4100b2 100644 --- 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 @@ -14,18 +14,46 @@ * (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 will reuse). + * `syncEditedMonitorBulk` (the orchestrator this route reuses). * - * Step 1 ships the route surface only — schema, registration, 501 placeholder. - * Behaviour is wired in subsequent commits. + * 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 { SyntheticsRestApiRouteFactory } from '../../types'; +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< - Record, + UpdateMonitorBulkResponse, Record, Record, { ids: string[]; attributes: Record } @@ -41,10 +69,133 @@ export const updateSyntheticsMonitorBulkRoute: SyntheticsRestApiRouteFactory< }), }, }, - handler: async ({ response }) => { - return response.customError({ - statusCode: 501, - body: { message: 'Bulk update endpoint not implemented yet' }, - }); + 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 patch.', + }, + }); + } + + 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; +}; From da5ec7f2bbff1fa92410f814e63e594d2ab41701 Mon Sep 17 00:00:00 2001 From: Panagiota Mitsopoulou Date: Thu, 7 May 2026 14:15:03 +0300 Subject: [PATCH 4/7] api integration tests across four describe blocks: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - happy path: public location, private location, multi-id batch - AAD regression: patch `enabled`, re-fetch via GET (decrypt path), assert secrets and other AAD fields round-trip unchanged. If mergeSourceMonitor ever drops prev AAD attrs, GET turns into 500. - per-id errors: mixed valid/missing ids, project-origin rejection, invalid-schedule validation failure - input validation: empty attributes → 400, empty ids → 400 --- .../api_integration/apis/synthetics/index.ts | 1 + .../apis/synthetics/update_monitor_bulk.ts | 283 ++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 x-pack/solutions/observability/test/api_integration/apis/synthetics/update_monitor_bulk.ts 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..3b57557540018 --- /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 `PATCH /internal/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 + .patch(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); + }); + }); + }); +} From bf27fa2a4649ae7ffd00f4c57dbaa134b0d348d2 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 7 May 2026 12:26:23 +0000 Subject: [PATCH 5/7] Changes from node scripts/eslint_all_files --no-cache --fix --- .../bulk_cruds/update_monitor_bulk.test.ts | 10 ++++++---- .../monitor_cruds/bulk_cruds/update_monitor_bulk.ts | 3 ++- .../monitor_cruds/services/update_monitor_api.test.ts | 4 +++- 3 files changed, 11 insertions(+), 6 deletions(-) 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 index d85b551c2f148..fe2238b1e1437 100644 --- 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 @@ -120,9 +120,9 @@ describe('updateSyntheticsMonitorBulkRoute', () => { }); it('rejects non-string ids', () => { - expect(() => - bodySchema.validate({ ids: [1, 2, 3], attributes: {} }) - ).toThrow(/\[ids\.0\]: expected value of type \[string\]/); + expect(() => bodySchema.validate({ ids: [1, 2, 3], attributes: {} })).toThrow( + /\[ids\.0\]: expected value of type \[string\]/ + ); }); }); @@ -254,7 +254,9 @@ describe('updateSyntheticsMonitorBulkRoute', () => { 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' } }]); + expect(result.body.errors).toEqual([ + { locationId: 'us_central', error: { reason: 'Timeout' } }, + ]); }); it('omits `errors` when sync reports no public location errors', async () => { 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 index eb578de4100b2..9b891c0708609 100644 --- 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 @@ -174,7 +174,8 @@ const classifyId = ( return { id, updated: false, - error: extractErrorMessage(fleetFailure.error) ?? 'Failed to sync monitor to private location', + error: + extractErrorMessage(fleetFailure.error) ?? 'Failed to sync monitor to private location', }; } 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 index 6073c4152abff..71a018734382e 100644 --- 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 @@ -292,7 +292,9 @@ describe('UpdateMonitorAPI', () => { ); getPrivateLocations.mockResolvedValue([{ id: 'pl-1', label: 'PL', spaces: ['default'] }]); - const { validateMonitorPrivateLocationSpaces } = jest.requireMock('../monitor_locations_utils'); + const { validateMonitorPrivateLocationSpaces } = jest.requireMock( + '../monitor_locations_utils' + ); validateMonitorPrivateLocationSpaces.mockReturnValue({ message: 'PL is not available in space "team-b"', attributes: { errors: [] }, From c4ee8c009d936c4713040ce2e15df1f3be94e3e5 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Sun, 31 May 2026 19:01:40 +0200 Subject: [PATCH 6/7] [Synthetics] Make bulk monitor update a public PUT endpoint Switch the bulk update endpoint from an internal PATCH route to a public PUT /api/synthetics/monitors/_bulk_update, matching the single-monitor partial-update convention (PUT) and the existing public _bulk_delete route. Drops PATCH from the supported route methods and updates unit + API tests. Co-authored-by: Cursor --- .../synthetics/common/constants/synthetics/rest_api.ts | 2 +- .../server/routes/create_route_with_auth.test.ts | 2 +- .../plugins/synthetics/server/routes/index.ts | 2 +- .../monitor_cruds/bulk_cruds/update_monitor_bulk.test.ts | 6 +++--- .../monitor_cruds/bulk_cruds/update_monitor_bulk.ts | 6 +++--- .../monitor_cruds/services/update_monitor_api.test.ts | 9 +++++---- .../routes/monitor_cruds/services/update_monitor_api.ts | 4 ++-- .../plugins/synthetics/server/routes/types.ts | 2 +- .../observability/plugins/synthetics/server/server.ts | 3 --- .../apis/synthetics/update_monitor_bulk.ts | 4 ++-- 10 files changed, 19 insertions(+), 21 deletions(-) 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 8989904bd71ab..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', @@ -22,7 +23,6 @@ export enum SYNTHETICS_API_URLS { // Service end points SYNTHETICS_MONITOR_RESET = '/internal/synthetics/monitors/{monitorId}/_reset', SYNTHETICS_MONITORS_BULK_RESET = '/internal/synthetics/monitors/_bulk_reset', - SYNTHETICS_MONITORS_BULK_UPDATE = '/internal/synthetics/monitors/_bulk_update', INDEX_TEMPLATES = '/internal/synthetics/service/index_templates', SERVICE_LOCATIONS = '/internal/uptime/service/locations', SYNTHETICS_MONITOR_INSPECT = '/internal/synthetics/service/monitor/inspect', diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/create_route_with_auth.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/create_route_with_auth.test.ts index a166c570a343a..3b90b98a8835e 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/create_route_with_auth.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/create_route_with_auth.test.ts @@ -8,7 +8,7 @@ import { createSyntheticsRouteWithAuth } from './create_route_with_auth'; import type { SupportedMethod } from './types'; -const methods: SupportedMethod[][] = [['GET'], ['POST'], ['PUT'], ['PATCH'], ['DELETE']]; +const methods: SupportedMethod[][] = [['GET'], ['POST'], ['PUT'], ['DELETE']]; describe('createSyntheticsRouteWithAuth', () => { it.each( 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 c8e27a6612479..6d241b3bb5b56 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/index.ts @@ -124,7 +124,6 @@ export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ getSyntheticsTriggerTaskRun, resetSyntheticsMonitorRoute, resetSyntheticsMonitorBulkRoute, - updateSyntheticsMonitorBulkRoute, cleanupPrivateLocationRoute, syncParamsSyntheticsParamsRoute, syncParamsSettingsParamsRoute, @@ -151,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 index fe2238b1e1437..6b4490d956f2c 100644 --- 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 @@ -72,8 +72,8 @@ describe('updateSyntheticsMonitorBulkRoute', () => { }); describe('route shape', () => { - it('uses PATCH on the bulk update path', () => { - expect(route.method).toBe('PATCH'); + it('uses PUT on the bulk update path', () => { + expect(route.method).toBe('PUT'); expect(route.path).toBe(SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_BULK_UPDATE); }); }); @@ -111,7 +111,7 @@ describe('updateSyntheticsMonitorBulkRoute', () => { ); }); - it('treats a missing attributes field as an empty patch — handler enforces non-empty', () => { + 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; 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 index 9b891c0708609..108ac084c32a5 100644 --- 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 @@ -6,7 +6,7 @@ */ /** - * Bulk PATCH endpoint for Synthetics monitors. + * 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` / @@ -58,7 +58,7 @@ export const updateSyntheticsMonitorBulkRoute: SyntheticsRestApiRouteFactory< Record, { ids: string[]; attributes: Record } > = () => ({ - method: 'PATCH', + method: 'PUT', path: SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_BULK_UPDATE, validate: {}, validation: { @@ -76,7 +76,7 @@ export const updateSyntheticsMonitorBulkRoute: SyntheticsRestApiRouteFactory< if (isEmpty(attributes)) { return response.badRequest({ body: { - message: '`attributes` is required and must contain at least one field to patch.', + message: '`attributes` is required and must contain at least one field to update.', }, }); } 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 index 71a018734382e..81e9b9cbf0b8d 100644 --- 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { ConfigKey } from '../../../../common/runtime_types'; +import { ConfigKey, ScheduleUnit } from '../../../../common/runtime_types'; import { UpdateMonitorAPI } from './update_monitor_api'; jest.mock('../../../synthetics_service/get_private_locations', () => ({ @@ -53,7 +53,6 @@ const mockDecryptedMonitor = (overrides: Partial> = {}) [ConfigKey.MONITOR_SOURCE_TYPE]: 'ui', [ConfigKey.REVISION]: 3, [ConfigKey.CONFIG_HASH]: 'old-hash', - [ConfigKey.MONITOR_TYPE]: 'http', secrets: '{}', ...(overrides.attributes as Record), }; @@ -237,7 +236,7 @@ describe('UpdateMonitorAPI', () => { const api = new UpdateMonitorAPI(routeContext); const result = await api.execute({ ids: ['mon-1'], - attributes: { schedule: { number: '7', unit: 'm' } }, + attributes: { schedule: { number: '7', unit: ScheduleUnit.MINUTES } }, }); expect(result.survivors).toHaveLength(0); @@ -388,7 +387,9 @@ describe('UpdateMonitorAPI', () => { const api = new UpdateMonitorAPI(routeContext); await api.execute({ ids: ['mon-1', 'mon-2'], - attributes: { locations: [{ id: 'us_central', isServiceManaged: true }] }, + attributes: { + locations: [{ id: 'us_central', label: 'US Central', isServiceManaged: true }], + }, }); expect(getPrivateLocations).toHaveBeenCalledTimes(1); 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 index ac866b6e7d044..f789323ba005e 100644 --- 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 @@ -6,8 +6,8 @@ */ /** - * Pre-processing service for the bulk PATCH endpoint - * (`PATCH /internal/synthetics/monitors/_bulk_update`). + * 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 diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/types.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/types.ts index fb3727840e33b..ca804dddceb2d 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/types.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/types.ts @@ -33,7 +33,7 @@ export type SyntheticsRequest = KibanaRequest< Record >; -export type SupportedMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; +export type SupportedMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; /** * Defines the basic properties employed by Uptime routes. diff --git a/x-pack/solutions/observability/plugins/synthetics/server/server.ts b/x-pack/solutions/observability/plugins/synthetics/server/server.ts index 1537316001501..ace252cd18ab9 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/server.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/server.ts @@ -44,9 +44,6 @@ export const initSyntheticsServer = ( case 'PUT': router.put(routeDefinition, handler); break; - case 'PATCH': - router.patch(routeDefinition, handler); - break; case 'DELETE': router.delete(routeDefinition, handler); break; 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 index 3b57557540018..1bfba377da245 100644 --- 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 @@ -19,7 +19,7 @@ import { import { SyntheticsMonitorTestService } from './services/synthetics_monitor_test_service'; /** - * api_integration coverage for `PATCH /internal/synthetics/monitors/_bulk_update`. + * 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 @@ -46,7 +46,7 @@ export default function ({ getService }: FtrProviderContext) { const bulkUpdate = (body: { ids: string[]; attributes: Record }) => supertest - .patch(SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_BULK_UPDATE) + .put(SYNTHETICS_API_URLS.SYNTHETICS_MONITORS_BULK_UPDATE) .set('kbn-xsrf', 'true') .send(body); From 9afc2e6f0ae52a234842063d116c8c7af085d720 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Sun, 31 May 2026 19:21:28 +0200 Subject: [PATCH 7/7] [Synthetics] Resolve bulk update permission checks once per request The bulk update preprocessing loop resolved the Elastic-managed-locations capability and the saved-objects bulk_update privilege once per monitor. Both answers are request-scoped, so cache the capability for the whole request and the privilege per unique space set, turning O(N) permission round-trips into O(1) / O(distinct space sets). Co-authored-by: Cursor --- .../services/update_monitor_api.test.ts | 89 +++++++++++++++++-- .../services/update_monitor_api.ts | 74 ++++++++++++--- 2 files changed, 146 insertions(+), 17 deletions(-) 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 index 81e9b9cbf0b8d..9a1530c1d3677 100644 --- 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 @@ -13,7 +13,13 @@ jest.mock('../../../synthetics_service/get_private_locations', () => ({ })); jest.mock('../edit_monitor', () => ({ - validatePermissions: jest.fn().mockResolvedValue(undefined), + 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', () => ({ @@ -101,8 +107,11 @@ describe('UpdateMonitorAPI', () => { const { validateMonitor } = jest.requireMock('../monitor_validation'); validateMonitor.mockImplementation((m: Record) => mockValidationResultFor(m)); - const { validatePermissions } = jest.requireMock('../edit_monitor'); - validatePermissions.mockResolvedValue(undefined); + const { validateLocationPermissions } = jest.requireMock('../edit_monitor'); + validateLocationPermissions.mockResolvedValue({ + elasticManagedLocationsEnabled: true, + canManagePrivateLocations: true, + }); const { assertCanUpdateMonitorInAllSpaces, validateMonitorPrivateLocationSpaces } = jest.requireMock('../monitor_locations_utils'); @@ -250,8 +259,8 @@ describe('UpdateMonitorAPI', () => { describe('forbidden', () => { it('records elastic-managed-locations permission failures', async () => { - const { validatePermissions } = jest.requireMock('../edit_monitor'); - validatePermissions.mockResolvedValue('Elastic managed locations are disabled'); + const { validateLocationPermissions } = jest.requireMock('../edit_monitor'); + validateLocationPermissions.mockResolvedValue({ elasticManagedLocationsEnabled: false }); const { routeContext, mocks } = createMockRouteContext(); mocks.findDecryptedMonitors.mockResolvedValue([mockDecryptedMonitor()]); @@ -358,6 +367,76 @@ describe('UpdateMonitorAPI', () => { }); }); + 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( 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 index f789323ba005e..aef52db9e7780 100644 --- 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 @@ -41,7 +41,8 @@ import { validateMonitorPrivateLocationSpaces, } from '../monitor_locations_utils'; import { validateMonitor } from '../monitor_validation'; -import { validatePermissions } from '../edit_monitor'; +import { validateLocationPermissions } from '../edit_monitor'; +import { ELASTIC_MANAGED_LOCATIONS_DISABLED } from '../project_monitor/add_monitor_project'; import type { RouteContext } from '../../types'; import { ConfigKey, @@ -80,6 +81,17 @@ 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; } @@ -211,16 +223,28 @@ export class UpdateMonitorAPI { 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 elasticManagedError = await validatePermissions( - this.routeContext, - decodedMonitor.locations - ); - if (elasticManagedError) { - return { code: 'forbidden', message: elasticManagedError }; + 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] ?? []; @@ -236,17 +260,43 @@ export class UpdateMonitorAPI { * message. The privilege itself was already audit-logged by core when * `checkSavedObjectsPrivileges` ran inside the assertion. */ - const spaceAuthError = await assertCanUpdateMonitorInAllSpaces( - this.routeContext, - editedMonitorSpaces, - savedObjectType - ); + 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