feat(chv): 对齐 Muse P12——审计轨迹与活动视图
统一审计记录模型(时间/主体/动作/对象/结果/原因/关联任务/ 关联审批)+ 追加写 JSONL + SHA-256 哈希链(改写/删除/调序可 被 verify 发现)+ 字段级白名单清洗(密钥/token 值/对话原文 绝不进审计);事件分类覆盖任务派发、VM 生命周期、同步、审批、 支付、配额、记忆、后台、推理/遥测;from_legacy_row 承接现有 audit_log 行;导出选 JSON。 活动视图后端:计划登记(已做 vs 计划做,流转受控)+ 已批准 权限登记(接 P5 describe 形状)+ activity_summary 纯函数; 结构化审批卡数据(风险分级 + 接受/拒绝明确动作 + 五档授权 选项,永久档须显式 opt-in)+ card_matches_channel 校验卡与 P5 通道载荷一致。 只做后端 + 数据接口,前端视图留接口;P3/P5 不在本基线, 接线走 dict 契约,集成由 round-11 合并;审计持久化先落独立 JSONL,待 P7 状态库就绪后迁移。真机验证排队在 round-11 之后。 新测试 43/43,全量 cloud 套件 1910 OK(skipped=8); mypy 在改动文件上干净。
This commit is contained in:
1 parent
06c3e172fe
commit
9759be47bb
5 files changed
+1355
No files matched your search
@@ -0,0 +1,274 @@
|
||||
"""活动视图后端(feat-chv-muse-p12,纯标准库 + atomic_file)。
|
||||
|
||||
接 Muse 官方要求(报告 §2.2 第 7 步、§2.6):完整审计轨迹展示
|
||||
已做与计划做的事;头像下方显示正在做什么的摘要,点头像可见
|
||||
完整活动日志与已批准权限;审批是严格能力,绑定连接器/目的地
|
||||
与用例。
|
||||
|
||||
本模块只给后端 + 数据接口,前端视图(客户端 UI)是另一侧的
|
||||
工作,契约见各函数 docstring。
|
||||
|
||||
三件东西:
|
||||
|
||||
- ``PlanStore``:计划登记——已做 vs 计划做。状态只认
|
||||
pending/running/done/cancelled,流转 pending→running/
|
||||
cancelled、running→done/cancelled,终态不可再动,非法流转
|
||||
抛 ValueError。落 JSON 文件,原子写(0600)。
|
||||
- ``PermissionRegistry``:已批准权限登记。接 P5
|
||||
``ScopedCapability.describe()`` 的 dict 形状(capability_id /
|
||||
tier / connector / host / use_case / session_id / task_id /
|
||||
methods / max_uses / uses_left / expires_at / revoked),不
|
||||
import P5 模块——P5 不在本基线里,集成时由 round-11 合并。
|
||||
``list_active()`` 只返回未撤销、未过期、次数未用完的授权。
|
||||
- ``activity_summary()``:纯函数,把计划列表 + 近期审计记录 +
|
||||
有效授权拼成活动摘要:正在做 / 计划中 / 已做 / 近期记录 /
|
||||
已批准权限数。正在做来自计划登记与审计记录,不是实时进程
|
||||
探针;VM 内真实执行状态以执行器为准。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from cloud.chv.atomic_file import atomic_write_text
|
||||
|
||||
# 计划状态与合法流转(终态 done/cancelled 不可再动)。
|
||||
PLAN_STATUSES = ("pending", "running", "done", "cancelled")
|
||||
_PLAN_TRANSITIONS: dict[str, tuple[str, ...]] = {
|
||||
"pending": ("running", "cancelled"),
|
||||
"running": ("done", "cancelled"),
|
||||
"done": (),
|
||||
"cancelled": (),
|
||||
}
|
||||
|
||||
|
||||
def _load_json(path: str) -> dict:
|
||||
"""读 JSON 文件:不存在或坏掉返回空 dict(调用方重建)。"""
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (ValueError, OSError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _save_json(path: str, data: dict) -> None:
|
||||
"""原子写 JSON 文件(0600,写失败原样抛错)。"""
|
||||
text = json.dumps(data, ensure_ascii=False, indent=2)
|
||||
atomic_write_text(path, text, mode=0o600)
|
||||
|
||||
|
||||
class PlanStore:
|
||||
"""计划登记:一个项目一串计划,状态流转受控。"""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
self._data = _load_json(path)
|
||||
|
||||
def add_plan(self, project_id: str, title: str,
|
||||
task_id: str = "",
|
||||
ts: float | None = None) -> dict:
|
||||
"""登记一条计划(初始 pending),返回该计划。"""
|
||||
if not project_id:
|
||||
raise ValueError("project_id 不能为空")
|
||||
if not title:
|
||||
raise ValueError("title 不能为空")
|
||||
now = ts if ts is not None else time.time()
|
||||
plan = {
|
||||
"plan_id": "%d-%s" % (int(now * 1000),
|
||||
str(len(self._plans(project_id)))),
|
||||
"project_id": project_id,
|
||||
"title": title,
|
||||
"task_id": task_id,
|
||||
"status": "pending",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
self._data.setdefault(project_id, []).append(plan)
|
||||
_save_json(self.path, self._data)
|
||||
return dict(plan)
|
||||
|
||||
def set_status(self, project_id: str, plan_id: str,
|
||||
status: str, ts: float | None = None) -> dict:
|
||||
"""改计划状态:非法流转或未知 plan_id 抛 ValueError。"""
|
||||
if status not in PLAN_STATUSES:
|
||||
raise ValueError("未知计划状态:%s" % status)
|
||||
for plan in self._plans(project_id):
|
||||
if plan["plan_id"] != plan_id:
|
||||
continue
|
||||
allowed = _PLAN_TRANSITIONS[plan["status"]]
|
||||
if status not in allowed:
|
||||
raise ValueError(
|
||||
"计划 %s 不能从 %s 到 %s"
|
||||
% (plan_id, plan["status"], status))
|
||||
plan["status"] = status
|
||||
plan["updated_at"] = ts if ts is not None else time.time()
|
||||
_save_json(self.path, self._data)
|
||||
return dict(plan)
|
||||
raise ValueError("未知计划:%s" % plan_id)
|
||||
|
||||
def list_plans(self, project_id: str, status: str = "") -> list:
|
||||
"""该项目的计划(副本,按创建时间排序,可按状态过滤)。"""
|
||||
plans = [dict(p) for p in self._plans(project_id)]
|
||||
plans.sort(key=lambda p: p.get("created_at", 0.0))
|
||||
if status:
|
||||
plans = [p for p in plans if p.get("status") == status]
|
||||
return plans
|
||||
|
||||
def _plans(self, project_id: str) -> list:
|
||||
"""该项目的计划列表(内部引用,调用方不得外泄)。"""
|
||||
items = self._data.get(project_id, [])
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
|
||||
class PermissionRegistry:
|
||||
"""已批准权限登记:接 P5 能力描述,不存任何凭证值。
|
||||
|
||||
授权只含绑定(连接器/主机/用例/档位/有效期/次数余量),
|
||||
与 P5 ``ScopedCapability.describe()`` 的字段一一对应。
|
||||
"""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
self._data = _load_json(path)
|
||||
|
||||
def record_grant(self, project_id: str, capability: dict,
|
||||
ts: float | None = None) -> dict:
|
||||
"""登记一条已签发的授权,返回该授权(副本)。
|
||||
|
||||
capability 是 P5 describe() 形状的 dict;缺
|
||||
capability_id/tier/connector/host/use_case 任一即
|
||||
抛 ValueError。同 capability_id 重复登记视为更新。
|
||||
"""
|
||||
if not project_id:
|
||||
raise ValueError("project_id 不能为空")
|
||||
required = ("capability_id", "tier", "connector", "host",
|
||||
"use_case")
|
||||
for key in required:
|
||||
if not capability.get(key):
|
||||
raise ValueError("能力描述缺字段:%s" % key)
|
||||
now = ts if ts is not None else time.time()
|
||||
grant = {
|
||||
"capability_id": str(capability["capability_id"]),
|
||||
"project_id": project_id,
|
||||
"tier": str(capability["tier"]),
|
||||
"connector": str(capability["connector"]),
|
||||
"host": str(capability["host"]),
|
||||
"use_case": str(capability["use_case"]),
|
||||
"session_id": capability.get("session_id") or "",
|
||||
"task_id": capability.get("task_id") or "",
|
||||
"methods": sorted(capability.get("methods") or []),
|
||||
"max_uses": capability.get("max_uses"),
|
||||
"uses_left": capability.get("uses_left"),
|
||||
"expires_at": capability.get("expires_at"),
|
||||
"revoked": bool(capability.get("revoked", False)),
|
||||
"granted_at": now,
|
||||
}
|
||||
grants = self._grants(project_id)
|
||||
for index, old in enumerate(grants):
|
||||
if old["capability_id"] == grant["capability_id"]:
|
||||
grants[index] = grant
|
||||
break
|
||||
else:
|
||||
grants.append(grant)
|
||||
self._data.setdefault(project_id, grants)
|
||||
_save_json(self.path, self._data)
|
||||
return dict(grant)
|
||||
|
||||
def note_use(self, project_id: str, capability_id: str) -> bool:
|
||||
"""记一次使用:次数余量减一;未知 id 或已无余量返回 False。"""
|
||||
for grant in self._grants(project_id):
|
||||
if grant["capability_id"] != capability_id:
|
||||
continue
|
||||
if grant.get("revoked"):
|
||||
return False
|
||||
left = grant.get("uses_left")
|
||||
if left is None:
|
||||
return True
|
||||
if left <= 0:
|
||||
return False
|
||||
grant["uses_left"] = left - 1
|
||||
_save_json(self.path, self._data)
|
||||
return True
|
||||
return False
|
||||
|
||||
def revoke(self, project_id: str, capability_id: str) -> bool:
|
||||
"""撤销一条授权:未知 id 返回 False,已撤销也返回 False。"""
|
||||
for grant in self._grants(project_id):
|
||||
if grant["capability_id"] != capability_id:
|
||||
continue
|
||||
if grant.get("revoked"):
|
||||
return False
|
||||
grant["revoked"] = True
|
||||
_save_json(self.path, self._data)
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_active(self, project_id: str,
|
||||
now: float | None = None) -> list:
|
||||
"""该项目当前有效的授权:未撤销、未过期、次数未用完。"""
|
||||
at = now if now is not None else time.time()
|
||||
active = [dict(g) for g in self._grants(project_id)
|
||||
if _grant_active(g, at)]
|
||||
active.sort(key=lambda g: g.get("granted_at", 0.0))
|
||||
return active
|
||||
|
||||
def list_all(self, project_id: str) -> list:
|
||||
"""该项目全部授权(含已撤销/已过期,副本,按签发排序)。"""
|
||||
grants = [dict(g) for g in self._grants(project_id)]
|
||||
grants.sort(key=lambda g: g.get("granted_at", 0.0))
|
||||
return grants
|
||||
|
||||
def _grants(self, project_id: str) -> list:
|
||||
"""该项目的授权列表(内部引用,调用方不得外泄)。"""
|
||||
items = self._data.get(project_id, [])
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
|
||||
def _grant_active(grant: dict, now: float) -> bool:
|
||||
"""单条授权是否当前有效(未撤销 + 未过期 + 有余量)。"""
|
||||
if grant.get("revoked"):
|
||||
return False
|
||||
expires_at = grant.get("expires_at")
|
||||
if expires_at is not None and float(expires_at) <= now:
|
||||
return False
|
||||
left = grant.get("uses_left")
|
||||
if left is not None and int(left) <= 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def activity_summary(plans: list, recent_records: list,
|
||||
grants: list) -> dict:
|
||||
"""拼活动摘要(纯函数):正在做 / 计划中 / 已做 / 近期 / 授权。
|
||||
|
||||
入参是调用方已按项目过滤好的列表:plans 为计划 dict 列表,
|
||||
recent_records 为近期审计记录(倒序),grants 为有效授权。
|
||||
返回只含描述性字段,不含任何密钥与对话原文。
|
||||
"""
|
||||
running = [p for p in plans if p.get("status") == "running"]
|
||||
pending = [p for p in plans if p.get("status") == "pending"]
|
||||
done = [p for p in plans if p.get("status") == "done"]
|
||||
current = running[0]["title"] if running else ""
|
||||
recent = [{"ts": r.get("ts"), "action": r.get("action"),
|
||||
"category": r.get("category"),
|
||||
"result": r.get("result"),
|
||||
"object_type": r.get("object_type"),
|
||||
"object_id": r.get("object_id")}
|
||||
for r in recent_records]
|
||||
return {
|
||||
"current": current,
|
||||
"running": running,
|
||||
"planned": pending,
|
||||
"done": done,
|
||||
"recent": recent,
|
||||
"granted_permissions": grants,
|
||||
"counts": {
|
||||
"running": len(running),
|
||||
"planned": len(pending),
|
||||
"done": len(done),
|
||||
"granted": len(grants),
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user