Skip to content

Commit bd5a461

Browse files
galshubeliclaude
andcommitted
feat(snowflake): add Snowflake loader with key-pair auth and security hardening
Snowflake database loader: - Full schema extraction (tables, columns, PKs, FKs, relationships) - Key-pair authentication support (bypasses MFA) - SHOW PRIMARY KEYS / SHOW IMPORTED KEYS for constraint discovery - Identifier validation and parameterized queries for SQL injection prevention - Connection timeouts (login: 30s, network: 60s) Frontend: - Snowflake option in DatabaseModal with manual/URL entry modes - Key-pair auth UI (password/keypair toggle with PEM textarea) - Custom API key/model passed through ChatService to backend Security: - @token_required on /validate-api-key endpoint - Vendor-specific API key format validation - Narrowed vendor allowlist for key validation - Upgraded fastmcp 3.0.1→3.2.0, litellm→1.83+, aiohttp→3.13.5 Other fixes: - load_dotenv() in config.py for reliable env loading - Memory gracefully disabled for non-Azure/OpenAI providers - Null-safe LLM description generation - Anthropic config fails fast without embeddings - python-dotenv as explicit dependency Tests: 39 tests (20 Snowflake loader + 19 settings route) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1f728b4 commit bd5a461

18 files changed

Lines changed: 1944 additions & 175 deletions

api/app_factory.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import os
66
import secrets
77

8-
from dotenv import load_dotenv
98
from fastapi import FastAPI, Request, HTTPException
109
from fastapi.responses import RedirectResponse, JSONResponse, FileResponse
1110
from fastapi.staticfiles import StaticFiles
@@ -23,9 +22,6 @@
2322
from api.routes.database import database_router
2423
from api.routes.tokens import tokens_router
2524
from api.routes.settings import settings_router
26-
27-
# Load environment variables from .env file
28-
load_dotenv()
2925
logging.basicConfig(
3026
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
3127
)
@@ -61,17 +57,14 @@ def _is_secure_request(request: Request) -> bool:
6157
"""Determine if the request is over HTTPS."""
6258
forwarded_proto = request.headers.get("x-forwarded-proto")
6359
if forwarded_proto:
64-
# Normalize: proxies may send comma-separated or mixed-case values
65-
first_proto = forwarded_proto.split(",")[0].strip().lower()
66-
return first_proto == "https"
60+
return forwarded_proto == "https"
6761
return request.url.scheme == "https"
6862

6963

7064
class CSRFMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-public-methods
7165
"""Double Submit Cookie CSRF protection.
7266
73-
Ensures a csrf_token cookie (readable by JS) exists, setting it
74-
on the response if the incoming request does not already carry one.
67+
Sets a csrf_token cookie (readable by JS) on every response.
7568
State-changing requests must echo the cookie value back
7669
via the X-CSRF-Token header. Bearer-token authenticated
7770
requests and auth/login endpoints are exempt.
@@ -136,7 +129,7 @@ def _ensure_csrf_cookie(self, request: Request, response):
136129
def create_app(): # pylint: disable=too-many-statements
137130
"""Create and configure the FastAPI application."""
138131

139-
# Create the FastAPI app instance with original routes
132+
# Create the FastAPI app instance just to set the o routes
140133
# Will be merged with MCP app later if MCP is enabled
141134
app = FastAPI(
142135
title="QueryWeaver"

api/config.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@
77
import logging
88
import dataclasses
99
from typing import Union
10+
11+
from dotenv import load_dotenv
1012
from litellm import embedding
1113

14+
# Ensure .env is loaded before Config reads os.getenv() at class definition time
15+
load_dotenv()
16+
1217
# Configure litellm logging to prevent sensitive data leakage
1318
def configure_litellm_logging():
1419
"""Configure litellm to suppress completion logs."""
@@ -64,6 +69,9 @@ def _with_prefix(model: str, provider: str) -> str:
6469
return prefix + model.removeprefix(prefix)
6570

6671

72+
SUPPORTED_VENDORS = ("openai", "anthropic", "gemini", "azure", "ollama", "cohere")
73+
74+
6775
@dataclasses.dataclass
6876
class Config:
6977
"""
@@ -103,7 +111,7 @@ class Config:
103111
EMBEDDING_MODEL_NAME = "voyage/voyage-3"
104112
else:
105113
raise ValueError(
106-
"Anthropic has no native embeddings. "
114+
"ANTHROPIC_API_KEY is set, but Anthropic has no native embeddings. "
107115
"Set EMBEDDING_MODEL or VOYAGE_API_KEY for embeddings."
108116
)
109117
elif os.getenv("COHERE_API_KEY"):

api/core/schema_loader.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from api.loaders.base_loader import BaseLoader
1414
from api.loaders.postgres_loader import PostgresLoader
1515
from api.loaders.mysql_loader import MySQLLoader
16+
from api.loaders.snowflake_loader import SnowflakeLoader
1617

1718
# Use the same delimiter as in the JavaScript frontend for streaming chunks
1819
MESSAGE_DELIMITER = "|||FALKORDB_MESSAGE_BOUNDARY|||"
@@ -44,6 +45,9 @@ def _step_detect_db_type(steps_counter: int, url: str) -> tuple[type[BaseLoader]
4445
elif url.startswith("mysql://"):
4546
db_type = "mysql"
4647
loader = MySQLLoader
48+
elif url.startswith("snowflake://"):
49+
db_type = "snowflake"
50+
loader = SnowflakeLoader
4751
else:
4852
raise InvalidArgumentError("Invalid database URL format")
4953

api/core/text2sql.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
from api.agents import AnalysisAgent, RelevancyAgent, ResponseFormatterAgent, FollowUpAgent
1616
from api.agents.healer_agent import HealerAgent
1717
from api.config import Config
18+
from api.config import SUPPORTED_VENDORS
1819
from api.extensions import db
1920
from api.graph import find, get_db_description, get_user_rules
2021
from api.loaders.postgres_loader import PostgresLoader
2122
from api.loaders.mysql_loader import MySQLLoader
23+
from api.loaders.snowflake_loader import SnowflakeLoader
2224
from api.memory.graphiti_tool import MemoryTool
2325
from api.sql_utils import SQLIdentifierQuoter, DatabaseSpecificQuoter
2426

@@ -83,6 +85,8 @@ def get_database_type_and_loader(db_url: str):
8385
return 'postgresql', PostgresLoader
8486
if db_url_lower.startswith('mysql://'):
8587
return 'mysql', MySQLLoader
88+
if db_url_lower.startswith('snowflake://'):
89+
return 'snowflake', SnowflakeLoader
8690

8791
# Default to PostgresLoader for backward compatibility
8892
return 'postgresql', PostgresLoader
@@ -257,20 +261,28 @@ async def generate(): # pylint: disable=too-many-locals,too-many-branches,too-m
257261
custom_model = chat_data.custom_model
258262

259263
# Validate custom model format (vendor/model)
260-
supported_vendors = ("openai", "anthropic", "gemini", "azure", "ollama", "cohere")
261264
if custom_model:
262265
parts = custom_model.split("/", 1)
263266
if len(parts) != 2 or not parts[0] or not parts[1]:
264267
raise InvalidArgumentError(
265268
"Invalid model format. Expected 'vendor/model' (e.g. 'openai/gpt-4.1')"
266269
)
267-
if parts[0] not in supported_vendors:
270+
if parts[0] not in SUPPORTED_VENDORS:
268271
raise InvalidArgumentError(
269-
f"Unsupported vendor '{parts[0]}'. Supported: {', '.join(supported_vendors)}"
272+
f"Unsupported vendor '{parts[0]}'. Supported: {', '.join(SUPPORTED_VENDORS)}"
270273
)
271274

272-
if custom_api_key is not None and len(custom_api_key.strip()) < 10:
273-
raise InvalidArgumentError("API key is too short")
275+
if custom_api_key is not None:
276+
key = custom_api_key.strip()
277+
if len(key) < 10:
278+
raise InvalidArgumentError("API key is too short")
279+
# Validate key format for known vendors
280+
if custom_model:
281+
vendor = custom_model.split("/", 1)[0]
282+
if vendor == "openai" and not key.startswith("sk-"):
283+
raise InvalidArgumentError("Invalid OpenAI API key format (expected 'sk-' prefix)")
284+
if vendor == "anthropic" and not key.startswith("sk-ant-"):
285+
raise InvalidArgumentError("Invalid Anthropic API key format (expected 'sk-ant-' prefix)")
274286

275287
agent_rel = RelevancyAgent(queries_history, result_history, custom_api_key, custom_model)
276288
agent_an = AnalysisAgent(queries_history, result_history, custom_api_key, custom_model)

api/index.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
"""Main entry point for the text2sql API."""
22

3-
from api.app_factory import create_app
3+
# Load .env before any app imports that read os.getenv at module level
4+
from dotenv import load_dotenv
5+
load_dotenv()
6+
7+
from api.app_factory import create_app # pylint: disable=wrong-import-position
48

59
app = create_app()
610

0 commit comments

Comments
 (0)