[Cloud Security] Add is_internal flag to fleet server hosts configuration (#175983)

## Summary

- Follow up after https://github.com/elastic/kibana/pull/175546
- Part of https://github.com/elastic/kibana/issues/165251

introducing a new `is_internal` config option for
`xpack.fleet.fleetServerHosts`. The usage is currently to protect the
internal fleet server hosts in the UI:

- filter them out in the Settings UI
- disable internal hosts in the agent policy form



### Checklist

Delete any items that are not applicable to this PR.

- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: David Kilfoyle <41695641+kilfoyle@users.noreply.github.com>
This commit is contained in:
Maxim Kholod 2024-02-02 17:09:22 +01:00 committed by GitHub
parent 5c00ddf1ef
commit 2b929cafeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 217 additions and 11 deletions

View file

@ -269,6 +269,8 @@ List of {fleet-server} hosts that are configured when the {fleet} app starts.
=====
`is_default`:::
Whether or not this host should be the default to use for {fleet-server}.
`is_internal`:::
If `true` the host will not appear in the UI, and can only be managed through `kibana.yml` or the {fleet} API.
`proxy_id`:::
Unique ID of the proxy to access the {fleet-server} host.
=====

View file

@ -632,6 +632,7 @@
"fleet-fleet-server-host": [
"host_urls",
"is_default",
"is_internal",
"is_preconfigured",
"name",
"proxy_id"

View file

@ -2091,6 +2091,10 @@
"is_default": {
"type": "boolean"
},
"is_internal": {
"type": "boolean",
"index": false
},
"host_urls": {
"type": "keyword",
"index": false

View file

@ -94,7 +94,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"file": "6b65ae5899b60ebe08656fd163ea532e557d3c98",
"file-upload-usage-collection-telemetry": "06e0a8c04f991e744e09d03ab2bd7f86b2088200",
"fileShare": "5be52de1747d249a221b5241af2838264e19aaa1",
"fleet-fleet-server-host": "b04898fcde07f4ce86e844c8fe2f4b23b77ef60a",
"fleet-fleet-server-host": "69be15f6b6f2a2875ad3c7050ddea7a87f505417",
"fleet-message-signing-keys": "93421f43fed2526b59092a4e3c65d64bc2266c0f",
"fleet-preconfiguration-deletion-record": "c52ea1e13c919afe8a5e8e3adbb7080980ecc08e",
"fleet-proxy": "6cb688f0d2dd856400c1dbc998b28704ff70363d",

View file

@ -5083,6 +5083,9 @@
"is_default": {
"type": "boolean"
},
"is_internal": {
"type": "boolean"
},
"host_urls": {
"type": "array",
"items": {
@ -5195,6 +5198,9 @@
"is_default": {
"type": "boolean"
},
"is_internal": {
"type": "boolean"
},
"host_urls": {
"type": "array",
"items": {
@ -8904,6 +8910,9 @@
"is_default": {
"type": "boolean"
},
"is_internal": {
"type": "boolean"
},
"is_preconfigured": {
"type": "boolean"
},

View file

@ -3171,6 +3171,8 @@ paths:
type: string
is_default:
type: boolean
is_internal:
type: boolean
host_urls:
type: array
items:
@ -3241,6 +3243,8 @@ paths:
type: string
is_default:
type: boolean
is_internal:
type: boolean
host_urls:
type: array
items:
@ -5762,6 +5766,8 @@ components:
type: string
is_default:
type: boolean
is_internal:
type: boolean
is_preconfigured:
type: boolean
host_urls:

View file

@ -7,6 +7,8 @@ properties:
type: string
is_default:
type: boolean
is_internal:
type: boolean
is_preconfigured:
type: boolean
host_urls:

View file

@ -51,6 +51,8 @@ post:
type: string
is_default:
type: boolean
is_internal:
type: boolean
host_urls:
type: array
items:

View file

@ -59,6 +59,8 @@ put:
type: string
is_default:
type: boolean
is_internal:
type: boolean
host_urls:
type: array
items:

View file

@ -11,6 +11,7 @@ export interface NewFleetServerHost {
host_urls: string[];
is_default: boolean;
is_preconfigured: boolean;
is_internal?: boolean;
proxy_id?: string | null;
}

View file

@ -19,6 +19,7 @@ export interface PutFleetServerHostsRequest {
name?: string;
host_urls?: string[];
is_default?: boolean;
is_internal?: boolean;
proxy_id?: string | null;
};
}
@ -29,6 +30,7 @@ export interface PostFleetServerHostsRequest {
name?: string;
host_urls?: string[];
is_default?: boolean;
is_internal?: boolean;
proxy_id?: string | null;
};
}

View file

@ -28,6 +28,13 @@ describe('Edit settings', () => {
host_urls: ['https://localhost:8220'],
is_default: true,
},
{
id: 'fleet-internal-host',
name: 'Internal Host',
host_urls: ['https://internal:8220'],
is_default: false,
is_internal: true,
},
],
page: 1,
perPage: 10000,
@ -160,4 +167,8 @@ describe('Edit settings', () => {
expect(interception.request.body.name).to.equal('output-logstash-1');
});
});
it('should not display internal fleet server hosts', () => {
cy.getBySel(SETTINGS_FLEET_SERVER_HOSTS.TABLE).should('not.contain', 'Internal Host');
});
});

View file

@ -190,6 +190,7 @@ export const SETTINGS_OUTPUTS_KAFKA = {
export const SETTINGS_FLEET_SERVER_HOSTS = {
ADD_BUTTON: 'settings.fleetServerHosts.addFleetServerHostBtn',
EDIT_BUTTON: 'fleetServerHostsTable.edit.btn',
TABLE: 'settingsFleetServerHostsTable',
};
export const AGENT_POLICY_FORM = {

View file

@ -11,7 +11,7 @@ import { useLicense } from '../../../../../../hooks/use_license';
import type { LicenseService } from '../../../../services';
import type { AgentPolicy } from '../../../../types';
import { useOutputOptions } from './hooks';
import { useOutputOptions, useFleetServerHostsOptions } from './hooks';
jest.mock('../../../../../../hooks/use_license');
@ -153,6 +153,35 @@ const mockApiCallsWithInternalOutputs = (http: MockedFleetStartServices['http'])
});
};
const mockApiCallsWithInternalFleetServerHost = (http: MockedFleetStartServices['http']) => {
http.get.mockImplementation(async (path) => {
if (typeof path !== 'string') {
throw new Error('Invalid request');
}
if (path === '/api/fleet/fleet_server_hosts') {
return {
data: {
items: [
{
id: 'default-host',
name: 'Default',
is_default: true,
},
{
id: 'internal-output',
name: 'Internal',
is_default: false,
is_internal: true,
},
],
},
};
}
return defaultHttpClientGetImplementation(path);
});
};
describe('useOutputOptions', () => {
it('should generate enabled options if the licence is platinium', async () => {
const testRenderer = createFleetTestRendererMock();
@ -636,3 +665,30 @@ describe('useOutputOptions', () => {
`);
});
});
describe('useFleetServerHostsOptions', () => {
it('should not enable internal fleet server hosts', async () => {
const testRenderer = createFleetTestRendererMock();
mockApiCallsWithInternalFleetServerHost(testRenderer.startServices.http);
const { result, waitForNextUpdate } = testRenderer.renderHook(() =>
useFleetServerHostsOptions({} as AgentPolicy)
);
expect(result.current.isLoading).toBeTruthy();
await waitForNextUpdate();
expect(result.current.fleetServerHostsOptions).toMatchInlineSnapshot(`
Array [
Object {
"disabled": undefined,
"inputDisplay": "Default (currently Default)",
"value": "@@##DEFAULT_SELECT##@@",
},
Object {
"disabled": true,
"inputDisplay": "Internal",
"value": "internal-output",
},
]
`);
});
});

View file

@ -224,9 +224,12 @@ export function useFleetServerHostsOptions(agentPolicy: Partial<NewAgentPolicy |
...fleetServerHostsRequest.data.items
.filter((item) => !item.is_default)
.map((item) => {
const isInternalFleetServerHost = !!item.is_internal;
return {
value: item.id,
inputDisplay: item.name,
disabled: isInternalFleetServerHost,
};
}),
];

View file

@ -152,5 +152,11 @@ export const FleetServerHostsTable: React.FunctionComponent<FleetServerHostsTabl
];
}, [getHref, deleteFleetServerHost]);
return <EuiBasicTable columns={columns} items={fleetServerHosts} />;
return (
<EuiBasicTable
columns={columns}
items={fleetServerHosts}
data-test-subj="settingsFleetServerHostsTable"
/>
);
};

View file

@ -50,6 +50,7 @@ export const SettingsApp = withConfirmModalProvider(() => {
const { outputs, fleetServerHosts, downloadSources, proxies } = useSettingsAppData();
const outputItems = outputs.data?.items.filter((item) => !item.is_internal);
const fleetServerHostsItems = fleetServerHosts.data?.items.filter((item) => !item.is_internal);
const { deleteOutput } = useDeleteOutput(outputs.resendRequest);
const { deleteDownloadSource } = useDeleteDownloadSource(downloadSources.resendRequest);
@ -81,7 +82,7 @@ export const SettingsApp = withConfirmModalProvider(() => {
(outputs.isLoading && outputs.isInitialRequest) ||
!outputItems ||
(fleetServerHosts.isLoading && fleetServerHosts.isInitialRequest) ||
!fleetServerHosts.data?.items ||
!fleetServerHostsItems ||
(downloadSources.isLoading && downloadSources.isInitialRequest) ||
!downloadSources.data?.items ||
(proxies.isLoading && proxies.isInitialRequest) ||
@ -99,7 +100,7 @@ export const SettingsApp = withConfirmModalProvider(() => {
<Routes>
<Route path={FLEET_ROUTING_PATHS.settings_edit_fleet_server_hosts}>
{(route: { match: { params: { itemId: string } } }) => {
const fleetServerHost = fleetServerHosts.data?.items.find(
const fleetServerHost = fleetServerHostsItems.find(
(o) => route.match.params.itemId === o.id
);
if (!fleetServerHost) {
@ -198,7 +199,7 @@ export const SettingsApp = withConfirmModalProvider(() => {
deleteFleetProxy={deleteFleetProxy}
proxies={proxies.data.items}
outputs={outputItems}
fleetServerHosts={fleetServerHosts.data.items}
fleetServerHosts={fleetServerHostsItems}
deleteOutput={deleteOutput}
deleteFleetServerHost={deleteFleetServerHost}
downloadSources={downloadSources.data.items}

View file

@ -626,11 +626,24 @@ const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({
properties: {
name: { type: 'keyword' },
is_default: { type: 'boolean' },
is_internal: { type: 'boolean', index: false },
host_urls: { type: 'keyword', index: false },
is_preconfigured: { type: 'boolean' },
proxy_id: { type: 'keyword' },
},
},
modelVersions: {
'1': {
changes: [
{
type: 'mappings_addition',
addedMappings: {
is_internal: { type: 'boolean', index: false },
},
},
],
},
},
},
[FLEET_PROXY_SAVED_OBJECT_TYPE]: {
name: FLEET_PROXY_SAVED_OBJECT_TYPE,

View file

@ -4,20 +4,29 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import { savedObjectsClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { securityMock } from '@kbn/security-plugin/server/mocks';
import { appContextService } from '../app_context';
import { getDefaultFleetServerHost, createFleetServerHost } from '../fleet_server_host';
import {
getDefaultFleetServerHost,
createFleetServerHost,
bulkGetFleetServerHosts,
updateFleetServerHost,
} from '../fleet_server_host';
import {
createCloudFleetServerHostIfNeeded,
getCloudFleetServersHosts,
getPreconfiguredFleetServerHostFromConfig,
createOrUpdatePreconfiguredFleetServerHosts,
} from './fleet_server_host';
import type { FleetServerHost } from '../../../common/types';
jest.mock('../fleet_server_host');
jest.mock('../app_context');
jest.mock('../agent_policy');
const mockedAppContextService = appContextService as jest.Mocked<typeof appContextService>;
mockedAppContextService.getSecuritySetup.mockImplementation(() => ({
@ -30,6 +39,12 @@ const mockedGetDefaultFleetServerHost = getDefaultFleetServerHost as jest.Mocked
const mockedCreateFleetServerHost = createFleetServerHost as jest.MockedFunction<
typeof createFleetServerHost
>;
const mockedUpdateFleetServerHost = updateFleetServerHost as jest.MockedFunction<
typeof updateFleetServerHost
>;
const mockedBulkGetFleetServerHosts = bulkGetFleetServerHosts as jest.MockedFunction<
typeof bulkGetFleetServerHosts
>;
describe('getPreconfiguredFleetServerHostFromConfig', () => {
it('should work with preconfigured fleetServerHosts', () => {
@ -85,6 +100,30 @@ describe('getPreconfiguredFleetServerHostFromConfig', () => {
expect(res.map(({ id }) => id)).toEqual(['fleet-123', 'fleet-default-fleet-server-host']);
});
it('should work with preconfigured internal fleetServerHosts', () => {
const config = {
fleetServerHosts: [
{
id: 'fleet-123',
name: 'TEST',
is_default: true,
host_urls: ['http://test.fr'],
},
{
id: 'fleet-internal',
name: 'TEST_INTERNAL',
is_default: false,
is_internal: true,
host_urls: ['http://test-internal.fr'],
},
],
};
const res = getPreconfiguredFleetServerHostFromConfig(config);
expect(res).toEqual(config.fleetServerHosts);
});
it('should throw if there is multiple default outputs', () => {
const config = {
agents: { fleet_server: { hosts: ['http://test.fr'] } },
@ -174,10 +213,8 @@ describe('getCloudFleetServersHosts', () => {
});
describe('createCloudFleetServerHostIfNeeded', () => {
beforeEach(() => {
mockedCreateFleetServerHost.mockReset();
});
afterEach(() => {
mockedCreateFleetServerHost.mockReset();
mockedAppContextService.getCloud.mockReset();
});
it('should do nothing if there is no cloud fleet server hosts', async () => {
@ -243,3 +280,45 @@ describe('createCloudFleetServerHostIfNeeded', () => {
);
});
});
describe('createOrUpdatePreconfiguredFleetServerHosts', () => {
beforeEach(() => {
mockedBulkGetFleetServerHosts.mockResolvedValue([
{
id: 'fleet-123',
name: 'TEST',
is_default: true,
host_urls: ['http://test.fr'],
},
{
id: 'fleet-internal',
name: 'TEST_INTERNAL',
is_default: false,
is_internal: false,
host_urls: ['http://test-internal.fr'],
},
] as FleetServerHost[]);
});
afterEach(() => {
mockedBulkGetFleetServerHosts.mockReset();
});
it('should update preconfigured fleet server hosts if is_internal flag changes', async () => {
const soClient = savedObjectsClientMock.create();
const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
await createOrUpdatePreconfiguredFleetServerHosts(soClient, esClient, [
{
id: 'fleet-internal',
name: 'TEST_INTERNAL',
is_default: false,
is_internal: true,
host_urls: ['http://test-internal.fr'],
is_preconfigured: false,
},
]);
expect(mockedCreateFleetServerHost).not.toBeCalled();
expect(mockedUpdateFleetServerHost).toBeCalled();
});
});

View file

@ -110,6 +110,7 @@ export async function createOrUpdatePreconfiguredFleetServerHosts(
(!existingHost.is_preconfigured ||
existingHost.is_default !== preconfiguredFleetServerHost.is_default ||
existingHost.name !== preconfiguredFleetServerHost.name ||
isDifferent(existingHost.is_internal, preconfiguredFleetServerHost.is_internal) ||
isDifferent(
existingHost.host_urls.map(normalizeHostsForAgents),
preconfiguredFleetServerHost.host_urls.map(normalizeHostsForAgents)

View file

@ -103,6 +103,7 @@ export const PreconfiguredFleetServerHostsSchema = schema.arrayOf(
id: schema.string(),
name: schema.string(),
is_default: schema.boolean({ defaultValue: false }),
is_internal: schema.maybe(schema.boolean()),
host_urls: schema.arrayOf(schema.string(), { minSize: 1 }),
proxy_id: schema.nullable(schema.string()),
}),

View file

@ -13,6 +13,7 @@ export const PostFleetServerHostRequestSchema = {
name: schema.string(),
host_urls: schema.arrayOf(schema.string(), { minSize: 1 }),
is_default: schema.boolean({ defaultValue: false }),
is_internal: schema.maybe(schema.boolean()),
proxy_id: schema.nullable(schema.string()),
}),
};
@ -27,6 +28,7 @@ export const PutFleetServerHostRequestSchema = {
name: schema.maybe(schema.string()),
host_urls: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })),
is_default: schema.maybe(schema.boolean({ defaultValue: false })),
is_internal: schema.maybe(schema.boolean()),
proxy_id: schema.nullable(schema.string()),
}),
};

View file

@ -104,6 +104,7 @@ export interface FleetServerHostSOAttributes {
host_urls: string[];
is_default: boolean;
is_preconfigured: boolean;
is_internal?: boolean;
proxy_id?: string | null;
}