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
185 lines
4.9 KiB
Go
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
|
|
}
|
|
}
|