RBAC + Per-Route Audit Events

This commit is contained in:
2026-01-20 10:08:32 +00:00
parent 47b90fee87
commit 6901f6fcc4
18 changed files with 381 additions and 122 deletions

View 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

View File

@@ -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
View 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
View 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()

View File

@@ -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
View 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")

View File

@@ -10,6 +10,7 @@ from ninja.errors import HttpError
from pydantic import Field
from apps.access.models import AccessRequest
from apps.core.rbac import ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER, get_user_role, require_roles
from apps.servers.models import Server
@@ -44,16 +45,6 @@ class AccessQuery(Schema):
requester_id: Optional[int] = None
def _require_authenticated(request: HttpRequest) -> None:
if not getattr(request.user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
def _is_admin(request: HttpRequest) -> bool:
user = request.user
return bool(getattr(user, "is_staff", False) or getattr(user, "is_superuser", False))
def _request_to_out(access_request: AccessRequest) -> AccessRequestOut:
return AccessRequestOut(
id=access_request.id,
@@ -73,10 +64,11 @@ def build_router() -> Router:
@router.get("/", response=List[AccessRequestOut])
def list_requests(request: HttpRequest, filters: AccessQuery = Query(...)):
"""List access requests for the user, or all if admin."""
_require_authenticated(request)
"""List access requests for the user, or all if admin/operator."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR}
qs = AccessRequest.objects.order_by("-requested_at")
if _is_admin(request):
if is_admin:
if filters.requester_id:
qs = qs.filter(requester_id=filters.requester_id)
else:
@@ -91,7 +83,7 @@ def build_router() -> Router:
@router.post("/", response=AccessRequestOut)
def create_request(request: HttpRequest, payload: AccessRequestCreateIn):
"""Create a new access request for a server."""
_require_authenticated(request)
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
try:
server = Server.objects.get(id=payload.server_id)
except Server.DoesNotExist:
@@ -111,24 +103,25 @@ def build_router() -> Router:
@router.get("/{request_id}", response=AccessRequestOut)
def get_request(request: HttpRequest, request_id: int):
"""Get an access request if permitted."""
_require_authenticated(request)
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
try:
access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found")
if not _is_admin(request) and access_request.requester_id != request.user.id:
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR}
if not is_admin and access_request.requester_id != request.user.id:
raise HttpError(403, "Forbidden")
return _request_to_out(access_request)
@router.patch("/{request_id}", response=AccessRequestOut)
def update_request(request: HttpRequest, request_id: int, payload: AccessRequestUpdateIn):
"""Update request status or expiry (admin or owner with restrictions)."""
_require_authenticated(request)
"""Update request status or expiry (admin/operator or owner with restrictions)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
try:
access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found")
is_admin = _is_admin(request)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR}
is_owner = access_request.requester_id == request.user.id
if not is_admin and not is_owner:
raise HttpError(403, "Forbidden")
@@ -167,12 +160,13 @@ def build_router() -> Router:
@router.delete("/{request_id}", response={204: None})
def delete_request(request: HttpRequest, request_id: int):
"""Delete an access request if permitted."""
_require_authenticated(request)
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
try:
access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found")
if not _is_admin(request) and access_request.requester_id != request.user.id:
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR}
if not is_admin and access_request.requester_id != request.user.id:
raise HttpError(403, "Forbidden")
access_request.delete()
return 204, None

View File

@@ -3,6 +3,7 @@ from typing import Optional
from django.http import HttpRequest
from ninja import Router, Schema
from apps.core.rbac import require_authenticated
class UserSchema(Schema):
id: int
@@ -20,6 +21,7 @@ def build_router() -> Router:
@router.get("/me", response=UserSchema)
def me(request: HttpRequest):
"""Return the current authenticated user's profile."""
require_authenticated(request)
user = request.user
return {
"id": user.id,

View File

@@ -9,6 +9,7 @@ from ninja import Router, Schema
from ninja.errors import HttpError
from pydantic import Field
from apps.core.rbac import ROLE_ADMIN, ROLE_OPERATOR, require_roles
from apps.access.models import AccessRequest
from apps.keys.models import SSHKey
from apps.servers.models import Server
@@ -34,21 +35,13 @@ class SyncReportOut(Schema):
status: str
def _require_admin(request: HttpRequest) -> None:
user = request.user
if not getattr(user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
if not (user.is_staff or user.is_superuser):
raise HttpError(403, "Forbidden")
def build_router() -> Router:
router = Router()
@router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut])
def authorized_keys(request: HttpRequest, server_id: int):
"""Return authorized public keys for a server (admin only)."""
_require_admin(request)
"""Return authorized public keys for a server (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
@@ -78,8 +71,8 @@ def build_router() -> Router:
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut)
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn):
"""Record an agent sync report for a server (admin only)."""
_require_admin(request)
"""Record an agent sync report for a server (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:

View File

@@ -8,6 +8,7 @@ from django.http import HttpRequest
from ninja import Query, Router, Schema
from apps.audit.models import AuditEventType, AuditLog
from apps.core.rbac import ROLE_ADMIN, ROLE_AUDITOR, ROLE_OPERATOR, require_roles
class AuditEventTypeSchema(Schema):
id: int
@@ -47,6 +48,7 @@ def build_router() -> Router:
@router.get("/event-types", response=List[AuditEventTypeSchema])
def list_event_types(request: HttpRequest):
"""List audit event types and their default severity."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_AUDITOR)
qs: QuerySet[AuditEventType] = AuditEventType.objects.all()
return [
{
@@ -62,6 +64,7 @@ def build_router() -> Router:
@router.get("/logs", response=List[AuditLogSchema])
def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)):
"""List audit logs with optional filters and pagination."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_AUDITOR)
qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all()
if filters.severity:
qs = qs.filter(severity=filters.severity)

View File

@@ -11,6 +11,7 @@ from ninja import Query, Router, Schema
from ninja.errors import HttpError
from pydantic import Field
from apps.core.rbac import ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER, get_user_role, require_roles
from apps.keys.models import SSHKey
@@ -43,16 +44,6 @@ class KeysQuery(Schema):
user_id: Optional[int] = None
def _require_authenticated(request: HttpRequest) -> None:
if not getattr(request.user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
def _is_admin(request: HttpRequest) -> bool:
user = request.user
return bool(getattr(user, "is_staff", False) or getattr(user, "is_superuser", False))
def _key_to_out(key: SSHKey) -> KeyOut:
return KeyOut(
id=key.id,
@@ -72,10 +63,11 @@ def build_router() -> Router:
@router.get("/", response=List[KeyOut])
def list_keys(request: HttpRequest, filters: KeysQuery = Query(...)):
"""List SSH keys for the current user, or any user if admin."""
_require_authenticated(request)
"""List SSH keys for the current user, or any user if admin/operator."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR}
qs = SSHKey.objects.order_by("-created_at")
if _is_admin(request):
if is_admin:
if filters.user_id:
qs = qs.filter(user_id=filters.user_id)
else:
@@ -85,10 +77,11 @@ def build_router() -> Router:
@router.post("/", response=KeyOut)
def create_key(request: HttpRequest, payload: KeyCreateIn):
"""Create an SSH public key for the current user (admin can specify user_id)."""
_require_authenticated(request)
"""Create an SSH public key for the current user (admin/operator can specify user_id)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR}
owner = request.user
if _is_admin(request) and payload.user_id:
if is_admin and payload.user_id:
User = get_user_model()
try:
owner = User.objects.get(id=payload.user_id)
@@ -111,24 +104,26 @@ def build_router() -> Router:
@router.get("/{key_id}", response=KeyOut)
def get_key(request: HttpRequest, key_id: int):
"""Get a specific SSH key if permitted."""
_require_authenticated(request)
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR}
try:
key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found")
if not _is_admin(request) and key.user_id != request.user.id:
if not is_admin and key.user_id != request.user.id:
raise HttpError(403, "Forbidden")
return _key_to_out(key)
@router.patch("/{key_id}", response=KeyOut)
def update_key(request: HttpRequest, key_id: int, payload: KeyUpdateIn):
"""Update key name or active state if permitted."""
_require_authenticated(request)
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR}
try:
key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found")
if not _is_admin(request) and key.user_id != request.user.id:
if not is_admin and key.user_id != request.user.id:
raise HttpError(403, "Forbidden")
if payload.name is None and payload.is_active is None:
raise HttpError(422, {"detail": "No fields provided."})
@@ -149,12 +144,13 @@ def build_router() -> Router:
@router.delete("/{key_id}", response={204: None})
def delete_key(request: HttpRequest, key_id: int):
"""Revoke an SSH key if permitted (soft delete)."""
_require_authenticated(request)
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR}
try:
key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found")
if not _is_admin(request) and key.user_id != request.user.id:
if not is_admin and key.user_id != request.user.id:
raise HttpError(403, "Forbidden")
if key.is_active:
key.is_active = False

View File

@@ -7,6 +7,7 @@ from django.http import HttpRequest
from ninja import File, Form, Router, Schema
from ninja.files import UploadedFile
from ninja.errors import HttpError
from apps.core.rbac import ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER, require_roles
from apps.servers.models import Server
@@ -34,20 +35,13 @@ class ServerUpdate(Schema):
ipv6: Optional[str] = None
def _require_admin(request: HttpRequest) -> None:
user = request.user
if not getattr(user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
if not (user.is_staff or user.is_superuser):
raise HttpError(403, "Forbidden")
def build_router() -> Router:
router = Router()
@router.get("/", response=List[ServerOut])
def list_servers(request: HttpRequest):
"""List servers visible to authenticated users."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
servers = Server.objects.all()
return [
{
@@ -65,6 +59,7 @@ def build_router() -> Router:
@router.get("/{server_id}", response=ServerOut)
def get_server(request: HttpRequest, server_id: int):
"""Get server details by id."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
@@ -82,7 +77,7 @@ def build_router() -> Router:
@router.post("/", response=ServerOut)
def create_server_json(request: HttpRequest, payload: ServerCreate):
"""Create a server using JSON payload (admin only)."""
_require_admin(request)
require_roles(request, ROLE_ADMIN)
server = Server.objects.create(
display_name=payload.display_name.strip(),
hostname=(payload.hostname or "").strip() or None,
@@ -109,7 +104,7 @@ def build_router() -> Router:
image: Optional[UploadedFile] = File(None),
):
"""Create a server with optional image upload (admin only)."""
_require_admin(request)
require_roles(request, ROLE_ADMIN)
server = Server(
display_name=display_name.strip(),
hostname=(hostname or "").strip() or None,
@@ -132,7 +127,7 @@ def build_router() -> Router:
@router.patch("/{server_id}", response=ServerOut)
def update_server(request: HttpRequest, server_id: int, payload: ServerUpdate):
"""Update server fields (admin only)."""
_require_admin(request)
require_roles(request, ROLE_ADMIN)
if (
payload.display_name is None
and payload.hostname is None
@@ -172,7 +167,7 @@ def build_router() -> Router:
@router.delete("/{server_id}", response={204: None})
def delete_server(request: HttpRequest, server_id: int):
"""Delete a server by id (admin only)."""
_require_admin(request)
require_roles(request, ROLE_ADMIN)
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:

View File

@@ -2,6 +2,8 @@ from typing import Literal, TypedDict
from ninja import Router
from apps.core.rbac import require_authenticated
class HealthResponse(TypedDict):
status: Literal["ok"]
@@ -11,8 +13,9 @@ def build_router() -> Router:
router = Router()
@router.get("/health", response=HealthResponse)
def health() -> HealthResponse:
def health(request) -> HealthResponse:
"""Health check endpoint for service monitoring."""
require_authenticated(request)
return {"status": "ok"}
return router

View File

@@ -10,6 +10,7 @@ from ninja import Query, Router, Schema
from ninja.errors import HttpError
from pydantic import Field
from apps.core.rbac import ROLE_ADMIN, ROLE_OPERATOR, require_roles
from apps.servers.models import Server
from apps.telemetry.models import TelemetryEvent
@@ -51,14 +52,6 @@ class TelemetryQuery(Schema):
success: Optional[bool] = None
def _require_admin(request: HttpRequest) -> None:
user = request.user
if not getattr(user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
if not (user.is_staff or user.is_superuser):
raise HttpError(403, "Forbidden")
def _event_to_out(event: TelemetryEvent) -> TelemetryOut:
return TelemetryOut(
id=event.id,
@@ -78,8 +71,8 @@ def build_router() -> Router:
@router.get("/", response=List[TelemetryOut])
def list_events(request: HttpRequest, filters: TelemetryQuery = Query(...)):
"""List telemetry events with filters (admin only)."""
_require_admin(request)
"""List telemetry events with filters (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
qs = TelemetryEvent.objects.order_by("-created_at")
if filters.event_type:
qs = qs.filter(event_type=filters.event_type)
@@ -94,8 +87,8 @@ def build_router() -> Router:
@router.post("/", response=TelemetryOut)
def create_event(request: HttpRequest, payload: TelemetryCreateIn):
"""Create a telemetry event entry (admin only)."""
_require_admin(request)
"""Create a telemetry event entry (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
server = None
if payload.server_id:
try:
@@ -122,8 +115,8 @@ def build_router() -> Router:
@router.get("/summary", response=TelemetrySummaryOut)
def summary(request: HttpRequest):
"""Return a high-level telemetry summary (admin only)."""
_require_admin(request)
"""Return a high-level telemetry summary (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
totals = TelemetryEvent.objects.aggregate(
total=Count("id"),
success=Count("id", filter=models.Q(success=True)),

View File

@@ -9,11 +9,13 @@ from ninja import Query, Router, Schema
from ninja.errors import HttpError
from pydantic import EmailStr, Field
from apps.core.rbac import ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER, get_user_role, require_roles, set_user_role
class UserCreateIn(Schema):
email: EmailStr
password: str = Field(min_length=8)
role: Literal["admin", "user"]
role: Literal["administrator", "operator", "auditor", "user", "admin"]
class UserListOut(Schema):
@@ -33,7 +35,7 @@ class UserDetailOut(Schema):
class UserUpdateIn(Schema):
email: EmailStr | None = None
password: str | None = Field(default=None, min_length=8)
role: Literal["admin", "user"] | None = None
role: Literal["administrator", "operator", "auditor", "user", "admin"] | None = None
is_active: bool | None = None
@@ -42,25 +44,8 @@ class UsersQuery(Schema):
offset: int = Field(default=0, ge=0)
def _require_admin(request: HttpRequest) -> None:
user = request.user
if not getattr(user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
if not (user.is_staff or user.is_superuser):
raise HttpError(403, "Forbidden")
def _role_from_user(user) -> str:
return "admin" if (user.is_staff or user.is_superuser) else "user"
def _apply_role(user, role: str) -> None:
if role == "admin":
user.is_staff = True
user.is_superuser = True
else:
user.is_staff = False
user.is_superuser = False
return get_user_role(user) or ROLE_USER
def build_router() -> Router:
@@ -68,19 +53,23 @@ def build_router() -> Router:
@router.post("/", response=UserDetailOut)
def create_user(request: HttpRequest, payload: UserCreateIn):
"""Create a user with role and password (admin only)."""
_require_admin(request)
"""Create a user with role and password (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
User = get_user_model()
email = payload.email.strip().lower()
if User.objects.filter(email__iexact=email).exists():
raise HttpError(422, {"email": ["Email already exists."]})
user = User(username=email, email=email, is_active=True)
_apply_role(user, payload.role)
user.set_password(payload.password)
try:
user.save()
except IntegrityError:
raise HttpError(422, {"email": ["Email already exists."]})
try:
set_user_role(user, payload.role)
except ValueError:
raise HttpError(422, {"role": ["Invalid role."]})
user.save()
return {
"id": user.id,
"email": user.email,
@@ -90,8 +79,8 @@ def build_router() -> Router:
@router.get("/", response=List[UserListOut])
def list_users(request: HttpRequest, pagination: UsersQuery = Query(...)):
"""List users with pagination (admin only)."""
_require_admin(request)
"""List users with pagination (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
User = get_user_model()
qs = User.objects.order_by("id")[pagination.offset : pagination.offset + pagination.limit]
return [
@@ -106,8 +95,8 @@ def build_router() -> Router:
@router.get("/{user_id}", response=UserDetailOut)
def get_user(request: HttpRequest, user_id: int):
"""Get user details by id (admin only)."""
_require_admin(request)
"""Get user details by id (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
User = get_user_model()
try:
user = User.objects.get(id=user_id)
@@ -123,7 +112,7 @@ def build_router() -> Router:
@router.patch("/{user_id}", response=UserDetailOut)
def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn):
"""Update user fields such as role, email, or status (admin only)."""
_require_admin(request)
require_roles(request, ROLE_ADMIN)
if payload.email is None and payload.password is None and payload.role is None and payload.is_active is None:
raise HttpError(422, {"detail": "No fields provided."})
User = get_user_model()
@@ -140,7 +129,10 @@ def build_router() -> Router:
if payload.password is not None:
user.set_password(payload.password)
if payload.role is not None:
_apply_role(user, payload.role)
try:
set_user_role(user, payload.role)
except ValueError:
raise HttpError(422, {"role": ["Invalid role."]})
if payload.is_active is not None:
user.is_active = payload.is_active
user.save()
@@ -154,7 +146,7 @@ def build_router() -> Router:
@router.delete("/{user_id}", response={204: None})
def delete_user(request: HttpRequest, user_id: int):
"""Delete a user by id (admin only)."""
_require_admin(request)
require_roles(request, ROLE_ADMIN)
User = get_user_model()
try:
user = User.objects.get(id=user_id)

View File

@@ -35,7 +35,7 @@ INSTALLED_APPS = [
"rest_framework",
"apps.audit",
"apps.accounts",
"apps.core",
"apps.core.apps.CoreConfig",
"apps.dashboard",
"apps.servers",
"apps.keys",
@@ -54,6 +54,7 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"apps.audit.middleware.ApiAuditLogMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]