diff --git a/internal/app/app.go b/internal/app/app.go index 56615b9..9f3cf9d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -370,7 +370,8 @@ func Run(opt Options) error { bus.SetPendingStore(st) pluginHost.OnCommands = bus.RegisterPluginCommands // hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。 - caps := hostcaps{st: st, pipe: pipe, set: setSvc} + authzSlot := &projectAuthz{} + caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot} applyCompiled(skillsRoot, bus, caps) mgr.OnUsage = func(owner string, tokens int) { budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0)) @@ -399,6 +400,8 @@ func Run(opt Options) error { } srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost) + // EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。 + srv.SetProjectAuthorizer(authzSlot.Authorize) srv.Workflows = wfReg mgr.SetWorkflows(wfReg) srv.Cards = cardSvc diff --git a/internal/app/compiled.go b/internal/app/compiled.go index c38a76e..9e75970 100644 --- a/internal/app/compiled.go +++ b/internal/app/compiled.go @@ -3,6 +3,7 @@ package app import ( "context" "encoding/json" + "errors" "fmt" "log" "os" @@ -65,6 +66,11 @@ func (r compiledRegistry) AddCommand(spec json.RawMessage) error { return r.bus.RegisterPluginCommands([]json.RawMessage{spec}) } +// RegisterResourceAuthorizer 声明式扩展无宿主进程内代码,不能提供回调(fail-closed)。 +func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorizer) error { + return errors.New("extension: declarative plugins cannot register resource authorizers") +} + func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error { log.Printf("extension: note %s %s (%d bytes)", kind, name, len(payload)) return nil diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 99d00a7..8ef6857 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -1,8 +1,10 @@ package app import ( + "agent-system-backend/pkg/extension" "context" "fmt" + "sync" "agent-system-backend/internal/ingest" "agent-system-backend/internal/rag" @@ -16,9 +18,37 @@ import ( // 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内 // inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。 type hostcaps struct { - st *store.Store - pipe *rag.Pipeline - set *settings.Service + st *store.Store + pipe *rag.Pipeline + set *settings.Service + authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) +} + +// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。 +type projectAuthz struct { + mu sync.Mutex + holder string // 持有者 pluginID(首个注册者生效) + fn extension.ResourceAuthorizer +} + +func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) { + a.mu.Lock() + defer a.mu.Unlock() + if a.fn != nil { + return // 首个注册者生效;后来者忽略(调用方经日志可查) + } + a.holder, a.fn = pluginID, fn +} + +// Authorize owner×project 共享读取判定;未注册一律 false(默认拒绝)。 +func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) { + a.mu.Lock() + fn := a.fn + a.mu.Unlock() + if fn == nil || owner == "" || projectID == "" { + return false, nil + } + return fn(owner, projectID) } // search owner 作用域混合检索;projectID/topK 为空/0 时用管线默认值。 diff --git a/internal/server/resource_authz_test.go b/internal/server/resource_authz_test.go new file mode 100644 index 0000000..fae9e35 --- /dev/null +++ b/internal/server/resource_authz_test.go @@ -0,0 +1,124 @@ +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + core "agent-system-core" +) + +// EXT-8 资源授权适配器(红测:基础设施已就位、读取入口尚未咨询)—— +// 默认拒绝保持现状;注册适配器后同项目成员可读共享资源、跨项目仍拒。 +func TestProjectResourceAuthorizerSharesReads(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + + alice, err := st.CreateAPIKey("alice", "e2e") + if err != nil { + t.Fatal(err) + } + bob, err := st.CreateAPIKey("bob", "e2e") + if err != nil { + t.Fatal(err) + } + carol, err := st.CreateAPIKey("carol", "e2e") + if err != nil { + t.Fatal(err) + } + + // alice 建项目并上传一份工件(owner=alice)。 + prj := struct{ Name string `json:"name"` }{Name: "EXT8 共享项目"} + req := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader(marshal(t, prj))) + req.Header.Set("Authorization", "Bearer "+alice) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("create project: %d %s", rec.Code, rec.Body.String()) + } + pid := decodeField(t, rec.Body.Bytes(), "id") + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, _ := mw.CreateFormFile("file", "共享说明.txt") + fw.Write([]byte("SHARED-CONTENT")) + mw.Close() + req = httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/uploads", &buf) + req.Header.Set("Authorization", "Bearer "+alice) + req.Header.Set("Content-Type", mw.FormDataContentType()) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("upload: %d %s", rec.Code, rec.Body.String()) + } + aid := decodeField(t, rec.Body.Bytes(), "artifactId") + + get := func(key, path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + + // 默认拒绝(未注册适配器):bob 任何读取都是 NotFound,不泄露存在性。 + for _, path := range []string{"/api/projects/" + pid, "/api/projects/" + pid + "/artifacts", "/api/artifacts/" + aid, "/api/artifacts/" + aid + "/content"} { + if rec := get(bob, path); rec.Code != http.StatusNotFound { + t.Fatalf("default-deny: %s = %d, want 404", path, rec.Code) + } + } + + // 注册适配器:bob 对该项目有共享授权;carol 无。 + srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { + return owner == "bob" && string(projectID) == pid, nil + }) + + if rec := get(bob, "/api/projects/"+pid); rec.Code != http.StatusOK { + t.Fatalf("shared project read: %d %s", rec.Code, rec.Body.String()) + } + if rec := get(bob, "/api/artifacts/"+aid); rec.Code != http.StatusOK { + t.Fatalf("shared artifact meta: %d %s", rec.Code, rec.Body.String()) + } + rec = get(bob, "/api/artifacts/"+aid+"/content") + if rec.Code != http.StatusOK || rec.Body.String() != "SHARED-CONTENT" { + t.Fatalf("shared artifact content: %d %q", rec.Code, rec.Body.String()) + } + rec = get(bob, "/api/projects/"+pid+"/artifacts") + if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte(aid)) { + t.Fatalf("shared artifact list: %d %s", rec.Code, rec.Body.String()) + } + + // 跨项目/未授权用户与错误路径仍拒(NotFound;错误回调 fail-closed)。 + for _, path := range []string{"/api/projects/" + pid, "/api/artifacts/" + aid, "/api/artifacts/" + aid + "/content"} { + if rec := get(carol, path); rec.Code != http.StatusNotFound { + t.Fatalf("carol must stay denied: %s = %d", path, rec.Code) + } + } + srv.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return false, io.ErrClosedPipe }) + if rec := get(bob, "/api/projects/"+pid); rec.Code != http.StatusNotFound { + t.Fatalf("authorizer error must fail closed: %d", rec.Code) + } +} + +func marshal(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +} + +func decodeField(t *testing.T, body []byte, field string) string { + t.Helper() + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("decode %s: %v (%s)", field, err, body) + } + return fmt.Sprint(m[field]) +} diff --git a/internal/server/server.go b/internal/server/server.go index bf5c184..ad2bfde 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -57,6 +57,24 @@ type Server struct { static fs.FS // Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。 Subscribe func(conversationID string) (<-chan core.StreamEvent, func()) + // ProjectAuthz EXT-8 资源授权适配器(组合根注入;nil=未注册,读取入口 + // owner 自有判定未命中即 NotFound——与未扩展基座行为一致)。 + ProjectAuthz func(owner string, projectID core.ID) (bool, error) +} + +// SetProjectAuthorizer 组合根在装载扩展后注入 EXT-8 适配器。 +func (s *Server) SetProjectAuthorizer(fn func(owner string, projectID core.ID) (bool, error)) { + s.ProjectAuthz = fn +} + +// authorizeSharedProject 读取路径的共享判定:适配器未注册或拒绝/出错一律 false +//(fail-closed;错误不区分,避免泄露存在性)。 +func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool { + if s.ProjectAuthz == nil { + return false + } + ok, err := s.ProjectAuthz(owner, projectID) + return err == nil && ok } func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.Service, s *settings.Service, ph *plugins.Host) *Server { diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 835590c..642d47f 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -6,14 +6,22 @@ import ( "encoding/json" "errors" "sync" + + core "agent-system-core" ) // SurfaceVersion 是本包导出面的实验版本。变更导出符号必须同时更新 surface.txt。 -const SurfaceVersion = "2.0.0-experimental" +// 2.1.0-experimental:EXT-8 新增 ResourceAuthorizer 与 Registry.RegisterResourceAuthorizer。 +const SurfaceVersion = "2.1.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") +// ResourceAuthorizer 是 EXT-8 资源授权适配器:基座在 owner 自有资源判定未命中时, +// 回调询问「owner 对 projectID 是否有共享读取授权」。只回答布尔,不接触基座内部 +// 状态;返回 error 视为拒绝(fail-closed)。仅作用于读取路径,写路径一律 owner 自有。 +type ResourceAuthorizer func(owner string, projectID core.ID) (bool, error) + // Registry 是进程内扩展的注册口。实现在基座内部,对下游只暴露这些方法。 type Registry interface { AddSkill(name, markdown string) error @@ -22,6 +30,9 @@ type Registry interface { // 基座在分发时按请求 Owner 构造 Host 句柄调用 h。 AddCommandHandler(spec json.RawMessage, h Handler) error Note(kind, name string, payload json.RawMessage) error + // RegisterResourceAuthorizer 注册 EXT-8 资源授权适配器(进程内扩展); + // 首个注册者生效。声明式扩展不支持(回调需宿主进程内代码)。 + RegisterResourceAuthorizer(authz ResourceAuthorizer) error } // InProc 是随下游二进制编译进来的扩展。 diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 834d0ed..c7f5658 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -26,7 +26,9 @@ Registry.AddCommand Registry.AddCommandHandler Registry.AddSkill Registry.Note +Registry.RegisterResourceAuthorizer Request ResetCompiled +ResourceAuthorizer SetRunner SurfaceVersion