Files
roa2web-service-auto/shared/auth/email_server_cache.py
Claude Agent b137e80b71 feat: multi-Oracle server support with runtime switching
Complete implementation of multi-server Oracle database support:

Backend:
- Multi-pool Oracle with lazy loading per server
- Email-to-server cache for automatic server discovery
- JWT tokens include server_id claim
- /auth/check-identity and /auth/check-email endpoints
- /auth/my-servers endpoint for listing user's accessible servers
- Server switch with password re-authentication

Frontend:
- New ServerSelector component for header dropdown
- Multi-step login flow (identity → server → password)
- Server switching from header with password modal
- Mobile drawer menu with server selection
- Dark mode support for all new components
- URL bookmark support with ?server= query param

Scripts:
- Unified start.sh replacing start-prod.sh/start-test.sh
- Unified ssh-tunnel.sh with multi-server support
- Updated status.sh for new architecture

Tests:
- E2E tests for multi-server and single-server login flows
- Backend unit tests for all new endpoints
- Oracle multi-pool integration tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 22:39:06 +00:00

363 lines
13 KiB
Python

"""
Email-Server Cache for Multi-Oracle Auto-Discovery
Builds and maintains a cache mapping emails to server IDs:
- At startup, connects to each Oracle server and extracts emails from CONTAFIN_ORACLE.UTILIZATORI
- Cache structure: {email: [server_ids]}
- Auto-refresh every 15 minutes (configurable)
- Thread-safe with asyncio.Lock
US-003: Auto-Discovery Email-Server Cache
US-013: Added username lookup support (direct query, no caching)
"""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Set
logger = logging.getLogger(__name__)
class EmailServerCache:
"""
Cache for email-to-server mapping.
Builds a dictionary {email: [server_ids]} by querying CONTAFIN_ORACLE.UTILIZATORI
on each configured Oracle server.
Features:
- Lazy initialization (build on first access or explicit call)
- Auto-refresh at configurable intervals
- Thread-safe operations
- Graceful handling of server connection failures
"""
_instance: Optional['EmailServerCache'] = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(EmailServerCache, cls).__new__(cls)
cls._instance._cache: Dict[str, List[str]] = {}
cls._instance._last_refresh: Optional[datetime] = None
cls._instance._refresh_interval = timedelta(minutes=15)
cls._instance._lock = asyncio.Lock()
cls._instance._initialized = False
cls._instance._refresh_task: Optional[asyncio.Task] = None
return cls._instance
def set_refresh_interval(self, minutes: int) -> None:
"""
Set the cache refresh interval.
Args:
minutes: Refresh interval in minutes (default: 15)
"""
self._refresh_interval = timedelta(minutes=minutes)
logger.info(f"Email cache refresh interval set to {minutes} minutes")
async def build_cache(self) -> None:
"""
Build the email-server cache by querying all configured Oracle servers.
Connects to each server and extracts active user emails from
CONTAFIN_ORACLE.UTILIZATORI table.
"""
from shared.database.oracle_pool import oracle_pool
from backend.config import settings
async with self._lock:
logger.info("[EMAIL-CACHE] Building email-server cache...")
new_cache: Dict[str, Set[str]] = {} # Use set to avoid duplicates
servers = settings.get_oracle_servers()
if not servers:
logger.warning("[EMAIL-CACHE] No Oracle servers configured")
self._cache = {}
self._last_refresh = datetime.now()
self._initialized = True
return
for server in servers:
try:
logger.info(f"[EMAIL-CACHE] Querying server '{server.id}' ({server.name})...")
# Get connection from the multi-pool
async with oracle_pool.get_connection(server.id) as connection:
with connection.cursor() as cursor:
# Query emails from UTILIZATORI table
# Only active users (INACTIV=0, STERS=0) with valid emails
cursor.execute("""
SELECT LOWER(EMAIL) as email
FROM CONTAFIN_ORACLE.UTILIZATORI
WHERE EMAIL IS NOT NULL
AND TRIM(EMAIL) IS NOT NULL
AND INACTIV = 0
AND STERS = 0
""")
rows = cursor.fetchall()
email_count = 0
for row in rows:
email = row[0].strip().lower() if row[0] else None
if email and '@' in email: # Basic email validation
if email not in new_cache:
new_cache[email] = set()
new_cache[email].add(server.id)
email_count += 1
logger.info(f"[EMAIL-CACHE] Found {email_count} valid emails on server '{server.id}'")
except Exception as e:
# Log error but continue with other servers
logger.error(f"[EMAIL-CACHE] Failed to query server '{server.id}': {e}")
continue
# Convert sets to sorted lists for consistent ordering
self._cache = {email: sorted(list(server_ids)) for email, server_ids in new_cache.items()}
self._last_refresh = datetime.now()
self._initialized = True
total_emails = len(self._cache)
multi_server_emails = sum(1 for servers in self._cache.values() if len(servers) > 1)
logger.info(f"[EMAIL-CACHE] ✅ Cache built: {total_emails} unique emails")
logger.info(f"[EMAIL-CACHE] {multi_server_emails} emails exist on multiple servers")
async def refresh_if_needed(self) -> bool:
"""
Refresh cache if the refresh interval has passed.
Returns:
True if cache was refreshed, False otherwise
"""
if not self._initialized:
await self.build_cache()
return True
if self._last_refresh is None:
await self.build_cache()
return True
time_since_refresh = datetime.now() - self._last_refresh
if time_since_refresh >= self._refresh_interval:
await self.build_cache()
return True
return False
def get_servers_for_email(self, email: str) -> List[str]:
"""
Get list of server IDs where the email exists.
Args:
email: User email address
Returns:
List of server_ids where this email exists.
Empty list if email not found (NOT an error).
"""
if not email:
return []
normalized_email = email.strip().lower()
servers = self._cache.get(normalized_email, [])
if servers:
logger.debug(f"[EMAIL-CACHE] Email '{normalized_email}' found on servers: {servers}")
else:
logger.debug(f"[EMAIL-CACHE] Email '{normalized_email}' not found in cache")
return servers.copy() # Return a copy to prevent external modification
def is_initialized(self) -> bool:
"""Check if cache has been built at least once."""
return self._initialized
def get_cache_stats(self) -> Dict:
"""
Get cache statistics.
Returns:
Dict with cache stats (total_emails, multi_server_count, last_refresh, etc.)
"""
if not self._initialized:
return {
'initialized': False,
'total_emails': 0,
'last_refresh': None,
'refresh_interval_minutes': self._refresh_interval.total_seconds() / 60
}
multi_server = sum(1 for servers in self._cache.values() if len(servers) > 1)
server_distribution = {}
for servers in self._cache.values():
count = len(servers)
server_distribution[count] = server_distribution.get(count, 0) + 1
return {
'initialized': True,
'total_emails': len(self._cache),
'multi_server_count': multi_server,
'server_distribution': server_distribution,
'last_refresh': self._last_refresh.isoformat() if self._last_refresh else None,
'refresh_interval_minutes': self._refresh_interval.total_seconds() / 60
}
async def start_auto_refresh(self) -> None:
"""
Start background task for automatic cache refresh.
Runs refresh at the configured interval (default: 15 minutes).
"""
if self._refresh_task and not self._refresh_task.done():
logger.warning("[EMAIL-CACHE] Auto-refresh task already running")
return
async def refresh_loop():
while True:
try:
await asyncio.sleep(self._refresh_interval.total_seconds())
logger.info("[EMAIL-CACHE] Auto-refresh triggered")
await self.build_cache()
except asyncio.CancelledError:
logger.info("[EMAIL-CACHE] Auto-refresh task cancelled")
break
except Exception as e:
logger.error(f"[EMAIL-CACHE] Auto-refresh error: {e}")
# Continue running, will retry on next interval
self._refresh_task = asyncio.create_task(refresh_loop())
logger.info(f"[EMAIL-CACHE] Auto-refresh started (every {self._refresh_interval.total_seconds() / 60:.0f} minutes)")
async def stop_auto_refresh(self) -> None:
"""Stop the auto-refresh background task."""
if self._refresh_task and not self._refresh_task.done():
self._refresh_task.cancel()
try:
await self._refresh_task
except asyncio.CancelledError:
pass
self._refresh_task = None
logger.info("[EMAIL-CACHE] Auto-refresh stopped")
def clear_cache(self) -> None:
"""Clear the cache (useful for testing)."""
self._cache = {}
self._initialized = False
self._last_refresh = None
logger.info("[EMAIL-CACHE] Cache cleared")
async def get_servers_for_username(self, username: str) -> List[str]:
"""
Get list of server IDs where the username exists (US-013).
Unlike email lookup which uses the cache, username lookup queries
Oracle directly on each server. This is because:
- Usernames are less commonly used for login
- Direct query ensures fresh data
- Avoids bloating the cache with both email and username mappings
Args:
username: Username to look up (case-insensitive, converted to uppercase)
Returns:
List of server_ids where this username exists.
Empty list if username not found (NOT an error).
"""
if not username:
return []
from shared.database.oracle_pool import oracle_pool
from backend.config import settings
normalized_username = username.strip().upper()
found_servers: List[str] = []
servers = settings.get_oracle_servers()
if not servers:
logger.warning("[EMAIL-CACHE] No Oracle servers configured for username lookup")
return []
for server in servers:
try:
async with oracle_pool.get_connection(server.id) as connection:
with connection.cursor() as cursor:
# Query for username in UTILIZATORI table
# Only active users (INACTIV=0, STERS=0)
cursor.execute("""
SELECT 1
FROM CONTAFIN_ORACLE.UTILIZATORI
WHERE UPPER(UTILIZATOR) = :username
AND INACTIV = 0
AND STERS = 0
AND ROWNUM = 1
""", {"username": normalized_username})
row = cursor.fetchone()
if row:
found_servers.append(server.id)
logger.debug(f"[EMAIL-CACHE] Username '{normalized_username}' found on server '{server.id}'")
except Exception as e:
logger.error(f"[EMAIL-CACHE] Failed to query username on server '{server.id}': {e}")
continue
if found_servers:
logger.info(f"[EMAIL-CACHE] Username '{normalized_username}' found on {len(found_servers)} server(s): {found_servers}")
else:
logger.debug(f"[EMAIL-CACHE] Username '{normalized_username}' not found on any server")
return sorted(found_servers)
# Global singleton instance
email_server_cache = EmailServerCache()
# Convenience functions for external use
def get_servers_for_email(email: str) -> List[str]:
"""
Get list of server IDs where the email exists.
This is a convenience function that wraps the singleton instance.
Args:
email: User email address
Returns:
List of server_ids. Empty list if email not found (NOT an error).
"""
return email_server_cache.get_servers_for_email(email)
async def build_email_cache() -> None:
"""Build/refresh the email-server cache."""
await email_server_cache.build_cache()
async def start_email_cache_refresh() -> None:
"""Start automatic cache refresh."""
await email_server_cache.start_auto_refresh()
async def stop_email_cache_refresh() -> None:
"""Stop automatic cache refresh."""
await email_server_cache.stop_auto_refresh()
async def get_servers_for_username(username: str) -> List[str]:
"""
Get list of server IDs where the username exists (US-013).
This is a convenience function that wraps the singleton instance.
Args:
username: Username to look up (case-insensitive)
Returns:
List of server_ids. Empty list if username not found (NOT an error).
"""
return await email_server_cache.get_servers_for_username(username)