Merge pull request 'Consult the EXT-8 resource authorizer in artifact read paths' (#105) from feature/ext-8-resource-authorizer into main
ci / vet-and-test (push) Successful in 42s

This commit was merged in pull request #105.
This commit is contained in:
2026-10-08 15:42:12 +00:00
16 changed files with 483 additions and 30 deletions
+4 -1
View File
@@ -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
+6
View File
@@ -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
+33 -3
View File
@@ -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 时用管线默认值。
+5 -3
View File
@@ -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()}
}
+1 -1
View File
@@ -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")
}
+68
View File
@@ -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)
}
// 两租户各产生一条命令审计(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("bob", 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])
}
}
}
+1 -1
View File
@@ -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 里同时声明按钮入口与对话入口。
// 新命令漏登记则本测试失败,合入被挡住。
+123
View File
@@ -0,0 +1,123 @@
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/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/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/artifacts/"+aid); 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])
}
+62 -9
View File
@@ -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 {
@@ -860,7 +878,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)
}
@@ -1060,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
+18 -9
View File
@@ -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
+77
View File
@@ -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)
}
}
+45
View File
@@ -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
+24
View File
@@ -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`,
}}},
)
}
+2 -2
View File
@@ -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 {
+12 -1
View File
@@ -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 是随下游二进制编译进来的扩展。
+2
View File
@@ -26,7 +26,9 @@ Registry.AddCommand
Registry.AddCommandHandler
Registry.AddSkill
Registry.Note
Registry.RegisterResourceAuthorizer
Request
ResetCompiled
ResourceAuthorizer
SetRunner
SurfaceVersion