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.
136 lines
4.2 KiB
Go
136 lines
4.2 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"agent-system-backend/internal/store"
|
|
)
|
|
|
|
// Two processes share one isolated PostgreSQL schema. The child has already
|
|
// accepted a stale early account read when the parent commits disable; only
|
|
// the final Backend transaction can stop the physical rename.
|
|
func TestPGDelegatedProjectionDisableAcrossProcesses(t *testing.T) {
|
|
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
|
|
if baseDSN == "" {
|
|
t.Skip("isolated PostgreSQL DSN required")
|
|
}
|
|
separator := "?"
|
|
if strings.Contains(baseDSN, "?") {
|
|
separator = "&"
|
|
}
|
|
if os.Getenv("IAM_U3_DELEGATE_CHILD") == "1" {
|
|
dsn := baseDSN + separator + "search_path=" + os.Getenv("IAM_U3_DELEGATE_SCHEMA") + ",public"
|
|
st, err := store.OpenExistingPostgres(dsn, filepath.Join(t.TempDir(), "artifacts"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
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 {
|
|
if err := os.WriteFile(os.Getenv("IAM_U3_DELEGATE_READY"), []byte("ready"), 0o600); err != nil {
|
|
return err
|
|
}
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
if _, err := os.Stat(os.Getenv("IAM_U3_DELEGATE_RELEASE")); err == nil {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return errors.New("parent did not release paused child")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
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", os.Getenv("IAM_U3_DELEGATE_PROJECT"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := projection.RenameOwnedProject(os.Getenv("IAM_U3_DELEGATE_PROJECT"), "late"); !errors.Is(err, store.ErrAccountForbidden) {
|
|
t.Fatalf("late physical rename was not fenced: %v", err)
|
|
}
|
|
return
|
|
}
|
|
st := openDirectoryTestStore(t)
|
|
if err := st.EnsureSystemIdentity("u3-delegate-two-process", "pg"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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)
|
|
}
|
|
var schema string
|
|
if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
barrier := t.TempDir()
|
|
ready, release := filepath.Join(barrier, "ready"), filepath.Join(barrier, "release")
|
|
cmd := exec.Command(os.Args[0], "-test.run=^TestPGDelegatedProjectionDisableAcrossProcesses$")
|
|
cmd.Env = append(os.Environ(), "IAM_U3_DELEGATE_CHILD=1", "IAM_U3_DELEGATE_SCHEMA="+schema,
|
|
"IAM_U3_DELEGATE_PROJECT="+string(project.ID), "IAM_U3_DELEGATE_READY="+ready, "IAM_U3_DELEGATE_RELEASE="+release)
|
|
var output bytes.Buffer
|
|
cmd.Stdout, cmd.Stderr = &output, &output
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
if _, err := os.Stat(ready); err == nil {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
_ = cmd.Process.Kill()
|
|
t.Fatalf("child did not reach stale early check: %s", strings.ReplaceAll(output.String(), baseDSN, "[REDACTED]"))
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
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 := os.WriteFile(release, []byte("release"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cmd.Wait(); err != nil {
|
|
t.Fatalf("child failed: %v: %s", err, strings.ReplaceAll(output.String(), baseDSN, "[REDACTED]"))
|
|
}
|
|
got, err := st.ProjectMetadata(string(project.ID))
|
|
if err != nil || got.Name != "before" {
|
|
t.Fatalf("disabled operator's project rename persisted: %+v %v", got, err)
|
|
}
|
|
}
|