Merge pull request #4381 from trungleduc/service-api

Add REST API for managing services at runtime
This commit is contained in:
Min RK
2023-08-09 11:41:18 +02:00
committed by GitHub
19 changed files with 1044 additions and 186 deletions

View File

@@ -21,7 +21,7 @@ fi
# Configure a set of databases in the database server for upgrade tests # Configure a set of databases in the database server for upgrade tests
# this list must be in sync with versions in test_db.py:test_upgrade # this list must be in sync with versions in test_db.py:test_upgrade
set -x set -x
for SUFFIX in '' _upgrade_110 _upgrade_122 _upgrade_130 _upgrade_150 _upgrade_211; do for SUFFIX in '' _upgrade_110 _upgrade_122 _upgrade_130 _upgrade_150 _upgrade_211 _upgrade_311; do
$SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true $SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
$SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};" $SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};"
done done

View File

@@ -1498,6 +1498,9 @@ components:
read:groups: Read group models. read:groups: Read group models.
read:groups:name: Read group names. read:groups:name: Read group names.
delete:groups: Delete groups. delete:groups: Delete groups.
admin:services:
Create, read, update, delete services, not including services
defined from config files.
list:services: List services, including at least their names. list:services: List services, including at least their names.
read:services: Read service models. read:services: Read service models.
read:services:name: Read service names. read:services:name: Read service names.

View File

@@ -24,6 +24,7 @@ such as:
- Checking which users are active - Checking which users are active
- Adding or removing users - Adding or removing users
- Adding or removing services
- Stopping or starting single user notebook servers - Stopping or starting single user notebook servers
- Authenticating services - Authenticating services
- Communicating with an individual Jupyter server's REST API - Communicating with an individual Jupyter server's REST API

View File

@@ -174,6 +174,47 @@ c.JupyterHub.services = [
In this case, the `url` field will be passed along to the Service as In this case, the `url` field will be passed along to the Service as
`JUPYTERHUB_SERVICE_URL`. `JUPYTERHUB_SERVICE_URL`.
## Adding or removing services at runtime
Only externally-managed services can be added at runtime by using JupyterHubs REST API.
### Add a new service
To add a new service, send a POST request to this endpoint
```
POST /hub/api/services/:servicename
```
**Required scope: `admin:services`**
**Payload**: The payload should contain the definition of the service to be created. The endpoint supports the same properties as externally-managed services defined in the config file.
**Possible responses**
- `201 Created`: The service and related objects are created (and started in case of a Hub-managed one) successfully.
- `400 Bad Request`: The payload is invalid or JupyterHub can not create the service.
- `409 Conflict`: The service with the same name already exists.
### Remove an existing service
To remove an existing service, send a DELETE request to this endpoint
```
DELETE /hub/api/services/:servicename
```
**Required scope: `admin:services`**
**Payload**: `None`
**Possible responses**
- `200 OK`: The service and related objects are removed (and stopped in case of a Hub-managed one) successfully.
- `400 Bad Request`: JupyterHub can not remove the service.
- `404 Not Found`: The requested service does not exist.
- `405 Not Allowed`: The requested service is created from the config file, it can not be removed at runtime.
## Writing your own Services ## Writing your own Services
When writing your own services, you have a few decisions to make (in addition When writing your own services, you have a few decisions to make (in addition

View File

@@ -0,0 +1,51 @@
"""Add from_config column to the services table
Revision ID: 3c2384c5aae1
Revises: 0eee8c825d24
Create Date: 2023-02-27 16:22:26.196231
"""
# revision identifiers, used by Alembic.
revision = '3c2384c5aae1'
down_revision = '0eee8c825d24'
branch_labels = None
depends_on = None
import sqlalchemy as sa
from alembic import op
from jupyterhub.orm import JSONDict, JSONList
COL_DATA = [
{'name': 'url', 'type': sa.Unicode(length=2047)},
{'name': 'oauth_client_allowed_scopes', 'type': JSONDict()},
{'name': 'info', 'type': JSONDict()},
{'name': 'display', 'type': sa.Boolean},
{'name': 'oauth_no_confirm', 'type': sa.Boolean},
{'name': 'command', 'type': JSONList()},
{'name': 'cwd', 'type': sa.Unicode(length=2047)},
{'name': 'environment', 'type': JSONDict()},
{'name': 'user', 'type': sa.Unicode(255)},
]
def upgrade():
engine = op.get_bind().engine
tables = sa.inspect(engine).get_table_names()
if 'services' in tables:
op.add_column(
'services',
sa.Column('from_config', sa.Boolean, default=True),
)
op.execute('UPDATE services SET from_config = true')
for item in COL_DATA:
op.add_column(
'services',
sa.Column(item['name'], item['type'], nullable=True),
)
def downgrade():
op.drop_column('services', sa.Column('from_config'))
for item in COL_DATA:
op.drop_column('services', sa.Column(item['name']))

View File

@@ -13,16 +13,37 @@ depends_on = None
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op from alembic import op
from sqlalchemy import Column, ForeignKey, Table from sqlalchemy import Column, ForeignKey, Table, text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from jupyterhub import orm, roles, scopes from jupyterhub import orm, roles
def access_scopes(oauth_client: orm.OAuthClient, db: Session):
"""Return scope(s) required to access an oauth client
This is a clone of `scopes.access_scopes` without using
the `orm.Service`
"""
scopes = set()
if oauth_client.identifier == "jupyterhub":
return frozenset()
spawner = oauth_client.spawner
if spawner:
scopes.add(f"access:servers!server={spawner.user.name}/{spawner.name}")
else:
statement = "SELECT * FROM services WHERE oauth_client_id = :identifier"
service = db.execute(
text(statement), {"identifier": oauth_client.identifier}
).fetchall()
if len(service) > 0:
scopes.add(f"access:services!service={service[0].name}")
return frozenset(scopes)
def upgrade(): def upgrade():
c = op.get_bind() c = op.get_bind()
tables = sa.inspect(c.engine).get_table_names() tables = sa.inspect(c.engine).get_table_names()
# oauth codes are short lived, no need to upgrade them # oauth codes are short lived, no need to upgrade them
@@ -100,7 +121,7 @@ def upgrade():
db = Session(bind=c) db = Session(bind=c)
for oauth_client in db.query(orm.OAuthClient): for oauth_client in db.query(orm.OAuthClient):
allowed_scopes = set(roles.roles_to_scopes(oauth_client.allowed_roles)) allowed_scopes = set(roles.roles_to_scopes(oauth_client.allowed_roles))
allowed_scopes.update(scopes.access_scopes(oauth_client)) allowed_scopes.update(access_scopes(oauth_client, db))
oauth_client.allowed_scopes = sorted(allowed_scopes) oauth_client.allowed_scopes = sorted(allowed_scopes)
db.commit() db.commit()
# drop token-role relationship # drop token-role relationship

View File

@@ -395,6 +395,23 @@ class APIHandler(BaseHandler):
_group_model_types = {'name': str, 'users': list, 'roles': list} _group_model_types = {'name': str, 'users': list, 'roles': list}
_service_model_types = {
'name': str,
'admin': bool,
'url': str,
'oauth_client_allowed_scopes': list,
'api_token': str,
'info': dict,
'display': bool,
'oauth_no_confirm': bool,
'command': list,
'cwd': str,
'environment': dict,
'user': str,
'oauth_client_id': str,
'oauth_redirect_uri': str,
}
def _check_model(self, model, model_types, name): def _check_model(self, model, model_types, name):
"""Check a model provided by a REST API request """Check a model provided by a REST API request
@@ -433,6 +450,15 @@ class APIHandler(BaseHandler):
400, ("group names must be str, not %r", type(groupname)) 400, ("group names must be str, not %r", type(groupname))
) )
def _check_service_model(self, model):
"""Check a request-provided service model from a REST API"""
self._check_model(model, self._service_model_types, 'service')
service_name = model.get('name')
if not isinstance(service_name, str):
raise web.HTTPError(
400, ("Service name must be str, not %r", type(service_name))
)
def get_api_pagination(self): def get_api_pagination(self):
default_limit = self.settings["api_page_default_limit"] default_limit = self.settings["api_page_default_limit"]
max_limit = self.settings["api_page_max_limit"] max_limit = self.settings["api_page_max_limit"]

View File

@@ -5,8 +5,14 @@ Currently GET-only, no actions can be taken to modify services.
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
import json import json
from typing import Optional, Tuple
from ..scopes import Scope, needs_scope from tornado import web
from .. import orm
from ..roles import get_default_roles
from ..scopes import Scope, _check_token_scopes, needs_scope
from ..services.service import Service
from .base import APIHandler from .base import APIHandler
@@ -25,9 +31,171 @@ class ServiceListAPIHandler(APIHandler):
class ServiceAPIHandler(APIHandler): class ServiceAPIHandler(APIHandler):
@needs_scope('read:services', 'read:services:name', 'read:roles:services') @needs_scope('read:services', 'read:services:name', 'read:roles:services')
def get(self, service_name): def get(self, service_name):
if service_name not in self.services:
raise web.HTTPError(404, f"No such service: {service_name}")
service = self.services[service_name] service = self.services[service_name]
self.write(json.dumps(self.service_model(service))) self.write(json.dumps(self.service_model(service)))
def _check_service_scopes(self, spec: dict):
user = self.current_user
requested_scopes = []
if spec.get('admin'):
default_roles = get_default_roles()
admin_scopes = [
role['scopes'] for role in default_roles if role['name'] == 'admin'
]
requested_scopes.extend(admin_scopes[0])
requested_client_scope = spec.get('oauth_client_allowed_scopes')
if requested_client_scope is not None:
requested_scopes.extend(requested_client_scope)
if len(requested_scopes) > 0:
try:
_check_token_scopes(requested_scopes, user, None)
except ValueError as e:
raise web.HTTPError(400, str(e))
async def add_service(self, spec: dict) -> Service:
"""Add a new service and related objects to the database
Args:
spec (dict): The service specification
Raises:
web.HTTPError: Raise if the service is not created
Returns:
Service: Returns the service instance.
"""
self._check_service_model(spec)
self._check_service_scopes(spec)
service_name = spec["name"]
managed = bool(spec.get('command'))
if managed:
msg = f"Can not create managed service {service_name} at runtime"
self.log.error(msg, exc_info=True)
raise web.HTTPError(400, msg)
try:
new_service = self.service_from_spec(spec)
except Exception:
msg = f"Failed to create service {service_name}"
self.log.error(msg, exc_info=True)
raise web.HTTPError(400, msg)
if new_service is None:
raise web.HTTPError(400, f"Failed to create service {service_name}")
if new_service.api_token:
# Add api token to database
await self.app._add_tokens(
{new_service.api_token: new_service.name}, kind='service'
)
if new_service.url:
# Start polling for external service
service_status = await self.app.start_service(service_name, new_service)
if not service_status:
self.log.error(
'Failed to start service %s',
service_name,
exc_info=True,
)
if new_service.oauth_no_confirm:
oauth_no_confirm_list = self.settings.get('oauth_no_confirm_list')
msg = f"Allowing service {new_service.name} to complete OAuth without confirmation on an authorization web page"
self.log.warning(msg)
oauth_no_confirm_list.add(new_service.oauth_client_id)
return new_service
@needs_scope('admin:services')
async def post(self, service_name: str):
data = self.get_json_body()
service, _ = self.find_service(service_name)
if service is not None:
raise web.HTTPError(409, f"Service {service_name} already exists")
if not data or not isinstance(data, dict):
raise web.HTTPError(400, "Invalid service data")
data['name'] = service_name
new_service = await self.add_service(data)
self.write(json.dumps(self.service_model(new_service)))
self.set_status(201)
@needs_scope('admin:services')
async def delete(self, service_name: str):
service, orm_service = self.find_service(service_name)
if service is None:
raise web.HTTPError(404, f"Service {service_name} does not exist")
if service.from_config:
raise web.HTTPError(
405, f"Service {service_name} is not modifiable at runtime"
)
try:
await self.remove_service(service, orm_service)
self.services.pop(service_name)
except Exception:
msg = f"Failed to remove service {service_name}"
self.log.error(msg, exc_info=True)
raise web.HTTPError(400, msg)
self.set_status(200)
async def remove_service(self, service: Service, orm_service: orm.Service) -> None:
"""Remove a service and all related objects from the database.
Args:
service (Service): the service object to be removed
orm_service (orm.Service): The `orm.Service` object linked
with `service`
"""
if service.managed:
await service.stop()
if service.oauth_client:
self.oauth_provider.remove_client(service.oauth_client_id)
if orm_service._server_id is not None:
orm_server = (
self.db.query(orm.Server).filter_by(id=orm_service._server_id).first()
)
if orm_server is not None:
self.db.delete(orm_server)
if service.oauth_no_confirm:
oauth_no_confirm_list = self.settings.get('oauth_no_confirm_list')
oauth_no_confirm_list.discard(service.oauth_client_id)
self.db.delete(orm_service)
self.db.commit()
def service_from_spec(self, spec) -> Optional[Service]:
"""Create service from api request"""
service = self.app.service_from_spec(spec, from_config=False)
self.db.commit()
return service
def find_service(
self, name: str
) -> Tuple[Optional[Service], Optional[orm.Service]]:
"""Get a service by name
return None if no such service
"""
orm_service = orm.Service.find(db=self.db, name=name)
if orm_service is not None:
service = self.services.get(name)
return service, orm_service
return (None, None)
default_handlers = [ default_handlers = [
(r"/api/services", ServiceListAPIHandler), (r"/api/services", ServiceListAPIHandler),

View File

@@ -21,6 +21,7 @@ from functools import partial
from getpass import getuser from getpass import getuser
from operator import itemgetter from operator import itemgetter
from textwrap import dedent from textwrap import dedent
from typing import Optional
from urllib.parse import unquote, urlparse, urlunparse from urllib.parse import unquote, urlparse, urlunparse
if sys.version_info[:2] < (3, 3): if sys.version_info[:2] < (3, 3):
@@ -2323,7 +2324,7 @@ class JupyterHub(Application):
if not self.authenticator.validate_username(name): if not self.authenticator.validate_username(name):
raise ValueError("Token user name %r is not valid" % name) raise ValueError("Token user name %r is not valid" % name)
if kind == 'service': if kind == 'service':
if not any(service["name"] == name for service in self.services): if not any(service_name == name for service_name in self._service_map):
self.log.warning( self.log.warning(
f"service {name} not in services, creating implicitly. It is recommended to register services using services list." f"service {name} not in services, creating implicitly. It is recommended to register services using services list."
) )
@@ -2386,8 +2387,20 @@ class JupyterHub(Application):
) )
pc.start() pc.start()
def init_services(self): def service_from_orm(
self._service_map.clear() self,
orm_service: orm.Service,
) -> Service:
"""Create the service instance and related objects from
ORM data.
Args:
orm_service (orm.Service): The `orm.Service` object
Returns:
Service: the created service
"""
if self.domain: if self.domain:
domain = 'services.' + self.domain domain = 'services.' + self.domain
parsed = urlparse(self.subdomain_host) parsed = urlparse(self.subdomain_host)
@@ -2395,118 +2408,208 @@ class JupyterHub(Application):
else: else:
domain = host = '' domain = host = ''
for spec in self.services: name = orm_service.name
if 'name' not in spec: service = Service(
raise ValueError('service spec must have a name: %r' % spec) parent=self,
name = spec['name'] app=self,
# get/create orm base_url=self.base_url,
orm_service = orm.Service.find(self.db, name=name) db=self.db,
if orm_service is None: orm=orm_service,
# not found, create a new one roles=orm_service.roles,
orm_service = orm.Service(name=name) domain=domain,
self.db.add(orm_service) host=host,
if spec.get('admin', False): hub=self.hub,
self.log.warning( )
f"Service {name} sets `admin: True`, which is deprecated in JupyterHub 2.0." traits = service.traits(input=True)
" You can assign now assign roles via `JupyterHub.load_roles` configuration." for key, trait in traits.items():
" If you specify services in the admin role configuration, " if not trait.metadata.get("in_db", True):
"the Service admin flag will be ignored." continue
orm_value = getattr(orm_service, key)
if orm_value is not None:
setattr(service, key, orm_value)
if orm_service.oauth_client is not None:
service.oauth_client_id = orm_service.oauth_client.identifier
service.oauth_redirect_uri = orm_service.oauth_client.redirect_uri
self._service_map[name] = service
return service
def service_from_spec(
self,
spec: Dict,
from_config=True,
) -> Optional[Service]:
"""Create the service instance and related objects from
config data.
Args:
spec (Dict): The spec of service, defined in the config file.
from_config (bool, optional): `True` if the service will be created
from the config file, `False` if it is created from REST API.
Defaults to `True`.
Returns:
Optional[Service]: The created service
"""
if self.domain:
domain = 'services.' + self.domain
parsed = urlparse(self.subdomain_host)
host = f'{parsed.scheme}://services.{parsed.netloc}'
else:
domain = host = ''
if 'name' not in spec:
raise ValueError('service spec must have a name: %r' % spec)
name = spec['name']
# get/create orm
orm_service = orm.Service.find(self.db, name=name)
if orm_service is None:
# not found, create a new one
orm_service = orm.Service(name=name, from_config=from_config)
self.db.add(orm_service)
if spec.get('admin', False):
self.log.warning(
f"Service {name} sets `admin: True`, which is deprecated in JupyterHub 2.0."
" You can assign now assign roles via `JupyterHub.load_roles` configuration."
" If you specify services in the admin role configuration, "
"the Service admin flag will be ignored."
)
roles.update_roles(self.db, entity=orm_service, roles=['admin'])
else:
# Do nothing if the config file tries to modify a API-base service
# or vice versa.
if orm_service.from_config != from_config:
if from_config:
self.log.error(
f"The service {name} from the config file is trying to modify a runtime-created service with the same name"
) )
roles.update_roles(self.db, entity=orm_service, roles=['admin']) else:
orm_service.admin = spec.get('admin', False) self.log.error(
self.db.commit() f"The runtime-created service {name} is trying to modify a config-based service with the same name"
service = Service( )
parent=self, return
app=self, orm_service.admin = spec.get('admin', False)
base_url=self.base_url,
db=self.db, self.db.commit()
orm=orm_service, service = Service(
roles=orm_service.roles, parent=self,
domain=domain, app=self,
host=host, base_url=self.base_url,
hub=self.hub, db=self.db,
orm=orm_service,
roles=orm_service.roles,
domain=domain,
host=host,
hub=self.hub,
)
traits = service.traits(input=True)
for key, value in spec.items():
trait = traits.get(key)
if trait is None:
raise AttributeError("No such service field: %s" % key)
setattr(service, key, value)
# also set the value on the orm object
# unless it's marked as not in the db
# (e.g. on the oauth object)
if trait.metadata.get("in_db", True):
setattr(orm_service, key, value)
if service.api_token:
self.service_tokens[service.api_token] = service.name
elif service.managed:
# generate new token
# TODO: revoke old tokens?
service.api_token = service.orm.new_api_token(note="generated at startup")
if service.url:
parsed = urlparse(service.url)
if parsed.scheme not in {"http", "https"}:
raise ValueError(
f"Unsupported scheme in URL for service {name}: {service.url}. Must be http[s]"
)
port = None
if parsed.port is not None:
port = parsed.port
elif parsed.scheme == 'http':
port = 80
elif parsed.scheme == 'https':
port = 443
server = service.orm.server = orm.Server(
proto=parsed.scheme,
ip=parsed.hostname,
port=port,
cookie_name=service.oauth_client_id,
base_url=service.prefix,
) )
self.db.add(server)
else:
service.orm.server = None
traits = service.traits(input=True) if service.oauth_available:
for key, value in spec.items(): allowed_scopes = set()
if key not in traits: if service.oauth_client_allowed_scopes:
raise AttributeError("No such service field: %s" % key) allowed_scopes.update(service.oauth_client_allowed_scopes)
setattr(service, key, value) if service.oauth_roles:
if not allowed_scopes:
if service.api_token: # DEPRECATED? It's still convenient and valid,
self.service_tokens[service.api_token] = service.name # e.g. 'admin'
elif service.managed: allowed_roles = list(
# generate new token self.db.query(orm.Role).filter(
# TODO: revoke old tokens? orm.Role.name.in_(service.oauth_roles)
service.api_token = service.orm.new_api_token(
note="generated at startup"
)
if service.url:
parsed = urlparse(service.url)
if parsed.port is not None:
port = parsed.port
elif parsed.scheme == 'http':
port = 80
elif parsed.scheme == 'https':
port = 443
server = service.orm.server = orm.Server(
proto=parsed.scheme,
ip=parsed.hostname,
port=port,
cookie_name=service.oauth_client_id,
base_url=service.prefix,
)
self.db.add(server)
else:
service.orm.server = None
if service.oauth_available:
allowed_scopes = set()
if service.oauth_client_allowed_scopes:
allowed_scopes.update(service.oauth_client_allowed_scopes)
if 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: allowed_scopes.update(roles.roles_to_scopes(allowed_roles))
self.log.warning( else:
f"Ignoring oauth_roles for {service.name}: {service.oauth_roles}," self.log.warning(
f" using oauth_client_allowed_scopes={allowed_scopes}." f"Ignoring oauth_roles for {service.name}: {service.oauth_roles},"
) f" using oauth_client_allowed_scopes={allowed_scopes}."
oauth_client = self.oauth_provider.add_client( )
client_id=service.oauth_client_id, oauth_client = self.oauth_provider.add_client(
client_secret=service.api_token, client_id=service.oauth_client_id,
redirect_uri=service.oauth_redirect_uri, client_secret=service.api_token,
description="JupyterHub service %s" % service.name, redirect_uri=service.oauth_redirect_uri,
) description="JupyterHub service %s" % service.name,
service.orm.oauth_client = oauth_client )
# add access-scopes, derived from OAuthClient itself service.orm.oauth_client = oauth_client
allowed_scopes.update(scopes.access_scopes(oauth_client)) # add access-scopes, derived from OAuthClient itself
oauth_client.allowed_scopes = sorted(allowed_scopes) 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)
self._service_map[name] = service
return service
def init_services(self):
self._service_map.clear()
for spec in self.services:
self.service_from_spec(spec, from_config=True)
for service_orm in self.db.query(orm.Service):
if service_orm.from_config:
# delete config-based services from db
# that are not in current config file:
if service_orm.name not in self._service_map:
self.db.delete(service_orm)
else: else:
if service.oauth_client: self.service_from_orm(service_orm)
self.db.delete(service.oauth_client)
self._service_map[name] = service
# delete services from db not in service config:
for service in self.db.query(orm.Service):
if service.name not in self._service_map:
self.db.delete(service)
self.db.commit() self.db.commit()
async def check_services_health(self): async def check_services_health(self):
"""Check connectivity of all services""" """Check connectivity of all services"""
for name, service in self._service_map.items(): for name, service in self._service_map.items():
if not service.url: if not service.url:
# no URL to check, nothing to do
continue continue
try: try:
await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True) await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True)
@@ -2687,7 +2790,6 @@ class JupyterHub(Application):
for user in self.users.values(): for user in self.users.values():
for spawner in user.spawners.values(): for spawner in user.spawners.values():
oauth_client_ids.add(spawner.oauth_client_id) oauth_client_ids.add(spawner.oauth_client_id)
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)): for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
if oauth_client.identifier not in oauth_client_ids: if oauth_client.identifier not in oauth_client_ids:
self.log.warning("Deleting OAuth client %s", oauth_client.identifier) self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
@@ -3129,6 +3231,72 @@ class JupyterHub(Application):
await self.proxy.check_routes(self.users, self._service_map, routes) await self.proxy.check_routes(self.users, self._service_map, routes)
async def start_service(
self,
service_name: str,
service: Service,
ssl_context: Optional[ssl.SSLContext] = None,
) -> bool:
"""Start a managed service or poll for external service
Args:
service_name (str): Name of the service.
service (Service): The service object.
Returns:
boolean: Returns `True` if the service is started successfully,
returns `False` otherwise.
"""
if ssl_context is None:
ssl_context = make_ssl_context(
self.internal_ssl_key,
self.internal_ssl_cert,
cafile=self.internal_ssl_ca,
purpose=ssl.Purpose.CLIENT_AUTH,
)
msg = f'{service_name} at {service.url}' if service.url else service_name
if service.managed:
self.log.info("Starting managed service %s", msg)
try:
await service.start()
except Exception as e:
self.log.critical(
"Failed to start service %s", service_name, exc_info=True
)
return False
else:
self.log.info("Adding external service %s", msg)
if service.url:
tries = 10 if service.managed else 1
for i in range(tries):
try:
await Server.from_orm(service.orm.server).wait_up(
http=True, timeout=1, ssl_context=ssl_context
)
except AnyTimeoutError:
if service.managed:
status = await service.spawner.poll()
if status is not None:
self.log.error(
"Service %s exited with status %s",
service_name,
status,
)
return False
else:
return True
else:
self.log.error(
"Cannot connect to %s service %s at %s. Is it running?",
service.kind,
service_name,
service.url,
)
return False
return True
async def start(self): async def start(self):
"""Start the whole thing""" """Start the whole thing"""
self.io_loop = loop = IOLoop.current() self.io_loop = loop = IOLoop.current()
@@ -3214,55 +3382,29 @@ class JupyterHub(Application):
# start the service(s) # start the service(s)
for service_name, service in self._service_map.items(): for service_name, service in self._service_map.items():
msg = f'{service_name} at {service.url}' if service.url else service_name service_ready = await self.start_service(service_name, service, ssl_context)
if service.managed: if not service_ready:
self.log.info("Starting managed service %s", msg) if service.from_config:
try: # Stop the application if a config-based service failed to start.
await service.start()
except Exception as e:
self.log.critical(
"Failed to start service %s", service_name, exc_info=True
)
self.exit(1) self.exit(1)
else:
self.log.info("Adding external service %s", msg)
if service.url:
tries = 10 if service.managed else 1
for i in range(tries):
try:
await Server.from_orm(service.orm.server).wait_up(
http=True, timeout=1, ssl_context=ssl_context
)
except AnyTimeoutError:
if service.managed:
status = await service.spawner.poll()
if status is not None:
self.log.error(
"Service %s exited with status %s",
service_name,
status,
)
break
else:
break
else: else:
# Only warn for database-based service, so that admin can connect
# to hub to remove the service.
self.log.error( self.log.error(
"Cannot connect to %s service %s at %s. Is it running?", "Failed to reach externally managed service %s",
service.kind,
service_name, service_name,
service.url, exc_info=True,
) )
await self.proxy.check_routes(self.users, self._service_map) await self.proxy.check_routes(self.users, self._service_map)
if self.service_check_interval and any( # Check services health
s.url for s in self._service_map.values() self._check_services_health_callback = None
): if self.service_check_interval:
pc = PeriodicCallback( self._check_services_health_callback = PeriodicCallback(
self.check_services_health, 1e3 * self.service_check_interval self.check_services_health, 1e3 * self.service_check_interval
) )
pc.start() self._check_services_health_callback.start()
if self.last_activity_interval: if self.last_activity_interval:
pc = PeriodicCallback( pc = PeriodicCallback(

View File

@@ -668,6 +668,18 @@ class JupyterHubOAuthServer(WebApplicationServer):
self.db.commit() self.db.commit()
return orm_client return orm_client
def remove_client(self, client_id):
"""Remove a client by its id if it is existed."""
orm_client = (
self.db.query(orm.OAuthClient).filter_by(identifier=client_id).one_or_none()
)
if orm_client is not None:
self.db.delete(orm_client)
self.db.commit()
app_log.info("Removed client %s", client_id)
else:
app_log.warning("No such client %s", client_id)
def fetch_by_client_id(self, client_id): def fetch_by_client_id(self, client_id):
"""Find a client by its id""" """Find a client by its id"""
client = self.db.query(orm.OAuthClient).filter_by(identifier=client_id).first() client = self.db.query(orm.OAuthClient).filter_by(identifier=client_id).first()

View File

@@ -394,6 +394,26 @@ class Service(Base):
'Role', secondary='service_role_map', back_populates='services' 'Role', secondary='service_role_map', back_populates='services'
) )
url = Column(Unicode(2047), nullable=True)
oauth_client_allowed_scopes = Column(JSONList, nullable=True)
info = Column(JSONDict, nullable=True)
display = Column(Boolean, nullable=True)
oauth_no_confirm = Column(Boolean, nullable=True)
command = Column(JSONList, nullable=True)
cwd = Column(Unicode(4095), nullable=True)
environment = Column(JSONDict, nullable=True)
user = Column(Unicode(255), nullable=True)
from_config = Column(Boolean, default=True)
api_tokens = relationship( api_tokens = relationship(
"APIToken", back_populates="service", cascade="all, delete-orphan" "APIToken", back_populates="service", cascade="all, delete-orphan"
) )
@@ -416,6 +436,7 @@ class Service(Base):
ondelete='SET NULL', ondelete='SET NULL',
), ),
) )
oauth_client = relationship( oauth_client = relationship(
'OAuthClient', 'OAuthClient',
back_populates="service", back_populates="service",

View File

@@ -34,6 +34,7 @@ def get_default_roles():
'admin-ui', 'admin-ui',
'admin:users', 'admin:users',
'admin:servers', 'admin:servers',
'admin:services',
'tokens', 'tokens',
'admin:groups', 'admin:groups',
'list:services', 'list:services',

View File

@@ -123,6 +123,10 @@ scope_definitions = {
'delete:groups': { 'delete:groups': {
'description': "Delete groups.", 'description': "Delete groups.",
}, },
'admin:services': {
'description': 'Create, read, update, delete services, not including services defined from config files.',
'subscopes': ['list:services', 'read:services', 'read:roles:services'],
},
'list:services': { 'list:services': {
'description': 'List services, including at least their names.', 'description': 'List services, including at least their names.',
'subscopes': ['read:services:name'], 'subscopes': ['read:services:name'],
@@ -435,7 +439,7 @@ def _expand_self_scope(username):
@lru_cache(maxsize=65535) @lru_cache(maxsize=65535)
def _expand_scope(scope): def _expand_scope(scope):
"""Returns a scope and all all subscopes """Returns a scope and all subscopes
Arguments: Arguments:
scope (str): the scope to expand scope (str): the scope to expand

View File

@@ -180,9 +180,12 @@ class Service(LoggingConfigurable):
- user: str - user: str
The name of a system user to become. The name of a system user to become.
If unspecified, run as the same user as the Hub. If unspecified, run as the same user as the Hub.
""" """
# inputs: # traits tagged with `input=True` are accepted as input from configuration / API
# input traits are also persisted to the db UNLESS they are also tagged with `in_db=False`
name = Unicode( name = Unicode(
help="""The name of the service. help="""The name of the service.
@@ -205,7 +208,7 @@ class Service(LoggingConfigurable):
DEPRECATED in 3.0: use oauth_client_allowed_scopes DEPRECATED in 3.0: use oauth_client_allowed_scopes
""" """
).tag(input=True) ).tag(input=True, in_db=False)
oauth_client_allowed_scopes = List( oauth_client_allowed_scopes = List(
help="""OAuth allowed scopes. help="""OAuth allowed scopes.
@@ -225,7 +228,7 @@ class Service(LoggingConfigurable):
If unspecified, an API token will be generated for managed services. If unspecified, an API token will be generated for managed services.
""" """
).tag(input=True) ).tag(input=True, in_db=False)
info = Dict( info = Dict(
help="""Provide a place to include miscellaneous information about the service, help="""Provide a place to include miscellaneous information about the service,
@@ -310,7 +313,7 @@ class Service(LoggingConfigurable):
You shouldn't generally need to change this. You shouldn't generally need to change this.
Default: `service-<name>` Default: `service-<name>`
""" """
).tag(input=True) ).tag(input=True, in_db=False)
@default('oauth_client_id') @default('oauth_client_id')
def _default_client_id(self): def _default_client_id(self):
@@ -331,7 +334,7 @@ class Service(LoggingConfigurable):
You shouldn't generally need to change this. You shouldn't generally need to change this.
Default: `/services/:name/oauth_callback` Default: `/services/:name/oauth_callback`
""" """
).tag(input=True) ).tag(input=True, in_db=False)
@default('oauth_redirect_uri') @default('oauth_redirect_uri')
def _default_redirect_uri(self): def _default_redirect_uri(self):
@@ -371,6 +374,11 @@ class Service(LoggingConfigurable):
else: else:
return self.server.base_url return self.server.base_url
@property
def from_config(self):
"""Is the service defined from config file?"""
return self.orm.from_config
def __repr__(self): def __repr__(self):
return "<{cls}(name={name}{managed})>".format( return "<{cls}(name={name}{managed})>".format(
cls=self.__class__.__name__, cls=self.__class__.__name__,

View File

@@ -167,6 +167,8 @@ async def cleanup_after(request, io_loop):
app = MockHub.instance() app = MockHub.instance()
if app.db_file.closed: if app.db_file.closed:
return return
# cleanup users
for uid, user in list(app.users.items()): for uid, user in list(app.users.items()):
for name, spawner in list(user.spawners.items()): for name, spawner in list(user.spawners.items()):
if spawner.active: if spawner.active:
@@ -181,6 +183,16 @@ async def cleanup_after(request, io_loop):
# delete groups # delete groups
for group in app.db.query(orm.Group): for group in app.db.query(orm.Group):
app.db.delete(group) app.db.delete(group)
# clear services
for name, service in app._service_map.items():
if service.managed:
service.stop()
for orm_service in app.db.query(orm.Service):
if orm_service.oauth_client:
app.oauth_provider.remove_client(orm_service.oauth_client_id)
app.db.delete(orm_service)
app._service_map.clear()
app.db.commit() app.db.commit()
@@ -262,10 +274,7 @@ class MockServiceSpawner(jupyterhub.services.service._ServiceSpawner):
poll_interval = 1 poll_interval = 1
_mock_service_counter = 0 async def _mockservice(request, app, name, external=False, url=False):
async def _mockservice(request, app, external=False, url=False):
""" """
Add a service to the application Add a service to the application
@@ -281,9 +290,6 @@ async def _mockservice(request, app, external=False, url=False):
If True, register the service at a URL If True, register the service at a URL
(as opposed to headless, API-only). (as opposed to headless, API-only).
""" """
global _mock_service_counter
_mock_service_counter += 1
name = 'mock-service-%i' % _mock_service_counter
spec = {'name': name, 'command': mockservice_cmd, 'admin': True} spec = {'name': name, 'command': mockservice_cmd, 'admin': True}
if url: if url:
if app.internal_ssl: if app.internal_ssl:
@@ -330,22 +336,33 @@ async def _mockservice(request, app, external=False, url=False):
return service return service
_service_name_counter = 0
@fixture @fixture
async def mockservice(request, app): def service_name():
global _service_name_counter
_service_name_counter += 1
name = f'test-service-{_service_name_counter}'
return name
@fixture
async def mockservice(request, app, service_name):
"""Mock a service with no external service url""" """Mock a service with no external service url"""
yield await _mockservice(request, app, url=False) yield await _mockservice(request, app, name=service_name, url=False)
@fixture @fixture
async def mockservice_external(request, app): async def mockservice_external(request, app, service_name):
"""Mock an externally managed service (don't start anything)""" """Mock an externally managed service (don't start anything)"""
yield await _mockservice(request, app, external=True, url=False) yield await _mockservice(request, app, name=service_name, external=True, url=False)
@fixture @fixture
async def mockservice_url(request, app): async def mockservice_url(request, app, service_name):
"""Mock a service with its own url to test external services""" """Mock a service with its own url to test external services"""
yield await _mockservice(request, app, url=True) yield await _mockservice(request, app, name=service_name, url=True)
@fixture @fixture
@@ -534,3 +551,17 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config):
queries = db_counts[nodeid] queries = db_counts[nodeid]
if queries: if queries:
terminalreporter.line(f"{queries:<6} {nodeid}") terminalreporter.line(f"{queries:<6} {nodeid}")
@fixture
def service_data(service_name):
"""Data used to create service at runtime"""
return {
"name": service_name,
"oauth_client_id": f"service-{service_name}",
"api_token": f"api_token-{service_name}",
"oauth_redirect_uri": "http://127.0.0.1:5555/oauth_callback-from-api",
"oauth_no_confirm": True,
"oauth_client_allowed_scopes": ["inherit"],
"info": {'foo': 'bar'},
}

View File

@@ -4,10 +4,12 @@ import json
import re import re
import sys import sys
import uuid import uuid
from copy import deepcopy
from datetime import datetime, timedelta from datetime import datetime, timedelta
from unittest import mock from unittest import mock
from urllib.parse import quote, urlparse from urllib.parse import quote, urlparse
import pytest
from pytest import fixture, mark from pytest import fixture, mark
from tornado.httputil import url_concat from tornado.httputil import url_concat
@@ -2062,7 +2064,7 @@ async def test_get_services(app, mockservice_url):
async def test_get_service(app, mockservice_url): async def test_get_service(app, mockservice_url):
mockservice = mockservice_url mockservice = mockservice_url
db = app.db db = app.db
r = await api_request(app, 'services/%s' % mockservice.name) r = await api_request(app, f"services/{mockservice.name}")
r.raise_for_status() r.raise_for_status()
assert r.status_code == 200 assert r.status_code == 200
@@ -2081,19 +2083,271 @@ async def test_get_service(app, mockservice_url):
} }
r = await api_request( r = await api_request(
app, app,
'services/%s' % mockservice.name, f"services/{mockservice.name}",
headers={'Authorization': 'token %s' % mockservice.api_token}, headers={'Authorization': 'token %s' % mockservice.api_token},
) )
r.raise_for_status() r.raise_for_status()
r = await api_request( r = await api_request(
app, 'services/%s' % mockservice.name, headers=auth_header(db, 'user') app, f"services/{mockservice.name}", headers=auth_header(db, 'user')
) )
assert r.status_code == 403 assert r.status_code == 403
r = await api_request(app, "services/nosuchservice")
assert r.status_code == 404
@pytest.fixture
def service_admin_user(create_user_with_scopes):
return create_user_with_scopes('admin:services')
@mark.services
async def test_create_service(app, service_admin_user, service_name, service_data):
db = app.db
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(service_data),
method='post',
)
r.raise_for_status()
assert r.status_code == 201
assert r.json()['name'] == service_name
orm_service = orm.Service.find(db, service_name)
assert orm_service is not None
oath_client = (
db.query(orm.OAuthClient)
.filter_by(identifier=service_data['oauth_client_id'])
.first()
)
assert oath_client.redirect_uri == service_data['oauth_redirect_uri']
assert service_name in app._service_map
assert (
app._service_map[service_name].oauth_no_confirm
== service_data['oauth_no_confirm']
)
@mark.services
async def test_create_service_no_role(app, service_name, service_data):
db = app.db
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, 'user'),
data=json.dumps(service_data),
method='post',
)
assert r.status_code == 403
@mark.services
async def test_create_service_conflict(
app, service_admin_user, mockservice, service_data, service_name
):
db = app.db
app.services = [{'name': service_name}]
app.init_services()
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(service_data),
method='post',
)
assert r.status_code == 409
@mark.services
async def test_create_service_duplication(
app, service_admin_user, service_name, service_data
):
db = app.db
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(service_data),
method='post',
)
assert r.status_code == 201
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(service_data),
method='post',
)
assert r.status_code == 409
@mark.services
async def test_create_managed_service(
app, service_admin_user, service_name, service_data
):
db = app.db
managed_service_data = deepcopy(service_data)
managed_service_data['command'] = ['foo']
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(managed_service_data),
method='post',
)
assert r.status_code == 400
assert 'Can not create managed service' in r.json()['message']
orm_service = orm.Service.find(db, service_name)
assert orm_service is None
@mark.services
async def test_create_admin_service(app, admin_user, service_name, service_data):
db = app.db
managed_service_data = deepcopy(service_data)
managed_service_data['admin'] = True
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, admin_user.name),
data=json.dumps(managed_service_data),
method='post',
)
assert r.status_code == 201
orm_service = orm.Service.find(db, service_name)
assert orm_service is not None
@mark.services
async def test_create_admin_service_without_admin_right(
app, service_admin_user, service_data, service_name
):
db = app.db
managed_service_data = deepcopy(service_data)
managed_service_data['admin'] = True
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(managed_service_data),
method='post',
)
assert r.status_code == 400
assert 'Not assigning requested scopes' in r.json()['message']
orm_service = orm.Service.find(db, service_name)
assert orm_service is None
@mark.services
async def test_create_service_with_scope(
app, create_user_with_scopes, service_name, service_data
):
db = app.db
managed_service_data = deepcopy(service_data)
managed_service_data['oauth_client_allowed_scopes'] = ["admin:users"]
managed_service_data['oauth_client_id'] = "service-client-with-scope"
user_with_scope = create_user_with_scopes('admin:services', 'admin:users')
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, user_with_scope.name),
data=json.dumps(managed_service_data),
method='post',
)
assert r.status_code == 201
orm_service = orm.Service.find(db, service_name)
assert orm_service is not None
@mark.services
async def test_create_service_without_requested_scope(
app,
service_admin_user,
service_data,
service_name,
):
db = app.db
managed_service_data = deepcopy(service_data)
managed_service_data['oauth_client_allowed_scopes'] = ["admin:users"]
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(managed_service_data),
method='post',
)
assert r.status_code == 400
assert 'Not assigning requested scopes' in r.json()['message']
orm_service = orm.Service.find(db, service_name)
assert orm_service is None
@mark.services
async def test_delete_service(app, service_admin_user, service_name, service_data):
db = app.db
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
data=json.dumps(service_data),
method='post',
)
assert r.status_code == 201
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
method='delete',
)
assert r.status_code == 200
orm_service = orm.Service.find(db, service_name)
assert orm_service is None
oath_client = (
db.query(orm.OAuthClient)
.filter_by(identifier=service_data['oauth_client_id'])
.first()
)
assert oath_client is None
assert service_name not in app._service_map
r = await api_request(app, f"services/{service_name}", method="delete")
assert r.status_code == 404
@mark.services
async def test_delete_service_from_config(app, service_admin_user, mockservice):
db = app.db
service_name = mockservice.name
r = await api_request(
app,
f'services/{service_name}',
headers=auth_header(db, service_admin_user.name),
method='delete',
)
assert r.status_code == 405
assert r.json()['message'] == f'Service {service_name} is not modifiable at runtime'
async def test_root_api(app): async def test_root_api(app):
base_url = app.hub.url
url = ujoin(base_url, 'api')
kwargs = {} kwargs = {}
if app.internal_ssl: if app.internal_ssl:
kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key) kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key)

View File

@@ -220,7 +220,7 @@ def test_cookie_secret_env(tmpdir, request):
assert not os.path.exists(hub.cookie_secret_file) assert not os.path.exists(hub.cookie_secret_file)
def test_cookie_secret_string_(): def test_cookie_secret_string():
cfg = Config() cfg = Config()
cfg.JupyterHub.cookie_secret = "not hex" cfg.JupyterHub.cookie_secret = "not hex"
@@ -270,18 +270,41 @@ async def test_load_groups(tmpdir, request):
) )
async def test_resume_spawners(tmpdir, request): @pytest.fixture
if not os.getenv('JUPYTERHUB_TEST_DB_URL'): def persist_db(tmpdir):
p = patch.dict( """ensure db will persist (overrides default sqlite://:memory:)"""
os.environ, if os.getenv('JUPYTERHUB_TEST_DB_URL'):
{ # already using a db, no need
'JUPYTERHUB_TEST_DB_URL': 'sqlite:///%s' yield
% tmpdir.join('jupyterhub.sqlite') return
}, with patch.dict(
) os.environ,
p.start() {'JUPYTERHUB_TEST_DB_URL': f"sqlite:///{tmpdir.join('jupyterhub.sqlite')}"},
request.addfinalizer(p.stop) ):
yield
@pytest.fixture
def new_hub(request, tmpdir, persist_db):
"""Fixture to launch a new hub for testing"""
async def new_hub():
kwargs = {}
ssl_enabled = getattr(request.module, "ssl_enabled", False)
if ssl_enabled:
kwargs['internal_certs_location'] = str(tmpdir)
app = MockHub(test_clean_db=False, **kwargs)
app.config.ConfigurableHTTPProxy.should_start = False
app.config.ConfigurableHTTPProxy.auth_token = 'unused'
request.addfinalizer(app.stop)
await app.initialize([])
return app
return new_hub
async def test_resume_spawners(tmpdir, request, new_hub):
async def new_hub(): async def new_hub():
kwargs = {} kwargs = {}
ssl_enabled = getattr(request.module, "ssl_enabled", False) ssl_enabled = getattr(request.module, "ssl_enabled", False)
@@ -473,3 +496,42 @@ async def test_user_creation(tmpdir, request):
"in-group", "in-group",
"in-role", "in-role",
} }
async def test_recreate_service_from_database(
request, new_hub, service_name, service_data
):
# create a hub and add a service (not from config)
app = await new_hub()
app.service_from_spec(service_data, from_config=False)
app.stop()
# new hub, should load service from db
app = await new_hub()
assert service_name in app._service_map
# verify keys
service = app._service_map[service_name]
for key, value in service_data.items():
if key in {'api_token'}:
# skip some keys
continue
assert getattr(service, key) == value
assert (
service_data['oauth_client_id'] in app.tornado_settings['oauth_no_confirm_list']
)
oauth_client = (
app.db.query(orm.OAuthClient)
.filter_by(identifier=service_data['oauth_client_id'])
.first()
)
assert oauth_client.redirect_uri == service_data['oauth_redirect_uri']
# delete service from db, start one more
app.db.delete(service.orm)
app.db.commit()
# start one more, service should be gone
app = await new_hub()
assert service_name not in app._service_map

View File

@@ -44,7 +44,9 @@ def generate_old_db(env_dir, hub_version, db_url):
# changes to this version list must also be reflected # changes to this version list must also be reflected
# in ci/init-db.sh # in ci/init-db.sh
@pytest.mark.parametrize('hub_version', ["1.1.0", "1.2.2", "1.3.0", "1.5.0", "2.1.1"]) @pytest.mark.parametrize(
'hub_version', ['1.1.0', '1.2.2', '1.3.0', '1.5.0', '2.1.1', '3.1.1']
)
async def test_upgrade(tmpdir, hub_version): async def test_upgrade(tmpdir, hub_version):
db_url = os.getenv('JUPYTERHUB_TEST_DB_URL') db_url = os.getenv('JUPYTERHUB_TEST_DB_URL')
if db_url: if db_url:

View File

@@ -236,6 +236,16 @@ def test_orm_roles_delete_cascade(db):
['tokens!group=hobbits'], ['tokens!group=hobbits'],
{'tokens!group=hobbits', 'read:tokens!group=hobbits'}, {'tokens!group=hobbits', 'read:tokens!group=hobbits'},
), ),
(
['admin:services'],
{
'read:roles:services',
'read:services:name',
'admin:services',
'list:services',
'read:services',
},
),
], ],
) )
def test_get_expanded_scopes(db, scopes, expected_scopes): def test_get_expanded_scopes(db, scopes, expected_scopes):