90e5e0037c
A) Per-option Save Button
- Add per-setting Save button in settings.html (visible only when value changed)
- Button calls POST /api/settings/{key} directly; existing bulk Save retained
- Add Audit Log link in settings page header
B) Immediate Worker Sync
- New app/utils/settings_sync.py with notify_settings_updated() (Redis version key)
and register_settings_reload_signal() (Celery task_prerun handler)
- Register signal in celery_worker.py at startup
- All API write paths call notify_settings_updated() after successful saves
C) Audit Log
- Add SettingsAuditLog model (key, old_value, new_value, changed_by, changed_at, action)
- save_setting_to_db / delete_setting_from_db accept changed_by and write audit entries
- New get_audit_log() service function (masks sensitive values)
- New GET /api/settings/audit-log endpoint (admin-only)
- New GET /admin/settings/audit-log view + audit_log.html template
- Visible to all admins (per clarified requirement)
D) Config Rollback / History
- New get_setting_history() and rollback_setting() service functions
- New GET /api/settings/{key}/history endpoint
- New POST /api/settings/{key}/rollback/{history_id} endpoint
- Rollback buttons in audit_log.html with confirmation dialog
- Tests: 25 new tests covering audit log, rollback, worker sync helpers, and API endpoints
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
453 lines
14 KiB
Python
453 lines
14 KiB
Python
"""
|
|
API endpoints for managing application settings.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Annotated, Any, Dict, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from pydantic import BaseModel, Field
|
|
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_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"])
|
|
|
|
|
|
def require_admin(request: Request) -> dict:
|
|
"""
|
|
Dependency to ensure the user is an admin.
|
|
Raises HTTPException if not admin.
|
|
|
|
Returns:
|
|
User dict from session
|
|
"""
|
|
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"
|
|
)
|
|
return user
|
|
|
|
|
|
DbSession = Annotated[Session, Depends(get_db)]
|
|
AdminUser = Annotated[dict, Depends(require_admin)]
|
|
|
|
|
|
class SettingUpdate(BaseModel):
|
|
"""Model for updating a setting"""
|
|
|
|
key: str = Field(..., description="Setting key")
|
|
value: Optional[str] = Field(None, description="Setting value (None to delete)")
|
|
|
|
|
|
class SettingResponse(BaseModel):
|
|
"""Model for setting response"""
|
|
|
|
key: str
|
|
value: Optional[str]
|
|
metadata: Dict[str, Any]
|
|
|
|
|
|
class SettingsListResponse(BaseModel):
|
|
"""Model for list of settings"""
|
|
|
|
settings: Dict[str, Any]
|
|
categories: Dict[str, list]
|
|
db_settings: Dict[str, str]
|
|
|
|
|
|
@router.get("/", response_model=SettingsListResponse)
|
|
async def get_settings(request: Request, db: DbSession, admin: AdminUser):
|
|
"""
|
|
Get all application settings with metadata.
|
|
Admin only.
|
|
"""
|
|
try:
|
|
# Get current runtime settings
|
|
current_settings = {}
|
|
for key in SETTING_METADATA.keys():
|
|
if hasattr(settings, key):
|
|
value = getattr(settings, key)
|
|
current_settings[key] = {
|
|
"value": value,
|
|
"metadata": get_setting_metadata(key),
|
|
}
|
|
|
|
# Get settings stored in database
|
|
db_settings = get_all_settings_from_db(db)
|
|
|
|
# Get settings organized by category
|
|
categories = get_settings_by_category()
|
|
|
|
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",
|
|
)
|
|
|
|
|
|
@router.get("/{key}", response_model=SettingResponse)
|
|
async def get_setting(key: str, request: Request, db: DbSession, admin: AdminUser):
|
|
"""
|
|
Get a specific setting by key.
|
|
Admin only.
|
|
"""
|
|
validate_setting_key_format(key)
|
|
try:
|
|
# Get current value
|
|
value = getattr(settings, key, None)
|
|
|
|
# Get metadata
|
|
metadata = get_setting_metadata(key)
|
|
|
|
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}",
|
|
)
|
|
|
|
|
|
@router.post("/{key}")
|
|
async def update_setting(
|
|
key: str,
|
|
setting: SettingUpdate,
|
|
request: Request,
|
|
db: DbSession,
|
|
admin: AdminUser,
|
|
):
|
|
"""
|
|
Update a specific setting.
|
|
Admin only.
|
|
"""
|
|
validate_setting_key(key)
|
|
try:
|
|
# Validate the setting value
|
|
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
|
|
)
|
|
|
|
# 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, changed_by=changed_by)
|
|
if not success:
|
|
raise HTTPException(
|
|
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)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Setting '{key}' updated successfully",
|
|
"restart_required": restart_required,
|
|
"key": key,
|
|
"value": setting.value,
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
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}",
|
|
)
|
|
|
|
|
|
@router.delete("/{key}")
|
|
async def delete_setting(key: str, request: Request, db: DbSession, admin: AdminUser):
|
|
"""
|
|
Delete a setting from the database (reverts to environment variable or default).
|
|
Admin only.
|
|
"""
|
|
validate_setting_key(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 = 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",
|
|
)
|
|
|
|
notify_settings_updated()
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Setting '{key}' deleted from database (will use environment variable or default)",
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
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}",
|
|
)
|
|
|
|
|
|
@router.get("/credentials")
|
|
async def list_credentials(request: Request, db: DbSession, admin: AdminUser):
|
|
"""
|
|
List all sensitive credential settings with their configured/unconfigured status.
|
|
|
|
Returns a credential audit report indicating which credentials are set and whether
|
|
each value originates from the database or an environment variable.
|
|
This endpoint is intended to support credential rotation workflows.
|
|
Admin only.
|
|
"""
|
|
try:
|
|
db_settings = get_all_settings_from_db(db)
|
|
credentials = []
|
|
|
|
for key, meta in SETTING_METADATA.items():
|
|
if not meta.get("sensitive", False):
|
|
continue
|
|
|
|
env_value = getattr(settings, key, None)
|
|
in_db = key in db_settings and db_settings[key]
|
|
|
|
if in_db:
|
|
source = "db"
|
|
configured = True
|
|
elif env_value:
|
|
source = "env"
|
|
configured = True
|
|
else:
|
|
source = None
|
|
configured = False
|
|
|
|
credentials.append(
|
|
{
|
|
"key": key,
|
|
"category": meta.get("category", "Other"),
|
|
"description": meta.get("description", ""),
|
|
"configured": configured,
|
|
"source": source,
|
|
"restart_required": meta.get("restart_required", False),
|
|
}
|
|
)
|
|
|
|
configured_count = sum(1 for c in credentials if c["configured"])
|
|
return {
|
|
"credentials": credentials,
|
|
"total": len(credentials),
|
|
"configured_count": configured_count,
|
|
"unconfigured_count": len(credentials) - configured_count,
|
|
}
|
|
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",
|
|
)
|
|
|
|
|
|
@router.post("/bulk-update")
|
|
async def bulk_update_settings(
|
|
updates: list[SettingUpdate], request: Request, db: DbSession, admin: AdminUser
|
|
):
|
|
"""
|
|
Update multiple settings at once.
|
|
Admin only.
|
|
"""
|
|
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
|
|
)
|
|
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, changed_by=changed_by
|
|
)
|
|
if success:
|
|
results.append(
|
|
{"key": update.key, "value": update.value, "status": "success"}
|
|
)
|
|
else:
|
|
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)})
|
|
|
|
if results:
|
|
notify_settings_updated()
|
|
|
|
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}",
|
|
)
|