Skip to content

Commit ff6ddeb

Browse files
feat: store actor as username string in RoleAssignmentAudit
Change RoleAssignmentAudit.actor from ForeignKey(User) to a plain CharField storing the username. Attribution of the operation is preserved unconditionally: deleting or retiring a user does not affect existing audit records, and the audit log has no dependency on the User table. The event payload carries actor as a username string resolved from get_current_user() at API call time. Update ADR-0012 to document the decision and its rationale.
1 parent f9b6043 commit ff6ddeb

5 files changed

Lines changed: 42 additions & 28 deletions

File tree

docs/decisions/0012-auditability.rst

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,12 @@ Event payload
7070
"subject": "<namespaced subject key, e.g. user^alice>",
7171
"role": "<namespaced role key, e.g. role^instructor>",
7272
"scope": "<namespaced scope key, e.g. course-v1^course-v1:Org+Course+Run>",
73-
"actor": "<User object for the caller, or None for system actor>",
73+
"actor": "<username of the caller, or None for system actor>",
7474
}
7575
7676
The actor is resolved from ``django_crum.get_current_user()`` at API call time. No callers
77-
need to pass ``actor=`` explicitly.
77+
need to pass ``actor=`` explicitly. The username is stored as a plain string rather than a
78+
reference to the ``User`` record, so attribution is preserved even if the user is deleted.
7879

7980
Audit table
8081
-----------
@@ -141,11 +142,11 @@ Consequences
141142
Consumers requiring guaranteed delivery must implement their own retry logic.
142143

143144
#. **``actor`` is nullable.** Non-request contexts (management commands, background tasks)
144-
record ``None``, logged as a system operation. ``actor`` is stored as a FK to ``User``
145-
with ``SET_NULL``: deleting a user loses attribution for their audit records. This is
146-
accepted because user deletion is rare in Open edX (retirement anonymizes rather than
147-
hard-deletes), and the FK enables admin filtering by actor. If unconditional attribution
148-
durability is needed, ``actor`` should be a plain string field instead.
145+
record ``None``, logged as a system operation. ``actor`` is stored as a plain username
146+
string rather than a FK to ``User``. Attribution is preserved unconditionally: deleting
147+
or retiring a user does not affect existing audit records. This also avoids a dependency
148+
on the ``User`` table from the audit log, keeping audit records fully independent from
149+
live data.
149150

150151
#. **Audit records are independent from live authorization state.** Deleting a subject,
151152
scope, or role does not remove its audit history. Records may reference identifiers that

openedx_authz/api/roles.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ def assign_role_to_subject_in_scope(subject: SubjectData, role: RoleData, scope:
243243
subject=subject.namespaced_key,
244244
role=role.namespaced_key,
245245
scope=scope.namespaced_key,
246-
actor=get_current_user()
246+
actor=getattr(get_current_user(), "username", None)
247247
)
248248
)
249249
)
@@ -294,7 +294,7 @@ def unassign_role_from_subject_in_scope(subject: SubjectData, role: RoleData, sc
294294
subject=subject.namespaced_key,
295295
role=role.namespaced_key,
296296
scope=scope.namespaced_key,
297-
actor=get_current_user()
297+
actor=getattr(get_current_user(), "username", None)
298298
)
299299
)
300300
)

openedx_authz/migrations/0008_roleassignmentaudit.py

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
# Generated by Django 4.2.24 on 2026-04-14 09:51
1+
# Generated by Django 4.2.24 on 2026-04-15 16:48
22

3-
from django.conf import settings
43
from django.db import migrations, models
5-
import django.db.models.deletion
64

75

86
class Migration(migrations.Migration):
97

108
dependencies = [
11-
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
129
("openedx_authz", "0007_coursescope"),
1310
]
1411

@@ -32,20 +29,37 @@ class Migration(migrations.Migration):
3229
max_length=20,
3330
),
3431
),
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()),
32+
(
33+
"subject",
34+
models.CharField(
35+
help_text="Namespaced key of the subject (e.g. 'user^john_doe').",
36+
max_length=255,
37+
),
38+
),
39+
(
40+
"role",
41+
models.CharField(
42+
help_text="Namespaced key of the role (e.g. 'role^library_admin').",
43+
max_length=255,
44+
),
45+
),
46+
(
47+
"scope",
48+
models.CharField(
49+
help_text="Namespaced key of the scope (e.g. 'course-v1^course-v1:org+course+run') or glob pattern.",
50+
max_length=255,
51+
),
52+
),
3953
(
4054
"actor",
41-
models.ForeignKey(
55+
models.CharField(
4256
blank=True,
57+
help_text="Username of the user who performed the operation, or None for system-initiated actions.",
58+
max_length=150,
4359
null=True,
44-
on_delete=django.db.models.deletion.SET_NULL,
45-
related_name="performed_role_assignment_audits",
46-
to=settings.AUTH_USER_MODEL,
4760
),
4861
),
62+
("timestamp", models.DateTimeField()),
4963
],
5064
options={
5165
"verbose_name": "Role Assignment Audit",

openedx_authz/models/core.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,12 +288,11 @@ class Operations(NamedTuple):
288288
max_length=255,
289289
help_text="Namespaced key of the scope (e.g. 'course-v1^course-v1:org+course+run') or glob pattern.",
290290
)
291-
actor = models.ForeignKey(
292-
User,
293-
on_delete=models.SET_NULL,
291+
actor = models.CharField(
292+
max_length=150,
294293
null=True,
295294
blank=True,
296-
related_name="performed_role_assignment_audits",
295+
help_text="Username of the user who performed the operation, or None for system-initiated actions.",
297296
)
298297
timestamp = models.DateTimeField()
299298

openedx_authz/tests/api/test_roles.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
"""
77

88
from importlib.resources import files
9-
from unittest.mock import MagicMock, patch
9+
from unittest.mock import patch
1010

1111
import casbin
1212
from ddt import data as ddt_data
1313
from ddt import ddt, unpack
1414
from django.test import TestCase
15-
from openedx_events.authz.signals import ROLE_ASSIGNMENT_CREATED, ROLE_ASSIGNMENT_DELETED
15+
1616

1717
from openedx_authz.api.data import (
1818
ActionData,
@@ -52,7 +52,7 @@
5252
from openedx_authz.engine.enforcer import AuthzEnforcer
5353
from openedx_authz.engine.utils import migrate_policy_between_enforcers
5454
from openedx_authz.models import ExtendedCasbinRule, Scope, Subject
55-
from openedx_authz.models.core import RoleAssignmentAudit
55+
5656

5757

5858
def _mock_get_or_create_scope(scope_data):

0 commit comments

Comments
 (0)