[AAP-78694] Add CleanTextMixin to settings serializer (#16661)

* Add cleantextmixin to settings serializer

* Make excluded_fields a cached property

* Add test coverage for category_slug all

* Fix OPTIONS 500 for settings CleanTextMixin pattern injection

* Apply validation for json and list fields

---------

Co-authored-by: Peter Braun <pbraun@redhat.com>
This commit is contained in:
Pratyush Bhandari
2026-09-22 17:41:47 +02:00
committed by GitHub
co-authored by Peter Braun
parent 2a6dd41186
commit 4c290176ef
5 changed files with 429 additions and 14 deletions
+25 -5
View File
@@ -218,9 +218,14 @@ def reverse_gfk(content_object, request):
class PlainSerializerCleanTextMixin(CleanTextMixin):
"""CleanTextMixin for plain `serializers.Serializer` subclasses with no
backing Django model (AAP-78694). Overrides ONLY field discovery -- treats
the serializer's own declared CharFields as the text fields to validate.
Everything else (Tier 1/2 dispatch, exclusions, audit logging, the
enforcement gate) is reused unchanged from CleanTextMixin.
the serializer's own declared fields as the text/JSON containers to validate.
Everything else (Tier 1/2 dispatch, nested JSON walk, exclusions, audit
logging, the enforcement gate) is reused unchanged from CleanTextMixin.
CharFields (including URLField) are Tier 1/2 top-level text. ListField,
DictField, and JSONField values are walked with CleanTextMixin's nested
string validation (same path as model JSONFields) so settings like
StringListField / KeyValueField are covered.
Known limitation: unlike the model-field path, this can't replicate the
get_internal_type() exclusion of SlugField/IPAddressField-style fields
@@ -228,9 +233,24 @@ class PlainSerializerCleanTextMixin(CleanTextMixin):
using this mixin today, since none declare such fields.
"""
# Structured containers whose string leaves should get validate_free_text.
# Matches model CleanTextMixin's JSONField walk for settings registry
# ListField/DictField/KeyValueField (and DRF JSONField if used).
_NESTED_TEXT_CONTAINER_FIELDS = (
serializers.ListField,
serializers.DictField,
serializers.JSONField,
)
def _classify_fields(self, model):
text_fields = [name for name, field in self.fields.items() if isinstance(field, serializers.CharField)]
return text_fields, []
text_fields = []
json_fields = []
for name, field in self.fields.items():
if isinstance(field, serializers.CharField):
text_fields.append(name)
elif isinstance(field, self._NESTED_TEXT_CONTAINER_FIELDS):
json_fields.append(name)
return text_fields, json_fields
class _CopySerializerFakeModel:
+62 -3
View File
@@ -18,6 +18,9 @@ lands on DAB devel, injection is a no-op so Controller can still import and run.
import copy
from rest_framework import serializers
from ansible_base.lib.serializers.mixins import CleanTextMixin
from ansible_base.lib.utils.settings import get_setting
try:
@@ -30,6 +33,13 @@ try:
except ImportError: # pragma: no cover - DAB without AAP-85987
_dab_inject_clean_text_patterns = None
try:
from ansible_base.lib.metadata import get_tier1_pattern as _get_tier1_pattern
from ansible_base.lib.metadata import get_tier2_pattern as _get_tier2_pattern
except ImportError: # pragma: no cover - DAB without AAP-85987
_get_tier1_pattern = None
_get_tier2_pattern = None
try:
from ansible_base.lib.metadata import TIER2_PATTERN_DESCRIPTION
except ImportError: # pragma: no cover - DAB without AAP-85987
@@ -209,13 +219,62 @@ def collect_survey_spec_text_errors(new_spec, old_spec=None):
return errors or None
def _uses_fake_model_meta(serializer):
"""True when Meta.model is a stand-in without Django's Options.get_field.
Plain serializers (SettingSingletonSerializer, CopySerializer) use a fake
Meta.model so CleanTextMixin audit logs have app_label/object_name. DAB's
inject_clean_text_patterns calls model._meta.get_field() and would 500 on
OPTIONS for those endpoints -- handle them here instead.
"""
model = getattr(getattr(serializer, 'Meta', None), 'model', None)
if model is None:
return False
meta = getattr(model, '_meta', None)
return meta is not None and not hasattr(meta, 'get_field')
def _inject_declared_char_field_patterns(field, field_info, serializer):
"""Tier 1/2 OPTIONS hints for declared CharFields on plain CleanText serializers."""
if _get_tier1_pattern is None or _get_tier2_pattern is None:
return field_info
if not get_setting('ENHANCED_INPUT_VALIDATION_ENABLED', False):
return field_info
if not isinstance(serializer, CleanTextMixin):
return field_info
if not isinstance(field, serializers.CharField):
return field_info
field_name = field.field_name
if field_name in getattr(serializer, 'excluded_fields', frozenset()):
return field_info
if field_name in getattr(serializer, 'name_fields', frozenset()):
tier1 = _get_tier1_pattern()
field_info['pattern'] = tier1['pattern']
field_info['patternDescription'] = tier1['description']
field_info['flags'] = tier1['flags']
field_info['normalize'] = tier1['normalize']
else:
tier2 = _get_tier2_pattern()
field_info['pattern'] = tier2['pattern']
field_info['patternDescription'] = tier2['description']
field_info['flags'] = tier2['flags']
return field_info
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.
For model-backed CleanTextMixin serializers, delegates to DAB's
``inject_clean_text_patterns``. For plain serializers with a fake Meta.model
(no ``_meta.get_field``), injects the same Tier 1/2 hints via DAB's public
``get_tier1_pattern`` / ``get_tier2_pattern`` helpers so OPTIONS still works.
"""
serializer = getattr(field, 'parent', None)
if serializer is not None and _uses_fake_model_meta(serializer):
return _inject_declared_char_field_patterns(field, field_info, serializer)
if _dab_inject_clean_text_patterns is None:
return field_info
return _dab_inject_clean_text_patterns(field, field_info)