Ephemeral keys for xterm.js. Initial rework of audit logging. All endpoints now return a 401 regardless of presence if not logged in.
This commit is contained in:
@@ -1,17 +1,140 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from unfold.admin import ModelAdmin
|
||||
from unfold.decorators import action # type: ignore
|
||||
|
||||
from .matching import list_api_endpoint_suggestions, list_websocket_endpoint_suggestions
|
||||
from .models import AuditEventType, AuditLog
|
||||
|
||||
|
||||
class AuditEventTypeAdminForm(forms.ModelForm):
|
||||
endpoints_text = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"rows": 8,
|
||||
"placeholder": "/api/v1/servers/\nGET /api/v1/servers/<int:server_id>/\n/ws/servers/*/shell/",
|
||||
}
|
||||
),
|
||||
help_text=(
|
||||
"One endpoint pattern per line. Supports '*' wildcards and optional METHOD prefixes "
|
||||
"like 'GET /api/v1/servers/*'."
|
||||
),
|
||||
label="Endpoint patterns",
|
||||
)
|
||||
ip_whitelist_text = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 4, "placeholder": "10.0.0.1\n192.168.1.0/24"}),
|
||||
help_text="One IP address or CIDR range per line.",
|
||||
label="IP whitelist entries",
|
||||
)
|
||||
ip_blacklist_text = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 4, "placeholder": "203.0.113.10\n198.51.100.0/24"}),
|
||||
help_text="One IP address or CIDR range per line.",
|
||||
label="IP blacklist entries",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = AuditEventType
|
||||
fields = (
|
||||
"key",
|
||||
"title",
|
||||
"description",
|
||||
"kind",
|
||||
"default_severity",
|
||||
"endpoints_text",
|
||||
"ip_whitelist_enabled",
|
||||
"ip_whitelist_text",
|
||||
"ip_blacklist_enabled",
|
||||
"ip_blacklist_text",
|
||||
)
|
||||
|
||||
class Media:
|
||||
js = ("audit/eventtype_form.js",)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
instance = kwargs.get("instance") or getattr(self, "instance", None)
|
||||
if instance and instance.pk:
|
||||
self.fields["endpoints_text"].initial = "\n".join(instance.endpoints or [])
|
||||
self.fields["ip_whitelist_text"].initial = "\n".join(instance.ip_whitelist or [])
|
||||
self.fields["ip_blacklist_text"].initial = "\n".join(instance.ip_blacklist or [])
|
||||
self.fields["endpoints_text"].widget.attrs["data-api-suggestions"] = json.dumps(
|
||||
list_api_endpoint_suggestions()
|
||||
)
|
||||
self.fields["endpoints_text"].widget.attrs["data-ws-suggestions"] = json.dumps(
|
||||
list_websocket_endpoint_suggestions()
|
||||
)
|
||||
|
||||
def _lines_to_list(self, value: str) -> list[str]:
|
||||
results: list[str] = []
|
||||
for line in (value or "").splitlines():
|
||||
candidate = line.strip()
|
||||
if candidate:
|
||||
results.append(candidate)
|
||||
return results
|
||||
|
||||
def clean_endpoints_text(self) -> str:
|
||||
value = self.cleaned_data.get("endpoints_text", "")
|
||||
# Normalize whitespace but keep the raw text for display.
|
||||
lines = self._lines_to_list(value)
|
||||
return "\n".join(lines)
|
||||
|
||||
def save(self, commit: bool = True):
|
||||
instance: AuditEventType = super().save(commit=False)
|
||||
endpoints_text = self.cleaned_data.get("endpoints_text", "")
|
||||
whitelist_text = self.cleaned_data.get("ip_whitelist_text", "")
|
||||
blacklist_text = self.cleaned_data.get("ip_blacklist_text", "")
|
||||
instance.endpoints = self._lines_to_list(endpoints_text)
|
||||
instance.ip_whitelist = self._lines_to_list(whitelist_text)
|
||||
instance.ip_blacklist = self._lines_to_list(blacklist_text)
|
||||
if commit:
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
|
||||
@admin.register(AuditEventType)
|
||||
class AuditEventTypeAdmin(ModelAdmin):
|
||||
list_display = ("key", "title", "default_severity", "created_at")
|
||||
search_fields = ("key", "title", "description")
|
||||
list_filter = ("default_severity",)
|
||||
form = AuditEventTypeAdminForm
|
||||
list_display = ("key", "title", "kind", "default_severity", "created_at")
|
||||
search_fields = ("key", "title", "description", "endpoints")
|
||||
list_filter = ("kind", "default_severity", "ip_whitelist_enabled", "ip_blacklist_enabled")
|
||||
ordering = ("key",)
|
||||
compressed_fields = True
|
||||
fieldsets = (
|
||||
(
|
||||
"Event Type",
|
||||
{
|
||||
"fields": (
|
||||
"key",
|
||||
"title",
|
||||
"description",
|
||||
"kind",
|
||||
"default_severity",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Endpoints",
|
||||
{
|
||||
"fields": ("endpoints_text",),
|
||||
"description": "Only matching endpoints will create audit events.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"IP Controls",
|
||||
{
|
||||
"fields": (
|
||||
"ip_whitelist_enabled",
|
||||
"ip_whitelist_text",
|
||||
"ip_blacklist_enabled",
|
||||
"ip_blacklist_text",
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(AuditLog)
|
||||
@@ -87,5 +210,3 @@ class AuditLogAdmin(ModelAdmin):
|
||||
{"fields": ("metadata",)},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
|
||||
|
||||
class AuditConfig(AppConfig):
|
||||
@@ -10,6 +11,10 @@ class AuditConfig(AppConfig):
|
||||
def ready(self) -> None:
|
||||
# Import signal handlers
|
||||
from . import signals # noqa: F401
|
||||
from .matching import clear_event_type_cache
|
||||
from .models import AuditEventType
|
||||
|
||||
post_save.connect(clear_event_type_cache, sender=AuditEventType)
|
||||
post_delete.connect(clear_event_type_cache, sender=AuditEventType)
|
||||
return super().ready()
|
||||
|
||||
|
||||
|
||||
231
app/apps/audit/matching.py
Normal file
231
app/apps/audit/matching.py
Normal file
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import ipaddress
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from django.urls import URLPattern, URLResolver, get_resolver
|
||||
|
||||
from .models import AuditEventType
|
||||
|
||||
_CACHE_TTL_SECONDS = 15.0
|
||||
_METHOD_RE = re.compile(r"^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+(.+)$", re.IGNORECASE)
|
||||
_REGEX_GROUP_RE = re.compile(r"\(\?P<(?P<name>\w+)>[^)]+\)")
|
||||
_CONVERTER_RE = re.compile(r"<(?:(?P<converter>[^:>]+):)?(?P<name>[^>]+)>")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedEndpointPattern:
|
||||
method: str | None
|
||||
pattern: str
|
||||
|
||||
|
||||
def _normalize_path(value: str) -> str:
|
||||
candidate = (value or "").strip()
|
||||
if not candidate:
|
||||
return ""
|
||||
if "?" in candidate:
|
||||
candidate = candidate.split("?", 1)[0]
|
||||
if not candidate.startswith("/"):
|
||||
candidate = f"/{candidate}"
|
||||
# Collapse duplicate slashes without being clever.
|
||||
while "//" in candidate:
|
||||
candidate = candidate.replace("//", "/")
|
||||
return candidate
|
||||
|
||||
|
||||
def _strip_regex_anchors(value: str) -> str:
|
||||
candidate = value.strip()
|
||||
if candidate.startswith("^"):
|
||||
candidate = candidate[1:]
|
||||
if candidate.endswith("$"):
|
||||
candidate = candidate[:-1]
|
||||
return candidate
|
||||
|
||||
|
||||
def _placeholder_to_wildcard(value: str) -> str:
|
||||
candidate = _strip_regex_anchors(value)
|
||||
candidate = _REGEX_GROUP_RE.sub("*", candidate)
|
||||
candidate = _CONVERTER_RE.sub("*", candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def parse_endpoint_pattern(raw_pattern: str) -> ParsedEndpointPattern | None:
|
||||
# Parse admin-provided patterns like:
|
||||
# - "/api/v1/servers/*"
|
||||
# - "GET /api/v1/servers/<int:server_id>/"
|
||||
# We normalize both Django-style placeholders and regex routes into
|
||||
# fnmatch-friendly wildcard patterns.
|
||||
if not raw_pattern:
|
||||
return None
|
||||
raw = raw_pattern.strip()
|
||||
if not raw:
|
||||
return None
|
||||
method: str | None = None
|
||||
endpoint = raw
|
||||
match = _METHOD_RE.match(raw)
|
||||
if match:
|
||||
method = match.group(1).upper()
|
||||
endpoint = match.group(2)
|
||||
endpoint = _normalize_path(_placeholder_to_wildcard(endpoint))
|
||||
if not endpoint:
|
||||
return None
|
||||
return ParsedEndpointPattern(method=method, pattern=endpoint)
|
||||
|
||||
|
||||
def _endpoint_matches_pattern(pattern: ParsedEndpointPattern, method: str, route: str, path: str) -> bool:
|
||||
if pattern.method and pattern.method != method.upper():
|
||||
return False
|
||||
route_norm = _normalize_path(route)
|
||||
path_norm = _normalize_path(path)
|
||||
return fnmatch.fnmatch(route_norm, pattern.pattern) or fnmatch.fnmatch(path_norm, pattern.pattern)
|
||||
|
||||
|
||||
def _parse_ip_entry(
|
||||
entry: str,
|
||||
) -> ipaddress.IPv4Address | ipaddress.IPv6Address | ipaddress.IPv4Network | ipaddress.IPv6Network | None:
|
||||
raw = (entry or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
if "/" in raw:
|
||||
return ipaddress.ip_network(raw, strict=False)
|
||||
return ipaddress.ip_address(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _ip_in_entries(ip: str, entries: Iterable[str]) -> bool:
|
||||
try:
|
||||
candidate_ip = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
for entry in entries:
|
||||
parsed = _parse_ip_entry(entry)
|
||||
if parsed is None:
|
||||
continue
|
||||
if isinstance(parsed, (ipaddress.IPv4Network, ipaddress.IPv6Network)):
|
||||
if candidate_ip in parsed:
|
||||
return True
|
||||
elif candidate_ip == parsed:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def ip_allowed_for_event(event_type: AuditEventType, ip: str | None) -> bool:
|
||||
# Apply whitelist first (default deny when enabled), then blacklist
|
||||
# (explicit deny). If the IP cannot be determined, we only allow it
|
||||
# when no whitelist is enforced.
|
||||
if not ip:
|
||||
# If we cannot determine the IP, allow by default unless a whitelist is enforced.
|
||||
return not event_type.ip_whitelist_enabled
|
||||
if event_type.ip_whitelist_enabled and not _ip_in_entries(ip, event_type.ip_whitelist or []):
|
||||
return False
|
||||
if event_type.ip_blacklist_enabled and _ip_in_entries(ip, event_type.ip_blacklist or []):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def endpoint_matches_event(event_type: AuditEventType, method: str, route: str, path: str) -> bool:
|
||||
# Event types are opt-in: an empty endpoint list never matches.
|
||||
# We allow either the resolved Django route or the raw path to match
|
||||
# so patterns can be authored using whichever is more stable.
|
||||
patterns = event_type.endpoints or []
|
||||
if not patterns:
|
||||
return False
|
||||
for raw_pattern in patterns:
|
||||
parsed = parse_endpoint_pattern(str(raw_pattern))
|
||||
if parsed and _endpoint_matches_pattern(parsed, method, route, path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_EVENT_TYPE_CACHE: dict[str, tuple[float, list[AuditEventType]]] = {}
|
||||
|
||||
|
||||
def clear_event_type_cache(*_args, **_kwargs) -> None:
|
||||
_EVENT_TYPE_CACHE.clear()
|
||||
|
||||
|
||||
def get_event_types_for_kind(kind: str) -> list[AuditEventType]:
|
||||
# Cache event-type catalogs briefly to avoid repeated DB hits on
|
||||
# high-volume request paths. The cache is cleared on save/delete.
|
||||
now = time.monotonic()
|
||||
cached = _EVENT_TYPE_CACHE.get(kind)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL_SECONDS:
|
||||
return cached[1]
|
||||
event_types = list(AuditEventType.objects.filter(kind=kind).order_by("key"))
|
||||
_EVENT_TYPE_CACHE[kind] = (now, event_types)
|
||||
return event_types
|
||||
|
||||
|
||||
def find_matching_event_type(kind: str, method: str, route: str, path: str, ip: str | None) -> AuditEventType | None:
|
||||
# Deterministic first-match semantics: the ordered catalog defines
|
||||
# precedence when multiple event types could match.
|
||||
for event_type in get_event_types_for_kind(kind):
|
||||
if not endpoint_matches_event(event_type, method=method, route=route, path=path):
|
||||
continue
|
||||
if not ip_allowed_for_event(event_type, ip):
|
||||
continue
|
||||
return event_type
|
||||
return None
|
||||
|
||||
|
||||
def _join_paths(prefix: str, segment: str) -> str:
|
||||
if not prefix:
|
||||
return segment
|
||||
if not segment:
|
||||
return prefix
|
||||
return f"{prefix.rstrip('/')}/{segment.lstrip('/')}"
|
||||
|
||||
|
||||
def _walk_urlpatterns(patterns: Iterable[URLPattern | URLResolver], prefix: str = "") -> list[str]:
|
||||
# Flatten the resolver tree into full route strings so the admin
|
||||
# UI can offer endpoint suggestions without hardcoding routes.
|
||||
results: list[str] = []
|
||||
for pattern in patterns:
|
||||
segment = str(pattern.pattern)
|
||||
combined = _join_paths(prefix, segment)
|
||||
if isinstance(pattern, URLResolver):
|
||||
results.extend(_walk_urlpatterns(pattern.url_patterns, combined))
|
||||
else:
|
||||
results.append(combined)
|
||||
return results
|
||||
|
||||
|
||||
def _normalize_suggestion(value: str) -> str:
|
||||
candidate = _strip_regex_anchors(value)
|
||||
candidate = candidate.replace("\\", "")
|
||||
candidate = _REGEX_GROUP_RE.sub(lambda m: f"<{m.group('name')}>", candidate)
|
||||
candidate = _normalize_path(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def list_api_endpoint_suggestions() -> list[str]:
|
||||
# Introspect the URL resolver and keep only API routes. Suggestions
|
||||
# are normalized to human-editable patterns (e.g., "<server_id>").
|
||||
resolver = get_resolver()
|
||||
raw_patterns = _walk_urlpatterns(resolver.url_patterns)
|
||||
suggestions: set[str] = set()
|
||||
for pattern in raw_patterns:
|
||||
if not pattern:
|
||||
continue
|
||||
normalized = _normalize_suggestion(pattern)
|
||||
if normalized.startswith("/api"):
|
||||
suggestions.add(normalized)
|
||||
return sorted(s for s in suggestions if s)
|
||||
|
||||
|
||||
def list_websocket_endpoint_suggestions() -> list[str]:
|
||||
# WebSocket routes are maintained separately by Channels, so we
|
||||
# import them directly from the ASGI routing module.
|
||||
try:
|
||||
from keywarden.routing import websocket_urlpatterns
|
||||
except Exception:
|
||||
return []
|
||||
raw_patterns = [str(p.pattern) for p in websocket_urlpatterns]
|
||||
suggestions = {_normalize_suggestion(p) for p in raw_patterns}
|
||||
return sorted(s for s in suggestions if s)
|
||||
@@ -1,15 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from .matching import find_matching_event_type
|
||||
from .models import AuditEventType, AuditLog
|
||||
from .utils import get_client_ip, get_request_id
|
||||
|
||||
_EVENT_CACHE: dict[str, AuditEventType] = {}
|
||||
_SKIP_PREFIXES = ("/api/v1/audit", "/api/v1/user")
|
||||
_SKIP_SUFFIXES = ("/health", "/health/")
|
||||
|
||||
@@ -18,6 +16,8 @@ def _is_api_request(path: str) -> bool:
|
||||
|
||||
|
||||
def _should_log_request(path: str) -> bool:
|
||||
# Only audit API traffic and skip endpoints that would recursively
|
||||
# generate noisy audit events (audit endpoints, health checks, etc.).
|
||||
if not _is_api_request(path):
|
||||
return False
|
||||
if path in _SKIP_PREFIXES:
|
||||
@@ -37,46 +37,12 @@ def _resolve_route(request, fallback: str) -> str:
|
||||
return fallback
|
||||
|
||||
|
||||
def _event_key_for(method: str, route: str) -> str:
|
||||
base = f"api_{method.lower()}_{route}"
|
||||
slug = slugify(base)
|
||||
if not slug:
|
||||
return "api_request"
|
||||
if len(slug) <= 64:
|
||||
return slug
|
||||
digest = hashlib.sha1(slug.encode("utf-8")).hexdigest()[:8]
|
||||
prefix_len = 64 - len(digest) - 1
|
||||
return f"{slug[:prefix_len]}-{digest}"
|
||||
|
||||
|
||||
def _event_title_for(method: str, route: str) -> str:
|
||||
title = f"API {method.upper()} {route}"
|
||||
if len(title) <= 128:
|
||||
return title
|
||||
return f"{title[:125]}..."
|
||||
|
||||
|
||||
def _get_endpoint_event(method: str, route: str) -> AuditEventType:
|
||||
key = _event_key_for(method, route)
|
||||
cached = _EVENT_CACHE.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
event, _ = AuditEventType.objects.get_or_create(
|
||||
key=key,
|
||||
defaults={
|
||||
"title": _event_title_for(method, route),
|
||||
"default_severity": AuditEventType.Severity.INFO,
|
||||
},
|
||||
)
|
||||
_EVENT_CACHE[key] = event
|
||||
return event
|
||||
|
||||
|
||||
class ApiAuditLogMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
# Fast-exit for non-audited paths before taking timing measurements.
|
||||
path = request.path_info or request.path
|
||||
if not _should_log_request(path):
|
||||
return self.get_response(request)
|
||||
@@ -96,8 +62,21 @@ class ApiAuditLogMiddleware:
|
||||
def _write_log(self, request, path: str, status_code: int, duration_ms: int, error: str | None = None) -> None:
|
||||
try:
|
||||
route = _resolve_route(request, path)
|
||||
client_ip = get_client_ip(request)
|
||||
# Audit events are explicit: if no configured event type matches,
|
||||
# we do not create either an event type or a log entry.
|
||||
event_type = find_matching_event_type(
|
||||
kind=AuditEventType.Kind.API,
|
||||
method=request.method,
|
||||
route=route,
|
||||
path=path,
|
||||
ip=client_ip,
|
||||
)
|
||||
if event_type is None:
|
||||
return
|
||||
user = getattr(request, "user", None)
|
||||
actor = user if getattr(user, "is_authenticated", False) else None
|
||||
# Store normalized request context for filtering and forensics.
|
||||
metadata = {
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
@@ -111,11 +90,11 @@ class ApiAuditLogMiddleware:
|
||||
AuditLog.objects.create(
|
||||
created_at=timezone.now(),
|
||||
actor=actor,
|
||||
event_type=_get_endpoint_event(request.method, route),
|
||||
event_type=event_type,
|
||||
message=f"API request {request.method} {route} -> {status_code}",
|
||||
severity=AuditEventType.Severity.INFO,
|
||||
severity=event_type.default_severity,
|
||||
source=AuditLog.Source.API,
|
||||
ip_address=get_client_ip(request),
|
||||
ip_address=client_ip,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
||||
request_id=get_request_id(request),
|
||||
metadata=metadata,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("audit", "0002_alter_auditlog_event_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="auditeventtype",
|
||||
name="kind",
|
||||
field=models.CharField(
|
||||
choices=[("api", "API"), ("websocket", "WebSocket")],
|
||||
db_index=True,
|
||||
default="api",
|
||||
help_text="Whether this event type applies to API or WebSocket traffic.",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="auditeventtype",
|
||||
name="endpoints",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text=(
|
||||
"List of endpoint patterns that should generate this event type. "
|
||||
"Use one pattern per line in the admin form. Supports '*' wildcards "
|
||||
"and optional METHOD prefixes like 'GET /api/v1/servers/*'."
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="auditeventtype",
|
||||
name="ip_whitelist_enabled",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="If enabled, only IPs in the whitelist will generate this event type.",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="auditeventtype",
|
||||
name="ip_whitelist",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="List of allowed IP addresses or CIDR ranges. One per line in the admin form.",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="auditeventtype",
|
||||
name="ip_blacklist_enabled",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="If enabled, IPs in the blacklist will be blocked for this event type.",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="auditeventtype",
|
||||
name="ip_blacklist",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="List of denied IP addresses or CIDR ranges. One per line in the admin form.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -13,6 +13,10 @@ class AuditEventType(models.Model):
|
||||
Useful for consistent naming, severity, and descriptions.
|
||||
"""
|
||||
|
||||
class Kind(models.TextChoices):
|
||||
API = "api", "API"
|
||||
WEBSOCKET = "websocket", "WebSocket"
|
||||
|
||||
class Severity(models.TextChoices):
|
||||
INFO = "info", "Info"
|
||||
WARNING = "warning", "Warning"
|
||||
@@ -22,9 +26,43 @@ class AuditEventType(models.Model):
|
||||
key = models.SlugField(max_length=64, unique=True, help_text="Stable machine key, e.g., user_login")
|
||||
title = models.CharField(max_length=128, help_text="Human-readable title")
|
||||
description = models.TextField(blank=True)
|
||||
kind = models.CharField(
|
||||
max_length=16,
|
||||
choices=Kind.choices,
|
||||
default=Kind.API,
|
||||
db_index=True,
|
||||
help_text="Whether this event type applies to API or WebSocket traffic.",
|
||||
)
|
||||
default_severity = models.CharField(
|
||||
max_length=16, choices=Severity.choices, default=Severity.INFO, db_index=True
|
||||
)
|
||||
endpoints = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text=(
|
||||
"List of endpoint patterns that should generate this event type. "
|
||||
"Use one pattern per line in the admin form. Supports '*' wildcards "
|
||||
"and optional METHOD prefixes like 'GET /api/v1/servers/*'."
|
||||
),
|
||||
)
|
||||
ip_whitelist_enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text="If enabled, only IPs in the whitelist will generate this event type.",
|
||||
)
|
||||
ip_whitelist = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="List of allowed IP addresses or CIDR ranges. One per line in the admin form.",
|
||||
)
|
||||
ip_blacklist_enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text="If enabled, IPs in the blacklist will be blocked for this event type.",
|
||||
)
|
||||
ip_blacklist = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="List of denied IP addresses or CIDR ranges. One per line in the admin form.",
|
||||
)
|
||||
created_at = models.DateTimeField(default=timezone.now, editable=False)
|
||||
|
||||
class Meta:
|
||||
@@ -33,7 +71,7 @@ class AuditEventType(models.Model):
|
||||
ordering = ["key"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.key} ({self.default_severity})"
|
||||
return f"{self.key} [{self.kind}] ({self.default_severity})"
|
||||
|
||||
|
||||
class AuditLog(models.Model):
|
||||
@@ -109,4 +147,4 @@ class AuditLog(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
actor = getattr(self.actor, "username", "system")
|
||||
return f"[{self.created_at:%Y-%m-%d %H:%M:%S}] {actor}: {self.message}"
|
||||
return f"[{self.created_at:%Y-%m-%d %H:%M:%S}] {actor}: {self.message}"
|
||||
|
||||
@@ -11,17 +11,18 @@ from .utils import get_client_ip
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def _get_or_create_event(key: str, title: str, severity: str = AuditEventType.Severity.INFO) -> AuditEventType:
|
||||
event, _ = AuditEventType.objects.get_or_create(
|
||||
key=key,
|
||||
defaults={"title": title, "default_severity": severity},
|
||||
)
|
||||
return event
|
||||
def _get_event(key: str) -> AuditEventType | None:
|
||||
try:
|
||||
return AuditEventType.objects.get(key=key)
|
||||
except AuditEventType.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
@receiver(user_logged_in)
|
||||
def on_user_logged_in(sender, request, user: User, **kwargs):
|
||||
event = _get_or_create_event("user_login", "User logged in", AuditEventType.Severity.INFO)
|
||||
event = _get_event("user_login")
|
||||
if event is None:
|
||||
return
|
||||
AuditLog.objects.create(
|
||||
created_at=timezone.now(),
|
||||
actor=user,
|
||||
@@ -37,7 +38,9 @@ def on_user_logged_in(sender, request, user: User, **kwargs):
|
||||
|
||||
@receiver(user_logged_out)
|
||||
def on_user_logged_out(sender, request, user: User, **kwargs):
|
||||
event = _get_or_create_event("user_logout", "User logged out", AuditEventType.Severity.INFO)
|
||||
event = _get_event("user_logout")
|
||||
if event is None:
|
||||
return
|
||||
AuditLog.objects.create(
|
||||
created_at=timezone.now(),
|
||||
actor=user,
|
||||
|
||||
93
app/apps/audit/static/audit/eventtype_form.js
Normal file
93
app/apps/audit/static/audit/eventtype_form.js
Normal file
@@ -0,0 +1,93 @@
|
||||
(function () {
|
||||
function parseSuggestions(textarea, key) {
|
||||
try {
|
||||
var raw = textarea.dataset[key];
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function splitLines(value) {
|
||||
return (value || "")
|
||||
.split(/\r?\n/)
|
||||
.map(function (line) {
|
||||
return line.trim();
|
||||
})
|
||||
.filter(function (line) {
|
||||
return line.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function appendLine(textarea, value) {
|
||||
var lines = splitLines(textarea.value);
|
||||
if (lines.indexOf(value) !== -1) {
|
||||
return;
|
||||
}
|
||||
lines.push(value);
|
||||
textarea.value = lines.join("\n");
|
||||
textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var textarea = document.getElementById("id_endpoints_text");
|
||||
var kindSelect = document.getElementById("id_kind");
|
||||
if (!textarea || !kindSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
var apiSuggestions = parseSuggestions(textarea, "apiSuggestions");
|
||||
var wsSuggestions = parseSuggestions(textarea, "wsSuggestions");
|
||||
|
||||
var container = document.createElement("div");
|
||||
container.className = "audit-endpoint-suggestions";
|
||||
container.style.marginTop = "0.5rem";
|
||||
|
||||
var title = document.createElement("div");
|
||||
title.style.fontWeight = "600";
|
||||
title.style.marginBottom = "0.25rem";
|
||||
title.textContent = "Suggested endpoints";
|
||||
container.appendChild(title);
|
||||
|
||||
var list = document.createElement("div");
|
||||
list.style.display = "flex";
|
||||
list.style.flexWrap = "wrap";
|
||||
list.style.gap = "0.25rem";
|
||||
container.appendChild(list);
|
||||
|
||||
textarea.parentNode.insertBefore(container, textarea.nextSibling);
|
||||
|
||||
function currentSuggestions() {
|
||||
return kindSelect.value === "websocket" ? wsSuggestions : apiSuggestions;
|
||||
}
|
||||
|
||||
function renderSuggestions() {
|
||||
var suggestions = currentSuggestions();
|
||||
list.innerHTML = "";
|
||||
if (!suggestions || suggestions.length === 0) {
|
||||
var empty = document.createElement("span");
|
||||
empty.textContent = "No endpoint suggestions were found.";
|
||||
empty.style.opacity = "0.7";
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
suggestions.slice(0, 40).forEach(function (suggestion) {
|
||||
var button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = suggestion;
|
||||
button.style.padding = "0.2rem 0.45rem";
|
||||
button.style.borderRadius = "999px";
|
||||
button.style.border = "1px solid #d1d5db";
|
||||
button.style.background = "#f9fafb";
|
||||
button.style.cursor = "pointer";
|
||||
button.addEventListener("click", function () {
|
||||
appendLine(textarea, suggestion);
|
||||
});
|
||||
list.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
kindSelect.addEventListener("change", renderSuggestions);
|
||||
renderSuggestions();
|
||||
});
|
||||
})();
|
||||
0
app/apps/audit/tests/__init__.py
Normal file
0
app/apps/audit/tests/__init__.py
Normal file
86
app/apps/audit/tests/test_api_audit_middleware.py
Normal file
86
app/apps/audit/tests/test_api_audit_middleware.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from apps.audit.matching import find_matching_event_type
|
||||
from apps.audit.middleware import ApiAuditLogMiddleware
|
||||
from apps.audit.models import AuditEventType, AuditLog
|
||||
|
||||
|
||||
class ApiAuditMiddlewareTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.factory = RequestFactory()
|
||||
self.middleware = ApiAuditLogMiddleware(lambda request: HttpResponse("ok"))
|
||||
|
||||
def _call(self, method: str, path: str, ip: str = "203.0.113.5") -> None:
|
||||
request = self.factory.generic(method, path)
|
||||
request.META["REMOTE_ADDR"] = ip
|
||||
self.middleware(request)
|
||||
|
||||
def test_no_matching_event_type_creates_no_logs_or_event_types(self) -> None:
|
||||
self._call("GET", "/api/auto/")
|
||||
self.assertEqual(AuditEventType.objects.count(), 0)
|
||||
self.assertEqual(AuditLog.objects.count(), 0)
|
||||
|
||||
def test_matching_event_type_creates_log(self) -> None:
|
||||
event_type = AuditEventType.objects.create(
|
||||
key="api_test",
|
||||
title="API test",
|
||||
kind=AuditEventType.Kind.API,
|
||||
endpoints=["/api/test/"],
|
||||
)
|
||||
self._call("GET", "/api/test/")
|
||||
log = AuditLog.objects.get()
|
||||
self.assertEqual(log.event_type_id, event_type.id)
|
||||
self.assertEqual(log.source, AuditLog.Source.API)
|
||||
self.assertEqual(log.severity, event_type.default_severity)
|
||||
|
||||
def test_ip_whitelist_blocks_and_allows(self) -> None:
|
||||
AuditEventType.objects.create(
|
||||
key="api_whitelist",
|
||||
title="API whitelist",
|
||||
kind=AuditEventType.Kind.API,
|
||||
endpoints=["/api/whitelist/"],
|
||||
ip_whitelist_enabled=True,
|
||||
ip_whitelist=["203.0.113.10"],
|
||||
)
|
||||
|
||||
self._call("GET", "/api/whitelist/", ip="203.0.113.5")
|
||||
self.assertEqual(AuditLog.objects.count(), 0)
|
||||
|
||||
self._call("GET", "/api/whitelist/", ip="203.0.113.10")
|
||||
self.assertEqual(AuditLog.objects.count(), 1)
|
||||
|
||||
def test_ip_blacklist_blocks(self) -> None:
|
||||
AuditEventType.objects.create(
|
||||
key="api_blacklist",
|
||||
title="API blacklist",
|
||||
kind=AuditEventType.Kind.API,
|
||||
endpoints=["/api/blacklist/"],
|
||||
ip_blacklist_enabled=True,
|
||||
ip_blacklist=["203.0.113.5"],
|
||||
)
|
||||
|
||||
self._call("GET", "/api/blacklist/", ip="203.0.113.5")
|
||||
self.assertEqual(AuditLog.objects.count(), 0)
|
||||
|
||||
|
||||
class AuditEventMatchingTests(TestCase):
|
||||
def test_websocket_event_type_can_match(self) -> None:
|
||||
event_type = AuditEventType.objects.create(
|
||||
key="ws_shell",
|
||||
title="WebSocket shell",
|
||||
kind=AuditEventType.Kind.WEBSOCKET,
|
||||
endpoints=["/ws/servers/*/shell/"],
|
||||
)
|
||||
matched = find_matching_event_type(
|
||||
kind=AuditEventType.Kind.WEBSOCKET,
|
||||
method="GET",
|
||||
route="/ws/servers/123/shell/",
|
||||
path="/ws/servers/123/shell/",
|
||||
ip="203.0.113.10",
|
||||
)
|
||||
self.assertIsNotNone(matched)
|
||||
self.assertEqual(matched.id, event_type.id)
|
||||
@@ -42,3 +42,51 @@ def get_request_id(request) -> str:
|
||||
or request.META.get("HTTP_X_CORRELATION_ID")
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def _get_scope_header(scope, header_name: str) -> str | None:
|
||||
headers = scope.get("headers") if scope else None
|
||||
if not headers:
|
||||
return None
|
||||
target = header_name.lower().encode("latin-1")
|
||||
for key, value in headers:
|
||||
if key.lower() == target:
|
||||
try:
|
||||
return value.decode("latin-1")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_client_ip_from_scope(scope) -> str | None:
|
||||
if not scope:
|
||||
return None
|
||||
x_real_ip = _normalize_ip(_get_scope_header(scope, "x-real-ip"))
|
||||
if x_real_ip:
|
||||
return x_real_ip
|
||||
forwarded_for = _get_scope_header(scope, "x-forwarded-for") or ""
|
||||
if forwarded_for:
|
||||
for part in forwarded_for.split(","):
|
||||
ip = _normalize_ip(part)
|
||||
if ip:
|
||||
return ip
|
||||
client = scope.get("client")
|
||||
if isinstance(client, (list, tuple)) and client:
|
||||
return _normalize_ip(str(client[0]))
|
||||
return None
|
||||
|
||||
|
||||
def get_request_id_from_scope(scope) -> str:
|
||||
if not scope:
|
||||
return ""
|
||||
return (
|
||||
_get_scope_header(scope, "x-request-id")
|
||||
or _get_scope_header(scope, "x-correlation-id")
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def get_user_agent_from_scope(scope) -> str:
|
||||
if not scope:
|
||||
return ""
|
||||
return _get_scope_header(scope, "user-agent") or ""
|
||||
|
||||
Reference in New Issue
Block a user