Multi-Tenant Grafana with DRF

While looking at Grafana Multi-Tenant arrangements, I realised it’s problematic to have a multi-tenant SQL database accessed by Grafana. One solution is to use the X-GRAFANA-USER. If we can trust this header it will allow our API to read it, and only give access to records matching (as defined by us) that user.

In postgres, there is per-row authorisation, but I’m not familiar with it and initally attempts at getting the header in a postgres procedure failed miserably.

Instead, since we have an API to this database already, let’s use json-datasource in Grafana (instead of postgres) to access the data.

See Django Object-level Permissions with DRF and Rules for background.

Django stuff

Unless you’re dumb like me, you started with a custom User. Let’s add a flag to that to mark grafana users:

# models.py
class MyCustomUser(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(verbose_name="email", unique=True, db_index=True)
    full_name = models.CharField(max_length=191, blank=True)
    ...
    # special access, just to API
    is_grafana = models.BooleanField(default=False)
    ...

Now we can write a custom Authentication class. We’ll base it on TokensAuthentication class, but keep that class to handle tokens seperately. We just use it to get the User object.


class GrafanaAuthentication(authentication.TokenAuthentication):
    """
    Grafana auth is a special case of Token Auth, where the X-Grafana-Header specifies
    an object the grafana user (ie. User.is_grafana==True) can read.
    we set auth.grafana_device for downstream permission classes to use
    e.g GrafanaDevicePermission
    """

    def authenticate(self, request):
        # normal Token auth to get the user
        user, auth = super().authenticate(request)
        if not user.is_grafana:
            return None  # None means 'didn't try to auth'

        if not (grafana_user := request.META.get("HTTP_X_GRAFANA_USER")):
            return None  # None means 'didn't try to auth'

        try:
            device = Device.objects.get(ref=grafana_user)
            # the guts: let the grafana user read this device (only)
            auth.grafana_device = (
                device
            )
        except Device.DoesNotExist:
            # None means 'didn't try to auth', but exception means "tried and failed"
            raise exceptions.AuthenticationFailed("No such device")

        return (user, auth)

So we will have a special ‘grafana’ user with a Token and .is_grafana checked. Grafana (i.e. DataProxy running in grafana-server) will make a request with this token. That ‘grafana’ user has no devices, but it’s header permits read access to the specified (via a string) device. The Grafana user (in Grafana) must match the Device for this to work. It’s relies on this convention. (More flexibility at the price of complexity: Django could store a mapping of grafana-user to devices-permitted. We don’t need this in our case.)

Right, at the moment, per-object permission will fail (see why) as ‘grafana’ user is not associated with any devices. So we write a custom Permission for DRF to use in the ModelView:

# rest/views.py
class DeviceViewSet(viewsets.ModelViewSet):
    ...
    permission_classes = [
        GrafanaDevicePermission | SomeOtherPermissions
    ]
# rest/permissions.py

class GrafanaDevicePermission(permissions.BasePermission):
    # Access to the table-level with this permission: read-only 
    def has_permission(self, request, view):
        return (
            request.user
            and request.user.is_authenticated
            and request.method in SAFE_METHODS
        )

    # Access to the object with this permission: only for
    # is_grafana users for the specific device, which was 
    # determined by X-GRAFANA_USER and stored request.auth
    # by GrafanaAuthentication
    # 
    def has_object_permission(self, request, view, obj):
        if not request.user:
            return False
        grafana_device = getattr(request.auth, "grafana_device", None)
        return (
            request.user.is_grafana
            and grafana_device
            and grafana_device == obj
        )

So to recap, when Grafana browser javascript want to see a Device’s details:

  • It calls DataProxy on grafana-server
  • grafana-server sets X-GRAFANA-HEADER to the Grafana username (like ${__user.login} but secure)
  • infinity-datasource connects to the DRF API using the Token specified to Grafana.
  • GrafanaDevicePermission reads the header and chooses which Device/s to allow access to. (In our case like-named Devices).
  • DRF calls the ModelView which has a Permissions class, GrafanaDevicePermission
  • This GrafanaDevicePermission compares the object the allowed Device/s and permits access based on that.
  • DRF serializes and returns the object as JSON.
  • We have a coffee.

Leave a Reply

Your email address will not be published. Required fields are marked *