|
4 | 4 | from django import forms |
5 | 5 | from django.contrib import admin |
6 | 6 |
|
| 7 | +from openedx_authz.api.data import ContentLibraryData, CourseOverviewData |
| 8 | +from openedx_authz.constants import AUTHZ_POLICY_ATTRIBUTES_SEPARATOR |
7 | 9 | from openedx_authz.models import ExtendedCasbinRule |
| 10 | +from openedx_authz.models.core import RoleAssignmentAudit |
8 | 11 |
|
9 | 12 |
|
10 | 13 | class CasbinRuleForm(forms.ModelForm): |
@@ -48,3 +51,69 @@ class CasbinRuleAdmin(admin.ModelAdmin): |
48 | 51 | # TODO: In a future, possibly we should only show an inline for the rules that |
49 | 52 | # have an extended rule, and show the subject and scope information in detail. |
50 | 53 | inlines = [ExtendedCasbinRuleInline] |
| 54 | + |
| 55 | + |
| 56 | +class ScopeTypeFilter(admin.SimpleListFilter): |
| 57 | + """Filter audit records by scope type (content library, course, etc.).""" |
| 58 | + |
| 59 | + title = "scope type" |
| 60 | + parameter_name = "scope_type" |
| 61 | + |
| 62 | + def lookups(self, request, model_admin): |
| 63 | + """Return the available scope type choices. |
| 64 | +
|
| 65 | + Audit records are independent from live Casbin tables and scope objects: |
| 66 | + there are no FK references to filter on. The namespace prefix in the |
| 67 | + stored ``scope`` string (e.g. ``lib^``, ``course-v1^``) is the only |
| 68 | + available signal for categorizing records by scope type. |
| 69 | + """ |
| 70 | + return [ |
| 71 | + (ContentLibraryData.NAMESPACE, "Content Library"), |
| 72 | + (CourseOverviewData.NAMESPACE, "Course"), |
| 73 | + ] |
| 74 | + |
| 75 | + def queryset(self, request, queryset): |
| 76 | + """Filter the queryset by scope namespace prefix.""" |
| 77 | + if self.value(): |
| 78 | + return queryset.filter( |
| 79 | + scope__startswith=f"{self.value()}{AUTHZ_POLICY_ATTRIBUTES_SEPARATOR}" |
| 80 | + ) |
| 81 | + return queryset |
| 82 | + |
| 83 | + |
| 84 | +@admin.register(RoleAssignmentAudit) |
| 85 | +class RoleAssignmentAuditAdmin(admin.ModelAdmin): |
| 86 | + """Read-only admin for the role assignment audit log.""" |
| 87 | + |
| 88 | + list_display = ("operation", "display_subject", "display_role", "display_scope", "actor", "timestamp") |
| 89 | + list_filter = ("operation", ScopeTypeFilter) |
| 90 | + search_fields = ("subject", "role", "scope") |
| 91 | + date_hierarchy = "timestamp" |
| 92 | + readonly_fields = ("operation", "subject", "role", "scope", "actor", "timestamp") |
| 93 | + |
| 94 | + @admin.display(description="subject") |
| 95 | + def display_subject(self, obj): |
| 96 | + """Subject key without the namespace prefix.""" |
| 97 | + return obj.subject_display |
| 98 | + |
| 99 | + @admin.display(description="role") |
| 100 | + def display_role(self, obj): |
| 101 | + """Role name without the namespace prefix.""" |
| 102 | + return obj.role_display |
| 103 | + |
| 104 | + @admin.display(description="scope") |
| 105 | + def display_scope(self, obj): |
| 106 | + """Scope key without the namespace prefix.""" |
| 107 | + return obj.scope_display |
| 108 | + |
| 109 | + def has_add_permission(self, request): |
| 110 | + """Audit records are created by the system only.""" |
| 111 | + return False |
| 112 | + |
| 113 | + def has_change_permission(self, request, obj=None): |
| 114 | + """Audit records must not be modified after creation.""" |
| 115 | + return False |
| 116 | + |
| 117 | + def has_delete_permission(self, request, obj=None): |
| 118 | + """Audit records must not be deleted through the admin.""" |
| 119 | + return False |
0 commit comments