mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-14 13:33:00 +00:00
store scopes on oauth clients, too
rather than roles, matching tokens because oauth clients are mostly involved with issuing tokens, they don't have roles themselves (their owners do). This deprecates the `oauth_roles` config on Spawners and Services, in favor of `oauth_allowed_scopes`. The ambiguously named `oauth_scopes` is renamed to `oauth_access_scopes`.
This commit is contained in:
@@ -564,7 +564,7 @@ paths:
|
||||
A list of role names from which to derive scopes.
|
||||
This is a shortcut for assigning collections of scopes;
|
||||
Tokens do not retain role assignment.
|
||||
(Changed in 2.3: roles are immediately resolved to scopes
|
||||
(Changed in 2.4: roles are immediately resolved to scopes
|
||||
instead of stored on roles.)
|
||||
items:
|
||||
type: string
|
||||
@@ -572,7 +572,7 @@ paths:
|
||||
type: array
|
||||
description: |
|
||||
A list of scopes that the token should have.
|
||||
(new in JupyterHub 2.3).
|
||||
(new in JupyterHub 2.4).
|
||||
items:
|
||||
type: string
|
||||
required: false
|
||||
|
@@ -231,7 +231,12 @@ async def write_something(request):
|
||||
|
||||
If you use {class}`~.HubOAuthenticated`, this check is performed automatically
|
||||
against the `.hub_scopes` attribute of each Handler
|
||||
(the default is populated from `$JUPYTERHUB_OAUTH_SCOPES` and usually `access:services!service=myservice`).
|
||||
(the default is populated from `$JUPYTERHUB_OAUTH_ACCESS_SCOPES` and usually `access:services!service=myservice`).
|
||||
|
||||
:::{versionchanged} 2.4
|
||||
The JUPYTERHUB_OAUTH_SCOPES environment variable is deprecated and renamed to JUPYTERHUB_OAUTH_ACCESS_SCOPES,
|
||||
to avoid ambiguity with JUPYTERHUB_OAUTH_ALLOWED_SCOPES
|
||||
:::
|
||||
|
||||
```python
|
||||
from tornado import web
|
||||
|
@@ -116,7 +116,10 @@ JUPYTERHUB_BASE_URL: Base URL of the Hub (https://mydomain[:port]/)
|
||||
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/:service-name/)
|
||||
JUPYTERHUB_SERVICE_URL: Local URL where the service is expected to be listening.
|
||||
Only for proxied web services.
|
||||
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service.
|
||||
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service
|
||||
(deprecated in 2.4, use JUPYTERHUB_OAUTH_ACCESS_SCOPES).
|
||||
JUPYTERHUB_OAUTH_ACCESS_SCOPES: JSON-serialized list of scopes to use for allowing access to the service (new in 2.4).
|
||||
JUPYTERHUB_OAUTH_ALLOWED_SCOPES: JSON-serialized list of scopes that can be requested on behalf of users (new in 2.4).
|
||||
```
|
||||
|
||||
For the previous 'cull idle' Service example, these environment variables
|
||||
@@ -376,7 +379,7 @@ The `scopes` field can be used to manage access.
|
||||
Note: a user will have access to a service to complete oauth access to the service for the first time.
|
||||
Individual permissions may be revoked at any later point without revoking the token,
|
||||
in which case the `scopes` field in this model should be checked on each access.
|
||||
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_SCOPES`.
|
||||
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_ACCESS_SCOPES`.
|
||||
|
||||
An example of using an Externally-Managed Service and authentication is
|
||||
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
|
||||
|
@@ -308,6 +308,9 @@ The process environment is returned by `Spawner.get_env`, which specifies the fo
|
||||
This is also the OAuth client secret.
|
||||
- JUPYTERHUB_CLIENT_ID - the OAuth client ID for authenticating visitors.
|
||||
- JUPYTERHUB_OAUTH_CALLBACK_URL - the callback URL to use in oauth, typically `/user/:name/oauth_callback`
|
||||
- JUPYTERHUB_OAUTH_ACCESS_SCOPES - the scopes required to access the server (called JUPYTERHUB_OAUTH_SCOPES prior to 2.4)
|
||||
- JUPYTERHUB_OAUTH_ALLOWED_SCOPES - the scopes the service is allowed to request.
|
||||
If no scopes are requested explicitly, these scopes will be requested.
|
||||
|
||||
Optional environment variables, depending on configuration:
|
||||
|
||||
|
@@ -7,7 +7,10 @@ c.JupyterHub.services = [
|
||||
'name': 'grades',
|
||||
'url': 'http://127.0.0.1:10101',
|
||||
'command': [sys.executable, './grades.py'],
|
||||
'oauth_roles': ['grader'],
|
||||
'oauth_allowed_scopes': [
|
||||
'custom:grades:write',
|
||||
'custom:grades:read',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
@@ -26,7 +26,7 @@ After logging in with any username and password, you should see a JSON dump of y
|
||||
```
|
||||
|
||||
What is contained in the model will depend on the permissions
|
||||
requested in the `oauth_roles` configuration of the service `whoami-oauth` service.
|
||||
requested in the `oauth_allowed_scopes` configuration of the service `whoami-oauth` service.
|
||||
The default is the minimum required for identification and access to the service,
|
||||
which will provide the username and current scopes.
|
||||
|
||||
|
@@ -14,11 +14,11 @@ c.JupyterHub.services = [
|
||||
# only requesting access to the service,
|
||||
# and identification by name,
|
||||
# nothing more.
|
||||
# Specifying 'oauth_roles' as a list of role names
|
||||
# Specifying 'oauth_allowed_scopes' as a list of scopes
|
||||
# allows requesting more information about users,
|
||||
# or the ability to take actions on users' behalf, as required.
|
||||
# The default 'token' role has the full permissions of its owner:
|
||||
# 'oauth_roles': ['token'],
|
||||
# the 'inherit' scope means the full permissions of the owner
|
||||
# 'oauth_allowed_scopes': ['inherit'],
|
||||
},
|
||||
]
|
||||
|
||||
|
@@ -13,12 +13,11 @@ depends_on = None
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import Column, ForeignKey, Integer, Table, Unicode
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import Column, ForeignKey, Table
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from jupyterhub import orm, roles
|
||||
from jupyterhub import orm, roles, scopes
|
||||
|
||||
|
||||
def upgrade():
|
||||
@@ -33,45 +32,84 @@ def upgrade():
|
||||
if 'oauth_codes' in tables:
|
||||
op.add_column('oauth_codes', sa.Column('scopes', orm.JSONList(), nullable=True))
|
||||
|
||||
if 'api_tokens' not in tables:
|
||||
if 'api_tokens' in tables:
|
||||
# may not be present,
|
||||
# e.g. upgrade from 1.x, token table dropped
|
||||
# no migration to do
|
||||
return
|
||||
# in which case no migration to do
|
||||
|
||||
# define new scopes column on API tokens
|
||||
op.add_column('api_tokens', sa.Column('scopes', orm.JSONList(), nullable=True))
|
||||
# define new scopes column on API tokens
|
||||
op.add_column('api_tokens', sa.Column('scopes', orm.JSONList(), nullable=True))
|
||||
|
||||
if 'api_token_role_map' in tables:
|
||||
# redefine the to-be-removed api_token->role relationship
|
||||
# so we can run a query on it for the migration
|
||||
token_role_map = Table(
|
||||
"api_token_role_map",
|
||||
orm.Base.metadata,
|
||||
Column(
|
||||
'api_token_id',
|
||||
ForeignKey('api_tokens.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
Column(
|
||||
'role_id',
|
||||
ForeignKey('roles.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
extend_existing=True,
|
||||
if 'api_token_role_map' in tables:
|
||||
# redefine the to-be-removed api_token->role relationship
|
||||
# so we can run a query on it for the migration
|
||||
token_role_map = Table(
|
||||
"api_token_role_map",
|
||||
orm.Base.metadata,
|
||||
Column(
|
||||
'api_token_id',
|
||||
ForeignKey('api_tokens.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
Column(
|
||||
'role_id',
|
||||
ForeignKey('roles.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
extend_existing=True,
|
||||
)
|
||||
orm.APIToken.roles = relationship('Role', secondary='api_token_role_map')
|
||||
|
||||
# tokens have roles, evaluate to scopes
|
||||
db = Session(bind=c)
|
||||
for token in db.query(orm.APIToken):
|
||||
token.scopes = list(roles.roles_to_scopes(token.roles))
|
||||
db.commit()
|
||||
# drop token-role relationship
|
||||
op.drop_table('api_token_role_map')
|
||||
|
||||
if 'oauth_clients' in tables:
|
||||
# define new scopes column on API tokens
|
||||
op.add_column(
|
||||
'oauth_clients', sa.Column('allowed_scopes', orm.JSONList(), nullable=True)
|
||||
)
|
||||
orm.APIToken.roles = relationship('Role', secondary='api_token_role_map')
|
||||
|
||||
# tokens have roles, evaluate to scopes
|
||||
db = Session(bind=c)
|
||||
for token in db.query(orm.APIToken):
|
||||
token.scopes = list(roles.roles_to_scopes(token.roles))
|
||||
db.commit()
|
||||
# drop token-role relationship
|
||||
op.drop_table('api_token_role_map')
|
||||
if 'oauth_client_role_map' in tables:
|
||||
# redefine the to-be-removed api_token->role relationship
|
||||
# so we can run a query on it for the migration
|
||||
client_role_map = Table(
|
||||
"oauth_client_role_map",
|
||||
orm.Base.metadata,
|
||||
Column(
|
||||
'oauth_client_id',
|
||||
ForeignKey('oauth_clients.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
Column(
|
||||
'role_id',
|
||||
ForeignKey('roles.id', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
),
|
||||
extend_existing=True,
|
||||
)
|
||||
orm.OAuthClient.allowed_roles = relationship(
|
||||
'Role', secondary='oauth_client_role_map'
|
||||
)
|
||||
|
||||
# oauth clients have allowed_roles, evaluate to allowed_scopes
|
||||
db = Session(bind=c)
|
||||
for oauth_client in db.query(orm.OAuthClient):
|
||||
allowed_scopes = set(roles.roles_to_scopes(oauth_client.allowed_roles))
|
||||
allowed_scopes.update(scopes.access_scopes(oauth_client))
|
||||
oauth_client.allowed_scopes = sorted(allowed_scopes)
|
||||
db.commit()
|
||||
# drop token-role relationship
|
||||
op.drop_table('oauth_client_role_map')
|
||||
|
||||
|
||||
def downgrade():
|
||||
# cannot map permissions from scopes back to roles
|
||||
# drop whole api token table (revokes all tokens), which will be recreated on hub start
|
||||
op.drop_table('api_tokens')
|
||||
op.drop_table('oauth_clients')
|
||||
op.drop_table('oauth_codes')
|
||||
|
@@ -2372,21 +2372,34 @@ class JupyterHub(Application):
|
||||
service.orm.server = None
|
||||
|
||||
if service.oauth_available:
|
||||
allowed_roles = []
|
||||
allowed_scopes = set()
|
||||
if service.oauth_allowed_scopes:
|
||||
allowed_scopes.update(service.oauth_allowed_scopes)
|
||||
if service.oauth_roles:
|
||||
allowed_roles = list(
|
||||
self.db.query(orm.Role).filter(
|
||||
orm.Role.name.in_(service.oauth_roles)
|
||||
if not allowed_scopes:
|
||||
# DEPRECATED? It's still convenient and valid,
|
||||
# e.g. 'admin'
|
||||
allowed_roles = list(
|
||||
self.db.query(orm.Role).filter(
|
||||
orm.Role.name.in_(service.oauth_roles)
|
||||
)
|
||||
)
|
||||
allowed_scopes.update(roles.roles_to_scopes(allowed_roles))
|
||||
else:
|
||||
self.log.warning(
|
||||
f"Ignoring oauth_roles for {service.name}: {service.oauth_roles},"
|
||||
f" using oauth_allowed_scopes={allowed_scopes}."
|
||||
)
|
||||
)
|
||||
oauth_client = self.oauth_provider.add_client(
|
||||
client_id=service.oauth_client_id,
|
||||
client_secret=service.api_token,
|
||||
redirect_uri=service.oauth_redirect_uri,
|
||||
allowed_roles=allowed_roles,
|
||||
description="JupyterHub service %s" % service.name,
|
||||
)
|
||||
service.orm.oauth_client = oauth_client
|
||||
# add access-scopes, derived from OAuthClient itself
|
||||
allowed_scopes.update(scopes.access_scopes(oauth_client))
|
||||
oauth_client.allowed_scopes = sorted(allowed_scopes)
|
||||
else:
|
||||
if service.oauth_client:
|
||||
self.db.delete(service.oauth_client)
|
||||
|
@@ -151,7 +151,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
)
|
||||
if orm_client is None:
|
||||
raise ValueError("No such client: %s" % client_id)
|
||||
scopes = roles_to_scopes(orm_client.allowed_roles)
|
||||
scopes = set(orm_client.allowed_scopes)
|
||||
if 'inherit' not in scopes:
|
||||
# add identify-user scope
|
||||
# and access-service scope
|
||||
@@ -570,7 +570,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
|
||||
# TODO: handle roles->scopes transition
|
||||
# In 2.0-2.2, `?scopes=` only accepted _role_ names,
|
||||
# but in 2.3 we accept and prefer scopes.
|
||||
# but in 2.4 we accept and prefer scopes.
|
||||
# For backward-compatibility, we still accept both.
|
||||
# Should roles be deprecated here, or kept as a convenience?
|
||||
try:
|
||||
@@ -589,7 +589,7 @@ class JupyterHubRequestValidator(RequestValidator):
|
||||
)
|
||||
requested_scopes = roles_to_scopes(requested_roles)
|
||||
|
||||
client_allowed_scopes = roles_to_scopes(orm_client.allowed_roles)
|
||||
client_allowed_scopes = set(orm_client.allowed_scopes)
|
||||
|
||||
# always grant reading the token-owner's name
|
||||
# and accessing the service itself
|
||||
@@ -624,7 +624,12 @@ class JupyterHubOAuthServer(WebApplicationServer):
|
||||
super().__init__(validator, *args, **kwargs)
|
||||
|
||||
def add_client(
|
||||
self, client_id, client_secret, redirect_uri, allowed_roles=None, description=''
|
||||
self,
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uri,
|
||||
allowed_scopes=None,
|
||||
description='',
|
||||
):
|
||||
"""Add a client
|
||||
|
||||
@@ -646,12 +651,12 @@ class JupyterHubOAuthServer(WebApplicationServer):
|
||||
app_log.info(f'Creating oauth client {client_id}')
|
||||
else:
|
||||
app_log.info(f'Updating oauth client {client_id}')
|
||||
if allowed_roles == None:
|
||||
allowed_roles = []
|
||||
if allowed_scopes == None:
|
||||
allowed_scopes = []
|
||||
orm_client.secret = hash_token(client_secret) if client_secret else ""
|
||||
orm_client.redirect_uri = redirect_uri
|
||||
orm_client.description = description or client_id
|
||||
orm_client.allowed_roles = allowed_roles
|
||||
orm_client.allowed_scopes = list(allowed_scopes)
|
||||
self.db.commit()
|
||||
return orm_client
|
||||
|
||||
|
@@ -39,7 +39,6 @@ from sqlalchemy.sql.expression import bindparam
|
||||
from sqlalchemy.types import LargeBinary, Text, TypeDecorator
|
||||
from tornado.log import app_log
|
||||
|
||||
from .roles import roles_to_scopes
|
||||
from .utils import compare_token, hash_token, new_token, random_port
|
||||
|
||||
# top-level variable for easier mocking in tests
|
||||
@@ -152,7 +151,6 @@ for has_role in (
|
||||
'user',
|
||||
'group',
|
||||
'service',
|
||||
'oauth_client',
|
||||
):
|
||||
role_map = Table(
|
||||
f'{has_role}_role_map',
|
||||
@@ -696,6 +694,9 @@ class APIToken(Hashed, Base):
|
||||
else:
|
||||
cls.check_token(db, token)
|
||||
|
||||
# avoid circular import
|
||||
from .roles import roles_to_scopes
|
||||
|
||||
if scopes is not None and roles is not None:
|
||||
raise ValueError(
|
||||
"Can only assign one of scopes or roles when creating tokens."
|
||||
@@ -826,9 +827,10 @@ class OAuthClient(Base):
|
||||
)
|
||||
codes = relationship(OAuthCode, backref='client', cascade='all, delete-orphan')
|
||||
|
||||
# these are the roles an oauth client is allowed to request
|
||||
# *not* the roles of the client itself
|
||||
allowed_roles = relationship('Role', secondary='oauth_client_role_map')
|
||||
# these are the scopes an oauth client is allowed to request
|
||||
# *not* the scopes of the client itself
|
||||
|
||||
allowed_scopes = Column(JSONList, default=[])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__}(identifier={self.identifier!r})>"
|
||||
|
@@ -343,7 +343,9 @@ class HubAuth(SingletonConfigurable):
|
||||
|
||||
@default('oauth_scopes')
|
||||
def _default_scopes(self):
|
||||
env_scopes = os.getenv('JUPYTERHUB_OAUTH_SCOPES')
|
||||
env_scopes = os.getenv('JUPYTERHUB_OAUTH_ACCESS_SCOPES')
|
||||
if not env_scopes:
|
||||
env_scopes = os.getenv('JUPYTERHUB_OAUTH_ACCESS_SCOPES')
|
||||
if env_scopes:
|
||||
return set(json.loads(env_scopes))
|
||||
service_name = os.getenv("JUPYTERHUB_SERVICE_NAME")
|
||||
@@ -864,7 +866,7 @@ class HubAuthenticated:
|
||||
- .hub_auth: A HubAuth instance
|
||||
- .hub_scopes: A set of JupyterHub 2.0 OAuth scopes to allow.
|
||||
Default comes from .hub_auth.oauth_scopes,
|
||||
which in turn is set by $JUPYTERHUB_OAUTH_SCOPES
|
||||
which in turn is set by $JUPYTERHUB_OAUTH_ACCESS_SCOPES
|
||||
Default values include:
|
||||
- 'access:services', 'access:services!service={service_name}' for services
|
||||
- 'access:servers', 'access:servers!user={user}',
|
||||
|
@@ -102,8 +102,8 @@ class _ServiceSpawner(LocalProcessSpawner):
|
||||
cmd = Command(minlen=0)
|
||||
_service_name = Unicode()
|
||||
|
||||
@default("oauth_scopes")
|
||||
def _default_oauth_scopes(self):
|
||||
@default("oauth_access_scopes")
|
||||
def _default_oauth_access_scopes(self):
|
||||
return [
|
||||
"access:services",
|
||||
f"access:services!service={self._service_name}",
|
||||
@@ -203,7 +203,14 @@ class Service(LoggingConfigurable):
|
||||
oauth_roles = List(
|
||||
help="""OAuth allowed roles.
|
||||
|
||||
This sets the maximum and default roles
|
||||
DEPRECATED in 2.4: use oauth_allowed_scopes
|
||||
"""
|
||||
).tag(input=True)
|
||||
|
||||
oauth_allowed_scopes = List(
|
||||
help="""OAuth allowed scopes.
|
||||
|
||||
This sets the maximum and default scopes
|
||||
assigned to oauth tokens issued for this service
|
||||
(i.e. tokens stored in browsers after authenticating with the server),
|
||||
defining what actions the service can take on behalf of logged-in users.
|
||||
|
@@ -36,7 +36,9 @@ from traitlets import (
|
||||
)
|
||||
from traitlets.config import LoggingConfigurable
|
||||
|
||||
from . import orm
|
||||
from .objects import Server
|
||||
from .roles import roles_to_scopes
|
||||
from .traitlets import ByteSpecification, Callable, Command
|
||||
from .utils import (
|
||||
AnyTimeoutError,
|
||||
@@ -274,8 +276,25 @@ class Spawner(LoggingConfigurable):
|
||||
|
||||
oauth_scopes = List(Unicode())
|
||||
|
||||
@default("oauth_scopes")
|
||||
def _default_oauth_scopes(self):
|
||||
@property
|
||||
def oauth_scopes(self):
|
||||
warnings.warn(
|
||||
"""Spawner.oauth_scopes is deprecated in JupyterHub 2.3.
|
||||
|
||||
Use Spawner.oauth_access_scopes
|
||||
""",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self.oauth_access_scopes
|
||||
|
||||
oauth_access_scopes = List(
|
||||
Unicode(),
|
||||
help="""The scope(s) needed to access this server""",
|
||||
)
|
||||
|
||||
@default("oauth_access_scopes")
|
||||
def _default_access_scopes(self):
|
||||
return [
|
||||
f"access:servers!server={self.user.name}/{self.name}",
|
||||
f"access:servers!user={self.user.name}",
|
||||
@@ -287,16 +306,77 @@ class Spawner(LoggingConfigurable):
|
||||
[Callable(), List()],
|
||||
help="""Allowed roles for oauth tokens.
|
||||
|
||||
Deprecated in 2.4: use oauth_allowed_scopes
|
||||
|
||||
This sets the maximum and default roles
|
||||
assigned to oauth tokens issued by a single-user server's
|
||||
oauth client (i.e. tokens stored in browsers after authenticating with the server),
|
||||
defining what actions the server can take on behalf of logged-in users.
|
||||
|
||||
Default is an empty list, meaning minimal permissions to identify users,
|
||||
no actions can be taken on their behalf.
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
oauth_allowed_scopes = Union(
|
||||
[Callable(), List()],
|
||||
help="""Allowed scopes for oauth tokens.
|
||||
|
||||
This sets the maximum and default scopes
|
||||
assigned to oauth tokens issued by a single-user server's
|
||||
oauth client (i.e. tokens stored in browsers after authenticating with the server),
|
||||
defining what actions the server can take on behalf of logged-in users.
|
||||
|
||||
Default is an empty list, meaning minimal permissions to identify users,
|
||||
no actions can be taken on their behalf.
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
def _get_oauth_allowed_scopes(self):
|
||||
"""Private method: get oauth allowed scopes
|
||||
|
||||
Handle:
|
||||
|
||||
- oauth_allowed_scopes
|
||||
- callable config
|
||||
- deprecated oauth_roles config
|
||||
- access_scopes
|
||||
"""
|
||||
# cases:
|
||||
# 1. only scopes
|
||||
# 2. only roles
|
||||
# 3. both! (conflict, favor scopes)
|
||||
scopes = []
|
||||
if self.oauth_allowed_scopes:
|
||||
allowed_scopes = self.oauth_allowed_scopes
|
||||
if callable(allowed_scopes):
|
||||
allowed_scopes = allowed_scopes(self)
|
||||
scopes.extend(allowed_scopes)
|
||||
|
||||
if self.oauth_roles:
|
||||
if scopes:
|
||||
# both defined! Warn
|
||||
warnings.warn(
|
||||
f"Ignoring deprecated Spawner.oauth_roles={self.oauth_roles} in favor of Spawner.oauth_allowed_scopes.",
|
||||
)
|
||||
else:
|
||||
role_names = self.oauth_roles
|
||||
if callable(role_names):
|
||||
role_names = role_names(self)
|
||||
roles = list(
|
||||
self.db.query(orm.Role).filter(orm.Role.name.in_(role_names))
|
||||
)
|
||||
if len(roles) != len(role_names):
|
||||
missing_roles = set(role_names).difference(
|
||||
{role.name for role in roles}
|
||||
)
|
||||
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)
|
||||
return sorted(set(scopes))
|
||||
|
||||
will_resume = Bool(
|
||||
False,
|
||||
help="""Whether the Spawner will resume on next start
|
||||
@@ -875,7 +955,12 @@ class Spawner(LoggingConfigurable):
|
||||
self.user.url, url_escape_path(self.name), 'oauth_callback'
|
||||
)
|
||||
|
||||
env['JUPYTERHUB_OAUTH_SCOPES'] = json.dumps(self.oauth_scopes)
|
||||
# deprecated env, renamed in 2.4 for disambiguation
|
||||
env['JUPYTERHUB_OAUTH_SCOPES'] = json.dumps(self.oauth_access_scopes)
|
||||
env['JUPYTERHUB_OAUTH_ACCESS_SCOPES'] = json.dumps(self.oauth_access_scopes)
|
||||
|
||||
# added in 2.4
|
||||
env['JUPYTERHUB_OAUTH_ALLOWED_SCOPES'] = json.dumps(self.oauth_allowed_scopes)
|
||||
|
||||
# Info previously passed on args
|
||||
env['JUPYTERHUB_USER'] = self.user.name
|
||||
|
@@ -1153,7 +1153,7 @@ async def test_oauth_page_scope_appearance(
|
||||
.filter_by(identifier=service.oauth_client_id)
|
||||
.one()
|
||||
)
|
||||
oauth_client.allowed_roles = [service_role]
|
||||
oauth_client.allowed_scopes = sorted(roles.roles_to_scopes([service_role]))
|
||||
app.db.commit()
|
||||
|
||||
s = AsyncSession()
|
||||
|
@@ -894,20 +894,18 @@ async def test_server_role_api_calls(
|
||||
assert r.status_code == response
|
||||
|
||||
|
||||
async def test_oauth_allowed_roles(app, create_temp_role):
|
||||
allowed_roles = ['oracle', 'goose']
|
||||
async def test_oauth_allowed_scopes(app):
|
||||
allowed_scopes = ['read:users', 'read:groups']
|
||||
service = {
|
||||
'name': 'oas1',
|
||||
'api_token': 'some-token',
|
||||
'oauth_roles': ['oracle', 'goose'],
|
||||
'oauth_allowed_scopes': allowed_scopes,
|
||||
}
|
||||
for role in allowed_roles:
|
||||
create_temp_role('read:users', role_name=role)
|
||||
app.services.append(service)
|
||||
app.init_services()
|
||||
app_service = app.services[0]
|
||||
assert app_service['name'] == 'oas1'
|
||||
assert set(app_service['oauth_roles']) == set(allowed_roles)
|
||||
assert set(app_service['oauth_allowed_scopes']) == set(allowed_scopes)
|
||||
|
||||
|
||||
async def test_user_group_roles(app, create_temp_role):
|
||||
|
@@ -7,7 +7,7 @@ from subprocess import Popen
|
||||
from async_generator import asynccontextmanager
|
||||
|
||||
from .. import orm
|
||||
from ..roles import update_roles
|
||||
from ..roles import roles_to_scopes
|
||||
from ..utils import (
|
||||
exponential_backoff,
|
||||
maybe_future,
|
||||
@@ -97,7 +97,10 @@ async def test_external_service(app):
|
||||
service = app._service_map[name]
|
||||
assert service.oauth_available
|
||||
assert service.oauth_client is not None
|
||||
assert service.oauth_client.allowed_roles == [orm.Role.find(app.db, "user")]
|
||||
assert set(service.oauth_client.allowed_scopes) == {
|
||||
"self",
|
||||
f"access:services!service={name}",
|
||||
}
|
||||
api_token = service.orm.api_tokens[0]
|
||||
url = public_url(app, service) + '/api/users'
|
||||
r = await async_requests.get(url, allow_redirects=False)
|
||||
|
@@ -4,7 +4,7 @@ import os
|
||||
import sys
|
||||
from binascii import hexlify
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -13,6 +13,7 @@ from tornado.httputil import url_concat
|
||||
from tornado.log import app_log
|
||||
|
||||
from .. import orm, roles, scopes
|
||||
from ..roles import roles_to_scopes
|
||||
from ..services.auth import _ExpiringDict
|
||||
from ..utils import url_path_join
|
||||
from .mocking import public_url
|
||||
@@ -292,9 +293,11 @@ async def test_oauth_service_roles(
|
||||
],
|
||||
},
|
||||
)
|
||||
oauth_client.allowed_roles = [
|
||||
orm.Role.find(app.db, role_name) for role_name in client_allowed_roles
|
||||
]
|
||||
oauth_client.allowed_scopes = sorted(
|
||||
roles_to_scopes(
|
||||
[orm.Role.find(app.db, role_name) for role_name in client_allowed_roles]
|
||||
)
|
||||
)
|
||||
app.db.commit()
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||
if request_scopes:
|
||||
@@ -486,7 +489,7 @@ async def test_oauth_page_hit(
|
||||
.filter_by(identifier=service.oauth_client_id)
|
||||
.one()
|
||||
)
|
||||
oauth_client.allowed_roles = list(test_roles.values())
|
||||
oauth_client.allowed_scopes = sorted(roles_to_scopes(list(test_roles.values())))
|
||||
|
||||
authorized_scopes = roles.roles_to_scopes([test_roles[t] for t in token_roles])
|
||||
authorized_scopes.update(scopes.identify_scopes())
|
||||
|
@@ -436,14 +436,16 @@ async def test_hub_connect_url(db):
|
||||
)
|
||||
|
||||
|
||||
async def test_spawner_oauth_roles(app, user):
|
||||
allowed_roles = ["admin", "user"]
|
||||
async def test_spawner_oauth_scopes(app, user):
|
||||
allowed_scopes = ["read:users"]
|
||||
spawner = user.spawners['']
|
||||
spawner.oauth_roles = allowed_roles
|
||||
spawner.oauth_allowed_scopes = allowed_scopes
|
||||
# exercise start/stop which assign roles to oauth client
|
||||
await spawner.user.spawn()
|
||||
oauth_client = spawner.orm_spawner.oauth_client
|
||||
assert sorted(role.name for role in oauth_client.allowed_roles) == allowed_roles
|
||||
assert sorted(oauth_client.allowed_scopes) == sorted(
|
||||
allowed_scopes + spawner.oauth_access_scopes
|
||||
)
|
||||
await spawner.user.stop()
|
||||
|
||||
|
||||
|
@@ -666,28 +666,11 @@ class User:
|
||||
client_id = spawner.oauth_client_id
|
||||
oauth_provider = self.settings.get('oauth_provider')
|
||||
if oauth_provider:
|
||||
allowed_roles = spawner.oauth_roles
|
||||
if callable(allowed_roles):
|
||||
allowed_roles = allowed_roles(spawner)
|
||||
|
||||
# allowed_roles config is a list of strings
|
||||
# oauth provider.allowed_roles is a list of orm.Roles
|
||||
if allowed_roles:
|
||||
allowed_role_names = allowed_roles
|
||||
allowed_roles = list(
|
||||
self.db.query(orm.Role).filter(orm.Role.name.in_(allowed_roles))
|
||||
)
|
||||
if len(allowed_roles) != len(allowed_role_names):
|
||||
missing_roles = set(allowed_role_names).difference(
|
||||
{role.name for role in allowed_roles}
|
||||
)
|
||||
raise ValueError(f"No such role(s): {', '.join(missing_roles)}")
|
||||
|
||||
oauth_client = oauth_provider.add_client(
|
||||
client_id,
|
||||
api_token,
|
||||
url_path_join(self.url, url_escape_path(server_name), 'oauth_callback'),
|
||||
allowed_roles=allowed_roles,
|
||||
allowed_scopes=spawner._get_oauth_allowed_scopes(),
|
||||
description="Server at %s"
|
||||
% (url_path_join(self.base_url, server_name) + '/'),
|
||||
)
|
||||
|
Reference in New Issue
Block a user