Files
cma-management/backend/app/api/notifications.py
T
fullstack-bot b815b7f623 fix(security): close unauthenticated CMA user API surface + audit tool (cma-api-auth-surface-audit-20260914)
Root cause: main.py include_router had no global auth dependency; user routers
(products/cost/predict/...) relied on assert_entity_scope, which treats a
missing session token as the Bot service channel and lets the request through.
Result: 46 user endpoints returned data without any token on a public site.

Changes:
- auth_middleware.py: add require_auth_401 (missing Bearer -> 401, delegates to
  require_auth otherwise); 401/403 semantics are explicit.
- router-level Depends(require_auth_401) on products, cost, predict,
  notifications, permissions, tenant, alignment; endpoint-level on
  /auth/roles and /admin/cache/clear.
- deps.py: document that get_entity_id/assert_entity_scope are NOT auth.
- frontend: add the missing Bearer header to 5 tokenless fetch/axios calls that
  hit the newly protected endpoints (KPIAlignment x2, RolePermissions x2,
  Dashboard x1).
- tests: new test_api_auth_surface_20260914.py (58 cases: no-token 401,
  token 200, cross-entity 403, bot channel unchanged); adapt test_predict /
  test_kpi_forecast to send session tokens like production.
- scripts/audit_api_auth_surface.py: reusable FastAPI dependency-graph audit
  (423 endpoints; A bot / B gap / C public), exits 1 when a B-class gap exists.
2026-09-14 16:41:15 +08:00

118 lines
4.0 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"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import NotificationChannel, NotificationLog
from app.auth_middleware import require_role, require_auth_401
from datetime import datetime
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:本 router 6 个端点全部无鉴权——无 token 可读写通知渠道(config 内含 webhook
# 地址/令牌等凭据)、可触发渠道测试推送、可读推送日志。收口为 require_auth_401。
router = APIRouter(prefix="/api/cma/notifications", tags=["通知配置"],
dependencies=[Depends(require_auth_401)])
def ch_to_dict(c):
return {
"id": c.id,
"name": c.name,
"channel_type": c.channel_type,
"config": c.config,
"enabled": c.enabled,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
@router.get("/channels")
def list_channels(db: Session = Depends(get_db)):
"""获取通知渠道列表"""
channels = db.query(NotificationChannel).order_by(NotificationChannel.id).all()
return {"data": [ch_to_dict(c) for c in channels]}
@router.post("/channels")
def create_channel(data: dict, db: Session = Depends(get_db)):
"""创建通知渠道"""
ch = NotificationChannel(
name=data["name"],
channel_type=data["channel_type"],
config=data.get("config", {}),
enabled=data.get("enabled", True),
)
db.add(ch)
db.commit()
db.refresh(ch)
return ch_to_dict(ch)
@router.put("/channels/{ch_id}")
def update_channel(ch_id: int, data: dict, db: Session = Depends(get_db)):
"""更新通知渠道"""
ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
if not ch:
raise HTTPException(404, "渠道不存在")
for k, v in data.items():
if hasattr(ch, k) and k not in ("id", "created_at"):
setattr(ch, k, v)
db.commit()
db.refresh(ch)
return ch_to_dict(ch)
@router.delete("/channels/{ch_id}")
def delete_channel(ch_id: int, db: Session = Depends(get_db)):
"""删除通知渠道"""
ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
if not ch:
raise HTTPException(404, "渠道不存在")
db.delete(ch)
db.commit()
return {"message": "已删除"}
@router.post("/channels/{ch_id}/test")
def test_channel(ch_id: int, db: Session = Depends(get_db)):
"""测试推送"""
from app.utils.notifier import push_alert
ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
if not ch:
raise HTTPException(404, "渠道不存在")
config = ch.config or {}
test_alert = {
"alert_level": "yellow",
"alert_message": "【测试通知】这是一条管理会计OS的测试预警",
"kpi_name": "销售总额",
"period": datetime.now().strftime("%Y-%m"),
"actual_value": "800,000",
"target_value": "1,000,000",
}
results = push_alert(test_alert, [{
"name": ch.name, "channel_type": ch.channel_type,
"config": config, "enabled": True
}])
return {"results": results}
@router.get("/logs")
def list_logs(page: int = 1, db: Session = Depends(get_db)):
"""通知历史"""
total = db.query(NotificationLog).count()
logs = db.query(NotificationLog).order_by(
NotificationLog.created_at.desc()
).offset((page - 1) * 20).limit(20).all()
return {
"total": total,
"data": [{
"id": l.id,
"alert_id": l.alert_id,
"channel": l.channel,
"recipient": l.recipient,
"title": l.title,
"status": l.status,
"error_msg": l.error_msg,
"sent_at": l.sent_at.isoformat() if l.sent_at else None,
"created_at": l.created_at.isoformat() if l.created_at else None,
} for l in logs]
}