vitalik / django-ninja

💨 Fast, Async-ready, Openapi, type hints based framework for building APIs
https://django-ninja.dev
MIT License
7.19k stars 425 forks source link

How implement permissions like DRF in django-ninja? #789

Open forrestkouakou opened 1 year ago

forrestkouakou commented 1 year ago

I'm starting a big project with django-ninja last week, I managed to implement a JWT authentication with python-jose and one feature my project heavily relies on is permissions like it works in DRF or DRF - Access Policy or other DRF-related packages. Can someone recommend me a way to achieve this?

PS: I noticed that there is a lot of stuff not available with django-ninja and that can be a problem in long term and I don't want to go back to DRF or something like fastapi 😞

vitalik commented 1 year ago

Hi @forrestkouakou

here is a quick example:


from jose import jwt
from ninja.security import HttpBearer

class JWT(HttpBearer):
    def authenticate(self, request, token):
        try:
            data = jwt.decode(token, '<YOUR_SECRET>')
            return data # or some parameter in decoded data
        except Exception:
            return None

api = NinjaAPI(auth=JWT())

this will get and decode token from Http Authentication Bearer header

forrestkouakou commented 1 year ago

Hey @vitalik, thanks but I've already done the authentication part as I mentioned. Now I'm struggling with permissions like it's done here https://www.django-rest-framework.org/api-guide/permissions/ or even https://github.com/rsinger86/drf-access-policy because ModelPermissions and ObjectPermissions are important for this project. I want to be able to define global permissions but also per-action (get, post, put, patch, delete) permissions.

In case it helps, I can share a complete jwt implementation.

vitalik commented 1 year ago

@forrestkouakou well it's not an easy question without context and business logic..

the most easy way is adopt authenticator with permission logic like this:


class StaffOnlyModify:
       "Simple permission that allows modify only to superuser"

      def check(request, user):
               if request.method == 'GET':
                       return
               if not user.is_superuser:
                       raise HttpForbidden(403, "This operation not allowed")

class JWT(HttpBearer):
    def __init__(self, permissions):
           self.permissions = permissions

    def authenticate(self, request, token):
        try:
            data = jwt.decode(token, '<YOUR_SECRET>')
            user = get_user(data['user_id'])
            self.permissions.check(request, user)  # <-----
            return data # or some parameter in decoded data
        except Exception:
            return None

auth_staff_can_modify = JWT(permissions=StaffOnlyModify())

api = NinjaAPI(auth=auth_staff_can_modify) # GLobal

@api.get('/some', auth=auth_staff_can_modify)
def foo(request):
    ...

but yeah, that does not check on object level that you requesting - only request attributes - this implementation should live inside the api function body where you have access to querysets and concrete objects:

@api.get('/some')
def foo(request, id: int):
     obj = get_object_or_404(Some, id=id)
     permission.check(obj, user)
     return obj

to have a permission framework like DRF - we need some sort of class based views system - which in ninja case takes a bit controvesial thoughts across users.

OtherBarry commented 1 year ago

I've implemented permissions using a decorator here. Not quite as robust as DRF's but it's simple and easily extended.

forrestkouakou commented 1 year ago

@vitalik below some use cases from a multivendor marketplace which is part of a bigger project I'm working on.

I have plenty of cases like these but you get the idea, I'd like to define custom permissions on models and objects that will be shared across the project.

@OtherBarry I took a look at your decorator which is quite interesting. I can see a has_permissions method, adding a kind of has_object_permissions will be great (I think 🤔 ) Can you please give an example of how I can use this? I guess I need to define a permission with a class and then pass it like following: @permission_required([ClassPermission])

OtherBarry commented 1 year ago

@forrestkouakou the primary issue with object permissions is that there's no model tied directly to views in django-ninja. You could try and infer a model by following the schemas associated with the view, but that'd be pretty messy.

My suggestion would be that you pass the relevant model as an argument to the decorator, so something like:

@router.get("/customer", response=List[CustomerSchema])
@requires_permission(CustomerModel)
def view(request) -> QuerySet[CustomerModel]:
    return CustomerModel.objects.all()

You could then create a new permissions class with something like

class DefaultModelPermissions(BasePermission):
    REQUEST_TYPE_PERMISSION_MAP = {
        "POST": "add",
        "GET": "view",
        "DELETE": "delete",
        "PUT": "change",
        "PATCH": "change"
    }

    def has_permissions(self, request: AuthenticatedHttpRequest, permissions: Tuple[Type[Model], ...]) -> bool:
        permission_type = self.REQUEST_TYPE_PERMISSION_MAP.get(request.method)
        if permission_type is None:
             raise HttpError(405)
        permission_strs = []
        for model in permissions:
            model_str = model._meta.label_lower
            permission_str = model_str.replace(".", f".{permission_type}_")
            permissions_strs.append(permission_str)
        return request.user.has_perms(permissions)

This will test if the user has the relevant default django permissions for the provided model(s).

It's a bit dirty (and totally untested), but it should work reasonably well, and hopefully it'll give you some ideas on implementing a cleaner version.

EDIT: Yeah looks like DRF handles it basically the same way, though much cleaner, so probably worth copying the relevant parts of that.

forrestkouakou commented 1 year ago

This approach looks good, thanks @OtherBarry I'll try to make something with the way DRF implemented this and what you suggested and maybe have a cleaner - and shareable - version.

patagoniapy commented 1 year ago

Definitely not the best, but here's what I do:

I create a check_permission function like so:

async def check_permission(user: object, permission: str):
    permissions = [permissions.codename async for permissions in Permission.objects.select_related('content_type').filter(user=user)]
    return permission in permissions

and then use it like so:

async def add_transaction(request):
    user = await request.auth
    perm = await check_permission(user, 'add_transaction')
    if perm:
        all_transactions = [transactions async for transactions in Transaction.objects.select_related('user').filter(user=user)]
        return all_transactions
    else:
        raise HttpError(403, "Insufficient Permissions")

Decorator method would probably help my DRY violation here of if perm:

vlada1g commented 1 year ago

@forrestkouakou What have you done at the end? I got task to create user permissions check system, i should implement permission check on app level or on endpoint level.

Tragio commented 6 months ago

For someone in the future, you can check Ninja Extra: https://eadwincode.github.io/django-ninja-extra/api_controller/api_controller_permission/

jnoring-pw commented 3 months ago

django ninja does authentication (who is making this call? If unknown or invalid, 401) really well. The existing framework provides a really nice mechanism to handle this.

But it doesn't handle authorization (I know who this is and/or their authentication is legit, but do they have permission to do this thing? If not, 403) well at all. Checking for "permissions" in the authentication check is also bad, because there isn't enough information to do it well (have to parse the request object, for example, and the payload/position arguments/queries aren't available at that point)

What about: an small update to Operation.run (and AsyncOperation.run):

    def run(self, request: HttpRequest, **kw: Any) -> HttpResponseBase:
        error = self._run_checks(request)
        if error:
            return error
        try:
            temporal_response = self.api.create_temporal_response(request)

            self._pre_permissions(request)  # request.auth or # request.user is now set
            values = self._get_values(request, kw, temporal_response)
            self._post_permissions(request, **values)  # everything parsed

            result = self.view_func(request, **values)
            return self._result_to_response(request, result, temporal_response)
        except Exception as e:
            if isinstance(e, TypeError) and "required positional argument" in str(e):
                msg = "Did you fail to use functools.wraps() in a decorator?"
                msg = f"{e.args[0]}: {msg}" if e.args else msg
                e.args = (msg,) + e.args[1:]
            return self.api.on_exception(request, e)

Would also need a bunch of plumbing code to pipe through pre/post permission checks? I'd happily post an MR for this if such a strategy has legs.

forrestkouakou commented 1 month ago

Well the team decided to move to DRF for the class-based views and I didn't get a chance to work again with django-ninja. @vitalik or others were you able to take a look at this object-level permission issue? I couldn't find an example or a permission section in the docs so I guess I'll have to figure it out myself. @OtherBarry 's suggestion was quite interesting but I couldn't dive into it unfortunately.

forrestkouakou commented 1 week ago

@jnoring-pw agreeing with your suggestion. Were you able to take a closer look recently?

django ninja does authentication (who is making this call? If unknown or invalid, 401) really well. The existing framework provides a really nice mechanism to handle this.

But it doesn't handle authorization (I know who this is and/or their authentication is legit, but do they have permission to do this thing? If not, 403) well at all. Checking for "permissions" in the authentication check is also bad, because there isn't enough information to do it well (have to parse the request object, for example, and the payload/position arguments/queries aren't available at that point)

What about: an small update to Operation.run (and AsyncOperation.run):

    def run(self, request: HttpRequest, **kw: Any) -> HttpResponseBase:
        error = self._run_checks(request)
        if error:
            return error
        try:
            temporal_response = self.api.create_temporal_response(request)

            self._pre_permissions(request)  # request.auth or # request.user is now set
            values = self._get_values(request, kw, temporal_response)
            self._post_permissions(request, **values)  # everything parsed

            result = self.view_func(request, **values)
            return self._result_to_response(request, result, temporal_response)
        except Exception as e:
            if isinstance(e, TypeError) and "required positional argument" in str(e):
                msg = "Did you fail to use functools.wraps() in a decorator?"
                msg = f"{e.args[0]}: {msg}" if e.args else msg
                e.args = (msg,) + e.args[1:]
            return self.api.on_exception(request, e)

Would also need a bunch of plumbing code to pipe through pre/post permission checks? I'd happily post an MR for this if such a strategy has legs.

jnoring-pw commented 1 week ago

@forrestkouakou No, I haven't put together code--before I'd do so, I'd probably want some sort of confirmation that it's an approach the project might entertain? @vitalik may be able to comment