aps-agent/server/gateway/governance_api.py

269 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 治理 API(moduleId: gateway-governance-api, 可重生 ✅)
# R71.6:约束/自动化规则 CRUD + 启停 + 规则审计轨迹(/api/governance)
# 与审计统计聚合(/api/gov/audit/stats)。
# 前缀建议:/api/governance(规则管理)与 /api/gov(审计统计)。
# ============================================================
from __future__ import annotations
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, Field
from server.aps_domain.governance_rules import (
RULE_ID_PATTERN,
GovernanceRuleError,
GovernanceRuleService,
)
from server.auth.context import get_identity
router = APIRouter(prefix="/api/governance", tags=["governance"])
gov_router = APIRouter(prefix="/api/gov", tags=["governance-audit"])
class RuleCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
ruleId: str | None = Field(default=None, pattern=RULE_ID_PATTERN.pattern)
roomId: str = Field(min_length=1, max_length=128)
trigger: dict[str, Any]
gear: str = Field(default="G1", pattern="^G[0-4]$")
action: str = "notify"
guardrails: list[str] = Field(default_factory=list)
enabled: bool = True
description: str = ""
params: dict[str, Any] = Field(default_factory=dict)
class RuleUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
ruleId: str | None = Field(default=None, pattern=RULE_ID_PATTERN.pattern)
roomId: str | None = Field(default=None, min_length=1, max_length=128)
trigger: dict[str, Any] | None = None
gear: str | None = Field(default=None, pattern="^G[0-4]$")
action: str | None = None
guardrails: list[str] | None = None
enabled: bool | None = None
description: str | None = None
params: dict[str, Any] | None = None
class RuleView(BaseModel):
ruleId: str
roomId: str
trigger: dict[str, Any]
gear: str
action: str
guardrails: list[str]
enabled: bool
paused: bool = False
description: str
params: dict[str, Any]
class RuleEnvelope(BaseModel):
rule: RuleView
class RuleListEnvelope(BaseModel):
rules: list[RuleView]
class RuleDeleteEnvelope(BaseModel):
deleted: bool
ruleId: str
class RuleStageEnvelope(BaseModel):
message: str
block: dict[str, Any]
class RuleAuditEnvelope(BaseModel):
ruleId: str
total: int
events: list[dict[str, Any]]
class AuditStatsEnvelope(BaseModel):
ok: bool
source: str
total: int
filtered: int
window: dict[str, Any]
byModule: list[dict[str, Any]]
byAction: list[dict[str, Any]]
byResult: list[dict[str, Any]]
trend: list[dict[str, Any]]
rows: list[dict[str, Any]]
def get_governance_service() -> GovernanceRuleService:
"""默认服务接入网关既有 AutomationRuntime 单例与当前 store。"""
return GovernanceRuleService()
ServiceDep = Annotated[GovernanceRuleService, Depends(get_governance_service)]
def _raise_governance_error(exc: GovernanceRuleError) -> None:
if exc.code == "RULE_NOT_FOUND":
http_code = status.HTTP_404_NOT_FOUND
elif exc.code in ("RULE_EXISTS", "RULE_ID_MISMATCH", "RULE_CONFLICT"):
http_code = status.HTTP_409_CONFLICT
elif exc.code in ("INVALID_GRANULARITY", "INVALID_TIME", "INVALID_TIME_WINDOW"):
http_code = status.HTTP_400_BAD_REQUEST
elif exc.code == "PERSISTENCE_FAILED":
http_code = status.HTTP_503_SERVICE_UNAVAILABLE
else:
http_code = 422
raise HTTPException(
status_code=http_code,
detail={"code": exc.code, "message": str(exc)},
) from exc
def _stage_governance_change(
service: GovernanceRuleService,
action: str,
payload: dict[str, Any],
) -> RuleStageEnvelope:
identity = get_identity(required=True)
try:
block = service.stage_change(
action,
payload,
session_id=f"governance:{identity.user_id}",
)
except GovernanceRuleError as exc:
_raise_governance_error(exc)
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
return RuleStageEnvelope(
message="治理规则变更已进入 P2 确认队列,确认前不会写入规则状态。",
block=block.model_dump(),
)
@router.get("/rules", response_model=RuleListEnvelope)
def list_governance_rules(
service: ServiceDep,
enabled: bool | None = None,
) -> RuleListEnvelope:
"""规则清单(可按启用状态过滤)。"""
return RuleListEnvelope(rules=service.list_rules(enabled=enabled))
@router.get("/rules/{rule_id}", response_model=RuleEnvelope)
def get_governance_rule(
rule_id: str,
service: ServiceDep,
) -> RuleEnvelope:
"""单条规则详情。"""
try:
return RuleEnvelope(rule=service.get_rule(rule_id))
except GovernanceRuleError as exc:
_raise_governance_error(exc)
@router.post("/rules", response_model=RuleStageEnvelope, status_code=status.HTTP_202_ACCEPTED)
def create_governance_rule(
request: RuleCreateRequest,
service: ServiceDep,
) -> RuleStageEnvelope:
"""创建规则 P2 出卡;确认前不写规则注册表。"""
return _stage_governance_change(
service,
"governance.rule.create",
request.model_dump(),
)
@router.put("/rules/{rule_id}", response_model=RuleStageEnvelope, status_code=status.HTTP_202_ACCEPTED)
def update_governance_rule(
rule_id: str,
request: RuleUpdateRequest,
service: ServiceDep,
) -> RuleStageEnvelope:
"""更新规则 P2 出卡;确认前不替换规则定义。"""
return _stage_governance_change(
service,
"governance.rule.update",
{"ruleId": rule_id, "patch": request.model_dump(exclude_none=True)},
)
@router.delete("/rules/{rule_id}", response_model=RuleStageEnvelope, status_code=status.HTTP_202_ACCEPTED)
def delete_governance_rule(
rule_id: str,
service: ServiceDep,
) -> RuleStageEnvelope:
"""删除规则 P2 出卡;确认前不移除规则。"""
return _stage_governance_change(
service,
"governance.rule.delete",
{"ruleId": rule_id},
)
@router.post("/rules/{rule_id}/enable", response_model=RuleStageEnvelope, status_code=status.HTTP_202_ACCEPTED)
def enable_governance_rule(
rule_id: str,
service: ServiceDep,
) -> RuleStageEnvelope:
"""启用规则 P2 出卡;确认前不参与 tick。"""
return _stage_governance_change(
service,
"governance.rule.enable",
{"ruleId": rule_id},
)
@router.post("/rules/{rule_id}/disable", response_model=RuleStageEnvelope, status_code=status.HTTP_202_ACCEPTED)
def disable_governance_rule(
rule_id: str,
service: ServiceDep,
) -> RuleStageEnvelope:
"""停用规则 P2 出卡;确认前保持原启用状态。"""
return _stage_governance_change(
service,
"governance.rule.disable",
{"ruleId": rule_id},
)
@router.get("/rules/{rule_id}/audit", response_model=RuleAuditEnvelope)
def governance_rule_audit(
rule_id: str,
service: ServiceDep,
limit: int = Query(default=50, ge=1, le=1000),
) -> RuleAuditEnvelope:
"""规则审计轨迹:target 命中该规则的全部审计事件。"""
return RuleAuditEnvelope(**service.rule_audit(rule_id, limit=limit))
@gov_router.get("/audit/stats", response_model=AuditStatsEnvelope)
def governance_audit_stats(
service: ServiceDep,
startTime: str | None = None,
endTime: str | None = None,
module: str | None = None,
action: str | None = None,
result: str | None = None,
granularity: str = Query(default="day", pattern="^(day|hour)$"),
) -> AuditStatsEnvelope:
"""审计量聚合:按模块/动作/结果/时间窗口统计(数据源复用 audit ledger)。"""
try:
return AuditStatsEnvelope(**service.audit_stats(
start_time=startTime,
end_time=endTime,
module=module,
action=action,
result=result,
granularity=granularity,
))
except GovernanceRuleError as exc:
_raise_governance_error(exc)