统一审计记录模型(时间/主体/动作/对象/结果/原因/关联任务/ 关联审批)+ 追加写 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 在改动文件上干净。
275 lines
11 KiB
Python
275 lines
11 KiB
Python
"""活动视图后端(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),
|
|
},
|
|
}
|