Django channels custom permissions system - django

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?

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.

Related

Django Channels Rest Framework V3 #model_observer message is None

I tried to find anything in the documentation of model observer which could explain under which conditions the model observer is called with message = None, but there was nothing particularly hinting into the right direction. From what I unterstand from the example:
class MyConsumer(GenericAsyncAPIConsumer):
queryset = User.objects.all()
serializer_class = UserSerializer
#model_observer(Comments)
async def comment_activity(
self,
message: CommentSerializer,
observer=None,
subscribing_request_ids=[]
**kwargs
):
await self.send_json(message.data)
#comment_activity.serializer
def comment_activity(self, instance: Comment, action, **kwargs) -> CommentSerializer:
'''This will return the comment serializer'''
return CommentSerializer(instance)
#action()
async def subscribe_to_comment_activity(self, request_id, **kwargs):
await self.comment_activity.subscribe(request_id=request_id)
The message should be the output of the CommentSerializer so the only way a message to become null is, if the serializer would return nothing ?!
To check if this is the case I added logs prints which sadly indicate that the serializer is receiving data and thous there is no reason the message could be None.
Its also occurring only from time to time so any advice about misunderstanding of how the observer works internally will be highly appreciated.
In our implementation:
class StorageConsumer(ListModelMixin, AsyncAPIConsumer):
"""
Async API endpoint that allows Storage updates to be pushed.
"""
async def accept(self, **kwargs):
await super().accept()
await self.model_change_bess.subscribe()
#model_observer(Bess)
async def model_change_bess(self, message, **kwargs):
if message is None:
logger.warning(f"model_change_bess change without a message")
return
message["model"] = kwargs["observer"].model_cls.__name__
await self.send_json(message)
#model_change_bess.serializer
def model_serialize(self, instance, action, **kwargs):
data = BessSerializer(instance).data
# TODO remove once the message is None bug is fixed
logger.info(f"model_serialize bess instance: {instance} data: {data}")
serialized_data = {
"project_id": data["project"],
"simulation_id": data["simulation"],
"data": data,
}
return serialized_data
the corresponding logs in case message is None looks like this:
16/06/2022 13:09:25 [INFO] api_consumers: model_serialize bess instance: Bess object (37227) data: {'id': 37227, 'created': '2022-06-16T13:09:25Z', 'modified': '2022-06-16T13:09:25Z', 'time_stamp': '2022-06-16T13:09:25Z', 'cycles': '0.02541166724274896000000000000000', 'p_bess': '-74.0000', 'soc': '0.45109075850806520000000000000000', 'soh': '0.99514591796314000000000000000000', 'temperature_bess': None, 'time_to_eol': None, 'feedback': True, 'project': 1, 'simulation': 1}
16/06/2022 13:09:25 [WARNING] api_consumers: model_change_bess change without a message

django-channels add user to multiple groups on connect

So I have my models like this.
class Box(models.Model):
objects = models.Manager()
name = models.CharField(max_length=100)
owner = models.ForeignKey('users.User', on_delete=models.CASCADE)
REQUIRED_FIELDS = [name, owner, icon]
class User(AbstractBaseUser):
objects = BaseUserManager()
email = models.EmailField(unique=True)
username = models.CharField(max_length=32, validators=[MinLengthValidator(2)], unique=True)
avatar = models.ImageField(upload_to='avatars/', default='avatars/default.jpg')
REQUIRED_FIELDS = [username, email]
class Member(models.Model):
objects = models.Manager()
box = models.ForeignKey('uploads.Box', on_delete=models.CASCADE, editable=False)
user = models.ForeignKey('users.User', on_delete=models.CASCADE, editable=False)
roles = models.ManyToManyField('roles.Role', through='roles.MemberRole')
invite = models.ForeignKey('users.Invite', null=True, blank=True, on_delete=models.CASCADE)
REQUIRED_FIELDS = [box, user]
I have a websockets framework with routing like this.
websocket_urlpatterns = [
path('gateway/', GatewayEventsConsumer.as_asgi()),
]
class GatewayEventsConsumer(AsyncWebsocketConsumer):
"""
An ASGI consumer for gateway event sending. Any authenticated
user can connect to this consumer. Users receive personalized events
based on the permissions they have.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
async def connect(self):
user = self.scope['user']
if user.is_anonymous:
# close the connection if the
# user isn't authenticated yet
await self.close()
for member in user.member_set.all():
# put user into groups according to the boxes
# they are a part of. Additional groups would be
# added mid-way if the user joins or creates
# a box during the lifetime of this connection
await self.channel_layer.group_add(member.box.id, self.channel_name)
await self.channel_layer.group_add(self.scope['user'].id, self.channel_name)
await self.accept()
async def disconnect(self, close_code):
for member in self.scope['user'].member_set.all():
# remove user from groups according to the boxes they
# are a part of. Additional groups would be
# removed mid-way if the user leaves or gets kicked
# out of a box during the lifetime of this connection
await self.channel_layer.group_discard(member.box.id, self.channel_name)
await self.channel_layer.group_discard(self.scope['user'].id, self.channel_name)
async def fire_event(self, event: dict):
formatted = {
'data': event['data'],
'event': event['event'],
}
box = event.get('box', None)
channel = event.get('overwrite_channel', None)
listener_perms = event.get('listener_permissions', [])
if not listener_perms or not box:
# box would be none if the event was user-specific
# don't need to check permissions. Fan-out event
# directly before checking member-permissions
return await self.send(text_data=json.dumps(formatted))
member = self.scope['user'].member_set.get(box=box)
if listener_perms in member.get_permissions(channel):
# check for permissions directly without any extra context
# validation. Because data-binding is outbound permission
# checking is not complex, unlike rest-framework checking
await self.send(text_data=json.dumps(formatted))
this is how I send ws messages.
(using django signals)
#receiver(post_delete, sender=Upload)
def on_upload_delete(instance=None, **kwargs) -> None:
async_to_sync(channel_layer.group_send)(
instance.box.id,
{
'type': 'fire_event',
'event': 'UPLOAD_DELETE',
'listener_permissions': ['READ_UPLOADS'],
'overwrite_channel': instance.channel,
'box': instance.box,
'data': PartialUploadSerializer(instance).data
}
)
The api needs to send box-specific events, so I have different groups for boxes.
Users which connect to these groups will receive the events they need.
So, when the user connects to the "gateway", I add the user to all the boxes they are a part of, (plus a private group to send user-specific information)
On disconnect, I remove them from the same.
However, I am facing issues here.
An example,
when an user joins a box during the scope of the connection, they would not receive the events that are being sent for that particular box.
when an user leaves a box during the scope of the connection, they would still receive events from that particular box.
Any ways to fix these issues?
relevant github discussion is here.
You can do this by adding 2 handlers similar to fire-event.
The first one adds a user to a group
The second one deletes a user from a group.
Then using Django Signals, send a websocket message to those handlers whenever a user becomes a box member or leaves the box

django channels group_send not working suddenly

I have recently converted my existing django project to have a react front end. However , im facing the issue whereby the backend of my django-channels is not working. The issue im having is that the group_send method is not working in my case , as such the consumer does not receive the information generated by a signal in my models. Here is my code :
consumers.py
class NotificationConsumer (AsyncJsonWebsocketConsumer):
async def connect (self):
close_old_connections()
user = self.scope["user"]
if not user.is_anonymous:
await self.accept()
#connects the user to his/her websocket channel
group_name = "notifications_{}".format(str(user))
print(group_name)
await self.channel_layer.group_add(group_name, self.channel_name)
async def disconnect (self, code):
close_old_connections()
user = self.scope["user"]
if not user.is_anonymous:
#Notifications
notifications_group_name = "notifications_{}".format(str(user))
await self.channel_layer.group_discard(notifications_group_name, self.channel_name)
async def user_notification (self, event):
close_old_connections()
print('Notification recieved by consumer')
await self.send_json({
'event': 'notification',
'data': {
'event_type': 'notification',
'notification_pk': event['notification_pk'],
'link': event['link'],
'date_created': event['date_created'],
'object_preview': event['object_preview'],
}
})
print(event)
Models/signals
def ChannelNotification(sender, instance, created, **kwargs):
if created:
channel_layer = get_channel_layer()
print(channel_layer)
group_name = "notifications_{}".format(str(instance.target))
print('inside channel signal')
print(group_name)
async_to_sync(channel_layer.group_send)(
group_name, {
"type": "user_notification",
"notification_pk": instance.pk,
"link": instance.object_url,
"date_created": instance.time.strftime("%Y-%m-%d %H:%M:%S"),
"object_preview": instance.object_preview,
}
)
Whenever an notification object is created in the database , the signal will be sent to my ChannelNotification which receives this information and transmit it to the consumer via group send . It used to work perfectly but im not sure what happened after i converted my project to have react as a frontend.

Update tasks in Celery with RabbitMQ

I'm using Celery in my django project to create tasks to send email at a specific time in the future. User can create a Notification instance with notify_on datetime field. Then I pass value of notify_on as a eta.
class Notification(models.Model):
...
notify_on = models.DateTimeField()
def notification_post_save(instance, *args, **kwargs):
send_notification.apply_async((instance,), eta=instance.notify_on)
signals.post_save.connect(notification_post_save, sender=Notification)
The problem with that approach is that if notify_on will be changed by the user, he will get two(or more) notifications instead of one.
The question is how do I update the task associated with a specific notification, or somehow delete the old one and create new.
First of all, by using post_save, we can't fetch the old data. So, here I'm overriding the save() method of the Notification model. Apart from that,create a field to store the celery task_id.
from celery.task.control import revoke
class Notification(models.Model):
...
notify_on = models.DateTimeField()
celery_task_id = models.CharField(max_length=100)
def save(self, *args, **kwargs):
pre_notify_on = Notification.objects.get(pk=self.pk).notify_on
super().save(*args, **kwargs)
post_notify_on = self.notify_on
if not self.celery_task_id: # initial task creation
task_object = send_notification.apply_async((self,), eta=self.notify_on)
Notification.objects.filter(pk=self.pk).update(celery_task_id=task_object.id)
elif pre_notify_on != post_notify_on:
# revoke the old task
revoke(self.celery_task_id, terminate=True)
task_object = send_notification.apply_async((self,), eta=self.notify_on)
Notification.objects.filter(pk=self.pk).update(celery_task_id=task_object.id)
Reference
Cancel an already executing task with Celery?
Django: How to access original (unmodified) instance in post_save signal
I think there is no need for deleting the previous tasks. You just have to validate that the task that is executing is the lasted one. For that create a new field called checksum that is a UUID field update that field everytime you change notify_on. Check this checksum in the task where you are sending the email.
class Notification(models.Model):
checksum = models.UUIDField(default=uuid.uuid4)
notify_on = models.DateTimeField()
def notification_post_save(instance, *args, **kwargs):
send_notification.apply_async((instance.id, str(instance.checksum)),eta=instance.notify_on)
signals.post_save.connect(notification_post_save, sender=Notification)
#shared_task
def send_notification(notification_id, checksum):
notification = Notification.objects.get(id=notification_id)
if str(notification.checksum) != checksum:
return False
#send email
Also please don't send signal everytime on notification object save just send this when notify_on changes. You can also check this
Identify the changed fields in django post_save signal

django-fsm: Permissions not raising exception

I've got source and target rule-based transition decorators working well in django-fsm (Finite State Machine). Now I'm trying to add permissions handling. This seems straightforward, but it seems that no matter what I do, the transition is executed, regardless the user's permissions or lack thereof. I've tried with Django permission strings, and I've tried with lambda, per the documentation. I've tried all of these:
#transition(field=state, source='prog', target='appr', permission='claims.change_claim')
and
#transition(field=state, source='prog', target='appr', permission=lambda instance, user: not user.has_perm('claims.change_claim'),)
and, just as a double-check, since permission should respond to any callable returning True/False, simply:
#transition(field=state, source='prog', target='appr', permission=False)
def approve(self):
Which should raise a TransitionNotAllowed for all users when accessing the transition. But nope - even basic users with no permissions can still execute the transition (claim.approve()).
To prove that I've got permission string right:
print(has_transition_perm(claim.approve, request.user))
prints False. I am doing validation as follows (works for source/target):
class ClaimEditForm(forms.ModelForm):
'''
Some users can transition claims through allowable states
'''
def clean_state(self):
state = self.cleaned_data['state']
if state == 'appr':
try:
self.instance.approve()
except TransitionNotAllowed:
raise forms.ValidationError("Claim could not be approved")
return state
class Meta:
model = Claim
fields = (
'state',
)
and the view handler is the standard:
if request.method == "POST":
claim_edit_form = ClaimEditForm(request.POST, instance=claim)
if claim_edit_form.is_valid(): # Validate transition rules
What am I missing? Thanks.
The problem turned out to be that the permission property does validation differently from the source/target validators. Rather than the decorator raising errors, you must evaluate the permissions established in the decorator elsewhere in your code. So to perform permission validation from a form, you need to pass in the user object, receive user in the form's init, and then compare against the result of has_transition_perm. So this works:
# model
#transition(field=state, source='prog', target='appr', permission='claims.change_claim')
def approve(self):
....
# view
if request.method == "POST":
claim_edit_form = ClaimEditForm(request.user, request.POST, instance=claim)
....
# form
from django_fsm import has_transition_perm
class ClaimEditForm(forms.ModelForm):
'''
Some users can transition claims through allowable states
(see permission property on claim.approve() decorator)
'''
def __init__(self, user, *args, **kwargs):
# We need to pass the user into the form to validate permissions
self.user = user
super(ClaimEditForm, self).__init__(*args, **kwargs)
def clean_state(self):
state = self.cleaned_data['state']
if state == 'appr':
if not has_transition_perm(self.instance.approve, self.user):
raise forms.ValidationError("You do not have permission for this transition")