From a32b3dd17f921059f03dfa16021481b527b8dd10 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 19 Jan 2026 18:29:22 +0000 Subject: [PATCH] Created /api/v1/keys, access-requests, telemetry, agent. Documented endpoints at /api/v1/docs --- API_DOCS.md | 15 ++ app/apps/access/__init__.py | 0 app/apps/access/admin.py | 19 ++ app/apps/access/apps.py | 7 + app/apps/access/migrations/0001_initial.py | 78 ++++++++ app/apps/access/migrations/__init__.py | 0 app/apps/access/models.py | 57 ++++++ app/apps/keys/__init__.py | 0 app/apps/keys/admin.py | 11 ++ app/apps/keys/apps.py | 7 + app/apps/keys/migrations/0001_initial.py | 53 +++++ app/apps/keys/migrations/__init__.py | 0 app/apps/keys/models.py | 66 +++++++ app/apps/telemetry/__init__.py | 0 app/apps/telemetry/admin.py | 11 ++ app/apps/telemetry/apps.py | 7 + app/apps/telemetry/migrations/0001_initial.py | 73 +++++++ app/apps/telemetry/migrations/__init__.py | 0 app/apps/telemetry/models.py | 48 +++++ app/keywarden/api/main.py | 8 + app/keywarden/api/routers/access.py | 183 ++++++++++++++++++ app/keywarden/api/routers/accounts.py | 2 +- app/keywarden/api/routers/agent.py | 104 ++++++++++ app/keywarden/api/routers/audit.py | 3 +- app/keywarden/api/routers/keys.py | 168 ++++++++++++++++ app/keywarden/api/routers/servers.py | 6 + app/keywarden/api/routers/system.py | 2 +- app/keywarden/api/routers/telemetry.py | 138 +++++++++++++ app/keywarden/api/routers/users.py | 5 + app/keywarden/settings/base.py | 3 + 30 files changed, 1071 insertions(+), 3 deletions(-) create mode 100644 API_DOCS.md create mode 100644 app/apps/access/__init__.py create mode 100644 app/apps/access/admin.py create mode 100644 app/apps/access/apps.py create mode 100644 app/apps/access/migrations/0001_initial.py create mode 100644 app/apps/access/migrations/__init__.py create mode 100644 app/apps/access/models.py create mode 100644 app/apps/keys/__init__.py create mode 100644 app/apps/keys/admin.py create mode 100644 app/apps/keys/apps.py create mode 100644 app/apps/keys/migrations/0001_initial.py create mode 100644 app/apps/keys/migrations/__init__.py create mode 100644 app/apps/keys/models.py create mode 100644 app/apps/telemetry/__init__.py create mode 100644 app/apps/telemetry/admin.py create mode 100644 app/apps/telemetry/apps.py create mode 100644 app/apps/telemetry/migrations/0001_initial.py create mode 100644 app/apps/telemetry/migrations/__init__.py create mode 100644 app/apps/telemetry/models.py create mode 100644 app/keywarden/api/routers/access.py create mode 100644 app/keywarden/api/routers/agent.py create mode 100644 app/keywarden/api/routers/keys.py create mode 100644 app/keywarden/api/routers/telemetry.py diff --git a/API_DOCS.md b/API_DOCS.md new file mode 100644 index 0000000..7f161ba --- /dev/null +++ b/API_DOCS.md @@ -0,0 +1,15 @@ +# API docs + +The API v1 interactive documentation is served by Django Ninja at `/api/v1/docs`. + +What it provides: +- Swagger UI for all v1 routes, request/response schemas, and example payloads. +- Try-it-out support for GET/POST/PATCH/DELETE. + +Authentication: +- Session auth works in the browser, but unsafe requests require CSRF. +- For API testing, use JWT: add `Authorization: Bearer `. + +Notes: +- Base URL for v1 endpoints is `/api/v1`. +- Admin-only routes return `403 Forbidden` when the token user is not staff/superuser. diff --git a/app/apps/access/__init__.py b/app/apps/access/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/access/admin.py b/app/apps/access/admin.py new file mode 100644 index 0000000..859ffcf --- /dev/null +++ b/app/apps/access/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from .models import AccessRequest + + +@admin.register(AccessRequest) +class AccessRequestAdmin(admin.ModelAdmin): + list_display = ( + "id", + "requester", + "server", + "status", + "requested_at", + "expires_at", + "decided_by", + ) + list_filter = ("status", "server") + search_fields = ("requester__username", "requester__email", "server__display_name") + ordering = ("-requested_at",) diff --git a/app/apps/access/apps.py b/app/apps/access/apps.py new file mode 100644 index 0000000..ce4f8fd --- /dev/null +++ b/app/apps/access/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AccessConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.access" + verbose_name = "Access Requests" diff --git a/app/apps/access/migrations/0001_initial.py b/app/apps/access/migrations/0001_initial.py new file mode 100644 index 0000000..07959d8 --- /dev/null +++ b/app/apps/access/migrations/0001_initial.py @@ -0,0 +1,78 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("servers", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="AccessRequest", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("approved", "Approved"), + ("denied", "Denied"), + ("revoked", "Revoked"), + ("cancelled", "Cancelled"), + ("expired", "Expired"), + ], + db_index=True, + default="pending", + max_length=16, + ), + ), + ("reason", models.TextField(blank=True)), + ("requested_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ("decided_at", models.DateTimeField(blank=True, null=True)), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ( + "decided_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="access_decisions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "requester", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="access_requests", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "server", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="access_requests", + to="servers.server", + ), + ), + ], + options={ + "verbose_name": "Access request", + "verbose_name_plural": "Access requests", + "ordering": ["-requested_at"], + "indexes": [ + models.Index(fields=["status", "requested_at"], name="acc_req_status_req_idx"), + models.Index(fields=["server", "status"], name="acc_req_server_status_idx"), + ], + }, + ), + ] diff --git a/app/apps/access/migrations/__init__.py b/app/apps/access/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/access/models.py b/app/apps/access/models.py new file mode 100644 index 0000000..c280db8 --- /dev/null +++ b/app/apps/access/models.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from django.conf import settings +from django.db import models +from django.utils import timezone + +from apps.servers.models import Server + + +class AccessRequest(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "Pending" + APPROVED = "approved", "Approved" + DENIED = "denied", "Denied" + REVOKED = "revoked", "Revoked" + CANCELLED = "cancelled", "Cancelled" + EXPIRED = "expired", "Expired" + + requester = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="access_requests", + ) + server = models.ForeignKey( + Server, on_delete=models.CASCADE, related_name="access_requests" + ) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.PENDING, db_index=True + ) + reason = models.TextField(blank=True) + requested_at = models.DateTimeField(default=timezone.now, editable=False) + decided_at = models.DateTimeField(null=True, blank=True) + expires_at = models.DateTimeField(null=True, blank=True) + decided_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="access_decisions", + ) + + class Meta: + verbose_name = "Access request" + verbose_name_plural = "Access requests" + indexes = [ + models.Index(fields=["status", "requested_at"], name="acc_req_status_req_idx"), + models.Index(fields=["server", "status"], name="acc_req_server_status_idx"), + ] + ordering = ["-requested_at"] + + def is_expired(self) -> bool: + if not self.expires_at: + return False + return self.expires_at <= timezone.now() + + def __str__(self) -> str: + return f"{self.requester_id} -> {self.server_id} ({self.status})" diff --git a/app/apps/keys/__init__.py b/app/apps/keys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/keys/admin.py b/app/apps/keys/admin.py new file mode 100644 index 0000000..cdcc716 --- /dev/null +++ b/app/apps/keys/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from .models import SSHKey + + +@admin.register(SSHKey) +class SSHKeyAdmin(admin.ModelAdmin): + list_display = ("id", "user", "name", "key_type", "fingerprint", "is_active", "created_at") + list_filter = ("is_active", "key_type") + search_fields = ("name", "user__username", "user__email", "fingerprint") + ordering = ("-created_at",) diff --git a/app/apps/keys/apps.py b/app/apps/keys/apps.py new file mode 100644 index 0000000..9650cd3 --- /dev/null +++ b/app/apps/keys/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class KeysConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.keys" + verbose_name = "SSH Keys" diff --git a/app/apps/keys/migrations/0001_initial.py b/app/apps/keys/migrations/0001_initial.py new file mode 100644 index 0000000..0e4484c --- /dev/null +++ b/app/apps/keys/migrations/0001_initial.py @@ -0,0 +1,53 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SSHKey", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=128)), + ("public_key", models.TextField()), + ("key_type", models.CharField(max_length=32)), + ("fingerprint", models.CharField(db_index=True, max_length=128)), + ("is_active", models.BooleanField(db_index=True, default=True)), + ("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ("revoked_at", models.DateTimeField(blank=True, null=True)), + ("last_used_at", models.DateTimeField(blank=True, null=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ssh_keys", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "SSH key", + "verbose_name_plural": "SSH keys", + "ordering": ["-created_at"], + "indexes": [ + models.Index(fields=["user", "is_active"], name="keys_user_active_idx"), + models.Index(fields=["fingerprint"], name="keys_fingerprint_idx"), + ], + "constraints": [ + models.UniqueConstraint( + fields=("user", "fingerprint"), + name="unique_user_key_fingerprint", + ) + ], + }, + ), + ] diff --git a/app/apps/keys/migrations/__init__.py b/app/apps/keys/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/keys/models.py b/app/apps/keys/models.py new file mode 100644 index 0000000..9cd2197 --- /dev/null +++ b/app/apps/keys/models.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import base64 +import binascii +import hashlib + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone + + +def parse_public_key(public_key: str) -> tuple[str, str, str]: + trimmed = (public_key or "").strip() + parts = trimmed.split() + if len(parts) < 2: + raise ValidationError("Invalid SSH public key format.") + key_type, key_b64 = parts[0], parts[1] + try: + key_bytes = base64.b64decode(key_b64.encode("ascii"), validate=True) + except (binascii.Error, ValueError) as exc: + raise ValidationError("Invalid SSH public key format.") from exc + digest = hashlib.sha256(key_bytes).digest() + fingerprint = "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=") + return key_type, key_b64, fingerprint + + +class SSHKey(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="ssh_keys" + ) + name = models.CharField(max_length=128) + public_key = models.TextField() + key_type = models.CharField(max_length=32) + fingerprint = models.CharField(max_length=128, db_index=True) + is_active = models.BooleanField(default=True, db_index=True) + created_at = models.DateTimeField(default=timezone.now, editable=False) + revoked_at = models.DateTimeField(null=True, blank=True) + last_used_at = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = "SSH key" + verbose_name_plural = "SSH keys" + indexes = [ + models.Index(fields=["user", "is_active"], name="keys_user_active_idx"), + models.Index(fields=["fingerprint"], name="keys_fingerprint_idx"), + ] + constraints = [ + models.UniqueConstraint( + fields=["user", "fingerprint"], name="unique_user_key_fingerprint" + ) + ] + ordering = ["-created_at"] + + def set_public_key(self, public_key: str) -> None: + key_type, key_b64, fingerprint = parse_public_key(public_key) + self.key_type = key_type + self.fingerprint = fingerprint + self.public_key = f"{key_type} {key_b64}" + + def revoke(self) -> None: + self.is_active = False + self.revoked_at = timezone.now() + + def __str__(self) -> str: + return f"{self.name} ({self.user_id})" diff --git a/app/apps/telemetry/__init__.py b/app/apps/telemetry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/telemetry/admin.py b/app/apps/telemetry/admin.py new file mode 100644 index 0000000..dea17f4 --- /dev/null +++ b/app/apps/telemetry/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from .models import TelemetryEvent + + +@admin.register(TelemetryEvent) +class TelemetryEventAdmin(admin.ModelAdmin): + list_display = ("id", "event_type", "server", "user", "success", "source", "created_at") + list_filter = ("success", "source", "event_type") + search_fields = ("event_type", "message", "server__display_name", "user__username") + ordering = ("-created_at",) diff --git a/app/apps/telemetry/apps.py b/app/apps/telemetry/apps.py new file mode 100644 index 0000000..7f623de --- /dev/null +++ b/app/apps/telemetry/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class TelemetryConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.telemetry" + verbose_name = "Telemetry" diff --git a/app/apps/telemetry/migrations/0001_initial.py b/app/apps/telemetry/migrations/0001_initial.py new file mode 100644 index 0000000..9450240 --- /dev/null +++ b/app/apps/telemetry/migrations/0001_initial.py @@ -0,0 +1,73 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("servers", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="TelemetryEvent", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("event_type", models.CharField(db_index=True, max_length=64)), + ("success", models.BooleanField(db_index=True, default=True)), + ( + "source", + models.CharField( + choices=[ + ("agent", "Agent"), + ("api", "API"), + ("ui", "UI"), + ("system", "System"), + ], + db_index=True, + default="api", + max_length=16, + ), + ), + ("message", models.TextField(blank=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(db_index=True, default=django.utils.timezone.now, editable=False)), + ( + "server", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="telemetry_events", + to="servers.server", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="telemetry_events", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Telemetry event", + "verbose_name_plural": "Telemetry events", + "ordering": ["-created_at"], + "indexes": [ + models.Index(fields=["created_at"], name="telemetry_created_at_idx"), + models.Index(fields=["event_type"], name="telemetry_event_type_idx"), + models.Index(fields=["server", "created_at"], name="telemetry_server_created_idx"), + models.Index(fields=["user", "created_at"], name="telemetry_user_created_idx"), + ], + }, + ), + ] diff --git a/app/apps/telemetry/migrations/__init__.py b/app/apps/telemetry/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/telemetry/models.py b/app/apps/telemetry/models.py new file mode 100644 index 0000000..a86cdeb --- /dev/null +++ b/app/apps/telemetry/models.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from django.conf import settings +from django.db import models +from django.utils import timezone + +from apps.servers.models import Server + + +class TelemetryEvent(models.Model): + class Source(models.TextChoices): + AGENT = "agent", "Agent" + API = "api", "API" + UI = "ui", "UI" + SYSTEM = "system", "System" + + event_type = models.CharField(max_length=64, db_index=True) + server = models.ForeignKey( + Server, null=True, blank=True, on_delete=models.SET_NULL, related_name="telemetry_events" + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="telemetry_events", + ) + success = models.BooleanField(default=True, db_index=True) + source = models.CharField( + max_length=16, choices=Source.choices, default=Source.API, db_index=True + ) + message = models.TextField(blank=True) + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(default=timezone.now, editable=False, db_index=True) + + class Meta: + verbose_name = "Telemetry event" + verbose_name_plural = "Telemetry events" + indexes = [ + models.Index(fields=["created_at"], name="telemetry_created_at_idx"), + models.Index(fields=["event_type"], name="telemetry_event_type_idx"), + models.Index(fields=["server", "created_at"], name="telemetry_server_created_idx"), + models.Index(fields=["user", "created_at"], name="telemetry_user_created_idx"), + ] + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"{self.event_type} ({'ok' if self.success else 'fail'})" diff --git a/app/keywarden/api/main.py b/app/keywarden/api/main.py index f4d00da..a161304 100644 --- a/app/keywarden/api/main.py +++ b/app/keywarden/api/main.py @@ -9,6 +9,10 @@ from .routers.audit import build_router as build_audit_router from .routers.system import build_router as build_system_router from .routers.servers import build_router as build_servers_router from .routers.users import build_router as build_users_router +from .routers.keys import build_router as build_keys_router +from .routers.access import build_router as build_access_router +from .routers.telemetry import build_router as build_telemetry_router +from .routers.agent import build_router as build_agent_router def register_routers(target_api: NinjaAPI) -> None: @@ -17,6 +21,10 @@ def register_routers(target_api: NinjaAPI) -> None: target_api.add_router("/audit", build_audit_router(), tags=["audit"]) target_api.add_router("/servers", build_servers_router(), tags=["servers"]) target_api.add_router("/users", build_users_router(), tags=["users"]) + target_api.add_router("/keys", build_keys_router(), tags=["keys"]) + target_api.add_router("/access-requests", build_access_router(), tags=["access"]) + target_api.add_router("/telemetry", build_telemetry_router(), tags=["telemetry"]) + target_api.add_router("/agent", build_agent_router(), tags=["agent"]) api = NinjaAPI( diff --git a/app/keywarden/api/routers/access.py b/app/keywarden/api/routers/access.py new file mode 100644 index 0000000..98f1aa4 --- /dev/null +++ b/app/keywarden/api/routers/access.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional + +from django.http import HttpRequest +from django.utils import timezone +from ninja import Query, Router, Schema +from ninja.errors import HttpError +from pydantic import Field + +from apps.access.models import AccessRequest +from apps.servers.models import Server + + +class AccessRequestCreateIn(Schema): + server_id: int + reason: Optional[str] = None + expires_at: Optional[datetime] = None + + +class AccessRequestUpdateIn(Schema): + status: Optional[str] = None + expires_at: Optional[datetime] = None + + +class AccessRequestOut(Schema): + id: int + requester_id: int + server_id: int + status: str + reason: str + requested_at: str + decided_at: Optional[str] = None + expires_at: Optional[str] = None + decided_by_id: Optional[int] = None + + +class AccessQuery(Schema): + limit: int = Field(default=50, ge=1, le=200) + offset: int = Field(default=0, ge=0) + status: Optional[str] = None + server_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: + return AccessRequestOut( + id=access_request.id, + requester_id=access_request.requester_id, + server_id=access_request.server_id, + status=access_request.status, + reason=access_request.reason or "", + requested_at=access_request.requested_at.isoformat(), + decided_at=access_request.decided_at.isoformat() if access_request.decided_at else None, + expires_at=access_request.expires_at.isoformat() if access_request.expires_at else None, + decided_by_id=access_request.decided_by_id, + ) + + +def build_router() -> Router: + 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) + qs = AccessRequest.objects.order_by("-requested_at") + if _is_admin(request): + if filters.requester_id: + qs = qs.filter(requester_id=filters.requester_id) + else: + qs = qs.filter(requester=request.user) + if filters.status: + qs = qs.filter(status=filters.status) + if filters.server_id: + qs = qs.filter(server_id=filters.server_id) + qs = qs[filters.offset : filters.offset + filters.limit] + return [_request_to_out(item) for item in qs] + + @router.post("/", response=AccessRequestOut) + def create_request(request: HttpRequest, payload: AccessRequestCreateIn): + """Create a new access request for a server.""" + _require_authenticated(request) + try: + server = Server.objects.get(id=payload.server_id) + except Server.DoesNotExist: + raise HttpError(404, "Server not found") + access_request = AccessRequest( + requester=request.user, + server=server, + reason=(payload.reason or "").strip(), + ) + if payload.expires_at: + access_request.expires_at = payload.expires_at + if timezone.is_naive(access_request.expires_at): + access_request.expires_at = timezone.make_aware(access_request.expires_at) + access_request.save() + return _request_to_out(access_request) + + @router.get("/{request_id}", response=AccessRequestOut) + def get_request(request: HttpRequest, request_id: int): + """Get an access request if permitted.""" + _require_authenticated(request) + 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: + 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) + try: + access_request = AccessRequest.objects.get(id=request_id) + except AccessRequest.DoesNotExist: + raise HttpError(404, "Not Found") + is_admin = _is_admin(request) + is_owner = access_request.requester_id == request.user.id + if not is_admin and not is_owner: + raise HttpError(403, "Forbidden") + if payload.status is None and payload.expires_at is None: + raise HttpError(422, {"detail": "No fields provided."}) + if payload.expires_at is not None: + if not is_admin: + raise HttpError(403, "Forbidden") + access_request.expires_at = payload.expires_at + if timezone.is_naive(access_request.expires_at): + access_request.expires_at = timezone.make_aware(access_request.expires_at) + if payload.status is not None: + status = payload.status + if is_admin: + if status not in { + AccessRequest.Status.APPROVED, + AccessRequest.Status.DENIED, + AccessRequest.Status.REVOKED, + AccessRequest.Status.CANCELLED, + }: + raise HttpError(422, {"status": ["Invalid status."]}) + else: + if status != AccessRequest.Status.CANCELLED: + raise HttpError(403, "Forbidden") + if access_request.status != AccessRequest.Status.PENDING: + raise HttpError(422, {"status": ["Only pending requests can be cancelled."]}) + access_request.status = status + access_request.decided_at = timezone.now() + if is_admin: + access_request.decided_by = request.user + else: + access_request.decided_by = None + access_request.save() + return _request_to_out(access_request) + + @router.delete("/{request_id}", response={204: None}) + def delete_request(request: HttpRequest, request_id: int): + """Delete an access request if permitted.""" + _require_authenticated(request) + 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: + raise HttpError(403, "Forbidden") + access_request.delete() + return 204, None + + return router + + +router = build_router() diff --git a/app/keywarden/api/routers/accounts.py b/app/keywarden/api/routers/accounts.py index cd13099..91d641c 100644 --- a/app/keywarden/api/routers/accounts.py +++ b/app/keywarden/api/routers/accounts.py @@ -19,6 +19,7 @@ def build_router() -> Router: @router.get("/me", response=UserSchema) def me(request: HttpRequest): + """Return the current authenticated user's profile.""" user = request.user return { "id": user.id, @@ -34,4 +35,3 @@ def build_router() -> Router: router = build_router() - diff --git a/app/keywarden/api/routers/agent.py b/app/keywarden/api/routers/agent.py new file mode 100644 index 0000000..8a171d7 --- /dev/null +++ b/app/keywarden/api/routers/agent.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import List, Optional + +from django.db import models +from django.http import HttpRequest +from django.utils import timezone +from ninja import Router, Schema +from ninja.errors import HttpError +from pydantic import Field + +from apps.access.models import AccessRequest +from apps.keys.models import SSHKey +from apps.servers.models import Server +from apps.telemetry.models import TelemetryEvent + + +class AuthorizedKeyOut(Schema): + user_id: int + username: str + email: str + public_key: str + fingerprint: str + + +class SyncReportIn(Schema): + applied_count: int = Field(default=0, ge=0) + revoked_count: int = Field(default=0, ge=0) + message: Optional[str] = None + metadata: dict = Field(default_factory=dict) + + +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) + try: + server = Server.objects.get(id=server_id) + except Server.DoesNotExist: + raise HttpError(404, "Server not found") + now = timezone.now() + access_qs = AccessRequest.objects.select_related("requester").filter( + server=server, + status=AccessRequest.Status.APPROVED, + ) + access_qs = access_qs.filter(models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)) + users = [req.requester for req in access_qs if req.requester and req.requester.is_active] + keys = SSHKey.objects.select_related("user").filter( + user__in=users, + is_active=True, + revoked_at__isnull=True, + ) + return [ + AuthorizedKeyOut( + user_id=key.user_id, + username=key.user.username, + email=key.user.email or "", + public_key=key.public_key, + fingerprint=key.fingerprint, + ) + for key in keys + ] + + @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) + try: + server = Server.objects.get(id=server_id) + except Server.DoesNotExist: + raise HttpError(404, "Server not found") + TelemetryEvent.objects.create( + event_type="agent_sync", + server=server, + success=True, + source=TelemetryEvent.Source.AGENT, + message=(payload.message or "").strip(), + metadata={ + "applied_count": payload.applied_count, + "revoked_count": payload.revoked_count, + **(payload.metadata or {}), + }, + ) + return SyncReportOut(status="ok") + + return router + + +router = build_router() diff --git a/app/keywarden/api/routers/audit.py b/app/keywarden/api/routers/audit.py index e76986b..5a4ad25 100644 --- a/app/keywarden/api/routers/audit.py +++ b/app/keywarden/api/routers/audit.py @@ -46,6 +46,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.""" qs: QuerySet[AuditEventType] = AuditEventType.objects.all() return [ { @@ -60,6 +61,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.""" qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all() if filters.severity: qs = qs.filter(severity=filters.severity) @@ -92,4 +94,3 @@ def build_router() -> Router: router = build_router() - diff --git a/app/keywarden/api/routers/keys.py b/app/keywarden/api/routers/keys.py new file mode 100644 index 0000000..97e4107 --- /dev/null +++ b/app/keywarden/api/routers/keys.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from typing import List, Optional + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.http import HttpRequest +from django.utils import timezone +from ninja import Query, Router, Schema +from ninja.errors import HttpError +from pydantic import Field + +from apps.keys.models import SSHKey + + +class KeyCreateIn(Schema): + name: str + public_key: str + user_id: Optional[int] = None + + +class KeyUpdateIn(Schema): + name: Optional[str] = None + is_active: Optional[bool] = None + + +class KeyOut(Schema): + id: int + user_id: int + name: str + public_key: str + key_type: str + fingerprint: str + is_active: bool + created_at: str + revoked_at: Optional[str] = None + + +class KeysQuery(Schema): + limit: int = Field(default=50, ge=1, le=200) + offset: int = Field(default=0, ge=0) + 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, + user_id=key.user_id, + name=key.name, + public_key=key.public_key, + key_type=key.key_type, + fingerprint=key.fingerprint, + is_active=key.is_active, + created_at=key.created_at.isoformat(), + revoked_at=key.revoked_at.isoformat() if key.revoked_at else None, + ) + + +def build_router() -> Router: + 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) + qs = SSHKey.objects.order_by("-created_at") + if _is_admin(request): + if filters.user_id: + qs = qs.filter(user_id=filters.user_id) + else: + qs = qs.filter(user=request.user) + qs = qs[filters.offset : filters.offset + filters.limit] + return [_key_to_out(key) for key in qs] + + @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) + owner = request.user + if _is_admin(request) and payload.user_id: + User = get_user_model() + try: + owner = User.objects.get(id=payload.user_id) + except User.DoesNotExist: + raise HttpError(404, "User not found") + name = (payload.name or "").strip() + if not name: + raise HttpError(422, {"name": ["Name cannot be empty."]}) + key = SSHKey(user=owner, name=name) + try: + key.set_public_key(payload.public_key) + except ValidationError as exc: + raise HttpError(422, {"public_key": [str(exc)]}) + try: + key.save() + except IntegrityError: + raise HttpError(422, {"public_key": ["Key already exists."]}) + return _key_to_out(key) + + @router.get("/{key_id}", response=KeyOut) + def get_key(request: HttpRequest, key_id: int): + """Get a specific SSH key if permitted.""" + _require_authenticated(request) + 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: + 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) + 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: + raise HttpError(403, "Forbidden") + if payload.name is None and payload.is_active is None: + raise HttpError(422, {"detail": "No fields provided."}) + if payload.name is not None: + name = payload.name.strip() + if not name: + raise HttpError(422, {"name": ["Name cannot be empty."]}) + key.name = name + if payload.is_active is not None: + key.is_active = payload.is_active + if payload.is_active: + key.revoked_at = None + else: + key.revoked_at = timezone.now() + key.save() + return _key_to_out(key) + + @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) + 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: + raise HttpError(403, "Forbidden") + if key.is_active: + key.is_active = False + key.revoked_at = timezone.now() + key.save(update_fields=["is_active", "revoked_at"]) + return 204, None + + return router + + +router = build_router() diff --git a/app/keywarden/api/routers/servers.py b/app/keywarden/api/routers/servers.py index 77e3773..d4f155d 100644 --- a/app/keywarden/api/routers/servers.py +++ b/app/keywarden/api/routers/servers.py @@ -47,6 +47,7 @@ def build_router() -> Router: @router.get("/", response=List[ServerOut]) def list_servers(request: HttpRequest): + """List servers visible to authenticated users.""" servers = Server.objects.all() return [ { @@ -63,6 +64,7 @@ def build_router() -> Router: @router.get("/{server_id}", response=ServerOut) def get_server(request: HttpRequest, server_id: int): + """Get server details by id.""" try: server = Server.objects.get(id=server_id) except Server.DoesNotExist: @@ -79,6 +81,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) server = Server.objects.create( display_name=payload.display_name.strip(), @@ -105,6 +108,7 @@ def build_router() -> Router: ipv6: Optional[str] = Form(None), image: Optional[UploadedFile] = File(None), ): + """Create a server with optional image upload (admin only).""" _require_admin(request) server = Server( display_name=display_name.strip(), @@ -127,6 +131,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) if ( payload.display_name is None @@ -166,6 +171,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) try: server = Server.objects.get(id=server_id) diff --git a/app/keywarden/api/routers/system.py b/app/keywarden/api/routers/system.py index b7a045c..0401d3e 100644 --- a/app/keywarden/api/routers/system.py +++ b/app/keywarden/api/routers/system.py @@ -12,10 +12,10 @@ def build_router() -> Router: @router.get("/health", response=HealthResponse) def health() -> HealthResponse: + """Health check endpoint for service monitoring.""" return {"status": "ok"} return router router = build_router() - diff --git a/app/keywarden/api/routers/telemetry.py b/app/keywarden/api/routers/telemetry.py new file mode 100644 index 0000000..d210905 --- /dev/null +++ b/app/keywarden/api/routers/telemetry.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from typing import List, Optional + +from django.contrib.auth import get_user_model +from django.db import models +from django.db.models import Count +from django.http import HttpRequest +from ninja import Query, Router, Schema +from ninja.errors import HttpError +from pydantic import Field + +from apps.servers.models import Server +from apps.telemetry.models import TelemetryEvent + + +class TelemetryCreateIn(Schema): + event_type: str + server_id: Optional[int] = None + user_id: Optional[int] = None + success: bool = True + source: Optional[str] = None + message: Optional[str] = None + metadata: dict = Field(default_factory=dict) + + +class TelemetryOut(Schema): + id: int + event_type: str + server_id: Optional[int] = None + user_id: Optional[int] = None + success: bool + source: str + message: str + metadata: dict + created_at: str + + +class TelemetrySummaryOut(Schema): + total: int + success: int + failure: int + + +class TelemetryQuery(Schema): + limit: int = Field(default=50, ge=1, le=200) + offset: int = Field(default=0, ge=0) + event_type: Optional[str] = None + server_id: Optional[int] = None + user_id: Optional[int] = 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: + return TelemetryOut( + id=event.id, + event_type=event.event_type, + server_id=event.server_id, + user_id=event.user_id, + success=event.success, + source=event.source, + message=event.message or "", + metadata=event.metadata or {}, + created_at=event.created_at.isoformat(), + ) + + +def build_router() -> Router: + 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) + qs = TelemetryEvent.objects.order_by("-created_at") + if filters.event_type: + qs = qs.filter(event_type=filters.event_type) + if filters.server_id: + qs = qs.filter(server_id=filters.server_id) + if filters.user_id: + qs = qs.filter(user_id=filters.user_id) + if filters.success is not None: + qs = qs.filter(success=filters.success) + qs = qs[filters.offset : filters.offset + filters.limit] + return [_event_to_out(event) for event in qs] + + @router.post("/", response=TelemetryOut) + def create_event(request: HttpRequest, payload: TelemetryCreateIn): + """Create a telemetry event entry (admin only).""" + _require_admin(request) + server = None + if payload.server_id: + try: + server = Server.objects.get(id=payload.server_id) + except Server.DoesNotExist: + raise HttpError(404, "Server not found") + if payload.user_id: + User = get_user_model() + if not User.objects.filter(id=payload.user_id).exists(): + raise HttpError(404, "User not found") + source = payload.source or TelemetryEvent.Source.API + if source not in TelemetryEvent.Source.values: + raise HttpError(422, {"source": ["Invalid source."]}) + event = TelemetryEvent.objects.create( + event_type=payload.event_type.strip(), + server=server, + user_id=payload.user_id, + success=payload.success, + source=source, + message=(payload.message or "").strip(), + metadata=payload.metadata or {}, + ) + return _event_to_out(event) + + @router.get("/summary", response=TelemetrySummaryOut) + def summary(request: HttpRequest): + """Return a high-level telemetry summary (admin only).""" + _require_admin(request) + totals = TelemetryEvent.objects.aggregate( + total=Count("id"), + success=Count("id", filter=models.Q(success=True)), + ) + total = totals.get("total") or 0 + success = totals.get("success") or 0 + return TelemetrySummaryOut(total=total, success=success, failure=total - success) + + return router + + +router = build_router() diff --git a/app/keywarden/api/routers/users.py b/app/keywarden/api/routers/users.py index d669a6e..d0e59c5 100644 --- a/app/keywarden/api/routers/users.py +++ b/app/keywarden/api/routers/users.py @@ -68,6 +68,7 @@ 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) User = get_user_model() email = payload.email.strip().lower() @@ -89,6 +90,7 @@ 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) User = get_user_model() qs = User.objects.order_by("id")[pagination.offset : pagination.offset + pagination.limit] @@ -104,6 +106,7 @@ 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) User = get_user_model() try: @@ -119,6 +122,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) 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."}) @@ -149,6 +153,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) User = get_user_model() try: diff --git a/app/keywarden/settings/base.py b/app/keywarden/settings/base.py index d8b1f43..5d26767 100644 --- a/app/keywarden/settings/base.py +++ b/app/keywarden/settings/base.py @@ -38,6 +38,9 @@ INSTALLED_APPS = [ "apps.core", "apps.dashboard", "apps.servers", + "apps.keys", + "apps.access", + "apps.telemetry", "ninja", # Django Ninja API "mozilla_django_oidc", # OIDC Client "tailwind",