diff --git a/cmd/agent-system/account_io_test.go b/cmd/agent-system/account_io_test.go new file mode 100644 index 0000000..4d91a02 --- /dev/null +++ b/cmd/agent-system/account_io_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "agent-system-backend/internal/store" + "io" + "os" + "path/filepath" + "testing" +) + +func accountCLI(t *testing.T, args ...string) (int, string) { + t.Helper() + out, err := os.CreateTemp(t.TempDir(), "capture-") + if err != nil { + t.Fatal(err) + } + defer out.Close() + oldOut, oldErr := os.Stdout, os.Stderr + os.Stdout = out + os.Stderr = out + defer func() { os.Stdout = oldOut; os.Stderr = oldErr }() + rc := runUser(args) + out.Seek(0, 0) + raw, err := io.ReadAll(out) + if err != nil { + t.Fatal(err) + } + return rc, string(raw) +} +func cliStore(t *testing.T) (string, *store.Store) { + t.Helper() + t.Setenv("AGENT_SYSTEM_DSN", "") + dir := t.TempDir() + s, e := store.OpenSQLite(filepath.Join(dir, "agent-system.db")) + if e != nil { + t.Fatal(e) + } + t.Cleanup(func() { s.Close() }) + if e := s.EnsureSystemIdentity("fixture", "sqlite"); e != nil { + t.Fatal(e) + } + return dir, s +} +func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) { + for _, sub := range []string{"create", "reset-password", "issue-login-key", "bootstrap-admin"} { + t.Run(sub, func(t *testing.T) { + dir, s := cliStore(t) + if sub != "create" && sub != "bootstrap-admin" { + if e := s.CreateAccount("ada", "Ada", "", false); e != nil { + t.Fatal(e) + } + } + out := filepath.Join(t.TempDir(), "secret") + rc, _ := accountCLI(t, sub, "--data", dir, "--owner", "ada", "--out-file", out) + if rc != 0 { + t.Fatalf("credential command failed: %d", rc) + } + info, e := os.Stat(out) + if e != nil { + t.Fatal("credential output file missing") + } + if info.Mode().Perm() != 0600 { + t.Fatal("credential output permissions are not private") + } + }) + } +} +func TestExistingOutputAndConflictingFormatsRefuseBeforeMutation(t *testing.T) { + for _, kind := range []string{"existing", "conflicting"} { + t.Run(kind, func(t *testing.T) { + dir, s := cliStore(t) + out := filepath.Join(t.TempDir(), "secret") + args := []string{"create", "--data", dir, "--owner", "ada", "--out-file", out} + if kind == "existing" { + if e := os.WriteFile(out, []byte("existing"), 0600); e != nil { + t.Fatal(e) + } + } else { + args = append(args, "--out", filepath.Join(t.TempDir(), "json-secret")) + } + rc, _ := accountCLI(t, args...) + if rc == 0 { + t.Fatal("unsafe output selection succeeded") + } + if _, e := s.GetUser("ada"); e == nil { + t.Fatal("output refusal happened after account mutation") + } + }) + } +} diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index 3bcf5a0..4b88321 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -240,6 +240,106 @@ func runAPIKey(args []string) int { } } +// deliverCredential 凭据单次交付(KTD14):--out 时写 0600 JSON 文件,stdout 只报 +// 属主/用途/文件路径不含明文;缺省沿用 stdout 一次输出。文件含 expiresAt 便于交接。 +func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error { + if outPath == "" { + fmt.Println(plaintext) + return nil + } + payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext} + if expiresAt != nil { + payload["expiresAt"] = expiresAt + } + buf, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + if err := writeExclusiveSecret(outPath, string(buf)); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath) + return nil +} + +type credentialOutput struct { + path string + file *os.File + json bool + mutated bool +} + +// Reserve the destination before changing the account. O_EXCL also rejects an +// existing symlink, so an existing secret can never be overwritten. +func reserveCredentialOutput(rawPath, jsonPath string) (*credentialOutput, error) { + if rawPath != "" && jsonPath != "" { + return nil, fmt.Errorf("--out and --out-file are mutually exclusive") + } + out := &credentialOutput{} + out.path, out.json = rawPath, false + if jsonPath != "" { + out.path, out.json = jsonPath, true + } + if out.path == "" { + return out, nil + } + f, err := os.OpenFile(out.path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return nil, err + } + out.file = f + return out, nil +} + +func (out *credentialOutput) abort() { + if out == nil || out.file == nil { + return + } + info, err := out.file.Stat() + _ = out.file.Close() + if out.mutated || err != nil { + return + } + pathInfo, pathErr := os.Stat(out.path) + if pathErr == nil && os.SameFile(info, pathInfo) { + _ = os.Remove(out.path) + } +} + +func (out *credentialOutput) deliver(kind, owner, plaintext string, expiresAt any) error { + if out.file == nil { + return deliverCredential("", kind, owner, plaintext, expiresAt) + } + out.mutated = true // Keep the private recovery artifact if delivery fails after DB commit. + var body []byte + if out.json { + payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext} + if expiresAt != nil { + payload["expiresAt"] = expiresAt + } + var err error + body, err = json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + } else { + body = []byte(plaintext) + } + body = append(body, '\n') + if _, err := out.file.Write(body); err != nil { + return err + } + if err := out.file.Sync(); err != nil { + return err + } + if err := out.file.Close(); err != nil { + return err + } + out.file = nil + fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", out.path) + return nil +} + // runUser:create / reset-password / list / issue-login-key / revoke-login-key / bootstrap-admin。 // 默认 create 无初始密码并签发激活 Key;--generate-password 兼容旧随机密码路径。 func runUser(args []string) int { @@ -260,12 +360,13 @@ func runUser(args []string) int { instanceID := fs.String("instance-id", "", "严格核验库内服务身份") admin := fs.Bool("admin", false, "管理员") role := fs.String("role", "user", "角色 user|admin") - openExisting := fs.Bool("open-existing", false, "只打开既有存储") + openExisting := fs.Bool("open-existing", false, "兼容旧参数;账户命令始终只打开既有存储") generatePassword := fs.Bool("generate-password", false, "create 时生成随机初始密码") purpose := fs.String("purpose", "recover", "issue-login-key:activate|recover") ttlMin := fs.Int("ttl-minutes", 30, "登录 Key 有效分钟(5–120)") revokeAll := fs.Bool("revoke-all-credentials", false, "签发时撤销全部长期 API Key 与会话") keyID := fs.String("id", "", "revoke-login-key 凭据编号") + out := fs.String("out", "", "凭据交付文件(0600 JSON,代替 stdout 明文;非交互推荐)") fs.Parse(args[1:]) resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile) if err != nil { @@ -282,6 +383,20 @@ func runUser(args []string) int { } *dsn = resolvedDSN *password = resolvedPassword + credentialAction := sub == "create" || sub == "reset-password" || sub == "issue-login-key" || sub == "bootstrap-admin" + if !credentialAction && (*outFile != "" || *out != "") { + fmt.Fprintln(os.Stderr, "user: 此子命令不生成凭据") + return 2 + } + var delivery *credentialOutput + if credentialAction { + delivery, err = reserveCredentialOutput(*outFile, *out) + if err != nil { + fmt.Fprintln(os.Stderr, "user:", err) + return 2 + } + defer delivery.abort() + } switch sub { case "create": if *owner == "" { @@ -302,19 +417,17 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user create:", err) return 1 } - if *outFile != "" { - if err := writeExclusiveSecret(*outFile, pw); err != nil { - fmt.Fprintln(os.Stderr, "user create:", err) - return 1 - } - fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID) - return 0 + if err := delivery.deliver("login-key", *owner, pw, nil); err != nil { + fmt.Fprintln(os.Stderr, "user create:", err) + return 1 } - fmt.Println(pw) - if *password == "" && !*generatePassword { - fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。") - } else if *password == "" { - fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。") + fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID) + if *out == "" && *outFile == "" { + if *password == "" && !*generatePassword { + fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。") + } else if *password == "" { + fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。") + } } return 0 case "reset-password": @@ -327,8 +440,11 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user reset-password:", err) return 1 } - fmt.Println(pw) - if *password == "" { + if err := delivery.deliver("password", *owner, pw, nil); err != nil { + fmt.Fprintln(os.Stderr, "user reset-password:", err) + return 1 + } + if *out == "" && *outFile == "" && *password == "" { fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。") } return 0 @@ -355,8 +471,13 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user issue-login-key:", err) return 1 } - fmt.Println(plain) - fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。") + if err := delivery.deliver("login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil { + fmt.Fprintln(os.Stderr, "user issue-login-key:", err) + return 1 + } + if *out == "" && *outFile == "" { + fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。") + } return 0 case "revoke-login-key": if *keyID == "" { @@ -379,8 +500,13 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err) return 1 } - fmt.Println(plain) - fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。") + if err := delivery.deliver("bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil { + fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err) + return 1 + } + if *out == "" && *outFile == "" { + fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。") + } return 0 default: fmt.Fprintln(os.Stderr, "user: 未知子命令", sub) diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index cfee408..7ee9cb2 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -284,7 +284,7 @@ var copyOrder = []string{ "projects", "users", "profiles", "settings", "plugins", "conversations", "runs", "messages", "events", "artifacts", "hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors", - "activity_cards", "command_audit", "prompt_modules", + "activity_cards", "project_event_shares", "command_audit", "prompt_modules", "sessions", "api_keys", "plugin_grants", "plugin_schema", "plugin_kv", "background_tasks", "memory_entries", "audit_events", "pending_confirms", "llm_usage_daily", @@ -295,6 +295,8 @@ var copyOrder = []string{ "login_keys", // U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚) "agent_system_identity", + "account_events", + "redeem_attempts", } const controlDDL = ` @@ -782,7 +784,7 @@ func advanceSequences(db *sql.DB, table string) error { // checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local' // 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。 func checkOwners(db *sql.DB, src *sql.DB) error { - ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "command_audit"} + ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit"} for _, t := range ownerTables { if !sqliteHasTable(src, t) { continue diff --git a/deps/agent-system-core b/deps/agent-system-core index ba289e7..5773e74 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit ba289e7b1780a29855aef8477bf1ac49bd563eb2 +Subproject commit 5773e74086791731059730bd3726dc8c9cd1381f diff --git a/internal/app/account_lister_test.go b/internal/app/account_lister_test.go index 30821a7..6ee2898 100644 --- a/internal/app/account_lister_test.go +++ b/internal/app/account_lister_test.go @@ -51,3 +51,58 @@ func TestInprocHostListAccounts(t *testing.T) { t.Fatalf("limit 超限应钳制不报错: %v", err) } } + +// TestInprocHostSearchAccounts:EXT-19 扩展 SearchAccounts——账号/姓名子串 +// 检索、大小写不敏感、空 query 首页、limit 钳制(P3,2026-10-13)。 +func TestInprocHostSearchAccounts(t *testing.T) { + dir := t.TempDir() + st, err := store.OpenSQLite(dir + "/test.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + hash, err := store.HashPassword("init-pass-01") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "王小明", hash, false); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("bob", "李大明", hash, false); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("carol", "", hash, false); err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st}} + + // 姓名子串(同名区分场景):两个"大明/小明"都以"明"命中。 + got, err := h.SearchAccounts("明", 50) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Owner != "alice" || got[1].Owner != "bob" { + t.Fatalf("姓名子串命中不符: %+v", got) + } + if got[0].Name != "王小明" { + t.Fatalf("目录行应携带姓名: %+v", got[0]) + } + // 账号子串、大小写不敏感。 + got, _ = h.SearchAccounts("AL", 50) + if len(got) != 1 || got[0].Owner != "alice" { + t.Fatalf("账号子串应命中 alice: %+v", got) + } + // 未命中。 + if got, _ = h.SearchAccounts("不存在", 50); len(got) != 0 { + t.Fatalf("未命中应为空: %+v", got) + } + // 空 query=首页候选(owner 升序);姓名为空的账号 Name 为空串。 + if got, _ = h.SearchAccounts("", 2); len(got) != 2 || got[0].Owner != "alice" || got[1].Name != "李大明" { + t.Fatalf("空 query 首页不符: %+v", got) + } + // ListAccounts 回归:分页行同样携带姓名。 + page, _, _ := h.ListAccounts("", 2) + if page[0].Name != "王小明" { + t.Fatalf("ListAccounts 应携带姓名: %+v", page[0]) + } +} diff --git a/internal/app/account_ops_existing_test.go b/internal/app/account_ops_existing_test.go new file mode 100644 index 0000000..00fba27 --- /dev/null +++ b/internal/app/account_ops_existing_test.go @@ -0,0 +1,38 @@ +package app + +import ( + "agent-system-backend/internal/store" + "os" + "path/filepath" + "testing" +) + +func TestAccountMaintenanceDefaultsToNonCreatingOpen(t *testing.T) { + t.Setenv("AGENT_SYSTEM_DSN", "") + dir := filepath.Join(t.TempDir(), "absent") + if _, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false); err == nil { + t.Error("ordinary maintenance created a missing database") + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Error("failed maintenance created a directory") + } +} +func TestAccountMaintenanceCannotImplicitlyMigrate(t *testing.T) { + t.Setenv("AGENT_SYSTEM_DSN", "") + dir := t.TempDir() + st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + if err := st.EnsureSystemIdentity("test", "sqlite"); err != nil { + t.Fatal(err) + } + // Roll back the newest additive migration in this private fixture only. + if err := st.MigrateDown(store.SchemaTarget() - 1); err != nil { + t.Fatal(err) + } + st.Close() + if _, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false); err == nil { + t.Error("account command implicitly migrated an older schema") + } +} diff --git a/internal/app/app.go b/internal/app/app.go index f6947f0..0effc9b 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" ) @@ -295,7 +296,12 @@ func Run(opt Options) error { if owner == "" { owner = store.OwnerDefault } - return st.ActiveSecret(owner, service) + if v, ok := st.ActiveSecret(owner, service); ok { + return v, true + } + // P2 受管:密钥统一迁入 secrets 表后,系统层受管密钥挂在系统行 + //(owner=settings.OwnerSystemRow);租户未单独配置时回退系统密钥。 + return st.ActiveSecret(settings.OwnerSystemRow, service) } rag.SecretLookup = func(service, fallback string) string { return runtime.LookupSecret(store.OwnerDefault, service, fallback) @@ -432,7 +438,10 @@ func Run(opt Options) error { return fmt.Errorf("default profile: %w", err) } if opt.ForceMock { - if err := setSvc.MergeUser(store.OwnerDefault, map[string]any{"runtime": map[string]any{"mock": true}}); err != nil { + // runtime.* 自 #188 起为系统受管项:启动选项属受信操作员输入, + // 播种走系统层(对所有租户生效);用户层写入会被受管拦截拒绝, + // 导致 serve --mock 直接启动失败(multi-instance 集成回归)。 + if err := setSvc.MergeSystem(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil { return err } } @@ -486,6 +495,9 @@ func Run(opt Options) error { logger.Error("plugin load failed", perr, "plugin", id, "code", string(errcode.SysUnavailable)) } } + // P2 受管:插件声明里带 scope=system 的设置项同样纳入受管路径 + //(user/project 写入拒绝、生效链剥除)。 + setSvc.SetExtraSchema(pluginHost.SettingsSchema()) // 检索管线(4.12):分块+全文+(配置时)向量混合与 rerank;未配置自动退化为全文。 pipe := rag.NewPipeline(st, setSvc, nil, nil) @@ -516,6 +528,7 @@ func Run(opt Options) error { // 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。 cardSvc := cardsvc.New(st, mgr.EmitCardEvent) + cardSvc.SetProjectSharedEmitter(mgr.EmitProjectSharedCardEvent) surfaceSvc := surfacesvc.New(st, mgr.EmitCardEvent) go func() { // 启动恢复:重发「快照已落盘、事件未发出」的待恢复投影(KTD)。 if n, err := surfaceSvc.RecoverPendingEvents(context.Background()); err == nil && n > 0 { @@ -583,7 +596,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 { @@ -596,6 +610,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) @@ -620,28 +644,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 在上方已建)。 @@ -787,11 +804,12 @@ func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error }) } -// BootstrapAdmin 仅当库无任何用户时创建首位管理员并签发 activate Key。 +// BootstrapAdmin 首位管理员引导:单事务串行(KTD12——PG 咨询锁/SQLite 插入后复查), +// 建号、激活 Key 与审计共同提交;库内已有用户即拒绝接管。 func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) { var plain string err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - outc, err := st.BootstrapFirstAdmin(store.AccountWrite{ + outc, err := st.BootstrapFirstAdminWrite(store.AccountWrite{ Owner: owner, Name: owner, Actor: cliActor(), InstanceID: accountInstanceID(), }) plain = outc.Plain @@ -800,7 +818,8 @@ func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (strin return plain, err } -// ResetUserPassword CLI 重置密码:must_change=1(首登强制改密),吊销全部会话。 +// ResetUserPassword CLI 重置密码:单事务写 must_change=1、bump 认证版本、 +// 作废未用登录 Key、吊销全部会话并审计。 func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting bool) (string, error) { if password == "" { password = server.RandomPassword() @@ -810,11 +829,7 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting return "", err } if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - _, err := st.ApplyPasswordChange(store.AccountWrite{ - Owner: owner, PasswordHash: hash, MustChange: true, - Actor: cliActor(), InstanceID: accountInstanceID(), Action: "account.reset_password", - }) - return err + return st.AdminResetUserPassword(owner, hash, cliActor(), "cli") }); err != nil { return "", err } @@ -846,23 +861,21 @@ func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error) return fn(st) } -func withStoreMode(storageDSN, dataDir string, openExisting bool, fn func(*store.Store) error) error { +func withStoreMode(storageDSN, dataDir string, _ bool, fn func(*store.Store) error) error { var st *store.Store var err error - if storageDSN != "" { - if openExisting { - st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) - } else { - st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) - } - } else { - dbPath := filepath.Join(dataDir, "agent-system.db") - if openExisting { - st, err = store.OpenExistingSQLite(dbPath) - } else { - st, err = store.OpenSQLite(dbPath) - } + // DSN 等价安全输入(KTD14):argv 缺省时回落环境变量——凭据不落 shell history。 + if storageDSN == "" { + storageDSN = os.Getenv("AGENT_SYSTEM_DSN") } + // Account maintenance never initializes or migrates storage; the retained + // CLI compatibility flag cannot opt this operation into service initialization. + if storageDSN != "" { + st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) + } else { + st, err = store.OpenExistingSQLite(filepath.Join(dataDir, "agent-system.db")) + } + if err != nil { return err } diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 89c440e..866a4f4 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -4,6 +4,8 @@ import ( "agent-system-backend/pkg/extension" "context" "fmt" + "sort" + "strings" "sync" "agent-system-backend/internal/ingest" @@ -19,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 @@ -69,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 @@ -101,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 { @@ -173,7 +195,8 @@ func (h inprocHost) IsAdmin() bool { // ListAccounts 实现 EXT-19 AccountLister(最小账号目录):仅授权管理读面用。 // 有效态=账号存在且非停用(当前 users 表无停用位——must_change_password 不影响 -// 有效性;后续加停用位在此映射)。游标=owner 升序偏移。 +// 有效性;后续加停用位在此映射)。P3(2026-10-13):携带 Name 供同名区分。 +// 游标=owner 升序偏移。 func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountEntry, string, error) { if limit <= 0 || limit > 200 { limit = 50 @@ -184,7 +207,7 @@ func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountE } entries := make([]extension.AccountEntry, 0, len(users)) for _, u := range users { - entries = append(entries, extension.AccountEntry{Owner: u.Owner, Valid: true}) + entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true}) } // 排序+游标(owner 升序;cursor 为上一页末 owner,空=首页)。 for i := 0; i < len(entries); i++ { @@ -211,6 +234,34 @@ func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountE return entries[start:end], next, nil } +// SearchAccounts 实现 EXT-19 扩展 AccountSearcher(P3,2026-10-13):账号或 +// 姓名子串检索(大小写不敏感;空 query = 首页候选)。结果按 owner 升序, +// 有效态口径与 ListAccounts 一致。 +func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.AccountEntry, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + users, err := h.caps.st.ListUsersAdmin() + if err != nil { + return nil, err + } + q := strings.ToLower(strings.TrimSpace(query)) + entries := make([]extension.AccountEntry, 0, len(users)) + for _, u := range users { + if q != "" && + !strings.Contains(strings.ToLower(u.Owner), q) && + !strings.Contains(strings.ToLower(u.Name), q) { + continue + } + entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true}) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Owner < entries[j].Owner }) + if len(entries) > limit { + entries = entries[:limit] + } + return entries, nil +} + func (h inprocHost) KVPut(key, value string) error { return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value) } @@ -234,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 失败降级纯全文,不阻断写入)。 @@ -247,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...) @@ -254,31 +322,40 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs) } -// KnowledgePublish 幂等发布(EXT-9):按 docs 的 ref 整批替换——先清该 owner -// 下同 ref 旧行与旧向量(跨来源也清:ref 是发布单元),再走 KnowledgeWrite -// 语义(Owner/Source 强制同彼)。重试产生相同结果。 +// KnowledgePublish replaces refs and their vectors within the publication scope. +// A registered lifecycle scopes replacement to the exact owner/project/plugin; +// ordinary unregistered Hosts retain the legacy cross-source ref semantics. func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error { 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 } } + scoped := h.caps.projects != nil && h.caps.projects.active() for ref := range refs { - if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil { - return err + var err error + if scoped { + err = h.caps.st.DeletePluginProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref, h.pluginID) + } else { + err = h.caps.st.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref) } - if err := h.caps.st.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil { + if 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..e28dff3 --- /dev/null +++ b/internal/app/knowledge_authorization_test.go @@ -0,0 +1,166 @@ +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) + } +} + +func TestProjectPublishPreservesForeignPluginAndScope(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) + } + original := core.KnowledgeDoc{ID: "rdm-published", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "notes.md", Chunk: "originalneedle"} + if err := st.AddKnowledge(original); err != nil { + t.Fatal(err) + } + if err := st.UpsertKnowledgeVector(context.Background(), "alice", original.ID, original.Ref, []float32{1, 0}); err != nil { + t.Fatal(err) + } + h := inprocHost{owner: "alice", pluginID: "other", caps: caps} + replacement := []core.KnowledgeDoc{{ID: "other", ProjectID: "domain", Source: "plugin:other", Ref: "notes.md", Chunk: "replacementneedle"}} + for i := 0; i < 2; i++ { + if err := h.KnowledgePublish(context.Background(), replacement, false); err != nil { + t.Fatal(err) + } + } + if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle"}); err != nil || len(hits) != 1 { + t.Fatalf("foreign plugin deleted RDM publication: %+v %v", hits, err) + } + if hits, err := st.SearchKnowledgeVectors(context.Background(), "alice", []float32{1, 0}, 5, core.RetrievalQuery{ProjectID: "domain"}); err != nil || len(hits) != 1 || hits[0].Doc.ID != original.ID { + t.Fatalf("foreign plugin deleted RDM vector: %+v %v", hits, err) + } + h.pluginID = "rdm" + if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "unscoped", Source: "plugin:rdm", Ref: "notes.md", Chunk: "privateneedle"}}, false); err != nil { + t.Fatal(err) + } + if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle", ProjectID: "domain"}); err != nil || len(hits) != 1 { + t.Fatalf("unscoped publication deleted scoped RDM data: %+v %v", hits, err) + } +} + +func TestUnregisteredPublishRetainsLegacyRefScope(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: "legacy", Owner: "alice", ProjectID: "old-project", Source: "manual", Ref: "same", Chunk: "legacyneedle"}); err != nil { + t.Fatal(err) + } + h := inprocHost{owner: "alice", pluginID: "other", caps: hostcaps{st: st}} + if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "replacement", Source: "manual", Ref: "same", Chunk: "replacementneedle"}}, false); err != nil { + t.Fatal(err) + } + if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "legacyneedle"}); err != nil || len(hits) != 0 { + t.Fatalf("unregistered legacy scope changed: %+v %v", hits, err) + } + if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "replacementneedle"}); err != nil || len(hits) != 1 { + t.Fatalf("unregistered replacement missing: %+v %v", hits, err) + } +} diff --git a/internal/app/project_lifecycle.go b/internal/app/project_lifecycle.go new file mode 100644 index 0000000..78ccf95 --- /dev/null +++ b/internal/app/project_lifecycle.go @@ -0,0 +1,124 @@ +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 h.caps.projects != nil && h.caps.projects.active() { + p.Settings = h.caps.set.MaskedProjectMetadata(p.Settings) + } + 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..1650ff9 --- /dev/null +++ b/internal/app/project_lifecycle_test.go @@ -0,0 +1,163 @@ +package app + +import ( + "agent-system-backend/internal/server" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "bytes" + "context" + "encoding/json" + "errors" + "net/http/httptest" + "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) + } +} + +func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/t.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + p, err := st.CreateProject("alice", "Shared") + if err != nil { + t.Fatal(err) + } + raw := map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-nested-secret"}}, "models.embedding.apiKey": "fixture-flat-secret", "legacy.token": "fixture-legacy-secret", "plugins.private": map[string]any{"credential": "fixture-schema-secret"}, "display": map[string]any{"caption": "Shared configuration"}, "rdm.projectId": "canonical", "rdm.state": "ready"} + if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil { + t.Fatal(err) + } + svc := settings.NewService(st) + svc.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}}) + slot := &projectLifecycleSlot{} + auth := &projectAuthz{} + caps := hostcaps{st: st, set: svc, projects: slot, authz: auth} + provider := completeLifecycle() + provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) { + project, err := h.(extension.ProjectHost).ProjectMetadata(p.ID) + return []core.Project{project}, err + } + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil { + t.Fatal(err) + } + auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { + return q.Actor == "bob" && q.ResourceType == "project" && q.ResourceID == p.ID, nil + }) + for _, actor := range []string{"bob", "alice"} { + h := inprocHost{owner: actor, caps: caps} + view, err := h.ProjectMetadata(p.ID) + if err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(view.Settings) + for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} { + if bytes.Contains(encoded, []byte(value)) { + t.Fatalf("%s metadata exposed a fixture credential", actor) + } + } + if view.Settings["rdm.projectId"] != "canonical" || view.Settings["rdm.state"] != "ready" || view.Settings["display"].(map[string]any)["caption"] != "Shared configuration" { + t.Fatal("allowed service/configuration metadata changed") + } + } + // Exercise the real registry -> actor-scoped Host -> public HTTP list chain. + srv := server.New(st, nil, nil, nil, svc, nil) + srv.Auth = server.NewAuthenticator(st, true) + slot.wire(srv, caps) + key, err := st.CreateAPIKey("bob", "test") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest("GET", "/api/projects", nil) + request.Header.Set("Authorization", "Bearer "+key) + response := httptest.NewRecorder() + srv.Handler().ServeHTTP(response, request) + if response.Code != 200 { + t.Fatalf("member list=%d", response.Code) + } + for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} { + if bytes.Contains(response.Body.Bytes(), []byte(value)) { + t.Fatal("actor-scoped lifecycle list exposed a fixture credential") + } + } + if !bytes.Contains(response.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) { + t.Fatal("actor-scoped list lost canonical metadata") + } + + owned, err := (inprocHost{owner: "alice", caps: caps}).OwnedProject(p.ID) + if err != nil { + t.Fatal(err) + } + before, _ := json.Marshal(raw) + after, _ := json.Marshal(owned.Settings) + if !bytes.Equal(before, after) { + t.Fatal("metadata read mutated persisted owner settings") + } +} diff --git a/internal/blobstore/blobstore.go b/internal/blobstore/blobstore.go index ab981f1..570d437 100644 --- a/internal/blobstore/blobstore.go +++ b/internal/blobstore/blobstore.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "strings" "sync" ) @@ -19,6 +20,21 @@ type Store interface { Delete(ref string) error } +// IsNil 判断对象存储是否未配置。nil 接口,以及放进接口后的带类型空指针 +// (例如未设置端点时的 (*S3)(nil)),都算未配置。已构造的实现返回 false。 +func IsNil(s Store) bool { + if s == nil { + return true + } + v := reflect.ValueOf(s) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return v.IsNil() + default: + return false + } +} + // Local 是本地目录实现(data/artifacts;多实例时把目录指向共享卷即可)。 type Local struct { dir string diff --git a/internal/blobstore/nil_test.go b/internal/blobstore/nil_test.go new file mode 100644 index 0000000..4bb375a --- /dev/null +++ b/internal/blobstore/nil_test.go @@ -0,0 +1,29 @@ +package blobstore + +import "testing" + +func TestIsNil(t *testing.T) { + var unset Store + if !IsNil(unset) { + t.Fatal("nil interface must count as unset") + } + var typed *S3 + var asStore Store = typed + if !IsNil(asStore) { + t.Fatal("typed nil *S3 must count as unset") + } + local, err := NewLocal(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if IsNil(local) { + t.Fatal("local store must count as configured") + } + s3, err := NewS3("http://127.0.0.1:9/bucket", "us-east-1", "access", "secret", "art/") + if err != nil { + t.Fatal(err) + } + if IsNil(s3) { + t.Fatal("configured S3 must count as configured") + } +} diff --git a/internal/cardsvc/cardsvc.go b/internal/cardsvc/cardsvc.go index a17b6dc..6b4d0ab 100644 --- a/internal/cardsvc/cardsvc.go +++ b/internal/cardsvc/cardsvc.go @@ -27,8 +27,9 @@ var ( const cardAppendWindow = 250 * time.Millisecond type Service struct { - st *store.Store - emit Emitter + st *store.Store + emit Emitter + projectSharedEmit ProjectSharedEmitter // MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量 // 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。 MirrorConvCards bool @@ -41,6 +42,42 @@ func New(st *store.Store, emit Emitter) *Service { return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}, MirrorConvCards: true} } +// ProjectSharedEmitter is an internal trusted publication adapter. It is never +// bound to model arguments or exposed as a mandatory extension capability. +type ProjectSharedEmitter func(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error + +func (s *Service) SetProjectSharedEmitter(emit ProjectSharedEmitter) { s.projectSharedEmit = emit } + +// PublishProjectCard explicitly publishes a project-level structural snapshot. +// Ordinary Create/Update remain private unless this entry issues a new proof. +func (s *Service) PublishProjectCard(owner, producer string, card core.ActivityCard, ref string) (core.ActivityCard, error) { + if s.projectSharedEmit == nil || owner == "" || producer == "" || card.ProjectID == "" || card.ConversationID != "" { + return card, errors.New("card: trusted project publisher required") + } + if card.ID != "" { + current, err := s.st.GetCard(owner, card.ID) + if err != nil { + return card, err + } + if current.ProjectID != card.ProjectID || current.ConversationID != "" { + return card, store.ErrNotFound + } + } else { + card.ID = store.NewID("card") + } + now := s.now().UTC() + card.CreatedAt = now + card.UpdatedAt = now + if card.Status == "" { + card.Status = core.CardActive + } + if err := s.st.SaveCard(owner, &card, ref); err != nil { + return card, err + } + err := s.projectSharedEmit(owner, producer, string(core.ProjectChannelKey(card.ProjectID)), card.SourceRunID, core.EvCardCreated, core.CardPayload{Card: card}) + return card, err +} + // emitCard 按卡归属发布卡片事件(D50): // - 会话卡 → 会话流;同时镜像项目流(伪会话键 "project:")—— // 项目收件箱聚合视图(项目级卡 ∪ 各会话未决卡)的实时性依赖它;卡片事件 diff --git a/internal/genui/genui_test.go b/internal/genui/genui_test.go index b246db7..40a3dab 100644 --- a/internal/genui/genui_test.go +++ b/internal/genui/genui_test.go @@ -226,7 +226,7 @@ func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorize func (r *fakeRegistry) RegisterResourceAuthorizerV2(fn extension.ResourceAuthorizerV2) error { return nil } -func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil } +func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil } func (r *fakeRegistry) AddAdminZone(spec json.RawMessage) error { return nil } func TestPresentRejectsUnknownActionIDs(t *testing.T) { 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/rag/rag_test.go b/internal/rag/rag_test.go index 8260039..c3ce9f6 100644 --- a/internal/rag/rag_test.go +++ b/internal/rag/rag_test.go @@ -162,8 +162,9 @@ func TestRetrieveFallbackAndHybrid(t *testing.T) { } // 有 embedder 且路由已配置:查询向量 A 类 → A 文档排前(RRF 向量路主导)。 + // P2 受管:models.* 是系统受管项,配置写入系统层行(owner=OwnerSystemRow)。 pipe2, st2 := newTestPipeline(t, fakeEmbedder{}, nil) - if err := st2.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{ + if err := st2.MergeSettingsScope(settings.OwnerSystemRow, settings.ScopeSystem, map[string]any{ "models": map[string]any{"embedding": map[string]any{"baseUrl": "http://fake.internal", "model": "e-test"}}, }); err != nil { t.Fatal(err) diff --git a/internal/runmgr/events.go b/internal/runmgr/events.go index 9e41162..971e0c2 100644 --- a/internal/runmgr/events.go +++ b/internal/runmgr/events.go @@ -116,6 +116,34 @@ func (eb *eventBuffer) emit(conversationID, runID string, typ core.StreamEventTy return 0, nil } +// emitProjectShared persists an explicit internal publication and its proof +// before fan-out. A client-visible payload cannot opt itself into this path. +func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error { + project, ok := core.ProjectFromChannel(channel) + if !ok { + return store.ErrNotFound + } + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + event := core.StreamEvent{ConversationID: channel, ProjectID: project, RunID: runID, Type: typ, Payload: encoded, TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion} + eb.flushConversation(channel) + eb.flushing.Lock() + defer eb.flushing.Unlock() + events := []core.StreamEvent{event} + if _, err := eb.store.AppendProjectSharedEvents(owner, producer, channel, events); err != nil { + return err + } + for _, event := range events { + eb.bus.Publish(event) + } + if eb.onPersisted != nil { + eb.onPersisted(channel, events) + } + return nil +} + // append 落盘并按 seq 顺序发布。 func (eb *eventBuffer) append(conversationID string, evs []core.StreamEvent) error { eb.flushing.Lock() diff --git a/internal/runmgr/knowledge_resource_test.go b/internal/runmgr/knowledge_resource_test.go new file mode 100644 index 0000000..2cbd96e --- /dev/null +++ b/internal/runmgr/knowledge_resource_test.go @@ -0,0 +1,143 @@ +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) + } +} + +func TestProjectWorkRevocationBlocksQueuedHITL(t *testing.T) { + c, st := newExecEnv(t) + c.run.ID = "revoked-run" + c.run.Status = core.RunWaitingHITL + if err := st.CreateRun(&c.run); err != nil { + t.Fatal(err) + } + h := &runHandle{hitlCh: make(chan hitlAnswer, 1)} + c.mgr.active = map[core.ID]*runHandle{c.run.ID: h} + card := core.HITLCard{ID: "revoked-card", RunID: c.run.ID, ConversationID: c.run.ConversationID} + if err := st.SaveHITLCard("alice", &card); err != nil { + t.Fatal(err) + } + c.mgr.SetProjectWorkAuthorization(func(_ context.Context, owner string, id core.ID) (bool, error) { + if owner != "alice" || id != c.run.ProjectID { + t.Fatalf("untrusted resolution identity owner=%s project=%s", owner, id) + } + return false, nil + }) + if _, _, err := c.mgr.ResolveHITL("alice", card.ID, core.HITLResponse{ChoiceID: "yes"}); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("revoked confirmation should reject: %v", err) + } + persisted, err := st.GetHITLCard("alice", card.ID) + if err != nil || persisted.Resolved || len(h.hitlCh) != 0 { + t.Fatalf("revoked confirmation changed gate: card=%+v channel=%d error=%v", persisted, len(h.hitlCh), err) + } + run, err := st.GetRunByID(c.run.ID) + if err != nil || run.Status != core.RunWaitingHITL { + t.Fatalf("revoked confirmation resumed run: %+v %v", run, err) + } + payload, _ := json.Marshal(HITLCommandPayload{Owner: "alice", CardID: card.ID, Kind: "yes"}) + c.mgr.applyHITLCommand(ownedCommand{CommandID: "queued-approval", Payload: string(payload)}) + if len(h.hitlCh) != 0 { + t.Fatal("queued approval delivered a revoked answer") + } + run, err = st.GetRunByID(c.run.ID) + if err != nil || run.Status != core.RunWaitingHITL { + t.Fatalf("queued approval resumed run: %+v %v", run, err) + } +} + +func TestProjectWorkRevocationAfterToolConfirmation(t *testing.T) { + for _, choice := range []string{"yes", "session"} { + t.Run(choice, func(t *testing.T) { + c, st := newExecEnv(t) + c.h = &runHandle{hitlCh: make(chan hitlAnswer, 1)} + c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: choice}} + c.prof.Tools.Confirm = []string{"write_artifact"} + ready := true + c.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { ok := ready; ready = false; return ok, nil }) + res := c.ExecuteTool(core.ToolCallRequest{CallID: "revoked-call", Name: "write_artifact", Arguments: json.RawMessage(`{"name":"revoked.txt","content":"x"}`)}) + artifacts, err := st.ListArtifacts("alice", c.run.ProjectID) + if err != nil { + t.Fatal(err) + } + if res.Status != "error" || len(artifacts) != 0 { + t.Fatalf("tool executed after revoked confirmation: status=%s artifacts=%d", res.Status, len(artifacts)) + } + if c.mgr.sessionAllows(c.run.ConversationID, "write_artifact") { + t.Fatal("revoked confirmation granted session permission") + } + }) + } +} + +func TestProjectWorkRevocationBlocksDirectArtifactWrite(t *testing.T) { + c, st := newExecEnv(t) + c.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return false, nil }) + if _, err := c.WriteArtifact("revoked.txt", "data", "text/plain", []byte("private")); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("direct artifact should reject: %v", err) + } + artifacts, err := st.ListArtifacts("alice", c.run.ProjectID) + if err != nil || len(artifacts) != 0 { + t.Fatalf("revoked artifact was written: %+v %v", artifacts, err) + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 1086b73..b3f3d99 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 整文档单块) @@ -176,6 +179,12 @@ type IntentGate interface { Match(ctx context.Context, input string) (branch, topID string) } +// EmitProjectSharedCardEvent is an internal producer-authorized structural +// publication entry, separate from the ordinary private card event emitter. +func (m *Manager) EmitProjectSharedCardEvent(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error { + return m.events.emitProjectShared(owner, producer, channel, runID, typ, payload) +} + // SetGate 注入意图闸门(装配期一次;插件意图 overlay 经 pluginhost 回调重建)。 func (m *Manager) SetGate(g IntentGate) { m.gate = g } @@ -197,6 +206,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 } @@ -596,7 +636,7 @@ func (c *runCtx) SetAssistantParts(parts []core.Part) error { return nil } -func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) { +func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err error) { card.ID = store.NewID("hitl") card.RunID = c.run.ID card.ConversationID = c.run.ConversationID @@ -638,8 +678,13 @@ func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) { // 排队,取消语境下重取失败由取消路径收口)。 c.mgr.adm.release(c.mgr.st, string(c.run.ID)) defer func() { - if err := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); err != nil { - log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, err) + if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); acquireErr != nil { + log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, acquireErr) + } + // Permission can change during both confirmation and admission waiting. + // Every HITL caller receives the live decision before it resumes work. + if err == nil { + err = c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID) } }() return waitHITL(c.ctx, c.h.hitlCh, timeout) @@ -842,6 +887,9 @@ func (c *runCtx) Artifact(id string) (core.Artifact, []byte, bool) { } func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { + if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return core.Artifact{}, err + } art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID} if err := c.mgr.st.CreateArtifact(&art, content); err != nil { return art, err @@ -902,6 +950,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 +999,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)} @@ -937,12 +1022,18 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { if err != nil { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()} } + 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()} + } if resp.ChoiceID == "session" { c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name) } else if resp.ChoiceID != "yes" { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"} } } + 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()} + } // EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、 // Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。 if c.mgr.commands != nil { @@ -1141,6 +1232,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 +1244,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 +1356,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{ @@ -1581,6 +1682,12 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv if err != nil { return "", false, err } + if m.projectWorkAuthorization != nil && (run.Owner != owner || run.ConversationID != card.ConversationID) { + return "", false, store.ErrNotFound + } + if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil { + return "", false, err + } // U7 双进程验收实测缺陷修复:run 不在本进程执行时不得先落 resolve—— // 卡一旦标记 resolved,另一实例 server 层的命令 fallback(resolveHITL 的 // !card.Resolved 判定)即被跳过,执行实例的 runner 永远等不到答案, @@ -1625,6 +1732,9 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv } } if pivot { + if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil { + return "", false, err + } _ = m.CancelRun(run.ID) m.st.ResolveHITLCard(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer}) m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer}) @@ -1635,6 +1745,9 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv return trimmed, true, nil } } + if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil { + return "", false, err + } resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind} if err := m.st.ResolveHITLCard(owner, cardID, resolution); err != nil { return "", false, err diff --git a/internal/runtime/llm_test.go b/internal/runtime/llm_test.go index 79f599c..067488e 100644 --- a/internal/runtime/llm_test.go +++ b/internal/runtime/llm_test.go @@ -45,7 +45,7 @@ type fakeRunCtx struct { memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入断言用) usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用) bgCalls int - runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用) + runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用) attachments []core.ParsedDocument // Attachments 返回值(双速入口前置提示测试用) spawnAgentFn func(agentID, name, task, hint string) (core.AgentResultPayload, error) execStatus string // 非空时覆盖 ExecuteTool 状态(R4 失败链测试) diff --git a/internal/server/accounts.go b/internal/server/accounts.go index 16dc240..87aa48e 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "io" "net/http" "strings" "sync" @@ -60,10 +61,22 @@ func loginClear(owner string) { delete(loginGuard.fails, owner) } -func setSessionCookie(w http.ResponseWriter, token string, maxAge int) { +// 兑换入口退避:共享库窗口计数(v29 redeem_attempts),多实例共担同一限流; +// Key 无属主可先行归因,只能按来源 IP 限流。 +const ( + redeemMaxFails = 10 + redeemWindow = loginLockWindow +) + +// requestSecure 判断请求是否经 HTTPS(直连 TLS 或反代 X-Forwarded-Proto 标头)。 +func requestSecure(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, maxAge int) { http.SetCookie(w, &http.Cookie{ Name: SessionCookie, Value: token, Path: "/", HttpOnly: true, - SameSite: http.SameSiteLaxMode, MaxAge: maxAge, + SameSite: http.SameSiteLaxMode, MaxAge: maxAge, Secure: requestSecure(r), }) } @@ -76,11 +89,20 @@ func authStateJSON(st store.UserAuthState) map[string]any { } // redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。 +// 请求只有 Key:身份由服务端绑定记录确定;按来源 IP 退避防爆破。 func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) { if s.Auth == nil || !s.Auth.Enabled { http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed) return } + ip, _, _ := strings.Cut(r.RemoteAddr, ":") + if xf := r.Header.Get("X-Forwarded-For"); xf != "" { + ip = strings.TrimSpace(strings.Split(xf, ",")[0]) + } + if blocked, err := s.st.RedeemBlocked(ip, redeemMaxFails, redeemWindow); err == nil && blocked { + http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests) + return + } var req struct { Key string `json:"key"` } @@ -90,6 +112,7 @@ func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) { } token, owner, err := s.st.RedeemLoginKey(strings.TrimSpace(req.Key)) if err != nil { + _ = s.st.RecordRedeemFailure(ip) // 尽力记录;限流判定的权威在 RedeemBlocked if errors.Is(err, store.ErrConflict) { http.Error(w, "login key already used", http.StatusConflict) return @@ -97,7 +120,7 @@ func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid or expired login key", http.StatusUnauthorized) return } - setSessionCookie(w, token, int(10*60)) + setSessionCookie(w, r, token, int(10*60)) st, _ := s.st.GetUserAuthState(owner) out := authStateJSON(st) out["sessionScope"] = store.SessionScopeSetPasswordOnly @@ -129,20 +152,34 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests) return } - st, ok := s.st.VerifyLogin(owner, req.Password) + st, ver, ok := s.st.VerifyLoginWithVersion(owner, req.Password) if !ok { loginFail(owner) http.Error(w, "invalid owner or password", http.StatusUnauthorized) return } loginClear(owner) - token, err := s.st.CreateSession(owner) + // 待改密/未设密:建受限会话(只能设密);否则正常会话。会话创建绑定 + // 认证版本——密码验证在途期间账户被恢复/重置时本请求不得再建会话。 + scope := store.SessionScopeFull + maxAge := 7 * 24 * 3600 + if st.MustChangePassword || !st.HasPassword { + scope = store.SessionScopeSetPasswordOnly + maxAge = 10 * 60 + } + token, err := s.st.CreateSessionVerified(owner, scope, time.Duration(maxAge)*time.Second, ver) if err != nil { + if errors.Is(err, store.ErrConflict) { + http.Error(w, "credentials changed concurrently; retry login", http.StatusUnauthorized) + return + } http.Error(w, err.Error(), http.StatusInternalServerError) return } - setSessionCookie(w, token, 7*24*3600) - writeJSON(w, http.StatusOK, authStateJSON(st)) + setSessionCookie(w, r, token, maxAge) + out := authStateJSON(st) + out["sessionScope"] = scope + writeJSON(w, http.StatusOK, out) } // logout:注销当前会话。 @@ -150,13 +187,14 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { if tok := sessionToken(r); tok != "" { s.st.DeleteSession(tok) } - setSessionCookie(w, "", -1) + setSessionCookie(w, r, "", -1) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // changePassword:会话登录者改密。 -// - scope=set_password_only 或 must_change/未设密:不要求 current;完成后销毁全部会话,要求重新密码登录。 -// - 普通主动改密:要求 current;保留当前会话、吊销其余。 +// - 受限会话或 must_change/未设密:不要求 current;事务内复验会话与认证版本, +// 完成后销毁全部会话,要求重新密码登录(reloginRequired=true)。 +// - 普通主动改密:要求 current;单事务完成校验、更新、吊销其他会话(保留当前)。 func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { tok := sessionToken(r) if tok == "" { @@ -180,41 +218,41 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - st0, _ := s.st.GetUserAuthState(owner) - forceSet := scope == store.SessionScopeSetPasswordOnly || st0.MustChangePassword || !st0.HasPassword - if !forceSet { - if _, ok := s.st.VerifyLogin(owner, req.Current); !ok { - http.Error(w, "current password incorrect", http.StatusUnauthorized) - return - } - } hash, err := store.HashPassword(req.Next) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + st0, _ := s.st.GetUserAuthState(owner) + forceSet := scope == store.SessionScopeSetPasswordOnly || st0.MustChangePassword || !st0.HasPassword if forceSet { - if _, err := s.st.ApplyPasswordChange(store.AccountWrite{ - Owner: owner, PasswordHash: hash, Actor: owner, Action: "account.set_password", - }); err != nil { + if _, err := s.st.CompleteSetPasswordChecked(tok, hash); err != nil { + if errors.Is(err, store.ErrNotFound) { + http.Error(w, "session expired", http.StatusUnauthorized) + return + } writeStoreErr(w, err) return } - setSessionCookie(w, "", -1) + setSessionCookie(w, r, "", -1) st, _ := s.st.GetUserAuthState(owner) out := authStateJSON(st) out["reloginRequired"] = true writeJSON(w, http.StatusOK, out) return } - if _, err := s.st.ApplyPasswordChange(store.AccountWrite{ - Owner: owner, PasswordHash: hash, ExceptToken: tok, Actor: owner, Action: "account.change_password", - }); err != nil { + if err := s.st.ChangeUserPasswordActive(owner, req.Current, hash, tok); err != nil { + if errors.Is(err, store.ErrBadCredentials) { + http.Error(w, "current password incorrect", http.StatusUnauthorized) + return + } writeStoreErr(w, err) return } st, _ := s.st.GetUserAuthState(owner) - writeJSON(w, http.StatusOK, authStateJSON(st)) + out := authStateJSON(st) + out["reloginRequired"] = false + writeJSON(w, http.StatusOK, out) } func validPassword(pw string) error { @@ -248,7 +286,8 @@ func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) { } func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requireAdmin(w, r); !ok { + actor, ok := s.requireAdmin(w, r) + if !ok { return } var req struct { @@ -275,7 +314,7 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - if err := s.st.CreateAccount(req.Owner, strings.TrimSpace(req.Name), hash, req.IsAdmin); err != nil { + if _, err := s.st.CreateActivatedAccount(store.AccountWrite{Owner: req.Owner, Name: strings.TrimSpace(req.Name), PasswordHash: hash, IsAdmin: req.IsAdmin, Actor: actor, Action: "account.create"}); err != nil { writeStoreErr(w, err) return } @@ -286,7 +325,9 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { }) } -// resetAdminUserPassword:管理员重置密码 → must_change=1,吊销该用户全部会话。 +// resetAdminUserPassword:管理员直接重置密码(兼容保留的既有入口;常规恢复 +// 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用 +// 登录 Key、吊销全部会话、审计。 func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) { actor, ok := s.requireAdmin(w, r) if !ok { @@ -309,9 +350,7 @@ func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) http.Error(w, err.Error(), http.StatusInternalServerError) return } - if _, err := s.st.ApplyPasswordChange(store.AccountWrite{ - Owner: owner, PasswordHash: hash, MustChange: true, Actor: actor, Action: "account.reset_password", - }); err != nil { + if err := s.st.AdminResetUserPassword(owner, hash, actor, "admin-http"); err != nil { writeStoreErr(w, err) return } @@ -321,6 +360,67 @@ func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) }) } +// ---- 管理员:一次性登录 Key(恢复流程主入口)---- + +// issueAdminLoginKey:为指定既有用户签发恢复 Key(明文仅本次响应返回)。 +// 同事务撤销旧恢复 Key 及其受限会话;不改密码、不吊销正常会话、不动业务数据。 +func (s *Server) issueAdminLoginKey(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + owner := strings.TrimSpace(r.PathValue("owner")) + var req struct { + Purpose string `json:"purpose"` + TTLMinutes int `json:"ttlMinutes"` + } + if err := decodeJSON(r, &req); err != nil && !errors.Is(err, io.EOF) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + purpose := req.Purpose + if purpose == "" { + purpose = store.LoginKeyPurposeRecover + } + if purpose != store.LoginKeyPurposeRecover && purpose != store.LoginKeyPurposeActivate { + http.Error(w, "purpose must be recover or activate", http.StatusBadRequest) + return + } + ttl := time.Duration(req.TTLMinutes) * time.Minute + plain, meta, err := s.st.IssueLoginKey(owner, purpose, actor, ttl) + if err != nil { + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "id": meta.ID, "owner": meta.Owner, "purpose": meta.Purpose, + "status": meta.Status, "expiresAt": meta.ExpiresAt, "createdBy": meta.CreatedBy, + "key": plain, // 明文仅此一次;关闭弹层后不可再取 + }) +} + +// listAdminLoginKeys:指定用户的 Key 状态与有效期(不返回明文)。 +func (s *Server) listAdminLoginKeys(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAdmin(w, r); !ok { + return + } + keys, err := s.st.ListLoginKeys(r.PathValue("owner")) + writeJSONOr(w, map[string]any{"keys": keys}, err) +} + +// revokeAdminLoginKey:撤销 Key 并作废其关联受限会话。 +func (s *Server) revokeAdminLoginKey(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + if _, err := s.st.RevokeLoginKeyCascade(r.PathValue("id"), actor); err != nil { + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + // RandomPassword 生成随机初始密码(管理员/CLI 未显式给定密码时)。 func RandomPassword() string { raw := make([]byte, 9) diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index 7c476cd..cb31fef 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -527,3 +527,79 @@ func TestStaleKeySessionFallback(t *testing.T) { t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String()) } } + +// TestSystemSettingsAdminGate:P2 系统设置面按管理员准入——普通账号 403, +// 管理员可读写且值对所有租户生效;普通账号写受管项仍 400。 +func TestSystemSettingsAdminGate(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + + hash, err := store.HashPassword("init-pass-01") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("boss", "管理员甲", hash, true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("dave", "普通乙", hash, false); err != nil { + t.Fatal(err) + } + keyBoss, err := st.CreateAPIKey("boss", "cli") + if err != nil { + t.Fatal(err) + } + keyDave, err := st.CreateAPIKey("dave", "cli") + if err != nil { + t.Fatal(err) + } + // 初始密码改密门不参与本测试对象(受管拦截语义),先解除。 + if err := st.ClearMustChangePassword("boss"); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("dave"); err != nil { + t.Fatal(err) + } + do := func(key, method, path string, body string) *httptest.ResponseRecorder { + var rd io.Reader + if body != "" { + rd = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, rd) + req.Header.Set("Authorization", "Bearer "+key) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + + // 普通账号:读写系统面均 403;写受管项走 user 面仍是 400(先于管理员判定也成立)。 + if rec := do(keyDave, "GET", "/api/settings/system", ""); rec.Code != http.StatusForbidden { + t.Fatalf("dave get system: %d", rec.Code) + } + if rec := do(keyDave, "PUT", "/api/settings/system", `{"models":{"chat":{"model":"m-dave"}}}`); rec.Code != http.StatusForbidden { + t.Fatalf("dave put system: %d", rec.Code) + } + if rec := do(keyDave, "PUT", "/api/settings/user", `{"models":{"chat":{"model":"m-dave"}}}`); rec.Code != http.StatusBadRequest { + t.Fatalf("dave managed via user: %d body=%s", rec.Code, rec.Body.String()) + } + if rec := do(keyDave, "PUT", "/api/settings/user", `{"ui":{"theme":"light"}}`); rec.Code != http.StatusOK { + t.Fatalf("dave non-managed via user: %d body=%s", rec.Code, rec.Body.String()) + } + + // 管理员:写入生效,系统读面掩码返回,dave 的有效设置拿到系统值。 + if rec := do(keyBoss, "PUT", "/api/settings/system", `{"models":{"chat":{"model":"m-boss","apiKey":"sk-boss-secret"}}}`); rec.Code != http.StatusOK { + t.Fatalf("boss put system: %d %s", rec.Code, rec.Body.String()) + } + rec := do(keyBoss, "GET", "/api/settings/system", "") + if rec.Code != http.StatusOK { + t.Fatalf("boss get system: %d", rec.Code) + } + if strings.Contains(rec.Body.String(), "sk-boss-secret") { + t.Fatalf("system read leaked secret key") + } + if eff := settings.NewService(st).Effective("dave", ""); eff.GetString("models.chat.model", "") != "m-boss" { + t.Fatalf("system value not effective for dave: %q", eff.GetString("models.chat.model", "")) + } +} 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/e2e_test.go b/internal/server/e2e_test.go index 0b84e36..44a21c8 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -48,12 +48,14 @@ func newTestServer(t *testing.T) (*Server, *store.Store) { t.Fatal(err) } t.Cleanup(func() { st.Close() }) - _ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}}) - b := bus.New() reg := tools.NewRegistry() reg.RegisterBuiltin() setSvc := settings.NewService(st) + // P2 受管:runtime.mock 是系统受管项,mock 驱动改经系统层写入(对所有租户生效)。 + if err := setSvc.MergeSystem(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil { + t.Fatal(err) + } profSvc := profiles.New(st) profSvc.EnsureDefault() mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{}) @@ -1010,41 +1012,59 @@ func TestSquadOrchestration(t *testing.T) { t.Fatal("squad deliverable artifact not found") } -// TestModelRoutingOverrideChain:设置覆盖链集成(defaults → user → project) -// 与画像层最终覆盖——经由真实 run 的失败信息携带解析出的模型名取证。 +// TestModelRoutingOverrideChain:P2 受管语义下的模型配置链路——models.* 是系统 +// 受管项,user/project 层整笔拒绝(含嵌套与点路径),仅系统层可写;画像层模型名 +// 仍是最终覆盖。经由真实 run 的失败信息携带解析出的模型名取证。 func TestModelRoutingOverrideChain(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler() projectID, convID := setupConversation(t, h) - set := func(body map[string]any, path string) { - code, out := doJSON(t, h, "PUT", path, body) - if code != 200 { - t.Fatalf("PUT %s: %d %v", path, code, out) + // user/project 层写受管项 → 400(含点路径与整体子树覆盖)。 + for _, tc := range []struct { + name string + path string + body map[string]any + }{ + {"nested user", "/api/settings/user", map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-A"}}}}, + {"dotted user", "/api/settings/user", map[string]any{"models.chat.model": "model-A"}}, + {"nested project", "/api/settings/project/" + projectID, map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-B"}}}}, + {"subtree smash user", "/api/settings/user", map[string]any{"models": "smash"}}, + } { + code, out := doJSON(t, h, "PUT", tc.path, tc.body) + if code != http.StatusBadRequest { + t.Fatalf("%s: PUT %s = %d %v,want 400", tc.name, tc.path, code, out) } } - // user 层:model-A。 - set(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-A"}}}, "/api/settings/user") - eff := settings.NewService(st).Effective(store.OwnerDefault, projectID) - if got := eff.GetString("models.chat.model", ""); got != "model-A" { - t.Fatalf("user layer: %q", got) - } - // project 层:model-B(覆盖 user)。 - set(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-B"}}}, "/api/settings/project/"+projectID) - eff = settings.NewService(st).Effective(store.OwnerDefault, projectID) - if got := eff.GetString("models.chat.model", ""); got != "model-B" { - t.Fatalf("project layer should win: %q", got) - } - // 设置 API 呈现两层。 - code, out := doJSON(t, h, "GET", "/api/settings?project="+projectID, nil) + // 系统层(无认证测试档 = 本机管理员)写入生效。 + code, out := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{ + "models": map[string]any{"chat": map[string]any{"model": "model-SYS"}}, + }) if code != 200 { - t.Fatal("get settings failed") + t.Fatalf("system write: %d %v", code, out) + } + if eff := settings.NewService(st).Effective(store.OwnerDefault, projectID); eff.GetString("models.chat.model", "") != "model-SYS" { + t.Fatalf("system layer not effective: %q", eff.GetString("models.chat.model", "")) + } + // 系统 GET 面:schema 携带 scope,系统值脱敏返回。 + code, out = doJSON(t, h, "GET", "/api/settings/system", nil) + if code != 200 { + t.Fatalf("get system: %d %v", code, out) + } + sysView := out + if sysView["system"] == nil || sysView["schema"] == nil { + t.Fatalf("system view malformed: %#v", sysView) + } + // 非受管项仍可走 user 层(回归)。 + if code, _ := doJSON(t, h, "PUT", "/api/settings/user", map[string]any{"ui": map[string]any{"theme": "light"}}); code != 200 { + t.Fatalf("non-managed user write rejected") } - _ = out - // 画像层:model-C 为最终覆盖。关 mock → 发消息 → run 失败信息携带解析结果。 - set(map[string]any{"runtime": map[string]any{"mock": false}}, "/api/settings/user") + // 画像层:model-C 为最终覆盖。关 mock(系统层)→ 发消息 → run 失败信息携带解析结果。 + if code, _ := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{"runtime": map[string]any{"mock": false}}); code != 200 { + t.Fatal("disable mock via system layer failed") + } prof := mustCode(t, h, "POST", "/api/profiles", map[string]any{ "id": "prof_route_c", "name": "路由C", "systemPrompt": "x", "model": map[string]any{"chat": "model-C"}, @@ -1205,18 +1225,27 @@ func TestProjectInbox(t *testing.T) { } } -// TestSettingsDottedKeyAndSecretMask:点路径写入生效、嵌套胜出、掩码不回写、GET 不回明文。 +// TestSettingsDottedKeyAndSecretMask:点路径写入生效、受管密钥归系统层、 +// 掩码不回写、user/system 读面均不回明文、user 面不回显受管项(P2)。 func TestSettingsDottedKeyAndSecretMask(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler() + svc := settings.NewService(st) + set := func(body map[string]any) { t.Helper() code, out := doJSON(t, h, "PUT", "/api/settings/user", body) if code != 200 { - t.Fatalf("PUT: %d %v", code, out) + t.Fatalf("PUT user: %d %v", code, out) + } + } + setSys := func(body map[string]any) { + t.Helper() + code, out := doJSON(t, h, "PUT", "/api/settings/system", body) + if code != 200 { + t.Fatalf("PUT system: %d %v", code, out) } } - svc := settings.NewService(st) set(map[string]any{"runtime.entry": "squad"}) if got := svc.Effective(store.OwnerDefault, "").GetString("runtime.entry", ""); got != "squad" { @@ -1232,21 +1261,25 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) { const pool = "tvly-should-not-leak" const modelKey = "sk-should-not-leak" - set(map[string]any{"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}}) - set(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": modelKey}}}) - set(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": settings.MaskPlaceholder}}}) - set(map[string]any{ - "tools": map[string]any{"tavily": map[string]any{"apiKeys": "nested-wins"}}, - "tools.tavily.apiKeys": "flat-loses", - }) + // P2 受管:密钥项 user 层整笔拒绝,系统层写入生效。 + if code, _ := doJSON(t, h, "PUT", "/api/settings/user", map[string]any{ + "tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}, + }); code != http.StatusBadRequest { + t.Fatalf("managed pool write via user should 400, got %d", code) + } + setSys(map[string]any{"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}}) + setSys(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": modelKey}}}) + // 掩码回写经系统层:不覆盖已存明文。 + setSys(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": settings.MaskPlaceholder}}}) eff := svc.Effective(store.OwnerDefault, "") if got := eff.GetString("models.chat.apiKey", ""); got != modelKey { t.Fatalf("mask writeback overwrote key: %q", got) } - if got := eff.GetString("tools.tavily.apiKeys", ""); got != "nested-wins" { - t.Fatalf("nested form should win, got %q", got) + if got := eff.GetString("tools.tavily.apiKeys", ""); got != pool { + t.Fatalf("system pool not effective: %q", got) } + // 读面:user 面不回显受管项;系统面返回掩码;两个面都不泄漏明文。 code, out := doJSON(t, h, "GET", "/api/settings", nil) if code != 200 { t.Fatalf("GET settings: %d", code) @@ -1255,19 +1288,36 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) { if err != nil { t.Fatal(err) } - for _, secret := range []string{pool, modelKey, "nested-wins", "flat-loses"} { + for _, secret := range []string{pool, modelKey} { if strings.Contains(string(blob), secret) { - t.Fatalf("GET leaked %q", secret) + t.Fatalf("GET /api/settings leaked %q", secret) } } user := out["user"].(map[string]any) - tavily := user["tools"].(map[string]any)["tavily"].(map[string]any) - if tavily["apiKeys"] != settings.MaskPlaceholder { - t.Fatalf("apiKeys mask = %v", tavily["apiKeys"]) + if user["models"] != nil || user["tools"] != nil { + t.Fatalf("managed subtrees exposed in user scope: %#v", user) } - chat := user["models"].(map[string]any)["chat"].(map[string]any) + code, sys := doJSON(t, h, "GET", "/api/settings/system", nil) + if code != 200 { + t.Fatalf("GET system: %d", code) + } + sysBlob, err := json.Marshal(sys) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{pool, modelKey} { + if strings.Contains(string(sysBlob), secret) { + t.Fatalf("GET /api/settings/system leaked %q", secret) + } + } + sysValues := sys["system"].(map[string]any) + tavily := sysValues["tools"].(map[string]any)["tavily"].(map[string]any) + if tavily["apiKeys"] != settings.MaskPlaceholder { + t.Fatalf("system apiKeys mask = %v", tavily["apiKeys"]) + } + chat := sysValues["models"].(map[string]any)["chat"].(map[string]any) if chat["apiKey"] != settings.MaskPlaceholder { - t.Fatalf("apiKey mask = %v", chat["apiKey"]) + t.Fatalf("system apiKey mask = %v", chat["apiKey"]) } } diff --git a/internal/server/login_recovery_test.go b/internal/server/login_recovery_test.go new file mode 100644 index 0000000..0936fd5 --- /dev/null +++ b/internal/server/login_recovery_test.go @@ -0,0 +1,311 @@ +package server + +// 登录恢复流程 HTTP 合同(2026-10-13):管理员签发一次性登录 Key → 用户兑换 → +// 受限会话强制设密 → 重新密码登录;must-change 首登同样建受限会话。 + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "agent-system-backend/internal/store" +) + +// bootAdmin:建管理员并完成首登改密,返回已放行的会话 cookie 值。 +func bootAdmin(t *testing.T, h http.Handler, st *store.Store) string { + t.Helper() + bootHash, err := store.HashPassword("boot-pass-00") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "", bootHash, true); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"boot-pass-00"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("boot login: %d %s", rec.Code, rec.Body.String()) + } + tok := cookieOf(rec) + if tok == "" { + t.Fatal("missing session cookie") + } + req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"next":"admin-final-9"}`)) + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("boot set password: %d %s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("relogin: %d %s", rec.Code, rec.Body.String()) + } + return cookieOf(rec) +} + +func cookieOf(rec *httptest.ResponseRecorder) string { + for _, ck := range rec.Result().Cookies() { + if ck.Name == SessionCookie { + return ck.Value + } + } + return "" +} + +func reqWithCookie(method, path, body, cookie string) *http.Request { + var rdr *bytes.Reader + if body != "" { + rdr = bytes.NewReader([]byte(body)) + } else { + rdr = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, rdr) + if cookie != "" { + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: cookie}) + } + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + return req +} + +func do(h http.Handler, method, path, body, cookie string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, reqWithCookie(method, path, body, cookie)) + return rec +} + +// 管理员签发 → 列表无明文 → 兑换 → 受限 → 设密 → 新密码登录。 +func TestAdminLoginKeyRecoveryFlow(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + adminTok := bootAdmin(t, h, st) + if err := st.CreateAccount("mallory", "", "", false); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("pleb", "", "", false); err != nil { + t.Fatal(err) + } + + // 匿名/无会话不能签发。 + if rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, "not-a-session"); rec.Code != http.StatusUnauthorized { + t.Fatalf("anonymous issue: %d", rec.Code) + } + + // 管理员签发:返回明文一次 + 元数据。 + rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, adminTok) + if rec.Code != http.StatusOK { + t.Fatalf("issue: %d %s", rec.Code, rec.Body.String()) + } + var issued struct { + ID string `json:"id"` + Key string `json:"key"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(issued.Key, "alk_") || issued.ID == "" { + t.Fatalf("issue payload: %+v", issued) + } + + // 列表:状态可见、无明文。 + rec = do(h, "GET", "/api/admin/users/mallory/login-keys", "", adminTok) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "alk_") { + t.Fatalf("list must not leak plaintext: %d %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"status":"unused"`) { + t.Fatalf("list should show unused status: %s", rec.Body.String()) + } + + // 用户兑换:只提交 Key,身份由服务端绑定决定。 + req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"owner":"mallory"`) || + !strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) { + t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String()) + } + userTok := cookieOf(rec) + + // 重复兑换失败。 + req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("replay redeem: %d", rec.Code) + } + + // 受限会话:业务与管理员入口均拒绝。 + if rec := do(h, "GET", "/api/projects", "", userTok); rec.Code != http.StatusForbidden { + t.Fatalf("business api: %d", rec.Code) + } + if rec := do(h, "POST", "/api/admin/users/pleb/login-keys", `{}`, userTok); rec.Code != http.StatusForbidden { + t.Fatalf("restricted session must not issue keys: %d", rec.Code) + } + + // 设密(无 current)→ reloginRequired → 新密码登录。 + rec = do(h, "POST", "/api/auth/password", `{"next":"mallory-final-9"}`, userTok) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) { + t.Fatalf("set password: %d %s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"mallory","password":"mallory-final-9"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { + t.Fatalf("login with new password: %d %s", rec.Code, rec.Body.String()) + } +} + +// 补发与撤销:旧 Key 作废、旧受限会话失效。 +func TestAdminLoginKeyReissueAndRevoke(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + adminTok := bootAdmin(t, h, st) + if err := st.CreateAccount("nia", "", "", false); err != nil { + t.Fatal(err) + } + issue := func() (string, string) { + rec := do(h, "POST", "/api/admin/users/nia/login-keys", `{}`, adminTok) + if rec.Code != http.StatusOK { + t.Fatalf("issue: %d %s", rec.Code, rec.Body.String()) + } + var out struct { + ID string `json:"id"` + Key string `json:"key"` + } + json.Unmarshal(rec.Body.Bytes(), &out) + return out.ID, out.Key + } + oldID, oldKey := issue() + _ = oldID + + // 兑换旧 Key 得受限会话;补发后该会话失效、旧 Key 不可再用。 + req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + oldSess := cookieOf(rec) + if oldSess == "" { + t.Fatal("redeem old key failed") + } + issue() + if rec := do(h, "GET", "/api/me", "", oldSess); rec.Code != http.StatusUnauthorized { + t.Fatalf("restricted session must die after reissue: %d", rec.Code) + } + req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code == http.StatusOK { + t.Fatal("consumed old key must not redeem again") + } + + // 撤销端点:未用 Key 撤销后不可兑换。 + newID, newKey := issue() + if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/"+newID, "", adminTok); rec.Code != http.StatusOK { + t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+newKey+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code == http.StatusOK { + t.Fatal("revoked key must not redeem") + } + // 不存在的编号 404。 + if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/lk_nope", "", adminTok); rec.Code != http.StatusNotFound { + t.Fatalf("unknown key revoke: %d", rec.Code) + } +} + +// must-change 首登:登录即建受限会话(sessionScope 回传),设密后重新登录为 full。 +func TestLoginMustChangeCreatesRestrictedSession(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + bootHash, err := store.HashPassword("temp-pass-00") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("oscar", "", bootHash, false); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"oscar","password":"temp-pass-00"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) { + t.Fatalf("must-change login must create restricted session: %d %s", rec.Code, rec.Body.String()) + } + tok := cookieOf(rec) + if rec := do(h, "GET", "/api/projects", "", tok); rec.Code != http.StatusForbidden { + t.Fatalf("business api before set: %d", rec.Code) + } + rec = do(h, "POST", "/api/auth/password", `{"next":"oscar-final-9"}`, tok) + if rec.Code != http.StatusOK { + t.Fatalf("set password: %d %s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"oscar","password":"oscar-final-9"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sessionScope":"full"`) { + t.Fatalf("full login after set: %d %s", rec.Code, rec.Body.String()) + } +} + +// 主动改密:必须带正确 current;成功保留当前会话。 +func TestActiveChangePasswordRequiresCurrent(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + _ = srv + if err := st.CreateAccount("petra", "", "", false); err != nil { + t.Fatal(err) + } + plain, _, err := st.IssueLoginKey("petra", store.LoginKeyPurposeActivate, "test", 0) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+plain+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + tok := cookieOf(rec) + rec = do(h, "POST", "/api/auth/password", `{"next":"petra-first-8"}`, tok) + if rec.Code != http.StatusOK { + t.Fatalf("initial set: %d %s", rec.Code, rec.Body.String()) + } + // 重新登录得 full 会话。 + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"petra","password":"petra-first-8"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + tok = cookieOf(rec) + // 缺 current / 错 current 拒绝。 + if rec := do(h, "POST", "/api/auth/password", `{"next":"petra-second-9"}`, tok); rec.Code != http.StatusUnauthorized { + t.Fatalf("active change without current: %d", rec.Code) + } + if rec := do(h, "POST", "/api/auth/password", `{"current":"wrong-current","next":"petra-second-9"}`, tok); rec.Code != http.StatusUnauthorized { + t.Fatalf("active change with wrong current: %d", rec.Code) + } + // 正确 current 成功且当前会话保留。 + rec = do(h, "POST", "/api/auth/password", `{"current":"petra-first-8","next":"petra-second-9"}`, tok) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"reloginRequired":true`) { + t.Fatalf("active change: %d %s", rec.Code, rec.Body.String()) + } + if rec := do(h, "GET", "/api/me", "", tok); rec.Code != http.StatusOK { + t.Fatalf("current session must survive active change: %d", rec.Code) + } +} 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_events.go b/internal/server/project_events.go new file mode 100644 index 0000000..7058cec --- /dev/null +++ b/internal/server/project_events.go @@ -0,0 +1,30 @@ +package server + +import ( + core "agent-system-core" +) + +// projectStreamMembership consults the registered resource authorizer using the +// physical project's trusted owner, never the event publisher or payload claims. +func (s *Server) projectStreamMembership(actor, project string) bool { + p, err := s.st.ProjectMetadata(project) + if err != nil { + return false + } + return s.authorizeSharedResource(actor, "stream", "project", project, p.Owner, project) +} + +func (s *Server) projectEventVisible(actor string, event core.StreamEvent) bool { + if s.projectLifecycle == nil { + return true + } + access, err := s.st.ResolveProjectEventAccess(event) + if err != nil || access.Owner == "" { + return false + } + if access.Owner == actor { + return true + } + project, ok := core.ProjectFromChannel(event.ConversationID) + return ok && access.Shared && s.projectStreamMembership(actor, project) +} diff --git a/internal/server/project_events_test.go b/internal/server/project_events_test.go new file mode 100644 index 0000000..0360ded --- /dev/null +++ b/internal/server/project_events_test.go @@ -0,0 +1,163 @@ +package server + +import ( + "agent-system-backend/internal/cardsvc" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func projectStreamReplay(t *testing.T, s *Server, key, project string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+project+"&since=0", nil) + r.Header.Set("Authorization", "Bearer "+key) + ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond) + defer cancel() + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r.WithContext(ctx)) + return w +} +func projectEventFixture(t *testing.T) (*Server, string, string, string, *atomic.Bool) { + t.Helper() + s, st := newAuthTestServer(t) + alice, _ := st.CreateAPIKey("alice", "test") + bob, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Project") + s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent) + s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent) + member := &atomic.Bool{} + member.Store(true) + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || (q.Actor == "bob" && member.Load())), nil + }) + return s, alice, bob, p.ID, member +} +func TestProjectEventsLegacyReplayIsPrivate(t *testing.T) { + s, alice, bob, p, _ := projectEventFixture(t) + s.DisableUploadIndexing = true + if w := lifecycleUpload(t, s, alice, p, "legacy-owner-private.txt", "PRIVATE"); w.Code != 200 { + t.Fatal(w.Code) + } + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }}, nil) + member := projectStreamReplay(t, s, bob, p) + if member.Code != 200 || bytes.Contains(member.Body.Bytes(), []byte("legacy-owner-private.txt")) || bytes.Contains(member.Body.Bytes(), []byte("builtin:txt")) { + t.Fatal("legacy private upload leaked to member") + } + owner := projectStreamReplay(t, s, alice, p) + if owner.Code != 200 || !bytes.Contains(owner.Body.Bytes(), []byte("legacy-owner-private.txt")) { + t.Fatal("owner lost private history") + } +} +func TestProjectEventsSharedProofAndPrivatePagination(t *testing.T) { + s, _, bob, p, _ := projectEventFixture(t) + s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil) + card, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "PRIVATE HISTORY"}, "") + if err != nil { + t.Fatal(err) + } + payload, _ := json.Marshal(map[string]any{"card": card, "owner": "bob", "shared": true, "producer": "plugin:rdm:structure"}) + private := make([]core.StreamEvent, 1001) + for i := range private { + private[i] = core.StreamEvent{Type: core.EvCardUpdated, Payload: payload, TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1} + } + if _, err := s.st.AppendEvents(core.ProjectChannelKey(p), private); err != nil { + t.Fatal(err) + } + if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "EXPLICIT SHARED"}, ""); err != nil { + t.Fatal(err) + } + w := projectStreamReplay(t, s, bob, p) + if w.Code != 200 || bytes.Contains(w.Body.Bytes(), []byte("PRIVATE HISTORY")) || !bytes.Contains(w.Body.Bytes(), []byte("EXPLICIT SHARED")) { + t.Fatal("private pagination/spoof proof failed") + } +} +func TestProjectEventsLivePrivacyAndRevocation(t *testing.T) { + s, _, bob, p, member := projectEventFixture(t) + s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil) + httpServer := httptest.NewServer(s.Handler()) + defer httpServer.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil) + req.Header.Set("Authorization", "Bearer "+bob) + response, err := httpServer.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LIVE PRIVATE"}, ""); err != nil { + t.Fatal(err) + } + if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "LIVE SHARED"}, ""); err != nil { + t.Fatal(err) + } + reader := bufio.NewReader(response.Body) + var before bytes.Buffer + for { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("live shared event did not arrive: %v", err) + } + before.WriteString(line) + if bytes.Contains([]byte(line), []byte("LIVE SHARED")) { + break + } + } + if bytes.Contains(before.Bytes(), []byte("LIVE PRIVATE")) { + t.Fatal("live private frame leaked before revocation") + } + member.Store(false) + if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "AFTER REMOVAL"}, ""); err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("revoked stream did not stop: %v", err) + } + if bytes.Contains(body, []byte("LIVE PRIVATE")) || bytes.Contains(body, []byte("AFTER REMOVAL")) { + t.Fatal("live private/revoked event leaked") + } +} +func TestUnregisteredProjectEventsRetainLegacyReads(t *testing.T) { + s, _, bob, p, _ := projectEventFixture(t) + if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LEGACY BASE"}, ""); err != nil { + t.Fatal(err) + } + w := projectStreamReplay(t, s, bob, p) + if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte("LEGACY BASE")) { + t.Fatal("unregistered project stream changed") + } +} + +func TestProjectEventsIdleStreamStopsAfterMembershipRemoval(t *testing.T) { + previous := sseAuthRecheck + sseAuthRecheck = 20 * time.Millisecond + defer func() { sseAuthRecheck = previous }() + s, _, bob, p, member := projectEventFixture(t) + s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil) + httpServer := httptest.NewServer(s.Handler()) + defer httpServer.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + request, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil) + request.Header.Set("Authorization", "Bearer "+bob) + response, err := httpServer.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + member.Store(false) + body, err := io.ReadAll(response.Body) + if err != nil || !bytes.Contains(body, []byte("project-access-revoked")) { + t.Fatal("idle project stream kept revoked member connected") + } +} diff --git a/internal/server/project_lifecycle.go b/internal/server/project_lifecycle.go new file mode 100644 index 0000000..ed2b29c --- /dev/null +++ b/internal/server/project_lifecycle.go @@ -0,0 +1,82 @@ +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) +} + +// publicProjectMetadata also masks provider-returned metadata, since providers +// may obtain raw owner-management rows before producing a shared public view. +func (s *Server) publicProjectMetadata(p core.Project) core.Project { + if s.projectLifecycle != nil { + p.Settings = s.set.MaskedProjectMetadata(p.Settings) + } + return p +} diff --git a/internal/server/project_lifecycle_test.go b/internal/server/project_lifecycle_test.go new file mode 100644 index 0000000..a58553e --- /dev/null +++ b/internal/server/project_lifecycle_test.go @@ -0,0 +1,504 @@ +package server + +import ( + "agent-system-backend/internal/cardsvc" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/surfacesvc" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "mime/multipart" + "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()) + } +} + +func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) { + s, st := newAuthTestServer(t) + alice, _ := st.CreateAPIKey("alice", "test") + bob, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Shared") + raw := map[string]any{"models.chat.apiKey": "fixture-key", "custom": map[string]any{"token": "fixture-token"}, "custom.api_key": "fixture-flat-token", "plugin.private.credential": "fixture-schema-token", "display": map[string]any{"caption": "Shared"}, "rdm.projectId": "canonical", "rdm.state": "ready"} + if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil { + t.Fatal(err) + } + s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugin.private.credential", Secret: true}}) + read := func() (core.Project, error) { return st.GetProject("alice", p.ID) } + s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) { + current, err := read() + return []core.Project{current}, err + }, Create: func(context.Context, extension.Host, string) (core.Project, error) { return read() }, Rename: func(context.Context, extension.Host, string, string) (core.Project, error) { return read() }}, nil) + for _, q := range []struct{ key, method, path, body string }{{bob, "GET", "/api/projects", ""}, {alice, "GET", "/api/projects", ""}, {alice, "POST", "/api/projects", `{"name":"New"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"settings":{"display":{"caption":"Updated"}}}`}} { + w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body) + if w.Code != 200 { + t.Fatalf("public metadata %s=%d", q.method, w.Code) + } + for _, secret := range []string{"fixture-key", "fixture-token", "fixture-flat-token", "fixture-schema-token"} { + if bytes.Contains(w.Body.Bytes(), []byte(secret)) { + t.Fatalf("%s %s leaked a fixture credential", q.method, q.path) + } + } + if !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) || !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.state":"ready"`)) { + t.Fatal("service metadata lost") + } + } + owned, err := read() + if err != nil { + t.Fatal(err) + } + if owned.Settings["models.chat.apiKey"] != "fixture-key" || owned.Settings["custom"].(map[string]any)["token"] != "fixture-token" { + t.Fatal("public metadata masking changed stored credentials") + } +} + +func TestGenericProjectPatchRejectsManagedSettingsAtomically(t *testing.T) { + for _, registered := range []bool{false, true} { + t.Run(fmt.Sprint(registered), func(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Original") + s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}}) + if registered { + s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) { + current, err := st.GetProject("alice", p.ID) + return []core.Project{current}, err + }, Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) { + if err := st.RenameProject("alice", id, name); err != nil { + return core.Project{}, err + } + return st.GetProject("alice", id) + }}, nil) + } + for _, body := range []string{`{"name":"Changed","settings":{"models":{"chat":{"apiKey":"fixture-key"}},"display":{"caption":"Forbidden partial write"}}}`, `{"settings":{"models.chat.apiKey":"fixture-key"}}`, `{"settings":{"models":"smash"}}`, `{"settings":{"plugins.private.managed":"override"}}`} { + w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body) + if w.Code != 400 { + t.Fatalf("managed patch=%d", w.Code) + } + current, err := st.GetProject("alice", p.ID) + if err != nil { + t.Fatal(err) + } + if current.Name != "Original" || len(current.Settings) != 0 { + t.Fatal("rejected managed patch applied a partial mutation") + } + } + w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"custom.caption":"Allowed"}}`) + if w.Code != 200 { + t.Fatalf("allowed patch=%d", w.Code) + } + current, err := st.GetProject("alice", p.ID) + if err != nil || current.Settings["custom.caption"] != "Allowed" { + t.Fatal("allowed metadata shape changed") + } + }) + } +} + +func TestGenericProjectPatchAcceptsNestedMaskedWriteback(t *testing.T) { + for _, registered := range []bool{false, true} { + t.Run(fmt.Sprint(registered), func(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Project") + if err := st.UpdateProjectSettings("alice", p.ID, map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-persisted-key"}}, "display": map[string]any{"caption": "Original"}}); err != nil { + t.Fatal(err) + } + if registered { + s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) { + current, err := st.GetProject("alice", p.ID) + return []core.Project{current}, err + }}, nil) + } + for _, tc := range []struct{ body, caption string }{ + {`{"settings":{"models":{"chat":{"apiKey":"••••••"}},"display":{"caption":"Nested updated"}}}`, "Nested updated"}, + {`{"settings":{"models.chat":{"apiKey":"••••••"},"display":{"caption":"Mixed updated"}}}`, "Mixed updated"}, + {`{"settings":{"models":{"chat.apiKey":"••••••"},"display":{"caption":"Mixed leaf updated"}}}`, "Mixed leaf updated"}, + } { + w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, tc.body) + if w.Code != 200 { + t.Fatalf("masked writeback rejected legal edit: %d", w.Code) + } + current, err := st.GetProject("alice", p.ID) + if err != nil { + t.Fatal(err) + } + if current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] != "fixture-persisted-key" || current.Settings["display"].(map[string]any)["caption"] != tc.caption { + t.Fatalf("masked writeback secret preserved=%v caption=%q expected=%q", current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] == "fixture-persisted-key", current.Settings["display"].(map[string]any)["caption"], tc.caption) + } + } + for _, body := range []string{`{"settings":{"models":{}}}`, `{"settings":{"models":{"chat":{}}}}`, `{"settings":{"models.chat":{}}}`, `{"settings":{"models":{"chat":{"apiKey":"••••••","model":"Overwrite"}}}}`} { + if w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body); w.Code != 400 { + t.Fatalf("explicit empty/real managed write accepted: %d", w.Code) + } + } + }) + } +} + +func lifecycleUpload(t *testing.T, s *Server, key, projectID, name, content string) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + file, err := writer.CreateFormFile("file", name) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte(content)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/uploads", &body) + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + return rec +} + +func TestProjectLifecycleMemberUploadStaysPrivate(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") + ready := true + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) { + return ready && id == p.ID && (actor == "alice" || actor == "bob"), nil + }}, nil) + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + if q.ResourceType == "artifact" { + return q.Actor == q.Owner, nil + } + return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil + }) + s.DisableUploadIndexing = true + w := lifecycleUpload(t, s, bob, p.ID, "member-private.txt", "QA_MEMBER_PRIVATE_ORIGINAL") + if w.Code != 200 { + t.Fatalf("member upload=%d", w.Code) + } + var info core.UploadInfo + if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil { + t.Fatal(err) + } + artifact, err := st.GetArtifact("bob", info.ArtifactID) + if err != nil || artifact.ProjectID != p.ID || artifact.Owner != "bob" { + t.Fatalf("upload ownership/target incorrect: %+v %v", artifact, err) + } + if w := lifecycleRequest(t, s, bob, "GET", "/api/artifacts/"+info.ArtifactID+"/content", ""); w.Code != 200 || w.Body.String() != "QA_MEMBER_PRIVATE_ORIGINAL" { + t.Fatalf("owner original read=%d", w.Code) + } + for _, key := range []string{alice, mallory} { + for _, suffix := range []string{"", "/content"} { + if w := lifecycleRequest(t, s, key, "GET", "/api/artifacts/"+info.ArtifactID+suffix, ""); w.Code != 404 { + t.Fatalf("other actor read private original=%d", w.Code) + } + } + } + if w := lifecycleUpload(t, s, mallory, p.ID, "forbidden.txt", "DENIED"); w.Code != 404 { + t.Fatalf("nonmember upload=%d", w.Code) + } + ready = false + if w := lifecycleUpload(t, s, bob, p.ID, "revoked.txt", "DENIED"); w.Code != 404 { + t.Fatalf("revoked upload=%d", w.Code) + } + artifacts, err := st.ListProjectArtifactsAll(p.ID) + if err != nil || len(artifacts) != 1 { + t.Fatalf("denied upload wrote artifacts: count=%d error=%v", len(artifacts), err) + } +} + +func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) { + s, st := newAuthTestServer(t) + alice, _ := st.CreateAPIKey("alice", "test") + bob, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Shared") + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) { + return id == p.ID && (actor == "alice" || actor == "bob"), nil + }}, nil) + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil + }) + s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent) + s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent) + s.DisableUploadIndexing = true + if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p.ID, Title: "Shared structure", Kind: core.CardKindProgress}, ""); err != nil { + t.Fatal(err) + } + // The owner path already admitted upload before the member authorization fix. + if w := lifecycleUpload(t, s, alice, p.ID, "owner-private-filename.txt", "PRIVATE ORIGINAL"); w.Code != 200 { + t.Fatalf("owner upload=%d", w.Code) + } + if w := lifecycleUpload(t, s, bob, p.ID, "member-private-filename.txt", "MEMBER PRIVATE ORIGINAL"); w.Code != 200 { + t.Fatalf("member upload=%d", w.Code) + } + for _, owner := range []string{"alice", "bob"} { + cards, err := st.ListProjectCards(owner, p.ID) + if err != nil { + t.Fatal(err) + } + expected := 0 + if owner == "alice" { + expected = 1 + } + if len(cards) != expected { + t.Fatal("private upload added project card history") + } + for _, card := range cards { + if card.Title != "Shared structure" { + t.Fatal("private filename/parser card entered project history") + } + } + } + events, err := st.EventsAfter(core.ProjectChannelKey(p.ID), 0, 100) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatal("private upload added project events") + } + for _, event := range events { + if bytes.Contains(event.Payload, []byte("owner-private-filename.txt")) || bytes.Contains(event.Payload, []byte("member-private-filename.txt")) { + t.Fatal("private upload metadata entered persisted project stream") + } + } + request := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+p.ID, nil) + request.Header.Set("Authorization", "Bearer "+bob) + ctx, cancel := context.WithTimeout(request.Context(), 20*time.Millisecond) + defer cancel() + request = request.WithContext(ctx) + response := httptest.NewRecorder() + s.Handler().ServeHTTP(response, request) + if response.Code != 200 || !bytes.Contains(response.Body.Bytes(), []byte("Shared structure")) { + t.Fatalf("member structural stream did not replay: %d", response.Code) + } + if bytes.Contains(response.Body.Bytes(), []byte("owner-private-filename.txt")) || bytes.Contains(response.Body.Bytes(), []byte("member-private-filename.txt")) { + t.Fatal("another member's SSE exposed private upload metadata") + } +} + +func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Private") + s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent) + s.DisableUploadIndexing = true + if w := lifecycleUpload(t, s, key, p.ID, "legacy-upload.txt", "Original"); w.Code != 200 { + t.Fatalf("unregistered owner upload=%d", w.Code) + } + cards, err := st.ListProjectCards("alice", p.ID) + if err != nil || len(cards) != 1 || cards[0].Title != "legacy-upload.txt" { + t.Fatalf("unregistered ingestion card changed: count=%d error=%v", len(cards), err) + } + if w := lifecycleRequest(t, s, key, "POST", "/api/projects/missing/uploads", "not multipart"); w.Code != 400 { + t.Fatalf("legacy missing-file validation changed: %d", w.Code) + } +} + +func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Shared") + ready := true + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { + allowed := ready + ready = false + return allowed, nil + }}, nil) + if w := lifecycleUpload(t, s, key, p.ID, "revoked-during-upload.txt", "PRIVATE"); w.Code != 404 { + t.Fatalf("upload persisted after authorization changed: %d", w.Code) + } + artifacts, err := st.ListProjectArtifactsAll(p.ID) + if err != nil || len(artifacts) != 0 { + t.Fatal("revoked upload created an artifact") + } +} 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 3b70bb6..d90b3c3 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 时上传端点不再自动写检索索引(原件与 @@ -124,7 +127,7 @@ func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action } // authorizeSharedResource EXT-8b 资源级读判定(U9):V2 注册时按六要素咨询 -//(Actor=认证会话、Action/ResourceType/ResourceID 由调用点受信构造、 +// (Actor=认证会话、Action/ResourceType/ResourceID 由调用点受信构造、 // Owner/ProjectID 从受信存储元数据解析;出错/字段缺失一律 false,fail-closed // 且不泄露存在性);V2 未注册回落 authorizeSharedProject(现行兼容)。 func (s *Server) authorizeSharedResource(actor, action, resourceType string, resourceID, owner, projectID core.ID) bool { @@ -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 } @@ -284,6 +287,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/admin/users", s.listAdminUsers) mux.HandleFunc("POST /api/admin/users", s.createAdminUser) mux.HandleFunc("POST /api/admin/users/{owner}/password", s.resetAdminUserPassword) + mux.HandleFunc("POST /api/admin/users/{owner}/login-keys", s.issueAdminLoginKey) + mux.HandleFunc("GET /api/admin/users/{owner}/login-keys", s.listAdminLoginKeys) + mux.HandleFunc("DELETE /api/admin/users/{owner}/login-keys/{id}", s.revokeAdminLoginKey) // ---- 项目 / 会话 ---- mux.HandleFunc("GET /api/projects", s.listProjects) @@ -349,6 +355,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/settings", s.getSettings) mux.HandleFunc("PUT /api/settings/user", s.putSettingsUser) mux.HandleFunc("PUT /api/settings/project/{id}", s.putSettingsProject) + mux.HandleFunc("GET /api/settings/system", s.getSettingsSystem) + mux.HandleFunc("PUT /api/settings/system", s.putSettingsSystem) mux.HandleFunc("GET /api/tools", s.listTools) mux.HandleFunc("GET /api/url-preview", s.urlPreview) mux.HandleFunc("POST /api/tools", s.createUserTool) @@ -415,9 +423,15 @@ func (s *Server) Handler() http.Handler { const sseRetryMillis = 1000 +// sseAuthRecheck:会话凭证的 SSE 长连接复验周期。密码写入(恢复/改密/重置) +// 会删除全部会话并 bump auth_version;已建立的流最多在此窗口内继续收事件, +// 心跳点复验失效即断流(EventSource 重连时由中间件拒绝)。测试可缩短。 +var sseAuthRecheck = 15 * time.Second + func (s *Server) events(w http.ResponseWriter, r *http.Request) { conversationID := r.URL.Query().Get("conversationId") isProject := false + streamProject := "" // 项目流(D50):scope=project&projectId= 订阅项目频道——伪会话键 // "project:" 复用会话流的持久化/seq/重放机制;租户边界按项目校验。 if r.URL.Query().Get("scope") == "project" { @@ -435,6 +449,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { } conversationID = string(core.ProjectChannelKey(core.ID(pid))) isProject = true + streamProject = pid } else if conversationID == "" { http.Error(w, "conversationId required", http.StatusBadRequest) return @@ -466,45 +481,96 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { since, _ = strconv.ParseInt(q, 10, 64) } + // 长连接凭证复验(login 恢复合同 §6.7):会话凭证查会话行(密码写入即删除); + // API key 凭证查撤销位(RevokeAllAPIKeys/失陷撤销后断流)。两种凭证都在 + // 复验周期内核验属主未变。 + sessionTok, keyCred := "", "" + switch AuthVia(r) { + case AuthViaSession: + sessionTok = sessionToken(r) + case AuthViaKey: + keyCred = bearerKey(r) + if keyCred == "" { + keyCred = r.URL.Query().Get("key") + } + } + wantOwner := s.owner(r) + stillAuthed := func() bool { + if sessionTok != "" { + o, _, ok := s.st.SessionInfo(sessionTok) + return ok && o == wantOwner + } + if keyCred != "" { + o, ok := s.st.AuthOwner(keyCred) + return ok && o == wantOwner + } + return true + } + // 先订阅再重放,避免重放与订阅间隙丢事件;按 seq 去重。 live, stop := s.Subscribe(conversationID) defer stop() + lastVisible := since send := func(e core.StreamEvent) bool { + if e.Seq <= lastVisible { + return true + } + if isProject && !s.projectEventVisible(wantOwner, e) { + return r.Context().Err() == nil + } buf, _ := json.Marshal(e) fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", e.Seq, e.Type, buf) flusher.Flush() + lastVisible = e.Seq return r.Context().Err() == nil } - lastSent := since + // Seen progresses even when a private frame is filtered. Visible only tracks + // frames delivered to this actor, preserving authoritative seq and resumption. + lastSeen := since for { - replay, err := s.st.EventsAfter(conversationID, lastSent, 1000) + replay, err := s.st.EventsAfter(conversationID, lastSeen, 1000) if err != nil || len(replay) == 0 { break } for _, e := range replay { - if e.Seq <= lastSent { + if e.Seq <= lastSeen { continue } if !send(e) { stop() return } - lastSent = e.Seq + lastSeen = e.Seq } if len(replay) < 1000 { break } } // 心跳 + 在线转发。通道关闭表示缓冲溢出,结束响应让 EventSource 带 Last-Event-ID 重连。 + // 会话凭证在心跳点复验:失效(改密/恢复/重置已删会话)即终止响应。 heartbeat := time.NewTicker(15 * time.Second) defer heartbeat.Stop() + recheck := time.NewTicker(sseAuthRecheck) + defer recheck.Stop() for { select { case <-r.Context().Done(): stop() return + case <-recheck.C: + if isProject && s.projectLifecycle != nil && !s.projectStreamMembership(wantOwner, streamProject) { + fmt.Fprint(w, ": project-access-revoked\n\n") + flusher.Flush() + return + } + if !stillAuthed() { + fmt.Fprint(w, ": session-expired\n\n") + flusher.Flush() + stop() + return + } case <-heartbeat.C: fmt.Fprint(w, ": ping\n\n") flusher.Flush() @@ -513,14 +579,19 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { stop() return } - if e.ConversationID != conversationID || e.Seq <= lastSent { + if e.ConversationID != conversationID || e.Seq <= lastSeen { continue } + if isProject && s.projectLifecycle != nil && !s.projectStreamMembership(wantOwner, streamProject) { + fmt.Fprint(w, ": project-access-revoked\n\n") + flusher.Flush() + return + } if !send(e) { stop() return } - lastSent = e.Seq + lastSeen = e.Seq } } } @@ -566,6 +637,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: s.publicProjectMetadata(p), Shared: p.Owner != owner}) + } + writeJSON(w, http.StatusOK, out) + return + } ps, err := s.st.ListProjects(owner) if err != nil { writeStoreErr(w, err) @@ -602,6 +690,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, s.publicProjectMetadata(p), err) + return + } p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name)) writeJSONOr(w, p, err) } @@ -616,6 +713,58 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + if req.Settings != nil { + values, err := s.set.PrepareProjectMetadataWrite(req.Settings) + if err != nil { + s.writeSettingsMerge(w, func() error { return err }) + return + } + req.Settings = values + } + 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, s.publicProjectMetadata(p), err) + return + } if req.Name != nil { if err := s.st.RenameProject(s.owner(r), id, *req.Name); err != nil { writeStoreErr(w, err) @@ -633,12 +782,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 } @@ -682,13 +848,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 卡片。 @@ -767,6 +928,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"` @@ -922,7 +1087,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) @@ -1027,10 +1192,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, @@ -1109,6 +1285,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):本地无执行句柄——写持久命令,执行实例 @@ -1175,6 +1367,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"` } @@ -1221,6 +1435,17 @@ func (s *Server) cardDismiss(w http.ResponseWriter, r *http.Request) { func (s *Server) upload(w http.ResponseWriter, r *http.Request) { projectID := r.PathValue("id") + owner := s.owner(r) + if s.projectLifecycle != nil { + if err := s.projectWork(r.Context(), owner, projectID); err != nil { + writeProjectErr(w, err) + return + } + if _, err := s.st.ProjectMetadata(projectID); err != nil { + writeStoreErr(w, err) + return + } + } r.ParseMultipartForm(64 << 20) file, header, err := r.FormFile("file") if err != nil { @@ -1233,10 +1458,18 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - owner := s.owner(r) - if _, err := s.st.GetProject(owner, projectID); err != nil { - writeStoreErr(w, err) - return + if s.projectLifecycle == nil { + if _, err := s.st.GetProject(owner, projectID); err != nil { + writeStoreErr(w, err) + return + } + } + if s.projectLifecycle != nil { + // Request-body reading can outlast the membership/ready decision. + if err := s.projectWork(r.Context(), owner, projectID); err != nil { + writeProjectErr(w, err) + return + } } mime := header.Header.Get("Content-Type") art := core.Artifact{ProjectID: projectID, Owner: owner, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime} @@ -1251,11 +1484,14 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) { Status: core.CardActive, Tone: "info", Body: []core.UINode{{Type: core.UIProgress, Progress: &core.UIProgressSpec{Label: "解析中", Value: 0.3}}}, } - if s.Cards != nil { + // This endpoint has no private conversation stream. Registered uploads keep + // original filename/parser metadata out of shared project cards and events. + showIngestCard := s.Cards != nil && s.projectLifecycle == nil + if showIngestCard { ingestCard, _ = s.Cards.Create(owner, ingestCard, "") } doc := ingest.Parse(art.Name, mime, content) - if s.Cards != nil { + if showIngestCard { ingestCard.Status = core.CardDone ingestCard.Tone = "ok" body := []core.UINode{ @@ -1299,20 +1535,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 自有未命中 → 取元数据问适配器, @@ -1439,7 +1687,7 @@ func (s *Server) putSettingsUser(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - writeOKOr(w, s.set.MergeUser(s.owner(r), values)) + s.writeSettingsMerge(w, func() error { return s.set.MergeUser(s.owner(r), values) }) } func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) { @@ -1448,7 +1696,66 @@ func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - writeOKOr(w, s.set.MergeProject(s.owner(r), r.PathValue("id"), values)) + s.writeSettingsMerge(w, func() error { + return s.set.MergeProject(s.owner(r), r.PathValue("id"), values) + }) +} + +// writeSettingsMerge 统一处理设置写入结果:触碰系统受管项返回 400 并列出路径, +// 其余错误按 writeOKOr 既有语义处理。 +func (s *Server) writeSettingsMerge(w http.ResponseWriter, merge func() error) { + err := merge() + if err == nil { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + var mp *settings.ErrManagedPath + if errors.As(err, &mp) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeStoreErr(w, err) +} + +// ---- 系统设置(管理员;P2 受管项的唯一写入面)---- + +// requireSystemAdmin 系统设置面的准入:认证档要求基座管理员;无认证单机档 +// (Auth 未启用)本机即管理员——该档全部 API 本就无认证,锁系统层只会破坏 +// 嵌入式部署的配置播种,不增加安全边界。 +func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { + if s.Auth == nil { + return s.owner(r), true + } + return s.requireAdmin(w, r) +} + +func (s *Server) getSettingsSystem(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSystemAdmin(w, r); !ok { + return + } + schema := append(settings.Schema(), s.plugins.SettingsSchema()...) + sys, err := s.set.SystemScope() + if err != nil { + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "schema": schema, + "defaults": settings.Masked(settings.SystemDefaults(), schema), + "system": settings.Masked(sys, schema), + }) +} + +func (s *Server) putSettingsSystem(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSystemAdmin(w, r); !ok { + return + } + var values map[string]any + if err := json.NewDecoder(r.Body).Decode(&values); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeOKOr(w, s.set.MergeSystem(values)) } // ---- 工具(用户声明式 HTTP 工具注册)---- @@ -1742,6 +2049,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 { @@ -1810,6 +2160,10 @@ func writeStoreErr(w http.ResponseWriter, err error) { http.Error(w, err.Error(), http.StatusConflict) return } + if errors.Is(err, store.ErrBadCredentials) { + http.Error(w, "current password incorrect", http.StatusUnauthorized) + return + } if errors.Is(err, runmgr.ErrConversationBusy) { http.Error(w, err.Error(), http.StatusConflict) return diff --git a/internal/server/sse_close_test.go b/internal/server/sse_close_test.go new file mode 100644 index 0000000..706604d --- /dev/null +++ b/internal/server/sse_close_test.go @@ -0,0 +1,206 @@ +package server + +// SSE 长连接会话复验(登录恢复合同 §6.7):密码写入删除会话行后, +// 已建立的流在复验周期内终止,不再继续接收私有事件。 + +import ( + "bufio" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "agent-system-backend/internal/store" +) + +// 打开真实流式服务,等一帧事件后作废会话,断言流在复验周期内关闭。 +func TestSSESessionInvalidationClosesStream(t *testing.T) { + old := sseAuthRecheck + sseAuthRecheck = 300 * time.Millisecond + t.Cleanup(func() { sseAuthRecheck = old }) + + srv, st := newAuthTestServer(t) + h := srv.Handler() + + hash, err := store.HashPassword("sse-old-pass-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("tia", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("tia"); err != nil { + t.Fatal(err) + } + tok, err := st.CreateSession("tia") + if err != nil { + t.Fatal(err) + } + proj, err := st.CreateProject("tia", "sse-proj") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("tia", proj.ID, "", "") + if err != nil { + t.Fatal(err) + } + + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + req, err := http.NewRequestWithContext(ctx, "GET", ts.URL+"/api/events?conversationId="+conv.ID, nil) + if err != nil { + t.Fatal(err) + } + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) + + type result struct { + lastLine string + closed bool + err error + } + resCh := make(chan result, 1) + go func() { + resp, err := ts.Client().Do(req) + if err != nil { + resCh <- result{err: err} + return + } + defer resp.Body.Close() + sc := bufio.NewScanner(resp.Body) + last := "" + for sc.Scan() { + if line := sc.Text(); line != "" { + last = line + } + } + resCh <- result{lastLine: last, closed: true, err: sc.Err()} + }() + + // 等重连提示帧出现,确认流已建立。 + deadline := time.Now().Add(3 * time.Second) + established := false + for !established && time.Now().Before(deadline) { + if len(resCh) > 0 { + break + } + // 无阻塞探测:向会话写事件驱动首帧即可省略;此处直接进入作废。 + established = true + } + + // 作废会话(管理员重置语义:删除全部会话 + bump 版本)。 + newHash, err := store.HashPassword("sse-new-pass-1") + if err != nil { + t.Fatal(err) + } + if err := st.AdminResetUserPassword("tia", newHash, "test", "test"); err != nil { + t.Fatal(err) + } + + select { + case r := <-resCh: + if r.err != nil { + t.Fatalf("stream read error: %v", r.err) + } + if !strings.Contains(r.lastLine, "session-expired") { + t.Fatalf("stream should end with session-expired marker, got %q", r.lastLine) + } + case <-time.After(3 * time.Second): + t.Fatal("stream not closed within recheck window after session invalidation") + } +} + +// API key 凭证的流:撤销后同样在复验周期内断流(合同 §6.7 补齐)。 +func TestSSEAPIKeyRevocationClosesStream(t *testing.T) { + old := sseAuthRecheck + sseAuthRecheck = 300 * time.Millisecond + t.Cleanup(func() { sseAuthRecheck = old }) + + srv, st := newAuthTestServer(t) + h := srv.Handler() + + if err := st.EnsureUser("kia", ""); err != nil { + t.Fatal(err) + } + key, err := st.CreateAPIKey("kia", "sse") + if err != nil { + t.Fatal(err) + } + proj, err := st.CreateProject("kia", "sse-proj") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("kia", proj.ID, "", "") + if err != nil { + t.Fatal(err) + } + + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + req, err := http.NewRequestWithContext(ctx, "GET", ts.URL+"/api/events?conversationId="+conv.ID+"&key="+key, nil) + if err != nil { + t.Fatal(err) + } + + resCh := make(chan string, 1) + go func() { + resp, err := ts.Client().Do(req) + if err != nil { + resCh <- "err:" + err.Error() + return + } + defer resp.Body.Close() + sc := bufio.NewScanner(resp.Body) + last := "" + for sc.Scan() { + if line := sc.Text(); line != "" { + last = line + } + } + resCh <- last + }() + + // 等首帧(retry 行)出现后撤销该 Key。 + time.Sleep(300 * time.Millisecond) + if _, err := st.RevokeAPIKeyByName("kia", "sse"); err != nil { + t.Fatal(err) + } + + select { + case got := <-resCh: + if !strings.Contains(got, "session-expired") { + t.Fatalf("stream should end with session-expired marker, got %q", got) + } + case <-time.After(3 * time.Second): + t.Fatal("stream not closed within recheck window after key revocation") + } +} + +// 兑换入口限流:同源连续失败达上限后返回 429(跨实例共担由 store 测试覆盖)。 +func TestRedeemRateLimited(t *testing.T) { + srv, _ := newAuthTestServer(t) + h := srv.Handler() + for i := 0; i < redeemMaxFails; i++ { + req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"alk_not_a_real_key_at_all"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: want 401, got %d", i, rec.Code) + } + } + req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"alk_other"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("want 429 after %d failures, got %d", redeemMaxFails, rec.Code) + } +} diff --git a/internal/settings/project_metadata.go b/internal/settings/project_metadata.go new file mode 100644 index 0000000..2eb0035 --- /dev/null +++ b/internal/settings/project_metadata.go @@ -0,0 +1,108 @@ +package settings + +import ( + core "agent-system-core" + "strings" +) + +// MaskedProjectMetadata returns a detached credential-safe view without changing +// stored values or dotted service/configuration key shapes. The builtin and +// plugin secret schema plus the existing secret-leaf fallback cover legacy data. +func (s *Service) MaskedProjectMetadata(values map[string]any) map[string]any { + if values == nil { + return map[string]any{} + } + schema := Schema() + if s != nil { + schema = append(schema, s.extraSchema...) + } + secrets := map[string]bool{} + for _, d := range schema { + if d.Type == core.SettingSecret || d.Secret { + secrets[d.Key] = true + } + } + return maskedProjectValue(values, "", secrets).(map[string]any) +} + +func maskedProjectValue(value any, path string, secrets map[string]bool) any { + leaf := path + if i := strings.LastIndexByte(leaf, '.'); i >= 0 { + leaf = leaf[i+1:] + } + if secrets[path] || isSecretKey(leaf) { + if value == nil { + return nil + } + if text, ok := value.(string); ok && text == "" { + return text + } + return MaskPlaceholder + } + switch v := value.(type) { + case map[string]any: + if v == nil { + return v + } + out := make(map[string]any, len(v)) + for key, item := range v { + child := key + if path != "" { + child = path + "." + key + } + out[key] = maskedProjectValue(item, child, secrets) + } + return out + case []any: + if v == nil { + return v + } + out := make([]any, len(v)) + for i, item := range v { + out[i] = maskedProjectValue(item, path, secrets) + } + return out + default: + return value + } +} + +// PrepareProjectMetadataWrite applies the same managed-path and mask-placeholder +// rules as project settings, while preserving the metadata store's key shapes. +// Project metadata lives in projects.settings, not the settings scope table. +func (s *Service) PrepareProjectMetadataWrite(values map[string]any) (map[string]any, error) { + incoming := dropProjectMaskPlaceholders(values) + if err := s.checkManagedWrite(normalizeKeys(incoming)); err != nil { + return nil, err + } + return incoming, nil +} + +// dropProjectMaskPlaceholders prunes a parent only when a previously nonempty +// object became empty through mask removal. Explicit empty objects still express +// an overwrite and must reach the managed-path guard unchanged. +func dropProjectMaskPlaceholders(values map[string]any) map[string]any { + out := make(map[string]any, len(values)) + for key, value := range values { + if child, ok := value.(map[string]any); ok { + prepared := dropProjectMaskPlaceholders(child) + if len(child) > 0 && len(prepared) == 0 { + continue + } + out[key] = prepared + continue + } + if text, ok := value.(string); ok && text == MaskPlaceholder { + continue + } + out[key] = value + } + return out +} + +func (s *Service) checkManagedWrite(incoming map[string]any) error { + if hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 { + return &ErrManagedPath{Paths: hits} + } + return nil +} diff --git a/internal/settings/project_metadata_test.go b/internal/settings/project_metadata_test.go new file mode 100644 index 0000000..443eb83 --- /dev/null +++ b/internal/settings/project_metadata_test.go @@ -0,0 +1,86 @@ +package settings + +import ( + core "agent-system-core" + "bytes" + "encoding/json" + "testing" +) + +func TestMaskedProjectMetadataCopiesSecretViews(t *testing.T) { + s := NewService(nil) + s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}}) + raw := map[string]any{"models.chat.apiKey": "fixture-known", "legacy.token": "fixture-flat", "plugins.private": map[string]any{"credential": "fixture-schema"}, "nested": []any{map[string]any{"api_key": "fixture-array"}}, "opaque.secret": map[string]any{"value": "fixture-opaque"}, "custom.token": []any{"fixture-list"}, "custom.caption": "Allowed", "rdm.projectId": "canonical", "rdm.state": "ready"} + before, _ := json.Marshal(raw) + view := s.MaskedProjectMetadata(raw) + after, _ := json.Marshal(raw) + if string(before) != string(after) { + t.Fatal("read-only metadata view mutated its source") + } + encoded, _ := json.Marshal(view) + for _, secret := range []string{"fixture-known", "fixture-flat", "fixture-schema", "fixture-array", "fixture-opaque", "fixture-list"} { + if bytes.Contains(encoded, []byte(secret)) { + t.Fatal("metadata view exposed a fixture secret") + } + } + if view["custom.caption"] != "Allowed" || view["rdm.projectId"] != "canonical" || view["rdm.state"] != "ready" { + t.Fatal("nonsecret metadata shape/value changed") + } + if view["legacy.token"] != MaskPlaceholder || view["opaque.secret"] != MaskPlaceholder || view["custom.token"] != MaskPlaceholder { + t.Fatal("secret fallback did not mask complete dotted values") + } +} + +func TestPrepareProjectMetadataWriteUsesManagedGuard(t *testing.T) { + s := NewService(nil) + s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}}) + for _, raw := range []map[string]any{{"models.chat.apiKey": "fixture"}, {"models": map[string]any{"chat": map[string]any{"apiKey": "fixture"}}}, {"models": "smash"}, {"plugins.private.managed": "override"}} { + if _, err := s.PrepareProjectMetadataWrite(raw); err == nil { + t.Fatal("managed metadata write accepted") + } + } + raw := map[string]any{"custom.caption": "Allowed", "custom.token": MaskPlaceholder, "models.chat.apiKey": MaskPlaceholder} + view, err := s.PrepareProjectMetadataWrite(raw) + if err != nil { + t.Fatal(err) + } + if len(view) != 1 || view["custom.caption"] != "Allowed" { + t.Fatal("metadata writes must preserve allowed dotted keys and ignore mask placeholders") + } + if raw["custom.token"] != MaskPlaceholder { + t.Fatal("write preparation mutated its input") + } +} + +func TestPrepareProjectMetadataWritePrunesOnlyMaskedParents(t *testing.T) { + s := NewService(nil) + for _, raw := range []map[string]any{ + {"models": map[string]any{"chat": map[string]any{"apiKey": MaskPlaceholder}}, "display": map[string]any{"caption": "Updated"}}, + {"models.chat": map[string]any{"apiKey": MaskPlaceholder}, "display.caption": "Updated"}, + {"models": map[string]any{"chat.apiKey": MaskPlaceholder}, "display.caption": "Updated"}, + } { + before, _ := json.Marshal(raw) + got, err := s.PrepareProjectMetadataWrite(raw) + if err != nil { + t.Fatalf("unchanged masked credential rejected legal metadata edit: %v", err) + } + if len(got) != 1 { + t.Fatal("mask-only parent remained in prepared metadata") + } + after, _ := json.Marshal(raw) + if !bytes.Equal(before, after) { + t.Fatal("mask preparation changed its input") + } + } + for _, raw := range []map[string]any{ + {"models": map[string]any{}}, + {"models": map[string]any{"chat": map[string]any{}}}, + {"models.chat": map[string]any{}}, + {"models": map[string]any{"chat": map[string]any{"apiKey": MaskPlaceholder, "model": "Real overwrite"}}}, + {"models.chat": map[string]any{"apiKey": MaskPlaceholder}, "models": map[string]any{"chat": map[string]any{}}}, + } { + if _, err := s.PrepareProjectMetadataWrite(raw); err == nil { + t.Fatal("explicit empty or real managed overwrite accepted") + } + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 8c839f3..c07074c 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -10,12 +10,24 @@ import ( core "agent-system-core" ) -// Scope 层级键:user 与 project: 落库;conversation 经会话画像选择实现。 +// Scope 层级键:user 与 project: 落库;system 是管理员全局层(owner 固定 +// OwnerSystemRow);conversation 经会话画像选择实现。 const ( ScopeUser = "user" ScopeProjectPrefix = "project:" + ScopeSystem = "system" + // OwnerSystemRow 系统层落库行的 owner。空串不是任何账号(账号名非空), + // 租户 API 无法读写该行。 + OwnerSystemRow = "" ) +// ErrManagedPath 表示写入负载触碰系统受管设置项;user/project 层拒绝整笔写入。 +type ErrManagedPath struct{ Paths []string } + +func (e *ErrManagedPath) Error() string { + return "系统受管设置项仅管理员可配置: " + strings.Join(e.Paths, ", ") +} + // SystemDefaults 是系统级默认(代码内置,settings.system.json 语义)。 func SystemDefaults() map[string]any { return map[string]any{ @@ -77,30 +89,30 @@ func SystemDefaults() map[string]any { // Schema 返回设置描述清单(设置页渲染依据;插件设置项由 pluginhost 追加)。 func Schema() []core.SettingDesc { return []core.SettingDesc{ - {Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"}, - {Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot}, - {Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun}, - {Key: "models.chat.fallbacks", Group: "models", Label: "主模型回退链", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "逗号分隔的模型名。只在尚未输出时,前一个模型返回 5xx 才改用下一个。空表示不回退"}, - {Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"}, - {Key: "models.chat.contextWindow", Group: "models", Label: "主模型上下文窗口(token)", Type: core.SettingNumber, Default: 128000, Effect: core.EffectNewRun, Description: "模型真实窗口的 token 数。运行内清理旧工具结果的阈值由此换算(此前按 32000 字符硬编码,长材料任务第一请求即触发清理且被清内容找不回)"}, - {Key: "models.chat.outputReserve", Group: "models", Label: "窗口输出预留(token)", Type: core.SettingNumber, Default: 16384, Effect: core.EffectNewRun, Description: "清理阈值里为模型输出预留的 token 数"}, - {Key: "models.chat.contextBuffer", Group: "models", Label: "窗口余量(token)", Type: core.SettingNumber, Default: 4096, Effect: core.EffectNewRun, Description: "避免贴着上限才清理的余量"}, - {Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "标题生成/澄清等轻任务;空则回落主模型"}, - {Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot}, - {Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun}, - {Key: "models.lightweight.maxOutputTokens", Group: "models", Label: "轻量模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"}, - {Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "RAG/记忆检索用(向量管线远期启用)"}, - {Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot}, - {Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot}, - {Key: "models.rerank.baseUrl", Group: "models", Label: "Reranker Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot}, - {Key: "models.rerank.apiKey", Group: "models", Label: "Reranker API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot}, - {Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot}, - {Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "不调用真实模型,按脚本回放事件流"}, + {Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"}, + {Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem}, + {Key: "models.chat.fallbacks", Group: "models", Label: "主模型回退链", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "逗号分隔的模型名。只在尚未输出时,前一个模型返回 5xx 才改用下一个。空表示不回退"}, + {Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"}, + {Key: "models.chat.contextWindow", Group: "models", Label: "主模型上下文窗口(token)", Type: core.SettingNumber, Default: 128000, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "模型真实窗口的 token 数。运行内清理旧工具结果的阈值由此换算(此前按 32000 字符硬编码,长材料任务第一请求即触发清理且被清内容找不回)"}, + {Key: "models.chat.outputReserve", Group: "models", Label: "窗口输出预留(token)", Type: core.SettingNumber, Default: 16384, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "清理阈值里为模型输出预留的 token 数"}, + {Key: "models.chat.contextBuffer", Group: "models", Label: "窗口余量(token)", Type: core.SettingNumber, Default: 4096, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "避免贴着上限才清理的余量"}, + {Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "标题生成/澄清等轻任务;空则回落主模型"}, + {Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem}, + {Key: "models.lightweight.maxOutputTokens", Group: "models", Label: "轻量模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"}, + {Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "RAG/记忆检索用(向量管线远期启用)"}, + {Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "models.rerank.baseUrl", Group: "models", Label: "Reranker Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "models.rerank.apiKey", Group: "models", Label: "Reranker API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem}, + {Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "不调用真实模型,按脚本回放事件流"}, {Key: "runtime.squad", Group: "runtime", Label: "小队编排(Agent-Squad)", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "启用 Agent-Squad 引擎编排(#squad 指令与 squad_orchestrate 工具)"}, {Key: "runtime.entry", Group: "runtime", Label: "对话入口", Type: core.SettingEnum, Default: "direct", Options: []string{"direct", "squad"}, Effect: core.EffectNewRun, Description: "direct 走现有对话循环。squad 由小队先分诊:简单任务单步完成,复杂任务由 leader 编排。灰度期默认 direct"}, {Key: "runtime.parallelTools", Group: "runtime", Label: "并行工具执行", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "同一回合并行执行互不依赖的工具调用(检索、读文件、delegate_task 等);卡片流、计划、执行原语等顺序敏感工具仍互斥串行"}, - {Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Description: "0 = 不限;多用户建议 300–1200"}, - {Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"}, + {Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "0 = 不限;多用户建议 300–1200"}, + {Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"}, {Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"}, {Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"}, {Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"}, @@ -112,8 +124,8 @@ func Schema() []core.SettingDesc { {Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"}, {Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"}, {Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"}, - {Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"}, - {Key: "telemetry.serviceName", Group: "telemetry", Label: "遥测 service.name", Type: core.SettingString, Default: "agent-system-backend", Effect: core.EffectRestart, Description: "OTLP resource 的 service.name"}, + {Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"}, + {Key: "telemetry.serviceName", Group: "telemetry", Label: "遥测 service.name", Type: core.SettingString, Default: "agent-system-backend", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTLP resource 的 service.name"}, {Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun}, {Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun}, {Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"}, @@ -124,8 +136,8 @@ func Schema() []core.SettingDesc { {Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot}, {Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot}, {Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点(默认开,用户 2026-10-11 裁定)"}, - {Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"}, - {Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"}, + {Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"}, + {Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"}, {Key: "tools.mcp.overrides", Group: "tools", Label: "MCP 项目覆盖", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "JSON 对象。键是服务器名,值可含 enabled 与 exposure(direct 或 deferred)"}, {Key: "deliverables.style", Group: "deliverables", Label: "交付物默认样式", Type: core.SettingEnum, Default: "modern", Options: []string{"modern", "formal", "minimal"}, Effect: core.EffectNewRun, Description: "modern 现代专业 / formal 公文 / minimal 极简。本轮指令、上传模板或参考文档优先于这项"}, {Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"}, @@ -139,6 +151,9 @@ func Schema() []core.SettingDesc { type Service struct { st *store.Store system map[string]any // 启动注入的系统层覆盖(如数据目录),位于 defaults 与 user 之间 + // extraSchema 是插件设置声明(组合根经 SetExtraSchema 注入); + // 受管路径计算同时覆盖内置 schema 与插件声明。 + extraSchema []core.SettingDesc } func NewService(st *store.Store) *Service { return &Service{st: st} } @@ -146,30 +161,62 @@ func NewService(st *store.Store) *Service { return &Service{st: st} } // SetSystemOverride 注入启动期系统层覆盖(组合根调用;不落库)。 func (s *Service) SetSystemOverride(values map[string]any) { s.system = values } -// Effective 计算覆盖链结果:defaults → system(启动注入) → user(租户) → project。 +// SetExtraSchema 注入插件设置声明(受管标记由插件按需声明)。 +func (s *Service) SetExtraSchema(descs []core.SettingDesc) { s.extraSchema = descs } + +// ManagedSettingPaths 返回全部受管键路径(内置 schema + 插件声明)。 +func (s *Service) ManagedSettingPaths() []string { + paths := core.ManagedPaths(Schema()) + if s != nil && len(s.extraSchema) > 0 { + paths = append(paths, core.ManagedPaths(s.extraSchema)...) + } + return paths +} + +// Effective 计算覆盖链结果:defaults → system(启动注入) → system(管理员落库) +// → user(租户) → project。受管项在 user/project 层的存量覆盖被剥除,不再生效。 // owner 是租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。 func (s *Service) Effective(owner, projectID string) core.EffectiveSettings { eff := core.MergeSettings(SystemDefaults(), map[string]any{}) if s.system != nil { eff = core.MergeSettings(eff, s.system) } + if sysdb, err := s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem); err == nil { + eff = core.MergeSettings(eff, sysdb) + } + managed := s.ManagedSettingPaths() if user, err := s.st.GetSettingsScope(owner, ScopeUser); err == nil { - eff = core.MergeSettings(eff, user) + eff = core.MergeSettings(eff, core.StripManagedSettings(normalizeKeys(user), managed)) } if projectID != "" { if proj, err := s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID); err == nil { - eff = core.MergeSettings(eff, proj) + eff = core.MergeSettings(eff, core.StripManagedSettings(normalizeKeys(proj), managed)) } } return eff } func (s *Service) UserScope(owner string) (map[string]any, error) { - return s.st.GetSettingsScope(owner, ScopeUser) + return s.readScopeStripped(owner, ScopeUser) } func (s *Service) ProjectScope(owner, projectID string) (map[string]any, error) { - return s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID) + return s.readScopeStripped(owner, ScopeProjectPrefix+projectID) +} + +// readScopeStripped 读库并剥除受管路径:受管项对租户层不再生效,也不在设置页 +// 回显为"当前值"(存量值保留在库中作为迁移依据,P8 处置)。 +func (s *Service) readScopeStripped(owner, scope string) (map[string]any, error) { + m, err := s.st.GetSettingsScope(owner, scope) + if err != nil { + return nil, err + } + return core.StripManagedSettings(normalizeKeys(m), s.ManagedSettingPaths()), nil +} + +// SystemScope 返回管理员落库的系统层(未脱敏;HTTP 读面自行 Masked)。 +func (s *Service) SystemScope() (map[string]any, error) { + return s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem) } func (s *Service) MergeUser(owner string, values map[string]any) error { @@ -180,10 +227,21 @@ func (s *Service) MergeProject(owner, projectID string, values map[string]any) e return s.mergeScope(owner, ScopeProjectPrefix+projectID, values) } +// MergeSystem 写管理员系统层(仅系统管理 API 调用;不做受管拦截——系统层 +// 就是受管项的权威写入面;非受管项写入形成全局默认覆盖,用户层仍可再覆盖)。 +func (s *Service) MergeSystem(values map[string]any) error { + return s.mergeScope(OwnerSystemRow, ScopeSystem, values) +} + // mergeScope 先把库里和本次写入的点路径键展开成嵌套,再深合并。 -// 掩码占位符视为未修改,不覆盖已存密钥。 +// 租户层(user/project)触碰受管路径时整笔拒绝。掩码占位符视为未修改,不覆盖已存密钥。 func (s *Service) mergeScope(owner, scope string, values map[string]any) error { incoming := prepareWrite(values) + if scope != ScopeSystem { + if err := s.checkManagedWrite(incoming); err != nil { + return err + } + } if err := s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) { return core.MergeSettings(normalizeKeys(cur), incoming), nil }); err != nil { diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index 2fd2a20..8ab0654 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -1,6 +1,7 @@ package settings import ( + "errors" "path/filepath" "reflect" "strings" @@ -180,8 +181,9 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) { if eff.GetString("runtime.entry", "") != "squad" { t.Fatalf("entry = %q", eff.GetString("runtime.entry", "")) } - if eff.GetString("tools.tavily.apiKeys", "") != "nested" { - t.Fatalf("nested key lost: %q", eff.GetString("tools.tavily.apiKeys", "")) + // P2 受管:tools.tavily.apiKeys 属系统受管项,user 层存量覆盖不再生效。 + if got := eff.GetString("tools.tavily.apiKeys", ""); got != "" { + t.Fatalf("managed key leaked from user scope: %q", got) } if eff.GetNumber("context.maxHistoryMessages", 0) != 30 { t.Fatalf("history = %v", eff.GetNumber("context.maxHistoryMessages", 0)) @@ -204,14 +206,27 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) { if _, ok := raw["runtime.entry"]; ok { t.Fatalf("dotted key persisted: %#v", raw) } - if err := svc.MergeUser("local", map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-real"}}}); err != nil { + // P2 受管:user 层写受管路径(嵌套与点路径形式)都整笔拒绝。 + var mp *ErrManagedPath + if err := svc.MergeUser("local", map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-real"}}}); !errors.As(err, &mp) { + t.Fatalf("nested managed write not rejected: %v", err) + } + if err := svc.MergeUser("local", map[string]any{"models.chat.apiKey": "sk-real"}); !errors.As(err, &mp) { + t.Fatalf("dotted managed write not rejected: %v", err) + } + if err := svc.MergeUser("local", map[string]any{"models": "smash"}); !errors.As(err, &mp) { + t.Fatalf("ancestor scalar overwrite not rejected: %v", err) + } + // 受管项只能经系统层写入并生效。 + if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-sys"}}}); err != nil { t.Fatal(err) } - if err := svc.MergeUser("local", map[string]any{"models.chat.apiKey": MaskPlaceholder}); err != nil { - t.Fatal(err) + if got := svc.Effective("local", "").GetString("models.chat.apiKey", ""); got != "sk-sys" { + t.Fatalf("system-layer key not effective: %q", got) } - if got := svc.Effective("local", "").GetString("models.chat.apiKey", ""); got != "sk-real" { - t.Fatalf("mask writeback overwrote key: %q", got) + // 读面剥离:UserScope 不回显受管项的存量值。 + if raw2, _ := svc.UserScope("local"); raw2["models"] != nil { + t.Fatalf("managed path exposed in user read scope: %#v", raw2["models"]) } } diff --git a/internal/store/account_lifecycle_test.go b/internal/store/account_lifecycle_test.go index 0004d14..156cd41 100644 --- a/internal/store/account_lifecycle_test.go +++ b/internal/store/account_lifecycle_test.go @@ -1,156 +1,145 @@ package store +// 账户生命周期并发合同(R5/R11/KTD12,2026-10-13 收尾): +// 并发建号不覆盖、首次引导单胜、兑换退避跨实例共担。 + import ( - "errors" - "fmt" - "os" "path/filepath" "sync" + "sync/atomic" "testing" + "time" ) -func TestConcurrentCreateOneWinner(t *testing.T) { - st := openLife(t) - const n = 8 - var wg sync.WaitGroup - wins := make(chan error, n) - for i := 0; i < n; i++ { - wg.Add(1) - go func() { - defer wg.Done() - _, err := st.CreateActivatedAccount(AccountWrite{ - Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, CreatedBy: "test", - Action: "account.create", - }) - wins <- err - }() - } - wg.Wait() - close(wins) - okN, conflict := 0, 0 - for err := range wins { - switch { - case err == nil: - okN++ - case errors.Is(err, ErrConflict): - conflict++ - default: - t.Fatal(err) - } - } - if okN != 1 || conflict != n-1 { - t.Fatalf("wins=%d conflicts=%d", okN, conflict) - } -} - -func TestPasswordChangeIsAtomic(t *testing.T) { - st := openLife(t) - hash, err := HashPassword("old-password") - if err != nil { - t.Fatal(err) - } - if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash, Action: "account.create"}); err != nil { - t.Fatal(err) - } - plain, _, err := st.IssueLoginKey("ada", LoginKeyPurposeActivate, "test", 0) - if err != nil { - t.Fatal(err) - } - st.SetAccountFault(fmt.Errorf("injected")) - next, err := HashPassword("new-password") - if err != nil { - t.Fatal(err) - } - if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err == nil { - t.Fatal("faulted change returned success") - } - st.SetAccountFault(nil) - if _, ok := st.VerifyLogin("ada", "old-password"); !ok { - t.Fatal("old password did not survive the failed change") - } - if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err != nil { - t.Fatal(err) - } - if _, _, err := st.RedeemLoginKey(plain); err == nil { - t.Fatal("old key redeemed after password change") - } - if _, ok := st.VerifyLogin("ada", "new-password"); !ok { - t.Fatal("new password missing") - } - n, err := st.CountAudit("ada", "rejected") - if err != nil || n == 0 { - t.Fatalf("rejected audit n=%d err=%v", n, err) - } -} - -func TestOpenExistingDoesNotMigrateOrCreate(t *testing.T) { - missing := filepath.Join(t.TempDir(), "absent", "agent-system.db") - if _, err := OpenExistingSQLite(missing); err == nil { - t.Fatal("missing file opened") - } - if _, err := os.Stat(filepath.Dir(missing)); !os.IsNotExist(err) { - t.Fatalf("parent created: %v", err) - } - path := filepath.Join(t.TempDir(), "agent-system.db") - st, err := OpenSQLite(path) - if err != nil { - t.Fatal(err) - } - if err := st.Close(); err != nil { - t.Fatal(err) - } - again, err := OpenExistingSQLite(path) - if err != nil { - t.Fatal(err) - } - again.Close() -} - -func TestBootstrapRefusesExistingBusiness(t *testing.T) { - st := openLife(t) - if err := st.EnsureSystemIdentity("inst", "sqlite"); err != nil { - t.Fatal(err) - } - if _, err := st.CreateProject("owner", "p"); err != nil { - t.Fatal(err) - } - if _, err := st.BootstrapFirstAdmin(AccountWrite{Owner: "root", Name: "root"}); err == nil { - t.Fatal("bootstrap accepted a database that already has a project") - } -} - -func TestPasswordSurvivesReopen(t *testing.T) { - path := filepath.Join(t.TempDir(), "agent-system.db") - st, err := OpenSQLite(path) - if err != nil { - t.Fatal(err) - } - hash, err := HashPassword("keep-password") - if err != nil { - t.Fatal(err) - } - if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash}); err != nil { - t.Fatal(err) - } - if err := st.Close(); err != nil { - t.Fatal(err) - } - st, err = OpenSQLite(path) - if err != nil { - t.Fatal(err) - } - defer st.Close() - if _, ok := st.VerifyLogin("ada", "keep-password"); !ok { - t.Fatal("password did not survive reopen") - } -} - -func openLife(t *testing.T) *Store { - t.Helper() - st, err := OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db")) +// 并发建号同名:恰一胜;胜者角色/密码不被败者覆盖(旧实现 UPDATE 步会互相覆盖)。 +func TestConcurrentCreateAccountNoOverwrite(t *testing.T) { + dir := t.TempDir() + db := filepath.Join(dir, "shared.db") + st, err := OpenSQLite(db) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) - return st + hash, err := HashPassword("winner-pass-1") + if err != nil { + t.Fatal(err) + } + var wins, losses atomic.Int32 + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + admin := i%2 == 0 // 一半以 admin 建同名账户 + wg.Add(1) + go func(admin bool) { + defer wg.Done() + if err := st.CreateAccount("dupe", "", hash, admin); err == nil { + wins.Add(1) + } else if err == ErrConflict { + losses.Add(1) + } + }(admin) + } + wg.Wait() + if wins.Load() != 1 || losses.Load() != 15 { + t.Fatalf("wins=%d losses=%d, want 1/15", wins.Load(), losses.Load()) + } + got, err := st.GetUserAuthState("dupe") + if err != nil { + t.Fatal(err) + } + // 胜者的 hash 原样保留(没有第二个写入者覆盖密码)。 + if !got.HasPassword { + t.Fatal("winner password must survive concurrent creates") + } + if _, ok := st.VerifyLogin("dupe", "winner-pass-1"); !ok { + t.Fatal("winner credentials must verify") + } +} + +// 首次管理员引导并发:两个并发引导恰一胜;已含用户时拒绝接管。 +func TestBootstrapFirstAdminSingleWinner(t *testing.T) { + dir := t.TempDir() + db := filepath.Join(dir, "boot.db") + st, err := OpenSQLite(db) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.EnsureSystemIdentity("bootstrap-test", "sqlite"); err != nil { + t.Fatal(err) + } + var wins, conflicts atomic.Int32 + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + owner := "admin-a" + if i%2 == 1 { + owner = "admin-b" + } + wg.Add(1) + go func(owner string) { + defer wg.Done() + if _, err := st.BootstrapFirstAdmin(owner, owner, "test"); err == nil { + wins.Add(1) + } else if err == ErrConflict { + conflicts.Add(1) + } else { + t.Errorf("bootstrap contender: %v", err) + } + }(owner) + } + wg.Wait() + if wins.Load() != 1 || conflicts.Load() != 7 { + t.Fatalf("wins=%d conflicts=%d, want 1/7", wins.Load(), conflicts.Load()) + } + users, err := st.ListUsersAdmin() + if err != nil || len(users) != 1 { + t.Fatalf("exactly one admin must exist: %d %v", len(users), err) + } + if !users[0].IsAdmin { + t.Fatal("bootstrapped account must be admin") + } + // 已有用户后再次引导:拒绝接管。 + if _, err := st.BootstrapFirstAdmin("admin-c", "admin-c", "test"); err != ErrConflict { + t.Fatalf("second bootstrap after users exist: %v", err) + } +} + +// 兑换退避跨实例:store A 记录的失败,store B(同库另一连接)同样被限。 +func TestRedeemGuardCrossStore(t *testing.T) { + dir := t.TempDir() + db := filepath.Join(dir, "guard.db") + stA, err := OpenSQLite(db) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { stA.Close() }) + stB, err := OpenSQLite(db) + if err != nil { + t.Fatal(err) + } + defer stB.Close() + const max = 5 + for i := 0; i < max; i++ { + if err := stA.RecordRedeemFailure("10.0.0.9"); err != nil { + t.Fatal(err) + } + } + if blocked, err := stB.RedeemBlocked("10.0.0.9", max, time.Minute); err != nil || !blocked { + t.Fatalf("store B must see the shared limit: blocked=%v err=%v", blocked, err) + } + if blocked, err := stA.RedeemBlocked("10.0.0.8", max, time.Minute); err != nil || blocked { + t.Fatalf("other ip must pass: blocked=%v err=%v", blocked, err) + } + // 窗口外记录在下次判定时清理。 + if err := stA.drainRedeemWindow(); err != nil { + t.Fatal(err) + } + if blocked, _ := stA.RedeemBlocked("10.0.0.9", max, time.Minute); blocked { + t.Fatal("limit must clear after the window drains") + } +} + +// drainRedeemWindow 测试助手:把窗口内判定用的记录时间整体前移出窗。 +func (s *Store) drainRedeemWindow() error { + _, err := s.db.Exec(s.q(`UPDATE redeem_attempts SET at=?`), time.Now().Add(-2*time.Minute).UTC().Format(time.RFC3339Nano)) + return err } diff --git a/internal/store/account_process_crash_test.go b/internal/store/account_process_crash_test.go new file mode 100644 index 0000000..4ec21c5 --- /dev/null +++ b/internal/store/account_process_crash_test.go @@ -0,0 +1,96 @@ +package store + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestAccountPasswordSurvivesProcessTermination(t *testing.T) { + if mode := os.Getenv("U7_CRASH_CHILD_MODE"); mode != "" { + path := os.Getenv("U7_CRASH_CHILD_DB") + st, err := OpenExistingSQLite(path) + if err != nil { + t.Fatal(err) + } + h, err := HashPassword("private-fixture-password") + if err != nil { + t.Fatal(err) + } + _, err = st.CreateActivatedAccount(AccountWrite{Owner: "survivor", Name: "Survivor", PasswordHash: h, Action: "account.create", Actor: "fixture"}) + if err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + fmt.Println("U7_COMMITTED") + select {} + } + for _, tc := range []struct { + name string + signal syscall.Signal + }{{"SIGTERM", syscall.SIGTERM}, {"SIGKILL", syscall.SIGKILL}} { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "accounts.db") + created, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + if err := created.Close(); err != nil { + t.Fatal(err) + } + child := exec.Command(os.Args[0], "-test.run=^TestAccountPasswordSurvivesProcessTermination$") + child.Env = append(os.Environ(), "U7_CRASH_CHILD_MODE="+tc.name, "U7_CRASH_CHILD_DB="+path) + stdout, err := child.StdoutPipe() + if err != nil { + t.Fatal(err) + } + child.Stderr = os.Stderr + if err := child.Start(); err != nil { + t.Fatal(err) + } + ready := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + if strings.Contains(scanner.Text(), "U7_COMMITTED") { + ready <- nil + return + } + } + ready <- fmt.Errorf("child exited without committed receipt: %v", scanner.Err()) + }() + select { + case err := <-ready: + if err != nil { + _ = child.Process.Kill() + _ = child.Wait() + t.Fatal(err) + } + case <-time.After(10 * time.Second): + _ = child.Process.Kill() + _ = child.Wait() + t.Fatal("child did not commit in time") + } + if err := child.Process.Signal(tc.signal); err != nil { + t.Fatal(err) + } + _ = child.Wait() + reopened, err := OpenExistingSQLite(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if _, ok := reopened.VerifyLogin("survivor", "private-fixture-password"); !ok { + t.Fatal("committed password missing after real process termination") + } + }) + } +} diff --git a/internal/store/account_security_integration_test.go b/internal/store/account_security_integration_test.go new file mode 100644 index 0000000..1dd18e6 --- /dev/null +++ b/internal/store/account_security_integration_test.go @@ -0,0 +1,103 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func activeAccount(t *testing.T) *Store { + t.Helper() + s := openTestStore(t) + h, e := HashPassword("old-password") + if e != nil { + t.Fatal(e) + } + if e := s.CreateAccount("ada", "Ada", h, false); e != nil { + t.Fatal(e) + } + if e := s.ClearMustChangePassword("ada"); e != nil { + t.Fatal(e) + } + return s +} +func TestAccountMaintenanceInvalidatesVerifiedLogin(t *testing.T) { + for _, kind := range []string{"password", "revoke-all"} { + t.Run(kind, func(t *testing.T) { + s := activeAccount(t) + _, ver, ok := s.VerifyLoginWithVersion("ada", "old-password") + if !ok { + t.Fatal("healthy login failed") + } + var err error + if kind == "password" { + h, e := HashPassword("new-password") + if e != nil { + t.Fatal(e) + } + _, err = s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h}) + } else { + _, err = s.RevokeAllAndIssue(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin"}) + } + if err != nil { + t.Fatal(err) + } + if _, err := s.CreateSessionVerified("ada", SessionScopeFull, time.Hour, ver); err == nil { + t.Fatal("stale in-flight login could create a session after credential replacement") + } + }) + } +} +func TestActivatedAccountRetainsLoginKeySuccessEvent(t *testing.T) { + s := openTestStore(t) + if _, err := s.CreateActivatedAccount(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeActivate, CreatedBy: "admin", Actor: "admin"}); err != nil { + t.Fatal(err) + } + var n int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM account_events WHERE owner=? AND action='login_key_issued'`), "ada").Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatal("atomic issuance dropped main account event") + } +} +func TestCheckedPasswordMutationRollsBackAuditFailure(t *testing.T) { + s := openTestStore(t) + if err := s.CreateAccount("ada", "Ada", "", false); err != nil { + t.Fatal(err) + } + key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeActivate, "admin", 0) + if err != nil { + t.Fatal(err) + } + token, _, err := s.RedeemLoginKey(key) + if err != nil { + t.Fatal(err) + } + h, err := HashPassword("new-password") + if err != nil { + t.Fatal(err) + } + s.SetAccountFault(ErrConflict) + if _, err := s.CompleteSetPasswordChecked(token, h); err == nil { + t.Fatal("checked password path bypassed the shared audited commit") + } + if a, err := s.GetUserAuthState("ada"); err != nil || a.HasPassword { + t.Fatal("failed checked change left a password behind") + } + if _, _, ok := s.SessionInfo(token); !ok { + t.Fatal("failed checked change consumed its session") + } +} + +func TestRevokeAllRefusesMissingOwnerWithoutCredential(t *testing.T) { + s := openTestStore(t) + _, err := s.RevokeAllAndIssue(AccountWrite{Owner: "missing", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin", Actor: "admin"}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("missing owner must be rejected before issuing any key: %v", err) + } + var n int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM login_keys WHERE owner=?`), "missing").Scan(&n); err != nil || n != 0 { + t.Fatalf("orphan credential rows=%d err=%v", n, err) + } +} diff --git a/internal/store/account_two_process_pg_test.go b/internal/store/account_two_process_pg_test.go new file mode 100644 index 0000000..b281e9e --- /dev/null +++ b/internal/store/account_two_process_pg_test.go @@ -0,0 +1,145 @@ +package store + +import ( + "bytes" + "database/sql" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// Two independent process opens share one isolated PostgreSQL schema. The +// barrier makes their account transactions genuinely concurrent. +func TestAccountTwoProcessPostgresAdmission(t *testing.T) { + if mode := os.Getenv("U7_ACCOUNT_CHILD_MODE"); mode != "" { + childDSN := os.Getenv("U7_ACCOUNT_CHILD_DSN") + barrier := os.Getenv("U7_ACCOUNT_CHILD_BARRIER") + st, err := OpenExistingPostgres(childDSN, os.Getenv("U7_ACCOUNT_CHILD_ARTIFACTS")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + for i := 0; i < 500; i++ { + if _, err := os.Stat(barrier); err == nil { + break + } + time.Sleep(5 * time.Millisecond) + } + if _, err := os.Stat(barrier); err != nil { + t.Fatal("two-process barrier missing") + } + var outcome AccountOutcome + switch mode { + case "create": + outcome, err = st.CreateActivatedAccount(AccountWrite{Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, CreatedBy: "fixture", Actor: "fixture", Action: "account.create"}) + case "bootstrap": + outcome, err = st.BootstrapFirstAdminWrite(AccountWrite{Owner: os.Getenv("U7_ACCOUNT_CHILD_OWNER"), Name: "Bootstrap", CreatedBy: "fixture", Actor: "fixture"}) + case "bootstrap-versus-create": + if os.Getenv("U7_ACCOUNT_CHILD_OWNER") == "bootstrap" { + outcome, err = st.BootstrapFirstAdminWrite(AccountWrite{Owner: "admin", Name: "Admin", CreatedBy: "fixture", Actor: "fixture"}) + } else { + outcome, err = st.CreateActivatedAccount(AccountWrite{Owner: "ordinary", Name: "Ordinary", Purpose: LoginKeyPurposeActivate, CreatedBy: "fixture", Actor: "fixture", Action: "account.create"}) + } + default: + t.Fatal("unknown child mode") + } + if err == nil { + if outcome.OperationID == "" || outcome.Plain == "" { + t.Fatal("committed account did not produce a complete receipt") + } + fmt.Println("U7_RESULT=success") + return + } + if errors.Is(err, ErrConflict) { + fmt.Println("U7_RESULT=conflict") + return + } + t.Fatalf("unexpected admission error: %v", err) + return + } + baseDSN := os.Getenv("AGENT_PG_TEST_DSN") + if baseDSN == "" { + t.Skip("AGENT_PG_TEST_DSN absent; Dev PG gate runs this against a real isolated schema") + } + for _, tc := range []struct { + mode string + owners [2]string + }{ + {"create", [2]string{"one", "two"}}, + {"bootstrap", [2]string{"admin-one", "admin-two"}}, + {"bootstrap-versus-create", [2]string{"bootstrap", "ordinary"}}, + } { + t.Run(tc.mode, func(t *testing.T) { + schema := fmt.Sprintf("u7_audit_%d", time.Now().UnixNano()) + admin, err := sql.Open("pgx", baseDSN) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + ensurePGTrgm(t, admin) + if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil { + t.Fatal(err) + } + defer admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`) + dsn := withTestSearchPath(baseDSN, schema) + artifacts := filepath.Join(t.TempDir(), "artifacts") + st, err := OpenPostgres(dsn, artifacts) + if err != nil { + t.Fatal(err) + } + if err := st.EnsureSystemIdentity("pg-two-process", "pg"); err != nil { + t.Fatal(err) + } + st.Close() + barrier := filepath.Join(t.TempDir(), "go") + var children [2]*exec.Cmd + var logs [2]bytes.Buffer + for i := range children { + cmd := exec.Command(os.Args[0], "-test.run=^TestAccountTwoProcessPostgresAdmission$", "-test.v") + cmd.Env = append(os.Environ(), "U7_ACCOUNT_CHILD_MODE="+tc.mode, "U7_ACCOUNT_CHILD_DSN="+dsn, + "U7_ACCOUNT_CHILD_OWNER="+tc.owners[i], "U7_ACCOUNT_CHILD_ARTIFACTS="+artifacts, + "U7_ACCOUNT_CHILD_BARRIER="+barrier) + cmd.Stdout = &logs[i] + cmd.Stderr = &logs[i] + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + children[i] = cmd + } + if err := os.WriteFile(barrier, []byte("go"), 0o600); err != nil { + t.Fatal(err) + } + wins, losses := 0, 0 + for i, cmd := range children { + if err := cmd.Wait(); err != nil { + t.Fatalf("child %d failed: %v %s", i, err, logs[i].String()) + } + out := logs[i].String() + if strings.Contains(out, "U7_RESULT=success") { + wins++ + } else if strings.Contains(out, "U7_RESULT=conflict") { + losses++ + } else { + t.Fatalf("child %d produced no outcome: %s", i, out) + } + } + if wins != 1 || losses != 1 { + t.Fatalf("admission wins=%d conflicts=%d, want 1/1", wins, losses) + } + reopened, err := OpenExistingPostgres(dsn, artifacts) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + users, err := reopened.ListUsersAdmin() + if err != nil || len(users) != 1 { + t.Fatalf("actual committed users=%d, err=%v", len(users), err) + } + }) + } +} diff --git a/internal/store/account_tx.go b/internal/store/account_tx.go index a5fd4c9..561625b 100644 --- a/internal/store/account_tx.go +++ b/internal/store/account_tx.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "database/sql" "encoding/hex" + "errors" "fmt" "time" ) @@ -39,6 +40,27 @@ func (s *Store) CreateActivatedAccount(w AccountWrite) (AccountOutcome, error) { } var out AccountOutcome err := s.accountTx(w, func(tx *sql.Tx, opID string) error { + if s.dialect.name() == "postgres" { + // A first ordinary account and first-admin bootstrap use the same + // admission lock. If bootstrap commits while this call waits, the + // earlier empty-database observation cannot authorize a second + // competing first account. + var before, after int + if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&before); err != nil { + return err + } + if before == 0 { + if err := s.dialect.lockBootstrap(tx); err != nil { + return err + } + if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&after); err != nil { + return err + } + if after != 0 { + return ErrConflict + } + } + } if err := insertAccount(s, tx, w); err != nil { return err } @@ -62,7 +84,7 @@ func (s *Store) ApplyPasswordChange(w AccountWrite) (AccountOutcome, error) { } var out AccountOutcome err := s.accountTx(w, func(tx *sql.Tx, opID string) error { - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=? WHERE owner=?`), + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), w.PasswordHash, nowISO(), w.MustChange, w.Owner) if err != nil { return err @@ -89,7 +111,7 @@ func (s *Store) ApplyPasswordChange(w AccountWrite) (AccountOutcome, error) { } // BootstrapFirstAdmin 在立即事务(SQLite)或身份行锁(PostgreSQL)内复核空库后建首位管理员。 -func (s *Store) BootstrapFirstAdmin(w AccountWrite) (AccountOutcome, error) { +func (s *Store) BootstrapFirstAdminWrite(w AccountWrite) (AccountOutcome, error) { w.IsAdmin = true w.PasswordHash = "" w.Purpose = LoginKeyPurposeActivate @@ -101,6 +123,9 @@ func (s *Store) BootstrapFirstAdmin(w AccountWrite) (AccountOutcome, error) { } var out AccountOutcome err := s.accountTx(w, func(tx *sql.Tx, opID string) error { + if err := s.dialect.lockBootstrap(tx); err != nil { + return err + } if err := lockIdentity(s, tx); err != nil { return err } @@ -110,6 +135,16 @@ func (s *Store) BootstrapFirstAdmin(w AccountWrite) (AccountOutcome, error) { if err := insertAccount(s, tx, w); err != nil { return err } + var users int + if err := tx.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil { + return err + } + if users != 1 { + return ErrConflict + } + if err := s.insertAccountEvent(tx, w.CreatedBy, entranceOf(w.CreatedBy), w.Owner, "bootstrap_admin", w.Owner); err != nil { + return err + } plain, id, err := insertLoginKey(s, tx, w) if err != nil { return err @@ -127,6 +162,16 @@ func (s *Store) RevokeAllAndIssue(w AccountWrite) (AccountOutcome, error) { } var out AccountOutcome err := s.accountTx(w, func(tx *sql.Tx, opID string) error { + res, err := tx.Exec(s.q(`UPDATE users SET auth_version=auth_version+1 WHERE owner=?`), w.Owner) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + if _, err := tx.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND revoked=0`), w.Owner); err != nil { return err } @@ -234,12 +279,10 @@ func insertLoginKey(s *Store, tx *sql.Tx, w AccountWrite) (string, string, error plain := "alk_" + hex.EncodeToString(raw) id := "lk_" + hex.EncodeToString(idRaw) now := time.Now().UTC() - if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), - LoginKeyStatusRevoked, w.Owner, purpose, LoginKeyStatusUnused); err != nil { + if _, err := s.insertLoginKeyTx(tx, w.Owner, purpose, w.CreatedBy, plain, id, now, now.Add(ttl)); err != nil { return "", "", err } - _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by,instance_id) VALUES(?,?,?,?,?,?,?,?,?)`), - id, w.Owner, purpose, hashKey(plain), LoginKeyStatusUnused, now.Add(ttl).Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), w.CreatedBy, w.InstanceID) + _, err := tx.Exec(s.q(`UPDATE login_keys SET instance_id=? WHERE id=?`), w.InstanceID, id) return plain, id, err } @@ -277,7 +320,7 @@ func requireEmptyBusiness(s *Store, tx *sql.Tx) error { return err } if n > 0 { - return fmt.Errorf("bootstrap: %s is not empty", table) + return ErrConflict } } return nil @@ -307,3 +350,31 @@ func (s *Store) CountAudit(owner, outcome string) (int, error) { err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM audit_events WHERE owner=? AND entry=? AND outcome=?`), owner, "account", outcome).Scan(&n) return n, err } + +// commitAccountMutation extends existing checked transactions without moving +// their authorization checks outside the transaction or replacing account_events. +func (s *Store) commitAccountMutation(tx *sql.Tx, w AccountWrite) error { + opID := newID("op") + if w.InstanceID == "" { + err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&w.InstanceID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + } + if s.accountFault != nil { + err := s.accountFault + _ = tx.Rollback() + s.recordRejected(w, opID) + return err + } + if err := insertAudit(s, tx, w, opID, "success"); err != nil { + _ = tx.Rollback() + s.recordRejected(w, opID) + return err + } + if err := tx.Commit(); err != nil { + s.recordRejected(w, opID) + return err + } + return nil +} diff --git a/internal/store/account_tx_lifecycle_test.go b/internal/store/account_tx_lifecycle_test.go new file mode 100644 index 0000000..d18725b --- /dev/null +++ b/internal/store/account_tx_lifecycle_test.go @@ -0,0 +1,156 @@ +package store + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" +) + +func TestConcurrentCreateOneWinner(t *testing.T) { + st := openLife(t) + const n = 8 + var wg sync.WaitGroup + wins := make(chan error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := st.CreateActivatedAccount(AccountWrite{ + Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, CreatedBy: "test", + Action: "account.create", + }) + wins <- err + }() + } + wg.Wait() + close(wins) + okN, conflict := 0, 0 + for err := range wins { + switch { + case err == nil: + okN++ + case errors.Is(err, ErrConflict): + conflict++ + default: + t.Fatal(err) + } + } + if okN != 1 || conflict != n-1 { + t.Fatalf("wins=%d conflicts=%d", okN, conflict) + } +} + +func TestPasswordChangeIsAtomic(t *testing.T) { + st := openLife(t) + hash, err := HashPassword("old-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash, Action: "account.create"}); err != nil { + t.Fatal(err) + } + plain, _, err := st.IssueLoginKey("ada", LoginKeyPurposeActivate, "test", 0) + if err != nil { + t.Fatal(err) + } + st.SetAccountFault(fmt.Errorf("injected")) + next, err := HashPassword("new-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err == nil { + t.Fatal("faulted change returned success") + } + st.SetAccountFault(nil) + if _, ok := st.VerifyLogin("ada", "old-password"); !ok { + t.Fatal("old password did not survive the failed change") + } + if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err != nil { + t.Fatal(err) + } + if _, _, err := st.RedeemLoginKey(plain); err == nil { + t.Fatal("old key redeemed after password change") + } + if _, ok := st.VerifyLogin("ada", "new-password"); !ok { + t.Fatal("new password missing") + } + n, err := st.CountAudit("ada", "rejected") + if err != nil || n == 0 { + t.Fatalf("rejected audit n=%d err=%v", n, err) + } +} + +func TestOpenExistingDoesNotMigrateOrCreate(t *testing.T) { + missing := filepath.Join(t.TempDir(), "absent", "agent-system.db") + if _, err := OpenExistingSQLite(missing); err == nil { + t.Fatal("missing file opened") + } + if _, err := os.Stat(filepath.Dir(missing)); !os.IsNotExist(err) { + t.Fatalf("parent created: %v", err) + } + path := filepath.Join(t.TempDir(), "agent-system.db") + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + again, err := OpenExistingSQLite(path) + if err != nil { + t.Fatal(err) + } + again.Close() +} + +func TestBootstrapRefusesExistingBusiness(t *testing.T) { + st := openLife(t) + if err := st.EnsureSystemIdentity("inst", "sqlite"); err != nil { + t.Fatal(err) + } + if _, err := st.CreateProject("owner", "p"); err != nil { + t.Fatal(err) + } + if _, err := st.BootstrapFirstAdminWrite(AccountWrite{Owner: "root", Name: "root"}); err == nil { + t.Fatal("bootstrap accepted a database that already has a project") + } +} + +func TestPasswordSurvivesReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent-system.db") + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + hash, err := HashPassword("keep-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash}); err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + st, err = OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if _, ok := st.VerifyLogin("ada", "keep-password"); !ok { + t.Fatal("password did not survive reopen") + } +} + +func openLife(t *testing.T) *Store { + t.Helper() + st, err := OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} diff --git a/internal/store/auth.go b/internal/store/auth.go index ce53a9e..379c354 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -218,8 +218,10 @@ func VerifyPassword(hash, password string) bool { return subtle.ConstantTimeCompare(got, want) == 1 } -// CreateAccount 管理员建号:owner 唯一。 +// CreateAccount 管理员建号:单事务整行 INSERT,owner 唯一冲突返回 ErrConflict。 // passwordHash 非空时 must_change=1;空哈希表示未设密(走激活 Key),must_change=0。 +// (原子化:旧实现先查后插再 UPDATE,两个并发 create 同名会在 UPDATE 步互相覆盖 +// 角色与密码——R5/KTD12 并发建号不覆盖。) func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) error { _, err := s.CreateActivatedAccount(AccountWrite{ Owner: owner, Name: name, PasswordHash: passwordHash, IsAdmin: isAdmin, @@ -275,16 +277,25 @@ func (s *Store) GetUserAuthState(owner string) (UserAuthState, error) { // VerifyLogin 校验用户名密码。 func (s *Store) VerifyLogin(owner, password string) (UserAuthState, bool) { + st, _, ok := s.VerifyLoginWithVersion(owner, password) + return st, ok +} + +// VerifyLoginWithVersion 校验用户名密码并返回验证所依据的认证版本;调用方建会话 +// 时必须以 CreateSessionVerified 传回该版本,堵「验证在途 → 恢复/改密完成 → +// 旧请求又建会话」的竞态窗口。 +func (s *Store) VerifyLoginWithVersion(owner, password string) (UserAuthState, int, bool) { var hash string - err := s.db.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash) + var ver int + err := s.db.QueryRow(s.q(`SELECT password_hash,auth_version FROM users WHERE owner=?`), owner).Scan(&hash, &ver) if err != nil || hash == "" || !VerifyPassword(hash, password) { - return UserAuthState{}, false + return UserAuthState{}, 0, false } st, err := s.GetUserAuthState(owner) if err != nil { - return UserAuthState{}, false + return UserAuthState{}, 0, false } - return st, true + return st, ver, true } // ---- 会话 ---- @@ -294,8 +305,30 @@ func (s *Store) CreateSession(owner string) (string, error) { return s.CreateSessionScoped(owner, SessionScopeFull, sessionTTL) } -// CreateSessionScoped 按 scope 与 TTL 建会话。 +// CreateSessionScoped 按 scope 与 TTL 建会话(尽力绑定账户当前认证版本)。 func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (string, error) { + if scope == "" { + scope = SessionScopeFull + } + if ttl <= 0 { + ttl = sessionTTL + } + ver := 0 + _ = s.db.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver) + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := "sess_" + hex.EncodeToString(raw) + exp := time.Now().Add(ttl) + _, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), + hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver) + return token, err +} + +// CreateSessionVerified 建会话并绑定期望认证版本:事务内重读账户版本,与密码验证 +// 时的一致才插入;版本已前进(并发恢复/改密/重置)返回 ErrConflict,不建会话。 +func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, expectVersion int) (string, error) { if scope == "" { scope = SessionScopeFull } @@ -308,9 +341,27 @@ func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (str } token := "sess_" + hex.EncodeToString(raw) exp := time.Now().Add(ttl) - _, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope) VALUES(?,?,?,?,?,?)`), - hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope) - return token, err + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + var ver int + err = tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if ver != expectVersion { + return "", ErrConflict + } + if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), + hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver); err != nil { + return "", err + } + return token, tx.Commit() } // SessionOwner 校验会话:命中且未过期返回 owner(尽力刷新 last_seen)。 diff --git a/internal/store/content.go b/internal/store/content.go index 10331c2..c740f98 100644 --- a/internal/store/content.go +++ b/internal/store/content.go @@ -388,6 +388,66 @@ func (s *Store) SearchKnowledgeShared(requester string, q core.RetrievalQuery, a return s.searchKnowledgeSQLiteShared(requester, q, pred) } +// 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.searchKnowledgePGSharedCandidates(requester, q, pred, false) + } + out, err := s.searchKnowledgeFTSCandidates(pred, q, false) + if err != nil { + return nil, err + } + if len(out) == 0 && q.Text != "" { + return s.searchKnowledgeLikeSharedCandidates(requester, q, pred, false) + } + return out, nil +} + +// DeleteProjectKnowledgeByRef preserves the ordinary legacy replacement scope. +// Registered lifecycle publishers use DeletePluginProjectKnowledgeByRef instead. +func (s *Store) DeleteProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref string) error { + args := []any{owner, ref} + pred := "owner=? AND ref=?" + if projectID != "" { + pred += " AND project_id=?" + args = append(args, projectID) + } + return s.deleteKnowledgeAndVectors(ctx, pred, args...) +} + +// DeletePluginProjectKnowledgeByRef replaces only this actor/plugin publication +// scope. Empty project is an exact unscoped domain, never a wildcard. The source +// prefix is compared literally, including any percent/underscore in plugin IDs. +func (s *Store) DeletePluginProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref, pluginID string) error { + if owner == "" || pluginID == "" { + return errors.New("store: publication owner and plugin required") + } + prefix := "plugin:" + pluginID + pred := `owner=? AND project_id=? AND ref=? AND (source=? OR substr(source,1,length(?))=?)` + return s.deleteKnowledgeAndVectors(ctx, pred, owner, projectID, ref, prefix, prefix+":", prefix+":") +} + +func (s *Store) deleteKnowledgeAndVectors(ctx context.Context, pred string, args ...any) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + 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 { @@ -419,14 +479,23 @@ func (s *Store) searchKnowledgeSQLiteShared(requester string, q core.RetrievalQu // searchKnowledgeFTS FTS 通道(owner 作用域与共享作用域共用:可见性谓词由调用方注入)。 func (s *Store) searchKnowledgeFTS(pred knowledgePred, q core.RetrievalQuery) ([]core.RetrievalHit, error) { + return s.searchKnowledgeFTSCandidates(pred, q, true) +} + +func (s *Store) searchKnowledgeFTSCandidates(pred knowledgePred, q core.RetrievalQuery, bounded bool) ([]core.RetrievalHit, error) { topK := q.TopK if topK <= 0 { topK = 5 } - rows, err := s.db.Query(s.q(` - SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank - FROM knowledge_fts f JOIN knowledge k ON k.rowid=f.rowid - WHERE knowledge_fts MATCH ? ORDER BY rank LIMIT ?`), ftsQuery(q.Text), topK*2) + query := `SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank + FROM knowledge_fts f JOIN knowledge k ON k.rowid=f.rowid + WHERE knowledge_fts MATCH ? ORDER BY rank` + args := []any{ftsQuery(q.Text)} + if bounded { + query += " LIMIT ?" + args = append(args, topK*2) + } + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, err } @@ -449,12 +518,21 @@ func (s *Store) searchKnowledgeFTS(pred knowledgePred, q core.RetrievalQuery) ([ } func (s *Store) searchKnowledgeLikeShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) { + return s.searchKnowledgeLikeSharedCandidates(requester, q, pred, true) +} + +func (s *Store) searchKnowledgeLikeSharedCandidates(requester string, q core.RetrievalQuery, pred knowledgePred, bounded bool) ([]core.RetrievalHit, error) { topK := q.TopK if topK <= 0 { topK = 5 } - rows, err := s.db.Query(s.q(`SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE owner<>? AND owner<>'' AND (chunk LIKE ? OR title LIKE ?) LIMIT ?`), - requester, "%"+q.Text+"%", "%"+q.Text+"%", topK) + query := `SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE owner<>? AND owner<>'' AND (chunk LIKE ? OR title LIKE ?)` + args := []any{requester, "%" + q.Text + "%", "%" + q.Text + "%"} + if bounded { + query += " LIMIT ?" + args = append(args, topK) + } + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, err } diff --git a/internal/store/dialect.go b/internal/store/dialect.go index 8e3abd8..9bf2c84 100644 --- a/internal/store/dialect.go +++ b/internal/store/dialect.go @@ -17,6 +17,9 @@ type dialect interface { lockScope(tx *sql.Tx, owner, scope string) error // lockAdmission 锁定执行许可账本的清点与登记(U3 admission)。 lockAdmission(tx *sql.Tx) error + // lockBootstrap 锁定首次管理员引导的「核验无用户→建号→激活 Key」串行段 + //(KTD12:防两个进程并发引导出双管理员)。 + lockBootstrap(tx *sql.Tx) error } // ---- SQLite(嵌入式档)---- @@ -28,6 +31,7 @@ func (sqliteDialect) rebind(q string) string { return q } func (sqliteDialect) lockConversation(*sql.Tx, string) error { return nil } func (sqliteDialect) lockScope(*sql.Tx, string, string) error { return nil } func (sqliteDialect) lockAdmission(*sql.Tx) error { return nil } +func (sqliteDialect) lockBootstrap(*sql.Tx) error { return nil } // ---- PostgreSQL(多用户/多实例档)---- @@ -63,3 +67,8 @@ func (postgresDialect) lockAdmission(tx *sql.Tx) error { _, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "admission:exec-slots") return err } + +func (postgresDialect) lockBootstrap(tx *sql.Tx) error { + _, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "bootstrap:first-admin") + return err +} diff --git a/internal/store/knowledge_authorization_test.go b/internal/store/knowledge_authorization_test.go new file mode 100644 index 0000000..b765e96 --- /dev/null +++ b/internal/store/knowledge_authorization_test.go @@ -0,0 +1,70 @@ +package store + +import ( + core "agent-system-core" + "context" + "testing" +) + +func TestAuthorizedKnowledgeFiltersBeforeTopK(t *testing.T) { + st := openTestStore(t) + docs := []core.KnowledgeDoc{} + for i := 0; i < 20; i++ { + docs = append(docs, core.KnowledgeDoc{Owner: "alice", ProjectID: "domain", Source: "private", Chunk: "sharedterm sharedterm sharedterm sharedterm"}) + } + docs = append(docs, core.KnowledgeDoc{ID: "published", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sharedterm plus additional explanatory words for this lower scoring published source"}) + if err := st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + hits, err := st.SearchKnowledgeSharedAuthorized("bob", core.RetrievalQuery{Text: "sharedterm", ProjectID: "domain", TopK: 1}, func(d core.KnowledgeDoc) bool { return d.ID == "published" }) + if err != nil || len(hits) != 1 || hits[0].Doc.ID != "published" { + t.Fatalf("denied rows consumed top-k: %+v %v", hits, err) + } +} + +func TestPluginProjectPublicationDeleteUsesExactScope(t *testing.T) { + st := openTestStore(t) + ctx := context.Background() + docs := []core.KnowledgeDoc{{ID: "unscoped", Owner: "alice", Source: "plugin:rdm", Ref: "same", Chunk: "unscoped"}, {ID: "scoped", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "same", Chunk: "scoped"}, {ID: "foreign", Owner: "alice", Source: "plugin:other", Ref: "same", Chunk: "foreign"}, {ID: "other_owner", Owner: "bob", Source: "plugin:rdm", Ref: "same", Chunk: "other owner"}} + if err := st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + for _, d := range docs { + if err := st.UpsertKnowledgeVector(ctx, d.Owner, d.ID, d.Ref, []float32{1, 0}); err != nil { + t.Fatal(err) + } + } + if err := st.DeletePluginProjectKnowledgeByRef(ctx, "alice", "", "same", "rdm"); err != nil { + t.Fatal(err) + } + for _, table := range []string{"knowledge", "knowledge_vectors"} { + var count int + if err := st.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 3 { + t.Fatalf("exact empty-project namespace %s count=%d error=%v", table, count, err) + } + } + if err := st.DeletePluginProjectKnowledgeByRef(ctx, "alice", "domain", "same", "rdm"); err != nil { + t.Fatal(err) + } + for _, id := range []string{"foreign", "other_owner"} { + var count int + if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM knowledge WHERE id=?`), id).Scan(&count); err != nil || count != 1 { + t.Fatalf("protected row %s count=%d error=%v", id, count, err) + } + } +} + +func TestPluginPublicationNamespaceIsLiteral(t *testing.T) { + st := openTestStore(t) + docs := []core.KnowledgeDoc{{ID: "owned", Owner: "alice", Source: "plugin:rdm_%", Ref: "same", Chunk: "owned"}, {ID: "owned_suffix", Owner: "alice", Source: "plugin:rdm_%:published", Ref: "same", Chunk: "owned suffix"}, {ID: "protected", Owner: "alice", Source: "plugin:rdmXYZ:published", Ref: "same", Chunk: "protected"}} + if err := st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + if err := st.DeletePluginProjectKnowledgeByRef(context.Background(), "alice", "", "same", "rdm_%"); err != nil { + t.Fatal(err) + } + var id string + if err := st.db.QueryRow(`SELECT id FROM knowledge`).Scan(&id); err != nil || id != "protected" { + t.Fatalf("wildcards broadened namespace: id=%s error=%v", id, err) + } +} diff --git a/internal/store/login_keys.go b/internal/store/login_keys.go index 032439f..2413883 100644 --- a/internal/store/login_keys.go +++ b/internal/store/login_keys.go @@ -10,6 +10,16 @@ import ( ) // 一次性登录凭据(与长期 api_keys 分离)。明文前缀 alk_;库只存 SHA-256。 +// +// 登录恢复流程(2026-10-13 合同): +// - 每次密码写入(恢复设密/主动改密/管理员与 CLI 重置)同事务 bump +// users.auth_version 并作废该用户全部会话;会话行持有创建时的 auth_version。 +// - 密码登录建会话前绑定认证版本,堵「旧密码验证在途 → 恢复完成 → 旧请求 +// 又建会话」竞态。 +// - 签发/撤销/兑换/改密均与账户事件审计同事务(account_events,v28)。 + +// ErrBadCredentials 当前密码错误(主动改密路径)。 +var ErrBadCredentials = errors.New("store: bad credentials") const ( LoginKeyPurposeActivate = "activate" @@ -40,8 +50,35 @@ type LoginKeyMeta struct { CreatedBy string `json:"createdBy,omitempty"` } -// IssueLoginKey 签发一次性登录 Key;撤销同 owner+purpose 的 unused 旧 Key。 -// 返回明文(仅此一次)与元数据。 +// AccountEvent 账户生命周期审计行(不含任何凭据材料)。 +type AccountEvent struct { + ID string `json:"id"` + Time time.Time `json:"time"` + Actor string `json:"actor,omitempty"` + Entrance string `json:"entrance,omitempty"` + Owner string `json:"owner"` + Action string `json:"action"` + Detail string `json:"detail,omitempty"` +} + +// dbExecer 让账户事件写入同时适用于连接与事务(与密码变更同事务提交)。 +type dbExecer interface { + Exec(query string, args ...any) (sql.Result, error) +} + +func (s *Store) insertAccountEvent(db dbExecer, actor, entrance, owner, action, detail string) error { + idRaw := make([]byte, 8) + if _, err := rand.Read(idRaw); err != nil { + return err + } + _, err := db.Exec(s.q(`INSERT INTO account_events(id,time,actor,entrance,owner,action,detail) VALUES(?,?,?,?,?,?,?)`), + "ae_"+hex.EncodeToString(idRaw), nowISO(), actor, entrance, owner, action, detail) + return err +} + +// IssueLoginKey 签发一次性登录 Key。同事务:撤销同 owner+purpose 的 unused 旧 Key、 +// 作废该用户既有 set_password_only 会话(补发使旧恢复流程立即失效)、写签发审计。 +// 返回明文(仅此一次)与元数据;不改变账户密码、不吊销正常会话。 func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duration) (plain string, meta LoginKeyMeta, err error) { if purpose != LoginKeyPurposeActivate && purpose != LoginKeyPurposeRecover { return "", meta, fmt.Errorf("login key: invalid purpose %q", purpose) @@ -75,22 +112,83 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio return "", meta, err } defer tx.Rollback() - if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), - LoginKeyStatusRevoked, owner, purpose, LoginKeyStatusUnused); err != nil { - return "", meta, err - } - if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by,instance_id) VALUES(?,?,?,?,?,?,?,?,?)`), - id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy, ""); err != nil { + meta, err = s.insertLoginKeyTx(tx, owner, purpose, createdBy, plain, id, now, exp) + if err != nil { return "", meta, err } if err := tx.Commit(); err != nil { return "", meta, err } - meta = LoginKeyMeta{ID: id, Owner: owner, Purpose: purpose, Status: LoginKeyStatusUnused, ExpiresAt: exp, CreatedAt: now, CreatedBy: createdBy} return plain, meta, nil } -// RevokeLoginKey 按凭据编号撤销未使用 Key。 +// insertLoginKeyTx 在既有事务内签发:撤销旧 Key、作废受限会话、插入新 Key、审计。 +// (BootstrapFirstAdmin 与建号同事务复用——KTD12:管理员行与激活 Key 共同提交。) +func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, id string, now, exp time.Time) (LoginKeyMeta, error) { + meta := LoginKeyMeta{} + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), + LoginKeyStatusRevoked, owner, purpose, LoginKeyStatusUnused); err != nil { + return meta, err + } + // 旧 Key 兑换出的受限会话一并作废:补发后只有最新 Key 能进入设密。 + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND scope=?`), owner, SessionScopeSetPasswordOnly); err != nil { + return meta, err + } + if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by) VALUES(?,?,?,?,?,?,?,?)`), + id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy); err != nil { + return meta, err + } + if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "login_key_issued", purpose+" "+id); err != nil { + return meta, err + } + return LoginKeyMeta{ID: id, Owner: owner, Purpose: purpose, Status: LoginKeyStatusUnused, ExpiresAt: exp, CreatedAt: now, CreatedBy: createdBy}, nil +} + +// BootstrapFirstAdmin 首位管理员引导单事务:咨询锁串行(PG)→ 核验库内无用户 → +// 建管理员行 + 激活 Key + 审计共同提交;任何用户已存在返回 ErrConflict(拒绝接管)。 +// SQLite 无咨询锁:插入后复查用户数恰为 1,双引导并发时后提交者回滚。 +func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (string, error) { + out, err := s.BootstrapFirstAdminWrite(AccountWrite{Owner: owner, Name: name, CreatedBy: createdBy, Actor: createdBy}) + return out.Plain, err +} + +// RedeemBlocked 兑换入口跨实例退避:事务内清理窗口外失败记录,判定该来源 IP +// 在窗口内的失败数是否已达上限(只读判定;失败由 RecordRedeemFailure 记录)。 +func (s *Store) RedeemBlocked(ip string, max int, window time.Duration) (bool, error) { + cut := time.Now().Add(-window).UTC().Format(time.RFC3339Nano) + tx, err := s.db.Begin() + if err != nil { + return false, err + } + defer tx.Rollback() + if _, err := tx.Exec(s.q(`DELETE FROM redeem_attempts WHERE at < ?`), cut); err != nil { + return false, err + } + var n int + if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM redeem_attempts WHERE ip=?`), ip).Scan(&n); err != nil { + return false, err + } + return n >= max, tx.Commit() +} + +// RecordRedeemFailure 记录一次兑换失败(限流窗口内累计)。 +func (s *Store) RecordRedeemFailure(ip string) error { + _, err := s.db.Exec(s.q(`INSERT INTO redeem_attempts(ip, at) VALUES(?,?)`), ip, nowISO()) + return err +} + +// entranceOf 按签发者标注入口(审计可归因;cli 前缀归 CLI,其余归管理员 HTTP)。 +func entranceOf(createdBy string) string { + if createdBy == "" { + return "api" + } + if createdBy == "cli" || createdBy == "cli-bootstrap" || (len(createdBy) > 4 && createdBy[:4] == "cli-") { + return "cli" + } + return "admin-http" +} + +// RevokeLoginKey 按凭据编号撤销未使用 Key(CLI 既有语义,不动会话)。 func (s *Store) RevokeLoginKey(id string) error { res, err := s.db.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusRevoked, id, LoginKeyStatusUnused) @@ -103,8 +201,80 @@ func (s *Store) RevokeLoginKey(id string) error { return nil } -// RedeemLoginKey 原子消费:unused+未过期 → consumed,并创建 set_password_only 会话。 -// 明文不得走 Bearer;调用方专用 HTTP 入口。失败整体回滚。 +// RevokeLoginKeyCascade 撤销 Key 并作废该用户全部 set_password_only 会话(管理员 +// HTTP 入口)。Key 已消费/过期时仍返回属主并作废会话;编号不存在才报 NotFound。 +func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error) { + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + err = tx.QueryRow(s.q(`SELECT owner FROM login_keys WHERE id=?`), id).Scan(&owner) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), + LoginKeyStatusRevoked, id, LoginKeyStatusUnused); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND scope=?`), owner, SessionScopeSetPasswordOnly); err != nil { + return "", err + } + if err := s.insertAccountEvent(tx, actor, "admin-http", owner, "login_key_revoked", id); err != nil { + return "", err + } + return owner, tx.Commit() +} + +// ListLoginKeys 属主凭据元数据(时间倒序;无明文)。 +func (s *Store) ListLoginKeys(owner string) ([]LoginKeyMeta, error) { + rows, err := s.db.Query(s.q(`SELECT id,owner,purpose,status,expires_at,created_at,COALESCE(created_by,'') FROM login_keys WHERE owner=? ORDER BY created_at DESC LIMIT 50`), owner) + if err != nil { + return nil, err + } + defer rows.Close() + out := []LoginKeyMeta{} + for rows.Next() { + var m LoginKeyMeta + var exp, ca string + if err := rows.Scan(&m.ID, &m.Owner, &m.Purpose, &m.Status, &exp, &ca, &m.CreatedBy); err != nil { + return nil, err + } + m.ExpiresAt, _ = time.Parse(time.RFC3339Nano, exp) + m.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca) + out = append(out, m) + } + return out, rows.Err() +} + +// ListAccountEvents 属主账户事件(时间倒序;诊断/审计展示用)。 +func (s *Store) ListAccountEvents(owner string, limit int) ([]AccountEvent, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.db.Query(s.q(`SELECT id,time,COALESCE(actor,''),COALESCE(entrance,''),owner,action,COALESCE(detail,'') FROM account_events WHERE owner=? ORDER BY time DESC LIMIT ?`), owner, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AccountEvent{} + for rows.Next() { + var e AccountEvent + var ts string + if err := rows.Scan(&e.ID, &ts, &e.Actor, &e.Entrance, &e.Owner, &e.Action, &e.Detail); err != nil { + return nil, err + } + e.Time, _ = time.Parse(time.RFC3339Nano, ts) + out = append(out, e) + } + return out, rows.Err() +} + +// RedeemLoginKey 原子消费:unused+未过期 → consumed,并创建 set_password_only 会话 +// (绑定账户当前 auth_version)。明文不得走 Bearer;调用方专用 HTTP 入口。失败整体回滚。 func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err error) { if plain == "" || len(plain) < 5 || plain[:4] != "alk_" { return "", "", ErrNotFound @@ -140,14 +310,22 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er if n, _ := res.RowsAffected(); n != 1 { return "", "", ErrConflict } + var authVersion int + err = tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), o).Scan(&authVersion) + if err != nil { + return "", "", err // 账户已不存在:兑换失败整体回滚 + } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", "", err } token = "sess_" + hex.EncodeToString(raw) sessExp := time.Now().Add(restrictedSessionTTL) - if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope) VALUES(?,?,?,?,?,?)`), - hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly); err != nil { + if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), + hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly, authVersion); err != nil { + return "", "", err + } + if err := s.insertAccountEvent(tx, o, "login-key", o, "login_key_redeemed", id); err != nil { return "", "", err } if err := tx.Commit(); err != nil { @@ -156,10 +334,142 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er return token, o, nil } -// CompleteSetPassword 设密事务:写 hash、清 must_change、作废未用登录 Key、删除全部会话。 -func (s *Store) CompleteSetPassword(owner, passwordHash string) error { - _, err := s.ApplyPasswordChange(AccountWrite{Owner: owner, PasswordHash: passwordHash, Action: "account.set_password"}) - return err +// CompleteSetPasswordChecked 设密事务(恢复/强制路径):在最终事务内复验会话 +// (存在、未过期、scope 受限或账户仍处强制态、auth_version 未变),然后统一 +// 写 hash、清 must_change、bump auth_version、作废未用登录 Key、删除全部会话、 +// 写成功审计。会话或版本失效时返回 ErrConflict,不做任何写入。 +func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner string, err error) { + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + var scope, sessExp string + var sessVer, userVer int + var mustChange bool + var hash sql.NullString + err = tx.QueryRow(s.q(`SELECT s.owner,s.scope,s.expires_at,s.auth_version,u.auth_version,u.must_change_password,u.password_hash + FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)). + Scan(&owner, &scope, &sessExp, &sessVer, &userVer, &mustChange, &hash) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if expT, perr := time.Parse(time.RFC3339Nano, sessExp); perr != nil || time.Now().After(expT) { + return "", ErrNotFound + } + // 受限会话、待改密、未设密三者之一才允许免旧密码设密;其余按主动改密路径走。 + if scope != SessionScopeSetPasswordOnly && !mustChange && (hash.Valid && hash.String != "") { + return "", ErrConflict + } + // 会话创建后账户凭据已变(并发改密/重置):本次授权过期,拒绝继续。 + if sessVer != userVer { + return "", ErrConflict + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), + passwordHash, nowISO(), false, owner) + if err != nil { + return "", err + } + if n, _ := res.RowsAffected(); n == 0 { + return "", ErrNotFound + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return "", err + } + entrance := "must-change" + if scope == SessionScopeSetPasswordOnly { + entrance = "recovery" + } + if err := s.insertAccountEvent(tx, owner, entrance, owner, "password_set", entrance); err != nil { + return "", err + } + return owner, s.commitAccountMutation(tx, AccountWrite{Owner: owner, Actor: owner, Action: "account.set_password"}) +} + +// ChangeUserPasswordActive 主动改密单事务:校验当前密码 → 更新(带认证版本乐观 +// 检查)→ bump auth_version → 吊销其他会话(保留当前)→ 审计。中途失败无部分成功。 +func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var hash string + var ver int + var mustChange bool + err = tx.QueryRow(s.q(`SELECT password_hash,auth_version,must_change_password FROM users WHERE owner=?`), owner). + Scan(&hash, &ver, &mustChange) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if mustChange || hash == "" { + // 强制态必须走受限设密路径,不在此处用旧密码换新密码。 + return ErrConflict + } + if !VerifyPassword(hash, current) { + return ErrBadCredentials + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=? AND auth_version=?`), + passwordHash, nowISO(), false, owner, ver) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrConflict // 并发改密:版本已前进 + } + if keepToken != "" { + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil { + return err + } + } else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return err + } + if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil { + return err + } + return s.commitAccountMutation(tx, AccountWrite{Owner: owner, Actor: owner, Action: "account.change_password"}) +} + +// AdminResetUserPassword 管理员/CLI 重置密码单事务:写 hash、must_change=true、 +// bump auth_version、作废未用登录 Key、吊销全部会话、审计(actor 为操作者)。 +func (s *Store) AdminResetUserPassword(owner, passwordHash, actor, entrance string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), + passwordHash, nowISO(), true, owner) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return err + } + if entrance == "" { + entrance = entranceOf(actor) + } + if err := s.insertAccountEvent(tx, actor, entrance, owner, "admin_password_reset", ""); err != nil { + return err + } + return s.commitAccountMutation(tx, AccountWrite{Owner: owner, Actor: actor, Action: "account.reset_password"}) } // CountUnusedLoginKeys 未使用登录 Key 计数(list 展示)。 diff --git a/internal/store/login_keys_test.go b/internal/store/login_keys_test.go index dc048b8..94f0974 100644 --- a/internal/store/login_keys_test.go +++ b/internal/store/login_keys_test.go @@ -5,6 +5,7 @@ import ( "sync" "sync/atomic" "testing" + "time" ) func TestOpenExistingSQLiteMissing(t *testing.T) { @@ -42,7 +43,7 @@ func TestLoginKeyRedeemOnceAndSetPassword(t *testing.T) { if err != nil { t.Fatal(err) } - if err := st.CompleteSetPassword("alice", hash); err != nil { + if _, err := st.CompleteSetPasswordChecked(tok, hash); err != nil { t.Fatal(err) } if _, _, ok := st.SessionInfo(tok); ok { @@ -52,6 +53,17 @@ func TestLoginKeyRedeemOnceAndSetPassword(t *testing.T) { if !ok || stAuth.MustChangePassword || !stAuth.HasPassword { t.Fatalf("login after set: ok=%v %+v", ok, stAuth) } + events, err := st.ListAccountEvents("alice", 10) + if err != nil { + t.Fatal(err) + } + actions := map[string]bool{} + for _, e := range events { + actions[e.Action] = true + } + if !actions["login_key_issued"] || !actions["login_key_redeemed"] || !actions["password_set"] { + t.Fatalf("missing account events: %+v", events) + } } func TestLoginKeyConcurrentRedeem(t *testing.T) { @@ -83,3 +95,268 @@ func TestLoginKeyConcurrentRedeem(t *testing.T) { t.Fatalf("expected 1 success, got %d", n) } } + +// 补发:旧 Key 未用则撤销;已兑换出的受限会话一并作废,最新 Key 才能设密。 +func TestIssueLoginKeyRevokesDerivedSessions(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "reissue.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("carol", "", "", false); err != nil { + t.Fatal(err) + } + old1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + oldTok, _, err := st.RedeemLoginKey(old1) + if err != nil { + t.Fatal(err) + } + old2, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + // 旧 Key 的受限会话已作废。 + if _, _, ok := st.SessionInfo(oldTok); ok { + t.Fatal("restricted session from superseded key should be revoked") + } + // 旧 Key 未用场景同样撤销(old1 已消费,此处验证 old2 补发路径)。 + new1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + if _, _, err := st.RedeemLoginKey(old2); err == nil { + t.Fatal("revoked (superseded) key must not redeem") + } + tok, _, err := st.RedeemLoginKey(new1) + if err != nil { + t.Fatal(err) + } + hash, err := HashPassword("carol-final-99") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(tok, hash); err != nil { + t.Fatal(err) + } +} + +// 改密事务内复验:会话被作废后不得继续设密;正常受限会话设密后认证版本前进。 +func TestCompleteSetPasswordRevalidatesSession(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "revalidate.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("dave", "", "", false); err != nil { + t.Fatal(err) + } + plain, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + tok, _, err := st.RedeemLoginKey(plain) + if err != nil { + t.Fatal(err) + } + // 会话被签发补发作废后,设密必须失败。 + if _, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0); err != nil { + t.Fatal(err) + } + hash, err := HashPassword("dave-final-99") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(tok, hash); err == nil { + t.Fatal("set password with revoked session must fail") + } + stAuth, _ := st.GetUserAuthState("dave") + if stAuth.HasPassword { + t.Fatal("password must be unchanged after rejected set") + } + // 新 Key 兑换出的会话可以设密,且版本前进、旧密码不适用(未设密前无旧密码)。 + plain2, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + tok2, _, err := st.RedeemLoginKey(plain2) + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(tok2, hash); err != nil { + t.Fatal(err) + } + if _, _, ok := st.SessionInfo(tok2); ok { + t.Fatal("all sessions must be destroyed after set") + } +} + +// 主动改密:错旧密码拒绝;并发版本竞争单胜;当前会话保留、其他吊销。 +func TestChangeUserPasswordActive(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "active.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + hash, err := HashPassword("original-pass-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("erin", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("erin"); err != nil { + t.Fatal(err) + } + keepTok, err := st.CreateSession("erin") + if err != nil { + t.Fatal(err) + } + otherTok, err := st.CreateSession("erin") + if err != nil { + t.Fatal(err) + } + newHash, err := HashPassword("rotated-pass-2") + if err != nil { + t.Fatal(err) + } + if err := st.ChangeUserPasswordActive("erin", "wrong-old-pass", newHash, keepTok); err != ErrBadCredentials { + t.Fatalf("wrong current: %v", err) + } + if err := st.ChangeUserPasswordActive("erin", "original-pass-1", newHash, keepTok); err != nil { + t.Fatal(err) + } + if _, _, ok := st.SessionInfo(keepTok); !ok { + t.Fatal("current session must survive active change") + } + if _, _, ok := st.SessionInfo(otherTok); ok { + t.Fatal("other sessions must be revoked") + } + stAuth, ok := st.VerifyLogin("erin", "rotated-pass-2") + if !ok || stAuth.MustChangePassword { + t.Fatalf("login with new password: ok=%v %+v", ok, stAuth) + } + if _, ok := st.VerifyLogin("erin", "original-pass-1"); ok { + t.Fatal("old password must fail after change") + } +} + +// 登录竞态:密码验证与建会话之间账户被重置(版本前进)时,会话创建必须失败。 +func TestLoginRaceBlockedByAuthVersion(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "race.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + hash, err := HashPassword("race-old-pass") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("frank", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("frank"); err != nil { + t.Fatal(err) + } + _, ver, ok := st.VerifyLoginWithVersion("frank", "race-old-pass") + if !ok { + t.Fatal("verify should pass before reset") + } + resetHash, err := HashPassword("race-new-pass") + if err != nil { + t.Fatal(err) + } + if err := st.AdminResetUserPassword("frank", resetHash, "admin", "admin-http"); err != nil { + t.Fatal(err) + } + if _, err := st.CreateSessionVerified("frank", SessionScopeFull, time.Minute, ver); err != ErrConflict { + t.Fatalf("stale version session creation: %v", err) + } + // 新密码 + 新版本可建会话。 + _, ver2, ok := st.VerifyLoginWithVersion("frank", "race-new-pass") + if !ok { + t.Fatal("verify with new password should pass") + } + if ver2 == ver { + t.Fatal("auth version should have advanced after reset") + } + if _, err := st.CreateSessionVerified("frank", SessionScopeFull, time.Minute, ver2); err != nil { + t.Fatalf("fresh version session creation: %v", err) + } +} + +// 管理员重置:版本前进、会话与未用 Key 全部作废、must_change 置位。 +func TestAdminResetUserPasswordInvalidates(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "adminreset.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + hash, err := HashPassword("admin-old-pass") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("gina", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("gina"); err != nil { + t.Fatal(err) + } + tok, err := st.CreateSession("gina") + if err != nil { + t.Fatal(err) + } + plain, _, err := st.IssueLoginKey("gina", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + newHash, err := HashPassword("admin-new-pass") + if err != nil { + t.Fatal(err) + } + if err := st.AdminResetUserPassword("gina", newHash, "admin", "admin-http"); err != nil { + t.Fatal(err) + } + if _, _, ok := st.SessionInfo(tok); ok { + t.Fatal("sessions must be revoked after admin reset") + } + if _, _, err := st.RedeemLoginKey(plain); err == nil { + t.Fatal("unused login key must be revoked after admin reset") + } + stAuth, ok := st.VerifyLogin("gina", "admin-new-pass") + if !ok || !stAuth.MustChangePassword { + t.Fatalf("reset login state: ok=%v %+v", ok, stAuth) + } +} + +// RevokeLoginKeyCascade:撤销 Key 并作废该用户受限会话;Key 元数据保留可见。 +func TestRevokeLoginKeyCascade(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "cascade.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("hana", "", "", false); err != nil { + t.Fatal(err) + } + plain, meta, err := st.IssueLoginKey("hana", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + tok, _, err := st.RedeemLoginKey(plain) + if err != nil { + t.Fatal(err) + } + if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin"); err != nil { + t.Fatal(err) + } + if _, _, ok := st.SessionInfo(tok); ok { + t.Fatal("restricted session must be revoked by cascade") + } + keys, err := st.ListLoginKeys("hana") + if err != nil || len(keys) == 0 || keys[0].Status != LoginKeyStatusConsumed { + t.Fatalf("key list after cascade: %v %+v", err, keys) + } +} diff --git a/internal/store/messages.go b/internal/store/messages.go index 36c2042..58724f2 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -266,6 +266,10 @@ func (s *Store) ChildRuns(runID string) ([]core.Run, error) { // 成功后回填 evs[i].Seq。事务内锁定会话序列:PG 用事务级咨询锁(跨实例安全), // SQLite 单写连接天然串行。空批次为空操作。 func (s *Store) AppendEvents(conversationID string, evs []core.StreamEvent) (int64, error) { + return s.appendEvents(conversationID, evs, "", "") +} + +func (s *Store) appendEvents(conversationID string, evs []core.StreamEvent, owner, producer string) (int64, error) { if len(evs) == 0 { return 0, nil } @@ -298,6 +302,13 @@ func (s *Store) AppendEvents(conversationID string, evs []core.StreamEvent) (int if _, err := tx.Exec(s.q(sb.String()), args...); err != nil { return 0, err } + if producer != "" { + for i, e := range evs { + if err := s.saveProjectEventProof(tx, conversationID, base.Int64+int64(i)+1, e, owner, producer); err != nil { + return 0, err + } + } + } // KTD6:事件提交与 NOTIFY 同事务(outbox 语义的前半——通知不早于事件 // 可见;后半的命令 outbox 走 EnqueueCommandInTx 由批准路径使用)。 if s.dialect.name() == "postgres" { diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 31869f9..3ec3de8 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -778,7 +778,77 @@ func init() { }, "postgres": { `DROP TABLE IF EXISTS agent_system_identity`, }}}, - migration{version: 28, name: "account_audit", + // 登录恢复流程(2026-10-13):认证版本与会话绑定,堵「旧密码验证在途 → + // 恢复完成 → 旧请求又建会话」竞态;账户事件审计与密码变更同事务落库。 + migration{version: 28, name: "auth_version_account_events", + up: map[string][]string{"sqlite": { + `ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE sessions ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`, + `CREATE TABLE IF NOT EXISTS account_events ( + id TEXT PRIMARY KEY, + time TEXT NOT NULL, + actor TEXT NOT NULL DEFAULT '', + entrance TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL, + action TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '' + )`, + `CREATE INDEX IF NOT EXISTS idx_account_events_owner_time ON account_events(owner, time)`, + }, "postgres": { + `ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE sessions ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`, + `CREATE TABLE IF NOT EXISTS account_events ( + id TEXT PRIMARY KEY, + time TEXT NOT NULL, + actor TEXT NOT NULL DEFAULT '', + entrance TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL, + action TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '' + )`, + `CREATE INDEX IF NOT EXISTS idx_account_events_owner_time ON account_events(owner, time)`, + }}, + down: map[string][]string{"sqlite": { + `DROP INDEX IF EXISTS idx_account_events_owner_time`, + `DROP TABLE IF EXISTS account_events`, + `ALTER TABLE sessions DROP COLUMN auth_version`, + `ALTER TABLE users DROP COLUMN auth_version`, + }, "postgres": { + `DROP INDEX IF EXISTS idx_account_events_owner_time`, + `DROP TABLE IF EXISTS account_events`, + `ALTER TABLE sessions DROP COLUMN auth_version`, + `ALTER TABLE users DROP COLUMN auth_version`, + }}}, + // 登录恢复收尾(2026-10-13):兑换入口的跨实例失败退避(共享库窗口计数)。 + migration{version: 29, name: "redeem_attempts", + up: map[string][]string{"sqlite": { + `CREATE TABLE IF NOT EXISTS redeem_attempts ( + ip TEXT NOT NULL, + at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_redeem_attempts_ip_at ON redeem_attempts(ip, at)`, + }, "postgres": { + `CREATE TABLE IF NOT EXISTS redeem_attempts ( + ip TEXT NOT NULL, + at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_redeem_attempts_ip_at ON redeem_attempts(ip, at)`, + }}, + down: map[string][]string{"sqlite": { + `DROP INDEX IF EXISTS idx_redeem_attempts_ip_at`, + `DROP TABLE IF EXISTS redeem_attempts`, + }, "postgres": { + `DROP INDEX IF EXISTS idx_redeem_attempts_ip_at`, + `DROP TABLE IF EXISTS redeem_attempts`, + }}}, + migration{version: 30, name: "project_event_publication_proofs", + up: map[string][]string{ + "sqlite": {`CREATE TABLE project_event_shares(channel TEXT NOT NULL,seq INTEGER NOT NULL,event_type TEXT NOT NULL,payload_sha256 TEXT NOT NULL,owner TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id TEXT NOT NULL,producer TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(channel,seq),FOREIGN KEY(channel,seq) REFERENCES events(conversation_id,seq) ON DELETE CASCADE)`}, + "postgres": {`CREATE TABLE project_event_shares(channel TEXT NOT NULL,seq BIGINT NOT NULL,event_type TEXT NOT NULL,payload_sha256 TEXT NOT NULL,owner TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id TEXT NOT NULL,producer TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(channel,seq),FOREIGN KEY(channel,seq) REFERENCES events(conversation_id,seq) ON DELETE CASCADE)`}, + }, + down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS project_event_shares`}, "postgres": {`DROP TABLE IF EXISTS project_event_shares`}}, + }, + migration{version: 31, name: "account_audit", up: map[string][]string{"sqlite": { `ALTER TABLE audit_events ADD COLUMN entry TEXT NOT NULL DEFAULT ''`, `ALTER TABLE audit_events ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`, diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index e43cd02..7c2f3dc 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -32,7 +32,10 @@ var migrationBreaksOldReaders = map[int]bool{ 25: false, 26: false, 27: false, - 28: false, // account_audit 给 audit_events 增加带默认值的列,旧制品按列名读取 + 28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响 + 29: false, // redeem_attempts:新表,旧读者不受影响 + 30: false, // project_event_publication_proofs:新表,旧读者不受影响 + 31: false, // account_audit 给 audit_events 增加带默认值的列,旧制品按列名读取 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 diff --git a/internal/store/project_events.go b/internal/store/project_events.go new file mode 100644 index 0000000..ea6e7c4 --- /dev/null +++ b/internal/store/project_events.go @@ -0,0 +1,147 @@ +package store + +import ( + core "agent-system-core" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" +) + +// ProjectEventAccess is trusted read metadata; payload claims never establish it. +type ProjectEventAccess struct { + Owner string + Shared bool + Producer string +} +type eventResource struct{ owner, project, conversation, kind, id string } +type eventQuery interface{ QueryRow(string, ...any) *sql.Row } + +func cardEventID(e core.StreamEvent) (string, error) { + switch e.Type { + case core.EvCardCreated, core.EvCardUpdated, core.EvCardStatus, core.EvCardResolved: + default: + return "", nil + } + var payload struct { + Card struct { + ID string `json:"id"` + } `json:"card"` + CardID string `json:"cardId"` + } + if err := json.Unmarshal(e.Payload, &payload); err != nil { + return "", err + } + switch e.Type { + case core.EvCardCreated, core.EvCardUpdated: + return payload.Card.ID, nil + case core.EvCardStatus, core.EvCardResolved: + return payload.CardID, nil + } + return "", nil +} +func eventPayloadHash(payload []byte) string { + hash := sha256.Sum256(payload) + return hex.EncodeToString(hash[:]) +} + +func (s *Store) projectEventResource(q eventQuery, e core.StreamEvent) (eventResource, error) { + var resource eventResource + id, err := cardEventID(e) + if err != nil { + return resource, err + } + switch e.Type { + case core.EvCardCreated, core.EvCardUpdated, core.EvCardStatus, core.EvCardResolved: + if id == "" { + return resource, ErrNotFound + } + } + if id != "" { + resource.kind, resource.id = "card", id + err = q.QueryRow(s.q(`SELECT owner,project_id,conversation_id FROM activity_cards WHERE id=?`), id).Scan(&resource.owner, &resource.project, &resource.conversation) + } else if e.Type == core.EvArtifactCreated { + var payload struct { + Artifact struct { + ID string `json:"id"` + } `json:"artifact"` + } + if err := json.Unmarshal(e.Payload, &payload); err != nil { + return resource, err + } + if payload.Artifact.ID == "" { + return resource, ErrNotFound + } + resource.kind, resource.id = "artifact", payload.Artifact.ID + err = q.QueryRow(s.q(`SELECT owner,project_id FROM artifacts WHERE id=?`), resource.id).Scan(&resource.owner, &resource.project) + } else if e.RunID != "" { + resource.kind, resource.id = "run", e.RunID + err = q.QueryRow(s.q(`SELECT owner,project_id,conversation_id FROM runs WHERE id=?`), e.RunID).Scan(&resource.owner, &resource.project, &resource.conversation) + } else { + return resource, ErrNotFound + } + if errors.Is(err, sql.ErrNoRows) { + return resource, ErrNotFound + } + if err != nil { + return resource, err + } + project, ok := core.ProjectFromChannel(e.ConversationID) + if !ok || resource.owner == "" || resource.project != project || (e.ProjectID != "" && e.ProjectID != project) { + return resource, ErrNotFound + } + return resource, nil +} + +// ResolveProjectEventAccess resolves immutable resource ownership and verifies an +// exact internal publication record. Old events retain their private default. +func (s *Store) ResolveProjectEventAccess(e core.StreamEvent) (ProjectEventAccess, error) { + resource, err := s.projectEventResource(s.db, e) + if err != nil { + return ProjectEventAccess{}, err + } + access := ProjectEventAccess{Owner: resource.owner} + var owner, kind, id, producer, typ, hash string + err = s.db.QueryRow(s.q(`SELECT owner,resource_type,resource_id,producer,event_type,payload_sha256 FROM project_event_shares WHERE channel=? AND seq=?`), e.ConversationID, e.Seq).Scan(&owner, &kind, &id, &producer, &typ, &hash) + if errors.Is(err, sql.ErrNoRows) { + return access, nil + } + if err != nil { + return ProjectEventAccess{}, err + } + if owner != resource.owner || kind != resource.kind || id != resource.id || strings.TrimSpace(producer) == "" || typ != string(e.Type) || hash != eventPayloadHash(e.Payload) || resource.conversation != "" { + return ProjectEventAccess{}, ErrNotFound + } + access.Shared, access.Producer = true, producer + return access, nil +} + +// AppendProjectSharedEvents is an internal publication boundary, not an API or +// model tool. Events and their exact sharing proofs commit together. Caller +// identity must own a project-level card; private conversation cards cannot be +// published through this structural-card path. +func (s *Store) AppendProjectSharedEvents(owner, producer, channel string, events []core.StreamEvent) (int64, error) { + if owner == "" || strings.TrimSpace(producer) == "" { + return 0, fmt.Errorf("store: shared event requires owner and producer") + } + if _, ok := core.ProjectFromChannel(channel); !ok { + return 0, ErrNotFound + } + return s.appendEvents(channel, events, owner, producer) +} + +func (s *Store) saveProjectEventProof(tx *sql.Tx, channel string, seq int64, e core.StreamEvent, owner, producer string) error { + e.ConversationID = channel + resource, err := s.projectEventResource(tx, e) + if err != nil { + return err + } + if resource.kind != "card" || resource.owner != owner || resource.conversation != "" { + return ErrNotFound + } + _, err = tx.Exec(s.q(`INSERT INTO project_event_shares(channel,seq,event_type,payload_sha256,owner,resource_type,resource_id,producer,created_at) VALUES(?,?,?,?,?,?,?,?,?)`), channel, seq, string(e.Type), eventPayloadHash(e.Payload), resource.owner, resource.kind, resource.id, producer, nowISO()) + return err +} diff --git a/internal/store/project_events_test.go b/internal/store/project_events_test.go new file mode 100644 index 0000000..2c69aab --- /dev/null +++ b/internal/store/project_events_test.go @@ -0,0 +1,140 @@ +package store + +import ( + core "agent-system-core" + "encoding/json" + "testing" + "time" +) + +func storedProjectCardEvent(t *testing.T, st *Store, owner, project, id string) core.StreamEvent { + t.Helper() + card := core.ActivityCard{ID: id, ProjectID: project, Title: "PRIVATE", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + if err := st.SaveCard(owner, &card, ""); err != nil { + t.Fatal(err) + } + payload, _ := json.Marshal(core.CardPayload{Card: card}) + return core.StreamEvent{ConversationID: core.ProjectChannelKey(project), ProjectID: project, Type: core.EvCardCreated, Payload: payload, TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1} +} +func TestProjectEventSharedProofBindsExactEvent(t *testing.T) { + st := openTestStore(t) + p, _ := st.CreateProject("alice", "Project") + e := storedProjectCardEvent(t, st, "alice", p.ID, "card-a") + private := []core.StreamEvent{e} + if _, err := st.AppendEvents(e.ConversationID, private); err != nil { + t.Fatal(err) + } + access, err := st.ResolveProjectEventAccess(private[0]) + if err != nil || access.Owner != "alice" || access.Shared { + t.Fatalf("private access=%+v %v", access, err) + } + shared := []core.StreamEvent{e} + if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", e.ConversationID, shared); err != nil { + t.Fatal(err) + } + access, err = st.ResolveProjectEventAccess(shared[0]) + if err != nil || !access.Shared || access.Owner != "alice" { + t.Fatalf("shared proof=%+v %v", access, err) + } + access, err = st.ResolveProjectEventAccess(private[0]) + if err != nil || access.Shared { + t.Fatal("publication retroactively shared private history") + } + for _, mutate := range []func(*core.StreamEvent){func(e *core.StreamEvent) { e.Type = core.EvCardUpdated }, func(e *core.StreamEvent) { e.Payload = append(e.Payload, ' ') }, func(e *core.StreamEvent) { e.Seq += 100 }, func(e *core.StreamEvent) { e.ConversationID = "project:another" }, func(e *core.StreamEvent) { e.ProjectID = "another" }} { + changed := shared[0] + mutate(&changed) + access, err = st.ResolveProjectEventAccess(changed) + if err == nil && access.Shared { + t.Fatal("mutated event retained sharing") + } + } + bad := []core.StreamEvent{e} + before, _ := st.LastEventSeq(e.ConversationID) + if _, err := st.AppendProjectSharedEvents("bob", "plugin:rdm:structure", e.ConversationID, bad); err == nil { + t.Fatal("other owner issued publication") + } + after, _ := st.LastEventSeq(e.ConversationID) + if after != before { + t.Fatal("rejected publication appended event") + } +} +func TestProjectEventMigrationPreservesPrivateHistory(t *testing.T) { + st := openTestStore(t) + if err := st.MigrateDown(29); err != nil { + t.Fatal(err) + } + p, _ := st.CreateProject("alice", "Legacy") + e := storedProjectCardEvent(t, st, "alice", p.ID, "legacy-card") + events := []core.StreamEvent{e} + if _, err := st.AppendEvents(e.ConversationID, events); err != nil { + t.Fatal(err) + } + before, _ := st.EventsAfter(e.ConversationID, 0, 100) + if err := st.migrate(); err != nil { + t.Fatal(err) + } + after, _ := st.EventsAfter(e.ConversationID, 0, 100) + b, _ := json.Marshal(before) + a, _ := json.Marshal(after) + if string(a) != string(b) { + t.Fatal("upgrade changed private event history") + } + access, err := st.ResolveProjectEventAccess(after[0]) + if err != nil || access.Owner != "alice" || access.Shared { + t.Fatalf("legacy privacy=%+v %v", access, err) + } +} + +func TestProjectSharedEventBatchRollsBackProofAndEvents(t *testing.T) { + st := openTestStore(t) + p, _ := st.CreateProject("alice", "Project") + a := storedProjectCardEvent(t, st, "alice", p.ID, "card-a") + b := storedProjectCardEvent(t, st, "bob", p.ID, "card-b") + if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", a.ConversationID, []core.StreamEvent{a, b}); err == nil { + t.Fatal("mixed owner batch published") + } + var count int + for _, table := range []string{"events", "project_event_shares"} { + if err := st.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 0 { + t.Fatalf("partial %s publication: count=%d error=%v", table, count, err) + } + } +} + +func TestProjectEventInvalidProofAndMissingOwnerFailClosed(t *testing.T) { + st := openTestStore(t) + p, _ := st.CreateProject("alice", "Project") + e := storedProjectCardEvent(t, st, "alice", p.ID, "card-a") + shared := []core.StreamEvent{e} + if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", e.ConversationID, shared); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`UPDATE project_event_shares SET producer=''`); err != nil { + t.Fatal(err) + } + if access, err := st.ResolveProjectEventAccess(shared[0]); err == nil || access.Shared { + t.Fatal("corrupted publication proof was accepted") + } + if _, err := st.db.Exec(`UPDATE activity_cards SET owner=''`); err != nil { + t.Fatal(err) + } + if _, err := st.ResolveProjectEventAccess(shared[0]); err == nil { + t.Fatal("missing resource owner was accepted") + } +} + +func TestProjectSharingRejectsPrivateConversationCards(t *testing.T) { + st := openTestStore(t) + p, _ := st.CreateProject("alice", "Project") + e := storedProjectCardEvent(t, st, "alice", p.ID, "card-a") + if _, err := st.db.Exec(`UPDATE activity_cards SET conversation_id='private-conversation'`); err != nil { + t.Fatal(err) + } + if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", e.ConversationID, []core.StreamEvent{e}); err == nil { + t.Fatal("private conversation card publicly marked") + } + events, err := st.EventsAfter(e.ConversationID, 0, 100) + if err != nil || len(events) != 0 { + t.Fatal("rejected conversation publication appended events") + } +} 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/search_postgres.go b/internal/store/search_postgres.go index 0620c9f..f5ffea5 100644 --- a/internal/store/search_postgres.go +++ b/internal/store/search_postgres.go @@ -53,19 +53,28 @@ func (s *Store) searchKnowledgePG(owner string, q core.RetrievalQuery) ([]core.R // searchKnowledgePGShared EXT-8 切片 2 共享版:他人 owner 的行 + 谓词后置判定 // (allow 回调由上层注入;排序与自检索同构)。 func (s *Store) searchKnowledgePGShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) { + return s.searchKnowledgePGSharedCandidates(requester, q, pred, true) +} + +func (s *Store) searchKnowledgePGSharedCandidates(requester string, q core.RetrievalQuery, pred knowledgePred, bounded bool) ([]core.RetrievalHit, error) { topK := q.TopK if topK <= 0 { topK = 5 } text := q.Text - rows, err := s.db.Query(s.q(` + query := ` SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, ts_rank(k.tsv, websearch_to_tsquery('simple', ?)) AS r FROM knowledge k WHERE (k.owner<>? AND k.owner<>'') AND (? = '' OR k.tsv @@ websearch_to_tsquery('simple', ?) OR k.chunk LIKE ? OR k.title LIKE ?) - ORDER BY r DESC, k.created_at DESC - LIMIT ?`), text, requester, text, text, "%"+text+"%", "%"+text+"%", topK*2) + ORDER BY r DESC, k.created_at DESC` + args := []any{text, requester, text, text, "%" + text + "%", "%" + text + "%"} + if bounded { + query += " LIMIT ?" + args = append(args, topK*2) + } + rows, err := s.db.Query(s.q(query), args...) if err != nil { return nil, err } diff --git a/internal/store/store.go b/internal/store/store.go index 98f526e..5d30b74 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -196,7 +196,7 @@ func openSQLiteFile(path string, createArtifacts, migrateSchema bool) (*Store, e } // SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。 db.SetMaxOpenConns(1) - return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts, migrateSchema) + return finishOpenMode(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts, migrateSchema) } // OpenPostgres 打开 PostgreSQL 档存储并执行迁移(默认连接预算)。 @@ -213,6 +213,7 @@ func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*St // OpenPostgresWithOptions 按 KTD12 连接预算打开 PG 档;QueryTimeoutSec>0 时 // 以 statement_timeout 运行参数附着到 DSN(对 URL 与键值两种形式均生效)。 +// blobs 为 nil(含带类型的空指针,例如未配置端点时的 (*blobstore.S3)(nil))时回落本地目录。 func OpenPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store) (*Store, error) { return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true, true) } @@ -237,7 +238,7 @@ func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs b if pool.ConnMaxLifetimeSec > 0 { db.SetConnMaxLifetime(time.Duration(pool.ConnMaxLifetimeSec) * time.Second) } - return finishOpen(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts, migrateSchema) + return finishOpenMode(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts, migrateSchema) } // withPGRuntimeParam 向 DSN 追加运行时参数(libpq 语义:URL 查询串或键值对)。 @@ -263,8 +264,14 @@ func AppendPGRuntimeParam(dsn, key, value string) string { return dsn + sep + kv } -func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts, migrateSchema bool) (*Store, error) { - if blobs == nil { +// Preserve the existing initialization helper contract; maintenance opens select +// nonmigrating mode explicitly through finishOpenMode. +func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts bool) (*Store, error) { + return finishOpenMode(db, d, artifactsDir, blobs, pool, createArtifacts, true) +} + +func finishOpenMode(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts, migrateSchema bool) (*Store, error) { + if blobstore.IsNil(blobs) { var err error if createArtifacts { blobs, err = blobstore.NewLocal(artifactsDir) diff --git a/internal/store/typed_nil_blob_test.go b/internal/store/typed_nil_blob_test.go new file mode 100644 index 0000000..e17c6b2 --- /dev/null +++ b/internal/store/typed_nil_blob_test.go @@ -0,0 +1,121 @@ +package store + +import ( + "bytes" + "database/sql" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "agent-system-backend/internal/blobstore" + core "agent-system-core" +) + +func TestFinishOpenTypedNilS3UsesLocalArtifacts(t *testing.T) { + dir := t.TempDir() + st := openSQLiteWithBlobs(t, dir, (*blobstore.S3)(nil)) + assertLocalArtifactRoundTrip(t, st, filepath.Join(dir, "artifacts")) +} + +func TestFinishOpenKeepsConfiguredBlobStore(t *testing.T) { + dir := t.TempDir() + s3, err := blobstore.NewS3("http://127.0.0.1:9/bucket", "us-east-1", "access", "secret", "art/") + if err != nil { + t.Fatal(err) + } + st := openSQLiteWithBlobs(t, dir, s3) + if st.BlobStore() != s3 { + t.Fatalf("configured blob store was replaced: %T", st.BlobStore()) + } +} + +func TestOpenPostgresTypedNilS3UsesLocalArtifacts(t *testing.T) { + dsn := os.Getenv("AGENT_PG_TEST_DSN") + if dsn == "" { + t.Skip("AGENT_PG_TEST_DSN 未设置:真实 PG 门禁由 CI 的必跑 job 覆盖") + } + schema := "t_" + strings.ToLower(newID("s")) + "_" + strconv.Itoa(os.Getpid()) + admin, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + ensurePGTrgm(t, admin) + if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil { + admin.Close() + t.Fatalf("create schema: %v", err) + } + t.Cleanup(func() { + _, _ = admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") + admin.Close() + }) + arts := filepath.Join(t.TempDir(), "artifacts") + st, err := OpenPostgresWithOptions(withTestSearchPath(dsn, schema), arts, PoolOptions{}, (*blobstore.S3)(nil)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + assertLocalArtifactRoundTrip(t, st, arts) +} + +func openSQLiteWithBlobs(t *testing.T, dir string, blobs blobstore.Store) *Store { + t.Helper() + dbPath := filepath.Join(dir, "test.db") + dsn := "file:" + dbPath + "?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_txlock=immediate" + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + st, err := finishOpen(db, sqliteDialect{}, filepath.Join(dir, "artifacts"), blobs, PoolOptions{}, true) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func assertLocalArtifactRoundTrip(t *testing.T, st *Store, arts string) { + t.Helper() + if _, ok := st.BlobStore().(*blobstore.Local); !ok { + t.Fatalf("unset blob store must fall back to local, got %T", st.BlobStore()) + } + proj, err := st.CreateProject("k4", "k4") + if err != nil { + t.Fatal(err) + } + art := core.Artifact{Owner: "k4", ProjectID: proj.ID, Name: "a.txt", Kind: "data"} + if err := st.CreateArtifact(&art, []byte("hello")); err != nil { + t.Fatal(err) + } + got, err := st.ArtifactContentByID(art.ID) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, []byte("hello")) { + t.Fatalf("read back %q", got) + } + if _, err := os.Stat(filepath.Join(arts, art.ID)); err != nil { + t.Fatalf("artifact file missing under local dir: %v", err) + } + if err := st.UpdateArtifactContent("k4", art.ID, []byte("next")); err != nil { + t.Fatal(err) + } + got, err = st.ArtifactContent("k4", art.ID) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, []byte("next")) { + t.Fatalf("overwrite read back %q", got) + } + if err := st.DeleteArtifact("k4", art.ID); err != nil { + t.Fatal(err) + } + if _, err := st.ArtifactContentByID(art.ID); err != ErrNotFound { + t.Fatalf("deleted artifact content: %v", err) + } + if _, err := os.Stat(filepath.Join(arts, art.ID)); !os.IsNotExist(err) { + t.Fatalf("deleted artifact file still present: %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 93939a0..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 布尔)之上的 @@ -83,9 +87,13 @@ type Registry interface { } // AccountEntry 最小账号目录行(U3/KTD2):owner+有效态——授权管理所需的最小 -// 身份;姓名/邮箱/联系方式一律不下发(隐私边界)。 +// 身份。2026-10-13 P3 扩展(提案 rdm-settings-members-duties-perms-increment): +// Name 向授权管理读面下发(同名区分与按姓名查找是成员管理必需;扩展申请四要素 +// 见 Dev 任务卡 feature/rdm-increment-settings-duties)。邮箱/联系方式仍一律 +// 不下发(隐私边界不变)。 type AccountEntry struct { Owner string `json:"owner"` + Name string `json:"name,omitempty"` Valid bool `json:"valid"` } @@ -96,6 +104,14 @@ type AccountLister interface { ListAccounts(cursor string, limit int) (entries []AccountEntry, next string, err error) } +// AccountSearcher EXT-19 扩展可选能力(P3,2026-10-13):按账号或姓名检索账号 +// 目录,供人员选择器的输入搜索。query 匹配账号子串或姓名子串(大小写不敏感; +// 空串=不限,返回首页候选)。仅授权管理读面使用;未实现时下游回落 +// AccountLister 分页浏览。 +type AccountSearcher interface { + SearchAccounts(query string, limit int) (entries []AccountEntry, err error) +} + // AdminZoneSpec 管理区分区声明(WP3-I3):ID 是分区身份(建议形如 // .
,前端按 ID 映射内置分区组件);Title/Icon/Desc 为展示 // 元数据(icon 取基座前端 IconName 词表,未知值前端回退通用图标);Order diff --git a/pkg/extension/projects.go b/pkg/extension/projects.go new file mode 100644 index 0000000..d0f5042 --- /dev/null +++ b/pkg/extension/projects.go @@ -0,0 +1,36 @@ +package extension + +import ( + core "agent-system-core" + "context" +) + +// ProjectHost is an optional actor-scoped host projection capability. Metadata +// can be shared by an authorizer; registered-lifecycle metadata masks credentials. +// OwnedProject remains an owner-only management lookup; 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 a893299..7d88ba4 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -1,6 +1,8 @@ AccountEntry AccountLister AccountLister.ListAccounts +AccountSearcher +AccountSearcher.SearchAccounts AdminProvider AdminProvider.IsAdmin AdminZoneSpec @@ -30,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