CNK's Blog

User and Group Management for Multitenancy

Once we set up a site, we want to hand over all control to the site admin. This means that they need to be able to add users and give them permissions to do things on their site (and only their site). In Wagtail this means a site admin, needs to be able to create and delete users and assign them to predefined groups.

Groups

Wagtail expects permissions to be assigned to users via groups. Users belong to groups and groups come with a set of permissions. Wagtail has a UI for creating groups and editing the permissions but we don’t use it (except sometimes for trouble shooting or on-off groups for Privacy options). The script we use to create a new site creates a standard set of groups (Admin, Editor, and Viewer) and assigns each a specific set of permissions (or in the case of Viewer, no admin privileges at all). Obviously we can’t have 500 groups named Admin so the machine names of these groups are each prefixed with the site’s hostname, e.g. “foo.localhost Admin”.

So far this is all pretty straightforward - except for the fact that there isn’t actually a foreign key relationship between sites and groups. The relationship between sites and groups is entirely based on the group name starting with the site’s hostname. Since we use code to create (and delete) sites, we haven’t had any problems with the lack of database integrity constraints. But that is something one might want to change if you were building a multitenant system where the sites and their associated objects were not so rigidly defined.

The only other down side of the hostname prefix is that is a bit ugly. We prefer our site owners see just “Admin” or “Editor”. So any place they would see the group name, we remove the hostname prefix. The main places that happens are the user forms discussed below and in the privacy restrictions forms (which we already customize to add an additional option). In the initialization method of our user forms, we call the following method to configure the groups section of the form. It takes care of filtering the allowed groups by site and cleaning up the displayed names.

    def configure_shared_fields(self):
        error_messages = self.fields['groups'].error_messages.copy()
        if not self.request.user.is_superuser:
            # Only superusers may grant superuser status to other users.
            self.fields.pop('is_superuser')

            # Site admins MUST assign at least one Group. Replace the messages with ones tailored to them.
            self.fields['groups'].required = True
            error_messages['required'] = self.error_messages['group_required']
            self.fields['groups'].help_text = "A user's groups determine their permissions within the site."

            site = Site.find_for_request(self.request)
            # Non-superusers are allowed to see only the Groups that belong to the current Site.
            # This also reduces the displayed Group name from e.g. "hostname.example.com Admins" to just "Admins".
            self.fields['groups'].choices = (
                (g.id, g.name.replace(site.hostname, '').strip())
                for g
                in Group.objects.filter(name__startswith=site.hostname)
            )
            # Changing the queryset alone isn't sufficient to change the available choices on the form (the "choices"
            # setting was created during the field's init). But we have to change the queryset anyway because it's
            # what the validation code uses to determine if the specified inputs are valid choices.
            self.fields['groups'].queryset = Group.objects.filter(name__startswith=site.hostname)
        else:
            self.fields['groups'].help_text = """Normal users require a Group. However, superusers should NOT be
                in any Groups. Thus, this field is required only when the Superuser checkbox is unchecked."""
            # Replace the "Required" error message with one tailored to superusers.
            error_messages['required'] = self.error_messages['group_required_superuser']
        self.fields['groups'].error_messages = error_messages

Users

I have said that I want a user to see completely different content when logging into Site A vs into Site B. One way to handle that would be to create separate users on the 2 sites. Problem solved, eh? However, most of our services are set up so you can use a standard set of credentials everywhere. Not only is this more convenient for our users (fewer passwords to track) but, since we are using a central store to check usernames and passwords, we can automatically disable access when someone leaves. So we need to use a single user record across all sites.

We need to use a single user record across sites. But we also have to enforce our strict site separation which means we can’t let Site A know that a user also has permissions on Site B. So we built our own user management views. In addition to allowing the views to behave differently when one is logged in as a superuser vs logged in as the admin of a site, it also made it easy for us to combine the steps of creating a user and assigning them to groups.

When adding new users to a site, we enter their username or name into a search which searches for an active account and returns the information we need to create a user record. Then we present a list of groups you can assign to this user. For site admins, this list only only shows the groups for the current site. But what if that user is already an Editor on some other site? If we just saved the form as is, first, that user will already exist in our system, so our “create” form actually needs to be an edit form. And more importantly, we need to not lose the group mappings that already exist - even though they were not included in the form data.

    def save(self, commit=True):
        """
        If a Django User with this username already exists, pull it from the DB and add the specified Groups to it,
        instead of creating a new User object.
        """
        user = super().save(commit=False)
        # Users can access django-admin iff they are a superuser.
        user.is_staff = user.is_superuser

        # Autocompleter note: The "LDAP User" field is actually an autocompleter for ContactInformation objects.
        # Since the form doesn't have a username field, we need to set this manually.
        user.username = self.cleaned_data['contact_info'].uid
        groups = Group.objects.filter(pk__in=self.cleaned_data['groups']).all()
        logger_extras = {
            'target_user': user.username,
            'target_user_superuser': user.is_superuser,
            'groups': ", ".join(str(g) for g in groups)
        }
        try:
            existing_user = get_user_model().objects.get(username=user.username)
        except get_user_model().DoesNotExist:
            existing_user = False
            # This is a brand new LDAPUser, so it needs to have its Django password made unusable, ensuring the user
            # can only log in via their LDAP credentials. We do this here so that the password doesn't change when an
            # LDAPUser is "added", but it already exists and is actually just being placed in a new Site's Group(s).
            user.set_unusable_password()
        else:
            user = existing_user
            # Don't bulk add groups or we disrupt existing group mappings. Add individual groups from the form
            for group in groups:
                user.groups.add(group)

            # Set the is_superadmin flag to True if the form data says to. This is necessary only
            # when there's an existing user because unlike 'user', 'existing_user' will not have had
            # these flags set by super().save(commit=False). We don't just set the flag to the form
            # value, because we never want to remove is_superadmin when this code gets executed.
            if self.cleaned_data.get('is_superadmin'):
                user.is_superadmin = True
        if existing_user:
            logger_extras['target_user_id'] = user.id

        # Populate the identifying information for the User form the ContactInformation object.
        user.first_name = self.cleaned_data['contact_info'].first_name
        user.last_name = self.cleaned_data['contact_info'].last_name
        user.email = self.cleaned_data['contact_info'].email

        if commit:
            user.save()
            if not existing_user:
                # Only call save_m2m() if we're not updating an existing User. It'll try to overwrite the updated
                # user.groups list, AND it'll crash for some reason I haven't figured out.
                self.save_m2m()
                logger.info('user.ldap.create', **logger_extras)
            else:
                logger.info('user.ldap.update', **logger_extras)
        return user

The clean and save methods in the edit form is a bit more straightforward because we know that we already have a user. But we still have to do a little fooling around with the form data because site admins can only edit users who belong to their site - which means those users must be assigned to one of the site’s groups. And site admins can’t change a user’s superuser or superadmin attributes.

    def clean(self):
        super().clean()

        # Remove any data about groups that may have been included in the form. We need to apply changes to Groups
        # manually, due to how non-superusers get presented with the Groups list.
        if not self.request.user.is_superuser:
            self.new_groups = self.cleaned_data.get('groups', [])
            with suppress(KeyError):
                del self.cleaned_data['groups']
            with suppress(ValueError):
                self.changed_data.remove('groups')

        # Superusers are allowed to be ungrouped.
        if self.cleaned_data.get('is_superuser'):
            if self.errors.get('groups') and self.errors['groups'][0] == self.error_messages['group_required']:
                del self.errors['groups']
                # The clean_groups() function removed self.cleaned_data['groups'] due to the error, but that will cause
                # the groups list to go unchanged upon save. So we need to set it to empty list.
                self.cleaned_data['groups'] = []

        # A superuser must assign a User to a Group, and/or set that User as a Superuser or a Super Admin.
        # If they don't do at least one, throw an informative error.
        if (
            self.request.user.is_superuser and
            not (self.cleaned_data.get('is_superuser') or self.cleaned_data.get('is_superadmin'))
            and not self.cleaned_data.get('groups')
        ):
            self.add_error('groups', forms.ValidationError(self.error_messages['group_required_superuser']))


    def save(self, commit=True):
        """
        In case the data in LDAP has changed, or it failed to populate on the previous create/edit, we override save()
        to re-populate this User's personal info from LDAP.
        """
        user = super().save(commit=False)
        # Users can access django-admin iff they are a superuser.
        user.is_staff = user.is_superuser
        populate_user_from_contact_info(user)
        if commit:
            user.save()
            self.save_m2m()
            if self.has_changed():
                logger_extras = {
                    'target_user': user.username,
                    'target_user_superuser': user.is_superuser,
                }
                for field_name in self.changed_data:
                    logger_extras[field_name] = self.cleaned_data[field_name]
                logger.info('user.ldap.update', **logger_extras)

            # Rather than setting the User's entire Groups list to just what's in this form's POST data, we must ensure
            # that only those Groups which belong to the current Site are affected (unless the current user is a
            # superuser), because Groups on other Sites will never be included in the POST data.
            # So, we check if the list of current-Site-Groups that the user belongs to differs from the list that was
            # set in the form.
            existing_local_groups = user.groups.filter(name__startswith=self.site.hostname).all()
            if not self.request.user.is_superuser and set(existing_local_groups) != set(self.new_groups):
                # If changes were made to the user's Groups, remove the old list of current-Site-groups
                # and apply the new one.
                user.groups.remove(*existing_local_groups)
                user.groups.add(*self.new_groups)

        return user

When logged in as a superuser, one sees a more normal list view - showing all users with their name and the full list of groups they belong to. And the superuser’s create/edit forms have all the form fields including is_superadmin and is_superuser. The one minor annoyance for the superuser forms is that the group list can get kind of out of hand since it displays all the groups. We never log into any of the sites as root unless we are creating a new site or doing some troubleshooting so we have never done anything about this.