Skip to content
Draft
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
6bc13de
add jhub apps service account with admin permissions
Adam-D-Lewis Jan 20, 2025
a2e1620
Merge branch 'main' into jhub_apps_user
Adam-D-Lewis Jan 21, 2025
234baa2
reduce permissions
Adam-D-Lewis Jan 21, 2025
d609271
cleanup
Adam-D-Lewis Jan 21, 2025
01d1d5d
consolidate calls
Adam-D-Lewis Jan 21, 2025
1bfe644
revert to non service account user for jhub apps startup apps
Adam-D-Lewis Jan 21, 2025
a4943bb
cleanup
Adam-D-Lewis Jan 21, 2025
5f9834a
hacky, but works
Adam-D-Lewis Jan 27, 2025
7e6204a
add role to service account + cleanup
Adam-D-Lewis Jan 27, 2025
2a3e49b
try to set service account auth state, but I don't think it's working
Adam-D-Lewis Jan 28, 2025
110b0ee
fix bug and set auth state for service account
Adam-D-Lewis Jan 28, 2025
a0f4efe
cleanup
Adam-D-Lewis Jan 28, 2025
f180f07
cleanup
Adam-D-Lewis Jan 28, 2025
6406e82
cleanup
Adam-D-Lewis Jan 28, 2025
325a601
make service account name a variable
Adam-D-Lewis Jan 28, 2025
64d3e0b
rename id to uuid for clarity
Adam-D-Lewis Jan 28, 2025
cb775e0
remove unneeded code
Adam-D-Lewis Jan 28, 2025
59078cc
fix
Adam-D-Lewis Jan 28, 2025
f799f3e
cleanup
Adam-D-Lewis Jan 28, 2025
21d0880
clarify docstring
Adam-D-Lewis Jan 28, 2025
0be3851
clarify docstring
Adam-D-Lewis Jan 28, 2025
fedf7ae
Merge branch 'main' into jhub_apps_user
Adam-D-Lewis Jan 28, 2025
2fb4fa8
fix buffer full deadlock
Adam-D-Lewis Jan 29, 2025
8cb0e63
ensure binary raw string
Adam-D-Lewis Jan 29, 2025
556661f
strip all ansi formatting sequences
Adam-D-Lewis Jan 29, 2025
7e5c2b0
Revert "strip all ansi formatting sequences"
Adam-D-Lewis Jan 29, 2025
37bd636
Revert "ensure binary raw string"
Adam-D-Lewis Jan 29, 2025
b6e75de
Revert "fix buffer full deadlock"
Adam-D-Lewis Jan 29, 2025
1fce666
fix fstring
Adam-D-Lewis Feb 3, 2025
865c8d6
add comment with jupyter/oauth code we are mimicking
Adam-D-Lewis Feb 3, 2025
fad0155
add keycloak service account name format comment
Adam-D-Lewis Feb 3, 2025
8569ee8
merge with main
Adam-D-Lewis Feb 10, 2025
80456c5
test that jupyterhub service account gets needed roles
Adam-D-Lewis Feb 10, 2025
627c4aa
add a startup app to ci deployment
Adam-D-Lewis Feb 10, 2025
6de7c1d
assert startup server is created
Adam-D-Lewis Feb 10, 2025
48eae29
fix test_startup_apps_created test
Adam-D-Lewis Feb 10, 2025
fbaec09
remove breakpoint
Adam-D-Lewis Feb 10, 2025
708f753
refactor keycloak command cli
Adam-D-Lewis Feb 10, 2025
e7da0aa
make test-user an admin
Adam-D-Lewis Feb 10, 2025
de43a81
fix test ids
Adam-D-Lewis Feb 10, 2025
9810fdb
update tests since test-user is now an admin
Adam-D-Lewis Feb 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ module "conda-store-openid-client" {
"https://${var.external-url}/conda-store/oauth_callback"
]
service-accounts-enabled = true
service-account-roles = [
"view-realm", "view-users", "view-clients"
]
service-account-roles = {
"realm-management" : ["view-realm", "view-users", "view-clients"]
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import json

import kubernetes.client.models
from tornado import gen

kubernetes.client.models.V1EndpointPort = (
kubernetes.client.models.CoreV1EndpointPort
Expand All @@ -11,9 +10,8 @@
from kubespawner import KubeSpawner # noqa: E402


@gen.coroutine
def get_username_hook(spawner):
auth_state = yield spawner.user.get_auth_state()
async def get_username_hook(spawner):
Copy link
Member Author

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.

auth_state = await spawner.user.get_auth_state()
username = auth_state["oauth_user"]["preferred_username"]

spawner.environment.update(
Expand All @@ -23,6 +21,13 @@ def get_username_hook(spawner):
)


async def pre_spawn_hook(spawner):
# if we are starting a service account pod, set/update auth_state
if spawner.user.name == spawner.authenticator.JHUB_SERVICE_ACCOUNT_NAME:
await spawner.authenticator.set_jhub_service_account_auth_state(spawner.user)
await get_username_hook(spawner)


def get_conda_store_environments(user_info: dict):
import urllib3
import yarl
Expand All @@ -44,7 +49,7 @@ def get_conda_store_environments(user_info: dict):
return [f"{env['namespace']['name']}-{env['name']}" for env in j.get("data", [])]


c.Spawner.pre_spawn_hook = get_username_hook
c.Spawner.pre_spawn_hook = pre_spawn_hook

c.JupyterHub.allow_named_servers = False
c.JupyterHub.spawner_class = KubeSpawner
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,6 @@ def render_profiles(spawner):
# userinfo request to have the groups in the key
# "auth_state.oauth_user.groups"
auth_state = yield spawner.user.get_auth_state()

username = auth_state["oauth_user"]["preferred_username"]

# only return the lowest level group name
Expand Down
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
Expand All @@ -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):
Expand All @@ -18,6 +19,12 @@ class KeyCloakOAuthenticator(GenericOAuthenticator):
feature added in JupyterHub 5.0 (https://github.com/jupyterhub/jupyterhub/pull/4748).
"""

JHUB_SERVICE_ACCOUNT_NAME = Unicode()

@default("JHUB_SERVICE_ACCOUNT_NAME")
def _default_jhub_service_account_name(self):
return f"service-account-{self.client_id}"

claim_roles_key = Union(
[Unicode(os.environ.get("OAUTH2_ROLES_KEY", "groups")), Callable()],
config=True,
Expand All @@ -30,6 +37,36 @@ 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(
'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):
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
Copy link
Member

Choose a reason for hiding this comment

The 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.

https://github.com/jupyterhub/oauthenticator/blob/d31bb193e84e7cda58b16f2f5d385c9b8affda4f/oauthenticator/oauth2.py#L1436

Copy link
Member Author

Choose a reason for hiding this comment

The 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
Expand All @@ -46,15 +83,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)
Copy link
Member Author

Choose a reason for hiding this comment

The 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
Expand All @@ -63,7 +100,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,
)
)
Expand Down Expand Up @@ -114,7 +151,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 = [
Expand All @@ -131,9 +168,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"
Expand Down Expand Up @@ -168,7 +205,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,
)
)

Expand Down Expand Up @@ -307,7 +344,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(
Expand All @@ -322,8 +359,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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,9 +344,10 @@ module "jupyterhub-openid-client" {
]
jupyterlab_profiles_mapper = true
service-accounts-enabled = true
service-account-roles = [
"view-realm", "view-users", "view-clients"
]
service-account-roles = {
"realm-management" : ["view-realm", "view-users", "view-clients"],
"jupyterhub" : ["allow-app-sharing-role"]
}
}


Expand Down
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
Expand Up @@ -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" {
Copy link
Member Author

Choose a reason for hiding this comment

The 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)))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ output "config" {
callback_urls = var.callback-url-paths
}
}

output "client_role_ids" {
description = "Map of role names to their IDs"
value = {
for role_key, role in keycloak_role.default_client_roles : role_key => role.id
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,17 @@ variable "service-accounts-enabled" {
default = false
}


variable "service-account-roles" {
description = "Roles to be granted to the service account. Requires setting service-accounts-enabled to true."
type = list(string)
default = []
description = <<-EOT
Map of client to client-roles to be granted to the service account client. Requires setting service-accounts-enabled to true.

e.g. {
"my-client": ["my-client-role"],
}
EOT
type = map(list(string))
default = {}
}


Expand Down
Loading