feat: 添加审批系统和遗留查询功能支持

- 添加审批系统,包括审批请求模型、服务和路由,支持创建、批准和拒绝操作
- 实现审批API密钥验证机制,区分普通API和审批API访问权限
- 添加Alembic数据库迁移支持,更新初始schema版本并添加降级保护
- 配置遗留MySQL查询白名单机制,支持命名查询和参数化查询
- 更新业务服务以集成审批流程,高风险操作需要审批票证
- 调整安全认证使用常量定义的HTTP头,增强安全性比较
- 优化.gitignore配置,添加日志目录排除和文档文件包含规则
- 更新Dockerfile添加alembic依赖包,修复OpenClaw适配器错误处理
```
This commit is contained in:
2026-07-06 09:39:25 +08:00
parent ae5990eaef
commit e3a4a6d426
28 changed files with 536 additions and 86 deletions

View File

@@ -0,0 +1,10 @@
from enum import StrEnum
class LegacyQueryName(StrEnum):
PROJECTS = "projects"
class LegacyQueryError(StrEnum):
QUERY_NOT_ALLOWED = "Legacy query is not in the configured allowlist"
PROJECT_QUERY_NOT_CONFIGURED = "LEGACY_PROJECT_QUERY is not configured. Configure it first."

View File

@@ -31,7 +31,10 @@ def describe_table(table_name: str, db: Session = Depends(get_db)) -> dict:
@router.post("/query", response_model=QueryResult)
def readonly_query(payload: ReadonlyQueryRequest, db: Session = Depends(get_db)) -> dict:
return LegacyMySQLService(db).execute_readonly(payload.sql, payload.params, payload.limit)
service = LegacyMySQLService(db)
if payload.sql:
return service.execute_readonly(payload.sql, payload.params, payload.limit)
return service.execute_allowed_query(payload.query_name, payload.params, payload.limit)
@router.get("/projects", response_model=QueryResult)
@@ -47,6 +50,7 @@ def sync_projects(
) -> dict:
return LegacyMySQLService(db).sync_projects(
source_query=payload.source_query,
source_query_name=payload.source_query_name,
field_map=payload.field_map,
limit=payload.limit,
dry_run=payload.dry_run,

View File

@@ -3,12 +3,20 @@ from typing import Any
from pydantic import BaseModel, Field
from app.core.constants import ActorValue
from app.modules.legacy_mysql.constants import LegacyQueryName
class ReadonlyQueryRequest(BaseModel):
"""Readonly SQL query request for the legacy MySQL connection."""
"""Allowlisted readonly query request for the legacy MySQL connection."""
sql: str = Field(..., description="Readonly SELECT statement.")
query_name: str | None = Field(
default=LegacyQueryName.PROJECTS,
description="Configured readonly query name.",
)
sql: str | None = Field(
default=None,
description="Deprecated: must exactly match a configured readonly query.",
)
params: dict[str, Any] = Field(default_factory=dict)
limit: int = Field(default=100, ge=1, le=500)
@@ -32,7 +40,11 @@ class LegacyProjectSyncRequest(BaseModel):
source_query: str | None = Field(
default=None,
description="Optional SELECT query for project sync.",
description="Deprecated: must exactly match a configured readonly query.",
)
source_query_name: str | None = Field(
default=LegacyQueryName.PROJECTS,
description="Configured readonly query name for project sync.",
)
field_map: dict[str, str] = Field(
default_factory=dict,

View File

@@ -19,6 +19,7 @@ from app.modules.audit.service import AuditService
from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue
from app.modules.business.models import LegacySyncRun, Project
from app.modules.business.service import serialize_model
from app.modules.legacy_mysql.constants import LegacyQueryError, LegacyQueryName
FORBIDDEN_SQL_TOKENS = {
"insert",
@@ -50,6 +51,18 @@ def _row_to_dict(row: RowMapping) -> dict[str, Any]:
return {key: _jsonable(value) for key, value in row.items()}
def _normalize_sql(sql: str) -> str:
return " ".join(sql.strip().rstrip(";").split()).lower()
def _query_name_text(query_name: str | LegacyQueryName | None) -> str:
if query_name is None:
return LegacyQueryName.PROJECTS.value
if isinstance(query_name, LegacyQueryName):
return query_name.value
return str(query_name)
class LegacyMySQLService:
"""Read legacy MySQL data and sync projects into the internal ledger."""
@@ -74,6 +87,17 @@ class LegacyMySQLService:
if tokens & FORBIDDEN_SQL_TOKENS:
raise HTTPException(status_code=400, detail="Forbidden SQL token in readonly query")
@staticmethod
def _allowed_queries() -> dict[str, str]:
settings = get_settings()
queries = {
_query_name_text(name): sql
for name, sql in settings.legacy_allowed_queries.items()
}
if settings.legacy_project_query:
queries.setdefault(LegacyQueryName.PROJECTS.value, settings.legacy_project_query)
return queries
def health(self) -> dict[str, str]:
engine = self._ensure_engine()
try:
@@ -113,6 +137,39 @@ class LegacyMySQLService:
sql: str,
params: dict[str, Any] | None = None,
limit: int = 100,
) -> dict[str, Any]:
"""Execute a readonly SQL statement only when it matches the allowlist."""
normalized_sql = _normalize_sql(sql)
for allowed_sql in self._allowed_queries().values():
if _normalize_sql(allowed_sql) == normalized_sql:
return self._execute_readonly_sql(allowed_sql, params, limit)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=LegacyQueryError.QUERY_NOT_ALLOWED,
)
def execute_allowed_query(
self,
query_name: str | None,
params: dict[str, Any] | None = None,
limit: int = 100,
) -> dict[str, Any]:
queries = self._allowed_queries()
normalized_name = _query_name_text(query_name)
sql = queries.get(normalized_name)
if not sql:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=LegacyQueryError.QUERY_NOT_ALLOWED,
)
return self._execute_readonly_sql(sql, params, limit)
def _execute_readonly_sql(
self,
sql: str,
params: dict[str, Any] | None = None,
limit: int = 100,
) -> dict[str, Any]:
self._ensure_readonly(sql)
engine = self._ensure_engine()
@@ -132,9 +189,9 @@ class LegacyMySQLService:
if not settings.legacy_project_query:
raise HTTPException(
status_code=400,
detail="LEGACY_PROJECT_QUERY is not configured. Configure it in .env first.",
detail=LegacyQueryError.PROJECT_QUERY_NOT_CONFIGURED,
)
return self.execute_readonly(settings.legacy_project_query, {"limit": limit}, limit=limit)
return self.execute_allowed_query(LegacyQueryName.PROJECTS, {"limit": limit}, limit=limit)
@staticmethod
def _value(
@@ -187,6 +244,7 @@ class LegacyMySQLService:
def sync_projects(
self,
source_query: str | None = None,
source_query_name: str | None = None,
field_map: dict[str, str] | None = None,
limit: int = 100,
dry_run: bool = True,
@@ -198,12 +256,13 @@ class LegacyMySQLService:
detail="Application database session is not available",
)
settings = get_settings()
query = source_query or settings.legacy_project_query
if not query:
raise HTTPException(status_code=400, detail="Project sync query is not configured")
rows = self.execute_readonly(query, {"limit": limit}, limit=limit)["rows"]
query_name = source_query_name or LegacyQueryName.PROJECTS
if source_query:
rows = self.execute_readonly(source_query, {"limit": limit}, limit=limit)["rows"]
query_ref = "allowlisted_inline_sql"
else:
rows = self.execute_allowed_query(query_name, {"limit": limit}, limit=limit)["rows"]
query_ref = _query_name_text(query_name)
field_map = field_map or {}
created = 0
updated = 0
@@ -288,7 +347,7 @@ class LegacyMySQLService:
target_type=BusinessDomain.PROJECTS,
risk_level=AuditRiskLevel.MEDIUM,
request_payload={
"source_query": source_query or "LEGACY_PROJECT_QUERY",
"source_query": query_ref,
"field_map": field_map,
"limit": limit,
"dry_run": dry_run,