Spike - RBAC AuthZ - Auditability
What does auditability mean for us?
An audit trail is a chronological record of events that allows you to answer: who did what, when, and why. For an authorization system this breaks into two dimensions:
Who performed security actions (e.g., role granting, editing, removal, read), admin action logging
Needs policy changes storage
Who changed identity or access settings
What configuration was available (model.conf & authz.policy) - when does immutability applies
Which user performed which action
Why was access granted (e.g., why was access denied) - more related to explainability
Any kind of info that lets us answer: Why did user X have access to resource Y at time T?
There is another dimension we could also consider: action auditing, extremely related to (1)
Approach | Description | Core Question | What is Audited | Examples | Mechanism | Data Required | Typical Systems |
|---|---|---|---|---|---|---|---|
(1) Authorization Change Audit (Attribution) | Tracks changes to the authorization system (roles, permissions, policies) and who made them | Who changed access? |
|
| Event logs (CRUD logs), audit tables, admin event streams (keycloak) |
| Keycloak, AWS IAM |
(2) Authorization Decision Audit (Explainability) | Explains or reconstructs why an access decision was made at a given time | Why was access granted/denied? |
|
| State versioning (revisions - SpiceDB / OpenFGA), decision logs, policy evaluation traces |
| SpiceDB, OpenFGA, OPA (Open Policy Agent) |
(3) Resource Action Audit (Usage) | Tracks what users actually do in the system using their permissions | Who used access? |
|
| Application logs, event streams, analytics pipelines In our case: tracking logs, Open edX events |
| Application logs, analytics systems (Operator Aspects dashboards?) |
Use cases
Personas
Operators: Django admin, logs, Aspects (if available)
Developers:
OpenedxPublicSignal, Django signal, Python APICompliance teams (future)
In scope
Use case | Scenario | Trigger | Dimension | In scope: M2 |
|---|---|---|---|---|
Role assignment: who did it? | Admin gave Alice the | Operator investigating unexpected access | Attribution | ✅ |
Role removal: who did it? | Alice lost access to course X. Was it intentional? Who revoked it? | Operator responding to a user complaint or incident | Attribution | ✅ |
Role history for a user | Every role Alice has ever had, gained, and lost, in order. | Off-boarding review, access audit, incident investigation | Attribution | ✅ |
Role assignment history for a resource | Who was assigned or removed from roles on course X this week, and in what order? | Content team reports unexpected access or missing access on a course | Attribution | ✅ |
Developer hooks on role lifecycle events | When a user is assigned the | Plugin extensibility via | Attribution | Deferred |
Audit Query API | Public HTTP API for querying role assignment history, filtered by user, role, scope, actor, and time range. Paginated for full export. | External tool or custom operator UI needs role assignment history without Django admin access | Attribution | Deferred |
Debug an unexpected access denial | Alice tried to edit course X and got a 403. What policy evaluation led to that result? | Developer debugging a reported access issue | Explainability | Deferred |
Inspect a user's current permissions | What roles does Alice have? What permissions do those roles grant? On which scopes? | Developer or operator verifying current state | Explainability | Deferred |
How are we going to do it?
Current status
ExtendedCasbinRule has timestamps but no actor, no operation type, and no extensibility mechanism. Neither pycasbin nor the adapter provide audit support; no plugin exists on PyPI.
Two dependencies already in the project cover what we need: django-crum for actor capture and django-simple-history for state reconstruction (transitive via edx-organizations, not yet applied here). enforce_ex() is available for real-time explainability.
Achitecture
TL;DR
The design uses three independent mechanisms, each answering a different question: OpenedxPublicSignal for real-time reaction to role changes and enforcement; RoleAssignmentAudit as the operation log (who did what, when); and django-simple-history on ExtendedCasbinRule for full state reconstruction at a point in time (future work).
The diagram below shows the three flows: (1) role lifecycle events for attribution, (2) enforcement events for real-time explainability (opt-in), and (3) point-in-time reconstruction for historical explainability.
(1) Role lifecycle events
Role lifecycle operations (assign, remove) go through openedx_authz.api.roles. After the Casbin write commits, an OpenedxPublicSignal fires via transaction.on_commit with three receivers:
RBAC event handler: receives the event and writes the operation to
RoleAssignmentAuditasynchronously, decoupled from the request cycle. Gives operators a queryable history in Django admin without needing external systems.Plugin signal receivers: developers register handlers from their own plugins to react to role events in real time (e.g., trigger a notification when a role is assigned).
Event bus: forwards events external systems if configured. Optional; the signal fires regardless. (Q: do we need tracking events for aspects to consume this?)
api/roles.py
├─ Casbin write (DB transaction)
└─ OpenedxPublicSignal (transaction.on_commit)
├─ (Async) event handler → RoleAssignmentAudit table (async, separate process)
├─ plugin signal receivers (real-time)
└─ event bus → Aspects / external systems (optional, if configured)The Casbin write is the source of truth. The event fires after it commits, and the RoleAssignmentAudit write is a projection of that event stream for operator convenience. If the Celery handler fails (when writing to the audit table), the policy is still durable.
Event payload
{
"operation": "ASSIGN" | "REMOVE",
"user": "user^alice",
"role": "role^instructor",
"scope": "course-v1^course-v1:Org+Course+Run",
"actor": "admin_username or None",
"timestamp": "2026-03-27T10:00:00Z",
}RoleAssignmentAudit model
Field | Type | Notes |
|---|---|---|
operation | CharField |
|
user | CharField | namespaced subject key, e.g. |
role | CharField | namespaced role key, e.g. |
scope | CharField | namespaced scope key, e.g. |
actor | CharField (nullable) | username of the caller; |
timestamp | DateTimeField | ISO 8601 UTC; set at API call time, stored as-is from the event payload |
The model is registered with Django admin, filterable by any of these fields.
(2) Enforcement events
enforce_ex() is exposed through openedx_authz.api.permissions. It returns (result, explain_rule): the boolean decision and the matched policy rule at check time. When enabled via a Django setting, an OpenedxPublicSignal fires after each call.
api/permissions.py
└─ enforce_ex()
└─ OpenedxPublicSignal (if AUTHZ_ENFORCEMENT_EVENTS_ENABLED)
├─ plugin signal consumers (real-time)
└─ event bus → Aspects (optional, if configured)Enforcement checks fire on every authorization check in the system, making a local audit table feels impractical. Operators who need to store enforcement history can route events to other systems that can manage large volumes of data.
Event payload
{
"user": "user^alice",
"action": "content_libraries.edit_library_content",
"scope": "lib:DemoX:CSPROB",
"result": True,
"explain_rule": ("g", "user^alice", "role^library_author", "lib:DemoX:CSPROB"),
"timestamp": "2026-03-27T10:00:00Z",
}(3) Access management history (future improvement)
To answer "why did user X have access to resource Y last Thursday?", the state at a given timestamp needs to be reconstructed and an enforcement check run against it. Two approaches are under consideration:
Option A (event replay): Replay all ASSIGN/REMOVE events from
RoleAssignmentAudit(or tracking logs) up to T to reconstruct the role set. No additional infrastructure needed but additional computations. Auth0 FGA uses this same pattern: their logging API is effectively an event store from which you replay to answer historical questions (but no transformation, only querying on the event store used).Option B (snapshots): Apply
HistoricalRecords()toExtendedCasbinRuleand useas_of(T)to get the full rule state, including policy definitions. More complete but requires an additional dependency.
Both require a breaking change to the is_user_allowed API signature to accept an as_of parameter. Not implemented in current scope.
authz.policy can be reconstructed via Option B since it is loaded into the DB, but model.conf is not, a model_hash field on ExtendedCasbinRule would let historical queries know which model version was in effect.
Future improvements
RBAC Analytics
With role lifecycle events in RoleAssignmentAudit and flowing events, the data is available to power authorization analytics in dedicated dashboard:
Role assignment trends over time (per course, org, user)
Access anomaly detection: unusual grant/revoke patterns, bulk role assignments
Role accumulation detection: users with an unusually high number of active roles
Dormant role detection: roles held but not exercised (requires combining with usage audit data)
Operator dashboard in Aspects, fed from the event bus
User activity auditing
Tracking what users do with their permissions is already partially covered by Open edX tracking logs. Coverage needs to be reviewed against the openedx-authz role-permission matrix to identify gaps.
Policy model versioning
authz.policy is loaded into the DB and covered by Option B. model.conf is not persisted. Adding a model_hash field to ExtendedCasbinRule would record which model version was in effect for each rule set, so historical queries know whether the model changed between then and now.
What users do with their access
Open edX already has tracking logs, and many of them feed directly into Aspects dashboards. That infrastructure covers student activity well. Coverage for authoring and admin activity, the personas most relevant to openedx-authz roles, is less clear and worth reviewing.