From 97eff99e5e19b819ec4b3b84aa7b96e2c8998a31 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Mon, 25 Apr 2022 15:25:57 -0700 Subject: [PATCH] Restrict access to profiles based on GH team membership profile_list is now dynamically generated, based on the GH teams user is a part of. This list of teams is refreshed only during login - so user needs to log out and log back in to see new teams! This also means that users removed from teams on GH will still have access to the profiles until they are logged out from the admin panel too (to be fixed) This approach is taken over customizing options_form to protect against users just bypassing the options form and using the API directly to spawn servers. Deployed to the leap hub, except 'large' & 'huge' is only available to leap-stc:leap-pangeo-research members, not to leap-stc:leap-pangeo-users members - based on https://github.com/2i2c-org/infrastructure/issues/1050#issuecomment-1077938916 Fixes https://github.com/2i2c-org/infrastructure/issues/1146 --- config/clusters/leap/common.values.yaml | 14 ++++++ helm-charts/basehub/values.yaml | 57 +++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/config/clusters/leap/common.values.yaml b/config/clusters/leap/common.values.yaml index 3e5a866266..dd0ed11f8e 100644 --- a/config/clusters/leap/common.values.yaml +++ b/config/clusters/leap/common.values.yaml @@ -36,6 +36,7 @@ basehub: allowNamedServers: true config: Authenticator: + enable_auth_state: true # This hub uses GitHub Teams auth and so we don't set # allowed_users in order to not deny access to valid members of # the listed teams. These people should have admin access though. @@ -44,6 +45,7 @@ basehub: JupyterHub: authenticator_class: github GitHubOAuthenticator: + populate_teams_in_auth_state: true allowed_organizations: - leap-stc:leap-pangeo-users - 2i2c-org:tech-team @@ -67,6 +69,9 @@ basehub: - display_name: "Small" description: 5GB RAM, 2 CPUs default: true + allowed_teams: + - leap-stc:leap-pangeo-users + - 2i2c-org:tech-team kubespawner_override: mem_limit: 7G mem_guarantee: 4.5G @@ -74,6 +79,9 @@ basehub: node.kubernetes.io/instance-type: n1-standard-2 - display_name: Medium description: 11GB RAM, 4 CPUs + allowed_teams: + - leap-stc:leap-pangeo-users + - 2i2c-org:tech-team kubespawner_override: mem_limit: 15G mem_guarantee: 11G @@ -81,6 +89,9 @@ basehub: node.kubernetes.io/instance-type: n1-standard-4 - display_name: Large description: 24GB RAM, 8 CPUs + allowed_teams: + - leap-stc:leap-pangeo-research + - 2i2c-org:tech-team kubespawner_override: mem_limit: 30G mem_guarantee: 24G @@ -88,6 +99,9 @@ basehub: node.kubernetes.io/instance-type: n1-standard-8 - display_name: Huge description: 52GB RAM, 16 CPUs + allowed_teams: + - leap-stc:leap-pangeo-research + - 2i2c-org:tech-team kubespawner_override: mem_limit: 60G mem_guarantee: 52G diff --git a/helm-charts/basehub/values.yaml b/helm-charts/basehub/values.yaml index 364ef56d0f..6f97c2dba2 100644 --- a/helm-charts/basehub/values.yaml +++ b/helm-charts/basehub/values.yaml @@ -441,3 +441,60 @@ jupyterhub: if get_config("custom.docs_service.enabled"): c.JupyterHub.services.append({"name": "docs", "url": "http://docs-service"}) + 09-gh-teams: | + from textwrap import dedent + from tornado import gen, web + + # Make a copy of the original profile_list, as that is the data we will work with + original_profile_list = c.KubeSpawner.profile_list + + # This has to be a gen.coroutine, not async def! Kubespawner uses gen.maybe_future to + # run this, and that only seems to recognize tornado coroutines, not async functions! + # We can convert this to async def once that has been fixed upstream. + @gen.coroutine + def custom_profile_list(spawner): + """ + Dynamically set allowed list of user profiles based on GitHub teams user is part of. + + Adds a 'allowed_teams' key to profile_list, with a list of GitHub teams (of the form + org-name:team-name) for which the profile is made available. + + If the user isn't part of any team whose membership grants them access to even a single + profile, they aren't allowed to start any servers. + """ + auth_state = yield spawner.user.get_auth_state() + + # Make a list of team names of form org-name:team-name + # This is the same syntax used by allowed_organizations traitlet of GitHubOAuthenticator + teams = set([f'{team_info["organization"]["login"]}:{team_info["slug"]}' for team_info in auth_state["teams"]]) + + allowed_profiles = [] + + for profile in original_profile_list: + # Keep the profile is the user is part of *any* team listed in allowed_teams + # If allowed_teams is empty or not set, it'll not be accessible to *anyone* + if set(profile.get('allowed_teams', [])) & teams: + allowed_profiles.append(profile) + print(f"Allowing profile {profile['display_name']} for user {spawner.user.name}") + else: + print(f"Dropping profile {profile['display_name']} for user {spawner.user.name}") + + 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 GitHub team membership is insufficient to launch any server profiles. + + GitHub teams you are a member of that this JupyterHub knows about are {', '.join(teams)}. + + 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 + + # 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 = custom_profile_list