From 5acb57401cc2266470bb8911dc9ab4df86d7b251 Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Tue, 1 Sep 2026 12:11:57 -0700 Subject: [PATCH 1/4] add validation pattern for credentials nested fields --- .../api/serializers/credential_type.py | 43 ++++++++++++++++- tests/integration/api/test_credential_type.py | 48 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/aap_eda/api/serializers/credential_type.py b/src/aap_eda/api/serializers/credential_type.py index 73d3219a8..42e9f07e7 100644 --- a/src/aap_eda/api/serializers/credential_type.py +++ b/src/aap_eda/api/serializers/credential_type.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from ansible_base.lib.metadata import get_tier2_pattern, validation_enabled +from ansible_base.lib.serializers.mixins import CleanTextMixin from rest_framework import serializers from aap_eda.core import models, validators @@ -37,8 +39,47 @@ class Meta: *read_only_fields, ] + def to_representation(self, instance): + data = super().to_representation(instance) + inputs = data.get("inputs") + if validation_enabled() and isinstance(inputs, dict): + data["inputs"] = _with_field_patterns(inputs) + return data + + +def _with_field_patterns(inputs: dict) -> dict: + """Return a copy of the inputs schema with patterns for string fields. + + Only non-secret "string" sub-fields get a pattern, since those are the + only ones CleanTextMixin's JSON sub-key validation enforces free-text + rules on; secret and boolean fields are left untouched. + """ + fields = inputs.get("fields") + if not isinstance(fields, list): + return inputs + + pattern = get_tier2_pattern() + new_fields = [ + { + **field, + "pattern": pattern["pattern"], + "pattern_description": pattern["description"], + } + if isinstance(field, dict) + and field.get("type") == "string" + and not field.get("secret") + else field + for field in fields + ] + return {**inputs, "fields": new_fields} + +class CredentialTypeCreateSerializer( + CleanTextMixin, serializers.ModelSerializer +): + # injectors commonly contain Jinja2 template syntax, so it is excluded + # from free-text checks. + excluded_fields = frozenset({"injectors"}) -class CredentialTypeCreateSerializer(serializers.ModelSerializer): inputs = serializers.JSONField( required=False, default=dict, diff --git a/tests/integration/api/test_credential_type.py b/tests/integration/api/test_credential_type.py index 797517f42..c052d813c 100644 --- a/tests/integration/api/test_credential_type.py +++ b/tests/integration/api/test_credential_type.py @@ -15,6 +15,7 @@ from unittest.mock import patch import pytest +from django.test import override_settings from pytest_lazyfixture import lazy_fixture from rest_framework import status from rest_framework.test import APIClient @@ -1267,3 +1268,50 @@ def test_eda_rule_engine_credential_validates_required_fields( assert response.status_code == status.HTTP_400_BAD_REQUEST assert "postgres_db_name" in str(response.data) + + +@pytest.mark.django_db +class TestCredentialTypeValidationPatterns: + """AAP-87587: pattern/pattern_description injection for JSON sub-keys.""" + + @override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) + def test_patterns_present_when_toggle_on( + self, + superuser_client: APIClient, + credential_type: models.CredentialType, + ): + from ansible_base.lib.metadata import get_tier2_pattern + + response = superuser_client.get( + f"{api_url_v1}/credential-types/{credential_type.id}/" + ) + assert response.status_code == status.HTTP_200_OK + + pattern = get_tier2_pattern() + fields_by_id = { + field["id"]: field for field in response.data["inputs"]["fields"] + } + + username_field = fields_by_id["username"] + assert username_field["pattern"] == pattern["pattern"] + assert username_field["pattern_description"] == pattern["description"] + + # secret fields are excluded even though they are also type "string" + password_field = fields_by_id["password"] + assert "pattern" not in password_field + assert "pattern_description" not in password_field + + @override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=False) + def test_patterns_absent_when_toggle_off( + self, + superuser_client: APIClient, + credential_type: models.CredentialType, + ): + response = superuser_client.get( + f"{api_url_v1}/credential-types/{credential_type.id}/" + ) + assert response.status_code == status.HTTP_200_OK + + for field in response.data["inputs"]["fields"]: + assert "pattern" not in field + assert "pattern_description" not in field From 282ac953d7f0f1fd1cf39211ed720be4e7fb58b1 Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Tue, 1 Sep 2026 13:19:45 -0700 Subject: [PATCH 2/4] call inject_clean_text_pattern in metadata class to provide pattern info on top level fields as well --- src/aap_eda/api/metadata.py | 2 ++ tests/integration/api/test_credential_type.py | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/aap_eda/api/metadata.py b/src/aap_eda/api/metadata.py index 249c039c8..f2f35619c 100644 --- a/src/aap_eda/api/metadata.py +++ b/src/aap_eda/api/metadata.py @@ -1,3 +1,4 @@ +from ansible_base.lib.metadata import inject_clean_text_patterns from django.core.exceptions import PermissionDenied from django.http import Http404 from django.utils.encoding import force_str @@ -23,6 +24,7 @@ class EDAMetadata(metadata.SimpleMetadata): def get_field_info(self, field): field_info = super().get_field_info(field) + field_info = inject_clean_text_patterns(field, field_info) for attr in ADDITIONAL_ATTRS: value = getattr(field, attr, None) diff --git a/tests/integration/api/test_credential_type.py b/tests/integration/api/test_credential_type.py index c052d813c..7273849f6 100644 --- a/tests/integration/api/test_credential_type.py +++ b/tests/integration/api/test_credential_type.py @@ -1315,3 +1315,36 @@ def test_patterns_absent_when_toggle_off( for field in response.data["inputs"]["fields"]: assert "pattern" not in field assert "pattern_description" not in field + + +@pytest.mark.django_db +class TestCredentialTypeOptionsValidationPatterns: + """EDAMetadata wires DAB's top-level OPTIONS pattern injection. + + EDA overrides DEFAULT_METADATA_CLASS with its own EDAMetadata, so DAB's + CleanTextMetadata never runs; EDAMetadata.get_field_info() must call + inject_clean_text_patterns() itself for CleanTextMixin serializers to + advertise a pattern on OPTIONS, same as any other DAB consumer. + """ + + @override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) + def test_options_includes_pattern_when_toggle_on( + self, superuser_client: APIClient + ): + response = superuser_client.options(f"{api_url_v1}/credential-types/") + assert response.status_code == status.HTTP_200_OK + + description_field = response.data["actions"]["POST"]["description"] + assert "pattern" in description_field + assert "patternDescription" in description_field + + @override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=False) + def test_options_excludes_pattern_when_toggle_off( + self, superuser_client: APIClient + ): + response = superuser_client.options(f"{api_url_v1}/credential-types/") + assert response.status_code == status.HTTP_200_OK + + description_field = response.data["actions"]["POST"]["description"] + assert "pattern" not in description_field + assert "patternDescription" not in description_field From 5afc67a1e82db82bdf0ee434a70237ec1297c216 Mon Sep 17 00:00:00 2001 From: Maeve Hoffer Date: Thu, 3 Sep 2026 09:59:44 -0700 Subject: [PATCH 3/4] Update src/aap_eda/api/serializers/credential_type.py Co-authored-by: Vidya Nambiar <43621546+vidyanambiar@users.noreply.github.com> --- src/aap_eda/api/serializers/credential_type.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/aap_eda/api/serializers/credential_type.py b/src/aap_eda/api/serializers/credential_type.py index 42e9f07e7..4d3408357 100644 --- a/src/aap_eda/api/serializers/credential_type.py +++ b/src/aap_eda/api/serializers/credential_type.py @@ -47,12 +47,15 @@ def to_representation(self, instance): return data +python def _with_field_patterns(inputs: dict) -> dict: """Return a copy of the inputs schema with patterns for string fields. - Only non-secret "string" sub-fields get a pattern, since those are the - only ones CleanTextMixin's JSON sub-key validation enforces free-text - rules on; secret and boolean fields are left untouched. + CleanTextMixin (from DAB) enforces free-text validation rules on + serializer string fields at write time (when the ENHANCED_INPUT_VALIDATION_ENABLED setting is turned on). Only non-secret "string" + sub-fields get a pattern here, since those are the only ones its + JSON sub-key validation applies to; secret and boolean fields are + left untouched. """ fields = inputs.get("fields") if not isinstance(fields, list): From 403d75f810e3819feab0edfd02f86edf6b8ef84c Mon Sep 17 00:00:00 2001 From: Chai Bot Date: Thu, 10 Sep 2026 20:19:39 +0000 Subject: [PATCH 4/4] Wrap DAB metadata imports in try/except for graceful fallback Until django-ansible-base PR #1119 (AAP-85987) lands, the new ansible_base.lib.metadata module may not exist. Wrap all imports from that module in try/except ImportError blocks so the feature is a no-op when DAB hasn't been updated yet, following the same pattern used in AWX (PR #16638). Co-Authored-By: Claude Opus 4.6 --- src/aap_eda/api/metadata.py | 8 ++++++-- .../api/serializers/credential_type.py | 19 +++++++++++++++---- tests/integration/api/test_credential_type.py | 11 ++++++++++- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/aap_eda/api/metadata.py b/src/aap_eda/api/metadata.py index f2f35619c..c8b23afb6 100644 --- a/src/aap_eda/api/metadata.py +++ b/src/aap_eda/api/metadata.py @@ -1,4 +1,7 @@ -from ansible_base.lib.metadata import inject_clean_text_patterns +try: + from ansible_base.lib.metadata import inject_clean_text_patterns +except ImportError: # pragma: no cover - DAB without AAP-85987 + inject_clean_text_patterns = None from django.core.exceptions import PermissionDenied from django.http import Http404 from django.utils.encoding import force_str @@ -24,7 +27,8 @@ class EDAMetadata(metadata.SimpleMetadata): def get_field_info(self, field): field_info = super().get_field_info(field) - field_info = inject_clean_text_patterns(field, field_info) + if inject_clean_text_patterns is not None: + field_info = inject_clean_text_patterns(field, field_info) for attr in ADDITIONAL_ATTRS: value = getattr(field, attr, None) diff --git a/src/aap_eda/api/serializers/credential_type.py b/src/aap_eda/api/serializers/credential_type.py index 4d3408357..db8282140 100644 --- a/src/aap_eda/api/serializers/credential_type.py +++ b/src/aap_eda/api/serializers/credential_type.py @@ -12,8 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -from ansible_base.lib.metadata import get_tier2_pattern, validation_enabled -from ansible_base.lib.serializers.mixins import CleanTextMixin +try: + from ansible_base.lib.metadata import get_tier2_pattern, validation_enabled +except ImportError: # pragma: no cover - DAB without AAP-85987 + get_tier2_pattern = None + validation_enabled = None + +try: + from ansible_base.lib.serializers.mixins import CleanTextMixin +except ImportError: # pragma: no cover - DAB without AAP-85987 + # Provide a no-op stand-in so the class definition is valid + class CleanTextMixin: + pass from rest_framework import serializers from aap_eda.core import models, validators @@ -42,12 +52,11 @@ class Meta: def to_representation(self, instance): data = super().to_representation(instance) inputs = data.get("inputs") - if validation_enabled() and isinstance(inputs, dict): + if validation_enabled is not None and validation_enabled() and isinstance(inputs, dict): data["inputs"] = _with_field_patterns(inputs) return data -python def _with_field_patterns(inputs: dict) -> dict: """Return a copy of the inputs schema with patterns for string fields. @@ -61,6 +70,8 @@ def _with_field_patterns(inputs: dict) -> dict: if not isinstance(fields, list): return inputs + if get_tier2_pattern is None: + return inputs pattern = get_tier2_pattern() new_fields = [ { diff --git a/tests/integration/api/test_credential_type.py b/tests/integration/api/test_credential_type.py index 7273849f6..523191228 100644 --- a/tests/integration/api/test_credential_type.py +++ b/tests/integration/api/test_credential_type.py @@ -24,6 +24,13 @@ from aap_eda.core.utils.credentials import SUPPORTED_KEYS_IN_INJECTORS from tests.integration.constants import api_url_v1 +try: + from ansible_base.lib.metadata import get_tier2_pattern as _get_tier2_pattern + _has_dab_validation_metadata = True +except ImportError: + _get_tier2_pattern = None + _has_dab_validation_metadata = False + INPUT = { "fields": [ { @@ -1274,13 +1281,14 @@ def test_eda_rule_engine_credential_validates_required_fields( class TestCredentialTypeValidationPatterns: """AAP-87587: pattern/pattern_description injection for JSON sub-keys.""" + @pytest.mark.skipif(not _has_dab_validation_metadata, reason="DAB validation metadata not available (AAP-85987)") @override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) def test_patterns_present_when_toggle_on( self, superuser_client: APIClient, credential_type: models.CredentialType, ): - from ansible_base.lib.metadata import get_tier2_pattern + get_tier2_pattern = _get_tier2_pattern response = superuser_client.get( f"{api_url_v1}/credential-types/{credential_type.id}/" @@ -1327,6 +1335,7 @@ class TestCredentialTypeOptionsValidationPatterns: advertise a pattern on OPTIONS, same as any other DAB consumer. """ + @pytest.mark.skipif(not _has_dab_validation_metadata, reason="DAB validation metadata not available (AAP-85987)") @override_settings(ENHANCED_INPUT_VALIDATION_ENABLED=True) def test_options_includes_pattern_when_toggle_on( self, superuser_client: APIClient