From 6901f6fcc47cd70f9783c7156377f89152e6e92b Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 20 Jan 2026 10:08:32 +0000 Subject: [PATCH] RBAC + Per-Route Audit Events --- app/apps/audit/middleware.py | 124 ++++++++++++++++++ app/apps/audit/signals.py | 7 +- app/apps/audit/utils.py | 44 +++++++ app/apps/core/apps.py | 20 +++ .../core/management/commands/ensure_admin.py | 6 +- app/apps/core/rbac.py | 75 +++++++++++ app/keywarden/api/routers/access.py | 36 +++-- app/keywarden/api/routers/accounts.py | 2 + app/keywarden/api/routers/agent.py | 17 +-- app/keywarden/api/routers/audit.py | 3 + app/keywarden/api/routers/keys.py | 40 +++--- app/keywarden/api/routers/servers.py | 19 +-- app/keywarden/api/routers/system.py | 5 +- app/keywarden/api/routers/telemetry.py | 21 +-- app/keywarden/api/routers/users.py | 52 ++++---- app/keywarden/settings/base.py | 3 +- nginx/configs/nginx.conf | 14 +- nginx/configs/nginx.conf.template | 15 ++- 18 files changed, 381 insertions(+), 122 deletions(-) create mode 100644 app/apps/audit/middleware.py create mode 100644 app/apps/audit/utils.py create mode 100644 app/apps/core/apps.py create mode 100644 app/apps/core/rbac.py diff --git a/app/apps/audit/middleware.py b/app/apps/audit/middleware.py new file mode 100644 index 0000000..4202e6e --- /dev/null +++ b/app/apps/audit/middleware.py @@ -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 diff --git a/app/apps/audit/signals.py b/app/apps/audit/signals.py index eb8e24f..b0735e5 100644 --- a/app/apps/audit/signals.py +++ b/app/apps/audit/signals.py @@ -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 {}, ) - - diff --git a/app/apps/audit/utils.py b/app/apps/audit/utils.py new file mode 100644 index 0000000..25e236b --- /dev/null +++ b/app/apps/audit/utils.py @@ -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 "" + ) diff --git a/app/apps/core/apps.py b/app/apps/core/apps.py new file mode 100644 index 0000000..4c72868 --- /dev/null +++ b/app/apps/core/apps.py @@ -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() diff --git a/app/apps/core/management/commands/ensure_admin.py b/app/apps/core/management/commands/ensure_admin.py index 33472ba..3bbb29d 100644 --- a/app/apps/core/management/commands/ensure_admin.py +++ b/app/apps/core/management/commands/ensure_admin.py @@ -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.")) - diff --git a/app/apps/core/rbac.py b/app/apps/core/rbac.py new file mode 100644 index 0000000..f1ea128 --- /dev/null +++ b/app/apps/core/rbac.py @@ -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") diff --git a/app/keywarden/api/routers/access.py b/app/keywarden/api/routers/access.py index 98f1aa4..ac01206 100644 --- a/app/keywarden/api/routers/access.py +++ b/app/keywarden/api/routers/access.py @@ -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 diff --git a/app/keywarden/api/routers/accounts.py b/app/keywarden/api/routers/accounts.py index 91d641c..d4ec3d3 100644 --- a/app/keywarden/api/routers/accounts.py +++ b/app/keywarden/api/routers/accounts.py @@ -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, diff --git a/app/keywarden/api/routers/agent.py b/app/keywarden/api/routers/agent.py index 8a171d7..2f53198 100644 --- a/app/keywarden/api/routers/agent.py +++ b/app/keywarden/api/routers/agent.py @@ -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: diff --git a/app/keywarden/api/routers/audit.py b/app/keywarden/api/routers/audit.py index 5a4ad25..7a10b4c 100644 --- a/app/keywarden/api/routers/audit.py +++ b/app/keywarden/api/routers/audit.py @@ -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) diff --git a/app/keywarden/api/routers/keys.py b/app/keywarden/api/routers/keys.py index 97e4107..659e4f0 100644 --- a/app/keywarden/api/routers/keys.py +++ b/app/keywarden/api/routers/keys.py @@ -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 diff --git a/app/keywarden/api/routers/servers.py b/app/keywarden/api/routers/servers.py index d4f155d..1b9a5f1 100644 --- a/app/keywarden/api/routers/servers.py +++ b/app/keywarden/api/routers/servers.py @@ -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: diff --git a/app/keywarden/api/routers/system.py b/app/keywarden/api/routers/system.py index 0401d3e..7e90f75 100644 --- a/app/keywarden/api/routers/system.py +++ b/app/keywarden/api/routers/system.py @@ -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 diff --git a/app/keywarden/api/routers/telemetry.py b/app/keywarden/api/routers/telemetry.py index d210905..f333059 100644 --- a/app/keywarden/api/routers/telemetry.py +++ b/app/keywarden/api/routers/telemetry.py @@ -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)), diff --git a/app/keywarden/api/routers/users.py b/app/keywarden/api/routers/users.py index d0e59c5..4397f0c 100644 --- a/app/keywarden/api/routers/users.py +++ b/app/keywarden/api/routers/users.py @@ -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) diff --git a/app/keywarden/settings/base.py b/app/keywarden/settings/base.py index bad8283..fed80f9 100644 --- a/app/keywarden/settings/base.py +++ b/app/keywarden/settings/base.py @@ -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", ] diff --git a/nginx/configs/nginx.conf b/nginx/configs/nginx.conf index 0786a9b..e61c51c 100644 --- a/nginx/configs/nginx.conf +++ b/nginx/configs/nginx.conf @@ -13,6 +13,12 @@ events { http { 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 options-ssl.conf; include options-http-headers.conf; @@ -22,6 +28,11 @@ http { '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; + map $http_x_forwarded_for $forwarded_for { + "" $remote_addr; + default $http_x_forwarded_for; + } + server { listen 80 default_server; listen [::]:80 default_server; @@ -46,7 +57,7 @@ http { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; 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; include options-https-headers.conf; } @@ -55,4 +66,3 @@ http { access_log /var/log/nginx/access.log main; types_hash_bucket_size 128; } - diff --git a/nginx/configs/nginx.conf.template b/nginx/configs/nginx.conf.template index e1e66e8..a28ca8f 100644 --- a/nginx/configs/nginx.conf.template +++ b/nginx/configs/nginx.conf.template @@ -13,6 +13,12 @@ events { http { 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 options-ssl.conf; include options-http-headers.conf; @@ -22,6 +28,11 @@ http { '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; + map $http_x_forwarded_for $forwarded_for { + "" $remote_addr; + default $http_x_forwarded_for; + } + server { listen 80; listen [::]:80; @@ -47,11 +58,11 @@ http { location / { proxy_pass http://127.0.0.1:8000; + include options-https-headers.conf; proxy_set_header Host $host; 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; - include options-https-headers.conf; } }