aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/deps.py
blob: 56af023d4c6b24351a9be2254aad94418901535a (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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
"""
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