Add EXT-8b resource-level authorization contract #178
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,12 @@ Registry.AddIntent
|
||||
Registry.AddSkill
|
||||
Registry.Note
|
||||
Registry.RegisterResourceAuthorizer
|
||||
Registry.RegisterResourceAuthorizerV2
|
||||
Request
|
||||
ResetCompiled
|
||||
ResourceAuthorizer
|
||||
ResourceAuthorizerV2
|
||||
ResourceAuthzQuery
|
||||
SetRunner
|
||||
SetScopedHostFactory
|
||||
SurfaceHost
|
||||
|
||||
Reference in New Issue
Block a user