887 lines
31 KiB
Python
887 lines
31 KiB
Python
"""外部系统目录、用户可见授权和密文连接的持久化服务层。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Sequence
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Optional
|
||
from urllib.parse import urlparse
|
||
from uuid import UUID
|
||
|
||
from sqlalchemy import delete, exists, or_, select
|
||
from sqlalchemy.exc import IntegrityError
|
||
|
||
from core.storage import session_scope
|
||
from core.storage.models import (
|
||
ExternalSystem,
|
||
ExternalSystemAudit,
|
||
ExternalSystemDefinition,
|
||
ExternalSystemGrant,
|
||
User,
|
||
)
|
||
|
||
from .crypto import configured as crypto_configured
|
||
from .crypto import decrypt_secret, encrypt_secret, mask_username
|
||
from .mcp import McpClient, McpConfig, McpConnectorError
|
||
from .openapi import OpenApiClient, OpenApiConfig, OpenApiError
|
||
from .registry import credential_fields, get_provider, merged_config, provider_specs
|
||
|
||
|
||
class ExternalSystemError(RuntimeError):
|
||
pass
|
||
|
||
|
||
_REVERIFY_KEYS = frozenset(
|
||
{
|
||
"base_url",
|
||
"openapi_url",
|
||
"mcp_url",
|
||
"expected_server_name",
|
||
"login_path",
|
||
"auth_type",
|
||
"username_field",
|
||
"password_field",
|
||
"token_field",
|
||
"auth_header_name",
|
||
"auth_header_template",
|
||
"verify_tls",
|
||
}
|
||
)
|
||
|
||
|
||
def _runtime_config(provider: str, data: dict[str, Any]) -> OpenApiConfig | McpConfig:
|
||
try:
|
||
merged = merged_config(provider, data)
|
||
connector = get_provider(provider).connector
|
||
if connector == "openapi":
|
||
return OpenApiConfig.from_mapping(merged)
|
||
if connector == "mcp":
|
||
return McpConfig.from_mapping(merged)
|
||
raise ExternalSystemError(f"unsupported external system connector: {connector}")
|
||
except (McpConnectorError, OpenApiError, TypeError, ValueError) as exc:
|
||
raise ExternalSystemError(str(exc)) from exc
|
||
|
||
|
||
def _normalized_config(provider: str, data: dict[str, Any]) -> dict[str, Any]:
|
||
cfg = _runtime_config(provider, data)
|
||
result = {
|
||
"base_url": cfg.base_url,
|
||
"login_path": cfg.login_path,
|
||
"operation_mode": cfg.operation_mode,
|
||
"operation_policies": dict(sorted(cfg.operation_policies.items())),
|
||
"timeout_seconds": cfg.timeout_seconds,
|
||
"max_result_bytes": cfg.max_result_bytes,
|
||
"max_total_result_bytes": cfg.max_total_result_bytes,
|
||
"verify_tls": cfg.verify_tls,
|
||
"query_guidance": cfg.query_guidance,
|
||
"recommended_operation_ids": list(cfg.recommended_operation_ids),
|
||
"auth_type": cfg.auth_type,
|
||
**cfg.auth_config,
|
||
}
|
||
if isinstance(cfg, OpenApiConfig):
|
||
result["openapi_url"] = cfg.openapi_url
|
||
else:
|
||
result.update(
|
||
{
|
||
"mcp_url": cfg.mcp_url,
|
||
"expected_server_name": cfg.expected_server_name,
|
||
"max_response_bytes": cfg.max_response_bytes,
|
||
}
|
||
)
|
||
return result
|
||
|
||
|
||
def _classify_definition_config_change(
|
||
provider: str,
|
||
old_data: dict[str, Any],
|
||
new_data: dict[str, Any],
|
||
) -> tuple[dict[str, Any], dict[str, Any], frozenset[str], str]:
|
||
"""按运行语义比较配置,避免旧 JSON 缺省字段被误判成认证变化。"""
|
||
old_config = _normalized_config(provider, old_data)
|
||
new_config = _normalized_config(provider, new_data)
|
||
changed = frozenset(
|
||
key
|
||
for key in old_config.keys() | new_config.keys()
|
||
if old_config.get(key) != new_config.get(key)
|
||
)
|
||
if not changed:
|
||
impact = "none"
|
||
elif changed & _REVERIFY_KEYS:
|
||
impact = "reverify"
|
||
else:
|
||
impact = "runtime"
|
||
return old_config, new_config, changed, impact
|
||
|
||
|
||
def _definition_view(
|
||
row: ExternalSystemDefinition, *, include_config: bool
|
||
) -> dict[str, Any]:
|
||
config = row.config or {}
|
||
result = {
|
||
"definition_id": str(row.definition_id),
|
||
"provider": row.provider,
|
||
"provider_title": get_provider(row.provider).title,
|
||
"connector": get_provider(row.provider).connector,
|
||
"name": row.name,
|
||
"enabled": row.enabled,
|
||
"revision": row.revision,
|
||
"owner_type": row.owner_type,
|
||
"owner_user_id": str(row.owner_user_id) if row.owner_user_id else None,
|
||
"visibility": row.visibility,
|
||
"trust_level": row.trust_level,
|
||
"review_status": row.review_status,
|
||
"egress_policy_id": row.egress_policy_id,
|
||
"host": urlparse(str(config.get("base_url") or "")).hostname or "",
|
||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||
"credential_fields": credential_fields(row.provider, config),
|
||
}
|
||
if include_config:
|
||
result["config"] = config
|
||
return result
|
||
|
||
|
||
def _validate_visibility(visibility: str) -> str:
|
||
mode = (visibility or "selected").strip().lower()
|
||
if mode not in {"organization", "selected", "private"}:
|
||
raise ExternalSystemError("visibility 必须是 organization、selected 或 private")
|
||
return mode
|
||
|
||
|
||
def _mark_connections_for_reverify(connections: Sequence[ExternalSystem]) -> None:
|
||
"""保留密文凭据,但在用户主动测试前阻止 agent 使用变更后的目标。"""
|
||
for connection in connections:
|
||
connection.last_verified_at = None
|
||
connection.last_error = "系统定义已更新,请重新验证连接"
|
||
connection.status = (
|
||
"needs_reverify" if connection.credentials else "needs_credentials"
|
||
)
|
||
|
||
|
||
def _advance_active_connections(
|
||
connections: Sequence[ExternalSystem], *, revision: int
|
||
) -> None:
|
||
"""仅运行策略变化不影响连接有效性,active 连接原子跟随新 revision。"""
|
||
for connection in connections:
|
||
if connection.status != "active":
|
||
continue
|
||
if connection.credentials:
|
||
connection.verified_revision = revision
|
||
else:
|
||
connection.status = "needs_credentials"
|
||
connection.last_verified_at = None
|
||
connection.last_error = "连接凭据缺失,请重新填写凭据"
|
||
|
||
|
||
def _selected_user_ids(s: Any, definition_id: UUID) -> list[str]:
|
||
return [
|
||
str(uid)
|
||
for uid in s.execute(
|
||
select(ExternalSystemGrant.user_id)
|
||
.where(ExternalSystemGrant.definition_id == definition_id)
|
||
.order_by(ExternalSystemGrant.user_id)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
]
|
||
|
||
|
||
def _sync_selected_users(
|
||
s: Any,
|
||
definition: ExternalSystemDefinition,
|
||
selected_user_ids: list[UUID],
|
||
*,
|
||
granted_by: UUID | None,
|
||
) -> None:
|
||
wanted = set(selected_user_ids)
|
||
if wanted:
|
||
existing_users = set(
|
||
s.execute(select(User.user_id).where(User.user_id.in_(wanted)))
|
||
.scalars()
|
||
.all()
|
||
)
|
||
missing = wanted - existing_users
|
||
if missing:
|
||
raise ExternalSystemError(
|
||
"包含不存在的用户: " + ", ".join(sorted(map(str, missing)))
|
||
)
|
||
current_rows = (
|
||
s.execute(
|
||
select(ExternalSystemGrant).where(
|
||
ExternalSystemGrant.definition_id == definition.definition_id
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
current = {row.user_id: row for row in current_rows}
|
||
for uid, row in current.items():
|
||
if uid not in wanted:
|
||
s.delete(row)
|
||
connection = s.execute(
|
||
select(ExternalSystem).where(
|
||
ExternalSystem.definition_id == definition.definition_id,
|
||
ExternalSystem.user_id == uid,
|
||
)
|
||
).scalar_one_or_none()
|
||
if connection is not None:
|
||
s.delete(connection) # 撤权同时删除该用户的密文凭据
|
||
connections = (
|
||
s.execute(
|
||
select(ExternalSystem).where(
|
||
ExternalSystem.definition_id == definition.definition_id
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
for connection in connections:
|
||
if connection.user_id not in wanted:
|
||
s.delete(connection)
|
||
for uid in wanted - set(current):
|
||
s.add(
|
||
ExternalSystemGrant(
|
||
user_id=uid,
|
||
definition_id=definition.definition_id,
|
||
granted_by=granted_by,
|
||
)
|
||
)
|
||
|
||
|
||
def provider_catalog(user_id: UUID) -> list[dict[str, Any]]:
|
||
try:
|
||
with session_scope() as s:
|
||
rows = (
|
||
s.execute(
|
||
select(ExternalSystemDefinition)
|
||
.where(
|
||
ExternalSystemDefinition.enabled.is_(True),
|
||
or_(
|
||
ExternalSystemDefinition.visibility == "organization",
|
||
ExternalSystemDefinition.owner_user_id == user_id,
|
||
exists(
|
||
select(ExternalSystemGrant.user_id).where(
|
||
ExternalSystemGrant.definition_id
|
||
== ExternalSystemDefinition.definition_id,
|
||
ExternalSystemGrant.user_id == user_id,
|
||
)
|
||
),
|
||
),
|
||
ExternalSystemDefinition.review_status == "active",
|
||
)
|
||
.order_by(ExternalSystemDefinition.name)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
definitions_by_provider: dict[str, list[dict[str, Any]]] = {}
|
||
for row in rows:
|
||
definitions_by_provider.setdefault(row.provider, []).append(
|
||
_definition_view(row, include_config=False)
|
||
)
|
||
except Exception:
|
||
definitions_by_provider = {}
|
||
key_ok = crypto_configured()
|
||
return [
|
||
{
|
||
"provider": spec.provider,
|
||
"title": spec.title,
|
||
"connector": spec.connector,
|
||
"default_auth_type": spec.default_auth_type,
|
||
"allowed_auth_types": list(spec.allowed_auth_types),
|
||
"configured": bool(definitions_by_provider.get(spec.provider) and key_ok),
|
||
"reason": ""
|
||
if key_ok
|
||
else "ZCBOT_CREDENTIAL_MASTER_KEY 未配置或少于 32 字符",
|
||
"definitions": definitions_by_provider.get(spec.provider, []),
|
||
}
|
||
for spec in provider_specs()
|
||
]
|
||
|
||
|
||
def list_external_system_definitions() -> list[dict[str, Any]]:
|
||
with session_scope() as s:
|
||
rows = (
|
||
s.execute(
|
||
select(ExternalSystemDefinition).order_by(ExternalSystemDefinition.name)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
results = []
|
||
for row in rows:
|
||
item = _definition_view(row, include_config=True)
|
||
item["selected_user_ids"] = _selected_user_ids(s, row.definition_id)
|
||
results.append(item)
|
||
return results
|
||
|
||
|
||
def create_external_system_definition(
|
||
admin_user_id: UUID,
|
||
*,
|
||
provider: str,
|
||
name: str,
|
||
config: dict[str, Any],
|
||
enabled: bool = True,
|
||
visibility: str = "selected",
|
||
selected_user_ids: Optional[list[UUID]] = None,
|
||
) -> dict[str, Any]:
|
||
provider = (provider or "").strip()
|
||
name = (name or "").strip()
|
||
try:
|
||
get_provider(provider)
|
||
except ValueError as exc:
|
||
raise ExternalSystemError(str(exc)) from exc
|
||
if not name or len(name) > 80:
|
||
raise ExternalSystemError("系统名称不能为空且不能超过 80 字符")
|
||
row = ExternalSystemDefinition(
|
||
provider=provider,
|
||
name=name,
|
||
config=_normalized_config(provider, config),
|
||
enabled=bool(enabled),
|
||
visibility=_validate_visibility(visibility),
|
||
owner_type="platform",
|
||
trust_level="managed",
|
||
review_status="active",
|
||
created_by=admin_user_id,
|
||
)
|
||
try:
|
||
with session_scope() as s:
|
||
s.add(row)
|
||
s.flush()
|
||
if row.visibility == "selected":
|
||
_sync_selected_users(
|
||
s, row, selected_user_ids or [], granted_by=admin_user_id
|
||
)
|
||
s.flush()
|
||
result = _definition_view(row, include_config=True)
|
||
result["selected_user_ids"] = _selected_user_ids(s, row.definition_id)
|
||
return result
|
||
except IntegrityError as exc:
|
||
raise ExternalSystemError("同名外部系统定义已存在") from exc
|
||
|
||
|
||
def update_external_system_definition(
|
||
definition_id: UUID,
|
||
*,
|
||
name: str,
|
||
config: dict[str, Any],
|
||
enabled: bool,
|
||
visibility: str,
|
||
selected_user_ids: Optional[list[UUID]] = None,
|
||
) -> dict[str, Any]:
|
||
name = (name or "").strip()
|
||
if not name or len(name) > 80:
|
||
raise ExternalSystemError("系统名称不能为空且不能超过 80 字符")
|
||
try:
|
||
with session_scope() as s:
|
||
row = s.execute(
|
||
select(ExternalSystemDefinition).where(
|
||
ExternalSystemDefinition.definition_id == definition_id
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is None:
|
||
raise ExternalSystemError("external system definition not found")
|
||
_, new_config, _, impact = (
|
||
_classify_definition_config_change(
|
||
row.provider,
|
||
row.config or {},
|
||
config,
|
||
)
|
||
)
|
||
config_changed = impact != "none"
|
||
connections = (
|
||
s.execute(
|
||
select(ExternalSystem).where(
|
||
ExternalSystem.definition_id == definition_id
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
if config_changed
|
||
else []
|
||
)
|
||
row.name = name
|
||
row.config = new_config
|
||
row.enabled = bool(enabled)
|
||
row.visibility = _validate_visibility(visibility)
|
||
if config_changed:
|
||
row.revision += 1
|
||
if impact == "runtime":
|
||
_advance_active_connections(connections, revision=row.revision)
|
||
else:
|
||
_mark_connections_for_reverify(connections)
|
||
if row.visibility == "selected":
|
||
_sync_selected_users(
|
||
s, row, selected_user_ids or [], granted_by=row.created_by
|
||
)
|
||
else:
|
||
grants = (
|
||
s.execute(
|
||
select(ExternalSystemGrant).where(
|
||
ExternalSystemGrant.definition_id == definition_id
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
for grant in grants:
|
||
s.delete(grant)
|
||
s.flush()
|
||
result = _definition_view(row, include_config=True)
|
||
result["selected_user_ids"] = _selected_user_ids(s, row.definition_id)
|
||
return result
|
||
except IntegrityError as exc:
|
||
raise ExternalSystemError("同名外部系统定义已存在") from exc
|
||
|
||
|
||
def delete_external_system_definition(definition_id: UUID) -> bool:
|
||
try:
|
||
with session_scope() as s:
|
||
result = s.execute(
|
||
delete(ExternalSystemDefinition).where(
|
||
ExternalSystemDefinition.definition_id == definition_id
|
||
)
|
||
)
|
||
return bool(getattr(result, "rowcount", 0))
|
||
except IntegrityError as exc:
|
||
raise ExternalSystemError("该系统已有用户连接,请先停用而不是删除") from exc
|
||
|
||
|
||
def get_definition(
|
||
definition_id: UUID, *, enabled_only: bool = False
|
||
) -> ExternalSystemDefinition:
|
||
with session_scope() as s:
|
||
stmt = select(ExternalSystemDefinition).where(
|
||
ExternalSystemDefinition.definition_id == definition_id
|
||
)
|
||
if enabled_only:
|
||
stmt = stmt.where(ExternalSystemDefinition.enabled.is_(True))
|
||
row = s.execute(stmt).scalar_one_or_none()
|
||
if row is None:
|
||
raise ExternalSystemError("external system definition not found")
|
||
s.expunge(row)
|
||
return row
|
||
|
||
|
||
def get_definition_for_user(
|
||
user_id: UUID, definition_id: UUID
|
||
) -> ExternalSystemDefinition:
|
||
with session_scope() as s:
|
||
row = s.execute(
|
||
select(ExternalSystemDefinition).where(
|
||
ExternalSystemDefinition.definition_id == definition_id,
|
||
ExternalSystemDefinition.enabled.is_(True),
|
||
ExternalSystemDefinition.review_status == "active",
|
||
or_(
|
||
ExternalSystemDefinition.visibility == "organization",
|
||
ExternalSystemDefinition.owner_user_id == user_id,
|
||
exists(
|
||
select(ExternalSystemGrant.user_id).where(
|
||
ExternalSystemGrant.definition_id == definition_id,
|
||
ExternalSystemGrant.user_id == user_id,
|
||
)
|
||
),
|
||
),
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is None:
|
||
raise ExternalSystemError("external system definition not found")
|
||
s.expunge(row)
|
||
return row
|
||
|
||
|
||
def _client(
|
||
provider: str,
|
||
credentials: dict[str, str],
|
||
config: dict[str, Any],
|
||
*,
|
||
cache_namespace: str = "",
|
||
) -> OpenApiClient | McpClient:
|
||
spec = get_provider(provider)
|
||
runtime_config = _runtime_config(provider, config)
|
||
if spec.connector == "openapi" and isinstance(runtime_config, OpenApiConfig):
|
||
return OpenApiClient(
|
||
credentials,
|
||
runtime_config,
|
||
cache_namespace=cache_namespace,
|
||
)
|
||
if spec.connector == "mcp" and isinstance(runtime_config, McpConfig):
|
||
return McpClient(
|
||
credentials,
|
||
runtime_config,
|
||
cache_namespace=cache_namespace,
|
||
)
|
||
raise ExternalSystemError(f"unsupported external system connector: {spec.connector}")
|
||
|
||
|
||
def _credential_values(
|
||
provider: str, config: dict[str, Any], credentials: dict[str, str]
|
||
) -> dict[str, str]:
|
||
fields = credential_fields(provider, config)
|
||
normalized = {
|
||
field["name"]: str(credentials.get(field["name"]) or "").strip()
|
||
for field in fields
|
||
}
|
||
missing = [field["label"] for field in fields if not normalized[field["name"]]]
|
||
if missing:
|
||
raise ExternalSystemError("请填写" + "、".join(missing))
|
||
return normalized
|
||
|
||
|
||
def _credentials(
|
||
provider: str,
|
||
config: dict[str, Any],
|
||
credentials: dict[str, str],
|
||
*,
|
||
user_id: UUID,
|
||
definition_id: UUID,
|
||
) -> dict[str, str]:
|
||
normalized = _credential_values(provider, config, credentials)
|
||
try:
|
||
return {
|
||
name: encrypt_secret(
|
||
value,
|
||
aad=f"{user_id}:{definition_id}:{name}",
|
||
)
|
||
for name, value in normalized.items()
|
||
}
|
||
except (RuntimeError, ValueError) as exc:
|
||
raise ExternalSystemError(str(exc)) from exc
|
||
|
||
|
||
def credentials_for(row: ExternalSystem) -> dict[str, str]:
|
||
try:
|
||
return {
|
||
name: decrypt_secret(
|
||
value,
|
||
aad=f"{row.user_id}:{row.definition_id}:{name}",
|
||
)
|
||
for name, value in row.credentials.items()
|
||
}
|
||
except (AttributeError, RuntimeError) as exc:
|
||
raise ExternalSystemError(str(exc)) from exc
|
||
|
||
|
||
def client_for_external_system(row: ExternalSystem) -> OpenApiClient | McpClient:
|
||
definition = get_definition_for_user(row.user_id, row.definition_id)
|
||
if row.status != "active" or row.verified_revision != definition.revision:
|
||
raise ExternalSystemError("外部系统连接需要重新验证")
|
||
return _client(
|
||
definition.provider,
|
||
credentials_for(row),
|
||
definition.config or {},
|
||
cache_namespace=(
|
||
f"connection:{row.external_system_id}:revision:{row.verified_revision}"
|
||
),
|
||
)
|
||
|
||
|
||
def _view(row: ExternalSystem, definition: ExternalSystemDefinition) -> dict[str, Any]:
|
||
try:
|
||
credential_ok = bool(row.credentials)
|
||
except (AttributeError, TypeError):
|
||
credential_ok = False
|
||
runtime_config = _runtime_config(definition.provider, definition.config or {})
|
||
return {
|
||
"external_system_id": str(row.external_system_id),
|
||
"definition_id": str(row.definition_id),
|
||
"system_name": definition.name,
|
||
"provider": definition.provider,
|
||
"connector": get_provider(definition.provider).connector,
|
||
"name": row.name,
|
||
"status": row.status if definition.enabled else "disabled",
|
||
"username_masked": row.credential_hint or "***",
|
||
"credential_configured": credential_ok,
|
||
"credential_fields": credential_fields(
|
||
definition.provider, definition.config or {}
|
||
),
|
||
"query_guidance": runtime_config.query_guidance,
|
||
"recommended_operation_ids": list(runtime_config.recommended_operation_ids),
|
||
"operation_mode": runtime_config.operation_mode,
|
||
"last_verified_at": row.last_verified_at.isoformat()
|
||
if row.last_verified_at
|
||
else None,
|
||
"definition_revision": definition.revision,
|
||
"verified_revision": row.verified_revision,
|
||
"last_error": row.last_error or "",
|
||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||
}
|
||
|
||
|
||
def list_external_systems(user_id: UUID) -> list[dict[str, Any]]:
|
||
with session_scope() as s:
|
||
rows = s.execute(
|
||
select(ExternalSystem, ExternalSystemDefinition)
|
||
.join(
|
||
ExternalSystemDefinition,
|
||
ExternalSystemDefinition.definition_id == ExternalSystem.definition_id,
|
||
)
|
||
.where(ExternalSystem.user_id == user_id)
|
||
.order_by(ExternalSystem.created_at)
|
||
).all()
|
||
return [_view(row, definition) for row, definition in rows]
|
||
|
||
|
||
def get_external_system(
|
||
user_id: UUID, system_id: UUID, *, active_only: bool = False
|
||
) -> ExternalSystem:
|
||
with session_scope() as s:
|
||
stmt = select(ExternalSystem).where(
|
||
ExternalSystem.external_system_id == system_id,
|
||
ExternalSystem.user_id == user_id,
|
||
)
|
||
if active_only:
|
||
stmt = stmt.where(ExternalSystem.status == "active")
|
||
row = s.execute(stmt).scalar_one_or_none()
|
||
if row is None:
|
||
raise ExternalSystemError("external system not found")
|
||
s.expunge(row)
|
||
return row
|
||
|
||
|
||
def create_external_system(
|
||
user_id: UUID,
|
||
*,
|
||
definition_id: UUID,
|
||
name: str,
|
||
credentials: dict[str, str],
|
||
) -> dict[str, Any]:
|
||
if not crypto_configured():
|
||
raise ExternalSystemError("ZCBOT_CREDENTIAL_MASTER_KEY 未配置或少于 32 字符")
|
||
definition = get_definition_for_user(user_id, definition_id)
|
||
name = (name or definition.name).strip()
|
||
if not name or len(name) > 80:
|
||
raise ExternalSystemError("连接名称不能为空且不能超过 80 字符")
|
||
plain = _credential_values(
|
||
definition.provider,
|
||
definition.config or {},
|
||
credentials,
|
||
)
|
||
try:
|
||
_client(
|
||
definition.provider,
|
||
plain,
|
||
definition.config,
|
||
cache_namespace=(
|
||
f"probe:{definition.definition_id}:revision:{definition.revision}:"
|
||
f"user:{user_id}"
|
||
),
|
||
).test_connection()
|
||
except (McpConnectorError, OpenApiError) as exc:
|
||
raise ExternalSystemError(str(exc)) from exc
|
||
try:
|
||
with session_scope() as s:
|
||
row = s.execute(
|
||
select(ExternalSystem).where(
|
||
ExternalSystem.user_id == user_id,
|
||
ExternalSystem.definition_id == definition.definition_id,
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is not None:
|
||
raise ExternalSystemError("该外部系统已连接,请使用更新凭据")
|
||
row = ExternalSystem(
|
||
user_id=user_id,
|
||
definition_id=definition.definition_id,
|
||
)
|
||
s.add(row)
|
||
row.name = name
|
||
row.credentials = _credentials(
|
||
definition.provider,
|
||
definition.config or {},
|
||
plain,
|
||
user_id=user_id,
|
||
definition_id=definition.definition_id,
|
||
)
|
||
row.credential_hint = (
|
||
mask_username(plain["username"]) if plain.get("username") else "***"
|
||
)
|
||
row.status = "active"
|
||
row.verified_revision = definition.revision
|
||
row.last_error = None
|
||
row.last_verified_at = datetime.now(timezone.utc)
|
||
s.flush()
|
||
return _view(row, definition)
|
||
except IntegrityError as exc:
|
||
raise ExternalSystemError("同名外部系统连接已存在") from exc
|
||
|
||
|
||
def update_external_system_credentials(
|
||
user_id: UUID,
|
||
system_id: UUID,
|
||
*,
|
||
credentials: dict[str, str],
|
||
) -> dict[str, Any]:
|
||
row = get_external_system(user_id, system_id)
|
||
definition = get_definition(row.definition_id, enabled_only=True)
|
||
plain = _credential_values(
|
||
definition.provider,
|
||
definition.config or {},
|
||
credentials,
|
||
)
|
||
try:
|
||
_client(
|
||
definition.provider,
|
||
plain,
|
||
definition.config,
|
||
cache_namespace=(
|
||
f"probe:{system_id}:revision:{definition.revision}:user:{user_id}"
|
||
),
|
||
).test_connection()
|
||
except (McpConnectorError, OpenApiError) as exc:
|
||
raise ExternalSystemError(str(exc)) from exc
|
||
with session_scope() as s:
|
||
current = s.execute(
|
||
select(ExternalSystem).where(
|
||
ExternalSystem.external_system_id == system_id,
|
||
ExternalSystem.user_id == user_id,
|
||
)
|
||
).scalar_one()
|
||
current.credentials = _credentials(
|
||
definition.provider,
|
||
definition.config or {},
|
||
plain,
|
||
user_id=user_id,
|
||
definition_id=definition.definition_id,
|
||
)
|
||
current.credential_hint = (
|
||
mask_username(plain["username"]) if plain.get("username") else "***"
|
||
)
|
||
current.status = "active"
|
||
current.verified_revision = definition.revision
|
||
current.last_error = None
|
||
current.last_verified_at = datetime.now(timezone.utc)
|
||
s.flush()
|
||
return _view(current, definition)
|
||
|
||
|
||
def test_external_system(user_id: UUID, system_id: UUID) -> dict[str, Any]:
|
||
row = get_external_system(user_id, system_id)
|
||
ok, error, probe = False, "", {}
|
||
try:
|
||
definition = get_definition_for_user(user_id, row.definition_id)
|
||
probe = _client(
|
||
definition.provider,
|
||
credentials_for(row),
|
||
definition.config or {},
|
||
cache_namespace=(
|
||
f"connection:{row.external_system_id}:revision:{definition.revision}"
|
||
),
|
||
).test_connection()
|
||
ok = True
|
||
except (ExternalSystemError, McpConnectorError, OpenApiError) as exc:
|
||
error = str(exc)
|
||
with session_scope() as s:
|
||
current = s.execute(
|
||
select(ExternalSystem).where(
|
||
ExternalSystem.external_system_id == system_id,
|
||
ExternalSystem.user_id == user_id,
|
||
)
|
||
).scalar_one()
|
||
current.status = "active" if ok else "invalid"
|
||
current.last_error = None if ok else error[:2000]
|
||
if ok:
|
||
current.last_verified_at = datetime.now(timezone.utc)
|
||
current_definition = s.execute(
|
||
select(ExternalSystemDefinition).where(
|
||
ExternalSystemDefinition.definition_id == current.definition_id
|
||
)
|
||
).scalar_one()
|
||
current.verified_revision = current_definition.revision
|
||
return {"ok": ok, "error": error if not ok else "", **probe}
|
||
|
||
|
||
def delete_external_system(user_id: UUID, system_id: UUID) -> bool:
|
||
with session_scope() as s:
|
||
row = s.execute(
|
||
select(ExternalSystem).where(
|
||
ExternalSystem.external_system_id == system_id,
|
||
ExternalSystem.user_id == user_id,
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is None:
|
||
return False
|
||
s.delete(row) # grants 独立存在,断开连接不撤销可见授权。
|
||
return True
|
||
|
||
|
||
def external_system_tools_available(user_id: UUID) -> bool:
|
||
if not crypto_configured():
|
||
return False
|
||
try:
|
||
with session_scope() as s:
|
||
return (
|
||
s.execute(
|
||
select(ExternalSystem.external_system_id)
|
||
.join(
|
||
ExternalSystemDefinition,
|
||
ExternalSystemDefinition.definition_id
|
||
== ExternalSystem.definition_id,
|
||
)
|
||
.where(
|
||
ExternalSystem.user_id == user_id,
|
||
ExternalSystem.status == "active",
|
||
ExternalSystemDefinition.enabled.is_(True),
|
||
ExternalSystemDefinition.review_status == "active",
|
||
ExternalSystem.verified_revision
|
||
== ExternalSystemDefinition.revision,
|
||
)
|
||
.limit(1)
|
||
).scalar_one_or_none()
|
||
is not None
|
||
)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def external_system_status_available(user_id: UUID) -> bool:
|
||
"""只读状态工具不需要解密凭据;有连接即向 agent 暴露状态。"""
|
||
try:
|
||
with session_scope() as s:
|
||
return (
|
||
s.execute(
|
||
select(ExternalSystem.external_system_id)
|
||
.where(ExternalSystem.user_id == user_id)
|
||
.limit(1)
|
||
).scalar_one_or_none()
|
||
is not None
|
||
)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def record_external_system_audit(
|
||
*,
|
||
user_id: UUID,
|
||
task_id: UUID | None,
|
||
external_system_id: UUID | None,
|
||
definition_id: UUID | None,
|
||
definition_revision: int,
|
||
event: str,
|
||
operation_id: str | None,
|
||
outcome: str,
|
||
duration_ms: int,
|
||
status_code: int | None = None,
|
||
response_bytes: int | None = None,
|
||
detail: dict[str, Any] | None = None,
|
||
) -> None:
|
||
"""写入无敏感载荷的调用审计;调用方决定失败时是否降级。"""
|
||
with session_scope() as s:
|
||
s.add(
|
||
ExternalSystemAudit(
|
||
user_id=user_id,
|
||
task_id=task_id,
|
||
external_system_id=external_system_id,
|
||
definition_id=definition_id,
|
||
definition_revision=max(0, int(definition_revision)),
|
||
event=event,
|
||
operation_id=operation_id,
|
||
outcome=outcome,
|
||
status_code=status_code,
|
||
duration_ms=max(0, int(duration_ms)),
|
||
response_bytes=response_bytes,
|
||
detail=detail or {},
|
||
)
|
||
)
|