solventum-image-metadata/src/database.py
SamoilenkoVadym 189cb3dab3 Add deployment script and configure reverse proxy with Azure SSO
- Add deploy.sh for idempotent Docker deployments
- Configure API_BASE for /solventum-image-metadata-back/ reverse proxy
- Enable Azure AD SSO with public client flow (no secret required)
- Remove hardcoded tester user for production security
- Add ProxyFix middleware for reverse proxy header handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 16:37:19 +00:00

403 lines
13 KiB
Python

"""Database management for user authentication and sessions."""
import sqlite3
import os
from datetime import datetime, timedelta
from typing import Optional, Dict, List
from pathlib import Path
from .utils import get_logger
logger = get_logger(__name__)
class Database:
"""SQLite database manager for Oliver Metadata Tool."""
def __init__(self, db_path: str = None):
"""
Initialize database connection.
Args:
db_path: Path to SQLite database file (None = auto-detect based on environment)
"""
# Auto-detect database path based on environment
if db_path is None:
DOCKER_MODE = os.getenv('DOCKER_MODE', 'false').lower() == 'true'
if DOCKER_MODE:
# Use persistent data directory in Docker
db_dir = Path('/app/data')
db_dir.mkdir(parents=True, exist_ok=True)
db_path = str(db_dir / 'oliver_metadata.db')
else:
# Use current directory for local development
db_path = 'oliver_metadata.db'
self.db_path = db_path
self.conn = sqlite3.connect(db_path, check_same_thread=False)
self.conn.row_factory = sqlite3.Row
self.create_tables()
logger.info(f"Database initialized at {db_path}")
def create_tables(self):
"""Create database tables if they don't exist."""
# Users table
self.conn.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT,
email TEXT,
full_name TEXT,
auth_method TEXT DEFAULT 'local',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP,
is_active BOOLEAN DEFAULT 1
)
''')
# Sessions table
self.conn.execute('''
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
ip_address TEXT,
user_agent TEXT,
FOREIGN KEY (user_id) REFERENCES users (id)
)
''')
# Audit log table
self.conn.execute('''
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
action TEXT NOT NULL,
details TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
)
''')
# Create indexes for performance
self.conn.execute('''
CREATE INDEX IF NOT EXISTS idx_sessions_user_id
ON sessions(user_id)
''')
self.conn.execute('''
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at
ON sessions(expires_at)
''')
self.conn.execute('''
CREATE INDEX IF NOT EXISTS idx_audit_user_id
ON audit_log(user_id)
''')
self.conn.execute('''
CREATE INDEX IF NOT EXISTS idx_audit_timestamp
ON audit_log(timestamp)
''')
self.conn.commit()
logger.info("Database tables created/verified")
def get_user_by_username(self, username: str) -> Optional[Dict]:
"""
Get user by username.
Args:
username: Username to look up
Returns:
User dictionary or None if not found
"""
try:
cursor = self.conn.execute('''
SELECT * FROM users WHERE username = ? AND is_active = 1
''', (username,))
row = cursor.fetchone()
return dict(row) if row else None
except Exception as e:
logger.error(f"Error fetching user '{username}': {e}")
return None
def get_user_by_id(self, user_id: int) -> Optional[Dict]:
"""
Get user by ID.
Args:
user_id: User ID to look up
Returns:
User dictionary or None if not found
"""
try:
cursor = self.conn.execute('''
SELECT * FROM users WHERE id = ? AND is_active = 1
''', (user_id,))
row = cursor.fetchone()
return dict(row) if row else None
except Exception as e:
logger.error(f"Error fetching user ID {user_id}: {e}")
return None
def create_user(
self,
username: str,
password_hash: Optional[str] = None,
email: Optional[str] = None,
full_name: Optional[str] = None,
auth_method: str = 'local'
) -> Optional[int]:
"""
Create a new user.
Args:
username: Unique username
password_hash: Hashed password (for local auth)
email: User email
full_name: User's full name
auth_method: Authentication method ('local' or 'sso')
Returns:
User ID if successful, None otherwise
"""
try:
cursor = self.conn.execute('''
INSERT INTO users (username, password_hash, email, full_name, auth_method)
VALUES (?, ?, ?, ?, ?)
''', (username, password_hash, email, full_name, auth_method))
self.conn.commit()
user_id = cursor.lastrowid
logger.info(f"Created user '{username}' (ID: {user_id})")
return user_id
except sqlite3.IntegrityError:
logger.warning(f"User '{username}' already exists")
return None
except Exception as e:
logger.error(f"Error creating user '{username}': {e}")
return None
def update_last_login(self, user_id: int):
"""
Update user's last login timestamp.
Args:
user_id: User ID
"""
try:
self.conn.execute('''
UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?
''', (user_id,))
self.conn.commit()
except Exception as e:
logger.error(f"Error updating last login for user {user_id}: {e}")
def create_session(
self,
user_id: int,
session_id: str,
expires_in_hours: int = 24,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None
) -> bool:
"""
Create new session for user.
Args:
user_id: User ID
session_id: Unique session identifier
expires_in_hours: Session expiration time in hours
ip_address: Client IP address
user_agent: Client user agent string
Returns:
True if successful
"""
try:
expires_at = datetime.now() + timedelta(hours=expires_in_hours)
self.conn.execute('''
INSERT INTO sessions (session_id, user_id, expires_at, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?)
''', (session_id, user_id, expires_at, ip_address, user_agent))
self.conn.commit()
logger.info(f"Created session for user {user_id}")
return True
except Exception as e:
logger.error(f"Error creating session: {e}")
return False
def get_session(self, session_id: str) -> Optional[Dict]:
"""
Get session by ID.
Args:
session_id: Session ID to look up
Returns:
Session dictionary or None if not found/expired
"""
try:
cursor = self.conn.execute('''
SELECT s.*, u.username, u.email, u.full_name
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.session_id = ? AND s.expires_at > CURRENT_TIMESTAMP
''', (session_id,))
row = cursor.fetchone()
return dict(row) if row else None
except Exception as e:
logger.error(f"Error fetching session: {e}")
return None
def delete_session(self, session_id: str) -> bool:
"""
Delete session (logout).
Args:
session_id: Session ID to delete
Returns:
True if successful
"""
try:
self.conn.execute('DELETE FROM sessions WHERE session_id = ?', (session_id,))
self.conn.commit()
logger.info(f"Deleted session {session_id}")
return True
except Exception as e:
logger.error(f"Error deleting session: {e}")
return False
def cleanup_expired_sessions(self):
"""Remove expired sessions from database."""
try:
cursor = self.conn.execute('''
DELETE FROM sessions WHERE expires_at < CURRENT_TIMESTAMP
''')
self.conn.commit()
deleted_count = cursor.rowcount
if deleted_count > 0:
logger.info(f"Cleaned up {deleted_count} expired sessions")
except Exception as e:
logger.error(f"Error cleaning up sessions: {e}")
def log_action(self, user_id: int, action: str, details: Optional[str] = None):
"""
Log user action to audit trail.
Args:
user_id: User ID performing the action
action: Action description (e.g., "login", "update_metadata", "export")
details: Optional additional details (JSON string or plain text)
"""
try:
self.conn.execute('''
INSERT INTO audit_log (user_id, action, details)
VALUES (?, ?, ?)
''', (user_id, action, details))
self.conn.commit()
except Exception as e:
logger.error(f"Error logging action: {e}")
def get_user_activity(
self,
user_id: int,
limit: int = 100,
offset: int = 0
) -> List[Dict]:
"""
Get user activity log.
Args:
user_id: User ID
limit: Maximum number of records to return
offset: Offset for pagination
Returns:
List of activity records
"""
try:
cursor = self.conn.execute('''
SELECT * FROM audit_log
WHERE user_id = ?
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
''', (user_id, limit, offset))
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error fetching user activity: {e}")
return []
def get_all_users(self, include_inactive: bool = False) -> List[Dict]:
"""
Get all users.
Args:
include_inactive: Include inactive users
Returns:
List of user dictionaries
"""
try:
query = 'SELECT * FROM users'
if not include_inactive:
query += ' WHERE is_active = 1'
query += ' ORDER BY created_at DESC'
cursor = self.conn.execute(query)
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error fetching users: {e}")
return []
def get_stats(self) -> Dict:
"""
Get database statistics.
Returns:
Statistics dictionary
"""
try:
stats = {}
# Count users
cursor = self.conn.execute('SELECT COUNT(*) as count FROM users WHERE is_active = 1')
stats['active_users'] = cursor.fetchone()['count']
# Count active sessions
cursor = self.conn.execute('''
SELECT COUNT(*) as count FROM sessions
WHERE expires_at > CURRENT_TIMESTAMP
''')
stats['active_sessions'] = cursor.fetchone()['count']
# Count audit log entries
cursor = self.conn.execute('SELECT COUNT(*) as count FROM audit_log')
stats['audit_entries'] = cursor.fetchone()['count']
# Recent activity (last 24 hours)
cursor = self.conn.execute('''
SELECT COUNT(*) as count FROM audit_log
WHERE timestamp > datetime('now', '-24 hours')
''')
stats['recent_activity'] = cursor.fetchone()['count']
return stats
except Exception as e:
logger.error(f"Error fetching stats: {e}")
return {}
def close(self):
"""Close database connection."""
try:
self.conn.close()
logger.info("Database connection closed")
except Exception as e:
logger.error(f"Error closing database: {e}")