|
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.api.users import is_user_allowed, get_scopes_for_user_and_permission |
7 | 10 |
|
8 | 11 | from openedx.core.djangoapps.content_libraries.models import ContentLibraryPermission |
9 | 12 |
|
@@ -54,6 +57,177 @@ def is_course_creator(user): |
54 | 57 |
|
55 | 58 | return get_course_creator_status(user) == 'granted' |
56 | 59 |
|
| 60 | + |
| 61 | +class HasPermissionInContentLibraryScope(Rule): |
| 62 | + """Bridgekeeper rule that checks permissions via Casbin's policy engine. |
| 63 | +
|
| 64 | + This rule integrates Casbin's role-based authorization with Bridgekeeper's |
| 65 | + declarative permission system. It checks if a user has been granted a specific |
| 66 | + permission (action) through their role assignments in Casbin. |
| 67 | +
|
| 68 | + The rule works by: |
| 69 | + 1. Querying Casbin grouping policies to find the user's role assignments |
| 70 | + 2. Querying Casbin permission policies to find which roles grant the action |
| 71 | + 3. Matching role assignments with scopes to determine where the user has permission |
| 72 | +
|
| 73 | + This enables both individual object permission checks and efficient QuerySet |
| 74 | + filtering - a key feature that allows database-level filtering instead of |
| 75 | + checking each object individually. |
| 76 | +
|
| 77 | + Attributes: |
| 78 | + action_external_key (str): The action/permission to check (e.g., 'view', 'edit'). |
| 79 | + This should be the external key WITHOUT the namespace prefix. |
| 80 | + For example, use 'view' not 'act^view'. |
| 81 | +
|
| 82 | + scope_field (str): The Django model field/property that contains the scope identifier. |
| 83 | + This tells the rule WHERE to find the scope value in your model. |
| 84 | + Defaults to 'id'. |
| 85 | +
|
| 86 | + **IMPORTANT**: This can be a model property (like `library_key`) or a field. |
| 87 | + For ContentLibrary, use 'library_key' which is a @property that returns |
| 88 | + the LibraryLocatorV2 string representation. |
| 89 | +
|
| 90 | + The scope_field serves two purposes: |
| 91 | + - **For QuerySet filtering**: Builds SQL like `WHERE scope_field IN (...)` |
| 92 | + - **For object checks**: Extracts the scope from `instance.scope_field` |
| 93 | +
|
| 94 | + Supports Django ORM field lookups for nested fields: |
| 95 | + - 'library_key' - a @property on the model (ContentLibrary case) |
| 96 | + - 'id' - direct field on the model |
| 97 | + - 'library__id' - field on a related model |
| 98 | + - 'course__org__key' - multi-level relationship |
| 99 | +
|
| 100 | + Examples: |
| 101 | + Basic usage with default scope_field: |
| 102 | + >>> from bridgekeeper import perms |
| 103 | + >>> from openedx_authz.permissions import HasPermissionInScope |
| 104 | + >>> |
| 105 | + >>> # Assumes the model's 'id' field contains the scope |
| 106 | + >>> can_view = HasPermissionInScope('view') |
| 107 | + >>> perms['libraries.view'] = can_view |
| 108 | +
|
| 109 | + Specifying a custom scope_field: |
| 110 | + >>> # When scope is in a field named 'library_id' |
| 111 | + >>> can_view = HasPermissionInScope('view', scope_field='library_id') |
| 112 | + >>> |
| 113 | + >>> # When scope is in a related model |
| 114 | + >>> can_manage = HasPermissionInScope('manage', scope_field='library__key') |
| 115 | +
|
| 116 | + Compound permissions with boolean operators: |
| 117 | + >>> from bridgekeeper.rules import Attribute |
| 118 | + >>> |
| 119 | + >>> is_active = Attribute('is_active', True) |
| 120 | + >>> is_staff = Attribute('is_staff', True) |
| 121 | + >>> can_view = HasPermissionInScope('view', scope_field='library_id') |
| 122 | + >>> |
| 123 | + >>> # User must be active AND (staff OR have explicit permission) |
| 124 | + >>> perms['libraries.view'] = is_active & (is_staff | can_view) |
| 125 | +
|
| 126 | + QuerySet filtering (efficient, database-level): |
| 127 | + >>> from openedx.core.djangoapps.content_libraries.models import ContentLibrary |
| 128 | + >>> |
| 129 | + >>> # Gets all libraries user can view in a single SQL query |
| 130 | + >>> visible_libraries = perms['libraries.view'].filter( |
| 131 | + ... request.user, |
| 132 | + ... ContentLibrary.objects.all() |
| 133 | + ... ) |
| 134 | +
|
| 135 | + Individual object checks: |
| 136 | + >>> library = ContentLibrary.objects.get(library_id='lib:DemoX:CSPROB') |
| 137 | + >>> if perms['libraries.view'].check(request.user, library): |
| 138 | + ... # User can view this specific library |
| 139 | + ... return render_library(library) |
| 140 | +
|
| 141 | + Note: |
| 142 | + The scope identifiers in Casbin policies must match the values in your |
| 143 | + Django model's scope_field. For example, if Casbin stores |
| 144 | + 'lib:DemoX:CSPROB' and your model has library_id='lib:DemoX:CSPROB', |
| 145 | + they must match exactly (including format and casing). |
| 146 | + """ |
| 147 | + |
| 148 | + def __init__(self, action_external_key: str, filter_keys: list[str] = ["org", "slug"]): |
| 149 | + """Initialize the rule with the action and filter keys to filter on. |
| 150 | +
|
| 151 | + Args: |
| 152 | + action_external_key (str): The action/permission to check (e.g., 'view', 'edit'). |
| 153 | + filter_keys (list[str]): The model fields to filter on when building QuerySet filters. |
| 154 | + Defaults to ['org', 'slug'] for ContentLibrary. |
| 155 | + """ |
| 156 | + self.action_external_key = action_external_key |
| 157 | + self.filter_keys = filter_keys |
| 158 | + |
| 159 | + def query(self, user): |
| 160 | + """Convert this rule to a Django Q object for QuerySet filtering. |
| 161 | +
|
| 162 | + This method enables efficient database-level filtering by: |
| 163 | + 1. Querying the authorization system to get ALL library scopes where the user has this permission |
| 164 | + 2. Parsing the library keys (org/slug pairs) from the scopes |
| 165 | + 3. Building a Django Q object that filters for libraries matching those org/slug combinations |
| 166 | +
|
| 167 | + This avoids N+1 query problems by filtering at the database level rather |
| 168 | + than checking permission for each object individually. |
| 169 | +
|
| 170 | + Args: |
| 171 | + user: The Django user object (must have a 'username' attribute). |
| 172 | +
|
| 173 | + Returns: |
| 174 | + Q: A Django Q object that can be used to filter a QuerySet. |
| 175 | + The Q object combines multiple conditions using OR (|) operators, |
| 176 | + where each condition matches a library's org and slug fields: |
| 177 | + Q(org__short_name='OrgA' & slug='lib-a') | Q(org__short_name='OrgB' & slug='lib-b') |
| 178 | +
|
| 179 | + Example: |
| 180 | + >>> # User has 'view' permission in scopes: ['lib:OrgA:lib-a', 'lib:OrgB:lib-b'] |
| 181 | + >>> rule = HasPermissionInContentLibraryScope('view', filter_keys=['org', 'slug']) |
| 182 | + >>> q = rule.query(user) |
| 183 | + >>> # Results in: Q(org__short_name='OrgA', slug='lib-a') | Q(org__short_name='OrgB', slug='lib-b') |
| 184 | + >>> |
| 185 | + >>> # Apply to queryset |
| 186 | + >>> libraries = ContentLibrary.objects.filter(q) |
| 187 | + >>> # SQL: SELECT * FROM content_library |
| 188 | + >>> # WHERE (org.short_name='OrgA' AND slug='lib-a') |
| 189 | + >>> # OR (org.short_name='OrgB' AND slug='lib-b') |
| 190 | + """ |
| 191 | + scopes = get_scopes_for_user_and_permission( |
| 192 | + user.username, |
| 193 | + self.action_external_key |
| 194 | + ) |
| 195 | + |
| 196 | + library_keys = [scope.library_key for scope in scopes] |
| 197 | + |
| 198 | + if not library_keys: |
| 199 | + return Q(pk__in=[]) # No access, return Q that matches nothing |
| 200 | + |
| 201 | + # Build Q object: OR together (org AND slug) conditions for each library |
| 202 | + query = Q() |
| 203 | + for library_key in library_keys: |
| 204 | + query |= Q(org__short_name=library_key.org, slug=library_key.slug) |
| 205 | + |
| 206 | + return query |
| 207 | + |
| 208 | + def check(self, user, instance): |
| 209 | + """Check if user has permission for a specific object instance. |
| 210 | +
|
| 211 | + This method is used for checking permission on individual objects rather |
| 212 | + than filtering a QuerySet. It extracts the scope from the object and |
| 213 | + checks if the user has the required permission in that scope via Casbin. |
| 214 | +
|
| 215 | + Args: |
| 216 | + user: The Django user object (must have a 'username' attribute). |
| 217 | + instance: The Django model instance to check permission for. |
| 218 | +
|
| 219 | + Returns: |
| 220 | + bool: True if the user has the permission in the object's scope, |
| 221 | + False otherwise. |
| 222 | +
|
| 223 | + Example: |
| 224 | + >>> rule = HasPermissionInScope('view') |
| 225 | + >>> can_view = rule.check(user, library) |
| 226 | + >>> # Checks if user has 'view' permission in scope 'lib:DemoX:CSPROB' |
| 227 | + """ |
| 228 | + return is_user_allowed(user.username, self.action_external_key, str(instance.library_key)) |
| 229 | + |
| 230 | + |
57 | 231 | ########################### Permissions ########################### |
58 | 232 |
|
59 | 233 | # Is the user allowed to view XBlocks from the specified content library |
@@ -87,7 +261,9 @@ def is_course_creator(user): |
87 | 261 | is_global_staff | |
88 | 262 | # Libraries with "public read" permissions can be accessed only by course creators |
89 | 263 | (Attribute('allow_public_read', True) & is_course_creator) | |
90 | | - # Otherwise the user must be part of the library's team |
| 264 | + # Users can access libraries within their authorized scope (via Casbin/role-based permissions) |
| 265 | + HasPermissionInContentLibraryScope("view_library") | |
| 266 | + # Fallback to: the user must be part of the library's team (legacy permission system) |
91 | 267 | has_explicit_read_permission_for_library |
92 | 268 | ) |
93 | 269 |
|
|
0 commit comments