feat(security): add encryption health inventory and key recovery wizard

Backend:
  - GET /api/security/encryption/health — inventory of all stored encrypted
    secrets (LLM providers, DB connections, profile Git tokens) with
    decrypt attempt and structured broken/healthy status
  - GET /api/security/encryption/fingerprint — non-secret key fingerprint
  - POST /api/security/encryption/recover — bulk replacement of
    undecryptable secrets with partial_success semantics

Frontend:
  - KeyRecoveryModel.svelte.ts — state machine (idle→scanning→
    healthy/needs_recovery→editing→saving→complete/partial_success/error)
  - KeyRecoveryWizard.svelte — tabbed dialog with LLM/DB/Git sections,
    security guidance, re-encrypt command display, edit/save flow
  - SystemSettings entry point — 'Check encrypted secrets' card with
    fingerprint and broken count
  - API methods: getEncryptionHealth, recoverEncryptedSecrets
  - Types: EncryptionRecoveryTypes
  - i18n: en/ru strings for recovery flow

Tests: 226 passed
This commit is contained in:
2026-07-06 18:05:05 +03:00
parent 5d5590d9d9
commit e1b0175ec4
10 changed files with 1571 additions and 236 deletions

View File

@@ -47,6 +47,7 @@ from .api.routes import (
dashboards,
dataset_review,
datasets,
encryption_health,
environments,
git,
health,
@@ -102,6 +103,7 @@ async def lifespan(app: FastAPI):
from src.core.database import SessionLocal as _Db
from src.models.llm import ValidationRun as _VR
from datetime import datetime, timezone
_s: _Ses = _Db()
_stuck = _s.query(_VR).filter(_VR.status == "running").all()
for _r in _stuck:
@@ -125,6 +127,8 @@ async def lifespan(app: FastAPI):
yield
# Shutdown
scheduler.stop()
# #endregion lifespan
# #region FastAPI_App [C:3] [TYPE Global] [SEMANTICS app, fastapi, instance, route-registry]
# @ingroup Module
@@ -145,7 +149,10 @@ app = FastAPI(
# the root task context, making trace_id visible to ALL middleware layers.
# See trace.py @RATIONALE for details.
from .core.middleware.trace import TraceContextMiddleware # noqa: E402
app.add_middleware(TraceContextMiddleware)
# #endregion FastAPI_App
# #region ensure_initial_admin_user [C:3] [TYPE Function]
# @ingroup Module
@@ -158,9 +165,7 @@ def ensure_initial_admin_user() -> None:
username = os.getenv("INITIAL_ADMIN_USERNAME", "").strip()
password = os.getenv("INITIAL_ADMIN_PASSWORD", "").strip()
if not username or not password:
logger.explore(
"INITIAL_ADMIN_CREATE enabled but credentials missing; skipping bootstrap"
)
logger.explore("INITIAL_ADMIN_CREATE enabled but credentials missing; skipping bootstrap")
return
# Warn about env-var password — visible via /proc to other processes
logger.explore(
@@ -202,6 +207,8 @@ def ensure_initial_admin_user() -> None:
raise
finally:
db.close()
# #endregion ensure_initial_admin_user
# #region run_alembic_migrations [C:2] [TYPE Function]
# @ingroup Module
@@ -233,7 +240,6 @@ def run_alembic_migrations() -> None:
# #endregion run_alembic_migrations
# #region app_middleware [TYPE Block]
# @ingroup Module
# @BRIEF Configure application-wide middleware (Session, CORS).
@@ -267,6 +273,7 @@ app.add_middleware(
allow_headers=["*"],
)
# HSTS — Strict-Transport-Security header (see [SEC:L-2])
# Only active when FORCE_HTTPS=true (enterprise deployments with proper certs).
# In dev or behind HTTP-only proxies, HSTS is disabled to avoid lockout.
@@ -277,6 +284,7 @@ class HSTSMiddleware(BaseHTTPMiddleware):
(add_header Strict-Transport-Security ...). This middleware is a
fallback for environments where nginx is not configured to do so.
"""
def __init__(self, app):
super().__init__(app)
self._enabled = os.getenv("FORCE_HTTPS", "").strip().lower() in {"1", "true", "yes"}
@@ -287,7 +295,10 @@ class HSTSMiddleware(BaseHTTPMiddleware):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
app.add_middleware(HSTSMiddleware)
# #endregion app_middleware
# #region global_exception_handler [C:2] [TYPE Function]
# @ingroup Module
@@ -314,6 +325,8 @@ async def global_exception_handler(request: Request, exc: Exception):
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Internal server error", "path": request.url.path},
)
# #endregion global_exception_handler
@@ -329,6 +342,8 @@ async def network_error_handler(request: Request, exc: NetworkError):
status_code=503,
detail="Environment unavailable. Please check if the Superset instance is running.",
)
# #endregion network_error_handler
# #region log_requests [C:3] [TYPE Function]
# @ingroup Module
@@ -349,13 +364,11 @@ async def log_requests(request: Request, call_next):
is_polling = request.url.path.endswith("/api/tasks") and request.method == "GET"
if not is_polling:
import json as _json, logging as _lg
if logger.isEnabledFor(_lg.INFO):
logger.reason("Incoming request", payload={"method": request.method, "path": request.url.path})
else:
_json.dump({"ts": __import__('datetime').datetime.now(__import__('datetime').UTC).isoformat(),
"level": "INFO", "src": "log_requests", "marker": "REASON",
"intent": f"Incoming request: {request.method} {request.url.path}"},
sys.stderr, ensure_ascii=False)
_json.dump({"ts": __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat(), "level": "INFO", "src": "log_requests", "marker": "REASON", "intent": f"Incoming request: {request.method} {request.url.path}"}, sys.stderr, ensure_ascii=False)
sys.stderr.write("\n")
sys.stderr.flush()
try:
@@ -364,10 +377,7 @@ async def log_requests(request: Request, call_next):
if logger.isEnabledFor(_lg.INFO):
logger.reflect("Response", payload={"status": response.status_code, "path": request.url.path})
else:
_json.dump({"ts": __import__('datetime').datetime.now(__import__('datetime').UTC).isoformat(),
"level": "INFO", "src": "log_requests", "marker": "REFLECT",
"intent": f"Response: {response.status_code} for {request.url.path}"},
sys.stderr, ensure_ascii=False)
_json.dump({"ts": __import__("datetime").datetime.now(__import__("datetime").UTC).isoformat(), "level": "INFO", "src": "log_requests", "marker": "REFLECT", "intent": f"Response: {response.status_code} for {request.url.path}"}, sys.stderr, ensure_ascii=False)
sys.stderr.write("\n")
sys.stderr.flush()
return response
@@ -377,6 +387,8 @@ async def log_requests(request: Request, call_next):
status_code=503,
detail="Environment unavailable. Please check if the Superset instance is running.",
)
# #endregion log_requests
# #region API_Routes [C:3] [TYPE Block]
# @ingroup Module
@@ -419,9 +431,12 @@ app.include_router(clean_release_v2.router)
app.include_router(profile.router)
app.include_router(dataset_review.router)
app.include_router(health.router)
app.include_router(encryption_health.router)
app.include_router(translate.router)
app.include_router(validation_tasks, prefix="/api/validation-tasks", tags=["Validation Tasks"])
app.include_router(maintenance.maintenance_router)
# #endregion API_Routes
# #region api.include_routers [C:1] [TYPE Action] [SEMANTICS routes, registration, api]
# @BRIEF Registers all API routers with the FastAPI application.
@@ -491,8 +506,11 @@ async def _authenticate_websocket(websocket: WebSocket, endpoint_name: str) -> b
error="Invalid token",
)
return False
# #endregion _authenticate_websocket
# #region websocket_endpoint [C:5] [TYPE Function]
# @ingroup Module
# @BRIEF Provides a WebSocket endpoint for real-time log streaming of a task with server-side filtering.
@@ -531,9 +549,7 @@ async def _authenticate_websocket(websocket: WebSocket, endpoint_name: str) -> b
# @TEST_EDGE ws_auth_missing_token -> connection rejected with 4001
# @TEST_EDGE ws_auth_invalid_token -> connection rejected with 4001
@app.websocket("/ws/logs/{task_id}")
async def websocket_endpoint(
websocket: WebSocket, task_id: str, source: str = None, level: str = None
):
async def websocket_endpoint(websocket: WebSocket, task_id: str, source: str = None, level: str = None):
"""
WebSocket endpoint for real-time log streaming AND task status updates.
Sends two message types:
@@ -546,7 +562,6 @@ async def websocket_endpoint(
"""
seed_trace_id()
with belief_scope("websocket_endpoint", f"task_id={task_id}"):
# ── WebSocket authentication (see [SEC:C-3]) ──
if not await _authenticate_websocket(websocket, "ws/logs"):
await websocket.close(code=4001, reason="Authentication required")
@@ -598,11 +613,13 @@ async def websocket_endpoint(
"input_required": task.input_required,
"input_request": task.input_request,
}
await websocket.send_json({
"type": "task_status",
"task_id": task.id,
"task": status_dict,
})
await websocket.send_json(
{
"type": "task_status",
"task_id": task.id,
"task": status_dict,
}
)
try:
# ── Send initial task status ──
@@ -640,9 +657,7 @@ async def websocket_endpoint(
task = task_manager.get_task(task_id)
if task and task.status == "AWAITING_INPUT" and task.input_request:
synthetic_log = {
"timestamp": task.logs[-1].timestamp.isoformat()
if task.logs
else "2024-01-01T00:00:00",
"timestamp": task.logs[-1].timestamp.isoformat() if task.logs else "2024-01-01T00:00:00",
"level": "INFO",
"message": "Task paused for user input (Connection Re-established)",
"context": {"input_request": task.input_request},
@@ -690,10 +705,7 @@ async def websocket_endpoint(
"level": log_dict.get("level"),
},
)
if (
"Task completed successfully" in result.message
or "Task failed" in result.message
):
if "Task completed successfully" in result.message or "Task failed" in result.message:
logger.reason(
"Observed terminal task log entry; delaying to preserve client visibility",
payload={"task_id": task_id, "message": result.message},
@@ -724,8 +736,11 @@ async def websocket_endpoint(
"Released WebSocket log and status queue subscriptions",
payload={"task_id": task_id},
)
# #endregion websocket_endpoint
# #region task_events_websocket [C:4] [TYPE Function]
# @ingroup Module
# @BRIEF WebSocket endpoint for global task events (status changes for ALL tasks).
@@ -779,8 +794,11 @@ async def task_events_websocket(websocket: WebSocket):
finally:
task_manager.unsubscribe_task_events(event_queue)
logger.reflect("Released global task events subscription")
# #endregion task_events_websocket
# #region maintenance_events_websocket [C:4] [TYPE Function]
# @ingroup Module
# @BRIEF WebSocket endpoint for maintenance events (created/ended/banner changes).
@@ -833,8 +851,11 @@ async def maintenance_events_websocket(websocket: WebSocket):
finally:
task_manager.unsubscribe_maintenance_events(event_queue)
logger.reflect("Released maintenance events subscription")
# #endregion maintenance_events_websocket
# #region dataset_websocket_endpoint [C:4] [TYPE Function]
# @ingroup Module
# @BRIEF WebSocket endpoint for dataset.updated events — auto-refresh on task completion.
@@ -846,7 +867,6 @@ async def maintenance_events_websocket(websocket: WebSocket):
async def dataset_websocket_endpoint(websocket: WebSocket, env_id: str):
seed_trace_id()
with belief_scope("dataset_websocket_endpoint", f"env_id={env_id}"):
# ── WebSocket authentication (see [SEC:C-3]) ──
if not await _authenticate_websocket(websocket, "ws/datasets"):
await websocket.close(code=4001, reason="Authentication required")
@@ -868,6 +888,8 @@ async def dataset_websocket_endpoint(websocket: WebSocket, env_id: str):
finally:
task_manager.unsubscribe_dataset_events(env_id, queue)
logger.reflect("Released dataset event subscription", payload={"env_id": env_id})
# #endregion dataset_websocket_endpoint
# #region translate_run_websocket [C:3] [TYPE Function]
# @ingroup Module
@@ -891,6 +913,7 @@ async def translate_run_websocket(websocket: WebSocket, run_id: str):
from .core.database import SessionLocal
from .plugins.translate.orchestrator_aggregator import TranslationResultAggregator
from .plugins.translate.events import TranslationEventLog
db = SessionLocal()
try:
event_log = TranslationEventLog(db)
@@ -916,14 +939,15 @@ async def translate_run_websocket(websocket: WebSocket, run_id: str):
except Exception as exc:
logger.explore("Translate run WS error", payload={"run_id": run_id}, error=str(exc))
logger.reflect("Translate run WS closed", payload={"run_id": run_id})
# #endregion translate_run_websocket
# #region StaticFiles [C:1] [TYPE Mount] [SEMANTICS static, frontend, spa]
# @BRIEF Mounts the frontend build directory to serve static assets.
frontend_path = project_root / "frontend" / "build"
if frontend_path.exists():
app.mount(
"/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static"
)
app.mount("/_app", StaticFiles(directory=str(frontend_path / "_app")), name="static")
# #region serve_spa [TYPE Function] [C:1]
# @PURPOSE Serves the SPA frontend for any path not matched by API routes.
# @PRE frontend_path exists.
@@ -933,20 +957,15 @@ if frontend_path.exists():
with belief_scope("serve_spa"):
# Only serve SPA for non-API paths
# API routes are registered separately and should be matched by FastAPI first
if file_path and (
file_path.startswith("api/")
or file_path.startswith("/api/")
or file_path == "api"
):
if file_path and (file_path.startswith("api/") or file_path.startswith("/api/") or file_path == "api"):
# This should not happen if API routers are properly registered
# Return 404 instead of serving HTML
raise HTTPException(
status_code=404, detail=f"API endpoint not found: {file_path}"
)
raise HTTPException(status_code=404, detail=f"API endpoint not found: {file_path}")
full_path = frontend_path / file_path
if file_path and full_path.is_file():
return FileResponse(str(full_path))
return FileResponse(str(frontend_path / "index.html"))
# #endregion serve_spa
else:
# #region read_root [TYPE Function] [C:1]
@@ -956,9 +975,8 @@ else:
@app.get("/")
async def read_root():
with belief_scope("read_root"):
return {
"message": "Superset Tools API is running (Frontend build not found)"
}
return {"message": "Superset Tools API is running (Frontend build not found)"}
# #endregion read_root
# #endregion StaticFiles
# #endregion AppModule