Files
server-26/drb-c2-core/app/internal/auth.py
T
Logan CusanoandClaude Opus 5 a195563da6
Build & Deploy / Build & push images (push) Successful in 4m26s
Build & Deploy / Deploy to VM (push) Successful in 1m55s
Let edge nodes read /systems with their own api_key
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>
2026-08-16 14:19:04 -04:00

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)