(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.
This commit is contained in:
2026-05-28 22:00:16 +02:00
parent 7f2bbe4d20
commit 81ee848f5f
13 changed files with 329 additions and 126 deletions

View File

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

View File

@@ -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, '<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:
self.client.force_login(self.staff_user)
@@ -71,12 +93,81 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
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"))
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: