diff --git a/awx/conf/fields.py b/awx/conf/fields.py index 9a748593c7..50aca441e0 100644 --- a/awx/conf/fields.py +++ b/awx/conf/fields.py @@ -53,6 +53,47 @@ class StringListField(ListField): return super(StringListField, self).to_representation(value) +class StringListBooleanField(ListField): + + default_error_messages = { + 'type_error': _('Expected None, True, False, a string or list of strings but got {input_type} instead.'), + } + child = CharField() + + def to_representation(self, value): + try: + if isinstance(value, (list, tuple)): + return super(StringListBooleanField, self).to_representation(value) + elif value in NullBooleanField.TRUE_VALUES: + return True + elif value in NullBooleanField.FALSE_VALUES: + return False + elif value in NullBooleanField.NULL_VALUES: + return None + elif isinstance(value, basestring): + return self.child.to_representation(value) + except TypeError: + pass + + self.fail('type_error', input_type=type(value)) + + def to_internal_value(self, data): + try: + if isinstance(data, (list, tuple)): + return super(StringListBooleanField, self).to_internal_value(data) + elif data in NullBooleanField.TRUE_VALUES: + return True + elif data in NullBooleanField.FALSE_VALUES: + return False + elif data in NullBooleanField.NULL_VALUES: + return None + elif isinstance(data, basestring): + return self.child.run_validation(data) + except TypeError: + pass + self.fail('type_error', input_type=type(data)) + + class URLField(CharField): def __init__(self, **kwargs): @@ -100,3 +141,25 @@ class KeyValueField(DictField): if not isinstance(value, six.string_types + six.integer_types + (float,)): self.fail('invalid_child', input=value) return ret + + +class ListTuplesField(ListField): + default_error_messages = { + 'type_error': _('Expected a list of tuples of max length 2 but got {input_type} instead.'), + } + + def to_representation(self, value): + if isinstance(value, (list, tuple)): + return super(ListTuplesField, self).to_representation(value) + else: + self.fail('type_error', input_type=type(value)) + + def to_internal_value(self, data): + if isinstance(data, list): + for x in data: + if not isinstance(x, (list, tuple)) or len(x) > 2: + self.fail('type_error', input_type=type(x)) + + return super(ListTuplesField, self).to_internal_value(data) + else: + self.fail('type_error', input_type=type(data)) diff --git a/awx/conf/tests/unit/test_fields.py b/awx/conf/tests/unit/test_fields.py new file mode 100644 index 0000000000..0126723e97 --- /dev/null +++ b/awx/conf/tests/unit/test_fields.py @@ -0,0 +1,86 @@ +import pytest + +from rest_framework.fields import ValidationError +from awx.conf.fields import StringListBooleanField, ListTuplesField + + +class TestStringListBooleanField(): + + FIELD_VALUES = [ + ("hello", "hello"), + (("a", "b"), ["a", "b"]), + (["a", "b", 1, 3.13, "foo", "bar", "foobar"], ["a", "b", "1", "3.13", "foo", "bar", "foobar"]), + ("True", True), + ("TRUE", True), + ("true", True), + (True, True), + ("False", False), + ("FALSE", False), + ("false", False), + (False, False), + ("", None), + ("null", None), + ("NULL", None), + ] + + FIELD_VALUES_INVALID = [ + 1.245, + {"a": "b"}, + ] + + @pytest.mark.parametrize("value_in, value_known", FIELD_VALUES) + def test_to_internal_value_valid(self, value_in, value_known): + field = StringListBooleanField() + v = field.to_internal_value(value_in) + assert v == value_known + + @pytest.mark.parametrize("value", FIELD_VALUES_INVALID) + def test_to_internal_value_invalid(self, value): + field = StringListBooleanField() + with pytest.raises(ValidationError) as e: + field.to_internal_value(value) + assert e.value.detail[0] == "Expected None, True, False, a string or list " \ + "of strings but got {} instead.".format(type(value)) + + @pytest.mark.parametrize("value_in, value_known", FIELD_VALUES) + def test_to_representation_valid(self, value_in, value_known): + field = StringListBooleanField() + v = field.to_representation(value_in) + assert v == value_known + + @pytest.mark.parametrize("value", FIELD_VALUES_INVALID) + def test_to_representation_invalid(self, value): + field = StringListBooleanField() + with pytest.raises(ValidationError) as e: + field.to_representation(value) + assert e.value.detail[0] == "Expected None, True, False, a string or list " \ + "of strings but got {} instead.".format(type(value)) + + +class TestListTuplesField(): + + FIELD_VALUES = [ + ([('a', 'b'), ('abc', '123')], [("a", "b"), ("abc", "123")]), + ] + + FIELD_VALUES_INVALID = [ + ("abc", type("abc")), + ([('a', 'b', 'c'), ('abc', '123', '456')], type(('a',))), + (['a', 'b'], type('a')), + (123, type(123)), + ] + + @pytest.mark.parametrize("value_in, value_known", FIELD_VALUES) + def test_to_internal_value_valid(self, value_in, value_known): + field = ListTuplesField() + v = field.to_internal_value(value_in) + assert v == value_known + + @pytest.mark.parametrize("value, t", FIELD_VALUES_INVALID) + def test_to_internal_value_invalid(self, value, t): + field = ListTuplesField() + with pytest.raises(ValidationError) as e: + field.to_internal_value(value) + assert e.value.detail[0] == "Expected a list of tuples of max length 2 " \ + "but got {} instead.".format(t) + diff --git a/awx/sso/conf.py b/awx/sso/conf.py index 156229c430..acaaefc7a7 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -1061,6 +1061,71 @@ register( feature_required='enterprise_auth', ) +register( + 'SOCIAL_AUTH_SAML_SECURITY_CONFIG', + field_class=fields.SAMLSecurityField, + allow_null=True, + default={'requestedAuthnContext': False}, + label=_('SAML Security Config'), + help_text=_('A dict of key value pairs that are passed to the underlying' + ' python-saml security setting' + ' https://github.com/onelogin/python-saml#settings'), + category=_('SAML'), + category_slug='saml', + placeholder=collections.OrderedDict([ + ("nameIdEncrypted", False), + ("authnRequestsSigned", False), + ("logoutRequestSigned", False), + ("logoutResponseSigned", False), + ("signMetadata", False), + ("wantMessagesSigned", False), + ("wantAssertionsSigned", False), + ("wantAssertionsEncrypted", False), + ("wantNameId", True), + ("wantNameIdEncrypted", False), + ("wantAttributeStatement", True), + ("requestedAuthnContext", True), + ("requestedAuthnContextComparison", "exact"), + ("metadataValidUntil", "2015-06-26T20:00:00Z"), + ("metadataCacheDuration", "PT518400S"), + ("signatureAlgorithm", "http://www.w3.org/2000/09/xmldsig#rsa-sha1"), + ("digestAlgorithm", "http://www.w3.org/2000/09/xmldsig#sha1"), + ]), + feature_required='enterprise_auth', +) + +register( + 'SOCIAL_AUTH_SAML_SP_EXTRA', + field_class=fields.DictField, + allow_null=True, + default=None, + label=_('SAML Service Provider extra configuration data'), + help_text=_('A dict of key value pairs to be passed to the underlying' + ' python-saml Service Provider configuration setting.'), + category=_('SAML'), + category_slug='saml', + placeholder=collections.OrderedDict(), + feature_required='enterprise_auth', +) + +register( + 'SOCIAL_AUTH_SAML_EXTRA_DATA', + field_class=fields.ListTuplesField, + allow_null=True, + default=None, + label=_('SAML IDP to extra_data attribute mapping'), + help_text=_('A list of tuples that maps IDP attributes to extra_attributes.' + ' Each attribute will be a list of values, even if only 1 value.'), + category=_('SAML'), + category_slug='saml', + placeholder=[ + ('attribute_name', 'extra_data_name_for_attribute'), + ('department', 'department'), + ('manager_full_name', 'manager_full_name') + ], + feature_required='enterprise_auth', +) + register( 'SOCIAL_AUTH_SAML_ORGANIZATION_MAP', field_class=fields.SocialOrganizationMapField, diff --git a/awx/sso/fields.py b/awx/sso/fields.py index c2400bae31..a483044464 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -345,41 +345,10 @@ class LDAPUserFlagsField(fields.DictField): return data -class LDAPDNMapField(fields.ListField): +class LDAPDNMapField(fields.StringListBooleanField): - default_error_messages = { - 'type_error': _('Expected None, True, False, a string or list of strings but got {input_type} instead.'), - } child = LDAPDNField() - def to_representation(self, value): - if isinstance(value, (list, tuple)): - return super(LDAPDNMapField, self).to_representation(value) - elif value in fields.NullBooleanField.TRUE_VALUES: - return True - elif value in fields.NullBooleanField.FALSE_VALUES: - return False - elif value in fields.NullBooleanField.NULL_VALUES: - return None - elif isinstance(value, basestring): - return self.child.to_representation(value) - else: - self.fail('type_error', input_type=type(value)) - - def to_internal_value(self, data): - if isinstance(data, (list, tuple)): - return super(LDAPDNMapField, self).to_internal_value(data) - elif data in fields.NullBooleanField.TRUE_VALUES: - return True - elif data in fields.NullBooleanField.FALSE_VALUES: - return False - elif data in fields.NullBooleanField.NULL_VALUES: - return None - elif isinstance(data, basestring): - return self.child.run_validation(data) - else: - self.fail('type_error', input_type=type(data)) - class BaseDictWithChildField(fields.DictField): @@ -649,3 +618,28 @@ class SAMLIdPField(BaseDictWithChildField): class SAMLEnabledIdPsField(fields.DictField): child = SAMLIdPField() + + +class SAMLSecurityField(fields.DictField): + + child_fields = { + 'nameIdEncrypted': fields.BooleanField(required=False), + 'authnRequestsSigned': fields.BooleanField(required=False), + 'logoutRequestSigned': fields.BooleanField(required=False), + 'logoutResponseSigned': fields.BooleanField(required=False), + 'signMetadata': fields.BooleanField(required=False), + 'wantMessagesSigned': fields.BooleanField(required=False), + 'wantAssertionsSigned': fields.BooleanField(required=False), + 'wantAssertionsEncrypted': fields.BooleanField(required=False), + 'wantNameId': fields.BooleanField(required=False), + 'wantNameIdEncrypted': fields.BooleanField(required=False), + 'wantAttributeStatement': fields.BooleanField(required=False), + 'requestedAuthnContext': fields.StringListBooleanField(required=False), + 'requestedAuthnContextComparison': fields.CharField(required=False), + 'metadataValidUntil': fields.CharField(allow_null=True, required=False), + 'metadataCacheDuration': fields.CharField(allow_null=True, required=False), + 'signatureAlgorithm': fields.CharField(allow_null=True, required=False), + 'digestAlgorithm': fields.CharField(allow_null=True, required=False), + } + allow_unknown_keys = True +