diff --git a/internal/app/app.go b/internal/app/app.go index 5955026..67587f3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -55,6 +55,7 @@ import ( "agent-system-backend/internal/surfacesvc" "agent-system-backend/internal/tools" "agent-system-backend/internal/workflow" + "agent-system-backend/pkg/extension" core "agent-system-core" ) @@ -594,7 +595,8 @@ func Run(opt Options) error { pluginHost.OnCommands = bus.RegisterPluginCommands // hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。 authzSlot := &projectAuthz{} - caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc} + projectSlot := &projectLifecycleSlot{} + caps := hostcaps{projects: projectSlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc} // EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面 // 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。 mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool { @@ -607,6 +609,16 @@ func Run(opt Options) error { // EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。 wireScopedHostFactory(caps) compiledSummary := applyCompiled(skillsRoot, bus, caps) + mgr.SetProjectWorkAuthorization(projectSlot.work()) + mgr.SetKnowledgeProjectScope(func(ctx context.Context, actor string, id core.ID) (core.ID, error) { + return projectSlot.resolve(ctx, caps, actor, id) + }) + if authzSlot.hasV2() { + mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool { + ok, err := authzSlot.AuthorizeResource(extension.ResourceAuthzQuery{Actor: actor, Action: "model-context", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source}) + return err == nil && ok + }) + } mgr.OnUsage = func(owner string, tokens int) { budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0)) status, aerr := st.AccountTokens(owner, tokens, budget) @@ -631,28 +643,21 @@ func Run(opt Options) error { } // EXT-8 余项:hostapi 检索面同样吃适配器共享(本人 + 放行的他人项目知识)。 hostSrv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) { - own, err := caps.searchDefault(ctx, owner, text) - if err != nil { - return nil, err - } - shared, err := st.SearchKnowledgeShared(owner, core.RetrievalQuery{Text: text, TopK: 5}, func(docOwner, projectID core.ID) bool { - ok, aerr := authzSlot.Authorize(owner, projectID) - return aerr == nil && ok - }) - if err != nil { - return nil, err - } - return append(own, shared...), nil + return caps.searchDefault(ctx, owner, text) } + hostSrv.Complete = func(ctx context.Context, owner, prompt string) (string, error) { return caps.complete(ctx, owner, prompt) } srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost) + projectSlot.wire(srv, caps) // EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。 srv.SetProjectAuthorizer(authzSlot.Authorize) // EXT-8b:资源级适配器注入读面(未注册=server 回落 V1,行为不变)。 - srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource) + if authzSlot.hasV2() { + srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource) + } // EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。 srv.DisableUploadIndexing = opt.DisableUploadIndexing // EXT-12 最小开关:会话卡不镜像进项目公共流(cardSvc 在上方已建)。 diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 9c75678..62cebe5 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -21,6 +21,7 @@ import ( // 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内 // inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。 type hostcaps struct { + projects *projectLifecycleSlot st *store.Store pipe *rag.Pipeline set *settings.Service @@ -71,10 +72,16 @@ func (a *projectAuthz) AuthorizeResource(q extension.ResourceAuthzQuery) (bool, if fn1 == nil || q.Owner == "" || q.ProjectID == "" { return false, nil } - return fn1(q.Owner, q.ProjectID) + return fn1(q.Actor, q.ProjectID) } // Authorize owner×project 共享读取判定;未注册一律 false(默认拒绝)。 +func (a *projectAuthz) hasV2() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.fn2 != nil +} + func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) { a.mu.Lock() fn := a.fn @@ -103,6 +110,19 @@ type sharedRetriever interface { func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) { q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK} + if c.projects != nil && projectID != "" { + id, err := c.projects.resolve(ctx, c, owner, projectID) + if err != nil { + return nil, err + } + q.ProjectID = id + } + if c.authz != nil && c.authz.hasV2() { + return c.pipe.RetrieveAuthorized(ctx, core.ID(owner), q, func(d core.KnowledgeDoc) bool { + ok, err := c.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source}) + return err == nil && ok + }) + } // EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走 // 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。 if c.authz != nil { @@ -265,6 +285,20 @@ func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text return h.caps.search(ctx, h.owner, projectID, text, topK) } +// knowledgeSource preserves ordinary host Source semantics. A lifecycle provider +// introduces shared publication provenance, so plugins may only assert their own +// namespace; another plugin cannot impersonate an RDM published row. +func (h inprocHost) knowledgeSource(source string) error { + if h.caps.projects == nil || !h.caps.projects.active() { + return nil + } + prefix := "plugin:" + h.pluginID + if source == prefix || strings.HasPrefix(source, prefix+":") { + return nil + } + return fmt.Errorf("extension: knowledge source must belong to plugin %q", h.pluginID) +} + // KnowledgeWrite 强制 owner 归属:Owner 改写为本次分发 owner,Source 为空补 // "plugin:";embed=false 只写全文行,embed=true 走 IndexChunks 建向量 // (embedding 失败降级纯全文,不阻断写入)。 @@ -278,6 +312,9 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc if docs[i].Source == "" { docs[i].Source = src } + if err := h.knowledgeSource(docs[i].Source); err != nil { + return err + } } if !embed { return h.caps.st.AddKnowledge(docs...) @@ -292,24 +329,26 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD if len(docs) == 0 { return nil } - refs := map[string]bool{} + type publicationRef struct{ projectID, ref string } + refs := map[publicationRef]bool{} for i := range docs { docs[i].Owner = core.ID(h.owner) if docs[i].Source == "" { docs[i].Source = "plugin:" + h.pluginID } + if err := h.knowledgeSource(docs[i].Source); err != nil { + return err + } if docs[i].Ref != "" { - refs[docs[i].Ref] = true + refs[publicationRef{docs[i].ProjectID, docs[i].Ref}] = true } } for ref := range refs { - if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil { - return err - } - if err := h.caps.st.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil { + if err := h.caps.st.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref); err != nil { return err } } + return h.KnowledgeWrite(ctx, docs, embed) } diff --git a/internal/app/knowledge_authorization_test.go b/internal/app/knowledge_authorization_test.go new file mode 100644 index 0000000..7df87d3 --- /dev/null +++ b/internal/app/knowledge_authorization_test.go @@ -0,0 +1,105 @@ +package app + +import ( + "agent-system-backend/internal/rag" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "testing" +) + +func TestHostKnowledgeV2AndProjectReplacement(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/t.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + pipe := rag.NewPipeline(st, settings.NewService(st), nil, nil) + auth := &projectAuthz{} + alice := inprocHost{owner: "alice", pluginID: "rdm", caps: hostcaps{st: st, pipe: pipe, authz: auth}} + first := []core.KnowledgeDoc{{ID: "slice-1", ProjectID: "domain-1", Ref: "notes.md", Chunk: "sharedneedle one"}} + second := []core.KnowledgeDoc{{ID: "slice-2", ProjectID: "domain-2", Ref: "notes.md", Chunk: "sharedneedle two"}} + for _, ds := range [][]core.KnowledgeDoc{first, second, second} { + if err := alice.KnowledgePublish(context.Background(), ds, false); err != nil { + t.Fatal(err) + } + } + own, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "sharedneedle"}) + if err != nil || len(own) != 2 { + t.Fatalf("cross project publish replaced first: %+v %v", own, err) + } + auth.set("rdm", func(string, core.ID) (bool, error) { return true, nil }) + auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { + return q.ResourceType == "project" || (q.ResourceType == "knowledge" && q.ResourceID == "slice-1" && q.ProjectID == "domain-1" && q.Actor == "bob" && q.Owner == "alice"), nil + }) + bob := inprocHost{owner: "bob", pluginID: "rdm", caps: alice.caps} + hits, err := bob.KnowledgeSearch(context.Background(), "", "sharedneedle", 5) + if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-1" { + t.Fatalf("host V2=%+v %v", hits, err) + } +} + +func TestHostKnowledgeResolvesTrustedProjectScope(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/t.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil { + t.Fatal(err) + } + slot := &projectLifecycleSlot{} + provider := completeLifecycle() + provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) { + if h.Owner() != "bob" { + t.Fatalf("wrong scope actor %q", h.Owner()) + } + return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}}, nil + } + caps := hostcaps{st: st, pipe: rag.NewPipeline(st, settings.NewService(st), nil, nil), authz: &projectAuthz{}, projects: slot} + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil { + t.Fatal(err) + } + caps.authz.setV2("rdm", func(extension.ResourceAuthzQuery) (bool, error) { return true, nil }) + h := inprocHost{owner: "bob", pluginID: "rdm", caps: caps} + hits, err := h.KnowledgeSearch(context.Background(), "host-current", "scopeneedle", 5) + if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" { + t.Fatalf("host scope: %+v %v", hits, err) + } +} + +func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/t.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + slot := &projectLifecycleSlot{} + caps := hostcaps{st: st, projects: slot} + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } + h := inprocHost{owner: "alice", pluginID: "other", caps: caps} + for _, publish := range []bool{false, true} { + docs := []core.KnowledgeDoc{{ID: "claimed-published", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "ref", Chunk: "spoofneedle"}} + var err error + if publish { + err = h.KnowledgePublish(context.Background(), docs, false) + } else { + err = h.KnowledgeWrite(context.Background(), docs, false) + } + if err == nil { + t.Fatalf("foreign namespace accepted publish=%v", publish) + } + } + if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "spoofneedle"}); err != nil || len(hits) != 0 { + t.Fatalf("foreign source wrote data: %+v %v", hits, err) + } + // Existing ordinary Hosts preserve their documented caller-provided Source. + h.caps.projects = nil + if err := h.KnowledgeWrite(context.Background(), []core.KnowledgeDoc{{Source: "manual", Chunk: "ordinaryneedle"}}, false); err != nil { + t.Fatalf("ordinary source semantics changed: %v", err) + } +} diff --git a/internal/app/project_lifecycle.go b/internal/app/project_lifecycle.go new file mode 100644 index 0000000..883d5c2 --- /dev/null +++ b/internal/app/project_lifecycle.go @@ -0,0 +1,121 @@ +package app + +import ( + "agent-system-backend/internal/projectscope" + "agent-system-backend/internal/server" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "errors" + "sync" +) + +type projectLifecycleSlot struct { + mu sync.Mutex + holder string + provider *extension.ProjectLifecycle +} + +func (r compiledRegistry) RegisterProjectLifecycle(extension.ProjectLifecycle) error { + return errors.New("extension: declarative plugins cannot register project lifecycle") +} +func (r pluginRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error { + if r.caps.projects == nil || r.pluginID == "" { + return errors.New("extension: project lifecycle slot unavailable") + } + if p.List == nil || p.Create == nil || p.Rename == nil || p.Archive == nil || p.Work == nil { + return errors.New("extension: project lifecycle requires all callbacks") + } + slot := r.caps.projects + slot.mu.Lock() + defer slot.mu.Unlock() + if slot.provider != nil { + return errors.New("extension: project lifecycle already registered") + } + slot.holder, slot.provider = r.pluginID, &p + return nil +} +func (r recordRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error { + reg, ok := r.Registry.(extension.ProjectLifecycleRegistry) + if !ok { + return errors.New("extension: project lifecycle registration unavailable") + } + return reg.RegisterProjectLifecycle(p) +} +func (slot *projectLifecycleSlot) wire(srv *server.Server, caps hostcaps) { + slot.mu.Lock() + defer slot.mu.Unlock() + if slot.provider == nil { + return + } + holder := slot.holder + srv.SetProjectLifecycle(*slot.provider, func(owner string) extension.Host { + return inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder} + }) +} + +func (slot *projectLifecycleSlot) active() bool { + slot.mu.Lock() + defer slot.mu.Unlock() + return slot.provider != nil +} + +func (slot *projectLifecycleSlot) work() func(context.Context, string, core.ID) (bool, error) { + slot.mu.Lock() + defer slot.mu.Unlock() + if slot.provider == nil { + return nil + } + return slot.provider.Work +} + +func (slot *projectLifecycleSlot) resolve(ctx context.Context, caps hostcaps, owner string, id core.ID) (core.ID, error) { + slot.mu.Lock() + if slot.provider == nil { + slot.mu.Unlock() + return id, nil + } + p, holder := *slot.provider, slot.holder + slot.mu.Unlock() + return projectscope.Resolve(ctx, inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}, p, id) +} + +var _ extension.ProjectHost = inprocHost{} + +func (h inprocHost) OwnedProjects() ([]core.Project, error) { + ps, err := h.caps.st.ListProjects(h.owner) + for i := range ps { + ps[i].Owner = h.owner + } + return ps, err +} +func (h inprocHost) OwnedProject(id string) (core.Project, error) { + return h.caps.st.GetProject(h.owner, id) +} +func (h inprocHost) ProjectMetadata(id string) (core.Project, error) { + p, err := h.caps.st.ProjectMetadata(id) + if err != nil { + return core.Project{}, err + } + if p.Owner == h.owner { + return p, nil + } + if h.caps.authz == nil { + return core.Project{}, store.ErrNotFound + } + ok, err := h.caps.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: h.owner, Action: "read", ResourceType: "project", ResourceID: core.ID(id), Owner: p.Owner, ProjectID: core.ID(id)}) + if err != nil || !ok { + return core.Project{}, store.ErrNotFound + } + return p, nil +} +func (h inprocHost) EnsureProject(id, name string) (core.Project, error) { + return h.caps.st.EnsureProject(h.owner, id, name) +} +func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) { + return h.caps.st.RenameOwnedProject(h.owner, id, name) +} +func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) { + return h.caps.st.ProjectNameAvailable(name, exceptID) +} diff --git a/internal/app/project_lifecycle_test.go b/internal/app/project_lifecycle_test.go new file mode 100644 index 0000000..ad261c0 --- /dev/null +++ b/internal/app/project_lifecycle_test.go @@ -0,0 +1,77 @@ +package app + +import ( + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "errors" + "testing" +) + +func completeLifecycle() extension.ProjectLifecycle { + return extension.ProjectLifecycle{ + List: func(context.Context, extension.Host) ([]core.Project, error) { return nil, nil }, + Create: func(context.Context, extension.Host, string) (core.Project, error) { return core.Project{}, nil }, + Rename: func(context.Context, extension.Host, string, string) (core.Project, error) { + return core.Project{}, nil + }, + Archive: func(context.Context, extension.Host, string) error { return nil }, + Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }, + } +} +func TestProjectLifecycleOptionalRegistration(t *testing.T) { + slot := &projectLifecycleSlot{} + reg := pluginRegistry{pluginID: "rdm", caps: hostcaps{projects: slot}} + if err := reg.RegisterProjectLifecycle(extension.ProjectLifecycle{}); err == nil { + t.Fatal("incomplete registration accepted") + } + if err := reg.RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } + if err := reg.RegisterProjectLifecycle(completeLifecycle()); err == nil { + t.Fatal("duplicate registration accepted") + } + if err := (compiledRegistry{}).RegisterProjectLifecycle(completeLifecycle()); err == nil { + t.Fatal("declarative callback accepted") + } + rr := recordRegistry{Registry: pluginRegistry{pluginID: "other", caps: hostcaps{projects: &projectLifecycleSlot{}}}} + optional, ok := any(rr).(extension.ProjectLifecycleRegistry) + if !ok { + t.Fatal("record registry hides optional registration") + } + if err := optional.RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } +} +func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/t.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + alice := inprocHost{owner: "alice", caps: hostcaps{st: st}} + p, err := alice.EnsureProject("stable", "Shared") + if err != nil { + t.Fatal(err) + } + auth := &projectAuthz{} + auth.set("rdm", func(owner string, id core.ID) (bool, error) { return owner == "bob" && id == core.ID(p.ID), nil }) + bob := inprocHost{owner: "bob", caps: hostcaps{st: st, authz: auth}} + if _, err := bob.ProjectMetadata(p.ID); err != nil { + t.Fatal(err) + } + if _, err := bob.OwnedProject(p.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("owned lookup=%v", err) + } + if _, err := bob.RenameOwnedProject(p.ID, "Other"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("shared rename=%v", err) + } + if _, err := bob.EnsureProject(p.ID, p.Name); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("shared ensure=%v", err) + } + auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { return false, nil }) + if _, err := bob.ProjectMetadata(p.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("V2 denial ignored=%v", err) + } +} diff --git a/internal/projectscope/projectscope.go b/internal/projectscope/projectscope.go new file mode 100644 index 0000000..25bc722 --- /dev/null +++ b/internal/projectscope/projectscope.go @@ -0,0 +1,35 @@ +// Package projectscope resolves host retrieval filters using service-provided +// lifecycle metadata. Stored/client settings never supply an authority mapping. +package projectscope + +import ( + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" +) + +func Resolve(ctx context.Context, host extension.Host, p extension.ProjectLifecycle, id core.ID) (core.ID, error) { + if id == "" { + return id, nil + } + if p.List == nil { + return "", store.ErrNotFound + } + projects, err := p.List(ctx, host) + if err != nil { + return "", err + } + for _, project := range projects { + domainID, _ := project.Settings["rdm.projectId"].(string) + if project.ID != id && domainID != id { + continue + } + state, _ := project.Settings["rdm.state"].(string) + if domainID == "" || state != "ready" { + return "", store.ErrNotFound + } + return core.ID(domainID), nil + } + return "", store.ErrNotFound +} diff --git a/internal/rag/hybrid.go b/internal/rag/hybrid.go index ba4c976..e9438d2 100644 --- a/internal/rag/hybrid.go +++ b/internal/rag/hybrid.go @@ -174,19 +174,66 @@ func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.Ret return merged, nil } +// RetrieveAuthorized is the resource-level path for HTTP, Host and model +// context. It filters owned and shared candidates before external reranking. +func (p *Pipeline) RetrieveAuthorized(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { + own, err := p.retrieve(ctx, owner, q, allow) + if err != nil { + return nil, err + } + shared, err := p.st.SearchKnowledgeSharedAuthorized(string(owner), q, allow) + if err != nil { + return nil, err + } + if p.embedder != nil { + if vectors, e := p.embedder.Embed(ctx, []string{q.Text}); e == nil && len(vectors) > 0 && len(vectors[0]) > 0 { + topK := q.TopK + if topK <= 0 { + topK = 5 + } + vs, e := p.st.SearchKnowledgeVectorsSharedAuthorized(ctx, string(owner), embedderModel(p.embedder), vectors[0], topK, q, allow) + if e == nil { + shared = rrfMerge(vs, shared) + } + } + } + merged := rrfMerge(own, shared) + if limit := q.TopK; limit > 0 && len(merged) > limit { + merged = merged[:limit] + } + return merged, nil +} +func authorizedHits(hits []core.RetrievalHit, allow func(core.KnowledgeDoc) bool) []core.RetrievalHit { + if allow == nil { + return hits + } + out := make([]core.RetrievalHit, 0, len(hits)) + for _, h := range hits { + if allow(h.Doc) { + out = append(out, h) + } + } + return out +} + // Retrieve 统一检索(core.Retriever)。 func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) { + return p.retrieve(ctx, owner, q, nil) +} +func (p *Pipeline) retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { topK := q.TopK if topK <= 0 { topK = 5 } if p.embedder == nil { - return p.st.SearchKnowledge(owner, q) + hits, err := p.st.SearchKnowledge(owner, q) + return authorizedHits(hits, allow), err } route := p.embeddingRoute(ctx) offline, _ := p.embedder.(interface{ Offline() bool }) if !route.OK() && (offline == nil || !offline.Offline()) { - return p.st.SearchKnowledge(owner, q) + hits, err := p.st.SearchKnowledge(owner, q) + return authorizedHits(hits, allow), err } qvecs, err := p.embedder.Embed(ctx, []string{q.Text}) if err != nil || len(qvecs) == 0 || len(qvecs[0]) == 0 { @@ -194,7 +241,8 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval if err != nil { log.Printf("[rag] query embedding fallback to fulltext: %v", err) } - return p.st.SearchKnowledge(owner, q) + hits, err := p.st.SearchKnowledge(owner, q) + return authorizedHits(hits, allow), err } q.TopK = topK * 2 // 每路多取,融合后再裁剪 vhits, err := p.st.SearchKnowledgeVectorsModel(ctx, owner, embedderModel(p.embedder), qvecs[0], topK*2, q) @@ -205,7 +253,7 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval if err != nil { return nil, err } - merged := rrfMerge(vhits, fhits) + merged := authorizedHits(rrfMerge(vhits, fhits), allow) if p.reranker != nil && p.rerankRoute(ctx).OK() && len(merged) > 0 { docs := make([]core.KnowledgeDoc, len(merged)) for i, h := range merged { diff --git a/internal/runmgr/knowledge_resource_test.go b/internal/runmgr/knowledge_resource_test.go new file mode 100644 index 0000000..70c2a59 --- /dev/null +++ b/internal/runmgr/knowledge_resource_test.go @@ -0,0 +1,66 @@ +package runmgr + +import ( + "agent-system-backend/internal/rag" + "agent-system-backend/internal/store" + core "agent-system-core" + "context" + "encoding/json" + "errors" + "testing" +) + +func TestRunKnowledgeResourceAuthorization(t *testing.T) { + rc, st := newExecEnv(t) + if err := st.AddKnowledge(core.KnowledgeDoc{ID: "allowed-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery published"}, core.KnowledgeDoc{ID: "private-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery private"}, core.KnowledgeDoc{ID: "restricted-self", Owner: "alice", ProjectID: rc.run.ProjectID, Chunk: "resourcequery self"}); err != nil { + t.Fatal(err) + } + rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil)) + rc.mgr.SetSharedKnowledgeAllow(func(string, core.ID) bool { return true }) + rc.mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool { + return actor == "alice" && d.ID == "allowed-slice" && d.Owner == "bob" + }) + hits, err := rc.Retrieve(core.RetrievalQuery{Text: "resourcequery"}) + if err != nil || len(hits) != 1 || hits[0].Doc.ID != "allowed-slice" { + t.Fatalf("model-context resources: %+v %v", hits, err) + } +} + +func TestProjectWorkRevocationGuardsDirectExecution(t *testing.T) { + rc, st := newExecEnv(t) + rc.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return false, nil }) + conv, err := st.GetConversation("alice", rc.run.ConversationID) + if err != nil { + t.Fatal(err) + } + if _, err := rc.mgr.StartConversationRun(conv, core.Message{}, nil, nil); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("direct run should reject: %v", err) + } + res := rc.ExecuteTool(core.ToolCallRequest{Name: "write_artifact", Arguments: json.RawMessage(`{"name":"forbidden.md","content":"x"}`)}) + if res.Status != "error" { + t.Fatalf("revoked execution: %+v", res) + } + artifacts, err := st.ListArtifacts("alice", rc.run.ProjectID) + if err != nil || len(artifacts) != 0 { + t.Fatalf("revoked tool wrote: %+v %v", artifacts, err) + } +} + +func TestRunKnowledgeResolvesTrustedProjectScope(t *testing.T) { + rc, st := newExecEnv(t) + if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "bob", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "bob", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil { + t.Fatal(err) + } + rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil)) + rc.mgr.SetKnowledgeResourceAllow(func(string, core.KnowledgeDoc) bool { return true }) + rc.mgr.SetKnowledgeProjectScope(func(_ context.Context, actor string, id core.ID) (core.ID, error) { + if actor != "alice" || id != rc.run.ProjectID { + t.Fatalf("untrusted scope actor=%s project=%s", actor, id) + } + return "domain-current", nil + }) + hits, err := rc.Retrieve(core.RetrievalQuery{Text: "scopeneedle"}) + if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" { + t.Fatalf("model current scope: %+v %v", hits, err) + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 1086b73..b9d7ddb 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -57,7 +57,10 @@ type Manager struct { retriever core.Retriever // 可选混合检索(nil = store 全文) // sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属 // projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。 - sharedKnowledgeAllow func(owner string, projectID core.ID) bool + sharedKnowledgeAllow func(owner string, projectID core.ID) bool + knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool + knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error) + projectWorkAuthorization func(context.Context, string, core.ID) (bool, error) // commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。 commands CommandSource indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块) @@ -197,6 +200,37 @@ func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.I m.sharedKnowledgeAllow = fn } +// SetKnowledgeResourceAllow makes resource-level authorization authoritative for +// model context, including owned rows. The callback receives stored identities. +func (m *Manager) SetKnowledgeResourceAllow(fn func(string, core.KnowledgeDoc) bool) { + m.knowledgeResourceAllow = fn +} + +// SetProjectWorkAuthorization is the live domain guard for starting runs and +// executing tools. Nil preserves the ordinary unregistered host behavior. +func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, core.ID) (bool, error)) { + m.projectWorkAuthorization = fn +} +func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error { + if m.projectWorkAuthorization == nil { + return nil + } + ok, err := m.projectWorkAuthorization(ctx, owner, id) + if err != nil { + return err + } + if !ok { + return store.ErrNotFound + } + return nil +} + +// SetKnowledgeProjectScope binds host project filters to the provider's trusted +// canonical domain metadata without widening the current conversation scope. +func (m *Manager) SetKnowledgeProjectScope(fn func(context.Context, string, core.ID) (core.ID, error)) { + m.knowledgeProjectScope = fn +} + // SetCommandSource 注入命令即工具源(EXT-14;组合根以命令总线适配)。nil = 现状。 func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src } @@ -902,6 +936,40 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { if q.ProjectID == "" { q.ProjectID = c.run.ProjectID } + if c.mgr.knowledgeProjectScope != nil { + id, err := c.mgr.knowledgeProjectScope(c.ctx, c.run.Owner, q.ProjectID) + if err != nil { + return nil, err + } + q.ProjectID = id + } + if c.mgr.knowledgeResourceAllow != nil { + allow := func(d core.KnowledgeDoc) bool { return c.mgr.knowledgeResourceAllow(string(c.run.Owner), d) } + if rs, ok := c.mgr.retriever.(interface { + RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) + }); ok { + return rs.RetrieveAuthorized(c.ctx, c.run.Owner, q, allow) + } + hits, err := c.mgr.st.SearchKnowledge(c.run.Owner, q) + if err != nil { + return nil, err + } + out := []core.RetrievalHit{} + for _, h := range hits { + if allow(h.Doc) { + out = append(out, h) + } + } + shared, err := c.mgr.st.SearchKnowledgeSharedAuthorized(c.run.Owner, q, allow) + if err != nil { + return nil, err + } + out = append(out, shared...) + if len(out) > q.TopK { + out = out[:q.TopK] + } + return out, nil + } if c.mgr.retriever != nil { if c.mgr.sharedKnowledgeAllow != nil { if rs, ok := c.mgr.retriever.(sharedRetriever); ok { @@ -917,6 +985,9 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { // ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。 func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { + if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()} + } policy := c.prof.Tools if !policy.ToolAllowed(call.Name) { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)} @@ -1141,6 +1212,9 @@ func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *wo // attachments 为已解析的附件文档(server 层经 ingest 处理)。 // inputs 是同一批附件的原件,落到会话工作区 inputs/,供模板和仿制使用。 func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) { + if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { + return core.Run{}, err + } if err := m.claimConversation(conv.ID); err != nil { return core.Run{}, err } @@ -1150,6 +1224,9 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core. // StartWorkflowRun 启动一条绑定工作流的会话 run。定义在这里拷贝, // 之后插件卸载不会改到已经在跑的这份。 func (m *Manager) StartWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) { + if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { + return core.Run{}, err + } if err := m.claimConversation(conv.ID); err != nil { return core.Run{}, err } @@ -1259,6 +1336,10 @@ func (m *Manager) claimConversation(conversationID string) error { } func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile, launch *workflow.Launch) (core.Run, error) { + if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { + m.releaseConversation(conv.ID) + return core.Run{}, err + } prof, _ := m.profiles.Get(conv.Owner, conv.ProfileID) eff := m.settings.Effective(conv.Owner, conv.ProjectID) run := core.Run{ diff --git a/internal/server/borrow.go b/internal/server/borrow.go index e0147fb..1ad8fd1 100644 --- a/internal/server/borrow.go +++ b/internal/server/borrow.go @@ -164,6 +164,10 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } + if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil { + writeProjectErr(w, err) + return + } var req struct { MessageID string `json:"messageId"` } diff --git a/internal/server/platform_api.go b/internal/server/platform_api.go index ee5ae69..bd7d9e9 100644 --- a/internal/server/platform_api.go +++ b/internal/server/platform_api.go @@ -273,6 +273,20 @@ func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) { http.Error(w, "surface capability not enabled", http.StatusNotImplemented) return } + if s.projectLifecycle != nil { + snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id"))) + if err != nil { + s.writeSurfaceError(w, err) + return + } + if snap.Identity.ProjectID != "" { + if err := s.projectWork(r.Context(), s.owner(r), snap.Identity.ProjectID); err != nil { + writeProjectErr(w, err) + return + } + } + } + // 注意:本端点不绑定任何 confirmed 类字段——高危确认只能经既有确认卡回道 // (bus.Confirm)置位,客户端/模型无法伪造(U5 提案第二节第 2 条红测)。 var body struct { diff --git a/internal/server/project_lifecycle.go b/internal/server/project_lifecycle.go new file mode 100644 index 0000000..eb4d747 --- /dev/null +++ b/internal/server/project_lifecycle.go @@ -0,0 +1,73 @@ +package server + +import ( + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "errors" + "net/http" +) + +// SetProjectLifecycle is wired only for a registered in-process provider. The +// optional host factory constructs a capability scoped to this request's actor. +func (s *Server) SetProjectLifecycle(p extension.ProjectLifecycle, host func(string) extension.Host) { + s.projectLifecycle = &p + s.projectHost = host +} +func (s *Server) lifecycleHost(owner string) extension.Host { + if s.projectHost == nil { + return nil + } + return s.projectHost(owner) +} +func (s *Server) projectWork(ctx context.Context, owner, projectID string) error { + if s.projectLifecycle == nil { + return nil + } + if s.projectLifecycle.Work == nil { + return store.ErrNotFound + } + ok, err := s.projectLifecycle.Work(ctx, owner, core.ID(projectID)) + if err != nil { + return err + } + if !ok { + return store.ErrNotFound + } + return nil +} + +// createPrivateConversation is shared by the API and /new. Only live work +// authorization grants member creation; the resulting conversation stays owned. +func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) { + if s.projectLifecycle != nil { + if err := s.projectWork(ctx, owner, projectID); err != nil { + return core.Conversation{}, err + } + return s.st.CreateConversationInProject(owner, projectID, title, profile) + } + c, err := s.st.CreateConversation(owner, projectID, title, profile) + if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) { + return s.st.CreateConversationInProject(owner, projectID, title, profile) + } + return c, err +} +func writeProjectErr(w http.ResponseWriter, err error) { + var coded interface{ StatusCode() int } + if errors.As(err, &coded) { + switch code := coded.StatusCode(); code { + case 400, 403, 404, 409, 503: + http.Error(w, err.Error(), code) + return + } + } + writeStoreErr(w, err) +} +func writeProjectJSON(w http.ResponseWriter, p any, err error) { + if err != nil { + writeProjectErr(w, err) + return + } + writeJSON(w, http.StatusOK, p) +} diff --git a/internal/server/project_lifecycle_test.go b/internal/server/project_lifecycle_test.go new file mode 100644 index 0000000..61ea6dc --- /dev/null +++ b/internal/server/project_lifecycle_test.go @@ -0,0 +1,199 @@ +package server + +import ( + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/surfacesvc" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +type lifecycleStatusError int + +func (e lifecycleStatusError) Error() string { return "provider refusal" } +func (e lifecycleStatusError) StatusCode() int { return int(e) } +func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + r.Header.Set("Authorization", "Bearer "+key) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + return w +} +func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Original") + c, _ := st.CreateConversation("alice", p.ID, "Private", "") + calls := []string{} + s.SetProjectLifecycle(extension.ProjectLifecycle{ + List: func(context.Context, extension.Host) ([]core.Project, error) { + calls = append(calls, "list") + return []core.Project{{ID: p.ID, Owner: "alice", Name: "Decorated", Settings: map[string]any{"rdm.state": "ready"}}}, nil + }, + Create: func(_ context.Context, _ extension.Host, name string) (core.Project, error) { + calls = append(calls, "create") + return core.Project{ID: "provider-created", Name: name}, nil + }, + Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) { + calls = append(calls, "rename") + return core.Project{ID: id, Name: name}, nil + }, + Archive: func(context.Context, extension.Host, string) error { calls = append(calls, "archive"); return nil }, + Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }, + }, nil) + for _, q := range []struct{ method, path, body string }{{"GET", "/api/projects", ""}, {"POST", "/api/projects", `{"name":" New "}`}, {"PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {"DELETE", "/api/projects/" + p.ID, ""}} { + w := lifecycleRequest(t, s, key, q.method, q.path, q.body) + if w.Code != 200 { + t.Fatalf("%s: %d %s", q.method, w.Code, w.Body.String()) + } + } + if len(calls) != 4 || calls[0] != "list" || calls[1] != "create" || calls[2] != "rename" || calls[3] != "archive" { + t.Fatalf("calls=%v", calls) + } + if _, err := st.GetConversation("alice", c.ID); err != nil { + t.Fatalf("archive removed history: %v", err) + } + s.SetProjectLifecycle(extension.ProjectLifecycle{Create: func(context.Context, extension.Host, string) (core.Project, error) { + return core.Project{}, fmtWrap(lifecycleStatusError(409)) + }}, nil) + if w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"dup"}`); w.Code != 409 { + t.Fatalf("trusted status=%d %s", w.Code, w.Body.String()) + } +} +func fmtWrap(err error) error { return errors.Join(errors.New("wrapped"), err) } +func TestProjectLifecycleMemberPrivateWork(t *testing.T) { + s, st := newAuthTestServer(t) + alice, _ := st.CreateAPIKey("alice", "test") + bob, _ := st.CreateAPIKey("bob", "test") + mallory, _ := st.CreateAPIKey("mallory", "test") + p, _ := st.CreateProject("alice", "Shared") + ac, _ := st.CreateConversation("alice", p.ID, "Alice private", "") + ready := true + s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil }) + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) { + return ready && (actor == "alice" || actor == "bob") && id == core.ID(p.ID), nil + }}, nil) + w := lifecycleRequest(t, s, bob, "POST", "/api/projects/"+p.ID+"/conversations", `{"title":"Bob private"}`) + if w.Code != 200 { + t.Fatalf("member create=%d %s", w.Code, w.Body.String()) + } + var bc core.Conversation + if err := json.Unmarshal(w.Body.Bytes(), &bc); err != nil { + t.Fatal(err) + } + w = lifecycleRequest(t, s, bob, "GET", "/api/projects/"+p.ID+"/conversations", "") + var cs []core.Conversation + _ = json.Unmarshal(w.Body.Bytes(), &cs) + if w.Code != 200 || len(cs) != 1 || cs[0].ID != bc.ID { + t.Fatalf("member list=%d %+v", w.Code, cs) + } + if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+ac.ID, ""); w.Code != 404 { + t.Fatalf("private history=%d", w.Code) + } + if w := lifecycleRequest(t, s, mallory, "POST", "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 { + t.Fatalf("nonmember create=%d", w.Code) + } + if w := lifecycleRequest(t, s, mallory, "GET", "/api/projects/"+p.ID+"/conversations", ""); w.Code != 404 { + t.Fatalf("nonmember list=%d", w.Code) + } + s.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() }) + s.WireBuiltinCommands() + w = lifecycleRequest(t, s, bob, "POST", "/api/conversations/"+bc.ID+"/messages", `{"text":"/new"}`) + if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte(`"conversation"`)) { + t.Fatalf("/new=%d %s", w.Code, w.Body.String()) + } + ready = false + for _, q := range []struct{ key, method, path, body string }{{bob, "POST", "/api/projects/" + p.ID + "/conversations", `{}`}, {bob, "GET", "/api/projects/" + p.ID + "/conversations", ""}, {alice, "POST", "/api/conversations/" + ac.ID + "/messages", `{"text":"new work"}`}, {bob, "POST", "/api/commands/conversation.create", `{"conversationId":"` + bc.ID + `"}`}} { + w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body) + if w.Code != 404 { + t.Fatalf("revoked %s=%d %s", q.path, w.Code, w.Body.String()) + } + } + if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+bc.ID, ""); w.Code != 200 { + t.Fatalf("owned revoked history=%d", w.Code) + } +} +func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Shared") + s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil }) + s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil) + if w := lifecycleRequest(t, s, key, http.MethodPost, "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 { + t.Fatalf("read-only write=%d %s", w.Code, w.Body.String()) + } + if cs, err := st.ListConversations("bob", p.ID); err != nil || len(cs) != 0 { + t.Fatalf("denial wrote state: %v %v", cs, err) + } +} + +func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Project") + conv, _ := st.CreateConversation("alice", p.ID, "Private", "") + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil) + card := core.HITLCard{ID: "hitl-revoked", ConversationID: conv.ID, RunID: "run-revoked", Kind: "confirm"} + if err := st.SaveHITLCard("alice", &card); err != nil { + t.Fatal(err) + } + for _, q := range []struct{ path, body string }{{"/api/conversations/" + conv.ID + "/regenerate", `{}`}, {"/api/hitl/" + card.ID + "/resolve", `{"choiceId":"yes"}`}} { + w := lifecycleRequest(t, s, key, "POST", q.path, q.body) + if w.Code != 404 { + t.Fatalf("resumption %s=%d %s", q.path, w.Code, w.Body.String()) + } + } +} + +func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Project") + conv, _ := st.CreateConversation("alice", p.ID, "Private", "") + svc := surfacesvc.New(st, nil) + s.Surfaces = svc + if _, err := svc.SetActivation(context.Background(), "alice", "generative-ui", true); err != nil { + t.Fatal(err) + } + snap, err := svc.Submit(context.Background(), "alice", "generative-ui", surfacesvc.SubmitRequest{ConversationID: conv.ID, Spec: json.RawMessage(`{"title":"Page","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"Submit"}]}]}`)}) + if err != nil { + t.Fatal(err) + } + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil) + w := lifecycleRequest(t, s, key, "POST", "/api/surfaces/"+snap.Identity.SurfaceID+"/actions", `{"actionId":"generative-ui.submit","revision":1,"requestId":"request"}`) + if w.Code != 404 { + t.Fatalf("revoked surface action=%d %s", w.Code, w.Body.String()) + } +} + +func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Project") + s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) { + current, err := st.GetProject("alice", p.ID) + if err != nil { + return nil, err + } + if current.Settings == nil { + current.Settings = map[string]any{} + } + current.Settings["rdm.projectId"] = "canonical" + current.Settings["rdm.state"] = "ready" + return []core.Project{current}, nil + }}, nil) + w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"rdm.projectId":"forged","example":true}}`) + var got core.Project + _ = json.Unmarshal(w.Body.Bytes(), &got) + if w.Code != 200 || got.Settings["rdm.projectId"] != "canonical" || got.Settings["example"] != true { + t.Fatalf("settings lost service metadata: %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/server/project_resource_filter_test.go b/internal/server/project_resource_filter_test.go new file mode 100644 index 0000000..581f147 --- /dev/null +++ b/internal/server/project_resource_filter_test.go @@ -0,0 +1,100 @@ +package server + +import ( + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "encoding/json" + "testing" +) + +func TestKnowledgeV2UsesDocumentIdentity(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + docs := []core.KnowledgeDoc{{ID: "published-slice", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle published"}, {ID: "private-draft", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle draft"}, {ID: "self-denied", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own restricted"}, {ID: "self-allowed", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own"}} + if err := st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil }) + seen := map[string]extension.ResourceAuthzQuery{} + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + seen[q.ResourceID] = q + return q.ResourceType == "project" || q.ResourceID == "published-slice" || q.ResourceID == "self-allowed", nil + }) + w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sharedneedle", "") + var hits []core.RetrievalHit + _ = json.Unmarshal(w.Body.Bytes(), &hits) + if w.Code != 200 || len(hits) != 2 { + t.Fatalf("V2 filtered hits=%d %s", w.Code, w.Body.String()) + } + for _, hit := range hits { + if hit.Doc.ID != "published-slice" && hit.Doc.ID != "self-allowed" { + t.Fatalf("private leak: %+v", hit.Doc) + } + } + q := seen["published-slice"] + if q.ResourceType != "knowledge" || q.Actor != "bob" || q.Action != "search" || q.Owner != "alice" || q.ProjectID != "domain-p" { + t.Fatalf("trusted doc query: %+v", q) + } +} +func TestArtifactListV2FiltersEachResource(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Project") + private := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Private", MIME: "text/plain"} + public := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Shared", MIME: "text/plain"} + if err := st.CreateArtifact(&private, []byte("private")); err != nil { + t.Fatal(err) + } + if err := st.CreateArtifact(&public, []byte("shared")); err != nil { + t.Fatal(err) + } + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + return q.ResourceType == "project" || (q.ResourceType == "artifact" && q.Action == "list" && q.ResourceID == public.ID && q.Owner == "alice"), nil + }) + w := lifecycleRequest(t, s, key, "GET", "/api/projects/"+p.ID+"/artifacts", "") + var as []core.Artifact + _ = json.Unmarshal(w.Body.Bytes(), &as) + if w.Code != 200 || len(as) != 1 || as[0].ID != public.ID { + t.Fatalf("artifact list=%d %s", w.Code, w.Body.String()) + } +} + +func TestKnowledgeV2ResolvesTrustedProjectScope(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil { + t.Fatal(err) + } + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { return q.ResourceType == "knowledge", nil }) + s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) { + return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}, {ID: "host-other", Settings: map[string]any{"rdm.projectId": "domain-other", "rdm.state": "ready"}}}, nil + }}, nil) + w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=host-current", "") + var hits []core.RetrievalHit + _ = json.Unmarshal(w.Body.Bytes(), &hits) + if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "slice-current" { + t.Fatalf("current scope=%d %s", w.Code, w.Body.String()) + } + if w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=unknown", ""); w.Code != 404 { + t.Fatalf("unknown scope=%d %s", w.Code, w.Body.String()) + } +} + +func TestKnowledgeV2UsesStoredPublicationSource(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + // A known published identity replaced by a private row must lose sharing. + if err := st.AddKnowledge(core.KnowledgeDoc{ID: "known-published-id", Owner: "alice", ProjectID: "domain", Source: "manual", Chunk: "sourceneedle private"}, core.KnowledgeDoc{ID: "real-published-id", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sourceneedle public"}); err != nil { + t.Fatal(err) + } + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + return q.ResourceType == "knowledge" && q.Source == "plugin:rdm:published", nil + }) + w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sourceneedle", "") + var hits []core.RetrievalHit + _ = json.Unmarshal(w.Body.Bytes(), &hits) + if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "real-published-id" { + t.Fatalf("actual stored source=%d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/server/resource_authz_consumption_test.go b/internal/server/resource_authz_consumption_test.go index c29b832..eebdead 100644 --- a/internal/server/resource_authz_consumption_test.go +++ b/internal/server/resource_authz_consumption_test.go @@ -77,7 +77,7 @@ func TestSharedReadPathsConsultV2WithResourceContext(t *testing.T) { t.Fatalf("download 查询六要素不符: %+v", dl) } ls := byAction["list"] - if ls.Actor != "bob" || ls.ResourceType != "project" || ls.ResourceID != pid || ls.ProjectID != pid { + if ls.Actor != "bob" || ls.ResourceType != "artifact" || ls.ResourceID != art.ID || ls.ProjectID != pid || ls.Owner != "alice" { t.Fatalf("list 查询不符: %+v", ls) } rd := byAction["read"] diff --git a/internal/server/server.go b/internal/server/server.go index c3f0596..c36f113 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -26,6 +26,7 @@ import ( "agent-system-backend/internal/intentgate" "agent-system-backend/internal/plugins" "agent-system-backend/internal/profiles" + "agent-system-backend/internal/projectscope" "agent-system-backend/internal/prompts" "agent-system-backend/internal/ratelimit" "agent-system-backend/internal/route" @@ -41,24 +42,26 @@ import ( ) type Server struct { - st *store.Store - Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入) - Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入) - Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入) - Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入) - LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入) - mgr *runmgr.Manager - Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入) - Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17) - Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27) - Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接) - Workflows *workflow.Registry // 已装载工作流(nil = 不接) - Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退) - reg *tools.Registry - prof *profiles.Service - set *settings.Service - plugins *plugins.Host - static fs.FS + projectLifecycle *extension.ProjectLifecycle + projectHost func(string) extension.Host + st *store.Store + Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入) + Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入) + Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入) + Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入) + LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入) + mgr *runmgr.Manager + Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入) + Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17) + Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27) + Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接) + Workflows *workflow.Registry // 已装载工作流(nil = 不接) + Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退) + reg *tools.Registry + prof *profiles.Service + set *settings.Service + plugins *plugins.Host + static fs.FS // Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。 Subscribe func(conversationID string) (<-chan core.StreamEvent, func()) // DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与 @@ -206,7 +209,7 @@ func (s *Server) WireBuiltinCommands() { if err != nil { return nil, err } - c, err := s.st.CreateConversation(conv.Owner, conv.ProjectID, "新会话", conv.ProfileID) + c, err := s.createPrivateConversation(ctx, conv.Owner, conv.ProjectID, "新会话", conv.ProfileID) if err != nil { return nil, err } @@ -612,6 +615,23 @@ type projectView struct { func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) + if s.projectLifecycle != nil { + if s.projectLifecycle.List == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + ps, err := s.projectLifecycle.List(r.Context(), s.lifecycleHost(owner)) + if err != nil { + writeProjectErr(w, err) + return + } + out := make([]projectView, 0, len(ps)) + for _, p := range ps { + out = append(out, projectView{Project: p, Shared: p.Owner != owner}) + } + writeJSON(w, http.StatusOK, out) + return + } ps, err := s.st.ListProjects(owner) if err != nil { writeStoreErr(w, err) @@ -648,6 +668,15 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { http.Error(w, "name required", http.StatusBadRequest) return } + if s.projectLifecycle != nil { + if s.projectLifecycle.Create == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name)) + writeProjectJSON(w, p, err) + return + } p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name)) writeJSONOr(w, p, err) } @@ -662,6 +691,50 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + if s.projectLifecycle != nil { + var p core.Project + var err error + if req.Name != nil { + if strings.TrimSpace(*req.Name) == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + if s.projectLifecycle.Rename == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + p, err = s.projectLifecycle.Rename(r.Context(), s.lifecycleHost(s.owner(r)), id, strings.TrimSpace(*req.Name)) + if err != nil { + writeProjectErr(w, err) + return + } + } + if req.Settings != nil { + if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil { + writeStoreErr(w, err) + return + } + } + if req.Name == nil || req.Settings != nil { + if s.projectLifecycle.List == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + var projects []core.Project + projects, err = s.projectLifecycle.List(r.Context(), s.lifecycleHost(s.owner(r))) + if err == nil { + err = store.ErrNotFound + for _, candidate := range projects { + if candidate.ID == id { + p, err = candidate, nil + break + } + } + } + } + writeProjectJSON(w, p, err) + return + } if req.Name != nil { if err := s.st.RenameProject(s.owner(r), id, *req.Name); err != nil { writeStoreErr(w, err) @@ -679,12 +752,29 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { } func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) { + if s.projectLifecycle != nil { + if s.projectLifecycle.Archive == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + if err := s.projectLifecycle.Archive(r.Context(), s.lifecycleHost(s.owner(r)), r.PathValue("id")); err != nil { + writeProjectErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } writeOKOr(w, s.st.DeleteProject(s.owner(r), r.PathValue("id"))) } func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) - if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil { + if s.projectLifecycle != nil { + if err := s.projectWork(r.Context(), owner, r.PathValue("id")); err != nil { + writeProjectErr(w, err) + return + } + } else if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil { writeStoreErr(w, err) return } @@ -728,13 +818,8 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) { if req.Title == "" { req.Title = "新会话" } - c, err := s.st.CreateConversation(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) - // EXT-15:自有未命中且适配器放行(成员视角的共享项目)→ 会话仍归属创建者, - // 只是落在他人项目下;撤销授权后此路径立即 404(不泄露存在性)。 - if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(s.owner(r), "read", "project", core.ID(r.PathValue("id")), "", core.ID(r.PathValue("id"))) { - c, err = s.st.CreateConversationInProject(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) - } - writeJSONOr(w, c, err) + c, err := s.createPrivateConversation(r.Context(), s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) + writeProjectJSON(w, c, err) } // conversationDetail 终态对账权威:消息树 + runs + 活动 HITL 卡片。 @@ -813,6 +898,10 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } + if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil { + writeProjectErr(w, err) + return + } var req struct { Text string `json:"text"` AttachmentIDs []string `json:"attachmentIds"` @@ -968,7 +1057,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { } if startErr != nil { s.mgr.ReleaseConversation(convID) - writeStoreErr(w, startErr) + writeProjectErr(w, startErr) return } _ = s.st.SetMessageRunID(msg.ID, run.ID) @@ -1073,10 +1162,21 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) { } var hostProject string if body.ConversationID != "" { - if conv, err := s.st.GetConversation(s.owner(r), body.ConversationID); err == nil { + conv, err := s.st.GetConversation(s.owner(r), body.ConversationID) + if err != nil { + if s.projectLifecycle != nil { + writeStoreErr(w, err) + return + } + } else { hostProject = string(conv.ProjectID) + if err := s.projectWork(r.Context(), s.owner(r), hostProject); err != nil { + writeProjectErr(w, err) + return + } } } + resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{ ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r), Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID, @@ -1155,6 +1255,22 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) { return } resp.CardID = r.PathValue("id") + if s.projectLifecycle != nil { + card, err := s.st.GetHITLCard(s.owner(r), resp.CardID) + if err != nil { + writeStoreErr(w, err) + return + } + conv, err := s.st.GetConversation(s.owner(r), card.ConversationID) + if err != nil { + writeStoreErr(w, err) + return + } + if err := s.projectWork(r.Context(), s.owner(r), conv.ProjectID); err != nil { + writeProjectErr(w, err) + return + } + } pivotText, pivoted, err := s.mgr.ResolveHITL(s.owner(r), string(resp.CardID), resp) if err != nil && s.st.Dialect() == "postgres" { // 跨实例 HITL(U5/KTD5):本地无执行句柄——写持久命令,执行实例 @@ -1221,6 +1337,28 @@ func (s *Server) cardToProject(w http.ResponseWriter, r *http.Request) { // cardAction 处理卡上按钮:resolved + 回执消息进对话时间线(确认即留痕)。 func (s *Server) cardAction(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) + if s.projectLifecycle != nil { + card, err := s.st.GetCard(owner, r.PathValue("id")) + if err != nil { + writeStoreErr(w, err) + return + } + pid := card.ProjectID + if card.ConversationID != "" { + conv, err := s.st.GetConversation(owner, card.ConversationID) + if err != nil { + writeStoreErr(w, err) + return + } + pid = conv.ProjectID + } + if pid != "" { + if err := s.projectWork(r.Context(), owner, pid); err != nil { + writeProjectErr(w, err) + return + } + } + } var body struct { FreeText string `json:"freeText,omitempty"` } @@ -1345,20 +1483,32 @@ func kindOf(filename string) string { } func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) { - owner := s.owner(r) - pid := r.PathValue("id") - if _, err := s.st.GetProject(owner, pid); err != nil { - // EXT-8b:资源级咨询(list×project);放行则返回项目全部工件(只读共享)。 + owner, pid := s.owner(r), r.PathValue("id") + var as []core.Artifact + var err error + if _, ownedErr := s.st.GetProject(owner, pid); ownedErr != nil { if !s.authorizeSharedResource(owner, "list", "project", core.ID(pid), "", core.ID(pid)) { - writeStoreErr(w, err) + writeStoreErr(w, ownedErr) return } - as, err := s.st.ListProjectArtifactsAll(pid) - writeJSONOr(w, as, err) + as, err = s.st.ListProjectArtifactsAll(pid) + } else { + as, err = s.st.ListArtifacts(owner, pid) + } + if err != nil { + writeStoreErr(w, err) return } - as, err := s.st.ListArtifacts(owner, pid) - writeJSONOr(w, as, err) + if s.ProjectAuthzV2 != nil { + filtered := make([]core.Artifact, 0, len(as)) + for _, a := range as { + if s.authorizeSharedResource(owner, "list", "artifact", a.ID, a.Owner, a.ProjectID) { + filtered = append(filtered, a) + } + } + as = filtered + } + writeJSON(w, http.StatusOK, as) } // sharedArtifactGet EXT-8 工件读取回退:owner 自有未命中 → 取元数据问适配器, @@ -1847,6 +1997,49 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) { topK, _ := strconv.Atoi(r.URL.Query().Get("topK")) query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK} owner := s.owner(r) + if s.projectLifecycle != nil && query.ProjectID != "" { + id, err := projectscope.Resolve(r.Context(), s.lifecycleHost(owner), *s.projectLifecycle, query.ProjectID) + if err != nil { + writeProjectErr(w, err) + return + } + query.ProjectID = id + } + if s.ProjectAuthzV2 != nil { + allow := func(d core.KnowledgeDoc) bool { + ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source}) + return err == nil && ok + } + if rs, ok := s.Retriever.(interface { + RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) + }); ok { + hits, err := rs.RetrieveAuthorized(r.Context(), owner, query, allow) + writeJSONOr(w, hits, err) + return + } + hits, err := s.st.SearchKnowledge(owner, query) + if err != nil { + writeStoreErr(w, err) + return + } + filtered := make([]core.RetrievalHit, 0, len(hits)) + for _, h := range hits { + if allow(h.Doc) { + filtered = append(filtered, h) + } + } + shared, err := s.st.SearchKnowledgeSharedAuthorized(owner, query, allow) + if err != nil { + writeStoreErr(w, err) + return + } + filtered = append(filtered, shared...) + if query.TopK > 0 && len(filtered) > query.TopK { + filtered = filtered[:query.TopK] + } + writeJSON(w, http.StatusOK, filtered) + return + } // EXT-8 余项:Retriever 支持共享版(向量+全文两路追加)时整体走共享版; // 否则维持切片 2 行为(本人检索 + 全文共享追加)。 allow := func(docOwner, projectID core.ID) bool { diff --git a/internal/store/content.go b/internal/store/content.go index 10331c2..df16303 100644 --- a/internal/store/content.go +++ b/internal/store/content.go @@ -381,13 +381,46 @@ func sharedVisible(requester string, q core.RetrievalQuery, allow func(docOwner, // 经 allow(docOwner, projectID) 放行的命中(全文通道;向量通道共享仍留后续切片)。 // allow 出错由调用方前置处理——本层只见布尔。 func (s *Store) SearchKnowledgeShared(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) { - pred := sharedVisible(requester, q, allow) + return s.SearchKnowledgeSharedAuthorized(requester, q, func(d core.KnowledgeDoc) bool { return allow(d.Owner, d.ProjectID) }) +} + +// SearchKnowledgeSharedAuthorized evaluates the trusted document identity before +// returning a shared row. Project-level permissions cannot grant private content. +func (s *Store) SearchKnowledgeSharedAuthorized(requester string, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { + pred := func(d core.KnowledgeDoc) bool { + return d.Owner != "" && d.Owner != requester && d.ProjectID != "" && + (q.ProjectID == "" || d.ProjectID == q.ProjectID) && + (len(q.Sources) == 0 || containsStr(q.Sources, d.Source)) && allow(d) + } if s.dialect.name() == "postgres" { return s.searchKnowledgePGShared(requester, q, pred) } return s.searchKnowledgeSQLiteShared(requester, q, pred) } +// DeleteProjectKnowledgeByRef replaces one publication scope and its vectors in +// a single transaction. Empty project is the legacy unscoped publication domain. +func (s *Store) DeleteProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + args := []any{owner, ref} + pred := "owner=? AND ref=?" + if projectID != "" { + pred += " AND project_id=?" + args = append(args, projectID) + } + if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...); err != nil { + return err + } + return tx.Commit() +} + func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) { out, err := s.searchKnowledgeFTS(ownerVisible(owner, q), q) if err != nil { diff --git a/internal/store/project_projection.go b/internal/store/project_projection.go new file mode 100644 index 0000000..13aa99d --- /dev/null +++ b/internal/store/project_projection.go @@ -0,0 +1,149 @@ +package store + +import ( + core "agent-system-core" + "database/sql" + "errors" + "strings" + "time" +) + +// lockProjectNames serializes projection identity and name admission across all +// owners. SQLite acquires its write reservation before any reads; PostgreSQL +// uses a transaction-scoped advisory lock shared by every projection mutation. +func (s *Store) lockProjectNames(tx *sql.Tx) error { + if s.dialect.name() == "sqlite" { + _, err := tx.Exec(`UPDATE projects SET id=id WHERE 1=0`) + return err + } + return s.dialect.lockScope(tx, "", "host-project-names") +} + +// EnsureProject creates a stable projection or verifies an identical retry. +func (s *Store) EnsureProject(owner, id, name string) (core.Project, error) { + name = strings.TrimSpace(name) + if owner == "" || id == "" || name == "" { + return core.Project{}, errors.New("store: project owner, id and name required") + } + tx, err := s.db.Begin() + if err != nil { + return core.Project{}, err + } + defer tx.Rollback() + if err = s.lockProjectNames(tx); err != nil { + return core.Project{}, err + } + p, err := s.projectMetadata(tx, id) + if err == nil { + if p.Owner != owner { + return core.Project{}, ErrNotFound + } + if p.Name != name { + return core.Project{}, ErrConflict + } + return p, tx.Commit() + } + if !errors.Is(err, ErrNotFound) { + return core.Project{}, err + } + available, err := s.projectNameAvailable(tx, name, id) + if err != nil { + return core.Project{}, err + } + if !available { + return core.Project{}, ErrConflict + } + now := time.Now().UTC() + p = core.Project{ID: id, Owner: owner, Name: name, CreatedAt: now, UpdatedAt: now, Settings: map[string]any{}} + _, err = tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`), id, owner, name, "{}", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) + if err != nil { + return core.Project{}, err + } + return p, tx.Commit() +} + +func (s *Store) RenameOwnedProject(owner, id, name string) (core.Project, error) { + name = strings.TrimSpace(name) + if name == "" { + return core.Project{}, errors.New("store: project name required") + } + tx, err := s.db.Begin() + if err != nil { + return core.Project{}, err + } + defer tx.Rollback() + if err = s.lockProjectNames(tx); err != nil { + return core.Project{}, err + } + p, err := s.projectMetadata(tx, id) + if err != nil { + return core.Project{}, err + } + if p.Owner != owner { + return core.Project{}, ErrNotFound + } + available, err := s.projectNameAvailable(tx, name, id) + if err != nil { + return core.Project{}, err + } + if !available { + return core.Project{}, ErrConflict + } + p.Name, p.UpdatedAt = name, time.Now().UTC() + _, err = tx.Exec(s.q(`UPDATE projects SET name=?,updated_at=? WHERE id=? AND owner=?`), p.Name, p.UpdatedAt.Format(time.RFC3339Nano), id, owner) + if err != nil { + return core.Project{}, err + } + return p, tx.Commit() +} + +type projectQuerier interface { + QueryRow(string, ...any) *sql.Row + Query(string, ...any) (*sql.Rows, error) +} + +func (s *Store) projectNameAvailable(q projectQuerier, name, exceptID string) (bool, error) { + // Use the same Go whitespace rule as admission, including legacy raw names. + rows, err := q.Query(s.q(`SELECT name FROM projects WHERE id<>?`), exceptID) + if err != nil { + return false, err + } + defer rows.Close() + name = strings.TrimSpace(name) + for rows.Next() { + var candidate string + if err := rows.Scan(&candidate); err != nil { + return false, err + } + if strings.TrimSpace(candidate) == name { + return false, nil + } + } + return true, rows.Err() +} + +func (s *Store) ProjectNameAvailable(name, exceptID string) (bool, error) { + return s.projectNameAvailable(s.db, name, exceptID) +} +func (s *Store) projectMetadata(q projectQuerier, id string) (core.Project, error) { + var p core.Project + var settings, ca, ua string + err := q.QueryRow(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects WHERE id=?`), id).Scan(&p.ID, &p.Owner, &p.Name, &settings, &ca, &ua) + if errors.Is(err, sql.ErrNoRows) { + return p, ErrNotFound + } + if err != nil { + return p, err + } + p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca) + p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua) + p.Settings = map[string]any{} + if settings != "" && settings != "{}" { + p.Settings, _ = unmarshalJSON[map[string]any](settings) + } + return p, nil +} + +// ProjectMetadata is an internal trusted lookup. Capabilities must authorize it +// before exposing metadata to another owner. +func (s *Store) ProjectMetadata(id string) (core.Project, error) { return s.projectMetadata(s.db, id) } diff --git a/internal/store/project_projection_test.go b/internal/store/project_projection_test.go new file mode 100644 index 0000000..199aa21 --- /dev/null +++ b/internal/store/project_projection_test.go @@ -0,0 +1,83 @@ +package store + +import ( + "errors" + "sync" + "testing" +) + +func TestProjectProjectionIdentityAndOwnership(t *testing.T) { + st := openTestStore(t) + p, err := st.EnsureProject("alice", "host-stable", " Global ") + if err != nil || p.ID != "host-stable" || p.Name != "Global" || p.Owner != "alice" { + t.Fatalf("ensure: %+v %v", p, err) + } + retry, err := st.EnsureProject("alice", p.ID, "Global") + if err != nil || retry.ID != p.ID { + t.Fatalf("retry: %+v %v", retry, err) + } + if _, err = st.EnsureProject("bob", p.ID, "Global"); !errors.Is(err, ErrNotFound) { + t.Fatalf("other owner: %v", err) + } + if _, err = st.EnsureProject("alice", p.ID, "Different"); !errors.Is(err, ErrConflict) { + t.Fatalf("different retry name: %v", err) + } + if _, err = st.RenameOwnedProject("bob", p.ID, "Other"); !errors.Is(err, ErrNotFound) { + t.Fatalf("rename owner: %v", err) + } +} + +func TestProjectProjectionGlobalNameAdmission(t *testing.T) { + st := openTestStore(t) + if _, err := st.CreateProject("legacy", "Taken"); err != nil { + t.Fatal(err) + } + if _, err := st.EnsureProject("alice", "a", " Taken "); !errors.Is(err, ErrConflict) { + t.Fatalf("legacy collision: %v", err) + } + var wg sync.WaitGroup + results := make(chan error, 2) + for _, owner := range []string{"alice", "bob"} { + wg.Add(1) + go func(owner string) { + defer wg.Done() + _, err := st.EnsureProject(owner, "p-"+owner, "Race") + results <- err + }(owner) + } + wg.Wait() + close(results) + successes, conflicts := 0, 0 + for err := range results { + if err == nil { + successes++ + } else if errors.Is(err, ErrConflict) { + conflicts++ + } else { + t.Fatal(err) + } + } + if successes != 1 || conflicts != 1 { + t.Fatalf("successes=%d conflicts=%d", successes, conflicts) + } + if _, err := st.EnsureProject("alice", "rename-a", "Rename source"); err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProject("alice", "rename-a", "Taken"); !errors.Is(err, ErrConflict) { + t.Fatalf("rename collision: %v", err) + } + available, err := st.ProjectNameAvailable("Taken", "") + if err != nil || available { + t.Fatalf("availability=%v %v", available, err) + } +} + +func TestProjectProjectionAdmissionTrimsLegacyWhitespace(t *testing.T) { + st := openTestStore(t) + if _, err := st.CreateProject("legacy", "\tReserved\u00a0"); err != nil { + t.Fatal(err) + } + if _, err := st.EnsureProject("other", "new-id", "Reserved"); !errors.Is(err, ErrConflict) { + t.Fatalf("trimmed legacy admission: %v", err) + } +} diff --git a/internal/store/vectors.go b/internal/store/vectors.go index 02d653a..e98c544 100644 --- a/internal/store/vectors.go +++ b/internal/store/vectors.go @@ -121,7 +121,16 @@ func (s *Store) SearchKnowledgeVectorsSharedModel(ctx context.Context, requester return hits, nil } } - return s.searchKnowledgeVectorsSharedScan(ctx, requester, model, vec, topK, filter, allow) + return s.SearchKnowledgeVectorsSharedAuthorized(ctx, requester, model, vec, topK, filter, func(d core.KnowledgeDoc) bool { return allow == nil || allow(d.Owner, d.ProjectID) }) +} + +// SearchKnowledgeVectorsSharedAuthorized applies per-document authorization to +// shared vector candidates before ranking, including their actual stored ID. +func (s *Store) SearchKnowledgeVectorsSharedAuthorized(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { + if topK <= 0 { + topK = 5 + } + return s.searchKnowledgeVectorsSharedAuthorizedScan(ctx, requester, model, vec, topK, filter, allow) } // allowAll 判定谓词是否恒真(nil 或恒放行)——库内路径的启用条件。 @@ -187,6 +196,9 @@ func (s *Store) searchKnowledgeVectorsPG(ctx context.Context, owner, model strin // searchKnowledgeVectorsSharedScan 原进程内共享扫描(降级/谓词路径)。 func (s *Store) searchKnowledgeVectorsSharedScan(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) { + return s.searchKnowledgeVectorsSharedAuthorizedScan(ctx, requester, model, vec, topK, filter, func(d core.KnowledgeDoc) bool { return allow == nil || allow(d.Owner, d.ProjectID) }) +} +func (s *Store) searchKnowledgeVectorsSharedAuthorizedScan(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { q := ` SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, v.vec FROM knowledge_vectors v JOIN knowledge k ON k.id = v.doc_id @@ -215,7 +227,7 @@ func (s *Store) searchKnowledgeVectorsSharedScan(ctx context.Context, requester, } d.Owner = own d.CreatedAt, _ = parseTime(ca) - if d.ProjectID == "" || !allow(d.Owner, d.ProjectID) { + if d.ProjectID == "" || !allow(d) { continue } if filter.ProjectID != "" && d.ProjectID != filter.ProjectID { diff --git a/pkg/extension/account_lister_test.go b/pkg/extension/account_lister_test.go index d448879..3932178 100644 --- a/pkg/extension/account_lister_test.go +++ b/pkg/extension/account_lister_test.go @@ -5,14 +5,21 @@ package extension // 不含姓名/邮箱/联系方式)。红:类型未定义,编译红先行。 import ( "encoding/json" + "strconv" "strings" "testing" ) func TestAccountListerSurface(t *testing.T) { - if !strings.HasPrefix(SurfaceVersion, "2.11.0") { - t.Fatalf("SurfaceVersion 应升至 2.11.0-experimental(新增导出符号须升版),got %s", SurfaceVersion) + parts := strings.Split(SurfaceVersion, ".") + minor := 0 + if len(parts) > 1 { + minor, _ = strconv.Atoi(parts[1]) } + if len(parts) < 2 || parts[0] != "2" || minor < 11 { + t.Fatalf("AccountLister requires experimental version 2.11 or later, got %s", SurfaceVersion) + } + // 接口方法签名即契约:游标分页 + 最小字段。 var _ AccountLister = (AccountLister)(nil) e := AccountEntry{Owner: "u1", Valid: true} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 976880d..2454e4e 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -32,7 +32,9 @@ import ( // 2.9.0-experimental:Registry.AddAdminZone 与 AdminZoneSpec(WP3-I3/KTD4:编译 // 插件向基座管理区声明展示分区;分区入口不做基座级权限判定,数据能力由 // 分区组件自身的命令面 fail-closed)。 -const SurfaceVersion = "2.11.0-experimental" +// 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted +// ResourceAuthzQuery.Source for per-document publication authorization. +const SurfaceVersion = "2.12.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") @@ -43,15 +45,17 @@ var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent- 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"` // 所属项目;空=无项目私域 + 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"` // 资源属主(受信存储解析,非请求自报) + // Source is optional trusted resource provenance, populated from stored knowledge rows. + Source string `json:"source,omitempty"` + ProjectID core.ID `json:"projectId,omitempty"` // 所属项目;空=无项目私域 } // ResourceAuthorizerV2 EXT-8b 资源级授权适配器:V1(owner×project 布尔)之上的 diff --git a/pkg/extension/projects.go b/pkg/extension/projects.go new file mode 100644 index 0000000..259e4dd --- /dev/null +++ b/pkg/extension/projects.go @@ -0,0 +1,35 @@ +package extension + +import ( + core "agent-system-core" + "context" +) + +// ProjectHost is an optional actor-scoped host projection capability. Metadata +// can be shared by an authorizer; owned lookups and mutations remain owner-only. +type ProjectHost interface { + Owner() string + OwnedProjects() ([]core.Project, error) + ProjectMetadata(projectID string) (core.Project, error) + OwnedProject(projectID string) (core.Project, error) + EnsureProject(projectID, name string) (core.Project, error) + RenameOwnedProject(projectID, name string) (core.Project, error) + ProjectNameAvailable(name, exceptID string) (bool, error) +} + +// ProjectLifecycle supplies the domain's public project lifecycle and live work +// authorization. Each Host is scoped to the authenticated actor. Errors with a +// StatusCode() int method may report 400, 403, 404, 409, or 503 to HTTP clients. +type ProjectLifecycle struct { + List func(context.Context, Host) ([]core.Project, error) + Create func(context.Context, Host, string) (core.Project, error) + Rename func(context.Context, Host, string, string) (core.Project, error) + Archive func(context.Context, Host, string) error + Work func(context.Context, string, core.ID) (bool, error) +} + +// ProjectLifecycleRegistry is optional so older Registry implementations retain +// their contract. Only in-process plugins can register one complete provider. +type ProjectLifecycleRegistry interface { + RegisterProjectLifecycle(ProjectLifecycle) error +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 8f4cfaf..7d88ba4 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -32,6 +32,17 @@ Main NewHostClient NewScopedHost Options +ProjectHost +ProjectHost.EnsureProject +ProjectHost.OwnedProject +ProjectHost.OwnedProjects +ProjectHost.Owner +ProjectHost.ProjectMetadata +ProjectHost.ProjectNameAvailable +ProjectHost.RenameOwnedProject +ProjectLifecycle +ProjectLifecycleRegistry +ProjectLifecycleRegistry.RegisterProjectLifecycle RegisterCompiled Registry Registry.AddAdminZone