mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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:
co-authored by
Zohaib Hassnain
parent
64f6c5cba2
commit
3496d62335
@@ -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=<falkordb-app-name>.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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-1
@@ -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 = [
|
||||
|
||||
@@ -83,11 +83,21 @@ def main(argv=None):
|
||||
|
||||
_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
|
||||
if args.host not in _LOOPBACK_HOSTS:
|
||||
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] 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."
|
||||
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:
|
||||
|
||||
+39
-13
@@ -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})
|
||||
|
||||
@@ -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``."""
|
||||
|
||||
+34
-15
@@ -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
|
||||
@@ -39,6 +40,18 @@ async def lifespan(app: FastAPI):
|
||||
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()
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user