703 lines
31 KiB
Go
703 lines
31 KiB
Go
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)
|
||
}
|
||
}
|