Skip to content

Commit

Permalink
[Security Solution][Endpoint] Update to Endpoint List and associated …
Browse files Browse the repository at this point in the history
…supporting API to be space aware (#194312)

## Summary

PR makes the following changes in support of Endpoint management support
for Spaces:


### Fleet

- Adds new method - `getByIds()` - to the server-side Agent service
- Updates some mocks


### Security Solution

- Updates the Endpoint host metadata API for both the list and single
endpoint to be space aware
- Updates the agent status API for Endpoint to be space aware
- Updates endpoint policy response API to be space aware
- New FTR API integration test suite was created
- included changes to some service utilities to enable loading test data
per-space
  • Loading branch information
paul-tavares authored Oct 17, 2024
1 parent 8cfa396 commit 2a786b8
Show file tree
Hide file tree
Showing 48 changed files with 1,564 additions and 244 deletions.
1 change: 1 addition & 0 deletions .buildkite/ftr_security_serverless_configs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ enabled:
- 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
Expand Down
1 change: 1 addition & 0 deletions .buildkite/ftr_security_stateful_configs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions x-pack/plugins/fleet/common/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

import type { FleetErrorType } from './types';

export class FleetError extends Error {
export class FleetError<TMeta = unknown> 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
}
Expand Down
9 changes: 6 additions & 3 deletions x-pack/plugins/fleet/server/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TMeta = unknown> extends FleetError<TMeta> {}
export class FleetTooManyRequestsError extends FleetError {}

export class OutputUnauthorizedError extends FleetError {}
Expand All @@ -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 {}
Expand All @@ -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 {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ const createClientMock = (): jest.Mocked<AgentClient> => ({
getAgentStatusForAgentPolicy: jest.fn(),
listAgents: jest.fn(),
getLatestAgentAvailableVersion: jest.fn(),
getByIds: jest.fn(async (..._) => []),
});

const createServiceMock = (): DeeplyMockedKeys<AgentService> => ({
asInternalUser: createClientMock(),
asInternalScopedUser: jest.fn().mockReturnValue(createClientMock()),
asScoped: jest.fn().mockReturnValue(createClientMock()),
});

Expand Down
21 changes: 21 additions & 0 deletions x-pack/plugins/fleet/server/services/agents/agent_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
36 changes: 35 additions & 1 deletion x-pack/plugins/fleet/server/services/agents/agent_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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.
Expand All @@ -60,6 +65,12 @@ export interface AgentClient {
*/
getAgent(agentId: string): Promise<Agent>;

/**
* Get multiple agents by id
* @param agentIds
*/
getByIds(agentIds: string[], options?: { ignoreMissing?: boolean }): Promise<Agent[]>;

/**
* Return the status by the Agent's id
*/
Expand Down Expand Up @@ -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<Agent[]> {
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);
Expand Down Expand Up @@ -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);
}
Expand Down
63 changes: 61 additions & 2 deletions x-pack/plugins/fleet/server/services/agents/crud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,6 +28,7 @@ import {
openPointInTime,
updateAgent,
_joinFilters,
getByIds,
} from './crud';

jest.mock('../audit_logging');
Expand All @@ -41,6 +46,7 @@ jest.mock('./versions', () => {
jest.mock('../spaces/helpers');

const mockedAuditLoggingService = auditLoggingService as jest.Mocked<typeof auditLoggingService>;
const isSpaceAwarenessEnabledMock = _isSpaceAwarenessEnabled as jest.Mock;

describe('Agents CRUD test', () => {
const soClientMock = savedObjectsClientMock.create();
Expand All @@ -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<Agent> = () => ({})
) {
return {
hits: {
total,
hits: ids.map((id: string) => ({
_id: id,
_source: {},
_source: generateSource(id),
fields: {
status: [status],
},
Expand Down Expand Up @@ -513,4 +528,48 @@ describe('Agents CRUD test', () => {
});
});
});

describe(`getByIds()`, () => {
let searchResponse: ReturnType<typeof getEsResponse>;

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
);
});
});
});
40 changes: 40 additions & 0 deletions x-pack/plugins/fleet/server/services/agents/crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Agent[]> => {
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,
Expand Down
4 changes: 3 additions & 1 deletion x-pack/plugins/fleet/server/services/package_policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -226,6 +232,30 @@ export class EndpointMetadataGenerator extends BaseDataGenerator {
return merge(hostInfo, overrides);
}

generateUnitedAgentMetadata(
overrides: DeepPartial<UnitedAgentMetadataPersistedData> = {}
): 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,
Expand Down
Loading

0 comments on commit 2a786b8

Please sign in to comment.