From 652fabb028cf89c127a61a9ba9aa34c18d0eb662 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Fri, 29 Mar 2024 17:16:57 -0700 Subject: [PATCH 01/17] Use GenericOAuthenticator to support Auth0 The Auth0 authenticator made the EarthScope authentication mechanism look more custom than it really was - it's really just standard auth0, and if you look at the [auth0 authenticator](https://github.com/jupyterhub/oauthenticator/blob/main/oauthenticator/auth0.py) code it's fairly minimal - just a couple of convenience functions. This PR switches that (+ our auth0 documentation) to simply use GenericOAuthenticator. This has the following advantages: 1. The Auth0 documentation we have can be easily ported to just support any Generic OAuth provider if needed in the future. 2. The GenericOAuthenticator has features the Auth0 one does not - particularly around groups management that we do want to use. While eventually I think this should be made available to all authenticators (and will work with upstream in doing so), moving to GenericOAuthenticator unblocks planning & scheduling engineering work here as soon as https://github.com/2i2c-org/infrastructure/pull/3818 is merged. 3. It signals that the EarthScope hub is not *that* special, just the first as a way for us to develop and offer new features. We should work on structuring how we do this, and signal when features are available in what hubs. But in the meantime, this reduces the overall apparent complexity to match actual complexity 4. Removes the custom logout_url work, and instead just mentions you need to set the `client_id` in the logout_url, and adds that to our auth0 documentation. This removes more custom code we have for EarthScope. Once https://github.com/jupyterhub/oauthenticator/pull/719 is merged, this helps us remove all custom code here from earthscope. This part fixes https://github.com/2i2c-org/infrastructure/issues/3715 This was triggered as cleanup by https://2i2c.freshdesk.com/a/tickets/1453. I'll create appropriate issues for next steps, and will prioritize any future work accordingly with our engineering processes. --- config/clusters/earthscope/common.values.yaml | 36 ++++--------------- .../earthscope/enc-prod.secret.values.yaml | 12 +++---- .../earthscope/enc-staging.secret.values.yaml | 12 +++---- config/clusters/earthscope/prod.values.yaml | 9 ++--- .../clusters/earthscope/staging.values.yaml | 9 ++--- .../configure-auth/auth0.md | 24 +++++++++---- 6 files changed, 46 insertions(+), 56 deletions(-) diff --git a/config/clusters/earthscope/common.values.yaml b/config/clusters/earthscope/common.values.yaml index 2021e3bbe7..d5cc9a6eb2 100644 --- a/config/clusters/earthscope/common.values.yaml +++ b/config/clusters/earthscope/common.values.yaml @@ -37,11 +37,11 @@ basehub: hub: extraConfig: 001-username-claim: | - from oauthenticator.auth0 import Auth0OAuthenticator + from oauthenticator.generic import GenericOAuthenticator from traitlets import List, Unicode, default from urllib.parse import urlencode - class CustomAuth0OAuthenticator(Auth0OAuthenticator): + class CustomGenericOAuthenticator(GenericOAuthenticator): # required_scopes functionality comes in from https://github.com/jupyterhub/oauthenticator/pull/719 # Can be removed from here once that PR is merged required_scopes = List( @@ -62,28 +62,6 @@ basehub: """, ) - # Upstreamed at https://github.com/jupyterhub/oauthenticator/pull/722 - logout_redirect_to_url = Unicode( - config=True, - help=""" - Redirect to this URL after the user is logged out. - - Must be explicitly added to the "Allowed Logout URLs" in the configuration - for this Auth0 application. See https://auth0.com/docs/authenticate/login/logout/redirect-users-after-logout - for more information. - """ - ) - - @default("logout_redirect_url") - def _logout_redirect_url_default(self): - url = f"https://{self.auth0_domain}/v2/logout" - if self.logout_redirect_to_url: - # If a redirectTo is set, we must also include the `client_id` - # Auth0 expects `client_id` to be snake cased while `redirectTo` is camel cased - params = urlencode({"client_id": self.client_id, "redirectTo": self.logout_redirect_to_url}) - url = f"{url}?{params}" - return url - async def check_allowed(self, username, auth_model): if await super().check_allowed(username, auth_model): return True @@ -112,15 +90,15 @@ basehub: c.Spawner.auth_state_hook = populate_token - c.JupyterHub.authenticator_class = CustomAuth0OAuthenticator + c.JupyterHub.authenticator_class = CustomGenericOAuthenticator config: - JupyterHub: - authenticator_class: auth0 - CustomAuth0OAuthenticator: + # JupyterHub: + # authenticator_class: auth0 + CustomGenericOAuthenticator: required_scopes: # This allows EarthScope to control who can login to the hub - geolab - Auth0OAuthenticator: + GenericOAuthenticator: scope: - openid # This gives us refresh token diff --git a/config/clusters/earthscope/enc-prod.secret.values.yaml b/config/clusters/earthscope/enc-prod.secret.values.yaml index 0300bd385c..7ec0eabf06 100644 --- a/config/clusters/earthscope/enc-prod.secret.values.yaml +++ b/config/clusters/earthscope/enc-prod.secret.values.yaml @@ -2,9 +2,9 @@ basehub: jupyterhub: hub: config: - Auth0OAuthenticator: - client_id: ENC[AES256_GCM,data:qn8Xel6vzFKHuL7gP8aGKQr3C7AGORQ7sCyNvKulbDE=,iv:bWYt/w31HcaEDjUBW3DZv/Lb4Ny/BPEjoBTsjp0XP6g=,tag:/02E1lYfhfOMcd+P2+DV8Q==,type:str] - client_secret: ENC[AES256_GCM,data:qry2vIkYLTRd7rlg6RTO6pB+e4SP5mvzClqagyJbbzXYkdeiGQccVFsERQ15RT/BRDX/PX4Bj5ZxcuCY9wGsxw==,iv:k763ow53AuqWG7dSyqkaosa9O4NwufRnmmORRxssGQA=,tag:MX4zqeOS3vdxhhYUljipJA==,type:str] + GenericOAuthenticator: + client_id: ENC[AES256_GCM,data:+ctWM1MpyksEjMLTnVZAw+N0Wv6ZNXL+fHdeamt64Ow=,iv:1KBoaNQTaUmyAt1wAO9pmvOkoLCl+B2eCBIu3SsRKYA=,tag:sK3EmbdSJP3UldX51274xA==,type:str] + client_secret: ENC[AES256_GCM,data:UtnmnF84dQ50h741JNvLmfBkzoI6ui16YVV8tRh1GXyCIrcu5bgy5StRIIjw3uRVo7g7bFFdGN6lIeQIUVd+Pw==,iv:arvQ5RbKiHFFNdyksmIA9UVoHWRdXgeKAhDGPPE7qrU=,tag:2NdZqXXBpEIUhrA3oTnKPw==,type:str] sops: kms: [] gcp_kms: @@ -14,8 +14,8 @@ sops: azure_kv: [] hc_vault: [] age: [] - lastmodified: "2024-02-01T19:48:36Z" - mac: ENC[AES256_GCM,data:Cw3rTUGqQlymWXXu/Z7qLSAIlULn5B3SAPxbzkeBDCFSO8u4fhuZXEjoEBvFFdujdEtU9Q7bASKRyl4aveZDJ+aZHboKNDV77d7atONojcEFj/DIy2ELQriMwyq1hx5hZS/onGgt8XLmcjXDJdMH6zEZOYrZl93uTuoS+Qt+4GI=,iv:oCWMr+17mgo+P1btrLglokBO3yYZ9JpZBTx36Vhtb3s=,tag:GEN4T0/9KQzz4+oTxvhbBQ==,type:str] + lastmodified: "2024-03-29T23:50:57Z" + mac: ENC[AES256_GCM,data:h9pUWffgf8vBqG4timmCMharFGj1jdP8iSaaczx1GfzouUG+hhlG82OQTFVSmLwhHkzlmxJxw+t7gi6Zwx9nNgVVfnwa4Qhw6V/XWrBRr8gre2I9+MuXXeYOcjiDqIyasF0TYxGW/kvLZ6+khGvi4iIhnk9rJOk/LpFhpj7IthQ=,iv:1yIFhnW1Mv+d5bBKFGgpMDCCt5zPGfP9YekGey4KF/g=,tag:YgXBpr03lP5q9y1sJ2CsUA==,type:str] pgp: [] unencrypted_suffix: _unencrypted - version: 3.7.3 + version: 3.8.1 diff --git a/config/clusters/earthscope/enc-staging.secret.values.yaml b/config/clusters/earthscope/enc-staging.secret.values.yaml index 9ae205942a..f48a3830a8 100644 --- a/config/clusters/earthscope/enc-staging.secret.values.yaml +++ b/config/clusters/earthscope/enc-staging.secret.values.yaml @@ -2,9 +2,9 @@ basehub: jupyterhub: hub: config: - Auth0OAuthenticator: - client_id: ENC[AES256_GCM,data:urLrYypX6IUSVpqFAumEAi9aGJKyQv8oQuNqw5HNhKo=,iv:sQcq2R5wbS2P00nygxPQ3p2LdAsxkRQrk4jvnMWAjQg=,tag:eosLpXx6vWQMNjIxDcsC7Q==,type:str] - client_secret: ENC[AES256_GCM,data:PrphM7gVSfUOInO008VgfhNU4r1+I4oLRT+ypJv5848Bvy1nN+ARzTrPgu7Q3KIiCaXyfNd3Xv6ieb0lsKCLZw==,iv:Vnbo4jG0sARtOL28GxgGAKKITQb5Tx6/TNscWUNgkJU=,tag:RgxhqVzdfsmwMTTEMCn2Zw==,type:str] + GenericOAuthenticator: + client_id: ENC[AES256_GCM,data:Rpa6XhJLmHBkccOZM58T0IwcviJvc2+jbLbL3LDQxgI=,iv:57//hbKbkT8PDa1kanOoS4wlWLvc1hp8fyGgMMaUKzk=,tag:zyv29aa/M7cqar2izZDRTg==,type:str] + client_secret: ENC[AES256_GCM,data:w7feSVDwFN0mbxvLH1DEpw/eanx5+vJXZ7JPSTkVxIAm0aZod4H7lhlEy/gmMgPUJfBF32tXPrrYh6Z5E83oIQ==,iv:RQt6NCiDwAwn15XGxF7T+DVdYck0kw/hKEV9ULgxY1k=,tag:BRnwMh76yaMF7RyKPhBd/g==,type:str] sops: kms: [] gcp_kms: @@ -14,8 +14,8 @@ sops: azure_kv: [] hc_vault: [] age: [] - lastmodified: "2024-02-01T19:48:31Z" - mac: ENC[AES256_GCM,data:ZYVgv+u0FD+jxYtgyITNLXr5bHNEEkkXtTM0SJGv8txbAVM4yt1k9CF95iVevPsRGG2yztY5vTDQaFGeg0tLGmG55fuuliZhMrB9RsDkmM3qEibVgQQTQZI5ZUciWHSBGm/NCMKnj6ujIx0h3E3cjtZBESIpONH+66kbuGhAlMo=,iv:ORwgid7PCff05bxWN9FuWNCN+wLY+bVZi0GfGDZwQj4=,tag:O+Bkh0UbeDzmAvYWJ5PpKQ==,type:str] + lastmodified: "2024-03-29T23:43:10Z" + mac: ENC[AES256_GCM,data:OnvUNbNHox7iF98w1aJSnrFJ1C3FSD+dz/l7ZK1z5uBnJAyhX3FhVoDGmA3TWAtS5U+ebiz8RbbVjJ/ge687ke2dL/Lnd9Ueay2tsF4ac1BYF6i5LqqsHqzaPwkrRVazB1aRgKx/O37Plm8KuAg2o9dN8jGtjnnSlbIxgJuJIUQ=,iv:Eo0iQ6qrbbcUkPFHzBwuMBGi1fCYnVWLPkkn9GQfrig=,tag:iUbwcWtat7qkfNE5i5MU6A==,type:str] pgp: [] unencrypted_suffix: _unencrypted - version: 3.7.3 + version: 3.8.1 diff --git a/config/clusters/earthscope/prod.values.yaml b/config/clusters/earthscope/prod.values.yaml index 55c932cbe2..8aec56439d 100644 --- a/config/clusters/earthscope/prod.values.yaml +++ b/config/clusters/earthscope/prod.values.yaml @@ -12,10 +12,11 @@ basehub: name: "EarthScope" hub: config: - CustomAuth0OAuthenticator: - logout_redirect_to_url: https://geolab.earthscope.cloud - Auth0OAuthenticator: - auth0_domain: login.earthscope.org + GenericOAuthenticator: + token_url: https://login.earthscope.org/oauth/token + authorize_url: https://login.earthscope.org/authorize + userdata_url: https://login.earthscope.org/userinfo + logout_redirect_url: https://login.earthscope.org/v2/logout?client_id=2PbhUTbRU6e7uIaaEZIShotx15MbvsJJ extra_authorize_params: # This isn't an actual URL, just a string. Must not have a trailing slash audience: https://api.earthscope.org diff --git a/config/clusters/earthscope/staging.values.yaml b/config/clusters/earthscope/staging.values.yaml index 11541d14a8..91c420e16a 100644 --- a/config/clusters/earthscope/staging.values.yaml +++ b/config/clusters/earthscope/staging.values.yaml @@ -13,10 +13,11 @@ basehub: name: "EarthScope staging" hub: config: - CustomAuth0OAuthenticator: - logout_redirect_to_url: https://staging.geolab.earthscope.cloud - Auth0OAuthenticator: - auth0_domain: login-dev.earthscope.org + GenericOAuthenticator: + token_url: https://login-dev.earthscope.org/oauth/token + authorize_url: https://login-dev.earthscope.org/authorize + userdata_url: https://login-dev.earthscope.org/userinfo + logout_redirect_url: https://login-dev.earthscope.org/v2/logout?client_id=Kn6kSKtw9TqgrSrEmDS0rlBM7Sc69BkL extra_authorize_params: # This isn't an actual URL, just a string. Must not have a trailing slash audience: https://api.dev.earthscope.org diff --git a/docs/hub-deployment-guide/configure-auth/auth0.md b/docs/hub-deployment-guide/configure-auth/auth0.md index ff5f34d6a1..80a2d222d6 100644 --- a/docs/hub-deployment-guide/configure-auth/auth0.md +++ b/docs/hub-deployment-guide/configure-auth/auth0.md @@ -43,17 +43,20 @@ administer. Solutions (potentially a shared account) are being explored. ## Configuring the JupyterHub to use Auth0 -We will use the upstream [Auth0OAuthenticator](https://github.com/jupyterhub/oauthenticator/blob/main/oauthenticator/auth0.py) -to allow folks to login to JupyterHub. +While there is an upstream [Auth0OAuthenticator](https://github.com/jupyterhub/oauthenticator/blob/main/oauthenticator/auth0.py), +it doesn't have any specific features that aren't in the upstream [GenericOAuthenticator](https://oauthenticator.readthedocs.io/en/latest/reference/api/gen/oauthenticator.generic.html), +and is missing some features that are present in the GenericOAuthenticator. Using the GenericOAuthenticator +here also allows us to support other Generic OAuth providers in the future, and not tie ourselves down +to Auth0. -In the `common.yaml` file for the cluster hosting the hubs, we set the authenticator to be `auth0`. +In the `common.yaml` file for the cluster hosting the hubs, we set the authenticator to be `generic`. ```yaml jupyterhub: hub: config: JupyterHub: - authenticator_class: auth0 + authenticator_class: generic ``` In the encrypted, per-hub config (of form `enc-.secret.values.yaml`), we specify the secret values @@ -63,9 +66,10 @@ we received from the community. jupyterhub: hub: config: - Auth0OAuthenticator: + GenericOAuthenticator: client_id: client_secret: + logout_redirect_url: https:///v2/logout?client_id= ``` And in the *unencrypted*, per-hub config (of form `.values.yaml`), we specify the non-secret @@ -75,12 +79,18 @@ config values. jupyterhub: hub: config: - Auth0OAuthenticator: - auth0_domain: + GenericOAuthenticator: + token_url: https:///oauth/token + authorize_url: https:///authorize + userdata_url: https:///userinfo scope: openid username_claim: sub ``` +Auth0 has documentation for the [userinfo](https://auth0.com/docs/api/authentication#get-user-info), +[token](https://auth0.com/docs/api/authentication#authenticate-user) and [authorize](https://auth0.com/docs/api/authentication#social) +endpoints. + Once deployed, this should allow users authorized by Auth0 to login to the hub! Their usernames will look like `:`, which looks a little strange but allows differentiation between people who use multiple accounts but the same email. \ No newline at end of file From 13ef8aa3658a9ef1b6abed61ae8e19b324d40d57 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Fri, 29 Mar 2024 18:04:58 -0700 Subject: [PATCH 02/17] Remove some unused imports --- config/clusters/earthscope/common.values.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/clusters/earthscope/common.values.yaml b/config/clusters/earthscope/common.values.yaml index d5cc9a6eb2..5a30214f59 100644 --- a/config/clusters/earthscope/common.values.yaml +++ b/config/clusters/earthscope/common.values.yaml @@ -38,8 +38,7 @@ basehub: extraConfig: 001-username-claim: | from oauthenticator.generic import GenericOAuthenticator - from traitlets import List, Unicode, default - from urllib.parse import urlencode + from traitlets import List, Unicode class CustomGenericOAuthenticator(GenericOAuthenticator): # required_scopes functionality comes in from https://github.com/jupyterhub/oauthenticator/pull/719 From 88a9b56bca99e97909ab7b4c99ee05fc01ac67c3 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Fri, 5 Apr 2024 15:13:02 -0700 Subject: [PATCH 03/17] Turn 'scope' from OAuth2 response into JupyterHub groups --- config/clusters/earthscope/common.values.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/clusters/earthscope/common.values.yaml b/config/clusters/earthscope/common.values.yaml index 5a30214f59..1492cd7f02 100644 --- a/config/clusters/earthscope/common.values.yaml +++ b/config/clusters/earthscope/common.values.yaml @@ -111,6 +111,9 @@ basehub: # This isn't an actual URL, just a string. Must not have a trailing slash audience: https://api.dev.earthscope.org username_claim: sub + # Convert 'scope' from the OAuth2 response into JupyterHub groups + manage_groups: true + claim_groups_key: 'scope' CILogonOAuthenticator: allowed_idps: http://github.com/login/oauth/authorize: From 1ec690f634a9d944a4c2c266be2148284c791d6d Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Fri, 5 Apr 2024 15:23:20 -0700 Subject: [PATCH 04/17] Filter profiles by JupyterHub groups --- config/clusters/earthscope/common.values.yaml | 92 ++++++++++++++++++- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/config/clusters/earthscope/common.values.yaml b/config/clusters/earthscope/common.values.yaml index 1492cd7f02..cb686a997c 100644 --- a/config/clusters/earthscope/common.values.yaml +++ b/config/clusters/earthscope/common.values.yaml @@ -76,6 +76,12 @@ basehub: return False + async def authenticate(self, *args, **kwargs): + resp = await super().authenticate(*args, **kwargs) + # Set scope to groups + resp["groups"] = resp["auth_state"]["scope"] + return resp + def populate_token(spawner, auth_state): # For our deployment-service-check health check user, there is no auth_state. # So these env variables need not be set. @@ -89,7 +95,75 @@ basehub: c.Spawner.auth_state_hook = populate_token + c.GenericOAuthenticator.manage_groups = True + c.JupyterHub.authenticator_class = CustomGenericOAuthenticator + + 05-gh-teams: | + # Filters profileList based on group membership of JupyterHubs + import copy + + from textwrap import dedent + from tornado import web + from oauthenticator.github import GitHubOAuthenticator + + original_profile_list = c.KubeSpawner.profile_list + + async def profile_list_allowed_groups_filter(spawner): + """ + Returns the initially configured profile_list filtered based on the + user's membership in each profile's `allowed_groups`. If + `allowed_groups` isn't set for a profile, its not filtered out. + + `allowed_groups` is a list of JupyterHub groups. + + If the returned profile_list is filtered to not include a profile, + an error is raised and the user isn't allowed to start a server. + """ + if spawner.user.name == "deployment-service-check": + print("Ignoring allowed_teams check for deployment-service-check") + return original_profile_list + + groups = {g.name.casefold() for g in spawner.user.groups} + print(f"User {spawner.user.name} is part of groups {groups}") + + # Filter out profiles with allowed_groups set if the user isn't part of any. + allowed_profiles = [] + for profile in copy.deepcopy(original_profile_list): + allowed_groups = set(profile.get("allowed_groups")) + if allowed_groups is None: + # If no allowed_groups are set, allow access to everything + allowed_profiles.append(profile) + continue + + if allowed_groups & groups: + print(f"Allowing profile {profile['display_name']} for user {spawner.user.name} based on group membership") + allowed_profiles.append(profile) + continue + + if len(allowed_profiles) == 0: + # If no profiles are allowed, user should not be able to spawn anything! + # If we don't explicitly stop this, user will be logged into the 'default' settings + # set in singleuser, without any profile overrides. Not desired behavior + # FIXME: User doesn't actually see this error message, just the generic 403. + error_msg = dedent(f""" + Your Group team membership is insufficient to launch any server profiles. + + GitHub teams you are a member of that this JupyterHub knows about are {', '.join(groups)}. + + If you are part of additional teams, log out of this JupyterHub and log back in to refresh that information. + """) + raise web.HTTPError(403, error_msg) + + return allowed_profiles + + # Only set this customized profile_list *if* we already have a profile_list set + # otherwise, we'll show users a blank server options form and they won't be able to + # start their server + if c.KubeSpawner.profile_list: + # Customize list of profiles dynamically, rather than override options form. + # This is more secure, as users can't override the options available to them via the hub API + c.KubeSpawner.profile_list = profile_list_allowed_groups_filter config: # JupyterHub: # authenticator_class: auth0 @@ -113,7 +187,7 @@ basehub: username_claim: sub # Convert 'scope' from the OAuth2 response into JupyterHub groups manage_groups: true - claim_groups_key: 'scope' + # claim_groups_key: 'scope' CILogonOAuthenticator: allowed_idps: http://github.com/login/oauth/authorize: @@ -132,6 +206,10 @@ basehub: profileList: - display_name: "Shared Small: 1-4 CPU, 8-32 GB" description: "A shared machine, the recommended option until you experience a limitation." + allowed_groups: + - geolab + - geolab:dev + - geolab:power profile_options: &profile_options image: display_name: Image @@ -166,9 +244,12 @@ basehub: mem_limit: null node_selector: node.kubernetes.io/instance-type: r5.xlarge - - display_name: "Small: 4 CPU, 32 GB" description: "A dedicated machine for you." + allowed_groups: + - geolab + - geolab:dev + - geolab:power profile_options: *profile_options kubespawner_override: mem_guarantee: 28.937G @@ -176,20 +257,23 @@ basehub: mem_limit: null node_selector: node.kubernetes.io/instance-type: r5.xlarge - - display_name: "Medium: 16 CPU, 128 GB" description: "A dedicated machine for you." profile_options: *profile_options + allowed_groups: + - geolab:dev + - geolab:power kubespawner_override: mem_guarantee: 120.513G cpu_guarantee: 1.6 mem_limit: null node_selector: node.kubernetes.io/instance-type: r5.4xlarge - - display_name: "Large: 64 CPU, 512 GB" description: "A dedicated machine for you" profile_options: *profile_options + allowed_groups: + - geolab:power kubespawner_override: mem_guarantee: 489.13G cpu_guarantee: 6.4 From 82ca8c01e77250b87ee2a0dc232906abb49359fd Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Thu, 18 Apr 2024 12:17:04 -0700 Subject: [PATCH 05/17] Request geolab scopes required --- config/clusters/earthscope/common.values.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/clusters/earthscope/common.values.yaml b/config/clusters/earthscope/common.values.yaml index cb686a997c..131d51958e 100644 --- a/config/clusters/earthscope/common.values.yaml +++ b/config/clusters/earthscope/common.values.yaml @@ -181,6 +181,8 @@ basehub: # automatically granted this scope, so we can test. See # https://2i2c.freshdesk.com/a/tickets/1280 for how this was granted. - geolab + - geolab:dev + - geolab:power extra_authorize_params: # This isn't an actual URL, just a string. Must not have a trailing slash audience: https://api.dev.earthscope.org From 72f7ac25a8147adf6ac5097f3d12e0180fc0e049 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 30 Apr 2024 22:37:52 +0000 Subject: [PATCH 06/17] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- config/clusters/earthscope/common.values.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/config/clusters/earthscope/common.values.yaml b/config/clusters/earthscope/common.values.yaml index 131d51958e..83820c2297 100644 --- a/config/clusters/earthscope/common.values.yaml +++ b/config/clusters/earthscope/common.values.yaml @@ -209,9 +209,9 @@ basehub: - display_name: "Shared Small: 1-4 CPU, 8-32 GB" description: "A shared machine, the recommended option until you experience a limitation." allowed_groups: - - geolab - - geolab:dev - - geolab:power + - geolab + - geolab:dev + - geolab:power profile_options: &profile_options image: display_name: Image @@ -249,9 +249,9 @@ basehub: - display_name: "Small: 4 CPU, 32 GB" description: "A dedicated machine for you." allowed_groups: - - geolab - - geolab:dev - - geolab:power + - geolab + - geolab:dev + - geolab:power profile_options: *profile_options kubespawner_override: mem_guarantee: 28.937G @@ -263,8 +263,8 @@ basehub: description: "A dedicated machine for you." profile_options: *profile_options allowed_groups: - - geolab:dev - - geolab:power + - geolab:dev + - geolab:power kubespawner_override: mem_guarantee: 120.513G cpu_guarantee: 1.6 @@ -275,7 +275,7 @@ basehub: description: "A dedicated machine for you" profile_options: *profile_options allowed_groups: - - geolab:power + - geolab:power kubespawner_override: mem_guarantee: 489.13G cpu_guarantee: 6.4 From 10f3c280435df007299e51d8462a7e5e4fafb695 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Tue, 30 Apr 2024 18:46:40 -0700 Subject: [PATCH 07/17] Cleanup how copy of original profile list is accessed Otherwise, there were issues with closure capture in earthscope - somehow, original_profile_list was actually still being magically set to the function, even though it works fine in gh-teams set in basehub/values.yaml! I suspect some traitlets magic. Instead of debugging it through, let's partial our way out. Also more cleanly set the profile_list override in gh-teams, so the overriding function is not set unless necessary. --- config/clusters/earthscope/common.values.yaml | 18 ++++---- helm-charts/basehub/values.yaml | 43 ++++++++++++------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/config/clusters/earthscope/common.values.yaml b/config/clusters/earthscope/common.values.yaml index 83820c2297..3cec0f4af0 100644 --- a/config/clusters/earthscope/common.values.yaml +++ b/config/clusters/earthscope/common.values.yaml @@ -99,17 +99,15 @@ basehub: c.JupyterHub.authenticator_class = CustomGenericOAuthenticator - 05-gh-teams: | + 002-group-profiles: | # Filters profileList based on group membership of JupyterHubs - import copy + from copy import deepcopy from textwrap import dedent from tornado import web - from oauthenticator.github import GitHubOAuthenticator + from functools import partial - original_profile_list = c.KubeSpawner.profile_list - - async def profile_list_allowed_groups_filter(spawner): + async def profile_list_allowed_groups_filter(original_profile_list, spawner): """ Returns the initially configured profile_list filtered based on the user's membership in each profile's `allowed_groups`. If @@ -129,7 +127,7 @@ basehub: # Filter out profiles with allowed_groups set if the user isn't part of any. allowed_profiles = [] - for profile in copy.deepcopy(original_profile_list): + for profile in original_profile_list: allowed_groups = set(profile.get("allowed_groups")) if allowed_groups is None: # If no allowed_groups are set, allow access to everything @@ -163,7 +161,11 @@ basehub: if c.KubeSpawner.profile_list: # Customize list of profiles dynamically, rather than override options form. # This is more secure, as users can't override the options available to them via the hub API - c.KubeSpawner.profile_list = profile_list_allowed_groups_filter + # We pass in a copy of the original profile_list set in config via partial, to reduce possible variable + # capture related issues. + c.KubeSpawner.profile_list = partial( + profile_list_allowed_groups_filter, deepcopy(c.KubeSpawner.profile_list) + ) config: # JupyterHub: # authenticator_class: auth0 diff --git a/helm-charts/basehub/values.yaml b/helm-charts/basehub/values.yaml index f7c7f0fb5e..0a7580db5a 100644 --- a/helm-charts/basehub/values.yaml +++ b/helm-charts/basehub/values.yaml @@ -396,7 +396,7 @@ jupyterhub: ) if proc.returncode == 0: return True - return False + return False def main(): @@ -1000,15 +1000,15 @@ jupyterhub: # requires Authenticator.enable_auth_state to be True as well. # - The user is a normal user, and not "deployment-service-check". # - import copy + from copy import deepcopy + from functools import partial from textwrap import dedent from tornado import web from oauthenticator.github import GitHubOAuthenticator + from z2jh import get_config - original_profile_list = c.KubeSpawner.profile_list - - async def profile_list_allowed_teams_filter(spawner): + async def profile_list_allowed_teams_filter(original_profile_list, spawner): """ Returns the initially configured profile_list filtered based on the user's membership in each profile's `allowed_teams`. If @@ -1020,11 +1020,6 @@ jupyterhub: If the returned profile_list is filtered to not include a profile, an error is raised and the user isn't allowed to start a server. """ - # Ensure GitHubOAuthenticator with populate_teams_in_auth_state set - if not isinstance(spawner.authenticator, GitHubOAuthenticator): - return original_profile_list - if not spawner.authenticator.populate_teams_in_auth_state: - return original_profile_list if spawner.user.name == "deployment-service-check": print("Ignoring allowed_teams check for deployment-service-check") return original_profile_list @@ -1044,7 +1039,7 @@ jupyterhub: # Filter out profiles with allowed_teams set if the user isn't part # of any. allowed_profiles = [] - for profile in copy.deepcopy(original_profile_list): + for profile in original_profile_list: allowed_teams = profile.get("allowed_teams") if allowed_teams is None: allowed_profiles.append(profile) @@ -1075,13 +1070,29 @@ jupyterhub: return allowed_profiles - # Only set this customized profile_list *if* we already have a profile_list set - # otherwise, we'll show users a blank server options form and they won't be able to - # start their server - if c.KubeSpawner.profile_list: + # Check if GitHubOAuthenticator is being used, via one of the 4 ways it can be specified + is_github_auth = c.JupyterHub.authenticator_class in ( + "github", "oauthenticator.GitHubOAuthenticator", "oauthenticator.github.GitHubOAuthenticator", + GitHubOAuthenticator) + + # Check that teams are going to be populated in auth_state + is_teams_populated = c.GitHubOAuthenticator.populate_teams_in_auth_state and ( + c.Authenticator.enable_auth_state or c.GitHubOAuthenticator.enable_auth_state + ) + + # Only use this customized profile_list function if: + # 1. We are using GitHubOAuthenticator + # 2. GitHub teams membership is populated in auth_state + # 3. profile_list is specified (otherwise users will get an empty screen when trying to launch servers) + if is_github_auth and is_teams_populated and c.KubeSpawner.profile_list: # Customize list of profiles dynamically, rather than override options form. # This is more secure, as users can't override the options available to them via the hub API - c.KubeSpawner.profile_list = profile_list_allowed_teams_filter + # We pass in a copy of the original profile_list set in config via partial, to reduce possible variable + # capture related issues. + c.KubeSpawner.profile_list = partial( + profile_list_allowed_teams_filter, + deepcopy(c.KubeSpawner.profile_list) + ) 06-salted-username: | # Allow anonymizing username to not store *any* PII From cc6493bdc1999d872f834330b9065452678af01f Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 1 May 2024 18:59:55 -0700 Subject: [PATCH 08/17] Allow restricting profile_options based on team / group membership --- config/clusters/earthscope/common.values.yaml | 15 ++++++++- helm-charts/basehub/values.yaml | 31 ++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/config/clusters/earthscope/common.values.yaml b/config/clusters/earthscope/common.values.yaml index 3cec0f4af0..66f360c098 100644 --- a/config/clusters/earthscope/common.values.yaml +++ b/config/clusters/earthscope/common.values.yaml @@ -127,8 +127,21 @@ basehub: # Filter out profiles with allowed_groups set if the user isn't part of any. allowed_profiles = [] - for profile in original_profile_list: + for orig_profile in original_profile_list: + profile = deepcopy(orig_profile) + if 'profile_options' in profile: + for k, po in profile['profile_options'].items(): + if 'choices' in po: + new_choices = {} + for k, c in po['choices'].items(): + if 'allowed_teams' not in c: + new_choices[k] = c + elif set(c['allowed_teams']) & groups: + new_choices[k] = c + po['choices'] = new_choices + allowed_groups = set(profile.get("allowed_groups")) + if allowed_groups is None: # If no allowed_groups are set, allow access to everything allowed_profiles.append(profile) diff --git a/helm-charts/basehub/values.yaml b/helm-charts/basehub/values.yaml index 0a7580db5a..b0b18e6b76 100644 --- a/helm-charts/basehub/values.yaml +++ b/helm-charts/basehub/values.yaml @@ -1039,7 +1039,36 @@ jupyterhub: # Filter out profiles with allowed_teams set if the user isn't part # of any. allowed_profiles = [] - for profile in original_profile_list: + for original_profile in original_profile_list: + # Make a copy, as we'll be modifying this profile + profile = deepcopy(original_profile) + + # Handle `allowed_teams` specified in profile_options + if 'profile_options' in profile: + for k, po in profile['profile_options'].items(): + + # If `unlisted_choice` has an `allowed_teams` and the current + # user is not present in any of those teams, we delete the + # `unlisted_choice` config entirely for this option. The user + # will then not be allowed to 'write in' a value. + if 'unlisted_choice' in po: + if 'allowed_teams' in po['unlisted_choice']: + if (set(po['unlisted_choice']['allowed_teams']) and teams): + del po['unlisted_choice'] + + if 'choices' in po: + new_choices = {} + for k, c in po['choices'].items(): + # If `allowed_teams` is not set for a profile option, it is automatically + # allowed for everyone + if 'allowed_teams' not in c: + new_choices[k] = c + # If `allowed_teams` *is* set for a profile option, it is allowed only for + # members of that team. + elif set(c['allowed_teams']) & teams: + new_choices[k] = c + po['choices'] = new_choices + allowed_teams = profile.get("allowed_teams") if allowed_teams is None: allowed_profiles.append(profile) From 6c7fc18a44c104ca9a2592fad3efe1416381257c Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 1 May 2024 19:38:36 -0700 Subject: [PATCH 09/17] Make `allowed_groups` available to everyone - `allowed_groups` functionality is put in basehub, and hence available to everyone! Individual authenticators still need to figure out how to enable groups, but that's separated out from `profile_list` filtering functionality. - Pending https://github.com/jupyterhub/oauthenticator/pull/735, we explicitly also treat GitHub teams from auth_state as 'groups'. This allows us to bring all our existing users along, without issue. - Get rid of the code duplication in earthscope - Rename all `allowed_teams` to `allowed_groups`. --- .../2i2c-aws-us/itcoocean.values.yaml | 2 +- .../clusters/2i2c-aws-us/showcase.values.yaml | 16 +-- config/clusters/earthscope/common.values.yaml | 81 ------------- config/clusters/leap/common.values.yaml | 8 +- config/clusters/meom-ige/common.values.yaml | 12 +- config/clusters/nasa-cryo/common.values.yaml | 8 +- config/clusters/openscapes/common.values.yaml | 8 +- .../clusters/pangeo-hubs/common.values.yaml | 8 +- .../clusters/smithsonian/common.values.yaml | 2 +- .../features/allow-unlisted-profile-choice.md | 4 +- .../configure-auth/github-orgs.md | 12 +- helm-charts/basehub/values.yaml | 108 ++++++++---------- 12 files changed, 90 insertions(+), 179 deletions(-) diff --git a/config/clusters/2i2c-aws-us/itcoocean.values.yaml b/config/clusters/2i2c-aws-us/itcoocean.values.yaml index e4ca3a6525..8a4f38feb1 100644 --- a/config/clusters/2i2c-aws-us/itcoocean.values.yaml +++ b/config/clusters/2i2c-aws-us/itcoocean.values.yaml @@ -248,7 +248,7 @@ jupyterhub: - display_name: "Bring your own image" description: Specify your own docker image (must have python and jupyterhub installed in it) slug: custom - allowed_teams: + allowed_groups: - Hackweek-ITCOocean:itcoocean-hackweek-2023 - nmfs-opensci:2i2c-demo - 2i2c-org:hub-access-for-2i2c-staff diff --git a/config/clusters/2i2c-aws-us/showcase.values.yaml b/config/clusters/2i2c-aws-us/showcase.values.yaml index 8809eff4d3..ef0d1d0644 100644 --- a/config/clusters/2i2c-aws-us/showcase.values.yaml +++ b/config/clusters/2i2c-aws-us/showcase.values.yaml @@ -58,7 +58,7 @@ basehub: profileList: - display_name: "Magic Link Demo" description: "For demoing magic links" - allowed_teams: + allowed_groups: - 2i2c-community-showcase:magiclinks-demo kubespawner_override: image: pangeo/pangeo-notebook:2023.06.20 @@ -69,7 +69,7 @@ basehub: node.kubernetes.io/instance-type: r5.xlarge - display_name: "NASA TOPS-T ScienceCore-ClimateRisk" description: "For collaborative work on 2i2c/MD's NASA TOPS-T ScienceCore Module" - allowed_teams: + allowed_groups: - 2i2c-demo-hub-access:showcase-topst - 2i2c-org:hub-access-for-2i2c-staff - ScienceCore:climaterisk-team @@ -82,7 +82,7 @@ basehub: node.kubernetes.io/instance-type: r5.xlarge - display_name: "NASA TOPS-T ScienceCore" description: "JupyterHubs for NASA ScienceCore Modules" - allowed_teams: + allowed_groups: - 2i2c-org:hub-access-for-2i2c-staff - ScienceCore:2i2c-showcase profile_options: @@ -132,7 +132,7 @@ basehub: node.kubernetes.io/instance-type: r5.xlarge - display_name: "Shared Small: 1-4 CPU, 8-32 GB" description: "A shared machine, the recommended option until you experience a limitation." - allowed_teams: &allowed_teams + allowed_groups: &allowed_groups - 2i2c-org:hub-access-for-2i2c-staff - 2i2c-community-showcase:access-2i2c-showcase profile_options: &profile_options @@ -188,7 +188,7 @@ basehub: - display_name: "Small: 4 CPU, 32 GB" description: "A dedicated machine for you." profile_options: *profile_options - allowed_teams: *allowed_teams + allowed_groups: *allowed_groups kubespawner_override: mem_guarantee: 28.937G cpu_guarantee: 0.4 @@ -199,7 +199,7 @@ basehub: - display_name: "Medium: 16 CPU, 128 GB" description: "A dedicated machine for you." profile_options: *profile_options - allowed_teams: *allowed_teams + allowed_groups: *allowed_groups kubespawner_override: mem_guarantee: 120.513G cpu_guarantee: 1.6 @@ -210,7 +210,7 @@ basehub: - display_name: "Large: 64 CPU, 512 GB" description: "A dedicated machine for you" profile_options: *profile_options - allowed_teams: *allowed_teams + allowed_groups: *allowed_groups kubespawner_override: mem_guarantee: 489.13G cpu_guarantee: 6.4 @@ -220,7 +220,7 @@ basehub: - display_name: NVIDIA Tesla T4, ~16 GB, ~4 CPUs slug: gpu - allowed_teams: + allowed_groups: - 2i2c-org:hub-access-for-2i2c-staff description: "Start a container on a dedicated node with a GPU" profile_options: diff --git a/config/clusters/earthscope/common.values.yaml b/config/clusters/earthscope/common.values.yaml index 66f360c098..4d0892b17a 100644 --- a/config/clusters/earthscope/common.values.yaml +++ b/config/clusters/earthscope/common.values.yaml @@ -98,87 +98,6 @@ basehub: c.GenericOAuthenticator.manage_groups = True c.JupyterHub.authenticator_class = CustomGenericOAuthenticator - - 002-group-profiles: | - # Filters profileList based on group membership of JupyterHubs - from copy import deepcopy - - from textwrap import dedent - from tornado import web - from functools import partial - - async def profile_list_allowed_groups_filter(original_profile_list, spawner): - """ - Returns the initially configured profile_list filtered based on the - user's membership in each profile's `allowed_groups`. If - `allowed_groups` isn't set for a profile, its not filtered out. - - `allowed_groups` is a list of JupyterHub groups. - - If the returned profile_list is filtered to not include a profile, - an error is raised and the user isn't allowed to start a server. - """ - if spawner.user.name == "deployment-service-check": - print("Ignoring allowed_teams check for deployment-service-check") - return original_profile_list - - groups = {g.name.casefold() for g in spawner.user.groups} - print(f"User {spawner.user.name} is part of groups {groups}") - - # Filter out profiles with allowed_groups set if the user isn't part of any. - allowed_profiles = [] - for orig_profile in original_profile_list: - profile = deepcopy(orig_profile) - if 'profile_options' in profile: - for k, po in profile['profile_options'].items(): - if 'choices' in po: - new_choices = {} - for k, c in po['choices'].items(): - if 'allowed_teams' not in c: - new_choices[k] = c - elif set(c['allowed_teams']) & groups: - new_choices[k] = c - po['choices'] = new_choices - - allowed_groups = set(profile.get("allowed_groups")) - - if allowed_groups is None: - # If no allowed_groups are set, allow access to everything - allowed_profiles.append(profile) - continue - - if allowed_groups & groups: - print(f"Allowing profile {profile['display_name']} for user {spawner.user.name} based on group membership") - allowed_profiles.append(profile) - continue - - if len(allowed_profiles) == 0: - # If no profiles are allowed, user should not be able to spawn anything! - # If we don't explicitly stop this, user will be logged into the 'default' settings - # set in singleuser, without any profile overrides. Not desired behavior - # FIXME: User doesn't actually see this error message, just the generic 403. - error_msg = dedent(f""" - Your Group team membership is insufficient to launch any server profiles. - - GitHub teams you are a member of that this JupyterHub knows about are {', '.join(groups)}. - - If you are part of additional teams, log out of this JupyterHub and log back in to refresh that information. - """) - raise web.HTTPError(403, error_msg) - - return allowed_profiles - - # Only set this customized profile_list *if* we already have a profile_list set - # otherwise, we'll show users a blank server options form and they won't be able to - # start their server - if c.KubeSpawner.profile_list: - # Customize list of profiles dynamically, rather than override options form. - # This is more secure, as users can't override the options available to them via the hub API - # We pass in a copy of the original profile_list set in config via partial, to reduce possible variable - # capture related issues. - c.KubeSpawner.profile_list = partial( - profile_list_allowed_groups_filter, deepcopy(c.KubeSpawner.profile_list) - ) config: # JupyterHub: # authenticator_class: auth0 diff --git a/config/clusters/leap/common.values.yaml b/config/clusters/leap/common.values.yaml index 44536212a7..1090165911 100644 --- a/config/clusters/leap/common.values.yaml +++ b/config/clusters/leap/common.values.yaml @@ -132,7 +132,7 @@ basehub: description: &profile_list_description "Start a container limited to a chosen share of capacity on a node of this type" slug: medium-full default: true - allowed_teams: + allowed_groups: - 2i2c-org:hub-access-for-2i2c-staff - leap-stc:leap-pangeo-full-access profile_options: @@ -235,13 +235,13 @@ basehub: # NOTE: This is the second medium profile list entry, with less node # share options for a different subset of users via the basehub - # specific allowed_teams configuration. + # specific allowed_groups configuration. # - display_name: "CPU only" description: *profile_list_description slug: medium-base default: true - allowed_teams: + allowed_groups: - leap-stc:leap-pangeo-base-access profile_options: requests: @@ -262,7 +262,7 @@ basehub: - display_name: GPU slug: gpu description: NVIDIA Tesla T4, 24GB RAM, 8 CPUs - allowed_teams: + allowed_groups: - 2i2c-org:hub-access-for-2i2c-staff - leap-stc:leap-pangeo-full-access profile_options: diff --git a/config/clusters/meom-ige/common.values.yaml b/config/clusters/meom-ige/common.values.yaml index f1edae137b..05fd095572 100644 --- a/config/clusters/meom-ige/common.values.yaml +++ b/config/clusters/meom-ige/common.values.yaml @@ -45,7 +45,7 @@ basehub: # # - display_name: Grenoble demo # default: true - # allowed_teams: + # allowed_groups: # - 2i2c-org:hub-access-for-2i2c-staff # - meom-group:hub-users # long term users # - demo-dask-grenoble2023:demo # temporary users for event @@ -93,7 +93,7 @@ basehub: # RAM on a node, not total node capacity - display_name: "Small" default: true - allowed_teams: &allowed_teams_normal_use + allowed_groups: &allowed_groups_normal_use - 2i2c-org:hub-access-for-2i2c-staff - meom-group:hub-users # long term users description: "~2 CPU, ~8G RAM" @@ -103,7 +103,7 @@ basehub: node_selector: node.kubernetes.io/instance-type: n1-standard-2 - display_name: "Medium" - allowed_teams: *allowed_teams_normal_use + allowed_groups: *allowed_groups_normal_use description: "~8 CPU, ~32G RAM" kubespawner_override: mem_limit: 32G @@ -111,7 +111,7 @@ basehub: node_selector: node.kubernetes.io/instance-type: n1-standard-8 - display_name: "Large" - allowed_teams: *allowed_teams_normal_use + allowed_groups: *allowed_groups_normal_use description: "~16 CPU, ~64G RAM" kubespawner_override: mem_limit: 64G @@ -119,7 +119,7 @@ basehub: node_selector: node.kubernetes.io/instance-type: n1-standard-16 - display_name: "Very Large" - allowed_teams: *allowed_teams_normal_use + allowed_groups: *allowed_groups_normal_use description: "~32 CPU, ~128G RAM" kubespawner_override: mem_limit: 128G @@ -127,7 +127,7 @@ basehub: node_selector: node.kubernetes.io/instance-type: n1-standard-32 - display_name: "Huge" - allowed_teams: *allowed_teams_normal_use + allowed_groups: *allowed_groups_normal_use description: "~64 CPU, ~256G RAM" kubespawner_override: mem_limit: 256G diff --git a/config/clusters/nasa-cryo/common.values.yaml b/config/clusters/nasa-cryo/common.values.yaml index 1f45fffe35..c0b578a146 100644 --- a/config/clusters/nasa-cryo/common.values.yaml +++ b/config/clusters/nasa-cryo/common.values.yaml @@ -119,7 +119,7 @@ basehub: description: &profile_list_description "Start a container with at least a chosen share of capacity on a node of this type" slug: small default: true - allowed_teams: + allowed_groups: - 2i2c-org:hub-access-for-2i2c-staff - CryoInTheCloud:cryoclouduser - CryoInTheCloud:cryocloudadvanced @@ -200,7 +200,7 @@ basehub: - display_name: "Medium: up to 16 CPU / 128 GB RAM" description: *profile_list_description slug: medium - allowed_teams: + allowed_groups: - 2i2c-org:hub-access-for-2i2c-staff - CryoInTheCloud:cryocloudadvanced - CryoInTheCloud:ml-in-glaciology @@ -268,7 +268,7 @@ basehub: # - display_name: "Large: up to 64 CPU / 512 GB RAM" # description: *profile_list_description # slug: large - # allowed_teams: + # allowed_groups: # - 2i2c-org:hub-access-for-2i2c-staff # - CryoInTheCloud:cryocloudadvanced # profile_options: @@ -328,7 +328,7 @@ basehub: - display_name: NVIDIA Tesla T4, ~16 GB, ~4 CPUs description: "Start a container on a dedicated node with a GPU" slug: "gpu" - allowed_teams: + allowed_groups: - 2i2c-org:hub-access-for-2i2c-staff - CryoInTheCloud:ml-in-glaciology profile_options: diff --git a/config/clusters/openscapes/common.values.yaml b/config/clusters/openscapes/common.values.yaml index 138242ec7a..ed41fb9779 100644 --- a/config/clusters/openscapes/common.values.yaml +++ b/config/clusters/openscapes/common.values.yaml @@ -45,7 +45,7 @@ basehub: - display_name: Python description: Python datascience environment default: true - allowed_teams: + allowed_groups: - 2i2c-org:hub-access-for-2i2c-staff - NASA-Openscapes:workshopaccess-2i2c # legacy but no plans to delete immediately until fledged - NASA-Openscapes:longtermaccess-2i2c @@ -128,7 +128,7 @@ basehub: node.kubernetes.io/instance-type: r5.4xlarge - display_name: R description: R (with RStudio) + Python environment - allowed_teams: + allowed_groups: - 2i2c-org:hub-access-for-2i2c-staff - NASA-Openscapes:workshopaccess-2i2c - NASA-Openscapes:longtermaccess-2i2c @@ -147,7 +147,7 @@ basehub: profile_options: *profile_options - display_name: Matlab description: Matlab environment - allowed_teams: + allowed_groups: - 2i2c-org:hub-access-for-2i2c-staff - NASA-Openscapes:workshopaccess-2i2c - NASA-Openscapes:longtermaccess-2i2c @@ -164,7 +164,7 @@ basehub: - display_name: "Bring your own image" description: Specify your own docker image (must have python and jupyterhub installed in it) slug: custom - allowed_teams: + allowed_groups: - NASA-Openscapes:longtermaccess-2i2c - 2i2c-org:hub-access-for-2i2c-staff # Requested in: https://2i2c.freshdesk.com/a/tickets/1284 diff --git a/config/clusters/pangeo-hubs/common.values.yaml b/config/clusters/pangeo-hubs/common.values.yaml index 96543bb828..3bb38ef5c4 100644 --- a/config/clusters/pangeo-hubs/common.values.yaml +++ b/config/clusters/pangeo-hubs/common.values.yaml @@ -62,7 +62,7 @@ basehub: - display_name: "Small" description: 5GB RAM, 2 CPUs default: true - allowed_teams: + allowed_groups: - pangeo-data:us-central1-b-gcp - 2i2c-org:hub-access-for-2i2c-staff kubespawner_override: @@ -72,7 +72,7 @@ basehub: node.kubernetes.io/instance-type: n1-standard-2 - display_name: Medium description: 11GB RAM, 4 CPUs - allowed_teams: + allowed_groups: - pangeo-data:us-central1-b-gcp - 2i2c-org:hub-access-for-2i2c-staff kubespawner_override: @@ -82,7 +82,7 @@ basehub: node.kubernetes.io/instance-type: n1-standard-4 - display_name: Large description: 24GB RAM, 8 CPUs - allowed_teams: + allowed_groups: - pangeo-data:cds-lab - 2i2c-org:hub-access-for-2i2c-staff kubespawner_override: @@ -92,7 +92,7 @@ basehub: node.kubernetes.io/instance-type: n1-standard-8 - display_name: Huge description: 52GB RAM, 16 CPUs - allowed_teams: + allowed_groups: - pangeo-data:cds-lab - 2i2c-org:hub-access-for-2i2c-staff kubespawner_override: diff --git a/config/clusters/smithsonian/common.values.yaml b/config/clusters/smithsonian/common.values.yaml index a33202123a..b6ee632298 100644 --- a/config/clusters/smithsonian/common.values.yaml +++ b/config/clusters/smithsonian/common.values.yaml @@ -168,7 +168,7 @@ basehub: - display_name: NVIDIA Tesla T4, ~16 GB, ~4 CPUs slug: gpu description: "Start a container on a dedicated node with a GPU" - allowed_teams: + allowed_groups: - 2i2c-org:hub-access-for-2i2c-staff - Smithsonian-SDCH:gpu-users profile_options: diff --git a/docs/howto/features/allow-unlisted-profile-choice.md b/docs/howto/features/allow-unlisted-profile-choice.md index 85b48d60a9..81f5dc929c 100644 --- a/docs/howto/features/allow-unlisted-profile-choice.md +++ b/docs/howto/features/allow-unlisted-profile-choice.md @@ -38,7 +38,7 @@ jupyterhub: In some hubs, we don't want *everyone* to be able to specify an image - but we do want some subset of users to be able to do so, for testing purposes. This can be done by coupling `unlisted_choice` with -[`allowed_teams`](auth:github-orgs:profile-list). +[`allowed_groups`](auth:github-orgs:profile-list). In the `profileList` for the hub in question, add a profile like this: @@ -46,7 +46,7 @@ In the `profileList` for the hub in question, add a profile like this: - display_name: "Test custom image" description: Test any custom image before rolling it out to rest of your users slug: custom-image-only - allowed_teams: + allowed_groups: - 2i2c-org:hub-access-for-2i2c-staff - profile_options: diff --git a/docs/hub-deployment-guide/configure-auth/github-orgs.md b/docs/hub-deployment-guide/configure-auth/github-orgs.md index e02161950a..84fce892b4 100644 --- a/docs/hub-deployment-guide/configure-auth/github-orgs.md +++ b/docs/hub-deployment-guide/configure-auth/github-orgs.md @@ -143,7 +143,7 @@ In addition, we can allow people access to specific profiles based on their GitH This only works if the hub is already set to allow people only from certain GitHub organizations to log in. -The key `allowed_teams` can be set for any profile definition, with a list of GitHub +The key `allowed_groups` can be set for any profile definition, with a list of GitHub teams (formatted as `:`) that will get access to that profile. Users need to be a member of any one of the listed teams for access. The list of teams a user is part of is fetched at login time - so if the user is added to a GitHub team, they need @@ -171,7 +171,7 @@ To enable this access, If `populate_teams_in_auth_state` is not set, this entire feature is disabled. 2. Specify which teams should have access to which profiles with an - `allowed_teams` key under `profileList`: + `allowed_groups` key under `profileList`: ```yaml jupyterhub: @@ -180,7 +180,7 @@ To enable this access, - display_name: Small description: 1.0 GB RAM default: true - allowed_teams: + allowed_groups: - : - 2i2c-org:hub-access-for-2i2c-staff kubespawner_override: @@ -188,7 +188,7 @@ To enable this access, mem_limit: 1G - display_name: Medium description: 4.0 GB RAM - allowed_teams: + allowed_groups: - : - 2i2c-org:hub-access-for-2i2c-staff kubespawner_override: @@ -198,8 +198,8 @@ To enable this access, Users who are a part of *any* of the listed teams will be able to access that profile. Add `2i2c-org:hub-access-for-2i2c-staff` to all - `allowed_teams` so 2i2c engineers can log in to debug issues. If - `allowed_teams` is not set, that profile is not available to anyone. + `allowed_groups` so 2i2c engineers can log in to debug issues. If + `allowed_groups` is not set, that profile is not available to anyone. ```{note} We used to allow restricting which profiles users can see based on what diff --git a/helm-charts/basehub/values.yaml b/helm-charts/basehub/values.yaml index b0b18e6b76..fd50600d9d 100644 --- a/helm-charts/basehub/values.yaml +++ b/helm-charts/basehub/values.yaml @@ -987,10 +987,10 @@ jupyterhub: timeout=spawner.k8s_api_request_retry_timeout ) c.Spawner.pre_spawn_hook = ensure_db_pvc - 05-gh-teams: | + 05-profile-groups: | # Re-assignes c.KubeSpawner.profile_list to a callable that filters the # initial configuration of profile_list based on the user's github - # org/team membership as declared via "allowed_teams" read from + # org/team membership as declared via "allowed_groups" read from # profile_list profiles. # # This only has effect if: @@ -1008,80 +1008,84 @@ jupyterhub: from oauthenticator.github import GitHubOAuthenticator from z2jh import get_config - async def profile_list_allowed_teams_filter(original_profile_list, spawner): + async def profile_list_allowed_groups_filter(original_profile_list, spawner): """ Returns the initially configured profile_list filtered based on the - user's membership in each profile's `allowed_teams`. If - `allowed_teams` isn't set for a profile, its not filtered out. + user's membership in each profile's `allowed_groups`. If + `allowed_groups` isn't set for a profile, that profile is allowed for + everyone. Similar functionality is provided for both `unlisted_choice` and + `choice` inside `profile_options`. - `allowed_teams` is a list of GitHub organizations and/or teams - specified with `` or `:` strings. + `allowed_groups` is a list of JupyterHub groups, set up by the authenticator. + In addition, for use with GitHubOAuthenticator, it can be a list of + teams the user is a part of, of form ':'. - If the returned profile_list is filtered to not include a profile, + If the returned profile_list is filtered to not include any profiles, an error is raised and the user isn't allowed to start a server. """ if spawner.user.name == "deployment-service-check": - print("Ignoring allowed_teams check for deployment-service-check") + print("Ignoring allowed_groups check for deployment-service-check") return original_profile_list - # Ensure auth_state is populated with teams info - auth_state = await spawner.user.get_auth_state() - if not auth_state or "teams" not in auth_state: - print(f"User {spawner.user.name} does not have any auth_state set") - raise web.HTTPError(403) + groups = {g.name.casefold() for g in spawner.user.groups} - # Format user's teams in auth_state to "org:team" - # casefold them so we can do case insensitive comparisons, as github itself is case insensitive (but preserving) - # for orgs and teams - teams = set([f'{team["organization"]["login"]}:{team["slug"]}'.casefold() for team in auth_state["teams"]]) - print(f"User {spawner.user.name} is part of teams {' '.join(teams)}") + # If we're using GitHubOAuthenticator, add the user's teams to the groups as well. + # Eventually this can be removed, as the user's teams can be set to be groups + # once https://github.com/jupyterhub/oauthenticator/pull/735 is merged + if isinstance(spawner.authenticator, GitHubOAuthenticator): + # Ensure auth_state is populated with teams info + auth_state = await spawner.user.get_auth_state() + if not auth_state or "teams" not in auth_state: + print(f"User {spawner.user.name} does not have any auth_state set") + raise web.HTTPError(403) - # Filter out profiles with allowed_teams set if the user isn't part - # of any. + groups |= set([f'{team["organization"]["login"]}:{team["slug"]}'.casefold() for team in auth_state["teams"]]) + + print(f"User {spawner.user.name} is part of groups {' '.join(groups)}") + + # Filter out profiles with allowed_groups set if the user isn't part of the group allowed_profiles = [] for original_profile in original_profile_list: # Make a copy, as we'll be modifying this profile profile = deepcopy(original_profile) - # Handle `allowed_teams` specified in profile_options + # Handle `allowed_groups` specified in profile_options if 'profile_options' in profile: for k, po in profile['profile_options'].items(): - # If `unlisted_choice` has an `allowed_teams` and the current + # If `unlisted_choice` has an `allowed_groups` and the current # user is not present in any of those teams, we delete the # `unlisted_choice` config entirely for this option. The user # will then not be allowed to 'write in' a value. if 'unlisted_choice' in po: - if 'allowed_teams' in po['unlisted_choice']: - if (set(po['unlisted_choice']['allowed_teams']) and teams): + if 'allowed_groups' in po['unlisted_choice']: + if not (set(po['unlisted_choice']['allowed_groups']) and groups): del po['unlisted_choice'] if 'choices' in po: new_choices = {} for k, c in po['choices'].items(): - # If `allowed_teams` is not set for a profile option, it is automatically + # If `allowed_groups` is not set for a profile option, it is automatically # allowed for everyone - if 'allowed_teams' not in c: + if 'allowed_groups' not in c: new_choices[k] = c - # If `allowed_teams` *is* set for a profile option, it is allowed only for + # If `allowed_groups` *is* set for a profile option, it is allowed only for # members of that team. - elif set(c['allowed_teams']) & teams: + elif set(c['allowed_groups']) & groups: new_choices[k] = c po['choices'] = new_choices - allowed_teams = profile.get("allowed_teams") - if allowed_teams is None: - allowed_profiles.append(profile) - continue - # casefold teams so we can do case insensitive comparisons, as github itself is case insensitive (but preserving) # for orgs and teams - allowed_teams = set([t.casefold() for t in allowed_teams if ':' in t]) + if 'allowed_groups' not in profile: + allowed_profiles.append(profile) + else: + allowed_groups = set([g.casefold() for g in profile.get("allowed_groups", [])]) - if allowed_teams & teams: - print(f"Allowing profile {profile['display_name']} for user {spawner.user.name} based on team membership") - allowed_profiles.append(profile) - continue + if allowed_groups & groups: + print(f"Allowing profile {profile['display_name']} for user {spawner.user.name} based on team membership") + allowed_profiles.append(profile) + continue if len(allowed_profiles) == 0: # If no profiles are allowed, user should not be able to spawn anything! @@ -1089,37 +1093,25 @@ jupyterhub: # set in singleuser, without any profile overrides. Not desired behavior # FIXME: User doesn't actually see this error message, just the generic 403. error_msg = dedent(f""" - Your GitHub team membership is insufficient to launch any server profiles. + Your JupyterHub group membership is insufficient to launch any server profiles. - GitHub teams you are a member of that this JupyterHub knows about are {', '.join(teams)}. + JupyterHub groups you are a member of are {', '.join(groups)}. - If you are part of additional teams, log out of this JupyterHub and log back in to refresh that information. + If you are part of additional groups, log out of this JupyterHub and log back in to refresh that information. """) raise web.HTTPError(403, error_msg) return allowed_profiles - # Check if GitHubOAuthenticator is being used, via one of the 4 ways it can be specified - is_github_auth = c.JupyterHub.authenticator_class in ( - "github", "oauthenticator.GitHubOAuthenticator", "oauthenticator.github.GitHubOAuthenticator", - GitHubOAuthenticator) - - # Check that teams are going to be populated in auth_state - is_teams_populated = c.GitHubOAuthenticator.populate_teams_in_auth_state and ( - c.Authenticator.enable_auth_state or c.GitHubOAuthenticator.enable_auth_state - ) - - # Only use this customized profile_list function if: - # 1. We are using GitHubOAuthenticator - # 2. GitHub teams membership is populated in auth_state - # 3. profile_list is specified (otherwise users will get an empty screen when trying to launch servers) - if is_github_auth and is_teams_populated and c.KubeSpawner.profile_list: + # Only set our custom filter if + # profile_list is specified (otherwise users will get an empty screen when trying to launch servers) + if c.KubeSpawner.profile_list: # Customize list of profiles dynamically, rather than override options form. # This is more secure, as users can't override the options available to them via the hub API # We pass in a copy of the original profile_list set in config via partial, to reduce possible variable # capture related issues. c.KubeSpawner.profile_list = partial( - profile_list_allowed_teams_filter, + profile_list_allowed_groups_filter, deepcopy(c.KubeSpawner.profile_list) ) From a9ff316d8c1c0a1fc0d2d1435baeb95da7e3289f Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Thu, 2 May 2024 19:53:00 -0700 Subject: [PATCH 10/17] Cleanup some earthscope config --- config/clusters/earthscope/common.values.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/config/clusters/earthscope/common.values.yaml b/config/clusters/earthscope/common.values.yaml index 4d0892b17a..fbdd2b9fdf 100644 --- a/config/clusters/earthscope/common.values.yaml +++ b/config/clusters/earthscope/common.values.yaml @@ -78,7 +78,9 @@ basehub: async def authenticate(self, *args, **kwargs): resp = await super().authenticate(*args, **kwargs) - # Set scope to groups + # Setup groups to be same as list of scopes granted + # This can go away after https://github.com/jupyterhub/oauthenticator/pull/735 is + # merged resp["groups"] = resp["auth_state"]["scope"] return resp @@ -99,8 +101,6 @@ basehub: c.JupyterHub.authenticator_class = CustomGenericOAuthenticator config: - # JupyterHub: - # authenticator_class: auth0 CustomGenericOAuthenticator: required_scopes: # This allows EarthScope to control who can login to the hub @@ -123,7 +123,6 @@ basehub: username_claim: sub # Convert 'scope' from the OAuth2 response into JupyterHub groups manage_groups: true - # claim_groups_key: 'scope' CILogonOAuthenticator: allowed_idps: http://github.com/login/oauth/authorize: From 7f7e3baaf61610bc6a0e458fc3da7fc2135603e6 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Fri, 3 May 2024 18:26:21 -0700 Subject: [PATCH 11/17] Document `allowed_groups` --- .../features/allow-unlisted-profile-choice.md | 3 +- docs/howto/features/index.md | 1 + docs/howto/features/profile-list-restrict.md | 207 ++++++++++++++++++ .../configure-auth/github-orgs.md | 68 +----- 4 files changed, 211 insertions(+), 68 deletions(-) create mode 100644 docs/howto/features/profile-list-restrict.md diff --git a/docs/howto/features/allow-unlisted-profile-choice.md b/docs/howto/features/allow-unlisted-profile-choice.md index 81f5dc929c..5528f34448 100644 --- a/docs/howto/features/allow-unlisted-profile-choice.md +++ b/docs/howto/features/allow-unlisted-profile-choice.md @@ -1,3 +1,4 @@ +(howto:features:unlisted-choice)= # Allow users to setup custom, free-form user profile choices Sometimes it is useful to allow users to specify their own, free-form choice for an option. @@ -38,7 +39,7 @@ jupyterhub: In some hubs, we don't want *everyone* to be able to specify an image - but we do want some subset of users to be able to do so, for testing purposes. This can be done by coupling `unlisted_choice` with -[`allowed_groups`](auth:github-orgs:profile-list). +[`allowed_groups`](howto:features:profile-list-restrict). In the `profileList` for the hub in question, add a profile like this: diff --git a/docs/howto/features/index.md b/docs/howto/features/index.md index 84d44851a9..a50f94a809 100644 --- a/docs/howto/features/index.md +++ b/docs/howto/features/index.md @@ -8,6 +8,7 @@ See the sections below for more details. ```{toctree} :maxdepth: 2 allow-unlisted-profile-choice.md +profile-list-restrict.md anonymized-usernames.md buckets.md cloud-access.md diff --git a/docs/howto/features/profile-list-restrict.md b/docs/howto/features/profile-list-restrict.md new file mode 100644 index 0000000000..137f81ef12 --- /dev/null +++ b/docs/howto/features/profile-list-restrict.md @@ -0,0 +1,207 @@ +(howto:features:profile-restrict)= +# Restrict profile options based on JupyterHub groups (or GitHub teams) + +Communities often want to *selectively* grant access to resources based on +what *groups* a user belongs to. The most common example being restricted +access to GPUs, really large resource allocations or the ability to specify +[arbitrary images to launch](howto:features:unlisted-choice). + +We override the [`profile_list`](https://jupyterhub-kubespawner.readthedocs.io/en/latest/spawner.html#kubespawner.KubeSpawner.profile_list) +feature of KubeSpawner to be able to restrict specific profiles or profile options +to only be available to users who belong to specific JupyterHub groups (or in the +case of using GitHub authentication, GitHub teams). + +## The `allowed_groups` configuration + +The key `allowed_groups` can be set under: + +1. Any **Profile** - the first level of options shown in the profile selection screen + to the user, selectable via radio buttons. +2. Any **Profile Option Choice** - the second level of options shown in the profile + selection screen to the user, selectable via dropdown box. +3. Any **Profile Option Unlisted Choice** - the optional way for end users to write in + an arbitrary value to be used, often for selecting the image to be run, instead of + selecting one of the pre-determined values. + +It can contain a list of JupyterHub **group names**. These are determined either via +the JupyterHub admin interface or provided externally via the authentication provider +(see below for how the group names look like) + +If `allowed_groups` is not set, that profile will be available to all users. + +So to restrict a profile, profile option choice or unlisted choice to a specific set +of users, put a `allowed_groups` config under whatever you want to restrict, and list +the groups that should be *allowed* access. Everyone else will not see that option, +and members of that group will. If there's no `allowed_groups` config under a particular +profile, profile option choice or unlisted choice, everyone who can log in to the hub +can see it. + +Now let's look at some examples. + +### Example 1: Restrict an entire profile + +Let's say a community wants to restrict a Matlab profile only to a select few +users, but their python environment be available to everyone. And they are using GitHub as their +authentication provider, with `GitHubOAuthenticator`. + +We would have a `profileList` like this: + +```yaml +- display_name: Python + description: Python datascience environment + default: true + kubespawner_override: + image: python-image:tag + profile_options: &profile_options + requests: &profile_options_resource_allocation + display_name: Resource Allocation + choices: + mem_1_9: + display_name: 1.9 GB RAM, upto 3.7 CPUs + kubespawner_override: + mem_guarantee: 1991244775 + mem_limit: 1991244775 + cpu_guarantee: 0.2328125 + cpu_limit: 3.725 + node_selector: + node.kubernetes.io/instance-type: r5.xlarge + default: true + mem_3_7: + display_name: 3.7 GB RAM, upto 3.7 CPUs + kubespawner_override: + mem_guarantee: 3982489550 + mem_limit: 3982489550 + cpu_guarantee: 0.465625 + cpu_limit: 3.725 + node_selector: + node.kubernetes.io/instance-type: r5.xlarge + mem_7_4: + display_name: 7.4 GB RAM, upto 3.7 CPUs + kubespawner_override: + mem_guarantee: 7964979101 + mem_limit: 7964979101 + cpu_guarantee: 0.93125 + cpu_limit: 3.725 + node_selector: + node.kubernetes.io/instance-type: r5.xlarge +- display_name: Matlab + description: Matlab environment + allowed_groups: + - 2i2c-org:hub-access-for-2i2c-staff + - organization:matlab-access + kubespawner_override: + image: matlab-image:tag + profile_options: *profile_options +``` + +The `Python` profile does not have an `allowed_groups` set, so everyone who can +log in to the hub can use that. The `Matlab` profile has an `allowed_groups` set, +and allows two groups - one specifically for 2i2c staff members, and another for +those the community has added to a `matlab-access` team inside their GitHub org. + +### Example 2: Restrict a particular `profile_option` choice + +Now let's say the community wants to restrict only users who are members of a +`large-compute` team to access the `7.4 GB RAM, upto 3.7 CPUs` and +`3.7 GB RAM, upto 3.7 CPUs` profile option. + +```yaml +- display_name: Python + description: Python datascience environment + default: true + kubespawner_override: + image: python-image:tag + profile_options: &profile_options + requests: &profile_options_resource_allocation + display_name: Resource Allocation + choices: + mem_1_9: + display_name: 1.9 GB RAM, upto 3.7 CPUs + kubespawner_override: + mem_guarantee: 1991244775 + mem_limit: 1991244775 + cpu_guarantee: 0.2328125 + cpu_limit: 3.725 + node_selector: + node.kubernetes.io/instance-type: r5.xlarge + default: true + mem_3_7: + display_name: 3.7 GB RAM, upto 3.7 CPUs + allowed_groups: + - 2i2c-org:hub-access-for-2i2c-staff + - organization:large-compute + kubespawner_override: + mem_guarantee: 3982489550 + mem_limit: 3982489550 + cpu_guarantee: 0.465625 + cpu_limit: 3.725 + node_selector: + node.kubernetes.io/instance-type: r5.xlarge + mem_7_4: + display_name: 7.4 GB RAM, upto 3.7 CPUs + allowed_groups: + - 2i2c-org:hub-access-for-2i2c-staff + - organization:large-compute + kubespawner_override: + mem_guarantee: 7964979101 + mem_limit: 7964979101 + cpu_guarantee: 0.93125 + cpu_limit: 3.725 + node_selector: + node.kubernetes.io/instance-type: r5.xlarge +- display_name: Matlab + description: Matlab environment + allowed_groups: + - 2i2c-org:hub-access-for-2i2c-staff + - organization:matlab-access + kubespawner_override: + image: matlab-image:tag + profile_options: *profile_options +``` + +Since this adds on from the previous example, it'll have the following behavior: + +1. Everyone can see and use the Python profile +2. Only members of the team `matlab-access` (and 2i2c staff) can see the `Matlab` + profile. +3. Only members of the team `large-compute` can see the two larger dropdown + items. This `profile_option` is the same for both python and matlab, so this + behavior is repeated for both of them. +4. So only people who are members of *both* `matlab-access` and `large-compute` can + see the larger dropdown options for the Matlab profile. + +## Enabling externally managed groups for `GitHubOAuthenticator` + +For hubs using `GitHubOAuthenticator`, groups (for the purposes of this feature alone) +are of the form `:`. + +The following extra config is also required to enable this feature. + +```yaml +jupyterhub: + hub: + config: + Authenticator: + enable_auth_state: true + GitHubOAuthenticator: + populate_teams_in_auth_state: true +``` + +```{note} +GitHubOAuthenticator is currently special cased in our code, until +[this PR](https://github.com/jupyterhub/oauthenticator/pull/735) is merged +and deployed. `allowed_groups` will treat GitHub team membership as groups, +but other JupyterHub functionality that depends on groups will not. +``` + +### Enabling access for 2i2c engineers + +All 2i2c engineers are part of the GitHub team `2i2c-org:hub-access-for-2i2c-staff`, so +every `allowed_group` entry should have an explicit mention of that team so 2i2c engineers +can access that option / profile and test it out when needed. + +## Enabling this feature for other Authenticators + +Currently, the EarthScope hub has this feature enabled via custom overrides. Once +[this PR](https://github.com/jupyterhub/oauthenticator/pull/735) is merged and +deployed, we can enable this feature for hubs using other Authenticators more generally. \ No newline at end of file diff --git a/docs/hub-deployment-guide/configure-auth/github-orgs.md b/docs/hub-deployment-guide/configure-auth/github-orgs.md index 84fce892b4..4bee84530d 100644 --- a/docs/hub-deployment-guide/configure-auth/github-orgs.md +++ b/docs/hub-deployment-guide/configure-auth/github-orgs.md @@ -132,7 +132,6 @@ You will **still** require admin access to the org to carry out those steps. Once you have confirmed with the Community Representative that users can login, you can remove yourself from the org. -(auth:github-orgs:profile-list)= ## Restricting user profiles based on GitHub Team Membership JupyterHub has support for using [profileList](https://zero-to-jupyterhub.readthedocs.io/en/latest/jupyterhub/customizing/user-environment.html#using-multiple-profiles-to-let-users-select-their-environment) @@ -141,72 +140,7 @@ server. In addition, we can allow people access to specific profiles based on their GitHub Teams membership! This only works if the hub is already set to allow people only from certain GitHub organizations -to log in. - -The key `allowed_groups` can be set for any profile definition, with a list of GitHub -teams (formatted as `:`) that will get access to that profile. Users -need to be a member of any one of the listed teams for access. The list of teams a user -is part of is fetched at login time - so if the user is added to a GitHub team, they need -to log out and log back in to the JupyterHub (not necessarily to GitHub!) to see the new -profiles they have access to. To remove access to a profile from a user, they have to be -removed from the appropriate team on GitHub *and* their JupyterHub user needs to be -deleted from the hub admin dashboard. - -To enable this access, - -1. Enable storing the list of GitHub teams a user is in as a part of - [`auth_state`](https://zero-to-jupyterhub.readthedocs.io/en/latest/administrator/authentication.html#enable-auth-state) - with the following config: - - ```yaml - jupyterhub: - hub: - config: - Authenticator: - enable_auth_state: true - GitHubOAuthenticator: - populate_teams_in_auth_state: true - ``` - - If `populate_teams_in_auth_state` is not set, this entire feature is disabled. - -2. Specify which teams should have access to which profiles with an - `allowed_groups` key under `profileList`: - - ```yaml - jupyterhub: - singleuser: - profileList: - - display_name: Small - description: 1.0 GB RAM - default: true - allowed_groups: - - : - - 2i2c-org:hub-access-for-2i2c-staff - kubespawner_override: - mem_guarantee: 1G - mem_limit: 1G - - display_name: Medium - description: 4.0 GB RAM - allowed_groups: - - : - - 2i2c-org:hub-access-for-2i2c-staff - kubespawner_override: - mem_guarantee: 4G - mem_limit: 4G - ``` - - Users who are a part of *any* of the listed teams will be able to access - that profile. Add `2i2c-org:hub-access-for-2i2c-staff` to all - `allowed_groups` so 2i2c engineers can log in to debug issues. If - `allowed_groups` is not set, that profile is not available to anyone. - - ```{note} - We used to allow restricting which profiles users can see based on what - org they were a part of, rather than just the *teams* they were a part of. - We no longer support this. - ``` - +to log in. See [](howto:features:profile-restrict) for more information. ### Enabling team based access on hub with pre-existing users If this is being enabled for users on a hub with *pre-existing* users, they From d206b4f34663799121d924cfe9202249fe9cf62d Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Fri, 3 May 2024 18:28:21 -0700 Subject: [PATCH 12/17] Fix typo --- docs/howto/features/allow-unlisted-profile-choice.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/howto/features/allow-unlisted-profile-choice.md b/docs/howto/features/allow-unlisted-profile-choice.md index 5528f34448..7414c2505f 100644 --- a/docs/howto/features/allow-unlisted-profile-choice.md +++ b/docs/howto/features/allow-unlisted-profile-choice.md @@ -39,7 +39,7 @@ jupyterhub: In some hubs, we don't want *everyone* to be able to specify an image - but we do want some subset of users to be able to do so, for testing purposes. This can be done by coupling `unlisted_choice` with -[`allowed_groups`](howto:features:profile-list-restrict). +[`allowed_groups`](howto:features:profile-restrict). In the `profileList` for the hub in question, add a profile like this: From a1825eaf3a2b57815fb22310d206d8c243186364 Mon Sep 17 00:00:00 2001 From: Yuvi Panda Date: Tue, 7 May 2024 10:10:43 -0700 Subject: [PATCH 13/17] Clarify missing `allowed_groups` Co-authored-by: Georgiana --- docs/howto/features/profile-list-restrict.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/howto/features/profile-list-restrict.md b/docs/howto/features/profile-list-restrict.md index 137f81ef12..4467f33907 100644 --- a/docs/howto/features/profile-list-restrict.md +++ b/docs/howto/features/profile-list-restrict.md @@ -27,14 +27,12 @@ It can contain a list of JupyterHub **group names**. These are determined either the JupyterHub admin interface or provided externally via the authentication provider (see below for how the group names look like) -If `allowed_groups` is not set, that profile will be available to all users. +If `allowed_groups` is not set, that profile, profile option choice or unlisted choice, will be visible and available to everyone who can log in to the hub/ So to restrict a profile, profile option choice or unlisted choice to a specific set of users, put a `allowed_groups` config under whatever you want to restrict, and list the groups that should be *allowed* access. Everyone else will not see that option, -and members of that group will. If there's no `allowed_groups` config under a particular -profile, profile option choice or unlisted choice, everyone who can log in to the hub -can see it. +and members of that group will. Now let's look at some examples. From f3c16bdabbeed6da02caee5b60cb4b3ec1794cdc Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Tue, 7 May 2024 10:11:32 -0700 Subject: [PATCH 14/17] Remove extra reduandant config --- config/clusters/earthscope/common.values.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/config/clusters/earthscope/common.values.yaml b/config/clusters/earthscope/common.values.yaml index fbdd2b9fdf..dc9015f7f7 100644 --- a/config/clusters/earthscope/common.values.yaml +++ b/config/clusters/earthscope/common.values.yaml @@ -97,8 +97,6 @@ basehub: c.Spawner.auth_state_hook = populate_token - c.GenericOAuthenticator.manage_groups = True - c.JupyterHub.authenticator_class = CustomGenericOAuthenticator config: CustomGenericOAuthenticator: From 19ee9a15e4e099e49c29e5f9ecdc163bf51933b1 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Tue, 7 May 2024 10:13:41 -0700 Subject: [PATCH 15/17] Add warning up top --- docs/howto/features/profile-list-restrict.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/howto/features/profile-list-restrict.md b/docs/howto/features/profile-list-restrict.md index 4467f33907..ba021e89e2 100644 --- a/docs/howto/features/profile-list-restrict.md +++ b/docs/howto/features/profile-list-restrict.md @@ -1,6 +1,13 @@ (howto:features:profile-restrict)= # Restrict profile options based on JupyterHub groups (or GitHub teams) +```{warning} +This is currently only functional for GitHub authentication with `GitHubOAuthenticator`, +and the earthscope hub is special cased. This will be more generally available once +group management is [broadly available](https://github.com/jupyterhub/oauthenticator/pull/735) +on OAuthenticator. +``` + Communities often want to *selectively* grant access to resources based on what *groups* a user belongs to. The most common example being restricted access to GPUs, really large resource allocations or the ability to specify From d2e8938a6f22463dbd4bac86e9fee2a9f426ee5d Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Tue, 7 May 2024 10:16:09 -0700 Subject: [PATCH 16/17] Cleanup documentation about case sensitivity --- helm-charts/basehub/values.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/helm-charts/basehub/values.yaml b/helm-charts/basehub/values.yaml index fd50600d9d..dd52685103 100644 --- a/helm-charts/basehub/values.yaml +++ b/helm-charts/basehub/values.yaml @@ -1027,6 +1027,7 @@ jupyterhub: print("Ignoring allowed_groups check for deployment-service-check") return original_profile_list + # casefold group names so we can do case insensitive comparisons. groups = {g.name.casefold() for g in spawner.user.groups} # If we're using GitHubOAuthenticator, add the user's teams to the groups as well. @@ -1039,6 +1040,8 @@ jupyterhub: print(f"User {spawner.user.name} does not have any auth_state set") raise web.HTTPError(403) + + # casefold teams to match what GitHub's API does when doing authorization calls groups |= set([f'{team["organization"]["login"]}:{team["slug"]}'.casefold() for team in auth_state["teams"]]) print(f"User {spawner.user.name} is part of groups {' '.join(groups)}") @@ -1075,8 +1078,6 @@ jupyterhub: new_choices[k] = c po['choices'] = new_choices - # casefold teams so we can do case insensitive comparisons, as github itself is case insensitive (but preserving) - # for orgs and teams if 'allowed_groups' not in profile: allowed_profiles.append(profile) else: From b8a8e499ae198f47851b88ea5e3ebf2a4ae72788 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Tue, 7 May 2024 10:22:32 -0700 Subject: [PATCH 17/17] Fix reference --- docs/howto/features/gpu.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/howto/features/gpu.md b/docs/howto/features/gpu.md index 5b059a2b75..2f1b78dfc7 100644 --- a/docs/howto/features/gpu.md +++ b/docs/howto/features/gpu.md @@ -214,7 +214,7 @@ jupyterhub: using `GitHubOAuthenticator`, and restricts access to the GPU only to members of that GitHub team. If `allowed_teams` is not used in other config in the `profileList`, you may need to also explicitly - [enable some other config (`enable_auth_state` and `populate_teams_in_auth_state`)](auth:github-orgs:profile-list) + [enable some other config (`enable_auth_state` and `populate_teams_in_auth_state`)](howto:features:profile-restrict) for this feature to work. Do a deployment with this config, and then we can test to make sure