diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index d41f0a8d9f1b3..8242a29838bf8 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -21066,6 +21066,31 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + nullable: true + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string host: format: uri type: string @@ -21084,6 +21109,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string + password: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string ssl: additionalProperties: false type: object @@ -21181,6 +21230,31 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + nullable: true + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string host: format: uri type: string @@ -21199,6 +21273,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string + password: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string ssl: additionalProperties: false type: object @@ -21241,6 +21339,31 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + nullable: true + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string host: format: uri type: string @@ -21259,6 +21382,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string + password: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string ssl: additionalProperties: false type: object @@ -21412,6 +21559,31 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + nullable: true + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string host: format: uri type: string @@ -21430,6 +21602,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string + password: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string ssl: additionalProperties: false type: object @@ -21521,6 +21717,31 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + nullable: true + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string host: format: uri type: string @@ -21539,6 +21760,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string + password: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string ssl: additionalProperties: false type: object @@ -21581,6 +21826,31 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + nullable: true + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string host: format: uri type: string @@ -21599,6 +21869,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string + password: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string ssl: additionalProperties: false type: object @@ -27036,6 +27330,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string proxy_headers: additionalProperties: anyOf: @@ -50080,6 +50398,8 @@ paths: required: - enabled - is_preconfigured + download_source_auth_secret_storage_requirements_met: + type: boolean has_seen_add_data_notice: type: boolean id: @@ -50254,6 +50574,8 @@ paths: required: - enabled - is_preconfigured + download_source_auth_secret_storage_requirements_met: + type: boolean has_seen_add_data_notice: type: boolean id: diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 24217694f9cdc..68ef33ab51d3d 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -23639,6 +23639,31 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + nullable: true + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string host: format: uri type: string @@ -23657,6 +23682,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string + password: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string ssl: additionalProperties: false type: object @@ -23754,6 +23803,31 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + nullable: true + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string host: format: uri type: string @@ -23772,6 +23846,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string + password: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string ssl: additionalProperties: false type: object @@ -23814,6 +23912,31 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + nullable: true + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string host: format: uri type: string @@ -23832,6 +23955,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string + password: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string ssl: additionalProperties: false type: object @@ -23985,6 +24132,31 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + nullable: true + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string host: format: uri type: string @@ -24003,6 +24175,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string + password: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string ssl: additionalProperties: false type: object @@ -24094,6 +24290,31 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + nullable: true + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string host: format: uri type: string @@ -24112,6 +24333,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string + password: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string ssl: additionalProperties: false type: object @@ -24154,6 +24399,31 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + nullable: true + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string host: format: uri type: string @@ -24172,6 +24442,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string + password: + anyOf: + - additionalProperties: false + type: object + properties: + id: + type: string + required: + - id + - type: string ssl: additionalProperties: false type: object @@ -29609,6 +29903,30 @@ paths: additionalProperties: false type: object properties: + auth: + additionalProperties: false + type: object + properties: + api_key: + type: string + headers: + items: + additionalProperties: false + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + maxItems: 100 + type: array + password: + type: string + username: + type: string proxy_headers: additionalProperties: anyOf: @@ -53033,6 +53351,8 @@ paths: required: - enabled - is_preconfigured + download_source_auth_secret_storage_requirements_met: + type: boolean has_seen_add_data_notice: type: boolean id: @@ -53207,6 +53527,8 @@ paths: required: - enabled - is_preconfigured + download_source_auth_secret_storage_requirements_met: + type: boolean has_seen_add_data_notice: type: boolean id: diff --git a/packages/kbn-check-saved-objects-cli/current_fields.json b/packages/kbn-check-saved-objects-cli/current_fields.json index 02bae799428c0..161e7b2853b7c 100644 --- a/packages/kbn-check-saved-objects-cli/current_fields.json +++ b/packages/kbn-check-saved-objects-cli/current_fields.json @@ -323,14 +323,10 @@ "version" ], "data_connector": [ - "config", - "createdAt", - "features", "kscIds", "name", "toolIds", "type", - "updatedAt", "workflowIds" ], "data_stream-config": [ @@ -342,12 +338,7 @@ "job_info.job_type", "job_info.status", "metadata", - "metadata.created_at", - "metadata.sample_count", - "metadata.version", - "result", - "result.field_mapping", - "result.ingest_pipeline" + "result" ], "dynamic-config-overrides": [], "endpoint:unified-user-artifact-manifest": [ @@ -850,6 +841,7 @@ "delete_unenrolled_agents", "delete_unenrolled_agents.enabled", "delete_unenrolled_agents.is_preconfigured", + "download_source_auth_secret_storage_requirements_met", "fleet_server_hosts", "has_seen_add_data_notice", "ilm_migration_status", @@ -866,10 +858,6 @@ "data_stream_count", "integration_id", "metadata", - "metadata.created_at", - "metadata.description", - "metadata.title", - "metadata.version", "status" ], "intercept_interaction_record": [], diff --git a/packages/kbn-check-saved-objects-cli/current_mappings.json b/packages/kbn-check-saved-objects-cli/current_mappings.json index 03d74bade4f19..271c0f5896c4d 100644 --- a/packages/kbn-check-saved-objects-cli/current_mappings.json +++ b/packages/kbn-check-saved-objects-cli/current_mappings.json @@ -2785,6 +2785,9 @@ } } }, + "download_source_auth_secret_storage_requirements_met": { + "type": "boolean" + }, "fleet_server_hosts": { "type": "keyword" }, diff --git a/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/ingest_manager_settings/10.8.0.json b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/ingest_manager_settings/10.8.0.json new file mode 100644 index 0000000000000..d1bbc10479a6e --- /dev/null +++ b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/ingest_manager_settings/10.8.0.json @@ -0,0 +1,53 @@ +{ + "10.7.0": [ + { + "has_seen_add_data_notice": true, + "prerelease_integrations_enabled": false, + "id": "id", + "version": "1", + "preconfigured_fields": ["fleet_server_hosts"], + "secret_storage_requirements_met": true, + "output_secret_storage_requirements_met": true, + "action_secret_storage_requirements_met": true, + "use_space_awareness_migration_status": "success", + "use_space_awareness_migration_started_at": "2022-10-11T13:45:20.123Z", + "delete_unenrolled_agents": { + "enabled": true, + "is_preconfigured": false + }, + "ilm_migration_status": { + "logs": "success", + "metrics": "success", + "synthetics": null + }, + "ssl_secret_storage_requirements_met": true, + "integration_knowledge_enabled": true + } + ], + "10.8.0": [ + { + "has_seen_add_data_notice": true, + "prerelease_integrations_enabled": false, + "id": "id", + "version": "1", + "preconfigured_fields": ["fleet_server_hosts"], + "secret_storage_requirements_met": true, + "output_secret_storage_requirements_met": true, + "action_secret_storage_requirements_met": true, + "use_space_awareness_migration_status": "success", + "use_space_awareness_migration_started_at": "2022-10-11T13:45:20.123Z", + "delete_unenrolled_agents": { + "enabled": true, + "is_preconfigured": false + }, + "ilm_migration_status": { + "logs": "success", + "metrics": "success", + "synthetics": null + }, + "ssl_secret_storage_requirements_met": true, + "integration_knowledge_enabled": true, + "download_source_auth_secret_storage_requirements_met": true + } + ] +} diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 2691a7dcd4a2a..f63dabd45c676 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -133,7 +133,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ingest-download-sources": "c87e062ef293585e85fccec0c865d7cef48e0ff9a919d7781d5f7627d275484b", "ingest-outputs": "4f3451469b080548fd0f2ca414a81d91bd0d5690c34378376433ab1ae960ce5c", "ingest-package-policies": "9b9a83b94a99e0574a69999310b00917ddd5bfd44a6079badf54026029fed597", - "ingest_manager_settings": "2fc06a303cbfea707b191b39d6e3ee31ca888cfe4cb1b58542c52443a46f4cd3", + "ingest_manager_settings": "6cd91fe6c52c516676d99021f51a4b3a162686880198ba3c556983f5fffbb5a3", "intercept_interaction_record": "02437dc1b92c7bc77563f8e8d758a13435080d493d21a5fd49d124d468cdeb20", "intercept_trigger_record": "141c827f6553a4b758290690e9ac3ec26f4e6aeedc05a8fc9e0ea163ebfcd8db", "inventory-view": "0583a6777ec2687968aa10e9a184670402a61f30478e8c944f7353e1488ad51e", @@ -859,8 +859,9 @@ describe('checking migration metadata changes on all registered SO types', () => "ingest-package-policies|warning: The SO type owner should ensure these transform functions DO NOT mutate after they are defined.", "================================================================================================================================", "ingest_manager_settings|global: aa0735c39adb396365f28cf118204854b9d5d71e", - "ingest_manager_settings|mappings: 8c1c22339d8aaa9999dd904bcb03612ef17b51dc", + "ingest_manager_settings|mappings: aea7fc3b25c3ec5a35700c777ed9810e0eef6b4a", "ingest_manager_settings|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", + "ingest_manager_settings|10.8.0: e85118e8403d2a79da4c4d7ce6511a90203b7edf51160d3742efc526804379f2", "ingest_manager_settings|10.7.0: ebe7556e3d57d040e577a0e1fbfc4f335182f47a95c95d94d218d8acb2f9ed86", "ingest_manager_settings|10.6.0: 71fa87e5b51ef3832975e22dbdb5f7a1d2604d067c56f7f4877a310b7fe928af", "ingest_manager_settings|10.5.0: 773163d78cfbd807f1c5f615e18a27d885044bb4cbcd99cd6dab00e42dcead22", @@ -1393,7 +1394,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ingest-download-sources": "10.1.0", "ingest-outputs": "10.8.0", "ingest-package-policies": "10.21.0", - "ingest_manager_settings": "10.7.0", + "ingest_manager_settings": "10.8.0", "intercept_interaction_record": "10.1.0", "intercept_trigger_record": "10.1.0", "inventory-view": "10.2.0", @@ -1548,7 +1549,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ingest-download-sources": "10.1.0", "ingest-outputs": "10.8.0", "ingest-package-policies": "10.21.0", - "ingest_manager_settings": "10.7.0", + "ingest_manager_settings": "10.8.0", "intercept_interaction_record": "10.1.0", "intercept_trigger_record": "10.1.0", "inventory-view": "10.2.0", diff --git a/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts b/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts index da7434ad1da17..7efea25e26d0e 100644 --- a/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts +++ b/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts @@ -75,7 +75,7 @@ describe('checking changes on all registered encrypted SO types', () => { "fleet-fleet-server-host": "3b8d0809aaf8a133596307bc29328207c7ceee1dc72233da75141ec47ad8d327", "fleet-message-signing-keys": "5cdcf6bf85247267f8876bda4226e871dbfefe01f050e898db7cbc267d57a275", "fleet-uninstall-tokens": "6e7d75921dcce46e566f175eab1b0e3825fe565f20cdb3c984e7037934d61e23", - "ingest-download-sources": "23eb3cf789fe13b4899215c6f919705b8a44b89f8feba7181e1f5db3c7699d40", + "ingest-download-sources": "b3740796eab0a91736e43bd22f7489cbf6f2ad0241ae370d1c8195b6a8d8ad52", "ingest-outputs": "d66716d5333484a25c57f7917bead5ac2576ec57a4b9eb61701b573f35ab62ad", "privmon-api-key": "7d7b76b3bc5287a784518731ba66d4f761052177fc04b1a85e5605846ab9de42", "synthetics-monitor": "f1c060b7be3b30187c4adcb35d74f1fa8a4290bd7faf04fec869de2aa387e21b", diff --git a/x-pack/platform/plugins/shared/fleet/common/constants/secrets.ts b/x-pack/platform/plugins/shared/fleet/common/constants/secrets.ts index e367b1e976607..9466ed94d9ac2 100644 --- a/x-pack/platform/plugins/shared/fleet/common/constants/secrets.ts +++ b/x-pack/platform/plugins/shared/fleet/common/constants/secrets.ts @@ -11,3 +11,4 @@ export const SECRETS_MINIMUM_FLEET_SERVER_VERSION = '8.10.0'; export const OUTPUT_SECRETS_MINIMUM_FLEET_SERVER_VERSION = '8.12.0'; export const ACTION_SECRETS_MINIMUM_FLEET_SERVER_VERSION = '9.2.0'; export const SSL_SECRETS_MINIMUM_FLEET_SERVER_VERSION = '9.3.0'; +export const DOWNLOAD_SOURCE_AUTH_SECRETS_MINIMUM_FLEET_SERVER_VERSION = '9.4.0'; diff --git a/x-pack/platform/plugins/shared/fleet/common/types/models/agent_policy.ts b/x-pack/platform/plugins/shared/fleet/common/types/models/agent_policy.ts index a672f2d76766f..505cfebede014 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/models/agent_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/models/agent_policy.ts @@ -191,10 +191,30 @@ export interface FullAgentPolicyMonitoring { }; }; } +export interface FullAgentPolicyDownloadAuth { + username?: string; + password?: string; + api_key?: string; + headers?: Array<{ + key: string; + value: string; + }>; +} + +export interface FullAgentPolicyDownloadAuthSecrets { + password?: { id: string }; + api_key?: { id: string }; +} + +export interface FullAgentPolicyDownloadSecrets extends BaseSSLSecrets { + auth?: FullAgentPolicyDownloadAuthSecrets; +} + export interface FullAgentPolicyDownload { sourceURI: string; ssl?: BaseSSLConfig; - secrets?: BaseSSLSecrets; + auth?: FullAgentPolicyDownloadAuth; + secrets?: FullAgentPolicyDownloadSecrets; proxy_url?: string; proxy_headers?: any; } diff --git a/x-pack/platform/plugins/shared/fleet/common/types/models/download_sources.ts b/x-pack/platform/plugins/shared/fleet/common/types/models/download_sources.ts index 5cf3d6c79a68e..696996cf91e45 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/models/download_sources.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/models/download_sources.ts @@ -5,7 +5,14 @@ * 2.0. */ -import type { BaseSSLSecrets } from './secret'; +import type { BaseSSLSecrets, SOSecret } from './secret'; + +export interface DownloadSourceSecrets extends BaseSSLSecrets { + auth?: { + password?: SOSecret; + api_key?: SOSecret; + }; +} export interface DownloadSourceBase { name: string; @@ -17,7 +24,16 @@ export interface DownloadSourceBase { certificate?: string; key?: string; }; - secrets?: BaseSSLSecrets; + auth?: { + headers?: Array<{ + key: string; + value: string; + }>; + username?: string; + password?: string; + api_key?: string; + }; + secrets?: DownloadSourceSecrets; } export type DownloadSource = DownloadSourceBase & { diff --git a/x-pack/platform/plugins/shared/fleet/common/types/models/settings.ts b/x-pack/platform/plugins/shared/fleet/common/types/models/settings.ts index 0ca6e2178ac73..77932c4333820 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/models/settings.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/models/settings.ts @@ -15,6 +15,7 @@ export interface BaseSettings { output_secret_storage_requirements_met?: boolean; action_secret_storage_requirements_met?: boolean; ssl_secret_storage_requirements_met?: boolean; + download_source_auth_secret_storage_requirements_met?: boolean; delete_unenrolled_agents?: { enabled: boolean; is_preconfigured: boolean; diff --git a/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/download_sources.ts b/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/download_sources.ts index db67df94ae265..8b5195df3804f 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/download_sources.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/download_sources.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { DownloadSourceBase, DownloadSource, BaseSSLSecrets } from '../models'; +import type { DownloadSourceBase, DownloadSource, DownloadSourceSecrets } from '../models'; import type { ListResult } from './common'; @@ -36,7 +36,13 @@ export interface PutDownloadSourceRequest { certificate?: string; key?: string; }; - secrets?: BaseSSLSecrets; + auth?: { + username?: string; + password?: string; + api_key?: string; + headers?: Array<{ key: string; value: string }>; + } | null; + secrets?: DownloadSourceSecrets; }; } @@ -52,7 +58,13 @@ export interface PostDownloadSourceRequest { certificate?: string; key?: string; }; - secrets?: BaseSSLSecrets; + auth?: { + username?: string; + password?: string; + api_key?: string; + headers?: Array<{ key: string; value: string }>; + } | null; + secrets?: DownloadSourceSecrets; }; } diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/authentication_form_section.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/authentication_form_section.tsx new file mode 100644 index 0000000000000..ebcb056e39366 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/authentication_form_section.tsx @@ -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 React from 'react'; +import { + EuiAccordion, + EuiButtonGroup, + EuiFieldPassword, + EuiFieldText, + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiTextColor, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; + +import { SecretFormRow } from '../edit_output_flyout/output_form_secret_form_row'; + +import type { AuthType, DownloadSourceFormInputsType } from './use_download_source_flyout_form'; +import { DownloadSourceHeaders } from './download_source_headers'; + +const AUTH_TYPE_OPTIONS = [ + { + id: 'none', + label: i18n.translate('xpack.fleet.settings.editDownloadSourcesFlyout.authTypeNone', { + defaultMessage: 'None', + }), + }, + { + id: 'username_password', + label: i18n.translate( + 'xpack.fleet.settings.editDownloadSourcesFlyout.authTypeUsernamePassword', + { defaultMessage: 'Username & password' } + ), + iconType: 'user', + }, + { + id: 'api_key', + label: i18n.translate('xpack.fleet.settings.editDownloadSourcesFlyout.authTypeApiKey', { + defaultMessage: 'API key', + }), + iconType: 'key', + }, +]; + +function getAuthTypeLabel(authType: AuthType): string { + switch (authType) { + case 'username_password': + return i18n.translate( + 'xpack.fleet.settings.editDownloadSourcesFlyout.authLabelUsernamePassword', + { defaultMessage: 'Username & password' } + ); + case 'api_key': + return i18n.translate('xpack.fleet.settings.editDownloadSourcesFlyout.authLabelApiKey', { + defaultMessage: 'API key', + }); + default: + return i18n.translate('xpack.fleet.settings.editDownloadSourcesFlyout.authLabelNone', { + defaultMessage: 'None', + }); + } +} + +interface AuthenticationFormSectionProps { + inputs: DownloadSourceFormInputsType; + useSecretsStorage: boolean; + isConvertedToSecretPassword: boolean; + isConvertedToSecretApiKey: boolean; + onToggleSecretAndClearValuePassword: (secretEnabled: boolean) => void; + onToggleSecretAndClearValueApiKey: (secretEnabled: boolean) => void; +} + +export const AuthenticationFormSection: React.FunctionComponent = ({ + inputs, + useSecretsStorage, + isConvertedToSecretPassword, + isConvertedToSecretApiKey, + onToggleSecretAndClearValuePassword, + onToggleSecretAndClearValueApiKey, +}) => { + const authType = inputs.authTypeInput.value as AuthType; + const hasHeaders = inputs.headersInput.value.some( + (header) => header.key !== '' || header.value !== '' + ); + const showAccordionOpen = authType !== 'none' || hasHeaders; + + return ( + <> + + +

+ + ({getAuthTypeLabel(authType)}) +

+
+ + } + > + + + inputs.authTypeInput.setValue(id)} + buttonSize="compressed" + isFullWidth + data-test-subj="downloadSourceAuthTypeButtonGroup" + /> + + {authType === 'username_password' && ( + <> + + + } + {...inputs.usernameInput.formRowProps} + > + + + {!useSecretsStorage ? ( + + } + {...inputs.passwordInput.formRowProps} + useSecretsStorage={useSecretsStorage} + onToggleSecretStorage={onToggleSecretAndClearValuePassword} + disabled={!useSecretsStorage} + secretType="download_source_auth" + > + + + ) : ( + + + + )} + + )} + + {authType === 'api_key' && ( + <> + + {!useSecretsStorage ? ( + + } + {...inputs.apiKeyInput.formRowProps} + useSecretsStorage={useSecretsStorage} + onToggleSecretStorage={onToggleSecretAndClearValueApiKey} + disabled={!useSecretsStorage} + secretType="download_source_auth" + > + + + ) : ( + + + + )} + + )} + + + +
+ + + ); +}; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/download_source_headers.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/download_source_headers.tsx new file mode 100644 index 0000000000000..0171af843795a --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/download_source_headers.tsx @@ -0,0 +1,220 @@ +/* + * 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 { + EuiButtonEmpty, + EuiButtonIcon, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormErrorText, + EuiFormRow, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useCallback, useMemo, useState } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import type { DownloadSourceFormInputsType } from './use_download_source_flyout_form'; + +export const DownloadSourceHeaders: React.FunctionComponent<{ + inputs: DownloadSourceFormInputsType; +}> = (props) => { + const { inputs } = props; + const { + props: { onChange }, + value: keyValuePairs, + formRowProps: { error: errors }, + } = inputs.headersInput; + + const [autoFocus, setAutoFocus] = useState(false); + + const handleKeyValuePairChange = useCallback( + (index: number, field: 'key' | 'value', value: string) => { + const updatedPairs = keyValuePairs.map((pair, i) => { + if (i === index) { + return { + ...pair, + [field]: value, + }; + } + return pair; + }); + onChange(updatedPairs); + }, + [keyValuePairs, onChange] + ); + + const addKeyValuePair = useCallback(() => { + setAutoFocus(true); + const updatedPairs = [...keyValuePairs, { key: '', value: '' }]; + onChange(updatedPairs); + }, [keyValuePairs, onChange]); + + const deleteKeyValuePair = useCallback( + (index: number) => { + const updatedPairs = keyValuePairs.filter((_, i) => i !== index); + onChange(updatedPairs); + }, + [keyValuePairs, onChange] + ); + + const deleteButtonDisabled = false; + const hasEmptyRow = keyValuePairs.some((pair) => pair.key === '' && pair.value === ''); + const addKeyValuePairButtonDisabled = hasEmptyRow; + + const displayErrors = (errorMessages?: string[]) => { + return errorMessages?.length + ? errorMessages.map((item, idx) => {item}) + : null; + }; + + const matchErrorsByIndex = useMemo( + () => (index: number, errorType: 'key' | 'value') => { + const headersErrors = errors as + | Array<{ + message: string; + index: number; + hasKeyError: boolean; + hasValueError: boolean; + }> + | undefined; + return headersErrors + ?.filter( + (error) => + error.index === index && (errorType === 'key' ? error.hasKeyError : error.hasValueError) + ) + .map((error) => error.message); + }, + [errors] + ); + + const globalErrors = useMemo(() => { + return ( + errors && + (errors as Array<{ index?: number; message: string }>) + .filter((err) => err.index === undefined) + .map(({ message }) => message) + ); + }, [errors]); + + return ( + <> + + +

+ +

+
+ + {keyValuePairs.map((pair, index) => { + const keyErrors = matchErrorsByIndex(index, 'key'); + const valueErrors = matchErrorsByIndex(index, 'value'); + return ( +
+ {index > 0 && } + + + + ) : undefined + } + error={displayErrors(keyErrors)} + isInvalid={(keyErrors?.length ?? 0) > 0} + > + 0} + data-test-subj={`downloadSourceHeadersKeyInput${index}`} + fullWidth + value={pair.key} + onChange={(e) => handleKeyValuePairChange(index, 'key', e.target.value)} + autoFocus={autoFocus && index === keyValuePairs.length - 1} + placeholder={i18n.translate( + 'xpack.fleet.settings.editDownloadSourcesFlyout.headerKeyPlaceholder', + { defaultMessage: 'Key' } + )} + /> + + + + + + ) : undefined + } + error={displayErrors(valueErrors)} + isInvalid={(valueErrors?.length ?? 0) > 0} + > + 0} + data-test-subj={`downloadSourceHeadersValueInput${index}`} + fullWidth + value={pair.value} + onChange={(e) => handleKeyValuePairChange(index, 'value', e.target.value)} + placeholder={i18n.translate( + 'xpack.fleet.settings.editDownloadSourcesFlyout.headerValuePlaceholder', + { defaultMessage: 'Value' } + )} + /> + + + + + deleteKeyValuePair(index)} + iconType="cross" + disabled={deleteButtonDisabled} + aria-label={i18n.translate( + 'xpack.fleet.settings.editDownloadSourcesFlyout.deleteHeaderButton', + { + defaultMessage: 'Delete header', + } + )} + /> + + +
+ ); + })} + {displayErrors(globalErrors)} + + + + + + + ); +}; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/index.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/index.test.tsx index 878b84b56629f..44e22eea818d2 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/index.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/index.test.tsx @@ -26,7 +26,7 @@ function renderFlyout(downloadSource?: DownloadSource) { return { comp }; } -describe('EditOutputFlyout', () => { +describe('EditDownloadSourceFlyout', () => { it('should render the flyout if there is no download source provided', async () => { const { comp } = renderFlyout(); expect(comp.queryByLabelText('Name')).not.toBeNull(); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/index.tsx index 1c27b52f6633c..23d9e0d13b744 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/index.tsx @@ -36,6 +36,7 @@ import { ExperimentalFeaturesService } from '../../../../services'; import { SSLFormSection } from '../edit_output_flyout/ssl_form_section'; +import { AuthenticationFormSection } from './authentication_form_section'; import { useDowloadSourceFlyoutForm } from './use_download_source_flyout_form'; export interface EditDownloadSourceFlyoutProps { @@ -65,6 +66,8 @@ export const EditDownloadSourceFlyout: React.FunctionComponent { if (!isFirstLoad) return; setIsFirstLoad(false); - // populate the secret input with the value of the plain input in order to re-save the output with secret storage + // populate the secret input with the value of the plain input in order to re-save the download source with secret storage if (useSecretsStorage && enableSSLSecrets) { + const newConvertedState = { ...isConvertedToSecret }; + if (inputs.sslKeyInput.value && !inputs.sslKeySecretInput.value) { inputs.sslKeySecretInput.setValue(inputs.sslKeyInput.value); inputs.sslKeyInput.clear(); - setIsConvertedToSecret({ ...isConvertedToSecret, sslKey: true }); + newConvertedState.sslKey = true; + } + + if (inputs.passwordInput.value && !inputs.passwordSecretInput.value) { + inputs.passwordSecretInput.setValue(inputs.passwordInput.value); + inputs.passwordInput.clear(); + newConvertedState.password = true; } + + if (inputs.apiKeyInput.value && !inputs.apiKeySecretInput.value) { + inputs.apiKeySecretInput.setValue(inputs.apiKeyInput.value); + inputs.apiKeyInput.clear(); + newConvertedState.apiKey = true; + } + + setIsConvertedToSecret(newConvertedState); } }, [ useSecretsStorage, inputs.sslKeyInput, inputs.sslKeySecretInput, + inputs.passwordInput, + inputs.passwordSecretInput, + inputs.apiKeyInput, + inputs.apiKeySecretInput, isFirstLoad, setIsFirstLoad, isConvertedToSecret, @@ -108,7 +131,27 @@ export const EditDownloadSourceFlyout: React.FunctionComponent ({ ...prev, sslKey: false })); + onToggleSecretStorage(secretEnabled); + }; + + const onToggleSecretAndClearValuePassword = (secretEnabled: boolean) => { + if (secretEnabled) { + inputs.passwordInput.clear(); + } else { + inputs.passwordSecretInput.setValue(''); + } + setIsConvertedToSecret((prev) => ({ ...prev, password: false })); + onToggleSecretStorage(secretEnabled); + }; + + const onToggleSecretAndClearValueApiKey = (secretEnabled: boolean) => { + if (secretEnabled) { + inputs.apiKeyInput.clear(); + } else { + inputs.apiKeySecretInput.setValue(''); + } + setIsConvertedToSecret((prev) => ({ ...prev, apiKey: false })); onToggleSecretStorage(secretEnabled); }; @@ -193,6 +236,15 @@ export const EditDownloadSourceFlyout: React.FunctionComponent + + inputs.proxyIdInput.setValue(options?.[0]?.value ?? '')} selectedOptions={ diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.test.tsx index 25136b0cb16db..6adc5d6c5f0e7 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { validateHost } from './use_download_source_flyout_form'; +import { validateHost, validateDownloadSourceHeaders } from './use_download_source_flyout_form'; describe('Download source form validation', () => { describe('validateHost', () => { @@ -45,4 +45,92 @@ describe('Download source form validation', () => { expect(res).toEqual(['Invalid URL']); }); }); + + describe('validateDownloadSourceHeaders', () => { + it('should return undefined for valid headers', () => { + const res = validateDownloadSourceHeaders([ + { key: 'X-Custom-Header', value: 'custom-value' }, + { key: 'Authorization', value: 'Bearer token' }, + ]); + + expect(res).toBeUndefined(); + }); + + it('should return undefined for empty headers (both key and value empty)', () => { + const res = validateDownloadSourceHeaders([{ key: '', value: '' }]); + + expect(res).toBeUndefined(); + }); + + it('should return error when key is provided without value', () => { + const res = validateDownloadSourceHeaders([{ key: 'X-Custom-Header', value: '' }]); + + expect(res).toEqual([ + { + message: 'Missing value for key "X-Custom-Header"', + index: 0, + hasKeyError: false, + hasValueError: true, + }, + ]); + }); + + it('should return error when value is provided without key', () => { + const res = validateDownloadSourceHeaders([{ key: '', value: 'some-value' }]); + + expect(res).toEqual([ + { + message: 'Missing key for value "some-value"', + index: 0, + hasKeyError: true, + hasValueError: false, + }, + ]); + }); + + it('should return error for duplicate keys', () => { + const res = validateDownloadSourceHeaders([ + { key: 'X-Custom-Header', value: 'value1' }, + { key: 'X-Custom-Header', value: 'value2' }, + ]); + + expect(res).toEqual([ + { + message: 'Duplicate key "X-Custom-Header"', + index: 1, + hasKeyError: true, + hasValueError: false, + }, + ]); + }); + + it('should return multiple errors for multiple issues', () => { + const res = validateDownloadSourceHeaders([ + { key: 'X-Valid', value: 'valid' }, + { key: 'X-Missing-Value', value: '' }, + { key: '', value: 'missing-key' }, + { key: 'X-Valid', value: 'duplicate' }, + ]); + + expect(res).toHaveLength(3); + expect(res).toContainEqual({ + message: 'Missing value for key "X-Missing-Value"', + index: 1, + hasKeyError: false, + hasValueError: true, + }); + expect(res).toContainEqual({ + message: 'Missing key for value "missing-key"', + index: 2, + hasKeyError: true, + hasValueError: false, + }); + expect(res).toContainEqual({ + message: 'Duplicate key "X-Valid"', + index: 3, + hasKeyError: true, + hasValueError: false, + }); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.tsx index c76d993aa4750..dd8861d20e4fe 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/download_source_flyout/use_download_source_flyout_form.tsx @@ -9,7 +9,7 @@ import { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { useSecretInput, useComboInput } from '../../../../hooks'; +import { useSecretInput, useComboInput, useRadioInput, useKeyValueInput } from '../../../../hooks'; import { sendPostDownloadSource, useInput, @@ -25,6 +25,8 @@ import type { DownloadSourceBase } from '../../../../../../../common/types'; import { confirmUpdate } from './confirm_update'; +export type AuthType = 'none' | 'username_password' | 'api_key'; + export interface DownloadSourceFormInputsType { nameInput: ReturnType; defaultDownloadSourceInput: ReturnType; @@ -34,6 +36,23 @@ export interface DownloadSourceFormInputsType { sslKeyInput: ReturnType; sslKeySecretInput: ReturnType; sslCertificateAuthoritiesInput: ReturnType; + authTypeInput: ReturnType; + usernameInput: ReturnType; + passwordInput: ReturnType; + passwordSecretInput: ReturnType; + apiKeyInput: ReturnType; + apiKeySecretInput: ReturnType; + headersInput: ReturnType; +} + +function getInitialAuthType(downloadSource?: DownloadSource): AuthType { + if (!downloadSource) return 'none'; + const ds = downloadSource as DownloadSourceBase; + if (ds.auth?.api_key || ds.secrets?.auth?.api_key) return 'api_key'; + if (ds.auth?.username || ds.auth?.password || ds.secrets?.auth?.password) { + return 'username_password'; + } + return 'none'; } export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource?: DownloadSource) { @@ -74,6 +93,41 @@ export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource undefined ); + // Auth inputs + const authTypeInput = useRadioInput(getInitialAuthType(downloadSource), isEditDisabled); + const usernameInput = useInput( + (downloadSource as DownloadSourceBase)?.auth?.username ?? '', + undefined, + isEditDisabled + ); + const passwordInput = useInput( + (downloadSource as DownloadSourceBase)?.auth?.password ?? '', + undefined, + isEditDisabled + ); + const passwordSecretInput = useSecretInput( + (downloadSource as DownloadSourceBase)?.secrets?.auth?.password, + undefined, + isEditDisabled + ); + const apiKeyInput = useInput( + (downloadSource as DownloadSourceBase)?.auth?.api_key ?? '', + undefined, + isEditDisabled + ); + const apiKeySecretInput = useSecretInput( + (downloadSource as DownloadSourceBase)?.secrets?.auth?.api_key, + undefined, + isEditDisabled + ); + + const headersInput = useKeyValueInput( + 'downloadSourceHeadersInput', + (downloadSource as DownloadSourceBase)?.auth?.headers ?? [{ key: '', value: '' }], + validateDownloadSourceHeaders, + isEditDisabled + ); + const inputs = { nameInput, hostInput, @@ -83,6 +137,13 @@ export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource sslKeyInput, sslCertificateAuthoritiesInput, sslKeySecretInput, + authTypeInput, + usernameInput, + passwordInput, + passwordSecretInput, + apiKeyInput, + apiKeySecretInput, + headersInput, }; const hasChanged = Object.values(inputs).some((input) => input.hasChanged); @@ -95,8 +156,81 @@ export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource const sslKeyValid = sslKeyInput.validate(); const sslKeySecretValid = sslKeySecretInput.validate(); - return nameInputValid && hostValid && sslCertificateValid && sslKeyValid && sslKeySecretValid; - }, [nameInput, hostInput, sslCertificateInput, sslKeyInput, sslKeySecretInput]); + const usernameValid = usernameInput.validate(); + const passwordValid = passwordInput.validate(); + const passwordSecretValid = passwordSecretInput.validate(); + const apiKeyValid = apiKeyInput.validate(); + const apiKeySecretValid = apiKeySecretInput.validate(); + const headersValid = headersInput.validate(); + + // Validate auth credentials based on selected auth type + const authType = authTypeInput.value as AuthType; + let authValid = true; + if (authType === 'username_password') { + // Username & password tab: require both username and password + const hasUsername = !!usernameInput.value; + const hasPassword = !!passwordInput.value || !!passwordSecretInput.value; + if (!hasUsername) { + usernameInput.setErrors([ + i18n.translate('xpack.fleet.settings.dowloadSourceFlyoutForm.usernameRequired', { + defaultMessage: 'Username is required', + }), + ]); + authValid = false; + } + if (!hasPassword) { + const passwordRequiredError = [ + i18n.translate('xpack.fleet.settings.dowloadSourceFlyoutForm.passwordRequired', { + defaultMessage: 'Password is required', + }), + ]; + passwordInput.setErrors(passwordRequiredError); + passwordSecretInput.setErrors(passwordRequiredError); + authValid = false; + } + } else if (authType === 'api_key') { + // API key tab: require api_key + const hasApiKey = !!apiKeyInput.value || !!apiKeySecretInput.value; + if (!hasApiKey) { + const apiKeyRequiredError = [ + i18n.translate('xpack.fleet.settings.dowloadSourceFlyoutForm.apiKeyRequired', { + defaultMessage: 'API key is required', + }), + ]; + apiKeyInput.setErrors(apiKeyRequiredError); + apiKeySecretInput.setErrors(apiKeyRequiredError); + authValid = false; + } + } + + return ( + nameInputValid && + hostValid && + sslCertificateValid && + sslKeyValid && + sslKeySecretValid && + usernameValid && + passwordValid && + passwordSecretValid && + apiKeyValid && + apiKeySecretValid && + headersValid && + authValid + ); + }, [ + nameInput, + hostInput, + sslCertificateInput, + sslKeyInput, + sslKeySecretInput, + usernameInput, + passwordInput, + passwordSecretInput, + apiKeyInput, + apiKeySecretInput, + headersInput, + authTypeInput.value, + ]); const submit = useCallback(async () => { try { @@ -105,6 +239,54 @@ export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource } setIsloading(true); + const authType = authTypeInput.value as AuthType; + let auth: PostDownloadSourceRequest['body']['auth'] | null; + + const filteredHeaders = headersInput.value.filter( + (header) => header.key !== '' || header.value !== '' + ); + const hasHeaders = filteredHeaders.length > 0; + + if (authType === 'none') { + // None tab: headers only or clear all auth + auth = hasHeaders ? { headers: filteredHeaders } : null; + } else if (authType === 'username_password') { + auth = { + username: usernameInput.value || undefined, + password: passwordInput.value || undefined, + headers: hasHeaders ? filteredHeaders : undefined, + }; + } else if (authType === 'api_key') { + auth = { + api_key: apiKeyInput.value || undefined, + headers: hasHeaders ? filteredHeaders : undefined, + }; + } else { + auth = null; + } + + const sslSecrets = + !sslKeyInput.value && sslKeySecretInput.value + ? { key: sslKeySecretInput.value } + : undefined; + + let authSecrets: + | { password?: string | { id: string }; api_key?: string | { id: string } } + | undefined; + if (authType === 'username_password' && !passwordInput.value && passwordSecretInput.value) { + authSecrets = { password: passwordSecretInput.value }; + } else if (authType === 'api_key' && !apiKeyInput.value && apiKeySecretInput.value) { + authSecrets = { api_key: apiKeySecretInput.value }; + } + + const secrets = + sslSecrets || authSecrets + ? { + ...(sslSecrets && { ssl: sslSecrets }), + ...(authSecrets && { auth: authSecrets }), + } + : undefined; + const data: PostDownloadSourceRequest['body'] = { name: nameInput.value.trim(), host: hostInput.value.trim(), @@ -115,18 +297,11 @@ export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource key: sslKeyInput.value || undefined, certificate_authorities: sslCertificateAuthoritiesInput.value.filter((val) => val !== ''), }, - ...(!sslKeyInput.value && - sslKeySecretInput.value && { - secrets: { - ssl: { - key: sslKeySecretInput.value || undefined, - }, - }, - }), + auth, + ...(secrets && { secrets }), }; if (downloadSource) { - // Update if (!(await confirmUpdate(downloadSource, confirm))) { setIsloading(false); return; @@ -137,7 +312,6 @@ export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource throw res.error; } } else { - // Create const res = await sendPostDownloadSource(data); if (res.error) { throw res.error; @@ -167,6 +341,13 @@ export function useDowloadSourceFlyoutForm(onSuccess: () => void, downloadSource sslCertificateInput.value, sslKeyInput.value, sslKeySecretInput.value, + authTypeInput.value, + usernameInput.value, + passwordInput.value, + passwordSecretInput.value, + apiKeyInput.value, + apiKeySecretInput.value, + headersInput.value, validate, ]); @@ -209,3 +390,69 @@ export function validateHost(value: string) { ]; } } + +export function validateDownloadSourceHeaders(pairs: Array<{ key: string; value: string }>) { + const errors: Array<{ + message: string; + index: number; + hasKeyError: boolean; + hasValueError: boolean; + }> = []; + + const existingKeys: Set = new Set(); + + pairs.forEach((pair, index) => { + const { key, value } = pair; + + const hasKey = !!key; + const hasValue = !!value; + + if (hasKey && !hasValue) { + errors.push({ + message: i18n.translate( + 'xpack.fleet.settings.dowloadSourceFlyoutForm.headersMissingValueError', + { + defaultMessage: 'Missing value for key "{key}"', + values: { key }, + } + ), + index, + hasKeyError: false, + hasValueError: true, + }); + } else if (!hasKey && hasValue) { + errors.push({ + message: i18n.translate( + 'xpack.fleet.settings.dowloadSourceFlyoutForm.headersMissingKeyError', + { + defaultMessage: 'Missing key for value "{value}"', + values: { value }, + } + ), + index, + hasKeyError: true, + hasValueError: false, + }); + } else if (hasKey && hasValue) { + if (existingKeys.has(key)) { + errors.push({ + message: i18n.translate( + 'xpack.fleet.settings.dowloadSourceFlyoutForm.headersDuplicateKeyError', + { + defaultMessage: 'Duplicate key "{key}"', + values: { key }, + } + ), + index, + hasKeyError: true, + hasValueError: false, + }); + } else { + existingKeys.add(key); + } + } + }); + if (errors.length) { + return errors; + } +} diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_secret_form_row.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_secret_form_row.tsx index 2818b3d26786b..db884b5a37c21 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_secret_form_row.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_secret_form_row.tsx @@ -26,9 +26,10 @@ import { i18n } from '@kbn/i18n'; import { SSL_SECRETS_MINIMUM_FLEET_SERVER_VERSION, OUTPUT_SECRETS_MINIMUM_FLEET_SERVER_VERSION, + DOWNLOAD_SOURCE_AUTH_SECRETS_MINIMUM_FLEET_SERVER_VERSION, } from '../../../../../../../common/constants'; -export type SecretType = 'output' | 'ssl'; +export type SecretType = 'output' | 'ssl' | 'download_source_auth'; export const SecretFormRow: React.FC<{ fullWidth?: boolean; @@ -64,6 +65,8 @@ export const SecretFormRow: React.FC<{ const minVersion = secretType === 'output' ? OUTPUT_SECRETS_MINIMUM_FLEET_SERVER_VERSION + : secretType === 'download_source_auth' + ? DOWNLOAD_SOURCE_AUTH_SECRETS_MINIMUM_FLEET_SERVER_VERSION : SSL_SECRETS_MINIMUM_FLEET_SERVER_VERSION; const hasInitialValue = !!initialValue; const [editMode, setEditMode] = useState(isConvertedToSecret || !initialValue); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/ssl_form_section.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/ssl_form_section.tsx index e70743d2a3de0..fe20410903897 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/ssl_form_section.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/ssl_form_section.tsx @@ -61,23 +61,35 @@ export const SSLFormSection: React.FunctionComponent = (props) => {

- + {type === 'download_source' ? ( + + ) : ( + + )}

- {showmTLSText ? ( + {type === 'download_source' ? ( + + ) : showmTLSText ? ( ) : ( )} diff --git a/x-pack/platform/plugins/shared/fleet/public/hooks/use_input.ts b/x-pack/platform/plugins/shared/fleet/public/hooks/use_input.ts index 21f73add70d7f..16757762cc63c 100644 --- a/x-pack/platform/plugins/shared/fleet/public/hooks/use_input.ts +++ b/x-pack/platform/plugins/shared/fleet/public/hooks/use_input.ts @@ -78,6 +78,7 @@ export function useInput( return true; }, setValue, + setErrors, hasChanged, }; } @@ -146,6 +147,7 @@ export function useSecretInput( return true; }, setValue, + setErrors, hasChanged, }; } diff --git a/x-pack/platform/plugins/shared/fleet/server/constants/index.ts b/x-pack/platform/plugins/shared/fleet/server/constants/index.ts index 0a6752f2d93fa..54307757ae495 100644 --- a/x-pack/platform/plugins/shared/fleet/server/constants/index.ts +++ b/x-pack/platform/plugins/shared/fleet/server/constants/index.ts @@ -103,6 +103,7 @@ export { OUTPUT_SECRETS_MINIMUM_FLEET_SERVER_VERSION, ACTION_SECRETS_MINIMUM_FLEET_SERVER_VERSION, SSL_SECRETS_MINIMUM_FLEET_SERVER_VERSION, + DOWNLOAD_SOURCE_AUTH_SECRETS_MINIMUM_FLEET_SERVER_VERSION, // outputs OUTPUT_HEALTH_DATA_STREAM, FLEET_SETUP_LOCK_TYPE, diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/download_source/handler.ts b/x-pack/platform/plugins/shared/fleet/server/routes/download_source/handler.ts index 99084d8121ba3..822c0d992cefc 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/download_source/handler.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/download_source/handler.ts @@ -26,10 +26,52 @@ import type { import { downloadSourceService } from '../../services/download_source'; import { agentPolicyService } from '../../services'; -function ensureNoDuplicateSecrets(downloadSource: Partial) { +// Support clearing auth via PUT requests +export type DownloadSourceWithNullableAuth = Partial & { + auth?: DownloadSource['auth'] | null; +}; + +/** + * Validates download source auth configuration. + * + * Allowed auth configurations: + * - auth headers only (no credentials) + * - username + password (together), optionally with headers (no api_key) + * - api_key, optionally with headers (no username/password) + * - auth: null (to clear all auth data) + * - auth: undefined (no changes to auth) + */ +export function validateDownloadSource(downloadSource: DownloadSourceWithNullableAuth) { + // For settings that can be stored as secrets, only allow either plain text or secret reference. if (downloadSource.ssl?.key && downloadSource.secrets?.ssl?.key) { throw Boom.badRequest('Cannot specify both ssl.key and secrets.ssl.key'); } + if (downloadSource.auth?.password && downloadSource.secrets?.auth?.password) { + throw Boom.badRequest('Cannot specify both auth.password and secrets.auth.password'); + } + if (downloadSource.auth?.api_key && downloadSource.secrets?.auth?.api_key) { + throw Boom.badRequest('Cannot specify both auth.api_key and secrets.auth.api_key'); + } + + // Disallow setting both username/password and api_key authentication. + const hasUsernameOrPassword = + downloadSource.auth?.username || + downloadSource.auth?.password || + downloadSource.secrets?.auth?.password; + const hasApiKey = downloadSource.auth?.api_key || downloadSource.secrets?.auth?.api_key; + if (hasUsernameOrPassword && hasApiKey) { + throw Boom.badRequest('Cannot specify both username/password and api_key authentication'); + } + + // Ensure username and password are provided together when username/password authentication is used. + const hasUsername = !!downloadSource.auth?.username; + const hasPassword = !!downloadSource.auth?.password || !!downloadSource.secrets?.auth?.password; + if (hasUsername && !hasPassword) { + throw Boom.badRequest('Username and password must be provided together'); + } + if (hasPassword && !hasUsername) { + throw Boom.badRequest('Username and password must be provided together'); + } } export const getDownloadSourcesHandler: RequestHandler = async (context, request, response) => { @@ -75,10 +117,11 @@ export const putDownloadSourcesHandler: RequestHandler< const coreContext = await context.core; const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; - ensureNoDuplicateSecrets(request.body); + const data = request.body as DownloadSourceWithNullableAuth; + validateDownloadSource(data); try { - await downloadSourceService.update(soClient, esClient, request.params.sourceId, request.body); + await downloadSourceService.update(soClient, esClient, request.params.sourceId, data); const downloadSource = await downloadSourceService.get(request.params.sourceId); if (downloadSource.is_default) { await agentPolicyService.bumpAllAgentPolicies(esClient); @@ -109,9 +152,10 @@ export const postDownloadSourcesHandler: RequestHandler< const coreContext = await context.core; const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; - const { id, ...data } = request.body; + const { id, auth, ...restData } = request.body; + const data = { ...restData, ...(auth !== null && { auth }) }; - ensureNoDuplicateSecrets(data); + validateDownloadSource(data); const downloadSource = await downloadSourceService.create(soClient, esClient, data, { id }); if (downloadSource.is_default) { diff --git a/x-pack/platform/plugins/shared/fleet/server/saved_objects/index.ts b/x-pack/platform/plugins/shared/fleet/server/saved_objects/index.ts index 744b6f80518af..7d83bbd65d5da 100644 --- a/x-pack/platform/plugins/shared/fleet/server/saved_objects/index.ts +++ b/x-pack/platform/plugins/shared/fleet/server/saved_objects/index.ts @@ -39,6 +39,7 @@ import { SettingsSchemaV5, SettingsSchemaV6, SettingsSchemaV7, + SettingsSchemaV8, } from '../types'; import { migrateSyntheticsPackagePolicyToV8120 } from './migrations/synthetics/to_v8_12_0'; @@ -187,6 +188,7 @@ export const getSavedObjectTypes = ( }, integration_knowledge_enabled: { type: 'boolean' }, ssl_secret_storage_requirements_met: { type: 'boolean' }, + download_source_auth_secret_storage_requirements_met: { type: 'boolean' }, }, }, migrations: { @@ -277,6 +279,20 @@ export const getSavedObjectTypes = ( create: SettingsSchemaV7, }, }, + 8: { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + download_source_auth_secret_storage_requirements_met: { type: 'boolean' }, + }, + }, + ], + schemas: { + forwardCompatibility: SettingsSchemaV8.extends({}, { unknowns: 'ignore' }), + create: SettingsSchemaV8, + }, + }, }, }, [LEGACY_AGENT_POLICY_SAVED_OBJECT_TYPE]: { @@ -1597,6 +1613,8 @@ export const OUTPUT_ENCRYPTED_FIELDS = new Set([ export const FLEET_SERVER_HOST_ENCRYPTED_FIELDS = new Set([{ key: 'ssl' }]); +export const DOWNLOAD_SOURCE_ENCRYPTED_FIELDS = new Set([{ key: 'ssl' }, { key: 'auth' }]); + export function registerEncryptedSavedObjects( encryptedSavedObjects: EncryptedSavedObjectsPluginSetup ) { @@ -1624,7 +1642,7 @@ export function registerEncryptedSavedObjects( }); encryptedSavedObjects.registerType({ type: DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, - attributesToEncrypt: new Set([{ key: 'ssl' }]), + attributesToEncrypt: DOWNLOAD_SOURCE_ENCRYPTED_FIELDS, // enforceRandomId allows to create an SO with an arbitrary id enforceRandomId: false, }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/full_agent_policy.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/full_agent_policy.test.ts index c7df82ee38db1..2a61c717db02b 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/full_agent_policy.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/full_agent_policy.test.ts @@ -2895,4 +2895,228 @@ describe('getBinarySourceSettings', () => { }); }); }); + + describe('with auth', () => { + it('should return agent download config with plain text auth (username/password)', () => { + const downloadSourceWithAuth = { + ...downloadSource, + auth: { + username: 'user1', + password: 'pass1', + }, + }; + expect(getBinarySourceSettings(downloadSourceWithAuth, undefined)).toEqual({ + sourceURI: 'http://custom-registry-test', + auth: { + username: 'user1', + password: 'pass1', + }, + }); + }); + + it('should return agent download config with plain text auth (api_key)', () => { + const downloadSourceWithApiKey = { + ...downloadSource, + auth: { + api_key: 'my-api-key', + }, + }; + expect(getBinarySourceSettings(downloadSourceWithApiKey, undefined)).toEqual({ + sourceURI: 'http://custom-registry-test', + auth: { + api_key: 'my-api-key', + }, + }); + }); + + it('should return agent download config with secrets.auth.password', () => { + const downloadSourceWithSecretPassword = { + ...downloadSource, + auth: { + username: 'user1', + }, + secrets: { + auth: { + password: { id: 'password-secret-id' }, + }, + }, + }; + expect(getBinarySourceSettings(downloadSourceWithSecretPassword, undefined)).toEqual({ + sourceURI: 'http://custom-registry-test', + auth: { + username: 'user1', + }, + secrets: { + auth: { + password: { id: 'password-secret-id' }, + }, + }, + }); + }); + + it('should return agent download config with secrets.auth.api_key', () => { + const downloadSourceWithSecretApiKey = { + ...downloadSource, + secrets: { + auth: { + api_key: { id: 'api-key-secret-id' }, + }, + }, + }; + expect(getBinarySourceSettings(downloadSourceWithSecretApiKey, undefined)).toEqual({ + sourceURI: 'http://custom-registry-test', + secrets: { + auth: { + api_key: { id: 'api-key-secret-id' }, + }, + }, + }); + }); + + it('should use secret password over plain text password when both are present', () => { + const downloadSourceWithBoth = { + ...downloadSource, + auth: { + username: 'user1', + password: 'plain-text-password', + }, + secrets: { + auth: { + password: { id: 'secret-password-id' }, + }, + }, + }; + expect(getBinarySourceSettings(downloadSourceWithBoth, undefined)).toEqual({ + sourceURI: 'http://custom-registry-test', + auth: { + username: 'user1', + // password should NOT be included here since it's in secrets + }, + secrets: { + auth: { + password: { id: 'secret-password-id' }, + }, + }, + }); + }); + + it('should use secret api_key over plain text api_key when both are present', () => { + const downloadSourceWithBoth = { + ...downloadSource, + auth: { + api_key: 'plain-text-api-key', + }, + secrets: { + auth: { + api_key: { id: 'secret-api-key-id' }, + }, + }, + }; + expect(getBinarySourceSettings(downloadSourceWithBoth, undefined)).toEqual({ + sourceURI: 'http://custom-registry-test', + // auth should NOT be included since api_key is in secrets + secrets: { + auth: { + api_key: { id: 'secret-api-key-id' }, + }, + }, + }); + }); + + it('should return config with both SSL and auth secrets', () => { + const downloadSourceWithAllSecrets = { + ...downloadSource, + auth: { + username: 'user1', + }, + secrets: { + ssl: { + key: { id: 'ssl-key-id' }, + }, + auth: { + password: { id: 'password-secret-id' }, + }, + }, + }; + expect(getBinarySourceSettings(downloadSourceWithAllSecrets, undefined)).toEqual({ + sourceURI: 'http://custom-registry-test', + auth: { + username: 'user1', + }, + secrets: { + ssl: { + key: { id: 'ssl-key-id' }, + }, + auth: { + password: { id: 'password-secret-id' }, + }, + }, + }); + }); + + it('should return agent download config with auth headers', () => { + const downloadSourceWithHeaders = { + ...downloadSource, + auth: { + username: 'user1', + password: 'pass1', + headers: [ + { key: 'X-Custom-Header', value: 'custom-value' }, + { key: 'Authorization', value: 'Bearer token123' }, + ], + }, + }; + expect(getBinarySourceSettings(downloadSourceWithHeaders, undefined)).toEqual({ + sourceURI: 'http://custom-registry-test', + auth: { + username: 'user1', + password: 'pass1', + headers: [ + { key: 'X-Custom-Header', value: 'custom-value' }, + { key: 'Authorization', value: 'Bearer token123' }, + ], + }, + }); + }); + + it('should filter out empty headers', () => { + const downloadSourceWithEmptyHeaders = { + ...downloadSource, + auth: { + api_key: 'my-api-key', + headers: [ + { key: 'X-Valid-Header', value: 'valid-value' }, + { key: '', value: '' }, + { key: 'Another-Header', value: 'another-value' }, + ], + }, + }; + expect(getBinarySourceSettings(downloadSourceWithEmptyHeaders, undefined)).toEqual({ + sourceURI: 'http://custom-registry-test', + auth: { + api_key: 'my-api-key', + headers: [ + { key: 'X-Valid-Header', value: 'valid-value' }, + { key: 'Another-Header', value: 'another-value' }, + ], + }, + }); + }); + + it('should not include headers in auth if all headers are empty', () => { + const downloadSourceWithOnlyEmptyHeaders = { + ...downloadSource, + auth: { + api_key: 'my-api-key', + headers: [{ key: '', value: '' }], + }, + }; + expect(getBinarySourceSettings(downloadSourceWithOnlyEmptyHeaders, undefined)).toEqual({ + sourceURI: 'http://custom-registry-test', + auth: { + api_key: 'my-api-key', + }, + }); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/full_agent_policy.ts b/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/full_agent_policy.ts index b807413c3dad7..45fc09c82c997 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/full_agent_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/full_agent_policy.ts @@ -855,15 +855,60 @@ export function getBinarySourceSettings( }), }; } - // if both ssl.es_key and secrets.ssl.key are present, prefer the secrets' + + if (downloadSource?.auth) { + const authConfig: FullAgentPolicyDownload['auth'] = {}; + if (downloadSource.auth.username) { + authConfig.username = downloadSource.auth.username; + } + if ( + downloadSource.auth.password && + typeof downloadSource?.secrets?.auth?.password !== 'object' + ) { + authConfig.password = downloadSource.auth.password; + } + if (downloadSource.auth.api_key && typeof downloadSource?.secrets?.auth?.api_key !== 'object') { + authConfig.api_key = downloadSource.auth.api_key; + } + // Filter out empty headers (both key and value are empty) + if (downloadSource.auth.headers && downloadSource.auth.headers.length > 0) { + const filteredHeaders = downloadSource.auth.headers.filter( + (header) => header.key !== '' || header.value !== '' + ); + if (filteredHeaders.length > 0) { + authConfig.headers = filteredHeaders; + } + } + if (Object.keys(authConfig).length > 0) { + config.auth = authConfig; + } + } + if (downloadSource?.secrets) { - config.secrets = { - ssl: { - ...(downloadSource.secrets?.ssl?.key && { - key: downloadSource.secrets.ssl.key, - }), - }, - }; + const secretsConfig: FullAgentPolicyDownload['secrets'] = {}; + + if (downloadSource.secrets?.ssl?.key) { + secretsConfig.ssl = { + key: downloadSource.secrets.ssl.key, + }; + } + + if (downloadSource.secrets?.auth) { + const authSecretsConfig: NonNullable['auth'] = {}; + if (typeof downloadSource.secrets.auth.password === 'object') { + authSecretsConfig.password = downloadSource.secrets.auth.password; + } + if (typeof downloadSource.secrets.auth.api_key === 'object') { + authSecretsConfig.api_key = downloadSource.secrets.auth.api_key; + } + if (Object.keys(authSecretsConfig).length > 0) { + secretsConfig.auth = authSecretsConfig; + } + } + + if (Object.keys(secretsConfig).length > 0) { + config.secrets = secretsConfig; + } } if (downloadSourceProxy) { diff --git a/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/package_policies_to_agent_permissions.ts b/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/package_policies_to_agent_permissions.ts index e463fda823cf5..a7fd35730ceba 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/package_policies_to_agent_permissions.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/agent_policies/package_policies_to_agent_permissions.ts @@ -122,7 +122,8 @@ export function storedPackagePoliciesToAgentPermissions( const dataStreams = getNormalizedDataStreams(pkg); if (!isDynamicInput && (!dataStreams || dataStreams.length === 0)) { - return [packagePolicy.id, maybeAddAdditionalPackagePoliciesPermissions(packagePolicy)]; + // Return empty object (not undefined) if no additional permissions + return [packagePolicy.id, maybeAddAdditionalPackagePoliciesPermissions(packagePolicy) ?? {}]; } let dataStreamsForPermissions: DataStreamMeta[]; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/download_source.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/download_source.test.ts index 91ac9ecab4a22..5da227c299342 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/download_source.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/download_source.test.ts @@ -17,9 +17,18 @@ import { DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE } from '../constants'; import { downloadSourceService } from './download_source'; import { appContextService } from './app_context'; import { agentPolicyService } from './agent_policy'; +import { + isSSLSecretStorageEnabled, + isDownloadSourceAuthSecretStorageEnabled, + extractAndWriteDownloadSourcesSecrets, + extractAndUpdateDownloadSourceSecrets, + deleteDownloadSourceSecrets, + deleteSecrets, +} from './secrets'; jest.mock('./app_context'); jest.mock('./agent_policy'); +jest.mock('./secrets'); const mockedAppContextService = appContextService as jest.Mocked; mockedAppContextService.getSecuritySetup.mockImplementation(() => ({ @@ -28,6 +37,26 @@ mockedAppContextService.getSecuritySetup.mockImplementation(() => ({ const mockedAgentPolicyService = agentPolicyService as jest.Mocked; +const mockedIsSSLSecretStorageEnabled = isSSLSecretStorageEnabled as jest.MockedFunction< + typeof isSSLSecretStorageEnabled +>; +const mockedIsDownloadSourceAuthSecretStorageEnabled = + isDownloadSourceAuthSecretStorageEnabled as jest.MockedFunction< + typeof isDownloadSourceAuthSecretStorageEnabled + >; +const mockedExtractAndWriteDownloadSourcesSecrets = + extractAndWriteDownloadSourcesSecrets as jest.MockedFunction< + typeof extractAndWriteDownloadSourcesSecrets + >; +const mockedExtractAndUpdateDownloadSourceSecrets = + extractAndUpdateDownloadSourceSecrets as jest.MockedFunction< + typeof extractAndUpdateDownloadSourceSecrets + >; +const mockedDeleteDownloadSourceSecrets = deleteDownloadSourceSecrets as jest.MockedFunction< + typeof deleteDownloadSourceSecrets +>; +const mockedDeleteSecrets = deleteSecrets as jest.MockedFunction; + function mockDownloadSourceSO(id: string, attributes: any = {}) { return { id, @@ -184,6 +213,23 @@ describe('Download Service', () => { mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ canEncrypt: true, } as any); + + // Default mock implementations for secrets functions + mockedIsSSLSecretStorageEnabled.mockResolvedValue(false); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(false); + mockedExtractAndWriteDownloadSourcesSecrets.mockImplementation(async ({ downloadSource }) => ({ + downloadSource, + secretReferences: [], + })); + mockedExtractAndUpdateDownloadSourceSecrets.mockImplementation( + async ({ downloadSourceUpdate }) => ({ + downloadSourceUpdate, + secretReferences: [], + secretsToDelete: [], + }) + ); + mockedDeleteDownloadSourceSecrets.mockResolvedValue(); + mockedDeleteSecrets.mockResolvedValue(); }); afterEach(() => { @@ -192,6 +238,12 @@ describe('Download Service', () => { mockedAgentPolicyService.removeDefaultSourceFromAll.mockReset(); mockedAppContextService.getInternalUserSOClient.mockReset(); mockedAppContextService.getEncryptedSavedObjectsSetup.mockReset(); + mockedIsSSLSecretStorageEnabled.mockReset(); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockReset(); + mockedExtractAndWriteDownloadSourcesSecrets.mockReset(); + mockedExtractAndUpdateDownloadSourceSecrets.mockReset(); + mockedDeleteDownloadSourceSecrets.mockReset(); + mockedDeleteSecrets.mockReset(); }); const esClient = elasticsearchServiceMock.createInternalClient(); @@ -295,6 +347,272 @@ describe('Download Service', () => { ); expect(soClientMock.create).toBeCalled(); }); + + describe('secret storage', () => { + it('should store SSL secrets when SSL secret storage is enabled', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(true); + mockedExtractAndWriteDownloadSourcesSecrets.mockResolvedValue({ + downloadSource: { + is_default: false, + name: 'Test', + host: 'http://test.co', + secrets: { ssl: { key: { id: 'secret-id' } } }, + }, + secretReferences: [{ id: 'secret-id' }], + }); + + await downloadSourceService.create( + soClientMock, + esClient, + { + is_default: false, + name: 'Test', + host: 'http://test.co', + secrets: { ssl: { key: 'my-ssl-key' } }, + }, + { id: 'download-source-test' } + ); + + expect(mockedExtractAndWriteDownloadSourcesSecrets).toBeCalledWith( + expect.objectContaining({ + includeSSLSecrets: true, + includeAuthSecrets: false, + }) + ); + expect(soClientMock.create).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + secrets: { ssl: { key: { id: 'secret-id' } } }, + }), + expect.anything() + ); + }); + + it('should store auth secrets when auth secret storage is enabled', async () => { + const soClientMock = getMockedSoClient(); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(true); + mockedExtractAndWriteDownloadSourcesSecrets.mockResolvedValue({ + downloadSource: { + is_default: false, + name: 'Test', + host: 'http://test.co', + secrets: { auth: { password: { id: 'auth-secret-id' } } }, + }, + secretReferences: [{ id: 'auth-secret-id' }], + }); + + await downloadSourceService.create( + soClientMock, + esClient, + { + is_default: false, + name: 'Test', + host: 'http://test.co', + secrets: { auth: { password: 'my-password' } }, + }, + { id: 'download-source-test' } + ); + + expect(mockedExtractAndWriteDownloadSourcesSecrets).toBeCalledWith( + expect.objectContaining({ + includeSSLSecrets: false, + includeAuthSecrets: true, + }) + ); + expect(soClientMock.create).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + secrets: { auth: { password: { id: 'auth-secret-id' } } }, + }), + expect.anything() + ); + }); + + it('should store both SSL and auth secrets when both are enabled', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(true); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(true); + mockedExtractAndWriteDownloadSourcesSecrets.mockResolvedValue({ + downloadSource: { + is_default: false, + name: 'Test', + host: 'http://test.co', + secrets: { + ssl: { key: { id: 'ssl-secret-id' } }, + auth: { password: { id: 'auth-secret-id' } }, + }, + }, + secretReferences: [{ id: 'ssl-secret-id' }, { id: 'auth-secret-id' }], + }); + + await downloadSourceService.create( + soClientMock, + esClient, + { + is_default: false, + name: 'Test', + host: 'http://test.co', + secrets: { + ssl: { key: 'my-ssl-key' }, + auth: { password: 'my-password' }, + }, + }, + { id: 'download-source-test' } + ); + + expect(mockedExtractAndWriteDownloadSourcesSecrets).toBeCalledWith( + expect.objectContaining({ + includeSSLSecrets: true, + includeAuthSecrets: true, + }) + ); + }); + + it('should store SSL key as plain text when SSL secret storage is disabled', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(false); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(false); + + await downloadSourceService.create( + soClientMock, + esClient, + { + is_default: false, + name: 'Test', + host: 'http://test.co', + secrets: { ssl: { key: 'my-ssl-key' } }, + }, + { id: 'download-source-test' } + ); + + expect(mockedExtractAndWriteDownloadSourcesSecrets).not.toBeCalled(); + expect(soClientMock.create).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + ssl: JSON.stringify({ key: 'my-ssl-key' }), + }), + expect.anything() + ); + }); + + it('should store auth as plain text when auth secret storage is disabled', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(false); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(false); + + await downloadSourceService.create( + soClientMock, + esClient, + { + is_default: false, + name: 'Test', + host: 'http://test.co', + secrets: { auth: { password: 'my-password' } }, + }, + { id: 'download-source-test' } + ); + + expect(mockedExtractAndWriteDownloadSourcesSecrets).not.toBeCalled(); + expect(soClientMock.create).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + auth: JSON.stringify({ password: 'my-password' }), + }), + expect.anything() + ); + }); + + it('should store SSL as secret but auth as plain text when only SSL secret storage is enabled', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(true); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(false); + mockedExtractAndWriteDownloadSourcesSecrets.mockResolvedValue({ + downloadSource: { + is_default: false, + name: 'Test', + host: 'http://test.co', + secrets: { ssl: { key: { id: 'ssl-secret-id' } } }, + }, + secretReferences: [{ id: 'ssl-secret-id' }], + }); + + await downloadSourceService.create( + soClientMock, + esClient, + { + is_default: false, + name: 'Test', + host: 'http://test.co', + secrets: { + ssl: { key: 'my-ssl-key' }, + auth: { password: 'my-password' }, + }, + }, + { id: 'download-source-test' } + ); + + expect(mockedExtractAndWriteDownloadSourcesSecrets).toBeCalledWith( + expect.objectContaining({ + includeSSLSecrets: true, + includeAuthSecrets: false, + }) + ); + // Auth should be stored as plain text + expect(soClientMock.create).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + auth: JSON.stringify({ password: 'my-password' }), + }), + expect.anything() + ); + }); + + it('should store auth as secret but SSL as plain text when only auth secret storage is enabled', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(false); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(true); + mockedExtractAndWriteDownloadSourcesSecrets.mockResolvedValue({ + downloadSource: { + is_default: false, + name: 'Test', + host: 'http://test.co', + secrets: { auth: { password: { id: 'auth-secret-id' } } }, + }, + secretReferences: [{ id: 'auth-secret-id' }], + }); + + await downloadSourceService.create( + soClientMock, + esClient, + { + is_default: false, + name: 'Test', + host: 'http://test.co', + secrets: { + ssl: { key: 'my-ssl-key' }, + auth: { password: 'my-password' }, + }, + }, + { id: 'download-source-test' } + ); + + expect(mockedExtractAndWriteDownloadSourcesSecrets).toBeCalledWith( + expect.objectContaining({ + includeSSLSecrets: false, + includeAuthSecrets: true, + }) + ); + // SSL should be stored as plain text + expect(soClientMock.create).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + ssl: JSON.stringify({ key: 'my-ssl-key' }), + }), + expect.anything() + ); + }); + }); }); describe('update', () => { @@ -350,6 +668,446 @@ describe('Download Service', () => { } ); }); + + describe('secret storage', () => { + it('should update SSL secrets when SSL secret storage is enabled', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(true); + mockedExtractAndUpdateDownloadSourceSecrets.mockResolvedValue({ + downloadSourceUpdate: { + name: 'Updated Test', + host: 'http://test.co', + secrets: { ssl: { key: { id: 'new-secret-id' } } }, + }, + secretReferences: [{ id: 'new-secret-id' }], + secretsToDelete: [], + }); + + await downloadSourceService.update(soClientMock, esClient, 'download-source-test', { + name: 'Updated Test', + host: 'http://test.co', + secrets: { ssl: { key: 'new-ssl-key' } }, + }); + + expect(mockedExtractAndUpdateDownloadSourceSecrets).toBeCalledWith( + expect.objectContaining({ + includeSSLSecrets: true, + includeAuthSecrets: false, + }) + ); + }); + + it('should update auth secrets when auth secret storage is enabled', async () => { + const soClientMock = getMockedSoClient(); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(true); + mockedExtractAndUpdateDownloadSourceSecrets.mockResolvedValue({ + downloadSourceUpdate: { + name: 'Updated Test', + host: 'http://test.co', + secrets: { auth: { password: { id: 'new-auth-secret-id' } } }, + }, + secretReferences: [{ id: 'new-auth-secret-id' }], + secretsToDelete: [], + }); + + await downloadSourceService.update(soClientMock, esClient, 'download-source-test', { + name: 'Updated Test', + host: 'http://test.co', + secrets: { auth: { password: 'new-password' } }, + }); + + expect(mockedExtractAndUpdateDownloadSourceSecrets).toBeCalledWith( + expect.objectContaining({ + includeSSLSecrets: false, + includeAuthSecrets: true, + }) + ); + }); + + it('should update both SSL and auth secrets when both are enabled', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(true); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(true); + mockedExtractAndUpdateDownloadSourceSecrets.mockResolvedValue({ + downloadSourceUpdate: { + name: 'Updated Test', + host: 'http://test.co', + secrets: { + ssl: { key: { id: 'new-ssl-secret-id' } }, + auth: { password: { id: 'new-auth-secret-id' } }, + }, + }, + secretReferences: [{ id: 'new-ssl-secret-id' }, { id: 'new-auth-secret-id' }], + secretsToDelete: [], + }); + + await downloadSourceService.update(soClientMock, esClient, 'download-source-test', { + name: 'Updated Test', + host: 'http://test.co', + secrets: { + ssl: { key: 'new-ssl-key' }, + auth: { password: 'new-password' }, + }, + }); + + expect(mockedExtractAndUpdateDownloadSourceSecrets).toBeCalledWith( + expect.objectContaining({ + includeSSLSecrets: true, + includeAuthSecrets: true, + }) + ); + }); + + it('should store SSL key as plain text when SSL secret storage is disabled', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(false); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(false); + + await downloadSourceService.update(soClientMock, esClient, 'download-source-test', { + name: 'Updated Test', + host: 'http://test.co', + secrets: { ssl: { key: 'new-ssl-key' } }, + }); + + expect(mockedExtractAndUpdateDownloadSourceSecrets).not.toBeCalled(); + expect(soClientMock.update).toBeCalledWith( + expect.anything(), + 'download-source-test', + expect.objectContaining({ + ssl: JSON.stringify({ key: 'new-ssl-key' }), + }) + ); + }); + + it('should store auth as plain text when auth secret storage is disabled', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(false); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(false); + + await downloadSourceService.update(soClientMock, esClient, 'download-source-test', { + name: 'Updated Test', + host: 'http://test.co', + secrets: { auth: { password: 'new-password' } }, + }); + + expect(mockedExtractAndUpdateDownloadSourceSecrets).not.toBeCalled(); + expect(soClientMock.update).toBeCalledWith( + expect.anything(), + 'download-source-test', + expect.objectContaining({ + auth: JSON.stringify({ password: 'new-password' }), + }) + ); + }); + + it('should store SSL as secret but auth as plain text when only SSL secret storage is enabled', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(true); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(false); + mockedExtractAndUpdateDownloadSourceSecrets.mockResolvedValue({ + downloadSourceUpdate: { + name: 'Updated Test', + host: 'http://test.co', + secrets: { ssl: { key: { id: 'new-ssl-secret-id' } } }, + }, + secretReferences: [{ id: 'new-ssl-secret-id' }], + secretsToDelete: [], + }); + + await downloadSourceService.update(soClientMock, esClient, 'download-source-test', { + name: 'Updated Test', + host: 'http://test.co', + secrets: { + ssl: { key: 'new-ssl-key' }, + auth: { password: 'new-password' }, + }, + }); + + expect(mockedExtractAndUpdateDownloadSourceSecrets).toBeCalledWith( + expect.objectContaining({ + includeSSLSecrets: true, + includeAuthSecrets: false, + }) + ); + // Auth should be stored as plain text + expect(soClientMock.update).toBeCalledWith( + expect.anything(), + 'download-source-test', + expect.objectContaining({ + auth: JSON.stringify({ password: 'new-password' }), + }) + ); + }); + }); + + describe('auth type switching', () => { + it('should remove password secret reference when switching to API key auth', async () => { + const soClientMock = getMockedSoClient(); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(true); + + // Mock the original item to have password auth + const esoClientMockLocal = getMockedEncryptedSoClient(); + esoClientMockLocal.getDecryptedAsInternalUser.mockResolvedValue( + mockDownloadSourceSO('download-source-test', { + is_default: false, + name: 'Test', + host: 'http://test.co', + auth: JSON.stringify({ username: 'user1' }), + secrets: { auth: { password: { id: 'old-password-secret-id' } } }, + }) + ); + + mockedExtractAndUpdateDownloadSourceSecrets.mockResolvedValue({ + downloadSourceUpdate: { + secrets: { auth: { api_key: { id: 'new-api-key-secret-id' } } }, + }, + secretReferences: [{ id: 'new-api-key-secret-id' }], + secretsToDelete: [{ id: 'old-password-secret-id' }], + }); + + await downloadSourceService.update(soClientMock, esClient, 'download-source-test', { + secrets: { auth: { api_key: 'new-api-key' } }, + }); + + // Verify the update was called with secrets containing only api_key (not password) + expect(soClientMock.update).toBeCalledWith( + expect.anything(), + 'download-source-test', + expect.objectContaining({ + secrets: expect.objectContaining({ + auth: expect.objectContaining({ + api_key: { id: 'new-api-key-secret-id' }, + }), + }), + }) + ); + }); + + it('should clear username when API key is set', async () => { + const soClientMock = getMockedSoClient(); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(false); + + // Mock the original item to have username/password auth + const esoClientMockLocal = getMockedEncryptedSoClient(); + esoClientMockLocal.getDecryptedAsInternalUser.mockResolvedValue( + mockDownloadSourceSO('download-source-test', { + is_default: false, + name: 'Test', + host: 'http://test.co', + auth: JSON.stringify({ username: 'user1', password: 'pass1' }), + }) + ); + + await downloadSourceService.update(soClientMock, esClient, 'download-source-test', { + auth: { api_key: 'new-api-key' }, + }); + + // Verify username is NOT in the stored auth + expect(soClientMock.update).toBeCalledWith( + expect.anything(), + 'download-source-test', + expect.objectContaining({ + auth: JSON.stringify({ api_key: 'new-api-key' }), + }) + ); + }); + + it('should clear username when secret API key is set', async () => { + const soClientMock = getMockedSoClient(); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(true); + + // Mock the original item to have username/password auth + const esoClientMockLocal = getMockedEncryptedSoClient(); + esoClientMockLocal.getDecryptedAsInternalUser.mockResolvedValue( + mockDownloadSourceSO('download-source-test', { + is_default: false, + name: 'Test', + host: 'http://test.co', + auth: JSON.stringify({ username: 'user1' }), + secrets: { auth: { password: { id: 'old-password-secret-id' } } }, + }) + ); + + mockedExtractAndUpdateDownloadSourceSecrets.mockResolvedValue({ + downloadSourceUpdate: { + secrets: { auth: { api_key: { id: 'new-api-key-secret-id' } } }, + }, + secretReferences: [{ id: 'new-api-key-secret-id' }], + secretsToDelete: [{ id: 'old-password-secret-id' }], + }); + + await downloadSourceService.update(soClientMock, esClient, 'download-source-test', { + secrets: { auth: { api_key: 'new-api-key' } }, + }); + + // Verify username is cleared (auth should be null or empty) + expect(soClientMock.update).toBeCalledWith( + expect.anything(), + 'download-source-test', + expect.objectContaining({ + auth: null, + }) + ); + }); + + it('should preserve SSL secrets when updating only name/host (not SSL fields)', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(true); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(false); + + const esoClientMockLocal = getMockedEncryptedSoClient(); + esoClientMockLocal.getDecryptedAsInternalUser.mockResolvedValue( + mockDownloadSourceSO('download-source-test', { + is_default: false, + name: 'Test', + host: 'http://test.co', + ssl: JSON.stringify({ certificate: 'cert' }), + secrets: { ssl: { key: { id: 'existing-ssl-secret-id' } } }, + }) + ); + + mockedExtractAndUpdateDownloadSourceSecrets.mockResolvedValue({ + downloadSourceUpdate: { + name: 'Updated Name', + }, + secretReferences: [], + secretsToDelete: [{ id: 'existing-ssl-secret-id' }], + }); + + await downloadSourceService.update(soClientMock, esClient, 'download-source-test', { + name: 'Updated Name', + }); + + expect(mockedDeleteSecrets).not.toBeCalled(); + + expect(soClientMock.update).toBeCalledWith( + expect.anything(), + 'download-source-test', + expect.objectContaining({ + secrets: expect.objectContaining({ + ssl: { key: { id: 'existing-ssl-secret-id' } }, + }), + }) + ); + }); + + it('should preserve auth secrets when updating only name/host (not auth fields)', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(false); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(true); + + const esoClientMockLocal = getMockedEncryptedSoClient(); + esoClientMockLocal.getDecryptedAsInternalUser.mockResolvedValue( + mockDownloadSourceSO('download-source-test', { + is_default: false, + name: 'Test', + host: 'http://test.co', + auth: JSON.stringify({ username: 'user1' }), + secrets: { auth: { password: { id: 'existing-auth-secret-id' } } }, + }) + ); + + mockedExtractAndUpdateDownloadSourceSecrets.mockResolvedValue({ + downloadSourceUpdate: { + name: 'Updated Name', + }, + secretReferences: [], + secretsToDelete: [{ id: 'existing-auth-secret-id' }], + }); + + await downloadSourceService.update(soClientMock, esClient, 'download-source-test', { + name: 'Updated Name', + }); + + expect(mockedDeleteSecrets).not.toBeCalled(); + + expect(soClientMock.update).toBeCalledWith( + expect.anything(), + 'download-source-test', + expect.objectContaining({ + secrets: expect.objectContaining({ + auth: { password: { id: 'existing-auth-secret-id' } }, + }), + }) + ); + }); + + it('should delete auth secrets when explicitly setting auth to null', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(false); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(true); + + const esoClientMockLocal = getMockedEncryptedSoClient(); + esoClientMockLocal.getDecryptedAsInternalUser.mockResolvedValue( + mockDownloadSourceSO('download-source-test', { + is_default: false, + name: 'Test', + host: 'http://test.co', + auth: JSON.stringify({ username: 'user1' }), + secrets: { auth: { password: { id: 'existing-auth-secret-id' } } }, + }) + ); + + mockedExtractAndUpdateDownloadSourceSecrets.mockResolvedValue({ + downloadSourceUpdate: {}, + secretReferences: [], + secretsToDelete: [{ id: 'existing-auth-secret-id' }], + }); + + await downloadSourceService.update(soClientMock, esClient, 'download-source-test', { + auth: null, + } as Parameters[3]); + + expect(mockedDeleteSecrets).toBeCalledWith( + expect.objectContaining({ + ids: ['existing-auth-secret-id'], + }) + ); + }); + + it('should replace auth entirely when updating with headers only', async () => { + const soClientMock = getMockedSoClient(); + mockedIsSSLSecretStorageEnabled.mockResolvedValue(false); + mockedIsDownloadSourceAuthSecretStorageEnabled.mockResolvedValue(true); + + const esoClientMockLocal = getMockedEncryptedSoClient(); + esoClientMockLocal.getDecryptedAsInternalUser.mockResolvedValue( + mockDownloadSourceSO('download-source-test', { + is_default: false, + name: 'Test', + host: 'http://test.co', + auth: JSON.stringify({ username: 'user1' }), + secrets: { auth: { password: { id: 'existing-auth-secret-id' } } }, + }) + ); + + mockedExtractAndUpdateDownloadSourceSecrets.mockResolvedValue({ + downloadSourceUpdate: { + auth: { headers: [{ key: 'X-Custom', value: 'test' }] }, + }, + secretReferences: [], + secretsToDelete: [{ id: 'existing-auth-secret-id' }], + }); + + await downloadSourceService.update(soClientMock, esClient, 'download-source-test', { + auth: { headers: [{ key: 'X-Custom', value: 'test' }] }, + }); + + expect(mockedDeleteSecrets).toBeCalledWith( + expect.objectContaining({ + ids: ['existing-auth-secret-id'], + }) + ); + + expect(soClientMock.update).toBeCalledWith( + expect.anything(), + 'download-source-test', + expect.objectContaining({ + auth: JSON.stringify({ headers: [{ key: 'X-Custom', value: 'test' }] }), + }) + ); + }); + }); }); describe('delete', () => { @@ -359,6 +1117,19 @@ describe('Download Service', () => { expect(mockedAgentPolicyService.removeDefaultSourceFromAll).toBeCalled(); expect(soClientMock.delete).toBeCalled(); }); + + it('should delete secrets when deleting a download source', async () => { + const soClientMock = getMockedSoClient(); + await downloadSourceService.delete('download-source-test'); + expect(mockedDeleteDownloadSourceSecrets).toBeCalledWith( + expect.objectContaining({ + downloadSource: expect.objectContaining({ + id: 'download-source-test', + }), + }) + ); + expect(soClientMock.delete).toBeCalled(); + }); }); describe('get', () => { diff --git a/x-pack/platform/plugins/shared/fleet/server/services/download_source.ts b/x-pack/platform/plugins/shared/fleet/server/services/download_source.ts index 2138989578acb..d1a0f178e9243 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/download_source.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/download_source.ts @@ -29,7 +29,11 @@ import { } from '../errors'; import { SO_SEARCH_LIMIT } from '../../common'; -import { deleteDownloadSourceSecrets, deleteSecrets, isSecretStorageEnabled } from './secrets'; +import { + deleteDownloadSourceSecrets, + deleteSecrets, + isDownloadSourceAuthSecretStorageEnabled, +} from './secrets'; import { agentPolicyService } from './agent_policy'; import { appContextService } from './app_context'; @@ -42,12 +46,32 @@ import { import { isSSLSecretStorageEnabled } from './secrets'; function savedObjectToDownloadSource(so: SavedObject) { - const { ssl, source_id: sourceId, ...attributes } = so.attributes; + const { ssl, auth, source_id: sourceId, secrets, ...attributes } = so.attributes; + + // Clean up null values from secrets (they may be set during updates to force removal) + let cleanedSecrets: typeof secrets | undefined; + if (secrets) { + const cleanedAuth = secrets.auth + ? Object.fromEntries(Object.entries(secrets.auth).filter(([_, v]) => v != null)) + : undefined; + const cleanedSsl = secrets.ssl + ? Object.fromEntries(Object.entries(secrets.ssl).filter(([_, v]) => v != null)) + : undefined; + cleanedSecrets = { + ...(cleanedSsl && Object.keys(cleanedSsl).length > 0 ? { ssl: cleanedSsl } : {}), + ...(cleanedAuth && Object.keys(cleanedAuth).length > 0 ? { auth: cleanedAuth } : {}), + } as typeof secrets; + if (Object.keys(cleanedSecrets).length === 0) { + cleanedSecrets = undefined; + } + } return { id: sourceId ?? so.id, ...attributes, + ...(cleanedSecrets ? { secrets: cleanedSecrets } : {}), ...(ssl ? { ssl: JSON.parse(ssl as string) } : {}), + ...(auth ? { auth: JSON.parse(auth as string) } : {}), }; } @@ -117,7 +141,9 @@ class DownloadSourceService { const logger = appContextService.getLogger(); logger.debug(`Creating new download source`); - const data: DownloadSourceSOAttributes = { ...omit(downloadSource, ['ssl', 'secrets']) }; + const data: DownloadSourceSOAttributes = { + ...omit(downloadSource, ['ssl', 'auth', 'secrets']), + }; if (!appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt) { throw new FleetEncryptedSavedObjectEncryptionKeyRequired( @@ -148,22 +174,58 @@ class DownloadSourceService { if (downloadSource.ssl) { data.ssl = JSON.stringify(downloadSource.ssl); } + + const sslSecretStorageEnabled = await isSSLSecretStorageEnabled(esClient, soClient); + const authSecretStorageEnabled = await isDownloadSourceAuthSecretStorageEnabled( + esClient, + soClient + ); + // Store secret values if enabled; if not, store plain text values - if (await isSSLSecretStorageEnabled(esClient, soClient)) { + if (sslSecretStorageEnabled || authSecretStorageEnabled) { const { downloadSource: downloadSourceWithSecrets } = await extractAndWriteDownloadSourcesSecrets({ downloadSource, esClient, + includeSSLSecrets: sslSecretStorageEnabled, + includeAuthSecrets: authSecretStorageEnabled, }); - if (downloadSourceWithSecrets.secrets) + if (downloadSourceWithSecrets.secrets) { data.secrets = downloadSourceWithSecrets.secrets as DownloadSourceSOAttributes['secrets']; - } else { + } + } + + // Handle auth field: when secret storage is enabled, remove sensitive values from plain text + if (downloadSource.auth) { + const authToStore = { ...downloadSource.auth }; + if (authSecretStorageEnabled) { + delete authToStore.password; + delete authToStore.api_key; + } + if (Object.keys(authToStore).length > 0) { + data.auth = JSON.stringify(authToStore); + } + } + + if (!sslSecretStorageEnabled) { if (!downloadSource.ssl?.key && downloadSource.secrets?.ssl?.key) { data.ssl = JSON.stringify({ ...downloadSource.ssl, ...downloadSource.secrets.ssl }); } } + if (!authSecretStorageEnabled) { + if (downloadSource.secrets?.auth) { + const plainTextAuth = { + ...downloadSource.auth, + ...downloadSource.secrets.auth, + }; + data.auth = JSON.stringify(plainTextAuth); + } else if (downloadSource.auth) { + data.auth = JSON.stringify(downloadSource.auth); + } + } + const newSo = await this.soClient.create( DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, data, @@ -186,7 +248,7 @@ class DownloadSourceService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, id: string, - newData: Partial + newData: Partial & { auth?: DownloadSource['auth'] | null } ) { let secretsToDelete: SecretReference[] = []; @@ -195,7 +257,7 @@ class DownloadSourceService { const originalItem = await this.get(id); const updateData: Partial = { - ...omit(newData, ['ssl', 'secrets']), + ...omit(newData, ['ssl', 'auth', 'secrets']), }; if (updateData.proxy_id) { @@ -215,6 +277,19 @@ class DownloadSourceService { updateData.ssl = null; } + // Handle auth field: + // - auth undefined AND no secrets.auth: preserve existing auth + // - auth null: clear all auth + // - auth object: replace entire auth state + const isAuthBeingUpdated = newData.auth !== undefined || newData.secrets?.auth !== undefined; + + let computedAuth: DownloadSourceBase['auth'] | null | undefined; + if (newData.auth !== undefined) { + computedAuth = newData.auth; + } else if (newData.secrets?.auth !== undefined) { + computedAuth = null; + } + if (updateData.is_default) { const defaultDownloadSourceId = await this.getDefaultDownloadSourceId(); @@ -222,23 +297,123 @@ class DownloadSourceService { await this.update(soClient, esClient, defaultDownloadSourceId, { is_default: false }); } } + + const sslSecretStorageEnabled = await isSSLSecretStorageEnabled(esClient, soClient); + const authSecretStorageEnabled = await isDownloadSourceAuthSecretStorageEnabled( + esClient, + soClient + ); + + const isSslBeingUpdated = newData.ssl !== undefined || newData.secrets?.ssl !== undefined; + + const getSecretId = (soSecret: unknown): string | undefined => { + if (typeof soSecret === 'object' && soSecret !== null && 'id' in soSecret) { + return (soSecret as { id: string }).id; + } + return undefined; + }; + // Store secret values if enabled; if not, store plain text values - if (await isSecretStorageEnabled(esClient, soClient)) { + if (sslSecretStorageEnabled || authSecretStorageEnabled) { const secretsRes = await extractAndUpdateDownloadSourceSecrets({ oldDownloadSource: originalItem, downloadSourceUpdate: newData, esClient, + includeSSLSecrets: sslSecretStorageEnabled, + includeAuthSecrets: authSecretStorageEnabled, }); - updateData.secrets = secretsRes.downloadSourceUpdate - .secrets as DownloadSourceSOAttributes['secrets']; - secretsToDelete = secretsRes.secretsToDelete; - } else { + // Filter out secrets for fields that are not being updated + secretsToDelete = secretsRes.secretsToDelete.filter((secret) => { + const sslKeyId = getSecretId(originalItem.secrets?.ssl?.key); + if (sslKeyId === secret.id && !isSslBeingUpdated) { + return false; + } + + const authPasswordId = getSecretId(originalItem.secrets?.auth?.password); + const authApiKeyId = getSecretId(originalItem.secrets?.auth?.api_key); + if ((authPasswordId === secret.id || authApiKeyId === secret.id) && !isAuthBeingUpdated) { + return false; + } + + return true; + }); + + const oldSecrets = (originalItem.secrets || {}) as DownloadSourceSOAttributes['secrets']; + const newSecrets = (secretsRes.downloadSourceUpdate.secrets || + {}) as DownloadSourceSOAttributes['secrets']; + + const mergedSecrets: DownloadSourceSOAttributes['secrets'] = {}; + + // SSL secrets: merge old and new, then remove deleted ones + // When SSL is NOT being updated, preserve the old SSL secrets + if (sslSecretStorageEnabled) { + if (isSslBeingUpdated) { + mergedSecrets.ssl = { ...oldSecrets?.ssl, ...newSecrets?.ssl }; + for (const secretToDelete of secretsToDelete) { + if (mergedSecrets.ssl?.key?.id === secretToDelete.id) { + delete mergedSecrets.ssl.key; + } + } + if (mergedSecrets.ssl && Object.keys(mergedSecrets.ssl).length === 0) { + delete mergedSecrets.ssl; + } + } else { + mergedSecrets.ssl = oldSecrets?.ssl; + } + } + + if (authSecretStorageEnabled) { + if (isAuthBeingUpdated) { + const newAuthSecrets = newSecrets?.auth; + mergedSecrets.auth = { + password: newAuthSecrets?.password || null, + api_key: newAuthSecrets?.api_key || null, + } as NonNullable['auth']; + } else { + mergedSecrets.auth = oldSecrets?.auth; + } + } + + updateData.secrets = { + ssl: mergedSecrets.ssl || null, + auth: mergedSecrets.auth, + } as DownloadSourceSOAttributes['secrets']; + } + + if (!sslSecretStorageEnabled) { if (!newData.ssl?.key && newData.secrets?.ssl?.key) { updateData.ssl = JSON.stringify({ ...newData.ssl, ...newData.secrets.ssl }); } } + if (authSecretStorageEnabled) { + if (computedAuth !== undefined) { + if (computedAuth === null) { + updateData.auth = null; + } else { + const authToStore = { ...computedAuth }; + delete authToStore.password; + delete authToStore.api_key; + const hasRemainingAuth = Object.keys(authToStore).length > 0; + updateData.auth = hasRemainingAuth ? JSON.stringify(authToStore) : null; + } + } + } else { + if (newData.secrets?.auth) { + const plainTextAuth = { + ...computedAuth, + ...newData.secrets.auth, + }; + updateData.auth = JSON.stringify(plainTextAuth); + } else if (computedAuth !== undefined) { + updateData.auth = + computedAuth === null || Object.keys(computedAuth).length === 0 + ? null + : JSON.stringify(computedAuth); + } + } + if (secretsToDelete.length) { try { await deleteSecrets({ esClient, ids: secretsToDelete.map((s) => s.id) }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/secrets/actions.ts b/x-pack/platform/plugins/shared/fleet/server/services/secrets/actions.ts index 1da8eceeeda23..65bb0f8dc4c68 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/secrets/actions.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/secrets/actions.ts @@ -13,66 +13,25 @@ import type { SecretReference, SOSecretPath, } from '../../../common/types'; -import { appContextService } from '../app_context'; -import { settingsService } from '..'; -import { checkFleetServerVersionsForSecretsStorage } from '../fleet_server'; import { ACTION_SECRETS_MINIMUM_FLEET_SERVER_VERSION } from '../../constants'; -import { deleteSOSecrets, extractAndWriteSOSecrets } from './common'; +import { + deleteSOSecrets, + extractAndWriteSOSecrets, + isSecretStorageEnabledForFeature, +} from './common'; -/** - * Check if action secret storage is enabled. - * Returns true if fleet server is standalone (serverless). - * Otherwise, checks if the minimum fleet server version requirement has been met. - * If the requirement has been met, updates the settings to enable action secret storage. - */ export async function isActionSecretStorageEnabled( esClient: ElasticsearchClient, soClient: SavedObjectsClientContract ): Promise { - const logger = appContextService.getLogger(); - - // if serverless then action secrets will always be supported - const isFleetServerStandalone = - appContextService.getConfig()?.internal?.fleetServerStandalone ?? false; - - if (isFleetServerStandalone) { - logger.trace('Action 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, action secrets are always on - const settings = await settingsService.getSettingsOrUndefined(soClient); - - if (settings && settings.action_secret_storage_requirements_met) { - logger.debug('Action secrets storage requirements already met, turned on in settings'); - return true; - } - - // otherwise check if we have the minimum fleet server version and enable secrets if so - if ( - await checkFleetServerVersionsForSecretsStorage( - esClient, - soClient, - ACTION_SECRETS_MINIMUM_FLEET_SERVER_VERSION - ) - ) { - logger.debug('Enabling action secrets storage as minimum fleet server version has been met'); - try { - await settingsService.saveSettings(soClient, { - action_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 action secrets storage: ${err.message}`); - } - - return true; - } - - logger.info('Secrets storage is disabled as minimum fleet server version has not been met'); - return false; + return isSecretStorageEnabledForFeature({ + esClient, + soClient, + featureName: 'Action secrets', + minimumFleetServerVersion: ACTION_SECRETS_MINIMUM_FLEET_SERVER_VERSION, + settingKey: 'action_secret_storage_requirements_met', + }); } /** diff --git a/x-pack/platform/plugins/shared/fleet/server/services/secrets/common.ts b/x-pack/platform/plugins/shared/fleet/server/services/secrets/common.ts index 948d1cf7f2c46..76dc1a104a78f 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/secrets/common.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/secrets/common.ts @@ -14,6 +14,7 @@ import type { SOSecretPath, DeletedSecretResponse, DeletedSecretReference, + BaseSettings, } from '../../../common/types'; import type { SecretReference } from '../../types'; import { FleetError } from '../../errors'; @@ -24,10 +25,46 @@ import { appContextService } from '../app_context'; import { settingsService } from '..'; import { checkFleetServerVersionsForSecretsStorage } from '../fleet_server'; -export async function isSecretStorageEnabled( - esClient: ElasticsearchClient, - soClient: SavedObjectsClientContract +type SecretStorageSettingsKey = Extract< + keyof BaseSettings, + | 'secret_storage_requirements_met' + | 'output_secret_storage_requirements_met' + | 'action_secret_storage_requirements_met' + | 'ssl_secret_storage_requirements_met' + | 'download_source_auth_secret_storage_requirements_met' +>; + +export interface SecretStorageCheckOptions { + esClient: ElasticsearchClient; + soClient: SavedObjectsClientContract; + /** + * A human-readable name for the feature (used in logging). + * Defaults to "Secrets". + */ + featureName?: string; + /** + * The minimum fleet server version required for this secret storage feature. + * Defaults to SECRETS_MINIMUM_FLEET_SERVER_VERSION. + */ + minimumFleetServerVersion?: string; + /** + * The setting key to check/update for this feature. + * Defaults to 'secret_storage_requirements_met'. + */ + settingKey?: SecretStorageSettingsKey; +} + +export async function isSecretStorageEnabledForFeature( + opts: SecretStorageCheckOptions ): Promise { + const { + esClient, + soClient, + featureName = 'Secrets', + minimumFleetServerVersion = SECRETS_MINIMUM_FLEET_SERVER_VERSION, + settingKey = 'secret_storage_requirements_met', + } = opts; + const logger = appContextService.getLogger(); // if serverless then secrets will always be supported @@ -35,7 +72,7 @@ export async function isSecretStorageEnabled( appContextService.getConfig()?.internal?.fleetServerStandalone ?? false; if (isFleetServerStandalone) { - logger.trace('Secrets storage is enabled as fleet server is standalone'); + logger.trace(`${featureName} storage is enabled as fleet server is standalone`); return true; } @@ -43,36 +80,51 @@ export async function isSecretStorageEnabled( // once the requirement has been met, secrets are always on const settings = await settingsService.getSettingsOrUndefined(soClient); - if (settings && settings.secret_storage_requirements_met) { - logger.debug('Secrets storage requirements already met, turned on in settings'); + if (settings && settings[settingKey]) { + logger.debug(`${featureName} storage requirements already met, turned on in settings`); return true; } const areAllFleetServersOnProperVersion = await checkFleetServerVersionsForSecretsStorage( esClient, soClient, - SECRETS_MINIMUM_FLEET_SERVER_VERSION + minimumFleetServerVersion ); // otherwise check if we have the minimum fleet server version and enable secrets if so if (areAllFleetServersOnProperVersion) { - logger.debug('Enabling secrets storage as minimum fleet server version has been met'); + logger.debug(`Enabling ${featureName} storage as minimum fleet server version has been met`); try { await settingsService.saveSettings(soClient, { - secret_storage_requirements_met: true, + [settingKey]: 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}`); + logger.warn(`Failed to save settings after enabling ${featureName} storage: ${err.message}`); } return true; } - logger.info('Secrets storage is disabled as minimum fleet server version has not been met'); + logger.info( + `${featureName} storage is disabled as minimum fleet server version has not been met` + ); return false; } +export async function isSecretStorageEnabled( + esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract +): Promise { + return isSecretStorageEnabledForFeature({ + esClient, + soClient, + featureName: 'Secrets', + minimumFleetServerVersion: SECRETS_MINIMUM_FLEET_SERVER_VERSION, + settingKey: 'secret_storage_requirements_met', + }); +} + export async function createSecrets(opts: { esClient: ElasticsearchClient; values: Array; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/secrets/download_sources.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/secrets/download_sources.test.ts index d069f2899b9b8..56b367682c90c 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/secrets/download_sources.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/secrets/download_sources.test.ts @@ -55,6 +55,7 @@ describe('Download source secrets', () => { const res = await extractAndWriteDownloadSourcesSecrets({ downloadSource, esClient: esClientMock, + includeSSLSecrets: true, }); expect(res.downloadSource).toEqual({ ...downloadSource, @@ -95,6 +96,7 @@ describe('Download source secrets', () => { oldDownloadSource: downloadSource, downloadSourceUpdate: updatedDownloadSource, esClient: esClientMock, + includeSSLSecrets: true, }); expect(res.downloadSourceUpdate).toEqual({ ...downloadSource, @@ -168,5 +170,244 @@ describe('Download source secrets', () => { }); expect(esClientMock.transport.request.mock.calls).toEqual([]); }); + + it('should delete auth secrets', async () => { + const downloadSourceWithAuthSecrets = { + ...downloadSource, + secrets: { + auth: { + password: { + id: 'password-secret-id', + }, + }, + }, + } as any; + + await deleteDownloadSourceSecrets({ + downloadSource: downloadSourceWithAuthSecrets, + esClient: esClientMock, + }); + expect(esClientMock.transport.request.mock.calls).toEqual([ + [ + { + method: 'DELETE', + path: '/_fleet/secret/password-secret-id', + }, + ], + ]); + }); + + it('should delete both SSL and auth secrets', async () => { + const downloadSourceWithAllSecrets = { + ...downloadSource, + secrets: { + ssl: { + key: { + id: 'ssl-key-secret-id', + }, + }, + auth: { + api_key: { + id: 'api-key-secret-id', + }, + }, + }, + } as any; + + await deleteDownloadSourceSecrets({ + downloadSource: downloadSourceWithAllSecrets, + esClient: esClientMock, + }); + expect(esClientMock.transport.request.mock.calls).toHaveLength(2); + expect(esClientMock.transport.request.mock.calls).toContainEqual([ + { + method: 'DELETE', + path: '/_fleet/secret/ssl-key-secret-id', + }, + ]); + expect(esClientMock.transport.request.mock.calls).toContainEqual([ + { + method: 'DELETE', + path: '/_fleet/secret/api-key-secret-id', + }, + ]); + }); + }); + + describe('auth secrets', () => { + const downloadSourceWithAuth = { + id: 'id1', + name: 'Agent binary', + host: 'https://binary-source-test', + is_default: false, + auth: { + username: 'user1', + }, + secrets: { + auth: { + password: 'secret-password', + }, + }, + }; + + describe('extractAndWriteDownloadSourcesSecrets with auth', () => { + it('should create auth password secrets', async () => { + const res = await extractAndWriteDownloadSourcesSecrets({ + downloadSource: downloadSourceWithAuth, + esClient: esClientMock, + includeAuthSecrets: true, + }); + expect(res.downloadSource).toEqual({ + ...downloadSourceWithAuth, + secrets: { + auth: { + password: { + id: expect.any(String), + }, + }, + }, + }); + expect(res.secretReferences).toEqual([{ id: expect.anything() }]); + expect(esClientMock.transport.request.mock.calls).toEqual([ + [ + { + body: { + value: 'secret-password', + }, + method: 'POST', + path: '/_fleet/secret', + }, + ], + ]); + }); + + it('should create auth api_key secrets', async () => { + const downloadSourceWithApiKey = { + ...downloadSourceWithAuth, + secrets: { + auth: { + api_key: 'secret-api-key', + }, + }, + }; + + const res = await extractAndWriteDownloadSourcesSecrets({ + downloadSource: downloadSourceWithApiKey, + esClient: esClientMock, + includeAuthSecrets: true, + }); + expect(res.downloadSource).toEqual({ + ...downloadSourceWithApiKey, + secrets: { + auth: { + api_key: { + id: expect.any(String), + }, + }, + }, + }); + expect(esClientMock.transport.request.mock.calls).toEqual([ + [ + { + body: { + value: 'secret-api-key', + }, + method: 'POST', + path: '/_fleet/secret', + }, + ], + ]); + }); + }); + + describe('extractAndUpdateDownloadSourceSecrets with auth', () => { + it('should handle switching from password to api_key auth', async () => { + const oldDownloadSource = { + ...downloadSourceWithAuth, + secrets: { + auth: { + password: { id: 'old-password-id' }, + }, + }, + }; + + const downloadSourceUpdate = { + secrets: { + auth: { + api_key: 'new-api-key', + }, + }, + }; + + const res = await extractAndUpdateDownloadSourceSecrets({ + oldDownloadSource: oldDownloadSource as any, + downloadSourceUpdate, + esClient: esClientMock, + includeAuthSecrets: true, + }); + + expect(res.downloadSourceUpdate).toEqual({ + secrets: { + auth: { + api_key: { + id: expect.any(String), + }, + }, + }, + }); + // Old password should be marked for deletion + expect(res.secretsToDelete).toEqual([{ id: 'old-password-id' }]); + // New api_key should be created + expect(esClientMock.transport.request.mock.calls).toEqual([ + [ + { + body: { + value: 'new-api-key', + }, + method: 'POST', + path: '/_fleet/secret', + }, + ], + ]); + }); + + it('should handle switching from api_key to password auth', async () => { + const oldDownloadSource = { + ...downloadSourceWithAuth, + secrets: { + auth: { + api_key: { id: 'old-api-key-id' }, + }, + }, + }; + + const downloadSourceUpdate = { + secrets: { + auth: { + password: 'new-password', + }, + }, + }; + + const res = await extractAndUpdateDownloadSourceSecrets({ + oldDownloadSource: oldDownloadSource as any, + downloadSourceUpdate, + esClient: esClientMock, + includeAuthSecrets: true, + }); + + expect(res.downloadSourceUpdate).toEqual({ + secrets: { + auth: { + password: { + id: expect.any(String), + }, + }, + }, + }); + // Old api_key should be marked for deletion + expect(res.secretsToDelete).toEqual([{ id: 'old-api-key-id' }]); + }); + }); }); }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/secrets/download_sources.ts b/x-pack/platform/plugins/shared/fleet/server/services/secrets/download_sources.ts index f99b81b56e23b..2cbc3161883bc 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/secrets/download_sources.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/secrets/download_sources.ts @@ -5,23 +5,63 @@ * 2.0. */ -import type { ElasticsearchClient } from '@kbn/core/server'; +import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; import type { SOSecretPath, DownloadSource, DownloadSourceBase } from '../../../common/types'; import type { SecretReference } from '../../types'; +import { DOWNLOAD_SOURCE_AUTH_SECRETS_MINIMUM_FLEET_SERVER_VERSION } from '../../constants'; -import { deleteSOSecrets, extractAndWriteSOSecrets, extractAndUpdateSOSecrets } from './common'; +import { + deleteSOSecrets, + extractAndWriteSOSecrets, + extractAndUpdateSOSecrets, + isSecretStorageEnabledForFeature, +} from './common'; -export async function extractAndWriteDownloadSourcesSecrets(opts: { +export async function isDownloadSourceAuthSecretStorageEnabled( + esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract +): Promise { + return isSecretStorageEnabledForFeature({ + esClient, + soClient, + featureName: 'Download source auth secrets', + minimumFleetServerVersion: DOWNLOAD_SOURCE_AUTH_SECRETS_MINIMUM_FLEET_SERVER_VERSION, + settingKey: 'download_source_auth_secret_storage_requirements_met', + }); +} + +export interface ExtractDownloadSourceSecretsOptions { downloadSource: DownloadSourceBase; esClient: ElasticsearchClient; secretHashes?: Record; -}): Promise<{ downloadSource: DownloadSourceBase; secretReferences: SecretReference[] }> { - const { downloadSource, esClient, secretHashes = {} } = opts; + /** + * If true, SSL secrets will be extracted and stored as secrets. + * If false or undefined, SSL secrets will not be processed. + */ + includeSSLSecrets?: boolean; + /** + * If true, auth secrets will be extracted and stored as secrets. + * If false or undefined, auth secrets will not be processed. + */ + includeAuthSecrets?: boolean; +} - const secretPaths = getDownloadSourcesSecretPaths(downloadSource).filter( - (path) => typeof path.value === 'string' - ); +export async function extractAndWriteDownloadSourcesSecrets( + opts: ExtractDownloadSourceSecretsOptions +): Promise<{ downloadSource: DownloadSourceBase; secretReferences: SecretReference[] }> { + const { + downloadSource, + esClient, + secretHashes = {}, + includeSSLSecrets = false, + includeAuthSecrets = false, + } = opts; + + const secretPaths = getDownloadSourcesSecretPaths(downloadSource, { + includeSSLSecrets, + includeAuthSecrets, + }).filter((path) => typeof path.value === 'string'); const secretRes = await extractAndWriteSOSecrets({ soObject: downloadSource, secretPaths, @@ -34,19 +74,41 @@ export async function extractAndWriteDownloadSourcesSecrets(opts: { }; } -export async function extractAndUpdateDownloadSourceSecrets(opts: { +export interface UpdateDownloadSourceSecretsOptions { oldDownloadSource: DownloadSourceBase; downloadSourceUpdate: Partial; esClient: ElasticsearchClient; secretHashes?: Record; -}): Promise<{ + /** + * If true, SSL secrets will be extracted and stored as secrets. + * If false or undefined, SSL secrets will not be processed. + */ + includeSSLSecrets?: boolean; + /** + * If true, auth secrets will be extracted and stored as secrets. + * If false or undefined, auth secrets will not be processed. + */ + includeAuthSecrets?: boolean; +} + +export async function extractAndUpdateDownloadSourceSecrets( + opts: UpdateDownloadSourceSecretsOptions +): Promise<{ downloadSourceUpdate: Partial; secretReferences: SecretReference[]; secretsToDelete: SecretReference[]; }> { - const { oldDownloadSource, downloadSourceUpdate, esClient, secretHashes } = opts; - const oldSecretPaths = getDownloadSourcesSecretPaths(oldDownloadSource); - const updatedSecretPaths = getDownloadSourcesSecretPaths(downloadSourceUpdate); + const { + oldDownloadSource, + downloadSourceUpdate, + esClient, + secretHashes, + includeSSLSecrets = false, + includeAuthSecrets = false, + } = opts; + const secretPathOptions = { includeSSLSecrets, includeAuthSecrets }; + const oldSecretPaths = getDownloadSourcesSecretPaths(oldDownloadSource, secretPathOptions); + const updatedSecretPaths = getDownloadSourcesSecretPaths(downloadSourceUpdate, secretPathOptions); const secretsRes = await extractAndUpdateSOSecrets({ updatedSoObject: downloadSourceUpdate, oldSecretPaths, @@ -66,7 +128,11 @@ export async function deleteDownloadSourceSecrets(opts: { esClient: ElasticsearchClient; }): Promise { const { downloadSource, esClient } = opts; - const secretPaths = getDownloadSourcesSecretPaths(downloadSource); + // When deleting, we want to find all secrets regardless of current feature flags + const secretPaths = getDownloadSourcesSecretPaths(downloadSource, { + includeSSLSecrets: true, + includeAuthSecrets: true, + }); await deleteSOSecrets(esClient, secretPaths); } @@ -75,24 +141,85 @@ export function getDownloadSourceSecretReferences( ): SecretReference[] { const secretPaths: SecretReference[] = []; + // SSL secrets if (typeof downloadSource.secrets?.ssl?.key === 'object') { secretPaths.push({ id: downloadSource.secrets.ssl.key.id, }); } + + // Auth secrets (username is not a secret, only password and api_key) + if (downloadSource.secrets?.auth) { + if (typeof downloadSource.secrets.auth.password === 'object') { + secretPaths.push({ + id: downloadSource.secrets.auth.password.id, + }); + } + if (typeof downloadSource.secrets.auth.api_key === 'object') { + secretPaths.push({ + id: downloadSource.secrets.auth.api_key.id, + }); + } + } + return secretPaths; } +interface GetDownloadSourcesSecretPathsOptions { + includeSSLSecrets?: boolean; + includeAuthSecrets?: boolean; +} + function getDownloadSourcesSecretPaths( - downloadSource: DownloadSource | Partial + downloadSource: DownloadSource | Partial, + options: GetDownloadSourcesSecretPathsOptions = {} ): SOSecretPath[] { + const { includeSSLSecrets = false, includeAuthSecrets = false } = options; const secretPaths: SOSecretPath[] = []; - if (downloadSource?.secrets?.ssl?.key) { + // SSL secrets + if (includeSSLSecrets && downloadSource?.secrets?.ssl?.key) { secretPaths.push({ path: 'secrets.ssl.key', value: downloadSource.secrets.ssl.key, }); } + + // Auth secrets (username is not a secret, only password and api_key) + if (includeAuthSecrets) { + // Check secrets.auth paths first + if (downloadSource?.secrets?.auth) { + if (downloadSource.secrets.auth.password) { + secretPaths.push({ + path: 'secrets.auth.password', + value: downloadSource.secrets.auth.password, + }); + } + if (downloadSource.secrets.auth.api_key) { + secretPaths.push({ + path: 'secrets.auth.api_key', + value: downloadSource.secrets.auth.api_key, + }); + } + } + + // Also convert plain text auth values to secrets when secret storage is enabled + // Only if not already specified in secrets.auth (to avoid duplicates) + if (downloadSource?.auth) { + if (downloadSource.auth.password && !downloadSource?.secrets?.auth?.password) { + secretPaths.push({ + path: 'secrets.auth.password', + value: downloadSource.auth.password, + }); + } + if (downloadSource.auth.api_key && !downloadSource?.secrets?.auth?.api_key) { + secretPaths.push({ + path: 'secrets.auth.api_key', + value: downloadSource.auth.api_key, + }); + } + } + } + return secretPaths; } diff --git a/x-pack/platform/plugins/shared/fleet/server/services/secrets/outputs.ts b/x-pack/platform/plugins/shared/fleet/server/services/secrets/outputs.ts index 828b1d105af9c..4fff88e10aa7a 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/secrets/outputs.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/secrets/outputs.ts @@ -16,59 +16,25 @@ import type { import type { NewOutput } from '../../../common'; import type { SecretReference } from '../../types'; import { OUTPUT_SECRETS_MINIMUM_FLEET_SERVER_VERSION } from '../../constants'; -import { appContextService } from '../app_context'; -import { settingsService } from '..'; -import { checkFleetServerVersionsForSecretsStorage } from '../fleet_server'; -import { deleteSOSecrets, extractAndWriteSOSecrets, extractAndUpdateSOSecrets } from './common'; +import { + deleteSOSecrets, + extractAndWriteSOSecrets, + extractAndUpdateSOSecrets, + isSecretStorageEnabledForFeature, +} from './common'; export async function isOutputSecretStorageEnabled( esClient: ElasticsearchClient, soClient: SavedObjectsClientContract ): Promise { - const logger = appContextService.getLogger(); - - // if serverless then output secrets will always be supported - const isFleetServerStandalone = - appContextService.getConfig()?.internal?.fleetServerStandalone ?? false; - - if (isFleetServerStandalone) { - logger.trace('Output 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, output secrets are always on - const settings = await settingsService.getSettingsOrUndefined(soClient); - - if (settings && settings.output_secret_storage_requirements_met) { - logger.debug('Output secrets storage requirements already met, turned on in settings'); - return true; - } - - // otherwise check if we have the minimum fleet server version and enable secrets if so - if ( - await checkFleetServerVersionsForSecretsStorage( - esClient, - soClient, - OUTPUT_SECRETS_MINIMUM_FLEET_SERVER_VERSION - ) - ) { - logger.debug('Enabling output secrets storage as minimum fleet server version has been met'); - try { - await settingsService.saveSettings(soClient, { - output_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 output secrets storage: ${err.message}`); - } - - return true; - } - - logger.info('Secrets storage is disabled as minimum fleet server version has not been met'); - return false; + return isSecretStorageEnabledForFeature({ + esClient, + soClient, + featureName: 'Output secrets', + minimumFleetServerVersion: OUTPUT_SECRETS_MINIMUM_FLEET_SERVER_VERSION, + settingKey: 'output_secret_storage_requirements_met', + }); } export async function extractAndWriteOutputSecrets(opts: { diff --git a/x-pack/platform/plugins/shared/fleet/server/services/secrets/ssl.ts b/x-pack/platform/plugins/shared/fleet/server/services/secrets/ssl.ts index fa5a5b3ac7105..0a92e47fd403a 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/secrets/ssl.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/secrets/ssl.ts @@ -8,54 +8,18 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; import { SSL_SECRETS_MINIMUM_FLEET_SERVER_VERSION } from '../../constants'; -import { checkFleetServerVersionsForSecretsStorage } from '../fleet_server'; -import { appContextService, settingsService } from '..'; + +import { isSecretStorageEnabledForFeature } from './common'; export async function isSSLSecretStorageEnabled( esClient: ElasticsearchClient, soClient: SavedObjectsClientContract ): Promise { - const logger = appContextService.getLogger(); - - // if serverless then secrets will always be supported - const isFleetServerStandalone = - appContextService.getConfig()?.internal?.fleetServerStandalone ?? false; - - if (isFleetServerStandalone) { - logger.trace('SSL 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.getSettingsOrUndefined(soClient); - - if (settings && settings.ssl_secret_storage_requirements_met) { - logger.debug('SSL secrets storage requirements already met, turned on in settings'); - return true; - } - - // otherwise check if we have the minimum fleet server version and enable secrets if so - if ( - await checkFleetServerVersionsForSecretsStorage( - esClient, - soClient, - SSL_SECRETS_MINIMUM_FLEET_SERVER_VERSION - ) - ) { - logger.debug('Enabling SSL secrets storage as minimum fleet server version has been met'); - try { - await settingsService.saveSettings(soClient, { - ssl_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 SSL secrets storage: ${err.message}`); - } - - return true; - } - - logger.info('Secrets storage is disabled as minimum fleet server version has not been met'); - return false; + return isSecretStorageEnabledForFeature({ + esClient, + soClient, + featureName: 'SSL secrets', + minimumFleetServerVersion: SSL_SECRETS_MINIMUM_FLEET_SERVER_VERSION, + settingKey: 'ssl_secret_storage_requirements_met', + }); } diff --git a/x-pack/platform/plugins/shared/fleet/server/services/settings.ts b/x-pack/platform/plugins/shared/fleet/server/services/settings.ts index c93cd860323fb..d8eee7d680163 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/settings.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/settings.ts @@ -32,6 +32,8 @@ function mapSettingsSO(settingsSo: SavedObject): Settings action_secret_storage_requirements_met: settingsSo.attributes.action_secret_storage_requirements_met, ssl_secret_storage_requirements_met: settingsSo.attributes.ssl_secret_storage_requirements_met, + download_source_auth_secret_storage_requirements_met: + settingsSo.attributes.download_source_auth_secret_storage_requirements_met, has_seen_add_data_notice: settingsSo.attributes.has_seen_add_data_notice, prerelease_integrations_enabled: settingsSo.attributes.prerelease_integrations_enabled, use_space_awareness_migration_status: diff --git a/x-pack/platform/plugins/shared/fleet/server/types/models/agent_policy.ts b/x-pack/platform/plugins/shared/fleet/server/types/models/agent_policy.ts index f6332bdd86bdd..0cdc549e5653b 100644 --- a/x-pack/platform/plugins/shared/fleet/server/types/models/agent_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/server/types/models/agent_policy.ts @@ -530,6 +530,18 @@ export const FullAgentPolicyResponseSchema = schema.object({ download: schema.object({ sourceURI: schema.string(), ssl: schema.maybe(BaseSSLSchema), + auth: schema.maybe( + schema.object({ + username: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + api_key: schema.maybe(schema.string()), + headers: schema.maybe( + schema.arrayOf(schema.object({ key: schema.string(), value: schema.string() }), { + maxSize: 100, + }) + ), + }) + ), secrets: schema.maybe(BaseSecretsSchema), timeout: schema.maybe(schema.string()), target_directory: schema.maybe(schema.string()), diff --git a/x-pack/platform/plugins/shared/fleet/server/types/models/download_sources.ts b/x-pack/platform/plugins/shared/fleet/server/types/models/download_sources.ts index ada0b13f8babb..283c689394020 100644 --- a/x-pack/platform/plugins/shared/fleet/server/types/models/download_sources.ts +++ b/x-pack/platform/plugins/shared/fleet/server/types/models/download_sources.ts @@ -37,9 +37,30 @@ const DownloadSourceBaseSchema = { key: schema.maybe(schema.string()), }) ), + auth: schema.maybe( + schema.oneOf([ + schema.literal(null), + schema.object({ + username: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + api_key: schema.maybe(schema.string()), + headers: schema.maybe( + schema.arrayOf(schema.object({ key: schema.string(), value: schema.string() }), { + maxSize: 100, + }) + ), + }), + ]) + ), secrets: schema.maybe( schema.object({ ssl: schema.maybe(schema.object({ key: schema.maybe(secretRefSchema) })), + auth: schema.maybe( + schema.object({ + password: schema.maybe(secretRefSchema), + api_key: schema.maybe(secretRefSchema), + }) + ), }) ), }; diff --git a/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/settings.ts b/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/settings.ts index 003540728afdb..6e31ac7c46d57 100644 --- a/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/settings.ts +++ b/x-pack/platform/plugins/shared/fleet/server/types/rest_spec/settings.ts @@ -113,8 +113,12 @@ export const SettingsSchemaV7 = SettingsSchemaV6.extends({ integration_knowledge_enabled: schema.maybe(schema.boolean()), }); +export const SettingsSchemaV8 = SettingsSchemaV7.extends({ + download_source_auth_secret_storage_requirements_met: schema.maybe(schema.boolean()), +}); + export const SettingsResponseSchema = schema.object({ - item: SettingsSchemaV7, + item: SettingsSchemaV8, }); export const PutSpaceSettingsRequestSchema = { diff --git a/x-pack/platform/plugins/shared/fleet/server/types/so_attributes.ts b/x-pack/platform/plugins/shared/fleet/server/types/so_attributes.ts index e14c8ff6401b5..bd7bf008c66b3 100644 --- a/x-pack/platform/plugins/shared/fleet/server/types/so_attributes.ts +++ b/x-pack/platform/plugins/shared/fleet/server/types/so_attributes.ts @@ -277,6 +277,7 @@ export interface SettingsSOAttributes { output_secret_storage_requirements_met?: boolean; action_secret_storage_requirements_met?: boolean; ssl_secret_storage_requirements_met?: boolean; + download_source_auth_secret_storage_requirements_met?: boolean; use_space_awareness_migration_status?: 'pending' | 'success' | 'error'; use_space_awareness_migration_started_at?: string | null; delete_unenrolled_agents?: { @@ -303,10 +304,15 @@ export interface DownloadSourceSOAttributes { source_id?: string; proxy_id?: string | null; ssl?: string | null; // encrypted ssl field + auth?: string | null; // encrypted auth field secrets?: { ssl?: { key?: { id: string }; }; + auth?: { + password?: { id: string }; + api_key?: { id: string }; + }; }; } export type SimpleSOAssetAttributes = SimpleSOAssetType['attributes']; diff --git a/x-pack/platform/test/fleet_api_integration/apis/download_sources/crud.ts b/x-pack/platform/test/fleet_api_integration/apis/download_sources/crud.ts index 50b17a5d8f3df..ad8444757a69e 100644 --- a/x-pack/platform/test/fleet_api_integration/apis/download_sources/crud.ts +++ b/x-pack/platform/test/fleet_api_integration/apis/download_sources/crud.ts @@ -204,7 +204,7 @@ export default function (providerContext: FtrProviderContext) { }); describe('POST /agent_download_sources', () => { - it('should not store secrets if fleet server does not meet minimum version', async function () { + it('should not store SSL secrets if fleet server does not meet minimum version', async function () { await clearAgents(); await createFleetServerAgent(fleetServerPolicyId, 'server_1', '7.0.0'); @@ -340,7 +340,7 @@ export default function (providerContext: FtrProviderContext) { expect(res.body.message).to.equal('Cannot specify both ssl.key and secrets.ssl.key'); }); - it('should store secrets if fleet server meets minimum version', async function () { + it('should store SSL secrets if fleet server meets minimum version', async function () { await clearAgents(); await createFleetServerAgent(fleetServerPolicyId, 'server_1', '9.3.0'); const res = await supertest @@ -364,6 +364,251 @@ export default function (providerContext: FtrProviderContext) { // @ts-ignore _source unknown type expect(secret1._source.value).to.equal('KEY1'); }); + + it('should allow creating a download source with username/password auth', async function () { + const { body: postResponse } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Download source with auth ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'testuser', + password: 'testpassword', + }, + }) + .expect(200); + + expect(postResponse.item.auth.username).to.eql('testuser'); + expect(postResponse.item.auth.password).to.eql('testpassword'); + }); + + it('should allow creating a download source with api_key auth', async function () { + const { body: postResponse } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Download source with api key ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + api_key: 'my-api-key', + }, + }) + .expect(200); + + expect(postResponse.item.auth.api_key).to.eql('my-api-key'); + }); + + it('should not allow both username/password and api_key', async function () { + const res = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Download source with conflicting auth ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'testuser', + password: 'testpassword', + api_key: 'my-api-key', + }, + }) + .expect(400); + + expect(res.body.message).to.contain( + 'Cannot specify both username/password and api_key authentication' + ); + }); + + it('should require password when username is provided', async function () { + const res = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Download source with username only ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'testuser', + }, + }) + .expect(400); + + expect(res.body.message).to.contain('Username and password must be provided together'); + }); + + it('should require username when password is provided', async function () { + const res = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Download source with password only ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + password: 'testpassword', + }, + }) + .expect(400); + + expect(res.body.message).to.contain('Username and password must be provided together'); + }); + + it('should not allow auth.password and secrets.auth.password at the same time', async function () { + const res = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Download source with duplicate password ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'testuser', + password: 'plainpassword', + }, + secrets: { + auth: { + password: 'secretpassword', + }, + }, + }) + .expect(400); + + expect(res.body.message).to.contain( + 'Cannot specify both auth.password and secrets.auth.password' + ); + }); + + it('should not allow auth.api_key and secrets.auth.api_key at the same time', async function () { + const res = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Download source with duplicate api_key ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + api_key: 'plain-api-key', + }, + secrets: { + auth: { + api_key: 'secret-api-key', + }, + }, + }) + .expect(400); + + expect(res.body.message).to.contain( + 'Cannot specify both auth.api_key and secrets.auth.api_key' + ); + }); + + it('should store auth secrets when fleet server meets minimum version', async function () { + await clearAgents(); + await createFleetServerAgent(fleetServerPolicyId, 'server_1', '9.4.0'); + const res = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Download source with auth secret ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'testuser', + }, + secrets: { + auth: { + password: 'secretpassword', + }, + }, + }) + .expect(200); + + expect(res.body.item.auth.username).to.eql('testuser'); + expect(res.body.item.secrets.auth.password).to.have.property('id'); + const secretId = res.body.item.secrets.auth.password.id; + const secret = await getSecretById(secretId); + // @ts-ignore _source unknown type + expect(secret._source.value).to.eql('secretpassword'); + }); + + it('should allow creating a download source with auth headers', async function () { + const { body: postResponse } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Download source with headers ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'testuser', + password: 'testpassword', + headers: [ + { key: 'X-Custom-Header', value: 'custom-value' }, + { key: 'X-Another-Header', value: 'another-value' }, + ], + }, + }) + .expect(200); + + expect(postResponse.item.auth.username).to.eql('testuser'); + expect(postResponse.item.auth.headers).to.eql([ + { key: 'X-Custom-Header', value: 'custom-value' }, + { key: 'X-Another-Header', value: 'another-value' }, + ]); + }); + + it('should allow creating a download source with api_key and headers', async function () { + const { body: postResponse } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Download source with api_key and headers ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + api_key: 'my-api-key', + headers: [{ key: 'X-Custom-Header', value: 'custom-value' }], + }, + }) + .expect(200); + + const apiKey = + postResponse.item.auth?.api_key ?? postResponse.item.secrets?.auth?.api_key?.id; + expect(apiKey).to.be.ok(); + expect(postResponse.item.auth.headers).to.eql([ + { key: 'X-Custom-Header', value: 'custom-value' }, + ]); + }); + + it('should allow creating a download source with auth headers only', async function () { + const { body: postResponse } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Download source with headers only ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + headers: [ + { key: 'X-Custom-Header', value: 'custom-value' }, + { key: 'X-Another-Header', value: 'another-value' }, + ], + }, + }) + .expect(200); + + expect(postResponse.item.auth.headers).to.eql([ + { key: 'X-Custom-Header', value: 'custom-value' }, + { key: 'X-Another-Header', value: 'another-value' }, + ]); + + expect(postResponse.item.auth.username).to.be(undefined); + expect(postResponse.item.auth.password).to.be(undefined); + expect(postResponse.item.auth.api_key).to.be(undefined); + }); }); describe('PUT /agent_download_sources/{sourceId}', () => { @@ -546,6 +791,457 @@ export default function (providerContext: FtrProviderContext) { }) .expect(400); }); + + it('should delete password secret when switching from username/password to api_key', async function () { + await clearAgents(); + await createFleetServerAgent(fleetServerPolicyId, 'server_1', '9.4.0'); + + const { body: createRes } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Switch auth test ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'testuser', + }, + secrets: { + auth: { + password: 'secretpassword', + }, + }, + }) + .expect(200); + + const dsId = createRes.item.id; + const passwordSecretId = createRes.item.secrets.auth.password.id; + + await supertest + .put(`/api/fleet/agent_download_sources/${dsId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Switch auth test ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + secrets: { + auth: { + api_key: 'new-api-key', + }, + }, + }) + .expect(200); + + try { + await getSecretById(passwordSecretId); + expect().fail('Password secret should have been deleted'); + } catch (e) { + // not found - expected + } + + const { body: getRes } = await supertest + .get(`/api/fleet/agent_download_sources/${dsId}`) + .expect(200); + + expect(getRes.item.auth).to.be(undefined); + expect(getRes.item.secrets.auth.api_key).to.have.property('id'); + }); + + it('should delete api_key secret when switching from api_key to username/password', async function () { + await clearAgents(); + await createFleetServerAgent(fleetServerPolicyId, 'server_1', '9.4.0'); + + const { body: createRes } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Switch auth test 2 ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + secrets: { + auth: { + api_key: 'secret-api-key', + }, + }, + }) + .expect(200); + + const dsId = createRes.item.id; + const apiKeySecretId = createRes.item.secrets.auth.api_key.id; + + await supertest + .put(`/api/fleet/agent_download_sources/${dsId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Switch auth test 2 ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'newuser', + }, + secrets: { + auth: { + password: 'newpassword', + }, + }, + }) + .expect(200); + + try { + await getSecretById(apiKeySecretId); + expect().fail('API key secret should have been deleted'); + } catch (e) { + // not found - expected + } + + const { body: getRes } = await supertest + .get(`/api/fleet/agent_download_sources/${dsId}`) + .expect(200); + + expect(getRes.item.auth.username).to.eql('newuser'); + expect(getRes.item.secrets.auth.password).to.have.property('id'); + }); + + it('should convert plain text auth to secret when secret storage is enabled', async function () { + await clearAgents(); + await createFleetServerAgent(fleetServerPolicyId, 'server_1', '9.4.0'); + + const { body: createRes } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Plain text to secret test ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'testuser', + password: 'plaintextpassword', + }, + }) + .expect(200); + + expect(createRes.item.auth.username).to.eql('testuser'); + expect(createRes.item.auth.password).to.be(undefined); + expect(createRes.item.secrets.auth.password).to.have.property('id'); + + const secretId = createRes.item.secrets.auth.password.id; + const secret = await getSecretById(secretId); + // @ts-ignore _source unknown type + expect(secret._source.value).to.eql('plaintextpassword'); + }); + + it('should allow updating headers on existing download source', async function () { + const { body: createRes } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Update headers test ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'testuser', + password: 'testpassword', + headers: [{ key: 'X-Original-Header', value: 'original-value' }], + }, + }) + .expect(200); + + const dsId = createRes.item.id; + expect(createRes.item.auth.headers).to.eql([ + { key: 'X-Original-Header', value: 'original-value' }, + ]); + + const { body: updateRes } = await supertest + .put(`/api/fleet/agent_download_sources/${dsId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Update headers test ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'testuser', + password: 'testpassword', + headers: [ + { key: 'X-Updated-Header', value: 'updated-value' }, + { key: 'X-New-Header', value: 'new-value' }, + ], + }, + }) + .expect(200); + + expect(updateRes.item.auth.headers).to.eql([ + { key: 'X-Updated-Header', value: 'updated-value' }, + { key: 'X-New-Header', value: 'new-value' }, + ]); + }); + + it('should replace auth entirely when updating with headers only', async function () { + await clearAgents(); + await createFleetServerAgent(fleetServerPolicyId, 'server_1', '9.4.0'); + + const { body: createRes } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Replace auth test ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'testuser', + headers: [{ key: 'X-Original-Header', value: 'original-value' }], + }, + secrets: { auth: { password: 'SECRET_PASSWORD' } }, + }) + .expect(200); + + const dsId = createRes.item.id; + + const { body: updateRes } = await supertest + .put(`/api/fleet/agent_download_sources/${dsId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Replace auth test updated ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + headers: [ + { key: 'X-Updated-Header', value: 'updated-value' }, + { key: 'X-New-Header', value: 'new-value' }, + ], + }, + }) + .expect(200); + + expect(updateRes.item.auth.headers).to.eql([ + { key: 'X-Updated-Header', value: 'updated-value' }, + { key: 'X-New-Header', value: 'new-value' }, + ]); + expect(updateRes.item.auth.username).to.be(undefined); + expect(updateRes.item.secrets?.auth?.password).to.be(undefined); + }); + + it('should replace api_key auth with headers only (complete replacement)', async function () { + await clearAgents(); + await createFleetServerAgent(fleetServerPolicyId, 'server_1', '9.4.0'); + + const { body: createRes } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Replace api_key test ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + headers: [{ key: 'X-Original-Header', value: 'original-value' }], + }, + secrets: { auth: { api_key: 'SECRET_API_KEY' } }, + }) + .expect(200); + + const dsId = createRes.item.id; + + const { body: updateRes } = await supertest + .put(`/api/fleet/agent_download_sources/${dsId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Replace api_key test updated ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + headers: [{ key: 'X-Updated-Header', value: 'updated-value' }], + }, + }) + .expect(200); + + expect(updateRes.item.auth.headers).to.eql([ + { key: 'X-Updated-Header', value: 'updated-value' }, + ]); + expect(updateRes.item.secrets?.auth?.api_key).to.be(undefined); + }); + + it('should preserve SSL secrets when updating only name/host', async function () { + await clearAgents(); + await createFleetServerAgent(fleetServerPolicyId, 'server_1', '8.12.0'); + + const { body: createRes } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Preserve SSL secrets test ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + ssl: { + certificate_authorities: ['cert authorities'], + certificate: 'path/to/cert', + }, + secrets: { ssl: { key: 'SSL_KEY_VALUE' } }, + }) + .expect(200); + + const dsId = createRes.item.id; + const secretId = createRes.item.secrets.ssl.key.id; + + const secret = await getSecretById(secretId); + // @ts-ignore _source unknown type + expect(secret._source.value).to.equal('SSL_KEY_VALUE'); + + const { body: updateRes } = await supertest + .put(`/api/fleet/agent_download_sources/${dsId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Preserve SSL secrets test updated ${Date.now()}`, + host: 'http://updated.test.fr:443', + is_default: false, + }) + .expect(200); + + expect(updateRes.item.secrets.ssl.key.id).to.equal(secretId); + expect(updateRes.item.ssl.certificate).to.equal('path/to/cert'); + expect(updateRes.item.ssl.certificate_authorities).to.eql(['cert authorities']); + + const secretAfterUpdate = await getSecretById(secretId); + // @ts-ignore _source unknown type + expect(secretAfterUpdate._source.value).to.equal('SSL_KEY_VALUE'); + }); + + it('should preserve auth secrets when updating only name/host', async function () { + await clearAgents(); + await createFleetServerAgent(fleetServerPolicyId, 'server_1', '9.4.0'); + + const { body: createRes } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Preserve auth secrets test ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'testuser', + headers: [{ key: 'X-Custom-Header', value: 'custom-value' }], + }, + secrets: { auth: { password: 'SECRET_PASSWORD' } }, + }) + .expect(200); + + const dsId = createRes.item.id; + const secretId = createRes.item.secrets.auth.password.id; + + const secret = await getSecretById(secretId); + // @ts-ignore _source unknown type + expect(secret._source.value).to.equal('SECRET_PASSWORD'); + + const { body: updateRes } = await supertest + .put(`/api/fleet/agent_download_sources/${dsId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Preserve auth secrets test updated ${Date.now()}`, + host: 'http://updated.test.fr:443', + is_default: false, + }) + .expect(200); + + expect(updateRes.item.secrets.auth.password.id).to.equal(secretId); + expect(updateRes.item.auth.username).to.equal('testuser'); + expect(updateRes.item.auth.headers).to.eql([ + { key: 'X-Custom-Header', value: 'custom-value' }, + ]); + + const secretAfterUpdate = await getSecretById(secretId); + // @ts-ignore _source unknown type + expect(secretAfterUpdate._source.value).to.equal('SECRET_PASSWORD'); + }); + + it('should preserve both SSL and auth secrets when updating only name/host', async function () { + await clearAgents(); + await createFleetServerAgent(fleetServerPolicyId, 'server_1', '9.4.0'); + + const { body: createRes } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Preserve all secrets test ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + ssl: { + certificate_authorities: ['cert authorities'], + certificate: 'path/to/cert', + }, + secrets: { + ssl: { key: 'SSL_KEY_VALUE' }, + auth: { password: 'SECRET_PASSWORD' }, + }, + auth: { + username: 'testuser', + headers: [{ key: 'X-Custom-Header', value: 'custom-value' }], + }, + }) + .expect(200); + + const dsId = createRes.item.id; + const sslSecretId = createRes.item.secrets.ssl.key.id; + const authSecretId = createRes.item.secrets.auth.password.id; + + const { body: updateRes } = await supertest + .put(`/api/fleet/agent_download_sources/${dsId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Preserve all secrets test updated ${Date.now()}`, + host: 'http://updated.test.fr:443', + is_default: false, + }) + .expect(200); + + expect(updateRes.item.secrets.ssl.key.id).to.equal(sslSecretId); + expect(updateRes.item.secrets.auth.password.id).to.equal(authSecretId); + expect(updateRes.item.ssl.certificate).to.equal('path/to/cert'); + expect(updateRes.item.auth.username).to.equal('testuser'); + + const sslSecretAfterUpdate = await getSecretById(sslSecretId); + // @ts-ignore _source unknown type + expect(sslSecretAfterUpdate._source.value).to.equal('SSL_KEY_VALUE'); + + const authSecretAfterUpdate = await getSecretById(authSecretId); + // @ts-ignore _source unknown type + expect(authSecretAfterUpdate._source.value).to.equal('SECRET_PASSWORD'); + }); + + it('should clear auth and headers when setting auth to null', async function () { + const { body: createRes } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Clear auth test ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: { + username: 'testuser', + password: 'testpassword', + headers: [{ key: 'X-Custom-Header', value: 'custom-value' }], + }, + }) + .expect(200); + + const dsId = createRes.item.id; + expect(createRes.item.auth.username).to.eql('testuser'); + expect(createRes.item.auth.headers).to.have.length(1); + + await supertest + .put(`/api/fleet/agent_download_sources/${dsId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: `Clear auth test ${Date.now()}`, + host: 'http://test.fr:443', + is_default: false, + auth: null, + }) + .expect(200); + + const { body: getRes } = await supertest + .get(`/api/fleet/agent_download_sources/${dsId}`) + .expect(200); + + expect(getRes.item.auth).to.be(undefined); + }); }); describe('proxy_id behaviour', () => {