package app // W9 X2 出口矩阵:可选 ProjectAuditHost 的读取面按【受信项目、actor、resource、 // run、source offset 与截止】读取,并结合 RDM audit.read 回调与每对象正文权做 // 服务端裁剪。本文件覆盖批准需求第(4)条列出的九个出口场景,以及第(3)条 // “缺基座能力返回 unsupported 覆盖范围”的语义。 // // RDM 消费端(audit.read / doc.read 回调的领域实现)不在本仓:这里只验证宿主 // 面必须向注册方询问、按对象裁剪、缺能力即 unsupported,不在宿主侧复制策略。 import ( "context" "encoding/json" "errors" "path/filepath" "strings" "testing" "time" agentcommand "agent-command" "agent-system-backend/internal/store" "agent-system-backend/pkg/extension" core "agent-system-core" ) // auditMatrix 是出口矩阵的共用基座:alice 拥有 A/B 两个项目并在其中各有一份 // 会话,bob 是 A 的第二成员(会话建在 A 下),carol 只存在于账号目录、不参与 // 任何审计行。alice 的 op-a 是一条已发布链接(绑定 document/doc-1 与一次 run)。 type auditMatrix struct { t *testing.T st *store.Store caps hostcaps projectA core.ID projectB core.ID foreign core.ID aliceConv core.ID bobConv core.ID runID string link extension.ProjectAuditLinkReceipt // 策略旋钮:每个用例按出口场景改写,回调只读它们。 auditAll bool auditRead map[core.ID]bool metadata map[string]bool body map[string]bool auditCalls []string discloseCall int } func newAuditMatrix(t *testing.T) *auditMatrix { t.Helper() st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) m := &auditMatrix{t: t, st: st, auditRead: map[core.ID]bool{}, metadata: map[string]bool{}, body: map[string]bool{}} for _, account := range []struct{ owner, name string }{ {"alice", "Alice at commit"}, {"bob", "Bob"}, {"carol", "Carol directory only"}, } { if err := st.EnsureUser(account.owner, account.name); err != nil { t.Fatal(err) } } projectA, err := st.CreateProject("alice", "A") if err != nil { t.Fatal(err) } projectB, err := st.CreateProject("alice", "B") if err != nil { t.Fatal(err) } foreign, err := st.CreateProject("carol", "C") if err != nil { t.Fatal(err) } m.projectA, m.projectB, m.foreign = projectA.ID, projectB.ID, foreign.ID aliceConv, err := st.CreateConversation("alice", string(projectA.ID), "A work", "") if err != nil { t.Fatal(err) } otherConv, err := st.CreateConversation("alice", string(projectB.ID), "B work", "") if err != nil { t.Fatal(err) } bobConv, err := st.CreateConversationInProject("bob", string(projectA.ID), "A shared", "") if err != nil { t.Fatal(err) } m.aliceConv, m.bobConv = aliceConv.ID, bobConv.ID run := core.Run{ConversationID: aliceConv.ID, ProjectID: projectA.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking} if err := st.CreateRun(&run); err != nil { t.Fatal(err) } m.runID = string(run.ID) m.append("alice", string(aliceConv.ID), "alice.submit", "op-a", m.runID) m.append("alice", string(otherConv.ID), "alice.other", "op-b", "") m.append("bob", string(bobConv.ID), "bob.submit", "op-bob", "") m.caps = hostcaps{st: st, projectAudit: &projectAuditSlot{}} reg := pluginRegistry{pluginID: "rdm", caps: m.caps} if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) { m.auditCalls = append(m.auditCalls, actor+"@"+string(projectID)) return m.auditAll || m.auditRead[projectID], nil }); err != nil { t.Fatal(err) } factory, err := (recordRegistry{Registry: reg}).RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) { return proof.Owner == "alice" && proof.ProjectID == m.projectA && proof.SourceKind == "domain-commit" && proof.SourceID == "commit-a" && proof.OperationID == "op-a" && proof.ResourceKind == "document" && proof.ResourceID == "doc-1" && proof.RunID == m.runID, nil }) if err != nil { t.Fatal(err) } if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, resource extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) { m.discloseCall++ return extension.ProjectAuditDisclosure{Metadata: actor == "bob" && m.metadata[resource.ResourceID], Body: actor == "bob" && m.body[resource.ResourceID]}, nil }); err != nil { t.Fatal(err) } publisher, err := factory.ForOwner("alice") if err != nil { t.Fatal(err) } receipt, err := publisher.PublishProjectAuditLink(context.Background(), extension.ProjectAuditLinkRequest{ ProjectID: m.projectA, SourceKind: "domain-commit", SourceID: "commit-a", OperationID: "op-a", ResourceKind: "document", ResourceID: "doc-1", RunID: m.runID, DisclosureClass: "metadata", IdempotencyKey: "outbox-a", }) if err != nil { t.Fatal(err) } m.link = receipt return m } func (m *auditMatrix) append(owner, conversationID, commandID, operationID, runID string) { m.t.Helper() if err := m.st.AppendAudit(owner, agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: owner, CommandID: commandID, ConversationID: conversationID, State: agentcommand.StateCommitted, OperationID: operationID, RunID: runID, ParamsDigest: "sensitive-digest"}); err != nil { m.t.Fatal(err) } } func (m *auditMatrix) host(owner string) inprocHost { return inprocHost{caps: m.caps, owner: owner, actor: owner, pluginID: "rdm"} } func (m *auditMatrix) linkedQuery() extension.ProjectAuditQuery { return extension.ProjectAuditQuery{ProjectID: m.projectA, LinkedSourceKind: "domain-commit", LinkedSourceID: "commit-a", LinkedHostOffset: m.link.HostAuditOffset} } func (m *auditMatrix) openBody() { m.metadata["doc-1"], m.body["doc-1"] = true, true } // 同 owner 跨项目/两成员:受信项目归属与成员身份都必须在服务端判定。 func TestProjectAuditExitMatrixSameOwnerCrossProjectAndTwoMembers(t *testing.T) { m := newAuditMatrix(t) m.auditRead[m.projectA] = true m.auditRead[m.projectB] = true m.metadata["doc-1"] = true bobPage, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}) if err != nil { t.Fatal(err) } if len(bobPage.Receipts) != 1 || bobPage.Receipts[0].CommandID != "bob.submit" || bobPage.Receipts[0].ActorName != "Bob" { t.Fatalf("second member sees another member's row: %+v", bobPage.Receipts) } alicePage, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}) if err != nil { t.Fatal(err) } if len(alicePage.Receipts) != 1 || alicePage.Receipts[0].CommandID != "alice.submit" || alicePage.Receipts[0].ActorName != "Alice at commit" { t.Fatalf("owner page left its project scope: %+v", alicePage.Receipts) } otherProject, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectB}) if err != nil { t.Fatal(err) } if len(otherProject.Receipts) != 1 || otherProject.Receipts[0].CommandID != "alice.other" { t.Fatalf("project B page: %+v", otherProject.Receipts) } m.auditRead[m.projectB] = false if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectB}); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("non-member read another project: %v", err) } if got := m.auditCalls[len(m.auditCalls)-1]; got != "bob@"+string(m.projectB) { t.Fatalf("host did not ask the domain about the caller's project: %s", got) } if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{}); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("empty project id: %v", err) } } // 恶意 projectId:调用方自报的项目标识只是定位符,既不能换出别的项目,也不能 // 让“看起来通过”的未知项目变成受信项目流。 func TestProjectAuditExitMatrixMaliciousProjectID(t *testing.T) { m := newAuditMatrix(t) // 领域回调对所有项目一律放行(配置失误):宿主仍只能按受信存储归集。 m.auditAll = true for _, projectID := range []core.ID{m.foreign, "no-such-project"} { page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: projectID}) if err != nil { t.Fatalf("project %q: %v", projectID, err) } if len(page.Receipts) != 0 { t.Fatalf("project %q returned rows: %+v", projectID, page.Receipts) } if page.Coverage.LegacyProjectBinding != "not-recorded" || page.Coverage.Export != "unsupported" { t.Fatalf("project %q coverage: %+v", projectID, page.Coverage) } } // 领域误放行也不能换出受信任存储之外的对象:每个主体只看得到自己的行。 for _, c := range []struct { owner string projectID core.ID want string }{ {"alice", m.projectA, "alice.submit"}, {"bob", m.projectA, "bob.submit"}, {"alice", m.projectB, "alice.other"}, {"bob", m.projectB, ""}, } { page, err := m.host(c.owner).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: c.projectID}) if err != nil { t.Fatalf("%s@%s: %v", c.owner, c.projectID, err) } got := []string{} for _, receipt := range page.Receipts { got = append(got, receipt.CommandID) } if strings.Join(got, ",") != c.want { t.Fatalf("%s@%s rows = %v, want [%s]", c.owner, c.projectID, got, c.want) } } // 他人项目下的链接按 projectId 定位失败:不得跨项目取回。 crossProject := m.linkedQuery() crossProject.ProjectID = m.foreign if _, err := m.host("bob").QueryProjectAudit(context.Background(), crossProject); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("cross-project link locator: %v", err) } // 调用方自报的 resource/run 定位符必须与受信绑定一致,否则拒绝。 // 先打开逐对象元数据权,使拒绝只能来自定位符校验本身。 m.metadata["doc-1"] = true for _, mutate := range []func(*extension.ProjectAuditQuery){ func(q *extension.ProjectAuditQuery) { q.LinkedResourceID = "doc-2" }, func(q *extension.ProjectAuditQuery) { q.LinkedResourceKind = "run" }, func(q *extension.ProjectAuditQuery) { q.LinkedRunID = "run-other" }, } { q := m.linkedQuery() mutate(&q) if _, err := m.host("bob").QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("forged locator was accepted: %+v err=%v", q, err) } } q := m.linkedQuery() q.LinkedResourceKind, q.LinkedResourceID, q.LinkedRunID = "document", "doc-1", m.runID if _, err := m.host("bob").QueryProjectAudit(context.Background(), q); err != nil { t.Fatalf("matching locators were refused: %v", err) } } // audit.read 无正文:元数据权成立、正文权不成立时只出元数据;请求正文则拒绝, // 不得返回一份“被悄悄裁剪”的页冒充有正文覆盖。 func TestProjectAuditExitMatrixAuditReadWithoutBody(t *testing.T) { m := newAuditMatrix(t) m.auditRead[m.projectA] = true m.metadata["doc-1"] = true page, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()) if err != nil { t.Fatal(err) } if len(page.Receipts) != 1 || page.Receipts[0].Resource != nil || page.Receipts[0].ActorName != "Alice at commit" { t.Fatalf("metadata-only page: %+v", page.Receipts) } if page.Coverage.ResourceBodies != "unsupported" || page.Coverage.CommandAudit != "verified-exact-link-only" { t.Fatalf("coverage overstated body coverage: %+v", page.Coverage) } body := m.linkedQuery() body.IncludeBody = true if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("body requested without the body right: %v", err) } // 无受信对象绑定的 actor 自有页没有每对象正文权可言:显式 unsupported。 owned := extension.ProjectAuditQuery{ProjectID: m.projectA, IncludeBody: true} if _, err := m.host("bob").QueryProjectAudit(context.Background(), owned); !errors.Is(err, extension.ErrProjectAuditUnsupported) { t.Fatalf("actor-owned body read: %v", err) } // 正文权成立时按对象裁剪下发,且不下发正文载荷。 m.openBody() // 有权但未请求:不主动多给对象身份。 undeclared, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()) if err != nil { t.Fatal(err) } if len(undeclared.Receipts) != 1 || undeclared.Receipts[0].Resource != nil || undeclared.Coverage.ResourceBodies != "unsupported" { t.Fatalf("body right was exercised without a body request: %+v coverage=%+v", undeclared.Receipts, undeclared.Coverage) } bodyPage, err := m.host("bob").QueryProjectAudit(context.Background(), body) if err != nil { t.Fatal(err) } if len(bodyPage.Receipts) != 1 || bodyPage.Receipts[0].Resource == nil || bodyPage.Receipts[0].Resource.Kind != "document" || bodyPage.Receipts[0].Resource.ID != "doc-1" || bodyPage.Receipts[0].Resource.RunID != m.runID || bodyPage.Coverage.ResourceBodies != "verified-exact-link-body" || bodyPage.Coverage.RunRelations != "verified-exact-link-run" { t.Fatalf("body-permitted page: %+v coverage=%+v", bodyPage.Receipts, bodyPage.Coverage) } raw, err := json.Marshal(bodyPage) if err != nil { t.Fatal(err) } if strings.Contains(string(raw), "sensitive-digest") || strings.Contains(string(raw), "paramsDigest") || strings.Contains(string(raw), string(m.aliceConv)) { t.Fatalf("body-permitted page leaked a payload or private locator: %s", raw) } } // 正文无 audit.read:对象权先成立也必须先被项目 audit.read 挡住,且不得因为 // 逐对象判定而向无 audit.read 的主体泄露“这个对象存在”。 func TestProjectAuditExitMatrixBodyWithoutAuditRead(t *testing.T) { m := newAuditMatrix(t) m.auditRead[m.projectA] = true m.openBody() body := m.linkedQuery() body.IncludeBody = true if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); err != nil { t.Fatalf("baseline body read: %v", err) } m.auditRead[m.projectA] = false before := m.discloseCall if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("body right survived revoked audit.read: %v", err) } if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("body read without audit.read: %v", err) } if m.discloseCall != before { t.Fatalf("per-object decision was consulted before audit.read: %d -> %d", before, m.discloseCall) } } // 对象已撤权/删改:撤权走逐对象判定;删除与改动让存储侧无法再把该链接解析成 // 受信任回执;已删除的 run 关系也不再服务回执。 func TestProjectAuditExitMatrixObjectRevokedDeletedOrEdited(t *testing.T) { m := newAuditMatrix(t) m.auditRead[m.projectA] = true m.openBody() if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil { t.Fatalf("baseline link: %v", err) } m.metadata["doc-1"], m.body["doc-1"] = false, false if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("revoked object: %v", err) } m.openBody() // run 关系被改(run 移出本项目):链接仍在,但受约束的 run 不再匹配。 if _, err := m.st.DB().Exec(`UPDATE runs SET project_id=? WHERE id=?`, string(m.projectB), m.runID); err != nil { t.Fatal(err) } if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("moved run kept serving its receipt: %v", err) } if _, err := m.st.DB().Exec(`UPDATE runs SET project_id=? WHERE id=?`, string(m.projectA), m.runID); err != nil { t.Fatal(err) } if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil { t.Fatalf("restored run: %v", err) } // 宿主回执被改动(载荷里的 operationId 漂移):不再是领域验证过的那一条。 var stored string if err := m.st.DB().QueryRow(`SELECT payload FROM command_audit WHERE id=?`, m.link.HostAuditOffset).Scan(&stored); err != nil { t.Fatal(err) } forged, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: "alice", CommandID: "alice.submit", ConversationID: string(m.aliceConv), State: agentcommand.StateCommitted, OperationID: "op-forged"}) if err != nil { t.Fatal(err) } if _, err := m.st.DB().Exec(`UPDATE command_audit SET payload=? WHERE id=?`, string(forged), m.link.HostAuditOffset); err != nil { t.Fatal(err) } if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("edited receipt still resolved: %v", err) } if _, err := m.st.DB().Exec(`UPDATE command_audit SET payload=? WHERE id=?`, stored, m.link.HostAuditOffset); err != nil { t.Fatal(err) } if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil { t.Fatalf("restored receipt: %v", err) } // 链接被删:关联不再存在,回执不再属于本项目审计。 if _, err := m.st.DB().Exec(`DELETE FROM project_audit_links WHERE id=?`, m.link.LinkID); err != nil { t.Fatal(err) } if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("deleted link still resolved: %v", err) } // 宿主回执的项目归属被改:外键仍在,但它已不是这一条被验证的绑定。 if _, err := m.st.DB().Exec(`INSERT INTO project_audit_links(id,host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, m.link.LinkID, m.link.HostAuditOffset, string(m.projectA), "alice", "domain-commit", "commit-a", "op-a", "document", "doc-1", m.runID, "metadata", "outbox-a", time.Now().UTC().Format(time.RFC3339Nano)); err != nil { t.Fatal(err) } if _, err := m.st.DB().Exec(`UPDATE command_audit SET project_id=? WHERE id=?`, string(m.projectB), m.link.HostAuditOffset); err != nil { t.Fatal(err) } if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) { t.Fatalf("re-attributed receipt still resolved: %v", err) } } // 历史 cutoff:第一页固定的上限在其后写入的行不得进入本次分页视图。 func TestProjectAuditExitMatrixHistoricalCutoff(t *testing.T) { m := newAuditMatrix(t) m.auditRead[m.projectA] = true for i := 0; i < 4; i++ { m.append("bob", string(m.bobConv), "bob.bulk-"+string(rune('a'+i)), "op-bulk", "") } first, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 2}) if err != nil { t.Fatal(err) } if len(first.Receipts) != 2 || first.CutoffOffset == 0 || first.NextOffset == 0 { t.Fatalf("first page: %+v", first) } m.append("bob", string(m.bobConv), "bob.after-cutoff", "op-after", "") seen := map[string]bool{} for _, receipt := range first.Receipts { seen[receipt.CommandID] = true } before, cutoff := first.NextOffset, first.CutoffOffset for page := first; page.NextOffset != 0; { if before >= cutoff { t.Fatalf("cursor did not move: before=%d cutoff=%d", before, cutoff) } page, err = m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, BeforeOffset: before, CutoffOffset: cutoff, Limit: 2}) if err != nil { t.Fatal(err) } if page.CutoffOffset != cutoff { t.Fatalf("cutoff moved mid-read: %d -> %d", cutoff, page.CutoffOffset) } before = page.NextOffset for _, receipt := range page.Receipts { if seen[receipt.CommandID] { t.Fatalf("duplicate receipt across pages: %s", receipt.CommandID) } seen[receipt.CommandID] = true } } if len(seen) != 5 || seen["bob.after-cutoff"] { t.Fatalf("historical cutoff read drifted: %v", seen) } // 不带 cutoff 的新读取才能看到其后写入的行。 fresh, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 10}) if err != nil { t.Fatal(err) } if len(fresh.Receipts) != 6 || fresh.CutoffOffset <= cutoff { t.Fatalf("fresh read did not advance: n=%d cutoff=%d", len(fresh.Receipts), fresh.CutoffOffset) } } // 导出:审计授权不是导出授权。本切片不提供整段导出,只能分页,且覆盖显式声明。 func TestProjectAuditExitMatrixExportIsRefusedNotDegraded(t *testing.T) { m := newAuditMatrix(t) m.auditRead[m.projectA] = true m.openBody() for _, q := range []extension.ProjectAuditQuery{ {ProjectID: m.projectA, Export: true}, {ProjectID: m.projectA, Export: true, Limit: 100000}, } { page, err := m.host("bob").QueryProjectAudit(context.Background(), q) if !errors.Is(err, extension.ErrProjectAuditUnsupported) || len(page.Receipts) != 0 { t.Fatalf("export was not refused: page=%+v err=%v", page, err) } } exportLink := m.linkedQuery() exportLink.Export = true if _, err := m.host("bob").QueryProjectAudit(context.Background(), exportLink); !errors.Is(err, extension.ErrProjectAuditUnsupported) { t.Fatalf("export through the linked path: %v", err) } linked, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()) if err != nil { t.Fatal(err) } if linked.Coverage.Export != "unsupported" { t.Fatalf("linked page export coverage: %+v", linked.Coverage) } for i := 0; i < 205; i++ { m.append("bob", string(m.bobConv), "bob.flood", "op-flood", "") } huge, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 100000}) if err != nil { t.Fatal(err) } if len(huge.Receipts) > 200 || huge.NextOffset == 0 || huge.Coverage.Export != "unsupported" { t.Fatalf("unbounded read was not capped and reported: n=%d next=%d coverage=%+v", len(huge.Receipts), huge.NextOffset, huge.Coverage) } } // 长分页:多次翻页必须完整、不重不漏,且游标单调收敛。 func TestProjectAuditExitMatrixLongPagination(t *testing.T) { m := newAuditMatrix(t) m.auditRead[m.projectA] = true const total = 25 for i := 0; i < total; i++ { m.append("bob", string(m.bobConv), "bob.page", "op-page", "") } seen := map[int64]bool{} before, cutoff := int64(0), int64(0) for pages := 0; ; pages++ { if pages > 10 { t.Fatalf("pagination did not terminate: %d rows", len(seen)) } page, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, BeforeOffset: before, CutoffOffset: cutoff, Limit: 7}) if err != nil { t.Fatal(err) } switch { case cutoff == 0: cutoff = page.CutoffOffset if cutoff == 0 { t.Fatal("first page must establish a cutoff") } case page.CutoffOffset != cutoff: t.Fatalf("cutoff drifted on page %d: %d != %d", pages, page.CutoffOffset, cutoff) } if len(page.Receipts) > 7 { t.Fatalf("page %d exceeded the requested limit: %d", pages, len(page.Receipts)) } for i, receipt := range page.Receipts { if seen[receipt.SourceOffset] { t.Fatalf("offset %d repeated on page %d", receipt.SourceOffset, pages) } if i > 0 && receipt.SourceOffset >= page.Receipts[i-1].SourceOffset { t.Fatalf("page %d is not strictly descending: %+v", pages, page.Receipts) } seen[receipt.SourceOffset] = true } if page.NextOffset == 0 { break } if before != 0 && page.NextOffset >= before { t.Fatalf("cursor did not advance: %d -> %d", before, page.NextOffset) } before = page.NextOffset } if len(seen) != total+1 { t.Fatalf("long pagination lost rows: got %d want %d", len(seen), total+1) } } // 旧记录不补造字段:显示名是动作当时记录的最小快照,旧行保持 not-recorded; // 账号目录也不会随审计页下发。 func TestProjectAuditExitMatrixLegacyRowsAreNotBackfilled(t *testing.T) { m := newAuditMatrix(t) m.auditRead[m.projectA] = true legacy, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: "alice", CommandID: "alice.legacy", ConversationID: string(m.aliceConv), State: agentcommand.StateCommitted}) if err != nil { t.Fatal(err) } if _, err := m.st.DB().Exec(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id) VALUES (?,?,'legacy','','committed','',?,?,?,'',?)`, time.Now().UTC().Format(time.RFC3339Nano), "alice.legacy", string(m.aliceConv), "alice", string(legacy), string(m.projectA)); err != nil { t.Fatal(err) } page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}) if err != nil { t.Fatal(err) } var legacyReceipt *extension.HostAuditReceipt for i := range page.Receipts { if page.Receipts[i].CommandID == "alice.legacy" { legacyReceipt = &page.Receipts[i] } } if legacyReceipt == nil || legacyReceipt.ActorName != "" || legacyReceipt.ActorNameStatus != "not-recorded" { t.Fatalf("legacy row fabricated a display name: %+v", page.Receipts) } if _, err := m.st.DB().Exec(`UPDATE users SET name=? WHERE owner=?`, "Alice renamed", "alice"); err != nil { t.Fatal(err) } after, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}) if err != nil { t.Fatal(err) } seen := 0 for _, receipt := range after.Receipts { seen++ switch receipt.CommandID { case "alice.legacy": if receipt.ActorName != "" || receipt.ActorNameStatus != "not-recorded" { t.Fatalf("rename backfilled an old row: %+v", receipt) } case "alice.submit": if receipt.ActorName != "Alice at commit" || receipt.ActorNameStatus != "recorded" { t.Fatalf("recorded name was not preserved: %+v", receipt) } } } if seen != 2 { t.Fatalf("unexpected row count after rename: %d", seen) } m.append("alice", string(m.aliceConv), "alice.after-rename", "op-rename", "") fresh, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 1}) if err != nil { t.Fatal(err) } if len(fresh.Receipts) != 1 || fresh.Receipts[0].CommandID != "alice.after-rename" || fresh.Receipts[0].ActorName != "Alice renamed" { t.Fatalf("new row did not record the current name: %+v", fresh.Receipts) } raw, err := json.Marshal(page) if err != nil { t.Fatal(err) } if strings.Contains(string(raw), "Carol directory only") || strings.Contains(string(raw), "Bob") { t.Fatalf("account directory leaked through the audit page: %s", raw) } } // owner 全量审计不是项目流:租户级审计与项目审计互不越界;缺基座能力一律 // unsupported(而非静默空页或降级读取)。 func TestProjectAuditOwnerWideAuditIsNotAProjectFlowAndUnsupportedIsExplicit(t *testing.T) { m := newAuditMatrix(t) m.auditRead[m.projectA] = true m.metadata["doc-1"] = true // 租户级审计(含无项目归属的旧行)不得成为项目流的取数来源。 if _, err := m.st.DB().Exec(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload) VALUES (?,'unbound','','','committed','','','alice','{}')`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { t.Fatal(err) } ownerWide, err := m.st.ListAudits("alice", "", 100) if err != nil { t.Fatal(err) } page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 100}) if err != nil { t.Fatal(err) } if len(ownerWide) <= len(page.Receipts) { t.Fatalf("owner-wide audit unexpectedly matched the project page: %d vs %d", len(ownerWide), len(page.Receipts)) } for _, receipt := range page.Receipts { if receipt.CommandID == "unbound" || receipt.CommandID == "bob.submit" { t.Fatalf("owner-wide or cross-member row reached the project page: %+v", receipt) } } if page.Coverage.CrossMember != "unsupported" || page.Coverage.LegacyProjectBinding != "not-recorded" { t.Fatalf("coverage must state the project-flow boundary: %+v", page.Coverage) } // 缺基座能力:没有槽位、没有注册方、不是注册插件的宿主句柄。 if _, err := (inprocHost{caps: hostcaps{st: m.st}, owner: "bob", actor: "bob", pluginID: "rdm"}). QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) { t.Fatalf("missing audit slot: %v", err) } if _, err := (inprocHost{caps: hostcaps{st: m.st, projectAudit: &projectAuditSlot{}}, owner: "bob", actor: "bob", pluginID: "rdm"}). QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) { t.Fatalf("unregistered authorizer: %v", err) } if _, err := (inprocHost{caps: m.caps, owner: "bob", actor: "bob", pluginID: "other"}). QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) { t.Fatalf("another plugin borrowed the policy: %v", err) } // 逐对象判定缺失时,精确链接读取也必须 unsupported。 noDisclosure := hostcaps{st: m.st, projectAudit: &projectAuditSlot{}} if err := (pluginRegistry{pluginID: "rdm", caps: noDisclosure}).RegisterProjectAuditAuthorizer( func(string, core.ID) (bool, error) { return true, nil }); err != nil { t.Fatal(err) } if _, err := (inprocHost{caps: noDisclosure, owner: "bob", actor: "bob", pluginID: "rdm"}). QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditUnsupported) { t.Fatalf("missing disclosure authorizer: %v", err) } // 声明式扩展与缺少该接口的注册表都明确拒绝注册,不静默忽略。 if err := (compiledRegistry{}).RegisterProjectAuditAuthorizer(func(string, core.ID) (bool, error) { return true, nil }); err == nil { t.Fatal("declarative plugin registered an audit authorizer") } if err := (recordRegistry{Registry: pluginRegistry{pluginID: "rdm", caps: hostcaps{}}}).RegisterProjectAuditAuthorizer( func(string, core.ID) (bool, error) { return true, nil }); !errors.Is(err, extension.ErrProjectAuditUnsupported) { t.Fatalf("registry without audit capability: %v", err) } // 导出与无对象正文权同样是显式 unsupported。 if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Export: true}); !errors.Is(err, extension.ErrProjectAuditUnsupported) { t.Fatalf("export on the actor-owned page: %v", err) } if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, IncludeBody: true}); !errors.Is(err, extension.ErrProjectAuditUnsupported) { t.Fatalf("body on the actor-owned page: %v", err) } }