mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 10:34:10 +00:00
Merge branch 'jupyterhub:main' into documentation
This commit is contained in:
@@ -4,7 +4,7 @@ The default Authenticator uses [PAM][] to authenticate system users with
|
||||
their username and password. With the default Authenticator, any user
|
||||
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,
|
||||
`Authenticator.allowed_users`:
|
||||
@@ -25,7 +25,7 @@ If this configuration value is not set, then **all authenticated users will be a
|
||||
```{note}
|
||||
As of JupyterHub 2.0, the full permissions of `admin_users`
|
||||
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.
|
||||
```
|
||||
|
||||
@@ -43,9 +43,9 @@ Users in the admin set are automatically added to the user `allowed_users` set,
|
||||
if they are not already present.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
owned by _other users_. If `JupyterHub.admin_access` is set to `True`,
|
||||
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.**
|
||||
|
||||
## 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
|
||||
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,
|
||||
@@ -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
|
||||
already exists. If you set the configuration value, `create_system_users`,
|
||||
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:
|
||||
|
||||
```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
|
||||
result in the Hub creating that user via the system `adduser` command
|
||||
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
|
||||
JupyterHub in situations where JupyterHub users map directly onto the
|
||||
system's UNIX users.
|
||||
@@ -101,19 +101,19 @@ system's UNIX users.
|
||||
JupyterHub's [OAuthenticator][] currently supports the following
|
||||
popular services:
|
||||
|
||||
- Auth0
|
||||
- Azure AD
|
||||
- Bitbucket
|
||||
- CILogon
|
||||
- GitHub
|
||||
- GitLab
|
||||
- Globus
|
||||
- Google
|
||||
- MediaWiki
|
||||
- Okpy
|
||||
- OpenShift
|
||||
- [Auth0](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.auth0.html#module-oauthenticator.auth0)
|
||||
- [Azure AD](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.azuread.html#module-oauthenticator.azuread)
|
||||
- [Bitbucket](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.bitbucket.html#module-oauthenticator.bitbucket)
|
||||
- [CILogon](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.cilogon.html#module-oauthenticator.cilogon)
|
||||
- [GitHub](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.github.html#module-oauthenticator.github)
|
||||
- [GitLab](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.gitlab.html#module-oauthenticator.gitlab)
|
||||
- [Globus](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.globus.html#module-oauthenticator.globus)
|
||||
- [Google](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.google.html#module-oauthenticator.google)
|
||||
- [MediaWiki](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.mediawiki.html#module-oauthenticator.mediawiki)
|
||||
- [Okpy](https://oauthenticator.readthedocs.io/en/latest/api/gen/oauthenticator.okpy.html#module-oauthenticator.okpy)
|
||||
- [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.
|
||||
|
||||
## Use DummyAuthenticator for testing
|
||||
|
@@ -3,6 +3,7 @@
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import json
|
||||
from datetime import datetime
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse
|
||||
|
||||
from oauthlib import oauth2
|
||||
@@ -241,6 +242,12 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
|
||||
uri, http_method, body, headers = self.extract_oauth_params()
|
||||
try:
|
||||
with mock.patch.object(
|
||||
self.oauth_provider.request_validator,
|
||||
"_current_user",
|
||||
self.current_user,
|
||||
create=True,
|
||||
):
|
||||
(
|
||||
requested_scopes,
|
||||
credentials,
|
||||
@@ -289,12 +296,15 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
required_scopes = {*scopes.identify_scopes(), *scopes.access_scopes(client)}
|
||||
user_scopes |= {"inherit", *required_scopes}
|
||||
|
||||
allowed_scopes = requested_scopes.intersection(user_scopes)
|
||||
excluded_scopes = requested_scopes.difference(user_scopes)
|
||||
# TODO: compute lower-level intersection of remaining _expanded_ scopes
|
||||
# (e.g. user has admin:users, requesting read:users!group=x)
|
||||
allowed_scopes, disallowed_scopes = scopes._resolve_requested_scopes(
|
||||
requested_scopes,
|
||||
user_scopes,
|
||||
user=user.orm_user,
|
||||
client=client,
|
||||
db=self.db,
|
||||
)
|
||||
|
||||
if excluded_scopes:
|
||||
if disallowed_scopes:
|
||||
self.log.warning(
|
||||
f"Service {client.description} requested scopes {','.join(requested_scopes)}"
|
||||
f" for user {self.current_user.name},"
|
||||
|
@@ -9,7 +9,13 @@ from tornado.log import app_log
|
||||
|
||||
from .. import orm
|
||||
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
|
||||
|
||||
# patch absolute-uri check
|
||||
@@ -551,7 +557,6 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
- Resource Owner Password Credentials Grant
|
||||
- Client Credentials Grant
|
||||
"""
|
||||
|
||||
orm_client = (
|
||||
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)
|
||||
|
||||
# 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
|
||||
# and accessing the service itself
|
||||
required_scopes = {*identify_scopes(), *access_scopes(orm_client)}
|
||||
requested_scopes.update(required_scopes)
|
||||
client_allowed_scopes.update(required_scopes)
|
||||
|
||||
# TODO: handle expanded_scopes intersection here?
|
||||
# e.g. client allowed to request admin:users,
|
||||
# but requests admin:users!name=x will not be allowed
|
||||
# This can probably be dealt with in config by listing expected requests
|
||||
# as explcitly allowed
|
||||
allowed_scopes, disallowed_scopes = _resolve_requested_scopes(
|
||||
requested_scopes,
|
||||
client_allowed_scopes,
|
||||
user=user.orm_user,
|
||||
client=orm_client,
|
||||
db=self.db,
|
||||
)
|
||||
|
||||
disallowed_scopes = requested_scopes.difference(client_allowed_scopes)
|
||||
if disallowed_scopes:
|
||||
app_log.error(
|
||||
f"Scope(s) not allowed for {client_id}: {', '.join(disallowed_scopes)}"
|
||||
|
@@ -461,16 +461,13 @@ def _expand_scope(scope):
|
||||
# reapply !filter
|
||||
if filter_:
|
||||
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:
|
||||
expanded_scopes = expanded_scope_names
|
||||
|
||||
@@ -569,6 +566,76 @@ def expand_scopes(scopes, owner=None, oauth_client=None):
|
||||
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):
|
||||
"""
|
||||
Check if there is a requirements to expand the `group` scope to individual `user` scopes.
|
||||
|
@@ -349,7 +349,9 @@ class HubAuth(SingletonConfigurable):
|
||||
@property
|
||||
def oauth_scopes(self):
|
||||
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
|
||||
|
||||
|
@@ -378,8 +378,8 @@ class Spawner(LoggingConfigurable):
|
||||
raise ValueError(f"No such role(s): {', '.join(missing_roles)}")
|
||||
scopes.extend(roles_to_scopes(roles))
|
||||
|
||||
# always add access scopes
|
||||
scopes.extend(self.oauth_access_scopes)
|
||||
# always add access scope
|
||||
scopes.append(f"access:servers!server={self.user.name}/{self.name}")
|
||||
return sorted(set(scopes))
|
||||
|
||||
will_resume = Bool(
|
||||
|
@@ -15,6 +15,7 @@ from ..scopes import (
|
||||
_check_scope_access,
|
||||
_expand_self_scope,
|
||||
_intersect_expanded_scopes,
|
||||
_resolve_requested_scopes,
|
||||
expand_scopes,
|
||||
get_scopes_for,
|
||||
identify_scopes,
|
||||
@@ -554,13 +555,17 @@ async def test_server_state_access(
|
||||
await api_request(
|
||||
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()
|
||||
headers = {'Authorization': 'token %s' % api_token}
|
||||
|
||||
# can I get the user model?
|
||||
r = await api_request(app, 'users', user.name, headers=headers)
|
||||
can_read_user_model = num_servers > 1 or 'read:users' in scopes
|
||||
if can_read_user_model:
|
||||
r.raise_for_status()
|
||||
user_model = r.json()
|
||||
if num_servers:
|
||||
if num_servers > 1:
|
||||
assert 'servers' in user_model
|
||||
server_models = user_model['servers']
|
||||
assert len(server_models) == num_servers
|
||||
@@ -569,6 +574,9 @@ async def test_server_state_access(
|
||||
assert keys_out.isdisjoint(server_model)
|
||||
else:
|
||||
assert 'servers' not in user_model
|
||||
else:
|
||||
assert r.status_code == 404
|
||||
|
||||
r = await api_request(
|
||||
app,
|
||||
'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)
|
||||
assert isinstance(expanded, frozenset)
|
||||
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
|
||||
|
@@ -223,7 +223,7 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo
|
||||
# explicit 'identify' maps to read:users:name!user
|
||||
(["token", "user"], ["identify"], ["read:users:name!user=$user"]),
|
||||
# any item outside the list isn't allowed
|
||||
(["token", "user"], ["token", "server"], None),
|
||||
(["token", "server"], ["token", "user"], None),
|
||||
(["read-only"], ["access:services"], None),
|
||||
# requesting subset
|
||||
(["admin", "user"], ["user"], ["user"]),
|
||||
@@ -246,7 +246,11 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo
|
||||
["custom:jupyter_server:read:*"],
|
||||
),
|
||||
# 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(
|
||||
@@ -289,7 +293,7 @@ async def test_oauth_service_roles(
|
||||
"name": "other",
|
||||
"description": "A role not held by our test user",
|
||||
"scopes": [
|
||||
"admin:users",
|
||||
"admin-ui",
|
||||
],
|
||||
},
|
||||
)
|
||||
@@ -299,12 +303,13 @@ async def test_oauth_service_roles(
|
||||
)
|
||||
)
|
||||
app.db.commit()
|
||||
user = create_user_with_scopes("access:services")
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||
if request_scopes:
|
||||
request_scopes = {s.replace("$user", user.name) for s in 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
|
||||
s = AsyncSession()
|
||||
user = create_user_with_scopes("access:services")
|
||||
roles.grant_role(app.db, user, "user")
|
||||
roles.grant_role(app.db, user, "read-only")
|
||||
name = user.name
|
||||
|
@@ -17,6 +17,7 @@ import pytest
|
||||
from .. import orm
|
||||
from .. import spawner as spawnermod
|
||||
from ..objects import Hub, Server
|
||||
from ..scopes import access_scopes
|
||||
from ..spawner import LocalProcessSpawner, Spawner
|
||||
from ..user import User
|
||||
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()
|
||||
oauth_client = spawner.orm_spawner.oauth_client
|
||||
assert sorted(oauth_client.allowed_scopes) == sorted(
|
||||
allowed_scopes + spawner.oauth_access_scopes
|
||||
allowed_scopes + list(access_scopes(oauth_client))
|
||||
)
|
||||
await spawner.user.stop()
|
||||
|
||||
|
Reference in New Issue
Block a user