atlas-chat 0.1.0__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.
- atlas/__init__.py +40 -0
- atlas/application/__init__.py +7 -0
- atlas/application/chat/__init__.py +7 -0
- atlas/application/chat/agent/__init__.py +10 -0
- atlas/application/chat/agent/act_loop.py +179 -0
- atlas/application/chat/agent/factory.py +142 -0
- atlas/application/chat/agent/protocols.py +46 -0
- atlas/application/chat/agent/react_loop.py +338 -0
- atlas/application/chat/agent/think_act_loop.py +171 -0
- atlas/application/chat/approval_manager.py +151 -0
- atlas/application/chat/elicitation_manager.py +191 -0
- atlas/application/chat/events/__init__.py +1 -0
- atlas/application/chat/events/agent_event_relay.py +112 -0
- atlas/application/chat/modes/__init__.py +1 -0
- atlas/application/chat/modes/agent.py +125 -0
- atlas/application/chat/modes/plain.py +74 -0
- atlas/application/chat/modes/rag.py +81 -0
- atlas/application/chat/modes/tools.py +179 -0
- atlas/application/chat/orchestrator.py +213 -0
- atlas/application/chat/policies/__init__.py +1 -0
- atlas/application/chat/policies/tool_authorization.py +99 -0
- atlas/application/chat/preprocessors/__init__.py +1 -0
- atlas/application/chat/preprocessors/message_builder.py +92 -0
- atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
- atlas/application/chat/service.py +454 -0
- atlas/application/chat/utilities/__init__.py +6 -0
- atlas/application/chat/utilities/error_handler.py +367 -0
- atlas/application/chat/utilities/event_notifier.py +546 -0
- atlas/application/chat/utilities/file_processor.py +613 -0
- atlas/application/chat/utilities/tool_executor.py +789 -0
- atlas/atlas_chat_cli.py +347 -0
- atlas/atlas_client.py +238 -0
- atlas/core/__init__.py +0 -0
- atlas/core/auth.py +205 -0
- atlas/core/authorization_manager.py +27 -0
- atlas/core/capabilities.py +123 -0
- atlas/core/compliance.py +215 -0
- atlas/core/domain_whitelist.py +147 -0
- atlas/core/domain_whitelist_middleware.py +82 -0
- atlas/core/http_client.py +28 -0
- atlas/core/log_sanitizer.py +102 -0
- atlas/core/metrics_logger.py +59 -0
- atlas/core/middleware.py +131 -0
- atlas/core/otel_config.py +242 -0
- atlas/core/prompt_risk.py +200 -0
- atlas/core/rate_limit.py +0 -0
- atlas/core/rate_limit_middleware.py +64 -0
- atlas/core/security_headers_middleware.py +51 -0
- atlas/domain/__init__.py +37 -0
- atlas/domain/chat/__init__.py +1 -0
- atlas/domain/chat/dtos.py +85 -0
- atlas/domain/errors.py +96 -0
- atlas/domain/messages/__init__.py +12 -0
- atlas/domain/messages/models.py +160 -0
- atlas/domain/rag_mcp_service.py +664 -0
- atlas/domain/sessions/__init__.py +7 -0
- atlas/domain/sessions/models.py +36 -0
- atlas/domain/unified_rag_service.py +371 -0
- atlas/infrastructure/__init__.py +10 -0
- atlas/infrastructure/app_factory.py +135 -0
- atlas/infrastructure/events/__init__.py +1 -0
- atlas/infrastructure/events/cli_event_publisher.py +140 -0
- atlas/infrastructure/events/websocket_publisher.py +140 -0
- atlas/infrastructure/sessions/in_memory_repository.py +56 -0
- atlas/infrastructure/transport/__init__.py +7 -0
- atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
- atlas/init_cli.py +226 -0
- atlas/interfaces/__init__.py +15 -0
- atlas/interfaces/events.py +134 -0
- atlas/interfaces/llm.py +54 -0
- atlas/interfaces/rag.py +40 -0
- atlas/interfaces/sessions.py +75 -0
- atlas/interfaces/tools.py +57 -0
- atlas/interfaces/transport.py +24 -0
- atlas/main.py +564 -0
- atlas/mcp/api_key_demo/README.md +76 -0
- atlas/mcp/api_key_demo/main.py +172 -0
- atlas/mcp/api_key_demo/run.sh +56 -0
- atlas/mcp/basictable/main.py +147 -0
- atlas/mcp/calculator/main.py +149 -0
- atlas/mcp/code-executor/execution_engine.py +98 -0
- atlas/mcp/code-executor/execution_environment.py +95 -0
- atlas/mcp/code-executor/main.py +528 -0
- atlas/mcp/code-executor/result_processing.py +276 -0
- atlas/mcp/code-executor/script_generation.py +195 -0
- atlas/mcp/code-executor/security_checker.py +140 -0
- atlas/mcp/corporate_cars/main.py +437 -0
- atlas/mcp/csv_reporter/main.py +545 -0
- atlas/mcp/duckduckgo/main.py +182 -0
- atlas/mcp/elicitation_demo/README.md +171 -0
- atlas/mcp/elicitation_demo/main.py +262 -0
- atlas/mcp/env-demo/README.md +158 -0
- atlas/mcp/env-demo/main.py +199 -0
- atlas/mcp/file_size_test/main.py +284 -0
- atlas/mcp/filesystem/main.py +348 -0
- atlas/mcp/image_demo/main.py +113 -0
- atlas/mcp/image_demo/requirements.txt +4 -0
- atlas/mcp/logging_demo/README.md +72 -0
- atlas/mcp/logging_demo/main.py +103 -0
- atlas/mcp/many_tools_demo/main.py +50 -0
- atlas/mcp/order_database/__init__.py +0 -0
- atlas/mcp/order_database/main.py +369 -0
- atlas/mcp/order_database/signal_data.csv +1001 -0
- atlas/mcp/pdfbasic/main.py +394 -0
- atlas/mcp/pptx_generator/main.py +760 -0
- atlas/mcp/pptx_generator/requirements.txt +13 -0
- atlas/mcp/pptx_generator/run_test.sh +1 -0
- atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
- atlas/mcp/progress_demo/main.py +167 -0
- atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
- atlas/mcp/progress_updates_demo/README.md +120 -0
- atlas/mcp/progress_updates_demo/main.py +497 -0
- atlas/mcp/prompts/main.py +222 -0
- atlas/mcp/public_demo/main.py +189 -0
- atlas/mcp/sampling_demo/README.md +169 -0
- atlas/mcp/sampling_demo/main.py +234 -0
- atlas/mcp/thinking/main.py +77 -0
- atlas/mcp/tool_planner/main.py +240 -0
- atlas/mcp/ui-demo/badmesh.png +0 -0
- atlas/mcp/ui-demo/main.py +383 -0
- atlas/mcp/ui-demo/templates/button_demo.html +32 -0
- atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
- atlas/mcp/ui-demo/templates/form_demo.html +28 -0
- atlas/mcp/username-override-demo/README.md +320 -0
- atlas/mcp/username-override-demo/main.py +308 -0
- atlas/modules/__init__.py +0 -0
- atlas/modules/config/__init__.py +34 -0
- atlas/modules/config/cli.py +231 -0
- atlas/modules/config/config_manager.py +1096 -0
- atlas/modules/file_storage/__init__.py +22 -0
- atlas/modules/file_storage/cli.py +330 -0
- atlas/modules/file_storage/content_extractor.py +290 -0
- atlas/modules/file_storage/manager.py +295 -0
- atlas/modules/file_storage/mock_s3_client.py +402 -0
- atlas/modules/file_storage/s3_client.py +417 -0
- atlas/modules/llm/__init__.py +19 -0
- atlas/modules/llm/caller.py +287 -0
- atlas/modules/llm/litellm_caller.py +675 -0
- atlas/modules/llm/models.py +19 -0
- atlas/modules/mcp_tools/__init__.py +17 -0
- atlas/modules/mcp_tools/client.py +2123 -0
- atlas/modules/mcp_tools/token_storage.py +556 -0
- atlas/modules/prompts/prompt_provider.py +130 -0
- atlas/modules/rag/__init__.py +24 -0
- atlas/modules/rag/atlas_rag_client.py +336 -0
- atlas/modules/rag/client.py +129 -0
- atlas/routes/admin_routes.py +865 -0
- atlas/routes/config_routes.py +484 -0
- atlas/routes/feedback_routes.py +361 -0
- atlas/routes/files_routes.py +274 -0
- atlas/routes/health_routes.py +40 -0
- atlas/routes/mcp_auth_routes.py +223 -0
- atlas/server_cli.py +164 -0
- atlas/tests/conftest.py +20 -0
- atlas/tests/integration/test_mcp_auth_integration.py +152 -0
- atlas/tests/manual_test_sampling.py +87 -0
- atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
- atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
- atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
- atlas/tests/test_agent_roa.py +135 -0
- atlas/tests/test_app_factory_smoke.py +47 -0
- atlas/tests/test_approval_manager.py +439 -0
- atlas/tests/test_atlas_client.py +188 -0
- atlas/tests/test_atlas_rag_client.py +447 -0
- atlas/tests/test_atlas_rag_integration.py +224 -0
- atlas/tests/test_attach_file_flow.py +287 -0
- atlas/tests/test_auth_utils.py +165 -0
- atlas/tests/test_backend_public_url.py +185 -0
- atlas/tests/test_banner_logging.py +287 -0
- atlas/tests/test_capability_tokens_and_injection.py +203 -0
- atlas/tests/test_compliance_level.py +54 -0
- atlas/tests/test_compliance_manager.py +253 -0
- atlas/tests/test_config_manager.py +617 -0
- atlas/tests/test_config_manager_paths.py +12 -0
- atlas/tests/test_core_auth.py +18 -0
- atlas/tests/test_core_utils.py +190 -0
- atlas/tests/test_docker_env_sync.py +202 -0
- atlas/tests/test_domain_errors.py +329 -0
- atlas/tests/test_domain_whitelist.py +359 -0
- atlas/tests/test_elicitation_manager.py +408 -0
- atlas/tests/test_elicitation_routing.py +296 -0
- atlas/tests/test_env_demo_server.py +88 -0
- atlas/tests/test_error_classification.py +113 -0
- atlas/tests/test_error_flow_integration.py +116 -0
- atlas/tests/test_feedback_routes.py +333 -0
- atlas/tests/test_file_content_extraction.py +1134 -0
- atlas/tests/test_file_extraction_routes.py +158 -0
- atlas/tests/test_file_library.py +107 -0
- atlas/tests/test_file_manager_unit.py +18 -0
- atlas/tests/test_health_route.py +49 -0
- atlas/tests/test_http_client_stub.py +8 -0
- atlas/tests/test_imports_smoke.py +30 -0
- atlas/tests/test_interfaces_llm_response.py +9 -0
- atlas/tests/test_issue_access_denied_fix.py +136 -0
- atlas/tests/test_llm_env_expansion.py +836 -0
- atlas/tests/test_log_level_sensitive_data.py +285 -0
- atlas/tests/test_mcp_auth_routes.py +341 -0
- atlas/tests/test_mcp_client_auth.py +331 -0
- atlas/tests/test_mcp_data_injection.py +270 -0
- atlas/tests/test_mcp_get_authorized_servers.py +95 -0
- atlas/tests/test_mcp_hot_reload.py +512 -0
- atlas/tests/test_mcp_image_content.py +424 -0
- atlas/tests/test_mcp_logging.py +172 -0
- atlas/tests/test_mcp_progress_updates.py +313 -0
- atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
- atlas/tests/test_mcp_prompts_server.py +39 -0
- atlas/tests/test_mcp_tool_result_parsing.py +296 -0
- atlas/tests/test_metrics_logger.py +56 -0
- atlas/tests/test_middleware_auth.py +379 -0
- atlas/tests/test_prompt_risk_and_acl.py +141 -0
- atlas/tests/test_rag_mcp_aggregator.py +204 -0
- atlas/tests/test_rag_mcp_service.py +224 -0
- atlas/tests/test_rate_limit_middleware.py +45 -0
- atlas/tests/test_routes_config_smoke.py +60 -0
- atlas/tests/test_routes_files_download_token.py +41 -0
- atlas/tests/test_routes_files_health.py +18 -0
- atlas/tests/test_runtime_imports.py +53 -0
- atlas/tests/test_sampling_integration.py +482 -0
- atlas/tests/test_security_admin_routes.py +61 -0
- atlas/tests/test_security_capability_tokens.py +65 -0
- atlas/tests/test_security_file_stats_scope.py +21 -0
- atlas/tests/test_security_header_injection.py +191 -0
- atlas/tests/test_security_headers_and_filename.py +63 -0
- atlas/tests/test_shared_session_repository.py +101 -0
- atlas/tests/test_system_prompt_loading.py +181 -0
- atlas/tests/test_token_storage.py +505 -0
- atlas/tests/test_tool_approval_config.py +93 -0
- atlas/tests/test_tool_approval_utils.py +356 -0
- atlas/tests/test_tool_authorization_group_filtering.py +223 -0
- atlas/tests/test_tool_details_in_config.py +108 -0
- atlas/tests/test_tool_planner.py +300 -0
- atlas/tests/test_unified_rag_service.py +398 -0
- atlas/tests/test_username_override_in_approval.py +258 -0
- atlas/tests/test_websocket_auth_header.py +168 -0
- atlas/version.py +6 -0
- atlas_chat-0.1.0.data/data/.env.example +253 -0
- atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
- atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
- atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
- atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
- atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
- atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
- atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
- atlas_chat-0.1.0.dist-info/METADATA +236 -0
- atlas_chat-0.1.0.dist-info/RECORD +250 -0
- atlas_chat-0.1.0.dist-info/WHEEL +5 -0
- atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
- atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Domain whitelist management for email access control.
|
|
3
|
+
|
|
4
|
+
Loads domain whitelist definitions from atlas.domain-whitelist.json and provides
|
|
5
|
+
validation for user email domains.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional, Set
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DomainWhitelistConfig:
|
|
19
|
+
"""Configuration for domain whitelist."""
|
|
20
|
+
domains: Set[str]
|
|
21
|
+
subdomain_matching: bool
|
|
22
|
+
version: str
|
|
23
|
+
description: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DomainWhitelistManager:
|
|
27
|
+
"""Manages domain whitelist configuration and validation."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config_path: Optional[Path] = None):
|
|
30
|
+
"""Initialize the domain whitelist manager.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
config_path: Path to domain-whitelist.json. If None, uses default location.
|
|
34
|
+
"""
|
|
35
|
+
self.config: Optional[DomainWhitelistConfig] = None
|
|
36
|
+
self.config_loaded: bool = False
|
|
37
|
+
|
|
38
|
+
if config_path is None:
|
|
39
|
+
# Try to find config in standard locations
|
|
40
|
+
backend_root = Path(__file__).parent.parent
|
|
41
|
+
project_root = backend_root.parent
|
|
42
|
+
|
|
43
|
+
search_paths = [
|
|
44
|
+
project_root / "config" / "overrides" / "domain-whitelist.json",
|
|
45
|
+
project_root / "config" / "defaults" / "domain-whitelist.json",
|
|
46
|
+
backend_root / "configfilesadmin" / "domain-whitelist.json",
|
|
47
|
+
backend_root / "configfiles" / "domain-whitelist.json",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
for path in search_paths:
|
|
51
|
+
if path.exists():
|
|
52
|
+
config_path = path
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
if config_path and config_path.exists():
|
|
56
|
+
self._load_config(config_path)
|
|
57
|
+
else:
|
|
58
|
+
logger.warning("No domain-whitelist.json found, whitelist validation disabled")
|
|
59
|
+
self.config = DomainWhitelistConfig(
|
|
60
|
+
domains=set(),
|
|
61
|
+
subdomain_matching=True,
|
|
62
|
+
version="1.0",
|
|
63
|
+
description="No config loaded"
|
|
64
|
+
)
|
|
65
|
+
self.config_loaded = False
|
|
66
|
+
|
|
67
|
+
def _load_config(self, config_path: Path):
|
|
68
|
+
"""Load domain whitelist configuration from JSON file."""
|
|
69
|
+
try:
|
|
70
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
71
|
+
config_data = json.load(f)
|
|
72
|
+
|
|
73
|
+
# Extract domains from the list of domain objects
|
|
74
|
+
domains = set()
|
|
75
|
+
for domain_entry in config_data.get('domains', []):
|
|
76
|
+
if isinstance(domain_entry, dict):
|
|
77
|
+
domains.add(domain_entry.get('domain', '').lower())
|
|
78
|
+
elif isinstance(domain_entry, str):
|
|
79
|
+
domains.add(domain_entry.lower())
|
|
80
|
+
|
|
81
|
+
self.config = DomainWhitelistConfig(
|
|
82
|
+
domains=domains,
|
|
83
|
+
subdomain_matching=config_data.get('subdomain_matching', True),
|
|
84
|
+
version=config_data.get('version', '1.0'),
|
|
85
|
+
description=config_data.get('description', '')
|
|
86
|
+
)
|
|
87
|
+
self.config_loaded = True
|
|
88
|
+
|
|
89
|
+
logger.info(f"Loaded {len(self.config.domains)} domains from {config_path}")
|
|
90
|
+
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.error(f"Error loading domain-whitelist.json: {e}")
|
|
93
|
+
logger.warning("Whitelist validation disabled due to config error")
|
|
94
|
+
# Use empty config on error
|
|
95
|
+
self.config = DomainWhitelistConfig(
|
|
96
|
+
domains=set(),
|
|
97
|
+
subdomain_matching=True,
|
|
98
|
+
version="1.0",
|
|
99
|
+
description="Error loading config"
|
|
100
|
+
)
|
|
101
|
+
self.config_loaded = False
|
|
102
|
+
|
|
103
|
+
def is_domain_allowed(self, email: str) -> bool:
|
|
104
|
+
"""Check if an email address is from an allowed domain.
|
|
105
|
+
|
|
106
|
+
Note: This method only validates against the whitelist.
|
|
107
|
+
The FEATURE_DOMAIN_WHITELIST_ENABLED flag controls whether
|
|
108
|
+
the middleware uses this validation.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
email: Email address to validate
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
True if domain is allowed, False otherwise
|
|
115
|
+
"""
|
|
116
|
+
# If config wasn't successfully loaded, allow all (fail open)
|
|
117
|
+
if not self.config_loaded:
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
if not email or "@" not in email:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
domain = email.split("@", 1)[1].lower()
|
|
124
|
+
|
|
125
|
+
# Check if domain is in whitelist (O(1) lookup)
|
|
126
|
+
if domain in self.config.domains:
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
# Check subdomains if enabled - check each parent level
|
|
130
|
+
if self.config.subdomain_matching:
|
|
131
|
+
# Split domain and check each parent level
|
|
132
|
+
# e.g., for "mail.dept.sandia.gov" check: "dept.sandia.gov", "sandia.gov"
|
|
133
|
+
parts = domain.split(".")
|
|
134
|
+
for i in range(1, len(parts)):
|
|
135
|
+
parent_domain = ".".join(parts[i:])
|
|
136
|
+
if parent_domain in self.config.domains:
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
def get_domains(self) -> Set[str]:
|
|
142
|
+
"""Get the set of whitelisted domains.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Set of allowed domains
|
|
146
|
+
"""
|
|
147
|
+
return self.config.domains if self.config else set()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Email domain whitelist validation middleware.
|
|
2
|
+
|
|
3
|
+
This middleware enforces that users must have email addresses from whitelisted
|
|
4
|
+
domains. Enabled/disabled via the FEATURE_DOMAIN_WHITELIST_ENABLED feature flag.
|
|
5
|
+
Domain list is loaded from atlas.domain-whitelist.json.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from fastapi import Request
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
12
|
+
from starlette.responses import JSONResponse, RedirectResponse, Response
|
|
13
|
+
|
|
14
|
+
from atlas.core.domain_whitelist import DomainWhitelistManager
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DomainWhitelistMiddleware(BaseHTTPMiddleware):
|
|
20
|
+
"""Middleware to enforce email domain whitelist restrictions."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, app, auth_redirect_url: str = "/auth"):
|
|
23
|
+
"""Initialize domain whitelist middleware.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
app: ASGI application
|
|
27
|
+
auth_redirect_url: URL to redirect to on auth failure (default: /auth)
|
|
28
|
+
"""
|
|
29
|
+
super().__init__(app)
|
|
30
|
+
self.auth_redirect_url = auth_redirect_url
|
|
31
|
+
self.whitelist_manager = DomainWhitelistManager()
|
|
32
|
+
|
|
33
|
+
logger.info(f"Domain whitelist middleware loaded: {len(self.whitelist_manager.get_domains())} domains (config_loaded={self.whitelist_manager.config_loaded})")
|
|
34
|
+
|
|
35
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
36
|
+
"""Check if user email is from a whitelisted domain.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
request: Incoming HTTP request
|
|
40
|
+
call_next: Next middleware/handler in chain
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Response from next handler if authorized, or 403/redirect if not
|
|
44
|
+
"""
|
|
45
|
+
# Skip check for health endpoint and auth redirect endpoint
|
|
46
|
+
if request.url.path == '/api/health' or request.url.path == self.auth_redirect_url:
|
|
47
|
+
return await call_next(request)
|
|
48
|
+
|
|
49
|
+
# Get email from request state (set by AuthMiddleware)
|
|
50
|
+
email = getattr(request.state, "user_email", None)
|
|
51
|
+
|
|
52
|
+
if not email or "@" not in email:
|
|
53
|
+
logger.warning("Domain whitelist check failed: missing or invalid email")
|
|
54
|
+
return self._unauthorized_response(request, "User email required")
|
|
55
|
+
|
|
56
|
+
# Check if domain is allowed
|
|
57
|
+
if not self.whitelist_manager.is_domain_allowed(email):
|
|
58
|
+
domain = email.split("@", 1)[1].lower()
|
|
59
|
+
logger.warning(f"Domain whitelist check failed: unauthorized domain {domain}")
|
|
60
|
+
return self._unauthorized_response(
|
|
61
|
+
request,
|
|
62
|
+
"Access restricted to whitelisted domains"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return await call_next(request)
|
|
66
|
+
|
|
67
|
+
def _unauthorized_response(self, request: Request, detail: str) -> Response:
|
|
68
|
+
"""Return appropriate unauthorized response based on endpoint type.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
request: Incoming HTTP request
|
|
72
|
+
detail: Error detail message
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
JSONResponse for API endpoints, RedirectResponse for others
|
|
76
|
+
"""
|
|
77
|
+
if request.url.path.startswith('/api/'):
|
|
78
|
+
return JSONResponse(
|
|
79
|
+
status_code=403,
|
|
80
|
+
content={"detail": detail}
|
|
81
|
+
)
|
|
82
|
+
return RedirectResponse(url=self.auth_redirect_url, status_code=302)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal HTTP client stub for basic chat functionality.
|
|
3
|
+
This is a temporary implementation for testing.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_rag_client(base_url: str = "", timeout: float = 30.0) -> Any:
|
|
13
|
+
"""
|
|
14
|
+
Create a simple RAG client stub.
|
|
15
|
+
For basic chat, this just returns a mock client.
|
|
16
|
+
"""
|
|
17
|
+
class MockRAGClient:
|
|
18
|
+
def __init__(self):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
async def query(self, *args, **kwargs):
|
|
22
|
+
"""Mock RAG query - returns empty result."""
|
|
23
|
+
return {
|
|
24
|
+
"content": "RAG not available in basic chat mode",
|
|
25
|
+
"metadata": {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return MockRAGClient()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal utilities for basic chat functionality.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import Request
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_CONTROL_CHARS_RE = re.compile(r'[\x00-\x1f\x7f-\x9f]')
|
|
14
|
+
# Matches Unicode line separators (LINE SEPARATOR and PARAGRAPH SEPARATOR)
|
|
15
|
+
_UNICODE_NEWLINES_RE = re.compile(r'[\u2028\u2029]')
|
|
16
|
+
# Matches explicit CR, LF, and CRLF for maximal coverage
|
|
17
|
+
_STANDARD_NEWLINES_RE = re.compile(r'(\r\n|\r|\n)')
|
|
18
|
+
|
|
19
|
+
def sanitize_for_logging(value: Any) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Sanitize a value for safe logging by removing ALL newlines (including Unicode and CRLF)
|
|
22
|
+
and control characters, to defend against log injection.
|
|
23
|
+
|
|
24
|
+
Removes ASCII control characters (C0 and C1 ranges), CR/LF in any combination,
|
|
25
|
+
and Unicode line/paragraph separators. This includes characters
|
|
26
|
+
like newlines (\\n, \\r, \\r\\n, U+2028, U+2029), tabs, escape sequences, and other
|
|
27
|
+
non-printable characters that could be used to manipulate log output or inject fake log entries.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
value: Any value to sanitize. If not a string, it will be converted
|
|
31
|
+
to string representation first.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
str: Sanitized string with all control and newline characters removed.
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
>>> sanitize_for_logging("Hello\\nWorld")
|
|
38
|
+
'HelloWorld'
|
|
39
|
+
>>> sanitize_for_logging("Test\\x1b[31mRed\\x1b[0m")
|
|
40
|
+
'TestRed'
|
|
41
|
+
>>> sanitize_for_logging("Fake\u2028Log")
|
|
42
|
+
'FakeLog'
|
|
43
|
+
>>> sanitize_for_logging("line1\\r\\nline2\\rline3\\nline4")
|
|
44
|
+
'line1line2line3line4'
|
|
45
|
+
>>> sanitize_for_logging("A\u2028B\u2029C")
|
|
46
|
+
'ABC'
|
|
47
|
+
>>> sanitize_for_logging(123)
|
|
48
|
+
'123'
|
|
49
|
+
"""
|
|
50
|
+
if value is None:
|
|
51
|
+
return ''
|
|
52
|
+
if not isinstance(value, str):
|
|
53
|
+
value = str(value)
|
|
54
|
+
value = _CONTROL_CHARS_RE.sub('', value)
|
|
55
|
+
value = _UNICODE_NEWLINES_RE.sub('', value)
|
|
56
|
+
value = _STANDARD_NEWLINES_RE.sub('', value)
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def summarize_tool_approval_response_for_logging(data: Any) -> str:
|
|
61
|
+
"""Return a non-sensitive summary of a tool approval response payload.
|
|
62
|
+
|
|
63
|
+
This is intentionally conservative: it never logs tool argument values or
|
|
64
|
+
rejection reasons because these can contain sensitive user content.
|
|
65
|
+
|
|
66
|
+
Expected input shape (from websocket):
|
|
67
|
+
{
|
|
68
|
+
"type": "tool_approval_response",
|
|
69
|
+
"tool_call_id": "...",
|
|
70
|
+
"approved": true/false,
|
|
71
|
+
"arguments": {...},
|
|
72
|
+
"reason": "..."
|
|
73
|
+
}
|
|
74
|
+
"""
|
|
75
|
+
if not isinstance(data, dict):
|
|
76
|
+
return f"type=tool_approval_response payload_type={sanitize_for_logging(type(data).__name__)}"
|
|
77
|
+
|
|
78
|
+
tool_call_id = sanitize_for_logging(data.get("tool_call_id"))
|
|
79
|
+
approved_raw = data.get("approved", False)
|
|
80
|
+
approved = bool(approved_raw)
|
|
81
|
+
|
|
82
|
+
arguments = data.get("arguments")
|
|
83
|
+
has_arguments = arguments is not None
|
|
84
|
+
arguments_count = len(arguments) if isinstance(arguments, dict) else (1 if has_arguments else 0)
|
|
85
|
+
|
|
86
|
+
reason = data.get("reason")
|
|
87
|
+
has_reason = bool(reason)
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
"type=tool_approval_response "
|
|
91
|
+
f"tool_call_id={tool_call_id} "
|
|
92
|
+
f"approved={approved} "
|
|
93
|
+
f"has_arguments={has_arguments} "
|
|
94
|
+
f"arguments_count={arguments_count} "
|
|
95
|
+
f"has_reason={has_reason}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def get_current_user(request: Request) -> str:
|
|
101
|
+
"""Get current user from request state (set by middleware)."""
|
|
102
|
+
return getattr(request.state, 'user_email', 'test@test.com')
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Metrics logging utility for tracking user activities without capturing sensitive data.
|
|
3
|
+
|
|
4
|
+
This module provides a centralized way to log user activity metrics that:
|
|
5
|
+
- Use the [METRIC] prefix for easy filtering
|
|
6
|
+
- Include the username for tracking
|
|
7
|
+
- Only log metadata (counts, sizes, types)
|
|
8
|
+
- NEVER log sensitive data like prompts, tool arguments, filenames, or error details
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from atlas.core.metrics_logger import log_metric
|
|
12
|
+
|
|
13
|
+
log_metric("llm_call", user_email, model="gpt-4", message_count=5)
|
|
14
|
+
log_metric("tool_call", user_email, tool_name="calculator")
|
|
15
|
+
log_metric("file_upload", user_email, file_size=1024, content_type="application/pdf")
|
|
16
|
+
log_metric("error", user_email, error_type="rate_limit")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def log_metric(
|
|
26
|
+
event_type: str,
|
|
27
|
+
user_email: Optional[str] = None,
|
|
28
|
+
**kwargs: Any
|
|
29
|
+
) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Log a metric event for user activity tracking.
|
|
32
|
+
|
|
33
|
+
This function respects the FEATURE_METRICS_LOGGING_ENABLED setting.
|
|
34
|
+
When disabled, no metrics are logged.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
event_type: Type of event (e.g., "llm_call", "tool_call", "file_upload", "error")
|
|
38
|
+
user_email: User's email address (will be sanitized)
|
|
39
|
+
**kwargs: Additional metadata to log (only non-sensitive data)
|
|
40
|
+
"""
|
|
41
|
+
# Import here to avoid circular dependencies
|
|
42
|
+
from atlas.core.log_sanitizer import sanitize_for_logging
|
|
43
|
+
from atlas.modules.config import config_manager
|
|
44
|
+
|
|
45
|
+
if not config_manager.app_settings.feature_metrics_logging_enabled:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
sanitized_user = sanitize_for_logging(user_email) if user_email else "unknown"
|
|
49
|
+
|
|
50
|
+
parts = [f"[METRIC] [{sanitized_user}] {event_type}"]
|
|
51
|
+
|
|
52
|
+
if kwargs:
|
|
53
|
+
metadata_parts = [
|
|
54
|
+
f"{key}={sanitize_for_logging(value)}"
|
|
55
|
+
for key, value in kwargs.items()
|
|
56
|
+
]
|
|
57
|
+
parts.append(" ".join(metadata_parts))
|
|
58
|
+
|
|
59
|
+
logger.info(" ".join(parts))
|
atlas/core/middleware.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""FastAPI middleware for authentication and logging."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from fastapi import Request
|
|
6
|
+
from fastapi.responses import JSONResponse, RedirectResponse
|
|
7
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
8
|
+
from starlette.responses import Response
|
|
9
|
+
|
|
10
|
+
from atlas.core.auth import get_user_from_aws_alb_jwt, get_user_from_header
|
|
11
|
+
from atlas.core.capabilities import verify_file_token
|
|
12
|
+
from atlas.infrastructure.app_factory import app_factory
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthMiddleware(BaseHTTPMiddleware):
|
|
18
|
+
"""Middleware to handle authentication and logging."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
app,
|
|
23
|
+
debug_mode: bool = False,
|
|
24
|
+
auth_header_name: str = "X-User-Email",
|
|
25
|
+
auth_header_type: str = "email-string",
|
|
26
|
+
auth_aws_expected_alb_arn: str = "",
|
|
27
|
+
auth_aws_region: str = "us-east-1",
|
|
28
|
+
proxy_secret_enabled: bool = False,
|
|
29
|
+
proxy_secret_header: str = "X-Proxy-Secret",
|
|
30
|
+
proxy_secret: str = None,
|
|
31
|
+
auth_redirect_url: str = "/auth"
|
|
32
|
+
):
|
|
33
|
+
super().__init__(app)
|
|
34
|
+
self.debug_mode = debug_mode
|
|
35
|
+
self.auth_header_name = auth_header_name
|
|
36
|
+
self.auth_header_type = auth_header_type
|
|
37
|
+
self.auth_aws_expected_alb_arn = auth_aws_expected_alb_arn
|
|
38
|
+
self.auth_aws_region = auth_aws_region
|
|
39
|
+
self.proxy_secret_enabled = proxy_secret_enabled
|
|
40
|
+
self.proxy_secret_header = proxy_secret_header
|
|
41
|
+
self.proxy_secret = proxy_secret
|
|
42
|
+
self.auth_redirect_url = auth_redirect_url
|
|
43
|
+
|
|
44
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
45
|
+
# Log request
|
|
46
|
+
logger.debug("Request: %s %s", request.method, request.url.path)
|
|
47
|
+
|
|
48
|
+
# Skip auth for static files, health check, and configured auth endpoint
|
|
49
|
+
if (request.url.path.startswith('/static') or
|
|
50
|
+
request.url.path == '/api/health' or
|
|
51
|
+
request.url.path == self.auth_redirect_url):
|
|
52
|
+
return await call_next(request)
|
|
53
|
+
|
|
54
|
+
# Validate proxy secret if enabled (skip in debug mode for local development)
|
|
55
|
+
if self.proxy_secret_enabled and self.proxy_secret and not self.debug_mode:
|
|
56
|
+
proxy_secret_value = request.headers.get(self.proxy_secret_header)
|
|
57
|
+
|
|
58
|
+
if not proxy_secret_value or proxy_secret_value != self.proxy_secret:
|
|
59
|
+
logger.warning(f"Invalid or missing proxy secret for {request.url.path}")
|
|
60
|
+
# Distinguish between API endpoints (return 401) and browser endpoints (redirect)
|
|
61
|
+
if request.url.path.startswith('/api/'):
|
|
62
|
+
return JSONResponse(
|
|
63
|
+
status_code=401,
|
|
64
|
+
content={"detail": "Unauthorized: Invalid proxy secret"}
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
return RedirectResponse(url=self.auth_redirect_url, status_code=302)
|
|
68
|
+
|
|
69
|
+
# Check for capability token in download URLs (allows MCP servers to access files)
|
|
70
|
+
if request.url.path.startswith('/api/files/download/'):
|
|
71
|
+
token = request.query_params.get('token')
|
|
72
|
+
if token:
|
|
73
|
+
claims = verify_file_token(token)
|
|
74
|
+
if claims:
|
|
75
|
+
# Valid capability token - extract user from token and allow request
|
|
76
|
+
# Note: We only validate token authenticity here (authentication).
|
|
77
|
+
# The route handler validates that token's file key matches the requested
|
|
78
|
+
# file (authorization). This separation of concerns keeps middleware focused
|
|
79
|
+
# on authentication while route handlers handle resource-specific authorization.
|
|
80
|
+
user_email = claims.get('u')
|
|
81
|
+
if user_email:
|
|
82
|
+
logger.debug("Authenticated via capability token for user: %s", user_email)
|
|
83
|
+
request.state.user_email = user_email
|
|
84
|
+
return await call_next(request)
|
|
85
|
+
else:
|
|
86
|
+
logger.warning("Valid token but missing user email claim")
|
|
87
|
+
else:
|
|
88
|
+
logger.warning("Invalid capability token provided")
|
|
89
|
+
|
|
90
|
+
# Check authentication via configured header (default: X-User-Email)
|
|
91
|
+
user_email = None
|
|
92
|
+
if self.debug_mode:
|
|
93
|
+
# In debug mode, honor auth header if provided, otherwise use config test user
|
|
94
|
+
x_auth_header = request.headers.get(self.auth_header_name)
|
|
95
|
+
if x_auth_header:
|
|
96
|
+
# Apply same authentication logic as production for testing
|
|
97
|
+
if self.auth_header_type == "aws-alb-jwt":
|
|
98
|
+
user_email = get_user_from_aws_alb_jwt(x_auth_header, self.auth_aws_expected_alb_arn, self.auth_aws_region)
|
|
99
|
+
else:
|
|
100
|
+
user_email = get_user_from_header(x_auth_header)
|
|
101
|
+
else:
|
|
102
|
+
# Get test user from config
|
|
103
|
+
config_manager = app_factory.get_config_manager()
|
|
104
|
+
user_email = config_manager.app_settings.test_user
|
|
105
|
+
# logger.info(f"Debug mode: using user {user_email}")
|
|
106
|
+
else:
|
|
107
|
+
x_auth_header = request.headers.get(self.auth_header_name)
|
|
108
|
+
|
|
109
|
+
# Extract the user's email, depending on the datatype of auth header
|
|
110
|
+
if self.auth_header_type == "aws-alb-jwt": # Amazon Application Load Balancer
|
|
111
|
+
user_email = get_user_from_aws_alb_jwt(x_auth_header, self.auth_aws_expected_alb_arn, self.auth_aws_region)
|
|
112
|
+
else:
|
|
113
|
+
user_email = get_user_from_header(x_auth_header)
|
|
114
|
+
|
|
115
|
+
if not user_email:
|
|
116
|
+
# Distinguish between API endpoints (return 401) and browser endpoints (redirect)
|
|
117
|
+
if request.url.path.startswith('/api/'):
|
|
118
|
+
logger.warning(f"Missing authentication for API endpoint: {request.url.path}")
|
|
119
|
+
return JSONResponse(
|
|
120
|
+
status_code=401,
|
|
121
|
+
content={"detail": "Unauthorized"}
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
logger.warning(f"Missing {self.auth_header_name}, redirecting to {self.auth_redirect_url}")
|
|
125
|
+
return RedirectResponse(url=self.auth_redirect_url, status_code=302)
|
|
126
|
+
|
|
127
|
+
# Add user to request state
|
|
128
|
+
request.state.user_email = user_email
|
|
129
|
+
|
|
130
|
+
response = await call_next(request)
|
|
131
|
+
return response
|