Why: Clock-derived IDs could collide and string-scoped hosts could impersonate a registered audit publisher.
Evidence: Fixed-clock concurrent, ambiguous private offset, forged scoped-host, and registered publisher RED-to-GREEN tests; go test ./... -count=1 and targeted race test passed locally as SELF.
Impact: Generate 192-bit host IDs, reject ambiguous explicit offsets, and issue a publication-only capability at verifier registration.
Refs: Stage 5 W9; approved Stage 6 audit-link design adjustment r1; review of a8fa2fe
185 lines
8.3 KiB
Go
185 lines
8.3 KiB
Go
package app
|
|
|
|
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"
|
|
)
|
|
|
|
func TestProjectAuditLinkSharesOnlyVerifiedCommittedMetadata(t *testing.T) {
|
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
if err := st.EnsureUser("alice", "Alice at action"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.EnsureUser("bob", "Bob"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, err := st.CreateProject("alice", "A")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
otherProject, err := st.CreateProject("alice", "B")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := st.CreateConversation("alice", string(p.ID), "private", "")
|
|
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, ParamsDigest: "secret-digest"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
private, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
offset := private[0].Offset
|
|
slot := &projectAuditSlot{}
|
|
caps := hostcaps{st: st, projectAudit: slot}
|
|
reg := pluginRegistry{pluginID: "rdm", caps: caps}
|
|
auditAllowed := true
|
|
if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) {
|
|
return auditAllowed && actor == "bob" && projectID == p.ID, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
publisherFactory, err := (recordRegistry{Registry: reg}).RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) {
|
|
return proof.Owner == "alice" && proof.ProjectID == p.ID && proof.SourceKind == "domain-commit" && proof.SourceID == "commit-1" && proof.OperationID == "op-1" && proof.ResourceKind == "document" && proof.ResourceID == "doc-1", nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
metadataAllowed := true
|
|
if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, resource extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) {
|
|
return extension.ProjectAuditDisclosure{Metadata: metadataAllowed && actor == "bob" && resource.ResourceID == "doc-1"}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bob := inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}
|
|
q := extension.ProjectAuditQuery{ProjectID: p.ID, LinkedSourceKind: "domain-commit", LinkedSourceID: "commit-1", LinkedHostOffset: offset}
|
|
if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
|
t.Fatalf("unlinked private audit: %v", err)
|
|
}
|
|
alicePublisher, err := publisherFactory.ForOwner("alice") // capability retained by trusted RDM outbox
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := alicePublisher.(extension.Host); ok {
|
|
t.Fatal("publisher factory exposed a full owner-scoped Host")
|
|
}
|
|
linkReq := extension.ProjectAuditLinkRequest{ProjectID: p.ID, SourceKind: "domain-commit", SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}
|
|
// A different compiled plugin can call the public scoped-host factory with
|
|
// "rdm" as text. That must not mint RDM's audit publication authority.
|
|
wireScopedHostFactory(caps)
|
|
t.Cleanup(func() { extension.SetScopedHostFactory(nil) })
|
|
forged, err := extension.NewScopedHost("alice", "rdm")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if publisher, ok := forged.(extension.ProjectAuditPublisher); ok {
|
|
if _, err := publisher.PublishProjectAuditLink(context.Background(), linkReq); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
|
t.Fatalf("self-reported plugin ID minted audit publisher: %v", err)
|
|
}
|
|
}
|
|
if rows, err := bob.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: p.ID}); err != nil || len(rows.Receipts) != 0 {
|
|
t.Fatalf("private owner row visible without exact link: %+v %v", rows, err)
|
|
}
|
|
wrongProject := linkReq
|
|
wrongProject.ProjectID = otherProject.ID
|
|
wrongProject.HostAuditOffset = offset
|
|
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), wrongProject); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
|
t.Fatalf("wrong host project: %v", err)
|
|
}
|
|
uncommitted := linkReq
|
|
uncommitted.SourceID, uncommitted.IdempotencyKey = "uncommitted", "outbox-uncommitted"
|
|
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), uncommitted); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
|
t.Fatalf("uncommitted source: %v", err)
|
|
}
|
|
if _, err := (pluginRegistry{pluginID: "other", caps: caps}).RegisterProjectAuditLinkVerifier(func(context.Context, extension.ProjectAuditLinkProof) (bool, error) { return true, nil }); err == nil {
|
|
t.Fatal("other plugin replaced the registered verifier")
|
|
}
|
|
pending := linkReq
|
|
pending.OperationID, pending.IdempotencyKey = "op-not-yet-recorded", "outbox-pending"
|
|
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), pending); !errors.Is(err, extension.ErrProjectAuditPending) {
|
|
t.Fatalf("host receipt race must be retryable: %v", err)
|
|
}
|
|
first, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first.HostAuditOffset != offset {
|
|
t.Fatalf("trusted resolved offset: got %d want %d", first.HostAuditOffset, offset)
|
|
}
|
|
retry, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq)
|
|
if err != nil || retry.LinkID != first.LinkID {
|
|
t.Fatalf("idempotent retry: first=%+v retry=%+v err=%v", first, retry, err)
|
|
}
|
|
page, err := bob.QueryProjectAudit(context.Background(), q)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(page.Receipts) != 1 || page.Receipts[0].CommandID != "rdm.confirm" || page.Receipts[0].ActorName != "Alice at action" || page.Receipts[0].ConversationID != "" || page.Coverage.CrossMember != "verified-exact-link-only" {
|
|
t.Fatalf("linked page: %+v", page)
|
|
}
|
|
raw, err := json.Marshal(page)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(raw), "secret-digest") || strings.Contains(string(raw), string(c.ID)) {
|
|
t.Fatalf("private receipt fields leaked: %s", raw)
|
|
}
|
|
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "private.note", OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
duplicates, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
privateOffset := duplicates[0].Offset
|
|
mislink := linkReq
|
|
mislink.HostAuditOffset, mislink.IdempotencyKey = privateOffset, "outbox-private"
|
|
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), mislink); !errors.Is(err, extension.ErrProjectAuditLinkConflict) {
|
|
t.Fatalf("ambiguous operation linked a private command: %v", err)
|
|
}
|
|
privateQuery := q
|
|
privateQuery.LinkedHostOffset = privateOffset
|
|
if _, err := bob.QueryProjectAudit(context.Background(), privateQuery); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
|
t.Fatalf("private command disclosed through ambiguous operation: %v", err)
|
|
}
|
|
metadataAllowed = false
|
|
if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
|
t.Fatalf("revoked object metadata: %v", err)
|
|
}
|
|
metadataAllowed, auditAllowed = true, false
|
|
if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
|
t.Fatalf("revoked audit.read: %v", err)
|
|
}
|
|
linkReq.ResourceID = "doc-2"
|
|
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq); !errors.Is(err, extension.ErrProjectAuditLinkConflict) {
|
|
t.Fatalf("same key different link: %v", err)
|
|
}
|
|
metadataAllowed, auditAllowed = true, true
|
|
if err := st.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq); err == nil || errors.Is(err, extension.ErrProjectAuditDenied) {
|
|
t.Fatalf("storage outage during publish must remain retryable: %v", err)
|
|
}
|
|
if _, err := bob.QueryProjectAudit(context.Background(), q); err == nil || errors.Is(err, extension.ErrProjectAuditDenied) {
|
|
t.Fatalf("storage outage during linked read must be explicit: %v", err)
|
|
}
|
|
}
|