This commit is contained in:
Your Name
2026-07-20 14:46:47 +08:00
parent bca0da7c7d
commit 845fefd6cf
6 changed files with 159 additions and 7 deletions
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -285,7 +285,7 @@ def get_network_device(device_id: int, db: Session = Depends(get_db)):
def create_network_device( def create_network_device(
name: str, name: str,
ip_address: str, ip_address: str,
credential_id: Optional[int] = None, snmp_credential_id: Optional[int] = None, # 前端字段名(与 ORM 列名一致)
port: int = 161, port: int = 161,
device_type: str = "other", device_type: str = "other",
description: Optional[str] = None, description: Optional[str] = None,
@@ -305,7 +305,7 @@ def create_network_device(
ip_address=ip_address, ip_address=ip_address,
port=port, port=port,
device_type=device_type, device_type=device_type,
snmp_credential_id=credential_id, snmp_credential_id=snmp_credential_id,
description=description, description=description,
location=location, location=location,
is_active=True is_active=True
@@ -337,7 +337,7 @@ def update_network_device(
device_id: int, device_id: int,
name: Optional[str] = None, name: Optional[str] = None,
ip_address: Optional[str] = None, ip_address: Optional[str] = None,
credential_id: Optional[int] = None, snmp_credential_id: Optional[int] = None,
port: Optional[int] = None, port: Optional[int] = None,
device_type: Optional[str] = None, device_type: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
@@ -363,8 +363,8 @@ def update_network_device(
device.name = name device.name = name
if ip_address: if ip_address:
device.ip_address = ip_address device.ip_address = ip_address
if credential_id is not None: if snmp_credential_id is not None:
device.snmp_credential_id = credential_id device.snmp_credential_id = snmp_credential_id
if port: if port:
device.port = port device.port = port
if device_type: if device_type:
+27
View File
@@ -17,6 +17,33 @@ class AuditAction(str, enum.Enum):
EXPORT = "export" EXPORT = "export"
OTHER = "other" OTHER = "other"
# ===== 兼容层:老 AuditAction 字符串字面量别名,值与新枚举对应 =====
# 旧代码(以及当前 AuditService._ACTION_MAP)里硬编码引用这些枚举属性名
NETWORK_CREATE = "create"
NETWORK_UPDATE = "update"
NETWORK_DELETE = "delete"
IP_UPDATE = "update"
IP_RESERVE = "update"
IP_UNRESERVE = "update"
SCAN_TRIGGER = "scan"
SNMP_CRED_CREATE = "create"
SNMP_CRED_UPDATE = "update"
SNMP_CRED_DELETE = "delete"
SNMP_DEVICE_CREATE = "create"
SNMP_DEVICE_UPDATE = "update"
SNMP_DEVICE_DELETE = "delete"
ALERT_ACK = "update"
ALERT_RESOLVE = "update"
USER_LOGIN = "login"
USER_LOGIN_FAILED = "login"
USER_LOGOUT = "logout"
USER_CREATE = "create"
USER_UPDATE_STATUS = "update"
USER_UNLOCK = "update"
USER_RESET_PASSWORD = "update"
USER_DELETE = "delete"
USER_CHANGE_PASSWORD = "update"
class AuditResource(str, enum.Enum): class AuditResource(str, enum.Enum):
"""资源类型枚举""" """资源类型枚举"""
+126 -1
View File
@@ -1,4 +1,4 @@
from typing import Optional, List from typing import Optional, List, Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime, timedelta from datetime import datetime, timedelta
import json import json
@@ -7,9 +7,134 @@ from app.models.audit import AuditLog, AuditAction, AuditResource
from app.models.auth import User from app.models.auth import User
# ===== 兼容层:老 API(字符串 action/resource)映射到新枚举 =====
# 字符串 action -> 枚举 + 资源类型;用于兼容各 endpoint 里硬编码的 "network.create" 等
_ACTION_MAP = {
# network
"network.create": (AuditAction.CREATE, AuditResource.NETWORK),
"network.update": (AuditAction.UPDATE, AuditResource.NETWORK),
"network.delete": (AuditAction.DELETE, AuditResource.NETWORK),
# ip
"ip.update": (AuditAction.UPDATE, AuditResource.IP_ADDRESS),
"ip.reserve": (AuditAction.UPDATE, AuditResource.IP_ADDRESS),
"ip.unreserve": (AuditAction.UPDATE, AuditResource.IP_ADDRESS),
# scan
"scan.trigger": (AuditAction.SCAN, AuditResource.SCAN_TASK),
# snmp
"snmp.credential.create": (AuditAction.CREATE, AuditResource.SNMP_CREDENTIAL),
"snmp.credential.update": (AuditAction.UPDATE, AuditResource.SNMP_CREDENTIAL),
"snmp.credential.delete": (AuditAction.DELETE, AuditResource.SNMP_CREDENTIAL),
"snmp.device.create": (AuditAction.CREATE, AuditResource.SNMP_DEVICE),
"snmp.device.update": (AuditAction.UPDATE, AuditResource.SNMP_DEVICE),
"snmp.device.delete": (AuditAction.DELETE, AuditResource.SNMP_DEVICE),
# alert
"alert.acknowledge": (AuditAction.UPDATE, AuditResource.ALERT),
"alert.resolve": (AuditAction.UPDATE, AuditResource.ALERT),
# user
"user.login": (AuditAction.LOGIN, AuditResource.USER),
"user.login.failed": (AuditAction.LOGIN, AuditResource.USER),
"user.logout": (AuditAction.LOGOUT, AuditResource.USER),
"user.create": (AuditAction.CREATE, AuditResource.USER),
"user.update_status": (AuditAction.UPDATE, AuditResource.USER),
"user.unlock": (AuditAction.UPDATE, AuditResource.USER),
"user.reset_password": (AuditAction.UPDATE, AuditResource.USER),
"user.delete": (AuditAction.DELETE, AuditResource.USER),
"user.change_password": (AuditAction.UPDATE, AuditResource.USER),
}
_RESOURCE_MAP = {
"network": AuditResource.NETWORK,
"ip": AuditResource.IP_ADDRESS,
"snmp_credential": AuditResource.SNMP_CREDENTIAL,
"snmp_device": AuditResource.SNMP_DEVICE,
"user": AuditResource.USER,
"alert": AuditResource.ALERT,
"scan": AuditResource.SCAN_TASK,
}
class AuditService: class AuditService:
"""审计日志服务""" """审计日志服务"""
@staticmethod
def record(
db: Session,
*,
action: str,
user=None,
resource_type: Optional[str] = None,
resource_id: Optional[Any] = None,
resource_name: Optional[str] = None,
method: Optional[str] = None,
path: Optional[str] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
status: str = "success",
detail: Optional[Any] = None,
) -> Optional["AuditLog"]:
"""
兼容层:将老版 API(字符串 action/resource)映射到新版 log()。
所有 endpoint 都通过本方法写审计,避免直接调用新签名造成 500。
任意环节失败都安全降级(不抛异常),审计写入失败不影响主业务。
"""
try:
# action 字符串 -> (枚举, 资源枚举) ;未知 action 用 OTHER + SYSTEM
enum_action, default_resource = _ACTION_MAP.get(
action, (AuditAction.OTHER, AuditResource.SYSTEM)
)
resource_enum = _RESOURCE_MAP.get(resource_type, default_resource)
# 详情拆分:dict 拆出 old/new;其余当 description
old_value = None
new_value = None
description = None
error_message = None
success = status != "failed"
if isinstance(detail, dict):
old_value = detail.get("before")
new_value = detail.get("after")
# 把 action 原始字符串 + 资源名放到 description 里,保留细节
desc_parts = [action]
if resource_name:
desc_parts.append(resource_name)
if status == "failed":
error_message = detail.get("error") or detail.get("message") or json.dumps(detail, ensure_ascii=False, default=str)
description = " | ".join(desc_parts)
else:
description = f"{action} | {resource_name or ''}"
if status == "failed" and detail:
error_message = str(detail)
# user 不能 None 传给 log()fallback 到 User(id=None, username='anonymous')
# 但 log() 会用 user.id / user.username / user.real_name,如果都是 None 就 OK
if user is None:
anon = User(id=None, username="anonymous", real_name=None)
else:
anon = user
return AuditService.log(
db=db,
user=anon,
action=enum_action,
resource=resource_enum,
resource_id=str(resource_id) if resource_id is not None else None,
description=description[:500] if description else None,
old_value=old_value,
new_value=new_value,
user_ip=ip_address,
user_agent=(user_agent or "")[:500],
request_method=method,
request_path=path,
success=success,
error_message=error_message,
)
except Exception as e:
db.rollback()
print(f"[audit] record() failed action={action}: {e}")
return None
@staticmethod @staticmethod
def log(db: Session, def log(db: Session,
user: User, user: User,