mirror of
https://github.com/aljazceru/enclava.git
synced 2025-12-17 07:24:34 +01:00
472 lines
18 KiB
Python
472 lines
18 KiB
Python
"""
|
|
Plugin Configuration Manager
|
|
Elegant, secure, and developer-friendly plugin configuration system
|
|
|
|
Design Principles:
|
|
1. Schemas embedded in plugin manifests (no hardcoding)
|
|
2. Automatic encryption for sensitive fields
|
|
3. Intelligent field type handling
|
|
4. Configuration resolution chain (defaults → user overrides)
|
|
5. Schema validation and caching
|
|
6. UUID-based operations throughout
|
|
"""
|
|
|
|
import json
|
|
import uuid
|
|
from typing import Dict, Any, List, Optional, Union, Tuple
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from cryptography.fernet import Fernet
|
|
from pydantic import BaseModel, ValidationError
|
|
import jsonschema
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import select
|
|
|
|
from app.core.config import settings
|
|
from app.core.logging import get_logger
|
|
from app.models.plugin import Plugin, PluginConfiguration
|
|
from app.utils.exceptions import PluginError
|
|
|
|
logger = get_logger("plugin.config.manager")
|
|
|
|
class ConfigurationField(BaseModel):
|
|
"""Represents a configuration field with type intelligence"""
|
|
name: str
|
|
value: Any
|
|
field_type: str
|
|
format: Optional[str] = None
|
|
is_sensitive: bool = False
|
|
is_encrypted: bool = False
|
|
validation_rules: Dict[str, Any] = {}
|
|
|
|
class ConfigurationResolver:
|
|
"""Resolves configuration from multiple sources with proper precedence"""
|
|
|
|
def __init__(self):
|
|
self.logger = get_logger("plugin.config.resolver")
|
|
|
|
def resolve_configuration(
|
|
self,
|
|
plugin_manifest: Dict[str, Any],
|
|
user_config: Optional[Dict[str, Any]] = None,
|
|
runtime_overrides: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Resolve configuration from multiple sources:
|
|
1. Manifest defaults (lowest priority)
|
|
2. User configuration (medium priority)
|
|
3. Runtime overrides (highest priority)
|
|
"""
|
|
# Start with manifest defaults
|
|
schema = plugin_manifest.get("spec", {}).get("config_schema", {})
|
|
resolved = self._extract_defaults_from_schema(schema)
|
|
|
|
# Apply user configuration
|
|
if user_config:
|
|
resolved.update(user_config)
|
|
|
|
# Apply runtime overrides
|
|
if runtime_overrides:
|
|
resolved.update(runtime_overrides)
|
|
|
|
return resolved
|
|
|
|
def _extract_defaults_from_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Extract default values from JSON schema"""
|
|
defaults = {}
|
|
properties = schema.get("properties", {})
|
|
|
|
for field_name, field_schema in properties.items():
|
|
if "default" in field_schema:
|
|
defaults[field_name] = field_schema["default"]
|
|
elif field_schema.get("type") == "object":
|
|
# Recursively extract defaults from nested objects
|
|
nested_defaults = self._extract_defaults_from_schema(field_schema)
|
|
if nested_defaults:
|
|
defaults[field_name] = nested_defaults
|
|
|
|
return defaults
|
|
|
|
class PluginEncryptionManager:
|
|
"""Handles encryption/decryption of sensitive configuration fields"""
|
|
|
|
def __init__(self):
|
|
self.logger = get_logger("plugin.encryption")
|
|
self._encryption_key = self._get_or_generate_key()
|
|
self._cipher = Fernet(self._encryption_key)
|
|
|
|
def _get_or_generate_key(self) -> bytes:
|
|
"""Get existing encryption key or generate new one"""
|
|
# In production, this should be stored securely (e.g., HashiCorp Vault)
|
|
key_env = settings.PLUGIN_ENCRYPTION_KEY if hasattr(settings, 'PLUGIN_ENCRYPTION_KEY') else None
|
|
|
|
if key_env:
|
|
return key_env.encode()
|
|
|
|
# Generate new key for development
|
|
key = Fernet.generate_key()
|
|
self.logger.warning(
|
|
"Generated new encryption key for plugin configurations. "
|
|
f"For production, set PLUGIN_ENCRYPTION_KEY environment variable"
|
|
)
|
|
return key
|
|
|
|
def encrypt_value(self, value: str) -> str:
|
|
"""Encrypt a sensitive configuration value"""
|
|
try:
|
|
encrypted = self._cipher.encrypt(value.encode())
|
|
return encrypted.decode()
|
|
except Exception as e:
|
|
self.logger.error(f"Encryption failed: {e}")
|
|
raise PluginError(f"Failed to encrypt configuration value: {e}")
|
|
|
|
def decrypt_value(self, encrypted_value: str) -> str:
|
|
"""Decrypt a sensitive configuration value"""
|
|
try:
|
|
decrypted = self._cipher.decrypt(encrypted_value.encode())
|
|
return decrypted.decode()
|
|
except Exception as e:
|
|
self.logger.error(f"Decryption failed: {e}")
|
|
raise PluginError(f"Failed to decrypt configuration value: {e}")
|
|
|
|
def identify_sensitive_fields(self, schema: Dict[str, Any]) -> List[str]:
|
|
"""Identify sensitive fields in schema that should be encrypted"""
|
|
sensitive_fields = []
|
|
properties = schema.get("properties", {})
|
|
|
|
for field_name, field_schema in properties.items():
|
|
# Check for explicit sensitive formats
|
|
format_type = field_schema.get("format", "")
|
|
if format_type in ["password", "secret", "token", "key"]:
|
|
sensitive_fields.append(field_name)
|
|
|
|
# Check for sensitive field names
|
|
if any(keyword in field_name.lower() for keyword in
|
|
["password", "secret", "token", "key", "credential", "private"]):
|
|
sensitive_fields.append(field_name)
|
|
|
|
# Recursively check nested objects
|
|
if field_schema.get("type") == "object":
|
|
nested_sensitive = self.identify_sensitive_fields(field_schema)
|
|
sensitive_fields.extend([f"{field_name}.{nested}" for nested in nested_sensitive])
|
|
|
|
return sensitive_fields
|
|
|
|
class PluginSchemaManager:
|
|
"""Manages plugin configuration schemas with caching and validation"""
|
|
|
|
def __init__(self):
|
|
self.logger = get_logger("plugin.schema.manager")
|
|
self._schema_cache: Dict[str, Dict[str, Any]] = {}
|
|
self._cache_timestamps: Dict[str, datetime] = {}
|
|
self.resolver = ConfigurationResolver()
|
|
self.encryption = PluginEncryptionManager()
|
|
|
|
async def get_plugin_schema(self, plugin_id: Union[str, uuid.UUID], db: AsyncSession) -> Optional[Dict[str, Any]]:
|
|
"""Get configuration schema for plugin (with caching)"""
|
|
plugin_uuid = self._ensure_uuid(plugin_id)
|
|
cache_key = str(plugin_uuid)
|
|
|
|
# Check cache first
|
|
if cache_key in self._schema_cache:
|
|
cache_time = self._cache_timestamps.get(cache_key)
|
|
if cache_time and (datetime.now() - cache_time).total_seconds() < 300: # 5 min cache
|
|
return self._schema_cache[cache_key]
|
|
|
|
# Load from database
|
|
stmt = select(Plugin).where(Plugin.id == plugin_uuid)
|
|
result = await db.execute(stmt)
|
|
plugin = result.scalar_one_or_none()
|
|
|
|
if not plugin:
|
|
self.logger.warning(f"Plugin not found: {plugin_id}")
|
|
return None
|
|
|
|
# Extract schema from manifest
|
|
manifest_data = plugin.manifest_data
|
|
if not manifest_data:
|
|
self.logger.warning(f"No manifest data for plugin {plugin.slug}")
|
|
return None
|
|
|
|
schema = manifest_data.get("spec", {}).get("config_schema")
|
|
if not schema:
|
|
self.logger.warning(f"No config_schema in manifest for plugin {plugin.slug}")
|
|
return None
|
|
|
|
# Cache the schema
|
|
self._schema_cache[cache_key] = schema
|
|
self._cache_timestamps[cache_key] = datetime.now()
|
|
|
|
return schema
|
|
|
|
async def validate_configuration(
|
|
self,
|
|
plugin_id: Union[str, uuid.UUID],
|
|
config_data: Dict[str, Any],
|
|
db: AsyncSession
|
|
) -> Tuple[bool, List[str]]:
|
|
"""Validate configuration against plugin schema"""
|
|
schema = await self.get_plugin_schema(plugin_id, db)
|
|
if not schema:
|
|
return False, ["No configuration schema available for plugin"]
|
|
|
|
try:
|
|
jsonschema.validate(config_data, schema)
|
|
return True, []
|
|
except jsonschema.ValidationError as e:
|
|
return False, [str(e)]
|
|
except Exception as e:
|
|
self.logger.error(f"Schema validation error: {e}")
|
|
return False, [f"Validation failed: {e}"]
|
|
|
|
async def process_configuration_fields(
|
|
self,
|
|
plugin_id: Union[str, uuid.UUID],
|
|
config_data: Dict[str, Any],
|
|
db: AsyncSession,
|
|
encrypt_sensitive: bool = True
|
|
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
"""
|
|
Process configuration fields, separating sensitive from non-sensitive data.
|
|
Returns (non_sensitive_data, encrypted_sensitive_data)
|
|
"""
|
|
schema = await self.get_plugin_schema(plugin_id, db)
|
|
if not schema:
|
|
return config_data, {}
|
|
|
|
sensitive_fields = self.encryption.identify_sensitive_fields(schema)
|
|
non_sensitive = {}
|
|
encrypted_sensitive = {}
|
|
|
|
# Process top-level and nested fields
|
|
for key, value in config_data.items():
|
|
if key in sensitive_fields and encrypt_sensitive:
|
|
# Top-level sensitive field
|
|
encrypted_value = self.encryption.encrypt_value(str(value))
|
|
encrypted_sensitive[key] = encrypted_value
|
|
elif isinstance(value, dict):
|
|
# Process nested object
|
|
nested_sensitive, nested_encrypted = self._process_nested_fields(
|
|
value, key, sensitive_fields, encrypt_sensitive
|
|
)
|
|
if nested_encrypted:
|
|
# Store nested encrypted fields with dot notation
|
|
for nested_key, encrypted_val in nested_encrypted.items():
|
|
encrypted_sensitive[f"{key}.{nested_key}"] = encrypted_val
|
|
# Store the non-sensitive parts of the nested object
|
|
if nested_sensitive:
|
|
non_sensitive[key] = nested_sensitive
|
|
else:
|
|
# No sensitive fields in this nested object
|
|
non_sensitive[key] = value
|
|
else:
|
|
non_sensitive[key] = value
|
|
|
|
return non_sensitive, encrypted_sensitive
|
|
|
|
def _process_nested_fields(
|
|
self,
|
|
nested_data: Dict[str, Any],
|
|
parent_key: str,
|
|
sensitive_fields: List[str],
|
|
encrypt_sensitive: bool
|
|
) -> Tuple[Dict[str, Any], Dict[str, str]]:
|
|
"""Process nested fields for encryption"""
|
|
nested_non_sensitive = {}
|
|
nested_encrypted = {}
|
|
|
|
for nested_key, nested_value in nested_data.items():
|
|
full_field_path = f"{parent_key}.{nested_key}"
|
|
if full_field_path in sensitive_fields and encrypt_sensitive:
|
|
# This nested field is sensitive - encrypt it
|
|
encrypted_value = self.encryption.encrypt_value(str(nested_value))
|
|
nested_encrypted[nested_key] = encrypted_value
|
|
else:
|
|
# This nested field is not sensitive
|
|
nested_non_sensitive[nested_key] = nested_value
|
|
|
|
return nested_non_sensitive, nested_encrypted
|
|
|
|
def decrypt_configuration(
|
|
self,
|
|
non_sensitive_data: Dict[str, Any],
|
|
encrypted_sensitive_data: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""Combine and decrypt configuration for plugin use"""
|
|
decrypted_config = non_sensitive_data.copy()
|
|
|
|
for key, encrypted_value in encrypted_sensitive_data.items():
|
|
try:
|
|
decrypted_value = self.encryption.decrypt_value(encrypted_value)
|
|
|
|
if "." in key:
|
|
# Handle nested fields with dot notation
|
|
parent_key, nested_key = key.split(".", 1)
|
|
if parent_key not in decrypted_config:
|
|
decrypted_config[parent_key] = {}
|
|
if isinstance(decrypted_config[parent_key], dict):
|
|
decrypted_config[parent_key][nested_key] = decrypted_value
|
|
else:
|
|
self.logger.warning(f"Cannot set nested field {key} - parent is not dict")
|
|
else:
|
|
# Top-level field
|
|
decrypted_config[key] = decrypted_value
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to decrypt field {key}: {e}")
|
|
# Continue with other fields, log error
|
|
|
|
return decrypted_config
|
|
|
|
def _ensure_uuid(self, plugin_id: Union[str, uuid.UUID]) -> uuid.UUID:
|
|
"""Ensure plugin_id is a UUID"""
|
|
if isinstance(plugin_id, uuid.UUID):
|
|
return plugin_id
|
|
|
|
try:
|
|
return uuid.UUID(plugin_id)
|
|
except ValueError:
|
|
raise PluginError(f"Invalid plugin ID format: {plugin_id}")
|
|
|
|
class PluginConfigurationManager:
|
|
"""Main configuration manager that orchestrates all operations"""
|
|
|
|
def __init__(self):
|
|
self.logger = get_logger("plugin.config.manager")
|
|
self.schema_manager = PluginSchemaManager()
|
|
self.resolver = ConfigurationResolver()
|
|
|
|
async def get_plugin_configuration_schema(
|
|
self,
|
|
plugin_id: Union[str, uuid.UUID],
|
|
db: AsyncSession
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Get configuration schema for plugin"""
|
|
return await self.schema_manager.get_plugin_schema(plugin_id, db)
|
|
|
|
async def save_plugin_configuration(
|
|
self,
|
|
plugin_id: Union[str, uuid.UUID],
|
|
user_id: int,
|
|
config_data: Dict[str, Any],
|
|
config_name: str = "Default Configuration",
|
|
config_description: Optional[str] = None,
|
|
db: AsyncSession = None
|
|
) -> PluginConfiguration:
|
|
"""Save plugin configuration with automatic encryption of sensitive fields"""
|
|
|
|
# Validate configuration against schema
|
|
is_valid, errors = await self.schema_manager.validate_configuration(plugin_id, config_data, db)
|
|
if not is_valid:
|
|
raise PluginError(f"Configuration validation failed: {', '.join(errors)}")
|
|
|
|
# Process fields (separate sensitive from non-sensitive)
|
|
non_sensitive, encrypted_sensitive = await self.schema_manager.process_configuration_fields(
|
|
plugin_id, config_data, db, encrypt_sensitive=True
|
|
)
|
|
|
|
# Check for existing configuration
|
|
plugin_uuid = self.schema_manager._ensure_uuid(plugin_id)
|
|
stmt = select(PluginConfiguration).where(
|
|
PluginConfiguration.plugin_id == plugin_uuid,
|
|
PluginConfiguration.user_id == user_id,
|
|
PluginConfiguration.is_active == True
|
|
)
|
|
result = await db.execute(stmt)
|
|
existing_config = result.scalar_one_or_none()
|
|
|
|
if existing_config:
|
|
# Update existing configuration
|
|
existing_config.config_data = non_sensitive
|
|
existing_config.encrypted_data = json.dumps(encrypted_sensitive) if encrypted_sensitive else None
|
|
existing_config.updated_at = datetime.now()
|
|
existing_config.description = config_description or existing_config.description
|
|
else:
|
|
# Create new configuration
|
|
config = PluginConfiguration(
|
|
id=uuid.uuid4(),
|
|
plugin_id=plugin_uuid,
|
|
user_id=user_id,
|
|
name=config_name,
|
|
description=config_description,
|
|
config_data=non_sensitive,
|
|
encrypted_data=json.dumps(encrypted_sensitive) if encrypted_sensitive else None,
|
|
is_active=True,
|
|
is_default=True, # First config is default
|
|
created_by_user_id=user_id
|
|
)
|
|
db.add(config)
|
|
existing_config = config
|
|
|
|
await db.commit()
|
|
return existing_config
|
|
|
|
async def get_plugin_configuration(
|
|
self,
|
|
plugin_id: Union[str, uuid.UUID],
|
|
user_id: int,
|
|
db: AsyncSession,
|
|
decrypt_sensitive: bool = True
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Get plugin configuration for user with automatic decryption"""
|
|
|
|
plugin_uuid = self.schema_manager._ensure_uuid(plugin_id)
|
|
stmt = select(PluginConfiguration).where(
|
|
PluginConfiguration.plugin_id == plugin_uuid,
|
|
PluginConfiguration.user_id == user_id,
|
|
PluginConfiguration.is_active == True
|
|
)
|
|
result = await db.execute(stmt)
|
|
config = result.scalar_one_or_none()
|
|
|
|
if not config:
|
|
return None
|
|
|
|
# Get non-sensitive data
|
|
config_data = config.config_data or {}
|
|
|
|
# Decrypt sensitive data if requested
|
|
if decrypt_sensitive and config.encrypted_data:
|
|
try:
|
|
encrypted_data = json.loads(config.encrypted_data)
|
|
decrypted_config = self.schema_manager.decrypt_configuration(config_data, encrypted_data)
|
|
return decrypted_config
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to decrypt configuration: {e}")
|
|
# Return non-sensitive data only
|
|
return config_data
|
|
|
|
return config_data
|
|
|
|
async def get_resolved_configuration(
|
|
self,
|
|
plugin_id: Union[str, uuid.UUID],
|
|
user_id: int,
|
|
db: AsyncSession,
|
|
runtime_overrides: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""Get fully resolved configuration (defaults + user config + overrides)"""
|
|
|
|
# Get plugin manifest for defaults
|
|
plugin_uuid = self.schema_manager._ensure_uuid(plugin_id)
|
|
stmt = select(Plugin).where(Plugin.id == plugin_uuid)
|
|
result = await db.execute(stmt)
|
|
plugin = result.scalar_one_or_none()
|
|
|
|
if not plugin:
|
|
raise PluginError(f"Plugin not found: {plugin_id}")
|
|
|
|
# Get user configuration
|
|
user_config = await self.get_plugin_configuration(plugin_id, user_id, db, decrypt_sensitive=True)
|
|
|
|
# Resolve configuration chain
|
|
resolved = self.resolver.resolve_configuration(
|
|
plugin_manifest=plugin.manifest_data,
|
|
user_config=user_config,
|
|
runtime_overrides=runtime_overrides
|
|
)
|
|
|
|
return resolved
|
|
|
|
# Global instance
|
|
plugin_config_manager = PluginConfigurationManager() |