From d314f83416b302a0bde37a3ff9cd88de59a6180d Mon Sep 17 00:00:00 2001 From: Aaron Tan Date: Thu, 18 May 2017 15:38:32 -0400 Subject: [PATCH] Introduce sso UserEnterpriseAuth model. --- awx/api/serializers.py | 10 ++-- awx/sso/backends.py | 47 ++++++++++--------- awx/sso/migrations/0001_initial.py | 27 +++++++++++ awx/sso/migrations/__init__.py | 0 awx/sso/models.py | 24 ++++++++++ awx/sso/tests/conftest.py | 13 +++++ .../test_get_or_set_enterprise_user.py | 38 +++++++++++++++ awx/sso/tests/functional/test_tacacsplus.py | 24 ---------- awx/sso/tests/unit/test_tacacsplus.py | 19 +------- docs/auth/README.md | 7 ++- 10 files changed, 139 insertions(+), 70 deletions(-) create mode 100644 awx/sso/migrations/0001_initial.py create mode 100644 awx/sso/migrations/__init__.py create mode 100644 awx/sso/tests/functional/test_get_or_set_enterprise_user.py delete mode 100644 awx/sso/tests/functional/test_tacacsplus.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index c4e7b08867..47406ddaf4 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -786,7 +786,8 @@ class UserSerializer(BaseSerializer): getattr(settings, 'SOCIAL_AUTH_GITHUB_TEAM_KEY', None) or getattr(settings, 'SOCIAL_AUTH_SAML_ENABLED_IDPS', None)) and obj.social_auth.all(): new_password = None - if obj.pk and getattr(settings, 'RADIUS_SERVER', '') and not obj.has_usable_password(): + if (getattr(settings, 'RADIUS_SERVER', None) or + getattr(settings, 'TACACSPLUS_HOST', None)) and obj.enterprise_auth.all(): new_password = None if new_password: obj.set_password(new_password) @@ -809,8 +810,9 @@ class UserSerializer(BaseSerializer): getattr(settings, 'SOCIAL_AUTH_GITHUB_TEAM_KEY', None) or getattr(settings, 'SOCIAL_AUTH_SAML_ENABLED_IDPS', None)) and obj.social_auth.all(): account_type = "social" - if obj.pk and getattr(settings, 'RADIUS_SERVER', '') and not obj.has_usable_password(): - account_type = "radius" + if (getattr(settings, 'RADIUS_SERVER', None) or + getattr(settings, 'TACACSPLUS_HOST', None)) and obj.enterprise_auth.all(): + account_type = "enterprise" return account_type def create(self, validated_data): @@ -3437,7 +3439,7 @@ class InstanceGroupSerializer(BaseSerializer): def get_instances(self, obj): return obj.instances.count() - + class ActivityStreamSerializer(BaseSerializer): changes = serializers.SerializerMethodField() diff --git a/awx/sso/backends.py b/awx/sso/backends.py index 56b710a6f1..18868462d3 100644 --- a/awx/sso/backends.py +++ b/awx/sso/backends.py @@ -31,6 +31,7 @@ from social.backends.saml import SAMLIdentityProvider as BaseSAMLIdentityProvide # Ansible Tower from awx.conf.license import feature_enabled +from awx.sso.models import UserEnterpriseAuth logger = logging.getLogger('awx.sso.backends') @@ -119,6 +120,27 @@ class LDAPBackend(BaseLDAPBackend): return set() +def _get_or_set_enterprise_user(username, password, provider): + created = False + try: + user = User.objects.all().prefetch_related('enterprise_auth').get(username=username) + except User.DoesNotExist: + user = User(username=username) + user.set_unusable_password() + user.save() + enterprise_auth = UserEnterpriseAuth(user=user, provider=provider) + enterprise_auth.save() + logger.debug("Created enterprise user %s via %s backend." % + (username, enterprise_auth.get_provider_display())) + created = True + # NOTE: remove has_usable_password logic in a future release. + if created or\ + not user.has_usable_password() or\ + (provider,) in user.enterprise_auth.all().values_list('provider') and not user.has_usable_password(): + return user + logger.warn("Enterprise user %s already defined in Tower." % username) + + class RADIUSBackend(BaseRADIUSBackend): ''' Custom Radius backend to verify license status @@ -143,31 +165,13 @@ class RADIUSBackend(BaseRADIUSBackend): return user def get_django_user(self, username, password=None): - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - logger.debug("Created RADIUS user %s" % (username,)) - user = User(username=username) - user.set_unusable_password() - user.save() - - return user + return _get_or_set_enterprise_user(username, password, 'radius') class TACACSPlusBackend(object): ''' Custom TACACS+ auth backend for AWX ''' - def _get_or_set_user(self, username, password): - user, created = User.objects.get_or_create( - username=username, - defaults={'is_superuser': False}, - ) - if created: - logger.debug("Created TACACS+ user %s" % (username,)) - user.set_unusable_password() - user.save() - return user def authenticate(self, username, password): if not django_settings.TACACSPLUS_HOST: @@ -190,10 +194,7 @@ class TACACSPlusBackend(object): logger.exception("TACACS+ Authentication Error: %s" % (e.message,)) return None if auth.valid: - user = self._get_or_set_user(username, password) - if not user.has_usable_password(): - return user - return None + return _get_or_set_enterprise_user(username, password, 'tacacs+') def get_user(self, user_id): if not django_settings.TACACSPLUS_HOST: diff --git a/awx/sso/migrations/0001_initial.py b/awx/sso/migrations/0001_initial.py new file mode 100644 index 0000000000..540215cf7f --- /dev/null +++ b/awx/sso/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserEnterpriseAuth', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('provider', models.CharField(max_length=32, choices=[(b'radius', 'RADIUS'), (b'tacacs+', 'TACACS+')])), + ('user', models.ForeignKey(related_name='enterprise_auth', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterUniqueTogether( + name='userenterpriseauth', + unique_together=set([('user', 'provider')]), + ), + ] diff --git a/awx/sso/migrations/__init__.py b/awx/sso/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/awx/sso/models.py b/awx/sso/models.py index e484e62be1..d706a2ca5c 100644 --- a/awx/sso/models.py +++ b/awx/sso/models.py @@ -1,2 +1,26 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. + +# Django +from django.db import models +from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ + + +class UserEnterpriseAuth(models.Model): + """Tower Enterprise Auth association model""" + + PROVIDER_CHOICES = ( + ('radius', _('RADIUS')), + ('tacacs+', _('TACACS+')), + ) + + class Meta: + unique_together = ('user', 'provider') + + user = models.ForeignKey( + User, related_name='enterprise_auth', on_delete=models.CASCADE + ) + provider = models.CharField( + max_length=32, choices=PROVIDER_CHOICES + ) diff --git a/awx/sso/tests/conftest.py b/awx/sso/tests/conftest.py index 2051c3fdaa..ffd41aec05 100644 --- a/awx/sso/tests/conftest.py +++ b/awx/sso/tests/conftest.py @@ -3,6 +3,7 @@ import pytest from django.contrib.auth.models import User from awx.sso.backends import TACACSPlusBackend +from awx.sso.models import UserEnterpriseAuth @pytest.fixture @@ -10,6 +11,16 @@ def tacacsplus_backend(): return TACACSPlusBackend() +@pytest.fixture +def existing_normal_user(): + try: + user = User.objects.get(username="alice") + except User.DoesNotExist: + user = User(username="alice", password="password") + user.save() + return user + + @pytest.fixture def existing_tacacsplus_user(): try: @@ -17,6 +28,8 @@ def existing_tacacsplus_user(): except User.DoesNotExist: user = User(username="foo") user.save() + enterprise_auth = UserEnterpriseAuth(user=user, provider='tacacs+') + enterprise_auth.save() return user diff --git a/awx/sso/tests/functional/test_get_or_set_enterprise_user.py b/awx/sso/tests/functional/test_get_or_set_enterprise_user.py new file mode 100644 index 0000000000..2cc914667c --- /dev/null +++ b/awx/sso/tests/functional/test_get_or_set_enterprise_user.py @@ -0,0 +1,38 @@ +# Python +import pytest +import mock + +# Tower +from awx.sso.backends import _get_or_set_enterprise_user + + +@pytest.mark.django_db +def test_fetch_user_if_exist(existing_tacacsplus_user): + new_user = _get_or_set_enterprise_user("foo", "password", "tacacs+") + assert new_user == existing_tacacsplus_user + + +@pytest.mark.django_db +def test_create_user_if_not_exist(existing_tacacsplus_user): + with mock.patch('awx.sso.backends.logger') as mocked_logger: + new_user = _get_or_set_enterprise_user("bar", "password", "tacacs+") + mocked_logger.debug.assert_called_once_with( + u'Created enterprise user bar via TACACS+ backend.' + ) + assert new_user != existing_tacacsplus_user + + +@pytest.mark.django_db +def test_created_user_has_no_usable_password(): + new_user = _get_or_set_enterprise_user("bar", "password", "tacacs+") + assert not new_user.has_usable_password() + + +@pytest.mark.django_db +def test_non_enterprise_user_does_not_get_pass(existing_normal_user): + with mock.patch('awx.sso.backends.logger') as mocked_logger: + new_user = _get_or_set_enterprise_user("alice", "password", "tacacs+") + mocked_logger.warn.assert_called_once_with( + u'Enterprise user alice already defined in Tower.' + ) + assert new_user is None diff --git a/awx/sso/tests/functional/test_tacacsplus.py b/awx/sso/tests/functional/test_tacacsplus.py deleted file mode 100644 index 541c163294..0000000000 --- a/awx/sso/tests/functional/test_tacacsplus.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest -import mock - - -@pytest.mark.django_db -def test_fetch_user_if_exist(tacacsplus_backend, existing_tacacsplus_user): - new_user = tacacsplus_backend._get_or_set_user("foo", "password") - assert new_user == existing_tacacsplus_user - - -@pytest.mark.django_db -def test_create_user_if_not_exist(tacacsplus_backend, existing_tacacsplus_user): - with mock.patch('awx.sso.backends.logger') as mocked_logger: - new_user = tacacsplus_backend._get_or_set_user("bar", "password") - mocked_logger.debug.assert_called_once_with( - 'Created TACACS+ user bar' - ) - assert new_user != existing_tacacsplus_user - - -@pytest.mark.django_db -def test_created_user_has_no_usable_password(tacacsplus_backend): - new_user = tacacsplus_backend._get_or_set_user("bar", "password") - assert not new_user.has_usable_password() diff --git a/awx/sso/tests/unit/test_tacacsplus.py b/awx/sso/tests/unit/test_tacacsplus.py index 58f5ae8c92..7776baf3dd 100644 --- a/awx/sso/tests/unit/test_tacacsplus.py +++ b/awx/sso/tests/unit/test_tacacsplus.py @@ -50,23 +50,6 @@ def test_client_return_invalid_fails_auth(tacacsplus_backend, feature_enabled): assert ret_user is None -def test_user_with_password_fails_auth(tacacsplus_backend, feature_enabled): - auth = mock.MagicMock() - auth.valid = True - client = mock.MagicMock() - client.authenticate.return_value = auth - user = mock.MagicMock() - user.has_usable_password = mock.MagicMock(return_value=True) - with mock.patch('awx.sso.backends.django_settings') as settings,\ - mock.patch('awx.sso.backends.feature_enabled', feature_enabled('enterprise_auth')),\ - mock.patch('tacacs_plus.TACACSClient', return_value=client),\ - mock.patch.object(tacacsplus_backend, '_get_or_set_user', return_value=user): - settings.TACACSPLUS_HOST = 'localhost' - settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' - ret_user = tacacsplus_backend.authenticate(u"user", u"pass") - assert ret_user is None - - def test_client_return_valid_passes_auth(tacacsplus_backend, feature_enabled): auth = mock.MagicMock() auth.valid = True @@ -77,7 +60,7 @@ def test_client_return_valid_passes_auth(tacacsplus_backend, feature_enabled): with mock.patch('awx.sso.backends.django_settings') as settings,\ mock.patch('awx.sso.backends.feature_enabled', feature_enabled('enterprise_auth')),\ mock.patch('tacacs_plus.TACACSClient', return_value=client),\ - mock.patch.object(tacacsplus_backend, '_get_or_set_user', return_value=user): + mock.patch('awx.sso.backends._get_or_set_enterprise_user', return_value=user): settings.TACACSPLUS_HOST = 'localhost' settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' ret_user = tacacsplus_backend.authenticate(u"user", u"pass") diff --git a/docs/auth/README.md b/docs/auth/README.md index f6470e74fd..67bcec8e0e 100644 --- a/docs/auth/README.md +++ b/docs/auth/README.md @@ -16,4 +16,9 @@ On the other hand, the rest of authentication methods use the same types of logi Tower will try authenticating against each enabled authentication method *in the specified order*, meaning if the same username and password is valid in multiple enabled auth methods (For example, both LDAP and TACACS+), Tower will only use the first positive match (In the above example, log a user in via LDAP and skip TACACS+). ## Notes: -* TACACS+/RADIUS users and normal Tower users are strictly separated. For example, suppose there is a TACACS+ user with username 'Alice' which is known to TACACS+ backend but not Tower. If a user record with the same username 'Alice' is created in Tower before any log in attempt, Tower will always use its own authentication backend to authenticate Alice, even if TACACS+ backend is also available. On the other hand, if a successful log in attempt is conducted before any explicit user creation in Tower, a TACACS+ user 'Alice' is automatically created and Tower will always use TACACS+ backend for authenticating 'Alice'. +* RADIUS users and TACACS+ users are categorized as 'Enterprise' users. The following rules apply to Enterprise users: + + * Enterprise users can only be created via the first successful login attempt from remote authentication backend. + * Enterprise users cannot be created/authenticated if non-enterprise users with the same name has already been created in Tower. + * Tower passwords of Enterprise users should always be empty and cannot be set by any user if there are enterprise backends enabled. + * If enterprise backends (RADIUS and TACACS+ for now) are disabled, an Enterprise user can be converted to a normal Tower user by setting password field. But this operation is irreversible (The converted Tower user can no longer be treated as Enterprise user)