diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 12:37:31 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 12:37:31 +0200 |
| commit | 8a4651e9d223de856ff085b329801998f95db138 (patch) | |
| tree | 6153ffaf46030613fa9024e85b9890c7fa979154 /packages/meshbay-hub/src/meshbay_hub/api | |
| parent | 1684fcb64531eaf2e9bb8567ba4bdcbada6469d8 (diff) | |
| download | meshbay-8a4651e9d223de856ff085b329801998f95db138.tar.gz | |
fix(hub): the admin allow-list grants an account, not a username
Each name in admin_usernames is pinned to the first active account seen
holding it (admin_pins), so a name freed by a deletion grants nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/deps.py | 65 |
1 files changed, 58 insertions, 7 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py index 2fd8b99..56af023 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py @@ -7,20 +7,70 @@ JWT scope enforcement: Node-scoped tokens CANNOT create/delete groups or manage membership. """ +import logging + from fastapi import Depends, Header, HTTPException, status from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.auth import decode_access_token from meshbay_hub.db.engine import get_db -from meshbay_hub.db.models import User +from meshbay_hub.db.models import AdminPin, User + +log = logging.getLogger(__name__) +# `hub.toml`'s `admin_usernames`, and the account each of those names was pinned +# to (`AdminPin`). The names only say which accounts to pin; what grants the +# role is the pinned id, so a listed name released by an account deletion and +# registered again by somebody else grants nothing. _admin_usernames: set[str] = set() +_admin_pins: dict[str, str] = {} def set_admin_usernames(usernames: list[str]) -> None: - global _admin_usernames + global _admin_usernames, _admin_pins _admin_usernames = set(usernames) + _admin_pins = {} + + +def set_admin_pins(pins: dict[str, str]) -> None: + global _admin_pins + _admin_pins = {name: uid for name, uid in pins.items() if name in _admin_usernames} + + +def _is_pinned_admin(user: User) -> bool: + return user.id in _admin_pins.values() + + +async def _pin_if_listed(db: AsyncSession, user: User) -> None: + """Pin an allow-listed name to the first active account seen holding it. + + Startup pins every listed name that already has an account; this covers a + name registered while the hub runs, so the operator's own first sign-in is + an admin one without a restart, as it was before pins existed. + """ + name = user.username + if name not in _admin_usernames or name in _admin_pins: + return + pin = await db.get(AdminPin, name) + if pin is None: + db.add(AdminPin(username=name, user_id=user.id)) + user.role = "admin" + try: + await db.commit() + except IntegrityError: + # Another worker pinned it first; its answer stands. + await db.rollback() + await db.refresh(user) + pin = await db.get(AdminPin, name) + if pin is None: + return + else: + log.info("Admin allow-list: %s pinned to account %s", name, user.id[:8]) + _admin_pins[name] = user.id + return + _admin_pins[name] = pin.user_id async def _decode_token(authorization: str = Header(...)) -> dict: @@ -56,6 +106,7 @@ async def get_current_user( if user.status != "active": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Account {user.status}") + await _pin_if_listed(db, user) return user @@ -102,14 +153,14 @@ async def require_node_scope( def user_is_admin(user: User) -> bool: - """Admin by DB role or by the config allow-list. Use inside a handler that - already depends on `require_moderator` but has to draw the admin line for - one field (see `admin_patch_user`).""" - return user.role == "admin" or user.username in _admin_usernames + """Admin by DB role or as the account a config allow-listed name is pinned + to. Use inside a handler that already depends on `require_moderator` but has + to draw the admin line for one field (see `admin_patch_user`).""" + return user.role == "admin" or _is_pinned_admin(user) def user_is_moderator(user: User) -> bool: - return user.role in ("moderator", "admin") or user.username in _admin_usernames + return user.role in ("moderator", "admin") or _is_pinned_admin(user) async def require_moderator( |