diff --git a/awx/api/serializers.py b/awx/api/serializers.py index f70e582c89..08503db45e 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2664,6 +2664,13 @@ class CredentialSerializer(BaseSerializer): return credential_type + def validate_inputs(self, inputs): + if self.instance and self.instance.credential_type.kind == "vault": + if 'vault_id' in inputs and inputs['vault_id'] != self.instance.inputs['vault_id']: + raise ValidationError(_('Vault IDs cannot be changed once they have been created.')) + + return inputs + class CredentialSerializerCreate(CredentialSerializer): diff --git a/awx/main/tests/functional/api/test_credential.py b/awx/main/tests/functional/api/test_credential.py index 3d277049ab..52814f3655 100644 --- a/awx/main/tests/functional/api/test_credential.py +++ b/awx/main/tests/functional/api/test_credential.py @@ -532,6 +532,49 @@ def test_vault_password_required(post, organization, admin): assert 'required fields (vault_password)' in j.job_explanation +@pytest.mark.django_db +def test_vault_id_immutable(post, patch, organization, admin): + vault = CredentialType.defaults['vault']() + vault.save() + response = post( + reverse('api:credential_list'), + { + 'credential_type': vault.pk, + 'organization': organization.id, + 'name': 'Best credential ever', + 'inputs': {'vault_id': 'password', 'vault_password': 'password'}, + }, + admin, + ) + assert response.status_code == 201 + assert Credential.objects.count() == 1 + response = patch( + reverse('api:credential_detail', kwargs={'pk': response.data['id']}), {'inputs': {'vault_id': 'password2', 'vault_password': 'password'}}, admin + ) + assert response.status_code == 400 + assert response.data['inputs'][0] == 'Vault IDs cannot be changed once they have been created.' + + +@pytest.mark.django_db +def test_patch_without_vault_id_valid(post, patch, organization, admin): + vault = CredentialType.defaults['vault']() + vault.save() + response = post( + reverse('api:credential_list'), + { + 'credential_type': vault.pk, + 'organization': organization.id, + 'name': 'Best credential ever', + 'inputs': {'vault_id': 'password', 'vault_password': 'password'}, + }, + admin, + ) + assert response.status_code == 201 + assert Credential.objects.count() == 1 + response = patch(reverse('api:credential_detail', kwargs={'pk': response.data['id']}), {'name': 'worst_credential_ever'}, admin) + assert response.status_code == 200 + + # # Net Credentials # diff --git a/awx/ui/src/screens/Credential/shared/CredentialFormFields/CredentialField.js b/awx/ui/src/screens/Credential/shared/CredentialFormFields/CredentialField.js index fe8b31731b..0d4b1ed840 100644 --- a/awx/ui/src/screens/Credential/shared/CredentialFormFields/CredentialField.js +++ b/awx/ui/src/screens/Credential/shared/CredentialFormFields/CredentialField.js @@ -1,5 +1,6 @@ /* eslint-disable react/jsx-no-useless-fragment */ import React, { useState } from 'react'; +import { useLocation } from 'react-router-dom'; import { useField, useFormikContext } from 'formik'; import { shape, string } from 'prop-types'; import styled from 'styled-components'; @@ -31,6 +32,7 @@ function CredentialInput({ fieldOptions, isFieldGroupValid, credentialKind, + isVaultIdDisabled, ...rest }) { const [fileName, setFileName] = useState(''); @@ -148,6 +150,7 @@ function CredentialInput({ onChange={(value, event) => { subFormField.onChange(event); }} + isDisabled={isVaultIdDisabled} validated={isValid ? 'default' : 'error'} /> ); @@ -167,6 +170,7 @@ CredentialInput.defaultProps = { function CredentialField({ credentialType, fieldOptions }) { const { values: formikValues } = useFormikContext(); + const location = useLocation(); const requiredFields = credentialType?.inputs?.required || []; const isRequired = requiredFields.includes(fieldOptions.id); const validateField = () => { @@ -242,6 +246,15 @@ function CredentialField({ credentialType, fieldOptions }) { ); } + + let disabled = false; + if ( + credentialType.kind === 'vault' && + location.pathname.endsWith('edit') && + fieldOptions.id === 'vault_id' + ) { + disabled = true; + } return ( ); diff --git a/awx/ui/src/screens/Credential/shared/CredentialFormFields/CredentialField.test.js b/awx/ui/src/screens/Credential/shared/CredentialFormFields/CredentialField.test.js index 78b1cd8dcd..3902fb1d58 100644 --- a/awx/ui/src/screens/Credential/shared/CredentialFormFields/CredentialField.test.js +++ b/awx/ui/src/screens/Credential/shared/CredentialFormFields/CredentialField.test.js @@ -13,6 +13,12 @@ const fieldOptions = { secret: true, }; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + pathname: '/credentials/3/edit', + }), +})); describe('', () => { let wrapper; test('renders correctly without initial value', () => { @@ -113,4 +119,33 @@ describe('', () => { expect(wrapper.find('TextInput').props().value).toBe(''); expect(wrapper.find('TextInput').props().placeholder).toBe('ENCRYPTED'); }); + test('Should check to see if the ability to edit vault ID is disabled after creation.', () => { + const vaultCredential = credentialTypes.find((type) => type.id === 3); + const vaultFieldOptions = { + id: 'vault_id', + label: 'Vault Identifier', + type: 'string', + secret: true, + }; + wrapper = mountWithContexts( + + {() => ( + + )} + + ); + expect(wrapper.find('CredentialInput').props().isDisabled).toBe(true); + expect(wrapper.find('KeyIcon').length).toBe(1); + }); });