From 8d1c02dcb99944e38dd69e79f2ae088871811990 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Wed, 20 May 2020 14:30:09 +0300 Subject: [PATCH] Collection invitation: implement creating and manipulating collections invitations. --- .../migrations/0003_collectioninvitation.py | 31 +++++++++++++++ .../0004_collectioninvitation_version.py | 18 +++++++++ django_etesync/models.py | 22 +++++++++++ django_etesync/serializers.py | 39 +++++++++++++++++++ django_etesync/views.py | 36 ++++++++++++++++- 5 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 django_etesync/migrations/0003_collectioninvitation.py create mode 100644 django_etesync/migrations/0004_collectioninvitation_version.py diff --git a/django_etesync/migrations/0003_collectioninvitation.py b/django_etesync/migrations/0003_collectioninvitation.py new file mode 100644 index 0000000..3880a63 --- /dev/null +++ b/django_etesync/migrations/0003_collectioninvitation.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.3 on 2020-05-20 11:03 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('django_etesync', '0002_userinfo'), + ] + + operations = [ + migrations.CreateModel( + name='CollectionInvitation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.CharField(db_index=True, max_length=44, validators=[django.core.validators.RegexValidator(message='Expected a 256bit base64url.', regex='^[a-zA-Z0-9\\-_]{43}$')])), + ('signedEncryptionKey', models.BinaryField()), + ('accessLevel', models.CharField(choices=[('adm', 'Admin'), ('rw', 'Read Write'), ('ro', 'Read Only')], default='ro', max_length=3)), + ('fromMember', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_etesync.CollectionMember')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incoming_invitations', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'fromMember')}, + }, + ), + ] diff --git a/django_etesync/migrations/0004_collectioninvitation_version.py b/django_etesync/migrations/0004_collectioninvitation_version.py new file mode 100644 index 0000000..3fbaed9 --- /dev/null +++ b/django_etesync/migrations/0004_collectioninvitation_version.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-05-21 14:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_etesync', '0003_collectioninvitation'), + ] + + operations = [ + migrations.AddField( + model_name='collectioninvitation', + name='version', + field=models.PositiveSmallIntegerField(default=1), + ), + ] diff --git a/django_etesync/models.py b/django_etesync/models.py index 36851a1..e0a79f6 100644 --- a/django_etesync/models.py +++ b/django_etesync/models.py @@ -144,6 +144,28 @@ class CollectionMember(models.Model): return '{} {}'.format(self.collection.uid, self.user) +class CollectionInvitation(models.Model): + uid = models.CharField(db_index=True, blank=False, null=False, + max_length=44, validators=[Base64Url256BitValidator]) + version = models.PositiveSmallIntegerField(default=1) + fromMember = models.ForeignKey(CollectionMember, on_delete=models.CASCADE) + # FIXME: make sure to delete all invitations for the same collection once one is accepted + + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='incoming_invitations', on_delete=models.CASCADE) + signedEncryptionKey = models.BinaryField(editable=False, blank=False, null=False) + accessLevel = models.CharField( + max_length=3, + choices=AccessLevels.choices, + default=AccessLevels.READ_ONLY, + ) + + class Meta: + unique_together = ('user', 'fromMember') + + def __str__(self): + return '{} {}'.format(self.fromMember.collection.uid, self.user) + + class UserInfo(models.Model): owner = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, primary_key=True) version = models.PositiveSmallIntegerField(default=1) diff --git a/django_etesync/serializers.py b/django_etesync/serializers.py index 06bb7a9..54b8d9c 100644 --- a/django_etesync/serializers.py +++ b/django_etesync/serializers.py @@ -262,6 +262,45 @@ class CollectionMemberSerializer(serializers.ModelSerializer): return instance +class CollectionInvitationSerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + source='user', + slug_field=User.USERNAME_FIELD, + queryset=User.objects + ) + collection = serializers.SlugRelatedField( + source='fromMember__collection', + slug_field='uid', + read_only=True, + ) + fromPubkey = BinaryBase64Field(source='fromMember__user__userinfo__pubkey', read_only=True) + signedEncryptionKey = BinaryBase64Field() + + class Meta: + model = models.CollectionInvitation + fields = ('username', 'uid', 'collection', 'signedEncryptionKey', 'accessLevel', 'fromPubkey', 'version') + + def create(self, validated_data): + collection = self.context['collection'] + request = self.context['request'] + + if request.user == validated_data.get('user'): + raise serializers.ValidationError('Inviting yourself is not allowed') + + member = collection.members.get(user=request.user) + + with transaction.atomic(): + return type(self).Meta.model.objects.create(**validated_data, fromMember=member) + + def update(self, instance, validated_data): + with transaction.atomic(): + instance.accessLevel = validated_data.pop('accessLevel') + instance.signedEncryptionKey = validated_data.pop('signedEncryptionKey') + instance.save() + + return instance + + class UserSerializer(serializers.ModelSerializer): class Meta: model = User diff --git a/django_etesync/views.py b/django_etesync/views.py index 5703410..ffb9503 100644 --- a/django_etesync/views.py +++ b/django_etesync/views.py @@ -34,7 +34,7 @@ import nacl.secret import nacl.hash from . import app_settings, permissions -from .models import Collection, CollectionItem, CollectionItemRevision, CollectionMember +from .models import Collection, CollectionItem, CollectionItemRevision, CollectionMember, CollectionInvitation from .serializers import ( b64encode, AuthenticationSignupSerializer, @@ -48,6 +48,7 @@ from .serializers import ( CollectionItemRevisionSerializer, CollectionItemChunkSerializer, CollectionMemberSerializer, + CollectionInvitationSerializer, UserSerializer, ) @@ -423,6 +424,38 @@ class CollectionMemberViewSet(BaseViewSet): return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) +class CollectionInvitationViewSet(BaseViewSet): + allowed_methods = ['GET', 'POST', 'PUT', 'DELETE'] + permission_classes = BaseViewSet.permission_classes + (permissions.IsCollectionAdmin, ) + queryset = CollectionInvitation.objects.all() + serializer_class = CollectionInvitationSerializer + lookup_field = 'uid' + lookup_url_kwarg = 'invitation_uid' + + def get_serializer_context(self): + context = super().get_serializer_context() + collection_uid = self.kwargs['collection_uid'] + try: + collection = self.get_collection_queryset(Collection.objects).get(uid=collection_uid) + except Collection.DoesNotExist: + raise Http404('Collection does not exist') + + context.update({'request': self.request, 'collection': collection}) + return context + + def get_queryset(self, queryset=None): + collection_uid = self.kwargs['collection_uid'] + try: + collection = self.get_collection_queryset(Collection.objects).get(uid=collection_uid) + except Collection.DoesNotExist: + raise Http404('Collection does not exist') + + if queryset is None: + queryset = type(self).queryset + + return queryset.filter(fromMember__collection=collection) + + class AuthenticationViewSet(viewsets.ViewSet): allowed_methods = ['POST'] @@ -561,6 +594,7 @@ class TestAuthenticationViewSet(viewsets.ViewSet): # Delete all of the journal data for this user for a clear test env request.user.collection_set.all().delete() + request.user.incoming_invitations.all().delete() # FIXME: also delete chunk files!!!