1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00

fix(instances): the VM page offers only the controls the user may use

The page showed controls the backend then refused: power to global
viewers, the console, virt-viewer and VDI controls and the root password
and SSH key forms to viewers, resize to staff, per-vCPU hotplug, video
model and guest agent to non-superusers, clone and options by the clone
permission alone, snapshots without change permission. The page now asks
the views' own rules (owner, is_change, clone and snapshot permissions,
the template rules). OS info, opened from the header, is there for every
viewer; the XML editor and tab restoring after a redirect no longer throw
when a pane is absent; Settings opens on the first section the user has.
The authorization matrix also covers a template VM, the template clone
rule and the template snapshot rule.
This commit is contained in:
catborise 2026-10-07 20:29:52 +03:00
parent 443b31d86b
commit bf1f122984
8 changed files with 294 additions and 71 deletions

View file

@ -85,7 +85,7 @@
{% trans "Resize" %}
</button>
</li>
{% if allow_admin_or_not_template and 'instances.snapshot_instances' in perms %}
{% if can_snapshot %}
<li class="nav-item" role="presentation">
<button class="nav-link action-button" id="snapshots-tab" aria-controls="snapshots" data-bs-toggle="pill" data-bs-target="#snapshots" type="button" role="tab" aria-selected="false">
<span id="action-block" aria-hidden="true">{% bs_icon 'camera' size='2em'%} </span>
@ -93,12 +93,14 @@
</button>
</li>
{% endif %}
{% if show_settings %}
<li class="nav-item" role="presentation">
<button class="nav-link action-button" id="settings-tab" aria-controls="settings" data-bs-toggle="pill" data-bs-target="#settings" type="button" role="tab" aria-selected="false">
<span id="action-block" aria-hidden="true">{% bs_icon 'gear-wide-connected' size='2em'%} </span>
{% trans "Settings" %}
</button>
</li>
{% endif %}
<li class="nav-item" role="presentation">
<button class="nav-link action-button" id="graphics-tab" aria-controls="graphics" data-bs-toggle="pill" data-bs-target="#graphics" type="button" role="tab" aria-selected="false">
<span id="action-block" aria-hidden="true">{% bs_icon 'bar-chart-line' size='2em'%} </span>
@ -117,8 +119,8 @@
{% include 'instances/power_tab.html' %}
{% include 'instances/access_tab.html' %}
{% include 'instances/resize_tab.html' %}
{% include 'instances/snapshots_tab.html' %}
{% include 'instances/settings_tab.html' %}
{% if can_snapshot %}{% include 'instances/snapshots_tab.html' %}{% endif %}
{% if show_settings %}{% include 'instances/settings_tab.html' %}{% endif %}
{% include 'instances/stats_tab.html' %}
{% include 'instances/destroy_tab.html' %}
</div>
@ -156,13 +158,16 @@
}
</script>
<script>
var editor = ace.edit("editor");
editor.getSession().setMode("ace/mode/xml");
// The XML editor exists only for superusers
if (document.getElementById("editor")) {
var editor = ace.edit("editor");
editor.getSession().setMode("ace/mode/xml");
var input = $('input[name="inst_xml"]');
var input = $('input[name="inst_xml"]');
editor.getSession().on("change", function () {
input.val(editor.getSession().getValue());
});
input.val(editor.getSession().getValue());
});
}
</script>
<script>
function random_mac(net) {
@ -269,6 +274,7 @@
$("#video_model_select option[value='" + video_model + "']").prop('selected', true);
}
});
{% if can_open_console %}
$(document).ready(function () {
// set vdi url
$.get("{% url 'vdi_url' compute.id instance.name %}", function(data) {
@ -276,6 +282,7 @@
$("#vdi_url").attr("href", data);
});
});
{% endif %}
{% if request.user.is_superuser %}
$(document).ready(function () {
random_mac('clone-net-mac-0');
@ -669,58 +676,41 @@
});
</script>
<script>
// Open the tab (and sub-tab) a redirect points to with #<pane id>
function show_tab(target) {
var button = document.querySelector('button[data-bs-target="' + target + '"]');
if (button) {
bootstrap.Tab.getOrCreateInstance(button).show();
}
}
var hash = location.hash;
if (~$.inArray(hash, ['#poweron', '#poweroff', '#powercycle', '#suspend', '#resume'])) {
var btnsect = $('#navbtn>li>a');
$(btnsect).each(function () {
if ($(this).attr('href') === '#power') {
$(this).trigger('click');
}
});
var parents = {
'#poweron': '#power', '#poweroff': '#power', '#powercycle': '#power', '#suspend': '#power', '#resume': '#power',
'#resizevm_cpu': '#resize', '#resizevm_mem': '#resize', '#resizevm_disk': '#resize',
'#boot_opt': '#settings', '#disks': '#settings', '#network': '#settings', '#clone': '#settings',
'#xmledit': '#settings', '#vncsettings': '#settings', '#migrate': '#settings', '#options': '#settings',
'#users': '#settings',
'#takesnapshot': '#snapshots', '#takeextsnapshot': '#snapshots', '#managesnapshot': '#snapshots'
};
if (hash === '#resize') {
show_tab('#resize');
} else if (parents[hash]) {
show_tab(parents[hash]);
show_tab(hash);
}
if (~$.inArray(hash, ['#resize', "#resizevm_cpu", "#resizevm_mem", "#resizevm_disk"])) {
var btnsect = $('#navbtn>li>a');
$(btnsect).each(function () {
if ($(this).attr('href') === '#resize') {
$(this).trigger('click');
}
});
}
if (~$.inArray(hash, ['#osinfo', '#boot_opt', "#disks", '#network', '#clone', '#xmledit', '#vncsettings', '#migrate', '#options', '#users'])) {
var btnsect = $('#navbtn>li>a');
$(btnsect).each(function () {
if ($(this).attr('href') === '#settings') {
$(this).trigger('click');
}
});
var btn = $('#settings>div>ul>li>a');
$(btn).each(function () {
if ($(this).attr('href') === hash) {
$(this).trigger('click');
}
});
}
if (~$.inArray(hash, ['#takesnapshot', "#takeextsnapshot", "#managesnapshot"])) {
var btnsect = $('#navbtn>li>a');
$(btnsect).each(function () {
if ($(this).attr('href') === '#snapshots') {
$(this).trigger('click');
}
});
var btn = $('#snapshots>div>ul>li>a');
$(btn).each(function () {
if ($(this).attr('href') === hash) {
$(this).trigger('click');
}
});
// Settings opens on its first section the user has (Boot is superuser-only)
var settings_tabs = document.querySelector('#settings .nav-tabs');
if (settings_tabs && !settings_tabs.querySelector('.nav-link.active')) {
var first = settings_tabs.querySelector('.nav-link:not(#osinfo-tab)');
if (first) {
bootstrap.Tab.getOrCreateInstance(first).show();
}
}
</script>
<script>
function get_osinfo() {
document.querySelector("#settings-tab").click();
document.querySelector("#osinfo-tab").click();
show_tab('#settings');
show_tab('#osinfo');
$.getJSON("{% url 'instances:osinfo' instance.id %}", function (data) {
$.each(data, function() {

View file

@ -7,21 +7,21 @@
{% trans "Console" %}
</button>
</li>
{% if app_settings.SHOW_ACCESS_ROOT_PASSWORD == 'True' %}
{% if app_settings.SHOW_ACCESS_ROOT_PASSWORD == 'True' and can_change %}
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#rootpasswd" type="button" role="tab" aria-controls="rootpasswd" aria-selected="false">
{% trans "Root Password" %}
</button>
</li>
{% endif %}
{% if app_settings.SHOW_ACCESS_SSH_KEYS == 'True' %}
{% if app_settings.SHOW_ACCESS_SSH_KEYS == 'True' and can_change %}
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#sshkeys" type="button" role="tab" aria-controls="sshkeys" aria-selected="false">
{% trans "SSH Keys" %}
</button>
</li>
{% endif %}
{% if instance.status == 1 %}
{% if instance.status == 1 and can_open_console %}
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#vdiconsole" type="button" role="tab" aria-controls="vdiconsole" aria-selected="false">
{% trans "VDI" %}
@ -34,6 +34,9 @@
<div role="tabpanel" class="tab-pane tab-pane-bordered active" id="vnconsole">
<p>{% blocktrans with type=instance.console_type|upper %} This action opens a new window with a {{ type }} connection to the console of the instance.{% endblocktrans %}
</p>
{% if not can_open_console %}
<p>{% trans "Only the owners of this instance and administrators can open its console." %}</p>
{% else %}
{% if instance.console_type == 'vnc' %}
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="scale"
@ -80,9 +83,10 @@
{% else %}
<button class="btn btn-lg btn-success float-end disabled">{% trans "Console" %}</button>
{% endif %}
{% endif %}
<div class="clearfix"></div>
</div>
{% if app_settings.SHOW_ACCESS_ROOT_PASSWORD == 'True' %}
{% if app_settings.SHOW_ACCESS_ROOT_PASSWORD == 'True' and can_change %}
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="rootpasswd">
<p>{% trans "You need shut down your instance and enter a new root password." %}</p>
<form action="{% url 'instances:rootpasswd' instance.id %}" method="post"
@ -105,7 +109,7 @@
<div class="clearfix"></div>
</div>
{% endif %}
{% if app_settings.SHOW_ACCESS_SSH_KEYS == 'True' %}
{% if app_settings.SHOW_ACCESS_SSH_KEYS == 'True' and can_change %}
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="sshkeys">
<p>{% trans "You need shut down your instance and choose your public key." %}</p>
<form action="{% url 'instances:add_public_key' instance.id %}"method="post"
@ -134,7 +138,7 @@
<div class="clearfix"></div>
</div>
{% endif %}
{% if instance.status == 1 %}
{% if instance.status == 1 and can_open_console %}
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="vdiconsole">
<p>{% trans "This action opens a remote viewer with a connection to the console of the instance." %}</p>
<div class="input-group">
@ -150,6 +154,7 @@
</div>
</div>
{% block script %}
{% if can_open_console %}
<script>
function open_console(view_style) {
const sc = $("#scale").is(':checked');
@ -160,4 +165,5 @@
window.open(url, '', 'width=850,height=600')
}
</script>
{% endif %}
{% endblock %}

View file

@ -1,5 +1,8 @@
{% load i18n %}
<div class="tab-pane active" id="power" role="tabpanel">
{% if not can_power %}
<p>{% trans "Only the owners of this instance and administrators can change its power state." %}</p>
{% else %}
<!-- Nav tabs -->
<ul class="nav nav-tabs" role="tablist" aria-label="Instance power actions">
{% if instance.status == 1 or instance.status == 2 %}
@ -144,4 +147,5 @@
</div>
{% endif %}
</div>
{% endif %}
</div>

View file

@ -22,7 +22,7 @@
<!-- Tab panes -->
<div class="tab-content">
<div role="tabpanel" class="tab-pane tab-pane-bordered active" id="resizevm_cpu">
{% if request.user.is_superuser or request.user.is_staff or userinstance.is_change %}
{% if can_change %}
{% if instance.status == 5 or not instance.vcpus %}
<form action="{% url 'instances:resizevm_cpu' instance.id %}" method="post" role="form" aria-label="Resize instance cpu form">{% csrf_token %}
<p class="fw-bold">{% trans "Logical host CPUs" %} : {{ vcpu_host }}</p>
@ -62,6 +62,7 @@
<p class="fw-bold">{% trans "Logical Instance Active/Maximum CPUs" %} : {{ instance.cur_vcpu }} / {{ instance.vcpu }} </p>
<div class="col-sm-3"></div>
<div class="col-sm-6">
{% if request.user.is_superuser %}
{% for id, vcpu in instance.vcpus.items %}
<form action="{% url 'instances:set_vcpu' instance.id %}" method="post" role="form" aria-label="Resize instance cpu form">{% csrf_token %}
<div class="d-grid col-sm-3">
@ -76,6 +77,7 @@
</div>
</form>
{% endfor %}
{% endif %}
</div>
<div class="col-sm-3"></div>
{% endif %}
@ -86,7 +88,7 @@
<div class="clearfix"></div>
</div>
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="resizevm_mem">
{% if request.user.is_superuser or request.user.is_staff or userinstance.is_change %}
{% if can_change %}
<form action="{% url 'instances:resize_memory' instance.id %}" method="post" role="form" aria-label="Resize instance memory form">
{% csrf_token %}
<p class="fw-bold">{% trans "Total host memory" %}: {{ memory_host|filesizeformat }}</p>
@ -127,7 +129,7 @@
<div class="clearfix"></div>
</div>
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="resizevm_disk">
{% if request.user.is_superuser or request.user.is_staff or userinstance.is_change %}
{% if can_change %}
<form action="{% url 'instances:resize_disk' instance.id %}" method="post" role="form" aria-label="Resize instance disk form">
{% csrf_token %}
<p class="fw-bold">{% trans "Disk allocation (GB)" %}:</p>

View file

@ -4,14 +4,14 @@
<div class="tab-pane" id="settings" role="tabpanel">
<!-- Nav tabs -->
<ul class="nav nav-tabs" role="tablist" aria-label="Instance settings">
{% if request.user.is_superuser %}
{% if instance.guest_agent_ready %}
{% if instance.guest_agent_ready %}
<li class="nav-item" role="presentation">
<button class="px-1 nav-link" data-bs-toggle="tab" id="osinfo-tab" data-bs-target="#osinfo" type="button" role="tab" aria-controls="osinfo" aria-selected="false" onclick="get_osinfo();">
<span>{% bs_icon 'info-circle' %} </span>
</button>
</li>
{% endif %}
{% endif %}
{% if request.user.is_superuser %}
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#boot_opt" type="button" role="tab" aria-controls="boot_opt" aria-selected="true">
{% trans "Boot" %}
@ -37,7 +37,7 @@
</button>
</li>
{% endif %}
{% if perms.instances.clone_instances %}
{% if can_clone %}
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#clone" type="button" role="tab" aria-controls="clone" aria-selected="false">
{% trans "Clone" %}
@ -56,7 +56,7 @@
</button>
</li>
{% endif %}
{% if perms.instances.clone_instances %}
{% if can_change %}
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#options" type="button" role="tab" aria-controls="options" aria-selected="false">
{% trans "Options" %}
@ -73,8 +73,10 @@
</ul>
<!-- Tab panes -->
<div class="tab-content">
{% if request.user.is_superuser %}
{% if instance.guest_agent_ready %}
{% include 'instances/info_tab.html' %}
{% endif %}
{% if request.user.is_superuser %}
<div role="tabpanel" class="tab-pane tab-pane-bordered active" id="boot_opt">
<p class="fw-bold">{% trans 'Autostart' %}</p>
<div class="row">
@ -716,7 +718,7 @@
<div class="clearfix"></div>
</div>
{% endif %}
{% if perms.instances.clone_instances %}
{% if can_clone %}
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="clone">
<p class="fw-bold">{% trans "Create a clone" %}</p>
<form class="form" action="{% url 'instances:clone' instance.id %}" method="post" role="form" aria-label="Create clone form">{% csrf_token %}
@ -811,6 +813,8 @@
</form>
<div class="clearfix"></div>
</div>
{% endif %}
{% if can_change %}
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="options">
<p>{% trans "To set instance template name description, shutdown the instance." %}</p>
<form action="{% url 'instances:change_options' instance.id %}" method="post" role="form" aria-label="Set instance template name description form">{% csrf_token %}
@ -847,7 +851,7 @@
</div>
</div>
</form>
{% if request.user.is_superuser %}
<p class="fw-bold">{% trans "To set instance video model, shutdown the instance." %}</p>
<form action="{% url 'instances:set_video_model' instance.id %}" method="post" role="form" aria-label="Set instance video model form">{% csrf_token %}
<div class="row">
@ -914,6 +918,7 @@
</div>
</div>
</form>
{% endif %}
<div class="clearfix"></div>
</div>
{% endif %}

View file

@ -0,0 +1,169 @@
"""
The VM page renders a control only for users the backend lets use it:
power to superusers and owners; the console, virt-viewer file and VDI URL to
superusers and owners (can_open_console); root password, SSH key, resize and
options to superusers and owners with is_change; per-vCPU hotplug, video
model and guest agent to superusers; clone with clone_instances on a template
or with is_change; snapshots with snapshot_instances and is_change, on a
template only for staff. is_staff and the global view_instances permission
alone are read-only.
"""
from unittest.mock import PropertyMock, patch
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.urls import reverse
from lxml import html as lxml_html
from accounts.models import UserInstance
from appsettings.models import AppSettings
from computes.models import Compute
from instances.models import Instance
CONSOLE = {"console", "vv file", "vdi"}
POWER = {"power"}
CHANGE = {"root password", "ssh key", "resize", "options"}
SUPERUSER_ONLY = {"vcpu hotplug", "video model", "guest agent"}
class PageControls:
def setUp(self):
AppSettings.objects.filter(
key__in=["SHOW_ACCESS_ROOT_PASSWORD", "SHOW_ACCESS_SSH_KEYS"]
).update(value="True")
self.compute = Compute.objects.create(
name="ctl-compute", hostname="127.0.0.1:1", login="root", password="", type=1
)
self.instance = Instance.objects.create(
compute=self.compute, name="ctl-vm", uuid="dddddddd-eeee-ffff-0000-111111111111"
)
def user(self, name, staff=False, perms=(), owner=None):
user = get_user_model().objects.create_user(name, password="x", is_staff=staff)
for codename in perms:
user.user_permissions.add(Permission.objects.get(codename=codename))
if owner is not None:
UserInstance.objects.create(instance=self.instance, user=user, **owner)
return user
def controls(self, user, status=1):
self.client.force_login(user)
with patch("instances.models.wvmInstance") as mock_wvm, patch.object(
Compute, "status", new_callable=PropertyMock, return_value=True
):
proxy = mock_wvm.return_value
proxy.get_memory.return_value = 1024
proxy.get_cur_memory.return_value = 1024
proxy.get_max_memory.return_value = 4096
proxy.get_vcpu.return_value = 2
proxy.get_cur_vcpu.return_value = 1
proxy.get_max_cpus.return_value = [1, 2, 3, 4]
# a running VM with hotpluggable vCPUs shows the per-vCPU buttons
proxy.get_vcpus.return_value = {
0: {"enabled": "yes", "hotpluggable": "no"},
1: {"enabled": "no", "hotpluggable": "yes"},
}
for getter in ("get_networks", "get_ifaces", "get_storages", "get_disk_devices",
"get_media_devices", "get_net_devices", "get_snapshot",
"get_external_snapshots"):
getattr(proxy, getter).return_value = []
proxy.get_console_type.return_value = "vnc"
proxy.get_status.return_value = status
response = self.client.get(reverse("instances:instance", args=[self.instance.id]))
self.assertEqual(response.status_code, 200)
doc = lxml_html.fromstring(response.content)
pk = self.instance.id
def form(name):
return bool(doc.xpath("//form[@action=$a]", a=reverse(f"instances:{name}", args=[pk])))
found = {
"console": bool(doc.xpath("//button[@id='consoleBtnGroup']")),
"vv file": bool(doc.xpath("//a[@href=$a]", a=reverse("instances:getvvfile", args=[pk]))),
"vdi": bool(doc.xpath("//*[@id='vdiconsole']")),
"power": form("poweroff") or form("poweron"),
"root password": form("rootpasswd"),
"ssh key": form("add_public_key"),
# a running VM with hotplug shows per-vCPU buttons instead of the CPU form
"resize": form("resize_memory"),
"options": form("change_options"),
"vcpu hotplug": form("set_vcpu"),
"video model": form("set_video_model"),
"guest agent": form("set_guest_agent"),
"clone": bool(doc.xpath("//*[@id='clone']")),
"snapshots": bool(doc.xpath("//*[@id='snapshots']")),
}
return {name for name, present in found.items() if present}
class PageControlsTestCase(PageControls, TestCase):
def test_superuser_sees_every_control(self):
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"}
)
def test_global_viewers_see_nothing_to_act_on(self):
perms = ("view_instances", "clone_instances", "snapshot_instances")
for user in (self.user("ctl_viewer", perms=perms), self.user("ctl_staff", staff=True, perms=perms)):
with self.subTest(user=user.username):
self.assertEqual(self.controls(user), set())
def test_owner_without_change_powers_and_opens_the_console(self):
owner = self.user("ctl_owner", perms=("clone_instances", "snapshot_instances"), owner={})
self.assertEqual(self.controls(owner), CONSOLE | POWER)
def test_owner_with_change_gets_change_clone_and_snapshots(self):
changer = self.user("ctl_changer", perms=("clone_instances", "snapshot_instances"),
owner={"is_change": True})
self.assertEqual(self.controls(changer), CONSOLE | POWER | CHANGE | {"clone", "snapshots"})
def test_owner_with_change_and_no_extra_permissions(self):
changer = self.user("ctl_plain_changer", owner={"is_change": True})
self.assertEqual(self.controls(changer), CONSOLE | POWER | CHANGE)
class TemplatePageControlsTestCase(PageControls, TestCase):
def setUp(self):
super().setUp()
self.instance.is_template = True
self.instance.save()
def test_a_template_is_cloned_by_viewing_it(self):
viewer = self.user("tpl_viewer", perms=("view_instances", "clone_instances"))
self.assertEqual(self.controls(viewer, status=5), {"clone"})
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})
staff = self.user("tpl_staff_changer", staff=True, perms=perms, owner={"is_change": True})
self.assertNotIn("snapshots", self.controls(owner, status=5))
self.assertIn("snapshots", self.controls(staff, status=5))
class TemplateSnapshotRuleTestCase(PageControls, TestCase):
"""The backend side of the template rule: only staff owners (and
superusers) take a snapshot of a template; other owners with is_change
and snapshot_instances are turned away without a libvirt call."""
def setUp(self):
super().setUp()
self.instance.is_template = True
self.instance.save()
def snapshot(self, user):
self.client.force_login(user)
with patch("instances.models.wvmInstance") as mock_wvm:
self.client.post(
reverse("instances:snapshot", args=[self.instance.id]), {"name": "s1", "description": ""}
)
return mock_wvm.return_value.create_snapshot.called
def test_only_staff_owners_snapshot_a_template(self):
perms = ("snapshot_instances",)
owner = self.user("snap_changer", perms=perms, owner={"is_change": True})
staff = self.user("snap_staff_changer", staff=True, perms=perms, owner={"is_change": True})
self.assertFalse(self.snapshot(owner))
self.assertTrue(self.snapshot(staff))

View file

@ -135,6 +135,21 @@ def _instance_page(request, pk, instance, compute):
)
except UserInstance.DoesNotExist:
userinstance = None
# The page offers only what the backend allows this user (get_instance
# perm_type="change", utils.can_open_console); is_staff and the global
# 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
can_change = request.user.is_superuser or bool(userinstance and userinstance.is_change)
can_clone = request.user.has_perm("instances.clone_instances") and (instance.is_template or can_change)
can_snapshot = (
can_change
and request.user.has_perm("instances.snapshot_instances")
and (request.user.is_superuser or request.user.is_staff or not instance.is_template)
)
show_settings = (
request.user.is_superuser or can_manage_console or can_clone or can_change or instance.guest_agent_ready
)
memory_range = [256, 512, 768, 1024, 2048, 3072, 4096, 6144, 8192, 16384]
if instance.memory not in memory_range:

View file

@ -128,6 +128,12 @@ ROLE_EXPECTATIONS = {
}
# A template VM is reached the same way: on templates, snapshot requests and
# poweron are no-ops for these roles, and cloning (which none of them may do
# without instances.clone_instances) has its own rule, see the clone test.
TEMPLATE_ROLE_EXPECTATIONS = ROLE_EXPECTATIONS
def iter_patterns(patterns, prefix="", params=None):
params = params or {}
for p in patterns:
@ -295,6 +301,32 @@ class AuthorizationMatrixTestCase(TestCase):
f"{role}: (unexpectedly reachable, unexpectedly denied)",
)
def test_role_reachability_on_a_template_vm(self):
self.instance.is_template = True
self.instance.save()
for role, (is_staff, view_instances, owner_flags, expected) in TEMPLATE_ROLE_EXPECTATIONS.items():
with self.subTest(role=role):
user = self._make_role_user(role, is_staff, view_instances, owner_flags)
reachable = self._reachable(user)
self.assertEqual(
(sorted(reachable - expected), sorted(expected - reachable)),
([], []),
f"template, {role}: (unexpectedly reachable, unexpectedly denied)",
)
def test_a_template_is_cloned_by_viewing_it_a_vm_needs_change(self):
user = self._make_role_user("cloner", False, True, None)
user.user_permissions.add(Permission.objects.get(codename="clone_instances"))
url = reverse("instances:clone", args=[self.instance.pk])
for is_template, reached in ((False, False), (True, True)):
with self.subTest(is_template=is_template):
self.instance.is_template = is_template
self.instance.save()
with transaction.atomic():
response = self._request(user, url)
transaction.set_rollback(True)
self.assertEqual(response.status_code not in DENIED, reached, response.status_code)
def test_owner_power_actions_succeed_through_web_and_api_with_real_test_driver(self):
import libvirt
from vrtManager.instance import wvmInstance