```
feat: 添加审批系统和遗留查询功能支持 - 添加审批系统,包括审批请求模型、服务和路由,支持创建、批准和拒绝操作 - 实现审批API密钥验证机制,区分普通API和审批API访问权限 - 添加Alembic数据库迁移支持,更新初始schema版本并添加降级保护 - 配置遗留MySQL查询白名单机制,支持命名查询和参数化查询 - 更新业务服务以集成审批流程,高风险操作需要审批票证 - 调整安全认证使用常量定义的HTTP头,增强安全性比较 - 优化.gitignore配置,添加日志目录排除和文档文件包含规则 - 更新Dockerfile添加alembic依赖包,修复OpenClaw适配器错误处理 ```
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
import json
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.time import utc_now
|
||||
from app.modules.approvals.constants import ApprovalActionValue, ApprovalStatus
|
||||
from app.modules.approvals.constants import (
|
||||
ApprovalActionValue,
|
||||
ApprovalErrorDetail,
|
||||
ApprovalStatus,
|
||||
)
|
||||
from app.modules.approvals.models import ApprovalRequest
|
||||
from app.modules.approvals.schemas import ApprovalCreate
|
||||
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
|
||||
@@ -61,7 +68,7 @@ class ApprovalService:
|
||||
if ticket is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Approval ticket not found",
|
||||
detail=ApprovalErrorDetail.NOT_FOUND,
|
||||
)
|
||||
return ticket
|
||||
|
||||
@@ -74,7 +81,15 @@ class ApprovalService:
|
||||
) -> ApprovalRequest:
|
||||
ticket = self.get_by_ticket(ticket_id)
|
||||
if ticket.status != ApprovalStatus.PENDING:
|
||||
raise HTTPException(status_code=409, detail="Approval ticket already decided")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=ApprovalErrorDetail.ALREADY_DECIDED,
|
||||
)
|
||||
if approved and approver == ticket.applicant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ApprovalErrorDetail.SELF_APPROVAL,
|
||||
)
|
||||
ticket.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED
|
||||
ticket.approver = approver
|
||||
ticket.decision_comment = comment
|
||||
@@ -95,6 +110,33 @@ class ApprovalService:
|
||||
)
|
||||
return ticket
|
||||
|
||||
def consume_for(
|
||||
self,
|
||||
ticket_id: str,
|
||||
domain: str,
|
||||
record_id: str | int | None,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
actor: str,
|
||||
) -> ApprovalRequest:
|
||||
ticket = self.get_by_ticket(ticket_id)
|
||||
if not self._is_ticket_scope_valid(ticket, domain, record_id, action):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ApprovalErrorDetail.NOT_APPROVED,
|
||||
)
|
||||
if not _payload_matches(ticket.payload, payload):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ApprovalErrorDetail.PAYLOAD_MISMATCH,
|
||||
)
|
||||
ticket.status = ApprovalStatus.USED
|
||||
ticket.used_by = actor
|
||||
ticket.used_at = utc_now()
|
||||
if record_id is not None and not ticket.record_id:
|
||||
ticket.record_id = str(record_id)
|
||||
return ticket
|
||||
|
||||
def is_approved_for(
|
||||
self,
|
||||
ticket_id: str,
|
||||
@@ -103,6 +145,15 @@ class ApprovalService:
|
||||
action: str,
|
||||
) -> bool:
|
||||
ticket = self.get_by_ticket(ticket_id)
|
||||
return self._is_ticket_scope_valid(ticket, domain, record_id, action)
|
||||
|
||||
@staticmethod
|
||||
def _is_ticket_scope_valid(
|
||||
ticket: ApprovalRequest,
|
||||
domain: str,
|
||||
record_id: str | int | None,
|
||||
action: str,
|
||||
) -> bool:
|
||||
if ticket.status != ApprovalStatus.APPROVED:
|
||||
return False
|
||||
if ticket.domain != domain:
|
||||
@@ -115,5 +166,28 @@ class ApprovalService:
|
||||
action,
|
||||
ApprovalActionValue.UPDATE,
|
||||
f"{ApprovalActionValue.UPDATE}:{domain}",
|
||||
ApprovalActionValue.WILDCARD,
|
||||
}
|
||||
|
||||
|
||||
def _payload_matches(approved_payload: str | None, requested_payload: dict[str, Any]) -> bool:
|
||||
try:
|
||||
parsed_payload = json.loads(approved_payload or "{}")
|
||||
except json.JSONDecodeError:
|
||||
parsed_payload = {}
|
||||
return _canonical_payload(parsed_payload) == _canonical_payload(requested_payload)
|
||||
|
||||
|
||||
def _canonical_payload(value: Any) -> str:
|
||||
return json.dumps(_json_safe(value), ensure_ascii=False, sort_keys=True, default=str)
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_json_safe(item) for item in value]
|
||||
return value
|
||||
|
||||
Reference in New Issue
Block a user