Initial django guardian integrations

This commit is contained in:
2026-01-25 17:48:14 +00:00
parent 6901f6fcc4
commit 66ffa3d3fb
24 changed files with 332 additions and 80 deletions

View File

@@ -13,7 +13,7 @@ KEYWARDEN_POSTGRES_HOST=keywarden-db
KEYWARDEN_POSTGRES_PORT=5432 KEYWARDEN_POSTGRES_PORT=5432
# Admin bootstrap # Admin
KEYWARDEN_ADMIN_USERNAME=admin KEYWARDEN_ADMIN_USERNAME=admin
KEYWARDEN_ADMIN_EMAIL=admin@example.com KEYWARDEN_ADMIN_EMAIL=admin@example.com
KEYWARDEN_ADMIN_PASSWORD=password KEYWARDEN_ADMIN_PASSWORD=password

View File

@@ -1,10 +1,11 @@
from django.contrib import admin from django.contrib import admin
from guardian.admin import GuardedModelAdmin
from .models import AccessRequest from .models import AccessRequest
@admin.register(AccessRequest) @admin.register(AccessRequest)
class AccessRequestAdmin(admin.ModelAdmin): class AccessRequestAdmin(GuardedModelAdmin):
list_display = ( list_display = (
"id", "id",
"requester", "requester",

View File

@@ -5,3 +5,7 @@ class AccessConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "apps.access" name = "apps.access"
verbose_name = "Access Requests" verbose_name = "Access Requests"
def ready(self) -> None:
from . import signals # noqa: F401
return super().ready()

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from django.db.models.signals import post_save
from django.dispatch import receiver
from guardian.shortcuts import assign_perm
from apps.core.rbac import assign_default_object_permissions
from .models import AccessRequest
@receiver(post_save, sender=AccessRequest)
def assign_access_request_perms(sender, instance: AccessRequest, created: bool, **kwargs) -> None:
if not created:
return
if instance.requester_id:
user = instance.requester
for perm in (
"access.view_accessrequest",
"access.change_accessrequest",
"access.delete_accessrequest",
):
assign_perm(perm, user, instance)
assign_default_object_permissions(instance)

View File

@@ -11,10 +11,11 @@ class CoreConfig(AppConfig):
verbose_name = "Core" verbose_name = "Core"
def ready(self) -> None: def ready(self) -> None:
from .rbac import ensure_role_groups from .rbac import assign_role_permissions, ensure_role_groups
def _ensure_roles(**_kwargs) -> None: def _ensure_roles(**_kwargs) -> None:
ensure_role_groups() ensure_role_groups()
assign_role_permissions()
post_migrate.connect(_ensure_roles, sender=self) post_migrate.connect(_ensure_roles, dispatch_uid="core_rbac")
return super().ready() return super().ready()

View File

@@ -0,0 +1,51 @@
from django.core.management.base import BaseCommand
from guardian.shortcuts import assign_perm
from apps.access.models import AccessRequest
from apps.core.rbac import assign_default_object_permissions
from apps.keys.models import SSHKey
from apps.servers.models import Server
class Command(BaseCommand):
help = "Backfill guardian object permissions for access requests and SSH keys."
def handle(self, *args, **options):
access_count = 0
for access_request in AccessRequest.objects.select_related("requester"):
if not access_request.requester_id:
assign_default_object_permissions(access_request)
else:
for perm in (
"access.view_accessrequest",
"access.change_accessrequest",
"access.delete_accessrequest",
):
assign_perm(perm, access_request.requester, access_request)
assign_default_object_permissions(access_request)
access_count += 1
key_count = 0
for key in SSHKey.objects.select_related("user"):
if not key.user_id:
assign_default_object_permissions(key)
else:
for perm in ("keys.view_sshkey", "keys.change_sshkey", "keys.delete_sshkey"):
assign_perm(perm, key.user, key)
assign_default_object_permissions(key)
key_count += 1
server_count = 0
for server in Server.objects.all():
assign_default_object_permissions(server)
server_count += 1
self.stdout.write(
self.style.SUCCESS(
"Synced object permissions for "
f"{access_count} access requests, "
f"{key_count} SSH keys, "
f"and {server_count} servers."
)
)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from django.contrib.auth.models import Group from django.contrib.auth.models import Group, Permission
from guardian.shortcuts import assign_perm
from ninja.errors import HttpError from ninja.errors import HttpError
ROLE_ADMIN = "administrator" ROLE_ADMIN = "administrator"
@@ -13,6 +14,38 @@ ROLE_ALL = ROLE_ORDER
ROLE_ALIASES = {"admin": ROLE_ADMIN} ROLE_ALIASES = {"admin": ROLE_ADMIN}
ROLE_INPUTS = tuple(sorted(set(ROLE_ORDER) | set(ROLE_ALIASES.keys()))) ROLE_INPUTS = tuple(sorted(set(ROLE_ORDER) | set(ROLE_ALIASES.keys())))
def _model_perms(app_label: str, model: str, actions: list[str]) -> list[str]:
return [f"{app_label}.{action}_{model}" for action in actions]
ROLE_PERMISSIONS = {
ROLE_ADMIN: [],
ROLE_OPERATOR: [
*_model_perms("servers", "server", ["view"]),
*_model_perms("access", "accessrequest", ["add", "view", "change", "delete"]),
*_model_perms("keys", "sshkey", ["add", "view", "change", "delete"]),
*_model_perms("telemetry", "telemetryevent", ["add", "view"]),
*_model_perms("audit", "auditlog", ["view"]),
*_model_perms("audit", "auditeventtype", ["view"]),
*_model_perms("auth", "user", ["add", "view"]),
],
ROLE_AUDITOR: [
*_model_perms("audit", "auditlog", ["view"]),
*_model_perms("audit", "auditeventtype", ["view"]),
],
ROLE_USER: [
*_model_perms("servers", "server", ["view"]),
*_model_perms("access", "accessrequest", ["add"]),
*_model_perms("keys", "sshkey", ["add"]),
],
}
OBJECT_PERMISSION_MODELS = {
("servers", "server"),
("access", "accessrequest"),
("keys", "sshkey"),
}
def normalize_role(role: str) -> str: def normalize_role(role: str) -> str:
normalized = (role or "").strip().lower() normalized = (role or "").strip().lower()
@@ -24,6 +57,56 @@ def ensure_role_groups() -> None:
Group.objects.get_or_create(name=role) Group.objects.get_or_create(name=role)
def assign_role_permissions() -> None:
ensure_role_groups()
for role, perm_codes in ROLE_PERMISSIONS.items():
group = Group.objects.get(name=role)
if role == ROLE_ADMIN:
group.permissions.set(Permission.objects.all())
continue
perms = []
for code in perm_codes:
if "." not in code:
continue
app_label, codename = code.split(".", 1)
try:
perms.append(
Permission.objects.get(
content_type__app_label=app_label,
codename=codename,
)
)
except Permission.DoesNotExist:
continue
group.permissions.set(perms)
def assign_default_object_permissions(instance) -> None:
app_label = instance._meta.app_label
model_name = instance._meta.model_name
if (app_label, model_name) not in OBJECT_PERMISSION_MODELS:
return
ensure_role_groups()
groups = {group.name: group for group in Group.objects.filter(name__in=ROLE_ORDER)}
for role, perm_codes in ROLE_PERMISSIONS.items():
if role == ROLE_ADMIN:
continue
group = groups.get(role)
if not group:
continue
for code in perm_codes:
if "." not in code:
continue
perm_app, codename = code.split(".", 1)
if perm_app != app_label:
continue
if not codename.endswith(f"_{model_name}"):
continue
if codename.startswith("add_"):
continue
assign_perm(code, group, instance)
def get_user_role(user, default: str = ROLE_USER) -> str | None: def get_user_role(user, default: str = ROLE_USER) -> str | None:
if not user or not getattr(user, "is_authenticated", False): if not user or not getattr(user, "is_authenticated", False):
return None return None
@@ -33,8 +116,6 @@ def get_user_role(user, default: str = ROLE_USER) -> str | None:
for role in ROLE_ORDER: for role in ROLE_ORDER:
if role in group_names: if role in group_names:
return role return role
if getattr(user, "is_staff", False):
return ROLE_ADMIN
return default return default
@@ -51,6 +132,9 @@ def set_user_role(user, role: str) -> str:
if canonical == ROLE_ADMIN: if canonical == ROLE_ADMIN:
user.is_staff = True user.is_staff = True
user.is_superuser = True user.is_superuser = True
elif canonical in {ROLE_OPERATOR, ROLE_AUDITOR}:
user.is_staff = True
user.is_superuser = False
else: else:
user.is_staff = False user.is_staff = False
user.is_superuser = False user.is_superuser = False
@@ -63,13 +147,9 @@ def require_authenticated(request) -> None:
raise HttpError(403, "Forbidden") raise HttpError(403, "Forbidden")
def require_roles(request, *roles: str) -> None: def require_perms(request, *perms: str) -> None:
user = getattr(request, "user", None) user = getattr(request, "user", None)
if not user or not getattr(user, "is_authenticated", False): if not user or not getattr(user, "is_authenticated", False):
raise HttpError(403, "Forbidden") raise HttpError(403, "Forbidden")
role = get_user_role(user) if not user.has_perms(perms):
if role == ROLE_ADMIN:
return
allowed = {normalize_role(entry) for entry in roles}
if role not in allowed:
raise HttpError(403, "Forbidden") raise HttpError(403, "Forbidden")

View File

@@ -1,10 +1,11 @@
from django.contrib import admin from django.contrib import admin
from guardian.admin import GuardedModelAdmin
from .models import SSHKey from .models import SSHKey
@admin.register(SSHKey) @admin.register(SSHKey)
class SSHKeyAdmin(admin.ModelAdmin): class SSHKeyAdmin(GuardedModelAdmin):
list_display = ("id", "user", "name", "key_type", "fingerprint", "is_active", "created_at") list_display = ("id", "user", "name", "key_type", "fingerprint", "is_active", "created_at")
list_filter = ("is_active", "key_type") list_filter = ("is_active", "key_type")
search_fields = ("name", "user__username", "user__email", "fingerprint") search_fields = ("name", "user__username", "user__email", "fingerprint")

View File

@@ -5,3 +5,7 @@ class KeysConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "apps.keys" name = "apps.keys"
verbose_name = "SSH Keys" verbose_name = "SSH Keys"
def ready(self) -> None:
from . import signals # noqa: F401
return super().ready()

19
app/apps/keys/signals.py Normal file
View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from django.db.models.signals import post_save
from django.dispatch import receiver
from guardian.shortcuts import assign_perm
from apps.core.rbac import assign_default_object_permissions
from .models import SSHKey
@receiver(post_save, sender=SSHKey)
def assign_ssh_key_perms(sender, instance: SSHKey, created: bool, **kwargs) -> None:
if not created:
return
if instance.user_id:
user = instance.user
for perm in ("keys.view_sshkey", "keys.change_sshkey", "keys.delete_sshkey"):
assign_perm(perm, user, instance)
assign_default_object_permissions(instance)

View File

@@ -1,10 +1,11 @@
from django.contrib import admin from django.contrib import admin
from guardian.admin import GuardedModelAdmin
from django.utils.html import format_html from django.utils.html import format_html
from .models import Server from .models import Server
@admin.register(Server) @admin.register(Server)
class ServerAdmin(admin.ModelAdmin): class ServerAdmin(GuardedModelAdmin):
list_display = ("avatar", "display_name", "hostname", "ipv4", "ipv6", "created_at") list_display = ("avatar", "display_name", "hostname", "ipv4", "ipv6", "created_at")
list_display_links = ("display_name",) list_display_links = ("display_name",)
search_fields = ("display_name", "hostname", "ipv4", "ipv6") search_fields = ("display_name", "hostname", "ipv4", "ipv6")
@@ -26,4 +27,3 @@ class ServerAdmin(admin.ModelAdmin):
) )
avatar.short_description = "" avatar.short_description = ""

View File

@@ -6,4 +6,7 @@ class ServersConfig(AppConfig):
name = "apps.servers" name = "apps.servers"
verbose_name = "Servers" verbose_name = "Servers"
def ready(self) -> None:
from . import signals # noqa: F401
return super().ready()

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from django.db.models.signals import post_save
from django.dispatch import receiver
from apps.core.rbac import assign_default_object_permissions
from .models import Server
@receiver(post_save, sender=Server)
def assign_server_perms(sender, instance: Server, created: bool, **kwargs) -> None:
if not created:
return
assign_default_object_permissions(instance)

View File

@@ -5,12 +5,13 @@ from typing import List, Optional
from django.http import HttpRequest from django.http import HttpRequest
from django.utils import timezone from django.utils import timezone
from guardian.shortcuts import get_objects_for_user
from ninja import Query, Router, Schema from ninja import Query, Router, Schema
from ninja.errors import HttpError from ninja.errors import HttpError
from pydantic import Field from pydantic import Field
from apps.access.models import AccessRequest from apps.access.models import AccessRequest
from apps.core.rbac import ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER, get_user_role, require_roles from apps.core.rbac import require_authenticated
from apps.servers.models import Server from apps.servers.models import Server
@@ -59,20 +60,31 @@ def _request_to_out(access_request: AccessRequest) -> AccessRequestOut:
) )
def _has_global_perm(request: HttpRequest, perm: str) -> bool:
user = request.user
return bool(user and user.has_perm(perm))
def build_router() -> Router: def build_router() -> Router:
router = Router() router = Router()
@router.get("/", response=List[AccessRequestOut]) @router.get("/", response=List[AccessRequestOut])
def list_requests(request: HttpRequest, filters: AccessQuery = Query(...)): def list_requests(request: HttpRequest, filters: AccessQuery = Query(...)):
"""List access requests for the user, or all if admin/operator.""" """List access requests for the user, or all if admin/operator."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) require_authenticated(request)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} user = request.user
qs = AccessRequest.objects.order_by("-requested_at") if _has_global_perm(request, "access.view_accessrequest"):
if is_admin: qs = AccessRequest.objects.all()
if filters.requester_id:
qs = qs.filter(requester_id=filters.requester_id)
else: else:
qs = qs.filter(requester=request.user) qs = get_objects_for_user(
user,
"access.view_accessrequest",
klass=AccessRequest,
accept_global_perms=False,
)
qs = qs.order_by("-requested_at")
if filters.requester_id and _has_global_perm(request, "access.view_accessrequest"):
qs = qs.filter(requester_id=filters.requester_id)
if filters.status: if filters.status:
qs = qs.filter(status=filters.status) qs = qs.filter(status=filters.status)
if filters.server_id: if filters.server_id:
@@ -83,7 +95,9 @@ def build_router() -> Router:
@router.post("/", response=AccessRequestOut) @router.post("/", response=AccessRequestOut)
def create_request(request: HttpRequest, payload: AccessRequestCreateIn): def create_request(request: HttpRequest, payload: AccessRequestCreateIn):
"""Create a new access request for a server.""" """Create a new access request for a server."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) require_authenticated(request)
if not request.user.has_perm("access.add_accessrequest"):
raise HttpError(403, "Forbidden")
try: try:
server = Server.objects.get(id=payload.server_id) server = Server.objects.get(id=payload.server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
@@ -103,28 +117,26 @@ def build_router() -> Router:
@router.get("/{request_id}", response=AccessRequestOut) @router.get("/{request_id}", response=AccessRequestOut)
def get_request(request: HttpRequest, request_id: int): def get_request(request: HttpRequest, request_id: int):
"""Get an access request if permitted.""" """Get an access request if permitted."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) require_authenticated(request)
try: try:
access_request = AccessRequest.objects.get(id=request_id) access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist: except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found") raise HttpError(404, "Not Found")
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} if not request.user.has_perm("access.view_accessrequest", access_request):
if not is_admin and access_request.requester_id != request.user.id:
raise HttpError(403, "Forbidden") raise HttpError(403, "Forbidden")
return _request_to_out(access_request) return _request_to_out(access_request)
@router.patch("/{request_id}", response=AccessRequestOut) @router.patch("/{request_id}", response=AccessRequestOut)
def update_request(request: HttpRequest, request_id: int, payload: AccessRequestUpdateIn): def update_request(request: HttpRequest, request_id: int, payload: AccessRequestUpdateIn):
"""Update request status or expiry (admin/operator or owner with restrictions).""" """Update request status or expiry (admin/operator or owner with restrictions)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) require_authenticated(request)
try: try:
access_request = AccessRequest.objects.get(id=request_id) access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist: except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found") raise HttpError(404, "Not Found")
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} if not request.user.has_perm("access.change_accessrequest", access_request):
is_owner = access_request.requester_id == request.user.id
if not is_admin and not is_owner:
raise HttpError(403, "Forbidden") raise HttpError(403, "Forbidden")
is_admin = _has_global_perm(request, "access.change_accessrequest")
if payload.status is None and payload.expires_at is None: if payload.status is None and payload.expires_at is None:
raise HttpError(422, {"detail": "No fields provided."}) raise HttpError(422, {"detail": "No fields provided."})
if payload.expires_at is not None: if payload.expires_at is not None:
@@ -160,13 +172,12 @@ def build_router() -> Router:
@router.delete("/{request_id}", response={204: None}) @router.delete("/{request_id}", response={204: None})
def delete_request(request: HttpRequest, request_id: int): def delete_request(request: HttpRequest, request_id: int):
"""Delete an access request if permitted.""" """Delete an access request if permitted."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) require_authenticated(request)
try: try:
access_request = AccessRequest.objects.get(id=request_id) access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist: except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found") raise HttpError(404, "Not Found")
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} if not request.user.has_perm("access.delete_accessrequest", access_request):
if not is_admin and access_request.requester_id != request.user.id:
raise HttpError(403, "Forbidden") raise HttpError(403, "Forbidden")
access_request.delete() access_request.delete()
return 204, None return 204, None

View File

@@ -9,7 +9,7 @@ from ninja import Router, Schema
from ninja.errors import HttpError from ninja.errors import HttpError
from pydantic import Field from pydantic import Field
from apps.core.rbac import ROLE_ADMIN, ROLE_OPERATOR, require_roles from apps.core.rbac import require_perms
from apps.access.models import AccessRequest from apps.access.models import AccessRequest
from apps.keys.models import SSHKey from apps.keys.models import SSHKey
from apps.servers.models import Server from apps.servers.models import Server
@@ -41,7 +41,12 @@ def build_router() -> Router:
@router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut]) @router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut])
def authorized_keys(request: HttpRequest, server_id: int): def authorized_keys(request: HttpRequest, server_id: int):
"""Return authorized public keys for a server (admin or operator).""" """Return authorized public keys for a server (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR) require_perms(
request,
"servers.view_server",
"keys.view_sshkey",
"access.view_accessrequest",
)
try: try:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
@@ -72,7 +77,7 @@ def build_router() -> Router:
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut) @router.post("/servers/{server_id}/sync-report", response=SyncReportOut)
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn): def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn):
"""Record an agent sync report for a server (admin or operator).""" """Record an agent sync report for a server (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR) require_perms(request, "servers.view_server", "telemetry.add_telemetryevent")
try: try:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:

View File

@@ -8,7 +8,7 @@ from django.http import HttpRequest
from ninja import Query, Router, Schema from ninja import Query, Router, Schema
from apps.audit.models import AuditEventType, AuditLog from apps.audit.models import AuditEventType, AuditLog
from apps.core.rbac import ROLE_ADMIN, ROLE_AUDITOR, ROLE_OPERATOR, require_roles from apps.core.rbac import require_perms
class AuditEventTypeSchema(Schema): class AuditEventTypeSchema(Schema):
id: int id: int
@@ -48,7 +48,7 @@ def build_router() -> Router:
@router.get("/event-types", response=List[AuditEventTypeSchema]) @router.get("/event-types", response=List[AuditEventTypeSchema])
def list_event_types(request: HttpRequest): def list_event_types(request: HttpRequest):
"""List audit event types and their default severity.""" """List audit event types and their default severity."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_AUDITOR) require_perms(request, "audit.view_auditeventtype")
qs: QuerySet[AuditEventType] = AuditEventType.objects.all() qs: QuerySet[AuditEventType] = AuditEventType.objects.all()
return [ return [
{ {
@@ -64,7 +64,7 @@ def build_router() -> Router:
@router.get("/logs", response=List[AuditLogSchema]) @router.get("/logs", response=List[AuditLogSchema])
def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)): def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)):
"""List audit logs with optional filters and pagination.""" """List audit logs with optional filters and pagination."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_AUDITOR) require_perms(request, "audit.view_auditlog")
qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all() qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all()
if filters.severity: if filters.severity:
qs = qs.filter(severity=filters.severity) qs = qs.filter(severity=filters.severity)

View File

@@ -7,11 +7,12 @@ from django.core.exceptions import ValidationError
from django.db import IntegrityError from django.db import IntegrityError
from django.http import HttpRequest from django.http import HttpRequest
from django.utils import timezone from django.utils import timezone
from guardian.shortcuts import get_objects_for_user
from ninja import Query, Router, Schema from ninja import Query, Router, Schema
from ninja.errors import HttpError from ninja.errors import HttpError
from pydantic import Field from pydantic import Field
from apps.core.rbac import ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER, get_user_role, require_roles from apps.core.rbac import require_authenticated
from apps.keys.models import SSHKey from apps.keys.models import SSHKey
@@ -58,28 +59,43 @@ def _key_to_out(key: SSHKey) -> KeyOut:
) )
def _has_global_perm(request: HttpRequest, perm: str) -> bool:
user = request.user
return bool(user and user.has_perm(perm))
def build_router() -> Router: def build_router() -> Router:
router = Router() router = Router()
@router.get("/", response=List[KeyOut]) @router.get("/", response=List[KeyOut])
def list_keys(request: HttpRequest, filters: KeysQuery = Query(...)): def list_keys(request: HttpRequest, filters: KeysQuery = Query(...)):
"""List SSH keys for the current user, or any user if admin/operator.""" """List SSH keys for the current user, or any user if admin/operator."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) require_authenticated(request)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} user = request.user
qs = SSHKey.objects.order_by("-created_at") if _has_global_perm(request, "keys.view_sshkey"):
if is_admin: qs = SSHKey.objects.all()
if filters.user_id:
qs = qs.filter(user_id=filters.user_id)
else: else:
qs = qs.filter(user=request.user) qs = get_objects_for_user(
user,
"keys.view_sshkey",
klass=SSHKey,
accept_global_perms=False,
)
qs = qs.order_by("-created_at")
if filters.user_id and _has_global_perm(request, "keys.view_sshkey"):
qs = qs.filter(user_id=filters.user_id)
qs = qs[filters.offset : filters.offset + filters.limit] qs = qs[filters.offset : filters.offset + filters.limit]
return [_key_to_out(key) for key in qs] return [_key_to_out(key) for key in qs]
@router.post("/", response=KeyOut) @router.post("/", response=KeyOut)
def create_key(request: HttpRequest, payload: KeyCreateIn): def create_key(request: HttpRequest, payload: KeyCreateIn):
"""Create an SSH public key for the current user (admin/operator can specify user_id).""" """Create an SSH public key for the current user (admin/operator can specify user_id)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) require_authenticated(request)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} if not request.user.has_perm("keys.add_sshkey"):
raise HttpError(403, "Forbidden")
is_admin = _has_global_perm(request, "keys.add_sshkey") and _has_global_perm(
request, "keys.view_sshkey"
)
owner = request.user owner = request.user
if is_admin and payload.user_id: if is_admin and payload.user_id:
User = get_user_model() User = get_user_model()
@@ -87,6 +103,8 @@ def build_router() -> Router:
owner = User.objects.get(id=payload.user_id) owner = User.objects.get(id=payload.user_id)
except User.DoesNotExist: except User.DoesNotExist:
raise HttpError(404, "User not found") raise HttpError(404, "User not found")
elif payload.user_id and payload.user_id != request.user.id:
raise HttpError(403, "Forbidden")
name = (payload.name or "").strip() name = (payload.name or "").strip()
if not name: if not name:
raise HttpError(422, {"name": ["Name cannot be empty."]}) raise HttpError(422, {"name": ["Name cannot be empty."]})
@@ -104,26 +122,24 @@ def build_router() -> Router:
@router.get("/{key_id}", response=KeyOut) @router.get("/{key_id}", response=KeyOut)
def get_key(request: HttpRequest, key_id: int): def get_key(request: HttpRequest, key_id: int):
"""Get a specific SSH key if permitted.""" """Get a specific SSH key if permitted."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) require_authenticated(request)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR}
try: try:
key = SSHKey.objects.get(id=key_id) key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist: except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found") raise HttpError(404, "Not Found")
if not is_admin and key.user_id != request.user.id: if not request.user.has_perm("keys.view_sshkey", key):
raise HttpError(403, "Forbidden") raise HttpError(403, "Forbidden")
return _key_to_out(key) return _key_to_out(key)
@router.patch("/{key_id}", response=KeyOut) @router.patch("/{key_id}", response=KeyOut)
def update_key(request: HttpRequest, key_id: int, payload: KeyUpdateIn): def update_key(request: HttpRequest, key_id: int, payload: KeyUpdateIn):
"""Update key name or active state if permitted.""" """Update key name or active state if permitted."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) require_authenticated(request)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR}
try: try:
key = SSHKey.objects.get(id=key_id) key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist: except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found") raise HttpError(404, "Not Found")
if not is_admin and key.user_id != request.user.id: if not request.user.has_perm("keys.change_sshkey", key):
raise HttpError(403, "Forbidden") raise HttpError(403, "Forbidden")
if payload.name is None and payload.is_active is None: if payload.name is None and payload.is_active is None:
raise HttpError(422, {"detail": "No fields provided."}) raise HttpError(422, {"detail": "No fields provided."})
@@ -144,13 +160,12 @@ def build_router() -> Router:
@router.delete("/{key_id}", response={204: None}) @router.delete("/{key_id}", response={204: None})
def delete_key(request: HttpRequest, key_id: int): def delete_key(request: HttpRequest, key_id: int):
"""Revoke an SSH key if permitted (soft delete).""" """Revoke an SSH key if permitted (soft delete)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) require_authenticated(request)
is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR}
try: try:
key = SSHKey.objects.get(id=key_id) key = SSHKey.objects.get(id=key_id)
except SSHKey.DoesNotExist: except SSHKey.DoesNotExist:
raise HttpError(404, "Not Found") raise HttpError(404, "Not Found")
if not is_admin and key.user_id != request.user.id: if not request.user.has_perm("keys.delete_sshkey", key):
raise HttpError(403, "Forbidden") raise HttpError(403, "Forbidden")
if key.is_active: if key.is_active:
key.is_active = False key.is_active = False

View File

@@ -7,7 +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 apps.core.rbac import ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER, require_roles from apps.core.rbac import require_perms
from apps.servers.models import Server from apps.servers.models import Server
@@ -41,7 +41,7 @@ def build_router() -> Router:
@router.get("/", response=List[ServerOut]) @router.get("/", response=List[ServerOut])
def list_servers(request: HttpRequest): def list_servers(request: HttpRequest):
"""List servers visible to authenticated users.""" """List servers visible to authenticated users."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) require_perms(request, "servers.view_server")
servers = Server.objects.all() servers = Server.objects.all()
return [ return [
{ {
@@ -59,7 +59,7 @@ def build_router() -> Router:
@router.get("/{server_id}", response=ServerOut) @router.get("/{server_id}", response=ServerOut)
def get_server(request: HttpRequest, server_id: int): def get_server(request: HttpRequest, server_id: int):
"""Get server details by id.""" """Get server details by id."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) require_perms(request, "servers.view_server")
try: try:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:
@@ -77,7 +77,7 @@ def build_router() -> Router:
@router.post("/", response=ServerOut) @router.post("/", response=ServerOut)
def create_server_json(request: HttpRequest, payload: ServerCreate): def create_server_json(request: HttpRequest, payload: ServerCreate):
"""Create a server using JSON payload (admin only).""" """Create a server using JSON payload (admin only)."""
require_roles(request, ROLE_ADMIN) require_perms(request, "servers.add_server")
server = Server.objects.create( server = Server.objects.create(
display_name=payload.display_name.strip(), display_name=payload.display_name.strip(),
hostname=(payload.hostname or "").strip() or None, hostname=(payload.hostname or "").strip() or None,
@@ -104,7 +104,7 @@ def build_router() -> Router:
image: Optional[UploadedFile] = File(None), image: Optional[UploadedFile] = File(None),
): ):
"""Create a server with optional image upload (admin only).""" """Create a server with optional image upload (admin only)."""
require_roles(request, ROLE_ADMIN) require_perms(request, "servers.add_server")
server = Server( server = Server(
display_name=display_name.strip(), display_name=display_name.strip(),
hostname=(hostname or "").strip() or None, hostname=(hostname or "").strip() or None,
@@ -127,7 +127,7 @@ def build_router() -> Router:
@router.patch("/{server_id}", response=ServerOut) @router.patch("/{server_id}", response=ServerOut)
def update_server(request: HttpRequest, server_id: int, payload: ServerUpdate): def update_server(request: HttpRequest, server_id: int, payload: ServerUpdate):
"""Update server fields (admin only).""" """Update server fields (admin only)."""
require_roles(request, ROLE_ADMIN) require_perms(request, "servers.change_server")
if ( if (
payload.display_name is None payload.display_name is None
and payload.hostname is None and payload.hostname is None
@@ -167,7 +167,7 @@ def build_router() -> Router:
@router.delete("/{server_id}", response={204: None}) @router.delete("/{server_id}", response={204: None})
def delete_server(request: HttpRequest, server_id: int): def delete_server(request: HttpRequest, server_id: int):
"""Delete a server by id (admin only).""" """Delete a server by id (admin only)."""
require_roles(request, ROLE_ADMIN) require_perms(request, "servers.delete_server")
try: try:
server = Server.objects.get(id=server_id) server = Server.objects.get(id=server_id)
except Server.DoesNotExist: except Server.DoesNotExist:

View File

@@ -10,7 +10,7 @@ from ninja import Query, Router, Schema
from ninja.errors import HttpError from ninja.errors import HttpError
from pydantic import Field from pydantic import Field
from apps.core.rbac import ROLE_ADMIN, ROLE_OPERATOR, require_roles from apps.core.rbac import require_perms
from apps.servers.models import Server from apps.servers.models import Server
from apps.telemetry.models import TelemetryEvent from apps.telemetry.models import TelemetryEvent
@@ -72,7 +72,7 @@ def build_router() -> Router:
@router.get("/", response=List[TelemetryOut]) @router.get("/", response=List[TelemetryOut])
def list_events(request: HttpRequest, filters: TelemetryQuery = Query(...)): def list_events(request: HttpRequest, filters: TelemetryQuery = Query(...)):
"""List telemetry events with filters (admin or operator).""" """List telemetry events with filters (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR) require_perms(request, "telemetry.view_telemetryevent")
qs = TelemetryEvent.objects.order_by("-created_at") qs = TelemetryEvent.objects.order_by("-created_at")
if filters.event_type: if filters.event_type:
qs = qs.filter(event_type=filters.event_type) qs = qs.filter(event_type=filters.event_type)
@@ -88,7 +88,7 @@ def build_router() -> Router:
@router.post("/", response=TelemetryOut) @router.post("/", response=TelemetryOut)
def create_event(request: HttpRequest, payload: TelemetryCreateIn): def create_event(request: HttpRequest, payload: TelemetryCreateIn):
"""Create a telemetry event entry (admin or operator).""" """Create a telemetry event entry (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR) require_perms(request, "telemetry.add_telemetryevent")
server = None server = None
if payload.server_id: if payload.server_id:
try: try:
@@ -116,7 +116,7 @@ def build_router() -> Router:
@router.get("/summary", response=TelemetrySummaryOut) @router.get("/summary", response=TelemetrySummaryOut)
def summary(request: HttpRequest): def summary(request: HttpRequest):
"""Return a high-level telemetry summary (admin or operator).""" """Return a high-level telemetry summary (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR) require_perms(request, "telemetry.view_telemetryevent")
totals = TelemetryEvent.objects.aggregate( totals = TelemetryEvent.objects.aggregate(
total=Count("id"), total=Count("id"),
success=Count("id", filter=models.Q(success=True)), success=Count("id", filter=models.Q(success=True)),

View File

@@ -9,7 +9,7 @@ from ninja import Query, Router, Schema
from ninja.errors import HttpError from ninja.errors import HttpError
from pydantic import EmailStr, Field from pydantic import EmailStr, Field
from apps.core.rbac import ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER, get_user_role, require_roles, set_user_role from apps.core.rbac import ROLE_USER, get_user_role, require_perms, set_user_role
class UserCreateIn(Schema): class UserCreateIn(Schema):
@@ -54,7 +54,7 @@ def build_router() -> Router:
@router.post("/", response=UserDetailOut) @router.post("/", response=UserDetailOut)
def create_user(request: HttpRequest, payload: UserCreateIn): def create_user(request: HttpRequest, payload: UserCreateIn):
"""Create a user with role and password (admin or operator).""" """Create a user with role and password (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR) require_perms(request, "auth.add_user")
User = get_user_model() User = get_user_model()
email = payload.email.strip().lower() email = payload.email.strip().lower()
if User.objects.filter(email__iexact=email).exists(): if User.objects.filter(email__iexact=email).exists():
@@ -80,7 +80,7 @@ def build_router() -> Router:
@router.get("/", response=List[UserListOut]) @router.get("/", response=List[UserListOut])
def list_users(request: HttpRequest, pagination: UsersQuery = Query(...)): def list_users(request: HttpRequest, pagination: UsersQuery = Query(...)):
"""List users with pagination (admin or operator).""" """List users with pagination (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR) require_perms(request, "auth.view_user")
User = get_user_model() User = get_user_model()
qs = User.objects.order_by("id")[pagination.offset : pagination.offset + pagination.limit] qs = User.objects.order_by("id")[pagination.offset : pagination.offset + pagination.limit]
return [ return [
@@ -96,7 +96,7 @@ def build_router() -> Router:
@router.get("/{user_id}", response=UserDetailOut) @router.get("/{user_id}", response=UserDetailOut)
def get_user(request: HttpRequest, user_id: int): def get_user(request: HttpRequest, user_id: int):
"""Get user details by id (admin or operator).""" """Get user details by id (admin or operator)."""
require_roles(request, ROLE_ADMIN, ROLE_OPERATOR) require_perms(request, "auth.view_user")
User = get_user_model() User = get_user_model()
try: try:
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
@@ -112,7 +112,7 @@ def build_router() -> Router:
@router.patch("/{user_id}", response=UserDetailOut) @router.patch("/{user_id}", response=UserDetailOut)
def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn): def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn):
"""Update user fields such as role, email, or status (admin only).""" """Update user fields such as role, email, or status (admin only)."""
require_roles(request, ROLE_ADMIN) require_perms(request, "auth.change_user")
if payload.email is None and payload.password is None and payload.role is None and payload.is_active is None: if payload.email is None and payload.password is None and payload.role is None and payload.is_active is None:
raise HttpError(422, {"detail": "No fields provided."}) raise HttpError(422, {"detail": "No fields provided."})
User = get_user_model() User = get_user_model()
@@ -146,7 +146,7 @@ def build_router() -> Router:
@router.delete("/{user_id}", response={204: None}) @router.delete("/{user_id}", response={204: None})
def delete_user(request: HttpRequest, user_id: int): def delete_user(request: HttpRequest, user_id: int):
"""Delete a user by id (admin only).""" """Delete a user by id (admin only)."""
require_roles(request, ROLE_ADMIN) require_perms(request, "auth.delete_user")
User = get_user_model() User = get_user_model()
try: try:
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)

View File

@@ -24,6 +24,7 @@ CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
INSTALLED_APPS = [ INSTALLED_APPS = [
"unfold.contrib.guardian",
"unfold", # Admin UI "unfold", # Admin UI
"unfold.contrib.filters", "unfold.contrib.filters",
"django.contrib.admin", "django.contrib.admin",
@@ -32,14 +33,15 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"guardian",
"rest_framework", "rest_framework",
"apps.audit", "apps.audit",
"apps.accounts", "apps.accounts",
"apps.core.apps.CoreConfig", "apps.core.apps.CoreConfig",
"apps.dashboard", "apps.dashboard",
"apps.servers", "apps.servers.apps.ServersConfig",
"apps.keys", "apps.keys.apps.KeysConfig",
"apps.access", "apps.access.apps.AccessConfig",
"apps.telemetry", "apps.telemetry",
"ninja", # Django Ninja API "ninja", # Django Ninja API
"mozilla_django_oidc", # OIDC Client "mozilla_django_oidc", # OIDC Client
@@ -208,6 +210,8 @@ KEYWARDEN_AUTH_MODE = AUTH_MODE
if AUTH_MODE == "oidc": if AUTH_MODE == "oidc":
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"guardian.backends.ObjectPermissionBackend",
"mozilla_django_oidc.auth.OIDCAuthenticationBackend", "mozilla_django_oidc.auth.OIDCAuthenticationBackend",
] ]
LOGIN_URL = "/oidc/authenticate/" LOGIN_URL = "/oidc/authenticate/"
@@ -215,6 +219,7 @@ else:
# native or hybrid -> allow both, native first for precedence # native or hybrid -> allow both, native first for precedence
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
"guardian.backends.ObjectPermissionBackend",
"mozilla_django_oidc.auth.OIDCAuthenticationBackend", "mozilla_django_oidc.auth.OIDCAuthenticationBackend",
] ]
LOGIN_URL = "/accounts/login/" LOGIN_URL = "/accounts/login/"
@@ -222,5 +227,7 @@ LOGOUT_URL = "/oidc/logout/"
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/"
ANONYMOUS_USER_NAME = None
def permission_callback(request): def permission_callback(request):
return request.user.has_perm("keywarden.change_model") return request.user.has_perm("keywarden.change_model")

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 512 512">
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
<!-- https://fontawesome.com/icons/circle-xmark?f=classic&s=solid -->
<!-- SPDX-License-Identifier: CC-BY-4.0 -->
<path fill="#dd4646" d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 683 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 512 512">
<!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
<!-- https://fontawesome.com/icons/circle-check?f=classic&s=solid -->
<!-- SPDX-License-Identifier: CC-BY-4.0 -->
<path fill="#70bf2b" d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 596 B

View File

@@ -7,6 +7,7 @@ Pillow>=10.0.0
mozilla-django-oidc>=4.0.0 mozilla-django-oidc>=4.0.0
django-unfold>=0.70.0 django-unfold>=0.70.0
django-tailwind==4.4.0 django-tailwind==4.4.0
django-guardian>=2.4.0
argon2-cffi>=23.1.0 argon2-cffi>=23.1.0
psycopg2-binary>=2.9.9 psycopg2-binary>=2.9.9
gunicorn==23.0.0 gunicorn==23.0.0