From 430caa6296ca7832dc709a73a6822961543ab4ea Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 12:04:43 +0000 Subject: [PATCH 1/2] Add red tests for EXT-8b read-path consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: U9 第二切片——server 读面须按资源级六要素咨询 V2(红:SetResourceAuthorizerV2 未定义)。 Evidence: go vet 编译红(槽位/消费缺失);断言下载/list/建会话三路径查询要素、V2 拒绝压过 V1、V2 缺席回落 V1。 Impact: 仅测试。 Refs: Backend#178;方案 r1 U9 --- .../server/resource_authz_consumption_test.go | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 internal/server/resource_authz_consumption_test.go diff --git a/internal/server/resource_authz_consumption_test.go b/internal/server/resource_authz_consumption_test.go new file mode 100644 index 0000000..e800636 --- /dev/null +++ b/internal/server/resource_authz_consumption_test.go @@ -0,0 +1,129 @@ +package server + +// EXT-8b 读面消费红测(方案 r1 U9 第二切片):五处共享读路径在 V2 注册后按 +// 资源级查询咨询(Actor/Action/ResourceType/ResourceID/Owner/ProjectID 由 +// 受信上下文构造);V2 拒绝压过 V1 放行(资源级权威);V2 未注册时五处全部 +// 回落 V1(兼容现行,RDM 现网行为不变)。红:当前 server 尚无 V2 槽位与 +// AuthorizeResource 消费——注册接口不存在,编译红先行。 + +import ( + "net/http" + "net/http/httptest" + "testing" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +func newV2CaptureServer(t *testing.T) (*Server, *store.Store, string, string, string) { + t.Helper() + srv, st := newAuthTestServer(t) + alice, err := st.CreateAPIKey("alice", "e2e") + if err != nil { + t.Fatal(err) + } + bob, err := st.CreateAPIKey("bob", "e2e") + if err != nil { + t.Fatal(err) + } + pj, err := st.CreateProject("alice", "资源级项目") + if err != nil { + t.Fatal(err) + } + return srv, st, alice, bob, pj.ID +} + +func TestSharedReadPathsConsultV2WithResourceContext(t *testing.T) { + srv, st, alice, bob, pid := newV2CaptureServer(t) + _ = alice + + // 项目内造一个工件(供 download 路径取元数据)。 + art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "res.txt", Kind: "file", MIME: "text/plain"} + if err := st.CreateArtifact(art, []byte("body")); err != nil { + t.Fatal(err) + } + + var got []extension.ResourceAuthzQuery + srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + got = append(got, q) + return true, nil + }) + h := srv.Handler() + call := func(method, path string) int { + req := httptest.NewRequest(method, path, nil) + req.Header.Set("Authorization", "Bearer "+bob) + rec := httptest.NewRecorder() + h.ServeHTTP(&rec, req) + return rec.Code + } + + // ① 工件下载(owner 自有未命中 → 资源级咨询)。 + code := call(http.MethodGet, "/api/projects/"+pid+"/artifacts/"+string(art.ID)+"/download") + if code != http.StatusOK { + t.Fatalf("download: %d", code) + } + // ② 项目工件清单。 + call(http.MethodGet, "/api/projects/"+pid+"/artifacts") + // ③ 共享项目建会话。 + call(http.MethodPost, "/api/projects/"+pid+"/conversations", nil) + + byAction := map[string]extension.ResourceAuthzQuery{} + for _, q := range got { + byAction[q.Action] = q + } + dl := byAction["download"] + if dl.Actor != "bob" || dl.ResourceType != "artifact" || dl.ResourceID != art.ID || dl.ProjectID != pid || dl.Owner != "alice" { + t.Fatalf("download 查询六要素不符: %+v", dl) + } + ls := byAction["list"] + if ls.Actor != "bob" || ls.ResourceType != "project" || ls.ResourceID != pid || ls.ProjectID != pid { + t.Fatalf("list 查询不符: %+v", ls) + } + rd := byAction["read"] + if rd.Actor != "bob" || rd.ResourceType != "project" || rd.ResourceID != pid { + t.Fatalf("read(conversation) 查询不符: %+v", rd) + } +} + +func TestV2DenyOverridesV1Allow(t *testing.T) { + srv, st, alice, bob, pid := newV2CaptureServer(t) + _ = alice + art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "d.txt", Kind: "file"} + if err := st.CreateArtifact(art, []byte("body")); err != nil { + t.Fatal(err) + } + // V1 全放行、V2 全拒绝——资源级权威必须压过 V1。 + srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { + return true, nil + }) + srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + return false, nil + }) + req := httptest.NewRequest(http.MethodGet, "/api/projects/"+pid+"/artifacts/"+string(art.ID)+"/download", nil) + req.Header.Set("Authorization", "Bearer "+bob) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(&rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("V2 拒绝须压过 V1 放行并保持 404(不泄露存在性), got %d", rec.Code) + } +} + +func TestV2AbsentFallsBackToV1(t *testing.T) { + srv, st, alice, bob, pid := newV2CaptureServer(t) + _ = alice + art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "f.txt", Kind: "file"} + if err := st.CreateArtifact(art, []byte("body")); err != nil { + t.Fatal(err) + } + srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { + return owner == "bob" && string(projectID) == pid, nil + }) + req := httptest.NewRequest(http.MethodGet, "/api/projects/"+pid+"/artifacts/"+string(art.ID)+"/download", nil) + req.Header.Set("Authorization", "Bearer "+bob) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(&rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("V2 未注册应回落 V1(现行行为), got %d", rec.Code) + } +} From 19d4e1ef0de927a039f98561ed766ef48ee79556 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 12:08:14 +0000 Subject: [PATCH 2/2] Consult resource-level authorizer on all shared read paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: 方案 r1 U9 第二切片——EXT-8b 契约落地后,读面(下载/清单/建会话/SSE 订阅/检索过滤/动作矩阵 read 回落)仍走 V1 owner×project 布尔,资源粒度与受信六要素未消费。 Evidence: 红测(SetResourceAuthorizerV2 未定义编译红,head 见任务收据);绿测三件:六要素查询断言(download=artifact×ID×Owner=alice×ProjectID、list/read=project×pid)、V2 拒绝压过 V1 放行(404 不泄露)、V2 缺席回落 V1(现行兼容);go test ./... 全绿。 Impact: Server 增 ProjectAuthzV2 槽与 SetResourceAuthorizerV2;authorizeSharedResource(六要素受信构造、字段缺失/出错一律 false)替换六处读路径的 V1 直调(artifactContent/getArtifact/sharedArtifactGet=listArtifacts/createConversation/SSE subscribe/检索 allow-fn/authorizeProjectAction read 回落);app.go 组合根注入 authzSlot.AuthorizeResource。V2 未注册时全部回落 V1,RDM 现网行为不变。 Refs: Backend#178;方案 r1 U9/KTD2 --- internal/app/app.go | 2 + .../server/resource_authz_consumption_test.go | 14 +++--- internal/server/server.go | 48 +++++++++++++++---- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 630ccc3..2244da4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -640,6 +640,8 @@ func Run(opt Options) error { srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost) // EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。 srv.SetProjectAuthorizer(authzSlot.Authorize) + // EXT-8b:资源级适配器注入读面(未注册=server 回落 V1,行为不变)。 + srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource) // EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。 srv.DisableUploadIndexing = opt.DisableUploadIndexing // EXT-12 最小开关:会话卡不镜像进项目公共流(cardSvc 在上方已建)。 diff --git a/internal/server/resource_authz_consumption_test.go b/internal/server/resource_authz_consumption_test.go index e800636..c29b832 100644 --- a/internal/server/resource_authz_consumption_test.go +++ b/internal/server/resource_authz_consumption_test.go @@ -54,19 +54,19 @@ func TestSharedReadPathsConsultV2WithResourceContext(t *testing.T) { req := httptest.NewRequest(method, path, nil) req.Header.Set("Authorization", "Bearer "+bob) rec := httptest.NewRecorder() - h.ServeHTTP(&rec, req) + h.ServeHTTP(rec, req) return rec.Code } // ① 工件下载(owner 自有未命中 → 资源级咨询)。 - code := call(http.MethodGet, "/api/projects/"+pid+"/artifacts/"+string(art.ID)+"/download") + code := call(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1") if code != http.StatusOK { t.Fatalf("download: %d", code) } // ② 项目工件清单。 call(http.MethodGet, "/api/projects/"+pid+"/artifacts") // ③ 共享项目建会话。 - call(http.MethodPost, "/api/projects/"+pid+"/conversations", nil) + call(http.MethodPost, "/api/projects/"+pid+"/conversations") byAction := map[string]extension.ResourceAuthzQuery{} for _, q := range got { @@ -100,10 +100,10 @@ func TestV2DenyOverridesV1Allow(t *testing.T) { srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { return false, nil }) - req := httptest.NewRequest(http.MethodGet, "/api/projects/"+pid+"/artifacts/"+string(art.ID)+"/download", nil) + req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1", nil) req.Header.Set("Authorization", "Bearer "+bob) rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(&rec, req) + srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("V2 拒绝须压过 V1 放行并保持 404(不泄露存在性), got %d", rec.Code) } @@ -119,10 +119,10 @@ func TestV2AbsentFallsBackToV1(t *testing.T) { srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { return owner == "bob" && string(projectID) == pid, nil }) - req := httptest.NewRequest(http.MethodGet, "/api/projects/"+pid+"/artifacts/"+string(art.ID)+"/download", nil) + req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1", nil) req.Header.Set("Authorization", "Bearer "+bob) rec := httptest.NewRecorder() - srv.Handler().ServeHTTP(&rec, req) + srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("V2 未注册应回落 V1(现行行为), got %d", rec.Code) } diff --git a/internal/server/server.go b/internal/server/server.go index 1e53ebc..3b70bb6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -67,6 +67,9 @@ type Server struct { // ProjectAuthz EXT-8 资源授权适配器(组合根注入;nil=未注册,读取入口 // owner 自有判定未命中即 NotFound——与未扩展基座行为一致)。 ProjectAuthz func(owner string, projectID core.ID) (bool, error) + // ProjectAuthzV2 EXT-8b 资源级授权适配器(U9;组合根注入;nil=未注册, + // 读面回落 ProjectAuthz 兼容)。查询六要素由本包受信构造。 + ProjectAuthzV2 func(q extension.ResourceAuthzQuery) (bool, error) // ProjectAuthzActions 动作级授权适配器(协作存储 U3,KTD13):注册后对 // 全部动作权威;未注册时 read 回落 ProjectAuthz(兼容),其余动作一律 // 默认拒绝——旧只读适配器不得自动获得写入能力。公开契约的版本化扩展 @@ -94,6 +97,11 @@ func (s *Server) SetProjectAuthorizer(fn func(owner string, projectID core.ID) ( s.ProjectAuthz = fn } +// SetResourceAuthorizerV2 组合根在装载扩展后注入 EXT-8b 资源级适配器。 +func (s *Server) SetResourceAuthorizerV2(fn func(q extension.ResourceAuthzQuery) (bool, error)) { + s.ProjectAuthzV2 = fn +} + // SetProjectActionAuthorizer 组合根注入 KTD13 动作级适配器(版本化公开契约 // 扩展落地前,宿主侧先以本槽位提供矩阵语义)。 func (s *Server) SetProjectActionAuthorizer(fn func(owner string, projectID core.ID, action ProjectAction) (bool, error)) { @@ -110,13 +118,32 @@ func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action return err == nil && ok } if action == ProjectRead { - return s.authorizeSharedProject(owner, projectID) + return s.authorizeSharedResource(owner, "read", "project", projectID, "", projectID) } return false } -// authorizeSharedProject 读取路径的共享判定:适配器未注册或拒绝/出错一律 false -// (fail-closed;错误不区分,避免泄露存在性)。 +// authorizeSharedResource EXT-8b 资源级读判定(U9):V2 注册时按六要素咨询 +//(Actor=认证会话、Action/ResourceType/ResourceID 由调用点受信构造、 +// Owner/ProjectID 从受信存储元数据解析;出错/字段缺失一律 false,fail-closed +// 且不泄露存在性);V2 未注册回落 authorizeSharedProject(现行兼容)。 +func (s *Server) authorizeSharedResource(actor, action, resourceType string, resourceID, owner, projectID core.ID) bool { + if s.ProjectAuthzV2 == nil { + return s.authorizeSharedProject(actor, projectID) + } + if actor == "" || action == "" || resourceType == "" || resourceID == "" { + return false + } + ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{ + Actor: actor, Action: action, ResourceType: resourceType, + ResourceID: resourceID, Owner: owner, ProjectID: projectID, + }) + return err == nil && ok +} + +// authorizeSharedProject 读取路径的共享判定(V1):适配器未注册或拒绝/出错一律 +// false(fail-closed;错误不区分,避免泄露存在性)。EXT-8b 落地后仅作 V2 缺席 +// 时的兼容回落——新读面一律走 authorizeSharedResource。 func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool { if s.ProjectAuthz == nil { return false @@ -400,8 +427,8 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { return } if _, err := s.st.GetProject(s.owner(r), pid); err != nil { - // EXT-8 切片 2:owner 自有未命中时咨询适配器(放行才可订阅项目流)。 - if !s.authorizeSharedProject(s.owner(r), core.ID(pid)) { + // EXT-8b:资源级咨询(stream×project);V2 缺席回落 V1。 + if !s.authorizeSharedResource(s.owner(r), "stream", "project", core.ID(pid), "", core.ID(pid)) { writeStoreErr(w, err) return } @@ -658,7 +685,7 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) { c, err := s.st.CreateConversation(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) // EXT-15:自有未命中且适配器放行(成员视角的共享项目)→ 会话仍归属创建者, // 只是落在他人项目下;撤销授权后此路径立即 404(不泄露存在性)。 - if errors.Is(err, store.ErrNotFound) && s.authorizeSharedProject(s.owner(r), core.ID(r.PathValue("id"))) { + if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(s.owner(r), "read", "project", core.ID(r.PathValue("id")), "", core.ID(r.PathValue("id"))) { c, err = s.st.CreateConversationInProject(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) } writeJSONOr(w, c, err) @@ -1275,8 +1302,8 @@ func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) pid := r.PathValue("id") if _, err := s.st.GetProject(owner, pid); err != nil { - // EXT-8:owner 自有未命中时问适配器;放行则返回项目全部工件(只读共享)。 - if !s.authorizeSharedProject(owner, core.ID(pid)) { + // EXT-8b:资源级咨询(list×project);放行则返回项目全部工件(只读共享)。 + if !s.authorizeSharedResource(owner, "list", "project", core.ID(pid), "", core.ID(pid)) { writeStoreErr(w, err) return } @@ -1295,7 +1322,8 @@ func (s *Server) sharedArtifactGet(owner, id string) (core.Artifact, bool) { if err != nil { return core.Artifact{}, false } - if !s.authorizeSharedProject(owner, a.ProjectID) { + // EXT-8b:工件级六要素(Owner=工件属主,受信元数据解析)。 + if !s.authorizeSharedResource(owner, "download", "artifact", a.ID, a.Owner, a.ProjectID) { return core.Artifact{}, false } return a, true @@ -1717,7 +1745,7 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) { // EXT-8 余项:Retriever 支持共享版(向量+全文两路追加)时整体走共享版; // 否则维持切片 2 行为(本人检索 + 全文共享追加)。 allow := func(docOwner, projectID core.ID) bool { - return s.authorizeSharedProject(owner, projectID) + return s.authorizeSharedResource(owner, "search", "project", projectID, docOwner, projectID) } if rs, ok := s.Retriever.(interface { RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)