""" FastAPI shared dependencies — injected via Depends(). JWT scope enforcement: - "user" scope (browser login): full access to all endpoints - "node" scope (Ed25519 daemon auth): read-only group access + node operations 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 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, _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: """Decode and verify JWT bearer token. Returns full payload.""" try: scheme, token = authorization.split(None, 1) if scheme.lower() != "bearer": raise ValueError return decode_access_token(token) except Exception: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}, ) async def get_current_user( payload: dict = Depends(_decode_token), db: AsyncSession = Depends(get_db), ) -> User: """ Verify the JWT bearer token and return the User from the database. Accepts both user-scoped and node-scoped tokens. """ result = await db.execute( select(User).where(User.id == payload["sub"])) user = result.scalar_one_or_none() if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") 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 def _reject_node_scope(payload: dict) -> None: """Refuse a node-scoped daemon token on a route meant for a person. A node's authority and a hub role are **different notions**. What a node may do is decided by its operator's roster pin on the node itself (NS4) and by the node scope's deliberately narrow reach; being an admin or a moderator is a hub role attached to a person's account. A node daemon authenticates with the node key and receives a `scope:"node"` token so that the machine can register, signal and host — never so that it can act as its operator on the hub. When the operator's account happens to also hold a hub role, that role is the *person's*, exercised from a browser with a user-scoped token, and must not be reachable by a token the daemon holds in memory. So the scope gate lives in one place and fronts every privileged dependency, not only group mutation. """ if payload.get("scope") == "node": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Node-scoped token cannot perform this operation — use browser", ) async def require_user_scope( payload: dict = Depends(_decode_token), current_user: User = Depends(get_current_user), ) -> User: """Reject node-scoped tokens — only browser (user-scope) can mutate groups.""" _reject_node_scope(payload) return current_user async def require_node_scope( payload: dict = Depends(_decode_token), current_user: User = Depends(get_current_user), ) -> User: """Only a node daemon's own token — for what a node fetches on its own behalf.""" if payload.get("scope") != "node": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Node token required") return current_user def user_is_admin(user: User) -> bool: """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 _is_pinned_admin(user) async def require_moderator( payload: dict = Depends(_decode_token), current_user: User = Depends(get_current_user), ) -> User: # A node-scoped daemon token is refused here even for a moderator's own # account: the hub moderation surface (suspending accounts, reading the IP # audit log, listing nodes) is the person's, not the machine's. _reject_node_scope(payload) if not user_is_moderator(current_user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Moderator access required") return current_user async def require_admin( payload: dict = Depends(_decode_token), current_user: User = Depends(get_current_user), ) -> User: # Likewise: revoking accounts and groups (signed, broadcast to every node) # and changing instance policy are administrative acts a person performs # from a browser, never something a node token may reach. _reject_node_scope(payload) if not user_is_admin(current_user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") return current_user