diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 41732bc7..4e78caa1 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -289,7 +289,7 @@ def get_input(self, field_name, **kwargs): :param field_name(str): The name of the input field. :param default(optional[str]): A default return value to use. """ - if self.credential_type.kind != 'external' and field_name in self.dynamic_input_fields: + if field_name in self.dynamic_input_fields: return self._get_dynamic_input(field_name) if field_name in self.credential_type.secret_fields: try: @@ -1276,13 +1276,32 @@ class Meta: metadata = DynamicCredentialInputField(blank=True, default=dict) def clean_target_credential(self): - if self.target_credential.credential_type.kind == 'external': - raise ValidationError(_('Target must be a non-external credential')) + if self.target_credential == self.source_credential: + raise ValidationError(_('Target and source credentials must be different')) return self.target_credential def clean_source_credential(self): if self.source_credential.credential_type.kind != 'external': raise ValidationError(_('Source must be an external credential')) + + # Cycle detection: check if adding this link would create a cycle + visited = set() + def has_cycle(cred_id, target_id): + if cred_id == target_id: + return True + if cred_id in visited: + return False + visited.add(cred_id) + + # Check all sources that point to this credential + for input_source in CredentialInputSource.objects.filter(target_credential_id=cred_id): + if has_cycle(input_source.source_credential_id, target_id): + return True + return False + + if has_cycle(self.source_credential_id, self.target_credential_id): + raise ValidationError(_('This would create a circular dependency between credentials')) + return self.source_credential def clean_input_field_name(self): @@ -1300,6 +1319,12 @@ def get_input_value(self): else: backend_kwargs[field_name] = value + # Resolve any dynamic inputs on the source credential + # (e.g., a field sourced from another external credential like Azure KV) + for field_name in self.source_credential.dynamic_input_fields: + if field_name not in backend_kwargs: + backend_kwargs[field_name] = self.source_credential._get_dynamic_input(field_name) + backend_kwargs.update(self.metadata) with set_environ(**settings.AWX_TASK_ENV): diff --git a/awx/main/tests/functional/api/test_credential_input_sources.py b/awx/main/tests/functional/api/test_credential_input_sources.py index d13710e0..c9f9513f 100644 --- a/awx/main/tests/functional/api/test_credential_input_sources.py +++ b/awx/main/tests/functional/api/test_credential_input_sources.py @@ -84,7 +84,7 @@ def test_create_from_list(get, post, admin, vault_credential, external_credentia @pytest.mark.django_db -def test_create_credential_input_source_with_external_target_returns_400(post, admin, external_credential, other_external_credential): +def test_create_credential_input_source_with_external_target_returns_201(post, admin, external_credential, other_external_credential): list_url = reverse( 'api:credential_input_source_list', ) @@ -95,8 +95,7 @@ def test_create_credential_input_source_with_external_target_returns_400(post, a 'metadata': {'key': 'some_key'}, } response = post(list_url, params, admin) - assert response.status_code == 400 - assert response.data['target_credential'] == ['Target must be a non-external credential'] + assert response.status_code == 201 @pytest.mark.django_db @@ -316,3 +315,117 @@ def test_create_credential_input_source_with_already_used_input_returns_400(post ] all_responses = [post(list_url, params, admin) for params in all_params] assert all_responses.pop().status_code == 400 + + +@pytest.mark.django_db +def test_create_credential_input_source_same_target_and_source_returns_400(post, admin, external_credential): + """Target and source credential cannot be the same credential.""" + list_url = reverse('api:credential_input_source_list') + params = { + 'target_credential': external_credential.pk, + 'source_credential': external_credential.pk, + 'input_field_name': 'token', + 'metadata': {'key': 'some_key'}, + } + response = post(list_url, params, admin) + assert response.status_code == 400 + assert b'Target and source credentials must be different' in response.content + + +@pytest.mark.django_db +def test_create_credential_input_source_circular_dependency_returns_400(post, admin, credentialtype_external, external_credential, other_external_credential): + """Creating an input source that would form a cycle between credentials should be rejected.""" + from awx.main.models import Credential + + third_external = Credential.objects.create( + credential_type=credentialtype_external, + name='third-external-cred', + inputs={'url': 'http://thirdhost.com', 'token': 'secret3'}, + ) + list_url = reverse('api:credential_input_source_list') + + # Set up chain: external_credential -> other_external_credential -> third_external + response = post( + list_url, + { + 'target_credential': external_credential.pk, + 'source_credential': other_external_credential.pk, + 'input_field_name': 'token', + 'metadata': {'key': 'key1'}, + }, + admin, + ) + assert response.status_code == 201 + + response = post( + list_url, + { + 'target_credential': other_external_credential.pk, + 'source_credential': third_external.pk, + 'input_field_name': 'token', + 'metadata': {'key': 'key2'}, + }, + admin, + ) + assert response.status_code == 201 + + # Now try to close the cycle: third_external sourced from external_credential + response = post( + list_url, + { + 'target_credential': third_external.pk, + 'source_credential': external_credential.pk, + 'input_field_name': 'token', + 'metadata': {'key': 'key3'}, + }, + admin, + ) + assert response.status_code == 400 + assert b'circular dependency' in response.content + + +@pytest.mark.django_db +def test_external_credential_get_input_resolves_dynamic_input(external_credential, other_external_credential): + """get_input on any credential type should resolve dynamic (externally-sourced) inputs.""" + input_source = CredentialInputSource.objects.create( + target_credential=external_credential, + source_credential=other_external_credential, + input_field_name='token', + metadata={'key': 'some_key'}, + ) + # The mock backend returns 'secret1', so the dynamic input should resolve + assert external_credential.get_input('token') == 'secret1' + + +@pytest.mark.django_db +def test_chained_external_credential_resolves_source_dynamic_inputs(credentialtype_external, external_credential, other_external_credential): + """When a source credential itself has dynamic inputs, get_input_value should resolve them.""" + # Create a third external credential to act as the leaf source + from awx.main.models import Credential + + third_external = Credential.objects.create( + credential_type=credentialtype_external, + name='third-external-cred', + inputs={'url': 'http://thirdhost.com', 'token': 'secret3'}, + ) + + # Chain: other_external_credential.token is sourced from third_external + CredentialInputSource.objects.create( + target_credential=other_external_credential, + source_credential=third_external, + input_field_name='token', + metadata={'key': 'chained_key'}, + ) + + # Now create an input source where other_external_credential is the source + input_source = CredentialInputSource.objects.create( + target_credential=external_credential, + source_credential=other_external_credential, + input_field_name='token', + metadata={'key': 'final_key'}, + ) + + # get_input_value should succeed — the source credential's dynamic 'token' + # field should be resolved before being passed to the backend + result = input_source.get_input_value() + assert result == 'secret' diff --git a/awx/ui/src/screens/Credential/shared/CredentialFormFields/CredentialField.js b/awx/ui/src/screens/Credential/shared/CredentialFormFields/CredentialField.js index e72eabf8..1be54e51 100644 --- a/awx/ui/src/screens/Credential/shared/CredentialFormFields/CredentialField.js +++ b/awx/ui/src/screens/Credential/shared/CredentialFormFields/CredentialField.js @@ -17,7 +17,6 @@ import { import { PficonHistoryIcon } from '@patternfly/react-icons'; import { PasswordInput } from 'components/FormField'; import AnsibleSelect from 'components/AnsibleSelect'; -import Popover from 'components/Popover'; import { CredentialType } from 'types'; import { required } from 'util/validators'; import { CredentialPluginField } from '../CredentialPlugins'; @@ -220,32 +219,6 @@ function CredentialField({ credentialType, fieldOptions }) { ); } - if (credentialType.kind === 'external') { - return ( - - } - isRequired={isRequired} - validated={isValid ? 'default' : 'error'} - > - - - ); - } if (credentialType.kind === 'ssh' && fieldOptions.id === 'become_method') { return (