Wizard: add compliance step

The Insights compliance support reuses most of the existing OpenSCAP
step.

Depending on the state of the feature flag, it will show radio buttons
allowing users to switch between regular openscap and Insights
compliance.
This commit is contained in:
Sanne Raymaekers 2024-09-11 12:11:37 +02:00 committed by Klara Simickova
parent 571fe18861
commit e43357bf55
3 changed files with 326 additions and 94 deletions

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Alert, Alert,
@ -18,6 +18,7 @@ import { v4 as uuidv4 } from 'uuid';
import OscapProfileInformation from './OscapProfileInformation'; import OscapProfileInformation from './OscapProfileInformation';
import { usePoliciesQuery, PolicyRead } from '../../../../store/complianceApi';
import { useAppDispatch, useAppSelector } from '../../../../store/hooks'; import { useAppDispatch, useAppSelector } from '../../../../store/hooks';
import { import {
DistributionProfileItem, DistributionProfileItem,
@ -29,9 +30,11 @@ import {
Services, Services,
} from '../../../../store/imageBuilderApi'; } from '../../../../store/imageBuilderApi';
import { import {
changeOscapProfile, changeCompliance,
selectDistribution, selectDistribution,
selectProfile, selectComplianceProfileID,
selectCompliancePolicyID,
selectCompliancePolicyTitle,
addPackage, addPackage,
addPartition, addPartition,
changeFileSystemConfigurationType, changeFileSystemConfigurationType,
@ -42,47 +45,125 @@ import {
changeMaskedServices, changeMaskedServices,
changeDisabledServices, changeDisabledServices,
changeKernelAppend, changeKernelAppend,
selectComplianceType,
} from '../../../../store/wizardSlice'; } from '../../../../store/wizardSlice';
import { useHasSpecificTargetOnly } from '../../utilities/hasSpecificTargetOnly'; import { useHasSpecificTargetOnly } from '../../utilities/hasSpecificTargetOnly';
import { parseSizeUnit } from '../../utilities/parseSizeUnit'; import { parseSizeUnit } from '../../utilities/parseSizeUnit';
import { Partition, Units } from '../FileSystem/FileSystemConfiguration'; import { Partition, Units } from '../FileSystem/FileSystemConfiguration';
const OpenSCAPFGLabel = () => {
return (
<>
OpenSCAP profile
<Popover
maxWidth="30rem"
bodyContent={
<TextContent>
<Text>
To run a manual compliance scan in OpenSCAP, download this image.
</Text>
</TextContent>
}
>
<Button
variant="plain"
aria-label="About OpenSCAP"
isInline
className="pf-u-pl-sm pf-u-pt-0 pf-u-pb-0 pf-u-pr-0"
>
<HelpIcon />
</Button>
</Popover>
</>
);
};
const ProfileSelector = () => { const ProfileSelector = () => {
const oscapProfile = useAppSelector(selectProfile); const policyID = useAppSelector(selectCompliancePolicyID);
const policyTitle = useAppSelector(selectCompliancePolicyTitle);
const profileID = useAppSelector(selectComplianceProfileID);
const release = useAppSelector(selectDistribution); const release = useAppSelector(selectDistribution);
const majorVersion = release.split('-')[1];
const hasWslTargetOnly = useHasSpecificTargetOnly('wsl'); const hasWslTargetOnly = useHasSpecificTargetOnly('wsl');
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const complianceType = useAppSelector(selectComplianceType);
const { const {
data: profiles, data: profiles,
isFetching, isFetching,
isSuccess, isSuccess,
isError, isError,
refetch, refetch,
} = useGetOscapProfilesQuery({ } = useGetOscapProfilesQuery(
distribution: release, {
}); distribution: release,
},
{
skip: complianceType === 'compliance',
}
);
const {
data: policies,
isFetching: isFetchingPolicies,
isSuccess: isSuccessPolicies,
} = usePoliciesQuery(
{
filter: `os_major_version=${majorVersion}`,
},
{
skip: complianceType === 'openscap',
}
);
const { data: currentProfileData } = useGetOscapCustomizationsQuery( const { data: currentProfileData } = useGetOscapCustomizationsQuery(
{ {
distribution: release, distribution: release,
// @ts-ignore if openScapProfile is undefined the query is going to get skipped // @ts-ignore if openScapProfile is undefined the query is going to get skipped
profile: oscapProfile, profile: profileID,
}, },
{ skip: !oscapProfile } { skip: !profileID }
); );
const [trigger] = useLazyGetOscapCustomizationsQuery(); const [trigger] = useLazyGetOscapCustomizationsQuery();
useEffect(() => {
if (!policies || policies.data === undefined) {
return;
}
if (policyID && !policyTitle) {
for (const p of policies.data) {
const pol = p as PolicyRead;
if (pol.id === policyID) {
dispatch(
changeCompliance({
policyID: pol.id,
profileID: pol.ref_id,
policyTitle: pol.title,
})
);
}
}
}
}, [isSuccessPolicies]);
const handleToggle = () => { const handleToggle = () => {
if (!isOpen) { if (!isOpen && complianceType === 'openscap') {
refetch(); refetch();
} }
setIsOpen(!isOpen); setIsOpen(!isOpen);
}; };
const handleClear = () => { const handleClear = () => {
dispatch(changeOscapProfile(undefined)); dispatch(
changeCompliance({
profileID: undefined,
policyID: undefined,
policyTitle: undefined,
})
);
clearOscapPackages(currentProfileData?.packages || []); clearOscapPackages(currentProfileData?.packages || []);
dispatch(changeFileSystemConfigurationType('automatic')); dispatch(changeFileSystemConfigurationType('automatic'));
handleServices(undefined); handleServices(undefined);
@ -142,9 +223,9 @@ const ProfileSelector = () => {
const handleSelect = ( const handleSelect = (
_event: React.MouseEvent<Element, MouseEvent>, _event: React.MouseEvent<Element, MouseEvent>,
selection: OScapSelectOptionValueType selection: OScapSelectOptionValueType | ComplianceSelectOptionValueType
) => { ) => {
if (selection.id === undefined) { if (selection.profileID === undefined) {
// handle user has selected 'None' case // handle user has selected 'None' case
handleClear(); handleClear();
} else { } else {
@ -152,7 +233,7 @@ const ProfileSelector = () => {
trigger( trigger(
{ {
distribution: release, distribution: release,
profile: selection.id, profile: selection.profileID as DistributionProfileItem,
}, },
true // preferCacheValue true // preferCacheValue
) )
@ -164,7 +245,24 @@ const ProfileSelector = () => {
handlePackages(oldOscapPackages, newOscapPackages); handlePackages(oldOscapPackages, newOscapPackages);
handleServices(response.services); handleServices(response.services);
dispatch(changeKernelAppend(response.kernel?.append || '')); dispatch(changeKernelAppend(response.kernel?.append || ''));
dispatch(changeOscapProfile(selection.id)); if (complianceType === 'openscap') {
dispatch(
changeCompliance({
profileID: selection.profileID,
policyID: undefined,
policyTitle: undefined,
})
);
} else {
const compl = selection as ComplianceSelectOptionValueType;
dispatch(
changeCompliance({
profileID: compl.profileID,
policyID: compl.policyID,
policyTitle: compl.toString(),
})
);
}
}); });
} }
setIsOpen(false); setIsOpen(false);
@ -180,67 +278,77 @@ const ProfileSelector = () => {
} }
}; };
const complianceOptions = () => {
if (!policies || policies.data === undefined) {
return [];
}
const res = [<ComplianceNoneOption key="compliance-non-option" />];
for (const p of policies.data) {
if (p === undefined) {
continue;
}
const pol = p as PolicyRead;
res.push(<ComplianceSelectOption key={pol.id} policy={pol} />);
}
return res;
};
return ( return (
<FormGroup <FormGroup
isRequired={true} isRequired={true}
data-testid="profiles-form-group" data-testid="profiles-form-group"
label={ label={complianceType === 'openscap' ? <OpenSCAPFGLabel /> : <>Policy</>}
<>
OpenSCAP profile
<Popover
maxWidth="30rem"
bodyContent={
<TextContent>
<Text>
To run a manual compliance scan in OpenSCAP, download this
image.
</Text>
</TextContent>
}
>
<Button
variant="plain"
aria-label="About OpenSCAP"
isInline
className="pf-u-pl-sm pf-u-pt-0 pf-u-pb-0 pf-u-pr-0"
>
<HelpIcon />
</Button>
</Popover>
</>
}
> >
<Select {complianceType === 'openscap' && (
loadingVariant={isFetching ? 'spinner' : undefined} <Select
ouiaId="profileSelect" loadingVariant={isFetching ? 'spinner' : undefined}
variant={SelectVariant.typeahead} ouiaId="profileSelect"
onToggle={handleToggle} variant={SelectVariant.typeahead}
onSelect={handleSelect} onToggle={handleToggle}
onClear={handleClear} onSelect={handleSelect}
maxHeight="300px" onClear={handleClear}
selections={oscapProfile} maxHeight="300px"
isOpen={isOpen} selections={profileID}
placeholderText="Select a profile" isOpen={isOpen}
typeAheadAriaLabel="Select a profile" placeholderText="Select a profile"
isDisabled={!isSuccess || hasWslTargetOnly} typeAheadAriaLabel="Select a profile"
onFilter={(_event, value) => { isDisabled={!isSuccess || hasWslTargetOnly}
if (profiles) { onFilter={(_event, value) => {
return [<OScapNoneOption key="oscap-none-option" />].concat( if (profiles) {
profiles.map((profile_id, index) => { return [<OScapNoneOption key="oscap-none-option" />].concat(
return ( profiles.map((profile_id, index) => {
<OScapSelectOption return (
key={index} <OScapSelectOption
profile_id={profile_id} key={index}
filter={value} profile_id={profile_id}
/> filter={value}
); />
}) );
); })
);
}
}}
>
{options()}
</Select>
)}
{complianceType === 'compliance' && (
<Select
isDisabled={isFetchingPolicies}
isOpen={isOpen}
onSelect={handleSelect}
onToggle={handleToggle}
selections={
isFetchingPolicies
? 'Loading policies'
: policyTitle || policyID || 'None'
} }
}} ouiaId="compliancePolicySelect"
> >
{options()} {complianceOptions()}
</Select> </Select>
)}
{isError && ( {isError && (
<Alert <Alert
title="Error fetching the profiles" title="Error fetching the profiles"
@ -267,7 +375,7 @@ type OScapSelectOptionPropType = {
}; };
type OScapSelectOptionValueType = { type OScapSelectOptionValueType = {
id: DistributionProfileItem; profileID: DistributionProfileItem;
toString: () => string; toString: () => string;
}; };
@ -291,7 +399,7 @@ const OScapSelectOption = ({
id: DistributionProfileItem, id: DistributionProfileItem,
name?: string name?: string
): OScapSelectOptionValueType => ({ ): OScapSelectOptionValueType => ({
id, profileID: id,
toString: () => name || '', toString: () => name || '',
}); });
@ -303,9 +411,52 @@ const OScapSelectOption = ({
/> />
); );
}; };
type ComplianceSelectOptionPropType = {
policy: PolicyRead;
};
type ComplianceSelectOptionValueType = {
policyID: string;
profileID: string;
toString: () => string;
};
const ComplianceNoneOption = () => {
return (
<SelectOption value={{ toString: () => 'None', compareTo: () => false }} />
);
};
const ComplianceSelectOption = ({ policy }: ComplianceSelectOptionPropType) => {
const selectObj = (
policyID: string,
profileID: string,
title: string
): ComplianceSelectOptionValueType => ({
policyID,
profileID,
toString: () => title,
});
const descr = (
<>
Threshold: {policy.compliance_threshold}
<br />
Active systems: {policy.total_system_count}
</>
);
return (
<SelectOption
key={policy.id}
value={selectObj(policy.id!, policy.ref_id!, policy.title!)}
description={descr}
/>
);
};
export const Oscap = () => { export const Oscap = () => {
const oscapProfile = useAppSelector(selectProfile); const oscapProfile = useAppSelector(selectComplianceProfileID);
const environments = useAppSelector(selectImageTypes); const environments = useAppSelector(selectImageTypes);
return ( return (

View file

@ -1,21 +1,44 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Button, Form, Text, Title } from '@patternfly/react-core'; import {
Button,
Form,
FormGroup,
Radio,
Text,
Title,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons'; import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { useFlag } from '@unleash/proxy-client-react';
import { Oscap } from './Oscap'; import { Oscap } from './Oscap';
import { COMPLIANCE_AND_VULN_SCANNING_URL } from '../../../../constants'; import {
COMPLIANCE_AND_VULN_SCANNING_URL,
COMPLIANCE_PROD_URL,
COMPLIANCE_STAGE_URL,
} from '../../../../constants';
import { imageBuilderApi } from '../../../../store/enhancedImageBuilderApi'; import { imageBuilderApi } from '../../../../store/enhancedImageBuilderApi';
import { useAppSelector } from '../../../../store/hooks'; import { useAppDispatch, useAppSelector } from '../../../../store/hooks';
import { selectDistribution } from '../../../../store/wizardSlice'; import {
ComplianceType,
changeComplianceType,
selectDistribution,
selectComplianceType,
} from '../../../../store/wizardSlice';
import { useGetEnvironment } from '../../../../Utilities/useGetEnvironment';
const OscapStep = () => { const OscapStep = () => {
const dispatch = useAppDispatch();
const complianceEnabled = useFlag('image-builder.compliance.enabled');
const complianceType = useAppSelector(selectComplianceType);
const prefetchOscapProfile = imageBuilderApi.usePrefetch( const prefetchOscapProfile = imageBuilderApi.usePrefetch(
'getOscapProfiles', 'getOscapProfiles',
{} {}
); );
const { isProd } = useGetEnvironment();
const release = useAppSelector(selectDistribution); const release = useAppSelector(selectDistribution);
useEffect(() => { useEffect(() => {
prefetchOscapProfile({ distribution: release }); prefetchOscapProfile({ distribution: release });
// This useEffect hook should run *only* on mount and therefore has an empty // This useEffect hook should run *only* on mount and therefore has an empty
@ -26,24 +49,78 @@ const OscapStep = () => {
return ( return (
<Form> <Form>
<Title headingLevel="h1" size="xl"> <Title headingLevel="h1" size="xl">
OpenSCAP profile {complianceEnabled ? 'Compliance' : 'OpenSCAP profile'}
</Title> </Title>
<Text> {complianceEnabled && (
OpenSCAP enables you to automatically monitor the adherence of your <FormGroup>
registered RHEL systems to a selected regulatory compliance profile. <Radio
<br /> id="openscap radio openscap type"
<Button label="OpenSCAP"
component="a" name="oscap-radio-openscap"
target="_blank" isChecked={complianceType === 'openscap'}
variant="link" onChange={() =>
icon={<ExternalLinkAltIcon />} dispatch(changeComplianceType('openscap' as ComplianceType))
iconPosition="right" }
isInline />
href={COMPLIANCE_AND_VULN_SCANNING_URL} <Radio
> id="openscap radio compliance type"
Documentation label="Insights compliance"
</Button> name="oscap-radio-compliance"
</Text> isChecked={complianceType === 'compliance'}
onChange={() =>
dispatch(changeComplianceType('compliance' as ComplianceType))
}
/>
</FormGroup>
)}
{(!complianceEnabled || complianceType === 'openscap') && (
<Text>
OpenSCAP enables you to automatically monitor the adherence of your
registered RHEL systems to a selected regulatory compliance profile.
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={COMPLIANCE_AND_VULN_SCANNING_URL}
>
Documentation
</Button>
</Text>
)}
{complianceType === 'compliance' && (
<Text>
Insights compliance enables you to monitor the adherence of your
registered RHEL systems to a selected compliance policy.
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={isProd() ? COMPLIANCE_PROD_URL : COMPLIANCE_STAGE_URL}
>
Define new policies in Insights Compliance
</Button>
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={COMPLIANCE_AND_VULN_SCANNING_URL}
>
Documentation
</Button>
</Text>
)}
<Oscap /> <Oscap />
</Form> </Form>
); );

View file

@ -24,6 +24,10 @@ export const RELEASE_LIFECYCLE_URL =
'https://access.redhat.com/support/policy/updates/errata'; 'https://access.redhat.com/support/policy/updates/errata';
export const AZURE_AUTH_URL = export const AZURE_AUTH_URL =
'https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow'; 'https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow';
export const COMPLIANCE_PROD_URL =
'https://console.redhat.com/insights/compliance/scappolicies';
export const COMPLIANCE_STAGE_URL =
'https://console.stage.redhat.com/insights/compliance/scappolicies';
export const ACTIVATION_KEYS_PROD_URL = export const ACTIVATION_KEYS_PROD_URL =
'https://console.redhat.com/insights/connector/activation-keys'; 'https://console.redhat.com/insights/connector/activation-keys';
export const ACTIVATION_KEYS_STAGE_URL = export const ACTIVATION_KEYS_STAGE_URL =