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,471 @@
|
||||
"""对齐 Muse P12 测试:审计轨迹与活动视图。
|
||||
|
||||
只用标准库,不启动真实 VM,不联网,不碰生产文件:
|
||||
审计轨迹、计划登记、权限登记一律落在临时目录。
|
||||
覆盖:清洗白名单、哈希链验签、项目隔离、旧行迁移、
|
||||
导出、计划流转、授权有效期/撤销/次数、审批卡风险与
|
||||
通道一致性——含否定测试。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.chv.activity import (
|
||||
PermissionRegistry,
|
||||
PlanStore,
|
||||
activity_summary,
|
||||
)
|
||||
from cloud.chv.approval_card import (
|
||||
build_approval_card,
|
||||
card_matches_channel,
|
||||
risk_level,
|
||||
)
|
||||
from cloud.chv.audit_trail import (
|
||||
AUDIT_CATEGORIES,
|
||||
AuditTrail,
|
||||
from_legacy_row,
|
||||
sanitize_detail,
|
||||
)
|
||||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.chv.activity import (
|
||||
PermissionRegistry,
|
||||
PlanStore,
|
||||
activity_summary,
|
||||
)
|
||||
from cloud.chv.approval_card import (
|
||||
build_approval_card,
|
||||
card_matches_channel,
|
||||
risk_level,
|
||||
)
|
||||
from cloud.chv.audit_trail import (
|
||||
AUDIT_CATEGORIES,
|
||||
AuditTrail,
|
||||
from_legacy_row,
|
||||
sanitize_detail,
|
||||
)
|
||||
|
||||
PROJECT = "proj-p12"
|
||||
OTHER = "proj-other"
|
||||
|
||||
|
||||
def _capability(cap_id="cap-1", tier="task", uses_left=3,
|
||||
expires_at=None, revoked=False):
|
||||
"""P5 ScopedCapability.describe() 形状的 dict。"""
|
||||
return {
|
||||
"capability_id": cap_id, "tier": tier,
|
||||
"connector": "gitea", "host": "gitea.tose.top",
|
||||
"use_case": "sync", "session_id": "s1", "task_id": "t1",
|
||||
"methods": ["GET", "POST"], "max_uses": 3,
|
||||
"uses_left": uses_left, "expires_at": expires_at,
|
||||
"revoked": revoked,
|
||||
}
|
||||
|
||||
|
||||
def _queue_record(**overrides):
|
||||
"""P3 ApprovalQueue.record() 形状的 dict。"""
|
||||
record = {
|
||||
"pending_id": "pend-abc123", "connector": "gitea",
|
||||
"method": "GET", "scheme": "https",
|
||||
"host": "gitea.tose.top", "port": 443, "path": "/api/v1/repos",
|
||||
"reasons": ["host_not_allowlisted"], "ts": 1000.0,
|
||||
}
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
class SanitizeTest(unittest.TestCase):
|
||||
def test_secret_keys_dropped(self):
|
||||
cleaned = sanitize_detail({
|
||||
"action": "sync",
|
||||
"gitea_token": "gto_abcdef",
|
||||
"api_key": "sk-123",
|
||||
"password": "hunter2",
|
||||
"nested": {"secret": "x", "repo": "r"},
|
||||
})
|
||||
self.assertEqual(cleaned,
|
||||
{"action": "sync", "nested": {"repo": "r"}})
|
||||
|
||||
def test_secret_value_in_string_redacted(self):
|
||||
cleaned = sanitize_detail(
|
||||
{"detail": "failed with token=abc123xyz and ok"})
|
||||
self.assertEqual(cleaned["detail"],
|
||||
"failed with token=*** and ok")
|
||||
|
||||
def test_benign_fields_kept(self):
|
||||
cleaned = sanitize_detail(
|
||||
{"repo": "admin/chv-sync-test", "count": 3,
|
||||
"tags": ["a", "b"]})
|
||||
self.assertEqual(cleaned, {"repo": "admin/chv-sync-test",
|
||||
"count": 3, "tags": ["a", "b"]})
|
||||
|
||||
def test_empty_and_none_detail(self):
|
||||
self.assertEqual(sanitize_detail({}), {})
|
||||
self.assertEqual(sanitize_detail(None), {})
|
||||
|
||||
|
||||
class AuditTrailTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.path = os.path.join(self.tmp.name, "audit.jsonl")
|
||||
self.trail = AuditTrail(self.path)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_append_and_list_roundtrip(self):
|
||||
rec = self.trail.append(
|
||||
PROJECT, "worker", "vm_lifecycle", "vm_boot",
|
||||
object_type="vm", object_id="chv-1", ts=1000.0,
|
||||
detail={"image": "lite10"})
|
||||
self.assertEqual(rec["seq"], 1)
|
||||
self.assertEqual(rec["prev_hash"], "0" * 64)
|
||||
self.assertEqual(len(rec["hash"]), 64)
|
||||
rows = self.trail.list_records(PROJECT)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["action"], "vm_boot")
|
||||
|
||||
def test_append_sanitizes_detail(self):
|
||||
self.trail.append(
|
||||
PROJECT, "worker", "approval", "ask",
|
||||
detail={"token": "gto_secret", "host": "example.invalid"},
|
||||
ts=1000.0)
|
||||
rows = self.trail.list_records(PROJECT)
|
||||
self.assertNotIn("token", rows[0]["detail"])
|
||||
self.assertEqual(rows[0]["detail"]["host"], "example.invalid")
|
||||
|
||||
def test_unknown_category_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.trail.append(PROJECT, "worker", "nope", "x")
|
||||
|
||||
def test_empty_project_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.trail.append("", "worker", "approval", "x")
|
||||
|
||||
def test_project_isolation(self):
|
||||
self.trail.append(PROJECT, "worker", "approval", "ask",
|
||||
ts=1000.0)
|
||||
self.trail.append(OTHER, "worker", "approval", "ask",
|
||||
ts=1001.0)
|
||||
self.assertEqual(len(self.trail.list_records(PROJECT)), 1)
|
||||
self.assertEqual(len(self.trail.list_records(OTHER)), 1)
|
||||
self.assertEqual(self.trail.list_records(""), [])
|
||||
self.assertEqual(self.trail.list_records("proj-missing"), [])
|
||||
|
||||
def test_list_filters_and_pagination(self):
|
||||
for index in range(5):
|
||||
self.trail.append(
|
||||
PROJECT, "worker", "vm_lifecycle", "vm_boot",
|
||||
task_id="t-%d" % (index % 2), ts=1000.0 + index)
|
||||
self.assertEqual(len(self.trail.list_records(PROJECT, task_id="t-0")),
|
||||
3)
|
||||
self.assertEqual(
|
||||
len(self.trail.list_records(PROJECT, since=1003.0)), 2)
|
||||
self.assertEqual(
|
||||
len(self.trail.list_records(PROJECT, limit=2)), 2)
|
||||
self.assertEqual(
|
||||
len(self.trail.list_records(PROJECT, limit=2, offset=2)), 2)
|
||||
rows = self.trail.list_records(PROJECT)
|
||||
self.assertGreater(rows[0]["ts"], rows[-1]["ts"])
|
||||
|
||||
def test_verify_passes_then_detects_tamper(self):
|
||||
for index in range(3):
|
||||
self.trail.append(PROJECT, "worker", "approval", "ask",
|
||||
ts=1000.0 + index)
|
||||
ok, bad = self.trail.verify()
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(bad, -1)
|
||||
with open(self.path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
tampered = json.loads(lines[1])
|
||||
tampered["action"] = "allow"
|
||||
lines[1] = json.dumps(tampered) + "\n"
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
ok, bad = self.trail.verify()
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(bad, 1)
|
||||
|
||||
def test_verify_detects_deletion(self):
|
||||
for index in range(3):
|
||||
self.trail.append(PROJECT, "worker", "approval", "ask",
|
||||
ts=1000.0 + index)
|
||||
with open(self.path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
f.writelines([lines[0], lines[2]])
|
||||
ok, bad = self.trail.verify()
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(bad, 1)
|
||||
|
||||
def test_reload_restores_chain(self):
|
||||
self.trail.append(PROJECT, "worker", "approval", "ask",
|
||||
ts=1000.0)
|
||||
second = AuditTrail(self.path)
|
||||
rec = second.append(PROJECT, "worker", "approval", "allow",
|
||||
ts=1001.0)
|
||||
self.assertEqual(rec["seq"], 2)
|
||||
ok, _ = second.verify()
|
||||
self.assertTrue(ok)
|
||||
|
||||
def test_export_json(self):
|
||||
self.trail.append(PROJECT, "worker", "approval", "ask",
|
||||
detail={"token": "nope"}, ts=1000.0)
|
||||
dest = os.path.join(self.tmp.name, "export.json")
|
||||
count = self.trail.export_json(PROJECT, dest)
|
||||
self.assertEqual(count, 1)
|
||||
with open(dest, "r", encoding="utf-8") as f:
|
||||
rows = json.load(f)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertNotIn("token", rows[0]["detail"])
|
||||
self.assertEqual(os.stat(dest).st_mode & 0o777, 0o600)
|
||||
self.assertEqual(self.trail.export_json(OTHER, dest), 0)
|
||||
|
||||
def test_all_categories_accepted(self):
|
||||
for category in sorted(AUDIT_CATEGORIES):
|
||||
self.trail.append(PROJECT, "worker", category, "probe",
|
||||
ts=1000.0)
|
||||
self.assertEqual(len(self.trail.list_records(PROJECT, limit=0)),
|
||||
len(AUDIT_CATEGORIES))
|
||||
|
||||
def test_legacy_row_conversion(self):
|
||||
row = {"id": 7, "user_id": "system", "project_id": PROJECT,
|
||||
"action": "worker_exec_start",
|
||||
"detail": {"token": "gto_x", "task": "t1"},
|
||||
"created_at": 1000.0}
|
||||
converted = from_legacy_row(row)
|
||||
self.assertTrue(converted["legacy"])
|
||||
self.assertEqual(converted["action"], "worker_exec_start")
|
||||
self.assertEqual(converted["object_id"], "7")
|
||||
self.assertNotIn("token", converted["detail"])
|
||||
self.assertEqual(converted["detail"]["task"], "t1")
|
||||
|
||||
|
||||
class PlanStoreTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.path = os.path.join(self.tmp.name, "plans.json")
|
||||
self.store = PlanStore(self.path)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_add_and_list(self):
|
||||
plan = self.store.add_plan(PROJECT, "建 round-11 镜像",
|
||||
task_id="t1", ts=1000.0)
|
||||
self.assertEqual(plan["status"], "pending")
|
||||
self.assertEqual(len(self.store.list_plans(PROJECT)), 1)
|
||||
|
||||
def test_valid_transitions(self):
|
||||
plan = self.store.add_plan(PROJECT, "同步仓库", ts=1000.0)
|
||||
moved = self.store.set_status(
|
||||
PROJECT, plan["plan_id"], "running", ts=1001.0)
|
||||
self.assertEqual(moved["status"], "running")
|
||||
done = self.store.set_status(
|
||||
PROJECT, plan["plan_id"], "done", ts=1002.0)
|
||||
self.assertEqual(done["status"], "done")
|
||||
|
||||
def test_illegal_transition_rejected(self):
|
||||
plan = self.store.add_plan(PROJECT, "跑测试", ts=1000.0)
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.set_status(PROJECT, plan["plan_id"], "done")
|
||||
self.store.set_status(PROJECT, plan["plan_id"], "cancelled")
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.set_status(PROJECT, plan["plan_id"], "running")
|
||||
|
||||
def test_unknown_plan_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.set_status(PROJECT, "nope", "running")
|
||||
|
||||
def test_empty_title_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.add_plan(PROJECT, "")
|
||||
|
||||
def test_list_filter_by_status(self):
|
||||
first = self.store.add_plan(PROJECT, "甲", ts=1000.0)
|
||||
self.store.add_plan(PROJECT, "乙", ts=1001.0)
|
||||
self.store.set_status(PROJECT, first["plan_id"], "running")
|
||||
self.assertEqual(
|
||||
len(self.store.list_plans(PROJECT, "running")), 1)
|
||||
self.assertEqual(
|
||||
len(self.store.list_plans(PROJECT, "pending")), 1)
|
||||
|
||||
def test_persistence_roundtrip(self):
|
||||
self.store.add_plan(PROJECT, "甲", ts=1000.0)
|
||||
second = PlanStore(self.path)
|
||||
self.assertEqual(len(second.list_plans(PROJECT)), 1)
|
||||
|
||||
|
||||
class PermissionRegistryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.path = os.path.join(self.tmp.name, "grants.json")
|
||||
self.registry = PermissionRegistry(self.path)
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_grant_and_list_active(self):
|
||||
self.registry.record_grant(PROJECT, _capability(), ts=1000.0)
|
||||
active = self.registry.list_active(PROJECT, now=1001.0)
|
||||
self.assertEqual(len(active), 1)
|
||||
self.assertEqual(active[0]["connector"], "gitea")
|
||||
self.assertEqual(active[0]["uses_left"], 3)
|
||||
|
||||
def test_missing_field_rejected(self):
|
||||
bad = _capability()
|
||||
del bad["host"]
|
||||
with self.assertRaises(ValueError):
|
||||
self.registry.record_grant(PROJECT, bad)
|
||||
|
||||
def test_expired_grant_not_active(self):
|
||||
self.registry.record_grant(
|
||||
PROJECT, _capability(expires_at=1000.0), ts=900.0)
|
||||
self.assertEqual(self.registry.list_active(PROJECT, now=1001.0),
|
||||
[])
|
||||
|
||||
def test_revoked_grant_not_active(self):
|
||||
self.registry.record_grant(PROJECT, _capability(), ts=1000.0)
|
||||
self.assertTrue(self.registry.revoke(PROJECT, "cap-1"))
|
||||
self.assertFalse(self.registry.revoke(PROJECT, "cap-1"))
|
||||
self.assertEqual(self.registry.list_active(PROJECT, now=1001.0),
|
||||
[])
|
||||
self.assertEqual(len(self.registry.list_all(PROJECT)), 1)
|
||||
|
||||
def test_uses_exhausted_not_active(self):
|
||||
self.registry.record_grant(PROJECT, _capability(uses_left=1),
|
||||
ts=1000.0)
|
||||
self.assertTrue(self.registry.note_use(PROJECT, "cap-1"))
|
||||
self.assertFalse(self.registry.note_use(PROJECT, "cap-1"))
|
||||
self.assertEqual(self.registry.list_active(PROJECT, now=1001.0),
|
||||
[])
|
||||
|
||||
def test_unlimited_uses_never_exhaust(self):
|
||||
self.registry.record_grant(
|
||||
PROJECT, _capability(uses_left=None), ts=1000.0)
|
||||
for _ in range(5):
|
||||
self.assertTrue(self.registry.note_use(PROJECT, "cap-1"))
|
||||
self.assertEqual(len(self.registry.list_active(PROJECT)), 1)
|
||||
|
||||
def test_unknown_capability_use_revoke_false(self):
|
||||
self.assertFalse(self.registry.note_use(PROJECT, "nope"))
|
||||
self.assertFalse(self.registry.revoke(PROJECT, "nope"))
|
||||
|
||||
def test_regrant_updates(self):
|
||||
self.registry.record_grant(PROJECT, _capability(), ts=1000.0)
|
||||
self.registry.record_grant(
|
||||
PROJECT, _capability(uses_left=9), ts=1001.0)
|
||||
active = self.registry.list_active(PROJECT, now=1002.0)
|
||||
self.assertEqual(len(active), 1)
|
||||
self.assertEqual(active[0]["uses_left"], 9)
|
||||
|
||||
|
||||
class ActivitySummaryTest(unittest.TestCase):
|
||||
def test_summary_shape_and_counts(self):
|
||||
plans = [
|
||||
{"title": "跑测试", "status": "running"},
|
||||
{"title": "建镜像", "status": "pending"},
|
||||
{"title": "同步", "status": "done"},
|
||||
]
|
||||
recent = [{"ts": 1000.0, "action": "vm_boot",
|
||||
"category": "vm_lifecycle", "result": "ok",
|
||||
"object_type": "vm", "object_id": "chv-1",
|
||||
"detail": {"token": "must-not-leak"}}]
|
||||
grants = [_capability()]
|
||||
summary = activity_summary(plans, recent, grants)
|
||||
self.assertEqual(summary["current"], "跑测试")
|
||||
self.assertEqual(summary["counts"],
|
||||
{"running": 1, "planned": 1, "done": 1,
|
||||
"granted": 1})
|
||||
self.assertEqual(len(summary["recent"]), 1)
|
||||
self.assertNotIn("detail", summary["recent"][0])
|
||||
|
||||
def test_no_running_current_empty(self):
|
||||
summary = activity_summary([], [], [])
|
||||
self.assertEqual(summary["current"], "")
|
||||
self.assertEqual(summary["counts"]["granted"], 0)
|
||||
|
||||
|
||||
class ApprovalCardTest(unittest.TestCase):
|
||||
def test_read_get_low_risk(self):
|
||||
card = build_approval_card(_queue_record())
|
||||
self.assertEqual(card["risk"], "low")
|
||||
self.assertEqual(card["action"], "gitea GET")
|
||||
self.assertEqual(card["expires_at"], 1900.0)
|
||||
self.assertEqual([a["id"] for a in card["actions"]],
|
||||
["accept", "deny"])
|
||||
self.assertEqual(
|
||||
[o["tier"] for o in card["grant_options"]],
|
||||
["one-shot", "session", "task", "timed", "permanent"])
|
||||
|
||||
def test_permanent_always_opt_in(self):
|
||||
card = build_approval_card(_queue_record())
|
||||
permanent = [o for o in card["grant_options"]
|
||||
if o["tier"] == "permanent"][0]
|
||||
self.assertTrue(permanent["requires_explicit_opt_in"])
|
||||
others = [o for o in card["grant_options"]
|
||||
if o["tier"] != "permanent"]
|
||||
self.assertTrue(all(
|
||||
not o["requires_explicit_opt_in"] for o in others))
|
||||
|
||||
def test_write_to_admin_high_risk(self):
|
||||
card = build_approval_card(_queue_record(
|
||||
method="POST", path="/admin/settings"))
|
||||
self.assertEqual(card["risk"], "high")
|
||||
|
||||
def test_write_to_private_host_high_risk(self):
|
||||
card = build_approval_card(_queue_record(
|
||||
method="DELETE", host="192.168.1.5", path="/api/v1/x"))
|
||||
self.assertEqual(card["risk"], "high")
|
||||
|
||||
def test_write_public_medium_risk(self):
|
||||
card = build_approval_card(_queue_record(
|
||||
method="POST", path="/api/v1/repos"))
|
||||
self.assertEqual(card["risk"], "medium")
|
||||
|
||||
def test_missing_pending_id_rejected(self):
|
||||
record = _queue_record()
|
||||
del record["pending_id"]
|
||||
with self.assertRaises(ValueError):
|
||||
build_approval_card(record)
|
||||
|
||||
def test_tiers_filtered_to_sentinel_choice(self):
|
||||
card = build_approval_card(_queue_record(),
|
||||
tiers=["one-shot", "task", "bogus"])
|
||||
self.assertEqual([o["tier"] for o in card["grant_options"]],
|
||||
["one-shot", "task"])
|
||||
|
||||
def test_risk_level_function(self):
|
||||
self.assertEqual(risk_level(_queue_record()), "low")
|
||||
self.assertEqual(risk_level(_queue_record(
|
||||
method="PUT", path="/api/v1/x")), "medium")
|
||||
|
||||
def test_card_matches_channel(self):
|
||||
card = build_approval_card(_queue_record())
|
||||
payload = {
|
||||
"pending_id": "pend-abc123",
|
||||
"nonce": "deadbeef",
|
||||
"target": {"connector": "gitea", "method": "GET",
|
||||
"scheme": "https", "host": "gitea.tose.top",
|
||||
"port": 443, "path": "/api/v1/repos"},
|
||||
"reasons": ["host_not_allowlisted"],
|
||||
"expires_at": 1900.0,
|
||||
}
|
||||
self.assertTrue(card_matches_channel(card, payload))
|
||||
|
||||
def test_card_mismatch_channel(self):
|
||||
card = build_approval_card(_queue_record())
|
||||
payload = {"pending_id": "pend-other", "target": {}}
|
||||
self.assertFalse(card_matches_channel(card, payload))
|
||||
payload = {"pending_id": "pend-abc123",
|
||||
"target": {"host": "evil.example.invalid"}}
|
||||
self.assertFalse(card_matches_channel(card, payload))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user