From cd447bed960700a06b448ce435a1a87fbff1dda7 Mon Sep 17 00:00:00 2001 From: Chris Church Date: Sun, 8 Nov 2015 23:52:13 -0500 Subject: [PATCH] Social auth and SSO updates: * Move auth backends into sso app. * Add support for mapping social auth users into organizations and teams. * Return social auth backends in a consistent order in the API. * Remove custom SAML attribute mapping and use options provided by PSA. * Add pipeline function to raise an exception if no user has been found or created; added comments on how to disable new user creation. * Add comments for defining a custom social auth pipeline function. --- awx/api/views.py | 7 +- awx/main/tests/base.py | 2 +- awx/settings/defaults.py | 22 ++-- awx/settings/local_settings.py.docker_compose | 72 +++++++++++ awx/settings/local_settings.py.example | 72 +++++++++++ awx/settings/postprocess.py | 7 +- awx/{main/backend.py => sso/backends.py} | 37 +++--- awx/sso/middleware.py | 4 +- awx/sso/pipeline.py | 116 +++++++++++++++++- 9 files changed, 304 insertions(+), 35 deletions(-) rename awx/{main/backend.py => sso/backends.py} (87%) diff --git a/awx/api/views.py b/awx/api/views.py index 6125f0a4bd..c6f89bd116 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -527,7 +527,10 @@ class AuthView(APIView): def get(self, request): data = SortedDict() err_backend, err_message = request.session.get('social_auth_error', (None, None)) - for name, backend in load_backends(settings.AUTHENTICATION_BACKENDS).items(): + auth_backends = load_backends(settings.AUTHENTICATION_BACKENDS).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: if (not feature_exists('enterprise_auth') and not feature_enabled('ldap')) or \ (not feature_enabled('enterprise_auth') and @@ -541,7 +544,7 @@ class AuthView(APIView): } if name == 'saml': backend_data['metadata_url'] = reverse('sso:saml_metadata') - for idp in settings.SOCIAL_AUTH_SAML_ENABLED_IDPS.keys(): + 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) diff --git a/awx/main/tests/base.py b/awx/main/tests/base.py index a7a6fe116f..bdea0523a8 100644 --- a/awx/main/tests/base.py +++ b/awx/main/tests/base.py @@ -28,11 +28,11 @@ from django.test.utils import override_settings # AWX from awx.main.models import * # noqa -from awx.main.backend import LDAPSettings from awx.main.management.commands.run_callback_receiver import CallbackReceiver from awx.main.management.commands.run_task_system import run_taskmanager from awx.main.utils import get_ansible_version from awx.main.task_engine import TaskEngager as LicenseWriter +from awx.sso.backends import LDAPSettings TEST_PLAYBOOK = '''- hosts: mygroup gather_facts: false diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index c36a6500bd..6733f0d408 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -2,6 +2,7 @@ # All Rights Reserved. import os +import re # noqa import sys import djcelery from datetime import timedelta @@ -217,13 +218,13 @@ REST_FRAMEWORK = { } AUTHENTICATION_BACKENDS = ( - 'awx.main.backend.LDAPBackend', - 'awx.main.backend.RADIUSBackend', + 'awx.sso.backends.LDAPBackend', + 'awx.sso.backends.RADIUSBackend', 'social.backends.google.GoogleOAuth2', 'social.backends.github.GithubOAuth2', 'social.backends.github.GithubOrganizationOAuth2', 'social.backends.github.GithubTeamOAuth2', - 'awx.main.backend.SAMLAuth', + 'awx.sso.backends.SAMLAuth', 'django.contrib.auth.backends.ModelBackend', ) @@ -355,13 +356,15 @@ SOCIAL_AUTH_PIPELINE = ( 'social.pipeline.social_auth.social_user', 'social.pipeline.user.get_username', 'social.pipeline.social_auth.associate_by_email', - 'social.pipeline.mail.mail_validation', 'social.pipeline.user.create_user', + 'awx.sso.pipeline.check_user_found_or_created', 'social.pipeline.social_auth.associate_user', 'social.pipeline.social_auth.load_extra_data', 'awx.sso.pipeline.set_is_active_for_new_user', 'social.pipeline.user.user_details', 'awx.sso.pipeline.prevent_inactive_login', + 'awx.sso.pipeline.update_user_orgs', + 'awx.sso.pipeline.update_user_teams', ) SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = '' @@ -390,14 +393,6 @@ SOCIAL_AUTH_SAML_TECHNICAL_CONTACT = {} SOCIAL_AUTH_SAML_SUPPORT_CONTACT = {} SOCIAL_AUTH_SAML_ENABLED_IDPS = {} -SOCIAL_AUTH_SAML_ATTRS_PERMANENT_ID = "name_id" -SOCIAL_AUTH_SAML_ATTRS_MAP = { - "first_name": "User.FirstName", - "last_name": "User.LastName", - "username": "User.email", - "email": "User.email", -} - SOCIAL_AUTH_LOGIN_URL = '/' SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/sso/complete/' SOCIAL_AUTH_LOGIN_ERROR_URL = '/sso/error/' @@ -411,6 +406,9 @@ SOCIAL_AUTH_CLEAN_USERNAMES = True SOCIAL_AUTH_SANITIZE_REDIRECTS = True SOCIAL_AUTH_REDIRECT_IS_HTTPS = False +SOCIAL_AUTH_ORGANIZATION_MAP = {} +SOCIAL_AUTH_TEAM_MAP = {} + # Any ANSIBLE_* settings will be passed to the subprocess environment by the # celery task. diff --git a/awx/settings/local_settings.py.docker_compose b/awx/settings/local_settings.py.docker_compose index 37d69b7e09..27360a9ecd 100644 --- a/awx/settings/local_settings.py.docker_compose +++ b/awx/settings/local_settings.py.docker_compose @@ -525,8 +525,80 @@ SOCIAL_AUTH_SAML_ENABLED_IDPS = { # 'url': 'https://myidp.example.com/sso', # 'x509cert': '', #}, + #'onelogin': { + # 'entity_id': 'https://app.onelogin.com/saml/metadata/123456', + # 'url': 'https://example.onelogin.com/trust/saml2/http-post/sso/123456', + # 'x509cert': '', + # 'attr_user_permanent_id': 'name_id', + # 'attr_first_name': 'User.FirstName', + # 'attr_last_name': 'User.LastName', + # 'attr_username': 'User.email', + # 'attr_email': 'User.email', + #}, } +SOCIAL_AUTH_ORGANIZATION_MAP = { + # Add all users to the default organization. + 'Default': { + 'users': True, + }, + #'Test Org': { + # 'admins': ['admin@example.com'], + # 'users': True, + #}, + #'Test Org 2': { + # 'admins': ['admin@example.com', re.compile(r'^tower-[^@]+*?@.*$], + # 'users': re.compile(r'^[^@].*?@example\.com$'), + #}, +} + +#SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_SAML_ORGANIZATION_MAP = {} + +SOCIAL_AUTH_TEAM_MAP = { + #'My Team': { + # 'organization': 'Test Org', + # 'users': ['re.compile(r'^[^@]+?@test\.example\.com$')'], + # 'remove': True, + #}, + #'Other Team': { + # 'organization': 'Test Org 2', + # 'users': re.compile(r'^[^@]+?@test2\.example\.com$'), + # 'remove': False, + #}, +} + +#SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP = {} +#SOCIAL_AUTH_GITHUB_TEAM_MAP = {} +#SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP = {} +#SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP = {} +#SOCIAL_AUTH_SAML_TEAM_MAP = {} + +# Uncomment one or more of the lines below to prevent new user accounts from +# being created for the selected social auth providers. 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. + +#SOCIAL_AUTH_USER_FIELDS = [] +#SOCIAL_AUTH_GOOGLE_OAUTH2_USER_FIELDS = [] +#SOCIAL_AUTH_GITHUB_USER_FIELDS = [] +#SOCIAL_AUTH_GITHUB_ORG_USER_FIELDS = [] +#SOCIAL_AUTH_GITHUB_TEAM_USER_FIELDS = [] +#SOCIAL_AUTH_SAML_USER_FIELDS = [] + +# It is also possible to add custom functions to the social auth pipeline for +# more advanced organization and team mapping. Use at your own risk. + +#def custom_social_auth_pipeline_function(backend, details, user=None, *args, **kwargs): +# print 'custom:', backend, details, user, args, kwargs + +#SOCIAL_AUTH_PIPELINE += ( +# 'awx.settings.development.custom_social_auth_pipeline_function', +#) + ############################################################################### # INVENTORY IMPORT TEST SETTINGS ############################################################################### diff --git a/awx/settings/local_settings.py.example b/awx/settings/local_settings.py.example index b83f5751ff..5ac0dc8ef5 100644 --- a/awx/settings/local_settings.py.example +++ b/awx/settings/local_settings.py.example @@ -523,8 +523,80 @@ SOCIAL_AUTH_SAML_ENABLED_IDPS = { # 'url': 'https://myidp.example.com/sso', # 'x509cert': '', #}, + #'onelogin': { + # 'entity_id': 'https://app.onelogin.com/saml/metadata/123456', + # 'url': 'https://example.onelogin.com/trust/saml2/http-post/sso/123456', + # 'x509cert': '', + # 'attr_user_permanent_id': 'name_id', + # 'attr_first_name': 'User.FirstName', + # 'attr_last_name': 'User.LastName', + # 'attr_username': 'User.email', + # 'attr_email': 'User.email', + #}, } +SOCIAL_AUTH_ORGANIZATION_MAP = { + # Add all users to the default organization. + 'Default': { + 'users': True, + }, + #'Test Org': { + # 'admins': ['admin@example.com'], + # 'users': True, + #}, + #'Test Org 2': { + # 'admins': ['admin@example.com', re.compile(r'^tower-[^@]+*?@.*$], + # 'users': re.compile(r'^[^@].*?@example\.com$'), + #}, +} + +#SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP = {} +#SOCIAL_AUTH_SAML_ORGANIZATION_MAP = {} + +SOCIAL_AUTH_TEAM_MAP = { + #'My Team': { + # 'organization': 'Test Org', + # 'users': ['re.compile(r'^[^@]+?@test\.example\.com$')'], + # 'remove': True, + #}, + #'Other Team': { + # 'organization': 'Test Org 2', + # 'users': re.compile(r'^[^@]+?@test2\.example\.com$'), + # 'remove': False, + #}, +} + +#SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP = {} +#SOCIAL_AUTH_GITHUB_TEAM_MAP = {} +#SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP = {} +#SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP = {} +#SOCIAL_AUTH_SAML_TEAM_MAP = {} + +# Uncomment one or more of the lines below to prevent new user accounts from +# being created for the selected social auth providers. 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. + +#SOCIAL_AUTH_USER_FIELDS = [] +#SOCIAL_AUTH_GOOGLE_OAUTH2_USER_FIELDS = [] +#SOCIAL_AUTH_GITHUB_USER_FIELDS = [] +#SOCIAL_AUTH_GITHUB_ORG_USER_FIELDS = [] +#SOCIAL_AUTH_GITHUB_TEAM_USER_FIELDS = [] +#SOCIAL_AUTH_SAML_USER_FIELDS = [] + +# It is also possible to add custom functions to the social auth pipeline for +# more advanced organization and team mapping. Use at your own risk. + +#def custom_social_auth_pipeline_function(backend, details, user=None, *args, **kwargs): +# print 'custom:', backend, details, user, args, kwargs + +#SOCIAL_AUTH_PIPELINE += ( +# 'awx.settings.development.custom_social_auth_pipeline_function', +#) + ############################################################################### # INVENTORY IMPORT TEST SETTINGS ############################################################################### diff --git a/awx/settings/postprocess.py b/awx/settings/postprocess.py index cd8fcfbfa2..544758e04f 100644 --- a/awx/settings/postprocess.py +++ b/awx/settings/postprocess.py @@ -7,10 +7,10 @@ # settings as needed. if not AUTH_LDAP_SERVER_URI: - AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'awx.main.backend.LDAPBackend'] + AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'awx.sso.backends.LDAPBackend'] if not RADIUS_SERVER: - AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'awx.main.backend.RADIUSBackend'] + AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'awx.sso.backends.RADIUSBackend'] if not all([SOCIAL_AUTH_GOOGLE_OAUTH2_KEY, SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET]): AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'social.backends.google.GoogleOAuth2'] @@ -28,8 +28,7 @@ if not all([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]): - AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'awx.main.backend.SAMLAuth'] + AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'awx.sso.backends.SAMLAuth'] if not AUTH_BASIC_ENABLED: REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = [x for x in REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] if x != 'rest_framework.authentication.BasicAuthentication'] - diff --git a/awx/main/backend.py b/awx/sso/backends.py similarity index 87% rename from awx/main/backend.py rename to awx/sso/backends.py index 9920283d21..d033fcab9b 100644 --- a/awx/main/backend.py +++ b/awx/sso/backends.py @@ -17,13 +17,15 @@ from django_auth_ldap.backend import populate_user from radiusauth.backends import RADIUSBackend as BaseRADIUSBackend # social +from social.backends.saml import OID_USERID from social.backends.saml import SAMLAuth as BaseSAMLAuth from social.backends.saml import SAMLIdentityProvider as BaseSAMLIdentityProvider # Ansible Tower from awx.api.license import feature_enabled -logger = logging.getLogger('awx.main.backend') +logger = logging.getLogger('awx.sso.backends') + class LDAPSettings(BaseLDAPSettings): @@ -80,6 +82,7 @@ class LDAPBackend(BaseLDAPBackend): def get_group_permissions(self, user, obj=None): return set() + class RADIUSBackend(BaseRADIUSBackend): ''' Custom Radius backend to verify license status @@ -101,26 +104,31 @@ class RADIUSBackend(BaseRADIUSBackend): return None return super(RADIUSBackend, self).get_user(user_id) + class TowerSAMLIdentityProvider(BaseSAMLIdentityProvider): ''' - Custom Identity Provider to make attributes to what we expect + Custom Identity Provider to make attributes to what we expect. ''' def get_user_permanent_id(self, attributes): - return attributes[django_settings.SOCIAL_AUTH_SAML_ATTRS_PERMANENT_ID] + uid = attributes[self.conf.get('attr_user_permanent_id', OID_USERID)] + if isinstance(uid, basestring): + return uid + return uid[0] - def get_user_details(self, attributes): + def get_attr(self, attributes, conf_key, default_attribute): """ - Given the SAML attributes extracted from the SSO response, get - the user data like name. + Get the attribute 'default_attribute' out of the attributes, + unless self.conf[conf_key] overrides the default by specifying + another attribute to use. """ - attrs = dict() - for social_attr in django_settings.SOCIAL_AUTH_SAML_ATTRS_MAP: - map_attr = django_settings.SOCIAL_AUTH_SAML_ATTRS_MAP[social_attr] - attrs[social_attr] = unicode(attributes[map_attr][0]) if map_attr in attributes else None - if attrs[social_attr] is None: - logger.warn("Could not map SAML attribute '%s', update SOCIAL_AUTH_SAML_ATTRS_MAP with the correct value" % social_attr) - return attrs + key = self.conf.get(conf_key, default_attribute) + value = attributes[key][0] if key in attributes else None + if conf_key in ('attr_first_name', 'attr_last_name', 'attr_username', 'attr_email') and value is None: + logger.warn("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 unicode(value) if value is not None else value class SAMLAuth(BaseSAMLAuth): @@ -129,7 +137,6 @@ class SAMLAuth(BaseSAMLAuth): ''' def get_idp(self, idp_name): - """Given the name of an IdP, get a SAMLIdentityProvider instance""" idp_config = self.setting('ENABLED_IDPS')[idp_name] return TowerSAMLIdentityProvider(idp_name, **idp_config) @@ -155,6 +162,7 @@ class SAMLAuth(BaseSAMLAuth): return None return super(SAMLAuth, self).get_user(user_id) + def _update_m2m_from_groups(user, ldap_user, rel, opts, remove=False): ''' Hepler function to update m2m relationship based on LDAP group membership. @@ -179,6 +187,7 @@ def _update_m2m_from_groups(user, ldap_user, rel, opts, remove=False): elif remove: rel.remove(user) + @receiver(populate_user) def on_populate_user(sender, **kwargs): ''' diff --git a/awx/sso/middleware.py b/awx/sso/middleware.py index c261ab6502..012bcefd55 100644 --- a/awx/sso/middleware.py +++ b/awx/sso/middleware.py @@ -17,8 +17,10 @@ from social.exceptions import SocialAuthBaseException from social.utils import social_logger from social.apps.django_app.middleware import SocialAuthExceptionMiddleware +# Ansible Tower from awx.main.models import AuthToken + class SocialAuthMiddleware(SocialAuthExceptionMiddleware): def process_request(self, request): @@ -61,7 +63,7 @@ class SocialAuthMiddleware(SocialAuthExceptionMiddleware): if strategy is None or self.raise_exception(request, exception): return - if isinstance(exception, SocialAuthBaseException): + if isinstance(exception, SocialAuthBaseException) or request.path.startswith('/sso/'): backend = getattr(request, 'backend', None) backend_name = getattr(backend, 'name', 'unknown-backend') full_backend_name = backend_name diff --git a/awx/sso/pipeline.py b/awx/sso/pipeline.py index e11b115cd6..7000d050ee 100644 --- a/awx/sso/pipeline.py +++ b/awx/sso/pipeline.py @@ -1,17 +1,38 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. +# Python +import re + # Python Social Auth from social.exceptions import AuthException +# Tower +from awx.api.license import feature_enabled + + +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): - """Authentication for this user is forbidden""" 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 @@ -21,3 +42,96 @@ def set_is_active_for_new_user(strategy, details, user=None, *args, **kwargs): def prevent_inactive_login(backend, details, user=None, *args, **kwargs): if user and not user.is_active: raise AuthInactive(backend) + + +def _update_m2m_from_expression(user, rel, expr, remove=False): + ''' + 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, (basestring, type(re.compile('')))): + expr = [expr] + for ex in expr: + if isinstance(ex, basestring): + 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: + rel.add(user) + elif remove: + rel.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 + from awx.main.models import Organization + multiple_orgs = feature_enabled('multiple_organizations') + org_map = backend.setting('ORGANIZATION_MAP') or {} + for org_name, org_opts in org_map.items(): + + # Get or create the org to update. If the license only allows for one + # org, always use the first active org, unless no org exists. + if multiple_orgs: + org = Organization.objects.get_or_create(name=org_name)[0] + else: + try: + org = Organization.objects.filter(active=True).order_by('pk')[0] + except IndexError: + continue + + # Update org admins from expression(s). + remove = bool(org_opts.get('remove', False)) + admins_expr = org_opts.get('admins', None) + remove_admins = bool(org_opts.get('remove_admins', remove)) + _update_m2m_from_expression(user, org.admins, 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.users, 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 Organization, Team + multiple_orgs = feature_enabled('multiple_organizations') + team_map = backend.setting('TEAM_MAP') or {} + for team_name, team_opts in team_map.items(): + + # Get or create the org to update. If the license only allows for one + # org, always use the first active org, unless no org exists. + if multiple_orgs: + if 'organization' not in team_opts: + continue + org = Organization.objects.get_or_create(name=team_opts['organization'])[0] + else: + try: + org = Organization.objects.filter(active=True).order_by('pk')[0] + except IndexError: + continue + + # 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', False)) + _update_m2m_from_expression(user, team.users, users_expr, remove)