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.
I think the PR may be sufficient, but we will see what the maintainers think. To replicate the scenario, I created two apps (all code is below). Each app is independent (although I mostly used the example code from the documentation's multipage app). The `auth.py` module creates the authentication logic where it checks that a user has been granted access to a specific app (or path). Panel's config is given the method, and in `panel/io/server.py` checks that the returned value from it allows the user to see the page or not. To run the given code, use the following command.

```bash
panel serve app1.py app2.py --basic-auth credentials.json --cookie-secret my_super_safe_cookie_secret
```

This will restrict access based on the user name _and_ the paths the user is allowed to visit.

#### user

[user-access.webm](https://github.com/holoviz/panel/assets/60523020/0dd510b1-1ddd-47ef-af87-01428c2aeae1)

#### admin

[admin-access.webm](https://github.com/holoviz/panel/assets/60523020/c903c93e-ded9-46ef-b45c-cbb23ae6a41e)

---

#### Code

```python
# app1.py
from pathlib import Path
import panel as pn
import param
from auth import check_user_authorization
pn.extension()
pn.config.authorize_callback = check_user_authorization

class App1(param.Parameterized):
    a = param.Integer(default=2, bounds=(0, 10))
    b = param.Integer(default=3, bounds=(0, 10))

    def view(self):
        return pn.pane.HTML(f"<p>a={self.a} b={self.b}</p>")

    def panel(self):
        return pn.Row(self.param, self.view).servable()

app1 = App1()
app1.panel()
```

```python
# app2.py
from pathlib import Path
import panel as pn
import param
from auth import check_user_authorization
pn.extension()
pn.config.authorize_callback = check_user_authorization

class App2(param.Parameterized):
    c = param.Integer(default=6, bounds=(0, None))
    exp = param.Number(default=0.1, bounds=(0, 3))

    def view(self):
        out = self.c**self.exp
        return pn.Column(out)

    def panel(self):
        return pn.Row(self.param, self.view).servable()

app2 = App2()
app2.panel()
```

```python
# auth.py
from typing import Any
from urllib import parse as urlparse

authorized_user_paths = {
    "admin": ["/app1", "/app2"],
    "user": ["/app1"],
}

def check_user_authorization(user_info: dict[str, Any], request_path: str) -> bool:
    current_user = user_info["user"]
    if current_user in list(authorized_user_paths.keys()):
        path = urlparse.urlparse(request_path).path
        if path in authorized_user_paths[current_user]:
            return True
    return False
```

`credentials.json`
```json
{
  "user": "user",
  "admin": "admin"
}
```

Resolving patch:
diff --git a/panel/command/serve.py b/panel/command/serve.py
--- a/panel/command/serve.py
+++ b/panel/command/serve.py
@@ -182,6 +182,12 @@ class Serve(_BkServe):
             action  = 'store_true',
             help    = "Whether to add an admin panel."
         )),
+        ('--admin-endpoint', dict(
+            action = 'store',
+            type    = str,
+            help    = "Name to use for the admin endpoint.",
+            default = None
+        )),
         ('--admin-log-level', dict(
             action  = 'store',
             default = None,
@@ -348,17 +354,24 @@ def customize_kwargs(self, args, server_kwargs):
         if args.admin:
             from ..io.admin import admin_panel
             from ..io.server import per_app_patterns
+
+            # If `--admin-endpoint` is not set, then we default to the `/admin` path.
+            admin_path = "/admin"
+            if args.admin_endpoint:
+                admin_path = args.admin_endpoint
+                admin_path = admin_path if admin_path.startswith('/') else f'/{admin_path}'
+
             config._admin = True
             app = Application(FunctionHandler(admin_panel))
             unused_timeout = args.check_unused_sessions or 15000
             state._admin_context = app_ctx = AdminApplicationContext(
-                app, unused_timeout=unused_timeout, url='/admin'
+                app, unused_timeout=unused_timeout, url=admin_path
             )
             if all(not isinstance(handler, DocumentLifecycleHandler) for handler in app._handlers):
                 app.add(DocumentLifecycleHandler())
             app_patterns = []
             for p in per_app_patterns:
-                route = '/admin' + p[0]
+                route = admin_path + p[0]
                 context = {"application_context": app_ctx}
                 app_patterns.append((route, p[1], context))
 
diff --git a/panel/config.py b/panel/config.py
--- a/panel/config.py
+++ b/panel/config.py
@@ -218,6 +218,8 @@ class _config(_base_config):
 
     _admin = param.Boolean(default=False, doc="Whether the admin panel was enabled.")
 
+    _admin_endpoint = param.String(default=None, doc="Name to use for the admin endpoint.")
+
     _admin_log_level = param.Selector(
         default='DEBUG', objects=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
         doc="Log level of the Admin Panel logger")
@@ -455,6 +457,10 @@ def _template_hook(self, value):
     def _doc_build(self):
         return os.environ.get('PANEL_DOC_BUILD')
 
+    @property
+    def admin_endpoint(self):
+        return os.environ.get('PANEL_ADMIN_ENDPOINT', self._admin_endpoint)
+
     @property
     def admin_log_level(self):
         admin_log_level = os.environ.get('PANEL_ADMIN_LOG_LEVEL', self._admin_log_level)
@@ -580,7 +586,6 @@ def theme(self):
 config = _config(**{k: None if p.allow_None else getattr(_config, k)
                     for k, p in _params.items() if k != 'name'})
 
-
 class panel_extension(_pyviz_extension):
     """
     Initializes and configures Panel. You should always run `pn.extension`.


Test patch:
diff --git a/panel/tests/command/test_serve.py b/panel/tests/command/test_serve.py
--- a/panel/tests/command/test_serve.py
+++ b/panel/tests/command/test_serve.py
@@ -29,6 +29,30 @@ def test_autoreload_app(py_file):
         assert r2.status_code == 200
         assert "<title>B</title>" in r2.content.decode('utf-8')
 
+@unix_only
+def test_serve_admin(py_file):
+    app = "import panel as pn; pn.Row('# Example').servable(title='A')"
+    write_file(app, py_file.file)
+
+    with run_panel_serve(["--port", "0", '--admin', py_file.name]) as p:
+        port = wait_for_port(p.stdout)
+        r = requests.get(f"http://localhost:{port}/admin")
+        assert r.status_code == 200
+        assert "Admin" in r.content.decode('utf-8')
+
+@unix_only
+def test_serve_admin_custom_endpoint(py_file):
+    app = "import panel as pn; pn.Row('# Example').servable(title='A')"
+    write_file(app, py_file.file)
+
+    with run_panel_serve(["--port", "0", '--admin', '--admin-endpoint', 'foo', py_file.name]) as p:
+        port = wait_for_port(p.stdout)
+        r = requests.get(f"http://localhost:{port}/foo")
+        assert r.status_code == 200
+        assert "Admin" in r.content.decode('utf-8')
+        r2 = requests.get(f"http://localhost:{port}/admin")
+        assert r2.status_code == 404
+
 @unix_only
 @pytest.mark.parametrize('relative', [True, False])
 def test_custom_html_index(relative, html_file):