Revoke all permissions from Authenticator.blocked_users

rather than only disabling login, fully block the user from Hub operations
by removing all group membership and role assignments
This commit is contained in:
Min RK
2024-08-12 13:48:05 +02:00
parent a377f8bc7f
commit 6be699c333
4 changed files with 123 additions and 2 deletions

View File

@@ -2182,7 +2182,11 @@ class JupyterHub(Application):
# but changes to the allowed_users set can occur in the database, # but changes to the allowed_users set can occur in the database,
# and persist across sessions. # and persist across sessions.
total_users = 0 total_users = 0
blocked_users = self.authenticator.blocked_users
for user in db.query(orm.User): for user in db.query(orm.User):
if user.name in blocked_users:
# don't call add_user with blocked users
continue
try: try:
f = self.authenticator.add_user(user) f = self.authenticator.add_user(user)
if f: if f:
@@ -2238,6 +2242,35 @@ class JupyterHub(Application):
await maybe_future(f) await maybe_future(f)
return user 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): async def init_groups(self):
"""Load predefined groups into the database""" """Load predefined groups into the database"""
db = self.db db = self.db
@@ -2965,6 +2998,18 @@ class JupyterHub(Application):
async def check_spawner(user, name, spawner): async def check_spawner(user, name, spawner):
status = 0 status = 0
if spawner.server: 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: try:
status = await spawner.poll() status = await spawner.poll()
except Exception: except Exception:
@@ -3356,6 +3401,7 @@ class JupyterHub(Application):
self.init_services() self.init_services()
await self.init_api_tokens() await self.init_api_tokens()
await self.init_role_assignment() await self.init_role_assignment()
await self.init_blocked_users()
self.init_tornado_settings() self.init_tornado_settings()
self.init_handlers() self.init_handlers()
self.init_tornado_application() self.init_tornado_application()

View File

@@ -301,6 +301,14 @@ class Authenticator(LoggingConfigurable):
.. versionadded: 0.9 .. 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 .. versionchanged:: 1.2
`Authenticator.blacklist` renamed to `blocked_users` `Authenticator.blacklist` renamed to `blocked_users`
""" """

View File

@@ -1234,6 +1234,8 @@ class APIToken(Hashed, Base):
orm_token.expires_at = cls.now() + timedelta(seconds=expires_in) orm_token.expires_at = cls.now() + timedelta(seconds=expires_in)
db.commit() db.commit()
if return_orm:
return orm_token
return token return token
def update_scopes(self, new_scopes): def update_scopes(self, new_scopes):

View File

@@ -8,6 +8,7 @@ import os
import re import re
import sys import sys
import time import time
from concurrent.futures import ThreadPoolExecutor
from subprocess import PIPE, Popen, check_output from subprocess import PIPE, Popen, check_output
from tempfile import NamedTemporaryFile, TemporaryDirectory from tempfile import NamedTemporaryFile, TemporaryDirectory
from unittest.mock import patch from unittest.mock import patch
@@ -16,6 +17,8 @@ import pytest
import traitlets import traitlets
from traitlets.config import Config from traitlets.config import Config
from jupyterhub.scopes import get_scopes_for
from .. import orm from .. import orm
from ..app import COOKIE_SECRET_BYTES, JupyterHub from ..app import COOKIE_SECRET_BYTES, JupyterHub
from .mocking import MockHub from .mocking import MockHub
@@ -289,8 +292,7 @@ def persist_db(tmpdir):
def new_hub(request, tmpdir, persist_db): def new_hub(request, tmpdir, persist_db):
"""Fixture to launch a new hub for testing""" """Fixture to launch a new hub for testing"""
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)
if ssl_enabled: if ssl_enabled:
kwargs['internal_certs_location'] = str(tmpdir) kwargs['internal_certs_location'] = str(tmpdir)
@@ -537,3 +539,66 @@ async def test_recreate_service_from_database(
# start one more, service should be gone # start one more, service should be gone
app = await new_hub() app = await new_hub()
assert service_name not in app._service_map 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