agent-exec transport 47f1c6dec77e33ca7e6408150b0d3dd0/5116ee9fd8bf
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user