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 django.utils import timezone
from .models import AuditEventType, AuditLog from .models import AuditEventType, AuditLog
from .utils import get_client_ip
User = get_user_model() User = get_user_model()
@@ -28,7 +29,7 @@ def on_user_logged_in(sender, request, user: User, **kwargs):
message=f"User {user} logged in", message=f"User {user} logged in",
severity=event.default_severity, severity=event.default_severity,
source=AuditLog.Source.UI, 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 ""), user_agent=(request.META.get("HTTP_USER_AGENT") if request else ""),
metadata={"path": request.path} 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", message=f"User {user} logged out",
severity=event.default_severity, severity=event.default_severity,
source=AuditLog.Source.UI, 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 ""), user_agent=(request.META.get("HTTP_USER_AGENT") if request else ""),
metadata={"path": request.path} 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.contrib.auth import get_user_model
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from apps.core.rbac import ROLE_ADMIN, set_user_role
class Command(BaseCommand): class Command(BaseCommand):
help = "Ensure a Django superuser exists using environment variables" help = "Ensure a Django superuser exists using environment variables"
@@ -41,6 +43,7 @@ class Command(BaseCommand):
if created: if created:
user.set_password(password) user.set_password(password)
set_user_role(user, ROLE_ADMIN)
user.save() user.save()
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' created.")) self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' created."))
return return
@@ -59,10 +62,11 @@ class Command(BaseCommand):
user.is_superuser = True user.is_superuser = True
changed = True changed = True
set_user_role(user, ROLE_ADMIN)
if changed: if changed:
user.save() user.save()
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' updated.")) self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' updated."))
else: else:
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' already present.")) 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 pydantic import Field
from apps.access.models import AccessRequest 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 from apps.servers.models import Server
@@ -44,16 +45,6 @@ class AccessQuery(Schema):
requester_id: Optional[int] = None 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: def _request_to_out(access_request: AccessRequest) -> AccessRequestOut:
return AccessRequestOut( return AccessRequestOut(
id=access_request.id, id=access_request.id,
@@ -73,10 +64,11 @@ def build_router() -> Router:
@router.get("/", response=List[AccessRequestOut]) @router.get("/", response=List[AccessRequestOut])
def list_requests(request: HttpRequest, filters: AccessQuery = Query(...)): def list_requests(request: HttpRequest, filters: AccessQuery = Query(...)):
"""List access requests for the user, or all if admin.""" """List access requests for the user, or all if admin/operator."""
_require_authenticated(request) 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") qs = AccessRequest.objects.order_by("-requested_at")
if _is_admin(request): if is_admin:
if filters.requester_id: if filters.requester_id:
qs = qs.filter(requester_id=filters.requester_id) qs = qs.filter(requester_id=filters.requester_id)
else: else:
@@ -91,7 +83,7 @@ def build_router() -> Router:
@router.post("/", response=AccessRequestOut) @router.post("/", response=AccessRequestOut)
def create_request(request: HttpRequest, payload: AccessRequestCreateIn): def create_request(request: HttpRequest, payload: AccessRequestCreateIn):
"""Create a new access request for a server.""" """Create a new access request for a server."""
_require_authenticated(request) require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
try: try:
server = Server.objects.get(id=payload.server_id) server = Server.objects.get(id=payload.server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
@@ -111,24 +103,25 @@ def build_router() -> Router:
@router.get("/{request_id}", response=AccessRequestOut) @router.get("/{request_id}", response=AccessRequestOut)
def get_request(request: HttpRequest, request_id: int): def get_request(request: HttpRequest, request_id: int):
"""Get an access request if permitted.""" """Get an access request if permitted."""
_require_authenticated(request) require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
try: try:
access_request = AccessRequest.objects.get(id=request_id) access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist: except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found") 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") raise HttpError(403, "Forbidden")
return _request_to_out(access_request) return _request_to_out(access_request)
@router.patch("/{request_id}", response=AccessRequestOut) @router.patch("/{request_id}", response=AccessRequestOut)
def update_request(request: HttpRequest, request_id: int, payload: AccessRequestUpdateIn): def update_request(request: HttpRequest, request_id: int, payload: AccessRequestUpdateIn):
"""Update request status or expiry (admin or owner with restrictions).""" """Update request status or expiry (admin/operator or owner with restrictions)."""
_require_authenticated(request) require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
try: try:
access_request = AccessRequest.objects.get(id=request_id) access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist: except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found") 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 is_owner = access_request.requester_id == request.user.id
if not is_admin and not is_owner: if not is_admin and not is_owner:
raise HttpError(403, "Forbidden") raise HttpError(403, "Forbidden")
@@ -167,12 +160,13 @@ def build_router() -> Router:
@router.delete("/{request_id}", response={204: None}) @router.delete("/{request_id}", response={204: None})
def delete_request(request: HttpRequest, request_id: int): def delete_request(request: HttpRequest, request_id: int):
"""Delete an access request if permitted.""" """Delete an access request if permitted."""
_require_authenticated(request) require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
try: try:
access_request = AccessRequest.objects.get(id=request_id) access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist: except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found") 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") raise HttpError(403, "Forbidden")
access_request.delete() access_request.delete()
return 204, None return 204, None

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ from django.http import HttpRequest
from ninja import Query, Router, Schema from ninja import Query, Router, Schema
from apps.audit.models import AuditEventType, AuditLog from apps.audit.models import AuditEventType, AuditLog
from apps.core.rbac import ROLE_ADMIN, ROLE_AUDITOR, ROLE_OPERATOR, require_roles
class AuditEventTypeSchema(Schema): class AuditEventTypeSchema(Schema):
id: int id: int
@@ -47,6 +48,7 @@ def build_router() -> Router:
@router.get("/event-types", response=List[AuditEventTypeSchema]) @router.get("/event-types", response=List[AuditEventTypeSchema])
def list_event_types(request: HttpRequest): def list_event_types(request: HttpRequest):
"""List audit event types and their default severity.""" """List audit event types and their default severity."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_AUDITOR)
qs: QuerySet[AuditEventType] = AuditEventType.objects.all() qs: QuerySet[AuditEventType] = AuditEventType.objects.all()
return [ return [
{ {
@@ -62,6 +64,7 @@ def build_router() -> Router:
@router.get("/logs", response=List[AuditLogSchema]) @router.get("/logs", response=List[AuditLogSchema])
def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)): def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)):
"""List audit logs with optional filters and pagination.""" """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() qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all()
if filters.severity: if filters.severity:
qs = qs.filter(severity=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 ninja.errors import HttpError
from pydantic import Field 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 from apps.keys.models import SSHKey
@@ -43,16 +44,6 @@ class KeysQuery(Schema):
user_id: Optional[int] = None 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: def _key_to_out(key: SSHKey) -> KeyOut:
return KeyOut( return KeyOut(
id=key.id, id=key.id,
@@ -72,10 +63,11 @@ def build_router() -> Router:
@router.get("/", response=List[KeyOut]) @router.get("/", response=List[KeyOut])
def list_keys(request: HttpRequest, filters: KeysQuery = Query(...)): def list_keys(request: HttpRequest, filters: KeysQuery = Query(...)):
"""List SSH keys for the current user, or any user if admin.""" """List SSH keys for the current user, or any user if admin/operator."""
_require_authenticated(request) 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") qs = SSHKey.objects.order_by("-created_at")
if _is_admin(request): if is_admin:
if filters.user_id: if filters.user_id:
qs = qs.filter(user_id=filters.user_id) qs = qs.filter(user_id=filters.user_id)
else: else:
@@ -85,10 +77,11 @@ def build_router() -> Router:
@router.post("/", response=KeyOut) @router.post("/", response=KeyOut)
def create_key(request: HttpRequest, payload: KeyCreateIn): def create_key(request: HttpRequest, payload: KeyCreateIn):
"""Create an SSH public key for the current user (admin can specify user_id).""" """Create an SSH public key for the current user (admin/operator can specify user_id)."""
_require_authenticated(request) require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR}
owner = request.user owner = request.user
if _is_admin(request) and payload.user_id: if is_admin and payload.user_id:
User = get_user_model() User = get_user_model()
try: try:
owner = User.objects.get(id=payload.user_id) owner = User.objects.get(id=payload.user_id)
@@ -111,24 +104,26 @@ def build_router() -> Router:
@router.get("/{key_id}", response=KeyOut) @router.get("/{key_id}", response=KeyOut)
def get_key(request: HttpRequest, key_id: int): def get_key(request: HttpRequest, key_id: int):
"""Get a specific SSH key if permitted.""" """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: try:
key = SSHKey.objects.get(id=key_id) key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist: except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found") 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") raise HttpError(403, "Forbidden")
return _key_to_out(key) return _key_to_out(key)
@router.patch("/{key_id}", response=KeyOut) @router.patch("/{key_id}", response=KeyOut)
def update_key(request: HttpRequest, key_id: int, payload: KeyUpdateIn): def update_key(request: HttpRequest, key_id: int, payload: KeyUpdateIn):
"""Update key name or active state if permitted.""" """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: try:
key = SSHKey.objects.get(id=key_id) key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist: except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found") 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") raise HttpError(403, "Forbidden")
if payload.name is None and payload.is_active is None: if payload.name is None and payload.is_active is None:
raise HttpError(422, {"detail": "No fields provided."}) raise HttpError(422, {"detail": "No fields provided."})
@@ -149,12 +144,13 @@ def build_router() -> Router:
@router.delete("/{key_id}", response={204: None}) @router.delete("/{key_id}", response={204: None})
def delete_key(request: HttpRequest, key_id: int): def delete_key(request: HttpRequest, key_id: int):
"""Revoke an SSH key if permitted (soft delete).""" """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: try:
key = SSHKey.objects.get(id=key_id) key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist: except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found") 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") raise HttpError(403, "Forbidden")
if key.is_active: if key.is_active:
key.is_active = False key.is_active = False

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ from ninja import Query, Router, Schema
from ninja.errors import HttpError from ninja.errors import HttpError
from pydantic import Field from pydantic import Field
from apps.core.rbac import ROLE_ADMIN, ROLE_OPERATOR, require_roles
from apps.servers.models import Server from apps.servers.models import Server
from apps.telemetry.models import TelemetryEvent from apps.telemetry.models import TelemetryEvent
@@ -51,14 +52,6 @@ class TelemetryQuery(Schema):
success: Optional[bool] = None 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: def _event_to_out(event: TelemetryEvent) -> TelemetryOut:
return TelemetryOut( return TelemetryOut(
id=event.id, id=event.id,
@@ -78,8 +71,8 @@ def build_router() -> Router:
@router.get("/", response=List[TelemetryOut]) @router.get("/", response=List[TelemetryOut])
def list_events(request: HttpRequest, filters: TelemetryQuery = Query(...)): def list_events(request: HttpRequest, filters: TelemetryQuery = Query(...)):
"""List telemetry events with filters (admin only).""" """List telemetry events with filters (admin or operator)."""
_require_admin(request) require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
qs = TelemetryEvent.objects.order_by("-created_at") qs = TelemetryEvent.objects.order_by("-created_at")
if filters.event_type: if filters.event_type:
qs = qs.filter(event_type=filters.event_type) qs = qs.filter(event_type=filters.event_type)
@@ -94,8 +87,8 @@ def build_router() -> Router:
@router.post("/", response=TelemetryOut) @router.post("/", response=TelemetryOut)
def create_event(request: HttpRequest, payload: TelemetryCreateIn): def create_event(request: HttpRequest, payload: TelemetryCreateIn):
"""Create a telemetry event entry (admin only).""" """Create a telemetry event entry (admin or operator)."""
_require_admin(request) require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
server = None server = None
if payload.server_id: if payload.server_id:
try: try:
@@ -122,8 +115,8 @@ def build_router() -> Router:
@router.get("/summary", response=TelemetrySummaryOut) @router.get("/summary", response=TelemetrySummaryOut)
def summary(request: HttpRequest): def summary(request: HttpRequest):
"""Return a high-level telemetry summary (admin only).""" """Return a high-level telemetry summary (admin or operator)."""
_require_admin(request) require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
totals = TelemetryEvent.objects.aggregate( totals = TelemetryEvent.objects.aggregate(
total=Count("id"), total=Count("id"),
success=Count("id", filter=models.Q(success=True)), 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 ninja.errors import HttpError
from pydantic import EmailStr, Field 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): class UserCreateIn(Schema):
email: EmailStr email: EmailStr
password: str = Field(min_length=8) password: str = Field(min_length=8)
role: Literal["admin", "user"] role: Literal["administrator", "operator", "auditor", "user", "admin"]
class UserListOut(Schema): class UserListOut(Schema):
@@ -33,7 +35,7 @@ class UserDetailOut(Schema):
class UserUpdateIn(Schema): class UserUpdateIn(Schema):
email: EmailStr | None = None email: EmailStr | None = None
password: str | None = Field(default=None, min_length=8) 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 is_active: bool | None = None
@@ -42,25 +44,8 @@ class UsersQuery(Schema):
offset: int = Field(default=0, ge=0) 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: def _role_from_user(user) -> str:
return "admin" if (user.is_staff or user.is_superuser) else "user" return get_user_role(user) or ROLE_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
def build_router() -> Router: def build_router() -> Router:
@@ -68,19 +53,23 @@ def build_router() -> Router:
@router.post("/", response=UserDetailOut) @router.post("/", response=UserDetailOut)
def create_user(request: HttpRequest, payload: UserCreateIn): def create_user(request: HttpRequest, payload: UserCreateIn):
"""Create a user with role and password (admin only).""" """Create a user with role and password (admin or operator)."""
_require_admin(request) require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
User = get_user_model() User = get_user_model()
email = payload.email.strip().lower() email = payload.email.strip().lower()
if User.objects.filter(email__iexact=email).exists(): if User.objects.filter(email__iexact=email).exists():
raise HttpError(422, {"email": ["Email already exists."]}) raise HttpError(422, {"email": ["Email already exists."]})
user = User(username=email, email=email, is_active=True) user = User(username=email, email=email, is_active=True)
_apply_role(user, payload.role)
user.set_password(payload.password) user.set_password(payload.password)
try: try:
user.save() user.save()
except IntegrityError: except IntegrityError:
raise HttpError(422, {"email": ["Email already exists."]}) 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 { return {
"id": user.id, "id": user.id,
"email": user.email, "email": user.email,
@@ -90,8 +79,8 @@ def build_router() -> Router:
@router.get("/", response=List[UserListOut]) @router.get("/", response=List[UserListOut])
def list_users(request: HttpRequest, pagination: UsersQuery = Query(...)): def list_users(request: HttpRequest, pagination: UsersQuery = Query(...)):
"""List users with pagination (admin only).""" """List users with pagination (admin or operator)."""
_require_admin(request) require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
User = get_user_model() User = get_user_model()
qs = User.objects.order_by("id")[pagination.offset : pagination.offset + pagination.limit] qs = User.objects.order_by("id")[pagination.offset : pagination.offset + pagination.limit]
return [ return [
@@ -106,8 +95,8 @@ def build_router() -> Router:
@router.get("/{user_id}", response=UserDetailOut) @router.get("/{user_id}", response=UserDetailOut)
def get_user(request: HttpRequest, user_id: int): def get_user(request: HttpRequest, user_id: int):
"""Get user details by id (admin only).""" """Get user details by id (admin or operator)."""
_require_admin(request) require_roles(request, ROLE_ADMIN, ROLE_OPERATOR)
User = get_user_model() User = get_user_model()
try: try:
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
@@ -123,7 +112,7 @@ def build_router() -> Router:
@router.patch("/{user_id}", response=UserDetailOut) @router.patch("/{user_id}", response=UserDetailOut)
def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn): def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn):
"""Update user fields such as role, email, or status (admin only).""" """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: 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."}) raise HttpError(422, {"detail": "No fields provided."})
User = get_user_model() User = get_user_model()
@@ -140,7 +129,10 @@ def build_router() -> Router:
if payload.password is not None: if payload.password is not None:
user.set_password(payload.password) user.set_password(payload.password)
if payload.role is not None: 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: if payload.is_active is not None:
user.is_active = payload.is_active user.is_active = payload.is_active
user.save() user.save()
@@ -154,7 +146,7 @@ def build_router() -> Router:
@router.delete("/{user_id}", response={204: None}) @router.delete("/{user_id}", response={204: None})
def delete_user(request: HttpRequest, user_id: int): def delete_user(request: HttpRequest, user_id: int):
"""Delete a user by id (admin only).""" """Delete a user by id (admin only)."""
_require_admin(request) require_roles(request, ROLE_ADMIN)
User = get_user_model() User = get_user_model()
try: try:
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)

View File

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

View File

@@ -13,6 +13,12 @@ events {
http { http {
real_ip_header X-Forwarded-For; real_ip_header X-Forwarded-For;
real_ip_recursive on;
set_real_ip_from 127.0.0.1;
set_real_ip_from ::1;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
include options-ssl.conf; include options-ssl.conf;
include options-http-headers.conf; include options-http-headers.conf;
@@ -22,6 +28,11 @@ http {
'$status $body_bytes_sent "$http_referer" ' '$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"'; '"$http_user_agent" "$http_x_forwarded_for"';
map $http_x_forwarded_for $forwarded_for {
"" $remote_addr;
default $http_x_forwarded_for;
}
server { server {
listen 80 default_server; listen 80 default_server;
listen [::]:80 default_server; listen [::]:80 default_server;
@@ -46,7 +57,7 @@ http {
proxy_pass http://127.0.0.1:8000; proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
include options-https-headers.conf; include options-https-headers.conf;
} }
@@ -55,4 +66,3 @@ http {
access_log /var/log/nginx/access.log main; access_log /var/log/nginx/access.log main;
types_hash_bucket_size 128; types_hash_bucket_size 128;
} }

View File

@@ -13,6 +13,12 @@ events {
http { http {
real_ip_header X-Forwarded-For; real_ip_header X-Forwarded-For;
real_ip_recursive on;
set_real_ip_from 127.0.0.1;
set_real_ip_from ::1;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
include options-ssl.conf; include options-ssl.conf;
include options-http-headers.conf; include options-http-headers.conf;
@@ -22,6 +28,11 @@ http {
'$status $body_bytes_sent "$http_referer" ' '$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"'; '"$http_user_agent" "$http_x_forwarded_for"';
map $http_x_forwarded_for $forwarded_for {
"" $remote_addr;
default $http_x_forwarded_for;
}
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
@@ -47,11 +58,11 @@ http {
location / { location / {
proxy_pass http://127.0.0.1:8000; proxy_pass http://127.0.0.1:8000;
include options-https-headers.conf;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
include options-https-headers.conf;
} }
} }