Merge new endpoints with development prototype #6
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})"
|
||||||
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'})"
|
||||||
@@ -9,6 +9,10 @@ from .routers.audit import build_router as build_audit_router
|
|||||||
from .routers.system import build_router as build_system_router
|
from .routers.system import build_router as build_system_router
|
||||||
from .routers.servers import build_router as build_servers_router
|
from .routers.servers import build_router as build_servers_router
|
||||||
from .routers.users import build_router as build_users_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:
|
def register_routers(target_api: NinjaAPI) -> None:
|
||||||
@@ -17,6 +21,10 @@ def register_routers(target_api: NinjaAPI) -> None:
|
|||||||
target_api.add_router("/audit", build_audit_router(), tags=["audit"])
|
target_api.add_router("/audit", build_audit_router(), tags=["audit"])
|
||||||
target_api.add_router("/servers", build_servers_router(), tags=["servers"])
|
target_api.add_router("/servers", build_servers_router(), tags=["servers"])
|
||||||
target_api.add_router("/users", build_users_router(), tags=["users"])
|
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(
|
api = NinjaAPI(
|
||||||
|
|||||||
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()
|
||||||
@@ -19,6 +19,7 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.get("/me", response=UserSchema)
|
@router.get("/me", response=UserSchema)
|
||||||
def me(request: HttpRequest):
|
def me(request: HttpRequest):
|
||||||
|
"""Return the current authenticated user's profile."""
|
||||||
user = request.user
|
user = request.user
|
||||||
return {
|
return {
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
@@ -34,4 +35,3 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
|
|
||||||
router = build_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()
|
||||||
@@ -46,6 +46,7 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.get("/event-types", response=List[AuditEventTypeSchema])
|
@router.get("/event-types", response=List[AuditEventTypeSchema])
|
||||||
def list_event_types(request: HttpRequest):
|
def list_event_types(request: HttpRequest):
|
||||||
|
"""List audit event types and their default severity."""
|
||||||
qs: QuerySet[AuditEventType] = AuditEventType.objects.all()
|
qs: QuerySet[AuditEventType] = AuditEventType.objects.all()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -60,6 +61,7 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.get("/logs", response=List[AuditLogSchema])
|
@router.get("/logs", response=List[AuditLogSchema])
|
||||||
def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)):
|
def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)):
|
||||||
|
"""List audit logs with optional filters and pagination."""
|
||||||
qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all()
|
qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all()
|
||||||
if filters.severity:
|
if filters.severity:
|
||||||
qs = qs.filter(severity=filters.severity)
|
qs = qs.filter(severity=filters.severity)
|
||||||
@@ -92,4 +94,3 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
|
|
||||||
router = build_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()
|
||||||
@@ -47,6 +47,7 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.get("/", response=List[ServerOut])
|
@router.get("/", response=List[ServerOut])
|
||||||
def list_servers(request: HttpRequest):
|
def list_servers(request: HttpRequest):
|
||||||
|
"""List servers visible to authenticated users."""
|
||||||
servers = Server.objects.all()
|
servers = Server.objects.all()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -63,6 +64,7 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.get("/{server_id}", response=ServerOut)
|
@router.get("/{server_id}", response=ServerOut)
|
||||||
def get_server(request: HttpRequest, server_id: int):
|
def get_server(request: HttpRequest, server_id: int):
|
||||||
|
"""Get server details by id."""
|
||||||
try:
|
try:
|
||||||
server = Server.objects.get(id=server_id)
|
server = Server.objects.get(id=server_id)
|
||||||
except Server.DoesNotExist:
|
except Server.DoesNotExist:
|
||||||
@@ -79,6 +81,7 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.post("/", response=ServerOut)
|
@router.post("/", response=ServerOut)
|
||||||
def create_server_json(request: HttpRequest, payload: ServerCreate):
|
def create_server_json(request: HttpRequest, payload: ServerCreate):
|
||||||
|
"""Create a server using JSON payload (admin only)."""
|
||||||
_require_admin(request)
|
_require_admin(request)
|
||||||
server = Server.objects.create(
|
server = Server.objects.create(
|
||||||
display_name=payload.display_name.strip(),
|
display_name=payload.display_name.strip(),
|
||||||
@@ -105,6 +108,7 @@ def build_router() -> Router:
|
|||||||
ipv6: Optional[str] = Form(None),
|
ipv6: Optional[str] = Form(None),
|
||||||
image: Optional[UploadedFile] = File(None),
|
image: Optional[UploadedFile] = File(None),
|
||||||
):
|
):
|
||||||
|
"""Create a server with optional image upload (admin only)."""
|
||||||
_require_admin(request)
|
_require_admin(request)
|
||||||
server = Server(
|
server = Server(
|
||||||
display_name=display_name.strip(),
|
display_name=display_name.strip(),
|
||||||
@@ -127,6 +131,7 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.patch("/{server_id}", response=ServerOut)
|
@router.patch("/{server_id}", response=ServerOut)
|
||||||
def update_server(request: HttpRequest, server_id: int, payload: ServerUpdate):
|
def update_server(request: HttpRequest, server_id: int, payload: ServerUpdate):
|
||||||
|
"""Update server fields (admin only)."""
|
||||||
_require_admin(request)
|
_require_admin(request)
|
||||||
if (
|
if (
|
||||||
payload.display_name is None
|
payload.display_name is None
|
||||||
@@ -166,6 +171,7 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.delete("/{server_id}", response={204: None})
|
@router.delete("/{server_id}", response={204: None})
|
||||||
def delete_server(request: HttpRequest, server_id: int):
|
def delete_server(request: HttpRequest, server_id: int):
|
||||||
|
"""Delete a server by id (admin only)."""
|
||||||
_require_admin(request)
|
_require_admin(request)
|
||||||
try:
|
try:
|
||||||
server = Server.objects.get(id=server_id)
|
server = Server.objects.get(id=server_id)
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.get("/health", response=HealthResponse)
|
@router.get("/health", response=HealthResponse)
|
||||||
def health() -> HealthResponse:
|
def health() -> HealthResponse:
|
||||||
|
"""Health check endpoint for service monitoring."""
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
router = build_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()
|
||||||
@@ -68,6 +68,7 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.post("/", response=UserDetailOut)
|
@router.post("/", response=UserDetailOut)
|
||||||
def create_user(request: HttpRequest, payload: UserCreateIn):
|
def create_user(request: HttpRequest, payload: UserCreateIn):
|
||||||
|
"""Create a user with role and password (admin only)."""
|
||||||
_require_admin(request)
|
_require_admin(request)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
email = payload.email.strip().lower()
|
email = payload.email.strip().lower()
|
||||||
@@ -89,6 +90,7 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.get("/", response=List[UserListOut])
|
@router.get("/", response=List[UserListOut])
|
||||||
def list_users(request: HttpRequest, pagination: UsersQuery = Query(...)):
|
def list_users(request: HttpRequest, pagination: UsersQuery = Query(...)):
|
||||||
|
"""List users with pagination (admin only)."""
|
||||||
_require_admin(request)
|
_require_admin(request)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
qs = User.objects.order_by("id")[pagination.offset : pagination.offset + pagination.limit]
|
qs = User.objects.order_by("id")[pagination.offset : pagination.offset + pagination.limit]
|
||||||
@@ -104,6 +106,7 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.get("/{user_id}", response=UserDetailOut)
|
@router.get("/{user_id}", response=UserDetailOut)
|
||||||
def get_user(request: HttpRequest, user_id: int):
|
def get_user(request: HttpRequest, user_id: int):
|
||||||
|
"""Get user details by id (admin only)."""
|
||||||
_require_admin(request)
|
_require_admin(request)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
try:
|
try:
|
||||||
@@ -119,6 +122,7 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.patch("/{user_id}", response=UserDetailOut)
|
@router.patch("/{user_id}", response=UserDetailOut)
|
||||||
def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn):
|
def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn):
|
||||||
|
"""Update user fields such as role, email, or status (admin only)."""
|
||||||
_require_admin(request)
|
_require_admin(request)
|
||||||
if payload.email is None and payload.password is None and payload.role is None and payload.is_active is None:
|
if payload.email is None and payload.password is None and payload.role is None and payload.is_active is None:
|
||||||
raise HttpError(422, {"detail": "No fields provided."})
|
raise HttpError(422, {"detail": "No fields provided."})
|
||||||
@@ -149,6 +153,7 @@ def build_router() -> Router:
|
|||||||
|
|
||||||
@router.delete("/{user_id}", response={204: None})
|
@router.delete("/{user_id}", response={204: None})
|
||||||
def delete_user(request: HttpRequest, user_id: int):
|
def delete_user(request: HttpRequest, user_id: int):
|
||||||
|
"""Delete a user by id (admin only)."""
|
||||||
_require_admin(request)
|
_require_admin(request)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ INSTALLED_APPS = [
|
|||||||
"apps.core",
|
"apps.core",
|
||||||
"apps.dashboard",
|
"apps.dashboard",
|
||||||
"apps.servers",
|
"apps.servers",
|
||||||
|
"apps.keys",
|
||||||
|
"apps.access",
|
||||||
|
"apps.telemetry",
|
||||||
"ninja", # Django Ninja API
|
"ninja", # Django Ninja API
|
||||||
"mozilla_django_oidc", # OIDC Client
|
"mozilla_django_oidc", # OIDC Client
|
||||||
"tailwind",
|
"tailwind",
|
||||||
|
|||||||
Reference in New Issue
Block a user