|
2 | 2 | Permissions for Content Libraries (v2, Learning-Core-based) |
3 | 3 | """ |
4 | 4 | from bridgekeeper import perms, rules |
5 | | -from bridgekeeper.rules import Attribute, ManyRelation, Relation, blanket_rule, in_current_groups |
| 5 | +from bridgekeeper.rules import Attribute, ManyRelation, Relation, blanket_rule, in_current_groups, Rule |
6 | 6 | from django.conf import settings |
| 7 | +from django.db.models import Q |
| 8 | + |
| 9 | +from openedx_authz import api as authz_api |
| 10 | +from openedx_authz.constants.permissions import VIEW_LIBRARY |
7 | 11 |
|
8 | 12 | from openedx.core.djangoapps.content_libraries.models import ContentLibraryPermission |
9 | 13 |
|
@@ -54,6 +58,154 @@ def is_course_creator(user): |
54 | 58 |
|
55 | 59 | return get_course_creator_status(user) == 'granted' |
56 | 60 |
|
| 61 | + |
| 62 | +class HasPermissionInContentLibraryScope(Rule): |
| 63 | + """Bridgekeeper rule that checks content library permissions via the openedx-authz system. |
| 64 | +
|
| 65 | + This rule integrates the openedx-authz authorization system (backed by Casbin) with |
| 66 | + Bridgekeeper's declarative permission system. It checks if a user has been granted a |
| 67 | + specific permission (action) through their role assignments in the authorization system. |
| 68 | +
|
| 69 | + The rule works by: |
| 70 | + 1. Querying the authorization system to find library scopes where the user has this permission |
| 71 | + 2. Parsing the library keys (org/slug) from the scopes |
| 72 | + 3. Building database filters to match ContentLibrary models with those org/slug combinations |
| 73 | +
|
| 74 | + Attributes: |
| 75 | + permission (PermissionData): The permission object representing the action to check |
| 76 | + (e.g., 'view', 'edit'). This is used to look up scopes in the authorization system. |
| 77 | +
|
| 78 | + filter_keys (list[str]): The Django model fields to use when building QuerySet filters. |
| 79 | + Defaults to ['org', 'slug'] for ContentLibrary models. |
| 80 | +
|
| 81 | + These fields are used to construct the Q object filters that match libraries |
| 82 | + based on the parsed components from library keys in authorization scopes. |
| 83 | +
|
| 84 | + For ContentLibrary, library keys have the format 'lib:ORG:SLUG', which maps to: |
| 85 | + - 'org' -> filters on org__short_name (related Organization model) |
| 86 | + - 'slug' -> filters on slug field |
| 87 | +
|
| 88 | + If filtering by different fields is needed, pass a custom list. For example: |
| 89 | + - ['org', 'slug'] - default for ContentLibrary (filters by org and slug) |
| 90 | + - ['id'] - filter by primary key (for other models) |
| 91 | +
|
| 92 | + Examples: |
| 93 | + Basic usage with default filter_keys: |
| 94 | + >>> from bridgekeeper import perms |
| 95 | + >>> from openedx.core.djangoapps.content_libraries.permissions import HasPermissionInContentLibraryScope |
| 96 | + >>> |
| 97 | + >>> # Uses default filter_keys=['org', 'slug'] for ContentLibrary |
| 98 | + >>> can_view = HasPermissionInContentLibraryScope('view_library') |
| 99 | + >>> perms['libraries.view_library'] = can_view |
| 100 | +
|
| 101 | + Compound permissions with boolean operators: |
| 102 | + >>> from bridgekeeper.rules import Attribute |
| 103 | + >>> |
| 104 | + >>> is_active = Attribute('is_active', True) |
| 105 | + >>> is_staff = Attribute('is_staff', True) |
| 106 | + >>> can_view = HasPermissionInContentLibraryScope('view_library') |
| 107 | + >>> |
| 108 | + >>> # User must be active AND (staff OR have explicit permission) |
| 109 | + >>> perms['libraries.view_library'] = is_active & (is_staff | can_view) |
| 110 | +
|
| 111 | + QuerySet filtering (efficient, database-level): |
| 112 | + >>> from openedx.core.djangoapps.content_libraries.models import ContentLibrary |
| 113 | + >>> |
| 114 | + >>> # Gets all libraries user can view in a single SQL query |
| 115 | + >>> visible_libraries = perms['libraries.view_library'].filter( |
| 116 | + ... request.user, |
| 117 | + ... ContentLibrary.objects.all() |
| 118 | + ... ) |
| 119 | +
|
| 120 | + Individual object checks: |
| 121 | + >>> library = ContentLibrary.objects.get(org__short_name='DemoX', slug='CSPROB') |
| 122 | + >>> if perms['libraries.view_library'].check(request.user, library): |
| 123 | + ... # User can view this specific library |
| 124 | +
|
| 125 | + Note: |
| 126 | + The library keys in authorization scopes must have the format 'lib:ORG:SLUG' |
| 127 | + to match the ContentLibrary model's org.short_name and slug fields. |
| 128 | + For example, scope 'lib:DemoX:CSPROB' matches a library with |
| 129 | + org.short_name='DemoX' and slug='CSPROB'. |
| 130 | + """ |
| 131 | + |
| 132 | + def __init__(self, permission: authz_api.PermissionData, filter_keys: list[str] | None = None): |
| 133 | + """Initialize the rule with the action and filter keys to filter on. |
| 134 | +
|
| 135 | + Args: |
| 136 | + permission (PermissionData): The permission to check (e.g., 'view', 'edit'). |
| 137 | + filter_keys (list[str]): The model fields to filter on when building QuerySet filters. |
| 138 | + Defaults to ['org', 'slug'] for ContentLibrary. |
| 139 | + """ |
| 140 | + self.permission = permission |
| 141 | + self.filter_keys = filter_keys if filter_keys is not None else ["org", "slug"] |
| 142 | + |
| 143 | + def query(self, user): |
| 144 | + """Convert this rule to a Django Q object for QuerySet filtering. |
| 145 | +
|
| 146 | + Args: |
| 147 | + user: The Django user object (must have a 'username' attribute). |
| 148 | +
|
| 149 | + Returns: |
| 150 | + Q: A Django Q object that can be used to filter a QuerySet. |
| 151 | + The Q object combines multiple conditions using OR (|) operators, |
| 152 | + where each condition matches a library's org and slug fields: |
| 153 | + Q(org__short_name='OrgA' & slug='lib-a') | Q(org__short_name='OrgB' & slug='lib-b') |
| 154 | +
|
| 155 | + Example: |
| 156 | + >>> # User has 'view' permission in scopes: ['lib:OrgA:lib-a', 'lib:OrgB:lib-b'] |
| 157 | + >>> rule = HasPermissionInContentLibraryScope('view', filter_keys=['org', 'slug']) |
| 158 | + >>> q = rule.query(user) |
| 159 | + >>> # Results in: Q(org__short_name='OrgA', slug='lib-a') | Q(org__short_name='OrgB', slug='lib-b') |
| 160 | + >>> |
| 161 | + >>> # Apply to queryset |
| 162 | + >>> libraries = ContentLibrary.objects.filter(q) |
| 163 | + >>> # SQL: SELECT * FROM content_library |
| 164 | + >>> # WHERE (org.short_name='OrgA' AND slug='lib-a') |
| 165 | + >>> # OR (org.short_name='OrgB' AND slug='lib-b') |
| 166 | + """ |
| 167 | + scopes = authz_api.get_scopes_for_user_and_permission( |
| 168 | + user.username, |
| 169 | + self.permission.identifier |
| 170 | + ) |
| 171 | + |
| 172 | + library_keys = [scope.library_key for scope in scopes] |
| 173 | + |
| 174 | + if not library_keys: |
| 175 | + return Q(pk__in=[]) # No access, return Q that matches nothing |
| 176 | + |
| 177 | + # Build Q object: OR together (org AND slug) conditions for each library |
| 178 | + query = Q() |
| 179 | + for library_key in library_keys: |
| 180 | + query |= Q(org__short_name=library_key.org, slug=library_key.slug) |
| 181 | + |
| 182 | + return query |
| 183 | + |
| 184 | + def check(self, user, instance, *args, **kwargs): # pylint: disable=arguments-differ |
| 185 | + """Check if user has permission for a specific object instance. |
| 186 | +
|
| 187 | + This method is used for checking permission on individual objects rather |
| 188 | + than filtering a QuerySet. It extracts the scope from the object and |
| 189 | + checks if the user has the required permission in that scope via Casbin. |
| 190 | +
|
| 191 | + Args: |
| 192 | + user: The Django user object (must have a 'username' attribute). |
| 193 | + instance: The Django model instance to check permission for. |
| 194 | + *args: Additional positional arguments (for compatibility with parent signature). |
| 195 | + **kwargs: Additional keyword arguments (for compatibility with parent signature). |
| 196 | +
|
| 197 | + Returns: |
| 198 | + bool: True if the user has the permission in the object's scope, |
| 199 | + False otherwise. |
| 200 | +
|
| 201 | + Example: |
| 202 | + >>> rule = HasPermissionInContentLibraryScope('view') |
| 203 | + >>> can_view = rule.check(user, library) |
| 204 | + >>> # Checks if user has 'view' permission in scope 'lib:DemoX:CSPROB' |
| 205 | + """ |
| 206 | + return authz_api.is_user_allowed(user.username, self.permission.identifier, str(instance.library_key)) |
| 207 | + |
| 208 | + |
57 | 209 | ########################### Permissions ########################### |
58 | 210 |
|
59 | 211 | # Is the user allowed to view XBlocks from the specified content library |
@@ -87,7 +239,9 @@ def is_course_creator(user): |
87 | 239 | is_global_staff | |
88 | 240 | # Libraries with "public read" permissions can be accessed only by course creators |
89 | 241 | (Attribute('allow_public_read', True) & is_course_creator) | |
90 | | - # Otherwise the user must be part of the library's team |
| 242 | + # Users can access libraries within their authorized scope (via Casbin/role-based permissions) |
| 243 | + HasPermissionInContentLibraryScope(VIEW_LIBRARY) | |
| 244 | + # Fallback to: the user must be part of the library's team (legacy permission system) |
91 | 245 | has_explicit_read_permission_for_library |
92 | 246 | ) |
93 | 247 |
|
|
0 commit comments