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