django-ninja icon indicating copy to clipboard operation
django-ninja copied to clipboard

[proposal] Authorization ( permissions ) handling

Open madEng84 opened this issue 3 years ago • 5 comments

In the docs I can see how to handle Authentication but not how to handle Authorization ( permissions ) After I have identified a logged user I want to return 403 if that user hasn't enough permission to do it DRF has a great way to handle it, using BasePermission.has_permission and BasePermission.has_object_permission

Maybe this library could handle permissions with something like this:


from ninja import Schema
from ninja.permissions import BasePermission

class CanSeeFruitsPerm(BasePermission):
    def has_permission(self, request, view):
        return request.user.has_perm("can_see_fruits")

class AppleOut(Schema):
    color: str
    quality: str
    

@api.get("/apples", auth=BasicAuth(), perm=CanSeeFruitsPerm(), response=List[AppleOut])
def list_apples(request):
    return Apple.objects.all()

What do you think about it?

Thank you

madEng84 avatar Jun 18 '21 13:06 madEng84

Great proposal! Wouldn't this mean a lot repetition though?

Another idea would be to add an optional parameter to the __init__ of the Ninja authentication base classes, and having Ninja run a has_permission method if a perm is specified. Then we can specify a permission when instantiating it.

For example

# inherit from base class BasicAuth
class UserAuth(BasicAuth):
    def authenticate(self, request, username, password):
        ....
    
    # Ninja runs this for you if a `perm` is specified on init
    def has_permission(self, request, perm):
        return request.user.has_perm(perm)

@api.get("/apples", auth=UserAuth(perm="can_see_fruits"), response=List[AppleOut])
def list_apples(request):
    return Apple.objects.all()

dozer133 avatar Jun 18 '21 16:06 dozer133

I'm glad you like this proposal

I think they are different things, one permission could be used for unauthenticated users too, or the same permission could be used for different authentication types

What do you think about?

madEng84 avatar Jun 28 '21 10:06 madEng84

I find myself repeating some sort of permission check against the authenticated user. It would be nice if Ninja supported permission checks like the following:

@router.get(
    url_name="resorce-get",
    path="/{user}/resource",
    response=ResourceSchema,
    auth=[auth1(), auth2(), ....].
    permissions=[perm1(), perm2(), ...]  # <- This
)
def get_resource(
    request: HttpRequest,
    user: str,
    **kwargs
):
....

This would hook nicely into Operation._run_checks

If something like this is welcome, I wouldn't mind trying to submit a PR for it.

marcosmoyano avatar Jun 09 '22 20:06 marcosmoyano

So what's the best method to approach different levels of permissions for users in Django ninja?

chrisbodon avatar Aug 09 '22 16:08 chrisbodon

To throw out another idea; it would've been nice with an approach similar to Django's user_passes_test. Though it could come with the adjustment of returning a 403 forbidden response if test didn't pass.

In case django ninja could support decorating an operation, it could look like something below (e.g. login_required and user_passes_test would probably have to be reimplemented to not trigger a redirect, like Django's do)

def email_check(user):
    return user.email.endswith('@example.com')


@api.get("/apples", auth=UserAuth(), response=List[AppleOut])
@login_required
@user_passes_test(email_check)
@permission_required("can_see_fruits", raise_exception=True)
def list_apples(request):
    return Apple.objects.all()

flaeppe avatar Aug 29 '22 19:08 flaeppe

And yes, I think we have to re-implement a few decorators here.

I thought I could get around this with raise_exception=True and catching Django's PermissionDenied:

from django.contrib.auth.decorators import permission_required
from django.core.exceptions import PermissionDenied
from ninja import Router

api = NinjaAPI(auth=[some_auth()])

@api.exception_handler(PermissionDenied)
def django_permission_denied(
    request: HttpRequest, exc: PermissionDenied
) -> HttpResponse:
    return api.create_response(...)
   
...
    
@router.get("/")  # <- mypy error
@permission_required('app.some_permission', raise_exception=True)  # note: raise_exception=True
def get_something(request: HttpRequest) -> ...:
    ...

But this is not going to work out, as I'm getting this mypy error:

error: Value of type variable "_VIEW" of function cannot be "Callable[[HttpRequest], ...]"  [type-var]

due to

https://github.com/typeddjango/django-stubs/blob/b81b1bf3868eaab9fecebdd56ed817ffcb07fad8/django-stubs/contrib/auth/decorators.pyi#L8

And I actually think django-stubs is right in assuming the response to be of type HttpResponseBase.

sebastian-philipp avatar Jan 27 '23 11:01 sebastian-philipp

I have made https://github.com/vitalik/django-ninja/discussions/776 as an alternative way to handle permissions :)

that's mostly inspied from django-rest-framework

baseplate-admin avatar Jun 16 '23 03:06 baseplate-admin

Why can't this be implemented using django builtin permission_required decorator?

from django.contrib.auth.decorators import permission_required

@permission_required("app.view_model")
@api.get("/model")
def model_get(request):
    ...

This apparently doesn't work as of 0.22.2

jtraub91 avatar Nov 09 '23 23:11 jtraub91

Why can't this be implemented using django builtin permission_required decorator?

cause, permission_required redirects to a login-page, which is not really the best idea for API clients

https://github.com/django/django/blob/f7389c4b07ceeb036436e065898e411b247bca78/django/contrib/auth/decorators.py#L36

sebastian-philipp avatar Nov 10 '23 19:11 sebastian-philipp