deerflow-code/offline-backend-20260512/backend/app/gateway/routers/workflow_data_sources.py
2026-09-07 18:24:55 +08:00

288 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Workflow data sources and HTTP credentials.
Write-only secrets: the DSN (or the credential header blob) is accepted on
create/update, encrypted immediately, and never returned. Reads expose only a
masked target so an operator can tell two sources apart.
Shared sources (``ownerId`` null) are admin-only; a regular user may manage
their own.
"""
from __future__ import annotations
import json
from typing import Any
from urllib.parse import urlsplit
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from app.gateway.workflow_audit import audit_workflow
from deerflow.config.app_config import get_app_config
from deerflow.workflows.security.secret_box import SecretKeyMissingError, secrets_available
from deerflow.workflows.security.sql_policy import assert_read_only, assert_tables_allowed
router = APIRouter(prefix="/api/workflows/data-sources", tags=["workflow-data-sources"])
_HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"})
class DataSourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=191)
description: str = ""
kind: str = Field(default="sql", pattern="^(sql|http)$")
dsn: str | None = None
headers: dict[str, str] | None = None
base_url: str = Field(default="", max_length=2048, alias="baseUrl")
allowed_methods: list[str] = Field(default_factory=list, alias="allowedMethods")
max_rows: int = Field(default=1000, ge=1, le=100_000, alias="maxRows")
allowed_tables: list[str] = Field(default_factory=list, alias="allowedTables")
enabled: bool = True
shared: bool = False
model_config = {"populate_by_name": True}
class QueryBody(BaseModel):
statement: str = Field(min_length=1)
class DataSourceUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=191)
description: str | None = None
dsn: str | None = None
headers: dict[str, str] | None = None
base_url: str | None = Field(default=None, max_length=2048, alias="baseUrl")
allowed_methods: list[str] | None = Field(default=None, alias="allowedMethods")
max_rows: int | None = Field(default=None, ge=1, le=100_000, alias="maxRows")
allowed_tables: list[str] | None = Field(default=None, alias="allowedTables")
enabled: bool | None = None
model_config = {"populate_by_name": True}
def _require_enabled() -> None:
if not get_app_config().workflows.enabled:
raise HTTPException(status_code=503, detail="Workflow Studio is disabled")
def _store(request: Request):
store = getattr(request.app.state, "workflow_data_source_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Workflow data source store not available")
return store
async def _is_admin(request: Request) -> bool:
user = await get_optional_user_from_request(request)
return user is not None and getattr(user, "system_role", None) == "admin"
def _normalise_http_metadata(
base_url: str,
allowed_methods: list[str],
) -> tuple[str, list[str]]:
target = base_url.strip()
try:
parsed = urlsplit(target)
except ValueError as exc:
raise HTTPException(status_code=400, detail="HTTP 接口地址格式无效") from exc
if (
parsed.scheme not in {"http", "https"}
or not parsed.netloc
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise HTTPException(
status_code=400,
detail="HTTP 接口地址必须是无凭据、无查询参数的 http(s) 地址",
)
normalised_methods: list[str] = []
for raw_method in allowed_methods:
method = str(raw_method).strip().upper()
if method not in _HTTP_METHODS:
raise HTTPException(status_code=400, detail=f"不支持的 HTTP 方法:{method}")
if method not in normalised_methods:
normalised_methods.append(method)
if not normalised_methods:
raise HTTPException(status_code=400, detail="至少选择一种 HTTP 方法")
return target, normalised_methods
def _normalise_headers(headers: dict[str, str] | None) -> dict[str, str]:
normalised: dict[str, str] = {}
for raw_name, raw_value in (headers or {}).items():
name = str(raw_name).strip()
value = str(raw_value)
if not name or any(char in name + value for char in "\r\n"):
raise HTTPException(status_code=400, detail="HTTP 请求头格式无效")
normalised[name] = value
return normalised
def _secret_from(kind: str, dsn: str | None, headers: dict[str, str] | None) -> str:
if kind == "http":
return json.dumps({"headers": _normalise_headers(headers)}, ensure_ascii=False)
if not dsn:
raise HTTPException(status_code=400, detail="SQL 数据源需要提供 dsn")
if "://" not in dsn:
raise HTTPException(status_code=400, detail="dsn 格式无效")
return dsn
@router.get("")
async def list_data_sources(request: Request) -> dict[str, Any]:
_require_enabled()
user_id = await get_current_user(request)
rows = await _store(request).list_sources(owner_id=user_id)
return {"dataSources": rows, "secretsEncryptionAvailable": secrets_available()}
@router.post("")
async def create_data_source(request: Request, body: DataSourceCreate) -> dict[str, Any]:
_require_enabled()
user_id = await get_current_user(request)
is_admin = await _is_admin(request)
if body.shared and not is_admin:
raise HTTPException(status_code=403, detail="仅管理员可创建共享数据源")
secret = _secret_from(body.kind, body.dsn, body.headers)
base_url = ""
allowed_methods: list[str] = []
if body.kind == "http":
base_url, allowed_methods = _normalise_http_metadata(
body.base_url,
body.allowed_methods,
)
try:
created = await _store(request).create_source(
{
"name": body.name,
"description": body.description,
"kind": body.kind,
"dsn": secret,
"base_url": base_url,
"allowed_methods": allowed_methods,
"max_rows": body.max_rows,
"allowed_tables": body.allowed_tables,
"enabled": body.enabled,
"owner_id": None if body.shared else user_id,
"created_by": user_id,
}
)
except SecretKeyMissingError as exc:
raise HTTPException(
status_code=503,
detail={
"code": "WORKFLOW_RESOURCE_MISSING",
"message": "服务端未配置 WORKFLOW_SECRET_KEY,无法安全保存连接信息",
},
) from exc
audit_workflow("data_source.create", user_id=user_id, dataSourceId=created["id"], kind=body.kind, shared=body.shared)
return created
async def _owned(request: Request, source_id: str) -> dict[str, Any]:
row = await _store(request).get_source(source_id)
if row is None:
raise HTTPException(status_code=404, detail="数据源不存在")
user_id = await get_current_user(request)
if row.get("owner_id") == user_id:
return row
if await _is_admin(request):
return row
raise HTTPException(status_code=403, detail="无权操作该数据源")
async def _visible(request: Request, source_id: str) -> dict[str, Any]:
row = await _store(request).get_source(source_id)
if row is None:
raise HTTPException(status_code=404, detail="数据源不存在")
user_id = await get_current_user(request)
if row.get("owner_id") in (user_id, None):
return row
if await _is_admin(request):
return row
raise HTTPException(status_code=403, detail="无权查看该数据源")
@router.get("/{source_id}")
async def get_data_source(request: Request, source_id: str) -> dict[str, Any]:
_require_enabled()
return await _visible(request, source_id)
@router.post("/{source_id}/introspect")
async def introspect_data_source(request: Request, source_id: str) -> dict[str, Any]:
"""Return the configured table allowlist only — never sample rows."""
_require_enabled()
row = await _visible(request, source_id)
tables = list(row.get("allowed_tables") or [])
return {
"dataSourceId": row["id"],
"kind": row.get("kind"),
"tables": [{"name": name} for name in tables],
"schemas": [],
}
@router.post("/{source_id}/validate-query")
async def validate_data_source_query(request: Request, source_id: str, body: QueryBody) -> dict[str, Any]:
"""Validate SQL against the read-only policy without executing it."""
_require_enabled()
row = await _visible(request, source_id)
try:
statement = assert_read_only(body.statement)
assert_tables_allowed(statement, list(row.get("allowed_tables") or []))
except Exception as exc: # noqa: BLE001
from deerflow.workflows.errors import WorkflowError
if isinstance(exc, WorkflowError):
raise HTTPException(status_code=400, detail=exc.to_body().model_dump(by_alias=True)) from exc
raise
audit_workflow("data_source.validate_query", user_id=await get_current_user(request), dataSourceId=source_id)
return {"ok": True, "statement": statement}
@router.put("/{source_id}")
async def update_data_source(request: Request, source_id: str, body: DataSourceUpdate) -> dict[str, Any]:
_require_enabled()
row = await _owned(request, source_id)
payload = body.model_dump(exclude_unset=True, by_alias=False)
kind = str(row.get("kind") or "sql")
if body.dsn is not None or body.headers is not None:
payload["dsn"] = _secret_from(kind, body.dsn, body.headers)
payload.pop("headers", None)
if kind == "http":
base_url, allowed_methods = _normalise_http_metadata(
body.base_url if body.base_url is not None else str(row.get("base_url") or ""),
body.allowed_methods
if body.allowed_methods is not None
else list(row.get("allowed_methods") or []),
)
payload["base_url"] = base_url
payload["allowed_methods"] = allowed_methods
else:
payload.pop("base_url", None)
payload.pop("allowed_methods", None)
try:
updated = await _store(request).update_source(source_id, payload)
except SecretKeyMissingError as exc:
raise HTTPException(status_code=503, detail="服务端未配置 WORKFLOW_SECRET_KEY") from exc
if updated is None:
raise HTTPException(status_code=404, detail="数据源不存在")
audit_workflow("data_source.update", user_id=await get_current_user(request), dataSourceId=source_id)
return updated
@router.delete("/{source_id}")
async def delete_data_source(request: Request, source_id: str) -> dict[str, Any]:
_require_enabled()
await _owned(request, source_id)
deleted = await _store(request).delete_source(source_id)
audit_workflow("data_source.delete", user_id=await get_current_user(request), dataSourceId=source_id)
return {"deleted": deleted}