From 81200b1ff0707ffcccce8823ebca0298d972a1b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Cano=20Duque?= Date: Tue, 7 Apr 2026 10:18:49 -0500 Subject: [PATCH] feat: add optional API key authentication All endpoints are now protected by an X-API-Key header when COMPASS_API_KEY is set. If the env var is unset, auth is disabled so local development requires no configuration. Returns 401 on missing or invalid key. Also adds 7 auth tests covering: no-key mode, valid key, invalid key, missing key, and auth applied to /chat, /upload, /documents. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 1 + backend/main.py | 12 +++++++++++- tests/test_api.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index d5adf64..b2ec8a9 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,4 @@ COMPASS_MODEL=ollama/gemma4:e4b COMPASS_DOCS_PATH=./data/docs COMPASS_WORKSPACE=./data/index COMPASS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 +COMPASS_API_KEY= # leave empty to disable auth (local dev) diff --git a/backend/main.py b/backend/main.py index 58a956d..4b5d6a2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,7 +5,8 @@ from contextlib import asynccontextmanager from dotenv import load_dotenv -from fastapi import FastAPI, Request, UploadFile, File, HTTPException +from fastapi import FastAPI, Request, UploadFile, File, HTTPException, Security +from fastapi.security.api_key import APIKeyHeader from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel @@ -24,6 +25,14 @@ COMPASS_MODEL = os.getenv("COMPASS_MODEL", "ollama/gemma4:e4b") DOCS_PATH = Path(os.getenv("COMPASS_DOCS_PATH", "./data/docs")) WORKSPACE = Path(os.getenv("COMPASS_WORKSPACE", "./data/index")) +API_KEY = os.getenv("COMPASS_API_KEY") # None = auth disabled + +_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + + +async def verify_api_key(key: str = Security(_api_key_header)): + if API_KEY and key != API_KEY: + raise HTTPException(status_code=401, detail="Invalid or missing API key.") os.environ["OLLAMA_API_BASE"] = OLLAMA_API_BASE @@ -57,6 +66,7 @@ async def lifespan(app: FastAPI): description="El cerebro operativo de tu empresa.", version="0.2.0", lifespan=lifespan, + dependencies=[Security(verify_api_key)], ) ALLOWED_ORIGINS = [ diff --git a/tests/test_api.py b/tests/test_api.py index 0e82d42..899d446 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,6 +3,52 @@ No Ollama, no disk I/O, no SQLite required — all external calls are mocked. """ +import pytest +from unittest.mock import patch + + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- + +class TestAuth: + def test_no_key_required_when_env_unset(self, client_empty): + """Auth is disabled by default — all requests pass through.""" + assert client_empty.get("/health").status_code == 200 + + def test_valid_key_is_accepted(self, client_empty): + with patch("backend.main.API_KEY", "secret"): + r = client_empty.get("/health", headers={"X-API-Key": "secret"}) + assert r.status_code == 200 + + def test_invalid_key_returns_401(self, client_empty): + with patch("backend.main.API_KEY", "secret"): + r = client_empty.get("/health", headers={"X-API-Key": "wrong"}) + assert r.status_code == 401 + + def test_missing_key_returns_401(self, client_empty): + with patch("backend.main.API_KEY", "secret"): + r = client_empty.get("/health") + assert r.status_code == 401 + + def test_auth_applies_to_chat(self, client_with_docs): + with patch("backend.main.API_KEY", "secret"): + r = client_with_docs.post("/chat", json={"question": "Hi"}) + assert r.status_code == 401 + + def test_auth_applies_to_upload(self, client_empty): + with patch("backend.main.API_KEY", "secret"): + r = client_empty.post( + "/upload", + files={"file": ("doc.md", b"# Test", "text/markdown")}, + ) + assert r.status_code == 401 + + def test_auth_applies_to_documents(self, client_empty): + with patch("backend.main.API_KEY", "secret"): + r = client_empty.get("/documents") + assert r.status_code == 401 + # --------------------------------------------------------------------------- # GET /health