From e6ab82e1bb2ca4b9f1ebd8b95a08f72998fc3209 Mon Sep 17 00:00:00 2001 From: Bena Kansara Date: Wed, 1 Apr 2026 10:08:06 +0200 Subject: [PATCH 01/12] validate private location spaces for multi-space monitors --- .../monitor_add_edit/form/field_config.tsx | 5 +- .../monitor_add_edit/form/field_wrappers.tsx | 33 +++ .../synthetics/server/mocks/server_mock.ts | 15 ++ .../routes/monitor_cruds/add_monitor.ts | 28 +++ .../add_monitor/add_monitor_api.ts | 13 +- .../routes/monitor_cruds/edit_monitor.ts | 40 +++- .../monitor_locations_utils.test.ts | 206 ++++++++++++++++++ .../monitor_cruds/monitor_locations_utils.ts | 152 +++++++++++++ .../monitor_cruds/monitor_validation.ts | 27 +++ .../project_monitor/add_monitor_project.ts | 31 +-- .../get_private_locations.ts | 16 +- 11 files changed, 533 insertions(+), 33 deletions(-) create mode 100644 x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.test.ts create mode 100644 x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.ts diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx index 9429783bf6c71..4e1eb40f63582 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx @@ -52,6 +52,7 @@ import { FieldPassword, Checkbox, ComboBox, + LocationsComboBox, Select, Switch, Source, @@ -428,7 +429,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ fieldKey: ConfigKey.LOCATIONS, required: true, controlled: true, - component: ComboBox, + component: LocationsComboBox, label: i18n.translate('xpack.synthetics.monitorConfig.locations.label', { defaultMessage: 'Locations', }), @@ -1345,7 +1346,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ id="xpack.synthetics.monitorConfig.throttlingDisabled.label" defaultMessage="Connection profile ( {icon} Important information about throttling: {link})" values={{ - icon: , + icon: , link: ( >((pr )); +export const LocationsComboBox = React.forwardRef>( + (props, _ref) => { + const { selectedOptions, options } = props; + const optionIds = new Set((options ?? []).map((o) => o.id)); + const unavailableLocations = (selectedOptions ?? []).filter( + (sel) => + !(sel as unknown as { isServiceManaged?: boolean }).isServiceManaged && + !optionIds.has(sel.id) + ); + + return ( + <> + + {unavailableLocations.length > 0 && ( + <> + + + {i18n.translate('xpack.synthetics.monitorConfig.locations.notInSpaceWarning', { + defaultMessage: + '{count, plural, one {# private location is} other {# private locations are}} not available in all spaces this monitor is shared to. Share the {count, plural, one {location} other {locations}} to all monitor spaces, or remove {count, plural, one {it} other {them}} from the monitor.', + values: { count: unavailableLocations.length }, + })} + + + )} + + ); + } +); + export const JSONEditor = React.forwardRef((props, _ref) => ( )); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/mocks/server_mock.ts b/x-pack/solutions/observability/plugins/synthetics/server/mocks/server_mock.ts index 38e5f142e904a..d645cefac6c06 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/mocks/server_mock.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/mocks/server_mock.ts @@ -45,6 +45,21 @@ export const getServerMock = () => { }, }, encryptedSavedObjects: mockEncryptedSO(), + coreStart: { + savedObjects: { + createInternalRepository: jest.fn().mockReturnValue({ + createPointInTimeFinder: jest.fn().mockImplementation(() => ({ + close: jest.fn(async () => {}), + find: jest.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { saved_objects: [] }; + }, + }), + })), + get: jest.fn(), + }), + }, + }, } as unknown as SyntheticsServerSetup; return serverMock; diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor.ts index 0850582e11274..d019c693f8fed 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor.ts @@ -19,10 +19,15 @@ import { import type { CreateMonitorPayLoad } from './add_monitor/add_monitor_api'; import { AddEditMonitorAPI } from './add_monitor/add_monitor_api'; import type { SyntheticsRestApiRouteFactory } from '../types'; +import { ConfigKey } from '../../../common/runtime_types'; import { SYNTHETICS_API_URLS } from '../../../common/constants'; import { normalizeAPIConfig, validateMonitor } from './monitor_validation'; import { mapSavedObjectToMonitor } from './formatters/saved_object_to_monitor'; import { getBrowserTimeoutWarningForMonitor } from './monitor_warnings'; +import { + assertCanUpdateMonitorInAllSpaces, + validateMonitorPrivateLocationSpaces, +} from './monitor_locations_utils'; export const addSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ({ method: 'POST', @@ -128,6 +133,29 @@ export const addSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ({ }); } + const monitorSpaces = normalizedMonitor[ConfigKey.KIBANA_SPACES] ?? []; + if (monitorSpaces.length > 0) { + const spaceAuthError = await assertCanUpdateMonitorInAllSpaces(routeContext, monitorSpaces); + if (spaceAuthError) { + return spaceAuthError; + } + } + + if (addMonitorAPI.allPrivateLocations && addMonitorAPI.allPrivateLocations.length > 0) { + const plSpaceError = validateMonitorPrivateLocationSpaces( + normalizedMonitor, + addMonitorAPI.allPrivateLocations + ); + if (plSpaceError) { + return response.badRequest({ + body: { + message: plSpaceError.message, + attributes: plSpaceError.attributes, + }, + }); + } + } + const { errors, newMonitor } = await addMonitorAPI.syncNewMonitor({ id, normalizedMonitor, diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts index 65ae2cb03febf..a8a14d25259d5 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts @@ -42,7 +42,7 @@ import { DefaultRuleService } from '../../default_alerts/default_alert_service'; import type { RouteContext } from '../../types'; import { formatTelemetryEvent, sendTelemetryEvents } from '../../telemetry/monitor_upgrade_sender'; import { formatKibanaNamespace } from '../../../../common/formatters'; -import { getPrivateLocations } from '../../../synthetics_service/get_private_locations'; +import { getPrivateLocationsForNamespaces } from '../../../synthetics_service/get_private_locations'; export type CreateMonitorPayLoad = MonitorFields & { url?: string; @@ -199,7 +199,16 @@ export class AddEditMonitorAPI { const monitorLocations = parseMonitorLocations(monitorPayload, prevLocations, internal); if (monitorLocations.privateLocations.length > 0) { - this.allPrivateLocations = await getPrivateLocations(savedObjectsClient); + const monitorSpaces = monitor[ConfigKey.KIBANA_SPACES] ?? []; + const namespacesForLookup = [ + ...new Set([this.routeContext.spaceId, ...monitorSpaces]), + ].filter(Boolean); + const internalClient = + this.routeContext.server.coreStart.savedObjects.createInternalRepository(); + this.allPrivateLocations = await getPrivateLocationsForNamespaces( + internalClient, + namespacesForLookup + ); } else { this.allPrivateLocations = []; } diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/edit_monitor.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/edit_monitor.ts index 0e23aed108b24..09cb1f6ce1b62 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/edit_monitor.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/edit_monitor.ts @@ -18,8 +18,12 @@ import { import type { CreateMonitorPayLoad } from './add_monitor/add_monitor_api'; import { AddEditMonitorAPI } from './add_monitor/add_monitor_api'; import { ELASTIC_MANAGED_LOCATIONS_DISABLED } from './project_monitor/add_monitor_project'; -import { getPrivateLocations } from '../../synthetics_service/get_private_locations'; +import { getPrivateLocationsForNamespaces } from '../../synthetics_service/get_private_locations'; import { mergeSourceMonitor } from './formatters/saved_object_to_monitor'; +import { + assertCanUpdateMonitorInAllSpaces, + validateMonitorPrivateLocationSpaces, +} from './monitor_locations_utils'; import type { RouteContext, SyntheticsRestApiRouteFactory } from '../types'; import type { MonitorFields, @@ -141,6 +145,32 @@ export const editSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ( }); } + const editedMonitorSpaces = (editedMonitor as MonitorFields)[ConfigKey.KIBANA_SPACES] ?? []; + if (editedMonitorSpaces.length > 0) { + const spaceAuthError = await assertCanUpdateMonitorInAllSpaces( + routeContext, + editedMonitorSpaces + ); + if (spaceAuthError) { + return spaceAuthError; + } + } + + if (editMonitorAPI.allPrivateLocations && editMonitorAPI.allPrivateLocations.length > 0) { + const plSpaceError = validateMonitorPrivateLocationSpaces( + editedMonitor as MonitorFields, + editMonitorAPI.allPrivateLocations + ); + if (plSpaceError) { + return response.badRequest({ + body: { + message: plSpaceError.message, + attributes: plSpaceError.attributes, + }, + }); + } + } + const monitorWithRevision = { ...validationResult.decodedMonitor, /* reset config hash to empty string. Ensures that the synthetics agent is able @@ -276,7 +306,13 @@ export const syncEditedMonitor = async ({ }; const formattedMonitor = formatSecrets(monitorWithId); - const allPrivateLocations = await getPrivateLocations(savedObjectsClient); + const monitorSpaces = (monitorWithId as MonitorFields)[ConfigKey.KIBANA_SPACES] ?? []; + const namespacesForLookup = [...new Set([spaceId, ...monitorSpaces])].filter(Boolean); + const internalClient = server.coreStart.savedObjects.createInternalRepository(); + const allPrivateLocations = await getPrivateLocationsForNamespaces( + internalClient, + namespacesForLookup + ); const [editedMonitorSavedObject, { publicSyncErrors, failedPolicyUpdates }] = await Promise.all( [ diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.test.ts new file mode 100644 index 0000000000000..d236525aabc9b --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.test.ts @@ -0,0 +1,206 @@ +/* + * 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 { + privateLocationCoversAllMonitorSpaces, + validateMonitorPrivateLocationSpaces, +} from './monitor_locations_utils'; +import { ConfigKey } from '../../../common/runtime_types'; +import type { MonitorFields, SyntheticsPrivateLocations } from '../../../common/runtime_types'; + +describe('privateLocationCoversAllMonitorSpaces', () => { + it('returns false when locationSpaces is undefined', () => { + expect(privateLocationCoversAllMonitorSpaces(['space-a'], undefined)).toBe(false); + }); + + it('returns false when locationSpaces is empty', () => { + expect(privateLocationCoversAllMonitorSpaces(['space-a'], [])).toBe(false); + }); + + it('returns true when location has * (all spaces)', () => { + expect(privateLocationCoversAllMonitorSpaces(['space-a', 'space-b'], ['*'])).toBe(true); + }); + + it('returns true when location spaces are a superset of monitor spaces', () => { + expect(privateLocationCoversAllMonitorSpaces(['space-a'], ['space-a', 'space-b'])).toBe(true); + }); + + it('returns true when location spaces exactly match monitor spaces', () => { + expect( + privateLocationCoversAllMonitorSpaces(['space-a', 'space-b'], ['space-a', 'space-b']) + ).toBe(true); + }); + + it('returns false when location spaces are a subset of monitor spaces', () => { + expect(privateLocationCoversAllMonitorSpaces(['space-a', 'space-b'], ['space-a'])).toBe(false); + }); + + it('returns false when location spaces do not overlap', () => { + expect(privateLocationCoversAllMonitorSpaces(['space-a'], ['space-b'])).toBe(false); + }); + + it('returns true for empty monitorSpaces', () => { + expect(privateLocationCoversAllMonitorSpaces([], ['space-a'])).toBe(true); + }); + + it('requires location to also be * when monitor spaces include *', () => { + expect(privateLocationCoversAllMonitorSpaces(['*'], ['space-a'])).toBe(false); + expect(privateLocationCoversAllMonitorSpaces(['*'], ['*'])).toBe(true); + }); + + it('treats monitor with * among other spaces as all-spaces', () => { + expect(privateLocationCoversAllMonitorSpaces(['*', 'space-a'], ['space-a'])).toBe(false); + expect(privateLocationCoversAllMonitorSpaces(['*', 'space-a'], ['*'])).toBe(true); + }); +}); + +describe('validateMonitorPrivateLocationSpaces', () => { + const makeMonitor = ( + locations: Array<{ id: string; label: string; isServiceManaged: boolean }>, + spaces: string[] + ) => + ({ + [ConfigKey.LOCATIONS]: locations, + [ConfigKey.KIBANA_SPACES]: spaces, + } as unknown as MonitorFields); + + const makePrivateLocations = ( + locs: Array<{ id: string; label: string; spaces?: string[] }> + ): SyntheticsPrivateLocations => + locs.map((loc) => ({ + id: loc.id, + label: loc.label, + agentPolicyId: 'policy-1', + isServiceManaged: false, + spaces: loc.spaces, + })); + + it('returns null when monitor has no spaces', () => { + const monitor = makeMonitor( + [{ id: 'private-loc-1', label: 'Private Location 1', isServiceManaged: false }], + [] + ); + const privateLocations = makePrivateLocations([ + { id: 'private-loc-1', label: 'Private Location 1', spaces: ['space-a'] }, + ]); + expect(validateMonitorPrivateLocationSpaces(monitor, privateLocations)).toBeNull(); + }); + + it('returns null when monitor has no private locations', () => { + const monitor = makeMonitor( + [{ id: 'us-east', label: 'US East', isServiceManaged: true }], + ['space-a', 'space-b'] + ); + expect(validateMonitorPrivateLocationSpaces(monitor, [])).toBeNull(); + }); + + it('returns null when all private locations cover all monitor spaces', () => { + const monitor = makeMonitor( + [{ id: 'private-loc-1', label: 'Private Location 1', isServiceManaged: false }], + ['space-a', 'space-b'] + ); + const privateLocations = makePrivateLocations([ + { id: 'private-loc-1', label: 'Private Location 1', spaces: ['space-a', 'space-b'] }, + ]); + expect(validateMonitorPrivateLocationSpaces(monitor, privateLocations)).toBeNull(); + }); + + it('returns null when private location has * spaces', () => { + const monitor = makeMonitor( + [{ id: 'private-loc-1', label: 'Private Location 1', isServiceManaged: false }], + ['space-a', 'space-b', 'space-c'] + ); + const privateLocations = makePrivateLocations([ + { id: 'private-loc-1', label: 'Private Location 1', spaces: ['*'] }, + ]); + expect(validateMonitorPrivateLocationSpaces(monitor, privateLocations)).toBeNull(); + }); + + it('returns error when monitor has * spaces but private location does not', () => { + const monitor = makeMonitor( + [{ id: 'private-loc-1', label: 'Private Location 1', isServiceManaged: false }], + ['*'] + ); + const privateLocations = makePrivateLocations([ + { id: 'private-loc-1', label: 'Private Location 1', spaces: ['space-a'] }, + ]); + const result = validateMonitorPrivateLocationSpaces(monitor, privateLocations); + expect(result).not.toBeNull(); + expect(result!.attributes.errors).toHaveLength(1); + expect(result!.attributes.errors[0].locationId).toBe('private-loc-1'); + }); + + it('returns null when both monitor and private location have * spaces', () => { + const monitor = makeMonitor( + [{ id: 'private-loc-1', label: 'Private Location 1', isServiceManaged: false }], + ['*'] + ); + const privateLocations = makePrivateLocations([ + { id: 'private-loc-1', label: 'Private Location 1', spaces: ['*'] }, + ]); + expect(validateMonitorPrivateLocationSpaces(monitor, privateLocations)).toBeNull(); + }); + + it('returns error when private location does not cover all monitor spaces', () => { + const monitor = makeMonitor( + [{ id: 'private-loc-1', label: 'Private Location 1', isServiceManaged: false }], + ['space-a', 'space-b'] + ); + const privateLocations = makePrivateLocations([ + { id: 'private-loc-1', label: 'Private Location 1', spaces: ['space-a'] }, + ]); + const result = validateMonitorPrivateLocationSpaces(monitor, privateLocations); + expect(result).not.toBeNull(); + expect(result!.attributes.errors).toHaveLength(1); + expect(result!.attributes.errors[0].locationId).toBe('private-loc-1'); + expect(result!.attributes.errors[0].missingSpaces).toEqual(['space-b']); + }); + + it('returns errors for multiple failing private locations', () => { + const monitor = makeMonitor( + [ + { id: 'private-loc-1', label: 'Private Location 1', isServiceManaged: false }, + { id: 'private-loc-2', label: 'Private Location 2', isServiceManaged: false }, + ], + ['space-a', 'space-b'] + ); + const privateLocations = makePrivateLocations([ + { id: 'private-loc-1', label: 'Private Location 1', spaces: ['space-a'] }, + { id: 'private-loc-2', label: 'Private Location 2', spaces: ['space-b'] }, + ]); + const result = validateMonitorPrivateLocationSpaces(monitor, privateLocations); + expect(result).not.toBeNull(); + expect(result!.attributes.errors).toHaveLength(2); + }); + + it('returns error when private location has undefined spaces', () => { + const monitor = makeMonitor( + [{ id: 'private-loc-1', label: 'Private Location 1', isServiceManaged: false }], + ['space-a'] + ); + const privateLocations = makePrivateLocations([ + { id: 'private-loc-1', label: 'Private Location 1' }, + ]); + const result = validateMonitorPrivateLocationSpaces(monitor, privateLocations); + expect(result).not.toBeNull(); + expect(result!.attributes.errors[0].locationId).toBe('private-loc-1'); + }); + + it('ignores public (service-managed) locations', () => { + const monitor = makeMonitor( + [ + { id: 'us-east', label: 'US East', isServiceManaged: true }, + { id: 'private-loc-1', label: 'Private Location 1', isServiceManaged: false }, + ], + ['space-a'] + ); + const privateLocations = makePrivateLocations([ + { id: 'private-loc-1', label: 'Private Location 1', spaces: ['space-a'] }, + ]); + expect(validateMonitorPrivateLocationSpaces(monitor, privateLocations)).toBeNull(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.ts new file mode 100644 index 0000000000000..23d6829779865 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.ts @@ -0,0 +1,152 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ALL_SPACES_ID } from '@kbn/spaces-plugin/common/constants'; +import type { MonitorFields, SyntheticsPrivateLocations } from '../../../common/runtime_types'; +import { ConfigKey } from '../../../common/runtime_types'; +import type { RouteContext } from '../types'; + +/** + * Returns true if a private location's spaces cover every space in the monitor's space list. + * A private location with '*' (ALL_SPACES_ID) covers all monitor spaces. + */ +export const privateLocationCoversAllMonitorSpaces = ( + monitorSpaces: string[], + locationSpaces: string[] | undefined +): boolean => { + if (!locationSpaces || locationSpaces.length === 0) { + return false; + } + + const locationIsAllSpaces = locationSpaces.includes(ALL_SPACES_ID); + const monitorIsAllSpaces = monitorSpaces.includes(ALL_SPACES_ID); + + if (monitorIsAllSpaces) { + return locationIsAllSpaces; + } + + if (locationIsAllSpaces) { + return true; + } + + const locationSpaceSet = new Set(locationSpaces); + return monitorSpaces.every((space) => locationSpaceSet.has(space)); +}; + +interface PrivateLocationSpaceError { + locationId: string; + locationLabel: string; + monitorSpaces: string[]; + missingSpaces: string[]; +} + +interface ValidationError { + message: string; + attributes: { + errors: PrivateLocationSpaceError[]; + }; +} + +/** + * Validates that every private location on a monitor is available in all the monitor's spaces. + * Returns null if valid, or a structured error describing which locations fail. + */ +export const validateMonitorPrivateLocationSpaces = ( + monitor: MonitorFields, + allPrivateLocations: SyntheticsPrivateLocations +): ValidationError | null => { + const monitorSpaces = monitor[ConfigKey.KIBANA_SPACES] ?? []; + if (monitorSpaces.length === 0) { + return null; + } + + const privateLocations = (monitor[ConfigKey.LOCATIONS] ?? []).filter( + (loc) => !loc.isServiceManaged + ); + if (privateLocations.length === 0) { + return null; + } + + const errors: PrivateLocationSpaceError[] = []; + + for (const loc of privateLocations) { + const matchedLocation = allPrivateLocations.find( + (privateLocation) => privateLocation.id === loc.id + ); + const locationSpaces = matchedLocation?.spaces; + + if (!privateLocationCoversAllMonitorSpaces(monitorSpaces, locationSpaces)) { + const monitorIsAllSpaces = monitorSpaces.includes(ALL_SPACES_ID); + const locationSpaceSet = new Set(locationSpaces ?? []); + const missingSpaces = monitorIsAllSpaces + ? ['*'] + : monitorSpaces.filter((s) => !locationSpaceSet.has(s)); + + errors.push({ + locationId: loc.id, + locationLabel: loc.label ?? loc.id, + monitorSpaces, + missingSpaces, + }); + } + } + + if (errors.length === 0) { + return null; + } + + const locationLabels = errors.map((e) => e.locationLabel).join(', '); + + return { + message: i18n.translate('xpack.synthetics.validation.privateLocationSpaceCoverage', { + defaultMessage: + 'The following private locations are not available in all spaces this monitor is shared to: {locationLabels}. ' + + 'Either share the private locations to all monitor spaces, or remove those spaces from the monitor.', + values: { locationLabels }, + }), + attributes: { errors }, + }; +}; + +/** + * Asserts that the current user has bulk_update privileges on the monitor saved object + * in all the specified spaces. Returns a 403 response if not authorized, or undefined if OK. + */ +export const assertCanUpdateMonitorInAllSpaces = async ( + routeContext: RouteContext, + spaceIds: string[] +) => { + const { request, response, server, spaceId } = routeContext; + + const uniqueSpaces = [...new Set(spaceIds)].filter((s) => s !== ALL_SPACES_ID); + if (uniqueSpaces.length <= 1 && uniqueSpaces[0] === spaceId) { + return; + } + if (uniqueSpaces.length === 0) { + return; + } + + const checkSavedObjectsPrivileges = + server.security.authz.checkSavedObjectsPrivilegesWithRequest(request); + + const { hasAllRequested } = await checkSavedObjectsPrivileges( + 'saved_object:synthetics-monitor/bulk_update', + uniqueSpaces + ); + + if (!hasAllRequested) { + return response.forbidden({ + body: { + message: i18n.translate('xpack.synthetics.validation.multiSpacePermissions', { + defaultMessage: + 'This monitor is shared to spaces where you do not have update permissions. To save changes, either request access to those spaces or remove them from the monitor.', + }), + }, + }); + } +}; diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_validation.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_validation.ts index dbf0d215ea176..19f1561f26a07 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_validation.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_validation.ts @@ -39,6 +39,7 @@ import { DEFAULT_FIELDS, HEARTBEAT_BROWSER_MONITOR_TIMEOUT_OVERHEAD_SECONDS, } from '../../../common/constants/monitor_defaults'; +import { privateLocationCoversAllMonitorSpaces } from './monitor_locations_utils'; type MonitorCodecType = | typeof ICMPFieldsCodec @@ -459,6 +460,22 @@ export function validateLocation( return INVALID_PRIVATE_LOCATION_ERROR(invalidLocation); } } + + if (hasPrivateLocationsConfigured && monitorFields.spaces && monitorFields.spaces.length > 0) { + for (const locationName of monitorFields.privateLocations ?? []) { + const loc = locationName.toLowerCase(); + const matchedLocation = privateLocations.find( + (privateLocation) => + privateLocation.label.toLowerCase() === loc || privateLocation.id.toLowerCase() === loc + ); + if (matchedLocation) { + if (!privateLocationCoversAllMonitorSpaces(monitorFields.spaces, matchedLocation.spaces)) { + return PRIVATE_LOCATION_SPACE_COVERAGE_ERROR(matchedLocation.label); + } + } + } + } + const hasEmptyLocations = monitorFields.locations && monitorFields.locations.length === 0 && @@ -537,6 +554,16 @@ const INVALID_PUBLIC_LOCATION_ERROR = (location: string) => }, }); +const PRIVATE_LOCATION_SPACE_COVERAGE_ERROR = (location: string) => + i18n.translate('xpack.synthetics.server.projectMonitors.privateLocationSpaceCoverageError', { + defaultMessage: + 'Private location "{location}" is not available in all spaces this monitor is shared to. ' + + 'Either share the private location to all monitor spaces, or remove those spaces from the monitor.', + values: { + location, + }, + }); + export const LOCATION_REQUIRED_ERROR = i18n.translate( 'xpack.synthetics.createMonitor.validation.noLocations', { diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/project_monitor/add_monitor_project.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/project_monitor/add_monitor_project.ts index 78be30c858be4..4e00d677d4a11 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/project_monitor/add_monitor_project.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/project_monitor/add_monitor_project.ts @@ -20,6 +20,7 @@ import type { ProjectMonitor } from '../../../../common/runtime_types'; import { SYNTHETICS_API_URLS } from '../../../../common/constants'; import { ProjectMonitorFormatter } from '../../../synthetics_service/project_monitor/project_monitor_formatter'; import { getBrowserTimeoutWarningsForProjectMonitors } from '../monitor_warnings'; +import { assertCanUpdateMonitorInAllSpaces } from '../monitor_locations_utils'; const MAX_PAYLOAD_SIZE = 1048576 * 100; // 50MiB const MAX_BROWSER_MONITORS = 250; @@ -178,30 +179,12 @@ const validMultiSpacePrivileges = async ( routeContext: RouteContext, monitors: ProjectMonitor[] ) => { - const { spaceId, request, response, server } = routeContext; - - const spacesList = monitors.flatMap((monitor) => monitor.spaces ?? []); - if (spacesList.length === 0 || (spacesList.length === 1 && spacesList[0] === spaceId)) { - // If there are no spaces or only the current space, no need to check privileges - return validProjectMultiSpace(routeContext, monitors); - } - - const checkSavedObjectsPrivileges = - server.security.authz.checkSavedObjectsPrivilegesWithRequest(request); - - const { hasAllRequested } = await checkSavedObjectsPrivileges( - 'saved_object:synthetics-monitor/bulk_update', - spacesList - ); - if (!hasAllRequested) { - throw response.forbidden({ - body: { - message: i18n.translate('xpack.synthetics.addMonitor.forbidden', { - defaultMessage: - 'You do not have sufficient permissions to update monitors in all required spaces.', - }), - }, - }); + const spacesList = [...new Set(monitors.flatMap((monitor) => monitor.spaces ?? []))]; + if (spacesList.length > 0) { + const spaceAuthError = await assertCanUpdateMonitorInAllSpaces(routeContext, spacesList); + if (spaceAuthError) { + throw spaceAuthError; + } } return validProjectMultiSpace(routeContext, monitors); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_private_locations.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_private_locations.ts index 51105038075d1..b700be040cbc4 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_private_locations.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/get_private_locations.ts @@ -21,10 +21,17 @@ import type { export const getPrivateLocations = async ( client: SavedObjectsClientContract, spaceId?: string +): Promise => { + return getPrivateLocationsForNamespaces(client, spaceId ? [spaceId] : undefined); +}; + +export const getPrivateLocationsForNamespaces = async ( + client: SavedObjectsClientContract, + namespaces?: string[] ): Promise => { try { const [results, legacyLocations] = await Promise.all([ - getNewPrivateLocations(client, spaceId), + getNewPrivateLocations(client, namespaces), getLegacyPrivateLocations(client), ]); @@ -37,11 +44,14 @@ export const getPrivateLocations = async ( } }; -const getNewPrivateLocations = async (client: SavedObjectsClientContract, spaceId?: string) => { +const getNewPrivateLocations = async ( + client: SavedObjectsClientContract, + namespaces?: string[] +) => { const finder = client.createPointInTimeFinder({ type: privateLocationSavedObjectName, perPage: 1000, - ...(spaceId ? { namespaces: [spaceId] } : {}), + ...(namespaces && namespaces.length > 0 ? { namespaces } : {}), }); const results: Array< From 3eefe7904f2229e72a7e6e76a0ecda463c1d32cb Mon Sep 17 00:00:00 2001 From: Bena Kansara Date: Wed, 1 Apr 2026 10:55:09 +0200 Subject: [PATCH 02/12] fix validation bypass when editing monitor spaces without changing locations --- .../monitor_cruds/add_monitor/add_monitor_api.ts | 14 ++++++++++++++ .../monitor_cruds/monitor_locations_utils.ts | 6 ++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts index a8a14d25259d5..3234c0107eb12 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts @@ -195,6 +195,20 @@ export class AddEditMonitorAPI { if (!locations && !privateLocations && prevLocations) { locationsVal = prevLocations; + + const prevPrivateLocations = prevLocations.filter((loc) => !loc.isServiceManaged); + if (prevPrivateLocations.length > 0) { + const monitorSpaces = monitor[ConfigKey.KIBANA_SPACES] ?? []; + const namespacesForLookup = [ + ...new Set([this.routeContext.spaceId, ...monitorSpaces]), + ].filter(Boolean); + const internalClient = + this.routeContext.server.coreStart.savedObjects.createInternalRepository(); + this.allPrivateLocations = await getPrivateLocationsForNamespaces( + internalClient, + namespacesForLookup + ); + } } else { const monitorLocations = parseMonitorLocations(monitorPayload, prevLocations, internal); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.ts index 23d6829779865..adce1cf6c18bd 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.ts @@ -123,8 +123,10 @@ export const assertCanUpdateMonitorInAllSpaces = async ( ) => { const { request, response, server, spaceId } = routeContext; - const uniqueSpaces = [...new Set(spaceIds)].filter((s) => s !== ALL_SPACES_ID); - if (uniqueSpaces.length <= 1 && uniqueSpaces[0] === spaceId) { + const uniqueSpaces = [...new Set(spaceIds)]; + const hasAllSpaces = uniqueSpaces.includes(ALL_SPACES_ID); + + if (!hasAllSpaces && uniqueSpaces.length <= 1 && uniqueSpaces[0] === spaceId) { return; } if (uniqueSpaces.length === 0) { From c1399559e92663a0d1e48abbae1f38186260617a Mon Sep 17 00:00:00 2001 From: Bena Kansara Date: Wed, 1 Apr 2026 12:13:12 +0200 Subject: [PATCH 03/12] fix types --- .../synthetics/server/routes/monitor_cruds/add_monitor.ts | 3 ++- .../server/routes/monitor_cruds/add_monitor/add_monitor_api.ts | 2 +- .../synthetics/server/routes/monitor_cruds/edit_monitor.ts | 3 +-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor.ts index d019c693f8fed..1cf76536e4693 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor.ts @@ -20,6 +20,7 @@ import type { CreateMonitorPayLoad } from './add_monitor/add_monitor_api'; import { AddEditMonitorAPI } from './add_monitor/add_monitor_api'; import type { SyntheticsRestApiRouteFactory } from '../types'; import { ConfigKey } from '../../../common/runtime_types'; +import type { MonitorFields } from '../../../common/runtime_types'; import { SYNTHETICS_API_URLS } from '../../../common/constants'; import { normalizeAPIConfig, validateMonitor } from './monitor_validation'; import { mapSavedObjectToMonitor } from './formatters/saved_object_to_monitor'; @@ -143,7 +144,7 @@ export const addSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ({ if (addMonitorAPI.allPrivateLocations && addMonitorAPI.allPrivateLocations.length > 0) { const plSpaceError = validateMonitorPrivateLocationSpaces( - normalizedMonitor, + normalizedMonitor as MonitorFields, addMonitorAPI.allPrivateLocations ); if (plSpaceError) { diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts index 3234c0107eb12..e98d863c0fab3 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts @@ -171,7 +171,7 @@ export class AddEditMonitorAPI { monitorPayload: CreateMonitorPayLoad, prevLocations?: MonitorFields['locations'] ) { - const { savedObjectsClient, syntheticsMonitorClient, request } = this.routeContext; + const { syntheticsMonitorClient, request } = this.routeContext; const internal = Boolean((request.query as { internal?: boolean })?.internal); const { locations, diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/edit_monitor.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/edit_monitor.ts index 09cb1f6ce1b62..a3387a897a0ae 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/edit_monitor.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/edit_monitor.ts @@ -282,8 +282,7 @@ export const syncEditedMonitor = async ({ routeContext: RouteContext; spaceId: string; }) => { - const { server, savedObjectsClient, syntheticsMonitorClient, monitorConfigRepository } = - routeContext; + const { server, syntheticsMonitorClient, monitorConfigRepository } = routeContext; const monitorId = decryptedPreviousMonitor.id; const monitorPrivateLocations = normalizedMonitor[ConfigKey.LOCATIONS].filter( From 9f01d59293bc4f6d3b1f94b72d56dd8d0f0806df Mon Sep 17 00:00:00 2001 From: Bena Kansara Date: Mon, 27 Apr 2026 15:54:31 +0200 Subject: [PATCH 04/12] remove unused translation key --- .../plugins/private/translations/translations/de-DE.json | 1 - .../plugins/private/translations/translations/fr-FR.json | 1 - .../plugins/private/translations/translations/ja-JP.json | 1 - .../plugins/private/translations/translations/zh-CN.json | 1 - 4 files changed, 4 deletions(-) diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index c5abfd948f5bd..d11ae613dfb70 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -46979,7 +46979,6 @@ "xpack.synthetics.addEditMonitor.scriptEditor.helpText": "Führt synthetische Testskripte aus, die inline definiert sind.", "xpack.synthetics.addEditMonitor.scriptEditor.label": "Skript-Editor", "xpack.synthetics.addEditMonitor.scriptEditor.placeholder": "// Fügen Sie hier Ihr Playwright-Skript ein...", - "xpack.synthetics.addMonitor.forbidden": "Sie verfügen nicht über ausreichende Berechtigungen, um Monitore in allen erforderlichen Bereichen zu aktualisieren.", "xpack.synthetics.aiAssistant.starterPrompts.explainNoData.prompt": "Warum sehe ich keine Überwachungen?", "xpack.synthetics.aiAssistant.starterPrompts.explainNoData.title": "Erklären", "xpack.synthetics.alertDropdown.noPermissions": "Sie haben nicht die erforderlichen Berechtigungen, um diese Aktion auszuführen.", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index a5342285b3c0a..016cc45259e69 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -47021,7 +47021,6 @@ "xpack.synthetics.addEditMonitor.scriptEditor.helpText": "Exécute des scripts de tests synthétiques définis en ligne.", "xpack.synthetics.addEditMonitor.scriptEditor.label": "Éditeur de script", "xpack.synthetics.addEditMonitor.scriptEditor.placeholder": "// Collez votre script Playwright ici...", - "xpack.synthetics.addMonitor.forbidden": "Vous n’avez pas suffisamment d’autorisations pour mettre à jour les moniteurs dans tous les espaces requis.", "xpack.synthetics.aiAssistant.starterPrompts.explainNoData.prompt": "Pourquoi n'y a-t-il aucun moniteur ?", "xpack.synthetics.aiAssistant.starterPrompts.explainNoData.title": "Expliquer", "xpack.synthetics.alertDropdown.noPermissions": "Vous ne disposez pas d'autorisations suffisantes pour effectuer cette action.", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index ba0a8fa112b71..4995becf44207 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -47172,7 +47172,6 @@ "xpack.synthetics.addEditMonitor.scriptEditor.helpText": "インラインで定義されたSyntheticテストスクリプトを実行します。", "xpack.synthetics.addEditMonitor.scriptEditor.label": "スクリプトエディター", "xpack.synthetics.addEditMonitor.scriptEditor.placeholder": "// ここにPlaywrightスクリプトを貼り付け...", - "xpack.synthetics.addMonitor.forbidden": "必要なすべてのスペースでモニターを更新する十分な権限がありません。", "xpack.synthetics.aiAssistant.starterPrompts.explainNoData.prompt": "モニターが表示されていない理由", "xpack.synthetics.aiAssistant.starterPrompts.explainNoData.title": "説明", "xpack.synthetics.alertDropdown.noPermissions": "このアクションを実行する十分な権限がありません。", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 2eccdc473babc..46fad81c6f573 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -47173,7 +47173,6 @@ "xpack.synthetics.addEditMonitor.scriptEditor.helpText": "运行内联定义的 Synthetics 测试脚本。", "xpack.synthetics.addEditMonitor.scriptEditor.label": "脚本编辑器", "xpack.synthetics.addEditMonitor.scriptEditor.placeholder": "// 在此处粘贴 Playwright 脚本......", - "xpack.synthetics.addMonitor.forbidden": "您没有足够的权限在全部所需工作区中更新监测。", "xpack.synthetics.aiAssistant.starterPrompts.explainNoData.prompt": "为什么我看不到任何监测?", "xpack.synthetics.aiAssistant.starterPrompts.explainNoData.title": "解释", "xpack.synthetics.alertDropdown.noPermissions": "您的权限不足,无法执行此操作。", From a15a34c698f07298bfb7082a09427c048f9ca304 Mon Sep 17 00:00:00 2001 From: Bena Kansara Date: Mon, 27 Apr 2026 17:38:20 +0200 Subject: [PATCH 05/12] share private location to spaces --- .../legacy_and_multispace_monitor_api.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/legacy_and_multispace_monitor_api.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/legacy_and_multispace_monitor_api.ts index a5cd2cd235272..8c43dcfe031f4 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/legacy_and_multispace_monitor_api.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/legacy_and_multispace_monitor_api.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { v4 as uuidv4 } from 'uuid'; import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants'; +import { privateLocationSavedObjectName } from '@kbn/synthetics-plugin/common/saved_objects/private_locations'; import { syntheticsMonitorSavedObjectType, legacySyntheticsMonitorTypeSingle, @@ -76,6 +77,18 @@ const runTests = ( return { ...rest, private_locations: [customPrivateLocation.id] }; }; + const sharePrivateLocationToSpaces = async (spacesToAdd: string[]) => { + if (!usePrivateLocations || !privateLocation || spacesToAdd.length === 0) { + return; + } + const res = await supertestEditorWithApiKey.post('/api/spaces/_update_objects_spaces').send({ + objects: [{ type: privateLocationSavedObjectName, id: privateLocation.id }], + spacesToAdd, + spacesToRemove: [], + }); + expect(res.status).eql(200, JSON.stringify(res.body)); + }; + before(async () => { await cleanSyntheticsTestData(kibanaServer); supertestEditorWithApiKey = await roleScopedSupertest.getSupertestWithRoleScope('editor', { @@ -216,6 +229,8 @@ const runTests = ( await kibanaServer.spaces.create({ id: NEW_SPACE, name: `Edit Space ${uuid}` }); spacesToDeleteIds.push(NEW_SPACE); + await sharePrivateLocationToSpaces([NEW_SPACE]); + await editMonitor( legacy.id, { spaces: ['default', NEW_SPACE], name: `legacy-now-multi-${uuid}` }, @@ -250,6 +265,8 @@ const runTests = ( await kibanaServer.spaces.create({ id: SPACE2, name: `Multi Space 2 ${uuid}` }); spacesToDeleteIds.push(SPACE1, SPACE2); + await sharePrivateLocationToSpaces([SPACE1, SPACE2]); + await editMonitor( multi.id, { spaces: ['default', SPACE1, SPACE2], name: `multi-edited-spaces-${uuid}` }, @@ -289,6 +306,8 @@ const runTests = ( await kibanaServer.spaces.create({ id: DEL_SPACE, name: `Del Space ${uuid}` }); spacesToDeleteIds.push(DEL_SPACE); + await sharePrivateLocationToSpaces([DEL_SPACE]); + await editMonitor( legacy.id, { spaces: ['default', DEL_SPACE], name: `legacy-del-multi-${uuid}` }, From ead32a2f0e18ba36c3269fd77cbb2340e1c7bfb0 Mon Sep 17 00:00:00 2001 From: Bena Kansara Date: Mon, 27 Apr 2026 18:48:10 +0200 Subject: [PATCH 06/12] update tests --- .../legacy_and_multispace_monitor_api.ts | 85 ++++++++++++++----- .../services/synthetics_private_location.ts | 42 +++++++-- 2 files changed, 100 insertions(+), 27 deletions(-) diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/legacy_and_multispace_monitor_api.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/legacy_and_multispace_monitor_api.ts index 8c43dcfe031f4..bcfac1404ed9c 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/legacy_and_multispace_monitor_api.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/legacy_and_multispace_monitor_api.ts @@ -7,8 +7,8 @@ import expect from '@kbn/expect'; import { v4 as uuidv4 } from 'uuid'; +import { ALL_SPACES_ID } from '@kbn/spaces-plugin/common/constants'; import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants'; -import { privateLocationSavedObjectName } from '@kbn/synthetics-plugin/common/saved_objects/private_locations'; import { syntheticsMonitorSavedObjectType, legacySyntheticsMonitorTypeSingle, @@ -77,25 +77,18 @@ const runTests = ( return { ...rest, private_locations: [customPrivateLocation.id] }; }; - const sharePrivateLocationToSpaces = async (spacesToAdd: string[]) => { - if (!usePrivateLocations || !privateLocation || spacesToAdd.length === 0) { - return; - } - const res = await supertestEditorWithApiKey.post('/api/spaces/_update_objects_spaces').send({ - objects: [{ type: privateLocationSavedObjectName, id: privateLocation.id }], - spacesToAdd, - spacesToRemove: [], - }); - expect(res.status).eql(200, JSON.stringify(res.body)); - }; - before(async () => { await cleanSyntheticsTestData(kibanaServer); supertestEditorWithApiKey = await roleScopedSupertest.getSupertestWithRoleScope('editor', { withInternalHeaders: true, }); + // Several tests in this suite share monitors to ad-hoc spaces, which is + // only allowed when the monitor's private locations cover those spaces. + // Provision the shared test private location as all-spaces so it mirrors + // a typical "globally available private location" setup and avoids having + // to re-share the location from inside individual tests. privateLocation = usePrivateLocations - ? await privateLocationService.addTestPrivateLocation() + ? await privateLocationService.addTestPrivateLocation([ALL_SPACES_ID]) : undefined; }); @@ -229,8 +222,6 @@ const runTests = ( await kibanaServer.spaces.create({ id: NEW_SPACE, name: `Edit Space ${uuid}` }); spacesToDeleteIds.push(NEW_SPACE); - await sharePrivateLocationToSpaces([NEW_SPACE]); - await editMonitor( legacy.id, { spaces: ['default', NEW_SPACE], name: `legacy-now-multi-${uuid}` }, @@ -265,8 +256,6 @@ const runTests = ( await kibanaServer.spaces.create({ id: SPACE2, name: `Multi Space 2 ${uuid}` }); spacesToDeleteIds.push(SPACE1, SPACE2); - await sharePrivateLocationToSpaces([SPACE1, SPACE2]); - await editMonitor( multi.id, { spaces: ['default', SPACE1, SPACE2], name: `multi-edited-spaces-${uuid}` }, @@ -306,8 +295,6 @@ const runTests = ( await kibanaServer.spaces.create({ id: DEL_SPACE, name: `Del Space ${uuid}` }); spacesToDeleteIds.push(DEL_SPACE); - await sharePrivateLocationToSpaces([DEL_SPACE]); - await editMonitor( legacy.id, { spaces: ['default', DEL_SPACE], name: `legacy-del-multi-${uuid}` }, @@ -494,6 +481,64 @@ const runTests = ( 'Invalid space ID provided in monitor configuration. It should always include the current space ID.' ); }); + + // The private-location-space-coverage validation only applies when the + // monitor actually has a private location configured. + if (usePrivateLocations) { + it('should throw error if a private location is not shared to all monitor spaces on edit', async () => { + // Spin up a single-space private location dedicated to this test so we + // can assert the validation rejects sharing the monitor to a space the + // private location does not cover. The suite-level `privateLocation` + // is intentionally all-spaces for the happy-path tests above. + const singleSpacePrivateLocation = await privateLocationService.addTestPrivateLocation(); + + const otherSpaceId = `pl-coverage-other-${uuidv4()}`; + await kibanaServer.spaces.create({ + id: otherSpaceId, + name: `PL Coverage Other Space`, + }); + spacesToDeleteIds.push(otherSpaceId); + + const monitorData = applyLocation( + { + ...getFixtureJson('http_monitor'), + name: `pl-coverage-${uuidv4()}`, + }, + singleSpacePrivateLocation + ); + + const createRes = await supertestEditorWithApiKey + .post( + `${SYNTHETICS_API_URLS.SYNTHETICS_MONITORS}?internal=true&savedObjectType=${syntheticsMonitorSavedObjectType}` + ) + .send(monitorData); + expect(createRes.status).eql(200, JSON.stringify(createRes.body)); + + const editRes = await supertestEditorWithApiKey + .put(`${SYNTHETICS_API_URLS.SYNTHETICS_MONITORS}/${createRes.body.id}?internal=true`) + .send({ + name: `pl-coverage-edit-${uuidv4()}`, + spaces: ['default', otherSpaceId], + }); + + expect(editRes.status).to.be(400); + expect(editRes.body.message).to.contain( + 'The following private locations are not available in all spaces this monitor is shared to' + ); + expect(editRes.body.message).to.contain(singleSpacePrivateLocation.label); + expect(editRes.body.attributes?.errors).to.be.an('array'); + expect(editRes.body.attributes?.errors?.[0]?.locationId).to.eql( + singleSpacePrivateLocation.id + ); + expect(editRes.body.attributes?.errors?.[0]?.missingSpaces).to.eql([otherSpaceId]); + + // Cleanup the monitor; it was never updated, so it still lives in the + // legacy or multi-space SO type with its original single space. + await supertestEditorWithApiKey + .delete(`${SYNTHETICS_API_URLS.SYNTHETICS_MONITORS}/${createRes.body.id}`) + .send(); + }); + } }); }; diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/services/synthetics_private_location.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/services/synthetics_private_location.ts index 0759110e2a61c..de9182fbf0521 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/services/synthetics_private_location.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/services/synthetics_private_location.ts @@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid'; import type { RetryService } from '@kbn/ftr-common-functional-services'; import { X_ELASTIC_INTERNAL_ORIGIN_REQUEST } from '@kbn/core-http-common'; +import { ALL_SPACES_ID } from '@kbn/spaces-plugin/common/constants'; import { privateLocationSavedObjectName } from '@kbn/synthetics-plugin/common/saved_objects/private_locations'; import type { SyntheticsPrivateLocations } from '@kbn/synthetics-plugin/common/runtime_types'; import type { KibanaSupertestProvider } from '@kbn/ftr-common-functional-services'; @@ -181,23 +182,40 @@ export class PrivateLocationTestService { }); } - async addTestPrivateLocation(spaceId = 'default') { - const apiResponse = await this.addFleetPolicy(uuidv4(), [spaceId]); + /** + * Creates a Fleet agent policy and a backing private location saved object. + * + * Accepts a single space id (default 'default') or an array of space ids + * — including the wildcard `*` (ALL_SPACES_ID) to create a globally-shared + * private location available in every space. Tests that share monitors to + * additional spaces should use the all-spaces variant so that the synthetics + * private-location-space-coverage validation is satisfied. + */ + async addTestPrivateLocation(spaces: string | string[] = 'default') { + const spaceIds = Array.isArray(spaces) ? spaces : [spaces]; + const apiResponse = await this.addFleetPolicy(uuidv4(), spaceIds); const testPolicyId = apiResponse.body.item.id; - return (await this.setTestLocations([testPolicyId], spaceId))[0]; + return (await this.setTestLocations([testPolicyId], spaceIds))[0]; } async addFleetPolicy(name: string, spaceIds = ['default']) { + const isAllSpaces = spaceIds.includes(ALL_SPACES_ID); + // The Fleet endpoint cannot be called with an `/s/*` URL prefix, so route + // all-spaces and default-only requests through the default space and rely + // on the `space_ids` payload to mark the policy as all-spaces. + const urlSpacePrefix = isAllSpaces || spaceIds[0] === 'default' ? '' : `/s/${spaceIds[0]}`; + const isMultiSpace = isAllSpaces || spaceIds.length > 1; + return await this.retry.try(async () => { const response = await this.supertestWithAuth - .post(`${spaceIds[0] !== 'default' ? `/s/${spaceIds[0]}` : ``}/api/fleet/agent_policies`) + .post(`${urlSpacePrefix}/api/fleet/agent_policies`) .set('kbn-xsrf', 'true') .send({ name, description: '', namespace: 'default', monitoring_enabled: [], - space_ids: spaceIds.length > 1 ? spaceIds : undefined, + space_ids: isMultiSpace ? spaceIds : undefined, }) .expect(200); return response; @@ -222,7 +240,17 @@ export class PrivateLocationTestService { }, isServiceManaged: false, })); - const urlSpaceId = spaceId ? (Array.isArray(spaceId) ? spaceId[0] : spaceId) : 'default'; + const initialNamespaces = spaceId + ? Array.isArray(spaceId) + ? spaceId + : [spaceId] + : ['default']; + // `*` is not a valid URL space prefix — issue the bulk_create from the + // default space and rely on `initialNamespaces` to share the saved object + // to all spaces. + const firstNamespace = initialNamespaces[0]; + const urlSpaceId = + firstNamespace === ALL_SPACES_ID || !firstNamespace ? 'default' : firstNamespace; await this.supertestWithAuth .post(`/s/${urlSpaceId}/api/saved_objects/_bulk_create`) @@ -233,7 +261,7 @@ export class PrivateLocationTestService { type: privateLocationSavedObjectName, id: location.id, attributes: location, - initialNamespaces: spaceId ? (Array.isArray(spaceId) ? spaceId : [spaceId]) : ['default'], + initialNamespaces, })) ) .expect(200); From 85dbfd713dd4c30107dba099cec21325174831f1 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:54:14 +0000 Subject: [PATCH 07/12] Changes from node scripts/lint_ts_projects --fix --- x-pack/solutions/observability/test/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/solutions/observability/test/tsconfig.json b/x-pack/solutions/observability/test/tsconfig.json index ef059c5b45db9..09b24d637c314 100644 --- a/x-pack/solutions/observability/test/tsconfig.json +++ b/x-pack/solutions/observability/test/tsconfig.json @@ -96,6 +96,7 @@ "@kbn/observability-agent-builder-plugin", "@kbn/maintenance-windows-plugin", "@kbn/product-doc-common", - "@kbn/kbn-client" + "@kbn/kbn-client", + "@kbn/spaces-plugin" ] } From efedd66e63add67c61ca97b07e204fe0bea9b75d Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:06:10 +0000 Subject: [PATCH 08/12] Changes from node scripts/regenerate_moon_projects.js --update --- x-pack/solutions/observability/test/moon.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/solutions/observability/test/moon.yml b/x-pack/solutions/observability/test/moon.yml index 82e64aee2f114..2f07ce4f376c5 100644 --- a/x-pack/solutions/observability/test/moon.yml +++ b/x-pack/solutions/observability/test/moon.yml @@ -91,6 +91,7 @@ dependsOn: - '@kbn/maintenance-windows-plugin' - '@kbn/product-doc-common' - '@kbn/kbn-client' + - '@kbn/spaces-plugin' tags: - test-helper - package From c63fd761aa63349e9bfc64506ed1bfb2102a678d Mon Sep 17 00:00:00 2001 From: Bena Kansara Date: Mon, 27 Apr 2026 21:01:16 +0200 Subject: [PATCH 09/12] update tests --- .../synthetics/create_monitor_project_private_location.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/create_monitor_project_private_location.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/create_monitor_project_private_location.ts index ae9e2e60e7986..c49f459869a08 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/create_monitor_project_private_location.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/create_monitor_project_private_location.ts @@ -2101,7 +2101,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { expect(resp.status).to.eql(403); expect(resp.body.message).to.eql( - 'You do not have sufficient permissions to update monitors in all required spaces.' + 'This monitor is shared to spaces where you do not have update permissions. To save changes, either request access to those spaces or remove them from the monitor.' ); } finally { await monitorTestService.deleteMonitorByJourney( @@ -2161,7 +2161,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { expect(resp.status).to.eql(403); expect(resp.body.message).to.eql( - 'You do not have sufficient permissions to update monitors in all required spaces.' + 'This monitor is shared to spaces where you do not have update permissions. To save changes, either request access to those spaces or remove them from the monitor.' ); } finally { await monitorTestService.deleteMonitorByJourney( From 6257c101789a0954e6ce0391ae2d4703e5683bac Mon Sep 17 00:00:00 2001 From: Bena Kansara Date: Mon, 27 Apr 2026 21:59:19 +0200 Subject: [PATCH 10/12] update tests --- .../create_monitor_project_private_location.ts | 9 +++++---- .../apis/synthetics/migrate_legacy_policies.ts | 11 +++++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/create_monitor_project_private_location.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/create_monitor_project_private_location.ts index c49f459869a08..b305d4ce4a639 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/create_monitor_project_private_location.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/create_monitor_project_private_location.ts @@ -2182,19 +2182,20 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { const SPACE_ID_2 = `test-space-2-${uuidv4()}`; const SPACE_NAME_1 = `test-space-name-1-${uuidv4()}`; const SPACE_NAME_2 = `test-space-name-2-${uuidv4()}`; - const spaceScopedPrivateLocation = await testPrivateLocationsService.addTestPrivateLocation( - SPACE_ID_1 - ); await kibanaServer.spaces.create({ id: SPACE_ID_1, name: SPACE_NAME_1 }); await kibanaServer.spaces.create({ id: SPACE_ID_2, name: SPACE_NAME_2 }); + const allSpacesPrivateLocation = await testPrivateLocationsService.addTestPrivateLocation([ + '*', + ]); + try { // Use a monitor with spaces: ['*'] const monitorId = uuidv4(); const monitor = { ...httpProjectMonitors.monitors[1], - privateLocations: [spaceScopedPrivateLocation.label], + privateLocations: [allSpacesPrivateLocation.label], id: monitorId, name: `All spaces Monitor ${monitorId}`, spaces: ['*'], diff --git a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/migrate_legacy_policies.ts b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/migrate_legacy_policies.ts index c9fc0660c421b..a74f65e516afd 100644 --- a/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/migrate_legacy_policies.ts +++ b/x-pack/solutions/observability/test/api_integration_deployment_agnostic/apis/synthetics/migrate_legacy_policies.ts @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; import { v4 as uuidv4 } from 'uuid'; import type { RoleCredentials } from '@kbn/ftr-common-functional-services'; +import { ALL_SPACES_ID } from '@kbn/spaces-plugin/common/constants'; import { SYNTHETICS_API_URLS } from '@kbn/synthetics-plugin/common/constants'; import type { PackagePolicy } from '@kbn/fleet-plugin/common'; import type { PrivateLocation, HTTPFields } from '@kbn/synthetics-plugin/common/runtime_types'; @@ -143,9 +144,15 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { _httpMonitorJson = getFixtureJson('http_monitor'); - const apiResponse = await testPrivateLocations.addFleetPolicy('Legacy Migration Test Policy'); + const apiResponse = await testPrivateLocations.addFleetPolicy( + 'Legacy Migration Test Policy', + [ALL_SPACES_ID] + ); testFleetPolicyID = apiResponse.body.item.id; - const locations = await testPrivateLocations.setTestLocations([testFleetPolicyID]); + const locations = await testPrivateLocations.setTestLocations( + [testFleetPolicyID], + [ALL_SPACES_ID] + ); privateLocation = locations[0]; const pkgResponse = await supertestAdmin.get('/api/fleet/epm/packages/synthetics'); From 331ac599a0ae51c79bda0ac4296cc070538fc483 Mon Sep 17 00:00:00 2001 From: Bena Kansara Date: Thu, 30 Apr 2026 19:18:09 +0200 Subject: [PATCH 11/12] use correct so type --- .../synthetics/server/routes/monitor_cruds/edit_monitor.ts | 3 ++- .../server/routes/monitor_cruds/monitor_locations_utils.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/edit_monitor.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/edit_monitor.ts index a3387a897a0ae..8483ed5d67bcc 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/edit_monitor.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/edit_monitor.ts @@ -149,7 +149,8 @@ export const editSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ( if (editedMonitorSpaces.length > 0) { const spaceAuthError = await assertCanUpdateMonitorInAllSpaces( routeContext, - editedMonitorSpaces + editedMonitorSpaces, + decryptedMonitorPrevMonitor.type ); if (spaceAuthError) { return spaceAuthError; diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.ts index adce1cf6c18bd..65fe2be01237d 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/monitor_cruds/monitor_locations_utils.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { ALL_SPACES_ID } from '@kbn/spaces-plugin/common/constants'; import type { MonitorFields, SyntheticsPrivateLocations } from '../../../common/runtime_types'; import { ConfigKey } from '../../../common/runtime_types'; +import { syntheticsMonitorSavedObjectType } from '../../../common/types/saved_objects'; import type { RouteContext } from '../types'; /** @@ -119,7 +120,8 @@ export const validateMonitorPrivateLocationSpaces = ( */ export const assertCanUpdateMonitorInAllSpaces = async ( routeContext: RouteContext, - spaceIds: string[] + spaceIds: string[], + savedObjectType: string = syntheticsMonitorSavedObjectType ) => { const { request, response, server, spaceId } = routeContext; @@ -137,7 +139,7 @@ export const assertCanUpdateMonitorInAllSpaces = async ( server.security.authz.checkSavedObjectsPrivilegesWithRequest(request); const { hasAllRequested } = await checkSavedObjectsPrivileges( - 'saved_object:synthetics-monitor/bulk_update', + `saved_object:${savedObjectType}/bulk_update`, uniqueSpaces ); From 2baeb1822730597776a26590dd021263cf00fff8 Mon Sep 17 00:00:00 2001 From: Bena Kansara Date: Thu, 30 Apr 2026 19:23:50 +0200 Subject: [PATCH 12/12] use correct so type when editing private location --- .../private_locations/edit_private_location.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/edit_private_location.ts b/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/edit_private_location.ts index f09172392f082..2de814131ef81 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/edit_private_location.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/routes/settings/private_locations/edit_private_location.ts @@ -21,6 +21,7 @@ import { SYNTHETICS_API_URLS } from '../../../../common/constants'; import { toClientContract, updatePrivateLocationMonitors } from './helpers'; import type { PrivateLocation } from '../../../../common/runtime_types'; import { parseArrayFilters } from '../../common'; +import { syntheticsMonitorSOTypes } from '../../../../common/types/saved_objects'; const EditPrivateLocationSchema = schema.object({ label: schema.maybe( @@ -76,11 +77,14 @@ const checkPrivileges = async ({ const checkSavedObjectsPrivileges = server.security.authz.checkSavedObjectsPrivilegesWithRequest(request); - const { hasAllRequested } = await checkSavedObjectsPrivileges( - 'saved_object:synthetics-monitor/bulk_update', - monitorsSpaces + const results = await Promise.all( + syntheticsMonitorSOTypes.map((soType) => + checkSavedObjectsPrivileges(`saved_object:${soType}/bulk_update`, monitorsSpaces) + ) ); + const hasAllRequested = results.every((result) => result.hasAllRequested); + if (!hasAllRequested) { return response.forbidden({ body: {