django-ninja
django-ninja copied to clipboard
Support for authorization
Add support for authorization.
Currently the package only supports authentication schemas. Would it be smart to add parameters for authorization similar to authentication? I believe so, because these two should be handled separately, since they solve 2 different problems.
Potential solution
This can be done similarly to current auth fields. Pass a list of callables which can then be checked.
Note:
I can implement this and create a PR.
Hi @KlemenS189 can you give pseudocode examples and use cases ?
Hey @vitalik
Example
@api.get("/add", authorization=[], auth=None)
def add(request, a: int, b: int):
return {"result": a + b}
or
@api.get("/add", permissions=[], auth=None)
def add(request, a: int, b: int):
return {"result": a + b}
I suggest permissions which is similar to django and distinguishable from auth
Use cases
At the company I work with, we are looking into integrating django ninja beside DRF. Maybe we could phase out DRF in the future... All classes which are not public have some sort of authorization. Adding this feature would allow code reuse and allow views to be:
@api.get("/add", permissions=[CanAccessThisView], auth=None)
def add(request, a: int, b: int):
return {"result": a + b}
instead of
@api.get("/add", auth=None)
def add(request: request.HttpRequest, a: int, b: int):
if not does_have_permission(request.user.id):
return 403, {"message": "You do not have permissions"}
return {"result": a + b}
Also, if you have multiple permissions checks, you can combine them with AND/OR like in DRF. If you have a large codebase, this would help a lot.
Its easier to have a callable and have django-ninja handle the permissions check.
This could be handled in ninja.operation.Operation._run_checks like so:
def _run_checks(self, request: HttpRequest) -> Optional[HttpResponse]:
"Runs security checks for each operation"
# auth:
if self.auth_callbacks:
error = self._run_authentication(request)
if error:
return error
# csrf:
if self.api.csrf:
error = check_csrf(request, self.view_func)
if error:
return error
# authorization
if self.authorization_callbacks:
error = self._run_authorization(request)
if error:
return error
return None
This can also be implemented with a new decorator. As I see, that there are many parameters in the main decorators. Correct me if I am wrong, but this can also be achieved with decorate_view?
@vitalik Sorry to bother. Do you have any thoughts on this?
This does not solve your initial problem, but it makes authz code just a little leaner. You could create a function that would perform the authorization you require and raise a custom exception if the expectations are not met. Then you need to register an exception_handler for that kind of exception to transform it into a proper 403 response (or any response, actually).
The downside is that you'd have to call this function in every API handler you would need to protect. I'm not sure if there's a way to apply it on a router level.
Here's an example using Django permissions (code not tested):
from ninja import NinjaAPI
class ForbiddenError(Exception):
pass
api = NinjaAPI(
# ...
)
@api.exception_handler(ForbiddenError)
def on_forbidden(request, exc: ForbiddenError):
return api.create_response(request, {"detail": str(exc) or "Forbidden"}, status=403)
def check_permission(request, permission: str):
if not request.user.has_perm(permission)
raise ForbiddenError(f"You do not have {permission} permission to perform this action.")
@api.post("/add")
def add_item(request)
check_permission(request, 'app_name.items.create')
return Item.objects().create()
@api.patch("/{item_id}")
def change_item(request, change_payload: ItemChange)
check_permission(request, 'app_name.items.change')
#...
@KlemenS189
I would extend @l1b3r idea and make a custom Permission router:
def check_permission_decorator(permission: str):
def decorator(func)
def wrapper(request, *a, **kw)
if not request.user.has_perm(permission):
raise ForbiddenError(f"You do not have {permission} permission to perform this action.")
return func(request, *a, **kw)
return wrapper
class PermissionRouter(Router):
def post(self, path, permission: str, **kwargs):
def decorator(view_func):
view_func = check_permission_decorator(permission)(view_func)
return self.add_api_operation(path, ["POST"], view_func, **kwargs)
return decorator
# and then use it like this:
router = PermissionRouter(auth=Some())
@router.post("/add", permission='app_name.items.create')
def add_item(request):
...
Having struggled through trying to setup permissions today, I'd very much recommend rolling some form of authorization into the base framework. It's possible to work around - but all of the options I came up with (custom decorator, custom router) were non-trivial to implement by hand. An established pattern would go a long way to speeding up implementation and adoption.
The proposed pattern here - a custom Router - requires overwriting each method function. That was painful enough that I had the same instinct of "the best way to solve this is to commit back to the framework".
FWIW - I like example 2 in @KlemenS189 's Nov 10th post.
Hey @jlucas91. I don't mind having authorization or permissions.
One downside of this approach is that there is a lot of function parameters to update and maintain.
Still I find it the most clean for DX.
In any case I wouldn't limit the permission parameter option only to strings. It should accept list of callables, where each callable can have custom logic to check for permission. Be it Django's permission or some logic with database fields.
Yeah - agreed re: lots of parameters to update/maintain. That's a +1 for me on rolling it into the base library :P. Better for the core framework to own it than ask consumers to.
Agreed - re: accepting a list of callables! It should present a similar structure to the Auth classes IMO.
I don't know if this is best practice but I was able to achieve this by import the permission_required decorator
` from django.contrib.auth.decorator import permission_required
@my_router.get( "my_recipes/", response=List[RecipeSchema], url_name="my_recipe_list", ) @/paginate(CustomPainator) @permission_required("recipes.view_recipe", raise_exception=True) def my_recipe_list(request) `
Not sure if this is the best way to do it.
Nice! That seems like a reasonable approach if you're using the built in Django auth system.