From 0270b401979625fe0398e42efc99b22a13af54d3 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 11:44:00 +0000 Subject: [PATCH] Add EXT-8b resource-level authorization contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: 方案 r1 U9 首切片——V1(owner×project 布尔)无法表达资源粒度判定,读面(下载/列表/检索/模型上下文)需要执行端按资源咨询下游。 Evidence: 红:resource_authz_v2_test.go 编译红(ResourceAuthzQuery/V2 未定义);绿:pkg/extension surface 棘轮(2.10.0)+ internal/app 槽位测试(V2 咨询/V1 回落/字段缺失拒/error fail-closed/首注册者生效/compiled 拒绝);go test ./... 全绿(收据 /tmp/be-v2-test.txt)。 Impact: 公开面新增 ResourceAuthzQuery/ResourceAuthorizerV2/Registry.RegisterResourceAuthorizerV2;projectAuthz 增 V2 槽(AuthorizeResource:V2 优先、V1 兼容回落、受信字段缺失=拒);compiledRegistry fail-closed 拒绝声明式注册;读路径消费接线(server 5 处 authorizeSharedProject 升级)留待下一切片。 Refs: 方案 r1 U9/KTD2;RDM EXT-8 现有消费者(hostcaps/projectAuthz) --- internal/app/compiled.go | 14 +++++ internal/app/hostcaps.go | 36 +++++++++++- internal/app/resource_authz_v2_test.go | 73 +++++++++++++++++++++++++ internal/genui/genui_test.go | 4 ++ pkg/extension/extension.go | 23 +++++++- pkg/extension/resource_authz_v2_test.go | 35 ++++++++++++ pkg/extension/surface.txt | 3 + 7 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 internal/app/resource_authz_v2_test.go create mode 100644 pkg/extension/resource_authz_v2_test.go diff --git a/internal/app/compiled.go b/internal/app/compiled.go index 433e182..da47164 100644 --- a/internal/app/compiled.go +++ b/internal/app/compiled.go @@ -107,6 +107,11 @@ func (r compiledRegistry) AddCommand(spec json.RawMessage) error { } // RegisterResourceAuthorizer 声明式扩展无宿主进程内代码,不能提供回调(fail-closed)。 +// RegisterResourceAuthorizerV2 声明式扩展同样不能提供资源级回调(fail-closed)。 +func (r compiledRegistry) RegisterResourceAuthorizerV2(extension.ResourceAuthorizerV2) error { + return errors.New("extension: declarative plugins cannot register resource authorizers") +} + func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorizer) error { return errors.New("extension: declarative plugins cannot register resource authorizers") } @@ -124,6 +129,15 @@ func (r pluginRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthoriz return nil } +// RegisterResourceAuthorizerV2 进程内扩展:填充 EXT-8b 资源级适配器槽(首注册生效)。 +func (r pluginRegistry) RegisterResourceAuthorizerV2(fn extension.ResourceAuthorizerV2) error { + if fn == nil { + return errors.New("extension: " + r.pluginID + ": nil resource authorizer v2") + } + r.caps.authz.setV2(r.pluginID, fn) + return nil +} + 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 ed94350..0e9ce60 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -28,9 +28,11 @@ type hostcaps struct { // projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。 type projectAuthz struct { - mu sync.Mutex - holder string // 持有者 pluginID(首个注册者生效) - fn extension.ResourceAuthorizer + mu sync.Mutex + holder string // 持有者 pluginID(首个注册者生效) + fn extension.ResourceAuthorizer + holder2 string // EXT-8b 资源级持有者(首个注册者生效) + fn2 extension.ResourceAuthorizerV2 } func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) { @@ -42,6 +44,34 @@ func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) { a.holder, a.fn = pluginID, fn } +func (a *projectAuthz) setV2(pluginID string, fn extension.ResourceAuthorizerV2) { + a.mu.Lock() + defer a.mu.Unlock() + if a.fn2 != nil { + return + } + a.holder2, a.fn2 = pluginID, fn +} + +// AuthorizeResource EXT-8b 资源级判定(U9):V2 注册时按查询六要素咨询(含 +// owner 自有命中与共享读取,fail-closed);未注册回落 V1 的 owner×project +// 布尔(兼容现行行为)。查询字段由执行端受信构造。 +func (a *projectAuthz) AuthorizeResource(q extension.ResourceAuthzQuery) (bool, error) { + a.mu.Lock() + fn2, fn1 := a.fn2, a.fn + a.mu.Unlock() + if fn2 != nil { + if q.Actor == "" || q.Action == "" || q.ResourceType == "" || q.ResourceID == "" { + return false, nil // 受信上下文字段缺失=不可判定=拒绝 + } + return fn2(q) + } + if fn1 == nil || q.Owner == "" || q.ProjectID == "" { + return false, nil + } + return fn1(q.Owner, q.ProjectID) +} + // Authorize owner×project 共享读取判定;未注册一律 false(默认拒绝)。 func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) { a.mu.Lock() diff --git a/internal/app/resource_authz_v2_test.go b/internal/app/resource_authz_v2_test.go new file mode 100644 index 0000000..17dff17 --- /dev/null +++ b/internal/app/resource_authz_v2_test.go @@ -0,0 +1,73 @@ +package app + +// EXT-8b 资源级授权槽位测试(U9 首切片): +// 1. V2 未注册 → AuthorizeResource 回落 V1(owner×project 兼容现行); +// 2. V2 注册 → 按查询六要素咨询;受信字段缺失(actor/action/type/id 空)=拒绝; +// 3. V2 返回 error = 拒绝(fail-closed); +// 4. 首注册者生效(后来者忽略); +// 5. compiledRegistry 的 V2 注册被拒(声明式无宿主回调)。 +import ( + "errors" + "testing" + + "agent-system-backend/pkg/extension" +) + +func TestAuthorizeResourceV2FallbackAndQuery(t *testing.T) { + var a projectAuthz + // 未注册任何适配器 → 拒绝。 + if ok, err := a.AuthorizeResource(extension.ResourceAuthzQuery{ + Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1", + Owner: "u2", ProjectID: "p1"}); ok || err != nil { + t.Fatalf("未注册应拒绝, got %v %v", ok, err) + } + // 仅 V1:回落 owner×project 布尔。 + a.set("p1", func(owner string, projectID string) (bool, error) { return true, nil }) + if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{ + Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1", + Owner: "u2", ProjectID: "p1"}); !ok { + t.Fatal("V1 允许时回落应放行") + } + // V1 路径要求 owner/project 非空。 + if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{ + Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1"}); ok { + t.Fatal("V1 回落在无项目私域应拒绝") + } + // 注册 V2 → 按六要素咨询。 + a.setV2("p1", func(q extension.ResourceAuthzQuery) (bool, error) { + return q.Actor == "u1" && q.Action == "download" && q.ResourceType == "artifact" && + q.ResourceID == "a1" && q.Owner == "u2" && q.ProjectID == "p1", nil + }) + if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{ + Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1", + Owner: "u2", ProjectID: "p1"}); !ok { + t.Fatal("V2 六要素齐备应放行") + } + // 六要素缺一(action 空)=拒绝(不可判定)。 + if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{ + Actor: "u1", ResourceType: "artifact", ResourceID: "a1", Owner: "u2", ProjectID: "p1"}); ok { + t.Fatal("受信字段缺失应拒绝") + } + // V2 出错=拒绝(fail-closed)。 + a2 := projectAuthz{} + a2.setV2("p1", func(extension.ResourceAuthzQuery) (bool, error) { return false, errors.New("boom") }) + if ok, err := a2.AuthorizeResource(extension.ResourceAuthzQuery{ + Actor: "u", Action: "read", ResourceType: "x", ResourceID: "1"}); ok || err == nil { + t.Fatal("V2 error 应拒绝") + } + // 首注册者生效。 + a3 := projectAuthz{} + a3.setV2("first", func(extension.ResourceAuthzQuery) (bool, error) { return true, nil }) + a3.setV2("second", func(extension.ResourceAuthzQuery) (bool, error) { return false, nil }) + if ok, _ := a3.AuthorizeResource(extension.ResourceAuthzQuery{ + Actor: "u", Action: "read", ResourceType: "x", ResourceID: "1"}); !ok { + t.Fatal("首个 V2 注册者应生效") + } +} + +func TestCompiledRegistryRejectsV2(t *testing.T) { + r := compiledRegistry{} + if err := r.RegisterResourceAuthorizerV2(func(extension.ResourceAuthzQuery) (bool, error) { return true, nil }); err == nil { + t.Fatal("声明式扩展不得注册资源级授权回调(fail-closed)") + } +} diff --git a/internal/genui/genui_test.go b/internal/genui/genui_test.go index ee007eb..b246db7 100644 --- a/internal/genui/genui_test.go +++ b/internal/genui/genui_test.go @@ -222,6 +222,10 @@ func (r *fakeRegistry) Note(kind, name string, p json.RawMessage) error { return func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error { return nil } + +func (r *fakeRegistry) RegisterResourceAuthorizerV2(fn extension.ResourceAuthorizerV2) error { + return nil +} func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil } func (r *fakeRegistry) AddAdminZone(spec json.RawMessage) error { return nil } diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 809a0d1..d8dbf72 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -25,10 +25,12 @@ import ( // 2.8.0-experimental:EXT-17 新增可选能力接口 SurfaceHost(SurfaceSave/Get/List/ // Activation;Generative UI U2)。实现由基座 hostcaps 注入;未装配时类型断言 // 不成立即明确拒绝,不给必实现 Host 接口加方法(不破坏既有消费者)。 +// 2.10.0-experimental:EXT-8b 新增 ResourceAuthzQuery/ResourceAuthorizerV2 与 +// Registry.RegisterResourceAuthorizerV2(资源级授权,U9 首切片)。 // 2.9.0-experimental:Registry.AddAdminZone 与 AdminZoneSpec(WP3-I3/KTD4:编译 // 插件向基座管理区声明展示分区;分区入口不做基座级权限判定,数据能力由 // 分区组件自身的命令面 fail-closed)。 -const SurfaceVersion = "2.9.0-experimental" +const SurfaceVersion = "2.10.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") @@ -38,6 +40,22 @@ var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent- // 状态;返回 error 视为拒绝(fail-closed)。仅作用于读取路径,写路径一律 owner 自有。 type ResourceAuthorizer func(owner string, projectID core.ID) (bool, error) +// ResourceAuthzQuery 是 EXT-8b 资源级授权查询(U9):字段由执行端从受信上下文 +//(认证会话、受信存储)构造,不采信请求自报的归属;下游只回答布尔,不接触基座 +// 内部状态;返回 error 视为拒绝(fail-closed)。读取路径专用;写路径一律 owner 自有。 +type ResourceAuthzQuery struct { + Actor string `json:"actor"` // 认证主体(发起读取的会话) + Action string `json:"action"` // read|download|list|search|stream|model-context + ResourceType string `json:"resourceType"` // 基座资源类型(artifact/attachment/...) + ResourceID core.ID `json:"resourceId"` // 资源稳定 ID + Owner string `json:"owner"` // 资源属主(受信存储解析,非请求自报) + ProjectID core.ID `json:"projectId,omitempty"` // 所属项目;空=无项目私域 +} + +// ResourceAuthorizerV2 EXT-8b 资源级授权适配器:V1(owner×project 布尔)之上的 +// 细粒度形态——注册后对域内读路径权威(含 owner 自有命中与共享读取)。 +type ResourceAuthorizerV2 func(q ResourceAuthzQuery) (bool, error) + // Registry 是进程内扩展的注册口。实现在基座内部,对下游只暴露这些方法。 type Registry interface { AddSkill(name, markdown string) error @@ -49,6 +67,9 @@ type Registry interface { // RegisterResourceAuthorizer 注册 EXT-8 资源授权适配器(进程内扩展); // 首个注册者生效。声明式扩展不支持(回调需宿主进程内代码)。 RegisterResourceAuthorizer(authz ResourceAuthorizer) error + // RegisterResourceAuthorizerV2 注册 EXT-8b 资源级授权适配器(进程内扩展); + // 首个注册者生效;注册后读路径按资源粒度咨询(V1 为未注册时的兼容回落)。 + RegisterResourceAuthorizerV2(authz ResourceAuthorizerV2) error // AddIntent 声明自然语言意图(G2-2):短语命中后路由到已注册命令—— // 经同一命令总线分发(同审计信封与 HITL 收口)。规格见 IntentSpec。 AddIntent(spec json.RawMessage) error diff --git a/pkg/extension/resource_authz_v2_test.go b/pkg/extension/resource_authz_v2_test.go new file mode 100644 index 0000000..395287f --- /dev/null +++ b/pkg/extension/resource_authz_v2_test.go @@ -0,0 +1,35 @@ +package extension + +// EXT-8b 资源级授权契约红测(方案 r1 U9 首切片): +// 1. 公开面新增 ResourceAuthzQuery / ResourceAuthorizerV2 / +// Registry.RegisterResourceAuthorizerV2(surface 棘轮须同步 surface.txt); +// 2. SurfaceVersion 升至 2.10.0-experimental; +// 3. compiledRegistry(声明式扩展)对 V2 一律拒绝(无宿主进程内代码, +// fail-closed——与 V1 同款语义)。 +import ( + "encoding/json" + "strings" + "testing" +) + +func TestResourceAuthzV2SurfacePresent(t *testing.T) { + if !strings.HasPrefix(SurfaceVersion, "2.10.0") { + t.Fatalf("SurfaceVersion 应升至 2.10.0-experimental(新增导出符号须升版),got %s", SurfaceVersion) + } + var _ ResourceAuthorizerV2 = func(q ResourceAuthzQuery) (bool, error) { + return q.Actor != "" && q.Action != "" && q.ResourceType != "" && q.ResourceID != "", nil + } + // 查询六要素字段存在且可构造(字段名即契约)。 + q := ResourceAuthzQuery{Actor: "u1", Action: "download", ResourceType: "artifact", + ResourceID: "a1", Owner: "u2", ProjectID: "p1"} + b, err := json.Marshal(q) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"actor":"u1"`, `"action":"download"`, `"resourceType":"artifact"`, + `"resourceId":"a1"`, `"owner":"u2"`, `"projectId":"p1"`} { + if !strings.Contains(string(b), want) { + t.Fatalf("查询字段序列化缺 %s: %s", want, b) + } + } +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 9fa2ef7..327acb4 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -36,9 +36,12 @@ Registry.AddIntent Registry.AddSkill Registry.Note Registry.RegisterResourceAuthorizer +Registry.RegisterResourceAuthorizerV2 Request ResetCompiled ResourceAuthorizer +ResourceAuthorizerV2 +ResourceAuthzQuery SetRunner SetScopedHostFactory SurfaceHost -- 2.54.0