From d75f2e750797cdfd24edd0dbd93bdcc5f1736ea8 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 17 May 2024 10:05:57 +0100 Subject: [PATCH 01/19] Parse scopes from keycloak to load into JupyterHub --- .../jupyterhub/files/jupyterhub/02-spawner.py | 1 - .../jupyterhub/files/jupyterhub/04-auth.py | 51 ++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) 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 c3934aad05..ea9511a4cc 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 @@ -72,7 +72,6 @@ def service_for_jhub_apps(name, url): "url": url, "external": True, }, - "oauth_no_confirm": True, } c.JupyterHub.services.extend( 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 082268a107..9a0b492dd2 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 @@ -4,6 +4,7 @@ from functools import reduce from jupyterhub.traitlets import Callable +from jupyterhub import scopes from oauthenticator.generic import GenericOAuthenticator from traitlets import Bool, Unicode, Union @@ -58,11 +59,19 @@ async def load_managed_roles(self): client_roles = await self._fetch_api( endpoint=f"clients/{jupyterhub_client_id}/roles", token=token ) + client_roles_rich = await self._get_roles_with_attributes(client_roles, client_id=jupyterhub_client_id, token=token) + self.log.info(f"client roles rich: {client_roles_rich}") # Includes roles like "default-roles-nebari", "offline_access", "uma_authorization" realm_roles = await self._fetch_api(endpoint="roles", token=token) + self.log.info(f"Realm roles: {realm_roles}") + self.log.info(f"Client roles: {client_roles}") roles = { - role["name"]: {"name": role["name"], "description": role["description"]} - for role in [*realm_roles, *client_roles] + role["name"]: { + "name": role["name"], + "description": role["description"], + "scopes": self._get_scope_from_role(role) + } + for role in [*realm_roles, *client_roles_rich] } # we could use either `name` (e.g. "developer") or `path` ("/developer"); # since the default claim key returns `path`, it seems preferable. @@ -90,8 +99,46 @@ async def load_managed_roles(self): ) role["users"] = [user["username"] for user in users] + self.log.info(f"Loading managed roles: {list(roles.values())}") return list(roles.values()) + def _get_scope_from_role(self, role): + """Return scopes from role if the component is jupyterhub""" + self.log.info(f"Getting scopes from role: {role}") + role_scopes = role.get("attributes", {}).get("scopes", []) + component = role.get("attributes", {}).get("component") + self.log.info(f"Component: {component}, scopes: {role_scopes}") + # Attributes as returned as array + # See this: https://stackoverflow.com/questions/68954733/keycloak-client-role-attribute-array + if component == ["jupyterhub"] and role_scopes: + return self.validate_scopes(role_scopes[0].split(",")) + else: + return [] + + def validate_scopes(self, role_scopes): + """Validate role scopes to sanity check user provided scopes from keycloak""" + try: + # This is not a public function, but there isn't any alternative + # method to verify scopes, and we do need to do this sanity check + # as a wrong scope could cause hub pod to fail + scopes._check_scopes_exist(role_scopes) + except scopes.ScopeNotFound as e: + self.log.error(f"Invalid scopes: {role_scopes}") + return [] + + async def _get_roles_with_attributes(self, roles, client_id, token): + """This fetches all roles by id to fetch there attributes.""" + roles_rich = [] + for role in roles: + # If this takes too much time, which isn't the case right now, we can + # also do multi-threaded requests + role_rich = await self._fetch_api( + endpoint=f"roles-by-id/{role['id']}?client={client_id}", + token=token + ) + roles_rich.append(role_rich) + return roles_rich + def _get_user_roles(self, user_info): if callable(self.claim_roles_key): return set(self.claim_roles_key(user_info)) From 55fd14cec472c9588ea84da3167ff953cbde8456 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 17 May 2024 10:18:48 +0100 Subject: [PATCH 02/19] await check_allowed and validate scopes --- .../kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py | 3 ++- 1 file changed, 2 insertions(+), 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 9a0b492dd2..816620d442 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 @@ -36,7 +36,7 @@ async def update_auth_model(self, auth_model): # 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): + if await self.check_allowed(auth_model["name"], auth_model): auth_model["roles"].append({"name": "user"}) return auth_model @@ -117,6 +117,7 @@ def _get_scope_from_role(self, role): def validate_scopes(self, role_scopes): """Validate role scopes to sanity check user provided scopes from keycloak""" + self.log.info(f"Validating role scopes: {role_scopes}") try: # This is not a public function, but there isn't any alternative # method to verify scopes, and we do need to do this sanity check From 54fa08b12ca50cd06c3d59cb2a4aa16e06888654 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 17 May 2024 09:24:22 +0000 Subject: [PATCH 03/19] [pre-commit.ci] Apply automatic pre-commit fixes --- .../services/jupyterhub/files/jupyterhub/04-auth.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 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 816620d442..23a1e44291 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 @@ -3,8 +3,8 @@ import urllib from functools import reduce -from jupyterhub.traitlets import Callable from jupyterhub import scopes +from jupyterhub.traitlets import Callable from oauthenticator.generic import GenericOAuthenticator from traitlets import Bool, Unicode, Union @@ -59,7 +59,9 @@ async def load_managed_roles(self): client_roles = await self._fetch_api( endpoint=f"clients/{jupyterhub_client_id}/roles", token=token ) - client_roles_rich = await self._get_roles_with_attributes(client_roles, client_id=jupyterhub_client_id, token=token) + client_roles_rich = await self._get_roles_with_attributes( + client_roles, client_id=jupyterhub_client_id, token=token + ) self.log.info(f"client roles rich: {client_roles_rich}") # Includes roles like "default-roles-nebari", "offline_access", "uma_authorization" realm_roles = await self._fetch_api(endpoint="roles", token=token) @@ -69,7 +71,7 @@ async def load_managed_roles(self): role["name"]: { "name": role["name"], "description": role["description"], - "scopes": self._get_scope_from_role(role) + "scopes": self._get_scope_from_role(role), } for role in [*realm_roles, *client_roles_rich] } @@ -123,7 +125,7 @@ def validate_scopes(self, role_scopes): # method to verify scopes, and we do need to do this sanity check # as a wrong scope could cause hub pod to fail scopes._check_scopes_exist(role_scopes) - except scopes.ScopeNotFound as e: + except scopes.ScopeNotFound: self.log.error(f"Invalid scopes: {role_scopes}") return [] @@ -134,8 +136,7 @@ async def _get_roles_with_attributes(self, roles, client_id, token): # If this takes too much time, which isn't the case right now, we can # also do multi-threaded requests role_rich = await self._fetch_api( - endpoint=f"roles-by-id/{role['id']}?client={client_id}", - token=token + endpoint=f"roles-by-id/{role['id']}?client={client_id}", token=token ) roles_rich.append(role_rich) return roles_rich From a2b6336153376cd3ee05140ab9d1922daffb75bf Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 20 May 2024 13:04:51 +0100 Subject: [PATCH 04/19] log loading managed roles --- .../kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py | 1 + 1 file changed, 1 insertion(+) 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 23a1e44291..062f0e570e 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 @@ -41,6 +41,7 @@ async def update_auth_model(self, auth_model): return auth_model async def load_managed_roles(self): + self.log.info("Loading managed roles") if not self.manage_roles: raise ValueError( "Managed roles can only be loaded when `manage_roles` is True" From 23075100ffd696afddbb79c96f0818586e822de4 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 21 May 2024 14:59:35 +0100 Subject: [PATCH 05/19] return role scopes --- .../kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py | 3 ++- 1 file changed, 2 insertions(+), 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 062f0e570e..fa2a387799 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 @@ -126,8 +126,9 @@ def validate_scopes(self, role_scopes): # method to verify scopes, and we do need to do this sanity check # as a wrong scope could cause hub pod to fail scopes._check_scopes_exist(role_scopes) + return role_scopes except scopes.ScopeNotFound: - self.log.error(f"Invalid scopes: {role_scopes}") + self.log.error(f"Invalid scopes, skipping: {role_scopes}") return [] async def _get_roles_with_attributes(self, roles, client_id, token): From 1eb0a6e57ff623a2097d165d500e2831e4f7c8dc Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Wed, 22 May 2024 13:22:15 +0100 Subject: [PATCH 06/19] augment user roles/scopes after login --- .../jupyterhub/files/jupyterhub/04-auth.py | 83 ++++++++++++++----- 1 file changed, 64 insertions(+), 19 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 fa2a387799..4bd7c0b88c 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 @@ -29,10 +29,38 @@ class KeyCloakOAuthenticator(GenericOAuthenticator): reset_managed_roles_on_startup = Bool(True) async def update_auth_model(self, auth_model): + """Updates and returns the auth_model dict. + This function is called everytime a user authenticates with JupyterHub, as in + everytime a user login to Nebari. + + It will fetch the roles and their corresponding scopes from keycloak + and return updated auth model which will updates roles/scopes for the + user. When a user's roles/scopes are updated, they take in-affect only + after they log in to Nebari. + """ + self.log.info("Updating 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"] = [{"name": role_name} for role_name in user_roles] + self.log.info(f"AUTH MODEL: {auth_model}") + user_id = auth_model["auth_state"]["oauth_user"]["sub"] + token = await self._get_token() + + jupyterhub_client_id = await self._get_jupyterhub_client_id(token=token) + user_roles = await self._get_client_roles_for_user( + user_id=user_id, client_id=jupyterhub_client_id, token=token + ) + user_roles_rich = await self._get_roles_with_attributes( + roles=user_roles, client_id=jupyterhub_client_id, token=token + ) + + self.log.info(f"USER ROLES: {user_roles}") + auth_model["roles"] = [ + { + "name": role["name"], + "description": role["description"], + "scopes": self._get_scope_from_role(role), + } + for role in user_roles_rich + ] # 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"}) @@ -40,14 +68,19 @@ async def update_auth_model(self, auth_model): auth_model["roles"].append({"name": "user"}) return auth_model - async def load_managed_roles(self): - self.log.info("Loading managed roles") - if not self.manage_roles: - raise ValueError( - "Managed roles can only be loaded when `manage_roles` is True" - ) - token = await self._get_token() + async def _get_jupyterhub_client_roles(self, jupyterhub_client_id, token): + """Get roles for the client named 'jupyterhub'.""" + # 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 + ) + client_roles_rich = await self._get_roles_with_attributes( + client_roles, client_id=jupyterhub_client_id, token=token + ) + return client_roles_rich + + async def _get_jupyterhub_client_id(self, token): # Get the clients list to find the "id" of "jupyterhub" client. clients_data = await self._fetch_api(endpoint="clients/", token=token) jupyterhub_clients = [ @@ -55,19 +88,25 @@ async def load_managed_roles(self): ] assert len(jupyterhub_clients) == 1 jupyterhub_client_id = jupyterhub_clients[0]["id"] + return jupyterhub_client_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 - ) - client_roles_rich = await self._get_roles_with_attributes( - client_roles, client_id=jupyterhub_client_id, token=token + async def load_managed_roles(self): + self.log.info("Loading managed roles") + if not self.manage_roles: + raise ValueError( + "Managed roles can only be loaded when `manage_roles` is True" + ) + token = await self._get_token() + # return [] + jupyterhub_client_id = await self._get_jupyterhub_client_id(token=token) + client_roles_rich = await self._get_jupyterhub_client_roles( + jupyterhub_client_id=jupyterhub_client_id, token=token ) self.log.info(f"client roles rich: {client_roles_rich}") # Includes roles like "default-roles-nebari", "offline_access", "uma_authorization" realm_roles = await self._fetch_api(endpoint="roles", token=token) self.log.info(f"Realm roles: {realm_roles}") - self.log.info(f"Client roles: {client_roles}") + self.log.info(f"Client roles: {client_roles_rich}") roles = { role["name"]: { "name": role["name"], @@ -88,7 +127,7 @@ async def load_managed_roles(self): # 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: + for client_role in client_roles_rich: role_name = client_role["name"] role = roles[role_name] # fetch role assignments to groups @@ -131,7 +170,7 @@ def validate_scopes(self, role_scopes): self.log.error(f"Invalid scopes, skipping: {role_scopes}") return [] - async def _get_roles_with_attributes(self, roles, client_id, token): + async def _get_roles_with_attributes(self, roles: dict, client_id: str, token: str): """This fetches all roles by id to fetch there attributes.""" roles_rich = [] for role in roles: @@ -143,6 +182,12 @@ async def _get_roles_with_attributes(self, roles, client_id, token): roles_rich.append(role_rich) return roles_rich + async def _get_client_roles_for_user(self, user_id, client_id, token): + user_roles = await self._fetch_api( + endpoint=f"users/{user_id}/role-mappings/clients/{client_id}/composite", token=token + ) + return user_roles + def _get_user_roles(self, user_info): if callable(self.claim_roles_key): return set(self.claim_roles_key(user_info)) From c1f628cd618cd8b50136534596ea2f88d2f18f61 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 May 2024 21:27:25 +0100 Subject: [PATCH 07/19] add remaining roles --- .../jupyterhub/files/jupyterhub/04-auth.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 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 4bd7c0b88c..71d3b09419 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 @@ -45,21 +45,31 @@ async def update_auth_model(self, auth_model): token = await self._get_token() jupyterhub_client_id = await self._get_jupyterhub_client_id(token=token) + user_info = auth_model["auth_state"][self.user_auth_state_key] + user_roles_from_claims = self._get_user_roles(user_info=user_info) + self.log.info(f"user_roles_from_claims: {user_roles_from_claims}") user_roles = await self._get_client_roles_for_user( user_id=user_id, client_id=jupyterhub_client_id, token=token ) user_roles_rich = await self._get_roles_with_attributes( roles=user_roles, client_id=jupyterhub_client_id, token=token ) + user_roles_rich_names = {role['name'] for role in user_roles_rich} + user_roles_non_jhub_client = [ + { + "name": role['name'] + } for role in user_roles if role['name'] in (user_roles_from_claims - user_roles_rich_names) + ] + self.log.info(f"user_roles_rich: {user_roles_rich}") self.log.info(f"USER ROLES: {user_roles}") auth_model["roles"] = [ { "name": role["name"], - "description": role["description"], + "description": role.get("description"), "scopes": self._get_scope_from_role(role), } - for role in user_roles_rich + for role in [*user_roles_rich, *user_roles_non_jhub_client] ] # note: because the roles check is comprehensive, we need to re-add the admin and user roles if auth_model["admin"]: @@ -163,7 +173,7 @@ def validate_scopes(self, role_scopes): try: # This is not a public function, but there isn't any alternative # method to verify scopes, and we do need to do this sanity check - # as a wrong scope could cause hub pod to fail + # as a invalid scopes could cause hub pod to fail scopes._check_scopes_exist(role_scopes) return role_scopes except scopes.ScopeNotFound: From 067492fe80ea10d5b023f312bc13724d67b1ba04 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 May 2024 21:30:54 +0100 Subject: [PATCH 08/19] remove unnecessary logs --- .../services/jupyterhub/files/jupyterhub/04-auth.py | 13 +------------ 1 file changed, 1 insertion(+), 12 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 71d3b09419..8beebd4fa0 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 @@ -38,16 +38,14 @@ async def update_auth_model(self, auth_model): user. When a user's roles/scopes are updated, they take in-affect only after they log in to Nebari. """ - self.log.info("Updating auth model") + self.log.info("Updating user auth model") auth_model = await super().update_auth_model(auth_model) - self.log.info(f"AUTH MODEL: {auth_model}") user_id = auth_model["auth_state"]["oauth_user"]["sub"] token = await self._get_token() jupyterhub_client_id = await self._get_jupyterhub_client_id(token=token) user_info = auth_model["auth_state"][self.user_auth_state_key] user_roles_from_claims = self._get_user_roles(user_info=user_info) - self.log.info(f"user_roles_from_claims: {user_roles_from_claims}") user_roles = await self._get_client_roles_for_user( user_id=user_id, client_id=jupyterhub_client_id, token=token ) @@ -60,9 +58,6 @@ async def update_auth_model(self, auth_model): "name": role['name'] } for role in user_roles if role['name'] in (user_roles_from_claims - user_roles_rich_names) ] - self.log.info(f"user_roles_rich: {user_roles_rich}") - - self.log.info(f"USER ROLES: {user_roles}") auth_model["roles"] = [ { "name": role["name"], @@ -112,11 +107,8 @@ async def load_managed_roles(self): client_roles_rich = await self._get_jupyterhub_client_roles( jupyterhub_client_id=jupyterhub_client_id, token=token ) - self.log.info(f"client roles rich: {client_roles_rich}") # Includes roles like "default-roles-nebari", "offline_access", "uma_authorization" realm_roles = await self._fetch_api(endpoint="roles", token=token) - self.log.info(f"Realm roles: {realm_roles}") - self.log.info(f"Client roles: {client_roles_rich}") roles = { role["name"]: { "name": role["name"], @@ -151,15 +143,12 @@ async def load_managed_roles(self): ) role["users"] = [user["username"] for user in users] - self.log.info(f"Loading managed roles: {list(roles.values())}") return list(roles.values()) def _get_scope_from_role(self, role): """Return scopes from role if the component is jupyterhub""" - self.log.info(f"Getting scopes from role: {role}") role_scopes = role.get("attributes", {}).get("scopes", []) component = role.get("attributes", {}).get("component") - self.log.info(f"Component: {component}, scopes: {role_scopes}") # Attributes as returned as array # See this: https://stackoverflow.com/questions/68954733/keycloak-client-role-attribute-array if component == ["jupyterhub"] and role_scopes: From 2b1a8579b57ec6cad5e1739ea45afa1514efef2b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 May 2024 20:32:00 +0000 Subject: [PATCH 09/19] [pre-commit.ci] Apply automatic pre-commit fixes --- .../jupyterhub/files/jupyterhub/04-auth.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 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 8beebd4fa0..21757723fc 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 @@ -30,8 +30,8 @@ class KeyCloakOAuthenticator(GenericOAuthenticator): async def update_auth_model(self, auth_model): """Updates and returns the auth_model dict. - This function is called everytime a user authenticates with JupyterHub, as in - everytime a user login to Nebari. + This function is called every time a user authenticates with JupyterHub, as in + every time a user login to Nebari. It will fetch the roles and their corresponding scopes from keycloak and return updated auth model which will updates roles/scopes for the @@ -52,11 +52,11 @@ async def update_auth_model(self, auth_model): user_roles_rich = await self._get_roles_with_attributes( roles=user_roles, client_id=jupyterhub_client_id, token=token ) - user_roles_rich_names = {role['name'] for role in user_roles_rich} + user_roles_rich_names = {role["name"] for role in user_roles_rich} user_roles_non_jhub_client = [ - { - "name": role['name'] - } for role in user_roles if role['name'] in (user_roles_from_claims - user_roles_rich_names) + {"name": role["name"]} + for role in user_roles + if role["name"] in (user_roles_from_claims - user_roles_rich_names) ] auth_model["roles"] = [ { @@ -183,7 +183,8 @@ async def _get_roles_with_attributes(self, roles: dict, client_id: str, token: s async def _get_client_roles_for_user(self, user_id, client_id, token): user_roles = await self._fetch_api( - endpoint=f"users/{user_id}/role-mappings/clients/{client_id}/composite", token=token + endpoint=f"users/{user_id}/role-mappings/clients/{client_id}/composite", + token=token, ) return user_roles From f82118a4059082517db9243abd8805cff691a5ae Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 May 2024 21:38:12 +0100 Subject: [PATCH 10/19] remove unnecessary return --- .../kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py | 1 - 1 file changed, 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 21757723fc..9934123a99 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 @@ -102,7 +102,6 @@ async def load_managed_roles(self): "Managed roles can only be loaded when `manage_roles` is True" ) token = await self._get_token() - # return [] jupyterhub_client_id = await self._get_jupyterhub_client_id(token=token) client_roles_rich = await self._get_jupyterhub_client_roles( jupyterhub_client_id=jupyterhub_client_id, token=token From bb0eb21280571273a0833344682bb3c29ab15b0b Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 May 2024 22:21:49 +0100 Subject: [PATCH 11/19] fix setting roles --- .../services/jupyterhub/files/jupyterhub/04-auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 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 9934123a99..66faf699f0 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 @@ -54,9 +54,9 @@ async def update_auth_model(self, auth_model): ) user_roles_rich_names = {role["name"] for role in user_roles_rich} user_roles_non_jhub_client = [ - {"name": role["name"]} - for role in user_roles - if role["name"] in (user_roles_from_claims - user_roles_rich_names) + {"name": role} + for role in user_roles_from_claims + if role in (user_roles_from_claims - user_roles_rich_names) ] auth_model["roles"] = [ { From fb4037150f79c7e609b1fd5b8a314a7a7168cf11 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sun, 26 May 2024 16:33:34 +0100 Subject: [PATCH 12/19] add tests for keycloak role attributes parsed correctly --- tests/tests_deployment/conftest.py | 11 +++ tests/tests_deployment/keycloak_utils.py | 84 +++++++++++++++++++ tests/tests_deployment/test_jupyterhub_api.py | 32 ++++++- tests/tests_deployment/utils.py | 9 +- 4 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 tests/tests_deployment/conftest.py create mode 100644 tests/tests_deployment/keycloak_utils.py diff --git a/tests/tests_deployment/conftest.py b/tests/tests_deployment/conftest.py new file mode 100644 index 0000000000..fa71302823 --- /dev/null +++ b/tests/tests_deployment/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from tests.tests_deployment.keycloak_utils import delete_client_keycloak_test_roles + + +@pytest.fixture() +def cleanup_keycloak_roles(): + # setup + yield + # teardown + delete_client_keycloak_test_roles(client_name="jupyterhub") diff --git a/tests/tests_deployment/keycloak_utils.py b/tests/tests_deployment/keycloak_utils.py new file mode 100644 index 0000000000..1d84ec4fb3 --- /dev/null +++ b/tests/tests_deployment/keycloak_utils.py @@ -0,0 +1,84 @@ +from _nebari.config import read_configuration +from _nebari.keycloak import get_keycloak_admin_from_config +from nebari.plugins import nebari_plugin_manager + + +def get_keycloak_client_details_by_name(client_name, keycloak_admin=None): + if not keycloak_admin: + keycloak_admin = get_keycloak_admin() + clients = keycloak_admin.get_clients() + for client in clients: + if client["clientId"] == client_name: + return client + + +def get_keycloak_user_details_by_name(username, keycloak_admin=None): + if not keycloak_admin: + keycloak_admin = get_keycloak_admin() + users = keycloak_admin.get_users() + for user in users: + if user['username'] == username: + return user + + +def get_keycloak_role_details_by_name(roles, role_name): + for role in roles: + if role['name'] == role_name: + return role + + +def get_keycloak_admin(): + config_schema = nebari_plugin_manager.config_schema + config = read_configuration("nebari-config.yaml", config_schema) + return get_keycloak_admin_from_config(config) + + +def create_keycloak_client_role(client_id: str, role_name: str, scopes: str, component: str): + keycloak_admin = get_keycloak_admin() + keycloak_admin.create_client_role( + client_id, + payload={ + "name": role_name, + "description": f"{role_name} description", + "attributes": { + "scopes": [scopes], + "component": [component] + } + } + ) + client_roles = keycloak_admin.get_client_roles(client_id=client_id) + return get_keycloak_role_details_by_name(client_roles, role_name) + + +def assign_keycloak_client_role_to_user(username: str, client_name: str, role: dict): + """Given a keycloak role and client name, assign that to the user""" + keycloak_admin = get_keycloak_admin() + user_details = get_keycloak_user_details_by_name(username=username, keycloak_admin=keycloak_admin) + client_details = get_keycloak_client_details_by_name(client_name=client_name, keycloak_admin=keycloak_admin) + keycloak_admin.assign_client_role( + user_id=user_details["id"], client_id=client_details['id'], roles=[role] + ) + + +def create_keycloak_role(client_name: str, role_name: str, scopes: str, component: str): + """Create a role keycloak role for the given client with scopes and + component set in attributes + """ + keycloak_admin = get_keycloak_admin() + client_details = get_keycloak_client_details_by_name(client_name=client_name, keycloak_admin=keycloak_admin) + return create_keycloak_client_role(client_details["id"], role_name=role_name, scopes=scopes, component=component) + + +def delete_client_keycloak_test_roles(client_name): + keycloak_admin = get_keycloak_admin() + client_details = get_keycloak_client_details_by_name( + client_name=client_name, keycloak_admin=keycloak_admin + ) + client_roles = keycloak_admin.get_client_roles(client_id=client_details["id"]) + for role in client_roles: + if not role["name"].startswith("test"): + continue + keycloak_admin.delete_client_role( + client_role_id=client_details["id"], + role_name=role["name"], + ) diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index 68fa70c1d7..76d8c15fbd 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -1,7 +1,10 @@ +import uuid + import pytest from tests.tests_deployment import constants -from tests.tests_deployment.utils import get_jupyterhub_session +from tests.tests_deployment.utils import get_jupyterhub_session, get_jupyterhub_token, create_jupyterhub_token +from tests.tests_deployment.keycloak_utils import create_keycloak_role, assign_keycloak_client_role_to_user @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") @@ -29,6 +32,33 @@ def test_jupyterhub_loads_roles_from_keycloak(): } +@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") +@pytest.mark.filterwarnings("ignore:.*auto_refresh_token is deprecated:DeprecationWarning") +def test_keycloak_roles_attributes_parsed_as_jhub_scopes(cleanup_keycloak_roles): + # check token scopes before role creation and assignment + response_before = create_jupyterhub_token(note="before-scope-parsing-test") + # create keycloak role with jupyterhub scopes in attributes + role = create_keycloak_role( + client_name="jupyterhub", + # Note: we're clearing this role after every test case, and we're clearing + # it by name, so it must start with test- to be deleted afterward + role_name=f"test-custom-role", + scopes="read:users:shares,read:groups:shares,users:shares", + component="jupyterhub" + ) + assert role + # assign created role to the user + assign_keycloak_client_role_to_user(constants.KEYCLOAK_USERNAME, client_name="jupyterhub", role=role) + response_after = create_jupyterhub_token(note="after-role-parsing") + # verify new scopes added/removed + scopes_difference = set(response_after.json()['scopes']) - set(response_before.json()['scopes']) + assert scopes_difference == { + 'read:groups:shares', + 'users:shares', + 'read:users:shares', + } + + @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") def test_jupyterhub_loads_groups_from_keycloak(): session = get_jupyterhub_session() diff --git a/tests/tests_deployment/utils.py b/tests/tests_deployment/utils.py index f37523d920..b0965dd1ae 100644 --- a/tests/tests_deployment/utils.py +++ b/tests/tests_deployment/utils.py @@ -26,21 +26,24 @@ def get_jupyterhub_session(): return session -def get_jupyterhub_token(note="jupyterhub-tests-deployment"): +def create_jupyterhub_token(note): session = get_jupyterhub_session() xsrf_token = session.cookies.get("_xsrf") headers = {"Referer": f"https://{constants.NEBARI_HOSTNAME}/hub/token"} if xsrf_token: headers["X-XSRFToken"] = xsrf_token data = {"note": note, "expires_in": None} - r = session.post( + return session.post( f"https://{constants.NEBARI_HOSTNAME}/hub/api/users/{constants.KEYCLOAK_USERNAME}/tokens", headers=headers, json=data, verify=False, ) - return r.json()["token"] + +def get_jupyterhub_token(note="jupyterhub-tests-deployment"): + response = create_jupyterhub_token(note=note) + return response.json()["token"] def monkeypatch_ssl_context(): From e36e6a109456eebf1bf9c80021a267fad06d7f99 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sun, 26 May 2024 16:44:31 +0100 Subject: [PATCH 13/19] add tests for negative test cases --- tests/tests_deployment/test_jupyterhub_api.py | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index 76d8c15fbd..55c86a0e95 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -32,31 +32,55 @@ def test_jupyterhub_loads_roles_from_keycloak(): } +@pytest.mark.parametrize( + "component,scopes,expected_scopes_difference", + ( + [ + "jupyterhub", + "read:users:shares,read:groups:shares,users:shares", + {'read:groups:shares', 'users:shares', 'read:users:shares'} + ], + [ + "invalid-component", + "read:users:shares,read:groups:shares,users:shares", + {} + ], + [ + "invalid-component", + "admin:invalid-scope", + {} + ], + ), +) @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") @pytest.mark.filterwarnings("ignore:.*auto_refresh_token is deprecated:DeprecationWarning") -def test_keycloak_roles_attributes_parsed_as_jhub_scopes(cleanup_keycloak_roles): +def test_keycloak_roles_attributes_parsed_as_jhub_scopes( + component, + scopes, + expected_scopes_difference, + cleanup_keycloak_roles +): # check token scopes before role creation and assignment - response_before = create_jupyterhub_token(note="before-scope-parsing-test") + token_response_before = create_jupyterhub_token(note="before-role-creation-and-assignment") + token_scopes_before = set(token_response_before.json()['scopes']) # create keycloak role with jupyterhub scopes in attributes role = create_keycloak_role( client_name="jupyterhub", # Note: we're clearing this role after every test case, and we're clearing # it by name, so it must start with test- to be deleted afterward role_name=f"test-custom-role", - scopes="read:users:shares,read:groups:shares,users:shares", - component="jupyterhub" + scopes=scopes, + component=component ) assert role # assign created role to the user assign_keycloak_client_role_to_user(constants.KEYCLOAK_USERNAME, client_name="jupyterhub", role=role) - response_after = create_jupyterhub_token(note="after-role-parsing") + token_response_after = create_jupyterhub_token(note="after-role-creation-and-assignment") + token_scopes_after = set(token_response_after.json()['scopes']) # verify new scopes added/removed - scopes_difference = set(response_after.json()['scopes']) - set(response_before.json()['scopes']) - assert scopes_difference == { - 'read:groups:shares', - 'users:shares', - 'read:users:shares', - } + expected_scopes_difference = token_scopes_after - token_scopes_before + # Comparing token scopes for the user before and after role assignment + assert expected_scopes_difference == expected_scopes_difference @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") From 88c2bc089b292aea995e0cee85580e446e771676 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sun, 26 May 2024 16:45:14 +0100 Subject: [PATCH 14/19] fix pre-commit changes --- tests/tests_deployment/keycloak_utils.py | 33 +++++++---- tests/tests_deployment/test_jupyterhub_api.py | 59 +++++++++---------- 2 files changed, 49 insertions(+), 43 deletions(-) diff --git a/tests/tests_deployment/keycloak_utils.py b/tests/tests_deployment/keycloak_utils.py index 1d84ec4fb3..5134de979b 100644 --- a/tests/tests_deployment/keycloak_utils.py +++ b/tests/tests_deployment/keycloak_utils.py @@ -17,13 +17,13 @@ def get_keycloak_user_details_by_name(username, keycloak_admin=None): keycloak_admin = get_keycloak_admin() users = keycloak_admin.get_users() for user in users: - if user['username'] == username: + if user["username"] == username: return user def get_keycloak_role_details_by_name(roles, role_name): for role in roles: - if role['name'] == role_name: + if role["name"] == role_name: return role @@ -33,18 +33,17 @@ def get_keycloak_admin(): return get_keycloak_admin_from_config(config) -def create_keycloak_client_role(client_id: str, role_name: str, scopes: str, component: str): +def create_keycloak_client_role( + client_id: str, role_name: str, scopes: str, component: str +): keycloak_admin = get_keycloak_admin() keycloak_admin.create_client_role( client_id, payload={ "name": role_name, "description": f"{role_name} description", - "attributes": { - "scopes": [scopes], - "component": [component] - } - } + "attributes": {"scopes": [scopes], "component": [component]}, + }, ) client_roles = keycloak_admin.get_client_roles(client_id=client_id) return get_keycloak_role_details_by_name(client_roles, role_name) @@ -53,10 +52,14 @@ def create_keycloak_client_role(client_id: str, role_name: str, scopes: str, com def assign_keycloak_client_role_to_user(username: str, client_name: str, role: dict): """Given a keycloak role and client name, assign that to the user""" keycloak_admin = get_keycloak_admin() - user_details = get_keycloak_user_details_by_name(username=username, keycloak_admin=keycloak_admin) - client_details = get_keycloak_client_details_by_name(client_name=client_name, keycloak_admin=keycloak_admin) + user_details = get_keycloak_user_details_by_name( + username=username, keycloak_admin=keycloak_admin + ) + client_details = get_keycloak_client_details_by_name( + client_name=client_name, keycloak_admin=keycloak_admin + ) keycloak_admin.assign_client_role( - user_id=user_details["id"], client_id=client_details['id'], roles=[role] + user_id=user_details["id"], client_id=client_details["id"], roles=[role] ) @@ -65,8 +68,12 @@ def create_keycloak_role(client_name: str, role_name: str, scopes: str, componen component set in attributes """ keycloak_admin = get_keycloak_admin() - client_details = get_keycloak_client_details_by_name(client_name=client_name, keycloak_admin=keycloak_admin) - return create_keycloak_client_role(client_details["id"], role_name=role_name, scopes=scopes, component=component) + client_details = get_keycloak_client_details_by_name( + client_name=client_name, keycloak_admin=keycloak_admin + ) + return create_keycloak_client_role( + client_details["id"], role_name=role_name, scopes=scopes, component=component + ) def delete_client_keycloak_test_roles(client_name): diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index 55c86a0e95..a6a3328dc5 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -1,10 +1,12 @@ -import uuid import pytest from tests.tests_deployment import constants -from tests.tests_deployment.utils import get_jupyterhub_session, get_jupyterhub_token, create_jupyterhub_token -from tests.tests_deployment.keycloak_utils import create_keycloak_role, assign_keycloak_client_role_to_user +from tests.tests_deployment.keycloak_utils import ( + assign_keycloak_client_role_to_user, + create_keycloak_role, +) +from tests.tests_deployment.utils import create_jupyterhub_token, get_jupyterhub_session @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") @@ -35,48 +37,45 @@ def test_jupyterhub_loads_roles_from_keycloak(): @pytest.mark.parametrize( "component,scopes,expected_scopes_difference", ( - [ - "jupyterhub", - "read:users:shares,read:groups:shares,users:shares", - {'read:groups:shares', 'users:shares', 'read:users:shares'} - ], - [ - "invalid-component", - "read:users:shares,read:groups:shares,users:shares", - {} - ], - [ - "invalid-component", - "admin:invalid-scope", - {} - ], + [ + "jupyterhub", + "read:users:shares,read:groups:shares,users:shares", + {"read:groups:shares", "users:shares", "read:users:shares"}, + ], + ["invalid-component", "read:users:shares,read:groups:shares,users:shares", {}], + ["invalid-component", "admin:invalid-scope", {}], ), ) @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") -@pytest.mark.filterwarnings("ignore:.*auto_refresh_token is deprecated:DeprecationWarning") +@pytest.mark.filterwarnings( + "ignore:.*auto_refresh_token is deprecated:DeprecationWarning" +) def test_keycloak_roles_attributes_parsed_as_jhub_scopes( - component, - scopes, - expected_scopes_difference, - cleanup_keycloak_roles + component, scopes, expected_scopes_difference, cleanup_keycloak_roles ): # check token scopes before role creation and assignment - token_response_before = create_jupyterhub_token(note="before-role-creation-and-assignment") - token_scopes_before = set(token_response_before.json()['scopes']) + token_response_before = create_jupyterhub_token( + note="before-role-creation-and-assignment" + ) + token_scopes_before = set(token_response_before.json()["scopes"]) # create keycloak role with jupyterhub scopes in attributes role = create_keycloak_role( client_name="jupyterhub", # Note: we're clearing this role after every test case, and we're clearing # it by name, so it must start with test- to be deleted afterward - role_name=f"test-custom-role", + role_name="test-custom-role", scopes=scopes, - component=component + component=component, ) assert role # assign created role to the user - assign_keycloak_client_role_to_user(constants.KEYCLOAK_USERNAME, client_name="jupyterhub", role=role) - token_response_after = create_jupyterhub_token(note="after-role-creation-and-assignment") - token_scopes_after = set(token_response_after.json()['scopes']) + assign_keycloak_client_role_to_user( + constants.KEYCLOAK_USERNAME, client_name="jupyterhub", role=role + ) + token_response_after = create_jupyterhub_token( + note="after-role-creation-and-assignment" + ) + token_scopes_after = set(token_response_after.json()["scopes"]) # verify new scopes added/removed expected_scopes_difference = token_scopes_after - token_scopes_before # Comparing token scopes for the user before and after role assignment From 27b2950d973ecfb4f0b987e62df9279c281b6b89 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 26 May 2024 16:29:34 +0000 Subject: [PATCH 15/19] [pre-commit.ci] Apply automatic pre-commit fixes --- tests/tests_deployment/test_jupyterhub_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index a6a3328dc5..5e1a54562b 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -1,4 +1,3 @@ - import pytest from tests.tests_deployment import constants From c04fc899bb4609166fb8b641bfc8d6dede153562 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sun, 26 May 2024 20:59:02 +0100 Subject: [PATCH 16/19] load config file path from environment variable --- tests/tests_deployment/keycloak_utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/tests_deployment/keycloak_utils.py b/tests/tests_deployment/keycloak_utils.py index 5134de979b..6e6f6c21e6 100644 --- a/tests/tests_deployment/keycloak_utils.py +++ b/tests/tests_deployment/keycloak_utils.py @@ -1,3 +1,6 @@ +import os +import pathlib + from _nebari.config import read_configuration from _nebari.keycloak import get_keycloak_admin_from_config from nebari.plugins import nebari_plugin_manager @@ -29,7 +32,9 @@ def get_keycloak_role_details_by_name(roles, role_name): def get_keycloak_admin(): config_schema = nebari_plugin_manager.config_schema - config = read_configuration("nebari-config.yaml", config_schema) + config_filepath = os.environ.get("NEBARI_CONFIG_PATH", "nebari-config.yaml") + assert pathlib.Path(config_filepath).exists() + config = read_configuration(config_filepath, config_schema) return get_keycloak_admin_from_config(config) From 6e1cdd96c2a803bed457f9515aa7af612dfd4074 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 27 May 2024 20:31:46 +0100 Subject: [PATCH 17/19] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - improve comments - improve error logging Co-authored-by: Michał Krassowski <5832902+krassowski@users.noreply.github.com> --- .../services/jupyterhub/files/jupyterhub/04-auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 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 66faf699f0..2ea1d0187b 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 @@ -148,7 +148,7 @@ def _get_scope_from_role(self, role): """Return scopes from role if the component is jupyterhub""" role_scopes = role.get("attributes", {}).get("scopes", []) component = role.get("attributes", {}).get("component") - # Attributes as returned as array + # Attributes are returned as a single-element array, unless `##` delimiter is used in Keycloak # See this: https://stackoverflow.com/questions/68954733/keycloak-client-role-attribute-array if component == ["jupyterhub"] and role_scopes: return self.validate_scopes(role_scopes[0].split(",")) @@ -164,8 +164,8 @@ def validate_scopes(self, role_scopes): # as a invalid scopes could cause hub pod to fail scopes._check_scopes_exist(role_scopes) return role_scopes - except scopes.ScopeNotFound: - self.log.error(f"Invalid scopes, skipping: {role_scopes}") + except scopes.ScopeNotFound as e: + self.log.error(f"Invalid scopes, skipping: {role_scopes} ({e})") return [] async def _get_roles_with_attributes(self, roles: dict, client_id: str, token: str): From 30bda526374a42a59a1875f503bd24aa2faff806 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Mon, 27 May 2024 21:49:28 +0100 Subject: [PATCH 18/19] log keycloak api call time --- .../services/jupyterhub/files/jupyterhub/04-auth.py | 10 ++++++++++ 1 file changed, 10 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 2ea1d0187b..f219069b07 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 @@ -1,5 +1,6 @@ import json import os +import time import urllib from functools import reduce @@ -38,6 +39,7 @@ async def update_auth_model(self, auth_model): user. When a user's roles/scopes are updated, they take in-affect only after they log in to Nebari. """ + start = time.time() self.log.info("Updating user auth model") auth_model = await super().update_auth_model(auth_model) user_id = auth_model["auth_state"]["oauth_user"]["sub"] @@ -49,9 +51,11 @@ async def update_auth_model(self, auth_model): user_roles = await self._get_client_roles_for_user( user_id=user_id, client_id=jupyterhub_client_id, token=token ) + keycloak_api_call_start = time.time() user_roles_rich = await self._get_roles_with_attributes( roles=user_roles, client_id=jupyterhub_client_id, token=token ) + keycloak_api_call_time_taken = time.time() - keycloak_api_call_start user_roles_rich_names = {role["name"] for role in user_roles_rich} user_roles_non_jhub_client = [ {"name": role} @@ -71,6 +75,12 @@ async def update_auth_model(self, auth_model): auth_model["roles"].append({"name": "admin"}) if await self.check_allowed(auth_model["name"], auth_model): auth_model["roles"].append({"name": "user"}) + execution_time = time.time() - start + self.log.info( + f"Auth model update complete, time taken: {execution_time} " + f"time taken for keycloak api call: {keycloak_api_call_time_taken} " + f"delta between full execution and keycloak call: {execution_time - keycloak_api_call_time_taken}" + ) return auth_model async def _get_jupyterhub_client_roles(self, jupyterhub_client_id, token): From 6ba0ed303eff0ba347e72f79f328753403227656 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 May 2024 12:43:16 +0100 Subject: [PATCH 19/19] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michał Krassowski <5832902+krassowski@users.noreply.github.com> --- .../services/jupyterhub/files/jupyterhub/04-auth.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 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 f219069b07..bc6fb6a721 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 @@ -48,10 +48,10 @@ async def update_auth_model(self, auth_model): jupyterhub_client_id = await self._get_jupyterhub_client_id(token=token) user_info = auth_model["auth_state"][self.user_auth_state_key] user_roles_from_claims = self._get_user_roles(user_info=user_info) + keycloak_api_call_start = time.time() user_roles = await self._get_client_roles_for_user( user_id=user_id, client_id=jupyterhub_client_id, token=token ) - keycloak_api_call_start = time.time() user_roles_rich = await self._get_roles_with_attributes( roles=user_roles, client_id=jupyterhub_client_id, token=token ) @@ -77,9 +77,9 @@ async def update_auth_model(self, auth_model): auth_model["roles"].append({"name": "user"}) execution_time = time.time() - start self.log.info( - f"Auth model update complete, time taken: {execution_time} " - f"time taken for keycloak api call: {keycloak_api_call_time_taken} " - f"delta between full execution and keycloak call: {execution_time - keycloak_api_call_time_taken}" + f"Auth model update complete, time taken: {execution_time}s " + f"time taken for keycloak api call: {keycloak_api_call_time_taken}s " + f"delta between full execution and keycloak call: {execution_time - keycloak_api_call_time_taken}s" ) return auth_model