Search code examples
djangodjango-channelsdjango-permissions

Django channels custom permissions system


So I have a system where users can be part of models called boxes through the model member.

Member models have their own set of roles which in turn have their own permissions.

I have specific methods which determine which set of permissions a member has in a box.

So now I have a websocket group named 'box_{box_id}' to which members can connect. Outbound events such as box related model creation are sent to this group.

However, some members should not listen to certain events sent based on the permissions they have.

This is a sample message that would be sent to the group which denotes an event {'event': EVENT TYPE, 'data': EVENT DATA}

So now, for example, an user cannot listen to the event with type UPLOAD_CREATE if he doesnt have READ_UPLOADS permissions in the box

How can I implement such checks using django channels?

EDIT

class LocalEventsConsumer(AsyncWebsocketConsumer):
    """
    An ASGI consumer for box-specific (local) event sending.
    Any valid member for the given box can connect to this consumer.
    """
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.box_id = self.scope['url_route']['kwargs']['box_id']
        self.events_group_name = 'box_%s_events' % self.box_id

        self.overwrites_cache = {}
        self.permissions_cache = set()
        # need to update cache on role and overwrite updates

    async def connect(self):
        try:
            # we cache the member object on connection
            # to help check permissions later on during
            # firing of events
            member_kwargs = {
                'user': self.scope['user'],
                'box__id': self.box_id,
            }
            self.member = api_models.Member.objects.get(**member_kwargs)
            self.permissions_cache = self.member.base_permissions
        except ObjectDoesNotExist:
            # we reject the connection if the
            # box-id passed in the url was invalid
            # or the user isn't a member of the box yet
            await self.close()

        await self.channel_layer.group_add(self.events_group_name, self.channel_name)
        await self.accept()

    async def disconnect(self, close_code):
        await self.channel_layer.group_discard(self.events_group_name, self.channel_name)

    async def fire_event(self, event: dict):
        member_permissions = self.get_event_permissions(event)
        required_permissions = event.pop('listener_permissions', set())
        if required_permissions in member_permissions:
            await self.send(event)

    def get_event_permissions(self, event):
        # handle permission caching throughout
        # the life of the user's connection
        overwrite_channel = event['data'].get('channel', None)
        overwrite_cache = self.overwrites_cache.get(overwrite_channel.id, None)
        if not overwrite_channel:
            # calculate overwrites if the event data at hand
            # has a channel attribute. We would need to calculate
            # overwrites only when channel-specific events are
            # triggered, like UPLOAD_CREATE and OVERWRITE_DELETE
            return self.permissions_cache
        if not overwrite_cache:
            overwrite_cache = self.member.permissions.get_overwrites(overwrite_channel)
            self.overwrites_cache[overwrite_channel.id] = overwrite_cache
        return overwrite_cache

    @receiver(post_delete, sender=api_models.MemberRole)
    @receiver(post_save, sender=api_models.MemberRole)
    def update_permissions_cache(self, instance=None, **kwargs):
        if instance.member == self.member:
            self.permissions_cache = self.member.base_permissions

    @receiver(post_delete, sender=api_models.Overwrite)
    @receiver(post_save, sender=api_models.Overwrite)
    def update_overwrites_cache(self, instance=None, **kwargs):
        overwrite_cache = self.overwrites_cache.get(instance.channel, None)
        if instance.role in self.member.roles.all() and overwrite_cache:
            self.overwrites_cache[instance.channel] = self.member.permissions.get_overwrites(instance.channel)

this is my current consumer. I use the fire_event type outside the consumer. However, everytime I need to get the permissions, I need to make a trip to the database. Therefore, I've implemented this permission caching system to mitigate the same. Should the same be altered?


Solution

  • You can check for these permissions in the method that sends the data to the client. Since they all belong to the same channel group, you cannot filter out at the level of sending to the group, at least to the best of my knowledge. So you can do something that like this:

    def receive(self, event):
        # update box
        ...
        # notify the members
        self.channel_layer.group_send(
            f'box_{self.box.id}', 
            {'type': 'notify_box_update', 'event': EVENT TYPE, 'data': EVENT DATA},
        )
    
    def notify_box_update(event):
        if has_permission(self.user, event['event'], self.box):
            self.send(event)
    

    Here, the notify event is sent to the group via the channel_layer but only users with the proper permission get it sent to them downstream. You can implement the has_permission method somewhere in your code to check for the permission given the user, box and event type.