From 1d0c075d68a6a0acf6eb93c24562b59db61b9a17 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 26 Jan 2026 00:21:49 +0000 Subject: [PATCH] Attempting to resolve unfold form inconsistencies. --- app/apps/access/admin.py | 81 +++++++++++++++++++++++++++- app/apps/access/permissions.py | 26 +++++++++ app/apps/access/signals.py | 3 ++ app/apps/access/tasks.py | 27 ++++++++++ app/apps/keys/admin.py | 9 +++- app/apps/servers/admin.py | 9 +++- app/apps/servers/views.py | 60 ++++++++++++++------- app/keywarden/__init__.py | 3 ++ app/keywarden/api/routers/access.py | 3 ++ app/keywarden/api/routers/servers.py | 15 +++++- app/keywarden/celery.py | 9 ++++ app/keywarden/settings/base.py | 16 ++++-- app/static/unfold/css/keywarden.css | 13 +++++ supervisor/supervisord.conf | 29 ++++++++++ 14 files changed, 276 insertions(+), 27 deletions(-) create mode 100644 app/apps/access/permissions.py create mode 100644 app/apps/access/tasks.py create mode 100644 app/keywarden/celery.py diff --git a/app/apps/access/admin.py b/app/apps/access/admin.py index ad072ab..1747bd0 100644 --- a/app/apps/access/admin.py +++ b/app/apps/access/admin.py @@ -1,11 +1,22 @@ from django.contrib import admin -from guardian.admin import GuardedModelAdmin +from django.urls import reverse +from django.utils import timezone +from django.utils.html import format_html +try: + from unfold.contrib.guardian.admin import GuardedModelAdmin +except ImportError: # Fallback for older Unfold builds without guardian admin shim. + from guardian.admin import GuardedModelAdmin as GuardianGuardedModelAdmin + from unfold.admin import ModelAdmin as UnfoldModelAdmin + + class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin): + pass from .models import AccessRequest @admin.register(AccessRequest) class AccessRequestAdmin(GuardedModelAdmin): + autocomplete_fields = ("requester", "server", "decided_by") list_display = ( "id", "requester", @@ -14,7 +25,75 @@ class AccessRequestAdmin(GuardedModelAdmin): "requested_at", "expires_at", "decided_by", + "delete_link", ) list_filter = ("status", "server") search_fields = ("requester__username", "requester__email", "server__display_name") ordering = ("-requested_at",) + compressed_fields = True + actions_on_top = True + actions_on_bottom = True + def get_readonly_fields(self, request, obj=None): + readonly = ["requested_at"] + if obj: + readonly.extend(["decided_at", "decided_by"]) + return readonly + + def get_fieldsets(self, request, obj=None): + if obj is None: + return ( + ( + "Request", + { + "fields": ( + "requester", + "server", + "status", + "reason", + "expires_at", + ) + }, + ), + ) + return ( + ( + "Request", + { + "fields": ( + "requester", + "server", + "status", + "reason", + "expires_at", + ) + }, + ), + ( + "Decision", + { + "fields": ( + "decided_at", + "decided_by", + ) + }, + ), + ) + + def save_model(self, request, obj, form, change) -> None: + if obj.status in { + AccessRequest.Status.APPROVED, + AccessRequest.Status.DENIED, + AccessRequest.Status.REVOKED, + AccessRequest.Status.CANCELLED, + }: + if not obj.decided_at: + obj.decided_at = timezone.now() + if not obj.decided_by_id and request.user and request.user.is_authenticated: + obj.decided_by = request.user + super().save_model(request, obj, form, change) + + def delete_link(self, obj: AccessRequest): + url = reverse("admin:access_accessrequest_delete", args=[obj.pk]) + return format_html('Delete', url) + + delete_link.short_description = "Delete" diff --git a/app/apps/access/permissions.py b/app/apps/access/permissions.py new file mode 100644 index 0000000..1a7a6ef --- /dev/null +++ b/app/apps/access/permissions.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from django.db.models import Q +from django.utils import timezone +from guardian.shortcuts import assign_perm, remove_perm + +from .models import AccessRequest + + +def sync_server_view_perm(access_request: AccessRequest) -> None: + if not access_request or not access_request.requester_id or not access_request.server_id: + return + now = timezone.now() + has_valid_access = ( + AccessRequest.objects.filter( + requester_id=access_request.requester_id, + server_id=access_request.server_id, + status=AccessRequest.Status.APPROVED, + ) + .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now)) + .exists() + ) + if has_valid_access: + assign_perm("servers.view_server", access_request.requester, access_request.server) + return + remove_perm("servers.view_server", access_request.requester, access_request.server) diff --git a/app/apps/access/signals.py b/app/apps/access/signals.py index 679ad0a..531055b 100644 --- a/app/apps/access/signals.py +++ b/app/apps/access/signals.py @@ -6,11 +6,13 @@ from guardian.shortcuts import assign_perm from apps.core.rbac import assign_default_object_permissions from .models import AccessRequest +from .permissions import sync_server_view_perm @receiver(post_save, sender=AccessRequest) def assign_access_request_perms(sender, instance: AccessRequest, created: bool, **kwargs) -> None: if not created: + sync_server_view_perm(instance) return if instance.requester_id: user = instance.requester @@ -21,3 +23,4 @@ def assign_access_request_perms(sender, instance: AccessRequest, created: bool, ): assign_perm(perm, user, instance) assign_default_object_permissions(instance) + sync_server_view_perm(instance) diff --git a/app/apps/access/tasks.py b/app/apps/access/tasks.py new file mode 100644 index 0000000..e4ebe02 --- /dev/null +++ b/app/apps/access/tasks.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from celery import shared_task +from django.db import transaction +from django.utils import timezone +from .models import AccessRequest +from .permissions import sync_server_view_perm + + +@shared_task +def expire_access_requests() -> int: + now = timezone.now() + expired_qs = AccessRequest.objects.select_related("server", "requester").filter( + status=AccessRequest.Status.APPROVED, + expires_at__isnull=False, + expires_at__lte=now, + ) + count = 0 + for access_request in expired_qs: + with transaction.atomic(): + access_request.status = AccessRequest.Status.EXPIRED + access_request.decided_at = now + access_request.decided_by = None + access_request.save(update_fields=["status", "decided_at", "decided_by"]) + sync_server_view_perm(access_request) + count += 1 + return count diff --git a/app/apps/keys/admin.py b/app/apps/keys/admin.py index 64018b1..00bc406 100644 --- a/app/apps/keys/admin.py +++ b/app/apps/keys/admin.py @@ -1,5 +1,12 @@ from django.contrib import admin -from guardian.admin import GuardedModelAdmin +try: + from unfold.contrib.guardian.admin import GuardedModelAdmin +except ImportError: # Fallback for older Unfold builds without guardian admin shim. + from guardian.admin import GuardedModelAdmin as GuardianGuardedModelAdmin + from unfold.admin import ModelAdmin as UnfoldModelAdmin + + class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin): + pass from .models import SSHKey diff --git a/app/apps/servers/admin.py b/app/apps/servers/admin.py index 2fb9b51..ccf1c6a 100644 --- a/app/apps/servers/admin.py +++ b/app/apps/servers/admin.py @@ -1,6 +1,13 @@ from django.contrib import admin from django.utils.html import format_html -from guardian.admin import GuardedModelAdmin +try: + from unfold.contrib.guardian.admin import GuardedModelAdmin +except ImportError: # Fallback for older Unfold builds without guardian admin shim. + from guardian.admin import GuardedModelAdmin as GuardianGuardedModelAdmin + from unfold.admin import ModelAdmin as UnfoldModelAdmin + + class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin): + pass from .models import AgentCertificateAuthority, EnrollmentToken, Server diff --git a/app/apps/servers/views.py b/app/apps/servers/views.py index f532e61..a3be21d 100644 --- a/app/apps/servers/views.py +++ b/app/apps/servers/views.py @@ -5,13 +5,25 @@ from django.db.models import Q from django.http import Http404 from django.shortcuts import render from django.utils import timezone +from guardian.shortcuts import get_objects_for_user from apps.access.models import AccessRequest +from apps.servers.models import Server @login_required(login_url="/accounts/login/") def dashboard(request): now = timezone.now() + if request.user.has_perm("servers.view_server"): + server_qs = Server.objects.all() + else: + server_qs = get_objects_for_user( + request.user, + "servers.view_server", + klass=Server, + accept_global_perms=False, + ) + access_qs = ( AccessRequest.objects.select_related("server") .filter( @@ -19,22 +31,24 @@ def dashboard(request): status=AccessRequest.Status.APPROVED, ) .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now)) - .order_by("-requested_at") ) - - seen = set() - servers = [] + expires_map = {} for access in access_qs: - if access.server_id in seen: - continue - seen.add(access.server_id) - servers.append( - { - "server": access.server, - "expires_at": access.expires_at, - "last_accessed": None, - } - ) + expires_at = access.expires_at + current = expires_map.get(access.server_id) + if current is None or expires_at is None: + expires_map[access.server_id] = None + elif current and expires_at and expires_at > current: + expires_map[access.server_id] = expires_at + + servers = [ + { + "server": server, + "expires_at": expires_map.get(server.id), + "last_accessed": None, + } + for server in server_qs + ] context = { "servers": servers, @@ -45,9 +59,17 @@ def dashboard(request): @login_required(login_url="/accounts/login/") def detail(request, server_id: int): now = timezone.now() + try: + server = Server.objects.get(id=server_id) + except Server.DoesNotExist: + raise Http404("Server not found") + if not request.user.has_perm("servers.view_server", server) and not request.user.has_perm( + "servers.view_server" + ): + raise Http404("Server not found") + access = ( - AccessRequest.objects.select_related("server") - .filter( + AccessRequest.objects.filter( requester=request.user, server_id=server_id, status=AccessRequest.Status.APPROVED, @@ -56,12 +78,10 @@ def detail(request, server_id: int): .order_by("-requested_at") .first() ) - if not access: - raise Http404("Server not found") context = { - "server": access.server, - "expires_at": access.expires_at, + "server": server, + "expires_at": access.expires_at if access else None, "last_accessed": None, } return render(request, "servers/detail.html", context) diff --git a/app/keywarden/__init__.py b/app/keywarden/__init__.py index e69de29..53f4ccb 100644 --- a/app/keywarden/__init__.py +++ b/app/keywarden/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/app/keywarden/api/routers/access.py b/app/keywarden/api/routers/access.py index b06cdfe..688e7b7 100644 --- a/app/keywarden/api/routers/access.py +++ b/app/keywarden/api/routers/access.py @@ -13,6 +13,7 @@ from pydantic import Field from apps.access.models import AccessRequest from apps.core.rbac import require_authenticated from apps.servers.models import Server +from apps.access.permissions import sync_server_view_perm class AccessRequestCreateIn(Schema): @@ -191,6 +192,7 @@ def build_router() -> Router: else: access_request.decided_by = None access_request.save() + sync_server_view_perm(access_request) return _request_to_out(access_request) @router.delete("/{request_id}", response={204: None}) @@ -208,6 +210,7 @@ def build_router() -> Router: if not request.user.has_perm("access.delete_accessrequest", access_request): raise HttpError(403, "Forbidden") access_request.delete() + sync_server_view_perm(access_request) return 204, None return router diff --git a/app/keywarden/api/routers/servers.py b/app/keywarden/api/routers/servers.py index 47348d7..8d8aa25 100644 --- a/app/keywarden/api/routers/servers.py +++ b/app/keywarden/api/routers/servers.py @@ -7,6 +7,7 @@ from django.http import HttpRequest from ninja import File, Form, Router, Schema from ninja.files import UploadedFile from ninja.errors import HttpError +from guardian.shortcuts import get_objects_for_user from apps.core.rbac import require_perms from apps.servers.models import Server @@ -42,7 +43,15 @@ def build_router() -> Router: def list_servers(request: HttpRequest): """List servers visible to authenticated users.""" require_perms(request, "servers.view_server") - servers = Server.objects.all() + if request.user.has_perm("servers.view_server"): + servers = Server.objects.all() + else: + servers = get_objects_for_user( + request.user, + "servers.view_server", + klass=Server, + accept_global_perms=False, + ) return [ { "id": s.id, @@ -64,6 +73,10 @@ def build_router() -> Router: server = Server.objects.get(id=server_id) except Server.DoesNotExist: raise HttpError(404, "Not Found") + if not request.user.has_perm("servers.view_server", server) and not request.user.has_perm( + "servers.view_server" + ): + raise HttpError(403, "Forbidden") return { "id": server.id, "display_name": server.display_name, diff --git a/app/keywarden/celery.py b/app/keywarden/celery.py new file mode 100644 index 0000000..4658ecf --- /dev/null +++ b/app/keywarden/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "keywarden.settings.dev") + +app = Celery("keywarden") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/app/keywarden/settings/base.py b/app/keywarden/settings/base.py index 40f254c..9ffcae5 100644 --- a/app/keywarden/settings/base.py +++ b/app/keywarden/settings/base.py @@ -96,6 +96,19 @@ SESSION_CACHE_ALIAS = "default" KEYWARDEN_AGENT_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_AGENT_CERT_VALIDITY_DAYS", "90")) +CELERY_BROKER_URL = os.getenv("KEYWARDEN_CELERY_BROKER_URL", REDIS_URL) +CELERY_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL) +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_TIMEZONE = "UTC" +CELERY_BEAT_SCHEDULE = { + "expire-access-requests": { + "task": "apps.access.tasks.expire_access_requests", + "schedule": 60.0, + }, +} + PASSWORD_HASHERS = [ "django.contrib.auth.hashers.Argon2PasswordHasher", "django.contrib.auth.hashers.PBKDF2PasswordHasher", @@ -159,9 +172,6 @@ UNFOLD = { "/static/unfold/css/simplebar.css", (lambda request: "/static/unfold/css/keywarden.css"), ], - "SCRIPTS": [ - "/static/unfold/js/simplebar.js", - ], "TABS": [ { "models": [ diff --git a/app/static/unfold/css/keywarden.css b/app/static/unfold/css/keywarden.css index 3e1e18f..89c204b 100644 --- a/app/static/unfold/css/keywarden.css +++ b/app/static/unfold/css/keywarden.css @@ -83,4 +83,17 @@ html:not(.dark) .kw-pill { .kw-warn { color: #fdba74; } /* orange-300 */ .kw-bad { color: #fca5a5; } /* red-300 */ +/* Align related-object action icons with their fields in admin forms. */ +.related-widget-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: nowrap; +} + +.related-widget-wrapper select, +.related-widget-wrapper .select2-container { + flex: 1 1 auto; + min-width: 0; +} diff --git a/supervisor/supervisord.conf b/supervisor/supervisord.conf index df8fcbc..7a891b9 100644 --- a/supervisor/supervisord.conf +++ b/supervisor/supervisord.conf @@ -27,6 +27,7 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 stopsignal=QUIT +# REMOVE FOR PROD, should be using seperate worker instances for scalability. [program:valkey] command=/usr/bin/valkey-server --bind 127.0.0.1 --port 6379 --save "" --appendonly no autostart=true @@ -36,3 +37,31 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 stopsignal=TERM + +[program:celery-worker] +command=/usr/local/bin/celery -A keywarden worker -l info +directory=/app +user=djangouser +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +stopsignal=TERM +stopasgroup=true +killasgroup=true + +[program:celery-beat] +command=/usr/local/bin/celery -A keywarden beat -l info +directory=/app +user=djangouser +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +stopsignal=TERM +stopasgroup=true +killasgroup=true