From 46073d30a575e115aeb9112fd8674160dc2134d5 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 01:22:57 +0000 Subject: [PATCH 01/16] Fall back to local artifacts when the blob store is a typed nil Why: an unset S3 client passed through the Store interface is not a nil interface, so uploads panic instead of using the local artifact directory. Evidence: TestIsNil and TestFinishOpenTypedNilS3UsesLocalArtifacts failed before this change and pass after it. The PostgreSQL case skips unless AGENT_PG_TEST_DSN is set. Impact: only an unset blob store falls back; a constructed S3 or local store is unchanged. Refs: internal/blobstore/blobstore.go internal/store/store.go Co-authored-by: Cursor --- internal/blobstore/blobstore.go | 16 ++++ internal/blobstore/nil_test.go | 29 ++++++ internal/store/store.go | 3 +- internal/store/typed_nil_blob_test.go | 121 ++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 internal/blobstore/nil_test.go create mode 100644 internal/store/typed_nil_blob_test.go 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/store/store.go b/internal/store/store.go index edfff68..ae22d78 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -212,6 +212,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) } @@ -263,7 +264,7 @@ func AppendPGRuntimeParam(dsn, key, value string) string { } func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts bool) (*Store, error) { - if blobs == nil { + 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) + } +} From 467718a519eb21980e61b8df541de53e94130e95 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 03:36:38 +0000 Subject: [PATCH 02/16] Harden login-key recovery flow with auth-version transactions Why: The login recovery contract (2026-10-13) requires admin-issued one-time keys, in-transaction revalidation of restricted sessions, a login race guard, and audited password writes; the previous implementation validated sessions outside the change-password transaction and let stale password verifications create sessions. Evidence: go test ./... green (SQLite); store tests switch to openTestStore so the CI test-pg job covers real PostgreSQL, including 100-way concurrent redeem single-winner and auth-version race cases (SELF, local run). Impact: New migration v28 (users/sessions.auth_version, account_events). New admin endpoints POST/GET /api/admin/users/{owner}/login-keys and DELETE .../login-keys/{id}. Login binds session creation to the verified auth version and creates restricted sessions for must-change accounts; redeem gains per-IP backoff; cookies gain Secure under TLS; active change-password and admin/CLI resets become single transactions that bump auth_version, revoke keys and sessions, and write audit events. Refs: modules/documents/docs/proposals/2026-10-12-svc-unified-deploy-and-login-key.md r2; Dev task feature/svc-login-recovery-flow --- cmd/storage-migrate/main.go | 1 + internal/app/app.go | 9 +- internal/server/accounts.go | 184 ++++++++++++--- internal/server/login_recovery_test.go | 311 +++++++++++++++++++++++++ internal/server/server.go | 9 +- internal/store/auth.go | 65 +++++- internal/store/login_keys.go | 278 ++++++++++++++++++++-- internal/store/login_keys_test.go | 279 +++++++++++++++++++++- internal/store/migrate.go | 41 ++++ internal/store/migrate_compat.go | 1 + 10 files changed, 1116 insertions(+), 62 deletions(-) create mode 100644 internal/server/login_recovery_test.go diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index cfee408..0bb6943 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -295,6 +295,7 @@ var copyOrder = []string{ "login_keys", // U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚) "agent_system_identity", + "account_events", } const controlDDL = ` diff --git a/internal/app/app.go b/internal/app/app.go index 314ad68..e4d095f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -800,7 +800,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 +811,7 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting return "", err } if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - if err := st.SetUserPassword(owner, hash); err != nil { - return err - } - st.DeleteUserSessions(owner, "") - return nil + return st.AdminResetUserPassword(owner, hash, "cli", "cli") }); err != nil { return "", err } diff --git a/internal/server/accounts.go b/internal/server/accounts.go index a753161..b1afaa5 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,43 @@ func loginClear(owner string) { delete(loginGuard.fails, owner) } -func setSessionCookie(w http.ResponseWriter, token string, maxAge int) { +// 兑换入口退避(进程内,按来源 IP;Key 无属主可先行归因,只能按来源限流)。 +var redeemGuard = struct { + sync.Mutex + fails map[string][]time.Time +}{fails: map[string][]time.Time{}} + +const redeemMaxFails = 10 + +func redeemLocked(ip string) bool { + redeemGuard.Lock() + defer redeemGuard.Unlock() + cut := time.Now().Add(-loginLockWindow) + kept := redeemGuard.fails[ip][:0] + for _, t := range redeemGuard.fails[ip] { + if t.After(cut) { + kept = append(kept, t) + } + } + redeemGuard.fails[ip] = kept + return len(kept) >= redeemMaxFails +} + +func redeemFail(ip string) { + redeemGuard.Lock() + defer redeemGuard.Unlock() + redeemGuard.fails[ip] = append(redeemGuard.fails[ip], time.Now()) +} + +// 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 +110,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 redeemLocked(ip) { + http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests) + return + } var req struct { Key string `json:"key"` } @@ -91,13 +134,15 @@ func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) { token, owner, err := s.st.RedeemLoginKey(strings.TrimSpace(req.Key)) if err != nil { if errors.Is(err, store.ErrConflict) { + redeemFail(ip) http.Error(w, "login key already used", http.StatusConflict) return } + redeemFail(ip) 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 +174,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 +209,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,42 +240,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.CompleteSetPassword(owner, hash); 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.SetUserPassword(owner, hash); 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 } - if err := s.st.ClearMustChangePassword(owner); err != nil { - writeStoreErr(w, err) - return - } - s.st.DeleteUserSessions(owner, tok) 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 { @@ -287,9 +346,12 @@ 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) { - if _, ok := s.requireAdmin(w, r); !ok { + actor, ok := s.requireAdmin(w, r) + if !ok { return } owner := r.PathValue("owner") @@ -309,17 +371,77 @@ func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) http.Error(w, err.Error(), http.StatusInternalServerError) return } - if err := s.st.SetUserPassword(owner, hash); err != nil { + if err := s.st.AdminResetUserPassword(owner, hash, actor, "admin-http"); err != nil { writeStoreErr(w, err) return } - s.st.DeleteUserSessions(owner, "") st, _ := s.st.GetUserAuthState(owner) writeJSON(w, http.StatusOK, map[string]any{ "owner": st.Owner, "mustChangePassword": st.MustChangePassword, }) } +// ---- 管理员:一次性登录 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/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/server.go b/internal/server/server.go index 3b70bb6..bb8db01 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -124,7 +124,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 { @@ -284,6 +284,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) @@ -1810,6 +1813,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/store/auth.go b/internal/store/auth.go index 0161cb7..0b99bfa 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -286,16 +286,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 } // ---- 会话 ---- @@ -305,8 +314,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 } @@ -319,9 +350,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/login_keys.go b/internal/store/login_keys.go index 3a3f4ad..2069a59 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" @@ -23,10 +33,10 @@ const ( SessionScopeFull = "full" SessionScopeSetPasswordOnly = "set_password_only" - defaultLoginKeyTTL = 30 * time.Minute - minLoginKeyTTL = 5 * time.Minute - maxLoginKeyTTL = 120 * time.Minute - restrictedSessionTTL = 10 * time.Minute + defaultLoginKeyTTL = 30 * time.Minute + minLoginKeyTTL = 5 * time.Minute + maxLoginKeyTTL = 120 * time.Minute + restrictedSessionTTL = 10 * time.Minute ) // LoginKeyMeta 对外可见元数据(无明文)。 @@ -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) @@ -79,10 +116,17 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio 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 + } if err := tx.Commit(); err != nil { return "", meta, err } @@ -90,7 +134,18 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio return plain, meta, nil } -// RevokeLoginKey 按凭据编号撤销未使用 Key。 +// 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 +158,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 +267,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,15 +291,122 @@ 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 { +// 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, tx.Commit() +} + +// 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() - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=? WHERE owner=?`), - passwordHash, nowISO(), false, owner) + 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 tx.Commit() +} + +// 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 } @@ -178,6 +420,12 @@ func (s *Store) CompleteSetPassword(owner, passwordHash string) error { 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 tx.Commit() } 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/migrate.go b/internal/store/migrate.go index 6dbac23..f36444c 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -778,6 +778,47 @@ func init() { }, "postgres": { `DROP TABLE IF EXISTS agent_system_identity`, }}}, + // 登录恢复流程(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`, + }}}, ) } diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index cd8e6fb..5eb403a 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -32,6 +32,7 @@ var migrationBreaksOldReaders = map[int]bool{ 25: false, 26: false, 27: false, + 28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 From 716fe9541cde0f1727e550807abedbf30b00b7e5 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 04:05:15 +0000 Subject: [PATCH 03/16] Enforce system-managed settings scope with admin system settings API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: P2——模型、密钥、Tavily、遥测与运行基础设施属系统受管项;user/project 层须整笔拒绝(含嵌套、点路径与子树覆盖),生效链剥除存量覆盖,管理员经新系统面统一配置。 Evidence: go test ./... 全绿;settings 单测覆盖嵌套/点路径/祖先标量拒绝、系统层生效、读面剥除;server e2e 覆盖 400/系统面读写/掩码;认证档 admin 门测试(普通 403、admin 200、密钥不泄漏)。 Impact: settings.go 受管标记与 mergeScope 拦截、Effective 增系统层、server 新增 GET/PUT /api/settings/system(无认证档本机即管理员)、SecretLookup 系统行回退、插件 schema 受管注入;测试基建 mock seed 与三处用例迁到系统层;svc.sh 播种改走系统面(仅 admin,无管理员时明确跳过)。Core gitlink 升至 9150e6f。 Refs: modules/documents/docs/proposals/2026-10-13-rdm-settings-members-duties-perms-increment.md P2 --- deps/agent-system-core | 2 +- internal/app/app.go | 10 ++- internal/rag/rag_test.go | 3 +- internal/server/auth_e2e_test.go | 76 ++++++++++++++++ internal/server/e2e_test.go | 140 +++++++++++++++++++---------- internal/server/server.go | 65 +++++++++++++- internal/settings/settings.go | 122 ++++++++++++++++++------- internal/settings/settings_test.go | 29 ++++-- 8 files changed, 358 insertions(+), 89 deletions(-) diff --git a/deps/agent-system-core b/deps/agent-system-core index ba289e7..9150e6f 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit ba289e7b1780a29855aef8477bf1ac49bd563eb2 +Subproject commit 9150e6f90b7ed360467492c6102d971a97c8a59a diff --git a/internal/app/app.go b/internal/app/app.go index 314ad68..ba8db5e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -295,7 +295,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) @@ -486,6 +491,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) 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/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/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/server.go b/internal/server/server.go index 3b70bb6..cbce1bd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -349,6 +349,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) @@ -1439,7 +1441,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 +1450,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 工具注册)---- diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 8c839f3..05dfd0a 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 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 hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 { + return &ErrManagedPath{Paths: hits} + } + } 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"]) } } From 386afcd4a63e0541e4dfe49a68054cbe975cd452 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 04:20:07 +0000 Subject: [PATCH 04/16] Point core pin at merged main 0f94e4b0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Core PR #30 合入后按 pin 级联惯例把嵌套 pin 对准组件 main 合入提交。 Evidence: go test ./internal/settings/ ./internal/server/ 全绿。 Impact: 仅 gitlink 指针调整(同一能力内容,9150e6f 为其祖先)。 --- deps/agent-system-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/agent-system-core b/deps/agent-system-core index 9150e6f..0f94e4b 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit 9150e6f90b7ed360467492c6102d971a97c8a59a +Subproject commit 0f94e4b062bf8736af3953821fe5d0f67d87ea8e From 8b73afeef6b2a2d16f5766a8d15afdf891109707 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 04:33:17 +0000 Subject: [PATCH 05/16] Close invalid sessions' SSE streams at heartbeat recheck Why: The 2026-10-13 login recovery contract (Dev docs/svc-deploy/ 2026-10-13-login-recovery-contract.md, plan item 6.7) requires that established SSE connections stop receiving private events once a password write destroys their session. Evidence: go test ./... green; TestSSESessionInvalidationClosesStream streams over a real HTTP server, invalidates the session via AdminResetUserPassword, and asserts the stream terminates with the session-expired marker within the recheck window (SELF, worktree run). Impact: Session-authenticated event streams revalidate at a 15s heartbeat cadence (package var, shortened in tests) and terminate on invalidation; API-key streams are unchanged (key revocation is separate). EventSource reconnects are rejected by the auth middleware. Refs: Dev PR #504; login recovery contract section 6.7. --- internal/server/server.go | 31 +++++++- internal/server/sse_close_test.go | 115 ++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 internal/server/sse_close_test.go diff --git a/internal/server/server.go b/internal/server/server.go index c15e91f..135c019 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -420,6 +420,11 @@ 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 @@ -471,6 +476,20 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { since, _ = strconv.ParseInt(q, 10, 64) } + // 会话凭证的长连接复验:密码写入会删除会话行;API key 凭证无会话可失效, + // 撤销 Key 的断流另行处理(login 恢复合同 §6.7)。 + sessionTok := "" + if AuthVia(r) == AuthViaSession { + sessionTok = sessionToken(r) + } + stillAuthed := func() bool { + if sessionTok == "" { + return true + } + o, _, ok := s.st.SessionInfo(sessionTok) + return ok && o == s.owner(r) + } + // 先订阅再重放,避免重放与订阅间隙丢事件;按 seq 去重。 live, stop := s.Subscribe(conversationID) defer stop() @@ -503,13 +522,23 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { } } // 心跳 + 在线转发。通道关闭表示缓冲溢出,结束响应让 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 !stillAuthed() { + fmt.Fprint(w, ": session-expired\n\n") + flusher.Flush() + stop() + return + } case <-heartbeat.C: fmt.Fprint(w, ": ping\n\n") flusher.Flush() @@ -1477,7 +1506,7 @@ func (s *Server) writeSettingsMerge(w http.ResponseWriter, merge func() error) { // ---- 系统设置(管理员;P2 受管项的唯一写入面)---- // requireSystemAdmin 系统设置面的准入:认证档要求基座管理员;无认证单机档 -//(Auth 未启用)本机即管理员——该档全部 API 本就无认证,锁系统层只会破坏 +// (Auth 未启用)本机即管理员——该档全部 API 本就无认证,锁系统层只会破坏 // 嵌入式部署的配置播种,不增加安全边界。 func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { if s.Auth == nil { diff --git a/internal/server/sse_close_test.go b/internal/server/sse_close_test.go new file mode 100644 index 0000000..df8fb0e --- /dev/null +++ b/internal/server/sse_close_test.go @@ -0,0 +1,115 @@ +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") + } +} From 0af1ebc91565bc01049a2fa880cd0d1c9bfeec1f Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 04:45:12 +0000 Subject: [PATCH 06/16] Seed the mock startup option into the system settings scope Why: Since the system-managed settings change (#188), runtime.* writes at the user scope are rejected, so the mock startup flag failed with "system-managed settings require admin" and the serve process never became healthy - the Dev multi-instance integration regression. Evidence: go build ok; internal/app and internal/settings suites green (SELF); the Dev composite CI on the follow-up PR re-runs make test-multi-instance against this fix (GATE). Impact: The mock startup flag now seeds runtime.mock via MergeSystem (trusted operator input, effective for every tenant through the Effective merge chain); no HTTP surface changes. Refs: Dev PR #508; backend #188. --- internal/app/app.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/app/app.go b/internal/app/app.go index 47d4311..1658497 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -437,7 +437,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 } } From e3b6cca8bcd9a3aa97610fb80bb6178bb5e355b3 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 06:04:26 +0000 Subject: [PATCH 07/16] Add account name passthrough and EXT-19 account search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: P3 人员选择器需要按账号或姓名查找成员并区分同名——EXT-19 目录此前只有 owner+有效态;扩展申请四要素记入 Dev 任务卡(授权管理读面下发姓名,联系方式仍不下发;停用能力经 facts F6 核实为未实现,Valid 维持存在性口径)。 Evidence: go test ./... 全绿;TestInprocHostSearchAccounts 覆盖姓名/账号子串、大小写不敏感、空 query 首页、ListAccounts 姓名回归;facts F5/F6 登记随 Dev PR。 Impact: AccountEntry 加 Name 可选字段;新增可选接口 AccountSearcher 与 inprocHost 实现;既有消费者不受影响(字段可选、接口可选)。 Refs: 提案 r1 P3;Dev 任务卡 feature/rdm-increment-settings-duties --- internal/app/account_lister_test.go | 55 +++++++++++++++++++++++++++++ internal/app/hostcaps.go | 35 ++++++++++++++++-- pkg/extension/extension.go | 14 +++++++- 3 files changed, 101 insertions(+), 3 deletions(-) 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/hostcaps.go b/internal/app/hostcaps.go index 89c440e..9c75678 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" @@ -173,7 +175,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 +187,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 +214,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) } diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 93939a0..976880d 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -83,9 +83,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 +100,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 From fe977c8e52f695648341b7be14e54960de85f9cd Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 06:05:10 +0000 Subject: [PATCH 08/16] Record account search surface entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: AccountSearcher 属有意的公开契约扩展,表面快照随行。 Evidence: go test ./... 全绿(TestSurfaceSnapshot 通过)。 --- pkg/extension/surface.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index a893299..8f4cfaf 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 From bc1e0a36f7163abd65e90a007d6ef31b84e9861c Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 06:05:45 +0000 Subject: [PATCH 09/16] Close the account lifecycle leftovers: atomicity, bootstrap lock, redeem guard, credential delivery Why: The login recovery contract leftovers and KTD12/KTD14 require: concurrent same-name account creation must not overwrite the winner, first-admin bootstrap must serialize across processes, the redeem endpoint backoff must be shared across instances, revoked API keys must close established SSE streams, and CLI credentials need an env-DSN path plus a non-interactive 0600 file delivery mode. Evidence: go test ./... green; new tests cover 16-way concurrent create (1 win/15 conflict, winner credentials intact), 8-way concurrent bootstrap (single admin, refuse takeover after), redeem guard shared across two store handles on one DB, SSE stream closed after key revocation within the recheck window, and HTTP 429 after the failure cap (SELF worktree run; CI is the GATE, PG via test-pg). Impact: Migration v29 (redeem_attempts); CreateAccount becomes a single-row INSERT with unique-violation conflict; BootstrapFirstAdmin advisory-locks (PG) / rechecks (SQLite) and commits admin+key+audit in one transaction; SSE revalidation covers API-key streams; redeem backoff moves to the shared DB; CLI gains AGENT_SYSTEM_DSN fallback and --out file delivery for create/reset-password/issue-login-key/ bootstrap-admin. Refs: docs/svc-deploy/2026-10-13-login-recovery-contract.md leftovers; proposal r2 KTD12/KTD14/R5/R11. --- cmd/agent-system/main.go | 63 ++++++++-- cmd/storage-migrate/main.go | 1 + internal/app/app.go | 19 ++- internal/genui/genui_test.go | 2 +- internal/runtime/llm_test.go | 2 +- internal/server/accounts.go | 38 ++---- internal/server/server.go | 32 ++++-- internal/server/sse_close_test.go | 91 +++++++++++++++ internal/store/account_lifecycle_test.go | 140 +++++++++++++++++++++++ internal/store/auth.go | 18 +-- internal/store/dialect.go | 9 ++ internal/store/login_keys.go | 121 +++++++++++++++++--- internal/store/migrate.go | 22 ++++ internal/store/migrate_compat.go | 1 + 14 files changed, 471 insertions(+), 88 deletions(-) create mode 100644 internal/store/account_lifecycle_test.go diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index f529c67..f06c6f8 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -238,6 +238,28 @@ 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 := os.WriteFile(outPath, buf, 0o600); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath) + 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,6 +282,7 @@ func runUser(args []string) int { 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:]) switch sub { case "create": @@ -281,11 +304,16 @@ func runUser(args []string) int { 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, "初始密码仅此一次输出;该用户首次登录须修改密码。") + if err := deliverCredential(*out, "login-key", *owner, pw, nil); err != nil { + fmt.Fprintln(os.Stderr, "user create:", err) + return 1 + } + if *out == "" { + if *password == "" && !*generatePassword { + fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。") + } else if *password == "" { + fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。") + } } return 0 case "reset-password": @@ -298,8 +326,11 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user reset-password:", err) return 1 } - fmt.Println(pw) - if *password == "" { + if err := deliverCredential(*out, "password", *owner, pw, nil); err != nil { + fmt.Fprintln(os.Stderr, "user reset-password:", err) + return 1 + } + if *out == "" && *password == "" { fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。") } return 0 @@ -326,8 +357,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 := deliverCredential(*out, "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 == "" { + fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。") + } return 0 case "revoke-login-key": if *keyID == "" { @@ -350,8 +386,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 := deliverCredential(*out, "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 == "" { + 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 0bb6943..bdb1561 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -296,6 +296,7 @@ var copyOrder = []string{ // U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚) "agent_system_identity", "account_events", + "redeem_attempts", } const controlDDL = ` diff --git a/internal/app/app.go b/internal/app/app.go index 1658497..5955026 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -790,21 +790,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 { - users, err := st.ListUsersAdmin() - if err != nil { - return err - } - if len(users) > 0 { - return fmt.Errorf("bootstrap-admin: users already exist (%d); refuse takeover", len(users)) - } - if err := st.CreateAccount(owner, owner, "", true); err != nil { - return err - } - p, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli-bootstrap", 0) + p, err := st.BootstrapFirstAdmin(owner, owner, "cli-bootstrap") plain = p return err }) @@ -857,6 +848,10 @@ func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error) func withStoreMode(storageDSN, dataDir string, openExisting bool, fn func(*store.Store) error) error { var st *store.Store var err error + // DSN 等价安全输入(KTD14):argv 缺省时回落环境变量——凭据不落 shell history。 + if storageDSN == "" { + storageDSN = os.Getenv("AGENT_SYSTEM_DSN") + } if storageDSN != "" { if openExisting { st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) 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/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 b1afaa5..68e7697 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -61,33 +61,12 @@ func loginClear(owner string) { delete(loginGuard.fails, owner) } -// 兑换入口退避(进程内,按来源 IP;Key 无属主可先行归因,只能按来源限流)。 -var redeemGuard = struct { - sync.Mutex - fails map[string][]time.Time -}{fails: map[string][]time.Time{}} - -const redeemMaxFails = 10 - -func redeemLocked(ip string) bool { - redeemGuard.Lock() - defer redeemGuard.Unlock() - cut := time.Now().Add(-loginLockWindow) - kept := redeemGuard.fails[ip][:0] - for _, t := range redeemGuard.fails[ip] { - if t.After(cut) { - kept = append(kept, t) - } - } - redeemGuard.fails[ip] = kept - return len(kept) >= redeemMaxFails -} - -func redeemFail(ip string) { - redeemGuard.Lock() - defer redeemGuard.Unlock() - redeemGuard.fails[ip] = append(redeemGuard.fails[ip], time.Now()) -} +// 兑换入口退避:共享库窗口计数(v29 redeem_attempts),多实例共担同一限流; +// Key 无属主可先行归因,只能按来源 IP 限流。 +const ( + redeemMaxFails = 10 + redeemWindow = loginLockWindow +) // requestSecure 判断请求是否经 HTTPS(直连 TLS 或反代 X-Forwarded-Proto 标头)。 func requestSecure(r *http.Request) bool { @@ -120,7 +99,7 @@ func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) { if xf := r.Header.Get("X-Forwarded-For"); xf != "" { ip = strings.TrimSpace(strings.Split(xf, ",")[0]) } - if redeemLocked(ip) { + if blocked, err := s.st.RedeemBlocked(ip, redeemMaxFails, redeemWindow); err == nil && blocked { http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests) return } @@ -133,12 +112,11 @@ 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) { - redeemFail(ip) http.Error(w, "login key already used", http.StatusConflict) return } - redeemFail(ip) http.Error(w, "invalid or expired login key", http.StatusUnauthorized) return } diff --git a/internal/server/server.go b/internal/server/server.go index 135c019..c3f0596 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -476,18 +476,30 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { since, _ = strconv.ParseInt(q, 10, 64) } - // 会话凭证的长连接复验:密码写入会删除会话行;API key 凭证无会话可失效, - // 撤销 Key 的断流另行处理(login 恢复合同 §6.7)。 - sessionTok := "" - if AuthVia(r) == AuthViaSession { + // 长连接凭证复验(login 恢复合同 §6.7):会话凭证查会话行(密码写入即删除); + // API key 凭证查撤销位(RevokeAllAPIKeys/失陷撤销后断流)。两种凭证都在 + // 复验周期内核验属主未变。 + sessionTok, keyCred := "", "" + switch AuthVia(r) { + case AuthViaSession: sessionTok = sessionToken(r) - } - stillAuthed := func() bool { - if sessionTok == "" { - return true + case AuthViaKey: + keyCred = bearerKey(r) + if keyCred == "" { + keyCred = r.URL.Query().Get("key") } - o, _, ok := s.st.SessionInfo(sessionTok) - return ok && o == s.owner(r) + } + 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 去重。 diff --git a/internal/server/sse_close_test.go b/internal/server/sse_close_test.go index df8fb0e..706604d 100644 --- a/internal/server/sse_close_test.go +++ b/internal/server/sse_close_test.go @@ -113,3 +113,94 @@ func TestSSESessionInvalidationClosesStream(t *testing.T) { 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/store/account_lifecycle_test.go b/internal/store/account_lifecycle_test.go new file mode 100644 index 0000000..10a1722 --- /dev/null +++ b/internal/store/account_lifecycle_test.go @@ -0,0 +1,140 @@ +package store + +// 账户生命周期并发合同(R5/R11/KTD12,2026-10-13 收尾): +// 并发建号不覆盖、首次引导单胜、兑换退避跨实例共担。 + +import ( + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" +) + +// 并发建号同名:恰一胜;胜者角色/密码不被败者覆盖(旧实现 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() }) + 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() }) + 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) + } + }(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/auth.go b/internal/store/auth.go index 0b99bfa..4d31272 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -218,15 +218,11 @@ 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 { - if _, err := s.GetUser(owner); err == nil { - return ErrConflict - } - if err := s.EnsureUser(owner, name); err != nil { - return err - } mustChange := passwordHash != "" setAt := "" if passwordHash != "" { @@ -234,8 +230,12 @@ func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) er } // 布尔列一律绑 Go bool 参数、不写 1/0 字面量:SQLite 两侧都容忍, // PostgreSQL 对 boolean 列收到 integer 会 42804(2026-10-05 user create 半失败根因)。 - _, err := s.db.Exec(s.q(`UPDATE users SET name=?, password_hash=?, password_set_at=?, must_change_password=?, is_admin=? WHERE owner=?`), - name, passwordHash, setAt, mustChange, isAdmin, owner) + _, err := s.db.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin) + VALUES(?,?,?,?,?,?,?,?)`), + newID("usr"), owner, name, nowISO(), passwordHash, setAt, mustChange, isAdmin) + if isUniqueViolation(err) { + return ErrConflict + } return 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/login_keys.go b/internal/store/login_keys.go index 2069a59..cea3683 100644 --- a/internal/store/login_keys.go +++ b/internal/store/login_keys.go @@ -112,28 +112,121 @@ 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 - } - // 旧 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 { + 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 } +// 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) (plain string, err error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + plain = "alk_" + hex.EncodeToString(raw) + idRaw := make([]byte, 8) + if _, err := rand.Read(idRaw); err != nil { + return "", err + } + id := "lk_" + hex.EncodeToString(idRaw) + now := time.Now().UTC() + exp := now.Add(defaultLoginKeyTTL) + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + if err := s.dialect.lockBootstrap(tx); err != nil { + return "", err + } + var n int + if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users`)).Scan(&n); err != nil { + return "", err + } + if n > 0 { + return "", ErrConflict // 有数据环境拒绝接管(R11) + } + if _, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin) + VALUES(?,?,?,?,?,?,?,?)`), + newID("usr"), owner, name, nowISO(), "", "", false, true); err != nil { + if isUniqueViolation(err) { + return "", ErrConflict + } + return "", err + } + // SQLite 并发兜底:本事务内用户数必须恰为 1(另一引导已提交则回滚)。 + if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users`)).Scan(&n); err != nil { + return "", err + } + if n != 1 { + return "", ErrConflict + } + if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, createdBy, plain, id, now, exp); err != nil { + return "", err + } + if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "bootstrap_admin", owner); err != nil { + return "", err + } + if err := tx.Commit(); err != nil { + return "", err + } + return plain, nil +} + +// 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 == "" { diff --git a/internal/store/migrate.go b/internal/store/migrate.go index f36444c..4428563 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -819,6 +819,28 @@ func init() { `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`, + }}}, ) } diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index 5eb403a..ac0347d 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -33,6 +33,7 @@ var migrationBreaksOldReaders = map[int]bool{ 26: false, 27: false, 28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响 + 29: false, // redeem_attempts:新表,旧读者不受影响 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 From 4e285bdd8d37ef94db88f5cfdaec58532994a925 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 07:07:47 +0000 Subject: [PATCH 10/16] Bump embedded core pin to TS mirror fix 5773e74 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: 三层 pin 全等——Core 权威 TS 镜像补受管 scope 后,Backend 嵌套 pin 同步。 Evidence: go build 通过;组合校验随 Dev CI。 Impact: gitlink 一枚。 --- deps/agent-system-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/agent-system-core b/deps/agent-system-core index 0f94e4b..5773e74 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit 0f94e4b062bf8736af3953821fe5d0f67d87ea8e +Subproject commit 5773e74086791731059730bd3726dc8c9cd1381f From 7cf8f02399a66ef2c871f6af4066ee1a95808d0a Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 08:43:02 +0000 Subject: [PATCH 11/16] Add project lifecycle delegation and private member work Why: Shared project reads previously doubled as write permission, project mutations bypassed the domain lifecycle, and coarse retrieval shared private resources. Evidence: SELF red-before-implementation logs cover projection, lifecycle, private work, resource filtering, canonical retrieval scope, publication source and namespace. SELF go test ./..., isolated PostgreSQL go test ./internal/store -count=1, and CGO_ENABLED=0 go build ./cmd/agent-system passed; task owner owns replay/CI. Impact: Add optional actor-scoped projection/lifecycle capabilities; registered providers control public lifecycle and live work. Preserve owned conversation history and ordinary unregistered behavior. Authorize knowledge by stored ID/source, map current host scopes through trusted service metadata, and filter artifact lists. Refs: Dev project-member-access implementation plan r1 Task 1; approved design r0.1; 2026-10-13 trusted publication Source hardening ruling --- internal/app/app.go | 33 ++- internal/app/hostcaps.go | 53 +++- internal/app/knowledge_authorization_test.go | 105 +++++++ internal/app/project_lifecycle.go | 121 ++++++++ internal/app/project_lifecycle_test.go | 77 +++++ internal/projectscope/projectscope.go | 35 +++ internal/rag/hybrid.go | 56 +++- internal/runmgr/knowledge_resource_test.go | 66 +++++ internal/runmgr/runmgr.go | 83 +++++- internal/server/borrow.go | 4 + internal/server/platform_api.go | 14 + internal/server/project_lifecycle.go | 73 +++++ internal/server/project_lifecycle_test.go | 199 +++++++++++++ .../server/project_resource_filter_test.go | 100 +++++++ .../server/resource_authz_consumption_test.go | 2 +- internal/server/server.go | 269 +++++++++++++++--- internal/store/content.go | 35 ++- internal/store/project_projection.go | 149 ++++++++++ internal/store/project_projection_test.go | 83 ++++++ internal/store/vectors.go | 16 +- pkg/extension/account_lister_test.go | 11 +- pkg/extension/extension.go | 20 +- pkg/extension/projects.go | 35 +++ pkg/extension/surface.txt | 11 + 24 files changed, 1572 insertions(+), 78 deletions(-) create mode 100644 internal/app/knowledge_authorization_test.go create mode 100644 internal/app/project_lifecycle.go create mode 100644 internal/app/project_lifecycle_test.go create mode 100644 internal/projectscope/projectscope.go create mode 100644 internal/runmgr/knowledge_resource_test.go create mode 100644 internal/server/project_lifecycle.go create mode 100644 internal/server/project_lifecycle_test.go create mode 100644 internal/server/project_resource_filter_test.go create mode 100644 internal/store/project_projection.go create mode 100644 internal/store/project_projection_test.go create mode 100644 pkg/extension/projects.go diff --git a/internal/app/app.go b/internal/app/app.go index 5955026..67587f3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -55,6 +55,7 @@ import ( "agent-system-backend/internal/surfacesvc" "agent-system-backend/internal/tools" "agent-system-backend/internal/workflow" + "agent-system-backend/pkg/extension" core "agent-system-core" ) @@ -594,7 +595,8 @@ func Run(opt Options) error { pluginHost.OnCommands = bus.RegisterPluginCommands // hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。 authzSlot := &projectAuthz{} - caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc} + projectSlot := &projectLifecycleSlot{} + caps := hostcaps{projects: projectSlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc} // EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面 // 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。 mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool { @@ -607,6 +609,16 @@ func Run(opt Options) error { // EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。 wireScopedHostFactory(caps) compiledSummary := applyCompiled(skillsRoot, bus, caps) + mgr.SetProjectWorkAuthorization(projectSlot.work()) + mgr.SetKnowledgeProjectScope(func(ctx context.Context, actor string, id core.ID) (core.ID, error) { + return projectSlot.resolve(ctx, caps, actor, id) + }) + if authzSlot.hasV2() { + mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool { + ok, err := authzSlot.AuthorizeResource(extension.ResourceAuthzQuery{Actor: actor, Action: "model-context", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source}) + return err == nil && ok + }) + } mgr.OnUsage = func(owner string, tokens int) { budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0)) status, aerr := st.AccountTokens(owner, tokens, budget) @@ -631,28 +643,21 @@ func Run(opt Options) error { } // EXT-8 余项:hostapi 检索面同样吃适配器共享(本人 + 放行的他人项目知识)。 hostSrv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) { - own, err := caps.searchDefault(ctx, owner, text) - if err != nil { - return nil, err - } - shared, err := st.SearchKnowledgeShared(owner, core.RetrievalQuery{Text: text, TopK: 5}, func(docOwner, projectID core.ID) bool { - ok, aerr := authzSlot.Authorize(owner, projectID) - return aerr == nil && ok - }) - if err != nil { - return nil, err - } - return append(own, shared...), nil + return caps.searchDefault(ctx, owner, text) } + hostSrv.Complete = func(ctx context.Context, owner, prompt string) (string, error) { return caps.complete(ctx, owner, prompt) } srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost) + projectSlot.wire(srv, caps) // EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。 srv.SetProjectAuthorizer(authzSlot.Authorize) // EXT-8b:资源级适配器注入读面(未注册=server 回落 V1,行为不变)。 - srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource) + if authzSlot.hasV2() { + srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource) + } // EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。 srv.DisableUploadIndexing = opt.DisableUploadIndexing // EXT-12 最小开关:会话卡不镜像进项目公共流(cardSvc 在上方已建)。 diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 9c75678..62cebe5 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -21,6 +21,7 @@ import ( // 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内 // inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。 type hostcaps struct { + projects *projectLifecycleSlot st *store.Store pipe *rag.Pipeline set *settings.Service @@ -71,10 +72,16 @@ func (a *projectAuthz) AuthorizeResource(q extension.ResourceAuthzQuery) (bool, if fn1 == nil || q.Owner == "" || q.ProjectID == "" { return false, nil } - return fn1(q.Owner, q.ProjectID) + return fn1(q.Actor, q.ProjectID) } // Authorize owner×project 共享读取判定;未注册一律 false(默认拒绝)。 +func (a *projectAuthz) hasV2() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.fn2 != nil +} + func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) { a.mu.Lock() fn := a.fn @@ -103,6 +110,19 @@ type sharedRetriever interface { func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) { q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK} + if c.projects != nil && projectID != "" { + id, err := c.projects.resolve(ctx, c, owner, projectID) + if err != nil { + return nil, err + } + q.ProjectID = id + } + if c.authz != nil && c.authz.hasV2() { + return c.pipe.RetrieveAuthorized(ctx, core.ID(owner), q, func(d core.KnowledgeDoc) bool { + ok, err := c.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source}) + return err == nil && ok + }) + } // EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走 // 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。 if c.authz != nil { @@ -265,6 +285,20 @@ func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text return h.caps.search(ctx, h.owner, projectID, text, topK) } +// knowledgeSource preserves ordinary host Source semantics. A lifecycle provider +// introduces shared publication provenance, so plugins may only assert their own +// namespace; another plugin cannot impersonate an RDM published row. +func (h inprocHost) knowledgeSource(source string) error { + if h.caps.projects == nil || !h.caps.projects.active() { + return nil + } + prefix := "plugin:" + h.pluginID + if source == prefix || strings.HasPrefix(source, prefix+":") { + return nil + } + return fmt.Errorf("extension: knowledge source must belong to plugin %q", h.pluginID) +} + // KnowledgeWrite 强制 owner 归属:Owner 改写为本次分发 owner,Source 为空补 // "plugin:";embed=false 只写全文行,embed=true 走 IndexChunks 建向量 // (embedding 失败降级纯全文,不阻断写入)。 @@ -278,6 +312,9 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc if docs[i].Source == "" { docs[i].Source = src } + if err := h.knowledgeSource(docs[i].Source); err != nil { + return err + } } if !embed { return h.caps.st.AddKnowledge(docs...) @@ -292,24 +329,26 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD if len(docs) == 0 { return nil } - refs := map[string]bool{} + type publicationRef struct{ projectID, ref string } + refs := map[publicationRef]bool{} for i := range docs { docs[i].Owner = core.ID(h.owner) if docs[i].Source == "" { docs[i].Source = "plugin:" + h.pluginID } + if err := h.knowledgeSource(docs[i].Source); err != nil { + return err + } if docs[i].Ref != "" { - refs[docs[i].Ref] = true + refs[publicationRef{docs[i].ProjectID, docs[i].Ref}] = true } } for ref := range refs { - if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil { - return err - } - if err := h.caps.st.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil { + if err := h.caps.st.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref); err != nil { return err } } + return h.KnowledgeWrite(ctx, docs, embed) } diff --git a/internal/app/knowledge_authorization_test.go b/internal/app/knowledge_authorization_test.go new file mode 100644 index 0000000..7df87d3 --- /dev/null +++ b/internal/app/knowledge_authorization_test.go @@ -0,0 +1,105 @@ +package app + +import ( + "agent-system-backend/internal/rag" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "testing" +) + +func TestHostKnowledgeV2AndProjectReplacement(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/t.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + pipe := rag.NewPipeline(st, settings.NewService(st), nil, nil) + auth := &projectAuthz{} + alice := inprocHost{owner: "alice", pluginID: "rdm", caps: hostcaps{st: st, pipe: pipe, authz: auth}} + first := []core.KnowledgeDoc{{ID: "slice-1", ProjectID: "domain-1", Ref: "notes.md", Chunk: "sharedneedle one"}} + second := []core.KnowledgeDoc{{ID: "slice-2", ProjectID: "domain-2", Ref: "notes.md", Chunk: "sharedneedle two"}} + for _, ds := range [][]core.KnowledgeDoc{first, second, second} { + if err := alice.KnowledgePublish(context.Background(), ds, false); err != nil { + t.Fatal(err) + } + } + own, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "sharedneedle"}) + if err != nil || len(own) != 2 { + t.Fatalf("cross project publish replaced first: %+v %v", own, err) + } + auth.set("rdm", func(string, core.ID) (bool, error) { return true, nil }) + auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { + return q.ResourceType == "project" || (q.ResourceType == "knowledge" && q.ResourceID == "slice-1" && q.ProjectID == "domain-1" && q.Actor == "bob" && q.Owner == "alice"), nil + }) + bob := inprocHost{owner: "bob", pluginID: "rdm", caps: alice.caps} + hits, err := bob.KnowledgeSearch(context.Background(), "", "sharedneedle", 5) + if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-1" { + t.Fatalf("host V2=%+v %v", hits, err) + } +} + +func TestHostKnowledgeResolvesTrustedProjectScope(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/t.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil { + t.Fatal(err) + } + slot := &projectLifecycleSlot{} + provider := completeLifecycle() + provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) { + if h.Owner() != "bob" { + t.Fatalf("wrong scope actor %q", h.Owner()) + } + return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}}, nil + } + caps := hostcaps{st: st, pipe: rag.NewPipeline(st, settings.NewService(st), nil, nil), authz: &projectAuthz{}, projects: slot} + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil { + t.Fatal(err) + } + caps.authz.setV2("rdm", func(extension.ResourceAuthzQuery) (bool, error) { return true, nil }) + h := inprocHost{owner: "bob", pluginID: "rdm", caps: caps} + hits, err := h.KnowledgeSearch(context.Background(), "host-current", "scopeneedle", 5) + if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" { + t.Fatalf("host scope: %+v %v", hits, err) + } +} + +func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/t.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + slot := &projectLifecycleSlot{} + caps := hostcaps{st: st, projects: slot} + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } + h := inprocHost{owner: "alice", pluginID: "other", caps: caps} + for _, publish := range []bool{false, true} { + docs := []core.KnowledgeDoc{{ID: "claimed-published", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "ref", Chunk: "spoofneedle"}} + var err error + if publish { + err = h.KnowledgePublish(context.Background(), docs, false) + } else { + err = h.KnowledgeWrite(context.Background(), docs, false) + } + if err == nil { + t.Fatalf("foreign namespace accepted publish=%v", publish) + } + } + if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "spoofneedle"}); err != nil || len(hits) != 0 { + t.Fatalf("foreign source wrote data: %+v %v", hits, err) + } + // Existing ordinary Hosts preserve their documented caller-provided Source. + h.caps.projects = nil + if err := h.KnowledgeWrite(context.Background(), []core.KnowledgeDoc{{Source: "manual", Chunk: "ordinaryneedle"}}, false); err != nil { + t.Fatalf("ordinary source semantics changed: %v", err) + } +} diff --git a/internal/app/project_lifecycle.go b/internal/app/project_lifecycle.go new file mode 100644 index 0000000..883d5c2 --- /dev/null +++ b/internal/app/project_lifecycle.go @@ -0,0 +1,121 @@ +package app + +import ( + "agent-system-backend/internal/projectscope" + "agent-system-backend/internal/server" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "errors" + "sync" +) + +type projectLifecycleSlot struct { + mu sync.Mutex + holder string + provider *extension.ProjectLifecycle +} + +func (r compiledRegistry) RegisterProjectLifecycle(extension.ProjectLifecycle) error { + return errors.New("extension: declarative plugins cannot register project lifecycle") +} +func (r pluginRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error { + if r.caps.projects == nil || r.pluginID == "" { + return errors.New("extension: project lifecycle slot unavailable") + } + if p.List == nil || p.Create == nil || p.Rename == nil || p.Archive == nil || p.Work == nil { + return errors.New("extension: project lifecycle requires all callbacks") + } + slot := r.caps.projects + slot.mu.Lock() + defer slot.mu.Unlock() + if slot.provider != nil { + return errors.New("extension: project lifecycle already registered") + } + slot.holder, slot.provider = r.pluginID, &p + return nil +} +func (r recordRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error { + reg, ok := r.Registry.(extension.ProjectLifecycleRegistry) + if !ok { + return errors.New("extension: project lifecycle registration unavailable") + } + return reg.RegisterProjectLifecycle(p) +} +func (slot *projectLifecycleSlot) wire(srv *server.Server, caps hostcaps) { + slot.mu.Lock() + defer slot.mu.Unlock() + if slot.provider == nil { + return + } + holder := slot.holder + srv.SetProjectLifecycle(*slot.provider, func(owner string) extension.Host { + return inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder} + }) +} + +func (slot *projectLifecycleSlot) active() bool { + slot.mu.Lock() + defer slot.mu.Unlock() + return slot.provider != nil +} + +func (slot *projectLifecycleSlot) work() func(context.Context, string, core.ID) (bool, error) { + slot.mu.Lock() + defer slot.mu.Unlock() + if slot.provider == nil { + return nil + } + return slot.provider.Work +} + +func (slot *projectLifecycleSlot) resolve(ctx context.Context, caps hostcaps, owner string, id core.ID) (core.ID, error) { + slot.mu.Lock() + if slot.provider == nil { + slot.mu.Unlock() + return id, nil + } + p, holder := *slot.provider, slot.holder + slot.mu.Unlock() + return projectscope.Resolve(ctx, inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}, p, id) +} + +var _ extension.ProjectHost = inprocHost{} + +func (h inprocHost) OwnedProjects() ([]core.Project, error) { + ps, err := h.caps.st.ListProjects(h.owner) + for i := range ps { + ps[i].Owner = h.owner + } + return ps, err +} +func (h inprocHost) OwnedProject(id string) (core.Project, error) { + return h.caps.st.GetProject(h.owner, id) +} +func (h inprocHost) ProjectMetadata(id string) (core.Project, error) { + p, err := h.caps.st.ProjectMetadata(id) + if err != nil { + return core.Project{}, err + } + if p.Owner == h.owner { + return p, nil + } + if h.caps.authz == nil { + return core.Project{}, store.ErrNotFound + } + ok, err := h.caps.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: h.owner, Action: "read", ResourceType: "project", ResourceID: core.ID(id), Owner: p.Owner, ProjectID: core.ID(id)}) + if err != nil || !ok { + return core.Project{}, store.ErrNotFound + } + return p, nil +} +func (h inprocHost) EnsureProject(id, name string) (core.Project, error) { + return h.caps.st.EnsureProject(h.owner, id, name) +} +func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) { + return h.caps.st.RenameOwnedProject(h.owner, id, name) +} +func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) { + return h.caps.st.ProjectNameAvailable(name, exceptID) +} diff --git a/internal/app/project_lifecycle_test.go b/internal/app/project_lifecycle_test.go new file mode 100644 index 0000000..ad261c0 --- /dev/null +++ b/internal/app/project_lifecycle_test.go @@ -0,0 +1,77 @@ +package app + +import ( + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "errors" + "testing" +) + +func completeLifecycle() extension.ProjectLifecycle { + return extension.ProjectLifecycle{ + List: func(context.Context, extension.Host) ([]core.Project, error) { return nil, nil }, + Create: func(context.Context, extension.Host, string) (core.Project, error) { return core.Project{}, nil }, + Rename: func(context.Context, extension.Host, string, string) (core.Project, error) { + return core.Project{}, nil + }, + Archive: func(context.Context, extension.Host, string) error { return nil }, + Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }, + } +} +func TestProjectLifecycleOptionalRegistration(t *testing.T) { + slot := &projectLifecycleSlot{} + reg := pluginRegistry{pluginID: "rdm", caps: hostcaps{projects: slot}} + if err := reg.RegisterProjectLifecycle(extension.ProjectLifecycle{}); err == nil { + t.Fatal("incomplete registration accepted") + } + if err := reg.RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } + if err := reg.RegisterProjectLifecycle(completeLifecycle()); err == nil { + t.Fatal("duplicate registration accepted") + } + if err := (compiledRegistry{}).RegisterProjectLifecycle(completeLifecycle()); err == nil { + t.Fatal("declarative callback accepted") + } + rr := recordRegistry{Registry: pluginRegistry{pluginID: "other", caps: hostcaps{projects: &projectLifecycleSlot{}}}} + optional, ok := any(rr).(extension.ProjectLifecycleRegistry) + if !ok { + t.Fatal("record registry hides optional registration") + } + if err := optional.RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } +} +func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/t.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + alice := inprocHost{owner: "alice", caps: hostcaps{st: st}} + p, err := alice.EnsureProject("stable", "Shared") + if err != nil { + t.Fatal(err) + } + auth := &projectAuthz{} + auth.set("rdm", func(owner string, id core.ID) (bool, error) { return owner == "bob" && id == core.ID(p.ID), nil }) + bob := inprocHost{owner: "bob", caps: hostcaps{st: st, authz: auth}} + if _, err := bob.ProjectMetadata(p.ID); err != nil { + t.Fatal(err) + } + if _, err := bob.OwnedProject(p.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("owned lookup=%v", err) + } + if _, err := bob.RenameOwnedProject(p.ID, "Other"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("shared rename=%v", err) + } + if _, err := bob.EnsureProject(p.ID, p.Name); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("shared ensure=%v", err) + } + auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { return false, nil }) + if _, err := bob.ProjectMetadata(p.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("V2 denial ignored=%v", err) + } +} diff --git a/internal/projectscope/projectscope.go b/internal/projectscope/projectscope.go new file mode 100644 index 0000000..25bc722 --- /dev/null +++ b/internal/projectscope/projectscope.go @@ -0,0 +1,35 @@ +// Package projectscope resolves host retrieval filters using service-provided +// lifecycle metadata. Stored/client settings never supply an authority mapping. +package projectscope + +import ( + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" +) + +func Resolve(ctx context.Context, host extension.Host, p extension.ProjectLifecycle, id core.ID) (core.ID, error) { + if id == "" { + return id, nil + } + if p.List == nil { + return "", store.ErrNotFound + } + projects, err := p.List(ctx, host) + if err != nil { + return "", err + } + for _, project := range projects { + domainID, _ := project.Settings["rdm.projectId"].(string) + if project.ID != id && domainID != id { + continue + } + state, _ := project.Settings["rdm.state"].(string) + if domainID == "" || state != "ready" { + return "", store.ErrNotFound + } + return core.ID(domainID), nil + } + return "", store.ErrNotFound +} diff --git a/internal/rag/hybrid.go b/internal/rag/hybrid.go index ba4c976..e9438d2 100644 --- a/internal/rag/hybrid.go +++ b/internal/rag/hybrid.go @@ -174,19 +174,66 @@ func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.Ret return merged, nil } +// RetrieveAuthorized is the resource-level path for HTTP, Host and model +// context. It filters owned and shared candidates before external reranking. +func (p *Pipeline) RetrieveAuthorized(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { + own, err := p.retrieve(ctx, owner, q, allow) + if err != nil { + return nil, err + } + shared, err := p.st.SearchKnowledgeSharedAuthorized(string(owner), q, allow) + if err != nil { + return nil, err + } + if p.embedder != nil { + if vectors, e := p.embedder.Embed(ctx, []string{q.Text}); e == nil && len(vectors) > 0 && len(vectors[0]) > 0 { + topK := q.TopK + if topK <= 0 { + topK = 5 + } + vs, e := p.st.SearchKnowledgeVectorsSharedAuthorized(ctx, string(owner), embedderModel(p.embedder), vectors[0], topK, q, allow) + if e == nil { + shared = rrfMerge(vs, shared) + } + } + } + merged := rrfMerge(own, shared) + if limit := q.TopK; limit > 0 && len(merged) > limit { + merged = merged[:limit] + } + return merged, nil +} +func authorizedHits(hits []core.RetrievalHit, allow func(core.KnowledgeDoc) bool) []core.RetrievalHit { + if allow == nil { + return hits + } + out := make([]core.RetrievalHit, 0, len(hits)) + for _, h := range hits { + if allow(h.Doc) { + out = append(out, h) + } + } + return out +} + // Retrieve 统一检索(core.Retriever)。 func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) { + return p.retrieve(ctx, owner, q, nil) +} +func (p *Pipeline) retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { topK := q.TopK if topK <= 0 { topK = 5 } if p.embedder == nil { - return p.st.SearchKnowledge(owner, q) + hits, err := p.st.SearchKnowledge(owner, q) + return authorizedHits(hits, allow), err } route := p.embeddingRoute(ctx) offline, _ := p.embedder.(interface{ Offline() bool }) if !route.OK() && (offline == nil || !offline.Offline()) { - return p.st.SearchKnowledge(owner, q) + hits, err := p.st.SearchKnowledge(owner, q) + return authorizedHits(hits, allow), err } qvecs, err := p.embedder.Embed(ctx, []string{q.Text}) if err != nil || len(qvecs) == 0 || len(qvecs[0]) == 0 { @@ -194,7 +241,8 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval if err != nil { log.Printf("[rag] query embedding fallback to fulltext: %v", err) } - return p.st.SearchKnowledge(owner, q) + hits, err := p.st.SearchKnowledge(owner, q) + return authorizedHits(hits, allow), err } q.TopK = topK * 2 // 每路多取,融合后再裁剪 vhits, err := p.st.SearchKnowledgeVectorsModel(ctx, owner, embedderModel(p.embedder), qvecs[0], topK*2, q) @@ -205,7 +253,7 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval if err != nil { return nil, err } - merged := rrfMerge(vhits, fhits) + merged := authorizedHits(rrfMerge(vhits, fhits), allow) if p.reranker != nil && p.rerankRoute(ctx).OK() && len(merged) > 0 { docs := make([]core.KnowledgeDoc, len(merged)) for i, h := range merged { diff --git a/internal/runmgr/knowledge_resource_test.go b/internal/runmgr/knowledge_resource_test.go new file mode 100644 index 0000000..70c2a59 --- /dev/null +++ b/internal/runmgr/knowledge_resource_test.go @@ -0,0 +1,66 @@ +package runmgr + +import ( + "agent-system-backend/internal/rag" + "agent-system-backend/internal/store" + core "agent-system-core" + "context" + "encoding/json" + "errors" + "testing" +) + +func TestRunKnowledgeResourceAuthorization(t *testing.T) { + rc, st := newExecEnv(t) + if err := st.AddKnowledge(core.KnowledgeDoc{ID: "allowed-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery published"}, core.KnowledgeDoc{ID: "private-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery private"}, core.KnowledgeDoc{ID: "restricted-self", Owner: "alice", ProjectID: rc.run.ProjectID, Chunk: "resourcequery self"}); err != nil { + t.Fatal(err) + } + rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil)) + rc.mgr.SetSharedKnowledgeAllow(func(string, core.ID) bool { return true }) + rc.mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool { + return actor == "alice" && d.ID == "allowed-slice" && d.Owner == "bob" + }) + hits, err := rc.Retrieve(core.RetrievalQuery{Text: "resourcequery"}) + if err != nil || len(hits) != 1 || hits[0].Doc.ID != "allowed-slice" { + t.Fatalf("model-context resources: %+v %v", hits, err) + } +} + +func TestProjectWorkRevocationGuardsDirectExecution(t *testing.T) { + rc, st := newExecEnv(t) + rc.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return false, nil }) + conv, err := st.GetConversation("alice", rc.run.ConversationID) + if err != nil { + t.Fatal(err) + } + if _, err := rc.mgr.StartConversationRun(conv, core.Message{}, nil, nil); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("direct run should reject: %v", err) + } + res := rc.ExecuteTool(core.ToolCallRequest{Name: "write_artifact", Arguments: json.RawMessage(`{"name":"forbidden.md","content":"x"}`)}) + if res.Status != "error" { + t.Fatalf("revoked execution: %+v", res) + } + artifacts, err := st.ListArtifacts("alice", rc.run.ProjectID) + if err != nil || len(artifacts) != 0 { + t.Fatalf("revoked tool wrote: %+v %v", artifacts, err) + } +} + +func TestRunKnowledgeResolvesTrustedProjectScope(t *testing.T) { + rc, st := newExecEnv(t) + if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "bob", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "bob", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil { + t.Fatal(err) + } + rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil)) + rc.mgr.SetKnowledgeResourceAllow(func(string, core.KnowledgeDoc) bool { return true }) + rc.mgr.SetKnowledgeProjectScope(func(_ context.Context, actor string, id core.ID) (core.ID, error) { + if actor != "alice" || id != rc.run.ProjectID { + t.Fatalf("untrusted scope actor=%s project=%s", actor, id) + } + return "domain-current", nil + }) + hits, err := rc.Retrieve(core.RetrievalQuery{Text: "scopeneedle"}) + if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" { + t.Fatalf("model current scope: %+v %v", hits, err) + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 1086b73..b9d7ddb 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -57,7 +57,10 @@ type Manager struct { retriever core.Retriever // 可选混合检索(nil = store 全文) // sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属 // projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。 - sharedKnowledgeAllow func(owner string, projectID core.ID) bool + sharedKnowledgeAllow func(owner string, projectID core.ID) bool + knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool + knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error) + projectWorkAuthorization func(context.Context, string, core.ID) (bool, error) // commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。 commands CommandSource indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块) @@ -197,6 +200,37 @@ func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.I m.sharedKnowledgeAllow = fn } +// SetKnowledgeResourceAllow makes resource-level authorization authoritative for +// model context, including owned rows. The callback receives stored identities. +func (m *Manager) SetKnowledgeResourceAllow(fn func(string, core.KnowledgeDoc) bool) { + m.knowledgeResourceAllow = fn +} + +// SetProjectWorkAuthorization is the live domain guard for starting runs and +// executing tools. Nil preserves the ordinary unregistered host behavior. +func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, core.ID) (bool, error)) { + m.projectWorkAuthorization = fn +} +func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error { + if m.projectWorkAuthorization == nil { + return nil + } + ok, err := m.projectWorkAuthorization(ctx, owner, id) + if err != nil { + return err + } + if !ok { + return store.ErrNotFound + } + return nil +} + +// SetKnowledgeProjectScope binds host project filters to the provider's trusted +// canonical domain metadata without widening the current conversation scope. +func (m *Manager) SetKnowledgeProjectScope(fn func(context.Context, string, core.ID) (core.ID, error)) { + m.knowledgeProjectScope = fn +} + // SetCommandSource 注入命令即工具源(EXT-14;组合根以命令总线适配)。nil = 现状。 func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src } @@ -902,6 +936,40 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { if q.ProjectID == "" { q.ProjectID = c.run.ProjectID } + if c.mgr.knowledgeProjectScope != nil { + id, err := c.mgr.knowledgeProjectScope(c.ctx, c.run.Owner, q.ProjectID) + if err != nil { + return nil, err + } + q.ProjectID = id + } + if c.mgr.knowledgeResourceAllow != nil { + allow := func(d core.KnowledgeDoc) bool { return c.mgr.knowledgeResourceAllow(string(c.run.Owner), d) } + if rs, ok := c.mgr.retriever.(interface { + RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) + }); ok { + return rs.RetrieveAuthorized(c.ctx, c.run.Owner, q, allow) + } + hits, err := c.mgr.st.SearchKnowledge(c.run.Owner, q) + if err != nil { + return nil, err + } + out := []core.RetrievalHit{} + for _, h := range hits { + if allow(h.Doc) { + out = append(out, h) + } + } + shared, err := c.mgr.st.SearchKnowledgeSharedAuthorized(c.run.Owner, q, allow) + if err != nil { + return nil, err + } + out = append(out, shared...) + if len(out) > q.TopK { + out = out[:q.TopK] + } + return out, nil + } if c.mgr.retriever != nil { if c.mgr.sharedKnowledgeAllow != nil { if rs, ok := c.mgr.retriever.(sharedRetriever); ok { @@ -917,6 +985,9 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { // ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。 func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { + if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()} + } policy := c.prof.Tools if !policy.ToolAllowed(call.Name) { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)} @@ -1141,6 +1212,9 @@ func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *wo // attachments 为已解析的附件文档(server 层经 ingest 处理)。 // inputs 是同一批附件的原件,落到会话工作区 inputs/,供模板和仿制使用。 func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) { + if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { + return core.Run{}, err + } if err := m.claimConversation(conv.ID); err != nil { return core.Run{}, err } @@ -1150,6 +1224,9 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core. // StartWorkflowRun 启动一条绑定工作流的会话 run。定义在这里拷贝, // 之后插件卸载不会改到已经在跑的这份。 func (m *Manager) StartWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) { + if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { + return core.Run{}, err + } if err := m.claimConversation(conv.ID); err != nil { return core.Run{}, err } @@ -1259,6 +1336,10 @@ func (m *Manager) claimConversation(conversationID string) error { } func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile, launch *workflow.Launch) (core.Run, error) { + if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { + m.releaseConversation(conv.ID) + return core.Run{}, err + } prof, _ := m.profiles.Get(conv.Owner, conv.ProfileID) eff := m.settings.Effective(conv.Owner, conv.ProjectID) run := core.Run{ diff --git a/internal/server/borrow.go b/internal/server/borrow.go index e0147fb..1ad8fd1 100644 --- a/internal/server/borrow.go +++ b/internal/server/borrow.go @@ -164,6 +164,10 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } + if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil { + writeProjectErr(w, err) + return + } var req struct { MessageID string `json:"messageId"` } diff --git a/internal/server/platform_api.go b/internal/server/platform_api.go index ee5ae69..bd7d9e9 100644 --- a/internal/server/platform_api.go +++ b/internal/server/platform_api.go @@ -273,6 +273,20 @@ func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) { http.Error(w, "surface capability not enabled", http.StatusNotImplemented) return } + if s.projectLifecycle != nil { + snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id"))) + if err != nil { + s.writeSurfaceError(w, err) + return + } + if snap.Identity.ProjectID != "" { + if err := s.projectWork(r.Context(), s.owner(r), snap.Identity.ProjectID); err != nil { + writeProjectErr(w, err) + return + } + } + } + // 注意:本端点不绑定任何 confirmed 类字段——高危确认只能经既有确认卡回道 // (bus.Confirm)置位,客户端/模型无法伪造(U5 提案第二节第 2 条红测)。 var body struct { diff --git a/internal/server/project_lifecycle.go b/internal/server/project_lifecycle.go new file mode 100644 index 0000000..eb4d747 --- /dev/null +++ b/internal/server/project_lifecycle.go @@ -0,0 +1,73 @@ +package server + +import ( + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "errors" + "net/http" +) + +// SetProjectLifecycle is wired only for a registered in-process provider. The +// optional host factory constructs a capability scoped to this request's actor. +func (s *Server) SetProjectLifecycle(p extension.ProjectLifecycle, host func(string) extension.Host) { + s.projectLifecycle = &p + s.projectHost = host +} +func (s *Server) lifecycleHost(owner string) extension.Host { + if s.projectHost == nil { + return nil + } + return s.projectHost(owner) +} +func (s *Server) projectWork(ctx context.Context, owner, projectID string) error { + if s.projectLifecycle == nil { + return nil + } + if s.projectLifecycle.Work == nil { + return store.ErrNotFound + } + ok, err := s.projectLifecycle.Work(ctx, owner, core.ID(projectID)) + if err != nil { + return err + } + if !ok { + return store.ErrNotFound + } + return nil +} + +// createPrivateConversation is shared by the API and /new. Only live work +// authorization grants member creation; the resulting conversation stays owned. +func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) { + if s.projectLifecycle != nil { + if err := s.projectWork(ctx, owner, projectID); err != nil { + return core.Conversation{}, err + } + return s.st.CreateConversationInProject(owner, projectID, title, profile) + } + c, err := s.st.CreateConversation(owner, projectID, title, profile) + if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) { + return s.st.CreateConversationInProject(owner, projectID, title, profile) + } + return c, err +} +func writeProjectErr(w http.ResponseWriter, err error) { + var coded interface{ StatusCode() int } + if errors.As(err, &coded) { + switch code := coded.StatusCode(); code { + case 400, 403, 404, 409, 503: + http.Error(w, err.Error(), code) + return + } + } + writeStoreErr(w, err) +} +func writeProjectJSON(w http.ResponseWriter, p any, err error) { + if err != nil { + writeProjectErr(w, err) + return + } + writeJSON(w, http.StatusOK, p) +} diff --git a/internal/server/project_lifecycle_test.go b/internal/server/project_lifecycle_test.go new file mode 100644 index 0000000..61ea6dc --- /dev/null +++ b/internal/server/project_lifecycle_test.go @@ -0,0 +1,199 @@ +package server + +import ( + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/surfacesvc" + "agent-system-backend/pkg/extension" + core "agent-system-core" + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +type lifecycleStatusError int + +func (e lifecycleStatusError) Error() string { return "provider refusal" } +func (e lifecycleStatusError) StatusCode() int { return int(e) } +func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + r.Header.Set("Authorization", "Bearer "+key) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + return w +} +func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Original") + c, _ := st.CreateConversation("alice", p.ID, "Private", "") + calls := []string{} + s.SetProjectLifecycle(extension.ProjectLifecycle{ + List: func(context.Context, extension.Host) ([]core.Project, error) { + calls = append(calls, "list") + return []core.Project{{ID: p.ID, Owner: "alice", Name: "Decorated", Settings: map[string]any{"rdm.state": "ready"}}}, nil + }, + Create: func(_ context.Context, _ extension.Host, name string) (core.Project, error) { + calls = append(calls, "create") + return core.Project{ID: "provider-created", Name: name}, nil + }, + Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) { + calls = append(calls, "rename") + return core.Project{ID: id, Name: name}, nil + }, + Archive: func(context.Context, extension.Host, string) error { calls = append(calls, "archive"); return nil }, + Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }, + }, nil) + for _, q := range []struct{ method, path, body string }{{"GET", "/api/projects", ""}, {"POST", "/api/projects", `{"name":" New "}`}, {"PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {"DELETE", "/api/projects/" + p.ID, ""}} { + w := lifecycleRequest(t, s, key, q.method, q.path, q.body) + if w.Code != 200 { + t.Fatalf("%s: %d %s", q.method, w.Code, w.Body.String()) + } + } + if len(calls) != 4 || calls[0] != "list" || calls[1] != "create" || calls[2] != "rename" || calls[3] != "archive" { + t.Fatalf("calls=%v", calls) + } + if _, err := st.GetConversation("alice", c.ID); err != nil { + t.Fatalf("archive removed history: %v", err) + } + s.SetProjectLifecycle(extension.ProjectLifecycle{Create: func(context.Context, extension.Host, string) (core.Project, error) { + return core.Project{}, fmtWrap(lifecycleStatusError(409)) + }}, nil) + if w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"dup"}`); w.Code != 409 { + t.Fatalf("trusted status=%d %s", w.Code, w.Body.String()) + } +} +func fmtWrap(err error) error { return errors.Join(errors.New("wrapped"), err) } +func TestProjectLifecycleMemberPrivateWork(t *testing.T) { + s, st := newAuthTestServer(t) + alice, _ := st.CreateAPIKey("alice", "test") + bob, _ := st.CreateAPIKey("bob", "test") + mallory, _ := st.CreateAPIKey("mallory", "test") + p, _ := st.CreateProject("alice", "Shared") + ac, _ := st.CreateConversation("alice", p.ID, "Alice private", "") + ready := true + s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil }) + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) { + return ready && (actor == "alice" || actor == "bob") && id == core.ID(p.ID), nil + }}, nil) + w := lifecycleRequest(t, s, bob, "POST", "/api/projects/"+p.ID+"/conversations", `{"title":"Bob private"}`) + if w.Code != 200 { + t.Fatalf("member create=%d %s", w.Code, w.Body.String()) + } + var bc core.Conversation + if err := json.Unmarshal(w.Body.Bytes(), &bc); err != nil { + t.Fatal(err) + } + w = lifecycleRequest(t, s, bob, "GET", "/api/projects/"+p.ID+"/conversations", "") + var cs []core.Conversation + _ = json.Unmarshal(w.Body.Bytes(), &cs) + if w.Code != 200 || len(cs) != 1 || cs[0].ID != bc.ID { + t.Fatalf("member list=%d %+v", w.Code, cs) + } + if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+ac.ID, ""); w.Code != 404 { + t.Fatalf("private history=%d", w.Code) + } + if w := lifecycleRequest(t, s, mallory, "POST", "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 { + t.Fatalf("nonmember create=%d", w.Code) + } + if w := lifecycleRequest(t, s, mallory, "GET", "/api/projects/"+p.ID+"/conversations", ""); w.Code != 404 { + t.Fatalf("nonmember list=%d", w.Code) + } + s.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() }) + s.WireBuiltinCommands() + w = lifecycleRequest(t, s, bob, "POST", "/api/conversations/"+bc.ID+"/messages", `{"text":"/new"}`) + if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte(`"conversation"`)) { + t.Fatalf("/new=%d %s", w.Code, w.Body.String()) + } + ready = false + for _, q := range []struct{ key, method, path, body string }{{bob, "POST", "/api/projects/" + p.ID + "/conversations", `{}`}, {bob, "GET", "/api/projects/" + p.ID + "/conversations", ""}, {alice, "POST", "/api/conversations/" + ac.ID + "/messages", `{"text":"new work"}`}, {bob, "POST", "/api/commands/conversation.create", `{"conversationId":"` + bc.ID + `"}`}} { + w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body) + if w.Code != 404 { + t.Fatalf("revoked %s=%d %s", q.path, w.Code, w.Body.String()) + } + } + if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+bc.ID, ""); w.Code != 200 { + t.Fatalf("owned revoked history=%d", w.Code) + } +} +func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Shared") + s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil }) + s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil) + if w := lifecycleRequest(t, s, key, http.MethodPost, "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 { + t.Fatalf("read-only write=%d %s", w.Code, w.Body.String()) + } + if cs, err := st.ListConversations("bob", p.ID); err != nil || len(cs) != 0 { + t.Fatalf("denial wrote state: %v %v", cs, err) + } +} + +func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Project") + conv, _ := st.CreateConversation("alice", p.ID, "Private", "") + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil) + card := core.HITLCard{ID: "hitl-revoked", ConversationID: conv.ID, RunID: "run-revoked", Kind: "confirm"} + if err := st.SaveHITLCard("alice", &card); err != nil { + t.Fatal(err) + } + for _, q := range []struct{ path, body string }{{"/api/conversations/" + conv.ID + "/regenerate", `{}`}, {"/api/hitl/" + card.ID + "/resolve", `{"choiceId":"yes"}`}} { + w := lifecycleRequest(t, s, key, "POST", q.path, q.body) + if w.Code != 404 { + t.Fatalf("resumption %s=%d %s", q.path, w.Code, w.Body.String()) + } + } +} + +func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Project") + conv, _ := st.CreateConversation("alice", p.ID, "Private", "") + svc := surfacesvc.New(st, nil) + s.Surfaces = svc + if _, err := svc.SetActivation(context.Background(), "alice", "generative-ui", true); err != nil { + t.Fatal(err) + } + snap, err := svc.Submit(context.Background(), "alice", "generative-ui", surfacesvc.SubmitRequest{ConversationID: conv.ID, Spec: json.RawMessage(`{"title":"Page","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"Submit"}]}]}`)}) + if err != nil { + t.Fatal(err) + } + s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil) + w := lifecycleRequest(t, s, key, "POST", "/api/surfaces/"+snap.Identity.SurfaceID+"/actions", `{"actionId":"generative-ui.submit","revision":1,"requestId":"request"}`) + if w.Code != 404 { + t.Fatalf("revoked surface action=%d %s", w.Code, w.Body.String()) + } +} + +func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("alice", "test") + p, _ := st.CreateProject("alice", "Project") + s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) { + current, err := st.GetProject("alice", p.ID) + if err != nil { + return nil, err + } + if current.Settings == nil { + current.Settings = map[string]any{} + } + current.Settings["rdm.projectId"] = "canonical" + current.Settings["rdm.state"] = "ready" + return []core.Project{current}, nil + }}, nil) + w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"rdm.projectId":"forged","example":true}}`) + var got core.Project + _ = json.Unmarshal(w.Body.Bytes(), &got) + if w.Code != 200 || got.Settings["rdm.projectId"] != "canonical" || got.Settings["example"] != true { + t.Fatalf("settings lost service metadata: %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/server/project_resource_filter_test.go b/internal/server/project_resource_filter_test.go new file mode 100644 index 0000000..581f147 --- /dev/null +++ b/internal/server/project_resource_filter_test.go @@ -0,0 +1,100 @@ +package server + +import ( + "agent-system-backend/pkg/extension" + core "agent-system-core" + "context" + "encoding/json" + "testing" +) + +func TestKnowledgeV2UsesDocumentIdentity(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + docs := []core.KnowledgeDoc{{ID: "published-slice", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle published"}, {ID: "private-draft", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle draft"}, {ID: "self-denied", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own restricted"}, {ID: "self-allowed", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own"}} + if err := st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil }) + seen := map[string]extension.ResourceAuthzQuery{} + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + seen[q.ResourceID] = q + return q.ResourceType == "project" || q.ResourceID == "published-slice" || q.ResourceID == "self-allowed", nil + }) + w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sharedneedle", "") + var hits []core.RetrievalHit + _ = json.Unmarshal(w.Body.Bytes(), &hits) + if w.Code != 200 || len(hits) != 2 { + t.Fatalf("V2 filtered hits=%d %s", w.Code, w.Body.String()) + } + for _, hit := range hits { + if hit.Doc.ID != "published-slice" && hit.Doc.ID != "self-allowed" { + t.Fatalf("private leak: %+v", hit.Doc) + } + } + q := seen["published-slice"] + if q.ResourceType != "knowledge" || q.Actor != "bob" || q.Action != "search" || q.Owner != "alice" || q.ProjectID != "domain-p" { + t.Fatalf("trusted doc query: %+v", q) + } +} +func TestArtifactListV2FiltersEachResource(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + p, _ := st.CreateProject("alice", "Project") + private := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Private", MIME: "text/plain"} + public := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Shared", MIME: "text/plain"} + if err := st.CreateArtifact(&private, []byte("private")); err != nil { + t.Fatal(err) + } + if err := st.CreateArtifact(&public, []byte("shared")); err != nil { + t.Fatal(err) + } + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + return q.ResourceType == "project" || (q.ResourceType == "artifact" && q.Action == "list" && q.ResourceID == public.ID && q.Owner == "alice"), nil + }) + w := lifecycleRequest(t, s, key, "GET", "/api/projects/"+p.ID+"/artifacts", "") + var as []core.Artifact + _ = json.Unmarshal(w.Body.Bytes(), &as) + if w.Code != 200 || len(as) != 1 || as[0].ID != public.ID { + t.Fatalf("artifact list=%d %s", w.Code, w.Body.String()) + } +} + +func TestKnowledgeV2ResolvesTrustedProjectScope(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil { + t.Fatal(err) + } + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { return q.ResourceType == "knowledge", nil }) + s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) { + return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}, {ID: "host-other", Settings: map[string]any{"rdm.projectId": "domain-other", "rdm.state": "ready"}}}, nil + }}, nil) + w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=host-current", "") + var hits []core.RetrievalHit + _ = json.Unmarshal(w.Body.Bytes(), &hits) + if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "slice-current" { + t.Fatalf("current scope=%d %s", w.Code, w.Body.String()) + } + if w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=unknown", ""); w.Code != 404 { + t.Fatalf("unknown scope=%d %s", w.Code, w.Body.String()) + } +} + +func TestKnowledgeV2UsesStoredPublicationSource(t *testing.T) { + s, st := newAuthTestServer(t) + key, _ := st.CreateAPIKey("bob", "test") + // A known published identity replaced by a private row must lose sharing. + if err := st.AddKnowledge(core.KnowledgeDoc{ID: "known-published-id", Owner: "alice", ProjectID: "domain", Source: "manual", Chunk: "sourceneedle private"}, core.KnowledgeDoc{ID: "real-published-id", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sourceneedle public"}); err != nil { + t.Fatal(err) + } + s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { + return q.ResourceType == "knowledge" && q.Source == "plugin:rdm:published", nil + }) + w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sourceneedle", "") + var hits []core.RetrievalHit + _ = json.Unmarshal(w.Body.Bytes(), &hits) + if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "real-published-id" { + t.Fatalf("actual stored source=%d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/server/resource_authz_consumption_test.go b/internal/server/resource_authz_consumption_test.go index c29b832..eebdead 100644 --- a/internal/server/resource_authz_consumption_test.go +++ b/internal/server/resource_authz_consumption_test.go @@ -77,7 +77,7 @@ func TestSharedReadPathsConsultV2WithResourceContext(t *testing.T) { t.Fatalf("download 查询六要素不符: %+v", dl) } ls := byAction["list"] - if ls.Actor != "bob" || ls.ResourceType != "project" || ls.ResourceID != pid || ls.ProjectID != pid { + if ls.Actor != "bob" || ls.ResourceType != "artifact" || ls.ResourceID != art.ID || ls.ProjectID != pid || ls.Owner != "alice" { t.Fatalf("list 查询不符: %+v", ls) } rd := byAction["read"] diff --git a/internal/server/server.go b/internal/server/server.go index c3f0596..c36f113 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -26,6 +26,7 @@ import ( "agent-system-backend/internal/intentgate" "agent-system-backend/internal/plugins" "agent-system-backend/internal/profiles" + "agent-system-backend/internal/projectscope" "agent-system-backend/internal/prompts" "agent-system-backend/internal/ratelimit" "agent-system-backend/internal/route" @@ -41,24 +42,26 @@ import ( ) type Server struct { - st *store.Store - Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入) - Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入) - Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入) - Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入) - LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入) - mgr *runmgr.Manager - Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入) - Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17) - Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27) - Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接) - Workflows *workflow.Registry // 已装载工作流(nil = 不接) - Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退) - reg *tools.Registry - prof *profiles.Service - set *settings.Service - plugins *plugins.Host - static fs.FS + projectLifecycle *extension.ProjectLifecycle + projectHost func(string) extension.Host + st *store.Store + Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入) + Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入) + Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入) + Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入) + LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入) + mgr *runmgr.Manager + Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入) + Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17) + Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27) + Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接) + Workflows *workflow.Registry // 已装载工作流(nil = 不接) + Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退) + reg *tools.Registry + prof *profiles.Service + set *settings.Service + plugins *plugins.Host + static fs.FS // Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。 Subscribe func(conversationID string) (<-chan core.StreamEvent, func()) // DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与 @@ -206,7 +209,7 @@ func (s *Server) WireBuiltinCommands() { if err != nil { return nil, err } - c, err := s.st.CreateConversation(conv.Owner, conv.ProjectID, "新会话", conv.ProfileID) + c, err := s.createPrivateConversation(ctx, conv.Owner, conv.ProjectID, "新会话", conv.ProfileID) if err != nil { return nil, err } @@ -612,6 +615,23 @@ type projectView struct { func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) + if s.projectLifecycle != nil { + if s.projectLifecycle.List == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + ps, err := s.projectLifecycle.List(r.Context(), s.lifecycleHost(owner)) + if err != nil { + writeProjectErr(w, err) + return + } + out := make([]projectView, 0, len(ps)) + for _, p := range ps { + out = append(out, projectView{Project: p, Shared: p.Owner != owner}) + } + writeJSON(w, http.StatusOK, out) + return + } ps, err := s.st.ListProjects(owner) if err != nil { writeStoreErr(w, err) @@ -648,6 +668,15 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { http.Error(w, "name required", http.StatusBadRequest) return } + if s.projectLifecycle != nil { + if s.projectLifecycle.Create == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name)) + writeProjectJSON(w, p, err) + return + } p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name)) writeJSONOr(w, p, err) } @@ -662,6 +691,50 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + if s.projectLifecycle != nil { + var p core.Project + var err error + if req.Name != nil { + if strings.TrimSpace(*req.Name) == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + if s.projectLifecycle.Rename == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + p, err = s.projectLifecycle.Rename(r.Context(), s.lifecycleHost(s.owner(r)), id, strings.TrimSpace(*req.Name)) + if err != nil { + writeProjectErr(w, err) + return + } + } + if req.Settings != nil { + if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil { + writeStoreErr(w, err) + return + } + } + if req.Name == nil || req.Settings != nil { + if s.projectLifecycle.List == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + var projects []core.Project + projects, err = s.projectLifecycle.List(r.Context(), s.lifecycleHost(s.owner(r))) + if err == nil { + err = store.ErrNotFound + for _, candidate := range projects { + if candidate.ID == id { + p, err = candidate, nil + break + } + } + } + } + writeProjectJSON(w, p, err) + return + } if req.Name != nil { if err := s.st.RenameProject(s.owner(r), id, *req.Name); err != nil { writeStoreErr(w, err) @@ -679,12 +752,29 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { } func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) { + if s.projectLifecycle != nil { + if s.projectLifecycle.Archive == nil { + writeProjectErr(w, store.ErrNotFound) + return + } + if err := s.projectLifecycle.Archive(r.Context(), s.lifecycleHost(s.owner(r)), r.PathValue("id")); err != nil { + writeProjectErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } writeOKOr(w, s.st.DeleteProject(s.owner(r), r.PathValue("id"))) } func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) - if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil { + if s.projectLifecycle != nil { + if err := s.projectWork(r.Context(), owner, r.PathValue("id")); err != nil { + writeProjectErr(w, err) + return + } + } else if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil { writeStoreErr(w, err) return } @@ -728,13 +818,8 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) { if req.Title == "" { req.Title = "新会话" } - c, err := s.st.CreateConversation(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) - // EXT-15:自有未命中且适配器放行(成员视角的共享项目)→ 会话仍归属创建者, - // 只是落在他人项目下;撤销授权后此路径立即 404(不泄露存在性)。 - if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(s.owner(r), "read", "project", core.ID(r.PathValue("id")), "", core.ID(r.PathValue("id"))) { - c, err = s.st.CreateConversationInProject(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) - } - writeJSONOr(w, c, err) + c, err := s.createPrivateConversation(r.Context(), s.owner(r), r.PathValue("id"), req.Title, req.ProfileID) + writeProjectJSON(w, c, err) } // conversationDetail 终态对账权威:消息树 + runs + 活动 HITL 卡片。 @@ -813,6 +898,10 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } + if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil { + writeProjectErr(w, err) + return + } var req struct { Text string `json:"text"` AttachmentIDs []string `json:"attachmentIds"` @@ -968,7 +1057,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { } if startErr != nil { s.mgr.ReleaseConversation(convID) - writeStoreErr(w, startErr) + writeProjectErr(w, startErr) return } _ = s.st.SetMessageRunID(msg.ID, run.ID) @@ -1073,10 +1162,21 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) { } var hostProject string if body.ConversationID != "" { - if conv, err := s.st.GetConversation(s.owner(r), body.ConversationID); err == nil { + conv, err := s.st.GetConversation(s.owner(r), body.ConversationID) + if err != nil { + if s.projectLifecycle != nil { + writeStoreErr(w, err) + return + } + } else { hostProject = string(conv.ProjectID) + if err := s.projectWork(r.Context(), s.owner(r), hostProject); err != nil { + writeProjectErr(w, err) + return + } } } + resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{ ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r), Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID, @@ -1155,6 +1255,22 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) { return } resp.CardID = r.PathValue("id") + if s.projectLifecycle != nil { + card, err := s.st.GetHITLCard(s.owner(r), resp.CardID) + if err != nil { + writeStoreErr(w, err) + return + } + conv, err := s.st.GetConversation(s.owner(r), card.ConversationID) + if err != nil { + writeStoreErr(w, err) + return + } + if err := s.projectWork(r.Context(), s.owner(r), conv.ProjectID); err != nil { + writeProjectErr(w, err) + return + } + } pivotText, pivoted, err := s.mgr.ResolveHITL(s.owner(r), string(resp.CardID), resp) if err != nil && s.st.Dialect() == "postgres" { // 跨实例 HITL(U5/KTD5):本地无执行句柄——写持久命令,执行实例 @@ -1221,6 +1337,28 @@ func (s *Server) cardToProject(w http.ResponseWriter, r *http.Request) { // cardAction 处理卡上按钮:resolved + 回执消息进对话时间线(确认即留痕)。 func (s *Server) cardAction(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) + if s.projectLifecycle != nil { + card, err := s.st.GetCard(owner, r.PathValue("id")) + if err != nil { + writeStoreErr(w, err) + return + } + pid := card.ProjectID + if card.ConversationID != "" { + conv, err := s.st.GetConversation(owner, card.ConversationID) + if err != nil { + writeStoreErr(w, err) + return + } + pid = conv.ProjectID + } + if pid != "" { + if err := s.projectWork(r.Context(), owner, pid); err != nil { + writeProjectErr(w, err) + return + } + } + } var body struct { FreeText string `json:"freeText,omitempty"` } @@ -1345,20 +1483,32 @@ func kindOf(filename string) string { } func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) { - owner := s.owner(r) - pid := r.PathValue("id") - if _, err := s.st.GetProject(owner, pid); err != nil { - // EXT-8b:资源级咨询(list×project);放行则返回项目全部工件(只读共享)。 + owner, pid := s.owner(r), r.PathValue("id") + var as []core.Artifact + var err error + if _, ownedErr := s.st.GetProject(owner, pid); ownedErr != nil { if !s.authorizeSharedResource(owner, "list", "project", core.ID(pid), "", core.ID(pid)) { - writeStoreErr(w, err) + writeStoreErr(w, ownedErr) return } - as, err := s.st.ListProjectArtifactsAll(pid) - writeJSONOr(w, as, err) + as, err = s.st.ListProjectArtifactsAll(pid) + } else { + as, err = s.st.ListArtifacts(owner, pid) + } + if err != nil { + writeStoreErr(w, err) return } - as, err := s.st.ListArtifacts(owner, pid) - writeJSONOr(w, as, err) + if s.ProjectAuthzV2 != nil { + filtered := make([]core.Artifact, 0, len(as)) + for _, a := range as { + if s.authorizeSharedResource(owner, "list", "artifact", a.ID, a.Owner, a.ProjectID) { + filtered = append(filtered, a) + } + } + as = filtered + } + writeJSON(w, http.StatusOK, as) } // sharedArtifactGet EXT-8 工件读取回退:owner 自有未命中 → 取元数据问适配器, @@ -1847,6 +1997,49 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) { topK, _ := strconv.Atoi(r.URL.Query().Get("topK")) query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK} owner := s.owner(r) + if s.projectLifecycle != nil && query.ProjectID != "" { + id, err := projectscope.Resolve(r.Context(), s.lifecycleHost(owner), *s.projectLifecycle, query.ProjectID) + if err != nil { + writeProjectErr(w, err) + return + } + query.ProjectID = id + } + if s.ProjectAuthzV2 != nil { + allow := func(d core.KnowledgeDoc) bool { + ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source}) + return err == nil && ok + } + if rs, ok := s.Retriever.(interface { + RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) + }); ok { + hits, err := rs.RetrieveAuthorized(r.Context(), owner, query, allow) + writeJSONOr(w, hits, err) + return + } + hits, err := s.st.SearchKnowledge(owner, query) + if err != nil { + writeStoreErr(w, err) + return + } + filtered := make([]core.RetrievalHit, 0, len(hits)) + for _, h := range hits { + if allow(h.Doc) { + filtered = append(filtered, h) + } + } + shared, err := s.st.SearchKnowledgeSharedAuthorized(owner, query, allow) + if err != nil { + writeStoreErr(w, err) + return + } + filtered = append(filtered, shared...) + if query.TopK > 0 && len(filtered) > query.TopK { + filtered = filtered[:query.TopK] + } + writeJSON(w, http.StatusOK, filtered) + return + } // EXT-8 余项:Retriever 支持共享版(向量+全文两路追加)时整体走共享版; // 否则维持切片 2 行为(本人检索 + 全文共享追加)。 allow := func(docOwner, projectID core.ID) bool { diff --git a/internal/store/content.go b/internal/store/content.go index 10331c2..df16303 100644 --- a/internal/store/content.go +++ b/internal/store/content.go @@ -381,13 +381,46 @@ func sharedVisible(requester string, q core.RetrievalQuery, allow func(docOwner, // 经 allow(docOwner, projectID) 放行的命中(全文通道;向量通道共享仍留后续切片)。 // allow 出错由调用方前置处理——本层只见布尔。 func (s *Store) SearchKnowledgeShared(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) { - pred := sharedVisible(requester, q, allow) + return s.SearchKnowledgeSharedAuthorized(requester, q, func(d core.KnowledgeDoc) bool { return allow(d.Owner, d.ProjectID) }) +} + +// SearchKnowledgeSharedAuthorized evaluates the trusted document identity before +// returning a shared row. Project-level permissions cannot grant private content. +func (s *Store) SearchKnowledgeSharedAuthorized(requester string, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { + pred := func(d core.KnowledgeDoc) bool { + return d.Owner != "" && d.Owner != requester && d.ProjectID != "" && + (q.ProjectID == "" || d.ProjectID == q.ProjectID) && + (len(q.Sources) == 0 || containsStr(q.Sources, d.Source)) && allow(d) + } if s.dialect.name() == "postgres" { return s.searchKnowledgePGShared(requester, q, pred) } return s.searchKnowledgeSQLiteShared(requester, q, pred) } +// DeleteProjectKnowledgeByRef replaces one publication scope and its vectors in +// a single transaction. Empty project is the legacy unscoped publication domain. +func (s *Store) DeleteProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + args := []any{owner, ref} + pred := "owner=? AND ref=?" + if projectID != "" { + pred += " AND project_id=?" + args = append(args, projectID) + } + if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...); err != nil { + return err + } + return tx.Commit() +} + func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) { out, err := s.searchKnowledgeFTS(ownerVisible(owner, q), q) if err != nil { diff --git a/internal/store/project_projection.go b/internal/store/project_projection.go new file mode 100644 index 0000000..13aa99d --- /dev/null +++ b/internal/store/project_projection.go @@ -0,0 +1,149 @@ +package store + +import ( + core "agent-system-core" + "database/sql" + "errors" + "strings" + "time" +) + +// lockProjectNames serializes projection identity and name admission across all +// owners. SQLite acquires its write reservation before any reads; PostgreSQL +// uses a transaction-scoped advisory lock shared by every projection mutation. +func (s *Store) lockProjectNames(tx *sql.Tx) error { + if s.dialect.name() == "sqlite" { + _, err := tx.Exec(`UPDATE projects SET id=id WHERE 1=0`) + return err + } + return s.dialect.lockScope(tx, "", "host-project-names") +} + +// EnsureProject creates a stable projection or verifies an identical retry. +func (s *Store) EnsureProject(owner, id, name string) (core.Project, error) { + name = strings.TrimSpace(name) + if owner == "" || id == "" || name == "" { + return core.Project{}, errors.New("store: project owner, id and name required") + } + tx, err := s.db.Begin() + if err != nil { + return core.Project{}, err + } + defer tx.Rollback() + if err = s.lockProjectNames(tx); err != nil { + return core.Project{}, err + } + p, err := s.projectMetadata(tx, id) + if err == nil { + if p.Owner != owner { + return core.Project{}, ErrNotFound + } + if p.Name != name { + return core.Project{}, ErrConflict + } + return p, tx.Commit() + } + if !errors.Is(err, ErrNotFound) { + return core.Project{}, err + } + available, err := s.projectNameAvailable(tx, name, id) + if err != nil { + return core.Project{}, err + } + if !available { + return core.Project{}, ErrConflict + } + now := time.Now().UTC() + p = core.Project{ID: id, Owner: owner, Name: name, CreatedAt: now, UpdatedAt: now, Settings: map[string]any{}} + _, err = tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`), id, owner, name, "{}", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) + if err != nil { + return core.Project{}, err + } + return p, tx.Commit() +} + +func (s *Store) RenameOwnedProject(owner, id, name string) (core.Project, error) { + name = strings.TrimSpace(name) + if name == "" { + return core.Project{}, errors.New("store: project name required") + } + tx, err := s.db.Begin() + if err != nil { + return core.Project{}, err + } + defer tx.Rollback() + if err = s.lockProjectNames(tx); err != nil { + return core.Project{}, err + } + p, err := s.projectMetadata(tx, id) + if err != nil { + return core.Project{}, err + } + if p.Owner != owner { + return core.Project{}, ErrNotFound + } + available, err := s.projectNameAvailable(tx, name, id) + if err != nil { + return core.Project{}, err + } + if !available { + return core.Project{}, ErrConflict + } + p.Name, p.UpdatedAt = name, time.Now().UTC() + _, err = tx.Exec(s.q(`UPDATE projects SET name=?,updated_at=? WHERE id=? AND owner=?`), p.Name, p.UpdatedAt.Format(time.RFC3339Nano), id, owner) + if err != nil { + return core.Project{}, err + } + return p, tx.Commit() +} + +type projectQuerier interface { + QueryRow(string, ...any) *sql.Row + Query(string, ...any) (*sql.Rows, error) +} + +func (s *Store) projectNameAvailable(q projectQuerier, name, exceptID string) (bool, error) { + // Use the same Go whitespace rule as admission, including legacy raw names. + rows, err := q.Query(s.q(`SELECT name FROM projects WHERE id<>?`), exceptID) + if err != nil { + return false, err + } + defer rows.Close() + name = strings.TrimSpace(name) + for rows.Next() { + var candidate string + if err := rows.Scan(&candidate); err != nil { + return false, err + } + if strings.TrimSpace(candidate) == name { + return false, nil + } + } + return true, rows.Err() +} + +func (s *Store) ProjectNameAvailable(name, exceptID string) (bool, error) { + return s.projectNameAvailable(s.db, name, exceptID) +} +func (s *Store) projectMetadata(q projectQuerier, id string) (core.Project, error) { + var p core.Project + var settings, ca, ua string + err := q.QueryRow(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects WHERE id=?`), id).Scan(&p.ID, &p.Owner, &p.Name, &settings, &ca, &ua) + if errors.Is(err, sql.ErrNoRows) { + return p, ErrNotFound + } + if err != nil { + return p, err + } + p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca) + p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua) + p.Settings = map[string]any{} + if settings != "" && settings != "{}" { + p.Settings, _ = unmarshalJSON[map[string]any](settings) + } + return p, nil +} + +// ProjectMetadata is an internal trusted lookup. Capabilities must authorize it +// before exposing metadata to another owner. +func (s *Store) ProjectMetadata(id string) (core.Project, error) { return s.projectMetadata(s.db, id) } diff --git a/internal/store/project_projection_test.go b/internal/store/project_projection_test.go new file mode 100644 index 0000000..199aa21 --- /dev/null +++ b/internal/store/project_projection_test.go @@ -0,0 +1,83 @@ +package store + +import ( + "errors" + "sync" + "testing" +) + +func TestProjectProjectionIdentityAndOwnership(t *testing.T) { + st := openTestStore(t) + p, err := st.EnsureProject("alice", "host-stable", " Global ") + if err != nil || p.ID != "host-stable" || p.Name != "Global" || p.Owner != "alice" { + t.Fatalf("ensure: %+v %v", p, err) + } + retry, err := st.EnsureProject("alice", p.ID, "Global") + if err != nil || retry.ID != p.ID { + t.Fatalf("retry: %+v %v", retry, err) + } + if _, err = st.EnsureProject("bob", p.ID, "Global"); !errors.Is(err, ErrNotFound) { + t.Fatalf("other owner: %v", err) + } + if _, err = st.EnsureProject("alice", p.ID, "Different"); !errors.Is(err, ErrConflict) { + t.Fatalf("different retry name: %v", err) + } + if _, err = st.RenameOwnedProject("bob", p.ID, "Other"); !errors.Is(err, ErrNotFound) { + t.Fatalf("rename owner: %v", err) + } +} + +func TestProjectProjectionGlobalNameAdmission(t *testing.T) { + st := openTestStore(t) + if _, err := st.CreateProject("legacy", "Taken"); err != nil { + t.Fatal(err) + } + if _, err := st.EnsureProject("alice", "a", " Taken "); !errors.Is(err, ErrConflict) { + t.Fatalf("legacy collision: %v", err) + } + var wg sync.WaitGroup + results := make(chan error, 2) + for _, owner := range []string{"alice", "bob"} { + wg.Add(1) + go func(owner string) { + defer wg.Done() + _, err := st.EnsureProject(owner, "p-"+owner, "Race") + results <- err + }(owner) + } + wg.Wait() + close(results) + successes, conflicts := 0, 0 + for err := range results { + if err == nil { + successes++ + } else if errors.Is(err, ErrConflict) { + conflicts++ + } else { + t.Fatal(err) + } + } + if successes != 1 || conflicts != 1 { + t.Fatalf("successes=%d conflicts=%d", successes, conflicts) + } + if _, err := st.EnsureProject("alice", "rename-a", "Rename source"); err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProject("alice", "rename-a", "Taken"); !errors.Is(err, ErrConflict) { + t.Fatalf("rename collision: %v", err) + } + available, err := st.ProjectNameAvailable("Taken", "") + if err != nil || available { + t.Fatalf("availability=%v %v", available, err) + } +} + +func TestProjectProjectionAdmissionTrimsLegacyWhitespace(t *testing.T) { + st := openTestStore(t) + if _, err := st.CreateProject("legacy", "\tReserved\u00a0"); err != nil { + t.Fatal(err) + } + if _, err := st.EnsureProject("other", "new-id", "Reserved"); !errors.Is(err, ErrConflict) { + t.Fatalf("trimmed legacy admission: %v", err) + } +} diff --git a/internal/store/vectors.go b/internal/store/vectors.go index 02d653a..e98c544 100644 --- a/internal/store/vectors.go +++ b/internal/store/vectors.go @@ -121,7 +121,16 @@ func (s *Store) SearchKnowledgeVectorsSharedModel(ctx context.Context, requester return hits, nil } } - return s.searchKnowledgeVectorsSharedScan(ctx, requester, model, vec, topK, filter, allow) + return s.SearchKnowledgeVectorsSharedAuthorized(ctx, requester, model, vec, topK, filter, func(d core.KnowledgeDoc) bool { return allow == nil || allow(d.Owner, d.ProjectID) }) +} + +// SearchKnowledgeVectorsSharedAuthorized applies per-document authorization to +// shared vector candidates before ranking, including their actual stored ID. +func (s *Store) SearchKnowledgeVectorsSharedAuthorized(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { + if topK <= 0 { + topK = 5 + } + return s.searchKnowledgeVectorsSharedAuthorizedScan(ctx, requester, model, vec, topK, filter, allow) } // allowAll 判定谓词是否恒真(nil 或恒放行)——库内路径的启用条件。 @@ -187,6 +196,9 @@ func (s *Store) searchKnowledgeVectorsPG(ctx context.Context, owner, model strin // searchKnowledgeVectorsSharedScan 原进程内共享扫描(降级/谓词路径)。 func (s *Store) searchKnowledgeVectorsSharedScan(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) { + return s.searchKnowledgeVectorsSharedAuthorizedScan(ctx, requester, model, vec, topK, filter, func(d core.KnowledgeDoc) bool { return allow == nil || allow(d.Owner, d.ProjectID) }) +} +func (s *Store) searchKnowledgeVectorsSharedAuthorizedScan(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) { q := ` SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, v.vec FROM knowledge_vectors v JOIN knowledge k ON k.id = v.doc_id @@ -215,7 +227,7 @@ func (s *Store) searchKnowledgeVectorsSharedScan(ctx context.Context, requester, } d.Owner = own d.CreatedAt, _ = parseTime(ca) - if d.ProjectID == "" || !allow(d.Owner, d.ProjectID) { + if d.ProjectID == "" || !allow(d) { continue } if filter.ProjectID != "" && d.ProjectID != filter.ProjectID { diff --git a/pkg/extension/account_lister_test.go b/pkg/extension/account_lister_test.go index d448879..3932178 100644 --- a/pkg/extension/account_lister_test.go +++ b/pkg/extension/account_lister_test.go @@ -5,14 +5,21 @@ package extension // 不含姓名/邮箱/联系方式)。红:类型未定义,编译红先行。 import ( "encoding/json" + "strconv" "strings" "testing" ) func TestAccountListerSurface(t *testing.T) { - if !strings.HasPrefix(SurfaceVersion, "2.11.0") { - t.Fatalf("SurfaceVersion 应升至 2.11.0-experimental(新增导出符号须升版),got %s", SurfaceVersion) + parts := strings.Split(SurfaceVersion, ".") + minor := 0 + if len(parts) > 1 { + minor, _ = strconv.Atoi(parts[1]) } + if len(parts) < 2 || parts[0] != "2" || minor < 11 { + t.Fatalf("AccountLister requires experimental version 2.11 or later, got %s", SurfaceVersion) + } + // 接口方法签名即契约:游标分页 + 最小字段。 var _ AccountLister = (AccountLister)(nil) e := AccountEntry{Owner: "u1", Valid: true} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 976880d..2454e4e 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -32,7 +32,9 @@ import ( // 2.9.0-experimental:Registry.AddAdminZone 与 AdminZoneSpec(WP3-I3/KTD4:编译 // 插件向基座管理区声明展示分区;分区入口不做基座级权限判定,数据能力由 // 分区组件自身的命令面 fail-closed)。 -const SurfaceVersion = "2.11.0-experimental" +// 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted +// ResourceAuthzQuery.Source for per-document publication authorization. +const SurfaceVersion = "2.12.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") @@ -43,15 +45,17 @@ var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent- type ResourceAuthorizer func(owner string, projectID core.ID) (bool, error) // ResourceAuthzQuery 是 EXT-8b 资源级授权查询(U9):字段由执行端从受信上下文 -//(认证会话、受信存储)构造,不采信请求自报的归属;下游只回答布尔,不接触基座 +// (认证会话、受信存储)构造,不采信请求自报的归属;下游只回答布尔,不接触基座 // 内部状态;返回 error 视为拒绝(fail-closed)。读取路径专用;写路径一律 owner 自有。 type ResourceAuthzQuery struct { - Actor string `json:"actor"` // 认证主体(发起读取的会话) - Action string `json:"action"` // read|download|list|search|stream|model-context - ResourceType string `json:"resourceType"` // 基座资源类型(artifact/attachment/...) - ResourceID core.ID `json:"resourceId"` // 资源稳定 ID - Owner string `json:"owner"` // 资源属主(受信存储解析,非请求自报) - ProjectID core.ID `json:"projectId,omitempty"` // 所属项目;空=无项目私域 + Actor string `json:"actor"` // 认证主体(发起读取的会话) + Action string `json:"action"` // read|download|list|search|stream|model-context + ResourceType string `json:"resourceType"` // 基座资源类型(artifact/attachment/...) + ResourceID core.ID `json:"resourceId"` // 资源稳定 ID + Owner string `json:"owner"` // 资源属主(受信存储解析,非请求自报) + // Source is optional trusted resource provenance, populated from stored knowledge rows. + Source string `json:"source,omitempty"` + ProjectID core.ID `json:"projectId,omitempty"` // 所属项目;空=无项目私域 } // ResourceAuthorizerV2 EXT-8b 资源级授权适配器:V1(owner×project 布尔)之上的 diff --git a/pkg/extension/projects.go b/pkg/extension/projects.go new file mode 100644 index 0000000..259e4dd --- /dev/null +++ b/pkg/extension/projects.go @@ -0,0 +1,35 @@ +package extension + +import ( + core "agent-system-core" + "context" +) + +// ProjectHost is an optional actor-scoped host projection capability. Metadata +// can be shared by an authorizer; owned lookups and mutations remain owner-only. +type ProjectHost interface { + Owner() string + OwnedProjects() ([]core.Project, error) + ProjectMetadata(projectID string) (core.Project, error) + OwnedProject(projectID string) (core.Project, error) + EnsureProject(projectID, name string) (core.Project, error) + RenameOwnedProject(projectID, name string) (core.Project, error) + ProjectNameAvailable(name, exceptID string) (bool, error) +} + +// ProjectLifecycle supplies the domain's public project lifecycle and live work +// authorization. Each Host is scoped to the authenticated actor. Errors with a +// StatusCode() int method may report 400, 403, 404, 409, or 503 to HTTP clients. +type ProjectLifecycle struct { + List func(context.Context, Host) ([]core.Project, error) + Create func(context.Context, Host, string) (core.Project, error) + Rename func(context.Context, Host, string, string) (core.Project, error) + Archive func(context.Context, Host, string) error + Work func(context.Context, string, core.ID) (bool, error) +} + +// ProjectLifecycleRegistry is optional so older Registry implementations retain +// their contract. Only in-process plugins can register one complete provider. +type ProjectLifecycleRegistry interface { + RegisterProjectLifecycle(ProjectLifecycle) error +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 8f4cfaf..7d88ba4 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -32,6 +32,17 @@ Main NewHostClient NewScopedHost Options +ProjectHost +ProjectHost.EnsureProject +ProjectHost.OwnedProject +ProjectHost.OwnedProjects +ProjectHost.Owner +ProjectHost.ProjectMetadata +ProjectHost.ProjectNameAvailable +ProjectHost.RenameOwnedProject +ProjectLifecycle +ProjectLifecycleRegistry +ProjectLifecycleRegistry.RegisterProjectLifecycle RegisterCompiled Registry Registry.AddAdminZone From b9bea5bee080b1e23ea5fa33af1878146ecd5e04 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 08:47:19 +0000 Subject: [PATCH 12/16] Filter private knowledge before applying shared result limits Why: Higher-ranked private rows could exhaust SQL candidate limits and hide an otherwise authorized published slice from a member's current-project search. Evidence: SELF TestAuthorizedKnowledgeFiltersBeforeTopK failed with no shared hits before the change and passed after it. SELF go test ./... and the isolated PostgreSQL Store suite passed; independent replay and current-HEAD CI remain owner gates. Impact: Scan the resource-authorized shared fulltext path until enough allowed results are found. Preserve ordinary legacy retrieval candidate limits and semantics. Refs: Dev project-member-access plan r1 Task 1; R5 published knowledge availability --- internal/store/content.go | 47 +++++++++++++++---- .../store/knowledge_authorization_test.go | 22 +++++++++ internal/store/search_postgres.go | 15 ++++-- 3 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 internal/store/knowledge_authorization_test.go diff --git a/internal/store/content.go b/internal/store/content.go index df16303..f203824 100644 --- a/internal/store/content.go +++ b/internal/store/content.go @@ -381,7 +381,11 @@ func sharedVisible(requester string, q core.RetrievalQuery, allow func(docOwner, // 经 allow(docOwner, projectID) 放行的命中(全文通道;向量通道共享仍留后续切片)。 // allow 出错由调用方前置处理——本层只见布尔。 func (s *Store) SearchKnowledgeShared(requester string, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) { - return s.SearchKnowledgeSharedAuthorized(requester, q, func(d core.KnowledgeDoc) bool { return allow(d.Owner, d.ProjectID) }) + pred := sharedVisible(requester, q, allow) + if s.dialect.name() == "postgres" { + return s.searchKnowledgePGShared(requester, q, pred) + } + return s.searchKnowledgeSQLiteShared(requester, q, pred) } // SearchKnowledgeSharedAuthorized evaluates the trusted document identity before @@ -393,9 +397,16 @@ func (s *Store) SearchKnowledgeSharedAuthorized(requester string, q core.Retriev (len(q.Sources) == 0 || containsStr(q.Sources, d.Source)) && allow(d) } if s.dialect.name() == "postgres" { - return s.searchKnowledgePGShared(requester, q, pred) + return s.searchKnowledgePGSharedCandidates(requester, q, pred, false) } - return s.searchKnowledgeSQLiteShared(requester, q, pred) + 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 replaces one publication scope and its vectors in @@ -452,14 +463,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 } @@ -482,12 +502,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/knowledge_authorization_test.go b/internal/store/knowledge_authorization_test.go new file mode 100644 index 0000000..2ef80c1 --- /dev/null +++ b/internal/store/knowledge_authorization_test.go @@ -0,0 +1,22 @@ +package store + +import ( + core "agent-system-core" + "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) + } +} 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 } From 4f72aaf3d8eaef1ae08e3df2d3c94f7bf913d615 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 09:04:06 +0000 Subject: [PATCH 13/16] Recheck resumed project work and isolate publication replacement Why: Membership could be revoked while a tool waited for confirmation or a queued HITL reply waited for consumption. Publication replacement also crossed an empty project scope or another plugin's source namespace for the same Ref. Evidence: SELF replay of all four independent review reproductions failed before the fix and passed unchanged afterward. Committed regressions cover direct and queued HITL, yes/session confirmation, direct artifact writes, protected vectors, exact empty project scopes, literal wildcard IDs and unregistered compatibility. SELF uncached go test ./..., isolated PostgreSQL Store tests and static build passed. Impact: Recheck trusted live owner/project authorization when HITL resumes and at write boundaries. Registered publishers replace only exact owner/project/ref/plugin rows and vectors; ordinary unregistered publication semantics are preserved. Refs: Dev project-member-access implementation plan r1 Task 1; independent Backend review findings against b9bea5bee080b1e23ea5fa33af1878146ecd5e04 --- internal/app/hostcaps.go | 15 +++- internal/app/knowledge_authorization_test.go | 61 +++++++++++++++ internal/runmgr/knowledge_resource_test.go | 77 +++++++++++++++++++ internal/runmgr/runmgr.go | 32 +++++++- internal/store/content.go | 30 ++++++-- .../store/knowledge_authorization_test.go | 48 ++++++++++++ 6 files changed, 249 insertions(+), 14 deletions(-) diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 62cebe5..866a4f4 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -322,9 +322,9 @@ 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 @@ -343,8 +343,15 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD 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.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref); err != nil { + 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 != nil { return err } } diff --git a/internal/app/knowledge_authorization_test.go b/internal/app/knowledge_authorization_test.go index 7df87d3..e28dff3 100644 --- a/internal/app/knowledge_authorization_test.go +++ b/internal/app/knowledge_authorization_test.go @@ -103,3 +103,64 @@ func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) { 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/runmgr/knowledge_resource_test.go b/internal/runmgr/knowledge_resource_test.go index 70c2a59..2cbd96e 100644 --- a/internal/runmgr/knowledge_resource_test.go +++ b/internal/runmgr/knowledge_resource_test.go @@ -64,3 +64,80 @@ func TestRunKnowledgeResolvesTrustedProjectScope(t *testing.T) { 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 b9d7ddb..e5fe6a3 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -630,7 +630,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 @@ -672,8 +672,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) @@ -876,6 +881,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 @@ -1008,12 +1016,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 { @@ -1662,6 +1676,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 永远等不到答案, @@ -1706,6 +1726,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}) @@ -1716,6 +1739,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/store/content.go b/internal/store/content.go index f203824..c740f98 100644 --- a/internal/store/content.go +++ b/internal/store/content.go @@ -409,20 +409,36 @@ func (s *Store) SearchKnowledgeSharedAuthorized(requester string, q core.Retriev return out, nil } -// DeleteProjectKnowledgeByRef replaces one publication scope and its vectors in -// a single transaction. Empty project is the legacy unscoped publication domain. +// 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 { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() args := []any{owner, ref} pred := "owner=? AND ref=?" if projectID != "" { pred += " AND project_id=?" args = append(args, projectID) } + 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 } diff --git a/internal/store/knowledge_authorization_test.go b/internal/store/knowledge_authorization_test.go index 2ef80c1..b765e96 100644 --- a/internal/store/knowledge_authorization_test.go +++ b/internal/store/knowledge_authorization_test.go @@ -2,6 +2,7 @@ package store import ( core "agent-system-core" + "context" "testing" ) @@ -20,3 +21,50 @@ func TestAuthorizedKnowledgeFiltersBeforeTopK(t *testing.T) { 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) + } +} From c4435d1415eda80e402c52b5ea865cf680be1997 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 09:46:20 +0000 Subject: [PATCH 14/16] Protect shared project metadata credentials and managed settings Why: Legacy project metadata can contain credentials outside the settings scope table. Sharing raw projection settings exposed them, and generic project PATCH bypassed the system-managed path guard already enforced by project settings endpoints. Evidence: SELF member Host regression failed before the fix. The fixed 4f72aaf server baseline overlay reproduced HTTP metadata leakage and managed PATCH returning 200. SELF shared Host-to-HTTP, immutable mask, dotted/schema/fallback, public mutation and atomic managed-write regressions passed; uncached full suite, isolated PG Store suite and static build passed. Independent delta replay remains a gate. Impact: Registered lifecycle metadata returns detached credential-masked views while preserving service/configuration keys and persisted data. Generic project PATCH shares the existing managed validator before rename or settings mutation; allowed metadata key shapes and raw owner-only management lookups remain available. Refs: Dev project-member-access plan r1 Task 1 and R5; approved hard secret boundary; 2026-10-13 shared project metadata review follow-up --- internal/app/project_lifecycle.go | 3 + internal/app/project_lifecycle_test.go | 86 ++++++++++++++++++++++ internal/server/project_lifecycle.go | 9 +++ internal/server/project_lifecycle_test.go | 82 +++++++++++++++++++++ internal/server/server.go | 14 +++- internal/settings/project_metadata.go | 86 ++++++++++++++++++++++ internal/settings/project_metadata_test.go | 53 +++++++++++++ internal/settings/settings.go | 6 +- pkg/extension/projects.go | 3 +- 9 files changed, 335 insertions(+), 7 deletions(-) create mode 100644 internal/settings/project_metadata.go create mode 100644 internal/settings/project_metadata_test.go diff --git a/internal/app/project_lifecycle.go b/internal/app/project_lifecycle.go index 883d5c2..78ccf95 100644 --- a/internal/app/project_lifecycle.go +++ b/internal/app/project_lifecycle.go @@ -98,6 +98,9 @@ func (h inprocHost) ProjectMetadata(id string) (core.Project, error) { 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 } diff --git a/internal/app/project_lifecycle_test.go b/internal/app/project_lifecycle_test.go index ad261c0..1650ff9 100644 --- a/internal/app/project_lifecycle_test.go +++ b/internal/app/project_lifecycle_test.go @@ -1,11 +1,16 @@ 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" ) @@ -75,3 +80,84 @@ func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) { 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/server/project_lifecycle.go b/internal/server/project_lifecycle.go index eb4d747..ed2b29c 100644 --- a/internal/server/project_lifecycle.go +++ b/internal/server/project_lifecycle.go @@ -71,3 +71,12 @@ func writeProjectJSON(w http.ResponseWriter, p any, err error) { } 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 index 61ea6dc..d4dc059 100644 --- a/internal/server/project_lifecycle_test.go +++ b/internal/server/project_lifecycle_test.go @@ -9,6 +9,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "testing" @@ -197,3 +198,84 @@ func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) { 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") + } + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c36f113..f932783 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -627,7 +627,7 @@ func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) { } out := make([]projectView, 0, len(ps)) for _, p := range ps { - out = append(out, projectView{Project: p, Shared: p.Owner != owner}) + out = append(out, projectView{Project: s.publicProjectMetadata(p), Shared: p.Owner != owner}) } writeJSON(w, http.StatusOK, out) return @@ -674,7 +674,7 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { return } p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name)) - writeProjectJSON(w, p, err) + writeProjectJSON(w, s.publicProjectMetadata(p), err) return } p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name)) @@ -691,6 +691,14 @@ 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 @@ -732,7 +740,7 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { } } } - writeProjectJSON(w, p, err) + writeProjectJSON(w, s.publicProjectMetadata(p), err) return } if req.Name != nil { diff --git a/internal/settings/project_metadata.go b/internal/settings/project_metadata.go new file mode 100644 index 0000000..56359ab --- /dev/null +++ b/internal/settings/project_metadata.go @@ -0,0 +1,86 @@ +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 := dropMaskPlaceholders(values) + if err := s.checkManagedWrite(normalizeKeys(incoming)); err != nil { + return nil, err + } + return incoming, nil +} + +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..12fa9f3 --- /dev/null +++ b/internal/settings/project_metadata_test.go @@ -0,0 +1,53 @@ +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") + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 05dfd0a..c07074c 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -167,7 +167,7 @@ func (s *Service) SetExtraSchema(descs []core.SettingDesc) { s.extraSchema = des // ManagedSettingPaths 返回全部受管键路径(内置 schema + 插件声明)。 func (s *Service) ManagedSettingPaths() []string { paths := core.ManagedPaths(Schema()) - if len(s.extraSchema) > 0 { + if s != nil && len(s.extraSchema) > 0 { paths = append(paths, core.ManagedPaths(s.extraSchema)...) } return paths @@ -238,8 +238,8 @@ func (s *Service) MergeSystem(values map[string]any) error { func (s *Service) mergeScope(owner, scope string, values map[string]any) error { incoming := prepareWrite(values) if scope != ScopeSystem { - if hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 { - return &ErrManagedPath{Paths: hits} + 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) { diff --git a/pkg/extension/projects.go b/pkg/extension/projects.go index 259e4dd..d0f5042 100644 --- a/pkg/extension/projects.go +++ b/pkg/extension/projects.go @@ -6,7 +6,8 @@ import ( ) // ProjectHost is an optional actor-scoped host projection capability. Metadata -// can be shared by an authorizer; owned lookups and mutations remain owner-only. +// 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) From d2f96e79bb4161e182d8216de4a402452f7c3a7c Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 10:23:37 +0000 Subject: [PATCH 15/16] Admit private member uploads and preserve masked metadata edits Why: Nested unchanged credential masks left empty parents that looked like managed subtree overwrites. Shared members also hit an owner-only upload guard, while upload ingestion cards could disclose private filenames and parser metadata. Evidence: SELF independent nested-mask probe, HTTP nested/mixed writeback, member upload, project card privacy and authorization-change regressions failed before each fix and passed after it. Affected settings/server suite passed; exact final-object build and independent replay remain delivery gates owned by the task coordinator. Impact: Prune only parents emptied by ignored masks; explicit empty/real managed writes stay rejected. Registered uploads check live work and trusted project existence, keep originals owned by the actor, and recheck before persistence. Suppress only upload project cards when no private stream exists; explicit structure cards and unregistered upload behavior remain available. Refs: Dev project-member-access plan r1 Task 1/R5; independent nested mask P2; 2026-10-13 composed member-upload and private project-stream acceptance findings --- internal/server/project_lifecycle_test.go | 222 +++++++++++++++++++++ internal/server/server.go | 34 +++- internal/settings/project_metadata.go | 24 ++- internal/settings/project_metadata_test.go | 33 +++ 4 files changed, 306 insertions(+), 7 deletions(-) diff --git a/internal/server/project_lifecycle_test.go b/internal/server/project_lifecycle_test.go index d4dc059..47ea686 100644 --- a/internal/server/project_lifecycle_test.go +++ b/internal/server/project_lifecycle_test.go @@ -1,6 +1,7 @@ package server import ( + "agent-system-backend/internal/cardsvc" "agent-system-backend/internal/cmdbus" "agent-system-backend/internal/surfacesvc" "agent-system-backend/pkg/extension" @@ -10,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "mime/multipart" "net/http" "net/http/httptest" "testing" @@ -279,3 +281,223 @@ func TestGenericProjectPatchRejectsManagedSettingsAtomically(t *testing.T) { }) } } + +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.DisableUploadIndexing = true + if _, err := s.Cards.Create("alice", 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/server.go b/internal/server/server.go index f932783..426070e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1413,6 +1413,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 { @@ -1425,10 +1436,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} @@ -1443,11 +1462,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{ diff --git a/internal/settings/project_metadata.go b/internal/settings/project_metadata.go index 56359ab..2eb0035 100644 --- a/internal/settings/project_metadata.go +++ b/internal/settings/project_metadata.go @@ -71,13 +71,35 @@ func maskedProjectValue(value any, path string, secrets map[string]bool) any { // 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 := dropMaskPlaceholders(values) + 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} diff --git a/internal/settings/project_metadata_test.go b/internal/settings/project_metadata_test.go index 12fa9f3..443eb83 100644 --- a/internal/settings/project_metadata_test.go +++ b/internal/settings/project_metadata_test.go @@ -51,3 +51,36 @@ func TestPrepareProjectMetadataWriteUsesManagedGuard(t *testing.T) { 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") + } + } +} From d26f3cf2e864a76ad0b868fd968da3257cc7bb44 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 11:12:47 +0000 Subject: [PATCH 16/16] Protect private project event replay and live delivery Why: Legacy project streams replayed upload filenames and parser metadata to newly authorized members even though the underlying cards remained owner-only. Evidence: SELF real HTTP red regressions exposed legacy replay, spoofed sharing claims, private-history pagination, and live delivery after membership removal. The affected packages, uncached full Backend suite, isolated PostgreSQL Store suite, and agent-system build pass after applying the event boundary. Impact: Registered lifecycle streams resolve trusted resource ownership and require an internal publication proof for cross-owner delivery. Event and proof commit in one transaction; v30 adds an additive SQLite/PostgreSQL sidecar without granting access to historical events. Seen and visible cursors remain separate, revoked members leave live streams, and unregistered base behavior remains compatible. The storage migration tool preserves the new proof rows. Refs: project-member-access-implementation-plan.md Task 1 / R5; authorized legacy SSE privacy follow-up; project-member-composed-legacy-project-history.json --- cmd/storage-migrate/main.go | 4 +- internal/app/app.go | 1 + internal/cardsvc/cardsvc.go | 41 +++++- internal/runmgr/events.go | 28 ++++ internal/runmgr/runmgr.go | 6 + internal/server/project_events.go | 30 ++++ internal/server/project_events_test.go | 163 ++++++++++++++++++++++ internal/server/project_lifecycle_test.go | 3 +- internal/server/server.go | 34 ++++- internal/store/messages.go | 11 ++ internal/store/migrate.go | 7 + internal/store/migrate_compat.go | 1 + internal/store/project_events.go | 147 +++++++++++++++++++ internal/store/project_events_test.go | 140 +++++++++++++++++++ 14 files changed, 605 insertions(+), 11 deletions(-) create mode 100644 internal/server/project_events.go create mode 100644 internal/server/project_events_test.go create mode 100644 internal/store/project_events.go create mode 100644 internal/store/project_events_test.go diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index bdb1561..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", @@ -784,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/internal/app/app.go b/internal/app/app.go index 67587f3..33749b7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -528,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 { 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/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/runmgr.go b/internal/runmgr/runmgr.go index e5fe6a3..b3f3d99 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -179,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 } 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_test.go b/internal/server/project_lifecycle_test.go index 47ea686..a58553e 100644 --- a/internal/server/project_lifecycle_test.go +++ b/internal/server/project_lifecycle_test.go @@ -409,8 +409,9 @@ func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) { 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.Create("alice", core.ActivityCard{ProjectID: p.ID, Title: "Shared structure", Kind: core.CardKindProgress}, ""); err != nil { + 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. diff --git a/internal/server/server.go b/internal/server/server.go index 426070e..d90b3c3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -431,6 +431,7 @@ 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" { @@ -448,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 @@ -509,28 +511,38 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { 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 @@ -548,6 +560,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { 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() @@ -562,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 } } } 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 4428563..c31e0e8 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -841,6 +841,13 @@ func init() { `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`}}, + }, ) } diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index ac0347d..86c692c 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -34,6 +34,7 @@ var migrationBreaksOldReaders = map[int]bool{ 27: false, 28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响 29: false, // redeem_attempts:新表,旧读者不受影响 + 30: false, // project_event_publication_proofs:新表,旧读者不受影响 } // 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") + } +}