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, uuid=""): """ 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, uuid = user[:150], host[:255], instance[:120], uuid[:36] try: with transaction.atomic(): Logs.objects.create(user=user, host=host, instance=instance, instance_uuid=uuid, message=message, success=success, ip=ip) except Exception as e: try: line = f"audit row not saved ({e!r}): {user} {host} {instance} {uuid} success={success} ip={ip} {message}" logger.warning(line.replace("\r", "\\r").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 uuid: str = "" 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 exc.args[0] # English: str() is the reader's language 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="", uuid=""): """ 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, entry.uuid, or entry.skip when nothing was done. """ entry = Entry(action, uuid=uuid) 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, entry.uuid) raise if not entry.skip: write(user, host, instance, f"{subject}: {entry.action}", True, ip, entry.uuid) def failed(request, compute, subject, action, why, instance="", uuid=""): """Write the failure row of an action that was refused before it started.""" from vrtManager.util import OperationError if isinstance(why, OperationError): why = why.args[0] # English: str() is the reader's language write(request.user.get_username(), host_of(compute), instance, f"{subject}: {action}: {why}", False, request_ip(request), uuid) def audit_vm(request, instance, action): """audit() for an operation on a VM: the row names the VM and carries its UUID.""" return audit(request, instance.compute, f"VM {instance.name}", action, instance=instance.name, uuid=instance.uuid) def failed_vm(request, instance, action, why): """failed() for an operation on a VM, such as one whose error came back as a reply.""" failed(request, instance.compute, f"VM {instance.name}", action, why, instance=instance.name, uuid=instance.uuid) 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])