Skip to content

Commit b0a0620

Browse files
committed
feat: add casbin model
1 parent d722867 commit b0a0620

8 files changed

Lines changed: 325 additions & 0 deletions

File tree

openedx_authz/apps.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ class OpenedxAuthzConfig(AppConfig):
1111
"""
1212

1313
name = "openedx_authz"
14+
plugin_app = {}

openedx_authz/management/__init__.py

Whitespace-only changes.

openedx_authz/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""
2+
Django management command for testing Casbin enforcement policies.
3+
4+
This command creates a Casbin enforcer from model.conf and policy.csv files,
5+
then tests enforcement for each request in request.txt.
6+
"""
7+
8+
import os
9+
10+
import casbin
11+
from django.core.management.base import BaseCommand, CommandError
12+
13+
14+
class Command(BaseCommand):
15+
"""
16+
Test Casbin enforcement policies using model.conf, policy.csv, and request.txt
17+
"""
18+
19+
help = (
20+
"Test Casbin enforcement policies using model.conf, policy.csv, and request.txt. "
21+
"Supports interactive mode for custom testing."
22+
)
23+
24+
def add_arguments(self, parser) -> None:
25+
"""Add the arguments to the parser."""
26+
parser.add_argument(
27+
"--model-file",
28+
type=str,
29+
default="model.conf",
30+
help="Path to the Casbin model configuration file (default: model.conf)",
31+
)
32+
parser.add_argument(
33+
"--policy-file",
34+
type=str,
35+
default="policy.csv",
36+
help="Path to the policy CSV file (default: policy.csv)",
37+
)
38+
parser.add_argument(
39+
"--request-file",
40+
type=str,
41+
default="request.txt",
42+
help="Path to the request test file (default: request.txt)",
43+
)
44+
parser.add_argument(
45+
"--interactive",
46+
action="store_true",
47+
help="Run in interactive mode for testing custom enforcement requests",
48+
)
49+
50+
def handle(self, *args, **options):
51+
"""Handle the command."""
52+
model_file = self.get_file_path(options["model_file"])
53+
policy_file = self.get_file_path(options["policy_file"])
54+
interactive_mode = options.get("interactive", False)
55+
56+
if not os.path.isfile(model_file):
57+
raise CommandError(f"Model file not found: {model_file}")
58+
if not os.path.isfile(policy_file):
59+
raise CommandError(f"Policy file not found: {policy_file}")
60+
61+
if not interactive_mode:
62+
request_file = self.get_file_path(options["request_file"])
63+
if not os.path.isfile(request_file):
64+
raise CommandError(f"Request file not found: {request_file}")
65+
66+
self.stdout.write(self.style.SUCCESS("=== Casbin Enforcement Testing ==="))
67+
self.stdout.write(f"Model file: {model_file}")
68+
self.stdout.write(f"Policy file: {policy_file}")
69+
if interactive_mode:
70+
self.stdout.write("Mode: Interactive")
71+
else:
72+
request_file = self.get_file_path(options["request_file"])
73+
self.stdout.write(f"Request file: {request_file}")
74+
self.stdout.write("")
75+
76+
try:
77+
enforcer = casbin.Enforcer(model_file, policy_file)
78+
self.stdout.write(self.style.SUCCESS("✓ Casbin enforcer created successfully"))
79+
80+
policies = enforcer.get_policy()
81+
roles = enforcer.get_grouping_policy()
82+
role_inheritance = enforcer.get_named_grouping_policy("g2")
83+
84+
self.stdout.write(f"✓ Loaded {len(policies)} policies")
85+
self.stdout.write(f"✓ Loaded {len(roles)} role assignments")
86+
self.stdout.write(f"✓ Loaded {len(role_inheritance)} action inheritance rules")
87+
self.stdout.write("")
88+
89+
if interactive_mode:
90+
self._run_interactive_mode(enforcer)
91+
else:
92+
request_file = self.get_file_path(options["request_file"])
93+
self._process_requests(enforcer, request_file)
94+
95+
except Exception as e:
96+
raise CommandError(f"Error creating Casbin enforcer: {str(e)}") from e
97+
98+
def get_file_path(self, file_name: str) -> str:
99+
"""Get the file path for the given file name."""
100+
return os.path.join(os.path.dirname(__file__), file_name)
101+
102+
def _process_requests(self, enforcer: casbin.Enforcer, request_file: str) -> None:
103+
"""Process each request in the request file and test enforcement."""
104+
self.stdout.write(self.style.SUCCESS("=== Processing Enforcement Requests ==="))
105+
106+
total_requests = 0
107+
passed_requests = 0
108+
failed_requests = 0
109+
110+
with open(request_file, "r") as file:
111+
for line_num, line in enumerate(file, 1):
112+
line = line.strip()
113+
114+
# Skip empty lines and comments
115+
if not line or line.startswith("#"):
116+
continue
117+
118+
total_requests += 1
119+
120+
try:
121+
# Parse request line: subject, action, object, scope, expected_result
122+
parts = [part.strip() for part in line.split(",")]
123+
if len(parts) != 5:
124+
self.stdout.write(
125+
self.style.ERROR(f"Line {line_num}: Invalid format - expected 5 parts, got {len(parts)}")
126+
)
127+
failed_requests += 1
128+
continue
129+
130+
subject, action, obj, scope, expected_str = parts
131+
expected_result = expected_str.lower() == "true"
132+
133+
actual_result = enforcer.enforce(subject, action, obj, scope)
134+
135+
if actual_result == expected_result:
136+
status = self.style.SUCCESS("✓ PASS")
137+
passed_requests += 1
138+
else:
139+
status = self.style.ERROR("✗ FAIL")
140+
failed_requests += 1
141+
142+
self.stdout.write(
143+
f"{status} Line {line_num:2d}: {subject}, {action}, {obj}, {scope} "
144+
f"-> Expected: {expected_result}, Got: {actual_result}"
145+
)
146+
147+
except (ValueError, IndexError) as e:
148+
self.stdout.write(self.style.ERROR(f"Line {line_num}: Error processing request - {str(e)}"))
149+
failed_requests += 1
150+
151+
self.stdout.write("")
152+
self.stdout.write(self.style.SUCCESS("=== Enforcement Test Summary ==="))
153+
self.stdout.write(f"Total requests: {total_requests}")
154+
self.stdout.write(self.style.SUCCESS(f"Passed: {passed_requests}"))
155+
if failed_requests > 0:
156+
self.stdout.write(self.style.ERROR(f"Failed: {failed_requests}"))
157+
else:
158+
self.stdout.write(f"Failed: {failed_requests}")
159+
160+
success_rate = (passed_requests / total_requests * 100) if total_requests > 0 else 0
161+
self.stdout.write(f"Success rate: {success_rate:.1f}%")
162+
163+
if failed_requests == 0:
164+
self.stdout.write(self.style.SUCCESS("All tests passed!"))
165+
else:
166+
self.stdout.write(self.style.WARNING(f"⚠️ {failed_requests} test(s) failed"))
167+
168+
def _run_interactive_mode(self, enforcer: casbin.Enforcer) -> None:
169+
"""Run interactive mode for testing custom enforcement requests."""
170+
self.stdout.write(self.style.SUCCESS("=== Interactive Mode ==="))
171+
self.stdout.write("Test custom enforcement requests interactively.")
172+
self.stdout.write("Format: subject action object scope")
173+
self.stdout.write("Example: user:alice act:read lib:test-lib org:OpenedX")
174+
self.stdout.write("Special commands: help, policies, users, quit")
175+
self.stdout.write("")
176+
177+
while True:
178+
try:
179+
user_input = input("Enter enforcement test (or command): ").strip()
180+
if not user_input:
181+
continue
182+
self._test_interactive_request(enforcer, user_input)
183+
except (KeyboardInterrupt, EOFError):
184+
break
185+
186+
def _test_interactive_request(self, enforcer: casbin.Enforcer, user_input: str) -> None:
187+
"""Test a single enforcement request from interactive input."""
188+
try:
189+
parts = [part.strip() for part in user_input.split()]
190+
if len(parts) != 4:
191+
self.stdout.write(self.style.ERROR(f"✗ Invalid format. Expected 4 parts, got {len(parts)}"))
192+
self.stdout.write(" Format: subject action object scope")
193+
self.stdout.write(" Example: user:alice act:read lib:test-lib org:OpenedX")
194+
return
195+
196+
subject, action, obj, scope = parts
197+
result = enforcer.enforce(subject, action, obj, scope)
198+
199+
if result:
200+
self.stdout.write(self.style.SUCCESS(f"✓ ALLOWED: {subject} {action} {obj} {scope}"))
201+
else:
202+
self.stdout.write(self.style.ERROR(f"✗ DENIED: {subject} {action} {obj} {scope}"))
203+
204+
except (ValueError, IndexError, TypeError) as e:
205+
self.stdout.write(self.style.ERROR(f"✗ Error processing request: {str(e)}"))
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[request_definition]
2+
r = sub, act, obj, scope
3+
4+
[policy_definition]
5+
p = sub, act, obj, eft
6+
7+
[role_definition]
8+
g = _, _, _
9+
g2 = _, _
10+
11+
[policy_effect]
12+
e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
13+
14+
[matchers]
15+
# Authorization matching logic
16+
#
17+
# SCOPE MATCHING:
18+
# - g(r.sub, p.sub, r.scope): check if subject has role in requested scope
19+
# - g(r.sub, p.sub, "*"): check if subject has global role
20+
#
21+
# OBJECT MATCHING:
22+
# - keyMatch(r.obj, p.obj): matches object IDs using exact match or regex patterns
23+
#
24+
# ACTION MATCHING:
25+
# - r.act == p.act: exact action match
26+
# - g2(p.act, r.act): policy action implies requested action via grouping
27+
#
28+
# All conditions must be true for a policy to match
29+
m = (g(r.sub, p.sub, r.scope) || g(r.sub, p.sub, "*")) && keyMatch(r.obj, p.obj) && (r.act == p.act || g2(p.act, r.act))
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
p, role:platform_admin, act:manage, *, allow
2+
p, role:org_admin, act:manage, lib:*, allow
3+
p, role:org_editor, act:edit, lib:*, allow
4+
p, role:library_author, act:edit, lib:*, allow
5+
p, role:library_reviewer, act:read, lib:*, allow
6+
p, role:org_editor, act:edit, lib:restricted-content, deny
7+
8+
g, user:admin, role:platform_admin, *
9+
g, user:alice, role:org_admin, org:OpenedX
10+
g, user:bob, role:org_editor, org:MIT
11+
g, user:mary, role:library_author, lib:math-basics
12+
g, user:john, role:library_author, lib:science-101
13+
g, user:sarah, role:library_reviewer, lib:math-basics
14+
15+
g2, act:manage, act:edit
16+
g2, act:manage, act:delete
17+
g2, act:edit, act:read
18+
g2, act:edit, act:write
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ===== ADMIN GLOBAL PERMISSIONS =====
2+
user:admin, act:manage, lib:math-basics, org:OpenedX, True
3+
user:admin, act:delete, lib:science-101, org:MIT, True
4+
user:admin, act:read, lib:openedx-library, org:OpenedX, True
5+
user:admin, act:read, lib:any-library, *, True
6+
user:admin, act:write, lib:any-library, *, True
7+
user:admin, act:delete, lib:any-library, *, True
8+
9+
# ===== ORG ADMIN PERMISSIONS =====
10+
user:alice, act:manage, lib:openedx-library, org:OpenedX, True
11+
user:alice, act:delete, lib:openedx-content, org:OpenedX, True
12+
user:alice, act:write, lib:math-basics, org:OpenedX, True
13+
user:alice, act:read, lib:openedx-test, org:OpenedX, True
14+
user:alice, act:write, lib:openedx-test, org:OpenedX, True
15+
user:alice, act:delete, lib:openedx-test, org:OpenedX, True
16+
user:alice, act:manage, lib:mit-library, org:MIT, False
17+
user:alice, act:read, lib:mit-content, org:MIT, False
18+
19+
# ===== ORG EDITOR PERMISSIONS =====
20+
user:bob, act:edit, lib:mit-course, org:MIT, True
21+
user:bob, act:read, lib:mit-content, org:MIT, True
22+
user:bob, act:write, lib:mit-data, org:MIT, True
23+
user:bob, act:delete, lib:mit-course, org:MIT, False
24+
user:bob, act:manage, lib:mit-course, org:MIT, False
25+
26+
# ===== LIBRARY AUTHOR PERMISSIONS =====
27+
user:mary, act:edit, lib:math-basics, lib:math-basics, True
28+
user:mary, act:read, lib:math-basics, lib:math-basics, True
29+
user:mary, act:write, lib:math-basics, lib:math-basics, True
30+
user:mary, act:delete, lib:math-basics, lib:math-basics, False
31+
user:mary, act:manage, lib:math-basics, lib:math-basics, False
32+
user:mary, act:edit, lib:science-101, lib:science-101, False
33+
user:john, act:edit, lib:science-101, lib:science-101, True
34+
user:john, act:read, lib:science-101, lib:science-101, True
35+
user:john, act:edit, lib:math-basics, lib:math-basics, False
36+
37+
# ===== LIBRARY REVIEWER PERMISSIONS =====
38+
user:sarah, act:read, lib:math-basics, lib:math-basics, True
39+
user:sarah, act:write, lib:math-basics, lib:math-basics, False
40+
user:sarah, act:edit, lib:math-basics, lib:math-basics, False
41+
user:sarah, act:delete, lib:math-basics, lib:math-basics, False
42+
43+
# ===== ACTION INHERITANCE TESTS =====
44+
user:alice, act:read, lib:openedx-test, org:OpenedX, True
45+
user:alice, act:write, lib:openedx-test, org:OpenedX, True
46+
user:alice, act:delete, lib:openedx-test, org:OpenedX, True
47+
user:bob, act:read, lib:mit-test, org:MIT, True
48+
user:bob, act:write, lib:mit-test, org:MIT, True
49+
user:bob, act:delete, lib:mit-test, org:MIT, False
50+
51+
# ===== DENY RULES TESTS =====
52+
user:bob, act:edit, lib:restricted-content, org:MIT, False
53+
user:bob, act:read, lib:restricted-content, org:MIT, False
54+
55+
# ===== SCOPE ISOLATION TESTS =====
56+
user:alice, act:manage, lib:openedx-lib, *, False
57+
user:mary, act:edit, lib:math-basics, org:OpenedX, False
58+
user:bob, act:edit, lib:mit-course, lib:mit-course, False
59+
60+
# ===== UNAUTHORIZED ACCESS TESTS =====
61+
user:unknown, act:read, lib:math-basics, lib:math-basics, False
62+
user:mary, act:read, lib:science-101, lib:science-101, False
63+
64+
# ===== SPECIAL CASE TESTS =====
65+
# This should be False, but it's returning True. This is a
66+
# special case, and we can prevent it from the Open edX layer
67+
user:mary, act:read, lib:science-101, lib:math-basics, False

setup.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,9 @@ def is_requirement(line):
159159
"Programming Language :: Python :: 3.11",
160160
"Programming Language :: Python :: 3.12",
161161
],
162+
entry_points={
163+
"lms.djangoapp": [
164+
"openedx_authz = openedx_authz.apps:OpenedxAuthzConfig",
165+
],
166+
},
162167
)

0 commit comments

Comments
 (0)