aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/deps.py
blob: 42f4101f12cc51419f56a56b3e509d933b487572 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
"""
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.
"""

from fastapi import Depends, Header, HTTPException, status
from sqlalchemy import select
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

_admin_usernames: set[str] = set()


def set_admin_usernames(usernames: list[str]) -> None:
    global _admin_usernames
    _admin_usernames = set(usernames)


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}")
    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


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


def user_is_moderator(user: User) -> bool:
    return user.role in ("moderator", "admin") or user.username in _admin_usernames


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