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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user