Files
Agent-System-Backend/internal/app/project_audit.go
T
agent-exec-transport 97f60c1df6
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m20s
agent-exec transport f461d74086dd6a80db72f83748f34b04/128fee1609ad
2026-10-22 03:41:15 +00:00

391 lines
17 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
}
// 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 {
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 ||
q.LinkedResourceKind != "" || q.LinkedResourceID != "" || q.LinkedRunID != "" {
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)
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", Export: "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 || q.Export {
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 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 {
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,
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
}
// 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"
if row.ActorName != "" {
nameStatus = "recorded"
}
receipt := 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", 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
}