Merge branch 'jupyterhub:main' into documentation

This commit is contained in:
mahamtariq58
2022-10-11 03:38:52 +05:00
committed by GitHub
9 changed files with 254 additions and 67 deletions

View File

@@ -4,7 +4,7 @@ The default Authenticator uses [PAM][] to authenticate system users with
their username and password. With the default Authenticator, any user their username and password. With the default Authenticator, any user
with an account and password on the system will be allowed to login. with an account and password on the system will be allowed to login.
## Create a set of allowed users ## Create a set of allowed users (`allowed_users`)
You can restrict which users are allowed to login with a set, You can restrict which users are allowed to login with a set,
`Authenticator.allowed_users`: `Authenticator.allowed_users`:
@@ -25,7 +25,7 @@ If this configuration value is not set, then **all authenticated users will be a
```{note} ```{note}
As of JupyterHub 2.0, the full permissions of `admin_users` As of JupyterHub 2.0, the full permissions of `admin_users`
should not be required. should not be required.
Instead, you can assign [roles][] to users or groups Instead, you can assign [roles](https://jupyterhub.readthedocs.io/en/stable/rbac/roles.html#define-role-target) to users or groups
with only the scopes they require. with only the scopes they require.
``` ```
@@ -43,9 +43,9 @@ Users in the admin set are automatically added to the user `allowed_users` set,
if they are not already present. if they are not already present.
Each authenticator may have different ways of determining whether a user is an Each authenticator may have different ways of determining whether a user is an
administrator. By default JupyterHub uses the PAMAuthenticator which provides the administrator. By default, JupyterHub uses the PAMAuthenticator which provides the
`admin_groups` option and can set administrator status based on a user `admin_groups` option and can set administrator status based on a user
group. For example we can let any user in the `wheel` group be admin: group. For example, we can let any user in the `wheel` group be an admin:
```python ```python
c.PAMAuthenticator.admin_groups = {'wheel'} c.PAMAuthenticator.admin_groups = {'wheel'}
@@ -57,12 +57,12 @@ Since the default `JupyterHub.admin_access` setting is `False`, the admins
do not have permission to log in to the single user notebook servers do not have permission to log in to the single user notebook servers
owned by _other users_. If `JupyterHub.admin_access` is set to `True`, owned by _other users_. If `JupyterHub.admin_access` is set to `True`,
then admins have permission to log in _as other users_ on their then admins have permission to log in _as other users_ on their
respective machines, for debugging. **As a courtesy, you should make respective machines for debugging. **As a courtesy, you should make
sure your users know if admin_access is enabled.** sure your users know if admin_access is enabled.**
## Add or remove users from the Hub ## Add or remove users from the Hub
Users can be added to and removed from the Hub via either the admin Users can be added to and removed from the Hub via the admin
panel or the REST API. When a user is **added**, the user will be panel or the REST API. When a user is **added**, the user will be
automatically added to the `allowed_users` set and database. Restarting the Hub automatically added to the `allowed_users` set and database. Restarting the Hub
will not require manually updating the `allowed_users` set in your config file, will not require manually updating the `allowed_users` set in your config file,
@@ -81,7 +81,7 @@ the ability to manage users on the local system. When you try to add a
new user to the Hub, a `LocalAuthenticator` will check if the user new user to the Hub, a `LocalAuthenticator` will check if the user
already exists. If you set the configuration value, `create_system_users`, already exists. If you set the configuration value, `create_system_users`,
to `True` in the configuration file, the `LocalAuthenticator` has to `True` in the configuration file, the `LocalAuthenticator` has
the privileges to add users to the system. The setting in the config the ability to add users to the system. The setting in the config
file is: file is:
```python ```python
@@ -91,7 +91,7 @@ c.LocalAuthenticator.create_system_users = True
Adding a user to the Hub that doesn't already exist on the system will Adding a user to the Hub that doesn't already exist on the system will
result in the Hub creating that user via the system `adduser` command result in the Hub creating that user via the system `adduser` command
line tool. This option is typically used on hosted deployments of line tool. This option is typically used on hosted deployments of
JupyterHub, to avoid the need to manually create all your users before JupyterHub to avoid the need to manually create all your users before
launching the service. This approach is not recommended when running launching the service. This approach is not recommended when running
JupyterHub in situations where JupyterHub users map directly onto the JupyterHub in situations where JupyterHub users map directly onto the
system's UNIX users. system's UNIX users.
@@ -101,19 +101,19 @@ system's UNIX users.
JupyterHub's [OAuthenticator][] currently supports the following JupyterHub's [OAuthenticator][] currently supports the following
popular services: popular services:
- Auth0 - [Auth0](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.auth0.html#module-oauthenticator.auth0)
- Azure AD - [Azure AD](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.azuread.html#module-oauthenticator.azuread)
- Bitbucket - [Bitbucket](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.bitbucket.html#module-oauthenticator.bitbucket)
- CILogon - [CILogon](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.cilogon.html#module-oauthenticator.cilogon)
- GitHub - [GitHub](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.github.html#module-oauthenticator.github)
- GitLab - [GitLab](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.gitlab.html#module-oauthenticator.gitlab)
- Globus - [Globus](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.globus.html#module-oauthenticator.globus)
- Google - [Google](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.google.html#module-oauthenticator.google)
- MediaWiki - [MediaWiki](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.mediawiki.html#module-oauthenticator.mediawiki)
- Okpy - [Okpy](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.okpy.html#module-oauthenticator.okpy)
- OpenShift - [OpenShift](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.openshift.html#module-oauthenticator.openshift)
A generic implementation, which you can use for OAuth authentication A [generic implementation](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.generic.html#module-oauthenticator.generic), which you can use for OAuth authentication
with any provider, is also available. with any provider, is also available.
## Use DummyAuthenticator for testing ## Use DummyAuthenticator for testing

View File

@@ -3,6 +3,7 @@
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import json import json
from datetime import datetime from datetime import datetime
from unittest import mock
from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse
from oauthlib import oauth2 from oauthlib import oauth2
@@ -241,12 +242,18 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
uri, http_method, body, headers = self.extract_oauth_params() uri, http_method, body, headers = self.extract_oauth_params()
try: try:
( with mock.patch.object(
requested_scopes, self.oauth_provider.request_validator,
credentials, "_current_user",
) = self.oauth_provider.validate_authorization_request( self.current_user,
uri, http_method, body, headers create=True,
) ):
(
requested_scopes,
credentials,
) = self.oauth_provider.validate_authorization_request(
uri, http_method, body, headers
)
credentials = self.add_credentials(credentials) credentials = self.add_credentials(credentials)
client = self.oauth_provider.fetch_by_client_id(credentials['client_id']) client = self.oauth_provider.fetch_by_client_id(credentials['client_id'])
allowed = False allowed = False
@@ -289,12 +296,15 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
required_scopes = {*scopes.identify_scopes(), *scopes.access_scopes(client)} required_scopes = {*scopes.identify_scopes(), *scopes.access_scopes(client)}
user_scopes |= {"inherit", *required_scopes} user_scopes |= {"inherit", *required_scopes}
allowed_scopes = requested_scopes.intersection(user_scopes) allowed_scopes, disallowed_scopes = scopes._resolve_requested_scopes(
excluded_scopes = requested_scopes.difference(user_scopes) requested_scopes,
# TODO: compute lower-level intersection of remaining _expanded_ scopes user_scopes,
# (e.g. user has admin:users, requesting read:users!group=x) user=user.orm_user,
client=client,
db=self.db,
)
if excluded_scopes: if disallowed_scopes:
self.log.warning( self.log.warning(
f"Service {client.description} requested scopes {','.join(requested_scopes)}" f"Service {client.description} requested scopes {','.join(requested_scopes)}"
f" for user {self.current_user.name}," f" for user {self.current_user.name},"

View File

@@ -9,7 +9,13 @@ from tornado.log import app_log
from .. import orm from .. import orm
from ..roles import roles_to_scopes from ..roles import roles_to_scopes
from ..scopes import _check_scopes_exist, access_scopes, identify_scopes from ..scopes import (
_check_scopes_exist,
_resolve_requested_scopes,
access_scopes,
expand_scopes,
identify_scopes,
)
from ..utils import compare_token, hash_token from ..utils import compare_token, hash_token
# patch absolute-uri check # patch absolute-uri check
@@ -551,7 +557,6 @@ class JupyterHubRequestValidator(RequestValidator):
- Resource Owner Password Credentials Grant - Resource Owner Password Credentials Grant
- Client Credentials Grant - Client Credentials Grant
""" """
orm_client = ( orm_client = (
self.db.query(orm.OAuthClient).filter_by(identifier=client_id).one_or_none() self.db.query(orm.OAuthClient).filter_by(identifier=client_id).one_or_none()
) )
@@ -591,19 +596,23 @@ class JupyterHubRequestValidator(RequestValidator):
client_allowed_scopes = set(orm_client.allowed_scopes) client_allowed_scopes = set(orm_client.allowed_scopes)
# scope resolution only works if we have a user defined
user = request.user or getattr(self, "_current_user")
# always grant reading the token-owner's name # always grant reading the token-owner's name
# and accessing the service itself # and accessing the service itself
required_scopes = {*identify_scopes(), *access_scopes(orm_client)} required_scopes = {*identify_scopes(), *access_scopes(orm_client)}
requested_scopes.update(required_scopes) requested_scopes.update(required_scopes)
client_allowed_scopes.update(required_scopes) client_allowed_scopes.update(required_scopes)
# TODO: handle expanded_scopes intersection here? allowed_scopes, disallowed_scopes = _resolve_requested_scopes(
# e.g. client allowed to request admin:users, requested_scopes,
# but requests admin:users!name=x will not be allowed client_allowed_scopes,
# This can probably be dealt with in config by listing expected requests user=user.orm_user,
# as explcitly allowed client=orm_client,
db=self.db,
)
disallowed_scopes = requested_scopes.difference(client_allowed_scopes)
if disallowed_scopes: if disallowed_scopes:
app_log.error( app_log.error(
f"Scope(s) not allowed for {client_id}: {', '.join(disallowed_scopes)}" f"Scope(s) not allowed for {client_id}: {', '.join(disallowed_scopes)}"

View File

@@ -461,16 +461,13 @@ def _expand_scope(scope):
# reapply !filter # reapply !filter
if filter_: if filter_:
expanded_scopes = { expanded_scopes = {
f"{scope_name}!{filter_}" for scope_name in expanded_scope_names f"{scope_name}!{filter_}"
for scope_name in expanded_scope_names
# server scopes have some cross-resource subscopes
# where the !server filter doesn't make sense,
# e.g. read:servers -> read:users:name
if not (filter_.startswith("server") and scope_name.startswith("read:user"))
} }
# special handling of server filter
# any read access via server filter includes permission to read the user's name
resource, _, value = filter_.partition('=')
if resource == 'server' and any(
scope_name.startswith("read:") for scope_name in expanded_scope_names
):
username, _, server = value.partition('/')
expanded_scopes.add(f'read:users:name!user={username}')
else: else:
expanded_scopes = expanded_scope_names expanded_scopes = expanded_scope_names
@@ -569,6 +566,76 @@ def expand_scopes(scopes, owner=None, oauth_client=None):
return frozenset(reduce_scopes(expanded_scopes)) return frozenset(reduce_scopes(expanded_scopes))
def _resolve_requested_scopes(requested_scopes, have_scopes, user, client, db):
"""Resolve requested scopes for an OAuth token
Intersects requested scopes with user scopes.
First, at the raw scope level,
then if some scopes remain, intersect expanded scopes.
Args:
requested_scopes (set):
raw scopes being requested.
have_scopes (set):
raw scopes currently held, against which requested_scopes will be checked.
user (orm.User):
user for whom the scopes will be issued
client (orm.OAuthClient):
oauth client which will own the token
db:
database session, required to resolve user|group intersections
Returns:
(allowed_scopes, disallowed_scopes):
sets of allowed and disallowed scopes from the request
"""
allowed_scopes = requested_scopes.intersection(have_scopes)
disallowed_scopes = requested_scopes.difference(have_scopes)
if not disallowed_scopes:
# simple intersection worked, all scopes granted
return (allowed_scopes, disallowed_scopes)
# if we got here, some scopes were disallowed.
# resolve fully expanded scopes to make sure scope intersections are properly allowed.
expanded_allowed = expand_scopes(allowed_scopes, user, client)
expanded_have = expand_scopes(have_scopes, user, client)
# compute one at a time so we can keep the abbreviated scopes
# if they are a subset of user scopes (e.g. requested !server, have !user)
for scope in list(disallowed_scopes):
expanded_disallowed = expand_scopes({scope}, user, client)
# don't check already-allowed scopes
expanded_disallowed -= expanded_allowed
if expanded_disallowed:
allowed_intersection = _intersect_expanded_scopes(
expanded_disallowed, expanded_have, db=db
)
else:
allowed_intersection = set()
if allowed_intersection == expanded_disallowed:
# full scope allowed (requested scope is subset of user scopes)
allowed_scopes.add(scope)
disallowed_scopes.remove(scope)
expanded_allowed = expand_scopes(allowed_scopes, user, client)
elif allowed_intersection:
# some scopes get through, but not all,
# allow the subset
allowed_scopes |= allowed_intersection
expanded_allowed = expand_scopes(allowed_scopes, user, client)
# choice: report that the requested scope wasn't _fully_ granted (current behavior)
# or report the exact (likely too detailed) set of not granted scopes (below)
# disallowed_scopes.remove(scope)
# disallowed_scopes |= expanded_disallowed.difference(allowed_intersection)
else:
# no new scopes granted, original check was right
pass
return (allowed_scopes, disallowed_scopes)
def _needs_scope_expansion(filter_, filter_value, sub_scope): def _needs_scope_expansion(filter_, filter_value, sub_scope):
""" """
Check if there is a requirements to expand the `group` scope to individual `user` scopes. Check if there is a requirements to expand the `group` scope to individual `user` scopes.

View File

@@ -349,7 +349,9 @@ class HubAuth(SingletonConfigurable):
@property @property
def oauth_scopes(self): def oauth_scopes(self):
warnings.warn( warnings.warn(
"HubAuth.oauth_scopes is deprecated in JupyterHub 3.0. Use .access_scopes" "HubAuth.oauth_scopes is deprecated in JupyterHub 3.0. Use .access_scopes",
DeprecationWarning,
stacklevel=2,
) )
return self.access_scopes return self.access_scopes

View File

@@ -378,8 +378,8 @@ class Spawner(LoggingConfigurable):
raise ValueError(f"No such role(s): {', '.join(missing_roles)}") raise ValueError(f"No such role(s): {', '.join(missing_roles)}")
scopes.extend(roles_to_scopes(roles)) scopes.extend(roles_to_scopes(roles))
# always add access scopes # always add access scope
scopes.extend(self.oauth_access_scopes) scopes.append(f"access:servers!server={self.user.name}/{self.name}")
return sorted(set(scopes)) return sorted(set(scopes))
will_resume = Bool( will_resume = Bool(

View File

@@ -15,6 +15,7 @@ from ..scopes import (
_check_scope_access, _check_scope_access,
_expand_self_scope, _expand_self_scope,
_intersect_expanded_scopes, _intersect_expanded_scopes,
_resolve_requested_scopes,
expand_scopes, expand_scopes,
get_scopes_for, get_scopes_for,
identify_scopes, identify_scopes,
@@ -554,21 +555,28 @@ async def test_server_state_access(
await api_request( await api_request(
app, 'users', user.name, 'servers', server_name, method='post' app, 'users', user.name, 'servers', server_name, method='post'
) )
service = create_service_with_scopes(*scopes) service = create_service_with_scopes("read:users:name!user=", *scopes)
api_token = service.new_api_token() api_token = service.new_api_token()
headers = {'Authorization': 'token %s' % api_token} headers = {'Authorization': 'token %s' % api_token}
# can I get the user model?
r = await api_request(app, 'users', user.name, headers=headers) r = await api_request(app, 'users', user.name, headers=headers)
r.raise_for_status() can_read_user_model = num_servers > 1 or 'read:users' in scopes
user_model = r.json() if can_read_user_model:
if num_servers: r.raise_for_status()
assert 'servers' in user_model user_model = r.json()
server_models = user_model['servers'] if num_servers > 1:
assert len(server_models) == num_servers assert 'servers' in user_model
for server, server_model in server_models.items(): server_models = user_model['servers']
assert keys_in.issubset(server_model) assert len(server_models) == num_servers
assert keys_out.isdisjoint(server_model) for server, server_model in server_models.items():
assert keys_in.issubset(server_model)
assert keys_out.isdisjoint(server_model)
else:
assert 'servers' not in user_model
else: else:
assert 'servers' not in user_model assert r.status_code == 404
r = await api_request( r = await api_request(
app, app,
'users', 'users',
@@ -1200,3 +1208,88 @@ def test_expand_scopes(app, user, scopes, expected, mockservice_external):
expanded = expand_scopes(scopes, owner=user.orm_user, oauth_client=oauth_client) expanded = expand_scopes(scopes, owner=user.orm_user, oauth_client=oauth_client)
assert isinstance(expanded, frozenset) assert isinstance(expanded, frozenset)
assert sorted(expanded) == sorted(expected) assert sorted(expanded) == sorted(expected)
@pytest.mark.parametrize(
"requested_scopes, have_scopes, expected_allowed, expected_disallowed",
[
(
["read:users:name!user"],
["read:users:name!user={user}"],
["read:users:name!user"],
[],
),
(
["read:servers!server"],
["read:servers!user"],
["read:servers!server"],
[],
),
(
["read:servers!server={server}"],
["read:servers"],
["read:servers!server={server}"],
[],
),
(
["admin:servers!server"],
["read:servers"],
["read:servers!server={server}"],
["admin:servers!server"],
),
(
["admin:servers", "read:users"],
["read:users"],
["read:users"],
["admin:servers"],
),
],
)
def test_resolve_requested_scopes(
app,
user,
group,
requested_scopes,
have_scopes,
expected_allowed,
expected_disallowed,
mockservice_external,
):
if isinstance(requested_scopes, str):
requested_scopes = [requested_scopes]
db = app.db
service = mockservice_external
spawner_name = "salmon"
server_name = f"{user.name}/{spawner_name}"
if '!server' in str(requested_scopes + have_scopes):
oauth_client = orm.OAuthClient()
db.add(oauth_client)
spawner = user.spawners[spawner_name]
spawner.orm_spawner.oauth_client = oauth_client
db.commit()
assert oauth_client.spawner is spawner.orm_spawner
else:
oauth_client = service.oauth_client
assert oauth_client is not None
def format_scopes(scopes):
return {
s.format(service=service.name, server=server_name, user=user.name)
for s in scopes
}
requested_scopes = format_scopes(requested_scopes)
have_scopes = format_scopes(have_scopes)
expected_allowed = format_scopes(expected_allowed)
expected_disallowed = format_scopes(expected_disallowed)
allowed, disallowed = _resolve_requested_scopes(
requested_scopes,
have_scopes,
user=user.orm_user,
client=oauth_client,
db=db,
)
assert allowed == expected_allowed
assert disallowed == expected_disallowed

View File

@@ -223,7 +223,7 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo
# explicit 'identify' maps to read:users:name!user # explicit 'identify' maps to read:users:name!user
(["token", "user"], ["identify"], ["read:users:name!user=$user"]), (["token", "user"], ["identify"], ["read:users:name!user=$user"]),
# any item outside the list isn't allowed # any item outside the list isn't allowed
(["token", "user"], ["token", "server"], None), (["token", "server"], ["token", "user"], None),
(["read-only"], ["access:services"], None), (["read-only"], ["access:services"], None),
# requesting subset # requesting subset
(["admin", "user"], ["user"], ["user"]), (["admin", "user"], ["user"], ["user"]),
@@ -246,7 +246,11 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo
["custom:jupyter_server:read:*"], ["custom:jupyter_server:read:*"],
), ),
# this one _should_ work, but doesn't until we implement expanded_scope filtering # this one _should_ work, but doesn't until we implement expanded_scope filtering
# (["read-only"], ["custom:jupyter_server:read:*!user=$user"], ["custom:jupyter_server:read:*!user=$user"]), (
["read-only"],
["custom:jupyter_server:read:*!user=$user"],
["custom:jupyter_server:read:*!user=$user"],
),
], ],
) )
async def test_oauth_service_roles( async def test_oauth_service_roles(
@@ -289,7 +293,7 @@ async def test_oauth_service_roles(
"name": "other", "name": "other",
"description": "A role not held by our test user", "description": "A role not held by our test user",
"scopes": [ "scopes": [
"admin:users", "admin-ui",
], ],
}, },
) )
@@ -299,12 +303,13 @@ async def test_oauth_service_roles(
) )
) )
app.db.commit() app.db.commit()
user = create_user_with_scopes("access:services")
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x') url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
if request_scopes: if request_scopes:
request_scopes = {s.replace("$user", user.name) for s in request_scopes}
url = url_concat(url, {"request-scope": " ".join(request_scopes)}) url = url_concat(url, {"request-scope": " ".join(request_scopes)})
# first request is only going to login and get us to the oauth form page # first request is only going to login and get us to the oauth form page
s = AsyncSession() s = AsyncSession()
user = create_user_with_scopes("access:services")
roles.grant_role(app.db, user, "user") roles.grant_role(app.db, user, "user")
roles.grant_role(app.db, user, "read-only") roles.grant_role(app.db, user, "read-only")
name = user.name name = user.name

View File

@@ -17,6 +17,7 @@ import pytest
from .. import orm from .. import orm
from .. import spawner as spawnermod from .. import spawner as spawnermod
from ..objects import Hub, Server from ..objects import Hub, Server
from ..scopes import access_scopes
from ..spawner import LocalProcessSpawner, Spawner from ..spawner import LocalProcessSpawner, Spawner
from ..user import User from ..user import User
from ..utils import AnyTimeoutError, new_token, url_path_join from ..utils import AnyTimeoutError, new_token, url_path_join
@@ -444,7 +445,7 @@ async def test_spawner_oauth_scopes(app, user):
await spawner.user.spawn() await spawner.user.spawn()
oauth_client = spawner.orm_spawner.oauth_client oauth_client = spawner.orm_spawner.oauth_client
assert sorted(oauth_client.allowed_scopes) == sorted( assert sorted(oauth_client.allowed_scopes) == sorted(
allowed_scopes + spawner.oauth_access_scopes allowed_scopes + list(access_scopes(oauth_client))
) )
await spawner.user.stop() await spawner.user.stop()