Skip to content

Commit

Permalink
Move group management from generic to base oauthenticator
Browse files Browse the repository at this point in the history
These are all extremely useful for managing groups in
all authenticators, not just genericauthenticators.
  • Loading branch information
yuvipanda committed Mar 29, 2024
1 parent 97b470b commit 33dd355
Show file tree
Hide file tree
Showing 2 changed files with 99 additions and 110 deletions.
108 changes: 0 additions & 108 deletions oauthenticator/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,48 +18,6 @@ class GenericOAuthenticator(OAuthenticator):
def _login_service_default(self):
return os.environ.get("LOGIN_SERVICE", "OAuth 2.0")

claim_groups_key = Union(
[Unicode(os.environ.get('OAUTH2_GROUPS_KEY', 'groups')), Callable()],
config=True,
help="""
Userdata groups claim key from returned json for USERDATA_URL.
Can be a string key name (use periods for nested keys), or a callable
that accepts the returned json (as a dict) and returns the groups list.
This configures how group membership in the upstream provider is determined
for use by `allowed_groups`, `admin_groups`, etc. If `manage_groups` is True,
this will also determine users' _JupyterHub_ group membership.
""",
)

allowed_groups = Set(
Unicode(),
config=True,
help="""
Allow members of selected groups to sign in.
When configuring this you may need to configure `claim_groups_key` as
well as it determines the key in the `userdata_url` response that is
assumed to list the groups a user is a member of.
""",
)

admin_groups = Set(
Unicode(),
config=True,
help="""
Allow members of selected groups to sign in and consider them as
JupyterHub admins.
If this is set and a user isn't part of one of these groups or listed in
`admin_users`, a user signing in will have their admin status revoked.
When configuring this you may need to configure `claim_groups_key` as
well as it determines the key in the `userdata_url` response that is
assumed to list the groups a user is a member of.
""",
)

@default("http_client")
def _default_http_client(self):
Expand Down Expand Up @@ -100,72 +58,6 @@ def _default_http_client(self):
""",
)

def get_user_groups(self, user_info):
"""
Returns a set of groups the user belongs to based on claim_groups_key
and provided user_info.
- If claim_groups_key is a callable, it is meant to return the groups
directly.
- If claim_groups_key is a nested dictionary key like
"permissions.groups", this function returns
user_info["permissions"]["groups"].
Note that this method is introduced by GenericOAuthenticator and not
present in the base class.
"""
if callable(self.claim_groups_key):
return set(self.claim_groups_key(user_info))
try:
return set(reduce(dict.get, self.claim_groups_key.split("."), user_info))
except TypeError:
self.log.error(
f"The claim_groups_key {self.claim_groups_key} does not exist in the user token"
)
return set()

async def update_auth_model(self, auth_model):
"""
Sets admin status to True or False if `admin_groups` is configured and
the user isn't part of `admin_users` or `admin_groups`. Note that
leaving it at None makes users able to retain an admin status while
setting it to False makes it be revoked.
Also populates groups if `manage_groups` is set.
"""
if self.manage_groups or self.admin_groups:
user_info = auth_model["auth_state"][self.user_auth_state_key]
user_groups = self.get_user_groups(user_info)

if self.manage_groups:
auth_model["groups"] = sorted(user_groups)

if auth_model["admin"]:
# auth_model["admin"] being True means the user was in admin_users
return auth_model

if self.admin_groups:
# admin status should in this case be True or False, not None
auth_model["admin"] = bool(user_groups & self.admin_groups)

return auth_model

async def check_allowed(self, username, auth_model):
"""
Overrides the OAuthenticator.check_allowed to also allow users part of
`allowed_groups`.
"""
if await super().check_allowed(username, auth_model):
return True

if self.allowed_groups:
user_info = auth_model["auth_state"][self.user_auth_state_key]
user_groups = self.get_user_groups(user_info)
if any(user_groups & self.allowed_groups):
return True

# users should be explicitly allowed via config, otherwise they aren't
return False


class LocalGenericOAuthenticator(LocalAuthenticator, GenericOAuthenticator):
Expand Down
101 changes: 99 additions & 2 deletions oauthenticator/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import base64
import json
import os
from functools import reduce
import uuid
from urllib.parse import quote, urlencode, urlparse, urlunparse

Expand All @@ -20,7 +21,7 @@
from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPRequest
from tornado.httputil import url_concat
from tornado.log import app_log
from traitlets import Any, Bool, Callable, Dict, List, Unicode, Union, default, validate
from traitlets import Any, Bool, Callable, Dict, List, Unicode, Union, Set, default, validate


def guess_callback_uri(protocol, host, hub_server_url):
Expand Down Expand Up @@ -264,6 +265,49 @@ class OAuthenticator(Authenticator):
""",
)

claim_groups_key = Union(
[Unicode(os.environ.get('OAUTH2_GROUPS_KEY', 'groups')), Callable()],
config=True,
help="""
Userdata groups claim key from returned json for USERDATA_URL.
Can be a string key name (use periods for nested keys), or a callable
that accepts the returned json (as a dict) and returns the groups list.
This configures how group membership in the upstream provider is determined
for use by `allowed_groups`, `admin_groups`, etc. If `manage_groups` is True,
this will also determine users' _JupyterHub_ group membership.
""",
)

allowed_groups = Set(
Unicode(),
config=True,
help="""
Allow members of selected groups to sign in.
When configuring this you may need to configure `claim_groups_key` as
well as it determines the key in the `userdata_url` response that is
assumed to list the groups a user is a member of.
""",
)

admin_groups = Set(
Unicode(),
config=True,
help="""
Allow members of selected groups to sign in and consider them as
JupyterHub admins.
If this is set and a user isn't part of one of these groups or listed in
`admin_users`, a user signing in will have their admin status revoked.
When configuring this you may need to configure `claim_groups_key` as
well as it determines the key in the `userdata_url` response that is
assumed to list the groups a user is a member of.
""",
)

allow_all = Bool(
False,
config=True,
Expand Down Expand Up @@ -995,11 +1039,42 @@ def build_auth_state_dict(self, token_info, user_info):
self.user_auth_state_key: user_info,
}

def get_user_groups(self, user_info):
"""
Returns a set of groups the user belongs to based on claim_groups_key
and provided user_info.
- If claim_groups_key is a callable, it is meant to return the groups
directly.
- If claim_groups_key is a nested dictionary key like
"permissions.groups", this function returns
user_info["permissions"]["groups"].
Note that this method is introduced by GenericOAuthenticator and not
present in the base class.
"""
if callable(self.claim_groups_key):
return set(self.claim_groups_key(user_info))
try:
return set(reduce(dict.get, self.claim_groups_key.split("."), user_info))
except TypeError:
self.log.error(
f"The claim_groups_key {self.claim_groups_key} does not exist in the user token"
)
return set()

async def update_auth_model(self, auth_model):
"""
Updates and returns the `auth_model` dict.
Should be overridden to collect information required for check_allowed.
Sets admin status to True or False if `admin_groups` is configured and
the user isn't part of `admin_users` or `admin_groups`. Note that
leaving it at None makes users able to retain an admin status while
setting it to False makes it be revoked.
Also populates groups if `manage_groups` is set.
Should be overridden to collect additional information required for check_allowed.
Args: auth_model - the auth model dictionary, containing:
- `name`: the normalized username
Expand All @@ -1010,6 +1085,21 @@ async def update_auth_model(self, auth_model):
Called by the :meth:`oauthenticator.OAuthenticator.authenticate`
"""
if self.manage_groups or self.admin_groups:
user_info = auth_model["auth_state"][self.user_auth_state_key]
user_groups = self.get_user_groups(user_info)

if self.manage_groups:
auth_model["groups"] = sorted(user_groups)

if auth_model["admin"]:
# auth_model["admin"] being True means the user was in admin_users
return auth_model

if self.admin_groups:
# admin status should in this case be True or False, not None
auth_model["admin"] = bool(user_groups & self.admin_groups)

return auth_model

async def authenticate(self, handler, data=None, **kwargs):
Expand Down Expand Up @@ -1087,6 +1177,13 @@ async def check_allowed(self, username, auth_model):
if username in self.allowed_users:
return True

# Allow users who are part of `allowed_groups`
if self.allowed_groups:
user_info = auth_model["auth_state"][self.user_auth_state_key]
user_groups = self.get_user_groups(user_info)
if any(user_groups & self.allowed_groups):
return True

# users should be explicitly allowed via config, otherwise they aren't
return False

Expand Down

0 comments on commit 33dd355

Please sign in to comment.