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)}
/>
+