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
164 lines
6.4 KiB
Go
164 lines
6.4 KiB
Go
package app
|
|
|
|
import (
|
|
"agent-system-backend/internal/server"
|
|
"agent-system-backend/internal/settings"
|
|
"agent-system-backend/internal/store"
|
|
"agent-system-backend/pkg/extension"
|
|
core "agent-system-core"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func completeLifecycle() extension.ProjectLifecycle {
|
|
return extension.ProjectLifecycle{
|
|
List: func(context.Context, extension.Host) ([]core.Project, error) { return nil, nil },
|
|
Create: func(context.Context, extension.Host, string) (core.Project, error) { return core.Project{}, nil },
|
|
Rename: func(context.Context, extension.Host, string, string) (core.Project, error) {
|
|
return core.Project{}, nil
|
|
},
|
|
Archive: func(context.Context, extension.Host, string) error { return nil },
|
|
Work: func(context.Context, string, core.ID) (bool, error) { return true, nil },
|
|
}
|
|
}
|
|
func TestProjectLifecycleOptionalRegistration(t *testing.T) {
|
|
slot := &projectLifecycleSlot{}
|
|
reg := pluginRegistry{pluginID: "rdm", caps: hostcaps{projects: slot}}
|
|
if err := reg.RegisterProjectLifecycle(extension.ProjectLifecycle{}); err == nil {
|
|
t.Fatal("incomplete registration accepted")
|
|
}
|
|
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err == nil {
|
|
t.Fatal("duplicate registration accepted")
|
|
}
|
|
if err := (compiledRegistry{}).RegisterProjectLifecycle(completeLifecycle()); err == nil {
|
|
t.Fatal("declarative callback accepted")
|
|
}
|
|
rr := recordRegistry{Registry: pluginRegistry{pluginID: "other", caps: hostcaps{projects: &projectLifecycleSlot{}}}}
|
|
optional, ok := any(rr).(extension.ProjectLifecycleRegistry)
|
|
if !ok {
|
|
t.Fatal("record registry hides optional registration")
|
|
}
|
|
if err := optional.RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) {
|
|
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
alice := inprocHost{owner: "alice", caps: hostcaps{st: st}}
|
|
p, err := alice.EnsureProject("stable", "Shared")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
auth := &projectAuthz{}
|
|
auth.set("rdm", func(owner string, id core.ID) (bool, error) { return owner == "bob" && id == core.ID(p.ID), nil })
|
|
bob := inprocHost{owner: "bob", caps: hostcaps{st: st, authz: auth}}
|
|
if _, err := bob.ProjectMetadata(p.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := bob.OwnedProject(p.ID); !errors.Is(err, store.ErrNotFound) {
|
|
t.Fatalf("owned lookup=%v", err)
|
|
}
|
|
if _, err := bob.RenameOwnedProject(p.ID, "Other"); !errors.Is(err, store.ErrNotFound) {
|
|
t.Fatalf("shared rename=%v", err)
|
|
}
|
|
if _, err := bob.EnsureProject(p.ID, p.Name); !errors.Is(err, store.ErrNotFound) {
|
|
t.Fatalf("shared ensure=%v", err)
|
|
}
|
|
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { return false, nil })
|
|
if _, err := bob.ProjectMetadata(p.ID); !errors.Is(err, store.ErrNotFound) {
|
|
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")
|
|
}
|
|
}
|