Skip to content

Commit

Permalink
Add Spawner options to enable admins to spawn user sessions (#598)
Browse files Browse the repository at this point in the history
* FEAT: add Spawner options for to Admin logins

* TST: update selenium tests with admin login and logout routines

* TST: fix to AdminDriverTest logout

* TST: include tests for Spawner Options form

* FIX: explictly include default methods on BaseSpawner

* FIX: typos in HTML string for options form

* FIX: further typos in HTML string for options form

* TST: fix CSS selector reference in options form test

* DOC: update user documentation on admin UI

* CLN: simplify BaseSpawner options_from_form and cmd logic

* FIX: typo, BaseSpawner.cmd property should be a list

* CLN: small clean up and flake8 fix

* TST: fixes to BaseSpawner.cmd unit tests

* DOC: add documentation for manually shutting down sessions when admin to re-create the options form

* DEV: override LogoutHandler to close admin session when user logs out, rather than manually navigating to JupyterHub home page

* DEV: expose SimphonyRemoteAuthMixin in remoteappmanager.jupyterhub.auth module

* DOC: update documentation on admin auto-sign outs

* ENH: robustify custom logout handler by explictly waiting for stop_single_user call to complete
  • Loading branch information
flongford committed Apr 20, 2022
1 parent 2f6e719 commit 5cc033c
Show file tree
Hide file tree
Showing 12 changed files with 173 additions and 22 deletions.
21 changes: 16 additions & 5 deletions doc/source/administration.rst
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
Administration
==============

As specified in the deployment section, the authenticator will grant administrative
rights to users in the specified set.
Once logged in, an administrative user will be served by a different application,
where it can add or remove users, applications, and authorize users to run specific
applications. It is also possible to stop currently running containers.
As specified in the configuration section, the authenticator will grant additional
administrative rights to users in the specified set.

Once logged in, an administrative user will have the option to spawn an "Admin" session,
providing them with a different application, where they can add or remove users,
applications, and authorize users to run specific applications. It is also possible to stop
currently running containers

**NOTE**: the existing "Admin" or "User" session must be shut down before the options form
will be shown again. This is a JupyterHub-level operation and is not performed by default
upon logging out. Typically it must be manually carried out by either navigating to
``https://<simphony-remote>/hub/admin`` or ``https://<simphony-remote>/hub/home`` whilst logged
in and selecting the appropriate the "Stop My Sever" option. For convenience we provide a custom
logout handler that automatically shuts down sessions upon an administrator sign out. This can be
used with any ``jupyterhub.auth.Authenticator`` subclass via inheriting the
``remoteappmanager.jupyterhub.auth.SimphonyRemoteAuthMixin`` mixin.

It is important to note that the administrative interface works only with
accounting backends supporting addition and removal. More specifically, it
Expand Down
4 changes: 2 additions & 2 deletions doc/source/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ Administration capabilities are decided by jupyterhub, not remoteappmanager.
c.Authenticator.admin_users = {"admin"}

Note that the entry must be a python set. Users in this set will, once logged
in, reach an administrative interface, instead of the docker application
management.
in, be able to launch an administrative interface in addition to the standard
docker application management.

.. _config_remoteappmanager:

Expand Down
1 change: 1 addition & 0 deletions remoteappmanager/jupyterhub/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .world_authenticator import WorldAuthenticator # noqa
from .basic_authenticator import BasicAuthenticator # noqa
from .github_whitelist_authenticator import GitHubWhitelistAuthenticator # noqa
from .simphony_remote_auth_mixin import SimphonyRemoteAuthMixin # noqa
4 changes: 3 additions & 1 deletion remoteappmanager/jupyterhub/auth/basic_authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
from jupyterhub.auth import Authenticator
from traitlets import Dict

from .simphony_remote_auth_mixin import SimphonyRemoteAuthMixin

class BasicAuthenticator(Authenticator):

class BasicAuthenticator(SimphonyRemoteAuthMixin, Authenticator):
""" Simple authenticator based on a fixed set of users"""

#: Dictionary of regular username: password keys allowed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from traitlets.config import LoggingConfigurable
from traitlets import Unicode, Float, Set

from .simphony_remote_auth_mixin import SimphonyRemoteAuthMixin


class FileWhitelistMixin(LoggingConfigurable):
"""
Expand Down Expand Up @@ -59,7 +61,8 @@ def whitelist(self, value):
pass


class GitHubWhitelistAuthenticator(FileWhitelistMixin, GitHubOAuthenticator):
class GitHubWhitelistAuthenticator(
SimphonyRemoteAuthMixin, FileWhitelistMixin, GitHubOAuthenticator):
"""A github authenticator that also verifies that the
user belongs to a specified whitelisted user as provided
by an external file (so that we don't have to restart
Expand Down
41 changes: 41 additions & 0 deletions remoteappmanager/jupyterhub/auth/simphony_remote_auth_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from tornado import gen

from jupyterhub.handlers import LogoutHandler as _LogoutHandler
from jupyterhub.handlers import LoginHandler


class LogoutHandler(_LogoutHandler):
""" Custom logout handler that also closes servers of admin
users, so that spawner options form will be shown during every login
"""
@gen.coroutine
def get(self):
user = self.get_current_user()
if user:
# Ensures admin sessions are shut down when user
# logs out so that the spawner options form is
# shown upon subsequent logins
# TODO: replace for configuring shutdown_on_logout option
# once running on jupyterhub>=1.0.0
if user.admin and user.spawner is not None:
self.log.info(f"Shutting down {user.name}'s server")
yield gen.maybe_future(self.stop_single_user(user))
self.log.info("User logged out: %s", user.name)
self.clear_login_cookie()
for name in user.other_user_cookies:
self.clear_login_cookie(name)
user.other_user_cookies = set([])
self.statsd.incr('logout')
self.redirect(self.settings['login_url'], permanent=False)


class SimphonyRemoteAuthMixin:

def get_handlers(self, app):
"""Includes standard LoginHandler and modified LogoutHandler that
closes admin sessions upon signing out
"""
return [
('/login', LoginHandler),
('/logout', LogoutHandler)
]
4 changes: 3 additions & 1 deletion remoteappmanager/jupyterhub/auth/world_authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@

from jupyterhub.auth import Authenticator

from .simphony_remote_auth_mixin import SimphonyRemoteAuthMixin

class WorldAuthenticator(Authenticator):

class WorldAuthenticator(SimphonyRemoteAuthMixin, Authenticator):
""" This authenticator authenticates everyone """

@gen.coroutine
Expand Down
40 changes: 35 additions & 5 deletions remoteappmanager/jupyterhub/spawners.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
import escapism
import string

from traitlets import Any, Unicode
from traitlets import Any, Unicode, default
from tornado import gen

from jupyterhub.spawner import LocalProcessSpawner
from jupyterhub import orm

ADMIN_CMD = "remoteappadmin"
USER_CMD = "remoteappmanager"


class BaseSpawner(LocalProcessSpawner):
"""Base class that provides common infrastructure to
Expand All @@ -25,10 +28,7 @@ class BaseSpawner(LocalProcessSpawner):
def cmd(self):
"""Overrides the base class traitlet so that we take full control
of the spawned command according to user admin status"""

return (["remoteappadmin"]
if self.user.admin is True
else ["remoteappmanager"])
return self.user_options.get('cmd', self._default_cmd())

def __init__(self, **kwargs):
super().__init__(**kwargs)
Expand All @@ -38,6 +38,36 @@ def __init__(self, **kwargs):
# contain only one.
self.proxy = self.db.query(orm.Proxy).first()

@default("options_form")
def _options_form_default(self):
""" Gives admins the option of spawning either RemoteAppManager
admin or user sessions
"""
if self.user.admin:
return """
<div>
<label for="session">Choose RemoteAppManager Session:</label>
<select id="session_form" name="session" size="2">
<option value="admin" selected>Admin</option>
<option value="user">User</option>
</select>
</div>
"""
return ""

def _default_cmd(self):
return [ADMIN_CMD] if self.user.admin else [USER_CMD]

def options_from_form(self, form_data):
""" Attempt to extract session selection from HTML form and
return default session if not available
"""
cmd = self._default_cmd()
if "session" in form_data:
selected = form_data.pop("session")[0]
cmd = [ADMIN_CMD] if selected == "admin" else [USER_CMD]
return {'cmd': cmd}

def get_args(self):
args = super().get_args()

Expand Down
25 changes: 21 additions & 4 deletions remoteappmanager/jupyterhub/tests/test_spawners.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
from jupyterhub import orm

from remoteappmanager.jupyterhub.spawners import (
SystemUserSpawner,
VirtualUserSpawner)
ADMIN_CMD, USER_CMD, SystemUserSpawner, VirtualUserSpawner)
from remoteappmanager.tests import fixtures
from remoteappmanager.tests.temp_mixin import TempMixin

Expand Down Expand Up @@ -94,7 +93,7 @@ def test_args_without_config_file_path(self):
self.assertIn("--base-urlpath=\"/\"", args)

def test_cmd(self):
self.assertEqual(self.spawner.cmd, ['remoteappmanager'])
self.assertEqual(self.spawner.cmd, [USER_CMD])

def test_default_config_file_path(self):
self.assertEqual(self.spawner.config_file_path, "")
Expand Down Expand Up @@ -161,7 +160,25 @@ def setUp(self):
self.spawner.user.admin = True

def test_cmd(self):
self.assertEqual(self.spawner.cmd, ['remoteappadmin'])
self.assertEqual(self.spawner.cmd, [ADMIN_CMD])

def test_cmd_user_session_override(self):
self.spawner.user_options = {"cmd": [USER_CMD]}
self.assertEqual(self.spawner.cmd, [USER_CMD])

def test_parse_options_from_form(self):
self.assertEqual(
self.spawner.options_from_form({}),
{"cmd": [ADMIN_CMD]}
)
self.assertEqual(
self.spawner.options_from_form({"session": ["user"]}),
{"cmd": [USER_CMD]}
)
self.assertEqual(
self.spawner.options_from_form({"session": ["admin"]}),
{"cmd": [ADMIN_CMD]}
)


class TestVirtualUserSpawner(TestSystemUserSpawner):
Expand Down
14 changes: 13 additions & 1 deletion selenium_tests/AdminDriverTest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,21 @@


class AdminDriverTest(RemoteAppDriverTest):

def admin_login(self):
""" Login as an admin user. Handles both entering admin credentials
and selecting appropriate Spawner options. We assume that if you
use this routine, you are currently on the login page.
"""
self.login("admin")

self.click_first_element_located(By.ID, "start")
self.click_first_element_located(By.CSS_SELECTOR, "input.btn")
self.click_first_element_located(By.ID, "start")

def setUp(self):
RemoteAppDriverTest.setUp(self)
self.login("admin")
self.admin_login()

def wait_until_visibility_of_row(self, row):
""" Wait until a specific row is visible
Expand Down
4 changes: 2 additions & 2 deletions selenium_tests/RemoteAppDriverTest.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,15 +247,15 @@ def click_modal_footer_button(self, name):
def login(self, username="test"):
""" Login as a given user name. We assume that if you use this routine,
you are currently on the login page.
Parameters
----------
username: String
The name of the user, it can be "admin" if you want to login as admin.
The name of the user, it is not expected to be an admin.
Example:
--------
login("JohnDoe")
login("admin")
"""
self.driver.get(self.base_url + "/hub/login")

Expand Down
32 changes: 32 additions & 0 deletions selenium_tests/test_spawner_options_form.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from selenium_tests.RemoteAppDriverTest import RemoteAppDriverTest
from selenium.webdriver.common.by import By


class TestSpawnerOptionsForm(RemoteAppDriverTest):

def test_admin_login_default_session(self):
self.login("admin")

self.click_first_element_located(By.ID, "start")
self.click_first_element_located(By.CSS_SELECTOR, "input.btn")
self.click_first_element_located(By.ID, "start")

self.wait_until_text_inside_element_located(
By.CSS_SELECTOR, ".header", "ADMIN")

def test_admin_login_user_session(self):
self.login("admin")

self.click_first_element_located(By.ID, "start")
self.click_first_element_located(
By.CSS_SELECTOR, "#session_form > option:nth-child(2)")
self.click_first_element_located(By.CSS_SELECTOR, "input.btn")
self.click_first_element_located(By.ID, "start")

self.wait_until_text_inside_element_located(
By.CSS_SELECTOR, ".header", "APPLICATIONS")

def tearDown(self):
self.logout()
RemoteAppDriverTest.tearDown(self)

0 comments on commit 5cc033c

Please sign in to comment.