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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ TOKEN_FILE_PATH=tokens.json
ENABLE_TOKEN_EXPIRY=true
DEFAULT_TOKEN_EXPIRY_HOURS=720
TOKEN_HASH_ALGORITHM=sha256
# Cache successful token-bound Doris route checks so MCP pings and discovery
# requests do not reconnect on every request. Set 0 to validate every request.
TOKEN_DB_VALIDATION_TTL_SECONDS=30
# No static bearer token is shipped. Generate one outside source control:
# python -c 'import secrets; print(secrets.token_urlsafe(32))'
# TOKEN_ADMIN=<paste-generated-value-here>
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ under **Unreleased** until a new version is selected and published.

- Preserved service availability after malformed requests, unknown methods,
header mismatches, unsupported versions, and missing capabilities.
- Kept static-token Doris pools usable after repeated query timeouts, and
stopped per-request token validation from mutating the shared global pool.
- Stopped list operations from converting Doris, permission, or internal
failures into successful empty resource, tool, or prompt collections.
- Corrected resource and prompt error semantics and tool `isError` results.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,7 @@ The Doris MCP Server includes a comprehensive enterprise-grade security framewor
* **🔐 Multi-Authentication System**: Complete Token, JWT, and OAuth authentication with independent control switches
* **🔗 Token-Bound Database Configuration**: Revolutionary approach allowing tokens to carry their own database connection parameters
* **🔄 Hot Reload Security**: Zero-downtime security configuration updates with intelligent token revalidation
* **⚡ Immediate Validation**: Real-time database and authentication validation at connection time
* **⚡ Route-Safe Validation**: Token-bound Doris routes are validated through their dedicated pools, with a short success cache so pings do not reconnect on every request
* **🛡️ Role-Based Authorization**: Advanced RBAC with four-tier security classification
* **🚫 Enhanced SQL Security**: Advanced SQL injection protection with improved pattern detection
* **🎭 Intelligent Data Masking**: Automatic sensitive data masking with user-based permissions
Expand All @@ -819,6 +819,7 @@ ENABLE_OAUTH_AUTH=false # Enable OAuth authentication
# Token Management (New in v0.6.0)
TOKEN_FILE_PATH=tokens.json # Token configuration file
TOKEN_HOT_RELOAD=true # Enable hot reloading
TOKEN_DB_VALIDATION_TTL_SECONDS=30 # Cache successful Doris route checks

# Legacy Configuration (Deprecated)
# AUTH_TYPE=token # Use individual switches instead
Expand Down
16 changes: 16 additions & 0 deletions doris_mcp_server/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ class SecurityConfig:
enable_token_expiry: bool = True # Enable token expiration
default_token_expiry_hours: int = 24 * 30 # Default expiry: 30 days
token_hash_algorithm: str = "sha256" # Token hashing algorithm: sha256, sha512
token_db_validation_ttl_seconds: int = 30

# Token Management Security (New in v0.6.0)
enable_http_token_management: bool = False # Enable HTTP token management endpoints (default: disabled for security)
Expand Down Expand Up @@ -1288,6 +1289,12 @@ def from_env(cls, env_file: str | None = None) -> "DorisConfig":
os.getenv("DEFAULT_TOKEN_EXPIRY_HOURS", str(config.security.default_token_expiry_hours))
)
config.security.token_hash_algorithm = os.getenv("TOKEN_HASH_ALGORITHM", config.security.token_hash_algorithm)
config.security.token_db_validation_ttl_seconds = int(
os.getenv(
"TOKEN_DB_VALIDATION_TTL_SECONDS",
str(config.security.token_db_validation_ttl_seconds),
)
)

# Token Management Security Configuration (New in v0.6.0)
config.security.enable_http_token_management = (
Expand Down Expand Up @@ -1780,6 +1787,11 @@ def validate(self) -> list[str]:
if self.security.token_expiry <= 0:
errors.append("Token expiry time must be greater than 0")

if not 0 <= self.security.token_db_validation_ttl_seconds <= 3600:
errors.append(
"Token database validation TTL must be in the range 0-3600 seconds"
)

if self.security.max_query_complexity <= 0:
errors.append("Maximum query complexity must be greater than 0")

Expand Down Expand Up @@ -2052,6 +2064,10 @@ def normalize_effective_auth_config(
)
except ValueError as exc:
raise AuthConfigError(str(exc)) from exc
if not 0 <= config.security.token_db_validation_ttl_seconds <= 3600:
raise AuthConfigError(
"TOKEN_DB_VALIDATION_TTL_SECONDS must be in the range 0-3600"
)

modern_auth_explicit = any(
[
Expand Down
94 changes: 41 additions & 53 deletions doris_mcp_server/utils/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1263,7 +1263,11 @@ async def get_connection_for_token(

except Exception as e:
self.logger.error(
f"Session {session_id}: Failed to acquire connection from token pool: {e}"
"Session %s: Failed to acquire connection from token pool "
"(%s): %s",
session_id,
type(e).__name__,
str(e) or "<no message>",
)
raise

Expand Down Expand Up @@ -1368,7 +1372,7 @@ async def close_all_token_pools(self) -> None:
self._token_pool_generations.clear()

async def configure_for_token(self, token: str) -> tuple[bool, str]:
"""Configure connection manager for token with new priority logic
"""Validate the database route selected for a static token.

Priority: Token-bound DB config > .env config > error

Expand All @@ -1381,61 +1385,45 @@ async def configure_for_token(self, token: str) -> tuple[bool, str]:
Raises:
RuntimeError: If no valid database configuration is available
"""
try:
# Priority 1: Try token-bound database config first
if self.token_manager:
db_config = self.token_manager.get_database_config_by_token(token)
if db_config:
# Convert DatabaseConfig to dictionary
token_db_config: DatabasePoolConfig = {
"host": db_config.host,
"port": db_config.port,
"user": db_config.user,
"password": db_config.password,
"database": db_config.database,
"charset": db_config.charset,
}

# Check if token-bound config is valid
if not self._is_config_empty(
token_db_config["host"]
) and not self._is_config_empty(token_db_config["user"]):
self.logger.info(
f"Using token-bound database configuration for host: {token_db_config['host']}"
)
self.active_db_config = token_db_config
self._update_db_params_from_config(self.active_db_config)

# Create/recreate connection pool with token-bound config
await self._ensure_pool_with_current_config()

return True, "token-bound"
current_token_config = self._get_current_token_db_config(token)
uses_token_config = bool(
current_token_config
and not self._is_config_empty(current_token_config.get("host"))
and not self._is_config_empty(current_token_config.get("user"))
)
config_source = "token-bound" if uses_token_config else "global-env"

# Priority 2: Use global .env config if available
if self._has_valid_global_config():
self.logger.info("Using global .env database configuration")
self.active_db_config = self.original_db_config.copy()
self._update_db_params_from_config(self.active_db_config)

# Create/recreate connection pool with global config
await self._ensure_pool_with_current_config()

return True, "global-env"

# Priority 3: No valid configuration available
error_msg = (
"No valid database configuration available for this token. "
"Please contact administrator to:\n"
"1. Add database configuration to tokens.json for this token, OR\n"
"2. Configure valid global database settings in .env file\n"
"Required fields: DB_HOST, DB_USER"
connection: DorisConnection | None = None
try:
# Validate the same dedicated route that query execution will use.
# Never mutate active_db_config or rebuild the shared global pool
# while authenticating a tenant token.
connection = await self.get_connection_for_token(
token,
f"token_validation_{self._get_token_hash(token)[:8]}",
)
self.logger.error(error_msg)
raise RuntimeError(error_msg)

result = await asyncio.wait_for(
connection.execute(
"SELECT 1 AS connection_check",
mask_result=False,
max_rows=1,
max_bytes=256,
),
timeout=self.connect_timeout,
)
if result.data != [{"connection_check": 1}]:
raise RuntimeError("Token database validation query returned no result")
return True, config_source
except Exception as e:
self.logger.error(f"Failed to configure database for token: {e}")
self.logger.error(
"Failed to validate database route for token (%s): %s",
type(e).__name__,
str(e) or "<no message>",
)
raise
finally:
if connection is not None:
await self.release_connection_for_token(token, connection)

async def _ensure_pool_with_current_config(self) -> None:
"""Ensure connection pool exists with current configuration"""
Expand Down
1 change: 1 addition & 0 deletions doris_mcp_server/utils/secret_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"TOKEN_FILE_PATH",
"TOKEN_HASH_ALGORITHM",
"TOKEN_HOT_RELOAD",
"TOKEN_DB_VALIDATION_TTL_SECONDS",
"TOKEN_SECRET",
}
)
Expand Down
99 changes: 79 additions & 20 deletions doris_mcp_server/utils/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@

from __future__ import annotations

import asyncio
import hashlib
import re
import time
from collections.abc import Callable, Mapping
from contextvars import ContextVar
from contextvars import Token as ContextToken
Expand Down Expand Up @@ -191,6 +194,8 @@ def __init__(

# Track initialization state
self._initialized = False
self._token_database_validation_cache: dict[str, float] = {}
self._token_database_validation_locks: dict[str, asyncio.Lock] = {}

async def initialize(self) -> None:
"""Initialize security manager components"""
Expand All @@ -215,6 +220,8 @@ async def shutdown(self) -> None:
"""Shutdown security manager components"""
try:
await self.auth_provider.shutdown()
self._token_database_validation_cache.clear()
self._token_database_validation_locks.clear()
self._initialized = False
self.logger.info("DorisSecurityManager shutdown completed")

Expand Down Expand Up @@ -484,10 +491,12 @@ async def _validate_token_database_config(
token: str,
token_info: TokenInfo,
) -> None:
"""Validate database configuration for token immediately during authentication
"""Validate a token database route with a short successful-result cache.

This ensures database connectivity issues are caught at authentication time,
not during query execution, providing better user experience.
Authentication runs for every MCP request, including pings and capability
discovery. Reconnecting to Doris on each request creates avoidable pool
pressure, so successful validations are cached by token and database
configuration fingerprint. Failures are never cached.

Args:
token: Raw authentication token
Expand All @@ -496,29 +505,79 @@ async def _validate_token_database_config(
Raises:
ValueError: If database configuration is invalid or connection fails
"""
try:
if not self.connection_manager:
self.logger.warning(
"Connection manager not available for immediate database validation"
)
return
if not self.connection_manager:
self.logger.warning(
"Connection manager not available for immediate database validation"
)
return

# Configure and test database connection for this token
success, config_source = await self.connection_manager.configure_for_token(
token
cache_key = self._token_database_validation_cache_key(token, token_info)
ttl_seconds = max(
0,
int(self.config.security.token_db_validation_ttl_seconds),
)
now = time.monotonic()
if self._token_database_validation_cache.get(cache_key, 0.0) > now:
self.logger.debug(
"Using cached database validation for token %s",
token_info.token_id,
)
return

if success:
lock = self._token_database_validation_locks.setdefault(
cache_key,
asyncio.Lock(),
)
async with lock:
now = time.monotonic()
if self._token_database_validation_cache.get(cache_key, 0.0) > now:
return
try:
success, config_source = (
await self.connection_manager.configure_for_token(token)
)
if not success:
raise ValueError("Database configuration validation failed")
if ttl_seconds:
self._token_database_validation_cache[cache_key] = (
time.monotonic() + ttl_seconds
)
self.logger.info(
f"Database configuration validated successfully for token {token_info.token_id} (source: {config_source})"
"Database configuration validated successfully for token %s "
"(source: %s)",
token_info.token_id,
config_source,
)
else:
raise ValueError("Database configuration validation failed")
except Exception as e:
self._token_database_validation_cache.pop(cache_key, None)
error_msg = (
"Database configuration validation failed for token "
f"{token_info.token_id}: {str(e) or type(e).__name__}"
)
self.logger.error(error_msg)
raise ValueError(error_msg) from e

except Exception as e:
error_msg = f"Database configuration validation failed for token {token_info.token_id}: {str(e)}"
self.logger.error(error_msg)
raise ValueError(error_msg)
@staticmethod
def _token_database_validation_cache_key(
token: str,
token_info: TokenInfo,
) -> str:
"""Return a non-secret cache key that changes with bound DB credentials."""
database_config = token_info.database_config
route = (
getattr(database_config, "host", ""),
getattr(database_config, "port", ""),
getattr(database_config, "user", ""),
getattr(database_config, "password", ""),
getattr(database_config, "database", ""),
getattr(database_config, "charset", ""),
)
digest = hashlib.sha256()
digest.update(token.encode("utf-8"))
for value in route:
digest.update(b"\0")
digest.update(str(value).encode("utf-8"))
return digest.hexdigest()


class AuthenticationProvider:
Expand Down
Loading