[AAP-93690] Add CleanText validation and OPTIONS patterns to subscription wizard (#16671)

* [AAP-93690] Add CleanText validation and OPTIONS patterns to subscriptions endpoint

Add SubscriptionCredentialsSerializer (PlainSerializerCleanTextMixin) for
/api/v2/config/subscriptions/ so POST requests validate credential string
fields through the CleanText enforcement pipeline.

- subscriptions_client_id, subscriptions_username: Tier 2 (free-text)
- subscriptions_client_secret, subscriptions_password: excluded (secrets)
- name_fields = frozenset() ensures Tier 2, not Tier 1
- Fake Meta.model satisfies CleanTextMixin audit logging and OPTIONS metadata
- ApiV2SubscriptionView gains serializer_class and get_serializer() so the
  existing metadata machinery advertises validation patterns on OPTIONS
- post() runs serializer.is_valid() before credential logic, preserving all
  existing error messages, status codes, and $encrypted$ handling
- Functional tests: unsafe-input rejection, secret exclusion, safe passthrough,
  OPTIONS pattern presence/absence, all existing happy/error paths still pass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Chai Bot <chai-bot@redhat.com>

* [AAP-93690] Preserve credential whitespace and improve docstring coverage

DRF CharField strips leading/trailing whitespace by default, which
silently altered subscription credentials before passing them to
validate_rh. Set trim_whitespace=False on all four credential fields
so serializer.validated_data preserves the exact submitted values.

Add four regression tests proving whitespace is passed unchanged to
validate_rh for username, password, client_id, and client_secret.

Add docstrings to concrete_model(), check_permissions(), post(), and
the ApiV2SubscriptionView class to meet the 80% docstring coverage
threshold.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Chai Bot <chai-bot@redhat.com>

* Accept explicit JSON null on SubscriptionCredentialsSerializer fields

DRF CharField rejects explicit JSON null by default, causing a 400
"This field may not be null" error when a frontend sends null for the
unused credential pair (e.g. client_id=null, client_secret=null with a
valid username/password). Add allow_null=True on all four optional
fields so the serializer passes null through to the view, where the
existing falsy-check credential-selection logic already handles it
correctly.

Includes a focused regression test proving a valid username/password
pair with null client fields is accepted and calls validate_rh with
the basic-auth pair.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Chai Bot <chai-bot@redhat.com>

* fix(subscriptions): supply request/view context to SubscriptionCredentialsSerializer

ApiV2SubscriptionView.get_serializer() now injects the standard DRF
serializer context (request, format, view) so that CleanTextMixin's
_log_validation_failure() can attribute rejected input to the
authenticated user and client IP.

post() is updated to call self.get_serializer(data=request.data)
instead of constructing the serializer class directly, ensuring
consistent context propagation.

A focused regression test asserts that the CleanText warning emitted
for unsafe subscription input includes the authenticated username
and client IP address.

Signed-off-by: redhat-chai-bot <redhat-chai-bot@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: redhat-chai-bot <redhat-chai-bot@users.noreply.github.com>

* remove jira ref, decouple test from DAB working

---------

Signed-off-by: Chai Bot <chai-bot@redhat.com>
Signed-off-by: redhat-chai-bot <redhat-chai-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maeve Hoffer
2026-09-30 19:46:55 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e995e5fc5f
commit 3c76e236c2
3 changed files with 326 additions and 1 deletions
+64
View File
@@ -253,6 +253,70 @@ class PlainSerializerCleanTextMixin(CleanTextMixin):
return text_fields, json_fields
class _SubscriptionCredentialsFakeOpts:
"""Minimal stand-in for Django model._meta (AAP-93690).
CleanTextMixin audit logs need app_label/object_name. OPTIONS metadata
(awx.api.metadata.get_field_info) walks Meta.model._meta.fields when
Meta.model is present -- an empty tuple keeps that path safe.
"""
app_label = 'main'
object_name = 'SubscriptionCredentials'
verbose_name = 'subscription credentials'
fields = ()
@property
def concrete_model(self):
"""Return the fake model class so CleanTextMixin can resolve the concrete model."""
return _SubscriptionCredentialsFakeModel
class _SubscriptionCredentialsFakeModel:
"""Stand-in for Meta.model on SubscriptionCredentialsSerializer (AAP-93690).
Never introspected for real fields -- PlainSerializerCleanTextMixin discovers
CharField entries from self.fields instead.
"""
_meta = _SubscriptionCredentialsFakeOpts()
class SubscriptionCredentialsSerializer(PlainSerializerCleanTextMixin, serializers.Serializer):
"""Validate subscription credential fields via CleanTextMixin (AAP-93690).
Uses PlainSerializerCleanTextMixin because there is no Django model behind
the /api/v2/config/subscriptions/ endpoint. All four credential fields are
optional (allow_blank + allow_null + default='') because only one credential
pair is required per request and callers may send explicit JSON ``null`` for
unused fields; mutual-exclusion and required-pair validation lives in the
view's post() handler to preserve its existing error messages/status codes.
CleanText enforcement:
- subscriptions_client_id, subscriptions_username: Tier 2 (free-text)
- subscriptions_client_secret, subscriptions_password: excluded (secrets)
"""
subscriptions_client_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, default='', trim_whitespace=False)
subscriptions_client_secret = serializers.CharField(required=False, allow_blank=True, allow_null=True, default='', trim_whitespace=False)
subscriptions_username = serializers.CharField(required=False, allow_blank=True, allow_null=True, default='', trim_whitespace=False)
subscriptions_password = serializers.CharField(required=False, allow_blank=True, allow_null=True, default='', trim_whitespace=False)
# Secrets must not be validated by CleanText -- they may contain arbitrary
# characters and are never rendered in the UI.
excluded_fields = frozenset({'subscriptions_client_secret', 'subscriptions_password'})
# AC3: client_id and username must use Tier 2 (free-text), not Tier 1
# (resource-name). DEFAULT_NAME_FIELDS includes 'username', which would
# promote *any* field named exactly 'username' to Tier 1 -- our fields are
# 'subscriptions_username' (no match), but an empty set makes the intent
# explicit and future-proof.
name_fields = frozenset()
class Meta:
model = _SubscriptionCredentialsFakeModel
class _CopySerializerFakeModel:
"""Stand-in for Meta.model on CopySerializer, a plain serializers.Serializer
with no backing model of its own (it's reused across many resource types via
+30 -1
View File
@@ -26,6 +26,7 @@ import requests
from ansible_base.lib.utils.schema import extend_schema_if_available
from awx.api.generics import APIView
from awx.api.serializers import SubscriptionCredentialsSerializer
from awx.conf.registry import settings_registry
from awx.main.analytics import all_collectors
from awx.main.ha import is_ha_environment
@@ -177,21 +178,49 @@ class ApiV2PingView(APIView):
class ApiV2SubscriptionView(APIView):
"""Validate and list AAP subscription credentials via the Red Hat portal."""
permission_classes = (IsAuthenticated,)
serializer_class = SubscriptionCredentialsSerializer
name = _('Subscriptions')
swagger_topic = 'System Configuration'
resource_purpose = 'aap subscription validation'
def check_permissions(self, request):
"""Restrict mutating methods to superusers; allow OPTIONS/HEAD for all authenticated users."""
super(ApiV2SubscriptionView, self).check_permissions(request)
if not request.user.is_superuser and request.method.lower() not in {'options', 'head'}:
self.permission_denied(request) # Raises PermissionDenied exception.
def get_serializer(self, *args, **kwargs):
"""Expose the serializer for OPTIONS metadata.
Plain APIView has no get_serializer(); this minimal implementation lets
Metadata.determine_actions() discover the serializer's fields and inject
CleanText validation patterns into OPTIONS responses.
Request and view context is included so that CleanTextMixin's audit
logging can attribute rejected input to the authenticated user and
client IP.
"""
kwargs.setdefault(
'context',
{
'request': self.request,
'format': self.format_kwarg,
'view': self,
},
)
return self.serializer_class(*args, **kwargs)
@extend_schema_if_available(
extensions={'x-ai-description': 'List valid AAP subscriptions'},
)
def post(self, request):
data = request.data.copy()
"""Validate subscription credentials against the Red Hat portal and persist them on success."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
try:
user = None