isa-model 0.3.91__py3-none-any.whl → 0.4.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- isa_model/client.py +1166 -584
- isa_model/core/cache/redis_cache.py +410 -0
- isa_model/core/config/config_manager.py +282 -12
- isa_model/core/config.py +91 -1
- isa_model/core/database/__init__.py +1 -0
- isa_model/core/database/direct_db_client.py +114 -0
- isa_model/core/database/migration_manager.py +563 -0
- isa_model/core/database/migrations.py +297 -0
- isa_model/core/database/supabase_client.py +258 -0
- isa_model/core/dependencies.py +316 -0
- isa_model/core/discovery/__init__.py +19 -0
- isa_model/core/discovery/consul_discovery.py +190 -0
- isa_model/core/logging/__init__.py +54 -0
- isa_model/core/logging/influx_logger.py +523 -0
- isa_model/core/logging/loki_logger.py +160 -0
- isa_model/core/models/__init__.py +46 -0
- isa_model/core/models/config_models.py +625 -0
- isa_model/core/models/deployment_billing_tracker.py +430 -0
- isa_model/core/models/model_billing_tracker.py +60 -88
- isa_model/core/models/model_manager.py +66 -25
- isa_model/core/models/model_metadata.py +690 -0
- isa_model/core/models/model_repo.py +217 -55
- isa_model/core/models/model_statistics_tracker.py +234 -0
- isa_model/core/models/model_storage.py +0 -1
- isa_model/core/models/model_version_manager.py +959 -0
- isa_model/core/models/system_models.py +857 -0
- isa_model/core/pricing_manager.py +2 -249
- isa_model/core/repositories/__init__.py +9 -0
- isa_model/core/repositories/config_repository.py +912 -0
- isa_model/core/resilience/circuit_breaker.py +366 -0
- isa_model/core/security/secrets.py +358 -0
- isa_model/core/services/__init__.py +2 -4
- isa_model/core/services/intelligent_model_selector.py +479 -370
- isa_model/core/storage/hf_storage.py +2 -2
- isa_model/core/types.py +8 -0
- isa_model/deployment/__init__.py +5 -48
- isa_model/deployment/core/__init__.py +2 -31
- isa_model/deployment/core/deployment_manager.py +1278 -368
- isa_model/deployment/local/__init__.py +31 -0
- isa_model/deployment/local/config.py +248 -0
- isa_model/deployment/local/gpu_gateway.py +607 -0
- isa_model/deployment/local/health_checker.py +428 -0
- isa_model/deployment/local/provider.py +586 -0
- isa_model/deployment/local/tensorrt_service.py +621 -0
- isa_model/deployment/local/transformers_service.py +644 -0
- isa_model/deployment/local/vllm_service.py +527 -0
- isa_model/deployment/modal/__init__.py +8 -0
- isa_model/deployment/modal/config.py +136 -0
- isa_model/deployment/modal/deployer.py +894 -0
- isa_model/deployment/modal/services/__init__.py +3 -0
- isa_model/deployment/modal/services/audio/__init__.py +1 -0
- isa_model/deployment/modal/services/audio/isa_audio_chatTTS_service.py +520 -0
- isa_model/deployment/modal/services/audio/isa_audio_openvoice_service.py +758 -0
- isa_model/deployment/modal/services/audio/isa_audio_service_v2.py +1044 -0
- isa_model/deployment/modal/services/embedding/__init__.py +1 -0
- isa_model/deployment/modal/services/embedding/isa_embed_rerank_service.py +296 -0
- isa_model/deployment/modal/services/llm/__init__.py +1 -0
- isa_model/deployment/modal/services/llm/isa_llm_service.py +424 -0
- isa_model/deployment/modal/services/video/__init__.py +1 -0
- isa_model/deployment/modal/services/video/isa_video_hunyuan_service.py +423 -0
- isa_model/deployment/modal/services/vision/__init__.py +1 -0
- isa_model/deployment/modal/services/vision/isa_vision_ocr_service.py +519 -0
- isa_model/deployment/modal/services/vision/isa_vision_qwen25_service.py +709 -0
- isa_model/deployment/modal/services/vision/isa_vision_table_service.py +676 -0
- isa_model/deployment/modal/services/vision/isa_vision_ui_service.py +833 -0
- isa_model/deployment/modal/services/vision/isa_vision_ui_service_optimized.py +660 -0
- isa_model/deployment/models/org-org-acme-corp-tenant-a-service-llm-20250825-225822/tenant-a-service_modal_service.py +48 -0
- isa_model/deployment/models/org-test-org-123-prefix-test-service-llm-20250825-225822/prefix-test-service_modal_service.py +48 -0
- isa_model/deployment/models/test-llm-service-llm-20250825-204442/test-llm-service_modal_service.py +48 -0
- isa_model/deployment/models/test-monitoring-gpt2-llm-20250825-212906/test-monitoring-gpt2_modal_service.py +48 -0
- isa_model/deployment/models/test-monitoring-gpt2-llm-20250825-213009/test-monitoring-gpt2_modal_service.py +48 -0
- isa_model/deployment/storage/__init__.py +5 -0
- isa_model/deployment/storage/deployment_repository.py +824 -0
- isa_model/deployment/triton/__init__.py +10 -0
- isa_model/deployment/triton/config.py +196 -0
- isa_model/deployment/triton/configs/__init__.py +1 -0
- isa_model/deployment/triton/provider.py +512 -0
- isa_model/deployment/triton/scripts/__init__.py +1 -0
- isa_model/deployment/triton/templates/__init__.py +1 -0
- isa_model/inference/__init__.py +47 -1
- isa_model/inference/ai_factory.py +179 -16
- isa_model/inference/legacy_services/__init__.py +21 -0
- isa_model/inference/legacy_services/model_evaluation.py +637 -0
- isa_model/inference/legacy_services/model_service.py +573 -0
- isa_model/inference/legacy_services/model_serving.py +717 -0
- isa_model/inference/legacy_services/model_training.py +561 -0
- isa_model/inference/models/__init__.py +21 -0
- isa_model/inference/models/inference_config.py +551 -0
- isa_model/inference/models/inference_record.py +675 -0
- isa_model/inference/models/performance_models.py +714 -0
- isa_model/inference/repositories/__init__.py +9 -0
- isa_model/inference/repositories/inference_repository.py +828 -0
- isa_model/inference/services/audio/__init__.py +21 -0
- isa_model/inference/services/audio/base_realtime_service.py +225 -0
- isa_model/inference/services/audio/base_stt_service.py +184 -11
- isa_model/inference/services/audio/isa_tts_service.py +0 -0
- isa_model/inference/services/audio/openai_realtime_service.py +320 -124
- isa_model/inference/services/audio/openai_stt_service.py +53 -11
- isa_model/inference/services/base_service.py +17 -1
- isa_model/inference/services/custom_model_manager.py +277 -0
- isa_model/inference/services/embedding/__init__.py +13 -0
- isa_model/inference/services/embedding/base_embed_service.py +111 -8
- isa_model/inference/services/embedding/isa_embed_service.py +305 -0
- isa_model/inference/services/embedding/ollama_embed_service.py +15 -3
- isa_model/inference/services/embedding/openai_embed_service.py +2 -4
- isa_model/inference/services/embedding/resilient_embed_service.py +285 -0
- isa_model/inference/services/embedding/tests/test_embedding.py +222 -0
- isa_model/inference/services/img/__init__.py +2 -2
- isa_model/inference/services/img/base_image_gen_service.py +24 -7
- isa_model/inference/services/img/replicate_image_gen_service.py +84 -422
- isa_model/inference/services/img/services/replicate_face_swap.py +193 -0
- isa_model/inference/services/img/services/replicate_flux.py +226 -0
- isa_model/inference/services/img/services/replicate_flux_kontext.py +219 -0
- isa_model/inference/services/img/services/replicate_sticker_maker.py +249 -0
- isa_model/inference/services/img/tests/test_img_client.py +297 -0
- isa_model/inference/services/llm/__init__.py +10 -2
- isa_model/inference/services/llm/base_llm_service.py +361 -26
- isa_model/inference/services/llm/cerebras_llm_service.py +628 -0
- isa_model/inference/services/llm/helpers/llm_adapter.py +71 -12
- isa_model/inference/services/llm/helpers/llm_prompts.py +342 -0
- isa_model/inference/services/llm/helpers/llm_utils.py +321 -23
- isa_model/inference/services/llm/huggingface_llm_service.py +581 -0
- isa_model/inference/services/llm/local_llm_service.py +747 -0
- isa_model/inference/services/llm/ollama_llm_service.py +11 -3
- isa_model/inference/services/llm/openai_llm_service.py +670 -56
- isa_model/inference/services/llm/yyds_llm_service.py +10 -3
- isa_model/inference/services/vision/__init__.py +27 -6
- isa_model/inference/services/vision/base_vision_service.py +118 -185
- isa_model/inference/services/vision/blip_vision_service.py +359 -0
- isa_model/inference/services/vision/helpers/image_utils.py +19 -10
- isa_model/inference/services/vision/isa_vision_service.py +634 -0
- isa_model/inference/services/vision/openai_vision_service.py +19 -10
- isa_model/inference/services/vision/tests/test_ocr_client.py +284 -0
- isa_model/inference/services/vision/vgg16_vision_service.py +257 -0
- isa_model/serving/api/cache_manager.py +245 -0
- isa_model/serving/api/dependencies/__init__.py +1 -0
- isa_model/serving/api/dependencies/auth.py +194 -0
- isa_model/serving/api/dependencies/database.py +139 -0
- isa_model/serving/api/error_handlers.py +284 -0
- isa_model/serving/api/fastapi_server.py +240 -18
- isa_model/serving/api/middleware/auth.py +317 -0
- isa_model/serving/api/middleware/security.py +268 -0
- isa_model/serving/api/middleware/tenant_context.py +414 -0
- isa_model/serving/api/routes/analytics.py +489 -0
- isa_model/serving/api/routes/config.py +645 -0
- isa_model/serving/api/routes/deployment_billing.py +315 -0
- isa_model/serving/api/routes/deployments.py +475 -0
- isa_model/serving/api/routes/gpu_gateway.py +440 -0
- isa_model/serving/api/routes/health.py +32 -12
- isa_model/serving/api/routes/inference_monitoring.py +486 -0
- isa_model/serving/api/routes/local_deployments.py +448 -0
- isa_model/serving/api/routes/logs.py +430 -0
- isa_model/serving/api/routes/settings.py +582 -0
- isa_model/serving/api/routes/tenants.py +575 -0
- isa_model/serving/api/routes/unified.py +992 -171
- isa_model/serving/api/routes/webhooks.py +479 -0
- isa_model/serving/api/startup.py +318 -0
- isa_model/serving/modal_proxy_server.py +249 -0
- isa_model/utils/gpu_utils.py +311 -0
- {isa_model-0.3.91.dist-info → isa_model-0.4.3.dist-info}/METADATA +76 -22
- isa_model-0.4.3.dist-info/RECORD +193 -0
- isa_model/deployment/cloud/__init__.py +0 -9
- isa_model/deployment/cloud/modal/__init__.py +0 -10
- isa_model/deployment/cloud/modal/isa_vision_doc_service.py +0 -766
- isa_model/deployment/cloud/modal/isa_vision_table_service.py +0 -532
- isa_model/deployment/cloud/modal/isa_vision_ui_service.py +0 -406
- isa_model/deployment/cloud/modal/register_models.py +0 -321
- isa_model/deployment/core/deployment_config.py +0 -356
- isa_model/deployment/core/isa_deployment_service.py +0 -401
- isa_model/deployment/gpu_int8_ds8/app/server.py +0 -66
- isa_model/deployment/gpu_int8_ds8/scripts/test_client.py +0 -43
- isa_model/deployment/gpu_int8_ds8/scripts/test_client_os.py +0 -35
- isa_model/deployment/runtime/deployed_service.py +0 -338
- isa_model/deployment/services/__init__.py +0 -9
- isa_model/deployment/services/auto_deploy_vision_service.py +0 -538
- isa_model/deployment/services/model_service.py +0 -332
- isa_model/deployment/services/service_monitor.py +0 -356
- isa_model/deployment/services/service_registry.py +0 -527
- isa_model/eval/__init__.py +0 -92
- isa_model/eval/benchmarks.py +0 -469
- isa_model/eval/config/__init__.py +0 -10
- isa_model/eval/config/evaluation_config.py +0 -108
- isa_model/eval/evaluators/__init__.py +0 -18
- isa_model/eval/evaluators/base_evaluator.py +0 -503
- isa_model/eval/evaluators/llm_evaluator.py +0 -472
- isa_model/eval/factory.py +0 -531
- isa_model/eval/infrastructure/__init__.py +0 -24
- isa_model/eval/infrastructure/experiment_tracker.py +0 -466
- isa_model/eval/metrics.py +0 -798
- isa_model/inference/adapter/unified_api.py +0 -248
- isa_model/inference/services/helpers/stacked_config.py +0 -148
- isa_model/inference/services/img/flux_professional_service.py +0 -603
- isa_model/inference/services/img/helpers/base_stacked_service.py +0 -274
- isa_model/inference/services/others/table_transformer_service.py +0 -61
- isa_model/inference/services/vision/doc_analysis_service.py +0 -640
- isa_model/inference/services/vision/helpers/base_stacked_service.py +0 -274
- isa_model/inference/services/vision/ui_analysis_service.py +0 -823
- isa_model/scripts/inference_tracker.py +0 -283
- isa_model/scripts/mlflow_manager.py +0 -379
- isa_model/scripts/model_registry.py +0 -465
- isa_model/scripts/register_models.py +0 -370
- isa_model/scripts/register_models_with_embeddings.py +0 -510
- isa_model/scripts/start_mlflow.py +0 -95
- isa_model/scripts/training_tracker.py +0 -257
- isa_model/training/__init__.py +0 -74
- isa_model/training/annotation/annotation_schema.py +0 -47
- isa_model/training/annotation/processors/annotation_processor.py +0 -126
- isa_model/training/annotation/storage/dataset_manager.py +0 -131
- isa_model/training/annotation/storage/dataset_schema.py +0 -44
- isa_model/training/annotation/tests/test_annotation_flow.py +0 -109
- isa_model/training/annotation/tests/test_minio copy.py +0 -113
- isa_model/training/annotation/tests/test_minio_upload.py +0 -43
- isa_model/training/annotation/views/annotation_controller.py +0 -158
- isa_model/training/cloud/__init__.py +0 -22
- isa_model/training/cloud/job_orchestrator.py +0 -402
- isa_model/training/cloud/runpod_trainer.py +0 -454
- isa_model/training/cloud/storage_manager.py +0 -482
- isa_model/training/core/__init__.py +0 -23
- isa_model/training/core/config.py +0 -181
- isa_model/training/core/dataset.py +0 -222
- isa_model/training/core/trainer.py +0 -720
- isa_model/training/core/utils.py +0 -213
- isa_model/training/factory.py +0 -424
- isa_model-0.3.91.dist-info/RECORD +0 -138
- /isa_model/{core/storage/minio_storage.py → deployment/modal/services/audio/isa_audio_fish_service.py} +0 -0
- /isa_model/deployment/{services → modal/services/vision}/simple_auto_deploy_vision_service.py +0 -0
- {isa_model-0.3.91.dist-info → isa_model-0.4.3.dist-info}/WHEEL +0 -0
- {isa_model-0.3.91.dist-info → isa_model-0.4.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,317 @@
|
|
1
|
+
"""
|
2
|
+
Optional Authentication Middleware
|
3
|
+
|
4
|
+
Provides optional API key authentication for the ISA Model Platform.
|
5
|
+
When authentication is disabled (default), all endpoints remain open.
|
6
|
+
When enabled, requires API keys for access.
|
7
|
+
"""
|
8
|
+
|
9
|
+
from fastapi import HTTPException, status, Depends, Request
|
10
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, APIKeyHeader, APIKeyQuery
|
11
|
+
from typing import Optional, Dict, List, Union
|
12
|
+
import hashlib
|
13
|
+
import secrets
|
14
|
+
import time
|
15
|
+
import logging
|
16
|
+
import os
|
17
|
+
import json
|
18
|
+
from pathlib import Path
|
19
|
+
|
20
|
+
logger = logging.getLogger(__name__)
|
21
|
+
|
22
|
+
# Configuration
|
23
|
+
AUTH_ENABLED = os.getenv("REQUIRE_API_KEYS", "false").lower() == "true"
|
24
|
+
API_KEYS_FILE = Path(os.path.dirname(__file__)).parent.parent.parent / "deployment" / "dev" / ".api_keys.json"
|
25
|
+
|
26
|
+
# Security schemes (only used when auth is enabled)
|
27
|
+
bearer_scheme = HTTPBearer(auto_error=False)
|
28
|
+
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
29
|
+
api_key_query = APIKeyQuery(name="api_key", auto_error=False)
|
30
|
+
|
31
|
+
class APIKeyManager:
|
32
|
+
def __init__(self):
|
33
|
+
self.api_keys: Dict[str, Dict] = {}
|
34
|
+
|
35
|
+
# Load API keys first to check if auth should be enabled
|
36
|
+
self.load_api_keys()
|
37
|
+
|
38
|
+
# Determine auth state: check explicit setting first, then auto-detect from keys
|
39
|
+
explicit_auth = AUTH_ENABLED
|
40
|
+
has_keys = len(self.api_keys) > 0
|
41
|
+
|
42
|
+
# If explicitly disabled (REQUIRE_API_KEYS=false), respect that setting
|
43
|
+
if os.getenv("REQUIRE_API_KEYS", "").lower() == "false":
|
44
|
+
self.auth_enabled = False
|
45
|
+
else:
|
46
|
+
# Otherwise, enable if explicitly set OR if API keys exist
|
47
|
+
self.auth_enabled = explicit_auth or has_keys
|
48
|
+
|
49
|
+
if self.auth_enabled:
|
50
|
+
logger.info(f"API Key authentication is ENABLED ({'explicit' if explicit_auth else 'auto-detected from keys'})")
|
51
|
+
else:
|
52
|
+
logger.info("API Key authentication is DISABLED - all endpoints are open")
|
53
|
+
|
54
|
+
def load_api_keys(self):
|
55
|
+
"""Load API keys from file"""
|
56
|
+
try:
|
57
|
+
if API_KEYS_FILE.exists():
|
58
|
+
with open(API_KEYS_FILE, 'r') as f:
|
59
|
+
self.api_keys = json.load(f)
|
60
|
+
logger.info(f"Loaded {len(self.api_keys)} API keys")
|
61
|
+
else:
|
62
|
+
self.api_keys = {}
|
63
|
+
logger.info("No API keys file found - authentication will be disabled")
|
64
|
+
except Exception as e:
|
65
|
+
logger.error(f"Error loading API keys: {e}")
|
66
|
+
self.api_keys = {}
|
67
|
+
|
68
|
+
def save_api_keys(self):
|
69
|
+
"""Save API keys to file"""
|
70
|
+
try:
|
71
|
+
API_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
72
|
+
with open(API_KEYS_FILE, 'w') as f:
|
73
|
+
json.dump(self.api_keys, f, indent=2)
|
74
|
+
logger.info("API keys saved successfully")
|
75
|
+
except Exception as e:
|
76
|
+
logger.error(f"Error saving API keys: {e}")
|
77
|
+
|
78
|
+
def create_default_keys(self):
|
79
|
+
"""Create default API keys for initial setup"""
|
80
|
+
admin_key = self.generate_api_key("admin", scopes=["read", "write", "admin"])
|
81
|
+
dev_key = self.generate_api_key("development", scopes=["read", "write"])
|
82
|
+
|
83
|
+
logger.warning("=== CREATED DEFAULT API KEYS ===")
|
84
|
+
logger.warning(f"Admin API Key: {admin_key}")
|
85
|
+
logger.warning(f"Development API Key: {dev_key}")
|
86
|
+
logger.warning("Please save these keys securely!")
|
87
|
+
logger.warning("=====================================")
|
88
|
+
|
89
|
+
return {"admin_key": admin_key, "dev_key": dev_key}
|
90
|
+
|
91
|
+
def generate_api_key(self, name: str, scopes: List[str] = None) -> str:
|
92
|
+
"""Generate a new API key"""
|
93
|
+
if scopes is None:
|
94
|
+
scopes = ["read"]
|
95
|
+
|
96
|
+
# Generate secure random key
|
97
|
+
key = f"isa_{secrets.token_urlsafe(32)}"
|
98
|
+
key_hash = hashlib.sha256(key.encode()).hexdigest()
|
99
|
+
|
100
|
+
# Store key metadata
|
101
|
+
self.api_keys[key_hash] = {
|
102
|
+
"name": name,
|
103
|
+
"scopes": scopes,
|
104
|
+
"created_at": time.time(),
|
105
|
+
"last_used": None,
|
106
|
+
"usage_count": 0,
|
107
|
+
"active": True
|
108
|
+
}
|
109
|
+
|
110
|
+
self.save_api_keys()
|
111
|
+
return key
|
112
|
+
|
113
|
+
def validate_api_key(self, api_key: str) -> Optional[Dict]:
|
114
|
+
"""Validate an API key and return its metadata"""
|
115
|
+
if not self.auth_enabled:
|
116
|
+
# When auth is disabled, return a default user context
|
117
|
+
return {
|
118
|
+
"name": "anonymous",
|
119
|
+
"scopes": ["read", "write", "admin"],
|
120
|
+
"auth_enabled": False
|
121
|
+
}
|
122
|
+
|
123
|
+
if not api_key:
|
124
|
+
return None
|
125
|
+
|
126
|
+
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
|
127
|
+
key_data = self.api_keys.get(key_hash)
|
128
|
+
|
129
|
+
if not key_data or not key_data.get("active", True):
|
130
|
+
return None
|
131
|
+
|
132
|
+
# Update usage statistics
|
133
|
+
key_data["last_used"] = time.time()
|
134
|
+
key_data["usage_count"] = key_data.get("usage_count", 0) + 1
|
135
|
+
key_data["auth_enabled"] = True
|
136
|
+
self.save_api_keys()
|
137
|
+
|
138
|
+
return key_data
|
139
|
+
|
140
|
+
def revoke_api_key(self, api_key: str) -> bool:
|
141
|
+
"""Revoke an API key"""
|
142
|
+
if not self.auth_enabled:
|
143
|
+
return False
|
144
|
+
|
145
|
+
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
|
146
|
+
if key_hash in self.api_keys:
|
147
|
+
self.api_keys[key_hash]["active"] = False
|
148
|
+
self.save_api_keys()
|
149
|
+
return True
|
150
|
+
return False
|
151
|
+
|
152
|
+
def list_api_keys(self) -> List[Dict]:
|
153
|
+
"""List all API keys (without revealing the actual keys)"""
|
154
|
+
if not self.auth_enabled:
|
155
|
+
return []
|
156
|
+
|
157
|
+
return [
|
158
|
+
{
|
159
|
+
"key_hash": key_hash[:16] + "...",
|
160
|
+
"name": data["name"],
|
161
|
+
"scopes": data["scopes"],
|
162
|
+
"created_at": data["created_at"],
|
163
|
+
"last_used": data.get("last_used"),
|
164
|
+
"usage_count": data.get("usage_count", 0),
|
165
|
+
"active": data.get("active", True)
|
166
|
+
}
|
167
|
+
for key_hash, data in self.api_keys.items()
|
168
|
+
]
|
169
|
+
|
170
|
+
def enable_auth(self):
|
171
|
+
"""Enable authentication"""
|
172
|
+
self.auth_enabled = True
|
173
|
+
if not self.api_keys:
|
174
|
+
return self.create_default_keys()
|
175
|
+
return None
|
176
|
+
|
177
|
+
def disable_auth(self):
|
178
|
+
"""Disable authentication"""
|
179
|
+
self.auth_enabled = False
|
180
|
+
|
181
|
+
# Global API key manager instance
|
182
|
+
api_key_manager = APIKeyManager()
|
183
|
+
|
184
|
+
async def get_api_key_from_request(
|
185
|
+
request: Request,
|
186
|
+
bearer_token: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
|
187
|
+
header_key: Optional[str] = Depends(api_key_header),
|
188
|
+
query_key: Optional[str] = Depends(api_key_query)
|
189
|
+
) -> Optional[str]:
|
190
|
+
"""Extract API key from various sources"""
|
191
|
+
|
192
|
+
# If auth is disabled, return None (will be handled as anonymous)
|
193
|
+
if not api_key_manager.auth_enabled:
|
194
|
+
return None
|
195
|
+
|
196
|
+
# Try Bearer token first
|
197
|
+
if bearer_token:
|
198
|
+
return bearer_token.credentials
|
199
|
+
|
200
|
+
# Try X-API-Key header
|
201
|
+
if header_key:
|
202
|
+
return header_key
|
203
|
+
|
204
|
+
# Try query parameter
|
205
|
+
if query_key:
|
206
|
+
return query_key
|
207
|
+
|
208
|
+
return None
|
209
|
+
|
210
|
+
async def authenticate_api_key(api_key: str = Depends(get_api_key_from_request)) -> Dict:
|
211
|
+
"""Authenticate API key and return user info (optional when auth disabled)"""
|
212
|
+
|
213
|
+
# When auth is disabled, always succeed with anonymous user
|
214
|
+
if not api_key_manager.auth_enabled:
|
215
|
+
return {
|
216
|
+
"name": "anonymous",
|
217
|
+
"scopes": ["read", "write", "admin"],
|
218
|
+
"auth_enabled": False,
|
219
|
+
"authenticated": False
|
220
|
+
}
|
221
|
+
|
222
|
+
# When auth is enabled, require valid API key
|
223
|
+
if not api_key:
|
224
|
+
raise HTTPException(
|
225
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
226
|
+
detail="API key required. Provide via Authorization header, X-API-Key header, or api_key query parameter",
|
227
|
+
headers={"WWW-Authenticate": "Bearer"}
|
228
|
+
)
|
229
|
+
|
230
|
+
key_data = api_key_manager.validate_api_key(api_key)
|
231
|
+
|
232
|
+
if not key_data:
|
233
|
+
raise HTTPException(
|
234
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
235
|
+
detail="Invalid or expired API key",
|
236
|
+
headers={"WWW-Authenticate": "Bearer"}
|
237
|
+
)
|
238
|
+
|
239
|
+
key_data["authenticated"] = True
|
240
|
+
return key_data
|
241
|
+
|
242
|
+
async def require_scope(required_scope: str):
|
243
|
+
"""Create a dependency that requires a specific scope"""
|
244
|
+
async def check_scope(current_user: Dict = Depends(authenticate_api_key)) -> Dict:
|
245
|
+
# When auth is disabled, always allow
|
246
|
+
if not current_user.get("auth_enabled", True):
|
247
|
+
return current_user
|
248
|
+
|
249
|
+
user_scopes = current_user.get("scopes", [])
|
250
|
+
|
251
|
+
if required_scope not in user_scopes and "admin" not in user_scopes:
|
252
|
+
raise HTTPException(
|
253
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
254
|
+
detail=f"Insufficient permissions. Required scope: {required_scope}"
|
255
|
+
)
|
256
|
+
|
257
|
+
return current_user
|
258
|
+
|
259
|
+
return check_scope
|
260
|
+
|
261
|
+
# Convenience dependencies for common scopes
|
262
|
+
async def require_read_access(current_user: Dict = Depends(authenticate_api_key)) -> Dict:
|
263
|
+
"""Require read access (or auth disabled)"""
|
264
|
+
if not current_user.get("auth_enabled", True):
|
265
|
+
return current_user
|
266
|
+
|
267
|
+
user_scopes = current_user.get("scopes", [])
|
268
|
+
if not any(scope in user_scopes for scope in ["read", "write", "admin"]):
|
269
|
+
raise HTTPException(
|
270
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
271
|
+
detail="Read access required"
|
272
|
+
)
|
273
|
+
return current_user
|
274
|
+
|
275
|
+
async def require_write_access(current_user: Dict = Depends(authenticate_api_key)) -> Dict:
|
276
|
+
"""Require write access (or auth disabled)"""
|
277
|
+
if not current_user.get("auth_enabled", True):
|
278
|
+
return current_user
|
279
|
+
|
280
|
+
user_scopes = current_user.get("scopes", [])
|
281
|
+
if not any(scope in user_scopes for scope in ["write", "admin"]):
|
282
|
+
raise HTTPException(
|
283
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
284
|
+
detail="Write access required"
|
285
|
+
)
|
286
|
+
return current_user
|
287
|
+
|
288
|
+
async def require_admin_access(current_user: Dict = Depends(authenticate_api_key)) -> Dict:
|
289
|
+
"""Require admin access (or auth disabled)"""
|
290
|
+
if not current_user.get("auth_enabled", True):
|
291
|
+
return current_user
|
292
|
+
|
293
|
+
user_scopes = current_user.get("scopes", [])
|
294
|
+
if "admin" not in user_scopes:
|
295
|
+
raise HTTPException(
|
296
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
297
|
+
detail="Admin access required"
|
298
|
+
)
|
299
|
+
return current_user
|
300
|
+
|
301
|
+
# Optional authentication (always returns user info, never fails)
|
302
|
+
async def optional_auth(api_key: str = Depends(get_api_key_from_request)) -> Dict:
|
303
|
+
"""Optional authentication - returns user info if available, anonymous if not"""
|
304
|
+
try:
|
305
|
+
return api_key_manager.validate_api_key(api_key) or {
|
306
|
+
"name": "anonymous",
|
307
|
+
"scopes": [],
|
308
|
+
"auth_enabled": api_key_manager.auth_enabled,
|
309
|
+
"authenticated": False
|
310
|
+
}
|
311
|
+
except Exception:
|
312
|
+
return {
|
313
|
+
"name": "anonymous",
|
314
|
+
"scopes": [],
|
315
|
+
"auth_enabled": api_key_manager.auth_enabled,
|
316
|
+
"authenticated": False
|
317
|
+
}
|
@@ -0,0 +1,268 @@
|
|
1
|
+
"""
|
2
|
+
Security middleware for production deployment
|
3
|
+
|
4
|
+
Provides comprehensive security features including:
|
5
|
+
- Rate limiting with Redis backend
|
6
|
+
- Security headers
|
7
|
+
- Request size limits
|
8
|
+
- Input validation and sanitization
|
9
|
+
- CORS protection
|
10
|
+
"""
|
11
|
+
|
12
|
+
import time
|
13
|
+
import logging
|
14
|
+
import os
|
15
|
+
import redis
|
16
|
+
from typing import Dict, Any, Optional, Callable
|
17
|
+
from fastapi import FastAPI, Request, Response, HTTPException, status
|
18
|
+
from fastapi.middleware.cors import CORSMiddleware
|
19
|
+
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
20
|
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
21
|
+
from slowapi.util import get_remote_address
|
22
|
+
from slowapi.errors import RateLimitExceeded
|
23
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
24
|
+
from starlette.responses import JSONResponse
|
25
|
+
import html
|
26
|
+
|
27
|
+
from ....core.config.config_manager import ConfigManager
|
28
|
+
|
29
|
+
# Configure logging
|
30
|
+
logger = logging.getLogger(__name__)
|
31
|
+
|
32
|
+
# Configuration from environment variables
|
33
|
+
config_manager = ConfigManager()
|
34
|
+
MAX_REQUEST_SIZE = int(os.getenv("MAX_REQUEST_SIZE_MB", "50")) * 1024 * 1024 # 50MB default
|
35
|
+
# Use Consul discovery for Redis URL with fallback
|
36
|
+
REDIS_URL = os.getenv("REDIS_URL", config_manager.get_redis_url())
|
37
|
+
ENABLE_RATE_LIMITING = os.getenv("ENABLE_RATE_LIMITING", "true").lower() == "true"
|
38
|
+
RATE_LIMIT_PER_MINUTE = os.getenv("RATE_LIMIT_PER_MINUTE", "100")
|
39
|
+
RATE_LIMIT_PER_HOUR = os.getenv("RATE_LIMIT_PER_HOUR", "1000")
|
40
|
+
|
41
|
+
# Security headers configuration
|
42
|
+
SECURITY_HEADERS = {
|
43
|
+
"X-Content-Type-Options": "nosniff",
|
44
|
+
"X-Frame-Options": "DENY",
|
45
|
+
"X-XSS-Protection": "1; mode=block",
|
46
|
+
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
47
|
+
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com; img-src 'self' https://fastapi.tiangolo.com data:; connect-src 'self'",
|
48
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
49
|
+
"Permissions-Policy": "geolocation=(), microphone=(), camera=()"
|
50
|
+
}
|
51
|
+
|
52
|
+
# Initialize Redis connection for rate limiting
|
53
|
+
try:
|
54
|
+
redis_client = redis.from_url(REDIS_URL, decode_responses=True)
|
55
|
+
redis_client.ping() # Test connection
|
56
|
+
logger.info("Redis connection established for rate limiting")
|
57
|
+
except Exception as e:
|
58
|
+
logger.warning(f"Redis connection failed, using in-memory rate limiting: {e}")
|
59
|
+
redis_client = None
|
60
|
+
|
61
|
+
# Initialize rate limiter
|
62
|
+
def get_remote_address_with_proxy(request: Request):
|
63
|
+
"""Get client IP considering proxy headers"""
|
64
|
+
forwarded_for = request.headers.get("X-Forwarded-For")
|
65
|
+
if forwarded_for:
|
66
|
+
return forwarded_for.split(",")[0].strip()
|
67
|
+
|
68
|
+
real_ip = request.headers.get("X-Real-IP")
|
69
|
+
if real_ip:
|
70
|
+
return real_ip
|
71
|
+
|
72
|
+
return get_remote_address(request)
|
73
|
+
|
74
|
+
# Rate limiter with Redis backend if available
|
75
|
+
if redis_client:
|
76
|
+
limiter = Limiter(
|
77
|
+
key_func=get_remote_address_with_proxy,
|
78
|
+
storage_uri=REDIS_URL,
|
79
|
+
strategy="fixed-window"
|
80
|
+
)
|
81
|
+
else:
|
82
|
+
limiter = Limiter(
|
83
|
+
key_func=get_remote_address_with_proxy,
|
84
|
+
strategy="fixed-window"
|
85
|
+
)
|
86
|
+
|
87
|
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
88
|
+
"""Add security headers to all responses"""
|
89
|
+
|
90
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
91
|
+
try:
|
92
|
+
response = await call_next(request)
|
93
|
+
|
94
|
+
# Add security headers
|
95
|
+
for header, value in SECURITY_HEADERS.items():
|
96
|
+
response.headers[header] = value
|
97
|
+
|
98
|
+
# Add processing time header
|
99
|
+
if hasattr(request.state, 'start_time'):
|
100
|
+
process_time = time.time() - request.state.start_time
|
101
|
+
response.headers["X-Process-Time"] = str(process_time)
|
102
|
+
|
103
|
+
return response
|
104
|
+
|
105
|
+
except Exception as e:
|
106
|
+
logger.error(f"Error in security headers middleware: {e}")
|
107
|
+
return JSONResponse(
|
108
|
+
status_code=500,
|
109
|
+
content={"error": "Internal server error"},
|
110
|
+
headers=SECURITY_HEADERS
|
111
|
+
)
|
112
|
+
|
113
|
+
class RequestValidationMiddleware(BaseHTTPMiddleware):
|
114
|
+
"""Validate request size and sanitize inputs"""
|
115
|
+
|
116
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
117
|
+
# Record start time for performance monitoring
|
118
|
+
request.state.start_time = time.time()
|
119
|
+
|
120
|
+
try:
|
121
|
+
# Check request size
|
122
|
+
content_length = request.headers.get("content-length")
|
123
|
+
if content_length and int(content_length) > MAX_REQUEST_SIZE:
|
124
|
+
logger.warning(
|
125
|
+
f"Request too large: {content_length} bytes > {MAX_REQUEST_SIZE} bytes "
|
126
|
+
f"from client {get_remote_address_with_proxy(request)}"
|
127
|
+
)
|
128
|
+
raise HTTPException(
|
129
|
+
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
130
|
+
detail=f"Request too large. Maximum size: {MAX_REQUEST_SIZE // (1024*1024)}MB"
|
131
|
+
)
|
132
|
+
|
133
|
+
# Sanitize query parameters
|
134
|
+
if request.url.query:
|
135
|
+
sanitized_query = html.escape(request.url.query)
|
136
|
+
if sanitized_query != request.url.query:
|
137
|
+
logger.warning(
|
138
|
+
f"Potentially malicious query parameters detected from client {get_remote_address_with_proxy(request)}: "
|
139
|
+
f"'{request.url.query}' -> '{sanitized_query}'"
|
140
|
+
)
|
141
|
+
|
142
|
+
# Log request details for monitoring
|
143
|
+
logger.info(
|
144
|
+
f"Request received: {request.method} {request.url.path} "
|
145
|
+
f"from {get_remote_address_with_proxy(request)} "
|
146
|
+
f"(UA: {request.headers.get('user-agent', 'unknown')})"
|
147
|
+
)
|
148
|
+
|
149
|
+
response = await call_next(request)
|
150
|
+
|
151
|
+
# Log response details
|
152
|
+
process_time = time.time() - request.state.start_time
|
153
|
+
logger.info(
|
154
|
+
f"Request completed: {request.method} {request.url.path} -> {response.status_code} "
|
155
|
+
f"in {process_time:.3f}s from {get_remote_address_with_proxy(request)}"
|
156
|
+
)
|
157
|
+
|
158
|
+
return response
|
159
|
+
|
160
|
+
except HTTPException:
|
161
|
+
raise
|
162
|
+
except Exception as e:
|
163
|
+
logger.error(
|
164
|
+
f"Error in request validation middleware: {e} "
|
165
|
+
f"({request.method} {request.url.path} from {get_remote_address_with_proxy(request)})"
|
166
|
+
)
|
167
|
+
raise HTTPException(
|
168
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
169
|
+
detail="Internal server error"
|
170
|
+
)
|
171
|
+
|
172
|
+
def setup_security_middleware(app: FastAPI):
|
173
|
+
"""Setup all security middleware for the FastAPI application"""
|
174
|
+
|
175
|
+
# Rate limiting setup
|
176
|
+
if ENABLE_RATE_LIMITING:
|
177
|
+
app.state.limiter = limiter
|
178
|
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
179
|
+
logger.info(f"Rate limiting enabled (Redis backend: {redis_client is not None})")
|
180
|
+
|
181
|
+
# Trusted hosts (production should specify allowed hosts)
|
182
|
+
allowed_hosts = os.getenv("ALLOWED_HOSTS", "*").split(",")
|
183
|
+
if allowed_hosts != ["*"]:
|
184
|
+
app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)
|
185
|
+
logger.info(f"Trusted hosts middleware enabled: {allowed_hosts}")
|
186
|
+
|
187
|
+
# CORS configuration
|
188
|
+
cors_origins = os.getenv("CORS_ORIGINS", "*").split(",")
|
189
|
+
app.add_middleware(
|
190
|
+
CORSMiddleware,
|
191
|
+
allow_origins=cors_origins,
|
192
|
+
allow_credentials=True,
|
193
|
+
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
194
|
+
allow_headers=["*"],
|
195
|
+
expose_headers=["X-Process-Time"]
|
196
|
+
)
|
197
|
+
logger.info(f"CORS middleware enabled for origins: {cors_origins}")
|
198
|
+
|
199
|
+
# Custom security middleware
|
200
|
+
app.add_middleware(SecurityHeadersMiddleware)
|
201
|
+
app.add_middleware(RequestValidationMiddleware)
|
202
|
+
|
203
|
+
logger.info("Security middleware setup completed")
|
204
|
+
|
205
|
+
def get_rate_limiter():
|
206
|
+
"""Get the configured rate limiter"""
|
207
|
+
return limiter
|
208
|
+
|
209
|
+
# Rate limiting decorators for different use cases
|
210
|
+
def rate_limit_standard():
|
211
|
+
"""Standard rate limit for general API usage"""
|
212
|
+
return limiter.limit(f"{RATE_LIMIT_PER_MINUTE}/minute")
|
213
|
+
|
214
|
+
def rate_limit_heavy():
|
215
|
+
"""Heavy rate limit for resource-intensive operations"""
|
216
|
+
heavy_limit = int(RATE_LIMIT_PER_MINUTE) // 5 # 20% of standard limit
|
217
|
+
return limiter.limit(f"{heavy_limit}/minute")
|
218
|
+
|
219
|
+
def rate_limit_auth():
|
220
|
+
"""Strict rate limit for authentication endpoints"""
|
221
|
+
return limiter.limit("10/minute")
|
222
|
+
|
223
|
+
# Security utilities
|
224
|
+
def sanitize_input(text: str) -> str:
|
225
|
+
"""Sanitize text input to prevent XSS attacks"""
|
226
|
+
if not isinstance(text, str):
|
227
|
+
return text
|
228
|
+
return html.escape(text)
|
229
|
+
|
230
|
+
def validate_api_key_format(api_key: str) -> bool:
|
231
|
+
"""Validate API key format"""
|
232
|
+
if not isinstance(api_key, str):
|
233
|
+
return False
|
234
|
+
|
235
|
+
# Check if it starts with expected prefix
|
236
|
+
if not api_key.startswith("isa_"):
|
237
|
+
return False
|
238
|
+
|
239
|
+
# Check minimum length (should be > 20 characters)
|
240
|
+
if len(api_key) < 25:
|
241
|
+
return False
|
242
|
+
|
243
|
+
return True
|
244
|
+
|
245
|
+
def get_client_info(request: Request) -> Dict[str, Any]:
|
246
|
+
"""Extract client information for logging and monitoring"""
|
247
|
+
return {
|
248
|
+
"ip": get_remote_address_with_proxy(request),
|
249
|
+
"user_agent": request.headers.get("user-agent", "unknown"),
|
250
|
+
"referer": request.headers.get("referer"),
|
251
|
+
"forwarded_for": request.headers.get("x-forwarded-for"),
|
252
|
+
"real_ip": request.headers.get("x-real-ip"),
|
253
|
+
"method": request.method,
|
254
|
+
"path": request.url.path,
|
255
|
+
"query": request.url.query
|
256
|
+
}
|
257
|
+
|
258
|
+
# Health check for Redis connection
|
259
|
+
async def check_redis_health() -> Dict[str, Any]:
|
260
|
+
"""Check Redis connection health"""
|
261
|
+
if not redis_client:
|
262
|
+
return {"redis": "disabled", "status": "ok"}
|
263
|
+
|
264
|
+
try:
|
265
|
+
redis_client.ping()
|
266
|
+
return {"redis": "connected", "status": "ok"}
|
267
|
+
except Exception as e:
|
268
|
+
return {"redis": "error", "status": "error", "error": str(e)}
|