Files
Agent-System-Backend/internal/settings/project_metadata.go
T
weichen d2f96e79bb Admit private member uploads and preserve masked metadata edits
Why:
Nested unchanged credential masks left empty parents that looked like managed
subtree overwrites. Shared members also hit an owner-only upload guard, while
upload ingestion cards could disclose private filenames and parser metadata.

Evidence:
SELF independent nested-mask probe, HTTP nested/mixed writeback, member upload,
project card privacy and authorization-change regressions failed before each fix
and passed after it. Affected settings/server suite passed; exact final-object
build and independent replay remain delivery gates owned by the task coordinator.

Impact:
Prune only parents emptied by ignored masks; explicit empty/real managed writes
stay rejected. Registered uploads check live work and trusted project existence,
keep originals owned by the actor, and recheck before persistence. Suppress only
upload project cards when no private stream exists; explicit structure cards and
unregistered upload behavior remain available.

Refs:
Dev project-member-access plan r1 Task 1/R5; independent nested mask P2;
2026-10-13 composed member-upload and private project-stream acceptance findings
2026-10-13 10:23:37 +00:00

109 lines
2.9 KiB
Go

package settings
import (
core "agent-system-core"
"strings"
)
// MaskedProjectMetadata returns a detached credential-safe view without changing
// stored values or dotted service/configuration key shapes. The builtin and
// plugin secret schema plus the existing secret-leaf fallback cover legacy data.
func (s *Service) MaskedProjectMetadata(values map[string]any) map[string]any {
if values == nil {
return map[string]any{}
}
schema := Schema()
if s != nil {
schema = append(schema, s.extraSchema...)
}
secrets := map[string]bool{}
for _, d := range schema {
if d.Type == core.SettingSecret || d.Secret {
secrets[d.Key] = true
}
}
return maskedProjectValue(values, "", secrets).(map[string]any)
}
func maskedProjectValue(value any, path string, secrets map[string]bool) any {
leaf := path
if i := strings.LastIndexByte(leaf, '.'); i >= 0 {
leaf = leaf[i+1:]
}
if secrets[path] || isSecretKey(leaf) {
if value == nil {
return nil
}
if text, ok := value.(string); ok && text == "" {
return text
}
return MaskPlaceholder
}
switch v := value.(type) {
case map[string]any:
if v == nil {
return v
}
out := make(map[string]any, len(v))
for key, item := range v {
child := key
if path != "" {
child = path + "." + key
}
out[key] = maskedProjectValue(item, child, secrets)
}
return out
case []any:
if v == nil {
return v
}
out := make([]any, len(v))
for i, item := range v {
out[i] = maskedProjectValue(item, path, secrets)
}
return out
default:
return value
}
}
// PrepareProjectMetadataWrite applies the same managed-path and mask-placeholder
// rules as project settings, while preserving the metadata store's key shapes.
// Project metadata lives in projects.settings, not the settings scope table.
func (s *Service) PrepareProjectMetadataWrite(values map[string]any) (map[string]any, error) {
incoming := dropProjectMaskPlaceholders(values)
if err := s.checkManagedWrite(normalizeKeys(incoming)); err != nil {
return nil, err
}
return incoming, nil
}
// dropProjectMaskPlaceholders prunes a parent only when a previously nonempty
// object became empty through mask removal. Explicit empty objects still express
// an overwrite and must reach the managed-path guard unchanged.
func dropProjectMaskPlaceholders(values map[string]any) map[string]any {
out := make(map[string]any, len(values))
for key, value := range values {
if child, ok := value.(map[string]any); ok {
prepared := dropProjectMaskPlaceholders(child)
if len(child) > 0 && len(prepared) == 0 {
continue
}
out[key] = prepared
continue
}
if text, ok := value.(string); ok && text == MaskPlaceholder {
continue
}
out[key] = value
}
return out
}
func (s *Service) checkManagedWrite(incoming map[string]any) error {
if hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 {
return &ErrManagedPath{Paths: hits}
}
return nil
}