391 lines
17 KiB
Go
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
|
|
}
|