Files
Agent-System-Backend/internal/store/audit.go
T
weichen 136e93bf1d Combine project context and audit with account main
Why: X3 must share one Backend surface with the X1 context and X2 audit candidates while current main now owns account migrations 32-34.

Evidence: go test -p 1 ./... and go build ./... passed on the merged tree; isolated PostgreSQL project audit, migration, and work-context tests passed against a temporary instance. Default parallel go test ./... had only the unrelated sandbox orphan timing test fail; its isolated ten-run replay passed.

Impact: Preserve account migrations 32-34, place X1/X2 migrations at 35-43, and retain account-effective write guards alongside project audit/context behavior. X3 observer remains a contract only.

Refs: TT-C01-TT-C04, TT-A01-TT-A04, TT-N03, TT-R01; combination probe d9b00c2
2026-10-14 15:41:11 +00:00

168 lines
6.5 KiB
Go

package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
agentcommand "agent-command"
)
// AppendAudit 追加命令审计信封(cmdbus 端口;v7 建表,v18 增加 owner 列)。
// owner 是租户身份(认证注入),与信封里的 Actor(发起主体)语义不同:查询按
// owner 过滤,跨租户审计不可见。
func (s *Store) AppendAudit(owner string, env agentcommand.AuditEnvelope) error {
if err := s.requireProjectAuditCommittedPrefix(); err != nil {
return err
}
raw, err := json.Marshal(env)
if err != nil {
return err
}
_, err = s.db.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload, actor_name, project_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT name FROM users WHERE owner=?), ''),
COALESCE((SELECT project_id FROM conversations WHERE id=? AND owner=?), ''))`),
env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, string(env.Source), env.Entrance, string(env.State),
env.OperationID, env.ConversationID, owner, string(raw), env.Actor, env.ConversationID, owner)
return err
}
// AppendAuditAccountChecked fences command result envelopes against account
// disable. Security-denial audit uses its separate account-state path.
func (s *Store) AppendAuditAccountChecked(owner string, env agentcommand.AuditEnvelope, allowImplicitDefault bool) error {
if err := s.requireProjectAuditCommittedPrefix(); err != nil {
return err
}
raw, err := json.Marshal(env)
if err != nil {
return err
}
return s.withEffectiveAccountWrite(owner, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
_, err := tx.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload, actor_name, project_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT name FROM users WHERE owner=?), ''),
COALESCE((SELECT project_id FROM conversations WHERE id=? AND owner=?), ''))`),
env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, string(env.Source), env.Entrance, string(env.State),
env.OperationID, env.ConversationID, owner, string(raw), env.Actor, env.ConversationID, owner)
return err
})
}
// ProjectCommandAuditRow is an internal command receipt. Callers must project
// only approved fields; the envelope contains a digest and is not a public DTO.
type ProjectCommandAuditRow struct {
Offset int64
ActorName string // empty on old rows or when no trusted account name was recorded
Envelope agentcommand.AuditEnvelope
}
// ErrAuditCutoffUnsafe prevents a PostgreSQL page from claiming a fixed cutoff
// when the ID generator, sequence, or all-writer database guard cannot prove
// commit order.
var ErrAuditCutoffUnsafe = errors.New("store: project audit cutoff unsafe")
func (s *Store) requireProjectAuditCommittedPrefix() error {
if s.dialect.name() != "postgres" {
return nil
} // SQLite serializes writers.
var safe bool
if err := s.db.QueryRow(`SELECT project_audit_cutoff_safe('command_audit'::regclass) AND project_audit_writers_guarded('command_audit'::regclass)`).Scan(&safe); err != nil {
return fmt.Errorf("%w: %v", ErrAuditCutoffUnsafe, err)
}
if !safe {
return ErrAuditCutoffUnsafe
}
return nil
}
// ListProjectCommandAudits reads the project resolved from a trusted conversation
// at append time. Reassigning or deleting that conversation cannot rewrite the
// original project. Legacy rows without this snapshot are excluded: current
// project membership cannot establish their historical project attribution.
// Only the authenticated owner can read this first private slice.
func (s *Store) ListProjectCommandAudits(actorOwner, projectID string, before, cutoff int64, limit int) ([]ProjectCommandAuditRow, int64, int64, error) {
if actorOwner == "" || projectID == "" || before < 0 || cutoff < 0 || (before != 0 && cutoff == 0) {
return nil, 0, 0, errors.New("project audit: invalid scope or cursor")
}
if err := s.requireProjectAuditCommittedPrefix(); err != nil {
return nil, 0, 0, err
}
if limit <= 0 || limit > 200 {
limit = 50
}
if cutoff == 0 {
if err := s.db.QueryRow(s.q(`SELECT COALESCE(MAX(id),0) FROM command_audit
WHERE project_id=? AND owner=?`), projectID, actorOwner).Scan(&cutoff); err != nil {
return nil, 0, 0, err
}
}
rows, err := s.db.Query(s.q(`SELECT id, actor_name, payload FROM command_audit
WHERE project_id=? AND owner=? AND id<=? AND (?=0 OR id<?)
ORDER BY id DESC LIMIT ?`), projectID, actorOwner, cutoff, before, before, limit+1)
if err != nil {
return nil, 0, 0, err
}
defer rows.Close()
out := make([]ProjectCommandAuditRow, 0, limit+1)
for rows.Next() {
var row ProjectCommandAuditRow
var raw string
if err := rows.Scan(&row.Offset, &row.ActorName, &raw); err != nil {
return nil, 0, 0, err
}
if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil {
return nil, 0, 0, err
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, 0, 0, err
}
if len(out) <= limit {
return out, cutoff, 0, nil
}
out = out[:limit]
return out, cutoff, out[len(out)-1].Offset, nil
}
// ListAudits 审计查询(owner 作用域,时间倒序;conversationID 空 = 该租户全局)。
// owner 为空视为无作用域:不返回任何行(防调用方漏传租户时把未归属存量行
// 全部泄出——服务端认证层恒注入非空 owner,这里是纵深防御)。
// v18 回填时 actor 为空的存量行 owner 保持空串,不匹配任何租户:这些行不再经
// API 返回(数据保留在表内,可直查数据库取证)——见 v18 迁移注释。
func (s *Store) ListAudits(owner, conversationID string, limit int) ([]agentcommand.AuditEnvelope, error) {
if owner == "" {
return []agentcommand.AuditEnvelope{}, nil
}
if limit <= 0 || limit > 500 {
limit = 100
}
var (
rows *sql.Rows
err error
)
if conversationID == "" {
rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit WHERE owner=? ORDER BY time DESC LIMIT ?`), owner, limit)
} else {
rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit WHERE owner=? AND conversation_id=? ORDER BY time DESC LIMIT ?`), owner, conversationID, limit)
}
if err != nil {
return nil, err
}
defer rows.Close()
out := []agentcommand.AuditEnvelope{}
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return nil, err
}
var env agentcommand.AuditEnvelope
if err := json.Unmarshal([]byte(raw), &env); err != nil {
return nil, err
}
out = append(out, env)
}
return out, rows.Err()
}