Skip to content
Draft
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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,20 @@ HINDSIGHT_API_LOG_LEVEL=info
# When set, visitors see a login page and must enter the key before
# accessing the dashboard or any /api/* routes (except /api/health).
# HINDSIGHT_CP_ACCESS_KEY=your-shared-secret-key

# Optional: Token the CP forwards to the dataplane admin API (/admin/*).
# Must match HINDSIGHT_API_ADMIN_TOKEN below. Leave unset for an open admin API.
# HINDSIGHT_CP_ADMIN_TOKEN=your-admin-token

# -----------------------------------------------------------------------------
# Admin surface (Optional, server-level)
# -----------------------------------------------------------------------------

# Enable the admin API (GET /admin/config) and the Control Plane /admin page.
# Off by default — the admin surface is invisible (404) until enabled.
# HINDSIGHT_API_ENABLE_ADMIN_API=true

# Optional: Require this bearer token for the admin API. When unset, the admin
# API is open (once enabled). When set, callers must send
# `Authorization: Bearer <token>`. Independent of the tenant API key.
# HINDSIGHT_API_ADMIN_TOKEN=your-admin-token
85 changes: 84 additions & 1 deletion hindsight-api-slim/hindsight_api/api/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
"""

import asyncio
import dataclasses
import hmac
import json
import logging
import re
Expand Down Expand Up @@ -79,7 +81,7 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
return Field(default_factory=default_factory, json_schema_extra=json_extra, **kwargs)


from hindsight_api.config import get_config
from hindsight_api.config import _get_raw_config, get_config
from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.providers.none_llm import LLMNotAvailableError
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
Expand Down Expand Up @@ -1206,6 +1208,33 @@ class BankConfigResponse(BaseModel):
overrides: dict[str, Any] = Field(description="Bank-specific configuration overrides only (Python field names)")


class AdminConfigResponse(BaseModel):
"""Response model for the server-level (admin) configuration view.

Returns the resolved ``HindsightConfig`` as a flat dict keyed by Python field
name. Credential fields (API keys, tokens, service-account keys, base URLs) are
masked: present as ``"***"`` when set and ``None`` when unset, so an operator can
see which credentials are configured without ever seeing their values.
"""

config: dict[str, Any] = Field(
description="Resolved server-level configuration (Python field names); credentials are redacted"
)


# Name suffixes that mark a config field as secret-bearing. Used in addition to
# HindsightConfig._CREDENTIAL_FIELDS so the admin config view never leaks a provider
# credential even if the (denylist) credential set misses a field. Suffixes are
# singular on purpose — "_token" must not also match value-bearing "_tokens" fields
# like recall_max_tokens.
_SENSITIVE_FIELD_SUFFIXES = ("_api_key", "_token", "_secret", "_access_key", "_account_key", "_password")


def _is_sensitive_config_field(field_name: str, credential_fields: set[str]) -> bool:
"""Whether a config field must be redacted in the admin view."""
return field_name in credential_fields or field_name.endswith(_SENSITIVE_FIELD_SUFFIXES)


class GraphDataResponse(BaseModel):
"""Response model for graph data endpoint."""

Expand Down Expand Up @@ -2413,6 +2442,7 @@ class FeaturesInfo(BaseModel):
mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
worker: bool = Field(description="Whether the background worker is enabled")
bank_config_api: bool = Field(description="Whether per-bank configuration API is enabled")
admin_api: bool = Field(description="Whether the admin API (/admin) is enabled")
file_upload_api: bool = Field(description="Whether file upload/conversion API is enabled")
document_export_api: bool = Field(description="Whether the document export endpoint is enabled")
document_import_api: bool = Field(description="Whether the document import endpoint is enabled")
Expand Down Expand Up @@ -2971,6 +3001,31 @@ def get_request_context(authorization: str | None = Header(default=None)) -> Req
api_key = authorization.strip()
return RequestContext(api_key=api_key)

def require_admin(authorization: str | None = Header(default=None)) -> None:
"""Guard for the admin surface.

- 404 when the admin API is disabled (so the surface is invisible by default).
- When ``HINDSIGHT_API_ADMIN_TOKEN`` is set, require it as a bearer token
(or a bare token) and reject with 401 otherwise. When unset, the admin API
is open (auth is optional) — consistent with the rest of the deployment.
"""
config = _get_raw_config()
if not config.enable_admin_api:
raise HTTPException(
status_code=404,
detail="Admin API is disabled. Set HINDSIGHT_API_ENABLE_ADMIN_API=true to enable.",
)
expected = config.admin_api_token
if expected:
token = None
if authorization:
if authorization.lower().startswith("bearer "):
token = authorization[7:].strip()
else:
token = authorization.strip()
if not token or not hmac.compare_digest(token, expected):
raise HTTPException(status_code=401, detail="Invalid or missing admin token")

def precheck_for(operation: str):
"""
Build a FastAPI dependency that runs ``OperationValidator.precheck``.
Expand Down Expand Up @@ -3080,6 +3135,7 @@ async def version_endpoint() -> VersionResponse:
mcp=config.mcp_enabled,
worker=config.worker_enabled,
bank_config_api=config.enable_bank_config_api,
admin_api=config.enable_admin_api,
file_upload_api=config.enable_file_upload_api,
document_export_api=config.enable_document_export_api,
document_import_api=config.enable_document_import_api,
Expand All @@ -3088,6 +3144,33 @@ async def version_endpoint() -> VersionResponse:
),
)

@app.get(
"/admin/config",
response_model=AdminConfigResponse,
summary="Get resolved server-level configuration",
description="Returns the resolved server-level configuration with credentials redacted. "
"Gated by HINDSIGHT_API_ENABLE_ADMIN_API and, when set, HINDSIGHT_API_ADMIN_TOKEN.",
tags=["Admin"],
operation_id="get_admin_config",
dependencies=[Depends(require_admin)],
)
async def admin_config_endpoint() -> AdminConfigResponse:
"""Expose the resolved ``HindsightConfig`` for operator inspection.

Sensitive fields (the credential denylist plus any field whose name ends in a
secret-bearing suffix — see ``_is_sensitive_config_field``) are masked so values
never leave the server: ``"***"`` when set, ``None`` when unset. All other fields
are returned as-is.
"""
config = _get_raw_config()
credential_fields = type(config).get_credential_fields()
raw = dataclasses.asdict(config)
redacted = {
key: ("***" if value is not None else None) if _is_sensitive_config_field(key, credential_fields) else value
for key, value in raw.items()
}
return AdminConfigResponse(config=redacted)

@app.get(
"/metrics",
summary="Prometheus metrics endpoint",
Expand Down
12 changes: 12 additions & 0 deletions hindsight-api-slim/hindsight_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,8 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:
ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS"
ENV_MCP_STATELESS = "HINDSIGHT_API_MCP_STATELESS"
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
ENV_ENABLE_ADMIN_API = "HINDSIGHT_API_ENABLE_ADMIN_API"
ENV_ADMIN_API_TOKEN = "HINDSIGHT_API_ADMIN_TOKEN"
ENV_DEFAULT_BANK_TEMPLATE = "HINDSIGHT_API_DEFAULT_BANK_TEMPLATE"
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
Expand Down Expand Up @@ -792,6 +794,8 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]:
DEFAULT_MCP_ENABLED_TOOLS: list[str] | None = None # None = all tools enabled
DEFAULT_MCP_STATELESS = False # False = stateful (supports SSE/GET); True = stateless (POST-only)
DEFAULT_ENABLE_BANK_CONFIG_API = True
DEFAULT_ENABLE_ADMIN_API = False # Admin surface (server config view) is off unless explicitly enabled
DEFAULT_ADMIN_API_TOKEN: str | None = None # None = admin API open (when enabled); set = required bearer token
DEFAULT_DEFAULT_BANK_TEMPLATE: dict | None = None # BankTemplateManifest dict applied to newly-created banks
DEFAULT_GRAPH_RETRIEVER = "link_expansion"
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
Expand Down Expand Up @@ -1387,6 +1391,10 @@ class HindsightConfig:
mcp_enabled_tools: list[str] | None # None = all tools; explicit list = allowlist
mcp_stateless: bool # True = stateless HTTP (POST-only); False = stateful (supports GET/SSE)
enable_bank_config_api: bool
# Admin surface (static, server-level only). enable_admin_api gates the /admin API +
# control-plane page; admin_api_token (when set) is the required bearer token.
enable_admin_api: bool
admin_api_token: str | None
# Default bank template (static, server-level only). When set, the manifest is applied
# to every newly-created bank, overriding the env/config defaults for any fields it sets.
default_bank_template: dict | None
Expand Down Expand Up @@ -1612,6 +1620,8 @@ class HindsightConfig:
# File parser credentials
"file_parser_iris_token",
"file_parser_llama_parse_api_key",
# Admin surface token (never exposed via the admin config view itself)
"admin_api_token",
}

# CONFIGURABLE_FIELDS: Safe behavioral settings that can be customized per-tenant/bank
Expand Down Expand Up @@ -2218,6 +2228,8 @@ def from_env(cls) -> "HindsightConfig":
mcp_stateless=os.getenv(ENV_MCP_STATELESS, str(DEFAULT_MCP_STATELESS)).lower() == "true",
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
== "true",
enable_admin_api=os.getenv(ENV_ENABLE_ADMIN_API, str(DEFAULT_ENABLE_ADMIN_API)).lower() == "true",
admin_api_token=os.getenv(ENV_ADMIN_API_TOKEN) or DEFAULT_ADMIN_API_TOKEN,
default_bank_template=_parse_default_bank_template(os.getenv(ENV_DEFAULT_BANK_TEMPLATE)),
# Recall
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
Expand Down
128 changes: 128 additions & 0 deletions hindsight-api-slim/tests/test_admin_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Tests for the admin surface: GET /admin/config + the admin_api feature flag.

These are deterministic (no LLM): the endpoint only reads server-level config. We
toggle env vars + clear the config cache to exercise the enable flag, the optional
admin token, and credential redaction.
"""

import httpx
import pytest
import pytest_asyncio

from hindsight_api.api import create_app
from hindsight_api.config import clear_config_cache


@pytest_asyncio.fixture
async def admin_client(memory):
"""Async test client for the FastAPI app (mock LLM)."""
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client


def _set_env(monkeypatch, **values: str | None) -> None:
"""Set/unset env vars and reset the cached config so the next read reflects them."""
for key, value in values.items():
if value is None:
monkeypatch.delenv(key, raising=False)
else:
monkeypatch.setenv(key, value)
clear_config_cache()


@pytest.fixture(autouse=True)
def _restore_config_cache():
"""Ensure the global config cache is reset after each test."""
yield
clear_config_cache()


@pytest.mark.asyncio
async def test_admin_config_disabled_by_default(admin_client, monkeypatch):
"""When the admin API is disabled (default), the endpoint is invisible (404)."""
_set_env(monkeypatch, HINDSIGHT_API_ENABLE_ADMIN_API=None, HINDSIGHT_API_ADMIN_TOKEN=None)

response = await admin_client.get("/admin/config")

assert response.status_code == 404


@pytest.mark.asyncio
async def test_admin_config_enabled_no_token(admin_client, monkeypatch):
"""When enabled without a token, the endpoint is open and returns config."""
_set_env(monkeypatch, HINDSIGHT_API_ENABLE_ADMIN_API="true", HINDSIGHT_API_ADMIN_TOKEN=None)

response = await admin_client.get("/admin/config")

assert response.status_code == 200
config = response.json()["config"]
# A representative spread of non-credential fields should be present.
assert "llm_provider" in config
assert "enable_admin_api" in config
assert config["enable_admin_api"] is True


@pytest.mark.asyncio
async def test_admin_config_redacts_credentials(admin_client, monkeypatch):
"""Credential fields are masked, never returned in cleartext."""
_set_env(
monkeypatch,
HINDSIGHT_API_ENABLE_ADMIN_API="true",
HINDSIGHT_API_ADMIN_TOKEN="s3cret-token",
HINDSIGHT_API_LLM_API_KEY="super-secret-key",
)

response = await admin_client.get("/admin/config", headers={"Authorization": "Bearer s3cret-token"})

assert response.status_code == 200
config = response.json()["config"]
# The configured LLM key is present but masked.
assert config["llm_api_key"] == "***"
assert "super-secret-key" not in response.text
# Provider keys that fall back to the LLM key (and aren't in the credential
# denylist) must also be masked — the view redacts by name, not just the set.
assert config["embeddings_openrouter_api_key"] == "***"
assert config["reranker_openrouter_api_key"] == "***"
# The admin token must never leak through its own config view.
assert config["admin_api_token"] == "***"
assert "s3cret-token" not in response.text
# Value-bearing fields that merely contain "token" in their name (plural) are
# NOT redacted — they carry useful config, not secrets.
assert config["recall_max_tokens"] != "***"


@pytest.mark.asyncio
async def test_admin_config_requires_token_when_set(admin_client, monkeypatch):
"""With a token configured, missing/wrong tokens are rejected; the right one passes."""
_set_env(
monkeypatch,
HINDSIGHT_API_ENABLE_ADMIN_API="true",
HINDSIGHT_API_ADMIN_TOKEN="right-token",
)

missing = await admin_client.get("/admin/config")
assert missing.status_code == 401

wrong = await admin_client.get("/admin/config", headers={"Authorization": "Bearer wrong-token"})
assert wrong.status_code == 401

bearer = await admin_client.get("/admin/config", headers={"Authorization": "Bearer right-token"})
assert bearer.status_code == 200

# A bare token (no "Bearer " prefix) is also accepted.
bare = await admin_client.get("/admin/config", headers={"Authorization": "right-token"})
assert bare.status_code == 200


@pytest.mark.asyncio
async def test_version_reports_admin_api_flag(admin_client, monkeypatch):
"""The /version feature flags track the admin enable flag."""
_set_env(monkeypatch, HINDSIGHT_API_ENABLE_ADMIN_API="true")
enabled = await admin_client.get("/version")
assert enabled.json()["features"]["admin_api"] is True

_set_env(monkeypatch, HINDSIGHT_API_ENABLE_ADMIN_API="false")
disabled = await admin_client.get("/version")
assert disabled.json()["features"]["admin_api"] is False
Loading