Merge pull request 'Merge new endpoints with development prototype' (#6) from api-dev into dev
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
15
API_DOCS.md
Normal file
15
API_DOCS.md
Normal file
@@ -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 <access_token>`.
|
||||
|
||||
Notes:
|
||||
- Base URL for v1 endpoints is `/api/v1`.
|
||||
- Admin-only routes return `403 Forbidden` when the token user is not staff/superuser.
|
||||
0
app/apps/access/__init__.py
Normal file
0
app/apps/access/__init__.py
Normal file
19
app/apps/access/admin.py
Normal file
19
app/apps/access/admin.py
Normal file
@@ -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",)
|
||||
7
app/apps/access/apps.py
Normal file
7
app/apps/access/apps.py
Normal file
@@ -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"
|
||||
78
app/apps/access/migrations/0001_initial.py
Normal file
78
app/apps/access/migrations/0001_initial.py
Normal file
@@ -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"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
app/apps/access/migrations/__init__.py
Normal file
0
app/apps/access/migrations/__init__.py
Normal file
57
app/apps/access/models.py
Normal file
57
app/apps/access/models.py
Normal file
@@ -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})"
|
||||
20
app/apps/accounts/migrations/0005_unique_user_email_index.py
Normal file
20
app/apps/accounts/migrations/0005_unique_user_email_index.py
Normal file
@@ -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;",
|
||||
),
|
||||
]
|
||||
0
app/apps/keys/__init__.py
Normal file
0
app/apps/keys/__init__.py
Normal file
11
app/apps/keys/admin.py
Normal file
11
app/apps/keys/admin.py
Normal file
@@ -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",)
|
||||
7
app/apps/keys/apps.py
Normal file
7
app/apps/keys/apps.py
Normal file
@@ -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"
|
||||
53
app/apps/keys/migrations/0001_initial.py
Normal file
53
app/apps/keys/migrations/0001_initial.py
Normal file
@@ -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",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
app/apps/keys/migrations/__init__.py
Normal file
0
app/apps/keys/migrations/__init__.py
Normal file
66
app/apps/keys/models.py
Normal file
66
app/apps/keys/models.py
Normal file
@@ -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})"
|
||||
0
app/apps/telemetry/__init__.py
Normal file
0
app/apps/telemetry/__init__.py
Normal file
11
app/apps/telemetry/admin.py
Normal file
11
app/apps/telemetry/admin.py
Normal file
@@ -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",)
|
||||
7
app/apps/telemetry/apps.py
Normal file
7
app/apps/telemetry/apps.py
Normal file
@@ -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"
|
||||
73
app/apps/telemetry/migrations/0001_initial.py
Normal file
73
app/apps/telemetry/migrations/0001_initial.py
Normal file
@@ -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"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
app/apps/telemetry/migrations/__init__.py
Normal file
0
app/apps/telemetry/migrations/__init__.py
Normal file
48
app/apps/telemetry/models.py
Normal file
48
app/apps/telemetry/models.py
Normal file
@@ -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'})"
|
||||
@@ -1,3 +1,2 @@
|
||||
from .main import api
|
||||
|
||||
from .main import api, api_v1
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
183
app/keywarden/api/routers/access.py
Normal file
183
app/keywarden/api/routers/access.py
Normal file
@@ -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()
|
||||
@@ -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,8 +14,12 @@ class UserSchema(Schema):
|
||||
is_superuser: bool
|
||||
|
||||
|
||||
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,
|
||||
@@ -29,4 +31,7 @@ def me(request: HttpRequest):
|
||||
"is_superuser": bool(user.is_superuser),
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
104
app/keywarden/api/routers/agent.py
Normal file
104
app/keywarden/api/routers/agent.py
Normal file
@@ -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()
|
||||
@@ -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,8 +41,12 @@ class LogsQuery(Schema):
|
||||
source: Optional[str] = None
|
||||
|
||||
|
||||
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 [
|
||||
{
|
||||
@@ -58,9 +59,9 @@ def list_event_types(request: HttpRequest):
|
||||
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)
|
||||
@@ -89,4 +90,7 @@ def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)):
|
||||
for al in qs
|
||||
]
|
||||
|
||||
return router
|
||||
|
||||
|
||||
router = build_router()
|
||||
|
||||
168
app/keywarden/api/routers/keys.py
Normal file
168
app/keywarden/api/routers/keys.py
Normal file
@@ -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()
|
||||
@@ -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,8 +27,27 @@ class ServerCreate(Schema):
|
||||
ipv6: Optional[str] = None
|
||||
|
||||
|
||||
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 [
|
||||
{
|
||||
@@ -43,9 +62,27 @@ def list_servers(request: HttpRequest):
|
||||
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,
|
||||
}
|
||||
|
||||
@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,
|
||||
@@ -62,7 +99,6 @@ def create_server_json(request: HttpRequest, payload: ServerCreate):
|
||||
"initial": server.initial,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload", response=ServerOut)
|
||||
def create_server_multipart(
|
||||
request: HttpRequest,
|
||||
@@ -72,6 +108,8 @@ def create_server_multipart(
|
||||
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,
|
||||
@@ -91,4 +129,58 @@ def create_server_multipart(
|
||||
"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()
|
||||
|
||||
@@ -2,15 +2,20 @@ from typing import Literal, TypedDict
|
||||
|
||||
from ninja import Router
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class HealthResponse(TypedDict):
|
||||
status: Literal["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()
|
||||
|
||||
138
app/keywarden/api/routers/telemetry.py
Normal file
138
app/keywarden/api/routers/telemetry.py
Normal file
@@ -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()
|
||||
169
app/keywarden/api/routers/users.py
Normal file
169
app/keywarden/api/routers/users.py
Normal file
@@ -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()
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,6 +10,7 @@ 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)),
|
||||
|
||||
@@ -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
|
||||
@@ -16,3 +17,4 @@ python-dotenv>=1.2
|
||||
whitenoise>=6.6
|
||||
cookiecutter>=2.6
|
||||
distlib>=0.3.8
|
||||
email-validator>=2.1.0
|
||||
|
||||
Reference in New Issue
Block a user