- {% 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()