105 lines
4.3 KiB
Python
105 lines
4.3 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 审计事件独立介质镜像 v1(moduleId: core-audit-mirror, 可重生 ✅)
|
|||
|
|
# plan.md §3.6 / 矩阵「审计 append-only 且可检测篡改」:
|
|||
|
|
# APS_AUDIT_MIRROR=1 时,write_audit 将每条事件同步镜像到独立
|
|||
|
|
# append-only JSONL(按租户/项目隔离);业务进程改 world 无法抹掉
|
|||
|
|
# 独立介质证据。镜像尽力而为:写入失败只记日志,不影响业务写。
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations # 前向类型引用
|
|||
|
|
|
|||
|
|
import json # JSONL 序列化
|
|||
|
|
import logging # 失败日志
|
|||
|
|
import os # 环境变量
|
|||
|
|
import threading # 并发写锁
|
|||
|
|
from datetime import datetime # 时间戳
|
|||
|
|
from pathlib import Path # 路径处理
|
|||
|
|
from typing import Any # 类型标注
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def mirror_enabled() -> bool:
|
|||
|
|
"""镜像开关:APS_AUDIT_MIRROR=1(或 true/yes)时启用。"""
|
|||
|
|
raw = (os.environ.get("APS_AUDIT_MIRROR") or "").strip().lower()
|
|||
|
|
return raw in ("1", "true", "yes", "on")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _default_mirror_dir() -> str:
|
|||
|
|
env = os.environ.get("APS_AUDIT_MIRROR_DIR")
|
|||
|
|
if env:
|
|||
|
|
return env
|
|||
|
|
try:
|
|||
|
|
from server.aps_home import path_under_data
|
|||
|
|
return str(path_under_data("audit-mirror"))
|
|||
|
|
except Exception:
|
|||
|
|
return os.path.join("server", "data", "audit-mirror")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _safe_scope(value: str) -> str:
|
|||
|
|
cleaned = "".join(c for c in (value or "default") if c.isalnum() or c in "-_")
|
|||
|
|
return cleaned[:64] or "default"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AuditMirror:
|
|||
|
|
"""独立 append-only 审计事件镜像(按租户/项目隔离,JSONL)。"""
|
|||
|
|
|
|||
|
|
def __init__(self, tenant_uuid: str = "platform", world_key: str = "default",
|
|||
|
|
mirror_dir: str | None = None) -> None:
|
|||
|
|
self.tenant_uuid = _safe_scope(tenant_uuid)
|
|||
|
|
self.world_key = _safe_scope(world_key)
|
|||
|
|
base = Path(mirror_dir or _default_mirror_dir())
|
|||
|
|
self.path = base / self.tenant_uuid / f"{self.world_key}.jsonl"
|
|||
|
|
self._lock = threading.Lock()
|
|||
|
|
|
|||
|
|
def _ensure_dir(self) -> None:
|
|||
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
def append_event(self, event: dict[str, Any]) -> dict[str, Any] | None:
|
|||
|
|
"""追加一条审计事件镜像(原子:单行写入 + flush + fsync)。"""
|
|||
|
|
record = {
|
|||
|
|
"event": event,
|
|||
|
|
"mirroredAt": _now_iso(),
|
|||
|
|
}
|
|||
|
|
with self._lock:
|
|||
|
|
try:
|
|||
|
|
self._ensure_dir()
|
|||
|
|
# 跨进程文件锁防并发 append 行交错(矩阵 116 行)
|
|||
|
|
from server.agent_core.audit_filelock import locked_append
|
|||
|
|
with locked_append(self.path) as f:
|
|||
|
|
f.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
|
|||
|
|
return record
|
|||
|
|
except OSError as exc:
|
|||
|
|
logger.warning("audit mirror append failed (%s): %s", self.path, exc)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def read_events(self) -> list[dict[str, Any]]:
|
|||
|
|
"""读取镜像中的全部事件(损坏行跳过并记日志)。"""
|
|||
|
|
if not self.path.exists():
|
|||
|
|
return []
|
|||
|
|
with self._lock:
|
|||
|
|
try:
|
|||
|
|
text = self.path.read_text(encoding="utf-8")
|
|||
|
|
except OSError as exc:
|
|||
|
|
logger.warning("audit mirror read failed (%s): %s", self.path, exc)
|
|||
|
|
return []
|
|||
|
|
events: list[dict[str, Any]] = []
|
|||
|
|
for line in text.splitlines():
|
|||
|
|
line = line.strip()
|
|||
|
|
if not line:
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
record = json.loads(line)
|
|||
|
|
if isinstance(record, dict) and isinstance(record.get("event"), dict):
|
|||
|
|
events.append(record["event"])
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
logger.warning("audit mirror corrupt line in %s", self.path)
|
|||
|
|
return events
|
|||
|
|
|
|||
|
|
def count(self) -> int:
|
|||
|
|
return len(self.read_events())
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _now_iso() -> str:
|
|||
|
|
from server.timeutil import fmt_dt
|
|||
|
|
return fmt_dt(datetime.now())
|