From e3c362956d03fde47f587a405ed1a3d9dfe48011 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 22 Feb 2018 12:09:55 -0500 Subject: [PATCH 1/5] add ldap group type like posixGroupType * Adds pattern to easy add django-auth-ldap group types classes and to pass parameters via AUTH_LDAP_GROUP_TYPE_PARAMS * Adds new group type PosixUIDGroupType that accepts the attribute, ldap_group_user_attr, on which to search for the user(s) in the group. --- awx/settings/defaults.py | 34 +++++++++++++++++ awx/sso/backends.py | 1 + awx/sso/conf.py | 21 ++++++++++ awx/sso/fields.py | 41 ++++++++++++++++++-- awx/sso/ldap_group_types.py | 76 +++++++++++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 awx/sso/ldap_group_types.py diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 4fdc097c339b..57152aaf1622 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -355,6 +355,40 @@ def IS_TESTING(argv=None): ldap.OPT_NETWORK_TIMEOUT: 30 } +# LDAP Backend settings +_AUTH_LDAP_SETTINGS_BASE = { + "AUTH_LDAP_SERVER_URI": "", + "AUTH_LDAP_BIND_DN": "", + "AUTH_LDAP_BIND_PASSWORD": "", + "AUTH_LDAP_START_TLS": False, + "AUTH_LDAP_USER_SEARCH": [], + "AUTH_LDAP_USER_DN_TEMPLATE": None, + "AUTH_LDAP_USER_ATTR_MAP": {}, + "AUTH_LDAP_GROUP_SEARCH": [], + "AUTH_LDAP_GROUP_TYPE": None, + "AUTH_LDAP_GROUP_TYPE_PARAMS": {}, + "AUTH_LDAP_REQUIRE_GROUP": None, + "AUTH_LDAP_DENY_GROUP": None, + "AUTH_LDAP_USER_FLAGS_BY_GROUP": {}, + "AUTH_LDAP_ORGANIZATION_MAP": {}, + "AUTH_LDAP_TEAM_MAP": {}, +} + + +def generate_ldap_backend(kv, count=None): + num = '' + if count is not None: + num = '{}_'.format(count) + for k,v in kv.iteritems(): + new_k = k.replace('AUTH_LDAP_', 'AUTH_LDAP_{}'.format(num)) + setattr(sys.modules[__name__], new_k, v) + + +generate_ldap_backend(_AUTH_LDAP_SETTINGS_BASE) +for i in xrange(1, 5): + generate_ldap_backend(_AUTH_LDAP_SETTINGS_BASE, i) + + # Radius server settings (default to empty string to skip using Radius auth). # Note: These settings may be overridden by database settings. RADIUS_SERVER = '' diff --git a/awx/sso/backends.py b/awx/sso/backends.py index 03bd7132dab3..4b20ec165f7e 100644 --- a/awx/sso/backends.py +++ b/awx/sso/backends.py @@ -42,6 +42,7 @@ class LDAPSettings(BaseLDAPSettings): defaults = dict(BaseLDAPSettings.defaults.items() + { 'ORGANIZATION_MAP': {}, 'TEAM_MAP': {}, + 'GROUP_TYPE_PARAMS': {}, }.items()) def __init__(self, prefix='AUTH_LDAP_', defaults={}): diff --git a/awx/sso/conf.py b/awx/sso/conf.py index e2c15c96fa95..65513e78822a 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -295,6 +295,27 @@ def _register_ldap(append=None): category_slug='ldap', feature_required='ldap', default='MemberDNGroupType', + depends_on=['AUTH_LDAP{}_GROUP_TYPE_PARAMS'.format(append_str)], + ) + + register( + 'AUTH_LDAP{}_GROUP_TYPE_PARAMS'.format(append_str), + field_class=fields.LDAPGroupTypeParamsField, + label=_('LDAP Group Type'), + help_text=_('Parameters to send the chosen group type.'), + category=_('LDAP'), + category_slug='ldap', + default=collections.OrderedDict([ + #('member_attr', 'member'), + ('name_attr', 'cn'), + ]), + placeholder=collections.OrderedDict([ + ('ldap_group_user_attr', 'legacyuid'), + ('member_attr', 'member'), + ('name_attr', 'cn'), + ]), + feature_required='ldap', + depends_on=['AUTH_LDAP{}_GROUP_TYPE'.format(append_str)], ) register( diff --git a/awx/sso/fields.py b/awx/sso/fields.py index b1868975e180..997a31182323 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -1,5 +1,6 @@ # Python LDAP import ldap +import awx # Django from django.utils.translation import ugettext_lazy as _ @@ -9,6 +10,9 @@ import django_auth_ldap.config from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion +# This must be imported so get_subclasses picks it up +from awx.sso.ldap_group_types import PosixUIDGroupType # noqa + # Tower from awx.conf import fields from awx.conf.fields import * # noqa @@ -335,19 +339,48 @@ def __init__(self, choices=None, **kwargs): def to_representation(self, value): if not value: - return '' + return 'MemberDNGroupType' if not isinstance(value, django_auth_ldap.config.LDAPGroupType): self.fail('type_error', input_type=type(value)) return value.__class__.__name__ def to_internal_value(self, data): + def find_class_in_modules(class_name): + module_search_space = [django_auth_ldap.config, awx.sso.ldap_group_types] + for m in module_search_space: + cls = getattr(m, class_name, None) + if cls: + return cls + return None + data = super(LDAPGroupTypeField, self).to_internal_value(data) if not data: return None + + from django.conf import settings + params = getattr(settings, iter(self.depends_on).next(), None) or {} + cls = find_class_in_modules(data) + if not cls: + return None + + # Per-group type parameter validation and handling here + + # Backwords compatability. Before AUTH_LDAP_GROUP_TYPE_PARAMS existed + # MemberDNGroupType was the only group type, of the underlying lib, that + # took a parameter. + params_sanitized = dict() if data.endswith('MemberDNGroupType'): - return getattr(django_auth_ldap.config, data)(member_attr='member') - else: - return getattr(django_auth_ldap.config, data)() + params.setdefault('member_attr', 'member') + params_sanitized['member_attr'] = params['member_attr'] + elif data.endswith('PosixUIDGroupType'): + params.setdefault('ldap_group_user_attr', 'uid') + params_sanitized['ldap_group_user_attr'] = params['ldap_group_user_attr'] + + return cls(**params_sanitized) + + +class LDAPGroupTypeParamsField(fields.DictField): + pass class LDAPUserFlagsField(fields.DictField): diff --git a/awx/sso/ldap_group_types.py b/awx/sso/ldap_group_types.py new file mode 100644 index 000000000000..4f8402eaa713 --- /dev/null +++ b/awx/sso/ldap_group_types.py @@ -0,0 +1,76 @@ +# Copyright (c) 2017 Ansible by Red Hat +# All Rights Reserved. + +# Python +import ldap + +# Django +from django.utils.encoding import force_str + +# 3rd party +from django_auth_ldap.config import LDAPGroupType + + +class PosixUIDGroupType(LDAPGroupType): + + def __init__(self, ldap_group_user_attr, *args, **kwargs): + super(PosixUIDGroupType, self).__init__(*args, **kwargs) + + self.ldap_group_user_attr = ldap_group_user_attr + + """ + An LDAPGroupType subclass that handles non-standard DS. + """ + def user_groups(self, ldap_user, group_search): + """ + Searches for any group that is either the user's primary or contains the + user as a member. + """ + groups = [] + + try: + user_uid = ldap_user.attrs[self.ldap_group_user_attr][0] + + if 'gidNumber' in ldap_user.attrs: + user_gid = ldap_user.attrs['gidNumber'][0] + filterstr = u'(|(gidNumber=%s)(memberUid=%s))' % ( + self.ldap.filter.escape_filter_chars(user_gid), + self.ldap.filter.escape_filter_chars(user_uid) + ) + else: + filterstr = u'(memberUid=%s)' % ( + self.ldap.filter.escape_filter_chars(user_uid), + ) + + search = group_search.search_with_additional_term_string(filterstr) + groups = search.execute(ldap_user.connection) + except (KeyError, IndexError): + pass + + return groups + + def is_member(self, ldap_user, group_dn): + """ + Returns True if the group is the user's primary group or if the user is + listed in the group's memberUid attribute. + """ + is_member = False + try: + user_uid = ldap_user.attrs[self.ldap_group_user_attr][0] + + try: + is_member = ldap_user.connection.compare_s(force_str(group_dn), 'memberUid', force_str(user_uid)) + except (ldap.UNDEFINED_TYPE, ldap.NO_SUCH_ATTRIBUTE): + is_member = False + + if not is_member: + try: + user_gid = ldap_user.attrs['gidNumber'][0] + is_member = ldap_user.connection.compare_s(force_str(group_dn), 'gidNumber', force_str(user_gid)) + except (ldap.UNDEFINED_TYPE, ldap.NO_SUCH_ATTRIBUTE): + is_member = False + except (KeyError, IndexError): + is_member = False + + return is_member + From 17795f82e865e2d8ee803529fca35f84ff76a2fe Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 15 Mar 2018 16:59:35 -0400 Subject: [PATCH 2/5] more parameters --- awx/sso/conf.py | 1 - awx/sso/fields.py | 4 ++++ awx/sso/ldap_group_types.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/awx/sso/conf.py b/awx/sso/conf.py index 65513e78822a..504b7724d44d 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -306,7 +306,6 @@ def _register_ldap(append=None): category=_('LDAP'), category_slug='ldap', default=collections.OrderedDict([ - #('member_attr', 'member'), ('name_attr', 'cn'), ]), placeholder=collections.OrderedDict([ diff --git a/awx/sso/fields.py b/awx/sso/fields.py index 997a31182323..fbc6ee75d481 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -369,6 +369,10 @@ def find_class_in_modules(class_name): # MemberDNGroupType was the only group type, of the underlying lib, that # took a parameter. params_sanitized = dict() + if isinstance(cls, LDAPGroupType): + if 'name_attr' in params: + params_sanitized['name_attr'] = params['name_attr'] + if data.endswith('MemberDNGroupType'): params.setdefault('member_attr', 'member') params_sanitized['member_attr'] = params['member_attr'] diff --git a/awx/sso/ldap_group_types.py b/awx/sso/ldap_group_types.py index 4f8402eaa713..c898cf28f835 100644 --- a/awx/sso/ldap_group_types.py +++ b/awx/sso/ldap_group_types.py @@ -1,4 +1,4 @@ -# Copyright (c) 2017 Ansible by Red Hat +# Copyright (c) 2018 Ansible by Red Hat # All Rights Reserved. # Python From 1c578cdd74b08e392edc5818b765a65d9f4bfd0a Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 19 Mar 2018 11:32:40 -0400 Subject: [PATCH 3/5] validate group type params --- awx/conf/settings.py | 7 +++- awx/sso/fields.py | 61 +++++++++++++++++++++++++++---- awx/sso/tests/unit/test_fields.py | 21 +++++++++++ 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/awx/conf/settings.py b/awx/conf/settings.py index 0af16846b777..4263deaa1d4e 100644 --- a/awx/conf/settings.py +++ b/awx/conf/settings.py @@ -305,7 +305,7 @@ def _preload_cache(self): settings_to_cache['_awx_conf_preload_expires'] = self._awx_conf_preload_expires self.cache.set_many(settings_to_cache, timeout=SETTING_CACHE_TIMEOUT) - def _get_local(self, name): + def _get_local(self, name, validate=True): self._preload_cache() cache_key = Setting.get_cache_key(name) try: @@ -368,7 +368,10 @@ def _get_local(self, name): field.run_validators(internal_value) return internal_value else: - return field.run_validation(value) + if validate: + return field.run_validation(value) + else: + return value except Exception: logger.warning( 'The current value "%r" for setting "%s" is invalid.', diff --git a/awx/sso/fields.py b/awx/sso/fields.py index fbc6ee75d481..94b82d7c9863 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -8,7 +8,11 @@ # Django Auth LDAP import django_auth_ldap.config -from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion +from django_auth_ldap.config import ( + LDAPSearch, + LDAPSearchUnion, + LDAPGroupType, +) # This must be imported so get_subclasses picks it up from awx.sso.ldap_group_types import PosixUIDGroupType # noqa @@ -28,6 +32,25 @@ def get_subclasses(cls): yield subclass +class DependsOnMixin(): + def get_depends_on(self): + """ + Get the value of the dependent field. + First try to find the value in the request. + Then fall back to the raw value from the setting in the DB. + """ + from django.conf import settings + dependent_key = iter(self.depends_on).next() + + if self.context: + request = self.context.get('request', None) + if request and request.data and \ + request.data.get(dependent_key, None): + return request.data.get(dependent_key) + res = settings._get_local(dependent_key, validate=False) + return res + + class AuthenticationBackendsField(fields.StringListField): # Mapping of settings that must be set in order to enable each @@ -326,7 +349,15 @@ def to_internal_value(self, data): return data -class LDAPGroupTypeField(fields.ChoiceField): +VALID_GROUP_TYPE_PARAMS_MAP = { + 'LDAPGroupType': ['name_attr'], + 'MemberDNGroupType': ['name_attr', 'member_attr'], + 'PosixUIDGroupType': ['name_attr', 'ldap_group_user_attr'], +} + + + +class LDAPGroupTypeField(fields.ChoiceField, DependsOnMixin): default_error_messages = { 'type_error': _('Expected an instance of LDAPGroupType but got {input_type} instead.'), @@ -357,8 +388,7 @@ def find_class_in_modules(class_name): if not data: return None - from django.conf import settings - params = getattr(settings, iter(self.depends_on).next(), None) or {} + params = self.get_depends_on() or {} cls = find_class_in_modules(data) if not cls: return None @@ -370,8 +400,9 @@ def find_class_in_modules(class_name): # took a parameter. params_sanitized = dict() if isinstance(cls, LDAPGroupType): - if 'name_attr' in params: - params_sanitized['name_attr'] = params['name_attr'] + for k in VALID_GROUP_TYPE_PARAMS_MAP['LDAPGroupType']: + if k in params: + params_sanitized['name_attr'] = params['name_attr'] if data.endswith('MemberDNGroupType'): params.setdefault('member_attr', 'member') @@ -383,8 +414,22 @@ def find_class_in_modules(class_name): return cls(**params_sanitized) -class LDAPGroupTypeParamsField(fields.DictField): - pass +class LDAPGroupTypeParamsField(fields.DictField, DependsOnMixin): + default_error_messages = { + 'invalid_keys': _('Invalid key(s): {invalid_keys}.'), + } + + def to_internal_value(self, value): + value = super(LDAPGroupTypeParamsField, self).to_internal_value(value) + if not value: + return value + group_type_str = self.get_depends_on() + group_type_str = group_type_str or '' + invalid_keys = (set(value.keys()) - set(VALID_GROUP_TYPE_PARAMS_MAP.get(group_type_str, 'LDAPGroupType'))) + if invalid_keys: + keys_display = json.dumps(list(invalid_keys)).lstrip('[').rstrip(']') + self.fail('invalid_keys', invalid_keys=keys_display) + return value class LDAPUserFlagsField(fields.DictField): diff --git a/awx/sso/tests/unit/test_fields.py b/awx/sso/tests/unit/test_fields.py index df09690c49df..11136652416d 100644 --- a/awx/sso/tests/unit/test_fields.py +++ b/awx/sso/tests/unit/test_fields.py @@ -1,11 +1,13 @@ import pytest +import mock from rest_framework.exceptions import ValidationError from awx.sso.fields import ( SAMLOrgAttrField, SAMLTeamAttrField, + LDAPGroupTypeParamsField, ) @@ -80,3 +82,22 @@ def test_internal_value_invalid(self, data, expected): field.to_internal_value(data) assert str(e.value) == str(expected) + +class TestLDAPGroupTypeParamsField(): + + @pytest.mark.parametrize("group_type, data, expected", [ + ('LDAPGroupType', {'name_attr': 'user', 'bob': ['a', 'b'], 'scooter': 'hello'}, + ValidationError('Invalid key(s): "bob", "scooter".')), + ('MemberDNGroupType', {'name_attr': 'user', 'member_attr': 'west', 'bob': ['a', 'b'], 'scooter': 'hello'}, + ValidationError('Invalid key(s): "bob", "scooter".')), + ('PosixUIDGroupType', {'name_attr': 'user', 'member_attr': 'west', 'ldap_group_user_attr': 'legacyThing', + 'bob': ['a', 'b'], 'scooter': 'hello'}, + ValidationError('Invalid key(s): "bob", "member_attr", "scooter".')), + ]) + def test_internal_value_invalid(self, group_type, data, expected): + field = LDAPGroupTypeParamsField() + field.get_depends_on = mock.MagicMock(return_value=group_type) + + with pytest.raises(type(expected)) as e: + field.to_internal_value(data) + assert str(e.value) == str(expected) From cb7e17885f4718d459828c8c640b1e513e087d52 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 23 Mar 2018 15:41:08 -0400 Subject: [PATCH 4/5] remove uneeded auth ldap settings * I had thought that setting the settings was required. But carefully selected defaults for the settings is the correct way to deal with errors I was seeing early in developing this feature. --- awx/settings/defaults.py | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 57152aaf1622..4fdc097c339b 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -355,40 +355,6 @@ def IS_TESTING(argv=None): ldap.OPT_NETWORK_TIMEOUT: 30 } -# LDAP Backend settings -_AUTH_LDAP_SETTINGS_BASE = { - "AUTH_LDAP_SERVER_URI": "", - "AUTH_LDAP_BIND_DN": "", - "AUTH_LDAP_BIND_PASSWORD": "", - "AUTH_LDAP_START_TLS": False, - "AUTH_LDAP_USER_SEARCH": [], - "AUTH_LDAP_USER_DN_TEMPLATE": None, - "AUTH_LDAP_USER_ATTR_MAP": {}, - "AUTH_LDAP_GROUP_SEARCH": [], - "AUTH_LDAP_GROUP_TYPE": None, - "AUTH_LDAP_GROUP_TYPE_PARAMS": {}, - "AUTH_LDAP_REQUIRE_GROUP": None, - "AUTH_LDAP_DENY_GROUP": None, - "AUTH_LDAP_USER_FLAGS_BY_GROUP": {}, - "AUTH_LDAP_ORGANIZATION_MAP": {}, - "AUTH_LDAP_TEAM_MAP": {}, -} - - -def generate_ldap_backend(kv, count=None): - num = '' - if count is not None: - num = '{}_'.format(count) - for k,v in kv.iteritems(): - new_k = k.replace('AUTH_LDAP_', 'AUTH_LDAP_{}'.format(num)) - setattr(sys.modules[__name__], new_k, v) - - -generate_ldap_backend(_AUTH_LDAP_SETTINGS_BASE) -for i in xrange(1, 5): - generate_ldap_backend(_AUTH_LDAP_SETTINGS_BASE, i) - - # Radius server settings (default to empty string to skip using Radius auth). # Note: These settings may be overridden by database settings. RADIUS_SERVER = '' From b9b85027387f38cb5a00671ba425fe3763884e5d Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 26 Mar 2018 09:47:51 -0400 Subject: [PATCH 5/5] introspect ldap group types for param validation * Instead of keeping a hard-coded mapping of valid args for each ldap group type; introspect the subclass to determine valid/invalid fields --- awx/sso/fields.py | 51 ++++++++++++++++--------------------- awx/sso/ldap_group_types.py | 5 ++-- 2 files changed, 24 insertions(+), 32 deletions(-) diff --git a/awx/sso/fields.py b/awx/sso/fields.py index 94b82d7c9863..0e7434f44377 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -11,7 +11,6 @@ from django_auth_ldap.config import ( LDAPSearch, LDAPSearchUnion, - LDAPGroupType, ) # This must be imported so get_subclasses picks it up @@ -32,6 +31,18 @@ def get_subclasses(cls): yield subclass +def find_class_in_modules(class_name): + ''' + Used to find ldap subclasses by string + ''' + module_search_space = [django_auth_ldap.config, awx.sso.ldap_group_types] + for m in module_search_space: + cls = getattr(m, class_name, None) + if cls: + return cls + return None + + class DependsOnMixin(): def get_depends_on(self): """ @@ -349,14 +360,6 @@ def to_internal_value(self, data): return data -VALID_GROUP_TYPE_PARAMS_MAP = { - 'LDAPGroupType': ['name_attr'], - 'MemberDNGroupType': ['name_attr', 'member_attr'], - 'PosixUIDGroupType': ['name_attr', 'ldap_group_user_attr'], -} - - - class LDAPGroupTypeField(fields.ChoiceField, DependsOnMixin): default_error_messages = { @@ -376,14 +379,6 @@ def to_representation(self, value): return value.__class__.__name__ def to_internal_value(self, data): - def find_class_in_modules(class_name): - module_search_space = [django_auth_ldap.config, awx.sso.ldap_group_types] - for m in module_search_space: - cls = getattr(m, class_name, None) - if cls: - return cls - return None - data = super(LDAPGroupTypeField, self).to_internal_value(data) if not data: return None @@ -399,17 +394,9 @@ def find_class_in_modules(class_name): # MemberDNGroupType was the only group type, of the underlying lib, that # took a parameter. params_sanitized = dict() - if isinstance(cls, LDAPGroupType): - for k in VALID_GROUP_TYPE_PARAMS_MAP['LDAPGroupType']: - if k in params: - params_sanitized['name_attr'] = params['name_attr'] - - if data.endswith('MemberDNGroupType'): - params.setdefault('member_attr', 'member') - params_sanitized['member_attr'] = params['member_attr'] - elif data.endswith('PosixUIDGroupType'): - params.setdefault('ldap_group_user_attr', 'uid') - params_sanitized['ldap_group_user_attr'] = params['ldap_group_user_attr'] + for attr in inspect.getargspec(cls.__init__).args[1:]: + if attr in params: + params_sanitized[attr] = params[attr] return cls(**params_sanitized) @@ -425,7 +412,13 @@ def to_internal_value(self, value): return value group_type_str = self.get_depends_on() group_type_str = group_type_str or '' - invalid_keys = (set(value.keys()) - set(VALID_GROUP_TYPE_PARAMS_MAP.get(group_type_str, 'LDAPGroupType'))) + + group_type_cls = find_class_in_modules(group_type_str) + if not group_type_cls: + # Fail safe + return {} + + invalid_keys = set(value.keys()) - set(inspect.getargspec(group_type_cls.__init__).args[1:]) if invalid_keys: keys_display = json.dumps(list(invalid_keys)).lstrip('[').rstrip(']') self.fail('invalid_keys', invalid_keys=keys_display) diff --git a/awx/sso/ldap_group_types.py b/awx/sso/ldap_group_types.py index c898cf28f835..84144e0af303 100644 --- a/awx/sso/ldap_group_types.py +++ b/awx/sso/ldap_group_types.py @@ -13,10 +13,9 @@ class PosixUIDGroupType(LDAPGroupType): - def __init__(self, ldap_group_user_attr, *args, **kwargs): - super(PosixUIDGroupType, self).__init__(*args, **kwargs) - + def __init__(self, name_attr='cn', ldap_group_user_attr='uid'): self.ldap_group_user_attr = ldap_group_user_attr + super(PosixUIDGroupType, self).__init__(name_attr) """ An LDAPGroupType subclass that handles non-standard DS.