[Fleet] Create package policy API Create Secrets (behind feature flag) (#156036)

## Summary

The first part of secrets phase 1. This is not a fully working
implementation of secrets just yet, hence why it is behind a feature
flag. This just implements creating secrets:

- on package policy creation, if a package has fields with `secrets:
true` set, then their values are stored in the .secrets system index, a
reference to the secret is stored on the package policy e.g { id : 1234
isSecretReference : true }

- The compiled policy (returned from the get full agent policy API, or
stored in the .fleet-policies index) shows secret values in the format
`$co.elastic.secret{12345}` and includes a top level secret_references
array with all secret IDs in it, allowing fleet server to look them up
in one swoop.

- This works for pakacge level vars, input level vars and stream level
vars

Part of https://github.com/elastic/kibana/issues/154715

How to test:

```
# clone the elasticsearch repo
gh pr checkout 95625
./gradlew run

# now get a service token
curl -XPOST -u elastic:password http://localhost:9200/_security/service/elastic/kibana/credential/token/token1

# paste the service token into your kibana config under
# elasticsearch.serviceAccountToken: "<your_token>"

# once kibana has started, we now need to run our own package registry to get a package with secrets in
# replace /Users/markhopkin/dev with the path to your kibana

docker run -p 8080:8080 -v /Users/markhopkin/dev/kibana/x-pack/test/fleet_api_integration/apis/fixtures/test_packages:/packages/test-packages -v /Users/markhopkin/dev/kibana/x-pack/test/fleet_api_integration/apis/fixtures/package_registry_config.yml:/package-registry/config.yml docker.elastic.co/package-registry/package-registry:main

# once kibana has started successfully once and installed the fleet_server package, add this to your kibana config
xpack.fleet.registryUrl: http://localhost:8080

# you can now see the 'secrets' package and create a package policy 
# after creating the package policy, check the .fleet-secrets index, the .fleet-policies index or the get package policy API to see how the secrets have been stored

```



### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [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
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces&mdash;unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes&mdash;Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Mark Hopkin 2023-05-12 22:11:52 +01:00 committed by GitHub
parent f59471bcdc
commit 53d3dcc537
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1406 additions and 46 deletions

View file

@ -1656,6 +1656,13 @@
"dynamic": false,
"properties": {}
},
"secret_references": {
"properties": {
"id": {
"type": "keyword"
}
}
},
"revision": {
"type": "integer"
},

View file

@ -105,7 +105,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"ingest-agent-policies": "d9906923f595f6f3163672351ed3a46472eb280e",
"ingest-download-sources": "95a15b6589ef46e75aca8f7e534c493f99cc3ccd",
"ingest-outputs": "f5adeb3f6abc732a6067137e170578dbf1f58c62",
"ingest-package-policies": "6dc1c9b80a8dc95fbc9c6d9b73dfc56a098eb440",
"ingest-package-policies": "b0b652adb1b26d056d8ed3c0303d0ad85c2c1ae9",
"ingest_manager_settings": "fb75bff08a8de3435b23664b1191f9244a255701",
"inventory-view": "6d47ef0b38166ecbd1c2fc7394599a4500db1ae4",
"kql-telemetry": "92d6357aa3ce28727492f86a54783f802dc38893",

View file

@ -22,6 +22,7 @@ export * from './authz';
export * from './file_storage';
export * from './message_signing_keys';
export * from './locators';
export * from './secrets';
export * from './uninstall_tokens';
// TODO: This is the default `index.max_result_window` ES setting, which dictates

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export const SECRETS_INDEX = '.fleet-secrets';

View file

@ -23,6 +23,7 @@ export const allowedExperimentalValues = Object.freeze({
showExperimentalShipperOptions: false,
fleetServerStandalone: false,
agentTamperProtectionEnabled: false,
secretsStorage: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -62,9 +62,13 @@ properties:
agent:
type: string
nullable: true
download_source_id:
type: string
nullable: true
secret_references:
type: array
items:
type: object
properties:
id:
type: string
required:
- id
- outputs

View file

@ -28,6 +28,17 @@ const DATA_STREAM_DATASET_VAR: RegistryVarsEntry = {
show_user: true,
};
export function packageHasNoPolicyTemplates(packageInfo: PackageInfo): boolean {
return (
!packageInfo.policy_templates ||
packageInfo.policy_templates.length === 0 ||
!packageInfo.policy_templates.find(
(policyTemplate) =>
isInputOnlyPolicyTemplate(policyTemplate) ||
(policyTemplate.inputs && policyTemplate.inputs.length > 0)
)
);
}
export function isInputOnlyPolicyTemplate(
policyTemplate: RegistryPolicyTemplate
): policyTemplate is RegistryPolicyInputOnlyTemplate {

View file

@ -22,10 +22,10 @@ import type {
import {
isValidNamespace,
doesPackageHaveIntegrations,
isInputOnlyPolicyTemplate,
getNormalizedInputs,
getNormalizedDataStreams,
} from '.';
import { packageHasNoPolicyTemplates } from './policy_template';
type Errors = string[] | null;
@ -92,15 +92,7 @@ export const validatePackagePolicy = (
}, {} as ValidationEntry);
}
if (
!packageInfo.policy_templates ||
packageInfo.policy_templates.length === 0 ||
!packageInfo.policy_templates.find(
(policyTemplate) =>
isInputOnlyPolicyTemplate(policyTemplate) ||
(policyTemplate.inputs && policyTemplate.inputs.length > 0)
)
) {
if (!packageInfo?.policy_templates?.length || packageHasNoPolicyTemplates(packageInfo)) {
validationResults.inputs = {};
return validationResults;
}

View file

@ -43,6 +43,7 @@ export interface FleetConfigType {
maxAgentPoliciesWithInactivityTimeout?: number;
disableRegistryVersionCheck?: boolean;
bundledPackageLocation?: string;
testSecretsIndex?: string;
};
internal?: {
disableILMPolicies: boolean;

View file

@ -6,7 +6,7 @@
*/
import type { agentPolicyStatuses } from '../../constants';
import type { MonitoringType, ValueOf } from '..';
import type { MonitoringType, PolicySecretReference, ValueOf } from '..';
import type { PackagePolicy, PackagePolicyPackage } from './package_policy';
import type { Output } from './output';
@ -122,6 +122,7 @@ export interface FullAgentPolicy {
signing_key: string;
};
};
secret_references?: PolicySecretReference[];
signed?: {
data: string;
signature: string;

View file

@ -396,6 +396,7 @@ export enum RegistryVarsEntryKeys {
options = 'options',
default = 'default',
os = 'os',
secret = 'secret',
}
// EPR types this as `[]map[string]interface{}`
@ -407,6 +408,7 @@ export interface RegistryVarsEntry {
[RegistryVarsEntryKeys.description]?: string;
[RegistryVarsEntryKeys.type]: RegistryVarType;
[RegistryVarsEntryKeys.required]?: boolean;
[RegistryVarsEntryKeys.secret]?: boolean;
[RegistryVarsEntryKeys.show_user]?: boolean;
[RegistryVarsEntryKeys.multi]?: boolean;
[RegistryVarsEntryKeys.options]?: Array<{ value: string; text: string }>;

View file

@ -18,3 +18,4 @@ export * from './preconfiguration';
export * from './download_sources';
export * from './fleet_server_policy_config';
export * from './fleet_proxy';
export * from './secret';

View file

@ -6,6 +6,7 @@
*/
import type { RegistryRelease, ExperimentalDataStreamFeature } from './epm';
import type { PolicySecretReference } from './secret';
export interface PackagePolicyPackage {
name: string;
@ -97,6 +98,7 @@ export interface PackagePolicy extends Omit<NewPackagePolicy, 'inputs'> {
version?: string;
agents?: number;
revision: number;
secret_references?: PolicySecretReference[];
updated_at: string;
updated_by: string;
created_at: string;

View file

@ -0,0 +1,24 @@
/*
* 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.
*/
export interface Secret {
id: string;
value: string;
}
export interface SecretElasticDoc {
value: string;
}
// this replaces a var value with a reference to a secret
export interface VarSecretReference {
id: string;
isSecretRef: true;
}
// this is used in the top level secret_refs array on package and agent policies
export interface PolicySecretReference {
id: string;
}

View file

@ -133,6 +133,7 @@ export const config: PluginConfigDescriptor = {
disableRegistryVersionCheck: schema.boolean({ defaultValue: false }),
allowAgentUpgradeSourceUri: schema.boolean({ defaultValue: false }),
bundledPackageLocation: schema.string({ defaultValue: DEFAULT_BUNDLED_PACKAGE_LOCATION }),
testSecretsIndex: schema.maybe(schema.string()),
}),
packageVerification: schema.object({
gpgKeyPath: schema.string({ defaultValue: DEFAULT_GPG_KEY_PATH }),

View file

@ -77,6 +77,8 @@ export {
ENDPOINT_PRIVILEGES,
// Message signing service
MESSAGE_SIGNING_SERVICE_API_ROUTES,
// secrets
SECRETS_INDEX,
} from '../../common/constants';
export {

View file

@ -201,6 +201,7 @@ const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({
dynamic: false,
properties: {},
},
secret_references: { properties: { id: { type: 'keyword' } } },
revision: { type: 'integer' },
updated_at: { type: 'date' },
updated_by: { type: 'keyword' },

View file

@ -68,6 +68,7 @@ Object {
},
},
"revision": 1,
"secret_references": Array [],
"signed": Object {
"data": "",
"signature": "",
@ -143,6 +144,7 @@ Object {
},
},
"revision": 1,
"secret_references": Array [],
"signed": Object {
"data": "",
"signature": "",
@ -218,6 +220,7 @@ Object {
},
},
"revision": 1,
"secret_references": Array [],
"signed": Object {
"data": "",
"signature": "",

View file

@ -106,6 +106,9 @@ export async function getFullAgentPolicy(
packageInfoCache,
getOutputIdForAgentPolicy(dataOutput)
),
secret_references: (agentPolicy?.package_policies || []).flatMap(
(policy) => policy.secret_references || []
),
revision: agentPolicy.revision,
agent: {
download: {

View file

@ -9,6 +9,7 @@ import Handlebars from 'handlebars';
import { safeLoad, safeDump } from 'js-yaml';
import type { PackagePolicyConfigRecord } from '../../../../common/types';
import { toCompiledSecretRef } from '../../secrets';
const handlebars = Handlebars.create();
@ -23,7 +24,6 @@ export function compileTemplate(variables: PackagePolicyConfigRecord, templateSt
}
compiledTemplate = replaceRootLevelYamlVariables(yamlValues, compiledTemplate);
const yamlFromCompiledTemplate = safeLoad(compiledTemplate, {});
// Hack to keep empty string ('') values around in the end yaml because
@ -90,6 +90,8 @@ function buildTemplateVariables(variables: PackagePolicyConfigRecord, templateSt
const yamlKeyPlaceholder = `##${key}##`;
varPart[lastKeyPart] = recordEntry.value ? `"${yamlKeyPlaceholder}"` : null;
yamlValues[yamlKeyPlaceholder] = recordEntry.value ? safeLoad(recordEntry.value) : null;
} else if (recordEntry.value && recordEntry.value.isSecretRef) {
varPart[lastKeyPart] = toCompiledSecretRef(recordEntry.value.id);
} else {
varPart[lastKeyPart] = recordEntry.value;
}

View file

@ -172,6 +172,7 @@ jest.mock('./epm/packages', () => {
return {
getPackageInfo: jest.fn().mockImplementation(mockedGetPackageInfo),
getInstallation: mockedGetInstallation,
ensureInstalledPackage: jest.fn(),
};
});
@ -256,6 +257,11 @@ describe('Package policy service', () => {
enabled: true,
policy_id: 'test',
inputs: [],
package: {
name: 'test',
title: 'Test',
version: '0.0.1',
},
},
// Skipping unique name verification just means we have to less mocking/setup
{ id: 'test-package-policy', skipUniqueNameVerification: true }

View file

@ -67,6 +67,7 @@ import type {
Installation,
ExperimentalDataStreamFeature,
DeletePackagePoliciesResponse,
PolicySecretReference,
} from '../../common/types';
import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../constants';
import {
@ -112,6 +113,7 @@ import { updateDatastreamExperimentalFeatures } from './epm/packages/update';
import type { PackagePolicyClient, PackagePolicyService } from './package_policy_service';
import { installAssetsForInputPackagePolicy } from './epm/packages/install';
import { auditLoggingService } from './audit_logging';
import { extractAndWriteSecrets } from './secrets';
export type InputsOverride = Partial<NewPackagePolicyInput> & {
vars?: Array<NewPackagePolicyInput['vars'] & { name: string }>;
@ -148,10 +150,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
context?: RequestHandlerContext,
request?: KibanaRequest
): Promise<PackagePolicy> {
// Ensure an ID is provided, so we can include it in the audit logs below
if (!options.id) {
options.id = SavedObjectsUtils.generateId();
}
const packagePolicyId = options?.id || uuidv4();
let authorizationHeader = options.authorizationHeader;
@ -161,13 +160,13 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
auditLoggingService.writeCustomSoAuditLog({
action: 'create',
id: options.id,
id: packagePolicyId,
savedObjectType: PACKAGE_POLICY_SAVED_OBJECT_TYPE,
});
const logger = appContextService.getLogger();
const enrichedPackagePolicy = await packagePolicyService.runExternalCallbacks(
let secretReferences: PolicySecretReference[] | undefined;
let enrichedPackagePolicy = await packagePolicyService.runExternalCallbacks(
'packagePolicyCreate',
packagePolicy,
soClient,
@ -197,11 +196,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
}
let elasticsearchPrivileges: NonNullable<PackagePolicy['elasticsearch']>['privileges'];
// Add ids to stream
const packagePolicyId = options?.id || uuidv4();
let inputs: PackagePolicyInput[] = enrichedPackagePolicy.inputs.map((input) =>
assignStreamIdToInput(packagePolicyId, input)
);
let inputs = getInputsWithStreamIds(enrichedPackagePolicy, packagePolicyId);
// Make sure the associated package is installed
if (enrichedPackagePolicy.package?.name) {
@ -244,6 +239,19 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
}
validatePackagePolicyOrThrow(enrichedPackagePolicy, pkgInfo);
const { secretsStorage: secretsStorageEnabled } = appContextService.getExperimentalFeatures();
if (secretsStorageEnabled) {
const secretsRes = await extractAndWriteSecrets({
packagePolicy: enrichedPackagePolicy,
packageInfo: pkgInfo,
esClient,
});
enrichedPackagePolicy = secretsRes.packagePolicy;
secretReferences = secretsRes.secret_references;
inputs = getInputsWithStreamIds(enrichedPackagePolicy, packagePolicyId);
}
inputs = await _compilePackagePolicyInputs(pkgInfo, enrichedPackagePolicy.vars || {}, inputs);
elasticsearchPrivileges = pkgInfo.elasticsearch?.privileges;
@ -270,6 +278,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
: {}),
inputs,
...(elasticsearchPrivileges && { elasticsearch: { privileges: elasticsearchPrivileges } }),
...(secretReferences?.length && { secret_references: secretReferences }),
revision: 1,
created_at: isoDate,
created_by: options?.user?.username ?? 'system',
@ -351,9 +360,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
const packagePolicyId = packagePolicy.id ?? uuidv4();
const agentPolicyId = packagePolicy.policy_id;
let inputs = packagePolicy.inputs.map((input) =>
assignStreamIdToInput(packagePolicyId, input)
);
let inputs = getInputsWithStreamIds(packagePolicy, packagePolicyId);
const { id, ...pkgPolicyWithoutId } = packagePolicy;
@ -671,9 +678,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
await requireUniqueName(soClient, enrichedPackagePolicy, id);
}
let inputs = restOfPackagePolicy.inputs.map((input) =>
assignStreamIdToInput(oldPackagePolicy.id, input)
);
let inputs = getInputsWithStreamIds(restOfPackagePolicy, oldPackagePolicy.id);
inputs = enforceFrozenInputs(oldPackagePolicy.inputs, inputs, options?.force);
let elasticsearchPrivileges: NonNullable<PackagePolicy['elasticsearch']>['privileges'];
@ -822,9 +827,7 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
throw new PackagePolicyRestrictionRelatedError(`Cannot update package policy ${id}`);
}
let inputs = restOfPackagePolicy.inputs.map((input) =>
assignStreamIdToInput(oldPackagePolicy.id, input)
);
let inputs = getInputsWithStreamIds(restOfPackagePolicy, oldPackagePolicy.id);
inputs = enforceFrozenInputs(oldPackagePolicy.inputs, inputs, options?.force);
let elasticsearchPrivileges: NonNullable<PackagePolicy['elasticsearch']>['privileges'];
@ -1746,13 +1749,19 @@ function validatePackagePolicyOrThrow(packagePolicy: NewPackagePolicy, pkgInfo:
}
}
function assignStreamIdToInput(packagePolicyId: string, input: NewPackagePolicyInput) {
return {
...input,
streams: input.streams.map((stream) => {
return { ...stream, id: `${input.type}-${stream.data_stream.dataset}-${packagePolicyId}` };
}),
};
function getInputsWithStreamIds(
packagePolicy: NewPackagePolicy,
packagePolicyId: string
): PackagePolicy['inputs'] {
return packagePolicy.inputs.map((input) => {
return {
...input,
streams: input.streams.map((stream) => ({
...stream,
id: `${input.type}-${stream.data_stream.dataset}-${packagePolicyId}`,
})),
};
});
}
export async function _compilePackagePolicyInputs(

View file

@ -0,0 +1,569 @@
/*
* 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.
*/
/*
* 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 { NewPackagePolicy, PackageInfo } from '../types';
import { getPolicySecretPaths } from './secrets';
describe('getPolicySecretPaths', () => {
describe('integration package with one policy template', () => {
const mockIntegrationPackage = {
name: 'mock-package',
title: 'Mock package',
version: '0[0].0',
description: 'description',
type: 'integration',
status: 'not_installed',
vars: [
{ name: 'pkg-secret-1', type: 'text', secret: true },
{ name: 'pkg-secret-2', type: 'text', secret: true },
],
data_streams: [
{
dataset: 'somedataset',
streams: [
{
input: 'foo',
title: 'Foo',
vars: [
{ name: 'stream-secret-1', type: 'text', secret: true },
{ name: 'stream-secret-2', type: 'text', secret: true },
],
},
],
},
],
policy_templates: [
{
name: 'pkgPolicy1',
title: 'Package policy 1',
description: 'test package policy',
inputs: [
{
type: 'foo',
title: 'Foo',
vars: [
{ default: 'foo-input-var-value', name: 'foo-input-var-name', type: 'text' },
{
name: 'input-secret-1',
type: 'text',
secret: true,
},
{
name: 'input-secret-2',
type: 'text',
secret: true,
},
{ name: 'foo-input3-var-name', type: 'text', multi: true },
],
},
],
},
],
} as unknown as PackageInfo;
it('policy with package level secret vars', () => {
const packagePolicy = {
vars: {
'pkg-secret-1': {
value: 'pkg-secret-1-val',
},
'pkg-secret-2': {
value: 'pkg-secret-2-val',
},
},
inputs: [],
} as unknown as NewPackagePolicy;
expect(getPolicySecretPaths(packagePolicy, mockIntegrationPackage)).toEqual([
{
path: 'vars.pkg-secret-1',
value: {
value: 'pkg-secret-1-val',
},
},
{
path: 'vars.pkg-secret-2',
value: {
value: 'pkg-secret-2-val',
},
},
]);
});
it('policy with input level secret vars', () => {
const packagePolicy = {
inputs: [
{
type: 'foo',
policy_template: 'pkgPolicy1',
vars: {
'input-secret-1': {
value: 'input-secret-1-val',
},
'input-secret-2': {
value: 'input-secret-2-val',
},
},
streams: [],
},
],
} as unknown as NewPackagePolicy;
expect(getPolicySecretPaths(packagePolicy, mockIntegrationPackage)).toEqual([
{
path: 'inputs[0].vars.input-secret-1',
value: { value: 'input-secret-1-val' },
},
{
path: 'inputs[0].vars.input-secret-2',
value: { value: 'input-secret-2-val' },
},
]);
});
it('stream level secret vars', () => {
const packagePolicy = {
inputs: [
{
type: 'foo',
policy_template: 'pkgPolicy1',
streams: [
{
data_stream: {
dataset: 'somedataset',
type: 'logs',
},
vars: {
'stream-secret-1': {
value: 'stream-secret-1-value',
},
'stream-secret-2': {
value: 'stream-secret-2-value',
},
},
},
],
},
],
} as unknown as NewPackagePolicy;
expect(getPolicySecretPaths(packagePolicy, mockIntegrationPackage)).toEqual([
{
path: 'inputs[0].streams[0].vars.stream-secret-1',
value: { value: 'stream-secret-1-value' },
},
{
path: 'inputs[0].streams[0].vars.stream-secret-2',
value: { value: 'stream-secret-2-value' },
},
]);
});
});
describe('integration package with multiple policy templates (e.g AWS)', () => {
const miniAWsPackage = {
name: 'aws',
title: 'AWS',
version: '0.5.3',
release: 'beta',
description: 'AWS Integration',
type: 'integration',
policy_templates: [
{
name: 'billing',
title: 'AWS Billing',
description: 'Collect AWS billing metrics',
data_streams: ['billing'],
inputs: [
{
type: 'aws/metrics',
title: 'Collect billing metrics',
description: 'Collect billing metrics',
input_group: 'metrics',
vars: [
{
name: 'password',
type: 'text',
secret: true,
},
],
},
],
},
{
name: 'cloudtrail',
title: 'AWS Cloudtrail',
description: 'Collect logs from AWS Cloudtrail',
data_streams: ['cloudtrail'],
inputs: [
{
type: 's3',
title: 'Collect logs from Cloudtrail service',
description: 'Collecting Cloudtrail logs using S3 input',
input_group: 'logs',
vars: [
{
name: 'password',
type: 'text',
secret: true,
},
],
},
{
type: 'httpjson',
title: 'Collect logs from third-party REST API (experimental)',
description: 'Collect logs from third-party REST API (experimental)',
input_group: 'logs',
vars: [
{
name: 'password',
type: 'text',
secret: true,
},
],
},
],
},
],
vars: [
{
name: 'secret_access_key',
type: 'text',
title: 'Secret Access Key',
multi: false,
required: false,
show_user: false,
secret: true,
},
],
data_streams: [
{
type: 'metrics',
dataset: 'aws.billing',
title: 'AWS billing metrics',
release: 'beta',
streams: [
{
input: 'aws/metrics',
vars: [
{
name: 'password',
type: 'text',
secret: true,
},
],
template_path: 'stream.yml.hbs',
title: 'AWS Billing metrics',
description: 'Collect AWS billing metrics',
enabled: true,
},
],
package: 'aws',
path: 'billing',
},
{
type: 'logs',
dataset: 'aws.cloudtrail',
title: 'AWS CloudTrail logs',
release: 'beta',
ingest_pipeline: 'default',
streams: [
{
input: 's3',
vars: [
{
name: 'password',
type: 'text',
secret: true,
},
],
template_path: 's3.yml.hbs',
},
{
input: 'httpjson',
vars: [
{
name: 'username',
type: 'text',
title: 'Splunk REST API Username',
multi: false,
required: true,
show_user: true,
},
{
name: 'password',
type: 'password',
title: 'Splunk REST API Password',
multi: false,
required: true,
show_user: true,
secret: true,
},
],
template_path: 'httpjson.yml.hbs',
},
],
package: 'aws',
path: 'cloudtrail',
},
],
} as PackageInfo;
it('single policy with package + input + stream level secret var', () => {
const policy = {
vars: {
secret_access_key: {
value: 'my_secret_access_key',
},
},
inputs: [
{
type: 'aws/metrics',
policy_template: 'billing',
enabled: true,
vars: {
password: { value: 'billing_input_password', type: 'text' },
},
streams: [
{
enabled: true,
data_stream: { type: 'metrics', dataset: 'aws.billing' },
vars: {
password: { value: 'billing_stream_password', type: 'text' },
},
},
],
},
],
};
expect(
getPolicySecretPaths(
policy as unknown as NewPackagePolicy,
miniAWsPackage as unknown as PackageInfo
)
).toEqual([
{
path: 'vars.secret_access_key',
value: {
value: 'my_secret_access_key',
},
},
{
path: 'inputs[0].vars.password',
value: {
type: 'text',
value: 'billing_input_password',
},
},
{
path: 'inputs[0].streams[0].vars.password',
value: {
type: 'text',
value: 'billing_stream_password',
},
},
]);
});
it('double policy with package + input + stream level secret var', () => {
const policy = {
vars: {
secret_access_key: {
value: 'my_secret_access_key',
},
},
inputs: [
{
type: 'httpjson',
policy_template: 'cloudtrail',
enabled: false,
vars: {
password: { value: 'cloudtrail_httpjson_input_password' },
},
streams: [
{
data_stream: { type: 'logs', dataset: 'aws.cloudtrail' },
vars: {
username: { value: 'hop_dev' },
password: { value: 'cloudtrail_httpjson_stream_password' },
},
},
],
},
{
type: 's3',
policy_template: 'cloudtrail',
enabled: true,
vars: {
password: { value: 'cloudtrail_s3_input_password' },
},
streams: [
{
enabled: true,
data_stream: { type: 'logs', dataset: 'aws.cloudtrail' },
vars: {
password: { value: 'cloudtrail_s3_stream_password' },
},
},
],
},
],
};
expect(
getPolicySecretPaths(
policy as unknown as NewPackagePolicy,
miniAWsPackage as unknown as PackageInfo
)
).toEqual([
{
path: 'vars.secret_access_key',
value: {
value: 'my_secret_access_key',
},
},
{
path: 'inputs[0].vars.password',
value: {
value: 'cloudtrail_httpjson_input_password',
},
},
{
path: 'inputs[0].streams[0].vars.password',
value: {
value: 'cloudtrail_httpjson_stream_password',
},
},
{
path: 'inputs[1].vars.password',
value: {
value: 'cloudtrail_s3_input_password',
},
},
{
path: 'inputs[1].streams[0].vars.password',
value: {
value: 'cloudtrail_s3_stream_password',
},
},
]);
});
});
describe('input package', () => {
const mockInputPackage = {
name: 'log',
version: '2.0.0',
description: 'Collect custom logs with Elastic Agent.',
title: 'Custom Logs',
format_version: '2.6.0',
owner: {
github: 'elastic/elastic-agent-data-plane',
},
type: 'input',
categories: ['custom', 'custom_logs'],
conditions: {},
icons: [],
policy_templates: [
{
name: 'logs',
title: 'Custom log file',
description: 'Collect your custom log files.',
multiple: true,
input: 'logfile',
type: 'logs',
template_path: 'input.yml.hbs',
vars: [
{
name: 'paths',
required: true,
title: 'Log file path',
description: 'Path to log files to be collected',
type: 'text',
multi: true,
},
{
name: 'data_stream.dataset',
required: true,
title: 'Dataset name',
description:
"Set the name for your dataset. Changing the dataset will send the data to a different index. You can't use `-` in the name of a dataset and only valid characters for [Elasticsearch index names](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html).\n",
type: 'text',
},
{
name: 'secret-1',
type: 'text',
secret: true,
},
{
name: 'secret-2',
type: 'text',
secret: true,
},
],
},
],
};
it('template level vars', () => {
const policy = {
inputs: [
{
type: 'logfile',
policy_template: 'logs',
enabled: true,
streams: [
{
enabled: true,
data_stream: {
type: 'logs',
dataset: 'log.logs',
},
vars: {
paths: {
value: ['/tmp/test.log'],
},
'data_stream.dataset': {
value: 'hello',
},
'secret-1': {
value: 'secret-1-value',
},
'secret-2': {
value: 'secret-2-value',
},
},
},
],
},
],
};
expect(
getPolicySecretPaths(
policy as unknown as NewPackagePolicy,
mockInputPackage as unknown as PackageInfo
)
).toEqual([
{
path: 'inputs[0].streams[0].vars.secret-1',
value: {
value: 'secret-1-value',
},
},
{
path: 'inputs[0].streams[0].vars.secret-2',
value: {
value: 'secret-2-value',
},
},
]);
});
});
});

View file

@ -0,0 +1,314 @@
/*
* 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 { ElasticsearchClient } from '@kbn/core/server';
import type { BulkResponse, DeleteResponse } from '@elastic/elasticsearch/lib/api/types';
import { keyBy, partition } from 'lodash';
import { set } from '@kbn/safer-lodash-set';
import { packageHasNoPolicyTemplates } from '../../common/services/policy_template';
import type {
NewPackagePolicy,
PackagePolicyConfigRecordEntry,
RegistryStream,
} from '../../common';
import {
doesPackageHaveIntegrations,
getNormalizedDataStreams,
getNormalizedInputs,
} from '../../common/services';
import type {
PackageInfo,
PackagePolicy,
RegistryVarsEntry,
Secret,
VarSecretReference,
PolicySecretReference,
} from '../types';
import { FleetError } from '../errors';
import { SECRETS_INDEX } from '../constants';
import { auditLoggingService } from './audit_logging';
import { appContextService } from './app_context';
interface SecretPath {
path: string;
value: PackagePolicyConfigRecordEntry;
}
// This will be removed once the secrets index PR is merged into elasticsearch
function getSecretsIndex() {
const testIndex = appContextService.getConfig()?.developer?.testSecretsIndex;
if (testIndex) {
return testIndex;
}
return SECRETS_INDEX;
}
export async function createSecrets(opts: {
esClient: ElasticsearchClient;
values: string[];
}): Promise<Secret[]> {
const { esClient, values } = opts;
const logger = appContextService.getLogger();
const body = values.flatMap((value) => [
{
create: { _index: getSecretsIndex() },
},
{ value },
]);
let res: BulkResponse;
try {
res = await esClient.bulk({
body,
});
const [errorItems, successItems] = partition(res.items, (a) => a.create?.error);
successItems.forEach((item) => {
auditLoggingService.writeCustomAuditLog({
message: `secret created: ${item.create!._id}`,
event: {
action: 'secret_create',
category: ['database'],
type: ['access'],
outcome: 'success',
},
});
});
if (errorItems.length) {
throw new Error(JSON.stringify(errorItems));
}
return res.items.map((item, i) => ({
id: item.create!._id as string,
value: values[i],
}));
} catch (e) {
const msg = `Error creating secrets in ${getSecretsIndex()} index: ${e}`;
logger.error(msg);
throw new FleetError(msg);
}
}
export async function deleteSecret(opts: {
esClient: ElasticsearchClient;
id: string;
}): Promise<DeleteResponse['result']> {
const { esClient, id } = opts;
let res: DeleteResponse;
try {
res = await esClient.delete({
index: getSecretsIndex(),
id,
});
auditLoggingService.writeCustomAuditLog({
message: `secret deleted: ${id}`,
event: {
action: 'secret_delete',
category: ['database'],
type: ['access'],
outcome: 'success',
},
});
} catch (e) {
const logger = appContextService.getLogger();
const msg = `Error deleting secret '${id}' from ${getSecretsIndex()} index: ${e}`;
logger.error(msg);
throw new FleetError(msg);
}
return res.result;
}
export async function extractAndWriteSecrets(opts: {
packagePolicy: NewPackagePolicy;
packageInfo: PackageInfo;
esClient: ElasticsearchClient;
}): Promise<{ packagePolicy: NewPackagePolicy; secret_references: PolicySecretReference[] }> {
const { packagePolicy, packageInfo, esClient } = opts;
const secretPaths = getPolicySecretPaths(packagePolicy, packageInfo);
if (!secretPaths.length) {
return { packagePolicy, secret_references: [] };
}
const secrets = await createSecrets({
esClient,
values: secretPaths.map((secretPath) => secretPath.value.value),
});
const policyWithSecretRefs = JSON.parse(JSON.stringify(packagePolicy));
secretPaths.forEach((secretPath, i) => {
set(policyWithSecretRefs, secretPath.path + '.value', toVarSecretRef(secrets[i].id));
});
return {
packagePolicy: policyWithSecretRefs,
secret_references: secrets.map(({ id }) => ({ id })),
};
}
function isSecretVar(varDef: RegistryVarsEntry) {
return varDef.secret === true;
}
function containsSecretVar(vars?: RegistryVarsEntry[]) {
return vars?.some(isSecretVar);
}
// this is how secrets are stored on the package policy
function toVarSecretRef(id: string): VarSecretReference {
return { id, isSecretRef: true };
}
// this is how IDs are inserted into compiled templates
export function toCompiledSecretRef(id: string) {
return `$co.elastic.secret{${id}}`;
}
// Given a package policy and a package,
// returns an array of lodash style paths to all secrets and their current values
export function getPolicySecretPaths(
packagePolicy: PackagePolicy | NewPackagePolicy,
packageInfo: PackageInfo
): SecretPath[] {
const packageLevelVarPaths = _getPackageLevelSecretPaths(packagePolicy, packageInfo);
if (!packageInfo?.policy_templates?.length || packageHasNoPolicyTemplates(packageInfo)) {
return packageLevelVarPaths;
}
const inputSecretPaths = _getInputSecretPaths(packagePolicy, packageInfo);
return [...packageLevelVarPaths, ...inputSecretPaths];
}
function _getPackageLevelSecretPaths(
packagePolicy: NewPackagePolicy,
packageInfo: PackageInfo
): SecretPath[] {
const packageSecretVars = packageInfo.vars?.filter(isSecretVar) || [];
const packageSecretVarsByName = keyBy(packageSecretVars, 'name');
const packageVars = Object.entries(packagePolicy.vars || {});
return packageVars.reduce((vars, [name, configEntry], i) => {
if (packageSecretVarsByName[name]) {
vars.push({
value: configEntry,
path: `vars.${name}`,
});
}
return vars;
}, [] as SecretPath[]);
}
function _getInputSecretPaths(
packagePolicy: NewPackagePolicy,
packageInfo: PackageInfo
): SecretPath[] {
if (!packageInfo?.policy_templates?.length) return [];
const inputSecretVarDefsByPolicyTemplateAndType =
_getInputSecretVarDefsByPolicyTemplateAndType(packageInfo);
const streamSecretVarDefsByDatasetAndInput =
_getStreamSecretVarDefsByDatasetAndInput(packageInfo);
return packagePolicy.inputs.flatMap((input, inputIndex) => {
if (!input.vars && !input.streams) {
return [];
}
const currentInputVarPaths: SecretPath[] = [];
const inputKey = doesPackageHaveIntegrations(packageInfo)
? `${input.policy_template}-${input.type}`
: input.type;
const inputVars = Object.entries(input.vars || {});
if (inputVars.length) {
inputVars.forEach(([name, configEntry]) => {
if (inputSecretVarDefsByPolicyTemplateAndType[inputKey]?.[name]) {
currentInputVarPaths.push({
path: `inputs[${inputIndex}].vars.${name}`,
value: configEntry,
});
}
});
}
if (input.streams.length) {
input.streams.forEach((stream, streamIndex) => {
const streamVarDefs =
streamSecretVarDefsByDatasetAndInput[`${stream.data_stream.dataset}-${input.type}`];
if (streamVarDefs && Object.keys(streamVarDefs).length) {
Object.entries(stream.vars || {}).forEach(([name, configEntry]) => {
if (streamVarDefs[name]) {
currentInputVarPaths.push({
path: `inputs[${inputIndex}].streams[${streamIndex}].vars.${name}`,
value: configEntry,
});
}
});
}
});
}
return currentInputVarPaths;
});
}
// a map of all secret vars for each dataset and input combo
function _getStreamSecretVarDefsByDatasetAndInput(packageInfo: PackageInfo) {
const dataStreams = getNormalizedDataStreams(packageInfo);
const streamsByDatasetAndInput = dataStreams.reduce<Record<string, RegistryStream>>(
(streams, dataStream) => {
dataStream.streams?.forEach((stream) => {
streams[`${dataStream.dataset}-${stream.input}`] = stream;
});
return streams;
},
{}
);
return Object.entries(streamsByDatasetAndInput).reduce<
Record<string, Record<string, RegistryVarsEntry>>
>((varDefs, [path, stream]) => {
if (stream.vars && containsSecretVar(stream.vars)) {
const secretVars = stream.vars.filter(isSecretVar);
varDefs[path] = keyBy(secretVars, 'name');
}
return varDefs;
}, {});
}
// a map of all secret vars for each policyTemplate and input type combo
function _getInputSecretVarDefsByPolicyTemplateAndType(packageInfo: PackageInfo) {
if (!packageInfo?.policy_templates?.length) return {};
const hasIntegrations = doesPackageHaveIntegrations(packageInfo);
return packageInfo.policy_templates.reduce<Record<string, Record<string, RegistryVarsEntry>>>(
(varDefs, policyTemplate) => {
const inputs = getNormalizedInputs(policyTemplate);
inputs.forEach((input) => {
const varDefKey = hasIntegrations ? `${policyTemplate.name}-${input.type}` : input.type;
const secretVars = input?.vars?.filter(isSecretVar);
if (secretVars?.length) {
varDefs[varDefKey] = keyBy(secretVars, 'name');
}
});
return varDefs;
},
{}
);
}

View file

@ -91,6 +91,10 @@ export type {
BulkInstallPackageInfo,
PackageAssetReference,
ExperimentalDataStreamFeature,
Secret,
SecretElasticDoc,
VarSecretReference,
PolicySecretReference,
} from '../../common/types';
export { ElasticsearchAssetType, KibanaAssetType, KibanaSavedObjectType } from '../../common/types';
export { dataTypes } from '../../common/constants';

View file

@ -239,4 +239,11 @@ export const PackagePolicySchema = schema.object({
compiled_input: schema.maybe(schema.any()),
})
),
secret_references: schema.maybe(
schema.arrayOf(
schema.object({
id: schema.string(),
})
)
),
});

View file

@ -95,5 +95,6 @@
"@kbn/shared-ux-router",
"@kbn/shared-ux-link-redirect-app",
"@kbn/core-http-router-server-internal",
"@kbn/safer-lodash-set",
]
}

View file

@ -0,0 +1,2 @@
package_var_secret: {{package_var_secret}}
input_var_secret: {{input_var_secret}}

View file

@ -0,0 +1,4 @@
config.version: "2"
package_var_secret: {{package_var_secret}}
input_var_secret: {{input_var_secret}}
stream_var_secret: {{stream_var_secret}}

View file

@ -0,0 +1,16 @@
- name: data_stream.type
type: constant_keyword
description: >
Data stream type.
- name: data_stream.dataset
type: constant_keyword
description: >
Data stream dataset.
- name: data_stream.namespace
type: constant_keyword
description: >
Data stream namespace.
- name: '@timestamp'
type: date
description: >
Event timestamp.

View file

@ -0,0 +1,13 @@
title: Test stream
type: logs
streams:
- input: test_input
title: test input
vars:
- name: stream_var_secret
type: text
title: Stream Var Secret
multi: false
required: true
show_user: true
secret: true

View file

@ -0,0 +1,3 @@
# secrets
This package has secrets

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<g fill="none" fill-rule="evenodd">
<path fill="#F04E98" d="M29,32.0001 L15.935,9.4321 C13.48,5.1941 7,6.9351 7,11.8321 L7,52.1681 C7,57.0651 13.48,58.8061 15.935,54.5671 L29,32.0001 Z"/>
<path fill="#FA744E" d="M34.7773,32.0001 L33.3273,34.5051 L20.2613,57.0731 C19.8473,57.7871 19.3533,58.4271 18.8023,59.0001 L34.9273,59.0001 C38.7073,59.0001 42.2213,57.0601 44.2363,53.8611 L58.0003,32.0001 L34.7773,32.0001 Z"/>
<path fill="#343741" d="M44.2363,10.1392 C42.2213,6.9402 38.7073,5.0002 34.9273,5.0002 L18.8023,5.0002 C19.3533,5.5732 19.8473,6.2122 20.2613,6.9272 L33.3273,29.4942 L34.7773,32.0002 L58.0003,32.0002 L44.2363,10.1392 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 750 B

View file

@ -0,0 +1,52 @@
format_version: 1.0.0
name: secrets
title: Package with secrets
description: This integration package has 3 secrets.
version: 1.0.0
categories: []
# Options are experimental, beta, ga
release: beta
# The package type. The options for now are [integration, solution], more type might be added in the future.
# The default type is integration and will be set if empty.
type: integration
license: basic
owner:
github: elastic/fleet
requirement:
elasticsearch:
versions: ">7.7.0"
kibana:
versions: ">7.7.0"
icons:
- src: "/img/logo.svg"
size: "16x16"
type: "image/svg+xml"
vars:
- name: package_var_secret
type: text
title: Package Var Secret
multi: false
required: true
show_user: true
secret: true
policy_templates:
- name: secrets
title: This
description: Test Package for Upgrading Package Policies
inputs:
- type: test_input
title: Test Input
description: Test Input
enabled: true
template_path: input.yml.hbs
vars:
- name: input_var_secret
type: text
title: Input Var Secret
multi: false
required: true
show_user: true
secret: true

View file

@ -18,6 +18,19 @@ export default function ({ loadTestFile, getService }) {
loadTestFile(require.resolve('./fleet_setup')); // ~ 6s
// Enrollment API keys
loadTestFile(require.resolve('./enrollment_api_keys/crud'));
// Package policies
loadTestFile(require.resolve('./policy_secrets'));
loadTestFile(require.resolve('./package_policy/create'));
loadTestFile(require.resolve('./package_policy/update'));
loadTestFile(require.resolve('./package_policy/get'));
loadTestFile(require.resolve('./package_policy/delete'));
loadTestFile(require.resolve('./package_policy/upgrade'));
loadTestFile(require.resolve('./package_policy/input_package_create_upgrade'));
// Agent policies
loadTestFile(require.resolve('./agent_policy'));
loadTestFile(require.resolve('./enrollment_api_keys/crud')); // ~ 20s
// Data Streams

View file

@ -703,7 +703,7 @@ export default function (providerContext: FtrProviderContext) {
.post(`/api/fleet/package_policies`)
.set('kbn-xsrf', 'xxxx')
.send({
name: 'unverified_content-1',
name: 'unverified_content_' + Date.now(),
description: '',
namespace: 'default',
policy_id: agentPolicyId,
@ -739,7 +739,7 @@ export default function (providerContext: FtrProviderContext) {
.post(`/api/fleet/package_policies`)
.set('kbn-xsrf', 'xxxx')
.send({
name: 'unverified_content-1',
name: 'unverified_content-' + Date.now(),
description: '',
namespace: 'default',
policy_id: agentPolicyId,

View file

@ -0,0 +1,271 @@
/*
* 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 { Client } from '@elastic/elasticsearch';
import expect from '@kbn/expect';
import { FullAgentPolicy } from '@kbn/fleet-plugin/common';
import { v4 as uuidv4 } from 'uuid';
import { FtrProviderContext } from '../../api_integration/ftr_provider_context';
import { skipIfNoDockerRegistry } from '../helpers';
import { setupFleetAndAgents } from './agents/services';
const secretVar = (id: string) => `$co.elastic.secret{${id}}`;
const arrayIdsEqual = (a: Array<{ id: string }>, b: Array<{ id: string }>) => {
if (a.length !== b.length) {
return false;
}
return a.every(({ id }) => b.find(({ id: bid }) => bid === id));
};
export default function (providerContext: FtrProviderContext) {
describe('fleet policy secrets', () => {
const { getService } = providerContext;
const es: Client = getService('es');
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const getPackagePolicyById = async (id: string) => {
const { body } = await supertest.get(`/api/fleet/package_policies/${id}`);
return body.item;
};
const maybeCreateSecretsIndex = async () => {
// create mock .secrets index for testing
if (await es.indices.exists({ index: '.fleet-test-secrets' })) {
await es.indices.delete({ index: '.fleet-test-secrets' });
}
await es.indices.create({
index: '.fleet-test-secrets',
body: {
mappings: {
properties: {
value: {
type: 'keyword',
},
},
},
},
});
};
const getFullAgentPolicyById = async (id: string) => {
const { body } = await supertest.get(`/api/fleet/agent_policies/${id}/full`).expect(200);
return body.item;
};
const getLatestPolicyRevision = async (id: string): Promise<{ data: FullAgentPolicy }> => {
const res = await es.search({
index: '.fleet-policies',
body: {
query: {
bool: {
filter: [
{
term: {
policy_id: id,
},
},
],
},
},
sort: [
{
revision_idx: {
order: 'desc',
},
},
],
size: 1,
},
});
return res.hits.hits[0]._source as any as { data: FullAgentPolicy };
};
let createdPackagePolicyId: string;
let packageVarId: string;
let inputVarId: string;
let streamVarId: string;
let expectedCompiledStream: any;
let expectedCompiledInput: any;
function expectCompiledPolicyVars(policy: any) {
expect(
arrayIdsEqual(policy.secret_references, [
{ id: packageVarId },
{ id: streamVarId },
{ id: inputVarId },
])
).to.eql(true);
expect(policy.inputs[0].package_var_secret).to.eql(secretVar(packageVarId));
expect(policy.inputs[0].input_var_secret).to.eql(secretVar(inputVarId));
expect(policy.inputs[0].streams[0].package_var_secret).to.eql(secretVar(packageVarId));
expect(policy.inputs[0].streams[0].input_var_secret).to.eql(secretVar(inputVarId));
expect(policy.inputs[0].streams[0].stream_var_secret).to.eql(secretVar(streamVarId));
}
skipIfNoDockerRegistry(providerContext);
let agentPolicyId: string;
before(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await getService('esArchiver').load(
'x-pack/test/functional/es_archives/fleet/empty_fleet_server'
);
await maybeCreateSecretsIndex();
});
setupFleetAndAgents(providerContext);
before(async () => {
const { body: agentPolicyResponse } = await supertest
.post(`/api/fleet/agent_policies`)
.set('kbn-xsrf', 'xxxx')
.send({
name: `Test policy ${uuidv4()}`,
namespace: 'default',
})
.expect(200);
agentPolicyId = agentPolicyResponse.item.id;
});
after(async () => {
await kibanaServer.savedObjects.cleanStandardList();
await getService('esArchiver').unload(
'x-pack/test/functional/es_archives/fleet/empty_fleet_server'
);
});
it('Should correctly create the policy with secrets', async () => {
const { body: createResBody } = await 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 createdPackagePolicy = createResBody.item;
createdPackagePolicyId = createdPackagePolicy.id;
packageVarId = createdPackagePolicy.vars.package_var_secret.value.id;
expect(packageVarId).to.be.an('string');
inputVarId = createdPackagePolicy.inputs[0].vars.input_var_secret.value.id;
expect(inputVarId).to.be.an('string');
streamVarId = createdPackagePolicy.inputs[0].streams[0].vars.stream_var_secret.value.id;
expect(streamVarId).to.be.an('string');
expect(
arrayIdsEqual(createdPackagePolicy.secret_references, [
{ id: packageVarId },
{ id: streamVarId },
{ id: inputVarId },
])
).to.eql(true);
expectedCompiledStream = {
'config.version': 2,
package_var_secret: secretVar(packageVarId),
input_var_secret: secretVar(inputVarId),
stream_var_secret: secretVar(streamVarId),
};
expect(createdPackagePolicy.inputs[0].streams[0].compiled_stream).to.eql(
expectedCompiledStream
);
expectedCompiledInput = {
package_var_secret: secretVar(packageVarId),
input_var_secret: secretVar(inputVarId),
};
expect(createdPackagePolicy.inputs[0].compiled_input).to.eql(expectedCompiledInput);
expect(createdPackagePolicy.vars.package_var_secret.value.isSecretRef).to.eql(true);
expect(createdPackagePolicy.inputs[0].vars.input_var_secret.value.isSecretRef).to.eql(true);
expect(
createdPackagePolicy.inputs[0].streams[0].vars.stream_var_secret.value.isSecretRef
).to.eql(true);
});
it('should return the policy correctly from the get policies API', async () => {
const packagePolicy = await getPackagePolicyById(createdPackagePolicyId);
expect(
arrayIdsEqual(packagePolicy.secret_references, [
{ id: packageVarId },
{ id: streamVarId },
{ id: inputVarId },
])
).to.eql(true);
expect(packagePolicy.inputs[0].streams[0].compiled_stream).to.eql(expectedCompiledStream);
expect(packagePolicy.inputs[0].compiled_input).to.eql(expectedCompiledInput);
expect(packagePolicy.vars.package_var_secret.value.isSecretRef).to.eql(true);
expect(packagePolicy.vars.package_var_secret.value.id).eql(packageVarId);
expect(packagePolicy.inputs[0].vars.input_var_secret.value.isSecretRef).to.eql(true);
expect(packagePolicy.inputs[0].vars.input_var_secret.value.id).eql(inputVarId);
expect(packagePolicy.inputs[0].streams[0].vars.stream_var_secret.value.isSecretRef).to.eql(
true
);
expect(packagePolicy.inputs[0].streams[0].vars.stream_var_secret.value.id).eql(streamVarId);
});
it('should have correctly created the secrets', async () => {
const searchRes = await es.search({
index: '.fleet-test-secrets',
body: {
query: {
ids: {
values: [packageVarId, inputVarId, streamVarId],
},
},
},
});
expect(searchRes.hits.hits.length).to.eql(3);
const secretValuesById = searchRes.hits.hits.reduce((acc: any, secret: any) => {
acc[secret._id] = secret._source.value;
return acc;
}, {});
expect(secretValuesById[packageVarId]).to.eql('package_secret_val');
expect(secretValuesById[inputVarId]).to.eql('input_secret_val');
expect(secretValuesById[streamVarId]).to.eql('stream_secret_val');
});
it('should have written the secrets to the .fleet-policies index', async () => {
const { data: policyDoc } = await getLatestPolicyRevision(agentPolicyId);
expectCompiledPolicyVars(policyDoc);
});
it('should return secret refs from agent policy API', async () => {
const agentPolicy = await getFullAgentPolicyById(agentPolicyId);
expectCompiledPolicyVars(agentPolicy);
});
});
}

View file

@ -70,6 +70,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
'./apis/fixtures/package_verification/signatures/fleet_test_key_public.asc'
)}`,
`--xpack.securitySolution.enableExperimental=${JSON.stringify(['endpointRbacEnabled'])}`,
`--xpack.fleet.enableExperimental=${JSON.stringify(['secretsStorage'])}`,
`--xpack.fleet.developer.testSecretsIndex=.fleet-test-secrets`,
`--logging.loggers=${JSON.stringify([
...getKibanaCliLoggers(xPackAPITestsConfig.get('kbnTestServer.serverArgs')),