Skip to content

Commit 88f8695

Browse files
refactor: consider when deleting extended model so the casbin rule is also deleted
1 parent c566560 commit 88f8695

10 files changed

Lines changed: 833 additions & 139 deletions

File tree

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

openedx_authz/handlers.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Signal handlers for the authorization framework.
2+
3+
These handlers ensure proper cleanup and consistency when models are deleted.
4+
"""
5+
6+
from casbin_adapter.models import CasbinRule
7+
from django.db.models.signals import post_delete
8+
from django.dispatch import receiver
9+
10+
from openedx_authz.models.core import ExtendedCasbinRule
11+
12+
import logging
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
@receiver(post_delete, sender=ExtendedCasbinRule)
18+
def delete_casbin_rule_on_extended_rule_deletion(sender, instance, **kwargs):
19+
"""Delete the companion CasbinRule after its ExtendedCasbinRule disappears.
20+
21+
The handler keeps authorization data symmetric with three common flows:
22+
- Direct ExtendedCasbinRule deletes (API/UI) trigger removal of the linked CasbinRule.
23+
- Cascades from `Scope` or `Subject` deletions clear their ExtendedCasbinRule rows and, via this handler, the matching CasbinRule entries.
24+
- Cascades initiated from the CasbinRule side (enforcer cleanups) leave the query as a no-op because the row is already gone.
25+
26+
Running on ``post_delete`` ensures database cascades complete before the cleanup runs, so
27+
enforcer-driven deletions no longer raise false errors.
28+
29+
Args:
30+
sender: The model class (ExtendedCasbinRule).
31+
instance: The ExtendedCasbinRule instance being deleted.
32+
**kwargs: Additional keyword arguments from the signal.
33+
"""
34+
try:
35+
# Rely on delete() being idempotent; returns 0 rows if the CasbinRule was
36+
# already removed (for example, because it triggered this signal).
37+
CasbinRule.objects.filter(id=instance.casbin_rule_id).delete()
38+
except Exception as exc:
39+
# Log but don't raise - we don't want to break the deletion of
40+
# ExtendedCasbinRule if something goes wrong while deleting the CasbinRule.
41+
logger.exception(
42+
"Error deleting CasbinRule %s during ExtendedCasbinRule cleanup",
43+
instance.casbin_rule_id,
44+
exc_info=exc,
45+
)

openedx_authz/migrations/0001_initial.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class Migration(migrations.Migration):
4949
('created_at', models.DateTimeField(auto_now_add=True)),
5050
('updated_at', models.DateTimeField(auto_now=True)),
5151
('metadata', models.JSONField(blank=True, null=True)),
52-
('casbin_rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extended_rule', to='casbin_adapter.casbinrule')),
52+
('casbin_rule', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='extended_rule', to='casbin_adapter.casbinrule')),
5353
('scope', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='casbin_rules', to='openedx_authz.scope')),
5454
('subject', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='casbin_rules', to='openedx_authz.subject')),
5555
],
@@ -66,7 +66,6 @@ class Migration(migrations.Migration):
6666
'content_library',
6767
models.ForeignKey(
6868
blank=True,
69-
db_constraint=False,
7069
null=True,
7170
on_delete=django.db.models.deletion.CASCADE,
7271
related_name='authz_scopes',
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 5.2.7 on 2025-10-21 16:41
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('casbin_adapter', '0001_initial'),
11+
('openedx_authz', '0002_alter_contentlibraryscope_scope_ptr'),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name='extendedcasbinrule',
17+
name='casbin_rule',
18+
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='extended_rule', to='casbin_adapter.casbinrule'),
19+
),
20+
]

openedx_authz/models/core.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,14 @@ class ExtendedCasbinRule(models.Model):
126126
package to include additional fields for storing metadata about each rule.
127127
"""
128128

129-
# Instead of making it 1:1 only with the CasbinRule primary key which we usually don't know, let's
130-
# make an unique key based on the casbin_rule field which is a concatenation of all the fields
131-
# in the CasbinRule model. This way, we can easily look up the ExtendedCasbinRule
132-
# based on a policy line which SHOULD be unique.
129+
# OneToOne relationship ensures each CasbinRule has at most one ExtendedCasbinRule.
130+
# We also maintain a unique key based on the casbin_rule field components for easy lookup
131+
# based on a policy line (ptype,v0,v1,v2,v3) which should be unique.
132+
#
133+
# Note: We use CASCADE here. When CasbinRule is deleted, ExtendedCasbinRule is also deleted.
134+
# The signal handler in handlers.py ensures the reverse (ExtendedCasbinRule deletion → CasbinRule deletion).
133135
casbin_rule_key = models.CharField(max_length=255, unique=True)
134-
casbin_rule = models.ForeignKey(
136+
casbin_rule = models.OneToOneField(
135137
"casbin_adapter.CasbinRule",
136138
on_delete=models.CASCADE,
137139
related_name="extended_rule",

openedx_authz/models/scopes.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,6 @@ class ContentLibraryScope(Scope):
4646
# Piggybacking on the existing ContentLibrary model to keep the ExtendedCasbinRule up to date
4747
# by deleting the Scope, and thus the ExtendedCasbinRule, when the ContentLibrary is deleted.
4848
#
49-
# Using string reference with db_constraint=False allows this model to work even when
50-
# the content_libraries app is not installed. The ForeignKey relationship is maintained
51-
# at the application level, but Django won't enforce referential integrity in the database
52-
# or validate that the app exists during model loading.
53-
#
5449
# When content_libraries IS available, the on_delete=CASCADE will still work at the
5550
# application level through Django's signal handlers.
5651
# Use a string reference to the external app's model so Django won't try
@@ -66,7 +61,6 @@ class ContentLibraryScope(Scope):
6661
null=True,
6762
blank=True,
6863
related_name="authz_scopes",
69-
db_constraint=False, # Don't enforce FK constraint - allows working without content_libraries app
7064
)
7165

7266
@classmethod

openedx_authz/settings/test.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ def plugin_settings(settings): # pylint: disable=unused-argument
3737
"django.contrib.sessions",
3838
"openedx_authz.engine.apps.CasbinAdapterConfig",
3939
"openedx_authz.apps.OpenedxAuthzConfig",
40-
"casbin_adapter.apps.CasbinAdapterConfig",
4140
"openedx_authz.tests.stubs.apps.StubsConfig",
4241
)
4342

0 commit comments

Comments
 (0)