From 3ab3cf92bdadc10b08727240530089b7d2b85455 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 27 Apr 2024 15:07:56 +0100 Subject: [PATCH 01/11] Upgrade to JupyterHub 5.0 --- .../services/jupyterhub/files/jupyterhub/02-spawner.py | 1 + .../template/modules/kubernetes/services/jupyterhub/main.tf | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/02-spawner.py b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/02-spawner.py index c3934aad0..fe8fa69d0 100644 --- a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/02-spawner.py +++ b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/02-spawner.py @@ -73,6 +73,7 @@ def service_for_jhub_apps(name, url): "external": True, }, "oauth_no_confirm": True, + "oauth_redirect_uri": "/services/:name/oauth_callback", } c.JupyterHub.services.extend( diff --git a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf index cf86d5a03..3ac041aac 100644 --- a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf +++ b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf @@ -57,7 +57,7 @@ resource "helm_release" "jupyterhub" { repository = "https://jupyterhub.github.io/helm-chart/" chart = "jupyterhub" - version = "3.2.1" + version = "4.0.0-0.dev.git.6586.h0a16e5a0" values = concat([ file("${path.module}/values.yaml"), From 62e527d27562a9e7821ff82945f073e39654bb65 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 4 May 2024 15:10:57 +0100 Subject: [PATCH 02/11] Fetch JupyterHub roles from Keycloak --- .../jupyterhub/files/jupyterhub/04-auth.py | 128 ++++++++++++++++++ .../kubernetes/services/jupyterhub/main.tf | 42 +++--- .../services/keycloak-client/main.tf | 30 +++- .../services/keycloak-client/outputs.tf | 6 +- .../services/keycloak-client/variables.tf | 13 ++ 5 files changed, 198 insertions(+), 21 deletions(-) create mode 100644 src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py diff --git a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py new file mode 100644 index 000000000..87c3c1a9c --- /dev/null +++ b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py @@ -0,0 +1,128 @@ +import json +import os +import urllib +from functools import reduce + +from jupyterhub.traitlets import Callable +from oauthenticator.generic import GenericOAuthenticator +from traitlets import Unicode, Union + + +class KeyCloakOAuthenticator(GenericOAuthenticator): + """ + Since `oauthenticator` 16.3 `GenericOAuthenticator` supports group management. + This subclass adds role management on top of it, building on the new `manage_roles` + feature added in JupyterHub 5.0 (https://github.com/jupyterhub/jupyterhub/pull/4748). + """ + + claim_roles_key = Union( + [Unicode(os.environ.get("OAUTH2_ROLES_KEY", "groups")), Callable()], + config=True, + help="""As `claim_groups_key` but for roles.""", + ) + + realm_api_url = Unicode( + config=True, help="""The keycloak REST API URL for the realm.""" + ) + + service_account_user_id = Unicode(config=True, help="""service_account_user_id.""") + + async def update_auth_model(self, auth_model): + auth_model = await super().update_auth_model(auth_model) + user_info = auth_model["auth_state"][self.user_auth_state_key] + user_roles = self._get_user_roles(user_info) + auth_model["roles"] = user_roles + return auth_model + + async def load_managed_roles(self): + if not self.manage_roles: + raise ValueError( + "Managed roles can only be loaded when `manage_roles` is True" + ) + token = await self._get_token() + + # Get the clients list to find the "id" of "jupyterhub" client. + clients_data = await self._fetch_api(endpoint="clients/", token=token) + jupyterhub_clients = [ + client for client in clients_data if client["clientId"] == "jupyterhub" + ] + assert len(jupyterhub_clients) == 1 + jupyterhub_client_id = jupyterhub_clients[0]["id"] + + # Includes roles like "jupyterhub_admin", "jupyterhub_developer", "dask_gateway_developer" + client_roles = await self._fetch_api( + endpoint=f"clients/{jupyterhub_client_id}/roles", token=token + ) + # Includes roles like "default-roles-nebari", "offline_access", "uma_authorization" + realm_roles = await self._fetch_api(endpoint="roles", token=token) + roles = { + role["name"]: {"name": role["name"], "description": role["description"]} + for role in [*realm_roles, *client_roles] + } + # we could use either `name` (e.g. "developer") or `path` ("/developer"); + # since the default claim key returns `path`, it seems preferable. + group_name_key = "path" + for realm_role in realm_roles: + role_name = realm_role["name"] + role = roles[role_name] + # fetch role assignments to groups + groups = await self._fetch_api(f"roles/{role_name}/groups", token=token) + role["groups"] = [group[group_name_key] for group in groups] + # fetch role assignments to users + users = await self._fetch_api(f"roles/{role_name}/users", token=token) + role["users"] = [user["username"] for user in users] + for client_role in client_roles: + role_name = client_role["name"] + role = roles[role_name] + # fetch role assignments to groups + groups = await self._fetch_api( + f"clients/{jupyterhub_client_id}/roles/{role_name}/groups", token=token + ) + role["groups"] = [group[group_name_key] for group in groups] + # fetch role assignments to users + users = await self._fetch_api( + f"clients/{jupyterhub_client_id}/roles/{role_name}/users", token=token + ) + role["users"] = [user["username"] for user in users] + + return list(roles.values()) + + def _get_user_roles(self, user_info): + if callable(self.claim_roles_key): + return set(self.claim_roles_key(user_info)) + try: + return set(reduce(dict.get, self.claim_roles_key.split("."), user_info)) + except TypeError: + self.log.error( + f"The claim_roles_key {self.claim_roles_key} does not exist in the user token" + ) + return set() + + async def _get_token(self) -> str: + http = self.http_client + + body = urllib.parse.urlencode( + { + "client_id": self.client_id, + "client_secret": self.client_secret, + "grant_type": "client_credentials", + } + ) + response = await http.fetch( + self.token_url, + method="POST", + body=body, + ) + data = json.loads(response.body) + return data["access_token"] # type: ignore[no-any-return] + + async def _fetch_api(self, endpoint: str, token: str): + response = await self.http_client.fetch( + f"{self.realm_api_url}/{endpoint}", + method="GET", + headers={"Authorization": f"Bearer {token}"}, + ) + return json.loads(response.body) + + +c.JupyterHub.authenticator_class = KeyCloakOAuthenticator diff --git a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf index 3ac041aac..29327a388 100644 --- a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf +++ b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf @@ -130,6 +130,7 @@ resource "helm_release" "jupyterhub" { "01-theme.py" = file("${path.module}/files/jupyterhub/01-theme.py") "02-spawner.py" = file("${path.module}/files/jupyterhub/02-spawner.py") "03-profiles.py" = file("${path.module}/files/jupyterhub/03-profiles.py") + "04-auth.py" = file("${path.module}/files/jupyterhub/04-auth.py") } services = { @@ -143,27 +144,28 @@ resource "helm_release" "jupyterhub" { # for simple key value configuration with jupyterhub traitlets # this hub.config property should be used config = { - JupyterHub = { - authenticator_class = "generic-oauth" - } Authenticator = { enable_auth_state = true } - GenericOAuthenticator = { - client_id = module.jupyterhub-openid-client.config.client_id - client_secret = module.jupyterhub-openid-client.config.client_secret - oauth_callback_url = "https://${var.external-url}/hub/oauth_callback" - authorize_url = module.jupyterhub-openid-client.config.authentication_url - token_url = module.jupyterhub-openid-client.config.token_url - userdata_url = module.jupyterhub-openid-client.config.userinfo_url - login_service = "Keycloak" - username_claim = "preferred_username" - claim_groups_key = "groups" - allowed_groups = ["/analyst", "/developer", "/admin"] - admin_groups = ["/admin"] - manage_groups = true - refresh_pre_spawn = true - validate_server_cert = false + KeyCloakOAuthenticator = { + client_id = module.jupyterhub-openid-client.config.client_id + client_secret = module.jupyterhub-openid-client.config.client_secret + oauth_callback_url = "https://${var.external-url}/hub/oauth_callback" + authorize_url = module.jupyterhub-openid-client.config.authentication_url + token_url = module.jupyterhub-openid-client.config.token_url + realm_api_url = module.jupyterhub-openid-client.config.realm_api_url + service_account_user_id = module.jupyterhub-openid-client.config.service_account_user_id + login_service = "Keycloak" + username_claim = "preferred_username" + claim_groups_key = "groups" + claim_roles_key = "roles" + allowed_groups = ["/analyst", "/developer", "/admin"] + admin_groups = ["/admin"] + manage_groups = true + manage_roles = true + reset_managed_roles_on_startup = true + refresh_pre_spawn = true + validate_server_cert = false # deprecated, to be removed (replaced by validate_server_cert) tls_verify = false @@ -283,6 +285,10 @@ module "jupyterhub-openid-client" { var.jupyterhub-logout-redirect-url ] jupyterlab_profiles_mapper = true + service-accounts-enabled = true + service-account-roles = [ + "view-realm", "view-users", "view-clients" + ] } diff --git a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/keycloak-client/main.tf b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/keycloak-client/main.tf index fd85eeb7a..7a2c3e648 100644 --- a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/keycloak-client/main.tf +++ b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/keycloak-client/main.tf @@ -15,7 +15,8 @@ resource "keycloak_openid_client" "main" { access_type = "CONFIDENTIAL" standard_flow_enabled = true - valid_redirect_uris = var.callback-url-paths + valid_redirect_uris = var.callback-url-paths + service_accounts_enabled = var.service-accounts-enabled } @@ -62,6 +63,33 @@ resource "keycloak_openid_user_attribute_protocol_mapper" "jupyterlab_profiles" aggregate_attributes = true } +data "keycloak_realm" "master" { + realm = "nebari" +} + +data "keycloak_openid_client" "realm_management" { + realm_id = var.realm_id + client_id = "realm-management" +} + +data "keycloak_role" "main-service" { + for_each = toset(var.service-account-roles) + + realm_id = data.keycloak_realm.master.id + client_id = data.keycloak_openid_client.realm_management.id + name = each.key +} + +resource "keycloak_openid_client_service_account_role" "main" { + for_each = toset(var.service-account-roles) + + realm_id = var.realm_id + service_account_user_id = keycloak_openid_client.main.service_account_user_id + client_id = data.keycloak_openid_client.realm_management.id + role = data.keycloak_role.main-service[each.key].name +} + + resource "keycloak_role" "main" { for_each = toset(flatten(values(var.role_mapping))) diff --git a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/keycloak-client/outputs.tf b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/keycloak-client/outputs.tf index bd1978bd4..6077c22b0 100644 --- a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/keycloak-client/outputs.tf +++ b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/keycloak-client/outputs.tf @@ -1,12 +1,14 @@ output "config" { description = "configuration credentials for connecting to openid client" value = { - client_id = keycloak_openid_client.main.client_id - client_secret = keycloak_openid_client.main.client_secret + client_id = keycloak_openid_client.main.client_id + client_secret = keycloak_openid_client.main.client_secret + service_account_user_id = keycloak_openid_client.main.service_account_user_id authentication_url = "https://${var.external-url}/auth/realms/${var.realm_id}/protocol/openid-connect/auth" token_url = "https://${var.external-url}/auth/realms/${var.realm_id}/protocol/openid-connect/token" userinfo_url = "https://${var.external-url}/auth/realms/${var.realm_id}/protocol/openid-connect/userinfo" + realm_api_url = "https://${var.external-url}/auth/admin/realms/${var.realm_id}" callback_urls = var.callback-url-paths } } diff --git a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/keycloak-client/variables.tf b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/keycloak-client/variables.tf index d20ecca48..b4e709c6a 100644 --- a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/keycloak-client/variables.tf +++ b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/keycloak-client/variables.tf @@ -16,6 +16,19 @@ variable "external-url" { } +variable "service-accounts-enabled" { + description = "Whether the client should have a service account created" + type = bool + default = false +} + +variable "service-account-roles" { + description = "Roles to be granted to the service account. Requires setting service-accounts-enabled to true." + type = list(string) + default = [] +} + + variable "role_mapping" { description = "Group to role mapping to establish for client" type = map(list(string)) From dd913d79f5a0c80c1be9eced2685cf25319d208f Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 4 May 2024 15:19:43 +0100 Subject: [PATCH 03/11] Add a test --- tests/tests_deployment/test_jupyterhub_api.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/tests_deployment/test_jupyterhub_api.py diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py new file mode 100644 index 000000000..e7e3fe3ec --- /dev/null +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -0,0 +1,21 @@ +import pytest +import requests + +from tests.tests_deployment import constants +from tests.tests_deployment.utils import get_jupyterhub_token + + +@pytest.fixture(scope="session") +def api_token(): + return get_jupyterhub_token("jupyterhub-api") + + +@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") +def test_jupyterhub_loads_roles_from_keycloak(api_token): + response = requests.get( + f"https://{constants.NEBARI_HOSTNAME}/hub/api/user/{constants.KEYCLOAK_USERNAME}", + headers={"Authorization": f"Bearer {api_token}"}, + verify=False, + ) + user = response.json() + assert user["roles"] == [] From 97e19271eec197ee37bcae1db4387a5b7994b0ba Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 4 May 2024 16:16:23 +0100 Subject: [PATCH 04/11] Restore `userdata_url`, remove not needed `service_account_user_id`, move `reset_managed_roles_on_startup` to implementation class. This reduces the diff substantially. --- .../jupyterhub/files/jupyterhub/04-auth.py | 4 +-- .../kubernetes/services/jupyterhub/main.tf | 36 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py index 87c3c1a9c..570074298 100644 --- a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py +++ b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py @@ -5,7 +5,7 @@ from jupyterhub.traitlets import Callable from oauthenticator.generic import GenericOAuthenticator -from traitlets import Unicode, Union +from traitlets import Bool, Unicode, Union class KeyCloakOAuthenticator(GenericOAuthenticator): @@ -25,7 +25,7 @@ class KeyCloakOAuthenticator(GenericOAuthenticator): config=True, help="""The keycloak REST API URL for the realm.""" ) - service_account_user_id = Unicode(config=True, help="""service_account_user_id.""") + reset_managed_roles_on_startup = Bool(True) async def update_auth_model(self, auth_model): auth_model = await super().update_auth_model(auth_model) diff --git a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf index 29327a388..8055de26d 100644 --- a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf +++ b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf @@ -148,24 +148,24 @@ resource "helm_release" "jupyterhub" { enable_auth_state = true } KeyCloakOAuthenticator = { - client_id = module.jupyterhub-openid-client.config.client_id - client_secret = module.jupyterhub-openid-client.config.client_secret - oauth_callback_url = "https://${var.external-url}/hub/oauth_callback" - authorize_url = module.jupyterhub-openid-client.config.authentication_url - token_url = module.jupyterhub-openid-client.config.token_url - realm_api_url = module.jupyterhub-openid-client.config.realm_api_url - service_account_user_id = module.jupyterhub-openid-client.config.service_account_user_id - login_service = "Keycloak" - username_claim = "preferred_username" - claim_groups_key = "groups" - claim_roles_key = "roles" - allowed_groups = ["/analyst", "/developer", "/admin"] - admin_groups = ["/admin"] - manage_groups = true - manage_roles = true - reset_managed_roles_on_startup = true - refresh_pre_spawn = true - validate_server_cert = false + client_id = module.jupyterhub-openid-client.config.client_id + client_secret = module.jupyterhub-openid-client.config.client_secret + oauth_callback_url = "https://${var.external-url}/hub/oauth_callback" + authorize_url = module.jupyterhub-openid-client.config.authentication_url + token_url = module.jupyterhub-openid-client.config.token_url + userdata_url = module.jupyterhub-openid-client.config.userinfo_url + realm_api_url = module.jupyterhub-openid-client.config.realm_api_url + service_account_id = module.jupyterhub-openid-client.config.service_account_user_id + login_service = "Keycloak" + username_claim = "preferred_username" + claim_groups_key = "groups" + claim_roles_key = "roles" + allowed_groups = ["/analyst", "/developer", "/admin"] + admin_groups = ["/admin"] + manage_groups = true + manage_roles = true + refresh_pre_spawn = true + validate_server_cert = false # deprecated, to be removed (replaced by validate_server_cert) tls_verify = false From c29c4928830892f82ef077a69cd2041b32dd4f30 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 4 May 2024 16:34:26 +0100 Subject: [PATCH 05/11] Make the roles list into a list of dicts --- .../kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py index 570074298..c96db21aa 100644 --- a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py +++ b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py @@ -31,7 +31,7 @@ async def update_auth_model(self, auth_model): auth_model = await super().update_auth_model(auth_model) user_info = auth_model["auth_state"][self.user_auth_state_key] user_roles = self._get_user_roles(user_info) - auth_model["roles"] = user_roles + auth_model["roles"] = [{"name": role_name} for role_name in user_roles] return auth_model async def load_managed_roles(self): From 4d2a167f56b8693f34ba5b513983d50ea7e93284 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 4 May 2024 17:34:50 +0100 Subject: [PATCH 06/11] Try using /users endpoint for tests --- tests/tests_deployment/test_jupyterhub_api.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index e7e3fe3ec..01d079cf2 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -13,9 +13,10 @@ def api_token(): @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") def test_jupyterhub_loads_roles_from_keycloak(api_token): response = requests.get( - f"https://{constants.NEBARI_HOSTNAME}/hub/api/user/{constants.KEYCLOAK_USERNAME}", + f"https://{constants.NEBARI_HOSTNAME}/hub/api/users", headers={"Authorization": f"Bearer {api_token}"}, verify=False, ) - user = response.json() - assert user["roles"] == [] + users = response.json() + print(users) + assert users[0]["roles"] == [] From a5e76ba663b5443c6986779b24bd5219dd15af12 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 4 May 2024 19:22:56 +0100 Subject: [PATCH 07/11] Try using xsrf token --- tests/tests_deployment/test_jupyterhub_api.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index 01d079cf2..5c02a193e 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -1,20 +1,15 @@ import pytest -import requests from tests.tests_deployment import constants -from tests.tests_deployment.utils import get_jupyterhub_token - - -@pytest.fixture(scope="session") -def api_token(): - return get_jupyterhub_token("jupyterhub-api") @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") -def test_jupyterhub_loads_roles_from_keycloak(api_token): - response = requests.get( +def test_jupyterhub_loads_roles_from_keycloak(): + session = get_jupyterhub_session() + xsrf_token = session.cookies.get("_xsrf") + response = session.get( f"https://{constants.NEBARI_HOSTNAME}/hub/api/users", - headers={"Authorization": f"Bearer {api_token}"}, + headers={"X-XSRFToken": xsrf_token}, verify=False, ) users = response.json() From 802d1914e0d2662c2d8f0f70399c2e8202671c03 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Mon, 6 May 2024 11:13:30 +0100 Subject: [PATCH 08/11] Remove unused argument --- .../template/modules/kubernetes/services/jupyterhub/main.tf | 1 - 1 file changed, 1 deletion(-) diff --git a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf index 8055de26d..66df90a81 100644 --- a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf +++ b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/main.tf @@ -155,7 +155,6 @@ resource "helm_release" "jupyterhub" { token_url = module.jupyterhub-openid-client.config.token_url userdata_url = module.jupyterhub-openid-client.config.userinfo_url realm_api_url = module.jupyterhub-openid-client.config.realm_api_url - service_account_id = module.jupyterhub-openid-client.config.service_account_user_id login_service = "Keycloak" username_claim = "preferred_username" claim_groups_key = "groups" From f8da2f909fa6800686ebadade28884ceb5912902 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Mon, 6 May 2024 12:26:49 +0100 Subject: [PATCH 09/11] Preserve user/admin roles --- .../services/jupyterhub/files/jupyterhub/04-auth.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py index c96db21aa..082268a10 100644 --- a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py +++ b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py @@ -32,6 +32,11 @@ async def update_auth_model(self, auth_model): user_info = auth_model["auth_state"][self.user_auth_state_key] user_roles = self._get_user_roles(user_info) auth_model["roles"] = [{"name": role_name} for role_name in user_roles] + # note: because the roles check is comprehensive, we need to re-add the admin and user roles + if auth_model["admin"]: + auth_model["roles"].append({"name": "admin"}) + if self.check_allowed(auth_model["name"], auth_model): + auth_model["roles"].append({"name": "user"}) return auth_model async def load_managed_roles(self): From 3fe718cd54e6fa70fd91ef8a5084301576b2891f Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Mon, 6 May 2024 12:27:04 +0100 Subject: [PATCH 10/11] Fix tests --- tests/tests_deployment/test_jupyterhub_api.py | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index 5c02a193e..aafa71a98 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -1,6 +1,7 @@ import pytest from tests.tests_deployment import constants +from tests.tests_deployment.utils import get_jupyterhub_session @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") @@ -8,10 +9,34 @@ def test_jupyterhub_loads_roles_from_keycloak(): session = get_jupyterhub_session() xsrf_token = session.cookies.get("_xsrf") response = session.get( - f"https://{constants.NEBARI_HOSTNAME}/hub/api/users", + f"https://{constants.NEBARI_HOSTNAME}/hub/api/users/{constants.KEYCLOAK_USERNAME}", headers={"X-XSRFToken": xsrf_token}, verify=False, ) - users = response.json() - print(users) - assert users[0]["roles"] == [] + user = response.json() + assert user["roles"] == [ + "user", + "manage-account", + "jupyterhub_developer", + "argo-developer", + "dask_gateway_developer", + "grafana_viewer", + "conda_store_developer", + "argo-viewer", + "grafana_developer", + "manage-account-links", + "view-profile", + ] + + +@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") +def test_jupyterhub_loads_groups_from_keycloak(): + session = get_jupyterhub_session() + xsrf_token = session.cookies.get("_xsrf") + response = session.get( + f"https://{constants.NEBARI_HOSTNAME}/hub/api/users/{constants.KEYCLOAK_USERNAME}", + headers={"X-XSRFToken": xsrf_token}, + verify=False, + ) + user = response.json() + assert user["groups"] == ["/analyst", "/developer", "/users"] From 90da37856528a991bf6d723e76bb82229b291196 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Mon, 6 May 2024 13:07:27 +0100 Subject: [PATCH 11/11] Use sets for assertions as order does not matter --- tests/tests_deployment/test_jupyterhub_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index aafa71a98..68fa70c1d 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -14,7 +14,7 @@ def test_jupyterhub_loads_roles_from_keycloak(): verify=False, ) user = response.json() - assert user["roles"] == [ + assert set(user["roles"]) == { "user", "manage-account", "jupyterhub_developer", @@ -26,7 +26,7 @@ def test_jupyterhub_loads_roles_from_keycloak(): "grafana_developer", "manage-account-links", "view-profile", - ] + } @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") @@ -39,4 +39,4 @@ def test_jupyterhub_loads_groups_from_keycloak(): verify=False, ) user = response.json() - assert user["groups"] == ["/analyst", "/developer", "/users"] + assert set(user["groups"]) == {"/analyst", "/developer", "/users"}