The node builds its OP25 config from GET /systems, but that router only accepted a Firebase token or the shared service key — a node holds neither. Every fetch returned 401 and the node fell back to its stale offline cache, so a system edited in the UI never reached the field. Confirmed on node-002 against the live server: "Failed to fetch systems from C2: 401 Unauthorized ... Offline cache will be used." The node sends no node_id with the request, only the bearer token, so the key is matched by querying node_keys for the value instead of fetching a known document the way /upload does. Read access only: the mutating routes in this router each carry their own require_admin_token, so widening the router-level gate doesn't let a node create, edit or delete a system. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
168 lines
6.9 KiB
Python
168 lines
6.9 KiB
Python
import secrets
|
|
import time
|
|
from collections import defaultdict, deque
|
|
from typing import Optional
|
|
from fastapi import HTTPException, Security
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
from firebase_admin import auth as firebase_auth
|
|
from app.config import settings
|
|
|
|
_bearer = HTTPBearer(auto_error=False)
|
|
|
|
|
|
async def require_firebase_token(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Verify a Firebase ID token from the Authorization: Bearer header."""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
try:
|
|
return firebase_auth.verify_id_token(credentials.credentials)
|
|
except Exception:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
|
|
|
|
async def require_service_or_firebase_token(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Accept either a Firebase ID token or the internal service key."""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
token = credentials.credentials
|
|
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
|
return {"service": True}
|
|
try:
|
|
return firebase_auth.verify_id_token(token)
|
|
except Exception:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
|
|
|
|
async def require_node_service_or_firebase_token(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Accept a node's own API key in addition to a service key / Firebase token.
|
|
|
|
Edge nodes need to read ``/systems`` to build their OP25 config, but they
|
|
hold neither a Firebase token nor the shared service key — only the
|
|
per-node api_key that ``/upload`` already trusts. Without this they got a
|
|
flat 401 and silently fell back to their stale offline cache, so a system
|
|
edited in the UI never reached the node.
|
|
|
|
Unlike ``/upload``, the node sends no node_id alongside the bearer token,
|
|
so the key is matched by querying ``node_keys`` for the value rather than
|
|
fetching a known document. Mutating routes are unaffected: they carry
|
|
their own ``require_admin_token`` dependency, so widening the router-level
|
|
gate grants nodes read access only.
|
|
"""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
token = credentials.credentials
|
|
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
|
return {"service": True}
|
|
try:
|
|
return firebase_auth.verify_id_token(token)
|
|
except Exception:
|
|
pass
|
|
# Deferred import: app.internal.firestore initialises firebase-admin at
|
|
# import time, and auth.py is imported from module scope in the routers.
|
|
from app.internal import firestore as fstore
|
|
matches = await fstore.collection_list("node_keys", api_key=token)
|
|
if matches:
|
|
return {"node": True, "node_id": matches[0].get("node_id")}
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
|
|
|
|
def get_role(decoded: dict) -> str:
|
|
"""Extract the effective role from a decoded Firebase token.
|
|
|
|
Checks the granular ``role`` claim first, then falls back to the legacy
|
|
``admin`` boolean so existing tokens continue to work during the transition.
|
|
"""
|
|
if decoded.get("role") == "admin" or decoded.get("admin"):
|
|
return "admin"
|
|
role = decoded.get("role", "viewer")
|
|
return role if role in ("admin", "operator", "viewer") else "viewer"
|
|
|
|
|
|
async def require_admin_token(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Verify a Firebase ID token AND require the admin role.
|
|
|
|
Accepts both the legacy ``admin: True`` boolean claim and the newer
|
|
``role: "admin"`` claim so tokens issued before the role migration still work.
|
|
"""
|
|
decoded = await require_firebase_token(credentials)
|
|
if get_role(decoded) != "admin":
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
return decoded
|
|
|
|
|
|
async def require_service_key(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Accept only the internal service key — used for bot-only endpoints."""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
if not settings.service_key:
|
|
raise HTTPException(status_code=503, detail="Service key not configured")
|
|
if not secrets.compare_digest(credentials.credentials, settings.service_key):
|
|
raise HTTPException(status_code=403, detail="Service key required")
|
|
return {"service": True}
|
|
|
|
|
|
async def require_service_key_or_admin(
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
|
) -> dict:
|
|
"""Accept either the internal service key or a Firebase admin token.
|
|
|
|
Used for endpoints that the Discord bot (service key) and dashboard admins
|
|
(Firebase + admin claim) both need to call, but regular Firebase users must not.
|
|
"""
|
|
if not credentials:
|
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
|
token = credentials.credentials
|
|
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
|
return {"service": True}
|
|
try:
|
|
decoded = firebase_auth.verify_id_token(token)
|
|
except Exception:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
if get_role(decoded) != "admin":
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
return decoded
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Simple in-memory sliding-window rate limiter
|
|
# ---------------------------------------------------------------------------
|
|
# Not persistent across restarts; good enough for a single-instance deployment.
|
|
# Key format is caller-defined (e.g. "{uid}:{endpoint}").
|
|
|
|
class _RateLimiter:
|
|
def __init__(self, max_calls: int, window_seconds: int):
|
|
self.max_calls = max_calls
|
|
self.window = window_seconds
|
|
self._log: dict[str, deque] = defaultdict(deque)
|
|
|
|
def check(self, key: str) -> None:
|
|
now = time.monotonic()
|
|
q = self._log[key]
|
|
while q and now - q[0] > self.window:
|
|
q.popleft()
|
|
if len(q) >= self.max_calls:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail="Rate limit exceeded. Please wait before trying again.",
|
|
)
|
|
q.append(now)
|
|
|
|
|
|
# Shared limiter instances
|
|
# trip chat: 20 requests per user per 5 minutes
|
|
trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
|
|
# per-incident summarize: 5 per incident per 10 minutes
|
|
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
|
# vocabulary bootstrap: 2 per system per hour
|
|
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
|