From 476b08a23be62e7abf2ba4f6e3330a598dec10d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:56:47 +0000 Subject: [PATCH] Add AI permission classes and comprehensive tests Co-authored-by: dawnsystem <42047891+dawnsystem@users.noreply.github.com> --- .../migrations/1073_add_ai_permissions.py | 26 + src/documents/models.py | 6 + src/documents/permissions.py | 82 +++ src/documents/tests/test_ai_permissions.py | 524 ++++++++++++++++++ 4 files changed, 638 insertions(+) create mode 100644 src/documents/migrations/1073_add_ai_permissions.py create mode 100644 src/documents/tests/test_ai_permissions.py diff --git a/src/documents/migrations/1073_add_ai_permissions.py b/src/documents/migrations/1073_add_ai_permissions.py new file mode 100644 index 000000000..0fea83d94 --- /dev/null +++ b/src/documents/migrations/1073_add_ai_permissions.py @@ -0,0 +1,26 @@ +# Generated migration for adding AI-related custom permissions + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "1072_workflowtrigger_filter_custom_field_query_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="document", + options={ + "ordering": ("-created",), + "permissions": [ + ("can_view_ai_suggestions", "Can view AI suggestions"), + ("can_apply_ai_suggestions", "Can apply AI suggestions"), + ("can_approve_deletions", "Can approve AI-recommended deletions"), + ("can_configure_ai", "Can configure AI settings"), + ], + "verbose_name": "document", + "verbose_name_plural": "documents", + }, + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 7b0b84b77..a31ce2e4d 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -317,6 +317,12 @@ class Document(SoftDeleteModel, ModelWithOwner): ordering = ("-created",) verbose_name = _("document") verbose_name_plural = _("documents") + permissions = [ + ("can_view_ai_suggestions", "Can view AI suggestions"), + ("can_apply_ai_suggestions", "Can apply AI suggestions"), + ("can_approve_deletions", "Can approve AI-recommended deletions"), + ("can_configure_ai", "Can configure AI settings"), + ] def __str__(self) -> str: created = self.created.isoformat() diff --git a/src/documents/permissions.py b/src/documents/permissions.py index cf6a9aa35..2ab20b497 100644 --- a/src/documents/permissions.py +++ b/src/documents/permissions.py @@ -219,3 +219,85 @@ class AcknowledgeTasksPermissions(BasePermission): perms = self.perms_map.get(request.method, []) return request.user.has_perms(perms) + + +class CanViewAISuggestionsPermission(BasePermission): + """ + Permission class to check if user can view AI suggestions. + + This permission allows users to view AI scan results and suggestions + for documents, including tags, correspondents, document types, and + other metadata suggestions. + """ + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + + # Superusers always have permission + if request.user.is_superuser: + return True + + # Check for specific permission + return request.user.has_perm("documents.can_view_ai_suggestions") + + +class CanApplyAISuggestionsPermission(BasePermission): + """ + Permission class to check if user can apply AI suggestions to documents. + + This permission allows users to apply AI-generated suggestions to documents, + such as auto-applying tags, correspondents, document types, etc. + """ + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + + # Superusers always have permission + if request.user.is_superuser: + return True + + # Check for specific permission + return request.user.has_perm("documents.can_apply_ai_suggestions") + + +class CanApproveDeletionsPermission(BasePermission): + """ + Permission class to check if user can approve AI-recommended deletions. + + This permission is required to approve deletion requests initiated by AI, + ensuring that no documents are deleted without explicit user authorization. + """ + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + + # Superusers always have permission + if request.user.is_superuser: + return True + + # Check for specific permission + return request.user.has_perm("documents.can_approve_deletions") + + +class CanConfigureAIPermission(BasePermission): + """ + Permission class to check if user can configure AI settings. + + This permission allows users to configure AI scanner settings, including + confidence thresholds, auto-apply behavior, and ML feature toggles. + Typically restricted to administrators. + """ + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + + # Superusers always have permission + if request.user.is_superuser: + return True + + # Check for specific permission + return request.user.has_perm("documents.can_configure_ai") diff --git a/src/documents/tests/test_ai_permissions.py b/src/documents/tests/test_ai_permissions.py new file mode 100644 index 000000000..f8266b2cd --- /dev/null +++ b/src/documents/tests/test_ai_permissions.py @@ -0,0 +1,524 @@ +""" +Unit tests for AI-related permissions. + +Tests cover: +- CanViewAISuggestionsPermission +- CanApplyAISuggestionsPermission +- CanApproveDeletionsPermission +- CanConfigureAIPermission +- Role-based access control +- Permission assignment and verification +""" + +from django.contrib.auth.models import Group, Permission, User +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +from rest_framework.test import APIRequestFactory + +from documents.models import Document +from documents.permissions import ( + CanApplyAISuggestionsPermission, + CanApproveDeletionsPermission, + CanConfigureAIPermission, + CanViewAISuggestionsPermission, +) + + +class MockView: + """Mock view for testing permissions.""" + + pass + + +class TestCanViewAISuggestionsPermission(TestCase): + """Test the CanViewAISuggestionsPermission class.""" + + def setUp(self): + """Set up test users and permissions.""" + self.factory = APIRequestFactory() + self.permission = CanViewAISuggestionsPermission() + self.view = MockView() + + # Create users + self.superuser = User.objects.create_superuser( + username="admin", email="admin@test.com", password="admin123" + ) + self.regular_user = User.objects.create_user( + username="regular", email="regular@test.com", password="regular123" + ) + self.permitted_user = User.objects.create_user( + username="permitted", email="permitted@test.com", password="permitted123" + ) + + # Assign permission to permitted_user + content_type = ContentType.objects.get_for_model(Document) + permission, created = Permission.objects.get_or_create( + codename="can_view_ai_suggestions", + name="Can view AI suggestions", + content_type=content_type, + ) + self.permitted_user.user_permissions.add(permission) + + def test_unauthenticated_user_denied(self): + """Test that unauthenticated users are denied.""" + request = self.factory.get("/api/ai/suggestions/") + request.user = None + + result = self.permission.has_permission(request, self.view) + + self.assertFalse(result) + + def test_superuser_allowed(self): + """Test that superusers are always allowed.""" + request = self.factory.get("/api/ai/suggestions/") + request.user = self.superuser + + result = self.permission.has_permission(request, self.view) + + self.assertTrue(result) + + def test_regular_user_without_permission_denied(self): + """Test that regular users without permission are denied.""" + request = self.factory.get("/api/ai/suggestions/") + request.user = self.regular_user + + result = self.permission.has_permission(request, self.view) + + self.assertFalse(result) + + def test_user_with_permission_allowed(self): + """Test that users with permission are allowed.""" + request = self.factory.get("/api/ai/suggestions/") + request.user = self.permitted_user + + result = self.permission.has_permission(request, self.view) + + self.assertTrue(result) + + +class TestCanApplyAISuggestionsPermission(TestCase): + """Test the CanApplyAISuggestionsPermission class.""" + + def setUp(self): + """Set up test users and permissions.""" + self.factory = APIRequestFactory() + self.permission = CanApplyAISuggestionsPermission() + self.view = MockView() + + # Create users + self.superuser = User.objects.create_superuser( + username="admin", email="admin@test.com", password="admin123" + ) + self.regular_user = User.objects.create_user( + username="regular", email="regular@test.com", password="regular123" + ) + self.permitted_user = User.objects.create_user( + username="permitted", email="permitted@test.com", password="permitted123" + ) + + # Assign permission to permitted_user + content_type = ContentType.objects.get_for_model(Document) + permission, created = Permission.objects.get_or_create( + codename="can_apply_ai_suggestions", + name="Can apply AI suggestions", + content_type=content_type, + ) + self.permitted_user.user_permissions.add(permission) + + def test_unauthenticated_user_denied(self): + """Test that unauthenticated users are denied.""" + request = self.factory.post("/api/ai/suggestions/apply/") + request.user = None + + result = self.permission.has_permission(request, self.view) + + self.assertFalse(result) + + def test_superuser_allowed(self): + """Test that superusers are always allowed.""" + request = self.factory.post("/api/ai/suggestions/apply/") + request.user = self.superuser + + result = self.permission.has_permission(request, self.view) + + self.assertTrue(result) + + def test_regular_user_without_permission_denied(self): + """Test that regular users without permission are denied.""" + request = self.factory.post("/api/ai/suggestions/apply/") + request.user = self.regular_user + + result = self.permission.has_permission(request, self.view) + + self.assertFalse(result) + + def test_user_with_permission_allowed(self): + """Test that users with permission are allowed.""" + request = self.factory.post("/api/ai/suggestions/apply/") + request.user = self.permitted_user + + result = self.permission.has_permission(request, self.view) + + self.assertTrue(result) + + +class TestCanApproveDeletionsPermission(TestCase): + """Test the CanApproveDeletionsPermission class.""" + + def setUp(self): + """Set up test users and permissions.""" + self.factory = APIRequestFactory() + self.permission = CanApproveDeletionsPermission() + self.view = MockView() + + # Create users + self.superuser = User.objects.create_superuser( + username="admin", email="admin@test.com", password="admin123" + ) + self.regular_user = User.objects.create_user( + username="regular", email="regular@test.com", password="regular123" + ) + self.permitted_user = User.objects.create_user( + username="permitted", email="permitted@test.com", password="permitted123" + ) + + # Assign permission to permitted_user + content_type = ContentType.objects.get_for_model(Document) + permission, created = Permission.objects.get_or_create( + codename="can_approve_deletions", + name="Can approve AI-recommended deletions", + content_type=content_type, + ) + self.permitted_user.user_permissions.add(permission) + + def test_unauthenticated_user_denied(self): + """Test that unauthenticated users are denied.""" + request = self.factory.post("/api/ai/deletions/approve/") + request.user = None + + result = self.permission.has_permission(request, self.view) + + self.assertFalse(result) + + def test_superuser_allowed(self): + """Test that superusers are always allowed.""" + request = self.factory.post("/api/ai/deletions/approve/") + request.user = self.superuser + + result = self.permission.has_permission(request, self.view) + + self.assertTrue(result) + + def test_regular_user_without_permission_denied(self): + """Test that regular users without permission are denied.""" + request = self.factory.post("/api/ai/deletions/approve/") + request.user = self.regular_user + + result = self.permission.has_permission(request, self.view) + + self.assertFalse(result) + + def test_user_with_permission_allowed(self): + """Test that users with permission are allowed.""" + request = self.factory.post("/api/ai/deletions/approve/") + request.user = self.permitted_user + + result = self.permission.has_permission(request, self.view) + + self.assertTrue(result) + + +class TestCanConfigureAIPermission(TestCase): + """Test the CanConfigureAIPermission class.""" + + def setUp(self): + """Set up test users and permissions.""" + self.factory = APIRequestFactory() + self.permission = CanConfigureAIPermission() + self.view = MockView() + + # Create users + self.superuser = User.objects.create_superuser( + username="admin", email="admin@test.com", password="admin123" + ) + self.regular_user = User.objects.create_user( + username="regular", email="regular@test.com", password="regular123" + ) + self.permitted_user = User.objects.create_user( + username="permitted", email="permitted@test.com", password="permitted123" + ) + + # Assign permission to permitted_user + content_type = ContentType.objects.get_for_model(Document) + permission, created = Permission.objects.get_or_create( + codename="can_configure_ai", + name="Can configure AI settings", + content_type=content_type, + ) + self.permitted_user.user_permissions.add(permission) + + def test_unauthenticated_user_denied(self): + """Test that unauthenticated users are denied.""" + request = self.factory.post("/api/ai/config/") + request.user = None + + result = self.permission.has_permission(request, self.view) + + self.assertFalse(result) + + def test_superuser_allowed(self): + """Test that superusers are always allowed.""" + request = self.factory.post("/api/ai/config/") + request.user = self.superuser + + result = self.permission.has_permission(request, self.view) + + self.assertTrue(result) + + def test_regular_user_without_permission_denied(self): + """Test that regular users without permission are denied.""" + request = self.factory.post("/api/ai/config/") + request.user = self.regular_user + + result = self.permission.has_permission(request, self.view) + + self.assertFalse(result) + + def test_user_with_permission_allowed(self): + """Test that users with permission are allowed.""" + request = self.factory.post("/api/ai/config/") + request.user = self.permitted_user + + result = self.permission.has_permission(request, self.view) + + self.assertTrue(result) + + +class TestRoleBasedAccessControl(TestCase): + """Test role-based access control for AI permissions.""" + + def setUp(self): + """Set up test groups and permissions.""" + # Create groups + self.viewer_group = Group.objects.create(name="AI Viewers") + self.editor_group = Group.objects.create(name="AI Editors") + self.admin_group = Group.objects.create(name="AI Administrators") + + # Get permissions + content_type = ContentType.objects.get_for_model(Document) + self.view_permission, _ = Permission.objects.get_or_create( + codename="can_view_ai_suggestions", + name="Can view AI suggestions", + content_type=content_type, + ) + self.apply_permission, _ = Permission.objects.get_or_create( + codename="can_apply_ai_suggestions", + name="Can apply AI suggestions", + content_type=content_type, + ) + self.approve_permission, _ = Permission.objects.get_or_create( + codename="can_approve_deletions", + name="Can approve AI-recommended deletions", + content_type=content_type, + ) + self.config_permission, _ = Permission.objects.get_or_create( + codename="can_configure_ai", + name="Can configure AI settings", + content_type=content_type, + ) + + # Assign permissions to groups + # Viewers can only view + self.viewer_group.permissions.add(self.view_permission) + + # Editors can view and apply + self.editor_group.permissions.add(self.view_permission, self.apply_permission) + + # Admins can do everything + self.admin_group.permissions.add( + self.view_permission, + self.apply_permission, + self.approve_permission, + self.config_permission, + ) + + def test_viewer_role_permissions(self): + """Test that viewer role has appropriate permissions.""" + user = User.objects.create_user( + username="viewer", email="viewer@test.com", password="viewer123" + ) + user.groups.add(self.viewer_group) + + # Refresh user to get updated permissions + user = User.objects.get(pk=user.pk) + + self.assertTrue(user.has_perm("documents.can_view_ai_suggestions")) + self.assertFalse(user.has_perm("documents.can_apply_ai_suggestions")) + self.assertFalse(user.has_perm("documents.can_approve_deletions")) + self.assertFalse(user.has_perm("documents.can_configure_ai")) + + def test_editor_role_permissions(self): + """Test that editor role has appropriate permissions.""" + user = User.objects.create_user( + username="editor", email="editor@test.com", password="editor123" + ) + user.groups.add(self.editor_group) + + # Refresh user to get updated permissions + user = User.objects.get(pk=user.pk) + + self.assertTrue(user.has_perm("documents.can_view_ai_suggestions")) + self.assertTrue(user.has_perm("documents.can_apply_ai_suggestions")) + self.assertFalse(user.has_perm("documents.can_approve_deletions")) + self.assertFalse(user.has_perm("documents.can_configure_ai")) + + def test_admin_role_permissions(self): + """Test that admin role has all permissions.""" + user = User.objects.create_user( + username="ai_admin", email="ai_admin@test.com", password="admin123" + ) + user.groups.add(self.admin_group) + + # Refresh user to get updated permissions + user = User.objects.get(pk=user.pk) + + self.assertTrue(user.has_perm("documents.can_view_ai_suggestions")) + self.assertTrue(user.has_perm("documents.can_apply_ai_suggestions")) + self.assertTrue(user.has_perm("documents.can_approve_deletions")) + self.assertTrue(user.has_perm("documents.can_configure_ai")) + + def test_user_with_multiple_groups(self): + """Test that user permissions accumulate from multiple groups.""" + user = User.objects.create_user( + username="multi_role", email="multi@test.com", password="multi123" + ) + user.groups.add(self.viewer_group, self.editor_group) + + # Refresh user to get updated permissions + user = User.objects.get(pk=user.pk) + + # Should have both viewer and editor permissions + self.assertTrue(user.has_perm("documents.can_view_ai_suggestions")) + self.assertTrue(user.has_perm("documents.can_apply_ai_suggestions")) + self.assertFalse(user.has_perm("documents.can_approve_deletions")) + + def test_direct_permission_assignment_overrides_group(self): + """Test that direct permission assignment works alongside group permissions.""" + user = User.objects.create_user( + username="special", email="special@test.com", password="special123" + ) + user.groups.add(self.viewer_group) + + # Directly assign approval permission + user.user_permissions.add(self.approve_permission) + + # Refresh user to get updated permissions + user = User.objects.get(pk=user.pk) + + # Should have viewer group permissions plus direct permission + self.assertTrue(user.has_perm("documents.can_view_ai_suggestions")) + self.assertFalse(user.has_perm("documents.can_apply_ai_suggestions")) + self.assertTrue(user.has_perm("documents.can_approve_deletions")) + self.assertFalse(user.has_perm("documents.can_configure_ai")) + + +class TestPermissionAssignment(TestCase): + """Test permission assignment and revocation.""" + + def setUp(self): + """Set up test user.""" + self.user = User.objects.create_user( + username="testuser", email="test@test.com", password="test123" + ) + content_type = ContentType.objects.get_for_model(Document) + self.view_permission, _ = Permission.objects.get_or_create( + codename="can_view_ai_suggestions", + name="Can view AI suggestions", + content_type=content_type, + ) + + def test_assign_permission_to_user(self): + """Test assigning permission to user.""" + self.assertFalse(self.user.has_perm("documents.can_view_ai_suggestions")) + + self.user.user_permissions.add(self.view_permission) + self.user = User.objects.get(pk=self.user.pk) + + self.assertTrue(self.user.has_perm("documents.can_view_ai_suggestions")) + + def test_revoke_permission_from_user(self): + """Test revoking permission from user.""" + self.user.user_permissions.add(self.view_permission) + self.user = User.objects.get(pk=self.user.pk) + self.assertTrue(self.user.has_perm("documents.can_view_ai_suggestions")) + + self.user.user_permissions.remove(self.view_permission) + self.user = User.objects.get(pk=self.user.pk) + + self.assertFalse(self.user.has_perm("documents.can_view_ai_suggestions")) + + def test_permission_persistence(self): + """Test that permissions persist across user retrieval.""" + self.user.user_permissions.add(self.view_permission) + + # Get user from database + retrieved_user = User.objects.get(username="testuser") + + self.assertTrue(retrieved_user.has_perm("documents.can_view_ai_suggestions")) + + +class TestPermissionEdgeCases(TestCase): + """Test edge cases and error conditions for permissions.""" + + def setUp(self): + """Set up test data.""" + self.factory = APIRequestFactory() + self.view = MockView() + + def test_anonymous_user_request(self): + """Test handling of anonymous user.""" + from django.contrib.auth.models import AnonymousUser + + permission = CanViewAISuggestionsPermission() + request = self.factory.get("/api/ai/suggestions/") + request.user = AnonymousUser() + + result = permission.has_permission(request, self.view) + + self.assertFalse(result) + + def test_missing_user_attribute(self): + """Test handling of request without user attribute.""" + permission = CanViewAISuggestionsPermission() + request = self.factory.get("/api/ai/suggestions/") + # Don't set request.user + + result = permission.has_permission(request, self.view) + + self.assertFalse(result) + + def test_inactive_user_with_permission(self): + """Test that inactive users are denied even with permission.""" + user = User.objects.create_user( + username="inactive", email="inactive@test.com", password="inactive123" + ) + user.is_active = False + user.save() + + # Add permission + content_type = ContentType.objects.get_for_model(Document) + permission, _ = Permission.objects.get_or_create( + codename="can_view_ai_suggestions", + name="Can view AI suggestions", + content_type=content_type, + ) + user.user_permissions.add(permission) + + permission_check = CanViewAISuggestionsPermission() + request = self.factory.get("/api/ai/suggestions/") + request.user = user + + # Inactive users should not pass authentication check + result = permission_check.has_permission(request, self.view) + + self.assertFalse(result)