CNK's Blog

Multitenancy with Wagtail

If you want to run several sites from the same Wagtail codebase, you have a couple of options which are summarized in the Wagtail docs.

Wagtail fully supports “multi-site” installations where “where content creators go into a single admin interface and manage the content of multiple websites”. But at work, we would like our Wagtail installation to treat every site as if it were completely independent. So if you have permissions on Site A and Site B, when you’re logged in to Site A, you should only see content, images, etc. from Site A. We also want site owners to be able to manage just about everything for their site. This means that they need to be able to configure their own site’s settings, manage their own collections, images, and documents and manage their own users. This series of blog posts will cover the changes we have made to enforce our version of multitenancy for sites built with the Wagtail CMS.

  1. Monkey Patching Wagtail
  2. Permission Patches for Multitenancy
  3. Users and Groups
  4. Site Creator
  5. Snippets
  6. Snippet Choosers
  7. Reports

These posts were originally written describing our patches while running Wagtail 5.1 (and Django 3.2). I have subsequently updated them for additional patches I made to upgrade to Wagtail 6.0 (and Django 4.2).

Determining the current site

When we first started using Wagtail, it included its own site middleware so request.site was available in all views. When this was removed in Wagtail 2.9, we started using CRequestMiddleware to make the request information available from a variety of contexts. We generally access the request via our own get_current_request method which allows us to provide a useful error message if the request is not available.

    def get_current_request(default=None, silent=True, label='__DEFAULT_LABEL__'):
        """
        Returns the current request.

        You can optionally use ``default`` to pass in a fake request object to act as the default if there
        is no current request, e.g. when ``get_current_request()`` is called during a manage.py command.

        :param default: (optional) a fake request object
        :type default: an object that emulates a Django request object

        :param silent: If ``False``, raise an exception if CRequestMiddleware can't get us a request object.  Default: True
        :type silent: boolean

        :param label: If ``silent`` is ``False``, put this label in our exception message
        :type label: string

        :rtype: a Django request object
        """
        request = CrequestMiddleware.get_request(default)
        if request is None and not silent:
            raise NoCurrentRequestException(
                "{} failed because there is no current request. Try using djunk.utils.FakeCurrentRequest.".format(label)
            )
        return request

NOTE: get_current_request has a parameter for setting a default site if none is available when the method is called but in practice we never provide a default site in code that is trying to access the request. Instead we use one of the methods below to fake the request and then let get_current_request use that to determine the site.

Setting current site in scripts and tests

Our data imports, manage.py scripts, and tests do not have a browser context, so get_current_request will fail in those circumstances. We have created a couple of methods to help set the request and site in those circumstances. This is working but it remains a bit of a pain point.

    class FakeRequest:
        """
        FakeRequest takes the place of the django HTTPRequest object in various testing scenarios where
        a real one doesn't exist, but the code under test expects one to be there.

        Wagtail 2.9 now determines the current Site by looking at the hostname and port in the request object,
        which means it calls get_host() on our faked out requests. Thus, we need to emulate it.
        """

        def __init__(self, site=None, user=None, **kwargs):
            self.user = user
            # Include empty GET and POST attrs, so code which expects request.GET or request.POST to exist won't crash.
            self.GET = self.POST = {}
            # Callers can override GET and POST, or override/add any other attribute using kwargs.
            self.__dict__.update(kwargs)
            self._wagtail_site = site

        def get_host(self):
            if not self._wagtail_site:
                return 'fakehost'
            return self._wagtail_site.hostname

        def get_port(self):
            # It should be safe to pretend all test traffic is on port 443.
            # HTTPRequest.get_port() explicitly returns a string, so we do, too.
            return '443'


    def set_fake_current_request(site=None, user=None, request=None, **kwargs):
        """
        Sets the current request to either a specified request object or a FakeRequest object built from the given Site
        and/or User. Any additional keyword args are added as attributes on the FakeRequest.
        """
        # If the caller didn't provide a request object, create a FakeRequest.
        if request is None:
            request = FakeRequest(site, user, **kwargs)
        # Set the created (or provided) request as the "current request".
        CrequestMiddleware.set_request(request)
        return request


    class FakeCurrentRequest():
        """
        Implements set_fake_current_request() as a context manager. Use like this:
        with FakeCurrentRequest(some_site, some_user):
            // .. do stuff
        OR
        with FakeCurrentRequest(request=some_request):
            // .. do stuff

        When the context manager exits, the current request will be automatically reverted to its previous state.
        """
        NO_CURRENT_REQUEST = 'no_current_request'

        def __init__(self, site=None, user=None, request=None, **kwargs):
            self.site = site
            self.user = user
            self.request = request
            self.kwargs = kwargs

        def __enter__(self):
            # Store a copy of the original current request, so we can restore it when the context manager exits.
            self.old_request = CrequestMiddleware.get_request(default=self.NO_CURRENT_REQUEST)
            return set_fake_current_request(self.site, self.user, self.request, **self.kwargs)

        def __exit__(self, *args):
            if self.old_request == self.NO_CURRENT_REQUEST:
                # If there wasn't a current request when we entered the contact manager, remove the current request.
                CrequestMiddleware.del_request()
            else:
                # Otherwise, set the current request back to whatever it was when we entered.
                CrequestMiddleware.set_request(self.old_request)