round-12: migrate P8 registries + P12 audit to AppStateStore
- app_state.py: host_state_db_path() (host-level DB, distinct from per-VM state_db_path); kv_get/kv_put/kv_delete on meta (namespaced keys; kv_put skips the no-secrets guard by design — registry docs hold user prompts, documented); audit_trail table (P12 record shape + hash chain, seq explicit); counts()/backup() gain audit_trail. - swarm.py / presentation.py / browser_tasks.py: JSON-file backends replaced by KV rows (p8-swarm / p8-ledger / p8-browser-tasks); legacy JSON imported once on first load (renamed .migrated); corrupt row / unreadable store returns empty (fail-safe, same semantics as before; next successful save self-heals). - audit_trail.py: StoreAuditTrail (same record shape, hash chain, filters, export as JSONL version, backed by audit_trail table); migrate_jsonl_to_store() moves a chain verbatim (broken chain refused, divergent seq refused, re-run idempotent). JSONL class kept for reading old files. - P12 dict contracts now run against real P3/P5 code (ApprovalQueue.record -> build_approval_card -> card_matches_channel; CapabilityIssuer.issue -> describe -> PermissionRegistry.record_grant). - P6->P3 local protocol test: real PrivsepClient + real worker server + real git status; worker git argv carries the real P3 helper + useHttpPath; no token in argv/env/audit. runuser + HTTP pull stay real-machine-only (see gap docs). - Gap docs updated: P8 migration done, guest-side browser follow-up explicitly left for the real-machine track (no task_id in the browser protocol yet; lifecycle needs a real VM to verify); P6 wiring + P12 migration marked done; production-readiness.md gains §20 (round-12 integration). - Tests: new cloud/tests/test_chv_muse_round12.py, 24/24 OK; test_chv_muse_p8.py ledger corrupt test rewritten for the KV backend (+ legacy-import test). P7+P8: 95+1 tests OK.
This commit is contained in:
1 parent
466cf7eae7
commit
d4cef29cdb
11 files changed
+1147
-63
No files matched your search
+169
-3
@@ -34,9 +34,13 @@ VM 内所有文件。
|
||||
- project_id 强制:``list_records()``/``export_json()`` 必须传 project_id,
|
||||
只返回该项目记录;跨项目读取返回空,不抛错(与现有
|
||||
``list_audit`` 行为一致,不泄露“存在别的项目记录”)。
|
||||
- 持久化:先落独立 JSONL 文件(0600),待 P7 状态库就绪后迁移;
|
||||
``from_legacy_row()`` 把现有 ``audit_log`` 行转成统一记录
|
||||
(标 ``legacy: true``,本身无哈希链,append 时重新入链)。
|
||||
- 持久化:round-12 已迁移到 P7 状态库(``StoreAuditTrail``,
|
||||
宿主级 AppStateStore 的 ``audit_trail`` 表,同一记录形状与
|
||||
哈希链);``AuditTrail``(JSONL 0600)保留,用于读旧文件与
|
||||
迁移源,``migrate_jsonl_to_store()`` 原样搬链(坏链拒绝、
|
||||
分叉拒绝、重跑幂等);``from_legacy_row()`` 把现有
|
||||
``audit_log`` 行转成统一记录(标 ``legacy: true``,本身无
|
||||
哈希链,append 时重新入链)。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -310,6 +314,168 @@ class AuditTrail:
|
||||
return [line.strip() for line in f if line.strip()]
|
||||
|
||||
|
||||
class StoreAuditTrail:
|
||||
"""AppStateStore 后端的统一审计轨迹(round-12 迁移)。
|
||||
|
||||
与 ``AuditTrail``(JSONL)同一记录形状、同一哈希链算法、
|
||||
同一过滤/导出语义;持久化改走宿主级 AppStateStore 的
|
||||
``audit_trail`` 表(``state_dir/app-state.sqlite``)。
|
||||
JSONL 版保留,用于读旧文件与迁移源;新写一律走本类。
|
||||
|
||||
写失败不静默丢(与 JSONL 版同款:append 抛错由调用方
|
||||
决定是否 best-effort);库打不开时 __init__ 即抛错,不
|
||||
回空链——审计链丢了不能假装没事(与 P8 登记的 fail-safe
|
||||
不同,见差距表)。
|
||||
"""
|
||||
|
||||
def __init__(self, state_dir: str) -> None:
|
||||
if not state_dir or not isinstance(state_dir, str):
|
||||
raise ValueError("state_dir required")
|
||||
self.state_dir = state_dir
|
||||
self._last_hash = GENESIS_HASH
|
||||
self._seq = 0
|
||||
self._load_tail()
|
||||
|
||||
def _open_store(self):
|
||||
"""打开宿主级状态库(每次调用新建连接,用完即关)。"""
|
||||
from cloud.chv.app_state import AppStateStore, \
|
||||
host_state_db_path
|
||||
store = AppStateStore(host_state_db_path(self.state_dir))
|
||||
store.open()
|
||||
return store
|
||||
|
||||
def _load_tail(self) -> None:
|
||||
"""启动时读库尾,恢复末尾 seq 与链哈希。"""
|
||||
with self._open_store() as store:
|
||||
rows = store.list_audit_trail_rows()
|
||||
if not rows:
|
||||
return
|
||||
last = rows[-1]
|
||||
self._seq = int(last["seq"])
|
||||
self._last_hash = str(last["hash"])
|
||||
|
||||
def append(self, project_id: str, actor: str, category: str,
|
||||
action: str, object_type: str = "",
|
||||
object_id: str = "", result: str = "ok",
|
||||
reason: str = "", task_id: str = "",
|
||||
approval_id: str = "",
|
||||
detail: dict | None = None,
|
||||
ts: float | None = None) -> dict:
|
||||
"""追加一条审计记录,返回该记录(含 seq 与链哈希)。"""
|
||||
if not project_id:
|
||||
raise ValueError("project_id 不能为空")
|
||||
if category not in AUDIT_CATEGORIES:
|
||||
raise ValueError("未知审计分类:%s" % category)
|
||||
seq = self._seq + 1
|
||||
record = {
|
||||
"seq": seq,
|
||||
"ts": ts if ts is not None else time.time(),
|
||||
"project_id": project_id,
|
||||
"actor": actor,
|
||||
"category": category,
|
||||
"action": action,
|
||||
"object_type": object_type,
|
||||
"object_id": object_id,
|
||||
"result": result,
|
||||
"reason": reason,
|
||||
"task_id": task_id,
|
||||
"approval_id": approval_id,
|
||||
"detail": sanitize_detail(detail),
|
||||
"prev_hash": self._last_hash,
|
||||
}
|
||||
chain_hash = compute_hash(self._last_hash, record)
|
||||
record["hash"] = chain_hash
|
||||
with self._open_store() as store:
|
||||
store.insert_audit_trail_row(record)
|
||||
self._seq = seq
|
||||
self._last_hash = chain_hash
|
||||
return dict(record)
|
||||
|
||||
def list_records(self, project_id: str, category: str = "",
|
||||
action: str = "", task_id: str = "",
|
||||
since: float = 0.0, until: float = 0.0,
|
||||
limit: int = 100, offset: int = 0) -> list:
|
||||
"""按时间倒序返回该项目的审计记录(副本,可分页过滤)。
|
||||
project_id 为空或不匹配一律返回空列表。limit <= 0
|
||||
表示不限制条数。"""
|
||||
if not project_id:
|
||||
return []
|
||||
with self._open_store() as store:
|
||||
rows = store.list_audit_trail_rows()
|
||||
kept = [r for r in rows if r.get("project_id") == project_id]
|
||||
kept = _filter_rows(kept, category, action, task_id,
|
||||
since, until)
|
||||
kept.sort(key=lambda r: (r.get("ts", 0.0), r.get("seq", 0)),
|
||||
reverse=True)
|
||||
if offset:
|
||||
kept = kept[offset:]
|
||||
if limit > 0:
|
||||
kept = kept[:limit]
|
||||
return [dict(r) for r in kept]
|
||||
|
||||
def verify(self) -> tuple[bool, int]:
|
||||
"""校验整条哈希链:返回 (是否通过, 首个坏行下标)。
|
||||
通过时下标为 -1。"""
|
||||
with self._open_store() as store:
|
||||
rows = store.list_audit_trail_rows()
|
||||
expected_prev = GENESIS_HASH
|
||||
expected_seq = 1
|
||||
for index, record in enumerate(rows):
|
||||
if not _chain_ok(record, expected_prev, expected_seq):
|
||||
return False, index
|
||||
expected_prev = record["hash"]
|
||||
expected_seq += 1
|
||||
return True, -1
|
||||
|
||||
def export_json(self, project_id: str, dest: str) -> int:
|
||||
"""导出该项目全部审计记录为 JSON 数组,返回导出条数。"""
|
||||
rows = self.list_records(project_id, limit=0)
|
||||
rows.sort(key=lambda r: (r.get("ts", 0.0), r.get("seq", 0)))
|
||||
with open(dest, "w", encoding="utf-8") as f:
|
||||
json.dump(rows, f, ensure_ascii=False, indent=2)
|
||||
os.chmod(dest, 0o600)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def migrate_jsonl_to_store(path: str, state_dir: str) -> int:
|
||||
"""把 JSONL 轨迹原样搬进 StoreAuditTrail,返回新插入条数。
|
||||
|
||||
先校验 JSONL 整条链(坏链即 ValueError,拒绝迁移,不静
|
||||
默丢行);行按原文 seq/hash/prev_hash 插入,不重算链;
|
||||
库里已有且内容相同的 seq 跳过(幂等,可重跑);已有但
|
||||
内容不同的 seq 即 ValueError(分叉,fail-closed)。
|
||||
文件不存在回 0。"""
|
||||
if not os.path.exists(path):
|
||||
return 0
|
||||
trail = AuditTrail(path)
|
||||
ok, bad = trail.verify()
|
||||
if not ok:
|
||||
raise ValueError("JSONL 审计链校验失败,拒绝迁移:行 %d"
|
||||
% bad)
|
||||
rows = trail._read_all()
|
||||
from cloud.chv.app_state import AppStateStore, \
|
||||
host_state_db_path
|
||||
store = AppStateStore(host_state_db_path(state_dir))
|
||||
store.open()
|
||||
try:
|
||||
existing = {int(r["seq"]): r
|
||||
for r in store.list_audit_trail_rows()}
|
||||
inserted = 0
|
||||
for row in sorted(rows, key=lambda r: int(r.get("seq", 0))):
|
||||
seq = int(row["seq"])
|
||||
if seq in existing:
|
||||
if existing[seq] != row:
|
||||
raise ValueError(
|
||||
"审计 seq %d 在库里已有不同内容,拒绝迁移"
|
||||
% seq)
|
||||
continue
|
||||
store.insert_audit_trail_row(row)
|
||||
inserted += 1
|
||||
return inserted
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def _filter_rows(rows: list, category: str, action: str,
|
||||
task_id: str, since: float, until: float) -> list:
|
||||
"""list() 的过滤:分类/动作/任务/时间窗口,全空即不过滤。"""
|
||||
|
||||
Reference in new issue
Block a user