diff --git a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.test.ts index 88795c1aafc79..002dd9fb6e4ca 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.test.ts @@ -85,6 +85,101 @@ describe('MonitorConfigRepository', () => { }); }); + describe('getAcrossSpaces', () => { + it('issues a single multi-space lookup and one legacy lookup per namespace', async () => { + const id = 'test-id'; + const namespaces = ['default', 'space-two']; + const mockMonitor = { + id, + attributes: { name: 'Test Monitor' }, + type: syntheticsMonitorSavedObjectType, + references: [], + }; + soClient.bulkGet.mockResolvedValue({ saved_objects: [mockMonitor] }); + + const result = await repository.getAcrossSpaces(id, namespaces); + + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { type: syntheticsMonitorSavedObjectType, id, namespaces: ['default', 'space-two'] }, + { type: legacySyntheticsMonitorTypeSingle, id, namespaces: ['default'] }, + { type: legacySyntheticsMonitorTypeSingle, id, namespaces: ['space-two'] }, + ]); + expect(result).toBe(mockMonitor); + }); + + it('returns the first saved object that has attributes and no error', async () => { + const id = 'test-id'; + const errored = { + id, + type: syntheticsMonitorSavedObjectType, + attributes: {}, + references: [], + error: { statusCode: 404, error: 'Not Found', message: 'not found' }, + }; + const found = { + id, + type: legacySyntheticsMonitorTypeSingle, + attributes: { name: 'Legacy' }, + references: [], + }; + soClient.bulkGet.mockResolvedValue({ saved_objects: [errored as any, found] }); + + const result = await repository.getAcrossSpaces(id, ['default']); + + expect(result).toBe(found); + }); + + it('throws not-found when no namespace has the monitor', async () => { + const id = 'missing-id'; + soClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id, + type: syntheticsMonitorSavedObjectType, + error: { statusCode: 404, error: 'Not Found', message: 'not found' }, + }, + ], + } as any); + + await expect(repository.getAcrossSpaces(id, ['default'])).rejects.toMatchObject({ + output: { statusCode: 404 }, + }); + }); + + it('deduplicates the namespaces array', async () => { + const id = 'dup-id'; + soClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { id, type: syntheticsMonitorSavedObjectType, attributes: {}, references: [] }, + ], + } as any); + + await repository.getAcrossSpaces(id, ['default', 'default', 'space-two']); + + const calledWith = soClient.bulkGet.mock.calls[0][0]; + expect(calledWith).toEqual([ + { type: syntheticsMonitorSavedObjectType, id, namespaces: ['default', 'space-two'] }, + { type: legacySyntheticsMonitorTypeSingle, id, namespaces: ['default'] }, + { type: legacySyntheticsMonitorTypeSingle, id, namespaces: ['space-two'] }, + ]); + }); + + it('uses the supplied saved objects client when provided', async () => { + const id = 'test-id'; + const altClient = savedObjectsClientMock.create(); + altClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { id, type: syntheticsMonitorSavedObjectType, attributes: {}, references: [] }, + ], + } as any); + + await repository.getAcrossSpaces(id, ['default'], altClient); + + expect(altClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).not.toHaveBeenCalled(); + }); + }); + describe('getDecrypted', () => { it('should get and decrypt a monitor by id and space', async () => { const id = 'test-id'; diff --git a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.ts b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.ts index 950e595b70bb3..03934d3196cbb 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_config_repository.ts @@ -6,8 +6,10 @@ */ import type { + ISavedObjectsRepository, SavedObject, SavedObjectReference, + SavedObjectsBulkGetObject, SavedObjectsClientContract, SavedObjectsFindOptions, SavedObjectsFindResult, @@ -75,6 +77,46 @@ export class MonitorConfigRepository { return resolved; } + /** + * Look up a monitor by id across the supplied spaces. + * + * Required for cross-space callers (e.g. the monitor health API) because + * `get` is bound to the request-scoped saved objects client and therefore + * only ever sees the request's space — see Kibana issue #270477. + * + * The multi-space type (`syntheticsMonitorSavedObjectType`, + * `namespaceType: 'multiple'`) supports a per-object `namespaces` array, so + * a single entry covers all spaces. The legacy type + * (`legacySyntheticsMonitorTypeSingle`, `namespaceType: 'single'`) only + * accepts one namespace per object, so we add one entry per space. + */ + async getAcrossSpaces( + id: string, + namespaces: string[], + soClient: SavedObjectsClientContract | ISavedObjectsRepository = this.soClient + ): Promise> { + const uniqueNamespaces = [...new Set(namespaces)]; + const bulkObjects: SavedObjectsBulkGetObject[] = [ + { type: syntheticsMonitorSavedObjectType, id, namespaces: uniqueNamespaces }, + ...uniqueNamespaces.map((namespace) => ({ + type: legacySyntheticsMonitorTypeSingle, + id, + namespaces: [namespace], + })), + ]; + const { saved_objects: results } = await soClient.bulkGet( + bulkObjects + ); + const resolved = results.find((obj) => obj?.attributes && !obj.error); + if (!resolved) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError( + syntheticsMonitorSavedObjectType, + id + ); + } + return resolved; + } + async getDecrypted( id: string, spaceId: string diff --git a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.test.ts b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.test.ts index 34933f7d0e9a9..d8e0660efd906 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.test.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.test.ts @@ -21,16 +21,20 @@ import { MonitorIntegrationHealthApi } from './monitor_integration_health_api'; jest.mock('../synthetics_service/get_private_locations'); jest.mock('../synthetics_service/private_location/synthetics_private_location'); +jest.mock('../synthetics_service/private_location/package_policy_service'); -import { getPrivateLocations } from '../synthetics_service/get_private_locations'; +import { getPrivateLocationsForNamespaces } from '../synthetics_service/get_private_locations'; import { SyntheticsPrivateLocation } from '../synthetics_service/private_location/synthetics_private_location'; +import { PackagePolicyService } from '../synthetics_service/private_location/package_policy_service'; -const mockedGetPrivateLocations = getPrivateLocations as jest.MockedFunction< - typeof getPrivateLocations ->; +const mockedGetPrivateLocationsForNamespaces = + getPrivateLocationsForNamespaces as jest.MockedFunction; const MockedSyntheticsPrivateLocation = SyntheticsPrivateLocation as jest.MockedClass< typeof SyntheticsPrivateLocation >; +const MockedPackagePolicyService = PackagePolicyService as jest.MockedClass< + typeof PackagePolicyService +>; const SPACE_ID = 'default'; @@ -70,13 +74,29 @@ const createPackagePolicy = (policyId: string, agentPolicyIds: string[]): Packag policy_ids: agentPolicyIds, } as unknown as PackagePolicy); -const buildApi = (overrides: { - monitorConfigRepository?: { get: jest.Mock }; - fleetGetByIDs?: jest.Mock; +interface BuildApiOverrides { + monitorConfigRepository?: { getAcrossSpaces: jest.Mock }; + /** + * Mocks the Synthetics PackagePolicyService wrapper that the health API + * uses to fetch package policies across spaces. + */ + packagePolicyServiceGetByIds?: jest.Mock; fleetAgentPolicyGetByIds?: jest.Mock; fleetGetInstallation?: jest.Mock; -}): MonitorIntegrationHealthApi => { - const fleetGetByIDs = overrides.fleetGetByIDs ?? jest.fn().mockResolvedValue([]); + getUnsafeInternalClient?: jest.Mock; + spaceId?: string; +} + +const buildApi = (overrides: BuildApiOverrides = {}): MonitorIntegrationHealthApi => { + const packagePolicyServiceGetByIds = + overrides.packagePolicyServiceGetByIds ?? jest.fn().mockResolvedValue([]); + + MockedPackagePolicyService.mockImplementation( + () => + ({ + getByIds: packagePolicyServiceGetByIds, + } as any) + ); const fleetAgentPolicyGetByIds = overrides.fleetAgentPolicyGetByIds ?? @@ -91,10 +111,12 @@ const buildApi = (overrides: { coreStart: { savedObjects: { createInternalRepository: jest.fn().mockReturnValue({}), + getUnsafeInternalClient: + overrides.getUnsafeInternalClient ?? + jest.fn().mockReturnValue({ asScopedToNamespace: jest.fn().mockReturnValue({}) }), }, }, fleet: { - packagePolicyService: { getByIDs: fleetGetByIDs }, agentPolicyService: { getByIds: fleetAgentPolicyGetByIds }, packageService: { asInternalUser: { getInstallation: fleetGetInstallation }, @@ -105,14 +127,14 @@ const buildApi = (overrides: { const savedObjectsClient = {} as SavedObjectsClientContract; const monitorConfigRepository = (overrides.monitorConfigRepository ?? { - get: jest.fn(), + getAcrossSpaces: jest.fn(), }) as unknown as MonitorConfigRepository; return new MonitorIntegrationHealthApi( server, savedObjectsClient, monitorConfigRepository, - SPACE_ID + overrides.spaceId ?? SPACE_ID ); }; @@ -158,7 +180,7 @@ describe('MonitorIntegrationHealthApi', () => { } as any) ); - mockedGetPrivateLocations.mockResolvedValue([]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([]); }); describe('monitor fetching and partial errors', () => { @@ -169,7 +191,7 @@ describe('MonitorIntegrationHealthApi', () => { ); const api = buildApi({ monitorConfigRepository: { - get: jest.fn().mockRejectedValue(notFoundError), + getAcrossSpaces: jest.fn().mockRejectedValue(notFoundError), }, }); @@ -193,7 +215,7 @@ describe('MonitorIntegrationHealthApi', () => { .mockResolvedValueOnce(successSO) .mockRejectedValueOnce(notFoundError); - const api = buildApi({ monitorConfigRepository: { get: getMock } }); + const api = buildApi({ monitorConfigRepository: { getAcrossSpaces: getMock } }); const result = await api.getHealth(['mon-1', 'mon-2']); @@ -207,7 +229,7 @@ describe('MonitorIntegrationHealthApi', () => { it('provides a default error message when rejection has no message', async () => { const api = buildApi({ monitorConfigRepository: { - get: jest.fn().mockRejectedValue({}), + getAcrossSpaces: jest.fn().mockRejectedValue({}), }, }); @@ -224,7 +246,7 @@ describe('MonitorIntegrationHealthApi', () => { ); const api = buildApi({ monitorConfigRepository: { - get: jest.fn().mockRejectedValue(forbiddenError), + getAcrossSpaces: jest.fn().mockRejectedValue(forbiddenError), }, }); @@ -238,7 +260,7 @@ describe('MonitorIntegrationHealthApi', () => { it('defaults statusCode to 500 for generic errors without output.statusCode', async () => { const api = buildApi({ monitorConfigRepository: { - get: jest.fn().mockRejectedValue(new Error('Something went wrong')), + getAcrossSpaces: jest.fn().mockRejectedValue(new Error('Something went wrong')), }, }); @@ -257,7 +279,7 @@ describe('MonitorIntegrationHealthApi', () => { }); const api = buildApi({ - monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, }); const result = await api.getHealth(['mon-1']); @@ -281,15 +303,15 @@ describe('MonitorIntegrationHealthApi', () => { locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], }); - mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc]); const expectedPolicyId = 'mon-1-priv-loc-1'; const packagePolicy = createPackagePolicy(expectedPolicyId, ['agent-policy-1']); - const fleetGetByIDs = jest.fn().mockResolvedValue([packagePolicy]); + const packagePolicyServiceGetByIds = jest.fn().mockResolvedValue([packagePolicy]); const api = buildApi({ - monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, - fleetGetByIDs, + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, + packagePolicyServiceGetByIds, }); const result = await api.getHealth(['mon-1']); @@ -320,12 +342,12 @@ describe('MonitorIntegrationHealthApi', () => { locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], }); - mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc]); - const fleetGetByIDs = jest.fn().mockResolvedValue([]); + const packagePolicyServiceGetByIds = jest.fn().mockResolvedValue([]); const api = buildApi({ - monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, - fleetGetByIDs, + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, + packagePolicyServiceGetByIds, }); const result = await api.getHealth(['mon-1']); @@ -343,10 +365,10 @@ describe('MonitorIntegrationHealthApi', () => { locations: [{ id: 'gone-loc', label: 'Gone Location', isServiceManaged: false }], }); - mockedGetPrivateLocations.mockResolvedValue([]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([]); const api = buildApi({ - monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, }); const result = await api.getHealth(['mon-1']); @@ -363,10 +385,10 @@ describe('MonitorIntegrationHealthApi', () => { locations: [{ id: 'gone-loc', isServiceManaged: false }], }); - mockedGetPrivateLocations.mockResolvedValue([]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([]); const api = buildApi({ - monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, }); const result = await api.getHealth(['mon-1']); @@ -382,11 +404,11 @@ describe('MonitorIntegrationHealthApi', () => { locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], }); - mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc]); const fleetAgentPolicyGetByIds = jest.fn().mockResolvedValue([]); const api = buildApi({ - monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, fleetAgentPolicyGetByIds, }); @@ -409,17 +431,17 @@ describe('MonitorIntegrationHealthApi', () => { ], }); - mockedGetPrivateLocations.mockResolvedValue([privateLoc1, privateLoc2]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc1, privateLoc2]); const expectedPolicyId1 = 'mon-1-loc-1'; - const fleetGetByIDs = jest + const packagePolicyServiceGetByIds = jest .fn() .mockResolvedValue([createPackagePolicy(expectedPolicyId1, ['existing-agent'])]); const fleetAgentPolicyGetByIds = jest.fn().mockResolvedValue([{ id: 'existing-agent' }]); const api = buildApi({ - monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, - fleetGetByIDs, + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, + packagePolicyServiceGetByIds, fleetAgentPolicyGetByIds, }); @@ -442,15 +464,15 @@ describe('MonitorIntegrationHealthApi', () => { locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], }); - mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc]); const expectedPolicyId = 'mon-1-priv-loc-1'; const packagePolicy = createPackagePolicy(expectedPolicyId, ['agent-policy-1']); - const fleetGetByIDs = jest.fn().mockResolvedValue([packagePolicy]); + const packagePolicyServiceGetByIds = jest.fn().mockResolvedValue([packagePolicy]); const api = buildApi({ - monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, - fleetGetByIDs, + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, + packagePolicyServiceGetByIds, }); const result = await api.getHealth(['mon-1']); @@ -470,29 +492,29 @@ describe('MonitorIntegrationHealthApi', () => { locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], }); - mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc]); const expectedPolicyId = `${monitorQueryId}-priv-loc-1`; const wrongPolicyId = `so-uuid-priv-loc-1`; const packagePolicy = createPackagePolicy(expectedPolicyId, ['agent-policy-1']); - const fleetGetByIDs = jest.fn().mockResolvedValue([packagePolicy]); + const packagePolicyServiceGetByIds = jest.fn().mockResolvedValue([packagePolicy]); const api = buildApi({ - monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, - fleetGetByIDs, + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, + packagePolicyServiceGetByIds, }); const result = await api.getHealth(['so-uuid']); - expect(fleetGetByIDs).toHaveBeenCalledWith( - expect.anything(), - expect.arrayContaining([expectedPolicyId]), - expect.anything() + expect(packagePolicyServiceGetByIds).toHaveBeenCalledWith( + expect.objectContaining({ + packagePolicyIds: expect.arrayContaining([expectedPolicyId]), + }) ); - expect(fleetGetByIDs).not.toHaveBeenCalledWith( - expect.anything(), - expect.arrayContaining([wrongPolicyId]), - expect.anything() + expect(packagePolicyServiceGetByIds).not.toHaveBeenCalledWith( + expect.objectContaining({ + packagePolicyIds: expect.arrayContaining([wrongPolicyId]), + }) ); expect(result.monitors[0].privateLocations[0].status).toBe( PrivateLocationHealthStatusValue.Healthy @@ -522,9 +544,9 @@ describe('MonitorIntegrationHealthApi', () => { ], }); - mockedGetPrivateLocations.mockResolvedValue([privateLoc1, privateLoc2]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc1, privateLoc2]); - const fleetGetByIDs = jest + const packagePolicyServiceGetByIds = jest .fn() .mockResolvedValue([ createPackagePolicy('mon-1-loc-1', ['agent-1']), @@ -534,8 +556,8 @@ describe('MonitorIntegrationHealthApi', () => { const getMock = jest.fn().mockResolvedValueOnce(so1).mockResolvedValueOnce(so2); const api = buildApi({ - monitorConfigRepository: { get: getMock }, - fleetGetByIDs, + monitorConfigRepository: { getAcrossSpaces: getMock }, + packagePolicyServiceGetByIds, }); const result = await api.getHealth(['mon-1', 'mon-2']); @@ -567,15 +589,15 @@ describe('MonitorIntegrationHealthApi', () => { locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], }); - mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc]); const legacyPolicyId = `mon-1-priv-loc-1-${SPACE_ID}`; const packagePolicy = createPackagePolicy(legacyPolicyId, ['agent-policy-1']); - const fleetGetByIDs = jest.fn().mockResolvedValue([packagePolicy]); + const packagePolicyServiceGetByIds = jest.fn().mockResolvedValue([packagePolicy]); const api = buildApi({ - monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, - fleetGetByIDs, + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, + packagePolicyServiceGetByIds, }); const result = await api.getHealth(['mon-1']); @@ -593,11 +615,11 @@ describe('MonitorIntegrationHealthApi', () => { locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], }); - mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc]); const newPolicyId = 'mon-1-priv-loc-1'; const legacyPolicyId = `mon-1-priv-loc-1-${SPACE_ID}`; - const fleetGetByIDs = jest + const packagePolicyServiceGetByIds = jest .fn() .mockResolvedValue([ createPackagePolicy(newPolicyId, ['agent-policy-1']), @@ -605,8 +627,8 @@ describe('MonitorIntegrationHealthApi', () => { ]); const api = buildApi({ - monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, - fleetGetByIDs, + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, + packagePolicyServiceGetByIds, }); const result = await api.getHealth(['mon-1']); @@ -623,15 +645,15 @@ describe('MonitorIntegrationHealthApi', () => { locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], }); - mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc]); const legacyPolicyId = `mon-1-priv-loc-1-${SPACE_ID}`; const packagePolicy = createPackagePolicy(legacyPolicyId, ['wrong-agent']); - const fleetGetByIDs = jest.fn().mockResolvedValue([packagePolicy]); + const packagePolicyServiceGetByIds = jest.fn().mockResolvedValue([packagePolicy]); const api = buildApi({ - monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, - fleetGetByIDs, + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, + packagePolicyServiceGetByIds, }); const result = await api.getHealth(['mon-1']); @@ -643,6 +665,250 @@ describe('MonitorIntegrationHealthApi', () => { }); }); + describe('cross-space lookups (issue #270477)', () => { + it('passes every space that has monitors to monitorConfigRepository.getAcrossSpaces', async () => { + MockedSyntheticsPrivateLocation.mockImplementation( + () => + ({ + getPolicyId: jest.fn( + (config: { origin?: string; id: string }, locId: string) => `${config.id}-${locId}` + ), + getLegacyPolicyIdsForAllSpaces: jest.fn(() => []), + getAllSpacesWithMonitors: jest.fn().mockResolvedValue(['space-two', 'space-three']), + getPolicyIdFormatInfo: jest.fn(() => ({ + hasNewFormatPolicyId: false, + hasAnyLegacyPolicyId: false, + legacyPolicyIds: [], + })), + } as any) + ); + + const so = createMonitorSO('mon-1'); + const getAcrossSpaces = jest.fn().mockResolvedValue(so); + + const api = buildApi({ + monitorConfigRepository: { getAcrossSpaces }, + spaceId: 'default', + }); + + await api.getHealth(['mon-1']); + + expect(getAcrossSpaces).toHaveBeenCalledTimes(1); + const [calledId, calledNamespaces] = getAcrossSpaces.mock.calls[0]; + expect(calledId).toBe('mon-1'); + expect(new Set(calledNamespaces)).toEqual(new Set(['default', 'space-two', 'space-three'])); + }); + + it('passes additional spaces (excluding the request space) to PackagePolicyService.getByIds', async () => { + MockedSyntheticsPrivateLocation.mockImplementation( + () => + ({ + getPolicyId: jest.fn( + (config: { origin?: string; id: string }, locId: string) => `${config.id}-${locId}` + ), + getLegacyPolicyIdsForAllSpaces: jest.fn(() => []), + // Reproduces the bug scenario in #270477: caller is in `default`, + // monitors live in `space-two`. + getAllSpacesWithMonitors: jest.fn().mockResolvedValue(['space-two']), + getPolicyIdFormatInfo: jest.fn( + ( + config: { id: string }, + locId: string, + existingPolicies: Array<{ id: string }> | undefined + ) => { + const newId = `${config.id}-${locId}`; + const hasNewFormatPolicyId = existingPolicies?.some((p) => p.id === newId) ?? false; + return { + hasNewFormatPolicyId, + hasAnyLegacyPolicyId: false, + legacyPolicyIds: [], + }; + } + ), + } as any) + ); + + const privateLoc = createPrivateLocation('priv-loc-1', 'agent-policy-1'); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc]); + + const so = createMonitorSO('mon-1', { + locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], + }); + + const packagePolicyServiceGetByIds = jest + .fn() + .mockResolvedValue([createPackagePolicy('mon-1-priv-loc-1', ['agent-policy-1'])]); + + const api = buildApi({ + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, + packagePolicyServiceGetByIds, + spaceId: 'default', + }); + + const result = await api.getHealth(['mon-1']); + + expect(packagePolicyServiceGetByIds).toHaveBeenCalledTimes(1); + const [args] = packagePolicyServiceGetByIds.mock.calls[0]; + expect(args.spaceId).toBe('default'); + // The request's space must not be repeated under additionalSpaceIds. + expect(args.additionalSpaceIds).not.toContain('default'); + // Any other space that has monitors must be included so cross-space + // package policies are discoverable. + expect(args.additionalSpaceIds).toEqual(expect.arrayContaining(['space-two'])); + + // The monitor's package policy was created in a different space, but the + // wrapper finds it — so the location is reported as healthy. + expect(result.monitors[0].privateLocations[0].status).toBe( + PrivateLocationHealthStatusValue.Healthy + ); + }); + + it('uses getUnsafeInternalClient with namespace-scoped clients for agent policy lookup', async () => { + const privateLoc = createPrivateLocation('priv-loc-1', 'agent-policy-1'); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc]); + + const so = createMonitorSO('mon-1', { + locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], + }); + + const asScopedToNamespace = jest.fn().mockReturnValue({}); + const getUnsafeInternalClient = jest.fn().mockReturnValue({ asScopedToNamespace }); + + const packagePolicy = createPackagePolicy('mon-1-priv-loc-1', ['agent-policy-1']); + + const api = buildApi({ + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, + packagePolicyServiceGetByIds: jest.fn().mockResolvedValue([packagePolicy]), + getUnsafeInternalClient, + }); + + await api.getHealth(['mon-1']); + + expect(getUnsafeInternalClient).toHaveBeenCalled(); + expect(asScopedToNamespace).toHaveBeenCalledWith(SPACE_ID); + }); + + it('finds agent policies that only exist in a non-default space', async () => { + // Reproduces the case in which an agent policy has space_ids: ['space-two'] so an internal + // client scoped to 'default' cannot find it. + const CUSTOM_SPACE = 'space-two'; + const privateLoc = createPrivateLocation('priv-loc-1', 'space-agent-policy'); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc]); + + const so = createMonitorSO('mon-1', { + locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], + }); + + // Override so getAllSpacesWithMonitors includes CUSTOM_SPACE, causing the health + // API to build spaces = {'default', CUSTOM_SPACE} for agent policy queries. + MockedSyntheticsPrivateLocation.mockImplementationOnce( + () => + ({ + getPolicyId: jest.fn( + (config: { origin?: string; id: string }, locId: string) => `${config.id}-${locId}` + ), + getLegacyPolicyIdsForAllSpaces: jest.fn(() => []), + getAllSpacesWithMonitors: jest.fn().mockResolvedValue([CUSTOM_SPACE]), + getPolicyIdFormatInfo: jest.fn( + ( + config: { id: string }, + locId: string, + existingPolicies: Array<{ id: string }> | undefined + ) => ({ + hasNewFormatPolicyId: + existingPolicies?.some((p) => p.id === `${config.id}-${locId}`) ?? false, + hasAnyLegacyPolicyId: false, + legacyPolicyIds: [], + }) + ), + } as any) + ); + + // Simulate the policy being absent in 'default' but present in CUSTOM_SPACE. + const fleetAgentPolicyGetByIds = jest + .fn() + .mockResolvedValueOnce([]) // default namespace → not found + .mockResolvedValueOnce([{ id: 'space-agent-policy' }]); // CUSTOM_SPACE → found + + const packagePolicy = createPackagePolicy('mon-1-priv-loc-1', ['space-agent-policy']); + + const api = buildApi({ + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, + packagePolicyServiceGetByIds: jest.fn().mockResolvedValue([packagePolicy]), + fleetAgentPolicyGetByIds, + }); + + const result = await api.getHealth(['mon-1']); + + // Both spaces were queried (default + CUSTOM_SPACE) + expect(fleetAgentPolicyGetByIds).toHaveBeenCalledTimes(2); + // Policy found in CUSTOM_SPACE → status must not be MissingAgentPolicy + expect(result.monitors[0].privateLocations[0].status).not.toBe( + PrivateLocationHealthStatusValue.MissingAgentPolicy + ); + }); + + it('fetches private locations across all spaces so monitors in non-default spaces are not reported as missing_location (Bug #4)', async () => { + // Reproduces the case in which a private location is created in a custom space. When _health is + // called from 'default', getPrivateLocations would be scoped to 'default' and could + // not find the location → MissingLocation. For this reason, we use getPrivateLocationsForNamespaces + // with allSpaces so every relevant namespace is searched. + const CUSTOM_SPACE = 'space-two'; + const privateLoc = createPrivateLocation('priv-loc-1', 'agent-policy-1'); + + // Override so getAllSpacesWithMonitors returns CUSTOM_SPACE, causing allSpaces + // to include it. The health API then passes allSpaces to getPrivateLocationsForNamespaces. + MockedSyntheticsPrivateLocation.mockImplementationOnce( + () => + ({ + getPolicyId: jest.fn( + (config: { origin?: string; id: string }, locId: string) => `${config.id}-${locId}` + ), + getLegacyPolicyIdsForAllSpaces: jest.fn(() => []), + getAllSpacesWithMonitors: jest.fn().mockResolvedValue([CUSTOM_SPACE]), + getPolicyIdFormatInfo: jest.fn( + ( + config: { id: string }, + locId: string, + existingPolicies: Array<{ id: string }> | undefined + ) => ({ + hasNewFormatPolicyId: + existingPolicies?.some((p) => p.id === `${config.id}-${locId}`) ?? false, + hasAnyLegacyPolicyId: false, + legacyPolicyIds: [], + }) + ), + } as any) + ); + + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc]); + + const so = createMonitorSO('mon-1', { + locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], + }); + + const packagePolicy = createPackagePolicy('mon-1-priv-loc-1', ['agent-policy-1']); + + const api = buildApi({ + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, + packagePolicyServiceGetByIds: jest.fn().mockResolvedValue([packagePolicy]), + }); + + const result = await api.getHealth(['mon-1']); + + // getPrivateLocationsForNamespaces must be called with allSpaces so cross-space + // locations are discoverable — verify both the default and CUSTOM_SPACE are included. + expect(mockedGetPrivateLocationsForNamespaces).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([SPACE_ID, CUSTOM_SPACE]) + ); + // Private location was found → status must not be MissingLocation + expect(result.monitors[0].privateLocations[0].status).not.toBe( + PrivateLocationHealthStatusValue.MissingLocation + ); + }); + }); + describe('healthy status has no reason field', () => { it('omits reason for healthy locations', async () => { const privateLoc = createPrivateLocation('priv-loc-1', 'agent-policy-1'); @@ -650,15 +916,15 @@ describe('MonitorIntegrationHealthApi', () => { locations: [{ id: 'priv-loc-1', label: 'Private Loc 1', isServiceManaged: false }], }); - mockedGetPrivateLocations.mockResolvedValue([privateLoc]); + mockedGetPrivateLocationsForNamespaces.mockResolvedValue([privateLoc]); const expectedPolicyId = 'mon-1-priv-loc-1'; const packagePolicy = createPackagePolicy(expectedPolicyId, ['agent-policy-1']); - const fleetGetByIDs = jest.fn().mockResolvedValue([packagePolicy]); + const packagePolicyServiceGetByIds = jest.fn().mockResolvedValue([packagePolicy]); const api = buildApi({ - monitorConfigRepository: { get: jest.fn().mockResolvedValue(so) }, - fleetGetByIDs, + monitorConfigRepository: { getAcrossSpaces: jest.fn().mockResolvedValue(so) }, + packagePolicyServiceGetByIds, }); const result = await api.getHealth(['mon-1']); diff --git a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.ts b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.ts index 8c74f26634feb..7d695e7cf9f8e 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/services/monitor_integration_health_api.ts @@ -8,6 +8,7 @@ import type { SavedObject } from '@kbn/core/server'; import type { SavedObjectsClientContract } from '@kbn/core/server'; import type { AgentPolicy, PackagePolicy } from '@kbn/fleet-plugin/common'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import { ConfigKey, PrivateLocationHealthStatusValue, @@ -18,7 +19,8 @@ import { type MonitorsHealthResponse, } from '../../common/runtime_types'; import { SyntheticsPrivateLocation } from '../synthetics_service/private_location/synthetics_private_location'; -import { getPrivateLocations } from '../synthetics_service/get_private_locations'; +import { PackagePolicyService } from '../synthetics_service/private_location/package_policy_service'; +import { getPrivateLocationsForNamespaces } from '../synthetics_service/get_private_locations'; import type { PrivateLocationAttributes } from '../runtime_types/private_locations'; import type { SyntheticsServerSetup } from '../types'; import type { MonitorConfigRepository } from './monitor_config_repository'; @@ -61,30 +63,36 @@ export class MonitorIntegrationHealthApi { } async getHealth(monitorIds: string[]): Promise { - const { foundMonitors, errors } = await this.fetchMonitors(monitorIds); + const privateLocationAPI = new SyntheticsPrivateLocation(this.server); + + // Resolve the union of every space that may host a relevant monitor or + // package policy. Computed up-front so monitor and package-policy lookups + // can both look across spaces — see Kibana issue #270477. + const allSpacesWithMonitors = await privateLocationAPI.getAllSpacesWithMonitors(); + const allSpaces = new Set([this.spaceId, ...allSpacesWithMonitors]); + + const { foundMonitors, errors } = await this.fetchMonitors(monitorIds, allSpaces); if (foundMonitors.length === 0) { return { monitors: [], errors }; } - const allPrivateLocations = await getPrivateLocations(this.savedObjectsClient); + const allPrivateLocations = await getPrivateLocationsForNamespaces(this.savedObjectsClient, [ + ...allSpaces, + ]); const allPrivateLocationsMap = new Map( allPrivateLocations.map((loc) => [loc.id, loc]) ); - const privateLocationAPI = new SyntheticsPrivateLocation(this.server); - - const allSpacesWithMonitors = await privateLocationAPI.getAllSpacesWithMonitors(); - const allSpaces = new Set([this.spaceId, ...allSpacesWithMonitors]); - const referencedAgentPolicyIds = [ ...new Set(allPrivateLocations.map((loc) => loc.agentPolicyId)), ]; const [existingPackagePoliciesMap, existingAgentPoliciesMap] = await Promise.all([ this.getExistingPackagePoliciesMap( - this.getExpectedPackagePolicyIds(foundMonitors, privateLocationAPI, allSpaces) + this.getExpectedPackagePolicyIds(foundMonitors, privateLocationAPI, allSpaces), + allSpaces ), - this.getExistingAgentPoliciesMap(referencedAgentPolicyIds), + this.getExistingAgentPoliciesMap(referencedAgentPolicyIds, allSpaces), ]); const existingPoliciesArray = [...existingPackagePoliciesMap.values()]; @@ -169,12 +177,19 @@ export class MonitorIntegrationHealthApi { return { monitors, errors }; } - private async fetchMonitors(monitorIds: string[]) { + private async fetchMonitors(monitorIds: string[], allSpaces: Set) { const errors: MonitorHealthError[] = []; const foundMonitors: FoundMonitor[] = []; + // Use an internal saved-objects repository (un-scoped) so monitors created + // in any space — not just the request's space — can be resolved. + const internalSoRepository = this.server.coreStart.savedObjects.createInternalRepository(); + const namespaces = [...allSpaces]; + const settledResults = await Promise.allSettled( - monitorIds.map((id) => this.monitorConfigRepository.get(id)) + monitorIds.map((id) => + this.monitorConfigRepository.getAcrossSpaces(id, namespaces, internalSoRepository) + ) ); for (let i = 0; i < monitorIds.length; i++) { @@ -225,32 +240,48 @@ export class MonitorIntegrationHealthApi { return [...ids]; } - private async getExistingPackagePoliciesMap(expectedPackagePolicyIds: string[]) { + private async getExistingPackagePoliciesMap( + expectedPackagePolicyIds: string[], + allSpaces: Set + ) { if (expectedPackagePolicyIds.length === 0) { return new Map(); } - const internalSoClient = this.server.coreStart.savedObjects.createInternalRepository(); - const existingPackagePolicies = await this.server.fleet.packagePolicyService.getByIDs( - internalSoClient, - expectedPackagePolicyIds, - { ignoreMissing: true } - ); + // The Synthetics wrapper builds a namespace-scoped saved-objects client + // per space and de-duplicates the results, so package policies created + // for monitors in any space are visible regardless of the caller's space. + const packagePolicyService = new PackagePolicyService(this.server); + const additionalSpaceIds = [...allSpaces].filter((space) => space !== this.spaceId); + const existingPackagePolicies = await packagePolicyService.getByIds({ + spaceId: this.spaceId, + packagePolicyIds: expectedPackagePolicyIds, + additionalSpaceIds, + }); return new Map((existingPackagePolicies ?? []).map((policy) => [policy.id, policy])); } - private async getExistingAgentPoliciesMap(agentPolicyIds: string[]) { + private async getExistingAgentPoliciesMap(agentPolicyIds: string[], allSpaces: Set) { if (agentPolicyIds.length === 0) { return new Map(); } - const internalSoClient = this.server.coreStart.savedObjects.createInternalRepository(); - const existingAgentPolicies = await this.server.fleet.agentPolicyService.getByIds( - internalSoClient, - agentPolicyIds, - { ignoreMissing: true, withPackagePolicies: false } + // Agent policies can be scoped to a non-default space via space_ids. A plain + // createInternalRepository() defaults to the 'default' namespace and misses + // those policies. Query every relevant space with a namespace-scoped internal + // client (same pattern as PackagePolicyService.getByIds) — see issue #270477. + const spaces = new Set([this.spaceId, DEFAULT_SPACE_ID, ...allSpaces]); + const unsafeClient = this.server.coreStart.savedObjects.getUnsafeInternalClient(); + const results = await Promise.all( + [...spaces].map((space) => + this.server.fleet.agentPolicyService.getByIds( + unsafeClient.asScopedToNamespace(space), + agentPolicyIds, + { ignoreMissing: true, withPackagePolicies: false } + ) + ) ); - return new Map((existingAgentPolicies ?? []).map((policy) => [policy.id, policy])); + return new Map(results.flat().map((policy) => [policy.id, policy])); } private static buildLocationStatus( 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< diff --git a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/private_location/package_policy_service.ts b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/private_location/package_policy_service.ts index 7b25b89cc2291..04230cc75504b 100644 --- a/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/private_location/package_policy_service.ts +++ b/x-pack/solutions/observability/plugins/synthetics/server/synthetics_service/private_location/package_policy_service.ts @@ -53,12 +53,24 @@ export class PackagePolicyService { ); } - async getByIds({ spaceId, packagePolicyIds }: { spaceId: string; packagePolicyIds: string[] }) { - // For legacy reasons, we need to get the package policies from both the space and the default space - const clients = - spaceId === DEFAULT_SPACE_ID - ? [this.getSpaceSoClient(DEFAULT_SPACE_ID)] - : [this.getSpaceSoClient(spaceId), this.getSpaceSoClient(DEFAULT_SPACE_ID)]; + async getByIds({ + spaceId, + packagePolicyIds, + additionalSpaceIds, + }: { + spaceId: string; + packagePolicyIds: string[]; + /** + * Extra spaces to look in alongside `spaceId` (and the default space). + * Use this when callers need a cross-space view — e.g. the monitor health + * API, which reports on monitors that may live in any space. + */ + additionalSpaceIds?: string[]; + }) { + // For legacy reasons, we always include the default space in addition to + // the request's space (older package policies were created there). + const spaces = new Set([spaceId, DEFAULT_SPACE_ID, ...(additionalSpaceIds ?? [])]); + const clients = [...spaces].map((space) => this.getSpaceSoClient(space)); const ids = await Promise.all( clients.map((soClient) =>