agent-exec transport f461d74086dd6a80db72f83748f34b04/128fee1609ad
This commit is contained in:
@@ -247,6 +247,12 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
|
|||||||
if h.actor == "" || h.actor != h.owner || q.ProjectID == "" {
|
if h.actor == "" || h.actor != h.owner || q.ProjectID == "" {
|
||||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
}
|
}
|
||||||
|
// An export is not a page. This slice has no whole-history export capability,
|
||||||
|
// so the request is refused outright rather than answered with a first page a
|
||||||
|
// consumer could mistake for the complete record.
|
||||||
|
if q.Export {
|
||||||
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||||
|
}
|
||||||
if h.caps.projectAudit == nil || h.caps.st == nil {
|
if h.caps.projectAudit == nil || h.caps.st == nil {
|
||||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||||
}
|
}
|
||||||
@@ -267,9 +273,16 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return extension.ProjectAuditPage{}, err
|
return extension.ProjectAuditPage{}, err
|
||||||
}
|
}
|
||||||
if q.LinkedSourceKind != "" || q.LinkedSourceID != "" || q.LinkedHostOffset != 0 {
|
if q.LinkedSourceKind != "" || q.LinkedSourceID != "" || q.LinkedHostOffset != 0 ||
|
||||||
|
q.LinkedResourceKind != "" || q.LinkedResourceID != "" || q.LinkedRunID != "" {
|
||||||
return h.queryLinkedProjectAudit(ctx, q, slot)
|
return h.queryLinkedProjectAudit(ctx, q, slot)
|
||||||
}
|
}
|
||||||
|
// The actor-owned page is not bound to one verified object, so no per-object
|
||||||
|
// body right can be decided for it. Refusing keeps Coverage.ResourceBodies
|
||||||
|
// honest instead of answering a page trimmed by an unasked decision.
|
||||||
|
if q.IncludeBody {
|
||||||
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||||
|
}
|
||||||
rows, cutoff, next, err := h.caps.st.ListProjectCommandAudits(h.owner, string(q.ProjectID), q.BeforeOffset, q.CutoffOffset, q.Limit)
|
rows, cutoff, next, err := h.caps.st.ListProjectCommandAudits(h.owner, string(q.ProjectID), q.BeforeOffset, q.CutoffOffset, q.Limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return extension.ProjectAuditPage{}, err
|
return extension.ProjectAuditPage{}, err
|
||||||
@@ -279,7 +292,7 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
|
|||||||
NextOffset: next, CutoffOffset: cutoff,
|
NextOffset: next, CutoffOffset: cutoff,
|
||||||
Coverage: extension.ProjectAuditCoverage{
|
Coverage: extension.ProjectAuditCoverage{
|
||||||
CommandAudit: "actor-owned-project-bound-only", CutoffConsistency: "committed-prefix", CrossMember: "unsupported", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", ModelReceipts: "unsupported",
|
CommandAudit: "actor-owned-project-bound-only", CutoffConsistency: "committed-prefix", CrossMember: "unsupported", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", ModelReceipts: "unsupported",
|
||||||
BackgroundReceipts: "unsupported", ResourceBodies: "unsupported",
|
BackgroundReceipts: "unsupported", ResourceBodies: "unsupported", Export: "unsupported",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
@@ -298,7 +311,7 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.ProjectAuditQuery, slot *projectAuditSlot) (extension.ProjectAuditPage, error) {
|
func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.ProjectAuditQuery, slot *projectAuditSlot) (extension.ProjectAuditPage, error) {
|
||||||
if q.LinkedSourceKind != "domain-commit" || q.LinkedSourceID == "" || q.LinkedHostOffset <= 0 || q.BeforeOffset != 0 || q.CutoffOffset != 0 {
|
if q.LinkedSourceKind != "domain-commit" || q.LinkedSourceID == "" || q.LinkedHostOffset <= 0 || q.BeforeOffset != 0 || q.CutoffOffset != 0 || q.Export {
|
||||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||||
}
|
}
|
||||||
slot.mu.RLock()
|
slot.mu.RLock()
|
||||||
@@ -311,9 +324,32 @@ func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.Pro
|
|||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, store.ErrConflict) {
|
||||||
|
// The host row was edited after publication: the receipt no longer matches
|
||||||
|
// the binding the domain verified. Fail closed instead of re-projecting it.
|
||||||
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return extension.ProjectAuditPage{}, err
|
return extension.ProjectAuditPage{}, err
|
||||||
}
|
}
|
||||||
|
// Caller locators only narrow the exact binding. Disagreement with stored host
|
||||||
|
// state denies; it never selects a different object.
|
||||||
|
if (q.LinkedResourceKind != "" && q.LinkedResourceKind != link.ResourceKind) ||
|
||||||
|
(q.LinkedResourceID != "" && q.LinkedResourceID != link.ResourceID) ||
|
||||||
|
(q.LinkedRunID != "" && q.LinkedRunID != link.RunID) {
|
||||||
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
|
}
|
||||||
|
// A run relation was verified at publication; re-check it against current
|
||||||
|
// storage so a deleted or moved run stops serving its receipt.
|
||||||
|
if link.RunID != "" {
|
||||||
|
bound, err := h.caps.st.RunBoundToAudit(link.Owner, link.ProjectID, row.Envelope.ConversationID, link.RunID)
|
||||||
|
if err != nil {
|
||||||
|
return extension.ProjectAuditPage{}, err
|
||||||
|
}
|
||||||
|
if !bound {
|
||||||
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
|
}
|
||||||
|
}
|
||||||
resource := extension.ProjectAuditLinkedResource{ProjectID: q.ProjectID, Owner: link.Owner, SourceKind: link.SourceKind, SourceID: link.SourceID,
|
resource := extension.ProjectAuditLinkedResource{ProjectID: q.ProjectID, Owner: link.Owner, SourceKind: link.SourceKind, SourceID: link.SourceID,
|
||||||
OperationID: link.OperationID, ResourceKind: link.ResourceKind, ResourceID: link.ResourceID, RunID: link.RunID, DisclosureClass: link.DisclosureClass}
|
OperationID: link.OperationID, ResourceKind: link.ResourceKind, ResourceID: link.ResourceID, RunID: link.RunID, DisclosureClass: link.DisclosureClass}
|
||||||
allowed, err := disclose(ctx, h.actor, resource)
|
allowed, err := disclose(ctx, h.actor, resource)
|
||||||
@@ -323,14 +359,32 @@ func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.Pro
|
|||||||
if !allowed.Metadata {
|
if !allowed.Metadata {
|
||||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
}
|
}
|
||||||
|
// audit.read and the per-object body right are separate authorities: a body
|
||||||
|
// request without that right is refused, never trimmed into a partial page.
|
||||||
|
if q.IncludeBody && !allowed.Body {
|
||||||
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return extension.ProjectAuditPage{}, err
|
||||||
|
}
|
||||||
nameStatus := "not-recorded"
|
nameStatus := "not-recorded"
|
||||||
if row.ActorName != "" {
|
if row.ActorName != "" {
|
||||||
nameStatus = "recorded"
|
nameStatus = "recorded"
|
||||||
}
|
}
|
||||||
return extension.ProjectAuditPage{Receipts: []extension.HostAuditReceipt{{SourceOffset: row.Offset, Time: row.Envelope.Time,
|
receipt := extension.HostAuditReceipt{SourceOffset: row.Offset, Time: row.Envelope.Time,
|
||||||
ActorID: row.Envelope.Actor, ActorName: row.ActorName, ActorNameStatus: nameStatus, CommandID: row.Envelope.CommandID,
|
ActorID: row.Envelope.Actor, ActorName: row.ActorName, ActorNameStatus: nameStatus, CommandID: row.Envelope.CommandID,
|
||||||
State: string(row.Envelope.State), OperationID: row.Envelope.OperationID}},
|
State: string(row.Envelope.State), OperationID: row.Envelope.OperationID}
|
||||||
Coverage: extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only",
|
coverage := extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only",
|
||||||
CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported",
|
CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported",
|
||||||
ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported"}}, nil
|
ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported", Export: "unsupported"}
|
||||||
|
if link.RunID != "" {
|
||||||
|
coverage.RunRelations = "verified-exact-link-run"
|
||||||
|
}
|
||||||
|
if q.IncludeBody {
|
||||||
|
// Body-permitted projection of the verified binding only: the object identity
|
||||||
|
// the domain attributed this command to, never resource content.
|
||||||
|
receipt.Resource = &extension.ProjectAuditReceiptResource{Kind: link.ResourceKind, ID: link.ResourceID, RunID: link.RunID}
|
||||||
|
coverage.ResourceBodies = "verified-exact-link-body"
|
||||||
|
}
|
||||||
|
return extension.ProjectAuditPage{Receipts: []extension.HostAuditReceipt{receipt}, Coverage: coverage}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,702 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,6 +148,10 @@ func (s *Store) SaveProjectAuditLink(link ProjectAuditLink) (ProjectAuditLink, e
|
|||||||
|
|
||||||
// ProjectAuditLinkedReceipt reads one exact association and the underlying
|
// ProjectAuditLinkedReceipt reads one exact association and the underlying
|
||||||
// host row. It does not enumerate private links or decide field disclosure.
|
// host row. It does not enumerate private links or decide field disclosure.
|
||||||
|
// The published binding is immutable: a row that was deleted, or edited after
|
||||||
|
// publication (state, operation, owner, or the stored envelope), no longer
|
||||||
|
// resolves as this link. A read must never project something the domain
|
||||||
|
// verifier never vouched for, so drift is refused instead of re-projected.
|
||||||
func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string, hostOffset int64) (ProjectAuditLink, ProjectCommandAuditRow, error) {
|
func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string, hostOffset int64) (ProjectAuditLink, ProjectCommandAuditRow, error) {
|
||||||
var row ProjectCommandAuditRow
|
var row ProjectCommandAuditRow
|
||||||
var link ProjectAuditLink
|
var link ProjectAuditLink
|
||||||
@@ -157,6 +161,7 @@ func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string
|
|||||||
}
|
}
|
||||||
err := s.db.QueryRow(s.q(`SELECT `+projectAuditLinkAliasedColumns+`,a.actor_name,a.payload FROM project_audit_links l
|
err := s.db.QueryRow(s.q(`SELECT `+projectAuditLinkAliasedColumns+`,a.actor_name,a.payload FROM project_audit_links l
|
||||||
JOIN command_audit a ON a.id=l.host_audit_offset AND a.owner=l.owner AND a.project_id=l.project_id
|
JOIN command_audit a ON a.id=l.host_audit_offset AND a.owner=l.owner AND a.project_id=l.project_id
|
||||||
|
AND a.state='committed' AND a.operation_id=l.operation_id
|
||||||
WHERE l.project_id=? AND l.source_kind=? AND l.source_id=? AND l.host_audit_offset=?`),
|
WHERE l.project_id=? AND l.source_kind=? AND l.source_id=? AND l.host_audit_offset=?`),
|
||||||
projectID, sourceKind, sourceID, hostOffset).Scan(&link.ID, &link.HostAuditOffset, &link.ProjectID, &link.Owner,
|
projectID, sourceKind, sourceID, hostOffset).Scan(&link.ID, &link.HostAuditOffset, &link.ProjectID, &link.Owner,
|
||||||
&link.SourceKind, &link.SourceID, &link.OperationID, &link.ResourceKind, &link.ResourceID, &link.RunID,
|
&link.SourceKind, &link.SourceID, &link.OperationID, &link.ResourceKind, &link.ResourceID, &link.RunID,
|
||||||
@@ -171,5 +176,11 @@ func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string
|
|||||||
if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil {
|
if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil {
|
||||||
return link, row, err
|
return link, row, err
|
||||||
}
|
}
|
||||||
|
// The link was published only against a committed row owned by link.Owner whose
|
||||||
|
// envelope carried this operation. Re-check the envelope itself: a payload that
|
||||||
|
// no longer matches the verified binding is drift, not a new fact.
|
||||||
|
if row.Envelope.OperationID != link.OperationID || row.Envelope.Actor != link.Owner || string(row.Envelope.State) != "committed" {
|
||||||
|
return link, row, ErrConflict
|
||||||
|
}
|
||||||
return link, row, nil
|
return link, row, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -95,3 +97,84 @@ func TestProjectAuditLinkStoreIdempotenceAndExactScope(t *testing.T) {
|
|||||||
t.Fatalf("ambiguous host operation must not choose one: %v", err)
|
t.Fatalf("ambiguous host operation must not choose one: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 已发布链接的绑定不可变:宿主回执在发布后被删或被改(状态、operation、载荷)
|
||||||
|
// 时,读取侧不得再把它当作领域验证过的那一条返回。SQLite 是本测试的落点——
|
||||||
|
// PostgreSQL 由 all-writer 触发器在写侧直接拒绝改动。
|
||||||
|
func TestProjectAuditLinkedReceiptRefusesEditedOrDeletedHostRows(t *testing.T) {
|
||||||
|
st, err := OpenSQLite(filepath.Join(t.TempDir(), "links.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
if err := st.EnsureUser("alice", "Alice"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p, err := st.CreateProject("alice", "A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := st.CreateConversation("alice", string(p.ID), "work", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm",
|
||||||
|
OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
link := ProjectAuditLink{HostAuditOffset: rows[0].Offset, ProjectID: string(p.ID), Owner: "alice", SourceKind: "domain-commit",
|
||||||
|
SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}
|
||||||
|
if _, err := st.SaveProjectAuditLink(link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, audit, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); err != nil ||
|
||||||
|
got.OperationID != "op-1" || audit.Envelope.CommandID != "rdm.confirm" {
|
||||||
|
t.Fatalf("baseline link: %+v %+v %v", got, audit, err)
|
||||||
|
}
|
||||||
|
var stored string
|
||||||
|
if err := st.db.QueryRow(st.q(`SELECT payload FROM command_audit WHERE id=?`), rows[0].Offset).Scan(&stored); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forged, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm",
|
||||||
|
OperationID: "op-forged", ConversationID: string(c.ID), State: agentcommand.StateCommitted})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET payload=? WHERE id=?`), string(forged), rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrConflict) {
|
||||||
|
t.Fatalf("edited envelope still resolved as the verified link: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET payload=? WHERE id=?`), stored, rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET operation_id=? WHERE id=?`), "op-other", rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("edited operation column still resolved: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET operation_id=? WHERE id=?`), "op-1", rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET state=? WHERE id=?`), "accepted", rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("uncommitted row still resolved: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET state=? WHERE id=?`), "committed", rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET project_id=? WHERE id=?`), "", rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("re-attributed row still resolved: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,7 +75,13 @@ import (
|
|||||||
// directory: RoleOption roleId+name, no permissions or bindings) and the
|
// directory: RoleOption roleId+name, no permissions or bindings) and the
|
||||||
// AccountDirectory purpose project.member-roster, an explicit project member
|
// AccountDirectory purpose project.member-roster, an explicit project member
|
||||||
// enumeration inside the trusted RDM scope.
|
// enumeration inside the trusted RDM scope.
|
||||||
const SurfaceVersion = "2.28.0-experimental"
|
// 2.29.0-experimental: X2 project audit read completion. ProjectAuditQuery gains
|
||||||
|
// optional linked resource/run locators, IncludeBody, and Export; receipts may
|
||||||
|
// carry ProjectAuditReceiptResource only under the domain's per-object body
|
||||||
|
// right; ProjectAuditCoverage states Export scope. Every addition is an optional
|
||||||
|
// field or a new exported type: existing ProjectAuditHost/ProjectAuditRegistry
|
||||||
|
// providers and queries keep compiling and behaving unchanged.
|
||||||
|
const SurfaceVersion = "2.29.0-experimental"
|
||||||
|
|
||||||
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||||||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ type ProjectAuditDisclosure struct {
|
|||||||
Body bool
|
Body bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProjectAuditDisclosureAuthorizer answers one per-object decision for the
|
||||||
|
// authenticated actor on a stored, immutable link. Metadata and Body are
|
||||||
|
// separate authorities: the host never substitutes one for the other, and it
|
||||||
|
// never answers a body request from a metadata-only right.
|
||||||
|
//
|
||||||
|
// TODO(RDM consumer, out of this repository): the decision itself — the current
|
||||||
|
// project audit.read grant, the document read duty plus stage scope that carries
|
||||||
|
// 正文权, and any export authority — belongs to the domain plugin that registers
|
||||||
|
// this callback. The Backend only refuses, trims, and reports coverage; it must
|
||||||
|
// not grow a local account/project policy to answer in the domain's place. A
|
||||||
|
// later RDM unit implements the callbacks and can then widen Coverage
|
||||||
|
// (Export, ResourceBodies, RunRelations) from this base's current honest
|
||||||
|
// "unsupported" statements.
|
||||||
type ProjectAuditDisclosureAuthorizer func(context.Context, string, ProjectAuditLinkedResource) (ProjectAuditDisclosure, error)
|
type ProjectAuditDisclosureAuthorizer func(context.Context, string, ProjectAuditLinkedResource) (ProjectAuditDisclosure, error)
|
||||||
|
|
||||||
// ProjectAuditPublisherFactory is issued only by successful verifier
|
// ProjectAuditPublisherFactory is issued only by successful verifier
|
||||||
@@ -107,6 +120,11 @@ type ProjectAuditPublisher interface {
|
|||||||
// database guards prove a
|
// database guards prove a
|
||||||
// committed prefix; otherwise the host rejects the read instead of claiming a
|
// committed prefix; otherwise the host rejects the read instead of claiming a
|
||||||
// fixed snapshot. See Coverage.CutoffConsistency.
|
// fixed snapshot. See Coverage.CutoffConsistency.
|
||||||
|
//
|
||||||
|
// A read is keyed by the trusted project, the authenticated actor, the verified
|
||||||
|
// resource and run, and the source offset plus cutoff. Every caller-supplied
|
||||||
|
// field is a locator, never an authorization: the host re-checks it against
|
||||||
|
// stored host facts and the domain callbacks before a receipt is projected.
|
||||||
type ProjectAuditQuery struct {
|
type ProjectAuditQuery struct {
|
||||||
ProjectID core.ID `json:"projectId"`
|
ProjectID core.ID `json:"projectId"`
|
||||||
BeforeOffset int64 `json:"beforeOffset,omitempty"`
|
BeforeOffset int64 `json:"beforeOffset,omitempty"`
|
||||||
@@ -117,10 +135,37 @@ type ProjectAuditQuery struct {
|
|||||||
LinkedSourceKind string `json:"linkedSourceKind,omitempty"`
|
LinkedSourceKind string `json:"linkedSourceKind,omitempty"`
|
||||||
LinkedSourceID string `json:"linkedSourceId,omitempty"`
|
LinkedSourceID string `json:"linkedSourceId,omitempty"`
|
||||||
LinkedHostOffset int64 `json:"linkedHostOffset,omitempty"`
|
LinkedHostOffset int64 `json:"linkedHostOffset,omitempty"`
|
||||||
|
// LinkedResourceKind/LinkedResourceID/LinkedRunID narrow the same exact
|
||||||
|
// locator to the verified binding. A value that disagrees with stored host
|
||||||
|
// state denies the read; it never widens it and never stands in for the
|
||||||
|
// domain's current decision on that object.
|
||||||
|
LinkedResourceKind string `json:"linkedResourceKind,omitempty"`
|
||||||
|
LinkedResourceID string `json:"linkedResourceId,omitempty"`
|
||||||
|
LinkedRunID string `json:"linkedRunId,omitempty"`
|
||||||
|
// IncludeBody asks for the per-object body projection of one verified link.
|
||||||
|
// The host asks the domain disclosure authorizer again for that object: a
|
||||||
|
// metadata-only right serves metadata only, and a request without a verified
|
||||||
|
// object binding is reported unsupported instead of a silently trimmed page.
|
||||||
|
IncludeBody bool `json:"includeBody,omitempty"`
|
||||||
|
// Export asks for a whole-history export. No base capability serves one, so
|
||||||
|
// the host refuses instead of degrading into a partial paginated read that
|
||||||
|
// would claim a coverage it cannot establish. See Coverage.Export.
|
||||||
|
Export bool `json:"export,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectAuditReceiptResource names the object one verified link attributes a
|
||||||
|
// receipt to. The host copies it from the stored binding and only when the
|
||||||
|
// domain granted the per-object body right; it is never derived from caller
|
||||||
|
// input and never carries resource content.
|
||||||
|
type ProjectAuditReceiptResource struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
RunID string `json:"runId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HostAuditReceipt is a server-projected receipt, never a raw audit payload.
|
// HostAuditReceipt is a server-projected receipt, never a raw audit payload.
|
||||||
// ActorName is the name recorded with this command; old rows have no snapshot.
|
// ActorName is the name recorded with this command; old rows have no snapshot.
|
||||||
|
// Resource appears only for a body-permitted verified link.
|
||||||
type HostAuditReceipt struct {
|
type HostAuditReceipt struct {
|
||||||
SourceOffset int64 `json:"sourceOffset"`
|
SourceOffset int64 `json:"sourceOffset"`
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
@@ -131,6 +176,7 @@ type HostAuditReceipt struct {
|
|||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
OperationID string `json:"operationId,omitempty"`
|
OperationID string `json:"operationId,omitempty"`
|
||||||
ConversationID string `json:"conversationId"`
|
ConversationID string `json:"conversationId"`
|
||||||
|
Resource *ProjectAuditReceiptResource `json:"resource,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectAuditCoverage states the exact scope of this first X2 slice. Missing
|
// ProjectAuditCoverage states the exact scope of this first X2 slice. Missing
|
||||||
@@ -144,6 +190,9 @@ type ProjectAuditCoverage struct {
|
|||||||
ModelReceipts string `json:"modelReceipts"`
|
ModelReceipts string `json:"modelReceipts"`
|
||||||
BackgroundReceipts string `json:"backgroundReceipts"`
|
BackgroundReceipts string `json:"backgroundReceipts"`
|
||||||
ResourceBodies string `json:"resourceBodies"`
|
ResourceBodies string `json:"resourceBodies"`
|
||||||
|
// Export states whether a whole-history export is served. An audit grant is
|
||||||
|
// not an export grant: this slice only pages, and says so.
|
||||||
|
Export string `json:"export"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectAuditPage struct {
|
type ProjectAuditPage struct {
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package extension
|
||||||
|
|
||||||
|
// W9 X2 读取面补完(2.29.0-experimental)的公开面契约:
|
||||||
|
// 1. 新增项全部是可选字段/新导出类型:既有 ProjectAuditHost、ProjectAuditRegistry
|
||||||
|
// 提供者与既有查询字面量照旧编译、照旧语义(不破坏旧 provider);
|
||||||
|
// 2. 新字段序列化带 omitempty:未使用的读取请求与旧消费者的线格式不变;
|
||||||
|
// 3. 正文投影与导出声明是显式契约字段,未开启时不下发。
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
core "agent-system-core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// legacyAuditHost 是 2.14.0 形态的消费者:只实现既有方法,不感知新增可选字段。
|
||||||
|
type legacyAuditHost struct{}
|
||||||
|
|
||||||
|
func (legacyAuditHost) QueryProjectAudit(context.Context, ProjectAuditQuery) (ProjectAuditPage, error) {
|
||||||
|
return ProjectAuditPage{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// legacyAuditRegistry 是 2.14.0 形态的注册方实现。
|
||||||
|
type legacyAuditRegistry struct{}
|
||||||
|
|
||||||
|
func (legacyAuditRegistry) RegisterProjectAuditAuthorizer(ProjectAuditAuthorizer) error { return nil }
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ ProjectAuditHost = legacyAuditHost{}
|
||||||
|
_ ProjectAuditRegistry = legacyAuditRegistry{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectAuditReadContractIsAdditiveAndOptional(t *testing.T) {
|
||||||
|
if SurfaceVersion < "2.29.0" {
|
||||||
|
t.Fatalf("SurfaceVersion must record the audit read completion, got %s", SurfaceVersion)
|
||||||
|
}
|
||||||
|
// 既有查询字面量(只填项目与游标)仍然可用,新增字段全部零值。
|
||||||
|
legacy := ProjectAuditQuery{ProjectID: core.ID("p1"), Limit: 10, BeforeOffset: 4, CutoffOffset: 9}
|
||||||
|
if legacy.IncludeBody || legacy.Export || legacy.LinkedResourceID != "" || legacy.LinkedRunID != "" {
|
||||||
|
t.Fatalf("optional fields defaulted to non-zero: %+v", legacy)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(legacy)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{"includeBody", "export", "linkedResourceKind", "linkedResourceId", "linkedRunId"} {
|
||||||
|
if strings.Contains(string(raw), unwanted) {
|
||||||
|
t.Fatalf("omitted optional field %s reached the wire: %s", unwanted, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"projectId":"p1"`, `"beforeOffset":4`, `"cutoffOffset":9`, `"limit":10`} {
|
||||||
|
if !strings.Contains(string(raw), want) {
|
||||||
|
t.Fatalf("existing field %s missing from the wire form: %s", want, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 显式请求时字段可见,消费端能区分“正文/导出请求”与普通分页。
|
||||||
|
full, err := json.Marshal(ProjectAuditQuery{ProjectID: core.ID("p1"), IncludeBody: true, Export: true,
|
||||||
|
LinkedResourceKind: "document", LinkedResourceID: "doc-1", LinkedRunID: "run-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"includeBody":true`, `"export":true`, `"linkedResourceKind":"document"`,
|
||||||
|
`"linkedResourceId":"doc-1"`, `"linkedRunId":"run-1"`} {
|
||||||
|
if !strings.Contains(string(full), want) {
|
||||||
|
t.Fatalf("requested field %s missing: %s", want, full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 未授予正文时回执不带 resource 字段;授予后才出现对象身份(不含正文载荷)。
|
||||||
|
metadataOnly, err := json.Marshal(ProjectAuditPage{Receipts: []HostAuditReceipt{{SourceOffset: 3, CommandID: "rdm.confirm"}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(metadataOnly), `"resource"`) {
|
||||||
|
t.Fatalf("metadata-only receipt carried a resource: %s", metadataOnly)
|
||||||
|
}
|
||||||
|
withBody, err := json.Marshal(ProjectAuditPage{Receipts: []HostAuditReceipt{{SourceOffset: 3, CommandID: "rdm.confirm",
|
||||||
|
Resource: &ProjectAuditReceiptResource{Kind: "document", ID: "doc-1", RunID: "run-1"}}},
|
||||||
|
Coverage: ProjectAuditCoverage{ResourceBodies: "verified-exact-link-body", Export: "unsupported"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"resource":{"kind":"document","id":"doc-1","runId":"run-1"}`, `"export":"unsupported"`} {
|
||||||
|
if !strings.Contains(string(withBody), want) {
|
||||||
|
t.Fatalf("body-permitted projection missing %s: %s", want, withBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 四个审计哨兵互不相等:unsupported(缺基座能力)与 denied(策略拒绝)
|
||||||
|
// 不得互相冒充。
|
||||||
|
if !errors.Is(ErrProjectAuditUnsupported, ErrProjectAuditUnsupported) {
|
||||||
|
t.Fatal("unsupported sentinel must stay comparable with errors.Is")
|
||||||
|
}
|
||||||
|
for _, distinct := range []error{ErrProjectAuditDenied, ErrProjectAuditLinkConflict, ErrProjectAuditPending} {
|
||||||
|
if errors.Is(ErrProjectAuditUnsupported, distinct) || errors.Is(distinct, ErrProjectAuditUnsupported) {
|
||||||
|
t.Fatalf("audit sentinels must stay distinct: %v vs %v", distinct, ErrProjectAuditUnsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,7 @@ ProjectAuditPublisher.PublishProjectAuditLink
|
|||||||
ProjectAuditPublisherFactory
|
ProjectAuditPublisherFactory
|
||||||
ProjectAuditPublisherFactory.ForOwner
|
ProjectAuditPublisherFactory.ForOwner
|
||||||
ProjectAuditQuery
|
ProjectAuditQuery
|
||||||
|
ProjectAuditReceiptResource
|
||||||
ProjectAuditRegistry
|
ProjectAuditRegistry
|
||||||
ProjectAuditRegistry.RegisterProjectAuditAuthorizer
|
ProjectAuditRegistry.RegisterProjectAuditAuthorizer
|
||||||
ProjectContextBundle
|
ProjectContextBundle
|
||||||
|
|||||||
Reference in New Issue
Block a user