"""Django REST Framework permission classes for JWT claim-based authorization.
This module provides permission classes that perform authorization based on claims
in validated JWT tokens (scopes, roles, permissions). These classes work with the
authentication classes and middleware to provide fine-grained access control.
Permission Logic:
Each permission class supports both OR and AND logic through different attributes:
- ``_any_`` attributes: User needs ANY ONE of the specified claims (OR logic)
- ``_all_`` attributes: User needs ALL of the specified claims (AND logic)
Configuration:
Configure custom claim names in Django settings::
# Optional: Configure custom claim names for roles
AXIOMS_ROLES_CLAIMS = ['roles', 'https://example.com/claims/roles']
# Optional: Configure custom claim names for permissions
AXIOMS_PERMISSIONS_CLAIMS = ['permissions', 'https://example.com/claims/permissions']
# Optional: Configure custom claim names for scopes
AXIOMS_SCOPE_CLAIMS = ['scope', 'scp']
Classes:
- ``HasAccessTokenScopes``: Check scopes (supports both OR and AND logic).
- ``HasAccessTokenRoles``: Check roles (supports both OR and AND logic).
- ``HasAccessTokenPermissions``: Check permissions (supports both OR and AND logic).
- ``IsSubOwner``: Object-level permission for token subject ownership.
- ``IsSubOwnerOrSafeOnly``: Object-level permission allowing safe methods or owner access.
- ``IsSafeOnly``: Permission allowing only safe HTTP methods.
- ``InsufficientPermission``: Exception raised when authorization fails.
Example::
from rest_framework.views import APIView
from rest_framework.response import Response
from axioms_drf.authentication import HasValidAccessToken
from axioms_drf.permissions import HasAccessTokenScopes, HasAccessTokenRoles
# OR logic - user needs ANY ONE scope
class DataView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenScopes]
access_token_scopes = ['read:data', 'write:data'] # OR logic (backward compatible)
# OR use: access_token_any_scopes = ['read:data', 'write:data']
def get(self, request):
return Response({'data': 'protected'})
# AND logic - user needs ALL scopes
class SecureView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenScopes]
access_token_all_scopes = ['read:data', 'write:data'] # AND logic
def post(self, request):
return Response({'status': 'created'})
"""
from axioms_core import (
check_permissions,
check_roles,
check_scopes,
get_claim_from_token,
)
from django.core.exceptions import ImproperlyConfigured
from rest_framework import status
from rest_framework.exceptions import APIException
from rest_framework.permissions import BasePermission
from .helper import build_config_from_django_settings
[docs]
class HasAccessTokenScopes(BasePermission):
"""Permission class that checks if user has required scopes.
Supports both OR logic (any scope) and AND logic (all scopes) through different
view attributes:
- ``access_token_scopes`` or ``access_token_any_scopes``: User needs ANY ONE
(OR logic)
- ``access_token_all_scopes``: User needs ALL (AND logic)
Attributes:
access_token_scopes: List of scopes (OR logic, backward compatible).
access_token_any_scopes: List of scopes (OR logic, explicit).
access_token_all_scopes: List of scopes (AND logic).
Example::
# OR logic - user needs read OR write
class DataView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenScopes]
access_token_scopes = ['read:data', 'write:data']
# AND logic - user needs BOTH read AND write
class SecureView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenScopes]
access_token_all_scopes = ['read:data', 'write:data']
# Method-level scopes - different scopes for each HTTP method
class MethodLevelView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenScopes]
@property
def access_token_scopes(self):
method_scopes = {
'GET': ['read:data'],
'POST': ['write:data'],
'DELETE': ['delete:data']
}
return method_scopes[self.request.method]
def get(self, request):
return Response({'data': []})
def post(self, request):
return Response({'status': 'created'})
# ViewSet with action-specific scopes
from rest_framework import viewsets
class ArticleViewSet(viewsets.ModelViewSet):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenScopes]
queryset = Article.objects.all()
serializer_class = ArticleSerializer
@property
def access_token_scopes(self):
action_scopes = {
'list': ['article:read'],
'retrieve': ['article:read'],
'create': ['article:create'],
'update': ['article:update'],
'partial_update': ['article:update'],
'destroy': ['article:delete'],
}
return action_scopes.get(self.action, [])
Raises:
InsufficientPermission: If user doesn't have required scopes.
ImproperlyConfigured: If no scope attribute is defined on the view.
"""
message = "Permission Denied"
[docs]
def has_permission(self, request, view):
"""Check if user has required scopes.
Args:
request: Django REST Framework request with ``auth_jwt`` attribute.
view: View instance with scope attributes.
Returns:
bool: ``True`` if user has required scopes.
Raises:
InsufficientPermission: If authorization fails.
"""
try:
auth_jwt = request.auth_jwt
config = build_config_from_django_settings()
token_scopes = get_claim_from_token(auth_jwt, "SCOPE", config) or ""
# Get all scope requirements
all_scopes = getattr(view, "access_token_all_scopes", None)
any_scopes = getattr(view, "access_token_any_scopes", None) or getattr(
view, "access_token_scopes", None
)
# At least one requirement must be defined
if not all_scopes and not any_scopes:
raise ImproperlyConfigured(
"Define access_token_scopes, access_token_any_scopes, "
"or access_token_all_scopes attribute"
)
# Check AND logic (all scopes required) if specified
if all_scopes:
if not check_scopes(token_scopes, all_scopes, operation="AND"):
raise InsufficientPermission
# Check OR logic (any scope sufficient) if specified
if any_scopes:
if not check_scopes(token_scopes, any_scopes, operation="OR"):
raise InsufficientPermission
# All checks passed
return True
except AttributeError:
raise InsufficientPermission
[docs]
class HasAccessTokenRoles(BasePermission):
"""Permission class that checks if user has required roles.
Supports both OR logic (any role) and AND logic (all roles) through different
view attributes:
- ``access_token_roles`` or ``access_token_any_roles``: User needs ANY ONE
(OR logic)
- ``access_token_all_roles``: User needs ALL (AND logic)
Attributes:
access_token_roles: List of roles (OR logic, backward compatible).
access_token_any_roles: List of roles (OR logic, explicit).
access_token_all_roles: List of roles (AND logic).
Example::
# OR logic - user needs admin OR moderator
class AdminView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenRoles]
access_token_roles = ['admin', 'moderator']
# AND logic - user needs BOTH admin AND superuser
class SuperAdminView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenRoles]
access_token_all_roles = ['admin', 'superuser']
# Method-level roles - different roles for each HTTP method
class MethodLevelView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenRoles]
@property
def access_token_roles(self):
method_roles = {
'GET': ['viewer', 'editor'],
'POST': ['editor', 'admin'],
'DELETE': ['admin']
}
return method_roles[self.request.method]
def get(self, request):
return Response({'data': []})
def post(self, request):
return Response({'status': 'created'})
# ViewSet with action-specific roles
from rest_framework import viewsets
class UserViewSet(viewsets.ModelViewSet):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenRoles]
queryset = User.objects.all()
serializer_class = UserSerializer
@property
def access_token_roles(self):
action_roles = {
'list': ['viewer', 'editor', 'admin'],
'retrieve': ['viewer', 'editor', 'admin'],
'create': ['admin'],
'update': ['editor', 'admin'],
'partial_update': ['editor', 'admin'],
'destroy': ['admin'],
}
return action_roles.get(self.action, [])
Raises:
InsufficientPermission: If user doesn't have required roles.
ImproperlyConfigured: If no role attribute is defined on the view.
"""
message = "Permission Denied"
[docs]
def has_permission(self, request, view):
"""Check if user has required roles.
Args:
request: Django REST Framework request with ``auth_jwt`` attribute.
view: View instance with role attributes.
Returns:
bool: ``True`` if user has required roles.
Raises:
InsufficientPermission: If authorization fails.
"""
try:
auth_jwt = request.auth_jwt
config = build_config_from_django_settings()
token_roles = get_claim_from_token(auth_jwt, "ROLES", config) or []
# Get all role requirements
all_roles = getattr(view, "access_token_all_roles", None)
any_roles = getattr(view, "access_token_any_roles", None) or getattr(
view, "access_token_roles", None
)
# At least one requirement must be defined
if not all_roles and not any_roles:
raise ImproperlyConfigured(
"Define access_token_roles, access_token_any_roles, "
"or access_token_all_roles attribute"
)
# Check AND logic (all roles required) if specified
if all_roles:
if not check_roles(token_roles, all_roles, operation="AND"):
raise InsufficientPermission
# Check OR logic (any role sufficient) if specified
if any_roles:
if not check_roles(token_roles, any_roles, operation="OR"):
raise InsufficientPermission
# All checks passed
return True
except AttributeError:
raise InsufficientPermission
[docs]
class HasAccessTokenPermissions(BasePermission):
"""Permission class that checks if user has required permissions.
Supports both OR logic (any permission) and AND logic (all permissions)
through different view attributes:
- ``access_token_permissions`` or ``access_token_any_permissions``:
User needs ANY ONE (OR logic)
- ``access_token_all_permissions``: User needs ALL (AND logic)
Attributes:
access_token_permissions: List of permissions (OR logic, backward compatible).
access_token_any_permissions: List of permissions (OR logic, explicit).
access_token_all_permissions: List of permissions (AND logic).
Example::
# OR logic - user needs read OR admin permission
class UserView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenPermissions]
access_token_permissions = ['user:read', 'user:admin']
# AND logic - user needs BOTH write AND delete
class CriticalView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenPermissions]
access_token_all_permissions = ['user:write', 'user:delete']
# Method-level permissions - different permission for each HTTP method
class MethodLevelView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenPermissions]
@property
def access_token_permissions(self):
method_permissions = {
'GET': ['user:read'],
'POST': ['user:create'],
'PATCH': ['user:update'],
'DELETE': ['user:delete']
}
return method_permissions[self.request.method]
def get(self, request):
return Response({'message': 'User read.'})
def post(self, request):
return Response({'message': 'User created.'})
# ViewSet with action-specific permissions
from rest_framework import viewsets
class DocumentViewSet(viewsets.ModelViewSet):
authentication_classes = [HasValidAccessToken]
permission_classes = [HasAccessTokenPermissions]
queryset = Document.objects.all()
serializer_class = DocumentSerializer
@property
def access_token_permissions(self):
action_permissions = {
'list': ['document:read'],
'retrieve': ['document:read'],
'create': ['document:create'],
'update': ['document:update'],
'partial_update': ['document:update'],
'destroy': ['document:delete'],
}
return action_permissions.get(self.action, [])
Raises:
InsufficientPermission: If user doesn't have required permissions.
ImproperlyConfigured: If no permission attribute is defined on the view.
"""
message = "Permission Denied"
[docs]
def has_permission(self, request, view):
"""Check if user has required permissions.
Args:
request: Django REST Framework request with ``auth_jwt`` attribute.
view: View instance with permission attributes.
Returns:
bool: ``True`` if user has required permissions.
Raises:
InsufficientPermission: If authorization fails.
"""
try:
auth_jwt = request.auth_jwt
config = build_config_from_django_settings()
token_permissions = (
get_claim_from_token(auth_jwt, "PERMISSIONS", config) or []
)
# Get all permission requirements
all_permissions = getattr(view, "access_token_all_permissions", None)
any_permissions = getattr(
view, "access_token_any_permissions", None
) or getattr(view, "access_token_permissions", None)
# At least one requirement must be defined
if not all_permissions and not any_permissions:
raise ImproperlyConfigured(
"Define access_token_permissions, access_token_any_permissions, "
"or access_token_all_permissions attribute"
)
# Check AND logic (all permissions required) if specified
if all_permissions:
if not check_permissions(
token_permissions, all_permissions, operation="AND"
):
raise InsufficientPermission
# Check OR logic (any permission sufficient) if specified
if any_permissions:
if not check_permissions(
token_permissions, any_permissions, operation="OR"
):
raise InsufficientPermission
# All checks passed
return True
except AttributeError:
raise InsufficientPermission
[docs]
class IsSubOwner(BasePermission):
"""Object-level permission that checks if the token subject matches the object owner.
This permission class checks if the ``sub`` (subject) claim from the JWT token
matches a specified attribute on the object being accessed. This is useful for
ensuring users can only access their own resources.
Attributes:
owner_attribute: Name of the object attribute to compare with token ``sub``.
Defaults to ``'user'``.
Example::
# Basic usage - compares token sub with object.owner
class ArticleDetailView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [IsSubOwner]
owner_attribute = 'author_id' # Compare with object.author_id
def get_object(self):
return Article.objects.get(pk=self.kwargs['pk'])
def get(self, request, pk):
article = self.get_object()
self.check_object_permissions(request, article)
return Response({'title': article.title})
# Using with ViewSet
class ArticleViewSet(viewsets.ModelViewSet):
authentication_classes = [HasValidAccessToken]
permission_classes = [IsSubOwner]
owner_attribute = 'user_id'
queryset = Article.objects.all()
serializer_class = ArticleSerializer
Raises:
InsufficientPermission: If token subject doesn't match object owner.
ImproperlyConfigured: If ``owner_attribute`` is not defined.
"""
message = "Permission Denied - Not the owner"
[docs]
def has_object_permission(self, request, view, obj):
"""Check if token subject matches object owner attribute.
Args:
request: Django REST Framework request with ``auth_jwt`` attribute.
view: View instance with ``owner_attribute``.
obj: Object being accessed.
Returns:
bool: ``True`` if token subject matches object owner.
Raises:
InsufficientPermission: If authorization fails.
ImproperlyConfigured: If owner_attribute is not set or object doesn't
have the attribute.
"""
try:
auth_jwt = request.auth_jwt
token_sub = getattr(auth_jwt, "sub", None)
if not token_sub:
raise InsufficientPermission
# Get the owner attribute name from view
owner_attr = getattr(view, "owner_attribute", None)
# Warn if owner_attribute is not explicitly set
if owner_attr is None:
import warnings
warnings.warn(
f"{view.__class__.__name__} does not explicitly set 'owner_attribute'. "
f"Defaulting to 'user'. This may cause ImproperlyConfigured errors. "
f"Set owner_attribute on your view to the correct field name "
f"(e.g., owner_attribute = 'author_sub').",
UserWarning,
stacklevel=2,
)
owner_attr = "user"
if not hasattr(obj, owner_attr):
raise ImproperlyConfigured(
f"Object does not have attribute '{owner_attr}'. "
f"Set owner_attribute on the view to the correct field name."
)
# Get the owner value from object
owner_value = getattr(obj, owner_attr, None)
# Compare token sub with object owner
if str(token_sub) != str(owner_value):
raise InsufficientPermission
return True
except AttributeError:
raise InsufficientPermission
[docs]
class IsSubOwnerOrSafeOnly(BasePermission):
"""Object-level permission for safe methods or owner-only modifications.
Allows safe HTTP methods (GET, HEAD, OPTIONS by default) for all authenticated
users, but restricts unsafe methods (POST, PUT, PATCH, DELETE) to the object owner.
Owner is determined by comparing the token ``sub`` claim with a specified object
attribute.
Attributes:
owner_attribute: Name of the object attribute to compare with token ``sub``.
Defaults to ``'owner'``.
safe_methods: Tuple of HTTP methods considered safe. Defaults to
``('GET', 'HEAD', 'OPTIONS')``.
Example::
# Allow anyone to read, but only owner can update/delete
class ArticleDetailView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [IsSubOwnerOrSafeOnly]
owner_attribute = 'author_id'
safe_methods = ('GET', 'HEAD', 'OPTIONS')
def get_object(self):
return Article.objects.get(pk=self.kwargs['pk'])
def get(self, request, pk):
# Anyone can read
article = self.get_object()
self.check_object_permissions(request, article)
return Response({'title': article.title})
def put(self, request, pk):
# Only owner can update
article = self.get_object()
self.check_object_permissions(request, article)
# Update logic
return Response({'status': 'updated'})
# Using with ViewSet
class ArticleViewSet(viewsets.ModelViewSet):
authentication_classes = [HasValidAccessToken]
permission_classes = [IsSubOwnerOrSafeOnly]
owner_attribute = 'user_id'
safe_methods = ('GET', 'HEAD', 'OPTIONS', 'LIST')
queryset = Article.objects.all()
serializer_class = ArticleSerializer
Raises:
InsufficientPermission: If non-safe method and token subject doesn't match owner.
ImproperlyConfigured: If ``owner_attribute`` is not defined.
"""
message = "Permission Denied - Safe methods only or must be owner"
[docs]
def has_object_permission(self, request, view, obj):
"""Check if request method is safe or token subject matches owner.
Args:
request: Django REST Framework request with ``auth_jwt`` and ``method``.
view: View instance with ``owner_attribute`` and ``safe_methods``.
obj: Object being accessed.
Returns:
bool: ``True`` if method is safe or user is owner.
Raises:
InsufficientPermission: If authorization fails.
"""
# Get safe methods from view or use default
safe_methods = getattr(view, "safe_methods", ("GET", "HEAD", "OPTIONS"))
# Allow safe methods for all authenticated users
if request.method in safe_methods:
return True
# For unsafe methods, check ownership
try:
auth_jwt = request.auth_jwt
token_sub = getattr(auth_jwt, "sub", None)
if not token_sub:
raise InsufficientPermission
# Get the owner attribute name from view
owner_attr = getattr(view, "owner_attribute", "user")
if not hasattr(obj, owner_attr):
raise ImproperlyConfigured(
f"Object does not have attribute '{owner_attr}'. "
f"Set owner_attribute on the view to the correct field name."
)
# Get the owner value from object
owner_value = getattr(obj, owner_attr, None)
# Compare token sub with object owner
if str(token_sub) != str(owner_value):
raise InsufficientPermission
return True
except AttributeError:
raise InsufficientPermission
[docs]
class IsSafeOnly(BasePermission):
"""Permission that only allows safe HTTP methods.
Restricts access to safe HTTP methods only (GET, HEAD, OPTIONS by default).
Useful for read-only endpoints where authenticated users can view but not modify.
Attributes:
safe_methods: Tuple of HTTP methods considered safe. Defaults to
``('GET', 'HEAD', 'OPTIONS')``.
Example::
# Read-only access for all authenticated users
class ArticleListView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [IsSafeOnly]
safe_methods = ('GET', 'HEAD', 'OPTIONS')
def get(self, request):
articles = Article.objects.all()
return Response({'articles': list(articles.values())})
def post(self, request):
# This will be denied by IsSafeOnly permission
return Response({'status': 'created'})
# Custom safe methods including LIST
class CustomReadOnlyView(APIView):
authentication_classes = [HasValidAccessToken]
permission_classes = [IsSafeOnly]
safe_methods = ('GET', 'HEAD', 'OPTIONS', 'LIST')
def get(self, request):
return Response({'data': 'read-only'})
Raises:
InsufficientPermission: If request method is not in safe_methods.
"""
message = "Permission Denied - Safe methods only"
[docs]
def has_permission(self, request, view):
"""Check if request method is safe.
Args:
request: Django REST Framework request with ``method``.
view: View instance with optional ``safe_methods`` attribute.
Returns:
bool: ``True`` if method is safe.
Raises:
InsufficientPermission: If method is not safe.
"""
# Get safe methods from view or use default
safe_methods = getattr(view, "safe_methods", ("GET", "HEAD", "OPTIONS"))
if request.method not in safe_methods:
raise InsufficientPermission
return True
[docs]
class InsufficientPermission(APIException):
"""Exception raised when user lacks required scopes, roles, or permissions.
This exception is raised by permission classes when a user's JWT token
doesn't contain the required claims for accessing a protected endpoint.
Follows RFC 6750 OAuth 2.0 Bearer Token Usage standard.
Attributes:
status_code: HTTP 403 Forbidden
default_detail: Error message dict with RFC 6750 compliant error and description
default_code: ``insufficient_scope``
Example::
# Automatically raised by permission classes
class ProtectedView(APIView):
permission_classes = [HasAccessTokenScopes]
access_token_scopes = ['admin']
def get(self, request):
# InsufficientPermission raised if user lacks 'admin' scope
return Response({'data': 'protected'})
"""
status_code = status.HTTP_403_FORBIDDEN
default_detail = {
"error": "insufficient_scope",
"error_description": "Insufficient role, scope or permission",
}
default_code = "insufficient_scope"