diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py index 34131ce..3aa5e5f 100644 --- a/backend/authentication/admin.py +++ b/backend/authentication/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \ - AccountPreference + AccountPreference, Group, GroupInvite, GroupInviteIncoming class ToolshedUserAdmin(admin.ModelAdmin): @@ -29,8 +29,30 @@ class FriendRequestIncomingAdmin(admin.ModelAdmin): search_fields = ('secret', 'befriender_username', 'befriender_domain', 'befriendee_user', 'befriender_public_key') +class GroupAdmin(admin.ModelAdmin): + list_display = ('name', 'domain', 'get_members') + search_fields = ('name', 'domain', 'members__username') + + @admin.display(description='Members') + def get_members(self, obj): + return ', '.join(str(member) for member in obj.members.all()) + + +class GroupInviteAdmin(admin.ModelAdmin): + list_display = ('secret', 'group', 'invitee_username', 'invitee_domain') + search_fields = ('secret', 'group__name', 'invitee_username', 'invitee_domain') + + +class GroupInviteIncomingAdmin(admin.ModelAdmin): + list_display = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user') + search_fields = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user') + + admin.site.register(ToolshedUser, ToolshedUserAdmin) admin.site.register(KnownIdentity, KnownIdentityAdmin) admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin) admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin) admin.site.register(AccountPreference, AccountPreferenceAdmin) +admin.site.register(Group, GroupAdmin) +admin.site.register(GroupInvite, GroupInviteAdmin) +admin.site.register(GroupInviteIncoming, GroupInviteIncomingAdmin) diff --git a/backend/authentication/migrations/0005_group_groupinviteincoming_groupinvite_and_more.py b/backend/authentication/migrations/0005_group_groupinviteincoming_groupinvite_and_more.py new file mode 100644 index 0000000..6ee56bf --- /dev/null +++ b/backend/authentication/migrations/0005_group_groupinviteincoming_groupinvite_and_more.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.2 on 2026-08-19 13:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0004_alter_accountpreference_id'), + ] + + operations = [ + migrations.CreateModel( + name='Group', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('domain', models.CharField(max_length=255)), + ('members', models.ManyToManyField(related_name='member_of_groups', to='authentication.knownidentity')), + ], + ), + migrations.CreateModel( + name='GroupInviteIncoming', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('secret', models.CharField(max_length=255)), + ('group_name', models.CharField(max_length=255)), + ('group_domain', models.CharField(max_length=255)), + ('inviter_username', models.CharField(max_length=255)), + ('inviter_domain', models.CharField(max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('invitee_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_invites_incoming', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='GroupInvite', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('secret', models.CharField(max_length=255)), + ('invitee_username', models.CharField(max_length=255)), + ('invitee_domain', models.CharField(max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='authentication.group')), + ], + ), + migrations.AddIndex( + model_name='group', + index=models.Index(fields=['name', 'domain'], name='group_idx'), + ), + migrations.AlterUniqueTogether( + name='group', + unique_together={('name', 'domain')}, + ), + ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index c6044a6..e120ce8 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -144,3 +144,42 @@ class FriendRequestIncoming(models.Model): befriender_public_key = models.CharField(max_length=255) befriendee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_incoming') created_at = models.DateTimeField(auto_now_add=True) + + +class Group(models.Model): + name = models.CharField(max_length=255) + domain = models.CharField(max_length=255) + members = models.ManyToManyField(KnownIdentity, related_name='member_of_groups') + + class Meta: + unique_together = ('name', 'domain') + indexes = [ + models.Index(fields=['name', 'domain'], name='group_idx'), + ] + + def __str__(self): + return f"#{self.name}@{self.domain}" + + def is_member(self, identity): + return self.members.filter(pk=identity.pk).exists() + + +class GroupInvite(models.Model): + """A pending invite tracked on the group's own home backend, checked when the invitee's + accept request arrives (see GroupInviteIncoming for the mirror record on the invitee's own + backend, and docs/design-in-progress/groups-mvp.md for the full invite/accept dance).""" + secret = models.CharField(max_length=255) + group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invites') + invitee_username = models.CharField(max_length=255) + invitee_domain = models.CharField(max_length=255) + created_at = models.DateTimeField(auto_now_add=True) + + +class GroupInviteIncoming(models.Model): + secret = models.CharField(max_length=255) + group_name = models.CharField(max_length=255) + group_domain = models.CharField(max_length=255) + inviter_username = models.CharField(max_length=255) + inviter_domain = models.CharField(max_length=255) + invitee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_invites_incoming') + created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 4a62dcf..0e4aa62 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from authentication.models import ToolshedUser +from authentication.models import ToolshedUser, Group class OwnerSerializer(serializers.ReadOnlyField): @@ -10,3 +10,12 @@ class OwnerSerializer(serializers.ReadOnlyField): def to_representation(self, value): return value.username + '@' + value.domain + + +class GroupOwnerSerializer(serializers.ReadOnlyField): + class Meta: + model = Group + fields = ['name', 'domain'] + + def to_representation(self, value): + return f"#{value.name}@{value.domain}" diff --git a/backend/authentication/signature_auth.py b/backend/authentication/signature_auth.py index 251ac04..2be90d3 100644 --- a/backend/authentication/signature_auth.py +++ b/backend/authentication/signature_auth.py @@ -16,6 +16,12 @@ def split_userhandle_or_throw(userhandle): return username, domain +def split_grouphandle_or_throw(grouphandle): + if not grouphandle.startswith('#'): + raise ValueError('Group handle must be in the format #name@domain') + return split_userhandle_or_throw(grouphandle[1:]) + + def verify_request(request, raw_request_body): authentication_header = request.META.get('HTTP_AUTHORIZATION') @@ -74,6 +80,36 @@ def verify_incoming_friend_request(request, raw_request_body): return False +def verify_incoming_group_invite(request, raw_request_body, handle_field, key_field): + """Self-certifying verifier for the two legs of the group invite/accept dance that land on a + backend which doesn't have the caller cached as a KnownIdentity yet (see + docs/design-in-progress/groups-mvp.md): the inviter delivering an invite to the invitee's own + backend (handle_field='inviter', key_field='inviter_key'), and the invitee accepting on the + group's home backend (handle_field='invitee', key_field='invitee_key'). Mirrors + verify_incoming_friend_request exactly, just with configurable field names.""" + try: + username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body) + except ValueError: + return False + try: + claimed_handle = request.data[handle_field] + claimed_key = request.data[key_field] + except KeyError: + return False + if not claimed_handle or not claimed_key: + return False + if username + "@" + domain != claimed_handle: + return False + if len(claimed_key) != 64: + return False + verify_key = VerifyKey(bytes.fromhex(claimed_key)) + try: + verify_key.verify(signed_data.encode('utf-8'), bytes.fromhex(signature_bytes_hex)) + return True + except BadSignatureError: + return False + + def authenticate_request_against_known_identities(request, raw_request_body): try: username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body) diff --git a/backend/authentication/tests/helpers.py b/backend/authentication/tests/helpers.py index 6347c49..78fbe26 100644 --- a/backend/authentication/tests/helpers.py +++ b/backend/authentication/tests/helpers.py @@ -3,7 +3,7 @@ import json from django.test import TestCase, Client from nacl.encoding import HexEncoder -from authentication.models import ToolshedUser, KnownIdentity +from authentication.models import ToolshedUser, KnownIdentity, Group from hostadmin.models import Domain from nacl.signing import SigningKey @@ -86,3 +86,9 @@ class UserTestMixin: domain=self.f['example_com'].name) self.f['ext_user1'] = DummyExternalUser('extuser1', 'external.org') self.f['ext_user2'] = DummyExternalUser('extuser2', 'external.org') + + +class GroupTestMixin: + def prepare_groups(self): + self.f['group1'] = Group.objects.create(name='group1', domain=self.f['example_com'].name) + self.f['group1'].members.add(self.f['local_user1'].public_identity) diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 709224d..cb96927 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -35,6 +35,7 @@ urlpatterns = [ path('auth/', include('authentication.api')), path('admin/', include('hostadmin.api')), path('api/', include('toolshed.api.friend')), + path('api/', include('toolshed.api.group')), path('api/', include('toolshed.api.inventory')), path('api/', include('toolshed.api.info')), path('api/', include('toolshed.api.files')), diff --git a/backend/toolshed/admin.py b/backend/toolshed/admin.py index a3d11a1..d813ac7 100644 --- a/backend/toolshed/admin.py +++ b/backend/toolshed/admin.py @@ -19,9 +19,9 @@ class ItemPropertyInline(admin.TabularInline): class InventoryItemAdmin(admin.ModelAdmin): list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner', - 'storage_location', 'get_tags', 'get_properties') + 'owner_group', 'storage_location', 'get_tags', 'get_properties') search_fields = ('name', 'description', 'category__name', 'availability_policy', 'owner__username', - 'storage_location__name', 'tags__name', 'itemproperty__property__name') + 'owner_group__name', 'storage_location__name', 'tags__name', 'itemproperty__property__name') inlines = (ItemTagInline, ItemPropertyInline) def get_queryset(self, request): diff --git a/backend/toolshed/api/group.py b/backend/toolshed/api/group.py new file mode 100644 index 0000000..561e3d2 --- /dev/null +++ b/backend/toolshed/api/group.py @@ -0,0 +1,165 @@ +import secrets + +from django.urls import path +from rest_framework import status +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.generics import get_object_or_404 +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.viewsets import ViewSetMixin + +from authentication.models import Group, GroupInvite, GroupInviteIncoming, KnownIdentity, ToolshedUser +from authentication.signature_auth import SignatureAuthentication, SignatureAuthenticationLocal, \ + authenticate_request_against_local_users, split_grouphandle_or_throw, split_userhandle_or_throw, \ + verify_incoming_group_invite +from toolshed.serializers import GroupSerializer, GroupInviteIncomingSerializer + + +class Groups(APIView, ViewSetMixin): + authentication_classes = [SignatureAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, format=None): # /api/groups/ + groups = request.user.member_of_groups.all() + return Response(GroupSerializer(groups, many=True).data) + + def post(self, request, format=None): # /api/groups/ + name = request.data.get('name') + if not name: + return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'name is required'}) + if Group.objects.filter(name=name, domain=request.user.domain).exists(): + return Response(status=status.HTTP_409_CONFLICT, data={'status': 'a group with this name already exists'}) + group = Group.objects.create(name=name, domain=request.user.domain) + group.members.add(request.user) + return Response(status=status.HTTP_201_CREATED, data=GroupSerializer(group).data) + + +class GroupDetail(APIView, ViewSetMixin): + authentication_classes = [SignatureAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, pk, format=None): # /api/groups// + group = get_object_or_404(Group, pk=pk) + if not group.is_member(request.user): + return Response(status=status.HTTP_404_NOT_FOUND) + return Response(GroupSerializer(group).data) + + +@api_view(['DELETE']) +@authentication_classes([SignatureAuthentication]) +@permission_classes([IsAuthenticated]) +def removeGroupMember(request, pk, identity_id, format=None): # /api/groups//members// + group = get_object_or_404(Group, pk=pk) + if not group.is_member(request.user): + return Response(status=status.HTTP_404_NOT_FOUND) + member = get_object_or_404(group.members, pk=identity_id) + if group.members.count() <= 1: + return Response(status=status.HTTP_400_BAD_REQUEST, + data={'status': "cannot remove the group's last member"}) + group.members.remove(member) + return Response(status=status.HTTP_204_NO_CONTENT) + + +@api_view(['POST']) +@authentication_classes([SignatureAuthentication]) +@permission_classes([IsAuthenticated]) +def createGroupInvite(request, pk, format=None): # /api/groups//invites/ + group = get_object_or_404(Group, pk=pk) + if not group.is_member(request.user): + return Response(status=status.HTTP_404_NOT_FOUND) + invitee = request.data.get('invitee') + if not invitee: + return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invitee is required'}) + try: + invitee_username, invitee_domain = split_userhandle_or_throw(invitee) + except ValueError: + return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid invitee handle'}) + if group.members.filter(username=invitee_username, domain=invitee_domain).exists(): + return Response(status=status.HTTP_208_ALREADY_REPORTED, data={'status': 'already a member'}) + secret = secrets.token_hex(64) + GroupInvite.objects.create(group=group, invitee_username=invitee_username, invitee_domain=invitee_domain, + secret=secret) + return Response(status=status.HTTP_201_CREATED, data={'secret': secret, 'status': 'pending'}) + + +class GroupInvitesIncoming(APIView, ViewSetMixin): + """/api/groupinvites/ - the invitee's own view of their pending invites, and the delivery + endpoint an inviter's client posts to directly on the invitee's own home backend (see + docs/design-in-progress/groups-mvp.md's invite/accept dance).""" + def get(self, request, format=None): # /api/groupinvites/ - only ever a local user checking their own invites + raw_request = request.body.decode('utf-8') + if not (user := authenticate_request_against_local_users(request, raw_request)): + return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'}) + invites = user.group_invites_incoming.all() + return Response(GroupInviteIncomingSerializer(invites, many=True).data) + + def post(self, request, format=None): # /api/groupinvites/ - delivery, self-certified, caller isn't known here + raw_request = request.body.decode('utf-8') + for field in ('group', 'inviter', 'inviter_key', 'invitee', 'secret'): + if field not in request.data: + return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': f'missing {field}'}) + if not verify_incoming_group_invite(request, raw_request, 'inviter', 'inviter_key'): + return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'}) + try: + group_name, group_domain = split_grouphandle_or_throw(request.data['group']) + inviter_username, inviter_domain = split_userhandle_or_throw(request.data['inviter']) + invitee_username, invitee_domain = split_userhandle_or_throw(request.data['invitee']) + except ValueError: + return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid handle'}) + try: + invitee_user = ToolshedUser.objects.get(username=invitee_username, domain=invitee_domain) + except ToolshedUser.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'invitee is not local to this server'}) + GroupInviteIncoming.objects.create( + group_name=group_name, group_domain=group_domain, + inviter_username=inviter_username, inviter_domain=inviter_domain, + invitee_user=invitee_user, secret=request.data['secret']) + return Response(status=status.HTTP_201_CREATED, data={'status': 'delivered'}) + + +@api_view(['DELETE']) +@authentication_classes([SignatureAuthenticationLocal]) +@permission_classes([IsAuthenticated]) +def declineGroupInvite(request, pk, format=None): # /api/groupinvites// + get_object_or_404(request.user.group_invites_incoming, pk=pk).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +@api_view(['POST']) +def acceptGroupInvite(request, format=None): # /api/group_invites/accept/ - lands on the group's home backend + raw_request = request.body.decode('utf-8') + for field in ('group', 'invitee', 'invitee_key', 'secret'): + if field not in request.data: + return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': f'missing {field}'}) + if not verify_incoming_group_invite(request, raw_request, 'invitee', 'invitee_key'): + return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'}) + try: + group_name, group_domain = split_grouphandle_or_throw(request.data['group']) + invitee_username, invitee_domain = split_userhandle_or_throw(request.data['invitee']) + except ValueError: + return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid handle'}) + try: + group = Group.objects.get(name=group_name, domain=group_domain) + except Group.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'no such group here'}) + invite = GroupInvite.objects.filter(group=group, invitee_username=invitee_username, + invitee_domain=invitee_domain, secret=request.data['secret']) + if not invite.exists(): + return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'no matching invite'}) + identity, _ = KnownIdentity.objects.get_or_create( + username=invitee_username, domain=invitee_domain, public_key=request.data['invitee_key']) + group.members.add(identity) + invite.delete() + return Response(status=status.HTTP_201_CREATED, data={'status': 'accepted'}) + + +urlpatterns = [ + path('groups/', Groups.as_view(), name='groups'), + path('groups//', GroupDetail.as_view(), name='group_detail'), + path('groups//members//', removeGroupMember, name='remove_group_member'), + path('groups//invites/', createGroupInvite, name='create_group_invite'), + path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'), + path('groupinvites//', declineGroupInvite, name='decline_group_invite'), + path('group_invites/accept/', acceptGroupInvite, name='accept_group_invite'), +] diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py index 07a415b..cd065f1 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -2,10 +2,11 @@ from django.db import transaction from django.urls import path from rest_framework import routers, viewsets, status from rest_framework.decorators import authentication_classes, api_view, permission_classes, action +from rest_framework.exceptions import NotFound, PermissionDenied from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from authentication.models import ToolshedUser, KnownIdentity +from authentication.models import ToolshedUser, KnownIdentity, Group from authentication.signature_auth import SignatureAuthentication, split_userhandle_or_throw from files.models import File from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance @@ -36,21 +37,61 @@ class InventoryItemViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] def get_queryset(self): - if type(self.request.user) == KnownIdentity and self.request.user.user.exists(): - return InventoryItem.objects.filter(owner=self.request.user.user.get()) + # A KnownIdentity acting purely as a group member (e.g. a remote member on a group + # hosted on this backend) never has a local ToolshedUser account here - group-owned + # items must stay reachable for such an identity, only personal ("owner=...") items + # require .user.exists(). + if type(self.request.user) != KnownIdentity: + return InventoryItem.objects.none() + identity = self.request.user + group_items = InventoryItem.objects.filter(owner_group__in=identity.member_of_groups.all()) + if self.action != 'list': + # retrieve/update/destroy: anything the caller may act on - their own items, or any + # group they're currently a member of. The narrower per-group listing below is only + # for the list action, so the main Inventory page stays scoped to personal items. + if identity.user.exists(): + return InventoryItem.objects.filter(owner=identity.user.get()) | group_items + return group_items + group_id = self.request.query_params.get('group') + if group_id: + try: + group = Group.objects.get(pk=group_id) + except Group.DoesNotExist: + return InventoryItem.objects.none() + if not group.is_member(identity): + return InventoryItem.objects.none() + return InventoryItem.objects.filter(owner_group=group) + if identity.user.exists(): + return InventoryItem.objects.filter(owner=identity.user.get()) return InventoryItem.objects.none() def perform_create(self, serializer): + group_id = self.request.data.get('owner_group') with transaction.atomic(): - serializer.save(owner=self.request.user.user.get()).clean() + if group_id: + try: + group = Group.objects.get(pk=group_id) + except Group.DoesNotExist: + raise NotFound('No such group') + if not group.is_member(self.request.user): + raise PermissionDenied('Not a member of this group') + serializer.save(owner=None, owner_group=group).clean() + else: + serializer.save(owner=self.request.user.user.get()).clean() + + @staticmethod + def _is_authorized(request, instance): + if instance.owner_id: + return request.user.user.filter(pk=instance.owner_id).exists() + return instance.owner_group.is_member(request.user) def perform_update(self, serializer): with transaction.atomic(): - if serializer.instance.owner == self.request.user.user.get(): + if self._is_authorized(self.request, serializer.instance): serializer.save().clean() def perform_destroy(self, instance): - if instance.owner == self.request.user.user.get(): + if self._is_authorized(self.request, instance): instance.delete() diff --git a/backend/toolshed/migrations/0012_inventoryitem_owner_group_alter_inventoryitem_owner.py b/backend/toolshed/migrations/0012_inventoryitem_owner_group_alter_inventoryitem_owner.py new file mode 100644 index 0000000..548bc8d --- /dev/null +++ b/backend/toolshed/migrations/0012_inventoryitem_owner_group_alter_inventoryitem_owner.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.2 on 2026-08-19 13:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('authentication', '0005_group_groupinviteincoming_groupinvite_and_more'), + ('toolshed', '0011_workflowinstance_staged_files'), + ] + + operations = [ + migrations.AddField( + model_name='inventoryitem', + name='owner_group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='authentication.group'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/toolshed/models.py b/backend/toolshed/models.py index bd34747..5ac047d 100644 --- a/backend/toolshed/models.py +++ b/backend/toolshed/models.py @@ -3,7 +3,7 @@ from django.core.validators import MinValueValidator, MaxValueValidator from django_softdelete.models import SoftDeleteModel from rest_framework.exceptions import ValidationError -from authentication.models import ToolshedUser, KnownIdentity +from authentication.models import ToolshedUser, KnownIdentity, Group from files.models import File @@ -97,7 +97,10 @@ class InventoryItem(SoftDeleteModel): category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='inventory_items') availability_policy = models.CharField(max_length=20, choices=AVAILABILITY_POLICY_CHOICES, default='private') owned_quantity = models.IntegerField(default=1, validators=[MinValueValidator(0)]) - owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='inventory_items') + owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True, + related_name='inventory_items') + owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True, + related_name='inventory_items') created_at = models.DateTimeField(auto_now_add=True) tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items') properties = models.ManyToManyField(Property, through='ItemProperty') @@ -108,6 +111,8 @@ class InventoryItem(SoftDeleteModel): def clean(self): if (self.name is None or self.name == "") and self.files.count() == 0: raise ValidationError("Name or at least one file must be set") + if (self.owner is None) == (self.owner_group is None): + raise ValidationError("Exactly one of owner or owner_group must be set") class ItemProperty(models.Model): diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index a42a8c7..dda7b50 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming -from authentication.serializers import OwnerSerializer +from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming +from authentication.serializers import OwnerSerializer, GroupOwnerSerializer from files.models import File from files.serializers import FileSerializer from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation, WorkflowInstance @@ -68,6 +68,45 @@ class FriendRequestSerializer(serializers.ModelSerializer): return obj.befriender_username + '@' + obj.befriender_domain +class GroupMemberSerializer(serializers.ModelSerializer): + username = serializers.SerializerMethodField() + + class Meta: + model = KnownIdentity + fields = ['id', 'username', 'public_key'] + + def get_username(self, obj): + return obj.username + '@' + obj.domain + + +class GroupSerializer(serializers.ModelSerializer): + handle = serializers.SerializerMethodField() + members = GroupMemberSerializer(many=True, read_only=True) + + class Meta: + model = Group + fields = ['id', 'name', 'domain', 'handle', 'members'] + read_only_fields = ['domain', 'handle', 'members'] + + def get_handle(self, obj): + return str(obj) + + +class GroupInviteIncomingSerializer(serializers.ModelSerializer): + group = serializers.SerializerMethodField() + inviter = serializers.SerializerMethodField() + + class Meta: + model = GroupInviteIncoming + fields = ['id', 'group', 'inviter', 'secret'] + + def get_group(self, obj): + return f"#{obj.group_name}@{obj.group_domain}" + + def get_inviter(self, obj): + return obj.inviter_username + '@' + obj.inviter_domain + + class PropertySerializer(serializers.ModelSerializer): category = serializers.SerializerMethodField() handle = serializers.SerializerMethodField() @@ -139,6 +178,7 @@ class ItemPropertySerializer(serializers.ModelSerializer): class InventoryItemSerializer(serializers.ModelSerializer): owner = OwnerSerializer(read_only=True) + owner_group = GroupOwnerSerializer(read_only=True) tags = serializers.SerializerMethodField() tags_input = serializers.ListField(child=serializers.CharField(), write_only=True, required=False) properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set') @@ -147,8 +187,8 @@ class InventoryItemSerializer(serializers.ModelSerializer): class Meta: model = InventoryItem - fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner', - 'tags', 'tags_input', 'properties', 'files', 'storage_location'] + fields = ['id', 'name', 'description', 'owner', 'owner_group', 'category', 'availability_policy', + 'owned_quantity', 'tags', 'tags_input', 'properties', 'files', 'storage_location'] def get_tags(self, obj): return [tag.name for tag in obj.tags.all()] diff --git a/backend/toolshed/tests/test_group.py b/backend/toolshed/tests/test_group.py new file mode 100644 index 0000000..b2542de --- /dev/null +++ b/backend/toolshed/tests/test_group.py @@ -0,0 +1,191 @@ +from django.test import Client + +from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase, \ + DummyExternalUser +from authentication.models import Group, GroupInvite, GroupInviteIncoming, KnownIdentity + +client = SignatureAuthClient() + + +class GroupModelTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase): + def setUp(self): + super().setUp() + self.prepare_users() + self.prepare_groups() + + def test_group_str(self): + self.assertEqual(str(self.f['group1']), '#group1@' + self.f['example_com'].name) + + def test_is_member(self): + self.assertTrue(self.f['group1'].is_member(self.f['local_user1'].public_identity)) + self.assertFalse(self.f['group1'].is_member(self.f['local_user2'].public_identity)) + + +class GroupApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase): + def setUp(self): + super().setUp() + self.prepare_users() + + def test_create_group(self): + reply = client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'}) + self.assertEqual(reply.status_code, 201) + group = Group.objects.get(name='workshop') + self.assertEqual(group.domain, self.f['local_user1'].domain) + self.assertTrue(group.is_member(self.f['local_user1'].public_identity)) + + def test_create_group_duplicate_name(self): + client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'}) + reply = client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'}) + self.assertEqual(reply.status_code, 409) + + def test_create_group_missing_name(self): + reply = client.post('/api/groups/', self.f['local_user1'], {}) + self.assertEqual(reply.status_code, 400) + + def test_list_groups(self): + self.prepare_groups() + reply = client.get('/api/groups/', self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(len(reply.json()), 1) + reply2 = client.get('/api/groups/', self.f['local_user2']) + self.assertEqual(reply2.status_code, 200) + self.assertEqual(len(reply2.json()), 0) + + def test_group_detail_member(self): + self.prepare_groups() + reply = client.get('/api/groups/{}/'.format(self.f['group1'].id), self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(reply.json()['handle'], str(self.f['group1'])) + self.assertEqual(len(reply.json()['members']), 1) + + def test_group_detail_non_member(self): + self.prepare_groups() + reply = client.get('/api/groups/{}/'.format(self.f['group1'].id), self.f['local_user2']) + self.assertEqual(reply.status_code, 404) + + def test_remove_member(self): + self.prepare_groups() + self.f['group1'].members.add(self.f['local_user2'].public_identity) + reply = client.delete('/api/groups/{}/members/{}/'.format( + self.f['group1'].id, self.f['local_user2'].public_identity.id), self.f['local_user1']) + self.assertEqual(reply.status_code, 204) + self.assertEqual(self.f['group1'].members.count(), 1) + + def test_remove_last_member_blocked(self): + self.prepare_groups() + reply = client.delete('/api/groups/{}/members/{}/'.format( + self.f['group1'].id, self.f['local_user1'].public_identity.id), self.f['local_user1']) + self.assertEqual(reply.status_code, 400) + self.assertEqual(self.f['group1'].members.count(), 1) + + def test_remove_member_non_member_denied(self): + self.prepare_groups() + self.f['group1'].members.add(self.f['local_user2'].public_identity) + reply = client.delete('/api/groups/{}/members/{}/'.format( + self.f['group1'].id, self.f['local_user1'].public_identity.id), self.f['ext_user1']) + self.assertEqual(reply.status_code, 404) + self.assertEqual(self.f['group1'].members.count(), 2) + + +class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase): + def setUp(self): + super().setUp() + self.prepare_users() + self.prepare_groups() + + def test_invite_local_member_full_flow(self): + group = self.f['group1'] + inviter = self.f['local_user1'] + invitee = self.f['local_user2'] + + reply1 = client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)}) + self.assertEqual(reply1.status_code, 201) + secret = reply1.json()['secret'] + self.assertEqual(GroupInvite.objects.count(), 1) + + reply2 = client.post('/api/groupinvites/', inviter, { + 'group': str(group), 'inviter': str(inviter), 'inviter_key': inviter.public_key(), + 'invitee': str(invitee), 'secret': secret + }) + self.assertEqual(reply2.status_code, 201) + incoming = GroupInviteIncoming.objects.get() + self.assertEqual(incoming.invitee_user, invitee) + + reply3 = client.get('/api/groupinvites/', invitee) + self.assertEqual(reply3.status_code, 200) + self.assertEqual(len(reply3.json()), 1) + self.assertEqual(reply3.json()[0]['group'], str(group)) + self.assertEqual(reply3.json()[0]['inviter'], str(inviter)) + + reply4 = client.post('/api/group_invites/accept/', invitee, { + 'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret + }) + self.assertEqual(reply4.status_code, 201) + self.assertTrue(group.is_member(invitee.public_identity)) + self.assertEqual(GroupInvite.objects.count(), 0) + + reply5 = client.delete('/api/groupinvites/{}/'.format(incoming.id), invitee) + self.assertEqual(reply5.status_code, 204) + self.assertEqual(GroupInviteIncoming.objects.count(), 0) + + def test_invite_non_member_denied(self): + group = self.f['group1'] + reply = client.post('/api/groups/{}/invites/'.format(group.id), self.f['local_user2'], + {'invitee': str(self.f['ext_user1'])}) + self.assertEqual(reply.status_code, 404) + self.assertEqual(GroupInvite.objects.count(), 0) + + def test_invite_already_member(self): + group = self.f['group1'] + group.members.add(self.f['local_user2'].public_identity) + reply = client.post('/api/groups/{}/invites/'.format(group.id), self.f['local_user1'], + {'invitee': str(self.f['local_user2'])}) + self.assertEqual(reply.status_code, 208) + + def test_decline_invite(self): + invite = GroupInviteIncoming.objects.create( + group_name=self.f['group1'].name, group_domain=self.f['group1'].domain, + inviter_username=self.f['local_user1'].username, inviter_domain=self.f['local_user1'].domain, + invitee_user=self.f['local_user2'], secret='some-secret') + reply = client.delete('/api/groupinvites/{}/'.format(invite.id), self.f['local_user2']) + self.assertEqual(reply.status_code, 204) + self.assertEqual(GroupInviteIncoming.objects.count(), 0) + + def test_accept_wrong_secret(self): + group = self.f['group1'] + inviter = self.f['local_user1'] + invitee = self.f['local_user2'] + client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)}) + reply = client.post('/api/group_invites/accept/', invitee, { + 'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), + 'secret': 'not-the-right-secret' + }) + self.assertEqual(reply.status_code, 404) + self.assertFalse(group.is_member(invitee.public_identity)) + + def test_accept_remote_member(self): + group = self.f['group1'] + inviter = self.f['local_user1'] + invitee = DummyExternalUser('newmember', 'remote.example', known=False) + reply1 = client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)}) + secret = reply1.json()['secret'] + + reply = client.post('/api/group_invites/accept/', invitee, { + 'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret + }) + self.assertEqual(reply.status_code, 201) + identity = KnownIdentity.objects.get(username='newmember', domain='remote.example') + self.assertTrue(group.is_member(identity)) + + def test_accept_bad_signature(self): + group = self.f['group1'] + inviter = self.f['local_user1'] + invitee = self.f['local_user2'] + reply1 = client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)}) + secret = reply1.json()['secret'] + bad_signature_client = SignatureAuthClient(bad_signature=True) + reply = bad_signature_client.post('/api/group_invites/accept/', invitee, { + 'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret + }) + self.assertEqual(reply.status_code, 401) + self.assertFalse(group.is_member(invitee.public_identity)) diff --git a/backend/toolshed/tests/test_inventory.py b/backend/toolshed/tests/test_inventory.py index fc7e5f2..d572b52 100644 --- a/backend/toolshed/tests/test_inventory.py +++ b/backend/toolshed/tests/test_inventory.py @@ -1,4 +1,4 @@ -from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase +from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase from files.tests import FilesTestMixin from toolshed.models import InventoryItem, Category from toolshed.tests import InventoryTestMixin @@ -333,4 +333,87 @@ class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, Invent 'properties': [{'name': 'prop1', 'value': 'value1'}, {'name': 'prop2', 'value': 'value2'}], 'files': [{'data': self.f['encoded_content3']}] }) - self.assertEqual(reply.status_code, 400) \ No newline at end of file + self.assertEqual(reply.status_code, 400) + + +class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase): + def setUp(self): + super().setUp() + self.prepare_users() + self.prepare_groups() + self.f['group1'].members.add(self.f['local_user2'].public_identity) + + def create_group_item(self, name='drill'): + return client.post('/api/inventory_items/', self.f['local_user1'], { + 'name': name, 'owned_quantity': 1, 'availability_policy': 'private', + 'owner_group': self.f['group1'].id, + }) + + def test_create_group_owned_item(self): + reply = self.create_group_item() + self.assertEqual(reply.status_code, 201) + item = InventoryItem.objects.get(name='drill') + self.assertIsNone(item.owner) + self.assertEqual(item.owner_group, self.f['group1']) + self.assertEqual(reply.json()['owner_group'], str(self.f['group1'])) + self.assertIsNone(reply.json()['owner']) + + def test_create_group_owned_item_non_member_denied(self): + reply = client.post('/api/inventory_items/', self.f['ext_user1'], { + 'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private', + 'owner_group': self.f['group1'].id, + }) + self.assertEqual(reply.status_code, 403) + self.assertEqual(InventoryItem.objects.count(), 0) + + def test_other_member_can_edit(self): + item_id = self.create_group_item().json()['id'] + reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], { + 'name': 'drill-renamed' + }) + self.assertEqual(reply.status_code, 200) + self.assertEqual(InventoryItem.objects.get(id=item_id).name, 'drill-renamed') + + def test_other_member_can_delete(self): + item_id = self.create_group_item().json()['id'] + reply = client.delete('/api/inventory_items/{}/'.format(item_id), self.f['local_user2']) + self.assertEqual(reply.status_code, 204) + self.assertEqual(InventoryItem.objects.filter(id=item_id).count(), 0) + + def test_remote_member_without_local_account_can_edit(self): + # A remote member (no ToolshedUser row at all on this backend, only a KnownIdentity - + # see docs/design-in-progress/groups-mvp.md) must still be able to act on group-owned + # items here; it must not be treated as unauthorized just because .user.exists() is False. + self.f['group1'].members.add(self.f['ext_user1'].public_identity) + item_id = self.create_group_item().json()['id'] + reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1']) + self.assertEqual(reply.status_code, 200) + reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'], { + 'name': 'drill-renamed-by-remote-member' + }) + self.assertEqual(reply.status_code, 200) + self.assertEqual(InventoryItem.objects.get(id=item_id).name, 'drill-renamed-by-remote-member') + + def test_non_member_cannot_see_or_edit(self): + item_id = self.create_group_item().json()['id'] + reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1']) + self.assertEqual(reply.status_code, 404) + + def test_group_items_excluded_from_personal_list(self): + self.create_group_item() + reply = client.get('/api/inventory_items/', self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(len(reply.json()), 0) + + def test_group_items_listed_by_group_query_param(self): + self.create_group_item() + reply = client.get('/api/inventory_items/?group={}'.format(self.f['group1'].id), self.f['local_user2']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(len(reply.json()), 1) + self.assertEqual(reply.json()[0]['name'], 'drill') + + def test_group_items_not_listed_for_non_member_query_param(self): + self.create_group_item() + reply = client.get('/api/inventory_items/?group={}'.format(self.f['group1'].id), self.f['ext_user1']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(len(reply.json()), 0) \ No newline at end of file diff --git a/deploy/dev/dns_server.py b/deploy/dev/dns_server.py index 1457699..00ef139 100644 --- a/deploy/dev/dns_server.py +++ b/deploy/dev/dns_server.py @@ -3,6 +3,9 @@ import socketserver import urllib.parse import dnslib import base64 +import socket + +UPSTREAM_DNS = ("8.8.8.8", 53) try: @@ -11,6 +14,13 @@ try: if record["name"] == qname and record["type"] == qtype and "value" in record: return record["value"] + def resolve_recursive(raw_query): + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(5) + sock.sendto(raw_query, UPSTREAM_DNS) + data, _ = sock.recvfrom(4096) + return data + class DnsHttpRequestHandler(http.server.BaseHTTPRequestHandler): def do_GET(self): @@ -40,15 +50,16 @@ try: print("SRV record") reply = dnslib.SRV(record["priority"], record["weight"], record["port"], record["target"]) response.add_answer(dnslib.RR(dns.q.qname, dns.q.qtype, rdata=reply)) + pack = response.pack() else: - response.header.rcode = dnslib.RCODE.NXDOMAIN + print(f"Recursively resolving {dns.q.qname}") + pack = resolve_recursive(raw) print(response) self.send_response(200) self.send_header("Content-type", "application/dns-message") self.end_headers() - pack = response.pack() self.wfile.write(pack) return except Exception as e: diff --git a/frontend/src/components/Sidebar.vue b/frontend/src/components/Sidebar.vue index be71d2a..0385425 100644 --- a/frontend/src/components/Sidebar.vue +++ b/frontend/src/components/Sidebar.vue @@ -33,6 +33,12 @@ Friends +