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:
2026-01-19 18:40:21 +00:00
34 changed files with 1520 additions and 166 deletions

15
API_DOCS.md Normal file
View 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.

View File

19
app/apps/access/admin.py Normal file
View 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
View 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"

View 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"),
],
},
),
]

View File

57
app/apps/access/models.py Normal file
View 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})"

View 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;",
),
]

View File

11
app/apps/keys/admin.py Normal file
View 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
View 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"

View 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",
)
],
},
),
]

View File

66
app/apps/keys/models.py Normal file
View 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})"

View File

View 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",)

View 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"

View 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"),
],
},
),
]

View 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'})"

View File

@@ -1,3 +1,2 @@
from .main import api
from .main import api, api_v1

View File

@@ -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)

View 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()

View File

@@ -3,8 +3,6 @@ from typing import Optional
from django.http import HttpRequest
from ninja import Router, Schema
router = Router()
class UserSchema(Schema):
id: int
@@ -16,17 +14,24 @@ class UserSchema(Schema):
is_superuser: bool
@router.get("/me", response=UserSchema)
def me(request: HttpRequest):
user = request.user
return {
"id": user.id,
"username": user.username,
"email": user.email or "",
"first_name": user.first_name or "",
"last_name": user.last_name or "",
"is_staff": bool(user.is_staff),
"is_superuser": bool(user.is_superuser),
}
def build_router() -> Router:
router = Router()
@router.get("/me", response=UserSchema)
def me(request: HttpRequest):
"""Return the current authenticated user's profile."""
user = request.user
return {
"id": user.id,
"username": user.username,
"email": user.email or "",
"first_name": user.first_name or "",
"last_name": user.last_name or "",
"is_staff": bool(user.is_staff),
"is_superuser": bool(user.is_superuser),
}
return router
router = build_router()

View 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()

View File

@@ -9,9 +9,6 @@ from ninja import Query, Router, Schema
from apps.audit.models import AuditEventType, AuditLog
router = Router()
class AuditEventTypeSchema(Schema):
id: int
key: str
@@ -44,49 +41,56 @@ class LogsQuery(Schema):
source: Optional[str] = None
@router.get("/event-types", response=List[AuditEventTypeSchema])
def list_event_types(request: HttpRequest):
qs: QuerySet[AuditEventType] = AuditEventType.objects.all()
return [
{
"id": et.id,
"key": et.key,
"title": et.title,
"description": et.description or "",
"default_severity": et.default_severity,
}
for et in qs
]
@router.get("/logs", response=List[AuditLogSchema])
def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)):
qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all()
if filters.severity:
qs = qs.filter(severity=filters.severity)
if filters.actor_id:
qs = qs.filter(actor_id=filters.actor_id)
if filters.event_type_key:
qs = qs.filter(event_type__key=filters.event_type_key)
if filters.source:
qs = qs.filter(source=filters.source)
qs = qs.order_by("-created_at")[filters.offset : filters.offset + filters.limit]
return [
{
"id": al.id,
"created_at": al.created_at.isoformat(),
"actor_id": al.actor_id,
"event_type_id": al.event_type_id,
"message": al.message,
"severity": al.severity,
"source": al.source,
"object_repr": al.object_repr or "",
"ip_address": al.ip_address or "",
"user_agent": al.user_agent or "",
"request_id": al.request_id or "",
"metadata": al.metadata or {},
}
for al in qs
]
def build_router() -> Router:
router = Router()
@router.get("/event-types", response=List[AuditEventTypeSchema])
def list_event_types(request: HttpRequest):
"""List audit event types and their default severity."""
qs: QuerySet[AuditEventType] = AuditEventType.objects.all()
return [
{
"id": et.id,
"key": et.key,
"title": et.title,
"description": et.description or "",
"default_severity": et.default_severity,
}
for et in qs
]
@router.get("/logs", response=List[AuditLogSchema])
def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)):
"""List audit logs with optional filters and pagination."""
qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all()
if filters.severity:
qs = qs.filter(severity=filters.severity)
if filters.actor_id:
qs = qs.filter(actor_id=filters.actor_id)
if filters.event_type_key:
qs = qs.filter(event_type__key=filters.event_type_key)
if filters.source:
qs = qs.filter(source=filters.source)
qs = qs.order_by("-created_at")[filters.offset : filters.offset + filters.limit]
return [
{
"id": al.id,
"created_at": al.created_at.isoformat(),
"actor_id": al.actor_id,
"event_type_id": al.event_type_id,
"message": al.message,
"severity": al.severity,
"source": al.source,
"object_repr": al.object_repr or "",
"ip_address": al.ip_address or "",
"user_agent": al.user_agent or "",
"request_id": al.request_id or "",
"metadata": al.metadata or {},
}
for al in qs
]
return router
router = build_router()

View 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()

View File

@@ -2,13 +2,13 @@ from __future__ import annotations
from typing import List, Optional
from django.db import IntegrityError
from django.http import HttpRequest
from ninja import Router, Schema, File, Form
from ninja import File, Form, Router, Schema
from ninja.files import UploadedFile
from ninja.errors import HttpError
from apps.servers.models import Server
router = Router()
class ServerOut(Schema):
id: int
@@ -27,68 +27,160 @@ class ServerCreate(Schema):
ipv6: Optional[str] = None
@router.get("/", response=List[ServerOut])
def list_servers(request: HttpRequest):
servers = Server.objects.all()
return [
{
"id": s.id,
"display_name": s.display_name,
"hostname": s.hostname,
"ipv4": s.ipv4,
"ipv6": s.ipv6,
"image_url": s.image_url,
"initial": s.initial,
class ServerUpdate(Schema):
display_name: Optional[str] = None
hostname: Optional[str] = None
ipv4: Optional[str] = None
ipv6: Optional[str] = None
def _require_admin(request: HttpRequest) -> None:
user = request.user
if not getattr(user, "is_authenticated", False):
raise HttpError(403, "Forbidden")
if not (user.is_staff or user.is_superuser):
raise HttpError(403, "Forbidden")
def build_router() -> Router:
router = Router()
@router.get("/", response=List[ServerOut])
def list_servers(request: HttpRequest):
"""List servers visible to authenticated users."""
servers = Server.objects.all()
return [
{
"id": s.id,
"display_name": s.display_name,
"hostname": s.hostname,
"ipv4": s.ipv4,
"ipv6": s.ipv6,
"image_url": s.image_url,
"initial": s.initial,
}
for s in servers
]
@router.get("/{server_id}", response=ServerOut)
def get_server(request: HttpRequest, server_id: int):
"""Get server details by id."""
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise HttpError(404, "Not Found")
return {
"id": server.id,
"display_name": server.display_name,
"hostname": server.hostname,
"ipv4": server.ipv4,
"ipv6": server.ipv6,
"image_url": server.image_url,
"initial": server.initial,
}
for s in servers
]
@router.post("/", response=ServerOut)
def create_server_json(request: HttpRequest, payload: ServerCreate):
server = Server.objects.create(
display_name=payload.display_name.strip(),
hostname=(payload.hostname or "").strip() or None,
ipv4=(payload.ipv4 or "").strip() or None,
ipv6=(payload.ipv6 or "").strip() or None,
)
return {
"id": server.id,
"display_name": server.display_name,
"hostname": server.hostname,
"ipv4": server.ipv4,
"ipv6": server.ipv6,
"image_url": server.image_url,
"initial": server.initial,
}
@router.post("/upload", response=ServerOut)
def create_server_multipart(
request: HttpRequest,
display_name: str = Form(...),
hostname: Optional[str] = Form(None),
ipv4: Optional[str] = Form(None),
ipv6: Optional[str] = Form(None),
image: Optional[UploadedFile] = File(None),
):
server = Server(
display_name=display_name.strip(),
hostname=(hostname or "").strip() or None,
ipv4=(ipv4 or "").strip() or None,
ipv6=(ipv6 or "").strip() or None,
)
if image:
server.image.save(image.name, image) # type: ignore[arg-type]
server.save()
return {
"id": server.id,
"display_name": server.display_name,
"hostname": server.hostname,
"ipv4": server.ipv4,
"ipv6": server.ipv6,
"image_url": server.image_url,
"initial": server.initial,
}
@router.post("/", response=ServerOut)
def create_server_json(request: HttpRequest, payload: ServerCreate):
"""Create a server using JSON payload (admin only)."""
_require_admin(request)
server = Server.objects.create(
display_name=payload.display_name.strip(),
hostname=(payload.hostname or "").strip() or None,
ipv4=(payload.ipv4 or "").strip() or None,
ipv6=(payload.ipv6 or "").strip() or None,
)
return {
"id": server.id,
"display_name": server.display_name,
"hostname": server.hostname,
"ipv4": server.ipv4,
"ipv6": server.ipv6,
"image_url": server.image_url,
"initial": server.initial,
}
@router.post("/upload", response=ServerOut)
def create_server_multipart(
request: HttpRequest,
display_name: str = Form(...),
hostname: Optional[str] = Form(None),
ipv4: Optional[str] = Form(None),
ipv6: Optional[str] = Form(None),
image: Optional[UploadedFile] = File(None),
):
"""Create a server with optional image upload (admin only)."""
_require_admin(request)
server = Server(
display_name=display_name.strip(),
hostname=(hostname or "").strip() or None,
ipv4=(ipv4 or "").strip() or None,
ipv6=(ipv6 or "").strip() or None,
)
if image:
server.image.save(image.name, image) # type: ignore[arg-type]
server.save()
return {
"id": server.id,
"display_name": server.display_name,
"hostname": server.hostname,
"ipv4": server.ipv4,
"ipv6": server.ipv6,
"image_url": server.image_url,
"initial": server.initial,
}
@router.patch("/{server_id}", response=ServerOut)
def update_server(request: HttpRequest, server_id: int, payload: ServerUpdate):
"""Update server fields (admin only)."""
_require_admin(request)
if (
payload.display_name is None
and payload.hostname is None
and payload.ipv4 is None
and payload.ipv6 is None
):
raise HttpError(422, {"detail": "No fields provided."})
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise HttpError(404, "Not Found")
if payload.display_name is not None:
display_name = payload.display_name.strip()
if not display_name:
raise HttpError(422, {"display_name": ["Display name cannot be empty."]})
server.display_name = display_name
if payload.hostname is not None:
server.hostname = (payload.hostname or "").strip() or None
if payload.ipv4 is not None:
server.ipv4 = (payload.ipv4 or "").strip() or None
if payload.ipv6 is not None:
server.ipv6 = (payload.ipv6 or "").strip() or None
try:
server.save()
except IntegrityError:
raise HttpError(422, {"detail": "Unique constraint violated."})
return {
"id": server.id,
"display_name": server.display_name,
"hostname": server.hostname,
"ipv4": server.ipv4,
"ipv6": server.ipv6,
"image_url": server.image_url,
"initial": server.initial,
}
@router.delete("/{server_id}", response={204: None})
def delete_server(request: HttpRequest, server_id: int):
"""Delete a server by id (admin only)."""
_require_admin(request)
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise HttpError(404, "Not Found")
server.delete()
return 204, None
return router
router = build_router()

View File

@@ -2,15 +2,20 @@ from typing import Literal, TypedDict
from ninja import Router
router = Router()
class HealthResponse(TypedDict):
status: Literal["ok"]
@router.get("/health", response=HealthResponse)
def health() -> HealthResponse:
return {"status": "ok"}
def build_router() -> Router:
router = Router()
@router.get("/health", response=HealthResponse)
def health() -> HealthResponse:
"""Health check endpoint for service monitoring."""
return {"status": "ok"}
return router
router = build_router()

View 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()

View 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()

View File

@@ -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": [

View File

@@ -2,7 +2,7 @@ from django.contrib import admin
from django.urls import path, include
from django.views.generic import RedirectView
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from keywarden.api import api as ninja_api
from keywarden.api import api as ninja_api, api_v1 as ninja_api_v1
urlpatterns = [
path("admin/", admin.site.urls),
@@ -10,7 +10,8 @@ urlpatterns = [
path("accounts/", include("apps.accounts.urls")),
# API
path("api/", ninja_api.urls),
path("api/v1/", ninja_api_v1.urls),
path("api/auth/jwt/create/", TokenObtainPairView.as_view(), name="jwt-create"),
path("api/auth/jwt/refresh/", TokenRefreshView.as_view(), name="jwt-refresh"),
path("", RedirectView.as_view(pattern_name="accounts:login", permanent=False)),
]
]

View File

@@ -7,6 +7,7 @@ Pillow>=10.0.0
mozilla-django-oidc>=4.0.0
django-unfold>=0.70.0
django-tailwind==4.4.0
argon2-cffi>=23.1.0
psycopg2-binary>=2.9.9
gunicorn==23.0.0
paramiko==4.0.0
@@ -15,4 +16,5 @@ celery>=5.5.0
python-dotenv>=1.2
whitenoise>=6.6
cookiecutter>=2.6
distlib>=0.3.8
distlib>=0.3.8
email-validator>=2.1.0