diff --git a/awx/ui/src/api/models/CredentialTypes.js b/awx/ui/src/api/models/CredentialTypes.js index 1c28c56478..72f641c117 100644 --- a/awx/ui/src/api/models/CredentialTypes.js +++ b/awx/ui/src/api/models/CredentialTypes.js @@ -7,7 +7,15 @@ class CredentialTypes extends Base { } async loadAllTypes( - acceptableKinds = ['machine', 'cloud', 'net', 'ssh', 'vault', 'kubernetes'] + acceptableKinds = [ + 'machine', + 'cloud', + 'net', + 'ssh', + 'vault', + 'kubernetes', + 'cryptography', + ] ) { const pageSize = 200; // The number of credential types a user can have is unlimited. In practice, it is unlikely for diff --git a/awx/ui/src/components/PromptDetail/PromptProjectDetail.js b/awx/ui/src/components/PromptDetail/PromptProjectDetail.js index 15cbfd03d7..1407aaa199 100644 --- a/awx/ui/src/components/PromptDetail/PromptProjectDetail.js +++ b/awx/ui/src/components/PromptDetail/PromptProjectDetail.js @@ -125,6 +125,21 @@ function PromptProjectDetail({ resource }) { } /> )} + {summary_fields?.signature_validation_credential?.id && ( + + } + /> + )} {optionsList && ( ', () => { scm_clean: true, scm_track_submodules: false, credential: 100, + signature_validation_credential: 200, local_path: '', organization: { id: 2, name: 'Bar' }, scm_update_on_launch: true, @@ -73,16 +74,32 @@ describe('', () => { }, }; + const cryptographyCredentialResolve = { + data: { + results: [ + { + id: 6, + name: 'GPG Public Key', + kind: 'cryptography', + }, + ], + count: 1, + }, + }; + beforeEach(async () => { await ProjectsAPI.readOptions.mockImplementation( () => projectOptionsResolve ); - await CredentialTypesAPI.read.mockImplementationOnce( + await CredentialTypesAPI.read.mockImplementation( () => scmCredentialResolve ); - await CredentialTypesAPI.read.mockImplementationOnce( + await CredentialTypesAPI.read.mockImplementation( () => insightsCredentialResolve ); + await CredentialTypesAPI.read.mockImplementation( + () => cryptographyCredentialResolve + ); }); afterEach(() => { @@ -110,6 +127,7 @@ describe('', () => { ...projectData, organization: 2, default_environment: 1, + signature_validation_credential: 200, }); }); diff --git a/awx/ui/src/screens/Project/ProjectDetail/ProjectDetail.js b/awx/ui/src/screens/Project/ProjectDetail/ProjectDetail.js index f5cccf0c2b..ea8be50453 100644 --- a/awx/ui/src/screens/Project/ProjectDetail/ProjectDetail.js +++ b/awx/ui/src/screens/Project/ProjectDetail/ProjectDetail.js @@ -124,7 +124,6 @@ function ProjectDetail({ project }) { ); } - const generateLastJobTooltip = (job) => ( <>
{t`MOST RECENT SYNC`}
@@ -149,6 +148,7 @@ function ProjectDetail({ project }) { } else if (summary_fields?.last_job) { job = summary_fields.last_job; } + const getSourceControlUrlHelpText = () => scm_type === 'git' ? projectHelpText.githubSourceControlUrl @@ -234,6 +234,22 @@ function ProjectDetail({ project }) { label={t`Source Control Refspec`} value={scm_refspec} /> + {summary_fields.signature_validation_credential && ( + + } + isEmpty={ + summary_fields.signature_validation_credential.length === 0 + } + /> + )} {summary_fields.credential && ( } + isEmpty={summary_fields.credential.length === 0} /> )} ', () => { name: 'qux', kind: 'scm', }, + signature_validation_credential: { + id: 2000, + name: 'svc', + kind: 'cryptography', + }, last_job: { id: 9000, status: 'successful', @@ -78,6 +83,7 @@ describe('', () => { scm_delete_on_update: true, scm_track_submodules: true, credential: 100, + signature_validation_credential: 200, status: 'successful', organization: 10, scm_update_on_launch: true, @@ -108,6 +114,10 @@ describe('', () => { 'Source Control Credential', `Scm: ${mockProject.summary_fields.credential.name}` ); + assertDetail( + 'Content Signature Validation Credential', + `Cryptography: ${mockProject.summary_fields.signature_validation_credential.name}` + ); assertDetail( 'Cache Timeout', `${mockProject.scm_update_cache_timeout} Seconds` diff --git a/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.js b/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.js index 45399aa1bf..dd3c6b85e0 100644 --- a/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.js +++ b/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.js @@ -18,10 +18,21 @@ function ProjectEdit({ project }) { // the API might throw an unexpected error if our creation request // has a zero-length string as its credential field. As a work-around, // normalize falsey credential fields by deleting them. - delete values.credential; - } else { + values.credential = null; + } else if (typeof values.credential.id === 'number') { values.credential = values.credential.id; } + if (values.scm_type === 'git') { + if (!values.signature_validation_credential) { + values.signature_validation_credential = null; + } else if ( + typeof values.signature_validation_credential.id === 'number' + ) { + values.signature_validation_credential = + values.signature_validation_credential.id; + } + } + try { const { data: { id }, diff --git a/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.test.js b/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.test.js index 903443c86d..4b7193dc12 100644 --- a/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.test.js +++ b/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.test.js @@ -21,6 +21,7 @@ describe('', () => { scm_clean: true, scm_track_submodules: false, credential: 100, + signature_validation_credential: 200, local_path: 'bar', organization: 2, scm_update_on_launch: true, @@ -33,6 +34,12 @@ describe('', () => { credential_type_id: 5, kind: 'insights', }, + signature_validation_credential: { + id: 200, + credential_type_id: 6, + kind: 'cryptography', + name: 'foo', + }, organization: { id: 2, name: 'Default', @@ -60,6 +67,7 @@ describe('', () => { const scmCredentialResolve = { data: { + count: 1, results: [ { id: 4, @@ -72,6 +80,7 @@ describe('', () => { const insightsCredentialResolve = { data: { + count: 1, results: [ { id: 5, @@ -82,6 +91,19 @@ describe('', () => { }, }; + const cryptographyCredentialResolve = { + data: { + count: 1, + results: [ + { + id: 6, + name: 'GPG Public Key', + kind: 'cryptography', + }, + ], + }, + }; + beforeEach(async () => { RootAPI.readAssetVariables.mockResolvedValue({ data: { @@ -91,12 +113,15 @@ describe('', () => { await ProjectsAPI.readOptions.mockImplementation( () => projectOptionsResolve ); - await CredentialTypesAPI.read.mockImplementationOnce( + await CredentialTypesAPI.read.mockImplementation( () => scmCredentialResolve ); - await CredentialTypesAPI.read.mockImplementationOnce( + await CredentialTypesAPI.read.mockImplementation( () => insightsCredentialResolve ); + await CredentialTypesAPI.read.mockImplementation( + () => cryptographyCredentialResolve + ); }); afterEach(() => { diff --git a/awx/ui/src/screens/Project/shared/Project.helptext.js b/awx/ui/src/screens/Project/shared/Project.helptext.js index 26b16efdc5..a592cd9491 100644 --- a/awx/ui/src/screens/Project/shared/Project.helptext.js +++ b/awx/ui/src/screens/Project/shared/Project.helptext.js @@ -105,6 +105,10 @@ const projectHelpTextStrings = { you can input tags, commit hashes, and arbitrary refs. Some commit hashes and refs may not be available unless you also provide a custom refspec.`, + signatureValidation: t`Enable content signing to verify that the content + has remained secure when a project is synced. + If the content has been tampered with, the + job will not run.`, options: { clean: t`Remove any local modifications prior to performing an update.`, delete: t`Delete the local repository in its entirety prior to diff --git a/awx/ui/src/screens/Project/shared/ProjectForm.js b/awx/ui/src/screens/Project/shared/ProjectForm.js index 72df160e73..c968a7ac5a 100644 --- a/awx/ui/src/screens/Project/shared/ProjectForm.js +++ b/awx/ui/src/screens/Project/shared/ProjectForm.js @@ -37,15 +37,22 @@ const fetchCredentials = async (credential) => { results: [insightsCredentialType], }, }, + { + data: { + results: [cryptographyCredentialType], + }, + }, ] = await Promise.all([ CredentialTypesAPI.read({ kind: 'scm' }), CredentialTypesAPI.read({ name: 'Insights' }), + CredentialTypesAPI.read({ kind: 'cryptography' }), ]); if (!credential) { return { scm: { typeId: scmCredentialType.id }, insights: { typeId: insightsCredentialType.id }, + cryptography: { typeId: cryptographyCredentialType.id }, }; } @@ -60,6 +67,13 @@ const fetchCredentials = async (credential) => { value: credential_type_id === insightsCredentialType.id ? credential : null, }, + cryptography: { + typeId: cryptographyCredentialType.id, + value: + credential_type_id === cryptographyCredentialType.id + ? credential + : null, + }, }; }; @@ -69,7 +83,9 @@ function ProjectFormFields({ project_local_paths, formik, setCredentials, + setSignatureValidationCredentials, credentials, + signatureValidationCredentials, scmTypeOptions, setScmSubFormState, scmSubFormState, @@ -79,6 +95,7 @@ function ProjectFormFields({ scm_branch: '', scm_refspec: '', credential: '', + signature_validation_credential: '', scm_clean: false, scm_delete_on_update: false, scm_track_submodules: false, @@ -86,7 +103,6 @@ function ProjectFormFields({ allow_override: false, scm_update_cache_timeout: 0, }; - const { setFieldValue, setFieldTouched } = useFormikContext(); const [scmTypeField, scmTypeMeta, scmTypeHelpers] = useField({ @@ -147,6 +163,19 @@ function ProjectFormFields({ [credentials, setCredentials] ); + const handleSignatureValidationCredentialSelection = useCallback( + (type, value) => { + setSignatureValidationCredentials({ + ...signatureValidationCredentials, + [type]: { + ...signatureValidationCredentials[type], + value, + }, + }); + }, + [signatureValidationCredentials, setSignatureValidationCredentials] + ); + const handleOrganizationUpdate = useCallback( (value) => { setFieldValue('organization', value); @@ -259,7 +288,13 @@ function ProjectFormFields({ git: ( ), @@ -295,7 +330,6 @@ function ProjectFormFields({ ); } - function ProjectForm({ project, submitError, ...props }) { const { handleCancel, handleSubmit } = props; const { summary_fields = {} } = project; @@ -307,6 +341,7 @@ function ProjectForm({ project, submitError, ...props }) { scm_branch: '', scm_refspec: '', credential: '', + signature_validation_credential: '', scm_clean: false, scm_delete_on_update: false, scm_track_submodules: false, @@ -318,12 +353,22 @@ function ProjectForm({ project, submitError, ...props }) { const [credentials, setCredentials] = useState({ scm: { typeId: null, value: null }, insights: { typeId: null, value: null }, + cryptography: { typeId: null, value: null }, }); + const [signatureValidationCredentials, setSignatureValidationCredentials] = + useState({ + scm: { typeId: null, value: null }, + insights: { typeId: null, value: null }, + cryptography: { typeId: null, value: null }, + }); useEffect(() => { async function fetchData() { try { const credentialResponse = fetchCredentials(summary_fields.credential); + const signatureValidationCredentialResponse = fetchCredentials( + summary_fields.signature_validation_credential + ); const { data: { actions: { @@ -335,6 +380,9 @@ function ProjectForm({ project, submitError, ...props }) { } = await ProjectsAPI.readOptions(); setCredentials(await credentialResponse); + setSignatureValidationCredentials( + await signatureValidationCredentialResponse + ); setScmTypeOptions(choices); } catch (error) { setContentError(error); @@ -344,7 +392,10 @@ function ProjectForm({ project, submitError, ...props }) { } fetchData(); - }, [summary_fields.credential]); + }, [ + summary_fields.credential, + summary_fields.signature_validation_credential, + ]); if (isLoading) { return ; @@ -378,6 +429,8 @@ function ProjectForm({ project, submitError, ...props }) { scm_update_cache_timeout: project.scm_update_cache_timeout || 0, scm_update_on_launch: project.scm_update_on_launch || false, scm_url: project.scm_url || '', + signature_validation_credential: + project.signature_validation_credential || '', default_environment: project.summary_fields?.default_environment || null, }} @@ -392,7 +445,11 @@ function ProjectForm({ project, submitError, ...props }) { project_local_paths={project_local_paths} formik={formik} setCredentials={setCredentials} + setSignatureValidationCredentials={ + setSignatureValidationCredentials + } credentials={credentials} + signatureValidationCredentials={signatureValidationCredentials} scmTypeOptions={scmTypeOptions} setScmSubFormState={setScmSubFormState} scmSubFormState={scmSubFormState} diff --git a/awx/ui/src/screens/Project/shared/ProjectForm.test.js b/awx/ui/src/screens/Project/shared/ProjectForm.test.js index 1e5a50814d..342634cf5f 100644 --- a/awx/ui/src/screens/Project/shared/ProjectForm.test.js +++ b/awx/ui/src/screens/Project/shared/ProjectForm.test.js @@ -19,6 +19,7 @@ describe('', () => { scm_clean: true, scm_track_submodules: false, credential: 100, + signature_validation_credential: 200, organization: 2, scm_update_on_launch: true, scm_update_cache_timeout: 3, @@ -35,6 +36,12 @@ describe('', () => { id: 2, name: 'Default', }, + signature_validation_credential: { + id: 200, + credential_type_id: 6, + kind: 'cryptography', + name: 'Svc', + }, }, }; @@ -58,6 +65,7 @@ describe('', () => { const scmCredentialResolve = { data: { + count: 1, results: [ { id: 4, @@ -70,6 +78,7 @@ describe('', () => { const insightsCredentialResolve = { data: { + count: 1, results: [ { id: 5, @@ -80,6 +89,19 @@ describe('', () => { }, }; + const cryptographyCredentialResolve = { + data: { + count: 1, + results: [ + { + id: 6, + name: 'GPG Public Key', + kind: 'cryptography', + }, + ], + }, + }; + beforeEach(async () => { RootAPI.readAssetVariables.mockResolvedValue({ data: { @@ -89,12 +111,15 @@ describe('', () => { await ProjectsAPI.readOptions.mockImplementation( () => projectOptionsResolve ); - await CredentialTypesAPI.read.mockImplementationOnce( + await CredentialTypesAPI.read.mockImplementation( () => scmCredentialResolve ); - await CredentialTypesAPI.read.mockImplementationOnce( + await CredentialTypesAPI.read.mockImplementation( () => insightsCredentialResolve ); + await CredentialTypesAPI.read.mockImplementation( + () => cryptographyCredentialResolve + ); }); afterEach(() => { @@ -153,9 +178,17 @@ describe('', () => { expect( wrapper.find('FormGroup[label="Source Control Refspec"]').length ).toBe(1); + expect( + wrapper.find('FormGroup[label="Content Signature Validation Credential"]') + .length + ).toBe(1); expect( wrapper.find('FormGroup[label="Source Control Credential"]').length ).toBe(1); + expect( + wrapper.find('FormGroup[label="Content Signature Validation Credential"]') + .length + ).toBe(1); expect(wrapper.find('FormGroup[label="Options"]').length).toBe(1); }); @@ -177,21 +210,52 @@ describe('', () => { id: 1, name: 'organization', }); - wrapper.find('CredentialLookup').invoke('onBlur')(); - wrapper.find('CredentialLookup').invoke('onChange')({ + wrapper + .find('CredentialLookup[label="Source Control Credential"]') + .invoke('onBlur')(); + wrapper + .find('CredentialLookup[label="Source Control Credential"]') + .invoke('onChange')({ id: 10, name: 'credential', }); + wrapper + .find( + 'CredentialLookup[label="Content Signature Validation Credential"]' + ) + .invoke('onBlur')(); + wrapper + .find( + 'CredentialLookup[label="Content Signature Validation Credential"]' + ) + .invoke('onChange')({ + id: 20, + name: 'signature_validation_credential', + }); }); wrapper.update(); expect(wrapper.find('OrganizationLookup').prop('value')).toEqual({ id: 1, name: 'organization', }); - expect(wrapper.find('CredentialLookup').prop('value')).toEqual({ + expect( + wrapper + .find('CredentialLookup[label="Source Control Credential"]') + .prop('value') + ).toEqual({ id: 10, name: 'credential', }); + expect( + wrapper + .find( + 'CredentialLookup[label="Content Signature Validation Credential"]' + ) + .prop('value') + ).toEqual({ + id: 20, + name: 'signature_validation_credential', + }); }); test('should display insights credential lookup when source control type is "insights"', async () => { @@ -358,7 +422,9 @@ describe('', () => { }); test('should display ContentError on throw', async () => { - CredentialTypesAPI.read = () => Promise.reject(new Error()); + CredentialTypesAPI.read.mockImplementationOnce(() => + Promise.reject(new Error()) + ); await act(async () => { wrapper = mountWithContexts( diff --git a/awx/ui/src/screens/Project/shared/ProjectSubForms/GitSubForm.js b/awx/ui/src/screens/Project/shared/ProjectSubForms/GitSubForm.js index 533aa05aef..b1b4f956b6 100644 --- a/awx/ui/src/screens/Project/shared/ProjectSubForms/GitSubForm.js +++ b/awx/ui/src/screens/Project/shared/ProjectSubForms/GitSubForm.js @@ -1,6 +1,8 @@ import 'styled-components/macro'; -import React from 'react'; +import React, { useCallback } from 'react'; import { t } from '@lingui/macro'; +import { useFormikContext } from 'formik'; +import CredentialLookup from 'components/Lookup/CredentialLookup'; import FormField from 'components/FormField'; import getDocsBaseUrl from 'util/getDocsBaseUrl'; import { useConfig } from 'contexts/Config'; @@ -16,9 +18,22 @@ import projectHelpStrings from '../Project.helptext'; const GitSubForm = ({ credential, + signature_validation_credential, onCredentialSelection, + onSignatureValidationCredentialSelection, scmUpdateOnLaunch, }) => { + const { setFieldValue, setFieldTouched } = useFormikContext(); + + const onCredentialChange = useCallback( + (value) => { + onSignatureValidationCredentialSelection('cryptography', value); + setFieldValue('signature_validation_credential', value); + setFieldTouched('signature_validation_credential', true, false); + }, + [onSignatureValidationCredentialSelection, setFieldValue, setFieldTouched] + ); + const docsURL = `${getDocsBaseUrl( useConfig() )}/html/userguide/projects.html#manage-playbooks-using-source-control`; @@ -35,6 +50,13 @@ const GitSubForm = ({ tooltipMaxWidth="400px" tooltip={projectHelpStrings.sourceControlRefspec(docsURL)} /> +