Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[8.x] [Security Solution][Endpoint] Update to Endpoint List and associated supporting API to be space aware (#194312) #196717

Merged
merged 1 commit into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .buildkite/ftr_security_serverless_configs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
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
Loading