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 aPermissions
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.