mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 11:46:49 +00:00
The Logs page shows who added, changed or deleted a compute, from web or API, and every login, failed login, lockout and logout, with the client address and a Result column (Done, Failed, Unknown for older rows). A failed change is recorded with its reason and the error still reaches the user; a refused one is recorded too. - logs.audit.write saves each row in its own savepoint and never raises, so a log write cannot break or undo the action it records. The audit() context manager writes one row when the change ends. - Logs gains success and ip; user, host and instance are wider and date is indexed. The page orders by date then id. - A compute edit names the changed fields; an edit that changes nothing writes no row, and an empty password field is no change. - A compute's VMs leaving the panel get a system row each: owners kept on the Removed VMs page, moved to another compute, restored, left without owners, or the removed record expired. - Job rows carry the result. A job's log entry that cannot be computed is logged and skipped instead of rolling back the finish (test_service.py changes this on purpose). - Logins are recorded by signal receivers; CustomLoginView is removed and the plain LoginView is used. A wrong OTP code is a failed login, counted by django-axes toward the lockout. Panel records and host-side objects follow in the next layers.
104 lines
3.6 KiB
Python
104 lines
3.6 KiB
Python
import ipaddress
|
|
import logging
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
|
|
import libvirt
|
|
from django.db import transaction
|
|
|
|
from accounts.lockout import client_ip
|
|
from logs.models import Logs
|
|
|
|
logger = logging.getLogger("logs.audit")
|
|
|
|
|
|
def write(user, host, instance, message, success=None, ip=None):
|
|
"""
|
|
Save one Logs row. A failure is written to the log file instead and never
|
|
raised, so an audit write cannot break the action it records.
|
|
"""
|
|
user, host, instance = user[:150], host[:255], instance[:120]
|
|
try:
|
|
with transaction.atomic():
|
|
Logs.objects.create(user=user, host=host, instance=instance, message=message, success=success, ip=ip)
|
|
except Exception as e:
|
|
try:
|
|
line = f"audit row not saved ({e!r}): {user} {host} {instance} success={success} ip={ip} {message}"
|
|
logger.warning(line.replace("\n", "\\n"))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def request_ip(request):
|
|
"""The client address of the request, or None if it is empty or not a plain IP address."""
|
|
value = client_ip(request)
|
|
try:
|
|
ipaddress.ip_address(value)
|
|
except ValueError:
|
|
return None
|
|
# a scoped IPv6 address (fe80::1%eth0) is valid for ipaddress but not for GenericIPAddressField
|
|
return None if "%" in value else value
|
|
|
|
|
|
@dataclass
|
|
class Entry:
|
|
action: str
|
|
skip: bool = False
|
|
|
|
|
|
def host_of(compute):
|
|
"""The host column for a Compute, or for a host name already at hand."""
|
|
if compute is None:
|
|
return "-"
|
|
return compute if isinstance(compute, str) else compute.name
|
|
|
|
|
|
def reason(exc, user):
|
|
"""Why an action failed, as text for the audit row; unknown errors give only their class name."""
|
|
from operations import claims
|
|
from operations.utils import busy_message, safe_text
|
|
from vrtManager.util import OperationError
|
|
|
|
if isinstance(exc, claims.Busy):
|
|
return busy_message(exc, user)
|
|
if isinstance(exc, OperationError):
|
|
return str(exc)
|
|
if isinstance(exc, libvirt.libvirtError):
|
|
return safe_text(exc.get_error_message() or str(exc))[:500]
|
|
return type(exc).__name__
|
|
|
|
|
|
@contextmanager
|
|
def audit(request, compute, subject, action, instance=""):
|
|
"""
|
|
Write one row when the block ends: done, or failed with the reason when it
|
|
raises (the exception goes on). The block may set entry.action, or
|
|
entry.skip when nothing was done.
|
|
"""
|
|
entry = Entry(action)
|
|
user, host, ip = request.user.get_username(), host_of(compute), request_ip(request)
|
|
try:
|
|
yield entry
|
|
except Exception as exc:
|
|
write(user, host, instance, f"{subject}: {entry.action}: {reason(exc, request.user)}", False, ip)
|
|
raise
|
|
if not entry.skip:
|
|
write(user, host, instance, f"{subject}: {entry.action}", True, ip)
|
|
|
|
|
|
def failed(request, compute, subject, action, why, instance=""):
|
|
"""Write the failure row of an action that was refused before it started."""
|
|
write(request.user.get_username(), host_of(compute), instance, f"{subject}: {action}: {why}", False, request_ip(request))
|
|
|
|
|
|
def snapshot(obj):
|
|
"""The field values of a model object, for changed_fields; a many-to-many field gives its related pks."""
|
|
values = {f.attname: getattr(obj, f.attname) for f in obj._meta.concrete_fields}
|
|
for f in obj._meta.many_to_many:
|
|
values[f.name] = frozenset(o.pk for o in getattr(obj, f.name).all()) if obj.pk else frozenset()
|
|
return values
|
|
|
|
|
|
def changed_fields(before, after):
|
|
"""The sorted names of the fields whose values differ between two snapshots."""
|
|
return sorted(key.removesuffix("_id") for key in before if before[key] != after[key])
|