Skip to content

Commit 6ccd5a6

Browse files
authored
Merge pull request #259 from eadwinCode/async_permission
feat: Async Permission and Permission DI Support
2 parents 88219b2 + bab6d53 commit 6ccd5a6

File tree

12 files changed

+1454
-137
lines changed

12 files changed

+1454
-137
lines changed
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
# Async Permissions in Django Ninja Extra
2+
3+
This guide explains how to use the asynchronous permissions system in Django Ninja Extra for efficient permission handling with async views and models.
4+
5+
## Introduction
6+
7+
Django Ninja Extra provides an asynchronous permissions system that builds upon the existing permissions framework, adding support for async/await syntax. This is particularly useful when:
8+
9+
- Working with async views and controllers
10+
- Performing permission checks that involve database queries
11+
- Building efficient APIs that don't block the event loop
12+
13+
The permission system in Django Ninja Extra has been redesigned to seamlessly integrate both synchronous and asynchronous permissions, making it easy to:
14+
15+
- Create async-first permission classes
16+
- Mix sync and async permissions with logical operators
17+
- Use dependency injection with permissions
18+
- Easily migrate from sync to async permissions
19+
20+
## Creating Async Permissions
21+
22+
### Basic Async Permission
23+
24+
To create a custom async permission, inherit from `AsyncBasePermission` and implement the `has_permission_async` method:
25+
26+
```python
27+
from ninja_extra.permissions import AsyncBasePermission
28+
29+
class IsUserPremiumAsync(AsyncBasePermission):
30+
async def has_permission_async(self, request, controller):
31+
# You can perform async database operations here
32+
user = request.user
33+
34+
# Async check (example using Django's async ORM methods)
35+
subscription = await user.subscription.aget()
36+
return subscription and subscription.is_premium
37+
38+
# The sync version is automatically handled for you
39+
# through async_to_sync conversion
40+
```
41+
42+
### Using Built-in Permissions
43+
44+
Django Ninja Extra's permission system automatically handles both sync and async operations for built-in permissions:
45+
46+
```python
47+
from ninja_extra import api_controller, http_get
48+
from ninja_extra.permissions import IsAuthenticated, IsAdminUser
49+
50+
@api_controller(permissions=[IsAuthenticated])
51+
class UserController:
52+
@http_get("/profile", permissions=[IsAdminUser])
53+
async def get_admin_profile(self, request):
54+
# Only accessible to admin users
55+
# IsAdminUser works with async views automatically
56+
return {"message": "Admin profile"}
57+
```
58+
59+
## Combining Permissions with Logical Operators
60+
61+
The permission system supports combining permissions using logical operators (`&`, `|`, `~`):
62+
63+
```python
64+
from ninja_extra import api_controller, http_get
65+
from ninja_extra.permissions import IsAuthenticated, IsAdminUser, AsyncBasePermission
66+
67+
# Custom async permission
68+
class HasPremiumSubscriptionAsync(AsyncBasePermission):
69+
async def has_permission_async(self, request, controller):
70+
# Async check
71+
user_profile = await request.user.profile.aget()
72+
return user_profile.has_premium_subscription
73+
74+
@api_controller("/content")
75+
class ContentController:
76+
# User must be authenticated AND have premium subscription
77+
@http_get("/premium", permissions=[IsAuthenticated() & HasPremiumSubscriptionAsync()])
78+
async def premium_content(self, request):
79+
return {"content": "Premium content"}
80+
81+
# User must be authenticated OR an admin
82+
@http_get("/special", permissions=[IsAuthenticated() | IsAdminUser()])
83+
async def special_content(self, request):
84+
return {"content": "Special content"}
85+
86+
# User must be authenticated but NOT an admin
87+
@http_get("/regular", permissions=[IsAuthenticated() & ~IsAdminUser()])
88+
async def regular_content(self, request):
89+
return {"content": "Regular user content"}
90+
```
91+
92+
### How Permission Operators Work
93+
94+
When permissions are combined with logical operators, they create instances of `AND`, `OR`, or `NOT` classes that automatically handle both sync and async permissions:
95+
96+
- **AND**: Both permissions must return `True` (short-circuits on first `False`)
97+
- **OR**: At least one permission must return `True` (short-circuits on first `True`)
98+
- **NOT**: Inverts the result of the permission
99+
100+
The operators intelligently dispatch to either `has_permission`/`has_object_permission` or `has_permission_async`/`has_object_permission_async` depending on the context and permission types.
101+
102+
## Mixing Sync and Async Permissions
103+
104+
You can seamlessly mix regular permissions with async permissions:
105+
106+
```python
107+
from ninja_extra import api_controller, http_get
108+
from ninja_extra.permissions import IsAuthenticated, IsAdminUser, AsyncBasePermission
109+
110+
# Custom async permission
111+
class IsProjectMemberAsync(AsyncBasePermission):
112+
async def has_permission_async(self, request, controller):
113+
project_id = controller.kwargs.get('project_id')
114+
if not project_id:
115+
return False
116+
117+
# Async database query
118+
return await is_member_of_project(request.user.id, project_id)
119+
120+
@api_controller("/projects")
121+
class ProjectController:
122+
# Mixing sync and async permissions
123+
@http_get("/{project_id}/details", permissions=[IsAuthenticated() & IsProjectMemberAsync()])
124+
async def project_details(self, request, project_id: int):
125+
# The framework automatically handles the conversion between sync and async
126+
project = await get_project_by_id(project_id)
127+
return project
128+
```
129+
130+
The permission system automatically handles conversions between sync and async:
131+
132+
- When an async view calls a sync permission, it's wrapped with `sync_to_async`
133+
- When a sync view calls an async permission, it's wrapped with `async_to_sync`
134+
- Logical operators (`AND`, `OR`, `NOT`) intelligently handle mixed permission types
135+
136+
## Object-Level Permissions
137+
138+
For object-level permissions, implement the `has_object_permission_async` method:
139+
140+
```python
141+
class IsOwnerAsync(AsyncBasePermission):
142+
async def has_object_permission_async(self, request, controller, obj):
143+
# Async check on the object
144+
return obj.owner_id == request.user.id
145+
146+
@api_controller("/posts")
147+
class PostController:
148+
@http_get("/{post_id}")
149+
async def get_post(self, request, post_id: int):
150+
# The async_check_object_permissions method will be called automatically
151+
# when using aget_object_or_exception or aget_object_or_none
152+
post = await self.aget_object_or_exception(Post, id=post_id)
153+
return {"title": post.title, "content": post.content}
154+
```
155+
156+
## Using Dependency Injection with Permissions
157+
158+
Django Ninja Extra's permission system now integrates with dependency injection:
159+
160+
```python
161+
from injector import inject
162+
from ninja_extra import api_controller, http_get, service_resolver
163+
from ninja_extra.permissions import AsyncBasePermission
164+
165+
class FeatureService:
166+
def has_feature_access(self, user, feature):
167+
# Check if user has access to a specific feature
168+
return getattr(user, f'has_{feature}', False)
169+
170+
171+
class FeaturePermission(AsyncBasePermission):
172+
__features__ = {}
173+
174+
feature: str = "basic"
175+
176+
@inject
177+
def __init__(self, feature_service: FeatureService):
178+
self.feature_service = feature_service
179+
self.message = f"Must have access to {self.feature} feature"
180+
181+
# Async version of permission check
182+
async def has_permission_async(self, request, controller):
183+
return self.feature_service.has_feature_access(request.user, self.feature)
184+
185+
@classmethod
186+
def create_as(cls, feature: str) -> Type[FeaturePermission]:
187+
# Create a new permission class with the same attributes
188+
if feature in cls.__features__:
189+
return cls.__features__[feature]
190+
permission_type = type(f"{cls.__name__}_{feature}", (cls,), {"feature": feature})
191+
cls.__features__[feature] = permission_type
192+
return permission_type
193+
194+
195+
@api_controller('features')
196+
class FeatureController(ControllerBase):
197+
@http_get('basic/', permissions=[FeaturePermission.create_as("basic")])
198+
async def basic_feature(self):
199+
return {"feature": "basic"}
200+
201+
@http_get('premium/', permissions=[FeaturePermission.create_as("premium")])
202+
async def premium_feature(self):
203+
return {"feature": "premium"}
204+
205+
# You can even combine injected permissions with operators
206+
@http_get('both/', permissions=[FeaturePermission.create_as("basic") & FeaturePermission.create_as("premium")])
207+
async def both_features(self):
208+
return {"feature": "both"}
209+
```
210+
211+
The permission system automatically resolves the dependencies for injected permissions.
212+
213+
## How the Permission Resolution Works
214+
215+
The permission system uses a sophisticated resolution mechanism:
216+
217+
1. **Class vs Instance**: Permissions can be specified as either classes (`IsAuthenticated`) or instances (`IsAuthenticated()`).
218+
2. **Dependency Injection**: Classes decorated with `@inject` are resolved using the dependency injector.
219+
3. **Operator Handling**: When permissions are combined with operators, the resolution happens lazily, only when the permission is actually checked.
220+
221+
This resolution process is handled by the `_get_permission_object` method in the operation classes (`AND`, `OR`, `NOT`).
222+
223+
## Performance Considerations
224+
225+
- Use `AsyncBasePermission` for async-first permission classes
226+
- For optimal performance with database queries, use async methods like `aget()`, `afilter()`, etc.
227+
- The permission system automatically handles conversion between sync and async contexts using `asgiref.sync`
228+
- Logical operators implement short-circuiting for efficiency
229+
230+
## Complete Example
231+
232+
```python
233+
from django.contrib.auth.models import User
234+
from asgiref.sync import sync_to_async
235+
from ninja_extra import api_controller, http_get, http_post, ControllerBase
236+
from ninja_extra.permissions import AsyncBasePermission, IsAuthenticated, AllowAny
237+
238+
# Custom async permission
239+
class IsStaffOrOwnerAsync(AsyncBasePermission):
240+
async def has_permission_async(self, request, controller):
241+
return request.user.is_authenticated
242+
243+
async def has_object_permission_async(self, request, controller, obj):
244+
# Either the user is staff or owns the object
245+
return request.user.is_staff or obj.owner_id == request.user.id
246+
247+
# Controller using mixed permissions
248+
@api_controller("/users", permissions=[IsAuthenticated])
249+
class UserController(ControllerBase):
250+
@http_get("/", permissions=[AllowAny])
251+
async def list_users(self, request):
252+
# Public endpoint
253+
users = await sync_to_async(list)(User.objects.values('id', 'username')[:10])
254+
return users
255+
256+
@http_get("/{user_id}")
257+
async def get_user(self, request, user_id: int):
258+
# Protected by IsAuthenticated from the controller
259+
user = await self.aget_object_or_exception(User, id=user_id)
260+
return {"id": user.id, "username": user.username}
261+
262+
@http_post("/update/{user_id}", permissions=[IsStaffOrOwnerAsync()])
263+
async def update_user(self, request, user_id: int, data: dict):
264+
# Protected by custom async permission
265+
user = await self.aget_object_or_exception(User, id=user_id)
266+
# Update user data
267+
return {"status": "success"}
268+
```
269+
270+
## Migrating from Sync Permissions
271+
272+
If you already have sync permission classes that you want to make async, follow these steps:
273+
274+
1. Change the base class from `BasePermission` to `AsyncBasePermission`
275+
2. Implement the async methods (`has_permission_async` and `has_object_permission_async`)
276+
3. Convert any blocking operations to their async equivalents
277+
4. Update your controller to use these permissions
278+
279+
The framework will automatically handle the interoperability between sync and async permissions.
280+
281+
## Testing Async Permissions
282+
283+
Testing async permissions is straightforward using pytest-asyncio:
284+
285+
```python
286+
import pytest
287+
from unittest.mock import Mock
288+
from django.contrib.auth.models import AnonymousUser
289+
from ninja_extra.permissions import AsyncBasePermission, IsAdminUser
290+
291+
# Custom async permission for testing
292+
class CustomAsyncPermission(AsyncBasePermission):
293+
async def has_permission_async(self, request, controller):
294+
return request.user.is_authenticated
295+
296+
@pytest.mark.asyncio
297+
async def test_async_permission():
298+
# Create a mock request
299+
authenticated_request = Mock(user=Mock(is_authenticated=True))
300+
anonymous_request = Mock(user=AnonymousUser())
301+
302+
# Test the permission
303+
permission = CustomAsyncPermission()
304+
assert await permission.has_permission_async(authenticated_request, None) is True
305+
assert await permission.has_permission_async(anonymous_request, None) is False
306+
307+
# Test with operators
308+
combined = CustomAsyncPermission() & IsAdminUser()
309+
310+
admin_request = Mock(user=Mock(is_authenticated=True, is_staff=True))
311+
assert await combined.has_permission_async(admin_request, None) is True
312+
assert await combined.has_permission_async(authenticated_request, None) is False
313+
314+
assert combined.has_permission(admin_request, None) is True
315+
assert combined.has_permission(authenticated_request, None) is False
316+
```
317+
318+
For controller integration tests:
319+
320+
```python
321+
@pytest.mark.asyncio
322+
async def test_controller_with_permissions():
323+
@api_controller("/test")
324+
class TestController(ControllerBase):
325+
@http_get("/protected", permissions=[CustomAsyncPermission()])
326+
async def protected_route(self):
327+
return {"success": True}
328+
329+
# Create async test client
330+
client = TestAsyncClient(TestController)
331+
332+
# Test with anonymous user
333+
response = await client.get("/protected", user=AnonymousUser())
334+
assert response.status_code == 403
335+
336+
# Test with authenticated user
337+
auth_user = Mock(is_authenticated=True)
338+
response = await client.get("/protected", user=auth_user)
339+
assert response.status_code == 200
340+
assert response.json() == {"success": True}
341+
```

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ nav:
3737
- Index: api_controller/index.md
3838
- Controller Routes: api_controller/api_controller_route.md
3939
- Controller Permissions: api_controller/api_controller_permission.md
40+
- Controller Async Permissions: api_controller/api_controller_async_permission.md
4041
- Model Controller:
4142
- Getting Started: api_controller/model_controller/01_getting_started.md
4243
- Model Configuration: api_controller/model_controller/02_model_configuration.md

ninja_extra/context.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from ninja_extra.interfaces.route_context import RouteContextBase
1111
from ninja_extra.lazy import settings_lazy
12-
from ninja_extra.types import PermissionType
12+
from ninja_extra.permissions import BasePermissionType
1313

1414
if t.TYPE_CHECKING:
1515
from ninja_extra.details import ViewSignature
@@ -32,7 +32,7 @@ class RouteContext(RouteContextBase):
3232
"_has_computed_route_parameters",
3333
]
3434

35-
permission_classes: PermissionType
35+
permission_classes: t.List[BasePermissionType]
3636
request: t.Union[t.Any, HttpRequest, None]
3737
response: t.Union[t.Any, HttpResponse, None]
3838
args: t.List[t.Any]
@@ -42,7 +42,7 @@ def __init__(
4242
self,
4343
request: HttpRequest,
4444
args: t.Optional[t.List[t.Any]] = None,
45-
permission_classes: t.Optional[PermissionType] = None,
45+
permission_classes: t.Optional[t.List[BasePermissionType]] = None,
4646
kwargs: t.Optional[DictStrAny] = None,
4747
response: t.Optional[HttpResponse] = None,
4848
api: t.Optional["NinjaExtraAPI"] = None,
@@ -53,7 +53,7 @@ def __init__(
5353
self.args: t.List[t.Any] = args or []
5454
self.kwargs: DictStrAny = kwargs or {}
5555
self.kwargs.update({"view_func_kwargs": {}})
56-
self.permission_classes: PermissionType = permission_classes or []
56+
self.permission_classes: t.List[BasePermissionType] = permission_classes or []
5757
self._api = api
5858
self._view_signature = view_signature
5959
self._has_computed_route_parameters = False
@@ -115,7 +115,7 @@ async def async_compute_route_parameters(self) -> None:
115115
def get_route_execution_context(
116116
request: HttpRequest,
117117
temporal_response: t.Optional[HttpResponse] = None,
118-
permission_classes: t.Optional[PermissionType] = None,
118+
permission_classes: t.Optional[t.List[BasePermissionType]] = None,
119119
api: t.Optional["NinjaExtraAPI"] = None,
120120
view_signature: t.Optional["ViewSignature"] = None,
121121
*args: t.Any,

0 commit comments

Comments
 (0)