-
Notifications
You must be signed in to change notification settings - Fork 94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fetch JupyterHub roles from Keycloak #2447
Merged
Merged
Changes from 11 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
3ab3cf9
Upgrade to JupyterHub 5.0
krassowski 62e527d
Fetch JupyterHub roles from Keycloak
krassowski dd913d7
Add a test
krassowski 97e1927
Restore `userdata_url`, remove not needed `service_account_user_id`,
krassowski c29c492
Make the roles list into a list of dicts
krassowski 4d2a167
Try using /users endpoint for tests
krassowski a5e76ba
Try using xsrf token
krassowski 802d191
Remove unused argument
krassowski f8da2f9
Preserve user/admin roles
krassowski 3fe718c
Fix tests
krassowski 90da378
Use sets for assertions as order does not matter
krassowski 5b5b214
Merge branch 'develop' into managed-roles
krassowski File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
133 changes: 133 additions & 0 deletions
133
...etes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/04-auth.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,133 @@ | ||
import json | ||
import os | ||
import urllib | ||
from functools import reduce | ||
|
||
from jupyterhub.traitlets import Callable | ||
from oauthenticator.generic import GenericOAuthenticator | ||
from traitlets import Bool, Unicode, Union | ||
|
||
|
||
class KeyCloakOAuthenticator(GenericOAuthenticator): | ||
""" | ||
Since `oauthenticator` 16.3 `GenericOAuthenticator` supports group management. | ||
This subclass adds role management on top of it, building on the new `manage_roles` | ||
feature added in JupyterHub 5.0 (https://github.com/jupyterhub/jupyterhub/pull/4748). | ||
""" | ||
|
||
claim_roles_key = Union( | ||
[Unicode(os.environ.get("OAUTH2_ROLES_KEY", "groups")), Callable()], | ||
config=True, | ||
help="""As `claim_groups_key` but for roles.""", | ||
) | ||
|
||
realm_api_url = Unicode( | ||
config=True, help="""The keycloak REST API URL for the realm.""" | ||
) | ||
|
||
reset_managed_roles_on_startup = Bool(True) | ||
|
||
async def update_auth_model(self, auth_model): | ||
auth_model = await super().update_auth_model(auth_model) | ||
user_info = auth_model["auth_state"][self.user_auth_state_key] | ||
user_roles = self._get_user_roles(user_info) | ||
auth_model["roles"] = [{"name": role_name} for role_name in user_roles] | ||
# note: because the roles check is comprehensive, we need to re-add the admin and user roles | ||
if auth_model["admin"]: | ||
auth_model["roles"].append({"name": "admin"}) | ||
if self.check_allowed(auth_model["name"], auth_model): | ||
auth_model["roles"].append({"name": "user"}) | ||
return auth_model | ||
|
||
async def load_managed_roles(self): | ||
if not self.manage_roles: | ||
raise ValueError( | ||
"Managed roles can only be loaded when `manage_roles` is True" | ||
) | ||
token = await self._get_token() | ||
|
||
# Get the clients list to find the "id" of "jupyterhub" client. | ||
clients_data = await self._fetch_api(endpoint="clients/", token=token) | ||
jupyterhub_clients = [ | ||
client for client in clients_data if client["clientId"] == "jupyterhub" | ||
] | ||
assert len(jupyterhub_clients) == 1 | ||
jupyterhub_client_id = jupyterhub_clients[0]["id"] | ||
|
||
# Includes roles like "jupyterhub_admin", "jupyterhub_developer", "dask_gateway_developer" | ||
client_roles = await self._fetch_api( | ||
endpoint=f"clients/{jupyterhub_client_id}/roles", token=token | ||
) | ||
# Includes roles like "default-roles-nebari", "offline_access", "uma_authorization" | ||
realm_roles = await self._fetch_api(endpoint="roles", token=token) | ||
roles = { | ||
role["name"]: {"name": role["name"], "description": role["description"]} | ||
for role in [*realm_roles, *client_roles] | ||
} | ||
# we could use either `name` (e.g. "developer") or `path` ("/developer"); | ||
# since the default claim key returns `path`, it seems preferable. | ||
group_name_key = "path" | ||
for realm_role in realm_roles: | ||
role_name = realm_role["name"] | ||
role = roles[role_name] | ||
# fetch role assignments to groups | ||
groups = await self._fetch_api(f"roles/{role_name}/groups", token=token) | ||
role["groups"] = [group[group_name_key] for group in groups] | ||
# fetch role assignments to users | ||
users = await self._fetch_api(f"roles/{role_name}/users", token=token) | ||
role["users"] = [user["username"] for user in users] | ||
for client_role in client_roles: | ||
role_name = client_role["name"] | ||
role = roles[role_name] | ||
# fetch role assignments to groups | ||
groups = await self._fetch_api( | ||
f"clients/{jupyterhub_client_id}/roles/{role_name}/groups", token=token | ||
) | ||
role["groups"] = [group[group_name_key] for group in groups] | ||
# fetch role assignments to users | ||
users = await self._fetch_api( | ||
f"clients/{jupyterhub_client_id}/roles/{role_name}/users", token=token | ||
) | ||
role["users"] = [user["username"] for user in users] | ||
|
||
return list(roles.values()) | ||
|
||
def _get_user_roles(self, user_info): | ||
if callable(self.claim_roles_key): | ||
return set(self.claim_roles_key(user_info)) | ||
try: | ||
return set(reduce(dict.get, self.claim_roles_key.split("."), user_info)) | ||
except TypeError: | ||
self.log.error( | ||
f"The claim_roles_key {self.claim_roles_key} does not exist in the user token" | ||
) | ||
return set() | ||
|
||
async def _get_token(self) -> str: | ||
http = self.http_client | ||
|
||
body = urllib.parse.urlencode( | ||
{ | ||
"client_id": self.client_id, | ||
"client_secret": self.client_secret, | ||
"grant_type": "client_credentials", | ||
} | ||
) | ||
response = await http.fetch( | ||
self.token_url, | ||
method="POST", | ||
body=body, | ||
) | ||
data = json.loads(response.body) | ||
return data["access_token"] # type: ignore[no-any-return] | ||
|
||
async def _fetch_api(self, endpoint: str, token: str): | ||
response = await self.http_client.fetch( | ||
f"{self.realm_api_url}/{endpoint}", | ||
method="GET", | ||
headers={"Authorization": f"Bearer {token}"}, | ||
) | ||
return json.loads(response.body) | ||
|
||
|
||
c.JupyterHub.authenticator_class = KeyCloakOAuthenticator |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
6 changes: 4 additions & 2 deletions
6
...tages/kubernetes_services/template/modules/kubernetes/services/keycloak-client/outputs.tf
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,14 @@ | ||
output "config" { | ||
description = "configuration credentials for connecting to openid client" | ||
value = { | ||
client_id = keycloak_openid_client.main.client_id | ||
client_secret = keycloak_openid_client.main.client_secret | ||
client_id = keycloak_openid_client.main.client_id | ||
client_secret = keycloak_openid_client.main.client_secret | ||
service_account_user_id = keycloak_openid_client.main.service_account_user_id | ||
|
||
authentication_url = "https://${var.external-url}/auth/realms/${var.realm_id}/protocol/openid-connect/auth" | ||
token_url = "https://${var.external-url}/auth/realms/${var.realm_id}/protocol/openid-connect/token" | ||
userinfo_url = "https://${var.external-url}/auth/realms/${var.realm_id}/protocol/openid-connect/userinfo" | ||
realm_api_url = "https://${var.external-url}/auth/admin/realms/${var.realm_id}" | ||
callback_urls = var.callback-url-paths | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import pytest | ||
|
||
from tests.tests_deployment import constants | ||
from tests.tests_deployment.utils import get_jupyterhub_session | ||
|
||
|
||
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") | ||
def test_jupyterhub_loads_roles_from_keycloak(): | ||
session = get_jupyterhub_session() | ||
xsrf_token = session.cookies.get("_xsrf") | ||
response = session.get( | ||
f"https://{constants.NEBARI_HOSTNAME}/hub/api/users/{constants.KEYCLOAK_USERNAME}", | ||
headers={"X-XSRFToken": xsrf_token}, | ||
verify=False, | ||
) | ||
user = response.json() | ||
assert set(user["roles"]) == { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is passing locally but failing on CI. I suspect this is because the CI is using docker images with older JupyterHub versions - if I am right, merging nebari-dev/nebari-docker-images#140 should make the tests green. |
||
"user", | ||
"manage-account", | ||
"jupyterhub_developer", | ||
"argo-developer", | ||
"dask_gateway_developer", | ||
"grafana_viewer", | ||
"conda_store_developer", | ||
"argo-viewer", | ||
"grafana_developer", | ||
"manage-account-links", | ||
"view-profile", | ||
} | ||
|
||
|
||
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") | ||
def test_jupyterhub_loads_groups_from_keycloak(): | ||
session = get_jupyterhub_session() | ||
xsrf_token = session.cookies.get("_xsrf") | ||
response = session.get( | ||
f"https://{constants.NEBARI_HOSTNAME}/hub/api/users/{constants.KEYCLOAK_USERNAME}", | ||
headers={"X-XSRFToken": xsrf_token}, | ||
verify=False, | ||
) | ||
user = response.json() | ||
assert set(user["groups"]) == {"/analyst", "/developer", "/users"} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤔 this still does not include all the roles that keycloak returns in oauth for users.
Currently it gives us:
uma_authorization
,offline_access
,default-roles-nebari
;jupyterhub_admin
,dask_gateway_developer
,dask_gateway_admin
, andjupyterhub_developer
, however the oauth response also includes the following (for non-admin user):manage-account
,argo-developer
,dask_gateway_developer
,grafana_viewer
,conda_store_developer
,argo-viewer
,grafana_developer
,manage-account-links
,view-profile
.The difference appears to be largely explained by the oauth response including roles for all clients, whereas the logic above only loads the jupyterhub client roles. I think this is ok.