diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 6f37e5db5f53e..c0e3152c94130 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -29479,6 +29479,13 @@ paths: maxLength: 255 minLength: 1 type: string + target_csp: + description: Target cloud service provider. If not provided, will be auto-detected from inputs. + enum: + - aws + - azure + - gcp + type: string description: description: Policy description. type: string diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index f497a9941a887..cbdf448129aea 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -32052,6 +32052,13 @@ paths: maxLength: 255 minLength: 1 type: string + target_csp: + description: Target cloud service provider. If not provided, will be auto-detected from inputs. + enum: + - aws + - azure + - gcp + type: string description: description: Policy description. type: string diff --git a/x-pack/platform/plugins/shared/fleet/common/constants/cloud_connector.ts b/x-pack/platform/plugins/shared/fleet/common/constants/cloud_connector.ts index ccc61954d1a09..b2d20c481bde5 100644 --- a/x-pack/platform/plugins/shared/fleet/common/constants/cloud_connector.ts +++ b/x-pack/platform/plugins/shared/fleet/common/constants/cloud_connector.ts @@ -22,6 +22,9 @@ export const TENANT_ID_VAR_NAME = 'tenant_id'; export const CLIENT_ID_VAR_NAME = 'client_id'; export const AZURE_CREDENTIALS_CLOUD_CONNECTOR_ID = 'azure_credentials_cloud_connector_id'; +// Cloud connector support flag +export const SUPPORTS_CLOUD_CONNECTORS_VAR_NAME = 'supports_cloud_connectors'; + // Account type variable names for different cloud providers export const AWS_ACCOUNT_TYPE_VAR_NAME = 'aws.account_type'; export const AZURE_ACCOUNT_TYPE_VAR_NAME = 'azure.account_type'; @@ -32,6 +35,9 @@ export const GCP_ACCOUNT_TYPE_VAR_NAME = 'gcp.account_type'; export const SINGLE_ACCOUNT = 'single-account'; export const ORGANIZATION_ACCOUNT = 'organization-account'; +// Default account type for cloud connectors when not explicitly specified +export const CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE = SINGLE_ACCOUNT; + export const SUPPORTED_CLOUD_CONNECTOR_VARS = [ AWS_ROLE_ARN_VAR_NAME, AWS_CREDENTIALS_EXTERNAL_ID_VAR_NAME, @@ -43,4 +49,5 @@ export const SUPPORTED_CLOUD_CONNECTOR_VARS = [ TENANT_ID_VAR_NAME, CLIENT_ID_VAR_NAME, AZURE_CREDENTIALS_CLOUD_CONNECTOR_ID, + SUPPORTS_CLOUD_CONNECTORS_VAR_NAME, ]; diff --git a/x-pack/platform/plugins/shared/fleet/common/experimental_features.ts b/x-pack/platform/plugins/shared/fleet/common/experimental_features.ts index 7617617006ce5..b9499d71d0932 100644 --- a/x-pack/platform/plugins/shared/fleet/common/experimental_features.ts +++ b/x-pack/platform/plugins/shared/fleet/common/experimental_features.ts @@ -26,6 +26,7 @@ const _allowedExperimentalValues = { enableSloTemplates: true, newBrowseIntegrationUx: false, // When enabled integrations, browse integrations page will use the new UX. enableVersionSpecificPolicies: false, // When enabled, version specific policies will be created when packages use agent version conditions + enableVarGroups: false, // When enabled, var_groups from the package spec drive conditional variable visibility and input filtering. enableIntegrationInactivityAlerting: false, // When enabled, an inactivity monitoring alerting rule template is created on fresh integration package install. }; diff --git a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/index.ts b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/index.ts index 72d0362523863..b23e33b1266aa 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/index.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/index.ts @@ -30,6 +30,7 @@ export { getCredentialSchema, getAllVarKeys, getAllSupportedVarNames, + getCredentialKeyFromVarName, } from './schemas'; // Accessor functions @@ -43,3 +44,14 @@ export { getVarTarget, findFirstVarEntry, } from './var_accessor'; + +// Var group helpers for cloud connector detection +export type { VarGroupSelection, CloudConnectorOptionResult } from './var_group_helpers'; +export { + getSelectedOption, + getCloudConnectorOption, + getCloudConnectorVars, + getAllCloudConnectorVarNames, + getIacTemplateUrlFromVarGroupSelection, + detectTargetCsp, +} from './var_group_helpers'; diff --git a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/schemas.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/schemas.test.ts index df95dcf61c120..c789ab7fcd50b 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/schemas.test.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/schemas.test.ts @@ -13,6 +13,7 @@ import { getCredentialSchema, getAllVarKeys, getAllSupportedVarNames, + getCredentialKeyFromVarName, } from './schemas'; describe('Cloud Connector Schemas', () => { @@ -55,10 +56,12 @@ describe('Cloud Connector Schemas', () => { expect(clientId.isSecret).toBe(true); }); - it('should have azureCredentialsCloudConnectorId field with correct keys', () => { - const { azureCredentialsCloudConnectorId } = AZURE_CREDENTIAL_SCHEMA.fields; - expect(azureCredentialsCloudConnectorId.primary).toBe('azure_credentials_cloud_connector_id'); - expect(azureCredentialsCloudConnectorId.isSecret).toBe(false); + it('should have azure_credentials_cloud_connector_id field with correct keys', () => { + const { azure_credentials_cloud_connector_id } = AZURE_CREDENTIAL_SCHEMA.fields; + expect(azure_credentials_cloud_connector_id.primary).toBe( + 'azure_credentials_cloud_connector_id' + ); + expect(azure_credentials_cloud_connector_id.isSecret).toBe(false); }); }); @@ -144,4 +147,74 @@ describe('Cloud Connector Schemas', () => { expect(allVarNames.length).toBeGreaterThan(0); }); }); + + describe('getCredentialKeyFromVarName', () => { + describe('AWS provider', () => { + it('should return roleArn for primary key role_arn', () => { + expect(getCredentialKeyFromVarName('aws', 'role_arn')).toBe('roleArn'); + }); + + it('should return roleArn for alias aws.role_arn', () => { + expect(getCredentialKeyFromVarName('aws', 'aws.role_arn')).toBe('roleArn'); + }); + + it('should return externalId for primary key external_id', () => { + expect(getCredentialKeyFromVarName('aws', 'external_id')).toBe('externalId'); + }); + + it('should return externalId for alias aws.credentials.external_id', () => { + expect(getCredentialKeyFromVarName('aws', 'aws.credentials.external_id')).toBe( + 'externalId' + ); + }); + + it('should return undefined for unknown var name', () => { + expect(getCredentialKeyFromVarName('aws', 'unknown_var')).toBeUndefined(); + }); + }); + + describe('Azure provider', () => { + it('should return tenantId for primary key tenant_id', () => { + expect(getCredentialKeyFromVarName('azure', 'tenant_id')).toBe('tenantId'); + }); + + it('should return tenantId for alias azure.credentials.tenant_id', () => { + expect(getCredentialKeyFromVarName('azure', 'azure.credentials.tenant_id')).toBe( + 'tenantId' + ); + }); + + it('should return clientId for primary key client_id', () => { + expect(getCredentialKeyFromVarName('azure', 'client_id')).toBe('clientId'); + }); + + it('should return clientId for alias azure.credentials.client_id', () => { + expect(getCredentialKeyFromVarName('azure', 'azure.credentials.client_id')).toBe( + 'clientId' + ); + }); + + it('should return azure_credentials_cloud_connector_id for its primary key', () => { + expect(getCredentialKeyFromVarName('azure', 'azure_credentials_cloud_connector_id')).toBe( + 'azure_credentials_cloud_connector_id' + ); + }); + }); + + describe('GCP provider', () => { + it('should return projectId for primary key project_id', () => { + expect(getCredentialKeyFromVarName('gcp', 'project_id')).toBe('projectId'); + }); + + it('should return serviceAccountKey for primary key service_account_key', () => { + expect(getCredentialKeyFromVarName('gcp', 'service_account_key')).toBe('serviceAccountKey'); + }); + }); + + describe('Unknown provider', () => { + it('should return undefined for unknown provider', () => { + expect(getCredentialKeyFromVarName('unknown' as any, 'role_arn')).toBeUndefined(); + }); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/schemas.ts b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/schemas.ts index 8edb10b137d9b..36c84da51c45a 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/schemas.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/schemas.ts @@ -59,7 +59,7 @@ export const AZURE_CREDENTIAL_SCHEMA: CloudConnectorCredentialSchema = { aliases: [AZURE_CLIENT_ID_VAR_NAME], // 'azure.credentials.client_id' isSecret: true, }, - azureCredentialsCloudConnectorId: { + azure_credentials_cloud_connector_id: { primary: AZURE_CREDENTIALS_CLOUD_CONNECTOR_ID, // 'azure_credentials_cloud_connector_id' aliases: [AZURE_CREDENTIALS_CLOUD_CONNECTOR_ID_VAR_NAME], // 'azure.credentials.azure_credentials_cloud_connector_id' isSecret: false, @@ -132,3 +132,34 @@ export function getAllSupportedVarNames(): string[] { return allVarNames; } + +/** + * Gets the credential property name for a given var key name. + * Handles both primary keys and aliases, mapping them back to the logical credential field name. + * + * @param provider - The cloud provider (e.g., 'aws', 'azure') + * @param varName - The var key name (e.g., 'role_arn' or 'aws.role_arn') + * @returns The credential property name (e.g., 'roleArn') or undefined if not found + * + * @example + * getCredentialKeyFromVarName('aws', 'role_arn') // → 'roleArn' + * getCredentialKeyFromVarName('aws', 'aws.role_arn') // → 'roleArn' + * getCredentialKeyFromVarName('azure', 'tenant_id') // → 'tenantId' + */ +export function getCredentialKeyFromVarName( + provider: CloudProvider, + varName: string +): string | undefined { + const schema = CREDENTIAL_SCHEMAS[provider]; + if (!schema) { + return undefined; + } + + for (const [credentialKey, mapping] of Object.entries(schema.fields)) { + if (mapping.primary === varName || mapping.aliases.includes(varName)) { + return credentialKey; + } + } + + return undefined; +} diff --git a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/types.ts b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/types.ts index 23115a97fa242..aa26900027a8e 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/types.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/types.ts @@ -66,7 +66,7 @@ export interface NormalizedAwsCredentials { export interface NormalizedAzureCredentials { tenantId?: string | { id: string; isSecretRef: boolean }; clientId?: string | { id: string; isSecretRef: boolean }; - azureCredentialsCloudConnectorId?: string; + azure_credentials_cloud_connector_id?: string; } /** diff --git a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_accessor.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_accessor.test.ts index 09231178ccb2e..ae5d35f953dff 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_accessor.test.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_accessor.test.ts @@ -454,7 +454,7 @@ describe('Cloud Connector Var Accessor', () => { expect(credentials).toEqual({ tenantId: 'tenant-123', clientId: 'client-456', - azureCredentialsCloudConnectorId: 'connector-789', + azure_credentials_cloud_connector_id: 'connector-789', }); }); @@ -543,7 +543,7 @@ describe('Cloud Connector Var Accessor', () => { { tenantId: 'new-tenant', clientId: 'new-client', - azureCredentialsCloudConnectorId: 'new-connector', + azure_credentials_cloud_connector_id: 'new-connector', }, 'azure', packageInfo diff --git a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_accessor.ts b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_accessor.ts index 5bfcc8e5c4758..86e7ed0658ccf 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_accessor.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_accessor.ts @@ -265,9 +265,9 @@ function readAzureCredentials( return { tenantId: findVarValue(vars, getAllVarKeys(schema.fields.tenantId)), clientId: findVarValue(vars, getAllVarKeys(schema.fields.clientId)), - azureCredentialsCloudConnectorId: findVarValue( + azure_credentials_cloud_connector_id: findVarValue( vars, - getAllVarKeys(schema.fields.azureCredentialsCloudConnectorId) + getAllVarKeys(schema.fields.azure_credentials_cloud_connector_id) ) as string | undefined, }; } @@ -395,13 +395,13 @@ function writeAzureCredentials( }; } - // Write azureCredentialsCloudConnectorId - if (credentials.azureCredentialsCloudConnectorId !== undefined) { - const connectorIdKeys = getAllVarKeys(schema.fields.azureCredentialsCloudConnectorId); + // Write azure_credentials_cloud_connector_id + if (credentials.azure_credentials_cloud_connector_id !== undefined) { + const connectorIdKeys = getAllVarKeys(schema.fields.azure_credentials_cloud_connector_id); const existingKey = findExistingVarKey(vars, connectorIdKeys) || connectorIdKeys[0]; updatedVars[existingKey] = { ...vars[existingKey], - value: credentials.azureCredentialsCloudConnectorId, + value: credentials.azure_credentials_cloud_connector_id, }; } diff --git a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_group_helpers.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_group_helpers.test.ts new file mode 100644 index 0000000000000..e182b4ac74b4b --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_group_helpers.test.ts @@ -0,0 +1,375 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { NewPackagePolicy } from '../../types'; +import type { RegistryVarGroup } from '../../types/models/package_spec'; + +import { + getSelectedOption, + getCloudConnectorOption, + getCloudConnectorVars, + getAllCloudConnectorVarNames, + getIacTemplateUrlFromVarGroupSelection, + detectTargetCsp, + type VarGroupSelection, +} from './var_group_helpers'; + +describe('var_group_helpers (cloud connector)', () => { + const createMockVarGroups = (): RegistryVarGroup[] => [ + { + name: 'auth_method', + title: 'Authentication Method', + selector_title: 'Select authentication method', + options: [ + { + name: 'cloud_connector', + title: 'Cloud Connector', + vars: ['role_arn', 'external_id'], + provider: 'aws', + iac_template_url: 'https://example.com/cloudformation.yaml', + }, + { + name: 'manual', + title: 'Manual', + vars: ['access_key', 'secret_key'], + }, + ], + }, + ]; + + const createMultipleVarGroups = (): RegistryVarGroup[] => [ + { + name: 'auth_method', + title: 'Authentication Method', + selector_title: 'Select authentication method', + options: [ + { + name: 'cloud_connector', + title: 'Cloud Connector', + vars: ['role_arn', 'external_id'], + provider: 'aws', + }, + { + name: 'manual', + title: 'Manual', + vars: ['access_key', 'secret_key'], + }, + ], + }, + { + name: 'region', + title: 'Region', + selector_title: 'Select region', + options: [ + { + name: 'us_east', + title: 'US East', + vars: ['region_us_east_var'], + }, + { + name: 'eu_west', + title: 'EU West', + vars: ['region_eu_west_var'], + }, + ], + }, + ]; + + describe('getSelectedOption', () => { + it('should return undefined when selectedOptionName is undefined', () => { + const varGroup = createMockVarGroups()[0]; + const result = getSelectedOption(varGroup, undefined); + expect(result).toBeUndefined(); + }); + + it('should return the selected option when found', () => { + const varGroup = createMockVarGroups()[0]; + const result = getSelectedOption(varGroup, 'cloud_connector'); + expect(result?.name).toBe('cloud_connector'); + expect(result?.provider).toBe('aws'); + }); + + it('should return undefined when option name does not exist', () => { + const varGroup = createMockVarGroups()[0]; + const result = getSelectedOption(varGroup, 'nonexistent'); + expect(result).toBeUndefined(); + }); + }); + + describe('getCloudConnectorOption', () => { + it('should return isSelected: false when varGroups is undefined', () => { + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + const result = getCloudConnectorOption(undefined, selections); + expect(result).toEqual({ isSelected: false }); + }); + + it('should return isSelected: false when varGroups is empty', () => { + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + const result = getCloudConnectorOption([], selections); + expect(result).toEqual({ isSelected: false }); + }); + + it('should return isSelected: false when no selection is made', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = {}; + const result = getCloudConnectorOption(varGroups, selections); + expect(result).toEqual({ isSelected: false }); + }); + + it('should return isSelected: true with provider when cloud connector is selected', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + const result = getCloudConnectorOption(varGroups, selections); + expect(result).toEqual({ + isSelected: true, + provider: 'aws', + }); + }); + + it('should return isSelected: false when non-cloud-connector option is selected', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'manual' }; + const result = getCloudConnectorOption(varGroups, selections); + expect(result).toEqual({ isSelected: false }); + }); + }); + + describe('getCloudConnectorVars', () => { + it('should return empty set when varGroups is undefined', () => { + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + const result = getCloudConnectorVars(undefined, selections); + expect(result).toEqual(new Set()); + }); + + it('should return empty set when varGroups is empty', () => { + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + const result = getCloudConnectorVars([], selections); + expect(result).toEqual(new Set()); + }); + + it('should return empty set when no selection is made', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = {}; + const result = getCloudConnectorVars(varGroups, selections); + expect(result).toEqual(new Set()); + }); + + it('should return empty set when non-cloud-connector option is selected', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'manual' }; + const result = getCloudConnectorVars(varGroups, selections); + expect(result).toEqual(new Set()); + }); + + it('should return vars for cloud connector option when selected', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + const result = getCloudConnectorVars(varGroups, selections); + expect(result).toEqual(new Set(['role_arn', 'external_id'])); + }); + + it('should only return vars from cloud connector option, not other var_groups', () => { + const varGroups = createMultipleVarGroups(); + const selections: VarGroupSelection = { + auth_method: 'cloud_connector', + region: 'us_east', + }; + const result = getCloudConnectorVars(varGroups, selections); + // Should only include cloud connector vars, not region vars + expect(result).toEqual(new Set(['role_arn', 'external_id'])); + expect(result.has('region_us_east_var')).toBe(false); + }); + + it('should return empty set when selection does not match any option', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'nonexistent' }; + const result = getCloudConnectorVars(varGroups, selections); + expect(result).toEqual(new Set()); + }); + }); + + describe('getAllCloudConnectorVarNames', () => { + it('should return empty set when varGroups is undefined', () => { + const result = getAllCloudConnectorVarNames(undefined); + expect(result).toEqual(new Set()); + }); + + it('should return empty set when varGroups is empty', () => { + const result = getAllCloudConnectorVarNames([]); + expect(result).toEqual(new Set()); + }); + + it('should return empty set when no options have a cloud connector', () => { + const varGroups: RegistryVarGroup[] = [ + { + name: 'auth_method', + title: 'Authentication Method', + selector_title: 'Select authentication method', + options: [{ name: 'manual', title: 'Manual', vars: ['access_key', 'secret_key'] }], + }, + ]; + const result = getAllCloudConnectorVarNames(varGroups); + expect(result).toEqual(new Set()); + }); + + it('should return vars from all options with a provider field', () => { + const varGroups = createMockVarGroups(); + const result = getAllCloudConnectorVarNames(varGroups); + expect(result).toEqual(new Set(['role_arn', 'external_id'])); + }); + + it('should aggregate vars from multiple cloud connector options across var_groups', () => { + const varGroups: RegistryVarGroup[] = [ + { + name: 'auth_method', + title: 'Authentication Method', + selector_title: 'Select authentication method', + options: [ + { + name: 'aws_cloud_connector', + title: 'AWS Cloud Connector', + vars: ['role_arn', 'external_id'], + provider: 'aws', + }, + { + name: 'azure_cloud_connector', + title: 'Azure Cloud Connector', + vars: ['tenant_id', 'client_id'], + provider: 'azure', + }, + { name: 'manual', title: 'Manual', vars: ['access_key'] }, + ], + }, + ]; + const result = getAllCloudConnectorVarNames(varGroups); + expect(result).toEqual(new Set(['role_arn', 'external_id', 'tenant_id', 'client_id'])); + }); + + it('should not include vars from non-provider options', () => { + const varGroups = createMockVarGroups(); + const result = getAllCloudConnectorVarNames(varGroups); + expect(result.has('access_key')).toBe(false); + expect(result.has('secret_key')).toBe(false); + }); + }); + + describe('getIacTemplateUrlFromVarGroupSelection', () => { + it('should return undefined when varGroups is undefined', () => { + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + const result = getIacTemplateUrlFromVarGroupSelection(undefined, selections); + expect(result).toBeUndefined(); + }); + + it('should return undefined when varGroups is empty', () => { + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + const result = getIacTemplateUrlFromVarGroupSelection([], selections); + expect(result).toBeUndefined(); + }); + + it('should return undefined when no selection is made', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = {}; + const result = getIacTemplateUrlFromVarGroupSelection(varGroups, selections); + expect(result).toBeUndefined(); + }); + + it('should return iac_template_url when cloud connector option is selected', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + const result = getIacTemplateUrlFromVarGroupSelection(varGroups, selections); + expect(result).toBe('https://example.com/cloudformation.yaml'); + }); + + it('should return undefined when selected option has no iac_template_url', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'manual' }; + const result = getIacTemplateUrlFromVarGroupSelection(varGroups, selections); + expect(result).toBeUndefined(); + }); + }); + + describe('detectTargetCsp', () => { + const createMockPackagePolicy = (overrides: Partial = {}): NewPackagePolicy => + ({ + name: 'test-policy', + namespace: 'default', + description: '', + enabled: true, + inputs: [], + policy_ids: [], + ...overrides, + } as NewPackagePolicy); + + it('should return provider from var_group selection when cloud connector is selected', () => { + const varGroups = createMockVarGroups(); + const packagePolicy = createMockPackagePolicy({ + var_group_selections: { auth_method: 'cloud_connector' }, + }); + const result = detectTargetCsp(packagePolicy, varGroups); + expect(result).toBe('aws'); + }); + + it('should return undefined when non-cloud-connector option is selected', () => { + const varGroups = createMockVarGroups(); + const packagePolicy = createMockPackagePolicy({ + var_group_selections: { auth_method: 'manual' }, + }); + const result = detectTargetCsp(packagePolicy, varGroups); + expect(result).toBeUndefined(); + }); + + it('should return undefined when no var_group_selections', () => { + const varGroups = createMockVarGroups(); + const packagePolicy = createMockPackagePolicy(); + const result = detectTargetCsp(packagePolicy, varGroups); + expect(result).toBeUndefined(); + }); + + it('should fallback to input type detection when no var_groups', () => { + const packagePolicy = createMockPackagePolicy({ + inputs: [{ type: 'aws-cloudwatch', enabled: true }] as NewPackagePolicy['inputs'], + }); + const result = detectTargetCsp(packagePolicy, undefined); + expect(result).toBe('aws'); + }); + + it('should fallback to input type detection for azure', () => { + const packagePolicy = createMockPackagePolicy({ + inputs: [{ type: 'azure-logs', enabled: true }] as NewPackagePolicy['inputs'], + }); + const result = detectTargetCsp(packagePolicy, undefined); + expect(result).toBe('azure'); + }); + + it('should fallback to input type detection for gcp', () => { + const packagePolicy = createMockPackagePolicy({ + inputs: [{ type: 'gcp-pubsub', enabled: true }] as NewPackagePolicy['inputs'], + }); + const result = detectTargetCsp(packagePolicy, undefined); + expect(result).toBe('gcp'); + }); + + it('should return undefined when input type does not match any provider', () => { + const packagePolicy = createMockPackagePolicy({ + inputs: [{ type: 'logfile', enabled: true }] as NewPackagePolicy['inputs'], + }); + const result = detectTargetCsp(packagePolicy, undefined); + expect(result).toBeUndefined(); + }); + + it('should prioritize var_group detection over input type detection', () => { + const varGroups = createMockVarGroups(); + const packagePolicy = createMockPackagePolicy({ + var_group_selections: { auth_method: 'cloud_connector' }, + inputs: [{ type: 'azure-logs', enabled: true }] as NewPackagePolicy['inputs'], + }); + // var_group has aws provider, input has azure - should return aws from var_group + const result = detectTargetCsp(packagePolicy, varGroups); + expect(result).toBe('aws'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_group_helpers.ts b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_group_helpers.ts new file mode 100644 index 0000000000000..199d16f65dea0 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/common/services/cloud_connectors/var_group_helpers.ts @@ -0,0 +1,181 @@ +/* + * 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 { isCloudProvider, type CloudProvider } from '../../types'; +import type { NewPackagePolicy, NewPackagePolicyInput } from '../../types'; +import type { RegistryVarGroup } from '../../types/models/package_spec'; + +import { getSelectedOption, type VarGroupSelection } from '../var_group_helpers'; + +// Re-export generic var_group helpers from common/services +export { getSelectedOption, type VarGroupSelection } from '../var_group_helpers'; + +/** + * Result of checking if a cloud connector option is selected + */ +export interface CloudConnectorOptionResult { + /** Whether a cloud connector option is currently selected */ + isSelected: boolean; + /** The cloud provider (e.g., 'aws', 'azure') if cloud connector is selected */ + provider?: CloudProvider; +} + +/** + * Checks if any selected var_group option has a `provider` field, indicating Cloud Connector support. + * Returns the provider value if found. + * + * @param varGroups - The var_groups from package info + * @param varGroupSelections - Current var_group selections + * @returns Object indicating if cloud connector is selected and the provider + */ +export const getCloudConnectorOption = ( + varGroups: RegistryVarGroup[] | undefined, + varGroupSelections: VarGroupSelection +): CloudConnectorOptionResult => { + if (!varGroups || varGroups.length === 0) { + return { isSelected: false }; + } + + for (const varGroup of varGroups) { + const selectedName = varGroupSelections[varGroup.name]; + if (!selectedName) { + continue; + } + + const selectedOption = getSelectedOption(varGroup, selectedName); + if (selectedOption && isCloudProvider(selectedOption.provider)) { + return { + isSelected: true, + provider: selectedOption.provider, + }; + } + } + return { isSelected: false }; +}; + +/** + * Gets the variable names that belong to the selected cloud connector option. + * These vars are handled by the CloudConnectorSetup component and should be hidden + * from the regular var fields UI to prevent duplicate inputs. + * + * @param varGroups - The var_groups from package info + * @param varGroupSelections - Current var_group selections + * @returns Set of variable names handled by cloud connector + */ +export const getCloudConnectorVars = ( + varGroups: RegistryVarGroup[] | undefined, + varGroupSelections: VarGroupSelection +): Set => { + if (!varGroups || varGroups.length === 0) { + return new Set(); + } + + for (const varGroup of varGroups) { + const selectedName = varGroupSelections[varGroup.name]; + if (!selectedName) { + continue; + } + + const selectedOption = getSelectedOption(varGroup, selectedName); + if (selectedOption?.provider) { + return new Set(selectedOption.vars); + } + } + return new Set(); +}; + +/** + * Gets the iac_template_url from the currently selected var_group option. + * This is used for Fleet integrations that store IaC template URLs (CloudFormation, ARM) + * as properties on the var_group option rather than in input.vars. + * + * @param varGroups - The var_groups from package info + * @param varGroupSelections - Current var_group selections + * @returns The IaC template URL or undefined + */ +export const getIacTemplateUrlFromVarGroupSelection = ( + varGroups: RegistryVarGroup[] | undefined, + varGroupSelections: VarGroupSelection +): string | undefined => { + if (!varGroups || varGroups.length === 0) { + return undefined; + } + + for (const varGroup of varGroups) { + const selectedName = varGroupSelections[varGroup.name]; + if (!selectedName) { + continue; + } + + const selectedOption = getSelectedOption(varGroup, selectedName); + if (selectedOption?.iac_template_url) { + return selectedOption.iac_template_url as string; + } + } + return undefined; +}; + +/** + * Returns all variable names that belong to any cloud connector option across all var_groups. + * Unlike getCloudConnectorVars (which only returns vars for the currently selected option), + * this returns vars from ALL options with a `provider` field, regardless of selection state. + * + * Used to identify vars that need to be cleared when switching away from cloud connector. + */ +export const getAllCloudConnectorVarNames = ( + varGroups: RegistryVarGroup[] | undefined +): Set => { + const varNames = new Set(); + if (!varGroups) return varNames; + + for (const varGroup of varGroups) { + if (!varGroup.options) continue; + for (const option of varGroup.options) { + if (option.provider && option.vars) { + for (const varName of option.vars) { + varNames.add(varName); + } + } + } + } + return varNames; +}; + +/** + * Detects the target cloud provider from either: + * 1. var_group selections (new approach - provider field in selected option) + * 2. Input type matching (legacy approach - input.type contains aws|azure|gcp) + * + * @param packagePolicy - The package policy to check + * @param varGroups - The var_groups from package info + * @returns The detected cloud provider or undefined + */ +export const detectTargetCsp = ( + packagePolicy: NewPackagePolicy, + varGroups: RegistryVarGroup[] | undefined +): CloudProvider | undefined => { + // First, check var_group selections for provider field (new approach) + if (varGroups && packagePolicy.var_group_selections) { + const cloudConnectorOption = getCloudConnectorOption( + varGroups, + packagePolicy.var_group_selections + ); + if (cloudConnectorOption.isSelected && cloudConnectorOption.provider) { + return cloudConnectorOption.provider; + } + } + + // Fallback to legacy input type detection + const input = packagePolicy.inputs?.find( + (pinput: NewPackagePolicyInput) => pinput.enabled === true + ); + const match = input?.type.match(/aws|azure|gcp/)?.[0]; + if (isCloudProvider(match)) { + return match; + } + return undefined; +}; diff --git a/x-pack/platform/plugins/shared/fleet/common/services/index.ts b/x-pack/platform/plugins/shared/fleet/common/services/index.ts index 41beeaf26beef..e8f95b0411712 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/index.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/index.ts @@ -107,5 +107,16 @@ export { removeSOAttributes, getSortConfig, checkTargetVersionsValidity } from ' export { isAwsCloudConnectorVars, isAzureCloudConnectorVars } from './cloud_connector_helpers'; +// Generic var_group helpers +export type { VarGroupSelection } from './var_group_helpers'; +export { + getSelectedOption, + getVisibleVarsForOption, + getVarsControlledByVarGroups, + shouldShowVar, + isVarRequiredByVarGroup, + isVarInSelectedVarGroupOption, +} from './var_group_helpers'; + // Cloud Connector accessor module export * from './cloud_connectors'; diff --git a/x-pack/platform/plugins/shared/fleet/common/services/var_group_helpers.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/var_group_helpers.test.ts new file mode 100644 index 0000000000000..179c603970680 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/common/services/var_group_helpers.test.ts @@ -0,0 +1,262 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RegistryVarGroup } from '../types'; + +import { + getSelectedOption, + getVisibleVarsForOption, + getVarsControlledByVarGroups, + shouldShowVar, + isVarRequiredByVarGroup, + isVarInSelectedVarGroupOption, + type VarGroupSelection, +} from './var_group_helpers'; + +describe('var_group_helpers', () => { + const createMockVarGroup = (): RegistryVarGroup => ({ + name: 'credential_type', + title: 'Setup Access', + selector_title: 'Preferred method', + options: [ + { + name: 'direct_access_key', + title: 'Direct Access Keys', + vars: ['access_key_id', 'secret_access_key'], + }, + { + name: 'assume_role', + title: 'Assume Role', + vars: ['role_arn', 'external_id'], + provider: 'aws', + }, + ], + }); + + const createRequiredVarGroup = (): RegistryVarGroup => ({ + name: 'auth_type', + title: 'Authentication Type', + selector_title: 'Select auth type', + required: true, + options: [ + { + name: 'oauth', + title: 'OAuth', + vars: ['client_id', 'client_secret'], + }, + { + name: 'api_key', + title: 'API Key', + vars: ['api_key'], + }, + ], + }); + + const createMultipleVarGroups = (): RegistryVarGroup[] => [ + createMockVarGroup(), + createRequiredVarGroup(), + ]; + + describe('getSelectedOption', () => { + it('should return undefined when selectedOptionName is undefined', () => { + const varGroup = createMockVarGroup(); + const result = getSelectedOption(varGroup, undefined); + expect(result).toBeUndefined(); + }); + + it('should return the selected option when found', () => { + const varGroup = createMockVarGroup(); + const result = getSelectedOption(varGroup, 'direct_access_key'); + expect(result).toBeDefined(); + expect(result?.name).toBe('direct_access_key'); + expect(result?.vars).toEqual(['access_key_id', 'secret_access_key']); + }); + + it('should return undefined when option name does not exist', () => { + const varGroup = createMockVarGroup(); + const result = getSelectedOption(varGroup, 'nonexistent'); + expect(result).toBeUndefined(); + }); + + it('should return option with provider field', () => { + const varGroup = createMockVarGroup(); + const result = getSelectedOption(varGroup, 'assume_role'); + expect(result?.provider).toBe('aws'); + }); + }); + + describe('getVisibleVarsForOption', () => { + it('should return undefined when selectedOptionName is undefined', () => { + const varGroup = createMockVarGroup(); + const result = getVisibleVarsForOption(varGroup, undefined); + expect(result).toBeUndefined(); + }); + + it('should return vars for the selected option', () => { + const varGroup = createMockVarGroup(); + const result = getVisibleVarsForOption(varGroup, 'direct_access_key'); + expect(result).toEqual(['access_key_id', 'secret_access_key']); + }); + + it('should return undefined when option does not exist', () => { + const varGroup = createMockVarGroup(); + const result = getVisibleVarsForOption(varGroup, 'nonexistent'); + expect(result).toBeUndefined(); + }); + }); + + describe('getVarsControlledByVarGroups', () => { + it('should return empty set for empty varGroups', () => { + const result = getVarsControlledByVarGroups([]); + expect(result).toEqual(new Set()); + }); + + it('should return all vars from all options in all var_groups', () => { + const varGroups = createMultipleVarGroups(); + const result = getVarsControlledByVarGroups(varGroups); + + // From credential_type group + expect(result.has('access_key_id')).toBe(true); + expect(result.has('secret_access_key')).toBe(true); + expect(result.has('role_arn')).toBe(true); + expect(result.has('external_id')).toBe(true); + + // From auth_type group + expect(result.has('client_id')).toBe(true); + expect(result.has('client_secret')).toBe(true); + expect(result.has('api_key')).toBe(true); + }); + + it('should return set with correct size', () => { + const varGroups = createMultipleVarGroups(); + const result = getVarsControlledByVarGroups(varGroups); + expect(result.size).toBe(7); + }); + }); + + describe('shouldShowVar', () => { + it('should return true for vars not controlled by any var_group', () => { + const varGroups = [createMockVarGroup()]; + const selections: VarGroupSelection = { credential_type: 'direct_access_key' }; + + // 'some_other_var' is not in any var_group + const result = shouldShowVar('some_other_var', varGroups, selections); + expect(result).toBe(true); + }); + + it('should return true for vars in the selected option', () => { + const varGroups = [createMockVarGroup()]; + const selections: VarGroupSelection = { credential_type: 'direct_access_key' }; + + expect(shouldShowVar('access_key_id', varGroups, selections)).toBe(true); + expect(shouldShowVar('secret_access_key', varGroups, selections)).toBe(true); + }); + + it('should return false for vars in non-selected options', () => { + const varGroups = [createMockVarGroup()]; + const selections: VarGroupSelection = { credential_type: 'direct_access_key' }; + + // role_arn and external_id are in assume_role, not direct_access_key + expect(shouldShowVar('role_arn', varGroups, selections)).toBe(false); + expect(shouldShowVar('external_id', varGroups, selections)).toBe(false); + }); + + it('should handle multiple var_groups correctly', () => { + const varGroups = createMultipleVarGroups(); + const selections: VarGroupSelection = { + credential_type: 'assume_role', + auth_type: 'oauth', + }; + + // Vars from selected options should be visible + expect(shouldShowVar('role_arn', varGroups, selections)).toBe(true); + expect(shouldShowVar('client_id', varGroups, selections)).toBe(true); + + // Vars from non-selected options should be hidden + expect(shouldShowVar('access_key_id', varGroups, selections)).toBe(false); + expect(shouldShowVar('api_key', varGroups, selections)).toBe(false); + }); + }); + + describe('isVarRequiredByVarGroup', () => { + it('should return false when varGroups is undefined', () => { + const result = isVarRequiredByVarGroup('client_id', undefined, { auth_type: 'oauth' }); + expect(result).toBe(false); + }); + + it('should return false when varGroups is empty', () => { + const result = isVarRequiredByVarGroup('client_id', [], { auth_type: 'oauth' }); + expect(result).toBe(false); + }); + + it('should return false when varGroupSelections is undefined', () => { + const varGroups = [createRequiredVarGroup()]; + const result = isVarRequiredByVarGroup('client_id', varGroups, undefined); + expect(result).toBe(false); + }); + + it('should return true for vars in required var_group selected option', () => { + const varGroups = [createRequiredVarGroup()]; + const selections: VarGroupSelection = { auth_type: 'oauth' }; + + expect(isVarRequiredByVarGroup('client_id', varGroups, selections)).toBe(true); + expect(isVarRequiredByVarGroup('client_secret', varGroups, selections)).toBe(true); + }); + + it('should return false for vars in non-required var_group', () => { + const varGroups = [createMockVarGroup()]; // not required + const selections: VarGroupSelection = { credential_type: 'direct_access_key' }; + + expect(isVarRequiredByVarGroup('access_key_id', varGroups, selections)).toBe(false); + }); + + it('should return false for vars in non-selected option of required var_group', () => { + const varGroups = [createRequiredVarGroup()]; + const selections: VarGroupSelection = { auth_type: 'oauth' }; + + // api_key is in the api_key option, not oauth + expect(isVarRequiredByVarGroup('api_key', varGroups, selections)).toBe(false); + }); + }); + + describe('isVarInSelectedVarGroupOption', () => { + it('should return false for vars not controlled by any var_group', () => { + const varGroups = [createMockVarGroup()]; + const selections: VarGroupSelection = { credential_type: 'direct_access_key' }; + + const result = isVarInSelectedVarGroupOption('some_other_var', varGroups, selections); + expect(result).toBe(false); + }); + + it('should return true for vars in selected option', () => { + const varGroups = [createMockVarGroup()]; + const selections: VarGroupSelection = { credential_type: 'direct_access_key' }; + + expect(isVarInSelectedVarGroupOption('access_key_id', varGroups, selections)).toBe(true); + expect(isVarInSelectedVarGroupOption('secret_access_key', varGroups, selections)).toBe(true); + }); + + it('should return false for vars in non-selected option', () => { + const varGroups = [createMockVarGroup()]; + const selections: VarGroupSelection = { credential_type: 'direct_access_key' }; + + expect(isVarInSelectedVarGroupOption('role_arn', varGroups, selections)).toBe(false); + expect(isVarInSelectedVarGroupOption('external_id', varGroups, selections)).toBe(false); + }); + + it('should differ from shouldShowVar for uncontrolled vars', () => { + const varGroups = [createMockVarGroup()]; + const selections: VarGroupSelection = { credential_type: 'direct_access_key' }; + + // shouldShowVar returns true for uncontrolled vars + expect(shouldShowVar('some_other_var', varGroups, selections)).toBe(true); + + // isVarInSelectedVarGroupOption returns false for uncontrolled vars + expect(isVarInSelectedVarGroupOption('some_other_var', varGroups, selections)).toBe(false); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/common/services/var_group_helpers.ts b/x-pack/platform/plugins/shared/fleet/common/services/var_group_helpers.ts new file mode 100644 index 0000000000000..e8f6cfddc3784 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/common/services/var_group_helpers.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RegistryVarGroup, RegistryVarGroupOption } from '../types'; + +/** + * Mapping of var_group names to selected option names + */ +export type VarGroupSelection = Record; + +/** + * Gets the full RegistryVarGroupOption object for the currently selected option in a var_group. + * + * @param varGroup - The var_group to search + * @param selectedOptionName - The name of the selected option + * @returns The selected option or undefined + */ +export const getSelectedOption = ( + varGroup: RegistryVarGroup, + selectedOptionName: string | undefined +): RegistryVarGroupOption | undefined => { + if (!selectedOptionName) { + return undefined; + } + return varGroup.options.find((opt) => opt.name === selectedOptionName); +}; + +/** + * Get variable names that should be visible based on the selected option. + * Returns undefined if no option is selected. + */ +export const getVisibleVarsForOption = ( + varGroup: RegistryVarGroup, + selectedOptionName: string | undefined +): string[] | undefined => { + if (!selectedOptionName) { + return undefined; + } + + const selectedOption = varGroup.options.find((opt) => opt.name === selectedOptionName); + return selectedOption?.vars; +}; + +/** + * Get all variable names that are controlled by any var_group. + * These vars should only be shown when their option is selected. + */ +export const getVarsControlledByVarGroups = (varGroups: RegistryVarGroup[]): Set => { + return new Set(varGroups.flatMap((group) => group.options.flatMap((option) => option.vars))); +}; + +/** + * Determines if a variable should be visible based on var_group selections. + */ +export const shouldShowVar = ( + varName: string, + varGroups: RegistryVarGroup[], + varGroupSelections: VarGroupSelection +): boolean => { + // Get all vars controlled by var_groups + const controlledVars = getVarsControlledByVarGroups(varGroups); + + // If this var is not controlled by any var_group, always show it + if (!controlledVars.has(varName)) { + return true; + } + + // Check if this var is in the selected option for any var_group + return varGroups + .filter((group) => varGroupSelections[group.name]) + .some((group) => { + const selectedOption = group.options.find( + (opt) => opt.name === varGroupSelections[group.name] + ); + return selectedOption?.vars.includes(varName); + }); +}; + +/** + * Determines if a variable is required due to being in a required var_group's selected option. + * When var_group.required is true, all vars in the selected option are treated as required. + */ +export const isVarRequiredByVarGroup = ( + varName: string, + varGroups: RegistryVarGroup[] | undefined, + varGroupSelections: VarGroupSelection | undefined +): boolean => { + if (!varGroups || varGroups.length === 0 || !varGroupSelections) { + return false; + } + + return varGroups + .filter((group) => group.required && varGroupSelections[group.name]) + .some((group) => { + const selectedOption = group.options.find( + (opt) => opt.name === varGroupSelections[group.name] + ); + return selectedOption?.vars.includes(varName); + }); +}; + +/** + * Checks if a variable is part of a currently selected var_group option. + * This is used to override show_user: false for vars that belong to a selected var_group option. + * + * Unlike shouldShowVar which returns true for vars NOT controlled by var_groups, + * this function returns false for such vars - it specifically checks if a var + * is controlled by a var_group AND is in the selected option. + */ +export const isVarInSelectedVarGroupOption = ( + varName: string, + varGroups: RegistryVarGroup[], + varGroupSelections: VarGroupSelection +): boolean => { + const controlledVars = getVarsControlledByVarGroups(varGroups); + + // If not controlled by any var_group, it's not "in a selected option" + if (!controlledVars.has(varName)) { + return false; + } + + // If controlled and shouldShowVar returns true, it means it's in a selected option + return shouldShowVar(varName, varGroups, varGroupSelections); +}; diff --git a/x-pack/platform/plugins/shared/fleet/common/types/models/cloud_connector.ts b/x-pack/platform/plugins/shared/fleet/common/types/models/cloud_connector.ts index 7c3e30962a93d..d722481fa9ef2 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/models/cloud_connector.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/models/cloud_connector.ts @@ -6,6 +6,14 @@ */ export type CloudProvider = 'aws' | 'azure' | 'gcp'; +const CLOUD_PROVIDERS: readonly CloudProvider[] = ['aws', 'azure', 'gcp']; + +/** + * Type guard to check if a value is a valid CloudProvider. + */ +export const isCloudProvider = (value: unknown): value is CloudProvider => + typeof value === 'string' && CLOUD_PROVIDERS.includes(value as CloudProvider); + export type AccountType = 'single-account' | 'organization-account'; export interface CloudConnectorSecretReference { diff --git a/x-pack/platform/plugins/shared/fleet/common/types/models/index.ts b/x-pack/platform/plugins/shared/fleet/common/types/models/index.ts index c15d675f5786b..9b5d947e57520 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/models/index.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/models/index.ts @@ -22,4 +22,4 @@ export type * from './secret'; export * from './setup_technology'; export type * from './fleet_setup_lock'; export * from './remote_synced_integrations'; -export type * from './cloud_connector'; +export * from './cloud_connector'; diff --git a/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/agentless_policy.ts b/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/agentless_policy.ts index e0319ba339ab9..371021d224c9a 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/agentless_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/rest_spec/agentless_policy.ts @@ -60,6 +60,14 @@ export const CreateAgentlessPolicyRequestSchema = { }, }) ), + target_csp: schema.maybe( + schema.oneOf([schema.literal('aws'), schema.literal('azure'), schema.literal('gcp')], { + meta: { + description: + 'Target cloud service provider. If not provided, will be auto-detected from inputs.', + }, + }) + ), }) ), }), diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/hooks.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/hooks.tsx index 8d0dcb51a3f6e..10aba8e69387f 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/hooks.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/hooks.tsx @@ -9,7 +9,7 @@ import { useMemo, useEffect, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { LICENCE_FOR_OUTPUT_PER_INTEGRATION } from '../../../../../../../../../common/constants'; -import type { PackagePolicy } from '../../../../../../../../../common/types'; +import type { NewPackagePolicy, PackagePolicy } from '../../../../../../../../../common/types'; import type { RegistryVarGroup } from '../../../../../../types'; import { getAllowedOutputTypesForPackagePolicy } from '../../../../../../../../../common/services/output_helpers'; import { useGetOutputs, useLicense } from '../../../../../../hooks'; @@ -18,6 +18,7 @@ import { computeDefaultVarGroupSelections, type VarGroupSelection, } from '../../../services/var_group_helpers'; +import { buildVarGroupPolicyUpdates } from '../../../services/var_group_policy_effects'; export function useDataStreamId() { const history = useHistory(); @@ -53,22 +54,44 @@ export function useOutputs( }; } +/** + * Update type for var group selection changes. + * Includes var_group_selections plus any additional policy effects. + */ +interface VarGroupSelectionsUpdate { + var_group_selections: VarGroupSelection; + [key: string]: unknown; +} + interface UseVarGroupSelectionsParams { varGroups: RegistryVarGroup[] | undefined; savedSelections: VarGroupSelection | undefined; isAgentlessEnabled: boolean; - onSelectionsChange: (update: { var_group_selections: VarGroupSelection }) => void; + /** + * Callback for selection changes. Receives var_group_selections and any + * computed policy effects (when packagePolicy is provided). + */ + onSelectionsChange: (update: VarGroupSelectionsUpdate) => void; + /** + * Optional: current package policy for computing policy effects. + * When provided along with varGroups, selection changes will compute + * and include policy effects (e.g., supports_cloud_connector) in the update. + * If not provided, only var_group_selections will be included in updates. + */ + packagePolicy?: NewPackagePolicy; } /** * Hook for managing var group selections state. - * Handles deriving current selections, initializing defaults, and selection changes. + * Handles deriving current selections, initializing defaults, selection changes, + * and computing policy effects based on selected options. */ export function useVarGroupSelections({ varGroups, savedSelections, isAgentlessEnabled, onSelectionsChange, + packagePolicy, }: UseVarGroupSelectionsParams) { // Derive current selections from saved or compute defaults const selections = useMemo((): VarGroupSelection => { @@ -86,17 +109,27 @@ export function useVarGroupSelections({ } }, [varGroups, isAgentlessEnabled, savedSelections, onSelectionsChange]); - // Handle selection change + // Handle selection change with policy effects computation const handleSelectionChange = useCallback( (groupName: string, optionName: string) => { + const newSelections: VarGroupSelection = { + ...savedSelections, + [groupName]: optionName, + }; + + // Compute policy effects (e.g., supports_cloud_connector) if packagePolicy is provided + const policyEffects = + packagePolicy && varGroups + ? buildVarGroupPolicyUpdates(packagePolicy, newSelections, varGroups) + : null; + + // Apply selections and any policy effects together onSelectionsChange({ - var_group_selections: { - ...savedSelections, - [groupName]: optionName, - }, + var_group_selections: newSelections, + ...(policyEffects || {}), }); }, - [savedSelections, onSelectionsChange] + [savedSelections, onSelectionsChange, packagePolicy, varGroups] ); return { selections, handleSelectionChange }; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_stream.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_stream.test.tsx index 80a97c75961b6..29a00e6844b24 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_stream.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_stream.test.tsx @@ -19,6 +19,8 @@ import type { RegistryVarGroup, } from '../../../../../../types'; +import { ExperimentalFeaturesService } from '../../../../../../services'; + import { PackagePolicyInputStreamConfig } from './package_policy_input_stream'; jest.mock('../../../../../../../../hooks', () => ({ @@ -179,6 +181,9 @@ describe('PackagePolicyInputStreamConfig', () => { let mockUpdatePackagePolicyInputStream: jest.Mock; beforeEach(() => { + jest.spyOn(ExperimentalFeaturesService, 'get').mockReturnValue({ + enableVarGroups: true, + } as any); testRenderer = createFleetTestRendererMock(); mockUpdatePackagePolicyInputStream = jest.fn(); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_stream.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_stream.tsx index 5bafbcd333dfe..1d9d8ea3ff44e 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_stream.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_stream.tsx @@ -61,6 +61,7 @@ import { useIndexTemplateExists } from '../../datastream_hooks'; import type { RegistryPolicyInputOnlyTemplate } from '../../../../../../../../../common/types/models/epm'; import { shouldShowVar, isVarRequiredByVarGroup } from '../../../services/var_group_helpers'; +import { ExperimentalFeaturesService } from '../../../../../../services'; import { PackagePolicyInputVarField } from './package_policy_input_var_field'; import { useDataStreamId, useVarGroupSelections } from './hooks'; @@ -98,6 +99,12 @@ export const PackagePolicyInputStreamConfig = memo( }) => { const { docLinks } = useStartServices(); const { isAgentlessEnabled } = useAgentless(); + const { enableVarGroups } = ExperimentalFeaturesService.get(); + + const pkgVarGroups = + enableVarGroups && packageInfo.var_groups ? packageInfo.var_groups : undefined; + const streamVarGroups = + enableVarGroups && packageInputStream.var_groups ? packageInputStream.var_groups : undefined; const { params: { packagePolicyId }, @@ -159,20 +166,17 @@ export const PackagePolicyInputStreamConfig = memo( }, [isDefaultDatastream, containerRef]); // Determine if this stream has its own var_groups (stream-level) or should use package-level - const hasStreamLevelVarGroups = - packageInputStream.var_groups && packageInputStream.var_groups.length > 0; + const hasStreamLevelVarGroups = streamVarGroups && streamVarGroups.length > 0; // Use stream-level var_groups if present, otherwise fall back to package-level - const effectiveVarGroups = hasStreamLevelVarGroups - ? packageInputStream.var_groups - : packageInfo.var_groups; + const effectiveVarGroups = hasStreamLevelVarGroups ? streamVarGroups : pkgVarGroups; // Stream-level var group selections - derives from policy, initializes defaults, handles changes const { selections: streamVarGroupSelections, handleSelectionChange: handleStreamVarGroupSelectionChange, } = useVarGroupSelections({ - varGroups: hasStreamLevelVarGroups ? packageInputStream.var_groups : undefined, + varGroups: hasStreamLevelVarGroups ? streamVarGroups : undefined, savedSelections: packagePolicyInputStream.var_group_selections, isAgentlessEnabled, onSelectionsChange: updatePackagePolicyInputStream, @@ -367,7 +371,7 @@ export const PackagePolicyInputStreamConfig = memo( {/* Stream-level Var Group Selectors */} {hasStreamLevelVarGroups && - packageInputStream.var_groups?.map((varGroup) => ( + streamVarGroups?.map((varGroup) => ( { // assume_role should be visible in default mode expect(options.find((o) => o.value === 'assume_role')).toBeDefined(); }); + + it('should render the selector as disabled when disabled prop is true', () => { + render(); + + const select = screen.getByTestId('varGroupSelector-credential_type'); + expect(select).toBeDisabled(); + }); + + it('should render the selector as enabled when disabled prop is false', () => { + render(); + + const select = screen.getByTestId('varGroupSelector-credential_type'); + expect(select).not.toBeDisabled(); + }); + + it('should render the selector as enabled when disabled prop is not provided', () => { + render(); + + const select = screen.getByTestId('varGroupSelector-credential_type'); + expect(select).not.toBeDisabled(); + }); + + it('should not call onSelectionChange when disabled and user tries to change', () => { + render(); + + const select = screen.getByTestId('varGroupSelector-credential_type'); + fireEvent.change(select, { target: { value: 'temporary_access_key' } }); + + // EuiSelect with disabled will prevent the change event from firing + expect(select).toBeDisabled(); + }); }); describe('isVarRequiredByVarGroup', () => { @@ -447,4 +483,173 @@ describe('VarGroupSelector', () => { expect(isVarRequiredByVarGroup('var1', [varGroupWithoutRequired], selections)).toBe(false); }); }); + + describe('getSelectedOption', () => { + it('should return the selected option', () => { + const result = getSelectedOption(mockVarGroup, 'direct_access_key'); + expect(result).toBeDefined(); + expect(result?.name).toBe('direct_access_key'); + expect(result?.vars).toEqual(['access_key_id', 'secret_access_key']); + }); + + it('should return undefined for unknown option name', () => { + const result = getSelectedOption(mockVarGroup, 'unknown'); + expect(result).toBeUndefined(); + }); + + it('should return undefined when selectedOptionName is undefined', () => { + const result = getSelectedOption(mockVarGroup, undefined); + expect(result).toBeUndefined(); + }); + + it('should return option with provider field', () => { + const varGroupWithProvider: RegistryVarGroup = { + name: 'auth', + title: 'Auth', + selector_title: 'Select', + options: [ + { name: 'manual', title: 'Manual', vars: ['key'] }, + { name: 'aws_connector', title: 'AWS Connector', vars: ['role'], provider: 'aws' }, + ], + }; + + const result = getSelectedOption(varGroupWithProvider, 'aws_connector'); + expect(result?.provider).toBe('aws'); + }); + }); + + describe('getCloudConnectorOption', () => { + const varGroupWithProvider: RegistryVarGroup = { + name: 'auth', + title: 'Auth', + selector_title: 'Select', + options: [ + { name: 'manual', title: 'Manual', vars: ['key'] }, + { name: 'aws_connector', title: 'AWS Connector', vars: ['role'], provider: 'aws' }, + { name: 'azure_connector', title: 'Azure Connector', vars: ['tenant'], provider: 'azure' }, + ], + }; + + it('should return isSelected: true with provider when cloud connector is selected', () => { + const result = getCloudConnectorOption([varGroupWithProvider], { auth: 'aws_connector' }); + expect(result.isSelected).toBe(true); + expect(result.provider).toBe('aws'); + }); + + it('should return azure provider when azure connector is selected', () => { + const result = getCloudConnectorOption([varGroupWithProvider], { auth: 'azure_connector' }); + expect(result.isSelected).toBe(true); + expect(result.provider).toBe('azure'); + }); + + it('should return isSelected: false when non-cloud-connector option is selected', () => { + const result = getCloudConnectorOption([varGroupWithProvider], { auth: 'manual' }); + expect(result.isSelected).toBe(false); + expect(result.provider).toBeUndefined(); + }); + + it('should return isSelected: false when varGroups is undefined', () => { + const result = getCloudConnectorOption(undefined, { auth: 'aws_connector' }); + expect(result.isSelected).toBe(false); + }); + + it('should return isSelected: false when varGroups is empty', () => { + const result = getCloudConnectorOption([], { auth: 'aws_connector' }); + expect(result.isSelected).toBe(false); + }); + + it('should return isSelected: false when no selection matches', () => { + const result = getCloudConnectorOption([varGroupWithProvider], { other_group: 'value' }); + expect(result.isSelected).toBe(false); + }); + }); + + describe('getIacTemplateUrlFromVarGroupSelection', () => { + const varGroupWithIacTemplateUrl: RegistryVarGroup = { + name: 'credential_type', + title: 'Credential Type', + selector_title: 'Select credential type', + options: [ + { name: 'manual', title: 'Manual', vars: ['access_key'] }, + { + name: 'cloud_connectors', + title: 'Cloud Connectors', + vars: ['role_arn'], + provider: 'aws', + iac_template_url: 'https://example.com/cloudformation-template.yaml', + }, + { + name: 'azure_connector', + title: 'Azure Connector', + vars: ['tenant_id'], + provider: 'azure', + iac_template_url: 'https://example.com/arm-template.json', + }, + ], + }; + + it('should return iac_template_url when cloud connector option with template is selected', () => { + const result = getIacTemplateUrlFromVarGroupSelection([varGroupWithIacTemplateUrl], { + credential_type: 'cloud_connectors', + }); + expect(result).toBe('https://example.com/cloudformation-template.yaml'); + }); + + it('should return azure iac_template_url when azure connector is selected', () => { + const result = getIacTemplateUrlFromVarGroupSelection([varGroupWithIacTemplateUrl], { + credential_type: 'azure_connector', + }); + expect(result).toBe('https://example.com/arm-template.json'); + }); + + it('should return undefined when selected option has no iac_template_url', () => { + const result = getIacTemplateUrlFromVarGroupSelection([varGroupWithIacTemplateUrl], { + credential_type: 'manual', + }); + expect(result).toBeUndefined(); + }); + + it('should return undefined when varGroups is undefined', () => { + const result = getIacTemplateUrlFromVarGroupSelection(undefined, { + credential_type: 'cloud_connectors', + }); + expect(result).toBeUndefined(); + }); + + it('should return undefined when varGroups is empty', () => { + const result = getIacTemplateUrlFromVarGroupSelection([], { + credential_type: 'cloud_connectors', + }); + expect(result).toBeUndefined(); + }); + + it('should return undefined when no selection matches any var_group', () => { + const result = getIacTemplateUrlFromVarGroupSelection([varGroupWithIacTemplateUrl], { + other_group: 'some_value', + }); + expect(result).toBeUndefined(); + }); + + it('should return undefined when varGroupSelections is empty', () => { + const result = getIacTemplateUrlFromVarGroupSelection([varGroupWithIacTemplateUrl], {}); + expect(result).toBeUndefined(); + }); + + it('should search across multiple var_groups and return first match', () => { + const multipleVarGroups: RegistryVarGroup[] = [ + { + name: 'auth_method', + title: 'Auth Method', + selector_title: 'Select auth', + options: [{ name: 'api_key', title: 'API Key', vars: ['key'] }], + }, + varGroupWithIacTemplateUrl, + ]; + + const result = getIacTemplateUrlFromVarGroupSelection(multipleVarGroups, { + credential_type: 'cloud_connectors', + }); + expect(result).toBe('https://example.com/cloudformation-template.yaml'); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/var_group_selector.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/var_group_selector.tsx index 5a0855eb71d2d..7491e3dd414b2 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/var_group_selector.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/var_group_selector.tsx @@ -18,6 +18,7 @@ interface VarGroupSelectorProps { onSelectionChange: (groupName: string, optionName: string) => void; isAgentlessEnabled: boolean; hideInVarGroupOptions?: Record; + disabled?: boolean; } /** @@ -30,6 +31,7 @@ export const VarGroupSelector: React.FC = ({ onSelectionChange, isAgentlessEnabled, hideInVarGroupOptions, + disabled = false, }) => { const visibleOptions = useMemo( () => getVisibleOptions(varGroup, isAgentlessEnabled, hideInVarGroupOptions), @@ -106,6 +108,7 @@ export const VarGroupSelector: React.FC = ({ options={selectOptions} value={selectedOptionName || ''} onChange={handleChange} + disabled={disabled} fullWidth /> diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.test.tsx index c19979cfcc4ad..ed34b2afcef38 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.test.tsx @@ -13,12 +13,9 @@ import type { TestRenderer } from '../../../../../../../mock'; import { createFleetTestRendererMock } from '../../../../../../../mock'; import type { NewPackagePolicy, PackageInfo } from '../../../../../types'; -import { validatePackagePolicy } from '../../services'; +import { validatePackagePolicy, isInputCompatibleWithVarGroupSelections } from '../../services'; -import { - StepConfigurePackagePolicy, - isInputCompatibleWithVarGroupSelections, -} from './step_configure_package'; +import { StepConfigurePackagePolicy } from './step_configure_package'; describe('StepConfigurePackage', () => { let packageInfo: PackageInfo; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.tsx index 7944f1008830a..4f9bced9b4217 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_configure_package.tsx @@ -25,42 +25,15 @@ import { } from '../../../../../../../../common/services'; import { isInputAllowedForDeploymentMode } from '../../../../../../../../common/services/agentless_policy_helper'; -import type { - PackageInfo, - NewPackagePolicy, - NewPackagePolicyInput, - RegistryInput, -} from '../../../../../types'; +import type { PackageInfo, NewPackagePolicy, NewPackagePolicyInput } from '../../../../../types'; import { Loading } from '../../../../../components'; import { doesPackageHaveIntegrations } from '../../../../../services'; import type { PackagePolicyValidationResults, VarGroupSelection } from '../../services'; +import { isInputCompatibleWithVarGroupSelections } from '../../services'; import { PackagePolicyInputPanel } from './components'; -/** - * Check if an input is compatible with the current var_group selections. - * An input is incompatible if any of its hide_in_var_group_options includes - * the currently selected option for that var_group. - */ -export function isInputCompatibleWithVarGroupSelections( - input: RegistryInput, - varGroupSelections: VarGroupSelection -): boolean { - if (!input.hide_in_var_group_options) { - return true; - } - - for (const [groupName, hiddenOptions] of Object.entries(input.hide_in_var_group_options)) { - const selectedOption = varGroupSelections[groupName]; - if (selectedOption && hiddenOptions.includes(selectedOption)) { - return false; - } - } - - return true; -} - export const StepConfigurePackagePolicy: React.FunctionComponent<{ packageInfo: PackageInfo; showOnlyIntegration?: string; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx index 3f03008ac2989..0ed67093aa454 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_define_package_policy.tsx @@ -28,13 +28,20 @@ import { import styled from 'styled-components'; import { NamespaceComboBox } from '../../../../../../../components/namespace_combo_box'; +import { CloudConnectorSetup } from '../../../../../../../components/cloud_connector'; import type { PackageInfo, NewPackagePolicy, RegistryVarsEntry } from '../../../../../types'; import { Loading } from '../../../../../components'; -import { useGetEpmDatastreams, useStartServices } from '../../../../../hooks'; +import { + useGetEpmDatastreams, + useStartServices, + useVarGroupCloudConnector, +} from '../../../../../hooks'; import { isAdvancedVar, shouldShowVar, isVarRequiredByVarGroup } from '../../services'; import type { PackagePolicyValidationResults } from '../../services'; +import { ExperimentalFeaturesService } from '../../../../../services'; + import { PackagePolicyInputVarField, VarGroupSelector, useVarGroupSelections } from './components'; import { useOutputs } from './components/hooks'; @@ -69,20 +76,36 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ isEditPage = false, isAgentlessSelected = false, }) => { - const { docLinks } = useStartServices(); + const { docLinks, cloud } = useStartServices(); + const { enableVarGroups } = ExperimentalFeaturesService.get(); + + const varGroups = + enableVarGroups && packageInfo.var_groups ? packageInfo.var_groups : undefined; // Form show/hide states const [isShowingAdvanced, setIsShowingAdvanced] = useState(noAdvancedToggle); - // Var group selections - derives from policy, initializes defaults, handles changes const { selections: varGroupSelections, handleSelectionChange: handleVarGroupSelectionChange } = useVarGroupSelections({ - varGroups: packageInfo.var_groups, + varGroups, savedSelections: packagePolicy.var_group_selections, isAgentlessEnabled: isAgentlessSelected, onSelectionsChange: updatePackagePolicy, + packagePolicy, }); + const { + isSelected: isCloudConnectorSelected, + cloudProvider, + iacTemplateUrl, + cloudConnectorVars, + handleCloudConnectorUpdate, + } = useVarGroupCloudConnector({ + varGroups, + varGroupSelections, + updatePackagePolicy, + }); + // Package-level vars, filtered by var_group visibility // and hiding deprecated vars on new installations const { requiredVars, advancedVars } = useMemo(() => { @@ -91,21 +114,20 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ if (packageInfo.vars) { packageInfo.vars.forEach((varDef) => { - // Hide deprecated vars on new installations - if (!isEditPage && !!varDef.deprecated) { + if (cloudConnectorVars.has(varDef.name) || (!isEditPage && !!varDef.deprecated)) { return; } // Check if var should be shown based on var_group selections if ( - packageInfo.var_groups && - packageInfo.var_groups.length > 0 && - !shouldShowVar(varDef.name, packageInfo.var_groups, varGroupSelections) + varGroups && + varGroups.length > 0 && + !shouldShowVar(varDef.name, varGroups, varGroupSelections) ) { return; // Skip this var, it's hidden by var_group selection } - if (isAdvancedVar(varDef, packageInfo.var_groups, varGroupSelections)) { + if (isAdvancedVar(varDef, varGroups, varGroupSelections)) { _advancedVars.push(varDef); } else { _requiredVars.push(varDef); @@ -114,7 +136,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ } return { requiredVars: _requiredVars, advancedVars: _advancedVars }; - }, [packageInfo.vars, packageInfo.var_groups, varGroupSelections, isEditPage]); + }, [packageInfo.vars, varGroups, varGroupSelections, cloudConnectorVars, isEditPage]); // Outputs const { @@ -266,17 +288,36 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ {/* Var Group Selectors */} - {packageInfo.var_groups?.map((varGroup) => ( + {varGroups?.map((varGroup) => ( ))} + {/* Cloud Connector Setup - shown when a cloud connector option is selected */} + {isCloudConnectorSelected && cloudProvider && ( + + + + )} + {/* Required vars */} {requiredVars.map((varDef) => { const { name: varName, type: varType } = varDef; @@ -284,7 +325,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ const value = packagePolicy.vars[varName].value; const requiredByVarGroup = isVarRequiredByVarGroup( varName, - packageInfo.var_groups, + varGroups, varGroupSelections ); @@ -502,7 +543,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ const value = packagePolicy.vars![varName].value; const requiredByVarGroup = isVarRequiredByVarGroup( varName, - packageInfo.var_groups, + varGroups, varGroupSelections ); return ( diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/index.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/index.ts index e08ca99ae18a4..b2ca8ec47eee6 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/index.ts +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/index.ts @@ -15,8 +15,17 @@ export { shouldShowVar, isVarRequiredByVarGroup, isVarInSelectedVarGroupOption, + getSelectedOption, + isInputCompatibleWithVarGroupSelections, + isInputVisibleForVarGroupSelections, } from './var_group_helpers'; export type { VarGroupSelection } from './var_group_helpers'; +export { + buildVarGroupPolicyUpdates, + registerPolicyUpdateHandler, + updateCloudConnectorPolicy, +} from './var_group_policy_effects'; +export type { PolicyUpdateHandler } from './var_group_policy_effects'; export type { PackagePolicyValidationResults, PackagePolicyConfigValidationResults, diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/var_group_helpers.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/var_group_helpers.ts index d96db2c37264b..e3af08afe3106 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/var_group_helpers.ts +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/var_group_helpers.ts @@ -5,15 +5,94 @@ * 2.0. */ -import type { RegistryVarGroup, RegistryVarGroupOption } from '../../../../types'; +import { + doesPackageHaveIntegrations, + getNormalizedInputs, +} from '../../../../../../../common/services'; +import type { + PackageInfo, + RegistryInput, + RegistryVarGroup, + RegistryVarGroupOption, +} from '../../../../types'; + +// Re-export generic var_group helpers from common/services +export type { VarGroupSelection } from '../../../../../../../common/services/cloud_connectors'; +import type { VarGroupSelection } from '../../../../../../../common/services/cloud_connectors'; +export { + getSelectedOption, + getVisibleVarsForOption, + getVarsControlledByVarGroups, + shouldShowVar, + isVarRequiredByVarGroup, + isVarInSelectedVarGroupOption, +} from '../../../../../../../common/services/var_group_helpers'; -export interface VarGroupSelection { - [groupName: string]: string; // groupName -> selected option name +/** + * Check if an input is compatible with the current var_group selections. + * An input is incompatible (hidden) if any of its hide_in_var_group_options includes + * the currently selected option for that var_group. + */ +export function isInputCompatibleWithVarGroupSelections( + registryInput: RegistryInput, + varGroupSelections: VarGroupSelection +): boolean { + if (!registryInput.hide_in_var_group_options) { + return true; + } + + for (const [groupName, hiddenOptions] of Object.entries( + registryInput.hide_in_var_group_options + )) { + const selectedOption = varGroupSelections[groupName]; + if (selectedOption && hiddenOptions.includes(selectedOption)) { + return false; + } + } + + return true; +} + +/** + * Returns whether an input is visible for the current var_group selections. + * When the package has no var_groups, all inputs are visible. + * Otherwise, the input is visible if it is compatible with current selections + * (same pattern as deployment mode: hidden inputs are disabled so validation omits them). + */ +export function isInputVisibleForVarGroupSelections( + input: { type: string; policy_template?: string }, + packageInfo: PackageInfo | undefined, + varGroupSelections: VarGroupSelection +): boolean { + if (!packageInfo?.var_groups?.length) { + return true; + } + + const hasIntegrations = doesPackageHaveIntegrations(packageInfo); + let registryInput: RegistryInput | undefined; + + for (const policyTemplate of packageInfo.policy_templates ?? []) { + const inputs = getNormalizedInputs(policyTemplate); + registryInput = inputs.find( + (i) => + i.type === input.type && + (hasIntegrations ? policyTemplate.name === input.policy_template : true) + ); + if (registryInput) break; + } + + if (!registryInput) { + return true; + } + + return isInputCompatibleWithVarGroupSelections(registryInput, varGroupSelections); } /** * Get visible options for a var group, filtering out options that should be hidden * based on deployment mode or hide_in_var_group_options configuration. + * + * Note: This function is UI-specific as it uses isAgentlessEnabled and hideInVarGroupOptions. */ export function getVisibleOptions( varGroup: RegistryVarGroup, @@ -42,39 +121,17 @@ export function getVisibleOptions( }); } -/** - * Get variable names that should be visible based on the selected option. - * Returns undefined if no option is selected. - */ -export function getVisibleVarsForOption( - varGroup: RegistryVarGroup, - selectedOptionName: string | undefined -): string[] | undefined { - if (!selectedOptionName) { - return undefined; - } - - const selectedOption = varGroup.options.find((opt) => opt.name === selectedOptionName); - return selectedOption?.vars; -} - -/** - * Get all variable names that are controlled by any var_group. - * These vars should only be shown when their option is selected. - */ -export function getVarsControlledByVarGroups(varGroups: RegistryVarGroup[]): Set { - return new Set(varGroups.flatMap((group) => group.options.flatMap((option) => option.vars))); -} - /** * Compute default selections from var_groups (first visible option for each group). + * + * Note: This function is UI-specific as it depends on getVisibleOptions. */ export function computeDefaultVarGroupSelections( varGroups: RegistryVarGroup[] | undefined, isAgentlessEnabled: boolean, hideInVarGroupOptions?: Record -): VarGroupSelection { - const defaults: VarGroupSelection = {}; +): Record { + const defaults: Record = {}; if (varGroups) { for (const varGroup of varGroups) { const visibleOptions = getVisibleOptions(varGroup, isAgentlessEnabled, hideInVarGroupOptions); @@ -85,77 +142,3 @@ export function computeDefaultVarGroupSelections( } return defaults; } - -/** - * Determines if a variable should be visible based on var_group selections. - */ -export function shouldShowVar( - varName: string, - varGroups: RegistryVarGroup[], - varGroupSelections: VarGroupSelection -): boolean { - // Get all vars controlled by var_groups - const controlledVars = getVarsControlledByVarGroups(varGroups); - - // If this var is not controlled by any var_group, always show it - if (!controlledVars.has(varName)) { - return true; - } - - // Check if this var is in the selected option for any var_group - return varGroups - .filter((group) => varGroupSelections[group.name]) - .some((group) => { - const selectedOption = group.options.find( - (opt) => opt.name === varGroupSelections[group.name] - ); - return selectedOption?.vars.includes(varName); - }); -} - -/** - * Determines if a variable is required due to being in a required var_group's selected option. - * When var_group.required is true, all vars in the selected option are treated as required. - */ -export function isVarRequiredByVarGroup( - varName: string, - varGroups: RegistryVarGroup[] | undefined, - varGroupSelections: VarGroupSelection | undefined -): boolean { - if (!varGroups || varGroups.length === 0 || !varGroupSelections) { - return false; - } - - return varGroups - .filter((group) => group.required && varGroupSelections[group.name]) - .some((group) => { - const selectedOption = group.options.find( - (opt) => opt.name === varGroupSelections[group.name] - ); - return selectedOption?.vars.includes(varName); - }); -} - -/** - * Checks if a variable is part of a currently selected var_group option. - * This is used to override show_user: false for vars that belong to a selected var_group option. - * - * Unlike shouldShowVar which returns true for vars NOT controlled by var_groups, - * this function returns false for such vars - it specifically checks if a var - * is controlled by a var_group AND is in the selected option. - */ -export function isVarInSelectedVarGroupOption( - varName: string, - varGroups: RegistryVarGroup[], - varGroupSelections: VarGroupSelection -): boolean { - const controlledVars = getVarsControlledByVarGroups(varGroups); - - // If not controlled by any var_group, it's not "in a selected option" - if (!controlledVars.has(varName)) { - return false; - } - - // If controlled and shouldShowVar returns true, it means it's in a selected option - return shouldShowVar(varName, varGroups, varGroupSelections); -} diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/var_group_policy_effects.test.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/var_group_policy_effects.test.ts new file mode 100644 index 0000000000000..34c6e26d2ad34 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/var_group_policy_effects.test.ts @@ -0,0 +1,398 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { NewPackagePolicy } from '../../../../../../../common'; +import type { RegistryVarGroup } from '../../../../types'; + +import { + updateCloudConnectorPolicy, + buildVarGroupPolicyUpdates, + registerPolicyUpdateHandler, +} from './var_group_policy_effects'; +import type { VarGroupSelection } from './var_group_helpers'; + +describe('var_group_policy_effects', () => { + const createMockPackagePolicy = ( + overrides: Partial = {} + ): NewPackagePolicy => ({ + name: 'test-policy', + namespace: 'default', + description: '', + enabled: true, + policy_id: 'agent-policy-1', + policy_ids: ['agent-policy-1'], + inputs: [], + ...overrides, + }); + + const createMockVarGroups = (): RegistryVarGroup[] => [ + { + name: 'auth_method', + title: 'Authentication Method', + selector_title: 'Select authentication method', + options: [ + { + name: 'cloud_connector', + title: 'Cloud Connector', + vars: ['role_arn', 'external_id'], + provider: 'aws', + }, + { + name: 'manual', + title: 'Manual', + vars: ['access_key', 'secret_key'], + }, + ], + }, + ]; + + describe('updateCloudConnectorPolicy', () => { + it('should return supports_cloud_connector: true when cloud connector option is selected', () => { + const packagePolicy = createMockPackagePolicy(); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result).toEqual({ + supports_cloud_connector: true, + cloud_connector_id: undefined, + }); + }); + + it('should return supports_cloud_connector: false when non-cloud-connector option is selected', () => { + const packagePolicy = createMockPackagePolicy({ supports_cloud_connector: true }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'manual' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result).toEqual({ + supports_cloud_connector: false, + cloud_connector_id: undefined, + }); + }); + + it('should return null when cloud connector is already enabled and selected', () => { + const packagePolicy = createMockPackagePolicy({ + supports_cloud_connector: true, + cloud_connector_id: undefined, + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result).toBeNull(); + }); + + it('should preserve cloud_connector_id when already set by CloudConnectorSetup', () => { + const packagePolicy = createMockPackagePolicy({ + supports_cloud_connector: true, + cloud_connector_id: 'existing-connector-id', + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result).toBeNull(); + }); + + it('should preserve cloud_connector_id in edit flow with supports_cloud_connectors var set', () => { + const packagePolicy = createMockPackagePolicy({ + supports_cloud_connector: true, + cloud_connector_id: 'saved-connector-id', + vars: { + supports_cloud_connectors: { value: true, type: 'bool' }, + }, + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result).toBeNull(); + }); + + it('should return null when cloud connector is already disabled and not selected', () => { + const packagePolicy = createMockPackagePolicy({ + supports_cloud_connector: false, + cloud_connector_id: undefined, + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'manual' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result).toBeNull(); + }); + + describe('supports_cloud_connectors var', () => { + it('should set supports_cloud_connectors var to true when selecting cloud connector', () => { + const packagePolicy = createMockPackagePolicy({ + vars: { + role_arn: { value: '' }, + external_id: { value: '' }, + supports_cloud_connectors: { value: false, type: 'bool' }, + }, + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result?.vars?.supports_cloud_connectors).toEqual({ value: true, type: 'bool' }); + expect(result?.supports_cloud_connector).toBe(true); + }); + + it('should set supports_cloud_connectors var to false and clear secret vars when deselecting', () => { + const packagePolicy = createMockPackagePolicy({ + supports_cloud_connector: true, + vars: { + role_arn: { value: 'some-arn' }, + external_id: { value: { isSecretRef: true, id: 'secret-1' }, type: 'password' }, + supports_cloud_connectors: { value: true, type: 'bool' }, + }, + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'manual' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result?.vars?.supports_cloud_connectors).toEqual({ value: false, type: 'bool' }); + expect(result?.vars?.external_id).toEqual({ value: '', type: 'password' }); + expect(result?.vars?.role_arn).toEqual({ value: 'some-arn' }); + expect(result?.supports_cloud_connector).toBe(false); + }); + + it('should preserve plain-text cloud connector vars and non-cloud-connector vars when deselecting', () => { + const packagePolicy = createMockPackagePolicy({ + supports_cloud_connector: true, + vars: { + role_arn: { value: 'some-arn' }, + external_id: { value: 'plain-text-id' }, + supports_cloud_connectors: { value: true }, + unrelated_var: { value: 'keep-this' }, + }, + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'manual' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result?.vars?.role_arn).toEqual({ value: 'some-arn' }); + expect(result?.vars?.external_id).toEqual({ value: 'plain-text-id' }); + expect(result?.vars?.supports_cloud_connectors).toEqual({ value: false }); + expect(result?.vars?.unrelated_var).toEqual({ value: 'keep-this' }); + }); + + it('should not include vars update when supports_cloud_connectors var does not exist', () => { + const packagePolicy = createMockPackagePolicy({ + vars: { + role_arn: { value: '' }, + }, + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result).toEqual({ + supports_cloud_connector: true, + cloud_connector_id: undefined, + }); + expect(result).not.toHaveProperty('vars'); + }); + + it('should return null when var is already in desired state', () => { + const packagePolicy = createMockPackagePolicy({ + supports_cloud_connector: true, + cloud_connector_id: undefined, + vars: { + supports_cloud_connectors: { value: true }, + }, + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result).toBeNull(); + }); + + it('should trigger update when root flag is correct but var is stale', () => { + const packagePolicy = createMockPackagePolicy({ + supports_cloud_connector: false, + cloud_connector_id: undefined, + vars: { + supports_cloud_connectors: { value: true }, + }, + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'manual' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result?.vars?.supports_cloud_connectors).toEqual({ value: false }); + }); + + it('should only clear secret-ref vars, not plain-text cloud connector vars', () => { + const packagePolicy = createMockPackagePolicy({ + supports_cloud_connector: true, + vars: { + role_arn: { value: 'arn:aws:iam::123456:role/MyRole' }, + external_id: { value: { isSecretRef: true, id: 'secret-abc' }, type: 'password' }, + supports_cloud_connectors: { value: true, type: 'bool' }, + }, + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'manual' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result?.vars?.role_arn).toEqual({ value: 'arn:aws:iam::123456:role/MyRole' }); + expect(result?.vars?.external_id).toEqual({ value: '', type: 'password' }); + expect(result?.vars?.supports_cloud_connectors).toEqual({ value: false, type: 'bool' }); + }); + + it('should clear secret-ref vars in input stream vars when deselecting', () => { + const packagePolicy = createMockPackagePolicy({ + supports_cloud_connector: true, + inputs: [ + { + type: 'aws-test', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { type: 'logs', dataset: 'test' }, + vars: { + role_arn: { value: 'arn:aws:iam::123456:role/MyRole' }, + external_id: { + value: { isSecretRef: true, id: 'secret-xyz' }, + type: 'password', + }, + some_other_var: { value: 'keep' }, + }, + }, + ], + }, + ], + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'manual' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + const streamVars = result?.inputs?.[0]?.streams[0]?.vars; + expect(streamVars?.role_arn).toEqual({ value: 'arn:aws:iam::123456:role/MyRole' }); + expect(streamVars?.external_id).toEqual({ value: '', type: 'password' }); + expect(streamVars?.some_other_var).toEqual({ value: 'keep' }); + }); + + it('should not return inputs when no cloud connector vars exist in streams', () => { + const packagePolicy = createMockPackagePolicy({ + supports_cloud_connector: true, + inputs: [ + { + type: 'aws-test', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { type: 'logs', dataset: 'test' }, + vars: { + unrelated_var: { value: 'keep' }, + }, + }, + ], + }, + ], + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'manual' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result).not.toHaveProperty('inputs'); + }); + + it('should update stale var without clearing cloud_connector_id', () => { + const packagePolicy = createMockPackagePolicy({ + supports_cloud_connector: true, + cloud_connector_id: 'existing-connector-id', + vars: { + supports_cloud_connectors: { value: false, type: 'bool' }, + }, + }); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + + const result = updateCloudConnectorPolicy(packagePolicy, selections, varGroups); + + expect(result).toEqual({ + supports_cloud_connector: true, + vars: expect.objectContaining({ + supports_cloud_connectors: { value: true, type: 'bool' }, + }), + }); + expect(result).not.toHaveProperty('cloud_connector_id'); + }); + }); + }); + + describe('buildVarGroupPolicyUpdates', () => { + it('should return null when varGroups is undefined', () => { + const packagePolicy = createMockPackagePolicy(); + const selections: VarGroupSelection = {}; + + const result = buildVarGroupPolicyUpdates(packagePolicy, selections, undefined); + + expect(result).toBeNull(); + }); + + it('should return null when varGroups is empty', () => { + const packagePolicy = createMockPackagePolicy(); + const selections: VarGroupSelection = {}; + + const result = buildVarGroupPolicyUpdates(packagePolicy, selections, []); + + expect(result).toBeNull(); + }); + + it('should compute effects from cloud connector handler', () => { + const packagePolicy = createMockPackagePolicy(); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + + const result = buildVarGroupPolicyUpdates(packagePolicy, selections, varGroups); + + expect(result).toEqual({ + supports_cloud_connector: true, + cloud_connector_id: undefined, + }); + }); + }); + + describe('registerPolicyUpdateHandler', () => { + it('should allow registering custom handlers', () => { + const customHandler = jest.fn().mockReturnValue({ custom_field: 'test' }); + + registerPolicyUpdateHandler(customHandler); + + const packagePolicy = createMockPackagePolicy(); + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'manual' }; + + const result = buildVarGroupPolicyUpdates(packagePolicy, selections, varGroups); + + expect(customHandler).toHaveBeenCalledWith(packagePolicy, selections, varGroups); + expect(result).toMatchObject({ custom_field: 'test' }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/var_group_policy_effects.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/var_group_policy_effects.ts new file mode 100644 index 0000000000000..dcaf2600e7098 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/services/var_group_policy_effects.ts @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + NewPackagePolicy, + NewPackagePolicyInput, + PackagePolicyConfigRecord, +} from '../../../../../../../common'; +import { SUPPORTS_CLOUD_CONNECTORS_VAR_NAME } from '../../../../../../../common/constants'; +import { + getCloudConnectorOption, + getAllCloudConnectorVarNames, + type VarGroupSelection, +} from '../../../../../../../common/services/cloud_connectors'; +import type { RegistryVarGroup } from '../../../../types'; + +/** + * Handler function type for computing policy effects based on var_group selections. + * Returns partial policy updates or null if no updates needed. + */ +export type PolicyUpdateHandler = ( + packagePolicy: NewPackagePolicy, + varGroupSelections: VarGroupSelection, + varGroups: RegistryVarGroup[] +) => Partial | null; + +// Registry of effect handlers for extensibility +const policyUpdateHandlers: PolicyUpdateHandler[] = []; + +/** + * Builds a vars update that sets the supports_cloud_connectors package-level var. + * Returns an empty object if the var doesn't exist in the current policy vars + * (meaning the integration doesn't declare it in its manifest). + */ +function buildSupportsCloudConnectorsVarsUpdate( + currentVars: PackagePolicyConfigRecord | undefined, + value: boolean +): { vars: PackagePolicyConfigRecord } | Record { + if (!currentVars || !(SUPPORTS_CLOUD_CONNECTORS_VAR_NAME in currentVars)) { + return {}; + } + + return { + vars: { + ...currentVars, + [SUPPORTS_CLOUD_CONNECTORS_VAR_NAME]: { + ...currentVars[SUPPORTS_CLOUD_CONNECTORS_VAR_NAME], + value, + }, + }, + }; +} + +function isSecretRef(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + (value as Record).isSecretRef === true + ); +} + +/** + * Resets cloud connector vars that hold secret references to empty strings, preserving + * type and other metadata. Plain-text vars are left untouched so non-sensitive values + * (e.g. role_arn) survive a var_group switch. + * Mutates nothing — returns a new record or undefined when no changes are needed. + */ +function clearSecretVarsInRecord( + vars: PackagePolicyConfigRecord, + varNames: Set +): PackagePolicyConfigRecord | undefined { + let changed = false; + const updated = { ...vars }; + for (const name of varNames) { + if (name in updated && isSecretRef(updated[name].value)) { + updated[name] = { ...updated[name], value: '' }; + changed = true; + } + } + return changed ? updated : undefined; +} + +/** + * Builds the complete deactivation update for package-level vars in a single pass: + * clears secret-ref cloud connector vars AND sets supports_cloud_connectors to false. + * Avoids the merge-conflict that arises when two full-record spreads are combined. + */ +function buildDeactivatePackageVarsUpdate( + currentVars: PackagePolicyConfigRecord | undefined, + cloudConnectorVarNames: Set +): { vars: PackagePolicyConfigRecord } | Record { + if (!currentVars) return {}; + + let changed = false; + const updated = { ...currentVars }; + + for (const name of cloudConnectorVarNames) { + if (name in updated && isSecretRef(updated[name].value)) { + updated[name] = { ...updated[name], value: '' }; + changed = true; + } + } + + if ( + SUPPORTS_CLOUD_CONNECTORS_VAR_NAME in updated && + updated[SUPPORTS_CLOUD_CONNECTORS_VAR_NAME].value !== false + ) { + updated[SUPPORTS_CLOUD_CONNECTORS_VAR_NAME] = { + ...updated[SUPPORTS_CLOUD_CONNECTORS_VAR_NAME], + value: false, + }; + changed = true; + } + + return changed ? { vars: updated } : {}; +} + +/** + * Clears secret-ref cloud connector vars from input/stream-level vars. + * Returns the full updated inputs array, or undefined when no changes are needed. + */ +function clearInputStreamVars( + inputs: NewPackagePolicyInput[], + cloudConnectorVarNames: Set +): NewPackagePolicyInput[] | undefined { + if (cloudConnectorVarNames.size === 0) return undefined; + + let inputsChanged = false; + const updatedInputs: NewPackagePolicyInput[] = inputs.map((input) => { + let inputChanged = false; + + const updatedStreams = input.streams.map((stream) => { + if (!stream.vars) return stream; + const clearedStreamVars = clearSecretVarsInRecord(stream.vars, cloudConnectorVarNames); + if (clearedStreamVars) { + inputChanged = true; + return { ...stream, vars: clearedStreamVars }; + } + return stream; + }); + + if (inputChanged) { + inputsChanged = true; + return { ...input, streams: updatedStreams }; + } + return input; + }); + + return inputsChanged ? updatedInputs : undefined; +} + +/** + * Cloud Connector policy effect handler. + * Sets supports_cloud_connector, cloud_connector_id, and the supports_cloud_connectors + * package-level var based on var_group selection. + * + * The supports_cloud_connectors var is required for the agent's auth provider to use + * cloud connector credential exchange. It must always be explicitly false when cloud + * connector is not selected. + * + * When deactivating cloud connector, this also clears cloud-connector vars that hold + * secret references (isSecretRef: true) at every scope — package vars, input vars, + * and stream vars — to prevent stale secrets from leaking into agent-based mode. + * Plain-text vars (e.g. role_arn) are preserved across the switch. + */ +export const updateCloudConnectorPolicy: PolicyUpdateHandler = ( + packagePolicy, + varGroupSelections, + varGroups +) => { + const cloudConnectorOption = getCloudConnectorOption(varGroups, varGroupSelections); + const currentVarValue = packagePolicy.vars?.[SUPPORTS_CLOUD_CONNECTORS_VAR_NAME]?.value; + + if (cloudConnectorOption.isSelected) { + // Only update if supports_cloud_connector flag or the var need to change. + // cloud_connector_id is intentionally NOT checked here — once set by + // CloudConnectorSetup it must be preserved across unrelated var_group changes. + if ( + packagePolicy.supports_cloud_connector !== true || + (currentVarValue !== undefined && currentVarValue !== true) + ) { + return { + supports_cloud_connector: true, + // Only initialize cloud_connector_id when first transitioning to cloud + // connector mode; preserve existing ID if CloudConnectorSetup already set one + ...(packagePolicy.supports_cloud_connector !== true + ? { cloud_connector_id: undefined } + : {}), + ...buildSupportsCloudConnectorsVarsUpdate(packagePolicy.vars, true), + }; + } + } else { + // Cloud connector not selected - clear the flags and cloud connector vars + if ( + packagePolicy.supports_cloud_connector === true || + packagePolicy.cloud_connector_id !== undefined || + (currentVarValue !== undefined && currentVarValue !== false) + ) { + const cloudConnectorVarNames = getAllCloudConnectorVarNames(varGroups); + + const updatedInputs = clearInputStreamVars(packagePolicy.inputs, cloudConnectorVarNames); + + return { + supports_cloud_connector: false, + cloud_connector_id: undefined, + ...buildDeactivatePackageVarsUpdate(packagePolicy.vars, cloudConnectorVarNames), + ...(updatedInputs ? { inputs: updatedInputs } : {}), + }; + } + } + + return null; +}; + +// Register the built-in cloud connector handler +policyUpdateHandlers.push(updateCloudConnectorPolicy); + +/** + * Register a custom policy effect handler. + * Handlers are called in order of registration. + */ +export function registerPolicyUpdateHandler(handler: PolicyUpdateHandler): void { + policyUpdateHandlers.push(handler); +} + +/** + * Compute all policy effects based on the current var_group selections. + * Aggregates results from all registered handlers (e.g., setting + * supports_cloud_connector and supports_cloud_connectors var). + */ +export function buildVarGroupPolicyUpdates( + packagePolicy: NewPackagePolicy, + varGroupSelections: VarGroupSelection, + varGroups: RegistryVarGroup[] | undefined +): Partial | null { + if (!varGroups || varGroups.length === 0) { + return null; + } + + let combinedEffects: Partial = {}; + let hasEffects = false; + + for (const handler of policyUpdateHandlers) { + const effects = handler(packagePolicy, varGroupSelections, varGroups); + if (effects) { + combinedEffects = { ...combinedEffects, ...effects }; + hasEffects = true; + } + } + + return hasEffects ? combinedEffects : null; +} diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.test.tsx index 417f9e2a7c336..962ecdb2d916f 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.test.tsx @@ -13,6 +13,7 @@ import { createFleetTestRendererMock } from '../../../../../../../mock'; import type { PackageInfo } from '../../../../../types'; import { sendGetPackagePolicies, useConfig } from '../../../../../hooks'; +import { ExperimentalFeaturesService } from '../../../../../services'; import { SelectedPolicyTab } from '../../components'; @@ -228,6 +229,9 @@ describe('useOnSubmit', () => { describe('input deployment mode filtering', () => { beforeEach(() => { jest.clearAllMocks(); + jest.spyOn(ExperimentalFeaturesService, 'get').mockReturnValue({ + enableVarGroups: true, + } as any); }); it('should disable inputs that are not allowed for agentless deployment mode', async () => { @@ -409,6 +413,69 @@ describe('useOnSubmit', () => { }); }); + it('should disable inputs hidden by var_group selection (same pattern as deployment mode)', async () => { + const packageInfoWithVarGroups = { + ...packageInfo, + var_groups: [ + { + name: 'credential_type', + title: 'Credential', + selector_title: 'Select credential', + required: true, + options: [ + { name: 'cloud_connectors', title: 'Cloud Connector', vars: [] }, + { name: 'direct_access_key', title: 'Direct', vars: [] }, + ], + }, + ], + policy_templates: [ + { + name: 'guardduty', + title: 'GuardDuty', + description: 'GuardDuty', + inputs: [ + { + type: 'guardduty', + title: 'GuardDuty', + description: 'GuardDuty input', + hide_in_var_group_options: { credential_type: ['cloud_connectors'] }, + }, + ], + }, + ], + } as unknown as PackageInfo; + + renderResult = testRenderer.renderHook(() => + useOnSubmit({ + agentCount: 0, + packageInfo: packageInfoWithVarGroups, + withSysMonitoring: false, + selectedPolicyTab: SelectedPolicyTab.NEW, + newAgentPolicy: { name: 'test', namespace: '' }, + queryParamsPolicyId: undefined, + hasFleetAddAgentsPrivileges: true, + setNewAgentPolicy: jest.fn(), + setSelectedPolicyTab: jest.fn(), + }) + ); + + await waitFor(() => new Promise((resolve) => resolve(null))); + + act(() => { + renderResult.result.current.updatePackagePolicy({ + var_group_selections: { credential_type: 'cloud_connectors' }, + }); + }); + + await waitFor(() => { + const { packagePolicy } = renderResult.result.current; + const guarddutyInput = packagePolicy.inputs?.find( + (input: any) => input.type === 'guardduty' + ); + expect(guarddutyInput?.enabled).toBe(false); + }); + }); + it('should enable all inputs for default deployment mode', async () => { // Mock packageInfo with inputs const packageInfoWithInputs: PackageInfo = { @@ -694,7 +761,7 @@ describe('useOnSubmit', () => { }); }); - it('should clear cloud_connectors and set supports_cloud_connector to false for gcp', () => { + it('should update cloud_connectors with target_csp gcp and set supports_cloud_connector to false for gcp', () => { const setNewAgentPolicy = jest.fn(); const setPackagePolicy = jest.fn(); @@ -736,7 +803,10 @@ describe('useOnSubmit', () => { ...newAgentPolicy, agentless: { ...newAgentPolicy.agentless, - cloud_connectors: undefined, + cloud_connectors: { + enabled: false, + target_csp: 'gcp', + }, }, }); expect(setPackagePolicy).toHaveBeenCalledWith({ diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx index 02c0c0fb6f4f6..7bcdfc2a74190 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/form.tsx @@ -46,11 +46,12 @@ import { useFleetStatus, sendCreatePackagePolicyForRq, } from '../../../../../hooks'; -import { isVerificationError, packageToPackagePolicy } from '../../../../../services'; -import type { - CreatePackagePolicyResponse, - NewPackagePolicyInput, -} from '../../../../../../../../common'; +import { + isVerificationError, + packageToPackagePolicy, + ExperimentalFeaturesService, +} from '../../../../../services'; +import type { CreatePackagePolicyResponse } from '../../../../../../../../common'; import { FLEET_ELASTIC_AGENT_PACKAGE, FLEET_SYSTEM_PACKAGE, @@ -60,9 +61,15 @@ import { import { getMaxPackageName } from '../../../../../../../../common/services'; import { isInputAllowedForDeploymentMode } from '../../../../../../../../common/services/agentless_policy_helper'; import { useConfirmForceInstall } from '../../../../../../integrations/hooks'; -import { validatePackagePolicy, validationHasErrors } from '../../services'; +import { detectTargetCsp } from '../../../../../../../../common/services/cloud_connectors'; +import { + validatePackagePolicy, + validationHasErrors, + isInputVisibleForVarGroupSelections, +} from '../../services'; import type { PackagePolicyValidationResults } from '../../services'; import type { PackagePolicyFormState } from '../../types'; +import type { RegistryVarGroup } from '../../../../../types'; import { SelectedPolicyTab } from '../../components'; import { useOnSaveNavigate } from '../../hooks'; import { prepareInputPackagePolicyDataset } from '../../services/prepare_input_pkg_policy_dataset'; @@ -145,7 +152,10 @@ export const createAgentPolicyIfNeeded = async ({ } }; -async function savePackagePolicy(pkgPolicy: CreatePackagePolicyRequest['body']) { +async function savePackagePolicy( + pkgPolicy: CreatePackagePolicyRequest['body'], + varGroups?: RegistryVarGroup[] +) { const { policy, forceCreateNeeded } = await prepareInputPackagePolicyDataset(pkgPolicy); // If agentless use agentless policies API @@ -154,6 +164,9 @@ async function savePackagePolicy(pkgPolicy: CreatePackagePolicyRequest['body']) return omit(pkg, 'title'); } + // Detect target cloud provider from var_groups or inputs + const targetCsp = detectTargetCsp(pkgPolicy as NewPackagePolicy, varGroups); + const agentlessRequestBody = { package: formatPackage(pkgPolicy.package), ...omit( @@ -176,6 +189,8 @@ async function savePackagePolicy(pkgPolicy: CreatePackagePolicyRequest['body']) ...(pkgPolicy.supports_cloud_connector && { cloud_connector: { enabled: true, + // Include target_csp if detected (required for var_groups packages) + ...(targetCsp && { target_csp: targetCsp }), ...(pkgPolicy.cloud_connector_id && { cloud_connector_id: pkgPolicy.cloud_connector_id, }), @@ -202,19 +217,18 @@ async function savePackagePolicy(pkgPolicy: CreatePackagePolicyRequest['body']) return result; } + // Update the agentless policy with cloud connector info in the new agent policy when the package policy input `aws.support_cloud_connectors is updated export const updateAgentlessCloudConnectorConfig = ( packagePolicy: NewPackagePolicy, newAgentPolicy: NewAgentPolicy, setNewAgentPolicy: (policy: NewAgentPolicy) => void, - setPackagePolicy: (policy: NewPackagePolicy) => void + setPackagePolicy: (policy: NewPackagePolicy) => void, + varGroups?: RegistryVarGroup[] ) => { - const input = packagePolicy.inputs?.find( - (pinput: NewPackagePolicyInput) => pinput.enabled === true - ); - const targetCsp = input?.type.match(/aws|azure/)?.[0]; + const targetCsp = detectTargetCsp(packagePolicy, varGroups); - // Making sure that the cloud connector is disabled when switching to GCP + // Making sure that the cloud connector is disabled when switching to GCP or unsupported provider if ( !targetCsp && (newAgentPolicy.agentless?.cloud_connectors || packagePolicy.supports_cloud_connector) @@ -304,6 +318,9 @@ export function useOnSubmit({ const confirmForceInstall = useConfirmForceInstall(); const spaceSettings = useSpaceSettingsContext(); const { canUseMultipleAgentPolicies } = useMultipleAgentPolicies(); + const { enableVarGroups } = ExperimentalFeaturesService.get(); + const varGroups = + enableVarGroups && packageInfo?.var_groups ? packageInfo?.var_groups : undefined; // only used to store the resulting package policy once saved const [savedPackagePolicy, setSavedPackagePolicy] = useState(); @@ -509,34 +526,62 @@ export function useOnSubmit({ selectedSetupTechnology === SetupTechnology.AGENTLESS; const newInputs = useMemo(() => { - return packagePolicy.inputs.map((input, i) => { - if ( - isInputAllowedForDeploymentMode( - input, - isAgentlessSelected ? 'agentless' : 'default', - packageInfo - ) - ) { + const varGroupSelections = packagePolicy.var_group_selections ?? {}; + return packagePolicy.inputs.map((input) => { + const allowedForDeploymentMode = isInputAllowedForDeploymentMode( + input, + isAgentlessSelected ? 'agentless' : 'default', + packageInfo + ); + const visibleForVarGroup = + !enableVarGroups || + isInputVisibleForVarGroupSelections(input, packageInfo, varGroupSelections); + if (allowedForDeploymentMode && visibleForVarGroup) { return input; - } else { - return { ...input, enabled: false }; } + return { ...input, enabled: false }; }); - }, [packagePolicy.inputs, isAgentlessSelected, packageInfo]); + }, [ + packagePolicy.inputs, + packagePolicy.var_group_selections, + isAgentlessSelected, + packageInfo, + enableVarGroups, + ]); + + // Compare current vs desired input enabled states so the effect below only fires + // when a var_group selection actually hides or reveals an input, preventing + // infinite update loops from new array references. + const inputsEnablingDiffer = useMemo(() => { + if (packagePolicy.inputs.length !== newInputs.length) return true; + return packagePolicy.inputs.some((input, i) => input.enabled !== newInputs[i]?.enabled); + }, [packagePolicy.inputs, newInputs]); useEffect(() => { - if (prevSetupTechnology !== selectedSetupTechnology) { + const shouldApplyInputs = + prevSetupTechnology !== selectedSetupTechnology || + (varGroups?.length && inputsEnablingDiffer); + if (shouldApplyInputs) { updatePackagePolicy({ inputs: newInputs, }); } - }, [newInputs, prevSetupTechnology, selectedSetupTechnology, updatePackagePolicy, packagePolicy]); + }, [ + newInputs, + prevSetupTechnology, + selectedSetupTechnology, + updatePackagePolicy, + packagePolicy, + inputsEnablingDiffer, + varGroups?.length, + ]); updateAgentlessCloudConnectorConfig( packagePolicy, newAgentPolicy, setNewAgentPolicy, - setPackagePolicy + setPackagePolicy, + varGroups ); const onSaveNavigate = useOnSaveNavigate({ @@ -679,11 +724,14 @@ export function useOnSubmit({ setFormState('LOADING'); try { // passing pkgPolicy with policy_id here as setPackagePolicy doesn't propagate immediately - const data = await savePackagePolicy({ - ...packagePolicy, - policy_ids: agentPolicyIdToSave, - force: forceInstall, - }); + const data = await savePackagePolicy( + { + ...packagePolicy, + policy_ids: agentPolicyIdToSave, + force: forceInstall, + }, + varGroups + ); if (data?.item.package) { await ensurePackageKibanaAssetsInstalled({ @@ -794,23 +842,24 @@ export function useOnSubmit({ }, [ formState, - spaceId, hasErrors, agentCount, + agentPolicies, + selectedPolicyTab, getAgentlessStatusForPackage, packageInfo, - selectedPolicyTab, - packagePolicy, isAgentlessAgentPolicy, - hasFleetAddAgentsPrivileges, - withSysMonitoring, + packagePolicy, newAgentPolicy, + withSysMonitoring, updatePackagePolicy, notifications.toasts, - agentPolicies, + docLinks.links.fleet.agentlessIntegrations, + varGroups, + hasFleetAddAgentsPrivileges, + spaceId, onSaveNavigate, confirmForceInstall, - docLinks.links.fleet.agentlessIntegrations, ] ); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx index f45ad479595e5..486314b87091e 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx @@ -65,7 +65,7 @@ import { ConfirmDeployAgentPolicyModal, IncompatibleAgentVersionCallout, } from '../../components'; -import { pkgKeyFromPackageInfo } from '../../../../services'; +import { pkgKeyFromPackageInfo, ExperimentalFeaturesService } from '../../../../services'; import type { CreatePackagePolicyParams } from '../types'; @@ -258,12 +258,15 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ // Derive var_group_selections from policy for StepConfigurePackagePolicy // Note: StepDefinePackagePolicy handles its own initialization and state management + const { enableVarGroups } = ExperimentalFeaturesService.get(); + const varGroups = + enableVarGroups && packageInfo?.var_groups ? packageInfo?.var_groups : undefined; const varGroupSelections = useMemo((): VarGroupSelection => { if (packagePolicy.var_group_selections) { return packagePolicy.var_group_selections; } - return computeDefaultVarGroupSelections(packageInfo?.var_groups, isAgentlessSelected); - }, [packagePolicy.var_group_selections, packageInfo?.var_groups, isAgentlessSelected]); + return computeDefaultVarGroupSelections(varGroups, isAgentlessSelected); + }, [packagePolicy.var_group_selections, varGroups, isAgentlessSelected]); const updateNewAgentPolicy = useCallback( (updatedFields: Partial) => { diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx index c9f9d16c2d588..342771607446e 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.test.tsx @@ -28,6 +28,8 @@ import { } from '../../../hooks'; import { useGetOnePackagePolicy } from '../../../../integrations/hooks'; +import { ExperimentalFeaturesService } from '../../../services'; + import { EditPackagePolicyPage } from '.'; type MockFn = jest.MockedFunction; @@ -249,6 +251,9 @@ describe('edit package policy page', () => { (renderResult = testRenderer.render(, { legacyRoot: true })); beforeEach(() => { + jest.spyOn(ExperimentalFeaturesService, 'get').mockReturnValue({ + enableVarGroups: true, + } as any); testRenderer = createFleetTestRendererMock(); lastStepConfigureProps = undefined; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index f098a7bd766b6..b9a7d508240a8 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -52,7 +52,7 @@ import { type VarGroupSelection, } from '../create_package_policy_page/services'; import type { AgentPolicy, PackagePolicyEditExtensionComponentProps } from '../../../types'; -import { pkgKeyFromPackageInfo } from '../../../services'; +import { pkgKeyFromPackageInfo, ExperimentalFeaturesService } from '../../../services'; import { getInheritedNamespace, @@ -153,12 +153,15 @@ export const EditPackagePolicyForm = memo<{ ); // Derive var_group_selections from policy for edit mode + const { enableVarGroups } = ExperimentalFeaturesService.get(); + const varGroups = + enableVarGroups && packageInfo?.var_groups ? packageInfo?.var_groups : undefined; const varGroupSelections = useMemo((): VarGroupSelection => { if (packagePolicy.var_group_selections) { return packagePolicy.var_group_selections; } - return computeDefaultVarGroupSelections(packageInfo?.var_groups, hasAgentlessAgentPolicy); - }, [packagePolicy.var_group_selections, packageInfo?.var_groups, hasAgentlessAgentPolicy]); + return computeDefaultVarGroupSelections(varGroups, hasAgentlessAgentPolicy); + }, [packagePolicy.var_group_selections, varGroups, hasAgentlessAgentPolicy]); const canWriteIntegrationPolicies = useAuthz().integrations.writeIntegrationPolicies; useSetIsReadOnly(!canWriteIntegrationPolicies); diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_cloud_connector/aws_cloud_connector_form.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_cloud_connector/aws_cloud_connector_form.tsx index ca105d64b77e2..f76cc4b4939d6 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_cloud_connector/aws_cloud_connector_form.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_cloud_connector/aws_cloud_connector_form.tsx @@ -10,17 +10,18 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { EuiAccordion, EuiSpacer, EuiButton, EuiLink } from '@elastic/eui'; import { CLOUD_CONNECTOR_NAME_INPUT_TEST_SUBJ } from '../../../../common/services/cloud_connectors/test_subjects'; -import { extractRawCredentialVars } from '../../../../common'; +import { + extractRawCredentialVars, + getCredentialKeyFromVarName, +} from '../../../../common/services/cloud_connectors'; import { type CloudConnectorFormProps } from '../types'; import { - updatePolicyWithAwsCloudConnectorCredentials, getCloudConnectorRemoteRoleTemplate, updateInputVarsWithCredentials, isAwsCredentials, - type AwsCloudConnectorFieldNames, } from '../utils'; -import { AWS_CLOUD_CONNECTOR_FIELD_NAMES, AWS_PROVIDER } from '../constants'; +import { ORGANIZATION_ACCOUNT } from '../constants'; import { CloudConnectorInputFields } from '../form/cloud_connector_input_fields'; import { CloudConnectorNameField } from '../form/cloud_connector_name_field'; @@ -29,27 +30,22 @@ import { getAwsCloudConnectorsCredentialsFormOptions } from './aws_cloud_connect import { CloudFormationCloudCredentialsGuide } from './aws_cloud_formation_guide'; export const AWSCloudConnectorForm: React.FC = ({ - input, newPolicy, packageInfo, cloud, - updatePolicy, hasInvalidRequiredVars = false, - isOrganization = false, - templateName, credentials, setCredentials, + accountType = ORGANIZATION_ACCOUNT, + iacTemplateUrl, }) => { - const cloudConnectorRemoteRoleTemplate = - cloud && templateName - ? getCloudConnectorRemoteRoleTemplate({ - input, - cloud, - packageInfo, - templateName, - provider: AWS_PROVIDER, - }) - : undefined; + const cloudConnectorRemoteRoleTemplate = cloud + ? getCloudConnectorRemoteRoleTemplate({ + cloud, + accountType, + iacTemplateUrl, + }) + : undefined; // Use accessor to get vars from the correct location (package-level or input-level) const inputVars = extractRawCredentialVars(newPolicy, packageInfo); @@ -82,7 +78,7 @@ export const AWSCloudConnectorForm: React.FC = ({ buttonContent={{'Steps to assume role'}} paddingSize="l" > - + = ({ fields={fields} packageInfo={packageInfo} onChange={(key, value) => { - // Update local credentials state if available - if (credentials && isAwsCredentials(credentials) && setCredentials) { - const updatedCredentials = { ...credentials }; - if ( - key === AWS_CLOUD_CONNECTOR_FIELD_NAMES.ROLE_ARN || - key === AWS_CLOUD_CONNECTOR_FIELD_NAMES.AWS_ROLE_ARN - ) { - updatedCredentials.roleArn = value; - } else if ( - key === AWS_CLOUD_CONNECTOR_FIELD_NAMES.EXTERNAL_ID || - key === AWS_CLOUD_CONNECTOR_FIELD_NAMES.AWS_EXTERNAL_ID - ) { - updatedCredentials.externalId = value; - } - setCredentials(updatedCredentials); - } else { - // Fallback to old method - updatePolicy({ - updatedPolicy: updatePolicyWithAwsCloudConnectorCredentials(newPolicy, input, { - [key]: value, - } as Record), - }); + if (!credentials || !isAwsCredentials(credentials) || !setCredentials) return; + + // Use schema-based lookup to map var names to credential properties + const credentialKey = getCredentialKeyFromVarName('aws', key); + if (credentialKey) { + setCredentials({ ...credentials, [credentialKey]: value }); } }} hasInvalidRequiredVars={hasInvalidRequiredVars} diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_cloud_connector/aws_cloud_formation_guide.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_cloud_connector/aws_cloud_formation_guide.tsx index bcf94992b63eb..60945c2bc5e48 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_cloud_connector/aws_cloud_formation_guide.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_cloud_connector/aws_cloud_formation_guide.tsx @@ -9,13 +9,18 @@ import React from 'react'; import { EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { AccountType } from '../../../types'; +import { ORGANIZATION_ACCOUNT } from '../constants'; + export interface CloudFormationCloudCredentialsGuideProps { - isOrganization?: boolean; + accountType?: AccountType; } export const CloudFormationCloudCredentialsGuide: React.FC< CloudFormationCloudCredentialsGuideProps -> = ({ isOrganization = false }) => { +> = ({ accountType = ORGANIZATION_ACCOUNT }) => { + const isOrganization = accountType === ORGANIZATION_ACCOUNT; + return (
diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/azure_cloud_connector/azure_cloud_connector_form.test.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/azure_cloud_connector/azure_cloud_connector_form.test.tsx index 42819c54a635c..7d4b1abd39f08 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/azure_cloud_connector/azure_cloud_connector_form.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/azure_cloud_connector/azure_cloud_connector_form.test.tsx @@ -157,7 +157,6 @@ describe('AzureCloudConnectorForm', () => { }; const defaultProps: CloudConnectorFormProps = { - input: createMockInput(), newPolicy: createMockPolicy(), packageInfo: createMockPackageInfo(), updatePolicy: mockUpdatePolicy, @@ -211,8 +210,8 @@ describe('AzureCloudConnectorForm', () => { ).not.toBeInTheDocument(); }); - it('should not render Deploy to Azure button when templateName is undefined', () => { - renderComponent({ templateName: undefined }); + it('should not render Deploy to Azure button when iacTemplateUrl is undefined', () => { + renderComponent({ iacTemplateUrl: undefined }); expect( screen.queryByTestId(AZURE_LAUNCH_CLOUD_CONNECTOR_ARM_TEMPLATE_TEST_SUBJ) diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/azure_cloud_connector/azure_cloud_connector_form.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/azure_cloud_connector/azure_cloud_connector_form.tsx index d42793714c2cd..ab0c7d57d012c 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/azure_cloud_connector/azure_cloud_connector_form.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/azure_cloud_connector/azure_cloud_connector_form.tsx @@ -14,19 +14,21 @@ import { AZURE_LAUNCH_CLOUD_CONNECTOR_ARM_TEMPLATE_TEST_SUBJ, CLOUD_CONNECTOR_NAME_INPUT_TEST_SUBJ, } from '../../../../common/services/cloud_connectors/test_subjects'; -import { extractRawCredentialVars } from '../../../../common'; +import { + extractRawCredentialVars, + getCredentialKeyFromVarName, + writeCredentials, +} from '../../../../common/services/cloud_connectors'; import type { CloudConnectorFormProps, CloudSetupForCloudConnector } from '../types'; import { - type AzureCloudConnectorFieldNames, getCloudConnectorRemoteRoleTemplate, isAzureCredentials, updateInputVarsWithCredentials, - updatePolicyWithAzureCloudConnectorCredentials, getDeploymentIdFromUrl, getKibanaComponentId, } from '../utils'; -import { AZURE_CLOUD_CONNECTOR_FIELD_NAMES, AZURE_PROVIDER } from '../constants'; +import { ORGANIZATION_ACCOUNT } from '../constants'; import { CloudConnectorInputFields } from '../form/cloud_connector_input_fields'; import { CloudConnectorNameField } from '../form/cloud_connector_name_field'; @@ -52,26 +54,23 @@ const getElasticStackId = (cloud?: CloudSetupForCloudConnector): string | undefi }; export const AzureCloudConnectorForm: React.FC = ({ - input, newPolicy, packageInfo, updatePolicy, cloud, hasInvalidRequiredVars = false, - templateName = 'azure-cloud-connector-template', credentials, setCredentials, + accountType = ORGANIZATION_ACCOUNT, + iacTemplateUrl, }) => { - const armTemplateUrl = - cloud && templateName - ? getCloudConnectorRemoteRoleTemplate({ - input, - cloud, - packageInfo, - templateName, - provider: AZURE_PROVIDER, - }) - : undefined; + const armTemplateUrl = cloud + ? getCloudConnectorRemoteRoleTemplate({ + cloud, + accountType, + iacTemplateUrl, + }) + : undefined; const elasticStackId = getElasticStackId(cloud); @@ -133,38 +132,24 @@ export const AzureCloudConnectorForm: React.FC = ({ fields={fields} packageInfo={packageInfo} onChange={(key, value) => { - // Update local credentials state if available - if (credentials && isAzureCredentials(credentials) && setCredentials) { - const updatedCredentials = { ...credentials }; - if ( - key === AZURE_CLOUD_CONNECTOR_FIELD_NAMES.TENANT_ID || - key === AZURE_CLOUD_CONNECTOR_FIELD_NAMES.AZURE_TENANT_ID - ) { - updatedCredentials.tenantId = value; - } else if ( - key === AZURE_CLOUD_CONNECTOR_FIELD_NAMES.CLIENT_ID || - key === AZURE_CLOUD_CONNECTOR_FIELD_NAMES.AZURE_CLIENT_ID - ) { - updatedCredentials.clientId = value; - } else if ( - key === AZURE_CLOUD_CONNECTOR_FIELD_NAMES.AZURE_CREDENTIALS_CLOUD_CONNECTOR_ID - ) { - updatedCredentials.azure_credentials_cloud_connector_id = value; - } - setCredentials(updatedCredentials); - } else { - // Fallback to old method - const updatedPolicyWithCredentials = updatePolicyWithAzureCloudConnectorCredentials( + // Use schema-based lookup to map var names to credential properties + const credentialKey = getCredentialKeyFromVarName('azure', key); + + // If we have credentials and setCredentials, update via credentials state + if (credentials && isAzureCredentials(credentials) && setCredentials && credentialKey) { + setCredentials({ ...credentials, [credentialKey]: value }); + return; + } + + // Fallback: update policy directly when credentials or setCredentials is unavailable + if (credentialKey) { + const updatedPolicy = writeCredentials( newPolicy, - input, - { - [key]: value, - } as Record + { [credentialKey]: value }, + 'azure', + packageInfo ); - - updatePolicy({ - updatedPolicy: updatedPolicyWithCredentials, - }); + updatePolicy({ updatedPolicy }); } }} hasInvalidRequiredVars={hasInvalidRequiredVars} diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/cloud_connector_policies_flyout/index.test.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/cloud_connector_policies_flyout/index.test.tsx index 8c98e09ffd4cd..f37766cbaa4f7 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/cloud_connector_policies_flyout/index.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/cloud_connector_policies_flyout/index.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { I18nProvider } from '@kbn/i18n-react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; @@ -194,20 +194,23 @@ describe('CloudConnectorPoliciesFlyout', () => { }); it('should enable save button when name is changed', async () => { - const user = userEvent.setup(); renderFlyout(); - const nameInput = screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.NAME_INPUT); + const nameInput = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.NAME_INPUT + ) as HTMLInputElement; const saveButton = screen.getByTestId( CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.FOOTER_SAVE_BUTTON ); expect(saveButton).toBeDisabled(); - await user.clear(nameInput); - await user.type(nameInput, 'New Name'); + // Use fireEvent.change for controlled inputs - more reliable than userEvent + fireEvent.change(nameInput, { target: { value: 'New Name' } }); - expect(saveButton).toBeEnabled(); + await waitFor(() => { + expect(saveButton).toBeEnabled(); + }); }); it('should call mutate when save button is clicked', async () => { @@ -220,13 +223,15 @@ describe('CloudConnectorPoliciesFlyout', () => { renderFlyout(); - const nameInput = screen.getByTestId(CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.NAME_INPUT); + const nameInput = screen.getByTestId( + CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.NAME_INPUT + ) as HTMLInputElement; const saveButton = screen.getByTestId( CLOUD_CONNECTOR_POLICIES_FLYOUT_TEST_SUBJECTS.FOOTER_SAVE_BUTTON ); - await user.clear(nameInput); - await user.type(nameInput, 'New Name'); + // Use fireEvent.change for controlled inputs - more reliable than userEvent + fireEvent.change(nameInput, { target: { value: 'New Name' } }); await user.click(saveButton); expect(mockMutate).toHaveBeenCalledWith({ name: 'New Name' }); diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/cloud_connector_setup.test.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/cloud_connector_setup.test.tsx index b40c6fe2fc92c..e1f8d60498fb3 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/cloud_connector_setup.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/cloud_connector_setup.test.tsx @@ -20,7 +20,7 @@ import { CloudConnectorTabs } from './cloud_connector_tabs'; import { isCloudConnectorReusableEnabled } from './utils'; import { CloudConnectorSetup, type CloudConnectorSetupProps } from './cloud_connector_setup'; import { NewCloudConnectorForm } from './form/new_cloud_connector_form'; -import { AWS_PROVIDER } from './constants'; +import { AWS_PROVIDER, ORGANIZATION_ACCOUNT, SINGLE_ACCOUNT } from './constants'; // Mock child components jest.mock('./form/new_cloud_connector_form', () => ({ @@ -70,7 +70,6 @@ const mockUpdatePolicyWithExistingCredentials = jest.fn(); const mockUpdatePolicy = jest.fn(); describe('CloudConnectorSetup', () => { - const mockInput = getMockPolicyAWS().inputs[0]; const mockPackageInfo = getMockPackageInfoAWS(); const mockPolicy = getMockPolicyAWS(); @@ -86,7 +85,6 @@ describe('CloudConnectorSetup', () => { } as CloudSetup; const defaultProps: CloudConnectorSetupProps = { - input: mockInput, newPolicy: mockPolicy, updatePolicy: mockUpdatePolicy, packageInfo: mockPackageInfo, @@ -141,7 +139,6 @@ describe('CloudConnectorSetup', () => { setExistingConnectionCredentials: mockSetExistingConnectionCredentials, updatePolicyWithNewCredentials: mockUpdatePolicyWithNewCredentials, updatePolicyWithExistingCredentials: mockUpdatePolicyWithExistingCredentials, - accountTypeFromInputs: undefined, }); }; @@ -356,54 +353,36 @@ describe('CloudConnectorSetup', () => { ); }); - it('should call useGetCloudConnectors hook with correct filter options', () => { + it('should call useGetCloudConnectors hook with default single account type', () => { setupMocks([]); renderComponent(); expect(mockUseGetCloudConnectors).toHaveBeenCalledWith({ cloudProvider: AWS_PROVIDER, - accountType: undefined, + accountType: SINGLE_ACCOUNT, }); }); - it('should call useGetCloudConnectors hook with single-account filter', () => { - mockUseGetCloudConnectors.mockReturnValue(createMockQueryResult([])); - mockUseCloudConnectorSetup.mockReturnValue({ - newConnectionCredentials: {}, - setNewConnectionCredentials: mockSetNewConnectionCredentials, - existingConnectionCredentials: {}, - setExistingConnectionCredentials: mockSetExistingConnectionCredentials, - updatePolicyWithNewCredentials: mockUpdatePolicyWithNewCredentials, - updatePolicyWithExistingCredentials: mockUpdatePolicyWithExistingCredentials, - accountTypeFromInputs: 'single-account', - }); + it('should call useGetCloudConnectors hook with single-account when passed as prop', () => { + setupMocks([]); - renderComponent(); + renderComponent({ accountType: SINGLE_ACCOUNT }); expect(mockUseGetCloudConnectors).toHaveBeenCalledWith({ cloudProvider: AWS_PROVIDER, - accountType: 'single-account', + accountType: SINGLE_ACCOUNT, }); }); - it('should call useGetCloudConnectors hook with organization-account filter', () => { - mockUseGetCloudConnectors.mockReturnValue(createMockQueryResult([])); - mockUseCloudConnectorSetup.mockReturnValue({ - newConnectionCredentials: {}, - setNewConnectionCredentials: mockSetNewConnectionCredentials, - existingConnectionCredentials: {}, - setExistingConnectionCredentials: mockSetExistingConnectionCredentials, - updatePolicyWithNewCredentials: mockUpdatePolicyWithNewCredentials, - updatePolicyWithExistingCredentials: mockUpdatePolicyWithExistingCredentials, - accountTypeFromInputs: 'organization-account', - }); + it('should call useGetCloudConnectors hook with organization-account when passed as prop', () => { + setupMocks([]); - renderComponent(); + renderComponent({ accountType: ORGANIZATION_ACCOUNT }); expect(mockUseGetCloudConnectors).toHaveBeenCalledWith({ cloudProvider: AWS_PROVIDER, - accountType: 'organization-account', + accountType: ORGANIZATION_ACCOUNT, }); }); @@ -559,7 +538,7 @@ describe('CloudConnectorSetup', () => { expect(mockUseGetCloudConnectors).toHaveBeenCalledWith({ cloudProvider: AZURE_PROVIDER, - accountType: undefined, + accountType: SINGLE_ACCOUNT, }); }); @@ -636,7 +615,7 @@ describe('CloudConnectorSetup', () => { expect(mockUseGetCloudConnectors).toHaveBeenCalledWith({ cloudProvider: AWS_PROVIDER, - accountType: undefined, + accountType: SINGLE_ACCOUNT, }); }); @@ -681,6 +660,7 @@ describe('CloudConnectorSetup', () => { renderComponent({ newPolicy: mockPolicyWithSupport }); + // When supports_cloud_connector is already true, updatePolicy should not be called expect(mockUpdatePolicy).not.toHaveBeenCalled(); }); diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/cloud_connector_setup.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/cloud_connector_setup.tsx index 49618ebdb5bf6..58c5835ed9023 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/cloud_connector_setup.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/cloud_connector_setup.tsx @@ -10,8 +10,8 @@ import { EuiSpacer, EuiText, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import type { CloudSetup } from '@kbn/cloud-plugin/public'; -import type { NewPackagePolicy, NewPackagePolicyInput, PackageInfo } from '../../../common'; -import type { CloudProvider } from '../../types'; +import type { NewPackagePolicy, PackageInfo } from '../../../common'; +import type { AccountType, CloudProvider } from '../../types'; import { NewCloudConnectorForm } from './form/new_cloud_connector_form'; import { ReusableCloudConnectorForm } from './form/reusable_cloud_connector_form'; @@ -19,10 +19,10 @@ import { useGetCloudConnectors } from './hooks/use_get_cloud_connectors'; import { useCloudConnectorSetup } from './hooks/use_cloud_connector_setup'; import { CloudConnectorTabs, type CloudConnectorTab } from './cloud_connector_tabs'; import type { UpdatePolicy } from './types'; -import { TABS, CLOUD_FORMATION_EXTERNAL_DOC_URL } from './constants'; +import { TABS, CLOUD_FORMATION_EXTERNAL_DOC_URL, SINGLE_ACCOUNT } from './constants'; import { isCloudConnectorReusableEnabled } from './utils'; + export interface CloudConnectorSetupProps { - input: NewPackagePolicyInput; newPolicy: NewPackagePolicy; packageInfo: PackageInfo; updatePolicy: UpdatePolicy; @@ -31,10 +31,13 @@ export interface CloudConnectorSetupProps { cloud?: CloudSetup; cloudProvider?: CloudProvider; templateName: string; + /** Optional account type. When undefined, defaults to 'single-account'. */ + accountType?: AccountType; + /** Optional IaC template URL from var_group selection. When provided, overrides template URL from packageInfo.policy_templates. */ + iacTemplateUrl?: string; } export const CloudConnectorSetup: React.FC = ({ - input, newPolicy, packageInfo, updatePolicy, @@ -43,6 +46,8 @@ export const CloudConnectorSetup: React.FC = ({ cloud, cloudProvider, templateName, + accountType = SINGLE_ACCOUNT, + iacTemplateUrl, }) => { const reusableFeatureEnabled = isCloudConnectorReusableEnabled( cloudProvider || '', @@ -50,19 +55,16 @@ export const CloudConnectorSetup: React.FC = ({ templateName ); - // Use the cloud connector setup hook const { newConnectionCredentials, existingConnectionCredentials, updatePolicyWithNewCredentials, updatePolicyWithExistingCredentials, - accountTypeFromInputs, } = useCloudConnectorSetup(newPolicy, updatePolicy, packageInfo, cloudProvider); - // Get filtered cloud connectors based on provider and account type const { data: cloudConnectors } = useGetCloudConnectors({ cloudProvider, - accountType: accountTypeFromInputs, + accountType, }); const cloudConnectorsCount = cloudConnectors?.length; const [selectedTabId, setSelectedTabId] = useState(TABS.NEW_CONNECTION); @@ -76,14 +78,17 @@ export const CloudConnectorSetup: React.FC = ({ }, [cloudConnectorsCount]); // Ensure root-level supports_cloud_connector is true when this component is rendered - if (!newPolicy.supports_cloud_connector) { - updatePolicy({ - updatedPolicy: { - ...newPolicy, - supports_cloud_connector: true, - }, - }); - } + // NOTE: This must be in a useEffect, NOT during render, to avoid React errors + useEffect(() => { + if (!newPolicy.supports_cloud_connector) { + updatePolicy({ + updatedPolicy: { + ...newPolicy, + supports_cloud_connector: true, + }, + }); + } + }, [newPolicy, updatePolicy]); const tabs: CloudConnectorTab[] = [ { @@ -122,8 +127,6 @@ export const CloudConnectorSetup: React.FC = ({
= ({ cloudProvider={cloudProvider} credentials={newConnectionCredentials} setCredentials={updatePolicyWithNewCredentials} + accountType={accountType} + iacTemplateUrl={iacTemplateUrl} /> ), @@ -152,7 +157,7 @@ export const CloudConnectorSetup: React.FC = ({ cloudProvider={cloudProvider} credentials={existingConnectionCredentials} setCredentials={updatePolicyWithExistingCredentials} - accountType={accountTypeFromInputs} + accountType={accountType} /> ), }, @@ -181,8 +186,6 @@ export const CloudConnectorSetup: React.FC = ({ <> {!reusableFeatureEnabled && ( = ({ cloudProvider={cloudProvider} credentials={newConnectionCredentials} setCredentials={updatePolicyWithNewCredentials} + accountType={accountType} + iacTemplateUrl={iacTemplateUrl} /> )} {reusableFeatureEnabled && ( diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/constants.ts b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/constants.ts index 50a545d566c3c..3835e3e890f57 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/constants.ts +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/constants.ts @@ -36,6 +36,8 @@ export const AWS_CLOUD_CONNECTOR_FIELD_NAMES = { AWS_EXTERNAL_ID: 'aws.credentials.external_id', } as const; +export const SUPPORTS_CLOUD_CONNECTORS_VAR_NAME = 'supports_cloud_connectors'; + export const AZURE_CLOUD_CONNECTOR_FIELD_NAMES = { TENANT_ID: 'tenant_id', CLIENT_ID: 'client_id', diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/form/new_cloud_connector_form.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/form/new_cloud_connector_form.tsx index d22170a846904..4f3c866de6aed 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/form/new_cloud_connector_form.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/form/new_cloud_connector_form.tsx @@ -13,17 +13,17 @@ import { AzureCloudConnectorForm } from '../azure_cloud_connector/azure_cloud_co import { AWS_PROVIDER, AZURE_PROVIDER } from '../constants'; export const NewCloudConnectorForm: React.FC = ({ - input, newPolicy, packageInfo, updatePolicy, isEditPage = false, cloud, cloudProvider, - templateName, credentials, setCredentials, hasInvalidRequiredVars, + accountType, + iacTemplateUrl, }) => { // Default to AWS if no cloudProvider is specified const provider = cloudProvider || AWS_PROVIDER; @@ -32,8 +32,6 @@ export const NewCloudConnectorForm: React.FC = ({ case AWS_PROVIDER: return ( = ({ cloudProvider={provider} credentials={credentials} setCredentials={setCredentials} + accountType={accountType} + iacTemplateUrl={iacTemplateUrl} /> ); case AZURE_PROVIDER: return ( = ({ hasInvalidRequiredVars={hasInvalidRequiredVars} credentials={credentials} setCredentials={setCredentials} + accountType={accountType} + iacTemplateUrl={iacTemplateUrl} /> ); case 'gcp': diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/hooks/use_cloud_connector_setup.ts b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/hooks/use_cloud_connector_setup.ts index 4634a98a2707b..5646e03210523 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/hooks/use_cloud_connector_setup.ts +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/hooks/use_cloud_connector_setup.ts @@ -163,7 +163,10 @@ export const useCloudConnectorSetup = ( // State for existing connection form const [existingConnectionCredentials, setExistingConnectionCredentials] = - useState({}); + useState(() => { + // In edit mode, if the policy has a cloud_connector_id, initialize with it + return newPolicy.cloud_connector_id ? { cloudConnectorId: newPolicy.cloud_connector_id } : {}; + }); // Extract account type from inputs const accountTypeFromInputs = useMemo(() => { diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/types.ts b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/types.ts index f75ec07f55e78..44b559288792f 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/types.ts +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/types.ts @@ -5,12 +5,11 @@ * 2.0. */ -import type { ReactNode } from 'react'; import type { CloudSetup } from '@kbn/cloud-plugin/public'; -import type { NewPackagePolicy, NewPackagePolicyInput, PackageInfo } from '../../../common'; +import type { NewPackagePolicy, PackageInfo } from '../../../common'; import type { CloudConnectorVar, CloudConnectorSecretVar } from '../../../common/types'; -import type { CloudConnectorSecretReference, CloudProvider } from '../../types'; +import type { AccountType, CloudConnectorSecretReference, CloudProvider } from '../../types'; import type { AWS_PROVIDER, AZURE_PROVIDER, GCP_PROVIDER } from './constants'; @@ -48,14 +47,7 @@ export type CloudConnectorCredentials = | AwsCloudConnectorCredentials | AzureCloudConnectorCredentials; -export interface CloudConnectorConfig { - provider: CloudProviders; - fields: CloudConnectorField[]; - description?: ReactNode; -} - export interface NewCloudConnectorFormProps { - input: NewPackagePolicyInput; newPolicy: NewPackagePolicy; packageInfo: PackageInfo; updatePolicy: UpdatePolicy; @@ -63,9 +55,12 @@ export interface NewCloudConnectorFormProps { hasInvalidRequiredVars: boolean; cloud?: CloudSetup; cloudProvider?: CloudProvider; - templateName?: string; credentials?: CloudConnectorCredentials; setCredentials: (credentials: CloudConnectorCredentials) => void; + /** Account type to determine organization vs single account behavior */ + accountType?: AccountType; + /** IaC template URL from var_group selection for generating cloud connector setup instructions. */ + iacTemplateUrl?: string; } // Define the interface for connector options @@ -87,7 +82,6 @@ export interface AzureCloudConnectorOption { } export interface CloudConnectorFormProps { - input: NewPackagePolicyInput; newPolicy: NewPackagePolicy; packageInfo: PackageInfo; updatePolicy: UpdatePolicy; @@ -95,10 +89,12 @@ export interface CloudConnectorFormProps { hasInvalidRequiredVars: boolean; cloud?: CloudSetup; cloudProvider?: CloudProvider; - isOrganization?: boolean; - templateName?: string; credentials?: CloudConnectorCredentials; setCredentials: (credentials: CloudConnectorCredentials) => void; + /** Account type for cloud connector template URL generation */ + accountType?: AccountType; + /** IaC template URL from var_group selection for generating cloud connector setup instructions. */ + iacTemplateUrl?: string; } export type CloudSetupForCloudConnector = Pick< @@ -111,16 +107,11 @@ export type CloudSetupForCloudConnector = Pick< | 'isServerlessEnabled' >; -export interface CloudFormationCloudCredentialsGuideProps { - cloudProvider?: CloudProvider; -} - export interface GetCloudConnectorRemoteRoleTemplateParams { - input: NewPackagePolicyInput; cloud: CloudSetupForCloudConnector; - packageInfo: PackageInfo; - templateName: string; - provider: CloudProviders; + accountType: AccountType; + /** IaC template URL to use for generating the cloud connector remote role template. */ + iacTemplateUrl?: string; } export interface CloudConnectorField { diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/utils.test.ts b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/utils.test.ts index 9e2d13d78cbe5..9f6f7f12600d0 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/utils.test.ts +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/utils.test.ts @@ -7,17 +7,11 @@ import type { CloudSetup } from '@kbn/cloud-plugin/public'; -import type { - PackagePolicyConfigRecord, - NewPackagePolicy, - NewPackagePolicyInput, - PackageInfo, -} from '../../../common'; +import type { PackagePolicyConfigRecord } from '../../../common'; import type { AwsCloudConnectorVars, AzureCloudConnectorVars } from '../../../common/types'; import { updateInputVarsWithCredentials, - updatePolicyWithAwsCloudConnectorCredentials, isCloudConnectorReusableEnabled, isAwsCloudConnectorVars, isAzureCloudConnectorVars, @@ -28,7 +22,7 @@ import { isCloudConnectorNameValid, CLOUD_CONNECTOR_NAME_MAX_LENGTH, } from './utils'; -import { getMockPolicyAWS, getMockPackageInfoAWS } from './test/mock'; +import { SINGLE_ACCOUNT, ORGANIZATION_ACCOUNT } from './constants'; import type { CloudConnectorCredentials } from './types'; import { AWS_PROVIDER, AZURE_PROVIDER } from './constants'; @@ -232,210 +226,85 @@ describe('updateInputVarsWithCredentials - Azure support', () => { }); }); -describe('updatePolicyWithAwsCloudConnectorCredentials', () => { - let mockPackagePolicy: NewPackagePolicy; - let mockInput: NewPackagePolicyInput; - - beforeEach(() => { - mockInput = { - type: 'cloudbeat/cis_aws', - policy_template: 'cis_aws', - enabled: true, - streams: [ - { - enabled: true, - data_stream: { type: 'logs', dataset: 'aws.cloudtrail' }, - vars: { - role_arn: { value: 'arn:aws:iam::123456789012:role/OriginalRole' }, - external_id: { value: 'original-external-id' }, - 'aws.role_arn': { value: 'arn:aws:iam::123456789012:role/OriginalAwsRole' }, - 'aws.credentials.external_id': { value: 'original-aws-external-id' }, - }, - }, - ], - }; - - mockPackagePolicy = { - id: 'test-policy-id', - enabled: true, - policy_id: 'test-policy', - policy_ids: ['test-policy'], - name: 'test-policy', - namespace: 'default', - package: { - name: 'cloud_security_posture', - title: 'Cloud Security Posture', - version: '1.0.0', - }, - inputs: [ - { - type: 'cloudbeat/cis_aws', - policy_template: 'cis_aws', - enabled: true, - streams: [ - { - enabled: true, - data_stream: { type: 'logs', dataset: 'aws.cloudtrail' }, - vars: mockInput.streams[0].vars, - }, - ], - }, - ], - }; - }); - - it('should return original policy when credentials is empty object', () => { - const result = updatePolicyWithAwsCloudConnectorCredentials(mockPackagePolicy, mockInput, {}); - - expect(result).toStrictEqual(mockPackagePolicy); - }); - - it('should update role_arn when provided in credentials', () => { - const credentials = { - role_arn: 'arn:aws:iam::123456789012:role/UpdatedRole', +describe('updateInputVarsWithCredentials - supports_cloud_connectors flag', () => { + it('should set supports_cloud_connectors to true when AWS credentials are provided and var exists', () => { + const inputVars: PackagePolicyConfigRecord = { + role_arn: { value: '' }, + external_id: { value: '' }, + supports_cloud_connectors: { value: undefined }, }; - const result = updatePolicyWithAwsCloudConnectorCredentials( - mockPackagePolicy, - mockInput, - credentials - ); - - expect(result.inputs[0].streams[0].vars?.role_arn.value).toBe( - 'arn:aws:iam::123456789012:role/UpdatedRole' - ); - }); - - it('should update external_id when provided in credentials', () => { - const credentials = { - external_id: 'updated-external-id', + const credentials: CloudConnectorCredentials = { + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'test-external-id', }; - const result = updatePolicyWithAwsCloudConnectorCredentials( - mockPackagePolicy, - mockInput, - credentials - ); + const result = updateInputVarsWithCredentials(inputVars, credentials); - expect(result.inputs[0].streams[0].vars?.external_id.value).toBe('updated-external-id'); + expect(result?.supports_cloud_connectors?.value).toBe(true); }); - it('should update aws.role_arn when provided in credentials', () => { - const credentials = { - 'aws.role_arn': 'arn:aws:iam::123456789012:role/UpdatedAwsRole', + it('should set supports_cloud_connectors to true when Azure credentials are provided and var exists', () => { + const inputVars: PackagePolicyConfigRecord = { + tenant_id: { value: '' }, + client_id: { value: '' }, + supports_cloud_connectors: { value: undefined }, }; - const result = updatePolicyWithAwsCloudConnectorCredentials( - mockPackagePolicy, - mockInput, - credentials - ); - - expect(result.inputs[0].streams[0].vars?.['aws.role_arn'].value).toBe( - 'arn:aws:iam::123456789012:role/UpdatedAwsRole' - ); - }); - - it('should update aws.credentials.external_id when provided in credentials', () => { - const credentials = { - 'aws.credentials.external_id': 'updated-aws-external-id', + const credentials: CloudConnectorCredentials = { + tenantId: 'test-tenant-id', + clientId: 'test-client-id', }; - const result = updatePolicyWithAwsCloudConnectorCredentials( - mockPackagePolicy, - mockInput, - credentials - ); + const result = updateInputVarsWithCredentials(inputVars, credentials); - expect(result.inputs[0].streams[0].vars?.['aws.credentials.external_id'].value).toBe( - 'updated-aws-external-id' - ); + expect(result?.supports_cloud_connectors?.value).toBe(true); }); - it('should handle multiple credential fields at once', () => { - const credentials = { - role_arn: 'arn:aws:iam::123456789012:role/UpdatedRole', - external_id: 'updated-external-id', - 'aws.role_arn': 'arn:aws:iam::123456789012:role/UpdatedAwsRole', - 'aws.credentials.external_id': 'updated-aws-external-id', + it('should set supports_cloud_connectors to false when credentials are undefined', () => { + const inputVars: PackagePolicyConfigRecord = { + role_arn: { value: 'some-arn' }, + external_id: { value: 'some-id' }, + supports_cloud_connectors: { value: true }, }; - const result = updatePolicyWithAwsCloudConnectorCredentials( - mockPackagePolicy, - mockInput, - credentials - ); + const result = updateInputVarsWithCredentials(inputVars, undefined); - const updatedVars = result.inputs[0].streams[0].vars; - expect(updatedVars?.role_arn.value).toBe('arn:aws:iam::123456789012:role/UpdatedRole'); - expect(updatedVars?.external_id.value).toBe('updated-external-id'); - expect(updatedVars?.['aws.role_arn'].value).toBe( - 'arn:aws:iam::123456789012:role/UpdatedAwsRole' - ); - expect(updatedVars?.['aws.credentials.external_id'].value).toBe('updated-aws-external-id'); + expect(result?.supports_cloud_connectors?.value).toBe(false); }); - it('should handle policy without inputs', () => { - const policyWithoutInputs = { ...mockPackagePolicy, inputs: [] }; - - const credentials = { - role_arn: 'arn:aws:iam::123456789012:role/UpdatedRole', + it('should not add supports_cloud_connectors if it does not exist in input vars', () => { + const inputVars: PackagePolicyConfigRecord = { + role_arn: { value: '' }, + external_id: { value: '' }, }; - const result = updatePolicyWithAwsCloudConnectorCredentials( - policyWithoutInputs, - mockInput, - credentials - ); - - expect(result.inputs).toEqual([]); - }); - - it('should return policy with empty inputs array when inputs is undefined', () => { - const policyWithoutInputs = { ...mockPackagePolicy }; - delete (policyWithoutInputs as Partial).inputs; - - const credentials = { - role_arn: 'arn:aws:iam::123456789012:role/UpdatedRole', + const credentials: CloudConnectorCredentials = { + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'test-external-id', }; - const result = updatePolicyWithAwsCloudConnectorCredentials( - policyWithoutInputs, - mockInput, - credentials - ); + const result = updateInputVarsWithCredentials(inputVars, credentials); - expect(result.inputs).toEqual([]); + expect(result).not.toHaveProperty('supports_cloud_connectors'); }); - it('should return updated policy when input streams vars is undefined', () => { - const inputWithoutVars: NewPackagePolicyInput = { - type: 'test-input', - policy_template: 'test-template', - enabled: true, - streams: [ - { - enabled: true, - data_stream: { type: 'logs', dataset: 'test.dataset' }, - }, - ], + it('should preserve other properties on the supports_cloud_connectors var entry', () => { + const inputVars: PackagePolicyConfigRecord = { + role_arn: { value: '' }, + external_id: { value: '' }, + supports_cloud_connectors: { value: undefined, type: 'bool' }, }; - const credentials = { - role_arn: 'arn:aws:iam::123456789012:role/UpdatedRole', + const credentials: CloudConnectorCredentials = { + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + externalId: 'test-external-id', }; - const result = updatePolicyWithAwsCloudConnectorCredentials( - mockPackagePolicy, - inputWithoutVars, - credentials - ); + const result = updateInputVarsWithCredentials(inputVars, credentials); - expect(result).toEqual( - expect.objectContaining({ - inputs: expect.any(Array), - }) - ); + expect(result?.supports_cloud_connectors?.value).toBe(true); + expect(result?.supports_cloud_connectors?.type).toBe('bool'); }); }); @@ -573,258 +442,144 @@ describe('Cloud Connector Type Guards', () => { }); describe('getCloudConnectorRemoteRoleTemplate', () => { - const mockInput = getMockPolicyAWS().inputs[0]; - const mockPackageInfo = getMockPackageInfoAWS(); - - // AWS-specific cloud setup - const mockAwsCloudSetup = { - isCloudEnabled: true, - // Cloud ID format: name:base64(endpoint$deployment$kibanaId) - // Decodes to: 'aws-endpoint$aws-deployment$aws-kibana-id' - cloudId: 'aws-cluster:YXdzLWVuZHBvaW50JGF3cy1kZXBsb3ltZW50JGF3cy1raWJhbmEtaWQ=', - baseUrl: 'https://aws.elastic.co', - deploymentUrl: 'https://cloud.elastic.co/deployments/aws-deployment-123/kibana', - profileUrl: 'https://aws.elastic.co/profile', - organizationUrl: 'https://aws.elastic.co/organizations', - snapshotsUrl: 'https://aws.elastic.co/snapshots', - isServerlessEnabled: false, - } as CloudSetup; - - // Azure-specific cloud setup - const mockAzureCloudSetup = { + // Cloud setup for testing - ESS deployment + const mockCloudSetup = { isCloudEnabled: true, // Cloud ID format: name:base64(endpoint$deployment$kibanaId) - // Decodes to: 'azure-endpoint$azure-deployment$azure-kibana-id' - cloudId: 'azure-cluster:YXp1cmUtZW5kcG9pbnQkYXp1cmUtZGVwbG95bWVudCRhenVyZS1raWJhbmEtaWQ=', - baseUrl: 'https://azure.elastic.co', - deploymentUrl: 'https://cloud.elastic.co/deployments/azure-deployment-456/kibana', - profileUrl: 'https://azure.elastic.co/profile', - organizationUrl: 'https://azure.elastic.co/organizations', - snapshotsUrl: 'https://azure.elastic.co/snapshots', + // Decodes to: 'endpoint$deployment$kibana-component-id' + cloudId: 'cluster:ZW5kcG9pbnQkZGVwbG95bWVudCRraWJhbmEtY29tcG9uZW50LWlk', + baseUrl: 'https://elastic.co', + deploymentUrl: 'https://cloud.elastic.co/deployments/deployment-123/kibana', + profileUrl: 'https://elastic.co/profile', + organizationUrl: 'https://elastic.co/organizations', + snapshotsUrl: 'https://elastic.co/snapshots', isServerlessEnabled: false, } as CloudSetup; - // GCP-specific cloud setup - const mockGcpCloudSetup = { - isCloudEnabled: true, - // Cloud ID format: name:base64(endpoint$deployment$kibanaId) - // Decodes to: 'gcp-endpoint$gcp-deployment$gcp-kibana-id' - cloudId: 'gcp-cluster:Z2NwLWVuZHBvaW50JGdjcC1kZXBsb3ltZW50JGdjcC1raWJhbmEtaWQ=', - baseUrl: 'https://gcp.elastic.co', - deploymentUrl: 'https://cloud.elastic.co/deployments/gcp-deployment-789/kibana', - profileUrl: 'https://gcp.elastic.co/profile', - organizationUrl: 'https://gcp.elastic.co/organizations', - snapshotsUrl: 'https://gcp.elastic.co/snapshots', - isServerlessEnabled: false, + // Serverless cloud setup + const mockServerlessCloudSetup = { + isCloudEnabled: false, + isServerlessEnabled: true, + serverless: { projectId: 'serverless-project-id' }, } as CloudSetup; - beforeEach(() => { - // Add cloud_formation_cloud_connectors_template to mock package info - const policyTemplate = mockPackageInfo.policy_templates?.[0]; - if (policyTemplate && 'inputs' in policyTemplate && policyTemplate.inputs?.[0]?.vars) { - policyTemplate.inputs[0].vars = [ - ...(policyTemplate.inputs[0].vars || []), - { - name: 'cloud_formation_cloud_connectors_template', - type: 'text', - title: 'CloudFormation Template', - multi: false, - required: false, - show_user: false, - default: - 'https://s3.amazonaws.com/cloudformation-templates/ACCOUNT_TYPE/RESOURCE_ID/template.yaml', - }, - ]; - } - }); - - describe('AWS Provider - Successful cases', () => { - it('should generate template URL for AWS with serverless enabled', () => { - const serverlessCloudSetup = { - ...mockAwsCloudSetup, - isCloudEnabled: false, // Disable ESS to test serverless only - isServerlessEnabled: true, - serverless: { projectId: 'aws-serverless-project' }, - } as CloudSetup; + const mockIacTemplateUrl = + 'https://example.com/templates/ACCOUNT_TYPE/RESOURCE_ID/cloudformation.yaml'; + describe('Successful template URL generation', () => { + it('should generate template URL with ESS deployment resource ID', () => { const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, - cloud: serverlessCloudSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, + cloud: mockCloudSetup, + accountType: SINGLE_ACCOUNT, + iacTemplateUrl: mockIacTemplateUrl, }); expect(result).toBe( - 'https://s3.amazonaws.com/cloudformation-templates/single-account/aws-serverless-project/template.yaml' + 'https://example.com/templates/single-account/kibana-component-id/cloudformation.yaml' ); }); - it('should generate template URL for AWS with cloud ESS deployment', () => { + it('should generate template URL with serverless project ID', () => { const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, - cloud: mockAwsCloudSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, + cloud: mockServerlessCloudSetup, + accountType: ORGANIZATION_ACCOUNT, + iacTemplateUrl: mockIacTemplateUrl, }); expect(result).toBe( - 'https://s3.amazonaws.com/cloudformation-templates/single-account/aws-kibana-id/template.yaml' + 'https://example.com/templates/organization-account/serverless-project-id/cloudformation.yaml' ); }); - it('should use cloud ESS deployment ID when both serverless and cloud are enabled', () => { - // Note: When both are enabled, cloud ESS takes precedence (last assignment wins) - const hybridCloudSetup = { - ...mockAwsCloudSetup, - isServerlessEnabled: true, - serverless: { projectId: 'serverless-priority' }, - } as CloudSetup; - + it('should replace ACCOUNT_TYPE placeholder with organization-account', () => { const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, - cloud: hybridCloudSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, - }); - - // Current behavior: cloud ESS ID overwrites serverless project ID - expect(result).toContain('aws-kibana-id'); - expect(result).not.toContain('serverless-priority'); - }); - - it('should use organization-account type when specified in input', () => { - const orgInput: NewPackagePolicyInput = { - ...mockInput, - streams: [ - { - ...mockInput.streams[0], - vars: { - 'aws.account_type': { value: 'organization-account', type: 'text' }, - }, - }, - ], - }; - - const result = getCloudConnectorRemoteRoleTemplate({ - input: orgInput, - cloud: mockAwsCloudSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, + cloud: mockCloudSetup, + accountType: ORGANIZATION_ACCOUNT, + iacTemplateUrl: mockIacTemplateUrl, }); expect(result).toContain('/organization-account/'); + expect(result).not.toContain('ACCOUNT_TYPE'); }); - it('should use single-account type by default for AWS', () => { + it('should replace ACCOUNT_TYPE placeholder with single-account', () => { const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, - cloud: mockAwsCloudSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, + cloud: mockCloudSetup, + accountType: SINGLE_ACCOUNT, + iacTemplateUrl: mockIacTemplateUrl, }); expect(result).toContain('/single-account/'); + expect(result).not.toContain('ACCOUNT_TYPE'); }); - it('should handle complex cloud ID with base64 encoding for AWS', () => { - const complexCloudSetup = { - ...mockAwsCloudSetup, - // Cloud ID format: name:base64(endpoint$deployment$kibanaId) - // Decodes to: 'eu-west-1.aws.found.io$deployment-complex$aws-kibana-complex-id' - cloudId: - 'production:ZXUtd2VzdC0xLmF3cy5mb3VuZC5pbyRkZXBsb3ltZW50LWNvbXBsZXgkYXdzLWtpYmFuYS1jb21wbGV4LWlk', - } as CloudSetup; - + it('should replace RESOURCE_ID placeholder with elastic resource ID', () => { const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, - cloud: complexCloudSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, + cloud: mockCloudSetup, + accountType: SINGLE_ACCOUNT, + iacTemplateUrl: mockIacTemplateUrl, }); - expect(result).toContain('aws-kibana-complex-id'); + expect(result).toContain('/kibana-component-id/'); + expect(result).not.toContain('RESOURCE_ID'); }); - it('should handle complex cloud ID with base64 encoding for GCP', () => { - const complexGcpCloudSetup = { - ...mockGcpCloudSetup, - // Cloud ID format: name:base64(endpoint$deployment$kibanaId) - // Decodes to: 'us-central1.gcp.cloud.es.io$gcp-deployment-complex$gcp-kibana-complex-id' - cloudId: - 'gcp-production:dXMtY2VudHJhbDEuZ2NwLmNsb3VkLmVzLmlvJGdjcC1kZXBsb3ltZW50LWNvbXBsZXgkZ2NwLWtpYmFuYS1jb21wbGV4LWlk', + it('should use ESS deployment ID when both serverless and cloud are enabled', () => { + const hybridCloudSetup = { + ...mockCloudSetup, + isServerlessEnabled: true, + serverless: { projectId: 'serverless-should-not-use' }, } as CloudSetup; const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, - cloud: complexGcpCloudSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, + cloud: hybridCloudSetup, + accountType: SINGLE_ACCOUNT, + iacTemplateUrl: mockIacTemplateUrl, }); - expect(result).toContain('gcp-kibana-complex-id'); + expect(result).toContain('kibana-component-id'); + expect(result).not.toContain('serverless-should-not-use'); }); - it('should handle complex cloud ID with base64 encoding for Azure', () => { - const complexAzureCloudSetup = { - ...mockAzureCloudSetup, - // Cloud ID format: name:base64(endpoint$deployment$kibanaId) - // Decodes to: 'westeurope.azure.elastic-cloud.com$azure-deployment-complex$azure-kibana-complex-id' + it('should handle complex cloud ID with base64 encoding', () => { + const complexCloudSetup = { + ...mockCloudSetup, + // Decodes to: 'eu-west-1.aws.found.io$deployment-complex$complex-kibana-id' cloudId: - 'azure-production:d2VzdGV1cm9wZS5henVyZS5lbGFzdGljLWNsb3VkLmNvbSRhenVyZS1kZXBsb3ltZW50LWNvbXBsZXgkYXp1cmUta2liYW5hLWNvbXBsZXgtaWQ=', + 'production:ZXUtd2VzdC0xLmF3cy5mb3VuZC5pbyRkZXBsb3ltZW50LWNvbXBsZXgkY29tcGxleC1raWJhbmEtaWQ=', } as CloudSetup; const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, - cloud: complexAzureCloudSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, + cloud: complexCloudSetup, + accountType: SINGLE_ACCOUNT, + iacTemplateUrl: mockIacTemplateUrl, }); - expect(result).toContain('azure-kibana-complex-id'); + expect(result).toContain('complex-kibana-id'); }); + }); - it('should handle deployment URL with different formats', () => { - const differentUrlSetup = { - ...mockAwsCloudSetup, - deploymentUrl: - 'https://cloud.elastic.co/deployments/aws-deployment-with-dashes-123/kibana/app', - } as CloudSetup; - + describe('Failure cases', () => { + it('should return undefined when iacTemplateUrl is not provided', () => { const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, - cloud: differentUrlSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, + cloud: mockCloudSetup, + accountType: SINGLE_ACCOUNT, + iacTemplateUrl: undefined, }); - expect(result).toBeDefined(); - expect(result).toContain('aws-kibana-id'); + expect(result).toBeUndefined(); }); - }); - describe('AWS Provider - Failure cases', () => { it('should return undefined when no elastic resource ID is available', () => { const noResourceCloudSetup = { - ...mockAwsCloudSetup, isCloudEnabled: false, isServerlessEnabled: false, } as CloudSetup; const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, cloud: noResourceCloudSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, + accountType: SINGLE_ACCOUNT, + iacTemplateUrl: mockIacTemplateUrl, }); expect(result).toBeUndefined(); @@ -832,18 +587,15 @@ describe('getCloudConnectorRemoteRoleTemplate', () => { it('should return undefined when serverless is enabled but project ID is missing', () => { const serverlessNoProjectSetup = { - ...mockAwsCloudSetup, - isCloudEnabled: false, // Disable ESS fallback + isCloudEnabled: false, isServerlessEnabled: true, serverless: { projectId: undefined }, } as CloudSetup; const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, cloud: serverlessNoProjectSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, + accountType: SINGLE_ACCOUNT, + iacTemplateUrl: mockIacTemplateUrl, }); expect(result).toBeUndefined(); @@ -851,16 +603,14 @@ describe('getCloudConnectorRemoteRoleTemplate', () => { it('should return undefined when cloud is enabled but deployment URL is missing', () => { const noDeploymentUrlSetup = { - ...mockAwsCloudSetup, + ...mockCloudSetup, deploymentUrl: undefined, } as CloudSetup; const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, cloud: noDeploymentUrlSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, + accountType: SINGLE_ACCOUNT, + iacTemplateUrl: mockIacTemplateUrl, }); expect(result).toBeUndefined(); @@ -868,73 +618,14 @@ describe('getCloudConnectorRemoteRoleTemplate', () => { it('should return undefined when cloud is enabled but cloud ID is missing', () => { const noCloudIdSetup = { - ...mockAwsCloudSetup, + ...mockCloudSetup, cloudId: undefined, } as CloudSetup; const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, cloud: noCloudIdSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, - }); - - expect(result).toBeUndefined(); - }); - - it('should return undefined when provider is invalid', () => { - const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, - cloud: mockAwsCloudSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - // @ts-expect-error Testing invalid provider type - provider: 'invalid-provider', - }); - - expect(result).toBeUndefined(); - }); - - it('should return undefined when template name does not exist in package info', () => { - const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, - cloud: mockAwsCloudSetup, - packageInfo: mockPackageInfo, - templateName: 'non-existent-template', - provider: AWS_PROVIDER, - }); - - expect(result).toBeUndefined(); - }); - - it('should return undefined when template URL field is not found in package info', () => { - const originalTemplate = mockPackageInfo.policy_templates![0]; - const packageInfoWithoutTemplate = { - ...mockPackageInfo, - policy_templates: [ - { - ...originalTemplate, - ...('inputs' in originalTemplate && originalTemplate.inputs - ? { - inputs: [ - { - ...originalTemplate.inputs[0], - vars: [], // Empty vars array - }, - ], - } - : {}), - }, - ], - } as PackageInfo; - - const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, - cloud: mockAwsCloudSetup, - packageInfo: packageInfoWithoutTemplate, - templateName: 'cspm', - provider: AWS_PROVIDER, + accountType: SINGLE_ACCOUNT, + iacTemplateUrl: mockIacTemplateUrl, }); expect(result).toBeUndefined(); @@ -942,18 +633,15 @@ describe('getCloudConnectorRemoteRoleTemplate', () => { it('should return undefined when cloud ID has missing kibana component', () => { const invalidCloudIdSetup = { - ...mockAwsCloudSetup, - // Cloud ID format: name:base64(endpoint$deployment) - missing kibana ID - // Decodes to: 'aws-endpoint$aws-deployment' (no third part) - cloudId: 'aws-cluster:YXdzLWVuZHBvaW50JGF3cy1kZXBsb3ltZW50', + ...mockCloudSetup, + // Decodes to: 'endpoint$deployment' (no third part) + cloudId: 'cluster:ZW5kcG9pbnQkZGVwbG95bWVudA==', } as CloudSetup; const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, cloud: invalidCloudIdSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, + accountType: SINGLE_ACCOUNT, + iacTemplateUrl: mockIacTemplateUrl, }); expect(result).toBeUndefined(); @@ -961,206 +649,19 @@ describe('getCloudConnectorRemoteRoleTemplate', () => { it('should return undefined when deployment URL has invalid format', () => { const invalidDeploymentUrlSetup = { - ...mockAwsCloudSetup, + ...mockCloudSetup, deploymentUrl: 'https://invalid-url-without-deployments-path', } as CloudSetup; const result = getCloudConnectorRemoteRoleTemplate({ - input: mockInput, cloud: invalidDeploymentUrlSetup, - packageInfo: mockPackageInfo, - templateName: 'cspm', - provider: AWS_PROVIDER, + accountType: SINGLE_ACCOUNT, + iacTemplateUrl: mockIacTemplateUrl, }); expect(result).toBeUndefined(); }); }); - - describe('Azure Provider Tests', () => { - let azureInput: NewPackagePolicyInput; - let azurePackageInfo: PackageInfo; - - beforeEach(() => { - // Create Azure-specific input - azureInput = { - ...mockInput, - type: 'cloudbeat/cis_azure', - policy_template: 'cspm', - streams: [ - { - enabled: true, - data_stream: { type: 'logs', dataset: 'cloud_security_posture.findings' }, - vars: { - 'azure.account_type': { value: 'single-account', type: 'text' }, - }, - }, - ], - }; - - // Create Azure-specific package info with ARM template URL - azurePackageInfo = { - ...mockPackageInfo, - policy_templates: [ - { - name: 'cspm', - title: 'CSPM', - description: 'CSPM', - inputs: [ - { - type: 'cloudbeat/cis_azure', - title: 'Azure CIS', - description: 'Azure CIS compliance monitoring', - vars: [ - { - name: 'arm_template_cloud_connectors_url', - type: 'text', - title: 'ARM Template URL', - multi: false, - required: false, - show_user: false, - default: - 'https://portal.azure.com/ACCOUNT_TYPE/deploy/RESOURCE_ID/template.json', - }, - ], - }, - ], - }, - ], - } as PackageInfo; - }); - - describe('Successful cases', () => { - it('should generate ARM template URL for Azure with serverless enabled', () => { - const serverlessCloudSetup = { - ...mockAzureCloudSetup, - isCloudEnabled: false, - isServerlessEnabled: true, - serverless: { projectId: 'azure-serverless-project' }, - } as CloudSetup; - - const result = getCloudConnectorRemoteRoleTemplate({ - input: azureInput, - cloud: serverlessCloudSetup, - packageInfo: azurePackageInfo, - templateName: 'cspm', - provider: AZURE_PROVIDER, - }); - - expect(result).toBe( - 'https://portal.azure.com/single-account/deploy/azure-serverless-project/template.json' - ); - }); - - it('should generate ARM template URL for Azure with cloud ESS deployment', () => { - const result = getCloudConnectorRemoteRoleTemplate({ - input: azureInput, - cloud: mockAzureCloudSetup, - packageInfo: azurePackageInfo, - templateName: 'cspm', - provider: AZURE_PROVIDER, - }); - - expect(result).toBe( - 'https://portal.azure.com/single-account/deploy/azure-kibana-id/template.json' - ); - }); - - it('should use Azure single-account type by default', () => { - const azureInputNoAccountType: NewPackagePolicyInput = { - ...azureInput, - streams: [ - { - ...azureInput.streams[0], - vars: {}, - }, - ], - }; - - const result = getCloudConnectorRemoteRoleTemplate({ - input: azureInputNoAccountType, - cloud: mockAzureCloudSetup, - packageInfo: azurePackageInfo, - templateName: 'cspm', - provider: AZURE_PROVIDER, - }); - - expect(result).toContain('/single-account/'); - }); - - it('should handle complex Azure cloud ID with base64 encoding', () => { - const complexAzureCloudSetup = { - ...mockAzureCloudSetup, - // Cloud ID format: name:base64(endpoint$deployment$kibanaId) - // Decodes to: 'westeurope.azure.elastic-cloud.com$azure-complex$azure-kibana-complex' - cloudId: - 'azure-production:d2VzdGV1cm9wZS5henVyZS5lbGFzdGljLWNsb3VkLmNvbSRhenVyZS1jb21wbGV4JGF6dXJlLWtpYmFuYS1jb21wbGV4', - } as CloudSetup; - - const result = getCloudConnectorRemoteRoleTemplate({ - input: azureInput, - cloud: complexAzureCloudSetup, - packageInfo: azurePackageInfo, - templateName: 'cspm', - provider: AZURE_PROVIDER, - }); - - expect(result).toContain('azure-kibana-complex'); - }); - }); - - describe('Failure cases', () => { - it('should return undefined when Azure template URL field is not found', () => { - const originalTemplate = azurePackageInfo.policy_templates![0]; - const packageInfoWithoutArmTemplate = { - ...azurePackageInfo, - policy_templates: [ - { - ...originalTemplate, - ...('inputs' in originalTemplate && originalTemplate.inputs - ? { - inputs: [ - { - ...originalTemplate.inputs[0], - vars: [], - }, - ], - } - : {}), - }, - ], - } as PackageInfo; - - const result = getCloudConnectorRemoteRoleTemplate({ - input: azureInput, - cloud: mockAzureCloudSetup, - packageInfo: packageInfoWithoutArmTemplate, - templateName: 'cspm', - provider: AZURE_PROVIDER, - }); - - expect(result).toBeUndefined(); - }); - - it('should return undefined when no elastic resource ID is available for Azure', () => { - const noResourceCloudSetup = { - ...mockAzureCloudSetup, - isCloudEnabled: false, - isServerlessEnabled: false, - } as CloudSetup; - - const result = getCloudConnectorRemoteRoleTemplate({ - input: azureInput, - cloud: noResourceCloudSetup, - packageInfo: azurePackageInfo, - templateName: 'cspm', - provider: AZURE_PROVIDER, - }); - - expect(result).toBeUndefined(); - }); - }); - }); }); describe('getKibanaComponentId', () => { diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/utils.ts b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/utils.ts index c44d416b65eb4..ca082bf5a5110 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/utils.ts +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/utils.ts @@ -8,13 +8,7 @@ import gte from 'semver/functions/gte'; import { i18n } from '@kbn/i18n'; -import type { - NewPackagePolicy, - NewPackagePolicyInput, - NewPackagePolicyInputStream, - PackageInfo, - PackagePolicyConfigRecord, -} from '../../../common'; +import type { PackageInfo, PackagePolicyConfigRecord } from '../../../common'; import type { AwsCloudConnectorVars, AzureCloudConnectorVars, @@ -25,33 +19,22 @@ import type { AwsCloudConnectorCredentials, AzureCloudConnectorCredentials, CloudConnectorCredentials, - CloudProviders, GetCloudConnectorRemoteRoleTemplateParams, } from './types'; import { AWS_CLOUD_CONNECTOR_FIELD_NAMES, AZURE_CLOUD_CONNECTOR_FIELD_NAMES, - CLOUD_FORMATION_TEMPLATE_URL_CLOUD_CONNECTORS, - ARM_TEMPLATE_URL_CLOUD_CONNECTORS, CLOUD_CONNECTOR_AWS_ASSET_INVENTORY_REUSABLE_MIN_VERSION, CLOUD_CONNECTOR_AWS_CSPM_REUSABLE_MIN_VERSION, CLOUD_CONNECTOR_AZURE_CSPM_REUSABLE_MIN_VERSION, CLOUD_CONNECTOR_AZURE_ASSET_INVENTORY_REUSABLE_MIN_VERSION, AWS_PROVIDER, AZURE_PROVIDER, - SINGLE_ACCOUNT, TEMPLATE_URL_ACCOUNT_TYPE_ENV_VAR, TEMPLATE_URL_ELASTIC_RESOURCE_ID_ENV_VAR, - AZURE_ACCOUNT_TYPE_INPUT_VAR_NAME, - AWS_ACCOUNT_TYPE_INPUT_VAR_NAME, + SUPPORTS_CLOUD_CONNECTORS_VAR_NAME, } from './constants'; -export type AzureCloudConnectorFieldNames = - (typeof AZURE_CLOUD_CONNECTOR_FIELD_NAMES)[keyof typeof AZURE_CLOUD_CONNECTOR_FIELD_NAMES]; - -export type AwsCloudConnectorFieldNames = - (typeof AWS_CLOUD_CONNECTOR_FIELD_NAMES)[keyof typeof AWS_CLOUD_CONNECTOR_FIELD_NAMES]; - // Cloud connector name validation constants export const CLOUD_CONNECTOR_NAME_MAX_LENGTH = 255; @@ -120,22 +103,6 @@ export function isAzureCredentials( return 'tenantId' in credentials; } -export function hasValidNewConnectionCredentials( - credentials: CloudConnectorCredentials, - provider?: string -): boolean { - if (!provider) return false; - - switch (provider) { - case AWS_PROVIDER: - return isAwsCredentials(credentials) && !!credentials.roleArn; - case AZURE_PROVIDER: - return isAzureCredentials(credentials) && !!credentials.tenantId; - default: - return false; - } -} - export const getDeploymentIdFromUrl = (url: string | undefined): string | undefined => { if (!url) return undefined; const match = url.match(/\/deployments\/([^/?#]+)/); @@ -179,44 +146,14 @@ export const getTemplateUrlFromPackageInfo = ( } }; -const getAccountTypeFromInput = ( - input: NewPackagePolicyInput, - provider: CloudProviders -): string | undefined => { - switch (provider) { - case AWS_PROVIDER: - return input?.streams?.[0]?.vars?.[AWS_ACCOUNT_TYPE_INPUT_VAR_NAME]?.value ?? SINGLE_ACCOUNT; - case AZURE_PROVIDER: - return ( - input?.streams?.[0]?.vars?.[AZURE_ACCOUNT_TYPE_INPUT_VAR_NAME]?.value ?? SINGLE_ACCOUNT - ); - } - return undefined; -}; - -const getTemplateFieldNameByProvider = (provider: CloudProviders): string | undefined => { - switch (provider) { - case AWS_PROVIDER: - return CLOUD_FORMATION_TEMPLATE_URL_CLOUD_CONNECTORS; - case AZURE_PROVIDER: - return ARM_TEMPLATE_URL_CLOUD_CONNECTORS; - default: - return undefined; - } -}; - export const getCloudConnectorRemoteRoleTemplate = ({ - input, cloud, - packageInfo, - templateName, - provider, + accountType, + iacTemplateUrl, }: GetCloudConnectorRemoteRoleTemplateParams): string | undefined => { let elasticResourceId: string | undefined; - const accountType = getAccountTypeFromInput(input, provider); const deploymentId = getDeploymentIdFromUrl(cloud?.deploymentUrl); const kibanaComponentId = getKibanaComponentId(cloud?.cloudId); - const templateUrlFieldName = getTemplateFieldNameByProvider(provider); if (cloud?.isServerlessEnabled && cloud?.serverless?.projectId) { elasticResourceId = cloud.serverless.projectId; @@ -226,144 +163,11 @@ export const getCloudConnectorRemoteRoleTemplate = ({ elasticResourceId = kibanaComponentId; } - if (!elasticResourceId || !templateUrlFieldName || !accountType) return undefined; - - return getTemplateUrlFromPackageInfo(packageInfo, templateName, templateUrlFieldName) - ?.replace(TEMPLATE_URL_ACCOUNT_TYPE_ENV_VAR, accountType) - ?.replace(TEMPLATE_URL_ELASTIC_RESOURCE_ID_ENV_VAR, elasticResourceId); -}; - -/** - * Helper function to update policy inputs with new variables - * @param policy - The package policy to update - * @param updatedVars - The updated variables to apply - * @returns Updated policy with new input variables - */ -const updatePolicyInputsWithVars = ( - policy: NewPackagePolicy, - updatedVars: PackagePolicyConfigRecord -): NewPackagePolicy => { - // Create a deep copy to avoid circular references - const updatedPolicy: NewPackagePolicy = { - ...policy, - inputs: policy.inputs - .map((input: NewPackagePolicyInput) => { - if (input.enabled && input.streams[0]?.enabled) { - return { - ...input, - streams: input.streams.map((stream: NewPackagePolicyInputStream) => { - if (stream.enabled) { - return { - ...stream, - vars: { ...updatedVars }, // Create a shallow copy instead of referencing directly - }; - } - return { ...stream }; // Return a copy of the original stream if not enabled - }), - }; - } - return { ...input }; // Return a copy of the original input if not enabled - }) - .filter(Boolean), // Filter out undefined values - }; - - return updatedPolicy; -}; - -/** - * Update AWS cloud connector credentials in package policy - */ -export const updatePolicyWithAwsCloudConnectorCredentials = ( - packagePolicy: NewPackagePolicy, - input: NewPackagePolicyInput, - inputCredentials: Partial> -): NewPackagePolicy => { - if (!inputCredentials) return packagePolicy; - - const updatedPolicy = { ...packagePolicy }; - - if (!updatedPolicy.inputs) { - updatedPolicy.inputs = []; - } - - if (!input.streams[0].vars) return updatedPolicy; - - const updatedVars = { ...input.streams[0].vars }; - - // Update role_arn if it exists in inputCredentials - if (inputCredentials.role_arn) { - updatedVars[AWS_CLOUD_CONNECTOR_FIELD_NAMES.ROLE_ARN].value = inputCredentials.role_arn; - } - // Update external_id if it exists in inputCredentials - if (inputCredentials.external_id) { - updatedVars[AWS_CLOUD_CONNECTOR_FIELD_NAMES.EXTERNAL_ID].value = inputCredentials.external_id; - } - // Update aws.role_arn if it exists in inputCredentials - if (inputCredentials[AWS_CLOUD_CONNECTOR_FIELD_NAMES.AWS_ROLE_ARN]) { - updatedVars[AWS_CLOUD_CONNECTOR_FIELD_NAMES.AWS_ROLE_ARN].value = - inputCredentials[AWS_CLOUD_CONNECTOR_FIELD_NAMES.AWS_ROLE_ARN]; - } - // Update aws.credentials.external_id if it exists in inputCredentials - if (inputCredentials[AWS_CLOUD_CONNECTOR_FIELD_NAMES.AWS_EXTERNAL_ID]) { - updatedVars[AWS_CLOUD_CONNECTOR_FIELD_NAMES.AWS_EXTERNAL_ID].value = - inputCredentials[AWS_CLOUD_CONNECTOR_FIELD_NAMES.AWS_EXTERNAL_ID]; - } - - return updatePolicyInputsWithVars(updatedPolicy, updatedVars); -}; - -/** - * Updates input variables with Aazure credentials - * @param inputVars - The original input variables - * @param inputCredentials - The Azure credentials to apply - * @returns Updated input variables with Azure credentials applied - */ -export const updatePolicyWithAzureCloudConnectorCredentials = ( - packagePolicy: NewPackagePolicy, - input: NewPackagePolicyInput, - inputCredentials: Partial> -): NewPackagePolicy => { - if (!inputCredentials) return packagePolicy; - - const updatedPolicy = { ...packagePolicy }; - - if (!updatedPolicy.inputs || !updatedPolicy.inputs[0]) { - return updatedPolicy; - } - - if (!input.streams || !input.streams[0].vars) return updatedPolicy; - - const updatedVars = { ...input.streams[0].vars }; - - // Update tenant_id if it exists in inputCredentials - if (inputCredentials.tenant_id) { - updatedVars[AZURE_CLOUD_CONNECTOR_FIELD_NAMES.TENANT_ID].value = inputCredentials.tenant_id; - } - - // Update client_id if it exists in inputCredentials - if (inputCredentials.client_id) { - updatedVars[AZURE_CLOUD_CONNECTOR_FIELD_NAMES.CLIENT_ID].value = inputCredentials.client_id; - } - - // Update azure.credentials.tenant_id if exists in inputCredentials - if (inputCredentials[AZURE_CLOUD_CONNECTOR_FIELD_NAMES.AZURE_TENANT_ID]) { - updatedVars[AZURE_CLOUD_CONNECTOR_FIELD_NAMES.AZURE_TENANT_ID].value = - inputCredentials[AZURE_CLOUD_CONNECTOR_FIELD_NAMES.AZURE_TENANT_ID]; - } - - // Update azure.credentials.client_id if exists in inputCredentials - if (inputCredentials[AZURE_CLOUD_CONNECTOR_FIELD_NAMES.AZURE_CLIENT_ID]) { - updatedVars[AZURE_CLOUD_CONNECTOR_FIELD_NAMES.AZURE_CLIENT_ID].value = - inputCredentials[AZURE_CLOUD_CONNECTOR_FIELD_NAMES.AZURE_CLIENT_ID]; - } - - // Update azure_credentials_cloud_connector_id if exists in inputCredentials - if (inputCredentials.azure_credentials_cloud_connector_id) { - updatedVars[AZURE_CLOUD_CONNECTOR_FIELD_NAMES.AZURE_CREDENTIALS_CLOUD_CONNECTOR_ID].value = - inputCredentials[AZURE_CLOUD_CONNECTOR_FIELD_NAMES.AZURE_CREDENTIALS_CLOUD_CONNECTOR_ID]; - } + if (!elasticResourceId || !accountType || !iacTemplateUrl) return undefined; - return updatePolicyInputsWithVars(updatedPolicy, updatedVars); + return iacTemplateUrl + .replace(TEMPLATE_URL_ACCOUNT_TYPE_ENV_VAR, accountType) + .replace(TEMPLATE_URL_ELASTIC_RESOURCE_ID_ENV_VAR, elasticResourceId); }; /** @@ -529,22 +333,35 @@ export const updateInputVarsWithCredentials = ( ): PackagePolicyConfigRecord | undefined => { if (!inputVars) return inputVars; + let updatedVars: PackagePolicyConfigRecord | undefined; + // If credentials is undefined, clear all credential fields (both AWS and Azure) if (!credentials) { - let clearedVars = updateInputVarsWithAwsCredentials(inputVars, undefined); - clearedVars = updateInputVarsWithAzureCredentials(clearedVars, undefined); - return clearedVars; - } - - if (isAwsCredentials(credentials)) { - return updateInputVarsWithAwsCredentials(inputVars, credentials); + updatedVars = updateInputVarsWithAwsCredentials(inputVars, undefined); + updatedVars = updateInputVarsWithAzureCredentials(updatedVars, undefined); + } else if (isAwsCredentials(credentials)) { + updatedVars = updateInputVarsWithAwsCredentials(inputVars, credentials); + } else if (isAzureCredentials(credentials)) { + updatedVars = updateInputVarsWithAzureCredentials(inputVars, credentials); + } else { + updatedVars = inputVars; } - if (isAzureCredentials(credentials)) { - return updateInputVarsWithAzureCredentials(inputVars, credentials); + // Set supports_cloud_connectors flag if the var exists in the record. + // This flag is required for the agent's auth provider to use cloud connector + // credential exchange and must be set alongside other cloud connector vars. + // Always explicitly false when not using cloud connectors (never undefined). + if (updatedVars && SUPPORTS_CLOUD_CONNECTORS_VAR_NAME in updatedVars) { + updatedVars = { + ...updatedVars, + [SUPPORTS_CLOUD_CONNECTORS_VAR_NAME]: { + ...updatedVars[SUPPORTS_CLOUD_CONNECTORS_VAR_NAME], + value: !!credentials, + }, + }; } - return inputVars; + return updatedVars; }; export const isCloudConnectorReusableEnabled = ( @@ -559,6 +376,10 @@ export const isCloudConnectorReusableEnabled = ( if (templateName === 'asset_inventory') { return gte(packageInfoVersion, CLOUD_CONNECTOR_AWS_ASSET_INVENTORY_REUSABLE_MIN_VERSION); } + + if (templateName === 'aws') { + return true; + } } else if (provider === AZURE_PROVIDER) { if (templateName === 'cspm') { return gte(packageInfoVersion, CLOUD_CONNECTOR_AZURE_CSPM_REUSABLE_MIN_VERSION); @@ -567,6 +388,7 @@ export const isCloudConnectorReusableEnabled = ( return gte(packageInfoVersion, CLOUD_CONNECTOR_AZURE_ASSET_INVENTORY_REUSABLE_MIN_VERSION); } } + return false; }; @@ -576,6 +398,13 @@ export const isCloudConnectorReusableEnabled = ( * If found, it returns the variable definition object; otherwise, it returns undefined. */ export const findVariableDef = (packageInfo: PackageInfo, key: string) => { + // First check package-level vars (for scope-aware credential storage) + const packageLevelVar = packageInfo?.vars?.find((v) => v?.name === key); + if (packageLevelVar) { + return packageLevelVar; + } + + // Then check data stream vars return packageInfo?.data_streams ?.filter((datastreams) => datastreams !== undefined) .map((ds) => ds.streams) diff --git a/x-pack/platform/plugins/shared/fleet/public/hooks/index.ts b/x-pack/platform/plugins/shared/fleet/public/hooks/index.ts index a7684ed0db246..c3c786f7ef3f0 100644 --- a/x-pack/platform/plugins/shared/fleet/public/hooks/index.ts +++ b/x-pack/platform/plugins/shared/fleet/public/hooks/index.ts @@ -37,3 +37,4 @@ export * from './use_multiple_agent_policies'; export * from './use_tour_manager'; export * from './use_dismissable_tour'; export * from './use_agentless_resources'; +export * from './use_var_group_cloud_connector'; diff --git a/x-pack/platform/plugins/shared/fleet/public/hooks/use_var_group_cloud_connector.test.ts b/x-pack/platform/plugins/shared/fleet/public/hooks/use_var_group_cloud_connector.test.ts new file mode 100644 index 0000000000000..aa9b85a143807 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/hooks/use_var_group_cloud_connector.test.ts @@ -0,0 +1,176 @@ +/* + * 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 { renderHook, act } from '@testing-library/react'; + +import type { RegistryVarGroup } from '../../common'; + +import { useVarGroupCloudConnector, type VarGroupSelection } from './use_var_group_cloud_connector'; + +describe('useVarGroupCloudConnector hook', () => { + const mockUpdatePackagePolicy = jest.fn(); + + const createMockVarGroups = (): RegistryVarGroup[] => [ + { + name: 'auth_method', + title: 'Authentication Method', + selector_title: 'Select authentication method', + options: [ + { + name: 'cloud_connector', + title: 'Cloud Connector', + vars: ['role_arn', 'external_id'], + provider: 'aws', + iac_template_url: 'https://example.com/cloudformation.yaml', + }, + { + name: 'manual', + title: 'Manual', + vars: ['access_key', 'secret_key'], + }, + ], + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return isSelected: false when varGroups is undefined', () => { + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + + const { result } = renderHook(() => + useVarGroupCloudConnector({ + varGroups: undefined, + varGroupSelections: selections, + updatePackagePolicy: mockUpdatePackagePolicy, + }) + ); + + expect(result.current.isSelected).toBe(false); + expect(result.current.cloudProvider).toBeUndefined(); + expect(result.current.iacTemplateUrl).toBeUndefined(); + expect(result.current.cloudConnectorVars.size).toBe(0); + }); + + it('should return isSelected: false when no var_group selection is made', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = {}; + + const { result } = renderHook(() => + useVarGroupCloudConnector({ + varGroups, + varGroupSelections: selections, + updatePackagePolicy: mockUpdatePackagePolicy, + }) + ); + + expect(result.current.isSelected).toBe(false); + expect(result.current.cloudProvider).toBeUndefined(); + expect(result.current.cloudConnectorVars.size).toBe(0); + }); + + it('should return isSelected: false when non-cloud-connector option is selected', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'manual' }; + + const { result } = renderHook(() => + useVarGroupCloudConnector({ + varGroups, + varGroupSelections: selections, + updatePackagePolicy: mockUpdatePackagePolicy, + }) + ); + + expect(result.current.isSelected).toBe(false); + expect(result.current.cloudProvider).toBeUndefined(); + expect(result.current.cloudConnectorVars.size).toBe(0); + }); + + it('should return cloud connector info when cloud connector option is selected', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + + const { result } = renderHook(() => + useVarGroupCloudConnector({ + varGroups, + varGroupSelections: selections, + updatePackagePolicy: mockUpdatePackagePolicy, + }) + ); + + expect(result.current.isSelected).toBe(true); + expect(result.current.cloudProvider).toBe('aws'); + expect(result.current.iacTemplateUrl).toBe('https://example.com/cloudformation.yaml'); + expect(result.current.cloudConnectorVars).toEqual(new Set(['role_arn', 'external_id'])); + }); + + it('should provide handleCloudConnectorUpdate callback that calls updatePackagePolicy', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + + const { result } = renderHook(() => + useVarGroupCloudConnector({ + varGroups, + varGroupSelections: selections, + updatePackagePolicy: mockUpdatePackagePolicy, + }) + ); + + const updatedPolicy = { name: 'updated-policy', enabled: true, policy_ids: [], inputs: [] }; + act(() => { + result.current.handleCloudConnectorUpdate({ updatedPolicy }); + }); + + expect(mockUpdatePackagePolicy).toHaveBeenCalledWith(updatedPolicy); + }); + + it('should update when varGroupSelections change', () => { + const varGroups = createMockVarGroups(); + let selections: VarGroupSelection = { auth_method: 'manual' }; + + const { result, rerender } = renderHook(() => + useVarGroupCloudConnector({ + varGroups, + varGroupSelections: selections, + updatePackagePolicy: mockUpdatePackagePolicy, + }) + ); + + expect(result.current.isSelected).toBe(false); + + // Change selection to cloud connector + selections = { auth_method: 'cloud_connector' }; + rerender(); + + expect(result.current.isSelected).toBe(true); + expect(result.current.cloudProvider).toBe('aws'); + }); + + it('should memoize values and not recreate on every render', () => { + const varGroups = createMockVarGroups(); + const selections: VarGroupSelection = { auth_method: 'cloud_connector' }; + + const { result, rerender } = renderHook(() => + useVarGroupCloudConnector({ + varGroups, + varGroupSelections: selections, + updatePackagePolicy: mockUpdatePackagePolicy, + }) + ); + + const firstCloudConnectorVars = result.current.cloudConnectorVars; + const firstHandleUpdate = result.current.handleCloudConnectorUpdate; + + // Rerender with same props + rerender(); + + // Memoized values should be the same reference + expect(result.current.cloudConnectorVars).toBe(firstCloudConnectorVars); + expect(result.current.handleCloudConnectorUpdate).toBe(firstHandleUpdate); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/public/hooks/use_var_group_cloud_connector.ts b/x-pack/platform/plugins/shared/fleet/public/hooks/use_var_group_cloud_connector.ts new file mode 100644 index 0000000000000..2718973d02491 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/hooks/use_var_group_cloud_connector.ts @@ -0,0 +1,96 @@ +/* + * 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 { useMemo, useCallback } from 'react'; + +import type { NewPackagePolicy, RegistryVarGroup } from '../../common'; +import { + getCloudConnectorOption, + getCloudConnectorVars, + getIacTemplateUrlFromVarGroupSelection, + type VarGroupSelection, +} from '../../common/services/cloud_connectors'; +import type { CloudProvider } from '../types'; +import type { UpdatePolicy } from '../components/cloud_connector/types'; + +export type { VarGroupSelection }; + +export interface CloudConnectorInfo { + /** Whether a cloud connector option is currently selected */ + isSelected: boolean; + /** The cloud provider (e.g., 'aws', 'azure') if cloud connector is selected */ + cloudProvider?: CloudProvider; + /** IaC template URL from the selected var_group option */ + iacTemplateUrl?: string; + /** Set of variable names handled by cloud connector (should be hidden from regular var fields) */ + cloudConnectorVars: Set; + /** Callback compatible with CloudConnectorSetup component */ + handleCloudConnectorUpdate: UpdatePolicy; +} + +export interface UseVarGroupCloudConnectorProps { + /** The var_groups from package info */ + varGroups: RegistryVarGroup[] | undefined; + /** Current var_group selections */ + varGroupSelections: VarGroupSelection; + /** Callback to update the package policy */ + updatePackagePolicy: (fields: Partial) => void; +} + +/** + * Hook to manage cloud connector state derived from var_group selections. + * + * When a var_group option with a `provider` field is selected, this indicates + * that the CloudConnectorSetup component should be shown instead of individual + * var input fields. + * + * This hook provides: + * - Whether a cloud connector option is selected + * - The cloud provider type (aws, azure, etc.) + * - The IaC template URL if available + * - The set of vars handled by cloud connector (to hide from regular form) + * - A callback compatible with CloudConnectorSetup + */ +export const useVarGroupCloudConnector = ({ + varGroups, + varGroupSelections, + updatePackagePolicy, +}: UseVarGroupCloudConnectorProps): CloudConnectorInfo => { + // Check if a cloud connector option is selected + const cloudConnectorOption = useMemo( + () => getCloudConnectorOption(varGroups, varGroupSelections), + [varGroups, varGroupSelections] + ); + + // Get IaC template URL from var_group selection + const iacTemplateUrl = useMemo( + () => getIacTemplateUrlFromVarGroupSelection(varGroups, varGroupSelections), + [varGroups, varGroupSelections] + ); + + // Get vars that belong to the selected cloud connector option + const cloudConnectorVars = useMemo( + () => getCloudConnectorVars(varGroups, varGroupSelections), + [varGroups, varGroupSelections] + ); + + // Create an UpdatePolicy callback compatible with CloudConnectorSetup + const handleCloudConnectorUpdate: UpdatePolicy = useCallback( + ({ updatedPolicy }) => { + updatePackagePolicy(updatedPolicy); + }, + [updatePackagePolicy] + ); + + return { + isSelected: cloudConnectorOption.isSelected, + cloudProvider: cloudConnectorOption.provider, + iacTemplateUrl, + cloudConnectorVars, + handleCloudConnectorUpdate, + }; +}; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/agentless/agentless_policies.ts b/x-pack/platform/plugins/shared/fleet/server/services/agentless/agentless_policies.ts index 19a06a3ccc358..bbc1f2a7635c2 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/agentless/agentless_policies.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/agentless/agentless_policies.ts @@ -126,21 +126,15 @@ export class AgentlessPoliciesServiceImpl implements AgentlessPoliciesService { // Build agentless config with cloud connectors if provided let agentlessConfig = baseAgentlessConfig; if (data.cloud_connector?.enabled) { - const inputsArray = data.inputs ? Object.entries(data.inputs) : []; - const input = inputsArray.find(([, pinput]) => pinput.enabled !== false); - const targetCsp = input?.[0].match(/aws|azure|gcp/)?.[0] as - | 'aws' - | 'azure' - | 'gcp' - | undefined; - this.logger.debug( - `Configuring cloud connectors for cloud provider: ${targetCsp} from cloud_connector object` + `Configuring cloud connectors for cloud provider: ${ + data.cloud_connector.target_csp || 'undefined' + } from cloud_connector object` ); agentlessConfig = { ...baseAgentlessConfig, cloud_connectors: { - target_csp: targetCsp, + target_csp: data.cloud_connector.target_csp, enabled: true, }, }; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/agentless_policy_integration.ts b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/agentless_policy_integration.ts index f60c35144a9d1..78224c62cc8cc 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/agentless_policy_integration.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/agentless_policy_integration.ts @@ -55,6 +55,7 @@ export async function createAndIntegrateCloudConnector(params: { esClient: ElasticsearchClient; logger: Logger; cloudConnectorName?: string; + accountType?: 'single-account' | 'organization-account'; }): Promise { const { packagePolicy, @@ -65,6 +66,7 @@ export async function createAndIntegrateCloudConnector(params: { esClient, logger, cloudConnectorName: providedCloudConnectorName, + accountType: providedAccountType, } = params; // Check if cloud connectors are enabled for this agentless policy @@ -147,8 +149,9 @@ export async function createAndIntegrateCloudConnector(params: { packageInfo ); - // Extract account type from package policy vars - const accountType = extractAccountType(cloudProvider, updatedPackagePolicy, packageInfo); + // Use provided account type from API request, or fall back to extraction from package policy vars + const accountType = + providedAccountType ?? extractAccountType(cloudProvider, updatedPackagePolicy, packageInfo); try { const cloudConnector = await cloudConnectorService.create(soClient, { diff --git a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.test.ts index 498894cb71085..b226da9007c7b 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.test.ts @@ -7,7 +7,11 @@ import type { NewPackagePolicy, PackageInfo } from '../../types'; -import { SINGLE_ACCOUNT, ORGANIZATION_ACCOUNT } from '../../../common/constants/cloud_connector'; +import { + SINGLE_ACCOUNT, + ORGANIZATION_ACCOUNT, + CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE, +} from '../../../common/constants/cloud_connector'; import { extractAccountType, validateAccountType } from './integration_helpers'; @@ -101,10 +105,12 @@ describe('cloud connector integration helpers', () => { ); }); - it('should return undefined when aws.account_type is not present', () => { + it('should default to single-account when aws.account_type is not present', () => { const packagePolicy = createMockPackagePolicy({}); - expect(extractAccountType('aws', packagePolicy, mockPackageInfo)).toBeUndefined(); + expect(extractAccountType('aws', packagePolicy, mockPackageInfo)).toBe( + CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE + ); }); }); @@ -127,25 +133,29 @@ describe('cloud connector integration helpers', () => { ); }); - it('should return undefined when azure.account_type is not present', () => { + it('should default to single-account when azure.account_type is not present', () => { const packagePolicy = createMockPackagePolicy({}); - expect(extractAccountType('azure', packagePolicy, mockPackageInfo)).toBeUndefined(); + expect(extractAccountType('azure', packagePolicy, mockPackageInfo)).toBe( + CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE + ); }); }); describe('GCP account type extraction', () => { - it('should return undefined for GCP (not yet supported)', () => { + it('should default to single-account for GCP (not yet supported)', () => { const packagePolicy = createMockPackagePolicy({ 'gcp.account_type': { value: 'single-project' }, }); - expect(extractAccountType('gcp', packagePolicy, mockPackageInfo)).toBeUndefined(); + expect(extractAccountType('gcp', packagePolicy, mockPackageInfo)).toBe( + CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE + ); }); }); describe('edge cases', () => { - it('should return undefined when inputs are empty', () => { + it('should default to single-account when inputs are empty', () => { const packagePolicy: NewPackagePolicy = { name: 'test-policy', namespace: 'default', @@ -154,10 +164,12 @@ describe('cloud connector integration helpers', () => { inputs: [], }; - expect(extractAccountType('aws', packagePolicy, mockPackageInfo)).toBeUndefined(); + expect(extractAccountType('aws', packagePolicy, mockPackageInfo)).toBe( + CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE + ); }); - it('should return undefined when no enabled input exists', () => { + it('should default to single-account when no enabled input exists', () => { const packagePolicy: NewPackagePolicy = { name: 'test-policy', namespace: 'default', @@ -178,10 +190,12 @@ describe('cloud connector integration helpers', () => { ], }; - expect(extractAccountType('aws', packagePolicy, mockPackageInfo)).toBeUndefined(); + expect(extractAccountType('aws', packagePolicy, mockPackageInfo)).toBe( + CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE + ); }); - it('should return undefined when streams have no vars', () => { + it('should default to single-account when streams have no vars', () => { const packagePolicy: NewPackagePolicy = { name: 'test-policy', namespace: 'default', @@ -201,7 +215,9 @@ describe('cloud connector integration helpers', () => { ], }; - expect(extractAccountType('aws', packagePolicy, mockPackageInfo)).toBeUndefined(); + expect(extractAccountType('aws', packagePolicy, mockPackageInfo)).toBe( + CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE + ); }); }); }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.ts b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.ts index 75a76b55f5bc0..bc1eec4ddd074 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.ts @@ -22,6 +22,7 @@ import { AZURE_ACCOUNT_TYPE_VAR_NAME, SINGLE_ACCOUNT, ORGANIZATION_ACCOUNT, + CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE, } from '../../../common/constants/cloud_connector'; import type { @@ -35,34 +36,41 @@ import type { import type { NewPackagePolicy } from '../../types'; /** - * Extracts the account type from package policy variables + * Extracts the account type from package policy + * + * Checks provider-specific account type vars (legacy approach for CSPM). + * Returns DEFAULT_ACCOUNT_TYPE ('single-account') if not found. * * @param cloudProvider - The cloud provider (aws, azure, gcp) * @param packagePolicy - The package policy containing account type vars * @param packageInfo - The package info for storage mode detection - * @returns Account type ('single-account' or 'organization-account') or undefined if not found + * @returns Account type ('single-account' or 'organization-account') */ export function extractAccountType( cloudProvider: CloudProvider, packagePolicy: NewPackagePolicy, packageInfo: PackageInfo -): AccountType | undefined { - // Use accessor to get vars from the correct location (package-level or input-level) +): AccountType { + // Check provider-specific vars (legacy approach for CSPM) const vars = extractRawCredentialVars(packagePolicy, packageInfo); - if (!vars) { - return undefined; - } + if (vars) { + let rawAccountType: string | undefined; - let rawAccountType: string | undefined; + if (cloudProvider === 'aws') { + rawAccountType = vars[AWS_ACCOUNT_TYPE_VAR_NAME]?.value; + } else if (cloudProvider === 'azure') { + rawAccountType = vars[AZURE_ACCOUNT_TYPE_VAR_NAME]?.value; + } - if (cloudProvider === 'aws') { - rawAccountType = vars[AWS_ACCOUNT_TYPE_VAR_NAME]?.value; - } else if (cloudProvider === 'azure') { - rawAccountType = vars[AZURE_ACCOUNT_TYPE_VAR_NAME]?.value; + const validated = validateAccountType(rawAccountType); + if (validated) { + return validated; + } } - return validateAccountType(rawAccountType); + // Default to single-account when not specified + return CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE; } /** diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts index cf39c21f169f7..c8bbf1b1d3881 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts @@ -25,6 +25,7 @@ import { LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE, PACKAGE_POLICY_SAVED_OBJECT_TYPE, } from '../../common/constants'; +import { CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE } from '../../common/constants/cloud_connector'; import { PackagePolicyMocks } from '../mocks/package_policy.mocks'; import type { @@ -843,6 +844,7 @@ describe('Package policy service', () => { }, }, cloudProvider: 'aws', + accountType: CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE, }); } finally { // Restore the original method @@ -1476,6 +1478,7 @@ describe('Package policy service', () => { }, }, cloudProvider: 'aws', + accountType: CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE, }); // Verify the name was auto-generated with the correct format diff --git a/x-pack/platform/plugins/shared/fleet/server/services/secrets/cloud_connector.ts b/x-pack/platform/plugins/shared/fleet/server/services/secrets/cloud_connector.ts index db0253afa2e9d..e7a8f6bd692af 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/secrets/cloud_connector.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/secrets/cloud_connector.ts @@ -155,7 +155,7 @@ async function extractAzureCloudConnectorSecrets( const schema = getCredentialSchema('azure'); const tenantIdKeys = getAllVarKeys(schema.fields.tenantId); const clientIdKeys = getAllVarKeys(schema.fields.clientId); - const connectorIdKeys = getAllVarKeys(schema.fields.azureCredentialsCloudConnectorId); + const connectorIdKeys = getAllVarKeys(schema.fields.azure_credentials_cloud_connector_id); // Look for Azure vars using schema-defined keys const tenantIdVar = findFirstVarEntry(vars, tenantIdKeys); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/aws_credentials_form/aws_credentials_form_agentless.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/aws_credentials_form/aws_credentials_form_agentless.tsx index f3d4bdb79b207..db5a78b28c677 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/aws_credentials_form/aws_credentials_form_agentless.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/aws_credentials_form/aws_credentials_form_agentless.tsx @@ -346,7 +346,6 @@ export const AwsCredentialsFormAgentless = ({ }> )} diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/azure_credentials_form/azure_credentials_form_agentless.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/azure_credentials_form/azure_credentials_form_agentless.tsx index 95239fd99f4b6..5ec5723c77461 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/azure_credentials_form/azure_credentials_form_agentless.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/public/src/components/fleet_extensions/azure_credentials_form/azure_credentials_form_agentless.tsx @@ -19,12 +19,18 @@ import type { import type { SetupTechnology } from '@kbn/fleet-plugin/common/types'; import { LazyCloudConnectorSetup } from '@kbn/fleet-plugin/public'; import type { CloudSetup } from '@kbn/cloud-plugin/public'; +import { SINGLE_ACCOUNT } from '@kbn/fleet-plugin/common'; import { ARM_TEMPLATE_EXTERNAL_DOC_URL, AZURE_CREDENTIALS_TYPE, AZURE_PROVIDER, + SUPPORTED_TEMPLATES_URL_FROM_PACKAGE_INFO_INPUT_VARS, } from '../constants'; -import { getCloudCredentialVarsConfig, updatePolicyWithInputs } from '../utils'; +import { + getCloudCredentialVarsConfig, + getTemplateUrlFromPackageInfo, + updatePolicyWithInputs, +} from '../utils'; import type { AzureOptions } from './get_azure_credentials_form_options'; import { getAgentlessCredentialsType, @@ -77,6 +83,7 @@ export const AzureCredentialsFormAgentless = ({ const { azureOverviewPath, azurePolicyType, isAzureCloudConnectorEnabled, templateName } = useCloudSetup(); + const accountType = input?.streams?.[0].vars?.['azure.account_type']?.value ?? SINGLE_ACCOUNT; const azureCredentialsType = getAgentlessCredentialsType(input, isAzureCloudConnectorEnabled); const credentialSelectionDisabled = isEditPage && @@ -148,7 +155,6 @@ export const AzureCredentialsFormAgentless = ({ {azureCredentialsType === 'cloud_connectors' && isAzureCloudConnectorEnabled ? ( }> ) : (