Files
Rafael Kallis 4bf49c5ba0 feat(extensions): add StaticKeysTenantExtension — env-configured per-user API keys with per-schema isolation (#3675)
* feat(extensions): add StaticKeysTenantExtension to the extensions registry

Env-configured static API keys with per-user schema isolation, shipped as a
standalone extension package (hindsight_ext_static_keys_tenant) following the
supabase-tenant pattern: pyproject for tests, Dockerfile for image packaging,
registry README entry, and developer docs pointer.

Carries over the reviewed implementation: no third-party deps beyond the
server, constant-time byte key comparison, fail-fast init validation
(schema collisions, >63-char schema names, duplicate keys), and lowercase
user-id normalization matching Postgres identifier folding.

* fix(extensions): never echo API keys in config errors; derive per-key metering ids

Review round 2 (nicoloboschi), must-fix #2 + inline comments:

- The ValueError messages for a malformed HINDSIGHT_API_TENANT_USERS entry
  quoted the raw entry (user_id:api_key pair), so a misconfiguration like
  'rafael:' would print the key of a nearby entry into startup logs — one
  paste into an issue and the key is disclosed. Errors now report the
  entry's index (and the user id once validated), never the key.
- The duplicate-key error named the key itself; it now names the two
  conflicting user ids and the key's sha256-derived key_id.
- _KeyEntry gains a stable, non-secret key_id (sha256 truncated to 16
  hex chars), and RequestContext.api_key_id now carries it instead of a
  duplicate of tenant_id — metering can finally tell which of a user's
  keys authenticated, and errors can name a key without disclosing it.
- Reworded the constant-time comment: the loop stops at the first match,
  so comparisons still depend on the matching key's position; harmless
  (invalid keys traverse the whole list) but the old text overpromised.

* fix(extensions): refuse HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED at startup

Review round 2 (nicoloboschi), should-fix #4. On ApiKeyTenantExtension the
flag downgrades one shared key to none; here it would hand unauthenticated
MCP clients the base schema in a deployment built for per-user isolation.
The extension now raises ValueError at init when the variable is set, and
authenticate_mcp always delegates to authenticate() (no bypass). Documented
in the package README's variable table.

* docs(extensions): document key constraints and pre-provisioning

Review round 2 (nicoloboschi), should-fix #3 + poller nit:

- README states the two key-format constraints (ASCII, no comma) and why:
  the comma is the pair separator, and a non-ASCII key can never
  authenticate because header values arrive latin-1-decoded while env
  values are utf-8-decoded — the bytes never match, so the key would fail
  closed with a permanent silent 401.
- Documents hindsight-admin run-db-migration as the way to pre-provision
  all configured tenant schemas, so the worker's idle-cycle fallback
  probes hit real schemas instead of raising swallowed EXISTS errors.

* fix(extensions): serialize concurrent first-provision per schema

Review round 2 (nicoloboschi), nit. Two concurrent first requests for the
same user both saw the schema missing and both called run_migration
(race inherited from supabase-tenant). A per-schema asyncio.Lock now
serializes first initialization, with a re-check inside the lock so the
loser of the race skips the redundant migration. Concurrent requests use
distinct locks, so unrelated users never wait on each other.

* ci(extensions): run static-keys-tenant tests and build its image

Review round 2 (nicoloboschi), must-fix #1. The registry package had no CI
coverage: its 40+ tests and its Dockerfile were never exercised on any
change. Mirrors the supabase-tenant wiring exactly — a detect-changes
filter and output mapping for hindsight-extensions/static-keys-tenant/**,
a test-extension-static-keys-tenant job (uv sync, pytest, docker build on
the latest-slim base), and the job in the report-pr-status gate.

* fix(extensions): pre-encode configured keys once at init

Follow-up to the constant-time comment (review round 2, inline nit):
_KeyEntry now stores the compare_digest-ready bytes (utf-8/surrogateescape,
the same codec bearer-token bytes are recovered with), so authenticate()
encodes only the incoming key per request instead of re-encoding every
configured key. Loop behavior is unchanged — bytes vs bytes, no fast path.
2026-09-09 10:19:47 +02:00

15 KiB

Extensions

Extensions allow you to customize and extend Hindsight behavior without modifying core code. They enable multi-tenancy, custom authentication, additional HTTP endpoints, and operation hooks.


Available Extensions

TenantExtension

Handles multi-tenancy and API key authentication. Validates incoming requests and determines which PostgreSQL schema to use for database operations, enabling tenant isolation at the database level.

Built-in: ApiKeyTenantExtension

A simple implementation that validates API keys against an environment variable and uses the public schema for all authenticated requests.

HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
HINDSIGHT_API_TENANT_API_KEY=your-secret-key

No longer built in: SupabaseTenantExtension

Validates Supabase JWTs and gives each authenticated user their own PostgreSQL schema. It now lives in the extensions registry, which documents its configuration and ships a Dockerfile that builds an image with it.

:::warning Breaking change in 0.9.3 Up to 0.9.2 this extension was built in, at hindsight_api.extensions.builtin.supabase_tenant. That path no longer exists, so an install still pointing at it fails at startup with ModuleNotFoundError. Add the extension to your image and set HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension. All HINDSIGHT_API_TENANT_* settings and the schema naming are unchanged. :::

External: StaticKeysTenantExtension

A fully self-hosted multi-user mode: users and their API keys are declared in environment variables (no external identity provider, no users table). Each user maps to their own PostgreSQL schema ({prefix}_{user_id}), provisioned lazily on first access, giving database-level memory isolation between users. Multiple API keys may map to the same user and schema.

User IDs are case-insensitive: they are lowercased (and dashes normalized to underscores) before building the schema name, so Rafael, rafael and RAFAEL all resolve to the same tenant schema.

It lives in the extensions registry, which documents its configuration and ships a Dockerfile that builds an image with it.

For other multi-tenant setups with separate schemas per tenant (e.g., custom JWT-based auth), implement a custom TenantExtension.


HttpExtension

Adds custom HTTP endpoints under the /ext/ path prefix. Useful for adding domain-specific APIs that integrate with Hindsight's memory engine.

Provides two router methods:

  • get_router(memory) — returns a FastAPI router mounted at /ext/
  • get_root_router(memory) — returns a FastAPI router mounted at the application root (for well-known endpoints or other paths that must be at specific locations). Returns None by default.

No built-in implementation - implement your own to add custom endpoints.

HINDSIGHT_API_HTTP_EXTENSION=mypackage.ext:MyHttpExtension

OperationValidatorExtension

Hooks into retain/recall/reflect operations for validation and monitoring. Use cases include:

  • Rate limiting and quota enforcement
  • Permission checks and content filtering
  • Audit logging and usage tracking
  • Custom metrics collection

No built-in implementation - implement your own based on your requirements.

HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator

MCPExtension

Registers additional MCP (Model Context Protocol) tools on the Hindsight MCP server. Enables external packages to add custom tools without modifying core code.

No built-in implementation - implement your own to add custom MCP tools.

HINDSIGHT_API_MCP_EXTENSION=mypackage.mcp:MyMCPExtension

Writing Custom Extensions

Extension Basics

Extensions are Python classes loaded via environment variables:

HINDSIGHT_API_<TYPE>_EXTENSION=mypackage.module:MyExtensionClass

Configuration is passed via prefixed environment variables:

HINDSIGHT_API_<TYPE>_SOME_CONFIG=value
# Extension receives: {"some_config": "value"}

All extensions support lifecycle hooks:

  • on_startup() - Called when the application starts
  • on_shutdown() - Called when the application shuts down

Extensions have access to an ExtensionContext that provides:

  • run_migration(schema) - Run database migrations for a schema
  • get_memory_engine() - Get the MemoryEngine interface

Example: Custom TenantExtension with JWT

import jwt
from hindsight_api.extensions import TenantExtension, TenantContext, AuthenticationError

class JwtTenantExtension(TenantExtension):
    def __init__(self, config: dict[str, str]):
        super().__init__(config)
        self.jwt_secret = config.get("jwt_secret")
        if not self.jwt_secret:
            raise ValueError("HINDSIGHT_API_TENANT_JWT_SECRET is required")

    async def authenticate(self, context: RequestContext) -> TenantContext:
        token = context.api_key
        if not token:
            # Optional headers dict is forwarded in HTTP/MCP error responses
            raise AuthenticationError("Bearer token required")

        try:
            payload = jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
            tenant_id = payload.get("tenant_id")
            if not tenant_id:
                raise AuthenticationError("Missing tenant_id in token")
            return TenantContext(schema_name=f"tenant_{tenant_id}")
        except jwt.InvalidTokenError as e:
            raise AuthenticationError(str(e))

AuthenticationError accepts an optional headers dict that is forwarded in both HTTP and MCP error responses. This is useful for returning custom headers like WWW-Authenticate:

raise AuthenticationError(
    "Authorization required",
    headers={"WWW-Authenticate": 'Bearer realm="example"'},
)

Reading additional request headers

RequestContext carries the Authorization header as api_key. To authenticate on a different header — for instance when a gateway terminates auth with one shared identity and forwards the per-caller identity separately — name the headers you want forwarded:

HINDSIGHT_API_EXTENSION_PASSTHROUGH_HEADERS=x-user-assertion

They are available as context.extra_headers, keyed by lower-cased name:

async def authenticate(self, context: RequestContext) -> TenantContext:
    assertion = context.extra_headers.get("x-user-assertion")
    if not assertion:
        raise AuthenticationError("x-user-assertion header required")

    user_id = verify_assertion(assertion)  # your verification
    return TenantContext(schema_name=f"tenant_{user_id}")

This works on both the HTTP and MCP transports, and the same RequestContext is passed to OperationValidatorExtension hooks, so a validator can enforce rules against the identity resolved here.

Only headers you list are forwarded, and only when present on the request. The variable is unset by default, so extensions see no header data unless you opt in.

A header sent more than once is not forwarded at all, and a warning is logged. There is no safe way to choose between the copies — a proxy may append its trusted value either before or after a client-supplied one — so an extension reading it sees nothing and fails the request, rather than silently accepting a value that may be spoofed. Make sure your proxy replaces the identity header it injects instead of appending to it.

:::caution Deferred operations extra_headers describes the request being served. Operations that run later — a queued retain, a scheduled consolidation, a mental-model refresh — are executed by a background worker with no request behind them, so their RequestContext carries no headers. Authorize on the header at request time; do not rely on it inside work that continues after the response. :::

Example: Custom HttpExtension

from fastapi import APIRouter
from hindsight_api.extensions import HttpExtension

class MyHttpExtension(HttpExtension):
    def get_router(self, memory: MemoryEngine) -> APIRouter:
        router = APIRouter()

        @router.get("/hello")
        async def hello():
            return {"message": "Hello from extension!"}

        @router.post("/custom/{bank_id}/action")
        async def custom_action(bank_id: str):
            # Access memory engine for database operations
            pool = await memory._get_pool()
            # ... custom logic
            return {"status": "ok"}

        return router

    def get_root_router(self, memory: MemoryEngine) -> APIRouter | None:
        """Optional: mount routes at the application root (not under /ext/)."""
        router = APIRouter()

        @router.get("/.well-known/my-metadata")
        async def metadata():
            return {"version": "1.0"}

        return router

Routes from get_router are available at /ext/hello, /ext/custom/{bank_id}/action, etc. Routes from get_root_router are mounted at the app root (e.g., /.well-known/my-metadata).

Example: Custom OperationValidatorExtension

from hindsight_api.extensions import (
    OperationValidatorExtension,
    ValidationResult,
    PrecheckContext,
    RetainContext,
    RecallContext,
    ReflectContext,
    RetainResult,
)

class MyValidator(OperationValidatorExtension):
    # Pre-body validation (optional)
    async def precheck(self, ctx: PrecheckContext) -> ValidationResult:
        if ctx.content_length is not None and ctx.content_length > 10_000_000:
            return ValidationResult.reject("Payload is too large")
        return ValidationResult.accept()

    # Pre-operation validation (required)
    async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
        # Implement your validation logic
        return ValidationResult.accept()
        # Or reject: return ValidationResult.reject("Reason")

    async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
        return ValidationResult.accept()

    async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
        return ValidationResult.accept()

    # Post-operation hooks (optional)
    async def on_retain_complete(self, result: RetainResult) -> None:
        # Log usage, update metrics, send notifications, etc.
        pass

precheck runs before the request body is read or deserialized. Its PrecheckContext.content_length is the parsed Content-Length header as an integer, or None when the header is missing or cannot be parsed (for example, chunked transfer encoding). Use it for cheap size-aware quota or cost guards; the full validate_* hooks still run after parsing and should enforce precise per-operation limits.

Deferring an operation

In addition to accept and reject, a validate_* hook can ask the worker to requeue the operation for a future time by raising DeferOperation. Use this for backpressure (rate-limited upstream, quota window not yet open, dependency warming up) — unlike a retry, it does not increment retry_count or write error_message. The worker sets next_retry_at to your exec_date and the task is invisible to claim queries until that time.

from datetime import datetime, timedelta, timezone

from hindsight_api.extensions import (
    DeferOperation,
    OperationValidatorExtension,
    RetainContext,
    ValidationResult,
)


class QuotaAwareValidator(OperationValidatorExtension):
    async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
        if not await self._quota_available(ctx.bank_id):
            raise DeferOperation(
                exec_date=datetime.now(timezone.utc) + timedelta(minutes=5),
                reason="bank quota window exhausted",
            )
        return ValidationResult.accept()

DeferOperation is worker-only: do not raise it from validate_recall or validate_reflect in synchronous HTTP request paths — there is no queue to defer to and it will surface as a 500.

Example: Custom MCPExtension

from mcp.server.fastmcp import FastMCP
from hindsight_api.extensions import MCPExtension
from hindsight_api.engine import MemoryEngine

class MyMCPExtension(MCPExtension):
    async def register_tools(self, mcp: FastMCP, memory: MemoryEngine) -> None:
        @mcp.tool()
        async def custom_search(query: str) -> str:
            """Custom MCP tool for specialized search."""
            # Access memory engine for operations
            pool = await memory._get_pool()
            # ... custom logic
            return f"Results for: {query}"

Deploying Custom Extensions

With Docker

Extensions are not bundled in the image. Build one on top of Hindsight that installs your extension's dependencies and copies it in. Install into the image's virtualenv explicitly — it was created by uv sync and ships no pip of its own, so a bare pip install lands where the server can't see it:

FROM ghcr.io/vectorize-io/hindsight:latest

RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
      'PyJWT[crypto]>=2.12.0' 'httpx>=0.27.0'

COPY my_extension /app/extensions/my_extension
ENV PYTHONPATH=/app/extensions

# Fail the build, not the first request, if it isn't importable.
RUN /app/api/.venv/bin/python -c "import my_extension"

Then point the service at that image and pass the extension's variables as environment. Give the API and worker containers the same extension configuration — the worker uses the tenant extension to enumerate schemas for background consolidation.

See the extensions registry README for the full recipe.

Bare Metal

Install your extension package in the same Python environment as Hindsight:

# Install Hindsight
pip install hindsight-api

# Install your extension package
pip install ./my-extensions
# or
pip install my-extensions-package

# Configure
export HINDSIGHT_API_TENANT_EXTENSION=my_extensions.auth:JwtTenantExtension
export HINDSIGHT_API_TENANT_JWT_SECRET=your-secret

# Run
hindsight-api

Contributing Extensions

Custom extensions that solve common use cases are welcome contributions to the Hindsight project. If you've built an extension for:

  • Authentication providers (OAuth, SAML, API gateways)
  • Rate limiting or quota management
  • Audit logging integrations
  • Metrics exporters (Datadog, New Relic, etc.)
  • Custom HTTP endpoints for specific platforms

Add it to the extensions registry — either as a directory under hindsight-extensions/, or as a registry entry linking to your own repository. That README covers the layout, the development workflow, and Docker packaging.

Extensions live outside the server so that installing Hindsight does not pull in a vendor's client library, and so changing an extension does not require a Hindsight release. Only extensions that add no dependencies and are useful to any deployment (ApiKeyTenantExtension, MemoryDefenseRegexExtension) stay in hindsight_api.extensions.builtin.