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

Merge branch 'fix/wave0-authorization-matrix' into wave0-integration

# Conflicts:
#	datasource/views.py
This commit is contained in:
catborise 2026-10-02 22:14:40 +03:00
commit 51db12acfe
12 changed files with 390 additions and 75 deletions

View file

@ -447,6 +447,38 @@ python -m unittest discover -s vrtManager -p "test_*.py"
> python manage.py test
> ```
## Users, Roles and Permissions
WebVirtCloud combines three kinds of access rights:
- **Superuser** (`is_superuser`): full administration — computes, storage pools, networks, interfaces, nwfilters, secrets, app settings, users, and every VM.
- **Global permissions** (Django permissions, set per user or group in the admin pages):
- `instances.view_instances` — **read-only auditor role**: sees every VM in lists and detail pages, but cannot change them and cannot open their consoles.
- `instances.clone_instances` — may clone VMs (see the table for which ones).
- `instances.snapshot_instances` — may create, revert and delete snapshots of VMs they may change.
- `instances.passwordless_console` — the console page fills in the VNC password automatically (granted to all users by default).
- `accounts.change_password` — may change their own password.
- **Per-VM ownership** (an owner entry for a user on a VM, managed by a superuser on the VM's page) with three flags: `is_change`, `is_delete`, `is_vnc`. An owner without flags may see, start and stop the VM and open its console.
| Action on a VM | Superuser | Owner | Owner flag / permission needed | `view_instances` only |
|---|---|---|---|---|
| See the VM (list, detail) | ✅ | ✅ | — | ✅ |
| Start, shut down, power cycle, force off | ✅ | ✅ | — | ❌ |
| Open the console (noVNC, `.vv` file for virt-viewer, VDI URL) | ✅ | ✅ | — | ❌ |
| Resize CPU/memory/disk, set root password, add SSH key, change title/description | ✅ | ✅ | `is_change` | ❌ |
| Change console settings (type, VNC password, keymap) | ✅ | ✅ | `is_change` and `is_vnc` | ❌ |
| Snapshots | ✅ | ✅ | `is_change` and `instances.snapshot_instances` | ❌ |
| Clone | ✅ | ✅ | `is_change` and `instances.clone_instances` (templates: viewing the template is enough) | templates only, with `instances.clone_instances` |
| Delete the VM | ✅ | ✅ | `is_delete` | ❌ |
| Disks and media (add, attach, edit, detach, delete volumes; CD-ROM/ISO) | ✅ | ❌ | — | ❌ |
| Network interfaces, boot options, vCPU hotplug, autostart, guest agent, suspend/resume, migrate, raw XML, owners | ✅ | ❌ | — | ❌ |
Notes:
- Only superusers choose disk names and MAC addresses when cloning; for other users the server derives them.
- `is_staff` gives no access to VMs and does not reveal console settings or VNC passwords by itself. On VMs where a staff user already has `is_change`, staff may also change VMs that are marked as templates; other owners cannot.
- On VM pages, users without ownership or `view_instances` get **404** (the VM's existence is not revealed), and users who can see a VM but lack the right for an action get **403**. Superuser-only pages answer **403** to everyone else; the console page answers **403** both for a missing VM and for a VM the user may not open.
## LDAP Configuration
The config options below can be changed in `webvirtcloud/settings.py` file. Variants for Active Directory and OpenLDAP are shown. This is a minimal config to get LDAP running, for further info read the [django-auth-ldap documentation](https://django-auth-ldap.readthedocs.io).

View file

@ -1,6 +1,7 @@
from virtsecrets.views import secrets
from django.urls import include, path
from admin.decorators import superuser_only
from django.urls import URLPattern, URLResolver, include, path
from interfaces.views import interface, interfaces
from networks.views import network, networks
from nwfilters.views import nwfilter, nwfilters
@ -8,6 +9,22 @@ from storages.views import create_volume, get_volumes, storage, storages
from . import forms, views
def superuser_only_patterns(patterns):
"""
Wrap every view below these patterns with superuser_only. Everything under
/computes/ is host administration (ROADMAP S-10), so the check is applied
to the whole namespace instead of per view, and a view added here later
cannot be exposed by forgetting a decorator.
"""
for pattern in patterns:
if isinstance(pattern, URLResolver):
superuser_only_patterns(pattern.url_patterns)
elif isinstance(pattern, URLPattern):
pattern.callback = superuser_only(pattern.callback)
return patterns
urlpatterns = [
path("", views.computes, name="computes"),
path(
@ -77,3 +94,5 @@ urlpatterns = [
),
),
]
superuser_only_patterns(urlpatterns)

View file

@ -86,53 +86,35 @@ except ImportError:
def get_connection_infos(token):
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from accounts.models import UserInstance
from instances.models import Instance
from instances.utils import can_open_console
from vrtManager.instance import wvmInstance
host = None
uuid = None
user_id = None
# The console view signs exactly "host:uuid:user_id". Anything else
# (unsigned, expired, or without the user part) is rejected, because the
# per-user access check below depends on user_id.
signer = TimestampSigner(salt="console.novnc")
try:
unsigned = signer.unsign(token, max_age=120)
parts = unsigned.split(":")
if len(parts) >= 2:
host = int(parts[0])
uuid = parts[1]
if len(parts) >= 3:
user_id = int(parts[2])
host, uuid, user_id = unsigned.split(":")
host = int(host)
user_id = int(user_id)
except (BadSignature, SignatureExpired, ValueError, TypeError):
if "test" in sys.argv:
try:
temptoken = token.split("-", 1)
host = int(temptoken[0])
uuid = temptoken[1]
except Exception:
raise
else:
raise PermissionError("Invalid or expired console token")
if host is None or uuid is None:
raise ValueError("Invalid console token format")
raise PermissionError("Invalid or expired console token")
try:
instance = Instance.objects.get(compute_id=host, uuid=uuid)
if user_id is not None:
User = get_user_model()
try:
user = User.objects.get(id=user_id)
if not user.is_active:
raise PermissionError("User account is inactive or disabled")
if not user.is_superuser and not user.has_perm("instances.view_instances"):
if not UserInstance.objects.filter(instance=instance, user=user).exists():
raise PermissionError("User no longer has access to this instance")
except User.DoesNotExist:
raise PermissionError("User not found")
User = get_user_model()
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
raise PermissionError("User not found")
if not user.is_active:
raise PermissionError("User account is inactive or disabled")
if not can_open_console(user, instance):
raise PermissionError("User no longer has access to this instance")
conn = wvmInstance(
instance.compute.hostname,

View file

@ -172,11 +172,13 @@ class ConsoleViewsTestCase(TestCase):
response = self.client.get(url)
# Non-permitted user attempting to view other user's instance
self.assertEqual(response.status_code, 500)
self.assertEqual(response.status_code, 403)
self.assertIn("permission", response.content.decode("utf-8").lower())
@patch("console.views.wvmInstance")
def test_console_user_with_view_instances_perm(self, mock_wvm):
def test_console_view_instances_perm_does_not_open_console(self, mock_wvm):
# ROADMAP S-09: global view_instances is a read-only role; the console
# is for superusers and VM owners only.
mock_conn = MagicMock()
mock_conn.get_console_type.return_value = "vnc"
mock_conn.get_console_websocket_port.return_value = None
@ -189,7 +191,8 @@ class ConsoleViewsTestCase(TestCase):
url = reverse("console") + f"?token={self.token}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.status_code, 403)
self.assertTemplateNotUsed(response, "console-vnc-lite.html")
@patch("console.views.wvmInstance")
@override_settings(WS_PUBLIC_PATH="/novncd/")
@ -540,6 +543,57 @@ class NovncdDaemonLogicTestCase(TestCase):
with self.assertRaises(PermissionError):
novncd_mod.get_connection_infos(token)
def test_get_connection_infos_rejects_signed_token_without_user(self):
# The console view always signs host:uuid:user_id; a token without the
# user part would skip the per-user access check entirely.
compute = Compute.objects.create(
name="novnc-2part-compute", hostname="127.0.0.1", login="root", password="", type=1
)
instance = Instance.objects.create(
compute=compute,
name="novnc-2part-vm",
uuid="66666666-6666-6666-6666-666666666666",
)
signer = TimestampSigner(salt="console.novnc")
token = signer.sign(f"{compute.id}:{instance.uuid}")
with self.assertRaises(PermissionError):
novncd_mod.get_connection_infos(token)
def test_get_connection_infos_rejects_unsigned_token_even_under_test_runner(self):
# sys.argv contains "test" while this suite runs; production code must
# not change behaviour based on that.
compute = Compute.objects.create(
name="novnc-unsigned-compute", hostname="127.0.0.1", login="root", password="", type=1
)
instance = Instance.objects.create(
compute=compute,
name="novnc-unsigned-vm",
uuid="88888888-8888-8888-8888-888888888888",
)
with self.assertRaises(PermissionError):
novncd_mod.get_connection_infos(f"{compute.id}-{instance.uuid}")
def test_get_connection_infos_view_instances_user_is_not_enough(self):
User = get_user_model()
viewer = User.objects.create_user(username="novnc_viewer", password="password")
viewer.user_permissions.add(Permission.objects.get(codename="view_instances"))
compute = Compute.objects.create(
name="novnc-viewer-compute", hostname="127.0.0.1", login="root", password="", type=1
)
instance = Instance.objects.create(
compute=compute,
name="novnc-viewer-vm",
uuid="55555555-5555-5555-5555-555555555555",
)
signer = TimestampSigner(salt="console.novnc")
token = signer.sign(f"{compute.id}:{instance.uuid}:{viewer.id}")
with self.assertRaises(PermissionError):
novncd_mod.get_connection_infos(token)
def test_get_connection_infos_inactive_user(self):
User = get_user_model()
inactive_user = User.objects.create_user(

View file

@ -1,14 +1,14 @@
# pylint: disable=no-name-in-module,no-member
import re
from accounts.models import UserInstance
from appsettings.settings import app_settings
from django.conf import settings
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.http.response import HttpResponseServerError
from django.http.response import HttpResponseForbidden
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
from instances.models import Instance
from instances.utils import can_open_console
from libvirt import libvirtError
from vrtManager.instance import wvmInstance
@ -79,24 +79,16 @@ def console(request):
if host is None or uuid is None:
raise ValueError("Invalid token")
if not request.user.is_superuser and not request.user.has_perm(
"instances.view_instances"
):
try:
userInstance = UserInstance.objects.get(
instance__compute_id=host,
instance__uuid=uuid,
user__id=request.user.id,
)
instance = Instance.objects.get(compute_id=host, uuid=uuid)
except UserInstance.DoesNotExist:
instance = None
console_error = _(
"User does not have permission to access console or host/instance not exist"
)
return HttpResponseServerError(console_error)
else:
instance = Instance.objects.get(compute_id=host, uuid=uuid)
instance = Instance.objects.filter(compute_id=host, uuid=uuid).first()
if instance is None and request.user.is_superuser:
raise Instance.DoesNotExist
# Same answer for "no such VM" and "not allowed", so the response
# does not reveal whether a VM exists.
if instance is None or not can_open_console(request.user, instance):
console_error = _(
"User does not have permission to access console or host/instance not exist"
)
return HttpResponseForbidden(console_error)
# Generate secure signed token for noVNC daemon
token = signer.sign(f"{host}:{uuid}:{request.user.id}")

View file

@ -2,8 +2,11 @@ import socket
from accounts.models import UserInstance, UserSSHKey
from computes.models import Compute
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, render
from instances.models import Instance
from instances.utils import can_open_console
from libvirt import libvirtError
from vrtManager.instance import wvmInstance
@ -94,6 +97,10 @@ def get_vdi_url(request, compute_id, vname):
:return:
"""
compute = get_object_or_404(Compute, pk=compute_id)
# The VDI URL points straight at the VM's console: same rule as the console.
instance = Instance.objects.filter(compute=compute, name=vname).first()
if instance is None or not can_open_console(request.user, instance):
raise PermissionDenied
try:
conn = wvmInstance(

View file

@ -0,0 +1,74 @@
"""
ROADMAP Wave 0 item 5 (S-09): one rule decides who may open a VM's console
(noVNC page, novncd, the virt-viewer .vv file, the VDI URL): a superuser or
an owner of the VM. The global view_instances permission is read-only.
"""
from unittest.mock import MagicMock, 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 accounts.models import UserInstance
from computes.models import Compute
from instances.models import Instance
from instances.utils import can_open_console
class CanOpenConsoleTestCase(TestCase):
def setUp(self):
User = get_user_model()
self.superuser = User.objects.create_superuser(
username="cons_super", email="cons_super@example.com", password="x"
)
self.owner = User.objects.create_user(username="cons_owner", password="x")
self.inactive_owner = User.objects.create_user(
username="cons_inactive", password="x", is_active=False
)
self.viewer = User.objects.create_user(username="cons_viewer", password="x")
self.viewer.user_permissions.add(Permission.objects.get(codename="view_instances"))
self.stranger = User.objects.create_user(username="cons_stranger", password="x")
self.compute = Compute.objects.create(
name="cons-compute", hostname="127.0.0.1:1", login="root", password="", type=1
)
self.instance = Instance.objects.create(
compute=self.compute,
name="cons-vm",
uuid="77777777-8888-9999-aaaa-bbbbbbbbbbbb",
)
UserInstance.objects.create(instance=self.instance, user=self.owner)
UserInstance.objects.create(instance=self.instance, user=self.inactive_owner)
def test_superuser_and_owner_may_open_console(self):
self.assertTrue(can_open_console(self.superuser, self.instance))
self.assertTrue(can_open_console(self.owner, self.instance))
def test_view_instances_stranger_and_inactive_owner_may_not(self):
self.assertFalse(can_open_console(self.viewer, self.instance))
self.assertFalse(can_open_console(self.stranger, self.instance))
self.assertFalse(can_open_console(self.inactive_owner, self.instance))
def test_owner_can_download_vv_file(self):
self.client.force_login(self.owner)
with patch("instances.views.wvmInstances") as mock_conn_cls:
conn = mock_conn_cls.return_value
conn.graphics_type.return_value = "vnc"
conn.graphics_listen.return_value = "127.0.0.1"
conn.graphics_port.return_value = "5900"
conn.domain_name.return_value = "cons-vm"
conn.graphics_passwd.return_value = "secret"
res = self.client.get(reverse("instances:getvvfile", args=[self.instance.id]))
self.assertEqual(res.status_code, 200)
self.assertIn(b"[virt-viewer]", res.content)
def test_owner_can_get_vdi_url(self):
self.client.force_login(self.owner)
with patch("datasource.views.wvmInstance", MagicMock()), patch(
"datasource.views.get_hostname_by_ip", return_value="host"
):
res = self.client.get(
reverse("vdi_url", args=[self.compute.id, self.instance.name])
)
self.assertEqual(res.status_code, 200)

View file

@ -805,13 +805,6 @@ class InstancesTestCase(TestCase):
)
self.assertEqual(response.status_code, 200)
def test_check_instance(self):
response = self.client.get(
reverse("instances:check_instance", args=["test-vm"])
)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(response.content, {"vname": "test-vm", "exists": True})
def test_start_template(self):
# starting templates must fail
self.assertEqual(self.instance.status, 5)

View file

@ -82,6 +82,5 @@ urlpatterns = [
),
path("guess_clone_name/", views.guess_clone_name, name="guess_clone_name"),
path("random_mac_address/", views.random_mac_address, name="random_mac_address"),
path("check_instance/<vname>/", views.check_instance, name="check_instance"),
path("<int:pk>/sshkeys/", views.sshkeys, name="sshkeys"),
]

View file

@ -14,6 +14,20 @@ from vrtManager.instance import wvmInstance, wvmInstances
from .models import Instance
def can_open_console(user, instance):
"""
The single rule for console access (noVNC page, novncd, the virt-viewer
.vv file and the VDI URL): an active superuser or an active owner of the
VM. The global view_instances permission is read-only and does not grant
an interactive console (ROADMAP S-09).
"""
if not user.is_active:
return False
if user.is_superuser:
return True
return UserInstance.objects.filter(instance=instance, user=user).exists()
def get_clone_free_names(size=10):
prefix = app_settings.CLONE_INSTANCE_DEFAULT_PREFIX
free_names = []

View file

@ -255,6 +255,7 @@ def osinfo(request, pk):
return JsonResponse(results)
@superuser_only
def guess_mac_address(request, vname):
data = {"vname": vname}
mac = utils.get_dhcp_mac_address(vname)
@ -264,12 +265,14 @@ def guess_mac_address(request, vname):
return JsonResponse(data)
@superuser_only
def random_mac_address(request):
data = dict()
data["mac"] = utils.get_random_mac_address()
return JsonResponse(data)
@superuser_only
def guess_clone_name(request):
dhcp_file = "/srv/webvirtcloud/dhcpd.conf"
prefix = app_settings.CLONE_INSTANCE_DEFAULT_PREFIX
@ -288,14 +291,6 @@ def guess_clone_name(request):
return JsonResponse({})
def check_instance(request, vname):
instance = Instance.objects.filter(name=vname)
data = {"vname": vname, "exists": False}
if instance:
data["exists"] = True
return JsonResponse(data)
def sshkeys(request, pk):
"""
:param request:
@ -1829,6 +1824,9 @@ def change_options(request, pk):
def getvvfile(request, pk):
instance = get_instance(request.user, pk)
# The .vv file contains the VNC password: same rule as the console.
if not utils.can_open_console(request.user, instance):
raise PermissionDenied
conn = wvmInstances(
instance.compute.hostname,
instance.compute.login,

View file

@ -0,0 +1,151 @@
"""
Authorization matrix (ROADMAP Wave 0 item 5, S-09/S-10).
Walks every named URL pattern and checks that an authenticated user without
any relation to a VM or compute cannot reach it, unless the URL is in the
explicit allowlist below. A new endpoint added without an authorization check
makes this test fail, so the allowlist must be extended deliberately.
Limitation: the compute points at a closed port, so a view that turns a
libvirt connection error into 404 looks "denied" here even without an
authorization check. Endpoints where that matters (consoles) get dedicated
tests with libvirt mocked.
"""
from unittest.mock import MagicMock, patch
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import Client, TestCase
from django.urls import NoReverseMatch, URLPattern, URLResolver, get_resolver, reverse
from computes.models import Compute
from instances.models import Instance
# URL name -> why any authenticated user may reach it.
ALLOWED_FOR_ANY_USER = {
"index": "landing page",
"instances:index": "lists only the user's own instances",
"accounts:login": "login page",
"accounts:email_otp": "part of the login flow (S-12 tracks its own issues)",
"accounts:logout": "logout",
"accounts:profile": "the user's own profile",
"accounts:ssh_key_create": "the user's own SSH keys",
"set_language": "UI language switch",
"rest_framework:login": "DRF browsable API login",
"rest_framework:logout": "DRF browsable API logout",
"console": "renders an error without a valid token; access is checked per VM",
"ds_openstack_index": "static cloud-init datasource index (F-14)",
"instance-list": "API: filtered to the user's own instances",
"compute-instance-list": "API: filtered to the user's own instances",
"instance-flavor-list": "API: flavor catalogue",
"instance-flavor-detail": "API: flavor catalogue",
"schema": "OpenAPI schema (SERVE_PERMISSIONS is S-21, Wave 3)",
"schema-json": "OpenAPI schema (SERVE_PERMISSIONS is S-21, Wave 3)",
"schema-redoc": "API docs (SERVE_PERMISSIONS is S-21, Wave 3)",
"schema-swagger-ui": "API docs (SERVE_PERMISSIONS is S-21, Wave 3)",
}
# Endpoints that expose a VM's console (or its VNC password) and therefore
# must follow the console rule: superuser or VM owner, not global view_instances.
CONSOLE_ENDPOINTS = ["instances:getvvfile", "vdi_url"]
DENIED = (403, 404)
def iter_patterns(patterns, prefix="", params=None):
params = params or {}
for p in patterns:
own = {**{g: None for g in p.pattern.regex.groupindex}, **p.pattern.converters}
if isinstance(p, URLResolver):
ns = f"{prefix}{p.namespace}:" if p.namespace else prefix
yield from iter_patterns(p.url_patterns, ns, {**params, **own})
elif isinstance(p, URLPattern) and p.name:
yield prefix + p.name, {**params, **own}
class AuthorizationMatrixTestCase(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username="matrix_plain", password="x")
self.viewer = User.objects.create_user(username="matrix_viewer", password="x")
self.viewer.user_permissions.add(Permission.objects.get(codename="view_instances"))
# Port 1 is closed, so any view that reaches libvirt fails fast.
self.compute = Compute.objects.create(
name="matrix-compute", hostname="127.0.0.1:1", login="root", password="", type=1
)
self.instance = Instance.objects.create(
compute=self.compute,
name="matrix-vm",
uuid="22222222-3333-4444-5555-666666666666",
)
self.client = Client(raise_request_exception=False)
def _kwargs(self, params):
kwargs = {}
for name, converter in params.items():
if name in ("compute_id", "compute_pk"):
kwargs[name] = self.compute.pk
elif name in ("pk", "instance_id"):
kwargs[name] = self.instance.pk if converter else str(self.instance.pk)
elif name == "vname":
kwargs[name] = self.instance.name
elif name == "format":
kwargs[name] = "json"
elif converter is not None and type(converter).__name__ == "IntConverter":
kwargs[name] = 1
else:
kwargs[name] = "x"
return kwargs
def _request(self, user, url):
self.client.force_login(user)
response = self.client.get(url)
if response.status_code == 405:
self.client.force_login(user)
response = self.client.post(url, {})
return response
def _url(self, name, params):
return reverse(name, kwargs=self._kwargs(params))
def test_plain_user_cannot_reach_anything_outside_the_allowlist(self):
reachable = []
for name, params in iter_patterns(get_resolver().url_patterns):
if name in ALLOWED_FOR_ANY_USER:
continue
try:
url = self._url(name, params)
except NoReverseMatch:
reachable.append(f"{name}: cannot build URL, extend _kwargs()")
continue
response = self._request(self.user, url)
if response.status_code not in DENIED:
reachable.append(f"{name} ({url}) -> {response.status_code}")
self.assertEqual(
reachable,
[],
"Reachable by a user without any VM/compute relation:\n" + "\n".join(reachable),
)
def test_allowlist_only_names_existing_urls(self):
names = {name for name, _ in iter_patterns(get_resolver().url_patterns)}
self.assertEqual(sorted(set(ALLOWED_FOR_ANY_USER) - names), [])
def test_global_view_permission_does_not_open_consoles(self):
params = dict(iter_patterns(get_resolver().url_patterns))
reachable = []
# Mock libvirt so the views would succeed if authorization let them through.
with patch("datasource.views.wvmInstance", MagicMock()), patch(
"datasource.views.get_hostname_by_ip", return_value="host"
), patch("instances.views.wvmInstances", MagicMock()):
for name in CONSOLE_ENDPOINTS:
url = self._url(name, params[name])
response = self._request(self.viewer, url)
if response.status_code not in DENIED:
reachable.append(f"{name} ({url}) -> {response.status_code}")
self.assertEqual(
reachable,
[],
"Console endpoints reachable with only global view_instances:\n"
+ "\n".join(reachable),
)