sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1
- P0: 实例访问控制全线接入权限组 (829测试) - P1: permissions字段激活 + 操作级权限检查 (843测试) - 前端: 删除数据权限页面, 权限组合并为唯一入口 - 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析 - 新增: MongoDB管理页面 + Dashboard增强 - 修复: Pydantic V2迁移 + 前端TS错误修复
This commit is contained in:
1 parent
6fcd9465cf
commit
4c9427e546
105 files changed
+23680
-471
No files matched your search
@@ -1,6 +1,9 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Optional, List, Dict, Any, TYPE_CHECKING
|
||||
from datetime import datetime, timezone
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.services.ticket_executor import ExecutionReport
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func, or_
|
||||
@@ -13,8 +16,11 @@ from app.models.ticket import Ticket
|
||||
from app.models.instance import Instance
|
||||
from app.schemas.ticket import TicketCreate, TicketReview, TicketResponse
|
||||
from app.core.crypto import decrypt_password
|
||||
from app.core.permissions import check_instance_access, check_instance_permission
|
||||
from app.services.audit_engine import SQLAuditEngine
|
||||
from app.services.audit_engine_v2 import SQLAuditEngineV2
|
||||
from app.services.audit_log_service import AuditLogService
|
||||
from app.models.workflow import WorkflowDefinition, WorkflowStep, TicketApproval
|
||||
import json
|
||||
|
||||
|
||||
@@ -111,13 +117,34 @@ async def create_ticket(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# Verify instance exists
|
||||
inst = await db.execute(select(Instance).where(Instance.id == data.instance_id))
|
||||
if not inst.scalar_one_or_none():
|
||||
inst_result = await db.execute(select(Instance).where(Instance.id == data.instance_id))
|
||||
instance = inst_result.scalar_one_or_none()
|
||||
if not instance:
|
||||
raise HTTPException(status_code=400, detail="数据源不存在")
|
||||
|
||||
# Auto-audit SQL before creating ticket
|
||||
engine = SQLAuditEngine(env="production" if data.db_type == "mysql" else "development")
|
||||
report = engine.audit(data.sql_content)
|
||||
# 权限组检查:用户是否有权对该数据源提交工单
|
||||
try:
|
||||
await check_instance_access(current_user, instance, db)
|
||||
except HTTPException:
|
||||
raise HTTPException(status_code=403, detail="您没有权限对该数据源提交变更工单")
|
||||
|
||||
# P1: 操作级权限检查
|
||||
await check_instance_permission(current_user, instance, "ticket:create", db)
|
||||
|
||||
# Auto-audit SQL before creating ticket (使用 V2 引擎)
|
||||
v2_engine = SQLAuditEngineV2(db_session=db, env="production" if data.db_type == "mysql" else "development")
|
||||
v2_report = await v2_engine.audit(data.sql_content, db_type=data.db_type)
|
||||
|
||||
# Check for default workflow to determine initial status
|
||||
default_wf_result = await db.execute(
|
||||
select(WorkflowDefinition).where(
|
||||
WorkflowDefinition.is_default == True,
|
||||
WorkflowDefinition.is_active == True,
|
||||
)
|
||||
)
|
||||
default_workflow = default_wf_result.scalar_one_or_none()
|
||||
|
||||
initial_status = "reviewing" if default_workflow else "pending"
|
||||
|
||||
ticket = Ticket(
|
||||
title=data.title,
|
||||
@@ -129,23 +156,42 @@ async def create_ticket(
|
||||
reason=data.reason,
|
||||
priority=data.priority,
|
||||
applicant_id=current_user.id,
|
||||
status="pending",
|
||||
audit_result=json.dumps(report.to_dict(), ensure_ascii=False) if report.findings else None,
|
||||
status=initial_status,
|
||||
audit_result=json.dumps(v2_report.to_dict(), ensure_ascii=False) if v2_report.findings else None,
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush() # flush to get ticket.id for audit log
|
||||
await db.flush() # flush to get ticket.id
|
||||
|
||||
# If default workflow exists, create TicketApproval entries for each step
|
||||
if default_workflow:
|
||||
wf_steps_result = await db.execute(
|
||||
select(WorkflowStep)
|
||||
.where(WorkflowStep.workflow_id == default_workflow.id)
|
||||
.order_by(WorkflowStep.step_number)
|
||||
)
|
||||
wf_steps = wf_steps_result.scalars().all()
|
||||
for step in wf_steps:
|
||||
approval = TicketApproval(
|
||||
ticket_id=ticket.id,
|
||||
step_number=step.step_number,
|
||||
step_name=step.name,
|
||||
approver_role=step.approver_role,
|
||||
action="pending",
|
||||
)
|
||||
db.add(approval)
|
||||
await db.flush()
|
||||
|
||||
# Log ticket creation
|
||||
await AuditLogService.log(
|
||||
db, user_id=current_user.id, action="CREATE_TICKET",
|
||||
target_type="ticket", target_id=ticket.id,
|
||||
detail=f"SQL审核评分: {report.score}, 通过: {report.passed}",
|
||||
detail=f"SQL审核评分: {v2_report.score}, 通过: {v2_report.passed}, 规则数: {len(v2_report.findings)}",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
result = await _enrich_ticket(ticket, db)
|
||||
result["audit_report"] = report.to_dict()
|
||||
result["audit_report"] = v2_report.to_dict()
|
||||
return result
|
||||
|
||||
|
||||
@@ -184,15 +230,19 @@ async def audit_sql(
|
||||
raise HTTPException(status_code=400, detail="数据源不存在")
|
||||
|
||||
env = "production" if instance.env == "production" else "development"
|
||||
engine = SQLAuditEngine(env=env)
|
||||
report = engine.audit(data.sql_content)
|
||||
db_type = instance.db_type or "mysql"
|
||||
|
||||
# 使用 V2 引擎进行审核(支持数据库加载规则配置)
|
||||
v2_engine = SQLAuditEngineV2(db_session=db, env=env)
|
||||
report = await v2_engine.audit(data.sql_content, db_type=db_type)
|
||||
|
||||
return {
|
||||
"score": report.score,
|
||||
"passed": report.passed,
|
||||
"has_warnings": report.has_warnings,
|
||||
"summary": report.summary,
|
||||
"findings": [
|
||||
{"rule": f.rule, "level": f.level.value, "message": f.message}
|
||||
{"rule": f.rule_code, "level": f.level.value, "message": f.message}
|
||||
for f in report.findings
|
||||
],
|
||||
}
|
||||
@@ -215,6 +265,41 @@ async def get_ticket(
|
||||
return await _enrich_ticket(ticket, db)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/approvals")
|
||||
async def get_ticket_approvals(
|
||||
ticket_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取工单的审批进度(多级审批流)"""
|
||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
||||
ticket = result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
raise HTTPException(status_code=404, detail="工单不存在")
|
||||
|
||||
approvals_result = await db.execute(
|
||||
select(TicketApproval)
|
||||
.where(TicketApproval.ticket_id == ticket_id)
|
||||
.order_by(TicketApproval.step_number)
|
||||
)
|
||||
approvals = approvals_result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": a.id,
|
||||
"step_number": a.step_number,
|
||||
"step_name": a.step_name,
|
||||
"approver_role": a.approver_role,
|
||||
"action": a.action,
|
||||
"approver_id": a.approver_id,
|
||||
"comment": a.comment,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
|
||||
}
|
||||
for a in approvals
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/review")
|
||||
async def review_ticket(
|
||||
ticket_id: int,
|
||||
@@ -231,8 +316,56 @@ async def review_ticket(
|
||||
raise HTTPException(status_code=400, detail=f"工单状态为 {ticket.status},无法审核")
|
||||
|
||||
if data.action == "approve":
|
||||
ticket.status = "approved"
|
||||
# Check for pending workflow steps (multi-step approval)
|
||||
pending_approvals_result = await db.execute(
|
||||
select(TicketApproval)
|
||||
.where(TicketApproval.ticket_id == ticket_id)
|
||||
.order_by(TicketApproval.step_number)
|
||||
)
|
||||
pending_approvals = pending_approvals_result.scalars().all()
|
||||
|
||||
if pending_approvals:
|
||||
# Multi-step workflow: find the current pending step
|
||||
current_step = None
|
||||
for pa in pending_approvals:
|
||||
if pa.action == "pending":
|
||||
current_step = pa
|
||||
break
|
||||
|
||||
if current_step:
|
||||
# Approve current step
|
||||
current_step.action = "approved"
|
||||
current_step.approver_id = current_user.id
|
||||
current_step.comment = data.comment or "审批通过"
|
||||
|
||||
# Check if there's a next pending step
|
||||
has_next = any(
|
||||
pa.action == "pending"
|
||||
for pa in pending_approvals
|
||||
if pa.id != current_step.id
|
||||
)
|
||||
if has_next:
|
||||
ticket.status = "reviewing"
|
||||
else:
|
||||
ticket.status = "approved"
|
||||
else:
|
||||
# All steps already processed
|
||||
ticket.status = "approved"
|
||||
else:
|
||||
# No workflow steps — single-step approval (backward compat)
|
||||
ticket.status = "approved"
|
||||
else:
|
||||
# Reject: reject the whole ticket immediately
|
||||
# Mark all pending approval steps as rejected
|
||||
pending_approvals_result = await db.execute(
|
||||
select(TicketApproval)
|
||||
.where(TicketApproval.ticket_id == ticket_id, TicketApproval.action == "pending")
|
||||
)
|
||||
for pa in pending_approvals_result.scalars().all():
|
||||
pa.action = "rejected"
|
||||
pa.approver_id = current_user.id
|
||||
pa.comment = data.comment or "审批拒绝"
|
||||
|
||||
ticket.status = "rejected"
|
||||
|
||||
ticket.approver_id = current_user.id
|
||||
@@ -419,6 +552,24 @@ async def execute_ticket(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin", "dba")),
|
||||
):
|
||||
"""执行工单。
|
||||
|
||||
使用多数据库工单执行引擎,按 db_type 自动分发到对应执行器。
|
||||
支持 MySQL、PostgreSQL、Redis、MongoDB。
|
||||
执行流程包括:语法预校验、执行前快照、执行变更、执行后验证、生成报告。
|
||||
|
||||
Args:
|
||||
ticket_id: 工单 ID
|
||||
db: 数据库会话
|
||||
current_user: 当前用户(需要 admin 或 dba 角色)
|
||||
|
||||
Returns:
|
||||
执行后的工单详情,包含执行报告
|
||||
|
||||
Raises:
|
||||
HTTPException: 工单不存在、状态不正确、数据源不存在等
|
||||
"""
|
||||
# 1. 查询工单
|
||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
||||
ticket = result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
@@ -427,27 +578,170 @@ async def execute_ticket(
|
||||
if ticket.status != "approved":
|
||||
raise HTTPException(status_code=400, detail="只能执行已审批的工单")
|
||||
|
||||
# 2. 查询数据源实例
|
||||
inst_result = await db.execute(
|
||||
select(Instance).where(Instance.id == ticket.instance_id)
|
||||
)
|
||||
instance = inst_result.scalar_one_or_none()
|
||||
if not instance:
|
||||
raise HTTPException(status_code=400, detail="数据源不存在")
|
||||
|
||||
# 3. 更新工单状态为执行中
|
||||
ticket.status = "executing"
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
# 4. 使用新的多数据库执行引擎
|
||||
from app.services.ticket_executor import TicketExecutor
|
||||
executor = TicketExecutor()
|
||||
|
||||
try:
|
||||
inst_result = await db.execute(select(Instance).where(Instance.id == ticket.instance_id))
|
||||
instance = inst_result.scalar_one_or_none()
|
||||
if not instance:
|
||||
raise HTTPException(status_code=400, detail="数据源不存在")
|
||||
|
||||
from app.services.db_connector import DBConnector
|
||||
# 解密密码
|
||||
instance.password = decrypt_password(instance.password)
|
||||
exec_result = await DBConnector.execute_dml(instance, ticket.target_database, ticket.sql_content)
|
||||
|
||||
ticket.status = "success"
|
||||
ticket.execute_result = exec_result
|
||||
# 执行工单
|
||||
report = await executor.execute(ticket, instance)
|
||||
|
||||
# 5. 更新工单状态和结果
|
||||
if report.status == "success":
|
||||
ticket.status = "success"
|
||||
else:
|
||||
ticket.status = "failed"
|
||||
|
||||
ticket.execute_result = report.to_json()
|
||||
ticket.execute_time = datetime.now(timezone.utc)
|
||||
|
||||
# 6. 记录执行日志
|
||||
log_detail = (
|
||||
f"执行结果: {report.status}, 影响行数: {report.affected_rows}, "
|
||||
f"耗时: {report.duration_ms}ms, 验证通过: {report.validation_passed}"
|
||||
)
|
||||
if report.errors:
|
||||
log_detail += f", 错误: {'; '.join(report.errors[:3])}"
|
||||
|
||||
await AuditLogService.log(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
action="EXECUTE_TICKET",
|
||||
target_type="ticket",
|
||||
target_id=ticket.id,
|
||||
detail=log_detail,
|
||||
)
|
||||
|
||||
# 7. 发送通知(执行完成后)
|
||||
try:
|
||||
await _send_execution_notification(db, ticket, report)
|
||||
except Exception:
|
||||
# 通知失败不应影响执行结果
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
ticket.status = "failed"
|
||||
ticket.execute_result = str(e)
|
||||
ticket.execute_time = datetime.now(timezone.utc)
|
||||
|
||||
await AuditLogService.log(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
action="EXECUTE_TICKET",
|
||||
target_type="ticket",
|
||||
target_id=ticket.id,
|
||||
detail=f"执行异常: {type(e).__name__}: {e}",
|
||||
)
|
||||
|
||||
# 8. 提交事务并返回
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
return await _enrich_ticket(ticket, db)
|
||||
|
||||
# 构建响应
|
||||
ticket_data = await _enrich_ticket(ticket, db)
|
||||
|
||||
# 尝试解析执行报告
|
||||
try:
|
||||
import json
|
||||
exec_report = json.loads(ticket.execute_result) if ticket.execute_result else {}
|
||||
if isinstance(exec_report, dict):
|
||||
from app.schemas.execution import ExecutionReportResponse
|
||||
ticket_data["execution_report"] = ExecutionReportResponse(
|
||||
status=exec_report.get("status", "unknown"),
|
||||
affected_rows=exec_report.get("affected_rows", 0),
|
||||
duration_ms=exec_report.get("duration_ms", 0),
|
||||
validation_passed=exec_report.get("validation_passed", False),
|
||||
warnings=exec_report.get("warnings", []),
|
||||
errors=exec_report.get("errors", []),
|
||||
raw_output=exec_report.get("raw_output"),
|
||||
before_snapshot_summary=exec_report.get("before_snapshot"),
|
||||
after_snapshot_summary=exec_report.get("after_snapshot"),
|
||||
)
|
||||
except Exception:
|
||||
# 如果解析失败,不返回结构化报告
|
||||
ticket_data["execution_report"] = None
|
||||
|
||||
return ticket_data
|
||||
|
||||
|
||||
async def _send_execution_notification(
|
||||
db: AsyncSession, ticket: Ticket, report: "ExecutionReport"
|
||||
) -> None:
|
||||
"""发送工单执行完成通知。
|
||||
|
||||
查询已启用的通知渠道并发送执行结果通知。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
ticket: 工单对象
|
||||
report: 执行报告
|
||||
"""
|
||||
from app.models.notification import NotificationChannel
|
||||
from app.services.notification_sender import NotificationSender
|
||||
|
||||
result = await db.execute(
|
||||
select(NotificationChannel).where(NotificationChannel.is_enabled == True)
|
||||
)
|
||||
channels = result.scalars().all()
|
||||
|
||||
if not channels:
|
||||
return
|
||||
|
||||
sender = NotificationSender()
|
||||
|
||||
# 确定事件类型
|
||||
if report.status == "success":
|
||||
event_type = "ticket_executed"
|
||||
else:
|
||||
event_type = "ticket_failed"
|
||||
|
||||
# 构建 SQL 预览(前 100 字符)
|
||||
sql_preview = ticket.sql_content[:100] + "..." if len(ticket.sql_content) > 100 else ticket.sql_content
|
||||
|
||||
# 获取申请人信息
|
||||
applicant_name = "未知"
|
||||
if ticket.applicant_id:
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == ticket.applicant_id)
|
||||
)
|
||||
applicant = result.scalar_one_or_none()
|
||||
if applicant:
|
||||
applicant_name = applicant.display_name
|
||||
|
||||
notification_data = {
|
||||
"title": ticket.title,
|
||||
"ticket_id": ticket.id,
|
||||
"db_type": ticket.db_type,
|
||||
"target_database": ticket.target_database,
|
||||
"sql_preview": sql_preview,
|
||||
"priority": ticket.priority,
|
||||
"applicant": applicant_name,
|
||||
"result": report.status,
|
||||
"duration": report.duration_ms,
|
||||
"affected_rows": report.affected_rows,
|
||||
"error": "; ".join(report.errors[:3]) if report.errors else "",
|
||||
"operator": "dba", # 执行人角色
|
||||
}
|
||||
|
||||
await sender.notify_ticket_event(
|
||||
ticket_id=ticket.id,
|
||||
event_type=event_type,
|
||||
channels=channels,
|
||||
ticket_data=notification_data,
|
||||
)
|
||||
Reference in new issue
Block a user