feat: Phase1安全基线 — 风险等级+审核超时+执行进度+通知脱敏+高危确认+维护窗口 (1119测试)
新增功能:
- ticket_sql_items模型: 逐条SQL执行记录(状态/耗时/影响行数)
- risk_evaluator.py: 4级风险评估(low/medium/high/critical), 支持MySQL/PG/Redis/Mongo
- Ticket模型: 新增risk_level + workflow_id字段
- 审核引擎签名改造: audit(sql, db_type, instance_id=None) 支持EXPLAIN预检
- 审核超时保护: 10s超时跳过审核, 防止工单创建阻塞
- 工单创建自动风险计算 + 审批流匹配(instance.workflow_id优先)
- SQL items查询API: GET /{ticket_id}/sql-items 含进度统计
- 执行器集成: 执行完成后自动更新SQL items逐条状态
- 通知脱敏: password/secret/token自动掩码, SQL预览截断100字符
- 高危操作强制二次确认: high/critical级别需confirmed=true
- 维护窗口控制: system_settings配置执行时间窗口(支持跨午夜)
- 45个新测试: 风险评估24+通知脱敏5+维护窗口2+SQL分类11+配置2
This commit is contained in:
1 parent
030f3f2339
commit
7be0bf1a7c
9 files changed
+809
-16
No files matched your search
@@ -56,6 +56,129 @@ def _generate_ticket_no(ticket_id: int) -> str:
|
||||
seq = str(ticket_id).zfill(3)[-3:] # ID 补齐到3位
|
||||
return f"{base}{ms}{seq}"
|
||||
|
||||
|
||||
def _classify_sql_type(sql: str) -> str:
|
||||
"""根据单条 SQL 语句判断类型。"""
|
||||
s = sql.strip().upper()
|
||||
if s.startswith(("SELECT", "SHOW", "EXPLAIN", "DESC", "DESCRIBE")):
|
||||
return "DQL"
|
||||
if s.startswith(("INSERT", "UPDATE", "DELETE", "REPLACE")):
|
||||
return "DML"
|
||||
if s.startswith(("CREATE", "ALTER", "DROP", "RENAME", "TRUNCATE")):
|
||||
return "DDL"
|
||||
if s.startswith(("GRANT", "REVOKE")):
|
||||
return "DCL"
|
||||
if s.startswith(("SET", "GET", "HSET", "HMSET", "LPUSH", "RPUSH", "SADD", "ZADD", "DEL", "FLUSHDB", "FLUSHALL")):
|
||||
return "SET"
|
||||
return "OTHER"
|
||||
|
||||
|
||||
async def _update_sql_items_from_report(db, ticket_id: int, report) -> None:
|
||||
"""根据执行报告更新 SQL items 状态。"""
|
||||
from app.models.ticket_sql_item import TicketSqlItem
|
||||
|
||||
result = await db.execute(
|
||||
select(TicketSqlItem)
|
||||
.where(TicketSqlItem.ticket_id == ticket_id)
|
||||
.order_by(TicketSqlItem.seq)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
if not items:
|
||||
return
|
||||
|
||||
if report.status == "success":
|
||||
for item in items:
|
||||
item.status = "success"
|
||||
item.finished_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
# 尝试从 raw_output 解析逐条结果
|
||||
exec_errors = getattr(report, "errors", [])
|
||||
error_stmt = ""
|
||||
if exec_errors:
|
||||
err_msg = str(exec_errors[0]) if exec_errors else ""
|
||||
# 找到出错的语句
|
||||
for item in items:
|
||||
if item.status == "pending":
|
||||
if not error_stmt:
|
||||
item.status = "failed"
|
||||
item.error_message = err_msg
|
||||
item.finished_at = datetime.now(timezone.utc)
|
||||
error_stmt = item.sql_text[:50]
|
||||
else:
|
||||
item.status = "skipped"
|
||||
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def _mark_sql_items_failed(db, ticket_id: int, error_msg: str) -> None:
|
||||
"""标记所有 pending 的 SQL items 为 failed。"""
|
||||
from app.models.ticket_sql_item import TicketSqlItem
|
||||
|
||||
result = await db.execute(
|
||||
select(TicketSqlItem)
|
||||
.where(
|
||||
TicketSqlItem.ticket_id == ticket_id,
|
||||
TicketSqlItem.status == "pending",
|
||||
)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
item.status = "failed"
|
||||
item.error_message = error_msg[:500]
|
||||
item.finished_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def _check_maintenance_window(db) -> Optional[str]:
|
||||
"""检查当前是否在维护窗口内。
|
||||
|
||||
Returns:
|
||||
None = 允许执行(无窗口限制或在窗口内)
|
||||
str = 窗口描述(表示不在窗口内,不允许执行)
|
||||
"""
|
||||
from app.models.system_setting import SystemSetting
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.key == "maintenance_window")
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
if not setting or not setting.value:
|
||||
return None # 无配置,不限制
|
||||
|
||||
import json as _json
|
||||
try:
|
||||
window = _json.loads(setting.value) if isinstance(setting.value, str) else setting.value
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
enabled = window.get("enabled", False)
|
||||
if not enabled:
|
||||
return None
|
||||
|
||||
# 格式: {"enabled": true, "start": "22:00", "end": "06:00", "days": ["mon","tue","wed","thu","fri"]}
|
||||
start_time = window.get("start", "22:00")
|
||||
end_time = window.get("end", "06:00")
|
||||
days = window.get("days", []) # 空=每天
|
||||
|
||||
now = datetime.now()
|
||||
current_day = now.strftime("%a").lower()[:3]
|
||||
current_time = now.strftime("%H:%M")
|
||||
|
||||
# 检查星期
|
||||
if days and current_day not in [d.lower()[:3] for d in days]:
|
||||
return f"{start_time}-{end_time} ({'/'.join(days)})"
|
||||
|
||||
# 检查时间(支持跨午夜)
|
||||
if start_time <= end_time:
|
||||
if not (start_time <= current_time <= end_time):
|
||||
return f"{start_time}-{end_time}"
|
||||
else:
|
||||
# 跨午夜: 22:00-06:00 意味着 22:00~24:00 或 00:00~06:00
|
||||
if not (current_time >= start_time or current_time <= end_time):
|
||||
return f"{start_time}-{end_time}(次日)"
|
||||
|
||||
return None # 在窗口内,允许执行
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.services.ticket_executor import ExecutionReport
|
||||
|
||||
@@ -144,6 +267,8 @@ async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
|
||||
"rollback_sql": ticket.rollback_sql,
|
||||
"status": ticket.status,
|
||||
"priority": ticket.priority,
|
||||
"risk_level": getattr(ticket, "risk_level", "low"),
|
||||
"workflow_id": getattr(ticket, "workflow_id", None),
|
||||
"applicant_id": ticket.applicant_id,
|
||||
"approver_id": ticket.approver_id,
|
||||
"reason": ticket.reason,
|
||||
@@ -373,27 +498,57 @@ async def create_ticket(
|
||||
# 3. 验证工单内容安全性
|
||||
_validate_ticket_content(data)
|
||||
|
||||
# 4. 自动审核 SQL
|
||||
# 4. 自动审核 SQL(带超时保护)
|
||||
env = "production" if instance.env == "production" else "development"
|
||||
v2_engine = SQLAuditEngineV2(db_session=db, env=env)
|
||||
import asyncio as _asyncio
|
||||
try:
|
||||
v2_report = await v2_engine.audit(data.sql_content, db_type=data.db_type)
|
||||
v2_report = await _asyncio.wait_for(
|
||||
v2_engine.audit(data.sql_content, db_type=data.db_type, instance_id=data.instance_id),
|
||||
timeout=10.0,
|
||||
)
|
||||
except _asyncio.TimeoutError:
|
||||
logger.warning("SQL 审核超时(10s),跳过审核: ticket title=%s", data.title)
|
||||
v2_report = None
|
||||
except Exception as e:
|
||||
logger.error("SQL 审核失败: %s", e)
|
||||
v2_report = None
|
||||
|
||||
# 5. 检查是否有默认审批流
|
||||
default_wf_result = await db.execute(
|
||||
select(WorkflowDefinition).where(
|
||||
WorkflowDefinition.is_default == True,
|
||||
WorkflowDefinition.is_active == True,
|
||||
)
|
||||
# 5. 计算风险等级
|
||||
from app.services.risk_evaluator import evaluate_risk_level
|
||||
audit_findings_raw = v2_report.to_dict().get("findings", []) if v2_report else []
|
||||
import sqlparse as _sqlparse
|
||||
stmt_list = [s.strip() for s in _sqlparse.split(data.sql_content) if s.strip()]
|
||||
risk_level = evaluate_risk_level(
|
||||
sql_content=data.sql_content,
|
||||
db_type=data.db_type,
|
||||
sql_type=data.sql_type or "",
|
||||
audit_findings=audit_findings_raw,
|
||||
statement_count=len(stmt_list),
|
||||
)
|
||||
default_workflow = default_wf_result.scalar_one_or_none()
|
||||
|
||||
initial_status = TicketStatus.REVIEWING.value if default_workflow else TicketStatus.PENDING.value
|
||||
# 6. 匹配审批流 — 优先按 instance.workflow_id,其次按 risk_level 匹配默认流
|
||||
workflow = None
|
||||
if instance.workflow_id:
|
||||
wf_result = await db.execute(
|
||||
select(WorkflowDefinition).where(
|
||||
WorkflowDefinition.id == instance.workflow_id,
|
||||
WorkflowDefinition.is_active == True,
|
||||
)
|
||||
)
|
||||
workflow = wf_result.scalar_one_or_none()
|
||||
if not workflow:
|
||||
default_wf_result = await db.execute(
|
||||
select(WorkflowDefinition).where(
|
||||
WorkflowDefinition.is_default == True,
|
||||
WorkflowDefinition.is_active == True,
|
||||
)
|
||||
)
|
||||
workflow = default_wf_result.scalar_one_or_none()
|
||||
|
||||
# 6. 创建工单
|
||||
initial_status = TicketStatus.REVIEWING.value if workflow else TicketStatus.PENDING.value
|
||||
|
||||
# 7. 创建工单
|
||||
ticket = Ticket(
|
||||
title=data.title,
|
||||
db_type=data.db_type,
|
||||
@@ -403,6 +558,8 @@ async def create_ticket(
|
||||
rollback_sql=data.rollback_sql,
|
||||
reason=data.reason,
|
||||
priority=data.priority,
|
||||
risk_level=risk_level,
|
||||
workflow_id=workflow.id if workflow else None,
|
||||
applicant_id=current_user.id,
|
||||
status=initial_status,
|
||||
audit_result=json.dumps(v2_report.to_dict(), ensure_ascii=False) if v2_report and v2_report.findings else None,
|
||||
@@ -416,10 +573,10 @@ async def create_ticket(
|
||||
await db.flush()
|
||||
|
||||
# 7. 创建审批步骤(多级审批流)
|
||||
if default_workflow:
|
||||
if workflow:
|
||||
wf_steps_result = await db.execute(
|
||||
select(WorkflowStep)
|
||||
.where(WorkflowStep.workflow_id == default_workflow.id)
|
||||
.where(WorkflowStep.workflow_id == workflow.id)
|
||||
.order_by(WorkflowStep.step_number)
|
||||
)
|
||||
wf_steps = wf_steps_result.scalars().all()
|
||||
@@ -434,7 +591,21 @@ async def create_ticket(
|
||||
db.add(approval)
|
||||
await db.flush()
|
||||
|
||||
# 8. 记录审计日志
|
||||
# 8. 创建 SQL 逐条执行记录
|
||||
from app.models.ticket_sql_item import TicketSqlItem
|
||||
for i, stmt in enumerate(stmt_list, start=1):
|
||||
stmt_type = _classify_sql_type(stmt)
|
||||
sql_item = TicketSqlItem(
|
||||
ticket_id=ticket.id,
|
||||
seq=i,
|
||||
sql_text=stmt,
|
||||
sql_type=stmt_type,
|
||||
status="pending",
|
||||
)
|
||||
db.add(sql_item)
|
||||
await db.flush()
|
||||
|
||||
# 9. 记录审计日志
|
||||
audit_detail = ""
|
||||
if v2_report:
|
||||
audit_detail = f"SQL审核评分: {v2_report.score}, 通过: {v2_report.passed}, 规则数: {len(v2_report.findings)}"
|
||||
@@ -776,6 +947,51 @@ async def get_ticket_timeline(
|
||||
return {"ticket_id": ticket_id, "timeline": timeline}
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/sql-items")
|
||||
async def get_ticket_sql_items(
|
||||
ticket_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取工单 SQL 逐条执行进度。"""
|
||||
from app.models.ticket_sql_item import TicketSqlItem
|
||||
|
||||
result = await db.execute(
|
||||
select(TicketSqlItem)
|
||||
.where(TicketSqlItem.ticket_id == ticket_id)
|
||||
.order_by(TicketSqlItem.seq)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
|
||||
return {
|
||||
"ticket_id": ticket_id,
|
||||
"total": len(items),
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"seq": item.seq,
|
||||
"sql_text": item.sql_text,
|
||||
"sql_type": item.sql_type,
|
||||
"status": item.status,
|
||||
"affected_rows": item.affected_rows,
|
||||
"duration_ms": item.duration_ms,
|
||||
"error_message": item.error_message,
|
||||
"started_at": item.started_at.isoformat() if item.started_at else None,
|
||||
"finished_at": item.finished_at.isoformat() if item.finished_at else None,
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
"progress": {
|
||||
"total": len(items),
|
||||
"success": sum(1 for i in items if i.status == "success"),
|
||||
"failed": sum(1 for i in items if i.status == "failed"),
|
||||
"running": sum(1 for i in items if i.status == "running"),
|
||||
"pending": sum(1 for i in items if i.status == "pending"),
|
||||
"skipped": sum(1 for i in items if i.status == "skipped"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/cancel")
|
||||
async def cancel_ticket(
|
||||
ticket_id: int,
|
||||
@@ -910,12 +1126,32 @@ async def execute_ticket(
|
||||
if confirm and not confirm.confirmed:
|
||||
raise HTTPException(status_code=400, detail="未确认执行,操作已取消")
|
||||
|
||||
# 3.1 高危操作强制二次确认
|
||||
risk_level = getattr(ticket, "risk_level", "low")
|
||||
if risk_level in ("high", "critical"):
|
||||
if not confirm or not confirm.confirmed:
|
||||
from app.services.risk_evaluator import RISK_LEVEL_CONFIG
|
||||
risk_cfg = RISK_LEVEL_CONFIG.get(risk_level, {})
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"该工单风险等级为「{risk_cfg.get('label', risk_level)}」,需要二次确认后才能执行。请传入 confirmed=true",
|
||||
)
|
||||
|
||||
# 4. 查询数据源实例
|
||||
instance = await _get_instance(db, ticket.instance_id)
|
||||
|
||||
# 5. 权限检查
|
||||
await check_instance_permission(current_user, instance, "ticket:execute", db)
|
||||
|
||||
# 5.1 维护窗口检查 — 非定时工单检查是否在维护窗口内
|
||||
if not ticket.scheduled_at:
|
||||
maintenance_window = await _check_maintenance_window(db)
|
||||
if maintenance_window:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"当前不在维护窗口内,允许执行时间: {maintenance_window}",
|
||||
)
|
||||
|
||||
# 6. 定时执行:如果 scheduled_at 在未来,改为 scheduled 状态
|
||||
if ticket.scheduled_at:
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -971,6 +1207,9 @@ async def execute_ticket(
|
||||
ticket.execute_result = report.to_json()
|
||||
ticket.execute_time = datetime.now(timezone.utc)
|
||||
|
||||
# 8.1 更新 SQL 逐条执行记录
|
||||
await _update_sql_items_from_report(db, ticket.id, report)
|
||||
|
||||
# 9. 记录执行日志
|
||||
log_detail = (
|
||||
f"执行结果: {report.status}, 影响行数: {report.affected_rows}, "
|
||||
@@ -1006,6 +1245,9 @@ async def execute_ticket(
|
||||
}, ensure_ascii=False)
|
||||
ticket.execute_time = datetime.now(timezone.utc)
|
||||
|
||||
# 标记所有 pending SQL items 为 failed
|
||||
await _mark_sql_items_failed(db, ticket.id, str(e))
|
||||
|
||||
await AuditLogService.log(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
|
||||
Reference in new issue
Block a user