Files
Agent-System-Backend/internal/settings/secrets.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

185 lines
4.9 KiB
Go

package settings
import (
"encoding/json"
"strings"
"time"
)
type secretSpec struct {
path string
service string
}
var secretSpecs = []secretSpec{
{"models.chat.apiKey", "models.chat"},
{"models.lightweight.apiKey", "models.lightweight"},
{"models.embedding.apiKey", "models.embedding"},
{"models.rerank.apiKey", "models.rerank"},
{"tools.tavily.apiKeys", "tools.tavily"},
}
// ProtectedSecretService is the system-owned credential namespace. Unknown
// model kinds are protected too; callers cannot evade policy by using a new key.
func ProtectedSecretService(service string) bool {
service = strings.TrimSpace(service)
return service == "models" || strings.HasPrefix(service, "models.") || service == "tools.tavily" || strings.HasPrefix(service, "tools.tavily.")
}
// PersonalCredentialReferenceAllowed protects global credentials from arbitrary
// user-declared HTTP destinations. Environment references are process authority,
// while unrelated personal service secrets may still be used by that user.
func (s *Service) PersonalCredentialReferenceAllowed(ref string) bool {
ref = strings.TrimSpace(ref)
if strings.HasPrefix(ref, "env:") || ProtectedSecretService(ref) {
return false
}
for _, d := range s.Descriptors() {
if d.Scope == "system" && (d.Secret || d.Type == "secret") && (ref == d.Key || strings.HasPrefix(ref, d.Key+".")) {
return false
}
}
return true
}
// LookupSecret resolves protected services only from system authority. A newly
// saved system form value (including explicit empty/revoked) supersedes legacy
// secret-table rows. Personal credentials remain usable for unrelated services.
func (s *Service) LookupSecret(owner, service string) (string, bool) {
if ProtectedSecretService(service) {
for _, spec := range secretSpecs {
if spec.service != service {
continue
}
system, err := s.SystemScope()
if err != nil {
return "", true
}
if value, exists := valueAtPath(normalizeKeys(system), spec.path); exists {
text, _ := value.(string)
if text == MaskPlaceholder {
return "", true
}
return text, true
}
}
return s.st.ActiveSecret(OwnerSystemRow, service)
}
if owner == "" {
owner = "local"
}
return s.st.ActiveSecret(owner, service)
}
func valueAtPath(values map[string]any, path string) (any, bool) {
var current any = values
for _, key := range strings.Split(path, ".") {
object, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = object[key]
if !ok {
return nil, false
}
}
return current, true
}
// SnapshotAndMigrateSecrets preserves protected snapshots and imports only an
// explicitly system-owned credential when its independent row is absent. It
// retains source values and disagreements for review; personal keys are not
// promoted. Mask placeholders are never usable credentials.
func (s *Service) SnapshotAndMigrateSecrets() error {
refs, err := s.st.ListSettingsScopes()
if err != nil {
return err
}
for _, ref := range refs {
cur, err := s.st.GetSettingsScope(ref.Owner, ref.Scope)
if err != nil {
return err
}
raw, err := json.Marshal(cur)
if err != nil {
return err
}
if _, err := s.st.SaveSnapshotIfChanged(ref.Owner, ref.Scope, string(raw), "startup"); err != nil {
return err
}
for _, spec := range secretSpecs {
// Never promote a personal/project credential to shared authority.
// Keep those rows intact for the protected migration inventory.
if ref.Owner != OwnerSystemRow || ref.Scope != ScopeSystem {
continue
}
val, ok := lookupString(cur, spec.path)
if !ok || val == MaskPlaceholder {
continue
}
has, err := s.st.HasSecret(ref.Owner, spec.service)
if err != nil {
return err
}
if !has {
if _, err := s.st.AddSecret(ref.Owner, spec.service, "迁入", val); err != nil {
return err
}
}
// Preserve the source, including disagreements with older table rows.
// LookupSecret makes this explicit system value authoritative.
}
}
return nil
}
func (s *Service) snapshotThrottled(owner, scope string) {
if at, ok := s.st.LatestSnapshotAt(owner, scope); ok && time.Since(at) < 10*time.Minute {
return
}
cur, err := s.st.GetSettingsScope(owner, scope)
if err != nil {
return
}
raw, err := json.Marshal(cur)
if err != nil {
return
}
_, _ = s.st.SaveSnapshotIfChanged(owner, scope, string(raw), "save")
}
func lookupString(m map[string]any, path string) (string, bool) {
var cur any = m
for _, p := range strings.Split(path, ".") {
obj, ok := cur.(map[string]any)
if !ok {
return "", false
}
cur, ok = obj[p]
if !ok {
return "", false
}
}
s, ok := cur.(string)
if !ok || strings.TrimSpace(s) == "" {
return "", false
}
return s, true
}
func deletePath(m map[string]any, path string) {
parts := strings.Split(path, ".")
cur := m
for i, p := range parts {
if i == len(parts)-1 {
delete(cur, p)
return
}
next, ok := cur[p].(map[string]any)
if !ok {
return
}
cur = next
}
}