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.
This commit is contained in:
Chris Meyers 2017-12-04 12:18:44 -05:00
parent 4707b5e020
commit 9d58b15135
6 changed files with 529 additions and 1 deletions

View File

@ -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.

View File

@ -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 \

View File

@ -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),
}

View File

@ -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))]

View File

@ -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': <UserSocialAuth: cmeyers@redhat.com>,
'social': None,
#'strategy': <awx.sso.strategies.django_strategy.AWXDjangoStrategy object at 0x8523a10>,
'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

View File

@ -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)