Protect shared project metadata credentials and managed settings

Why:
Legacy project metadata can contain credentials outside the settings scope table.
Sharing raw projection settings exposed them, and generic project PATCH bypassed
the system-managed path guard already enforced by project settings endpoints.

Evidence:
SELF member Host regression failed before the fix. The fixed 4f72aaf server
baseline overlay reproduced HTTP metadata leakage and managed PATCH returning 200.
SELF shared Host-to-HTTP, immutable mask, dotted/schema/fallback, public mutation
and atomic managed-write regressions passed; uncached full suite, isolated PG
Store suite and static build passed. Independent delta replay remains a gate.

Impact:
Registered lifecycle metadata returns detached credential-masked views while
preserving service/configuration keys and persisted data. Generic project PATCH
shares the existing managed validator before rename or settings mutation; allowed
metadata key shapes and raw owner-only management lookups remain available.

Refs:
Dev project-member-access plan r1 Task 1 and R5; approved hard secret boundary;
2026-10-13 shared project metadata review follow-up
This commit is contained in:
2026-10-13 09:46:20 +00:00
parent 4f72aaf3d8
commit c4435d1415
9 changed files with 335 additions and 7 deletions
+3
View File
@@ -98,6 +98,9 @@ func (h inprocHost) ProjectMetadata(id string) (core.Project, error) {
if err != nil { if err != nil {
return core.Project{}, err return core.Project{}, err
} }
if h.caps.projects != nil && h.caps.projects.active() {
p.Settings = h.caps.set.MaskedProjectMetadata(p.Settings)
}
if p.Owner == h.owner { if p.Owner == h.owner {
return p, nil return p, nil
} }
+86
View File
@@ -1,11 +1,16 @@
package app package app
import ( import (
"agent-system-backend/internal/server"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store" "agent-system-backend/internal/store"
"agent-system-backend/pkg/extension" "agent-system-backend/pkg/extension"
core "agent-system-core" core "agent-system-core"
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"net/http/httptest"
"testing" "testing"
) )
@@ -75,3 +80,84 @@ func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) {
t.Fatalf("V2 denial ignored=%v", err) t.Fatalf("V2 denial ignored=%v", err)
} }
} }
func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
p, err := st.CreateProject("alice", "Shared")
if err != nil {
t.Fatal(err)
}
raw := map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-nested-secret"}}, "models.embedding.apiKey": "fixture-flat-secret", "legacy.token": "fixture-legacy-secret", "plugins.private": map[string]any{"credential": "fixture-schema-secret"}, "display": map[string]any{"caption": "Shared configuration"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
t.Fatal(err)
}
svc := settings.NewService(st)
svc.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}})
slot := &projectLifecycleSlot{}
auth := &projectAuthz{}
caps := hostcaps{st: st, set: svc, projects: slot, authz: auth}
provider := completeLifecycle()
provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) {
project, err := h.(extension.ProjectHost).ProjectMetadata(p.ID)
return []core.Project{project}, err
}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
t.Fatal(err)
}
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) {
return q.Actor == "bob" && q.ResourceType == "project" && q.ResourceID == p.ID, nil
})
for _, actor := range []string{"bob", "alice"} {
h := inprocHost{owner: actor, caps: caps}
view, err := h.ProjectMetadata(p.ID)
if err != nil {
t.Fatal(err)
}
encoded, _ := json.Marshal(view.Settings)
for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} {
if bytes.Contains(encoded, []byte(value)) {
t.Fatalf("%s metadata exposed a fixture credential", actor)
}
}
if view.Settings["rdm.projectId"] != "canonical" || view.Settings["rdm.state"] != "ready" || view.Settings["display"].(map[string]any)["caption"] != "Shared configuration" {
t.Fatal("allowed service/configuration metadata changed")
}
}
// Exercise the real registry -> actor-scoped Host -> public HTTP list chain.
srv := server.New(st, nil, nil, nil, svc, nil)
srv.Auth = server.NewAuthenticator(st, true)
slot.wire(srv, caps)
key, err := st.CreateAPIKey("bob", "test")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest("GET", "/api/projects", nil)
request.Header.Set("Authorization", "Bearer "+key)
response := httptest.NewRecorder()
srv.Handler().ServeHTTP(response, request)
if response.Code != 200 {
t.Fatalf("member list=%d", response.Code)
}
for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} {
if bytes.Contains(response.Body.Bytes(), []byte(value)) {
t.Fatal("actor-scoped lifecycle list exposed a fixture credential")
}
}
if !bytes.Contains(response.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) {
t.Fatal("actor-scoped list lost canonical metadata")
}
owned, err := (inprocHost{owner: "alice", caps: caps}).OwnedProject(p.ID)
if err != nil {
t.Fatal(err)
}
before, _ := json.Marshal(raw)
after, _ := json.Marshal(owned.Settings)
if !bytes.Equal(before, after) {
t.Fatal("metadata read mutated persisted owner settings")
}
}
+9
View File
@@ -71,3 +71,12 @@ func writeProjectJSON(w http.ResponseWriter, p any, err error) {
} }
writeJSON(w, http.StatusOK, p) writeJSON(w, http.StatusOK, p)
} }
// publicProjectMetadata also masks provider-returned metadata, since providers
// may obtain raw owner-management rows before producing a shared public view.
func (s *Server) publicProjectMetadata(p core.Project) core.Project {
if s.projectLifecycle != nil {
p.Settings = s.set.MaskedProjectMetadata(p.Settings)
}
return p
}
+82
View File
@@ -9,6 +9,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -197,3 +198,84 @@ func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) {
t.Fatalf("settings lost service metadata: %d %s", w.Code, w.Body.String()) t.Fatalf("settings lost service metadata: %d %s", w.Code, w.Body.String())
} }
} }
func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := st.CreateAPIKey("alice", "test")
bob, _ := st.CreateAPIKey("bob", "test")
p, _ := st.CreateProject("alice", "Shared")
raw := map[string]any{"models.chat.apiKey": "fixture-key", "custom": map[string]any{"token": "fixture-token"}, "custom.api_key": "fixture-flat-token", "plugin.private.credential": "fixture-schema-token", "display": map[string]any{"caption": "Shared"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
t.Fatal(err)
}
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugin.private.credential", Secret: true}})
read := func() (core.Project, error) { return st.GetProject("alice", p.ID) }
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := read()
return []core.Project{current}, err
}, Create: func(context.Context, extension.Host, string) (core.Project, error) { return read() }, Rename: func(context.Context, extension.Host, string, string) (core.Project, error) { return read() }}, nil)
for _, q := range []struct{ key, method, path, body string }{{bob, "GET", "/api/projects", ""}, {alice, "GET", "/api/projects", ""}, {alice, "POST", "/api/projects", `{"name":"New"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"settings":{"display":{"caption":"Updated"}}}`}} {
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
if w.Code != 200 {
t.Fatalf("public metadata %s=%d", q.method, w.Code)
}
for _, secret := range []string{"fixture-key", "fixture-token", "fixture-flat-token", "fixture-schema-token"} {
if bytes.Contains(w.Body.Bytes(), []byte(secret)) {
t.Fatalf("%s %s leaked a fixture credential", q.method, q.path)
}
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) || !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.state":"ready"`)) {
t.Fatal("service metadata lost")
}
}
owned, err := read()
if err != nil {
t.Fatal(err)
}
if owned.Settings["models.chat.apiKey"] != "fixture-key" || owned.Settings["custom"].(map[string]any)["token"] != "fixture-token" {
t.Fatal("public metadata masking changed stored credentials")
}
}
func TestGenericProjectPatchRejectsManagedSettingsAtomically(t *testing.T) {
for _, registered := range []bool{false, true} {
t.Run(fmt.Sprint(registered), func(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Original")
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}})
if registered {
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := st.GetProject("alice", p.ID)
return []core.Project{current}, err
}, Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) {
if err := st.RenameProject("alice", id, name); err != nil {
return core.Project{}, err
}
return st.GetProject("alice", id)
}}, nil)
}
for _, body := range []string{`{"name":"Changed","settings":{"models":{"chat":{"apiKey":"fixture-key"}},"display":{"caption":"Forbidden partial write"}}}`, `{"settings":{"models.chat.apiKey":"fixture-key"}}`, `{"settings":{"models":"smash"}}`, `{"settings":{"plugins.private.managed":"override"}}`} {
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body)
if w.Code != 400 {
t.Fatalf("managed patch=%d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil {
t.Fatal(err)
}
if current.Name != "Original" || len(current.Settings) != 0 {
t.Fatal("rejected managed patch applied a partial mutation")
}
}
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"custom.caption":"Allowed"}}`)
if w.Code != 200 {
t.Fatalf("allowed patch=%d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil || current.Settings["custom.caption"] != "Allowed" {
t.Fatal("allowed metadata shape changed")
}
})
}
}
+11 -3
View File
@@ -627,7 +627,7 @@ func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
} }
out := make([]projectView, 0, len(ps)) out := make([]projectView, 0, len(ps))
for _, p := range ps { for _, p := range ps {
out = append(out, projectView{Project: p, Shared: p.Owner != owner}) out = append(out, projectView{Project: s.publicProjectMetadata(p), Shared: p.Owner != owner})
} }
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
return return
@@ -674,7 +674,7 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
return return
} }
p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name)) p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name))
writeProjectJSON(w, p, err) writeProjectJSON(w, s.publicProjectMetadata(p), err)
return return
} }
p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name)) p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name))
@@ -691,6 +691,14 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
if req.Settings != nil {
values, err := s.set.PrepareProjectMetadataWrite(req.Settings)
if err != nil {
s.writeSettingsMerge(w, func() error { return err })
return
}
req.Settings = values
}
if s.projectLifecycle != nil { if s.projectLifecycle != nil {
var p core.Project var p core.Project
var err error var err error
@@ -732,7 +740,7 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
writeProjectJSON(w, p, err) writeProjectJSON(w, s.publicProjectMetadata(p), err)
return return
} }
if req.Name != nil { if req.Name != nil {
+86
View File
@@ -0,0 +1,86 @@
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 := dropMaskPlaceholders(values)
if err := s.checkManagedWrite(normalizeKeys(incoming)); err != nil {
return nil, err
}
return incoming, nil
}
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
}
@@ -0,0 +1,53 @@
package settings
import (
core "agent-system-core"
"bytes"
"encoding/json"
"testing"
)
func TestMaskedProjectMetadataCopiesSecretViews(t *testing.T) {
s := NewService(nil)
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}})
raw := map[string]any{"models.chat.apiKey": "fixture-known", "legacy.token": "fixture-flat", "plugins.private": map[string]any{"credential": "fixture-schema"}, "nested": []any{map[string]any{"api_key": "fixture-array"}}, "opaque.secret": map[string]any{"value": "fixture-opaque"}, "custom.token": []any{"fixture-list"}, "custom.caption": "Allowed", "rdm.projectId": "canonical", "rdm.state": "ready"}
before, _ := json.Marshal(raw)
view := s.MaskedProjectMetadata(raw)
after, _ := json.Marshal(raw)
if string(before) != string(after) {
t.Fatal("read-only metadata view mutated its source")
}
encoded, _ := json.Marshal(view)
for _, secret := range []string{"fixture-known", "fixture-flat", "fixture-schema", "fixture-array", "fixture-opaque", "fixture-list"} {
if bytes.Contains(encoded, []byte(secret)) {
t.Fatal("metadata view exposed a fixture secret")
}
}
if view["custom.caption"] != "Allowed" || view["rdm.projectId"] != "canonical" || view["rdm.state"] != "ready" {
t.Fatal("nonsecret metadata shape/value changed")
}
if view["legacy.token"] != MaskPlaceholder || view["opaque.secret"] != MaskPlaceholder || view["custom.token"] != MaskPlaceholder {
t.Fatal("secret fallback did not mask complete dotted values")
}
}
func TestPrepareProjectMetadataWriteUsesManagedGuard(t *testing.T) {
s := NewService(nil)
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}})
for _, raw := range []map[string]any{{"models.chat.apiKey": "fixture"}, {"models": map[string]any{"chat": map[string]any{"apiKey": "fixture"}}}, {"models": "smash"}, {"plugins.private.managed": "override"}} {
if _, err := s.PrepareProjectMetadataWrite(raw); err == nil {
t.Fatal("managed metadata write accepted")
}
}
raw := map[string]any{"custom.caption": "Allowed", "custom.token": MaskPlaceholder, "models.chat.apiKey": MaskPlaceholder}
view, err := s.PrepareProjectMetadataWrite(raw)
if err != nil {
t.Fatal(err)
}
if len(view) != 1 || view["custom.caption"] != "Allowed" {
t.Fatal("metadata writes must preserve allowed dotted keys and ignore mask placeholders")
}
if raw["custom.token"] != MaskPlaceholder {
t.Fatal("write preparation mutated its input")
}
}
+3 -3
View File
@@ -167,7 +167,7 @@ func (s *Service) SetExtraSchema(descs []core.SettingDesc) { s.extraSchema = des
// ManagedSettingPaths 返回全部受管键路径(内置 schema + 插件声明)。 // ManagedSettingPaths 返回全部受管键路径(内置 schema + 插件声明)。
func (s *Service) ManagedSettingPaths() []string { func (s *Service) ManagedSettingPaths() []string {
paths := core.ManagedPaths(Schema()) paths := core.ManagedPaths(Schema())
if len(s.extraSchema) > 0 { if s != nil && len(s.extraSchema) > 0 {
paths = append(paths, core.ManagedPaths(s.extraSchema)...) paths = append(paths, core.ManagedPaths(s.extraSchema)...)
} }
return paths return paths
@@ -238,8 +238,8 @@ func (s *Service) MergeSystem(values map[string]any) error {
func (s *Service) mergeScope(owner, scope string, values map[string]any) error { func (s *Service) mergeScope(owner, scope string, values map[string]any) error {
incoming := prepareWrite(values) incoming := prepareWrite(values)
if scope != ScopeSystem { if scope != ScopeSystem {
if hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 { if err := s.checkManagedWrite(incoming); err != nil {
return &ErrManagedPath{Paths: hits} return err
} }
} }
if err := s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) { if err := s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) {
+2 -1
View File
@@ -6,7 +6,8 @@ import (
) )
// ProjectHost is an optional actor-scoped host projection capability. Metadata // ProjectHost is an optional actor-scoped host projection capability. Metadata
// can be shared by an authorizer; owned lookups and mutations remain owner-only. // can be shared by an authorizer; registered-lifecycle metadata masks credentials.
// OwnedProject remains an owner-only management lookup; mutations remain owner-only.
type ProjectHost interface { type ProjectHost interface {
Owner() string Owner() string
OwnedProjects() ([]core.Project, error) OwnedProjects() ([]core.Project, error)