From 01126a4cabaf36d70d04aef057247ad33d0eb534 Mon Sep 17 00:00:00 2001 From: Vadim Malanov Date: Sat, 11 Jul 2026 17:18:42 +0300 Subject: [PATCH] fix(auth): exempt machine endpoints from global API-key and identity middlewares --- app/api/security.py | 12 ++++++++ tests/test_ingest_auth.py | 64 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/app/api/security.py b/app/api/security.py index 5d6e285..9d2aef3 100644 --- a/app/api/security.py +++ b/app/api/security.py @@ -26,6 +26,14 @@ from starlette.types import ASGIApp EXEMPT_PATHS: tuple[str, ...] = ("/", "/docs", "/redoc", "/openapi.json") EXEMPT_SUFFIXES: tuple[str, ...] = ("/health",) +# Machine (service-to-service) endpoints authenticate through the mandatory +# ``require_service_api_key`` route dependency, which accepts INGEST_API_KEY or +# API_KEY. The global middlewares must not swallow them: the API_KEY middleware +# only knows the global key (the ingest key would be rejected), and the +# trusted-identity middleware expects user headers machine callers never have. +# Exempting them here does NOT open the endpoints — their own dependency stays +# mandatory (see tests/test_ingest_auth.py). +MACHINE_PATH_SUFFIXES: tuple[str, ...] = ("/knowledge-ingest", "/dispatch/inbox") def _extract_token(request: Request) -> str | None: @@ -97,6 +105,8 @@ def install_api_key_auth(app: FastAPI) -> None: return await call_next(request) if any(path.endswith(s) for s in EXEMPT_SUFFIXES): return await call_next(request) + if any(path.endswith(s) for s in MACHINE_PATH_SUFFIXES): + return await call_next(request) if not path.startswith(settings.app_api_prefix): return await call_next(request) @@ -136,6 +146,8 @@ def install_trusted_headers_auth(app: FastAPI) -> None: return await call_next(request) if path in EXEMPT_PATHS or any(path.endswith(s) for s in EXEMPT_SUFFIXES): return await call_next(request) + if any(path.endswith(s) for s in MACHINE_PATH_SUFFIXES): + return await call_next(request) if not path.startswith(fresh_settings.app_api_prefix): return await call_next(request) diff --git a/tests/test_ingest_auth.py b/tests/test_ingest_auth.py index 7a370a1..88c02cb 100644 --- a/tests/test_ingest_auth.py +++ b/tests/test_ingest_auth.py @@ -126,3 +126,67 @@ def test_knowledge_ingest_accepts_with_service_key(ingest_secured_app): assert res.status_code == 200 assert res.json()["status"] == "rejected" assert res.json()["reason_code"] == "security_gate_not_approved" + + +@pytest.fixture +def machine_layers_app(monkeypatch): + """Server posture: global API_KEY + identity enforcement + ingest key. + + Machine endpoints (knowledge-ingest, dispatch inbox) authenticate through + their own mandatory ``require_service_api_key`` dependency, so neither the + global API_KEY middleware (which only knows the global key) nor the + user-identity middleware (machine callers have no X-TeamHub-* headers) may + swallow those requests with 401. + """ + monkeypatch.setenv("INGEST_API_KEY", "svc-key") + monkeypatch.setenv("API_KEY", "glob-key") + monkeypatch.setenv("AUTH_REQUIRE_IDENTITY", "true") + import app.config as cfg + import app.main as main_module + + cfg.get_settings.cache_clear() + importlib.reload(cfg) + importlib.reload(main_module) + yield main_module.app + for _k in ("API_KEY", "INGEST_API_KEY", "AUTH_REQUIRE_IDENTITY"): + os.environ.pop(_k, None) + cfg.get_settings.cache_clear() + importlib.reload(cfg) + importlib.reload(main_module) + + +def test_knowledge_ingest_accepts_ingest_key_despite_global_key_and_identity(machine_layers_app): + from app.config import settings + + client = TestClient(machine_layers_app) + res = client.post( + f"{settings.app_api_prefix}/knowledge-ingest", + headers={"X-API-Key": "svc-key"}, # ingest key, no identity headers + json=_MANIFEST, + ) + assert res.status_code == 200 + assert res.json()["status"] == "rejected" + assert res.json()["reason_code"] == "security_gate_not_approved" + + +def test_machine_endpoints_still_require_their_service_key(machine_layers_app): + from app.config import settings + + client = TestClient(machine_layers_app) + res = client.post( + f"{settings.app_api_prefix}/knowledge-ingest", + json=_MANIFEST, # no key at all + ) + assert res.status_code == 401 + + +def test_user_routes_keep_global_key_and_identity_enforcement(machine_layers_app): + from app.config import settings + + client = TestClient(machine_layers_app) + # A normal (non-machine) API route: the ingest key must NOT unlock it. + res = client.get( + f"{settings.app_api_prefix}/documents", + headers={"X-API-Key": "svc-key"}, + ) + assert res.status_code == 401