edx-rbac 3.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
edx_rbac/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ Library to help managing role based access controls for django apps.
3
+ """
4
+ from importlib.metadata import version
5
+
6
+ __version__ = version("edx-rbac")
@@ -0,0 +1,28 @@
1
+ """
2
+ Django admin integration for djangoapps using edx-rbac.
3
+ """
4
+
5
+ from django.contrib import admin
6
+
7
+ from edx_rbac.admin.forms import UserRoleAssignmentAdminForm
8
+
9
+
10
+ class UserRoleAssignmentAdmin(admin.ModelAdmin):
11
+ """
12
+ Django admin for UserRoleAssignment.
13
+ """
14
+
15
+ class Meta:
16
+ """
17
+ Meta class for UserRoleAssignmentAdmin.
18
+ """
19
+ abstract = True
20
+
21
+ list_display = (
22
+ 'user', 'role'
23
+ )
24
+
25
+ list_filter = ('role',)
26
+ search_fields = ('user__email', 'role__name')
27
+ fields = ('user', 'role',)
28
+ form = UserRoleAssignmentAdminForm
@@ -0,0 +1,29 @@
1
+ """
2
+ Forms to be used for djangoapps extending edx_rbac.
3
+ """
4
+
5
+ from django import forms
6
+ from django.utils.translation import gettext as _
7
+
8
+ from edx_rbac.fields import UserFromEmailField
9
+
10
+
11
+ class UserRoleAssignmentAdminForm(forms.ModelForm):
12
+ """
13
+ Custom Form for CRUD operations on models extending UserRoleAssignment.
14
+ """
15
+ user = UserFromEmailField(
16
+ label=_('User Email'),
17
+ required=True
18
+ )
19
+
20
+ class Meta:
21
+ abstract = True
22
+
23
+ def __init__(self, *args, **kwargs):
24
+ instance = kwargs.get('instance')
25
+ initial = kwargs.get('initial', {})
26
+ if instance:
27
+ initial['user'] = instance.user.email
28
+ kwargs['initial'] = initial
29
+ super().__init__(*args, **kwargs)
edx_rbac/apps.py ADDED
@@ -0,0 +1,13 @@
1
+ """
2
+ edx_rbac Django application initialization.
3
+ """
4
+
5
+ from django.apps import AppConfig
6
+
7
+
8
+ class EdxRbacConfig(AppConfig):
9
+ """
10
+ Configuration for the edx_rbac Django application.
11
+ """
12
+
13
+ name = 'edx_rbac'
edx_rbac/constants.py ADDED
@@ -0,0 +1,16 @@
1
+ """
2
+ All constants pertaining to the edx_rbac module.
3
+ """
4
+ # The string denoting that a given role is applicable across all contexts
5
+ ALL_ACCESS_CONTEXT = '*'
6
+
7
+ # .. toggle_name: RBAC_IGNORE_INVALID_JWT_COOKIE
8
+ # .. toggle_implementation: DjangoSetting
9
+ # .. toggle_default: False
10
+ # .. toggle_description: When true, causes instances of `jwt.exceptions.InvalidTokenError`
11
+ # to be ignored instead of raised in the `get_decoded_jwt()` utility function.
12
+ # Defaults to False for backward-compatibility purposes, but it's recommended
13
+ # to be set to True in dependent applications.
14
+ # .. toggle_use_cases: opt_in
15
+ # .. toggle_creation_date: 2024-08-19
16
+ IGNORE_INVALID_JWT_COOKIE_SETTING = 'RBAC_IGNORE_INVALID_JWT_COOKIE'
edx_rbac/decorators.py ADDED
@@ -0,0 +1,43 @@
1
+ """
2
+ Taken from https://github.com/escodebar/django-rest-framework-rules/blob/master/rest_framework_rules/decorators.py.
3
+ """
4
+
5
+ import functools
6
+
7
+ import crum
8
+
9
+
10
+ def permission_required(*permissions, **decorator_kwargs):
11
+ """
12
+ Verify permissions for access to the api.
13
+
14
+ :param permissions: Permissions added via django_rules add_perm
15
+ :param decorator_kwargs: Arguments for permission checks
16
+ :return: decorator
17
+ """
18
+ def decorator(view):
19
+ """Verify permissions decorator."""
20
+ @functools.wraps(view)
21
+ def wrapped_view(self, request, *args, **kwargs):
22
+ """Wrap for the view function."""
23
+ fn = decorator_kwargs.get('fn', None)
24
+ if callable(fn):
25
+ obj = fn(request, *args, **kwargs)
26
+ else:
27
+ obj = fn
28
+
29
+ crum.set_current_request(request)
30
+
31
+ missing_permissions = [perm for perm in permissions
32
+ if not request.user.has_perm(perm, obj)]
33
+ if any(missing_permissions):
34
+ # raises a permission denied exception causing a 403 response
35
+ self.permission_denied(
36
+ request,
37
+ message=f"Missing: {', '.join(missing_permissions)}"
38
+ )
39
+
40
+ return view(self, request, *args, **kwargs)
41
+ return wrapped_view
42
+
43
+ return decorator
edx_rbac/fields.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ Fields to be used for djangoapps extending edx_rbac.
3
+ """
4
+
5
+ from django import forms
6
+ from django.apps import apps
7
+ from django.conf import settings
8
+ from django.core.exceptions import ValidationError
9
+
10
+ USER_MODEL = apps.get_model(settings.AUTH_USER_MODEL)
11
+
12
+
13
+ class UserFromEmailField(forms.EmailField):
14
+ """
15
+ Custom Form Field class for selecting users by entering an email.
16
+
17
+ Meant to be used for models with foreign keys to the user table.
18
+ """
19
+
20
+ def clean(self, value):
21
+ """
22
+ Override for UserFromEmailField clean method.
23
+ """
24
+ try:
25
+ user = USER_MODEL.objects.get(email=value)
26
+ except USER_MODEL.DoesNotExist as error:
27
+ raise ValidationError(f'User with email {value} does not exist') from error
28
+
29
+ return user
edx_rbac/mixins.py ADDED
@@ -0,0 +1,183 @@
1
+ """
2
+ Mixin adapted from https://github.com/escodebar/django-rest-framework-rules/blob/master/rest_framework_rules/mixins.py.
3
+
4
+ Keeps py2 backward compatibility and only holds on to the necessary bits of the mixin needed.
5
+ """
6
+
7
+ import crum
8
+ from django.core.exceptions import ImproperlyConfigured
9
+ from django.utils.functional import cached_property
10
+
11
+ from edx_rbac import utils
12
+
13
+
14
+ class PermissionRequiredMixin:
15
+ """
16
+ Mixin for checking permissions with django rules.
17
+ """
18
+
19
+ object_permission_required = None
20
+ permission_required = None
21
+
22
+ def get_permission_required(self):
23
+ """
24
+ Return permissions required for the view it is mixed into.
25
+ """
26
+ if self.permission_required is None:
27
+ # This prevents a misconfiguration of the view into which the mixin
28
+ # is mixed. If the mixin is used, at least one permission should be
29
+ # required.
30
+ raise ImproperlyConfigured(
31
+ f'{self.__class__.__name__} is missing the permission_required attribute. Define '
32
+ f'{self.__class__.__name__}.permission_required, or override '
33
+ f'{self.__class__.__name__}.get_permission_required().'
34
+ )
35
+ if isinstance(self.permission_required, str):
36
+ perms = (self.permission_required, )
37
+ else:
38
+ perms = self.permission_required
39
+ return perms
40
+
41
+ def check_permissions(self, request):
42
+ """
43
+ Check through permissions required and throws a permission_denied if missing any.
44
+
45
+ If `get_permission_object` is implemented, it will be called and should return the object
46
+ for which the @rules predicate checks against.
47
+ """
48
+ crum.set_current_request(request)
49
+ user = request.user
50
+
51
+ if hasattr(self, 'get_permission_object') and callable(self.get_permission_object):
52
+ obj = self.get_permission_object()
53
+ else:
54
+ obj = None
55
+
56
+ missing_permissions = [perm for perm in self.get_permission_required()
57
+ if not user.has_perm(perm, obj)]
58
+
59
+ if any(missing_permissions):
60
+ self.permission_denied(
61
+ request,
62
+ message=f"MISSING: {', '.join(missing_permissions)}"
63
+ )
64
+
65
+
66
+ class PermissionRequiredForListingMixin(PermissionRequiredMixin):
67
+ """
68
+ Mixin for checking role-based access against multiple objects, specifically
69
+ using the "list" action of a DRF ViewSet. The brunt of the work is done in
70
+ the `accessible_contexts` cached property - this property determines the set
71
+ of "contexts" (probably identifiers of some model) to which the requesting
72
+ user has access under any of the `allowed_roles`. It's pretty opinionated and
73
+ you _must_ define the following fields:
74
+
75
+ `list_lookup_field` (string) - The field of the model you want to list instances of
76
+ that holds values defined by `accessible_contexts`.
77
+
78
+ `allowed_roles` (list) - A list of one or more roles against which access is checked. It's disjunctive,
79
+ which means that if "role_1" grants access to resources "A" and "B", and
80
+ "role_2" grants access to resource "C", defining `allowed_roles = ['role_1', 'role_2']`
81
+ will cause resources "A", "B", and "C" to be returned by a listing operation
82
+ for a user who is granted both "role_1" and "role_2".
83
+
84
+ `role_assignment_class` (class) - The RoleAssignmentClass against which DB-defined access is checked.
85
+
86
+ `base_queryset` (property) - A queryset which acts as the "base case". It should generally return
87
+ all accessible instances for a user who has access to anything within this viewset (like a superuser
88
+ or admin).
89
+ """
90
+ # This flag indicates whether requesting users with `is_staff = True`
91
+ # should ever encounter a PermissionDenied exception.
92
+ staff_are_never_forbidden = True
93
+
94
+ # This flag indicates whether requesting users with `is_superuser = True`
95
+ # should always have the `ALL_ACCESS_CONTEXT` included in their set of accessible contexts.
96
+ superusers_can_access_anything = True
97
+
98
+ # The field (as a string) of the model you want to list instances of.
99
+ list_lookup_field = None
100
+
101
+ # A list of one or more roles against which access is checked.
102
+ allowed_roles = []
103
+
104
+ # The RoleAssignmentClass against which DB-defined access is checked
105
+ role_assignment_class = None
106
+
107
+ @property
108
+ def base_queryset(self):
109
+ """
110
+ Should return a queryset that acts as the "base case".
111
+ It should generally return all accessible instances for a user who has access
112
+ to everything within this viewset (like a superuser or admin).
113
+ """
114
+ raise NotImplementedError
115
+
116
+ @cached_property
117
+ def request_action(self):
118
+ """
119
+ Returns `self.action` if it exists, None otherwise.
120
+ """
121
+ return getattr(self, 'action', None)
122
+
123
+ @cached_property
124
+ def accessible_contexts(self):
125
+ """
126
+ Cached set of contexts (usually model identifiers) the requesting user has access to.
127
+ Returns a set that contains the `ALL_ACCESS_CONTEXT` identifier
128
+ if the requesting user is a superuser.
129
+ """
130
+ contexts_via_jwt = utils.contexts_accessible_from_request(self.request, self.allowed_roles)
131
+ contexts_via_db = set()
132
+
133
+ if self.role_assignment_class:
134
+ contexts_via_db = utils.contexts_accessible_from_database(
135
+ self.request.user, self.allowed_roles, self.role_assignment_class
136
+ )
137
+
138
+ if self.request.user.is_superuser and self.superusers_can_access_anything:
139
+ contexts_via_db.add(utils.ALL_ACCESS_CONTEXT)
140
+
141
+ return contexts_via_jwt | contexts_via_db
142
+
143
+ def check_permissions(self, request):
144
+ """
145
+ If dealing with a "list" action, goes through some customized
146
+ logic to check which contexts are accessible by the requesting
147
+ user. If none are, and the user is not staff/super, raise
148
+ a `PermissionDenied` exception. Uses the parent class's `check_permissions()`
149
+ method if the request action is not "list".
150
+ """
151
+ if self.request_action == 'list':
152
+ # Super-users and staff won't get Forbidden responses,
153
+ # but depending on their assigned roles, staff may
154
+ # get an empty result set.
155
+ if request.user.is_superuser:
156
+ return
157
+ if request.user.is_staff and self.staff_are_never_forbidden:
158
+ return
159
+ if not self.accessible_contexts:
160
+ self.permission_denied(request)
161
+ else:
162
+ super().check_permissions(request)
163
+
164
+ def get_queryset(self):
165
+ """
166
+ Expects `self.base_queryset` to be explicitly defined as the "base case"
167
+ and `self.list_lookup_field` to be defined.
168
+ You'll most likely want it to be `MyModel.objects.all().order_by('some_field')`.
169
+ """
170
+ if getattr(self, 'base_queryset', None) is None:
171
+ raise Exception(f'{self.__class__} must have a non-null "base_queryset" field.')
172
+ if not getattr(self, 'list_lookup_field', None):
173
+ raise Exception(f'{self.__class__} must have a truthy "list_lookup_field" field.')
174
+
175
+ if self.request_action == 'list':
176
+ if not self.accessible_contexts:
177
+ return self.base_queryset.none()
178
+ if utils.has_access_to_all(self.accessible_contexts):
179
+ return self.base_queryset
180
+ kwargs = {self.list_lookup_field + '__in': self.accessible_contexts}
181
+ return self.base_queryset.filter(**kwargs)
182
+
183
+ return self.base_queryset
edx_rbac/models.py ADDED
@@ -0,0 +1,113 @@
1
+ """
2
+ Database models for edx_rbac.
3
+ """
4
+
5
+ from django.conf import settings
6
+ from django.core.exceptions import FieldDoesNotExist
7
+ from django.db import models
8
+ from django.db.models.base import ModelBase
9
+ from django.utils.translation import gettext_lazy as _
10
+ from model_utils.models import TimeStampedModel
11
+
12
+
13
+ class UserRoleAssignmentCreator(ModelBase):
14
+ """
15
+ The model extending UserRoleAssignment should get a foreign key to a model that is a subclass of UserRole.
16
+ """
17
+
18
+ def __new__(mcs, name, bases, attrs):
19
+ """
20
+ Override to dynamically create foreign key for objects begotten from abstract class.
21
+ """
22
+ model = super().__new__(mcs, name, bases, attrs)
23
+ if model.__name__ != 'UserRoleAssignment' and 'UserRoleAssignment' in [b.__name__ for b in bases]:
24
+ try:
25
+ model._meta.get_field('role')
26
+ except FieldDoesNotExist as error:
27
+ if model.role_class and issubclass(model.role_class, UserRole):
28
+ model.add_to_class(
29
+ 'role',
30
+ models.ForeignKey(model.role_class, db_index=True, on_delete=models.CASCADE),
31
+ )
32
+ else:
33
+ raise Exception('role_class must be defined for any subclass of UserRole!') from error
34
+ return model
35
+
36
+
37
+ class UserRole(TimeStampedModel):
38
+ """
39
+ Model defining a role a user can have.
40
+ """
41
+
42
+ name = models.CharField(unique=True, max_length=255, db_index=True)
43
+ description = models.TextField(null=True, blank=True)
44
+
45
+ class Meta:
46
+ """
47
+ Meta class for UserRole.
48
+ """
49
+
50
+ abstract = True
51
+
52
+ def __str__(self):
53
+ """
54
+ Return human-readable string representation.
55
+ """
56
+ return self.name
57
+
58
+
59
+ class UserRoleAssignment(TimeStampedModel, metaclass=UserRoleAssignmentCreator):
60
+ """
61
+ Model for mapping users and their roles.
62
+ """
63
+ role_class = None
64
+
65
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, db_index=True, on_delete=models.CASCADE)
66
+
67
+ applies_to_all_contexts = models.BooleanField(
68
+ default=False,
69
+ null=False,
70
+ help_text=_(
71
+ 'If true, indicates that the user is effectively assigned their role for any and all contexts. '
72
+ 'Defaults to False.'
73
+ ),
74
+ )
75
+
76
+ class Meta:
77
+ """
78
+ Meta class for UserRoleAssignment.
79
+ """
80
+
81
+ abstract = True
82
+
83
+ def get_context(self):
84
+ """
85
+ Return relevant context data related to this role assignment. Defaults to returning None.
86
+ """
87
+ return None
88
+
89
+ @classmethod
90
+ def get_assignments(cls, user, role_names=None):
91
+ """
92
+ Return iterator of (rolename, context).
93
+ """
94
+ if not user.is_anonymous:
95
+ kwargs = {'user': user}
96
+ if role_names:
97
+ kwargs['role__name__in'] = role_names
98
+
99
+ for assignment in cls.objects.filter(**kwargs).select_related('role'):
100
+ yield assignment.role.name, assignment.get_context()
101
+
102
+ def __str__(self):
103
+ """
104
+ Return human-readable string representation.
105
+ """
106
+ # pylint: disable=no-member
107
+ return f'{self.user.id}:{self.role.name}'
108
+
109
+ def __repr__(self):
110
+ """
111
+ Return uniquely identifying string representation.
112
+ """
113
+ return self.__str__()
@@ -0,0 +1,22 @@
1
+
2
+ {% comment %}
3
+ As the developer of this package, don't place anything here if you can help it
4
+ since this allows developers to have interoperability between your template
5
+ structure and their own.
6
+
7
+ Example: Developer melding the 2SoD pattern to fit inside with another pattern::
8
+
9
+ {% extends "base.html" %}
10
+ {% load static %}
11
+
12
+ <!-- Their site uses old school block layout -->
13
+ {% block extra_js %}
14
+
15
+ <!-- Your package using 2SoD block layout -->
16
+ {% block javascript %}
17
+ <script src="{% static 'js/ninja.js' %}" type="text/javascript"></script>
18
+ {% endblock javascript %}
19
+
20
+ {% endblock extra_js %}
21
+ {% endcomment %}
22
+
edx_rbac/urls.py ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ URLs for edx_rbac.
3
+ """
4
+
5
+ from django.urls import re_path
6
+ from django.views.generic import TemplateView
7
+
8
+ urlpatterns = [
9
+ re_path(r'', TemplateView.as_view(template_name="edx_rbac/base.html")),
10
+ ]