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)])