diff --git a/README.md b/README.md index 791d36a..a51968f 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,11 @@ Create a superuser if needed: sudo -u pobsync pobsync-manage createsuperuser ``` +The control panel supports two access levels. Django staff users can manage hosts, SSH keys, configs, retention, +notifications, logs, and administrative actions. Normal authenticated users can view backup status pages such as the +dashboard, hosts, runs, snapshots, schedules, purged history, changelog, and `/api/status/`, but cannot see SSH +credentials or run mutating actions. + For other Django management commands on native installs, use `pobsync-manage` so the production environment file is loaded before Django starts: diff --git a/src/pobsync_backend/access.py b/src/pobsync_backend/access.py new file mode 100644 index 0000000..4f9b110 --- /dev/null +++ b/src/pobsync_backend/access.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from collections.abc import Callable +from functools import wraps +from typing import Any + +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.http import HttpRequest, HttpResponse + + +def can_view_status(user) -> bool: + return bool(user.is_authenticated) + + +def can_manage_control_panel(user) -> bool: + return bool(user.is_authenticated and user.is_staff) + + +def status_view_required(view_func: Callable[..., HttpResponse]) -> Callable[..., HttpResponse]: + return login_required(view_func) + + +def control_panel_admin_required(view_func: Callable[..., HttpResponse]) -> Callable[..., HttpResponse]: + @login_required + @wraps(view_func) + def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + if not can_manage_control_panel(request.user): + raise PermissionDenied + return view_func(request, *args, **kwargs) + + return wrapper + + +def access_context(request: HttpRequest) -> dict[str, Any]: + return { + "can_view_status": can_view_status(request.user), + "can_manage_control_panel": can_manage_control_panel(request.user), + } diff --git a/src/pobsync_backend/api.py b/src/pobsync_backend/api.py index 471342f..6a09861 100644 --- a/src/pobsync_backend/api.py +++ b/src/pobsync_backend/api.py @@ -2,16 +2,16 @@ from __future__ import annotations from typing import Any -from django.contrib.admin.views.decorators import staff_member_required from django.db import connection from django.db.models import Count from django.http import JsonResponse from django.utils import timezone from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord +from .access import control_panel_admin_required, status_view_required -@staff_member_required +@control_panel_admin_required def api_index(request) -> JsonResponse: return JsonResponse( { @@ -26,7 +26,7 @@ def api_index(request) -> JsonResponse: ) -@staff_member_required +@status_view_required def status(request) -> JsonResponse: latest_run = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at").first() latest_schedule = ScheduleConfig.objects.select_related("host").order_by("-last_started_at", "-updated_at").first() @@ -55,7 +55,7 @@ def status(request) -> JsonResponse: ) -@staff_member_required +@control_panel_admin_required def hosts(request) -> JsonResponse: host_qs = ( HostConfig.objects.annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True)) @@ -65,7 +65,7 @@ def hosts(request) -> JsonResponse: return JsonResponse({"ok": True, "hosts": [_host_payload(host) for host in host_qs]}) -@staff_member_required +@control_panel_admin_required def snapshots(request) -> JsonResponse: snapshot_qs = SnapshotRecord.objects.select_related("host", "base").order_by("host__host", "-started_at", "dirname") host_filter = request.GET.get("host") @@ -78,7 +78,7 @@ def snapshots(request) -> JsonResponse: return JsonResponse({"ok": True, "snapshots": [_snapshot_payload(snapshot) for snapshot in snapshot_qs[:limit]]}) -@staff_member_required +@control_panel_admin_required def runs(request) -> JsonResponse: run_qs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at") host_filter = request.GET.get("host") diff --git a/src/pobsync_backend/context_processors.py b/src/pobsync_backend/context_processors.py new file mode 100644 index 0000000..bb10669 --- /dev/null +++ b/src/pobsync_backend/context_processors.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from django.http import HttpRequest + +from .access import access_context + + +def pobsync_access(request: HttpRequest) -> dict[str, object]: + return access_context(request) diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 54919a9..0ff25f9 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -919,17 +919,23 @@ Dashboard Hosts - SSH Keys - Notifications - Logs + {% if can_manage_control_panel %} + SSH Keys + Notifications + Logs + {% endif %} Purged - Self Check + {% if can_manage_control_panel %} + Self Check + {% endif %} Changelog Status API - Admin + {% if can_manage_control_panel %} + Admin + {% endif %} {{ request.user.username }} diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index 8393f9d..605c41f 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -9,27 +9,31 @@

Dashboard

Backup health, required action, storage pressure, and recent activity in one place.
-
- New host - {% if global_config %}Edit global config{% else %}Create global config{% endif %} -
+ {% if can_manage_control_panel %} +
+ New host + {% if global_config %}Edit global config{% else %}Create global config{% endif %} +
+ {% endif %} - {% if not global_config or not counts.hosts %} -
-

Setup

- {% if not global_config %} -

No default global config exists yet. Create it first so discovery, backups, and retention know where pobsync stores snapshots.

-
- Create global config -
- {% elif not counts.hosts %} -

Global config is ready. Add the first host to make this dashboard useful.

-
- Add first host -
- {% endif %} -
+ {% if can_manage_control_panel %} + {% if not global_config or not counts.hosts %} +
+

Setup

+ {% if not global_config %} +

No default global config exists yet. Create it first so discovery, backups, and retention know where pobsync stores snapshots.

+
+ Create global config +
+ {% elif not counts.hosts %} +

Global config is ready. Add the first host to make this dashboard useful.

+
+ Add first host +
+ {% endif %} +
+ {% endif %} {% endif %}
-
- {% csrf_token %} - -
+ {% if can_manage_control_panel %} +
+ {% csrf_token %} + +
+ {% endif %} {% endif %} {% if retention_warning.error %}
{{ retention_warning.error }}
@@ -80,8 +82,9 @@
-
-

Backup Control

+ {% if can_manage_control_panel %} +
+

Backup Control

{% if active_run %} {{ active_run.status }} @@ -121,10 +124,16 @@

Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.

{% endif %} {% endif %} -
+
+ {% endif %}
-

Schedule Edit schedule

+

+ Schedule + {% if can_manage_control_panel %} + Edit schedule + {% endif %} +

{% if schedule %}
Schedule expression{{ schedule.cron_expr }}
@@ -136,7 +145,9 @@

Evaluated by the pobsync scheduler service.

{% else %}

No schedule configured.

- Add schedule + {% if can_manage_control_panel %} + Add schedule + {% endif %} {% endif %}
@@ -252,8 +263,9 @@ {% endif %} -
-

Host Check

+ {% if can_manage_control_panel %} +
+

Host Check

OK
{{ host_check_summary.ok }}
Warnings
{{ host_check_summary.warning }}
@@ -279,26 +291,32 @@ {% endfor %} -
+
+ {% endif %}

Configuration

Address{{ host.address }}
-
SSH key{{ host.ssh_credential|default:"global default" }}
-
SSH{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}
+ {% if can_manage_control_panel %} +
SSH key{{ host.ssh_credential|default:"global default" }}
+
SSH{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}
+ {% endif %}
Backup source{{ host.source_root|default:"global default" }}
Retentiond{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}
- + {% if can_manage_control_panel %} + + {% endif %}
-
-

Connection Preflight & SSH

+ {% if can_manage_control_panel %} +
+

Connection Preflight & SSH

{% if last_preflight %}
@@ -341,7 +359,8 @@ {% endfor %}
{% endif %} -
+
+ {% endif %}

Snapshot Storage

@@ -360,16 +379,18 @@
{% endif %} -
-
- {% csrf_token %} - -
-
- {% csrf_token %} - -
-
+ {% if can_manage_control_panel %} +
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+ {% endif %}
@@ -451,8 +472,9 @@ {% endif %} -
-

Backup Options

+ {% if can_manage_control_panel %} +
+

Backup Options

Use this when the quick actions above need a custom label, include/exclude override, or prune limit.

{% csrf_token %} @@ -471,7 +493,8 @@
-
+
+ {% endif %}

Latest Runs View all

diff --git a/src/pobsync_backend/templates/pobsync_backend/hosts_list.html b/src/pobsync_backend/templates/pobsync_backend/hosts_list.html index 15d7d33..0bb38b6 100644 --- a/src/pobsync_backend/templates/pobsync_backend/hosts_list.html +++ b/src/pobsync_backend/templates/pobsync_backend/hosts_list.html @@ -9,9 +9,11 @@

Hosts

Configured backup targets, schedules, retention state, and host-level controls.
-
- New host -
+ {% if can_manage_control_panel %} +
+ New host +
+ {% endif %}
diff --git a/src/pobsync_backend/templates/pobsync_backend/partials/run_detail_live.html b/src/pobsync_backend/templates/pobsync_backend/partials/run_detail_live.html index d831dbd..7671d32 100644 --- a/src/pobsync_backend/templates/pobsync_backend/partials/run_detail_live.html +++ b/src/pobsync_backend/templates/pobsync_backend/partials/run_detail_live.html @@ -32,7 +32,7 @@
{% endif %} -{% if run.status == "failed" or run.status == "warning" %} +{% if can_manage_control_panel and run.status == "failed" or can_manage_control_panel and run.status == "warning" %} {% if not run.reviewed_at %}

Review Required

@@ -78,7 +78,7 @@ {% endif %}
Log: - {% if dry_run_summary.log_available %} + {% if dry_run_summary.log_available and can_manage_control_panel %} Open full rsync log {% elif rsync_log_path %} {{ rsync_log_path }} (missing) @@ -155,7 +155,7 @@ {% if live_progress.log.path %}
Log: - {% if live_progress.log.exists %} + {% if live_progress.log.exists and can_manage_control_panel %} Open full rsync log {% else %} {{ live_progress.log.path }} (missing) @@ -189,7 +189,7 @@
Base: {{ run.base_path|default:"" }}
Rsync log: - {% if rsync_log_exists %} + {% if rsync_log_exists and can_manage_control_panel %} {{ rsync_log_path }} {% elif rsync_log_path %} {{ rsync_log_path }} (missing) @@ -204,7 +204,7 @@

Rsync Log

- {% if rsync_log_exists %} + {% if rsync_log_exists and can_manage_control_panel %}
{{ rsync_log_path }}
{% elif rsync_log_path %} diff --git a/src/pobsync_backend/tests/test_api.py b/src/pobsync_backend/tests/test_api.py index b7bcd83..74a252b 100644 --- a/src/pobsync_backend/tests/test_api.py +++ b/src/pobsync_backend/tests/test_api.py @@ -18,6 +18,12 @@ class ApiTests(TestCase): is_staff=True, is_superuser=True, ) + self.readonly_user = user_model.objects.create_user( + username="viewer", + password="secret", + is_staff=False, + is_superuser=False, + ) def test_api_requires_staff_login(self) -> None: response = self.client.get("/api/hosts/") @@ -25,6 +31,15 @@ class ApiTests(TestCase): self.assertEqual(response.status_code, 302) self.assertIn("/admin/login/", response["Location"]) + def test_readonly_user_can_access_status_endpoint_only(self) -> None: + self.client.force_login(self.readonly_user) + + status_response = self.client.get("/api/status/") + hosts_response = self.client.get("/api/hosts/") + + self.assertEqual(status_response.status_code, 200) + self.assertEqual(hosts_response.status_code, 403) + def test_hosts_endpoint_returns_counts_and_schedule(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create(host="web-01", address="web-01.example.test") diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 7afb6f4..cf7d83b 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -36,6 +36,12 @@ class ViewTests(TestCase): is_staff=True, is_superuser=True, ) + self.readonly_user = user_model.objects.create_user( + username="viewer", + password="secret", + is_staff=False, + is_superuser=False, + ) def test_dashboard_requires_staff_login(self) -> None: response = self.client.get(reverse("dashboard")) @@ -63,6 +69,22 @@ class ViewTests(TestCase): self.assertContains(response, reverse("admin:index")) self.assertContains(response, 'Dashboard', html=False) + def test_readonly_navigation_hides_admin_and_sensitive_links(self) -> None: + self.client.force_login(self.readonly_user) + + response = self.client.get(reverse("dashboard")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, reverse("dashboard")) + self.assertContains(response, reverse("hosts_list")) + self.assertContains(response, reverse("changelog")) + self.assertContains(response, "/api/status/") + self.assertNotContains(response, reverse("ssh_credentials")) + self.assertNotContains(response, reverse("notification_targets")) + self.assertNotContains(response, reverse("logs")) + self.assertNotContains(response, reverse("self_check")) + self.assertNotContains(response, reverse("admin:index")) + def test_base_navigation_marks_current_secondary_page(self) -> None: self.client.force_login(self.staff_user) @@ -71,12 +93,81 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, f'Self Check', html=False) - def test_changelog_requires_staff_login(self) -> None: + def test_changelog_requires_login(self) -> None: response = self.client.get(reverse("changelog")) self.assertEqual(response.status_code, 302) self.assertIn("/admin/login/", response["Location"]) + def test_readonly_user_can_view_status_pages(self) -> None: + self.client.force_login(self.readonly_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + snapshot = SnapshotRecord.objects.create( + host=host, + kind=SnapshotRecord.Kind.SCHEDULED, + dirname="20260519-021500Z__ABCDEFGH", + path="/backups/web-01/scheduled/20260519-021500Z__ABCDEFGH", + status="success", + ) + run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=snapshot) + + urls = [ + reverse("dashboard"), + reverse("hosts_list"), + reverse("host_detail", args=[host.host]), + reverse("runs_list"), + reverse("run_detail", args=[run.id]), + reverse("snapshots_list"), + reverse("snapshot_detail", args=[snapshot.id]), + reverse("schedules_list"), + reverse("purged_snapshots"), + ] + + for url in urls: + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_readonly_user_cannot_access_sensitive_or_mutating_views(self) -> None: + self.client.force_login(self.readonly_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + + blocked_urls = [ + reverse("ssh_credentials"), + reverse("logs"), + reverse("self_check"), + reverse("edit_global_config"), + reverse("create_host_config"), + reverse("edit_host_config", args=[host.host]), + reverse("edit_host_schedule", args=[host.host]), + ] + + for url in blocked_urls: + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_readonly_host_detail_hides_backup_controls_and_sensitive_config(self) -> None: + self.client.force_login(self.readonly_user) + GlobalConfig.objects.create(name="default", backup_root="/backups") + credential = SshCredential.objects.create(name="root-key", key_path="/var/lib/pobsync/state/root-key") + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + ssh_credential=credential, + ssh_user="root", + ) + + response = self.client.get(reverse("host_detail", args=[host.host])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Host Status") + self.assertNotContains(response, "Backup Control") + self.assertNotContains(response, "Backup Options") + self.assertNotContains(response, "Connection Preflight") + self.assertNotContains(response, "root-key") + self.assertNotContains(response, reverse("queue_manual_backup", args=[host.host])) + def test_changelog_renders_repository_changelog(self) -> None: self.client.force_login(self.staff_user) with TemporaryDirectory() as tmp: diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 52b2cd7..edaf30e 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -10,7 +10,6 @@ from pathlib import Path from urllib.parse import urlencode from django.contrib import messages -from django.contrib.admin.views.decorators import staff_member_required from django.conf import settings from django.http import FileResponse, Http404 from django.db.models import Count, Q @@ -24,6 +23,7 @@ from pobsync import __version__ from pobsync.errors import PobsyncError from pobsync.config.defaults import DEFAULT_EXCLUDES, DEFAULT_RSYNC_ARGS +from .access import access_context, control_panel_admin_required, status_view_required from .backup_runner import queue_backup_run from .config_checks import collect_effective_host_config_checks, collect_global_config_checks from .forms import ( @@ -59,19 +59,19 @@ from .ssh_keys import SshKeyError, delete_generated_key_files, generate_ssh_key, from .stats_summary import collect_dashboard_stats, collect_host_stats -@staff_member_required +@status_view_required def dashboard(request): - return render(request, "pobsync_backend/dashboard.html", _dashboard_context()) + return render(request, "pobsync_backend/dashboard.html", _dashboard_context(request)) -@staff_member_required +@status_view_required def dashboard_priority_live(request): - return render(request, "pobsync_backend/partials/dashboard_priority.html", _dashboard_context()) + return render(request, "pobsync_backend/partials/dashboard_priority.html", _dashboard_context(request)) -@staff_member_required +@status_view_required def dashboard_hosts_live(request): - return render(request, "pobsync_backend/partials/dashboard_hosts.html", _dashboard_context()) + return render(request, "pobsync_backend/partials/dashboard_hosts.html", _dashboard_context(request)) def _host_cards_context(*, enabled: str = "") -> dict[str, object]: @@ -120,7 +120,7 @@ def _host_cards_context(*, enabled: str = "") -> dict[str, object]: } -def _dashboard_context() -> dict[str, object]: +def _dashboard_context(request) -> dict[str, object]: global_config = GlobalConfig.objects.filter(name="default").first() host_context = _host_cards_context() hosts = host_context["hosts"] @@ -129,6 +129,7 @@ def _dashboard_context() -> dict[str, object]: recent_runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")[:6] stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config) context = { + **access_context(request), "hosts": hosts, "global_config": global_config, "stats_summary": stats_summary, @@ -159,7 +160,7 @@ def _dashboard_context() -> dict[str, object]: return context -@staff_member_required +@status_view_required def hosts_list(request): enabled = request.GET.get("enabled", "").strip() if enabled not in {"", "yes", "no"}: @@ -171,15 +172,16 @@ def hosts_list(request): request, "pobsync_backend/hosts_list.html", { + **access_context(request), **context, "global_config": global_config, - "show_host_controls": True, + "show_host_controls": request.user.is_staff, "total_count": HostConfig.objects.count(), }, ) -@staff_member_required +@control_panel_admin_required @require_POST def update_host_state(request, host: str): host_config = get_object_or_404(HostConfig, host=host) @@ -290,7 +292,7 @@ def _retention_warning_summary(retention_warning) -> str: return " ".join(parts) -@staff_member_required +@status_view_required def changelog(request): changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md" try: @@ -312,7 +314,7 @@ def changelog(request): ) -@staff_member_required +@control_panel_admin_required def self_check(request): checks = collect_self_checks() return render( @@ -325,13 +327,13 @@ def self_check(request): ) -@staff_member_required +@control_panel_admin_required def logs(request): context = _log_context(request) return render(request, "pobsync_backend/logs.html", context) -@staff_member_required +@control_panel_admin_required def notification_targets(request): targets = NotificationTarget.objects.order_by("name") deliveries = NotificationDelivery.objects.select_related("target", "run", "run__host").order_by("-created_at")[:12] @@ -345,7 +347,7 @@ def notification_targets(request): ) -@staff_member_required +@control_panel_admin_required def create_notification_target(request): if request.method == "POST": form = NotificationTargetForm(request.POST) @@ -367,7 +369,7 @@ def create_notification_target(request): ) -@staff_member_required +@control_panel_admin_required def edit_notification_target(request, target_id: int): target = get_object_or_404(NotificationTarget, id=target_id) if request.method == "POST": @@ -390,7 +392,7 @@ def edit_notification_target(request, target_id: int): ) -@staff_member_required +@status_view_required def runs_list(request): status = request.GET.get("status", "").strip() run_type = request.GET.get("type", "").strip() @@ -422,7 +424,7 @@ def runs_list(request): return render(request, "pobsync_backend/runs_list.html", context) -@staff_member_required +@status_view_required def snapshots_list(request): kind = request.GET.get("kind", "").strip() status = request.GET.get("status", "").strip() @@ -448,7 +450,7 @@ def snapshots_list(request): return render(request, "pobsync_backend/snapshots_list.html", context) -@staff_member_required +@status_view_required def schedules_list(request): enabled = request.GET.get("enabled", "").strip() prune = request.GET.get("prune", "").strip() @@ -486,7 +488,7 @@ def schedules_list(request): return render(request, "pobsync_backend/schedules_list.html", context) -@staff_member_required +@status_view_required def purged_snapshots(request): host = request.GET.get("host", "").strip() action = request.GET.get("action", "").strip() @@ -507,7 +509,7 @@ def purged_snapshots(request): return render(request, "pobsync_backend/purged_snapshots.html", context) -@staff_member_required +@control_panel_admin_required def ssh_credentials(request): context = { "credentials": SshCredential.objects.order_by("name"), @@ -515,7 +517,7 @@ def ssh_credentials(request): return render(request, "pobsync_backend/ssh_credentials.html", context) -@staff_member_required +@control_panel_admin_required def create_ssh_credential(request): if request.method == "POST": form = SshCredentialForm(request.POST, request.FILES) @@ -536,7 +538,7 @@ def create_ssh_credential(request): ) -@staff_member_required +@control_panel_admin_required def generate_ssh_credential(request): if request.method == "POST": form = SshCredentialGenerateForm(request.POST) @@ -572,7 +574,7 @@ def generate_ssh_credential(request): ) -@staff_member_required +@control_panel_admin_required def edit_ssh_credential(request, credential_id: int): credential = get_object_or_404(SshCredential, id=credential_id) if request.method == "POST": @@ -594,7 +596,7 @@ def edit_ssh_credential(request, credential_id: int): ) -@staff_member_required +@control_panel_admin_required @require_POST def delete_ssh_credential(request, credential_id: int): credential = get_object_or_404(SshCredential, id=credential_id) @@ -618,7 +620,7 @@ def delete_ssh_credential(request, credential_id: int): return redirect("ssh_credentials") -@staff_member_required +@control_panel_admin_required def edit_global_config(request): global_config = GlobalConfig.objects.filter(name="default").first() if request.method == "POST": @@ -644,7 +646,7 @@ def edit_global_config(request): ) -@staff_member_required +@control_panel_admin_required def create_host_config(request): if request.method == "POST": form = CreateHostConfigForm(request.POST) @@ -672,7 +674,7 @@ def create_host_config(request): ) -@staff_member_required +@status_view_required def host_detail(request, host: str): host_config = get_object_or_404(HostConfig, host=host) global_config = GlobalConfig.objects.filter(name="default").first() @@ -685,7 +687,9 @@ def host_detail(request, host: str): has_global_config = global_config is not None backup_gate = collect_backup_gate(host_config, global_config) stats_summary = collect_host_stats(host=host_config, limit=10) + can_manage = request.user.is_staff context = { + **access_context(request), "host": host_config, "schedule": schedule, "retention_warning": _retention_warning_for_host(host_config, schedule), @@ -696,11 +700,11 @@ def host_detail(request, host: str): "host_check_summary": summarize_self_checks(backup_gate.checks), "backup_gate": backup_gate, "last_preflight": (host_config.config or {}).get("last_preflight") if isinstance(host_config.config, dict) else {}, - "effective_config": effective_host_config_preview(host_config, global_config) if global_config else {}, + "effective_config": effective_host_config_preview(host_config, global_config) if global_config and can_manage else {}, "stats_summary": stats_summary, "manual_backup_form": ManualBackupForm(initial=_default_manual_backup_initial(host_config)), - "can_queue_dry_run": host_config.enabled and has_global_config and backup_gate.can_queue_dry_run and active_run is None, - "can_queue_real_backup": host_config.enabled and has_global_config and backup_gate.can_queue_real and active_run is None, + "can_queue_dry_run": can_manage and host_config.enabled and has_global_config and backup_gate.can_queue_dry_run and active_run is None, + "can_queue_real_backup": can_manage and host_config.enabled and has_global_config and backup_gate.can_queue_real and active_run is None, "has_global_config": has_global_config, "active_run": active_run, "latest_runs": host_config.runs.select_related("snapshot").order_by("-created_at")[:10], @@ -720,7 +724,7 @@ def host_detail(request, host: str): return render(request, "pobsync_backend/host_detail.html", context) -@staff_member_required +@control_panel_admin_required @require_POST def prepare_host_directories(request, host: str): host_config = get_object_or_404(HostConfig, host=host) @@ -733,7 +737,7 @@ def prepare_host_directories(request, host: str): return redirect("host_detail", host=host_config.host) -@staff_member_required +@control_panel_admin_required @require_POST def scan_host_known_key(request, host: str): host_config = get_object_or_404(HostConfig, host=host) @@ -755,7 +759,7 @@ def scan_host_known_key(request, host: str): return redirect("host_detail", host=host_config.host) -@staff_member_required +@control_panel_admin_required @require_POST def run_host_preflight(request, host: str): host_config = get_object_or_404(HostConfig, host=host) @@ -783,7 +787,7 @@ def run_host_preflight(request, host: str): return redirect("host_detail", host=host_config.host) -@staff_member_required +@control_panel_admin_required @require_POST def queue_manual_backup(request, host: str): host_config = get_object_or_404(HostConfig, host=host) @@ -823,22 +827,22 @@ def queue_manual_backup(request, host: str): return redirect("run_detail", run_id=run.id) -@staff_member_required +@status_view_required def run_detail(request, run_id: int): run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) - return render(request, "pobsync_backend/run_detail.html", _run_detail_context(run)) + return render(request, "pobsync_backend/run_detail.html", _run_detail_context(run, request=request)) -@staff_member_required +@status_view_required def run_detail_live(request, run_id: int): run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) - context = _run_detail_context(run) + context = _run_detail_context(run, request=request) response = render(request, "pobsync_backend/partials/run_detail_live.html", context) response["X-Pobsync-Refresh-Active"] = "true" if context["can_auto_refresh"] else "false" return response -def _run_detail_context(run: BackupRun) -> dict[str, object]: +def _run_detail_context(run: BackupRun, *, request=None) -> dict[str, object]: result = run.result if isinstance(run.result, dict) else {} run_stats = result.get("stats") if isinstance(result.get("stats"), dict) else {} rsync_result = result.get("rsync") if isinstance(result.get("rsync"), dict) else {} @@ -848,8 +852,10 @@ def _run_detail_context(run: BackupRun) -> dict[str, object]: rsync_log_path = _run_rsync_log_path(run) rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path) requested = result.get("requested") if isinstance(result.get("requested"), dict) else {} - can_cancel = run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING} + can_manage = bool(request and request.user.is_staff) + can_cancel = can_manage and run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING} return { + **(access_context(request) if request is not None else {}), "run": run, "can_cancel": can_cancel, "can_auto_refresh": can_cancel, @@ -880,7 +886,7 @@ def _run_detail_context(run: BackupRun) -> dict[str, object]: } -@staff_member_required +@control_panel_admin_required def run_rsync_log(request, run_id: int): run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) log_path = _run_rsync_log_path(run) @@ -889,7 +895,7 @@ def run_rsync_log(request, run_id: int): return FileResponse(log_path.open("rb"), content_type="text/plain; charset=utf-8") -@staff_member_required +@control_panel_admin_required @require_POST def cancel_run(request, run_id: int): run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id) @@ -913,7 +919,7 @@ def cancel_run(request, run_id: int): return redirect("run_detail", run_id=run.id) -@staff_member_required +@control_panel_admin_required @require_POST def resolve_run_review(request, run_id: int): run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id) @@ -931,7 +937,7 @@ def resolve_run_review(request, run_id: int): return _redirect_after_run_review(request, run) -@staff_member_required +@control_panel_admin_required @require_POST def resolve_host_incomplete_reviews(request, host: str): host_config = get_object_or_404(HostConfig, host=host) @@ -947,7 +953,7 @@ def resolve_host_incomplete_reviews(request, host: str): return redirect("host_detail", host=host_config.host) -@staff_member_required +@status_view_required def snapshot_detail(request, snapshot_id: int): snapshot = get_object_or_404( SnapshotRecord.objects.select_related("host", "base").prefetch_related("derived_snapshots", "backup_runs"), @@ -965,7 +971,7 @@ def snapshot_detail(request, snapshot_id: int): return render(request, "pobsync_backend/snapshot_detail.html", context) -@staff_member_required +@control_panel_admin_required @require_POST def discover_host_snapshots(request, host: str): host_config = get_object_or_404(HostConfig, host=host) @@ -986,7 +992,7 @@ def discover_host_snapshots(request, host: str): return redirect("host_detail", host=host_config.host) -@staff_member_required +@control_panel_admin_required def host_retention_plan(request, host: str): host_config = get_object_or_404(HostConfig, host=host) kind = request.GET.get("kind", "scheduled") @@ -1037,7 +1043,7 @@ def host_retention_plan(request, host: str): return render(request, "pobsync_backend/retention_plan.html", context) -@staff_member_required +@control_panel_admin_required @require_POST def apply_host_retention(request, host: str): host_config = get_object_or_404(HostConfig, host=host) @@ -1091,7 +1097,7 @@ def apply_host_retention(request, host: str): return target -@staff_member_required +@control_panel_admin_required @require_POST def cleanup_host_incomplete_snapshots(request, host: str): host_config = get_object_or_404(HostConfig, host=host) @@ -1126,7 +1132,7 @@ def cleanup_host_incomplete_snapshots(request, host: str): return redirect("host_retention_plan", host=host_config.host) -@staff_member_required +@control_panel_admin_required def edit_host_config(request, host: str): host_config = get_object_or_404(HostConfig, host=host) global_config = GlobalConfig.objects.filter(name="default").first() @@ -1152,7 +1158,7 @@ def edit_host_config(request, host: str): ) -@staff_member_required +@control_panel_admin_required def edit_host_schedule(request, host: str): host_config = get_object_or_404(HostConfig, host=host) schedule = _schedule_for_host(host_config) diff --git a/src/pobsync_server/settings.py b/src/pobsync_server/settings.py index b000ab9..b58632e 100644 --- a/src/pobsync_server/settings.py +++ b/src/pobsync_server/settings.py @@ -48,6 +48,7 @@ TEMPLATES = [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "pobsync_backend.context_processors.pobsync_access", ], }, }, @@ -55,6 +56,8 @@ TEMPLATES = [ WSGI_APPLICATION = "pobsync_server.wsgi.application" +LOGIN_URL = "/admin/login/" + def _database_config() -> dict[str, object]: engine = os.getenv("POBSYNC_DB_ENGINE", "sqlite").strip().lower()