[Fleet] Logstash Output - prevent updating data_output_id for preconfigured policies (#154445)

Closes https://github.com/elastic/kibana/issues/154326

## Summary

After the merge of https://github.com/elastic/kibana/pull/153226, when
creating/updating a Logstash output as default, the `Elastic Cloud agent
policy` preconfigured on Cloud gets reassigned to the "default" ES
policy instead than keeping the `Elastic Cloud internal output`.

<img width="1418" alt="Screenshot 2023-04-04 at 12 51 21"
src="https://user-images.githubusercontent.com/16084106/230112067-a2767d1a-1191-4877-8dec-546d1590e41f.png">

Tee bug is fixed by checking if any given fleet server policy is
`preconfigured` or if it has already an assigned `data_output_id`, in
which cases it doesn't get updated.

### Testing
- Create an ES output additional to the default one
- Create a preconfigured fleet server policy and make sure it has
`fleet-server` integration (this is to simulate the preconfigured cloud
policy)
- Assign the previous output to the preconfigured policy
- Now create a new `logstash` output and make it default
- Check that the preconfigured policy maintains the custom output
previously assigned

### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Nicolas Chaulet <nicolas.chaulet@elastic.co>
This commit is contained in:
Cristina Amico 2023-04-11 20:38:17 +02:00 committed by GitHub
parent 2636262e09
commit a5de314687
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 196 additions and 53 deletions

View file

@ -189,6 +189,7 @@ describe('Output Service', () => {
mockedAppContextService.getInternalUserSOClient.mockReset();
mockedAppContextService.getEncryptedSavedObjectsSetup.mockReset();
mockedAuditLoggingService.writeCustomSoAuditLog.mockReset();
mockedAgentPolicyService.update.mockReset();
});
describe('create', () => {
it('work with a predefined id', async () => {
@ -443,7 +444,7 @@ describe('Output Service', () => {
expect.anything(),
'fleet_server_policy',
{ data_output_id: 'output-test' },
{ force: true }
{ force: false }
);
});
@ -497,14 +498,6 @@ describe('Output Service', () => {
},
{ id: 'output-1' }
);
expect(mockedAgentPolicyService.update).toBeCalledWith(
expect.anything(),
expect.anything(),
'fleet_server_policy',
{ data_output_id: 'output-test' },
{ force: true }
);
});
it('should call audit logger', async () => {
@ -798,6 +791,72 @@ describe('Output Service', () => {
is_default: true,
});
expect(soClient.update).toBeCalledWith(expect.anything(), expect.anything(), {
type: 'logstash',
hosts: ['test:4343'],
is_default: true,
ca_sha256: null,
ca_trusted_fingerprint: null,
});
expect(mockedAgentPolicyService.update).toBeCalledWith(
expect.anything(),
expect.anything(),
'fleet_server_policy',
{ data_output_id: 'output-test' },
{ force: false }
);
});
it('Should update fleet server policies with data_output_id=default_output_id and force=true if a default ES output is changed to logstash, from preconfiguration', async () => {
const soClient = getMockedSoClient({
defaultOutputId: 'output-test',
});
mockedAgentPolicyService.list.mockResolvedValue({
items: [
{
name: 'fleet server policy',
id: 'fleet_server_policy',
is_default_fleet_server: true,
package_policies: [
{
name: 'fleet-server-123',
package: {
name: 'fleet_server',
},
},
],
},
{
name: 'agent policy 1',
id: 'agent_policy_1',
is_managed: false,
package_policies: [
{
name: 'nginx',
package: {
name: 'nginx',
},
},
],
},
],
} as unknown as ReturnType<typeof mockedAgentPolicyService.list>);
mockedAgentPolicyService.hasFleetServerIntegration.mockReturnValue(true);
await outputService.update(
soClient,
esClientMock,
'output-test',
{
type: 'logstash',
hosts: ['test:4343'],
is_default: true,
},
{
fromPreconfiguration: true,
}
);
expect(soClient.update).toBeCalledWith(expect.anything(), expect.anything(), {
type: 'logstash',
hosts: ['test:4343'],

View file

@ -165,7 +165,8 @@ async function validateTypeChanges(
id: string,
data: Partial<Output>,
originalOutput: Output,
defaultDataOutputId: string | null
defaultDataOutputId: string | null,
fromPreconfiguration: boolean
) {
const mergedIsDefault = data.is_default ?? originalOutput.is_default;
const fleetServerPolicies = await findPoliciesWithFleetServer(soClient, id, mergedIsDefault);
@ -178,18 +179,42 @@ async function validateTypeChanges(
// Validate no policy with fleet server use that policy
validateLogstashOutputNotUsedInFleetServerPolicy(fleetServerPolicies);
}
// if a logstash output is updated to become default, update the fleet server policies to use the previous ES output or default output
if (data?.type === outputType.Logstash && mergedIsDefault) {
await updateFleetServerPoliciesDataOutputId(
soClient,
esClient,
data,
mergedIsDefault,
defaultDataOutputId,
fleetServerPolicies,
fromPreconfiguration
);
}
async function updateFleetServerPoliciesDataOutputId(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
data: Partial<Output>,
isDefault: boolean,
defaultDataOutputId: string | null,
fleetServerPolicies: AgentPolicy[],
fromPreconfiguration: boolean
) {
// if a logstash output is updated to become default
// if fleet server policies are don't have data_output_id or if they are using the new output
// update them to use the default output
if (data?.type === outputType.Logstash && isDefault) {
for (const policy of fleetServerPolicies) {
await agentPolicyService.update(
soClient,
esClient,
policy.id,
{ data_output_id: defaultDataOutputId },
{
force: true,
}
);
if (!policy.data_output_id || policy.data_output_id === data?.id) {
await agentPolicyService.update(
soClient,
esClient,
policy.id,
{
data_output_id: defaultDataOutputId,
},
{ force: fromPreconfiguration }
);
}
}
}
}
@ -302,6 +327,7 @@ class OutputService {
options?: { id?: string; fromPreconfiguration?: boolean; overwrite?: boolean }
): Promise<Output> {
const data: OutputSOAttributes = { ...omit(output, 'ssl') };
const defaultDataOutputId = await this.getDefaultDataOutputId(soClient);
if (output.type === outputType.Logstash) {
await validateLogstashOutputNotUsedInAPMPolicy(soClient, undefined, data.is_default);
@ -311,34 +337,24 @@ class OutputService {
);
}
}
if (data.type === outputType.Logstash) {
const defaultDataOutputId = await this.getDefaultDataOutputId(soClient);
const fleetServerPolicies = await findPoliciesWithFleetServer(soClient);
// if a logstash output is updated to become default, update the fleet server policies to use the previous ES output or default output
if (data.is_default) {
for (const policy of fleetServerPolicies) {
await agentPolicyService.update(
soClient,
esClient,
policy.id,
{ data_output_id: defaultDataOutputId },
{
force: true,
}
);
}
}
}
const fleetServerPolicies = await findPoliciesWithFleetServer(soClient);
await updateFleetServerPoliciesDataOutputId(
soClient,
esClient,
data,
data.is_default,
defaultDataOutputId,
fleetServerPolicies,
options?.fromPreconfiguration ?? false
);
// ensure only default output exists
if (data.is_default) {
const defaultDataOuputId = await this.getDefaultDataOutputId(soClient);
if (defaultDataOuputId) {
if (defaultDataOutputId) {
await this.update(
soClient,
esClient,
defaultDataOuputId,
defaultDataOutputId,
{ is_default: false },
{ fromPreconfiguration: options?.fromPreconfiguration ?? false }
);
@ -549,7 +565,15 @@ class OutputService {
const mergedType = data.type ?? originalOutput.type;
const defaultDataOutputId = await this.getDefaultDataOutputId(soClient);
await validateTypeChanges(soClient, esClient, id, data, originalOutput, defaultDataOutputId);
await validateTypeChanges(
soClient,
esClient,
id,
data,
originalOutput,
defaultDataOutputId,
fromPreconfiguration
);
// If the output type changed
if (data.type && data.type !== originalOutput.type) {

View file

@ -27,6 +27,9 @@ export default function (providerContext: FtrProviderContext) {
setupFleetAndAgents(providerContext);
let defaultOutputId: string;
let ESOutputId: string;
let fleetServerPolicyId: string;
let fleetServerPolicyWithCustomOutputId: string;
before(async function () {
// we must first force install the fleet_server package to override package verification error on policy create
@ -53,6 +56,7 @@ export default function (providerContext: FtrProviderContext) {
})
.expect(200);
const fleetServerPolicy = apiResponse.item;
fleetServerPolicyId = fleetServerPolicy.id;
({ body: apiResponse } = await supertest
.post(`/api/fleet/agent_policies`)
@ -92,6 +96,30 @@ export default function (providerContext: FtrProviderContext) {
}
defaultOutputId = defaultOutput.id;
const { body: postResponse1 } = await supertest
.post(`/api/fleet/outputs`)
.set('kbn-xsrf', 'xxxx')
.send({
name: 'ESoutput',
type: 'elasticsearch',
hosts: ['https://test.fr'],
})
.expect(200);
ESOutputId = postResponse1.item.id;
({ body: apiResponse } = await supertest
.post(`/api/fleet/agent_policies`)
.set('kbn-xsrf', 'kibana')
.send({
name: 'Preconfigured Fleet Server policy',
namespace: 'default',
has_fleet_server: true,
data_output_id: `${ESOutputId}`,
})
.expect(200));
const fleetServerPolicyWithCustomOutput = apiResponse.item;
fleetServerPolicyWithCustomOutputId = fleetServerPolicyWithCustomOutput.id;
});
after(async () => {
@ -100,10 +128,12 @@ export default function (providerContext: FtrProviderContext) {
});
describe('GET /outputs', () => {
it('should list the default output', async () => {
it('should list all the outputs', async () => {
const { body: getOutputsRes } = await supertest.get(`/api/fleet/outputs`).expect(200);
expect(getOutputsRes.items.length).to.eql(1);
expect(getOutputsRes.items.length).to.eql(2);
const findDefault = getOutputsRes.items.find((item: any) => item.is_default === true);
expect(findDefault.id).to.eql(defaultOutputId);
});
});
@ -160,7 +190,7 @@ export default function (providerContext: FtrProviderContext) {
);
});
it('should allow to update a default ES output keeping it ES', async function () {
it('should allow to update a default ES output if keeping it ES', async function () {
await supertest
.put(`/api/fleet/outputs/${defaultOutputId}`)
.set('kbn-xsrf', 'xxxx')
@ -173,7 +203,7 @@ export default function (providerContext: FtrProviderContext) {
});
it('should allow to update a non-default ES output to logstash', async function () {
const { body: postResponse } = await supertest
const { body: postResponse2 } = await supertest
.post(`/api/fleet/outputs`)
.set('kbn-xsrf', 'xxxx')
.send({
@ -188,7 +218,7 @@ export default function (providerContext: FtrProviderContext) {
})
.expect(200);
const { id: logstashOutput1Id } = postResponse.item;
const { id: logstashOutput1Id } = postResponse2.item;
await supertest
.put(`/api/fleet/outputs/${logstashOutput1Id}`)
.set('kbn-xsrf', 'xxxx')
@ -203,9 +233,19 @@ export default function (providerContext: FtrProviderContext) {
},
})
.expect(200);
const { body } = await supertest.get(`/api/fleet/agent_policies/${fleetServerPolicyId}`);
const updatedFleetServerPolicy = body.item;
expect(updatedFleetServerPolicy.data_output_id === defaultOutputId);
const { body: bodyWithOutput } = await supertest.get(
`/api/fleet/agent_policies/${fleetServerPolicyWithCustomOutputId}`
);
const updatedFleetServerPolicyWithCustomOutput = bodyWithOutput.item;
expect(updatedFleetServerPolicyWithCustomOutput.data_output_id === ESOutputId);
});
it('should allow to update a default logstash output to logstash', async function () {
it('should allow to update a default logstash output to logstash and fleet server policies should be updated', async function () {
const { body: postResponse } = await supertest
.post(`/api/fleet/outputs`)
.set('kbn-xsrf', 'xxxx')
@ -238,6 +278,16 @@ export default function (providerContext: FtrProviderContext) {
.expect(200);
await supertest.get(`/api/fleet/outputs`).expect(200);
const { body } = await supertest.get(`/api/fleet/agent_policies/${fleetServerPolicyId}`);
const updatedFleetServerPolicy = body.item;
expect(updatedFleetServerPolicy.data_output_id === defaultOutputId);
const { body: bodyWithOutput } = await supertest.get(
`/api/fleet/agent_policies/${fleetServerPolicyWithCustomOutputId}`
);
const updatedFleetServerPolicyWithCustomOutput = bodyWithOutput.item;
expect(updatedFleetServerPolicyWithCustomOutput.data_output_id === ESOutputId);
});
it('should allow to update a logstash output with the shipper values', async function () {
@ -361,7 +411,7 @@ export default function (providerContext: FtrProviderContext) {
});
});
it('should allow to create a logstash output', async function () {
it('should allow to create a new logstash output', async function () {
const { body: postResponse } = await supertest
.post(`/api/fleet/outputs`)
.set('kbn-xsrf', 'xxxx')
@ -392,7 +442,7 @@ export default function (providerContext: FtrProviderContext) {
});
});
it('should allow to create a new logstash default output', async function () {
it('should allow to create a new logstash default output and fleet server policies should not change', async function () {
const { body: postResponse } = await supertest
.post(`/api/fleet/outputs`)
.set('kbn-xsrf', 'xxxx')
@ -422,6 +472,16 @@ export default function (providerContext: FtrProviderContext) {
certificate_authorities: ['CA1', 'CA2'],
},
});
const { body } = await supertest.get(`/api/fleet/agent_policies/${fleetServerPolicyId}`);
const updatedFleetServerPolicy = body.item;
expect(updatedFleetServerPolicy.data_output_id === defaultOutputId);
const { body: bodyWithOutput } = await supertest.get(
`/api/fleet/agent_policies/${fleetServerPolicyWithCustomOutputId}`
);
const updatedFleetServerPolicyWithCustomOutput = bodyWithOutput.item;
expect(updatedFleetServerPolicyWithCustomOutput.data_output_id === ESOutputId);
});
it('should not allow to create a logstash output with http hosts ', async function () {
@ -445,7 +505,7 @@ export default function (providerContext: FtrProviderContext) {
);
});
it('should toggle default output when creating a new default output ', async function () {
it('should toggle the default output when creating a new one', async function () {
await supertest
.post(`/api/fleet/outputs`)
.set('kbn-xsrf', 'xxxx')