diff --git a/website/admin/__init__.py b/website/admin/__init__.py index f2ab80a6..5980c694 100644 --- a/website/admin/__init__.py +++ b/website/admin/__init__.py @@ -56,6 +56,7 @@ banner_admin, grant_admin, keyword_admin, + logentry_admin, news_admin, person_admin, photo_admin, diff --git a/website/admin/admin_site.py b/website/admin/admin_site.py index 73c4e7e8..d3d0caa2 100644 --- a/website/admin/admin_site.py +++ b/website/admin/admin_site.py @@ -73,7 +73,7 @@ class MakeabilityLabAdminSite(admin.AdminSite): ), ( "Administration", - ["Group", "User"], + ["Group", "User", "LogEntry"], None ), ] diff --git a/website/admin/logentry_admin.py b/website/admin/logentry_admin.py new file mode 100644 index 00000000..571b33b3 --- /dev/null +++ b/website/admin/logentry_admin.py @@ -0,0 +1,99 @@ +""" +Read-only admin for Django's ``LogEntry`` audit log. + +Django records every add/change/delete performed through the admin in +``django.contrib.admin.models.LogEntry``, but does not surface it anywhere in +the UI—the "Recent actions" sidebar on the index page is deliberately scoped to +the current user's own actions (``{% get_admin_log ... for_user user %}``). + +This registers ``LogEntry`` as a fully read-only changelist so a superuser can +browse *everyone's* admin activity, filtered by user, action type, content +type, and date. It is intentionally superuser-only (like Grant/Award) because +it exposes who edited what across all accounts. + +The log is append-only by Django and this admin never adds, edits, or deletes +rows; it is purely a viewer. +""" + +from django.contrib import admin +from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION +from django.utils.html import format_html + +from website.admin.admin_site import ml_admin_site + + +@admin.register(LogEntry, site=ml_admin_site) +class LogEntryAdmin(admin.ModelAdmin): + """Superuser-only, read-only browser over the admin action log.""" + + # Newest first—matches the mental model of an activity feed. + ordering = ('-action_time',) + date_hierarchy = 'action_time' + + list_display = ( + 'action_time', + 'user', + 'action_label', + 'content_type', + 'object_link', + 'change_summary', + ) + list_filter = ('action_flag', 'content_type', 'user') + search_fields = ('object_repr', 'change_message', 'user__username', + 'user__first_name', 'user__last_name') + + # FK columns rendered on every row—join them in the changelist query so we + # don't fire per-row lookups (#1346). + list_select_related = ('user', 'content_type') + + @admin.display(description='Action', ordering='action_flag') + def action_label(self, obj): + """Human-readable action name with a color cue.""" + label, color = { + ADDITION: ('Added', '#2e7d32'), + CHANGE: ('Changed', '#946c00'), + DELETION: ('Deleted', '#b00020'), + }.get(obj.action_flag, ('Unknown', '#666')) + return format_html('{}', color, label) + + @admin.display(description='Object') + def object_link(self, obj): + """The affected object as a link to its admin edit page. + + Deletions have no surviving object to link to (and the referenced row + may be gone), so we fall back to the recorded ``object_repr`` text. + """ + if obj.action_flag != DELETION: + try: + url = obj.get_admin_url() + except Exception: + url = None + if url: + return format_html('{}', url, obj.object_repr) + return obj.object_repr or '—' + + @admin.display(description='Details') + def change_summary(self, obj): + """Django's formatted change message (e.g. 'Changed Title and Authors').""" + message = obj.get_change_message() + return message or '—' + + # --- Read-only + superuser-only enforcement ------------------------------- + # LogEntry is an append-only audit trail; never allow mutation through here, + # and restrict all visibility to superusers (this exposes cross-account + # activity, like Grant/Award). + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def has_view_permission(self, request, obj=None): + return request.user.is_superuser + + def has_module_permission(self, request): + return request.user.is_superuser diff --git a/website/tests/test_logentry_admin.py b/website/tests/test_logentry_admin.py new file mode 100644 index 00000000..9618f6e5 --- /dev/null +++ b/website/tests/test_logentry_admin.py @@ -0,0 +1,121 @@ +""" +Regression tests for the read-only LogEntry admin (site-wide action log). + +Pins the two properties that matter: (1) the LogEntry changelist is a +read-only viewer—no add/change/delete—and (2) it is superuser-only, so +editors/contributors can neither see nor reach it. See website/admin/ +logentry_admin.py. +""" + +from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse + +from website.admin.admin_site import ml_admin_site +from website.admin.logentry_admin import LogEntryAdmin +from website.tests.base import DatabaseTestCase + +User = get_user_model() + + +class LogEntryAdminPermissionTests(DatabaseTestCase): + """The audit log is read-only and superuser-only.""" + + def setUp(self): + self.admin = LogEntryAdmin(LogEntry, ml_admin_site) + self.superuser = User.objects.create_superuser( + username="root", email="root@example.com", password="pw") + self.editor = User.objects.create_user( + username="editor", email="editor@example.com", password="pw", + is_staff=True) + + def _request(self, user): + # Lightweight stand-in: the permission hooks only read request.user. + class _Req: + pass + req = _Req() + req.user = user + return req + + def test_log_is_read_only(self): + req = self._request(self.superuser) + self.assertFalse(self.admin.has_add_permission(req)) + self.assertFalse(self.admin.has_change_permission(req)) + self.assertFalse(self.admin.has_delete_permission(req)) + + def test_only_superusers_can_view(self): + self.assertTrue( + self.admin.has_view_permission(self._request(self.superuser))) + self.assertTrue( + self.admin.has_module_permission(self._request(self.superuser))) + self.assertFalse( + self.admin.has_view_permission(self._request(self.editor))) + self.assertFalse( + self.admin.has_module_permission(self._request(self.editor))) + + +class LogEntryAdminViewTests(DatabaseTestCase): + """End-to-end: the changelist renders for superusers and is blocked otherwise.""" + + def setUp(self): + self.superuser = User.objects.create_superuser( + username="root", email="root@example.com", password="pw") + self.editor = User.objects.create_user( + username="editor", email="editor@example.com", password="pw", + is_staff=True) + # Seed one log row of each action type so the display columns render. + ct = ContentType.objects.get_for_model(User) + for flag in (ADDITION, CHANGE, DELETION): + LogEntry.objects.log_action( + user_id=self.superuser.pk, + content_type_id=ct.pk, + object_id=self.editor.pk, + object_repr=str(self.editor), + action_flag=flag, + change_message="test", + ) + + def test_superuser_sees_changelist(self): + self.client.force_login(self.superuser) + url = reverse("admin:admin_logentry_changelist") + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + # The seeded rows' object_repr should appear in the rendered list. + self.assertContains(resp, str(self.editor)) + + def test_non_superuser_is_denied(self): + self.client.force_login(self.editor) + url = reverse("admin:admin_logentry_changelist") + resp = self.client.get(url) + # Django admin redirects/403s when module perms are absent; either way + # the editor must not get a 200 list of everyone's actions. + self.assertNotEqual(resp.status_code, 200) + + +class LogEntryAdminDisplayTests(DatabaseTestCase): + """The custom display columns don't raise on any action type.""" + + def setUp(self): + self.admin = LogEntryAdmin(LogEntry, ml_admin_site) + self.user = User.objects.create_superuser( + username="root", email="root@example.com", password="pw") + self.ct = ContentType.objects.get_for_model(User) + + def _entry(self, flag): + return LogEntry.objects.log_action( + user_id=self.user.pk, content_type_id=self.ct.pk, + object_id=self.user.pk, object_repr=str(self.user), + action_flag=flag, change_message="changed something") + + def test_deletion_object_link_falls_back_to_repr(self): + # Deletions have no live object to link to; must fall back to text, + # never raise (get_admin_url would point at a possibly-gone row). + entry = self._entry(DELETION) + self.assertIn(str(self.user), self.admin.object_link(entry)) + + def test_action_label_covers_all_flags(self): + for flag, expected in ((ADDITION, "Added"), + (CHANGE, "Changed"), + (DELETION, "Deleted")): + self.assertIn(expected, self.admin.action_label(self._entry(flag)))