From 66ffa3d3fb25a074faaab07631388527ccbc409a Mon Sep 17 00:00:00 2001 From: boris Date: Sun, 25 Jan 2026 17:48:14 +0000 Subject: [PATCH] Initial django guardian integrations --- .env.example | 2 +- app/apps/access/admin.py | 3 +- app/apps/access/apps.py | 4 + app/apps/access/signals.py | 23 +++++ app/apps/core/apps.py | 5 +- .../management/commands/sync_object_perms.py | 51 ++++++++++ app/apps/core/rbac.py | 98 +++++++++++++++++-- app/apps/keys/admin.py | 3 +- app/apps/keys/apps.py | 4 + app/apps/keys/signals.py | 19 ++++ app/apps/servers/admin.py | 4 +- app/apps/servers/apps.py | 3 + app/apps/servers/signals.py | 14 +++ app/keywarden/api/routers/access.py | 49 ++++++---- app/keywarden/api/routers/agent.py | 11 ++- app/keywarden/api/routers/audit.py | 6 +- app/keywarden/api/routers/keys.py | 53 ++++++---- app/keywarden/api/routers/servers.py | 14 +-- app/keywarden/api/routers/telemetry.py | 8 +- app/keywarden/api/routers/users.py | 12 +-- app/keywarden/settings/base.py | 13 ++- app/static/guardian/img/icon-no.svg | 6 ++ app/static/guardian/img/icon-yes.svg | 6 ++ requirements.txt | 1 + 24 files changed, 332 insertions(+), 80 deletions(-) create mode 100644 app/apps/access/signals.py create mode 100644 app/apps/core/management/commands/sync_object_perms.py create mode 100644 app/apps/keys/signals.py create mode 100644 app/apps/servers/signals.py create mode 100644 app/static/guardian/img/icon-no.svg create mode 100644 app/static/guardian/img/icon-yes.svg diff --git a/.env.example b/.env.example index b9b14a6..cf08c6e 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,7 @@ KEYWARDEN_POSTGRES_HOST=keywarden-db KEYWARDEN_POSTGRES_PORT=5432 -# Admin bootstrap +# Admin KEYWARDEN_ADMIN_USERNAME=admin KEYWARDEN_ADMIN_EMAIL=admin@example.com KEYWARDEN_ADMIN_PASSWORD=password diff --git a/app/apps/access/admin.py b/app/apps/access/admin.py index 859ffcf..ad072ab 100644 --- a/app/apps/access/admin.py +++ b/app/apps/access/admin.py @@ -1,10 +1,11 @@ from django.contrib import admin +from guardian.admin import GuardedModelAdmin from .models import AccessRequest @admin.register(AccessRequest) -class AccessRequestAdmin(admin.ModelAdmin): +class AccessRequestAdmin(GuardedModelAdmin): list_display = ( "id", "requester", diff --git a/app/apps/access/apps.py b/app/apps/access/apps.py index ce4f8fd..4d0e41c 100644 --- a/app/apps/access/apps.py +++ b/app/apps/access/apps.py @@ -5,3 +5,7 @@ class AccessConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.access" verbose_name = "Access Requests" + + def ready(self) -> None: + from . import signals # noqa: F401 + return super().ready() diff --git a/app/apps/access/signals.py b/app/apps/access/signals.py new file mode 100644 index 0000000..679ad0a --- /dev/null +++ b/app/apps/access/signals.py @@ -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) diff --git a/app/apps/core/apps.py b/app/apps/core/apps.py index 4c72868..05d1434 100644 --- a/app/apps/core/apps.py +++ b/app/apps/core/apps.py @@ -11,10 +11,11 @@ class CoreConfig(AppConfig): verbose_name = "Core" def ready(self) -> None: - from .rbac import ensure_role_groups + from .rbac import assign_role_permissions, ensure_role_groups def _ensure_roles(**_kwargs) -> None: 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() diff --git a/app/apps/core/management/commands/sync_object_perms.py b/app/apps/core/management/commands/sync_object_perms.py new file mode 100644 index 0000000..769de08 --- /dev/null +++ b/app/apps/core/management/commands/sync_object_perms.py @@ -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." + ) + ) diff --git a/app/apps/core/rbac.py b/app/apps/core/rbac.py index f1ea128..fcd3f2e 100644 --- a/app/apps/core/rbac.py +++ b/app/apps/core/rbac.py @@ -1,6 +1,7 @@ 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 ROLE_ADMIN = "administrator" @@ -13,6 +14,38 @@ ROLE_ALL = ROLE_ORDER ROLE_ALIASES = {"admin": ROLE_ADMIN} 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: normalized = (role or "").strip().lower() @@ -24,6 +57,56 @@ def ensure_role_groups() -> None: 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: if not user or not getattr(user, "is_authenticated", False): return None @@ -33,8 +116,6 @@ def get_user_role(user, default: str = ROLE_USER) -> str | None: for role in ROLE_ORDER: if role in group_names: return role - if getattr(user, "is_staff", False): - return ROLE_ADMIN return default @@ -51,6 +132,9 @@ def set_user_role(user, role: str) -> str: if canonical == ROLE_ADMIN: user.is_staff = True user.is_superuser = True + elif canonical in {ROLE_OPERATOR, ROLE_AUDITOR}: + user.is_staff = True + user.is_superuser = False else: user.is_staff = False user.is_superuser = False @@ -63,13 +147,9 @@ def require_authenticated(request) -> None: raise HttpError(403, "Forbidden") -def require_roles(request, *roles: str) -> None: +def require_perms(request, *perms: str) -> None: user = getattr(request, "user", None) if not user or not getattr(user, "is_authenticated", False): raise HttpError(403, "Forbidden") - role = get_user_role(user) - if role == ROLE_ADMIN: - return - allowed = {normalize_role(entry) for entry in roles} - if role not in allowed: + if not user.has_perms(perms): raise HttpError(403, "Forbidden") diff --git a/app/apps/keys/admin.py b/app/apps/keys/admin.py index cdcc716..64018b1 100644 --- a/app/apps/keys/admin.py +++ b/app/apps/keys/admin.py @@ -1,10 +1,11 @@ from django.contrib import admin +from guardian.admin import GuardedModelAdmin from .models import SSHKey @admin.register(SSHKey) -class SSHKeyAdmin(admin.ModelAdmin): +class SSHKeyAdmin(GuardedModelAdmin): list_display = ("id", "user", "name", "key_type", "fingerprint", "is_active", "created_at") list_filter = ("is_active", "key_type") search_fields = ("name", "user__username", "user__email", "fingerprint") diff --git a/app/apps/keys/apps.py b/app/apps/keys/apps.py index 9650cd3..9081d5c 100644 --- a/app/apps/keys/apps.py +++ b/app/apps/keys/apps.py @@ -5,3 +5,7 @@ class KeysConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.keys" verbose_name = "SSH Keys" + + def ready(self) -> None: + from . import signals # noqa: F401 + return super().ready() diff --git a/app/apps/keys/signals.py b/app/apps/keys/signals.py new file mode 100644 index 0000000..69c1e07 --- /dev/null +++ b/app/apps/keys/signals.py @@ -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) diff --git a/app/apps/servers/admin.py b/app/apps/servers/admin.py index 48f2f59..c461aab 100644 --- a/app/apps/servers/admin.py +++ b/app/apps/servers/admin.py @@ -1,10 +1,11 @@ from django.contrib import admin +from guardian.admin import GuardedModelAdmin from django.utils.html import format_html from .models import Server @admin.register(Server) -class ServerAdmin(admin.ModelAdmin): +class ServerAdmin(GuardedModelAdmin): list_display = ("avatar", "display_name", "hostname", "ipv4", "ipv6", "created_at") list_display_links = ("display_name",) search_fields = ("display_name", "hostname", "ipv4", "ipv6") @@ -26,4 +27,3 @@ class ServerAdmin(admin.ModelAdmin): ) avatar.short_description = "" - diff --git a/app/apps/servers/apps.py b/app/apps/servers/apps.py index 02109f4..6a87ec4 100644 --- a/app/apps/servers/apps.py +++ b/app/apps/servers/apps.py @@ -6,4 +6,7 @@ class ServersConfig(AppConfig): name = "apps.servers" verbose_name = "Servers" + def ready(self) -> None: + from . import signals # noqa: F401 + return super().ready() diff --git a/app/apps/servers/signals.py b/app/apps/servers/signals.py new file mode 100644 index 0000000..b077f23 --- /dev/null +++ b/app/apps/servers/signals.py @@ -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) diff --git a/app/keywarden/api/routers/access.py b/app/keywarden/api/routers/access.py index ac01206..82788f0 100644 --- a/app/keywarden/api/routers/access.py +++ b/app/keywarden/api/routers/access.py @@ -5,12 +5,13 @@ from typing import List, Optional from django.http import HttpRequest from django.utils import timezone +from guardian.shortcuts import get_objects_for_user from ninja import Query, Router, Schema from ninja.errors import HttpError from pydantic import Field 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 @@ -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: router = Router() @router.get("/", response=List[AccessRequestOut]) def list_requests(request: HttpRequest, filters: AccessQuery = Query(...)): """List access requests for the user, or all if admin/operator.""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) - is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} - qs = AccessRequest.objects.order_by("-requested_at") - if is_admin: - if filters.requester_id: - qs = qs.filter(requester_id=filters.requester_id) + require_authenticated(request) + user = request.user + if _has_global_perm(request, "access.view_accessrequest"): + qs = AccessRequest.objects.all() 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: qs = qs.filter(status=filters.status) if filters.server_id: @@ -83,7 +95,9 @@ def build_router() -> Router: @router.post("/", response=AccessRequestOut) def create_request(request: HttpRequest, payload: AccessRequestCreateIn): """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: server = Server.objects.get(id=payload.server_id) except Server.DoesNotExist: @@ -103,28 +117,26 @@ def build_router() -> Router: @router.get("/{request_id}", response=AccessRequestOut) def get_request(request: HttpRequest, request_id: int): """Get an access request if permitted.""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) + require_authenticated(request) try: access_request = AccessRequest.objects.get(id=request_id) except AccessRequest.DoesNotExist: raise HttpError(404, "Not Found") - is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} - if not is_admin and access_request.requester_id != request.user.id: + if not request.user.has_perm("access.view_accessrequest", access_request): raise HttpError(403, "Forbidden") return _request_to_out(access_request) @router.patch("/{request_id}", response=AccessRequestOut) def update_request(request: HttpRequest, request_id: int, payload: AccessRequestUpdateIn): """Update request status or expiry (admin/operator or owner with restrictions).""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) + require_authenticated(request) try: access_request = AccessRequest.objects.get(id=request_id) except AccessRequest.DoesNotExist: raise HttpError(404, "Not Found") - is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} - is_owner = access_request.requester_id == request.user.id - if not is_admin and not is_owner: + if not request.user.has_perm("access.change_accessrequest", access_request): raise HttpError(403, "Forbidden") + is_admin = _has_global_perm(request, "access.change_accessrequest") if payload.status is None and payload.expires_at is None: raise HttpError(422, {"detail": "No fields provided."}) if payload.expires_at is not None: @@ -160,13 +172,12 @@ def build_router() -> Router: @router.delete("/{request_id}", response={204: None}) def delete_request(request: HttpRequest, request_id: int): """Delete an access request if permitted.""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) + require_authenticated(request) try: access_request = AccessRequest.objects.get(id=request_id) except AccessRequest.DoesNotExist: raise HttpError(404, "Not Found") - is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} - if not is_admin and access_request.requester_id != request.user.id: + if not request.user.has_perm("access.delete_accessrequest", access_request): raise HttpError(403, "Forbidden") access_request.delete() return 204, None diff --git a/app/keywarden/api/routers/agent.py b/app/keywarden/api/routers/agent.py index 2f53198..39c24db 100644 --- a/app/keywarden/api/routers/agent.py +++ b/app/keywarden/api/routers/agent.py @@ -9,7 +9,7 @@ from ninja import Router, Schema from ninja.errors import HttpError 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.keys.models import SSHKey from apps.servers.models import Server @@ -41,7 +41,12 @@ def build_router() -> Router: @router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut]) def authorized_keys(request: HttpRequest, server_id: int): """Return authorized public keys for a server (admin or operator).""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR) + require_perms( + request, + "servers.view_server", + "keys.view_sshkey", + "access.view_accessrequest", + ) try: server = Server.objects.get(id=server_id) except Server.DoesNotExist: @@ -72,7 +77,7 @@ def build_router() -> Router: @router.post("/servers/{server_id}/sync-report", response=SyncReportOut) def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn): """Record an agent sync report for a server (admin or operator).""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR) + require_perms(request, "servers.view_server", "telemetry.add_telemetryevent") try: server = Server.objects.get(id=server_id) except Server.DoesNotExist: diff --git a/app/keywarden/api/routers/audit.py b/app/keywarden/api/routers/audit.py index 7a10b4c..52c4733 100644 --- a/app/keywarden/api/routers/audit.py +++ b/app/keywarden/api/routers/audit.py @@ -8,7 +8,7 @@ from django.http import HttpRequest from ninja import Query, Router, Schema 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): id: int @@ -48,7 +48,7 @@ def build_router() -> Router: @router.get("/event-types", response=List[AuditEventTypeSchema]) def list_event_types(request: HttpRequest): """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() return [ { @@ -64,7 +64,7 @@ def build_router() -> Router: @router.get("/logs", response=List[AuditLogSchema]) def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)): """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() if filters.severity: qs = qs.filter(severity=filters.severity) diff --git a/app/keywarden/api/routers/keys.py b/app/keywarden/api/routers/keys.py index 659e4f0..b9011e1 100644 --- a/app/keywarden/api/routers/keys.py +++ b/app/keywarden/api/routers/keys.py @@ -7,11 +7,12 @@ from django.core.exceptions import ValidationError from django.db import IntegrityError from django.http import HttpRequest from django.utils import timezone +from guardian.shortcuts import get_objects_for_user from ninja import Query, Router, Schema from ninja.errors import HttpError 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 @@ -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: router = Router() @router.get("/", response=List[KeyOut]) def list_keys(request: HttpRequest, filters: KeysQuery = Query(...)): """List SSH keys for the current user, or any user if admin/operator.""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) - is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} - qs = SSHKey.objects.order_by("-created_at") - if is_admin: - if filters.user_id: - qs = qs.filter(user_id=filters.user_id) + require_authenticated(request) + user = request.user + if _has_global_perm(request, "keys.view_sshkey"): + qs = SSHKey.objects.all() 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] return [_key_to_out(key) for key in qs] @router.post("/", response=KeyOut) def create_key(request: HttpRequest, payload: KeyCreateIn): """Create an SSH public key for the current user (admin/operator can specify user_id).""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) - is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} + require_authenticated(request) + 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 if is_admin and payload.user_id: User = get_user_model() @@ -87,6 +103,8 @@ def build_router() -> Router: owner = User.objects.get(id=payload.user_id) except User.DoesNotExist: 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() if not name: raise HttpError(422, {"name": ["Name cannot be empty."]}) @@ -104,26 +122,24 @@ def build_router() -> Router: @router.get("/{key_id}", response=KeyOut) def get_key(request: HttpRequest, key_id: int): """Get a specific SSH key if permitted.""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) - is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} + require_authenticated(request) try: key = SSHKey.objects.get(id=key_id) except SSHKey.DoesNotExist: 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") return _key_to_out(key) @router.patch("/{key_id}", response=KeyOut) def update_key(request: HttpRequest, key_id: int, payload: KeyUpdateIn): """Update key name or active state if permitted.""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) - is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} + require_authenticated(request) try: key = SSHKey.objects.get(id=key_id) except SSHKey.DoesNotExist: 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") if payload.name is None and payload.is_active is None: raise HttpError(422, {"detail": "No fields provided."}) @@ -144,13 +160,12 @@ def build_router() -> Router: @router.delete("/{key_id}", response={204: None}) def delete_key(request: HttpRequest, key_id: int): """Revoke an SSH key if permitted (soft delete).""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) - is_admin = get_user_role(request.user) in {ROLE_ADMIN, ROLE_OPERATOR} + require_authenticated(request) try: key = SSHKey.objects.get(id=key_id) except SSHKey.DoesNotExist: 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") if key.is_active: key.is_active = False diff --git a/app/keywarden/api/routers/servers.py b/app/keywarden/api/routers/servers.py index 1b9a5f1..ddb5374 100644 --- a/app/keywarden/api/routers/servers.py +++ b/app/keywarden/api/routers/servers.py @@ -7,7 +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 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 @@ -41,7 +41,7 @@ def build_router() -> Router: @router.get("/", response=List[ServerOut]) def list_servers(request: HttpRequest): """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() return [ { @@ -59,7 +59,7 @@ def build_router() -> Router: @router.get("/{server_id}", response=ServerOut) def get_server(request: HttpRequest, server_id: int): """Get server details by id.""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR, ROLE_USER) + require_perms(request, "servers.view_server") try: server = Server.objects.get(id=server_id) except Server.DoesNotExist: @@ -77,7 +77,7 @@ def build_router() -> Router: @router.post("/", response=ServerOut) def create_server_json(request: HttpRequest, payload: ServerCreate): """Create a server using JSON payload (admin only).""" - require_roles(request, ROLE_ADMIN) + require_perms(request, "servers.add_server") server = Server.objects.create( display_name=payload.display_name.strip(), hostname=(payload.hostname or "").strip() or None, @@ -104,7 +104,7 @@ def build_router() -> Router: image: Optional[UploadedFile] = File(None), ): """Create a server with optional image upload (admin only).""" - require_roles(request, ROLE_ADMIN) + require_perms(request, "servers.add_server") server = Server( display_name=display_name.strip(), hostname=(hostname or "").strip() or None, @@ -127,7 +127,7 @@ def build_router() -> Router: @router.patch("/{server_id}", response=ServerOut) def update_server(request: HttpRequest, server_id: int, payload: ServerUpdate): """Update server fields (admin only).""" - require_roles(request, ROLE_ADMIN) + require_perms(request, "servers.change_server") if ( payload.display_name is None and payload.hostname is None @@ -167,7 +167,7 @@ def build_router() -> Router: @router.delete("/{server_id}", response={204: None}) def delete_server(request: HttpRequest, server_id: int): """Delete a server by id (admin only).""" - require_roles(request, ROLE_ADMIN) + require_perms(request, "servers.delete_server") try: server = Server.objects.get(id=server_id) except Server.DoesNotExist: diff --git a/app/keywarden/api/routers/telemetry.py b/app/keywarden/api/routers/telemetry.py index f333059..11cd6c5 100644 --- a/app/keywarden/api/routers/telemetry.py +++ b/app/keywarden/api/routers/telemetry.py @@ -10,7 +10,7 @@ from ninja import Query, Router, Schema from ninja.errors import HttpError 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.telemetry.models import TelemetryEvent @@ -72,7 +72,7 @@ def build_router() -> Router: @router.get("/", response=List[TelemetryOut]) def list_events(request: HttpRequest, filters: TelemetryQuery = Query(...)): """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") if filters.event_type: qs = qs.filter(event_type=filters.event_type) @@ -88,7 +88,7 @@ def build_router() -> Router: @router.post("/", response=TelemetryOut) def create_event(request: HttpRequest, payload: TelemetryCreateIn): """Create a telemetry event entry (admin or operator).""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR) + require_perms(request, "telemetry.add_telemetryevent") server = None if payload.server_id: try: @@ -116,7 +116,7 @@ def build_router() -> Router: @router.get("/summary", response=TelemetrySummaryOut) def summary(request: HttpRequest): """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( total=Count("id"), success=Count("id", filter=models.Q(success=True)), diff --git a/app/keywarden/api/routers/users.py b/app/keywarden/api/routers/users.py index 4397f0c..7660904 100644 --- a/app/keywarden/api/routers/users.py +++ b/app/keywarden/api/routers/users.py @@ -9,7 +9,7 @@ from ninja import Query, Router, Schema from ninja.errors import HttpError 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): @@ -54,7 +54,7 @@ def build_router() -> Router: @router.post("/", response=UserDetailOut) def create_user(request: HttpRequest, payload: UserCreateIn): """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() email = payload.email.strip().lower() if User.objects.filter(email__iexact=email).exists(): @@ -80,7 +80,7 @@ def build_router() -> Router: @router.get("/", response=List[UserListOut]) def list_users(request: HttpRequest, pagination: UsersQuery = Query(...)): """List users with pagination (admin or operator).""" - require_roles(request, ROLE_ADMIN, ROLE_OPERATOR) + require_perms(request, "auth.view_user") User = get_user_model() qs = User.objects.order_by("id")[pagination.offset : pagination.offset + pagination.limit] return [ @@ -96,7 +96,7 @@ def build_router() -> Router: @router.get("/{user_id}", response=UserDetailOut) def get_user(request: HttpRequest, user_id: int): """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() try: user = User.objects.get(id=user_id) @@ -112,7 +112,7 @@ def build_router() -> Router: @router.patch("/{user_id}", response=UserDetailOut) def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn): """Update user fields such as role, email, or status (admin only).""" - require_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: raise HttpError(422, {"detail": "No fields provided."}) User = get_user_model() @@ -146,7 +146,7 @@ def build_router() -> Router: @router.delete("/{user_id}", response={204: None}) def delete_user(request: HttpRequest, user_id: int): """Delete a user by id (admin only).""" - require_roles(request, ROLE_ADMIN) + require_perms(request, "auth.delete_user") User = get_user_model() try: user = User.objects.get(id=user_id) diff --git a/app/keywarden/settings/base.py b/app/keywarden/settings/base.py index fed80f9..e08337a 100644 --- a/app/keywarden/settings/base.py +++ b/app/keywarden/settings/base.py @@ -24,6 +24,7 @@ CSRF_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True INSTALLED_APPS = [ + "unfold.contrib.guardian", "unfold", # Admin UI "unfold.contrib.filters", "django.contrib.admin", @@ -32,14 +33,15 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "guardian", "rest_framework", "apps.audit", "apps.accounts", "apps.core.apps.CoreConfig", "apps.dashboard", - "apps.servers", - "apps.keys", - "apps.access", + "apps.servers.apps.ServersConfig", + "apps.keys.apps.KeysConfig", + "apps.access.apps.AccessConfig", "apps.telemetry", "ninja", # Django Ninja API "mozilla_django_oidc", # OIDC Client @@ -208,6 +210,8 @@ KEYWARDEN_AUTH_MODE = AUTH_MODE if AUTH_MODE == "oidc": AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "guardian.backends.ObjectPermissionBackend", "mozilla_django_oidc.auth.OIDCAuthenticationBackend", ] LOGIN_URL = "/oidc/authenticate/" @@ -215,6 +219,7 @@ else: # native or hybrid -> allow both, native first for precedence AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", + "guardian.backends.ObjectPermissionBackend", "mozilla_django_oidc.auth.OIDCAuthenticationBackend", ] LOGIN_URL = "/accounts/login/" @@ -222,5 +227,7 @@ LOGOUT_URL = "/oidc/logout/" LOGIN_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/" +ANONYMOUS_USER_NAME = None + def permission_callback(request): return request.user.has_perm("keywarden.change_model") diff --git a/app/static/guardian/img/icon-no.svg b/app/static/guardian/img/icon-no.svg new file mode 100644 index 0000000..667eeaa --- /dev/null +++ b/app/static/guardian/img/icon-no.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/static/guardian/img/icon-yes.svg b/app/static/guardian/img/icon-yes.svg new file mode 100644 index 0000000..ad5c373 --- /dev/null +++ b/app/static/guardian/img/icon-yes.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/requirements.txt b/requirements.txt index b628adf..704ed4d 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 +django-guardian>=2.4.0 argon2-cffi>=23.1.0 psycopg2-binary>=2.9.9 gunicorn==23.0.0