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
168 lines
6.5 KiB
Go
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()
|
|
}
|