[Cloud Security] [CIS GCP] GCP Organization option (#166983)

## Summary

This PR is for adding the GCP Organization option as well as updating
the Single option to include Project ID field. Still rough

Changes: 
- Added GCP Organization Option
- Project ID field now exist on Google Cloud Shell Single option as well
as Organization Option
- Organization ID field added to the form when user chose account_type :
GCP Organization
- Project ID are now optional (previously users aren't able to save the
integration without filling in the Project ID)
- Removed Beta tag for CIS GCP

TODO:
- Make sure previous installation using previous wont break because of
the new fields and requirement (migration)
- More tests
- Clean up

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Rickyanto Ang 2023-09-28 14:02:23 -07:00 committed by GitHub
parent 859ae9e50d
commit 8759b03474
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 727 additions and 130 deletions

View file

@ -93,7 +93,6 @@ export const cloudPostureIntegrations: CloudPostureIntegrations = {
defaultMessage: 'CIS GCP',
}),
icon: googleCloudLogo,
isBeta: true,
},
// needs to be a function that disables/enabled based on integration version
{

View file

@ -25,6 +25,7 @@ import type { NewPackagePolicy } from '@kbn/fleet-plugin/public';
import { NewPackagePolicyInput, PackageInfo } from '@kbn/fleet-plugin/common';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { GcpCredentialsType } from '../../../common/types';
import {
CLOUDBEAT_GCP,
SETUP_ACCESS_CLOUD_SHELL,
@ -39,10 +40,12 @@ import {
import { MIN_VERSION_GCP_CIS } from '../../common/constants';
import { cspIntegrationDocsNavigation } from '../../common/navigation/constants';
import { ReadDocumentation } from './aws_credentials_form/aws_credentials_form';
import { GCP_ORGANIZATION_ACCOUNT } from './policy_template_form';
export const CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS = {
GOOGLE_CLOUD_SHELL_SETUP: 'google_cloud_shell_setup_test_id',
PROJECT_ID: 'project_id_test_id',
ORGANIZATION_ID: 'organization_id_test_id',
CREDENTIALS_TYPE: 'credentials_type_test_id',
CREDENTIALS_FILE: 'credentials_file_test_id',
CREDENTIALS_JSON: 'credentials_json_test_id',
@ -71,7 +74,21 @@ const GCPSetupInfoContent = () => (
</>
);
const GoogleCloudShellSetup = () => {
const GoogleCloudShellSetup = ({
fields,
onChange,
input,
}: {
fields: Array<GcpFields[keyof GcpFields] & { value: string; id: string }>;
onChange: (key: string, value: string) => void;
input: NewPackagePolicyInput;
}) => {
const accountType = input.streams?.[0]?.vars?.['gcp.account_type']?.value;
const getFieldById = (id: keyof GcpInputFields['fields']) => {
return fields.find((element) => element.id === id);
};
const projectIdFields = getFieldById('gcp.project_id');
const organizationIdFields = getFieldById('gcp.organization_id');
return (
<>
<EuiText
@ -96,12 +113,22 @@ const GoogleCloudShellSetup = () => {
defaultMessage="Log into your Google Cloud Console"
/>
</li>
<li>
<FormattedMessage
id="xpack.csp.gcpIntegration.cloudShellSetupStep.save"
defaultMessage="Note down the GCP project ID of the project you wish to monitor"
/>
</li>
{accountType === GCP_ORGANIZATION_ACCOUNT ? (
<li>
<FormattedMessage
id="xpack.csp.gcpIntegration.organizationCloudShellSetupStep.save"
defaultMessage="Note down the GCP organization ID of the organization you wish to monitor and project ID where you want to provision resources for monitoring purposes and provide them in the input boxes below"
/>
</li>
) : (
<li>
<FormattedMessage
id="xpack.csp.gcpIntegration.cloudShellSetupStep.save"
defaultMessage="Note down the GCP project ID of the project you wish to monitor"
/>
</li>
)}
<li>
<FormattedMessage
id="xpack.csp.gcpIntegration.cloudShellSetupStep.launch"
@ -111,6 +138,31 @@ const GoogleCloudShellSetup = () => {
</ol>
</EuiText>
<EuiSpacer size="l" />
<EuiForm component="form">
{organizationIdFields && accountType === GCP_ORGANIZATION_ACCOUNT && (
<EuiFormRow fullWidth label={gcpField.fields['gcp.organization_id'].label}>
<EuiFieldText
data-test-subj={CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS.ORGANIZATION_ID}
id={organizationIdFields.id}
fullWidth
value={organizationIdFields.value || ''}
onChange={(event) => onChange(organizationIdFields.id, event.target.value)}
/>
</EuiFormRow>
)}
{projectIdFields && (
<EuiFormRow fullWidth label={gcpField.fields['gcp.project_id'].label}>
<EuiFieldText
data-test-subj={CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS.PROJECT_ID}
id={projectIdFields.id}
fullWidth
value={projectIdFields.value || ''}
onChange={(event) => onChange(projectIdFields.id, event.target.value)}
/>
</EuiFormRow>
)}
</EuiForm>
<EuiSpacer size="m" />
</>
);
};
@ -137,6 +189,12 @@ interface GcpInputFields {
export const gcpField: GcpInputFields = {
fields: {
'gcp.organization_id': {
label: i18n.translate('xpack.csp.gcpIntegration.organizationIdFieldLabel', {
defaultMessage: 'Organization ID',
}),
type: 'text',
},
'gcp.project_id': {
label: i18n.translate('xpack.csp.gcpIntegration.projectidFieldLabel', {
defaultMessage: 'Project ID',
@ -190,17 +248,14 @@ const getSetupFormatOptions = (): Array<{
interface GcpFormProps {
newPolicy: NewPackagePolicy;
input: Extract<
NewPackagePolicyPostureInput,
{ type: 'cloudbeat/cis_aws' | 'cloudbeat/cis_eks' | 'cloudbeat/cis_gcp' }
>;
input: Extract<NewPackagePolicyPostureInput, { type: 'cloudbeat/cis_gcp' }>;
updatePolicy(updatedPolicy: NewPackagePolicy): void;
packageInfo: PackageInfo;
setIsValid: (isValid: boolean) => void;
onChange: any;
}
const getInputVarsFields = (input: NewPackagePolicyInput, fields: GcpFields) =>
export const getInputVarsFields = (input: NewPackagePolicyInput, fields: GcpFields) =>
Object.entries(input.streams[0].vars || {})
.filter(([id]) => id in fields)
.map(([id, inputVar]) => {
@ -290,6 +345,10 @@ const useCloudShellUrl = ({
}, [newPolicy?.vars?.cloud_shell_url, newPolicy, packageInfo, setupFormat]);
};
export const getGcpCredentialsType = (
input: Extract<NewPackagePolicyPostureInput, { type: 'cloudbeat/cis_gcp' }>
): GcpCredentialsType | undefined => input.streams[0].vars?.setup_access.value;
export const GcpCredentialsForm = ({
input,
newPolicy,
@ -298,6 +357,12 @@ export const GcpCredentialsForm = ({
setIsValid,
onChange,
}: GcpFormProps) => {
/* Create a subset of properties from GcpField to use for hiding value of credentials json and credentials file when user switch from Manual to Cloud Shell, we wanna keep Project and Organization ID */
const subsetOfGcpField = (({ ['gcp.credentials.file']: a, ['gcp.credentials.json']: b }) => ({
'gcp.credentials.file': a,
['gcp.credentials.json']: b,
}))(gcpField.fields);
const fieldsToHide = getInputVarsFields(input, subsetOfGcpField);
const fields = getInputVarsFields(input, gcpField.fields);
const validSemantic = semverValid(packageInfo.version);
const integrationVersionNumberOnly = semverCoerce(validSemantic) || '';
@ -305,55 +370,11 @@ export const GcpCredentialsForm = ({
const fieldsSnapshot = useRef({});
const lastSetupAccessType = useRef<string | undefined>(undefined);
const setupFormat = getSetupFormatFromInput(input);
const getFieldById = (id: keyof GcpInputFields['fields']) => {
return fields.find((element) => element.id === id);
};
useCloudShellUrl({
packageInfo,
newPolicy,
updatePolicy,
setupFormat,
});
const onSetupFormatChange = (newSetupFormat: SetupFormatGCP) => {
if (newSetupFormat === SETUP_ACCESS_CLOUD_SHELL) {
// We need to store the current manual fields to restore them later
fieldsSnapshot.current = Object.fromEntries(
fields.map((field) => [field.id, { value: field.value }])
);
// We need to store the last manual credentials type to restore it later
lastSetupAccessType.current = input.streams[0].vars?.setup_access?.value;
updatePolicy(
getPosturePolicy(newPolicy, input.type, {
setup_access: {
value: SETUP_ACCESS_CLOUD_SHELL,
type: 'text',
},
// Clearing fields from previous setup format to prevent exposing credentials
// when switching from manual to cloud formation
...Object.fromEntries(fields.map((field) => [field.id, { value: undefined }])),
})
);
} else {
updatePolicy(
getPosturePolicy(newPolicy, input.type, {
setup_access: {
// Restoring last manual credentials type
value: SETUP_ACCESS_MANUAL,
type: 'text',
},
// Restoring fields from manual setup format if any
...fieldsSnapshot.current,
})
);
}
};
const accountType = input.streams?.[0]?.vars?.['gcp.account_type']?.value;
const isOrganization = accountType === 'organization-account';
// Integration is Invalid IF Version is not at least 1.5.0 OR Setup Access is manual but Project ID is empty
useEffect(() => {
const isProjectIdEmpty =
setupFormat === SETUP_ACCESS_MANUAL && !getFieldById('gcp.project_id')?.value;
const isInvalidPolicy = isInvalid || isProjectIdEmpty;
const isInvalidPolicy = isInvalid;
setIsValid(!isInvalidPolicy);
@ -362,7 +383,48 @@ export const GcpCredentialsForm = ({
updatedPolicy: newPolicy,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [input, packageInfo, setupFormat]);
}, [setupFormat, input.type]);
useCloudShellUrl({
packageInfo,
newPolicy,
updatePolicy,
setupFormat,
});
const onSetupFormatChange = (newSetupFormat: SetupFormatGCP) => {
if (newSetupFormat === 'google_cloud_shell') {
// We need to store the current manual fields to restore them later
fieldsSnapshot.current = Object.fromEntries(
fieldsToHide.map((field) => [field.id, { value: field.value }])
);
// We need to store the last manual credentials type to restore it later
lastSetupAccessType.current = getGcpCredentialsType(input);
updatePolicy(
getPosturePolicy(newPolicy, input.type, {
setup_access: {
value: 'google_cloud_shell',
type: 'text',
},
// Clearing fields from previous setup format to prevent exposing credentials
// when switching from manual to cloud formation
...Object.fromEntries(fieldsToHide.map((field) => [field.id, { value: undefined }])),
})
);
} else {
updatePolicy(
getPosturePolicy(newPolicy, input.type, {
setup_access: {
// Restoring last manual credentials type
value: lastSetupAccessType.current || SETUP_ACCESS_MANUAL,
type: 'text',
},
// Restoring fields from manual setup format if any
...fieldsSnapshot.current,
})
);
}
};
if (isInvalid) {
return (
@ -385,19 +447,29 @@ export const GcpCredentialsForm = ({
size="s"
options={getSetupFormatOptions()}
idSelected={setupFormat}
onChange={onSetupFormatChange}
onChange={(idSelected: SetupFormatGCP) =>
idSelected !== setupFormat && onSetupFormatChange(idSelected)
}
/>
<EuiSpacer size="l" />
{setupFormat === SETUP_ACCESS_MANUAL ? (
{setupFormat === SETUP_ACCESS_CLOUD_SHELL ? (
<GoogleCloudShellSetup
fields={fields}
onChange={(key, value) =>
updatePolicy(getPosturePolicy(newPolicy, input.type, { [key]: { value } }))
}
input={input}
/>
) : (
<GcpInputVarFields
fields={fields}
onChange={(key, value) =>
updatePolicy(getPosturePolicy(newPolicy, input.type, { [key]: { value } }))
}
isOrganization={isOrganization}
/>
) : (
<GoogleCloudShellSetup />
)}
<EuiSpacer size="s" />
<ReadDocumentation url={cspIntegrationDocsNavigation.cspm.getStartedPath} />
<EuiSpacer />
@ -408,13 +480,18 @@ export const GcpCredentialsForm = ({
const GcpInputVarFields = ({
fields,
onChange,
isOrganization,
}: {
fields: Array<GcpFields[keyof GcpFields] & { value: string; id: string }>;
onChange: (key: string, value: string) => void;
isOrganization: boolean;
}) => {
const getFieldById = (id: keyof GcpInputFields['fields']) => {
return fields.find((element) => element.id === id);
};
const organizationIdFields = getFieldById('gcp.organization_id');
const projectIdFields = getFieldById('gcp.project_id');
const credentialsTypeFields = getFieldById('gcp.credentials.type');
const credentialFilesFields = getFieldById('gcp.credentials.file');
@ -428,6 +505,17 @@ const GcpInputVarFields = ({
return (
<div>
<EuiForm component="form">
{organizationIdFields && isOrganization && (
<EuiFormRow fullWidth label={gcpField.fields['gcp.organization_id'].label}>
<EuiFieldText
data-test-subj={CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS.ORGANIZATION_ID}
id={organizationIdFields.id}
fullWidth
value={organizationIdFields.value || ''}
onChange={(event) => onChange(organizationIdFields.id, event.target.value)}
/>
</EuiFormRow>
)}
{projectIdFields && (
<EuiFormRow fullWidth label={gcpField.fields['gcp.project_id'].label}>
<EuiFieldText

View file

@ -154,9 +154,11 @@ const getPolicyMock = (
const gcpVarsMock = {
'gcp.project_id': { type: 'text' },
'gcp.organization_id': { type: 'text' },
'gcp.credentials.file': { type: 'text' },
'gcp.credentials.json': { type: 'text' },
'gcp.credentials.type': { type: 'text' },
'gcp.account_type': { value: 'organization-account', type: 'text' },
};
const azureVarsMock = {

View file

@ -10,6 +10,8 @@ import {
CspPolicyTemplateForm,
AWS_ORGANIZATION_ACCOUNT,
AWS_SINGLE_ACCOUNT,
GCP_ORGANIZATION_ACCOUNT,
GCP_SINGLE_ACCOUNT,
} from './policy_template_form';
import { TestProvider } from '../../test/test_provider';
import {
@ -1011,7 +1013,7 @@ describe('<CspPolicyTemplateForm />', () => {
it(`renders Google Cloud Shell forms when Setup Access is set to Google Cloud Shell`, () => {
let policy = getMockPolicyGCP();
policy = getPosturePolicy(policy, CLOUDBEAT_GCP, {
credentials_type: { value: 'credentials-file' },
'gcp.account_type': { value: GCP_ORGANIZATION_ACCOUNT },
setup_access: { value: 'google_cloud_shell' },
});
@ -1028,31 +1030,6 @@ describe('<CspPolicyTemplateForm />', () => {
).toBeInTheDocument();
});
it(`project ID is required for Manual users`, () => {
let policy = getMockPolicyGCP();
policy = getPosturePolicy(policy, CLOUDBEAT_GCP, {
'gcp.project_id': { value: undefined },
setup_access: { value: 'manual' },
});
const { rerender } = render(
<WrappedComponent newPolicy={policy} packageInfo={getMockPackageInfoCspmGCP()} />
);
expect(onChange).toHaveBeenCalledWith({
isValid: false,
updatedPolicy: policy,
});
policy = getPosturePolicy(policy, CLOUDBEAT_GCP, {
'gcp.project_id': { value: '' },
setup_access: { value: 'manual' },
});
rerender(<WrappedComponent newPolicy={policy} packageInfo={getMockPackageInfoCspmGCP()} />);
expect(onChange).toHaveBeenCalledWith({
isValid: false,
updatedPolicy: policy,
});
});
it(`renders ${CLOUDBEAT_GCP} Credentials File fields`, () => {
let policy = getMockPolicyGCP();
policy = getPosturePolicy(policy, CLOUDBEAT_GCP, {
@ -1136,6 +1113,96 @@ describe('<CspPolicyTemplateForm />', () => {
updatedPolicy: policy,
});
});
it(`${CLOUDBEAT_GCP} form do not displays upgrade message for supported versions and gcp organization option is enabled`, () => {
let policy = getMockPolicyGCP();
policy = getPosturePolicy(policy, CLOUDBEAT_GCP, {
'gcp.credentials.type': { value: 'manual' },
'gcp.account_type': { value: GCP_ORGANIZATION_ACCOUNT },
});
const { queryByText, getByLabelText } = render(
<WrappedComponent newPolicy={policy} packageInfo={{ version: '1.6.0' } as PackageInfo} />
);
expect(
queryByText(
'GCP Organization not supported in current integration version. Please upgrade to the latest version to enable GCP Organizations integration.'
)
).not.toBeInTheDocument();
expect(getByLabelText('GCP Organization')).toBeEnabled();
});
it(`renders ${CLOUDBEAT_GCP} Organization fields when account type is Organization and Setup Access is Google Cloud Shell`, () => {
let policy = getMockPolicyGCP();
policy = getPosturePolicy(policy, CLOUDBEAT_GCP, {
'gcp.account_type': { value: GCP_ORGANIZATION_ACCOUNT },
setup_access: { value: 'google_cloud_shell' },
});
const { getByLabelText, getByTestId } = render(
<WrappedComponent newPolicy={policy} packageInfo={getMockPackageInfoCspmGCP()} />
);
expect(getByTestId(CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS.ORGANIZATION_ID)).toBeInTheDocument();
expect(getByLabelText('Organization ID')).toBeInTheDocument();
});
it(`renders ${CLOUDBEAT_GCP} Organization fields when account type is Organization and Setup Access is manual`, () => {
let policy = getMockPolicyGCP();
policy = getPosturePolicy(policy, CLOUDBEAT_GCP, {
'gcp.account_type': { value: GCP_ORGANIZATION_ACCOUNT },
setup_access: { value: 'manual' },
});
const { getByLabelText, getByTestId } = render(
<WrappedComponent newPolicy={policy} packageInfo={getMockPackageInfoCspmGCP()} />
);
expect(getByTestId(CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS.ORGANIZATION_ID)).toBeInTheDocument();
expect(getByLabelText('Organization ID')).toBeInTheDocument();
});
it(`Should not render ${CLOUDBEAT_GCP} Organization fields when account type is Single`, () => {
let policy = getMockPolicyGCP();
policy = getPosturePolicy(policy, CLOUDBEAT_GCP, {
'gcp.account_type': { value: GCP_SINGLE_ACCOUNT },
setup_access: { value: 'google_cloud_shell' },
});
const { queryByLabelText, queryByTestId } = render(
<WrappedComponent newPolicy={policy} packageInfo={getMockPackageInfoCspmGCP()} />
);
expect(queryByTestId(CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS.ORGANIZATION_ID)).toBeNull();
expect(queryByLabelText('Organization ID')).toBeNull();
});
it(`updates ${CLOUDBEAT_GCP} organization id`, () => {
let policy = getMockPolicyGCP();
policy = getPosturePolicy(policy, CLOUDBEAT_GCP, {
'gcp.account_type': { value: GCP_ORGANIZATION_ACCOUNT },
setup_access: { value: 'manual' },
});
const { getByTestId } = render(
<WrappedComponent newPolicy={policy} packageInfo={getMockPackageInfoCspmGCP()} />
);
userEvent.type(getByTestId(CIS_GCP_INPUT_FIELDS_TEST_SUBJECTS.ORGANIZATION_ID), 'c');
policy = getPosturePolicy(policy, CLOUDBEAT_GCP, {
'gcp.organization_id': { value: 'c' },
});
expect(onChange).toHaveBeenCalledWith({
isValid: true,
updatedPolicy: policy,
});
});
});
describe('Azure Credentials input fields', () => {

View file

@ -4,9 +4,11 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import semverCompare from 'semver/functions/compare';
import semverValid from 'semver/functions/valid';
import semverCoerce from 'semver/functions/coerce';
import semverLt from 'semver/functions/lt';
import {
EuiCallOut,
EuiFieldText,
@ -54,6 +56,7 @@ import {
PolicyTemplateVarsForm,
} from './policy_template_selectors';
import { usePackagePolicyList } from '../../common/api/use_package_policy_list';
import { gcpField, getInputVarsFields } from './gcp_credential_form';
const DEFAULT_INPUT_TYPE = {
kspm: CLOUDBEAT_VANILLA,
@ -82,13 +85,14 @@ interface IntegrationInfoFieldsProps {
export const AWS_SINGLE_ACCOUNT = 'single-account';
export const AWS_ORGANIZATION_ACCOUNT = 'organization-account';
export const GCP_SINGLE_ACCOUNT = 'single-account-gcp';
export const GCP_ORGANIZATION_ACCOUNT = 'organization-account-gcp';
export const GCP_SINGLE_ACCOUNT = 'single-account';
export const GCP_ORGANIZATION_ACCOUNT = 'organization-account';
export const AZURE_SINGLE_ACCOUNT = 'single-account-azure';
export const AZURE_ORGANIZATION_ACCOUNT = 'organization-account-azure';
type AwsAccountType = typeof AWS_SINGLE_ACCOUNT | typeof AWS_ORGANIZATION_ACCOUNT;
type AzureAccountType = typeof AZURE_SINGLE_ACCOUNT | typeof AZURE_ORGANIZATION_ACCOUNT;
type GcpAccountType = typeof GCP_SINGLE_ACCOUNT | typeof GCP_ORGANIZATION_ACCOUNT;
const getAwsAccountTypeOptions = (isAwsOrgDisabled: boolean): CspRadioGroupProps['options'] => [
{
@ -111,19 +115,18 @@ const getAwsAccountTypeOptions = (isAwsOrgDisabled: boolean): CspRadioGroupProps
},
];
const getGcpAccountTypeOptions = (): CspRadioGroupProps['options'] => [
const getGcpAccountTypeOptions = (isGcpOrgDisabled: boolean): CspRadioGroupProps['options'] => [
{
id: GCP_ORGANIZATION_ACCOUNT,
label: i18n.translate('xpack.csp.fleetIntegration.gcpAccountType.gcpOrganizationLabel', {
defaultMessage: 'GCP Organization',
}),
disabled: true,
tooltip: i18n.translate(
'xpack.csp.fleetIntegration.gcpAccountType.gcpOrganizationDisabledTooltip',
{
defaultMessage: 'Coming Soon',
}
),
disabled: isGcpOrgDisabled,
tooltip: isGcpOrgDisabled
? i18n.translate('xpack.csp.fleetIntegration.gcpAccountType.gcpOrganizationDisabledTooltip', {
defaultMessage: 'Supported from integration version 1.6.0 and above',
})
: undefined,
},
{
id: GCP_SINGLE_ACCOUNT,
@ -258,6 +261,12 @@ const AwsAccountTypeSelect = ({
);
};
const getGcpAccountType = (
input: Extract<NewPackagePolicyPostureInput, { type: 'cloudbeat/cis_gcp' }>
): GcpAccountType | undefined => input.streams[0].vars?.['gcp.account_type']?.value;
const GCP_ORG_MINIMUM_PACKAGE_VERSION = '1.6.0';
const GcpAccountTypeSelect = ({
input,
newPolicy,
@ -269,6 +278,71 @@ const GcpAccountTypeSelect = ({
updatePolicy: (updatedPolicy: NewPackagePolicy) => void;
packageInfo: PackageInfo;
}) => {
// This will disable the gcp org option for any version below 1.6.0 which introduced support for account_type. https://github.com/elastic/integrations/pull/6682
const validSemantic = semverValid(packageInfo.version);
const integrationVersionNumberOnly = semverCoerce(validSemantic) || '';
const isGcpOrgDisabled = semverLt(integrationVersionNumberOnly, GCP_ORG_MINIMUM_PACKAGE_VERSION);
const gcpAccountTypeOptions = useMemo(
() => getGcpAccountTypeOptions(isGcpOrgDisabled),
[isGcpOrgDisabled]
);
/* Create a subset of properties from GcpField to use for hiding value of Organization ID when switching account type from Organization to Single */
const subsetOfGcpField = (({ ['gcp.organization_id']: a }) => ({ 'gcp.organization_id': a }))(
gcpField.fields
);
const fieldsToHide = getInputVarsFields(input, subsetOfGcpField);
const fieldsSnapshot = useRef({});
const lastSetupAccessType = useRef<string | undefined>(undefined);
const onSetupFormatChange = (newSetupFormat: string) => {
if (newSetupFormat === 'single-account') {
// We need to store the current manual fields to restore them later
fieldsSnapshot.current = Object.fromEntries(
fieldsToHide.map((field) => [field.id, { value: field.value }])
);
// We need to store the last manual credentials type to restore it later
lastSetupAccessType.current = input.streams[0].vars?.['gcp.account_type'].value;
updatePolicy(
getPosturePolicy(newPolicy, input.type, {
'gcp.account_type': {
value: 'single-account',
type: 'text',
},
// Clearing fields from previous setup format to prevent exposing credentials
// when switching from manual to cloud formation
...Object.fromEntries(fieldsToHide.map((field) => [field.id, { value: undefined }])),
})
);
} else {
updatePolicy(
getPosturePolicy(newPolicy, input.type, {
'gcp.account_type': {
// Restoring last manual credentials type
value: lastSetupAccessType.current || 'organization-account',
type: 'text',
},
// Restoring fields from manual setup format if any
...fieldsSnapshot.current,
})
);
}
};
useEffect(() => {
if (!getGcpAccountType(input)) {
updatePolicy(
getPosturePolicy(newPolicy, input.type, {
'gcp.account_type': {
value: isGcpOrgDisabled ? GCP_SINGLE_ACCOUNT : GCP_ORGANIZATION_ACCOUNT,
type: 'text',
},
})
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [input]);
return (
<>
<EuiText color="subdued" size="s">
@ -278,28 +352,47 @@ const GcpAccountTypeSelect = ({
/>
</EuiText>
<EuiSpacer size="l" />
{isGcpOrgDisabled && (
<>
<EuiCallOut color="warning">
<FormattedMessage
id="xpack.csp.fleetIntegration.gcpAccountType.gcpOrganizationNotSupportedMessage"
defaultMessage="GCP Organization not supported in current integration version. Please upgrade to the latest version to enable GCP Organizations integration."
/>
</EuiCallOut>
<EuiSpacer size="l" />
</>
)}
<RadioGroup
idSelected={GCP_SINGLE_ACCOUNT}
options={getGcpAccountTypeOptions()}
onChange={(accountType) => {
updatePolicy(
getPosturePolicy(newPolicy, input.type, {
gcp_account_type: {
value: accountType,
type: 'text',
},
})
);
}}
idSelected={getGcpAccountType(input) || ''}
options={gcpAccountTypeOptions}
onChange={(accountType) =>
accountType !== getGcpAccountType(input) && onSetupFormatChange(accountType)
}
size="m"
/>
<EuiSpacer size="l" />
<EuiText color="subdued" size="s">
<FormattedMessage
id="xpack.csp.fleetIntegration.gcpAccountType.singleAccountDescription"
defaultMessage="Deploying to a single account is suitable for an initial POC. To ensure complete coverage, it is strongly recommended to deploy CSPM at the organization-level, which automatically connects all accounts (both current and future)."
/>
</EuiText>
{getGcpAccountType(input) === GCP_ORGANIZATION_ACCOUNT && (
<>
<EuiSpacer size="l" />
<EuiText color="subdued" size="s">
<FormattedMessage
id="xpack.csp.fleetIntegration.gcpAccountType.gcpOrganizationDescription"
defaultMessage="Connect Elastic to every GCP Project (current and future) in your environment by providing Elastic with read-only (configuration) access to your GCP organization"
/>
</EuiText>
</>
)}
{getGcpAccountType(input) === GCP_SINGLE_ACCOUNT && (
<>
<EuiSpacer size="l" />
<EuiText color="subdued" size="s">
<FormattedMessage
id="xpack.csp.fleetIntegration.gcpAccountType.gcpSingleAccountDescription"
defaultMessage="Deploying to a single project is suitable for an initial POC. To ensure compete coverage, it is strongly recommended to deploy CSPM at the organization-level, which automatically connects all projects (both current and future)."
/>
</EuiText>
</>
)}
</>
);
};

View file

@ -30,6 +30,7 @@ import {
} from '../../../../../hooks';
import { GoogleCloudShellGuide } from '../../../../../components';
import { ManualInstructions } from '../../../../../../../components/enrollment_instructions';
import { getGcpIntegrationDetailsFromPackagePolicy } from '../../../../../../../services';
export const PostInstallGoogleCloudShellModal: React.FunctionComponent<{
onConfirm: () => void;
@ -46,6 +47,8 @@ export const PostInstallGoogleCloudShellModal: React.FunctionComponent<{
);
const { fleetServerHosts, fleetProxy } = useFleetServerHostsForPolicy(agentPolicy);
const agentVersion = useAgentVersion();
const { gcpProjectId, gcpOrganizationId, gcpAccountType } =
getGcpIntegrationDetailsFromPackagePolicy(packagePolicy);
const { cloudShellUrl, error, isError, isLoading } = useCreateCloudShellUrl({
enrollmentAPIKey: apyKeysData?.data?.items[0]?.api_key,
@ -61,6 +64,9 @@ export const PostInstallGoogleCloudShellModal: React.FunctionComponent<{
fleetServerHosts,
fleetProxy,
agentVersion,
gcpProjectId,
gcpOrganizationId,
gcpAccountType,
});
return (
@ -75,7 +81,10 @@ export const PostInstallGoogleCloudShellModal: React.FunctionComponent<{
</EuiModalHeader>
<EuiModalBody>
<GoogleCloudShellGuide commandText={installManagedCommands.googleCloudShell} />
<GoogleCloudShellGuide
commandText={installManagedCommands.googleCloudShell}
hasProjectId={!!gcpProjectId}
/>
{error && isError && (
<>
<EuiSpacer size="m" />

View file

@ -14,15 +14,17 @@ import { GoogleCloudShellGuide } from '../google_cloud_shell_guide';
interface Props {
cloudShellUrl: string;
cloudShellCommand: string;
projectId?: string;
}
export const GoogleCloudShellInstructions: React.FunctionComponent<Props> = ({
cloudShellUrl,
cloudShellCommand,
projectId,
}) => {
return (
<>
<GoogleCloudShellGuide commandText={cloudShellCommand} />
<GoogleCloudShellGuide commandText={cloudShellCommand} hasProjectId={!!projectId} />
<EuiSpacer size="m" />
<EuiButton
color="primary"

View file

@ -14,7 +14,11 @@ import type { EuiContainedStepProps } from '@elastic/eui/src/components/steps/st
import type { FullAgentPolicy } from '../../../../common/types/models/agent_policy';
import { fullAgentPolicyToYaml, agentPolicyRouteService } from '../../../services';
import {
fullAgentPolicyToYaml,
agentPolicyRouteService,
getGcpIntegrationDetailsFromAgentPolicy,
} from '../../../services';
import { StandaloneInstructions, ManualInstructions } from '../../enrollment_instructions';
@ -221,6 +225,9 @@ export const ManagedSteps: React.FunctionComponent<InstructionProps> = ({
const agentVersion = useAgentVersion();
const { gcpProjectId, gcpOrganizationId, gcpAccountType } =
getGcpIntegrationDetailsFromAgentPolicy(selectedPolicy);
const fleetServerHost = fleetServerHosts?.[0];
const installManagedCommands = ManualInstructions({
@ -228,6 +235,9 @@ export const ManagedSteps: React.FunctionComponent<InstructionProps> = ({
fleetServerHosts,
fleetProxy,
agentVersion: agentVersion || '',
gcpProjectId,
gcpOrganizationId,
gcpAccountType,
});
const instructionsSteps = useMemo(() => {
@ -273,6 +283,7 @@ export const ManagedSteps: React.FunctionComponent<InstructionProps> = ({
selectedApiKeyId,
cloudShellUrl: cloudSecurityIntegration.cloudShellUrl,
cloudShellCommand: installManagedCommands.googleCloudShell,
projectId: gcpProjectId,
})
);
} else if (cloudSecurityIntegration?.isAzureArmTemplate) {
@ -343,6 +354,7 @@ export const ManagedSteps: React.FunctionComponent<InstructionProps> = ({
enrolledAgentIds,
agentDataConfirmed,
installedPackagePolicy,
gcpProjectId,
]);
if (!agentVersion) {

View file

@ -21,12 +21,14 @@ export const InstallGoogleCloudShellManagedAgentStep = ({
isComplete,
cloudShellUrl,
cloudShellCommand,
projectId,
}: {
selectedApiKeyId?: string;
apiKeyData?: GetOneEnrollmentAPIKeyResponse | null;
isComplete?: boolean;
cloudShellUrl?: string | undefined;
cloudShellCommand?: string;
projectId?: string;
}): EuiContainedStepProps => {
const nonCompleteStatus = selectedApiKeyId ? undefined : 'disabled';
const status = isComplete ? 'complete' : nonCompleteStatus;
@ -41,6 +43,7 @@ export const InstallGoogleCloudShellManagedAgentStep = ({
<GoogleCloudShellInstructions
cloudShellUrl={cloudShellUrl || ''}
cloudShellCommand={cloudShellCommand || ''}
projectId={projectId || ''}
/>
) : (
<React.Fragment />

View file

@ -28,11 +28,17 @@ export const ManualInstructions = ({
fleetServerHosts,
fleetProxy,
agentVersion: agentVersion,
gcpProjectId = '<PROJECT_ID>',
gcpOrganizationId = '<ORGANIZATION_ID>',
gcpAccountType,
}: {
apiKey: string;
fleetServerHosts: string[];
fleetProxy?: FleetProxy;
agentVersion: string;
gcpProjectId?: string;
gcpOrganizationId?: string;
gcpAccountType?: string;
}) => {
const enrollArgs = getfleetServerHostsEnrollArgs(apiKey, fleetServerHosts, fleetProxy);
const fleetServerUrl = enrollArgs?.split('--url=')?.pop()?.split('--enrollment')[0];
@ -64,7 +70,9 @@ sudo elastic-agent enroll ${enrollArgs} \nsudo systemctl enable elastic-agent \n
sudo rpm -vi elastic-agent-${agentVersion}-x86_64.rpm
sudo elastic-agent enroll ${enrollArgs} \nsudo systemctl enable elastic-agent \nsudo systemctl start elastic-agent`;
const googleCloudShellCommand = `gcloud config set project <PROJECT_ID> && \nFLEET_URL=${fleetServerUrl} ENROLLMENT_TOKEN=${enrollmentToken} STACK_VERSION=${agentVersion} ./deploy.sh`;
const googleCloudShellCommand = `gcloud config set project ${gcpProjectId} && ${
gcpAccountType === 'organization-account' ? `\nORG_ID=${gcpOrganizationId}` : ``
} \nFLEET_URL=${fleetServerUrl} ENROLLMENT_TOKEN=${enrollmentToken} \nSTACK_VERSION=${agentVersion} ./deploy.sh`;
return {
linux: linuxCommand,

View file

@ -23,7 +23,7 @@ const Link = ({ children, url }: { children: React.ReactNode; url: string }) =>
</EuiLink>
);
export const GoogleCloudShellGuide = (props: { commandText: string }) => {
export const GoogleCloudShellGuide = (props: { commandText: string; hasProjectId?: boolean }) => {
return (
<>
<EuiSpacer size="xs" />
@ -48,10 +48,17 @@ export const GoogleCloudShellGuide = (props: { commandText: string }) => {
<ol>
<li>
<>
<FormattedMessage
id="xpack.fleet.googleCloudShell.guide.steps.copy"
defaultMessage="Replace <PROJECT_ID> in the following command with your project ID and copy the command"
/>
{props?.hasProjectId ? (
<FormattedMessage
id="xpack.fleet.googleCloudShell.guide.steps.copyWithProjectId"
defaultMessage="Copy the command below"
/>
) : (
<FormattedMessage
id="xpack.fleet.googleCloudShell.guide.steps.copyWithoutProjectId"
defaultMessage="Replace <PROJECT_ID> in the following command with your project ID and copy the command"
/>
)}
<EuiSpacer size="m" />
<EuiCodeBlock language="bash" isCopyable contentEditable="true">
{props.commandText}

View file

@ -0,0 +1,101 @@
/*
* 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 { getGcpIntegrationDetailsFromAgentPolicy } from './get_gcp_integration_details_from_agent_policy';
const undefinedAllValue = {
gcpAccountType: undefined,
gcpOrganizationId: undefined,
gcpProjectId: undefined,
};
describe('getGcpIntegrationDetailsFromAgentPolicy', () => {
test('returns undefined when agentPolicy is undefined', () => {
const result = getGcpIntegrationDetailsFromAgentPolicy(undefined);
expect(result).toEqual(undefinedAllValue);
});
test('returns undefined when agentPolicy is defined but inputs are empty', () => {
const selectedPolicy = { inputs: [] };
// @ts-expect-error
const result = getGcpIntegrationDetailsFromAgentPolicy(selectedPolicy);
expect(result).toEqual(undefinedAllValue);
});
it('should return undefined when no input has enabled and gcp integration details', () => {
const selectedPolicy = {
package_policies: [
{
inputs: [
{ enabled: false, streams: [{}] },
{ enabled: true, streams: [{ vars: { other_property: 'false' } }] },
{ enabled: true, streams: [{ other_property: 'False' }] },
],
},
{
inputs: [
{ enabled: false, streams: [{}] },
{ enabled: false, streams: [{}] },
],
},
],
};
// @ts-expect-error
const result = getGcpIntegrationDetailsFromAgentPolicy(selectedPolicy);
expect(result).toEqual(undefinedAllValue);
});
it('should return the first gcp integration details when available', () => {
const selectedPolicy = {
package_policies: [
{
inputs: [
{ enabled: false, streams: [{}] },
{ enabled: true, streams: [{ vars: { other_property: 'false' } }] },
{ enabled: true, streams: [{ other_property: 'False' }] },
],
},
{
inputs: [
{ enabled: false, streams: [{}] },
{
enabled: true,
streams: [
{
vars: {
'gcp.account_type': { value: 'account_type_test_1' },
'gcp.project_id': { value: 'project_id_1' },
'gcp.organization_id': { value: 'organization_id_1' },
},
},
],
},
{
enabled: true,
streams: [
{
vars: {
'gcp.account_type': { value: 'account_type_test_2' },
'gcp.project_id': { value: 'project_id_2' },
'gcp.organization_id': { value: 'organization_id_2' },
},
},
],
},
],
},
],
};
// @ts-expect-error
const result = getGcpIntegrationDetailsFromAgentPolicy(selectedPolicy);
expect(result).toEqual({
gcpAccountType: 'account_type_test_1',
gcpOrganizationId: 'organization_id_1',
gcpProjectId: 'project_id_1',
});
});
// Add more test cases as needed
});

View file

@ -0,0 +1,71 @@
/*
* 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 { AgentPolicy } from '../types';
/**
* Get the project id, organization id and account type of gcp integration from an agent policy
*/
export const getGcpIntegrationDetailsFromAgentPolicy = (selectedPolicy?: AgentPolicy) => {
let gcpProjectId = selectedPolicy?.package_policies?.reduce((acc, packagePolicy) => {
const findGcpProjectId = packagePolicy.inputs?.reduce((accInput, input) => {
if (accInput !== '') {
return accInput;
}
if (input?.enabled && input?.streams[0]?.vars?.['gcp.project_id']?.value) {
return input?.streams[0]?.vars?.['gcp.project_id']?.value;
}
return accInput;
}, '');
if (findGcpProjectId) {
return findGcpProjectId;
}
return acc;
}, '');
let gcpOrganizationId = selectedPolicy?.package_policies?.reduce((acc, packagePolicy) => {
const findGcpProjectId = packagePolicy.inputs?.reduce((accInput, input) => {
if (accInput !== '') {
return accInput;
}
if (input?.enabled && input?.streams[0]?.vars?.['gcp.organization_id']?.value) {
return input?.streams[0]?.vars?.['gcp.organization_id']?.value;
}
return accInput;
}, '');
if (findGcpProjectId) {
return findGcpProjectId;
}
return acc;
}, '');
let gcpAccountType = selectedPolicy?.package_policies?.reduce((acc, packagePolicy) => {
const findGcpProjectId = packagePolicy.inputs?.reduce((accInput, input) => {
if (accInput !== '') {
return accInput;
}
if (input?.enabled && input?.streams[0]?.vars?.['gcp.account_type']?.value) {
return input?.streams[0]?.vars?.['gcp.account_type']?.value;
}
return accInput;
}, '');
if (findGcpProjectId) {
return findGcpProjectId;
}
return acc;
}, '');
gcpProjectId = gcpProjectId !== '' ? gcpProjectId : undefined;
gcpOrganizationId = gcpOrganizationId !== '' ? gcpOrganizationId : undefined;
gcpAccountType = gcpAccountType !== '' ? gcpAccountType : undefined;
return {
gcpProjectId,
gcpOrganizationId,
gcpAccountType,
};
};

View file

@ -0,0 +1,80 @@
/*
* 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 { getGcpIntegrationDetailsFromPackagePolicy } from './get_gcp_integration_details_from_package_policy';
const undefinedAllValue = {
gcpAccountType: undefined,
gcpOrganizationId: undefined,
gcpProjectId: undefined,
};
describe('getGcpIntegrationDetailsFromPackagePolicy', () => {
test('returns undefined when packagePolicy is undefined', () => {
const result = getGcpIntegrationDetailsFromPackagePolicy(undefined);
expect(result).toEqual(undefinedAllValue);
});
test('returns undefined when packagePolicy is defined but inputs are empty', () => {
const packagePolicy = { inputs: [] };
// @ts-expect-error
const result = getGcpIntegrationDetailsFromPackagePolicy(packagePolicy);
expect(result).toEqual(undefinedAllValue);
});
it('should return undefined when no input has enabled and gcp integration details', () => {
const packagePolicy = {
inputs: [
{ enabled: false, streams: [{}] },
{ enabled: true, streams: [{ vars: { other_property: 'false' } }] },
{ enabled: true, streams: [{ other_property: 'False' }] },
],
};
// @ts-expect-error
const result = getGcpIntegrationDetailsFromPackagePolicy(packagePolicy);
expect(result).toEqual(undefinedAllValue);
});
it('should return the first gcp integration details when available', () => {
const packagePolicy = {
inputs: [
{ enabled: false, streams: [{}] },
{
enabled: true,
streams: [
{
vars: {
'gcp.account_type': { value: 'account_type_test_1' },
'gcp.project_id': { value: 'project_id_1' },
'gcp.organization_id': { value: 'organization_id_1' },
},
},
],
},
{
enabled: true,
streams: [
{
vars: {
'gcp.account_type': { value: 'account_type_test_2' },
'gcp.project_id': { value: 'project_id_2' },
'gcp.organization_id': { value: 'organization_id_2' },
},
},
],
},
],
};
// @ts-expect-error
const result = getGcpIntegrationDetailsFromPackagePolicy(packagePolicy);
expect(result).toEqual({
gcpAccountType: 'account_type_test_1',
gcpOrganizationId: 'organization_id_1',
gcpProjectId: 'project_id_1',
});
});
// Add more test cases as needed
});

View file

@ -0,0 +1,53 @@
/*
* 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 { PackagePolicy } from '../types';
/**
* Get the project id, organization id and account type of gcp integration from a package policy
*/
export const getGcpIntegrationDetailsFromPackagePolicy = (packagePolicy?: PackagePolicy) => {
let gcpProjectId = packagePolicy?.inputs?.reduce((accInput, input) => {
if (accInput !== '') {
return accInput;
}
if (input?.enabled && input?.streams[0]?.vars?.['gcp.project_id']?.value) {
return input?.streams[0]?.vars?.['gcp.project_id']?.value;
}
return accInput;
}, '');
let gcpOrganizationId = packagePolicy?.inputs?.reduce((accInput, input) => {
if (accInput !== '') {
return accInput;
}
if (input?.enabled && input?.streams[0]?.vars?.['gcp.organization_id']?.value) {
return input?.streams[0]?.vars?.['gcp.organization_id']?.value;
}
return accInput;
}, '');
let gcpAccountType = packagePolicy?.inputs?.reduce((accInput, input) => {
if (accInput !== '') {
return accInput;
}
if (input?.enabled && input?.streams[0]?.vars?.['gcp.account_type']?.value) {
return input?.streams[0]?.vars?.['gcp.account_type']?.value;
}
return accInput;
}, '');
gcpProjectId = gcpProjectId !== '' ? gcpProjectId : undefined;
gcpOrganizationId = gcpOrganizationId !== '' ? gcpOrganizationId : undefined;
gcpAccountType = gcpAccountType !== '' ? gcpAccountType : undefined;
return {
gcpProjectId,
gcpOrganizationId,
gcpAccountType,
};
};

View file

@ -53,3 +53,5 @@ export { getTemplateUrlFromAgentPolicy } from './get_template_url_from_agent_pol
export { getTemplateUrlFromPackageInfo } from './get_template_url_from_package_info';
export { getCloudShellUrlFromPackagePolicy } from './get_cloud_shell_url_from_package_policy';
export { getCloudShellUrlFromAgentPolicy } from './get_cloud_shell_url_from_agent_policy';
export { getGcpIntegrationDetailsFromPackagePolicy } from './get_gcp_integration_details_from_package_policy';
export { getGcpIntegrationDetailsFromAgentPolicy } from './get_gcp_integration_details_from_agent_policy';