Files
Agent-System-Backend/internal/settings/migration.go
T
weichen f965c5877f Enforce settings ownership and versioned shared configuration
Why: Personal project rows and credentials could override system policy and split shared business configuration by actor.

Evidence: SELF receipts /tmp/project-settings-backend-full-20261014-973d52c1.json and /tmp/rdm-settings-backend-final-green-20261014-03.json preserve full commands, object snapshots and output. PostgreSQL and current-head CI remain NOT_RUN.

Impact: Add scope-aware APIs, a fail-closed optional project configuration authorizer, CAS updates and explicit atomic migration. Preserve legacy rows and private preferences; protected secrets and runtime model routes obey system ownership.

Refs: P1/P2/P8, docs/records/2026-10-14-rdm-settings-completion-contract.md
2026-10-14 02:28:41 +00:00

112 lines
3.9 KiB
Go

package settings
import (
core "agent-system-core"
"fmt"
"reflect"
"sort"
"strings"
)
// MigrationEntry deliberately includes no values, masks, or value digests. Raw
// recovery data stays in the existing protected DB rows/snapshots.
type MigrationEntry struct {
Owner string `json:"owner"`
Scope string `json:"scope"`
Path string `json:"path"`
Status string `json:"status"`
Reason string `json:"reason"`
LastSnapshotAt string `json:"lastSnapshotAt,omitempty"`
}
type MigrationInventory struct {
DryRun bool `json:"dryRun"`
Entries []MigrationEntry `json:"entries"`
}
// MigrationInventory is read-only. Settings rows have no trustworthy per-key
// modification timestamps, so snapshot time is only a recovery reference. It
// never guesses the newest owner or promotes a personal credential/configuration.
func (s *Service) MigrationInventory() (MigrationInventory, error) {
out := MigrationInventory{DryRun: true, Entries: []MigrationEntry{}}
refs, err := s.st.ListSettingsScopes()
if err != nil {
return out, err
}
type candidate struct {
index int
value any
}
groups := map[string][]candidate{}
for _, ref := range refs {
if ref.Owner == OwnerSystemRow || (ref.Scope != ScopeUser && !strings.HasPrefix(ref.Scope, ScopeProjectPrefix)) {
continue
}
values, err := s.st.GetSettingsScope(ref.Owner, ref.Scope)
if err != nil {
return out, err
}
values = normalizeKeys(values)
stamp := ""
if at, ok := s.st.LatestSnapshotAt(ref.Owner, ref.Scope); ok {
stamp = at.UTC().Format("2006-01-02T15:04:05.999999999Z07:00")
}
for _, d := range s.Descriptors() {
value, present := valueAtPath(values, d.Key)
if !present {
continue
}
entry := MigrationEntry{Owner: ref.Owner, Scope: ref.Scope, Path: d.Key, LastSnapshotAt: stamp}
if d.Scope == "system" {
entry.Status = "quarantined"
entry.Reason = "系统受管旧覆盖已退出生效链,原值保留;禁止自动提升为系统配置"
} else if d.Scope == ProjectSettingScope && strings.HasPrefix(ref.Scope, ScopeProjectPrefix) {
entry.Status = "requires-decision"
entry.Reason = "旧项目业务副本保留;按键选择权威来源后才能迁移,快照时间不等于修改时间"
group := ref.Scope + ":" + d.Key
groups[group] = append(groups[group], candidate{len(out.Entries), value})
} else {
continue
}
out.Entries = append(out.Entries, entry)
}
known := map[string]bool{}
for _, d := range s.Descriptors() {
known[d.Key] = true
}
for _, path := range core.TouchesManagedSettings(values, s.ManagedSettingPaths()) {
if !known[path] {
out.Entries = append(out.Entries, MigrationEntry{Owner: ref.Owner, Scope: ref.Scope, Path: path, Status: "quarantined", Reason: "系统受管命名空间的旧未知项已退出生效链,原值保留", LastSnapshotAt: stamp})
}
}
}
for _, group := range groups {
if len(group) < 2 {
continue
}
for _, item := range group[1:] {
if !reflect.DeepEqual(group[0].value, item.value) {
for _, candidate := range group {
out.Entries[candidate.index].Status = "conflict"
out.Entries[candidate.index].Reason = "同项目同键的多 owner 副本值不同;必须由项目负责人逐键裁定,禁止自动合并"
}
break
}
}
}
secrets, err := s.st.SecretInventory()
if err != nil {
return out, err
}
for _, entry := range secrets {
if entry.Owner != "" && ProtectedSecretService(entry.Service) {
out.Entries = append(out.Entries, MigrationEntry{Owner: entry.Owner, Scope: "secret_keys", Path: entry.Service, Status: "quarantined", Reason: fmt.Sprintf("%d 条个人密钥已退出生效链,原记录保留;系统凭据须单独配置", entry.Count)})
}
}
sort.Slice(out.Entries, func(i, j int) bool {
a, b := out.Entries[i], out.Entries[j]
return a.Scope+"\x00"+a.Path+"\x00"+a.Owner < b.Scope+"\x00"+b.Path+"\x00"+b.Owner
})
return out, nil
}