mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-10 19:43:01 +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
|
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
|
||||||
|
@@ -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,6 +242,12 @@ 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(
|
||||||
|
self.oauth_provider.request_validator,
|
||||||
|
"_current_user",
|
||||||
|
self.current_user,
|
||||||
|
create=True,
|
||||||
|
):
|
||||||
(
|
(
|
||||||
requested_scopes,
|
requested_scopes,
|
||||||
credentials,
|
credentials,
|
||||||
@@ -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},"
|
||||||
|
@@ -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)}"
|
||||||
|
@@ -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.
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
@@ -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(
|
||||||
|
@@ -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,13 +555,17 @@ 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)
|
||||||
|
can_read_user_model = num_servers > 1 or 'read:users' in scopes
|
||||||
|
if can_read_user_model:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
user_model = r.json()
|
user_model = r.json()
|
||||||
if num_servers:
|
if num_servers > 1:
|
||||||
assert 'servers' in user_model
|
assert 'servers' in user_model
|
||||||
server_models = user_model['servers']
|
server_models = user_model['servers']
|
||||||
assert len(server_models) == num_servers
|
assert len(server_models) == num_servers
|
||||||
@@ -569,6 +574,9 @@ async def test_server_state_access(
|
|||||||
assert keys_out.isdisjoint(server_model)
|
assert keys_out.isdisjoint(server_model)
|
||||||
else:
|
else:
|
||||||
assert 'servers' not in user_model
|
assert 'servers' not in user_model
|
||||||
|
else:
|
||||||
|
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
|
||||||
|
@@ -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
|
||||||
|
@@ -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()
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user