diff --git a/cmd/agent-system/account_io.go b/cmd/agent-system/account_io.go new file mode 100644 index 0000000..65db6da --- /dev/null +++ b/cmd/agent-system/account_io.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" +) + +func runCapabilities(args []string) int { + fs := flag.NewFlagSet("capabilities", flag.ExitOnError) + asJSON := fs.Bool("json", false, "以 JSON 打印能力,不打开数据库") + _ = fs.Parse(args) + caps := map[string]any{ + "accountTransaction": true, + "accountAudit": true, + "accountOpsOpen": true, + "passwordStdin": true, + "dsnFile": true, + "outFile": true, + } + if !*asJSON { + fmt.Fprintln(os.Stderr, "用法:agent-system capabilities --json") + return 2 + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(caps); err != nil { + fmt.Fprintln(os.Stderr, "capabilities:", err) + return 1 + } + return 0 +} + +func resolveAccountDSN(flagDSN, dsnFile string) (string, error) { + if flagDSN != "" { + fmt.Fprintln(os.Stderr, "warning: --dsn is deprecated; use AGENT_SYSTEM_DSN or --dsn-file") + } + if dsnFile != "" { + info, err := os.Stat(dsnFile) + if err != nil { + return "", err + } + if info.Mode().Perm() != 0o600 { + return "", fmt.Errorf("dsn file %s must be mode 0600", dsnFile) + } + raw, err := os.ReadFile(dsnFile) + if err != nil { + return "", err + } + return string(trimNewline(raw)), nil + } + if env := os.Getenv("AGENT_SYSTEM_DSN"); env != "" { + return env, nil + } + return flagDSN, nil +} + +func resolveAccountPassword(flagPassword string, fromStdin bool) (string, error) { + if flagPassword != "" { + fmt.Fprintln(os.Stderr, "warning: --password is deprecated; use --password-stdin") + } + if !fromStdin { + return flagPassword, nil + } + raw, err := io.ReadAll(os.Stdin) + if err != nil { + return "", err + } + return string(trimNewline(raw)), nil +} + +func writeExclusiveSecret(path, body string) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer f.Close() + _, err = io.WriteString(f, body+"\n") + return err +} + +func trimNewline(raw []byte) []byte { + for len(raw) > 0 && (raw[len(raw)-1] == '\n' || raw[len(raw)-1] == '\r') { + raw = raw[:len(raw)-1] + } + return raw +} diff --git a/cmd/agent-system/account_io_test.go b/cmd/agent-system/account_io_test.go new file mode 100644 index 0000000..1096b8c --- /dev/null +++ b/cmd/agent-system/account_io_test.go @@ -0,0 +1,158 @@ +package main + +import ( + "agent-system-backend/internal/store" + "bytes" + "io" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func accountCLI(t *testing.T, args ...string) (int, string) { + t.Helper() + out, err := os.CreateTemp(t.TempDir(), "capture-") + if err != nil { + t.Fatal(err) + } + defer out.Close() + oldOut, oldErr := os.Stdout, os.Stderr + os.Stdout = out + os.Stderr = out + defer func() { os.Stdout = oldOut; os.Stderr = oldErr }() + rc := runUser(args) + out.Seek(0, 0) + raw, err := io.ReadAll(out) + if err != nil { + t.Fatal(err) + } + return rc, string(raw) +} +func cliStore(t *testing.T) (string, *store.Store) { + t.Helper() + t.Setenv("AGENT_SYSTEM_DSN", "") + dir := t.TempDir() + s, e := store.OpenSQLite(filepath.Join(dir, "agent-system.db")) + if e != nil { + t.Fatal(e) + } + t.Cleanup(func() { s.Close() }) + if e := s.EnsureSystemIdentity("fixture", "sqlite"); e != nil { + t.Fatal(e) + } + return dir, s +} +func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) { + for _, sub := range []string{"create", "reset-password", "issue-login-key", "bootstrap-admin"} { + t.Run(sub, func(t *testing.T) { + dir, s := cliStore(t) + if sub != "create" && sub != "bootstrap-admin" { + if e := s.CreateAccount("ada", "Ada", "", false); e != nil { + t.Fatal(e) + } + } + out := filepath.Join(t.TempDir(), "secret") + rc, _ := accountCLI(t, sub, "--data", dir, "--owner", "ada", "--out-file", out) + if rc != 0 { + t.Fatalf("credential command failed: %d", rc) + } + info, e := os.Stat(out) + if e != nil { + t.Fatal("credential output file missing") + } + if info.Mode().Perm() != 0600 { + t.Fatal("credential output permissions are not private") + } + }) + } +} +func TestExistingOutputAndConflictingFormatsRefuseBeforeMutation(t *testing.T) { + for _, kind := range []string{"existing", "conflicting"} { + t.Run(kind, func(t *testing.T) { + dir, s := cliStore(t) + out := filepath.Join(t.TempDir(), "secret") + args := []string{"create", "--data", dir, "--owner", "ada", "--out-file", out} + if kind == "existing" { + if e := os.WriteFile(out, []byte("existing"), 0600); e != nil { + t.Fatal(e) + } + } else { + args = append(args, "--out", filepath.Join(t.TempDir(), "json-secret")) + } + rc, _ := accountCLI(t, args...) + if rc == 0 { + t.Fatal("unsafe output selection succeeded") + } + if _, e := s.GetUser("ada"); e == nil { + t.Fatal("output refusal happened after account mutation") + } + }) + } +} + +func TestAPIKeyCreateUsesExclusivePrivateOutput(t *testing.T) { + if dir := os.Getenv("U7_APIKEY_CHILD_DIR"); dir != "" { + rc := runAPIKey([]string{"create", "--data", dir, "--owner", "ada", "--name", "fixture", "--out-file", os.Getenv("U7_APIKEY_CHILD_OUT")}) + if rc != 0 { + t.Fatal("apikey create rejected its private output destination") + } + return + } + for _, existing := range []bool{false, true} { + name := "new" + if existing { + name = "existing" + } + t.Run(name, func(t *testing.T) { + dir, st := cliStore(t) + if err := st.CreateAccount("ada", "Ada", "", false); err != nil { + t.Fatal(err) + } + out := filepath.Join(t.TempDir(), "credential") + if existing { + if err := os.WriteFile(out, []byte("preserve"), 0600); err != nil { + t.Fatal(err) + } + } + cmd := exec.Command(os.Args[0], "-test.run=^TestAPIKeyCreateUsesExclusivePrivateOutput$") + cmd.Env = append(os.Environ(), "U7_APIKEY_CHILD_DIR="+dir, "U7_APIKEY_CHILD_OUT="+out) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if existing { + if err == nil { + t.Fatal("existing output was overwritten or mutation succeeded") + } + raw, readErr := os.ReadFile(out) + if readErr != nil || string(raw) != "preserve" { + t.Fatal("existing output was modified") + } + keys, listErr := st.ListAPIKeys("ada") + if listErr != nil || len(keys) != 0 { + t.Fatal("mutation happened before output preflight") + } + return + } + if err != nil { + t.Fatal("new private output was rejected") + } + info, statErr := os.Stat(out) + if statErr != nil || info.Mode().Perm() != 0600 { + t.Fatal("credential output missing or not private") + } + raw, readErr := os.ReadFile(out) + if readErr != nil || len(raw) == 0 { + t.Fatal("credential output empty") + } + if bytes.Contains(stdout.Bytes(), bytes.TrimSpace(raw)) { + t.Fatal("credential was written to stdout") + } + keys, listErr := st.ListAPIKeys("ada") + if listErr != nil || len(keys) != 1 { + t.Fatal("successful issue was not committed") + } + }) + } +} diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index f06c6f8..66a22a9 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -43,9 +43,11 @@ func main() { os.Exit(runUser(os.Args[2:])) case "identity": os.Exit(runIdentity(os.Args[2:])) + case "capabilities": + os.Exit(runCapabilities(os.Args[2:])) default: fmt.Fprintf(os.Stderr, "agent-system: 未知子命令 %s\n", os.Args[1]) - fmt.Fprintln(os.Stderr, "用法:agent-system [参数]") + fmt.Fprintln(os.Stderr, "用法:agent-system [参数]") os.Exit(2) } } @@ -194,22 +196,43 @@ func runAPIKey(args []string) int { fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError) data := fs.String("data", "./data", "数据目录") dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)") + dsnFile := fs.String("dsn-file", "", "0600权限的PostgreSQL DSN文件") owner := fs.String("owner", "", "租户名(owner)") name := fs.String("name", "default", "密钥名称") + outFile := fs.String("out-file", "", "把明文密钥独占写入该文件(0600)") fs.Parse(args[1:]) if *owner == "" { fmt.Fprintln(os.Stderr, "apikey: --owner 必填") return 2 } + if sub != "create" && *outFile != "" { + fmt.Fprintln(os.Stderr, "apikey: 此子命令不生成密钥") + return 2 + } + resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile) + if err != nil { + fmt.Fprintln(os.Stderr, "apikey:", err) + return 1 + } + *dsn = resolvedDSN switch sub { case "create": + delivery, err := reserveCredentialOutput(*outFile, "") + if err != nil { + fmt.Fprintln(os.Stderr, "apikey create:", err) + return 1 + } + defer delivery.abort() key, err := app.CreateAPIKey(*dsn, *data, *owner, *name) if err != nil { fmt.Fprintln(os.Stderr, "apikey create:", err) return 1 } // 明文仅此一次输出;库中只存 SHA-256 摘要。 - fmt.Println(key) + if err := delivery.deliver("api-key", *owner, key, nil); err != nil { + fmt.Fprintln(os.Stderr, "apikey create:", err) + return 1 + } return 0 case "list": keys, err := app.ListAPIKeys(*dsn, *data, *owner) @@ -253,13 +276,91 @@ func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) er if err != nil { return err } - if err := os.WriteFile(outPath, buf, 0o600); err != nil { + if err := writeExclusiveSecret(outPath, string(buf)); err != nil { return err } fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath) return nil } +type credentialOutput struct { + path string + file *os.File + json bool + mutated bool +} + +// Reserve the destination before changing the account. O_EXCL also rejects an +// existing symlink, so an existing secret can never be overwritten. +func reserveCredentialOutput(rawPath, jsonPath string) (*credentialOutput, error) { + if rawPath != "" && jsonPath != "" { + return nil, fmt.Errorf("--out and --out-file are mutually exclusive") + } + out := &credentialOutput{} + out.path, out.json = rawPath, false + if jsonPath != "" { + out.path, out.json = jsonPath, true + } + if out.path == "" { + return out, nil + } + f, err := os.OpenFile(out.path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return nil, err + } + out.file = f + return out, nil +} + +func (out *credentialOutput) abort() { + if out == nil || out.file == nil { + return + } + info, err := out.file.Stat() + _ = out.file.Close() + if out.mutated || err != nil { + return + } + pathInfo, pathErr := os.Stat(out.path) + if pathErr == nil && os.SameFile(info, pathInfo) { + _ = os.Remove(out.path) + } +} + +func (out *credentialOutput) deliver(kind, owner, plaintext string, expiresAt any) error { + if out.file == nil { + return deliverCredential("", kind, owner, plaintext, expiresAt) + } + out.mutated = true // Keep the private recovery artifact if delivery fails after DB commit. + var body []byte + if out.json { + payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext} + if expiresAt != nil { + payload["expiresAt"] = expiresAt + } + var err error + body, err = json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + } else { + body = []byte(plaintext) + } + body = append(body, '\n') + if _, err := out.file.Write(body); err != nil { + return err + } + if err := out.file.Sync(); err != nil { + return err + } + if err := out.file.Close(); err != nil { + return err + } + out.file = nil + fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", out.path) + return nil +} + // runUser:create / reset-password / list / issue-login-key / revoke-login-key / bootstrap-admin。 // 默认 create 无初始密码并签发激活 Key;--generate-password 兼容旧随机密码路径。 func runUser(args []string) int { @@ -270,13 +371,17 @@ func runUser(args []string) int { sub := args[0] fs := flag.NewFlagSet("user "+sub, flag.ExitOnError) data := fs.String("data", "./data", "数据目录") - dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)") + dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档;已弃用,优先 AGENT_SYSTEM_DSN 或 --dsn-file)") + dsnFile := fs.String("dsn-file", "", "0600 权限的 DSN 文件") owner := fs.String("owner", "", "用户名(owner)") name := fs.String("name", "", "显示名") - password := fs.String("password", "", "密码(create 空=激活 Key;加 --generate-password 则随机)") + password := fs.String("password", "", "密码(已弃用;请用 --password-stdin)") + passwordStdin := fs.Bool("password-stdin", false, "从 stdin 读取密码") + outFile := fs.String("out-file", "", "把明文凭据独占写入该文件(0600)") + instanceID := fs.String("instance-id", "", "严格核验库内服务身份") admin := fs.Bool("admin", false, "管理员") role := fs.String("role", "user", "角色 user|admin") - openExisting := fs.Bool("open-existing", false, "只打开既有存储") + openExisting := fs.Bool("open-existing", false, "兼容旧参数;账户命令始终只打开既有存储") generatePassword := fs.Bool("generate-password", false, "create 时生成随机初始密码") purpose := fs.String("purpose", "recover", "issue-login-key:activate|recover") ttlMin := fs.Int("ttl-minutes", 30, "登录 Key 有效分钟(5–120)") @@ -284,6 +389,35 @@ func runUser(args []string) int { keyID := fs.String("id", "", "revoke-login-key 凭据编号") out := fs.String("out", "", "凭据交付文件(0600 JSON,代替 stdout 明文;非交互推荐)") fs.Parse(args[1:]) + resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile) + if err != nil { + fmt.Fprintln(os.Stderr, "user:", err) + return 1 + } + resolvedPassword, err := resolveAccountPassword(*password, *passwordStdin) + if err != nil { + fmt.Fprintln(os.Stderr, "user:", err) + return 1 + } + if *instanceID != "" { + _ = os.Setenv("AGENT_SYSTEM_INSTANCE_ID", *instanceID) + } + *dsn = resolvedDSN + *password = resolvedPassword + credentialAction := sub == "create" || sub == "reset-password" || sub == "issue-login-key" || sub == "bootstrap-admin" + if !credentialAction && (*outFile != "" || *out != "") { + fmt.Fprintln(os.Stderr, "user: 此子命令不生成凭据") + return 2 + } + var delivery *credentialOutput + if credentialAction { + delivery, err = reserveCredentialOutput(*outFile, *out) + if err != nil { + fmt.Fprintln(os.Stderr, "user:", err) + return 2 + } + defer delivery.abort() + } switch sub { case "create": if *owner == "" { @@ -299,16 +433,17 @@ func runUser(args []string) int { fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role) return 2 } - pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword) + pw, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword) if err != nil { fmt.Fprintln(os.Stderr, "user create:", err) return 1 } - if err := deliverCredential(*out, "login-key", *owner, pw, nil); err != nil { + if err := delivery.deliver("login-key", *owner, pw, nil); err != nil { fmt.Fprintln(os.Stderr, "user create:", err) return 1 } - if *out == "" { + fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID) + if *out == "" && *outFile == "" { if *password == "" && !*generatePassword { fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。") } else if *password == "" { @@ -326,11 +461,11 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user reset-password:", err) return 1 } - if err := deliverCredential(*out, "password", *owner, pw, nil); err != nil { + if err := delivery.deliver("password", *owner, pw, nil); err != nil { fmt.Fprintln(os.Stderr, "user reset-password:", err) return 1 } - if *out == "" && *password == "" { + if *out == "" && *outFile == "" && *password == "" { fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。") } return 0 @@ -357,11 +492,11 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user issue-login-key:", err) return 1 } - if err := deliverCredential(*out, "login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil { + if err := delivery.deliver("login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil { fmt.Fprintln(os.Stderr, "user issue-login-key:", err) return 1 } - if *out == "" { + if *out == "" && *outFile == "" { fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。") } return 0 @@ -386,11 +521,11 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err) return 1 } - if err := deliverCredential(*out, "bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil { + if err := delivery.deliver("bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil { fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err) return 1 } - if *out == "" { + if *out == "" && *outFile == "" { fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。") } return 0 diff --git a/internal/app/account_ops_existing_test.go b/internal/app/account_ops_existing_test.go new file mode 100644 index 0000000..9580649 --- /dev/null +++ b/internal/app/account_ops_existing_test.go @@ -0,0 +1,58 @@ +package app + +import ( + "agent-system-backend/internal/store" + "os" + "path/filepath" + "testing" +) + +func TestAccountMaintenanceDefaultsToNonCreatingOpen(t *testing.T) { + t.Setenv("AGENT_SYSTEM_DSN", "") + for _, tc := range []struct { + name string + run func(string) error + }{ + {"user", func(dir string) error { + _, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false) + return err + }}, + {"apikey", func(dir string) error { + _, err := CreateAPIKey("", dir, "ada", "test") + return err + }}, + {"identity verify", func(dir string) error { + _, err := VerifySystemIdentity("", dir, "fixture", "sqlite") + return err + }}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "absent") + if err := tc.run(dir); err == nil { + t.Error("maintenance created a missing database") + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Error("failed maintenance created a directory") + } + }) + } +} +func TestAccountMaintenanceCannotImplicitlyMigrate(t *testing.T) { + t.Setenv("AGENT_SYSTEM_DSN", "") + dir := t.TempDir() + st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + if err := st.EnsureSystemIdentity("test", "sqlite"); err != nil { + t.Fatal(err) + } + // Roll back the newest additive migration in this private fixture only. + if err := st.MigrateDown(store.SchemaTarget() - 1); err != nil { + t.Fatal(err) + } + st.Close() + if _, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false); err == nil { + t.Error("account command implicitly migrated an older schema") + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 7e6af4e..18a3825 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -740,8 +740,8 @@ func Run(opt Options) error { // CreateUserAccount CLI 建号。 // password 空且 generatePassword=false:无初始密码,同事务签发 activate Key,返回明文 Key。 // generatePassword=true 或显式 password:兼容旧路径(随机/给定密码 + must_change)。 -func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) { - var out string +func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, string, error) { + var out, opID string err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { if password != "" || generatePassword { pw := password @@ -752,23 +752,29 @@ func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmi if err != nil { return err } - if err := st.CreateAccount(owner, name, hash, isAdmin); err != nil { + outc, err := st.CreateActivatedAccount(store.AccountWrite{ + Owner: owner, Name: name, PasswordHash: hash, IsAdmin: isAdmin, MustChange: true, + Actor: cliActor(), InstanceID: accountInstanceID(), Action: "account.create", + }) + if err != nil { return err } out = pw + opID = outc.OperationID return nil } - if err := st.CreateAccount(owner, name, "", isAdmin); err != nil { - return err - } - plain, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli", 0) + outc, err := st.CreateActivatedAccount(store.AccountWrite{ + Owner: owner, Name: name, IsAdmin: isAdmin, Purpose: store.LoginKeyPurposeActivate, CreatedBy: "cli", + Actor: cliActor(), InstanceID: accountInstanceID(), Action: "account.create", + }) if err != nil { return err } - out = plain + out = outc.Plain + opID = outc.OperationID return nil }) - return out, err + return out, opID, err } // IssueUserLoginKey CLI 签发激活/恢复 Key。 @@ -776,10 +782,12 @@ func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, var plain string err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { if revokeAllCreds { - if err := st.RevokeAllAPIKeys(owner); err != nil { - return err - } - st.DeleteUserSessions(owner, "") + outc, err := st.RevokeAllAndIssue(store.AccountWrite{ + Owner: owner, Purpose: purpose, CreatedBy: "cli", TTL: ttl, + Actor: cliActor(), InstanceID: accountInstanceID(), + }) + plain = outc.Plain + return err } p, _, err := st.IssueLoginKey(owner, purpose, "cli", ttl) plain = p @@ -800,8 +808,10 @@ func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) { var plain string err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - p, err := st.BootstrapFirstAdmin(owner, owner, "cli-bootstrap") - plain = p + outc, err := st.BootstrapFirstAdminWrite(store.AccountWrite{ + Owner: owner, Name: owner, Actor: cliActor(), InstanceID: accountInstanceID(), + }) + plain = outc.Plain return err }) return plain, err @@ -818,7 +828,7 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting return "", err } if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - return st.AdminResetUserPassword(owner, hash, "cli", "cli") + return st.AdminResetUserPassword(owner, hash, cliActor(), "cli") }); err != nil { return "", err } @@ -850,83 +860,75 @@ func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error) return fn(st) } -func withStoreMode(storageDSN, dataDir string, openExisting bool, fn func(*store.Store) error) error { +func withStoreMode(storageDSN, dataDir string, _ bool, fn func(*store.Store) error) error { var st *store.Store var err error // DSN 等价安全输入(KTD14):argv 缺省时回落环境变量——凭据不落 shell history。 if storageDSN == "" { storageDSN = os.Getenv("AGENT_SYSTEM_DSN") } + // Account maintenance never initializes or migrates storage; the retained + // CLI compatibility flag cannot opt this operation into service initialization. if storageDSN != "" { - if openExisting { - st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) - } else { - st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) - } + st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) } else { - dbPath := filepath.Join(dataDir, "agent-system.db") - if openExisting { - st, err = store.OpenExistingSQLite(dbPath) - } else { - st, err = store.OpenSQLite(dbPath) - } + st, err = store.OpenExistingSQLite(filepath.Join(dataDir, "agent-system.db")) } + if err != nil { return err } defer st.Close() + if id := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); id != "" { + dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF")) + if dbref == "" { + if storageDSN != "" { + dbref = "pg" + } else { + dbref = "sqlite" + } + } + if err := st.VerifySystemIdentity(id, dbref); err != nil { + return err + } + } return fn(st) } // CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。 func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) { - var st *store.Store - var err error - if storageDSN != "" { - st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) - } else { - st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db")) - } - if err != nil { - return "", err - } - defer st.Close() - return st.CreateAPIKey(owner, name) + var plain string + err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { + p, e := st.CreateAPIKey(owner, name) + plain = p + return e + }) + return plain, err } // ListAPIKeys 列出租户密钥(摘要视图)。 func ListAPIKeys(storageDSN, dataDir, owner string) ([]store.APIKeyInfo, error) { - var st *store.Store - var err error - if storageDSN != "" { - st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) - } else { - st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db")) - } - if err != nil { - return nil, err - } - defer st.Close() - if _, err := st.GetUser(owner); err != nil { - return nil, fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner) - } - return st.ListAPIKeys(owner) + var out []store.APIKeyInfo + err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { + if _, err := st.GetUser(owner); err != nil { + return fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner) + } + var e error + out, e = st.ListAPIKeys(owner) + return e + }) + return out, err } // RevokeAPIKey 撤销指定名称密钥。 func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) { - var st *store.Store - var err error - if storageDSN != "" { - st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) - } else { - st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db")) - } - if err != nil { - return false, err - } - defer st.Close() - return st.RevokeAPIKeyByName(owner, name) + var ok bool + err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { + var e error + ok, e = st.RevokeAPIKeyByName(owner, name) + return e + }) + return ok, err } // EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。 @@ -955,6 +957,14 @@ func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) (int, e return schemaVersion, err } +func cliActor() string { + return fmt.Sprintf("%d", os.Getuid()) +} + +func accountInstanceID() string { + return strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")) +} + // cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。 func cachedInt(ttl time.Duration, fn func() int) func() int { var mu sync.Mutex diff --git a/internal/server/accounts.go b/internal/server/accounts.go index 68e7697..87aa48e 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -286,7 +286,8 @@ func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) { } func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requireAdmin(w, r); !ok { + actor, ok := s.requireAdmin(w, r) + if !ok { return } var req struct { @@ -313,7 +314,7 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - if err := s.st.CreateAccount(req.Owner, strings.TrimSpace(req.Name), hash, req.IsAdmin); err != nil { + if _, err := s.st.CreateActivatedAccount(store.AccountWrite{Owner: req.Owner, Name: strings.TrimSpace(req.Name), PasswordHash: hash, IsAdmin: req.IsAdmin, Actor: actor, Action: "account.create"}); err != nil { writeStoreErr(w, err) return } diff --git a/internal/store/account_lifecycle_test.go b/internal/store/account_lifecycle_test.go index 10a1722..156cd41 100644 --- a/internal/store/account_lifecycle_test.go +++ b/internal/store/account_lifecycle_test.go @@ -64,6 +64,9 @@ func TestBootstrapFirstAdminSingleWinner(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { st.Close() }) + if err := st.EnsureSystemIdentity("bootstrap-test", "sqlite"); err != nil { + t.Fatal(err) + } var wins, conflicts atomic.Int32 var wg sync.WaitGroup for i := 0; i < 8; i++ { @@ -78,6 +81,8 @@ func TestBootstrapFirstAdminSingleWinner(t *testing.T) { wins.Add(1) } else if err == ErrConflict { conflicts.Add(1) + } else { + t.Errorf("bootstrap contender: %v", err) } }(owner) } diff --git a/internal/store/account_process_crash_test.go b/internal/store/account_process_crash_test.go new file mode 100644 index 0000000..61fb4fe --- /dev/null +++ b/internal/store/account_process_crash_test.go @@ -0,0 +1,104 @@ +package store + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestAccountPasswordSurvivesProcessTermination(t *testing.T) { + if mode := os.Getenv("U7_CRASH_CHILD_MODE"); mode != "" { + path := os.Getenv("U7_CRASH_CHILD_DB") + st, err := OpenExistingSQLite(path) + if err != nil { + t.Fatal(err) + } + h, err := HashPassword("private-fixture-password") + if err != nil { + t.Fatal(err) + } + _, err = st.CreateActivatedAccount(AccountWrite{Owner: "survivor", Name: "Survivor", PasswordHash: h, Action: "account.create", Actor: "fixture"}) + if err != nil { + t.Fatal(err) + } + // Keep the database connection and process alive at the signal point; + // a graceful close would only test reopen, not abrupt termination. + fmt.Println("U7_COMMITTED") + select {} + } + for _, tc := range []struct { + name string + signal syscall.Signal + }{{"SIGTERM", syscall.SIGTERM}, {"SIGKILL", syscall.SIGKILL}} { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "accounts.db") + created, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + if err := created.Close(); err != nil { + t.Fatal(err) + } + child := exec.Command(os.Args[0], "-test.run=^TestAccountPasswordSurvivesProcessTermination$") + child.Env = append(os.Environ(), "U7_CRASH_CHILD_MODE="+tc.name, "U7_CRASH_CHILD_DB="+path) + stdout, err := child.StdoutPipe() + if err != nil { + t.Fatal(err) + } + child.Stderr = os.Stderr + if err := child.Start(); err != nil { + t.Fatal(err) + } + ready := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + if strings.Contains(scanner.Text(), "U7_COMMITTED") { + ready <- nil + return + } + } + ready <- fmt.Errorf("child exited without committed receipt: %v", scanner.Err()) + }() + select { + case err := <-ready: + if err != nil { + _ = child.Process.Kill() + _ = child.Wait() + t.Fatal(err) + } + case <-time.After(10 * time.Second): + _ = child.Process.Kill() + _ = child.Wait() + t.Fatal("child did not commit in time") + } + if err := child.Process.Signal(tc.signal); err != nil { + t.Fatal(err) + } + waitErr := child.Wait() + var exitErr *exec.ExitError + if !errors.As(waitErr, &exitErr) { + t.Fatalf("child was not terminated by %s: %v", tc.name, waitErr) + } + status, ok := exitErr.Sys().(syscall.WaitStatus) + if !ok || !status.Signaled() || status.Signal() != tc.signal { + t.Fatalf("child ended without requested %s: %v", tc.name, waitErr) + } + reopened, err := OpenExistingSQLite(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if _, ok := reopened.VerifyLogin("survivor", "private-fixture-password"); !ok { + t.Fatal("committed password missing after real process termination") + } + }) + } +} diff --git a/internal/store/account_security_integration_test.go b/internal/store/account_security_integration_test.go new file mode 100644 index 0000000..8359ad5 --- /dev/null +++ b/internal/store/account_security_integration_test.go @@ -0,0 +1,187 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func activeAccount(t *testing.T) *Store { + t.Helper() + s := openTestStore(t) + h, e := HashPassword("old-password") + if e != nil { + t.Fatal(e) + } + if e := s.CreateAccount("ada", "Ada", h, false); e != nil { + t.Fatal(e) + } + if e := s.ClearMustChangePassword("ada"); e != nil { + t.Fatal(e) + } + return s +} +func TestAccountMaintenanceInvalidatesVerifiedLogin(t *testing.T) { + for _, kind := range []string{"password", "revoke-all"} { + t.Run(kind, func(t *testing.T) { + s := activeAccount(t) + _, ver, ok := s.VerifyLoginWithVersion("ada", "old-password") + if !ok { + t.Fatal("healthy login failed") + } + var err error + if kind == "password" { + h, e := HashPassword("new-password") + if e != nil { + t.Fatal(e) + } + _, err = s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h}) + } else { + _, err = s.RevokeAllAndIssue(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin"}) + } + if err != nil { + t.Fatal(err) + } + if _, err := s.CreateSessionVerified("ada", SessionScopeFull, time.Hour, ver); err == nil { + t.Fatal("stale in-flight login could create a session after credential replacement") + } + }) + } +} +func TestActivatedAccountRetainsLoginKeySuccessEvent(t *testing.T) { + s := openTestStore(t) + if _, err := s.CreateActivatedAccount(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeActivate, CreatedBy: "admin", Actor: "admin"}); err != nil { + t.Fatal(err) + } + var n int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM account_events WHERE owner=? AND action='login_key_issued'`), "ada").Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatal("atomic issuance dropped main account event") + } +} +func TestCheckedPasswordMutationRollsBackAuditFailure(t *testing.T) { + s := openTestStore(t) + if err := s.CreateAccount("ada", "Ada", "", false); err != nil { + t.Fatal(err) + } + key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeActivate, "admin", 0) + if err != nil { + t.Fatal(err) + } + token, _, err := s.RedeemLoginKey(key) + if err != nil { + t.Fatal(err) + } + h, err := HashPassword("new-password") + if err != nil { + t.Fatal(err) + } + s.SetAccountFault(ErrConflict) + if _, err := s.CompleteSetPasswordChecked(token, h); err == nil { + t.Fatal("checked password path bypassed the shared audited commit") + } + if a, err := s.GetUserAuthState("ada"); err != nil || a.HasPassword { + t.Fatal("failed checked change left a password behind") + } + if _, _, ok := s.SessionInfo(token); !ok { + t.Fatal("failed checked change consumed its session") + } +} + +func TestRevokeAllRefusesMissingOwnerWithoutCredential(t *testing.T) { + s := openTestStore(t) + _, err := s.RevokeAllAndIssue(AccountWrite{Owner: "missing", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin", Actor: "admin"}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("missing owner must be rejected before issuing any key: %v", err) + } + var n int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM login_keys WHERE owner=?`), "missing").Scan(&n); err != nil || n != 0 { + t.Fatalf("orphan credential rows=%d err=%v", n, err) + } +} + +func TestActivePasswordChangeRevokesUnusedRecoveryKey(t *testing.T) { + s := activeAccount(t) + key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0) + if err != nil { + t.Fatal(err) + } + session, err := s.CreateSession("ada") + if err != nil { + t.Fatal(err) + } + hash, err := HashPassword("replacement-password") + if err != nil { + t.Fatal(err) + } + if err := s.ChangeUserPasswordActive("ada", "old-password", hash, session); err != nil { + t.Fatal(err) + } + if _, _, err := s.RedeemLoginKey(key); err == nil { + t.Fatal("unused recovery key issued before active password change still redeems") + } +} + +func TestRejectedActiveChangeIsAudited(t *testing.T) { + s := activeAccount(t) + h, err := HashPassword("next-password") + if err != nil { + t.Fatal(err) + } + err = s.ChangeUserPasswordActive("ada", "incorrect-current-password", h, "") + if !errors.Is(err, ErrBadCredentials) { + t.Fatalf("expected bad credentials, got %v", err) + } + count, err := s.CountAudit("ada", "rejected") + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("rejected checked account mutation has %d rejected audit rows, want1", count) + } +} + +func TestDelayedOldVersionSessionIsRejectedAtUse(t *testing.T) { + s := activeAccount(t) + _, ver, ok := s.VerifyLoginWithVersion("ada", "old-password") + if !ok { + t.Fatal("healthy verification missing") + } + h, err := HashPassword("new-password") + if err != nil { + t.Fatal(err) + } + if _, err := s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h}); err != nil { + t.Fatal(err) + } + // Represents an insert racing after the replacement's earlier DELETE. + token := "sess_private_stale_fixture" + _, 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), "ada", nowISO(), time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeFull, ver) + if err != nil { + t.Fatal(err) + } + if _, _, ok := s.SessionInfo(token); ok { + t.Fatal("session carrying the retired auth_version became authorized") + } +} + +func TestActivePasswordChangePreservesCurrentVersionedSession(t *testing.T) { + s := activeAccount(t) + token, err := s.CreateSession("ada") + if err != nil { + t.Fatal(err) + } + h, err := HashPassword("new-password") + if err != nil { + t.Fatal(err) + } + if err := s.ChangeUserPasswordActive("ada", "old-password", h, token); err != nil { + t.Fatal(err) + } + if owner, scope, ok := s.SessionInfo(token); !ok || owner != "ada" || scope != SessionScopeFull { + t.Fatal("active change did not retain its current full session") + } +} diff --git a/internal/store/account_serialization_pg_test.go b/internal/store/account_serialization_pg_test.go new file mode 100644 index 0000000..436c086 --- /dev/null +++ b/internal/store/account_serialization_pg_test.go @@ -0,0 +1,167 @@ +package store + +import ( + "context" + "testing" + "time" +) + +// The trigger holds a transaction after its authorization read. A second +// connection must wait for that owner's lock before replacing credentials. +func holdPGAccountWrite(t *testing.T, s *Store, table, event, suffix string) func() { + t.Helper() + lock := "u7_account_" + suffix + _, err := s.db.Exec(`CREATE FUNCTION u7_pause_account() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_advisory_xact_lock(hashtext(current_schema() || ':` + lock + `')); RETURN NEW; END $$`) + if err != nil { + t.Fatal(err) + } + _, err = s.db.Exec(`CREATE TRIGGER u7_pause_account BEFORE ` + event + ` ON ` + table + ` FOR EACH ROW EXECUTE FUNCTION u7_pause_account()`) + if err != nil { + t.Fatal(err) + } + conn, err := s.db.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(context.Background(), `SELECT pg_advisory_lock(hashtext(current_schema() || ':`+lock+`'))`); err != nil { + conn.Close() + t.Fatal(err) + } + released := false + release := func() { + if !released { + released = true + _, _ = conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock(hashtext(current_schema() || ':`+lock+`'))`) + _ = conn.Close() + } + } + t.Cleanup(release) + return release +} + +func waitForPGAccountWrite(t *testing.T, s *Store, suffix string) { + t.Helper() + lock := "u7_account_" + suffix + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + var n int + err := s.db.QueryRow(`SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND NOT granted + AND objid=(hashtext(current_schema() || ':` + lock + `')::bigint & 4294967295)::oid`).Scan(&n) + if err != nil { + t.Fatal(err) + } + if n > 0 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("account write did not reach the deterministic PostgreSQL pause") +} + +func awaitPGAccountResult[T any](t *testing.T, done <-chan T) T { + t.Helper() + select { + case result := <-done: + return result + case <-time.After(5 * time.Second): + t.Fatal("serialized account transaction did not finish") + var zero T + return zero + } +} + +func TestPGVerifiedSessionAndPasswordChangeSerialize(t *testing.T) { + s := activeAccount(t) + if s.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + _, version, ok := s.VerifyLoginWithVersion("ada", "old-password") + if !ok { + t.Fatal("initial login failed") + } + release := holdPGAccountWrite(t, s, "sessions", "INSERT", "session") + type sessionResult struct { + token string + err error + } + sessionDone := make(chan sessionResult, 1) + go func() { + token, err := s.CreateSessionVerified("ada", SessionScopeFull, time.Hour, version) + sessionDone <- sessionResult{token, err} + }() + waitForPGAccountWrite(t, s, "session") + hash, err := HashPassword("replacement-password") + if err != nil { + t.Fatal(err) + } + changeDone := make(chan error, 1) + go func() { + _, err := s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: hash, Actor: "admin"}) + changeDone <- err + }() + select { + case err := <-changeDone: + t.Fatalf("replacement committed ahead of the in-flight verified session: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + session := awaitPGAccountResult(t, sessionDone) + if session.err != nil { + t.Fatal(session.err) + } + if err := awaitPGAccountResult(t, changeDone); err != nil { + t.Fatal(err) + } + if _, _, ok := s.SessionInfo(session.token); ok { + t.Fatal("old verified session survived later password replacement") + } +} + +func TestPGRestrictedPasswordSetAndKeyReissueSerialize(t *testing.T) { + s := openTestStore(t) + if s.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + if err := s.CreateAccount("ada", "Ada", "", false); err != nil { + t.Fatal(err) + } + key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0) + if err != nil { + t.Fatal(err) + } + token, _, err := s.RedeemLoginKey(key) + if err != nil { + t.Fatal(err) + } + hash, err := HashPassword("replacement-password") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, s, "users", "UPDATE", "password") + setDone := make(chan error, 1) + go func() { + _, err := s.CompleteSetPasswordChecked(token, hash) + setDone <- err + }() + waitForPGAccountWrite(t, s, "password") + issueDone := make(chan error, 1) + go func() { + _, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0) + issueDone <- err + }() + select { + case err := <-issueDone: + t.Fatalf("key replacement committed ahead of the in-flight password set: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, setDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, issueDone); err != nil { + t.Fatal(err) + } + if _, _, ok := s.SessionInfo(token); ok { + t.Fatal("old restricted session survived password set and key reissue") + } +} diff --git a/internal/store/account_two_process_pg_test.go b/internal/store/account_two_process_pg_test.go new file mode 100644 index 0000000..fea8054 --- /dev/null +++ b/internal/store/account_two_process_pg_test.go @@ -0,0 +1,202 @@ +package store + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// Two independent process opens share one isolated PostgreSQL schema. The +// barrier makes their account transactions genuinely concurrent. +func TestAccountTwoProcessPostgresAdmission(t *testing.T) { + if mode := os.Getenv("U7_ACCOUNT_CHILD_MODE"); mode != "" { + childDSN := os.Getenv("U7_ACCOUNT_CHILD_DSN") + barrier := os.Getenv("U7_ACCOUNT_CHILD_BARRIER") + st, err := OpenExistingPostgres(childDSN, os.Getenv("U7_ACCOUNT_CHILD_ARTIFACTS")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := os.WriteFile(os.Getenv("U7_ACCOUNT_CHILD_READY"), []byte("ready"), 0o600); err != nil { + t.Fatal(err) + } + for i := 0; i < 500; i++ { + if _, err := os.Stat(barrier); err == nil { + break + } + time.Sleep(5 * time.Millisecond) + } + if _, err := os.Stat(barrier); err != nil { + t.Fatal("two-process barrier missing") + } + var outcome AccountOutcome + switch mode { + case "create": + outcome, err = st.CreateActivatedAccount(AccountWrite{Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, CreatedBy: "fixture", Actor: "fixture", Action: "account.create"}) + case "bootstrap": + outcome, err = st.BootstrapFirstAdminWrite(AccountWrite{Owner: os.Getenv("U7_ACCOUNT_CHILD_OWNER"), Name: "Bootstrap", CreatedBy: "fixture", Actor: "fixture"}) + case "bootstrap-versus-create": + if os.Getenv("U7_ACCOUNT_CHILD_OWNER") == "bootstrap" { + outcome, err = st.BootstrapFirstAdminWrite(AccountWrite{Owner: "admin", Name: "Admin", CreatedBy: "fixture", Actor: "fixture"}) + } else { + outcome, err = st.CreateActivatedAccount(AccountWrite{Owner: "ordinary", Name: "Ordinary", Purpose: LoginKeyPurposeActivate, CreatedBy: "fixture", Actor: "fixture", Action: "account.create"}) + } + default: + t.Fatal("unknown child mode") + } + if err == nil { + if outcome.OperationID == "" || outcome.Plain == "" { + t.Fatal("committed account did not produce a complete receipt") + } + fmt.Println("U7_RESULT=success") + return + } + if errors.Is(err, ErrConflict) { + fmt.Println("U7_RESULT=conflict") + return + } + t.Fatalf("unexpected admission error: %v", err) + return + } + baseDSN := os.Getenv("AGENT_PG_TEST_DSN") + if baseDSN == "" { + t.Skip("AGENT_PG_TEST_DSN absent; Dev PG gate runs this against a real isolated schema") + } + for _, tc := range []struct { + mode string + owners [2]string + }{ + {"create", [2]string{"one", "two"}}, + {"bootstrap", [2]string{"admin-one", "admin-two"}}, + {"bootstrap-versus-create", [2]string{"bootstrap", "ordinary"}}, + } { + t.Run(tc.mode, func(t *testing.T) { + schema := fmt.Sprintf("u7_audit_%d", time.Now().UnixNano()) + admin, err := sql.Open("pgx", baseDSN) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + ensurePGTrgm(t, admin) + if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil { + t.Fatal(err) + } + defer admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`) + dsn := withTestSearchPath(baseDSN, schema) + artifacts := filepath.Join(t.TempDir(), "artifacts") + st, err := OpenPostgres(dsn, artifacts) + if err != nil { + t.Fatal(err) + } + if err := st.EnsureSystemIdentity("pg-two-process", "pg"); err != nil { + t.Fatal(err) + } + st.Close() + // Hold the shared admission point so both independent processes + // are demonstrably contending before either may commit. + lockKey := "bootstrap:first-admin" + if tc.mode == "create" { + lockKey = "account:same" + } + lockConn, err := admin.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + defer lockConn.Close() + if _, err := lockConn.ExecContext(context.Background(), `SELECT pg_advisory_lock(hashtext($1))`, lockKey); err != nil { + t.Fatal(err) + } + released := false + release := func() { + if !released { + released = true + _, _ = lockConn.ExecContext(context.Background(), `SELECT pg_advisory_unlock(hashtext($1))`, lockKey) + } + } + defer release() + barrier := filepath.Join(t.TempDir(), "go") + readyDir := t.TempDir() + var children [2]*exec.Cmd + var logs [2]bytes.Buffer + for i := range children { + readyPath := filepath.Join(readyDir, fmt.Sprintf("ready-%d", i)) + cmd := exec.Command(os.Args[0], "-test.run=^TestAccountTwoProcessPostgresAdmission$", "-test.v") + cmd.Env = append(os.Environ(), "U7_ACCOUNT_CHILD_MODE="+tc.mode, "U7_ACCOUNT_CHILD_DSN="+dsn, + "U7_ACCOUNT_CHILD_OWNER="+tc.owners[i], "U7_ACCOUNT_CHILD_ARTIFACTS="+artifacts, + "U7_ACCOUNT_CHILD_BARRIER="+barrier, "U7_ACCOUNT_CHILD_READY="+readyPath) + cmd.Stdout = &logs[i] + cmd.Stderr = &logs[i] + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + children[i] = cmd + } + deadline := time.Now().Add(10 * time.Second) + for i := range children { + readyPath := filepath.Join(readyDir, fmt.Sprintf("ready-%d", i)) + for { + if _, err := os.Stat(readyPath); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("child %d never opened the shared database: %s", i, logs[i].String()) + } + time.Sleep(5 * time.Millisecond) + } + } + if err := os.WriteFile(barrier, []byte("go"), 0o600); err != nil { + t.Fatal(err) + } + for { + var waiters int + err := admin.QueryRow(`SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND NOT granted + AND objid=(hashtext($1)::bigint & 4294967295)::oid`, lockKey).Scan(&waiters) + if err != nil { + t.Fatal(err) + } + if waiters >= 2 { + break + } + if time.Now().After(deadline) { + t.Fatalf("only %d of 2 child processes reached the shared admission lock: %s | %s", waiters, logs[0].String(), logs[1].String()) + } + time.Sleep(5 * time.Millisecond) + } + release() + wins, losses := 0, 0 + for i, cmd := range children { + if err := cmd.Wait(); err != nil { + t.Fatalf("child %d failed: %v %s", i, err, logs[i].String()) + } + out := logs[i].String() + if strings.Contains(out, "U7_RESULT=success") { + wins++ + } else if strings.Contains(out, "U7_RESULT=conflict") { + losses++ + } else { + t.Fatalf("child %d produced no outcome: %s", i, out) + } + } + if wins != 1 || losses != 1 { + t.Fatalf("admission wins=%d conflicts=%d, want 1/1", wins, losses) + } + reopened, err := OpenExistingPostgres(dsn, artifacts) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + users, err := reopened.ListUsersAdmin() + if err != nil || len(users) != 1 { + t.Fatalf("actual committed users=%d, err=%v", len(users), err) + } + }) + } +} diff --git a/internal/store/account_tx.go b/internal/store/account_tx.go new file mode 100644 index 0000000..88f72b1 --- /dev/null +++ b/internal/store/account_tx.go @@ -0,0 +1,372 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" +) + +// AccountWrite 是一次账户变更的调用参数。成功审计与变更同事务。 +type AccountWrite struct { + Owner string + Name string + PasswordHash string + IsAdmin bool + MustChange bool + ExceptToken string + Purpose string + CreatedBy string + TTL time.Duration + RevokeAllKeys bool + Actor string + InstanceID string + Action string +} + +// AccountOutcome 是成功提交后可以对外展示的标识。不含密码、哈希或明文。 +type AccountOutcome struct { + Plain string + CredentialID string + OperationID string +} + +// CreateActivatedAccount 严格插入账户。passwordHash 为空且 Purpose 非空时,同一事务签发登录 Key。 +func (s *Store) CreateActivatedAccount(w AccountWrite) (AccountOutcome, error) { + if w.Action == "" { + w.Action = "account.create" + } + var out AccountOutcome + err := s.accountTx(w, func(tx *sql.Tx, opID string) error { + if s.dialect.name() == "postgres" { + // A first ordinary account and first-admin bootstrap use the same + // admission lock. If bootstrap commits while this call waits, the + // earlier empty-database observation cannot authorize a second + // competing first account. + var before, after int + if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&before); err != nil { + return err + } + if before == 0 { + if err := s.dialect.lockBootstrap(tx); err != nil { + return err + } + if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&after); err != nil { + return err + } + if after != 0 { + return ErrConflict + } + } + } + if err := insertAccount(s, tx, w); err != nil { + return err + } + if w.PasswordHash == "" && w.Purpose != "" { + plain, id, err := insertLoginKey(s, tx, w) + if err != nil { + return err + } + out.Plain, out.CredentialID = plain, id + } + out.OperationID = opID + return nil + }) + return out, err +} + +// ApplyPasswordChange 在同一事务里写哈希、更新强制改密标志、作废未使用登录 Key,并处理会话。 +func (s *Store) ApplyPasswordChange(w AccountWrite) (AccountOutcome, error) { + if w.Action == "" { + w.Action = "account.password" + } + var out AccountOutcome + err := s.accountTx(w, func(tx *sql.Tx, opID string) error { + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), + w.PasswordHash, nowISO(), w.MustChange, w.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, w.Owner, LoginKeyStatusUnused); err != nil { + return err + } + if w.ExceptToken != "" { + _, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), w.Owner, hashKey(w.ExceptToken)) + } else { + _, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner) + } + if err != nil { + return err + } + if w.ExceptToken != "" { + if _, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=(SELECT auth_version FROM users WHERE owner=?) WHERE owner=? AND token_hash=?`), + w.Owner, w.Owner, hashKey(w.ExceptToken)); err != nil { + return err + } + } + out.OperationID = opID + return nil + }) + return out, err +} + +// BootstrapFirstAdmin 在立即事务(SQLite)或身份行锁(PostgreSQL)内复核空库后建首位管理员。 +func (s *Store) BootstrapFirstAdminWrite(w AccountWrite) (AccountOutcome, error) { + w.IsAdmin = true + w.PasswordHash = "" + w.Purpose = LoginKeyPurposeActivate + if w.CreatedBy == "" { + w.CreatedBy = "cli-bootstrap" + } + if w.Action == "" { + w.Action = "account.bootstrap" + } + var out AccountOutcome + err := s.accountTx(w, func(tx *sql.Tx, opID string) error { + if err := s.dialect.lockBootstrap(tx); err != nil { + return err + } + if err := lockIdentity(s, tx); err != nil { + return err + } + if err := requireEmptyBusiness(s, tx); err != nil { + return err + } + if err := insertAccount(s, tx, w); err != nil { + return err + } + var users int + if err := tx.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil { + return err + } + if users != 1 { + return ErrConflict + } + if err := s.insertAccountEvent(tx, w.CreatedBy, entranceOf(w.CreatedBy), w.Owner, "bootstrap_admin", w.Owner); err != nil { + return err + } + plain, id, err := insertLoginKey(s, tx, w) + if err != nil { + return err + } + out.Plain, out.CredentialID, out.OperationID = plain, id, opID + return nil + }) + return out, err +} + +// RevokeAllAndIssue 把吊销长期密钥、删除会话、作废未使用登录 Key 和签发新 Key 放进一次提交。 +func (s *Store) RevokeAllAndIssue(w AccountWrite) (AccountOutcome, error) { + if w.Action == "" { + w.Action = "account.revoke_credentials" + } + var out AccountOutcome + err := s.accountTx(w, func(tx *sql.Tx, opID string) error { + res, err := tx.Exec(s.q(`UPDATE users SET auth_version=auth_version+1 WHERE owner=?`), w.Owner) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + + if _, err := tx.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND revoked=0`), w.Owner); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), + LoginKeyStatusRevoked, w.Owner, LoginKeyStatusUnused); err != nil { + return err + } + plain, id, err := insertLoginKey(s, tx, w) + if err != nil { + return err + } + out.Plain, out.CredentialID, out.OperationID = plain, id, opID + return nil + }) + return out, err +} + +func (s *Store) accountTx(w AccountWrite, fn func(*sql.Tx, string) error) error { + opID := newID("op") + tx, err := s.db.Begin() + if err != nil { + return err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + if w.Owner != "" { + if err := s.dialect.lockAccount(tx, w.Owner); err != nil { + return err + } + } + if w.InstanceID == "" { + if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&w.InstanceID); err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + } + if err := fn(tx, opID); err != nil { + _ = tx.Rollback() + committed = true + s.recordRejected(w, opID) + return err + } + if s.accountFault != nil { + err = s.accountFault + _ = tx.Rollback() + committed = true + s.recordRejected(w, opID) + return err + } + if err := insertAudit(s, tx, w, opID, "success"); err != nil { + _ = tx.Rollback() + committed = true + s.recordRejected(w, opID) + return err + } + if err := tx.Commit(); err != nil { + // Commit may have reached the database even if the client lost its + // response. Recording a rejection here would assert an unknown outcome. + return err + } + committed = true + return nil +} + +func (s *Store) recordRejected(w AccountWrite, opID string) { + _, _ = s.db.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`), + nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, "rejected") +} + +func insertAudit(s *Store, tx *sql.Tx, w AccountWrite, opID, outcome string) error { + _, err := tx.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`), + nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, outcome) + return err +} + +func insertAccount(s *Store, tx *sql.Tx, w AccountWrite) error { + mustChange := w.PasswordHash != "" || w.MustChange + setAt := "" + if w.PasswordHash != "" { + setAt = nowISO() + } + _, 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"), w.Owner, w.Name, nowISO(), w.PasswordHash, setAt, mustChange, w.IsAdmin) + if isUniqueViolation(err) { + return ErrConflict + } + return err +} + +func insertLoginKey(s *Store, tx *sql.Tx, w AccountWrite) (string, string, error) { + purpose := w.Purpose + if purpose != LoginKeyPurposeActivate && purpose != LoginKeyPurposeRecover { + return "", "", fmt.Errorf("login key: invalid purpose %q", purpose) + } + ttl := w.TTL + if ttl <= 0 { + ttl = defaultLoginKeyTTL + } + if ttl < minLoginKeyTTL { + ttl = minLoginKeyTTL + } + if ttl > maxLoginKeyTTL { + ttl = maxLoginKeyTTL + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", "", err + } + idRaw := make([]byte, 8) + if _, err := rand.Read(idRaw); err != nil { + return "", "", err + } + plain := "alk_" + hex.EncodeToString(raw) + id := "lk_" + hex.EncodeToString(idRaw) + now := time.Now().UTC() + if _, err := s.insertLoginKeyTx(tx, w.Owner, purpose, w.CreatedBy, plain, id, now, now.Add(ttl)); err != nil { + return "", "", err + } + _, err := tx.Exec(s.q(`UPDATE login_keys SET instance_id=? WHERE id=?`), w.InstanceID, id) + return plain, id, err +} + +func lockIdentity(s *Store, tx *sql.Tx) error { + q := `SELECT instance_id FROM agent_system_identity` + if s.dialect.name() == "postgres" { + q += ` FOR UPDATE` + } + rows, err := tx.Query(q) + if err != nil { + return err + } + defer rows.Close() + n := 0 + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return err + } + n++ + } + if err := rows.Err(); err != nil { + return err + } + if n == 0 { + return fmt.Errorf("%w: agent_system_identity 无行", ErrSystemIdentityMissing) + } + return nil +} + +func requireEmptyBusiness(s *Store, tx *sql.Tx) error { + for _, table := range []string{"users", "projects", "conversations", "messages", "runs", "api_keys", "sessions", "login_keys"} { + var n int + if err := tx.QueryRow(`SELECT COUNT(1) FROM ` + table).Scan(&n); err != nil { + return err + } + if n > 0 { + return ErrConflict + } + } + return nil +} + +func (s *Store) requireCurrentSchema() error { + applied, err := s.MigrationStatus() + if err != nil || len(applied) == 0 { + return fmt.Errorf("schema 版本不可读;请运行 agent-system migrate,或先启动一次服务") + } + latest := migrations[len(migrations)-1].version + got := applied[len(applied)-1] + if got != latest { + return fmt.Errorf("schema v%d 与本二进制 v%d 不一致;请运行 agent-system migrate,或先启动一次服务", got, latest) + } + return nil +} + +// SetAccountFault 让下一次账户事务在提交前失败,用于半态测试。 +func (s *Store) SetAccountFault(err error) { + s.accountFault = err +} + +// CountAudit 返回指定 outcome 的账户审计条数。 +func (s *Store) CountAudit(owner, outcome string) (int, error) { + var n int + err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM audit_events WHERE owner=? AND entry=? AND outcome=?`), owner, "account", outcome).Scan(&n) + return n, err +} diff --git a/internal/store/account_tx_lifecycle_test.go b/internal/store/account_tx_lifecycle_test.go new file mode 100644 index 0000000..d18725b --- /dev/null +++ b/internal/store/account_tx_lifecycle_test.go @@ -0,0 +1,156 @@ +package store + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" +) + +func TestConcurrentCreateOneWinner(t *testing.T) { + st := openLife(t) + const n = 8 + var wg sync.WaitGroup + wins := make(chan error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := st.CreateActivatedAccount(AccountWrite{ + Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, CreatedBy: "test", + Action: "account.create", + }) + wins <- err + }() + } + wg.Wait() + close(wins) + okN, conflict := 0, 0 + for err := range wins { + switch { + case err == nil: + okN++ + case errors.Is(err, ErrConflict): + conflict++ + default: + t.Fatal(err) + } + } + if okN != 1 || conflict != n-1 { + t.Fatalf("wins=%d conflicts=%d", okN, conflict) + } +} + +func TestPasswordChangeIsAtomic(t *testing.T) { + st := openLife(t) + hash, err := HashPassword("old-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash, Action: "account.create"}); err != nil { + t.Fatal(err) + } + plain, _, err := st.IssueLoginKey("ada", LoginKeyPurposeActivate, "test", 0) + if err != nil { + t.Fatal(err) + } + st.SetAccountFault(fmt.Errorf("injected")) + next, err := HashPassword("new-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err == nil { + t.Fatal("faulted change returned success") + } + st.SetAccountFault(nil) + if _, ok := st.VerifyLogin("ada", "old-password"); !ok { + t.Fatal("old password did not survive the failed change") + } + if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err != nil { + t.Fatal(err) + } + if _, _, err := st.RedeemLoginKey(plain); err == nil { + t.Fatal("old key redeemed after password change") + } + if _, ok := st.VerifyLogin("ada", "new-password"); !ok { + t.Fatal("new password missing") + } + n, err := st.CountAudit("ada", "rejected") + if err != nil || n == 0 { + t.Fatalf("rejected audit n=%d err=%v", n, err) + } +} + +func TestOpenExistingDoesNotMigrateOrCreate(t *testing.T) { + missing := filepath.Join(t.TempDir(), "absent", "agent-system.db") + if _, err := OpenExistingSQLite(missing); err == nil { + t.Fatal("missing file opened") + } + if _, err := os.Stat(filepath.Dir(missing)); !os.IsNotExist(err) { + t.Fatalf("parent created: %v", err) + } + path := filepath.Join(t.TempDir(), "agent-system.db") + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + again, err := OpenExistingSQLite(path) + if err != nil { + t.Fatal(err) + } + again.Close() +} + +func TestBootstrapRefusesExistingBusiness(t *testing.T) { + st := openLife(t) + if err := st.EnsureSystemIdentity("inst", "sqlite"); err != nil { + t.Fatal(err) + } + if _, err := st.CreateProject("owner", "p"); err != nil { + t.Fatal(err) + } + if _, err := st.BootstrapFirstAdminWrite(AccountWrite{Owner: "root", Name: "root"}); err == nil { + t.Fatal("bootstrap accepted a database that already has a project") + } +} + +func TestPasswordSurvivesReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent-system.db") + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + hash, err := HashPassword("keep-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash}); err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + st, err = OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if _, ok := st.VerifyLogin("ada", "keep-password"); !ok { + t.Fatal("password did not survive reopen") + } +} + +func openLife(t *testing.T) *Store { + t.Helper() + st, err := OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} diff --git a/internal/store/auth.go b/internal/store/auth.go index 4d31272..bb87030 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -223,19 +223,10 @@ func VerifyPassword(hash, password string) bool { // (原子化:旧实现先查后插再 UPDATE,两个并发 create 同名会在 UPDATE 步互相覆盖 // 角色与密码——R5/KTD12 并发建号不覆盖。) func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) error { - mustChange := passwordHash != "" - setAt := "" - if passwordHash != "" { - setAt = nowISO() - } - // 布尔列一律绑 Go bool 参数、不写 1/0 字面量:SQLite 两侧都容忍, - // PostgreSQL 对 boolean 列收到 integer 会 42804(2026-10-05 user create 半失败根因)。 - _, 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 - } + _, err := s.CreateActivatedAccount(AccountWrite{ + Owner: owner, Name: name, PasswordHash: passwordHash, IsAdmin: isAdmin, + Action: "account.create", + }) return err } @@ -322,17 +313,32 @@ func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (str 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(?,?,?,?,?,?,?)`), + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + if err := s.dialect.lockAccount(tx, owner); err != nil { + return "", err + } + var ver int + if err := tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver); errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } else if err != nil { + return "", err + } + _, 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) - return token, err + if err != nil { + return "", err + } + return token, tx.Commit() } // CreateSessionVerified 建会话并绑定期望认证版本:事务内重读账户版本,与密码验证 @@ -355,6 +361,9 @@ func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, ex return "", err } defer tx.Rollback() + if err := s.dialect.lockAccount(tx, owner); err != nil { + return "", err + } var ver int err = tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver) if errors.Is(err, sql.ErrNoRows) { @@ -385,11 +394,17 @@ func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) { return "", "", false } var exp string - err := s.db.QueryRow(s.q(`SELECT owner,expires_at,COALESCE(scope,'full') FROM sessions WHERE token_hash=?`), hashKey(token)). - Scan(&owner, &exp, &scope) + var sessionVersion, accountVersion int + err := s.db.QueryRow(s.q(`SELECT s.owner,s.expires_at,COALESCE(s.scope,'full'),s.auth_version,u.auth_version + FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)). + Scan(&owner, &exp, &scope, &sessionVersion, &accountVersion) if err != nil { return "", "", false } + if sessionVersion != accountVersion { + _, _ = s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=? AND auth_version=?`), hashKey(token), sessionVersion) + return "", "", false + } if expT, err := time.Parse(time.RFC3339Nano, exp); err != nil || time.Now().After(expT) { s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=?`), hashKey(token)) return "", "", false diff --git a/internal/store/dialect.go b/internal/store/dialect.go index 9bf2c84..5c54cea 100644 --- a/internal/store/dialect.go +++ b/internal/store/dialect.go @@ -20,6 +20,8 @@ type dialect interface { // lockBootstrap 锁定首次管理员引导的「核验无用户→建号→激活 Key」串行段 //(KTD12:防两个进程并发引导出双管理员)。 lockBootstrap(tx *sql.Tx) error + // lockAccount serializes credential and session admission for one owner. + lockAccount(tx *sql.Tx, owner string) error } // ---- SQLite(嵌入式档)---- @@ -32,6 +34,7 @@ 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 } +func (sqliteDialect) lockAccount(*sql.Tx, string) error { return nil } // ---- PostgreSQL(多用户/多实例档)---- @@ -72,3 +75,8 @@ func (postgresDialect) lockBootstrap(tx *sql.Tx) error { _, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "bootstrap:first-admin") return err } + +func (postgresDialect) lockAccount(tx *sql.Tx, owner string) error { + _, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "account:"+owner) + return err +} diff --git a/internal/store/login_keys.go b/internal/store/login_keys.go index cea3683..7ac1a73 100644 --- a/internal/store/login_keys.go +++ b/internal/store/login_keys.go @@ -112,6 +112,15 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio return "", meta, err } defer tx.Rollback() + if err := s.dialect.lockAccount(tx, owner); err != nil { + return "", meta, err + } + var exists int + if err := tx.QueryRow(s.q(`SELECT 1 FROM users WHERE owner=?`), owner).Scan(&exists); errors.Is(err, sql.ErrNoRows) { + return "", meta, ErrNotFound + } else if err != nil { + return "", meta, err + } meta, err = s.insertLoginKeyTx(tx, owner, purpose, createdBy, plain, id, now, exp) if err != nil { return "", meta, err @@ -147,59 +156,9 @@ func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, i // 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 +func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (string, error) { + out, err := s.BootstrapFirstAdminWrite(AccountWrite{Owner: owner, Name: name, CreatedBy: createdBy, Actor: createdBy}) + return out.Plain, err } // RedeemBlocked 兑换入口跨实例退避:事务内清理窗口外失败记录,判定该来源 IP @@ -254,11 +213,20 @@ func (s *Store) RevokeLoginKey(id string) error { // RevokeLoginKeyCascade 撤销 Key 并作废该用户全部 set_password_only 会话(管理员 // HTTP 入口)。Key 已消费/过期时仍返回属主并作废会话;编号不存在才报 NotFound。 func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error) { + if err := s.db.QueryRow(s.q(`SELECT owner FROM login_keys WHERE id=?`), id).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } else if err != nil { + return "", err + } tx, err := s.db.Begin() if err != nil { return "", err } defer tx.Rollback() + if err := s.dialect.lockAccount(tx, owner); err != nil { + return "", err + } + lockedOwner := owner err = tx.QueryRow(s.q(`SELECT owner FROM login_keys WHERE id=?`), id).Scan(&owner) if errors.Is(err, sql.ErrNoRows) { return "", ErrNotFound @@ -266,6 +234,9 @@ func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error if err != nil { return "", err } + if owner != lockedOwner { + return "", ErrConflict + } if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusRevoked, id, LoginKeyStatusUnused); err != nil { return "", err @@ -329,11 +300,20 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er if plain == "" || len(plain) < 5 || plain[:4] != "alk_" { return "", "", ErrNotFound } + var lockOwner string + if err := s.db.QueryRow(s.q(`SELECT owner FROM login_keys WHERE key_hash=?`), hashKey(plain)).Scan(&lockOwner); errors.Is(err, sql.ErrNoRows) { + return "", "", ErrNotFound + } else if err != nil { + return "", "", err + } tx, err := s.db.Begin() if err != nil { return "", "", err } defer tx.Rollback() + if err := s.dialect.lockAccount(tx, lockOwner); err != nil { + return "", "", err + } var id, o, status, exp string err = tx.QueryRow(s.q(`SELECT id,owner,status,expires_at FROM login_keys WHERE key_hash=?`), hashKey(plain)). Scan(&id, &o, &status, &exp) @@ -343,6 +323,9 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er if err != nil { return "", "", err } + if o != lockOwner { + return "", "", ErrConflict + } expT, err := time.Parse(time.RFC3339Nano, exp) if err != nil || time.Now().After(expT) { _, _ = tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusExpired, id, LoginKeyStatusUnused) @@ -389,137 +372,139 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er // 写 hash、清 must_change、bump auth_version、作废未用登录 Key、删除全部会话、 // 写成功审计。会话或版本失效时返回 ErrConflict,不做任何写入。 func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner string, err error) { - tx, err := s.db.Begin() + // Discover only the lock key here. Authorization is checked again after + // acquiring the owner's transaction lock, alongside key reissue/revocation. + if err := s.db.QueryRow(s.q(`SELECT owner FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } else if err != nil { + return "", err + } + lockOwner := owner + err = s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.set_password"}, func(tx *sql.Tx, _ string) error { + var scope, sessExp, actualOwner 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(&actualOwner, &scope, &sessExp, &sessVer, &userVer, &mustChange, &hash) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if actualOwner != lockOwner { + return ErrConflict + } + 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, lockOwner) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n != 1 { + return ErrNotFound + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), + LoginKeyStatusRevoked, lockOwner, LoginKeyStatusUnused); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), lockOwner); err != nil { + return err + } + entrance := "must-change" + if scope == SessionScopeSetPasswordOnly { + entrance = "recovery" + } + return s.insertAccountEvent(tx, lockOwner, entrance, lockOwner, "password_set", entrance) + }) 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() + return lockOwner, nil } // ChangeUserPasswordActive 主动改密单事务:校验当前密码 → 更新(带认证版本乐观 // 检查)→ bump auth_version → 吊销其他会话(保留当前)→ 审计。中途失败无部分成功。 func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken string) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - var hash string - var ver int - var mustChange bool - err = tx.QueryRow(s.q(`SELECT password_hash,auth_version,must_change_password FROM users WHERE owner=?`), owner). - Scan(&hash, &ver, &mustChange) - if errors.Is(err, sql.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - if mustChange || hash == "" { - // 强制态必须走受限设密路径,不在此处用旧密码换新密码。 - return ErrConflict - } - if !VerifyPassword(hash, current) { - return ErrBadCredentials - } - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=? AND auth_version=?`), - passwordHash, nowISO(), false, owner, ver) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return ErrConflict // 并发改密:版本已前进 - } - if keepToken != "" { - if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil { + return s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.change_password"}, func(tx *sql.Tx, _ string) error { + 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 } - } 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() + 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 != 1 { + return ErrConflict + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return err + } + if keepToken != "" { + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=? WHERE owner=? AND token_hash=?`), + ver+1, owner, hashKey(keepToken)); err != nil { + return err + } + } else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return err + } + return s.insertAccountEvent(tx, owner, "settings", owner, "password_change", "") + }) } // AdminResetUserPassword 管理员/CLI 重置密码单事务:写 hash、must_change=true、 // bump auth_version、作废未用登录 Key、吊销全部会话、审计(actor 为操作者)。 func (s *Store) AdminResetUserPassword(owner, passwordHash, actor, entrance string) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), - passwordHash, nowISO(), true, owner) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return ErrNotFound - } - if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), - LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { - return err - } - if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { - return err - } if entrance == "" { entrance = entranceOf(actor) } - if err := s.insertAccountEvent(tx, actor, entrance, owner, "admin_password_reset", ""); err != nil { - return err - } - return tx.Commit() + return s.accountTx(AccountWrite{Owner: owner, Actor: actor, Action: "account.reset_password"}, func(tx *sql.Tx, _ string) error { + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), + passwordHash, nowISO(), true, owner) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n != 1 { + 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 + } + return s.insertAccountEvent(tx, actor, entrance, owner, "admin_password_reset", "") + }) } // CountUnusedLoginKeys 未使用登录 Key 计数(list 展示)。 diff --git a/internal/store/migrate.go b/internal/store/migrate.go index c31e0e8..3ec3de8 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -848,6 +848,29 @@ func init() { }, down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS project_event_shares`}, "postgres": {`DROP TABLE IF EXISTS project_event_shares`}}, }, + migration{version: 31, name: "account_audit", + up: map[string][]string{"sqlite": { + `ALTER TABLE audit_events ADD COLUMN entry TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE audit_events ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE audit_events ADD COLUMN operation_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE audit_events ADD COLUMN outcome TEXT NOT NULL DEFAULT ''`, + }, "postgres": { + `ALTER TABLE audit_events ADD COLUMN entry TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE audit_events ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE audit_events ADD COLUMN operation_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE audit_events ADD COLUMN outcome TEXT NOT NULL DEFAULT ''`, + }}, + down: map[string][]string{"sqlite": { + `ALTER TABLE audit_events DROP COLUMN outcome`, + `ALTER TABLE audit_events DROP COLUMN operation_id`, + `ALTER TABLE audit_events DROP COLUMN instance_id`, + `ALTER TABLE audit_events DROP COLUMN entry`, + }, "postgres": { + `ALTER TABLE audit_events DROP COLUMN outcome`, + `ALTER TABLE audit_events DROP COLUMN operation_id`, + `ALTER TABLE audit_events DROP COLUMN instance_id`, + `ALTER TABLE audit_events DROP COLUMN entry`, + }}}, ) } diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index 86c692c..7c2f3dc 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -35,6 +35,7 @@ var migrationBreaksOldReaders = map[int]bool{ 28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响 29: false, // redeem_attempts:新表,旧读者不受影响 30: false, // project_event_publication_proofs:新表,旧读者不受影响 + 31: false, // account_audit 给 audit_events 增加带默认值的列,旧制品按列名读取 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 diff --git a/internal/store/store.go b/internal/store/store.go index ae22d78..5d30b74 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -77,6 +77,7 @@ type Store struct { migrateMu sync.Mutex migrateFault error // 非空时 migratePass 在「DDL 已执行、版本未登记」处失败(原子性测试) + accountFault error // 非空时账户事务在提交前失败(半态测试) vecPGOnce sync.Once // pgvector 列探测(U6-3:一次性缓存) vecPG bool @@ -87,7 +88,7 @@ func OpenSQLite(path string) (*Store, error) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, err } - return openSQLiteFile(path, true) + return openSQLiteFile(path, true, true) } // OpenIdentity 打开既有库做身份 ensure/verify:不创建文件或目录,不执行迁移。 @@ -171,8 +172,8 @@ func (s *Store) SchemaVersion() (int, error) { return int(v.Int64), nil } -// OpenExistingSQLite 打开既有嵌入式库:数据文件不存在则失败,不创建空库或父目录。 -// 仍执行兼容迁移(requireCompatible);用于账户运维,避免恢复操作顺带建库。 +// OpenExistingSQLite 打开既有嵌入式库:数据文件不存在则失败,不创建空库或父目录,也不迁移。 +// schema 与本二进制不一致时拒绝,并提示使用 migrate 或先启动服务。 func OpenExistingSQLite(path string) (*Store, error) { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { @@ -180,10 +181,10 @@ func OpenExistingSQLite(path string) (*Store, error) { } return nil, err } - return openSQLiteFile(path, false) + return openSQLiteFile(path, false, false) } -func openSQLiteFile(path string, createArtifacts bool) (*Store, error) { +func openSQLiteFile(path string, createArtifacts, migrateSchema bool) (*Store, error) { // _txlock=immediate:写事务在 BEGIN 即取库写锁(而非首个写语句升级), // 跨进程迁移/写路径依赖 busy_timeout 排队而不是中途 SQLITE_BUSY(KTD8)。 // busy_timeout 30s:迁移竞争测试在慢速 CI 盘上 4 个迁移者串行全程可超 5s @@ -195,7 +196,7 @@ func openSQLiteFile(path string, createArtifacts bool) (*Store, error) { } // SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。 db.SetMaxOpenConns(1) - return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts) + return finishOpenMode(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts, migrateSchema) } // OpenPostgres 打开 PostgreSQL 档存储并执行迁移(默认连接预算)。 @@ -214,15 +215,15 @@ func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*St // 以 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) + return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true, true) } -// OpenExistingPostgres 打开既有 PG 库并执行兼容迁移;不创建 artifacts 目录。 +// OpenExistingPostgres 打开既有 PG 库,不创建 artifacts 目录,也不迁移。 func OpenExistingPostgres(dsn, artifactsDir string) (*Store, error) { - return openPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, nil, false) + return openPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, nil, false, false) } -func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store, createArtifacts bool) (*Store, error) { +func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store, createArtifacts, migrateSchema bool) (*Store, error) { pool = pool.effective() if pool.QueryTimeoutSec > 0 { dsn = withPGRuntimeParam(dsn, "statement_timeout", strconv.Itoa(pool.QueryTimeoutSec*1000)) @@ -237,7 +238,7 @@ func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs b if pool.ConnMaxLifetimeSec > 0 { db.SetConnMaxLifetime(time.Duration(pool.ConnMaxLifetimeSec) * time.Second) } - return finishOpen(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts) + return finishOpenMode(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts, migrateSchema) } // withPGRuntimeParam 向 DSN 追加运行时参数(libpq 语义:URL 查询串或键值对)。 @@ -263,7 +264,13 @@ func AppendPGRuntimeParam(dsn, key, value string) string { return dsn + sep + kv } +// Preserve the existing initialization helper contract; maintenance opens select +// nonmigrating mode explicitly through finishOpenMode. func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts bool) (*Store, error) { + return finishOpenMode(db, d, artifactsDir, blobs, pool, createArtifacts, true) +} + +func finishOpenMode(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts, migrateSchema bool) (*Store, error) { if blobstore.IsNil(blobs) { var err error if createArtifacts { @@ -277,7 +284,12 @@ func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Stor } } s := &Store{db: db, dialect: d, dataDir: artifactsDir, blobs: blobs, pool: pool} - if err := s.migrate(); err != nil { + if migrateSchema { + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + } else if err := s.requireCurrentSchema(); err != nil { db.Close() return nil, err }