diff --git a/internal/server/project_lifecycle.go b/internal/server/project_lifecycle.go index 2bbfb67..82ecd3c 100644 --- a/internal/server/project_lifecycle.go +++ b/internal/server/project_lifecycle.go @@ -38,6 +38,36 @@ func (s *Server) projectWork(ctx context.Context, owner, projectID string) error return nil } +// projectRead is the read-side member decision (U3). Live work stays the +// authority for every member who is still active. A member the provider reports +// as no longer live (RDM state=removed) is not decided by a second membership +// probe: the decision goes back to the one resource authorization query surface +// the domain already answers, and only a shared-domain read grant opens the +// read path. Everything else stays denied and every write keeps projectWork. +// +// With no resource authorizer registered (EXT-8b V2 absent) this is exactly +// projectWork, so un-extended hosts keep their current denial unchanged. +func (s *Server) projectRead(ctx context.Context, owner, projectID string) error { + if s.projectLifecycle == nil { + return nil + } + if s.projectLifecycle.Work == nil { + return store.ErrNotFound + } + ok, err := s.projectLifecycle.Work(ctx, owner, core.ID(projectID)) + if err != nil { + return err + } + if ok { + return nil + } + if projectID != "" && s.ProjectAuthzV2 != nil && + s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) { + return nil + } + return store.ErrNotFound +} + // createPrivateConversation is shared by the API and /new. Only live work // authorization grants member creation; the resulting conversation stays owned. func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) { diff --git a/internal/server/project_lifecycle_test.go b/internal/server/project_lifecycle_test.go index c56fce9..326008d 100644 --- a/internal/server/project_lifecycle_test.go +++ b/internal/server/project_lifecycle_test.go @@ -3,6 +3,7 @@ package server import ( "agent-system-backend/internal/cardsvc" "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" "agent-system-backend/internal/surfacesvc" "agent-system-backend/pkg/extension" core "agent-system-core" @@ -506,3 +507,252 @@ func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) { t.Fatal("revoked upload created an artifact") } } + +// ---- U3:被移除成员(removed)的会话面——读放行、写关闭(与 RDM U3a 配对) ---- +// +// RDM 侧 U3a(成员状态机 active/removed/absent)经两个受信扩展面回答 Backend: +// ProjectLifecycle.Work(活着的工作权限)与 extension.ResourceAuthzQuery +// (共享域读)。被移除成员失去全部工作与写权限,只保留共享域读的只读路径。 +// 被测面是真实 HTTP 服务,供应侧换成可注入的成员状态 fixture。 + +const ( + memberActive = "active" + memberRemoved = "removed" + memberAbsent = "absent" + + removedMemberHistoryProbe = "REMOVED_MEMBER_HISTORY_PROBE" + removedMemberWriteProbe = "REMOVED_MEMBER_WRITE_PROBE" +) + +// memberStateFixture 是可注入的 RDM 供应侧:同一成员状态机回答上面两个面。 +type memberStateFixture struct { + owner string + member string + projectID string + memberState string + readAllowed bool + queries []extension.ResourceAuthzQuery +} + +func (f *memberStateFixture) stateOf(actor string) string { + switch { + case actor == f.owner: + return memberActive + case actor == f.member: + if f.memberState == "" { + return memberActive + } + return f.memberState + } + return memberAbsent +} + +// work 是活工作探针:只有 active 成员可工作,removed 与 absent 一律 false。 +func (f *memberStateFixture) work(_ context.Context, actor string, id core.ID) (bool, error) { + return id == core.ID(f.projectID) && f.stateOf(actor) == memberActive, nil +} + +// authorize 是唯一资源授权查询面:读动作跟随成员状态(removed 只保留共享域读), +// 写与副作用动作对任何成员状态都拒绝(与 RDM 侧 IsMember 只认 active 一致)。 +func (f *memberStateFixture) authorize(q extension.ResourceAuthzQuery) (bool, error) { + f.queries = append(f.queries, q) + if q.ResourceType != "project" || q.ResourceID != core.ID(f.projectID) || q.ProjectID != core.ID(f.projectID) { + return false, nil + } + switch q.Action { + case "read", "list", "stream": + default: + return false, nil + } + if state := f.stateOf(q.Actor); state != memberActive { + return state == memberRemoved && f.readAllowed, nil + } + return true, nil +} + +// removedMemberWorld 是上面 fixture 装好的真实服务:alice 是项目属主,bob 是 +// 被移除的成员,mallory 从未入项目。bob 的会话属主就是 bob 自己。 +type removedMemberWorld struct { + s *Server + st *store.Store + fixture *memberStateFixture + keys map[string]string + project core.Project + mine core.Conversation + other core.Conversation +} + +func newRemovedMemberWorld(t *testing.T, state string) *removedMemberWorld { + t.Helper() + s, st := newAuthTestServer(t) + keys := map[string]string{} + for _, owner := range []string{"alice", "bob", "mallory"} { + key, err := createLegacyAPIKeyFixture(t, st, owner, "test") + if err != nil { + t.Fatal(err) + } + keys[owner] = key + } + project, err := st.CreateProject("alice", "Shared") + if err != nil { + t.Fatal(err) + } + mine, err := st.CreateConversationInProject("bob", project.ID, "Bob session", "") + if err != nil { + t.Fatal(err) + } + other, err := st.CreateConversation("alice", project.ID, "Alice private", "") + if err != nil { + t.Fatal(err) + } + if err := st.AppendMessage(&core.Message{ConversationID: mine.ID, Role: core.RoleUser, + Parts: []core.Part{{Type: core.PartText, Text: removedMemberHistoryProbe}}}); err != nil { + t.Fatal(err) + } + fixture := &memberStateFixture{owner: "alice", member: "bob", projectID: project.ID, memberState: state, readAllowed: true} + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: fixture.work}, nil) + s.SetResourceAuthorizerV2(fixture.authorize) + s.DisableUploadIndexing = true + return &removedMemberWorld{s: s, st: st, fixture: fixture, keys: keys, project: project, mine: mine, other: other} +} + +func (w *removedMemberWorld) listPath() string { + return "/api/projects/" + string(w.project.ID) + "/conversations" +} + +// requireDenied 固化既有 fail-closed 语义:被移除成员与非成员在写门上一律 +// 403/404(不泄露存在性),两者都算拒绝——放行成 200 才是回归放宽。 +func requireDenied(t *testing.T, rec *httptest.ResponseRecorder, label string) { + t.Helper() + if rec.Code != http.StatusForbidden && rec.Code != http.StatusNotFound { + t.Fatalf("%s: gate opened (%d) %s", label, rec.Code, rec.Body.String()) + } +} + +// TestRemovedMemberReadsOwnSessionHistory 覆盖 (a):removed+共享读放行时, +// 被移除成员读本人会话列表、会话详情与消息历史成功,且不新增任何可见性。 +func TestRemovedMemberReadsOwnSessionHistory(t *testing.T) { + w := newRemovedMemberWorld(t, memberRemoved) + bob := w.keys["bob"] + + rec := lifecycleRequest(t, w.s, bob, http.MethodGet, w.listPath(), "") + var cs []core.Conversation + _ = json.Unmarshal(rec.Body.Bytes(), &cs) + if rec.Code != 200 || len(cs) != 1 || cs[0].ID != w.mine.ID { + t.Fatalf("removed member own list=%d %s", rec.Code, rec.Body.String()) + } + + // 放行必须来自同一资源授权查询面(不是第二套 membership 判定)。 + granted := false + for _, q := range w.fixture.queries { + if q.Actor == "bob" && q.Action == "read" && q.ResourceType == "project" && + q.ResourceID == core.ID(w.project.ID) && q.ProjectID == core.ID(w.project.ID) && q.AuthorityEpoch >= 1 { + granted = true + } + } + if !granted { + t.Fatalf("read decision did not consume the resource authorization query: %+v", w.fixture.queries) + } + + // 会话详情与消息历史(只读)。 + rec = lifecycleRequest(t, w.s, bob, http.MethodGet, "/api/conversations/"+string(w.mine.ID), "") + var detail struct { + Conversation core.Conversation `json:"conversation"` + Messages []core.Message `json:"messages"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &detail) + if rec.Code != 200 || string(detail.Conversation.ID) != string(w.mine.ID) || len(detail.Messages) != 1 || + !bytes.Contains(rec.Body.Bytes(), []byte(removedMemberHistoryProbe)) { + t.Fatalf("removed member own detail=%d %s", rec.Code, rec.Body.String()) + } + rec = lifecycleRequest(t, w.s, bob, http.MethodGet, "/api/conversations/"+string(w.mine.ID)+"/export", "") + if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte(removedMemberHistoryProbe)) { + t.Fatalf("removed member own export=%d %s", rec.Code, rec.Body.String()) + } + + // 不新增可见性:他人会话继续不可见。 + for _, q := range []struct{ label, key, path string }{ + {"other member session", bob, "/api/conversations/" + string(w.other.ID)}, + {"nonmember list", w.keys["mallory"], w.listPath()}, + {"nonmember removed member session", w.keys["mallory"], "/api/conversations/" + string(w.mine.ID)}, + {"nonmember owner session", w.keys["mallory"], "/api/conversations/" + string(w.other.ID)}, + {"owner reading another member session", w.keys["alice"], "/api/conversations/" + string(w.mine.ID)}, + } { + if rec := lifecycleRequest(t, w.s, q.key, http.MethodGet, q.path, ""); rec.Code != http.StatusNotFound { + t.Fatalf("%s=%d %s", q.label, rec.Code, rec.Body.String()) + } + } +} + +// TestRemovedMemberWriteGatesStayClosed 覆盖 (b):写与副作用对 removed 一律拒绝。 +func TestRemovedMemberWriteGatesStayClosed(t *testing.T) { + w := newRemovedMemberWorld(t, memberRemoved) + bob := w.keys["bob"] + w.s.Bus = cmdbus.New(w.st, nil, func() time.Time { return time.Now() }) + w.s.WireBuiltinCommands() + card := core.HITLCard{ID: "hitl-removed-member", ConversationID: w.mine.ID, RunID: "run-removed-member", Kind: "confirm"} + if err := w.st.SaveHITLCard("bob", &card); err != nil { + t.Fatal(err) + } + for _, q := range []struct{ label, method, path, body string }{ + {"create conversation", http.MethodPost, w.listPath(), `{"title":"Denied"}`}, + {"send message", http.MethodPost, "/api/conversations/" + string(w.mine.ID) + "/messages", `{"text":"` + removedMemberWriteProbe + `"}`}, + {"retry", http.MethodPost, "/api/conversations/" + string(w.mine.ID) + "/regenerate", `{}`}, + {"resume command", http.MethodPost, "/api/commands/conversation.create", `{"conversationId":"` + string(w.mine.ID) + `"}`}, + {"hitl resolve", http.MethodPost, "/api/hitl/hitl-removed-member/resolve", `{"choiceId":"yes"}`}, + } { + requireDenied(t, lifecycleRequest(t, w.s, bob, q.method, q.path, q.body), "removed member "+q.label) + } + requireDenied(t, lifecycleUpload(t, w.s, bob, string(w.project.ID), "removed-member.txt", removedMemberWriteProbe), "removed member upload") + + // 拒绝必须没有留下任何副作用。 + if cs, err := w.st.ListConversations("bob", string(w.project.ID)); err != nil || len(cs) != 1 { + t.Fatalf("denied create wrote a conversation: count=%d error=%v", len(cs), err) + } + if msgs, err := w.st.ListMessages(string(w.mine.ID)); err != nil || len(msgs) != 1 { + t.Fatalf("denied send wrote messages: count=%d error=%v", len(msgs), err) + } + if artifacts, err := w.st.ListProjectArtifactsAll(string(w.project.ID)); err != nil || len(artifacts) != 0 { + t.Fatalf("denied upload wrote artifacts: count=%d error=%v", len(artifacts), err) + } + if stored, err := w.st.GetHITLCard("bob", "hitl-removed-member"); err != nil || stored.Resolved { + t.Fatalf("denied hitl resolve changed the card: %+v error=%v", stored, err) + } +} + +// TestRemovedMemberWithoutSharedReadGrantStaysDenied 覆盖共享读被收回时读门同样关闭: +// 放行只能来自资源授权查询面的回答,不能因为「成员曾入项目」而无条件放行。 +func TestRemovedMemberWithoutSharedReadGrantStaysDenied(t *testing.T) { + w := newRemovedMemberWorld(t, memberRemoved) + w.fixture.readAllowed = false + requireDenied(t, lifecycleRequest(t, w.s, w.keys["bob"], http.MethodGet, w.listPath(), ""), "removed member without shared read") + // 本人会话详情仍按会话所有权放行(既有语义,U3 未改动这一条)。 + if rec := lifecycleRequest(t, w.s, w.keys["bob"], http.MethodGet, "/api/conversations/"+string(w.mine.ID), ""); rec.Code != 200 { + t.Fatalf("owned session detail=%d %s", rec.Code, rec.Body.String()) + } + requireDenied(t, lifecycleRequest(t, w.s, w.keys["bob"], http.MethodPost, w.listPath(), `{}`), "removed member create without shared read") +} + +// TestRestoredMemberRegainsSessionReadWrite 覆盖 (d):供应侧再报 active 后读写复原。 +func TestRestoredMemberRegainsSessionReadWrite(t *testing.T) { + w := newRemovedMemberWorld(t, memberRemoved) + bob := w.keys["bob"] + requireDenied(t, lifecycleRequest(t, w.s, bob, http.MethodPost, w.listPath(), `{}`), "removed member create before restore") + + w.fixture.memberState = memberActive + if rec := lifecycleRequest(t, w.s, bob, http.MethodGet, w.listPath(), ""); rec.Code != 200 { + t.Fatalf("restored member list=%d %s", rec.Code, rec.Body.String()) + } + rec := lifecycleRequest(t, w.s, bob, http.MethodPost, w.listPath(), `{"title":"Restored"}`) + if rec.Code != 200 { + t.Fatalf("restored member create=%d %s", rec.Code, rec.Body.String()) + } + var created core.Conversation + _ = json.Unmarshal(rec.Body.Bytes(), &created) + if string(created.ID) == "" || string(created.Owner) != "bob" || string(created.ProjectID) != string(w.project.ID) { + t.Fatalf("restored create shape=%+v", created) + } + if rec := lifecycleUpload(t, w.s, bob, string(w.project.ID), "restored.txt", "RESTORED"); rec.Code != 200 { + t.Fatalf("restored member upload=%d %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index e9f7919..2b77bc0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -870,7 +870,10 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) { func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) if s.projectLifecycle != nil { - if err := s.projectWork(r.Context(), owner, r.PathValue("id")); err != nil { + // Read path (U3): a removed member keeps read access to their own + // sessions when the domain grants a shared-domain read. The store query + // below is still owner-scoped, so no other member's session appears. + if err := s.projectRead(r.Context(), owner, r.PathValue("id")); err != nil { writeProjectErr(w, err) return }