diff --git a/.buildkite/ftr_security_serverless_configs.yml b/.buildkite/ftr_security_serverless_configs.yml index 689f53daa9a007..c3532be5a8b476 100644 --- a/.buildkite/ftr_security_serverless_configs.yml +++ b/.buildkite/ftr_security_serverless_configs.yml @@ -95,6 +95,7 @@ disabled: - x-pack/test/security_solution_api_integration/test_suites/edr_workflows/policy_response/trial_license_complete_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/edr_workflows/resolver/trial_license_complete_tier/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/edr_workflows/response_actions/trial_license_complete_tier/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/configs/serverless.config.ts - x-pack/test/security_solution_endpoint/configs/serverless.endpoint.config.ts - x-pack/test/security_solution_endpoint/configs/serverless.integrations.config.ts # serverless config files that run deployment-agnostic tests diff --git a/.buildkite/ftr_security_stateful_configs.yml b/.buildkite/ftr_security_stateful_configs.yml index dbe529596102e1..aa37c6f52fb8c4 100644 --- a/.buildkite/ftr_security_stateful_configs.yml +++ b/.buildkite/ftr_security_stateful_configs.yml @@ -85,6 +85,7 @@ enabled: - x-pack/test/security_solution_api_integration/test_suites/edr_workflows/policy_response/trial_license_complete_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/edr_workflows/resolver/trial_license_complete_tier/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/edr_workflows/response_actions/trial_license_complete_tier/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/configs/ess.config.ts - x-pack/test/security_solution_endpoint/configs/endpoint.config.ts - x-pack/test/security_solution_endpoint/configs/integrations.config.ts - x-pack/test/api_integration/apis/cloud_security_posture/config.ts diff --git a/x-pack/plugins/fleet/common/errors.ts b/x-pack/plugins/fleet/common/errors.ts index c41f6238f86475..9750fdbaf0d3b4 100644 --- a/x-pack/plugins/fleet/common/errors.ts +++ b/x-pack/plugins/fleet/common/errors.ts @@ -8,9 +8,9 @@ import type { FleetErrorType } from './types'; -export class FleetError extends Error { +export class FleetError extends Error { attributes?: { type: FleetErrorType }; - constructor(message?: string, public readonly meta?: unknown) { + constructor(message?: string, public readonly meta?: TMeta) { super(message); this.name = this.constructor.name; // for stack traces } diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index abc36f7df9692b..2f9b42799075f7 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -95,7 +95,7 @@ export class FleetEncryptedSavedObjectEncryptionKeyRequired extends FleetError { export class FleetSetupError extends FleetError {} export class GenerateServiceTokenError extends FleetError {} export class FleetUnauthorizedError extends FleetError {} -export class FleetNotFoundError extends FleetError {} +export class FleetNotFoundError extends FleetError {} export class FleetTooManyRequestsError extends FleetError {} export class OutputUnauthorizedError extends FleetError {} @@ -105,7 +105,7 @@ export class DownloadSourceError extends FleetError {} export class DeleteUnenrolledAgentsPreconfiguredError extends FleetError {} // Not found errors -export class AgentNotFoundError extends FleetNotFoundError {} +export class AgentNotFoundError extends FleetNotFoundError<{ agentId: string }> {} export class AgentPolicyNotFoundError extends FleetNotFoundError {} export class AgentActionNotFoundError extends FleetNotFoundError {} export class DownloadSourceNotFound extends FleetNotFoundError {} @@ -115,7 +115,10 @@ export class SigningServiceNotFoundError extends FleetNotFoundError {} export class InputNotFoundError extends FleetNotFoundError {} export class OutputNotFoundError extends FleetNotFoundError {} export class PackageNotFoundError extends FleetNotFoundError {} -export class PackagePolicyNotFoundError extends FleetNotFoundError {} +export class PackagePolicyNotFoundError extends FleetNotFoundError<{ + /** The package policy ID that was not found */ + packagePolicyId: string; +}> {} export class StreamNotFoundError extends FleetNotFoundError {} export class FleetServerHostUnauthorizedError extends FleetUnauthorizedError {} diff --git a/x-pack/plugins/fleet/server/services/agents/agent_service.mock.ts b/x-pack/plugins/fleet/server/services/agents/agent_service.mock.ts index d6d6922e1bdf9c..316bfaa228b098 100644 --- a/x-pack/plugins/fleet/server/services/agents/agent_service.mock.ts +++ b/x-pack/plugins/fleet/server/services/agents/agent_service.mock.ts @@ -15,10 +15,12 @@ const createClientMock = (): jest.Mocked => ({ getAgentStatusForAgentPolicy: jest.fn(), listAgents: jest.fn(), getLatestAgentAvailableVersion: jest.fn(), + getByIds: jest.fn(async (..._) => []), }); const createServiceMock = (): DeeplyMockedKeys => ({ asInternalUser: createClientMock(), + asInternalScopedUser: jest.fn().mockReturnValue(createClientMock()), asScoped: jest.fn().mockReturnValue(createClientMock()), }); diff --git a/x-pack/plugins/fleet/server/services/agents/agent_service.test.ts b/x-pack/plugins/fleet/server/services/agents/agent_service.test.ts index 2db66f04d8a9b7..95d61060c7e669 100644 --- a/x-pack/plugins/fleet/server/services/agents/agent_service.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/agent_service.test.ts @@ -185,6 +185,27 @@ describe('AgentService', () => { () => new AgentServiceImpl(mockEsClient, mockSoClient).asInternalUser ); }); + + describe('asInternalScopedUser', () => { + it('should throw error if no space id is passed', () => { + const agentService = new AgentServiceImpl( + elasticsearchServiceMock.createElasticsearchClient(), + savedObjectsClientMock.create() + ); + + expect(() => agentService.asInternalScopedUser('')).toThrowError(TypeError); + }); + + { + const mockEsClient = elasticsearchServiceMock.createElasticsearchClient(); + const mockSoClient = savedObjectsClientMock.create(); + expectApisToCallServicesSuccessfully( + mockEsClient, + () => mockSoClient, + () => new AgentServiceImpl(mockEsClient, mockSoClient).asInternalUser + ); + } + }); }); function expectApisToCallServicesSuccessfully( diff --git a/x-pack/plugins/fleet/server/services/agents/agent_service.ts b/x-pack/plugins/fleet/server/services/agents/agent_service.ts index c6eb4e55ed8fee..b6791b061e985e 100644 --- a/x-pack/plugins/fleet/server/services/agents/agent_service.ts +++ b/x-pack/plugins/fleet/server/services/agents/agent_service.ts @@ -27,7 +27,7 @@ import { FleetUnauthorizedError } from '../../errors'; import { getCurrentNamespace } from '../spaces/get_current_namespace'; -import { getAgentsByKuery, getAgentById } from './crud'; +import { getAgentsByKuery, getAgentById, getByIds } from './crud'; import { getAgentStatusById, getAgentStatusForAgentPolicy } from './status'; import { getLatestAvailableAgentVersion } from './versions'; @@ -42,6 +42,11 @@ export interface AgentService { */ asScoped(req: KibanaRequest): AgentClient; + /** + * Scoped services to a given space + */ + asInternalScopedUser(spaceId: string): AgentClient; + /** * Only use for server-side usages (eg. telemetry), should not be used for end users unless an explicit authz check is * done. @@ -60,6 +65,12 @@ export interface AgentClient { */ getAgent(agentId: string): Promise; + /** + * Get multiple agents by id + * @param agentIds + */ + getByIds(agentIds: string[], options?: { ignoreMissing?: boolean }): Promise; + /** * Return the status by the Agent's id */ @@ -128,6 +139,14 @@ class AgentClientImpl implements AgentClient { return getAgentById(this.internalEsClient, this.soClient, agentId); } + public async getByIds( + agentIds: string[], + options?: Partial<{ ignoreMissing: boolean }> + ): Promise { + await this.#runPreflight(); + return getByIds(this.internalEsClient, this.soClient, agentIds, options); + } + public async getAgentStatusById(agentId: string) { await this.#runPreflight(); return getAgentStatusById(this.internalEsClient, this.soClient, agentId); @@ -187,6 +206,21 @@ export class AgentServiceImpl implements AgentService { ); } + public asInternalScopedUser(spaceId: string): AgentClient { + if (!spaceId) { + throw new TypeError(`spaceId argument is required!`); + } + + const soClient = appContextService.getInternalUserSOClientForSpaceId(spaceId); + + return new AgentClientImpl( + this.internalEsClient, + soClient, + undefined, + getCurrentNamespace(soClient) + ); + } + public get asInternalUser() { return new AgentClientImpl(this.internalEsClient, this.soClient); } diff --git a/x-pack/plugins/fleet/server/services/agents/crud.test.ts b/x-pack/plugins/fleet/server/services/agents/crud.test.ts index cd27870e32a3a9..00119e5bc44fb4 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.test.ts @@ -9,6 +9,10 @@ import type { ElasticsearchClient } from '@kbn/core/server'; import { elasticsearchServiceMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; import { toElasticsearchQuery } from '@kbn/es-query'; +import { isSpaceAwarenessEnabled as _isSpaceAwarenessEnabled } from '../spaces/helpers'; + +import { AgentNotFoundError } from '../..'; + import { AGENTS_INDEX } from '../../constants'; import { createAppContextStartContractMock } from '../../mocks'; import type { Agent } from '../../types'; @@ -24,6 +28,7 @@ import { openPointInTime, updateAgent, _joinFilters, + getByIds, } from './crud'; jest.mock('../audit_logging'); @@ -41,6 +46,7 @@ jest.mock('./versions', () => { jest.mock('../spaces/helpers'); const mockedAuditLoggingService = auditLoggingService as jest.Mocked; +const isSpaceAwarenessEnabledMock = _isSpaceAwarenessEnabled as jest.Mock; describe('Agents CRUD test', () => { const soClientMock = savedObjectsClientMock.create(); @@ -63,13 +69,22 @@ describe('Agents CRUD test', () => { appContextService.start(mockContract); }); - function getEsResponse(ids: string[], total: number, status: AgentStatus) { + afterEach(() => { + isSpaceAwarenessEnabledMock.mockReset(); + }); + + function getEsResponse( + ids: string[], + total: number, + status: AgentStatus, + generateSource: (id: string) => Partial = () => ({}) + ) { return { hits: { total, hits: ids.map((id: string) => ({ _id: id, - _source: {}, + _source: generateSource(id), fields: { status: [status], }, @@ -513,4 +528,48 @@ describe('Agents CRUD test', () => { }); }); }); + + describe(`getByIds()`, () => { + let searchResponse: ReturnType; + + beforeEach(() => { + searchResponse = getEsResponse(['1', '2'], 2, 'online', (id) => { + return { id, namespaces: ['foo'] }; + }); + (soClientMock.getCurrentNamespace as jest.Mock).mockReturnValue('foo'); + searchMock.mockImplementation(async () => searchResponse); + }); + + it('should return a list of agents', async () => { + await expect(getByIds(esClientMock, soClientMock, ['1', '2'])).resolves.toEqual([ + expect.objectContaining({ id: '1' }), + expect.objectContaining({ id: '2' }), + ]); + }); + + it('should omit agents that are not found if `ignoreMissing` is true', async () => { + searchResponse.hits.hits = [searchResponse.hits.hits[0]]; + + await expect( + getByIds(esClientMock, soClientMock, ['1', '2'], { ignoreMissing: true }) + ).resolves.toEqual([expect.objectContaining({ id: '1' })]); + }); + + it('should error if agent is not found and `ignoreMissing` is false', async () => { + searchResponse.hits.hits = [searchResponse.hits.hits[0]]; + + await expect(getByIds(esClientMock, soClientMock, ['1', '2'])).rejects.toThrow( + AgentNotFoundError + ); + }); + + it('should error if agent is not part of current space', async () => { + searchResponse.hits.hits[0]._source.namespaces = ['bar']; + isSpaceAwarenessEnabledMock.mockResolvedValue(true); + + await expect(getByIds(esClientMock, soClientMock, ['1', '2'])).rejects.toThrow( + AgentNotFoundError + ); + }); + }); }); diff --git a/x-pack/plugins/fleet/server/services/agents/crud.ts b/x-pack/plugins/fleet/server/services/agents/crud.ts index 541829f32deb2e..f7682ce0e77263 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.ts @@ -415,6 +415,46 @@ export async function getAgentById( return agentHit; } +/** + * Get list of agents by `id`. service method performs space awareness checks. + * @param esClient + * @param soClient + * @param agentIds + * @param options + * + * @throws AgentNotFoundError + */ +export const getByIds = async ( + esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract, + agentIds: string[], + options?: Partial<{ ignoreMissing: boolean }> +): Promise => { + const agentsHits = await getAgentsById(esClient, soClient, agentIds); + const currentNamespace = getCurrentNamespace(soClient); + const response: Agent[] = []; + + for (const agentHit of agentsHits) { + let throwError = false; + + if ('notFound' in agentHit && !options?.ignoreMissing) { + throwError = true; + } else if ((await isAgentInNamespace(agentHit as Agent, currentNamespace)) !== true) { + throwError = true; + } + + if (throwError) { + throw new AgentNotFoundError(`Agent ${agentHit.id} not found`, { agentId: agentHit.id }); + } + + if (!(`notFound` in agentHit)) { + response.push(agentHit); + } + } + + return response; +}; + async function _filterAgents( esClient: ElasticsearchClient, soClient: SavedObjectsClientContract, diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 82b76e553031ee..ff1c4abcef4e99 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -771,7 +771,9 @@ class PackagePolicyClientImpl implements PackagePolicyClient { if (options.ignoreMissing && so.error.statusCode === 404) { return null; } else if (so.error.statusCode === 404) { - throw new PackagePolicyNotFoundError(`Package policy ${so.id} not found`); + throw new PackagePolicyNotFoundError(`Package policy ${so.id} not found`, { + packagePolicyId: so.id, + }); } else { throw new FleetError(so.error.message); } diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_metadata_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_metadata_generator.ts index b14ddc1e8af9e2..34de79d937278c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_metadata_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_metadata_generator.ts @@ -11,9 +11,15 @@ import type { DeepPartial } from 'utility-types'; import { merge } from 'lodash'; import { set } from '@kbn/safer-lodash-set'; import { gte } from 'semver'; +import type { Agent } from '@kbn/fleet-plugin/common'; import type { EndpointCapabilities } from '../service/response_actions/constants'; import { BaseDataGenerator } from './base_data_generator'; -import type { HostMetadataInterface, OSFields, HostInfoInterface } from '../types'; +import type { + HostMetadataInterface, + OSFields, + HostInfoInterface, + UnitedAgentMetadataPersistedData, +} from '../types'; import { EndpointStatus, HostPolicyResponseActionStatus, HostStatus } from '../types'; export interface GetCustomEndpointMetadataGeneratorOptions { @@ -226,6 +232,30 @@ export class EndpointMetadataGenerator extends BaseDataGenerator { return merge(hostInfo, overrides); } + generateUnitedAgentMetadata( + overrides: DeepPartial = {} + ): UnitedAgentMetadataPersistedData { + const endpointMetadata = this.generate(); + + return merge( + { + agent: { + id: endpointMetadata.agent.id, + }, + united: { + endpoint: endpointMetadata, + agent: { + agent: { + id: endpointMetadata.agent.id, + }, + policy_id: this.seededUUIDv4(), + } as Agent, + }, + } as UnitedAgentMetadataPersistedData, + overrides + ); + } + protected randomOsFields(): OSFields { return this.randomChoice([ EndpointMetadataGenerator.windowsOSFields, diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts index a54f16a634d692..8f1f9c7e21c083 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_endpoint_hosts.ts @@ -42,7 +42,12 @@ import { indexFleetEndpointPolicy, } from './index_fleet_endpoint_policy'; import { metadataCurrentIndexPattern } from '../constants'; -import { EndpointDataLoadingError, mergeAndAppendArrays, wrapErrorAndRejectPromise } from './utils'; +import { + EndpointDataLoadingError, + fetchActiveSpaceId, + mergeAndAppendArrays, + wrapErrorAndRejectPromise, +} from './utils'; export interface IndexedHostsResponse extends IndexedFleetAgentResponse, @@ -112,6 +117,7 @@ export const indexEndpointHostDocs = usageTracker.track( const timeBetweenDocs = 6 * 3600 * 1000; // 6 hours between metadata documents const timestamp = new Date().getTime(); const kibanaVersion = await fetchKibanaVersion(kbnClient); + const activeSpaceId = await fetchActiveSpaceId(kbnClient); const response: IndexedHostsResponse = { hosts: [], agents: [], @@ -137,7 +143,7 @@ export const indexEndpointHostDocs = usageTracker.track( for (let j = 0; j < numDocs; j++) { generator.updateHostData(); - generator.updateHostPolicyData(); + generator.updateHostPolicyData({ excludeInitialPolicy: true }); hostMetadata = generator.generateHostMetadata( timestamp - timeBetweenDocs * (numDocs - j - 1), @@ -178,6 +184,7 @@ export const indexEndpointHostDocs = usageTracker.track( const { agents, fleetAgentsIndex, operations } = buildFleetAgentBulkCreateOperations({ endpoints: [hostMetadata], agentPolicyId: policyId, + spaceId: activeSpaceId, kibanaVersion, }); diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts index ad1b1f9bcd315e..46f70345371348 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_agent.ts @@ -10,13 +10,12 @@ import type { DeleteByQueryResponse, IndexRequest, } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { KbnClient } from '@kbn/test'; import type { FleetServerAgent } from '@kbn/fleet-plugin/common'; import { AGENTS_INDEX } from '@kbn/fleet-plugin/common'; import type { BulkRequest } from '@elastic/elasticsearch/lib/api/types'; import type { DeepPartial } from 'utility-types'; import type { ToolingLog } from '@kbn/tooling-log'; -import { usageTracker } from './usage_tracker'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import type { HostMetadata } from '../types'; import { FleetAgentGenerator } from '../data_generators/fleet_agent_generator'; import { createToolingLogger, wrapErrorAndRejectPromise } from './utils'; @@ -28,57 +27,12 @@ export interface IndexedFleetAgentResponse { fleetAgentsIndex: string; } -/** - * Indexes a Fleet Agent - * (NOTE: ensure that fleet is setup first before calling this loading function) - * - * @param esClient - * @param kbnClient - * @param endpointHost - * @param agentPolicyId - * @param [kibanaVersion] - * @param [fleetAgentGenerator] - */ -export const indexFleetAgentForHost = usageTracker.track( - 'indexFleetAgentForHost', - async ( - esClient: Client, - kbnClient: KbnClient, - endpointHost: HostMetadata, - agentPolicyId: string, - kibanaVersion: string = '8.0.0', - fleetAgentGenerator: FleetAgentGenerator = defaultFleetAgentGenerator - ): Promise => { - const agentDoc = generateFleetAgentEsHitForEndpointHost( - endpointHost, - agentPolicyId, - kibanaVersion, - fleetAgentGenerator - ); - - await esClient - .index({ - index: agentDoc._index, - id: agentDoc._id, - body: agentDoc._source, - op_type: 'create', - refresh: 'wait_for', - }) - .catch(wrapErrorAndRejectPromise); - - return { - fleetAgentsIndex: agentDoc._index, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - agents: [agentDoc._source!], - }; - } -); - const generateFleetAgentEsHitForEndpointHost = ( endpointHost: HostMetadata, agentPolicyId: string, kibanaVersion: string = '8.0.0', - fleetAgentGenerator: FleetAgentGenerator = defaultFleetAgentGenerator + fleetAgentGenerator: FleetAgentGenerator = defaultFleetAgentGenerator, + spaceId: string = DEFAULT_SPACE_ID ) => { return fleetAgentGenerator.generateEsHit({ _id: endpointHost.agent.id, @@ -102,6 +56,7 @@ const generateFleetAgentEsHitForEndpointHost = ( }, }, policy_id: agentPolicyId, + namespaces: [spaceId], }, }); }; @@ -110,6 +65,7 @@ interface BuildFleetAgentBulkCreateOperationsOptions { endpoints: HostMetadata[]; agentPolicyId: string; kibanaVersion?: string; + spaceId?: string; fleetAgentGenerator?: FleetAgentGenerator; } @@ -130,6 +86,7 @@ export const buildFleetAgentBulkCreateOperations = ({ agentPolicyId, kibanaVersion = '8.0.0', fleetAgentGenerator = defaultFleetAgentGenerator, + spaceId = DEFAULT_SPACE_ID, }: BuildFleetAgentBulkCreateOperationsOptions): BuildFleetAgentBulkCreateOperationsResponse => { const response: BuildFleetAgentBulkCreateOperationsResponse = { operations: [], @@ -142,7 +99,8 @@ export const buildFleetAgentBulkCreateOperations = ({ endpointHost, agentPolicyId, kibanaVersion, - fleetAgentGenerator + fleetAgentGenerator, + spaceId ); response.operations.push( diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_server.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_server.ts index 72abfe79b0ae03..47e33db0809eea 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_server.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/index_fleet_server.ts @@ -8,11 +8,13 @@ import type { Client } from '@elastic/elasticsearch'; import { kibanaPackageJson } from '@kbn/repo-info'; import type { KbnClient } from '@kbn/test'; +import { v4 as uuidV4 } from 'uuid'; import type { GetPackagePoliciesResponse, AgentPolicy, GetOneAgentPolicyResponse, CreateAgentPolicyResponse, + NewAgentPolicy, } from '@kbn/fleet-plugin/common'; import { AGENT_POLICY_API_ROUTES, @@ -23,11 +25,12 @@ import { packagePolicyRouteService, } from '@kbn/fleet-plugin/common'; import type { ToolingLog } from '@kbn/tooling-log'; -import { fetchFleetLatestAvailableAgentVersion } from '../utils/fetch_fleet_version'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import { indexFleetServerAgent } from './index_fleet_agent'; import { catchAxiosErrorFormatAndThrow } from '../format_axios_error'; import { usageTracker } from './usage_tracker'; -import { createToolingLogger, wrapErrorAndRejectPromise } from './utils'; +import { createToolingLogger, fetchActiveSpaceId, wrapErrorAndRejectPromise } from './utils'; /** * Will ensure that at least one fleet server is present in the `.fleet-agents` index. This will @@ -48,17 +51,18 @@ export const enableFleetServerIfNecessary = usageTracker.track( log: ToolingLog = createToolingLogger(), version: string = kibanaPackageJson.version ) => { - let agentVersion = version; + const activeSpaceId = await fetchActiveSpaceId(kbnClient); + const agentPolicy = await getOrCreateFleetServerAgentPolicy(kbnClient, activeSpaceId, log); - if (isServerless) { - agentVersion = await fetchFleetLatestAvailableAgentVersion(kbnClient); - } - - const agentPolicy = await getOrCreateFleetServerAgentPolicy(kbnClient, log); - - if (!isServerless && !(await hasFleetServerAgent(esClient, agentPolicy.id))) { + if ( + !isServerless && + !(await hasFleetServerAgent(esClient, agentPolicy.id, activeSpaceId, log)) + ) { log.debug(`Indexing a new fleet server agent`); + const lastCheckin = new Date(); + const agentVersion = version; + lastCheckin.setFullYear(lastCheckin.getFullYear() + 1); const indexedAgent = await indexFleetServerAgent(esClient, log, { @@ -66,9 +70,10 @@ export const enableFleetServerIfNecessary = usageTracker.track( agent: { version: agentVersion }, last_checkin_status: 'online', last_checkin: lastCheckin.toISOString(), + namespaces: agentPolicy.space_ids ?? [activeSpaceId], }); - log.verbose(`New fleet server agent indexed:\n${JSON.stringify(indexedAgent)}`); + log.verbose(`New fleet server agent indexed:\n${JSON.stringify(indexedAgent, null, 2)}`); } else { log.debug(`Nothing to do. A Fleet Server agent is already registered with Fleet`); } @@ -77,6 +82,7 @@ export const enableFleetServerIfNecessary = usageTracker.track( const getOrCreateFleetServerAgentPolicy = async ( kbnClient: KbnClient, + spaceId?: string, log: ToolingLog = createToolingLogger() ): Promise => { const packagePolicies = await kbnClient @@ -92,8 +98,10 @@ const getOrCreateFleetServerAgentPolicy = async ( .catch(catchAxiosErrorFormatAndThrow); if (packagePolicies.data.items[0]) { - log.debug(`Found an existing package policy - fetching associated agent policy`); - log.verbose(JSON.stringify(packagePolicies.data.items[0])); + log.debug( + `Found an existing Fleet Server package policy [${packagePolicies.data.items[0].id}] - fetching associated agent policy` + ); + log.verbose(JSON.stringify(packagePolicies.data, null, 2)); return kbnClient .request({ @@ -103,8 +111,9 @@ const getOrCreateFleetServerAgentPolicy = async ( }) .catch(catchAxiosErrorFormatAndThrow) .then((response) => { + log.debug(`Returning existing Fleet Server agent policy [${response.data.item.id}]`); log.verbose( - `Existing agent policy for Fleet Server:\n${JSON.stringify(response.data.item)}` + `Existing agent policy for Fleet Server:\n${JSON.stringify(response.data.item, null, 2)}` ); return response.data.item; @@ -113,26 +122,33 @@ const getOrCreateFleetServerAgentPolicy = async ( log.debug(`Creating a new fleet server agent policy`); + const policy: NewAgentPolicy = { + name: `Fleet Server policy (${Math.random().toString(32).substring(2)})`, + id: uuidV4(), + description: `Created by CLI Tool via: ${__filename}`, + namespace: spaceId ?? DEFAULT_SPACE_ID, + monitoring_enabled: [], + // This will ensure the Fleet Server integration policy + // is also created and added to the agent policy + has_fleet_server: true, + }; + + log.verbose(`New policy:\n${JSON.stringify(policy, null, 2)}`); + // create new Fleet Server agent policy return kbnClient .request({ method: 'POST', path: AGENT_POLICY_API_ROUTES.CREATE_PATTERN, headers: { 'elastic-api-version': '2023-10-31' }, - body: { - name: `Fleet Server policy (${Math.random().toString(32).substring(2)})`, - description: `Created by CLI Tool via: ${__filename}`, - namespace: 'default', - monitoring_enabled: [], - // This will ensure the Fleet Server integration policy - // is also created and added to the agent policy - has_fleet_server: true, - }, + body: policy, }) .then((response) => { log.verbose( `No fleet server agent policy found. Created a new one:\n${JSON.stringify( - response.data.item + response.data.item, + null, + 2 )}` ); @@ -143,8 +159,23 @@ const getOrCreateFleetServerAgentPolicy = async ( const hasFleetServerAgent = async ( esClient: Client, - fleetServerAgentPolicyId: string + fleetServerAgentPolicyId: string, + spaceId?: string, + log: ToolingLog = createToolingLogger() ): Promise => { + const query: QueryDslQueryContainer = { + bool: { + filter: [ + { + term: { + policy_id: fleetServerAgentPolicyId, + }, + }, + ...(spaceId ? [{ term: { namespaces: spaceId } }] : []), + ], + }, + }; + const searchResponse = await esClient .search( { @@ -152,16 +183,19 @@ const hasFleetServerAgent = async ( ignore_unavailable: true, rest_total_hits_as_int: true, size: 1, - _source: false, - query: { - match: { - policy_id: fleetServerAgentPolicyId, - }, - }, + query, }, { ignore: [404] } ) .catch(wrapErrorAndRejectPromise); + log.verbose( + `Search for a fleet server agent with query:\n${JSON.stringify( + query, + null, + 2 + )}\nreturn:\n ${fleetServerAgentPolicyId}]\n${JSON.stringify(searchResponse, null, 2)}` + ); + return Boolean(searchResponse?.hits.total); }; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_loaders/utils.ts b/x-pack/plugins/security_solution/common/endpoint/data_loaders/utils.ts index f695bfc3afa67f..e648bcef98bc8a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_loaders/utils.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_loaders/utils.ts @@ -5,11 +5,14 @@ * 2.0. */ -import { mergeWith } from 'lodash'; +import { memoize, mergeWith } from 'lodash'; import type { ToolingLogTextWriterConfig } from '@kbn/tooling-log'; import { ToolingLog } from '@kbn/tooling-log'; import type { Flags } from '@kbn/dev-cli-runner'; import moment from 'moment/moment'; +import type { Space } from '@kbn/spaces-plugin/common'; +import type { KbnClient } from '@kbn/test'; +import { catchAxiosErrorFormatAndThrow } from '../format_axios_error'; import { EndpointError } from '../errors'; export const RETRYABLE_TRANSIENT_ERRORS: Readonly> = [ @@ -183,3 +186,13 @@ export const getElapsedTime = ( return `${hours}:${minutes}:${seconds}.${milliseconds}`; }; + +export const fetchActiveSpaceId = memoize(async (kbnClient: KbnClient): Promise => { + return kbnClient + .request({ + method: 'GET', + path: `/internal/spaces/_active_space`, + }) + .catch(catchAxiosErrorFormatAndThrow) + .then((response) => response.data.id); +}); diff --git a/x-pack/plugins/security_solution/common/endpoint/format_axios_error.ts b/x-pack/plugins/security_solution/common/endpoint/format_axios_error.ts index fa46f7940c17eb..791dbafe15538d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/format_axios_error.ts +++ b/x-pack/plugins/security_solution/common/endpoint/format_axios_error.ts @@ -6,10 +6,11 @@ */ import { AxiosError } from 'axios'; +import { EndpointError } from './errors'; /* eslint-disable @typescript-eslint/no-explicit-any */ -export class FormattedAxiosError extends Error { +export class FormattedAxiosError extends EndpointError { public readonly request: { method: string; url: string; @@ -28,7 +29,8 @@ export class FormattedAxiosError extends Error { super( `${axiosError.message}${ axiosError?.response?.data ? `: ${JSON.stringify(axiosError?.response?.data)}` : '' - }${url ? `\n(Request: ${method} ${url})` : ''}` + }${url ? `\n(Request: ${method} ${url})` : ''}`, + axiosError ); this.request = { diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 50ae6b40697701..b4ee20f3d5ba27 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -400,10 +400,20 @@ export class EndpointDocGenerator extends BaseDataGenerator { /** * Updates the current Host common record applied Policy to a different one from the list * of random choices and gives it a random policy response status. + * */ - public updateHostPolicyData() { + public updateHostPolicyData({ + excludeInitialPolicy = false, + }: Partial<{ + /** Excludes the initial policy id (non-existent) that endpoint reports when it first is installed */ + excludeInitialPolicy: boolean; + }> = {}) { const newInfo = this.commonInfo; - newInfo.Endpoint.policy.applied = this.randomChoice(APPLIED_POLICIES); + newInfo.Endpoint.policy.applied = this.randomChoice( + excludeInitialPolicy + ? APPLIED_POLICIES.filter(({ id }) => id !== '00000000-0000-0000-0000-000000000000') + : APPLIED_POLICIES + ); newInfo.Endpoint.policy.applied.status = this.randomChoice(POLICY_RESPONSE_STATUSES); this.commonInfo = newInfo; } diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index 45390e1f030601..2eaae4705e04dc 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -538,6 +538,7 @@ export interface HostMetadataInterface { status: EndpointStatus; policy: { applied: { + /** The Endpoint integration policy UUID */ id: string; status: HostPolicyResponseActionStatus; name: string; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/spaces.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/spaces.ts index 8b36aaeff59fde..92affc609bf0c1 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/spaces.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/spaces.ts @@ -27,8 +27,12 @@ export const ensureSpaceIdExists = async ( return; } - const alreadyExists = await kbnClient.spaces - .get(spaceId) + const alreadyExists = await kbnClient + .request({ + method: 'GET', + path: `/api/spaces/space/${spaceId}`, + headers: { 'elastic-api-version': '2023-10-31' }, + }) .then(() => { log.debug(`Space id [${spaceId}] already exists. Nothing to do.`); return true; @@ -45,12 +49,20 @@ export const ensureSpaceIdExists = async ( if (!alreadyExists) { log.info(`Creating space id [${spaceId}]`); - await kbnClient.spaces - .create({ - name: spaceId, - id: spaceId, + await kbnClient + .request({ + method: 'POST', + path: `/api/spaces/space`, + headers: { 'elastic-api-version': '2023-10-31' }, + body: { + name: spaceId, + id: spaceId, + }, }) - .catch(catchAxiosErrorFormatAndThrow); + .catch(catchAxiosErrorFormatAndThrow) + .then((response) => { + log.verbose(`space created:\n${JSON.stringify(response.data, null, 2)}`); + }); } }; diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index b22dd4c7ab8bbf..192fb6059325aa 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -253,20 +253,30 @@ export class EndpointAppContextService { throw new EndpointAppContentServicesNotStartedError(); } + const spaceIdValue = this.experimentalFeatures.endpointManagementSpaceAwarenessEnabled + ? spaceId + : DEFAULT_SPACE_ID; + return new EndpointMetadataService( this.startDependencies.esClient, - this.savedObjects.createInternalScopedSoClient({ readonly: false }), - this.getInternalFleetServices(), + this.savedObjects.createInternalScopedSoClient({ readonly: false, spaceId: spaceIdValue }), + this.getInternalFleetServices(spaceIdValue), this.createLogger('endpointMetadata') ); } - public getInternalFleetServices(): EndpointInternalFleetServicesInterface { + /** + * SpaceId should be defined if wanting go get back an inernal client that is scoped to a given space id + * @param spaceId + */ + public getInternalFleetServices(spaceId?: string): EndpointInternalFleetServicesInterface { if (this.fleetServicesFactory === null) { throw new EndpointAppContentServicesNotStartedError(); } - return this.fleetServicesFactory.asInternalUser(); + return this.fleetServicesFactory.asInternalUser( + this.experimentalFeatures.endpointManagementSpaceAwarenessEnabled ? spaceId : undefined + ); } public getManifestManager(): ManifestManager | undefined { diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks/mocks.ts index cf683283e716da..5ab221b7bfc076 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks/mocks.ts @@ -50,6 +50,7 @@ import { unsecuredActionsClientMock } from '@kbn/actions-plugin/server/unsecured import type { PluginStartContract as ActionPluginStartContract } from '@kbn/actions-plugin/server'; import type { Mutable } from 'utility-types'; import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; +import { createSavedObjectsClientFactoryMock } from '../services/saved_objects/saved_objects_client_factory.mocks'; import { EndpointMetadataService } from '../services/metadata'; import { createEndpointFleetServicesFactoryMock } from '../services/fleet/endpoint_fleet_services_factory.mocks'; import type { ProductFeaturesService } from '../../lib/product_features_service'; @@ -99,7 +100,8 @@ export const createMockEndpointAppContext = ( export const createMockEndpointAppContextService = ( mockManifestManager?: ManifestManager ): jest.Mocked => { - const { esClient, fleetStartServices } = createMockEndpointAppContextServiceStartContract(); + const { esClient, fleetStartServices, savedObjectsServiceStart } = + createMockEndpointAppContextServiceStartContract(); const fleetServices = createEndpointFleetServicesFactoryMock({ fleetDependencies: fleetStartServices, }).service.asInternalUser(); @@ -141,6 +143,7 @@ export const createMockEndpointAppContextService = ( getInternalResponseActionsClient: jest.fn(() => { return responseActionsClientMock.create(); }), + savedObjects: createSavedObjectsClientFactoryMock({ savedObjectsServiceStart }).service, } as unknown as jest.Mocked; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.test.ts index ff34ff6d66d1e6..6ea890cdf716e0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.test.ts @@ -109,7 +109,8 @@ describe('Agent Status API route handler', () => { expect(httpResponseMock.ok).toHaveBeenCalled(); expect(getAgentStatusClientMock).toHaveBeenCalledWith(agentType, { esClient: (await httpHandlerContextMock.core).elasticsearch.client.asInternalUser, - soClient: (await httpHandlerContextMock.core).savedObjects.client, + soClient: + apiTestSetup.endpointAppContextMock.service.savedObjects.createInternalScopedSoClient(), connectorActionsClient: (await httpHandlerContextMock.actions).getActionsClient(), endpointService: apiTestSetup.endpointAppContextMock.service, }); @@ -145,4 +146,38 @@ describe('Agent Status API route handler', () => { }, }); }); + + it('should NOT use space ID in creating SO client when feature is disabled', async () => { + await apiTestSetup + .getRegisteredVersionedRoute('get', AGENT_STATUS_ROUTE, '1') + .routeHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); + + expect(httpResponseMock.ok).toHaveBeenCalled(); + expect( + apiTestSetup.endpointAppContextMock.service.savedObjects.createInternalScopedSoClient + ).toHaveBeenCalledWith({ + spaceId: undefined, + }); + }); + + it('should use a scoped SO client when spaces awareness feature is enabled', async () => { + // @ts-expect-error write to readonly property + apiTestSetup.endpointAppContextMock.service.experimentalFeatures.endpointManagementSpaceAwarenessEnabled = + true; + + ((await httpHandlerContextMock.securitySolution).getSpaceId as jest.Mock).mockReturnValue( + 'foo' + ); + + await apiTestSetup + .getRegisteredVersionedRoute('get', AGENT_STATUS_ROUTE, '1') + .routeHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock); + + expect(httpResponseMock.ok).toHaveBeenCalled(); + expect( + apiTestSetup.endpointAppContextMock.service.savedObjects.createInternalScopedSoClient + ).toHaveBeenCalledWith({ + spaceId: 'foo', + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.ts index 0a9bdbde9876e6..e6ea2f75957858 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/agent/agent_status_handler.ts @@ -78,18 +78,29 @@ export const getAgentStatusRouteHandler = ( ); } - const esClient = (await context.core).elasticsearch.client.asInternalUser; - const soClient = (await context.core).savedObjects.client; - const connectorActionsClient = (await context.actions).getActionsClient(); - const agentStatusClient = getAgentStatusClient(agentType, { - esClient, - soClient, - connectorActionsClient, - endpointService: endpointContext.service, - }); - const data = await agentStatusClient.getAgentStatuses(agentIds); - try { + const [securitySolutionPlugin, corePlugin, actionsPlugin] = await Promise.all([ + context.securitySolution, + context.core, + context.actions, + ]); + const esClient = corePlugin.elasticsearch.client.asInternalUser; + const spaceId = endpointContext.service.experimentalFeatures + .endpointManagementSpaceAwarenessEnabled + ? securitySolutionPlugin.getSpaceId() + : undefined; + const soClient = endpointContext.service.savedObjects.createInternalScopedSoClient({ + spaceId, + }); + const connectorActionsClient = actionsPlugin.getActionsClient(); + const agentStatusClient = getAgentStatusClient(agentType, { + esClient, + soClient, + connectorActionsClient, + endpointService: endpointContext.service, + }); + const data = await agentStatusClient.getAgentStatuses(agentIds); + return response.ok({ body: { data } }); } catch (e) { return errorHandler(logger, response, e); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts index 90eb56fbc83f2b..5e887049a8d13b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -9,6 +9,7 @@ import type { TypeOf } from '@kbn/config-schema'; import type { Logger, RequestHandler } from '@kbn/core/server'; import { FLEET_ENDPOINT_PACKAGE } from '@kbn/fleet-plugin/common'; +import { stringify } from '../../utils/stringify'; import type { MetadataListResponse, EndpointSortableField, @@ -45,7 +46,10 @@ export function getMetadataListRequestHandler( SecuritySolutionRequestHandlerContext > { return async (context, request, response) => { - const endpointMetadataService = endpointAppContext.service.getEndpointMetadataService(); + logger.debug(() => `endpoint host metadata list request:\n${stringify(request.query)}`); + + const spaceId = (await context.securitySolution).getSpaceId(); + const endpointMetadataService = endpointAppContext.service.getEndpointMetadataService(spaceId); try { const { data, total } = await endpointMetadataService.getHostMetadataList(request.query); @@ -77,7 +81,8 @@ export const getMetadataRequestHandler = function ( SecuritySolutionRequestHandlerContext > { return async (context, request, response) => { - const endpointMetadataService = endpointAppContext.service.getEndpointMetadataService(); + const spaceId = (await context.securitySolution).getSpaceId(); + const endpointMetadataService = endpointAppContext.service.getEndpointMetadataService(spaceId); try { return response.ok({ diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index dbf60ef127c225..00054964e4401f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -19,7 +19,11 @@ import { } from '@kbn/core/server/mocks'; import { createAppContextStartContractMock as fleetCreateAppContextStartContractMock } from '@kbn/fleet-plugin/server/mocks'; import { appContextService as fleetAppContextService } from '@kbn/fleet-plugin/server/services'; -import type { HostInfo, MetadataListResponse } from '../../../../common/endpoint/types'; +import type { + HostInfo, + MetadataListResponse, + UnitedAgentMetadataPersistedData, +} from '../../../../common/endpoint/types'; import { HostStatus } from '../../../../common/endpoint/types'; import { registerEndpointRoutes } from '.'; import { @@ -62,6 +66,7 @@ import type { TransformGetTransformStatsResponse } from '@elastic/elasticsearch/ import { getEndpointAuthzInitialStateMock } from '../../../../common/endpoint/service/authz/mocks'; import type { VersionedRouteConfig } from '@kbn/core-http-server'; import type { SecuritySolutionPluginRouterMock } from '../../../mocks'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; describe('test endpoint routes', () => { let routerMock: SecuritySolutionPluginRouterMock; @@ -124,15 +129,9 @@ describe('test endpoint routes', () => { afterEach(() => endpointAppContextService.stop()); describe('GET list endpoints route', () => { - it('should return expected metadata', async () => { - const mockRequest = httpServerMock.createKibanaRequest({ - query: { - page: 0, - pageSize: 10, - hostStatuses: ['updating'], - kuery: 'not host.ip:10.140.73.246', - }, - }); + let searchListResponse: estypes.SearchResponse; + + beforeEach(() => { mockSavedObjectClient.find.mockResolvedValueOnce({ total: 0, saved_objects: [], @@ -144,12 +143,25 @@ describe('test endpoint routes', () => { withoutSpaceExtensions: mockSavedObjectClient, }) ); + searchListResponse = unitedMetadataSearchResponseMock( + new EndpointDocGenerator('seed').generateHostMetadata() + ); mockAgentClient.getAgentStatusById.mockResolvedValue('error'); mockAgentClient.listAgents.mockResolvedValue(noUnenrolledAgent); mockAgentPolicyService.getByIds = jest.fn().mockResolvedValueOnce([]); - const metadata = new EndpointDocGenerator().generateHostMetadata(); + mockScopedClient.asInternalUser.search.mockResponseOnce(searchListResponse); + }); + + it('should return expected metadata', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ + query: { + page: 0, + pageSize: 10, + hostStatuses: ['updating'], + kuery: 'not host.ip:10.140.73.246', + }, + }); const esSearchMock = mockScopedClient.asInternalUser.search; - esSearchMock.mockResponseOnce(unitedMetadataSearchResponseMock(metadata)); ({ routeHandler, routeConfig } = getRegisteredVersionedRouteMock( routerMock, @@ -233,7 +245,9 @@ describe('test endpoint routes', () => { expect(mockResponse.ok).toBeCalled(); const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as MetadataListResponse; expect(endpointResultList.data.length).toEqual(1); - expect(endpointResultList.data[0].metadata).toEqual(metadata); + expect(endpointResultList.data[0].metadata).toEqual( + searchListResponse.hits.hits[0]._source!.united.endpoint + ); expect(endpointResultList.total).toEqual(1); expect(endpointResultList.page).toEqual(0); expect(endpointResultList.pageSize).toEqual(10); @@ -262,6 +276,27 @@ describe('test endpoint routes', () => { expect(mockResponse.forbidden).toBeCalled(); }); + + it('should use space id when retrieving Endpoint Metadata service client', async () => { + const mockRequest = httpServerMock.createKibanaRequest(); + const mockContext = createRouteHandlerContext(mockScopedClient, mockSavedObjectClient); + (mockContext.securitySolution.getSpaceId as jest.Mock).mockReturnValue('foo'); + + ({ routeHandler, routeConfig } = getRegisteredVersionedRouteMock( + routerMock, + 'get', + HOST_METADATA_LIST_ROUTE, + '2023-10-31' + )); + const getEndpointMetadataServiceSpy = jest.spyOn( + endpointAppContextService, + 'getEndpointMetadataService' + ); + + await routeHandler(mockContext, mockRequest, mockResponse); + + expect(getEndpointMetadataServiceSpy).toHaveBeenCalledWith('foo'); + }); }); describe('GET endpoint details route', () => { @@ -497,6 +532,34 @@ describe('test endpoint routes', () => { expect(mockResponse.forbidden).toBeCalled(); }); + + it('should retrieve Endpoint Metadata Service client using the space id', async () => { + const response = legacyMetadataSearchResponseMock( + new EndpointDocGenerator().generateHostMetadata() + ); + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: response.hits.hits[0]._id }, + }); + const esSearchMock = mockScopedClient.asInternalUser.search; + mockAgentClient.getAgent.mockResolvedValue(agentGenerator.generate({ status: 'online' })); + esSearchMock.mockResponseOnce(response); + const getEndpointMetadataServiceSpy = jest.spyOn( + endpointAppContextService, + 'getEndpointMetadataService' + ); + ({ routeConfig, routeHandler } = getRegisteredVersionedRouteMock( + routerMock, + 'get', + HOST_METADATA_GET_ROUTE, + '2023-10-31' + )); + const mockContext = createRouteHandlerContext(mockScopedClient, mockSavedObjectClient); + (mockContext.securitySolution.getSpaceId as jest.Mock).mockReturnValue('foo'); + + await routeHandler(mockContext, mockRequest, mockResponse); + + expect(getEndpointMetadataServiceSpy).toHaveBeenCalledWith('foo'); + }); }); describe('GET metadata transform stats route', () => { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts index 2adbb0638912a6..ab7ca52052b3db 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts @@ -27,6 +27,8 @@ import type { Agent } from '@kbn/fleet-plugin/common/types/models'; import type { AgentClient } from '@kbn/fleet-plugin/server/services'; import { get } from 'lodash'; import type { ScopedClusterClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import type { TypeOf } from '@kbn/config-schema'; +import type { GetPolicyResponseSchema } from '../../../../common/api/endpoint'; describe('test policy response handler', () => { let endpointAppContextService: EndpointAppContextService; @@ -48,11 +50,15 @@ describe('test policy response handler', () => { it('should return the latest policy response for a host', async () => { const response = createSearchResponse(new EndpointDocGenerator().generatePolicyResponse()); - const hostPolicyResponseHandler = getHostPolicyResponseHandler(); + const hostPolicyResponseHandler = getHostPolicyResponseHandler(endpointAppContextService); mockScopedClient.asInternalUser.search.mockResponseOnce(response); - const mockRequest = httpServerMock.createKibanaRequest({ - params: { agentId: 'id' }, + const mockRequest = httpServerMock.createKibanaRequest< + never, + TypeOf, + never + >({ + query: { agentId: 'id' }, }); await hostPolicyResponseHandler( @@ -71,12 +77,16 @@ describe('test policy response handler', () => { }); it('should return not found when there is no response policy for host', async () => { - const hostPolicyResponseHandler = getHostPolicyResponseHandler(); + const hostPolicyResponseHandler = getHostPolicyResponseHandler(endpointAppContextService); mockScopedClient.asInternalUser.search.mockResponseOnce(createSearchResponse()); - const mockRequest = httpServerMock.createKibanaRequest({ - params: { agentId: 'id' }, + const mockRequest = httpServerMock.createKibanaRequest< + never, + TypeOf, + never + >({ + query: { agentId: 'foo' }, }); await hostPolicyResponseHandler( @@ -87,9 +97,34 @@ describe('test policy response handler', () => { mockResponse ); - expect(mockResponse.notFound).toBeCalled(); - const message = mockResponse.notFound.mock.calls[0][0]?.body; - expect(message).toEqual('Policy Response Not Found'); + expect(mockResponse.notFound).toHaveBeenCalledWith({ + body: expect.objectContaining({ + message: 'Policy response for endpoint id [foo] not found', + }), + }); + }); + + it('should retrieve internal fleet services using space id', async () => { + mockScopedClient.asInternalUser.search.mockResponseOnce(createSearchResponse()); + const getInternalFleetServicesSpy = jest.spyOn( + endpointAppContextService, + 'getInternalFleetServices' + ); + const hostPolicyResponseHandler = getHostPolicyResponseHandler(endpointAppContextService); + const mockRequest = httpServerMock.createKibanaRequest< + never, + TypeOf, + never + >({ + query: { agentId: 'foo' }, + }); + const mockContext = requestContextMock.convertContext( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient) + ); + ((await mockContext.securitySolution).getSpaceId as jest.Mock).mockReturnValue('foo'); + await hostPolicyResponseHandler(mockContext, mockRequest, mockResponse); + + expect(getInternalFleetServicesSpy).toHaveBeenCalledWith('foo'); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts index af8a38bcd0de26..7367201f5883a2 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.ts @@ -7,7 +7,9 @@ import type { RequestHandler } from '@kbn/core/server'; import type { TypeOf } from '@kbn/config-schema'; -import { policyIndexPattern } from '../../../../common/endpoint/constants'; +import type { SecuritySolutionRequestHandlerContext } from '../../../types'; +import type { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { errorHandler } from '../error_handler'; import type { GetPolicyResponseSchema, GetAgentPolicySummaryRequestSchema, @@ -15,21 +17,37 @@ import type { import type { EndpointAppContext } from '../../types'; import { getAgentPolicySummary, getPolicyResponseByAgentId } from './service'; import type { GetAgentSummaryResponse } from '../../../../common/endpoint/types'; +import { NotFoundError } from '../../errors'; -export const getHostPolicyResponseHandler = function (): RequestHandler< - undefined, +export const getHostPolicyResponseHandler = function ( + endpointAppContextServices: EndpointAppContextService +): RequestHandler< + never, TypeOf, - undefined + never, + SecuritySolutionRequestHandlerContext > { + const logger = endpointAppContextServices.createLogger('endpointPolicyResponse'); + return async (context, request, response) => { - const client = (await context.core).elasticsearch.client; - const doc = await getPolicyResponseByAgentId(policyIndexPattern, request.query.agentId, client); + const spaceId = (await context.securitySolution).getSpaceId(); + const esClient = (await context.core).elasticsearch.client.asInternalUser; + const fleetServices = endpointAppContextServices.getInternalFleetServices(spaceId); - if (doc) { - return response.ok({ body: doc }); - } + try { + const agentId = request.query.agentId; + const doc = await getPolicyResponseByAgentId(agentId, esClient, fleetServices); + + if (doc) { + return response.ok({ body: doc }); + } - return response.notFound({ body: 'Policy Response Not Found' }); + logger.debug(`Agent id [${agentId}] has no policy response documents indexed yet`); + + throw new NotFoundError(`Policy response for endpoint id [${agentId}] not found`); + } catch (err) { + return errorHandler(logger, response, err); + } }; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/index.ts index 18bf0bfcdd0984..f437ed332828cf 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/index.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type { IRouter } from '@kbn/core/server'; import { GetPolicyResponseSchema, GetAgentPolicySummaryRequestSchema, @@ -17,10 +16,14 @@ import { BASE_POLICY_RESPONSE_ROUTE, } from '../../../../common/endpoint/constants'; import { withEndpointAuthz } from '../with_endpoint_authz'; +import type { SecuritySolutionPluginRouter } from '../../../types'; export const INITIAL_POLICY_ID = '00000000-0000-0000-0000-000000000000'; -export function registerPolicyRoutes(router: IRouter, endpointAppContext: EndpointAppContext) { +export function registerPolicyRoutes( + router: SecuritySolutionPluginRouter, + endpointAppContext: EndpointAppContext +) { const logger = endpointAppContext.logFactory.get('endpointPolicy'); router.versioned @@ -39,7 +42,7 @@ export function registerPolicyRoutes(router: IRouter, endpointAppContext: Endpoi withEndpointAuthz( { any: ['canReadSecuritySolution', 'canAccessFleet'] }, logger, - getHostPolicyResponseHandler() + getHostPolicyResponseHandler(endpointAppContext.service) ) ); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.test.ts index 24d26e715fb74d..f95e4c4c2d1d67 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.test.ts @@ -6,36 +6,91 @@ */ import { GetPolicyResponseSchema } from '../../../../common/api/endpoint'; -import { getESQueryPolicyResponseByAgentID } from './service'; +import { getESQueryPolicyResponseByAgentID, getPolicyResponseByAgentId } from './service'; +import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; +import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import type { EndpointInternalFleetServicesInterfaceMocked } from '../../services/fleet/endpoint_fleet_services_factory.mocks'; +import { createEndpointFleetServicesFactoryMock } from '../../services/fleet/endpoint_fleet_services_factory.mocks'; +import { applyEsClientSearchMock } from '../../mocks/utils.mock'; +import { policyIndexPattern } from '../../../../common/endpoint/constants'; +import { EndpointPolicyResponseGenerator } from '../../../../common/endpoint/data_generators/endpoint_policy_response_generator'; -describe('test policy handlers schema', () => { - it('validate that get policy response query schema', async () => { - expect( - GetPolicyResponseSchema.query.validate({ - agentId: 'id', - }) - ).toBeTruthy(); +describe('Policy Response Services', () => { + describe('test policy handlers schema', () => { + it('validate that get policy response query schema', async () => { + expect( + GetPolicyResponseSchema.query.validate({ + agentId: 'id', + }) + ).toBeTruthy(); - expect(() => GetPolicyResponseSchema.query.validate({})).toThrowError(); + expect(() => GetPolicyResponseSchema.query.validate({})).toThrowError(); + }); }); -}); -describe('test policy query', () => { - it('queries for the correct host', async () => { - const agentId = 'f757d3c0-e874-11ea-9ad9-015510b487f4'; - const query = getESQueryPolicyResponseByAgentID(agentId, 'anyindex'); - expect(query.body?.query?.bool?.filter).toEqual({ term: { 'agent.id': agentId } }); + describe('test policy query', () => { + it('queries for the correct host', async () => { + const agentId = 'f757d3c0-e874-11ea-9ad9-015510b487f4'; + const query = getESQueryPolicyResponseByAgentID(agentId, 'anyindex'); + expect(query.body?.query?.bool?.filter).toEqual({ term: { 'agent.id': agentId } }); + }); + + it('filters out initial policy by ID', async () => { + const query = getESQueryPolicyResponseByAgentID( + 'f757d3c0-e874-11ea-9ad9-015510b487f4', + 'anyindex' + ); + expect(query.body?.query?.bool?.must_not).toEqual({ + term: { + 'Endpoint.policy.applied.id': '00000000-0000-0000-0000-000000000000', + }, + }); + }); }); - it('filters out initial policy by ID', async () => { - const query = getESQueryPolicyResponseByAgentID( - 'f757d3c0-e874-11ea-9ad9-015510b487f4', - 'anyindex' - ); - expect(query.body?.query?.bool?.must_not).toEqual({ - term: { - 'Endpoint.policy.applied.id': '00000000-0000-0000-0000-000000000000', - }, + describe('getPolicyResponseByAgentId()', () => { + let esClientMock: ElasticsearchClientMock; + let fleetServicesMock: EndpointInternalFleetServicesInterfaceMocked; + + beforeEach(() => { + esClientMock = elasticsearchServiceMock.createElasticsearchClient(); + fleetServicesMock = createEndpointFleetServicesFactoryMock().service.asInternalUser(); + + applyEsClientSearchMock({ + esClientMock, + index: policyIndexPattern, + response: EndpointPolicyResponseGenerator.toEsSearchResponse([ + EndpointPolicyResponseGenerator.toEsSearchHit( + new EndpointPolicyResponseGenerator('seed').generate({ agent: { id: '1-2-3' } }) + ), + ]), + }); + }); + + it('should search using the agent id provided on input', async () => { + await getPolicyResponseByAgentId('1-2-3', esClientMock, fleetServicesMock); + + expect(esClientMock.search).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + query: expect.objectContaining({ + bool: expect.objectContaining({ + filter: expect.objectContaining({ + term: expect.objectContaining({ + 'agent.id': '1-2-3', + }), + }), + }), + }), + }), + }) + ); + }); + + it('should validate that agent id is in current space', async () => { + await getPolicyResponseByAgentId('1-2-3', esClientMock, fleetServicesMock); + + expect(fleetServicesMock.ensureInCurrentSpace).toHaveBeenCalledWith({ agentIds: ['1-2-3'] }); }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.ts index fda09585e35d91..c5f398ee2d1f3b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/service.ts @@ -5,12 +5,15 @@ * 2.0. */ -import type { IScopedClusterClient, KibanaRequest } from '@kbn/core/server'; +import type { ElasticsearchClient, KibanaRequest } from '@kbn/core/server'; import type { Agent } from '@kbn/fleet-plugin/common/types/models'; import type { ISearchRequestParams } from '@kbn/search-types'; -import type { GetHostPolicyResponse, HostPolicyResponse } from '../../../../common/endpoint/types'; -import { INITIAL_POLICY_ID } from '.'; +import type { EndpointFleetServicesInterface } from '../../services/fleet'; +import { policyIndexPattern } from '../../../../common/endpoint/constants'; +import { catchAndWrapError } from '../../utils'; import type { EndpointAppContext } from '../../types'; +import { INITIAL_POLICY_ID } from '.'; +import type { GetHostPolicyResponse, HostPolicyResponse } from '../../../../common/endpoint/types'; export const getESQueryPolicyResponseByAgentID = ( agentID: string, @@ -46,14 +49,17 @@ export const getESQueryPolicyResponseByAgentID = ( }; export async function getPolicyResponseByAgentId( - index: string, agentID: string, - dataClient: IScopedClusterClient + esClient: ElasticsearchClient, + fleetServices: EndpointFleetServicesInterface ): Promise { - const query = getESQueryPolicyResponseByAgentID(agentID, index); - const response = await dataClient.asInternalUser.search(query); + const query = getESQueryPolicyResponseByAgentID(agentID, policyIndexPattern); + const response = await esClient.search(query).catch(catchAndWrapError); if (response.hits.hits.length > 0 && response.hits.hits[0]._source != null) { + // Ensure agent is in the current space id. Call to fleet will Error if agent is not in current space + await fleetServices.ensureInCurrentSpace({ agentIds: [agentID] }); + return { policy_response: response.hits.hits[0]._source, }; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/agent/clients/endpoint/endpoint_agent_status_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/agent/clients/endpoint/endpoint_agent_status_client.test.ts new file mode 100644 index 00000000000000..821f7e6a43d429 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/agent/clients/endpoint/endpoint_agent_status_client.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AgentStatusClientOptions } from '../lib/base_agent_status_client'; +import type { ApplyMetadataMocksResponse } from '../../../metadata/mocks'; +import { createEndpointMetadataServiceTestContextMock } from '../../../metadata/mocks'; +import { EndpointAgentStatusClient } from '../../..'; +import { getPendingActionsSummary as _getPendingActionsSummary } from '../../../actions/pending_actions_summary'; +import { createMockEndpointAppContextService } from '../../../../mocks'; +import { appContextService as fleetAppContextService } from '@kbn/fleet-plugin/server/services'; +import { createAppContextStartContractMock as fleetCreateAppContextStartContractMock } from '@kbn/fleet-plugin/server/mocks'; + +jest.mock('../../../actions/pending_actions_summary', () => { + const realModule = jest.requireActual('../../../actions/pending_actions_summary'); + return { + ...realModule, + getPendingActionsSummary: jest.fn(realModule.getPendingActionsSummary), + }; +}); + +const getPendingActionsSummaryMock = _getPendingActionsSummary as jest.Mock; + +describe('EndpointAgentStatusClient', () => { + let constructorOptions: AgentStatusClientOptions; + let statusClient: EndpointAgentStatusClient; + let dataMocks: ApplyMetadataMocksResponse; + + beforeEach(() => { + const endpointAppContextServiceMock = createMockEndpointAppContextService(); + const metadataMocks = createEndpointMetadataServiceTestContextMock(); + const soClient = endpointAppContextServiceMock.savedObjects.createInternalScopedSoClient({ + readonly: false, + }); + + dataMocks = metadataMocks.applyMetadataMocks( + metadataMocks.esClient, + metadataMocks.fleetServices + ); + (soClient.getCurrentNamespace as jest.Mock).mockReturnValue('foo'); + (endpointAppContextServiceMock.getEndpointMetadataService as jest.Mock).mockReturnValue( + metadataMocks.endpointMetadataService + ); + constructorOptions = { + endpointService: endpointAppContextServiceMock, + esClient: metadataMocks.esClient, + soClient, + }; + statusClient = new EndpointAgentStatusClient(constructorOptions); + + // FIXME:PT need to remove the need for this mock. It appears in several test files on our side. + // Its currently needed due to the direct use of Fleet's `buildAgentStatusRuntimeField()` in + // `x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts:239` + (soClient.find as jest.Mock).mockResolvedValue({ saved_objects: [] }); + fleetAppContextService.start( + fleetCreateAppContextStartContractMock({}, false, { + withoutSpaceExtensions: soClient, + }) + ); + }); + + it('should retrieve endpoint metadata service using space id', async () => { + await statusClient.getAgentStatuses(['one', 'two']); + + expect(constructorOptions.endpointService.getEndpointMetadataService).toHaveBeenCalledWith( + 'foo' + ); + }); + + it('should retrieve metadata and pending actions for the agents passed on input', async () => { + const metadataClient = constructorOptions.endpointService.getEndpointMetadataService(); + const agentIds = ['one', 'two']; + jest.spyOn(metadataClient, 'getHostMetadataList'); + await statusClient.getAgentStatuses(agentIds); + + expect(metadataClient.getHostMetadataList).toHaveBeenCalledWith( + expect.objectContaining({ kuery: 'agent.id: one or agent.id: two' }) + ); + expect(getPendingActionsSummaryMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + agentIds + ); + }); + + it('should return expected data structure', async () => { + await expect( + statusClient.getAgentStatuses([dataMocks.unitedMetadata.agent.id]) + ).resolves.toEqual({ + '0dc3661d-6e67-46b0-af39-6f12b025fcb0': { + agentId: '0dc3661d-6e67-46b0-af39-6f12b025fcb0', + agentType: 'endpoint', + found: true, + isolated: false, + lastSeen: expect.any(String), + pendingActions: {}, + status: 'unhealthy', + }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/agent/clients/endpoint/endpoint_agent_status_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/agent/clients/endpoint/endpoint_agent_status_client.ts index ed8e4f45a13676..eb059738a02905 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/agent/clients/endpoint/endpoint_agent_status_client.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/agent/clients/endpoint/endpoint_agent_status_client.ts @@ -16,8 +16,11 @@ export class EndpointAgentStatusClient extends AgentStatusClient { protected readonly agentType: ResponseActionAgentType = 'endpoint'; async getAgentStatuses(agentIds: string[]): Promise { - const metadataService = this.options.endpointService.getEndpointMetadataService(); + const soClient = this.options.soClient; const esClient = this.options.esClient; + const metadataService = this.options.endpointService.getEndpointMetadataService( + soClient.getCurrentNamespace() + ); try { const agentIdsKql = agentIds.map((agentId) => `agent.id: ${agentId}`).join(' or '); @@ -53,7 +56,9 @@ export class EndpointAgentStatusClient extends AgentStatusClient { }, {}); } catch (err) { const error = new AgentStatusClientError( - `Failed to fetch endpoint agent statuses for agentIds: [${agentIds}], failed with: ${err.message}`, + `Failed to fetch endpoint agent statuses for agentIds: [${agentIds.join()}], failed with: ${ + err.message + }`, 500, err ); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.mocks.ts b/x-pack/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.mocks.ts index 1e37993c955010..91119ea3df5fb1 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.mocks.ts @@ -9,15 +9,19 @@ import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; import type { FleetStartContract } from '@kbn/fleet-plugin/server'; import { createFleetStartContractMock } from '@kbn/fleet-plugin/server/mocks'; import type { SavedObjectsClientFactory } from '../saved_objects'; -import type { EndpointFleetServicesFactoryInterface } from './endpoint_fleet_services_factory'; +import type { + EndpointFleetServicesFactoryInterface, + EndpointInternalFleetServicesInterface, +} from './endpoint_fleet_services_factory'; import { EndpointFleetServicesFactory } from './endpoint_fleet_services_factory'; import { createSavedObjectsClientFactoryMock } from '../saved_objects/saved_objects_client_factory.mocks'; -interface EndpointFleetServicesFactoryInterfaceMocked +export type EndpointInternalFleetServicesInterfaceMocked = + DeeplyMockedKeys; + +export interface EndpointFleetServicesFactoryInterfaceMocked extends EndpointFleetServicesFactoryInterface { - asInternalUser: () => DeeplyMockedKeys< - ReturnType - >; + asInternalUser: () => EndpointInternalFleetServicesInterfaceMocked; } interface CreateEndpointFleetServicesFactoryMockOptions { @@ -36,11 +40,19 @@ export const createEndpointFleetServicesFactoryMock = ( savedObjects = createSavedObjectsClientFactoryMock().service, } = dependencies; + const serviceFactoryMock = new EndpointFleetServicesFactory( + fleetDependencies, + savedObjects + ) as unknown as EndpointFleetServicesFactoryInterfaceMocked; + + const fleetInternalServicesMocked = serviceFactoryMock.asInternalUser(); + jest.spyOn(fleetInternalServicesMocked, 'ensureInCurrentSpace'); + + const asInternalUserSpy = jest.spyOn(serviceFactoryMock, 'asInternalUser'); + asInternalUserSpy.mockReturnValue(fleetInternalServicesMocked); + return { - service: new EndpointFleetServicesFactory( - fleetDependencies, - savedObjects - ) as unknown as EndpointFleetServicesFactoryInterfaceMocked, + service: serviceFactoryMock, dependencies: { fleetDependencies, savedObjects }, }; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.ts b/x-pack/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.ts index 27df7645b7fc26..50e20062722182 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/fleet/endpoint_fleet_services_factory.ts @@ -12,7 +12,14 @@ import type { PackagePolicyClient, PackageClient, } from '@kbn/fleet-plugin/server'; +import { AgentNotFoundError } from '@kbn/fleet-plugin/server'; import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '@kbn/fleet-plugin/common'; +import type { SavedObjectsClientContract } from '@kbn/core/server'; +import { + AgentPolicyNotFoundError, + PackagePolicyNotFoundError, +} from '@kbn/fleet-plugin/server/errors'; +import { NotFoundError } from '../../errors'; import type { SavedObjectsClientFactory } from '../saved_objects'; /** @@ -25,14 +32,26 @@ export interface EndpointFleetServicesInterface { packagePolicy: PackagePolicyClient; /** The `kuery` that can be used to filter for Endpoint integration policies */ endpointPolicyKuery: string; + + /** + * Will check the data provided to ensure it is visible for the current space. Supports + * several types of data (ex. integration policies, agent policies, etc) + */ + ensureInCurrentSpace(options: EnsureInCurrentSpaceOptions): Promise; } +type EnsureInCurrentSpaceOptions = Partial<{ + agentIds: string[]; + agentPolicyIds: string[]; + integrationPolicyIds: string[]; +}>; + export interface EndpointInternalFleetServicesInterface extends EndpointFleetServicesInterface { savedObjects: SavedObjectsClientFactory; } export interface EndpointFleetServicesFactoryInterface { - asInternalUser(): EndpointInternalFleetServicesInterface; + asInternalUser(spaceId?: string): EndpointInternalFleetServicesInterface; } /** @@ -44,24 +63,66 @@ export class EndpointFleetServicesFactory implements EndpointFleetServicesFactor private readonly savedObjects: SavedObjectsClientFactory ) {} - asInternalUser(): EndpointInternalFleetServicesInterface { + asInternalUser(spaceId?: string): EndpointInternalFleetServicesInterface { const { agentPolicyService: agentPolicy, packagePolicyService: packagePolicy, agentService, packageService, } = this.fleetDependencies; + const agent = spaceId + ? agentService.asInternalScopedUser(spaceId) + : agentService.asInternalUser; + + // Lazily Initialized at the time it is needed + let soClient: SavedObjectsClientContract; + + const ensureInCurrentSpace: EndpointFleetServicesInterface['ensureInCurrentSpace'] = async ({ + integrationPolicyIds = [], + agentPolicyIds = [], + agentIds = [], + }): Promise => { + if (!soClient) { + soClient = this.savedObjects.createInternalScopedSoClient({ spaceId }); + } + + const handlePromiseErrors = (err: Error): never => { + // We wrap the error with our own Error class so that the API can property return a 404 + if ( + err instanceof AgentNotFoundError || + err instanceof AgentPolicyNotFoundError || + err instanceof PackagePolicyNotFoundError + ) { + throw new NotFoundError(err.message, err); + } + + throw err; + }; + + await Promise.all([ + agentIds.length ? agent.getByIds(agentIds).catch(handlePromiseErrors) : null, + + agentPolicyIds.length + ? agentPolicy.getByIds(soClient, agentPolicyIds).catch(handlePromiseErrors) + : null, + + integrationPolicyIds.length + ? packagePolicy.getByIDs(soClient, integrationPolicyIds).catch(handlePromiseErrors) + : null, + ]); + }; return { - agent: agentService.asInternalUser, + agent, agentPolicy, packages: packageService.asInternalUser, packagePolicy, - endpointPolicyKuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: "endpoint"`, - savedObjects: this.savedObjects, + + endpointPolicyKuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: "endpoint"`, + ensureInCurrentSpace, }; } } diff --git a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.test.ts index 2fe173ff55eb5c..8a4022771d69c0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.test.ts @@ -76,6 +76,18 @@ describe('EndpointMetadataService', () => { const response = await metadataService.findHostMetadataForFleetAgents(fleetAgentIds); expect(response).toEqual([endpointMetadataDoc]); }); + + it('should validate agent is visible in current space', async () => { + const data = testMockedContext.applyMetadataMocks( + testMockedContext.esClient, + testMockedContext.fleetServices + ); + await metadataService.findHostMetadataForFleetAgents([data.unitedMetadata.agent.id]); + + expect(testMockedContext.fleetServices.ensureInCurrentSpace).toHaveBeenCalledWith({ + agentIds: [data.unitedMetadata.agent.id], + }); + }); }); describe('#getHostMetadataList', () => { @@ -219,4 +231,32 @@ describe('EndpointMetadataService', () => { expect(endpointPackagePolicies).toEqual(expected); }); }); + + describe('#getHostMetadata()', () => { + it('should validate agent is visible in current space', async () => { + const data = testMockedContext.applyMetadataMocks( + testMockedContext.esClient, + testMockedContext.fleetServices + ); + await metadataService.getHostMetadata(data.unitedMetadata.agent.id); + + expect(testMockedContext.fleetServices.ensureInCurrentSpace).toHaveBeenCalledWith({ + agentIds: [data.unitedMetadata.agent.id], + }); + }); + }); + + describe('#getMetadataForEndpoints()', () => { + it('should validate agent is visible in current space', async () => { + const data = testMockedContext.applyMetadataMocks( + testMockedContext.esClient, + testMockedContext.fleetServices + ); + await metadataService.getMetadataForEndpoints([data.unitedMetadata.agent.id]); + + expect(testMockedContext.fleetServices.ensureInCurrentSpace).toHaveBeenCalledWith({ + agentIds: [data.unitedMetadata.agent.id], + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts index 3f3d756c70aab3..1ce77561b79049 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts @@ -65,6 +65,28 @@ export class EndpointMetadataService { private readonly logger?: Logger ) {} + /** + * Validates that the data retrieved is valid for the current user space. We do this + * by just querying fleet to ensure the policy is visible in the current space + * (the space is determined from the `soClient`) + * + * @protected + */ + protected async ensureDataValidForSpace(data: SearchResponse): Promise { + const agentIds = (data?.hits?.hits || []) + .map((hit) => hit._source?.agent.id ?? '') + .filter((id) => !!id); + + if (agentIds.length > 0) { + this.logger?.debug( + `Checking to see if the following agent ids are valid for current space:\n${agentIds.join( + '\n' + )}` + ); + await this.fleetServices.ensureInCurrentSpace({ agentIds }); + } + } + /** * Retrieve a single endpoint host metadata. Note that the return endpoint document, if found, * could be associated with a Fleet Agent that is no longer active. If wanting to ensure the @@ -77,6 +99,9 @@ export class EndpointMetadataService { async getHostMetadata(endpointId: string): Promise { const query = getESQueryHostMetadataByID(endpointId); const queryResult = await this.esClient.search(query).catch(catchAndWrapError); + + await this.ensureDataValidForSpace(queryResult); + const endpointMetadata = queryResponseToHostResult(queryResult).result; if (endpointMetadata) { @@ -100,6 +125,8 @@ export class EndpointMetadataService { .search(query, { ignore: [404] }) .catch(catchAndWrapError); + await this.ensureDataValidForSpace(searchResult); + return queryResponseToHostListResult(searchResult).resultList; } @@ -335,6 +362,9 @@ export class EndpointMetadataService { unitedMetadataQueryResponse = await this.esClient.search( unitedIndexQuery ); + // FYI: we don't need to run the ES search response through `this.ensureDataValidForSpace()` because + // the query (`unitedIndexQuery`) above already included a filter with all of the valid policy ids + // for the current space - thus data is already coped to the space } catch (error) { const errorType = error?.meta?.body?.error?.type ?? ''; if (errorType === 'index_not_found_exception') { @@ -389,7 +419,6 @@ export class EndpointMetadataService { const agentPolicy = agentPoliciesMap[_agent.policy_id!]; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const endpointPolicy = endpointPoliciesMap[_agent.policy_id!]; - const runtimeFields: Partial = { status: doc?.fields?.status?.[0], last_checkin: doc?.fields?.last_checkin?.[0], @@ -415,10 +444,10 @@ export class EndpointMetadataService { async getMetadataForEndpoints(endpointIDs: string[]): Promise { const query = getESQueryHostMetadataByIDs(endpointIDs); - const { body } = await this.esClient.search(query, { - meta: true, - }); - const hosts = queryResponseToHostListResult(body); - return hosts.resultList; + const searchResult = await this.esClient.search(query).catch(catchAndWrapError); + + await this.ensureDataValidForSpace(searchResult); + + return queryResponseToHostListResult(searchResult).resultList; } } diff --git a/x-pack/plugins/security_solution/server/endpoint/services/metadata/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/services/metadata/mocks.ts index f0c5fb8d74bcdf..51c70a461ee148 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/metadata/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/metadata/mocks.ts @@ -9,11 +9,25 @@ import type { SavedObjectsServiceStart } from '@kbn/core/server'; import { coreMock, type ElasticsearchClientMock, loggingSystemMock } from '@kbn/core/server/mocks'; import type { createPackagePolicyServiceMock } from '@kbn/fleet-plugin/server/mocks'; import type { AgentPolicyServiceInterface, AgentService } from '@kbn/fleet-plugin/server'; +import type { Agent, GetAgentPoliciesResponseItem } from '@kbn/fleet-plugin/common'; +import type { + PolicyData, + UnitedAgentMetadataPersistedData, +} from '../../../../common/endpoint/types'; +import { FleetAgentPolicyGenerator } from '../../../../common/endpoint/data_generators/fleet_agent_policy_generator'; +import { FleetAgentGenerator } from '../../../../common/endpoint/data_generators/fleet_agent_generator'; +import { FleetPackagePolicyGenerator } from '../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import { applyEsClientSearchMock } from '../../mocks/utils.mock'; +import type { EndpointInternalFleetServicesInterfaceMocked } from '../fleet/endpoint_fleet_services_factory.mocks'; import { createEndpointFleetServicesFactoryMock } from '../fleet/endpoint_fleet_services_factory.mocks'; import { createMockEndpointAppContextServiceStartContract } from '../../mocks'; import { EndpointMetadataService } from './endpoint_metadata_service'; -import type { EndpointInternalFleetServicesInterface } from '../fleet/endpoint_fleet_services_factory'; import { SavedObjectsClientFactory } from '../saved_objects'; +import { + METADATA_UNITED_INDEX, + metadataCurrentIndexPattern, +} from '../../../../common/endpoint/constants'; +import { EndpointMetadataGenerator } from '../../../../common/endpoint/data_generators/endpoint_metadata_generator'; /** * Endpoint Metadata Service test context. Includes an instance of `EndpointMetadataService` along with the @@ -25,9 +39,10 @@ export interface EndpointMetadataServiceTestContextMock { agentPolicyService: jest.Mocked; packagePolicyService: ReturnType; endpointMetadataService: EndpointMetadataService; - fleetServices: EndpointInternalFleetServicesInterface; + fleetServices: EndpointInternalFleetServicesInterfaceMocked; logger: ReturnType['get']>; esClient: ElasticsearchClientMock; + applyMetadataMocks: typeof applyMetadataMocks; } export const createEndpointMetadataServiceTestContextMock = @@ -64,12 +79,111 @@ export const createEndpointMetadataServiceTestContextMock = agentService: { asInternalUser: fleetServices.agent, asScoped: jest.fn().mockReturnValue(fleetServices.agent), + asInternalScopedUser: jest.fn().mockReturnValue(fleetServices.agent), }, agentPolicyService: fleetServices.agentPolicy, packagePolicyService: fleetServices.packagePolicy, logger, endpointMetadataService, fleetServices, + applyMetadataMocks, esClient: esClient as ElasticsearchClientMock, }; }; + +export interface ApplyMetadataMocksResponse { + unitedMetadata: UnitedAgentMetadataPersistedData; + integrationPolicies: PolicyData[]; + agentPolicies: GetAgentPoliciesResponseItem[]; + agents: Agent[]; +} + +/** + * Apply mocks to the various services used to retrieve metadata via the EndpointMetadataService. + * Returns the data that is used in the mocks, thus allowing manipulation of it before running the + * test. + * @param esClientMock + * @param fleetServices + */ +export const applyMetadataMocks = ( + esClientMock: ElasticsearchClientMock, + fleetServices: EndpointInternalFleetServicesInterfaceMocked +): ApplyMetadataMocksResponse => { + const metadataGenerator = new EndpointMetadataGenerator('seed'); + const fleetIntegrationPolicyGenerator = new FleetPackagePolicyGenerator('seed'); + const fleetAgentGenerator = new FleetAgentGenerator('seed'); + const fleetAgentPolicyGenerator = new FleetAgentPolicyGenerator('seed'); + + const unitedMetadata = metadataGenerator.generateUnitedAgentMetadata(); + const integrationPolicies = [ + fleetIntegrationPolicyGenerator.generateEndpointPackagePolicy({ + id: unitedMetadata.united.endpoint.Endpoint.policy.applied.id, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + policy_ids: [unitedMetadata.united.agent.policy_id!], + }), + ]; + const agentPolicies = [ + fleetAgentPolicyGenerator.generate({ id: unitedMetadata.united.agent.policy_id }), + ]; + const agents = [ + fleetAgentGenerator.generate({ + id: unitedMetadata.agent.id, + policy_id: agentPolicies[0].id, + }), + ]; + + applyEsClientSearchMock({ + esClientMock, + index: METADATA_UNITED_INDEX, + response: metadataGenerator.toEsSearchResponse([ + metadataGenerator.toEsSearchHit(unitedMetadata, METADATA_UNITED_INDEX), + ]), + }); + + applyEsClientSearchMock({ + esClientMock, + index: metadataCurrentIndexPattern, + response: metadataGenerator.toEsSearchResponse([ + metadataGenerator.toEsSearchHit(unitedMetadata.united.endpoint, metadataCurrentIndexPattern), + ]), + }); + + fleetServices.packagePolicy.list.mockImplementation(async (_, { page = 1 }) => { + // FYI: need to implement returning an empty list of items after page 1 due to how + // `getAllEndpointPackagePolicies()` is currently looping through all policies + // See `x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/endpoint_package_policies.ts` + return { + items: page === 1 ? integrationPolicies : [], + page: 1, + total: 1, + perPage: 20, + }; + }); + + fleetServices.packagePolicy.get.mockImplementation(async () => { + return integrationPolicies[0]; + }); + + fleetServices.agentPolicy.getByIds.mockImplementation(async () => { + return agentPolicies; + }); + + fleetServices.agentPolicy.get.mockImplementation(async () => { + return agentPolicies[0]; + }); + + fleetServices.agent.getByIds.mockImplementation(async () => { + return agents; + }); + + fleetServices.agent.getAgent.mockImplementation(async () => { + return agents[0]; + }); + + return { + unitedMetadata, + integrationPolicies, + agentPolicies, + agents, + }; +}; diff --git a/x-pack/test/functional/es_archives/endpoint/policy/data.json.gz b/x-pack/test/functional/es_archives/endpoint/policy/data.json.gz deleted file mode 100644 index 88c7995a2c26c7..00000000000000 Binary files a/x-pack/test/functional/es_archives/endpoint/policy/data.json.gz and /dev/null differ diff --git a/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/policy_response/trial_license_complete_tier/policy_response.ts b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/policy_response/trial_license_complete_tier/policy_response.ts index f9629f3a71d1b5..8f3cc8fa8b33ea 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/policy_response/trial_license_complete_tier/policy_response.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/policy_response/trial_license_complete_tier/policy_response.ts @@ -10,11 +10,10 @@ import TestAgent from 'supertest/lib/agent'; import { FtrProviderContext } from '../../../../ftr_provider_context_edr_workflows'; export default function ({ getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - const endpointDataStreamHelpers = getService('endpointDataStreamHelpers'); const utils = getService('securitySolutionUtils'); + const endpointTestresources = getService('endpointTestResources'); - describe('@ess @serverless Endpoint policy api', function () { + describe('@ess @serverless Endpoint policy response api', function () { let adminSupertest: TestAgent; before(async () => { @@ -22,19 +21,24 @@ export default function ({ getService }: FtrProviderContext) { }); describe('GET /api/endpoint/policy_response', () => { - before( - async () => - await esArchiver.load('x-pack/test/functional/es_archives/endpoint/policy', { - useCreate: true, - }) - ); + let mockData: Awaited>; + + before(async () => { + mockData = await endpointTestresources.loadEndpointData(); + }); // the endpoint uses data streams and es archiver does not support deleting them at the moment so we need // to do it manually - after(async () => await endpointDataStreamHelpers.deletePolicyStream(getService)); + after(async () => { + if (mockData) { + await endpointTestresources.unloadEndpointData(mockData); + // @ts-expect-error + mockData = undefined; + } + }); it('should return one policy response for an id', async () => { - const expectedAgentId = 'a10ac658-a3bc-4ac6-944a-68d9bd1c5a5e'; + const expectedAgentId = mockData.hosts[0].agent.id; const { body } = await adminSupertest .get(`/api/endpoint/policy_response?agentId=${expectedAgentId}`) .send() @@ -50,7 +54,7 @@ export default function ({ getService }: FtrProviderContext) { .send() .expect(404); - expect(body.message).to.contain('Policy Response Not Found'); + expect(body.message).to.contain('Policy response for endpoint id [bad_id] not found'); }); }); }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/configs/ess.config.ts new file mode 100644 index 00000000000000..422e3fbb866777 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/configs/ess.config.ts @@ -0,0 +1,48 @@ +/* + * 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 { FtrConfigProviderContext } from '@kbn/test'; +import type { ExperimentalFeatures as SecuritySolutionExperimentalFeatures } from '@kbn/security-solution-plugin/common'; +import type { ExperimentalFeatures as FleetExperimentalFeatures } from '@kbn/fleet-plugin/common/experimental_features'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile( + require.resolve('../../../../../config/ess/config.base.edr_workflows.trial') + ); + + const securitySolutionEnableExperimental: Array = [ + 'endpointManagementSpaceAwarenessEnabled', + ]; + const fleetEnableExperimental: Array = ['useSpaceAwareness']; + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('..')], + junit: { + reportName: 'EDR Workflows - Space Awareness Integration Tests - ESS Env - Trial License', + }, + kbnTestServer: { + ...functionalConfig.get('kbnTestServer'), + serverArgs: [ + ...functionalConfig.get('kbnTestServer.serverArgs').filter( + // Exclude Fleet and Security solution experimental features + // properties since we are overriding them here + (arg: string) => + !arg.includes('xpack.fleet.enableExperimental') && + !arg.includes('xpack.securitySolution.enableExperimental') + ), + // FLEET: set any experimental feature flags for testing + `--xpack.fleet.enableExperimental=${JSON.stringify(fleetEnableExperimental)}`, + + // SECURITY SOLUTION: set any experimental feature flags for testing + `--xpack.securitySolution.enableExperimental=${JSON.stringify( + securitySolutionEnableExperimental + )}`, + ], + }, + }; +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/configs/serverless.config.ts new file mode 100644 index 00000000000000..446fd5be070798 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/configs/serverless.config.ts @@ -0,0 +1,51 @@ +/* + * 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 { FtrConfigProviderContext } from '@kbn/test'; +import type { ExperimentalFeatures as SecuritySolutionExperimentalFeatures } from '@kbn/security-solution-plugin/common'; +import type { ExperimentalFeatures as FleetExperimentalFeatures } from '@kbn/fleet-plugin/common/experimental_features'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile( + require.resolve('../../../../../config/serverless/config.base.edr_workflows') + ); + + const securitySolutionEnableExperimental: Array = [ + 'endpointManagementSpaceAwarenessEnabled', + ]; + const fleetEnableExperimental: Array = ['useSpaceAwareness']; + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('..')], + junit: { + reportName: 'EDR Workflows - Space Awareness Integration Tests - Serverless Env - Complete', + }, + kbnTestServer: { + ...functionalConfig.get('kbnTestServer'), + serverArgs: [ + ...functionalConfig.get('kbnTestServer.serverArgs').filter( + // Exclude Fleet and Security solution experimental features + // properties since we are overriding them here + (arg: string) => + !arg.includes('xpack.fleet.enableExperimental') && + !arg.includes('xpack.securitySolution.enableExperimental') + ), + // FLEET: set any experimental feature flags for testing + `--xpack.fleet.enableExperimental=${JSON.stringify(fleetEnableExperimental)}`, + + // SECURITY SOLUTION: set any experimental feature flags for testing + `--xpack.securitySolution.enableExperimental=${JSON.stringify( + securitySolutionEnableExperimental + )}`, + + // Enable spaces UI capabilities + '--xpack.spaces.maxSpaces=100', + ], + }, + }; +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/index.ts b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/index.ts new file mode 100644 index 00000000000000..729b88f25c5783 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/index.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getRegistryUrl as getRegistryUrlFromIngest } from '@kbn/fleet-plugin/server'; +import { isServerlessKibanaFlavor } from '@kbn/security-solution-plugin/common/endpoint/utils/kibana_status'; +import { enableFleetSpaceAwareness } from '@kbn/security-solution-plugin/scripts/endpoint/common/fleet_services'; +import { FtrProviderContext } from '../../../../ftr_provider_context_edr_workflows'; +import { ROLE } from '../../../../config/services/security_solution_edr_workflows_roles_users'; + +export default function endpointAPIIntegrationTests(providerContext: FtrProviderContext) { + const { loadTestFile, getService } = providerContext; + + describe('Endpoint plugin spaces support', function () { + const ingestManager = getService('ingestManager'); + const rolesUsersProvider = getService('rolesUsersProvider'); + const kbnClient = getService('kibanaServer'); + const log = getService('log'); + const endpointRegistryHelpers = getService('endpointRegistryHelpers'); + + const roles = Object.values(ROLE); + before(async () => { + if (!endpointRegistryHelpers.isRegistryEnabled()) { + log.warning('These tests are being run with an external package registry'); + } + + const registryUrl = + endpointRegistryHelpers.getRegistryUrlFromTestEnv() ?? getRegistryUrlFromIngest(); + log.info(`Package registry URL for tests: ${registryUrl}`); + try { + await ingestManager.setup(); + } catch (err) { + log.warning(`Error setting up ingestManager: ${err}`); + } + + if (!(await isServerlessKibanaFlavor(kbnClient))) { + // create role/user + for (const role of roles) { + await rolesUsersProvider.createRole({ predefinedRole: role }); + await rolesUsersProvider.createUser({ name: role, roles: [role] }); + } + } + + // Enable fleet space awareness + log.info('Enabling Fleet space awareness'); + await enableFleetSpaceAwareness(kbnClient); + }); + + after(async () => { + if (!(await isServerlessKibanaFlavor(kbnClient))) { + // delete role/user + await rolesUsersProvider.deleteUsers(roles); + await rolesUsersProvider.deleteRoles(roles); + } + }); + + loadTestFile(require.resolve('./space_awareness')); + }); +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/space_awareness.ts b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/space_awareness.ts new file mode 100644 index 00000000000000..d36eb3d2d2e6fb --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/spaces/trial_license_complete_tier/space_awareness.ts @@ -0,0 +1,190 @@ +/* + * 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 TestAgent from 'supertest/lib/agent'; +import { ensureSpaceIdExists } from '@kbn/security-solution-plugin/scripts/endpoint/common/spaces'; +import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; +import expect from '@kbn/expect'; +import { + AGENT_STATUS_ROUTE, + BASE_POLICY_RESPONSE_ROUTE, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, +} from '@kbn/security-solution-plugin/common/endpoint/constants'; +import { createSupertestErrorLogger } from '../../utils'; +import { FtrProviderContext } from '../../../../ftr_provider_context_edr_workflows'; + +export default function ({ getService }: FtrProviderContext) { + const utils = getService('securitySolutionUtils'); + const endpointTestresources = getService('endpointTestResources'); + const kbnServer = getService('kibanaServer'); + const log = getService('log'); + + describe('@ess @serverless Endpoint management space awareness support', function () { + let adminSupertest: TestAgent; + let dataSpaceA: Awaited>; + let dataSpaceB: Awaited>; + + before(async () => { + adminSupertest = await utils.createSuperTest(); + + await Promise.all([ + ensureSpaceIdExists(kbnServer, 'space_a', { log }), + ensureSpaceIdExists(kbnServer, 'space_b', { log }), + ]); + + dataSpaceA = await endpointTestresources.loadEndpointData({ + spaceId: 'space_a', + generatorSeed: Math.random().toString(32), + }); + + dataSpaceB = await endpointTestresources.loadEndpointData({ + spaceId: 'space_b', + generatorSeed: Math.random().toString(32), + }); + + log.verbose( + `mocked data loaded:\nSPACE A:\n${JSON.stringify( + dataSpaceA, + null, + 2 + )}\nSPACE B:\n${JSON.stringify(dataSpaceB, null, 2)}` + ); + }); + + // the endpoint uses data streams and es archiver does not support deleting them at the moment so we need + // to do it manually + after(async () => { + if (dataSpaceA) { + await dataSpaceA.unloadEndpointData(); + // @ts-expect-error + dataSpaceA = undefined; + } + if (dataSpaceB) { + await dataSpaceB.unloadEndpointData(); + // @ts-expect-error + dataSpaceB = undefined; + } + }); + + describe(`Policy Response API: ${BASE_POLICY_RESPONSE_ROUTE}`, () => { + it('should return policy response in space', async () => { + const { body } = await adminSupertest + .get( + addSpaceIdToPath( + '/', + dataSpaceA.spaceId, + `/api/endpoint/policy_response?agentId=${dataSpaceA.hosts[0].agent.id}` + ) + ) + .on('error', createSupertestErrorLogger(log)) + .send() + .expect(200); + + expect(body.policy_response.agent.id).to.eql(dataSpaceA.hosts[0].agent.id); + }); + + it('should return not found for a host policy response not in current space', async () => { + await adminSupertest + .get( + addSpaceIdToPath( + '/', + dataSpaceA.spaceId, + `/api/endpoint/policy_response?agentId=${dataSpaceB.hosts[0].agent.id}` + ) + ) + .on('error', createSupertestErrorLogger(log).ignoreCodes([404])) + .send() + .expect(404); + }); + }); + + describe(`Host Metadata List API: ${HOST_METADATA_LIST_ROUTE}`, () => { + it('should retrieve list with only metadata for hosts in current space', async () => { + const { body } = await adminSupertest + .get(addSpaceIdToPath('/', dataSpaceA.spaceId, HOST_METADATA_LIST_ROUTE)) + .on('error', createSupertestErrorLogger(log)) + .send() + .expect(200); + + expect(body.total).to.eql(1); + expect(body.data[0].metadata.agent.id).to.eql(dataSpaceA.hosts[0].agent.id); + }); + + it('should not return host data from other spaces when using kuery value', async () => { + const { body } = await adminSupertest + .get(addSpaceIdToPath('/', dataSpaceA.spaceId, HOST_METADATA_LIST_ROUTE)) + .on('error', createSupertestErrorLogger(log)) + .query({ + kuery: `united.endpoint.agent.id: "${dataSpaceB.hosts[0].agent.id}"`, + }) + .send() + .expect(200); + + expect(body.total).to.eql(0); + }); + }); + + describe(`Host Details Metadata API: ${HOST_METADATA_GET_ROUTE}`, () => { + it('should retrieve metadata details for agent id in space', async () => { + await adminSupertest + .get( + addSpaceIdToPath( + '/', + dataSpaceA.spaceId, + HOST_METADATA_GET_ROUTE.replace('{id}', dataSpaceA.hosts[0].agent.id) + ) + ) + .on('error', createSupertestErrorLogger(log)) + .send() + .expect(200); + }); + + it('should NOT return metadata details for agent id that is not in current space', async () => { + await adminSupertest + .get( + addSpaceIdToPath( + '/', + dataSpaceA.spaceId, + HOST_METADATA_GET_ROUTE.replace('{id}', dataSpaceB.hosts[0].agent.id) + ) + ) + .on('error', createSupertestErrorLogger(log).ignoreCodes([404])) + .send() + .expect(404); + }); + }); + + describe(`Agent Status API: ${AGENT_STATUS_ROUTE}`, () => { + it('should return status for an agent in current space', async () => { + const { body } = await adminSupertest + .get(addSpaceIdToPath('/', dataSpaceA.spaceId, AGENT_STATUS_ROUTE)) + .query({ agentIds: [dataSpaceA.hosts[0].agent.id] }) + .set('elastic-api-version', '1') + .set('x-elastic-internal-origin', 'kibana') + .on('error', createSupertestErrorLogger(log)) + .send() + .expect(200); + + expect(body.data[dataSpaceA.hosts[0].agent.id].found).to.eql(true); + }); + + it('should NOT return status for an agent that is not in current space', async () => { + const { body } = await adminSupertest + .get(addSpaceIdToPath('/', dataSpaceA.spaceId, AGENT_STATUS_ROUTE)) + .query({ agentIds: [dataSpaceB.hosts[0].agent.id] }) + .set('elastic-api-version', '1') + .set('x-elastic-internal-origin', 'kibana') + .on('error', createSupertestErrorLogger(log)) + .send() + .expect(200); + + expect(body.data[dataSpaceB.hosts[0].agent.id].found).to.eql(false); + }); + }); + }); +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/utils/index.ts b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/utils/index.ts new file mode 100644 index 00000000000000..cb4a0fc06b90a1 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/utils/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './supertest_error_logger'; diff --git a/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/utils/supertest_error_logger.ts b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/utils/supertest_error_logger.ts new file mode 100644 index 00000000000000..67f009cb55874b --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/edr_workflows/utils/supertest_error_logger.ts @@ -0,0 +1,73 @@ +/* + * 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 SuperTest from 'supertest'; +import { ToolingLog } from '@kbn/tooling-log'; + +export interface LogErrorDetailsInterface { + (this: SuperTest.Test, err: Error & { response?: any }): SuperTest.Test; + ignoreCodes: ( + codes: number[] + ) => (this: SuperTest.Test, err: Error & { response?: SuperTest.Response }) => SuperTest.Test; +} + +/** + * Creates a logger that can be used with `supertest` to log details around errors + * + * @param log + * + * @example + * const errorLogger = createSupertestErrorLogger(log); + * + * supertestWithoutAuth + * .post(`some/url`) + * .on('error', errorLogger) //<< Add logger to `error` event + * .send({}) + * + * // Ignore 404 + * supertestWithoutAuth + * .post(`some/url`) + * .on('error', errorLogger.ignoreCodes([404]) //<< Add logger to `error` event and ignore 404 + * .send({}) + */ +export const createSupertestErrorLogger = (log: ToolingLog): LogErrorDetailsInterface => { + /** + * Utility for use with `supertest` that logs errors with details returned by the API + * @param err + */ + const logErrorDetails: LogErrorDetailsInterface = function (err) { + if (err.response && (err.response.body || err.response.text)) { + let outputData = + 'RESPONSE:\n' + err.response.body + ? JSON.stringify(err.response.body, null, 2) + : err.response.text; + + if (err.response.request) { + const { url = '', method = '', _data = '' } = err.response.request; + + outputData += `\nREQUEST: + ${method} ${url} + ${JSON.stringify(_data, null, 2)} + `; + } + + log.error(outputData); + } + + return this ?? err; + }; + logErrorDetails.ignoreCodes = (codes) => { + return function (err) { + if (err.response && err.response.status && !codes.includes(err.response.status)) { + return logErrorDetails.call(this, err); + } + return this; + }; + }; + + return logErrorDetails; +}; diff --git a/x-pack/test/security_solution_api_integration/tsconfig.json b/x-pack/test/security_solution_api_integration/tsconfig.json index b7a320dd197207..17d5053c053281 100644 --- a/x-pack/test/security_solution_api_integration/tsconfig.json +++ b/x-pack/test/security_solution_api_integration/tsconfig.json @@ -50,5 +50,6 @@ "@kbn/search-types", "@kbn/security-plugin", "@kbn/ftr-common-functional-ui-services", + "@kbn/spaces-plugin", ] } diff --git a/x-pack/test/security_solution_endpoint/services/endpoint.ts b/x-pack/test/security_solution_endpoint/services/endpoint.ts index 01f3aac83dd840..2d247657d90f71 100644 --- a/x-pack/test/security_solution_endpoint/services/endpoint.ts +++ b/x-pack/test/security_solution_endpoint/services/endpoint.ts @@ -21,6 +21,7 @@ import { } from '@kbn/security-solution-plugin/common/endpoint/constants'; import { deleteIndexedHostsAndAlerts, + DeleteIndexedHostsAndAlertsResponse, IndexedHostsAndAlertsResponse, indexHostsAndAlerts, } from '@kbn/security-solution-plugin/common/endpoint/index_data'; @@ -40,11 +41,17 @@ import seedrandom from 'seedrandom'; import { fetchFleetLatestAvailableAgentVersion } from '@kbn/security-solution-plugin/common/endpoint/utils/fetch_fleet_version'; import { KbnClient } from '@kbn/test'; import { isServerlessKibanaFlavor } from '@kbn/security-solution-plugin/common/endpoint/utils/kibana_status'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; +import { createKbnClient } from '@kbn/security-solution-plugin/scripts/endpoint/common/stack_services'; import { FtrService } from '../../functional/ftr_provider_context'; +export type IndexedHostsAndAlertsResponseExtended = IndexedHostsAndAlertsResponse & { + unloadEndpointData(): Promise; + spaceId: string; +}; + // Document Generator override that uses a custom Endpoint Metadata generator and sets the // `agent.version` to the current version - const createDocGeneratorClass = async (kbnClient: KbnClient, isServerless: boolean) => { let version = kibanaPackageJson.version; if (isServerless) { @@ -74,6 +81,26 @@ export class EndpointTestResources extends FtrService { private readonly supertest = this.ctx.getService('supertest'); private readonly log = this.ctx.getService('log'); + public getScopedKbnClient(spaceId: string = DEFAULT_SPACE_ID): KbnClient { + if (!spaceId || spaceId === DEFAULT_SPACE_ID) { + return this.kbnClient; + } + + const kbnClientOptions: Parameters[0] = { + url: this.kbnClient.resolveUrl('/'), + username: this.config.get('servers.elasticsearch.username'), + password: this.config.get('servers.elasticsearch.password'), + spaceId, + }; + + this.log.info(`creating new KbnClient with:\n${JSON.stringify(kbnClientOptions, null, 2)}`); + + // Was not included above in order to keep the output of the log.info() above clean in the output + kbnClientOptions.log = this.log; + + return createKbnClient(kbnClientOptions); + } + async stopTransform(transformId: string) { const stopRequest = { transform_id: `${transformId}*`, @@ -120,8 +147,9 @@ export class EndpointTestResources extends FtrService { waitUntilTransformed: boolean; waitTimeout: number; customIndexFn: () => Promise; + spaceId: string; }> = {} - ): Promise { + ): Promise { const { numHosts = 1, numHostDocs = 1, @@ -131,12 +159,16 @@ export class EndpointTestResources extends FtrService { waitUntilTransformed = true, waitTimeout = 120000, customIndexFn, + spaceId = DEFAULT_SPACE_ID, } = options; + const kbnClient = this.getScopedKbnClient(spaceId); + let currentTransformName = metadataTransformPrefix; let unitedTransformName = METADATA_UNITED_TRANSFORM; + if (waitUntilTransformed && customIndexFn) { - const endpointPackage = await getEndpointPackageInfo(this.kbnClient); + const endpointPackage = await getEndpointPackageInfo(kbnClient); const isV2 = isEndpointPackageV2(endpointPackage.version); if (isV2) { @@ -152,18 +184,15 @@ export class EndpointTestResources extends FtrService { await this.stopTransform(unitedTransformName); } - const isServerless = await isServerlessKibanaFlavor(this.kbnClient); - const CurrentKibanaVersionDocGenerator = await createDocGeneratorClass( - this.kbnClient, - isServerless - ); + const isServerless = await isServerlessKibanaFlavor(kbnClient); + const CurrentKibanaVersionDocGenerator = await createDocGeneratorClass(kbnClient, isServerless); // load data into the system const indexedData = customIndexFn ? await customIndexFn() : await indexHostsAndAlerts( this.esClient as Client, - this.kbnClient, + kbnClient, generatorSeed, numHosts, numHostDocs, @@ -194,15 +223,29 @@ export class EndpointTestResources extends FtrService { await this.waitForUnitedEndpoints(agentIds, waitTimeout); } - return indexedData; + return { + ...indexedData, + spaceId, + unloadEndpointData: (): Promise => { + return this.unloadEndpointData(indexedData, { spaceId }); + }, + }; } /** * Deletes the loaded data created via `loadEndpointData()` * @param indexedData + * @param options */ - async unloadEndpointData(indexedData: IndexedHostsAndAlertsResponse) { - return deleteIndexedHostsAndAlerts(this.esClient as Client, this.kbnClient, indexedData); + async unloadEndpointData( + indexedData: IndexedHostsAndAlertsResponse, + { spaceId = DEFAULT_SPACE_ID }: { spaceId?: string } = {} + ): Promise { + return deleteIndexedHostsAndAlerts( + this.esClient as Client, + this.getScopedKbnClient(spaceId), + indexedData + ); } private async waitForIndex( @@ -315,10 +358,10 @@ export class EndpointTestResources extends FtrService { * installs (or upgrades) the Endpoint Fleet package * (NOTE: ensure that fleet is setup first before calling this function) */ - async installOrUpgradeEndpointFleetPackage(): ReturnType< - typeof installOrUpgradeEndpointFleetPackage - > { - return installOrUpgradeEndpointFleetPackage(this.kbnClient, this.log); + async installOrUpgradeEndpointFleetPackage( + spaceId: string = DEFAULT_SPACE_ID + ): ReturnType { + return installOrUpgradeEndpointFleetPackage(this.getScopedKbnClient(spaceId), this.log); } /** @@ -383,8 +426,8 @@ export class EndpointTestResources extends FtrService { return response; } - async isEndpointPackageV2(): Promise { - const endpointPackage = await getEndpointPackageInfo(this.kbnClient); + async isEndpointPackageV2(spaceId: string = DEFAULT_SPACE_ID): Promise { + const endpointPackage = await getEndpointPackageInfo(this.getScopedKbnClient(spaceId)); return isEndpointPackageV2(endpointPackage.version); } } diff --git a/x-pack/test/security_solution_endpoint/tsconfig.json b/x-pack/test/security_solution_endpoint/tsconfig.json index e4ce04de12a595..d9aa0c922bdf29 100644 --- a/x-pack/test/security_solution_endpoint/tsconfig.json +++ b/x-pack/test/security_solution_endpoint/tsconfig.json @@ -28,5 +28,6 @@ "@kbn/test", "@kbn/test-subj-selector", "@kbn/ftr-common-functional-services", + "@kbn/spaces-plugin", ] }