From a3036f74fcf5cf65591c732b8ef20bd4e1b961a0 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 19 Jan 2026 17:43:32 +0000 Subject: [PATCH] Added CRUD endpoints for each application. --- .../0005_unique_user_email_index.py | 20 ++ app/keywarden/api/__init__.py | 3 +- app/keywarden/api/main.py | 34 ++- app/keywarden/api/routers/accounts.py | 33 +-- app/keywarden/api/routers/audit.py | 95 ++++---- app/keywarden/api/routers/servers.py | 216 ++++++++++++------ app/keywarden/api/routers/system.py | 15 +- app/keywarden/api/routers/users.py | 164 +++++++++++++ app/keywarden/settings/base.py | 27 +-- app/keywarden/urls.py | 5 +- requirements.txt | 4 +- 11 files changed, 451 insertions(+), 165 deletions(-) create mode 100644 app/apps/accounts/migrations/0005_unique_user_email_index.py create mode 100644 app/keywarden/api/routers/users.py diff --git a/app/apps/accounts/migrations/0005_unique_user_email_index.py b/app/apps/accounts/migrations/0005_unique_user_email_index.py new file mode 100644 index 0000000..993acaa --- /dev/null +++ b/app/apps/accounts/migrations/0005_unique_user_email_index.py @@ -0,0 +1,20 @@ +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0004_delete_account"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RunSQL( + sql=( + "CREATE UNIQUE INDEX IF NOT EXISTS auth_user_email_uniq " + "ON auth_user (email);" + ), + reverse_sql="DROP INDEX IF EXISTS auth_user_email_uniq;", + ), + ] diff --git a/app/keywarden/api/__init__.py b/app/keywarden/api/__init__.py index ba85869..baeb938 100644 --- a/app/keywarden/api/__init__.py +++ b/app/keywarden/api/__init__.py @@ -1,3 +1,2 @@ -from .main import api - +from .main import api, api_v1 diff --git a/app/keywarden/api/main.py b/app/keywarden/api/main.py index d909453..f4d00da 100644 --- a/app/keywarden/api/main.py +++ b/app/keywarden/api/main.py @@ -4,10 +4,19 @@ 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 + + +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"]) api = NinjaAPI( @@ -17,11 +26,14 @@ api = NinjaAPI( auth=[django_auth, JWTAuth()], csrf=True, # enforce CSRF for session-authenticated unsafe requests ) +register_routers(api) -# Mount routers -api.add_router("/system", system_router, tags=["system"]) -api.add_router("/user", accounts_router, tags=["user"]) -api.add_router("/audit", audit_router, tags=["audit"]) -api.add_router("/servers", servers_router, tags=["servers"]) - - +api_v1 = NinjaAPI( + title="Keywarden API", + version="1.0.0", + description="Authenticated API for internal app use and external clients.", + auth=[django_auth, JWTAuth()], + csrf=True, + urls_namespace="api-v1", +) +register_routers(api_v1) diff --git a/app/keywarden/api/routers/accounts.py b/app/keywarden/api/routers/accounts.py index c4ccd4b..cd13099 100644 --- a/app/keywarden/api/routers/accounts.py +++ b/app/keywarden/api/routers/accounts.py @@ -3,8 +3,6 @@ from typing import Optional from django.http import HttpRequest from ninja import Router, Schema -router = Router() - class UserSchema(Schema): id: int @@ -16,17 +14,24 @@ class UserSchema(Schema): is_superuser: bool -@router.get("/me", response=UserSchema) -def me(request: HttpRequest): - user = request.user - return { - "id": user.id, - "username": user.username, - "email": user.email or "", - "first_name": user.first_name or "", - "last_name": user.last_name or "", - "is_staff": bool(user.is_staff), - "is_superuser": bool(user.is_superuser), - } +def build_router() -> Router: + router = Router() + + @router.get("/me", response=UserSchema) + def me(request: HttpRequest): + user = request.user + return { + "id": user.id, + "username": user.username, + "email": user.email or "", + "first_name": user.first_name or "", + "last_name": user.last_name or "", + "is_staff": bool(user.is_staff), + "is_superuser": bool(user.is_superuser), + } + + return router +router = build_router() + diff --git a/app/keywarden/api/routers/audit.py b/app/keywarden/api/routers/audit.py index bd98c15..e76986b 100644 --- a/app/keywarden/api/routers/audit.py +++ b/app/keywarden/api/routers/audit.py @@ -9,9 +9,6 @@ from ninja import Query, Router, Schema from apps.audit.models import AuditEventType, AuditLog -router = Router() - - class AuditEventTypeSchema(Schema): id: int key: str @@ -44,49 +41,55 @@ 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 - ] +def build_router() -> Router: + router = Router() + + @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 + ] + + return router -@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 - ] - +router = build_router() diff --git a/app/keywarden/api/routers/servers.py b/app/keywarden/api/routers/servers.py index 94b94d5..77e3773 100644 --- a/app/keywarden/api/routers/servers.py +++ b/app/keywarden/api/routers/servers.py @@ -2,13 +2,13 @@ from __future__ import annotations from typing import List, Optional +from django.db import IntegrityError from django.http import HttpRequest -from ninja import Router, Schema, File, Form +from ninja import File, Form, Router, Schema from ninja.files import UploadedFile +from ninja.errors import HttpError from apps.servers.models import Server -router = Router() - class ServerOut(Schema): id: int @@ -27,68 +27,154 @@ 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): + 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): + 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): + _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), + ): + _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): + _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): + _require_admin(request) + try: + server = Server.objects.get(id=server_id) + except Server.DoesNotExist: + raise HttpError(404, "Not Found") + server.delete() + return 204, None + + return router +router = build_router() diff --git a/app/keywarden/api/routers/system.py b/app/keywarden/api/routers/system.py index f2b5e94..b7a045c 100644 --- a/app/keywarden/api/routers/system.py +++ b/app/keywarden/api/routers/system.py @@ -2,15 +2,20 @@ from typing import Literal, TypedDict from ninja import Router -router = Router() - class HealthResponse(TypedDict): status: Literal["ok"] -@router.get("/health", response=HealthResponse) -def health() -> HealthResponse: - return {"status": "ok"} +def build_router() -> Router: + router = Router() + + @router.get("/health", response=HealthResponse) + def health() -> HealthResponse: + return {"status": "ok"} + + return router +router = build_router() + diff --git a/app/keywarden/api/routers/users.py b/app/keywarden/api/routers/users.py new file mode 100644 index 0000000..d669a6e --- /dev/null +++ b/app/keywarden/api/routers/users.py @@ -0,0 +1,164 @@ +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): + _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(...)): + _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): + _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): + _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): + _require_admin(request) + User = get_user_model() + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + raise HttpError(404, "Not Found") + user.delete() + return 204, None + + return router + + +router = build_router() diff --git a/app/keywarden/settings/base.py b/app/keywarden/settings/base.py index c6faed0..d8b1f43 100644 --- a/app/keywarden/settings/base.py +++ b/app/keywarden/settings/base.py @@ -86,6 +86,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 +152,6 @@ UNFOLD = { "SCRIPTS": [ "/static/unfold/js/simplebar.js", ], - "SITE_DROPDOWN": [ - { - "icon": "diamond", - "title": _("Keywarden Development"), - "link": "https://keywarden.dev.ntbx.io", - "attrs": { - "target": "_blank", - }, - }, - { - "icon": "diamond", - "title": _("Keywarden [Inactive]"), - "link": "https://keywarden.ntbx.io", - "attrs": { - "target": "_blank", - }, - }, - - ], "TABS": [ { "models": [ diff --git a/app/keywarden/urls.py b/app/keywarden/urls.py index 5a34adb..1fcc001 100644 --- a/app/keywarden/urls.py +++ b/app/keywarden/urls.py @@ -2,7 +2,7 @@ from django.contrib import admin from django.urls import path, include from django.views.generic import RedirectView from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView -from keywarden.api import api as ninja_api +from keywarden.api import api as ninja_api, api_v1 as ninja_api_v1 urlpatterns = [ path("admin/", admin.site.urls), @@ -10,7 +10,8 @@ urlpatterns = [ path("accounts/", include("apps.accounts.urls")), # API path("api/", ninja_api.urls), + path("api/v1/", ninja_api_v1.urls), path("api/auth/jwt/create/", TokenObtainPairView.as_view(), name="jwt-create"), path("api/auth/jwt/refresh/", TokenRefreshView.as_view(), name="jwt-refresh"), path("", RedirectView.as_view(pattern_name="accounts:login", permanent=False)), -] \ No newline at end of file +] diff --git a/requirements.txt b/requirements.txt index 7774f61..b628adf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ Pillow>=10.0.0 mozilla-django-oidc>=4.0.0 django-unfold>=0.70.0 django-tailwind==4.4.0 +argon2-cffi>=23.1.0 psycopg2-binary>=2.9.9 gunicorn==23.0.0 paramiko==4.0.0 @@ -15,4 +16,5 @@ celery>=5.5.0 python-dotenv>=1.2 whitenoise>=6.6 cookiecutter>=2.6 -distlib>=0.3.8 \ No newline at end of file +distlib>=0.3.8 +email-validator>=2.1.0