```
feat: 添加审批系统和遗留查询功能支持 - 添加审批系统,包括审批请求模型、服务和路由,支持创建、批准和拒绝操作 - 实现审批API密钥验证机制,区分普通API和审批API访问权限 - 添加Alembic数据库迁移支持,更新初始schema版本并添加降级保护 - 配置遗留MySQL查询白名单机制,支持命名查询和参数化查询 - 更新业务服务以集成审批流程,高风险操作需要审批票证 - 调整安全认证使用常量定义的HTTP头,增强安全性比较 - 优化.gitignore配置,添加日志目录排除和文档文件包含规则 - 更新Dockerfile添加alembic依赖包,修复OpenClaw适配器错误处理 ```
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user