From 7492feab42132fd2e7ed2a0151eeb2bc203f7202 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 08:17:31 +0000 Subject: [PATCH] Fence delegated project projection by its real operator 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. --- docs/iam-u3-account-write-fence-inventory.md | 6 +- internal/app/compiled.go | 2 +- internal/app/delegated_project_projection.go | 122 ++++++++++++ ...delegated_project_projection_entry_test.go | 108 +++++++++++ .../delegated_project_projection_pg_test.go | 135 +++++++++++++ internal/app/project_lifecycle.go | 2 +- internal/app/project_lifecycle_test.go | 182 ++++++++++++++++++ internal/app/scoped_host_test.go | 3 + internal/store/account_write_guard.go | 48 +++++ internal/store/project_projection.go | 4 +- .../store/project_projection_fence_test.go | 175 +++++++++++++++++ pkg/extension/projects.go | 12 +- pkg/extension/surface.txt | 2 + 13 files changed, 793 insertions(+), 8 deletions(-) create mode 100644 internal/app/delegated_project_projection.go create mode 100644 internal/app/delegated_project_projection_entry_test.go create mode 100644 internal/app/delegated_project_projection_pg_test.go diff --git a/docs/iam-u3-account-write-fence-inventory.md b/docs/iam-u3-account-write-fence-inventory.md index 48edd6e..0f4b0b2 100644 --- a/docs/iam-u3-account-write-fence-inventory.md +++ b/docs/iam-u3-account-write-fence-inventory.md @@ -1,6 +1,6 @@ # IAM U3 A1: account write fence inventory -Status: stage 4–5 implementation inventory with **one open delegated-projection blocker**; stage 6 is not ready. The route table records the **4053c4d baseline finding** and the intended final sink; its `Now` column is historical, not a claim about the amended candidate. Scope follows the approved Documents amendment `docs/proposals/2026-10-14-iam-u3-stage6-review-amendment.md` at `fb3a8214df963697ebd745ad11eb852ed9444fe3`. The pending additional proposal is `docs/proposals/2026-10-14-iam-u3-delegated-projection-actor-amendment.md` in the Documents task branch. +Status: stage 4–5 implementation inventory; the fixed candidate is the commit containing this file, and stage 6 human review remains pending. The route table records the **4053c4d baseline finding** and the intended final sink; its `Now` column is historical, not a claim about the amended candidate. Scope follows the approved Documents A1–A4 amendment `fb3a8214df963697ebd745ad11eb852ed9444fe3` and approved delegated-actor amendment `5783940e4c434102cbca8c22bd282d87b28fa840`. The 66 non-GET `/api` registrations were enumerated from `internal/server/server.go`. Paths below omit `/api`. `A` means `s.owner(r)` (auth-off: implicit `store.OwnerDefault`); `B` means body owner, `K` redeemed-key owner, `S` current password-session owner. Target resource owner never substitutes for actor. In the historical `Now` column, `T` means the baseline already had a final Store check; `M` means a Backend DB write lacked one; `E` means an external or process-local effect; `—` means no business mutation or permitted credential cleanup. `D` means a short Store transaction with actor account lock and enabled read **before** resource/scope/conversation locks; `F` means stage immutable bytes, then `D` for the visible DB reference; `X` means last effective-state check before external action plus cancellation; `C` means `D` at each command handler's actual sink; `P` means `D` for Backend projection and `X` at the separate RDM callback. `Vnn` identifies the route for traceability; verification uses representative concurrency cases and complete source-to-sink review, rather than one duplicate test per route. @@ -103,9 +103,9 @@ At the 4053c4d baseline, Host/runmgr effective-account queries were early checks This section supersedes the historical `Now` column when checking the amended candidate. V01–V15 retain their existing account/credential transaction or response-only behavior; V16 uses actor-checked login-key revocation. V17–V23 route project, conversation, message, run creation and link writes through account-checked Store entries. V24–V35 use guarded file/message and borrow/secret/context/command entries; V25 prepares the message exclusion and regeneration marker in one checked transaction, then consumes the marker only with successful run settlement. V36–V44 guard command queue, HITL, card, surface/event, artifact metadata and index writes. Artifact content updates switch immutable blob references in the checked transaction. V45–V51 guard profile, prompt, settings and snapshot writes, using the **request actor** for system settings. V52–V59 combine last checks for process/HTTP/tool effects with guarded plugin, surface, card, command pending and audit DB writes. V60–V66 use guarded knowledge, task, memory, audit and hold updates. V65 refuses whole-database backup under authenticated mode because administrator status does not grant access to other owners' business content; auth-off backup has a last check. V67 is read-only at zero tokens. -VH01–VH04/VH06/VH08 use the Host owner and final Store account checks for Backend-owned rows; H05's RDM callback has a separate last actor check but the delegated rename gap below remains. VH07 checks immediately before its external model call and passes the caller context. VG01–VG09 use checked run, admission/lease, event, HITL, card, artifact, index, notice, command and settle sinks. File staging, transcript, tool HTTP/search/code execution and overlay writeback check just before effects and propagate cancellation. Failed/cancelled/interrupted run sealing, lease release, notice deletion and failed-start marker deletion are narrowly scoped safety cleanup; positive token usage is accounting for model work already incurred. +VH01–VH04/VH06/VH08 use the Host owner and final Store account checks for Backend-owned rows. H05 delegated rename now derives a **single-project** projection from the current authenticated lifecycle or command Host; its final Store transaction locks the real operator and creator in stable order, checks both effective states and verifies the persisted project owner. The global `NewScopedHost` cannot derive this delegated capability; its original creator-owned methods remain. VH07 checks immediately before its external model call and passes the caller context. VG01–VG09 use checked run, admission/lease, event, HITL, card, artifact, index, notice, command and settle sinks. File staging, transcript, tool HTTP/search/code execution and overlay writeback check just before effects and propagate cancellation. Failed/cancelled/interrupted run sealing, lease release, notice deletion and failed-start marker deletion are narrowly scoped safety cleanup; positive token usage is accounting for model work already incurred. -**Open H05/V17–V19 blocker:** delegated RDM rename reconstructs a creator-scoped Host after authorizing the real operator. The current Backend `RenameOwnedProject` final transaction checks the creator as actor and can therefore commit after the delegated operator is disabled. This is a Backend same-database late write, outside the accepted cross-database limitation. The additional Documents proposal specifies a request-bound operator projection capability and a two-account final fence. Do not mark A1 or stage 6 complete until that change is approved, implemented and verified. +The H05/V17–V19 late-write blocker was reproduced in the earlier candidate and repaired under the separately approved Documents `5783940` plan. Tests cover creator/operator effective state, a disable between early check and final write on SQLite/PG, a two-process PG disable, opposite lock order progress, and both HTTP PATCH and no-conversation command Host sources. The RDM database authorization/intent commit is still separate from Backend's account/project transaction; a permission withdrawal or cross-database failure is handled by the existing I1 failure intent and recovery path rather than a zero-window assertion. Completion and stage 6 claims require exact-HEAD receipts and human review. The account lock closes the **account-enabled versus Backend-owned business write** race on the same database. It does not prove that a session or API key revoked after request authentication cannot complete a write while that account remains enabled; that is a distinct credential-version fence and outside this amendment's promise. RDM commits in its own database and external requests, processes and files cannot share the Backend transaction. The last check can race with those effects, and a multi-file operation can leave earlier files committed when a later check fails. No cross-database or external zero-window claim is made. diff --git a/internal/app/compiled.go b/internal/app/compiled.go index 1e3c90a..644978a 100644 --- a/internal/app/compiled.go +++ b/internal/app/compiled.go @@ -64,7 +64,7 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand return fmt.Errorf("extension: command spec: %w", err) } return r.bus.RegisterPluginCommand(s, func(ctx context.Context, req cmdbus.Request) (any, error) { - host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor} + host := requestBoundHost{inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor}} return h(ctx, host, extension.Request{ ID: req.ID, Params: req.Params, Entrance: req.Entrance, Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID, diff --git a/internal/app/delegated_project_projection.go b/internal/app/delegated_project_projection.go new file mode 100644 index 0000000..eecb699 --- /dev/null +++ b/internal/app/delegated_project_projection.go @@ -0,0 +1,122 @@ +package app + +import ( + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +// requestBoundHost is created only for a current lifecycle or command request. +// A reconstructed global ScopedHost is a plain inprocHost and cannot expose +// the delegated projection method by type assertion. +type requestBoundHost struct{ inprocHost } + +// ProjectProjectionForCreator only derives from a live request Host of the +// registered RDM provider. The operator is inherited from that Host, never +// accepted as a caller-supplied field. RDM's project.settings decision remains +// its own domain authority; this capability fences the Backend projection. +func (bound requestBoundHost) ProjectProjectionForCreator(creator, projectID string) (extension.ProjectHost, error) { + h := bound.inprocHost + if h.actor == "" || h.actor != h.owner || h.pluginID != "rdm" || + h.caps.projects == nil || h.caps.st == nil || creator == "" || projectID == "" { + return nil, store.ErrAccountForbidden + } + slot := h.caps.projects + slot.mu.Lock() + registered := slot.provider != nil && slot.holder == h.pluginID + slot.mu.Unlock() + if !registered { + return nil, store.ErrAccountForbidden + } + if err := h.requireEffective(); err != nil { + return nil, err + } + if h.caps.accountGuard != nil { + if err := h.caps.accountGuard(creator); err != nil { + return nil, err + } + } + project, err := h.caps.st.ProjectMetadata(projectID) + if err != nil { + return nil, err + } + if string(project.Owner) != creator || string(project.ID) != projectID { + return nil, store.ErrNotFound + } + return delegatedProjectProjection{caps: h.caps, pluginID: h.pluginID, + actor: h.actor, creator: creator, projectID: projectID}, nil +} + +// delegatedProjectProjection exposes only ProjectHost, and every method is +// confined to the bound project. It cannot be cast to the broader Host. +type delegatedProjectProjection struct { + caps hostcaps + pluginID string + actor string + creator string + projectID string +} + +var _ extension.ProjectHost = delegatedProjectProjection{} + +func (p delegatedProjectProjection) Owner() string { return p.creator } + +func (p delegatedProjectProjection) check() error { + if p.caps.accountGuard == nil { + return nil + } + if err := p.caps.accountGuard(p.actor); err != nil { + return err + } + return p.caps.accountGuard(p.creator) +} + +func (p delegatedProjectProjection) target(id string) error { + if id == "" || id != p.projectID { + return store.ErrNotFound + } + return nil +} + +func (p delegatedProjectProjection) OwnedProjects() ([]core.Project, error) { + return nil, store.ErrAccountForbidden +} + +func (p delegatedProjectProjection) OwnedProject(string) (core.Project, error) { + return core.Project{}, store.ErrAccountForbidden +} + +func (p delegatedProjectProjection) EnsureProject(string, string) (core.Project, error) { + return core.Project{}, store.ErrAccountForbidden +} + +func (p delegatedProjectProjection) ProjectMetadata(id string) (core.Project, error) { + if err := p.target(id); err != nil { + return core.Project{}, err + } + if err := p.check(); err != nil { + return core.Project{}, err + } + view := inprocHost{caps: p.caps, pluginID: p.pluginID, owner: p.creator, actor: p.actor} + return view.ProjectMetadata(id) +} + +func (p delegatedProjectProjection) ProjectNameAvailable(name, exceptID string) (bool, error) { + if err := p.target(exceptID); err != nil { + return false, err + } + if err := p.check(); err != nil { + return false, err + } + return p.caps.st.ProjectNameAvailable(name, exceptID) +} + +func (p delegatedProjectProjection) RenameOwnedProject(id, name string) (core.Project, error) { + if err := p.target(id); err != nil { + return core.Project{}, err + } + if err := p.check(); err != nil { + return core.Project{}, err + } + return p.caps.st.RenameOwnedProjectAccountChecked(p.actor, p.creator, p.projectID, name, p.caps.allowImplicitLocal) +} diff --git a/internal/app/delegated_project_projection_entry_test.go b/internal/app/delegated_project_projection_entry_test.go new file mode 100644 index 0000000..ddd9320 --- /dev/null +++ b/internal/app/delegated_project_projection_entry_test.go @@ -0,0 +1,108 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/server" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +func TestDelegatedProjectionIsBoundAtHTTPAndCommandEntries(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/entries.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) + } + } + httpProject, err := st.CreateProject("alice", "HTTP original") + if err != nil { + t.Fatal(err) + } + commandProject, err := st.CreateProject("alice", "Command original") + if err != nil { + t.Fatal(err) + } + check := func(owner string) error { + state, err := st.GetUserAuthState(owner) + if err != nil { + return err + } + if !state.Enabled { + return store.ErrAccountForbidden + } + return nil + } + projectFromRequest := func(h extension.Host, id, name string) (core.Project, error) { + bound, ok := h.(extension.DelegatedProjectProjectionHost) + if !ok { + return core.Project{}, errors.New("request Host lost operator-bound projection") + } + projection, err := bound.ProjectProjectionForCreator("alice", id) + if err != nil { + return core.Project{}, err + } + return projection.RenameOwnedProject(id, name) + } + slot := &projectLifecycleSlot{} + caps := hostcaps{st: st, set: settings.NewService(st), projects: slot, accountGuard: check} + provider := completeLifecycle() + provider.Rename = func(_ context.Context, h extension.Host, id, name string) (core.Project, error) { + return projectFromRequest(h, id, name) + } + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil { + t.Fatal(err) + } + srv := server.New(st, nil, nil, nil, caps.set, nil) + srv.Auth = server.NewAuthenticator(st, true) + slot.wire(srv, caps) + key, err := st.CreateAPIKey("bob", "delegate-entry") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPatch, "/api/projects/"+string(httpProject.ID), strings.NewReader(`{"name":"via HTTP"}`)) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("HTTP lifecycle rename: status=%d body=%s", rec.Code, rec.Body.String()) + } + bus := cmdbus.New(st, nil, nil) + bus.SetEffectiveAccountGuard(func(_ context.Context, owner string) error { return check(owner) }) + bus.RequireAccountCheckedWrites(false) + reg := pluginRegistry{pluginID: "rdm", caps: caps, compiledRegistry: compiledRegistry{bus: bus}} + err = reg.AddCommandHandler(json.RawMessage(`{"id":"rdm.fixture.rename","title":"Rename"}`), + func(_ context.Context, h extension.Host, req extension.Request) (any, error) { + id, _ := req.Params["id"].(string) + name, _ := req.Params["name"].(string) + return projectFromRequest(h, id, name) + }) + if err != nil { + t.Fatal(err) + } + result := bus.Dispatch(context.Background(), cmdbus.Request{ID: "rdm.fixture.rename", Owner: "bob", Actor: "bob", + Params: map[string]any{"id": string(commandProject.ID), "name": "via command"}}) + if result.Receipt.State != agentcommand.StateCommitted { + t.Fatalf("command rename: %+v", result.Receipt) + } + for _, tc := range []struct{ id, want string }{{string(httpProject.ID), "via HTTP"}, {string(commandProject.ID), "via command"}} { + project, err := st.ProjectMetadata(tc.id) + if err != nil || project.Name != tc.want { + t.Fatalf("entry %s did not reach bound projection: %+v %v", tc.id, project, err) + } + } +} diff --git a/internal/app/delegated_project_projection_pg_test.go b/internal/app/delegated_project_projection_pg_test.go new file mode 100644 index 0000000..f1259c5 --- /dev/null +++ b/internal/app/delegated_project_projection_pg_test.go @@ -0,0 +1,135 @@ +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) + } +} diff --git a/internal/app/project_lifecycle.go b/internal/app/project_lifecycle.go index c35f5c3..cc79eeb 100644 --- a/internal/app/project_lifecycle.go +++ b/internal/app/project_lifecycle.go @@ -51,7 +51,7 @@ func (slot *projectLifecycleSlot) wire(srv *server.Server, caps hostcaps) { } holder := slot.holder srv.SetProjectLifecycle(*slot.provider, func(owner string) extension.Host { - return inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder} + return requestBoundHost{inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}} }) } diff --git a/internal/app/project_lifecycle_test.go b/internal/app/project_lifecycle_test.go index 6422f4e..f23d3ee 100644 --- a/internal/app/project_lifecycle_test.go +++ b/internal/app/project_lifecycle_test.go @@ -11,7 +11,12 @@ import ( "encoding/json" "errors" "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" "testing" + "time" ) func completeLifecycle() extension.ProjectLifecycle { @@ -169,3 +174,180 @@ func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) { 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) + } +} diff --git a/internal/app/scoped_host_test.go b/internal/app/scoped_host_test.go index 2a279e1..0791606 100644 --- a/internal/app/scoped_host_test.go +++ b/internal/app/scoped_host_test.go @@ -42,6 +42,9 @@ func TestScopedHostFactory(t *testing.T) { if h.Owner() != "alice" || h.PluginID() != "rdm" { t.Fatalf("Owner/PluginID = %s/%s, want alice/rdm", h.Owner(), h.PluginID()) } + if _, ok := h.(extension.DelegatedProjectProjectionHost); ok { + t.Fatal("globally reconstructed Host exposed request-bound projection authority") + } // KV 往返落 owner 作用域。 if err := h.KVPut("k1", "v1"); err != nil { t.Fatal(err) diff --git a/internal/store/account_write_guard.go b/internal/store/account_write_guard.go index a5acf66..02f3f75 100644 --- a/internal/store/account_write_guard.go +++ b/internal/store/account_write_guard.go @@ -3,6 +3,7 @@ package store import ( "database/sql" "errors" + "sort" ) // withEffectiveAccountWrite runs a Backend-owned business mutation after the @@ -38,3 +39,50 @@ func (s *Store) withEffectiveAccountWrite(actor string, allowImplicitDefault boo } return tx.Commit() } + +// withEffectiveProjectActorsWrite fences a delegated project projection against +// both the real operator and the persisted creator. Distinct accounts are +// acquired together in owner order before the global project-name lock. +func (s *Store) withEffectiveProjectActorsWrite(actor, creator string, allowImplicitDefault bool, fn func(*sql.Tx) error) error { + if actor == "" || creator == "" || fn == nil { + return ErrAccountForbidden + } + if actor == creator { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + return fn(tx) + }) + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + ordered := []string{actor, creator} + sort.Strings(ordered) + query := s.q(`SELECT ` + accountColumns + ` FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + for _, owner := range ordered { + if err := s.dialect.lockAccount(tx, owner); err != nil { + return err + } + account, err := scanAccount(tx.QueryRow(query, owner)) + if errors.Is(err, ErrNotFound) && allowImplicitDefault && owner == OwnerDefault { + continue + } + if errors.Is(err, ErrNotFound) { + return ErrAccountForbidden + } + if err != nil { + return err + } + if !account.Enabled { + return ErrAccountForbidden + } + } + if err := fn(tx); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/project_projection.go b/internal/store/project_projection.go index 675d0f6..83fc9bd 100644 --- a/internal/store/project_projection.go +++ b/internal/store/project_projection.go @@ -125,7 +125,7 @@ func (s *Store) EnsureProjectAccountChecked(actor, owner, id, name string, allow return core.Project{}, errors.New("store: project owner, id and name required") } var project core.Project - err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + err := s.withEffectiveProjectActorsWrite(actor, owner, allowImplicitDefault, func(tx *sql.Tx) error { if err := s.lockProjectNames(tx); err != nil { return err } @@ -173,7 +173,7 @@ func (s *Store) RenameOwnedProjectAccountChecked(actor, owner, id, name string, return core.Project{}, errors.New("store: project name required") } var project core.Project - err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + err := s.withEffectiveProjectActorsWrite(actor, owner, allowImplicitDefault, func(tx *sql.Tx) error { if err := s.lockProjectNames(tx); err != nil { return err } diff --git a/internal/store/project_projection_fence_test.go b/internal/store/project_projection_fence_test.go index 57f4161..a7a6e4e 100644 --- a/internal/store/project_projection_fence_test.go +++ b/internal/store/project_projection_fence_test.go @@ -2,11 +2,110 @@ package store import ( "errors" + "fmt" "sync" "testing" "time" ) +func TestPGDelegatedProjectionOppositeActorOrderMakesProgress(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("isolated PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + for _, owner := range []string{"alice", "bob"} { + if err := st.CreateAccount(owner, "", "", false); err != nil { + t.Fatal(err) + } + } + aliceProject, err := st.CreateProject("alice", "Alice original") + if err != nil { + t.Fatal(err) + } + bobProject, err := st.CreateProject("bob", "Bob original") + if err != nil { + t.Fatal(err) + } + for round := 0; round < 8; round++ { + start := make(chan struct{}) + results := make(chan error, 2) + go func(n int) { + <-start + _, err := st.RenameOwnedProjectAccountChecked("bob", "alice", string(aliceProject.ID), fmt.Sprintf("Alice %d", n), false) + results <- err + }(round) + go func(n int) { + <-start + _, err := other.RenameOwnedProjectAccountChecked("alice", "bob", string(bobProject.ID), fmt.Sprintf("Bob %d", n), false) + results <- err + }(round) + close(start) + for i := 0; i < 2; i++ { + select { + case err := <-results: + if err != nil { + t.Fatalf("opposite delegated account order, round %d: %v", round, err) + } + case <-time.After(5 * time.Second): + t.Fatalf("opposite delegated account order stalled at round %d", round) + } + } + } +} + +func TestPGDelegatedRenameCommitsBeforeCompetingDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("isolated PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + 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) + } + bob, err := st.GetAccountByOwner("bob") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "projects", "UPDATE", "delegate-rename") + writeDone := make(chan error, 1) + go func() { + _, err := st.RenameOwnedProjectAccountChecked("bob", "alice", string(project.ID), "renamed", false) + writeDone <- err + }() + waitForPGAccountWrite(t, st, "delegate-rename") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed before paused rename: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + got, err := st.ProjectMetadata(string(project.ID)) + if err != nil || got.Name != "renamed" { + t.Fatalf("completed rename was lost: %+v %v", got, err) + } +} + func TestProjectProjectionAccountCheckedStateOwnershipAndIdempotency(t *testing.T) { st := openTestStore(t) t.Logf("project projection fence dialect=%s", st.Dialect()) @@ -99,6 +198,82 @@ func TestProjectProjectionAccountCheckedStateOwnershipAndIdempotency(t *testing. } } +func TestDelegatedProjectionRejectsDisabledCreatorAtFinalWrite(t *testing.T) { + st := openTestStore(t) + 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) + } + alice, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", alice.ID, alice.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProjectAccountChecked("bob", "alice", string(project.ID), "late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("delegated write ignored disabled creator: %v", err) + } + got, err := st.ProjectMetadata(string(project.ID)) + if err != nil || got.Name != "original" { + t.Fatalf("disabled creator's project changed: %+v %v", got, err) + } +} + +func TestDelegatedProjectionImplicitLocalAppliesOnlyToMissingLocal(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + aliceProject, err := st.CreateProject("alice", "Alice original") + if err != nil { + t.Fatal(err) + } + localProject, err := st.CreateProject(OwnerDefault, "Local original") + if err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProjectAccountChecked(OwnerDefault, "alice", string(aliceProject.ID), "Auth-on rejection", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing local operator passed auth-on fence: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked("alice", OwnerDefault, string(localProject.ID), "Auth-on rejection", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing local creator passed auth-on fence: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked(OwnerDefault, "alice", string(aliceProject.ID), "Local delegated", true); err != nil { + t.Fatalf("missing auth-off local operator rejected: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked("alice", OwnerDefault, string(localProject.ID), "Alice delegated", true); err != nil { + t.Fatalf("missing auth-off local creator rejected: %v", err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + local, err := st.GetAccountByOwner(OwnerDefault) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", local.ID, local.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProjectAccountChecked(OwnerDefault, "alice", string(aliceProject.ID), "Late operator", true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled local operator renamed: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked("alice", OwnerDefault, string(localProject.ID), "Late creator", true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled local creator renamed: %v", err) + } +} + func TestPGProjectProjectionAccountFenceSerializesWithDisable(t *testing.T) { st := openTestStore(t) if st.Dialect() != "postgres" { diff --git a/pkg/extension/projects.go b/pkg/extension/projects.go index d0f5042..29bf9fa 100644 --- a/pkg/extension/projects.go +++ b/pkg/extension/projects.go @@ -7,7 +7,9 @@ import ( // ProjectHost is an optional actor-scoped host projection capability. Metadata // can be shared by an authorizer; registered-lifecycle metadata masks credentials. -// OwnedProject remains an owner-only management lookup; mutations remain owner-only. +// OwnedProject remains an owner-only management lookup. A request-bound +// delegated projection may carry a separate real operator and fixed creator; +// its final Store transaction checks both accounts and the persisted owner. type ProjectHost interface { Owner() string OwnedProjects() ([]core.Project, error) @@ -18,6 +20,14 @@ type ProjectHost interface { ProjectNameAvailable(name, exceptID string) (bool, error) } +// DelegatedProjectProjectionHost is an optional capability of a current, +// authenticated RDM request Host. It derives a single-project creator +// projection while retaining the original operator for final account fencing. +// A globally reconstructed ScopedHost does not implement this capability. +type DelegatedProjectProjectionHost interface { + ProjectProjectionForCreator(creator, projectID string) (ProjectHost, error) +} + // ProjectLifecycle supplies the domain's public project lifecycle and live work // authorization. Each Host is scoped to the authenticated actor. Errors with a // StatusCode() int method may report 400, 403, 404, 409, or 503 to HTTP clients. diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 8a682ad..ca7118a 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -26,6 +26,8 @@ AdminProvider AdminProvider.IsAdmin AdminZoneSpec Compiled +DelegatedProjectProjectionHost +DelegatedProjectProjectionHost.ProjectProjectionForCreator ErrNotLinked Handler Host