[Fleet] Only enable secret storage once all fleet servers are above 8.10.0 (#163627)

## Summary

Closes #157456

Secret storage requires that fleet servers are 8.10.0 or above. 

This PR adds a backend check that all fleet servers are above 8.10.0
before enabling secrets storage. Once all fleet servers are above that
version, secrets are permanently enabled.

the fleet server check checks all agents in policies that contain the
fleet server package.

A flag on the`ingest_manager_settings` saved. object
`secret_storage_requirements_met` is used to make a note that the check
has previously passed, meaning we don't have to keep querying the agents
and policies.

Test scenarios (all covered by integration tests) : 

- given a deployment with no fleet servers connected, on creating a
package policy with secret variables, the values should be stored in
plain text not as a secret reference
- given a deployment with at least one fleet server that is below
8.10.0, on creating a package policy with secret variables, the values
should be stored in plain text not as a secret reference
- given a deployment where all fleet servers are 8.10.0 or above,
secrets should be stored as secret references and in the secrets index
- if a package policy was created before secrets were enabled, and since
its creation the fleet server versions pass the check, when updating
that policy, all secrets should move to being secret references.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Julia Bardi <julia.bardi@elastic.co>
Co-authored-by: Julia Bardi <90178898+juliaElastic@users.noreply.github.com>
This commit is contained in:
Mark Hopkin 2023-08-14 08:02:03 +01:00 committed by GitHub
parent 5dd5ec2182
commit 56be6c6fb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 404 additions and 12 deletions

View file

@ -1505,6 +1505,9 @@
},
"prerelease_integrations_enabled": {
"type": "boolean"
},
"secret_storage_requirements_met": {
"type": "boolean"
}
}
},

View file

@ -107,7 +107,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"ingest-download-sources": "d7edc5e588d9afa61c4b831604582891c54ef1c7",
"ingest-outputs": "b4e636b13a5d0f89f0400fb67811d4cca4736eb0",
"ingest-package-policies": "55816507db0134b8efbe0509e311a91ce7e1c6cc",
"ingest_manager_settings": "418311b03c8eda53f5d2ea6f54c1356afaa65511",
"ingest_manager_settings": "64955ef1b7a9ffa894d4bb9cf863b5602bfa6885",
"inventory-view": "b8683c8e352a286b4aca1ab21003115a4800af83",
"kql-telemetry": "93c1d16c1a0dfca9c8842062cf5ef8f62ae401ad",
"legacy-url-alias": "9b8cca3fbb2da46fd12823d3cd38fdf1c9f24bc8",

View file

@ -6,3 +6,5 @@
*/
export const SECRETS_ENDPOINT_PATH = '/_fleet/secret';
export const SECRETS_MINIMUM_FLEET_SERVER_VERSION = '8.10.0';

View file

@ -14,4 +14,5 @@ export interface BaseSettings {
export interface Settings extends BaseSettings {
id: string;
preconfigured_fields?: Array<'fleet_server_hosts'>;
secret_storage_requirements_met?: boolean;
}

View file

@ -79,6 +79,7 @@ export {
MESSAGE_SIGNING_SERVICE_API_ROUTES,
// secrets
SECRETS_ENDPOINT_PATH,
SECRETS_MINIMUM_FLEET_SERVER_VERSION,
} from '../../common/constants';
export {

View file

@ -90,6 +90,7 @@ const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({
fleet_server_hosts: { type: 'keyword' },
has_seen_add_data_notice: { type: 'boolean', index: false },
prerelease_integrations_enabled: { type: 'boolean' },
secret_storage_requirements_met: { type: 'boolean' },
},
},
migrations: {

View file

@ -444,6 +444,66 @@ export async function getAgentsById(
);
}
// given a list of agentPolicyIds, return a map of agent version => count of agents
// this is used to get all fleet server versions
export async function getAgentVersionsForAgentPolicyIds(
esClient: ElasticsearchClient,
agentPolicyIds: string[]
): Promise<Record<string, number>> {
const versionCount: Record<string, number> = {};
if (!agentPolicyIds.length) {
return versionCount;
}
try {
const res = esClient.search<
FleetServerAgent,
Record<'agent_versions', { buckets: Array<{ key: string; doc_count: number }> }>
>({
size: 0,
track_total_hits: false,
body: {
query: {
bool: {
filter: [
{
terms: {
policy_id: agentPolicyIds,
},
},
],
},
},
aggs: {
agent_versions: {
terms: {
field: 'local_metadata.elastic.agent.version.keyword',
size: 1000,
},
},
},
},
index: AGENTS_INDEX,
ignore_unavailable: true,
});
const { aggregations } = await res;
if (aggregations && aggregations.agent_versions) {
aggregations.agent_versions.buckets.forEach((bucket) => {
versionCount[bucket.key] = bucket.doc_count;
});
}
} catch (error) {
if (error.statusCode !== 404) {
throw error;
}
}
return versionCount;
}
export async function getAgentByAccessAPIKeyId(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,

View file

@ -5,10 +5,14 @@
* 2.0.
*/
import type { ElasticsearchClient } from '@kbn/core/server';
import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
import semverGte from 'semver/functions/gte';
import semverCoerce from 'semver/functions/coerce';
import { FLEET_SERVER_SERVERS_INDEX } from '../../constants';
import { getAgentVersionsForAgentPolicyIds } from '../agents';
import { packagePolicyService } from '../package_policy';
/**
* Check if at least one fleet server is connected
*/
@ -23,3 +27,42 @@ export async function hasFleetServers(esClient: ElasticsearchClient) {
return (res.hits.total as number) > 0;
}
export async function allFleetServerVersionsAreAtLeast(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
version: string
): Promise<boolean> {
let hasMore = true;
const policyIds = new Set<string>();
let page = 1;
while (hasMore) {
const res = await packagePolicyService.list(soClient, {
page: page++,
perPage: 20,
kuery: 'ingest-package-policies.package.name:fleet_server',
});
for (const item of res.items) {
policyIds.add(item.policy_id);
}
if (res.items.length === 0) {
hasMore = false;
}
}
const versionCounts = await getAgentVersionsForAgentPolicyIds(esClient, [...policyIds]);
const versions = Object.keys(versionCounts);
// there must be at least one fleet server agent for this check to pass
if (versions.length === 0) {
return false;
}
return _allVersionsAreAtLeast(version, versions);
}
function _allVersionsAreAtLeast(version: string, versions: string[]) {
return versions.every((v) => semverGte(semverCoerce(v)!, version));
}

View file

@ -117,6 +117,7 @@ import {
extractAndUpdateSecrets,
extractAndWriteSecrets,
deleteSecretsIfNotReferenced as deleteSecrets,
isSecretStorageEnabled,
} from './secrets';
export type InputsOverride = Partial<NewPackagePolicyInput> & {
@ -243,8 +244,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
}
validatePackagePolicyOrThrow(enrichedPackagePolicy, pkgInfo);
const { secretsStorage: secretsStorageEnabled } = appContextService.getExperimentalFeatures();
if (secretsStorageEnabled) {
if (await isSecretStorageEnabled(esClient, soClient)) {
const secretsRes = await extractAndWriteSecrets({
packagePolicy: { ...enrichedPackagePolicy, inputs },
packageInfo: pkgInfo,
@ -747,8 +747,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
});
validatePackagePolicyOrThrow(packagePolicy, pkgInfo);
const { secretsStorage: secretsStorageEnabled } = appContextService.getExperimentalFeatures();
if (secretsStorageEnabled) {
if (await isSecretStorageEnabled(esClient, soClient)) {
const secretsRes = await extractAndUpdateSecrets({
oldPackagePolicy,
packagePolicyUpdate: { ...restOfPackagePolicy, inputs },
@ -913,9 +912,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
);
if (pkgInfo) {
validatePackagePolicyOrThrow(packagePolicy, pkgInfo);
const { secretsStorage: secretsStorageEnabled } =
appContextService.getExperimentalFeatures();
if (secretsStorageEnabled) {
if (await isSecretStorageEnabled(esClient, soClient)) {
const secretsRes = await extractAndUpdateSecrets({
oldPackagePolicy,
packagePolicyUpdate: { ...restOfPackagePolicy, inputs },

View file

@ -34,7 +34,7 @@ import type {
} from '../types';
import { FleetError } from '../errors';
import { SECRETS_ENDPOINT_PATH } from '../constants';
import { SECRETS_ENDPOINT_PATH, SECRETS_MINIMUM_FLEET_SERVER_VERSION } from '../constants';
import { retryTransientEsErrors } from './epm/elasticsearch/retry';
@ -42,6 +42,8 @@ import { auditLoggingService } from './audit_logging';
import { appContextService } from './app_context';
import { packagePolicyService } from './package_policy';
import { settingsService } from '.';
import { allFleetServerVersionsAreAtLeast } from './fleet_server';
export async function createSecrets(opts: {
esClient: ElasticsearchClient;
@ -270,10 +272,21 @@ export async function extractAndUpdateSecrets(opts: {
...createdSecrets.map(({ id }) => ({ id })),
];
const secretsToDelete: PolicySecretReference[] = [];
toDelete.forEach((secretPath) => {
// check if the previous secret is actually a secret refrerence
// it may be that secrets were not enabled at the time of creation
// in which case they are just stored as plain text
if (secretPath.value.value.isSecretRef) {
secretsToDelete.push({ id: secretPath.value.value.id });
}
});
return {
packagePolicyUpdate: policyWithSecretRefs,
secretReferences,
secretsToDelete: toDelete.map((secretPath) => ({ id: secretPath.value.value.id })),
secretsToDelete,
};
}
@ -344,6 +357,58 @@ export function getPolicySecretPaths(
return [...packageLevelVarPaths, ...inputSecretPaths];
}
export async function isSecretStorageEnabled(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract
): Promise<boolean> {
const logger = appContextService.getLogger();
// first check if the feature flag is enabled, if not secrets are disabled
const { secretsStorage: secretsStorageEnabled } = appContextService.getExperimentalFeatures();
if (!secretsStorageEnabled) {
logger.debug('Secrets storage is disabled by feature flag');
return false;
}
// if serverless then secrets will always be supported
const isFleetServerStandalone =
appContextService.getConfig()?.internal?.fleetServerStandalone ?? false;
if (isFleetServerStandalone) {
logger.trace('Secrets storage is enabled as fleet server is standalone');
return true;
}
// now check the flag in settings to see if the fleet server requirement has already been met
// once the requirement has been met, secrets are always on
const settings = await settingsService.getSettings(soClient);
if (settings.secret_storage_requirements_met) {
logger.debug('Secrets storage already met, turned on is settings');
return true;
}
// otherwise check if we have the minimum fleet server version and enable secrets if so
if (
await allFleetServerVersionsAreAtLeast(esClient, soClient, SECRETS_MINIMUM_FLEET_SERVER_VERSION)
) {
logger.debug('Enabling secrets storage as minimum fleet server version has been met');
try {
await settingsService.saveSettings(soClient, {
secret_storage_requirements_met: true,
});
} catch (err) {
// we can suppress this error as it will be retried on the next function call
logger.warn(`Failed to save settings after enabling secrets storage: ${err.message}`);
}
return true;
}
logger.info('Secrets storage is disabled as minimum fleet server version has not been met');
return false;
}
function _getPackageLevelSecretPaths(
packagePolicy: NewPackagePolicy,
packageInfo: PackageInfo

View file

@ -202,6 +202,7 @@ export interface SettingsSOAttributes {
prerelease_integrations_enabled: boolean;
has_seen_add_data_notice?: boolean;
fleet_server_hosts?: string[];
secret_storage_requirements_met?: boolean;
}
export interface DownloadSourceSOAttributes {

View file

@ -12,6 +12,7 @@
import type { Client } from '@elastic/elasticsearch';
import expect from '@kbn/expect';
import { FullAgentPolicy } from '@kbn/fleet-plugin/common';
import { GLOBAL_SETTINGS_SAVED_OBJECT_TYPE } from '@kbn/fleet-plugin/common/constants';
import { v4 as uuidv4 } from 'uuid';
import { FtrProviderContext } from '../../api_integration/ftr_provider_context';
import { skipIfNoDockerRegistry } from '../helpers';
@ -50,6 +51,126 @@ export default function (providerContext: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const createFleetServerAgentPolicy = async () => {
const agentPolicyResponse = await supertest
.post(`/api/fleet/agent_policies`)
.set('kbn-xsrf', 'xxx')
.send({
name: `Fleet server policy ${uuidv4()}`,
namespace: 'default',
})
.expect(200);
const agentPolicyId = agentPolicyResponse.body.item.id;
// create fleet_server package policy
await supertest
.post(`/api/fleet/package_policies`)
.set('kbn-xsrf', 'xxx')
.send({
force: true,
package: {
name: 'fleet_server',
version: '1.3.1',
},
name: `Fleet Server ${uuidv4()}`,
namespace: 'default',
policy_id: agentPolicyId,
vars: {},
inputs: {
'fleet_server-fleet-server': {
enabled: true,
vars: {
custom: '',
},
streams: {},
},
},
})
.expect(200);
return agentPolicyId;
};
const createPolicyWithSecrets = async () => {
return supertest
.post(`/api/fleet/package_policies`)
.set('kbn-xsrf', 'xxxx')
.send({
name: `secrets-${Date.now()}`,
description: '',
namespace: 'default',
policy_id: agentPolicyId,
inputs: {
'secrets-test_input': {
enabled: true,
vars: {
input_var_secret: 'input_secret_val',
},
streams: {
'secrets.log': {
enabled: true,
vars: {
stream_var_secret: 'stream_secret_val',
},
},
},
},
},
vars: {
package_var_secret: 'package_secret_val',
},
package: {
name: 'secrets',
version: '1.0.0',
},
})
.expect(200);
};
const createFleetServerAgent = async (
agentPolicyId: string,
hostname: string,
agentVersion: string
) => {
const agentResponse = await es.index({
index: '.fleet-agents',
refresh: true,
body: {
access_api_key_id: 'api-key-3',
active: true,
policy_id: agentPolicyId,
type: 'PERMANENT',
local_metadata: {
host: { hostname },
elastic: { agent: { version: agentVersion } },
},
user_provided_metadata: {},
enrolled_at: '2022-06-21T12:14:25Z',
last_checkin: '2022-06-27T12:28:29Z',
tags: ['tag1'],
},
});
return agentResponse._id;
};
const clearAgents = async () => {
try {
await es.deleteByQuery({
index: '.fleet-agents',
refresh: true,
body: {
query: {
match_all: {},
},
},
});
} catch (err) {
// index doesn't exist
}
};
const getSecrets = async (ids?: string[]) => {
const query = ids ? { terms: { _id: ids } } : { match_all: {} };
return es.search({
@ -71,7 +192,7 @@ export default function (providerContext: FtrProviderContext) {
},
});
} catch (err) {
// index doesnt exis
// index doesn't exist
}
};
@ -80,6 +201,36 @@ export default function (providerContext: FtrProviderContext) {
return body.item;
};
const enableSecrets = async () => {
try {
await kibanaServer.savedObjects.update({
type: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE,
id: 'fleet-default-settings',
attributes: {
secret_storage_requirements_met: true,
},
overwrite: false,
});
} catch (e) {
throw e;
}
};
const disableSecrets = async () => {
try {
await kibanaServer.savedObjects.update({
type: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE,
id: 'fleet-default-settings',
attributes: {
secret_storage_requirements_met: false,
},
overwrite: false,
});
} catch (e) {
throw e;
}
};
const getFullAgentPolicyById = async (id: string) => {
const { body } = await supertest.get(`/api/fleet/agent_policies/${id}/full`).expect(200);
return body.item;
@ -141,10 +292,13 @@ export default function (providerContext: FtrProviderContext) {
skipIfNoDockerRegistry(providerContext);
let agentPolicyId: string;
let fleetServerAgentPolicyId: string;
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await deleteAllSecrets();
await clearAgents();
await enableSecrets();
});
setupFleetAndAgents(providerContext);
@ -160,6 +314,8 @@ export default function (providerContext: FtrProviderContext) {
.expect(200);
agentPolicyId = agentPolicyResponse.item.id;
fleetServerAgentPolicyId = await createFleetServerAgentPolicy();
});
after(async () => {
@ -427,5 +583,67 @@ export default function (providerContext: FtrProviderContext) {
expect(searchRes.hits.hits.length).to.eql(0);
});
it('should not store secrets if fleet server does not meet minimum version', async () => {
await createFleetServerAgent(fleetServerAgentPolicyId, 'server_1', '7.0.0');
await disableSecrets();
const createdPolicy = await createPolicyWSecretVar();
// secret should be in plain text i.e not a secret refrerence
expect(createdPolicy.vars.package_var_secret.value).eql('package_secret_val');
});
async function createPolicyWSecretVar() {
const { body: createResBody } = await createPolicyWithSecrets();
const createdPolicy = createResBody.item;
return createdPolicy;
}
it('should not store secrets if there are no fleet servers', async () => {
await clearAgents();
const { body: createResBody } = await createPolicyWithSecrets();
const createdPolicy = createResBody.item;
// secret should be in plain text i.e not a secret refrerence
expect(createdPolicy.vars.package_var_secret.value).eql('package_secret_val');
});
it('should convert plain text values to secrets once fleet server requirements are met', async () => {
await clearAgents();
const createdPolicy = await createPolicyWSecretVar();
await createFleetServerAgent(fleetServerAgentPolicyId, 'server_2', '9.0.0');
const updatedPolicy = createdPolicyToUpdatePolicy(createdPolicy);
delete updatedPolicy.name;
updatedPolicy.vars.package_var_secret.value = 'package_secret_val_2';
const updateRes = await supertest
.put(`/api/fleet/package_policies/${createdPolicy.id}`)
.set('kbn-xsrf', 'xxxx')
.send(updatedPolicy)
.expect(200);
const updatedPolicyRes = updateRes.body.item;
expect(updatedPolicyRes.vars.package_var_secret.value.isSecretRef).eql(true);
expect(updatedPolicyRes.inputs[0].vars.input_var_secret.value.isSecretRef).eql(true);
expect(updatedPolicyRes.inputs[0].streams[0].vars.stream_var_secret.value.isSecretRef).eql(
true
);
});
it('should not revert to plaintext values if the user adds an out of date fleet server', async () => {
await createFleetServerAgent(fleetServerAgentPolicyId, 'server_3', '7.0.0');
const createdPolicy = await createPolicyWSecretVar();
expect(createdPolicy.vars.package_var_secret.value.isSecretRef).eql(true);
});
});
}