Initial django guardian integrations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
23
app/apps/access/signals.py
Normal file
23
app/apps/access/signals.py
Normal 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)
|
||||
@@ -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()
|
||||
|
||||
51
app/apps/core/management/commands/sync_object_perms.py
Normal file
51
app/apps/core/management/commands/sync_object_perms.py
Normal 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."
|
||||
)
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
19
app/apps/keys/signals.py
Normal file
19
app/apps/keys/signals.py
Normal 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)
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
14
app/apps/servers/signals.py
Normal file
14
app/apps/servers/signals.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
6
app/static/guardian/img/icon-no.svg
Normal file
6
app/static/guardian/img/icon-no.svg
Normal 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 |
6
app/static/guardian/img/icon-yes.svg
Normal file
6
app/static/guardian/img/icon-yes.svg
Normal 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 |
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user