diff --git a/docs/source/_static/rest-api.yml b/docs/source/_static/rest-api.yml index 2609558c..0af48e92 100644 --- a/docs/source/_static/rest-api.yml +++ b/docs/source/_static/rest-api.yml @@ -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 diff --git a/docs/source/rbac/scopes.md b/docs/source/rbac/scopes.md index a3a1aadc..36a1856c 100644 --- a/docs/source/rbac/scopes.md +++ b/docs/source/rbac/scopes.md @@ -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 diff --git a/docs/source/reference/services.md b/docs/source/reference/services.md index f6c02675..941d3401 100644 --- a/docs/source/reference/services.md +++ b/docs/source/reference/services.md @@ -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, diff --git a/docs/source/reference/spawners.md b/docs/source/reference/spawners.md index f91ec320..88851ae5 100644 --- a/docs/source/reference/spawners.md +++ b/docs/source/reference/spawners.md @@ -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: diff --git a/examples/custom-scopes/jupyterhub_config.py b/examples/custom-scopes/jupyterhub_config.py index 735b1092..6c7210e0 100644 --- a/examples/custom-scopes/jupyterhub_config.py +++ b/examples/custom-scopes/jupyterhub_config.py @@ -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', + ], }, ] diff --git a/examples/service-whoami/README.md b/examples/service-whoami/README.md index 845f37d4..5a88a01d 100644 --- a/examples/service-whoami/README.md +++ b/examples/service-whoami/README.md @@ -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. diff --git a/examples/service-whoami/jupyterhub_config.py b/examples/service-whoami/jupyterhub_config.py index 6928dd26..f5dcaa79 100644 --- a/examples/service-whoami/jupyterhub_config.py +++ b/examples/service-whoami/jupyterhub_config.py @@ -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'], }, ] diff --git a/jupyterhub/alembic/versions/651f5419b74d_api_token_scopes.py b/jupyterhub/alembic/versions/651f5419b74d_api_token_scopes.py index 05677d4e..483a91c5 100644 --- a/jupyterhub/alembic/versions/651f5419b74d_api_token_scopes.py +++ b/jupyterhub/alembic/versions/651f5419b74d_api_token_scopes.py @@ -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') diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 0d672533..a85716a1 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -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) diff --git a/jupyterhub/oauth/provider.py b/jupyterhub/oauth/provider.py index dc19eb42..2c86e8e7 100644 --- a/jupyterhub/oauth/provider.py +++ b/jupyterhub/oauth/provider.py @@ -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 diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index b8a45af3..a6a41a2f 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -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})>" diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py index a2d59b87..9701626a 100644 --- a/jupyterhub/services/auth.py +++ b/jupyterhub/services/auth.py @@ -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}', diff --git a/jupyterhub/services/service.py b/jupyterhub/services/service.py index 57876222..b775109a 100644 --- a/jupyterhub/services/service.py +++ b/jupyterhub/services/service.py @@ -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. diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 14f345dc..1e40aa5a 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -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 diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index e769ac17..29507a20 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -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() diff --git a/jupyterhub/tests/test_roles.py b/jupyterhub/tests/test_roles.py index b62d9a22..f9c4cf3d 100644 --- a/jupyterhub/tests/test_roles.py +++ b/jupyterhub/tests/test_roles.py @@ -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): diff --git a/jupyterhub/tests/test_services.py b/jupyterhub/tests/test_services.py index a858553b..a5d50571 100644 --- a/jupyterhub/tests/test_services.py +++ b/jupyterhub/tests/test_services.py @@ -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) diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py index 8cda0dbc..8d5bba6d 100644 --- a/jupyterhub/tests/test_services_auth.py +++ b/jupyterhub/tests/test_services_auth.py @@ -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()) diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py index 1cc0332a..01899597 100644 --- a/jupyterhub/tests/test_spawner.py +++ b/jupyterhub/tests/test_spawner.py @@ -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() diff --git a/jupyterhub/user.py b/jupyterhub/user.py index bf091a13..39310069 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -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) + '/'), )