diff --git a/operations/api/__init__.py b/operations/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/operations/api/serializers.py b/operations/api/serializers.py new file mode 100644 index 00000000..5cb67709 --- /dev/null +++ b/operations/api/serializers.py @@ -0,0 +1,43 @@ +from django.utils.translation import gettext as _ +from rest_framework import serializers + +from operations.jobs import KINDS, label_of +from operations.models import Operation +from operations.utils import shown_label + + +class OperationSerializer(serializers.ModelSerializer): + label = serializers.SerializerMethodField() + resource_label = serializers.SerializerMethodField() + cancellable = serializers.SerializerMethodField() + + class Meta: + model = Operation + fields = ["id", "kind", "label", "username", "resource", "resource_label", "state", "phase", + "progress_done", "progress_total", "result", "error", "created_at", "started_at", + "finished_at", "cancel_requested", "cancellable", "resolution", "resolution_note"] + read_only_fields = fields + + def get_label(self, op) -> str: + return str(_(label_of(op.kind))) + + def get_resource_label(self, op) -> str: + return shown_label(op, self.context["request"].user) + + def get_cancellable(self, op) -> bool: + kind = KINDS.get(op.kind) + return op.state == Operation.QUEUED or bool(kind and kind.cancellable and op.state == Operation.RUNNING) + + +class OperationSummarySerializer(serializers.Serializer): + kind = serializers.CharField() + label = serializers.CharField() + state = serializers.CharField() + since = serializers.DateTimeField() + + +class ResourceJobsSerializer(serializers.Serializer): + jobs = OperationSerializer(many=True) + others = OperationSummarySerializer(many=True) + short_operation = serializers.BooleanField() + worker_last_seen = serializers.DateTimeField(allow_null=True) diff --git a/operations/api/viewsets.py b/operations/api/viewsets.py new file mode 100644 index 00000000..787b21d7 --- /dev/null +++ b/operations/api/viewsets.py @@ -0,0 +1,58 @@ +from django.http import Http404 +from django.utils import timezone +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import mixins, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError +from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response + +from operations import jobs +from operations.models import Claim, Operation, last_seen +from operations.utils import may_see, summary, visible + +from .serializers import OperationSerializer, ResourceJobsSerializer + +SHOWN_STATES = Operation.ACTIVE + (Operation.UNCERTAIN,) + + +class Pages(PageNumberPagination): + page_size = 50 + + +class OperationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + serializer_class = OperationSerializer + pagination_class = Pages + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): # schema generation has no user + return Operation.objects.none() + user = self.request.user + qs = Operation.objects.all() if user.is_superuser else Operation.objects.filter(user=user) + fields = ("state", "kind", "resource", "username") if user.is_superuser else ("state", "kind", "resource") + for field in fields: + if value := self.request.query_params.get(field): + qs = qs.filter(**{field: value}) + return qs + + @extend_schema(request=None, responses=OperationSerializer) + @action(detail=True, methods=["post"]) + def cancel(self, request, pk=None): + op = self.get_object() + return Response(OperationSerializer(jobs.cancel(op.pk, request.user), context={"request": request}).data) + + @extend_schema(parameters=[OpenApiParameter("key", str, required=True)], responses=ResourceJobsSerializer) + @action(detail=False, methods=["get"]) + def resource(self, request): + key = request.query_params.get("key", "") + if not key: + raise ValidationError({"key": "required"}) + if not visible(request.user, key): + raise Http404 + active = list(Operation.objects.filter(claims__resource=key, state__in=SHOWN_STATES).distinct()) + mine = [op for op in active if may_see(op, request.user)] + others = [summary(op, request.user) for op in active if not may_see(op, request.user)] + short = Claim.objects.filter(resource=key, operation__isnull=True, expires_at__gt=timezone.now()).exists() + return Response(ResourceJobsSerializer( + {"jobs": mine, "others": others, "short_operation": short, "worker_last_seen": last_seen()}, + context={"request": request}).data) diff --git a/operations/tests/test_api.py b/operations/tests/test_api.py new file mode 100644 index 00000000..a1a4e20f --- /dev/null +++ b/operations/tests/test_api.py @@ -0,0 +1,88 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.test import TransactionTestCase + +from accounts.models import UserInstance +from computes.models import Compute +from instances.models import Instance +from operations import claims, jobs +from operations.models import Claim, Operation +from operations.tests import fakes + + +class OperationsApiTestCase(TransactionTestCase): + def setUp(self): + fakes.reset() + U = get_user_model() + self.owner = U.objects.create_user("api_owner", "o@example.com", "pw") + self.other = U.objects.create_user("api_other", "x@example.com", "pw") + compute = Compute.objects.create(name="api-c", hostname="192.0.2.20", login="root", password="", type=1) + self.vm = Instance.objects.create(compute=compute, name="api-vm", uuid="u1") + self.vm2 = Instance.objects.create(compute=compute, name="api-vm2", uuid="u2") + UserInstance.objects.create(user=self.other, instance=self.vm) + UserInstance.objects.create(user=self.owner, instance=self.vm2) + self.op = jobs.submit("test.noop", self.owner, {"uuid": "u1"}, "key-00000001") + + def test_users_list_only_their_own_jobs(self): + self.client.force_login(self.other) + self.assertEqual(self.client.get("/api/v1/operations/").json()["count"], 0) + self.assertEqual(self.client.get(f"/api/v1/operations/{self.op.pk}/").status_code, 404) + self.client.force_login(self.owner) + self.assertEqual(self.client.get("/api/v1/operations/").json()["count"], 1) + + def test_the_resource_view_shows_others_jobs_as_summaries(self): + self.client.force_login(self.other) + data = self.client.get("/api/v1/operations/resource/", {"key": "instance:u1"}).json() + self.assertEqual(data["jobs"], []) + [summary] = data["others"] + self.assertEqual(summary["kind"], "test.noop") + for private in ("id", "user", "username", "params"): + self.assertNotIn(private, summary) + + def test_a_resource_the_user_cannot_see_is_refused(self): + stranger = get_user_model().objects.create_user("api_s", "s@example.com", "pw") + self.client.force_login(stranger) + self.assertEqual(self.client.get("/api/v1/operations/resource/", {"key": "instance:u1"}).status_code, 404) + + def test_a_short_operation_is_flagged(self): + claims.admit({"instance:u2": Claim.EXCLUSIVE}, holder="another-request") + self.client.force_login(self.owner) + self.assertTrue(self.client.get("/api/v1/operations/resource/", {"key": "instance:u2"}).json()["short_operation"]) + + def test_cancel_through_the_api(self): + self.client.force_login(self.owner) + response = self.client.post(f"/api/v1/operations/{self.op.pk}/cancel/") + self.assertEqual((response.status_code, response.json()["state"]), (200, "cancelled")) + + def test_a_job_that_cannot_stop_answers_400(self): + Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING) + self.client.force_login(self.owner) + self.assertEqual(self.client.post(f"/api/v1/operations/{self.op.pk}/cancel/").status_code, 400) + + def _labels_seen_by(self, user, label="wvc-test-host/wvc-test-vm"): + op = jobs.submit("test.noop", self.owner, {"uuid": "u2"}, "key-00000002") + Operation.objects.filter(pk=op.pk).update(resource_label=label) + self.client.force_login(user) + listed = [row["resource_label"] for row in self.client.get("/api/v1/operations/").json()["results"] + if row["id"] == str(op.pk)] + detail = self.client.get(f"/api/v1/operations/{op.pk}/").json()["resource_label"] + [job] = self.client.get("/api/v1/operations/resource/", {"key": "instance:u2"}).json()["jobs"] + cancelled = self.client.post(f"/api/v1/operations/{op.pk}/cancel/").json()["resource_label"] + return listed + [detail, job["resource_label"], cancelled] + + def test_a_plain_owner_sees_only_the_vm_name(self): + self.assertEqual(self._labels_seen_by(self.owner), ["wvc-test-vm"] * 4) + + def test_a_compute_name_with_a_slash_stays_hidden(self): + # libvirt refuses "/" in VM names; compute names may contain it + self.assertEqual(self._labels_seen_by(self.owner, "wvc-test-region/wvc-test-host/wvc-test-vm"), + ["wvc-test-vm"] * 4) + + def test_users_who_may_see_hosts_see_the_compute_name(self): + self.owner.user_permissions.add(Permission.objects.get(codename="view_instances", + content_type__app_label="instances")) + self.assertEqual(self._labels_seen_by(self.owner), ["wvc-test-host/wvc-test-vm"] * 4) + + def test_superusers_see_the_compute_name(self): + admin = get_user_model().objects.create_superuser("api_admin", "a@example.com", "pw") + self.assertEqual(self._labels_seen_by(admin), ["wvc-test-host/wvc-test-vm"] * 4) diff --git a/operations/utils.py b/operations/utils.py index 18b1c0e2..dd0c60d3 100644 --- a/operations/utils.py +++ b/operations/utils.py @@ -2,8 +2,9 @@ do not see the hosts get "host unreachable" instead of a connection error's text (which names the host). What a refused request says about the job holding the resource: details for its owner and superusers, a minimal summary for -everyone else. Worker settings from the Settings page. The worker reads them -itself (the settings middleware only runs in gunicorn).""" +everyone else; and who may see jobs on a resource page. Worker settings from +the Settings page. The worker reads them itself (the settings middleware only +runs in gunicorn).""" import re @@ -14,7 +15,9 @@ from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy from libvirt import libvirtError +from accounts.models import UserInstance from appsettings.models import AppSettings +from instances.models import Instance from vrtManager.util import ConnectionFailed from . import jobs @@ -83,6 +86,26 @@ def may_see(op, user): return user.is_superuser or (op.user_id is not None and op.user_id == user.pk) +def shown_label(op, user): + """The job's "/" label, without the compute for users the app hides hosts from. + A compute name may contain "/", a VM name may not (libvirt refuses it).""" + if user.is_superuser or user.has_perm("instances.view_instances"): + return op.resource_label + return op.resource_label.rsplit("/", 1)[-1] + + +def visible(user, key): + if user.is_superuser: + return True + kind, _sep, rest = key.partition(":") + if kind != "instance": + return False # compute, storage and network pages are superuser-only + instances = Instance.objects.filter(uuid=rest) + if user.has_perm("instances.view_instances"): + return instances.exists() + return UserInstance.objects.filter(user=user, instance__in=instances).exists() + + def summary(op, user): base = {"kind": op.kind, "label": str(_(jobs.label_of(op.kind))), "state": op.state, "since": op.created_at} if may_see(op, user): diff --git a/webvirtcloud/tests/test_authorization_matrix.py b/webvirtcloud/tests/test_authorization_matrix.py index 0ffd56d7..527dc38b 100644 --- a/webvirtcloud/tests/test_authorization_matrix.py +++ b/webvirtcloud/tests/test_authorization_matrix.py @@ -46,6 +46,8 @@ ALLOWED_FOR_ANY_USER = { "compute-instance-list": "API: filtered to the user's own instances", "instance-flavor-list": "API: flavor catalogue", "instance-flavor-detail": "API: flavor catalogue", + "operation-list": "API: filtered to the user's own jobs", + "operation-resource": "API: checks per resource who may see it", "schema": "API docs: any authenticated user (SERVE_PERMISSIONS)", "schema-json": "API docs: any authenticated user (SERVE_PERMISSIONS)", "schema-redoc": "API docs: any authenticated user (SERVE_PERMISSIONS)", diff --git a/webvirtcloud/urls-api.py b/webvirtcloud/urls-api.py index 06f610b0..6302eae2 100644 --- a/webvirtcloud/urls-api.py +++ b/webvirtcloud/urls-api.py @@ -4,6 +4,7 @@ from rest_framework_nested import routers from computes.api.viewsets import ComputeArchitecturesView, ComputeViewSet from networks.api.viewsets import NetworkViewSet from interfaces.api.viewsets import InterfaceViewSet +from operations.api.viewsets import OperationViewSet from storages.api.viewsets import StorageViewSet, VolumeViewSet from instances.api.viewsets import FlavorViewSet, \ InstancesViewSet, \ @@ -17,6 +18,7 @@ router.register(r'computes', ComputeViewSet) router.register(r'migrate', MigrateViewSet, basename='instance-migrate') router.register(r'flavor', FlavorViewSet, basename='instance-flavor') router.register(r'instances', InstancesViewSet, basename='instance') +router.register(r'operations', OperationViewSet, basename='operation') compute_router = routers.NestedSimpleRouter(router, r'computes', lookup='compute', use_regex_path=False) compute_router.register(r'instances', InstanceViewSet, basename='compute-instance')