diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dfc8334526..d69c35621c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,6 +47,7 @@ jobs: integrations-agent-plugin: ${{ steps.filter.outputs.integrations-agent-plugin }} integrations-copilot-cli: ${{ steps.filter.outputs.integrations-copilot-cli }} extensions-supabase-tenant: ${{ steps.filter.outputs.extensions-supabase-tenant }} + extensions-static-keys-tenant: ${{ steps.filter.outputs.extensions-static-keys-tenant }} integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }} integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }} integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }} @@ -179,6 +180,8 @@ jobs: - 'hindsight-integrations/copilot-cli/**' extensions-supabase-tenant: - 'hindsight-extensions/supabase-tenant/**' + extensions-static-keys-tenant: + - 'hindsight-extensions/static-keys-tenant/**' integrations-crewai: - 'hindsight-integrations/crewai/**' integrations-litellm: @@ -3966,6 +3969,53 @@ jobs: --build-arg HINDSIGHT_IMAGE=ghcr.io/vectorize-io/hindsight:latest-slim \ -t hindsight-with-supabase . + test-extension-static-keys-tenant: + needs: [detect-changes] + if: >- + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.extensions-static-keys-tenant == 'true' || + needs.detect-changes.outputs.core == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || '' }} + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + prune-cache: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: ".python-version" + + # No --frozen: the lock is not checked in, because tool.uv.sources resolves + # hindsight-api-slim from the local checkout and its dependency tree moves + # with core. + - name: Install dependencies + working-directory: ./hindsight-extensions/static-keys-tenant + run: uv sync + + - name: Run tests + working-directory: ./hindsight-extensions/static-keys-tenant + run: uv run pytest tests -v + + # The Dockerfile IS the distribution mechanism — its final `import` step is + # the only thing proving the extension is reachable from the interpreter the + # server runs. Build it from the repo root, where the sources are in context. + - name: Build the extension image + run: | + docker build \ + -f hindsight-extensions/static-keys-tenant/Dockerfile \ + --build-arg HINDSIGHT_IMAGE=ghcr.io/vectorize-io/hindsight:latest-slim \ + -t hindsight-with-static-keys . + test-crewai-integration: needs: [detect-changes] if: >- @@ -5595,6 +5645,7 @@ jobs: - test-obsidian-integration - test-agent-framework-integration - test-extension-supabase-tenant + - test-extension-static-keys-tenant - test-crewai-integration - test-langgraph-integration - test-superagent-integration diff --git a/hindsight-docs/docs/developer/extensions.md b/hindsight-docs/docs/developer/extensions.md index cdecec359d..1cc159126c 100644 --- a/hindsight-docs/docs/developer/extensions.md +++ b/hindsight-docs/docs/developer/extensions.md @@ -27,6 +27,14 @@ Validates [Supabase](https://supabase.com) JWTs and gives each authenticated use 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](https://github.com/vectorize-io/hindsight/tree/main/hindsight-extensions/static-keys-tenant), 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`. --- diff --git a/hindsight-extensions/README.md b/hindsight-extensions/README.md index e5ccfeccb7..4b659db716 100644 --- a/hindsight-extensions/README.md +++ b/hindsight-extensions/README.md @@ -17,6 +17,7 @@ Hindsight that copies the extension in — see [Packaging](#packaging-an-extensi | Extension | Slot | What it does | | --- | --- | --- | | [`supabase-tenant`](./supabase-tenant) | `TENANT` | Validates [Supabase](https://supabase.com) Auth JWTs and gives each user their own Postgres schema | +| [`static-keys-tenant`](./static-keys-tenant) | `TENANT` | Authenticates static API keys from env vars and gives each user their own Postgres schema | Extensions maintained outside this repository can be listed here too — open a PR adding a row that links to yours. diff --git a/hindsight-extensions/static-keys-tenant/Dockerfile b/hindsight-extensions/static-keys-tenant/Dockerfile new file mode 100644 index 0000000000..0dc3250d87 --- /dev/null +++ b/hindsight-extensions/static-keys-tenant/Dockerfile @@ -0,0 +1,32 @@ +# A Hindsight image with the static-keys tenant extension in it. +# +# Extensions are not bundled with the server and are not published to PyPI: +# you ship one by copying it into an image built on top of Hindsight. +# +# Build from the repository root so the extension sources are in context: +# docker build -f hindsight-extensions/static-keys-tenant/Dockerfile \ +# -t hindsight-with-static-keys . +# +# Run: +# docker run -p 8888:8888 \ +# -e HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_static_keys_tenant:StaticKeysTenantExtension \ +# -e HINDSIGHT_API_TENANT_USERS=user1:key1,user2:key2 \ +# hindsight-with-static-keys +# +# Override the base with `--build-arg HINDSIGHT_IMAGE=...:latest-slim` if you +# don't need the bundled local embedding/reranking models. +ARG HINDSIGHT_IMAGE=ghcr.io/vectorize-io/hindsight:latest +FROM ${HINDSIGHT_IMAGE} + +# The static-keys extension has no third-party dependencies beyond the server +# itself — nothing to pip install here. + +# Put the extension on the server's import path. /app/extensions is ours — the +# image does not use it — so this cannot shadow anything the server ships. +COPY hindsight-extensions/static-keys-tenant/hindsight_ext_static_keys_tenant \ + /app/extensions/hindsight_ext_static_keys_tenant +ENV PYTHONPATH=/app/extensions + +# Fail the build, rather than the first authenticated request, if the extension +# is not importable from the interpreter the server actually runs. +RUN /app/api/.venv/bin/python -c "import hindsight_ext_static_keys_tenant" diff --git a/hindsight-extensions/static-keys-tenant/README.md b/hindsight-extensions/static-keys-tenant/README.md new file mode 100644 index 0000000000..23c1958e88 --- /dev/null +++ b/hindsight-extensions/static-keys-tenant/README.md @@ -0,0 +1,96 @@ +# Static-keys tenant extension + +A Hindsight `TenantExtension` that authenticates requests with static API keys +declared in environment variables and gives every user their own PostgreSQL schema, +so memories are isolated at the database level. + +- **Self-hosted, no identity provider**: users and keys come from the environment, + no external IdP, no users table. +- **Schema per user**: a user `rafael` gets the schema `user_rafael` (lowercased, + dashes become underscores), migrated on first access and cached afterwards. +- **Multiple keys per user**: `rafael:key1,rafael:key2` both authenticate as `rafael` + into the same schema. +- **Constant-time key comparison** with `hmac.compare_digest` on every request. +- **Fail-fast on misconfiguration**: invalid entries, duplicate keys, schema-name + collisions and over-long schema names are rejected at startup. + +> This is a newer, dependency-free complement to +> [`supabase-tenant`](../supabase-tenant): where Supabase is the source of identity, +> this one is for fully self-hosted, single-node multi-user deployments with a +> handful of statically configured users. + +## Install + +Extensions are not published to PyPI. Build an image with this one in it, from the +repository root: + +```bash +docker build -f hindsight-extensions/static-keys-tenant/Dockerfile -t hindsight-with-static-keys . +``` + +See the [Dockerfile](./Dockerfile) for what it does, and the +[packaging guide](../README.md#packaging-an-extension) for the general pattern. + +To run the server outside Docker, put `hindsight_ext_static_keys_tenant/` on the +`PYTHONPATH` of the environment Hindsight runs in. There are no extra dependencies +to install — the extension only uses the server's own extension interfaces. + +## Configure + +```bash +HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_static_keys_tenant:StaticKeysTenantExtension +HINDSIGHT_API_TENANT_USERS=user1:key1,user1:key2,user2:key3 +``` + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `HINDSIGHT_API_TENANT_USERS` | yes | — | Comma-separated `user_id:api_key` pairs. Multiple keys may map to the same user | +| `HINDSIGHT_API_TENANT_SCHEMA_PREFIX` | no | `user` | Schema name prefix; must be a valid Postgres identifier | +| `HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED` | no | — | **Not supported.** Setting it to a truthy value fails at startup: MCP clients always authenticate with a user's API key, so they get the same isolation as HTTP | + +User IDs are **case-insensitive** and normalized before building the schema name: +they are lowercased and dashes become underscores (`Rafael`, `rafael` and `RAFAEL` +all resolve to `user_rafael`), matching how PostgreSQL folds unquoted identifiers. +Two distinct users whose ids collide after normalization (e.g. `jane-doe` vs +`jane_doe`, or ids longer than the 63-byte identifier limit) are rejected at startup. + +API keys have two format constraints (**keys must be ASCII and must not contain a +comma**): the comma is the pair separator, and a non-ASCII key could never +authenticate anyway — HTTP header values arrive latin-1-decoded while environment +variables are utf-8-decoded, so the byte sequences would never match and the key +would silently 401 forever. + +Give the API and the worker the **same** variables: the worker calls `list_tenants()` +to decide which schemas to consolidate, so a worker without the extension leaves every +tenant's background processing stopped. + +`list_tenants()` returns every configured user, so the worker can poll schemas that do +not exist yet (an idle-cycle probe against a missing schema is skipped harmlessly). +To avoid that and provision all tenant schemas up front, run the admin sweep once +after changing `HINDSIGHT_API_TENANT_USERS`: + +```bash +uv run hindsight-admin run-db-migration +``` + +## Use + +Clients pass their configured API key as a bearer token: + +```bash +curl -H "Authorization: Bearer " \ + http://localhost:8888/v1/default/banks +``` + +Unknown or missing keys get a 401; every key is compared in constant time. + +## Develop + +```bash +uv sync +uv run pytest tests -v +``` + +## License + +MIT. diff --git a/hindsight-extensions/static-keys-tenant/hindsight_ext_static_keys_tenant/__init__.py b/hindsight-extensions/static-keys-tenant/hindsight_ext_static_keys_tenant/__init__.py new file mode 100644 index 0000000000..24da2ad295 --- /dev/null +++ b/hindsight-extensions/static-keys-tenant/hindsight_ext_static_keys_tenant/__init__.py @@ -0,0 +1,10 @@ +"""Static-keys tenant extension for the Hindsight API server. + +Configure the server to load it with:: + + HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_static_keys_tenant:StaticKeysTenantExtension +""" + +from hindsight_ext_static_keys_tenant.extension import StaticKeysTenantExtension + +__all__ = ["StaticKeysTenantExtension"] diff --git a/hindsight-extensions/static-keys-tenant/hindsight_ext_static_keys_tenant/extension.py b/hindsight-extensions/static-keys-tenant/hindsight_ext_static_keys_tenant/extension.py new file mode 100644 index 0000000000..0a07cb8e8b --- /dev/null +++ b/hindsight-extensions/static-keys-tenant/hindsight_ext_static_keys_tenant/extension.py @@ -0,0 +1,356 @@ +"""Static-keys tenant extension for Hindsight. + +Ships separately from the Hindsight server: build an image on top of Hindsight +that copies this package in (see the Dockerfile beside it). + +Bridges ApiKeyTenantExtension (one shared key) and SupabaseTenantExtension +(external IdP): a fully self-hosted, single-node multi-user mode where users +and their API keys are declared in environment variables. Each user maps to +their own PostgreSQL schema ({prefix}_{user_id}), provisioned lazily on first +access — memory isolation at the database level, with the worker processing +every tenant via list_tenants(). + +Features: + - In-memory user store from env vars (no users table, no external IdP) + - Multiple API keys may map to the same user (same isolated schema) + - Per-user schema isolation with lazy provisioning + caching + - Constant-time key comparison (hmac.compare_digest) + - Works for HTTP and MCP auth + - Usage-metering: sets RequestContext.tenant_id / api_key_id after auth + - Fail-fast on misconfiguration + +Configuration via environment variables: + HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_static_keys_tenant:StaticKeysTenantExtension + HINDSIGHT_API_TENANT_USERS=user1:key1,user1:key2,user2:key3 # required, comma-separated user:key pairs + HINDSIGHT_API_TENANT_SCHEMA_PREFIX=user # optional, default: "user" (creates user_ schemas) + # Note: HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED is rejected at startup — MCP + # clients always authenticate with a user's key (no isolation bypass). + +Usage: + Clients pass their API key in the Authorization header: + + curl -H "Authorization: Bearer " \\ + http://localhost:8888/v1/default/banks + +Author: Rafael Kallis +License: MIT +""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import logging +import re +from dataclasses import dataclass + +from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension +from hindsight_api.models import RequestContext + +logger = logging.getLogger(__name__) + +__all__ = ["StaticKeysTenantExtension"] + +# Schema prefix must be a valid Postgres identifier component (letters, digits, underscores) +_SCHEMA_PREFIX_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + +# User IDs appear in schema names, so they must be safe as a Postgres identifier +# component: start with a letter/underscore, then letters/digits/underscore/dash. +# Dashes are normalized to underscores and the whole id is lowercased before +# building the schema name (Postgres folds unquoted identifiers to lowercase). +_USER_ID_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") + +# PostgreSQL truncates identifiers to NAMEDATALEN (63) bytes. An operator can +# configure two distinct user ids that differ only past that boundary; both +# would land on the same truncated schema and silently share memories. Reject +# at init rather than discovering the collision at runtime. +_MAX_SCHEMA_LENGTH = 63 + + +def _derive_key_id(api_key: str) -> str: + """Derive a stable, non-secret identifier for an API key. + + ``RequestContext.api_key_id`` is documented as identifying *the key* (and + multiple keys per user is a selling point of this extension), but metering + must never log or store the key itself. A truncated sha256 digest is + deterministic, short, and safe to paste into an issue or a chat — it is + also the name used to refer to a key in configuration error messages. + """ + return hashlib.sha256(api_key.encode("utf-8", "surrogateescape")).hexdigest()[:16] + + +@dataclass(frozen=True) +class _KeyEntry: + """A configured API key's mapping: the owning user and its isolated schema.""" + + user_id: str + schema_name: str + key_id: str + # The key pre-encoded for compare_digest (utf-8/surrogateescape): computed + # once at init so authenticate() never re-encodes every configured key on + # every request. + key_bytes: bytes + + +def _encode_key(api_key: str) -> bytes: + """Encode a configured key the same way bearer-token bytes are recovered. + + Header values arrive latin-1-decoded and are re-encoded with + "surrogateescape" (see authenticate), so the same codec here makes the two + sides byte-comparable. + """ + return api_key.encode("utf-8", "surrogateescape") + + +class StaticKeysTenantExtension(TenantExtension): + """ + TenantExtension mapping env-configured static API keys to per-user schemas. + + Each entry in ``HINDSIGHT_API_TENANT_USERS`` is a ``user_id:api_key`` pair. + Multiple keys may map to the same user. Authenticated requests are mapped to + schema ``{prefix}_{user_id}`` (user ids are lowercased, dashes normalized to + underscores), provisioned lazily on first access. + + Example: + HINDSIGHT_API_TENANT_USERS=rafael:key-a,sophie:key-b + HINDSIGHT_API_TENANT_SCHEMA_PREFIX=user + + User "rafael" with key "key-a" gets schema "user_rafael"; + user "sophie" with key "key-b" gets schema "user_sophie". + """ + + def __init__(self, config: dict[str, str]) -> None: + """ + Initialize with configuration from environment variables. + + Config keys are derived from HINDSIGHT_API_TENANT_* env vars: + - HINDSIGHT_API_TENANT_USERS -> config["users"] (required) + - HINDSIGHT_API_TENANT_SCHEMA_PREFIX -> config["schema_prefix"] (optional, default "user") + - HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED -> config["mcp_auth_disabled"] (optional) + + Args: + config: Dictionary of configuration values from environment. + + Raises: + ValueError: If required configuration is missing or invalid. + """ + super().__init__(config) + + users_raw = config.get("users", "") + self.schema_prefix = config.get("schema_prefix", "user") + + if not users_raw.strip(): + raise ValueError( + "HINDSIGHT_API_TENANT_USERS is required when using StaticKeysTenantExtension. " + 'Format: "user1:key1,user2:key2"' + ) + + if not _SCHEMA_PREFIX_RE.match(self.schema_prefix): + raise ValueError( + f"Invalid schema_prefix '{self.schema_prefix}'. " + "Must be a valid Postgres identifier (letters, digits, underscores, starting with a letter or underscore)." + ) + + # Parse users into an API-key -> _KeyEntry map. Multiple keys may map + # to the same user. Also keep a per-user map so list_tenants() can + # return every configured tenant, even before any authentication has + # happened. + self._key_to_user: dict[str, _KeyEntry] = {} + self._users: dict[str, str] = {} # user_id -> schema_name + + for index, entry in enumerate(users_raw.split(",")): + entry = entry.strip() + if not entry: + continue + # Error messages below must never quote `entry` or `api_key`: an + # operator who misconfigures HINDSIGHT_API_TENANT_USERS pastes the + # startup error into an issue or a chat, and would disclose the + # key. Report the entry's index (and the user id, once it is known + # and validated) instead — the key is named by its sha256-derived + # key_id, see _derive_key_id. + if ":" not in entry: + raise ValueError( + f'Invalid HINDSIGHT_API_TENANT_USERS entry at index {index}. Expected format "user_id:api_key".' + ) + user_id, api_key = entry.split(":", 1) + user_id = user_id.strip() + api_key = api_key.strip() + if not user_id or not api_key: + raise ValueError( + f"Invalid HINDSIGHT_API_TENANT_USERS entry at index {index}: " + + ("user_id" if not user_id else "api_key") + + " must be non-empty." + ) + if not _USER_ID_RE.match(user_id): + raise ValueError( + f"Invalid user_id '{user_id}' in HINDSIGHT_API_TENANT_USERS. " + "Must start with a letter or underscore, then letters, digits, underscores or dashes." + ) + + # Normalize the user id to lowercase. PostgreSQL folds unquoted + # identifiers to lowercase at query time (the runtime's fq_table() + # does not quote the schema, while the migration path creates it + # quoted), so a mixed-case id like "Rafael" and its lowercase twin + # "rafael" would collapse to the same schema at runtime — either + # breaking every query or, worse, silently sharing one schema + # between two users. Lowercasing makes case variants the same + # canonical user (consistent with Postgres folding), so they merge + # onto one schema rather than drifting apart. + user_id = user_id.lower() + + # Each schema must be a unique isolation boundary. Two distinct + # users whose ids differ in non-case ways yet normalize to the same + # schema (e.g. "jane-doe" and "jane_doe"), or that collide past the + # 63-byte identifier limit, would silently read and write each + # other's memories — reject loudly instead of breaking the + # extension's isolation guarantee. + safe_user_id = user_id.replace("-", "_") + schema_name = f"{self.schema_prefix}_{safe_user_id}" + if len(schema_name) > _MAX_SCHEMA_LENGTH: + raise ValueError( + f"Schema name '{schema_name}' for user_id '{user_id}' exceeds the PostgreSQL " + f"identifier limit of {_MAX_SCHEMA_LENGTH} characters." + ) + existing = self._users.get(user_id) + if existing is not None and existing != schema_name: + raise ValueError( + f"Schema name collision for user_id '{user_id}': " + f"'{existing}' vs '{schema_name}'. User ids must map to distinct schemas." + ) + claimed_by = next((uid for uid, s in self._users.items() if uid != user_id and s == schema_name), None) + if claimed_by is not None: + raise ValueError( + f"Schema name '{schema_name}' for user_id '{user_id}' is already claimed by " + f"user_id '{claimed_by}'. Two distinct users cannot share one schema." + ) + if api_key in self._key_to_user: + raise ValueError( + f"Duplicate API key (key_id '{_derive_key_id(api_key)}') in HINDSIGHT_API_TENANT_USERS " + f"is configured for both user_id '{self._key_to_user[api_key].user_id}' " + f"and user_id '{user_id}'. Each key must be unique." + ) + + self._key_to_user[api_key] = _KeyEntry( + user_id=user_id, schema_name=schema_name, key_id=_derive_key_id(api_key), key_bytes=_encode_key(api_key) + ) + self._users[user_id] = schema_name + + # Track initialized schemas to avoid redundant migrations. Two + # concurrent first requests for the same user would both see the + # schema missing and both run migrations without the per-schema lock + # below serializing them. + self._initialized_schemas: set[str] = set() + self._schema_locks: dict[str, asyncio.Lock] = {} + + # HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED is deliberately unsupported. + # On ApiKeyTenantExtension (one shared key) the flag downgrades a shared + # secret to none — a local convenience. Here it would hand any + # unauthenticated MCP client the base schema in a deployment whose + # entire purpose is per-user isolation, so refuse at startup rather + # than ship that footgun (review round 2: refuse-at-init over parity). + if config.get("mcp_auth_disabled", "").lower() in ("true", "1", "yes"): + raise ValueError( + "HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED is not supported by StaticKeysTenantExtension: " + "it would let unauthenticated MCP clients into the base schema of a multi-user " + "deployment. Remove the variable; MCP clients authenticate with a user's API key." + ) + + # ------------------------------------------------------------------ + # Authentication + # ------------------------------------------------------------------ + + async def authenticate(self, context: RequestContext) -> TenantContext: + """ + Validate the API key and return tenant context. + + Args: + context: Request context containing the API key (the Authorization header). + + Returns: + TenantContext with schema_name set to ``{prefix}_{user_id}``. + + Raises: + AuthenticationError: If the key is missing or unknown. + """ + key = context.api_key + if not key: + raise AuthenticationError("Missing Authorization header. Expected: Bearer ") + + # Compare with hmac.compare_digest over every configured key — never an + # exact-equality fast path, which would let unknown keys skip the + # constant-time loop entirely and leak key size/shape via timing. The + # number of comparisons still depends on the matching key's position + # (we stop at the first match); that reveals nothing to an attacker + # holding only invalid keys, and an attacker holding a valid key + # already knows where it sits in the list. Configured keys are + # pre-encoded to bytes at init (_KeyEntry.key_bytes), so each request + # only encodes the incoming key. Header values arrive latin-1-decoded, + # so encode with "surrogateescape" so any byte sequence round-trips + # losslessly instead of raising TypeError (a 500, not a 401) for + # non-ASCII bearer tokens. + key_bytes = key.encode("utf-8", "surrogateescape") + match: _KeyEntry | None = None + for entry in self._key_to_user.values(): + if hmac.compare_digest(key_bytes, entry.key_bytes): + match = entry + break + + if match is None: + raise AuthenticationError("Invalid API key") + + user_id, schema_name = match.user_id, match.schema_name + + # Initialize schema on first access. The per-schema lock serializes + # concurrent first requests (two requests racing here would otherwise + # both call run_migration for the same schema); inside the lock the + # check runs again so the loser of the lock skips a redundant migration. + if schema_name not in self._initialized_schemas: + async with self._schema_lock(schema_name): + if schema_name not in self._initialized_schemas: + await self._initialize_schema(schema_name) + + # Usage metering: the HTTP/MCP layers read these fields back after auth + # to attribute operations to a tenant / API key. api_key_id identifies + # *the key* (see RequestContext), not the user — with multiple keys per + # user it must distinguish which key authenticated. It is the derived, + # non-secret key_id, never the key itself. + context.tenant_id = user_id + context.api_key_id = match.key_id + + return TenantContext(schema_name=schema_name) + + async def authenticate_mcp(self, context: RequestContext) -> TenantContext: + """Authenticate MCP requests — same isolation as HTTP, no bypass.""" + return await self.authenticate(context) + + # ------------------------------------------------------------------ + # Schema management + # ------------------------------------------------------------------ + + def _schema_lock(self, schema_name: str) -> asyncio.Lock: + """Return (creating if needed) the init lock for one tenant schema.""" + lock = self._schema_locks.get(schema_name) + if lock is None: + lock = asyncio.Lock() + self._schema_locks[schema_name] = lock + return lock + + async def _initialize_schema(self, schema_name: str) -> None: + """Run migrations for a new tenant schema and cache the result.""" + logger.info("Initializing schema: %s", schema_name) + try: + await self.context.run_migration(schema_name) + self._initialized_schemas.add(schema_name) + logger.info("Schema ready: %s", schema_name) + except Exception as e: + logger.error("Schema initialization failed for %s: %s", schema_name, e) + raise AuthenticationError(f"Failed to initialize tenant: {e!s}") + + # ------------------------------------------------------------------ + # Worker discovery + # ------------------------------------------------------------------ + + async def list_tenants(self) -> list[Tenant]: + """Return all configured tenants for worker processing.""" + return [Tenant(schema=schema, tenant_id=user_id) for user_id, schema in self._users.items()] diff --git a/hindsight-extensions/static-keys-tenant/pyproject.toml b/hindsight-extensions/static-keys-tenant/pyproject.toml new file mode 100644 index 0000000000..477c93bb00 --- /dev/null +++ b/hindsight-extensions/static-keys-tenant/pyproject.toml @@ -0,0 +1,30 @@ +# This file exists to run the tests, nothing else. The extension is not +# published to PyPI and is not built into a wheel — it is shipped by copying +# hindsight_ext_static_keys_tenant/ into a derived image (see Dockerfile). +# +# `package = false` keeps uv from trying to build or install this directory: +# there is no build backend and no distribution here, just sources on the path. +[project] +name = "hindsight-ext-static-keys-tenant" +version = "0" +requires-python = ">=3.11" +dependencies = [ + # The server provides the extension interfaces this is written against. + # It is a test-time dependency only: at runtime the server is the host + # process that imports the extension, not something it installs. + "hindsight-api-slim", + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", +] + +[tool.uv] +package = false + +[tool.uv.sources] +hindsight-api-slim = { path = "../../hindsight-api-slim", editable = true } + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/hindsight-extensions/static-keys-tenant/tests/test_package_entrypoint.py b/hindsight-extensions/static-keys-tenant/tests/test_package_entrypoint.py new file mode 100644 index 0000000000..a413ca394b --- /dev/null +++ b/hindsight-extensions/static-keys-tenant/tests/test_package_entrypoint.py @@ -0,0 +1,37 @@ +"""The import path this extension is documented under must actually resolve. + +``HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_static_keys_tenant:StaticKeysTenantExtension`` +is the value in every README, Dockerfile and migration note. It is resolved by +``load_extension`` at server startup, so a missing re-export would only surface +as a boot failure in someone's deployment. +""" + +import os +from unittest.mock import patch + +from hindsight_api.extensions.loader import load_extension +from hindsight_api.extensions.tenant import TenantExtension + +from hindsight_ext_static_keys_tenant import StaticKeysTenantExtension + + +def test_class_is_exported_from_the_package_root(): + from hindsight_ext_static_keys_tenant.extension import ( + StaticKeysTenantExtension as from_module, + ) + + assert StaticKeysTenantExtension is from_module + + +def test_documented_env_value_loads_the_extension(): + env = { + "HINDSIGHT_API_TENANT_EXTENSION": "hindsight_ext_static_keys_tenant:StaticKeysTenantExtension", + "HINDSIGHT_API_TENANT_USERS": "rafael:key-a,sophie:key-b", + "HINDSIGHT_API_TENANT_SCHEMA_PREFIX": "tenant", + } + with patch.dict(os.environ, env, clear=False): + extension = load_extension("TENANT", TenantExtension) + + assert isinstance(extension, StaticKeysTenantExtension) + assert extension.schema_prefix == "tenant" + assert extension._users == {"rafael": "tenant_rafael", "sophie": "tenant_sophie"} diff --git a/hindsight-extensions/static-keys-tenant/tests/test_static_keys_tenant.py b/hindsight-extensions/static-keys-tenant/tests/test_static_keys_tenant.py new file mode 100644 index 0000000000..d574161ebe --- /dev/null +++ b/hindsight-extensions/static-keys-tenant/tests/test_static_keys_tenant.py @@ -0,0 +1,461 @@ +"""Tests for StaticKeysTenantExtension (env-configured per-user API keys).""" + +import asyncio +import hashlib +import hmac +from unittest.mock import AsyncMock, patch + +import pytest + +from hindsight_api.extensions.context import ExtensionContext +from hindsight_api.extensions.loader import load_extension +from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension +from hindsight_api.models import RequestContext +from hindsight_ext_static_keys_tenant.extension import StaticKeysTenantExtension, _KeyEntry + + +def _make_config(**overrides) -> dict[str, str]: + """Build a minimal valid config, overridable per test.""" + config = { + "users": "rafael:key-a,sophie:key-b", + } + config.update(overrides) + return config + + +def _make_extension(**overrides) -> StaticKeysTenantExtension: + return StaticKeysTenantExtension(_make_config(**overrides)) + + +def _expected_entry(user_id: str, key: str, schema_prefix: str = "user") -> _KeyEntry: + """Build the _KeyEntry the extension must produce for one configured pair.""" + return _KeyEntry( + user_id=user_id.lower(), + schema_name=f"{schema_prefix}_{user_id.lower().replace('-', '_')}", + key_id=hashlib.sha256(key.encode("utf-8", "surrogateescape")).hexdigest()[:16], + key_bytes=key.encode("utf-8", "surrogateescape"), + ) + + +class TestStaticKeysTenantExtensionInit: + """Tests for initialization and configuration parsing.""" + + def test_init_with_valid_config(self): + ext = _make_extension() + assert ext.schema_prefix == "user" + assert ext._key_to_user == { + "key-a": _expected_entry("rafael", "key-a"), + "key-b": _expected_entry("sophie", "key-b"), + } + assert ext._users == {"rafael": "user_rafael", "sophie": "user_sophie"} + + def test_init_missing_users(self): + with pytest.raises(ValueError, match="HINDSIGHT_API_TENANT_USERS is required"): + _make_extension(users="") + + def test_init_default_schema_prefix(self): + ext = _make_extension() + assert ext.schema_prefix == "user" + + def test_init_custom_schema_prefix(self): + ext = _make_extension(schema_prefix="tenant") + assert ext._users == {"rafael": "tenant_rafael", "sophie": "tenant_sophie"} + + def test_init_rejects_invalid_schema_prefix(self): + with pytest.raises(ValueError, match="Invalid schema_prefix"): + _make_extension(schema_prefix="1bad") + + def test_init_rejects_schema_prefix_with_dash(self): + with pytest.raises(ValueError, match="Invalid schema_prefix"): + _make_extension(schema_prefix="bad-prefix") + + def test_init_accepts_underscore_prefix(self): + ext = _make_extension(schema_prefix="my_user") + assert ext.schema_prefix == "my_user" + + def test_init_rejects_duplicate_api_key(self): + # The error must name the colliding users via the derived key_id, never + # the key itself — it may end up pasted into an issue or a chat. + with pytest.raises(ValueError, match=r"Duplicate API key \(key_id '[0-9a-f]{16}'\)"): + _make_extension(users="alice:k1,bob:k1") + + def test_init_duplicate_api_key_error_does_not_leak_key(self): + with pytest.raises(ValueError) as excinfo: + _make_extension(users="alice:k1,bob:k1") + assert "k1" not in str(excinfo.value) + + def test_init_duplicate_api_key_error_names_both_users(self): + with pytest.raises(ValueError, match="alice") as first: + _make_extension(users="alice:k1,bob:k1") + with pytest.raises(ValueError, match="bob") as second: + _make_extension(users="alice:k1,bob:k1") + assert "alice" in str(first.value) and "bob" in str(second.value) + + def test_init_rejects_entry_without_colon_does_not_leak_key(self): + # `entry` is the raw user_id:api_key pair; quoting it in the error would + # disclose the key of a malformed entry. Only the index may be reported. + with pytest.raises(ValueError, match="entry at index 1") as excinfo: + _make_extension(users="rafael:key-a,sophie") # missing colon → whole entry malformed + assert "sophie" not in str(excinfo.value) # cannot report user_id without ':'; index only + + def test_init_rejects_empty_api_key_does_not_leak_entry(self): + # HINDSIGHT_API_TENANT_USERS=rafael: — the malformed entry contains no + # key here, but the same message path is shared with entries that do, + # so assert the message stays free of entry/key material. + with pytest.raises(ValueError, match="api_key must be non-empty"): + _make_extension(users="rafael:") + + def test_init_rejects_entry_without_colon(self): + with pytest.raises(ValueError, match="Invalid HINDSIGHT_API_TENANT_USERS entry"): + _make_extension(users="rafael") + + def test_init_rejects_sql_injection_user_id(self): + with pytest.raises(ValueError, match="Invalid user_id"): + _make_extension(users='rafael"; DROP TABLE memory_units;--:key-a') + + def test_init_rejects_empty_user_id(self): + with pytest.raises(ValueError, match="user_id must be non-empty"): + _make_extension(users=":key-a") + + def test_init_rejects_empty_api_key(self): + with pytest.raises(ValueError, match="api_key must be non-empty"): + _make_extension(users="rafael:") + + def test_init_rejects_user_id_starting_with_digit(self): + with pytest.raises(ValueError, match="Invalid user_id"): + _make_extension(users="1rafael:key-a") + + def test_key_id_is_stable_and_derived_from_key(self): + # key_id is the truncated sha256 of the key bytes — stable across + # restarts (so metering can attribute usage to a key long-term) and + # short enough to quote in error messages. + ext = _make_extension(users="rafael:key-a") + entry = ext._key_to_user["key-a"] + assert entry.key_id == hashlib.sha256(b"key-a").hexdigest()[:16] + assert len(entry.key_id) == 16 + assert entry.key_id != "key-a" # not the secret itself + ext2 = _make_extension(users="rafael:key-a") + assert ext2._key_to_user["key-a"].key_id == entry.key_id # deterministic + + def test_key_id_differs_between_keys(self): + ext = _make_extension(users="rafael:key-a,rafael:key-b") + assert ext._key_to_user["key-a"].key_id != ext._key_to_user["key-b"].key_id + + def test_init_normalizes_dashes_in_user_id(self): + ext = _make_extension(users="my-user-1:key-a") + assert ext._users == {"my-user-1": "user_my_user_1"} + + def test_init_normalizes_mixed_case_user_id_to_lowercase(self): + # "Rafael" must land on schema "user_rafael": Postgres folds unquoted + # identifiers to lowercase at runtime (fq_table does not quote the + # schema), so a mixed-case schema would break every query — or worse, + # collapse two users onto one schema. Lowercasing up front keeps the + # isolation guarantee. + ext = _make_extension(users="Rafael:key-a") + assert ext._users == {"rafael": "user_rafael"} + assert ext._key_to_user == {"key-a": _expected_entry("rafael", "key-a")} + + def test_init_normalizes_mixed_case_prefix_and_dashes(self): + # Mixed case + dashes must normalize to a single stable lowercased schema. + ext = _make_extension(users="My-User-1:key-a") + assert ext._users == {"my-user-1": "user_my_user_1"} + + def test_init_rejects_mixed_case_dash_collision(self): + # "Jane-Doe" and "jane_doe" are different spellings that normalize to + # the same schema (lowercased + dashes to underscores) — two distinct + # user ids must never share one isolated schema. + with pytest.raises(ValueError, match="already claimed"): + _make_extension(users="Jane-Doe:k1,jane_doe:k2") + + def test_init_accepts_case_insensitive_duplicate_user(self): + # The same user written with different case is the same tenant, so its + # keys may coexist on one schema. + ext = _make_extension(users="Rafael:k1,rafael:k2") + assert ext._users == {"rafael": "user_rafael"} + assert ext._key_to_user == { + "k1": _expected_entry("Rafael", "k1"), + "k2": _expected_entry("rafael", "k2"), + } + + def test_init_multiple_keys_same_user(self): + ext = _make_extension(users="rafael:key-a,rafael:key-b") + assert ext._key_to_user == { + "key-a": _expected_entry("rafael", "key-a"), + "key-b": _expected_entry("rafael", "key-b"), + } + assert ext._users == {"rafael": "user_rafael"} + + def test_init_rejects_schema_collision_after_dash_normalization(self): + # "jane-doe" and "jane_doe" both normalize to user_jane_doe — two + # distinct users must never share one isolated schema. + with pytest.raises(ValueError, match="already claimed"): + _make_extension(users="jane-doe:k1,jane_doe:k2") + + def test_init_rejects_schema_name_over_63_chars(self): + long_user = "u" * 62 + with pytest.raises(ValueError, match="exceeds the PostgreSQL identifier limit"): + _make_extension(users=f"{long_user}:key-a") + + def test_init_rejects_duplicate_api_key(self): + with pytest.raises(ValueError, match="Duplicate API key"): + _make_extension(users="alice:k1,bob:k1") + + def test_init_multiple_keys_same_user_does_not_collide(self): + # The legitimate multi-key-per-user case must not raise. + ext = _make_extension(users="alice:k1,alice:k2") + assert ext._users == {"alice": "user_alice"} + + def test_is_tenant_extension_subclass(self): + ext = _make_extension() + assert isinstance(ext, TenantExtension) + + +class TestStaticKeysTenantExtensionAuthenticate: + """Tests for authentication.""" + + @pytest.mark.asyncio + async def test_authenticate_valid_key(self): + ext = _make_extension() + mock_context = AsyncMock(spec=ExtensionContext) + mock_context.run_migration = AsyncMock() + ext._context = mock_context + + result = await ext.authenticate(RequestContext(api_key="key-a")) + + assert isinstance(result, TenantContext) + assert result.schema_name == "user_rafael" + mock_context.run_migration.assert_called_once_with("user_rafael") + + @pytest.mark.asyncio + async def test_authenticate_missing_key(self): + ext = _make_extension() + with pytest.raises(AuthenticationError, match="Missing Authorization header"): + await ext.authenticate(RequestContext(api_key=None)) + + @pytest.mark.asyncio + async def test_authenticate_unknown_key(self): + ext = _make_extension() + with pytest.raises(AuthenticationError, match="Invalid API key"): + await ext.authenticate(RequestContext(api_key="wrong-key")) + + @pytest.mark.asyncio + async def test_authenticate_non_ascii_key_is_401_not_500(self): + # Header values arrive latin-1-decoded, so a byte >= 0x80 (here 'é', + # U+00E9) is a legitimate non-ASCII str. It must be rejected as an + # invalid key (AuthenticationError -> 401), not raise TypeError out of + # authenticate() (which would be a 500) the way str-vs-str + # hmac.compare_digest does. + ext = _make_extension() + with pytest.raises(AuthenticationError, match="Invalid API key"): + await ext.authenticate(RequestContext(api_key="\xe9")) + + @pytest.mark.asyncio + async def test_authenticate_compares_all_keys_in_constant_time(self, monkeypatch): + # Regression pin for the auth fix: there is no exact-equality fast path + # anymore — an unknown key must run hmac.compare_digest over EVERY + # configured key, on bytes (not str, which would TypeError on non-ASCII). + ext = _make_extension() + compared: list[tuple[bytes, bytes]] = [] + orig = hmac.compare_digest + + def spy(a, b): + compared.append((a, b)) + return orig(a, b) + + monkeypatch.setattr("hindsight_ext_static_keys_tenant.extension.hmac.compare_digest", spy) + + with pytest.raises(AuthenticationError, match="Invalid API key"): + await ext.authenticate(RequestContext(api_key="wrong-key")) + + assert len(compared) == len(ext._key_to_user), "every configured key must be compared" + assert compared, "compare_digest must always run over all keys (no fast path)" + assert all(isinstance(a, bytes) and isinstance(b, bytes) for a, b in compared) + # Configured keys are pre-encoded at init: the compared bytes equal the + # utf-8/surrogateescape encoding of the stored keys. + expected = {entry.key_bytes for entry in ext._key_to_user.values()} + assert {b for _, b in compared} == expected + + @pytest.mark.asyncio + async def test_authenticate_valid_key_matches_preencoded_bytes(self): + # A valid key authenticates through the pre-encoded-bytes comparison. + ext = _make_extension() + mock_context = AsyncMock(spec=ExtensionContext) + mock_context.run_migration = AsyncMock() + ext._context = mock_context + + result = await ext.authenticate(RequestContext(api_key="key-a")) + assert result.schema_name == "user_rafael" + assert ext._key_to_user["key-a"].key_bytes == b"key-a" + + @pytest.mark.asyncio + async def test_authenticate_sets_usage_metering_fields(self): + ext = _make_extension() + mock_context = AsyncMock(spec=ExtensionContext) + mock_context.run_migration = AsyncMock() + ext._context = mock_context + + ctx = RequestContext(api_key="key-a") + await ext.authenticate(ctx) + + assert ctx.tenant_id == "rafael" + # api_key_id identifies *the key*, not the user — with multiple keys + # per user, metering must be able to tell which key was used. It is + # the derived, non-secret key_id (never the key itself). + assert ctx.api_key_id == hashlib.sha256(b"key-a").hexdigest()[:16] + assert ctx.api_key_id != ctx.tenant_id + + @pytest.mark.asyncio + async def test_authenticate_metering_distinguishes_keys_for_same_user(self): + ext = _make_extension(users="rafael:key-a,rafael:key-b") + mock_context = AsyncMock(spec=ExtensionContext) + mock_context.run_migration = AsyncMock() + ext._context = mock_context + + ctx_a = RequestContext(api_key="key-a") + await ext.authenticate(ctx_a) + ctx_b = RequestContext(api_key="key-b") + await ext.authenticate(ctx_b) + + # Same user/tenant, different keys → different api_key_id. + assert ctx_a.tenant_id == ctx_b.tenant_id == "rafael" + assert ctx_a.api_key_id != ctx_b.api_key_id + + @pytest.mark.asyncio + async def test_authenticate_mixed_case_key_maps_to_lowercase_schema(self): + # A user configured with a mixed-case id authenticates onto the + # lowercased schema, matching what the runtime's fq_table() resolves. + ext = _make_extension(users="Rafael:key-a") + mock_context = AsyncMock(spec=ExtensionContext) + mock_context.run_migration = AsyncMock() + ext._context = mock_context + + result = await ext.authenticate(RequestContext(api_key="key-a")) + + assert result.schema_name == "user_rafael" + mock_context.run_migration.assert_called_once_with("user_rafael") + + @pytest.mark.asyncio + async def test_authenticate_provisions_schema_once(self): + ext = _make_extension() + mock_context = AsyncMock(spec=ExtensionContext) + mock_context.run_migration = AsyncMock() + ext._context = mock_context + + await ext.authenticate(RequestContext(api_key="key-a")) + await ext.authenticate(RequestContext(api_key="key-a")) + + mock_context.run_migration.assert_called_once_with("user_rafael") + + @pytest.mark.asyncio + async def test_authenticate_concurrent_first_requests_provision_once(self): + # Two first requests for the same user racing each other must not both + # run migrations: the per-schema lock serializes them and the loser + # skips the (now cached) migration on re-check. + ext = _make_extension() + mock_context = AsyncMock(spec=ExtensionContext) + mock_context.run_migration = AsyncMock() + ext._context = mock_context + + await asyncio.gather( + ext.authenticate(RequestContext(api_key="key-a")), + ext.authenticate(RequestContext(api_key="key-a")), + ) + + mock_context.run_migration.assert_called_once_with("user_rafael") + + @pytest.mark.asyncio + async def test_authenticate_schema_init_failure_not_cached(self): + ext = _make_extension() + mock_context = AsyncMock(spec=ExtensionContext) + mock_context.run_migration = AsyncMock(side_effect=RuntimeError("Migration failed")) + ext._context = mock_context + + with pytest.raises(AuthenticationError, match="Failed to initialize tenant"): + await ext.authenticate(RequestContext(api_key="key-a")) + + assert "user_rafael" not in ext._initialized_schemas + + +class TestStaticKeysTenantExtensionMcp: + """Tests for MCP auth.""" + + @pytest.mark.asyncio + async def test_authenticate_mcp_delegates(self): + ext = _make_extension() + mock_context = AsyncMock(spec=ExtensionContext) + mock_context.run_migration = AsyncMock() + ext._context = mock_context + + with patch.object(ext, "authenticate") as mock_authenticate: + mock_authenticate.return_value = TenantContext(schema_name="user_rafael") + result = await ext.authenticate_mcp(RequestContext(api_key="key-a")) + mock_authenticate.assert_awaited_once() + assert result.schema_name == "user_rafael" + + @pytest.mark.asyncio + async def test_authenticate_mcp_disabled_is_refused_at_init(self): + # The flag would let unauthenticated MCP clients into the base schema + # of a deployment whose whole purpose is per-user isolation — refuse + # at startup (fail-fast contract) instead of silently downgrading auth. + with pytest.raises(ValueError, match="not supported by StaticKeysTenantExtension"): + _make_extension(mcp_auth_disabled="true") + + @pytest.mark.asyncio + async def test_authenticate_mcp_requires_auth(self): + # Without the flag there is no MCP bypass: a bad key is rejected on the + # MCP path exactly as on HTTP. + ext = _make_extension() + with pytest.raises(AuthenticationError, match="Invalid API key"): + await ext.authenticate_mcp(RequestContext(api_key="wrong-key")) + + +class TestStaticKeysTenantExtensionListTenants: + """Tests for list_tenants.""" + + @pytest.mark.asyncio + async def test_list_tenants_returns_all_configured(self): + ext = _make_extension() + tenants = await ext.list_tenants() + assert tenants == [ + Tenant(schema="user_rafael", tenant_id="rafael"), + Tenant(schema="user_sophie", tenant_id="sophie"), + ] + + @pytest.mark.asyncio + async def test_list_tenants_includes_tenant_id(self): + ext = _make_extension() + tenants = await ext.list_tenants() + assert all(t.tenant_id is not None for t in tenants) + assert {t.tenant_id for t in tenants} == {"rafael", "sophie"} + + @pytest.mark.asyncio + async def test_list_tenants_uses_lowercased_user_ids(self): + # Mixed-case ids are normalized at init, so list_tenants() must hand + # the worker the same lowercased tenant_id and schema it authenticates + # onto — otherwise the consolidation reconcile sweep would build a + # RequestContext that never matches an authenticated tenant. + ext = _make_extension(users="Rafael:key-a,Sophie:key-b") + tenants = await ext.list_tenants() + assert tenants == [ + Tenant(schema="user_rafael", tenant_id="rafael"), + Tenant(schema="user_sophie", tenant_id="sophie"), + ] + + +class TestStaticKeysTenantExtensionLoader: + """Tests for loading via the extension loader.""" + + def test_load_via_extension_loader(self, monkeypatch): + monkeypatch.setenv( + "HINDSIGHT_API_TENANT_EXTENSION", + "hindsight_ext_static_keys_tenant:StaticKeysTenantExtension", + ) + monkeypatch.setenv("HINDSIGHT_API_TENANT_USERS", "rafael:key-a,sophie:key-b") + monkeypatch.setenv("HINDSIGHT_API_TENANT_SCHEMA_PREFIX", "tenant") + + ext = load_extension("TENANT", TenantExtension) + + assert ext is not None + assert isinstance(ext, StaticKeysTenantExtension) + assert ext.schema_prefix == "tenant" + assert ext._users == {"rafael": "tenant_rafael", "sophie": "tenant_sophie"}