mirror of
https://github.com/ansible/awx.git
synced 2026-10-31 06:26:15 -02:30
[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:
co-authored by
Claude Opus 4.6
parent
e995e5fc5f
commit
3c76e236c2
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user