Merge pull request 'Consult resource-level authorizer on all shared read paths' (#179) from feature/ext8-read-consumption into main
ci / vet-and-test (push) Successful in 47s
ci / vet-and-test (push) Successful in 47s
This commit was merged in pull request #179.
This commit is contained in:
@@ -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 在上方已建)。
|
||||
|
||||
@@ -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/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")
|
||||
|
||||
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/artifacts/"+string(art.ID)+"/content?download=1", 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/artifacts/"+string(art.ID)+"/content?download=1", 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)
|
||||
}
|
||||
}
|
||||
+38
-10
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user