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
112 lines
3.9 KiB
Go
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
|
|
}
|