diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py index 3aa5e5f..34131ce 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, Group, GroupInvite, GroupInviteIncoming + AccountPreference class ToolshedUserAdmin(admin.ModelAdmin): @@ -29,30 +29,8 @@ 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 deleted file mode 100644 index 6ee56bf..0000000 --- a/backend/authentication/migrations/0005_group_groupinviteincoming_groupinvite_and_more.py +++ /dev/null @@ -1,56 +0,0 @@ -# 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 e120ce8..c6044a6 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -144,42 +144,3 @@ 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 0e4aa62..4a62dcf 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, Group +from authentication.models import ToolshedUser class OwnerSerializer(serializers.ReadOnlyField): @@ -10,12 +10,3 @@ 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 2be90d3..251ac04 100644 --- a/backend/authentication/signature_auth.py +++ b/backend/authentication/signature_auth.py @@ -16,12 +16,6 @@ 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') @@ -80,36 +74,6 @@ 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 78fbe26..6347c49 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, Group +from authentication.models import ToolshedUser, KnownIdentity from hostadmin.models import Domain from nacl.signing import SigningKey @@ -86,9 +86,3 @@ 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 cb96927..709224d 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -35,7 +35,6 @@ 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 d813ac7..a3d11a1 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', - 'owner_group', 'storage_location', 'get_tags', 'get_properties') + 'storage_location', 'get_tags', 'get_properties') search_fields = ('name', 'description', 'category__name', 'availability_policy', 'owner__username', - 'owner_group__name', 'storage_location__name', 'tags__name', 'itemproperty__property__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 deleted file mode 100644 index 561e3d2..0000000 --- a/backend/toolshed/api/group.py +++ /dev/null @@ -1,165 +0,0 @@ -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 cd065f1..07a415b 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -2,11 +2,10 @@ 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, Group +from authentication.models import ToolshedUser, KnownIdentity from authentication.signature_auth import SignatureAuthentication, split_userhandle_or_throw from files.models import File from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance @@ -37,61 +36,21 @@ class InventoryItemViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] def get_queryset(self): - # 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()) + if type(self.request.user) == KnownIdentity and self.request.user.user.exists(): + return InventoryItem.objects.filter(owner=self.request.user.user.get()) return InventoryItem.objects.none() def perform_create(self, serializer): - group_id = self.request.data.get('owner_group') with transaction.atomic(): - 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) + serializer.save(owner=self.request.user.user.get()).clean() def perform_update(self, serializer): with transaction.atomic(): - if self._is_authorized(self.request, serializer.instance): + if serializer.instance.owner == self.request.user.user.get(): serializer.save().clean() def perform_destroy(self, instance): - if self._is_authorized(self.request, instance): + if instance.owner == self.request.user.user.get(): 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 deleted file mode 100644 index 548bc8d..0000000 --- a/backend/toolshed/migrations/0012_inventoryitem_owner_group_alter_inventoryitem_owner.py +++ /dev/null @@ -1,27 +0,0 @@ -# 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 5ac047d..bd34747 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, Group +from authentication.models import ToolshedUser, KnownIdentity from files.models import File @@ -97,10 +97,7 @@ 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, 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') + owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, 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') @@ -111,8 +108,6 @@ 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 dda7b50..a42a8c7 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, Group, GroupInviteIncoming -from authentication.serializers import OwnerSerializer, GroupOwnerSerializer +from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming +from authentication.serializers import OwnerSerializer from files.models import File from files.serializers import FileSerializer from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation, WorkflowInstance @@ -68,45 +68,6 @@ 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() @@ -178,7 +139,6 @@ 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') @@ -187,8 +147,8 @@ class InventoryItemSerializer(serializers.ModelSerializer): class Meta: model = InventoryItem - fields = ['id', 'name', 'description', 'owner', 'owner_group', 'category', 'availability_policy', - 'owned_quantity', 'tags', 'tags_input', 'properties', 'files', 'storage_location'] + fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner', + '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 deleted file mode 100644 index b2542de..0000000 --- a/backend/toolshed/tests/test_group.py +++ /dev/null @@ -1,191 +0,0 @@ -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 d572b52..fc7e5f2 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, GroupTestMixin, ToolshedTestCase +from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase from files.tests import FilesTestMixin from toolshed.models import InventoryItem, Category from toolshed.tests import InventoryTestMixin @@ -333,87 +333,4 @@ 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) - - -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 + self.assertEqual(reply.status_code, 400) \ No newline at end of file diff --git a/deploy/dev/dns_server.py b/deploy/dev/dns_server.py index 00ef139..1457699 100644 --- a/deploy/dev/dns_server.py +++ b/deploy/dev/dns_server.py @@ -3,9 +3,6 @@ import socketserver import urllib.parse import dnslib import base64 -import socket - -UPSTREAM_DNS = ("8.8.8.8", 53) try: @@ -14,13 +11,6 @@ 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): @@ -50,16 +40,15 @@ 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: - print(f"Recursively resolving {dns.q.qname}") - pack = resolve_recursive(raw) + response.header.rcode = dnslib.RCODE.NXDOMAIN 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 0385425..be71d2a 100644 --- a/frontend/src/components/Sidebar.vue +++ b/frontend/src/components/Sidebar.vue @@ -33,12 +33,6 @@ Friends -