blob: 1f475785a9ebafac28f20c59b93c851b5f9c4585 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Doris Security Management Module
Implements enterprise-level authentication, authorization, SQL security validation and data masking functionality
"""
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
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING, Any
import sqlparse
from sqlparse.sql import Statement
from sqlparse.tokens import Keyword, Name
from .auth_credentials import (
EMPTY_CREDENTIAL,
BearerCredentials,
normalize_bearer_credentials,
)
from .config import (
DorisConfig,
EffectiveAuthConfig,
get_effective_auth_config,
)
from .datetime_utils import utc_now
from .logger import get_logger
if TYPE_CHECKING:
from ..auth.doris_oauth_provider import DorisOAuthProvider
from ..auth.jwt_manager import JWTManager
from ..auth.oauth_provider import OAuthAuthenticationProvider
from ..auth.token_manager import (
DatabaseConfig as TokenDatabaseConfig,
)
from ..auth.token_manager import (
TokenInfo,
TokenManager,
)
from .db import DorisConnectionManager
# Global ContextVar for auth_context - must be a single instance shared across all modules
# This allows token-bound database configuration to work correctly in concurrent requests
mcp_auth_context_var: ContextVar[AuthContext | None] = ContextVar(
"mcp_auth_context",
default=None,
)
RESERVED_DORIS_OAUTH_PREFIX = "doa_"
# Backward-compatible name; the value is a public token namespace, not a secret.
RESERVED_DORIS_OAUTH_TOKEN_PREFIX = RESERVED_DORIS_OAUTH_PREFIX
ANONYMOUS_PRINCIPAL = "anonymous"
ANONYMOUS_SESSION_ID = "anonymous_session"
class SecurityLevel(Enum):
"""Security level enumeration"""
PUBLIC = "public"
INTERNAL = "internal"
CONFIDENTIAL = "confidential"
# Bandit audit: classification wire value, never credential material.
SECRET = "secret" # nosec B105
@dataclass
class AuthContext:
"""Authentication context for audit and session tracking"""
token_id: str = "" # Token identifier for audit logging
user_id: str = "" # User identifier
roles: list[str] = field(default_factory=list) # User roles
permissions: list[str] = field(default_factory=list) # User permissions
security_level: SecurityLevel = field(
default_factory=lambda: SecurityLevel.INTERNAL
) # Security level
client_ip: str = "unknown" # Client IP address
session_id: str = "" # Session identifier
login_time: datetime = field(default_factory=utc_now)
last_activity: datetime | None = None
token: str = "" # Raw token for token-bound database configuration
auth_method: str = "" # anonymous, token, jwt, external_oauth, doris_oauth
doris_user: str = ""
oauth_client_id: str = ""
oauth_scopes: list[str] = field(default_factory=list)
oauth_token_id: str = ""
oauth_issuer: str = ""
oauth_resource: str = ""
oauth_audiences: list[str] = field(default_factory=list)
pool_key: str = ""
semantic_tools_enabled: bool = False
semantic_resources_enabled: bool = False
doris_oauth_child_tools_enabled: bool = False
doris_oauth_child_tool_allowlist: tuple[str, ...] = field(
default_factory=tuple
)
doris_oauth_db_tools_enabled: bool = False
doris_oauth_db_tool_allowlist: tuple[str, ...] = field(default_factory=tuple)
doris_oauth_query_tools_enabled: bool = False
doris_oauth_query_tool_allowlist: tuple[str, ...] = field(default_factory=tuple)
doris_oauth_explain_tools_enabled: bool = False
doris_oauth_explain_tool_allowlist: tuple[str, ...] = field(default_factory=tuple)
def get_current_auth_context() -> AuthContext | None:
"""Return the current request auth context."""
return mcp_auth_context_var.get()
def set_current_auth_context(
auth_context: AuthContext,
) -> ContextToken[AuthContext | None]:
"""Set auth context and return the ContextVar token for reset."""
return mcp_auth_context_var.set(auth_context)
def clear_current_auth_context() -> ContextToken[AuthContext | None]:
"""Force-clear auth context and return the ContextVar token."""
return mcp_auth_context_var.set(None)
def reset_auth_context(token: ContextToken[AuthContext | None]) -> None:
"""Reset auth context using the token returned by set_current_auth_context."""
mcp_auth_context_var.reset(token)
@dataclass
class ValidationResult:
"""Validation result"""
is_valid: bool
error_message: str | None = None
risk_level: str = "low"
blocked_operations: list[str] = field(default_factory=list)
@dataclass
class MaskingRule:
"""Data masking rule"""
column_pattern: str
algorithm: str
parameters: dict[str, Any]
security_level: SecurityLevel
MaskingAlgorithm = Callable[[str, dict[str, Any]], str]
class DorisSecurityManager:
"""Doris security manager
Provides complete security control functionality, including authentication, authorization, SQL security validation and data masking
"""
def __init__(
self,
config: DorisConfig,
connection_manager: DorisConnectionManager | None = None,
) -> None:
self.config = config
self.logger = get_logger(__name__)
self.connection_manager = connection_manager
# Initialize security components
self.auth_provider = AuthenticationProvider(config, self)
self.authz_provider = AuthorizationProvider(config)
self.sql_validator = SQLSecurityValidator(config)
self.masking_processor = DataMaskingProcessor(config)
# Security rule configuration
self.blocked_keywords = self._load_blocked_keywords()
self.sensitive_tables = self._load_sensitive_tables()
self.masking_rules = self._load_masking_rules()
# 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"""
if self._initialized:
return
try:
# Initialize authentication provider (for JWT setup)
await self.auth_provider.initialize()
self._initialized = True
self.logger.info("DorisSecurityManager initialized successfully")
except Exception as e:
self.logger.error(f"Failed to initialize DorisSecurityManager: {e}")
raise
def _get_effective_auth_config(self) -> EffectiveAuthConfig:
return get_effective_auth_config(self.config)
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")
except Exception as e:
self.logger.error(f"Error during DorisSecurityManager shutdown: {e}")
raise
def _load_blocked_keywords(self) -> set[str]:
"""Load blocked SQL keywords from configuration"""
return set(self.config.security.blocked_keywords)
def _load_sensitive_tables(self) -> dict[str, SecurityLevel]:
"""Load sensitive table configuration"""
default_tables = {
"user_info": SecurityLevel.CONFIDENTIAL,
"payment_records": SecurityLevel.SECRET,
"employee_data": SecurityLevel.CONFIDENTIAL,
"public_reports": SecurityLevel.PUBLIC,
}
for table_name, level in self.config.security.sensitive_tables.items():
try:
default_tables[table_name] = SecurityLevel(level.lower())
except ValueError:
default_tables[table_name] = SecurityLevel.INTERNAL
return default_tables
def _load_masking_rules(self) -> list[MaskingRule]:
"""Load data masking rules"""
default_rules = [
MaskingRule(
column_pattern=r".*phone.*|.*mobile.*",
algorithm="phone_mask",
parameters={"mask_char": "*", "keep_prefix": 3, "keep_suffix": 4},
security_level=SecurityLevel.INTERNAL,
),
MaskingRule(
column_pattern=r".*email.*",
algorithm="email_mask",
parameters={"mask_char": "*"},
security_level=SecurityLevel.INTERNAL,
),
MaskingRule(
column_pattern=r".*id_card.*|.*identity.*",
algorithm="id_mask",
parameters={"mask_char": "*", "keep_prefix": 6, "keep_suffix": 4},
security_level=SecurityLevel.CONFIDENTIAL,
),
]
# Load custom rules from configuration
for rule_config in self.config.security.masking_rules:
default_rules.append(MaskingRule(**rule_config))
return default_rules
async def authenticate_request(
self,
auth_input: BearerCredentials | Mapping[str, Any],
) -> AuthContext:
"""Validate request authentication information
Tries authentication methods in normalized effective config order.
Any one method succeeding allows access
If all methods are disabled, returns anonymous context
"""
effective_auth = self._get_effective_auth_config()
credentials = normalize_bearer_credentials(auth_input)
legacy_auth_info: dict[str, Any] | None = None
if isinstance(auth_input, Mapping):
legacy_auth_info = dict(auth_input)
legacy_auth_type = (
str(legacy_auth_info.get("type") or "") if legacy_auth_info else ""
)
if not effective_auth.auth_methods:
if legacy_auth_type and legacy_auth_info is not None:
return await self.auth_provider.authenticate(legacy_auth_info)
self.logger.debug("All authentication methods are disabled")
return AuthContext(
token_id=ANONYMOUS_PRINCIPAL,
user_id=ANONYMOUS_PRINCIPAL,
roles=[ANONYMOUS_PRINCIPAL],
permissions=["read"],
security_level=SecurityLevel.PUBLIC,
client_ip=credentials.client_ip,
session_id=ANONYMOUS_SESSION_ID,
auth_method=ANONYMOUS_PRINCIPAL,
pool_key="global",
)
last_error = None
for auth_method in effective_auth.auth_methods:
try:
if auth_method == "doris_oauth":
return await self.auth_provider.authenticate_doris_oauth(
credentials
)
if auth_method == "token":
return await self.auth_provider.authenticate_token(credentials)
if auth_method == "jwt":
return await self.auth_provider.authenticate_jwt(credentials)
if auth_method == "external_oauth":
return await self.auth_provider.authenticate_oauth(credentials)
except Exception as e:
self.logger.debug(f"{auth_method} authentication failed: {e}")
last_error = e
if auth_method == "doris_oauth" and credentials.token.startswith(
RESERVED_DORIS_OAUTH_TOKEN_PREFIX
):
raise
# All enabled authentication methods failed
from ..auth.oauth_token_validation import (
OAuthAccessTokenValidationError,
)
if isinstance(last_error, OAuthAccessTokenValidationError):
raise last_error
error_message = (
f"Authentication failed: {str(last_error)}"
if last_error
else "No authentication method succeeded"
)
self.logger.warning(
f"Authentication failed for client {credentials.client_ip}: {error_message}"
)
raise ValueError(error_message)
async def authorize_resource_access(
self, auth_context: AuthContext, resource_uri: str
) -> bool:
"""Validate resource access permissions"""
return await self.authz_provider.check_permission(
auth_context, resource_uri, "read"
)
async def validate_sql_security(
self, sql: str, auth_context: AuthContext
) -> ValidationResult:
"""Validate SQL query security"""
return await self.sql_validator.validate(sql, auth_context)
async def apply_data_masking(
self, data: list[dict[str, Any]], auth_context: AuthContext
) -> list[dict[str, Any]]:
"""Apply data masking processing"""
return await self.masking_processor.process(data, auth_context)
# OAuth-specific methods
def get_oauth_authorization_url(self) -> tuple[str, str]:
"""Get OAuth authorization URL
Returns:
Tuple of (authorization_url, state)
"""
if not self.auth_provider.oauth_provider:
raise ValueError("OAuth is not enabled")
return self.auth_provider.oauth_provider.get_authorization_url()
async def handle_oauth_callback(self, code: str, state: str) -> AuthContext:
"""Handle OAuth callback
Args:
code: Authorization code from OAuth provider
state: State parameter for CSRF protection
Returns:
AuthContext for authenticated user
"""
if not self.auth_provider.oauth_provider:
raise ValueError("OAuth is not enabled")
return await self.auth_provider.oauth_provider.handle_callback(code, state)
def get_oauth_provider_info(self) -> dict[str, Any]:
"""Get OAuth provider information
Returns:
OAuth provider information
"""
if not self.auth_provider.oauth_provider:
return {"enabled": False}
return self.auth_provider.oauth_provider.get_provider_info()
# Token management methods
async def create_token(
self,
token_id: str,
expires_hours: int | None = None,
description: str = "",
custom_token: str | None = None,
database_config: TokenDatabaseConfig | None = None,
) -> str:
"""Create a new API access token
Args:
token_id: Unique token identifier for audit and management
expires_hours: Token expiration in hours (None for no expiration)
description: Token description for management purposes
custom_token: Custom token string (if None, generates random token)
database_config: Optional database configuration for this token
Returns:
Generated token string
"""
if not self.auth_provider.token_manager:
raise ValueError("Token manager not initialized")
return await self.auth_provider.token_manager.create_token(
token_id=token_id,
expires_hours=expires_hours,
description=description,
custom_token=custom_token,
database_config=database_config,
)
async def revoke_token(self, token_id: str) -> bool:
"""Revoke a token by token ID
Args:
token_id: Token ID to revoke
Returns:
True if token was revoked successfully
"""
if not self.auth_provider.token_manager:
raise ValueError("Token manager not initialized")
return await self.auth_provider.token_manager.revoke_token(token_id)
async def list_tokens(self) -> list[dict[str, Any]]:
"""List all tokens (without sensitive data)
Returns:
List of token information
"""
if not self.auth_provider.token_manager:
raise ValueError("Token manager not initialized")
return await self.auth_provider.token_manager.list_tokens()
async def cleanup_expired_tokens(self) -> int:
"""Remove expired tokens and return count
Returns:
Number of expired tokens removed
"""
if not self.auth_provider.token_manager:
return 0
return await self.auth_provider.token_manager.cleanup_expired_tokens()
def get_token_stats(self) -> dict[str, Any]:
"""Get token statistics
Returns:
Token statistics dictionary
"""
if not self.auth_provider.token_manager:
return {"error": "Token manager not initialized"}
return self.auth_provider.token_manager.get_token_stats()
async def _validate_token_database_config(
self,
token: str,
token_info: TokenInfo,
) -> None:
"""Validate a token database route with a short successful-result cache.
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
token_info: TokenInfo object from token validation
Raises:
ValueError: If database configuration is invalid or connection fails
"""
if not self.connection_manager:
self.logger.warning(
"Connection manager not available for immediate database validation"
)
return
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
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(
"Database configuration validated successfully for token %s "
"(source: %s)",
token_info.token_id,
config_source,
)
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
@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:
"""Authentication provider"""
def __init__(
self,
config: DorisConfig,
security_manager: DorisSecurityManager | None = None,
) -> None:
self.config = config
self.logger = get_logger(__name__)
self.session_cache: dict[str, AuthContext] = {}
self.jwt_manager: JWTManager | None = None
self.oauth_provider: OAuthAuthenticationProvider | None = None
self.doris_oauth_provider: DorisOAuthProvider | None = None
self.token_manager: TokenManager | None = None
self.security_manager = security_manager
self.effective_auth = get_effective_auth_config(config)
# Initialize authentication providers based on individual switches
auth_methods_enabled = []
# Initialize Token manager if enabled
if self.effective_auth.enable_token_auth:
self._initialize_token_manager()
auth_methods_enabled.append("Token")
# Initialize JWT manager if enabled
if self.effective_auth.enable_jwt_auth:
self._initialize_jwt_manager()
auth_methods_enabled.append("JWT")
# Initialize OAuth provider if enabled
if self.effective_auth.enable_external_oauth_auth:
self._initialize_oauth_provider()
auth_methods_enabled.append("OAuth")
if self.effective_auth.enable_doris_oauth_auth:
self._initialize_doris_oauth_provider()
auth_methods_enabled.append("Doris OAuth")
if auth_methods_enabled:
self.logger.info(
f"Authentication enabled with methods: {', '.join(auth_methods_enabled)}"
)
else:
self.logger.info(
"All authentication methods are disabled - anonymous access allowed"
)
def _initialize_jwt_manager(self) -> None:
"""Initialize JWT manager"""
try:
from ..auth.jwt_manager import JWTManager
self.jwt_manager = JWTManager(self.config)
self.logger.info("JWT manager initialized")
except ImportError as e:
self.logger.error(f"Failed to import JWT manager: {e}")
raise
except Exception as e:
self.logger.error(f"Failed to initialize JWT manager: {e}")
raise
def _initialize_token_manager(self) -> None:
"""Initialize Token manager"""
try:
from ..auth.token_manager import TokenManager
self.token_manager = TokenManager(self.config)
self.logger.info("Token manager initialized")
except ImportError as e:
self.logger.error(f"Failed to import Token manager: {e}")
raise
except Exception as e:
self.logger.error(f"Failed to initialize Token manager: {e}")
raise
def _initialize_oauth_provider(self) -> None:
"""Initialize OAuth provider"""
try:
from ..auth.oauth_provider import OAuthAuthenticationProvider
self.oauth_provider = OAuthAuthenticationProvider(self.config)
self.logger.info("OAuth provider initialized")
except ImportError as e:
self.logger.error(f"Failed to import OAuth provider: {e}")
raise
except Exception as e:
self.logger.error(f"Failed to initialize OAuth provider: {e}")
raise
def _initialize_doris_oauth_provider(self) -> None:
"""Initialize Doris-backed OAuth provider shell."""
try:
from ..auth.doris_oauth_provider import DorisOAuthProvider
self.doris_oauth_provider = DorisOAuthProvider(self.config)
self.logger.info("Doris OAuth provider initialized")
except ImportError as e:
self.logger.error(f"Failed to import Doris OAuth provider: {e}")
raise
except Exception as e:
self.logger.error(f"Failed to initialize Doris OAuth provider: {e}")
raise
def configure_doris_oauth(
self,
connection_manager: DorisConnectionManager,
) -> None:
"""Inject the Phase 2 connection manager after it is created."""
if self.doris_oauth_provider:
self.doris_oauth_provider.configure_connection_manager(connection_manager)
async def initialize(self) -> None:
"""Initialize authentication provider asynchronously"""
if self.jwt_manager:
success = await self.jwt_manager.initialize()
if not success:
raise RuntimeError("Failed to initialize JWT manager")
self.logger.info("JWT authentication provider initialized successfully")
if self.token_manager:
# Token manager doesn't need async initialization, just log success
self.logger.info("Token authentication provider initialized successfully")
if self.oauth_provider:
success = await self.oauth_provider.initialize()
if not success:
raise RuntimeError("Failed to initialize OAuth provider")
self.logger.info("OAuth authentication provider initialized successfully")
async def shutdown(self) -> None:
"""Shutdown authentication provider"""
if self.jwt_manager:
await self.jwt_manager.shutdown()
self.logger.info("JWT authentication provider shutdown completed")
if self.token_manager:
# Token manager doesn't need async shutdown, just log
self.logger.info("Token authentication provider shutdown completed")
if self.oauth_provider:
await self.oauth_provider.shutdown()
self.logger.info("OAuth authentication provider shutdown completed")
if self.doris_oauth_provider:
await self.doris_oauth_provider.shutdown()
self.logger.info("Doris OAuth authentication provider shutdown completed")
async def authenticate(self, auth_info: dict[str, Any]) -> AuthContext:
"""Legacy direct authentication entrypoint.
Runtime HTTP auth goes through DorisSecurityManager.authenticate_request()
and EffectiveAuthConfig. This method is kept for existing direct callers
that pass an explicit auth_info["type"].
"""
auth_type = str(auth_info.get("type") or "").strip().lower()
credentials = normalize_bearer_credentials(auth_info)
if auth_type == "token":
if not self.effective_auth.enable_token_auth:
raise ValueError("Token authentication is not enabled")
if not self.token_manager:
raise ValueError("Token manager is not initialized")
return await self.authenticate_token(credentials)
if auth_type == "basic":
raise ValueError(
"Basic authentication is not supported; use a configured "
"Bearer or OAuth authentication provider"
)
if auth_type == "jwt":
return await self.authenticate_jwt(credentials)
if auth_type == "oauth":
if "code" in auth_info and "state" in auth_info:
if not self.effective_auth.enable_external_oauth_auth:
raise ValueError("OAuth authentication is not enabled")
if not self.oauth_provider:
raise ValueError("OAuth provider not initialized")
auth_context = await self.oauth_provider.handle_callback(
auth_info["code"],
auth_info["state"],
)
auth_context.auth_method = "external_oauth"
auth_context.token = EMPTY_CREDENTIAL
auth_context.pool_key = "global"
return auth_context
return await self.authenticate_oauth(credentials)
if auth_type == "doris_oauth":
return await self.authenticate_doris_oauth(credentials)
raise ValueError(f"Unsupported authentication type: {auth_type or '<missing>'}")
async def authenticate_token(
self,
credentials: BearerCredentials,
) -> AuthContext:
"""Perform token authentication"""
if not self.effective_auth.enable_token_auth:
raise ValueError("Token authentication is not enabled")
return await self._authenticate_token(credentials)
async def authenticate_jwt(
self,
credentials: BearerCredentials,
) -> AuthContext:
"""Perform JWT authentication"""
if not self.effective_auth.enable_jwt_auth:
raise ValueError("JWT authentication is not enabled")
return await self._authenticate_jwt(credentials)
async def authenticate_oauth(
self,
credentials: BearerCredentials,
) -> AuthContext:
"""Perform OAuth authentication"""
if not self.effective_auth.enable_external_oauth_auth:
raise ValueError("OAuth authentication is not enabled")
return await self._authenticate_oauth(credentials)
async def authenticate_doris_oauth(
self,
credentials: BearerCredentials,
) -> AuthContext:
"""Authenticate a Doris OAuth doa_ access token."""
if not self.effective_auth.enable_doris_oauth_auth:
raise ValueError("Doris OAuth authentication is not enabled")
if not credentials.is_bearer or not credentials.token.startswith(
RESERVED_DORIS_OAUTH_TOKEN_PREFIX
):
raise ValueError("Missing Doris OAuth access token")
if not self.doris_oauth_provider:
raise ValueError("Doris OAuth provider is not initialized")
return await self.doris_oauth_provider.authenticate_access_token(credentials)
async def _authenticate_jwt(
self,
credentials: BearerCredentials,
) -> AuthContext:
"""JWT authentication"""
if not self.jwt_manager:
raise ValueError("JWT manager not initialized")
if not credentials.is_bearer:
raise ValueError("Missing JWT token")
try:
# Use JWT middleware for authentication
from ..auth.auth_middleware import AuthMiddleware
middleware = AuthMiddleware(self.jwt_manager)
return await middleware.authenticate_request(credentials)
except Exception as e:
self.logger.error(f"JWT authentication failed: {e}")
raise ValueError(f"JWT authentication failed: {str(e)}")
async def _authenticate_oauth(
self,
credentials: BearerCredentials,
) -> AuthContext:
"""OAuth authentication"""
if not self.oauth_provider:
raise ValueError("OAuth provider not initialized")
if not credentials.is_bearer:
raise ValueError("Missing external OAuth access token")
auth_context = await self.oauth_provider.authenticate_with_token(
credentials.token
)
auth_context.auth_method = "external_oauth"
auth_context.token = EMPTY_CREDENTIAL
auth_context.pool_key = "global"
return auth_context
async def _authenticate_token(
self,
credentials: BearerCredentials,
) -> AuthContext:
"""Token authentication"""
if not self.token_manager:
raise ValueError("Token manager not initialized")
if not credentials.is_static_token:
raise ValueError("Missing authentication token")
token = credentials.token
try:
# Validate token using TokenManager
validation_result = await self.token_manager.validate_token(token)
if not validation_result.is_valid:
raise ValueError(
f"Token validation failed: {validation_result.error_message}"
)
token_info = validation_result.token_info
if token_info is None:
raise ValueError("Token validation did not return token information")
# Immediately validate database configuration for this token
if self.security_manager:
await self.security_manager._validate_token_database_config(
token, token_info
)
return AuthContext(
token_id=token_info.token_id,
user_id=token_info.token_id, # Use token_id as user_id for token auth
roles=["token_user"], # Default role for token users
permissions=["read", "write"], # Default permissions for token users
security_level=SecurityLevel.INTERNAL,
client_ip=credentials.client_ip,
session_id=credentials.session_id or f"session_{token_info.token_id}",
login_time=utc_now(),
last_activity=token_info.last_used,
token=token, # Store raw token for token-bound database configuration
auth_method="token",
pool_key=f"static_token:{token_info.token_id}",
)
except Exception as e:
self.logger.error(f"Token authentication failed: {e}")
raise ValueError(f"Token authentication failed: {str(e)}")
class AuthorizationProvider:
"""Authorization provider"""
def __init__(self, config: DorisConfig | Mapping[str, Any]) -> None:
self.config = config
self.logger = get_logger(__name__)
self.permission_cache: dict[str, bool] = {}
# Load sensitive tables configuration
self.sensitive_tables = self._load_sensitive_tables()
def _load_sensitive_tables(self) -> dict[str, SecurityLevel]:
"""Load sensitive table configuration"""
default_tables = {
"user_info": SecurityLevel.CONFIDENTIAL,
"payment_records": SecurityLevel.SECRET,
"employee_data": SecurityLevel.CONFIDENTIAL,
"public_reports": SecurityLevel.PUBLIC,
}
if isinstance(self.config, Mapping):
config_tables = self.config.get("sensitive_tables", {})
# Convert string values to SecurityLevel enum
for table_name, level in config_tables.items():
if isinstance(level, str):
try:
default_tables[table_name] = SecurityLevel(level.lower())
except ValueError:
default_tables[table_name] = SecurityLevel.INTERNAL
else:
default_tables[table_name] = level
return default_tables
else:
return default_tables
async def check_permission(
self, auth_context: AuthContext, resource_uri: str, action: str
) -> bool:
"""Check permissions"""
# Parse resource information
resource_info = self._parse_resource_uri(resource_uri)
# First check security level - this is mandatory
if not await self._check_security_level_permission(auth_context, resource_info):
return False
# Then check role-based permissions
if await self._check_role_permission(auth_context, resource_info, action):
return True
# Finally check user-based permissions
if await self._check_user_permission(auth_context, resource_info, action):
return True
return False
def _parse_resource_uri(self, uri: str) -> dict[str, str]:
"""Parse resource URI"""
parts = uri.split("/")
if len(parts) >= 3:
return {
"type": parts[2], # table, view, etc.
"name": parts[3] if len(parts) > 3 else "",
"schema": parts[4] if len(parts) > 4 else "default",
}
return {"type": "unknown", "name": "", "schema": "default"}
async def _check_role_permission(
self, auth_context: AuthContext, resource_info: dict[str, str], action: str
) -> bool:
"""Check role-based permissions"""
# Role permission mapping
role_permissions = {
"data_analyst": {"table": ["read"], "view": ["read"]},
"data_admin": {
"table": ["read", "write", "admin"],
"view": ["read", "write", "admin"],
},
}
for role in auth_context.roles:
role_perms = role_permissions.get(role, {})
resource_perms = role_perms.get(resource_info["type"], [])
if action in resource_perms:
return True
return False
async def _check_user_permission(
self, auth_context: AuthContext, resource_info: dict[str, str], action: str
) -> bool:
"""Check user-based permissions"""
# User-specific permission check
if "admin" in auth_context.permissions:
return True
if action == "read" and "read_data" in auth_context.permissions:
return True
return False
async def _check_security_level_permission(
self, auth_context: AuthContext, resource_info: dict[str, str]
) -> bool:
"""Check security level permissions"""
# Get resource security level
resource_security_level = self._get_resource_security_level(resource_info)
# Check if user security level is sufficient
security_hierarchy = {
SecurityLevel.PUBLIC: 0,
SecurityLevel.INTERNAL: 1,
SecurityLevel.CONFIDENTIAL: 2,
SecurityLevel.SECRET: 3,
}
user_level = security_hierarchy.get(auth_context.security_level, 0)
resource_level = security_hierarchy.get(resource_security_level, 0)
# User must have higher or equal security level to access resource
return user_level >= resource_level
def _get_resource_security_level(
self, resource_info: dict[str, str]
) -> SecurityLevel:
"""Get resource security level"""
# Get table security level from configuration
table_name = resource_info.get("name", "")
# Use the loaded sensitive tables
sensitive_tables = self.sensitive_tables
# Convert string values to SecurityLevel enum if needed
security_level = sensitive_tables.get(table_name, SecurityLevel.INTERNAL)
if isinstance(security_level, str):
try:
security_level = SecurityLevel(security_level.lower())
except ValueError:
security_level = SecurityLevel.INTERNAL
return security_level
class SQLSecurityValidator:
"""SQL security validator"""
def __init__(self, config: DorisConfig | Mapping[str, Any]) -> None:
self.config = config
self.logger = get_logger(__name__)
self.blocked_keywords: set[str]
self.max_query_complexity: int
self.enable_security_check: bool
# Handle DorisConfig object or dictionary configuration
if isinstance(config, Mapping):
# Dictionary configuration
self.blocked_keywords = {
str(keyword) for keyword in config.get("blocked_keywords", [])
}
self.max_query_complexity = int(config.get("max_query_complexity", 100))
self.enable_security_check = bool(config.get("enable_security_check", True))
else:
# DorisConfig object with security attribute - unified source from config
self.blocked_keywords = set(config.security.blocked_keywords)
self.max_query_complexity = config.security.max_query_complexity
self.enable_security_check = config.security.enable_security_check
async def validate(self, sql: str, auth_context: AuthContext) -> ValidationResult:
"""Validate SQL query security"""
# If security check is disabled, always return valid
if not self.enable_security_check:
self.logger.debug("SQL security check is disabled, allowing all queries")
return ValidationResult(is_valid=True)
try:
# SECURITY FIX: Parse ALL SQL statements, not just the first one
# This prevents bypassing security checks by injecting additional statements
all_statements = sqlparse.parse(sql)
if not all_statements:
return ValidationResult(
is_valid=False,
error_message="Empty or invalid SQL statement",
risk_level="medium",
)
# SECURITY FIX: Validate each statement individually
for idx, parsed in enumerate(all_statements):
# Skip empty statements (e.g., from trailing semicolons)
if not parsed.tokens or str(parsed).strip() == "":
continue
self.logger.debug(
"Validating SQL statement %s/%s",
idx + 1,
len(all_statements),
)
# Check blocked operations first (more specific)
keyword_result = await self._check_blocked_keywords(parsed)
if not keyword_result.is_valid:
keyword_result.error_message = (
f"Statement {idx + 1}: {keyword_result.error_message}"
)
return keyword_result
# Check SQL injection risks
injection_result = await self._check_sql_injection(sql, parsed)
if not injection_result.is_valid:
injection_result.error_message = (
f"Statement {idx + 1}: {injection_result.error_message}"
)
return injection_result
# Check query complexity
complexity_result = await self._check_query_complexity(parsed)
if not complexity_result.is_valid:
complexity_result.error_message = (
f"Statement {idx + 1}: {complexity_result.error_message}"
)
return complexity_result
# Check table access permissions
table_result = await self._check_table_access(parsed, auth_context)
if not table_result.is_valid:
table_result.error_message = (
f"Statement {idx + 1}: {table_result.error_message}"
)
return table_result
# The legacy validator remains in use below the formal Query
# runtime. Reuse the canonical fail-closed allowlist so unknown
# Doris statements cannot pass merely because they are absent
# from blocked_keywords.
from .query_runtime import QueryRuntimeFailure, ReadOnlySQLGuard
try:
ReadOnlySQLGuard.validate(sql)
except QueryRuntimeFailure as exc:
return ValidationResult(
is_valid=False,
error_message=f"Read-only SQL policy violation: {exc}",
risk_level="high",
)
return ValidationResult(is_valid=True)
except Exception as e:
self.logger.error(f"SQL security validation failed: {e}")
return ValidationResult(
is_valid=False,
error_message=f"SQL parsing error: {str(e)}",
risk_level="high",
)
async def _check_sql_injection(
self, sql: str, parsed: Statement
) -> ValidationResult:
"""Check SQL injection risks with improved pattern detection
FIX for Issue #62 Bug 2: Improved patterns to reduce false positives
Now better distinguishes between legitimate SQL (like BETWEEN...AND) and injection attempts
"""
# Improved injection patterns that are more specific and less prone to false positives
injection_patterns = [
# Stacked queries with dangerous operations (true injection risk)
r";\s*(DROP|DELETE|TRUNCATE|ALTER|CREATE|INSERT|UPDATE)\s+",
# UNION-based injection (but allow legitimate UNION queries)
# Only flag if UNION is followed by suspicious patterns like SELECT with WHERE 1=1
r"UNION\s+(ALL\s+)?SELECT\s+.*\s+(WHERE|AND|OR)\s+\d+\s*=\s*\d+",
r"UNION\s+(ALL\s+)?SELECT\s+.*\b(PASSWORD|SECRET|TOKEN|CREDENTIAL|ADMIN)\b",
# Boolean tautology injection. BETWEEN clauses are cleaned below before this is applied.
r"\b(OR|AND)\s+\d+\s*=\s*\d+\b",
# Boolean-based blind injection with comments (true injection pattern)
r"(WHERE|AND|OR)\s+\d+\s*=\s*\d+\s*(--|#|/\*)",
# Quote-based injection attempts (but not in legitimate strings)
r"(WHERE|AND|OR)\s+(['\"])[^\2]*\2\s*=\s*\2[^\2]*\2",
# Time-based blind injection
r"(SLEEP|WAITFOR|BENCHMARK)\s*\(",
# System stored procedure injection
r"(EXEC|EXECUTE|SP_|XP_)\s*\(",
# Script injection attempts
r"<\s*(SCRIPT|JAVASCRIPT|VBSCRIPT)",
]
# FIX: Don't flag legitimate SQL functions and keywords
# These patterns are too broad and cause false positives:
# - REMOVED: r"(char|ascii|substring|concat)\s*\(" - These are legitimate SQL functions
# - REMOVED: r"(\s|^)(or|and)\s+\d+\s*=\s*\d+" - This flags BETWEEN...AND constructs
# - REMOVED: r"(\s|^)(or|and)\s+['\"].*['\"]" - This is too broad
sql_upper = sql.upper()
# Special case: Allow BETWEEN...AND which is legitimate SQL
# This prevents false positives like "WHERE dt BETWEEN '2025-01-01' AND '2025-01-31'"
if "BETWEEN" in sql_upper and "AND" in sql_upper:
# This is likely a BETWEEN clause, not injection
# Check if AND appears in a BETWEEN context
between_pattern = r"BETWEEN\s+[^\s]+\s+AND\s+[^\s]+"
if re.search(between_pattern, sql_upper, re.IGNORECASE):
# Remove BETWEEN clauses before checking other patterns
sql_cleaned = re.sub(
between_pattern, "BETWEEN_CLAUSE", sql_upper, flags=re.IGNORECASE
)
sql_to_check = sql_cleaned
else:
sql_to_check = sql_upper
else:
sql_to_check = sql_upper
for pattern in injection_patterns:
if re.search(pattern, sql_to_check, re.IGNORECASE):
self.logger.warning(
f"Potential SQL injection pattern detected: {pattern}"
)
return ValidationResult(
is_valid=False,
error_message="Potential SQL injection risk detected",
risk_level="high",
)
# Check suspicious quotes and comments (with improved detection)
if self._has_suspicious_quotes_or_comments(sql):
return ValidationResult(
is_valid=False,
error_message="Suspicious quote or comment pattern detected",
risk_level="medium",
)
return ValidationResult(is_valid=True)
def _has_suspicious_quotes_or_comments(self, sql: str) -> bool:
"""Check suspicious quote and comment patterns with improved detection
FIX for Issue #62 Bug 2: Improved detection to reduce false positives
Now distinguishes between legitimate comments/strings and injection attempts
"""
try:
# Use sqlparse to parse the SQL and distinguish between code and comments/strings
import sqlparse
from sqlparse.tokens import Comment, String
# Parse the SQL
parsed = sqlparse.parse(sql)
if not parsed:
# If parsing fails, be conservative
return True
statement = parsed[0]
# Check for unmatched quotes ONLY in non-string tokens
# This prevents false positives from legitimate string content
non_string_content = []
for token in statement.flatten():
if token.ttype in (String.Single, String.Double):
# Skip string content - quotes inside strings are legitimate
continue
elif token.ttype in (Comment.Single, Comment.Multi):
# Comments are generally OK, but check for suspicious injection patterns
comment_value = str(token).lower()
comment_body = re.sub(r"^(--|#|/\*)\s*", "", comment_value).strip()
truncation_pattern = r"^(and|or|union|select|drop|delete|insert|update|exec|execute)\b"
sensitive_pattern = r"\b(admin|credential|password|secret|token)\b"
if re.search(truncation_pattern, comment_body) or re.search(
sensitive_pattern, comment_body
):
self.logger.warning(
f"Suspicious SQL keyword in comment: {token}"
)
return True
# Normal comments are OK
continue
else:
# Accumulate non-string, non-comment content
non_string_content.append(str(token))
# Check for unmatched quotes in non-string content
non_string_text = "".join(non_string_content)
single_quotes = non_string_text.count("'")
double_quotes = non_string_text.count('"')
# Only flag if there are unmatched quotes in actual SQL code (not in strings)
if single_quotes % 2 != 0 or double_quotes % 2 != 0:
return True
# FIX: Don't flag legitimate SQL comments
# Comments are OK as long as they don't contain dangerous patterns (already checked above)
return False
except Exception as e:
self.logger.debug(f"SQL parsing error in quote/comment check: {e}")
# On parsing error, fall back to conservative check
# But be more lenient than before
return False # Don't flag on parse errors to reduce false positives
async def _check_blocked_keywords(self, parsed: Statement) -> ValidationResult:
"""Check blocked keywords"""
blocked_operations = []
# Check all tokens in the parsed statement
for token in parsed.flatten():
# Check if token is a keyword (including DML/DDL) or name that matches blocked operations
if (
token.ttype is Keyword
or token.ttype is Name
or (token.ttype and str(token.ttype).startswith("Token.Keyword"))
):
token_value = token.value.upper().strip()
if token_value in self.blocked_keywords:
blocked_operations.append(token_value)
# Also check for DDL/DML keywords in token values
elif hasattr(token, "value") and token.value:
token_value = token.value.upper().strip()
for blocked_keyword in self.blocked_keywords:
if blocked_keyword in token_value:
blocked_operations.append(blocked_keyword)
if blocked_operations:
return ValidationResult(
is_valid=False,
error_message=f"Contains blocked operations: {', '.join(set(blocked_operations))}",
risk_level="high",
blocked_operations=list(set(blocked_operations)),
)
return ValidationResult(is_valid=True)
async def _check_query_complexity(self, parsed: Statement) -> ValidationResult:
"""Check query complexity"""
complexity_score = 0
# Calculate complexity score
for token in parsed.flatten():
if token.ttype is Keyword:
keyword = token.value.upper()
if keyword in ["JOIN", "INNER", "LEFT", "RIGHT", "FULL"]:
complexity_score += 10
elif keyword in ["UNION", "INTERSECT", "EXCEPT"]:
complexity_score += 15
elif keyword in ["GROUP BY", "ORDER BY", "HAVING"]:
complexity_score += 5
elif keyword in ["SUBQUERY", "EXISTS", "IN"]:
complexity_score += 8
if complexity_score > self.max_query_complexity:
return ValidationResult(
is_valid=False,
error_message=f"Query complexity too high (score: {complexity_score}, limit: {self.max_query_complexity})",
risk_level="medium",
)
return ValidationResult(is_valid=True)
async def _check_table_access(
self, parsed: Statement, auth_context: AuthContext
) -> ValidationResult:
"""Check table access permissions"""
# Extract table names from query
tables = self._extract_table_names(parsed)
# Check access permissions for each table
unauthorized_tables = []
for table in tables:
# Should call authorization provider to check permissions
# Simplified implementation, assume some tables require special permissions
if (
table.lower() in ["sensitive_data", "admin_logs"]
and "admin" not in auth_context.roles
):
unauthorized_tables.append(table)
if unauthorized_tables:
return ValidationResult(
is_valid=False,
error_message=f"No access to tables: {', '.join(unauthorized_tables)}",
risk_level="high",
)
return ValidationResult(is_valid=True)
def _extract_table_names(self, parsed: Statement) -> list[str]:
"""Extract table names from SQL statement"""
tables = []
# Simplified table name extraction logic
tokens = list(parsed.flatten())
for i, token in enumerate(tokens):
if token.ttype is Keyword and token.value.upper() == "FROM":
# Find table name after FROM
for j in range(i + 1, len(tokens)):
next_token = tokens[j]
if next_token.ttype is Name:
tables.append(next_token.value)
break
elif next_token.ttype is Keyword:
break
return tables
class DataMaskingProcessor:
"""Data masking processor"""
def __init__(self, config: DorisConfig | Mapping[str, Any]) -> None:
self.config = config
self.logger = get_logger(__name__)
self.masking_algorithms: dict[str, MaskingAlgorithm] = (
self._init_masking_algorithms()
)
self.masking_rules = self._load_masking_rules()
def _load_masking_rules(self) -> list[MaskingRule]:
"""Load data masking rules"""
default_rules = [
MaskingRule(
column_pattern=r".*phone.*|.*mobile.*",
algorithm="phone_mask",
parameters={"mask_char": "*", "keep_prefix": 3, "keep_suffix": 4},
security_level=SecurityLevel.INTERNAL,
),
MaskingRule(
column_pattern=r".*email.*",
algorithm="email_mask",
parameters={"mask_char": "*"},
security_level=SecurityLevel.INTERNAL,
),
MaskingRule(
column_pattern=r".*id_card.*|.*identity.*",
algorithm="id_mask",
parameters={"mask_char": "*", "keep_prefix": 6, "keep_suffix": 4},
security_level=SecurityLevel.CONFIDENTIAL,
),
]
# Load custom rules from configuration
if isinstance(self.config, Mapping):
custom_rules = self.config.get("masking_rules", [])
for rule_config in custom_rules:
if isinstance(rule_config, dict):
# Convert string security level to enum
if "security_level" in rule_config and isinstance(
rule_config["security_level"], str
):
try:
rule_config["security_level"] = SecurityLevel(
rule_config["security_level"].lower()
)
except ValueError:
rule_config["security_level"] = SecurityLevel.INTERNAL
default_rules.append(MaskingRule(**rule_config))
elif isinstance(rule_config, MaskingRule):
default_rules.append(rule_config)
return default_rules
def _init_masking_algorithms(self) -> dict[str, MaskingAlgorithm]:
"""Initialize masking algorithms"""
return {
"phone_mask": self._mask_phone,
"email_mask": self._mask_email,
"id_mask": self._mask_id_card,
"name_mask": self._mask_name,
"partial_mask": self._mask_partial,
}
async def process(
self, data: list[dict[str, Any]], auth_context: AuthContext
) -> list[dict[str, Any]]:
"""Process data masking"""
if not data:
return data
# Get applicable masking rules
applicable_rules = self._get_applicable_rules(auth_context)
masked_data: list[dict[str, Any]] = []
for row in data:
masked_row: dict[str, Any] = {}
for column, value in row.items():
masked_value = await self._apply_masking_rules(
column, value, applicable_rules
)
masked_row[column] = masked_value
masked_data.append(masked_row)
return masked_data
def _get_applicable_rules(self, auth_context: AuthContext) -> list[MaskingRule]:
"""Get applicable masking rules"""
applicable_rules: list[MaskingRule] = []
for rule in self.masking_rules:
# Decide whether to apply masking rules based on user security level
if self._should_apply_rule(rule, auth_context):
applicable_rules.append(rule)
return applicable_rules
def _should_apply_rule(self, rule: MaskingRule, auth_context: AuthContext) -> bool:
"""Determine whether masking rule should be applied"""
# Admin users can see original data
if "admin" in auth_context.roles:
return False
# Decide based on security level
security_hierarchy = {
SecurityLevel.PUBLIC: 0,
SecurityLevel.INTERNAL: 1,
SecurityLevel.CONFIDENTIAL: 2,
SecurityLevel.SECRET: 3,
}
user_level = security_hierarchy.get(auth_context.security_level, 0)
rule_level = security_hierarchy.get(rule.security_level, 0)
# Apply masking if user level is less than or equal to rule level
return user_level <= rule_level
async def _apply_masking_rules(
self, column: str, value: Any, rules: list[MaskingRule]
) -> Any:
"""Apply masking rules"""
if value is None:
return value
for rule in rules:
if re.match(rule.column_pattern, column, re.IGNORECASE):
algorithm = self.masking_algorithms.get(rule.algorithm)
if algorithm:
return algorithm(str(value), rule.parameters)
return value
def _mask_phone(self, value: str, params: dict[str, Any]) -> str:
"""Phone number masking"""
if len(value) < 7:
return value
mask_char = str(params.get("mask_char", "*"))
keep_prefix = int(params.get("keep_prefix", 3))
keep_suffix = int(params.get("keep_suffix", 4))
if len(value) <= keep_prefix + keep_suffix:
return mask_char * len(value)
prefix = value[:keep_prefix]
suffix = value[-keep_suffix:]
middle_length = len(value) - keep_prefix - keep_suffix
return prefix + mask_char * middle_length + suffix
def _mask_email(self, value: str, params: dict[str, Any]) -> str:
"""Email masking"""
if "@" not in value:
return value
mask_char = str(params.get("mask_char", "*"))
local, domain = value.split("@", 1)
if len(local) <= 2:
masked_local = mask_char * len(local)
else:
masked_local = local[0] + mask_char * (len(local) - 2) + local[-1]
return f"{masked_local}@{domain}"
def _mask_id_card(self, value: str, params: dict[str, Any]) -> str:
"""ID card number masking"""
if len(value) < 10:
return value
mask_char = str(params.get("mask_char", "*"))
keep_prefix = int(params.get("keep_prefix", 6))
keep_suffix = int(params.get("keep_suffix", 4))
if len(value) <= keep_prefix + keep_suffix:
return mask_char * len(value)
prefix = value[:keep_prefix]
suffix = value[-keep_suffix:]
middle_length = len(value) - keep_prefix - keep_suffix
return prefix + mask_char * middle_length + suffix
def _mask_name(self, value: str, params: dict[str, Any]) -> str:
"""Name masking"""
if len(value) <= 1:
return value
mask_char = str(params.get("mask_char", "*"))
if len(value) == 2:
return value[0] + mask_char
else:
return value[0] + mask_char * (len(value) - 2) + value[-1]
def _mask_partial(self, value: str, params: dict[str, Any]) -> str:
"""Partial masking"""
mask_char = str(params.get("mask_char", "*"))
mask_ratio = float(params.get("mask_ratio", 0.5))
mask_length = int(len(value) * mask_ratio)
start_pos = (len(value) - mask_length) // 2
result = list(value)
for i in range(start_pos, start_pos + mask_length):
if i < len(result):
result[i] = mask_char
return "".join(result)