security: fullstack hardening — task ownership, mapping validation, API-key scoping, test fixes

Backend:
- Add validate_mapping_database_ownership() to verify source/target UUIDs
  belong to declared environments before persisting mappings (mappings.py)
- Add API-key environment scoping to get_mappings (filter) and
  suggest_mappings_api (enforce) (mappings.py)
- Add user_id Column to TaskRecord model + Alembic migration (task.py)
- Persist task.user_id on save, restore on load (persistence.py)
- Wire current_user.id into migrate_dashboards + backup_dashboards
  task creation (_action_routes.py)
- Fix test_migration_routes.py: module-level patch leak → autouse fixture,
  SupersetClient→AsyncSupersetClient, AsyncMock for sync_environment/run
- Fix 7 Pydantic serializer warnings: 'PENDING'→TaskStatus.PENDING
  in test_tasks.py + import TaskStatus

Frontend:
- Deepen isDryRunResult(): validate selection field, risk.items entries
  (all 5 fields), and diff object uuids individually (ExecutorModel.svelte.ts)

Prior work included: task password redaction, resume ownership checks,
canonical dry-run DTO alignment, migration UI callback fixes, credential
exposure reduction, assistant dry-run await fix.
This commit is contained in:
2026-07-15 23:02:23 +03:00
parent 30c8acf7ae
commit 20071b8c7a
68 changed files with 3845 additions and 376 deletions

View File

@@ -21,6 +21,7 @@ from contextlib import asynccontextmanager
import os
from pathlib import Path
import sys
import uuid
# project_root is used for static files mounting
project_root = Path(__file__).resolve().parent.parent.parent
@@ -38,6 +39,7 @@ from .api.routes import (
admin,
admin_api_keys,
agent_conversations,
agent_lifecycle,
agent_status,
agent_superset,
agent_superset_explore,
@@ -65,7 +67,7 @@ from .api.routes import (
)
from .api.routes.validation_tasks import router as validation_tasks
from .core.auth.security import get_password_hash
from ss_tools.shared.cot_logger import get_trace_id, seed_trace_id
from ss_tools.shared.cot_logger import get_trace_id, seed_trace_id, set_trace_id
from .core.database import AuthSessionLocal, init_db
from .core.encryption_key import ensure_encryption_key
from .core.logger import belief_scope, logger
@@ -476,6 +478,7 @@ app.include_router(reports.router)
app.include_router(assistant.router, prefix="/api/assistant", tags=["Assistant"])
app.include_router(agent_conversations.agent_router, tags=["Agent"])
app.include_router(agent_conversations.router, tags=["Assistant"])
app.include_router(agent_lifecycle.router)
app.include_router(agent_status.router)
app.include_router(agent_superset.router, tags=["Agent Superset"])
app.include_router(agent_superset_explore.router, tags=["Agent Superset"])
@@ -564,6 +567,32 @@ async def _authenticate_websocket(websocket: WebSocket, endpoint_name: str) -> b
# #endregion _authenticate_websocket
# #region _set_websocket_trace_id [C:2] [TYPE Function] [SEMANTICS websocket,trace,context]
# @ingroup Module
# @BRIEF Apply a valid UUID4 x-trace-id query parameter to the current WebSocket context.
# @PRE websocket is a live WebSocket whose query params may include x-trace-id.
# @POST Valid UUID4 query value is propagated; invalid or missing values receive a new trace ID.
# @SIDE_EFFECT Sets the shared trace ContextVar for this WebSocket task.
# @RATIONALE Browser WebSocket clients cannot set arbitrary request headers, so trace
# propagation uses a query parameter alongside the authentication token.
# @REJECTED Requiring a custom WebSocket header was rejected — browser WebSocket APIs do not
# permit application-defined headers during the opening handshake.
def _set_websocket_trace_id(websocket: WebSocket) -> str:
incoming = websocket.query_params.get("x-trace-id", "")
if incoming:
try:
parsed = uuid.UUID(hex=incoming)
if parsed.version == 4:
set_trace_id(incoming)
return incoming
except (ValueError, AttributeError):
pass
return seed_trace_id()
# #endregion _set_websocket_trace_id
# #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.
@@ -613,7 +642,7 @@ async def websocket_endpoint(websocket: WebSocket, task_id: str, source: str = N
level: Filter logs by minimum level (DEBUG, INFO, WARNING, ERROR)
token: JWT or API key for authentication (required, see [SEC:C-3])
"""
seed_trace_id()
_set_websocket_trace_id(websocket)
with belief_scope("websocket_endpoint", f"task_id={task_id}"):
# ── WebSocket authentication (see [SEC:C-3]) ──
if not await _authenticate_websocket(websocket, "ws/logs"):
@@ -815,7 +844,7 @@ async def task_events_websocket(websocket: WebSocket):
Query Parameters:
token: JWT or API key for authentication (required)
"""
seed_trace_id()
_set_websocket_trace_id(websocket)
with belief_scope("task_events_websocket"):
if not await _authenticate_websocket(websocket, "ws/task-events"):
await websocket.close(code=4001, reason="Authentication required")
@@ -872,7 +901,7 @@ async def maintenance_events_websocket(websocket: WebSocket):
Query Parameters:
token: JWT or API key for authentication (required)
"""
seed_trace_id()
_set_websocket_trace_id(websocket)
with belief_scope("maintenance_events_websocket"):
if not await _authenticate_websocket(websocket, "ws/maintenance/events"):
await websocket.close(code=4001, reason="Authentication required")
@@ -918,7 +947,7 @@ async def maintenance_events_websocket(websocket: WebSocket):
# @SIDE_EFFECT Subscribes to dataset event queue in task manager lifecycle.
@app.websocket("/ws/datasets/{env_id}")
async def dataset_websocket_endpoint(websocket: WebSocket, env_id: str):
seed_trace_id()
_set_websocket_trace_id(websocket)
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"):
@@ -954,7 +983,7 @@ async def dataset_websocket_endpoint(websocket: WebSocket, env_id: str):
# @UX_FEEDBACK Client receives {status, total_records, successful_records, failed_records, progressPct, ...}
@app.websocket("/ws/translate/run/{run_id}")
async def translate_run_websocket(websocket: WebSocket, run_id: str):
seed_trace_id()
_set_websocket_trace_id(websocket)
if not await _authenticate_websocket(websocket, "ws/translate/run"):
await websocket.close(code=4001, reason="Authentication required")
return