From 9d58b15135d301dfcbf93066a028431ee53fdbc7 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Mon, 4 Dec 2017 12:18:44 -0500 Subject: [PATCH 1/5] 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) + From 664bdec57fc45516b82793b9c251d4ffbc394b9b Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Fri, 5 Jan 2018 14:43:33 -0500 Subject: [PATCH 2/5] add documentation --- docs/auth/saml.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/auth/saml.md diff --git a/docs/auth/saml.md b/docs/auth/saml.md new file mode 100644 index 0000000000..24894bfbd0 --- /dev/null +++ b/docs/auth/saml.md @@ -0,0 +1,85 @@ +# SAML +Security Assertion Markup Language, or SAML, is an open standard for exchanging authentication and/or authorization data between an identity provider (i.e. LDAP) and a service provider (i.e. AWX). More concretely, AWX can be configured to talk with SAML in order to authenticate (create/login/logout) users of AWX. User Team and Organization membership can be embedded in the SAML response to AWX. + +# Configure SAML Authentication +Please see the Tower documentation as well as Ansible blog posts for basic SAML configuration. + +# Configure SAML for Team and Organization Membership +AWX can be configured to look for particular attributes that contain AWX Team and Organization membership to associate with users when the login to AWX. The attribute names are defined in AWX settings. Specifically, the authentication settings tab and SAML sub category fields *SAML Team Map* and *SAML Organization Attribute Mapping*. The meaning and usefulness of these settings is best motivated through example. + +**Example SAML Organization Attribute Mapping** +Below is an example SAML attribute that embeds user organization membership in the attribute *member-of*. +``` + + + Engineering + IT + HR + Sales + + +``` +Below, the corresponding AWX configuration. +``` +{ + "saml_attr": "member-of", + "remove": true +} +``` +**saml_attr:** The saml attribute name where the organization array can be found. +**remove:** True to remove user from all organizations before adding the user to the list of Organizations. False to keep the user in whatever Organization(s) they are in while adding the user to the Organization(s) in the SAML attribute. + +**Example SAML Team Map** +Below is another example of a SAML attribute that contains a Team membership in a list. +``` + + + member + staff + + +``` + +``` +{ + "saml_attr": "eduPersonAffiliation", + "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" + } + ] +} +``` +**saml_attr:** The saml attribute name where the team array can be found. +**remove:** True to remove user from all Teams before adding the user to the list of Teams. False to keep the user in whatever Team(s) they are in while adding the user to the Team(s) in the SAML attribute. +**team_org_map:** An array of dictionaries of the form `{ "team": "", "organization": "" }` that defines mapping from AWX Team -> AWX Organization. This is needed because the same named Team can exist in multiple Organizations in Tower. The organization to which a team listed in a SAML attribute belongs to would be ambiguous without this mapping. + From 0a9d3d47b9747a2f163367d19783dabc21074cbb Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Tue, 9 Jan 2018 10:45:10 -0500 Subject: [PATCH 3/5] more efficiently determine saml team mapping --- awx/sso/pipeline.py | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/awx/sso/pipeline.py b/awx/sso/pipeline.py index 299a42ccf8..5fa3e568e3 100644 --- a/awx/sso/pipeline.py +++ b/awx/sso/pipeline.py @@ -186,27 +186,30 @@ def update_user_teams_by_saml_attr(backend, details, user=None, *args, **kwargs) if team_map.get('saml_attr') is None: return - attr_values = kwargs.get('response', {}).get('attributes', {}).get(team_map['saml_attr'], []) + saml_team_names = set(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] + for team_name_map in team_map.get('team_org_map', []): + team_name = team_name_map.get('team', '') + if team_name in saml_team_names: + 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) + 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 From de02138dfdbdcc707cac8b4d8980d670c5db777d Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Wed, 10 Jan 2018 09:26:11 -0500 Subject: [PATCH 4/5] spelling is hard --- docs/auth/saml.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/auth/saml.md b/docs/auth/saml.md index 24894bfbd0..58e765ce20 100644 --- a/docs/auth/saml.md +++ b/docs/auth/saml.md @@ -5,7 +5,7 @@ Security Assertion Markup Language, or SAML, is an open standard for exchanging Please see the Tower documentation as well as Ansible blog posts for basic SAML configuration. # Configure SAML for Team and Organization Membership -AWX can be configured to look for particular attributes that contain AWX Team and Organization membership to associate with users when the login to AWX. The attribute names are defined in AWX settings. Specifically, the authentication settings tab and SAML sub category fields *SAML Team Map* and *SAML Organization Attribute Mapping*. The meaning and usefulness of these settings is best motivated through example. +AWX can be configured to look for particular attributes that contain AWX Team and Organization membership to associate with users when they login to AWX. The attribute names are defined in AWX settings. Specifically, the authentication settings tab and SAML sub category fields *SAML Team Map* and *SAML Organization Attribute Mapping*. The meaning and usefulness of these settings is best motivated through example. **Example SAML Organization Attribute Mapping** Below is an example SAML attribute that embeds user organization membership in the attribute *member-of*. From e49dfd6ee2358b246a40e3992436143fa05c78a9 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Thu, 11 Jan 2018 16:20:49 -0500 Subject: [PATCH 5/5] only run saml pipeline if saml social auth * Do not trigger saml social auth pipeline methods if the user logging in was not created by the saml social auth backend. --- awx/settings/defaults.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index c66de9a208..e60e1f646b 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -493,7 +493,8 @@ else: SOCIAL_AUTH_STRATEGY = 'social_django.strategy.DjangoStrategy' SOCIAL_AUTH_STORAGE = 'social_django.models.DjangoStorage' SOCIAL_AUTH_USER_MODEL = AUTH_USER_MODEL # noqa -SOCIAL_AUTH_PIPELINE = ( + +_SOCIAL_AUTH_PIPELINE_BASE = ( 'social_core.pipeline.social_auth.social_details', 'social_core.pipeline.social_auth.social_uid', 'social_core.pipeline.social_auth.auth_allowed', @@ -507,6 +508,12 @@ SOCIAL_AUTH_PIPELINE = ( 'awx.sso.pipeline.set_is_active_for_new_user', 'social_core.pipeline.user.user_details', 'awx.sso.pipeline.prevent_inactive_login', +) +SOCIAL_AUTH_PIPELINE = _SOCIAL_AUTH_PIPELINE_BASE + ( + 'awx.sso.pipeline.update_user_orgs', + 'awx.sso.pipeline.update_user_teams', +) +SOCIAL_AUTH_SAML_PIPELINE = _SOCIAL_AUTH_PIPELINE_BASE + ( 'awx.sso.pipeline.update_user_orgs_by_saml_attr', 'awx.sso.pipeline.update_user_teams_by_saml_attr', 'awx.sso.pipeline.update_user_orgs',