panel
panel copied to clipboard
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 :-)
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_asynccheck ifrequest_handler.request.pathis 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 -
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 (Somewhere here) 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.
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
admin
Code
# 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()
# 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()
# 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
{
"user": "user",
"admin": "admin"
}
@MarcSkovMadsen PR #5447 will work to change the name of the admin page. It does not change the fact that if a user logs in to the panel app, they can still access the admin page. So with a login you can try the branch out using
panel serve app1.py app2.py --basic-auth credentials.json --cookie-secret my_super_safe_cookie_secret \
--admin --admin-endpoint="/random-id"
and you will be redirected to a 404 if you go to /admin, but get the admin page if you go to /random-id