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

Profilegroups #1203

Merged
merged 9 commits into from
Mar 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions docs/source/admin_guide/system_maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ profiles:
jupyterlab:
- display_name: Small Instance
description: Stable environment with 1 cpu / 1 GB ram
access: yaml
groups:
- admin
kubespawner_override:
Expand All @@ -102,6 +103,7 @@ profiles:
jupyterlab:
- display_name: Small Instance
description: Stable environment with 1 cpu / 1 GB ram
access: yaml
groups:
- admin
kubespawner_override:
Expand Down
37 changes: 34 additions & 3 deletions docs/source/installation/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ profiles:
jupyterlab:
- display_name: Small Instance
description: Stable environment with 1 cpu / 1 GB ram
access: all
default: true
kubespawner_override:
cpu_limit: 1
Expand All @@ -482,11 +483,25 @@ profiles:
mem_guarantee: 1G
- display_name: Medium Instance
description: Stable environment with 1.5 cpu / 2 GB ram
access: yaml
groups:
- admin
- developers
users:
- bob
kubespawner_override:
cpu_limit: 1.5
cpu_guarantee: 1.25
mem_limit: 2G
mem_guarantee: 2G
- display_name: Large Instance
description: Stable environment with 2 cpu / 4 GB ram
access: keycloak
kubespawner_override:
cpu_limit: 2
cpu_guarantee: 2
mem_limit: 4G
mem_guarantee: 4G
dask_worker:
"Small Worker":
worker_cores_limit: 1
Expand All @@ -500,9 +515,24 @@ profiles:
worker_memory: 2G
```

For each `profiles.jupyterlab` is a named JupyterLab profile. It closely follows the [KubeSpawner](https://jupyterhub-kubespawner.readthedocs.io/en/latest/spawner.html) API. The
only exception is that two keys are added `users` and `groups` which allow restriction of profiles to a given set of groups and users. We recommend using groups to manage profile
access.
### JupyterLab Profiles

For each `profiles.jupyterlab` is a named JupyterLab profile.

Use the `kubespawner_override` field to define behavior as per the [KubeSpawner](https://jupyterhub-kubespawner.readthedocs.io/en/latest/spawner.html) API.

It is possible to control which users have access to which JupyterLab profiles. Each profile has a field named `access` which can be set to `all` (default if omitted), `yaml`, or
`keycloak`.

`all` means every user will have access to the profile.

`yaml` means that access is restricted to anyone with their username in the `users` field of the profile or who belongs to a group named in the `groups` field.

`keycloak` means that access is restricted to any user who in Keycloak has either their group(s) or user with the attribute `jupyterlab_profiles` containing this profile name. For
example, if the user is in a Keycloak group named `developers` which has an attribute `jupyterlab_profiles` set to `Large Instance`, they will have access to the Large Instance
profile. To specify multiple profiles for one group (or user) delimit their names using `##` - for example, `Large Instance##Another Instance`.

### Dask Profiles

Finally, we allow for configuration of the Dask workers. In general, similar to the JupyterLab instances you only need to configuration the cores and memory.

Expand Down Expand Up @@ -734,6 +764,7 @@ profiles:
jupyterlab:
- display_name: Small Instance
description: Stable environment with 1 cpu / 1 GB ram
access: yaml
groups:
- admin
kubespawner_override:
Expand Down
21 changes: 20 additions & 1 deletion qhub/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ class AuthenticationEnum(str, enum.Enum):
custom = "custom"


class AccessEnum(str, enum.Enum):
all = "all"
yaml = "yaml"
keycloak = "keycloak"


class Base(pydantic.BaseModel):
...

Expand Down Expand Up @@ -290,13 +296,26 @@ class Config:


class JupyterLabProfile(Base):
access: AccessEnum = AccessEnum.all
Copy link
Member

Choose a reason for hiding this comment

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

I agree with the default value to keep this backwards compatible.

display_name: str
description: str
default: typing.Optional[bool]
users: typing.Optional[typing.List[str]]
groups: typing.Optional[typing.List[str]]
kubespawner_override: typing.Optional[KubeSpawner]

@root_validator
def only_yaml_can_have_groups_and_users(cls, values):
if values["access"] != AccessEnum.yaml:
if (
values.get("users", None) is not None
or values.get("groups", None) is not None
):
raise ValueError(
"Profile must not contain groups or users fields unless access = yaml"
)
return values


class DaskWorkerProfile(Base):
worker_cores_limit: int
Expand All @@ -313,7 +332,7 @@ class Profiles(Base):
jupyterlab: typing.List[JupyterLabProfile]
dask_worker: typing.Dict[str, DaskWorkerProfile]

@validator("jupyterlab", pre=True)
@validator("jupyterlab")
def check_default(cls, v, values):
"""Check if only one default value is present"""
default = [attrs["default"] for attrs in v if "default" in attrs]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ resource "keycloak_group" "groups" {
realm_id = keycloak_realm.main.id
name = each.key
attributes = {}

lifecycle {
ignore_changes = [
attributes,
]
}
}

resource "keycloak_default_groups" "default" {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ def base_profile_home_mounts(username):
"name": "skel",
"configMap": {
"name": skel_mount["name"],
}
}
},
},
]
}

Expand All @@ -46,7 +46,9 @@ def base_profile_home_mounts(username):
]
}

MKDIR_OWN_DIRECTORY = "mkdir -p /mnt/{path} && chmod 777 /mnt/{path} && cp -r /etc/skel/. /mnt/{path}"
MKDIR_OWN_DIRECTORY = (
"mkdir -p /mnt/{path} && chmod 777 /mnt/{path} && cp -r /etc/skel/. /mnt/{path}"
)
command = MKDIR_OWN_DIRECTORY.format(
path=pvc_home_mount_path.format(username=username)
)
Expand Down Expand Up @@ -299,7 +301,7 @@ def configure_user(username, groups, uid=1000, gid=100):
}


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

If profile is not available for given username, groups returns
Expand All @@ -315,11 +317,21 @@ def render_profile(profile, username, groups):
}
}
"""
# check that username or groups in allowed groups for profile
user_not_in_users = username not in set(profile.get('users', []))
user_not_in_groups = (set(groups) & set(profile.get('groups', []))) == set()
if ('users' in profile or 'groups' in profile) and user_not_in_users and user_not_in_groups:
return None
access = profile.get("access", "all")

if access == "yaml":
# check that username or groups in allowed groups for profile
# profile.groups and profile.users can be None or empty lists, or may not be members of profile at all
user_not_in_users = username not in set(profile.get("users", []) or [])
user_not_in_groups = (
set(groups) & set(profile.get("groups", []) or [])
) == set()
if user_not_in_users and user_not_in_groups:
return None
elif access == "keycloak":
# Keycloak mapper should provide the 'jupyterlab_profiles' attribute from groups/user
if profile.get("display_name", None) not in keycloak_profilenames:
return None

profile = copy.copy(profile)
profile_kubespawner_override = profile.get("kubespawner_override")
Expand Down Expand Up @@ -354,10 +366,18 @@ def render_profiles(spawner):
groups = [os.path.basename(_) for _ in auth_state["oauth_user"]["groups"]]
spawner.log.error(f"user info: {username} {groups}")

keycloak_profilenames = auth_state["oauth_user"].get("jupyterlab_profiles", [])

# fetch available profiles and render additional attributes
profile_list = z2jh.get_config("custom.profiles")
return list(
filter(None, [render_profile(p, username, groups) for p in profile_list])
filter(
None,
[
render_profile(p, username, groups, keycloak_profilenames)
for p in profile_list
],
)
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,5 @@ module "jupyterhub-openid-client" {
"https://${var.external-url}/hub/oauth_callback",
var.jupyterhub-logout-redirect-url
]
jupyterlab_profiles_mapper = true
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,22 @@ resource "keycloak_openid_group_membership_protocol_mapper" "main" {
add_to_userinfo = true
}

resource "keycloak_openid_user_attribute_protocol_mapper" "jupyterlab_profiles" {
count = var.jupyterlab_profiles_mapper ? 1 : 0

realm_id = var.realm_id
client_id = keycloak_openid_client.main.id
name = "jupyterlab_profiles_mapper"
claim_name = "jupyterlab_profiles"

add_to_id_token = true
add_to_access_token = true
add_to_userinfo = true

user_attribute = "jupyterlab_profiles"
multivalued = true
aggregate_attributes = true
}

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 @@ -27,3 +27,9 @@ variable "callback-url-paths" {
description = "URLs to use for openid callback"
type = list(string)
}

variable "jupyterlab_profiles_mapper" {
description = "Create a mapper for jupyterlab_profiles group/user attributes"
type = bool
default = false
}
26 changes: 26 additions & 0 deletions qhub/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,32 @@ def _version_specific_upgrade(
return config


class Upgrade_0_4_1(UpgradeStep):
version = "0.4.1"

def _version_specific_upgrade(
self, config, start_version, config_filename: pathlib.Path, *args, **kwargs
):
"""
Upgrade jupyterlab profiles.
"""
print("\nUpgrading jupyterlab profiles in order to specify access type:\n")

profiles_jupyterlab = config.get("profiles", {}).get("jupyterlab", [])
for profile in profiles_jupyterlab:
name = profile.get("display_name", "")

if "groups" in profile or "users" in profile:
profile["access"] = "yaml"
else:
profile["access"] = "all"

print(
f"Setting access type of JupyterLab profile {name} to {profile['access']}"
)
return config


__rounded_version__ = ".".join([str(c) for c in rounded_ver_parse(__version__)])

# Manually-added upgrade steps must go above this line
Expand Down
2 changes: 1 addition & 1 deletion tests/test_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def qhub_users_import_json():
),
],
)
def test_upgrade(
def test_upgrade_4_0(
old_qhub_config_path_str,
attempt_fixes,
expect_upgrade_error,
Expand Down