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

[Fleet] RBAC - Make agents write APIs space aware #188507

Merged
Merged
3 changes: 3 additions & 0 deletions x-pack/plugins/fleet/common/types/models/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface NewAgentAction {
ack_data?: any;
sent_at?: string;
agents: string[];
namespaces?: string[];
created_at?: string;
id?: string;
expiration?: string;
Expand Down Expand Up @@ -408,6 +409,8 @@ export interface FleetServerAgentAction {
*/
agents?: string[];

namespaces?: string[];

/**
* Date when the agent should execute that agent. This field could be altered by Fleet server for progressive rollout of the action.
*/
Expand Down
39 changes: 19 additions & 20 deletions x-pack/plugins/fleet/server/routes/agent/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import { uniq } from 'lodash';
import { type RequestHandler, SavedObjectsErrorHelpers } from '@kbn/core/server';
import type { TypeOf } from '@kbn/config-schema';
import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server';

import type {
GetAgentsResponse,
Expand All @@ -21,7 +20,6 @@ import type {
GetAgentUploadsResponse,
PostAgentReassignResponse,
PostRetrieveAgentsByActionsResponse,
Agent,
} from '../../../common/types';
import type {
GetAgentsRequestSchema,
Expand All @@ -45,18 +43,7 @@ import { defaultFleetErrorHandler, FleetNotFoundError } from '../../errors';
import * as AgentService from '../../services/agents';
import { fetchAndAssignAgentMetrics } from '../../services/agents/agent_metrics';
import { getAgentStatusForAgentPolicy } from '../../services/agents';

export function verifyNamespace(agent: Agent, currentNamespace?: string) {
const isInNamespace =
(currentNamespace && agent.namespaces?.includes(currentNamespace)) ||
(!currentNamespace &&
(!agent.namespaces ||
agent.namespaces.length === 0 ||
agent.namespaces?.includes(DEFAULT_NAMESPACE_STRING)));
if (!isInNamespace) {
throw new FleetNotFoundError(`${agent.id} not found in namespace`);
}
}
import { isAgentInNamespace } from '../../services/agents/namespace';

export const getAgentHandler: FleetRequestHandler<
TypeOf<typeof GetOneAgentRequestSchema.params>,
Expand All @@ -68,7 +55,9 @@ export const getAgentHandler: FleetRequestHandler<

let agent = await fleetContext.agentClient.asCurrentUser.getAgent(request.params.agentId);

verifyNamespace(agent, coreContext.savedObjects.client.getCurrentNamespace());
if (!isAgentInNamespace(agent, coreContext.savedObjects.client.getCurrentNamespace())) {
throw new FleetNotFoundError(`${agent.id} not found in namespace`);
}

if (request.query.withMetrics) {
agent = (await fetchAndAssignAgentMetrics(esClientCurrentUser, [agent]))[0];
Expand All @@ -90,12 +79,17 @@ export const getAgentHandler: FleetRequestHandler<
}
};

export const deleteAgentHandler: RequestHandler<
export const deleteAgentHandler: FleetRequestHandler<
TypeOf<typeof DeleteAgentRequestSchema.params>
> = async (context, request, response) => {
const [coreContext, fleetContext] = await Promise.all([context.core, context.fleet]);
const esClient = coreContext.elasticsearch.client.asInternalUser;

try {
const coreContext = await context.core;
const esClient = coreContext.elasticsearch.client.asInternalUser;
const agent = await fleetContext.agentClient.asCurrentUser.getAgent(request.params.agentId);
if (!isAgentInNamespace(agent, coreContext.savedObjects.client.getCurrentNamespace())) {
throw new FleetNotFoundError(`${agent.id} not found in namespace`);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I would be tempted to move these three line to a separate function, something like findAgentInNamespace, since it's repeated three times in the same file. What do you think?


await AgentService.deleteAgent(esClient, request.params.agentId);

Expand All @@ -116,12 +110,12 @@ export const deleteAgentHandler: RequestHandler<
}
};

export const updateAgentHandler: RequestHandler<
export const updateAgentHandler: FleetRequestHandler<
TypeOf<typeof UpdateAgentRequestSchema.params>,
undefined,
TypeOf<typeof UpdateAgentRequestSchema.body>
> = async (context, request, response) => {
const coreContext = await context.core;
const [coreContext, fleetContext] = await Promise.all([context.core, context.fleet]);
const esClient = coreContext.elasticsearch.client.asInternalUser;
const soClient = coreContext.savedObjects.client;

Expand All @@ -134,6 +128,11 @@ export const updateAgentHandler: RequestHandler<
}

try {
const agent = await fleetContext.agentClient.asCurrentUser.getAgent(request.params.agentId);
if (!isAgentInNamespace(agent, coreContext.savedObjects.client.getCurrentNamespace())) {
throw new FleetNotFoundError(`${agent.id} not found in namespace`);
}

await AgentService.updateAgent(esClient, request.params.agentId, partialAgent);
const body = {
item: await AgentService.getAgentById(esClient, soClient, request.params.agentId),
Expand Down
3 changes: 3 additions & 0 deletions x-pack/plugins/fleet/server/services/agents/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export async function createAgentAction(
? undefined
: newAgentAction.expiration ?? new Date(now + ONE_MONTH_IN_MS).toISOString(),
agents: newAgentAction.agents,
namespaces: newAgentAction.namespaces,
action_id: actionId,
data: newAgentAction.data,
type: newAgentAction.type,
Expand Down Expand Up @@ -182,6 +183,7 @@ export async function bulkCreateAgentActionResults(
results: Array<{
actionId: string;
agentId: string;
namespaces?: string[];
error?: string;
}>
): Promise<void> {
Expand All @@ -194,6 +196,7 @@ export async function bulkCreateAgentActionResults(
'@timestamp': new Date().toISOString(),
action_id: result.actionId,
agent_id: result.agentId,
namespaces: result.namespaces,
error: result.error,
};

Expand Down
72 changes: 72 additions & 0 deletions x-pack/plugins/fleet/server/services/agents/namespace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* 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 { Agent } from '../../types';

import { agentsKueryNamespaceFilter, isAgentInNamespace } from './namespace';

describe('isAgentInNamespace', () => {
describe('when the namespace is defined', () => {
it('returns true if the agent namespaces include the namespace', () => {
const agent = { id: '123', namespaces: ['default', 'space1'] } as Agent;
expect(isAgentInNamespace(agent, 'space1')).toEqual(true);
});

it('returns false if the agent namespaces do not include the namespace', () => {
const agent = { id: '123', namespaces: ['default', 'space1'] } as Agent;
expect(isAgentInNamespace(agent, 'space2')).toEqual(false);
});

it('returns false if the agent has zero length namespaces', () => {
const agent = { id: '123', namespaces: [] as string[] } as Agent;
expect(isAgentInNamespace(agent, 'space1')).toEqual(false);
});

it('returns false if the agent does not have namespaces', () => {
const agent = { id: '123' } as Agent;
expect(isAgentInNamespace(agent, 'space1')).toEqual(false);
});
});

describe('when the namespace is undefined', () => {
it('returns true if the agent does not have namespaces', () => {
const agent = { id: '123' } as Agent;
expect(isAgentInNamespace(agent)).toEqual(true);
});

it('returns true if the agent has zero length namespaces', () => {
const agent = { id: '123', namespaces: [] as string[] } as Agent;
expect(isAgentInNamespace(agent)).toEqual(true);
});

it('returns true if the agent namespaces include the default one', () => {
const agent = { id: '123', namespaces: ['default'] } as Agent;
expect(isAgentInNamespace(agent)).toEqual(true);
});

it('returns false if the agent namespaces include the default one', () => {
const agent = { id: '123', namespaces: ['space1'] } as Agent;
expect(isAgentInNamespace(agent)).toEqual(false);
});
});
});

describe('agentsKueryNamespaceFilter', () => {
it('returns undefined if the namespace is undefined', () => {
expect(agentsKueryNamespaceFilter()).toBeUndefined();
});

it('returns a kuery for the default space', () => {
expect(agentsKueryNamespaceFilter('default')).toEqual(
'namespaces:(default) or not namespaces:*'
);
});

it('returns a kuery for custom spaces', () => {
expect(agentsKueryNamespaceFilter('space1')).toEqual('namespaces:(space1)');
});
});
29 changes: 29 additions & 0 deletions x-pack/plugins/fleet/server/services/agents/namespace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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 { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server';

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

export function isAgentInNamespace(agent: Agent, namespace?: string) {
jillguyonnet marked this conversation as resolved.
Show resolved Hide resolved
return (
(namespace && agent.namespaces?.includes(namespace)) ||
(!namespace &&
(!agent.namespaces ||
agent.namespaces.length === 0 ||
agent.namespaces?.includes(DEFAULT_NAMESPACE_STRING)))
);
}

export function agentsKueryNamespaceFilter(namespace?: string) {
if (!namespace) {
return;
}
return namespace === DEFAULT_NAMESPACE_STRING
? `namespaces:(${DEFAULT_NAMESPACE_STRING}) or not namespaces:*`
: `namespaces:(${namespace})`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -374,4 +374,118 @@ describe('update_agent_tags', () => {
})
);
});

it('should update tags for agents in the space', async () => {
soClient.getCurrentNamespace.mockReturnValue('default');
esClient.search.mockResolvedValue({
hits: {
hits: [
{
_id: 'agent1',
_source: {
tags: ['one', 'two', 'three'],
namespaces: ['default'],
},
fields: {
status: 'online',
},
},
],
},
} as any);

await updateAgentTags(soClient, esClient, { agentIds: ['agent1'] }, ['one'], ['two']);

expect(esClient.updateByQuery).toHaveBeenCalledWith(
expect.objectContaining({
conflicts: 'proceed',
index: '.fleet-agents',
query: {
terms: { _id: ['agent1'] },
},
script: expect.objectContaining({
lang: 'painless',
params: expect.objectContaining({
tagsToAdd: ['one'],
tagsToRemove: ['two'],
updatedAt: expect.anything(),
}),
source: expect.anything(),
}),
})
);
});

it('should not update tags for agents in another space', async () => {
soClient.getCurrentNamespace.mockReturnValue('default');
esClient.search.mockResolvedValue({
hits: {
hits: [
{
_id: 'agent1',
_source: {
tags: ['one', 'two', 'three'],
namespaces: ['myspace'],
},
fields: {
status: 'online',
},
},
],
},
} as any);

await updateAgentTags(soClient, esClient, { agentIds: ['agent1'] }, ['one'], ['two']);

expect(esClient.updateByQuery).not.toHaveBeenCalled();
});

it('should add namespace filter to kuery in the default space', async () => {
soClient.getCurrentNamespace.mockReturnValue('default');

await updateAgentTags(
soClient,
esClient,
{ kuery: 'status:healthy OR status:offline' },
[],
['remove']
);

expect(UpdateAgentTagsActionRunner).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
batchSize: 10000,
kuery:
'(namespaces:(default) or not namespaces:*) AND (status:healthy OR status:offline) AND (tags:remove)',
tagsToAdd: [],
tagsToRemove: ['remove'],
}),
expect.anything()
);
});

it('should add namespace filter to kuery in a custom space', async () => {
soClient.getCurrentNamespace.mockReturnValue('myspace');

await updateAgentTags(
soClient,
esClient,
{ kuery: 'status:healthy OR status:offline' },
[],
['remove']
);

expect(UpdateAgentTagsActionRunner).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
batchSize: 10000,
kuery: '(namespaces:(myspace)) AND (status:healthy OR status:offline) AND (tags:remove)',
tagsToAdd: [],
tagsToRemove: ['remove'],
}),
expect.anything()
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { SO_SEARCH_LIMIT } from '../../constants';
import { getAgentsById, getAgentsByKuery, openPointInTime } from './crud';
import type { GetAgentsOptions } from '.';
import { UpdateAgentTagsActionRunner, updateTagsBatch } from './update_agent_tags_action_runner';
import { agentsKueryNamespaceFilter, isAgentInNamespace } from './namespace';

export async function updateAgentTags(
soClient: SavedObjectsClientContract,
Expand All @@ -25,6 +26,7 @@ export async function updateAgentTags(
): Promise<{ actionId: string }> {
const outgoingErrors: Record<Agent['id'], Error> = {};
const givenAgents: Agent[] = [];
const currentNameSpace = soClient.getCurrentNamespace();

if ('agentIds' in options) {
const maybeAgents = await getAgentsById(esClient, soClient, options.agentIds);
Expand All @@ -33,14 +35,19 @@ export async function updateAgentTags(
outgoingErrors[maybeAgent.id] = new AgentReassignmentError(
`Cannot find agent ${maybeAgent.id}`
);
} else if (!isAgentInNamespace(maybeAgent, currentNameSpace)) {
outgoingErrors[maybeAgent.id] = new AgentReassignmentError(
`Agent ${maybeAgent.id} is not in the current space`
);
} else {
givenAgents.push(maybeAgent);
}
}
} else if ('kuery' in options) {
const batchSize = options.batchSize ?? SO_SEARCH_LIMIT;

const filters = [];
const namespaceFilter = agentsKueryNamespaceFilter(currentNameSpace);
const filters = namespaceFilter ? [namespaceFilter] : [];
if (options.kuery !== '') {
filters.push(options.kuery);
}
Expand Down
Loading