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:
Min RK
2022-04-28 16:43:16 +02:00
parent f2085fdf0f
commit 62b38934e5
20 changed files with 260 additions and 105 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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:

View File

@@ -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',
],
},
]

View File

@@ -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.

View File

@@ -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'],
},
]

View File

@@ -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')

View File

@@ -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)

View File

@@ -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

View File

@@ -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})>"

View File

@@ -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}',

View File

@@ -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.

View File

@@ -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

View File

@@ -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()

View File

@@ -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):

View File

@@ -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)

View File

@@ -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())

View File

@@ -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()

View File

@@ -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) + '/'),
)