Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/boring_semantic_layer/agents/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,8 +432,8 @@ def main():
)
serve_parser.add_argument(
"--host",
default="0.0.0.0",
help="Host to bind to (default: 0.0.0.0)",
default="127.0.0.1",
help="Host to bind to (default: 127.0.0.1)",
)
serve_parser.add_argument(
"--port",
Expand All @@ -448,7 +448,7 @@ def main():
)
serve_parser.add_argument(
"--cors-origins",
help="Comma-separated CORS allowlist. Defaults to BSL_CORS_ORIGINS or '*' if unset.",
help="Comma-separated CORS allowlist. Defaults to BSL_CORS_ORIGINS or no origins.",
)
serve_parser.set_defaults(func=cmd_serve)

Expand Down
4 changes: 2 additions & 2 deletions src/boring_semantic_layer/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
from __future__ import annotations

import os
from collections.abc import Sequence
from pathlib import Path
from typing import Sequence

from .api import create_app

Expand All @@ -13,7 +13,7 @@

def main(
config: str | None = None,
host: str = "0.0.0.0",
host: str = "127.0.0.1",
port: int = 8000,
reload: bool = False,
cors_origins: Sequence[str] | None = None,
Expand Down
95 changes: 82 additions & 13 deletions src/boring_semantic_layer/server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

from __future__ import annotations

import inspect
import json
import logging
import os
import re
from collections.abc import Mapping, Sequence
import secrets
from collections.abc import Awaitable, Callable, Mapping, Sequence
from contextlib import asynccontextmanager
from typing import Any

Expand All @@ -22,6 +24,8 @@

logger = logging.getLogger(__name__)

AuthHook = Callable[[Request], bool | None | Awaitable[bool | None]]


class QueryRequest(BaseModel):
"""HTTP request body for the query endpoint."""
Expand Down Expand Up @@ -88,9 +92,21 @@ def _check_grain_fields(self) -> ComparePeriodsRequest:
def _default_cors_origins() -> list[str]:
raw = os.environ.get("BSL_CORS_ORIGINS")
if not raw:
return ["*"]
return []
origins = [origin.strip() for origin in raw.split(",") if origin.strip()]
return origins or ["*"]
return origins


def _api_key_auth_hook(api_key: str) -> AuthHook:
"""Build a constant-time API-key hook for simple deployments."""

def authenticate(request: Request) -> bool:
authorization = request.headers.get("authorization", "")
bearer = authorization[7:] if authorization.lower().startswith("bearer ") else ""
supplied = request.headers.get("x-bsl-api-key") or bearer
return bool(supplied) and secrets.compare_digest(supplied, api_key)

return authenticate


def _get_models(request: Request) -> Mapping[str, Any]:
Expand Down Expand Up @@ -129,18 +145,25 @@ def _build_model_response(model: Any) -> dict[str, Any]:


def _get_time_range_response(model: Any, model_name: str) -> dict[str, str]:
time_dim_name = _find_time_dimension(model, list(model.dimensions))
dimensions = model.get_dimensions()
time_dim_name = _find_time_dimension(model, list(dimensions))
if not time_dim_name:
raise HTTPException(status_code=400, detail=f"Model '{model_name}' has no time dimension")

tbl = model.table
col_name = time_dim_name.split(".")[-1] if "." in time_dim_name else time_dim_name
time_col = tbl[col_name]
time_col = dimensions[time_dim_name](tbl)
result = tbl.aggregate(start=time_col.min(), end=time_col.max()).execute()

def to_iso(value: Any) -> str:
# Some backends materialize an Ibis date aggregate as a midnight
# pandas Timestamp. Preserve the semantic datatype in the HTTP value.
if time_col.type().is_date() and callable(as_date := getattr(value, "date", None)):
value = as_date()
return value.isoformat()

return {
"start": result["start"].iloc[0].isoformat(),
"end": result["end"].iloc[0].isoformat(),
"start": to_iso(result["start"].iloc[0]),
"end": to_iso(result["end"].iloc[0]),
}


Expand Down Expand Up @@ -227,20 +250,66 @@ def create_app(
models: Mapping[str, Any] | None = None,
config_path: str | None = None,
cors_origins: Sequence[str] | None = None,
auth_hook: AuthHook | None = None,
api_key: str | None = None,
) -> FastAPI:
"""Create the FastAPI app for the BSL HTTP server."""
"""Create the FastAPI app for the BSL HTTP server.

``auth_hook`` is called before every endpoint except health checks and CORS
preflight requests. It may raise ``HTTPException`` or return ``False`` to
reject a request. For simple deployments, ``api_key`` (or ``BSL_API_KEY``)
enables Bearer and ``X-BSL-API-Key`` authentication.
"""

if auth_hook is not None and api_key:
raise ValueError("Specify either auth_hook or api_key, not both")
configured_api_key = api_key or (os.environ.get("BSL_API_KEY") if auth_hook is None else None)
effective_auth_hook = auth_hook or (
_api_key_auth_hook(configured_api_key) if configured_api_key else None
)

@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.models = models if models is not None else load_models(config_path)
yield

app = FastAPI(title="Boring Semantic Layer HTTP API", version="0.1.0", lifespan=lifespan)

@app.middleware("http")
async def authenticate_request(request: Request, call_next):
if (
effective_auth_hook is None
or request.url.path == "/health"
or request.method == "OPTIONS"
):
return await call_next(request)
try:
authorized = effective_auth_hook(request)
if inspect.isawaitable(authorized):
authorized = await authorized
except HTTPException as exc:
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers=exc.headers,
)
except Exception:
logger.exception("HTTP authentication hook failed")
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
if authorized is False:
return JSONResponse(
status_code=401,
content={"detail": "Unauthorized"},
headers={"WWW-Authenticate": "Bearer"},
)
return await call_next(request)

allowed_origins = list(_default_cors_origins() if cors_origins is None else cors_origins)
app.add_middleware(
CORSMiddleware,
allow_origins=list(cors_origins or _default_cors_origins()),
allow_methods=["*"],
allow_headers=["*"],
allow_origins=allowed_origins,
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "Content-Type", "X-BSL-API-Key"],
)

@app.exception_handler(ValueError)
Expand All @@ -250,7 +319,7 @@ async def value_error_handler(_request: Request, exc: ValueError) -> JSONRespons
@app.exception_handler(Exception)
async def unhandled_exception_handler(_request: Request, exc: Exception) -> JSONResponse:
logger.exception("Unhandled HTTP API error")
return JSONResponse(status_code=500, content={"detail": str(exc)})
return JSONResponse(status_code=500, content={"detail": "Internal server error"})

@app.get("/health")
def health() -> dict[str, str]:
Expand Down
87 changes: 87 additions & 0 deletions src/boring_semantic_layer/tests/test_server_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

from __future__ import annotations

import inspect

import ibis
import pandas as pd
import pytest
from fastapi.testclient import TestClient

from boring_semantic_layer import to_semantic_table
from boring_semantic_layer.server import create_app
from boring_semantic_layer.server import main as serve
from boring_semantic_layer.server.loader import load_models


Expand Down Expand Up @@ -127,6 +130,10 @@ def test_health(client):
assert response.json() == {"status": "ok"}


def test_server_defaults_to_loopback():
assert inspect.signature(serve).parameters["host"].default == "127.0.0.1"


def test_list_models(client):
response = client.get("/models")

Expand Down Expand Up @@ -157,6 +164,34 @@ def test_get_time_range(client):
assert data["end"].startswith("2024-01-30")


def test_get_time_range_evaluates_derived_dimension():
con = ibis.duckdb.connect(":memory:")
tbl = con.create_table(
"derived_time_http",
pd.DataFrame(
{
"ts": pd.to_datetime(
["2024-03-01 12:30:00", "2024-03-04 08:15:00", "2024-03-09 18:45:00"]
)
}
),
overwrite=True,
)
model = to_semantic_table(tbl, name="events").with_dimensions(
event_date={
"expr": lambda t: t.ts.date(),
"is_time_dimension": True,
"smallest_time_grain": "day",
}
)

with TestClient(create_app(models={"events": model})) as derived_client:
response = derived_client.get("/models/events/time-range")

assert response.status_code == 200
assert response.json() == {"start": "2024-03-01", "end": "2024-03-09"}


def test_search_dimension_values(client):
response = client.get("/models/flights/dimensions/carrier/values", params={"limit": 2})

Expand Down Expand Up @@ -297,3 +332,55 @@ def test_missing_model_returns_404(client):

assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()


def test_auth_hook_protects_data_endpoints_but_not_health(sample_models):
app = create_app(
models=sample_models,
auth_hook=lambda request: request.headers.get("x-test-token") == "secret",
)
with TestClient(app) as auth_client:
assert auth_client.get("/health").status_code == 200
assert auth_client.get("/models").status_code == 401
authorized = auth_client.get("/models", headers={"x-test-token": "secret"})

assert authorized.status_code == 200


def test_api_key_authentication(sample_models):
with TestClient(create_app(models=sample_models, api_key="secret")) as auth_client:
assert auth_client.get("/models").status_code == 401
bearer = auth_client.get("/models", headers={"Authorization": "Bearer secret"})
custom_header = auth_client.get("/models", headers={"X-BSL-API-Key": "secret"})

assert bearer.status_code == 200
assert custom_header.status_code == 200


def test_default_cors_does_not_allow_arbitrary_origins(sample_models):
with TestClient(create_app(models=sample_models)) as cors_client:
response = cors_client.options(
"/models",
headers={
"Origin": "https://untrusted.example",
"Access-Control-Request-Method": "GET",
},
)

assert "access-control-allow-origin" not in response.headers


def test_unhandled_errors_do_not_leak_exception_text():
class BrokenModel:
def query(self, **_kwargs):
raise RuntimeError("database password is hunter2")

app = create_app(models={"broken": BrokenModel()})
with TestClient(app, raise_server_exceptions=False) as error_client:
response = error_client.post(
"/query",
json={"model_name": "broken", "measures": ["count"], "get_chart": False},
)

assert response.status_code == 500
assert response.json() == {"detail": "Internal server error"}
67 changes: 67 additions & 0 deletions src/boring_semantic_layer/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import ibis
from ibis import _
from returns.result import Failure, Success

Expand Down Expand Up @@ -44,6 +45,72 @@ def test_safe_eval_disallowed_names():
assert isinstance(result, Failure)


def test_safe_eval_blocks_dunder_attribute_escape():
result = safe_eval("().__class__.__mro__[1].__subclasses__()")
assert isinstance(result, Failure)


def test_safe_eval_blocks_dunder_escape_even_with_allowed_names():
result = safe_eval(
"().__class__.__mro__[1].__subclasses__()",
allowed_names={"_"},
)
assert isinstance(result, Failure)


def test_safe_eval_blocks_dunder_inside_lambda_body():
result = safe_eval("lambda t: t.__class__")
assert isinstance(result, Failure)


def test_safe_eval_blocks_private_names_from_context():
result = safe_eval("__import__('os')", context={"__import__": __import__})
assert isinstance(result, Failure)


def test_safe_eval_blocks_effectful_method_calls():
class MockModel:
def execute(self):
raise AssertionError("execute should not be called")

result = safe_eval("model.execute()", context={"model": MockModel()})
assert isinstance(result, Failure)


def test_safe_eval_blocks_arbitrary_model_attribute_chains():
class MockModel:
table = object()

result = safe_eval("model.table", context={"model": MockModel()})
assert isinstance(result, Failure)


def test_safe_eval_allows_query_metadata_attributes():
class MockModel:
dimensions = ("origin", "destination")
measures = ("flight_count",)

dimensions = safe_eval("model.dimensions", context={"model": MockModel()})
measures = safe_eval("model.measures", context={"model": MockModel()})

assert dimensions.unwrap() == ("origin", "destination")
assert measures.unwrap() == ("flight_count",)


def test_safe_eval_allows_curated_ibis_helpers():
result = safe_eval("ibis.literal(1)", context={"ibis": ibis}, allowed_names={"ibis"})
assert isinstance(result, Success)


def test_safe_eval_blocks_ibis_io_helpers():
result = safe_eval(
"ibis.duckdb.connect(':memory:')",
context={"ibis": ibis},
allowed_names={"ibis"},
)
assert isinstance(result, Failure)


def test_safe_eval_ibis_column_access():
result = safe_eval("_.column_name", context={"_": _}, allowed_names={"_"})
assert isinstance(result, Success)
Expand Down
Loading
Loading