diff --git a/docs/docs/create-apps/general-app.md b/docs/docs/create-apps/general-app.md index 909a0084..bfca0d6e 100644 --- a/docs/docs/create-apps/general-app.md +++ b/docs/docs/create-apps/general-app.md @@ -38,6 +38,7 @@ Check out their specific documentation pages for details. | Spawner profile | Instance type (i.e. machines with CPU/RAM/GPU resources) required for running your application. Only applicable if you're using `KubeSpawner` as the spawner for JupyterHub. | | Custom Command (required if the Framework is "Custom Command") | Python command to start an arbitrary app. | | Allow Public Access | Toggle to share the application with anyone on the internet, including unauthenticated users. | +| Sharing | To share the application with a set of users and or groups. | Example: diff --git a/jhub_apps/configuration.py b/jhub_apps/configuration.py index d7522842..f5f882cb 100644 --- a/jhub_apps/configuration.py +++ b/jhub_apps/configuration.py @@ -4,6 +4,7 @@ from traitlets.config import LazyConfigValue from jhub_apps import JAppsConfig +from jhub_apps.hub_client.utils import is_jupyterhub_5 from jhub_apps.spawner.spawner_creation import subclass_spawner @@ -78,6 +79,7 @@ def install_jhub_apps(c, spawner_to_subclass): "scopes": [ # declare what permissions the service should have "list:users", # list users + "list:groups", # list groups "read:users:activity", # read user last-activity "read:users", # read user last-activity "admin:servers", # start/stop servers @@ -87,7 +89,9 @@ def install_jhub_apps(c, spawner_to_subclass): "access:services", "list:services", "read:services", # read service models - ], + ] + ([ + "shares" + ] if is_jupyterhub_5() else []), }, { "name": "user", @@ -95,7 +99,7 @@ def install_jhub_apps(c, spawner_to_subclass): "scopes": [ "self", "access:services", - "admin:auth_state" + "admin:auth_state", ], }, ] diff --git a/jhub_apps/hub_client/hub_client.py b/jhub_apps/hub_client/hub_client.py index ef109996..f0b3bd79 100644 --- a/jhub_apps/hub_client/hub_client.py +++ b/jhub_apps/hub_client/hub_client.py @@ -1,3 +1,6 @@ +import typing +from concurrent.futures import ThreadPoolExecutor + import structlog import os import re @@ -5,6 +8,8 @@ import requests +from jhub_apps.service.models import UserOptions, SharePermissions +from jhub_apps.hub_client.utils import is_jupyterhub_5 API_URL = os.environ.get("JUPYTERHUB_API_URL") JUPYTERHUB_API_TOKEN = os.environ.get("JUPYTERHUB_API_TOKEN") @@ -82,14 +87,14 @@ def start_server(self, username, servername): r.raise_for_status() return r.status_code, servername - def create_server(self, username, servername, user_options=None): + def create_server(self, username: str, servername: str, user_options: UserOptions = None): logger.info("Creating new server", user=username) - servername = self.normalize_server_name(servername) - servername = f"{servername}-{uuid.uuid4().hex[:7]}" + normalized_servername = self.normalize_server_name(servername) + unique_servername = f"{normalized_servername}-{uuid.uuid4().hex[:7]}" logger.info("Normalized servername", servername=servername) - return self._create_server(username, servername, user_options) + return self._create_server(username, unique_servername, user_options) - def edit_server(self, username, servername, user_options=None): + def edit_server(self, username: str, servername: str, user_options: UserOptions = None): logger.info("Editing server", server_name=servername) server = self.get_server(username, servername) if server: @@ -101,15 +106,101 @@ def edit_server(self, username, servername, user_options=None): logger.info("Now creating the server with new params", server_name=servername) return self._create_server(username, servername, user_options) - def _create_server(self, username, servername, user_options): + def _create_server(self, username: str, servername: str, user_options: UserOptions = None): url = f"/users/{username}/servers/{servername}" params = user_options.model_dump() data = {"name": servername, **params} logger.info("Creating new server", server_name=servername) r = requests.post(API_URL + url, headers=self._headers(), json=data) r.raise_for_status() + if is_jupyterhub_5(): + logger.info("Sharing", share_with=user_options.share_with) + self._share_server_with_multiple_entities( + username, + servername, + share_with=user_options.share_with + ) + else: + logger.info("Not sharing server as JupyterHub < 5.x") return r.status_code, servername + def _share_server( + self, + username: str, + servername: str, + share_to_user: typing.Optional[str], + share_to_group: typing.Optional[str], + ): + url = f"/shares/{username}/{servername}" + if share_to_user: + data = {"user": share_to_user} + elif share_to_group: + data = {"group": share_to_group} + else: + raise ValueError("None of share_to_user or share_to_group provided") + share_with = share_to_group or share_to_user + logger.info(f"Sharing {username}/{servername} with {share_with}") + return requests.post(API_URL + url, headers=self._headers(), json=data) + + def _share_server_with_multiple_entities( + self, + username: str, + servername: str, + share_with: typing.Optional[SharePermissions] = None + ): + """ + :param username: owner of the servername + :param servername: servername to share + :param share_to_users: list of users to share the server with + :param share_to_groups: list of groups to share the server with + :return: mapping of dict of users + group to corresponding response json from Hub API + """ + if not share_with: + logger.info("Neither of share_to_user or share_to_group provided, NOT sharing") + return + logger.info( + f"Requested to share {username}/{servername}", + share_to_users=share_with.users, share_to_groups=share_with.groups + ) + users = share_with.users or [] + groups = share_with.groups or [] + share_to_user_args = [(username, servername, user, None,) for user in users] + share_to_group_args = [(username, servername, None, group,) for group in groups] + executor_arguments = share_to_user_args + share_to_group_args + # Remove any previously shared access, this is useful when editing apps + self._revoke_shared_access(username, servername) + # NOTE: JupyterHub 5.x doesn't provide a way for bulk sharing, as in share with a + # set of groups and users. Since we don't have a task queue in jhub-apps at the moment, + # we're using multithreading to call JupyterHub API to share the app with multiple users/groups + # to remove any visible lag in the API request to create server. + with ThreadPoolExecutor(max_workers=10) as ex: + logger.info(f"Share executor arguments: {executor_arguments}") + response_results = list(ex.map(lambda p: self._share_server(*p), executor_arguments)) + + user_and_groups = users + groups + response_results_json = [resp.json() for resp in response_results] + user_group_and_response_map = dict(zip(user_and_groups, response_results_json)) + logger.info("Sharing response", response=user_group_and_response_map) + return user_group_and_response_map + + def _revoke_shared_access(self, username: str, servername: str): + """Revoke all shared access to a given server""" + logger.info("Revoking shared servers access", user=username, servername=servername) + url = f"/shares/{username}/{servername}" + return requests.delete(API_URL + url, headers=self._headers()) + + def get_shared_servers(self, username: str): + """List servers shared with user""" + if not is_jupyterhub_5(): + logger.info("Unable to get shared servers as this feature is not available in JupyterHub < 5.x") + return [] + logger.info("Getting shared servers", user=username) + url = f"/users/{username}/shared" + response = requests.get(API_URL + url, headers=self._headers()) + rjson = response.json() + shared_servers = rjson["items"] + return shared_servers + def delete_server(self, username, server_name, remove=False): if server_name is None: # Default server and not named server @@ -125,3 +216,24 @@ def get_services(self): r = requests.get(API_URL + "/services", headers=self._headers()) r.raise_for_status() return r.json() + + def get_groups(self): + """Returns all the groups in JupyterHub""" + r = requests.get(API_URL + "/groups", headers=self._headers()) + r.raise_for_status() + return r.json() + + +def get_users_and_group_allowed_to_share_with(user): + """Returns a list of users and groups""" + hclient = HubClient() + users = hclient.get_users() + user_names = [u["name"] for u in users if u["name"] != user.name] + groups = hclient.get_groups() + group_names = [group['name'] for group in groups] + # TODO: Filter users and groups based on what the user has access to share with + # parsed_scopes = parse_scopes(scopes) + return { + "users": user_names, + "groups": group_names + } diff --git a/jhub_apps/hub_client/utils.py b/jhub_apps/hub_client/utils.py new file mode 100644 index 00000000..2b6c486f --- /dev/null +++ b/jhub_apps/hub_client/utils.py @@ -0,0 +1,5 @@ +import jupyterhub + + +def is_jupyterhub_5(): + return jupyterhub.version_info[0] == 5 diff --git a/jhub_apps/service/__init__.py b/jhub_apps/service/__init__.py index 34f275ed..e69de29b 100644 --- a/jhub_apps/service/__init__.py +++ b/jhub_apps/service/__init__.py @@ -1,3 +0,0 @@ -from .app import app - -__all__ = ["app"] diff --git a/jhub_apps/service/models.py b/jhub_apps/service/models.py index cd4efdf5..796b8902 100644 --- a/jhub_apps/service/models.py +++ b/jhub_apps/service/models.py @@ -19,6 +19,11 @@ class Server(BaseModel): user_options: Optional[Any] +class SharePermissions(BaseModel): + users: List[str] + groups: List[str] + + class User(BaseModel): name: str admin: bool @@ -30,6 +35,7 @@ class User(BaseModel): servers: Optional[Dict[str, Server]] = None scopes: List[str] auth_state: Optional[Dict] = None + share_permissions: typing.Optional[SharePermissions] = None # https://stackoverflow.com/questions/64501193/fastapi-how-to-use-httpexception-in-responses @@ -66,6 +72,7 @@ class UserOptions(BaseModel): # Keep app alive, even when there is no activity # So that it's not killed by idle culler keep_alive: typing.Optional[bool] = False + share_with: typing.Optional[SharePermissions] = None class ServerCreation(BaseModel): diff --git a/jhub_apps/service/routes.py b/jhub_apps/service/routes.py index e943b480..73123eb6 100644 --- a/jhub_apps/service/routes.py +++ b/jhub_apps/service/routes.py @@ -104,7 +104,17 @@ def get_all_shared_servers(hub_users, current_hub_user): ] all_servers.extend(hub_user_servers_with_name) # Filter default servers - return list(filter(lambda server: server["name"] != "", all_servers)) + all_servers_without_default_jlab = list(filter(lambda server: server["name"] != "", all_servers)) + # Filter servers shared with the user + hub_client = HubClient() + shared_servers = hub_client.get_shared_servers(username=current_hub_user["name"]) + shared_server_names = {shared_server["server"]["name"] for shared_server in shared_servers} + shared_servers_rich = [ + server for server in all_servers_without_default_jlab + if server["name"] in shared_server_names + ] + return shared_servers_rich + @router.get("/server/", description="Get all servers") diff --git a/jhub_apps/service/security.py b/jhub_apps/service/security.py index dd70446e..fe37ce81 100644 --- a/jhub_apps/service/security.py +++ b/jhub_apps/service/security.py @@ -5,6 +5,7 @@ from fastapi.security import OAuth2AuthorizationCodeBearer, APIKeyCookie from fastapi.security.api_key import APIKeyQuery +from jhub_apps.hub_client.hub_client import get_users_and_group_allowed_to_share_with, is_jupyterhub_5 from .auth import get_jhub_token_from_jwt_token from .client import get_client from .models import User @@ -69,6 +70,8 @@ async def get_current_user( }, ) user = User(**resp.json()) + if is_jupyterhub_5(): + user.share_permissions = get_users_and_group_allowed_to_share_with(user) if any(scope in user.scopes for scope in access_scopes): return user else: diff --git a/jhub_apps/service/utils.py b/jhub_apps/service/utils.py index e9076686..bff1dba5 100644 --- a/jhub_apps/service/utils.py +++ b/jhub_apps/service/utils.py @@ -1,4 +1,5 @@ import base64 + import structlog import os diff --git a/jhub_apps/spawner/types.py b/jhub_apps/spawner/types.py index 169dab9d..eed5b8fd 100644 --- a/jhub_apps/spawner/types.py +++ b/jhub_apps/spawner/types.py @@ -1,4 +1,3 @@ -import typing from dataclasses import dataclass from enum import Enum from pathlib import Path @@ -25,19 +24,6 @@ def json(self): } -@dataclass -class UserOptions: - jhub_app: bool - display_name: str - description: str - thumbnail: str - filepath: str - framework: str - custom_command: typing.Optional[str] = None - conda_env: typing.Optional[dict] = None - profile: typing.Optional[str] = None - - class Framework(Enum): panel = "panel" bokeh = "bokeh" diff --git a/jhub_apps/tests/conftest.py b/jhub_apps/tests/conftest.py index 6b0a4b63..79a78de8 100644 --- a/jhub_apps/tests/conftest.py +++ b/jhub_apps/tests/conftest.py @@ -21,7 +21,7 @@ def client(): os.environ["PUBLIC_HOST"] = "/" os.environ["JUPYTERHUB_CLIENT_ID"] = "test-client-id" os.environ["JUPYTERHUB_OAUTH_CALLBACK_URL"] = "/" - from jhub_apps.service import app + from jhub_apps.service.app import app from jhub_apps.service.security import get_current_user async def mock_get_user_name(): diff --git a/jhub_apps/tests/test_api.py b/jhub_apps/tests/test_api.py index b34c1742..4eff81d0 100644 --- a/jhub_apps/tests/test_api.py +++ b/jhub_apps/tests/test_api.py @@ -24,7 +24,12 @@ def mock_user_options(): "ENV_VAR_KEY_2": "ENV_VAR_KEY_2", }, "profile": "", - "public": False + "public": False, + "keep_alive": False, + "share_with": { + "users": ["alice", "john"], + "groups": ["alpha", "beta"] + } } return user_options diff --git a/jhub_apps/tests/test_hub_client.py b/jhub_apps/tests/test_hub_client.py new file mode 100644 index 00000000..cb63e138 --- /dev/null +++ b/jhub_apps/tests/test_hub_client.py @@ -0,0 +1,15 @@ +from jhub_apps.hub_client.hub_client import HubClient + + +def test_normalize_server_name(): + hub_client = HubClient() + # test escaping + assert hub_client.normalize_server_name("../../another-endpoint") == "another-endpoint" + # Test long server name + assert hub_client.normalize_server_name("x"*1000) == "x"*240 + # Test all special characters + assert hub_client.normalize_server_name("server!@£$%^&*<>:~`±") == "server" + # Replace space with dash + assert hub_client.normalize_server_name("some server name") == "some-server-name" + # lowercase + assert hub_client.normalize_server_name("SOMESERVERNAME") == "someservername" diff --git a/jhub_apps/tests_e2e/test_integration.py b/jhub_apps/tests_e2e/test_integration.py index 241c7f79..b9991f0d 100644 --- a/jhub_apps/tests_e2e/test_integration.py +++ b/jhub_apps/tests_e2e/test_integration.py @@ -5,6 +5,7 @@ import structlog from playwright.sync_api import Playwright, expect +from jhub_apps.hub_client.utils import is_jupyterhub_5 from jhub_apps.spawner.types import Framework BASE_URL = "http://127.0.0.1:8000" @@ -42,16 +43,26 @@ def test_panel_app_creation(playwright: Playwright, with_server_options) -> None app_suffix = uuid.uuid4().hex[:6] # for searching app with unique name in the UI app_name = f"{framework} app {app_suffix}" - app_page_title = "Panel Test App" try: page.goto(BASE_URL) - sign_in_and_authorize(app_suffix, page) - create_app(app_name, page, with_server_options) - wait_for_element_in_app = "div.bk-slider-title >> text=Slider:" - slider_text_element = page.wait_for_selector(wait_for_element_in_app) - assert slider_text_element is not None, "Slider text element not found!" - logger.info("Checking page title") - expect(page).to_have_title(re.compile(app_page_title)) + share_with_user = f"admin-{uuid.uuid4().hex[:6]}" + create_users(page, users=[share_with_user]) + page.goto(BASE_URL) + sign_in_and_authorize(page, username=f"admin-{app_suffix}", password="password") + create_app( + app_name, page, with_server_options, share_with_users=[share_with_user] + ) + assert_working_panel_app(page) + app_url = page.url + logger.info(f"Access panel app from shared user: {share_with_user}: {app_url}") + page.goto(BASE_URL) + sign_out(page) + sign_in_and_authorize(page, username=share_with_user, password="password") + page.goto(app_url) + if is_jupyterhub_5(): + logger.info("Click Authorize button for accessing the shared app") + page.get_by_role("button", name="Authorize").click() + assert_working_panel_app(page) except Exception as e: # So that we save the video, before we exit context.close() @@ -59,7 +70,22 @@ def test_panel_app_creation(playwright: Playwright, with_server_options) -> None raise e -def create_app(app_name, page, with_server_options=True): +def assert_working_panel_app(page): + wait_for_element_in_app = "div.bk-slider-title >> text=Slider:" + slider_text_element = page.wait_for_selector(wait_for_element_in_app) + assert slider_text_element is not None, "Slider text element not found!" + logger.info("Checking page title") + app_page_title = "Panel Test App" + expect(page).to_have_title(re.compile(app_page_title)) + + +def create_app( + app_name, + page, + with_server_options=True, + share_with_users=None, + share_with_groups=None, +): logger.info("Creating App") page.get_by_role("button", name="Create App").click() logger.info("Fill App display Name") @@ -68,8 +94,10 @@ def create_app(app_name, page, with_server_options=True): logger.info("Select Framework") page.locator("id=framework").click() page.get_by_role("option", name="Panel").click() + if is_jupyterhub_5(): + select_share_options(page, users=share_with_users, groups=share_with_groups) if with_server_options: - next_page_locator = page.get_by_role("button", name="Next") + next_page_locator = page.locator("id=submit-btn") logger.info("Select Next Page for Server options") expect(next_page_locator).to_be_visible() next_page_locator.click() @@ -78,7 +106,6 @@ def create_app(app_name, page, with_server_options=True): logger.info("Expect Small Instance to be visible") expect(small_instance_radio_button).to_be_visible() small_instance_radio_button.check() - create_app_locator = page.get_by_role("button", name="Create App") logger.info("Expect Create App button to be visible") expect(create_app_locator).to_be_visible() @@ -86,12 +113,52 @@ def create_app(app_name, page, with_server_options=True): create_app_locator.click() -def sign_in_and_authorize(app_suffix, page): +def select_share_options(page, users=None, groups=None): + logger.info("Selecting share form") + share_locator = page.locator("id=share-permissions-autocomplete") + expect(share_locator).to_be_visible() + + users = users or [] + groups = groups or [] + for user in users: + logger.info(f"Fill user: {user} in share") + share_locator.fill(user) + logger.info(f"Select user: {user} in share") + page.get_by_role("option", name=user).click() + + for group in groups: + logger.info(f"Fill group: {group} in share") + share_locator.fill(group) + logger.info(f"Select group: {group} in share") + page.get_by_role("option", name=f"{group} (Group)").click() + page.get_by_role("button", name="Share").click() + + +def create_users(page, users): + """Create users by logging in""" + for user in users: + sign_in_and_authorize(page, user, password="password") + sign_out(page) + + +def sign_in_and_authorize(page, username, password): logger.info("Signing in") page.get_by_label("Username:").click() - page.get_by_label("Username:").fill(f"admin-{app_suffix}") - page.get_by_label("Password:").fill("admin") + page.get_by_label("Username:").fill(username) + page.get_by_label("Password:").fill(password) logger.info("Pressing Sign in button") page.get_by_role("button", name="Sign in").click() logger.info("Click Authorize button") page.get_by_role("button", name="Authorize").click() + + +def sign_out(page): + logger.info("Click on profile menu") + profile_top_corner_locator = page.locator("id=profile-menu-btn") + expect(profile_top_corner_locator).to_be_visible() + profile_top_corner_locator.click() + logger.info("Click on Logout button") + logout_button = page.get_by_role("menuitem").filter(has_text="Logout") + expect(logout_button).to_be_visible() + logout_button.click() + logger.info("Logged out") diff --git a/jupyterhub_config.py b/jupyterhub_config.py index b22c6a6d..a6353c46 100644 --- a/jupyterhub_config.py +++ b/jupyterhub_config.py @@ -15,7 +15,7 @@ c.JupyterHub.bind_url = hub_url c.JAppsConfig.jupyterhub_config_path = "jupyterhub_config.py" c.JAppsConfig.conda_envs = [] -c.JAppsConfig.service_workers = 4 +c.JAppsConfig.service_workers = 1 c.JupyterHub.default_url = "/hub/home" c = install_jhub_apps(c, spawner_to_subclass=SimpleLocalProcessSpawner) @@ -52,3 +52,8 @@ def service_for_jhub_apps(name, url): "display_version": True, **themes.DEFAULT_THEME } + +c.JupyterHub.load_groups = { + 'class-A': {"users": ['john', 'alice']}, + 'class-B': {"users": ['john', 'alice']} +}