diff --git a/doc/source/administration.rst b/doc/source/administration.rst index ecc8dc61..f038c2e9 100644 --- a/doc/source/administration.rst +++ b/doc/source/administration.rst @@ -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:///hub/admin`` or ``https:///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 diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index 59801c90..0e8ad8b6 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -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: diff --git a/remoteappmanager/jupyterhub/auth/__init__.py b/remoteappmanager/jupyterhub/auth/__init__.py index 83f8ac23..98219036 100644 --- a/remoteappmanager/jupyterhub/auth/__init__.py +++ b/remoteappmanager/jupyterhub/auth/__init__.py @@ -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 diff --git a/remoteappmanager/jupyterhub/auth/basic_authenticator.py b/remoteappmanager/jupyterhub/auth/basic_authenticator.py index fa135d25..df449b8c 100644 --- a/remoteappmanager/jupyterhub/auth/basic_authenticator.py +++ b/remoteappmanager/jupyterhub/auth/basic_authenticator.py @@ -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 diff --git a/remoteappmanager/jupyterhub/auth/github_whitelist_authenticator.py b/remoteappmanager/jupyterhub/auth/github_whitelist_authenticator.py index 334bcd38..157bffc9 100644 --- a/remoteappmanager/jupyterhub/auth/github_whitelist_authenticator.py +++ b/remoteappmanager/jupyterhub/auth/github_whitelist_authenticator.py @@ -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): """ @@ -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 diff --git a/remoteappmanager/jupyterhub/auth/simphony_remote_auth_mixin.py b/remoteappmanager/jupyterhub/auth/simphony_remote_auth_mixin.py new file mode 100644 index 00000000..1abb32eb --- /dev/null +++ b/remoteappmanager/jupyterhub/auth/simphony_remote_auth_mixin.py @@ -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) + ] diff --git a/remoteappmanager/jupyterhub/auth/world_authenticator.py b/remoteappmanager/jupyterhub/auth/world_authenticator.py index 127f5b8d..0d30c54d 100644 --- a/remoteappmanager/jupyterhub/auth/world_authenticator.py +++ b/remoteappmanager/jupyterhub/auth/world_authenticator.py @@ -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 diff --git a/remoteappmanager/jupyterhub/spawners.py b/remoteappmanager/jupyterhub/spawners.py index 682b5b83..6bd2a533 100644 --- a/remoteappmanager/jupyterhub/spawners.py +++ b/remoteappmanager/jupyterhub/spawners.py @@ -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 @@ -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) @@ -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 """ +
+ + +
+ """ + 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() diff --git a/remoteappmanager/jupyterhub/tests/test_spawners.py b/remoteappmanager/jupyterhub/tests/test_spawners.py index bc185128..735f6dcd 100644 --- a/remoteappmanager/jupyterhub/tests/test_spawners.py +++ b/remoteappmanager/jupyterhub/tests/test_spawners.py @@ -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 @@ -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, "") @@ -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): diff --git a/selenium_tests/AdminDriverTest.py b/selenium_tests/AdminDriverTest.py index 96456473..4efc372f 100644 --- a/selenium_tests/AdminDriverTest.py +++ b/selenium_tests/AdminDriverTest.py @@ -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 diff --git a/selenium_tests/RemoteAppDriverTest.py b/selenium_tests/RemoteAppDriverTest.py index 7a06cc66..34e5073c 100644 --- a/selenium_tests/RemoteAppDriverTest.py +++ b/selenium_tests/RemoteAppDriverTest.py @@ -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") diff --git a/selenium_tests/test_spawner_options_form.py b/selenium_tests/test_spawner_options_form.py new file mode 100644 index 00000000..f2a3d472 --- /dev/null +++ b/selenium_tests/test_spawner_options_form.py @@ -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)