aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/deps.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/deps.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py65
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(