-
Notifications
You must be signed in to change notification settings - Fork 97
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
add service account with allow-app-sharing-role permissions #2917
base: main
Are you sure you want to change the base?
Changes from all commits
6bc13de
a2e1620
234baa2
d609271
01d1d5d
1bfe644
a4943bb
5f9834a
7e6204a
2a3e49b
110b0ee
a0f4efe
f180f07
6406e82
325a601
64d3e0b
cb775e0
59078cc
f799f3e
21d0880
0be3851
fedf7ae
2fb4fa8
8cb0e63
556661f
7e5c2b0
37bd636
b6e75de
1fce666
865c8d6
fad0155
8569ee8
80456c5
627c4aa
6de7c1d
48eae29
fbaec09
708f753
e7da0aa
de43a81
9810fdb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
import asyncio | ||
import json | ||
import logging | ||
import os | ||
import time | ||
import urllib | ||
|
@@ -8,7 +9,7 @@ | |
from jupyterhub import scopes | ||
from jupyterhub.traitlets import Callable | ||
from oauthenticator.generic import GenericOAuthenticator | ||
from traitlets import Bool, Unicode, Union | ||
from traitlets import Bool, Unicode, Union, default | ||
|
||
|
||
class KeyCloakOAuthenticator(GenericOAuthenticator): | ||
|
@@ -18,6 +19,13 @@ class KeyCloakOAuthenticator(GenericOAuthenticator): | |
feature added in JupyterHub 5.0 (https://github.com/jupyterhub/jupyterhub/pull/4748). | ||
""" | ||
|
||
JHUB_SERVICE_ACCOUNT_NAME = Unicode() | ||
|
||
# Keycloak currently dictates service account name format as `service-account-<client_id>` See https://github.com/keycloak/keycloak/blob/5e6bb9f7bd2c83febd12668f2605aa8ecbdcf130/docs/documentation/server_admin/topics/admin-cli.adoc?plain=1#L1008 for more info. | ||
@default("JHUB_SERVICE_ACCOUNT_NAME") | ||
def _default_jhub_service_account_name(self): | ||
return f"service-account-{self.client_id}" | ||
Adam-D-Lewis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
claim_roles_key = Union( | ||
[Unicode(os.environ.get("OAUTH2_ROLES_KEY", "groups")), Callable()], | ||
config=True, | ||
|
@@ -30,6 +38,39 @@ class KeyCloakOAuthenticator(GenericOAuthenticator): | |
|
||
reset_managed_roles_on_startup = Bool(True) | ||
|
||
async def set_jhub_service_account_auth_state(self, user): | ||
if user.name != self.JHUB_SERVICE_ACCOUNT_NAME: | ||
raise ValueError( | ||
f'User name "{user.name}" does not match service account name "{self.JHUB_SERVICE_ACCOUNT_NAME}"' | ||
) | ||
auth_model = await self.authenticate_service_account() | ||
await user.save_auth_state(auth_model["auth_state"]) | ||
logging.info(f'Auth state set for service account: "{user.name}"') | ||
|
||
async def authenticate_service_account(self): | ||
# We mimic what OAuthenticator currently does in `authenticate` method, but the logic may change in the future | ||
# Currently, the logic is based on https://github.com/jupyterhub/oauthenticator/blob/d31bb193e84e7cda58b16f2f5d385c9b8affda4f/oauthenticator/oauth2.py#L1436 | ||
|
||
token_info = await self._get_token_info() | ||
|
||
# Get user info using the access token | ||
user_info = await self.token_to_user(token_info) | ||
|
||
# Get/set username | ||
username = self.user_info_to_username(user_info) | ||
username = self.normalize_username(username) | ||
|
||
# Build auth model similar to OAuth flow | ||
auth_model = { | ||
"name": username, | ||
"admin": True if username in self.admin_users else None, | ||
"auth_state": self.build_auth_state_dict(token_info, user_info), | ||
} | ||
|
||
auth_model = await self.update_auth_model(auth_model) | ||
Comment on lines
+54
to
+70
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. We should add a note here and link to the JupyterHub code for posterity, incase something changes in JupyterHub, we can catch-up with that. 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. done |
||
|
||
return auth_model | ||
|
||
async def update_auth_model(self, auth_model): | ||
"""Updates and returns the auth_model dict. | ||
This function is called every time a user authenticates with JupyterHub, as in | ||
|
@@ -46,15 +87,15 @@ async def update_auth_model(self, 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) | ||
jupyterhub_client_uuid = await self._get_jupyterhub_client_uuid(token=token) | ||
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. flyby: rename jupyterhub_client_id to jupyterhub_client_uuid so devs don't get confused with jupyterhub's client_id which is different. |
||
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 | ||
user_id=user_id, client_id=jupyterhub_client_uuid, token=token | ||
) | ||
user_roles_rich = await self._get_roles_with_attributes( | ||
roles=user_roles, client_id=jupyterhub_client_id, token=token | ||
roles=user_roles, client_id=jupyterhub_client_uuid, token=token | ||
) | ||
|
||
# Include which groups have permission to mount shared directories (user by | ||
|
@@ -63,7 +104,7 @@ async def update_auth_model(self, auth_model): | |
await self.get_client_groups_with_mount_permissions( | ||
user_groups=auth_model["auth_state"]["oauth_user"]["groups"], | ||
user_roles=user_roles_rich, | ||
client_id=jupyterhub_client_id, | ||
client_id=jupyterhub_client_uuid, | ||
token=token, | ||
) | ||
) | ||
|
@@ -114,7 +155,7 @@ async def _get_jupyterhub_client_roles(self, jupyterhub_client_id, token): | |
) | ||
return client_roles_rich | ||
|
||
async def _get_jupyterhub_client_id(self, token): | ||
async def _get_jupyterhub_client_uuid(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 = [ | ||
|
@@ -131,9 +172,9 @@ async def load_managed_roles(self): | |
"Managed roles can only be loaded when `manage_roles` is True" | ||
) | ||
token = await self._get_token() | ||
jupyterhub_client_id = await self._get_jupyterhub_client_id(token=token) | ||
jupyterhub_client_uuid = await self._get_jupyterhub_client_uuid(token=token) | ||
client_roles_rich = await self._get_jupyterhub_client_roles( | ||
jupyterhub_client_id=jupyterhub_client_id, token=token | ||
jupyterhub_client_id=jupyterhub_client_uuid, token=token | ||
) | ||
|
||
# Includes roles like "default-roles-nebari", "offline_access", "uma_authorization" | ||
|
@@ -168,7 +209,7 @@ async def load_managed_roles(self): | |
await self._get_users_and_groups_for_role( | ||
role_name, | ||
token=token, | ||
client_id=jupyterhub_client_id, | ||
client_id=jupyterhub_client_uuid, | ||
) | ||
) | ||
|
||
|
@@ -307,7 +348,7 @@ def _get_user_roles(self, user_info): | |
) | ||
return set() | ||
|
||
async def _get_token(self) -> str: | ||
async def _get_token_info(self) -> str: | ||
http = self.http_client | ||
|
||
body = urllib.parse.urlencode( | ||
|
@@ -322,8 +363,12 @@ async def _get_token(self) -> str: | |
method="POST", | ||
body=body, | ||
) | ||
data = json.loads(response.body) | ||
return data["access_token"] # type: ignore[no-any-return] | ||
token_info = json.loads(response.body) | ||
return token_info | ||
|
||
async def _get_token(self) -> str: | ||
token_info = await self._get_token_info() | ||
return token_info["access_token"] # type: ignore[no-any-return] | ||
|
||
async def _fetch_api(self, endpoint: str, token: str): | ||
response = await self.http_client.fetch( | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
terraform { | ||
required_providers { | ||
keycloak = { | ||
source = "mrparkers/keycloak" | ||
version = "3.7.0" | ||
} | ||
} | ||
required_version = ">= 1.0" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -67,29 +67,54 @@ data "keycloak_realm" "master" { | |
realm = "nebari" | ||
} | ||
|
||
data "keycloak_openid_client" "realm_management" { | ||
realm_id = var.realm_id | ||
client_id = "realm-management" | ||
} | ||
|
||
data "keycloak_role" "main-service" { | ||
for_each = toset(var.service-account-roles) | ||
# Get client data for each service account client | ||
data "keycloak_openid_client" "service_clients" { | ||
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. Before we only allowed service accounts to get roles from the realm-management client. This PR allows us to set roles by any client. This functionality was needed to be able to set the allow-app-sharing-role on the jupyterhub service account. |
||
for_each = var.service-account-roles | ||
|
||
realm_id = data.keycloak_realm.master.id | ||
client_id = data.keycloak_openid_client.realm_management.id | ||
name = each.key | ||
} | ||
realm_id = var.realm_id | ||
client_id = each.key | ||
depends_on = [keycloak_openid_client.main] | ||
} | ||
|
||
# Get role data for each client's roles | ||
data "keycloak_role" "client_roles" { | ||
for_each = { | ||
for pair in flatten([ | ||
for client, roles in var.service-account-roles : [ | ||
for role in roles : { | ||
key = "${client}-${role}" | ||
client = client | ||
role = role | ||
} | ||
] | ||
]) : pair.key => pair | ||
} | ||
|
||
resource "keycloak_openid_client_service_account_role" "main" { | ||
for_each = toset(var.service-account-roles) | ||
realm_id = var.realm_id | ||
client_id = data.keycloak_openid_client.service_clients[each.value.client].id | ||
name = each.value.role | ||
} | ||
|
||
resource "keycloak_openid_client_service_account_role" "client_roles" { | ||
for_each = { | ||
for pair in flatten([ | ||
for client, roles in var.service-account-roles : [ | ||
for role in roles : { | ||
key = "${client}-${role}" | ||
client = client | ||
role = role | ||
} | ||
] | ||
]) : pair.key => pair | ||
} | ||
|
||
realm_id = var.realm_id | ||
service_account_user_id = keycloak_openid_client.main.service_account_user_id | ||
client_id = data.keycloak_openid_client.realm_management.id | ||
role = data.keycloak_role.main-service[each.key].name | ||
client_id = data.keycloak_openid_client.service_clients[each.value.client].id | ||
role = data.keycloak_role.client_roles[each.key].name | ||
} | ||
|
||
|
||
resource "keycloak_role" "main" { | ||
for_each = toset(flatten(values(var.role_mapping))) | ||
|
||
|
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.
flyby: tornado coroutine -> native coroutine. We don't need to use a tornado coroutine.