Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2f7521d
feat: add initial extended model for consistency and additional storage
mariajgrimaldi Oct 14, 2025
6a6752d
feat: integration tests for consistency mechanism
mariajgrimaldi Oct 16, 2025
59bca69
feat: add model to be used as backreference to maintain rules up to date
mariajgrimaldi Oct 16, 2025
3c1bd7c
refactor!: use registry pattern to extend base models
mariajgrimaldi Oct 20, 2025
f65d318
refactor: consider when deleting extended model so the casbin rule is…
mariajgrimaldi Oct 21, 2025
ca33863
test: implement integration tests for main rest API use cases
mariajgrimaldi Oct 22, 2025
0616a7a
refactor: load policies into enforcer before running tests
mariajgrimaldi Oct 23, 2025
4549c87
refactor: get adapter from enforcer as singleton
mariajgrimaldi Oct 23, 2025
17a79b0
refactor: run make format
mariajgrimaldi Oct 23, 2025
d1c3b76
refactor: address quality issues for ruff format
mariajgrimaldi Oct 23, 2025
2db6b9e
refactor: add no_pii to models
mariajgrimaldi Oct 23, 2025
c0f3b7f
refactor: address quality issues
mariajgrimaldi Oct 24, 2025
8b9c7e9
refactor: regenerate migrations
mariajgrimaldi Oct 24, 2025
0e56bc9
refactor: fix issues when rebasing
mariajgrimaldi Oct 24, 2025
bec5f23
refactor: drop unused variables from data classes
mariajgrimaldi Oct 24, 2025
a376a34
refactor: drop failing test
mariajgrimaldi Oct 24, 2025
6003a71
refactor: use swappable dependency to avoid wrong generation for scope
mariajgrimaldi Oct 24, 2025
38d3c24
refactor: drop wrong imports after changes
mariajgrimaldi Nov 4, 2025
ad64d80
refactor: address integration test failure
mariajgrimaldi Nov 4, 2025
f34923e
refactor: consider double namespace instead
mariajgrimaldi Nov 4, 2025
ef2e6d5
refactor: address quality issues
mariajgrimaldi Nov 4, 2025
c6d8d57
test: add minimal unittest suite for extended casbin model
mariajgrimaldi Nov 4, 2025
c384808
refactor: drop /tests package in favor of openedx_authz/tests
mariajgrimaldi Nov 5, 2025
2f11250
refactor: address quality issues
mariajgrimaldi Nov 5, 2025
0f827b5
docs: update docs for next release
mariajgrimaldi Nov 6, 2025
9be04f5
test: add test for missing coverage
mariajgrimaldi Nov 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,24 @@ Change Log
Unreleased
**********

0.14.0 - 2025-11-10
0.15.0 - 2025-11-11
********************

Added
=====

* `ExtendedCasbinRule` model to extend the base CasbinRule model for additional metadata, and cascade delete
support.

0.14.0 - 2025-11-11
********************

Added
=====

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

0.13.1 - 2025-11-10
0.13.1 - 2025-11-11
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been thinking the entire day that is the 10th.

********************

Fixed
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ quality: ## check coding style with pycodestyle and pylint
tox -e quality

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

pii_check: ## check for PII annotations on all Django models
tox -e pii_check
Expand Down
2 changes: 1 addition & 1 deletion openedx_authz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

import os

__version__ = "0.14.0"
__version__ = "0.15.0"

ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
27 changes: 22 additions & 5 deletions openedx_authz/api/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

from collections import defaultdict

from django.db import transaction

from openedx_authz.api.data import (
GroupingPolicyIndex,
PermissionData,
Expand All @@ -21,6 +23,7 @@
)
from openedx_authz.api.permissions import get_permission_from_policy
from openedx_authz.engine.enforcer import AuthzEnforcer
from openedx_authz.models import ExtendedCasbinRule

__all__ = [
"get_permissions_for_single_role",
Expand Down Expand Up @@ -197,11 +200,25 @@ def assign_role_to_subject_in_scope(subject: SubjectData, role: RoleData, scope:
bool: True if the role was assigned successfully, False otherwise.
"""
enforcer = AuthzEnforcer.get_enforcer()
return enforcer.add_role_for_user_in_domain(
subject.namespaced_key,
role.namespaced_key,
scope.namespaced_key,
)
adapter = AuthzEnforcer.get_adapter()

with transaction.atomic():
role_assignment = enforcer.add_role_for_user_in_domain(
subject.namespaced_key,
role.namespaced_key,
scope.namespaced_key,
)
if not role_assignment:
return False
extended_rule = ExtendedCasbinRule.create_based_on_policy(
subject,
role,
scope,
adapter,
)
if not extended_rule:
raise Exception("Failed to create ExtendedCasbinRule for the assignment")
return True
Comment thread
mariajgrimaldi marked this conversation as resolved.


def batch_assign_role_to_subjects_in_scope(subjects: list[SubjectData], role: RoleData, scope: ScopeData) -> None:
Expand Down
5 changes: 5 additions & 0 deletions openedx_authz/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class OpenedxAuthzConfig(AppConfig):
name = "openedx_authz"
verbose_name = "Open edX AuthZ"
default_auto_field = "django.db.models.BigAutoField"

plugin_app = {
"url_config": {
"lms.djangoapp": {
Expand All @@ -39,3 +40,7 @@ class OpenedxAuthzConfig(AppConfig):
},
},
}

def ready(self):
"""Import signal handlers when Django starts."""
import openedx_authz.handlers # pylint: disable=import-outside-toplevel,unused-import
18 changes: 18 additions & 0 deletions openedx_authz/engine/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,21 @@ def filter_query(
filter_kwargs = {f"{attr.value}__in": filter_values}
queryset = queryset.filter(**filter_kwargs)
return queryset.order_by("id")

def query_policy(self, filter: Filter) -> QuerySet: # pylint: disable=redefined-builtin
"""
Retrieve policy rules from the database based on filter criteria.

This method constructs a Django queryset to fetch CasbinRule objects
that match the specified filter attributes. It supports filtering by
policy type (ptype) and policy values (v0-v5).

Args:
filter (Filter): Filter object with attributes (ptype, v0, v1, v2, v3, v4, v5)
containing lists of values to filter by. Empty lists are ignored.

Returns:
QuerySet: Queryset of CasbinRule objects matching the filter criteria.
"""
queryset = CasbinRule.objects.using(self.db_alias)
return self.filter_query(queryset, filter)
18 changes: 18 additions & 0 deletions openedx_authz/engine/enforcer.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,14 @@ class AuthzEnforcer:
allowed = enforcer.get_enforcer().enforce(user, resource, action)

Any of the two approaches will yield the same singleton enforcer instance.

Attributes:
_enforcer (SyncedEnforcer): The singleton enforcer instance.
_adapter (ExtendedAdapter): The singleton adapter instance.
"""

_enforcer = None
_adapter = None

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

return cls._enforcer

@classmethod
def get_adapter(cls) -> ExtendedAdapter:
"""Get the adapter instance, getting it from the enforcer if needed.

Returns:
ExtendedAdapter: The singleton adapter instance.
"""
if cls._adapter is None:
# We need to access the protected member _e to get the adapter from the base enforcer
# which the SyncedEnforcer wraps.
cls._adapter = cls.get_enforcer()._e.adapter # pylint: disable=protected-access
return cls._adapter

@classmethod
def _initialize_enforcer(cls) -> SyncedEnforcer:
"""
Expand Down
50 changes: 50 additions & 0 deletions openedx_authz/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""
Signal handlers for the authorization framework.

These handlers ensure proper cleanup and consistency when models are deleted.
"""

import logging

from casbin_adapter.models import CasbinRule
from django.db.models.signals import post_delete
from django.dispatch import receiver

from openedx_authz.models.core import ExtendedCasbinRule

logger = logging.getLogger(__name__)


@receiver(post_delete, sender=ExtendedCasbinRule)
def delete_casbin_rule_on_extended_rule_deletion(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Delete the companion CasbinRule after its ExtendedCasbinRule disappears.

The handler keeps authorization data symmetric with three common flows:

- Direct ExtendedCasbinRule deletes (API/UI) trigger removal of the linked CasbinRule.
- Cascades from `Scope` or `Subject` deletions clear their ExtendedCasbinRule rows and,
via this handler, the matching CasbinRule entries.
- Cascades initiated from the CasbinRule side (enforcer cleanups) leave the query as a
no-op because the row is already gone.

Running on ``post_delete`` ensures database cascades complete before the cleanup runs, so
enforcer-driven deletions no longer raise false errors.

Args:
sender: The model class (ExtendedCasbinRule).
instance: The ExtendedCasbinRule instance being deleted.
**kwargs: Additional keyword arguments from the signal.
"""
try:
# Rely on delete() being idempotent; returns 0 rows if the CasbinRule was
# already removed (for example, because it triggered this signal).
CasbinRule.objects.filter(id=instance.casbin_rule_id).delete()
except Exception as exc: # pylint: disable=broad-exception-caught
# Log but don't raise - we don't want to break the deletion of
# ExtendedCasbinRule if something goes wrong while deleting the CasbinRule.
logger.exception(
"Error deleting CasbinRule %s during ExtendedCasbinRule cleanup",
instance.casbin_rule_id,
exc_info=exc,
)
78 changes: 78 additions & 0 deletions openedx_authz/migrations/0002_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Generated by Django 4.2.24 on 2025-10-24 11:19

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
initial = True

dependencies = [
("casbin_adapter", "0001_initial"),
("openedx_authz", "0001_add_casbin_dependency"),
]

operations = [
migrations.CreateModel(
name="Scope",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Subject",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="ExtendedCasbinRule",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("casbin_rule_key", models.CharField(max_length=255, unique=True)),
("description", models.TextField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("metadata", models.JSONField(blank=True, null=True)),
(
"casbin_rule",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="extended_rule",
to="casbin_adapter.casbinrule",
),
),
(
"scope",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="casbin_rules",
to="openedx_authz.scope",
),
),
(
"subject",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="casbin_rules",
to="openedx_authz.subject",
),
),
],
options={
"verbose_name": "Extended Casbin Rule",
"verbose_name_plural": "Extended Casbin Rules",
},
),
]
42 changes: 42 additions & 0 deletions openedx_authz/migrations/0003_usersubject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Generated by Django 4.2.24 on 2025-10-24 11:19

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("openedx_authz", "0002_initial"),
]

operations = [
migrations.CreateModel(
name="UserSubject",
fields=[
(
"subject_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="openedx_authz.subject",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="authz_subjects",
to=settings.AUTH_USER_MODEL,
),
),
],
bases=("openedx_authz.subject",),
),
]
43 changes: 43 additions & 0 deletions openedx_authz/migrations/0004_contentlibraryscope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Generated by Django 4.2.24 on 2025-10-24 11:19
# Custom migration - DO NOT REGENERATE
# This migration conditionally depends on the content library model based on settings

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("openedx_authz", "0003_usersubject"),
]

operations = [
migrations.CreateModel(
name="ContentLibraryScope",
fields=[
(
"scope_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="openedx_authz.scope",
),
),
(
"content_library",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="authz_scopes",
to=settings.OPENEDX_AUTHZ_CONTENT_LIBRARY_MODEL,
),
),
],
bases=("openedx_authz.scope",),
),
]
10 changes: 0 additions & 10 deletions openedx_authz/models.py

This file was deleted.

Loading