OEP-9: User Authorization (Permissions)#
OEP |
|
Title |
User Authorization (Permissions) |
Last Modified |
2023-10-02 |
Author |
Jeremy Bowman <jbowman@edx.org> |
Arbiter |
Eddie Fagin <eddie@edx.org> |
Status |
Replaced by OEP-66 |
Type |
Best Practice |
Created |
2016-09-09 |
Warning
This OEP has been replaced by OEP-66
Abstract#
Proposes best practices for implementing authorization in edX services such that there’s a clear separation between checking whether or not a user has permission to perform a given action and the details of how that determination is made.
Motivation#
To date, the implementation and verification of permissions have been somewhat conflated in the edX codebase. When a user attempts an action which is not permitted for all users, the code typically directly checks properties of the user: are they a superuser, do they belong to a particular group or have a particular role, etc. This has a few drawbacks:
Implementation of new features can get held up waiting for business decisions on exactly which types of users should be allowed to perform them.
This is often a violation of DRY (“Don’t Repeat Yourself”) which results in the same basic permission check being copied in multiple code locations, making it very difficult to consistently change its implementation (as may happen when a new type of user is introduced, or the need for a special exception becomes clear).
Fine-grained permission checks have sometimes been avoided even when appropriate due to the difficulty of copying the permission code around, finding a common place to store it, or updating all the code that used a coarser initial implementation. This has resulted in some users being technically capable of performing actions which logically should not be permitted.
When a decision is made to change who is granted a particular permission, it can be difficult to avoid accidentally changing other permissions with a similar implementation.
Specification#
Most authorization checks in Python code should use the standard Django authorization API, including the optional support for object-level permissions which isn’t implemented in the default backends. Some examples can help give context for the details:
if user.has_perm('my_app.change_modelname', model_instance):
# Code which depends on the user being allowed to edit that specific model instance
if user.has_perm('other_app.add_othermodel'):
# Code which depends on the user being allowed to create new instances of OtherModel
from django.contrib.auth.decorators import permission_required
@permission_required('polls.vote')
def my_view(request):
# ...
Permission Names#
Note that a permission name should respect the following rules:
It should contain only lower-case ASCII letters, periods, and underscores.
It should start with the name of a Django application followed by a period.
It should follow an “action_modelname” pattern for the rest of the name if appropriate (especially because several of these are used by the Django admin interface if defined), or a short description of an action otherwise.
Rule-Based Authorization#
While the Django authorization API is quite flexible, many Django developers
have not really utilized it because the default authentication backend that
comes with Django lacks support for object-level permissions and requires the
addition of per-user database records for even the most trivial permission
checks. Fortunately, Django supports custom authentication backends, and
checks each one that’s in use when making authorization checks. The backend
which we currently recommend for use in defining new permission checks is
rules. It makes no changes to the authentication of users trying to log
into the system, but allows the creation of new permissions by mapping
their name to a function which implements the check. Django apps which are
implemented in the repository for a service should generally define their
custom permissions in a rules.py
module where they will be automatically
loaded, as described in the documentation. For example:
import rules
from rules.predicates import is_superuser
@rules.predicate
def is_report_owner(user, report):
return report.owner == user
rules.add_perm('my_app.view_report', is_report_owner | is_superuser)
This allows permissions to be named and implemented in one place, without
requiring any additional database configuration. Note that reusable Django
applications should not automatically register implementations of their
permissions, as the actual services using them may need to implement their
own rules for them. rules
also provides an improved
permission_required
view decorator which support testing object-level
permissions; see the documentation for details.
Note that although the optional second argument to User.has_perm()
is
often a model instance, it can technically be any Python object which contains
information relevant to the permission being tested. This allows for even
greater flexibility in the kinds of authorization rules that can be
implemented.
QuerySets#
One drawback to rule-based authorization vs. explicit configuration of permissions in the database is that it complicates the filtering of querysets to return only permission-appropriate results. Checking the rule function for each result from the query both requires fetching more results than are needed and is likely to throw off pagination numbers (with some pages even having no results that pass the permission check). So instead, an alternate implementation of each rule that must be used as a queryset filter is needed. There doesn’t yet seem to be a good implementation of this that doesn’t require a lot of custom model manager methods, but such a package might work something like this:
from django.db.models import Q
from qpermissions import filter, perms
from rules.predicates import is_superuser
@filter
def is_book_author(user):
return Q(author=user)
is_book_author_or_superuser = is_book_author | is_superuser
perms['books.view_book'] = is_book_author_or_superuser
Book.objects.filter(perms['books.view_book'](user))
This would allow filter implementations for specific permissions to be kept
separate from model implementations, enable reuse of common elements in
multiple permissions, and perhaps even permit reuse in filter definitions of
rules
predicates that only check User
attributes. An
early version of this filtering API was proposed as an addition to the
rules
library, but it was concluded that it would work better as a
separate package.
Django REST Framework#
When using Django REST Framework to build a REST API, note that it has object
permissions and query filtering mechanisms which are designed to be compatible
with Django’s authorization API. This means they also work well with the
rules
authentication backend described above. You can
set the permissions policy to a class such as DjangoObjectPermissions
and DRF will automatically check the appropriate object permission whenever
performing an action on a single object. That particular class always denies
permission to anonymous users and assumes that there are no view_*
permissions relevant to viewing or listing objects; those points can be
changed if desired by creating a subclass, for example:
class DjangoObjectPermissionsIncludingView(permissions.DjangoObjectPermissions):
authenticated_users_only = False
perms_map = {
'GET': ['%(app_label)s.view_%(model_name)s'],
'OPTIONS': ['%(app_label)s.view_%(model_name)s'],
'HEAD': ['%(app_label)s.view_%(model_name)s'],
'POST': ['%(app_label)s.add_%(model_name)s'],
'PUT': ['%(app_label)s.change_%(model_name)s'],
'PATCH': ['%(app_label)s.change_%(model_name)s'],
'DELETE': ['%(app_label)s.delete_%(model_name)s'],
}
If additional information about the session is needed beyond the user’s identity in order to make a permission decision (for example, if an action should only be allowed if the client has been granted a particular OAuth scope, as outlined in OEP-4), then a custom BasePermission subclass can be implemented which both consults the Django authorization API and makes the necessary checks against the session or other properties of the request object.
In order to filter the querysets used to generate list responses to only include objects appropriate for the users permissions, an appropriate filter class should also be set. A generic implementation using the library proposed above for mapping permissions to Q objects might look as follows:
from qpermissions import perms
class DjangoPermissionRulesFilter(BaseFilterBackend):
perm_format = '%(app_label)s.view_%(model_name)s'
def filter_queryset(self, request, queryset, view):
user = request.user
model_cls = queryset.model
kwargs = {
'app_label': model_cls._meta.app_label,
'model_name': get_model_name(model_cls)
}
permission = self.perm_format % kwargs
if permission not in perms:
return queryset
return queryset.filter(perms[permission](user))
Such a class would be used in a view’s filter_backends attribute or could be used by default for all view classes which don’t override it.
Rationale#
Discussions about authorization in Open edX have made slow progress to date because they often got bogged down in the details of which particular rules to use for making authorization checks (or at least what kinds of rules to use, e.g. role membership) and how to pass the information needed to make authorization decisions across service boundaries. In the meantime, working code has needed to make authorization decisions and in the absence of concrete guidance has usually resorted to explicit User attribute checks, with the corresponding problems outlined in the Motivation section above.
Meanwhile, the implementation of OEP-3 has required the ability to apply authorization checks in a reusable application which should have no knowledge of the exact business logic used to implement them. To facilitate this and break down the overall authorization topic into more manageable chunks, this OEP was initiated to handle just best practices of how to be able to perform authorization checks in a consistent manner that makes no unreasonable assumptions about their implementation. Here are some of the goals which have shaped the recommendations:
Maintain compatibility with Django admin, Django REST Framework, and other 3rd-party Django packages (many of them use Django’s authorization API, and a few even use object-level permissions)
Reuse existing libraries when feasible
Keep a clear separation between the implementation and usage of permissions
Don’t require database migrations or data loads each time a new permission is added or the implementation of one is changed
Backward Compatibility#
The rules
package can be added to existing packages with minimal impact,
as it doesn’t inherently change the outcome of any authorization checks. In
the handful of places where Open edX code actually uses the Django
authorization API already, the permission implementation can be switched to
use rules
when convenient (at which point whichever implementation is
already in use should be deactivated so they don’t come to disagree over
time.) New code can be written to follow the guidelines in this OEP, and
existing code can be gradually updated as the need arises. However, effort
should be made to update all relevant code related to a new explicit
permission to avoid tempting developers into thinking they’ve fully updated
the implementation of a permission just by updating its rule while older code
still uses a copy of the original implementation.