From 3e19f6f9de5aacf03bbf352d4575d1585f60ff53 Mon Sep 17 00:00:00 2001 From: Djebran Lezzoum Date: Wed, 18 Sep 2024 15:07:45 +0200 Subject: [PATCH] Remove sso app Remove sso app. --- Makefile | 4 +- awx/api/conf.py | 3 +- awx/api/serializers.py | 23 +- awx/api/views/__init__.py | 40 +- awx/conf/signals.py | 15 - awx/conf/tests/functional/test_api.py | 19 - awx/main/access.py | 5 +- awx/main/middleware.py | 4 +- awx/main/migrations/0196_delete_profile.py | 16 + .../migrations/0197_remove_sso_app_content.py | 27 + awx/main/models/__init__.py | 16 +- awx/main/models/oauth.py | 12 - awx/main/models/organization.py | 22 +- awx/main/tests/functional/api/test_oauth.py | 49 +- .../tests/functional/api/test_settings.py | 197 -- awx/main/tests/functional/test_ldap.py | 103 - .../unit/commands/test_dump_auth_config.py | 132 -- awx/settings/defaults.py | 115 +- awx/sso/__init__.py | 2 - awx/sso/apps.py | 8 - awx/sso/backends.py | 469 ----- awx/sso/common.py | 213 --- awx/sso/conf.py | 1667 ----------------- awx/sso/fields.py | 725 ------- awx/sso/ldap_group_types.py | 73 - awx/sso/middleware.py | 80 - awx/sso/migrations/0001_initial.py | 21 - .../0002_expand_provider_options.py | 16 - .../0003_convert_saml_string_to_list.py | 58 - awx/sso/migrations/__init__.py | 0 awx/sso/models.py | 19 - awx/sso/saml_pipeline.py | 291 --- awx/sso/social_base_pipeline.py | 39 - awx/sso/social_pipeline.py | 90 - awx/sso/tests/__init__.py | 0 awx/sso/tests/conftest.py | 34 - awx/sso/tests/functional/__init__.py | 0 awx/sso/tests/functional/test_backends.py | 115 -- awx/sso/tests/functional/test_common.py | 377 ---- .../test_get_or_set_enterprise_user.py | 37 - awx/sso/tests/functional/test_ldap.py | 19 - .../tests/functional/test_saml_pipeline.py | 711 ------- .../functional/test_social_base_pipeline.py | 76 - .../tests/functional/test_social_pipeline.py | 113 -- awx/sso/tests/test_env.py | 4 - awx/sso/tests/unit/test_fields.py | 235 --- awx/sso/tests/unit/test_ldap.py | 25 - awx/sso/tests/unit/test_pipelines.py | 12 - awx/sso/tests/unit/test_tacacsplus.py | 116 -- awx/sso/urls.py | 15 - awx/sso/validators.py | 74 - awx/sso/views.py | 68 - awx/urls.py | 4 +- awx/wsgi.py | 1 - awx_collection/test/awx/test_settings.py | 30 - licenses/defusedxml.txt | 48 - licenses/python-jose.txt | 21 - licenses/social-auth-app-django.txt | 27 - licenses/social-auth-core.txt | 27 - requirements/requirements.in | 2 - requirements/requirements.txt | 19 - 61 files changed, 72 insertions(+), 6711 deletions(-) create mode 100644 awx/main/migrations/0196_delete_profile.py create mode 100644 awx/main/migrations/0197_remove_sso_app_content.py delete mode 100644 awx/main/tests/functional/test_ldap.py delete mode 100644 awx/main/tests/unit/commands/test_dump_auth_config.py delete mode 100644 awx/sso/__init__.py delete mode 100644 awx/sso/apps.py delete mode 100644 awx/sso/backends.py delete mode 100644 awx/sso/common.py delete mode 100644 awx/sso/conf.py delete mode 100644 awx/sso/fields.py delete mode 100644 awx/sso/ldap_group_types.py delete mode 100644 awx/sso/middleware.py delete mode 100644 awx/sso/migrations/0001_initial.py delete mode 100644 awx/sso/migrations/0002_expand_provider_options.py delete mode 100644 awx/sso/migrations/0003_convert_saml_string_to_list.py delete mode 100644 awx/sso/migrations/__init__.py delete mode 100644 awx/sso/models.py delete mode 100644 awx/sso/saml_pipeline.py delete mode 100644 awx/sso/social_base_pipeline.py delete mode 100644 awx/sso/social_pipeline.py delete mode 100644 awx/sso/tests/__init__.py delete mode 100644 awx/sso/tests/conftest.py delete mode 100644 awx/sso/tests/functional/__init__.py delete mode 100644 awx/sso/tests/functional/test_backends.py delete mode 100644 awx/sso/tests/functional/test_common.py delete mode 100644 awx/sso/tests/functional/test_get_or_set_enterprise_user.py delete mode 100644 awx/sso/tests/functional/test_ldap.py delete mode 100644 awx/sso/tests/functional/test_saml_pipeline.py delete mode 100644 awx/sso/tests/functional/test_social_base_pipeline.py delete mode 100644 awx/sso/tests/functional/test_social_pipeline.py delete mode 100644 awx/sso/tests/test_env.py delete mode 100644 awx/sso/tests/unit/test_fields.py delete mode 100644 awx/sso/tests/unit/test_ldap.py delete mode 100644 awx/sso/tests/unit/test_pipelines.py delete mode 100644 awx/sso/tests/unit/test_tacacsplus.py delete mode 100644 awx/sso/urls.py delete mode 100644 awx/sso/validators.py delete mode 100644 awx/sso/views.py delete mode 100644 licenses/defusedxml.txt delete mode 100644 licenses/python-jose.txt delete mode 100644 licenses/social-auth-app-django.txt delete mode 100644 licenses/social-auth-core.txt diff --git a/Makefile b/Makefile index 60aae0395cb0..7f7f76d73ae3 100644 --- a/Makefile +++ b/Makefile @@ -345,7 +345,7 @@ api-lint: awx-link: [ -d "/awx_devel/awx.egg-info" ] || $(PYTHON) /awx_devel/tools/scripts/egg_info_dev -TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests +TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests PYTEST_ARGS ?= -n auto ## Run all API unit tests. test: @@ -446,7 +446,7 @@ test_unit: @if [ "$(VENV_BASE)" ]; then \ . $(VENV_BASE)/awx/bin/activate; \ fi; \ - py.test awx/main/tests/unit awx/conf/tests/unit awx/sso/tests/unit + py.test awx/main/tests/unit awx/conf/tests/unit ## Output test coverage as HTML (into htmlcov directory). coverage_html: diff --git a/awx/api/conf.py b/awx/api/conf.py index 72aaf3eec3d9..89ab7cadf617 100644 --- a/awx/api/conf.py +++ b/awx/api/conf.py @@ -8,7 +8,6 @@ from awx.conf import fields, register, register_validate from awx.api.fields import OAuth2ProviderField from oauth2_provider.settings import oauth2_settings -from awx.sso.common import is_remote_auth_enabled register( @@ -109,7 +108,7 @@ def authentication_validate(serializer, attrs): - if attrs.get('DISABLE_LOCAL_AUTH', False) and not is_remote_auth_enabled(): + if attrs.get('DISABLE_LOCAL_AUTH', False): raise serializers.ValidationError(_("There are no remote authentication systems configured.")) return attrs diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 231bf3bbcda0..44e7f173464b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -134,8 +134,6 @@ # AWX Utils from awx.api.validators import HostnameRegexValidator -from awx.sso.common import get_external_account - logger = logging.getLogger('awx.api.serializers') # Fields that should be summarized regardless of object type. @@ -961,8 +959,6 @@ def get_types(self): class UserSerializer(BaseSerializer): password = serializers.CharField(required=False, default='', help_text=_('Field used to change the password.')) - ldap_dn = serializers.CharField(source='profile.ldap_dn', read_only=True) - external_account = serializers.SerializerMethodField(help_text=_('Set if the account is managed by an external service')) is_system_auditor = serializers.BooleanField(default=False) show_capabilities = ['edit', 'delete'] @@ -979,22 +975,13 @@ class Meta: 'is_superuser', 'is_system_auditor', 'password', - 'ldap_dn', 'last_login', - 'external_account', ) extra_kwargs = {'last_login': {'read_only': True}} def to_representation(self, obj): ret = super(UserSerializer, self).to_representation(obj) - if self.get_external_account(obj): - # If this is an external account it shouldn't have a password field - ret.pop('password', None) - else: - # If its an internal account lets assume there is a password and return $encrypted$ to the user - ret['password'] = '$encrypted$' - if obj and type(self) is UserSerializer: - ret['auth'] = obj.social_auth.values('provider', 'uid') + ret['password'] = '$encrypted$' return ret def get_validation_exclusions(self, obj=None): @@ -1027,10 +1014,7 @@ def validate_password(self, value): return value def _update_password(self, obj, new_password): - # For now we're not raising an error, just not saving password for - # users managed by LDAP who already have an unusable password set. - # Get external password will return something like ldap or enterprise or None if the user isn't external. We only want to allow a password update for a None option - if new_password and new_password != '$encrypted$' and not self.get_external_account(obj): + if new_password and new_password != '$encrypted$': obj.set_password(new_password) obj.save(update_fields=['password']) @@ -1045,9 +1029,6 @@ def _update_password(self, obj, new_password): obj.set_unusable_password() obj.save(update_fields=['password']) - def get_external_account(self, obj): - return get_external_account(obj) - def create(self, validated_data): new_password = validated_data.pop('password', None) is_system_auditor = validated_data.pop('is_system_auditor', None) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index b93a7d8d5e53..5ae17d07e0cc 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -50,9 +50,6 @@ # ansi2html from ansi2html import Ansi2HTMLConverter -# Python Social Auth -from social_core.backends.utils import load_backends - # Django OAuth Toolkit from oauth2_provider.models import get_access_token_model @@ -129,6 +126,9 @@ from awx.api.pagination import UnifiedJobEventPagination from awx.main.utils import set_environ +if 'ansible_base.authentication' in getattr(settings, "INSTALLED_APPS", []): + from ansible_base.authentication.models.authenticator import Authenticator as AnsibleBaseAuthenticator + logger = logging.getLogger('awx.api.views') @@ -684,30 +684,18 @@ class AuthView(APIView): swagger_topic = 'System Configuration' def get(self, request): - from rest_framework.reverse import reverse - data = OrderedDict() - err_backend, err_message = request.session.get('social_auth_error', (None, None)) - auth_backends = list(load_backends(settings.AUTHENTICATION_BACKENDS, force_load=True).items()) - # Return auth backends in consistent order: Google, GitHub, SAML. - auth_backends.sort(key=lambda x: 'g' if x[0] == 'google-oauth2' else x[0]) - for name, backend in auth_backends: - login_url = reverse('social:begin', args=(name,)) - complete_url = request.build_absolute_uri(reverse('social:complete', args=(name,))) - backend_data = {'login_url': login_url, 'complete_url': complete_url} - if name == 'saml': - backend_data['metadata_url'] = reverse('sso:saml_metadata') - for idp in sorted(settings.SOCIAL_AUTH_SAML_ENABLED_IDPS.keys()): - saml_backend_data = dict(backend_data.items()) - saml_backend_data['login_url'] = '%s?idp=%s' % (login_url, idp) - full_backend_name = '%s:%s' % (name, idp) - if (err_backend == full_backend_name or err_backend == name) and err_message: - saml_backend_data['error'] = err_message - data[full_backend_name] = saml_backend_data - else: - if err_backend == name and err_message: - backend_data['error'] = err_message - data[name] = backend_data + if 'ansible_base.authentication' in getattr(settings, "INSTALLED_APPS", []): + # app is using ansible_base authentication + # add ansible_base authenticators + authenticators = AnsibleBaseAuthenticator.objects.filter(enabled=True, category="sso") + for authenticator in authenticators: + login_url = authenticator.get_login_url() + data[authenticator.name] = { + 'login_url': login_url, + 'name': authenticator.name, + } + return Response(data) diff --git a/awx/conf/signals.py b/awx/conf/signals.py index d8297becb40e..fb96019a7890 100644 --- a/awx/conf/signals.py +++ b/awx/conf/signals.py @@ -61,18 +61,3 @@ def on_post_delete_setting(sender, **kwargs): key = getattr(instance, '_saved_key_', None) if key: handle_setting_change(key, True) - - -@receiver(setting_changed) -def disable_local_auth(**kwargs): - if (kwargs['setting'], kwargs['value']) == ('DISABLE_LOCAL_AUTH', True): - from django.contrib.auth.models import User - from oauth2_provider.models import RefreshToken - from awx.main.models.oauth import OAuth2AccessToken - from awx.main.management.commands.revoke_oauth2_tokens import revoke_tokens - - logger.warning("Triggering token invalidation for local users.") - - qs = User.objects.filter(profile__ldap_dn='', enterprise_auth__isnull=True, social_auth__isnull=True) - revoke_tokens(RefreshToken.objects.filter(revoked=None, user__in=qs)) - revoke_tokens(OAuth2AccessToken.objects.filter(user__in=qs)) diff --git a/awx/conf/tests/functional/test_api.py b/awx/conf/tests/functional/test_api.py index b600c3766db6..183fd7b8b682 100644 --- a/awx/conf/tests/functional/test_api.py +++ b/awx/conf/tests/functional/test_api.py @@ -8,7 +8,6 @@ from awx.conf import fields from awx.conf.registry import settings_registry from awx.conf.models import Setting -from awx.sso import fields as sso_fields @pytest.fixture @@ -103,24 +102,6 @@ def test_setting_singleton_update(api_request, dummy_setting): assert response.data['FOO_BAR'] == 4 -@pytest.mark.django_db -def test_setting_singleton_update_hybriddictfield_with_forbidden(api_request, dummy_setting): - # Some HybridDictField subclasses have a child of _Forbidden, - # indicating that only the defined fields can be filled in. Make - # sure that the _Forbidden validator doesn't get used for the - # fields. See also https://github.com/ansible/awx/issues/4099. - with dummy_setting('FOO_BAR', field_class=sso_fields.SAMLOrgAttrField, category='FooBar', category_slug='foobar'), mock.patch( - 'awx.conf.views.clear_setting_cache' - ): - api_request( - 'patch', - reverse('api:setting_singleton_detail', kwargs={'category_slug': 'foobar'}), - data={'FOO_BAR': {'saml_admin_attr': 'Admins', 'saml_attr': 'Orgs'}}, - ) - response = api_request('get', reverse('api:setting_singleton_detail', kwargs={'category_slug': 'foobar'})) - assert response.data['FOO_BAR'] == {'saml_admin_attr': 'Admins', 'saml_attr': 'Orgs'} - - @pytest.mark.django_db def test_setting_singleton_update_dont_change_readonly_fields(api_request, dummy_setting): with dummy_setting('FOO_BAR', field_class=fields.IntegerField, read_only=True, default=4, category='FooBar', category_slug='foobar'), mock.patch( diff --git a/awx/main/access.py b/awx/main/access.py index 3a217fe2afa4..74c604436807 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -642,10 +642,7 @@ class UserAccess(BaseAccess): """ model = User - prefetch_related = ( - 'profile', - 'resource', - ) + prefetch_related = ('resource',) def filtered_queryset(self): if settings.ORG_ADMINS_CAN_SEE_ALL_USERS and (self.user.admin_of_organizations.exists() or self.user.auditor_of_organizations.exists()): diff --git a/awx/main/middleware.py b/awx/main/middleware.py index 433ade596fe4..3fc54bc2465e 100644 --- a/awx/main/middleware.py +++ b/awx/main/middleware.py @@ -93,8 +93,8 @@ def process_request(self, request): user = request.user if not user.pk: return - if not (user.profile.ldap_dn or user.social_auth.exists() or user.enterprise_auth.exists()): - logout(request) + + logout(request) class URLModificationMiddleware(MiddlewareMixin): diff --git a/awx/main/migrations/0196_delete_profile.py b/awx/main/migrations/0196_delete_profile.py new file mode 100644 index 000000000000..bdfdf90b480e --- /dev/null +++ b/awx/main/migrations/0196_delete_profile.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.10 on 2024-09-16 10:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0195_EE_permissions'), + ] + + operations = [ + migrations.DeleteModel( + name='Profile', + ), + ] diff --git a/awx/main/migrations/0197_remove_sso_app_content.py b/awx/main/migrations/0197_remove_sso_app_content.py new file mode 100644 index 000000000000..71bbb33f19a1 --- /dev/null +++ b/awx/main/migrations/0197_remove_sso_app_content.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.10 on 2024-09-16 15:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0196_delete_profile'), + ] + + operations = [ + # delete all sso application migrations + migrations.RunSQL("DELETE FROM django_migrations WHERE app = 'sso';"), + # delete all sso application content group permissions + migrations.RunSQL( + "DELETE FROM auth_group_permissions " + "WHERE permission_id IN " + "(SELECT id FROM auth_permission WHERE content_type_id in (SELECT id FROM django_content_type WHERE app_label = 'sso'));" + ), + # delete all sso application content permissions + migrations.RunSQL("DELETE FROM auth_permission " "WHERE content_type_id IN (SELECT id FROM django_content_type WHERE app_label = 'sso');"), + # delete sso application content type + migrations.RunSQL("DELETE FROM django_content_type WHERE app_label = 'sso';"), + # drop sso application created table + migrations.RunSQL("DROP TABLE IF EXISTS sso_userenterpriseauth;"), + ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index a799b077f30a..427d9fb0a349 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -18,7 +18,7 @@ # AWX from awx.main.models.base import BaseModel, PrimordialModel, accepts_json, CLOUD_INVENTORY_SOURCES, VERBOSITY_CHOICES # noqa from awx.main.models.unified_jobs import UnifiedJob, UnifiedJobTemplate, StdoutMaxBytesExceeded # noqa -from awx.main.models.organization import Organization, Profile, Team, UserSessionMembership # noqa +from awx.main.models.organization import Organization, Team, UserSessionMembership # noqa from awx.main.models.credential import Credential, CredentialType, CredentialInputSource, ManagedCredentialType, build_safe_env # noqa from awx.main.models.projects import Project, ProjectUpdate # noqa from awx.main.models.receptor_address import ReceptorAddress # noqa @@ -244,20 +244,6 @@ def user_is_system_auditor(user, tf): User.add_to_class('is_system_auditor', user_is_system_auditor) -def user_is_in_enterprise_category(user, category): - ret = (category,) in user.enterprise_auth.values_list('provider') and not user.has_usable_password() - # NOTE: this if-else block ensures existing enterprise users are still able to - # log in. Remove it in a future release - if category == 'radius': - ret = ret or not user.has_usable_password() - elif category == 'saml': - ret = ret or user.social_auth.all() - return ret - - -User.add_to_class('is_in_enterprise_category', user_is_in_enterprise_category) - - def o_auth2_application_get_absolute_url(self, request=None): return reverse('api:o_auth2_application_detail', kwargs={'pk': self.pk}, request=request) diff --git a/awx/main/models/oauth.py b/awx/main/models/oauth.py index fbd77721194f..adda62d5741e 100644 --- a/awx/main/models/oauth.py +++ b/awx/main/models/oauth.py @@ -12,9 +12,7 @@ # Django OAuth Toolkit from oauth2_provider.models import AbstractApplication, AbstractAccessToken from oauth2_provider.generators import generate_client_secret -from oauthlib import oauth2 -from awx.sso.common import get_external_account from awx.main.fields import OAuth2ClientSecretField @@ -123,15 +121,5 @@ def _update_last_used(): connection.on_commit(_update_last_used) return valid - def validate_external_users(self): - if self.user and settings.ALLOW_OAUTH2_FOR_EXTERNAL_USERS is False: - external_account = get_external_account(self.user) - if external_account is not None: - raise oauth2.AccessDeniedError( - _('OAuth2 Tokens cannot be created by users associated with an external authentication provider ({})').format(external_account) - ) - def save(self, *args, **kwargs): - if not self.pk: - self.validate_external_users() super(OAuth2AccessToken, self).save(*args, **kwargs) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 939595ea9e9c..23ce7598a296 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -15,8 +15,8 @@ # AWX from awx.api.versioning import reverse -from awx.main.fields import AutoOneToOneField, ImplicitRoleField, OrderedManyToManyField -from awx.main.models.base import BaseModel, CommonModel, CommonModelNameNotUnique, CreatedModifiedModel, NotificationFieldsModel +from awx.main.fields import ImplicitRoleField, OrderedManyToManyField +from awx.main.models.base import BaseModel, CommonModel, CommonModelNameNotUnique, NotificationFieldsModel from awx.main.models.rbac import ( ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_AUDITOR, @@ -24,7 +24,7 @@ from awx.main.models.unified_jobs import UnifiedJob from awx.main.models.mixins import ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin -__all__ = ['Organization', 'Team', 'Profile', 'UserSessionMembership'] +__all__ = ['Organization', 'Team', 'UserSessionMembership'] class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin): @@ -167,22 +167,6 @@ def get_absolute_url(self, request=None): return reverse('api:team_detail', kwargs={'pk': self.pk}, request=request) -class Profile(CreatedModifiedModel): - """ - Profile model related to User object. Currently stores LDAP DN for users - loaded from LDAP. - """ - - class Meta: - app_label = 'main' - - user = AutoOneToOneField('auth.User', related_name='profile', editable=False, on_delete=models.CASCADE) - ldap_dn = models.CharField( - max_length=1024, - default='', - ) - - class UserSessionMembership(BaseModel): """ A lookup table for API session membership given user. Note, there is a diff --git a/awx/main/tests/functional/api/test_oauth.py b/awx/main/tests/functional/api/test_oauth.py index e95d2cdc4abf..032b74d4963f 100644 --- a/awx/main/tests/functional/api/test_oauth.py +++ b/awx/main/tests/functional/api/test_oauth.py @@ -13,8 +13,7 @@ from awx.main.utils.encryption import decrypt_value, get_encryption_key from awx.api.versioning import reverse from awx.main.models.oauth import OAuth2Application as Application, OAuth2AccessToken as AccessToken -from awx.main.tests.functional import immediate_on_commit -from awx.sso.models import UserEnterpriseAuth + from oauth2_provider.models import RefreshToken @@ -33,52 +32,6 @@ def test_personal_access_token_creation(oauth_application, post, alice): assert 'refresh_token' in resp_json -@pytest.mark.django_db -@pytest.mark.parametrize('allow_oauth, status', [(True, 201), (False, 403)]) -def test_token_creation_disabled_for_external_accounts(oauth_application, post, alice, allow_oauth, status): - UserEnterpriseAuth(user=alice, provider='radius').save() - url = drf_reverse('api:oauth_authorization_root_view') + 'token/' - - with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USERS=allow_oauth): - resp = post( - url, - data='grant_type=password&username=alice&password=alice&scope=read', - content_type='application/x-www-form-urlencoded', - HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))), - status=status, - ) - if allow_oauth: - assert AccessToken.objects.count() == 1 - else: - assert 'OAuth2 Tokens cannot be created by users associated with an external authentication provider' in smart_str(resp.content) # noqa - assert AccessToken.objects.count() == 0 - - -@pytest.mark.django_db -def test_existing_token_enabled_for_external_accounts(oauth_application, get, post, admin): - UserEnterpriseAuth(user=admin, provider='radius').save() - url = drf_reverse('api:oauth_authorization_root_view') + 'token/' - with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USERS=True): - resp = post( - url, - data='grant_type=password&username=admin&password=admin&scope=read', - content_type='application/x-www-form-urlencoded', - HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([oauth_application.client_id, oauth_application.client_secret])))), - status=201, - ) - token = json.loads(resp.content)['access_token'] - assert AccessToken.objects.count() == 1 - - with immediate_on_commit(): - resp = get(drf_reverse('api:user_me_list', kwargs={'version': 'v2'}), HTTP_AUTHORIZATION='Bearer ' + token, status=200) - assert json.loads(resp.content)['results'][0]['username'] == 'admin' - - with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USER=False): - with immediate_on_commit(): - resp = get(drf_reverse('api:user_me_list', kwargs={'version': 'v2'}), HTTP_AUTHORIZATION='Bearer ' + token, status=200) - assert json.loads(resp.content)['results'][0]['username'] == 'admin' - - @pytest.mark.django_db def test_pat_creation_no_default_scope(oauth_application, post, admin): # tests that the default scope is overriden diff --git a/awx/main/tests/functional/api/test_settings.py b/awx/main/tests/functional/api/test_settings.py index a84a6f7f6acd..e096637dc28c 100644 --- a/awx/main/tests/functional/api/test_settings.py +++ b/awx/main/tests/functional/api/test_settings.py @@ -7,7 +7,6 @@ # AWX from awx.api.versioning import reverse -from awx.conf.models import Setting from awx.conf.registry import settings_registry TEST_GIF_LOGO = 'data:image/gif;base64,R0lGODlhIQAjAPIAAP//////AP8AAMzMAJmZADNmAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJCgAHACwAAAAAIQAjAAADo3i63P4wykmrvTjrzZsxXfR94WMQBFh6RECuixHMLyzPQ13ewZCvow9OpzEAjIBj79cJJmU+FceIVEZ3QRozxBttmyOBwPBtisdX4Bha3oxmS+llFIPHQXQKkiSEXz9PeklHBzx3hYNyEHt4fmmAhHp8Nz45KgV5FgWFOFEGmwWbGqEfniChohmoQZ+oqRiZDZhEgk81I4mwg4EKVbxzrDHBEAkAIfkECQoABwAsAAAAACEAIwAAA6V4utz+MMpJq724GpP15p1kEAQYQmOwnWjgrmxjuMEAx8rsDjZ+fJvdLWQAFAHGWo8FRM54JqIRmYTigDrDMqZTbbbMj0CgjTLHZKvPQH6CTx+a2vKR0XbbOsoZ7SphG057gjl+c0dGgzeGNiaBiSgbBQUHBV08NpOVlkMSk0FKjZuURHiiOJxQnSGfQJuoEKREejK0dFRGjoiQt7iOuLx0rgxYEQkAIfkECQoABwAsAAAAACEAIwAAA7h4utxnxslJDSGR6nrz/owxYB64QUEwlGaVqlB7vrAJscsd3Lhy+wBArGEICo3DUFH4QDqK0GMy51xOgcGlEAfJ+iAFie62chR+jYKaSAuQGOqwJp7jGQRDuol+F/jxZWsyCmoQfwYwgoM5Oyg1i2w0A2WQIW2TPYOIkleQmy+UlYygoaIPnJmapKmqKiusMmSdpjxypnALtrcHioq3ury7hGm3dnVosVpMWFmwREZbddDOSsjVswcJACH5BAkKAAcALAAAAAAhACMAAAOxeLrc/jDKSZUxNS9DCNYV54HURQwfGRlDEFwqdLVuGjOsW9/Odb0wnsUAKBKNwsMFQGwyNUHckVl8bqI4o43lA26PNkv1S9DtNuOeVirw+aTI3qWAQwnud1vhLSnQLS0GeFF+GoVKNF0fh4Z+LDQ6Bn5/MTNmL0mAl2E3j2aclTmRmYCQoKEDiaRDKFhJez6UmbKyQowHtzy1uEl8DLCnEktrQ2PBD1NxSlXKIW5hz6cJACH5BAkKAAcALAAAAAAhACMAAAOkeLrc/jDKSau9OOvNlTFd9H3hYxAEWDJfkK5LGwTq+g0zDR/GgM+10A04Cm56OANgqTRmkDTmSOiLMgFOTM9AnFJHuexzYBAIijZf2SweJ8ttbbXLmd5+wBiJosSCoGF/fXEeS1g8gHl9hxODKkh4gkwVIwUekESIhA4FlgV3PyCWG52WI2oGnR2lnUWpqhqVEF4Xi7QjhpsshpOFvLosrnpoEAkAIfkECQoABwAsAAAAACEAIwAAA6l4utz+MMpJq71YGpPr3t1kEAQXQltQnk8aBCa7bMMLy4wx1G8s072PL6SrGQDI4zBThCU/v50zCVhidIYgNPqxWZkDg0AgxB2K4vEXbBSvr1JtZ3uOext0x7FqovF6OXtfe1UzdjAxhINPM013ChtJER8FBQeVRX8GlpggFZWWfjwblTiigGZnfqRmpUKbljKxDrNMeY2eF4R8jUiSur6/Z8GFV2WBtwwJACH5BAkKAAcALAAAAAAhACMAAAO6eLrcZi3KyQwhkGpq8f6ONWQgaAxB8JTfg6YkO50pzD5xhaurhCsGAKCnEw6NucNDCAkyI8ugdAhFKpnJJdMaeiofBejowUseCr9GYa0j1GyMdVgjBxoEuPSZXWKf7gKBeHtzMms0gHgGfDIVLztmjScvNZEyk28qjT40b5aXlHCbDgOhnzedoqOOlKeopaqrCy56sgtotbYKhYW6e7e9tsHBssO6eSTIm1peV0iuFUZDyU7NJnmcuQsJACH5BAkKAAcALAAAAAAhACMAAAOteLrc/jDKSZsxNS9DCNYV54Hh4H0kdAXBgKaOwbYX/Miza1vrVe8KA2AoJL5gwiQgeZz4GMXlcHl8xozQ3kW3KTajL9zsBJ1+sV2fQfALem+XAlRApxu4ioI1UpC76zJ4fRqDBzI+LFyFhH1iiS59fkgziW07jjRAG5QDeECOLk2Tj6KjnZafW6hAej6Smgevr6yysza2tiCuMasUF2Yov2gZUUQbU8YaaqjLpQkAOw==' # NOQA @@ -66,129 +65,6 @@ def test_awx_task_env_validity(get, patch, admin, value, expected): assert resp.data['AWX_TASK_ENV'] == dict() -@pytest.mark.django_db -def test_ldap_settings(get, put, patch, delete, admin): - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) - get(url, user=admin, expect=200) - # The PUT below will fail at the moment because AUTH_LDAP_GROUP_TYPE - # defaults to None but cannot be set to None. - # put(url, user=admin, data=response.data, expect=200) - delete(url, user=admin, expect=204) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': ''}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldap.example.com'}, expect=400) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldap://ldap.example.com'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldaps://ldap.example.com'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldap://ldap.example.com:389'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldaps://ldap.example.com:636'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldap://ldap.example.com ldap://ldap2.example.com'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldap://ldap.example.com,ldap://ldap2.example.com'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_SERVER_URI': 'ldap://ldap.example.com, ldap://ldap2.example.com'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_BIND_DN': 'cn=Manager,dc=example,dc=com'}, expect=200) - patch(url, user=admin, data={'AUTH_LDAP_BIND_DN': u'cn=暴力膜,dc=大新闻,dc=真的粉丝'}, expect=200) - - -@pytest.mark.django_db -@pytest.mark.parametrize( - 'value', - [ - None, - '', - 'INVALID', - 1, - [1], - ['INVALID'], - ], -) -def test_ldap_user_flags_by_group_invalid_dn(get, patch, admin, value): - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) - patch(url, user=admin, data={'AUTH_LDAP_USER_FLAGS_BY_GROUP': {'is_superuser': value}}, expect=400) - - -@pytest.mark.django_db -def test_ldap_user_flags_by_group_string(get, patch, admin): - expected = 'CN=Admins,OU=Groups,DC=example,DC=com' - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) - patch(url, user=admin, data={'AUTH_LDAP_USER_FLAGS_BY_GROUP': {'is_superuser': expected}}, expect=200) - resp = get(url, user=admin) - assert resp.data['AUTH_LDAP_USER_FLAGS_BY_GROUP']['is_superuser'] == [expected] - - -@pytest.mark.django_db -def test_ldap_user_flags_by_group_list(get, patch, admin): - expected = ['CN=Admins,OU=Groups,DC=example,DC=com', 'CN=Superadmins,OU=Groups,DC=example,DC=com'] - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) - patch(url, user=admin, data={'AUTH_LDAP_USER_FLAGS_BY_GROUP': {'is_superuser': expected}}, expect=200) - resp = get(url, user=admin) - assert resp.data['AUTH_LDAP_USER_FLAGS_BY_GROUP']['is_superuser'] == expected - - -@pytest.mark.parametrize( - 'setting', - [ - 'AUTH_LDAP_USER_DN_TEMPLATE', - 'AUTH_LDAP_REQUIRE_GROUP', - 'AUTH_LDAP_DENY_GROUP', - ], -) -@pytest.mark.django_db -def test_empty_ldap_dn(get, put, patch, delete, admin, setting): - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) - patch(url, user=admin, data={setting: ''}, expect=200) - resp = get(url, user=admin, expect=200) - assert resp.data[setting] is None - - patch(url, user=admin, data={setting: None}, expect=200) - resp = get(url, user=admin, expect=200) - assert resp.data[setting] is None - - -@pytest.mark.django_db -def test_radius_settings(get, put, patch, delete, admin, settings): - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'radius'}) - response = get(url, user=admin, expect=200) - put(url, user=admin, data=response.data, expect=200) - # Set secret via the API. - patch(url, user=admin, data={'RADIUS_SECRET': 'mysecret'}, expect=200) - response = get(url, user=admin, expect=200) - assert response.data['RADIUS_SECRET'] == '$encrypted$' - assert Setting.objects.filter(key='RADIUS_SECRET').first().value.startswith('$encrypted$') - assert settings.RADIUS_SECRET == 'mysecret' - # Set secret via settings wrapper. - settings_wrapper = settings._awx_conf_settings - settings_wrapper.RADIUS_SECRET = 'mysecret2' - response = get(url, user=admin, expect=200) - assert response.data['RADIUS_SECRET'] == '$encrypted$' - assert Setting.objects.filter(key='RADIUS_SECRET').first().value.startswith('$encrypted$') - assert settings.RADIUS_SECRET == 'mysecret2' - # If we send back $encrypted$, the setting is not updated. - patch(url, user=admin, data={'RADIUS_SECRET': '$encrypted$'}, expect=200) - response = get(url, user=admin, expect=200) - assert response.data['RADIUS_SECRET'] == '$encrypted$' - assert Setting.objects.filter(key='RADIUS_SECRET').first().value.startswith('$encrypted$') - assert settings.RADIUS_SECRET == 'mysecret2' - # If we send an empty string, the setting is also set to an empty string. - patch(url, user=admin, data={'RADIUS_SECRET': ''}, expect=200) - response = get(url, user=admin, expect=200) - assert response.data['RADIUS_SECRET'] == '' - assert Setting.objects.filter(key='RADIUS_SECRET').first().value == '' - assert settings.RADIUS_SECRET == '' - - -@pytest.mark.django_db -def test_tacacsplus_settings(get, put, patch, admin): - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'tacacsplus'}) - response = get(url, user=admin, expect=200) - put(url, user=admin, data=response.data, expect=200) - patch(url, user=admin, data={'TACACSPLUS_SECRET': 'mysecret'}, expect=200) - patch(url, user=admin, data={'TACACSPLUS_SECRET': ''}, expect=200) - patch(url, user=admin, data={'TACACSPLUS_HOST': 'localhost'}, expect=400) - patch(url, user=admin, data={'TACACSPLUS_SECRET': 'mysecret'}, expect=200) - patch(url, user=admin, data={'TACACSPLUS_HOST': 'localhost'}, expect=200) - patch(url, user=admin, data={'TACACSPLUS_HOST': '', 'TACACSPLUS_SECRET': ''}, expect=200) - patch(url, user=admin, data={'TACACSPLUS_HOST': 'localhost', 'TACACSPLUS_SECRET': ''}, expect=400) - patch(url, user=admin, data={'TACACSPLUS_HOST': 'localhost', 'TACACSPLUS_SECRET': 'mysecret'}, expect=200) - - @pytest.mark.django_db def test_ui_settings(get, put, patch, delete, admin): url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ui'}) @@ -317,76 +193,3 @@ def test_logging_aggregator_connection_test_valid(put, post, admin): # "Test" the logger url = reverse('api:setting_logging_test') post(url, {}, user=admin, expect=202) - - -@pytest.mark.django_db -@pytest.mark.parametrize('headers', [True, False]) -def test_saml_x509cert_validation(patch, get, admin, headers): - cert = "MIIEogIBAAKCAQEA1T4za6qBbHxFpN5f9eFvA74MFjrsjcp1uvzOaE23AYKMDEJghJ6dqQ7GwHLNIeIeumqDFmODauIzrgSDJTT5+NG30Rr+rRi0zDkrkBAj/AtA+SaVhbzqB6ZSd7LaMly9XAc+82OKlNpuWS9hPmFaSShzDTXRu5RRyvm4NDCAOGDu5hyVR2pV/ffKDNfNkChnqzvRRW9laQcVmliZhlTGn7nPZ+JbjpwEy0nwW+4zoAiEvwnT52N4xTqIcYOnXtGiaf13dh7FkUfYmS0tzF3+h8QRKwtIm4y+sq84R/kr79/0t5aRUpJynNrECajzmArpL4IjXKTPIyUpTKirJgGnCwIDAQABAoIBAC6bbbm2hpsjfkVOpUKkhxMWUqX5MwK6oYjBAIwjkEAwPFPhnh7eXC87H42oidVCCt1LsmMOVQbjcdAzBEb5kTkk/Twi3k8O+1U3maHfJT5NZ2INYNjeNXh+jb/Dw5UGWAzpOIUR2JQ4Oa4cgPCVbppW0O6uOKz6+fWXJv+hKiUoBCC0TiY52iseHJdUOaKNxYRD2IyIzCAxFSd5tZRaARIYDsugXp3E/TdbsVWA7bmjIBOXq+SquTrlB8x7j3B7+Pi09nAJ2U/uV4PHE+/2Fl009ywfmqancvnhwnz+GQ5jjP+gTfghJfbO+Z6M346rS0Vw+osrPgfyudNHlCswHOECgYEA/Cfq25gDP07wo6+wYWbx6LIzj/SSZy/Ux9P8zghQfoZiPoaq7BQBPAzwLNt7JWST8U11LZA8/wo6ch+HSTMk+m5ieVuru2cHxTDqeNlh94eCrNwPJ5ayA5U6LxAuSCTAzp+rv6KQUx1JcKSEHuh+nRYTKvUDE6iA6YtPLO96lLUCgYEA2H5rOPX2M4w1Q9zjol77lplbPRdczXNd0PIzhy8Z2ID65qvmr1nxBG4f2H96ykW8CKLXNvSXreNZ1BhOXc/3Hv+3mm46iitB33gDX4mlV4Jyo/w5IWhUKRyoW6qXquFFsScxRzTrx/9M+aZeRRLdsBk27HavFEg6jrbQ0SleZL8CgYAaM6Op8d/UgkVrHOR9Go9kmK/W85kK8+NuaE7Ksf57R0eKK8AzC9kc/lMuthfTyOG+n0ff1i8gaVWtai1Ko+/hvfqplacAsDIUgYK70AroB8LCZ5ODj5sr2CPVpB7LDFakod7c6O2KVW6+L7oy5AHUHOkc+5y4PDg5DGrLxo68SQKBgAlGoWF3aG0c/MtDk51JZI43U+lyLs++ua5SMlMAeaMFI7rucpvgxqrh7Qthqukvw7a7A22fXUBeFWM5B2KNnpD9c+hyAKAa6l+gzMQzKZpuRGsyS2BbEAAS8kO7M3Rm4o2MmFfstI2FKs8nibJ79HOvIONQ0n+T+K5Utu2/UAQRAoGAFB4fiIyQ0nYzCf18Z4Wvi/qeIOW+UoBonIN3y1h4wruBywINHxFMHx4aVImJ6R09hoJ9D3Mxli3xF/8JIjfTG5fBSGrGnuofl14d/XtRDXbT2uhVXrIkeLL/ojODwwEx0VhxIRUEjPTvEl6AFSRRcBp3KKzQ/cu7ENDY6GTlOUI=" # noqa - if headers: - cert = '-----BEGIN CERTIFICATE-----\n' + cert + '\n-----END CERTIFICATE-----' - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'saml'}) - resp = patch( - url, - user=admin, - data={ - 'SOCIAL_AUTH_SAML_ENABLED_IDPS': { - "okta": { - "attr_last_name": "LastName", - "attr_username": "login", - "entity_id": "http://www.okta.com/abc123", - "attr_user_permanent_id": "login", - "url": "https://example.okta.com/app/abc123/xyz123/sso/saml", - "attr_email": "Email", - "x509cert": cert, - "attr_first_name": "FirstName", - } - } - }, - ) - assert resp.status_code == 200 - - -@pytest.mark.django_db -def test_github_settings(get, put, patch, delete, admin): - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'github'}) - get(url, user=admin, expect=200) - delete(url, user=admin, expect=204) - response = get(url, user=admin, expect=200) - data = dict(response.data.items()) - put(url, user=admin, data=data, expect=200) - patch(url, user=admin, data={'SOCIAL_AUTH_GITHUB_KEY': '???'}, expect=200) - response = get(url, user=admin, expect=200) - assert response.data['SOCIAL_AUTH_GITHUB_KEY'] == '???' - data.pop('SOCIAL_AUTH_GITHUB_KEY') - put(url, user=admin, data=data, expect=200) - response = get(url, user=admin, expect=200) - assert response.data['SOCIAL_AUTH_GITHUB_KEY'] == '' - - -@pytest.mark.django_db -def test_github_enterprise_settings(get, put, patch, delete, admin): - url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'github-enterprise'}) - get(url, user=admin, expect=200) - delete(url, user=admin, expect=204) - response = get(url, user=admin, expect=200) - data = dict(response.data.items()) - put(url, user=admin, data=data, expect=200) - patch( - url, - user=admin, - data={ - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL': 'example.com', - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL': 'example.com', - }, - expect=200, - ) - response = get(url, user=admin, expect=200) - assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_URL'] == 'example.com' - assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL'] == 'example.com' - data.pop('SOCIAL_AUTH_GITHUB_ENTERPRISE_URL') - data.pop('SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL') - put(url, user=admin, data=data, expect=200) - response = get(url, user=admin, expect=200) - assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_URL'] == '' - assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL'] == '' diff --git a/awx/main/tests/functional/test_ldap.py b/awx/main/tests/functional/test_ldap.py deleted file mode 100644 index 2467ff52e38f..000000000000 --- a/awx/main/tests/functional/test_ldap.py +++ /dev/null @@ -1,103 +0,0 @@ -import ldap -import ldif -import pytest -import os -from mockldap import MockLdap - -from awx.api.versioning import reverse - - -@pytest.fixture -def ldap_generator(): - def fn(fname, host='localhost'): - fh = open(os.path.join(os.path.dirname(os.path.realpath(__file__)), fname), 'rb') - ctrl = ldif.LDIFRecordList(fh) - ctrl.parse() - - directory = dict(ctrl.all_records) - - mockldap = MockLdap(directory) - - mockldap.start() - mockldap['ldap://{}/'.format(host)] - - conn = ldap.initialize('ldap://{}/'.format(host)) - - return conn - # mockldap.stop() - - return fn - - -@pytest.fixture -def ldap_settings_generator(): - def fn(prefix='', dc='ansible', host='ldap.ansible.com'): - prefix = '_{}'.format(prefix) if prefix else '' - - data = { - 'AUTH_LDAP_SERVER_URI': 'ldap://{}'.format(host), - 'AUTH_LDAP_BIND_DN': 'cn=eng_user1,ou=people,dc={},dc=com'.format(dc), - 'AUTH_LDAP_BIND_PASSWORD': 'password', - "AUTH_LDAP_USER_SEARCH": ["ou=people,dc={},dc=com".format(dc), "SCOPE_SUBTREE", "(cn=%(user)s)"], - "AUTH_LDAP_TEAM_MAP": { - "LDAP Sales": {"organization": "LDAP Organization", "users": "cn=sales,ou=groups,dc={},dc=com".format(dc), "remove": True}, - "LDAP IT": {"organization": "LDAP Organization", "users": "cn=it,ou=groups,dc={},dc=com".format(dc), "remove": True}, - "LDAP Engineering": {"organization": "LDAP Organization", "users": "cn=engineering,ou=groups,dc={},dc=com".format(dc), "remove": True}, - }, - "AUTH_LDAP_REQUIRE_GROUP": None, - "AUTH_LDAP_USER_ATTR_MAP": {"first_name": "givenName", "last_name": "sn", "email": "mail"}, - "AUTH_LDAP_GROUP_SEARCH": ["dc={},dc=com".format(dc), "SCOPE_SUBTREE", "(objectClass=groupOfNames)"], - "AUTH_LDAP_USER_FLAGS_BY_GROUP": {"is_superuser": "cn=superusers,ou=groups,dc={},dc=com".format(dc)}, - "AUTH_LDAP_ORGANIZATION_MAP": { - "LDAP Organization": { - "admins": "cn=engineering_admins,ou=groups,dc={},dc=com".format(dc), - "remove_admins": False, - "users": [ - "cn=engineering,ou=groups,dc={},dc=com".format(dc), - "cn=sales,ou=groups,dc={},dc=com".format(dc), - "cn=it,ou=groups,dc={},dc=com".format(dc), - ], - "remove_users": False, - } - }, - } - - if prefix: - data_new = dict() - for k, v in data.items(): - k_new = k.replace('AUTH_LDAP', 'AUTH_LDAP{}'.format(prefix)) - data_new[k_new] = v - else: - data_new = data - - return data_new - - return fn - - -# Note: mockldap isn't fully featured. Fancy queries aren't fully baked. -# However, objects returned are solid so they should flow through django ldap middleware nicely. -@pytest.mark.skip(reason="Needs Update - CA") -@pytest.mark.django_db -def test_login(ldap_generator, patch, post, admin, ldap_settings_generator): - auth_url = reverse('api:auth_token_view') - ldap_settings_url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) - - # Generate mock ldap servers and init with ldap data - ldap_generator("../data/ldap_example.ldif", "ldap.example.com") - ldap_generator("../data/ldap_redhat.ldif", "ldap.redhat.com") - ldap_generator("../data/ldap_ansible.ldif", "ldap.ansible.com") - - ldap_settings_example = ldap_settings_generator(dc='example') - ldap_settings_ansible = ldap_settings_generator(prefix='1', dc='ansible') - ldap_settings_redhat = ldap_settings_generator(prefix='2', dc='redhat') - - # eng_user1 exists in ansible and redhat but not example - patch(ldap_settings_url, user=admin, data=ldap_settings_example, expect=200) - - post(auth_url, data={'username': 'eng_user1', 'password': 'password'}, expect=400) - - patch(ldap_settings_url, user=admin, data=ldap_settings_ansible, expect=200) - patch(ldap_settings_url, user=admin, data=ldap_settings_redhat, expect=200) - - post(auth_url, data={'username': 'eng_user1', 'password': 'password'}, expect=200) diff --git a/awx/main/tests/unit/commands/test_dump_auth_config.py b/awx/main/tests/unit/commands/test_dump_auth_config.py deleted file mode 100644 index 48024ff5e425..000000000000 --- a/awx/main/tests/unit/commands/test_dump_auth_config.py +++ /dev/null @@ -1,132 +0,0 @@ -from io import StringIO -import json -from django.core.management import call_command -from django.test import TestCase, override_settings - - -settings_dict = { - "SOCIAL_AUTH_SAML_SP_ENTITY_ID": "SP_ENTITY_ID", - "SOCIAL_AUTH_SAML_SP_PUBLIC_CERT": "SP_PUBLIC_CERT", - "SOCIAL_AUTH_SAML_SP_PRIVATE_KEY": "SP_PRIVATE_KEY", - "SOCIAL_AUTH_SAML_ORG_INFO": "ORG_INFO", - "SOCIAL_AUTH_SAML_TECHNICAL_CONTACT": "TECHNICAL_CONTACT", - "SOCIAL_AUTH_SAML_SUPPORT_CONTACT": "SUPPORT_CONTACT", - "SOCIAL_AUTH_SAML_SP_EXTRA": "SP_EXTRA", - "SOCIAL_AUTH_SAML_SECURITY_CONFIG": "SECURITY_CONFIG", - "SOCIAL_AUTH_SAML_EXTRA_DATA": "EXTRA_DATA", - "SOCIAL_AUTH_SAML_ENABLED_IDPS": { - "Keycloak": { - "attr_last_name": "last_name", - "attr_groups": "groups", - "attr_email": "email", - "attr_user_permanent_id": "name_id", - "attr_username": "username", - "entity_id": "https://example.com/auth/realms/awx", - "url": "https://example.com/auth/realms/awx/protocol/saml", - "x509cert": "-----BEGIN CERTIFICATE-----\nMIIDDjCCAfYCCQCPBeVvpo8+VzANBgkqhkiG9w0BAQsFADBJMQswCQYDVQQGEwJV\nUzELMAkGA1UECAwCTkMxDzANBgNVBAcMBkR1cmhhbTEMMAoGA1UECgwDYXd4MQ4w\nDAYDVQQDDAVsb2NhbDAeFw0yNDAxMTgxNDA4MzFaFw0yNTAxMTcxNDA4MzFaMEkx\nCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJOQzEPMA0GA1UEBwwGRHVyaGFtMQwwCgYD\nVQQKDANhd3gxDjAMBgNVBAMMBWxvY2FsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\nMIIBCgKCAQEAzouj93oyFXsHEABdPESh3CYpp5QJJBM4TLYIIolk6PFOFIVwBuFY\nfExi5w7Hh4A42lPM6RkrT+u3h7LV39H9MRUfqygOSmaxICTOI0sU9ROHc44fWWzN\n756OP4B5zSiqG82q8X7nYVkcID+2F/3ekPLMOlWn53OrcdfKKDIcqavoTkQJefc2\nggXU3WgVCxGki/qCm+e5cZ1Cpl/ykSLOT8dWMEzDd12kin66zJ3KYz9F2Q5kQTh4\nKRAChnBBoEqzOfENHEAaHALiXOlVSy61VcLbtvskRMMwBtsydlnd9n/HGnktgrid\n3Ca0z5wBTHWjAOBvCKxKJuDa+jmyHEnpcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IB\nAQBXvmyPWgXhC26cHYJBgQqj57dZ+n7p00kM1J+27oDMjGmbmX+XIKXLWazw/rG3\ngDjw9MXI2tVCrQMX0ohjphaULXhb/VBUPDOiW+k7C6AB3nZySFRflcR3cM4f83zF\nMoBd0549h5Red4p72FeOKNJRTN8YO4ooH9YNh5g0FQkgqn7fV9w2CNlomeKIW9zP\nm8tjFw0cJUk2wEYBVl8O7ko5rgNlzhkLoZkMvJhKa99AQJA6MAdyoLl1lv56Kq4X\njk+mMEiz9SaInp+ILQ1uQxZEwuC7DoGRW76rV4Fnie6+DLft4WKZfX1497mx8NV3\noR0abutJaKnCj07dwRu4/EsK\n-----END CERTIFICATE-----", - "attr_first_name": "first_name", - } - }, - "SOCIAL_AUTH_SAML_CALLBACK_URL": "CALLBACK_URL", - "AUTH_LDAP_1_SERVER_URI": "SERVER_URI", - "AUTH_LDAP_1_BIND_DN": "BIND_DN", - "AUTH_LDAP_1_BIND_PASSWORD": "BIND_PASSWORD", - "AUTH_LDAP_1_GROUP_SEARCH": ["GROUP_SEARCH"], - "AUTH_LDAP_1_GROUP_TYPE": "string object", - "AUTH_LDAP_1_GROUP_TYPE_PARAMS": {"member_attr": "member", "name_attr": "cn"}, - "AUTH_LDAP_1_USER_DN_TEMPLATE": "USER_DN_TEMPLATE", - "AUTH_LDAP_1_USER_SEARCH": ["USER_SEARCH"], - "AUTH_LDAP_1_USER_ATTR_MAP": { - "email": "email", - "last_name": "last_name", - "first_name": "first_name", - }, - "AUTH_LDAP_1_CONNECTION_OPTIONS": {}, - "AUTH_LDAP_1_START_TLS": None, -} - - -@override_settings(**settings_dict) -class TestDumpAuthConfigCommand(TestCase): - def setUp(self): - super().setUp() - self.expected_config = [ - { - "type": "ansible_base.authentication.authenticator_plugins.saml", - "name": "Keycloak", - "enabled": True, - "create_objects": True, - "users_unique": False, - "remove_users": True, - "configuration": { - "SP_ENTITY_ID": "SP_ENTITY_ID", - "SP_PUBLIC_CERT": "SP_PUBLIC_CERT", - "SP_PRIVATE_KEY": "SP_PRIVATE_KEY", - "ORG_INFO": "ORG_INFO", - "TECHNICAL_CONTACT": "TECHNICAL_CONTACT", - "SUPPORT_CONTACT": "SUPPORT_CONTACT", - "SP_EXTRA": "SP_EXTRA", - "SECURITY_CONFIG": "SECURITY_CONFIG", - "EXTRA_DATA": "EXTRA_DATA", - "ENABLED_IDPS": { - "Keycloak": { - "attr_last_name": "last_name", - "attr_groups": "groups", - "attr_email": "email", - "attr_user_permanent_id": "name_id", - "attr_username": "username", - "entity_id": "https://example.com/auth/realms/awx", - "url": "https://example.com/auth/realms/awx/protocol/saml", - "x509cert": "-----BEGIN CERTIFICATE-----\nMIIDDjCCAfYCCQCPBeVvpo8+VzANBgkqhkiG9w0BAQsFADBJMQswCQYDVQQGEwJV\nUzELMAkGA1UECAwCTkMxDzANBgNVBAcMBkR1cmhhbTEMMAoGA1UECgwDYXd4MQ4w\nDAYDVQQDDAVsb2NhbDAeFw0yNDAxMTgxNDA4MzFaFw0yNTAxMTcxNDA4MzFaMEkx\nCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJOQzEPMA0GA1UEBwwGRHVyaGFtMQwwCgYD\nVQQKDANhd3gxDjAMBgNVBAMMBWxvY2FsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\nMIIBCgKCAQEAzouj93oyFXsHEABdPESh3CYpp5QJJBM4TLYIIolk6PFOFIVwBuFY\nfExi5w7Hh4A42lPM6RkrT+u3h7LV39H9MRUfqygOSmaxICTOI0sU9ROHc44fWWzN\n756OP4B5zSiqG82q8X7nYVkcID+2F/3ekPLMOlWn53OrcdfKKDIcqavoTkQJefc2\nggXU3WgVCxGki/qCm+e5cZ1Cpl/ykSLOT8dWMEzDd12kin66zJ3KYz9F2Q5kQTh4\nKRAChnBBoEqzOfENHEAaHALiXOlVSy61VcLbtvskRMMwBtsydlnd9n/HGnktgrid\n3Ca0z5wBTHWjAOBvCKxKJuDa+jmyHEnpcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IB\nAQBXvmyPWgXhC26cHYJBgQqj57dZ+n7p00kM1J+27oDMjGmbmX+XIKXLWazw/rG3\ngDjw9MXI2tVCrQMX0ohjphaULXhb/VBUPDOiW+k7C6AB3nZySFRflcR3cM4f83zF\nMoBd0549h5Red4p72FeOKNJRTN8YO4ooH9YNh5g0FQkgqn7fV9w2CNlomeKIW9zP\nm8tjFw0cJUk2wEYBVl8O7ko5rgNlzhkLoZkMvJhKa99AQJA6MAdyoLl1lv56Kq4X\njk+mMEiz9SaInp+ILQ1uQxZEwuC7DoGRW76rV4Fnie6+DLft4WKZfX1497mx8NV3\noR0abutJaKnCj07dwRu4/EsK\n-----END CERTIFICATE-----", - "attr_first_name": "first_name", - } - }, - "CALLBACK_URL": "CALLBACK_URL", - "IDP_URL": "https://example.com/auth/realms/awx/protocol/saml", - "IDP_X509_CERT": "-----BEGIN CERTIFICATE-----\nMIIDDjCCAfYCCQCPBeVvpo8+VzANBgkqhkiG9w0BAQsFADBJMQswCQYDVQQGEwJV\nUzELMAkGA1UECAwCTkMxDzANBgNVBAcMBkR1cmhhbTEMMAoGA1UECgwDYXd4MQ4w\nDAYDVQQDDAVsb2NhbDAeFw0yNDAxMTgxNDA4MzFaFw0yNTAxMTcxNDA4MzFaMEkx\nCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJOQzEPMA0GA1UEBwwGRHVyaGFtMQwwCgYD\nVQQKDANhd3gxDjAMBgNVBAMMBWxvY2FsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\nMIIBCgKCAQEAzouj93oyFXsHEABdPESh3CYpp5QJJBM4TLYIIolk6PFOFIVwBuFY\nfExi5w7Hh4A42lPM6RkrT+u3h7LV39H9MRUfqygOSmaxICTOI0sU9ROHc44fWWzN\n756OP4B5zSiqG82q8X7nYVkcID+2F/3ekPLMOlWn53OrcdfKKDIcqavoTkQJefc2\nggXU3WgVCxGki/qCm+e5cZ1Cpl/ykSLOT8dWMEzDd12kin66zJ3KYz9F2Q5kQTh4\nKRAChnBBoEqzOfENHEAaHALiXOlVSy61VcLbtvskRMMwBtsydlnd9n/HGnktgrid\n3Ca0z5wBTHWjAOBvCKxKJuDa+jmyHEnpcQIDAQABMA0GCSqGSIb3DQEBCwUAA4IB\nAQBXvmyPWgXhC26cHYJBgQqj57dZ+n7p00kM1J+27oDMjGmbmX+XIKXLWazw/rG3\ngDjw9MXI2tVCrQMX0ohjphaULXhb/VBUPDOiW+k7C6AB3nZySFRflcR3cM4f83zF\nMoBd0549h5Red4p72FeOKNJRTN8YO4ooH9YNh5g0FQkgqn7fV9w2CNlomeKIW9zP\nm8tjFw0cJUk2wEYBVl8O7ko5rgNlzhkLoZkMvJhKa99AQJA6MAdyoLl1lv56Kq4X\njk+mMEiz9SaInp+ILQ1uQxZEwuC7DoGRW76rV4Fnie6+DLft4WKZfX1497mx8NV3\noR0abutJaKnCj07dwRu4/EsK\n-----END CERTIFICATE-----", - "IDP_ENTITY_ID": "https://example.com/auth/realms/awx", - "IDP_ATTR_EMAIL": "email", - "IDP_GROUPS": "groups", - "IDP_ATTR_USERNAME": "username", - "IDP_ATTR_LAST_NAME": "last_name", - "IDP_ATTR_FIRST_NAME": "first_name", - "IDP_ATTR_USER_PERMANENT_ID": "name_id", - }, - }, - { - "type": "ansible_base.authentication.authenticator_plugins.ldap", - "name": "LDAP_1", - "enabled": True, - "create_objects": True, - "users_unique": False, - "remove_users": True, - "configuration": { - "SERVER_URI": ["SERVER_URI"], - "BIND_DN": "BIND_DN", - "BIND_PASSWORD": "BIND_PASSWORD", - "CONNECTION_OPTIONS": {}, - "GROUP_TYPE": "str", - "GROUP_TYPE_PARAMS": {"member_attr": "member", "name_attr": "cn"}, - "GROUP_SEARCH": ["GROUP_SEARCH"], - "START_TLS": None, - "USER_DN_TEMPLATE": "USER_DN_TEMPLATE", - "USER_ATTR_MAP": {"email": "email", "last_name": "last_name", "first_name": "first_name"}, - "USER_SEARCH": ["USER_SEARCH"], - }, - }, - ] - - def test_json_returned_from_cmd(self): - output = StringIO() - call_command("dump_auth_config", stdout=output) - cmmd_output = json.loads(output.getvalue()) - - # check configured SAML return - assert cmmd_output[0] == self.expected_config[0] - - # check configured LDAP return - assert cmmd_output[2] == self.expected_config[1] - - # check unconfigured LDAP return - assert "LDAP_0_missing_fields" in cmmd_output[1] - assert cmmd_output[1]["LDAP_0_missing_fields"] == ['SERVER_URI', 'GROUP_TYPE', 'GROUP_TYPE_PARAMS', 'USER_DN_TEMPLATE', 'USER_ATTR_MAP'] diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index d51818d7222e..ddb795de6afc 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -315,8 +315,6 @@ 'django.contrib.messages.context_processors.messages', 'awx.ui.context_processors.csp', 'awx.ui.context_processors.version', - 'social_django.context_processors.backends', - 'social_django.context_processors.login_redirect', ], 'builtins': ['awx.main.templatetags.swagger'], }, @@ -346,14 +344,12 @@ 'rest_framework', 'django_extensions', 'polymorphic', - 'social_django', 'django_guid', 'corsheaders', 'awx.conf', 'awx.main', 'awx.api', 'awx.ui', - 'awx.sso', 'solo', 'ansible_base.rest_filters', 'ansible_base.jwt_consumer', @@ -388,27 +384,7 @@ # 'URL_FORMAT_OVERRIDE': None, } -AUTHENTICATION_BACKENDS = ( - 'awx.sso.backends.LDAPBackend', - 'awx.sso.backends.LDAPBackend1', - 'awx.sso.backends.LDAPBackend2', - 'awx.sso.backends.LDAPBackend3', - 'awx.sso.backends.LDAPBackend4', - 'awx.sso.backends.LDAPBackend5', - 'awx.sso.backends.RADIUSBackend', - 'awx.sso.backends.TACACSPlusBackend', - 'social_core.backends.google.GoogleOAuth2', - 'social_core.backends.github.GithubOAuth2', - 'social_core.backends.github.GithubOrganizationOAuth2', - 'social_core.backends.github.GithubTeamOAuth2', - 'social_core.backends.github_enterprise.GithubEnterpriseOAuth2', - 'social_core.backends.github_enterprise.GithubEnterpriseOrganizationOAuth2', - 'social_core.backends.github_enterprise.GithubEnterpriseTeamOAuth2', - 'social_core.backends.open_id_connect.OpenIdConnectAuth', - 'social_core.backends.azuread.AzureADOAuth2', - 'awx.sso.backends.SAMLAuth', - 'awx.main.backends.AWXModelBackend', -) +AUTHENTICATION_BACKENDS = ('awx.main.backends.AWXModelBackend',) # Django OAuth Toolkit settings @@ -498,10 +474,6 @@ DJANGO_REDIS_IGNORE_EXCEPTIONS = True CACHES = {'default': {'BACKEND': 'awx.main.cache.AWXRedisCache', 'LOCATION': 'unix:///var/run/redis/redis.sock?db=1'}} -# Social Auth configuration. -SOCIAL_AUTH_STRATEGY = 'social_django.strategy.DjangoStrategy' -SOCIAL_AUTH_STORAGE = 'social_django.models.DjangoStorage' -SOCIAL_AUTH_USER_MODEL = 'auth.User' ROLE_SINGLETON_USER_RELATIONSHIP = '' ROLE_SINGLETON_TEAM_RELATIONSHIP = '' @@ -509,90 +481,6 @@ ROLE_BYPASS_SUPERUSER_FLAGS = ['is_superuser'] ROLE_BYPASS_ACTION_FLAGS = {'view': 'is_system_auditor'} -_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', - 'social_core.pipeline.social_auth.social_user', - 'social_core.pipeline.user.get_username', - 'social_core.pipeline.social_auth.associate_by_email', - 'social_core.pipeline.user.create_user', - 'awx.sso.social_base_pipeline.check_user_found_or_created', - 'social_core.pipeline.social_auth.associate_user', - 'social_core.pipeline.social_auth.load_extra_data', - 'awx.sso.social_base_pipeline.set_is_active_for_new_user', - 'social_core.pipeline.user.user_details', - 'awx.sso.social_base_pipeline.prevent_inactive_login', -) -SOCIAL_AUTH_PIPELINE = _SOCIAL_AUTH_PIPELINE_BASE + ( - 'awx.sso.social_pipeline.update_user_orgs', - 'awx.sso.social_pipeline.update_user_teams', - 'ansible_base.resource_registry.utils.service_backed_sso_pipeline.redirect_to_resource_server', -) -SOCIAL_AUTH_SAML_PIPELINE = _SOCIAL_AUTH_PIPELINE_BASE + ('awx.sso.saml_pipeline.populate_user', 'awx.sso.saml_pipeline.update_user_flags') -SAML_AUTO_CREATE_OBJECTS = True - -SOCIAL_AUTH_LOGIN_URL = '/' -SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/sso/complete/' -SOCIAL_AUTH_LOGIN_ERROR_URL = '/sso/error/' -SOCIAL_AUTH_INACTIVE_USER_URL = '/sso/inactive/' - -SOCIAL_AUTH_RAISE_EXCEPTIONS = False -SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL = False -# SOCIAL_AUTH_SLUGIFY_USERNAMES = True -SOCIAL_AUTH_CLEAN_USERNAMES = True - -SOCIAL_AUTH_SANITIZE_REDIRECTS = True -SOCIAL_AUTH_REDIRECT_IS_HTTPS = False - -# Note: These settings may be overridden by database settings. -SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = '' -SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = '' -SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = ['profile'] - -SOCIAL_AUTH_GITHUB_KEY = '' -SOCIAL_AUTH_GITHUB_SECRET = '' -SOCIAL_AUTH_GITHUB_SCOPE = ['user:email', 'read:org'] - -SOCIAL_AUTH_GITHUB_ORG_KEY = '' -SOCIAL_AUTH_GITHUB_ORG_SECRET = '' -SOCIAL_AUTH_GITHUB_ORG_NAME = '' -SOCIAL_AUTH_GITHUB_ORG_SCOPE = ['user:email', 'read:org'] - -SOCIAL_AUTH_GITHUB_TEAM_KEY = '' -SOCIAL_AUTH_GITHUB_TEAM_SECRET = '' -SOCIAL_AUTH_GITHUB_TEAM_ID = '' -SOCIAL_AUTH_GITHUB_TEAM_SCOPE = ['user:email', 'read:org'] - -SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY = '' -SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET = '' -SOCIAL_AUTH_GITHUB_ENTERPRISE_SCOPE = ['user:email', 'read:org'] - -SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY = '' -SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET = '' -SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME = '' -SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SCOPE = ['user:email', 'read:org'] - -SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY = '' -SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET = '' -SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID = '' -SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SCOPE = ['user:email', 'read:org'] - -SOCIAL_AUTH_AZUREAD_OAUTH2_KEY = '' -SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET = '' - -SOCIAL_AUTH_SAML_SP_ENTITY_ID = '' -SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = '' -SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = '' -SOCIAL_AUTH_SAML_ORG_INFO = {} -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 = {} -SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR = {} - # Any ANSIBLE_* settings will be passed to the task runner subprocess # environment @@ -1034,7 +922,6 @@ 'awx.main.middleware.DisableLocalAuthMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'awx.main.middleware.OptionalURLPrefixPath', - 'awx.sso.middleware.SocialAuthMiddleware', 'crum.CurrentRequestUserMiddleware', 'awx.main.middleware.URLModificationMiddleware', 'awx.main.middleware.SessionTimeoutMiddleware', diff --git a/awx/sso/__init__.py b/awx/sso/__init__.py deleted file mode 100644 index e484e62be15d..000000000000 --- a/awx/sso/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. diff --git a/awx/sso/apps.py b/awx/sso/apps.py deleted file mode 100644 index 6203ca6d6a11..000000000000 --- a/awx/sso/apps.py +++ /dev/null @@ -1,8 +0,0 @@ -# Django -from django.apps import AppConfig -from django.utils.translation import gettext_lazy as _ - - -class SSOConfig(AppConfig): - name = 'awx.sso' - verbose_name = _('Single Sign-On') diff --git a/awx/sso/backends.py b/awx/sso/backends.py deleted file mode 100644 index 572afc3ef04b..000000000000 --- a/awx/sso/backends.py +++ /dev/null @@ -1,469 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -# Python -from collections import OrderedDict -import logging -import uuid - -import ldap - -# Django -from django.dispatch import receiver -from django.contrib.auth.models import User -from django.conf import settings as django_settings -from django.core.signals import setting_changed -from django.utils.encoding import force_str -from django.http import HttpResponse - -# django-auth-ldap -from django_auth_ldap.backend import LDAPSettings as BaseLDAPSettings -from django_auth_ldap.backend import LDAPBackend as BaseLDAPBackend -from django_auth_ldap.backend import populate_user -from django.core.exceptions import ImproperlyConfigured - -# radiusauth -from radiusauth.backends import RADIUSBackend as BaseRADIUSBackend - -# tacacs+ auth -import tacacs_plus - -# social -from social_core.backends.saml import OID_USERID -from social_core.backends.saml import SAMLAuth as BaseSAMLAuth -from social_core.backends.saml import SAMLIdentityProvider as BaseSAMLIdentityProvider - -# Ansible Tower -from awx.sso.models import UserEnterpriseAuth -from awx.sso.common import create_org_and_teams, reconcile_users_org_team_mappings - -logger = logging.getLogger('awx.sso.backends') - - -class LDAPSettings(BaseLDAPSettings): - defaults = dict(list(BaseLDAPSettings.defaults.items()) + list({'ORGANIZATION_MAP': {}, 'TEAM_MAP': {}, 'GROUP_TYPE_PARAMS': {}}.items())) - - def __init__(self, prefix='AUTH_LDAP_', defaults={}): - super(LDAPSettings, self).__init__(prefix, defaults) - - # If a DB-backed setting is specified that wipes out the - # OPT_NETWORK_TIMEOUT, fall back to a sane default - if ldap.OPT_NETWORK_TIMEOUT not in getattr(self, 'CONNECTION_OPTIONS', {}): - options = getattr(self, 'CONNECTION_OPTIONS', {}) - options[ldap.OPT_NETWORK_TIMEOUT] = 30 - self.CONNECTION_OPTIONS = options - - # when specifying `.set_option()` calls for TLS in python-ldap, the - # *order* in which you invoke them *matters*, particularly in Python3, - # where dictionary insertion order is persisted - # - # specifically, it is *critical* that `ldap.OPT_X_TLS_NEWCTX` be set *last* - # this manual sorting puts `OPT_X_TLS_NEWCTX` *after* other TLS-related - # options - # - # see: https://github.com/python-ldap/python-ldap/issues/55 - newctx_option = self.CONNECTION_OPTIONS.pop(ldap.OPT_X_TLS_NEWCTX, None) - self.CONNECTION_OPTIONS = OrderedDict(self.CONNECTION_OPTIONS) - if newctx_option is not None: - self.CONNECTION_OPTIONS[ldap.OPT_X_TLS_NEWCTX] = newctx_option - - -class LDAPBackend(BaseLDAPBackend): - """ - Custom LDAP backend for AWX. - """ - - settings_prefix = 'AUTH_LDAP_' - - def __init__(self, *args, **kwargs): - self._dispatch_uid = uuid.uuid4() - super(LDAPBackend, self).__init__(*args, **kwargs) - setting_changed.connect(self._on_setting_changed, dispatch_uid=self._dispatch_uid) - - def _on_setting_changed(self, sender, **kwargs): - # If any AUTH_LDAP_* setting changes, force settings to be reloaded for - # this backend instance. - if kwargs.get('setting', '').startswith(self.settings_prefix): - self._settings = None - - def _get_settings(self): - if self._settings is None: - self._settings = LDAPSettings(self.settings_prefix) - return self._settings - - def _set_settings(self, settings): - self._settings = settings - - settings = property(_get_settings, _set_settings) - - def authenticate(self, request, username, password): - if self.settings.START_TLS and ldap.OPT_X_TLS_REQUIRE_CERT in self.settings.CONNECTION_OPTIONS: - # with python-ldap, if you want to set connection-specific TLS - # parameters, you must also specify OPT_X_TLS_NEWCTX = 0 - # see: https://stackoverflow.com/a/29722445 - # see: https://stackoverflow.com/a/38136255 - self.settings.CONNECTION_OPTIONS[ldap.OPT_X_TLS_NEWCTX] = 0 - - if not self.settings.SERVER_URI: - return None - try: - user = User.objects.get(username=username) - if user and (not user.profile or not user.profile.ldap_dn): - return None - except User.DoesNotExist: - pass - - try: - for setting_name, type_ in [('GROUP_SEARCH', 'LDAPSearch'), ('GROUP_TYPE', 'LDAPGroupType')]: - if getattr(self.settings, setting_name) is None: - raise ImproperlyConfigured("{} must be an {} instance.".format(setting_name, type_)) - ldap_user = super(LDAPBackend, self).authenticate(request, username, password) - # If we have an LDAP user and that user we found has an ldap_user internal object and that object has a bound connection - # Then we can try and force an unbind to close the sticky connection - if ldap_user and ldap_user.ldap_user and ldap_user.ldap_user._connection_bound: - logger.debug("Forcing LDAP connection to close") - try: - ldap_user.ldap_user._connection.unbind_s() - ldap_user.ldap_user._connection_bound = False - except Exception: - logger.exception(f"Got unexpected LDAP exception when forcing LDAP disconnect for user {ldap_user}, login will still proceed") - return ldap_user - except Exception: - logger.exception("Encountered an error authenticating to LDAP") - return None - - def get_user(self, user_id): - if not self.settings.SERVER_URI: - return None - return super(LDAPBackend, self).get_user(user_id) - - # Disable any LDAP based authorization / permissions checking. - - def has_perm(self, user, perm, obj=None): - return False - - def has_module_perms(self, user, app_label): - return False - - def get_all_permissions(self, user, obj=None): - return set() - - def get_group_permissions(self, user, obj=None): - return set() - - -class LDAPBackend1(LDAPBackend): - settings_prefix = 'AUTH_LDAP_1_' - - -class LDAPBackend2(LDAPBackend): - settings_prefix = 'AUTH_LDAP_2_' - - -class LDAPBackend3(LDAPBackend): - settings_prefix = 'AUTH_LDAP_3_' - - -class LDAPBackend4(LDAPBackend): - settings_prefix = 'AUTH_LDAP_4_' - - -class LDAPBackend5(LDAPBackend): - settings_prefix = 'AUTH_LDAP_5_' - - -def _decorate_enterprise_user(user, provider): - user.set_unusable_password() - user.save() - enterprise_auth, _ = UserEnterpriseAuth.objects.get_or_create(user=user, provider=provider) - return enterprise_auth - - -def _get_or_set_enterprise_user(username, password, provider): - created = False - try: - user = User.objects.prefetch_related('enterprise_auth').get(username=username) - except User.DoesNotExist: - user = User(username=username) - enterprise_auth = _decorate_enterprise_user(user, provider) - logger.debug("Created enterprise user %s via %s backend." % (username, enterprise_auth.get_provider_display())) - created = True - if created or user.is_in_enterprise_category(provider): - return user - logger.warning("Enterprise user %s already defined in Tower." % username) - - -class RADIUSBackend(BaseRADIUSBackend): - """ - Custom Radius backend to verify license status - """ - - def authenticate(self, request, username, password): - if not django_settings.RADIUS_SERVER: - return None - return super(RADIUSBackend, self).authenticate(request, username, password) - - def get_user(self, user_id): - if not django_settings.RADIUS_SERVER: - return None - user = super(RADIUSBackend, self).get_user(user_id) - if not user.has_usable_password(): - return user - - def get_django_user(self, username, password=None, groups=[], is_staff=False, is_superuser=False): - return _get_or_set_enterprise_user(force_str(username), force_str(password), 'radius') - - -class TACACSPlusBackend(object): - """ - Custom TACACS+ auth backend for AWX - """ - - def authenticate(self, request, username, password): - if not django_settings.TACACSPLUS_HOST: - return None - try: - # Upstream TACACS+ client does not accept non-string, so convert if needed. - tacacs_client = tacacs_plus.TACACSClient( - django_settings.TACACSPLUS_HOST, - django_settings.TACACSPLUS_PORT, - django_settings.TACACSPLUS_SECRET, - timeout=django_settings.TACACSPLUS_SESSION_TIMEOUT, - ) - auth_kwargs = {'authen_type': tacacs_plus.TAC_PLUS_AUTHEN_TYPES[django_settings.TACACSPLUS_AUTH_PROTOCOL]} - if django_settings.TACACSPLUS_AUTH_PROTOCOL: - client_ip = self._get_client_ip(request) - if client_ip: - auth_kwargs['rem_addr'] = client_ip - auth = tacacs_client.authenticate(username, password, **auth_kwargs) - except Exception as e: - logger.exception("TACACS+ Authentication Error: %s" % str(e)) - return None - if auth.valid: - return _get_or_set_enterprise_user(username, password, 'tacacs+') - - def get_user(self, user_id): - if not django_settings.TACACSPLUS_HOST: - return None - try: - return User.objects.get(pk=user_id) - except User.DoesNotExist: - return None - - def _get_client_ip(self, request): - if not request or not hasattr(request, 'META'): - return None - - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') - if x_forwarded_for: - ip = x_forwarded_for.split(',')[0] - else: - ip = request.META.get('REMOTE_ADDR') - return ip - - -class TowerSAMLIdentityProvider(BaseSAMLIdentityProvider): - """ - Custom Identity Provider to make attributes to what we expect. - """ - - def get_user_permanent_id(self, attributes): - uid = attributes[self.conf.get('attr_user_permanent_id', OID_USERID)] - if isinstance(uid, str): - return uid - return uid[0] - - def get_attr(self, attributes, conf_key, default_attribute): - """ - Get the attribute 'default_attribute' out of the attributes, - unless self.conf[conf_key] overrides the default by specifying - another attribute to use. - """ - key = self.conf.get(conf_key, default_attribute) - value = attributes[key] if key in attributes else None - # In certain implementations (like https://pagure.io/ipsilon) this value is a string, not a list - if isinstance(value, (list, tuple)): - value = value[0] - if conf_key in ('attr_first_name', 'attr_last_name', 'attr_username', 'attr_email') and value is None: - logger.warning( - "Could not map user detail '%s' from SAML attribute '%s'; update SOCIAL_AUTH_SAML_ENABLED_IDPS['%s']['%s'] with the correct SAML attribute.", - conf_key[5:], - key, - self.name, - conf_key, - ) - return str(value) if value is not None else value - - -class SAMLAuth(BaseSAMLAuth): - """ - Custom SAMLAuth backend to verify license status - """ - - def get_idp(self, idp_name): - idp_config = self.setting('ENABLED_IDPS')[idp_name] - return TowerSAMLIdentityProvider(idp_name, **idp_config) - - def authenticate(self, request, *args, **kwargs): - if not all( - [ - django_settings.SOCIAL_AUTH_SAML_SP_ENTITY_ID, - django_settings.SOCIAL_AUTH_SAML_SP_PUBLIC_CERT, - django_settings.SOCIAL_AUTH_SAML_SP_PRIVATE_KEY, - django_settings.SOCIAL_AUTH_SAML_ORG_INFO, - django_settings.SOCIAL_AUTH_SAML_TECHNICAL_CONTACT, - django_settings.SOCIAL_AUTH_SAML_SUPPORT_CONTACT, - django_settings.SOCIAL_AUTH_SAML_ENABLED_IDPS, - ] - ): - return None - pipeline_result = super(SAMLAuth, self).authenticate(request, *args, **kwargs) - - if isinstance(pipeline_result, HttpResponse): - return pipeline_result - else: - user = pipeline_result - - # Comes from https://github.com/omab/python-social-auth/blob/v0.2.21/social/backends/base.py#L91 - if getattr(user, 'is_new', False): - enterprise_auth = _decorate_enterprise_user(user, 'saml') - logger.debug("Created enterprise user %s from %s backend." % (user.username, enterprise_auth.get_provider_display())) - elif user and not user.is_in_enterprise_category('saml'): - return None - if user: - logger.debug("Enterprise user %s already created in Tower." % user.username) - return user - - def get_user(self, user_id): - if not all( - [ - django_settings.SOCIAL_AUTH_SAML_SP_ENTITY_ID, - django_settings.SOCIAL_AUTH_SAML_SP_PUBLIC_CERT, - django_settings.SOCIAL_AUTH_SAML_SP_PRIVATE_KEY, - django_settings.SOCIAL_AUTH_SAML_ORG_INFO, - django_settings.SOCIAL_AUTH_SAML_TECHNICAL_CONTACT, - django_settings.SOCIAL_AUTH_SAML_SUPPORT_CONTACT, - django_settings.SOCIAL_AUTH_SAML_ENABLED_IDPS, - ] - ): - return None - return super(SAMLAuth, self).get_user(user_id) - - -def _update_m2m_from_groups(ldap_user, opts, remove=True): - """ - Hepler function to evaluate the LDAP team/org options to determine if LDAP user should - be a member of the team/org based on their ldap group dns. - - Returns: - True - User should be added - False - User should be removed - None - Users membership should not be changed - """ - if opts is None: - return None - elif not opts: - pass - elif isinstance(opts, bool) and opts is True: - return True - else: - if isinstance(opts, str): - opts = [opts] - # If any of the users groups matches any of the list options - for group_dn in opts: - if not isinstance(group_dn, str): - continue - if ldap_user._get_groups().is_member_of(group_dn): - return True - if remove: - return False - return None - - -@receiver(populate_user, dispatch_uid='populate-ldap-user') -def on_populate_user(sender, **kwargs): - """ - Handle signal from LDAP backend to populate the user object. Update user - organization/team memberships according to their LDAP groups. - """ - user = kwargs['user'] - ldap_user = kwargs['ldap_user'] - backend = ldap_user.backend - - # Boolean to determine if we should force an user update - # to avoid duplicate SQL update statements - force_user_update = False - - # Prefetch user's groups to prevent LDAP queries for each org/team when - # checking membership. - ldap_user._get_groups().get_group_dns() - - # If the LDAP user has a first or last name > $maxlen chars, truncate it - for field in ('first_name', 'last_name'): - max_len = User._meta.get_field(field).max_length - field_len = len(getattr(user, field)) - if field_len > max_len: - setattr(user, field, getattr(user, field)[:max_len]) - force_user_update = True - logger.warning('LDAP user {} has {} > max {} characters'.format(user.username, field, max_len)) - - org_map = getattr(backend.settings, 'ORGANIZATION_MAP', {}) - team_map_settings = getattr(backend.settings, 'TEAM_MAP', {}) - orgs_list = list(org_map.keys()) - team_map = {} - for team_name, team_opts in team_map_settings.items(): - if not team_opts.get('organization', None): - # You can't save the LDAP config in the UI w/o an org (or '' or null as the org) so if we somehow got this condition its an error - logger.error("Team named {} in LDAP team map settings is invalid due to missing organization".format(team_name)) - continue - team_map[team_name] = team_opts['organization'] - - create_org_and_teams(orgs_list, team_map, 'LDAP') - - # Compute in memory what the state is of the different LDAP orgs - org_roles_and_ldap_attributes = {'admin_role': 'admins', 'auditor_role': 'auditors', 'member_role': 'users'} - desired_org_states = {} - for org_name, org_opts in org_map.items(): - remove = bool(org_opts.get('remove', True)) - desired_org_states[org_name] = {} - for org_role_name in org_roles_and_ldap_attributes.keys(): - ldap_name = org_roles_and_ldap_attributes[org_role_name] - opts = org_opts.get(ldap_name, None) - remove = bool(org_opts.get('remove_{}'.format(ldap_name), remove)) - desired_org_states[org_name][org_role_name] = _update_m2m_from_groups(ldap_user, opts, remove) - - # If everything returned None (because there was no configuration) we can remove this org from our map - # This will prevent us from loading the org in the next query - if all(desired_org_states[org_name][org_role_name] is None for org_role_name in org_roles_and_ldap_attributes.keys()): - del desired_org_states[org_name] - - # Compute in memory what the state is of the different LDAP teams - desired_team_states = {} - for team_name, team_opts in team_map_settings.items(): - if 'organization' not in team_opts: - continue - users_opts = team_opts.get('users', None) - remove = bool(team_opts.get('remove', True)) - state = _update_m2m_from_groups(ldap_user, users_opts, remove) - if state is not None: - organization = team_opts['organization'] - if organization not in desired_team_states: - desired_team_states[organization] = {} - desired_team_states[organization][team_name] = {'member_role': state} - - # Check if user.profile is available, otherwise force user.save() - try: - _ = user.profile - except ValueError: - force_user_update = True - finally: - if force_user_update: - user.save() - - # Update user profile to store LDAP DN. - profile = user.profile - if profile.ldap_dn != ldap_user.dn: - profile.ldap_dn = ldap_user.dn - profile.save() - - reconcile_users_org_team_mappings(user, desired_org_states, desired_team_states, 'LDAP') diff --git a/awx/sso/common.py b/awx/sso/common.py deleted file mode 100644 index 99abc51d5a03..000000000000 --- a/awx/sso/common.py +++ /dev/null @@ -1,213 +0,0 @@ -# Copyright (c) 2022 Ansible, Inc. -# All Rights Reserved. - -import logging - -from django.contrib.contenttypes.models import ContentType -from django.db.utils import IntegrityError -from awx.main.models import Organization, Team - -logger = logging.getLogger('awx.sso.common') - - -def get_orgs_by_ids(): - existing_orgs = {} - for org_id, org_name in Organization.objects.all().values_list('id', 'name'): - existing_orgs[org_name] = org_id - return existing_orgs - - -def reconcile_users_org_team_mappings(user, desired_org_states, desired_team_states, source): - # - # Arguments: - # user - a user object - # desired_org_states: { '': { '': or None } } - # desired_team_states: { '': { '': { '': or None } } } - # source - a text label indicating the "authentication adapter" for debug messages - # - # This function will load the users existing roles and then based on the desired states modify the users roles - # True indicates the user needs to be a member of the role - # False indicates the user should not be a member of the role - # None means this function should not change the users membership of a role - # - - content_types = [] - reconcile_items = [] - if desired_org_states: - content_types.append(ContentType.objects.get_for_model(Organization)) - reconcile_items.append(('organization', desired_org_states)) - if desired_team_states: - content_types.append(ContentType.objects.get_for_model(Team)) - reconcile_items.append(('team', desired_team_states)) - - if not content_types: - # If both desired states were empty we can simply return because there is nothing to reconcile - return - - # users_roles is a flat set of IDs - users_roles = set(user.roles.filter(content_type__in=content_types).values_list('pk', flat=True)) - - for object_type, desired_states in reconcile_items: - roles = [] - # Get a set of named tuples for the org/team name plus all of the roles we got above - if object_type == 'organization': - for sub_dict in desired_states.values(): - for role_name in sub_dict: - if sub_dict[role_name] is None: - continue - if role_name not in roles: - roles.append(role_name) - model_roles = Organization.objects.filter(name__in=desired_states.keys()).values_list('name', *roles, named=True) - else: - team_names = [] - for teams_dict in desired_states.values(): - team_names.extend(teams_dict.keys()) - for sub_dict in teams_dict.values(): - for role_name in sub_dict: - if sub_dict[role_name] is None: - continue - if role_name not in roles: - roles.append(role_name) - model_roles = Team.objects.filter(name__in=team_names).values_list('name', 'organization__name', *roles, named=True) - - for row in model_roles: - for role_name in roles: - if object_type == 'organization': - desired_state = desired_states.get(row.name, {}) - else: - desired_state = desired_states.get(row.organization__name, {}).get(row.name, {}) - - if desired_state.get(role_name, None) is None: - # The mapping was not defined for this [org/team]/role so we can just pass - continue - - # If somehow the auth adapter knows about an items role but that role is not defined in the DB we are going to print a pretty error - # This is your classic safety net that we should never hit; but here you are reading this comment... good luck and Godspeed. - role_id = getattr(row, role_name, None) - if role_id is None: - logger.error("{} adapter wanted to manage role {} of {} {} but that role is not defined".format(source, role_name, object_type, row.name)) - continue - - if desired_state[role_name]: - # The desired state was the user mapped into the object_type, if the user was not mapped in map them in - if role_id not in users_roles: - logger.debug("{} adapter adding user {} to {} {} as {}".format(source, user.username, object_type, row.name, role_name)) - user.roles.add(role_id) - else: - # The desired state was the user was not mapped into the org, if the user has the permission remove it - if role_id in users_roles: - logger.debug("{} adapter removing user {} permission of {} from {} {}".format(source, user.username, role_name, object_type, row.name)) - user.roles.remove(role_id) - - -def create_org_and_teams(org_list, team_map, adapter, can_create=True): - # - # org_list is a set of organization names - # team_map is a dict of {: } - # - # Move this junk into save of the settings for performance later, there is no need to do that here - # with maybe the exception of someone defining this in settings before the server is started? - # ============================================================================================================== - - if not can_create: - logger.debug(f"Adapter {adapter} is not allowed to create orgs/teams") - return - - # Get all of the IDs and names of orgs in the DB and create any new org defined in LDAP that does not exist in the DB - existing_orgs = get_orgs_by_ids() - - # Parse through orgs and teams provided and create a list of unique items we care about creating - all_orgs = list(set(org_list)) - all_teams = [] - for team_name in team_map: - org_name = team_map[team_name] - if org_name: - if org_name not in all_orgs: - all_orgs.append(org_name) - # We don't have to test if this is in all_teams because team_map is already a hash - all_teams.append(team_name) - else: - # The UI should prevent this condition so this is just a double check to prevent a stack trace.... - # although the rest of the login process might stack later on - logger.error("{} adapter is attempting to create a team {} but it does not have an org".format(adapter, team_name)) - - for org_name in all_orgs: - if org_name and org_name not in existing_orgs: - logger.info("{} adapter is creating org {}".format(adapter, org_name)) - try: - new_org = get_or_create_org_with_default_galaxy_cred(name=org_name) - except IntegrityError: - # Another thread must have created this org before we did so now we need to get it - new_org = get_or_create_org_with_default_galaxy_cred(name=org_name) - # Add the org name to the existing orgs since we created it and we may need it to build the teams below - existing_orgs[org_name] = new_org.id - - # Do the same for teams - existing_team_names = list(Team.objects.all().values_list('name', flat=True)) - for team_name in all_teams: - if team_name not in existing_team_names: - logger.info("{} adapter is creating team {} in org {}".format(adapter, team_name, team_map[team_name])) - try: - Team.objects.create(name=team_name, organization_id=existing_orgs[team_map[team_name]]) - except IntegrityError: - # If another process got here before us that is ok because we don't need the ID from this team or anything - pass - # End move some day - # ============================================================================================================== - - -def get_or_create_org_with_default_galaxy_cred(**kwargs): - from awx.main.models import Organization, Credential - - (org, org_created) = Organization.objects.get_or_create(**kwargs) - if org_created: - logger.debug("Created org {} (id {}) from {}".format(org.name, org.id, kwargs)) - public_galaxy_credential = Credential.objects.filter(managed=True, name='Ansible Galaxy').first() - if public_galaxy_credential is not None: - org.galaxy_credentials.add(public_galaxy_credential) - logger.debug("Added default Ansible Galaxy credential to org") - else: - logger.debug("Could not find default Ansible Galaxy credential to add to org") - return org - - -def get_external_account(user): - account_type = None - - # Previously this method also checked for active configuration which meant that if a user logged in from LDAP - # and then LDAP was no longer configured it would "convert" the user from an LDAP account_type to none. - # This did have one benefit that if a login type was removed intentionally the user could be given a username password. - # But it had a limitation that the user would have to have an active session (or an admin would have to go set a temp password). - # It also lead to the side affect that if LDAP was ever reconfigured the user would convert back to LDAP but still have a local password. - # That local password could then be used to bypass LDAP authentication. - try: - if user.pk and user.profile.ldap_dn and not user.has_usable_password(): - account_type = "ldap" - except AttributeError: - pass - - if user.social_auth.all(): - account_type = "social" - - if user.enterprise_auth.all(): - account_type = "enterprise" - - return account_type - - -def is_remote_auth_enabled(): - from django.conf import settings - - # Append LDAP, Radius, TACACS+ and SAML options - settings_that_turn_on_remote_auth = [ - 'AUTH_LDAP_SERVER_URI', - 'SOCIAL_AUTH_SAML_ENABLED_IDPS', - 'RADIUS_SERVER', - 'TACACSPLUS_HOST', - ] - # Also include any SOCAIL_AUTH_*KEY (except SAML) - for social_auth_key in dir(settings): - if social_auth_key.startswith('SOCIAL_AUTH_') and social_auth_key.endswith('_KEY') and 'SAML' not in social_auth_key: - settings_that_turn_on_remote_auth.append(social_auth_key) - - return any(getattr(settings, s, None) for s in settings_that_turn_on_remote_auth) diff --git a/awx/sso/conf.py b/awx/sso/conf.py deleted file mode 100644 index 03640fccd8ae..000000000000 --- a/awx/sso/conf.py +++ /dev/null @@ -1,1667 +0,0 @@ -# Python -import collections -import urllib.parse as urlparse - -# Django -from django.conf import settings -from django.urls import reverse -from django.utils.translation import gettext_lazy as _ - -# Django REST Framework -from rest_framework import serializers - -# AWX -from awx.conf import register, register_validate, fields -from awx.sso.fields import ( - AuthenticationBackendsField, - LDAPConnectionOptionsField, - LDAPDNField, - LDAPDNWithUserField, - LDAPGroupTypeField, - LDAPGroupTypeParamsField, - LDAPOrganizationMapField, - LDAPSearchField, - LDAPSearchUnionField, - LDAPServerURIField, - LDAPTeamMapField, - LDAPUserAttrMapField, - LDAPUserFlagsField, - SAMLContactField, - SAMLEnabledIdPsField, - SAMLOrgAttrField, - SAMLOrgInfoField, - SAMLSecurityField, - SAMLTeamAttrField, - SAMLUserFlagsAttrField, - SocialOrganizationMapField, - SocialTeamMapField, -) -from awx.main.validators import validate_private_key, validate_certificate -from awx.sso.validators import validate_ldap_bind_dn, validate_tacacsplus_disallow_nonascii # noqa - - -class SocialAuthCallbackURL(object): - def __init__(self, provider): - self.provider = provider - - def __call__(self): - path = reverse('social:complete', args=(self.provider,)) - return urlparse.urljoin(settings.TOWER_URL_BASE, path) - - -SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT = _( - '''\ -Mapping to organization admins/users from social auth accounts. This setting -controls which users are placed into which organizations based on their -username and email address. Configuration details are available in the -documentation.\ -''' -) - -# FIXME: /regex/gim (flags) - -SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER = collections.OrderedDict( - [ - ('Default', collections.OrderedDict([('users', True)])), - ('Test Org', collections.OrderedDict([('admins', ['admin@example.com']), ('auditors', ['auditor@example.com']), ('users', True)])), - ( - 'Test Org 2', - collections.OrderedDict( - [ - ('admins', ['admin@example.com', r'/^tower-[^@]+*?@.*$/']), - ('remove_admins', True), - ('users', r'/^[^@].*?@example\.com$/i'), - ('remove_users', True), - ] - ), - ), - ] -) - -SOCIAL_AUTH_TEAM_MAP_HELP_TEXT = _( - '''\ -Mapping of team members (users) from social auth accounts. Configuration -details are available in the documentation.\ -''' -) - -SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER = collections.OrderedDict( - [ - ('My Team', collections.OrderedDict([('organization', 'Test Org'), ('users', [r'/^[^@]+?@test\.example\.com$/']), ('remove', True)])), - ('Other Team', collections.OrderedDict([('organization', 'Test Org 2'), ('users', r'/^[^@]+?@test2\.example\.com$/i'), ('remove', False)])), - ] -) - -if settings.ALLOW_LOCAL_RESOURCE_MANAGEMENT: - ############################################################################### - # AUTHENTICATION BACKENDS DYNAMIC SETTING - ############################################################################### - - register( - 'AUTHENTICATION_BACKENDS', - field_class=AuthenticationBackendsField, - label=_('Authentication Backends'), - help_text=_('List of authentication backends that are enabled based on license features and other authentication settings.'), - read_only=True, - depends_on=AuthenticationBackendsField.get_all_required_settings(), - category=_('Authentication'), - category_slug='authentication', - ) - - register( - 'SOCIAL_AUTH_ORGANIZATION_MAP', - field_class=SocialOrganizationMapField, - allow_null=True, - default=None, - label=_('Social Auth Organization Map'), - help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT, - category=_('Authentication'), - category_slug='authentication', - placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER, - ) - - register( - 'SOCIAL_AUTH_TEAM_MAP', - field_class=SocialTeamMapField, - allow_null=True, - default=None, - label=_('Social Auth Team Map'), - help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT, - category=_('Authentication'), - category_slug='authentication', - placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, - ) - - register( - 'SOCIAL_AUTH_USER_FIELDS', - field_class=fields.StringListField, - allow_null=True, - default=None, - label=_('Social Auth User Fields'), - help_text=_( - 'When set to an empty list `[]`, this setting prevents new user ' - 'accounts from being created. Only users who have previously ' - 'logged in using social auth or have a user account with a ' - 'matching email address will be able to login.' - ), - category=_('Authentication'), - category_slug='authentication', - placeholder=['username', 'email'], - ) - - register( - 'SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL', - field_class=fields.BooleanField, - default=False, - label=_('Use Email address for usernames'), - help_text=_('Enabling this setting will tell social auth to use the full Email as username instead of the full name'), - category=_('Authentication'), - category_slug='authentication', - ) - - ############################################################################### - # LDAP AUTHENTICATION SETTINGS - ############################################################################### - - def _register_ldap(append=None): - append_str = '_{}'.format(append) if append else '' - - register( - 'AUTH_LDAP{}_SERVER_URI'.format(append_str), - field_class=LDAPServerURIField, - allow_blank=True, - default='', - label=_('LDAP Server URI'), - help_text=_( - 'URI to connect to LDAP server, such as "ldap://ldap.example.com:389" ' - '(non-SSL) or "ldaps://ldap.example.com:636" (SSL). Multiple LDAP ' - 'servers may be specified by separating with spaces or commas. LDAP ' - 'authentication is disabled if this parameter is empty.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder='ldaps://ldap.example.com:636', - ) - - register( - 'AUTH_LDAP{}_BIND_DN'.format(append_str), - field_class=fields.CharField, - allow_blank=True, - default='', - validators=[validate_ldap_bind_dn], - label=_('LDAP Bind DN'), - help_text=_( - 'DN (Distinguished Name) of user to bind for all search queries. This' - ' is the system user account we will use to login to query LDAP for other' - ' user information. Refer to the documentation for example syntax.' - ), - category=_('LDAP'), - category_slug='ldap', - ) - - register( - 'AUTH_LDAP{}_BIND_PASSWORD'.format(append_str), - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('LDAP Bind Password'), - help_text=_('Password used to bind LDAP user account.'), - category=_('LDAP'), - category_slug='ldap', - encrypted=True, - ) - - register( - 'AUTH_LDAP{}_START_TLS'.format(append_str), - field_class=fields.BooleanField, - default=False, - label=_('LDAP Start TLS'), - help_text=_('Whether to enable TLS when the LDAP connection is not using SSL.'), - category=_('LDAP'), - category_slug='ldap', - ) - - register( - 'AUTH_LDAP{}_CONNECTION_OPTIONS'.format(append_str), - field_class=LDAPConnectionOptionsField, - default={'OPT_REFERRALS': 0, 'OPT_NETWORK_TIMEOUT': 30}, - label=_('LDAP Connection Options'), - help_text=_( - 'Additional options to set for the LDAP connection. LDAP ' - 'referrals are disabled by default (to prevent certain LDAP ' - 'queries from hanging with AD). Option names should be strings ' - '(e.g. "OPT_REFERRALS"). Refer to ' - 'https://www.python-ldap.org/doc/html/ldap.html#options for ' - 'possible options and values that can be set.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder=collections.OrderedDict([('OPT_REFERRALS', 0), ('OPT_NETWORK_TIMEOUT', 30)]), - ) - - register( - 'AUTH_LDAP{}_USER_SEARCH'.format(append_str), - field_class=LDAPSearchUnionField, - default=[], - label=_('LDAP User Search'), - help_text=_( - 'LDAP search query to find users. Any user that matches the given ' - 'pattern will be able to login to the service. The user should also be ' - 'mapped into an organization (as defined in the ' - 'AUTH_LDAP_ORGANIZATION_MAP setting). If multiple search queries ' - 'need to be supported use of "LDAPUnion" is possible. See ' - 'the documentation for details.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder=('OU=Users,DC=example,DC=com', 'SCOPE_SUBTREE', '(sAMAccountName=%(user)s)'), - ) - - register( - 'AUTH_LDAP{}_USER_DN_TEMPLATE'.format(append_str), - field_class=LDAPDNWithUserField, - allow_blank=True, - allow_null=True, - default=None, - label=_('LDAP User DN Template'), - help_text=_( - 'Alternative to user search, if user DNs are all of the same ' - 'format. This approach is more efficient for user lookups than ' - 'searching if it is usable in your organizational environment. If ' - 'this setting has a value it will be used instead of ' - 'AUTH_LDAP_USER_SEARCH.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder='uid=%(user)s,OU=Users,DC=example,DC=com', - ) - - register( - 'AUTH_LDAP{}_USER_ATTR_MAP'.format(append_str), - field_class=LDAPUserAttrMapField, - default={}, - label=_('LDAP User Attribute Map'), - help_text=_( - 'Mapping of LDAP user schema to API user attributes. The default' - ' setting is valid for ActiveDirectory but users with other LDAP' - ' configurations may need to change the values. Refer to the' - ' documentation for additional details.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder=collections.OrderedDict([('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail')]), - ) - - register( - 'AUTH_LDAP{}_GROUP_SEARCH'.format(append_str), - field_class=LDAPSearchField, - default=[], - label=_('LDAP Group Search'), - help_text=_( - 'Users are mapped to organizations based on their membership in LDAP' - ' groups. This setting defines the LDAP search query to find groups. ' - 'Unlike the user search, group search does not support LDAPSearchUnion.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder=('DC=example,DC=com', 'SCOPE_SUBTREE', '(objectClass=group)'), - ) - - register( - 'AUTH_LDAP{}_GROUP_TYPE'.format(append_str), - field_class=LDAPGroupTypeField, - label=_('LDAP Group Type'), - help_text=_( - 'The group type may need to be changed based on the type of the ' - 'LDAP server. Values are listed at: ' - 'https://django-auth-ldap.readthedocs.io/en/stable/groups.html#types-of-groups' - ), - category=_('LDAP'), - category_slug='ldap', - default='MemberDNGroupType', - depends_on=['AUTH_LDAP{}_GROUP_TYPE_PARAMS'.format(append_str)], - ) - - register( - 'AUTH_LDAP{}_GROUP_TYPE_PARAMS'.format(append_str), - field_class=LDAPGroupTypeParamsField, - label=_('LDAP Group Type Parameters'), - help_text=_('Key value parameters to send the chosen group type init method.'), - category=_('LDAP'), - category_slug='ldap', - default=collections.OrderedDict([('member_attr', 'member'), ('name_attr', 'cn')]), - placeholder=collections.OrderedDict([('ldap_group_user_attr', 'legacyuid'), ('member_attr', 'member'), ('name_attr', 'cn')]), - depends_on=['AUTH_LDAP{}_GROUP_TYPE'.format(append_str)], - ) - - register( - 'AUTH_LDAP{}_REQUIRE_GROUP'.format(append_str), - field_class=LDAPDNField, - allow_blank=True, - allow_null=True, - default=None, - label=_('LDAP Require Group'), - help_text=_( - 'Group DN required to login. If specified, user must be a member ' - 'of this group to login via LDAP. If not set, everyone in LDAP ' - 'that matches the user search will be able to login to the service. ' - 'Only one require group is supported.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder='CN=Service Users,OU=Users,DC=example,DC=com', - ) - - register( - 'AUTH_LDAP{}_DENY_GROUP'.format(append_str), - field_class=LDAPDNField, - allow_blank=True, - allow_null=True, - default=None, - label=_('LDAP Deny Group'), - help_text=_( - 'Group DN denied from login. If specified, user will not be allowed to login if a member of this group. Only one deny group is supported.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder='CN=Disabled Users,OU=Users,DC=example,DC=com', - ) - - register( - 'AUTH_LDAP{}_USER_FLAGS_BY_GROUP'.format(append_str), - field_class=LDAPUserFlagsField, - default={}, - label=_('LDAP User Flags By Group'), - help_text=_( - 'Retrieve users from a given group. At this time, superuser and system' - ' auditors are the only groups supported. Refer to the' - ' documentation for more detail.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder=collections.OrderedDict( - [('is_superuser', 'CN=Domain Admins,CN=Users,DC=example,DC=com'), ('is_system_auditor', 'CN=Domain Auditors,CN=Users,DC=example,DC=com')] - ), - ) - - register( - 'AUTH_LDAP{}_ORGANIZATION_MAP'.format(append_str), - field_class=LDAPOrganizationMapField, - default={}, - label=_('LDAP Organization Map'), - help_text=_( - 'Mapping between organization admins/users and LDAP groups. This ' - 'controls which users are placed into which organizations ' - 'relative to their LDAP group memberships. Configuration details ' - 'are available in the documentation.' - ), - category=_('LDAP'), - category_slug='ldap', - placeholder=collections.OrderedDict( - [ - ( - 'Test Org', - collections.OrderedDict( - [ - ('admins', 'CN=Domain Admins,CN=Users,DC=example,DC=com'), - ('auditors', 'CN=Domain Auditors,CN=Users,DC=example,DC=com'), - ('users', ['CN=Domain Users,CN=Users,DC=example,DC=com']), - ('remove_users', True), - ('remove_admins', True), - ] - ), - ), - ( - 'Test Org 2', - collections.OrderedDict( - [('admins', 'CN=Administrators,CN=Builtin,DC=example,DC=com'), ('users', True), ('remove_users', True), ('remove_admins', True)] - ), - ), - ] - ), - ) - - register( - 'AUTH_LDAP{}_TEAM_MAP'.format(append_str), - field_class=LDAPTeamMapField, - default={}, - label=_('LDAP Team Map'), - help_text=_('Mapping between team members (users) and LDAP groups. Configuration details are available in the documentation.'), - category=_('LDAP'), - category_slug='ldap', - placeholder=collections.OrderedDict( - [ - ( - 'My Team', - collections.OrderedDict([('organization', 'Test Org'), ('users', ['CN=Domain Users,CN=Users,DC=example,DC=com']), ('remove', True)]), - ), - ( - 'Other Team', - collections.OrderedDict([('organization', 'Test Org 2'), ('users', 'CN=Other Users,CN=Users,DC=example,DC=com'), ('remove', False)]), - ), - ] - ), - ) - - _register_ldap() - _register_ldap('1') - _register_ldap('2') - _register_ldap('3') - _register_ldap('4') - _register_ldap('5') - - ############################################################################### - # RADIUS AUTHENTICATION SETTINGS - ############################################################################### - - register( - 'RADIUS_SERVER', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('RADIUS Server'), - help_text=_('Hostname/IP of RADIUS server. RADIUS authentication is disabled if this setting is empty.'), - category=_('RADIUS'), - category_slug='radius', - placeholder='radius.example.com', - ) - - register( - 'RADIUS_PORT', - field_class=fields.IntegerField, - min_value=1, - max_value=65535, - default=1812, - label=_('RADIUS Port'), - help_text=_('Port of RADIUS server.'), - category=_('RADIUS'), - category_slug='radius', - ) - - register( - 'RADIUS_SECRET', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('RADIUS Secret'), - help_text=_('Shared secret for authenticating to RADIUS server.'), - category=_('RADIUS'), - category_slug='radius', - encrypted=True, - ) - - ############################################################################### - # TACACSPLUS AUTHENTICATION SETTINGS - ############################################################################### - - register( - 'TACACSPLUS_HOST', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('TACACS+ Server'), - help_text=_('Hostname of TACACS+ server.'), - category=_('TACACS+'), - category_slug='tacacsplus', - ) - - register( - 'TACACSPLUS_PORT', - field_class=fields.IntegerField, - min_value=1, - max_value=65535, - default=49, - label=_('TACACS+ Port'), - help_text=_('Port number of TACACS+ server.'), - category=_('TACACS+'), - category_slug='tacacsplus', - ) - - register( - 'TACACSPLUS_SECRET', - field_class=fields.CharField, - allow_blank=True, - default='', - validators=[validate_tacacsplus_disallow_nonascii], - label=_('TACACS+ Secret'), - help_text=_('Shared secret for authenticating to TACACS+ server.'), - category=_('TACACS+'), - category_slug='tacacsplus', - encrypted=True, - ) - - register( - 'TACACSPLUS_SESSION_TIMEOUT', - field_class=fields.IntegerField, - min_value=0, - default=5, - label=_('TACACS+ Auth Session Timeout'), - help_text=_('TACACS+ session timeout value in seconds, 0 disables timeout.'), - category=_('TACACS+'), - category_slug='tacacsplus', - unit=_('seconds'), - ) - - register( - 'TACACSPLUS_AUTH_PROTOCOL', - field_class=fields.ChoiceField, - choices=['ascii', 'pap'], - default='ascii', - label=_('TACACS+ Authentication Protocol'), - help_text=_('Choose the authentication protocol used by TACACS+ client.'), - category=_('TACACS+'), - category_slug='tacacsplus', - ) - - register( - 'TACACSPLUS_REM_ADDR', - field_class=fields.BooleanField, - default=True, - label=_('TACACS+ client address sending enabled'), - help_text=_('Enable the client address sending by TACACS+ client.'), - category=_('TACACS+'), - category_slug='tacacsplus', - ) - - ############################################################################### - # GOOGLE OAUTH2 AUTHENTICATION SETTINGS - ############################################################################### - - register( - 'SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL', - field_class=fields.CharField, - read_only=True, - default=SocialAuthCallbackURL('google-oauth2'), - label=_('Google OAuth2 Callback URL'), - help_text=_( - 'Provide this URL as the callback URL for your application as part of your registration process. Refer to the documentation for more detail.' - ), - category=_('Google OAuth2'), - category_slug='google-oauth2', - depends_on=['TOWER_URL_BASE'], - ) - - register( - 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('Google OAuth2 Key'), - help_text=_('The OAuth2 key from your web application.'), - category=_('Google OAuth2'), - category_slug='google-oauth2', - placeholder='528620852399-gm2dt4hrl2tsj67fqamk09k1e0ad6gd8.apps.googleusercontent.com', - ) - - register( - 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('Google OAuth2 Secret'), - help_text=_('The OAuth2 secret from your web application.'), - category=_('Google OAuth2'), - category_slug='google-oauth2', - placeholder='q2fMVCmEregbg-drvebPp8OW', - encrypted=True, - ) - - register( - 'SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS', - field_class=fields.StringListField, - default=[], - label=_('Google OAuth2 Allowed Domains'), - help_text=_('Update this setting to restrict the domains who are allowed to login using Google OAuth2.'), - category=_('Google OAuth2'), - category_slug='google-oauth2', - placeholder=['example.com'], - ) - - register( - 'SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS', - field_class=fields.DictField, - default={}, - label=_('Google OAuth2 Extra Arguments'), - help_text=_( - 'Extra arguments for Google OAuth2 login. You can restrict it to' - ' only allow a single domain to authenticate, even if the user is' - ' logged in with multple Google accounts. Refer to the' - ' documentation for more detail.' - ), - category=_('Google OAuth2'), - category_slug='google-oauth2', - placeholder={'hd': 'example.com'}, - ) - - register( - 'SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP', - field_class=SocialOrganizationMapField, - allow_null=True, - default=None, - label=_('Google OAuth2 Organization Map'), - help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT, - category=_('Google OAuth2'), - category_slug='google-oauth2', - placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER, - ) - - register( - 'SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP', - field_class=SocialTeamMapField, - allow_null=True, - default=None, - label=_('Google OAuth2 Team Map'), - help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT, - category=_('Google OAuth2'), - category_slug='google-oauth2', - placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, - ) - - ############################################################################### - # GITHUB OAUTH2 AUTHENTICATION SETTINGS - ############################################################################### - - register( - 'SOCIAL_AUTH_GITHUB_CALLBACK_URL', - field_class=fields.CharField, - read_only=True, - default=SocialAuthCallbackURL('github'), - label=_('GitHub OAuth2 Callback URL'), - help_text=_( - 'Provide this URL as the callback URL for your application as part of your registration process. Refer to the documentation for more detail.' - ), - category=_('GitHub OAuth2'), - category_slug='github', - depends_on=['TOWER_URL_BASE'], - ) - - register( - 'SOCIAL_AUTH_GITHUB_KEY', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub OAuth2 Key'), - help_text=_('The OAuth2 key (Client ID) from your GitHub developer application.'), - category=_('GitHub OAuth2'), - category_slug='github', - ) - - register( - 'SOCIAL_AUTH_GITHUB_SECRET', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub OAuth2 Secret'), - help_text=_('The OAuth2 secret (Client Secret) from your GitHub developer application.'), - category=_('GitHub OAuth2'), - category_slug='github', - encrypted=True, - ) - - register( - 'SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP', - field_class=SocialOrganizationMapField, - allow_null=True, - default=None, - label=_('GitHub OAuth2 Organization Map'), - help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT, - category=_('GitHub OAuth2'), - category_slug='github', - placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER, - ) - - register( - 'SOCIAL_AUTH_GITHUB_TEAM_MAP', - field_class=SocialTeamMapField, - allow_null=True, - default=None, - label=_('GitHub OAuth2 Team Map'), - help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT, - category=_('GitHub OAuth2'), - category_slug='github', - placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, - ) - - ############################################################################### - # GITHUB ORG OAUTH2 AUTHENTICATION SETTINGS - ############################################################################### - - register( - 'SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL', - field_class=fields.CharField, - read_only=True, - default=SocialAuthCallbackURL('github-org'), - label=_('GitHub Organization OAuth2 Callback URL'), - help_text=_( - 'Provide this URL as the callback URL for your application as part of your registration process. Refer to the documentation for more detail.' - ), - category=_('GitHub Organization OAuth2'), - category_slug='github-org', - depends_on=['TOWER_URL_BASE'], - ) - - register( - 'SOCIAL_AUTH_GITHUB_ORG_KEY', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Organization OAuth2 Key'), - help_text=_('The OAuth2 key (Client ID) from your GitHub organization application.'), - category=_('GitHub Organization OAuth2'), - category_slug='github-org', - ) - - register( - 'SOCIAL_AUTH_GITHUB_ORG_SECRET', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Organization OAuth2 Secret'), - help_text=_('The OAuth2 secret (Client Secret) from your GitHub organization application.'), - category=_('GitHub Organization OAuth2'), - category_slug='github-org', - encrypted=True, - ) - - register( - 'SOCIAL_AUTH_GITHUB_ORG_NAME', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Organization Name'), - help_text=_('The name of your GitHub organization, as used in your organization\'s URL: https://github.com//.'), - category=_('GitHub Organization OAuth2'), - category_slug='github-org', - ) - - register( - 'SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP', - field_class=SocialOrganizationMapField, - allow_null=True, - default=None, - label=_('GitHub Organization OAuth2 Organization Map'), - help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT, - category=_('GitHub Organization OAuth2'), - category_slug='github-org', - placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER, - ) - - register( - 'SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP', - field_class=SocialTeamMapField, - allow_null=True, - default=None, - label=_('GitHub Organization OAuth2 Team Map'), - help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT, - category=_('GitHub Organization OAuth2'), - category_slug='github-org', - placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, - ) - - ############################################################################### - # GITHUB TEAM OAUTH2 AUTHENTICATION SETTINGS - ############################################################################### - - register( - 'SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL', - field_class=fields.CharField, - read_only=True, - default=SocialAuthCallbackURL('github-team'), - label=_('GitHub Team OAuth2 Callback URL'), - help_text=_( - 'Create an organization-owned application at ' - 'https://github.com/organizations//settings/applications ' - 'and obtain an OAuth2 key (Client ID) and secret (Client Secret). ' - 'Provide this URL as the callback URL for your application.' - ), - category=_('GitHub Team OAuth2'), - category_slug='github-team', - depends_on=['TOWER_URL_BASE'], - ) - - register( - 'SOCIAL_AUTH_GITHUB_TEAM_KEY', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Team OAuth2 Key'), - help_text=_('The OAuth2 key (Client ID) from your GitHub organization application.'), - category=_('GitHub Team OAuth2'), - category_slug='github-team', - ) - - register( - 'SOCIAL_AUTH_GITHUB_TEAM_SECRET', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Team OAuth2 Secret'), - help_text=_('The OAuth2 secret (Client Secret) from your GitHub organization application.'), - category=_('GitHub Team OAuth2'), - category_slug='github-team', - encrypted=True, - ) - - register( - 'SOCIAL_AUTH_GITHUB_TEAM_ID', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Team ID'), - help_text=_('Find the numeric team ID using the Github API: http://fabian-kostadinov.github.io/2015/01/16/how-to-find-a-github-team-id/.'), - category=_('GitHub Team OAuth2'), - category_slug='github-team', - ) - - register( - 'SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP', - field_class=SocialOrganizationMapField, - allow_null=True, - default=None, - label=_('GitHub Team OAuth2 Organization Map'), - help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT, - category=_('GitHub Team OAuth2'), - category_slug='github-team', - placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER, - ) - - register( - 'SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP', - field_class=SocialTeamMapField, - allow_null=True, - default=None, - label=_('GitHub Team OAuth2 Team Map'), - help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT, - category=_('GitHub Team OAuth2'), - category_slug='github-team', - placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, - ) - - ############################################################################### - # GITHUB ENTERPRISE OAUTH2 AUTHENTICATION SETTINGS - ############################################################################### - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_CALLBACK_URL', - field_class=fields.CharField, - read_only=True, - default=SocialAuthCallbackURL('github-enterprise'), - label=_('GitHub Enterprise OAuth2 Callback URL'), - help_text=_( - 'Provide this URL as the callback URL for your application as part of your registration process. Refer to the documentation for more detail.' - ), - category=_('GitHub Enterprise OAuth2'), - category_slug='github-enterprise', - depends_on=['TOWER_URL_BASE'], - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise URL'), - help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise documentation for more details.'), - category=_('GitHub Enterprise OAuth2'), - category_slug='github-enterprise', - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise API URL'), - help_text=_( - 'The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github Enterprise documentation for more details.' - ), - category=_('GitHub Enterprise OAuth2'), - category_slug='github-enterprise', - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise OAuth2 Key'), - help_text=_('The OAuth2 key (Client ID) from your GitHub Enterprise developer application.'), - category=_('GitHub Enterprise OAuth2'), - category_slug='github-enterprise', - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise OAuth2 Secret'), - help_text=_('The OAuth2 secret (Client Secret) from your GitHub Enterprise developer application.'), - category=_('GitHub OAuth2'), - category_slug='github-enterprise', - encrypted=True, - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORGANIZATION_MAP', - field_class=SocialOrganizationMapField, - allow_null=True, - default=None, - label=_('GitHub Enterprise OAuth2 Organization Map'), - help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT, - category=_('GitHub Enterprise OAuth2'), - category_slug='github-enterprise', - placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER, - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_MAP', - field_class=SocialTeamMapField, - allow_null=True, - default=None, - label=_('GitHub Enterprise OAuth2 Team Map'), - help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT, - category=_('GitHub Enterprise OAuth2'), - category_slug='github-enterprise', - placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, - ) - - ############################################################################### - # GITHUB ENTERPRISE ORG OAUTH2 AUTHENTICATION SETTINGS - ############################################################################### - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_CALLBACK_URL', - field_class=fields.CharField, - read_only=True, - default=SocialAuthCallbackURL('github-enterprise-org'), - label=_('GitHub Enterprise Organization OAuth2 Callback URL'), - help_text=_( - 'Provide this URL as the callback URL for your application as part of your registration process. Refer to the documentation for more detail.' - ), - category=_('GitHub Enterprise Organization OAuth2'), - category_slug='github-enterprise-org', - depends_on=['TOWER_URL_BASE'], - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise Organization URL'), - help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise documentation for more details.'), - category=_('GitHub Enterprise OAuth2'), - category_slug='github-enterprise-org', - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise Organization API URL'), - help_text=_( - 'The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github Enterprise documentation for more details.' - ), - category=_('GitHub Enterprise OAuth2'), - category_slug='github-enterprise-org', - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise Organization OAuth2 Key'), - help_text=_('The OAuth2 key (Client ID) from your GitHub Enterprise organization application.'), - category=_('GitHub Enterprise Organization OAuth2'), - category_slug='github-enterprise-org', - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise Organization OAuth2 Secret'), - help_text=_('The OAuth2 secret (Client Secret) from your GitHub Enterprise organization application.'), - category=_('GitHub Enterprise Organization OAuth2'), - category_slug='github-enterprise-org', - encrypted=True, - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise Organization Name'), - help_text=_('The name of your GitHub Enterprise organization, as used in your organization\'s URL: https://github.com//.'), - category=_('GitHub Enterprise Organization OAuth2'), - category_slug='github-enterprise-org', - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP', - field_class=SocialOrganizationMapField, - allow_null=True, - default=None, - label=_('GitHub Enterprise Organization OAuth2 Organization Map'), - help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT, - category=_('GitHub Enterprise Organization OAuth2'), - category_slug='github-enterprise-org', - placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER, - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP', - field_class=SocialTeamMapField, - allow_null=True, - default=None, - label=_('GitHub Enterprise Organization OAuth2 Team Map'), - help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT, - category=_('GitHub Enterprise Organization OAuth2'), - category_slug='github-enterprise-org', - placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, - ) - - ############################################################################### - # GITHUB ENTERPRISE TEAM OAUTH2 AUTHENTICATION SETTINGS - ############################################################################### - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_CALLBACK_URL', - field_class=fields.CharField, - read_only=True, - default=SocialAuthCallbackURL('github-enterprise-team'), - label=_('GitHub Enterprise Team OAuth2 Callback URL'), - help_text=_( - 'Create an organization-owned application at ' - 'https://github.com/organizations//settings/applications ' - 'and obtain an OAuth2 key (Client ID) and secret (Client Secret). ' - 'Provide this URL as the callback URL for your application.' - ), - category=_('GitHub Enterprise Team OAuth2'), - category_slug='github-enterprise-team', - depends_on=['TOWER_URL_BASE'], - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise Team URL'), - help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise documentation for more details.'), - category=_('GitHub Enterprise OAuth2'), - category_slug='github-enterprise-team', - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise Team API URL'), - help_text=_( - 'The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github Enterprise documentation for more details.' - ), - category=_('GitHub Enterprise OAuth2'), - category_slug='github-enterprise-team', - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise Team OAuth2 Key'), - help_text=_('The OAuth2 key (Client ID) from your GitHub Enterprise organization application.'), - category=_('GitHub Enterprise Team OAuth2'), - category_slug='github-enterprise-team', - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise Team OAuth2 Secret'), - help_text=_('The OAuth2 secret (Client Secret) from your GitHub Enterprise organization application.'), - category=_('GitHub Enterprise Team OAuth2'), - category_slug='github-enterprise-team', - encrypted=True, - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('GitHub Enterprise Team ID'), - help_text=_('Find the numeric team ID using the Github Enterprise API: http://fabian-kostadinov.github.io/2015/01/16/how-to-find-a-github-team-id/.'), - category=_('GitHub Enterprise Team OAuth2'), - category_slug='github-enterprise-team', - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP', - field_class=SocialOrganizationMapField, - allow_null=True, - default=None, - label=_('GitHub Enterprise Team OAuth2 Organization Map'), - help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT, - category=_('GitHub Enterprise Team OAuth2'), - category_slug='github-enterprise-team', - placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER, - ) - - register( - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP', - field_class=SocialTeamMapField, - allow_null=True, - default=None, - label=_('GitHub Enterprise Team OAuth2 Team Map'), - help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT, - category=_('GitHub Enterprise Team OAuth2'), - category_slug='github-enterprise-team', - placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, - ) - - ############################################################################### - # MICROSOFT AZURE ACTIVE DIRECTORY SETTINGS - ############################################################################### - - register( - 'SOCIAL_AUTH_AZUREAD_OAUTH2_CALLBACK_URL', - field_class=fields.CharField, - read_only=True, - default=SocialAuthCallbackURL('azuread-oauth2'), - label=_('Azure AD OAuth2 Callback URL'), - help_text=_( - 'Provide this URL as the callback URL for your application as part of your registration process. Refer to the documentation for more detail. ' - ), - category=_('Azure AD OAuth2'), - category_slug='azuread-oauth2', - depends_on=['TOWER_URL_BASE'], - ) - - register( - 'SOCIAL_AUTH_AZUREAD_OAUTH2_KEY', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('Azure AD OAuth2 Key'), - help_text=_('The OAuth2 key (Client ID) from your Azure AD application.'), - category=_('Azure AD OAuth2'), - category_slug='azuread-oauth2', - ) - - register( - 'SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('Azure AD OAuth2 Secret'), - help_text=_('The OAuth2 secret (Client Secret) from your Azure AD application.'), - category=_('Azure AD OAuth2'), - category_slug='azuread-oauth2', - encrypted=True, - ) - - register( - 'SOCIAL_AUTH_AZUREAD_OAUTH2_ORGANIZATION_MAP', - field_class=SocialOrganizationMapField, - allow_null=True, - default=None, - label=_('Azure AD OAuth2 Organization Map'), - help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT, - category=_('Azure AD OAuth2'), - category_slug='azuread-oauth2', - placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER, - ) - - register( - 'SOCIAL_AUTH_AZUREAD_OAUTH2_TEAM_MAP', - field_class=SocialTeamMapField, - allow_null=True, - default=None, - label=_('Azure AD OAuth2 Team Map'), - help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT, - category=_('Azure AD OAuth2'), - category_slug='azuread-oauth2', - placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, - ) - - ############################################################################### - # Generic OIDC AUTHENTICATION SETTINGS - ############################################################################### - - register( - 'SOCIAL_AUTH_OIDC_KEY', - field_class=fields.CharField, - allow_null=False, - default=None, - label=_('OIDC Key'), - help_text='The OIDC key (Client ID) from your IDP.', - category=_('Generic OIDC'), - category_slug='oidc', - ) - - register( - 'SOCIAL_AUTH_OIDC_SECRET', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('OIDC Secret'), - help_text=_('The OIDC secret (Client Secret) from your IDP.'), - category=_('Generic OIDC'), - category_slug='oidc', - encrypted=True, - ) - - register( - 'SOCIAL_AUTH_OIDC_OIDC_ENDPOINT', - field_class=fields.CharField, - allow_blank=True, - default='', - label=_('OIDC Provider URL'), - help_text=_('The URL for your OIDC provider including the path up to /.well-known/openid-configuration'), - category=_('Generic OIDC'), - category_slug='oidc', - ) - - register( - 'SOCIAL_AUTH_OIDC_VERIFY_SSL', - field_class=fields.BooleanField, - default=True, - label=_('Verify OIDC Provider Certificate'), - help_text=_('Verify the OIDC provider ssl certificate.'), - category=_('Generic OIDC'), - category_slug='oidc', - ) - - ############################################################################### - # SAML AUTHENTICATION SETTINGS - ############################################################################### - - def get_saml_metadata_url(): - return urlparse.urljoin(settings.TOWER_URL_BASE, reverse('sso:saml_metadata')) - - def get_saml_entity_id(): - return settings.TOWER_URL_BASE - - register( - 'SAML_AUTO_CREATE_OBJECTS', - field_class=fields.BooleanField, - default=True, - label=_('Automatically Create Organizations and Teams on SAML Login'), - help_text=_('When enabled (the default), mapped Organizations and Teams will be created automatically on successful SAML login.'), - category=_('SAML'), - category_slug='saml', - ) - - register( - 'SOCIAL_AUTH_SAML_CALLBACK_URL', - field_class=fields.CharField, - read_only=True, - default=SocialAuthCallbackURL('saml'), - label=_('SAML Assertion Consumer Service (ACS) URL'), - help_text=_( - 'Register the service as a service provider (SP) with each identity ' - 'provider (IdP) you have configured. Provide your SP Entity ID ' - 'and this ACS URL for your application.' - ), - category=_('SAML'), - category_slug='saml', - depends_on=['TOWER_URL_BASE'], - ) - - register( - 'SOCIAL_AUTH_SAML_METADATA_URL', - field_class=fields.CharField, - read_only=True, - default=get_saml_metadata_url, - label=_('SAML Service Provider Metadata URL'), - help_text=_('If your identity provider (IdP) allows uploading an XML metadata file, you can download one from this URL.'), - category=_('SAML'), - category_slug='saml', - ) - - register( - 'SOCIAL_AUTH_SAML_SP_ENTITY_ID', - field_class=fields.CharField, - allow_blank=True, - default=get_saml_entity_id, - label=_('SAML Service Provider Entity ID'), - help_text=_( - 'The application-defined unique identifier used as the ' - 'audience of the SAML service provider (SP) configuration. ' - 'This is usually the URL for the service.' - ), - category=_('SAML'), - category_slug='saml', - depends_on=['TOWER_URL_BASE'], - ) - - register( - 'SOCIAL_AUTH_SAML_SP_PUBLIC_CERT', - field_class=fields.CharField, - allow_blank=True, - validators=[validate_certificate], - label=_('SAML Service Provider Public Certificate'), - help_text=_('Create a keypair to use as a service provider (SP) and include the certificate content here.'), - category=_('SAML'), - category_slug='saml', - ) - - register( - 'SOCIAL_AUTH_SAML_SP_PRIVATE_KEY', - field_class=fields.CharField, - allow_blank=True, - validators=[validate_private_key], - label=_('SAML Service Provider Private Key'), - help_text=_('Create a keypair to use as a service provider (SP) and include the private key content here.'), - category=_('SAML'), - category_slug='saml', - encrypted=True, - ) - - register( - 'SOCIAL_AUTH_SAML_ORG_INFO', - field_class=SAMLOrgInfoField, - label=_('SAML Service Provider Organization Info'), - help_text=_('Provide the URL, display name, and the name of your app. Refer to the documentation for example syntax.'), - category=_('SAML'), - category_slug='saml', - placeholder=collections.OrderedDict( - [('en-US', collections.OrderedDict([('name', 'example'), ('displayname', 'Example'), ('url', 'http://www.example.com')]))] - ), - ) - - register( - 'SOCIAL_AUTH_SAML_TECHNICAL_CONTACT', - field_class=SAMLContactField, - allow_blank=True, - label=_('SAML Service Provider Technical Contact'), - help_text=_('Provide the name and email address of the technical contact for your service provider. Refer to the documentation for example syntax.'), - category=_('SAML'), - category_slug='saml', - placeholder=collections.OrderedDict([('givenName', 'Technical Contact'), ('emailAddress', 'techsup@example.com')]), - ) - - register( - 'SOCIAL_AUTH_SAML_SUPPORT_CONTACT', - field_class=SAMLContactField, - allow_blank=True, - label=_('SAML Service Provider Support Contact'), - help_text=_('Provide the name and email address of the support contact for your service provider. Refer to the documentation for example syntax.'), - category=_('SAML'), - category_slug='saml', - placeholder=collections.OrderedDict([('givenName', 'Support Contact'), ('emailAddress', 'support@example.com')]), - ) - - register( - 'SOCIAL_AUTH_SAML_ENABLED_IDPS', - field_class=SAMLEnabledIdPsField, - default={}, - label=_('SAML Enabled Identity Providers'), - help_text=_( - 'Configure the Entity ID, SSO URL and certificate for each identity' - ' provider (IdP) in use. Multiple SAML IdPs are supported. Some IdPs' - ' may provide user data using attribute names that differ from the' - ' default OIDs. Attribute names may be overridden for each IdP. Refer' - ' to the Ansible documentation for additional details and syntax.' - ), - category=_('SAML'), - category_slug='saml', - placeholder=collections.OrderedDict( - [ - ( - 'Okta', - collections.OrderedDict( - [ - ('entity_id', 'http://www.okta.com/HHniyLkaxk9e76wD0Thh'), - ('url', 'https://dev-123456.oktapreview.com/app/ansibletower/HHniyLkaxk9e76wD0Thh/sso/saml'), - ('x509cert', 'MIIDpDCCAoygAwIBAgIGAVVZ4rPzMA0GCSqGSIb3...'), - ('attr_user_permanent_id', 'username'), - ('attr_first_name', 'first_name'), - ('attr_last_name', 'last_name'), - ('attr_username', 'username'), - ('attr_email', 'email'), - ] - ), - ), - ( - 'OneLogin', - collections.OrderedDict( - [ - ('entity_id', 'https://app.onelogin.com/saml/metadata/123456'), - ('url', 'https://example.onelogin.com/trust/saml2/http-post/sso/123456'), - ('x509cert', 'MIIEJjCCAw6gAwIBAgIUfuSD54OPSBhndDHh3gZo...'), - ('attr_user_permanent_id', 'name_id'), - ('attr_first_name', 'User.FirstName'), - ('attr_last_name', 'User.LastName'), - ('attr_username', 'User.email'), - ('attr_email', 'User.email'), - ] - ), - ), - ] - ), - ) - - register( - 'SOCIAL_AUTH_SAML_SECURITY_CONFIG', - field_class=SAMLSecurityField, - allow_null=True, - default={'requestedAuthnContext': False}, - label=_('SAML Security Config'), - help_text=_( - 'A dict of key value pairs that are passed to the underlying python-saml security setting https://github.com/onelogin/python-saml#settings' - ), - category=_('SAML'), - category_slug='saml', - placeholder=collections.OrderedDict( - [ - ("nameIdEncrypted", False), - ("authnRequestsSigned", False), - ("logoutRequestSigned", False), - ("logoutResponseSigned", False), - ("signMetadata", False), - ("wantMessagesSigned", False), - ("wantAssertionsSigned", False), - ("wantAssertionsEncrypted", False), - ("wantNameId", True), - ("wantNameIdEncrypted", False), - ("wantAttributeStatement", True), - ("requestedAuthnContext", True), - ("requestedAuthnContextComparison", "exact"), - ("metadataValidUntil", "2015-06-26T20:00:00Z"), - ("metadataCacheDuration", "PT518400S"), - ("signatureAlgorithm", "http://www.w3.org/2000/09/xmldsig#rsa-sha1"), - ("digestAlgorithm", "http://www.w3.org/2000/09/xmldsig#sha1"), - ] - ), - ) - - register( - 'SOCIAL_AUTH_SAML_SP_EXTRA', - field_class=fields.DictField, - allow_null=True, - default=None, - label=_('SAML Service Provider extra configuration data'), - help_text=_('A dict of key value pairs to be passed to the underlying python-saml Service Provider configuration setting.'), - category=_('SAML'), - category_slug='saml', - placeholder=collections.OrderedDict(), - ) - - register( - 'SOCIAL_AUTH_SAML_EXTRA_DATA', - field_class=fields.ListTuplesField, - allow_null=True, - default=None, - label=_('SAML IDP to extra_data attribute mapping'), - help_text=_('A list of tuples that maps IDP attributes to extra_attributes.' ' Each attribute will be a list of values, even if only 1 value.'), - category=_('SAML'), - category_slug='saml', - placeholder=[('attribute_name', 'extra_data_name_for_attribute'), ('department', 'department'), ('manager_full_name', 'manager_full_name')], - ) - - register( - 'SOCIAL_AUTH_SAML_ORGANIZATION_MAP', - field_class=SocialOrganizationMapField, - allow_null=True, - default=None, - label=_('SAML Organization Map'), - help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT, - category=_('SAML'), - category_slug='saml', - placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER, - ) - - register( - 'SOCIAL_AUTH_SAML_TEAM_MAP', - field_class=SocialTeamMapField, - allow_null=True, - default=None, - label=_('SAML Team Map'), - help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT, - category=_('SAML'), - category_slug='saml', - placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, - ) - - register( - 'SOCIAL_AUTH_SAML_ORGANIZATION_ATTR', - field_class=SAMLOrgAttrField, - allow_null=True, - default=None, - label=_('SAML Organization Attribute Mapping'), - help_text=_('Used to translate user organization membership.'), - category=_('SAML'), - category_slug='saml', - placeholder=collections.OrderedDict( - [ - ('saml_attr', 'organization'), - ('saml_admin_attr', 'organization_admin'), - ('saml_auditor_attr', 'organization_auditor'), - ('remove', True), - ('remove_admins', True), - ('remove_auditors', True), - ] - ), - ) - - register( - 'SOCIAL_AUTH_SAML_TEAM_ATTR', - field_class=SAMLTeamAttrField, - allow_null=True, - default=None, - label=_('SAML Team Attribute Mapping'), - help_text=_('Used to translate user team membership.'), - category=_('SAML'), - category_slug='saml', - placeholder=collections.OrderedDict( - [ - ('saml_attr', 'team'), - ('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')]), - ], - ), - ] - ), - ) - - register( - 'SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR', - field_class=SAMLUserFlagsAttrField, - allow_null=True, - default=None, - label=_('SAML User Flags Attribute Mapping'), - help_text=_('Used to map super users and system auditors from SAML.'), - category=_('SAML'), - category_slug='saml', - placeholder=[ - ('is_superuser_attr', 'saml_attr'), - ('is_superuser_value', ['value']), - ('is_superuser_role', ['saml_role']), - ('remove_superusers', True), - ('is_system_auditor_attr', 'saml_attr'), - ('is_system_auditor_value', ['value']), - ('is_system_auditor_role', ['saml_role']), - ('remove_system_auditors', True), - ], - ) - - register( - 'LOCAL_PASSWORD_MIN_LENGTH', - field_class=fields.IntegerField, - min_value=0, - default=0, - label=_('Minimum number of characters in local password'), - help_text=_('Minimum number of characters required in a local password. 0 means no minimum'), - category=_('Authentication'), - category_slug='authentication', - ) - - register( - 'LOCAL_PASSWORD_MIN_DIGITS', - field_class=fields.IntegerField, - min_value=0, - default=0, - label=_('Minimum number of digit characters in local password'), - help_text=_('Minimum number of digit characters required in a local password. 0 means no minimum'), - category=_('Authentication'), - category_slug='authentication', - ) - - register( - 'LOCAL_PASSWORD_MIN_UPPER', - field_class=fields.IntegerField, - min_value=0, - default=0, - label=_('Minimum number of uppercase characters in local password'), - help_text=_('Minimum number of uppercase characters required in a local password. 0 means no minimum'), - category=_('Authentication'), - category_slug='authentication', - ) - - register( - 'LOCAL_PASSWORD_MIN_SPECIAL', - field_class=fields.IntegerField, - min_value=0, - default=0, - label=_('Minimum number of special characters in local password'), - help_text=_('Minimum number of special characters required in a local password. 0 means no minimum'), - category=_('Authentication'), - category_slug='authentication', - ) - - def tacacs_validate(serializer, attrs): - if not serializer.instance or not hasattr(serializer.instance, 'TACACSPLUS_HOST') or not hasattr(serializer.instance, 'TACACSPLUS_SECRET'): - return attrs - errors = [] - host = serializer.instance.TACACSPLUS_HOST - if 'TACACSPLUS_HOST' in attrs: - host = attrs['TACACSPLUS_HOST'] - secret = serializer.instance.TACACSPLUS_SECRET - if 'TACACSPLUS_SECRET' in attrs: - secret = attrs['TACACSPLUS_SECRET'] - if host and not secret: - errors.append('TACACSPLUS_SECRET is required when TACACSPLUS_HOST is provided.') - if errors: - raise serializers.ValidationError(_('\n'.join(errors))) - return attrs - - register_validate('tacacsplus', tacacs_validate) diff --git a/awx/sso/fields.py b/awx/sso/fields.py deleted file mode 100644 index a81cb1cf34d4..000000000000 --- a/awx/sso/fields.py +++ /dev/null @@ -1,725 +0,0 @@ -import collections -import copy -import inspect -import json -import re - -import six - -# Python LDAP -import ldap -import awx - -# Django -from django.utils.translation import gettext_lazy as _ - -# Django Auth LDAP -import django_auth_ldap.config -from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion - -from rest_framework.exceptions import ValidationError -from rest_framework.fields import empty, Field, SkipField - -# This must be imported so get_subclasses picks it up -from awx.sso.ldap_group_types import PosixUIDGroupType # noqa - -# AWX -from awx.conf import fields -from awx.main.validators import validate_certificate -from awx.sso.validators import ( # noqa - validate_ldap_dn, - validate_ldap_bind_dn, - validate_ldap_dn_with_user, - validate_ldap_filter, - validate_ldap_filter_with_user, - validate_tacacsplus_disallow_nonascii, -) - - -def get_subclasses(cls): - for subclass in cls.__subclasses__(): - for subsubclass in get_subclasses(subclass): - yield subsubclass - yield subclass - - -def find_class_in_modules(class_name): - """ - Used to find ldap subclasses by string - """ - module_search_space = [django_auth_ldap.config, awx.sso.ldap_group_types] - for m in module_search_space: - cls = getattr(m, class_name, None) - if cls: - return cls - return None - - -class DependsOnMixin: - def get_depends_on(self): - """ - Get the value of the dependent field. - First try to find the value in the request. - Then fall back to the raw value from the setting in the DB. - """ - from django.conf import settings - - dependent_key = next(iter(self.depends_on)) - - if self.context: - request = self.context.get('request', None) - if request and request.data and request.data.get(dependent_key, None): - return request.data.get(dependent_key) - res = settings._get_local(dependent_key, validate=False) - return res - - -class _Forbidden(Field): - default_error_messages = {'invalid': _('Invalid field.')} - - def run_validation(self, value): - self.fail('invalid') - - -class HybridDictField(fields.DictField): - """A DictField, but with defined fixed Fields for certain keys.""" - - def __init__(self, *args, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - - fields = [ - sorted( - ((field_name, obj) for field_name, obj in cls.__dict__.items() if isinstance(obj, Field) and field_name != 'child'), - key=lambda x: x[1]._creation_counter, - ) - for cls in reversed(self.__class__.__mro__) - ] - self._declared_fields = collections.OrderedDict(f for group in fields for f in group) - - super().__init__(*args, **kwargs) - - def to_representation(self, value): - fields = copy.deepcopy(self._declared_fields) - return { - key: field.to_representation(val) if val is not None else None - for key, val, field in ((six.text_type(key), val, fields.get(key, self.child)) for key, val in value.items()) - if not field.write_only - } - - def run_child_validation(self, data): - result = {} - - if not data and self.allow_blank: - return result - - errors = collections.OrderedDict() - fields = copy.deepcopy(self._declared_fields) - keys = set(fields.keys()) | set(data.keys()) - - for key in keys: - value = data.get(key, empty) - key = six.text_type(key) - field = fields.get(key, self.child) - try: - if field.read_only: - continue # Ignore read_only fields, as Serializer seems to do. - result[key] = field.run_validation(value) - except ValidationError as e: - errors[key] = e.detail - except SkipField: - pass - - if not errors: - return result - raise ValidationError(errors) - - -class AuthenticationBackendsField(fields.StringListField): - # Mapping of settings that must be set in order to enable each - # authentication backend. - REQUIRED_BACKEND_SETTINGS = collections.OrderedDict( - [ - ('awx.sso.backends.LDAPBackend', ['AUTH_LDAP_SERVER_URI']), - ('awx.sso.backends.LDAPBackend1', ['AUTH_LDAP_1_SERVER_URI']), - ('awx.sso.backends.LDAPBackend2', ['AUTH_LDAP_2_SERVER_URI']), - ('awx.sso.backends.LDAPBackend3', ['AUTH_LDAP_3_SERVER_URI']), - ('awx.sso.backends.LDAPBackend4', ['AUTH_LDAP_4_SERVER_URI']), - ('awx.sso.backends.LDAPBackend5', ['AUTH_LDAP_5_SERVER_URI']), - ('awx.sso.backends.RADIUSBackend', ['RADIUS_SERVER']), - ('social_core.backends.google.GoogleOAuth2', ['SOCIAL_AUTH_GOOGLE_OAUTH2_KEY', 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET']), - ('social_core.backends.github.GithubOAuth2', ['SOCIAL_AUTH_GITHUB_KEY', 'SOCIAL_AUTH_GITHUB_SECRET']), - ('social_core.backends.open_id_connect.OpenIdConnectAuth', ['SOCIAL_AUTH_OIDC_KEY', 'SOCIAL_AUTH_OIDC_SECRET', 'SOCIAL_AUTH_OIDC_OIDC_ENDPOINT']), - ( - 'social_core.backends.github.GithubOrganizationOAuth2', - ['SOCIAL_AUTH_GITHUB_ORG_KEY', 'SOCIAL_AUTH_GITHUB_ORG_SECRET', 'SOCIAL_AUTH_GITHUB_ORG_NAME'], - ), - ('social_core.backends.github.GithubTeamOAuth2', ['SOCIAL_AUTH_GITHUB_TEAM_KEY', 'SOCIAL_AUTH_GITHUB_TEAM_SECRET', 'SOCIAL_AUTH_GITHUB_TEAM_ID']), - ( - 'social_core.backends.github_enterprise.GithubEnterpriseOAuth2', - [ - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL', - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL', - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY', - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET', - ], - ), - ( - 'social_core.backends.github_enterprise.GithubEnterpriseOrganizationOAuth2', - [ - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL', - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL', - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY', - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET', - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME', - ], - ), - ( - 'social_core.backends.github_enterprise.GithubEnterpriseTeamOAuth2', - [ - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL', - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL', - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY', - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET', - 'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID', - ], - ), - ('social_core.backends.azuread.AzureADOAuth2', ['SOCIAL_AUTH_AZUREAD_OAUTH2_KEY', 'SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET']), - ( - 'awx.sso.backends.SAMLAuth', - [ - 'SOCIAL_AUTH_SAML_SP_ENTITY_ID', - 'SOCIAL_AUTH_SAML_SP_PUBLIC_CERT', - 'SOCIAL_AUTH_SAML_SP_PRIVATE_KEY', - 'SOCIAL_AUTH_SAML_ORG_INFO', - 'SOCIAL_AUTH_SAML_TECHNICAL_CONTACT', - 'SOCIAL_AUTH_SAML_SUPPORT_CONTACT', - 'SOCIAL_AUTH_SAML_ENABLED_IDPS', - ], - ), - ('django.contrib.auth.backends.ModelBackend', []), - ('awx.main.backends.AWXModelBackend', []), - ] - ) - - @classmethod - def get_all_required_settings(cls): - all_required_settings = set(['LICENSE']) - for required_settings in cls.REQUIRED_BACKEND_SETTINGS.values(): - all_required_settings.update(required_settings) - return all_required_settings - - def __init__(self, *args, **kwargs): - kwargs.setdefault('default', self._default_from_required_settings) - super(AuthenticationBackendsField, self).__init__(*args, **kwargs) - - def _default_from_required_settings(self): - from django.conf import settings - - try: - backends = settings._awx_conf_settings._get_default('AUTHENTICATION_BACKENDS') - except AttributeError: - backends = self.REQUIRED_BACKEND_SETTINGS.keys() - # Filter which authentication backends are enabled based on their - # required settings being defined and non-empty. - for backend, required_settings in self.REQUIRED_BACKEND_SETTINGS.items(): - if backend not in backends: - continue - if all([getattr(settings, rs, None) for rs in required_settings]): - continue - backends = [x for x in backends if x != backend] - return backends - - -class LDAPServerURIField(fields.URLField): - def __init__(self, **kwargs): - kwargs.setdefault('schemes', ('ldap', 'ldaps')) - kwargs.setdefault('allow_plain_hostname', True) - super(LDAPServerURIField, self).__init__(**kwargs) - - def run_validators(self, value): - for url in filter(None, re.split(r'[, ]', (value or ''))): - super(LDAPServerURIField, self).run_validators(url) - return value - - -class LDAPConnectionOptionsField(fields.DictField): - default_error_messages = {'invalid_options': _('Invalid connection option(s): {invalid_options}.')} - - def to_representation(self, value): - value = value or {} - opt_names = ldap.OPT_NAMES_DICT - # Convert integer options to their named constants. - repr_value = {} - for opt, opt_value in value.items(): - if opt in opt_names: - repr_value[opt_names[opt]] = opt_value - return repr_value - - def to_internal_value(self, data): - data = super(LDAPConnectionOptionsField, self).to_internal_value(data) - valid_options = dict([(v, k) for k, v in ldap.OPT_NAMES_DICT.items()]) - invalid_options = set(data.keys()) - set(valid_options.keys()) - if invalid_options: - invalid_options = sorted(list(invalid_options)) - options_display = json.dumps(invalid_options).lstrip('[').rstrip(']') - self.fail('invalid_options', invalid_options=options_display) - # Convert named options to their integer constants. - internal_data = {} - for opt_name, opt_value in data.items(): - internal_data[valid_options[opt_name]] = opt_value - return internal_data - - -class LDAPDNField(fields.CharField): - def __init__(self, **kwargs): - super(LDAPDNField, self).__init__(**kwargs) - self.validators.append(validate_ldap_dn) - - def run_validation(self, data=empty): - value = super(LDAPDNField, self).run_validation(data) - # django-auth-ldap expects DN fields (like AUTH_LDAP_REQUIRE_GROUP) - # to be either a valid string or ``None`` (not an empty string) - return None if value == '' else value - - -class LDAPDNListField(fields.StringListField): - def __init__(self, **kwargs): - super(LDAPDNListField, self).__init__(**kwargs) - self.validators.append(lambda dn: list(map(validate_ldap_dn, dn))) - - def run_validation(self, data=empty): - if not isinstance(data, (list, tuple)): - data = [data] - return super(LDAPDNListField, self).run_validation(data) - - -class LDAPDNWithUserField(fields.CharField): - def __init__(self, **kwargs): - super(LDAPDNWithUserField, self).__init__(**kwargs) - self.validators.append(validate_ldap_dn_with_user) - - def run_validation(self, data=empty): - value = super(LDAPDNWithUserField, self).run_validation(data) - # django-auth-ldap expects DN fields (like AUTH_LDAP_USER_DN_TEMPLATE) - # to be either a valid string or ``None`` (not an empty string) - return None if value == '' else value - - -class LDAPFilterField(fields.CharField): - def __init__(self, **kwargs): - super(LDAPFilterField, self).__init__(**kwargs) - self.validators.append(validate_ldap_filter) - - -class LDAPFilterWithUserField(fields.CharField): - def __init__(self, **kwargs): - super(LDAPFilterWithUserField, self).__init__(**kwargs) - self.validators.append(validate_ldap_filter_with_user) - - -class LDAPScopeField(fields.ChoiceField): - def __init__(self, choices=None, **kwargs): - choices = choices or [('SCOPE_BASE', _('Base')), ('SCOPE_ONELEVEL', _('One Level')), ('SCOPE_SUBTREE', _('Subtree'))] - super(LDAPScopeField, self).__init__(choices, **kwargs) - - def to_representation(self, value): - for choice in self.choices.keys(): - if value == getattr(ldap, choice): - return choice - return super(LDAPScopeField, self).to_representation(value) - - def to_internal_value(self, data): - value = super(LDAPScopeField, self).to_internal_value(data) - return getattr(ldap, value) - - -class LDAPSearchField(fields.ListField): - default_error_messages = { - 'invalid_length': _('Expected a list of three items but got {length} instead.'), - 'type_error': _('Expected an instance of LDAPSearch but got {input_type} instead.'), - } - ldap_filter_field_class = LDAPFilterField - - def to_representation(self, value): - if not value: - return [] - if not isinstance(value, LDAPSearch): - self.fail('type_error', input_type=type(value)) - return [ - LDAPDNField().to_representation(value.base_dn), - LDAPScopeField().to_representation(value.scope), - self.ldap_filter_field_class().to_representation(value.filterstr), - ] - - def to_internal_value(self, data): - data = super(LDAPSearchField, self).to_internal_value(data) - if len(data) == 0: - return None - if len(data) != 3: - self.fail('invalid_length', length=len(data)) - return LDAPSearch( - LDAPDNField().run_validation(data[0]), LDAPScopeField().run_validation(data[1]), self.ldap_filter_field_class().run_validation(data[2]) - ) - - -class LDAPSearchWithUserField(LDAPSearchField): - ldap_filter_field_class = LDAPFilterWithUserField - - -class LDAPSearchUnionField(fields.ListField): - default_error_messages = {'type_error': _('Expected an instance of LDAPSearch or LDAPSearchUnion but got {input_type} instead.')} - ldap_search_field_class = LDAPSearchWithUserField - - def to_representation(self, value): - if not value: - return [] - elif isinstance(value, LDAPSearchUnion): - return [self.ldap_search_field_class().to_representation(s) for s in value.searches] - elif isinstance(value, LDAPSearch): - return self.ldap_search_field_class().to_representation(value) - else: - self.fail('type_error', input_type=type(value)) - - def to_internal_value(self, data): - data = super(LDAPSearchUnionField, self).to_internal_value(data) - if len(data) == 0: - return None - if len(data) == 3 and isinstance(data[0], str): - return self.ldap_search_field_class().run_validation(data) - else: - search_args = [] - for i in range(len(data)): - if not isinstance(data[i], list): - raise ValidationError('In order to ultilize LDAP Union, input element No. %d should be a search query array.' % (i + 1)) - try: - search_args.append(self.ldap_search_field_class().run_validation(data[i])) - except Exception as e: - if hasattr(e, 'detail') and isinstance(e.detail, list): - e.detail.insert(0, "Error parsing LDAP Union element No. %d:" % (i + 1)) - raise e - return LDAPSearchUnion(*search_args) - - -class LDAPUserAttrMapField(fields.DictField): - default_error_messages = {'invalid_attrs': _('Invalid user attribute(s): {invalid_attrs}.')} - valid_user_attrs = {'first_name', 'last_name', 'email'} - child = fields.CharField() - - def to_internal_value(self, data): - data = super(LDAPUserAttrMapField, self).to_internal_value(data) - invalid_attrs = set(data.keys()) - self.valid_user_attrs - if invalid_attrs: - invalid_attrs = sorted(list(invalid_attrs)) - attrs_display = json.dumps(invalid_attrs).lstrip('[').rstrip(']') - self.fail('invalid_attrs', invalid_attrs=attrs_display) - return data - - -class LDAPGroupTypeField(fields.ChoiceField, DependsOnMixin): - default_error_messages = { - 'type_error': _('Expected an instance of LDAPGroupType but got {input_type} instead.'), - 'missing_parameters': _('Missing required parameters in {dependency}.'), - 'invalid_parameters': _('Invalid group_type parameters. Expected instance of dict but got {parameters_type} instead.'), - } - - def __init__(self, choices=None, **kwargs): - group_types = get_subclasses(django_auth_ldap.config.LDAPGroupType) - choices = choices or [(x.__name__, x.__name__) for x in group_types] - super(LDAPGroupTypeField, self).__init__(choices, **kwargs) - - def to_representation(self, value): - if not value: - return 'MemberDNGroupType' - if not isinstance(value, django_auth_ldap.config.LDAPGroupType): - self.fail('type_error', input_type=type(value)) - return value.__class__.__name__ - - def to_internal_value(self, data): - data = super(LDAPGroupTypeField, self).to_internal_value(data) - if not data: - return None - - cls = find_class_in_modules(data) - if not cls: - return None - - # Per-group type parameter validation and handling here - - # Backwords compatability. Before AUTH_LDAP_GROUP_TYPE_PARAMS existed - # MemberDNGroupType was the only group type, of the underlying lib, that - # took a parameter. - params = self.get_depends_on() or {} - params_sanitized = dict() - - cls_args = inspect.getfullargspec(cls.__init__).args[1:] - - if cls_args: - if not isinstance(params, dict): - self.fail('invalid_parameters', parameters_type=type(params)) - - for attr in cls_args: - if attr in params: - params_sanitized[attr] = params[attr] - - try: - return cls(**params_sanitized) - except TypeError: - self.fail('missing_parameters', dependency=list(self.depends_on)[0]) - - -class LDAPGroupTypeParamsField(fields.DictField, DependsOnMixin): - default_error_messages = {'invalid_keys': _('Invalid key(s): {invalid_keys}.')} - - def to_internal_value(self, value): - value = super(LDAPGroupTypeParamsField, self).to_internal_value(value) - if not value: - return value - group_type_str = self.get_depends_on() - group_type_str = group_type_str or '' - - group_type_cls = find_class_in_modules(group_type_str) - if not group_type_cls: - # Fail safe - return {} - - invalid_keys = set(value.keys()) - set(inspect.getfullargspec(group_type_cls.__init__).args[1:]) - if invalid_keys: - invalid_keys = sorted(list(invalid_keys)) - keys_display = json.dumps(invalid_keys).lstrip('[').rstrip(']') - self.fail('invalid_keys', invalid_keys=keys_display) - return value - - -class LDAPUserFlagsField(fields.DictField): - default_error_messages = {'invalid_flag': _('Invalid user flag: "{invalid_flag}".')} - valid_user_flags = {'is_superuser', 'is_system_auditor'} - child = LDAPDNListField() - - def to_internal_value(self, data): - data = super(LDAPUserFlagsField, self).to_internal_value(data) - invalid_flags = set(data.keys()) - self.valid_user_flags - if invalid_flags: - self.fail('invalid_flag', invalid_flag=list(invalid_flags)[0]) - return data - - -class LDAPDNMapField(fields.StringListBooleanField): - child = LDAPDNField() - - -class LDAPSingleOrganizationMapField(HybridDictField): - admins = LDAPDNMapField(allow_null=True, required=False) - users = LDAPDNMapField(allow_null=True, required=False) - auditors = LDAPDNMapField(allow_null=True, required=False) - remove_admins = fields.BooleanField(required=False) - remove_users = fields.BooleanField(required=False) - remove_auditors = fields.BooleanField(required=False) - - child = _Forbidden() - - -class LDAPOrganizationMapField(fields.DictField): - child = LDAPSingleOrganizationMapField() - - -class LDAPSingleTeamMapField(HybridDictField): - organization = fields.CharField() - users = LDAPDNMapField(allow_null=True, required=False) - remove = fields.BooleanField(required=False) - - child = _Forbidden() - - -class LDAPTeamMapField(fields.DictField): - child = LDAPSingleTeamMapField() - - -class SocialMapStringRegexField(fields.CharField): - def to_representation(self, value): - if isinstance(value, type(re.compile(''))): - flags = [] - if value.flags & re.I: - flags.append('i') - if value.flags & re.M: - flags.append('m') - return '/{}/{}'.format(value.pattern, ''.join(flags)) - else: - return super(SocialMapStringRegexField, self).to_representation(value) - - def to_internal_value(self, data): - data = super(SocialMapStringRegexField, self).to_internal_value(data) - match = re.match(r'^/(?P.*)/(?P[im]+)?$', data) - if match: - flags = 0 - if match.group('flags'): - if 'i' in match.group('flags'): - flags |= re.I - if 'm' in match.group('flags'): - flags |= re.M - try: - return re.compile(match.group('pattern'), flags) - except re.error as e: - raise ValidationError('{}: {}'.format(e, data)) - return data - - -class SocialMapField(fields.ListField): - default_error_messages = {'type_error': _('Expected None, True, False, a string or list of strings but got {input_type} instead.')} - child = SocialMapStringRegexField() - - def to_representation(self, value): - if isinstance(value, (list, tuple)): - return super(SocialMapField, self).to_representation(value) - elif value in fields.BooleanField.TRUE_VALUES: - return True - elif value in fields.BooleanField.FALSE_VALUES: - return False - elif value in fields.BooleanField.NULL_VALUES: - return None - elif isinstance(value, (str, type(re.compile('')))): - return self.child.to_representation(value) - else: - self.fail('type_error', input_type=type(value)) - - def to_internal_value(self, data): - if isinstance(data, (list, tuple)): - return super(SocialMapField, self).to_internal_value(data) - elif data in fields.BooleanField.TRUE_VALUES: - return True - elif data in fields.BooleanField.FALSE_VALUES: - return False - elif data in fields.BooleanField.NULL_VALUES: - return None - elif isinstance(data, str): - return self.child.run_validation(data) - else: - self.fail('type_error', input_type=type(data)) - - -class SocialSingleOrganizationMapField(HybridDictField): - admins = SocialMapField(allow_null=True, required=False) - users = SocialMapField(allow_null=True, required=False) - remove_admins = fields.BooleanField(required=False) - remove_users = fields.BooleanField(required=False) - organization_alias = SocialMapField(allow_null=True, required=False) - - child = _Forbidden() - - -class SocialOrganizationMapField(fields.DictField): - child = SocialSingleOrganizationMapField() - - -class SocialSingleTeamMapField(HybridDictField): - organization = fields.CharField() - users = SocialMapField(allow_null=True, required=False) - remove = fields.BooleanField(required=False) - - child = _Forbidden() - - -class SocialTeamMapField(fields.DictField): - child = SocialSingleTeamMapField() - - -class SAMLOrgInfoValueField(HybridDictField): - name = fields.CharField() - displayname = fields.CharField() - url = fields.URLField() - - -class SAMLOrgInfoField(fields.DictField): - default_error_messages = {'invalid_lang_code': _('Invalid language code(s) for org info: {invalid_lang_codes}.')} - child = SAMLOrgInfoValueField() - - def to_internal_value(self, data): - data = super(SAMLOrgInfoField, self).to_internal_value(data) - invalid_keys = set() - for key in data.keys(): - if not re.match(r'^[a-z]{2}(?:-[a-z]{2})??$', key, re.I): - invalid_keys.add(key) - if invalid_keys: - invalid_keys = sorted(list(invalid_keys)) - keys_display = json.dumps(invalid_keys).lstrip('[').rstrip(']') - self.fail('invalid_lang_code', invalid_lang_codes=keys_display) - return data - - -class SAMLContactField(HybridDictField): - givenName = fields.CharField() - emailAddress = fields.EmailField() - - -class SAMLIdPField(HybridDictField): - entity_id = fields.CharField() - url = fields.URLField() - x509cert = fields.CharField(validators=[validate_certificate]) - attr_user_permanent_id = fields.CharField(required=False) - attr_first_name = fields.CharField(required=False) - attr_last_name = fields.CharField(required=False) - attr_username = fields.CharField(required=False) - attr_email = fields.CharField(required=False) - - -class SAMLEnabledIdPsField(fields.DictField): - child = SAMLIdPField() - - -class SAMLSecurityField(HybridDictField): - nameIdEncrypted = fields.BooleanField(required=False) - authnRequestsSigned = fields.BooleanField(required=False) - logoutRequestSigned = fields.BooleanField(required=False) - logoutResponseSigned = fields.BooleanField(required=False) - signMetadata = fields.BooleanField(required=False) - wantMessagesSigned = fields.BooleanField(required=False) - wantAssertionsSigned = fields.BooleanField(required=False) - wantAssertionsEncrypted = fields.BooleanField(required=False) - wantNameId = fields.BooleanField(required=False) - wantNameIdEncrypted = fields.BooleanField(required=False) - wantAttributeStatement = fields.BooleanField(required=False) - requestedAuthnContext = fields.StringListBooleanField(required=False) - requestedAuthnContextComparison = fields.CharField(required=False) - metadataValidUntil = fields.CharField(allow_null=True, required=False) - metadataCacheDuration = fields.CharField(allow_null=True, required=False) - signatureAlgorithm = fields.CharField(allow_null=True, required=False) - digestAlgorithm = fields.CharField(allow_null=True, required=False) - - -class SAMLOrgAttrField(HybridDictField): - remove = fields.BooleanField(required=False) - saml_attr = fields.CharField(required=False, allow_null=True) - remove_admins = fields.BooleanField(required=False) - saml_admin_attr = fields.CharField(required=False, allow_null=True) - remove_auditors = fields.BooleanField(required=False) - saml_auditor_attr = fields.CharField(required=False, allow_null=True) - - child = _Forbidden() - - -class SAMLTeamAttrTeamOrgMapField(HybridDictField): - team = fields.CharField(required=True, allow_null=False) - team_alias = fields.CharField(required=False, allow_null=True) - organization = fields.CharField(required=True, allow_null=False) - - child = _Forbidden() - - -class SAMLTeamAttrField(HybridDictField): - 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) - - child = _Forbidden() - - -class SAMLUserFlagsAttrField(HybridDictField): - is_superuser_attr = fields.CharField(required=False, allow_null=True) - is_superuser_value = fields.StringListField(required=False, allow_null=True) - is_superuser_role = fields.StringListField(required=False, allow_null=True) - remove_superusers = fields.BooleanField(required=False, allow_null=True) - is_system_auditor_attr = fields.CharField(required=False, allow_null=True) - is_system_auditor_value = fields.StringListField(required=False, allow_null=True) - is_system_auditor_role = fields.StringListField(required=False, allow_null=True) - remove_system_auditors = fields.BooleanField(required=False, allow_null=True) - - child = _Forbidden() diff --git a/awx/sso/ldap_group_types.py b/awx/sso/ldap_group_types.py deleted file mode 100644 index 2a5434c15440..000000000000 --- a/awx/sso/ldap_group_types.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright (c) 2018 Ansible by Red Hat -# All Rights Reserved. - -# Python -import ldap - -# Django -from django.utils.encoding import force_str - -# 3rd party -from django_auth_ldap.config import LDAPGroupType - - -class PosixUIDGroupType(LDAPGroupType): - def __init__(self, name_attr='cn', ldap_group_user_attr='uid'): - self.ldap_group_user_attr = ldap_group_user_attr - super(PosixUIDGroupType, self).__init__(name_attr) - - """ - An LDAPGroupType subclass that handles non-standard DS. - """ - - def user_groups(self, ldap_user, group_search): - """ - Searches for any group that is either the user's primary or contains the - user as a member. - """ - groups = [] - - try: - user_uid = ldap_user.attrs[self.ldap_group_user_attr][0] - - if 'gidNumber' in ldap_user.attrs: - user_gid = ldap_user.attrs['gidNumber'][0] - filterstr = u'(|(gidNumber=%s)(memberUid=%s))' % ( - self.ldap.filter.escape_filter_chars(user_gid), - self.ldap.filter.escape_filter_chars(user_uid), - ) - else: - filterstr = u'(memberUid=%s)' % (self.ldap.filter.escape_filter_chars(user_uid),) - - search = group_search.search_with_additional_term_string(filterstr) - search.attrlist = [str(self.name_attr)] - groups = search.execute(ldap_user.connection) - except (KeyError, IndexError): - pass - - return groups - - def is_member(self, ldap_user, group_dn): - """ - Returns True if the group is the user's primary group or if the user is - listed in the group's memberUid attribute. - """ - is_member = False - try: - user_uid = ldap_user.attrs[self.ldap_group_user_attr][0] - - try: - is_member = ldap_user.connection.compare_s(force_str(group_dn), 'memberUid', force_str(user_uid)) - except (ldap.UNDEFINED_TYPE, ldap.NO_SUCH_ATTRIBUTE): - is_member = False - - if not is_member: - try: - user_gid = ldap_user.attrs['gidNumber'][0] - is_member = ldap_user.connection.compare_s(force_str(group_dn), 'gidNumber', force_str(user_gid)) - except (ldap.UNDEFINED_TYPE, ldap.NO_SUCH_ATTRIBUTE): - is_member = False - except (KeyError, IndexError): - is_member = False - - return is_member diff --git a/awx/sso/middleware.py b/awx/sso/middleware.py deleted file mode 100644 index f8b2b7974167..000000000000 --- a/awx/sso/middleware.py +++ /dev/null @@ -1,80 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -# Python -import urllib.parse - -# Django -from django.conf import settings -from django.utils.functional import LazyObject -from django.shortcuts import redirect - -# Python Social Auth -from social_core.exceptions import SocialAuthBaseException -from social_core.utils import social_logger -from social_django import utils -from social_django.middleware import SocialAuthExceptionMiddleware - - -class SocialAuthMiddleware(SocialAuthExceptionMiddleware): - def process_request(self, request): - if request.path.startswith('/sso'): - # See upgrade blocker note in requirements/README.md - utils.BACKENDS = settings.AUTHENTICATION_BACKENDS - token_key = request.COOKIES.get('token', '') - token_key = urllib.parse.quote(urllib.parse.unquote(token_key).strip('"')) - - if not hasattr(request, 'successful_authenticator'): - request.successful_authenticator = None - - if not request.path.startswith('/sso/') and 'migrations_notran' not in request.path: - if request.user and request.user.is_authenticated: - # The rest of the code base rely hevily on type/inheritance checks, - # LazyObject sent from Django auth middleware can be buggy if not - # converted back to its original object. - if isinstance(request.user, LazyObject) and request.user._wrapped: - request.user = request.user._wrapped - request.session.pop('social_auth_error', None) - request.session.pop('social_auth_last_backend', None) - return self.get_response(request) - - def process_view(self, request, callback, callback_args, callback_kwargs): - if request.path.startswith('/sso/login/'): - request.session['social_auth_last_backend'] = callback_kwargs['backend'] - - def process_exception(self, request, exception): - strategy = getattr(request, 'social_strategy', None) - if strategy is None or self.raise_exception(request, exception): - return - - if isinstance(exception, SocialAuthBaseException) or request.path.startswith('/sso/'): - backend = getattr(request, 'backend', None) - backend_name = getattr(backend, 'name', 'unknown-backend') - - message = self.get_message(request, exception) - if request.session.get('social_auth_last_backend') != backend_name: - backend_name = request.session.get('social_auth_last_backend') - message = request.GET.get('error_description', message) - - full_backend_name = backend_name - try: - idp_name = strategy.request_data()['RelayState'] - full_backend_name = '%s:%s' % (backend_name, idp_name) - except KeyError: - pass - - social_logger.error(message) - - url = self.get_redirect_uri(request, exception) - request.session['social_auth_error'] = (full_backend_name, message) - return redirect(url) - - def get_message(self, request, exception): - msg = str(exception) - if msg and msg[-1] not in '.?!': - msg = msg + '.' - return msg - - def get_redirect_uri(self, request, exception): - strategy = getattr(request, 'social_strategy', None) - return strategy.session_get('next', '') or strategy.setting('LOGIN_ERROR_URL') diff --git a/awx/sso/migrations/0001_initial.py b/awx/sso/migrations/0001_initial.py deleted file mode 100644 index d759e22437b5..000000000000 --- a/awx/sso/migrations/0001_initial.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- 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', on_delete=models.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.AlterUniqueTogether(name='userenterpriseauth', unique_together=set([('user', 'provider')])), - ] diff --git a/awx/sso/migrations/0002_expand_provider_options.py b/awx/sso/migrations/0002_expand_provider_options.py deleted file mode 100644 index 68f877717f2f..000000000000 --- a/awx/sso/migrations/0002_expand_provider_options.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [('sso', '0001_initial')] - - operations = [ - migrations.AlterField( - model_name='userenterpriseauth', - name='provider', - field=models.CharField(max_length=32, choices=[('radius', 'RADIUS'), ('tacacs+', 'TACACS+'), ('saml', 'SAML')]), - ) - ] diff --git a/awx/sso/migrations/0003_convert_saml_string_to_list.py b/awx/sso/migrations/0003_convert_saml_string_to_list.py deleted file mode 100644 index bacc25e3c00f..000000000000 --- a/awx/sso/migrations/0003_convert_saml_string_to_list.py +++ /dev/null @@ -1,58 +0,0 @@ -from django.db import migrations, connection -import json - -_values_to_change = ['is_superuser_value', 'is_superuser_role', 'is_system_auditor_value', 'is_system_auditor_role'] - - -def _get_setting(): - with connection.cursor() as cursor: - cursor.execute('SELECT value FROM conf_setting WHERE key= %s', ['SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR']) - row = cursor.fetchone() - if row == None: - return {} - existing_setting = row[0] - - try: - existing_json = json.loads(existing_setting) - except json.decoder.JSONDecodeError as e: - print("Failed to decode existing json setting:") - print(existing_setting) - raise e - - return existing_json - - -def _set_setting(value): - with connection.cursor() as cursor: - cursor.execute('UPDATE conf_setting SET value = %s WHERE key = %s', [json.dumps(value), 'SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR']) - - -def forwards(app, schema_editor): - # The Operation should use schema_editor to apply any changes it - # wants to make to the database. - existing_json = _get_setting() - for key in _values_to_change: - if existing_json.get(key, None) and isinstance(existing_json.get(key), str): - existing_json[key] = [existing_json.get(key)] - _set_setting(existing_json) - - -def backwards(app, schema_editor): - existing_json = _get_setting() - for key in _values_to_change: - if existing_json.get(key, None) and not isinstance(existing_json.get(key), str): - try: - existing_json[key] = existing_json.get(key).pop() - except IndexError: - existing_json[key] = "" - _set_setting(existing_json) - - -class Migration(migrations.Migration): - dependencies = [ - ('sso', '0002_expand_provider_options'), - ] - - operations = [ - migrations.RunPython(forwards, backwards), - ] diff --git a/awx/sso/migrations/__init__.py b/awx/sso/migrations/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/awx/sso/models.py b/awx/sso/models.py deleted file mode 100644 index 28eb23857f4b..000000000000 --- a/awx/sso/models.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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 gettext_lazy as _ - - -class UserEnterpriseAuth(models.Model): - """Enterprise Auth association model""" - - PROVIDER_CHOICES = (('radius', _('RADIUS')), ('tacacs+', _('TACACS+')), ('saml', _('SAML'))) - - 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/saml_pipeline.py b/awx/sso/saml_pipeline.py deleted file mode 100644 index e0244a9ce344..000000000000 --- a/awx/sso/saml_pipeline.py +++ /dev/null @@ -1,291 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -# Python -import re -import logging - -# Django -from django.conf import settings - -from awx.main.models import Team -from awx.sso.common import create_org_and_teams, reconcile_users_org_team_mappings, get_orgs_by_ids - -logger = logging.getLogger('awx.sso.saml_pipeline') - - -def populate_user(backend, details, user=None, *args, **kwargs): - if not user: - return - - # Build the in-memory settings for how this user should be modeled - desired_org_state = {} - desired_team_state = {} - orgs_to_create = [] - teams_to_create = {} - _update_user_orgs_by_saml_attr(backend, desired_org_state, orgs_to_create, **kwargs) - _update_user_teams_by_saml_attr(desired_team_state, teams_to_create, **kwargs) - _update_user_orgs(backend, desired_org_state, orgs_to_create, user) - _update_user_teams(backend, desired_team_state, teams_to_create, user) - - # If the SAML adapter is allowed to create objects, lets do that first - create_org_and_teams(orgs_to_create, teams_to_create, 'SAML', settings.SAML_AUTO_CREATE_OBJECTS) - - # Finally reconcile the user - reconcile_users_org_team_mappings(user, desired_org_state, desired_team_state, 'SAML') - - -def _update_m2m_from_expression(user, expr, remove=True): - """ - Helper function to update m2m relationship based on user matching one or - more expressions. - """ - should_add = False - if expr is None or not expr: - pass - elif expr is True: - should_add = True - else: - if isinstance(expr, (str, type(re.compile('')))): - expr = [expr] - for ex in expr: - if isinstance(ex, str): - if user.username == ex or user.email == ex: - should_add = True - elif isinstance(ex, type(re.compile(''))): - if ex.match(user.username) or ex.match(user.email): - should_add = True - if should_add: - return True - elif remove: - return False - else: - return None - - -def _update_user_orgs(backend, desired_org_state, orgs_to_create, user=None): - """ - Update organization memberships for the given user based on mapping rules - defined in settings. - """ - org_map = backend.setting('ORGANIZATION_MAP') or {} - for org_name, org_opts in org_map.items(): - organization_alias = org_opts.get('organization_alias') - if organization_alias: - organization_name = organization_alias - else: - organization_name = org_name - if organization_name not in orgs_to_create: - orgs_to_create.append(organization_name) - - remove = bool(org_opts.get('remove', True)) - - if organization_name not in desired_org_state: - desired_org_state[organization_name] = {} - - for role_name, user_type in (('admin_role', 'admins'), ('member_role', 'users'), ('auditor_role', 'auditors')): - is_member_expression = org_opts.get(user_type, None) - remove_members = bool(org_opts.get('remove_{}'.format(user_type), remove)) - has_role = _update_m2m_from_expression(user, is_member_expression, remove_members) - desired_org_state[organization_name][role_name] = desired_org_state[organization_name].get(role_name, False) or has_role - - -def _update_user_teams(backend, desired_team_state, teams_to_create, user=None): - """ - Update team memberships for the given user based on mapping rules defined - in settings. - """ - - team_map = backend.setting('TEAM_MAP') or {} - for team_name, team_opts in team_map.items(): - # Get or create the org to update. - if 'organization' not in team_opts: - continue - teams_to_create[team_name] = team_opts['organization'] - users_expr = team_opts.get('users', None) - remove = bool(team_opts.get('remove', True)) - add_or_remove = _update_m2m_from_expression(user, users_expr, remove) - if add_or_remove is not None: - org_name = team_opts['organization'] - if org_name not in desired_team_state: - desired_team_state[org_name] = {} - desired_team_state[org_name][team_name] = {'member_role': add_or_remove} - - -def _update_user_orgs_by_saml_attr(backend, desired_org_state, orgs_to_create, **kwargs): - org_map = settings.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR - roles_and_flags = ( - ('member_role', 'remove', 'saml_attr'), - ('admin_role', 'remove_admins', 'saml_admin_attr'), - ('auditor_role', 'remove_auditors', 'saml_auditor_attr'), - ) - - # If the remove_flag was present we need to load all of the orgs and remove the user from the role - all_orgs = None - for role, remove_flag, _ in roles_and_flags: - remove = bool(org_map.get(remove_flag, True)) - if remove: - # Only get the all orgs once, and only if needed - if all_orgs is None: - all_orgs = get_orgs_by_ids() - for org_name in all_orgs.keys(): - if org_name not in desired_org_state: - desired_org_state[org_name] = {} - desired_org_state[org_name][role] = False - - # Now we can add the user as a member/admin/auditor for any orgs they have specified - for role, _, attr_flag in roles_and_flags: - if org_map.get(attr_flag) is None: - continue - saml_attr_values = kwargs.get('response', {}).get('attributes', {}).get(org_map.get(attr_flag), []) - for org_name in saml_attr_values: - try: - organization_alias = backend.setting('ORGANIZATION_MAP').get(org_name).get('organization_alias') - if organization_alias is not None: - organization_name = organization_alias - else: - organization_name = org_name - except Exception: - organization_name = org_name - if organization_name not in orgs_to_create: - orgs_to_create.append(organization_name) - if organization_name not in desired_org_state: - desired_org_state[organization_name] = {} - desired_org_state[organization_name][role] = True - - -def _update_user_teams_by_saml_attr(desired_team_state, teams_to_create, **kwargs): - # - # Map users into organizations based on SOCIAL_AUTH_SAML_TEAM_ATTR setting - # - team_map = settings.SOCIAL_AUTH_SAML_TEAM_ATTR - if team_map.get('saml_attr') is None: - return - - all_teams = None - # The role and flag is hard coded here but intended to be flexible in case we ever wanted to add another team type - for role, remove_flag in [('member_role', 'remove')]: - remove = bool(team_map.get(remove_flag, True)) - if remove: - # Only get the all orgs once, and only if needed - if all_teams is None: - all_teams = Team.objects.all().values_list('name', 'organization__name') - for team_name, organization_name in all_teams: - if organization_name not in desired_team_state: - desired_team_state[organization_name] = {} - desired_team_state[organization_name][team_name] = {role: False} - - saml_team_names = set(kwargs.get('response', {}).get('attributes', {}).get(team_map['saml_attr'], [])) - - for team_name_map in team_map.get('team_org_map', []): - team_name = team_name_map.get('team', None) - team_alias = team_name_map.get('team_alias', None) - organization_name = team_name_map.get('organization', None) - if team_name in saml_team_names: - if not organization_name: - # Settings field validation should prevent this. - logger.error("organization name invalid for team {}".format(team_name)) - continue - - if team_alias: - team_name = team_alias - - teams_to_create[team_name] = organization_name - user_is_member_of_team = True - else: - user_is_member_of_team = False - - if organization_name not in desired_team_state: - desired_team_state[organization_name] = {} - desired_team_state[organization_name][team_name] = {'member_role': user_is_member_of_team} - - -def _get_matches(list1, list2): - # Because we are just doing an intersection here we don't really care which list is in which parameter - - # A SAML provider could return either a string or a list of items so we need to coerce the SAML value into a list (if needed) - if not isinstance(list1, (list, tuple)): - list1 = [list1] - - # In addition, we used to allow strings in the SAML config instead of Lists. The migration should take case of that but just in case, we will convert our list too - if not isinstance(list2, (list, tuple)): - list2 = [list2] - - return set(list1).intersection(set(list2)) - - -def _check_flag(user, flag, attributes, user_flags_settings): - ''' - Helper function to set the is_superuser is_system_auditor flags for the SAML adapter - Returns the new flag and whether or not it changed the flag - ''' - new_flag = False - is_role_key = "is_%s_role" % (flag) - is_attr_key = "is_%s_attr" % (flag) - is_value_key = "is_%s_value" % (flag) - remove_setting = "remove_%ss" % (flag) - - # Check to see if we are respecting a role and, if so, does our user have that role? - required_roles = user_flags_settings.get(is_role_key, None) - if required_roles: - matching_roles = _get_matches(required_roles, attributes.get('Role', [])) - - # We do a 2 layer check here so that we don't spit out the else message if there is no role defined - if matching_roles: - logger.debug("User %s has %s role(s) %s" % (user.username, flag, ', '.join(matching_roles))) - new_flag = True - else: - logger.debug("User %s is missing the %s role(s) %s" % (user.username, flag, ', '.join(required_roles))) - - # Next, check to see if we are respecting an attribute; this will take priority over the role if its defined - attr_setting = user_flags_settings.get(is_attr_key, None) - if attr_setting and attributes.get(attr_setting, None): - # Do we have a required value for the attribute - required_value = user_flags_settings.get(is_value_key, None) - if required_value: - # If so, check and see if the value of the attr matches the required value - saml_user_attribute_value = attributes.get(attr_setting, None) - matching_values = _get_matches(required_value, saml_user_attribute_value) - - if matching_values: - logger.debug("Giving %s %s from attribute %s with matching values %s" % (user.username, flag, attr_setting, ', '.join(matching_values))) - new_flag = True - # if they don't match make sure that new_flag is false - else: - logger.debug( - "Refusing %s for %s because attr %s (%s) did not match value(s) %s" - % (flag, user.username, attr_setting, ", ".join(saml_user_attribute_value), ', '.join(required_value)) - ) - new_flag = False - # If there was no required value then we can just allow them in because of the attribute - else: - logger.debug("Giving %s %s from attribute %s" % (user.username, flag, attr_setting)) - new_flag = True - - # Get the users old flag - old_value = getattr(user, "is_%s" % (flag)) - - # If we are not removing the flag and they were a system admin and now we don't want them to be just return - remove_flag = user_flags_settings.get(remove_setting, True) - if not remove_flag and (old_value and not new_flag): - logger.debug("Remove flag %s preventing removal of %s for %s" % (remove_flag, flag, user.username)) - return old_value, False - - # If the user was flagged and we are going to make them not flagged make sure there is a message - if old_value and not new_flag: - logger.debug("Revoking %s from %s" % (flag, user.username)) - - return new_flag, old_value != new_flag - - -def update_user_flags(backend, details, user=None, *args, **kwargs): - user_flags_settings = settings.SOCIAL_AUTH_SAML_USER_FLAGS_BY_ATTR - - attributes = kwargs.get('response', {}).get('attributes', {}) - logger.debug("User attributes for %s: %s" % (user.username, attributes)) - - user.is_superuser, superuser_changed = _check_flag(user, 'superuser', attributes, user_flags_settings) - user.is_system_auditor, auditor_changed = _check_flag(user, 'system_auditor', attributes, user_flags_settings) - - if superuser_changed or auditor_changed: - user.save() diff --git a/awx/sso/social_base_pipeline.py b/awx/sso/social_base_pipeline.py deleted file mode 100644 index ccdaf1d20079..000000000000 --- a/awx/sso/social_base_pipeline.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -# Python Social Auth -from social_core.exceptions import AuthException - -# Django -from django.utils.translation import gettext_lazy as _ - - -class AuthNotFound(AuthException): - def __init__(self, backend, email_or_uid, *args, **kwargs): - self.email_or_uid = email_or_uid - super(AuthNotFound, self).__init__(backend, *args, **kwargs) - - def __str__(self): - return _('An account cannot be found for {0}').format(self.email_or_uid) - - -class AuthInactive(AuthException): - def __str__(self): - return _('Your account is inactive') - - -def check_user_found_or_created(backend, details, user=None, *args, **kwargs): - if not user: - email_or_uid = details.get('email') or kwargs.get('email') or kwargs.get('uid') or '???' - raise AuthNotFound(backend, email_or_uid) - - -def set_is_active_for_new_user(strategy, details, user=None, *args, **kwargs): - if kwargs.get('is_new', False): - details['is_active'] = True - return {'details': details} - - -def prevent_inactive_login(backend, details, user=None, *args, **kwargs): - if user and not user.is_active: - raise AuthInactive(backend) diff --git a/awx/sso/social_pipeline.py b/awx/sso/social_pipeline.py deleted file mode 100644 index b4fb4c1fe323..000000000000 --- a/awx/sso/social_pipeline.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -# Python -import re -import logging - -from awx.sso.common import get_or_create_org_with_default_galaxy_cred - -logger = logging.getLogger('awx.sso.social_pipeline') - - -def _update_m2m_from_expression(user, related, expr, remove=True): - """ - Helper function to update m2m relationship based on user matching one or - more expressions. - """ - should_add = False - if expr is None: - return - elif not expr: - pass - elif expr is True: - should_add = True - else: - if isinstance(expr, (str, type(re.compile('')))): - expr = [expr] - for ex in expr: - if isinstance(ex, str): - if user.username == ex or user.email == ex: - should_add = True - elif isinstance(ex, type(re.compile(''))): - if ex.match(user.username) or ex.match(user.email): - should_add = True - if should_add: - related.add(user) - elif remove: - related.remove(user) - - -def update_user_orgs(backend, details, user=None, *args, **kwargs): - """ - Update organization memberships for the given user based on mapping rules - defined in settings. - """ - if not user: - return - - org_map = backend.setting('ORGANIZATION_MAP') or {} - for org_name, org_opts in org_map.items(): - organization_alias = org_opts.get('organization_alias') - if organization_alias: - organization_name = organization_alias - else: - organization_name = org_name - org = get_or_create_org_with_default_galaxy_cred(name=organization_name) - - # Update org admins from expression(s). - remove = bool(org_opts.get('remove', True)) - admins_expr = org_opts.get('admins', None) - remove_admins = bool(org_opts.get('remove_admins', remove)) - _update_m2m_from_expression(user, org.admin_role.members, admins_expr, remove_admins) - - # Update org users from expression(s). - users_expr = org_opts.get('users', None) - remove_users = bool(org_opts.get('remove_users', remove)) - _update_m2m_from_expression(user, org.member_role.members, users_expr, remove_users) - - -def update_user_teams(backend, details, user=None, *args, **kwargs): - """ - Update team memberships for the given user based on mapping rules defined - in settings. - """ - if not user: - return - from awx.main.models import Team - - team_map = backend.setting('TEAM_MAP') or {} - for team_name, team_opts in team_map.items(): - # Get or create the org to update. - if 'organization' not in team_opts: - continue - org = get_or_create_org_with_default_galaxy_cred(name=team_opts['organization']) - - # Update team members from expression(s). - team = Team.objects.get_or_create(name=team_name, organization=org)[0] - 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) diff --git a/awx/sso/tests/__init__.py b/awx/sso/tests/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/awx/sso/tests/conftest.py b/awx/sso/tests/conftest.py deleted file mode 100644 index f94b1c528f6d..000000000000 --- a/awx/sso/tests/conftest.py +++ /dev/null @@ -1,34 +0,0 @@ -import pytest - -from django.contrib.auth.models import User - -from awx.sso.backends import TACACSPlusBackend -from awx.sso.models import UserEnterpriseAuth - - -@pytest.fixture -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: - user = User.objects.get(username="foo") - except User.DoesNotExist: - user = User(username="foo") - user.set_unusable_password() - user.save() - enterprise_auth = UserEnterpriseAuth(user=user, provider='tacacs+') - enterprise_auth.save() - return user diff --git a/awx/sso/tests/functional/__init__.py b/awx/sso/tests/functional/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/awx/sso/tests/functional/test_backends.py b/awx/sso/tests/functional/test_backends.py deleted file mode 100644 index a0d2c31da3e2..000000000000 --- a/awx/sso/tests/functional/test_backends.py +++ /dev/null @@ -1,115 +0,0 @@ -import pytest -from awx.sso.backends import _update_m2m_from_groups - - -class MockLDAPGroups(object): - def is_member_of(self, group_dn): - return bool(group_dn) - - -class MockLDAPUser(object): - def _get_groups(self): - return MockLDAPGroups() - - -@pytest.mark.parametrize( - "setting, expected_result", - [ - (True, True), - ('something', True), - (False, False), - ('', False), - ], -) -def test_mock_objects(setting, expected_result): - ldap_user = MockLDAPUser() - assert ldap_user._get_groups().is_member_of(setting) == expected_result - - -@pytest.mark.parametrize( - "opts, remove, expected_result", - [ - # In these case we will pass no opts so we should get None as a return in all cases - ( - None, - False, - None, - ), - ( - None, - True, - None, - ), - # Next lets test with empty opts ([]) This should return False if remove is True and None otherwise - ( - [], - True, - False, - ), - ( - [], - False, - None, - ), - # Next opts is True, this will always return True - ( - True, - True, - True, - ), - ( - True, - False, - True, - ), - # If we get only a non-string as an option we hit a continue and will either return None or False depending on the remove flag - ( - [32], - False, - None, - ), - ( - [32], - True, - False, - ), - # Finally we need to test whether or not a user should be allowed in or not. - # We use a mock class for ldap_user that simply returns true/false based on the otps - ( - ['true'], - False, - True, - ), - # In this test we are going to pass a string to test the part of the code that coverts strings into array, this should give us True - ( - 'something', - True, - True, - ), - ( - [''], - False, - None, - ), - ( - False, - True, - False, - ), - # Empty strings are considered opts == None and will result in None or False based on the remove flag - ( - '', - True, - False, - ), - ( - '', - False, - None, - ), - ], -) -@pytest.mark.django_db -def test__update_m2m_from_groups(opts, remove, expected_result): - ldap_user = MockLDAPUser() - assert expected_result == _update_m2m_from_groups(ldap_user, opts, remove) diff --git a/awx/sso/tests/functional/test_common.py b/awx/sso/tests/functional/test_common.py deleted file mode 100644 index f2b3e5781d90..000000000000 --- a/awx/sso/tests/functional/test_common.py +++ /dev/null @@ -1,377 +0,0 @@ -import pytest -from collections import Counter -from django.core.exceptions import FieldError -from django.utils.timezone import now -from django.test.utils import override_settings - -from awx.main.models import Credential, CredentialType, Organization, Team, User -from awx.sso.common import ( - get_orgs_by_ids, - reconcile_users_org_team_mappings, - create_org_and_teams, - get_or_create_org_with_default_galaxy_cred, - is_remote_auth_enabled, - get_external_account, -) - - -class MicroMockObject(object): - def all(self): - return True - - -@pytest.mark.django_db -class TestCommonFunctions: - @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 galaxy_credential(self): - galaxy_type = CredentialType.objects.create(kind='galaxy') - cred = Credential( - created=now(), modified=now(), name='Ansible Galaxy', managed=True, credential_type=galaxy_type, inputs={'url': 'https://galaxy.ansible.com/'} - ) - cred.save() - - def test_get_orgs_by_ids(self, orgs): - orgs_and_ids = get_orgs_by_ids() - o1, o2, o3 = orgs - assert Counter(orgs_and_ids.keys()) == Counter([o1.name, o2.name, o3.name]) - assert Counter(orgs_and_ids.values()) == Counter([o1.id, o2.id, o3.id]) - - def test_reconcile_users_org_team_mappings(self): - # Create objects for us to play with - user = User.objects.create(username='user1@foo.com', last_name='foo', first_name='bar', email='user1@foo.com', is_active=True) - org1 = Organization.objects.create(name='Default1') - org2 = Organization.objects.create(name='Default2') - team1 = Team.objects.create(name='Team1', organization=org1) - team2 = Team.objects.create(name='Team1', organization=org2) - - # Try adding nothing - reconcile_users_org_team_mappings(user, {}, {}, 'Nada') - assert list(user.roles.all()) == [] - - # Add a user to an org that does not exist (should have no affect) - reconcile_users_org_team_mappings( - user, - { - 'junk': {'member_role': True}, - }, - {}, - 'Nada', - ) - assert list(user.roles.all()) == [] - - # Remove a user to an org that does not exist (should have no affect) - reconcile_users_org_team_mappings( - user, - { - 'junk': {'member_role': False}, - }, - {}, - 'Nada', - ) - assert list(user.roles.all()) == [] - - # Add the user to the orgs - reconcile_users_org_team_mappings(user, {org1.name: {'member_role': True}, org2.name: {'member_role': True}}, {}, 'Nada') - assert len(user.roles.all()) == 2 - assert user in org1.member_role - assert user in org2.member_role - - # Remove the user from the orgs - reconcile_users_org_team_mappings(user, {org1.name: {'member_role': False}, org2.name: {'member_role': False}}, {}, 'Nada') - assert list(user.roles.all()) == [] - assert user not in org1.member_role - assert user not in org2.member_role - - # Remove the user from the orgs (again, should have no affect) - reconcile_users_org_team_mappings(user, {org1.name: {'member_role': False}, org2.name: {'member_role': False}}, {}, 'Nada') - assert list(user.roles.all()) == [] - assert user not in org1.member_role - assert user not in org2.member_role - - # Add a user back to the member role - reconcile_users_org_team_mappings( - user, - { - org1.name: { - 'member_role': True, - }, - }, - {}, - 'Nada', - ) - users_roles = set(user.roles.values_list('pk', flat=True)) - assert len(users_roles) == 1 - assert user in org1.member_role - - # Add the user to additional roles - reconcile_users_org_team_mappings( - user, - { - org1.name: {'admin_role': True, 'auditor_role': True}, - }, - {}, - 'Nada', - ) - assert len(user.roles.all()) == 3 - assert user in org1.member_role - assert user in org1.admin_role - assert user in org1.auditor_role - - # Add a user to a non-existent role (results in FieldError exception) - with pytest.raises(FieldError): - reconcile_users_org_team_mappings( - user, - { - org1.name: { - 'dne_role': True, - }, - }, - {}, - 'Nada', - ) - - # Try adding a user to a role that should not exist on an org (technically this works at this time) - reconcile_users_org_team_mappings( - user, - { - org1.name: { - 'read_role_id': True, - }, - }, - {}, - 'Nada', - ) - assert len(user.roles.all()) == 4 - assert user in org1.member_role - assert user in org1.admin_role - assert user in org1.auditor_role - - # Remove all of the org perms to test team perms - reconcile_users_org_team_mappings( - user, - { - org1.name: { - 'read_role_id': False, - 'member_role': False, - 'admin_role': False, - 'auditor_role': False, - }, - }, - {}, - 'Nada', - ) - assert list(user.roles.all()) == [] - - # Add the user as a member to one of the teams - reconcile_users_org_team_mappings(user, {}, {org1.name: {team1.name: {'member_role': True}}}, 'Nada') - assert len(user.roles.all()) == 1 - assert user in team1.member_role - # Validate that the user did not become a member of a team with the same name in a different org - assert user not in team2.member_role - - # Remove the user from the team - reconcile_users_org_team_mappings(user, {}, {org1.name: {team1.name: {'member_role': False}}}, 'Nada') - assert list(user.roles.all()) == [] - assert user not in team1.member_role - - # Remove the user from the team again - reconcile_users_org_team_mappings(user, {}, {org1.name: {team1.name: {'member_role': False}}}, 'Nada') - assert list(user.roles.all()) == [] - - # Add the user to a team that does not exist (should have no affect) - reconcile_users_org_team_mappings(user, {}, {org1.name: {'junk': {'member_role': True}}}, 'Nada') - assert list(user.roles.all()) == [] - - # Remove the user from a team that does not exist (should have no affect) - reconcile_users_org_team_mappings(user, {}, {org1.name: {'junk': {'member_role': False}}}, 'Nada') - assert list(user.roles.all()) == [] - - # Test a None setting - reconcile_users_org_team_mappings(user, {}, {org1.name: {'junk': {'member_role': None}}}, 'Nada') - assert list(user.roles.all()) == [] - - # Add the user multiple teams in different orgs - reconcile_users_org_team_mappings(user, {}, {org1.name: {team1.name: {'member_role': True}}, org2.name: {team2.name: {'member_role': True}}}, 'Nada') - assert len(user.roles.all()) == 2 - assert user in team1.member_role - assert user in team2.member_role - - # Remove the user from just one of the teams - reconcile_users_org_team_mappings(user, {}, {org2.name: {team2.name: {'member_role': False}}}, 'Nada') - assert len(user.roles.all()) == 1 - assert user in team1.member_role - assert user not in team2.member_role - - @pytest.mark.parametrize( - "org_list, team_map, can_create, org_count, team_count", - [ - # In this case we will only pass in organizations - ( - ["org1", "org2"], - {}, - True, - 2, - 0, - ), - # In this case we will only pass in teams but the orgs will be created from the teams - ( - [], - {"team1": "org1", "team2": "org2"}, - True, - 2, - 2, - ), - # In this case we will reuse an org - ( - ["org1"], - {"team1": "org1", "team2": "org1"}, - True, - 1, - 2, - ), - # In this case we have a combination of orgs, orgs reused and an org created by a team - ( - ["org1", "org2", "org3"], - {"team1": "org1", "team2": "org4"}, - True, - 4, - 2, - ), - # In this case we will test a case that the UI should prevent and have a team with no Org - # This should create org1/2 but only team1 - ( - ["org1"], - {"team1": "org2", "team2": None}, - True, - 2, - 1, - ), - # Block any creation with the can_create flag - ( - ["org1"], - {"team1": "org2", "team2": None}, - False, - 0, - 0, - ), - ], - ) - def test_create_org_and_teams(self, galaxy_credential, org_list, team_map, can_create, org_count, team_count): - create_org_and_teams(org_list, team_map, 'py.test', can_create=can_create) - assert Organization.objects.count() == org_count - assert Team.objects.count() == team_count - - def test_get_or_create_org_with_default_galaxy_cred_add_galaxy_cred(self, galaxy_credential): - # If this method creates the org it should get the default galaxy credential - num_orgs = 4 - for number in range(1, (num_orgs + 1)): - get_or_create_org_with_default_galaxy_cred(name=f"Default {number}") - - assert Organization.objects.count() == 4 - - for o in Organization.objects.all(): - assert o.galaxy_credentials.count() == 1 - assert o.galaxy_credentials.first().name == 'Ansible Galaxy' - - def test_get_or_create_org_with_default_galaxy_cred_no_galaxy_cred(self, galaxy_credential): - # If the org is pre-created, we should not add the galaxy_credential - num_orgs = 4 - for number in range(1, (num_orgs + 1)): - Organization.objects.create(name=f"Default {number}") - get_or_create_org_with_default_galaxy_cred(name=f"Default {number}") - - assert Organization.objects.count() == 4 - - for o in Organization.objects.all(): - assert o.galaxy_credentials.count() == 0 - - @pytest.mark.parametrize( - "enable_ldap, enable_social, enable_enterprise, expected_results", - [ - (False, False, False, None), - (True, False, False, 'ldap'), - (True, True, False, 'social'), - (True, True, True, 'enterprise'), - (False, True, True, 'enterprise'), - (False, False, True, 'enterprise'), - (False, True, False, 'social'), - ], - ) - def test_get_external_account(self, enable_ldap, enable_social, enable_enterprise, expected_results): - try: - user = User.objects.get(username="external_tester") - except User.DoesNotExist: - user = User(username="external_tester") - user.set_unusable_password() - user.save() - - if enable_ldap: - user.profile.ldap_dn = 'test.dn' - if enable_social: - from social_django.models import UserSocialAuth - - social_auth, _ = UserSocialAuth.objects.get_or_create( - uid='667ec049-cdf3-45d0-a4dc-0465f7505954', - provider='oidc', - extra_data={}, - user_id=user.id, - ) - user.social_auth.set([social_auth]) - if enable_enterprise: - from awx.sso.models import UserEnterpriseAuth - - enterprise_auth = UserEnterpriseAuth(user=user, provider='tacacs+') - enterprise_auth.save() - - assert get_external_account(user) == expected_results - - @pytest.mark.parametrize( - "setting, expected", - [ - # Set none of the social auth settings - ('JUNK_SETTING', False), - # Set the hard coded settings - ('AUTH_LDAP_SERVER_URI', True), - ('SOCIAL_AUTH_SAML_ENABLED_IDPS', True), - ('RADIUS_SERVER', True), - ('TACACSPLUS_HOST', True), - # Set some SOCIAL_SOCIAL_AUTH_OIDC_KEYAUTH_*_KEY settings - ('SOCIAL_AUTH_AZUREAD_OAUTH2_KEY', True), - ('SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY', True), - ('SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY', True), - ('SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY', True), - ('SOCIAL_AUTH_GITHUB_KEY', True), - ('SOCIAL_AUTH_GITHUB_ORG_KEY', True), - ('SOCIAL_AUTH_GITHUB_TEAM_KEY', True), - ('SOCIAL_AUTH_GOOGLE_OAUTH2_KEY', True), - ('SOCIAL_AUTH_OIDC_KEY', True), - # Try a hypothetical future one - ('SOCIAL_AUTH_GIBBERISH_KEY', True), - # Do a SAML one - ('SOCIAL_AUTH_SAML_SP_PRIVATE_KEY', False), - ], - ) - def test_is_remote_auth_enabled(self, setting, expected): - with override_settings(**{setting: True}): - assert is_remote_auth_enabled() == expected - - @pytest.mark.parametrize( - "key_one, key_one_value, key_two, key_two_value, expected", - [ - ('JUNK_SETTING', True, 'JUNK2_SETTING', True, False), - ('AUTH_LDAP_SERVER_URI', True, 'SOCIAL_AUTH_AZUREAD_OAUTH2_KEY', True, True), - ('JUNK_SETTING', True, 'SOCIAL_AUTH_AZUREAD_OAUTH2_KEY', True, True), - ('AUTH_LDAP_SERVER_URI', False, 'SOCIAL_AUTH_AZUREAD_OAUTH2_KEY', False, False), - ], - ) - def test_is_remote_auth_enabled_multiple_keys(self, key_one, key_one_value, key_two, key_two_value, expected): - with override_settings(**{key_one: key_one_value}): - with override_settings(**{key_two: key_two_value}): - assert is_remote_auth_enabled() == expected 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 deleted file mode 100644 index 3f37b41df319..000000000000 --- a/awx/sso/tests/functional/test_get_or_set_enterprise_user.py +++ /dev/null @@ -1,37 +0,0 @@ -# Python -import pytest -from unittest import mock - -# AWX -from awx.sso.backends import _get_or_set_enterprise_user - - -@pytest.mark.django_db -def test_fetch_user_if_exist(existing_tacacsplus_user): - with mock.patch('awx.sso.backends.logger') as mocked_logger: - new_user = _get_or_set_enterprise_user("foo", "password", "tacacs+") - mocked_logger.debug.assert_not_called() - mocked_logger.warning.assert_not_called() - 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.warning.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_ldap.py b/awx/sso/tests/functional/test_ldap.py deleted file mode 100644 index 881ab29e2b4f..000000000000 --- a/awx/sso/tests/functional/test_ldap.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.test.utils import override_settings -import ldap -import pytest - -from awx.sso.backends import LDAPSettings - - -@override_settings(AUTH_LDAP_CONNECTION_OPTIONS={ldap.OPT_NETWORK_TIMEOUT: 60}) -@pytest.mark.django_db -def test_ldap_with_custom_timeout(): - settings = LDAPSettings() - assert settings.CONNECTION_OPTIONS == {ldap.OPT_NETWORK_TIMEOUT: 60} - - -@override_settings(AUTH_LDAP_CONNECTION_OPTIONS={ldap.OPT_REFERRALS: 0}) -@pytest.mark.django_db -def test_ldap_with_missing_timeout(): - settings = LDAPSettings() - assert settings.CONNECTION_OPTIONS == {ldap.OPT_REFERRALS: 0, ldap.OPT_NETWORK_TIMEOUT: 30} diff --git a/awx/sso/tests/functional/test_saml_pipeline.py b/awx/sso/tests/functional/test_saml_pipeline.py deleted file mode 100644 index 5204dc30ae46..000000000000 --- a/awx/sso/tests/functional/test_saml_pipeline.py +++ /dev/null @@ -1,711 +0,0 @@ -import pytest -import re - -from django.test.utils import override_settings -from awx.main.models import User, Organization, Team -from awx.sso.saml_pipeline import ( - _update_m2m_from_expression, - _update_user_orgs, - _update_user_teams, - _update_user_orgs_by_saml_attr, - _update_user_teams_by_saml_attr, - _check_flag, -) - -# from unittest import mock -# from django.utils.timezone import now -# , Credential, CredentialType - - -@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 TestSAMLPopulateUser: - # The main populate_user does not need to be tested since its just a conglomeration of other functions that we test - # This test is here in case someone alters the code in the future in a way that does require testing - def test_populate_user(self): - assert True - - -@pytest.mark.django_db -class TestSAMLSimpleMaps: - # This tests __update_user_orgs and __update_user_teams - @pytest.fixture - def backend(self): - class Backend: - s = { - 'ORGANIZATION_MAP': { - 'Default': { - 'remove': True, - 'admins': 'foobar', - 'remove_admins': True, - 'users': 'foo', - 'remove_users': True, - 'organization_alias': '', - } - }, - 'TEAM_MAP': {'Blue': {'organization': 'Default', 'remove': True, 'users': ''}, 'Red': {'organization': 'Default', 'remove': True, 'users': ''}}, - } - - def setting(self, key): - return self.s[key] - - return Backend() - - def test__update_user_orgs(self, 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('.*') - - desired_org_state = {} - orgs_to_create = [] - _update_user_orgs(backend, desired_org_state, orgs_to_create, u1) - _update_user_orgs(backend, desired_org_state, orgs_to_create, u2) - _update_user_orgs(backend, desired_org_state, orgs_to_create, u3) - - assert desired_org_state == {'Default': {'member_role': True, 'admin_role': True, 'auditor_role': False}} - assert orgs_to_create == ['Default'] - - # 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 - desired_org_state = {} - orgs_to_create = [] - _update_user_orgs(backend, desired_org_state, orgs_to_create, u1) - assert desired_org_state == {'Default': {'member_role': False, 'admin_role': False, 'auditor_role': False}} - assert orgs_to_create == ['Default'] - - # Test remove feature disabled - backend.setting('ORGANIZATION_MAP')['Default']['remove_admins'] = False - backend.setting('ORGANIZATION_MAP')['Default']['remove_users'] = False - desired_org_state = {} - orgs_to_create = [] - _update_user_orgs(backend, desired_org_state, orgs_to_create, u2) - - assert desired_org_state == {'Default': {'member_role': None, 'admin_role': None, 'auditor_role': False}} - assert orgs_to_create == ['Default'] - - # Test organization alias feature - backend.setting('ORGANIZATION_MAP')['Default']['organization_alias'] = 'Default_Alias' - orgs_to_create = [] - _update_user_orgs(backend, {}, orgs_to_create, u1) - assert orgs_to_create == ['Default_Alias'] - - 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('.*') - - desired_team_state = {} - teams_to_create = {} - _update_user_teams(backend, desired_team_state, teams_to_create, u1) - assert teams_to_create == {'Red': 'Default', 'Blue': 'Default'} - assert desired_team_state == {'Default': {'Blue': {'member_role': True}, 'Red': {'member_role': True}}} - - # 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'] = '' - - desired_team_state = {} - teams_to_create = {} - _update_user_teams(backend, desired_team_state, teams_to_create, u1) - assert teams_to_create == {'Red': 'Default', 'Blue': 'Default'} - assert desired_team_state == {'Default': {'Blue': {'member_role': False}, 'Red': {'member_role': False}}} - - # Test remove feature disabled - backend.setting('TEAM_MAP')['Blue']['remove'] = False - backend.setting('TEAM_MAP')['Red']['remove'] = False - - desired_team_state = {} - teams_to_create = {} - _update_user_teams(backend, desired_team_state, teams_to_create, u2) - assert teams_to_create == {'Red': 'Default', 'Blue': 'Default'} - # If we don't care about team memberships we just don't add them to the hash so this would be an empty hash - assert desired_team_state == {} - - -@pytest.mark.django_db -class TestSAMLM2M: - @pytest.mark.parametrize( - "expression, remove, expected_return", - [ - # No expression with no remove - (None, False, None), - ("", False, None), - # No expression with remove - (None, True, False), - # True expression with and without remove - (True, False, True), - (True, True, True), - # Single string matching the user name - ("user1", False, True), - # Single string matching the user email - ("user1@foo.com", False, True), - # Single string not matching username or email, no remove - ("user27", False, None), - # Single string not matching username or email, with remove - ("user27", True, False), - # Same tests with arrays instead of strings - (["user1"], False, True), - (["user1@foo.com"], False, True), - (["user27"], False, None), - (["user27"], True, False), - # Arrays with nothing matching - (["user27", "user28"], False, None), - (["user27", "user28"], True, False), - # Arrays with all matches - (["user1", "user1@foo.com"], False, True), - # Arrays with some match, some not - (["user1", "user28", "user27"], False, True), - # - # Note: For RE's, usually settings takes care of the compilation for us, so we have to do it manually for testing. - # we also need to remove any / or flags for the compile to happen - # - # Matching username regex non-array - (re.compile("^user.*"), False, True), - (re.compile("^user.*"), True, True), - # Matching email regex non-array - (re.compile(".*@foo.com$"), False, True), - (re.compile(".*@foo.com$"), True, True), - # Non-array not matching username or email - (re.compile("^$"), False, None), - (re.compile("^$"), True, False), - # All re tests just in array form - ([re.compile("^user.*")], False, True), - ([re.compile("^user.*")], True, True), - ([re.compile(".*@foo.com$")], False, True), - ([re.compile(".*@foo.com$")], True, True), - ([re.compile("^$")], False, None), - ([re.compile("^$")], True, False), - # An re with username matching but not email - ([re.compile("^user.*"), re.compile(".*@bar.com$")], False, True), - # An re with email matching but not username - ([re.compile("^user27$"), re.compile(".*@foo.com$")], False, True), - # An re array with no matching - ([re.compile("^user27$"), re.compile(".*@bar.com$")], False, None), - ([re.compile("^user27$"), re.compile(".*@bar.com$")], True, False), - # - # A mix of re and strings - # - # String matches, re does not - (["user1", re.compile(".*@bar.com$")], False, True), - # String does not match, re does - (["user27", re.compile(".*@foo.com$")], False, True), - # Nothing matches - (["user27", re.compile(".*@bar.com$")], False, None), - (["user27", re.compile(".*@bar.com$")], True, False), - ], - ) - def test__update_m2m_from_expression(self, expression, remove, expected_return): - user = User.objects.create(username='user1', last_name='foo', first_name='bar', email='user1@foo.com') - return_val = _update_m2m_from_expression(user, expression, remove) - assert return_val == expected_return - - -@pytest.mark.django_db -class TestSAMLAttrMaps: - @pytest.fixture - def backend(self): - class Backend: - s = { - 'ORGANIZATION_MAP': { - 'Default1': { - 'remove': True, - 'admins': 'foobar', - 'remove_admins': True, - 'users': 'foo', - 'remove_users': True, - 'organization_alias': 'o1_alias', - } - } - } - - def setting(self, key): - return self.s[key] - - return Backend() - - @pytest.mark.parametrize( - "setting, expected_state, expected_orgs_to_create, kwargs_member_of_mods", - [ - ( - # Default test, make sure that our roles get applied and removed as specified (with an alias) - { - 'saml_attr': 'memberOf', - 'saml_admin_attr': 'admins', - 'saml_auditor_attr': 'auditors', - 'remove': True, - 'remove_admins': True, - }, - { - 'Default2': {'member_role': True}, - 'Default3': {'admin_role': True}, - 'Default4': {'auditor_role': True}, - 'o1_alias': {'member_role': True}, - 'Rando1': {'admin_role': False, 'auditor_role': False, 'member_role': False}, - }, - [ - 'o1_alias', - 'Default2', - 'Default3', - 'Default4', - ], - None, - ), - ( - # Similar test, we are just going to override the values "coming from the IdP" to limit the teams - { - 'saml_attr': 'memberOf', - 'saml_admin_attr': 'admins', - 'saml_auditor_attr': 'auditors', - 'remove': True, - 'remove_admins': True, - }, - { - 'Default3': {'admin_role': True, 'member_role': True}, - 'Default4': {'auditor_role': True}, - 'Rando1': {'admin_role': False, 'auditor_role': False, 'member_role': False}, - }, - [ - 'Default3', - 'Default4', - ], - ['Default3'], - ), - ( - # Test to make sure the remove logic is working - { - 'saml_attr': 'memberOf', - 'saml_admin_attr': 'admins', - 'saml_auditor_attr': 'auditors', - 'remove': False, - 'remove_admins': False, - 'remove_auditors': False, - }, - { - 'Default2': {'member_role': True}, - 'Default3': {'admin_role': True}, - 'Default4': {'auditor_role': True}, - 'o1_alias': {'member_role': True}, - }, - [ - 'o1_alias', - 'Default2', - 'Default3', - 'Default4', - ], - ['Default1', 'Default2'], - ), - ], - ) - def test__update_user_orgs_by_saml_attr(self, backend, setting, expected_state, expected_orgs_to_create, kwargs_member_of_mods): - kwargs = { - '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'], - 'admins': ['Default3'], - 'auditors': ['Default4'], - 'groups': ['Blue', 'Red'], - 'User.email': ['cmeyers@redhat.com'], - 'User.LastName': ['Meyers'], - 'name_id': 'cmeyers@redhat.com', - 'User.FirstName': ['Chris'], - 'PersonImmutableID': [], - }, - }, - 'social': None, - 'strategy': None, - 'new_association': False, - } - if kwargs_member_of_mods: - kwargs['response']['attributes']['memberOf'] = kwargs_member_of_mods - - # Create a random organization in the database for testing - Organization.objects.create(name='Rando1') - - with override_settings(SOCIAL_AUTH_SAML_ORGANIZATION_ATTR=setting): - desired_org_state = {} - orgs_to_create = [] - _update_user_orgs_by_saml_attr(backend, desired_org_state, orgs_to_create, **kwargs) - assert desired_org_state == expected_state - assert orgs_to_create == expected_orgs_to_create - - @pytest.mark.parametrize( - "setting, expected_team_state, expected_teams_to_create, kwargs_group_override", - [ - ( - { - 'saml_attr': 'groups', - 'remove': False, - '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'}, - {'team': 'Yellow', 'team_alias': 'Yellow_Alias', 'organization': 'Default4', 'organization_alias': 'Default4_Alias'}, - ], - }, - { - 'Default1': { - 'Blue': {'member_role': True}, - 'Green': {'member_role': False}, - 'Red': {'member_role': True}, - }, - 'Default2': { - 'Blue': {'member_role': True}, - }, - 'Default3': { - 'Blue': {'member_role': True}, - 'Green': {'member_role': False}, - }, - 'Default4': { - 'Yellow': {'member_role': False}, - }, - }, - { - 'Blue': 'Default3', - 'Red': 'Default1', - }, - None, - ), - ( - { - 'saml_attr': 'groups', - 'remove': False, - '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'}, - {'team': 'Yellow', 'team_alias': 'Yellow_Alias', 'organization': 'Default4', 'organization_alias': 'Default4_Alias'}, - ], - }, - { - 'Default1': { - 'Blue': {'member_role': True}, - 'Green': {'member_role': True}, - 'Red': {'member_role': True}, - }, - 'Default2': { - 'Blue': {'member_role': True}, - }, - 'Default3': { - 'Blue': {'member_role': True}, - 'Green': {'member_role': True}, - }, - 'Default4': { - 'Yellow': {'member_role': False}, - }, - }, - { - 'Blue': 'Default3', - 'Red': 'Default1', - 'Green': 'Default3', - }, - ['Blue', 'Red', 'Green'], - ), - ( - { - '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'}, - {'team': 'Yellow', 'team_alias': 'Yellow_Alias', 'organization': 'Default4', 'organization_alias': 'Default4_Alias'}, - ], - }, - { - 'Default1': { - 'Blue': {'member_role': False}, - 'Green': {'member_role': True}, - 'Red': {'member_role': False}, - }, - 'Default2': { - 'Blue': {'member_role': False}, - }, - 'Default3': { - 'Blue': {'member_role': False}, - 'Green': {'member_role': True}, - }, - 'Default4': { - 'Yellow': {'member_role': False}, - }, - 'Rando1': { - 'Rando1': {'member_role': False}, - }, - }, - { - 'Green': 'Default3', - }, - ['Green'], - ), - ], - ) - def test__update_user_teams_by_saml_attr(self, setting, expected_team_state, expected_teams_to_create, kwargs_group_override): - kwargs = { - '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'], - 'admins': ['Default3'], - 'auditors': ['Default4'], - 'groups': ['Blue', 'Red'], - 'User.email': ['cmeyers@redhat.com'], - 'User.LastName': ['Meyers'], - 'name_id': 'cmeyers@redhat.com', - 'User.FirstName': ['Chris'], - 'PersonImmutableID': [], - }, - }, - 'social': None, - 'strategy': None, - 'new_association': False, - } - if kwargs_group_override: - kwargs['response']['attributes']['groups'] = kwargs_group_override - - o = Organization.objects.create(name='Rando1') - Team.objects.create(name='Rando1', organization_id=o.id) - - with override_settings(SOCIAL_AUTH_SAML_TEAM_ATTR=setting): - desired_team_state = {} - teams_to_create = {} - _update_user_teams_by_saml_attr(desired_team_state, teams_to_create, **kwargs) - assert desired_team_state == expected_team_state - assert teams_to_create == expected_teams_to_create - - -@pytest.mark.django_db -class TestSAMLUserFlags: - @pytest.mark.parametrize( - "user_flags_settings, expected, is_superuser", - [ - # In this case we will pass no user flags so new_flag should be false and changed will def be false - ( - {}, - (False, False), - False, - ), - # NOTE: The first handful of tests test role/value as string instead of lists. - # This was from the initial implementation of these fields but the code should be able to handle this - # There are a couple tests at the end of this which will validate arrays in these values. - # - # In this case we will give the user a group to make them an admin - ( - {'is_superuser_role': 'test-role-1'}, - (True, True), - False, - ), - # In this case we will give the user a flag that will make then an admin - ( - {'is_superuser_attr': 'is_superuser'}, - (True, True), - False, - ), - # In this case we will give the user a flag but the wrong value - ( - {'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'junk'}, - (False, False), - False, - ), - # In this case we will give the user a flag and the right value - ( - {'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'true'}, - (True, True), - False, - ), - # In this case we will give the user a proper role and an is_superuser_attr role that they don't have, this should make them an admin - ( - {'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'gibberish', 'is_superuser_value': 'true'}, - (True, True), - False, - ), - # In this case we will give the user a proper role and an is_superuser_attr role that they have, this should make them an admin - ( - {'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'test-role-1'}, - (True, True), - False, - ), - # In this case we will give the user a proper role and an is_superuser_attr role that they have but a bad value, this should make them an admin - ( - {'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'junk'}, - (False, False), - False, - ), - # In this case we will give the user everything - ( - {'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'true'}, - (True, True), - False, - ), - # In this test case we will validate that a single attribute (instead of a list) still works - ( - {'is_superuser_attr': 'name_id', 'is_superuser_value': 'test_id'}, - (True, True), - False, - ), - # This will be a negative test for a single attribute - ( - {'is_superuser_attr': 'name_id', 'is_superuser_value': 'junk'}, - (False, False), - False, - ), - # The user is already a superuser so we should remove them - ( - {'is_superuser_attr': 'name_id', 'is_superuser_value': 'junk', 'remove_superusers': True}, - (False, True), - True, - ), - # The user is already a superuser but we don't have a remove field - ( - {'is_superuser_attr': 'name_id', 'is_superuser_value': 'junk', 'remove_superusers': False}, - (True, False), - True, - ), - # Positive test for multiple values for is_superuser_value - ( - {'is_superuser_attr': 'is_superuser', 'is_superuser_value': ['junk', 'junk2', 'else', 'junk']}, - (True, True), - False, - ), - # Negative test for multiple values for is_superuser_value - ( - {'is_superuser_attr': 'is_superuser', 'is_superuser_value': ['junk', 'junk2', 'junk']}, - (False, True), - True, - ), - # Positive test for multiple values of is_superuser_role - ( - {'is_superuser_role': ['junk', 'junk2', 'something', 'junk']}, - (True, True), - False, - ), - # Negative test for multiple values of is_superuser_role - ( - {'is_superuser_role': ['junk', 'junk2', 'junk']}, - (False, True), - True, - ), - ], - ) - def test__check_flag(self, user_flags_settings, expected, is_superuser): - user = User() - user.username = 'John' - user.is_superuser = is_superuser - - attributes = { - 'email': ['noone@nowhere.com'], - 'last_name': ['Westcott'], - 'is_superuser': ['something', 'else', 'true'], - 'username': ['test_id'], - 'first_name': ['John'], - 'Role': ['test-role-1', 'something', 'different'], - 'name_id': 'test_id', - } - - assert expected == _check_flag(user, 'superuser', attributes, user_flags_settings) - - -@pytest.mark.django_db -def test__update_user_orgs_org_map_and_saml_attr(): - """ - This combines the action of two other tests where an org membership is defined both by - the ORGANIZATION_MAP and the SOCIAL_AUTH_SAML_ORGANIZATION_ATTR at the same time - """ - - # This data will make the user a member - class BackendClass: - s = { - 'ORGANIZATION_MAP': { - 'Default1': { - 'remove': True, - 'remove_admins': True, - 'users': 'foobar', - 'remove_users': True, - 'organization_alias': 'o1_alias', - } - } - } - - def setting(self, key): - return self.s[key] - - backend = BackendClass() - - setting = { - 'saml_attr': 'memberOf', - 'saml_admin_attr': 'admins', - 'saml_auditor_attr': 'auditors', - 'remove': True, - 'remove_admins': True, - } - - # This data from the server will make the user an admin of the organization - kwargs = { - 'username': 'foobar', - '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': { - 'admins': ['Default1'], - }, - }, - 'social': None, - 'strategy': None, - 'new_association': False, - } - - this_user = User.objects.create(username='foobar') - - with override_settings(SOCIAL_AUTH_SAML_ORGANIZATION_ATTR=setting): - desired_org_state = {} - orgs_to_create = [] - - # this should add user as an admin of the org - _update_user_orgs_by_saml_attr(backend, desired_org_state, orgs_to_create, **kwargs) - assert desired_org_state['o1_alias']['admin_role'] is True - - assert set(orgs_to_create) == set(['o1_alias']) - - # this should add user as a member of the org without reverting the admin status - _update_user_orgs(backend, desired_org_state, orgs_to_create, this_user) - assert desired_org_state['o1_alias']['member_role'] is True - assert desired_org_state['o1_alias']['admin_role'] is True - - assert set(orgs_to_create) == set(['o1_alias']) diff --git a/awx/sso/tests/functional/test_social_base_pipeline.py b/awx/sso/tests/functional/test_social_base_pipeline.py deleted file mode 100644 index 38a49e15f331..000000000000 --- a/awx/sso/tests/functional/test_social_base_pipeline.py +++ /dev/null @@ -1,76 +0,0 @@ -import pytest - -from awx.main.models import User -from awx.sso.social_base_pipeline import AuthNotFound, check_user_found_or_created, set_is_active_for_new_user, prevent_inactive_login, AuthInactive - - -@pytest.mark.django_db -class TestSocialBasePipeline: - def test_check_user_found_or_created_no_exception(self): - # If we have a user (the True param, we should not get an exception) - try: - check_user_found_or_created(None, {}, True) - except AuthNotFound: - assert False, 'check_user_found_or_created should not have raised an exception with a user' - - @pytest.mark.parametrize( - "details, kwargs, expected_id", - [ - ( - {}, - {}, - '???', - ), - ( - {}, - {'uid': 'kwargs_uid'}, - 'kwargs_uid', - ), - ( - {}, - {'uid': 'kwargs_uid', 'email': 'kwargs_email'}, - 'kwargs_email', - ), - ( - {'email': 'details_email'}, - {'uid': 'kwargs_uid', 'email': 'kwargs_email'}, - 'details_email', - ), - ], - ) - def test_check_user_found_or_created_exceptions(self, details, expected_id, kwargs): - with pytest.raises(AuthNotFound) as e: - check_user_found_or_created(None, details, False, None, **kwargs) - assert f'An account cannot be found for {expected_id}' == str(e.value) - - @pytest.mark.parametrize( - "kwargs, expected_details, expected_response", - [ - ({}, {}, None), - ({'is_new': False}, {}, None), - ({'is_new': True}, {'is_active': True}, {'details': {'is_active': True}}), - ], - ) - def test_set_is_active_for_new_user(self, kwargs, expected_details, expected_response): - details = {} - response = set_is_active_for_new_user(None, details, None, None, **kwargs) - assert details == expected_details - assert response == expected_response - - def test_prevent_inactive_login_no_exception_no_user(self): - try: - prevent_inactive_login(None, None, None, None, None) - except AuthInactive: - assert False, 'prevent_inactive_login should not have raised an exception with no user' - - def test_prevent_inactive_login_no_exception_active_user(self): - user = User.objects.create(username='user1@foo.com', last_name='foo', first_name='bar', email='user1@foo.com', is_active=True) - try: - prevent_inactive_login(None, None, user, None, None) - except AuthInactive: - assert False, 'prevent_inactive_login should not have raised an exception with an active user' - - def test_prevent_inactive_login_no_exception_inactive_user(self): - user = User.objects.create(username='user1@foo.com', last_name='foo', first_name='bar', email='user1@foo.com', is_active=False) - with pytest.raises(AuthInactive): - prevent_inactive_login(None, None, user, None, None) diff --git a/awx/sso/tests/functional/test_social_pipeline.py b/awx/sso/tests/functional/test_social_pipeline.py deleted file mode 100644 index f26886e71944..000000000000 --- a/awx/sso/tests/functional/test_social_pipeline.py +++ /dev/null @@ -1,113 +0,0 @@ -import pytest -import re - -from awx.sso.social_pipeline import update_user_orgs, update_user_teams -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 TestSocialPipeline: - @pytest.fixture - def backend(self): - class Backend: - s = { - 'ORGANIZATION_MAP': { - 'Default': { - 'remove': True, - 'admins': 'foobar', - 'remove_admins': True, - 'users': 'foo', - 'remove_users': True, - 'organization_alias': '', - } - }, - '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 - - # Test organization alias feature - backend.setting('ORGANIZATION_MAP')['Default']['organization_alias'] = 'Default_Alias' - update_user_orgs(backend, None, u1) - assert Organization.objects.get(name="Default_Alias") is not None - - 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 diff --git a/awx/sso/tests/test_env.py b/awx/sso/tests/test_env.py deleted file mode 100644 index b63da8ed8a16..000000000000 --- a/awx/sso/tests/test_env.py +++ /dev/null @@ -1,4 +0,0 @@ -# Ensure that our autouse overwrites are working -def test_cache(settings): - assert settings.CACHES['default']['BACKEND'] == 'django.core.cache.backends.locmem.LocMemCache' - assert settings.CACHES['default']['LOCATION'].startswith('unique-') diff --git a/awx/sso/tests/unit/test_fields.py b/awx/sso/tests/unit/test_fields.py deleted file mode 100644 index 35ab58d07fab..000000000000 --- a/awx/sso/tests/unit/test_fields.py +++ /dev/null @@ -1,235 +0,0 @@ -import pytest -from unittest import mock - -from rest_framework.exceptions import ValidationError - -from awx.sso.fields import SAMLOrgAttrField, SAMLTeamAttrField, SAMLUserFlagsAttrField, LDAPGroupTypeParamsField, LDAPServerURIField - - -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}), - ({'remove': True, 'saml_admin_attr': 'foobar'}, {'remove': True, 'saml_admin_attr': 'foobar'}), - ({'saml_admin_attr': 'foobar'}, {'saml_admin_attr': 'foobar'}), - ({'remove_admins': True, 'saml_admin_attr': 'foobar'}, {'remove_admins': True, 'saml_admin_attr': 'foobar'}), - ( - {'remove': True, 'saml_attr': 'foo', 'remove_admins': True, 'saml_admin_attr': 'bar'}, - {'remove': True, 'saml_attr': 'foo', 'remove_admins': True, 'saml_admin_attr': 'bar'}, - ), - ], - ) - 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'}, {'remove': ['Must be a valid boolean.']}), - ({'remove': True, 'saml_attr': False}, {'saml_attr': ['Not a valid string.']}), - ( - {'remove': True, 'saml_attr': False, 'foo': 'bar', 'gig': 'ity'}, - {'saml_attr': ['Not a valid string.'], 'foo': ['Invalid field.'], 'gig': ['Invalid field.']}, - ), - ({'remove_admins': True, 'saml_admin_attr': False}, {'saml_admin_attr': ['Not a valid string.']}), - ({'remove_admins': 'blah', 'saml_admin_attr': 'foobar'}, {'remove_admins': ['Must be a valid boolean.']}), - ], - ) - def test_internal_value_invalid(self, data, expected): - field = SAMLOrgAttrField() - with pytest.raises(ValidationError) as e: - field.to_internal_value(data) - assert e.value.detail == 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'}, - ], - }, - { - 'remove': True, - 'saml_attr': 'foobar', - 'team_org_map': [ - {'team': 'Engineering', 'team_alias': 'Engineering Team', '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'}]}, - {'team_org_map': {0: {'not_a_valid_key': ['Invalid field.']}}}, - ), - ( - {'remove': False, 'saml_attr': 'foobar', 'team_org_map': [{'organization': 'Ansible'}]}, - {'team_org_map': {0: {'team': ['This field is required.']}}}, - ), - ( - {'remove': False, 'saml_attr': 'foobar', 'team_org_map': [{}]}, - {'team_org_map': {0: {'organization': ['This field is required.'], 'team': ['This field is required.']}}}, - ), - ], - ) - def test_internal_value_invalid(self, data, expected): - field = SAMLTeamAttrField() - with pytest.raises(ValidationError) as e: - field.to_internal_value(data) - assert e.value.detail == expected - - -class TestSAMLUserFlagsAttrField: - @pytest.mark.parametrize( - "data", - [ - {}, - {'is_superuser_attr': 'something'}, - {'is_superuser_value': ['value']}, - {'is_superuser_role': ['my_peeps']}, - {'remove_superusers': False}, - {'is_system_auditor_attr': 'something_else'}, - {'is_system_auditor_value': ['value2']}, - {'is_system_auditor_role': ['other_peeps']}, - {'remove_system_auditors': False}, - ], - ) - def test_internal_value_valid(self, data): - field = SAMLUserFlagsAttrField() - res = field.to_internal_value(data) - assert res == data - - @pytest.mark.parametrize( - "data, expected", - [ - ( - { - 'junk': 'something', - 'is_superuser_value': 'value', - 'is_superuser_role': 'my_peeps', - 'is_system_auditor_attr': 'else', - 'is_system_auditor_value': 'value2', - 'is_system_auditor_role': 'other_peeps', - }, - { - 'junk': ['Invalid field.'], - 'is_superuser_role': ['Expected a list of items but got type "str".'], - 'is_superuser_value': ['Expected a list of items but got type "str".'], - 'is_system_auditor_role': ['Expected a list of items but got type "str".'], - 'is_system_auditor_value': ['Expected a list of items but got type "str".'], - }, - ), - ( - { - 'junk': 'something', - }, - { - 'junk': ['Invalid field.'], - }, - ), - ( - { - 'junk': 'something', - 'junk2': 'else', - }, - { - 'junk': ['Invalid field.'], - 'junk2': ['Invalid field.'], - }, - ), - # make sure we can't pass a string to the boolean fields - ( - { - 'remove_superusers': 'test', - 'remove_system_auditors': 'test', - }, - { - "remove_superusers": ["Must be a valid boolean."], - "remove_system_auditors": ["Must be a valid boolean."], - }, - ), - ], - ) - def test_internal_value_invalid(self, data, expected): - field = SAMLUserFlagsAttrField() - with pytest.raises(ValidationError) as e: - field.to_internal_value(data) - print(e.value.detail) - assert e.value.detail == expected - - -class TestLDAPGroupTypeParamsField: - @pytest.mark.parametrize( - "group_type, data, expected", - [ - ('LDAPGroupType', {'name_attr': 'user', 'bob': ['a', 'b'], 'scooter': 'hello'}, ['Invalid key(s): "bob", "scooter".']), - ('MemberDNGroupType', {'name_attr': 'user', 'member_attr': 'west', 'bob': ['a', 'b'], 'scooter': 'hello'}, ['Invalid key(s): "bob", "scooter".']), - ( - 'PosixUIDGroupType', - {'name_attr': 'user', 'member_attr': 'west', 'ldap_group_user_attr': 'legacyThing', 'bob': ['a', 'b'], 'scooter': 'hello'}, - ['Invalid key(s): "bob", "member_attr", "scooter".'], - ), - ], - ) - def test_internal_value_invalid(self, group_type, data, expected): - field = LDAPGroupTypeParamsField() - field.get_depends_on = mock.MagicMock(return_value=group_type) - - with pytest.raises(ValidationError) as e: - field.to_internal_value(data) - assert e.value.detail == expected - - -class TestLDAPServerURIField: - @pytest.mark.parametrize( - "ldap_uri, exception, expected", - [ - (r'ldap://servername.com:444', None, r'ldap://servername.com:444'), - (r'ldap://servername.so3:444', None, r'ldap://servername.so3:444'), - (r'ldaps://servername3.s300:344', None, r'ldaps://servername3.s300:344'), - (r'ldap://servername.-so3:444', ValidationError, None), - ], - ) - def test_run_validators_valid(self, ldap_uri, exception, expected): - field = LDAPServerURIField() - if exception is None: - assert field.run_validators(ldap_uri) == expected - else: - with pytest.raises(exception): - field.run_validators(ldap_uri) diff --git a/awx/sso/tests/unit/test_ldap.py b/awx/sso/tests/unit/test_ldap.py deleted file mode 100644 index aa54aaa49dbe..000000000000 --- a/awx/sso/tests/unit/test_ldap.py +++ /dev/null @@ -1,25 +0,0 @@ -import ldap - -from awx.sso.backends import LDAPSettings -from awx.sso.validators import validate_ldap_filter -from django.core.cache import cache - - -def test_ldap_default_settings(mocker): - from_db = mocker.Mock(**{'order_by.return_value': []}) - mocker.patch('awx.conf.models.Setting.objects.filter', return_value=from_db) - settings = LDAPSettings() - assert settings.ORGANIZATION_MAP == {} - assert settings.TEAM_MAP == {} - - -def test_ldap_default_network_timeout(mocker): - cache.clear() # clearing cache avoids picking up stray default for OPT_REFERRALS - from_db = mocker.Mock(**{'order_by.return_value': []}) - mocker.patch('awx.conf.models.Setting.objects.filter', return_value=from_db) - settings = LDAPSettings() - assert settings.CONNECTION_OPTIONS[ldap.OPT_NETWORK_TIMEOUT] == 30 - - -def test_ldap_filter_validator(): - validate_ldap_filter('(test-uid=%(user)s)', with_user=True) diff --git a/awx/sso/tests/unit/test_pipelines.py b/awx/sso/tests/unit/test_pipelines.py deleted file mode 100644 index 94a1111187b8..000000000000 --- a/awx/sso/tests/unit/test_pipelines.py +++ /dev/null @@ -1,12 +0,0 @@ -import pytest - - -@pytest.mark.parametrize( - "lib", - [ - ("saml_pipeline"), - ("social_pipeline"), - ], -) -def test_module_loads(lib): - module = __import__("awx.sso." + lib) # noqa diff --git a/awx/sso/tests/unit/test_tacacsplus.py b/awx/sso/tests/unit/test_tacacsplus.py deleted file mode 100644 index 49315a96432c..000000000000 --- a/awx/sso/tests/unit/test_tacacsplus.py +++ /dev/null @@ -1,116 +0,0 @@ -from unittest import mock -import pytest - - -def test_empty_host_fails_auth(tacacsplus_backend): - with mock.patch('awx.sso.backends.django_settings') as settings: - settings.TACACSPLUS_HOST = '' - ret_user = tacacsplus_backend.authenticate(None, u"user", u"pass") - assert ret_user is None - - -def test_client_raises_exception(tacacsplus_backend): - client = mock.MagicMock() - client.authenticate.side_effect = Exception("foo") - with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('awx.sso.backends.logger') as logger, mock.patch( - 'tacacs_plus.TACACSClient', return_value=client - ): - settings.TACACSPLUS_HOST = 'localhost' - settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' - ret_user = tacacsplus_backend.authenticate(None, u"user", u"pass") - assert ret_user is None - logger.exception.assert_called_once_with("TACACS+ Authentication Error: foo") - - -def test_client_return_invalid_fails_auth(tacacsplus_backend): - auth = mock.MagicMock() - auth.valid = False - client = mock.MagicMock() - client.authenticate.return_value = auth - with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client): - settings.TACACSPLUS_HOST = 'localhost' - settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' - ret_user = tacacsplus_backend.authenticate(None, u"user", u"pass") - assert ret_user is None - - -def test_client_return_valid_passes_auth(tacacsplus_backend): - 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=False) - with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client), 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(None, u"user", u"pass") - assert ret_user == user - - -@pytest.mark.parametrize( - "client_ip_header,client_ip_header_value,expected_client_ip", - [('HTTP_X_FORWARDED_FOR', '12.34.56.78, 23.45.67.89', '12.34.56.78'), ('REMOTE_ADDR', '12.34.56.78', '12.34.56.78')], -) -def test_remote_addr_is_passed_to_client_if_available_and_setting_enabled(tacacsplus_backend, client_ip_header, client_ip_header_value, expected_client_ip): - 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=False) - request = mock.MagicMock() - request.META = { - client_ip_header: client_ip_header_value, - } - with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client), mock.patch( - 'awx.sso.backends._get_or_set_enterprise_user', return_value=user - ): - settings.TACACSPLUS_HOST = 'localhost' - settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' - settings.TACACSPLUS_REM_ADDR = True - tacacsplus_backend.authenticate(request, u"user", u"pass") - - client.authenticate.assert_called_once_with('user', 'pass', authen_type=1, rem_addr=expected_client_ip) - - -def test_remote_addr_is_completely_ignored_in_client_call_if_setting_is_disabled(tacacsplus_backend): - 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=False) - request = mock.MagicMock() - request.META = {} - with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client), mock.patch( - 'awx.sso.backends._get_or_set_enterprise_user', return_value=user - ): - settings.TACACSPLUS_HOST = 'localhost' - settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' - settings.TACACSPLUS_REM_ADDR = False - tacacsplus_backend.authenticate(request, u"user", u"pass") - - client.authenticate.assert_called_once_with('user', 'pass', authen_type=1) - - -def test_remote_addr_is_completely_ignored_in_client_call_if_unavailable_and_setting_enabled(tacacsplus_backend): - 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=False) - request = mock.MagicMock() - request.META = {} - with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client), mock.patch( - 'awx.sso.backends._get_or_set_enterprise_user', return_value=user - ): - settings.TACACSPLUS_HOST = 'localhost' - settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' - settings.TACACSPLUS_REM_ADDR = True - tacacsplus_backend.authenticate(request, u"user", u"pass") - - client.authenticate.assert_called_once_with('user', 'pass', authen_type=1) diff --git a/awx/sso/urls.py b/awx/sso/urls.py deleted file mode 100644 index 93da0996c970..000000000000 --- a/awx/sso/urls.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -from django.urls import re_path - -from awx.sso.views import sso_complete, sso_error, sso_inactive, saml_metadata - - -app_name = 'sso' -urlpatterns = [ - re_path(r'^complete/$', sso_complete, name='sso_complete'), - re_path(r'^error/$', sso_error, name='sso_error'), - re_path(r'^inactive/$', sso_inactive, name='sso_inactive'), - re_path(r'^metadata/saml/$', saml_metadata, name='saml_metadata'), -] diff --git a/awx/sso/validators.py b/awx/sso/validators.py deleted file mode 100644 index 478b86b36fc9..000000000000 --- a/awx/sso/validators.py +++ /dev/null @@ -1,74 +0,0 @@ -# Python -import re - -# Python-LDAP -import ldap - -# Django -from django.core.exceptions import ValidationError -from django.utils.translation import gettext_lazy as _ - -__all__ = [ - 'validate_ldap_dn', - 'validate_ldap_dn_with_user', - 'validate_ldap_bind_dn', - 'validate_ldap_filter', - 'validate_ldap_filter_with_user', - 'validate_tacacsplus_disallow_nonascii', -] - - -def validate_ldap_dn(value, with_user=False): - if with_user: - if '%(user)s' not in value: - raise ValidationError(_('DN must include "%%(user)s" placeholder for username: %s') % value) - dn_value = value.replace('%(user)s', 'USER') - else: - dn_value = value - try: - ldap.dn.str2dn(dn_value.encode('utf-8')) - except ldap.DECODING_ERROR: - raise ValidationError(_('Invalid DN: %s') % value) - - -def validate_ldap_dn_with_user(value): - validate_ldap_dn(value, with_user=True) - - -def validate_ldap_bind_dn(value): - if not re.match(r'^[A-Za-z][A-Za-z0-9._-]*?\\[A-Za-z0-9 ._-]+?$', value.strip()) and not re.match( - r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', value.strip() - ): - validate_ldap_dn(value) - - -def validate_ldap_filter(value, with_user=False): - value = value.strip() - if not value: - return - if with_user: - if '%(user)s' not in value: - raise ValidationError(_('DN must include "%%(user)s" placeholder for username: %s') % value) - dn_value = value.replace('%(user)s', 'USER') - else: - dn_value = value - if re.match(r'^\([A-Za-z0-9-]+?=[^()]+?\)$', dn_value): - return - elif re.match(r'^\([&|!]\(.*?\)\)$', dn_value): - try: - map(validate_ldap_filter, ['(%s)' % x for x in dn_value[3:-2].split(')(')]) - return - except ValidationError: - pass - raise ValidationError(_('Invalid filter: %s') % value) - - -def validate_ldap_filter_with_user(value): - validate_ldap_filter(value, with_user=True) - - -def validate_tacacsplus_disallow_nonascii(value): - try: - value.encode('ascii') - except (UnicodeEncodeError, UnicodeDecodeError): - raise ValidationError(_('TACACS+ secret does not allow non-ascii characters')) diff --git a/awx/sso/views.py b/awx/sso/views.py deleted file mode 100644 index b6fd724df7dd..000000000000 --- a/awx/sso/views.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -# Python -import urllib.parse -import logging - -# Django -from django.urls import reverse -from django.http import HttpResponse -from django.views.generic import View -from django.views.generic.base import RedirectView -from django.utils.encoding import smart_str -from django.conf import settings - -logger = logging.getLogger('awx.sso.views') - - -class BaseRedirectView(RedirectView): - permanent = True - - def get_redirect_url(self, *args, **kwargs): - last_path = self.request.COOKIES.get('lastPath', '') - last_path = urllib.parse.quote(urllib.parse.unquote(last_path).strip('"')) - url = reverse('ui:index') - if last_path: - return '%s#%s' % (url, last_path) - else: - return url - - -sso_error = BaseRedirectView.as_view() -sso_inactive = BaseRedirectView.as_view() - - -class CompleteView(BaseRedirectView): - def dispatch(self, request, *args, **kwargs): - response = super(CompleteView, self).dispatch(request, *args, **kwargs) - if self.request.user and self.request.user.is_authenticated: - logger.info(smart_str(u"User {} logged in".format(self.request.user.username))) - response.set_cookie( - 'userLoggedIn', 'true', secure=getattr(settings, 'SESSION_COOKIE_SECURE', False), samesite=getattr(settings, 'USER_COOKIE_SAMESITE', 'Lax') - ) - response.setdefault('X-API-Session-Cookie-Name', getattr(settings, 'SESSION_COOKIE_NAME', 'awx_sessionid')) - return response - - -sso_complete = CompleteView.as_view() - - -class MetadataView(View): - def get(self, request, *args, **kwargs): - from social_django.utils import load_backend, load_strategy - - complete_url = reverse('social:complete', args=('saml',)) - try: - saml_backend = load_backend(load_strategy(request), 'saml', redirect_uri=complete_url) - metadata, errors = saml_backend.generate_metadata_xml() - except Exception as e: - logger.exception('unable to generate SAML metadata') - errors = e - if not errors: - return HttpResponse(content=metadata, content_type='text/xml') - else: - return HttpResponse(content=str(errors), content_type='text/plain') - - -saml_metadata = MetadataView.as_view() diff --git a/awx/urls.py b/awx/urls.py index 1eff5fb44ff9..daef360d5788 100644 --- a/awx/urls.py +++ b/awx/urls.py @@ -26,8 +26,6 @@ def get_urlpatterns(prefix=None): path(f'api{prefix}v2/', include(api_version_urls)), path(f'api{prefix}', include(api_urls)), path('', include(root_urls)), - re_path(r'^sso/', include('awx.sso.urls', namespace='sso')), - re_path(r'^sso/', include('social_django.urls', namespace='social')), re_path(r'^(?:api/)?400.html$', handle_400), re_path(r'^(?:api/)?403.html$', handle_403), re_path(r'^(?:api/)?404.html$', handle_404), @@ -36,7 +34,7 @@ def get_urlpatterns(prefix=None): re_path(r'^login/', handle_login_redirect), # want api/v2/doesnotexist to return a 404, not match the ui urls, # so use a negative lookahead assertion here - re_path(r'^(?!api/|sso/).*', include('awx.ui.urls', namespace='ui')), + re_path(r'^(?!api/).*', include('awx.ui.urls', namespace='ui')), ] if settings.SETTINGS_MODULE == 'awx.settings.development': diff --git a/awx/wsgi.py b/awx/wsgi.py index 4817fbae1e86..2fad3f27daf1 100644 --- a/awx/wsgi.py +++ b/awx/wsgi.py @@ -13,7 +13,6 @@ from django.conf import settings # NOQA from django.urls import resolve # NOQA from django.core.wsgi import get_wsgi_application # NOQA -import social_django # NOQA """ diff --git a/awx_collection/test/awx/test_settings.py b/awx_collection/test/awx/test_settings.py index 69e823b3b9cf..2e0de9d2e04d 100644 --- a/awx_collection/test/awx/test_settings.py +++ b/awx_collection/test/awx/test_settings.py @@ -7,36 +7,6 @@ from awx.conf.models import Setting -@pytest.mark.django_db -def test_setting_flat_value(run_module, admin_user): - the_value = 'CN=service_account,OU=ServiceAccounts,DC=domain,DC=company,DC=org' - result = run_module('settings', dict(name='AUTH_LDAP_BIND_DN', value=the_value), admin_user) - assert not result.get('failed', False), result.get('msg', result) - assert result.get('changed'), result - - assert Setting.objects.get(key='AUTH_LDAP_BIND_DN').value == the_value - - -@pytest.mark.django_db -def test_setting_dict_value(run_module, admin_user): - the_value = {'email': 'mail', 'first_name': 'givenName', 'last_name': 'surname'} - result = run_module('settings', dict(name='AUTH_LDAP_USER_ATTR_MAP', value=the_value), admin_user) - assert not result.get('failed', False), result.get('msg', result) - assert result.get('changed'), result - - assert Setting.objects.get(key='AUTH_LDAP_USER_ATTR_MAP').value == the_value - - -@pytest.mark.django_db -def test_setting_nested_type(run_module, admin_user): - the_value = {'email': 'mail', 'first_name': 'givenName', 'last_name': 'surname'} - result = run_module('settings', dict(settings={'AUTH_LDAP_USER_ATTR_MAP': the_value}), admin_user) - assert not result.get('failed', False), result.get('msg', result) - assert result.get('changed'), result - - assert Setting.objects.get(key='AUTH_LDAP_USER_ATTR_MAP').value == the_value - - @pytest.mark.django_db def test_setting_bool_value(run_module, admin_user): for the_value in (True, False): diff --git a/licenses/defusedxml.txt b/licenses/defusedxml.txt deleted file mode 100644 index 029a548be418..000000000000 --- a/licenses/defusedxml.txt +++ /dev/null @@ -1,48 +0,0 @@ -PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 --------------------------------------------- - -1. This LICENSE AGREEMENT is between the Python Software Foundation -("PSF"), and the Individual or Organization ("Licensee") accessing and -otherwise using this software ("Python") in source or binary form and -its associated documentation. - -2. Subject to the terms and conditions of this License Agreement, PSF -hereby grants Licensee a nonexclusive, royalty-free, world-wide -license to reproduce, analyze, test, perform and/or display publicly, -prepare derivative works, distribute, and otherwise use Python -alone or in any derivative version, provided, however, that PSF's -License Agreement and PSF's notice of copyright, i.e., "Copyright (c) -2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008 Python Software Foundation; -All Rights Reserved" are retained in Python alone or in any derivative -version prepared by Licensee. - -3. In the event Licensee prepares a derivative work that is based on -or incorporates Python or any part thereof, and wants to make -the derivative work available to others as provided herein, then -Licensee hereby agrees to include in any such work a brief summary of -the changes made to Python. - -4. PSF is making Python available to Licensee on an "AS IS" -basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR -IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND -DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS -FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT -INFRINGE ANY THIRD PARTY RIGHTS. - -5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON -FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS -A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, -OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. - -6. This License Agreement will automatically terminate upon a material -breach of its terms and conditions. - -7. Nothing in this License Agreement shall be deemed to create any -relationship of agency, partnership, or joint venture between PSF and -Licensee. This License Agreement does not grant permission to use PSF -trademarks or trade name in a trademark sense to endorse or promote -products or services of Licensee, or any third party. - -8. By copying, installing or otherwise using Python, Licensee -agrees to be bound by the terms and conditions of this License -Agreement. diff --git a/licenses/python-jose.txt b/licenses/python-jose.txt deleted file mode 100644 index 59160df34b42..000000000000 --- a/licenses/python-jose.txt +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2015 Michael Davis - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/licenses/social-auth-app-django.txt b/licenses/social-auth-app-django.txt deleted file mode 100644 index 796a37a54f3f..000000000000 --- a/licenses/social-auth-app-django.txt +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2012-2016, Matías Aguirre -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - - 3. Neither the name of this project nor the names of its contributors may be - used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/licenses/social-auth-core.txt b/licenses/social-auth-core.txt deleted file mode 100644 index 284c8ac16593..000000000000 --- a/licenses/social-auth-core.txt +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2012-2016, Matías Aguirre -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - - 3. Neither the name of this project nor the names of its contributors may be - used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/requirements/requirements.in b/requirements/requirements.in index 8a2bd21ae664..341b51d1c923 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -56,8 +56,6 @@ python-ldap pyyaml>=6.0.1 pyzstd # otel collector log file compression library receptorctl -social-auth-core[openidconnect]==4.4.2 # see UPGRADE BLOCKERs -social-auth-app-django==5.4.0 # see UPGRADE BLOCKERs sqlparse>=0.4.4 # Required by django https://github.com/ansible/awx/security/dependabot/96 redis[hiredis] requests diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 2838fb6e15f1..6e335d63f135 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -106,17 +106,12 @@ cryptography==41.0.7 # pyjwt # pyopenssl # service-identity - # social-auth-core cython==0.29.37 # via -r /awx_devel/requirements/requirements.in daphne==3.0.2 # via # -r /awx_devel/requirements/requirements.in # channels -defusedxml==0.7.1 - # via - # python3-openid - # social-auth-core deprecated==1.2.14 # via # opentelemetry-api @@ -138,7 +133,6 @@ django==4.2.10 # django-polymorphic # django-solo # djangorestframework - # social-auth-app-django # via -r /awx_devel/requirements/requirements_git.txt django-auth-ldap==4.6.0 # via -r /awx_devel/requirements/requirements.in @@ -305,7 +299,6 @@ oauthlib==3.2.2 # django-oauth-toolkit # kubernetes # requests-oauthlib - # social-auth-core openshift==0.13.2 # via -r /awx_devel/requirements/requirements.in opentelemetry-api==1.24.0 @@ -394,7 +387,6 @@ pyjwt[crypto]==2.8.0 # adal # django-ansible-base # msal - # social-auth-core # twilio pyopenssl==24.0.0 # via @@ -416,8 +408,6 @@ python-dateutil==2.8.2 # receptorctl python-dsv-sdk==1.0.4 # via -r /awx_devel/requirements/requirements.in -python-jose==3.3.0 - # via social-auth-core python-ldap==3.4.4 # via # -r /awx_devel/requirements/requirements.in @@ -427,7 +417,6 @@ python-string-utils==1.0.0 python-tss-sdk==1.2.2 # via -r /awx_devel/requirements/requirements.in python3-openid==3.2.0 - # via social-auth-core # via -r /awx_devel/requirements/requirements_git.txt pytz==2024.1 # via @@ -466,13 +455,11 @@ requests==2.31.0 # python-dsv-sdk # python-tss-sdk # requests-oauthlib - # social-auth-core # twilio requests-oauthlib==1.3.1 # via # kubernetes # msrest - # social-auth-core rpds-py==0.18.0 # via # jsonschema @@ -509,12 +496,6 @@ slack-sdk==3.27.0 # via -r /awx_devel/requirements/requirements.in smmap==5.0.1 # via gitdb -social-auth-app-django==5.4.0 - # via -r /awx_devel/requirements/requirements.in -social-auth-core[openidconnect]==4.4.2 - # via - # -r /awx_devel/requirements/requirements.in - # social-auth-app-django sqlparse==0.4.4 # via # -r /awx_devel/requirements/requirements.in