Skip to content

Commit d5bb0ac

Browse files
feat: add role assignment audit trail (#261)
Emits ROLE_ASSIGNMENT_CREATED and ROLE_ASSIGNMENT_DELETED events after role assignment operations commit, so consumers can stay up to date on the authorization state of the system. Each event includes the subject, role, scope, and the actor performing the operation (via get_current_user()). Also adds the auditability ADR (0012) documenting the design decisions behind this feature.
1 parent 7afbff4 commit d5bb0ac

20 files changed

Lines changed: 764 additions & 43 deletions

File tree

CHANGELOG.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ Change Log
1414
Unreleased
1515
**********
1616

17+
1.13.0 - 2026-04-22
18+
*******************
19+
20+
Added
21+
=====
22+
23+
* Add ``RoleAssignmentAudit`` model to record role assignment and removal events, including operation type, subject, role, scope, actor database ID, and timestamp.
24+
* Emit ``ROLE_ASSIGNMENT_CREATED`` and ``ROLE_ASSIGNMENT_DELETED`` Open edX public signal events via ``transaction.on_commit`` after every successful role assignment or removal.
25+
* Add Django admin for ``RoleAssignmentAudit`` with filters by operation type and scope type (course, content library), date hierarchy, and search by subject, role, and scope.
26+
1727
1.12.0 - 2026-04-20
1828
*******************
1929

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
0012: Auditability for Authorization Changes
2+
############################################
3+
4+
Status
5+
******
6+
7+
**Draft**
8+
9+
Context
10+
*******
11+
12+
The existing architecture (see `ADR 0005`_) introduced ``ExtendedCasbinRule``, which adds
13+
``created_at``, ``updated_at``, and a ``metadata`` JSON field to the ``CasbinRule`` table.
14+
This is not an audit trail: there is no actor, no operation type, and no mechanism for
15+
downstream consumers to react to changes.
16+
17+
Operators and developers need answers the current system cannot provide:
18+
19+
- Who assigned this role, and when?
20+
- Who removed a user's access, and was it intentional?
21+
- Why was a permission check denied?
22+
23+
A spike (OEPM-Spike: RBAC AuthZ Auditability) examined how peer systems approach this.
24+
Auditability decomposes into three dimensions:
25+
26+
1. **Attribution**: who changed access? (role assignments, removals)
27+
2. **Explainability**: why was access granted or denied? (policy evaluation at check time)
28+
3. **Usage**: who used access? (resource access events, business operations)
29+
30+
`SpiceDB`_ and `OpenFGA`_ track the full authorization graph as a versioned changelog,
31+
enabling historical reconstruction. Keycloak uses event listeners on administrative actions.
32+
openedx-authz sits between these: a mutable policy store with no built-in audit layer.
33+
(See `OEPM-Spike\: RBAC AuthZ Auditability`_ for the peer system analysis.)
34+
35+
The pycasbin ecosystem has no audit plugin. Two transitive dependencies cover what is needed:
36+
``django-crum`` (via ``edx-django-utils``) for actor capture, and ``django-simple-history``
37+
(via ``edx-organizations``) for point-in-time state reconstruction.
38+
39+
Decision
40+
********
41+
42+
Three independent mechanisms, each answering a different question:
43+
44+
- ``OpenedxPublicSignal``: something happened, react now
45+
- ``RoleAssignmentAudit``: what happened, in what order, performed by whom
46+
- ``django-simple-history`` on ``ExtendedCasbinRule``: what was the full state at time T
47+
(future work)
48+
49+
See the `OEPM-Spike\: RBAC AuthZ Auditability`_ for the architecture diagram of the three
50+
flows.
51+
52+
#. Attribution: Role Lifecycle Events and Audit Table
53+
=====================================================
54+
55+
Emit an ``OpenedxPublicSignal`` from ``openedx_authz.api.roles`` after every successful role
56+
assignment or removal, via ``transaction.on_commit``. A synchronous Django signal receiver
57+
writes the event to ``RoleAssignmentAudit`` in the same process.
58+
59+
The handler is enabled by default. Operators with Aspects or a SIEM can disable it via a
60+
Django setting to avoid the redundant write. If the handler fails, the Casbin write and the
61+
event are unaffected.
62+
63+
Event payload
64+
-------------
65+
66+
.. code:: python
67+
68+
{
69+
"operation": "created" | "deleted",
70+
"subject": "<namespaced subject key, e.g. user^alice>",
71+
"role": "<namespaced role key, e.g. role^instructor>",
72+
"scope": "<namespaced scope key, e.g. course-v1^course-v1:Org+Course+Run>",
73+
"actor_id": <database ID of the caller (int), or None for system actor>,
74+
}
75+
76+
The actor is resolved from ``django_crum.get_current_user()`` at API call time. No callers
77+
need to pass ``actor_id=`` explicitly.
78+
79+
Audit table
80+
-----------
81+
82+
``RoleAssignmentAudit`` mirrors the event payload. Registered in Django admin, filterable by
83+
user, role, scope, actor_id, and timestamp.
84+
85+
Subject, role, and scope are stored as plain namespaced key strings (e.g. ``user^alice``,
86+
``role^instructor``, ``lib^lib:Org1:lib1``). There are no FK references to live ``Subject``,
87+
``Scope``, or Casbin tables. Audit records survive the deletion of the underlying objects by
88+
design: the value of an audit log depends on its unconditional durability.
89+
90+
Because there are no FK references, the namespace prefix embedded in each string is the only
91+
available signal for categorizing records by type. Admin filters (e.g. "content library",
92+
"course") rely on ``scope__startswith`` lookups against that prefix rather than relational
93+
joins.
94+
95+
Developer extensibility
96+
-----------------------
97+
98+
Plugin authors register handlers on the ``OpenedxPublicSignal`` to react to role lifecycle
99+
events (notifications, cache updates, analytics). Developers without an event bus can consume
100+
the underlying Django signal directly. If an event bus is configured, events are forwarded to
101+
Aspects or external systems automatically.
102+
103+
#. Explainability: Real-Time Decision Context
104+
=============================================
105+
106+
Expose ``enforce_ex()`` through the public Python API. It returns ``(result, explain_rule)``:
107+
the boolean decision and the matched policy rule. Callers get the exact rule that allowed or
108+
denied the request.
109+
110+
Enforcement events are opt-in via ``AUTHZ_ENFORCEMENT_EVENTS_ENABLED``. When enabled, each
111+
check fires an ``OpenedxPublicSignal`` forwarded to plugin consumers or an event bus. No audit
112+
table is written: the volume makes per-check storage impractical.
113+
114+
Historical explainability ("why did this user have access last Tuesday?") is deferred. Two
115+
options are available, both requiring a breaking change to ``is_user_allowed`` to accept
116+
``as_of``:
117+
118+
- **Option A (event replay):** Replay ``ASSIGN``/``REMOVE`` events from ``RoleAssignmentAudit``
119+
up to T. No extra infrastructure; the data is already there once attribution is implemented.
120+
The `Auth0 FGA Logging API`_ uses this same pattern: their logging API is an event store
121+
that you replay to answer historical questions.
122+
- **Option B (snapshots):** Add ``HistoricalRecords()`` to ``ExtendedCasbinRule`` and use
123+
``as_of(T)`` for the full rule state, including policy definitions. History collection must
124+
start before the target timestamp.
125+
126+
``authz.policy`` is loaded into the DB and covered by Option B. ``model.conf`` is not
127+
persisted. A ``model_hash`` field on ``ExtendedCasbinRule`` would let historical queries
128+
detect whether the model changed.
129+
130+
Consequences
131+
************
132+
133+
#. **Operators get a filterable role assignment history in Django admin.** No external
134+
tooling required.
135+
136+
#. **Developers get a stable** ``OpenedxPublicSignal`` **extension point.** First formally
137+
defined event in openedx-authz. Callers of ``openedx_authz.api.roles`` need no signature
138+
changes.
139+
140+
#. **Events are best-effort.** If the audit write fails, the Casbin policy is still durable.
141+
Consumers requiring guaranteed delivery must implement their own retry logic.
142+
143+
#. **``actor_id`` is nullable.** Non-request contexts (management commands, background tasks)
144+
record ``None``, logged as a system operation. ``actor_id`` is stored as a plain integer
145+
(the database ID of the caller) rather than a FK to ``User``. This avoids a dependency
146+
on the ``User`` table and keeps audit records fully independent from live data. Attribution
147+
is preserved unconditionally: deleting or retiring a user does not affect existing records.
148+
149+
#. **Audit records are independent from live authorization state.** Deleting a subject,
150+
scope, or role does not remove its audit history. Records may reference identifiers that
151+
no longer exist.
152+
153+
#. **``RoleAssignmentAudit`` introduces a new migration.** No existing table is modified.
154+
155+
#. **The** ``OpenedxPublicSignal`` **schema is a public API surface.** Field additions are
156+
backward-compatible; removals and renames are breaking changes.
157+
158+
#. **``RoleAssignmentAudit`` is not tamper-proof.** Compliance-grade immutability is a
159+
later-phase concern.
160+
161+
#. **No new dependencies introduced.** ``django-crum`` and ``django-simple-history`` are
162+
already transitive dependencies.
163+
164+
#. **Usage auditing belongs at the application layer** (Open edX tracking events, Aspects),
165+
not in the authorization library.
166+
167+
#. **Developers can retrieve the matched policy rule at check time** for "why was this
168+
denied?" debugging. The explanation is point-in-time only; historical explainability is
169+
deferred.
170+
171+
#. **Enforcement events are opt-in by design.** Enabling them without an external consumer
172+
produces events that are emitted and discarded.
173+
174+
Alternatives Considered
175+
***********************
176+
177+
``django-simple-history`` on ``ExtendedCasbinRule`` as the attribution audit trail
178+
===================================================================================
179+
180+
Rejected for three reasons:
181+
182+
- ``save_policy`` (`casbin-django-orm-adapter adapter.py`_) uses ``QuerySet.delete()`` and
183+
``bulk_create``, both of which bypass model signals. History snapshots reflect when the
184+
table was written, not when a role was assigned.
185+
- ``ExtendedCasbinRule`` fields (``ptype``, ``v0``--``v5``) are semi-opaque and require an
186+
interpretation layer. ``RoleAssignmentAudit`` translates at write time.
187+
188+
``django-simple-history`` remains the right tool for Option B (point-in-time state
189+
reconstruction), where it is a snapshot mechanism, not an operation log.
190+
191+
References
192+
**********
193+
194+
- `ADR 0002`_
195+
- `ADR 0004`_
196+
- `ADR 0005`_
197+
- `Auth0 FGA Logging API`_
198+
- `openedx-events documentation`_
199+
- `django-simple-history documentation`_
200+
- `django-crum documentation`_
201+
- `OEPM-Spike: RBAC AuthZ Auditability`_
202+
203+
.. _ADR 0002: https://github.com/openedx/openedx-authz/blob/main/docs/decisions/0002-authorization-model-foundation.rst
204+
.. _ADR 0004: https://github.com/openedx/openedx-authz/blob/main/docs/decisions/0004-technology-selection.rst
205+
.. _ADR 0005: https://github.com/openedx/openedx-authz/blob/main/docs/decisions/0005-architecture-and-data-modeling.rst
206+
.. _Auth0 FGA Logging API: https://auth0.com/blog/auth0-fga-logging-api-a-complete-audit-trail-for-authorization/
207+
.. _SpiceDB: https://github.com/authzed/spicedb
208+
.. _OpenFGA: https://openfga.dev/
209+
.. _openedx-events documentation: https://docs.openedx.org/projects/openedx-events/en/latest/
210+
.. _django-simple-history documentation: https://django-simple-history.readthedocs.io/
211+
.. _django-crum documentation: https://pypi.org/project/django-crum/
212+
.. _casbin-django-orm-adapter adapter.py: https://github.com/officialpycasbin/django-orm-adapter/blob/main/casbin_adapter/adapter.py
213+
.. _OEPM-Spike\: RBAC AuthZ Auditability: https://openedx.atlassian.net/wiki/spaces/OEPM/pages/6045859842/Spike+-+RBAC+AuthZ+-+Auditability

openedx_authz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
import os
66

7-
__version__ = "1.12.0"
7+
__version__ = "1.13.0"
88

99
ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

openedx_authz/admin.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
from django.contrib import admin
88
from django.utils.html import format_html
99

10+
from openedx_authz.api.data import ContentLibraryData, CourseOverviewData
1011
from openedx_authz.models import AuthzCourseAuthoringMigrationRun, ExtendedCasbinRule
12+
from openedx_authz.models.core import RoleAssignmentAudit
1113

1214

1315
def pretty_json(value) -> str:
@@ -87,3 +89,68 @@ class AuthzCourseAuthoringMigrationRunAdmin(admin.ModelAdmin):
8789
def pretty_metadata(self, obj):
8890
"""Return formatted JSON for the metadata field."""
8991
return pretty_json(obj.metadata)
92+
93+
94+
class ScopeTypeFilter(admin.SimpleListFilter):
95+
"""Filter audit records by scope type (content library, course, etc.)."""
96+
97+
title = "scope type"
98+
parameter_name = "scope_type"
99+
100+
def lookups(self, request, model_admin):
101+
"""
102+
Return the available scope type choices.
103+
104+
Audit records are independent from live Casbin tables and scope objects:
105+
there are no FK references to filter on. The namespace prefix in the
106+
stored ``scope`` string (e.g. ``lib^``, ``course-v1^``) is the only
107+
available signal for categorizing records by scope type.
108+
"""
109+
return [
110+
(ContentLibraryData.NAMESPACE, "Content Library"),
111+
(CourseOverviewData.NAMESPACE, "Course"),
112+
]
113+
114+
def queryset(self, request, queryset):
115+
"""Filter the queryset by scope namespace prefix."""
116+
if self.value():
117+
return queryset.for_scope_namespace(self.value())
118+
return queryset
119+
120+
121+
@admin.register(RoleAssignmentAudit)
122+
class RoleAssignmentAuditAdmin(admin.ModelAdmin):
123+
"""Read-only admin for the role assignment audit log."""
124+
125+
list_display = ("operation", "display_subject", "display_role", "display_scope", "actor_id", "timestamp")
126+
list_filter = ("operation", ScopeTypeFilter)
127+
search_fields = ("subject", "role", "scope")
128+
date_hierarchy = "timestamp"
129+
readonly_fields = ("operation", "subject", "role", "scope", "actor_id", "timestamp")
130+
131+
@admin.display(description="subject")
132+
def display_subject(self, obj):
133+
"""Subject key without the namespace prefix."""
134+
return obj.subject_display
135+
136+
@admin.display(description="role")
137+
def display_role(self, obj):
138+
"""Role name without the namespace prefix."""
139+
return obj.role_display
140+
141+
@admin.display(description="scope")
142+
def display_scope(self, obj):
143+
"""Scope key without the namespace prefix."""
144+
return obj.scope_display
145+
146+
def has_add_permission(self, request):
147+
"""Audit records are created by the system only."""
148+
return False
149+
150+
def has_change_permission(self, request, obj=None):
151+
"""Audit records must not be modified after creation."""
152+
return False
153+
154+
def has_delete_permission(self, request, obj=None):
155+
"""Audit records must not be deleted through the admin."""
156+
return False

0 commit comments

Comments
 (0)