Repository: encode/django-rest-framework

Issue:
Decouple POST/create (API browser) form from GET/list (API) permissions.
I would like to use `DjangoModelPermissions` to restrict API access in general, and then explicitly allow access by anonymous users to POST/create and GET/retrieve (but not GET/list). For GET/retrieve, an additional URL argument will be verified so that clients can only GET/retrieve objects for which they know the URL.

I think I can allow access to POST/create by subclassing `DjangoModelPermissions.has_permission()` and returning `True` if `request.method == "POST"`. But, I can't test this using the API browser because when it returns a 403 forbidden response to my GET/list API request, it also does not show the POST/create form even though I do have POST/create permission.

This is incorrectly denying me the ability to submit a POST/create request via the API browser because a GET/list request is denied.

Also, on a somewhat related note, I don't think there is any (at least no easy) way to allow GET/retrieve but deny GET/list? I'm not using ViewSets, just generic CBVs. I think that if I did use ViewSets, an `action` attribute would be set on the view.

Could (and should) this also be set on generic CBVs, so that `has_permission()` can check `view.action` as well as `request.method` in determining whether or not to authorize a request?

This inconsistency makes ViewSets less of a convenience and more of a requirement if you need to customise permissions in this way.



Discussion:
The relevant line in code appears to be #125 in `rest_framework/base.html`, where it has `{% if response.status_code != 403 %}`.

This hides the form that would enable us to submit a POST/create request (which I have permission to do) in the API browser because an API response of 403 was returned for GET/list (which I have intentionally denied).

I'm not sure about supporting this.  The behavior is as it is, in order to avoid leaking any accidental information about objects existence or otherwise that might accidentally be able to be inferred by the inclusion of those forms.  If you look into the git history for that particular line you might be able to track down exactly when and why it was introduced.

I'm somewhat inclined to close this as being simply not a supported style of using the browsable API.  Typically in this case you'd want list to return a _filtered_ list of any objects the user can operate on.  Of course if you want to modify the behavior in your own app you still can, by customising the browsable API template (and omitting that line) 

(Oops, hit send early)

here... http://django-rest-framework.org/topics/browsable-api.html#customizing

> Could (and should) this also be set on generic CBVs, so that has_permission() can check view.action as well as request.method in determining whether or not to authorize a request?

Nope.  action is specifically a viewset concept.  In regular views you're making a request with a particular method, but there's no specific action, just a view that's being called.  You can do something similar by inspecting the URL kwargs that are being passed through eg to differentiate between a list view (eg. no pk being passed in) and a detail view (eg. a pk or other kwarg being passed in the URL kwargs)

I looked through the history for that file (took a while - a few renames to follow), and there doesn't appear to be any discussion or justification around this change.

https://github.com/tomchristie/django-rest-framework/commit/0b18b58c170aae0c2a93a608997e557fdcefe826

Could you elaborate on how exactly exposing a `POST/create` form in the API browser could leak information about object existence? The form should only be exposed when the client is authorised to make `POST/create` requests.

To be clear, I am not suggesting that the API response for the `GET/list` request (which is on the same page) should be allowed just because `POST/create` is allowed. I don't really see a reason why these can't be decoupled?

The "<Model> List" page in the API browser should display a 403 forbidden response to the `GET/list` request (if GET/list is not allowed), but it should still display an empty form (if `POST/create` is allowed) so that users a) know they have permission to POST/create; and b) can actually submit a test POST/create request via the API browser. These two permission checks for two different endpoints should be separate from each other, even though they are displayed on the same page in the API browser.

Currently, anyone using the API browser when `GET/list` is denied but `POST/create` is allowed will have to know in advance that `POST/create` is allowed (even though the API browser says not), and will have to manually construct and submit requests in order to test their API.

I tried your suggested method of allowing `GET/list` (just so I could use the `POST/create` form in the API browser) and then filtering the queryset with `.none()` (because I don't actually want to allow `GET/list`). But this has a knock-on effect of breaking `GET/retrieve` (because the queryset has no objects). It's also forcing me to open up more permissions than I should have to, for API endpoints that I'm not interested in using.

I don't want anonymous users to be able to `GET/list` all available objects, but as these are anonymous users, I can't filter the set of objects that they can operate on for `GET/retrieve`. Instead, I limit access to `GET/retrieve` specific objects by using a PK and a salted hash as URL arguments, that they must know in advance (e.g. after they `POST/create` an object, they will be told the PK and hash or the complete URL to `GET/retrieve`.

Customising the API browser template as you suggested won't be easy. The docs say I should create `rest_framework/api.html` and extend `rest_framework/base.html`, then override the blocks that I want to change. Unfortunately the entire content section (including the form) is not wrapped in a block. Even if there was a block around the form section specifically, I'd still have to copy and paste nearly 100 lines of HTML just to remove that one line so that the form would display.

Please reconsider :) The API browser should accurately expose authorised endpoints and facilitate the creation of POST/PUT/PATCH requests.

One example might be if a project is making permission checks inside the view logic for some reason.  The user could receive a 403 response, but checking the permission classes would indicate that PUT and POST are allowed.  PUT forms would then end up displaying instance data on the 403 pages.

I'm not saying that your request isn't at all sensible - it does make sense, but I'm not convinced it's worth the trade off in potentially exposing data unexpectedly.  The browsable API isn't imposing any particularly awkward constraints here - you need to be able to make a permitted GET request to the endpoint or we won't expose any extra information.

May be happy to reconsider if it's getting re-raised by other folks but I'd suggest that you consider either customizing the browsable API so it works for your specific use case, or using a more typical style of data representation (eg have your list view expose the filtered set of objects that the user has view permission)

It's often said of the Django Admin that "the Admin is not your App". I think the same holds true of DRF's Browsable API.

The Browseable API serves as a awesome prototyping tool — but it has necessary limitations: it's too much of a job to extend it to serve as a general purpose web application for all use-cases. 

If people want to customise it for their own app then cool — go ahead, and share the results by all means — but, just as in customising the Django Admin, it's often going to be better to build the app you want rather than trying to push it too far out of shape.

I completely agree that the API browser is not your app. I am not trying to make it my application, and I only use the Django Admin for basic raw access to the database for the same reason.

I am only using the API browser to inspect, navigate and test the boundaries and permissions of my API during development. The only customisation I am making here is assigning different permissions for various endpoints.

This is why I think it would be most beneficial for the API browser to accurately reflect the actual permissions defined in the API. So that we can be confident that what we see during development is what clients will see in production.

On the subject of leaking data, there's no danger that displaying the POST/create will leak anything, right? So we could at least still show the POST/create form even if the GET/list endpoint returns 403 forbidden, because no GET/list data is needed to construct a POST/create request.

As for PUT, a normal API client would first have to GET/retrieve the data, alter it, and then PUT/update it. So the API browser should not assume that PUT/update permission also grants GET/retrieve permission, and should only display (or only pre-populate with initial data) the PUT/update form if the GET/retrieve operation on the same page was actually successful.

This should cover off the use-case where permissions are being checked inside the actual view. No data will be leaked and displayed in the PUT/update form if GET/retrieve is denied in the view, and both POST/create and PUT/update can return 403 denied in the view when the form is submitted.

If the answer remains "don't do that - you want something non-standard, you're free to customise", I would at least like to make that customisation a little easier. Perhaps a hook somewhere in the view code where I can set a context variable in certain situations that will dictate whether or not the form is displayed, instead of a blank denial when the GET/list request is denied? Or at the very least, a block around the form section in `rest_framework/base.html` so that I can copy and paste 81 of 83 lines of code :)

Unfortunately, with anonymous users I don't think I can adopt a typical "filtered list of objects this user can operate on" workflow. This particular app is for anonymous users to submit entries for online promotions. There is no authentication. There is no need and it's too risky to have anonymous users listing entries that might belong to other users, but I can give users a URL (with a PK and salted hash as URL arguments) that they can use to lookup an entry they just created.

Adding customisation hooks is not a bad idea in principle. @mrmachine: If you have time to sketch out the required changes and put them in a pull request we'd have something concrete to base discussion around. (Any pull request doesn't need to be perfect to begin — it can be "How about something like this?")

> On the subject of leaking data, there's no danger that displaying the POST/create will leak anything, right?

Even with the POST form you may be exposing information such as which URLs are valid.

Having the 403 check in there just seems safer.  It would be seriously bad news if we accidentally opened up API information on some live installations somewhere because we'd not fully thought some type of request or edge case through.

> Adding customisation hooks is not a bad idea in principle.

Refactoring BrowsableAPIRenderer to use a `.get_context()` method would make sense here.  We could drop the 403 check into a 'include_forms' context key, and you'd simply be able to subclass BrowsableAPIRenderer, overriding `.get_context()`if you wanted to modify it. I think there's also some clean up that could be done along the way, by just populating the context dictionary directly, rather than by setting a bunch of attributes that are then immediatly passed into the dictionary and never reused elsewhere.

Hope I've explained adequately here! :)


Resolving patch:
diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py
--- a/rest_framework/renderers.py
+++ b/rest_framework/renderers.py
@@ -564,67 +564,65 @@ def get_description(self, view):
     def get_breadcrumbs(self, request):
         return get_breadcrumbs(request.path)
 
-    def render(self, data, accepted_media_type=None, renderer_context=None):
+    def get_context(self, data, accepted_media_type, renderer_context):
         """
-        Render the HTML for the browsable API representation.
+        Returns the context used to render.
         """
-        self.accepted_media_type = accepted_media_type or ''
-        self.renderer_context = renderer_context or {}
-
         view = renderer_context['view']
         request = renderer_context['request']
         response = renderer_context['response']
 
         renderer = self.get_default_renderer(view)
-        content = self.get_content(renderer, data, accepted_media_type, renderer_context)
-
-        put_form = self.get_rendered_html_form(view, 'PUT', request)
-        post_form = self.get_rendered_html_form(view, 'POST', request)
-        patch_form = self.get_rendered_html_form(view, 'PATCH', request)
-        delete_form = self.get_rendered_html_form(view, 'DELETE', request)
-        options_form = self.get_rendered_html_form(view, 'OPTIONS', request)
 
         raw_data_put_form = self.get_raw_data_form(view, 'PUT', request)
-        raw_data_post_form = self.get_raw_data_form(view, 'POST', request)
         raw_data_patch_form = self.get_raw_data_form(view, 'PATCH', request)
         raw_data_put_or_patch_form = raw_data_put_form or raw_data_patch_form
 
-        name = self.get_name(view)
-        description = self.get_description(view)
-        breadcrumb_list = self.get_breadcrumbs(request)
-
-        template = loader.get_template(self.template)
-        context = RequestContext(request, {
-            'content': content,
+        context = {
+            'content': self.get_content(renderer, data, accepted_media_type, renderer_context),
             'view': view,
             'request': request,
             'response': response,
-            'description': description,
-            'name': name,
+            'description': self.get_description(view),
+            'name': self.get_name(view),
             'version': VERSION,
-            'breadcrumblist': breadcrumb_list,
+            'breadcrumblist': self.get_breadcrumbs(request),
             'allowed_methods': view.allowed_methods,
             'available_formats': [renderer.format for renderer in view.renderer_classes],
 
-            'put_form': put_form,
-            'post_form': post_form,
-            'patch_form': patch_form,
-            'delete_form': delete_form,
-            'options_form': options_form,
+            'put_form': self.get_rendered_html_form(view, 'PUT', request),
+            'post_form': self.get_rendered_html_form(view, 'POST', request),
+            'patch_form': self.get_rendered_html_form(view, 'PATCH', request),
+            'delete_form': self.get_rendered_html_form(view, 'DELETE', request),
+            'options_form': self.get_rendered_html_form(view, 'OPTIONS', request),
 
             'raw_data_put_form': raw_data_put_form,
-            'raw_data_post_form': raw_data_post_form,
+            'raw_data_post_form': self.get_raw_data_form(view, 'POST', request),
             'raw_data_patch_form': raw_data_patch_form,
             'raw_data_put_or_patch_form': raw_data_put_or_patch_form,
 
+            'display_edit_forms': bool(response.status_code != 403),
+
             'api_settings': api_settings
-        })
+        }
+        return context
 
+    def render(self, data, accepted_media_type=None, renderer_context=None):
+        """
+        Render the HTML for the browsable API representation.
+        """
+        self.accepted_media_type = accepted_media_type or ''
+        self.renderer_context = renderer_context or {}
+
+        template = loader.get_template(self.template)
+        context = self.get_context(data, accepted_media_type, renderer_context)
+        context = RequestContext(renderer_context['request'], context)
         ret = template.render(context)
 
         # Munge DELETE Response code to allow us to return content
         # (Do this *after* we've rendered the template so that we include
         # the normal deletion response code in the output)
+        response = renderer_context['response']
         if response.status_code == status.HTTP_204_NO_CONTENT:
             response.status_code = status.HTTP_200_OK
 


Test patch: