diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 8bc5b937..7f5704cb 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -2182,7 +2182,11 @@ class JupyterHub(Application): # but changes to the allowed_users set can occur in the database, # and persist across sessions. total_users = 0 + blocked_users = self.authenticator.blocked_users for user in db.query(orm.User): + if user.name in blocked_users: + # don't call add_user with blocked users + continue try: f = self.authenticator.add_user(user) if f: @@ -2238,6 +2242,35 @@ class JupyterHub(Application): await maybe_future(f) return user + async def init_blocked_users(self): + """Revoke all permissions for users in Authenticator.blocked_users""" + blocked_users = self.authenticator.blocked_users + if not blocked_users: + # nothing to check + return + db = self.db + for user in db.query(orm.User).filter(orm.User.name.in_(blocked_users)): + # revoke permissions from blocked users + # so already-issued credentials have no access to the API + self.log.debug(f"Found blocked user in database: {user.name}") + if user.admin: + self.log.warning( + f"Removing admin permissions from blocked user {user.name}" + ) + user.admin = False + if user.roles: + self.log.warning( + f"Removing blocked user {user.name} from roles: {', '.join(role.name for role in user.roles)}" + ) + user.roles = [] + if user.groups: + self.log.warning( + f"Removing blocked user {user.name} from groups: {', '.join(group.name for group in user.groups)}" + ) + user.groups = [] + + db.commit() + async def init_groups(self): """Load predefined groups into the database""" db = self.db @@ -2965,6 +2998,18 @@ class JupyterHub(Application): async def check_spawner(user, name, spawner): status = 0 if spawner.server: + if user.name in self.authenticator.blocked_users: + self.log.warning( + f"Stopping spawner for blocked user: {spawner._log_name}" + ) + try: + await user.stop(name) + except Exception: + self.log.exception( + f"Failed to stop {spawner._log_name}", + exc_info=True, + ) + return try: status = await spawner.poll() except Exception: @@ -3356,6 +3401,7 @@ class JupyterHub(Application): self.init_services() await self.init_api_tokens() await self.init_role_assignment() + await self.init_blocked_users() self.init_tornado_settings() self.init_handlers() self.init_tornado_application() diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index 3ab15aa9..84f5594b 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -300,6 +300,14 @@ class Authenticator(LoggingConfigurable): If empty, does not perform any additional restriction. .. versionadded: 0.9 + + .. versionchanged:: 5.2 + Users blocked via `blocked_users` that may have logged in in the past + have all permissions and group membership revoked + and all servers stopped at JupyterHub startup. + Previously, User permissions (e.g. API tokens) + and servers were unaffected and required additional + administrator operations to block after a user is added to `blocked_users`. .. versionchanged:: 1.2 `Authenticator.blacklist` renamed to `blocked_users` diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 91504a4a..4bc62e44 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -1234,6 +1234,8 @@ class APIToken(Hashed, Base): orm_token.expires_at = cls.now() + timedelta(seconds=expires_in) db.commit() + if return_orm: + return orm_token return token def update_scopes(self, new_scopes): diff --git a/jupyterhub/tests/test_app.py b/jupyterhub/tests/test_app.py index 819a00e6..0e400da6 100644 --- a/jupyterhub/tests/test_app.py +++ b/jupyterhub/tests/test_app.py @@ -8,6 +8,7 @@ import os import re import sys import time +from concurrent.futures import ThreadPoolExecutor from subprocess import PIPE, Popen, check_output from tempfile import NamedTemporaryFile, TemporaryDirectory from unittest.mock import patch @@ -16,6 +17,8 @@ import pytest import traitlets from traitlets.config import Config +from jupyterhub.scopes import get_scopes_for + from .. import orm from ..app import COOKIE_SECRET_BYTES, JupyterHub from .mocking import MockHub @@ -289,8 +292,7 @@ def persist_db(tmpdir): def new_hub(request, tmpdir, persist_db): """Fixture to launch a new hub for testing""" - async def new_hub(): - kwargs = {} + async def new_hub(**kwargs): ssl_enabled = getattr(request.module, "ssl_enabled", False) if ssl_enabled: kwargs['internal_certs_location'] = str(tmpdir) @@ -537,3 +539,66 @@ async def test_recreate_service_from_database( # start one more, service should be gone app = await new_hub() assert service_name not in app._service_map + + +async def test_revoke_blocked_users(app, username, groupname, new_hub): + config = Config() + config.Authenticator.admin_users = {username} + config.JupyterHub.load_groups = { + groupname: { + "users": [username], + }, + } + config.JupyterHub.load_roles = [ + { + "name": "testrole", + "scopes": ["access:services"], + "groups": [groupname], + } + ] + app = await new_hub(config=config) + user = app.users[username] + + # load some credentials, start server + await user.spawn() + # await app.proxy.add_user(user) + spawner = user.spawners[''] + token = user.new_api_token(return_orm=True) + app.cleanup_servers = False + app.stop() + + # before state + assert await spawner.poll() is None + assert sorted(role.name for role in user.roles) == ['admin', 'user'] + assert [g.name for g in user.groups] == [groupname] + assert user.admin + user_scopes = get_scopes_for(user) + assert "access:servers" in user_scopes + token_scopes = get_scopes_for(token) + assert "access:servers" in token_scopes + + # start a new hub, now with blocked users + config = Config() + name_doesnt_exist = user.name + "-doesntexist" + config.Authenticator.blocked_users = {user.name, name_doesnt_exist} + config.JupyterHub.init_spawners_timeout = 60 + # background spawner.proc.wait to avoid waiting for zombie process here + with ThreadPoolExecutor(1) as pool: + pool.submit(spawner.proc.wait) + app2 = await new_hub(config=config) + assert app2.db_url == app.db_url + + # check that blocked user has no permissions + user2 = app2.users[user.name] + assert user2.roles == [] + assert user2.groups == [] + assert user2.admin is False + user_scopes = get_scopes_for(user2) + assert user_scopes == set() + token = orm.APIToken.find(app2.db, token.token) + token_scopes = get_scopes_for(token) + assert token_scopes == set() + + # spawner stopped + assert user2.spawners == {} + assert await spawner.poll() is not None