From 9d58b15135d301dfcbf93066a028431ee53fdbc7 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Mon, 4 Dec 2017 12:18:44 -0500 Subject: [PATCH] allow for saml attributes to define team and org related to https://github.com/ansible/awx/issues/217 * Adds a configure tower in tower setting for users to configure a saml attribute that tower will use to put users into teams and orgs. --- awx/settings/defaults.py | 5 + awx/sso/conf.py | 58 +++++ awx/sso/fields.py | 28 ++- awx/sso/pipeline.py | 75 ++++++ awx/sso/tests/functional/test_pipeline.py | 282 ++++++++++++++++++++++ awx/sso/tests/unit/test_fields.py | 82 +++++++ 6 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 awx/sso/tests/functional/test_pipeline.py create mode 100644 awx/sso/tests/unit/test_fields.py diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 25d8073396..c66de9a208 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -507,6 +507,8 @@ SOCIAL_AUTH_PIPELINE = ( 'awx.sso.pipeline.set_is_active_for_new_user', 'social_core.pipeline.user.user_details', 'awx.sso.pipeline.prevent_inactive_login', + 'awx.sso.pipeline.update_user_orgs_by_saml_attr', + 'awx.sso.pipeline.update_user_teams_by_saml_attr', 'awx.sso.pipeline.update_user_orgs', 'awx.sso.pipeline.update_user_teams', ) @@ -554,6 +556,9 @@ SOCIAL_AUTH_SAML_TECHNICAL_CONTACT = {} SOCIAL_AUTH_SAML_SUPPORT_CONTACT = {} SOCIAL_AUTH_SAML_ENABLED_IDPS = {} +SOCIAL_AUTH_SAML_ORGANIZATION_ATTR = {} +SOCIAL_AUTH_SAML_TEAM_ATTR = {} + # Any ANSIBLE_* settings will be passed to the subprocess environment by the # celery task. diff --git a/awx/sso/conf.py b/awx/sso/conf.py index acaaefc7a7..f2bff4682d 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -1152,6 +1152,64 @@ register( feature_required='enterprise_auth', ) +register( + 'SOCIAL_AUTH_SAML_ORGANIZATION_ATTR', + field_class=fields.SAMLOrgAttrField, + allow_null=True, + default=None, + label=_('SAML Organization Attribute Mapping'), + help_text=_('Used to translate user organization membership into Tower.'), + category=_('SAML'), + category_slug='saml', + placeholder=collections.OrderedDict([ + ('saml_attr', 'organization'), + ('remove', True), + ]), + feature_required='enterprise_auth', +) + +register( + 'SOCIAL_AUTH_SAML_TEAM_ATTR', + field_class=fields.SAMLTeamAttrField, + allow_null=True, + default=None, + label=_('SAML Team Map'), + help_text=_('Used to translate user team membership into Tower.'), + category=_('SAML'), + category_slug='saml', + placeholder=collections.OrderedDict([ + ('saml_attr', 'organization'), + ('remove', True), + ('team_org_map', [ + collections.OrderedDict([ + ('team', 'Marketing'), + ('organization', 'Red Hat'), + ]), + collections.OrderedDict([ + ('team', 'Human Resources'), + ('organization', 'Red Hat'), + ]), + collections.OrderedDict([ + ('team', 'Engineering'), + ('organization', 'Red Hat'), + ]), + collections.OrderedDict([ + ('team', 'Engineering'), + ('organization', 'Ansible'), + ]), + collections.OrderedDict([ + ('team', 'Quality Engineering'), + ('organization', 'Ansible'), + ]), + collections.OrderedDict([ + ('team', 'Sales'), + ('organization', 'Ansible'), + ]), + ]), + ]), + feature_required='enterprise_auth', +) + def tacacs_validate(serializer, attrs): if not serializer.instance or \ diff --git a/awx/sso/fields.py b/awx/sso/fields.py index a483044464..1ddfc1689b 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -3,6 +3,7 @@ import ldap # Django from django.utils.translation import ugettext_lazy as _ +from django.core.exceptions import ValidationError # Django Auth LDAP import django_auth_ldap.config @@ -620,7 +621,7 @@ class SAMLEnabledIdPsField(fields.DictField): child = SAMLIdPField() -class SAMLSecurityField(fields.DictField): +class SAMLSecurityField(BaseDictWithChildField): child_fields = { 'nameIdEncrypted': fields.BooleanField(required=False), @@ -643,3 +644,28 @@ class SAMLSecurityField(fields.DictField): } allow_unknown_keys = True + +class SAMLOrgAttrField(BaseDictWithChildField): + + child_fields = { + 'remove': fields.BooleanField(required=False), + 'saml_attr': fields.CharField(required=False, allow_null=True), + } + + +class SAMLTeamAttrTeamOrgMapField(BaseDictWithChildField): + + child_fields = { + 'team': fields.CharField(required=True, allow_null=False), + 'organization': fields.CharField(required=True, allow_null=False), + } + + +class SAMLTeamAttrField(BaseDictWithChildField): + + child_fields = { + 'team_org_map': fields.ListField(required=False, child=SAMLTeamAttrTeamOrgMapField(), allow_null=True), + 'remove': fields.BooleanField(required=False), + 'saml_attr': fields.CharField(required=False, allow_null=True), + } + diff --git a/awx/sso/pipeline.py b/awx/sso/pipeline.py index d96e9b3147..299a42ccf8 100644 --- a/awx/sso/pipeline.py +++ b/awx/sso/pipeline.py @@ -3,17 +3,22 @@ # Python import re +import logging # Python Social Auth from social_core.exceptions import AuthException # Django from django.utils.translation import ugettext_lazy as _ +from django.db.models import Q # Tower from awx.conf.license import feature_enabled +logger = logging.getLogger('awx.sso.pipeline') + + class AuthNotFound(AuthException): def __init__(self, backend, email_or_uid, *args, **kwargs): @@ -138,3 +143,73 @@ def update_user_teams(backend, details, user=None, *args, **kwargs): users_expr = team_opts.get('users', None) remove = bool(team_opts.get('remove', True)) _update_m2m_from_expression(user, team.member_role.members, users_expr, remove) + + +def update_user_orgs_by_saml_attr(backend, details, user=None, *args, **kwargs): + if not user: + return + from awx.main.models import Organization + from django.conf import settings + multiple_orgs = feature_enabled('multiple_organizations') + org_map = settings.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR + if org_map.get('saml_attr') is None: + return + + attr_values = kwargs.get('response', {}).get('attributes', {}).get(org_map['saml_attr'], []) + + org_ids = [] + + for org_name in attr_values: + if multiple_orgs: + org = Organization.objects.get_or_create(name=org_name)[0] + else: + try: + org = Organization.objects.order_by('pk')[0] + except IndexError: + continue + + org_ids.append(org.id) + org.member_role.members.add(user) + + if org_map.get('remove', True): + [o.member_role.members.remove(user) for o in + Organization.objects.filter(Q(member_role__members=user) & ~Q(id__in=org_ids))] + + +def update_user_teams_by_saml_attr(backend, details, user=None, *args, **kwargs): + if not user: + return + from awx.main.models import Organization, Team + from django.conf import settings + multiple_orgs = feature_enabled('multiple_organizations') + team_map = settings.SOCIAL_AUTH_SAML_TEAM_ATTR + if team_map.get('saml_attr') is None: + return + + attr_values = kwargs.get('response', {}).get('attributes', {}).get(team_map['saml_attr'], []) + + team_ids = [] + for team_name in attr_values: + for team_name_map in team_map.get('team_org_map', []): + if team_name_map.get('team', '') == team_name: + if multiple_orgs: + if not team_name_map.get('organization', ''): + # Settings field validation should prevent this. + logger.error("organization name invalid for team {}".format(team_name)) + continue + org = Organization.objects.get_or_create(name=team_name_map['organization'])[0] + else: + try: + org = Organization.objects.order_by('pk')[0] + except IndexError: + continue + team = Team.objects.get_or_create(name=team_name, organization=org)[0] + + team_ids.append(team.id) + team.member_role.members.add(user) + + if team_map.get('remove', True): + [t.member_role.members.remove(user) for t in + Team.objects.filter(Q(member_role__members=user) & ~Q(id__in=team_ids))] + + diff --git a/awx/sso/tests/functional/test_pipeline.py b/awx/sso/tests/functional/test_pipeline.py new file mode 100644 index 0000000000..0e2abe67a3 --- /dev/null +++ b/awx/sso/tests/functional/test_pipeline.py @@ -0,0 +1,282 @@ + +import pytest +import mock +import re + +from awx.sso.pipeline import ( + update_user_orgs, + update_user_teams, + update_user_orgs_by_saml_attr, + update_user_teams_by_saml_attr, +) + +from awx.main.models import ( + User, + Team, + Organization +) + + +@pytest.fixture +def users(): + u1 = User.objects.create(username='user1@foo.com', last_name='foo', first_name='bar', email='user1@foo.com') + u2 = User.objects.create(username='user2@foo.com', last_name='foo', first_name='bar', email='user2@foo.com') + u3 = User.objects.create(username='user3@foo.com', last_name='foo', first_name='bar', email='user3@foo.com') + return (u1, u2, u3) + + +@pytest.mark.django_db +class TestSAMLMap(): + + @pytest.fixture + def backend(self): + class Backend: + s = { + 'ORGANIZATION_MAP': { + 'Default': { + 'remove': True, + 'admins': 'foobar', + 'remove_admins': True, + 'users': 'foo', + 'remove_users': True, + } + }, + 'TEAM_MAP': { + 'Blue': { + 'organization': 'Default', + 'remove': True, + 'users': '', + }, + 'Red': { + 'organization': 'Default', + 'remove': True, + 'users': '', + } + } + } + + def setting(self, key): + return self.s[key] + + return Backend() + + @pytest.fixture + def org(self): + return Organization.objects.create(name="Default") + + def test_update_user_orgs(self, org, backend, users): + u1, u2, u3 = users + + # Test user membership logic with regular expressions + backend.setting('ORGANIZATION_MAP')['Default']['admins'] = re.compile('.*') + backend.setting('ORGANIZATION_MAP')['Default']['users'] = re.compile('.*') + + update_user_orgs(backend, None, u1) + update_user_orgs(backend, None, u2) + update_user_orgs(backend, None, u3) + + assert org.admin_role.members.count() == 3 + assert org.member_role.members.count() == 3 + + # Test remove feature enabled + backend.setting('ORGANIZATION_MAP')['Default']['admins'] = '' + backend.setting('ORGANIZATION_MAP')['Default']['users'] = '' + backend.setting('ORGANIZATION_MAP')['Default']['remove_admins'] = True + backend.setting('ORGANIZATION_MAP')['Default']['remove_users'] = True + update_user_orgs(backend, None, u1) + + assert org.admin_role.members.count() == 2 + assert org.member_role.members.count() == 2 + + # Test remove feature disabled + backend.setting('ORGANIZATION_MAP')['Default']['remove_admins'] = False + backend.setting('ORGANIZATION_MAP')['Default']['remove_users'] = False + update_user_orgs(backend, None, u2) + + assert org.admin_role.members.count() == 2 + assert org.member_role.members.count() == 2 + + def test_update_user_teams(self, backend, users): + u1, u2, u3 = users + + # Test user membership logic with regular expressions + backend.setting('TEAM_MAP')['Blue']['users'] = re.compile('.*') + backend.setting('TEAM_MAP')['Red']['users'] = re.compile('.*') + + update_user_teams(backend, None, u1) + update_user_teams(backend, None, u2) + update_user_teams(backend, None, u3) + + assert Team.objects.get(name="Red").member_role.members.count() == 3 + assert Team.objects.get(name="Blue").member_role.members.count() == 3 + + # Test remove feature enabled + backend.setting('TEAM_MAP')['Blue']['remove'] = True + backend.setting('TEAM_MAP')['Red']['remove'] = True + backend.setting('TEAM_MAP')['Blue']['users'] = '' + backend.setting('TEAM_MAP')['Red']['users'] = '' + + update_user_teams(backend, None, u1) + + assert Team.objects.get(name="Red").member_role.members.count() == 2 + assert Team.objects.get(name="Blue").member_role.members.count() == 2 + + # Test remove feature disabled + backend.setting('TEAM_MAP')['Blue']['remove'] = False + backend.setting('TEAM_MAP')['Red']['remove'] = False + + update_user_teams(backend, None, u2) + + assert Team.objects.get(name="Red").member_role.members.count() == 2 + assert Team.objects.get(name="Blue").member_role.members.count() == 2 + + +@pytest.mark.django_db +class TestSAMLAttr(): + + @pytest.fixture + def kwargs(self): + return { + 'username': u'cmeyers@redhat.com', + 'uid': 'idp:cmeyers@redhat.com', + 'request': { + u'SAMLResponse': [], + u'RelayState': [u'idp'] + }, + 'is_new': False, + 'response': { + 'session_index': '_0728f0e0-b766-0135-75fa-02842b07c044', + 'idp_name': u'idp', + 'attributes': { + 'memberOf': ['Default1', 'Default2'], + 'groups': ['Blue', 'Red'], + 'User.email': ['cmeyers@redhat.com'], + 'User.LastName': ['Meyers'], + 'name_id': 'cmeyers@redhat.com', + 'User.FirstName': ['Chris'], + 'PersonImmutableID': [] + } + }, + #'social': , + 'social': None, + #'strategy': , + 'strategy': None, + 'new_association': False + } + + @pytest.fixture + def orgs(self): + o1 = Organization.objects.create(name='Default1') + o2 = Organization.objects.create(name='Default2') + o3 = Organization.objects.create(name='Default3') + return (o1, o2, o3) + + @pytest.fixture + def mock_settings(self): + class MockSettings(): + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR = { + 'saml_attr': 'memberOf', + 'remove': True, + } + SOCIAL_AUTH_SAML_TEAM_ATTR = { + 'saml_attr': 'groups', + 'remove': True, + 'team_org_map': [ + {'team': 'Blue', 'organization': 'Default1'}, + {'team': 'Blue', 'organization': 'Default2'}, + {'team': 'Blue', 'organization': 'Default3'}, + {'team': 'Red', 'organization': 'Default1'}, + {'team': 'Green', 'organization': 'Default1'}, + {'team': 'Green', 'organization': 'Default3'}, + ] + } + return MockSettings() + + def test_update_user_orgs_by_saml_attr(self, orgs, users, kwargs, mock_settings): + with mock.patch('django.conf.settings', mock_settings): + o1, o2, o3 = orgs + u1, u2, u3 = users + + # Test getting orgs from attribute + update_user_orgs_by_saml_attr(None, None, u1, **kwargs) + update_user_orgs_by_saml_attr(None, None, u2, **kwargs) + update_user_orgs_by_saml_attr(None, None, u3, **kwargs) + + assert o1.member_role.members.count() == 3 + assert o2.member_role.members.count() == 3 + assert o3.member_role.members.count() == 0 + + # Test remove logic enabled + kwargs['response']['attributes']['memberOf'] = ['Default3'] + + update_user_orgs_by_saml_attr(None, None, u1, **kwargs) + + assert o1.member_role.members.count() == 2 + assert o2.member_role.members.count() == 2 + assert o3.member_role.members.count() == 1 + + # Test remove logic disabled + mock_settings.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR['remove'] = False + kwargs['response']['attributes']['memberOf'] = ['Default1', 'Default2'] + + update_user_orgs_by_saml_attr(None, None, u1, **kwargs) + + assert o1.member_role.members.count() == 3 + assert o2.member_role.members.count() == 3 + assert o3.member_role.members.count() == 1 + + def test_update_user_teams_by_saml_attr(self, orgs, users, kwargs, mock_settings): + with mock.patch('django.conf.settings', mock_settings): + o1, o2, o3 = orgs + u1, u2, u3 = users + + # Test getting teams from attribute with team->org mapping + + kwargs['response']['attributes']['groups'] = ['Blue', 'Red', 'Green'] + + # Ensure basic functionality + update_user_teams_by_saml_attr(None, None, u1, **kwargs) + update_user_teams_by_saml_attr(None, None, u2, **kwargs) + update_user_teams_by_saml_attr(None, None, u3, **kwargs) + + assert Team.objects.get(name='Blue', organization__name='Default1').member_role.members.count() == 3 + assert Team.objects.get(name='Blue', organization__name='Default2').member_role.members.count() == 3 + assert Team.objects.get(name='Blue', organization__name='Default3').member_role.members.count() == 3 + + assert Team.objects.get(name='Red', organization__name='Default1').member_role.members.count() == 3 + + assert Team.objects.get(name='Green', organization__name='Default1').member_role.members.count() == 3 + assert Team.objects.get(name='Green', organization__name='Default3').member_role.members.count() == 3 + + # Test remove logic + kwargs['response']['attributes']['groups'] = ['Green'] + update_user_teams_by_saml_attr(None, None, u1, **kwargs) + update_user_teams_by_saml_attr(None, None, u2, **kwargs) + update_user_teams_by_saml_attr(None, None, u3, **kwargs) + + assert Team.objects.get(name='Blue', organization__name='Default1').member_role.members.count() == 0 + assert Team.objects.get(name='Blue', organization__name='Default2').member_role.members.count() == 0 + assert Team.objects.get(name='Blue', organization__name='Default3').member_role.members.count() == 0 + + assert Team.objects.get(name='Red', organization__name='Default1').member_role.members.count() == 0 + + assert Team.objects.get(name='Green', organization__name='Default1').member_role.members.count() == 3 + assert Team.objects.get(name='Green', organization__name='Default3').member_role.members.count() == 3 + + # Test remove logic disabled + mock_settings.SOCIAL_AUTH_SAML_TEAM_ATTR['remove'] = False + kwargs['response']['attributes']['groups'] = ['Blue'] + + update_user_teams_by_saml_attr(None, None, u1, **kwargs) + update_user_teams_by_saml_attr(None, None, u2, **kwargs) + update_user_teams_by_saml_attr(None, None, u3, **kwargs) + + assert Team.objects.get(name='Blue', organization__name='Default1').member_role.members.count() == 3 + assert Team.objects.get(name='Blue', organization__name='Default2').member_role.members.count() == 3 + assert Team.objects.get(name='Blue', organization__name='Default3').member_role.members.count() == 3 + + assert Team.objects.get(name='Red', organization__name='Default1').member_role.members.count() == 0 + + assert Team.objects.get(name='Green', organization__name='Default1').member_role.members.count() == 3 + assert Team.objects.get(name='Green', organization__name='Default3').member_role.members.count() == 3 + diff --git a/awx/sso/tests/unit/test_fields.py b/awx/sso/tests/unit/test_fields.py new file mode 100644 index 0000000000..df09690c49 --- /dev/null +++ b/awx/sso/tests/unit/test_fields.py @@ -0,0 +1,82 @@ + +import pytest + +from rest_framework.exceptions import ValidationError + +from awx.sso.fields import ( + SAMLOrgAttrField, + SAMLTeamAttrField, +) + + +class TestSAMLOrgAttrField(): + + @pytest.mark.parametrize("data, expected", [ + ({}, {}), + ({'remove': True, 'saml_attr': 'foobar'}, {'remove': True, 'saml_attr': 'foobar'}), + ({'remove': True, 'saml_attr': 1234}, {'remove': True, 'saml_attr': '1234'}), + ({'remove': True, 'saml_attr': 3.14}, {'remove': True, 'saml_attr': '3.14'}), + ({'saml_attr': 'foobar'}, {'saml_attr': 'foobar'}), + ({'remove': True}, {'remove': True}), + ]) + def test_internal_value_valid(self, data, expected): + field = SAMLOrgAttrField() + res = field.to_internal_value(data) + assert res == expected + + @pytest.mark.parametrize("data, expected", [ + ({'remove': 'blah', 'saml_attr': 'foobar'}, + ValidationError('"blah" is not a valid boolean.')), + ({'remove': True, 'saml_attr': False}, + ValidationError('Not a valid string.')), + ({'remove': True, 'saml_attr': False, 'foo': 'bar', 'gig': 'ity'}, + ValidationError('Invalid key(s): "gig", "foo".')), + ]) + def test_internal_value_invalid(self, data, expected): + field = SAMLOrgAttrField() + with pytest.raises(type(expected)) as e: + field.to_internal_value(data) + assert str(e.value) == str(expected) + + +class TestSAMLTeamAttrField(): + + @pytest.mark.parametrize("data", [ + {}, + {'remove': True, 'saml_attr': 'foobar', 'team_org_map': []}, + {'remove': True, 'saml_attr': 'foobar', 'team_org_map': [ + {'team': 'Engineering', 'organization': 'Ansible'} + ]}, + {'remove': True, 'saml_attr': 'foobar', 'team_org_map': [ + {'team': 'Engineering', 'organization': 'Ansible'}, + {'team': 'Engineering', 'organization': 'Ansible2'}, + {'team': 'Engineering2', 'organization': 'Ansible'}, + ]}, + {'remove': True, 'saml_attr': 'foobar', 'team_org_map': [ + {'team': 'Engineering', 'organization': 'Ansible'}, + {'team': 'Engineering', 'organization': 'Ansible2'}, + {'team': 'Engineering2', 'organization': 'Ansible'}, + ]}, + ]) + def test_internal_value_valid(self, data): + field = SAMLTeamAttrField() + res = field.to_internal_value(data) + assert res == data + + @pytest.mark.parametrize("data, expected", [ + ({'remove': True, 'saml_attr': 'foobar', 'team_org_map': [ + {'team': 'foobar', 'not_a_valid_key': 'blah', 'organization': 'Ansible'}, + ]}, ValidationError('Invalid key(s): "not_a_valid_key".')), + ({'remove': False, 'saml_attr': 'foobar', 'team_org_map': [ + {'organization': 'Ansible'}, + ]}, ValidationError('Missing key(s): "team".')), + ({'remove': False, 'saml_attr': 'foobar', 'team_org_map': [ + {}, + ]}, ValidationError('Missing key(s): "organization", "team".')), + ]) + def test_internal_value_invalid(self, data, expected): + field = SAMLTeamAttrField() + with pytest.raises(type(expected)) as e: + field.to_internal_value(data) + assert str(e.value) == str(expected) +