(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:
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user