mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
feat(logs): a refused page or API call leaves a Logs row
Each 401 or 403 answered to a signed-in user or to a request with credentials (an API token) writes "Access denied (status): method path" with the user and the address. Anonymous refusals are left out, so a scanner cannot fill the table.
This commit is contained in:
parent
bc79c0c88a
commit
9ac1a29516
4 changed files with 51 additions and 2 deletions
|
|
@ -774,7 +774,7 @@ What an update from an older version changes for operators and users:
|
|||
- External snapshots need libvirt 9.9 or newer on the computes. libvirt 9.10 (openEuler 24.03 LTS) refuses to revert a snapshot taken without memory of a running or paused VM ("Invalid target domain state 'disk-snapshot'"); 11.10 reverts it. A VM's snapshots form one chain: after a revert, delete the newer snapshots before taking another. An internal snapshot is reverted only on a shut-off VM. Snapshot names no longer get an `s1.` prefix. A snapshot an older version took of a UEFI VM can only be deleted, not reverted. On NFS, the qemu user needs write access to the image directory to remove merged snapshot files.
|
||||
- A storage volume a VM still needs (a disk, a backing file or a snapshot's file) is not deleted; deleting a VM with its files keeps a file another compute's VM or volume uses at the same path, and is refused before the VM is stopped when one of the other computes cannot be reached.
|
||||
- A VM that vanishes from its compute, or whose compute is deleted, keeps its owners for `INSTANCE_OWNERSHIP_RETENTION_DAYS` (30) in case the same UUID comes back; superusers manage these records on the "Removed VMs" page.
|
||||
- The Logs page records who changed what (computes, users, groups, VM owners, settings, SSH keys, host networks, pools, volumes, interfaces, secrets and network filters) and every login, failed login and lockout, with the address and the result.
|
||||
- The Logs page records who changed what (computes, users, groups, VM owners, settings, SSH keys, host networks, pools, volumes, interfaces, secrets and network filters), every login, failed login and lockout, and every page or API call refused to a signed-in user or an API token, with the address and the result.
|
||||
- On a host whose libvirt cannot change network interfaces (the udev backend: RHEL 9 and later, Debian 12), the interface pages offer no create, start, stop or delete.
|
||||
- The panel is offered in English, Turkish and Simplified Chinese.
|
||||
- With SELinux (RHEL family, openEuler), systemd could not run the virtualenv's programs and the panel did not start. The installer now writes file context rules (`httpd_sys_content_t` for the panel, `bin_t` for `venv/bin`) instead of `chcon`, so a relabel keeps them. A manual installation runs `semanage fcontext -a -t bin_t "/srv/webvirtcloud/venv/bin(/.*)?"` and `restorecon -R /srv/webvirtcloud` once; an installer one runs the installer again.
|
||||
|
|
|
|||
26
logs/tests/test_access_denied.py
Normal file
26
logs/tests/test_access_denied.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""A 401 or 403 answered to a signed-in user or to a request with an API
|
||||
token leaves a Logs row; an anonymous one does not."""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from logs.models import Logs
|
||||
|
||||
|
||||
class AccessDeniedTests(TestCase):
|
||||
def test_a_page_refused_to_a_signed_in_user_is_logged(self):
|
||||
self.client.force_login(get_user_model().objects.create_user("plain"))
|
||||
response = self.client.get(reverse("admin:user_list"), HTTP_X_REAL_IP="192.0.2.7")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
row = Logs.objects.get(message__startswith="Access denied")
|
||||
self.assertEqual((row.user, row.success, row.ip), ("plain", False, "192.0.2.7"))
|
||||
self.assertEqual(row.message, "Access denied (403): GET /admin/users/")
|
||||
|
||||
def test_a_refused_api_token_is_logged(self):
|
||||
response = self.client.get(reverse("instance-list"), HTTP_AUTHORIZATION="Token wvc_" + "0" * 64)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(Logs.objects.get(message__startswith="Access denied").message, "Access denied (401): GET /api/v1/instances/")
|
||||
|
||||
def test_an_anonymous_refusal_is_not_logged(self):
|
||||
self.assertEqual(self.client.get(reverse("instance-list")).status_code, 401)
|
||||
self.assertFalse(Logs.objects.filter(message__startswith="Access denied").exists())
|
||||
|
|
@ -7,7 +7,7 @@ from django.shortcuts import redirect, render
|
|||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from libvirt import libvirtError
|
||||
from logs.audit import request_ip
|
||||
from logs.audit import request_ip, write
|
||||
from operations import claims, jobs
|
||||
from operations.utils import busy_message, public_error_text, said, summary, words
|
||||
|
||||
|
|
@ -63,6 +63,28 @@ class RequestIdMiddleware:
|
|||
return response
|
||||
|
||||
|
||||
class AccessDeniedMiddleware:
|
||||
"""
|
||||
A Logs row for each 401 or 403 answered to a signed-in user or to a request
|
||||
with credentials (an API token). Anonymous requests are left out: a
|
||||
scanner would fill the table.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
if response.status_code in (401, 403):
|
||||
user = getattr(request, "user", None)
|
||||
signed_in = user is not None and user.is_authenticated
|
||||
if signed_in or "Authorization" in request.headers:
|
||||
write(user.get_username() if signed_in else "", "-", "",
|
||||
f"Access denied ({response.status_code}): {request.method} {request.path}", False,
|
||||
request_ip(request))
|
||||
return response
|
||||
|
||||
|
||||
class ContentSecurityPolicyMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ INSTALLED_APPS = [
|
|||
|
||||
MIDDLEWARE = [
|
||||
"webvirtcloud.middleware.RequestIdMiddleware",
|
||||
"webvirtcloud.middleware.AccessDeniedMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue