Skip to content

Commit b7e82cc

Browse files
authored
[FC-0099] feat: implement custom matcher in casbin model (#114)
1 parent f33befd commit b7e82cc

8 files changed

Lines changed: 171 additions & 12 deletions

File tree

CHANGELOG.rst

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@ Change Log
1414
Unreleased
1515
**********
1616

17-
0.13.1 - 2025-11-06
17+
0.14.0 - 2025-11-10
18+
********************
19+
20+
Added
21+
=====
22+
23+
* Implement custom matcher to check for staff and superuser status.
24+
25+
0.13.1 - 2025-11-10
1826
********************
1927

2028
Fixed

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.13.1"
7+
__version__ = "0.14.0"
88

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

openedx_authz/engine/config/model.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,4 @@ e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
9292
# 1. Subject must have role in scope OR global role
9393
# 2. Scope must match pattern
9494
# 3. Action must match OR inherit via action grouping
95-
m = (g(r.sub, p.sub, r.scope) || g(r.sub, p.sub, "*")) && keyMatch(r.scope, p.scope) && (r.act == p.act || g2(p.act, r.act))
95+
m = is_staff_or_superuser(r.sub, r.act, r.scope) || (g(r.sub, p.sub, r.scope) || g(r.sub, p.sub, "*")) && keyMatch(r.scope, p.scope) && (r.act == p.act || g2(p.act, r.act))

openedx_authz/engine/enforcer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from django.conf import settings
2323

2424
from openedx_authz.engine.adapter import ExtendedAdapter
25+
from openedx_authz.engine.matcher import is_admin_or_superuser_check
2526

2627

2728
def libraries_v2_enabled() -> bool:
@@ -195,5 +196,6 @@ def _initialize_enforcer(cls) -> SyncedEnforcer:
195196

196197
adapter = ExtendedAdapter()
197198
enforcer = SyncedEnforcer(settings.CASBIN_MODEL, adapter)
199+
enforcer.add_function("is_staff_or_superuser", is_admin_or_superuser_check)
198200

199201
return enforcer

openedx_authz/engine/matcher.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Custom condition checker. Note only used for data_library scope"""
2+
3+
from django.contrib.auth import get_user_model
4+
5+
from openedx_authz.api.data import ContentLibraryData, ScopeData, UserData
6+
from openedx_authz.rest_api.utils import get_user_by_username_or_email
7+
8+
User = get_user_model()
9+
10+
11+
def is_admin_or_superuser_check(request_user: str, request_action: str, request_scope: str) -> bool: # pylint: disable=unused-argument
12+
"""
13+
Evaluates custom, non-role-based conditions for authorization checks.
14+
15+
Checks attribute-based conditions that don't rely on role assignments.
16+
Currently handles ContentLibraryData scopes by granting access to staff
17+
and superusers.
18+
19+
Args:
20+
request_user (str): Namespaced user key (format: "user::<username>")
21+
request_action (str): Namespaced action key (format: "action::<action_name>")
22+
request_scope (str): Namespaced scope key (format: "scope_type::<scope_id>")
23+
24+
Returns:
25+
bool: True if the condition is satisfied (user is staff/superuser for
26+
ContentLibraryData scopes), False otherwise (including when user
27+
doesn't exist or scope type is not supported)
28+
"""
29+
try:
30+
username = UserData(namespaced_key=request_user).external_key
31+
user = get_user_by_username_or_email(username)
32+
except User.DoesNotExist:
33+
return False
34+
35+
scope = ScopeData(namespaced_key=request_scope)
36+
37+
if isinstance(scope, ContentLibraryData):
38+
return user.is_staff or user.is_superuser
39+
40+
return False

openedx_authz/tests/rest_api/test_views.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,36 @@ def test_permission_validation_success(self, request_data: list[dict], permissio
195195
self.assertEqual(response.status_code, status.HTTP_200_OK)
196196
self.assertEqual(response.data, expected_response)
197197

198+
@data(
199+
("lib:AnyOrg1:ANYLIB1", True),
200+
("lib:AnyOrg2:ANYLIB2", True),
201+
("lib:AnyOrg3:ANYLIB3", True),
202+
("global:AnyScope1", False),
203+
)
204+
@unpack
205+
def test_permission_validation_staff_superuser_access(self, scope: str, expected_result: bool):
206+
"""Test that staff/superuser users have guaranteed permissions for ContentLibrary scopes.
207+
208+
Test cases:
209+
- ContentLibrary scopes (lib:*): Staff/superuser automatically allowed
210+
- Generic scopes (global:*): No automatic access granted
211+
212+
Expected result:
213+
- Returns 200 OK status
214+
- For library scopes: All permissions are allowed (True)
215+
- For non-library scopes: Permissions follow normal authorization (False)
216+
"""
217+
self.client.force_authenticate(user=self.admin_user)
218+
request_data = [{"action": perm.identifier, "scope": scope} for perm in roles.LIBRARY_ADMIN_PERMISSIONS]
219+
expected_response = request_data.copy()
220+
for item in expected_response:
221+
item["allowed"] = expected_result
222+
223+
response = self.client.post(self.url, data=request_data, format="json")
224+
225+
self.assertEqual(response.status_code, status.HTTP_200_OK)
226+
self.assertEqual(response.data, expected_response)
227+
198228
@data(
199229
# Single permission
200230
[{"action": "edit_library"}],

openedx_authz/tests/test_enforcement.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
1010
from unittest import TestCase
1111

1212
import casbin
13+
import pytest
1314
from ddt import data, ddt, unpack
15+
from django.contrib.auth import get_user_model
1416

1517
from openedx_authz import ROOT_DIRECTORY
1618
from openedx_authz.constants import roles
19+
from openedx_authz.engine.matcher import is_admin_or_superuser_check
1720
from openedx_authz.tests.test_utils import (
1821
make_action_key,
1922
make_library_key,
@@ -22,6 +25,8 @@
2225
make_user_key,
2326
)
2427

28+
User = get_user_model()
29+
2530

2631
class AuthRequest(TypedDict):
2732
"""
@@ -44,6 +49,7 @@ class AuthRequest(TypedDict):
4449
]
4550

4651

52+
@pytest.mark.django_db
4753
@ddt
4854
class CasbinEnforcementTestCase(TestCase):
4955
"""
@@ -65,6 +71,7 @@ def setUpClass(cls) -> None:
6571
raise FileNotFoundError(f"Model file not found: {model_file}")
6672

6773
cls.enforcer = casbin.Enforcer(model_file)
74+
cls.enforcer.add_function("is_staff_or_superuser", is_admin_or_superuser_check)
6875

6976
def _load_policy(self, policy: list[str]) -> None:
7077
"""
@@ -573,3 +580,84 @@ def test_wildcard_library_access(self, scope: str, expected_result: bool):
573580
"expected_result": expected_result,
574581
}
575582
self._test_enforcement(self.POLICY, request)
583+
584+
585+
@pytest.mark.django_db
586+
@ddt
587+
class StaffSuperuserAccessTests(CasbinEnforcementTestCase):
588+
"""
589+
Tests for staff and superuser automatic permission grants via is_staff_or_superuser.
590+
591+
This test class verifies that staff members and superusers are automatically
592+
granted access to ContentLibrary scopes through the is_admin_or_superuser_check function,
593+
without requiring explicit role assignments.
594+
"""
595+
596+
# Empty policy - no role assignments for staff/superuser users
597+
POLICY = []
598+
599+
def setUp(self) -> None:
600+
"""Set up the test environment."""
601+
super().setUp()
602+
User.objects.create_user(username="staff_user", email="[email protected]", password="test", is_staff=True)
603+
User.objects.create_superuser(username="superuser", email="[email protected]", password="test")
604+
User.objects.create_user(username="regular_user", email="[email protected]", password="test")
605+
606+
@data(
607+
# Staff user has automatic access to any library scope
608+
(
609+
make_user_key("staff_user"),
610+
make_action_key("view_library"),
611+
make_library_key("lib:TestOrg:TestLib"),
612+
True,
613+
),
614+
(
615+
make_user_key("staff_user"),
616+
make_action_key("edit_library"),
617+
make_library_key("lib:AnyOrg:AnyLib"),
618+
True,
619+
),
620+
# Superuser has automatic access to any library scope
621+
(
622+
make_user_key("superuser"),
623+
make_action_key("view_library"),
624+
make_library_key("lib:TestOrg:TestLib"),
625+
True,
626+
),
627+
(
628+
make_user_key("superuser"),
629+
make_action_key("delete_library"),
630+
make_library_key("lib:AnyOrg:AnyLib"),
631+
True,
632+
),
633+
# Regular user without role assignment has no access
634+
(
635+
make_user_key("regular_user"),
636+
make_action_key("view_library"),
637+
make_library_key("lib:TestOrg:TestLib"),
638+
False,
639+
),
640+
# Non existent library scope access denied
641+
(
642+
make_user_key("regular_user"),
643+
make_action_key("view_library"),
644+
make_library_key("lib:NonExistent:NoLib"),
645+
False,
646+
),
647+
)
648+
@unpack
649+
def test_staff_superuser_guaranteed_permissions(self, subject: str, action: str, scope: str, expected_result: bool):
650+
"""Test that staff and superusers have guaranteed permissions for ContentLibrary scopes.
651+
652+
This test validates that:
653+
- Staff users automatically have access to all library scopes without role assignments
654+
- Superusers automatically have access to all library scopes without role assignments
655+
- Regular users require explicit role assignments to access libraries
656+
- Access is granted through the is_staff_or_superuser matcher function
657+
658+
Expected result:
659+
- Staff and superusers can perform any action on any ContentLibrary scope
660+
- Regular users are denied access without role assignments
661+
"""
662+
request = {"subject": subject, "action": action, "scope": scope, "expected_result": expected_result}
663+
self._test_enforcement(self.POLICY, request)

tests/test_models.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,3 @@
22
"""
33
Tests for the `openedx-authz` models module.
44
"""
5-
6-
import pytest
7-
8-
9-
@pytest.mark.skip(reason="Placeholder to allow pytest to succeed before real tests are in place.")
10-
def test_placeholder():
11-
"""
12-
TODO: Delete this test once there are real tests.
13-
"""

0 commit comments

Comments
 (0)