mirror of
https://github.com/ansible/awx.git
synced 2026-03-07 11:41:08 -03:30
Introduce sso UserEnterpriseAuth model.
This commit is contained in:
@@ -786,7 +786,8 @@ class UserSerializer(BaseSerializer):
|
|||||||
getattr(settings, 'SOCIAL_AUTH_GITHUB_TEAM_KEY', None) or
|
getattr(settings, 'SOCIAL_AUTH_GITHUB_TEAM_KEY', None) or
|
||||||
getattr(settings, 'SOCIAL_AUTH_SAML_ENABLED_IDPS', None)) and obj.social_auth.all():
|
getattr(settings, 'SOCIAL_AUTH_SAML_ENABLED_IDPS', None)) and obj.social_auth.all():
|
||||||
new_password = None
|
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
|
new_password = None
|
||||||
if new_password:
|
if new_password:
|
||||||
obj.set_password(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_GITHUB_TEAM_KEY', None) or
|
||||||
getattr(settings, 'SOCIAL_AUTH_SAML_ENABLED_IDPS', None)) and obj.social_auth.all():
|
getattr(settings, 'SOCIAL_AUTH_SAML_ENABLED_IDPS', None)) and obj.social_auth.all():
|
||||||
account_type = "social"
|
account_type = "social"
|
||||||
if obj.pk and getattr(settings, 'RADIUS_SERVER', '') and not obj.has_usable_password():
|
if (getattr(settings, 'RADIUS_SERVER', None) or
|
||||||
account_type = "radius"
|
getattr(settings, 'TACACSPLUS_HOST', None)) and obj.enterprise_auth.all():
|
||||||
|
account_type = "enterprise"
|
||||||
return account_type
|
return account_type
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
@@ -3437,7 +3439,7 @@ class InstanceGroupSerializer(BaseSerializer):
|
|||||||
def get_instances(self, obj):
|
def get_instances(self, obj):
|
||||||
return obj.instances.count()
|
return obj.instances.count()
|
||||||
|
|
||||||
|
|
||||||
class ActivityStreamSerializer(BaseSerializer):
|
class ActivityStreamSerializer(BaseSerializer):
|
||||||
|
|
||||||
changes = serializers.SerializerMethodField()
|
changes = serializers.SerializerMethodField()
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from social.backends.saml import SAMLIdentityProvider as BaseSAMLIdentityProvide
|
|||||||
|
|
||||||
# Ansible Tower
|
# Ansible Tower
|
||||||
from awx.conf.license import feature_enabled
|
from awx.conf.license import feature_enabled
|
||||||
|
from awx.sso.models import UserEnterpriseAuth
|
||||||
|
|
||||||
logger = logging.getLogger('awx.sso.backends')
|
logger = logging.getLogger('awx.sso.backends')
|
||||||
|
|
||||||
@@ -119,6 +120,27 @@ class LDAPBackend(BaseLDAPBackend):
|
|||||||
return set()
|
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):
|
class RADIUSBackend(BaseRADIUSBackend):
|
||||||
'''
|
'''
|
||||||
Custom Radius backend to verify license status
|
Custom Radius backend to verify license status
|
||||||
@@ -143,31 +165,13 @@ class RADIUSBackend(BaseRADIUSBackend):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
def get_django_user(self, username, password=None):
|
def get_django_user(self, username, password=None):
|
||||||
try:
|
return _get_or_set_enterprise_user(username, password, 'radius')
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class TACACSPlusBackend(object):
|
class TACACSPlusBackend(object):
|
||||||
'''
|
'''
|
||||||
Custom TACACS+ auth backend for AWX
|
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):
|
def authenticate(self, username, password):
|
||||||
if not django_settings.TACACSPLUS_HOST:
|
if not django_settings.TACACSPLUS_HOST:
|
||||||
@@ -190,10 +194,7 @@ class TACACSPlusBackend(object):
|
|||||||
logger.exception("TACACS+ Authentication Error: %s" % (e.message,))
|
logger.exception("TACACS+ Authentication Error: %s" % (e.message,))
|
||||||
return None
|
return None
|
||||||
if auth.valid:
|
if auth.valid:
|
||||||
user = self._get_or_set_user(username, password)
|
return _get_or_set_enterprise_user(username, password, 'tacacs+')
|
||||||
if not user.has_usable_password():
|
|
||||||
return user
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_user(self, user_id):
|
def get_user(self, user_id):
|
||||||
if not django_settings.TACACSPLUS_HOST:
|
if not django_settings.TACACSPLUS_HOST:
|
||||||
|
|||||||
27
awx/sso/migrations/0001_initial.py
Normal file
27
awx/sso/migrations/0001_initial.py
Normal file
@@ -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')]),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
awx/sso/migrations/__init__.py
Normal file
0
awx/sso/migrations/__init__.py
Normal file
@@ -1,2 +1,26 @@
|
|||||||
# Copyright (c) 2015 Ansible, Inc.
|
# Copyright (c) 2015 Ansible, Inc.
|
||||||
# All Rights Reserved.
|
# 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
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import pytest
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
from awx.sso.backends import TACACSPlusBackend
|
from awx.sso.backends import TACACSPlusBackend
|
||||||
|
from awx.sso.models import UserEnterpriseAuth
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -10,6 +11,16 @@ def tacacsplus_backend():
|
|||||||
return TACACSPlusBackend()
|
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
|
@pytest.fixture
|
||||||
def existing_tacacsplus_user():
|
def existing_tacacsplus_user():
|
||||||
try:
|
try:
|
||||||
@@ -17,6 +28,8 @@ def existing_tacacsplus_user():
|
|||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
user = User(username="foo")
|
user = User(username="foo")
|
||||||
user.save()
|
user.save()
|
||||||
|
enterprise_auth = UserEnterpriseAuth(user=user, provider='tacacs+')
|
||||||
|
enterprise_auth.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
38
awx/sso/tests/functional/test_get_or_set_enterprise_user.py
Normal file
38
awx/sso/tests/functional/test_get_or_set_enterprise_user.py
Normal file
@@ -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
|
||||||
@@ -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()
|
|
||||||
@@ -50,23 +50,6 @@ def test_client_return_invalid_fails_auth(tacacsplus_backend, feature_enabled):
|
|||||||
assert ret_user is None
|
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):
|
def test_client_return_valid_passes_auth(tacacsplus_backend, feature_enabled):
|
||||||
auth = mock.MagicMock()
|
auth = mock.MagicMock()
|
||||||
auth.valid = True
|
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,\
|
with mock.patch('awx.sso.backends.django_settings') as settings,\
|
||||||
mock.patch('awx.sso.backends.feature_enabled', feature_enabled('enterprise_auth')),\
|
mock.patch('awx.sso.backends.feature_enabled', feature_enabled('enterprise_auth')),\
|
||||||
mock.patch('tacacs_plus.TACACSClient', return_value=client),\
|
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_HOST = 'localhost'
|
||||||
settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii'
|
settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii'
|
||||||
ret_user = tacacsplus_backend.authenticate(u"user", u"pass")
|
ret_user = tacacsplus_backend.authenticate(u"user", u"pass")
|
||||||
|
|||||||
@@ -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+).
|
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:
|
## 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user