Skip to content

Commit 9e10685

Browse files
feat: add role assignment audit trail
Introduces RoleAssignmentAudit, a read-only log of role assignment and removal events. Records store namespaced key strings (subject, role, scope) rather than FK references to live objects, so the audit history survives deletions independently of the authorization state. - Model and migration (0008) - Handler wired to ROLE_ASSIGNMENT_CREATED/DELETED signals - Read-only Django admin with scope-type filter and namespace-stripped display - Display properties (subject_display, role_display, scope_display) on the model - AUTHZ_POLICY_ATTRIBUTES_SEPARATOR moved to constants/ to avoid circular import - ADR 0012 updated with independence guarantee, actor exception, and filter rationale
1 parent cda1920 commit 9e10685

10 files changed

Lines changed: 447 additions & 16 deletions

File tree

docs/decisions/0012-auditability.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ Audit table
9999
``RoleAssignmentAudit`` mirrors the event payload. Registered in Django admin, filterable by
100100
user, role, scope, actor, and timestamp.
101101

102+
Subject, role, and scope are stored as plain namespaced key strings (e.g. ``user^alice``,
103+
``role^instructor``, ``lib^lib:Org1:lib1``). There are no FK references to live ``Subject``,
104+
``Scope``, or Casbin tables. Audit records survive the deletion of the underlying objects by
105+
design: the value of an audit log depends on its unconditional durability.
106+
107+
Because there are no FK references, the namespace prefix embedded in each string is the only
108+
available signal for categorizing records by type. Admin filters (e.g. "content library",
109+
"course") rely on ``scope__startswith`` lookups against that prefix rather than relational
110+
joins.
111+
102112
Developer extensibility
103113
-----------------------
104114

@@ -169,6 +179,15 @@ Both flows
169179
in the authorization library.
170180
- ``RoleAssignmentAudit`` is not tamper-proof. Compliance-grade immutability is a
171181
later-phase concern.
182+
- Audit records are independent from live authorization state. Deleting a subject, scope, or
183+
role does not remove its audit history. Records may reference identifiers that no longer
184+
exist in the system.
185+
- ``actor`` is the exception: it is stored as a FK to the ``User`` model with ``SET_NULL``.
186+
Deleting a user sets ``actor`` to ``None``, losing attribution for any audit records they
187+
produced. This is an accepted trade-off: user deletion is rare in Open edX (the standard
188+
path is retirement, which anonymizes rather than hard-deletes), and the FK enables direct
189+
admin filtering by actor. If unconditional attribution durability is needed, ``actor``
190+
should be changed to a plain string field.
172191

173192
Alternatives Considered
174193
***********************

openedx_authz/admin.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
from django import forms
55
from django.contrib import admin
66

7+
from openedx_authz.api.data import ContentLibraryData, CourseOverviewData
8+
from openedx_authz.constants import AUTHZ_POLICY_ATTRIBUTES_SEPARATOR
79
from openedx_authz.models import ExtendedCasbinRule
10+
from openedx_authz.models.core import RoleAssignmentAudit
811

912

1013
class CasbinRuleForm(forms.ModelForm):
@@ -48,3 +51,69 @@ class CasbinRuleAdmin(admin.ModelAdmin):
4851
# TODO: In a future, possibly we should only show an inline for the rules that
4952
# have an extended rule, and show the subject and scope information in detail.
5053
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

openedx_authz/api/data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from opaque_keys.edx.locator import LibraryLocatorV2
1414
from organizations.models import Organization
1515

16+
from openedx_authz.constants import AUTHZ_POLICY_ATTRIBUTES_SEPARATOR
1617
from openedx_authz.models.scopes import get_content_library_model, get_course_overview_model
1718

1819
ContentLibrary = get_content_library_model()
@@ -35,7 +36,6 @@
3536
"UserData",
3637
]
3738

38-
AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^"
3939
EXTERNAL_KEY_SEPARATOR = ":"
4040
GLOBAL_SCOPE_WILDCARD = "*"
4141
NAMESPACED_KEY_PATTERN = rf"^.+{re.escape(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR)}.+$"

openedx_authz/api/roles.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
from crum import get_current_user
1414
from django.db import transaction
1515

16+
from openedx_events.authz.data import RoleAssignmentData as RoleAssignmentEventData
17+
from openedx_events.authz.signals import ROLE_ASSIGNMENT_CREATED, ROLE_ASSIGNMENT_DELETED
18+
1619
from openedx_authz.api.data import (
1720
GroupingPolicyIndex,
1821
PermissionData,
@@ -25,8 +28,7 @@
2528
from openedx_authz.api.permissions import get_permission_from_policy
2629
from openedx_authz.engine.enforcer import AuthzEnforcer
2730
from openedx_authz.models import ExtendedCasbinRule
28-
from openedx_events.authz.signals import ROLE_ASSIGNMENT_CREATED, ROLE_ASSIGNMENT_DELETED
29-
from openedx_events.authz.data import RoleAssignmentData as RoleAssignmentEventData
31+
from openedx_authz.models.core import RoleAssignmentAudit
3032

3133
__all__ = [
3234
"get_permissions_for_single_role",
@@ -232,10 +234,10 @@ def assign_role_to_subject_in_scope(subject: SubjectData, role: RoleData, scope:
232234
transaction.on_commit(
233235
lambda: ROLE_ASSIGNMENT_CREATED.send_event(
234236
role_assignment=RoleAssignmentEventData(
235-
operation=RoleAssignmentEventData.OPERATIONS.created,
236-
subject=subject,
237-
role=role,
238-
scope=scope,
237+
operation=RoleAssignmentAudit.OPERATIONS.created,
238+
subject=subject.namespaced_key,
239+
role=role.namespaced_key,
240+
scope=scope.namespaced_key,
239241
actor=get_current_user()
240242
)
241243
)
@@ -283,10 +285,10 @@ def unassign_role_from_subject_in_scope(subject: SubjectData, role: RoleData, sc
283285
transaction.on_commit(
284286
lambda: ROLE_ASSIGNMENT_DELETED.send_event(
285287
role_assignment=RoleAssignmentEventData(
286-
operation=RoleAssignmentEventData.OPERATIONS.deleted,
287-
subject=subject,
288-
role=role,
289-
scope=scope,
288+
operation=RoleAssignmentAudit.OPERATIONS.deleted,
289+
subject=subject.namespaced_key,
290+
role=role.namespaced_key,
291+
scope=scope.namespaced_key,
290292
actor=get_current_user()
291293
)
292294
)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Shared low-level constants for openedx_authz.
2+
3+
Defined here rather than in api.data so that models and other modules at the
4+
bottom of the import chain can use them without creating circular imports.
5+
"""
6+
7+
AUTHZ_POLICY_ATTRIBUTES_SEPARATOR = "^"

openedx_authz/handlers.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99
from casbin_adapter.models import CasbinRule
1010
from django.db.models.signals import post_delete
1111
from django.dispatch import receiver
12+
from openedx_events.authz.signals import ROLE_ASSIGNMENT_CREATED, ROLE_ASSIGNMENT_DELETED
1213

1314
from openedx_authz.api.users import unassign_all_roles_from_user
14-
from openedx_authz.models.core import ExtendedCasbinRule
15+
from openedx_authz.models.core import ExtendedCasbinRule, RoleAssignmentAudit
1516

1617
try:
1718
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL
@@ -82,3 +83,33 @@ def unassign_roles_on_user_retirement(sender, user, **kwargs): # pylint: disabl
8283
# Only register the handler if the signal is available (i.e., running in Open edX)
8384
if USER_RETIRE_LMS_CRITICAL is not None:
8485
USER_RETIRE_LMS_CRITICAL.connect(unassign_roles_on_user_retirement)
86+
87+
88+
@receiver(ROLE_ASSIGNMENT_CREATED)
89+
@receiver(ROLE_ASSIGNMENT_DELETED)
90+
def create_audit_record_on_role_assignment_change(sender, role_assignment, **kwargs): # pylint: disable=unused-argument
91+
"""
92+
Create an audit record when a role assignment is created or deleted.
93+
94+
This handler listens for both creation and deletion of role assignments and logs the changes
95+
for auditing purposes.
96+
97+
Args:
98+
sender: The signal class (ROLE_ASSIGNMENT_CREATED or ROLE_ASSIGNMENT_DELETED).
99+
role_assignment: RoleAssignmentEventData carrying the operation, subject, role, scope, and actor.
100+
**kwargs: Additional keyword arguments from the signal.
101+
"""
102+
try:
103+
RoleAssignmentAudit.objects.create(
104+
operation=role_assignment.operation,
105+
subject=role_assignment.subject,
106+
role=role_assignment.role,
107+
scope=role_assignment.scope,
108+
actor=role_assignment.actor,
109+
timestamp=kwargs["metadata"].time,
110+
)
111+
except Exception as exc: # pylint: disable=broad-exception-caught
112+
logger.exception(
113+
"Error creating audit record for role assignment change: %s",
114+
exc,
115+
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Generated by Django 4.2.24 on 2026-04-14 09:51
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
("openedx_authz", "0007_coursescope"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="RoleAssignmentAudit",
18+
fields=[
19+
(
20+
"id",
21+
models.BigAutoField(
22+
auto_created=True,
23+
primary_key=True,
24+
serialize=False,
25+
verbose_name="ID",
26+
),
27+
),
28+
(
29+
"operation",
30+
models.CharField(
31+
choices=[("created", "created"), ("deleted", "deleted")],
32+
max_length=20,
33+
),
34+
),
35+
("subject", models.CharField(max_length=255)),
36+
("role", models.CharField(max_length=255)),
37+
("scope", models.CharField(max_length=255)),
38+
("timestamp", models.DateTimeField()),
39+
(
40+
"actor",
41+
models.ForeignKey(
42+
blank=True,
43+
null=True,
44+
on_delete=django.db.models.deletion.SET_NULL,
45+
related_name="performed_role_assignment_audits",
46+
to=settings.AUTH_USER_MODEL,
47+
),
48+
),
49+
],
50+
options={
51+
"verbose_name": "Role Assignment Audit",
52+
"verbose_name_plural": "Role Assignment Audits",
53+
"ordering": ["-timestamp"],
54+
},
55+
),
56+
]

openedx_authz/models/core.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@
55
schema that focuses on the core authorization logic.
66
"""
77

8-
from typing import ClassVar
8+
from typing import ClassVar, NamedTuple
99

10+
from django.contrib.auth import get_user_model
1011
from django.db import models, transaction
1112

13+
from openedx_authz.constants import AUTHZ_POLICY_ATTRIBUTES_SEPARATOR
1214
from openedx_authz.engine.filter import Filter
1315

1416

17+
User = get_user_model()
18+
1519
class BaseRegistryModel(models.Model):
1620
"""Base model that supports automatic subclass registration.
1721
@@ -231,3 +235,65 @@ def create_based_on_policy(
231235
)
232236

233237
return extended_rule
238+
239+
240+
class RoleAssignmentAudit(models.Model):
241+
"""Model to audit role assignments and unassignments.
242+
243+
.. no_pii:
244+
245+
This model captures the history of role assignments and unassignments for subjects
246+
within specific scopes. It can be used for auditing purposes to track changes in
247+
role assignments over time.
248+
"""
249+
250+
class Operations(NamedTuple):
251+
created: str = "created"
252+
deleted: str = "deleted"
253+
254+
OPERATIONS = Operations()
255+
256+
operation = models.CharField(
257+
max_length=20,
258+
choices=[(op, op) for op in OPERATIONS],
259+
)
260+
subject = models.CharField(
261+
max_length=255,
262+
help_text="Namespaced key of the subject (e.g. 'user^john_doe').",
263+
)
264+
role = models.CharField(
265+
max_length=255,
266+
help_text="Namespaced key of the role (e.g. 'role^library_admin').",
267+
)
268+
scope = models.CharField(
269+
max_length=255,
270+
help_text="Namespaced key of the scope (e.g. 'course-v1^course-v1:org+course+run') or glob pattern.",
271+
)
272+
actor = models.ForeignKey(
273+
User,
274+
on_delete=models.SET_NULL,
275+
null=True,
276+
blank=True,
277+
related_name="performed_role_assignment_audits",
278+
)
279+
timestamp = models.DateTimeField()
280+
281+
class Meta:
282+
verbose_name = "Role Assignment Audit"
283+
verbose_name_plural = "Role Assignment Audits"
284+
ordering = ["-timestamp"]
285+
286+
@property
287+
def subject_display(self) -> str:
288+
"""Subject key without the namespace prefix (e.g. ``john_doe`` for ``user^john_doe``)."""
289+
return self.subject.split(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR, 1)[-1]
290+
291+
@property
292+
def role_display(self) -> str:
293+
"""Role name without the namespace prefix (e.g. ``library_admin`` for ``role^library_admin``)."""
294+
return self.role.split(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR, 1)[-1]
295+
296+
@property
297+
def scope_display(self) -> str:
298+
"""Scope key without the namespace prefix (e.g. ``lib:Org1:lib1`` for ``lib^lib:Org1:lib1``)."""
299+
return self.scope.split(AUTHZ_POLICY_ATTRIBUTES_SEPARATOR, 1)[-1]

0 commit comments

Comments
 (0)