diff --git a/app/tests.py b/app/tests.py index b4fda3f..b8b4522 100644 --- a/app/tests.py +++ b/app/tests.py @@ -43,6 +43,43 @@ def testAsAdmin(self): self.assertContains(r, "View admin site") +class TestNodeUsersView(TestCase): + """ + TestNodeUsersView tests that the update ssh public keys renders. + """ + + def testResponse(self): + project = Project.objects.create(name="Test") + + node = Node.objects.create(vsn="W123") + NodeMembership.objects.create(project=project, node=node, can_develop=True) + + # create user with dev access to this node + user = User.objects.create( + username="someuser", + ssh_public_keys="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0QZW4toqXPDOKToSeSpaax2ISgzlEA+C0ANphhbHAk", + ) + UserMembership.objects.create(project=project, user=user, can_develop=True) + + # create user without dev access to this node + User.objects.create_user( + username="anotheruser", + ssh_public_keys="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0QZW4toqXPDOKToSeSpaax2ISgzlEA+C0ANphhZAZA", + ) + + r = self.client.get("/nodes/W123/users") + self.assertEqual(r.status_code, status.HTTP_200_OK) + self.assertEqual( + r.json(), + [ + { + "user": "someuser", + "ssh_public_keys": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0QZW4toqXPDOKToSeSpaax2ISgzlEA+C0ANphhbHAk\n", + } + ], + ) + + class TestUpdateSSHPublicKeysView(TestCase): """ TestUpdateSSHPublicKeysView tests that the update ssh public keys renders. diff --git a/app/urls.py b/app/urls.py index 4437b0c..3b1f8a4 100644 --- a/app/urls.py +++ b/app/urls.py @@ -26,6 +26,7 @@ "portal-logout/", views.LogoutView.as_view(redirect_field_name="callback") ), # for portal compatibility path("nodes//authorized_keys", views.NodeAuthorizedKeysView.as_view()), + path("nodes//users", views.NodeUsersView.as_view()), ] + format_suffix_patterns( [ # token views diff --git a/app/views.py b/app/views.py index 658ba85..2e05df7 100644 --- a/app/views.py +++ b/app/views.py @@ -23,6 +23,7 @@ from .forms import UpdateSSHPublicKeysForm, CompleteLoginForm from .permissions import IsSelf, IsMatchingUsername from .models import Node +import re User = get_user_model() @@ -154,10 +155,18 @@ def get(self, request: Request, vsn: str) -> Response: except Node.DoesNotExist: raise Http404 - user_ssh_public_keys = node.project_set.filter( + queryset = node.project_set.filter( usermembership__can_develop=True, nodemembership__can_develop=True, - ).values_list("users__ssh_public_keys", flat=True) + ) + + user_filter = request.query_params.get("user") + if user_filter: + queryset = queryset.filter(users__username=user_filter) + + user_ssh_public_keys = queryset.values_list( + "users__ssh_public_keys", flat=True + ).distinct() keys = [] @@ -169,6 +178,45 @@ def get(self, request: Request, vsn: str) -> Response: # return Response("\n".join(keys), content_type="text/plain") +class NodeUsersView(APIView): + """ + This view provides the list of users and their ssh public keys who have developer access to a specific node. + + TODO(sean) Consider adding an endpoint which authenticates a username + ssh public key instead of providing + the list for local tracking. + """ + + permission_classes = [AllowAny] + + # Used to filter only keys with valid type and content. This also excludes the comment to prevent accidentally + # leaking sensitive information about user, even if this is unlikely to happen. + ssh_public_key_pattern = re.compile(r"(ssh-\S+\s+\S+)") + + def get(self, request: Request, vsn: str) -> Response: + try: + node = Node.objects.get(vsn=vsn) + except Node.DoesNotExist: + raise Http404 + + items = node.project_set.filter( + usermembership__can_develop=True, + nodemembership__can_develop=True, + ).values_list("users__username", "users__ssh_public_keys") + + results = [ + { + "user": username, + "ssh_public_keys": "".join( + s + "\n" + for s in self.ssh_public_key_pattern.findall(ssh_public_keys) + ), + } + for username, ssh_public_keys in items + ] + + return Response(results) + + class UpdateSSHPublicKeysView(LoginRequiredMixin, FormView): form_class = UpdateSSHPublicKeysForm template_name = "update-my-keys.html"