From 3496d62335ae70210ab5b817ccca8d7d83f274eb Mon Sep 17 00:00:00 2001 From: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:35:00 +0530 Subject: [PATCH] security: require API-key auth on all Explorer API routes (GHSA-j4mq) (#909) * security: require API-key auth on all Explorer API routes (GHSA-j4mq-hprp-987v) Every Explorer route (bulk import/export, delete, LLM-backed ontology generation, SPARQL, etc.) was mounted with no authentication, and both server entrypoints bind 0.0.0.0 by default. Anyone reaching the port got full read/write/delete on the graph. - Add require_auth dependency (explorer/dependencies.py): checks X-API-Key against SEMANTICA_API_KEY, fails closed with 503 if unconfigured (not silently anonymous), 401 on wrong/missing key. SEMANTICA_ALLOW_ANONYMOUS=true opts out explicitly for local dev. - Wire dependencies=[Depends(require_auth)] into all 11 API routers in both explorer/app.py and server.py. /health, /api/info, static assets, and the SPA catch-all stay public. - /ws/graph-updates handshake now checks the same key via header or ?api_key= query param (browsers can't set custom WS headers) before accepting the connection. - Default bind changed from 0.0.0.0 to 127.0.0.1 in server.py's main() and cli.py's `server start`; the CLI warns if a non-loopback host is passed explicitly without a key configured. - Startup logging reports the resolved auth mode in both app factories. - Document/generate SEMANTICA_API_KEY in the deploy recipes that expose a public endpoint by default: docker-compose, Railway, Fly, Render. Added tests/explorer/test_explorer_auth.py covering fail-closed default, wrong/missing/correct key, anonymous opt-in, public-route exemptions, and the WS handshake. Added tests/explorer/conftest.py defaulting the pre-existing ~200 explorer tests to SEMANTICA_ALLOW_ANONYMOUS=true so they keep exercising route logic without needing a key. * fix CORS --------- Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> --- deploy/fly/README.md | 3 + deploy/railway/README.md | 3 + deploy/render/README.md | 2 + deploy/render/render.yaml | 2 + docker-compose.dev.yml | 2 + docker-compose.yml | 5 + semantica/cli.py | 12 ++- semantica/explorer/__init__.py | 22 ++-- semantica/explorer/app.py | 52 ++++++--- semantica/explorer/dependencies.py | 65 +++++++++++- semantica/server.py | 51 ++++++--- tests/explorer/conftest.py | 17 +++ tests/explorer/test_explorer_auth.py | 152 +++++++++++++++++++++++++++ 13 files changed, 350 insertions(+), 38 deletions(-) create mode 100644 tests/explorer/conftest.py create mode 100644 tests/explorer/test_explorer_auth.py diff --git a/deploy/fly/README.md b/deploy/fly/README.md index 5c5d1258..ba0dc2b6 100644 --- a/deploy/fly/README.md +++ b/deploy/fly/README.md @@ -9,7 +9,10 @@ flyctl launch --copy-config --config deploy/fly/fly.toml --no-deploy # Fly.io private networking uses .internal hostnames — do not use localhost # unless FalkorDB is a co-located process inside the same Machine. flyctl secrets set FALKORDB_HOST=.internal FALKORDB_PORT=6379 +flyctl secrets set SEMANTICA_API_KEY=$(openssl rand -hex 32) flyctl deploy --config deploy/fly/fly.toml ``` Change `app` in `fly.toml` before launch if the default app name is already taken. + +Fly apps get a public `*.fly.dev` URL by default, so `SEMANTICA_API_KEY` is required — without it the Explorer refuses every protected route (503) rather than serving anonymously. Pass the same value as the `X-API-Key` header from any client that talks to the deployed API. diff --git a/deploy/railway/README.md b/deploy/railway/README.md index a42163ca..b755a032 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -9,7 +9,10 @@ railway add --database redis railway variable --set "FALKORDB_HOST=${{Redis.REDISHOST}}" railway variable --set "FALKORDB_PORT=${{Redis.REDISPORT}}" railway variable --set "ALLOWED_ORIGINS=https://${{RAILWAY_PUBLIC_DOMAIN}}" +railway variable --set "SEMANTICA_API_KEY=$(openssl rand -hex 32)" railway up ``` The Redis plugin variables are wired to the requested FalkorDB env names for deployment compatibility. The Explorer currently reads these settings but does not persist graph state to FalkorDB. + +Railway exposes this service on a public domain, so `SEMANTICA_API_KEY` is required — without it the Explorer refuses every protected route (503) rather than serving anonymously. Pass the same value as the `X-API-Key` header from any client that talks to the deployed API. diff --git a/deploy/render/README.md b/deploy/render/README.md index e252527a..d4652ac5 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -9,3 +9,5 @@ render blueprint apply deploy/render/render.yaml ``` After creation, update `ALLOWED_ORIGINS` in the Render dashboard if you attach a custom domain. + +`SEMANTICA_API_KEY` is auto-generated by the blueprint (`generateValue: true`) since this service gets a public `onrender.com` URL — without it the Explorer refuses every protected route (503) rather than serving anonymously. Find the generated value in the Render dashboard's environment tab and pass it as the `X-API-Key` header from any client that talks to the deployed API. diff --git a/deploy/render/render.yaml b/deploy/render/render.yaml index d0776749..5f385d01 100644 --- a/deploy/render/render.yaml +++ b/deploy/render/render.yaml @@ -20,6 +20,8 @@ services: type: keyvalue name: semantica-explorer-redis property: port + - key: SEMANTICA_API_KEY + generateValue: true - type: keyvalue name: semantica-explorer-redis diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5ff13b8d..495f8ef7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -16,6 +16,8 @@ services: ALLOWED_ORIGINS: http://localhost:5173,http://127.0.0.1:5173,http://localhost:8000,http://127.0.0.1:8000 FALKORDB_HOST: falkordb FALKORDB_PORT: "6379" + # Local dev only: this compose file is not for public exposure. + SEMANTICA_ALLOW_ANONYMOUS: "true" volumes: - ./semantica:/app/semantica - ./pyproject.toml:/app/pyproject.toml:ro diff --git a/docker-compose.yml b/docker-compose.yml index 746b6bf3..43336d3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,11 @@ services: FALKORDB_HOST: falkordb FALKORDB_PORT: "6379" ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:8000,http://127.0.0.1:8000} + # Required for API access - the Explorer refuses all protected routes + # (503) until this is set. Generate one with `openssl rand -hex 32`. + SEMANTICA_API_KEY: ${SEMANTICA_API_KEY:-} + # Trusted local-only setups only: bypasses the API key entirely. + SEMANTICA_ALLOW_ANONYMOUS: ${SEMANTICA_ALLOW_ANONYMOUS:-false} depends_on: falkordb: condition: service_started diff --git a/semantica/cli.py b/semantica/cli.py index 5b179c13..7b944dc6 100644 --- a/semantica/cli.py +++ b/semantica/cli.py @@ -4065,7 +4065,7 @@ def server(ctx: click.Context) -> None: @click.option("--port", default=8000, type=int, show_default=True) @click.option("--workers", default=1, type=int, show_default=True) @click.option("--reload", is_flag=True, default=False, help="Enable hot reload.") -@click.option("--host", default="0.0.0.0", show_default=True) +@click.option("--host", default="127.0.0.1", show_default=True) @click.pass_obj def server_start(cli_ctx: CLIContext, port: int, workers: int, reload: bool, host: str) -> None: """Start the REST API server. @@ -4076,6 +4076,16 @@ def server_start(cli_ctx: CLIContext, port: int, workers: int, reload: bool, hos """ cli_ctx = _require_ctx(cli_ctx) + _LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"} + if host not in _LOOPBACK_HOSTS: + console.print( + f"[{_WARN_STY}] ⚠[/{_WARN_STY}] Binding to [cyan]{host}[/cyan] exposes " + "the server to the network. Set SEMANTICA_API_KEY before doing this " + "in any reachable environment — without it, protected routes refuse " + "all requests (503), and with SEMANTICA_ALLOW_ANONYMOUS=true they are " + "wide open." + ) + def _action() -> None: import subprocess as sp cmd = [ diff --git a/semantica/explorer/__init__.py b/semantica/explorer/__init__.py index 0445eb5f..f9a780fe 100644 --- a/semantica/explorer/__init__.py +++ b/semantica/explorer/__init__.py @@ -83,12 +83,22 @@ def main(argv=None): _LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"} if args.host not in _LOOPBACK_HOSTS: - _err.print( - f"[bold yellow]Warning:[/bold yellow] Binding to " - f"[cyan]{args.host}[/cyan] exposes the Explorer to the network. " - "The API has no authentication — all graph data will be readable " - "and writable by any host that can reach this port." - ) + import os as _os + if _os.environ.get("SEMANTICA_ALLOW_ANONYMOUS", "").strip().lower() == "true": + _err.print( + f"[bold yellow]Warning:[/bold yellow] Binding to " + f"[cyan]{args.host}[/cyan] with SEMANTICA_ALLOW_ANONYMOUS=true " + "exposes the Explorer to the network with no authentication — " + "all graph data will be readable and writable by any host that " + "can reach this port." + ) + elif not _os.environ.get("SEMANTICA_API_KEY"): + _err.print( + f"[bold yellow]Warning:[/bold yellow] Binding to " + f"[cyan]{args.host}[/cyan] but SEMANTICA_API_KEY is not set — " + "protected routes will refuse all requests (503) until it is " + "configured." + ) if not args.no_browser: import threading diff --git a/semantica/explorer/app.py b/semantica/explorer/app.py index c214021a..e9f1d075 100644 --- a/semantica/explorer/app.py +++ b/semantica/explorer/app.py @@ -8,13 +8,14 @@ from contextlib import asynccontextmanager from pathlib import Path from typing import Optional -from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect +from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from .. import __version__ from ..context.context_graph import ContextGraph +from .dependencies import anonymous_access_allowed, get_expected_api_key, is_valid_api_key, require_auth from .session import GraphSession from .ws import ConnectionManager @@ -97,6 +98,22 @@ def create_app( @asynccontextmanager async def lifespan(app: FastAPI): + import logging as _lifespan_logging + _lifespan_logger = _lifespan_logging.getLogger(__name__) + if anonymous_access_allowed(): + _lifespan_logger.warning( + "Explorer is running with SEMANTICA_ALLOW_ANONYMOUS=true — " + "all API routes are unauthenticated. Do not expose this " + "process beyond localhost." + ) + elif get_expected_api_key(): + _lifespan_logger.info("Explorer API authentication: enabled (SEMANTICA_API_KEY set).") + else: + _lifespan_logger.warning( + "Explorer API authentication: NOT CONFIGURED. All protected " + "routes will return 503 until SEMANTICA_API_KEY is set." + ) + app.state.event_loop = asyncio.get_running_loop() app.state.ws_manager = ConnectionManager() app.state.session = active_session @@ -123,7 +140,7 @@ def create_app( allow_origins=settings["allowed_origins"], allow_credentials=_allow_credentials, allow_methods=["GET", "POST", "DELETE", "OPTIONS"], - allow_headers=["Content-Type", "Authorization"], + allow_headers=["Content-Type", "Authorization", "X-API-Key"], max_age=600, ) @@ -159,22 +176,31 @@ def create_app( from .routes.temporal import router as temporal_router from .routes.vocabulary import router as vocabulary_router - app.include_router(graph_router) - app.include_router(analytics_router) - app.include_router(decisions_router) - app.include_router(temporal_router) - app.include_router(enrich_router) - app.include_router(export_import_router) - app.include_router(annotations_router) - app.include_router(sparql_router) - app.include_router(provenance_router) - app.include_router(vocabulary_router) - app.include_router(ontology_router) + _auth = [Depends(require_auth)] + app.include_router(graph_router, dependencies=_auth) + app.include_router(analytics_router, dependencies=_auth) + app.include_router(decisions_router, dependencies=_auth) + app.include_router(temporal_router, dependencies=_auth) + app.include_router(enrich_router, dependencies=_auth) + app.include_router(export_import_router, dependencies=_auth) + app.include_router(annotations_router, dependencies=_auth) + app.include_router(sparql_router, dependencies=_auth) + app.include_router(provenance_router, dependencies=_auth) + app.include_router(vocabulary_router, dependencies=_auth) + app.include_router(ontology_router, dependencies=_auth) _WS_MAX_MESSAGE_BYTES = 64 * 1024 # 64 KB — control messages only @app.websocket("/ws/graph-updates") async def websocket_endpoint(websocket: WebSocket): + # Browsers can't set custom headers on a WebSocket handshake, so + # accept the key via header (non-browser clients) or query param + # (browser clients), same SEMANTICA_API_KEY the REST routes check. + candidate = websocket.headers.get("x-api-key") or websocket.query_params.get("api_key") + if not is_valid_api_key(candidate): + await websocket.close(code=4401) # unauthorized + return + manager: ConnectionManager = app.state.ws_manager await manager.connect(websocket) await manager.send_personal(websocket, "connection_ack", {"connected": True}) diff --git a/semantica/explorer/dependencies.py b/semantica/explorer/dependencies.py index 44cbc6d8..7a97faeb 100644 --- a/semantica/explorer/dependencies.py +++ b/semantica/explorer/dependencies.py @@ -2,14 +2,75 @@ Semantica Explorer : FastAPI Dependencies Provides ``Depends()``-compatible callables for injecting the -current ``GraphSession`` and ``ConnectionManager`` into route handlers. +current ``GraphSession`` and ``ConnectionManager`` into route handlers, +and for enforcing API-key authentication on protected routes. """ -from fastapi import Request, HTTPException, status +import hmac +import os +from typing import Optional + +from fastapi import Request, HTTPException, Security, status +from fastapi.security.api_key import APIKeyHeader from .session import GraphSession from .ws import ConnectionManager +_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + + +def get_expected_api_key() -> Optional[str]: + """Read the configured API key from the environment on every call. + + Read fresh (not cached) so tests and ops tooling can rotate the key + without restarting the process. + """ + return os.environ.get("SEMANTICA_API_KEY") or None + + +def anonymous_access_allowed() -> bool: + return os.environ.get("SEMANTICA_ALLOW_ANONYMOUS", "").strip().lower() == "true" + + +def is_valid_api_key(candidate: Optional[str]) -> bool: + """Return True if *candidate* matches the configured key, or if the + server has explicitly opted into anonymous access.""" + if anonymous_access_allowed(): + return True + expected = get_expected_api_key() + if not expected: + return False + return bool(candidate) and hmac.compare_digest(candidate, expected) + + +def require_auth(api_key: Optional[str] = Security(_api_key_header)) -> None: + """Dependency enforcing the ``X-API-Key`` header on protected routes. + + Every Explorer/API router (except health/info/static assets) should be + mounted with ``dependencies=[Depends(require_auth)]``. If + SEMANTICA_API_KEY is unset, requests are refused with 503 rather than + silently served unauthenticated — SEMANTICA_ALLOW_ANONYMOUS=true opts + into that explicitly for local development. + """ + if anonymous_access_allowed(): + return + expected = get_expected_api_key() + if not expected: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=( + "Server is not configured for authentication. Set the " + "SEMANTICA_API_KEY environment variable, or explicitly opt " + "into unauthenticated access (development only) with " + "SEMANTICA_ALLOW_ANONYMOUS=true." + ), + ) + if not api_key or not hmac.compare_digest(api_key, expected): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API key. Send it as the X-API-Key header.", + ) + def get_session(request: Request) -> GraphSession: """Retrieve the GraphSession stored on ``app.state``.""" diff --git a/semantica/server.py b/semantica/server.py index 45ce61e0..5001c09f 100644 --- a/semantica/server.py +++ b/semantica/server.py @@ -10,7 +10,7 @@ import os import uvicorn from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI, HTTPException, Request +from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from starlette.middleware.base import BaseHTTPMiddleware @@ -25,6 +25,7 @@ try: from .context.context_graph import ContextGraph from .explorer.session import GraphSession from .explorer.ws import ConnectionManager + from .explorer.dependencies import anonymous_access_allowed, get_expected_api_key, require_auth EXPLORER_AVAILABLE = True except ImportError: EXPLORER_AVAILABLE = False @@ -37,8 +38,20 @@ STATIC_DIR = Path(__file__).parent / "static" async def lifespan(app: FastAPI): """Lifecycle manager for startup and shutdown events.""" logging.info("Starting up Semantica API...") - + if EXPLORER_AVAILABLE: + if anonymous_access_allowed(): + logging.warning( + "SEMANTICA_ALLOW_ANONYMOUS=true — all Explorer API routes are " + "unauthenticated. Do not expose this process beyond localhost." + ) + elif get_expected_api_key(): + logging.info("Explorer API authentication: enabled (SEMANTICA_API_KEY set).") + else: + logging.warning( + "Explorer API authentication: NOT CONFIGURED. Protected routes " + "will return 503 until SEMANTICA_API_KEY is set." + ) try: logging.info("Initializing Graph engine and Database connection...") graph = ContextGraph() @@ -79,7 +92,7 @@ app.add_middleware( allow_origins=_cors_origins, allow_credentials=True, allow_methods=["GET", "POST", "DELETE", "OPTIONS"], - allow_headers=["Content-Type", "Authorization"], + allow_headers=["Content-Type", "Authorization", "X-API-Key"], max_age=600, ) @@ -159,17 +172,18 @@ if EXPLORER_AVAILABLE: sparql ) - app.include_router(analytics.router) - app.include_router(annotations.router) - app.include_router(decisions.router) - app.include_router(enrich.router) - app.include_router(export_import.router) - app.include_router(graph.router) - app.include_router(ontology.router) - app.include_router(temporal.router) - app.include_router(vocabulary.router) - app.include_router(provenance.router) - app.include_router(sparql.router) + _auth = [Depends(require_auth)] + app.include_router(analytics.router, dependencies=_auth) + app.include_router(annotations.router, dependencies=_auth) + app.include_router(decisions.router, dependencies=_auth) + app.include_router(enrich.router, dependencies=_auth) + app.include_router(export_import.router, dependencies=_auth) + app.include_router(graph.router, dependencies=_auth) + app.include_router(ontology.router, dependencies=_auth) + app.include_router(temporal.router, dependencies=_auth) + app.include_router(vocabulary.router, dependencies=_auth) + app.include_router(provenance.router, dependencies=_auth) + app.include_router(sparql.router, dependencies=_auth) logging.info("Explorer, Vocabulary, SPARQL, Provenance, and Ontology API routes successfully mounted.") except Exception as exc: @@ -237,8 +251,13 @@ async def serve_spa(full_path: str): ) def main(): - """Server entry point.""" - uvicorn.run(app, host="0.0.0.0", port=8000) + """Server entry point. + + Binds to loopback by default; set SEMANTICA_HOST to expose beyond + localhost (e.g. behind a reverse proxy that terminates auth/TLS). + """ + host = os.environ.get("SEMANTICA_HOST", "127.0.0.1") + uvicorn.run(app, host=host, port=8000) if __name__ == "__main__": main() \ No newline at end of file diff --git a/tests/explorer/conftest.py b/tests/explorer/conftest.py new file mode 100644 index 00000000..b943d514 --- /dev/null +++ b/tests/explorer/conftest.py @@ -0,0 +1,17 @@ +"""Shared fixtures for explorer API tests. + +Most of this suite predates the API-key auth layer added for +GHSA-j4mq-hprp-987v and exercises route logic, not authentication. Default +every test under tests/explorer/ to SEMANTICA_ALLOW_ANONYMOUS=true so those +tests keep talking to the Explorer without needing an X-API-Key header. +Auth-specific tests (test_explorer_auth.py) override this per-test via the +same `monkeypatch` fixture. +""" + +import pytest + + +@pytest.fixture(autouse=True) +def _default_to_anonymous_explorer_access(monkeypatch): + monkeypatch.setenv("SEMANTICA_ALLOW_ANONYMOUS", "true") + monkeypatch.delenv("SEMANTICA_API_KEY", raising=False) diff --git a/tests/explorer/test_explorer_auth.py b/tests/explorer/test_explorer_auth.py new file mode 100644 index 00000000..4b7b9c6f --- /dev/null +++ b/tests/explorer/test_explorer_auth.py @@ -0,0 +1,152 @@ +"""Tests for the API-key auth dependency added for GHSA-j4mq-hprp-987v +(missing authentication on all Explorer API routes). + +Covers: protected routes refuse requests when no key is configured (fail +closed, not fail open), reject wrong/missing keys once a key is +configured, accept the correct key, remain reachable when +SEMANTICA_ALLOW_ANONYMOUS=true is set explicitly, and that health/info/ +static routes stay public regardless. Also covers the /ws/graph-updates +handshake, which can't use the same FastAPI Depends() plumbing since +browsers can't set custom headers on a WebSocket handshake. +""" + +import pytest + +from semantica.context.context_graph import ContextGraph +from semantica.explorer.app import create_app +from semantica.explorer.session import GraphSession + +try: + from starlette.testclient import TestClient +except ImportError: + pytest.skip( + "starlette TestClient is required for explorer tests. Install semantica[explorer].", + allow_module_level=True, + ) + + +def _build_sample_graph() -> ContextGraph: + graph = ContextGraph(advanced_analytics=False) + graph.add_node("python", node_type="language", content="Python") + return graph + + +@pytest.fixture +def client(): + session = GraphSession(_build_sample_graph()) + app = create_app(session=session) + with TestClient(app) as test_client: + yield test_client + + +# --------------------------------------------------------------------------- +# Fail-closed: no SEMANTICA_API_KEY and no explicit anonymous opt-in. +# --------------------------------------------------------------------------- + +def test_protected_route_returns_503_when_auth_not_configured(client, monkeypatch): + monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False) + monkeypatch.delenv("SEMANTICA_API_KEY", raising=False) + + resp = client.get("/api/graph/nodes") + + assert resp.status_code == 503 + + +def test_write_route_also_refuses_when_auth_not_configured(client, monkeypatch): + monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False) + monkeypatch.delenv("SEMANTICA_API_KEY", raising=False) + + resp = client.post("/api/export", json={"format": "json"}) + + assert resp.status_code == 503 + + +# --------------------------------------------------------------------------- +# Configured key: wrong/missing key rejected, correct key accepted. +# --------------------------------------------------------------------------- + +def test_protected_route_rejects_missing_key(client, monkeypatch): + monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False) + monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key") + + resp = client.get("/api/graph/nodes") + + assert resp.status_code == 401 + + +def test_protected_route_rejects_wrong_key(client, monkeypatch): + monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False) + monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key") + + resp = client.get("/api/graph/nodes", headers={"X-API-Key": "wrong-key"}) + + assert resp.status_code == 401 + + +def test_protected_route_accepts_correct_key(client, monkeypatch): + monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False) + monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key") + + resp = client.get("/api/graph/nodes", headers={"X-API-Key": "correct-key"}) + + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Explicit opt-in: SEMANTICA_ALLOW_ANONYMOUS=true. +# --------------------------------------------------------------------------- + +def test_anonymous_opt_in_allows_requests_without_a_key(client, monkeypatch): + monkeypatch.setenv("SEMANTICA_ALLOW_ANONYMOUS", "true") + monkeypatch.delenv("SEMANTICA_API_KEY", raising=False) + + resp = client.get("/api/graph/nodes") + + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Public routes stay public regardless of auth configuration. +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("path", ["/api/health", "/api/info"]) +def test_public_routes_stay_public_when_auth_not_configured(client, monkeypatch, path): + monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False) + monkeypatch.delenv("SEMANTICA_API_KEY", raising=False) + + resp = client.get(path) + + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# WebSocket handshake: header or query-param key, same policy as REST. +# --------------------------------------------------------------------------- + +def test_websocket_rejects_connection_without_key_when_configured(client, monkeypatch): + monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False) + monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key") + + with pytest.raises(Exception): + with client.websocket_connect("/ws/graph-updates"): + pass + + +def test_websocket_accepts_connection_with_correct_query_param_key(client, monkeypatch): + monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False) + monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key") + + with client.websocket_connect("/ws/graph-updates?api_key=correct-key") as websocket: + ack = websocket.receive_json() + assert ack["event"] == "connection_ack" + + +def test_websocket_accepts_connection_with_header_key(client, monkeypatch): + monkeypatch.delenv("SEMANTICA_ALLOW_ANONYMOUS", raising=False) + monkeypatch.setenv("SEMANTICA_API_KEY", "correct-key") + + with client.websocket_connect( + "/ws/graph-updates", headers={"X-API-Key": "correct-key"} + ) as websocket: + ack = websocket.receive_json() + assert ack["event"] == "connection_ack"