diff --git a/app/api/settings.py b/app/api/settings.py index 0dad89b6..dd82bbb4 100644 --- a/app/api/settings.py +++ b/app/api/settings.py @@ -11,16 +11,17 @@ from sqlalchemy.orm import Session from app.config import settings from app.database import get_db -from app.utils.input_validation import validate_setting_key, validate_setting_key_format -from app.utils.settings_service import ( - SETTING_METADATA, - delete_setting_from_db, - get_all_settings_from_db, - get_setting_metadata, - get_settings_by_category, - save_setting_to_db, - validate_setting_value, -) +from app.utils.input_validation import (validate_setting_key, + validate_setting_key_format) +from app.utils.settings_service import (SETTING_METADATA, + delete_setting_from_db, + get_all_settings_from_db, + get_audit_log, get_setting_history, + get_setting_metadata, + get_settings_by_category, + rollback_setting, save_setting_to_db, + validate_setting_value) +from app.utils.settings_sync import notify_settings_updated logger = logging.getLogger(__name__) router = APIRouter(prefix="/settings", tags=["settings"]) @@ -36,7 +37,9 @@ def require_admin(request: Request) -> dict: """ user = request.session.get("user") if not user or not user.get("is_admin"): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required" + ) return user @@ -79,7 +82,10 @@ async def get_settings(request: Request, db: DbSession, admin: AdminUser): for key in SETTING_METADATA.keys(): if hasattr(settings, key): value = getattr(settings, key) - current_settings[key] = {"value": value, "metadata": get_setting_metadata(key)} + current_settings[key] = { + "value": value, + "metadata": get_setting_metadata(key), + } # Get settings stored in database db_settings = get_all_settings_from_db(db) @@ -87,10 +93,15 @@ async def get_settings(request: Request, db: DbSession, admin: AdminUser): # Get settings organized by category categories = get_settings_by_category() - return SettingsListResponse(settings=current_settings, categories=categories, db_settings=db_settings) + return SettingsListResponse( + settings=current_settings, categories=categories, db_settings=db_settings + ) except Exception as e: logger.error(f"Error retrieving settings: {e}") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve settings") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve settings", + ) @router.get("/{key}", response_model=SettingResponse) @@ -107,11 +118,14 @@ async def get_setting(key: str, request: Request, db: DbSession, admin: AdminUse # Get metadata metadata = get_setting_metadata(key) - return SettingResponse(key=key, value=str(value) if value is not None else None, metadata=metadata) + return SettingResponse( + key=key, value=str(value) if value is not None else None, metadata=metadata + ) except Exception as e: logger.error(f"Error retrieving setting {key}: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to retrieve setting: {key}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve setting: {key}", ) @@ -133,15 +147,31 @@ async def update_setting( if setting.value is not None: is_valid, error_message = validate_setting_value(key, setting.value) if not is_valid: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_message) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=error_message + ) + + # Determine the username for the audit log + user = request.session.get("user", {}) if hasattr(request, "session") else {} + changed_by = ( + user.get("preferred_username") + or user.get("username") + or user.get("email") + or user.get("id") + or "admin" + ) # Save to database - success = save_setting_to_db(db, key, setting.value) + success = save_setting_to_db(db, key, setting.value, changed_by=changed_by) if not success: raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save setting to database" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to save setting to database", ) + # Notify workers that settings have changed + notify_settings_updated() + # Get metadata metadata = get_setting_metadata(key) restart_required = metadata.get("restart_required", False) @@ -158,7 +188,8 @@ async def update_setting( except Exception as e: logger.error(f"Error updating setting {key}: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update setting: {key}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update setting: {key}", ) @@ -170,9 +201,23 @@ async def delete_setting(key: str, request: Request, db: DbSession, admin: Admin """ validate_setting_key(key) try: - success = delete_setting_from_db(db, key) + user = request.session.get("user", {}) if hasattr(request, "session") else {} + changed_by = ( + user.get("preferred_username") + or user.get("username") + or user.get("email") + or user.get("id") + or "admin" + ) + + success = delete_setting_from_db(db, key, changed_by=changed_by) if not success: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Setting '{key}' not found in database") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Setting '{key}' not found in database", + ) + + notify_settings_updated() return { "success": True, @@ -183,7 +228,8 @@ async def delete_setting(key: str, request: Request, db: DbSession, admin: Admin except Exception as e: logger.error(f"Error deleting setting {key}: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete setting: {key}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete setting: {key}", ) @@ -238,11 +284,16 @@ async def list_credentials(request: Request, db: DbSession, admin: AdminUser): } except Exception as e: logger.error(f"Error retrieving credential list: {e}") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve credentials") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve credentials", + ) @router.post("/bulk-update") -async def bulk_update_settings(updates: list[SettingUpdate], request: Request, db: DbSession, admin: AdminUser): +async def bulk_update_settings( + updates: list[SettingUpdate], request: Request, db: DbSession, admin: AdminUser +): """ Update multiple settings at once. Admin only. @@ -250,25 +301,152 @@ async def bulk_update_settings(updates: list[SettingUpdate], request: Request, d results = [] errors = [] + user = request.session.get("user", {}) if hasattr(request, "session") else {} + changed_by = ( + user.get("preferred_username") + or user.get("username") + or user.get("email") + or user.get("id") + or "admin" + ) + for update in updates: try: # Validate the setting value if update.value is not None: - is_valid, error_message = validate_setting_value(update.key, update.value) + is_valid, error_message = validate_setting_value( + update.key, update.value + ) if not is_valid: errors.append({"key": update.key, "error": error_message}) continue # Save to database - success = save_setting_to_db(db, update.key, update.value) + success = save_setting_to_db( + db, update.key, update.value, changed_by=changed_by + ) if success: - results.append({"key": update.key, "value": update.value, "status": "success"}) + results.append( + {"key": update.key, "value": update.value, "status": "success"} + ) else: - errors.append({"key": update.key, "error": "Failed to save to database"}) + errors.append( + {"key": update.key, "error": "Failed to save to database"} + ) except Exception as e: logger.error(f"Error updating setting {update.key}: {e}") errors.append({"key": update.key, "error": str(e)}) - restart_required = any(get_setting_metadata(result["key"]).get("restart_required", False) for result in results) + if results: + notify_settings_updated() - return {"success": len(errors) == 0, "updated": results, "errors": errors, "restart_required": restart_required} + restart_required = any( + get_setting_metadata(result["key"]).get("restart_required", False) + for result in results + ) + + return { + "success": len(errors) == 0, + "updated": results, + "errors": errors, + "restart_required": restart_required, + } + + +@router.get("/audit-log") +async def list_audit_log( + request: Request, + db: DbSession, + admin: AdminUser, + limit: int = 100, + offset: int = 0, +): + """ + Retrieve the settings audit log (most recent first). + + Returns all configuration changes recorded in the audit log. + Sensitive values are masked in the response. + Admin only. + """ + try: + entries = get_audit_log(db, limit=limit, offset=offset) + return {"entries": entries, "limit": limit, "offset": offset} + except Exception as e: + logger.error(f"Error retrieving audit log: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to retrieve audit log", + ) + + +@router.get("/{key}/history") +async def get_key_history(key: str, request: Request, db: DbSession, admin: AdminUser): + """ + Get the change history for a specific setting key. + + Returns all audit log entries for that key, most recent first. + Admin only. + """ + validate_setting_key_format(key) + try: + entries = get_setting_history(db, key) + return {"key": key, "history": entries} + except Exception as e: + logger.error(f"Error retrieving history for {key}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve history for setting: {key}", + ) + + +@router.post("/{key}/rollback/{history_id}") +async def rollback_setting_to_history( + key: str, + history_id: int, + request: Request, + db: DbSession, + admin: AdminUser, +): + """ + Revert a setting to the value it held at a specific point in the audit log. + + The ``history_id`` is the ID of the :class:`~app.models.SettingsAuditLog` + entry whose ``new_value`` should be reinstated. If that entry recorded a + deletion (``new_value`` is ``None``), the setting is removed from the + database and reverts to its ENV/default value. + + A new audit log entry is written to record the rollback. + Admin only. + """ + validate_setting_key_format(key) + try: + user = request.session.get("user", {}) if hasattr(request, "session") else {} + changed_by = ( + user.get("preferred_username") + or user.get("username") + or user.get("email") + or user.get("id") + or "admin" + ) + + success = rollback_setting(db, key, history_id, changed_by=changed_by) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"History entry {history_id} not found for setting '{key}'", + ) + + notify_settings_updated() + + return { + "success": True, + "message": f"Setting '{key}' rolled back to history entry {history_id}", + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error rolling back setting {key} to history {history_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to roll back setting: {key}", + ) diff --git a/app/celery_worker.py b/app/celery_worker.py index 1ed82467..ddc74225 100644 --- a/app/celery_worker.py +++ b/app/celery_worker.py @@ -3,30 +3,32 @@ from celery.schedules import crontab # Ensure tasks are loaded -from app import tasks # noqa: F401 - Imports app/tasks.py so Celery can register tasks - +from app import \ + tasks # noqa: F401 - Imports app/tasks.py so Celery can register tasks # Import the shared Celery instance from app.celery_app import celery from app.config import settings from app.tasks.check_credentials import check_credentials from app.tasks.convert_to_pdf import convert_to_pdf # noqa: F401 -from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf # noqa: F401 -from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt # noqa: F401 +from app.tasks.embed_metadata_into_pdf import \ + embed_metadata_into_pdf # noqa: F401 +from app.tasks.extract_metadata_with_gpt import \ + extract_metadata_with_gpt # noqa: F401 from app.tasks.imap_tasks import pull_all_inboxes # noqa: F401 from app.tasks.monitor_stalled_steps import monitor_stalled_steps # noqa: F401 - # **Ensure all tasks are imported before Celery starts** from app.tasks.process_document import process_document # noqa: F401 -from app.tasks.process_with_azure_document_intelligence import process_with_azure_document_intelligence # noqa: F401 +from app.tasks.process_with_azure_document_intelligence import \ + process_with_azure_document_intelligence # noqa: F401 from app.tasks.refine_text_with_gpt import refine_text_with_gpt # noqa: F401 from app.tasks.rotate_pdf_pages import rotate_pdf_pages # noqa: F401 from app.tasks.send_to_all import send_to_all_destinations # noqa: F401 - # Import new send tasks from app.tasks.upload_to_dropbox import upload_to_dropbox # noqa: F401 from app.tasks.upload_to_email import upload_to_email # noqa: F401 from app.tasks.upload_to_ftp import upload_to_ftp # noqa: F401 -from app.tasks.upload_to_google_drive import upload_to_google_drive # noqa: F401 +from app.tasks.upload_to_google_drive import \ + upload_to_google_drive # noqa: F401 from app.tasks.upload_to_nextcloud import upload_to_nextcloud # noqa: F401 from app.tasks.upload_to_onedrive import upload_to_onedrive # noqa: F401 from app.tasks.upload_to_paperless import upload_to_paperless # noqa: F401 @@ -34,6 +36,10 @@ from app.tasks.upload_to_s3 import upload_to_s3 # noqa: F401 from app.tasks.upload_to_sftp import upload_to_sftp # noqa: F401 from app.tasks.upload_to_webdav import upload_to_webdav # noqa: F401 from app.tasks.uptime_kuma_tasks import ping_uptime_kuma # noqa: F401 +# Register the settings reload signal handler so workers pick up config changes +from app.utils.settings_sync import register_settings_reload_signal + +register_settings_reload_signal() celery.conf.task_routes = { "app.tasks.*": {"queue": "default"}, @@ -89,4 +95,6 @@ celery.conf.beat_schedule = { } # Remove None entries from beat_schedule -celery.conf.beat_schedule = {k: v for k, v in celery.conf.beat_schedule.items() if v is not None} +celery.conf.beat_schedule = { + k: v for k, v in celery.conf.beat_schedule.items() if v is not None +} diff --git a/app/models.py b/app/models.py index 848f71bf..d62099d2 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,7 @@ # app/models.py -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, String, + Text, UniqueConstraint, func) from app.database import Base @@ -69,21 +70,33 @@ class FileProcessingStep(Base): id = Column(Integer, primary_key=True, index=True) file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True) - step_name = Column(String, nullable=False, index=True) # e.g., "hash_file", "upload_to_dropbox" - status = Column(String, nullable=False) # "pending", "in_progress", "success", "failure", "skipped" + step_name = Column( + String, nullable=False, index=True + ) # e.g., "hash_file", "upload_to_dropbox" + status = Column( + String, nullable=False + ) # "pending", "in_progress", "success", "failure", "skipped" started_at = Column(DateTime(timezone=True), nullable=True) # When step started - completed_at = Column(DateTime(timezone=True), nullable=True) # When step finished (success/failure) + completed_at = Column( + DateTime(timezone=True), nullable=True + ) # When step finished (success/failure) error_message = Column(Text, nullable=True) # Error message if status is "failure" created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) - __table_args__ = (UniqueConstraint("file_id", "step_name", name="unique_file_step"),) + __table_args__ = ( + UniqueConstraint("file_id", "step_name", name="unique_file_step"), + ) class ProcessingLog(Base): __tablename__ = "processing_logs" id = Column(Integer, primary_key=True, index=True) - file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=True) # Optional file association + file_id = Column( + Integer, ForeignKey(_FILES_ID_FK), nullable=True + ) # Optional file association task_id = Column(String, index=True) # Celery task ID step_name = Column(String) # e.g., "OCR", "convert_to_pdf", "upload_s3" status = Column(String) # "pending", "in_progress", "success", "failure" @@ -98,7 +111,29 @@ class ApplicationSettings(Base): __tablename__ = "application_settings" id = Column(Integer, primary_key=True, index=True) - key = Column(String, unique=True, index=True, nullable=False) # Setting key (e.g., 'database_url') - value = Column(String, nullable=True) # Setting value (stored as string, converted as needed) + key = Column( + String, unique=True, index=True, nullable=False + ) # Setting key (e.g., 'database_url') + value = Column( + String, nullable=True + ) # Setting value (stored as string, converted as needed) created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class SettingsAuditLog(Base): + """Audit log for all configuration changes made via the settings UI.""" + + __tablename__ = "settings_audit_log" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String, nullable=False, index=True) # Setting key that was changed + old_value = Column(String, nullable=True) # Previous value (None if first-time set) + new_value = Column(String, nullable=True) # New value (None if deleted) + changed_by = Column( + String, nullable=False + ) # Username of the admin who made the change + changed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + action = Column(String, nullable=False) # "update" or "delete" diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 5fc20ce7..3b6fc85a 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -13,7 +13,7 @@ from typing import Any, Dict, List, Optional, Tuple from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session -from app.models import ApplicationSettings +from app.models import ApplicationSettings, SettingsAuditLog logger = logging.getLogger(__name__) @@ -871,7 +871,9 @@ def get_setting_from_db(db: Session, key: str) -> Optional[str]: Setting value as string (decrypted if necessary), or None if not found """ try: - setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first() + setting = ( + db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first() + ) if not setting: return None @@ -888,16 +890,20 @@ def get_setting_from_db(db: Session, key: str) -> Optional[str]: return None -def save_setting_to_db(db: Session, key: str, value: Optional[str]) -> bool: +def save_setting_to_db( + db: Session, key: str, value: Optional[str], changed_by: str = "system" +) -> bool: """ Save or update a setting in the database. Automatically encrypts sensitive values if encryption is enabled. + Records an entry in the settings audit log. Args: db: Database session key: Setting key value: Setting value (as string) + changed_by: Username of the admin performing the change (for audit log) Returns: True if successful, False otherwise @@ -908,22 +914,53 @@ def save_setting_to_db(db: Session, key: str, value: Optional[str]) -> bool: storage_value = value if metadata.get("sensitive", False) and value: - from app.utils.encryption import encrypt_value, is_encryption_available + 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)") + logger.warning( + f"Storing sensitive setting {key} in plaintext (encryption unavailable)" + ) + + setting = ( + db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first() + ) + old_storage_value = setting.value if setting else None - setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first() if setting: setting.value = storage_value else: setting = ApplicationSettings(key=key, value=storage_value) db.add(setting) + + # Determine human-readable old value for audit log (decrypt if needed) + old_display_value = None + if old_storage_value is not None: + if metadata.get("sensitive", False): + try: + from app.utils.encryption import decrypt_value + + old_display_value = decrypt_value(old_storage_value) + except Exception: + old_display_value = old_storage_value + else: + old_display_value = old_storage_value + + # Write audit log entry + audit_entry = SettingsAuditLog( + key=key, + old_value=old_display_value, + new_value=value, + changed_by=changed_by, + action="update", + ) + db.add(audit_entry) + db.commit() - logger.info(f"Saved setting {key} to database") + logger.info(f"Saved setting {key} to database (changed_by={changed_by})") return True except SQLAlchemyError as e: logger.error(f"Error saving setting {key} to database: {e}") @@ -963,23 +1000,54 @@ def get_all_settings_from_db(db: Session) -> Dict[str, str]: return {} -def delete_setting_from_db(db: Session, key: str) -> bool: +def delete_setting_from_db(db: Session, key: str, changed_by: str = "system") -> bool: """ Delete a setting from the database. + Records an entry in the settings audit log. + Args: db: Database session key: Setting key to delete + changed_by: Username of the admin performing the change (for audit log) Returns: True if successful, False otherwise """ try: - setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first() + setting = ( + db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first() + ) if setting: + # Capture old value for audit log (decrypt if sensitive) + metadata = get_setting_metadata(key) + old_display_value = None + if setting.value is not None: + if metadata.get("sensitive", False): + try: + from app.utils.encryption import decrypt_value + + old_display_value = decrypt_value(setting.value) + except Exception: + old_display_value = setting.value + else: + old_display_value = setting.value + db.delete(setting) + + audit_entry = SettingsAuditLog( + key=key, + old_value=old_display_value, + new_value=None, + changed_by=changed_by, + action="delete", + ) + db.add(audit_entry) + db.commit() - logger.info(f"Deleted setting {key} from database") + logger.info( + f"Deleted setting {key} from database (changed_by={changed_by})" + ) return True return False except SQLAlchemyError as e: @@ -1061,3 +1129,161 @@ def validate_setting_value(key: str, value: str) -> Tuple[bool, Optional[str]]: return False, "session_secret must be at least 32 characters" return True, None + + +def get_audit_log( + db: Session, limit: int = 100, offset: int = 0 +) -> List[Dict[str, Any]]: + """ + Retrieve the settings audit log, most recent first. + + Sensitive values are masked in the returned list so the log is safe to + display in the admin UI without leaking secrets. + + Args: + db: Database session + limit: Maximum number of entries to return + offset: Number of entries to skip (for pagination) + + Returns: + List of audit log entry dicts ordered by changed_at descending + """ + try: + entries = ( + db.query(SettingsAuditLog) + .order_by(SettingsAuditLog.changed_at.desc()) + .limit(limit) + .offset(offset) + .all() + ) + result = [] + for entry in entries: + meta = get_setting_metadata(entry.key) + is_sensitive = meta.get("sensitive", False) + result.append( + { + "id": entry.id, + "key": entry.key, + "old_value": ( + "[REDACTED]" + if is_sensitive and entry.old_value + else entry.old_value + ), + "new_value": ( + "[REDACTED]" + if is_sensitive and entry.new_value + else entry.new_value + ), + "changed_by": entry.changed_by, + "changed_at": ( + entry.changed_at.isoformat() if entry.changed_at else None + ), + "action": entry.action, + } + ) + return result + except SQLAlchemyError as e: + logger.error(f"Error retrieving audit log: {e}") + return [] + + +def get_setting_history(db: Session, key: str) -> List[Dict[str, Any]]: + """ + Retrieve the change history for a specific setting key, most recent first. + + Sensitive values are masked so the response is safe to surface in the UI. + + Args: + db: Database session + key: Setting key + + Returns: + List of audit log entry dicts for this key + """ + try: + entries = ( + db.query(SettingsAuditLog) + .filter(SettingsAuditLog.key == key) + .order_by(SettingsAuditLog.changed_at.desc()) + .all() + ) + meta = get_setting_metadata(key) + is_sensitive = meta.get("sensitive", False) + result = [] + for entry in entries: + result.append( + { + "id": entry.id, + "key": entry.key, + "old_value": ( + "[REDACTED]" + if is_sensitive and entry.old_value + else entry.old_value + ), + "new_value": ( + "[REDACTED]" + if is_sensitive and entry.new_value + else entry.new_value + ), + "changed_by": entry.changed_by, + "changed_at": ( + entry.changed_at.isoformat() if entry.changed_at else None + ), + "action": entry.action, + } + ) + return result + except SQLAlchemyError as e: + logger.error(f"Error retrieving history for setting {key}: {e}") + return [] + + +def rollback_setting( + db: Session, key: str, history_id: int, changed_by: str = "system" +) -> bool: + """ + Revert a setting to the value recorded in a specific audit log entry. + + The value stored in the chosen history entry's ``new_value`` field is + re-applied as the current database value. If that value is ``None`` + (i.e. the entry recorded a deletion) the setting is removed from the + database entirely, reverting to ENV/defaults. + + A new audit log entry is written to record the rollback operation. + + Args: + db: Database session + key: Setting key to roll back + history_id: ID of the SettingsAuditLog entry whose ``new_value`` + should become the restored value + changed_by: Username performing the rollback (for audit log) + + Returns: + True if successful, False if the history entry was not found or an + error occurred + """ + try: + history_entry = ( + db.query(SettingsAuditLog) + .filter(SettingsAuditLog.id == history_id, SettingsAuditLog.key == key) + .first() + ) + if not history_entry: + logger.warning( + f"Rollback failed: audit log entry {history_id} not found for key '{key}'" + ) + return False + + target_value = history_entry.new_value + + if target_value is None: + # The history entry recorded a deletion – reinstate that by deleting the current db value + return delete_setting_from_db(db, key, changed_by=changed_by) + else: + return save_setting_to_db(db, key, target_value, changed_by=changed_by) + except SQLAlchemyError as e: + logger.error( + f"Error rolling back setting {key} to history entry {history_id}: {e}" + ) + db.rollback() + return False diff --git a/app/utils/settings_sync.py b/app/utils/settings_sync.py new file mode 100644 index 00000000..8ffd03d1 --- /dev/null +++ b/app/utils/settings_sync.py @@ -0,0 +1,86 @@ +""" +Worker settings synchronisation helper. + +When an admin saves a configuration change through the UI, any running Celery +workers still hold the *old* values in their in-process ``settings`` singleton. +This module provides two complementary mechanisms to propagate the change: + +1. **Publish** (API side): :func:`notify_settings_updated` writes a monotonically + increasing timestamp to a Redis key. This is called immediately after every + successful ``save_setting_to_db`` / ``delete_setting_from_db`` operation. + +2. **Subscribe** (worker side): :func:`register_settings_reload_signal` installs + a Celery ``task_prerun`` signal handler. Before each task begins the handler + reads the Redis version key; if it has changed since the last reload it calls + :func:`~app.utils.config_loader.reload_settings_from_db` so the worker picks + up the new values *before* executing the task body. + +The Redis key used is ``docuelevate:settings_version``. Workers cache the last +seen version in a module-level variable to avoid redundant DB round-trips when +nothing has changed. +""" + +import logging +import time + +import redis +from celery.signals import task_prerun + +logger = logging.getLogger(__name__) + +#: Redis key that stores the current settings "version" (epoch timestamp string). +SETTINGS_VERSION_KEY = "docuelevate:settings_version" + +#: Module-level cache: the settings version seen by *this* process on its last reload. +_last_seen_version: str = "" + + +def notify_settings_updated() -> None: + """ + Publish a settings-updated signal by updating the Redis version key. + + Call this after every successful settings write so that all worker + processes know they need to reload their in-memory configuration. + + Errors are caught and logged rather than raised so that a Redis + connectivity issue does not prevent the primary save from succeeding. + """ + try: + from app.config import settings + + r = redis.from_url(settings.redis_url, socket_connect_timeout=2) + version = str(time.time()) + r.set(SETTINGS_VERSION_KEY, version) + logger.debug(f"Settings version bumped to {version}") + except Exception as exc: + logger.warning(f"Could not publish settings update to Redis: {exc}") + + +def register_settings_reload_signal() -> None: + """ + Install a Celery ``task_prerun`` signal handler for worker processes. + + This should be called once during Celery worker initialisation (e.g. from + ``celery_worker.py``). After registration, every task will check the + settings version key in Redis before it starts and reload configuration + from the database if a newer version is detected. + """ + + @task_prerun.connect(weak=False) + def _reload_if_stale(sender, **kwargs) -> None: # type: ignore[misc] + """Reload settings from DB if the Redis version key has changed.""" + global _last_seen_version + try: + from app.config import settings + from app.utils.config_loader import reload_settings_from_db + + r = redis.from_url(settings.redis_url, socket_connect_timeout=2) + current_version = (r.get(SETTINGS_VERSION_KEY) or b"").decode() + if current_version and current_version != _last_seen_version: + reload_settings_from_db(settings) + _last_seen_version = current_version + logger.info(f"Worker settings reloaded (version={current_version})") + except Exception as exc: + logger.debug(f"Settings version check skipped: {exc}") + + logger.info("Settings reload signal handler registered on task_prerun") diff --git a/app/views/settings.py b/app/views/settings.py index 7379d874..3b40a94d 100644 --- a/app/views/settings.py +++ b/app/views/settings.py @@ -12,13 +12,12 @@ from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session from app.utils.config_validator.masking import mask_sensitive_value -from app.utils.settings_service import ( - SETTING_METADATA, - get_all_settings_from_db, - get_setting_metadata, - get_settings_by_category, -) -from app.views.base import APIRouter, get_db, require_login, settings, templates +from app.utils.settings_service import (SETTING_METADATA, + get_all_settings_from_db, + get_setting_metadata, + get_settings_by_category) +from app.views.base import (APIRouter, get_db, require_login, settings, + templates) logger = logging.getLogger(__name__) router = APIRouter() @@ -103,7 +102,9 @@ async def settings_page(request: Request, db: Session = Depends(get_db)): settings_data[category].append( { "key": key, - "display_value": display_value if display_value is not None else "", + "display_value": ( + display_value if display_value is not None else "" + ), "metadata": metadata, "source": source, "source_label": source_label, @@ -112,11 +113,19 @@ async def settings_page(request: Request, db: Session = Depends(get_db)): ) return templates.TemplateResponse( - "settings.html", {"request": request, "settings_data": settings_data, "app_version": settings.version} + "settings.html", + { + "request": request, + "settings_data": settings_data, + "app_version": settings.version, + }, ) except Exception as e: logger.error(f"Error loading settings page: {e}") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to load settings page") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load settings page", + ) @router.get("/admin/credentials") @@ -166,7 +175,9 @@ async def credentials_page(request: Request, db: Session = Depends(get_db)): ) total = sum(len(v) for v in categories.values()) - configured_count = sum(1 for creds in categories.values() for c in creds if c["configured"]) + configured_count = sum( + 1 for creds in categories.values() for c in creds if c["configured"] + ) return templates.TemplateResponse( "credentials.html", @@ -181,4 +192,39 @@ async def credentials_page(request: Request, db: Session = Depends(get_db)): ) except Exception as e: logger.error(f"Error loading credentials page: {e}") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to load credentials page") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load credentials page", + ) + + +@router.get("/admin/settings/audit-log") +@require_login +@require_admin_access +async def audit_log_page(request: Request, db: Session = Depends(get_db)): + """ + Settings audit log page - admin only. + + Displays a chronological log of all configuration changes made via the + settings UI, including who made the change and what the old/new values + were. Sensitive values are masked. Provides rollback buttons to revert + any setting to a previous value. + """ + from app.utils.settings_service import get_audit_log + + try: + entries = get_audit_log(db, limit=200) + return templates.TemplateResponse( + "audit_log.html", + { + "request": request, + "entries": entries, + "app_version": settings.version, + }, + ) + except Exception as e: + logger.error(f"Error loading audit log page: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load audit log page", + ) diff --git a/frontend/templates/audit_log.html b/frontend/templates/audit_log.html new file mode 100644 index 00000000..fa16bac6 --- /dev/null +++ b/frontend/templates/audit_log.html @@ -0,0 +1,157 @@ +{% extends "base.html" %} +{% block title %}Settings Audit Log - DocuElevate{% endblock %} + +{% block content %} +
+ Chronological record of all configuration changes made via the settings UI. + Sensitive values are masked. Use the rollback button to revert any setting to a prior value. +
+| When | +Changed By | +Setting Key | +Action | +Old Value | +New Value | +Rollback | +
|---|---|---|---|---|---|---|
| {{ entry.changed_at }} | +{{ entry.changed_by }} | +{{ entry.key }} | ++ {% if entry.action == 'delete' %} + delete + {% elif entry.action == 'rollback' %} + rollback + {% else %} + update + {% endif %} + | ++ {% if entry.old_value %} + {{ entry.old_value }} + {% else %} + — + {% endif %} + | ++ {% if entry.new_value %} + {{ entry.new_value }} + {% else %} + — (deleted) + {% endif %} + | ++ + | +
No configuration changes recorded yet.
+Changes you make on the Settings page will appear here.
+- This is a convenience feature to view and edit application settings through the web interface. -
++ This is a convenience feature to view and edit application settings through the web interface. +
+📋 Settings Precedence Order: