Skip to content
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

Refactor shared group mounting using RBAC #2593

Merged
merged 27 commits into from
Sep 2, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9e38ab1
Include shared group directory mouting role
viniciusdc Jul 26, 2024
ff74a0d
[pre-commit.ci] Apply automatic pre-commit fixes
pre-commit-ci[bot] Jul 29, 2024
5ca8dbc
Add review commits & refactor container configs
viniciusdc Jul 31, 2024
6a8e000
Include testing for new allow-group-directory-creation-role
viniciusdc Jul 31, 2024
bb7a509
[pre-commit.ci] Apply automatic pre-commit fixes
pre-commit-ci[bot] Jul 31, 2024
fcbd4b6
Fix incorrect naming attribute
viniciusdc Jul 31, 2024
8025f0c
Merge branch '2431-rbac-mounted-dirs' of github.com:viniciusdc/qhub-c…
viniciusdc Jul 31, 2024
afca632
Fix incorrect naming attribute
viniciusdc Jul 31, 2024
c5f651c
Refactor mount dir role permission gathering logic
viniciusdc Aug 2, 2024
7bc8a04
[pre-commit.ci] Apply automatic pre-commit fixes
pre-commit-ci[bot] Aug 2, 2024
b1336f8
fix request URL for client redirects & fix set logic
viniciusdc Aug 2, 2024
a100a44
Merge branch '2431-rbac-mounted-dirs' of github.com:viniciusdc/qhub-c…
viniciusdc Aug 2, 2024
8f05abb
remove debugging comments & add another test for checking default groups
viniciusdc Aug 5, 2024
7dd72cd
[pre-commit.ci] Apply automatic pre-commit fixes
pre-commit-ci[bot] Aug 5, 2024
3324d30
Merge branch 'develop' into 2431-rbac-mounted-dirs
viniciusdc Aug 5, 2024
5713783
clean up left legacy code
viniciusdc Aug 5, 2024
742f7fe
rm debugging self.log statements
viniciusdc Aug 5, 2024
c955e6a
Merge branch 'develop' into 2431-rbac-mounted-dirs
viniciusdc Aug 12, 2024
fc349d9
apply suggestions
viniciusdc Aug 20, 2024
d2cbb94
Merge branch 'develop' into 2431-rbac-mounted-dirs
viniciusdc Aug 20, 2024
53cfcd3
Apply suggestions from code review
viniciusdc Aug 20, 2024
655b743
Update tests/tests_deployment/test_jupyterhub_api.py
viniciusdc Aug 20, 2024
6f0ad66
[pre-commit.ci] Apply automatic pre-commit fixes
pre-commit-ci[bot] Aug 20, 2024
e448f17
Merge branch 'develop' into 2431-rbac-mounted-dirs
viniciusdc Aug 28, 2024
c1e0a95
Merge branch 'develop' into 2431-rbac-mounted-dirs
viniciusdc Aug 30, 2024
0c5d9aa
Missing list object for role components
viniciusdc Aug 30, 2024
3ce8e88
Merge branch 'develop' into 2431-rbac-mounted-dirs
viniciusdc Sep 2, 2024
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 @@ -82,11 +82,11 @@ def base_profile_home_mounts(username):
}


def base_profile_shared_mounts(groups):
def base_profile_shared_mounts(groups, group_roles):
"""Configure the group directory mounts for user.

Ensure that {shared}/{group} directory exists and user has
permissions to read/write/execute. Kubernetes does not allow the
Ensure that {shared}/{group} directory exists based on the scope availability
and if user has permissions to read/write/execute. Kubernetes does not allow the
same pvc to be a volume thus we must check that the home and share
pvc are not the same for some operation.

Expand All @@ -103,40 +103,50 @@ def base_profile_shared_mounts(groups):
{"name": "shared", "persistentVolumeClaim": {"claimName": shared_pvc_name}}
)

extra_container_config = {
"volumeMounts": [
{
"mountPath": pod_shared_mount_path.format(group=group),
"name": "shared" if home_pvc_name != shared_pvc_name else "home",
"subPath": pvc_shared_mount_path.format(group=group),
}
for group in groups
]
}
groups_to_volume_mount = []
for group in groups:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason we need to loop through groups to create a list, then loop through that list to create a list of volume mounts and a list of commands and a list of init containers??

Couldn't we just generate the list of volume mounts, commands and init containers in the original loop and just use them later? That would be more readable and reduce the loops from 4 to 1

It may even be worth extracting that loop into a seperate function that takes a list of groups and group roles in and returns the 3 lists that you need further in this function.

Copy link
Contributor Author

@viniciusdc viniciusdc Jul 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I complelty agree; though most of this was already here.If possible, I would prefer to include enhancements in a separate PR. No strong opinions, if you also consider this would suit this PR I can change that as well 😄

for roles in group_roles.get(group, []):
# Check if the group has a role that has a shared-directory scope
if "shared-directory" in roles.get("attributes", {}).get("component", []):
groups_to_volume_mount.append(group)
break

extra_container_config = {"volumeMounts": []}

MKDIR_OWN_DIRECTORY = "mkdir -p /mnt/{path} && chmod 777 /mnt/{path}"
command = " && ".join(
[
MKDIR_OWN_DIRECTORY.format(path=pvc_shared_mount_path.format(group=group))
for group in groups
for group in groups_to_volume_mount
]
)

init_containers = [
{
"name": "initialize-shared-mounts",
"image": "busybox:1.31",
"command": ["sh", "-c", command],
"securityContext": {"runAsUser": 0},
"volumeMounts": [
{
"mountPath": f"/mnt/{pvc_shared_mount_path.format(group=group)}",
"name": "shared" if home_pvc_name != shared_pvc_name else "home",
"subPath": pvc_shared_mount_path.format(group=group),
}
for group in groups
],
"volumeMounts": [],
}
]

for groups in groups_to_volume_mount:
viniciusdc marked this conversation as resolved.
Show resolved Hide resolved
extra_container_config["volumeMounts"].append(
{
"mountPath": pod_shared_mount_path.format(group=groups),
"name": "shared" if home_pvc_name != shared_pvc_name else "home",
"subPath": pvc_shared_mount_path.format(group=groups),
}
)
init_containers[0]["volumeMounts"].append(
{
"mountPath": f"/mnt/{pvc_shared_mount_path.format(group=groups)}",
"name": "shared" if home_pvc_name != shared_pvc_name else "home",
"subPath": pvc_shared_mount_path.format(group=groups),
}
)

return {
"extra_pod_config": extra_pod_config,
"extra_container_config": extra_container_config,
Expand Down Expand Up @@ -475,7 +485,7 @@ def profile_conda_store_viewer_token():
}


def render_profile(profile, username, groups, keycloak_profilenames):
def render_profile(profile, username, groups, keycloak_profilenames, group_roles):
"""Render each profile for user.

If profile is not available for given username, groups returns
Expand Down Expand Up @@ -513,7 +523,7 @@ def render_profile(profile, username, groups, keycloak_profilenames):
deep_merge,
[
base_profile_home_mounts(username),
base_profile_shared_mounts(groups),
base_profile_shared_mounts(groups, group_roles),
profile_conda_store_mounts(username, groups),
base_profile_extra_mounts(),
configure_user(username, groups),
Expand Down Expand Up @@ -543,13 +553,35 @@ def preserve_envvars(spawner):
return profile


def parse_roles(data):
parsed_roles = {}

for role in data:
for group in role["groups"]:
# group = str(group).replace("/", "")
group_name = Path(group).name
if group_name not in parsed_roles:
parsed_roles[group_name] = []

role_info = {
"description": role["description"],
"name": role["name"],
"attributes": role["attributes"],
}

parsed_roles[group_name].append(role_info)

return parsed_roles


@gen.coroutine
def render_profiles(spawner):
# jupyterhub does not yet manage groups but it will soon
# so for now we rely on auth_state from the keycloak
# userinfo request to have the groups in the key
# "auth_state.oauth_user.groups"
auth_state = yield spawner.user.get_auth_state()
group_roles = parse_roles(spawner.authenticator.group_roles_map)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to cause problem in jhub-apps: https://github.com/nebari-dev/jhub-apps/blob/657f7c871003e5f678d865b2b4aad1845e3b5bf7/jhub_apps/service/utils.py#L53

Since that's a downstream problem, it's not blocking this but I would need to think of an alternative approach in jhub-apps to get the spawner with authenticator.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can add the extra information to the auth_state object (probably), this way you don't need to customize the above mock spawner, and we will not need to rely on spawner.authenticator here (which I think its messy).

Though, the reason I didn't go for it was that I was skeptical of changing it directly as

  • changes might not fully apply
  • It looked like that object was moved across different location in the jupyter code during the spawn process

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will have a try on that, and check how it goes

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll check if there is a less hacky way to do this on the jhub-apps.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually there isn't a non-hacky way to do this in jhub-apps. In native JupyterHub the spawner object is magically available due to the huge initialisation done at the startup.

Ref:

We do need to set the groups via auth_state and in-fact I think that's even cleaner as all the roles and scopes parsing happens in the authenticator and other components like spawner just consumes the output via auth_state.


username = auth_state["oauth_user"]["preferred_username"]
# only return the lowest level group name
Expand All @@ -566,7 +598,7 @@ def render_profiles(spawner):
filter(
None,
[
render_profile(p, username, groups, keycloak_profilenames)
render_profile(p, username, groups, keycloak_profilenames, group_roles)
for p in profile_list
],
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,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, List, Unicode, Union


class KeyCloakOAuthenticator(GenericOAuthenticator):
Expand All @@ -27,6 +27,11 @@ class KeyCloakOAuthenticator(GenericOAuthenticator):
config=True, help="""The keycloak REST API URL for the realm."""
)

group_roles_map = List(
config=False,
help="""A mapping of roles to groups based on the user's assigned roles.""",
)

reset_managed_roles_on_startup = Bool(True)

async def update_auth_model(self, auth_model):
Expand Down Expand Up @@ -55,6 +60,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
)
self.group_roles_map = await self._fetch_and_map_roles(
roles=user_roles_rich,
token=token,
url=f"clients/{jupyterhub_client_id}/roles/{{role_name}}",
)
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 = [
Expand Down Expand Up @@ -154,17 +164,36 @@ async def load_managed_roles(self):

return list(roles.values())

def _get_scope_from_role(self, role):
def _get_scope_from_role(self, role, component_filter=["jupyterhub"]):
viniciusdc marked this conversation as resolved.
Show resolved Hide resolved
"""Return scopes from role if the component is jupyterhub"""
role_scopes = role.get("attributes", {}).get("scopes", [])
component = role.get("attributes", {}).get("component")
viniciusdc marked this conversation as resolved.
Show resolved Hide resolved
# 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:
if component == component_filter and role_scopes:
return self.validate_scopes(role_scopes[0].split(","))
elif component_filter is None:
return self.validate_scopes(role_scopes)
viniciusdc marked this conversation as resolved.
Show resolved Hide resolved
else:
return []

async def _fetch_and_map_roles(self, roles, token, url, group_name_key="path"):
# we could use either `name` (e.g. "developer") or `path` ("/developer");
# since the default claim key returns `path`, it seems preferable.

self.log.info("Mapping roles with groups and users..")

for role in roles:
role_name = role["name"]
# fetch role assignments to groups
base_url = url.format(role_name=role_name)
groups = await self._fetch_api(f"{base_url}/groups", token=token)
role["groups"] = [group[group_name_key] for group in groups]
users = await self._fetch_api(f"{base_url}/users", token=token)
role["users"] = [user["username"] for user in users]

return roles
viniciusdc marked this conversation as resolved.
Show resolved Hide resolved

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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,17 @@ module "jupyterhub-openid-client" {
"component" : "jupyterhub"
}
},
{
"name" : "allow-group-directory-creation-role",
"description" : "Grants a group the ability to manage the creation of its corresponding mounted directory.",
# Adding it to analyst group such that it's applied to every user.
viniciusdc marked this conversation as resolved.
Show resolved Hide resolved
"groups" : ["admin", "analyst", "developer"],
"attributes" : {
# grants permissions to read services
viniciusdc marked this conversation as resolved.
Show resolved Hide resolved
"scopes" : "write:shared-mount",
"component" : "shared-directory"
}
},
]
callback-url-paths = [
"https://${var.external-url}/hub/oauth_callback",
Expand Down
10 changes: 10 additions & 0 deletions tests/tests_deployment/keycloak_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ def create_keycloak_role(client_name: str, role_name: str, scopes: str, componen
)


def get_keycloak_client_role(client_name, role_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_role(
client_id=client_details["id"], role_name=role_name
)


def get_keycloak_client_roles(client_name):
keycloak_admin = get_keycloak_admin()
client_details = get_keycloak_client_details_by_name(
Expand Down
19 changes: 19 additions & 0 deletions tests/tests_deployment/test_jupyterhub_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from tests.tests_deployment.keycloak_utils import (
assign_keycloak_client_role_to_user,
create_keycloak_role,
get_keycloak_client_role,
get_keycloak_client_roles,
)
from tests.tests_deployment.utils import create_jupyterhub_token, get_jupyterhub_session
Expand Down Expand Up @@ -33,6 +34,7 @@ def test_jupyterhub_loads_roles_from_keycloak():
"view-profile",
# default roles
"allow-read-access-to-services-role",
"allow-group-directory-creation-role",
}


Expand All @@ -52,6 +54,23 @@ def test_check_default_roles_added_in_keycloak():
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
assert "allow-group-directory-creation-role" in role_names


@pytest.mark.parametrize(
"component,scopes",
(["shared-directory", "write:shared-mount"],),
)
@pytest.mark.filterwarnings(
"ignore:.*auto_refresh_token is deprecated:DeprecationWarning"
)
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_check_default_groups_receive_directory_creation_scope(component, scopes):
client_role = get_keycloak_client_role(
client_name="jupyterhub", role_name="allow-group-directory-creation-role"
)
assert client_role["attributes"]["component"][0] == component
assert client_role["attributes"]["scopes"][0] == scopes


@pytest.mark.parametrize(
Expand Down
Loading