From 208f1765828d1de162ccf576dd8ee052beb98b77 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 27 Jun 2024 10:57:51 +0100 Subject: [PATCH 01/13] Add default role for app sharing --- .../jupyterhub/files/jupyterhub/04-auth.py | 25 ++++++++++++++++++- 1 file changed, 24 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 bc6fb6a721..96e10dfd00 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 @@ -10,6 +10,20 @@ from traitlets import Bool, Unicode, Union +# A set of roles to create automatically to help with basic permissions +DEFAULT_ROLES = [ + { + "name": "allow-app-sharing-role", + "description": "Allow app sharing for apps created via JupyterHub App Launcher (jhub-apps)", + # grants permissions to share user's server + # grants permissions to read other user's names + # grants permissions to read other groups' names + # The later two are required for sharing with a group or user + "scopes": "shares!user,read:users:name,read:groups:name", + } +] + + class KeyCloakOAuthenticator(GenericOAuthenticator): """ Since `oauthenticator` 16.3 `GenericOAuthenticator` supports group management. @@ -105,6 +119,14 @@ async def _get_jupyterhub_client_id(self, token): jupyterhub_client_id = jupyterhub_clients[0]["id"] return jupyterhub_client_id + def _get_default_roles_which_does_not_exists(self, managed_roles): + """Add default roles which_does_not_exists already""" + default_roles_to_add = [ + role for role in DEFAULT_ROLES + if role["name"] not in managed_roles + ] + return default_roles_to_add + async def load_managed_roles(self): self.log.info("Loading managed roles") if not self.manage_roles: @@ -152,7 +174,8 @@ async def load_managed_roles(self): ) role["users"] = [user["username"] for user in users] - return list(roles.values()) + default_roles = self._get_default_roles_which_does_not_exists(roles) + return list(roles.values()) + default_roles def _get_scope_from_role(self, role): """Return scopes from role if the component is jupyterhub""" From e0886627f2ba6b7c22e4914cb890853271cdfc8d Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 2 Jul 2024 10:55:15 +0100 Subject: [PATCH 02/13] cleanup role addition and add docs --- .../jupyterhub/files/jupyterhub/04-auth.py | 167 +++++++++++++++--- .../kubernetes/services/jupyterhub/main.tf | 2 +- 2 files changed, 147 insertions(+), 22 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 96e10dfd00..e26a11b92e 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,7 +1,9 @@ import json import os import time +import typing import urllib +from collections import defaultdict from functools import reduce from jupyterhub import scopes @@ -9,18 +11,25 @@ from oauthenticator.generic import GenericOAuthenticator from traitlets import Bool, Unicode, Union - # A set of roles to create automatically to help with basic permissions DEFAULT_ROLES = [ { "name": "allow-app-sharing-role", "description": "Allow app sharing for apps created via JupyterHub App Launcher (jhub-apps)", - # grants permissions to share user's server + # grants permissions to share server # grants permissions to read other user's names # grants permissions to read other groups' names # The later two are required for sharing with a group or user - "scopes": "shares!user,read:users:name,read:groups:name", - } + "scopes": ["shares,read:users:name,read:groups:name"], + }, + { + "name": "allow-read-access-to-services-role", + "description": "Allow read access to services, such that they are visible on the home page e.g. conda-store", + # grants permissions to read services + "scopes": ["read:services"], + # Adding it to analyst group such that it's applied to every user. + "groups": ["/analyst"], + }, ] @@ -119,14 +128,6 @@ async def _get_jupyterhub_client_id(self, token): jupyterhub_client_id = jupyterhub_clients[0]["id"] return jupyterhub_client_id - def _get_default_roles_which_does_not_exists(self, managed_roles): - """Add default roles which_does_not_exists already""" - default_roles_to_add = [ - role for role in DEFAULT_ROLES - if role["name"] not in managed_roles - ] - return default_roles_to_add - async def load_managed_roles(self): self.log.info("Loading managed roles") if not self.manage_roles: @@ -135,9 +136,17 @@ async def load_managed_roles(self): ) token = await self._get_token() 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 ) + try: + await self._create_default_keycloak_client_roles( + DEFAULT_ROLES, client_roles_rich, jupyterhub_client_id, token + ) + except Exception as e: + self.log.error("Unable to create default roles") + self.log.exception(e) # Includes roles like "default-roles-nebari", "offline_access", "uma_authorization" realm_roles = await self._fetch_api(endpoint="roles", token=token) roles = { @@ -173,9 +182,7 @@ async def load_managed_roles(self): f"clients/{jupyterhub_client_id}/roles/{role_name}/users", token=token ) role["users"] = [user["username"] for user in users] - - default_roles = self._get_default_roles_which_does_not_exists(roles) - return list(roles.values()) + default_roles + return list(roles.values()) def _get_scope_from_role(self, role): """Return scopes from role if the component is jupyterhub""" @@ -202,17 +209,120 @@ def validate_scopes(self, role_scopes): return [] async def _get_roles_with_attributes(self, roles: dict, client_id: str, token: str): - """This fetches all roles by id to fetch there attributes.""" + """This fetches all roles by id to fetch their 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 + # also do multithreaded 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 + async def _create_default_keycloak_client_roles( + self, + roles: typing.List[dict], + existing_roles: typing.List[dict], + client_id: str, + token: str, + ): + """Create default roles for jupyterhub keycloak client""" + self.log.info("Creating default roles, which does not exits already") + self.log.info( + f"Roles to create: {roles}, existing roles: {existing_roles}, client_id: {client_id}" + ) + existing_role_name_mapping = {role["name"]: role for role in existing_roles} + + for role in roles: + self.log.info(f"Creating role: {role}") + if role["name"] in existing_role_name_mapping: + self.log.info(f"role: {role} exists skipping") + continue + + self.log.info(f"Creating keycloak client role: {role}") + body = json.dumps( + { + "name": role.get("name"), + "description": role.get("description"), + "attributes": {"scopes": role.get("scopes")}, + } + ) + response = await self._fetch_api( + endpoint=f"clients/{client_id}/roles", + token=token, + method="POST", + body=body, + extra_headers={"Content-Type": "application/json"}, + ) + self.log.info(f"Keycloak client role creation response: {response}") + + role_name_mapping = await self._get_keycloak_roles( + token=token, client_id=client_id + ) + groups = await self._get_keycloak_groups(token) + for role in roles: + keycloak_role = role_name_mapping[role["name"]] + if len(keycloak_role) != 1: + self.log.error( + f"Multiple roles with same name: {keycloak_role}, skipping" + ) + continue + if not role.get("groups"): + self.log.info( + f"No groups assigned for the role {keycloak_role}, not attaching to any group" + ) + continue + for group_path in role.get("groups"): + keycloak_group = groups[group_path][0] + keycloak_group_id = keycloak_group["id"] + self.log.info( + f"Assigning role: {keycloak_role[0]['name']} to group: {keycloak_group['name']}, client: {client_id}" + ) + response_content = await self._assign_keycloak_client_role( + client_id=client_id, + group_id=keycloak_group_id, + token=token, + role=keycloak_role[0], + ) + self.log.info(f"Role assignment response_content: {response_content}") + + async def _get_keycloak_groups(self, token: str) -> typing.DefaultDict[str, list]: + self.log.info("Getting keycloak groups") + rjson = await self._fetch_api(endpoint="groups", token=token) + self.log.info(f"Keycloak groups: {rjson}") + group_name_mapping = defaultdict(list) + for group in rjson: + group_name_mapping[group["path"]].append(group) + self.log.info(f"Keycloak groups name mapping: {group_name_mapping}") + return group_name_mapping + + async def _get_keycloak_roles( + self, token: str, client_id: str + ) -> typing.DefaultDict[str, list]: + self.log.info(f"getting keycloak roles for client: {client_id}") + rjson = await self._fetch_api( + endpoint=f"clients/{client_id}/roles", token=token + ) + role_name_mapping = defaultdict(list) + for role in rjson: + role_name_mapping[role["name"]].append(role) + self.log.info(f"keycloak roles name mapping: {role_name_mapping}") + return role_name_mapping + + async def _assign_keycloak_client_role( + self, client_id: str, group_id: str, token: str, role: dict + ): + response_content = await self._fetch_api( + endpoint=f"groups/{group_id}/role-mappings/clients/{client_id}", + token=token, + method="POST", + body=json.dumps([role]), + extra_headers={"Content-Type": "application/json"}, + ) + self.log.info(f"role assignment response: {response_content}") + return response_content + 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", @@ -241,6 +351,9 @@ async def _get_token(self) -> str: "grant_type": "client_credentials", } ) + self.log.info( + f"getting token client_id: {self.client_id}, client_secret: {self.client_secret} token: {self.token_url}" + ) response = await http.fetch( self.token_url, method="POST", @@ -249,13 +362,25 @@ async def _get_token(self) -> str: data = json.loads(response.body) return data["access_token"] # type: ignore[no-any-return] - async def _fetch_api(self, endpoint: str, token: str): + async def _fetch_api( + self, + endpoint: str, + token: str, + method: str = "GET", + extra_headers=None, + **kwargs, + ): + append_headers = extra_headers if extra_headers else {} response = await self.http_client.fetch( f"{self.realm_api_url}/{endpoint}", - method="GET", - headers={"Authorization": f"Bearer {token}"}, + method=method, + headers={"Authorization": f"Bearer {token}", **append_headers}, + **kwargs, ) - return json.loads(response.body) + try: + return json.loads(response.body) + except json.decoder.JSONDecodeError: + return 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 fe7716cf88..041590dc4b 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 @@ -286,7 +286,7 @@ module "jupyterhub-openid-client" { jupyterlab_profiles_mapper = true service-accounts-enabled = true service-account-roles = [ - "view-realm", "view-users", "view-clients" + "view-realm", "view-users", "view-clients", "manage-clients", "manage-users" ] } From 6fadae95be5a95ecb0baf5d0e88ab9a6bae1bac9 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jul 2024 11:17:16 +0100 Subject: [PATCH 03/13] add missing roles --- tests/tests_deployment/test_jupyterhub_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index 5e1a54562b..b66dd1c3d9 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -30,6 +30,9 @@ def test_jupyterhub_loads_roles_from_keycloak(): "grafana_developer", "manage-account-links", "view-profile", + # default roles + "allow-app-sharing-role", + "allow-read-access-to-services-role", } From 7ae142f1814fa0db7846c8b470c3e48ce4e495c0 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jul 2024 13:35:15 +0100 Subject: [PATCH 04/13] add component attribute to role creation --- .../services/jupyterhub/files/jupyterhub/04-auth.py | 5 ++++- tests/tests_deployment/test_jupyterhub_api.py | 8 ++++++++ 2 files changed, 12 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 e26a11b92e..1b80efdeef 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 @@ -245,7 +245,10 @@ async def _create_default_keycloak_client_roles( { "name": role.get("name"), "description": role.get("description"), - "attributes": {"scopes": role.get("scopes")}, + "attributes": { + "scopes": role.get("scopes"), + "component": ["jupyterhub"] + }, } ) response = await self._fetch_api( diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index b66dd1c3d9..f0cede90c8 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -36,6 +36,14 @@ def test_jupyterhub_loads_roles_from_keycloak(): } +@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") +def test_default_user_role_scopes(): + # check token scopes before role creation and assignment + token_response = create_jupyterhub_token(note="get-default-scopes") + token_scopes = set(token_response.json()["scopes"]) + assert "read:services" in token_scopes + + @pytest.mark.parametrize( "component,scopes,expected_scopes_difference", ( From 6aeaf41a064312c27fb263f7c03fe153f778aa4b Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jul 2024 14:35:18 +0100 Subject: [PATCH 05/13] remove unrequired role --- .../kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py | 2 +- tests/tests_deployment/test_jupyterhub_api.py | 1 - 2 files changed, 1 insertion(+), 2 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 1b80efdeef..dc54f61454 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 @@ -247,7 +247,7 @@ async def _create_default_keycloak_client_roles( "description": role.get("description"), "attributes": { "scopes": role.get("scopes"), - "component": ["jupyterhub"] + "component": ["jupyterhub"], }, } ) diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index f0cede90c8..fef6fe28a5 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -31,7 +31,6 @@ def test_jupyterhub_loads_roles_from_keycloak(): "manage-account-links", "view-profile", # default roles - "allow-app-sharing-role", "allow-read-access-to-services-role", } From 90911684a319f9223d0972bb474f8bb64f6fd454 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jul 2024 14:48:26 +0100 Subject: [PATCH 06/13] add test for keycloak roles --- tests/tests_deployment/keycloak_utils.py | 8 ++++++++ tests/tests_deployment/test_jupyterhub_api.py | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/tests/tests_deployment/keycloak_utils.py b/tests/tests_deployment/keycloak_utils.py index 6e6f6c21e6..b11c64b93f 100644 --- a/tests/tests_deployment/keycloak_utils.py +++ b/tests/tests_deployment/keycloak_utils.py @@ -81,6 +81,14 @@ def create_keycloak_role(client_name: str, role_name: str, scopes: str, componen ) +def get_keycloak_client_roles(client_name): + keycloak_admin = get_keycloak_admin() + client_details = get_keycloak_client_details_by_name( + client_name=client_name, keycloak_admin=keycloak_admin + ) + return keycloak_admin.get_client_roles(client_id=client_details["id"]) + + def delete_client_keycloak_test_roles(client_name): keycloak_admin = get_keycloak_admin() client_details = get_keycloak_client_details_by_name( diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index fef6fe28a5..f7a145479e 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -4,6 +4,7 @@ from tests.tests_deployment.keycloak_utils import ( assign_keycloak_client_role_to_user, create_keycloak_role, + get_keycloak_client_roles, ) from tests.tests_deployment.utils import create_jupyterhub_token, get_jupyterhub_session @@ -43,6 +44,16 @@ def test_default_user_role_scopes(): assert "read:services" in token_scopes +@pytest.mark.filterwarnings( + "ignore:.*auto_refresh_token is deprecated:DeprecationWarning" +) +def test_check_default_roles_added_in_keycloak(): + client_roles = get_keycloak_client_roles(client_name="jupyterhub") + role_names = [role["name"] for role in client_roles] + assert "allow-app-sharing-role" in role_names + assert "allow-read-access-to-services-role" in role_names + + @pytest.mark.parametrize( "component,scopes,expected_scopes_difference", ( From 9b3f4a0c1509fcc32523f918ba4892332a4445c1 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jul 2024 15:04:11 +0100 Subject: [PATCH 07/13] minor refactoring --- .../jupyterhub/files/jupyterhub/04-auth.py | 58 ++++++++++--------- .../kubernetes/services/jupyterhub/main.tf | 2 + 2 files changed, 33 insertions(+), 27 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 dc54f61454..e3ec06038c 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 @@ -228,7 +228,7 @@ async def _create_default_keycloak_client_roles( token: str, ): """Create default roles for jupyterhub keycloak client""" - self.log.info("Creating default roles, which does not exits already") + self.log.info("Creating default roles, which does not exists already") self.log.info( f"Roles to create: {roles}, existing roles: {existing_roles}, client_id: {client_id}" ) @@ -239,57 +239,61 @@ async def _create_default_keycloak_client_roles( if role["name"] in existing_role_name_mapping: self.log.info(f"role: {role} exists skipping") continue - - self.log.info(f"Creating keycloak client role: {role}") - body = json.dumps( - { - "name": role.get("name"), - "description": role.get("description"), - "attributes": { - "scopes": role.get("scopes"), - "component": ["jupyterhub"], - }, - } - ) - response = await self._fetch_api( - endpoint=f"clients/{client_id}/roles", - token=token, - method="POST", - body=body, - extra_headers={"Content-Type": "application/json"}, - ) - self.log.info(f"Keycloak client role creation response: {response}") + await self._create_keycloak_client_role(role, client_id, token) role_name_mapping = await self._get_keycloak_roles( token=token, client_id=client_id ) groups = await self._get_keycloak_groups(token) for role in roles: - keycloak_role = role_name_mapping[role["name"]] - if len(keycloak_role) != 1: + keycloak_roles = role_name_mapping[role["name"]] + if len(keycloak_roles) != 1: self.log.error( - f"Multiple roles with same name: {keycloak_role}, skipping" + f"Multiple roles with same name: {keycloak_roles}, skipping" ) continue if not role.get("groups"): self.log.info( - f"No groups assigned for the role {keycloak_role}, not attaching to any group" + f"No groups defined for the role {keycloak_roles}, not attaching to any group" ) continue for group_path in role.get("groups"): keycloak_group = groups[group_path][0] keycloak_group_id = keycloak_group["id"] self.log.info( - f"Assigning role: {keycloak_role[0]['name']} to group: {keycloak_group['name']}, client: {client_id}" + f"Assigning role: {keycloak_roles[0]['name']} " + f"to group: {keycloak_group['name']}," + f"client: {client_id}" ) response_content = await self._assign_keycloak_client_role( client_id=client_id, group_id=keycloak_group_id, token=token, - role=keycloak_role[0], + role=keycloak_roles[0], ) self.log.info(f"Role assignment response_content: {response_content}") + async def _create_keycloak_client_role(self, role, client_id, token): + self.log.info(f"Creating keycloak client role: {role}") + body = json.dumps( + { + "name": role.get("name"), + "description": role.get("description"), + "attributes": { + "scopes": role.get("scopes"), + "component": ["jupyterhub"], + }, + } + ) + response = await self._fetch_api( + endpoint=f"clients/{client_id}/roles", + token=token, + method="POST", + body=body, + extra_headers={"Content-Type": "application/json"}, + ) + self.log.info(f"Keycloak client role creation response: {response}") + async def _get_keycloak_groups(self, token: str) -> typing.DefaultDict[str, list]: self.log.info("Getting keycloak groups") rjson = await self._fetch_api(endpoint="groups", token=token) 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 041590dc4b..87987eaf40 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 @@ -286,6 +286,8 @@ module "jupyterhub-openid-client" { jupyterlab_profiles_mapper = true service-accounts-enabled = true service-account-roles = [ + # "manage-clients" is required for creating roles for the client + # "manage-users" is required for attaching roles to groups "view-realm", "view-users", "view-clients", "manage-clients", "manage-users" ] } From cc3c6171746f4621a1ccae082a75347db152c22d Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jul 2024 15:14:16 +0100 Subject: [PATCH 08/13] filter insecure request warning --- tests/tests_deployment/test_jupyterhub_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index f7a145479e..0f660c3fd1 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -47,6 +47,7 @@ def test_default_user_role_scopes(): @pytest.mark.filterwarnings( "ignore:.*auto_refresh_token is deprecated:DeprecationWarning" ) +@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") def test_check_default_roles_added_in_keycloak(): client_roles = get_keycloak_client_roles(client_name="jupyterhub") role_names = [role["name"] for role in client_roles] From f55c1e3dd0fac8893657f149821d86a006997412 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jul 2024 15:31:51 +0100 Subject: [PATCH 09/13] add more docs --- .../jupyterhub/files/jupyterhub/04-auth.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 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 e3ec06038c..f457d82ec4 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 @@ -21,6 +21,9 @@ # grants permissions to read other groups' names # The later two are required for sharing with a group or user "scopes": ["shares,read:users:name,read:groups:name"], + # not attaching this to any group by default as that might not be desirable for all + # deployments and this gives the user (admin) a choice to attach or not attach this to any + # user or group based on the permission structure of the team / organization. }, { "name": "allow-read-access-to-services-role", @@ -141,6 +144,8 @@ async def load_managed_roles(self): jupyterhub_client_id=jupyterhub_client_id, token=token ) try: + # Creating the default roles in keycloak instead of jupyterhub directly + # to keep keycloak as single source of truth. await self._create_default_keycloak_client_roles( DEFAULT_ROLES, client_roles_rich, jupyterhub_client_id, token ) @@ -296,10 +301,10 @@ async def _create_keycloak_client_role(self, role, client_id, token): async def _get_keycloak_groups(self, token: str) -> typing.DefaultDict[str, list]: self.log.info("Getting keycloak groups") - rjson = await self._fetch_api(endpoint="groups", token=token) - self.log.info(f"Keycloak groups: {rjson}") + response_json = await self._fetch_api(endpoint="groups", token=token) + self.log.info(f"Keycloak groups: {response_json}") group_name_mapping = defaultdict(list) - for group in rjson: + for group in response_json: group_name_mapping[group["path"]].append(group) self.log.info(f"Keycloak groups name mapping: {group_name_mapping}") return group_name_mapping @@ -307,12 +312,13 @@ async def _get_keycloak_groups(self, token: str) -> typing.DefaultDict[str, list async def _get_keycloak_roles( self, token: str, client_id: str ) -> typing.DefaultDict[str, list]: + """Get keycloak roles for a client""" self.log.info(f"getting keycloak roles for client: {client_id}") - rjson = await self._fetch_api( + response_json = await self._fetch_api( endpoint=f"clients/{client_id}/roles", token=token ) role_name_mapping = defaultdict(list) - for role in rjson: + for role in response_json: role_name_mapping[role["name"]].append(role) self.log.info(f"keycloak roles name mapping: {role_name_mapping}") return role_name_mapping @@ -320,6 +326,7 @@ async def _get_keycloak_roles( async def _assign_keycloak_client_role( self, client_id: str, group_id: str, token: str, role: dict ): + """Given a group id and a role, assign role to the group""" response_content = await self._fetch_api( endpoint=f"groups/{group_id}/role-mappings/clients/{client_id}", token=token, @@ -358,9 +365,6 @@ async def _get_token(self) -> str: "grant_type": "client_credentials", } ) - self.log.info( - f"getting token client_id: {self.client_id}, client_secret: {self.client_secret} token: {self.token_url}" - ) response = await http.fetch( self.token_url, method="POST", From d97e3440538f9be333641bdf93e5a65282655432 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jul 2024 15:38:05 +0100 Subject: [PATCH 10/13] undo unnecessary comments --- .../kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py | 1 - tests/tests_deployment/test_jupyterhub_api.py | 1 - 2 files changed, 2 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 f457d82ec4..1bc0a3da04 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 @@ -70,7 +70,6 @@ async def update_auth_model(self, auth_model): auth_model = await super().update_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) diff --git a/tests/tests_deployment/test_jupyterhub_api.py b/tests/tests_deployment/test_jupyterhub_api.py index 0f660c3fd1..4144fd4fe8 100644 --- a/tests/tests_deployment/test_jupyterhub_api.py +++ b/tests/tests_deployment/test_jupyterhub_api.py @@ -38,7 +38,6 @@ def test_jupyterhub_loads_roles_from_keycloak(): @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") def test_default_user_role_scopes(): - # check token scopes before role creation and assignment token_response = create_jupyterhub_token(note="get-default-scopes") token_scopes = set(token_response.json()["scopes"]) assert "read:services" in token_scopes From f3eeffb39c9135ae119ec8ff33e80e4152c447ee Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 12 Jul 2024 11:01:36 +0100 Subject: [PATCH 11/13] create default roles at deployment --- .../jupyterhub/files/jupyterhub/04-auth.py | 170 +----------------- .../kubernetes/services/jupyterhub/main.tf | 30 +++- .../services/keycloak-client/main.tf | 36 ++++ .../services/keycloak-client/variables.tf | 11 ++ 4 files changed, 80 insertions(+), 167 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 1bc0a3da04..cbd20a4418 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,9 +1,7 @@ import json import os import time -import typing import urllib -from collections import defaultdict from functools import reduce from jupyterhub import scopes @@ -11,30 +9,6 @@ from oauthenticator.generic import GenericOAuthenticator from traitlets import Bool, Unicode, Union -# A set of roles to create automatically to help with basic permissions -DEFAULT_ROLES = [ - { - "name": "allow-app-sharing-role", - "description": "Allow app sharing for apps created via JupyterHub App Launcher (jhub-apps)", - # grants permissions to share server - # grants permissions to read other user's names - # grants permissions to read other groups' names - # The later two are required for sharing with a group or user - "scopes": ["shares,read:users:name,read:groups:name"], - # not attaching this to any group by default as that might not be desirable for all - # deployments and this gives the user (admin) a choice to attach or not attach this to any - # user or group based on the permission structure of the team / organization. - }, - { - "name": "allow-read-access-to-services-role", - "description": "Allow read access to services, such that they are visible on the home page e.g. conda-store", - # grants permissions to read services - "scopes": ["read:services"], - # Adding it to analyst group such that it's applied to every user. - "groups": ["/analyst"], - }, -] - class KeyCloakOAuthenticator(GenericOAuthenticator): """ @@ -70,6 +44,7 @@ async def update_auth_model(self, auth_model): auth_model = await super().update_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) @@ -138,19 +113,9 @@ async def load_managed_roles(self): ) token = await self._get_token() 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 ) - try: - # Creating the default roles in keycloak instead of jupyterhub directly - # to keep keycloak as single source of truth. - await self._create_default_keycloak_client_roles( - DEFAULT_ROLES, client_roles_rich, jupyterhub_client_id, token - ) - except Exception as e: - self.log.error("Unable to create default roles") - self.log.exception(e) # Includes roles like "default-roles-nebari", "offline_access", "uma_authorization" realm_roles = await self._fetch_api(endpoint="roles", token=token) roles = { @@ -186,6 +151,7 @@ async def load_managed_roles(self): 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_scope_from_role(self, role): @@ -224,118 +190,6 @@ async def _get_roles_with_attributes(self, roles: dict, client_id: str, token: s roles_rich.append(role_rich) return roles_rich - async def _create_default_keycloak_client_roles( - self, - roles: typing.List[dict], - existing_roles: typing.List[dict], - client_id: str, - token: str, - ): - """Create default roles for jupyterhub keycloak client""" - self.log.info("Creating default roles, which does not exists already") - self.log.info( - f"Roles to create: {roles}, existing roles: {existing_roles}, client_id: {client_id}" - ) - existing_role_name_mapping = {role["name"]: role for role in existing_roles} - - for role in roles: - self.log.info(f"Creating role: {role}") - if role["name"] in existing_role_name_mapping: - self.log.info(f"role: {role} exists skipping") - continue - await self._create_keycloak_client_role(role, client_id, token) - - role_name_mapping = await self._get_keycloak_roles( - token=token, client_id=client_id - ) - groups = await self._get_keycloak_groups(token) - for role in roles: - keycloak_roles = role_name_mapping[role["name"]] - if len(keycloak_roles) != 1: - self.log.error( - f"Multiple roles with same name: {keycloak_roles}, skipping" - ) - continue - if not role.get("groups"): - self.log.info( - f"No groups defined for the role {keycloak_roles}, not attaching to any group" - ) - continue - for group_path in role.get("groups"): - keycloak_group = groups[group_path][0] - keycloak_group_id = keycloak_group["id"] - self.log.info( - f"Assigning role: {keycloak_roles[0]['name']} " - f"to group: {keycloak_group['name']}," - f"client: {client_id}" - ) - response_content = await self._assign_keycloak_client_role( - client_id=client_id, - group_id=keycloak_group_id, - token=token, - role=keycloak_roles[0], - ) - self.log.info(f"Role assignment response_content: {response_content}") - - async def _create_keycloak_client_role(self, role, client_id, token): - self.log.info(f"Creating keycloak client role: {role}") - body = json.dumps( - { - "name": role.get("name"), - "description": role.get("description"), - "attributes": { - "scopes": role.get("scopes"), - "component": ["jupyterhub"], - }, - } - ) - response = await self._fetch_api( - endpoint=f"clients/{client_id}/roles", - token=token, - method="POST", - body=body, - extra_headers={"Content-Type": "application/json"}, - ) - self.log.info(f"Keycloak client role creation response: {response}") - - async def _get_keycloak_groups(self, token: str) -> typing.DefaultDict[str, list]: - self.log.info("Getting keycloak groups") - response_json = await self._fetch_api(endpoint="groups", token=token) - self.log.info(f"Keycloak groups: {response_json}") - group_name_mapping = defaultdict(list) - for group in response_json: - group_name_mapping[group["path"]].append(group) - self.log.info(f"Keycloak groups name mapping: {group_name_mapping}") - return group_name_mapping - - async def _get_keycloak_roles( - self, token: str, client_id: str - ) -> typing.DefaultDict[str, list]: - """Get keycloak roles for a client""" - self.log.info(f"getting keycloak roles for client: {client_id}") - response_json = await self._fetch_api( - endpoint=f"clients/{client_id}/roles", token=token - ) - role_name_mapping = defaultdict(list) - for role in response_json: - role_name_mapping[role["name"]].append(role) - self.log.info(f"keycloak roles name mapping: {role_name_mapping}") - return role_name_mapping - - async def _assign_keycloak_client_role( - self, client_id: str, group_id: str, token: str, role: dict - ): - """Given a group id and a role, assign role to the group""" - response_content = await self._fetch_api( - endpoint=f"groups/{group_id}/role-mappings/clients/{client_id}", - token=token, - method="POST", - body=json.dumps([role]), - extra_headers={"Content-Type": "application/json"}, - ) - self.log.info(f"role assignment response: {response_content}") - return response_content - 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", @@ -372,25 +226,13 @@ async def _get_token(self) -> str: data = json.loads(response.body) return data["access_token"] # type: ignore[no-any-return] - async def _fetch_api( - self, - endpoint: str, - token: str, - method: str = "GET", - extra_headers=None, - **kwargs, - ): - append_headers = extra_headers if extra_headers else {} + async def _fetch_api(self, endpoint: str, token: str): response = await self.http_client.fetch( f"{self.realm_api_url}/{endpoint}", - method=method, - headers={"Authorization": f"Bearer {token}", **append_headers}, - **kwargs, + method="GET", + headers={"Authorization": f"Bearer {token}"}, ) - try: - return json.loads(response.body) - except json.decoder.JSONDecodeError: - return response.body + 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 87987eaf40..4878e4f4c4 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 @@ -279,6 +279,32 @@ module "jupyterhub-openid-client" { "developer" = ["jupyterhub_developer", "dask_gateway_developer"] "analyst" = ["jupyterhub_developer"] } + client_roles = [ + { + "name" : "allow-app-sharing-role", + "description" : "Allow app sharing for apps created via JupyterHub App Launcher (jhub-apps)", + groups : [], + "attributes" : { + # grants permissions to share server + # grants permissions to read other user's names + # grants permissions to read other groups' names + # The later two are required for sharing with a group or user + "scopes" : "shares,read:users:name,read:groups:name" + "component" : "jupyterhub" + } + }, + { + "name" : "allow-read-access-to-services-role", + "description" : "Allow read access to services, such that they are visible on the home page e.g. conda-store", + # Adding it to analyst group such that it's applied to every user. + "groups" : ["analyst"], + "attributes" : { + # grants permissions to read services + "scopes" : "read:services", + "component" : "jupyterhub" + } + }, + ] callback-url-paths = [ "https://${var.external-url}/hub/oauth_callback", var.jupyterhub-logout-redirect-url @@ -286,9 +312,7 @@ module "jupyterhub-openid-client" { jupyterlab_profiles_mapper = true service-accounts-enabled = true service-account-roles = [ - # "manage-clients" is required for creating roles for the client - # "manage-users" is required for attaching roles to groups - "view-realm", "view-users", "view-clients", "manage-clients", "manage-users" + "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 7a2c3e648d..5b3e3fb486 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 @@ -99,6 +99,42 @@ resource "keycloak_role" "main" { description = each.key } +resource "keycloak_role" "default_client_roles" { + for_each = { for role in var.client_roles : role.name => role } + realm_id = var.realm_id + client_id = keycloak_openid_client.main.id + name = each.value.name + description = each.value.description + attributes = each.value.attributes +} + +locals { + group_role_mapping = flatten([ + for role_object in var.client_roles : [ + for group_name in role_object.groups : { + group : group_name + role_name : role_object.name + } + ] + ]) + + groups = toset([ + for index, value in local.group_role_mapping : value.group + ]) +} + +data "keycloak_group" "client_role_groups" { + for_each = local.groups + realm_id = var.realm_id + name = each.value +} + +resource "keycloak_group_roles" "assign_roles" { + for_each = { for idx, value in local.group_role_mapping : idx => value } + realm_id = var.realm_id + group_id = data.keycloak_group.client_role_groups[each.value.group].id + role_ids = [keycloak_role.default_client_roles[each.value.role_name].id] +} data "keycloak_group" "main" { for_each = var.role_mapping 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 b4e709c6a5..7626cc2b93 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 @@ -46,3 +46,14 @@ variable "jupyterlab_profiles_mapper" { type = bool default = false } + +variable "client_roles" { + description = "Create roles for the client and assign it to groups" + default = [] + type = list(object({ + name = string + description = string + groups = optional(list(string)) + attributes = map(any) + })) +} From c6bc01bd0a56ed6a6ee5ac25e1be4996e86447ea Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 12 Jul 2024 12:00:11 +0100 Subject: [PATCH 12/13] fix group assignment --- .../services/keycloak-client/main.tf | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) 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 5b3e3fb486..e23aeb13c8 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 @@ -99,6 +99,24 @@ resource "keycloak_role" "main" { description = each.key } +data "keycloak_group" "main" { + for_each = var.role_mapping + + realm_id = var.realm_id + name = each.key +} + + +resource "keycloak_group_roles" "group_roles" { + for_each = var.role_mapping + + realm_id = var.realm_id + group_id = data.keycloak_group.main[each.key].id + role_ids = [for role in each.value : keycloak_role.main[role].id] + + exhaustive = false +} + resource "keycloak_role" "default_client_roles" { for_each = { for role in var.client_roles : role.name => role } realm_id = var.realm_id @@ -118,38 +136,21 @@ locals { ] ]) - groups = toset([ + client_roles_groups = toset([ for index, value in local.group_role_mapping : value.group ]) } data "keycloak_group" "client_role_groups" { - for_each = local.groups + for_each = local.client_roles_groups realm_id = var.realm_id name = each.value } resource "keycloak_group_roles" "assign_roles" { - for_each = { for idx, value in local.group_role_mapping : idx => value } - realm_id = var.realm_id - group_id = data.keycloak_group.client_role_groups[each.value.group].id - role_ids = [keycloak_role.default_client_roles[each.value.role_name].id] -} - -data "keycloak_group" "main" { - for_each = var.role_mapping - - realm_id = var.realm_id - name = each.key -} - - -resource "keycloak_group_roles" "group_roles" { - for_each = var.role_mapping - - realm_id = var.realm_id - group_id = data.keycloak_group.main[each.key].id - role_ids = [for role in each.value : keycloak_role.main[role].id] - + for_each = { for idx, value in local.group_role_mapping : idx => value } + realm_id = var.realm_id + group_id = data.keycloak_group.client_role_groups[each.value.group].id + role_ids = [keycloak_role.default_client_roles[each.value.role_name].id] exhaustive = false } From 5c60c4ca64156ccdaaea872c0670954b64322f19 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 12 Jul 2024 14:05:17 +0100 Subject: [PATCH 13/13] groups -> "groups" 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> --- .../template/modules/kubernetes/services/jupyterhub/main.tf | 2 +- 1 file changed, 1 insertion(+), 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 4878e4f4c4..8c310c5edb 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 @@ -283,7 +283,7 @@ module "jupyterhub-openid-client" { { "name" : "allow-app-sharing-role", "description" : "Allow app sharing for apps created via JupyterHub App Launcher (jhub-apps)", - groups : [], + "groups" : [], "attributes" : { # grants permissions to share server # grants permissions to read other user's names