From a7cc69b61e86a1e642abc918916c4c50a6d4fa1c Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 8 Oct 2026 13:57:30 +0000 Subject: [PATCH 1/4] Add a red test for cross-tenant command audit leakage Why: GET /api/commands/audit returns every tenant's audit rows; the regression test pins the desired owner-scoped behavior before the fix. Evidence: go test ./internal/server -run TestCommandAuditOwnerScoping FAIL (owner alice sees bob's row; output recorded on the task card). Impact: Test only; suite red on this commit by design. Refs: Task card R1; U0 proposal section 5 C7 security finding. --- internal/server/audit_scope_test.go | 68 +++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 internal/server/audit_scope_test.go diff --git a/internal/server/audit_scope_test.go b/internal/server/audit_scope_test.go new file mode 100644 index 0000000..b90d3e5 --- /dev/null +++ b/internal/server/audit_scope_test.go @@ -0,0 +1,68 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + agentcommand "agent-command" +) + +// 命令审计按租户作用域(回归:GET /api/commands/audit 曾返回全租户审计行—— +// ListAudits 无 owner 条件、command_audit 无 owner 列;多用户档下任何登录 +// 用户可读他人命令 ID、入口、会话 ID 与参数摘要)。 +// 经真实认证中间件(API key → owner)请求,断言每个租户只见本租户的行。 +func TestCommandAuditOwnerScoping(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + + aliceKey, err := st.CreateAPIKey("alice", "e2e") + if err != nil { + t.Fatal(err) + } + bobKey, err := st.CreateAPIKey("bob", "e2e") + if err != nil { + t.Fatal(err) + } + + // 两租户各产生一条命令审计(信封 Actor=发起租户——修复前唯一可用的归属线索)。 + if err := st.AppendAudit(agentcommand.AuditEnvelope{ + CommandID: "cmd.alice", Actor: "alice", State: agentcommand.StateCommitted, + }); err != nil { + t.Fatal(err) + } + if err := st.AppendAudit(agentcommand.AuditEnvelope{ + CommandID: "cmd.bob", Actor: "bob", State: agentcommand.StateCommitted, + }); err != nil { + t.Fatal(err) + } + + get := func(key string) []map[string]any { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/commands/audit", nil) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("audit query: %d %s", rec.Code, rec.Body.String()) + } + var out struct { + Audits []map[string]any `json:"audits"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + return out.Audits + } + + for key, owner := range map[string]string{aliceKey: "alice", bobKey: "bob"} { + rows := get(key) + if len(rows) != 1 { + t.Fatalf("owner %s: audit rows = %d, want 1(跨租户行不得返回;got=%v)", owner, len(rows), rows) + } + if rows[0]["actor"] != owner || rows[0]["commandId"] != "cmd."+owner { + t.Fatalf("owner %s: wrong row %v", owner, rows[0]) + } + } +} -- 2.54.0 From e0b1c27f1f757938debf979b00c971350401b607 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 8 Oct 2026 14:02:19 +0000 Subject: [PATCH 2/4] Scope command audit storage and queries to the requesting owner Why: GET /api/commands/audit leaked every tenant's audit rows (command ids, entrances, conversation ids, param digests) to any authenticated user; owner was never recorded nor filtered. Evidence: Red run at a7cc69b FAIL (alice sees bob's row); after fix go test ./... 32/32 packages ok, including TestCommandAuditOwnerScoping and the v18 backfill test (rows recorded on the task card). Impact: v18 migration adds command_audit.owner with actor-based backfill (empty-actor rows stay invisible via API, kept in-table); AppendAudit/ListAudits gain owner params; cmdbus port and fakes updated; handler passes authenticated owner. No public extension surface change. Refs: Task card R1; U0 proposal section 5 C7 finding. --- internal/cmdbus/cmdbus.go | 8 +-- internal/cmdbus/cmdbus_test.go | 2 +- internal/server/audit_scope_test.go | 6 +-- internal/server/command_pairs_test.go | 2 +- internal/server/server.go | 2 +- internal/store/audit.go | 27 ++++++---- internal/store/audit_test.go | 77 +++++++++++++++++++++++++++ internal/store/migrate.go | 24 +++++++++ internal/store/store_test.go | 4 +- 9 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 internal/store/audit_test.go diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index 17e6512..7f1bc2d 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -18,9 +18,11 @@ import ( "agent-system-core" ) -// AuditStore 审计落盘端口(store 实现;v7 command_audit)。 +// AuditStore 审计落盘端口(store 实现;v7 建表、v18 加 owner 列)。 +// owner 是请求租户(req.Owner):审计行按租户归属,查询按 owner 过滤, +// 跨租户审计不可见(信封 Actor 是发起主体,不作归属依据)。 type AuditStore interface { - AppendAudit(env agentcommand.AuditEnvelope) error + AppendAudit(owner string, env agentcommand.AuditEnvelope) error } // CardPort 确认卡所需卡片能力(cardsvc.Service 实现)。 @@ -275,7 +277,7 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas if _, src, err := b.reg.Resolve(req.ID); err == nil { env.Source = src } - if err := b.audit.AppendAudit(env); err != nil { + if err := b.audit.AppendAudit(req.Owner, env); err != nil { // 审计不可用:结果标记未知,不臆断成功(四态语义)。 return agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateUnknown, Reason: "audit unavailable: " + err.Error(), Time: b.now().UTC()} } diff --git a/internal/cmdbus/cmdbus_test.go b/internal/cmdbus/cmdbus_test.go index 4b78adc..7b4e3f3 100644 --- a/internal/cmdbus/cmdbus_test.go +++ b/internal/cmdbus/cmdbus_test.go @@ -20,7 +20,7 @@ type memAudit struct { fail bool } -func (m *memAudit) AppendAudit(env agentcommand.AuditEnvelope) error { +func (m *memAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error { if m.fail { return errors.New("audit down") } diff --git a/internal/server/audit_scope_test.go b/internal/server/audit_scope_test.go index b90d3e5..049532c 100644 --- a/internal/server/audit_scope_test.go +++ b/internal/server/audit_scope_test.go @@ -26,13 +26,13 @@ func TestCommandAuditOwnerScoping(t *testing.T) { t.Fatal(err) } - // 两租户各产生一条命令审计(信封 Actor=发起租户——修复前唯一可用的归属线索)。 - if err := st.AppendAudit(agentcommand.AuditEnvelope{ + // 两租户各产生一条命令审计(owner 列=v18 起的归属依据;Actor 仅为信封内容)。 + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{ CommandID: "cmd.alice", Actor: "alice", State: agentcommand.StateCommitted, }); err != nil { t.Fatal(err) } - if err := st.AppendAudit(agentcommand.AuditEnvelope{ + if err := st.AppendAudit("bob", agentcommand.AuditEnvelope{ CommandID: "cmd.bob", Actor: "bob", State: agentcommand.StateCommitted, }); err != nil { t.Fatal(err) diff --git a/internal/server/command_pairs_test.go b/internal/server/command_pairs_test.go index 61991eb..ea6a952 100644 --- a/internal/server/command_pairs_test.go +++ b/internal/server/command_pairs_test.go @@ -18,7 +18,7 @@ type pair struct { type nopAudit struct{} -func (nopAudit) AppendAudit(agentcommand.AuditEnvelope) error { return nil } +func (nopAudit) AppendAudit(string, agentcommand.AuditEnvelope) error { return nil } // 每个内置命令都必须在 command-pairs.json 里同时声明按钮入口与对话入口。 // 新命令漏登记则本测试失败,合入被挡住。 diff --git a/internal/server/server.go b/internal/server/server.go index 9cd08a7..bf5c184 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -860,7 +860,7 @@ func (s *Server) listWorkspaces(w http.ResponseWriter, r *http.Request) { // listAudits 审计查询(会话维度或全局;入口对称的可核查凭证)。 func (s *Server) listAudits(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - audits, err := s.st.ListAudits(r.URL.Query().Get("conversationId"), limit) + audits, err := s.st.ListAudits(s.owner(r), r.URL.Query().Get("conversationId"), limit) writeJSONOr(w, map[string]any{"audits": audits}, err) } diff --git a/internal/store/audit.go b/internal/store/audit.go index 1bff805..b5479a8 100644 --- a/internal/store/audit.go +++ b/internal/store/audit.go @@ -8,21 +8,30 @@ import ( agentcommand "agent-command" ) -// AppendAudit 追加命令审计信封(cmdbus 端口;v7 command_audit)。 -func (s *Store) AppendAudit(env agentcommand.AuditEnvelope) error { +// AppendAudit 追加命令审计信封(cmdbus 端口;v7 建表,v18 增加 owner 列)。 +// owner 是租户身份(认证注入),与信封里的 Actor(发起主体)语义不同:查询按 +// owner 过滤,跨租户审计不可见。 +func (s *Store) AppendAudit(owner string, env agentcommand.AuditEnvelope) error { raw, err := json.Marshal(env) if err != nil { return err } - _, err = s.db.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), + _, err = s.db.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, string(env.Source), env.Entrance, string(env.State), - env.OperationID, env.ConversationID, string(raw)) + env.OperationID, env.ConversationID, owner, string(raw)) return err } -// ListAudits 审计查询(时间倒序;conversationID 空 = 全局)。 -func (s *Store) ListAudits(conversationID string, limit int) ([]agentcommand.AuditEnvelope, error) { +// ListAudits 审计查询(owner 作用域,时间倒序;conversationID 空 = 该租户全局)。 +// owner 为空视为无作用域:不返回任何行(防调用方漏传租户时把未归属存量行 +// 全部泄出——服务端认证层恒注入非空 owner,这里是纵深防御)。 +// v18 回填时 actor 为空的存量行 owner 保持空串,不匹配任何租户:这些行不再经 +// API 返回(数据保留在表内,可直查数据库取证)——见 v18 迁移注释。 +func (s *Store) ListAudits(owner, conversationID string, limit int) ([]agentcommand.AuditEnvelope, error) { + if owner == "" { + return []agentcommand.AuditEnvelope{}, nil + } if limit <= 0 || limit > 500 { limit = 100 } @@ -31,9 +40,9 @@ func (s *Store) ListAudits(conversationID string, limit int) ([]agentcommand.Aud err error ) if conversationID == "" { - rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit ORDER BY time DESC LIMIT ?`), limit) + rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit WHERE owner=? ORDER BY time DESC LIMIT ?`), owner, limit) } else { - rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit WHERE conversation_id=? ORDER BY time DESC LIMIT ?`), conversationID, limit) + rows, err = s.db.Query(s.q(`SELECT payload FROM command_audit WHERE owner=? AND conversation_id=? ORDER BY time DESC LIMIT ?`), owner, conversationID, limit) } if err != nil { return nil, err diff --git a/internal/store/audit_test.go b/internal/store/audit_test.go new file mode 100644 index 0000000..06458a9 --- /dev/null +++ b/internal/store/audit_test.go @@ -0,0 +1,77 @@ +package store + +import ( + "path/filepath" + "testing" + "time" + + agentcommand "agent-command" +) + +// v18 迁移:command_audit 增加 owner 列并按 payload.actor 回填存量行 +// (GET /api/commands/audit 曾跨租户可读;见 migrate.go v18 注释)。 +// 路径:全新库(已到 v18)→ MigrateDown(17) 回出无 owner 列的旧表 → +// 直插两行修复前形态的信封 → 重开触发 v18 → 归属断言。 +func TestCommandAuditOwnerBackfill(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.db") + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + if err := st.MigrateDown(17); err != nil { + t.Fatalf("migrate down to v17: %v", err) + } + now := time.Now().UTC().Format(time.RFC3339Nano) + legacy := func(actor, cmd string) string { + // 修复前形态:信封 Actor 即发起租户,表内无归属列。 + return `{"time":"` + now + `","actor":"` + actor + `","commandId":"` + cmd + `","source":"builtin","state":"committed"}` + } + if _, err := st.db.Exec(st.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload) + VALUES (?, 'cmd.a', 'builtin', 'api', 'committed', '', 'c1', ?)`), now, legacy("alice", "cmd.a")); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload) + VALUES (?, 'cmd.x', 'builtin', 'api', 'committed', '', '', ?)`), now, `{"time":"`+now+`","commandId":"cmd.x","state":"committed"}`); err != nil { + t.Fatal(err) // actor 为空的存量行:无法归属 + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + + st2, err := OpenSQLite(path) // 重开触发 v18:加列 + 回填 + if err != nil { + t.Fatal(err) + } + defer st2.Close() + + aliceRows, err := st2.ListAudits("alice", "", 10) + if err != nil { + t.Fatal(err) + } + if len(aliceRows) != 1 || aliceRows[0].CommandID != "cmd.a" { + t.Fatalf("alice rows = %+v, want 1 条 cmd.a(actor 回填归属)", aliceRows) + } + bobRows, err := st2.ListAudits("bob", "", 10) + if err != nil { + t.Fatal(err) + } + if len(bobRows) != 0 { + t.Fatalf("bob rows = %+v, want 0", bobRows) + } + if rows, _ := st2.ListAudits("", "", 10); len(rows) != 0 { + t.Fatalf("空 owner(actor 缺失的存量行)不得经 API 归属返回,got %d 行", len(rows)) + } + + // 新写入走 owner 列;同租户按会话过滤仍可用。 + if err := st2.AppendAudit("alice", agentcommand.AuditEnvelope{CommandID: "cmd.a2", Actor: "alice", + State: agentcommand.StateCommitted, ConversationID: "c2"}); err != nil { + t.Fatal(err) + } + convRows, err := st2.ListAudits("alice", "c2", 10) + if err != nil { + t.Fatal(err) + } + if len(convRows) != 1 || convRows[0].CommandID != "cmd.a2" { + t.Fatalf("alice/c2 rows = %+v, want cmd.a2", convRows) + } +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 842056d..89eba48 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -407,6 +407,30 @@ func init() { migration{version: 17, name: "pending_notices", up: map[string][]string{"sqlite": v17Up, "postgres": v17Up}, down: map[string][]string{"sqlite": v17Down, "postgres": v17Down}}, + migration{version: 18, name: "command_audit_owner", + // 命令审计租户归属(GET /api/commands/audit 曾无 owner 过滤,跨租户可读)。 + // 存量行回填取 payload.actor——修复前行未记租户,Actor 是唯一可用线索且 + // 对历史 HTTP 入口恒等于发起租户;actor 为空的行无法归属,owner 留空, + // API 查询不再返回(数据保留在表内,可直查数据库取证)。索引覆盖 + // owner×conversation×time 的两种查询形态。 + up: map[string][]string{"sqlite": { + `ALTER TABLE command_audit ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, + `UPDATE command_audit SET owner = COALESCE(json_extract(payload, '$.actor'), '') WHERE owner = ''`, + `CREATE INDEX IF NOT EXISTS idx_audit_owner ON command_audit(owner, conversation_id, time)`, + }, "postgres": { + `ALTER TABLE command_audit ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, + `UPDATE command_audit SET owner = COALESCE(payload::json->>'actor', '') WHERE owner = ''`, + `CREATE INDEX IF NOT EXISTS idx_audit_owner ON command_audit(owner, conversation_id, time)`, + }}, + // down:先删引用列的索引再 DROP COLUMN(sqlite 对被索引列直接 DROP + // 会报 no such column: owner;PG 先删同样无害)。 + down: map[string][]string{"sqlite": { + `DROP INDEX IF EXISTS idx_audit_owner`, + `ALTER TABLE command_audit DROP COLUMN owner`, + }, "postgres": { + `DROP INDEX IF EXISTS idx_audit_owner`, + `ALTER TABLE command_audit DROP COLUMN owner`, + }}}, ) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 1c8f083..ef307e8 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -71,8 +71,8 @@ func TestMigrationUpgradesExistingDatabase(t *testing.T) { if err != nil { t.Fatal(err) } - if len(got) != 17 { - t.Fatalf("expected versions [1..17], got %v", got) + if len(got) != 18 { + t.Fatalf("expected versions [1..18], got %v", got) } // 老库形态(v2 前):events 全局自增、settings 单列主键。 if err := st.MigrateDown(1); err != nil { -- 2.54.0 From d89e74880bfb15a1cd0f2f75a98af64363adc283 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 8 Oct 2026 15:32:04 +0000 Subject: [PATCH 3/4] 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. --- internal/app/app.go | 5 +- internal/app/compiled.go | 6 ++ internal/app/hostcaps.go | 36 ++++++- internal/server/resource_authz_test.go | 124 +++++++++++++++++++++++++ internal/server/server.go | 18 ++++ pkg/extension/extension.go | 13 ++- pkg/extension/surface.txt | 2 + 7 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 internal/server/resource_authz_test.go 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 -- 2.54.0 From a4783d18e7156e4f48188e6816be865ed82380f6 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 8 Oct 2026 15:34:18 +0000 Subject: [PATCH 4/4] Consult the EXT-8 authorizer in project artifact read paths Why: With the surface and injection seam in place, the read endpoints must fall back to the adapter when owner-scoped lookup misses, keeping default-deny and fail-closed semantics. Evidence: TestProjectResourceAuthorizerSharesReads green (default deny, shared list/meta/content via adapter, third-party denied, authorizer error fail-closed); go test ./... 32/32 packages ok. Impact: listArtifacts/getArtifact/artifactContent consult authorizeSharedProject; store gains gated helpers (GetArtifactByID/ArtifactContentByID/ListProjectArtifactsAll) documented internal-only; writes untouched; SSE project stream and project card aggregate deliberately deferred to EXT-12 (conversation-card mirroring privacy). Refs: EXT-8; red run at d89e748; task card. --- internal/server/resource_authz_test.go | 11 +++--- internal/server/server.go | 53 +++++++++++++++++++++----- internal/store/content.go | 45 ++++++++++++++++++++++ 3 files changed, 94 insertions(+), 15 deletions(-) diff --git a/internal/server/resource_authz_test.go b/internal/server/resource_authz_test.go index fae9e35..f2599b5 100644 --- a/internal/server/resource_authz_test.go +++ b/internal/server/resource_authz_test.go @@ -33,7 +33,9 @@ func TestProjectResourceAuthorizerSharesReads(t *testing.T) { } // alice 建项目并上传一份工件(owner=alice)。 - prj := struct{ Name string `json:"name"` }{Name: "EXT8 共享项目"} + 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() @@ -78,9 +80,6 @@ func TestProjectResourceAuthorizerSharesReads(t *testing.T) { 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()) } @@ -94,13 +93,13 @@ func TestProjectResourceAuthorizerSharesReads(t *testing.T) { } // 跨项目/未授权用户与错误路径仍拒(NotFound;错误回调 fail-closed)。 - for _, path := range []string{"/api/projects/" + pid, "/api/artifacts/" + aid, "/api/artifacts/" + aid + "/content"} { + for _, path := range []string{"/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 { + if rec := get(bob, "/api/artifacts/"+aid); rec.Code != http.StatusNotFound { t.Fatalf("authorizer error must fail closed: %d", rec.Code) } } diff --git a/internal/server/server.go b/internal/server/server.go index ad2bfde..00fe705 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -68,7 +68,7 @@ func (s *Server) SetProjectAuthorizer(fn func(owner string, projectID core.ID) ( } // authorizeSharedProject 读取路径的共享判定:适配器未注册或拒绝/出错一律 false -//(fail-closed;错误不区分,避免泄露存在性)。 +// (fail-closed;错误不区分,避免泄露存在性)。 func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool { if s.ProjectAuthz == nil { return false @@ -1078,26 +1078,61 @@ func kindOf(filename string) string { func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) - if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil { - writeStoreErr(w, err) + pid := r.PathValue("id") + if _, err := s.st.GetProject(owner, pid); err != nil { + // EXT-8:owner 自有未命中时问适配器;放行则返回项目全部工件(只读共享)。 + if !s.authorizeSharedProject(owner, core.ID(pid)) { + writeStoreErr(w, err) + return + } + as, err := s.st.ListProjectArtifactsAll(pid) + writeJSONOr(w, as, err) return } - as, err := s.st.ListArtifacts(owner, r.PathValue("id")) + as, err := s.st.ListArtifacts(owner, pid) writeJSONOr(w, as, err) } +// sharedArtifactGet EXT-8 工件读取回退:owner 自有未命中 → 取元数据问适配器, +// 放行才返回;否则保持 NotFound(不泄露存在性)。 +func (s *Server) sharedArtifactGet(owner, id string) (core.Artifact, bool) { + a, err := s.st.GetArtifactByID(id) + if err != nil { + return core.Artifact{}, false + } + if !s.authorizeSharedProject(owner, a.ProjectID) { + return core.Artifact{}, false + } + return a, true +} + func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) { - a, err := s.st.GetArtifact(s.owner(r), r.PathValue("id")) + owner := s.owner(r) + a, err := s.st.GetArtifact(owner, r.PathValue("id")) + if err != nil { + shared, ok := s.sharedArtifactGet(owner, r.PathValue("id")) + if !ok { + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, shared) + return + } writeJSONOr(w, a, err) } func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) { - a, err := s.st.GetArtifact(s.owner(r), r.PathValue("id")) + owner := s.owner(r) + a, err := s.st.GetArtifact(owner, r.PathValue("id")) if err != nil { - writeStoreErr(w, err) - return + shared, ok := s.sharedArtifactGet(owner, r.PathValue("id")) + if !ok { + writeStoreErr(w, err) + return + } + a = shared } - content, err := s.st.ArtifactContent(s.owner(r), a.ID) + content, err := s.st.ArtifactContentByID(a.ID) if err != nil { writeStoreErr(w, err) return diff --git a/internal/store/content.go b/internal/store/content.go index 3f8a0e3..792d49d 100644 --- a/internal/store/content.go +++ b/internal/store/content.go @@ -111,6 +111,51 @@ func (s *Store) ListArtifacts(owner, projectID string) ([]core.Artifact, error) return out, rows.Err() } +// ---- EXT-8 共享读取助手:无 owner 过滤,仅供 HTTP 层在 +// authorizeSharedProject 判定通过后使用;其他调用点一律禁止(会破坏租户边界)。---- + +// GetArtifactByID 按工件 ID 取元数据(无 owner 过滤;调用方须先过 EXT-8 判定)。 +func (s *Store) GetArtifactByID(id string) (core.Artifact, error) { + a, err := scanArtifact(func(dst ...any) error { + return s.db.QueryRow(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE id=?`), id).Scan(dst...) + }) + if errors.Is(err, sql.ErrNoRows) { + return a, ErrNotFound + } + return a, err +} + +// ArtifactContentByID 按工件 ID 取内容(无 owner 过滤;调用方须先过 EXT-8 判定)。 +func (s *Store) ArtifactContentByID(id string) ([]byte, error) { + var ref string + err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=?`), id).Scan(&ref) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return s.blobs.Get(ref) +} + +// ListProjectArtifactsAll 项目全部工件(含其他 owner;调用方须先过 EXT-8 判定)。 +func (s *Store) ListProjectArtifactsAll(projectID string) ([]core.Artifact, error) { + rows, err := s.db.Query(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE project_id=? ORDER BY updated_at DESC`), projectID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []core.Artifact{} + for rows.Next() { + a, err := scanArtifact(rows.Scan) + if err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + // DeleteArtifact 先删行后删对象:崩溃间隙只可能残留无主文件(可 GC),不悬空引用。 func (s *Store) DeleteArtifact(owner, id string) error { var path string -- 2.54.0