Files
Agent-System-Backend/internal/app/project_audit.go
T
weichen 63ebc85695 Make PostgreSQL project audit cutoffs commit ordered
Why: PostgreSQL sequence IDs could commit out of order and make fixed project audit cursors permanently skip a late row.

Evidence: SELF PG cross-page RED, CACHE 10 counterexample, configuration drift RED/GREEN, and pre-commit Go, PG migration, and race receipts are recorded in the W9 task card. Fixed-HEAD checks follow this commit.

Impact: Guard implicit command audit ID allocation with a transaction advisory lock; reject unsafe sequence/default drift; preserve explicit-ID storage migration; report committed-prefix only for verified project pages.

Refs: Approved W9 PostgreSQL cutoff adjustment r1; W9 Backend task card.
2026-10-14 05:41:37 +00:00

337 lines
15 KiB
Go

package app
import (
"context"
"errors"
"sync"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// projectAuditSlot binds the audit.read policy to its registering domain plugin.
// A different plugin cannot borrow that policy through its own Host handle.
type projectAuditSlot struct {
mu sync.RWMutex
holder string
check extension.ProjectAuditAuthorizer
verifier extension.ProjectAuditLinkVerifier
disclose extension.ProjectAuditDisclosureAuthorizer
publisherToken *auditPublisherToken
}
// This identity is available only to the publisher factory returned at
// registration. A caller-provided pluginID cannot manufacture the pointer.
type auditPublisherToken struct{ marker byte }
type projectAuditPublisherFactory struct {
caps hostcaps
pluginID string
token *auditPublisherToken
}
func (f projectAuditPublisherFactory) ForOwner(owner string) (extension.ProjectAuditPublisher, error) {
if owner == "" || f.token == nil {
return nil, extension.ErrProjectAuditDenied
}
return projectAuditPublisher{caps: f.caps, owner: owner, pluginID: f.pluginID, token: f.token}, nil
}
type projectAuditPublisher struct {
caps hostcaps
owner string
pluginID string
token *auditPublisherToken
}
func (p projectAuditPublisher) PublishProjectAuditLink(ctx context.Context, req extension.ProjectAuditLinkRequest) (extension.ProjectAuditLinkReceipt, error) {
h := inprocHost{caps: p.caps, owner: p.owner, pluginID: p.pluginID}
return h.publishProjectAuditLink(ctx, req, p.token)
}
func (s *projectAuditSlot) register(pluginID string, check extension.ProjectAuditAuthorizer) error {
if pluginID == "" || check == nil {
return extension.ErrProjectAuditUnsupported
}
s.mu.Lock()
defer s.mu.Unlock()
if s.check != nil || (s.holder != "" && s.holder != pluginID) {
return errors.New("extension: project audit authorizer already registered")
}
s.holder, s.check = pluginID, check
return nil
}
func (s *projectAuditSlot) registerVerifier(pluginID string, verify extension.ProjectAuditLinkVerifier) (*auditPublisherToken, error) {
if pluginID == "" || verify == nil {
return nil, extension.ErrProjectAuditUnsupported
}
s.mu.Lock()
defer s.mu.Unlock()
if s.verifier != nil || (s.holder != "" && s.holder != pluginID) {
return nil, errors.New("extension: project audit verifier already registered")
}
s.holder, s.verifier = pluginID, verify
s.publisherToken = &auditPublisherToken{marker: 1}
return s.publisherToken, nil
}
func (s *projectAuditSlot) registerDisclosure(pluginID string, disclose extension.ProjectAuditDisclosureAuthorizer) error {
if pluginID == "" || disclose == nil {
return extension.ErrProjectAuditUnsupported
}
s.mu.Lock()
defer s.mu.Unlock()
if s.disclose != nil || (s.holder != "" && s.holder != pluginID) {
return errors.New("extension: project audit disclosure authorizer already registered")
}
s.holder, s.disclose = pluginID, disclose
return nil
}
func (r compiledRegistry) RegisterProjectAuditAuthorizer(extension.ProjectAuditAuthorizer) error {
return errors.New("extension: declarative plugins cannot register project audit authorizers")
}
func (r pluginRegistry) RegisterProjectAuditAuthorizer(check extension.ProjectAuditAuthorizer) error {
if r.caps.projectAudit == nil {
return extension.ErrProjectAuditUnsupported
}
return r.caps.projectAudit.register(r.pluginID, check)
}
func (r recordRegistry) RegisterProjectAuditAuthorizer(check extension.ProjectAuditAuthorizer) error {
reg, ok := r.Registry.(extension.ProjectAuditRegistry)
if !ok {
return extension.ErrProjectAuditUnsupported
}
return reg.RegisterProjectAuditAuthorizer(check)
}
func (r compiledRegistry) RegisterProjectAuditLinkVerifier(extension.ProjectAuditLinkVerifier) (extension.ProjectAuditPublisherFactory, error) {
return nil, errors.New("extension: declarative plugins cannot register project audit link verifiers")
}
func (r compiledRegistry) RegisterProjectAuditDisclosureAuthorizer(extension.ProjectAuditDisclosureAuthorizer) error {
return errors.New("extension: declarative plugins cannot register project audit disclosure authorizers")
}
func (r pluginRegistry) RegisterProjectAuditLinkVerifier(verify extension.ProjectAuditLinkVerifier) (extension.ProjectAuditPublisherFactory, error) {
if r.caps.projectAudit == nil {
return nil, extension.ErrProjectAuditUnsupported
}
token, err := r.caps.projectAudit.registerVerifier(r.pluginID, verify)
if err != nil {
return nil, err
}
return projectAuditPublisherFactory{caps: r.caps, pluginID: r.pluginID, token: token}, nil
}
func (r pluginRegistry) RegisterProjectAuditDisclosureAuthorizer(disclose extension.ProjectAuditDisclosureAuthorizer) error {
if r.caps.projectAudit == nil {
return extension.ErrProjectAuditUnsupported
}
return r.caps.projectAudit.registerDisclosure(r.pluginID, disclose)
}
func (r recordRegistry) RegisterProjectAuditLinkVerifier(verify extension.ProjectAuditLinkVerifier) (extension.ProjectAuditPublisherFactory, error) {
reg, ok := r.Registry.(extension.ProjectAuditLinkRegistry)
if !ok {
return nil, extension.ErrProjectAuditUnsupported
}
return reg.RegisterProjectAuditLinkVerifier(verify)
}
func (r recordRegistry) RegisterProjectAuditDisclosureAuthorizer(disclose extension.ProjectAuditDisclosureAuthorizer) error {
reg, ok := r.Registry.(extension.ProjectAuditLinkRegistry)
if !ok {
return extension.ErrProjectAuditUnsupported
}
return reg.RegisterProjectAuditDisclosureAuthorizer(disclose)
}
var _ extension.ProjectAuditHost = inprocHost{}
var _ extension.ProjectAuditPublisher = projectAuditPublisher{}
var _ extension.ProjectAuditPublisherFactory = projectAuditPublisherFactory{}
func (h inprocHost) publishProjectAuditLink(ctx context.Context, req extension.ProjectAuditLinkRequest, capability *auditPublisherToken) (extension.ProjectAuditLinkReceipt, error) {
if err := ctx.Err(); err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if h.caps.projectAudit == nil || h.caps.st == nil {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditUnsupported
}
slot := h.caps.projectAudit
slot.mu.RLock()
holder, verify, registeredToken := slot.holder, slot.verifier, slot.publisherToken
slot.mu.RUnlock()
if holder != h.pluginID || verify == nil || registeredToken == nil || capability != registeredToken {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditUnsupported
}
if h.owner == "" || (h.actor != "" && h.actor != h.owner) || req.HostAuditOffset < 0 || req.ProjectID == "" || req.SourceKind != "domain-commit" || req.SourceID == "" || req.OperationID == "" || req.ResourceKind == "" || req.ResourceID == "" || req.DisclosureClass != "metadata" || req.IdempotencyKey == "" {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
// An explicit offset is only a locator. A historical collision or a forged
// offset must not turn a private command with the same operation ID into a
// published project receipt.
resolved, err := h.caps.st.CommittedAuditOffset(h.owner, string(req.ProjectID), req.OperationID)
if errors.Is(err, store.ErrNotFound) {
if req.HostAuditOffset == 0 {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditPending
}
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
if errors.Is(err, store.ErrConflict) {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict
}
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if req.HostAuditOffset != 0 && req.HostAuditOffset != resolved {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict
}
req.HostAuditOffset = resolved
row, projectID, err := h.caps.st.CommandAuditForLink(h.owner, req.HostAuditOffset)
if errors.Is(err, store.ErrNotFound) {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if projectID != string(req.ProjectID) || row.Envelope.Actor != h.owner || row.Envelope.OperationID != req.OperationID || string(row.Envelope.State) != "committed" || row.Envelope.RunID != req.RunID {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
if req.RunID != "" {
bound, err := h.caps.st.RunBoundToAudit(h.owner, projectID, row.Envelope.ConversationID, req.RunID)
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if !bound {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
}
previous, lookupErr := h.caps.st.ProjectAuditLinkByKey(h.owner, req.IdempotencyKey)
if lookupErr == nil {
if previous.HostAuditOffset != req.HostAuditOffset || previous.ProjectID != string(req.ProjectID) || previous.SourceKind != req.SourceKind || previous.SourceID != req.SourceID || previous.OperationID != req.OperationID || previous.ResourceKind != req.ResourceKind || previous.ResourceID != req.ResourceID || previous.RunID != req.RunID || previous.DisclosureClass != req.DisclosureClass {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict
}
return extension.ProjectAuditLinkReceipt{LinkID: previous.ID, HostAuditOffset: previous.HostAuditOffset}, nil
}
if !errors.Is(lookupErr, store.ErrNotFound) {
return extension.ProjectAuditLinkReceipt{}, lookupErr
}
proof := extension.ProjectAuditLinkProof{ProjectAuditLinkRequest: req, Owner: h.owner}
ok, err := verify(ctx, proof)
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
if !ok {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied
}
if err := ctx.Err(); err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
link := store.ProjectAuditLink{HostAuditOffset: req.HostAuditOffset, ProjectID: projectID, Owner: h.owner,
SourceKind: req.SourceKind, SourceID: req.SourceID, OperationID: req.OperationID, ResourceKind: req.ResourceKind,
ResourceID: req.ResourceID, RunID: req.RunID, DisclosureClass: req.DisclosureClass, IdempotencyKey: req.IdempotencyKey}
saved, err := h.caps.st.SaveProjectAuditLink(link)
if errors.Is(err, store.ErrConflict) {
return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict
}
if err != nil {
return extension.ProjectAuditLinkReceipt{}, err
}
return extension.ProjectAuditLinkReceipt{LinkID: saved.ID, HostAuditOffset: saved.HostAuditOffset}, nil
}
func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAuditQuery) (extension.ProjectAuditPage, error) {
if err := ctx.Err(); err != nil {
return extension.ProjectAuditPage{}, err
}
if h.actor == "" || h.actor != h.owner || q.ProjectID == "" {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
if h.caps.projectAudit == nil || h.caps.st == nil {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
slot := h.caps.projectAudit
slot.mu.RLock()
holder, check := slot.holder, slot.check
slot.mu.RUnlock()
if holder != h.pluginID || check == nil {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
ok, err := check(h.actor, core.ID(q.ProjectID))
if err != nil {
return extension.ProjectAuditPage{}, err
}
if !ok {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
if err := ctx.Err(); err != nil {
return extension.ProjectAuditPage{}, err
}
if q.LinkedSourceKind != "" || q.LinkedSourceID != "" || q.LinkedHostOffset != 0 {
return h.queryLinkedProjectAudit(ctx, q, slot)
}
rows, cutoff, next, err := h.caps.st.ListProjectCommandAudits(h.owner, string(q.ProjectID), q.BeforeOffset, q.CutoffOffset, q.Limit)
if err != nil {
return extension.ProjectAuditPage{}, err
}
page := extension.ProjectAuditPage{
Receipts: make([]extension.HostAuditReceipt, 0, len(rows)),
NextOffset: next, CutoffOffset: cutoff,
Coverage: extension.ProjectAuditCoverage{
CommandAudit: "actor-owned-project-bound-only", CutoffConsistency: "committed-prefix", CrossMember: "unsupported", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", ModelReceipts: "unsupported",
BackgroundReceipts: "unsupported", ResourceBodies: "unsupported",
},
}
for _, row := range rows {
nameStatus := "not-recorded"
if row.ActorName != "" {
nameStatus = "recorded"
}
page.Receipts = append(page.Receipts, extension.HostAuditReceipt{
SourceOffset: row.Offset, Time: row.Envelope.Time, ActorID: row.Envelope.Actor,
ActorName: row.ActorName, ActorNameStatus: nameStatus,
CommandID: row.Envelope.CommandID, State: string(row.Envelope.State),
OperationID: row.Envelope.OperationID, ConversationID: row.Envelope.ConversationID,
})
}
return page, nil
}
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 {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
slot.mu.RLock()
disclose := slot.disclose
slot.mu.RUnlock()
if disclose == nil {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
}
link, row, err := h.caps.st.ProjectAuditLinkedReceipt(string(q.ProjectID), q.LinkedSourceKind, q.LinkedSourceID, q.LinkedHostOffset)
if errors.Is(err, store.ErrNotFound) {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
if err != nil {
return extension.ProjectAuditPage{}, err
}
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}
allowed, err := disclose(ctx, h.actor, resource)
if err != nil {
return extension.ProjectAuditPage{}, err
}
if !allowed.Metadata {
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
}
nameStatus := "not-recorded"
if row.ActorName != "" {
nameStatus = "recorded"
}
return extension.ProjectAuditPage{Receipts: []extension.HostAuditReceipt{{SourceOffset: row.Offset, Time: row.Envelope.Time,
ActorID: row.Envelope.Actor, ActorName: row.ActorName, ActorNameStatus: nameStatus, CommandID: row.Envelope.CommandID,
State: string(row.Envelope.State), OperationID: row.Envelope.OperationID}},
Coverage: extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only",
CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported",
ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported"}}, nil
}