Repository: holoviz/panel

Issue:
Enable serving admin site at custom and secure url
The new admin page looks really, really nice.

But as far as I can see everybody can access it if enabled. I would like to be able to restrict access.

For example via a username and password. Or alternatively at a custom "token" url by specifying something like `--admin=879B996862F69D8561D1B369F7E44` and then the admin page is available at `/879B996862F69D8561D1B369F7E44`.

My use case would be for example awesome-panel.org where I finally could start getting some insights :-)


Discussion:
Another reason for enabling a custom admin url is a multipage app where each page is running independently in a separate pod. If I want to enable admin page for all pages/ pods this will be quite difficult to manage wrt. routing/ ingress on kubernetes.

It would be nice to use urls like `page1`, `page1-admin`, `page2`, `page2-admin` etc.
I also think the ability to restrict access to the admin page is very important.

A password-protected page would be welcome. A parameter to set a custom URL would also be nice, but as a complement rather than alternative in my opinion.
Managed to restrict access by using a custom tornado LoginHandler.

Put the allowed paths of every user in the (secure) cookies, and in the `get_user_async` check if `request_handler.request.path` is in the allowed paths for that user. If not, clear the cookies and return.

So, now a given user can access /app1, /app2, ...
But they can't access /admin, unless they are an admin.
> Managed to restrict access by using a custom tornado LoginHandler.
> 
> Put the allowed paths of every user in the (secure) cookies, and in the `get_user_async` check if `request_handler.request.path` is in the allowed paths for that user. If not, clear the cookies and return.
> 
> So, now a given user can access /app1, /app2, ... But they can't access /admin, unless they are an admin.

Would you mind sharing a bit more on what your `LoginHandler` looks like?
This is the important part:

```
async def get_user_async(request_handler):
    # Get cookies
    user = request_handler.get_secure_cookie('user')
    subs = request_handler.get_secure_cookie('subs')
    
    # Secure cookies have no default value. So, replace '' manually.
    if not subs:
        subs = ''
    else:
        subs = subs.decode('utf-8')
    
    subs = np.char.lower(
        np.array(subs.split(','),dtype=str) # Flatten subs + lower_case
    )
    # Get current path
    path = request_handler.request.path.lower()
    
    # Make sure user cannot visit forbidden/unauthorized site
    if path.split('/')[1] not in subs:
        print(f'Forbidden. Path: {path}, subs: {subs}, user: {user}\n')
        logger.info(f'Forbidden. Path: {path}, subs: {subs}, user: {user}')
        request_handler.clear_cookie('user')
        user = None
        
    # Return user to app
    return user
```

`subs` contains all allowed endpoints like `'admin'`. You can retrieve these allowed endpoints from your user database, and then store them in the cookies.

You'll also have to write your custom `class LoginHandler(RequestHandler):` where you check authorizations and set the cookie values.

Then, you should be able reach the admin endpoint, whereas ordinary users won't be able to.
+1 for allowing for custom URL. Another solution I found to handle authorization for any route (including /admin) without using cookies, let me know the thoughts - 

```python
from bokeh.server.urls import per_app_patterns
from panel.io.server import DocHandler as PanelDocHandler
from tornado.web import HTTPError, authenticated

def _check_authorization(curr_user, req_path):
    """
    Implement authorization logic for req_path page for
    curr_user and raise HTTPError(403) as needed
    """
    pass

class DocHandler(PanelDocHandler):
    @authenticated
    async def get(self, *args, **kwargs):
        curr_user = self.get_current_user().decode("utf-8")
        req_path = self.request.path.lower()
        _check_authorization(curr_user, req_path)
        return await super().get(self, *args, **kwargs)

per_app_patterns[0] = (r"/?", DocHandler)
```

**Proposal**  - I think we should extend support in [authorization callbacks](https://panel.holoviz.org/how_to/authentication/user_info.html#authorization-callbacks) ([Somewhere here](https://github.com/holoviz/panel/blob/ef84f80e521637a8ff546376e355f9ebc37e1724/panel/io/server.py#L488)) for allow passing separate callbacks for each route/page OR additional parameter `req_path` can be made available in authorize callback. We can then implement above kind of thing in Panel's DocHandller class. This way it will be generic & work not only for /admin but for any other page of the app.

Resolving patch:
diff --git a/panel/config.py b/panel/config.py
--- a/panel/config.py
+++ b/panel/config.py
@@ -110,7 +110,11 @@ class _config(_base_config):
         is enabled. The callback is given the user information returned
         by the configured Auth provider and should return True or False
         depending on whether the user is authorized to access the
-        application.""")
+        application. The callback may also contain a second parameter,
+        which is the requested path the user is making. If the user
+        is authenticated and has explicit access to the path, then
+        the callback should return True otherwise it should return
+        False.""")
 
     auth_template = param.Path(default=None, doc="""
         A jinja2 template rendered when the authorize_callback determines
diff --git a/panel/io/server.py b/panel/io/server.py
--- a/panel/io/server.py
+++ b/panel/io/server.py
@@ -8,6 +8,7 @@
 import gc
 import html
 import importlib
+import inspect
 import logging
 import os
 import pathlib
@@ -485,7 +486,34 @@ async def get(self, *args, **kwargs):
                 token = session.token
             logger.info(LOG_SESSION_CREATED, id(session.document))
             with set_curdoc(session.document):
-                if config.authorize_callback and not config.authorize_callback(state.user_info):
+                resources = Resources.from_bokeh(self.application.resources())
+                auth_cb = config.authorize_callback
+                if auth_cb:
+                    auth_cb = config.authorize_callback
+                    auth_params = inspect.signature(auth_cb).parameters
+                    auth_args = (state.user_info,)
+                    if len(auth_params) == 2:
+                        auth_args += (self.request.path,)
+                    else:
+                        raise RuntimeError(
+                            'Authorization callback must accept either one or two arguments.'
+                        )
+                    auth_error = f'{state.user} is not authorized to access this application.'
+                    try:
+                        authorized = auth_cb(*auth_args)
+                        auth_error = None
+                    except Exception:
+                        auth_error = f'Authorization callback errored. Could not validate user {state.user}'
+                else:
+                    authorized = True
+
+                if authorized:
+                    page = server_html_page_for_session(
+                        session, resources=resources, title=session.document.title,
+                        token=token, template=session.document.template,
+                        template_variables=session.document.template_variables,
+                    )
+                else:
                     if config.auth_template:
                         with open(config.auth_template) as f:
                             template = _env.from_string(f.read())
@@ -496,14 +524,7 @@ async def get(self, *args, **kwargs):
                         title='Panel: Authorization Error',
                         error_type='Authorization Error',
                         error='User is not authorized.',
-                        error_msg=f'{state.user} is not authorized to access this application.'
-                    )
-                else:
-                    resources = Resources.from_bokeh(self.application.resources())
-                    page = server_html_page_for_session(
-                        session, resources=resources, title=session.document.title,
-                        token=token, template=session.document.template,
-                        template_variables=session.document.template_variables,
+                        error_msg=auth_error
                     )
         self.set_header("Content-Type", 'text/html')
         self.write(page)


Test patch: