[AAP-87586] advertise validation patterns on top level and JSON sub-key fields (#16638)

* feat(api): advertise validation patterns on JSON sub-key fields
Credential type inputs.fields/metadata and notification OPTIONS now include
pattern and pattern_description for non-secret string sub-keys when
ENHANCED_INPUT_VALIDATION_ENABLED is on.

* Fix lint issue

* Fix sonarqube issue

* feat(api): advertise validation patterns on top-level CharFields

Wires DAB's inject_clean_text_patterns into awx.api.metadata.Metadata so
top-level CharField OPTIONS metadata gets pattern/patternDescription/flags,
matching the JSON sub-key pattern exposure already in place. No-op until
ENHANCED_INPUT_VALIDATION_ENABLED is on and a serializer mixes in CleanTextMixin.

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>

* Increase test coverage

* Fix lint issues

* Address review feedback on validation pattern injection

Import TIER2_PATTERN_DESCRIPTION from DAB instead of keeping a manually
synced duplicate, and short-circuit inject_patterns_into_field_list
before iterating fields when enhanced input validation is disabled.

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>

* Avoid mutating shared field dicts in inject_patterns_into_field_list

Shallow-copy each field before injecting pattern metadata so callers
that pass module-level schema dicts (e.g. ManagedCredentialType.registry
entries) don't get them mutated in place, matching the existing
copy-before-mutate approach in inject_patterns_into_init_parameters.

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>

* Add validation pattern for survey fields

---------

Signed-off-by: Pratyush Bhandari <prbhanda@redhat.com>
Co-authored-by: Peter Braun <pbraun@redhat.com>
This commit is contained in:
Pratyush Bhandari
2026-09-21 15:52:54 +02:00
committed by GitHub
co-authored by Peter Braun
parent bbda905e00
commit 32f588e15b
11 changed files with 984 additions and 1 deletions
+13 -1
View File
@@ -23,6 +23,7 @@ from rest_framework.request import clone_request
# AWX
from awx.api.fields import ChoiceNullField
from awx.api.validation_patterns import build_survey_spec_options_schema, inject_patterns_into_init_parameters, inject_top_level_clean_text_patterns
from awx.main.fields import ImplicitRoleField
from awx.main.models import NotificationTemplate
from awx.main.utils.execution_environments import get_default_pod_spec
@@ -131,7 +132,9 @@ class Metadata(metadata.SimpleMetadata):
# are conditional on the type selected.
if field.field_name == 'notification_configuration':
for notification_type_name, notification_tr_name, notification_type_class in NotificationTemplate.NOTIFICATION_TYPES:
field_info[notification_type_name] = notification_type_class.init_parameters
# Copy before injecting patterns so class-level init_parameters
# dicts are never mutated (AAP-87586).
field_info[notification_type_name] = inject_patterns_into_init_parameters(notification_type_class.init_parameters)
# Special handling of notification messages where the required properties
# are conditional on the type selected.
@@ -178,6 +181,10 @@ class Metadata(metadata.SimpleMetadata):
elif isinstance(model_field, BooleanField):
field_info['type'] = 'boolean'
# Advertise CleanTextMixin Tier 1/Tier 2 patterns on top-level CharFields (AAP-87586).
# No-op unless ENHANCED_INPUT_VALIDATION_ENABLED is on and the serializer mixes in CleanTextMixin.
field_info = inject_top_level_clean_text_patterns(field, field_info)
return field_info
def get_serializer_info(self, serializer, method=None):
@@ -240,6 +247,11 @@ class Metadata(metadata.SimpleMetadata):
continue
actions[method].pop(field)
# Survey spec uses EmptySerializer, so POST has no field metadata unless we
# supply the JSON sub-key catalog (AAP-87586).
if getattr(view, 'advertises_survey_spec_schema', False) and 'POST' in actions:
actions['POST'] = build_survey_spec_options_schema()
return actions
def determine_metadata(self, request, view):
+6
View File
@@ -51,6 +51,7 @@ from ansible_base.rbac.models import RoleEvaluation, ObjectRole
from ansible_base.rbac import permission_registry
# AWX
from awx.api.validation_patterns import inject_patterns_into_field_list
from awx.main.access import get_user_capabilities
from awx.main.constants import ACTIVE_STATES, org_role_to_permission
from awx.main.models import (
@@ -3122,6 +3123,11 @@ class CredentialTypeSerializer(CleanTextMixin, BaseSerializer):
# Normalize fields and filter out internal fields
value['inputs']['fields'] = [f for f in fields if not f.get('internal')]
# Advertise CleanTextMixin Tier 2 patterns on JSON sub-keys (AAP-87586).
# Gated on ENHANCED_INPUT_VALIDATION_ENABLED; secret fields are skipped.
inject_patterns_into_field_list(value['inputs']['fields'])
inject_patterns_into_field_list(value['inputs'].get('metadata'))
return value
def filter_field_metadata(self, fields, method):
+221
View File
@@ -0,0 +1,221 @@
# Copyright (c) 2026 Ansible, Inc.
# All Rights Reserved.
"""Inject DAB CleanTextMixin frontend patterns into API OPTIONS metadata.
Controller defines its own ``DEFAULT_METADATA_CLASS`` (``awx.api.metadata.Metadata``)
rather than using DAB's ``CleanTextMetadata``, so top-level CharField OPTIONS
metadata is not handled automatically by DAB -- ``inject_top_level_clean_text_patterns``
below is called directly from ``awx.api.metadata.Metadata.get_field_info`` to cover it.
JSON sub-keys (credential inputs, notification config, credential input source
metadata, survey_spec question fields) are domain-owned schemas that DAB has no
visibility into at all, so pattern injection for those lives here unconditionally.
Depends on django-ansible-base PR #1119 (AAP-85987) for
``build_tier2_frontend_pattern`` and ``inject_clean_text_patterns``. Until that
lands on DAB devel, injection is a no-op so Controller can still import and run.
"""
import copy
from ansible_base.lib.utils.settings import get_setting
try:
from ansible_base.lib.metadata import build_tier2_frontend_pattern
except ImportError: # pragma: no cover - DAB without AAP-85987
build_tier2_frontend_pattern = None
try:
from ansible_base.lib.metadata import inject_clean_text_patterns as _dab_inject_clean_text_patterns
except ImportError: # pragma: no cover - DAB without AAP-85987
_dab_inject_clean_text_patterns = None
try:
from ansible_base.lib.metadata import TIER2_PATTERN_DESCRIPTION
except ImportError: # pragma: no cover - DAB without AAP-85987
TIER2_PATTERN_DESCRIPTION = "This field can't include HTML tags, script markup, unsafe URI schemes, shell or template syntax, or control characters."
try:
from ansible_base.lib.utils.validation import validate_free_text as _validate_free_text
except ImportError: # pragma: no cover - DAB without CleanTextMixin validators
_validate_free_text = None
_STRING_TYPES = frozenset({'string', 'str'})
# Survey editor strings advertised on OPTIONS /survey_spec/ and validated on POST.
# default/choices are omitted: they may contain Jinja/playbook text or password values.
SURVEY_SPEC_TOP_LEVEL_TEXT_KEYS = ('name', 'description')
SURVEY_QUESTION_TEXT_KEYS = ('question_name', 'question_description', 'variable')
def enhanced_input_validation_enabled():
return bool(get_setting('ENHANCED_INPUT_VALIDATION_ENABLED', False))
def free_text_pattern_metadata():
"""Return Tier 2 (validate_free_text) pattern keys for API clients, or None."""
if build_tier2_frontend_pattern is None:
return None
return {
'pattern': build_tier2_frontend_pattern(),
'pattern_description': TIER2_PATTERN_DESCRIPTION,
'flags': 'i',
}
def _is_string_schema(field_schema, field_type=None):
if field_type is None:
field_type = field_schema.get('type', 'string')
return field_type in _STRING_TYPES
def inject_free_text_pattern(field_schema, *, secret=False, field_type=None):
"""Mutate a schema dict in place if it is a non-secret string field.
No-op when the install-time toggle is off or DAB pattern helpers are missing.
"""
if not isinstance(field_schema, dict):
return field_schema
if not enhanced_input_validation_enabled():
return field_schema
if secret or field_schema.get('secret') is True:
return field_schema
if not _is_string_schema(field_schema, field_type=field_type):
return field_schema
metadata = free_text_pattern_metadata()
if metadata is None:
return field_schema
field_schema.update(metadata)
return field_schema
def inject_patterns_into_field_list(fields):
"""Inject patterns into a credential-type ``fields`` or ``metadata`` list in place.
Each field is shallow-copied before mutation so shared/module-level schema
dicts (e.g. ``ManagedCredentialType.registry`` entries) are never modified.
"""
if not enhanced_input_validation_enabled():
return
if not isinstance(fields, list):
return
for i, field in enumerate(fields):
fields[i] = inject_free_text_pattern(copy.copy(field))
def inject_patterns_into_init_parameters(init_parameters):
"""Return a copy of notification ``init_parameters`` with patterns injected.
Copies first so class-level backend dicts are never mutated.
Password-typed keys are treated as secrets and skipped.
"""
params = copy.deepcopy(init_parameters) if isinstance(init_parameters, dict) else {}
if not enhanced_input_validation_enabled():
return params
for field_schema in params.values():
if not isinstance(field_schema, dict):
continue
inject_free_text_pattern(field_schema, secret=field_schema.get('type') == 'password')
return params
def _survey_string_field_schema(*, required, label):
schema = {'type': 'string', 'required': required, 'label': label}
inject_free_text_pattern(schema)
return schema
def build_survey_spec_options_schema():
"""Return OPTIONS ``actions.POST`` metadata for ``/survey_spec/``.
``JobTemplateSurveySpec`` uses ``EmptySerializer``, so DRF OPTIONS has no
nested field schema to stamp. This builds the JSON sub-key catalog the UI
survey editor needs (survey title plus question editor strings).
``default`` / ``choices`` are not included — they may contain Jinja,
playbook text, or password values.
"""
return {
'name': _survey_string_field_schema(required=True, label='Name'),
'description': _survey_string_field_schema(required=True, label='Description'),
'spec': {
'type': 'json',
'required': True,
'label': 'Spec',
'question_name': _survey_string_field_schema(required=True, label='Question'),
'question_description': _survey_string_field_schema(required=False, label='Description'),
'variable': _survey_string_field_schema(required=True, label='Answer variable name'),
},
}
def _survey_questions_list(spec):
questions = spec.get('spec') if isinstance(spec, dict) else None
return questions if isinstance(questions, list) else []
def _survey_questions_by_variable(questions):
return {q['variable']: q for q in questions if isinstance(q, dict) and isinstance(q.get('variable'), str)}
def _matching_old_survey_question(item, idx, old_questions, old_by_var):
"""Resolve the prior question for grandfathering (by variable, then index)."""
old_item = old_by_var.get(item.get('variable'))
if old_item is None and idx < len(old_questions) and isinstance(old_questions[idx], dict):
return old_questions[idx]
return old_item or {}
def _record_survey_free_text_error(errors, error_key, value, old_value):
"""Validate ``value`` with Tier 2 rules unless unchanged from ``old_value``."""
if not isinstance(value, str) or value == old_value:
return
from rest_framework.serializers import ValidationError
try:
_validate_free_text(value)
except ValidationError as exc:
errors[error_key] = exc.detail
def _collect_survey_question_text_errors(errors, new_questions, old_questions):
old_by_var = _survey_questions_by_variable(old_questions)
for idx, item in enumerate(new_questions):
if not isinstance(item, dict):
continue
old_item = _matching_old_survey_question(item, idx, old_questions, old_by_var)
for field in SURVEY_QUESTION_TEXT_KEYS:
_record_survey_free_text_error(errors, f'spec[{idx}].{field}', item.get(field), old_item.get(field))
def collect_survey_spec_text_errors(new_spec, old_spec=None):
"""Return field-keyed Tier 2 errors for survey editor strings, or None.
Grandfathers unchanged values against ``old_spec`` (by variable, then index).
No-op when the install-time toggle is off or DAB validators are missing.
"""
if not enhanced_input_validation_enabled() or _validate_free_text is None:
return None
if not isinstance(new_spec, dict):
return None
old_spec = old_spec if isinstance(old_spec, dict) else {}
errors = {}
for field in SURVEY_SPEC_TOP_LEVEL_TEXT_KEYS:
_record_survey_free_text_error(errors, field, new_spec.get(field), old_spec.get(field))
_collect_survey_question_text_errors(errors, _survey_questions_list(new_spec), _survey_questions_list(old_spec))
return errors or None
def inject_top_level_clean_text_patterns(field, field_info):
"""Advertise DAB CleanTextMixin Tier 1/Tier 2 patterns on a top-level serializer field.
Delegates entirely to DAB's ``inject_clean_text_patterns``, which no-ops unless
``ENHANCED_INPUT_VALIDATION_ENABLED`` is on and the field's serializer mixes in
``CleanTextMixin``. No-op when the DAB helper is missing.
"""
if _dab_inject_clean_text_patterns is None:
return field_info
return _dab_inject_clean_text_patterns(field, field_info)
+6
View File
@@ -118,6 +118,7 @@ from awx.api.permissions import (
)
from awx.api import renderers
from awx.api import serializers
from awx.api.validation_patterns import collect_survey_spec_text_errors
from awx.api.metadata import RoleMetadata
from awx.main.constants import ACTIVE_STATES, SURVEY_TYPE_MAPPING
from awx.main.scheduler.dag_workflow import WorkflowDAG
@@ -2815,6 +2816,7 @@ class JobTemplateSurveySpec(GenericAPIView):
obj_permission_type = 'admin'
serializer_class = serializers.EmptySerializer
resource_purpose = 'job template survey specification'
advertises_survey_spec_schema = True
@extend_schema_if_available(extensions={"x-ai-description": "Get job template survey specification"})
def get(self, request, *args, **kwargs):
@@ -2985,6 +2987,10 @@ class JobTemplateSurveySpec(GenericAPIView):
# Submission provides new encrypted default
survey_item['default'] = encrypt_value(survey_item['default'])
text_errors = collect_survey_spec_text_errors(new_spec, old_spec)
if text_errors:
return Response(text_errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_if_available(extensions={"x-ai-description": "Delete job template survey specification"})
def delete(self, request, *args, **kwargs):
obj = self.get_object()