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/accounts/migrations/0005_unique_user_email_index.py b/app/apps/accounts/migrations/0005_unique_user_email_index.py new file mode 100644 index 0000000..993acaa --- /dev/null +++ b/app/apps/accounts/migrations/0005_unique_user_email_index.py @@ -0,0 +1,20 @@ +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0004_delete_account"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RunSQL( + sql=( + "CREATE UNIQUE INDEX IF NOT EXISTS auth_user_email_uniq " + "ON auth_user (email);" + ), + reverse_sql="DROP INDEX IF EXISTS auth_user_email_uniq;", + ), + ] 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/__init__.py b/app/keywarden/api/__init__.py index ba85869..baeb938 100644 --- a/app/keywarden/api/__init__.py +++ b/app/keywarden/api/__init__.py @@ -1,3 +1,2 @@ -from .main import api - +from .main import api, api_v1 diff --git a/app/keywarden/api/main.py b/app/keywarden/api/main.py index d909453..a161304 100644 --- a/app/keywarden/api/main.py +++ b/app/keywarden/api/main.py @@ -4,10 +4,27 @@ from ninja import NinjaAPI, Router, Schema from ninja.security import django_auth from .security import JWTAuth -from .routers.accounts import router as accounts_router -from .routers.audit import router as audit_router -from .routers.system import router as system_router -from .routers.servers import router as servers_router +from .routers.accounts import build_router as build_accounts_router +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: + target_api.add_router("/system", build_system_router(), tags=["system"]) + target_api.add_router("/user", build_accounts_router(), tags=["user"]) + 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( @@ -17,11 +34,14 @@ api = NinjaAPI( auth=[django_auth, JWTAuth()], csrf=True, # enforce CSRF for session-authenticated unsafe requests ) +register_routers(api) -# Mount routers -api.add_router("/system", system_router, tags=["system"]) -api.add_router("/user", accounts_router, tags=["user"]) -api.add_router("/audit", audit_router, tags=["audit"]) -api.add_router("/servers", servers_router, tags=["servers"]) - - +api_v1 = NinjaAPI( + title="Keywarden API", + version="1.0.0", + description="Authenticated API for internal app use and external clients.", + auth=[django_auth, JWTAuth()], + csrf=True, + urls_namespace="api-v1", +) +register_routers(api_v1) 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 c4ccd4b..91d641c 100644 --- a/app/keywarden/api/routers/accounts.py +++ b/app/keywarden/api/routers/accounts.py @@ -3,8 +3,6 @@ from typing import Optional from django.http import HttpRequest from ninja import Router, Schema -router = Router() - class UserSchema(Schema): id: int @@ -16,17 +14,24 @@ class UserSchema(Schema): is_superuser: bool -@router.get("/me", response=UserSchema) -def me(request: HttpRequest): - user = request.user - return { - "id": user.id, - "username": user.username, - "email": user.email or "", - "first_name": user.first_name or "", - "last_name": user.last_name or "", - "is_staff": bool(user.is_staff), - "is_superuser": bool(user.is_superuser), - } +def build_router() -> Router: + 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, + "username": user.username, + "email": user.email or "", + "first_name": user.first_name or "", + "last_name": user.last_name or "", + "is_staff": bool(user.is_staff), + "is_superuser": bool(user.is_superuser), + } + + return 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 bd98c15..5a4ad25 100644 --- a/app/keywarden/api/routers/audit.py +++ b/app/keywarden/api/routers/audit.py @@ -9,9 +9,6 @@ from ninja import Query, Router, Schema from apps.audit.models import AuditEventType, AuditLog -router = Router() - - class AuditEventTypeSchema(Schema): id: int key: str @@ -44,49 +41,56 @@ class LogsQuery(Schema): source: Optional[str] = None -@router.get("/event-types", response=List[AuditEventTypeSchema]) -def list_event_types(request: HttpRequest): - qs: QuerySet[AuditEventType] = AuditEventType.objects.all() - return [ - { - "id": et.id, - "key": et.key, - "title": et.title, - "description": et.description or "", - "default_severity": et.default_severity, - } - for et in qs - ] - - -@router.get("/logs", response=List[AuditLogSchema]) -def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)): - qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all() - if filters.severity: - qs = qs.filter(severity=filters.severity) - if filters.actor_id: - qs = qs.filter(actor_id=filters.actor_id) - if filters.event_type_key: - qs = qs.filter(event_type__key=filters.event_type_key) - if filters.source: - qs = qs.filter(source=filters.source) - qs = qs.order_by("-created_at")[filters.offset : filters.offset + filters.limit] - return [ - { - "id": al.id, - "created_at": al.created_at.isoformat(), - "actor_id": al.actor_id, - "event_type_id": al.event_type_id, - "message": al.message, - "severity": al.severity, - "source": al.source, - "object_repr": al.object_repr or "", - "ip_address": al.ip_address or "", - "user_agent": al.user_agent or "", - "request_id": al.request_id or "", - "metadata": al.metadata or {}, - } - for al in qs - ] +def build_router() -> Router: + 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 [ + { + "id": et.id, + "key": et.key, + "title": et.title, + "description": et.description or "", + "default_severity": et.default_severity, + } + for et in qs + ] + + @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) + if filters.actor_id: + qs = qs.filter(actor_id=filters.actor_id) + if filters.event_type_key: + qs = qs.filter(event_type__key=filters.event_type_key) + if filters.source: + qs = qs.filter(source=filters.source) + qs = qs.order_by("-created_at")[filters.offset : filters.offset + filters.limit] + return [ + { + "id": al.id, + "created_at": al.created_at.isoformat(), + "actor_id": al.actor_id, + "event_type_id": al.event_type_id, + "message": al.message, + "severity": al.severity, + "source": al.source, + "object_repr": al.object_repr or "", + "ip_address": al.ip_address or "", + "user_agent": al.user_agent or "", + "request_id": al.request_id or "", + "metadata": al.metadata or {}, + } + for al in qs + ] + + return 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 94b94d5..d4f155d 100644 --- a/app/keywarden/api/routers/servers.py +++ b/app/keywarden/api/routers/servers.py @@ -2,13 +2,13 @@ from __future__ import annotations from typing import List, Optional +from django.db import IntegrityError from django.http import HttpRequest -from ninja import Router, Schema, File, Form +from ninja import File, Form, Router, Schema from ninja.files import UploadedFile +from ninja.errors import HttpError from apps.servers.models import Server -router = Router() - class ServerOut(Schema): id: int @@ -27,68 +27,160 @@ class ServerCreate(Schema): ipv6: Optional[str] = None -@router.get("/", response=List[ServerOut]) -def list_servers(request: HttpRequest): - servers = Server.objects.all() - return [ - { - "id": s.id, - "display_name": s.display_name, - "hostname": s.hostname, - "ipv4": s.ipv4, - "ipv6": s.ipv6, - "image_url": s.image_url, - "initial": s.initial, +class ServerUpdate(Schema): + display_name: Optional[str] = None + hostname: Optional[str] = None + ipv4: 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: + router = Router() + + @router.get("/", response=List[ServerOut]) + def list_servers(request: HttpRequest): + """List servers visible to authenticated users.""" + servers = Server.objects.all() + return [ + { + "id": s.id, + "display_name": s.display_name, + "hostname": s.hostname, + "ipv4": s.ipv4, + "ipv6": s.ipv6, + "image_url": s.image_url, + "initial": s.initial, + } + for s in servers + ] + + @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: + raise HttpError(404, "Not Found") + return { + "id": server.id, + "display_name": server.display_name, + "hostname": server.hostname, + "ipv4": server.ipv4, + "ipv6": server.ipv6, + "image_url": server.image_url, + "initial": server.initial, } - for s in servers - ] - - -@router.post("/", response=ServerOut) -def create_server_json(request: HttpRequest, payload: ServerCreate): - server = Server.objects.create( - display_name=payload.display_name.strip(), - hostname=(payload.hostname or "").strip() or None, - ipv4=(payload.ipv4 or "").strip() or None, - ipv6=(payload.ipv6 or "").strip() or None, - ) - return { - "id": server.id, - "display_name": server.display_name, - "hostname": server.hostname, - "ipv4": server.ipv4, - "ipv6": server.ipv6, - "image_url": server.image_url, - "initial": server.initial, - } - - -@router.post("/upload", response=ServerOut) -def create_server_multipart( - request: HttpRequest, - display_name: str = Form(...), - hostname: Optional[str] = Form(None), - ipv4: Optional[str] = Form(None), - ipv6: Optional[str] = Form(None), - image: Optional[UploadedFile] = File(None), -): - server = Server( - display_name=display_name.strip(), - hostname=(hostname or "").strip() or None, - ipv4=(ipv4 or "").strip() or None, - ipv6=(ipv6 or "").strip() or None, - ) - if image: - server.image.save(image.name, image) # type: ignore[arg-type] - server.save() - return { - "id": server.id, - "display_name": server.display_name, - "hostname": server.hostname, - "ipv4": server.ipv4, - "ipv6": server.ipv6, - "image_url": server.image_url, - "initial": server.initial, - } + + @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(), + hostname=(payload.hostname or "").strip() or None, + ipv4=(payload.ipv4 or "").strip() or None, + ipv6=(payload.ipv6 or "").strip() or None, + ) + return { + "id": server.id, + "display_name": server.display_name, + "hostname": server.hostname, + "ipv4": server.ipv4, + "ipv6": server.ipv6, + "image_url": server.image_url, + "initial": server.initial, + } + + @router.post("/upload", response=ServerOut) + def create_server_multipart( + request: HttpRequest, + display_name: str = Form(...), + hostname: Optional[str] = Form(None), + ipv4: Optional[str] = Form(None), + 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(), + hostname=(hostname or "").strip() or None, + ipv4=(ipv4 or "").strip() or None, + ipv6=(ipv6 or "").strip() or None, + ) + if image: + server.image.save(image.name, image) # type: ignore[arg-type] + server.save() + return { + "id": server.id, + "display_name": server.display_name, + "hostname": server.hostname, + "ipv4": server.ipv4, + "ipv6": server.ipv6, + "image_url": server.image_url, + "initial": server.initial, + } + + @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 + and payload.hostname is None + and payload.ipv4 is None + and payload.ipv6 is None + ): + raise HttpError(422, {"detail": "No fields provided."}) + try: + server = Server.objects.get(id=server_id) + except Server.DoesNotExist: + raise HttpError(404, "Not Found") + if payload.display_name is not None: + display_name = payload.display_name.strip() + if not display_name: + raise HttpError(422, {"display_name": ["Display name cannot be empty."]}) + server.display_name = display_name + if payload.hostname is not None: + server.hostname = (payload.hostname or "").strip() or None + if payload.ipv4 is not None: + server.ipv4 = (payload.ipv4 or "").strip() or None + if payload.ipv6 is not None: + server.ipv6 = (payload.ipv6 or "").strip() or None + try: + server.save() + except IntegrityError: + raise HttpError(422, {"detail": "Unique constraint violated."}) + return { + "id": server.id, + "display_name": server.display_name, + "hostname": server.hostname, + "ipv4": server.ipv4, + "ipv6": server.ipv6, + "image_url": server.image_url, + "initial": server.initial, + } + + @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) + except Server.DoesNotExist: + raise HttpError(404, "Not Found") + server.delete() + return 204, None + + return router +router = build_router() diff --git a/app/keywarden/api/routers/system.py b/app/keywarden/api/routers/system.py index f2b5e94..0401d3e 100644 --- a/app/keywarden/api/routers/system.py +++ b/app/keywarden/api/routers/system.py @@ -2,15 +2,20 @@ from typing import Literal, TypedDict from ninja import Router -router = Router() - class HealthResponse(TypedDict): status: Literal["ok"] -@router.get("/health", response=HealthResponse) -def health() -> HealthResponse: - return {"status": "ok"} +def build_router() -> Router: + 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 new file mode 100644 index 0000000..d0e59c5 --- /dev/null +++ b/app/keywarden/api/routers/users.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from typing import List, Literal + +from django.contrib.auth import get_user_model +from django.db import IntegrityError +from django.http import HttpRequest +from ninja import Query, Router, Schema +from ninja.errors import HttpError +from pydantic import EmailStr, Field + + +class UserCreateIn(Schema): + email: EmailStr + password: str = Field(min_length=8) + role: Literal["admin", "user"] + + +class UserListOut(Schema): + id: int + email: str + role: str + is_active: bool + + +class UserDetailOut(Schema): + id: int + email: str + role: str + is_active: bool + + +class UserUpdateIn(Schema): + email: EmailStr | None = None + password: str | None = Field(default=None, min_length=8) + role: Literal["admin", "user"] | None = None + is_active: bool | None = None + + +class UsersQuery(Schema): + limit: int = Field(default=50, ge=1, le=200) + 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 + + +def build_router() -> Router: + 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() + 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."]}) + return { + "id": user.id, + "email": user.email, + "role": _role_from_user(user), + "is_active": user.is_active, + } + + @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] + return [ + { + "id": user.id, + "email": user.email or "", + "role": _role_from_user(user), + "is_active": user.is_active, + } + for user in qs + ] + + @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: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + raise HttpError(404, "Not Found") + return { + "id": user.id, + "email": user.email or "", + "role": _role_from_user(user), + "is_active": user.is_active, + } + + @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."}) + User = get_user_model() + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + raise HttpError(404, "Not Found") + if payload.email is not None: + email = payload.email.strip().lower() + if User.objects.filter(email__iexact=email).exclude(id=user_id).exists(): + raise HttpError(422, {"email": ["Email already exists."]}) + user.email = email + user.username = email + if payload.password is not None: + user.set_password(payload.password) + if payload.role is not None: + _apply_role(user, payload.role) + if payload.is_active is not None: + user.is_active = payload.is_active + user.save() + return { + "id": user.id, + "email": user.email or "", + "role": _role_from_user(user), + "is_active": user.is_active, + } + + @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: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + raise HttpError(404, "Not Found") + user.delete() + return 204, None + + return router + + +router = build_router() diff --git a/app/keywarden/settings/base.py b/app/keywarden/settings/base.py index c6faed0..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", @@ -86,6 +89,14 @@ CACHES = { SESSION_ENGINE = "django.contrib.sessions.backends.cache" SESSION_CACHE_ALIAS = "default" +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.Argon2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", + "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", + "django.contrib.auth.hashers.ScryptPasswordHasher", +] + STATIC_URL = "/static/" STATIC_ROOT = BASE_DIR/"static" STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" @@ -144,25 +155,6 @@ UNFOLD = { "SCRIPTS": [ "/static/unfold/js/simplebar.js", ], - "SITE_DROPDOWN": [ - { - "icon": "diamond", - "title": _("Keywarden Development"), - "link": "https://keywarden.dev.ntbx.io", - "attrs": { - "target": "_blank", - }, - }, - { - "icon": "diamond", - "title": _("Keywarden [Inactive]"), - "link": "https://keywarden.ntbx.io", - "attrs": { - "target": "_blank", - }, - }, - - ], "TABS": [ { "models": [ diff --git a/app/keywarden/urls.py b/app/keywarden/urls.py index 5a34adb..1fcc001 100644 --- a/app/keywarden/urls.py +++ b/app/keywarden/urls.py @@ -2,7 +2,7 @@ from django.contrib import admin from django.urls import path, include from django.views.generic import RedirectView from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView -from keywarden.api import api as ninja_api +from keywarden.api import api as ninja_api, api_v1 as ninja_api_v1 urlpatterns = [ path("admin/", admin.site.urls), @@ -10,7 +10,8 @@ urlpatterns = [ path("accounts/", include("apps.accounts.urls")), # API path("api/", ninja_api.urls), + path("api/v1/", ninja_api_v1.urls), path("api/auth/jwt/create/", TokenObtainPairView.as_view(), name="jwt-create"), path("api/auth/jwt/refresh/", TokenRefreshView.as_view(), name="jwt-refresh"), path("", RedirectView.as_view(pattern_name="accounts:login", permanent=False)), -] \ No newline at end of file +] diff --git a/requirements.txt b/requirements.txt index 7774f61..b628adf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ Pillow>=10.0.0 mozilla-django-oidc>=4.0.0 django-unfold>=0.70.0 django-tailwind==4.4.0 +argon2-cffi>=23.1.0 psycopg2-binary>=2.9.9 gunicorn==23.0.0 paramiko==4.0.0 @@ -15,4 +16,5 @@ celery>=5.5.0 python-dotenv>=1.2 whitenoise>=6.6 cookiecutter>=2.6 -distlib>=0.3.8 \ No newline at end of file +distlib>=0.3.8 +email-validator>=2.1.0