Add the EXT-8 authorizer surface and a red sharing test

Why: EXT-8 is accepted (Documents #49); the surface (Registry.RegisterResourceAuthorizer, ResourceAuthorizer, SurfaceVersion 2.1.0-experimental) plus the Server injection seam must land before the read-path consultation, and the behavioral red run proves the gap.
Evidence: go test ./internal/server -run TestProjectResourceAuthorizerSharesReads FAIL (shared read 404 with adapter set); default-deny section passes; row recorded on the task card.
Impact: pkg/extension additive surface (surface.txt updated); projectAuthz slot in hostcaps; Server.ProjectAuthz + setter; app wiring after compiled load; declarative registry fails closed. Read behavior unchanged.
Refs: EXT-8 in Documents 2026-10-08-ext-c1-c7-rdm-shared-contracts; task card.
This commit is contained in:
2026-10-08 15:32:04 +00:00
parent e0b1c27f1f
commit d89e74880b
7 changed files with 199 additions and 5 deletions
+4 -1
View File
@@ -370,7 +370,8 @@ func Run(opt Options) error {
bus.SetPendingStore(st) bus.SetPendingStore(st)
pluginHost.OnCommands = bus.RegisterPluginCommands pluginHost.OnCommands = bus.RegisterPluginCommands
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。 // 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) applyCompiled(skillsRoot, bus, caps)
mgr.OnUsage = func(owner string, tokens int) { mgr.OnUsage = func(owner string, tokens int) {
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0)) 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) srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
// EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。
srv.SetProjectAuthorizer(authzSlot.Authorize)
srv.Workflows = wfReg srv.Workflows = wfReg
mgr.SetWorkflows(wfReg) mgr.SetWorkflows(wfReg)
srv.Cards = cardSvc srv.Cards = cardSvc
+6
View File
@@ -3,6 +3,7 @@ package app
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -65,6 +66,11 @@ func (r compiledRegistry) AddCommand(spec json.RawMessage) error {
return r.bus.RegisterPluginCommands([]json.RawMessage{spec}) 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 { func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error {
log.Printf("extension: note %s %s (%d bytes)", kind, name, len(payload)) log.Printf("extension: note %s %s (%d bytes)", kind, name, len(payload))
return nil return nil
+33 -3
View File
@@ -1,8 +1,10 @@
package app package app
import ( import (
"agent-system-backend/pkg/extension"
"context" "context"
"fmt" "fmt"
"sync"
"agent-system-backend/internal/ingest" "agent-system-backend/internal/ingest"
"agent-system-backend/internal/rag" "agent-system-backend/internal/rag"
@@ -16,9 +18,37 @@ import (
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内 // 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。 // inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
type hostcaps struct { type hostcaps struct {
st *store.Store st *store.Store
pipe *rag.Pipeline pipe *rag.Pipeline
set *settings.Service 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 时用管线默认值。 // search owner 作用域混合检索;projectID/topK 为空/0 时用管线默认值。
+124
View File
@@ -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])
}
+18
View File
@@ -57,6 +57,24 @@ type Server struct {
static fs.FS static fs.FS
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。 // Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
Subscribe func(conversationID string) (<-chan core.StreamEvent, func()) 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 { func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.Service, s *settings.Service, ph *plugins.Host) *Server {
+12 -1
View File
@@ -6,14 +6,22 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"sync" "sync"
core "agent-system-core"
) )
// SurfaceVersion 是本包导出面的实验版本。变更导出符号必须同时更新 surface.txt。 // 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 无法启动服务。 // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") 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 是进程内扩展的注册口。实现在基座内部,对下游只暴露这些方法。 // Registry 是进程内扩展的注册口。实现在基座内部,对下游只暴露这些方法。
type Registry interface { type Registry interface {
AddSkill(name, markdown string) error AddSkill(name, markdown string) error
@@ -22,6 +30,9 @@ type Registry interface {
// 基座在分发时按请求 Owner 构造 Host 句柄调用 h。 // 基座在分发时按请求 Owner 构造 Host 句柄调用 h。
AddCommandHandler(spec json.RawMessage, h Handler) error AddCommandHandler(spec json.RawMessage, h Handler) error
Note(kind, name string, payload json.RawMessage) error Note(kind, name string, payload json.RawMessage) error
// RegisterResourceAuthorizer 注册 EXT-8 资源授权适配器(进程内扩展);
// 首个注册者生效。声明式扩展不支持(回调需宿主进程内代码)。
RegisterResourceAuthorizer(authz ResourceAuthorizer) error
} }
// InProc 是随下游二进制编译进来的扩展。 // InProc 是随下游二进制编译进来的扩展。
+2
View File
@@ -26,7 +26,9 @@ Registry.AddCommand
Registry.AddCommandHandler Registry.AddCommandHandler
Registry.AddSkill Registry.AddSkill
Registry.Note Registry.Note
Registry.RegisterResourceAuthorizer
Request Request
ResetCompiled ResetCompiled
ResourceAuthorizer
SetRunner SetRunner
SurfaceVersion SurfaceVersion