diff --git a/app/api/settings.py b/app/api/settings.py index 75c9e99a..97688f5e 100644 --- a/app/api/settings.py +++ b/app/api/settings.py @@ -103,6 +103,145 @@ async def get_settings(request: Request, db: DbSession, admin: AdminUser): ) +@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.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("/export-env") +async def export_env_settings( + request: Request, + db: DbSession, + admin: AdminUser, + source: str = "db", +): + """ + Export current settings as a ``.env`` file. + + Query params: + - ``source=db`` (default) – only settings explicitly saved to the database. + - ``source=effective`` – full runtime configuration (DB > ENV > defaults) for + every key defined in SETTING_METADATA. + + Returns a downloadable plain-text file suitable for bootstrapping another + installation. All values — including sensitive ones — are included; only + admins can access this endpoint. + """ + from fastapi.responses import Response as FastAPIResponse + + from app.utils.settings_service import get_settings_for_export + + if source not in ("db", "effective"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="source must be 'db' or 'effective'", + ) + + try: + export_data = get_settings_for_export(db, source=source) + lines = [ + "# DocuElevate configuration export", + f"# Source: {source}", + "# Generated by DocuElevate Settings Export", + "# WARNING: This file contains sensitive values. Handle with care.", + "", + ] + for env_key, value in export_data.items(): + lines.append(f"{env_key}={value}") + lines.append("") # trailing newline + content = "\n".join(lines) + + return FastAPIResponse( + content=content, + media_type="text/plain", + headers={"Content-Disposition": f'attachment; filename="docuelevate-{source}.env"'}, + ) + except Exception as e: + logger.error(f"Error exporting settings: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to export settings", + ) + + @router.get("/{key}", response_model=SettingResponse) async def get_setting(key: str, request: Request, db: DbSession, admin: AdminUser): """ @@ -220,63 +359,6 @@ async def delete_setting(key: str, request: Request, db: DbSession, admin: Admin ) -@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): """ @@ -323,32 +405,6 @@ async def bulk_update_settings(updates: list[SettingUpdate], request: Request, d } -@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): """ @@ -416,59 +472,3 @@ async def rollback_setting_to_history( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to roll back setting: {key}", ) - - -@router.get("/export-env") -async def export_env_settings( - request: Request, - db: DbSession, - admin: AdminUser, - source: str = "db", -): - """ - Export current settings as a ``.env`` file. - - Query params: - - ``source=db`` (default) – only settings explicitly saved to the database. - - ``source=effective`` – full runtime configuration (DB > ENV > defaults) for - every key defined in SETTING_METADATA. - - Returns a downloadable plain-text file suitable for bootstrapping another - installation. All values — including sensitive ones — are included; only - admins can access this endpoint. - """ - from fastapi.responses import Response as FastAPIResponse - - from app.utils.settings_service import get_settings_for_export - - if source not in ("db", "effective"): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="source must be 'db' or 'effective'", - ) - - try: - export_data = get_settings_for_export(db, source=source) - lines = [ - "# DocuElevate configuration export", - f"# Source: {source}", - "# Generated by DocuElevate Settings Export", - "# WARNING: This file contains sensitive values. Handle with care.", - "", - ] - for env_key, value in export_data.items(): - lines.append(f"{env_key}={value}") - lines.append("") # trailing newline - content = "\n".join(lines) - - return FastAPIResponse( - content=content, - media_type="text/plain", - headers={"Content-Disposition": f'attachment; filename="docuelevate-{source}.env"'}, - ) - except Exception as e: - logger.error(f"Error exporting settings: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to export settings", - ) diff --git a/app/utils/settings_sync.py b/app/utils/settings_sync.py index 893e14ce..3298c189 100644 --- a/app/utils/settings_sync.py +++ b/app/utils/settings_sync.py @@ -38,7 +38,9 @@ _last_seen_version: str = "" def notify_settings_updated() -> None: """ - Publish a settings-updated signal by updating the Redis version key. + Publish a settings-updated signal by updating the Redis version key, and + immediately reload the in-process ``settings`` singleton so the API + process serves fresh values without a restart. Call this after every successful settings write so that all worker processes know they need to reload their in-memory configuration. @@ -56,6 +58,19 @@ def notify_settings_updated() -> None: except Exception as exc: logger.warning(f"Could not publish settings update to Redis: {exc}") + # Reload the in-process settings singleton immediately so the API node + # returns updated values (e.g. oauth_provider_name on the login page) + # without needing a restart. Workers use the task_prerun signal handler + # instead, so this only affects the API/web process. + try: + from app.config import settings + from app.utils.config_loader import reload_settings_from_db + + reload_settings_from_db(settings) + logger.debug("In-process settings reloaded after settings update") + except Exception as exc: + logger.warning(f"Could not reload in-process settings: {exc}") + def register_settings_reload_signal() -> None: """ diff --git a/app/views/settings.py b/app/views/settings.py index dd34ed03..1e520e33 100644 --- a/app/views/settings.py +++ b/app/views/settings.py @@ -72,25 +72,25 @@ async def settings_page(request: Request, db: Session = Depends(get_db)): for category, keys in categories.items(): settings_data[category] = [] for key in keys: - # Get current value from settings (already has precedence applied) - value = getattr(settings, key, None) - - # Determine the source of this setting - # Check if it's in the database + # Determine the source of this setting and get the effective value + # Check if it's in the database (DB takes precedence) if key in db_settings: source = "database" source_label = "DB" source_color = "green" + value = db_settings[key] # Check if it's from environment variable elif key.upper() in os.environ or key in os.environ: source = "environment" source_label = "ENV" source_color = "blue" + value = getattr(settings, key, None) else: # It's using the default value source = "default" source_label = "DEFAULT" source_color = "gray" + value = getattr(settings, key, None) # Get metadata metadata = get_setting_metadata(key) diff --git a/frontend/templates/settings.html b/frontend/templates/settings.html index 36afbd72..b35ccefe 100644 --- a/frontend/templates/settings.html +++ b/frontend/templates/settings.html @@ -185,7 +185,7 @@ {% endif %} - +