From a195563da6c066f242ae483141ddd180b47640c5 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 16 Aug 2026 14:19:04 -0400 Subject: [PATCH] Let edge nodes read /systems with their own api_key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- drb-c2-core/app/internal/auth.py | 35 ++++++++++++++++++++++++++++++++ drb-c2-core/app/main.py | 12 +++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/drb-c2-core/app/internal/auth.py b/drb-c2-core/app/internal/auth.py index c662759..801667c 100644 --- a/drb-c2-core/app/internal/auth.py +++ b/drb-c2-core/app/internal/auth.py @@ -37,6 +37,41 @@ async def require_service_or_firebase_token( 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. diff --git a/drb-c2-core/app/main.py b/drb-c2-core/app/main.py index a5bdd3a..f2e7ba7 100644 --- a/drb-c2-core/app/main.py +++ b/drb-c2-core/app/main.py @@ -9,7 +9,11 @@ from app.internal.summarizer import summarizer_loop from app.internal.vocabulary_learner import vocabulary_induction_loop from app.internal.recorrelation_sweep import recorrelation_loop from app.config import settings -from app.internal.auth import require_firebase_token, require_service_or_firebase_token +from app.internal.auth import ( + require_firebase_token, + require_service_or_firebase_token, + require_node_service_or_firebase_token, +) from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users from app.routers import enrollment from app.internal import dynsec @@ -81,7 +85,11 @@ app.add_middleware( ) app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)]) -app.include_router(systems.router, dependencies=[Depends(require_service_or_firebase_token)]) +# systems is the one router edge nodes read directly (system_cacher.py builds +# the OP25 config from it), so its gate also accepts a per-node api_key. The +# write routes inside carry their own require_admin_token, so nodes get read +# access only. +app.include_router(systems.router, dependencies=[Depends(require_node_service_or_firebase_token)]) app.include_router(calls.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])