1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 12:46:49 +00:00

fix(instances): a template is not power-cycled on, and deleted only by superusers and staff owners

Power cycle force-stopped and started a template, which power on refuses.
A non-staff owner with is_delete could delete a template that the
template rule otherwise leaves to superusers and staff owners;
get_instance(perm_type="delete") now refuses it, and the page offers
Destroy by the same rule.
This commit is contained in:
catborise 2026-10-07 23:57:10 +03:00
parent e481b24240
commit 70bed79c60
8 changed files with 98 additions and 16 deletions

View file

@ -9,7 +9,7 @@
{% endblock page_heading %}
{% block content %}
{% if request.user.is_superuser or userinstance.is_delete %}
{% if can_delete %}
{% if instance.status == 3 %}
<div class="alert alert-danger">
{% trans "Instance is suspended, cannot destroy!" %}

View file

@ -12,7 +12,7 @@
<div class="tab-content">
<div role="tabpanel" class="tab-pane tab-pane-bordered active" id="destroy">
<p>{% trans 'This action starts remove instance process' %}</p>
{% if request.user.is_superuser or userinstance.is_delete %}
{% if can_delete %}
{% if instance.status == 3 %}
<a class="btn btn-lg btn-success disabled float-end">{% trans "Destroy" %}</a>
{% else %}

View file

@ -89,6 +89,8 @@ class PageControls:
"resize": form("resize_memory"),
"options": form("change_options"),
"console settings": form("update_console"),
# a link to the destroy confirmation page
"destroy": bool(doc.xpath("//a[@href=$a]", a=reverse("instances:destroy", args=[pk]))),
"vcpu hotplug": form("set_vcpu"),
"video model": form("set_video_model"),
"guest agent": form("set_guest_agent"),
@ -104,7 +106,7 @@ class PageControlsTestCase(PageControls, TestCase):
superuser = get_user_model().objects.create_superuser("ctl_super", "ctl_super@example.com", "x")
self.assertEqual(
self.controls(superuser),
CONSOLE | POWER | CHANGE | SUPERUSER_ONLY | {"clone", "snapshots", "console settings"},
CONSOLE | POWER | CHANGE | SUPERUSER_ONLY | {"clone", "snapshots", "console settings", "destroy"},
)
def test_global_viewers_see_nothing_to_act_on(self):
@ -150,6 +152,12 @@ class TemplatePageControlsTestCase(PageControls, TestCase):
self.assertNotIn("console settings", self.controls(owner, status=5))
self.assertIn("console settings", self.controls(staff, status=5))
def test_only_staff_owners_destroy_a_template(self):
owner = self.user("tpl_deleter", owner={"is_delete": True})
staff = self.user("tpl_staff_deleter", staff=True, owner={"is_delete": True})
self.assertNotIn("destroy", self.controls(owner, status=5))
self.assertIn("destroy", self.controls(staff, status=5))
def test_snapshots_of_a_template_are_for_staff_owners(self):
perms = ("clone_instances", "snapshot_instances")
owner = self.user("tpl_changer", perms=perms, owner={"is_change": True})

View file

@ -0,0 +1,64 @@
"""
A template is not started (power on or power cycle, web or API) and is
changed or deleted only by superusers and staff owners.
"""
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from accounts.models import UserInstance
from computes.models import Compute
from instances.models import Instance
class TemplateRulesTestCase(TestCase):
def setUp(self):
compute = Compute.objects.create(name="tpl-c", hostname="192.0.2.5", login="root", password="", type=1)
self.template = Instance.objects.create(
compute=compute, name="tpl-vm", uuid="ffffffff-0000-1111-2222-333333333333", is_template=True
)
self.owner = get_user_model().objects.create_user("tpl_owner", password="x")
UserInstance.objects.create(instance=self.template, user=self.owner, is_delete=True)
self.staff = get_user_model().objects.create_user("tpl_staff", password="x", is_staff=True)
UserInstance.objects.create(instance=self.template, user=self.staff, is_delete=True)
def test_power_cycle_does_not_start_a_template(self):
api = APIClient()
api.force_login(self.owner)
self.client.force_login(self.owner)
for post in (
lambda: self.client.post(reverse("instances:powercycle", args=[self.template.id])),
lambda: api.post(reverse("compute-instance-powercycle", args=[self.template.compute_id, self.template.id])),
):
with patch("instances.models.wvmInstance") as wvm:
post()
self.assertFalse(wvm.return_value.start.called)
self.assertFalse(wvm.return_value.force_shutdown.called)
def test_only_staff_owners_delete_a_template(self):
self.client.force_login(self.owner)
with patch("instances.models.wvmInstance") as wvm:
response = self.client.post(reverse("instances:destroy", args=[self.template.id]))
self.assertEqual(response.status_code, 403)
self.assertFalse(wvm.return_value.delete.called)
self.client.force_login(self.staff)
with patch("instances.models.wvmInstance") as wvm:
wvm.return_value.instance.isActive.return_value = False
self.client.post(reverse("instances:destroy", args=[self.template.id]))
self.assertTrue(wvm.return_value.delete.called)
def test_the_destroy_confirmation_page_offers_the_form_to_who_may_delete(self):
url = reverse("instances:destroy", args=[self.template.id])
superuser = get_user_model().objects.create_superuser("tpl_root", "r@example.com", "x")
for user, status, form in ((superuser, 200, True), (self.staff, 200, True), (self.owner, 403, False)):
with self.subTest(user=user.username):
self.client.force_login(user)
with patch("instances.models.wvmInstance") as wvm:
wvm.return_value.get_status.return_value = 5
response = self.client.get(url)
self.assertEqual(response.status_code, status)
self.assertEqual('id="delete_form"' in response.content.decode(), form)

View file

@ -137,6 +137,10 @@ def _instance_page(request, pk, instance, compute):
# view_instances permission are read-only.
can_open_console = utils.can_open_console(request.user, instance)
can_power = request.user.is_superuser or userinstance is not None
# get_instance(perm_type="delete"): a template only for superusers and staff owners
can_delete = request.user.is_superuser or bool(
userinstance and userinstance.is_delete and (request.user.is_staff or not instance.is_template)
)
# get_instance(perm_type="change"): a template only for superusers and staff owners
can_change = request.user.is_superuser or bool(
userinstance and userinstance.is_change and (request.user.is_staff or not instance.is_template)
@ -393,9 +397,9 @@ def get_instance(user, pk, perm_type="view"):
perm_type:
- 'view': superuser, has_perm("instances.view_instances"), or UserInstance owner
- 'power': superuser or UserInstance owner
- 'change': superuser or (UserInstance owner and is_change); a template
only for superusers and staff owners
- 'change': superuser or (UserInstance owner and is_change)
- 'delete': superuser or (UserInstance owner and is_delete)
A template is changed or deleted only by superusers and staff owners.
"""
valid_perms = {"view", "power", "change", "delete"}
if perm_type not in valid_perms:
@ -424,7 +428,7 @@ def get_instance(user, pk, perm_type="view"):
if not (user_inst and user_inst.is_change) or (instance.is_template and not user.is_staff):
raise PermissionDenied
elif perm_type == "delete":
if not (user_inst and user_inst.is_delete):
if not (user_inst and user_inst.is_delete) or (instance.is_template and not user.is_staff):
raise PermissionDenied
else:
raise PermissionDenied
@ -573,11 +577,14 @@ def poweron(request, pk):
@serialize_instance_mutation
def powercycle(request, pk):
instance = get_instance(request.user, pk, perm_type="power")
instance.proxy.force_shutdown()
instance.proxy.start()
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Power Cycle")
)
if instance.is_template:
messages.warning(request, _("Templates cannot be started."))
else:
instance.proxy.force_shutdown()
instance.proxy.start()
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Power Cycle")
)
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@ -686,6 +693,8 @@ def destroy(request, pk):
{
"instance": instance,
"userinstance": userinstance,
# get_instance(perm_type="delete") above already refused the rest
"can_delete": True,
},
)