Add encryption and setup wizard features
ENCRYPTION: - Add cryptography library for secure storage - Implement Fernet encryption for sensitive settings - Key derived from SESSION_SECRET - Auto-encrypt/decrypt transparent to app - "enc:" prefix identifies encrypted values - Graceful fallback if crypto unavailable SETUP WIZARD: - Detect fresh installs needing configuration - 3-step wizard: Infrastructure, Security, AI Services - "/" redirects to wizard if setup required - Auto-generate session_secret option - Skip option for advanced users - Beautiful UI with progress indicators UI IMPROVEMENTS: - Enhanced sensitive field display - Lock icon showing encryption status - Improved show/hide toggle for passwords - Better visual hierarchy FILES: - app/utils/encryption.py - Encryption utilities - app/utils/setup_wizard.py - Wizard detection logic - app/views/wizard.py - Wizard routes - frontend/templates/setup_wizard.html - Wizard UI - requirements.txt - Added cryptography - IMPLEMENTATION_CHECKLIST.md - Status tracking Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Encryption utilities for securing sensitive settings in the database.
|
||||
|
||||
Uses Fernet symmetric encryption with a key derived from SESSION_SECRET.
|
||||
This provides encryption at rest for sensitive configuration values.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import base64
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Lazy-load cryptography to avoid import errors if not installed
|
||||
_cipher_suite = None
|
||||
|
||||
|
||||
def _get_cipher_suite():
|
||||
"""
|
||||
Get or create the Fernet cipher suite for encryption/decryption.
|
||||
|
||||
The encryption key is derived from SESSION_SECRET to ensure:
|
||||
1. Settings are encrypted at rest in the database
|
||||
2. The same key is used across app restarts
|
||||
3. No additional secret management needed
|
||||
|
||||
Returns:
|
||||
Fernet cipher suite instance
|
||||
"""
|
||||
global _cipher_suite
|
||||
|
||||
if _cipher_suite is None:
|
||||
try:
|
||||
from cryptography.fernet import Fernet
|
||||
from app.config import settings
|
||||
|
||||
# Derive a Fernet-compatible key from SESSION_SECRET
|
||||
# Fernet requires a 32-byte base64-encoded key
|
||||
secret = settings.session_secret.encode('utf-8')
|
||||
|
||||
# Use SHA256 to get exactly 32 bytes, then base64 encode
|
||||
key_bytes = hashlib.sha256(secret).digest()
|
||||
fernet_key = base64.urlsafe_b64encode(key_bytes)
|
||||
|
||||
_cipher_suite = Fernet(fernet_key)
|
||||
logger.debug("Encryption cipher suite initialized")
|
||||
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"cryptography library not installed. "
|
||||
"Sensitive settings will be stored in plaintext. "
|
||||
"Install with: pip install cryptography"
|
||||
)
|
||||
_cipher_suite = None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize encryption: {e}")
|
||||
_cipher_suite = None
|
||||
|
||||
return _cipher_suite
|
||||
|
||||
|
||||
def encrypt_value(plaintext: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Encrypt a plaintext value for storage in the database.
|
||||
|
||||
Args:
|
||||
plaintext: The value to encrypt (or None)
|
||||
|
||||
Returns:
|
||||
Encrypted value as base64 string, or plaintext if encryption unavailable
|
||||
"""
|
||||
if plaintext is None or plaintext == "":
|
||||
return plaintext
|
||||
|
||||
cipher = _get_cipher_suite()
|
||||
|
||||
if cipher is None:
|
||||
# Encryption not available, store in plaintext with warning
|
||||
logger.warning("Storing sensitive value in plaintext (encryption unavailable)")
|
||||
return plaintext
|
||||
|
||||
try:
|
||||
encrypted_bytes = cipher.encrypt(plaintext.encode('utf-8'))
|
||||
# Prefix with "enc:" to identify encrypted values
|
||||
return "enc:" + encrypted_bytes.decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error(f"Encryption failed: {e}")
|
||||
# Fall back to plaintext
|
||||
return plaintext
|
||||
|
||||
|
||||
def decrypt_value(ciphertext: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Decrypt a value from the database.
|
||||
|
||||
Args:
|
||||
ciphertext: The encrypted value (or plaintext if not encrypted)
|
||||
|
||||
Returns:
|
||||
Decrypted plaintext value
|
||||
"""
|
||||
if ciphertext is None or ciphertext == "":
|
||||
return ciphertext
|
||||
|
||||
# Check if value is encrypted (has "enc:" prefix)
|
||||
if not ciphertext.startswith("enc:"):
|
||||
# Not encrypted, return as-is
|
||||
return ciphertext
|
||||
|
||||
cipher = _get_cipher_suite()
|
||||
|
||||
if cipher is None:
|
||||
logger.error("Cannot decrypt value: encryption not available")
|
||||
return "[ENCRYPTED - Cannot decrypt]"
|
||||
|
||||
try:
|
||||
# Remove "enc:" prefix and decrypt
|
||||
encrypted_bytes = ciphertext[4:].encode('utf-8')
|
||||
plaintext_bytes = cipher.decrypt(encrypted_bytes)
|
||||
return plaintext_bytes.decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error(f"Decryption failed: {e}")
|
||||
return "[DECRYPTION FAILED]"
|
||||
|
||||
|
||||
def is_encrypted(value: Optional[str]) -> bool:
|
||||
"""
|
||||
Check if a value is encrypted.
|
||||
|
||||
Args:
|
||||
value: The value to check
|
||||
|
||||
Returns:
|
||||
True if the value is encrypted, False otherwise
|
||||
"""
|
||||
return value is not None and isinstance(value, str) and value.startswith("enc:")
|
||||
|
||||
|
||||
def is_encryption_available() -> bool:
|
||||
"""
|
||||
Check if encryption is available.
|
||||
|
||||
Returns:
|
||||
True if cryptography library is installed and encryption is working
|
||||
"""
|
||||
return _get_cipher_suite() is not None
|
||||
@@ -878,16 +878,27 @@ def get_setting_from_db(db: Session, key: str) -> Optional[str]:
|
||||
"""
|
||||
Retrieve a setting value from the database.
|
||||
|
||||
Automatically decrypts sensitive values if encryption is enabled.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
key: Setting key to retrieve
|
||||
|
||||
Returns:
|
||||
Setting value as string, or None if not found
|
||||
Setting value as string (decrypted if necessary), or None if not found
|
||||
"""
|
||||
try:
|
||||
setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first()
|
||||
return setting.value if setting else None
|
||||
if not setting:
|
||||
return None
|
||||
|
||||
# Check if this setting is sensitive and should be decrypted
|
||||
metadata = get_setting_metadata(key)
|
||||
if metadata.get("sensitive", False):
|
||||
from app.utils.encryption import decrypt_value
|
||||
return decrypt_value(setting.value)
|
||||
|
||||
return setting.value
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Error retrieving setting {key} from database: {e}")
|
||||
return None
|
||||
@@ -897,6 +908,8 @@ def save_setting_to_db(db: Session, key: str, value: Optional[str]) -> bool:
|
||||
"""
|
||||
Save or update a setting in the database.
|
||||
|
||||
Automatically encrypts sensitive values if encryption is enabled.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
key: Setting key
|
||||
@@ -906,11 +919,24 @@ def save_setting_to_db(db: Session, key: str, value: Optional[str]) -> bool:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check if this setting is sensitive and should be encrypted
|
||||
metadata = get_setting_metadata(key)
|
||||
storage_value = value
|
||||
|
||||
if metadata.get("sensitive", False) and value:
|
||||
from app.utils.encryption import encrypt_value, is_encryption_available
|
||||
|
||||
if is_encryption_available():
|
||||
storage_value = encrypt_value(value)
|
||||
logger.debug(f"Encrypted sensitive setting: {key}")
|
||||
else:
|
||||
logger.warning(f"Storing sensitive setting {key} in plaintext (encryption unavailable)")
|
||||
|
||||
setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first()
|
||||
if setting:
|
||||
setting.value = value
|
||||
setting.value = storage_value
|
||||
else:
|
||||
setting = ApplicationSettings(key=key, value=value)
|
||||
setting = ApplicationSettings(key=key, value=storage_value)
|
||||
db.add(setting)
|
||||
db.commit()
|
||||
logger.info(f"Saved setting {key} to database")
|
||||
@@ -925,15 +951,28 @@ def get_all_settings_from_db(db: Session) -> Dict[str, str]:
|
||||
"""
|
||||
Retrieve all settings from the database.
|
||||
|
||||
Automatically decrypts sensitive values if encryption is enabled.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dictionary of setting key-value pairs
|
||||
Dictionary of setting key-value pairs (decrypted)
|
||||
"""
|
||||
try:
|
||||
settings = db.query(ApplicationSettings).all()
|
||||
return {setting.key: setting.value for setting in settings}
|
||||
result = {}
|
||||
|
||||
for setting in settings:
|
||||
# Check if this setting is sensitive and should be decrypted
|
||||
metadata = get_setting_metadata(setting.key)
|
||||
if metadata.get("sensitive", False):
|
||||
from app.utils.encryption import decrypt_value
|
||||
result[setting.key] = decrypt_value(setting.value)
|
||||
else:
|
||||
result[setting.key] = setting.value
|
||||
|
||||
return result
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Error retrieving all settings from database: {e}")
|
||||
return {}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Setup wizard utilities for first-time system configuration.
|
||||
|
||||
Detects if the system needs initial setup and provides required settings list.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_required_settings() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of settings that are absolutely required for the system to operate.
|
||||
|
||||
Returns:
|
||||
List of required setting definitions with metadata
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"key": "database_url",
|
||||
"label": "Database URL",
|
||||
"description": "Database connection string (e.g., sqlite:///./app/database.db)",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"default": "sqlite:///./app/database.db",
|
||||
"wizard_step": 1,
|
||||
"wizard_category": "Core Infrastructure"
|
||||
},
|
||||
{
|
||||
"key": "redis_url",
|
||||
"label": "Redis URL",
|
||||
"description": "Redis connection for task queue (e.g., redis://localhost:6379/0)",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"default": "redis://localhost:6379/0",
|
||||
"wizard_step": 1,
|
||||
"wizard_category": "Core Infrastructure"
|
||||
},
|
||||
{
|
||||
"key": "workdir",
|
||||
"label": "Working Directory",
|
||||
"description": "Directory for temporary file storage and processing",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"default": "/workdir",
|
||||
"wizard_step": 1,
|
||||
"wizard_category": "Core Infrastructure"
|
||||
},
|
||||
{
|
||||
"key": "gotenberg_url",
|
||||
"label": "Gotenberg URL",
|
||||
"description": "Gotenberg service URL for document conversion",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"default": "http://gotenberg:3000",
|
||||
"wizard_step": 1,
|
||||
"wizard_category": "Core Infrastructure"
|
||||
},
|
||||
{
|
||||
"key": "session_secret",
|
||||
"label": "Session Secret",
|
||||
"description": "Secret key for session encryption (min 32 characters, auto-generate recommended)",
|
||||
"type": "string",
|
||||
"sensitive": True,
|
||||
"default": None, # Should be generated
|
||||
"wizard_step": 2,
|
||||
"wizard_category": "Security"
|
||||
},
|
||||
{
|
||||
"key": "admin_username",
|
||||
"label": "Admin Username",
|
||||
"description": "Username for the admin account",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"default": "admin",
|
||||
"wizard_step": 2,
|
||||
"wizard_category": "Security"
|
||||
},
|
||||
{
|
||||
"key": "admin_password",
|
||||
"label": "Admin Password",
|
||||
"description": "Password for the admin account",
|
||||
"type": "string",
|
||||
"sensitive": True,
|
||||
"default": None, # Must be set
|
||||
"wizard_step": 2,
|
||||
"wizard_category": "Security"
|
||||
},
|
||||
{
|
||||
"key": "openai_api_key",
|
||||
"label": "OpenAI API Key",
|
||||
"description": "API key for OpenAI services (metadata extraction)",
|
||||
"type": "string",
|
||||
"sensitive": True,
|
||||
"default": None,
|
||||
"wizard_step": 3,
|
||||
"wizard_category": "AI Services"
|
||||
},
|
||||
{
|
||||
"key": "azure_ai_key",
|
||||
"label": "Azure AI Key",
|
||||
"description": "Azure AI key for document intelligence (OCR)",
|
||||
"type": "string",
|
||||
"sensitive": True,
|
||||
"default": None,
|
||||
"wizard_step": 3,
|
||||
"wizard_category": "AI Services"
|
||||
},
|
||||
{
|
||||
"key": "azure_region",
|
||||
"label": "Azure Region",
|
||||
"description": "Azure region for AI services (e.g., eastus)",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"default": "eastus",
|
||||
"wizard_step": 3,
|
||||
"wizard_category": "AI Services"
|
||||
},
|
||||
{
|
||||
"key": "azure_endpoint",
|
||||
"label": "Azure Endpoint",
|
||||
"description": "Azure AI endpoint URL",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"default": None,
|
||||
"wizard_step": 3,
|
||||
"wizard_category": "AI Services"
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def is_setup_required() -> bool:
|
||||
"""
|
||||
Check if the system requires initial setup.
|
||||
|
||||
Returns True if any critical required settings are missing or have placeholder values.
|
||||
|
||||
Returns:
|
||||
True if setup wizard should be shown, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Critical settings that must be configured
|
||||
critical_settings = [
|
||||
("session_secret", ["INSECURE_DEFAULT_FOR_DEVELOPMENT_ONLY_DO_NOT_USE_IN_PRODUCTION_MINIMUM_32_CHARS"]),
|
||||
("admin_password", [None, "", "your_secure_password", "changeme", "admin"]),
|
||||
("openai_api_key", [None, "", "<OPENAI_API_KEY>", "test-key"]),
|
||||
("azure_ai_key", [None, "", "<AZURE_AI_KEY>", "test-key"]),
|
||||
]
|
||||
|
||||
for setting_key, invalid_values in critical_settings:
|
||||
value = getattr(settings, setting_key, None)
|
||||
if value in invalid_values:
|
||||
logger.warning(f"Setup required: {setting_key} has placeholder or missing value")
|
||||
return True
|
||||
|
||||
# All critical settings are configured
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking if setup required: {e}")
|
||||
# If we can't check, assume setup is not required (fail open)
|
||||
return False
|
||||
|
||||
|
||||
def get_missing_required_settings() -> List[str]:
|
||||
"""
|
||||
Get list of required settings that are missing or have placeholder values.
|
||||
|
||||
Returns:
|
||||
List of setting keys that need to be configured
|
||||
"""
|
||||
missing = []
|
||||
|
||||
for required_setting in get_required_settings():
|
||||
key = required_setting["key"]
|
||||
value = getattr(settings, key, None)
|
||||
|
||||
# Check if value is missing or is a placeholder
|
||||
placeholder_values = [
|
||||
None, "",
|
||||
f"<{key.upper()}>",
|
||||
"test-key",
|
||||
"your_secure_password",
|
||||
"changeme",
|
||||
"INSECURE_DEFAULT_FOR_DEVELOPMENT_ONLY_DO_NOT_USE_IN_PRODUCTION_MINIMUM_32_CHARS"
|
||||
]
|
||||
|
||||
if value in placeholder_values:
|
||||
missing.append(key)
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
def get_wizard_steps() -> Dict[int, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get setup wizard steps organized by step number.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping step number to list of settings in that step
|
||||
"""
|
||||
steps = {}
|
||||
|
||||
for setting in get_required_settings():
|
||||
step_num = setting.get("wizard_step", 1)
|
||||
if step_num not in steps:
|
||||
steps[step_num] = []
|
||||
steps[step_num].append(setting)
|
||||
|
||||
return steps
|
||||
Reference in New Issue
Block a user