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>
This commit is contained in:
Mohd Kaif
2026-08-11 14:05:00 +05:00
committed by GitHub
co-authored by Zohaib Hassnain
parent 64f6c5cba2
commit 3496d62335
13 changed files with 350 additions and 38 deletions
+3
View File
@@ -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 # Fly.io private networking uses .internal hostnames — do not use localhost
# unless FalkorDB is a co-located process inside the same Machine. # unless FalkorDB is a co-located process inside the same Machine.
flyctl secrets set FALKORDB_HOST=<falkordb-app-name>.internal FALKORDB_PORT=6379 flyctl secrets set FALKORDB_HOST=<falkordb-app-name>.internal FALKORDB_PORT=6379
flyctl secrets set SEMANTICA_API_KEY=$(openssl rand -hex 32)
flyctl deploy --config deploy/fly/fly.toml flyctl deploy --config deploy/fly/fly.toml
``` ```
Change `app` in `fly.toml` before launch if the default app name is already taken. 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.
+3
View File
@@ -9,7 +9,10 @@ railway add --database redis
railway variable --set "FALKORDB_HOST=${{Redis.REDISHOST}}" railway variable --set "FALKORDB_HOST=${{Redis.REDISHOST}}"
railway variable --set "FALKORDB_PORT=${{Redis.REDISPORT}}" railway variable --set "FALKORDB_PORT=${{Redis.REDISPORT}}"
railway variable --set "ALLOWED_ORIGINS=https://${{RAILWAY_PUBLIC_DOMAIN}}" railway variable --set "ALLOWED_ORIGINS=https://${{RAILWAY_PUBLIC_DOMAIN}}"
railway variable --set "SEMANTICA_API_KEY=$(openssl rand -hex 32)"
railway up 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. 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.
+2
View File
@@ -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. 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.
+2
View File
@@ -20,6 +20,8 @@ services:
type: keyvalue type: keyvalue
name: semantica-explorer-redis name: semantica-explorer-redis
property: port property: port
- key: SEMANTICA_API_KEY
generateValue: true
- type: keyvalue - type: keyvalue
name: semantica-explorer-redis name: semantica-explorer-redis
+2
View File
@@ -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 ALLOWED_ORIGINS: http://localhost:5173,http://127.0.0.1:5173,http://localhost:8000,http://127.0.0.1:8000
FALKORDB_HOST: falkordb FALKORDB_HOST: falkordb
FALKORDB_PORT: "6379" FALKORDB_PORT: "6379"
# Local dev only: this compose file is not for public exposure.
SEMANTICA_ALLOW_ANONYMOUS: "true"
volumes: volumes:
- ./semantica:/app/semantica - ./semantica:/app/semantica
- ./pyproject.toml:/app/pyproject.toml:ro - ./pyproject.toml:/app/pyproject.toml:ro
+5
View File
@@ -8,6 +8,11 @@ services:
FALKORDB_HOST: falkordb FALKORDB_HOST: falkordb
FALKORDB_PORT: "6379" FALKORDB_PORT: "6379"
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:8000,http://127.0.0.1:8000} 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: depends_on:
falkordb: falkordb:
condition: service_started condition: service_started
+11 -1
View File
@@ -4065,7 +4065,7 @@ def server(ctx: click.Context) -> None:
@click.option("--port", default=8000, type=int, show_default=True) @click.option("--port", default=8000, type=int, show_default=True)
@click.option("--workers", default=1, 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("--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 @click.pass_obj
def server_start(cli_ctx: CLIContext, port: int, workers: int, reload: bool, host: str) -> None: def server_start(cli_ctx: CLIContext, port: int, workers: int, reload: bool, host: str) -> None:
"""Start the REST API server. """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) 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: def _action() -> None:
import subprocess as sp import subprocess as sp
cmd = [ cmd = [
+13 -3
View File
@@ -83,11 +83,21 @@ def main(argv=None):
_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"} _LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
if args.host not in _LOOPBACK_HOSTS: if args.host not in _LOOPBACK_HOSTS:
import os as _os
if _os.environ.get("SEMANTICA_ALLOW_ANONYMOUS", "").strip().lower() == "true":
_err.print( _err.print(
f"[bold yellow]Warning:[/bold yellow] Binding to " f"[bold yellow]Warning:[/bold yellow] Binding to "
f"[cyan]{args.host}[/cyan] exposes the Explorer to the network. " f"[cyan]{args.host}[/cyan] with SEMANTICA_ALLOW_ANONYMOUS=true "
"The API has no authentication — all graph data will be readable " "exposes the Explorer to the network with no authentication — "
"and writable by any host that can reach this port." "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: if not args.no_browser:
+39 -13
View File
@@ -8,13 +8,14 @@ from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Optional 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.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from .. import __version__ from .. import __version__
from ..context.context_graph import ContextGraph 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 .session import GraphSession
from .ws import ConnectionManager from .ws import ConnectionManager
@@ -97,6 +98,22 @@ def create_app(
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): 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.event_loop = asyncio.get_running_loop()
app.state.ws_manager = ConnectionManager() app.state.ws_manager = ConnectionManager()
app.state.session = active_session app.state.session = active_session
@@ -123,7 +140,7 @@ def create_app(
allow_origins=settings["allowed_origins"], allow_origins=settings["allowed_origins"],
allow_credentials=_allow_credentials, allow_credentials=_allow_credentials,
allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"], allow_headers=["Content-Type", "Authorization", "X-API-Key"],
max_age=600, max_age=600,
) )
@@ -159,22 +176,31 @@ def create_app(
from .routes.temporal import router as temporal_router from .routes.temporal import router as temporal_router
from .routes.vocabulary import router as vocabulary_router from .routes.vocabulary import router as vocabulary_router
app.include_router(graph_router) _auth = [Depends(require_auth)]
app.include_router(analytics_router) app.include_router(graph_router, dependencies=_auth)
app.include_router(decisions_router) app.include_router(analytics_router, dependencies=_auth)
app.include_router(temporal_router) app.include_router(decisions_router, dependencies=_auth)
app.include_router(enrich_router) app.include_router(temporal_router, dependencies=_auth)
app.include_router(export_import_router) app.include_router(enrich_router, dependencies=_auth)
app.include_router(annotations_router) app.include_router(export_import_router, dependencies=_auth)
app.include_router(sparql_router) app.include_router(annotations_router, dependencies=_auth)
app.include_router(provenance_router) app.include_router(sparql_router, dependencies=_auth)
app.include_router(vocabulary_router) app.include_router(provenance_router, dependencies=_auth)
app.include_router(ontology_router) 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 _WS_MAX_MESSAGE_BYTES = 64 * 1024 # 64 KB — control messages only
@app.websocket("/ws/graph-updates") @app.websocket("/ws/graph-updates")
async def websocket_endpoint(websocket: WebSocket): 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 manager: ConnectionManager = app.state.ws_manager
await manager.connect(websocket) await manager.connect(websocket)
await manager.send_personal(websocket, "connection_ack", {"connected": True}) await manager.send_personal(websocket, "connection_ack", {"connected": True})
+63 -2
View File
@@ -2,14 +2,75 @@
Semantica Explorer : FastAPI Dependencies Semantica Explorer : FastAPI Dependencies
Provides ``Depends()``-compatible callables for injecting the 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 .session import GraphSession
from .ws import ConnectionManager 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: def get_session(request: Request) -> GraphSession:
"""Retrieve the GraphSession stored on ``app.state``.""" """Retrieve the GraphSession stored on ``app.state``."""
+34 -15
View File
@@ -10,7 +10,7 @@ import os
import uvicorn import uvicorn
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path 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.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
@@ -25,6 +25,7 @@ try:
from .context.context_graph import ContextGraph from .context.context_graph import ContextGraph
from .explorer.session import GraphSession from .explorer.session import GraphSession
from .explorer.ws import ConnectionManager from .explorer.ws import ConnectionManager
from .explorer.dependencies import anonymous_access_allowed, get_expected_api_key, require_auth
EXPLORER_AVAILABLE = True EXPLORER_AVAILABLE = True
except ImportError: except ImportError:
EXPLORER_AVAILABLE = False EXPLORER_AVAILABLE = False
@@ -39,6 +40,18 @@ async def lifespan(app: FastAPI):
logging.info("Starting up Semantica API...") logging.info("Starting up Semantica API...")
if EXPLORER_AVAILABLE: 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: try:
logging.info("Initializing Graph engine and Database connection...") logging.info("Initializing Graph engine and Database connection...")
graph = ContextGraph() graph = ContextGraph()
@@ -79,7 +92,7 @@ app.add_middleware(
allow_origins=_cors_origins, allow_origins=_cors_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"], allow_headers=["Content-Type", "Authorization", "X-API-Key"],
max_age=600, max_age=600,
) )
@@ -159,17 +172,18 @@ if EXPLORER_AVAILABLE:
sparql sparql
) )
app.include_router(analytics.router) _auth = [Depends(require_auth)]
app.include_router(annotations.router) app.include_router(analytics.router, dependencies=_auth)
app.include_router(decisions.router) app.include_router(annotations.router, dependencies=_auth)
app.include_router(enrich.router) app.include_router(decisions.router, dependencies=_auth)
app.include_router(export_import.router) app.include_router(enrich.router, dependencies=_auth)
app.include_router(graph.router) app.include_router(export_import.router, dependencies=_auth)
app.include_router(ontology.router) app.include_router(graph.router, dependencies=_auth)
app.include_router(temporal.router) app.include_router(ontology.router, dependencies=_auth)
app.include_router(vocabulary.router) app.include_router(temporal.router, dependencies=_auth)
app.include_router(provenance.router) app.include_router(vocabulary.router, dependencies=_auth)
app.include_router(sparql.router) 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.") logging.info("Explorer, Vocabulary, SPARQL, Provenance, and Ontology API routes successfully mounted.")
except Exception as exc: except Exception as exc:
@@ -237,8 +251,13 @@ async def serve_spa(full_path: str):
) )
def main(): def main():
"""Server entry point.""" """Server entry point.
uvicorn.run(app, host="0.0.0.0", port=8000)
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__": if __name__ == "__main__":
main() main()
+17
View File
@@ -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)
+152
View File
@@ -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"