Attempting to resolve unfold form inconsistencies.

This commit is contained in:
2026-01-26 00:21:49 +00:00
parent b95084ddc3
commit 1d0c075d68
14 changed files with 276 additions and 27 deletions

View File

@@ -1,11 +1,22 @@
from django.contrib import admin 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 from .models import AccessRequest
@admin.register(AccessRequest) @admin.register(AccessRequest)
class AccessRequestAdmin(GuardedModelAdmin): class AccessRequestAdmin(GuardedModelAdmin):
autocomplete_fields = ("requester", "server", "decided_by")
list_display = ( list_display = (
"id", "id",
"requester", "requester",
@@ -14,7 +25,75 @@ class AccessRequestAdmin(GuardedModelAdmin):
"requested_at", "requested_at",
"expires_at", "expires_at",
"decided_by", "decided_by",
"delete_link",
) )
list_filter = ("status", "server") list_filter = ("status", "server")
search_fields = ("requester__username", "requester__email", "server__display_name") search_fields = ("requester__username", "requester__email", "server__display_name")
ordering = ("-requested_at",) 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('<a class="text-red-600" href="{}">Delete</a>', url)
delete_link.short_description = "Delete"

View File

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

View File

@@ -6,11 +6,13 @@ from guardian.shortcuts import assign_perm
from apps.core.rbac import assign_default_object_permissions from apps.core.rbac import assign_default_object_permissions
from .models import AccessRequest from .models import AccessRequest
from .permissions import sync_server_view_perm
@receiver(post_save, sender=AccessRequest) @receiver(post_save, sender=AccessRequest)
def assign_access_request_perms(sender, instance: AccessRequest, created: bool, **kwargs) -> None: def assign_access_request_perms(sender, instance: AccessRequest, created: bool, **kwargs) -> None:
if not created: if not created:
sync_server_view_perm(instance)
return return
if instance.requester_id: if instance.requester_id:
user = instance.requester user = instance.requester
@@ -21,3 +23,4 @@ def assign_access_request_perms(sender, instance: AccessRequest, created: bool,
): ):
assign_perm(perm, user, instance) assign_perm(perm, user, instance)
assign_default_object_permissions(instance) assign_default_object_permissions(instance)
sync_server_view_perm(instance)

27
app/apps/access/tasks.py Normal file
View File

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

View File

@@ -1,5 +1,12 @@
from django.contrib import admin 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 from .models import SSHKey

View File

@@ -1,6 +1,13 @@
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html 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 from .models import AgentCertificateAuthority, EnrollmentToken, Server

View File

@@ -5,13 +5,25 @@ from django.db.models import Q
from django.http import Http404 from django.http import Http404
from django.shortcuts import render from django.shortcuts import render
from django.utils import timezone from django.utils import timezone
from guardian.shortcuts import get_objects_for_user
from apps.access.models import AccessRequest from apps.access.models import AccessRequest
from apps.servers.models import Server
@login_required(login_url="/accounts/login/") @login_required(login_url="/accounts/login/")
def dashboard(request): def dashboard(request):
now = timezone.now() 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 = ( access_qs = (
AccessRequest.objects.select_related("server") AccessRequest.objects.select_related("server")
.filter( .filter(
@@ -19,22 +31,24 @@ def dashboard(request):
status=AccessRequest.Status.APPROVED, status=AccessRequest.Status.APPROVED,
) )
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now)) .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
.order_by("-requested_at")
) )
expires_map = {}
seen = set()
servers = []
for access in access_qs: for access in access_qs:
if access.server_id in seen: expires_at = access.expires_at
continue current = expires_map.get(access.server_id)
seen.add(access.server_id) if current is None or expires_at is None:
servers.append( expires_map[access.server_id] = None
{ elif current and expires_at and expires_at > current:
"server": access.server, expires_map[access.server_id] = expires_at
"expires_at": access.expires_at,
"last_accessed": None, servers = [
} {
) "server": server,
"expires_at": expires_map.get(server.id),
"last_accessed": None,
}
for server in server_qs
]
context = { context = {
"servers": servers, "servers": servers,
@@ -45,9 +59,17 @@ def dashboard(request):
@login_required(login_url="/accounts/login/") @login_required(login_url="/accounts/login/")
def detail(request, server_id: int): def detail(request, server_id: int):
now = timezone.now() 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 = ( access = (
AccessRequest.objects.select_related("server") AccessRequest.objects.filter(
.filter(
requester=request.user, requester=request.user,
server_id=server_id, server_id=server_id,
status=AccessRequest.Status.APPROVED, status=AccessRequest.Status.APPROVED,
@@ -56,12 +78,10 @@ def detail(request, server_id: int):
.order_by("-requested_at") .order_by("-requested_at")
.first() .first()
) )
if not access:
raise Http404("Server not found")
context = { context = {
"server": access.server, "server": server,
"expires_at": access.expires_at, "expires_at": access.expires_at if access else None,
"last_accessed": None, "last_accessed": None,
} }
return render(request, "servers/detail.html", context) return render(request, "servers/detail.html", context)

View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ("celery_app",)

View File

@@ -13,6 +13,7 @@ from pydantic import Field
from apps.access.models import AccessRequest from apps.access.models import AccessRequest
from apps.core.rbac import require_authenticated from apps.core.rbac import require_authenticated
from apps.servers.models import Server from apps.servers.models import Server
from apps.access.permissions import sync_server_view_perm
class AccessRequestCreateIn(Schema): class AccessRequestCreateIn(Schema):
@@ -191,6 +192,7 @@ def build_router() -> Router:
else: else:
access_request.decided_by = None access_request.decided_by = None
access_request.save() access_request.save()
sync_server_view_perm(access_request)
return _request_to_out(access_request) return _request_to_out(access_request)
@router.delete("/{request_id}", response={204: None}) @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): if not request.user.has_perm("access.delete_accessrequest", access_request):
raise HttpError(403, "Forbidden") raise HttpError(403, "Forbidden")
access_request.delete() access_request.delete()
sync_server_view_perm(access_request)
return 204, None return 204, None
return router return router

View File

@@ -7,6 +7,7 @@ from django.http import HttpRequest
from ninja import File, Form, Router, Schema from ninja import File, Form, Router, Schema
from ninja.files import UploadedFile from ninja.files import UploadedFile
from ninja.errors import HttpError from ninja.errors import HttpError
from guardian.shortcuts import get_objects_for_user
from apps.core.rbac import require_perms from apps.core.rbac import require_perms
from apps.servers.models import Server from apps.servers.models import Server
@@ -42,7 +43,15 @@ def build_router() -> Router:
def list_servers(request: HttpRequest): def list_servers(request: HttpRequest):
"""List servers visible to authenticated users.""" """List servers visible to authenticated users."""
require_perms(request, "servers.view_server") 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 [ return [
{ {
"id": s.id, "id": s.id,
@@ -64,6 +73,10 @@ def build_router() -> Router:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
raise HttpError(404, "Not Found") 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 { return {
"id": server.id, "id": server.id,
"display_name": server.display_name, "display_name": server.display_name,

9
app/keywarden/celery.py Normal file
View File

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

View File

@@ -96,6 +96,19 @@ SESSION_CACHE_ALIAS = "default"
KEYWARDEN_AGENT_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_AGENT_CERT_VALIDITY_DAYS", "90")) 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 = [ PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher", "django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher", "django.contrib.auth.hashers.PBKDF2PasswordHasher",
@@ -159,9 +172,6 @@ UNFOLD = {
"/static/unfold/css/simplebar.css", "/static/unfold/css/simplebar.css",
(lambda request: "/static/unfold/css/keywarden.css"), (lambda request: "/static/unfold/css/keywarden.css"),
], ],
"SCRIPTS": [
"/static/unfold/js/simplebar.js",
],
"TABS": [ "TABS": [
{ {
"models": [ "models": [

View File

@@ -83,4 +83,17 @@ html:not(.dark) .kw-pill {
.kw-warn { color: #fdba74; } /* orange-300 */ .kw-warn { color: #fdba74; } /* orange-300 */
.kw-bad { color: #fca5a5; } /* red-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;
}

View File

@@ -27,6 +27,7 @@ stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
stopsignal=QUIT stopsignal=QUIT
# REMOVE FOR PROD, should be using seperate worker instances for scalability.
[program:valkey] [program:valkey]
command=/usr/bin/valkey-server --bind 127.0.0.1 --port 6379 --save "" --appendonly no command=/usr/bin/valkey-server --bind 127.0.0.1 --port 6379 --save "" --appendonly no
autostart=true autostart=true
@@ -36,3 +37,31 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
stopsignal=TERM 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