2 Commits

Author SHA1 Message Date
b4833560b5 Merge pull request '(feature) Add read-only access level to control panel' (#72) from issue-40-access-levels into master
Reviewed-on: #72
2026-05-28 22:00:45 +02:00
81ee848f5f (feature) Add read-only access level to control panel
Introduce a central access policy that lets authenticated non-staff users view
backup status pages while keeping credentials, logs, configs, and mutating
actions staff-only.

Hide sensitive navigation and host controls for read-only users, expose only
the status API to authenticated viewers, and document the two access levels.
2026-05-28 22:00:16 +02:00
13 changed files with 329 additions and 126 deletions

View File

@@ -131,6 +131,11 @@ Create a superuser if needed:
sudo -u pobsync pobsync-manage createsuperuser 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 For other Django management commands on native installs, use `pobsync-manage` so the production environment file is
loaded before Django starts: loaded before Django starts:

View File

@@ -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),
}

View File

@@ -2,16 +2,16 @@ from __future__ import annotations
from typing import Any from typing import Any
from django.contrib.admin.views.decorators import staff_member_required
from django.db import connection from django.db import connection
from django.db.models import Count from django.db.models import Count
from django.http import JsonResponse from django.http import JsonResponse
from django.utils import timezone from django.utils import timezone
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord 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: def api_index(request) -> JsonResponse:
return JsonResponse( return JsonResponse(
{ {
@@ -26,7 +26,7 @@ def api_index(request) -> JsonResponse:
) )
@staff_member_required @status_view_required
def status(request) -> JsonResponse: def status(request) -> JsonResponse:
latest_run = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at").first() 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() 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: def hosts(request) -> JsonResponse:
host_qs = ( host_qs = (
HostConfig.objects.annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True)) 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]}) return JsonResponse({"ok": True, "hosts": [_host_payload(host) for host in host_qs]})
@staff_member_required @control_panel_admin_required
def snapshots(request) -> JsonResponse: def snapshots(request) -> JsonResponse:
snapshot_qs = SnapshotRecord.objects.select_related("host", "base").order_by("host__host", "-started_at", "dirname") snapshot_qs = SnapshotRecord.objects.select_related("host", "base").order_by("host__host", "-started_at", "dirname")
host_filter = request.GET.get("host") 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]]}) 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: def runs(request) -> JsonResponse:
run_qs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at") run_qs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")
host_filter = request.GET.get("host") host_filter = request.GET.get("host")

View File

@@ -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)

View File

@@ -919,17 +919,23 @@
<span class="nav-primary" aria-label="Primary navigation"> <span class="nav-primary" aria-label="Primary navigation">
<a href="{% url 'dashboard' %}" {% if request.resolver_match.url_name == "dashboard" %}aria-current="page"{% endif %}>Dashboard</a> <a href="{% url 'dashboard' %}" {% if request.resolver_match.url_name == "dashboard" %}aria-current="page"{% endif %}>Dashboard</a>
<a href="{% url 'hosts_list' %}" {% if request.resolver_match.url_name == "hosts_list" or request.resolver_match.url_name == "host_detail" or request.resolver_match.url_name == "create_host_config" or request.resolver_match.url_name == "edit_host_config" or request.resolver_match.url_name == "edit_host_schedule" %}aria-current="page"{% endif %}>Hosts</a> <a href="{% url 'hosts_list' %}" {% if request.resolver_match.url_name == "hosts_list" or request.resolver_match.url_name == "host_detail" or request.resolver_match.url_name == "create_host_config" or request.resolver_match.url_name == "edit_host_config" or request.resolver_match.url_name == "edit_host_schedule" %}aria-current="page"{% endif %}>Hosts</a>
{% if can_manage_control_panel %}
<a href="{% url 'ssh_credentials' %}" {% if request.resolver_match.url_name == "ssh_credentials" or request.resolver_match.url_name == "create_ssh_credential" or request.resolver_match.url_name == "generate_ssh_credential" or request.resolver_match.url_name == "edit_ssh_credential" %}aria-current="page"{% endif %}>SSH Keys</a> <a href="{% url 'ssh_credentials' %}" {% if request.resolver_match.url_name == "ssh_credentials" or request.resolver_match.url_name == "create_ssh_credential" or request.resolver_match.url_name == "generate_ssh_credential" or request.resolver_match.url_name == "edit_ssh_credential" %}aria-current="page"{% endif %}>SSH Keys</a>
<a href="{% url 'notification_targets' %}" {% if request.resolver_match.url_name == "notification_targets" or request.resolver_match.url_name == "create_notification_target" or request.resolver_match.url_name == "edit_notification_target" %}aria-current="page"{% endif %}>Notifications</a> <a href="{% url 'notification_targets' %}" {% if request.resolver_match.url_name == "notification_targets" or request.resolver_match.url_name == "create_notification_target" or request.resolver_match.url_name == "edit_notification_target" %}aria-current="page"{% endif %}>Notifications</a>
<a href="{% url 'logs' %}" {% if request.resolver_match.url_name == "logs" %}aria-current="page"{% endif %}>Logs</a> <a href="{% url 'logs' %}" {% if request.resolver_match.url_name == "logs" %}aria-current="page"{% endif %}>Logs</a>
{% endif %}
<a href="{% url 'purged_snapshots' %}" {% if request.resolver_match.url_name == "purged_snapshots" %}aria-current="page"{% endif %}>Purged</a> <a href="{% url 'purged_snapshots' %}" {% if request.resolver_match.url_name == "purged_snapshots" %}aria-current="page"{% endif %}>Purged</a>
</span> </span>
<span class="spacer"></span> <span class="spacer"></span>
<span class="nav-secondary" aria-label="System navigation"> <span class="nav-secondary" aria-label="System navigation">
{% if can_manage_control_panel %}
<a href="{% url 'self_check' %}" {% if request.resolver_match.url_name == "self_check" %}aria-current="page"{% endif %}>Self Check</a> <a href="{% url 'self_check' %}" {% if request.resolver_match.url_name == "self_check" %}aria-current="page"{% endif %}>Self Check</a>
{% endif %}
<a href="{% url 'changelog' %}" {% if request.resolver_match.url_name == "changelog" %}aria-current="page"{% endif %}>Changelog</a> <a href="{% url 'changelog' %}" {% if request.resolver_match.url_name == "changelog" %}aria-current="page"{% endif %}>Changelog</a>
<a href="/api/status/">Status API</a> <a href="/api/status/">Status API</a>
{% if can_manage_control_panel %}
<a href="{% url 'admin:index' %}">Admin</a> <a href="{% url 'admin:index' %}">Admin</a>
{% endif %}
</span> </span>
<span class="muted nav-user">{{ request.user.username }}</span> <span class="muted nav-user">{{ request.user.username }}</span>
</nav> </nav>

View File

@@ -9,12 +9,15 @@
<h1>Dashboard</h1> <h1>Dashboard</h1>
<div class="page-subtitle">Backup health, required action, storage pressure, and recent activity in one place.</div> <div class="page-subtitle">Backup health, required action, storage pressure, and recent activity in one place.</div>
</div> </div>
{% if can_manage_control_panel %}
<section class="actions" aria-label="Dashboard actions"> <section class="actions" aria-label="Dashboard actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a> <a class="button-link" href="{% url 'create_host_config' %}">New host</a>
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a> <a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
</section> </section>
{% endif %}
</header> </header>
{% if can_manage_control_panel %}
{% if not global_config or not counts.hosts %} {% if not global_config or not counts.hosts %}
<section class="panel"> <section class="panel">
<h2>Setup</h2> <h2>Setup</h2>
@@ -31,6 +34,7 @@
{% endif %} {% endif %}
</section> </section>
{% endif %} {% endif %}
{% endif %}
<div <div
data-refresh-url="{% url 'dashboard_priority_live' %}" data-refresh-url="{% url 'dashboard_priority_live' %}"

View File

@@ -27,11 +27,13 @@
{{ retention_warning.incomplete_count }} incomplete snapshot(s) exist. Retention does not delete incomplete {{ retention_warning.incomplete_count }} incomplete snapshot(s) exist. Retention does not delete incomplete
snapshots automatically; inspect them before cleanup. snapshots automatically; inspect them before cleanup.
</div> </div>
{% if can_manage_control_panel %}
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}"> <form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="secondary">Mark incomplete reviewed</button> <button type="submit" class="secondary">Mark incomplete reviewed</button>
</form> </form>
{% endif %} {% endif %}
{% endif %}
{% if retention_warning.error %} {% if retention_warning.error %}
<div>{{ retention_warning.error }}</div> <div>{{ retention_warning.error }}</div>
{% endif %} {% endif %}
@@ -80,6 +82,7 @@
</div> </div>
</article> </article>
{% if can_manage_control_panel %}
<article class="panel host-control-panel"> <article class="panel host-control-panel">
<h2>Backup Control</h2> <h2>Backup Control</h2>
<div class="operator-state"> <div class="operator-state">
@@ -122,9 +125,15 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</article> </article>
{% endif %}
<article class="panel host-control-panel"> <article class="panel host-control-panel">
<h2>Schedule <a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a></h2> <h2>
Schedule
{% if can_manage_control_panel %}
<a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a>
{% endif %}
</h2>
{% if schedule %} {% if schedule %}
<div class="host-control-meta"> <div class="host-control-meta">
<div><span class="label">Schedule expression</span><strong>{{ schedule.cron_expr }}</strong></div> <div><span class="label">Schedule expression</span><strong>{{ schedule.cron_expr }}</strong></div>
@@ -136,8 +145,10 @@
<p class="muted">Evaluated by the pobsync scheduler service.</p> <p class="muted">Evaluated by the pobsync scheduler service.</p>
{% else %} {% else %}
<p class="muted">No schedule configured.</p> <p class="muted">No schedule configured.</p>
{% if can_manage_control_panel %}
<a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Add schedule</a> <a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Add schedule</a>
{% endif %} {% endif %}
{% endif %}
</article> </article>
<article class="panel host-control-panel"> <article class="panel host-control-panel">
@@ -252,6 +263,7 @@
</section> </section>
{% endif %} {% endif %}
{% if can_manage_control_panel %}
<section class="panel"> <section class="panel">
<h2>Host Check</h2> <h2>Host Check</h2>
<section class="grid" aria-label="Host check summary"> <section class="grid" aria-label="Host check summary">
@@ -280,23 +292,29 @@
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
{% endif %}
<div class="panel-grid"> <div class="panel-grid">
<section class="panel"> <section class="panel">
<h2>Configuration</h2> <h2>Configuration</h2>
<div class="host-control-meta"> <div class="host-control-meta">
<div><span class="label">Address</span><strong>{{ host.address }}</strong></div> <div><span class="label">Address</span><strong>{{ host.address }}</strong></div>
{% if can_manage_control_panel %}
<div><span class="label">SSH key</span><strong>{{ host.ssh_credential|default:"global default" }}</strong></div> <div><span class="label">SSH key</span><strong>{{ host.ssh_credential|default:"global default" }}</strong></div>
<div><span class="label">SSH</span><strong>{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</strong></div> <div><span class="label">SSH</span><strong>{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</strong></div>
{% endif %}
<div><span class="label">Backup source</span><strong>{{ host.source_root|default:"global default" }}</strong></div> <div><span class="label">Backup source</span><strong>{{ host.source_root|default:"global default" }}</strong></div>
<div><span class="label">Retention</span><strong>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</strong></div> <div><span class="label">Retention</span><strong>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</strong></div>
</div> </div>
{% if can_manage_control_panel %}
<div class="actions inline"> <div class="actions inline">
<a class="button-link secondary compact" href="{% url 'edit_host_config' host.host %}">Edit config</a> <a class="button-link secondary compact" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<a class="button-link secondary compact" href="{% url 'host_retention_plan' host.host %}">Plan retention</a> <a class="button-link secondary compact" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
</div> </div>
{% endif %}
</section> </section>
{% if can_manage_control_panel %}
<section class="panel"> <section class="panel">
<h2>Connection Preflight &amp; SSH</h2> <h2>Connection Preflight &amp; SSH</h2>
{% if last_preflight %} {% if last_preflight %}
@@ -342,6 +360,7 @@
</div> </div>
{% endif %} {% endif %}
</section> </section>
{% endif %}
<section class="panel"> <section class="panel">
<h2>Snapshot Storage</h2> <h2>Snapshot Storage</h2>
@@ -360,6 +379,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if can_manage_control_panel %}
<div class="actions inline"> <div class="actions inline">
<form method="post" action="{% url 'discover_host_snapshots' host.host %}"> <form method="post" action="{% url 'discover_host_snapshots' host.host %}">
{% csrf_token %} {% csrf_token %}
@@ -370,6 +390,7 @@
<button type="submit" class="secondary compact">Prepare directories</button> <button type="submit" class="secondary compact">Prepare directories</button>
</form> </form>
</div> </div>
{% endif %}
</section> </section>
</div> </div>
@@ -451,6 +472,7 @@
</section> </section>
{% endif %} {% endif %}
{% if can_manage_control_panel %}
<section class="panel"> <section class="panel">
<h2>Backup Options</h2> <h2>Backup Options</h2>
<p class="muted">Use this when the quick actions above need a custom label, include/exclude override, or prune limit.</p> <p class="muted">Use this when the quick actions above need a custom label, include/exclude override, or prune limit.</p>
@@ -472,6 +494,7 @@
</div> </div>
</form> </form>
</section> </section>
{% endif %}
<section class="panel"> <section class="panel">
<h2>Latest Runs <a class="button-link secondary compact" href="{% url 'runs_list' %}?host={{ host.host }}">View all</a></h2> <h2>Latest Runs <a class="button-link secondary compact" href="{% url 'runs_list' %}?host={{ host.host }}">View all</a></h2>

View File

@@ -9,9 +9,11 @@
<h1>Hosts</h1> <h1>Hosts</h1>
<div class="page-subtitle">Configured backup targets, schedules, retention state, and host-level controls.</div> <div class="page-subtitle">Configured backup targets, schedules, retention state, and host-level controls.</div>
</div> </div>
{% if can_manage_control_panel %}
<section class="actions" aria-label="Host actions"> <section class="actions" aria-label="Host actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a> <a class="button-link" href="{% url 'create_host_config' %}">New host</a>
</section> </section>
{% endif %}
</header> </header>
<section class="grid dashboard-summary-grid" aria-label="Host summary"> <section class="grid dashboard-summary-grid" aria-label="Host summary">

View File

@@ -32,7 +32,7 @@
</section> </section>
{% endif %} {% 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 %} {% if not run.reviewed_at %}
<section class="panel highlight warning"> <section class="panel highlight warning">
<h2>Review Required</h2> <h2>Review Required</h2>
@@ -78,7 +78,7 @@
{% endif %} {% endif %}
<div> <div>
<strong>Log:</strong> <strong>Log:</strong>
{% if dry_run_summary.log_available %} {% if dry_run_summary.log_available and can_manage_control_panel %}
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a> <a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
{% elif rsync_log_path %} {% elif rsync_log_path %}
<span class="muted">{{ rsync_log_path }} (missing)</span> <span class="muted">{{ rsync_log_path }} (missing)</span>
@@ -155,7 +155,7 @@
{% if live_progress.log.path %} {% if live_progress.log.path %}
<div> <div>
<strong>Log:</strong> <strong>Log:</strong>
{% if live_progress.log.exists %} {% if live_progress.log.exists and can_manage_control_panel %}
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a> <a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
{% else %} {% else %}
<span class="muted">{{ live_progress.log.path }} (missing)</span> <span class="muted">{{ live_progress.log.path }} (missing)</span>
@@ -189,7 +189,7 @@
<div><strong>Base:</strong> {{ run.base_path|default:"" }}</div> <div><strong>Base:</strong> {{ run.base_path|default:"" }}</div>
<div> <div>
<strong>Rsync log:</strong> <strong>Rsync log:</strong>
{% if rsync_log_exists %} {% if rsync_log_exists and can_manage_control_panel %}
<a href="{% url 'run_rsync_log' run.id %}">{{ rsync_log_path }}</a> <a href="{% url 'run_rsync_log' run.id %}">{{ rsync_log_path }}</a>
{% elif rsync_log_path %} {% elif rsync_log_path %}
<span class="muted">{{ rsync_log_path }} (missing)</span> <span class="muted">{{ rsync_log_path }} (missing)</span>
@@ -204,7 +204,7 @@
<section class="panel"> <section class="panel">
<h2>Rsync Log</h2> <h2>Rsync Log</h2>
<div class="stack spaced"> <div class="stack spaced">
{% if rsync_log_exists %} {% if rsync_log_exists and can_manage_control_panel %}
<div><a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a></div> <div><a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a></div>
<div class="muted">{{ rsync_log_path }}</div> <div class="muted">{{ rsync_log_path }}</div>
{% elif rsync_log_path %} {% elif rsync_log_path %}

View File

@@ -18,6 +18,12 @@ class ApiTests(TestCase):
is_staff=True, is_staff=True,
is_superuser=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: def test_api_requires_staff_login(self) -> None:
response = self.client.get("/api/hosts/") response = self.client.get("/api/hosts/")
@@ -25,6 +31,15 @@ class ApiTests(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"]) 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: def test_hosts_endpoint_returns_counts_and_schedule(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test") host = HostConfig.objects.create(host="web-01", address="web-01.example.test")

View File

@@ -36,6 +36,12 @@ class ViewTests(TestCase):
is_staff=True, is_staff=True,
is_superuser=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: def test_dashboard_requires_staff_login(self) -> None:
response = self.client.get(reverse("dashboard")) response = self.client.get(reverse("dashboard"))
@@ -63,6 +69,22 @@ class ViewTests(TestCase):
self.assertContains(response, reverse("admin:index")) self.assertContains(response, reverse("admin:index"))
self.assertContains(response, '<a href="/" aria-current="page">Dashboard</a>', html=False) self.assertContains(response, '<a href="/" aria-current="page">Dashboard</a>', 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: def test_base_navigation_marks_current_secondary_page(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
@@ -71,12 +93,81 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, f'<a href="{reverse("self_check")}" aria-current="page">Self Check</a>', html=False) self.assertContains(response, f'<a href="{reverse("self_check")}" aria-current="page">Self Check</a>', html=False)
def test_changelog_requires_staff_login(self) -> None: def test_changelog_requires_login(self) -> None:
response = self.client.get(reverse("changelog")) response = self.client.get(reverse("changelog"))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"]) 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: def test_changelog_renders_repository_changelog(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
with TemporaryDirectory() as tmp: with TemporaryDirectory() as tmp:

View File

@@ -10,7 +10,6 @@ from pathlib import Path
from urllib.parse import urlencode from urllib.parse import urlencode
from django.contrib import messages from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
from django.conf import settings from django.conf import settings
from django.http import FileResponse, Http404 from django.http import FileResponse, Http404
from django.db.models import Count, Q from django.db.models import Count, Q
@@ -24,6 +23,7 @@ from pobsync import __version__
from pobsync.errors import PobsyncError from pobsync.errors import PobsyncError
from pobsync.config.defaults import DEFAULT_EXCLUDES, DEFAULT_RSYNC_ARGS 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 .backup_runner import queue_backup_run
from .config_checks import collect_effective_host_config_checks, collect_global_config_checks from .config_checks import collect_effective_host_config_checks, collect_global_config_checks
from .forms import ( 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 from .stats_summary import collect_dashboard_stats, collect_host_stats
@staff_member_required @status_view_required
def dashboard(request): 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): 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): 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]: 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() global_config = GlobalConfig.objects.filter(name="default").first()
host_context = _host_cards_context() host_context = _host_cards_context()
hosts = host_context["hosts"] 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] 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) stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config)
context = { context = {
**access_context(request),
"hosts": hosts, "hosts": hosts,
"global_config": global_config, "global_config": global_config,
"stats_summary": stats_summary, "stats_summary": stats_summary,
@@ -159,7 +160,7 @@ def _dashboard_context() -> dict[str, object]:
return context return context
@staff_member_required @status_view_required
def hosts_list(request): def hosts_list(request):
enabled = request.GET.get("enabled", "").strip() enabled = request.GET.get("enabled", "").strip()
if enabled not in {"", "yes", "no"}: if enabled not in {"", "yes", "no"}:
@@ -171,15 +172,16 @@ def hosts_list(request):
request, request,
"pobsync_backend/hosts_list.html", "pobsync_backend/hosts_list.html",
{ {
**access_context(request),
**context, **context,
"global_config": global_config, "global_config": global_config,
"show_host_controls": True, "show_host_controls": request.user.is_staff,
"total_count": HostConfig.objects.count(), "total_count": HostConfig.objects.count(),
}, },
) )
@staff_member_required @control_panel_admin_required
@require_POST @require_POST
def update_host_state(request, host: str): def update_host_state(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) host_config = get_object_or_404(HostConfig, host=host)
@@ -290,7 +292,7 @@ def _retention_warning_summary(retention_warning) -> str:
return " ".join(parts) return " ".join(parts)
@staff_member_required @status_view_required
def changelog(request): def changelog(request):
changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md" changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"
try: try:
@@ -312,7 +314,7 @@ def changelog(request):
) )
@staff_member_required @control_panel_admin_required
def self_check(request): def self_check(request):
checks = collect_self_checks() checks = collect_self_checks()
return render( return render(
@@ -325,13 +327,13 @@ def self_check(request):
) )
@staff_member_required @control_panel_admin_required
def logs(request): def logs(request):
context = _log_context(request) context = _log_context(request)
return render(request, "pobsync_backend/logs.html", context) return render(request, "pobsync_backend/logs.html", context)
@staff_member_required @control_panel_admin_required
def notification_targets(request): def notification_targets(request):
targets = NotificationTarget.objects.order_by("name") targets = NotificationTarget.objects.order_by("name")
deliveries = NotificationDelivery.objects.select_related("target", "run", "run__host").order_by("-created_at")[:12] 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): def create_notification_target(request):
if request.method == "POST": if request.method == "POST":
form = NotificationTargetForm(request.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): def edit_notification_target(request, target_id: int):
target = get_object_or_404(NotificationTarget, id=target_id) target = get_object_or_404(NotificationTarget, id=target_id)
if request.method == "POST": 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): def runs_list(request):
status = request.GET.get("status", "").strip() status = request.GET.get("status", "").strip()
run_type = request.GET.get("type", "").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) return render(request, "pobsync_backend/runs_list.html", context)
@staff_member_required @status_view_required
def snapshots_list(request): def snapshots_list(request):
kind = request.GET.get("kind", "").strip() kind = request.GET.get("kind", "").strip()
status = request.GET.get("status", "").strip() status = request.GET.get("status", "").strip()
@@ -448,7 +450,7 @@ def snapshots_list(request):
return render(request, "pobsync_backend/snapshots_list.html", context) return render(request, "pobsync_backend/snapshots_list.html", context)
@staff_member_required @status_view_required
def schedules_list(request): def schedules_list(request):
enabled = request.GET.get("enabled", "").strip() enabled = request.GET.get("enabled", "").strip()
prune = request.GET.get("prune", "").strip() prune = request.GET.get("prune", "").strip()
@@ -486,7 +488,7 @@ def schedules_list(request):
return render(request, "pobsync_backend/schedules_list.html", context) return render(request, "pobsync_backend/schedules_list.html", context)
@staff_member_required @status_view_required
def purged_snapshots(request): def purged_snapshots(request):
host = request.GET.get("host", "").strip() host = request.GET.get("host", "").strip()
action = request.GET.get("action", "").strip() action = request.GET.get("action", "").strip()
@@ -507,7 +509,7 @@ def purged_snapshots(request):
return render(request, "pobsync_backend/purged_snapshots.html", context) return render(request, "pobsync_backend/purged_snapshots.html", context)
@staff_member_required @control_panel_admin_required
def ssh_credentials(request): def ssh_credentials(request):
context = { context = {
"credentials": SshCredential.objects.order_by("name"), "credentials": SshCredential.objects.order_by("name"),
@@ -515,7 +517,7 @@ def ssh_credentials(request):
return render(request, "pobsync_backend/ssh_credentials.html", context) return render(request, "pobsync_backend/ssh_credentials.html", context)
@staff_member_required @control_panel_admin_required
def create_ssh_credential(request): def create_ssh_credential(request):
if request.method == "POST": if request.method == "POST":
form = SshCredentialForm(request.POST, request.FILES) 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): def generate_ssh_credential(request):
if request.method == "POST": if request.method == "POST":
form = SshCredentialGenerateForm(request.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): def edit_ssh_credential(request, credential_id: int):
credential = get_object_or_404(SshCredential, id=credential_id) credential = get_object_or_404(SshCredential, id=credential_id)
if request.method == "POST": 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 @require_POST
def delete_ssh_credential(request, credential_id: int): def delete_ssh_credential(request, credential_id: int):
credential = get_object_or_404(SshCredential, id=credential_id) 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") return redirect("ssh_credentials")
@staff_member_required @control_panel_admin_required
def edit_global_config(request): def edit_global_config(request):
global_config = GlobalConfig.objects.filter(name="default").first() global_config = GlobalConfig.objects.filter(name="default").first()
if request.method == "POST": 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): def create_host_config(request):
if request.method == "POST": if request.method == "POST":
form = CreateHostConfigForm(request.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): def host_detail(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) host_config = get_object_or_404(HostConfig, host=host)
global_config = GlobalConfig.objects.filter(name="default").first() 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 has_global_config = global_config is not None
backup_gate = collect_backup_gate(host_config, global_config) backup_gate = collect_backup_gate(host_config, global_config)
stats_summary = collect_host_stats(host=host_config, limit=10) stats_summary = collect_host_stats(host=host_config, limit=10)
can_manage = request.user.is_staff
context = { context = {
**access_context(request),
"host": host_config, "host": host_config,
"schedule": schedule, "schedule": schedule,
"retention_warning": _retention_warning_for_host(host_config, 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), "host_check_summary": summarize_self_checks(backup_gate.checks),
"backup_gate": backup_gate, "backup_gate": backup_gate,
"last_preflight": (host_config.config or {}).get("last_preflight") if isinstance(host_config.config, dict) else {}, "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, "stats_summary": stats_summary,
"manual_backup_form": ManualBackupForm(initial=_default_manual_backup_initial(host_config)), "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_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": host_config.enabled and has_global_config and backup_gate.can_queue_real 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, "has_global_config": has_global_config,
"active_run": active_run, "active_run": active_run,
"latest_runs": host_config.runs.select_related("snapshot").order_by("-created_at")[:10], "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) return render(request, "pobsync_backend/host_detail.html", context)
@staff_member_required @control_panel_admin_required
@require_POST @require_POST
def prepare_host_directories(request, host: str): def prepare_host_directories(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) 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) return redirect("host_detail", host=host_config.host)
@staff_member_required @control_panel_admin_required
@require_POST @require_POST
def scan_host_known_key(request, host: str): def scan_host_known_key(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) 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) return redirect("host_detail", host=host_config.host)
@staff_member_required @control_panel_admin_required
@require_POST @require_POST
def run_host_preflight(request, host: str): def run_host_preflight(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) 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) return redirect("host_detail", host=host_config.host)
@staff_member_required @control_panel_admin_required
@require_POST @require_POST
def queue_manual_backup(request, host: str): def queue_manual_backup(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) 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) return redirect("run_detail", run_id=run.id)
@staff_member_required @status_view_required
def run_detail(request, run_id: int): def run_detail(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) 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): def run_detail_live(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) 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 = render(request, "pobsync_backend/partials/run_detail_live.html", context)
response["X-Pobsync-Refresh-Active"] = "true" if context["can_auto_refresh"] else "false" response["X-Pobsync-Refresh-Active"] = "true" if context["can_auto_refresh"] else "false"
return response 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 {} result = run.result if isinstance(run.result, dict) else {}
run_stats = result.get("stats") if isinstance(result.get("stats"), 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 {} 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_path = _run_rsync_log_path(run)
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path) rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {} 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 { return {
**(access_context(request) if request is not None else {}),
"run": run, "run": run,
"can_cancel": can_cancel, "can_cancel": can_cancel,
"can_auto_refresh": 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): def run_rsync_log(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id) run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
log_path = _run_rsync_log_path(run) 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") return FileResponse(log_path.open("rb"), content_type="text/plain; charset=utf-8")
@staff_member_required @control_panel_admin_required
@require_POST @require_POST
def cancel_run(request, run_id: int): def cancel_run(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id) 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) return redirect("run_detail", run_id=run.id)
@staff_member_required @control_panel_admin_required
@require_POST @require_POST
def resolve_run_review(request, run_id: int): def resolve_run_review(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id) 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) return _redirect_after_run_review(request, run)
@staff_member_required @control_panel_admin_required
@require_POST @require_POST
def resolve_host_incomplete_reviews(request, host: str): def resolve_host_incomplete_reviews(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) 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) return redirect("host_detail", host=host_config.host)
@staff_member_required @status_view_required
def snapshot_detail(request, snapshot_id: int): def snapshot_detail(request, snapshot_id: int):
snapshot = get_object_or_404( snapshot = get_object_or_404(
SnapshotRecord.objects.select_related("host", "base").prefetch_related("derived_snapshots", "backup_runs"), 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) return render(request, "pobsync_backend/snapshot_detail.html", context)
@staff_member_required @control_panel_admin_required
@require_POST @require_POST
def discover_host_snapshots(request, host: str): def discover_host_snapshots(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) 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) return redirect("host_detail", host=host_config.host)
@staff_member_required @control_panel_admin_required
def host_retention_plan(request, host: str): def host_retention_plan(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) host_config = get_object_or_404(HostConfig, host=host)
kind = request.GET.get("kind", "scheduled") 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) return render(request, "pobsync_backend/retention_plan.html", context)
@staff_member_required @control_panel_admin_required
@require_POST @require_POST
def apply_host_retention(request, host: str): def apply_host_retention(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) host_config = get_object_or_404(HostConfig, host=host)
@@ -1091,7 +1097,7 @@ def apply_host_retention(request, host: str):
return target return target
@staff_member_required @control_panel_admin_required
@require_POST @require_POST
def cleanup_host_incomplete_snapshots(request, host: str): def cleanup_host_incomplete_snapshots(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) 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) return redirect("host_retention_plan", host=host_config.host)
@staff_member_required @control_panel_admin_required
def edit_host_config(request, host: str): def edit_host_config(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) host_config = get_object_or_404(HostConfig, host=host)
global_config = GlobalConfig.objects.filter(name="default").first() 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): def edit_host_schedule(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) host_config = get_object_or_404(HostConfig, host=host)
schedule = _schedule_for_host(host_config) schedule = _schedule_for_host(host_config)

View File

@@ -48,6 +48,7 @@ TEMPLATES = [
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"pobsync_backend.context_processors.pobsync_access",
], ],
}, },
}, },
@@ -55,6 +56,8 @@ TEMPLATES = [
WSGI_APPLICATION = "pobsync_server.wsgi.application" WSGI_APPLICATION = "pobsync_server.wsgi.application"
LOGIN_URL = "/admin/login/"
def _database_config() -> dict[str, object]: def _database_config() -> dict[str, object]:
engine = os.getenv("POBSYNC_DB_ENGINE", "sqlite").strip().lower() engine = os.getenv("POBSYNC_DB_ENGINE", "sqlite").strip().lower()