Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions runtime/hub/core/authenticators/firstuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@

from __future__ import annotations

from concurrent.futures import ThreadPoolExecutor

import bcrypt
from firstuseauthenticator import FirstUseAuthenticator

Expand Down Expand Up @@ -105,6 +107,78 @@ def set_password(self, username: str, password: str, force_change: bool = True)
suffix = " (force change on next login)" if force_change else ""
return f"Password set for {username}{suffix}"

def batch_set_passwords(
self,
users: list[dict],
force_change: bool = True,
) -> dict:
"""Set passwords for multiple users in a single transaction.

Args:
users: List of dicts with 'username' and 'password' keys.
force_change: Whether to force password change on first login.

Returns:
Dict with 'success', 'failed' counts and 'results' list.
"""
min_len = getattr(self, "min_password_length", 1)
results = {"success": 0, "failed": 0, "results": []}

# Validate passwords first
valid_entries = []
for entry in users:
username = entry["username"]
password = entry["password"]
if not password or len(password) < min_len:
results["failed"] += 1
results["results"].append(
{
"username": username,
"status": "failed",
"error": f"Password too short (min {min_len})",
}
)
continue
valid_entries.append((username, password))

# Parallel bcrypt hashing (bcrypt releases GIL, threads give real speedup)
def _hash(pw: str) -> bytes:
return bcrypt.hashpw(pw.encode("utf8"), bcrypt.gensalt())

with ThreadPoolExecutor() as pool:
hash_results = list(pool.map(_hash, [pw for _, pw in valid_entries]))
hashed = [(username, h) for (username, _), h in zip(valid_entries, hash_results)]

# Single DB transaction with per-user savepoints
with session_scope() as session:
for username, password_hash in hashed:
try:
with session.begin_nested():
user_pw = session.query(UserPassword).filter_by(username=username).first()
if user_pw:
user_pw.password_hash = password_hash
user_pw.force_change = force_change
else:
user_pw = UserPassword(
username=username,
password_hash=password_hash,
force_change=force_change,
)
session.add(user_pw)
results["success"] += 1
results["results"].append({"username": username, "status": "success"})
except Exception as e:
results["failed"] += 1
results["results"].append(
{
"username": username,
"status": "failed",
"error": str(e),
}
)

return results

def mark_force_password_change(self, username: str, force: bool = True) -> None:
"""Mark or unmark a user for forced password change."""
with session_scope() as session:
Expand Down
107 changes: 104 additions & 3 deletions runtime/hub/core/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"quota_rates": {},
"quota_enabled": False,
"minimum_quota_to_start": 10,
"default_quota": 0,
}


Expand All @@ -66,6 +67,7 @@ def configure_handlers(
quota_rates: dict[str, int] | None = None,
quota_enabled: bool = False,
minimum_quota_to_start: int = 10,
default_quota: int = 0,
) -> None:
"""Configure handler module with runtime settings."""
if accelerator_options is not None:
Expand All @@ -74,6 +76,7 @@ def configure_handlers(
_handler_config["quota_rates"] = quota_rates
_handler_config["quota_enabled"] = quota_enabled
_handler_config["minimum_quota_to_start"] = minimum_quota_to_start
_handler_config["default_quota"] = default_quota


# =============================================================================
Expand Down Expand Up @@ -377,6 +380,77 @@ async def get(self):
self.finish(json.dumps({"password": password}))


class AdminAPIBatchSetPasswordHandler(APIHandler):
"""API endpoint for batch setting user passwords."""

@web.authenticated
async def post(self):
"""Set passwords for multiple users in a single request."""
assert self.current_user is not None
if not self.current_user.admin:
self.set_status(403)
self.set_header("Content-Type", "application/json")
return self.finish(json.dumps({"error": "Admin access required"}))

try:
data = json.loads(self.request.body.decode("utf-8"))
users = data.get("users", [])
force_change = data.get("force_change", True)

if not users or not isinstance(users, list):
self.set_status(400)
self.set_header("Content-Type", "application/json")
return self.finish(json.dumps({"error": "users array is required"}))

if len(users) > 1000:
self.set_status(400)
self.set_header("Content-Type", "application/json")
return self.finish(json.dumps({"error": "Maximum 1000 users per batch"}))

# Validate entries
for entry in users:
if not isinstance(entry, dict) or "username" not in entry or "password" not in entry:
self.set_status(400)
self.set_header("Content-Type", "application/json")
return self.finish(json.dumps({"error": "Each entry must have username and password"}))
if entry.get("username", "").startswith("github:"):
self.set_status(400)
self.set_header("Content-Type", "application/json")
return self.finish(
json.dumps({"error": f"Cannot set password for GitHub user: {entry['username']}"})
)

firstuse_auth = None
if isinstance(self.authenticator, MultiAuthenticator):
for authenticator in self.authenticator._authenticators:
if isinstance(authenticator, CustomFirstUseAuthenticator):
firstuse_auth = authenticator
break

if not firstuse_auth:
self.set_status(500)
self.set_header("Content-Type", "application/json")
return self.finish(json.dumps({"error": "Password management not available"}))

loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, lambda: firstuse_auth.batch_set_passwords(users, force_change=force_change)
)

self.set_header("Content-Type", "application/json")
self.finish(json.dumps(result))

except json.JSONDecodeError:
self.set_status(400)
self.set_header("Content-Type", "application/json")
self.finish(json.dumps({"error": "Invalid JSON"}))
except Exception as e:
self.log.error(f"Failed to batch set passwords: {e}")
self.set_status(500)
self.set_header("Content-Type", "application/json")
self.finish(json.dumps({"error": "Internal server error"}))


# =============================================================================
# Quota Management Handlers
# =============================================================================
Expand Down Expand Up @@ -531,19 +605,44 @@ async def post(self):
quota_manager = get_quota_manager()
admin_name = self.current_user.name

# Separate unlimited and regular users
unlimited_users = [u for u in req.users if u.unlimited is True]
unset_unlimited_users = [u for u in req.users if u.unlimited is False]
regular_users = [u for u in req.users if u.unlimited is None]

results = {"success": 0, "failed": 0, "details": []}

for user in req.users:
# Handle unlimited users
for user in unlimited_users:
try:
quota_manager.set_balance(user.username, user.amount, admin_name)
quota_manager.set_unlimited(user.username, True, admin_name)
results["success"] += 1
results["details"].append({"username": user.username, "status": "success", "balance": user.amount})
results["details"].append({"username": user.username, "status": "success", "unlimited": True})
except Exception:
results["failed"] += 1
results["details"].append(
{"username": user.username, "status": "failed", "error": "Processing error"}
)

# Handle unset-unlimited users
for user in unset_unlimited_users:
try:
quota_manager.set_unlimited(user.username, False, admin_name)
results["success"] += 1
except Exception:
results["failed"] += 1
results["details"].append(
{"username": user.username, "status": "failed", "error": "Processing error"}
)

# Batch set balance for regular users + unset-unlimited users in single transaction
batch_users = [(u.username, u.amount) for u in regular_users + unset_unlimited_users]
if batch_users:
batch_result = quota_manager.batch_set_quota(batch_users, admin_name)
results["success"] += batch_result["success"]
results["failed"] += batch_result["failed"]
results["details"].extend(batch_result.get("details", []))

self.set_header("Content-Type", "application/json")
self.finish(json.dumps(results))

Expand Down Expand Up @@ -641,6 +740,7 @@ async def get(self):
"enabled": _handler_config["quota_enabled"],
"rates": _handler_config["quota_rates"],
"minimum_to_start": _handler_config["minimum_quota_to_start"],
"default_quota": _handler_config["default_quota"],
}
)
)
Expand Down Expand Up @@ -1079,6 +1179,7 @@ def get_handlers() -> list[tuple[str, type]]:
(r"/admin/users", AdminUIHandler),
(r"/admin/groups", AdminUIHandler),
(r"/admin/api/set-password", AdminAPISetPasswordHandler),
(r"/admin/api/batch-set-password", AdminAPIBatchSetPasswordHandler),
(r"/admin/api/generate-password", AdminAPIGeneratePasswordHandler),
# Accelerator info API
(r"/api/accelerators", AcceleratorsAPIHandler),
Expand Down
41 changes: 32 additions & 9 deletions runtime/hub/core/quota/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,15 +476,38 @@ def get_all_balances(self) -> list[dict]:
session.close()

def batch_set_quota(self, users: list[tuple[str, int]], admin: str | None = None) -> dict:
"""Set quota for multiple users at once."""
results = {"success": 0, "failed": 0}
for username, amount in users:
try:
self.set_balance(username, amount, admin)
results["success"] += 1
except Exception as e:
results["failed"] += 1
print(f"Failed to set quota for {username}: {e}")
"""Set quota for multiple users in a single transaction with per-user savepoints."""
results = {"success": 0, "failed": 0, "details": []}
with self._op_lock, session_scope() as session:
for username, amount in users:
try:
with session.begin_nested():
uname = username.lower()
user = session.query(UserQuota).filter(UserQuota.username == uname).first()
if not user:
user = UserQuota(username=uname, balance=amount)
session.add(user)
balance_before = 0
else:
balance_before = user.balance
user.balance = amount

transaction = QuotaTransaction(
username=uname,
amount=amount - balance_before,
transaction_type="set",
balance_before=balance_before,
balance_after=amount,
description=f"Balance set to {amount}",
created_by=admin,
)
session.add(transaction)
results["success"] += 1
results["details"].append({"username": uname, "status": "success", "balance": amount})
except Exception as e:
results["failed"] += 1
results["details"].append({"username": username, "status": "failed", "error": str(e)})
print(f"Failed to set quota for {username}: {e}")
return results

def _match_targets(self, username: str, balance: int, is_unlimited: bool, targets: dict) -> bool:
Expand Down
3 changes: 2 additions & 1 deletion runtime/hub/core/quota/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ class BatchQuotaUser(BaseModel):
"""User entry for batch quota operation."""

username: str = Field(..., min_length=1, max_length=200, pattern=r"^[a-zA-Z0-9._@-]+$")
amount: int = Field(..., ge=-10_000_000, le=10_000_000)
amount: int = Field(default=0, ge=-10_000_000, le=10_000_000)
unlimited: bool | None = Field(default=None, description="Set unlimited quota (overrides amount)")


class BatchQuotaRequest(BaseModel):
Expand Down
1 change: 1 addition & 0 deletions runtime/hub/core/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ async def auth_state_hook(spawner, auth_state):
quota_rates=config.build_quota_rates(),
quota_enabled=config.quota_enabled,
minimum_quota_to_start=config.quota.minimumToStart,
default_quota=config.quota.defaultQuota,
)

if not hasattr(c.JupyterHub, "extra_handlers") or c.JupyterHub.extra_handlers is None:
Expand Down
Loading