Skip to content

Commit c9ed193

Browse files
authored
[FC-0099] feat: add casbin model configuration (CONF) (#49)
Adds the Casbin configuration model model. This model (model.conf) defines how requests and policies are structured and evaluated. The built model for Open edX takes into account the following points: - Resource Grouping - Namespace with Wildcard Support - System-Wide Roles - Granularity of Permissions - Exceptions / Negative Rules - Scoped Assignments
1 parent cb4f794 commit c9ed193

20 files changed

Lines changed: 988 additions & 1 deletion

File tree

.coveragerc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ omit =
88
*admin.py
99
*/static/*
1010
*/templates/*
11+
*/tests/*

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ def on_init(app): # pylint: disable=unused-argument
559559
docs_path,
560560
os.path.join(root_path, "openedx_authz"),
561561
os.path.join(root_path, "openedx_authz/migrations"),
562+
os.path.join(root_path, "openedx_authz/tests"),
562563
]
563564
)
564565

openedx_authz/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
"""
2-
One-line description for README and other doc files.
2+
Open edX AuthZ provides the architecture and foundations of the authorization framework.
33
"""
44

5+
import os
6+
57
__version__ = "0.1.0"
8+
9+
ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

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 = {}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# ===== ACTION GROUPING (g2) =====
2+
3+
# manage implies edit, delete, read, write
4+
g2, act:manage, act:edit
5+
g2, act:manage, act:delete
6+
g2, act:edit, act:read
7+
g2, act:edit, act:write
8+
9+
# edit implies read, write
10+
g2, act:edit, act:read
11+
g2, act:edit, act:write
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
############################################
2+
# Open edX AuthZ — Casbin Model Configuration
3+
#
4+
# This model supports:
5+
# - Scoped role assignments (user roles tied to specific contexts)
6+
# - Action grouping (manage → read/write/edit/delete to reduce duplication)
7+
# - System-wide roles (global scope "*" applies everywhere)
8+
# - Negative rules (deny overrides allow for exceptions)
9+
# - Namespace support (course:*, lib:*, org:*, etc.)
10+
# - Extensibility (new resource types just need new namespaces)
11+
############################################
12+
13+
[request_definition]
14+
# Request format: subject (user), action, scope (specific resource being accessed)
15+
#
16+
# sub = subject/principal with namespace (e.g., "user:alice", "service:lms")
17+
# act = action with namespace (e.g., "act:read", "act:manage", "act:edit-courses")
18+
# scope = authorization scope context (e.g., "org:OpenedX", "course-v1:...", "*" for global)
19+
#
20+
# SCOPE SEMANTICS:
21+
# Scope determines the authorization context and which role assignments apply
22+
# - "*" = global scope (system-wide roles apply everywhere)
23+
# - "org:..." = organization-scoped roles (apply within specific organization)
24+
# - "course-v1:..." = course-scoped roles (apply within specific course)
25+
# - "lib:..." = library-scoped roles (apply within specific library)
26+
#
27+
# Application must provide appropriate scope based on business logic.
28+
r = sub, act, scope
29+
30+
[policy_definition]
31+
# Policy format: subject (role), action, scope (pattern), effect
32+
#
33+
# sub = role or user with namespace (e.g., "role:org_admin", "user:bob")
34+
# act = action identifier (e.g., "act:manage", "act:read", "act:edit-courses")
35+
# scope = scope where policy applies (e.g., "*", "org:*", "course-v1:*", "lib:*")
36+
# eft = "allow" or "deny" (deny overrides allow for exceptions)
37+
p = sub, act, scope, eft
38+
39+
[role_definition]
40+
# g: Role assignments with scope
41+
# Format: user/subject, role, scope
42+
#
43+
# Examples:
44+
# g, user:alice, role:org_admin, org:OpenedX # Alice is org admin for OpenedX
45+
# g, user:bob, role:course_instructor, course-v1:... # Bob is instructor for specific course
46+
# g, user:carol, role:library_admin, * # Carol is global library admin
47+
#
48+
# Role hierarchy (optional):
49+
# g, role:org_admin, role:org_editor, org:OpenedX # org_admin inherits org_editor permissions
50+
g = _, _, _
51+
52+
# g2: Action grouping and implications
53+
# Maps high-level actions to specific actions to reduce policy duplication
54+
#
55+
# Examples:
56+
# g2, act:manage, act:edit # manage implies edit
57+
# g2, act:manage, act:delete # manage implies delete
58+
# g2, act:edit-courses, act:read # edit-courses implies read (for resource access)
59+
# g2, act:edit-courses, act:write # edit-courses implies write (for resource modification)
60+
g2 = _, _
61+
62+
[policy_effect]
63+
# Deny-override policy: allow if any rule allows AND no rule denies
64+
# This enables negative rules/exceptions (e.g., "manage all courses except course Z")
65+
#
66+
# Evaluation order:
67+
# 1. Check if any policy grants allow
68+
# 2. Check if any policy specifies deny
69+
# 3. If deny found, result is deny (exceptions win)
70+
# 4. If allow found and no deny, result is allow
71+
# 5. If no matches, result is deny (default secure)
72+
e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
73+
74+
[matchers]
75+
# Authorization matching logic
76+
#
77+
# ROLE MATCHING:
78+
# - g(r.sub, p.sub, r.scope): check if subject has role in requested scope
79+
# - g(r.sub, p.sub, "*"): check if subject has role in all resources in the scope
80+
#
81+
# SCOPE MATCHING:
82+
# - keyMatch(r.scope, p.scope): scope matches pattern
83+
#
84+
# ACTION MATCHING:
85+
# - r.act == p.act: exact action match
86+
# - g2(p.act, r.act): policy action implies requested action via grouping
87+
#
88+
# All conditions must be true for a policy to match:
89+
# 1. Subject must have role in scope OR global role
90+
# 2. Scope must match pattern
91+
# 3. Action must match OR inherit via action grouping
92+
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))

openedx_authz/management/__init__.py

Whitespace-only changes.

openedx_authz/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""
2+
Django management command for interactive Casbin enforcement testing.
3+
4+
This command creates a Casbin enforcer using the model.conf configuration and a
5+
user-specified policy file, then provides an interactive mode for testing
6+
authorization enforcement requests.
7+
8+
The command supports:
9+
- Loading Casbin model from the built-in model.conf file or a custom file (specified via --model-file-path argument)
10+
- Using custom policy files (specified via --policy-file-path argument)
11+
- Interactive testing with format: subject action scope
12+
- Real-time enforcement results with visual feedback (✓ ALLOWED / ✗ DENIED)
13+
- Display of loaded policies, role assignments, and action grouping rules
14+
15+
Example usage:
16+
python manage.py enforcement --policy-file-path /path/to/authz.policy
17+
18+
python manage.py enforcement --policy-file-path /path/to/authz.policy --model-file-path /path/to/model.conf
19+
20+
Example test input:
21+
user:alice act:read org:OpenedX
22+
"""
23+
24+
import argparse
25+
import os
26+
27+
import casbin
28+
from django.core.management.base import BaseCommand, CommandError
29+
30+
from openedx_authz import ROOT_DIRECTORY
31+
32+
33+
class Command(BaseCommand):
34+
"""
35+
Django management command for interactive Casbin enforcement testing.
36+
37+
This command loads a Casbin model configuration and user-specified policy file
38+
to create an enforcer instance, then provides an interactive shell for testing
39+
authorization requests in real-time with immediate feedback.
40+
"""
41+
42+
help = (
43+
"Interactive mode for testing Casbin enforcement policies using a custom model file and"
44+
"a custom policy file. Provides real-time authorization testing with format: subject action scope. "
45+
"Use --policy-file-path to specify the policy file location. "
46+
"Use --model-file-path to specify the model file location. "
47+
)
48+
49+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
50+
"""Add command-line arguments to the argument parser.
51+
52+
Args:
53+
parser (argparse.ArgumentParser): The Django argument parser instance to configure.
54+
"""
55+
parser.add_argument(
56+
"--policy-file-path",
57+
type=str,
58+
required=True,
59+
help="Path to the Casbin policy file (supports CSV format with policies, roles, and action grouping)",
60+
)
61+
parser.add_argument(
62+
"--model-file-path",
63+
type=str,
64+
required=False,
65+
help="Path to the Casbin model file. If not provided, the default model.conf file will be used.",
66+
)
67+
68+
def handle(self, *args, **options):
69+
"""Execute the enforcement testing command.
70+
71+
Loads the Casbin model and policy files, creates an enforcer instance,
72+
displays configuration summary, and starts the interactive testing mode.
73+
74+
Args:
75+
*args: Positional command arguments (unused).
76+
**options: Command options including `policy_file_path` and `model_file_path`.
77+
78+
Raises:
79+
CommandError: If model or policy files are not found or enforcer creation fails.
80+
"""
81+
model_file_path = self._get_file_path("model.conf") or options["model_file_path"]
82+
policy_file_path = options["policy_file_path"]
83+
84+
if not os.path.isfile(model_file_path):
85+
raise CommandError(f"Model file not found: {model_file_path}")
86+
if not os.path.isfile(policy_file_path):
87+
raise CommandError(f"Policy file not found: {policy_file_path}")
88+
89+
self.stdout.write(self.style.SUCCESS("Casbin Interactive Enforcement"))
90+
self.stdout.write(f"Model file path: {model_file_path}")
91+
self.stdout.write(f"Policy file path: {policy_file_path}")
92+
self.stdout.write("")
93+
94+
try:
95+
enforcer = casbin.Enforcer(model_file_path, policy_file_path)
96+
self.stdout.write(self.style.SUCCESS("Casbin enforcer created successfully"))
97+
98+
policies = enforcer.get_policy()
99+
roles = enforcer.get_grouping_policy()
100+
action_grouping = enforcer.get_named_grouping_policy("g2")
101+
102+
self.stdout.write(f"✓ Loaded {len(policies)} policies")
103+
self.stdout.write(f"✓ Loaded {len(roles)} role assignments")
104+
self.stdout.write(f"✓ Loaded {len(action_grouping)} action grouping rules")
105+
self.stdout.write("")
106+
107+
self._run_interactive_mode(enforcer)
108+
109+
except Exception as e:
110+
raise CommandError(f"Error creating Casbin enforcer: {str(e)}") from e
111+
112+
def _get_file_path(self, file_name: str) -> str:
113+
"""Construct the full file path for a configuration file.
114+
115+
Args:
116+
file_name (str): The name of the configuration file (e.g., 'model.conf').
117+
118+
Returns:
119+
str: The absolute path to the configuration file in the engine/config directory.
120+
"""
121+
return os.path.join(ROOT_DIRECTORY, "engine", "config", file_name)
122+
123+
def _run_interactive_mode(self, enforcer: casbin.Enforcer) -> None:
124+
"""Start the interactive enforcement testing shell.
125+
126+
Provides a continuous loop where users can input enforcement requests
127+
in the format 'subject action scope' and receive immediate
128+
authorization results with visual feedback.
129+
130+
Args:
131+
enforcer (casbin.Enforcer): The configured Casbin enforcer instance for testing.
132+
133+
Note:
134+
Exit the interactive mode with Ctrl+C or Ctrl+D.
135+
"""
136+
self.stdout.write(self.style.SUCCESS("Interactive Mode"))
137+
self.stdout.write("Test custom enforcement requests interactively.")
138+
self.stdout.write("Enter 'quit', 'exit', or 'q' to exit the interactive mode.")
139+
self.stdout.write("")
140+
self.stdout.write("Format: subject action scope")
141+
self.stdout.write("Example: user:alice act:read org:OpenedX")
142+
self.stdout.write("")
143+
144+
while True:
145+
try:
146+
user_input = input("Enter enforcement test: ").strip()
147+
148+
if not user_input:
149+
continue
150+
151+
if user_input.lower() in ["quit", "exit", "q"]:
152+
break
153+
154+
self._test_interactive_request(enforcer, user_input)
155+
except (KeyboardInterrupt, EOFError):
156+
self.stdout.write(self.style.ERROR("Exiting interactive mode..."))
157+
break
158+
159+
def _test_interactive_request(self, enforcer: casbin.Enforcer, user_input: str) -> None:
160+
"""Process and test a single enforcement request from user input.
161+
162+
Parses the input string, validates the format, executes the enforcement
163+
check, and displays the result with appropriate styling.
164+
165+
Args:
166+
enforcer (casbin.Enforcer): The Casbin enforcer instance to use for testing.
167+
user_input (str): The user's input string in format 'subject action scope'.
168+
169+
Expected format:
170+
subject: The requesting entity (e.g., 'user:alice')
171+
action: The requested action (e.g., 'act:read')
172+
scope: The authorization context (e.g., 'org:OpenedX')
173+
"""
174+
try:
175+
parts = [part.strip() for part in user_input.split()]
176+
if len(parts) != 3:
177+
self.stdout.write(self.style.ERROR(f"✗ Invalid format. Expected 3 parts, got {len(parts)}"))
178+
self.stdout.write("Format: subject action scope")
179+
self.stdout.write("Example: user:alice act:read org:OpenedX")
180+
return
181+
182+
subject, action, scope = parts
183+
result = enforcer.enforce(subject, action, scope)
184+
185+
if result:
186+
self.stdout.write(self.style.SUCCESS(f"✓ ALLOWED: {subject} {action} {scope}"))
187+
else:
188+
self.stdout.write(self.style.ERROR(f"✗ DENIED: {subject} {action} {scope}"))
189+
190+
except (ValueError, IndexError, TypeError) as e:
191+
self.stdout.write(self.style.ERROR(f"✗ Error processing request: {str(e)}"))

openedx_authz/tests/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""
2+
Tests package for openedx_authz.
3+
4+
This package contains all tests for the openedx_authz application.
5+
"""

0 commit comments

Comments
 (0)