fix: harden agent startup and websocket auth

This commit is contained in:
root
2026-07-27 11:49:18 +03:00
parent a386a1fd5c
commit 3fd8525c4e
10 changed files with 283 additions and 29 deletions

View File

@@ -34,7 +34,8 @@ openpyxl
# DB for LangGraph checkpoint
psycopg2-binary
psycopg>=3.1
# Bundle libpq with the agent environment; plain psycopg requires an OS-level libpq.
psycopg[binary]>=3.1
# Retry/utility
tenacity>=8.0.0

View File

@@ -9,7 +9,7 @@
import inspect as _inspect
import os
from urllib.parse import urlsplit
from urllib.parse import urlsplit, urlunsplit
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
@@ -56,20 +56,91 @@ _CHECKPOINTER_INIT = False
_CHECKPOINTER_CONN = None
# #region AgentChat.LangGraph.Setup.InitCheckpointer [C:3] [TYPE Function] [SEMANTICS agent-chat,langgraph,checkpointer,postgres]
# #region AgentChat.LangGraph.Setup.RedactDbUrl [C:2] [TYPE Function] [SEMANTICS agent-chat,diagnostics,security]
# @ingroup AgentChat
# @BRIEF Initialize AsyncPostgresSaver from DATABASE_URL env var.
# @BRIEF Redact password from a database URL for safe diagnostic logging.
# @INVARIANT Never returns raw password or full credentials in the output string.
def _redact_db_url(url: str) -> str:
"""Return a copy of `url` with the password replaced by '***'."""
if not url:
return "<empty>"
try:
parsed = urlsplit(url)
if not parsed.scheme or not parsed.hostname:
return "<invalid-url>"
host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
port = f":{parsed.port}" if parsed.port else ""
user = f"{parsed.username}:***@" if parsed.username else ""
return urlunsplit((parsed.scheme, f"{user}{host}{port}", parsed.path, "", ""))
except Exception:
return "<unparseable-url>"
# #endregion AgentChat.LangGraph.Setup.RedactDbUrl
# #region AgentChat.LangGraph.Setup.InitCheckpointer [C:4] [TYPE Function] [SEMANTICS agent-chat,langgraph,checkpointer,postgres]
# @ingroup AgentChat
# @BRIEF Initialize AsyncPostgresSaver from DATABASE_URL env var with validation and diagnostics.
# @SIDE_EFFECT Connects to PostgreSQL; creates checkpointer table via setup().
# @INVARIANT DATABASE_URL must be a valid PostgreSQL URI before connection is attempted.
# @RATIONALE Production agent crash-loop was caused by an empty or misconfigured DATABASE_URL
# that resolved to a nonexistent Unix socket. Pre-connection validation + redacted
# diagnostics (host, port, db name — never password) allows operators to debug
# configuration failures without exposing secrets in logs.
async def init_checkpointer() -> None:
global _CHECKPOINTER, _CHECKPOINTER_INIT, _CHECKPOINTER_CONN
if _CHECKPOINTER_INIT:
return
db_url = os.getenv("DATABASE_URL")
pg_url = db_url.replace("postgresql+psycopg2://", "postgres://").replace("postgresql://", "postgres://")
_CHECKPOINTER_CONN = await psycopg.AsyncConnection.connect(pg_url, autocommit=True, row_factory=dict_row)
_CHECKPOINTER = AsyncPostgresSaver(_CHECKPOINTER_CONN)
await _CHECKPOINTER.setup()
if not db_url or not db_url.strip():
logger.explore(
"DATABASE_URL env var is missing or empty — checkpointer cannot be initialized",
payload={"env_var": "DATABASE_URL"},
error="DATABASE_URL is not set",
)
raise RuntimeError("DATABASE_URL is not set. PostgreSQL checkpointer requires a valid database URI.")
# Redact password from URL for safe diagnostic logging.
_sanitized = _redact_db_url(db_url)
logger.reason(
"Initializing PostgreSQL checkpointer",
payload={"sanitized_url": _sanitized},
)
pg_url: str = db_url.replace("postgresql+psycopg2://", "postgres://").replace("postgresql://", "postgres://")
if not pg_url.startswith("postgres://") and not pg_url.startswith("postgresql://"):
logger.explore(
"DATABASE_URL does not look like a PostgreSQL connection string",
payload={"sanitized_url": _sanitized, "parsed_scheme": pg_url.split("://")[0] if "://" in pg_url else "none"},
error="Not a PostgreSQL URL",
)
raise RuntimeError(f"DATABASE_URL must be a PostgreSQL URI. Got invalid scheme.")
try:
_CHECKPOINTER_CONN = await psycopg.AsyncConnection.connect(pg_url, autocommit=True, row_factory=dict_row)
except Exception as e:
logger.explore(
"Failed to connect to PostgreSQL checkpointer",
payload={"sanitized_url": _sanitized, "exception_type": type(e).__name__},
error="PostgreSQL connection failed; inspect database service availability and credentials",
)
raise
try:
_CHECKPOINTER = AsyncPostgresSaver(_CHECKPOINTER_CONN)
await _CHECKPOINTER.setup()
except Exception as e:
logger.explore(
"Checkpointer setup() failed",
payload={"sanitized_url": _sanitized, "exception_type": type(e).__name__},
error="PostgreSQL checkpointer setup failed; inspect database schema permissions",
)
raise
_CHECKPOINTER_INIT = True
logger.reason(
"PostgreSQL checkpointer initialized successfully",
payload={"sanitized_url": _sanitized},
)
# #endregion AgentChat.LangGraph.Setup.InitCheckpointer
_llm_config: dict | None = None

View File

@@ -64,6 +64,44 @@ def test_llm_diagnostics_redacts_api_key_and_path():
# #endregion Test.AgentChat.TestLlmDiagnostics
# #region Test.AgentChat.TestCheckpointerDiagnostics [C:3] [TYPE Class] [SEMANTICS test,agent,checkpointer,security]
# @BRIEF Verify checkpointer diagnostics redact credentials and reject invalid configuration safely.
# @RELATION BINDS_TO -> [AgentChat.LangGraph.Setup.InitCheckpointer]
class TestCheckpointerDiagnostics:
def test_redact_db_url_removes_password_query_and_fragment(self):
from ss_tools.agent.langgraph_setup import _redact_db_url
redacted = _redact_db_url("postgresql://user:secret@[::1]:5432/ss_tools?sslpassword=hidden#fragment")
assert redacted == "postgresql://user:***@[::1]:5432/ss_tools"
assert "secret" not in redacted
assert "hidden" not in redacted
def test_redact_db_url_handles_unparseable_value(self):
from ss_tools.agent.langgraph_setup import _redact_db_url
assert _redact_db_url("not a database url") == "<invalid-url>"
@pytest.mark.anyio
async def test_init_checkpointer_rejects_missing_database_url(self, monkeypatch):
import ss_tools.agent.langgraph_setup as ls
monkeypatch.delenv("DATABASE_URL", raising=False)
ls._CHECKPOINTER_INIT = False
with pytest.raises(RuntimeError, match="DATABASE_URL is not set"):
await ls.init_checkpointer()
@pytest.mark.anyio
async def test_init_checkpointer_redacts_connection_failure(self, monkeypatch):
import ss_tools.agent.langgraph_setup as ls
monkeypatch.setenv("DATABASE_URL", "postgresql://agent:secret@db:5432/ss_tools?sslpassword=hidden")
ls._CHECKPOINTER_INIT = False
with patch("ss_tools.agent.langgraph_setup.psycopg.AsyncConnection.connect", new=AsyncMock(side_effect=Exception("secret"))):
with pytest.raises(Exception, match="secret"):
await ls.init_checkpointer()
# #endregion Test.AgentChat.TestCheckpointerDiagnostics
# #region Test.AgentChat.TestCreateAgent [C:2] [TYPE Function]
# @BRIEF Test create_agent with various LLM config states.
class TestCreateAgent:

View File

@@ -20,7 +20,6 @@ import asyncio
from contextlib import asynccontextmanager
import os
from pathlib import Path
import sys
import uuid
# project_root is used for static files mounting
@@ -669,28 +668,52 @@ def _authorize_websocket(websocket: WebSocket, resource: str, action: str) -> bo
try:
from .core.auth.jwt import decode_token
from .core.database import SessionLocal
from .models.auth import User, Role
from .models.auth import User
payload = decode_token(ws_token)
username = payload.get("sub")
if not isinstance(username, str) or not username:
logger.explore(
"WebSocket authorization — token missing 'sub' claim",
payload={"resource": resource, "action": action},
error="JWT payload has no valid subject",
)
return False
db = SessionLocal()
try:
user = db.query(User).filter(User.username == username).first()
if not user:
logger.explore(
"WebSocket authorization — user not found in database",
payload={"resource": resource, "action": action, "username": username},
error="Authenticated user missing from local DB",
)
return False
if not getattr(user, "is_active", True):
logger.explore(
"WebSocket authorization — user is inactive",
payload={"resource": resource, "action": action, "username": username},
error="User account is disabled",
)
return False
# Admin bypass via is_admin flag
# is_admin is the sole administrative authority. The database migration
# backfills the legacy Admin role before this authorization path runs.
for role in user.roles:
if getattr(role, "is_admin", False):
return True
for perm in role.permissions:
if perm.resource == resource and perm.action == action:
return True
logger.explore(
"WebSocket authorization denied — no matching permission or admin role",
payload={"resource": resource, "action": action, "user": username,
"roles": [r.name for r in user.roles],
"is_active": getattr(user, "is_active", True)},
error="No matching RBAC permission",
)
return False
finally:
db.close()

View File

@@ -831,12 +831,8 @@ def has_permission(resource: str, action: str):
if perm.resource == resource and perm.action == action:
return current_user
# Special case for Admin role (full access) — uses is_admin flag, not name string.
# Fallback to role.name == "Admin" for roles created before is_admin migration.
if any(
getattr(role, "is_admin", False) or role.name == "Admin"
for role in current_user.roles
):
# is_admin is the single source of truth for the administrative bypass.
if any(getattr(role, "is_admin", False) for role in current_user.roles):
return current_user
from .core.auth.logger import log_security_event

View File

@@ -44,10 +44,18 @@ def create_admin(username, password, email=None):
admin_role = db.query(Role).filter(Role.name == "Admin").first()
if not admin_role:
logger.reason("Creating Admin role")
admin_role = Role(name="Admin", description="System Administrator")
admin_role = Role(
name="Admin",
description="System Administrator",
is_admin=True,
)
db.add(admin_role)
db.commit()
db.refresh(admin_role)
elif not admin_role.is_admin:
logger.reason("Marking existing Admin role as administrative")
admin_role.is_admin = True
db.commit()
# 2. Check if user already exists
existing_user = db.query(User).filter(User.username == username).first()

View File

@@ -37,7 +37,7 @@ def _make_client(overrides: dict | None = None) -> tuple[TestClient, MagicMock]:
id="admin-1", username="admin", email="admin@x.com",
auth_source="LOCAL",
created_at=__import__("datetime").datetime.now(),
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
roles=[RoleSchema(id="r1", name="Admin", description="", is_admin=True, permissions=[])],
)
mock_plugin_loader = MagicMock()

View File

@@ -9,11 +9,12 @@
from pathlib import Path
import sys
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
class TestAuthenticateWebsocket:
@@ -117,4 +118,47 @@ class TestAuthenticateWebsocketApiKeyException:
msl.return_value = db
assert await _authenticate_websocket(ws, "ws/logs") is False
# #endregion Test.AppModule.TestApikeyDbException
# #region Test.AppModule.AuthorizeWebsocket [C:3] [TYPE Class] [SEMANTICS test,app,ws,authorization,rbac]
# @BRIEF Verify WebSocket authorization grants access only through is_admin or explicit permissions.
# @RELATION BINDS_TO -> [App.AppModule.AuthorizeWebsocket]
class TestAuthorizeWebsocket:
def _authorize(self, user, resource="tasks", action="READ"):
from src.app import _authorize_websocket
ws = MagicMock()
ws.query_params = {"token": "valid.jwt"}
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = user
with (
patch("src.core.auth.jwt.decode_token", return_value={"sub": "testuser"}),
patch("src.core.database.SessionLocal", return_value=db),
):
result = _authorize_websocket(ws, resource, action)
db.close.assert_called_once()
return result
def test_is_admin_role_bypasses_permission_check(self):
user = SimpleNamespace(
is_active=True,
roles=[SimpleNamespace(name="Admin", is_admin=True, permissions=[])],
)
assert self._authorize(user) is True
def test_legacy_admin_name_without_flag_does_not_bypass_permissions(self):
user = SimpleNamespace(
is_active=True,
roles=[SimpleNamespace(name="Admin", is_admin=False, permissions=[])],
)
assert self._authorize(user) is False
def test_explicit_permission_allows_websocket_access(self):
permission = SimpleNamespace(resource="tasks", action="READ")
user = SimpleNamespace(
is_active=True,
roles=[SimpleNamespace(name="Operator", is_admin=False, permissions=[permission])],
)
assert self._authorize(user) is True
# #endregion Test.AppModule.AuthorizeWebsocket
# #endregion Test.AppModule.WsAuth

View File

@@ -1,13 +1,19 @@
<!-- #region Tasks.TaskResultPanel [C:2] [TYPE Component] [SEMANTICS task, result, summary, status, plugin] -->
<!-- #region Tasks.TaskResultPanel [C:3] [TYPE Component] [SEMANTICS task, result, summary, status, plugin] -->
<!-- @ingroup Tasks -->
<!-- @BRIEF Displays decision-oriented task outcome summaries with optional technical details. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:i18n] -->
<!-- @RELATION CALLS -> [Services.StorageService.DownloadFileFunction] -->
<!-- @UX_STATE Empty -> No task selected, placeholder shown. -->
<!-- @UX_STATE Loaded -> Task result displayed with status color coding. -->
<!-- @UX_STATE Downloading -> Selected artifact button disabled with busy state. -->
<!-- @UX_RECOVERY Download error -> Toast notification with failure reason; user can retry via the same button. -->
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.js';
import { log } from '$lib/cot-logger';
import { notifications } from '$lib/toasts.svelte.js';
import { EmptyState } from '$lib/ui';
import { downloadFile } from '../../../services/storageService';
import type { FailedDashboardEntry, MigrationTaskResult } from '$types/dashboard';
let {
@@ -20,6 +26,7 @@
const migrationResult = $derived(
pluginId === 'superset-migration' ? result as MigrationTaskResult : null,
);
let downloadingArchives = $state(new Set<string>());
function phaseLabel(phase?: string): string {
const labels: Record<string, string> = {
@@ -31,8 +38,25 @@
return phase ? labels[phase] || phase : '';
}
function archiveUrl(path: string): string {
return `/api/storage/download/migrations_failed/${path}`;
/**
* Authenticated download of a migration failure artifact.
* Uses bearer-token fetch (downloadFile from storageService) instead of raw <a href>
* which cannot send Authorization headers and returns 401.
*/
async function downloadArchive(label: string, path: string): Promise<void> {
if (downloadingArchives.has(path)) return;
downloadingArchives = new Set([...downloadingArchives, path]);
log('Tasks.TaskResultPanel', 'REASON', 'Downloading migration artifact', { label, path });
try {
await downloadFile('migrations_failed', path);
log('Tasks.TaskResultPanel', 'REFLECT', 'Artifact downloaded', { label, path });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Download failed';
log('Tasks.TaskResultPanel', 'EXPLORE', 'Artifact download failed', { label, path }, msg);
notifications.error(`${label}: ${msg}`);
} finally {
downloadingArchives = new Set([...downloadingArchives].filter((entry) => entry !== path));
}
}
const migrationStatusLabel = $derived(
@@ -182,8 +206,8 @@
{/if}
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
<button type="button" class="text-xs font-medium text-primary hover:text-primary-hover" onclick={() => onshowrelatedlogs(failed)}>{$t.tasks?.show_related_logs || 'Show related logs'}</button>
{#if failed.source_archive_path}<a href={archiveUrl(failed.source_archive_path)} download class="text-xs font-medium text-primary hover:text-primary-hover">{$t.tasks?.download_source_archive || 'Download source export ZIP'}</a>{/if}
{#if failed.archive_path}<a href={archiveUrl(failed.archive_path)} download class="text-xs font-medium text-primary hover:text-primary-hover">{$t.tasks?.download_transformed_archive || 'Download transformed ZIP'}</a>{/if}
{#if failed.source_archive_path}<button type="button" class="text-xs font-medium text-primary hover:text-primary-hover disabled:cursor-not-allowed disabled:opacity-60" disabled={downloadingArchives.has(failed.source_archive_path)} aria-busy={downloadingArchives.has(failed.source_archive_path)} onclick={() => downloadArchive($t.tasks?.download_source_archive || 'Source ZIP', failed.source_archive_path)}>{$t.tasks?.download_source_archive || 'Download source export ZIP'}</button>{/if}
{#if failed.archive_path}<button type="button" class="text-xs font-medium text-primary hover:text-primary-hover disabled:cursor-not-allowed disabled:opacity-60" disabled={downloadingArchives.has(failed.archive_path)} aria-busy={downloadingArchives.has(failed.archive_path)} onclick={() => downloadArchive($t.tasks?.download_transformed_archive || 'Transformed ZIP', failed.archive_path)}>{$t.tasks?.download_transformed_archive || 'Download transformed ZIP'}</button>{/if}
</div>
</li>
{/each}

View File

@@ -1,10 +1,20 @@
// #region Test.Tasks.TaskResultPanel.Migration [C:2] [TYPE Module] [SEMANTICS test,tasks,migration,result]
// @BRIEF Migration result panel shows successful and failed dashboard names plus Superset error text.
// @RELATION BINDS_TO -> [Tasks.TaskResultPanel]
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import TaskResultPanel from '../TaskResultPanel.svelte';
const { downloadFile, log, error } = vi.hoisted(() => ({
downloadFile: vi.fn(),
log: vi.fn(),
error: vi.fn(),
}));
vi.mock('../../../../services/storageService', () => ({ downloadFile }));
vi.mock('$lib/cot-logger', () => ({ log, getTraceId: () => 'test-trace-id' }));
vi.mock('$lib/toasts.svelte.js', () => ({ notifications: { error } }));
vi.mock('$lib/i18n/index.svelte.js', () => ({
t: {
subscribe: (fn: (_v: Record<string, unknown>) => void) => {
@@ -49,6 +59,10 @@ vi.mock('$lib/i18n/index.svelte.js', () => ({
}));
describe('TaskResultPanel migration summary', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows diagnostic fields, raw Superset response, artifacts, and related-log action', async () => {
const onshowrelatedlogs = vi.fn();
const task = {
@@ -95,10 +109,45 @@ describe('TaskResultPanel migration summary', () => {
expect(screen.getByText('HTTP 422')).toBeTruthy();
expect(screen.getByText(/Exception: SupersetAPIError/)).toBeTruthy();
expect(screen.getByText('Raw Superset response')).toBeTruthy();
expect(screen.getByRole('link', { name: 'Download source export ZIP' }).getAttribute('href')).toContain('3.source.zip');
expect(screen.getByRole('link', { name: 'Download transformed ZIP' }).getAttribute('href')).toContain('3.zip');
expect(screen.getByRole('button', { name: 'Download source export ZIP' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Download transformed ZIP' })).toBeTruthy();
screen.getByRole('button', { name: 'Show related logs' }).click();
expect(onshowrelatedlogs).toHaveBeenCalledWith(expect.objectContaining({ id: 3, phase: 'import' }));
});
it('downloads artifacts through the authenticated storage service and blocks duplicate clicks', async () => {
let resolveDownload: (() => void) | undefined;
downloadFile.mockImplementation(() => new Promise<void>((resolve) => { resolveDownload = resolve; }));
const task = {
plugin_id: 'superset-migration',
result: { status: 'FAILED', failed_dashboards: [{ id: 3, title: 'Revenue', error: 'failed', archive_path: 'task-1/3.zip' }] },
};
render(TaskResultPanel, { props: { task } });
const button = screen.getByRole('button', { name: 'Download transformed ZIP' });
await fireEvent.click(button);
await fireEvent.click(button);
expect(downloadFile).toHaveBeenCalledTimes(1);
expect(downloadFile).toHaveBeenCalledWith('migrations_failed', 'task-1/3.zip');
expect((button as HTMLButtonElement).disabled).toBe(true);
resolveDownload?.();
await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false));
});
it('reports authenticated download failures and allows retry', async () => {
downloadFile.mockRejectedValueOnce(new Error('Forbidden'));
const task = {
plugin_id: 'superset-migration',
result: { status: 'FAILED', failed_dashboards: [{ id: 3, title: 'Revenue', error: 'failed', archive_path: 'task-1/3.zip' }] },
};
render(TaskResultPanel, { props: { task } });
const button = screen.getByRole('button', { name: 'Download transformed ZIP' });
await fireEvent.click(button);
await waitFor(() => expect(error).toHaveBeenCalledWith('Download transformed ZIP: Forbidden'));
expect((button as HTMLButtonElement).disabled).toBe(false);
});
});
// #endregion Test.Tasks.TaskResultPanel.Migration