Skip to content

Commit c5ce1d1

Browse files
[FC-0099] feat: add model to be used as backreference to maintain rules consistent (openedx#100)
Introduce a consistency mechanism between Open edX domain objects (such as users and content libraries) and Casbin policies. The goal is to keep authorization data synchronized with the lifecycle of real objects, ensuring that no orphaned or stale policies remain after deletions. Initially, the plan was to use an event-based approach that listened to Open edX lifecycle events. However, this approach was limited because it would require creating new events for the user lifecycle, the platform does not expose a custom User model, and these events would only be useful in the short term since the long-term plan was already to move to a back-reference model. Because of these limitations, this implementation goes straight for the more sustainable option: a back-reference model that ensures transactional consistency at the Django application layer.
1 parent b7e82cc commit c5ce1d1

32 files changed

Lines changed: 2730 additions & 38 deletions

CHANGELOG.rst

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,24 @@ Change Log
1414
Unreleased
1515
**********
1616

17-
0.14.0 - 2025-11-10
17+
0.15.0 - 2025-11-11
18+
********************
19+
20+
Added
21+
=====
22+
23+
* `ExtendedCasbinRule` model to extend the base CasbinRule model for additional metadata, and cascade delete
24+
support.
25+
26+
0.14.0 - 2025-11-11
1827
********************
1928

2029
Added
2130
=====
2231

2332
* Implement custom matcher to check for staff and superuser status.
2433

25-
0.13.1 - 2025-11-10
34+
0.13.1 - 2025-11-11
2635
********************
2736

2837
Fixed

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ quality: ## check coding style with pycodestyle and pylint
5959
tox -e quality
6060

6161
format: ## format code with black and isort. Enable ruff to fix E (pycodestyle) and I (isort) issues
62-
ruff format openedx_authz tests test_utils manage.py setup.py
63-
ruff check --fix openedx_authz tests test_utils manage.py setup.py
62+
ruff format openedx_authz tests manage.py setup.py
63+
ruff check --fix openedx_authz tests manage.py setup.py
6464

6565
pii_check: ## check for PII annotations on all Django models
6666
tox -e pii_check

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__ = "0.14.0"
7+
__version__ = "0.15.0"
88

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

openedx_authz/api/roles.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
from collections import defaultdict
1212

13+
from django.db import transaction
14+
1315
from openedx_authz.api.data import (
1416
GroupingPolicyIndex,
1517
PermissionData,
@@ -21,6 +23,7 @@
2123
)
2224
from openedx_authz.api.permissions import get_permission_from_policy
2325
from openedx_authz.engine.enforcer import AuthzEnforcer
26+
from openedx_authz.models import ExtendedCasbinRule
2427

2528
__all__ = [
2629
"get_permissions_for_single_role",
@@ -197,11 +200,25 @@ def assign_role_to_subject_in_scope(subject: SubjectData, role: RoleData, scope:
197200
bool: True if the role was assigned successfully, False otherwise.
198201
"""
199202
enforcer = AuthzEnforcer.get_enforcer()
200-
return enforcer.add_role_for_user_in_domain(
201-
subject.namespaced_key,
202-
role.namespaced_key,
203-
scope.namespaced_key,
204-
)
203+
adapter = AuthzEnforcer.get_adapter()
204+
205+
with transaction.atomic():
206+
role_assignment = enforcer.add_role_for_user_in_domain(
207+
subject.namespaced_key,
208+
role.namespaced_key,
209+
scope.namespaced_key,
210+
)
211+
if not role_assignment:
212+
return False
213+
extended_rule = ExtendedCasbinRule.create_based_on_policy(
214+
subject,
215+
role,
216+
scope,
217+
adapter,
218+
)
219+
if not extended_rule:
220+
raise Exception("Failed to create ExtendedCasbinRule for the assignment")
221+
return True
205222

206223

207224
def batch_assign_role_to_subjects_in_scope(subjects: list[SubjectData], role: RoleData, scope: ScopeData) -> None:

openedx_authz/apps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class OpenedxAuthzConfig(AppConfig):
1313
name = "openedx_authz"
1414
verbose_name = "Open edX AuthZ"
1515
default_auto_field = "django.db.models.BigAutoField"
16+
1617
plugin_app = {
1718
"url_config": {
1819
"lms.djangoapp": {
@@ -39,3 +40,7 @@ class OpenedxAuthzConfig(AppConfig):
3940
},
4041
},
4142
}
43+
44+
def ready(self):
45+
"""Import signal handlers when Django starts."""
46+
import openedx_authz.handlers # pylint: disable=import-outside-toplevel,unused-import

openedx_authz/engine/adapter.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,21 @@ def filter_query(
129129
filter_kwargs = {f"{attr.value}__in": filter_values}
130130
queryset = queryset.filter(**filter_kwargs)
131131
return queryset.order_by("id")
132+
133+
def query_policy(self, filter: Filter) -> QuerySet: # pylint: disable=redefined-builtin
134+
"""
135+
Retrieve policy rules from the database based on filter criteria.
136+
137+
This method constructs a Django queryset to fetch CasbinRule objects
138+
that match the specified filter attributes. It supports filtering by
139+
policy type (ptype) and policy values (v0-v5).
140+
141+
Args:
142+
filter (Filter): Filter object with attributes (ptype, v0, v1, v2, v3, v4, v5)
143+
containing lists of values to filter by. Empty lists are ignored.
144+
145+
Returns:
146+
QuerySet: Queryset of CasbinRule objects matching the filter criteria.
147+
"""
148+
queryset = CasbinRule.objects.using(self.db_alias)
149+
return self.filter_query(queryset, filter)

openedx_authz/engine/enforcer.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,14 @@ class AuthzEnforcer:
6161
allowed = enforcer.get_enforcer().enforce(user, resource, action)
6262
6363
Any of the two approaches will yield the same singleton enforcer instance.
64+
65+
Attributes:
66+
_enforcer (SyncedEnforcer): The singleton enforcer instance.
67+
_adapter (ExtendedAdapter): The singleton adapter instance.
6468
"""
6569

6670
_enforcer = None
71+
_adapter = None
6772

6873
def __new__(cls):
6974
"""Singleton pattern to ensure a single enforcer instance."""
@@ -171,6 +176,19 @@ def get_enforcer(cls) -> SyncedEnforcer:
171176

172177
return cls._enforcer
173178

179+
@classmethod
180+
def get_adapter(cls) -> ExtendedAdapter:
181+
"""Get the adapter instance, getting it from the enforcer if needed.
182+
183+
Returns:
184+
ExtendedAdapter: The singleton adapter instance.
185+
"""
186+
if cls._adapter is None:
187+
# We need to access the protected member _e to get the adapter from the base enforcer
188+
# which the SyncedEnforcer wraps.
189+
cls._adapter = cls.get_enforcer()._e.adapter # pylint: disable=protected-access
190+
return cls._adapter
191+
174192
@classmethod
175193
def _initialize_enforcer(cls) -> SyncedEnforcer:
176194
"""

openedx_authz/handlers.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""
2+
Signal handlers for the authorization framework.
3+
4+
These handlers ensure proper cleanup and consistency when models are deleted.
5+
"""
6+
7+
import logging
8+
9+
from casbin_adapter.models import CasbinRule
10+
from django.db.models.signals import post_delete
11+
from django.dispatch import receiver
12+
13+
from openedx_authz.models.core import ExtendedCasbinRule
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
@receiver(post_delete, sender=ExtendedCasbinRule)
19+
def delete_casbin_rule_on_extended_rule_deletion(sender, instance, **kwargs): # pylint: disable=unused-argument
20+
"""
21+
Delete the companion CasbinRule after its ExtendedCasbinRule disappears.
22+
23+
The handler keeps authorization data symmetric with three common flows:
24+
25+
- Direct ExtendedCasbinRule deletes (API/UI) trigger removal of the linked CasbinRule.
26+
- Cascades from `Scope` or `Subject` deletions clear their ExtendedCasbinRule rows and,
27+
via this handler, the matching CasbinRule entries.
28+
- Cascades initiated from the CasbinRule side (enforcer cleanups) leave the query as a
29+
no-op because the row is already gone.
30+
31+
Running on ``post_delete`` ensures database cascades complete before the cleanup runs, so
32+
enforcer-driven deletions no longer raise false errors.
33+
34+
Args:
35+
sender: The model class (ExtendedCasbinRule).
36+
instance: The ExtendedCasbinRule instance being deleted.
37+
**kwargs: Additional keyword arguments from the signal.
38+
"""
39+
try:
40+
# Rely on delete() being idempotent; returns 0 rows if the CasbinRule was
41+
# already removed (for example, because it triggered this signal).
42+
CasbinRule.objects.filter(id=instance.casbin_rule_id).delete()
43+
except Exception as exc: # pylint: disable=broad-exception-caught
44+
# Log but don't raise - we don't want to break the deletion of
45+
# ExtendedCasbinRule if something goes wrong while deleting the CasbinRule.
46+
logger.exception(
47+
"Error deleting CasbinRule %s during ExtendedCasbinRule cleanup",
48+
instance.casbin_rule_id,
49+
exc_info=exc,
50+
)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Generated by Django 4.2.24 on 2025-10-24 11:19
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
initial = True
10+
11+
dependencies = [
12+
("casbin_adapter", "0001_initial"),
13+
("openedx_authz", "0001_add_casbin_dependency"),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="Scope",
19+
fields=[
20+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
21+
],
22+
options={
23+
"abstract": False,
24+
},
25+
),
26+
migrations.CreateModel(
27+
name="Subject",
28+
fields=[
29+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
30+
],
31+
options={
32+
"abstract": False,
33+
},
34+
),
35+
migrations.CreateModel(
36+
name="ExtendedCasbinRule",
37+
fields=[
38+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
39+
("casbin_rule_key", models.CharField(max_length=255, unique=True)),
40+
("description", models.TextField(blank=True, null=True)),
41+
("created_at", models.DateTimeField(auto_now_add=True)),
42+
("updated_at", models.DateTimeField(auto_now=True)),
43+
("metadata", models.JSONField(blank=True, null=True)),
44+
(
45+
"casbin_rule",
46+
models.OneToOneField(
47+
on_delete=django.db.models.deletion.CASCADE,
48+
related_name="extended_rule",
49+
to="casbin_adapter.casbinrule",
50+
),
51+
),
52+
(
53+
"scope",
54+
models.ForeignKey(
55+
blank=True,
56+
null=True,
57+
on_delete=django.db.models.deletion.CASCADE,
58+
related_name="casbin_rules",
59+
to="openedx_authz.scope",
60+
),
61+
),
62+
(
63+
"subject",
64+
models.ForeignKey(
65+
blank=True,
66+
null=True,
67+
on_delete=django.db.models.deletion.CASCADE,
68+
related_name="casbin_rules",
69+
to="openedx_authz.subject",
70+
),
71+
),
72+
],
73+
options={
74+
"verbose_name": "Extended Casbin Rule",
75+
"verbose_name_plural": "Extended Casbin Rules",
76+
},
77+
),
78+
]
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Generated by Django 4.2.24 on 2025-10-24 11:19
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
11+
("openedx_authz", "0002_initial"),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name="UserSubject",
17+
fields=[
18+
(
19+
"subject_ptr",
20+
models.OneToOneField(
21+
auto_created=True,
22+
on_delete=django.db.models.deletion.CASCADE,
23+
parent_link=True,
24+
primary_key=True,
25+
serialize=False,
26+
to="openedx_authz.subject",
27+
),
28+
),
29+
(
30+
"user",
31+
models.ForeignKey(
32+
blank=True,
33+
null=True,
34+
on_delete=django.db.models.deletion.CASCADE,
35+
related_name="authz_subjects",
36+
to=settings.AUTH_USER_MODEL,
37+
),
38+
),
39+
],
40+
bases=("openedx_authz.subject",),
41+
),
42+
]

0 commit comments

Comments
 (0)