Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable serving admin site at custom and secure url #3179

Closed
MarcSkovMadsen opened this issue Feb 8, 2022 · 8 comments · Fixed by #5386 or #5447
Closed

Enable serving admin site at custom and secure url #3179

MarcSkovMadsen opened this issue Feb 8, 2022 · 8 comments · Fixed by #5386 or #5447
Labels
type: enhancement Minor feature or improvement to an existing feature
Milestone

Comments

@MarcSkovMadsen
Copy link
Collaborator

MarcSkovMadsen commented Feb 8, 2022

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 :-)

@MarcSkovMadsen MarcSkovMadsen added TRIAGE Default label for untriaged issues type: feature A major new feature and removed TRIAGE Default label for untriaged issues labels Feb 8, 2022
@MarcSkovMadsen MarcSkovMadsen added this to the Wishlist milestone Feb 8, 2022
@philippjfr philippjfr added type: enhancement Minor feature or improvement to an existing feature and removed type: feature A major new feature labels Feb 8, 2022
@philippjfr philippjfr modified the milestones: Wishlist, v0.13.0 Feb 8, 2022
@MarcSkovMadsen MarcSkovMadsen changed the title Enable serving admin site at custom and secured url Enable serving admin site at custom and secure url Feb 8, 2022
@MarcSkovMadsen
Copy link
Collaborator Author

MarcSkovMadsen commented Feb 11, 2022

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.

@philippjfr philippjfr modified the milestones: v0.13.0, next Mar 28, 2022
@TheoMathurin
Copy link
Contributor

TheoMathurin commented Jul 25, 2022

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.

@Material-Scientist
Copy link

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.

@Lnk2past
Copy link
Contributor

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?

@Material-Scientist
Copy link

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.

@nishikantparmariam
Copy link

nishikantparmariam commented Jul 25, 2023

+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.

ndmlny-qs added a commit to ndmlny-qs/panel that referenced this issue Aug 10, 2023
This commit adds a single parameter used in the
`config.authorize_callback` that allows the user supplied method to
check if an app user is authorized to view the requested app at the
given path.

Resolves holoviz#3179
@ndmlny-qs
Copy link
Contributor

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

user-access.webm

admin

admin-access.webm

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"
}

@ndmlny-qs
Copy link
Contributor

@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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: enhancement Minor feature or improvement to an existing feature
Projects
None yet
Development

Successfully merging a pull request may close this issue.

7 participants