diff --git a/cms/auth_content/exporters/__init__.py b/cms/auth_content/exporters/__init__.py new file mode 100644 index 000000000..e31a9fa75 --- /dev/null +++ b/cms/auth_content/exporters/__init__.py @@ -0,0 +1,3 @@ +from cms.auth_content.exporters.user_exporter import ( + generate_user_permission_sets_csv_rows, +) diff --git a/cms/auth_content/exporters/user_exporter.py b/cms/auth_content/exporters/user_exporter.py new file mode 100644 index 000000000..cd3442004 --- /dev/null +++ b/cms/auth_content/exporters/user_exporter.py @@ -0,0 +1,23 @@ +import csv + + +class Echo: + @staticmethod + def write(value): + return value + + +def generate_user_permission_sets_csv_rows(users): + writer = csv.writer(Echo()) + yield writer.writerow( + ["User ID", "Permission Set", "Gives Global Access", "Permissions"] + ) + for user in users: + permission_sets = user.permission_sets.all() + if permission_sets: + for ps in permission_sets: + yield writer.writerow( + [user.user_id, ps.display_name, ps.global_access, ps.name] + ) + else: + yield writer.writerow([user.user_id, "", "", ""]) diff --git a/cms/auth_content/models/permission_sets.py b/cms/auth_content/models/permission_sets.py index 4b0179229..b59cc294c 100644 --- a/cms/auth_content/models/permission_sets.py +++ b/cms/auth_content/models/permission_sets.py @@ -115,6 +115,10 @@ def permission_set_details(self): parts = [part.strip() for part in self.name.split("|")] return mark_safe("
".join(parts)) + @property + def global_access(self): + return self.theme == "-1" and self.geography_type == "-1" + panels = [ FieldPanel("display_name"), FieldPanel("theme"), diff --git a/cms/auth_content/views.py b/cms/auth_content/views.py new file mode 100644 index 000000000..db82e2712 --- /dev/null +++ b/cms/auth_content/views.py @@ -0,0 +1,38 @@ +import logging +from datetime import datetime + +from django.http import StreamingHttpResponse +from django.views.decorators.http import require_http_methods + +from cms.auth_content.exporters import generate_user_permission_sets_csv_rows +from cms.auth_content.models.users import User + +audit_logger = logging.getLogger("audit") + + +@require_http_methods(["GET"]) +def export_user_permission_sets_csv(request): + user_id = ( + request.user.id + if request.user and request.user.is_authenticated + else "anonymous" + ) + audit_logger.info( + "User permission sets exported to CSV", + extra={ + "user": user_id, + "action": "CSV EXPORT", + "target": "Users and permissions", + }, + ) + + users = User.objects.with_permission_sets() + response = StreamingHttpResponse( + generate_user_permission_sets_csv_rows(users), + content_type="text/csv", + ) + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + response["Content-Disposition"] = ( + f'attachment; filename="dashboard_cms_users_{timestamp}.csv"' + ) + return response diff --git a/cms/auth_content/wagtail_hooks.py b/cms/auth_content/wagtail_hooks.py index 138364c2c..e6cac2683 100644 --- a/cms/auth_content/wagtail_hooks.py +++ b/cms/auth_content/wagtail_hooks.py @@ -1,13 +1,18 @@ +from django.urls import path, reverse +from django.utils.functional import cached_property from wagtail import hooks +from wagtail.admin.views.generic import IndexView from wagtail.admin.viewsets.model import ( ModelPermissionPolicy, ModelViewSet, ModelViewSetGroup, ) +from wagtail.admin.widgets import Button from cms.auth_content.models.api_application import APIApplication from cms.auth_content.models.permission_sets import PermissionSet from cms.auth_content.models.users import User +from cms.auth_content.views import export_user_permission_sets_csv class NoEditPermissionPolicy(ModelPermissionPolicy): @@ -31,11 +36,26 @@ class PermissionSetViewSet(ModelViewSet): inspect_view_fields = ["permission_set_details"] +class UserIndexView(IndexView): + @cached_property + def header_more_buttons(self): + buttons = super().header_more_buttons + buttons.append( + Button( + "Export to CSV", + reverse("export_user_permission_sets_csv"), + ) + ) + return buttons + + class UserViewSet(ModelViewSet): model = User menu_label = "Users" icon = "user" + index_view_class = UserIndexView + class APIApplicationViewSet(ModelViewSet): model = APIApplication @@ -53,3 +73,14 @@ class AuthGroup(ModelViewSetGroup): @hooks.register("register_admin_viewset") def register_auth_viewset(): return AuthGroup() + + +@hooks.register("register_admin_urls") +def register_user_export_url(): + return [ + path( + "user/export-csv/", + export_user_permission_sets_csv, + name="export_user_permission_sets_csv", + ), + ] diff --git a/metrics/data/managers/rbac_models/user.py b/metrics/data/managers/rbac_models/user.py index b2967685f..2fd1b138e 100644 --- a/metrics/data/managers/rbac_models/user.py +++ b/metrics/data/managers/rbac_models/user.py @@ -79,3 +79,9 @@ def get_permission_sets_for_user(user_id: UUID) -> models.QuerySet: 5 """ return PermissionSet.objects.filter(user__user_id=user_id) + + def with_permission_sets(self): + """ + Gets all users and their assigned permission sets, ordering by user id. + """ + return self.prefetch_related("permission_sets").order_by("user_id") diff --git a/tests/integration/cms/auth_content/__init__.py b/tests/integration/cms/auth_content/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/cms/auth_content/exporters/__init__.py b/tests/integration/cms/auth_content/exporters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/cms/auth_content/exporters/test_user_exporter.py b/tests/integration/cms/auth_content/exporters/test_user_exporter.py new file mode 100644 index 000000000..8e52c71c2 --- /dev/null +++ b/tests/integration/cms/auth_content/exporters/test_user_exporter.py @@ -0,0 +1,72 @@ +import uuid +from django.test import TestCase + +from cms.auth_content.models.users import User +from cms.auth_content.models.permission_sets import PermissionSet +from cms.auth_content.exporters.user_exporter import ( + generate_user_permission_sets_csv_rows, +) + + +class TestUserCsvExporter(TestCase): + def test_user_with_no_permission_sets_exports_as_blank_row(self): + user = User.objects.create(user_id=uuid.uuid4()) + + rows = list(generate_user_permission_sets_csv_rows(User.objects.all())) + + self.assertEqual(len(rows), 2) + self.assertEqual( + rows[0], "User ID,Permission Set,Gives Global Access,Permissions\r\n" + ) + self.assertEqual(rows[1], f"{str(user.user_id)},,,\r\n") + + def test_export_with_multiple_users_and_permissions(self): + # Results are sorted by user id, so hardcoding easier to determine uuids to ensure test order + user_without_permissions = User.objects.create( + user_id="11111111-1111-1111-1111-111111111111" + ) + user_with_one_permission_set = User.objects.create( + user_id="22222222-2222-2222-2222-222222222222" + ) + user_with_two_permission_sets = User.objects.create( + user_id="33333333-3333-3333-3333-333333333333" + ) + global_access_permission_set = PermissionSet.objects.create( + display_name="Global", + theme="-1", + sub_theme="-1", + topic="-1", + metric="-1", + geography_type="-1", + ) + limited_access_permission_set = PermissionSet.objects.create( + display_name="Limited", + theme="11", + sub_theme="-1", + topic="-1", + metric="-1", + geography_type="-1", + ) + user_with_one_permission_set.permission_sets.add(limited_access_permission_set) + user_with_two_permission_sets.permission_sets.add( + global_access_permission_set, limited_access_permission_set + ) + rows = list(generate_user_permission_sets_csv_rows(User.objects.all())) + + self.assertEqual(len(rows), 5) + self.assertEqual( + rows[0], "User ID,Permission Set,Gives Global Access,Permissions\r\n" + ) + self.assertEqual(rows[1], f"{str(user_without_permissions.user_id)},,,\r\n") + self.assertEqual( + rows[2], + f"{str(user_with_one_permission_set.user_id)},Limited,False,Theme: 11 | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All)\r\n", + ) + self.assertEqual( + rows[3], + f"{str(user_with_two_permission_sets.user_id)},Global,True,Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All)\r\n", + ) + self.assertEqual( + rows[4], + f"{str(user_with_two_permission_sets.user_id)},Limited,False,Theme: 11 | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All)\r\n", + ) diff --git a/tests/integration/cms/auth_content/test_views.py b/tests/integration/cms/auth_content/test_views.py new file mode 100644 index 000000000..2d95fdddd --- /dev/null +++ b/tests/integration/cms/auth_content/test_views.py @@ -0,0 +1,59 @@ +import freezegun +from unittest import mock +import uuid +from django.test import TestCase +from django.urls import reverse + +from cms.auth_content.models.users import User + + +class TestExportUserPermissionsView(TestCase): + def setUp(self): + self.mock_logger = mock.patch("cms.auth_content.views.audit_logger").start() + self.addCleanup(mock.patch.stopall) + self.superuser = self._create_superuser() + self.client.force_login(self.superuser) + + @staticmethod + def _create_superuser(): + from django.contrib.auth import get_user_model + + AdminUser = get_user_model() + return AdminUser.objects.create_superuser( + username="admin", email="admin@example.com", password="password" + ) + + def test_export_requires_login(self): + self.client.logout() + url = reverse("export_user_permission_sets_csv") + + response = self.client.get(url) + + self.assertEqual(response.status_code, 302) + + @freezegun.freeze_time("2026-08-17 12:00:00") + def test_export_user_permissions_view_response(self): + User.objects.create(user_id=uuid.uuid4()) + url = reverse("export_user_permission_sets_csv") + + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/csv") + self.assertIn( + 'attachment; filename="dashboard_cms_users_20260817-120000.csv"', + response["Content-Disposition"], + ) + + def test_exporting_users_creates_audit_log(self): + User.objects.create(user_id=uuid.uuid4()) + url = reverse("export_user_permission_sets_csv") + + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.mock_logger.info.assert_called_once() + _, kwargs = self.mock_logger.info.call_args + self.assertEqual(kwargs["extra"]["user"], self.superuser.id) + self.assertEqual(kwargs["extra"]["action"], "CSV EXPORT") + self.assertEqual(kwargs["extra"]["target"], "Users and permissions") diff --git a/tests/unit/auth_content/models/__init__.py b/tests/unit/auth_content/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/auth_content/models/test_permission_sets.py b/tests/unit/auth_content/models/test_permission_sets.py new file mode 100644 index 000000000..7e7ceffbc --- /dev/null +++ b/tests/unit/auth_content/models/test_permission_sets.py @@ -0,0 +1,15 @@ +from cms.auth_content.models.permission_sets import PermissionSet + + +class TestPermissionSet: + def test_global_access_property_with_global_access(self): + permission_set = PermissionSet() + permission_set.theme = "-1" + permission_set.geography_type = "-1" + assert permission_set.global_access == True + + def test_global_access_property_without_global_access(self): + permission_set = PermissionSet() + permission_set.theme = "-1" + permission_set.geography_type = "1" + assert permission_set.global_access == False diff --git a/tests/unit/cms/auth_content/test_wagtail_hooks.py b/tests/unit/cms/auth_content/test_wagtail_hooks.py index 5a8a6bee6..3ec645e24 100644 --- a/tests/unit/cms/auth_content/test_wagtail_hooks.py +++ b/tests/unit/cms/auth_content/test_wagtail_hooks.py @@ -7,6 +7,7 @@ NoEditPermissionPolicy, PermissionSetViewSet, AuthGroup, + UserIndexView, register_auth_viewset, ) @@ -20,6 +21,14 @@ def test_register_auth_viewset(self): assert len(result.items) == 3 +class TestUserIndexView(TestCase): + def test_header_more_buttons(self): + view = UserIndexView() + assert len(view.header_more_buttons) == 1 + assert view.header_more_buttons[0].label == "Export to CSV" + assert view.header_more_buttons[0].url == "/cms-admin/user/export-csv/" + + class TestPermissionSetDetailsProperty(TestCase): def test_single_value_no_pipe(self):