[Fleet] Do not allow namespace or dataset to be edited for input only package policies (#148422)

## Summary

Part of #145529.

Input packages will create component and index templates on package
policy creation. These changes make it so that to change the namespace
or dataset of an input only package the user must create a new package
policy, this is because by changing these the user is sending the data
to a new destination which semantically is a different policy.

 Question: what do we publicly call package policies? Here is the new
text I have added:

<img width="674" alt="Screenshot 2023-01-04 at 21 05 15"
src="https://user-images.githubusercontent.com/3315046/210650968-79460ff4-dd52-47bd-beb6-a0ace608bcbb.png">
This commit is contained in:
Mark Hopkin 2023-01-11 15:14:12 +00:00 committed by GitHub
parent 0707da5be1
commit f1ede739a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 252 additions and 35 deletions

View file

@ -20,7 +20,7 @@ const DATA_STREAM_DATASET_VAR: RegistryVarsEntry = {
type: 'text',
title: 'Dataset name',
description:
"Set the name for your dataset. Changing the dataset will send the data to a different index. You can't use `-` in the name of a dataset and only valid characters for [Elasticsearch index names](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html).\n",
"Set the name for your dataset. Once selected a dataset cannot be changed without creating a new integration policy. You can't use `-` in the name of a dataset and only valid characters for [Elasticsearch index names](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html) are permitted.\n",
multi: false,
required: true,
show_user: true,

View file

@ -13,7 +13,8 @@ export const DatasetComboBox: React.FC<{
value: any;
onChange: (newValue: any) => void;
datasets: string[];
}> = ({ value, onChange, datasets }) => {
isDisabled?: boolean;
}> = ({ value, onChange, datasets, isDisabled }) => {
const datasetOptions = datasets.map((dataset: string) => ({ label: dataset })) ?? [];
const defaultOption = 'generic';
const [selectedOptions, setSelectedOptions] = useState<Array<{ label: string }>>([
@ -42,7 +43,6 @@ export const DatasetComboBox: React.FC<{
setSelectedOptions([newOption]);
onChange(searchValue);
};
return (
<EuiComboBox
aria-label={i18n.translate('xpack.fleet.datasetCombo.ariaLabel', {
@ -61,6 +61,7 @@ export const DatasetComboBox: React.FC<{
values: { searchValue: '{searchValue}' },
})}
isClearable={false}
isDisabled={isDisabled}
/>
);
};

View file

@ -29,6 +29,7 @@ export const PackagePolicyInputConfig: React.FunctionComponent<{
updatePackagePolicyInput: (updatedInput: Partial<NewPackagePolicyInput>) => void;
inputVarsValidationResults: PackagePolicyConfigValidationResults;
forceShowErrors?: boolean;
isEditPage?: boolean;
}> = memo(
({
hasInputStreams,
@ -37,6 +38,7 @@ export const PackagePolicyInputConfig: React.FunctionComponent<{
updatePackagePolicyInput,
inputVarsValidationResults,
forceShowErrors,
isEditPage = false,
}) => {
// Showing advanced options toggle state
const [isShowingAdvanced, setIsShowingAdvanced] = useState<boolean>(false);
@ -121,6 +123,7 @@ export const PackagePolicyInputConfig: React.FunctionComponent<{
}}
errors={inputVarsValidationResults.vars?.[varName]}
forceShowErrors={forceShowErrors}
isEditPage={isEditPage}
/>
</EuiFlexItem>
);
@ -178,6 +181,7 @@ export const PackagePolicyInputConfig: React.FunctionComponent<{
}}
errors={inputVarsValidationResults.vars?.[varName]}
forceShowErrors={forceShowErrors}
isEditPage={isEditPage}
/>
</EuiFlexItem>
);

View file

@ -80,6 +80,7 @@ export const PackagePolicyInputPanel: React.FunctionComponent<{
updatePackagePolicyInput: (updatedInput: Partial<NewPackagePolicyInput>) => void;
inputValidationResults: PackagePolicyInputValidationResults;
forceShowErrors?: boolean;
isEditPage?: boolean;
}> = memo(
({
packageInput,
@ -91,6 +92,7 @@ export const PackagePolicyInputPanel: React.FunctionComponent<{
updatePackagePolicyInput,
inputValidationResults,
forceShowErrors,
isEditPage = false,
}) => {
const defaultDataStreamId = useDataStreamId();
// Showing streams toggle state
@ -213,7 +215,6 @@ export const PackagePolicyInputPanel: React.FunctionComponent<{
{/* Header rule break */}
{isShowingStreams ? <EuiSpacer size="l" /> : null}
{/* Input level policy */}
{isShowingStreams && packageInput.vars && packageInput.vars.length ? (
<Fragment>
@ -224,6 +225,7 @@ export const PackagePolicyInputPanel: React.FunctionComponent<{
updatePackagePolicyInput={updatePackagePolicyInput}
inputVarsValidationResults={{ vars: inputValidationResults?.vars }}
forceShowErrors={forceShowErrors}
isEditPage={isEditPage}
/>
{hasInputStreams ? <ShortenedHorizontalRule margin="m" /> : <EuiSpacer size="l" />}
</Fragment>
@ -273,6 +275,7 @@ export const PackagePolicyInputPanel: React.FunctionComponent<{
inputValidationResults?.streams![packagePolicyInputStream!.data_stream!.dataset]
}
forceShowErrors={forceShowErrors}
isEditPage={isEditPage}
/>
{index !== inputStreams.length - 1 ? (
<>

View file

@ -58,6 +58,7 @@ interface Props {
updatePackagePolicyInputStream: (updatedStream: Partial<NewPackagePolicyInputStream>) => void;
inputStreamValidationResults: PackagePolicyConfigValidationResults;
forceShowErrors?: boolean;
isEditPage?: boolean;
}
export const PackagePolicyInputStreamConfig = memo<Props>(
@ -70,6 +71,7 @@ export const PackagePolicyInputStreamConfig = memo<Props>(
updatePackagePolicyInputStream,
inputStreamValidationResults,
forceShowErrors,
isEditPage,
}) => {
const config = useConfig();
const isExperimentalDataStreamSettingsEnabled =
@ -226,6 +228,7 @@ export const PackagePolicyInputStreamConfig = memo<Props>(
forceShowErrors={forceShowErrors}
packageType={packageInfo.type}
datasets={datasets}
isEditPage={isEditPage}
/>
</EuiFlexItem>
);
@ -287,6 +290,7 @@ export const PackagePolicyInputStreamConfig = memo<Props>(
forceShowErrors={forceShowErrors}
packageType={packageInfo.type}
datasets={datasets}
isEditPage={isEditPage}
/>
</EuiFlexItem>
);

View file

@ -40,6 +40,7 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{
frozen?: boolean;
packageType?: string;
datasets?: string[];
isEditPage?: boolean;
}> = memo(
({
varDef,
@ -50,6 +51,7 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{
frozen,
packageType,
datasets = [],
isEditPage = false,
}) => {
const [isDirty, setIsDirty] = useState<boolean>(false);
const { multi, required, type, title, name, description } = varDef;
@ -68,9 +70,15 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{
/>
);
}
if (name === 'data_stream.dataset' && packageType === 'input') {
return <DatasetComboBox datasets={datasets} value={value} onChange={onChange} />;
return (
<DatasetComboBox
datasets={datasets}
value={value}
onChange={onChange}
isDisabled={isEditPage}
/>
);
}
switch (type) {
case 'textarea':
@ -152,7 +160,19 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{
/>
);
}
}, [isInvalid, multi, onChange, type, value, fieldLabel, frozen, datasets, name, packageType]);
}, [
multi,
name,
packageType,
type,
value,
onChange,
frozen,
datasets,
isEditPage,
isInvalid,
fieldLabel,
]);
// Boolean cannot be optional by default set to false
const isOptional = useMemo(() => type !== 'bool' && !required, [required, type]);

View file

@ -37,6 +37,7 @@ export const StepConfigurePackagePolicy: React.FunctionComponent<{
validationResults: PackagePolicyValidationResults;
submitAttempted: boolean;
noTopRule?: boolean;
isEditPage?: boolean;
}> = ({
packageInfo,
showOnlyIntegration,
@ -45,6 +46,7 @@ export const StepConfigurePackagePolicy: React.FunctionComponent<{
validationResults,
submitAttempted,
noTopRule = false,
isEditPage = false,
}) => {
const hasIntegrations = useMemo(() => doesPackageHaveIntegrations(packageInfo), [packageInfo]);
const packagePolicyTemplates = useMemo(
@ -109,6 +111,7 @@ export const StepConfigurePackagePolicy: React.FunctionComponent<{
]
}
forceShowErrors={submitAttempted}
isEditPage={isEditPage}
/>
<EuiHorizontalRule margin="m" />
</EuiFlexItem>

View file

@ -92,7 +92,7 @@ describe('StepDefinePackagePolicy', () => {
let testRenderer: TestRenderer;
let renderResult: ReturnType<typeof testRenderer.render>;
const render = ({ isUpdate } = { isUpdate: false }) =>
const render = () =>
(renderResult = testRenderer.render(
<StepDefinePackagePolicy
agentPolicy={agentPolicy}
@ -101,7 +101,6 @@ describe('StepDefinePackagePolicy', () => {
updatePackagePolicy={mockUpdatePackagePolicy}
validationResults={validationResults}
submitAttempted={false}
isUpdate={isUpdate}
/>
));
@ -165,7 +164,7 @@ describe('StepDefinePackagePolicy', () => {
describe('update', () => {
describe('when package vars are introduced in a new package version', () => {
it('should display new package vars', () => {
render({ isUpdate: true });
render();
waitFor(async () => {
expect(renderResult.getByDisplayValue('showUserVarVal')).toBeInTheDocument();

View file

@ -50,23 +50,21 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{
agentPolicy?: AgentPolicy;
packageInfo: PackageInfo;
packagePolicy: NewPackagePolicy;
integrationToEnable?: string;
updatePackagePolicy: (fields: Partial<NewPackagePolicy>) => void;
validationResults: PackagePolicyValidationResults;
submitAttempted: boolean;
isUpdate?: boolean;
isEditPage?: boolean;
noAdvancedToggle?: boolean;
}> = memo(
({
agentPolicy,
packageInfo,
packagePolicy,
integrationToEnable,
isUpdate,
updatePackagePolicy,
validationResults,
submitAttempted,
noAdvancedToggle = false,
isEditPage = false,
}) => {
const { docLinks } = useStartServices();
@ -251,7 +249,6 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{
)}
{/* Advanced options content */}
{/* Todo: Populate list of existing namespaces */}
{isShowingAdvanced ? (
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="m">
@ -266,27 +263,35 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{
/>
}
helpText={
<FormattedMessage
id="xpack.fleet.createPackagePolicy.stepConfigure.packagePolicyNamespaceHelpLabel"
defaultMessage="Change the default namespace inherited from the selected Agent policy. This setting changes the name of the integration's data stream. {learnMore}."
values={{
learnMore: (
<EuiLink
href={docLinks.links.fleet.datastreamsNamingScheme}
target="_blank"
>
{i18n.translate(
'xpack.fleet.createPackagePolicy.stepConfigure.packagePolicyNamespaceHelpLearnMoreLabel',
{ defaultMessage: 'Learn more' }
)}
</EuiLink>
),
}}
/>
isEditPage && packageInfo.type === 'input' ? (
<FormattedMessage
id="xpack.fleet.createPackagePolicy.stepConfigure.packagePolicyInputOnlyEditNamespaceHelpLabel"
defaultMessage="The namespace cannot be changed for this integration. Create a new integration policy to use a different namespace."
/>
) : (
<FormattedMessage
id="xpack.fleet.createPackagePolicy.stepConfigure.packagePolicyNamespaceHelpLabel"
defaultMessage="Change the default namespace inherited from the selected Agent policy. This setting changes the name of the integration's data stream. {learnMore}."
values={{
learnMore: (
<EuiLink
href={docLinks.links.fleet.datastreamsNamingScheme}
target="_blank"
>
{i18n.translate(
'xpack.fleet.createPackagePolicy.stepConfigure.packagePolicyNamespaceHelpLearnMoreLabel',
{ defaultMessage: 'Learn more' }
)}
</EuiLink>
),
}}
/>
)
}
>
<EuiComboBox
noSuggestions
isDisabled={isEditPage && packageInfo.type === 'input'}
singleSelection={true}
selectedOptions={
packagePolicy.namespace ? [{ label: packagePolicy.namespace }] : []

View file

@ -284,7 +284,6 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({
updatePackagePolicy={updatePackagePolicy}
validationResults={validationResults!}
submitAttempted={formState === 'INVALID'}
integrationToEnable={integrationInfo?.name}
/>
{/* Only show the out-of-box configuration step if a UI extension is NOT registered */}

View file

@ -286,7 +286,7 @@ export const EditPackagePolicyForm = memo<{
updatePackagePolicy={updatePackagePolicy}
validationResults={validationResults!}
submitAttempted={formState === 'INVALID'}
isUpdate={true}
isEditPage={true}
/>
)}
@ -298,6 +298,7 @@ export const EditPackagePolicyForm = memo<{
updatePackagePolicy={updatePackagePolicy}
validationResults={validationResults!}
submitAttempted={formState === 'INVALID'}
isEditPage={true}
/>
)}

View file

@ -55,6 +55,7 @@ import {
packagePolicyService,
_applyIndexPrivileges,
_compilePackagePolicyInputs,
_validateRestrictedFieldsNotModifiedOrThrow,
} from './package_policy';
import { appContextService } from './app_context';
@ -4578,3 +4579,130 @@ describe('_applyIndexPrivileges()', () => {
expect(streamOut).toEqual(expectedStream);
});
});
describe('_validateRestrictedFieldsNotModifiedOrThrow()', () => {
const pkgInfo = {
name: 'custom_logs',
title: 'Custom Logs',
version: '1.0.0',
type: 'input',
} as any as PackageInfo;
const createInputPkgPolicy = (opts: { namespace: string; dataset: string }) => {
const { namespace, dataset } = opts;
return {
id: 'id-1234',
version: 'WzI1MywxXQ==',
name: 'custom_logs-1',
namespace,
description: '',
enabled: true,
policy_id: '1234',
revision: 1,
created_at: '2023-01-04T14:51:53.061Z',
created_by: 'elastic',
updated_at: '2023-01-04T14:51:53.061Z',
updated_by: 'elastic',
vars: {},
inputs: [
{
type: 'logfile',
policy_template: 'logs',
enabled: true,
streams: [
{
enabled: true,
data_stream: {
type: 'logs',
dataset: 'custom_logs.logs',
},
vars: {
'data_stream.dataset': {
type: 'text',
value: dataset,
},
},
id: 'logfile-custom_logs.logs-1',
},
],
},
],
package: {
name: 'custom_logs',
title: 'Custom Logs',
version: '1.0.0',
},
};
};
it('should not throw if restricted fields are not modified', () => {
const oldPackagePolicy = createInputPkgPolicy({
namespace: 'default',
dataset: 'custom_logs.logs',
});
expect(() =>
_validateRestrictedFieldsNotModifiedOrThrow({
oldPackagePolicy,
packagePolicyUpdate: oldPackagePolicy,
pkgInfo,
})
).not.toThrow();
});
it('should throw if namespace is modified', () => {
const oldPackagePolicy = createInputPkgPolicy({
namespace: 'default',
dataset: 'custom_logs.logs',
});
const newPackagePolicy = createInputPkgPolicy({
namespace: 'new-namespace',
dataset: 'custom_logs.logs',
});
expect(() =>
_validateRestrictedFieldsNotModifiedOrThrow({
oldPackagePolicy,
packagePolicyUpdate: newPackagePolicy,
pkgInfo,
})
).toThrowErrorMatchingInlineSnapshot(
`"Package policy namespace cannot be modified for input only packages, please create a new package policy."`
);
});
it('should throw if dataset is modified', () => {
const oldPackagePolicy = createInputPkgPolicy({
namespace: 'default',
dataset: 'custom_logs.logs',
});
const newPackagePolicy = createInputPkgPolicy({
namespace: 'default',
dataset: 'new-dataset',
});
expect(() =>
_validateRestrictedFieldsNotModifiedOrThrow({
oldPackagePolicy,
packagePolicyUpdate: newPackagePolicy,
pkgInfo,
})
).toThrowErrorMatchingInlineSnapshot(
`"Package policy dataset cannot be modified for input only packages, please create a new package policy."`
);
});
it('should not throw if dataset is modified but package is integration package', () => {
const oldPackagePolicy = createInputPkgPolicy({
namespace: 'default',
dataset: 'custom_logs.logs',
});
const newPackagePolicy = createInputPkgPolicy({
namespace: 'default',
dataset: 'new-dataset',
});
expect(() =>
_validateRestrictedFieldsNotModifiedOrThrow({
oldPackagePolicy,
packagePolicyUpdate: newPackagePolicy,
pkgInfo: { ...pkgInfo, type: 'integration' },
})
).not.toThrow();
});
});

View file

@ -519,7 +519,11 @@ class PackagePolicyClientImpl implements PackagePolicyClient {
pkgVersion: packagePolicy.package.version,
prerelease: true,
});
_validateRestrictedFieldsNotModifiedOrThrow({
pkgInfo,
oldPackagePolicy,
packagePolicyUpdate,
});
validatePackagePolicyOrThrow(packagePolicy, pkgInfo);
inputs = await _compilePackagePolicyInputs(pkgInfo, packagePolicy.vars || {}, inputs);
@ -1950,6 +1954,52 @@ export function preconfigurePackageInputs(
return resultingPackagePolicy;
}
// input only packages cannot have their namespace or dataset modified
export function _validateRestrictedFieldsNotModifiedOrThrow(opts: {
pkgInfo: PackageInfo;
oldPackagePolicy: PackagePolicy;
packagePolicyUpdate: UpdatePackagePolicy;
}) {
const { pkgInfo, oldPackagePolicy, packagePolicyUpdate } = opts;
if (pkgInfo.type !== 'input') return;
const { namespace, inputs } = packagePolicyUpdate;
if (namespace && namespace !== oldPackagePolicy.namespace) {
throw new PackagePolicyValidationError(
i18n.translate('xpack.fleet.updatePackagePolicy.namespaceCannotBeModified', {
defaultMessage:
'Package policy namespace cannot be modified for input only packages, please create a new package policy.',
})
);
}
if (inputs) {
for (const input of inputs) {
const oldInput = oldPackagePolicy.inputs.find((i) => i.id === input.id);
if (oldInput) {
for (const stream of input.streams || []) {
const oldStream = oldInput.streams.find(
(s) => s.data_stream.dataset === stream.data_stream.dataset
);
if (
oldStream &&
oldStream?.vars?.['data_stream.dataset'] &&
oldStream?.vars['data_stream.dataset'] !== stream?.vars?.['data_stream.dataset']
) {
throw new PackagePolicyValidationError(
i18n.translate('xpack.fleet.updatePackagePolicy.datasetCannotBeModified', {
defaultMessage:
'Package policy dataset cannot be modified for input only packages, please create a new package policy.',
})
);
}
}
}
}
}
}
async function validateIsNotHostedPolicy(
soClient: SavedObjectsClientContract,
id: string,