Skip to content

Commit 9a4b6a7

Browse files
committed
squash!: Added migration test, formatted code
1 parent 018c369 commit 9a4b6a7

7 files changed

Lines changed: 334 additions & 137 deletions

File tree

openedx_authz/engine/utils.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
from casbin import Enforcer
1010

11+
from openedx_authz.api.users import assign_role_to_user_in_scope, batch_assign_role_to_users_in_scope
12+
from openedx_authz.constants.roles import LIBRARY_ADMIN, LIBRARY_AUTHOR, LIBRARY_USER
13+
1114
logger = logging.getLogger(__name__)
1215

1316
GROUPING_POLICY_PTYPES = ["g", "g2", "g3", "g4", "g5", "g6"]
@@ -69,3 +72,82 @@ def migrate_policy_between_enforcers(
6972
except Exception as e:
7073
logger.error(f"Error loading policies from file: {e}")
7174
raise
75+
76+
77+
def migrate_legacy_permissions(ContentLibraryPermission):
78+
"""
79+
Migrate legacy permission data to the new Casbin-based authorization model.
80+
This function reads legacy permissions from the ContentLibraryPermission model
81+
and assigns equivalent roles in the new authorization system.
82+
83+
The old Library permissions are stored in the ContentLibraryPermission model, it consists of the following columns:
84+
85+
- library: FK to ContentLibrary
86+
- user: optional FK to User
87+
- group: optional FK to Group
88+
- access_level: 'admin' | 'author' | 'read'
89+
90+
In the new Authz model, this would roughly translate to:
91+
92+
- library: scope
93+
- user: subject
94+
- access_level: role
95+
96+
Now, we don't have an equivalent concept to "Group", for this we will go through the users in the group and assign
97+
roles independently.
98+
99+
param ContentLibraryPermission: The ContentLibraryPermission model to use.
100+
"""
101+
102+
legacy_permissions = ContentLibraryPermission.objects.select_related(
103+
"library", "library__org", "user", "group"
104+
).all()
105+
106+
# List to keep track of any permissions that could not be migrated
107+
permissions_with_errors = []
108+
109+
for permission in legacy_permissions:
110+
# Migrate the permission to the new model
111+
112+
# Derive equivalent role based on access level
113+
access_level_to_role = {
114+
"admin": LIBRARY_ADMIN,
115+
"author": LIBRARY_AUTHOR,
116+
"read": LIBRARY_USER,
117+
}
118+
119+
role = access_level_to_role.get(permission.access_level)
120+
if role is None:
121+
# This should not happen as there are no more access_levels defined
122+
# in ContentLibraryPermission, log and skip
123+
logger.error(f"Unknown access level: {permission.access_level} for User: {permission.user}")
124+
permissions_with_errors.append(permission)
125+
continue
126+
127+
# Generating scope based on library identifier
128+
scope = f"lib:{permission.library.org.name}:{permission.library.slug}"
129+
130+
if permission.group:
131+
# Permission applied to a group
132+
users = [user.username for user in permission.group.user_set.all()]
133+
logger.info(
134+
f"Migrating permissions for Users: {users} in Group: {permission.group.name} "
135+
f"to Role: {role.external_key} in Scope: {scope}"
136+
)
137+
batch_assign_role_to_users_in_scope(
138+
users=users, role_external_key=role.external_key, scope_external_key=scope
139+
)
140+
else:
141+
# Permission applied to individual user
142+
logger.info(
143+
f"Migrating permission for User: {permission.user.username} "
144+
f"to Role: {role.external_key} in Scope: {scope}"
145+
)
146+
147+
assign_role_to_user_in_scope(
148+
user_external_key=permission.user.username,
149+
role_external_key=role.external_key,
150+
scope_external_key=scope,
151+
)
152+
153+
return permissions_with_errors

openedx_authz/migrations/0002_migrate_legacy_permissions.py

Lines changed: 0 additions & 135 deletions
This file was deleted.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Generated by Django 5.2.7 on 2025-11-03 20:39
2+
3+
import logging
4+
5+
from django.db import migrations
6+
7+
from openedx_authz.engine.utils import migrate_legacy_permissions
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
def _log_migration_errors(permissions_with_errors: list) -> None:
13+
"""
14+
Log the permissions that could not be migrated during the migration process.
15+
Args:
16+
permissions_with_errors (list): List of ContentLibraryPermission instances that failed to migrate.
17+
"""
18+
logger.error(
19+
f"Migration completed with errors for {len(permissions_with_errors)} permissions.\n"
20+
"The following permissions could not be migrated:"
21+
)
22+
for permission in permissions_with_errors:
23+
logger.error(
24+
"Access level: %s, %sLibrary: %s",
25+
permission.access_level,
26+
f"User: {permission.user.username}, " if permission.user else f"Group: {permission.group.name}, ",
27+
permission.library.slug,
28+
)
29+
30+
31+
def apply_migrate_legacy_permissions(apps, schema_editor):
32+
"""
33+
Wrapper to run the migration using the historical version of the ContentLibraryPermission model.
34+
"""
35+
# ContentLibraryPermission model from the content_libraries app, here is where the legacy permissions are stored
36+
try:
37+
ContentLibraryPermission = apps.get_model("content_libraries", "ContentLibraryPermission")
38+
except LookupError:
39+
# Don't run the migration where the content_libraries app is not installed, like during development.
40+
logger.warning("ContentLibraryPermission model not found. Skipping migration.")
41+
return
42+
43+
permissions_with_errors = migrate_legacy_permissions(ContentLibraryPermission)
44+
45+
if permissions_with_errors:
46+
_log_migration_errors(permissions_with_errors)
47+
48+
49+
class Migration(migrations.Migration):
50+
"""
51+
Migration to transfer legacy permissions from ContentLibraryPermission
52+
to the new Casbin-based authorization model.
53+
"""
54+
55+
dependencies = [
56+
("openedx_authz", "0004_contentlibraryscope"),
57+
]
58+
59+
operations = [
60+
migrations.RunPython(apply_migrate_legacy_permissions),
61+
]

openedx_authz/models/core.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def get_registry(cls) -> dict[str, type["BaseRegistryModel"]]:
4444
"""
4545
return cls._registry
4646

47+
4748
class ScopeManager(models.Manager):
4849
"""Custom manager for Scope model that handles polymorphic behavior."""
4950

openedx_authz/tests/api/test_roles.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -968,7 +968,7 @@ def test_assign_role_creates_extended_casbin_rule(self):
968968
new_count = ExtendedCasbinRule.objects.count()
969969
self.assertEqual(new_count, initial_count + 1)
970970

971-
extended_rule = ExtendedCasbinRule.objects.order_by('-id').first()
971+
extended_rule = ExtendedCasbinRule.objects.order_by("-id").first()
972972
self.assertIsNotNone(extended_rule)
973973
self.assertIsNotNone(extended_rule.casbin_rule)
974974
self.assertIsNotNone(extended_rule.subject)

openedx_authz/tests/stubs/models.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@
88
from opaque_keys.edx.locator import LibraryLocatorV2
99

1010

11+
class Organization(models.Model):
12+
"""Stub model representing an organization for testing purposes.
13+
14+
.. no_pii:
15+
"""
16+
17+
name = models.CharField(max_length=255)
18+
short_name = models.CharField(max_length=100)
19+
20+
def __str__(self):
21+
return str(self.name)
22+
23+
1124
class ContentLibraryManager(models.Manager):
1225
"""Manager for ContentLibrary model with helper methods."""
1326

@@ -38,9 +51,37 @@ class ContentLibrary(models.Model):
3851

3952
locator = models.CharField(max_length=255, unique=True, db_index=True)
4053
title = models.CharField(max_length=255, blank=True, null=True)
54+
slug = models.SlugField(allow_unicode=True)
55+
org = models.ForeignKey(Organization, on_delete=models.PROTECT, null=True)
4156
created_at = models.DateTimeField(auto_now_add=True)
4257

4358
objects = ContentLibraryManager()
4459

4560
def __str__(self):
46-
return self.locator
61+
return str(self.locator)
62+
63+
64+
# Legacy permission models for testing purposes
65+
class ContentLibraryPermission(models.Model):
66+
"""Stub model representing legacy content library permissions for testing purposes.
67+
68+
.. no_pii:
69+
"""
70+
71+
ADMIN_LEVEL = "admin"
72+
AUTHOR_LEVEL = "author"
73+
READ_LEVEL = "read"
74+
ACCESS_LEVEL_CHOICES = (
75+
(ADMIN_LEVEL, "Administer users and author content"),
76+
(AUTHOR_LEVEL, "Author content"),
77+
(READ_LEVEL, "Read-only"),
78+
)
79+
80+
library = models.ForeignKey(ContentLibrary, on_delete=models.CASCADE)
81+
user = models.ForeignKey("auth.User", on_delete=models.CASCADE, null=True, blank=True)
82+
group = models.ForeignKey("auth.Group", on_delete=models.CASCADE, null=True, blank=True)
83+
access_level = models.CharField(max_length=30, choices=ACCESS_LEVEL_CHOICES)
84+
85+
def __str__(self):
86+
who = self.user.username if self.user else self.group.name
87+
return f"ContentLibraryPermission ({self.access_level} for {who})"

0 commit comments

Comments
 (0)