Why: RDM delegated rename rebuilt the creator Host and could commit a Backend project rename after the delegated operator was disabled.
Evidence: SQLite and isolated PostgreSQL targeted checks cover operator/creator disable order, two processes, sorted locks, auth-off local compatibility, and HTTP/command Host wiring; exact committed-head replay remains pending.
Impact: Adds a request-bound single-project extension capability and checks operator plus creator in the final Backend transaction. Global ScopedHost cannot derive delegated authority; RDM cross-database atomicity remains outside the promise.
Refs: Approved Documents delegated-projection amendment 5783940, U3 A1-A4 candidate adcd8fe, Dev feature/iam-u3-account-directory-recovery.
354 lines
12 KiB
Go
354 lines
12 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"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
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()
|
|
for _, owner := range []string{"alice", "bob"} {
|
|
if err := st.EnsureUser(owner, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
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)
|
|
if err := st.EnsureUser("bob", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestRequestBoundDelegatedProjectionFencesRealOperator(t *testing.T) {
|
|
st, err := store.OpenSQLite(t.TempDir() + "/delegated-project.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
for _, account := range []struct {
|
|
owner string
|
|
admin bool
|
|
}{{"admin", true}, {"alice", false}, {"bob", false}} {
|
|
if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
project, err := st.CreateProject("alice", "original")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
guard := accountEffectiveAuthorization(st, true)
|
|
check := func(owner string) error {
|
|
valid, err := guard(context.Background(), owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !valid {
|
|
return store.ErrAccountForbidden
|
|
}
|
|
return nil
|
|
}
|
|
slot := &projectLifecycleSlot{}
|
|
caps := hostcaps{st: st, projects: slot, accountGuard: check}
|
|
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}}
|
|
delegator, ok := any(request).(interface {
|
|
ProjectProjectionForCreator(creator, projectID string) (extension.ProjectHost, error)
|
|
})
|
|
if !ok {
|
|
t.Fatal("request-bound delegated projection capability missing")
|
|
}
|
|
projection, err := delegator.ProjectProjectionForCreator("alice", string(project.ID))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if projection.Owner() != "alice" {
|
|
t.Fatalf("trusted projection owner=%s", projection.Owner())
|
|
}
|
|
if _, ok := projection.(extension.Host); ok {
|
|
t.Fatal("single-project projection exposed broad creator Host capabilities")
|
|
}
|
|
if _, err := projection.RenameOwnedProject(string(project.ID), "delegated"); err != nil {
|
|
t.Fatalf("active delegated rename: %v", err)
|
|
}
|
|
if _, err := projection.RenameOwnedProject("another-project", "hijack"); !errors.Is(err, store.ErrNotFound) {
|
|
t.Fatalf("projection escaped bound project: %v", err)
|
|
}
|
|
if _, err := projection.EnsureProject("another-project", "hijack"); !errors.Is(err, store.ErrAccountForbidden) {
|
|
t.Fatalf("projection created another project: %v", err)
|
|
}
|
|
bob, err := st.GetAccountByOwner("bob")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := projection.RenameOwnedProject(string(project.ID), "late"); !errors.Is(err, store.ErrAccountForbidden) {
|
|
t.Fatalf("disabled real operator renamed creator project: %v", err)
|
|
}
|
|
got, err := st.ProjectMetadata(string(project.ID))
|
|
if err != nil || got.Name != "delegated" {
|
|
t.Fatalf("late rename changed project: %+v %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestDelegatedProjectionRejectsDisableBetweenEarlyCheckAndWrite(t *testing.T) {
|
|
var st, other *store.Store
|
|
var err error
|
|
if baseDSN := os.Getenv("AGENT_PG_TEST_DSN"); baseDSN != "" {
|
|
st = openDirectoryTestStore(t)
|
|
if err := st.EnsureSystemIdentity("u3-delegated-projection", "pg"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var schema string
|
|
if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
separator := "?"
|
|
if strings.Contains(baseDSN, "?") {
|
|
separator = "&"
|
|
}
|
|
other, err = store.OpenExistingPostgres(baseDSN+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "other-artifacts"))
|
|
} else {
|
|
db := filepath.Join(t.TempDir(), "shared.db")
|
|
st, err = store.OpenSQLite(db)
|
|
if err == nil {
|
|
other, err = store.OpenExistingSQLite(db)
|
|
}
|
|
if st != nil {
|
|
defer st.Close()
|
|
}
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer other.Close()
|
|
for _, account := range []struct {
|
|
owner string
|
|
admin bool
|
|
}{{"admin", true}, {"alice", false}, {"bob", false}} {
|
|
if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
project, err := st.CreateProject("alice", "before")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entered, release := make(chan struct{}), make(chan struct{})
|
|
var bobChecks atomic.Int32
|
|
check := func(owner string) error {
|
|
state, err := st.GetUserAuthState(owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !state.Enabled {
|
|
return store.ErrAccountForbidden
|
|
}
|
|
if owner == "bob" && bobChecks.Add(1) == 2 {
|
|
close(entered)
|
|
<-release
|
|
}
|
|
return nil
|
|
}
|
|
slot := &projectLifecycleSlot{}
|
|
caps := hostcaps{st: st, projects: slot, accountGuard: check}
|
|
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
host := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}}
|
|
projection, err := host.ProjectProjectionForCreator("alice", string(project.ID))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
written := make(chan error, 1)
|
|
go func() {
|
|
_, err := projection.RenameOwnedProject(string(project.ID), "late")
|
|
written <- err
|
|
}()
|
|
select {
|
|
case <-entered:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("rename did not reach paused early account check")
|
|
}
|
|
bob, err := st.GetAccountByOwner("bob")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := other.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
close(release)
|
|
select {
|
|
case err := <-written:
|
|
if !errors.Is(err, store.ErrAccountForbidden) {
|
|
t.Fatalf("late delegated rename committed: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("late rename did not return")
|
|
}
|
|
got, err := st.ProjectMetadata(string(project.ID))
|
|
if err != nil || got.Name != "before" {
|
|
t.Fatalf("project changed after disable: %+v %v", got, err)
|
|
}
|
|
}
|