RBAC + Per-Route Audit Events
This commit is contained in:
124
app/apps/audit/middleware.py
Normal file
124
app/apps/audit/middleware.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
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/")
|
||||
|
||||
def _is_api_request(path: str) -> bool:
|
||||
return path == "/api" or path.startswith("/api/")
|
||||
|
||||
|
||||
def _should_log_request(path: str) -> bool:
|
||||
if not _is_api_request(path):
|
||||
return False
|
||||
if path in _SKIP_PREFIXES:
|
||||
return False
|
||||
if any(path.startswith(prefix + "/") for prefix in _SKIP_PREFIXES):
|
||||
return False
|
||||
if any(path.endswith(suffix) for suffix in _SKIP_SUFFIXES):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _resolve_route(request, fallback: str) -> str:
|
||||
match = getattr(request, "resolver_match", None)
|
||||
route = getattr(match, "route", None) if match else None
|
||||
if route:
|
||||
return route if route.startswith("/") else f"/{route}"
|
||||
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):
|
||||
path = request.path_info or request.path
|
||||
if not _should_log_request(path):
|
||||
return self.get_response(request)
|
||||
|
||||
start = time.monotonic()
|
||||
try:
|
||||
response = self.get_response(request)
|
||||
except Exception as exc:
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
self._write_log(request, path, 500, duration_ms, error=type(exc).__name__)
|
||||
raise
|
||||
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
self._write_log(request, path, response.status_code, duration_ms)
|
||||
return response
|
||||
|
||||
def _write_log(self, request, path: str, status_code: int, duration_ms: int, error: str | None = None) -> None:
|
||||
try:
|
||||
route = _resolve_route(request, path)
|
||||
user = getattr(request, "user", None)
|
||||
actor = user if getattr(user, "is_authenticated", False) else None
|
||||
metadata = {
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"route": route,
|
||||
"status_code": status_code,
|
||||
"duration_ms": duration_ms,
|
||||
"query_string": request.META.get("QUERY_STRING", ""),
|
||||
}
|
||||
if error:
|
||||
metadata["error"] = error
|
||||
AuditLog.objects.create(
|
||||
created_at=timezone.now(),
|
||||
actor=actor,
|
||||
event_type=_get_endpoint_event(request.method, route),
|
||||
message=f"API request {request.method} {route} -> {status_code}",
|
||||
severity=AuditEventType.Severity.INFO,
|
||||
source=AuditLog.Source.API,
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
||||
request_id=get_request_id(request),
|
||||
metadata=metadata,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
@@ -6,6 +6,7 @@ from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import AuditEventType, AuditLog
|
||||
from .utils import get_client_ip
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -28,7 +29,7 @@ def on_user_logged_in(sender, request, user: User, **kwargs):
|
||||
message=f"User {user} logged in",
|
||||
severity=event.default_severity,
|
||||
source=AuditLog.Source.UI,
|
||||
ip_address=(request.META.get("REMOTE_ADDR") if request else None),
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=(request.META.get("HTTP_USER_AGENT") if request else ""),
|
||||
metadata={"path": request.path} if request else {},
|
||||
)
|
||||
@@ -44,9 +45,7 @@ def on_user_logged_out(sender, request, user: User, **kwargs):
|
||||
message=f"User {user} logged out",
|
||||
severity=event.default_severity,
|
||||
source=AuditLog.Source.UI,
|
||||
ip_address=(request.META.get("REMOTE_ADDR") if request else None),
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=(request.META.get("HTTP_USER_AGENT") if request else ""),
|
||||
metadata={"path": request.path} if request else {},
|
||||
)
|
||||
|
||||
|
||||
|
||||
44
app/apps/audit/utils.py
Normal file
44
app/apps/audit/utils.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
|
||||
|
||||
def _normalize_ip(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
return None
|
||||
if candidate.startswith("[") and "]" in candidate:
|
||||
candidate = candidate[1 : candidate.index("]")]
|
||||
elif candidate.count(":") == 1 and candidate.rsplit(":", 1)[1].isdigit():
|
||||
candidate = candidate.rsplit(":", 1)[0]
|
||||
try:
|
||||
return str(ipaddress.ip_address(candidate))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def get_client_ip(request) -> str | None:
|
||||
if not request:
|
||||
return None
|
||||
x_real_ip = _normalize_ip(request.META.get("HTTP_X_REAL_IP"))
|
||||
if x_real_ip:
|
||||
return x_real_ip
|
||||
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
||||
if forwarded_for:
|
||||
for part in forwarded_for.split(","):
|
||||
ip = _normalize_ip(part)
|
||||
if ip:
|
||||
return ip
|
||||
return _normalize_ip(request.META.get("REMOTE_ADDR"))
|
||||
|
||||
|
||||
def get_request_id(request) -> str:
|
||||
if not request:
|
||||
return ""
|
||||
return (
|
||||
request.META.get("HTTP_X_REQUEST_ID")
|
||||
or request.META.get("HTTP_X_CORRELATION_ID")
|
||||
or ""
|
||||
)
|
||||
20
app/apps/core/apps.py
Normal file
20
app/apps/core/apps.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db.models.signals import post_migrate
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.core"
|
||||
label = "core"
|
||||
verbose_name = "Core"
|
||||
|
||||
def ready(self) -> None:
|
||||
from .rbac import ensure_role_groups
|
||||
|
||||
def _ensure_roles(**_kwargs) -> None:
|
||||
ensure_role_groups()
|
||||
|
||||
post_migrate.connect(_ensure_roles, sender=self)
|
||||
return super().ready()
|
||||
@@ -3,6 +3,8 @@ import os
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.core.rbac import ROLE_ADMIN, set_user_role
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Ensure a Django superuser exists using environment variables"
|
||||
@@ -41,6 +43,7 @@ class Command(BaseCommand):
|
||||
|
||||
if created:
|
||||
user.set_password(password)
|
||||
set_user_role(user, ROLE_ADMIN)
|
||||
user.save()
|
||||
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' created."))
|
||||
return
|
||||
@@ -59,10 +62,11 @@ class Command(BaseCommand):
|
||||
user.is_superuser = True
|
||||
changed = True
|
||||
|
||||
set_user_role(user, ROLE_ADMIN)
|
||||
|
||||
if changed:
|
||||
user.save()
|
||||
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' updated."))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' already present."))
|
||||
|
||||
|
||||
|
||||
75
app/apps/core/rbac.py
Normal file
75
app/apps/core/rbac.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from ninja.errors import HttpError
|
||||
|
||||
ROLE_ADMIN = "administrator"
|
||||
ROLE_OPERATOR = "operator"
|
||||
ROLE_AUDITOR = "auditor"
|
||||
ROLE_USER = "user"
|
||||
|
||||
ROLE_ORDER = (ROLE_ADMIN, ROLE_OPERATOR, ROLE_AUDITOR, ROLE_USER)
|
||||
ROLE_ALL = ROLE_ORDER
|
||||
ROLE_ALIASES = {"admin": ROLE_ADMIN}
|
||||
ROLE_INPUTS = tuple(sorted(set(ROLE_ORDER) | set(ROLE_ALIASES.keys())))
|
||||
|
||||
|
||||
def normalize_role(role: str) -> str:
|
||||
normalized = (role or "").strip().lower()
|
||||
return ROLE_ALIASES.get(normalized, normalized)
|
||||
|
||||
|
||||
def ensure_role_groups() -> None:
|
||||
for role in ROLE_ORDER:
|
||||
Group.objects.get_or_create(name=role)
|
||||
|
||||
|
||||
def get_user_role(user, default: str = ROLE_USER) -> str | None:
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return None
|
||||
if getattr(user, "is_superuser", False):
|
||||
return ROLE_ADMIN
|
||||
group_names = set(user.groups.values_list("name", flat=True))
|
||||
for role in ROLE_ORDER:
|
||||
if role in group_names:
|
||||
return role
|
||||
if getattr(user, "is_staff", False):
|
||||
return ROLE_ADMIN
|
||||
return default
|
||||
|
||||
|
||||
def set_user_role(user, role: str) -> str:
|
||||
canonical = normalize_role(role)
|
||||
if canonical not in ROLE_ORDER:
|
||||
raise ValueError(f"Invalid role: {role}")
|
||||
ensure_role_groups()
|
||||
role_groups = list(Group.objects.filter(name__in=ROLE_ORDER))
|
||||
if role_groups:
|
||||
user.groups.remove(*role_groups)
|
||||
target_group = Group.objects.get(name=canonical)
|
||||
user.groups.add(target_group)
|
||||
if canonical == ROLE_ADMIN:
|
||||
user.is_staff = True
|
||||
user.is_superuser = True
|
||||
else:
|
||||
user.is_staff = False
|
||||
user.is_superuser = False
|
||||
return canonical
|
||||
|
||||
|
||||
def require_authenticated(request) -> None:
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
raise HttpError(403, "Forbidden")
|
||||
|
||||
|
||||
def require_roles(request, *roles: str) -> None:
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
raise HttpError(403, "Forbidden")
|
||||
role = get_user_role(user)
|
||||
if role == ROLE_ADMIN:
|
||||
return
|
||||
allowed = {normalize_role(entry) for entry in roles}
|
||||
if role not in allowed:
|
||||
raise HttpError(403, "Forbidden")
|
||||
Reference in New Issue
Block a user