Django inconsistent responses with graphene-django and django-multitenant custom set_current_tenant middleware

65 Views Asked by At

I've a django-multitenant SAAS with this tenant model:

class Company(TenantModel):
    name = models.CharField(
        max_length=100,
        unique=True
    )
    slug = AutoSlugField(
        populate_from='name'
    )

    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        verbose_name_plural = 'Companies'

    class TenantMeta:
        tenant_field_name = 'id'

    def __str__(self) -> str:
        return self.name

I've setup a GraphQL api using graphene-django

In my User model I've a ManyToManyField so the user can manage one or more companies.

The goal is to have a company selector in the UI so the user can decide which company to manage.

In order to accomplish that, I'm sending the tenant_id in the request headers and using a middleware (as suggested in the documentation) to execute the set_tenant_id() function.

After trying many things I ended up with a setup that works perfectly in localhost:

class SetTenantFromHeadersMiddleware:
    """
    This Middleware works together with the next one (MultitenantGraphQLMiddleware).
    This one is responsible for setting the current tenant if the header was provided, this logic is here
    because for some reason in the MultitenantGraphQLMiddleware things differ as that's a GraphQL middleware:
    It gets executed once per field in the request and was randomly raising Company.DoesNotExist exception.
    """

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.path == '/graphql':
            tenant_header = request.META.get('HTTP_N1_TENANT', None)
            if tenant_header:
                _, company_id = from_global_id(tenant_header)
                try:
                    company = Company.objects.get(id=company_id)
                    set_current_tenant(company)
                except Company.DoesNotExist:
                    # This will be handled in MultitenantGraphQLMiddleware by validating there's a
                    # current tenant before resolving. It's safe to pass at this point.
                    pass

        response = self.get_response(request)

        return response


class MultitenantGraphQLMiddleware:
    """
    This middleware is responsible for validating there's a current tenant or selecting default
    from user's companies.
    """
    @property
    def safe_mutations(self) -> list[str]:
        return [
            CreateCompany.__name__,
            'TokenAuth',
            'RefreshToken',
        ]

    @property
    def safe_queries(self) -> list[str]:
        return ['Me']

    def is_safe_operation(self, operation) -> bool:
        is_mutation = operation.operation == OperationType.MUTATION
        is_safe_mutation = is_mutation and operation.name.value in self.safe_mutations
        is_query = operation.operation == OperationType.QUERY
        is_safe_query = is_query and operation.name.value in self.safe_queries
        return is_safe_mutation or is_safe_query

    def resolve(self, next, root, info, **kwargs):
        if self.is_safe_operation(info.operation):
            set_current_tenant(None)
            return next(root, info, **kwargs)

        if not hasattr(info.context, '_tenant_verified'):
            current_tenant = get_current_tenant()
            if current_tenant:
                if current_tenant.id not in info.context.user.companies.all().values_list('id', flat=True):
                    raise Exception('Provided company ID does not belong to authenticated user')
            else:
                company = info.context.user.default_company
                if not company:
                    raise Exception('Cannot execute GraphQL operation as there is no current tenant')
                set_current_tenant(company)

        return next(root, info, **kwargs)

In settings.py:

...

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'corsheaders.middleware.CorsMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'django_multitenant.middlewares.MultitenantMiddleware',
    'api.middleware.SetTenantFromHeadersMiddleware',
]

...

GRAPHENE = {
    'SCHEMA': 'api.schema.schema',
    'MIDDLEWARE': [
        'api.middleware.MultitenantGraphQLMiddleware',
        'api.middleware.LoginRequiredMiddleware',
        'graphql_jwt.middleware.JSONWebTokenMiddleware',
    ]
}

...

After deploying changes to the server (running the application inside a Docker container with uwsgi) it behaves in a very weird way by retrieving objects associated to another company or an empty list. That happens randomly. This works perfectly in localhost.

For example when fetching transactions, this happens:

Postman GraphQL empty list

The immediate next request:

Postman GraphQL transactions

0

There are 0 best solutions below