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..0d8bb85 --- /dev/null +++ b/cmd/agent-system/account_io_test.go @@ -0,0 +1,180 @@ +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", "initialize-account", "bootstrap-admin"} { + t.Run(sub, func(t *testing.T) { + dir, s := cliStore(t) + if sub != "bootstrap-admin" { + if _, e := s.BootstrapFirstAdminWrite(store.AccountWrite{Owner: "root", Name: "Root", Actor: "test"}); e != nil { + t.Fatal(e) + } + } + out := filepath.Join(t.TempDir(), "secret") + args := []string{sub, "--data", dir, "--owner", "ada", "--out-file", out} + if sub == "initialize-account" { + a, e := s.CreatePendingAccount("root", "ada", store.AccountProfile{Name: "Ada"}) + if e != nil { + t.Fatal(e) + } + args = append(args, "--user-id", a.ID, "--expected-state-version", "1", "--expected-generation", "0") + } + rc, _ := accountCLI(t, args...) + 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 TestAPIKeyCreateWithoutProtectedDestinationRefusesBeforeMutation(t *testing.T) { + dir, st := cliStore(t) + if err := st.CreateAccount("ada", "Ada", "", false); err != nil { + t.Fatal(err) + } + if rc := runAPIKey([]string{"create", "--data", dir, "--owner", "ada", "--name", "fixture"}); rc != 2 { + t.Fatalf("API key creation without private destination returned %d", rc) + } + var count int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM api_keys WHERE owner='ada'`).Scan(&count); err != nil || count != 0 { + t.Fatalf("rejected request created %d API keys: %v", count, err) + } +} + +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 f6a5299..f10abc7 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,47 @@ 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": + if *outFile == "" { + fmt.Fprintln(os.Stderr, "apikey create: --out-file is required for protected credential delivery") + return 2 + } + 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) @@ -238,11 +265,11 @@ func runAPIKey(args []string) int { } } -// deliverCredential writes a new 0600 file without following/overwriting an -// existing path. A secret never goes to stdout or a world-readable old file. +// deliverCredential writes only to a new private file. The secret never goes +// to stdout or an existing path; a JSON payload may include expiresAt. func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error { if outPath == "" { - return fmt.Errorf("protected --out delivery path required") + return fmt.Errorf("protected credential delivery path required") } payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext} if expiresAt != nil { @@ -252,22 +279,92 @@ func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) er if err != nil { return err } - file, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return err - } - if _, err := file.Write(buf); err != nil { - _ = file.Close() - return err - } - if err := file.Close(); err != nil { + if err := writeExclusiveSecret(outPath, string(buf)); err != nil { return err } fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath) return nil } -// runUser manages local owner initialization and metadata-only account actions. +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 fmt.Errorf("protected credential delivery path required") + } + 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:本地 owner 账号初始化、续办和只读/撤销操作。 func runUser(args []string) int { if len(args) == 0 { fmt.Fprintln(os.Stderr, "用法:agent-system user [flags]") @@ -276,29 +373,70 @@ 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)") - userID := fs.String("user-id", "", "待初始化账号稳定 ID") - expectedStateVersion := fs.Int64("expected-state-version", 0, "待初始化账号当前 stateVersion") - expectedGeneration := fs.Int64("expected-generation", -1, "待初始化账号当前 generation") + userID := fs.String("user-id", "", "待初始化账号稳定ID") + expectedStateVersion := fs.Int64("expected-state-version", 0, "待初始化账号状态版本") + expectedGeneration := fs.Int64("expected-generation", -1, "待初始化账号代数") name := fs.String("name", "", "显示名") - password := fs.String("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, "旧随机初始密码参数(现已拒绝)") fs.String("purpose", "recover", "旧 issue-login-key 参数(现已拒绝)") fs.Int("ttl-minutes", 30, "旧 issue-login-key 参数(现已拒绝)") fs.Bool("revoke-all-credentials", false, "旧 issue-login-key 参数(现已拒绝)") keyID := fs.String("id", "", "revoke-login-key 凭据编号") - out := fs.String("out", "", "凭据保护交付文件(0600 JSON,必填)") + out := fs.String("out", "", "凭据保护交付文件(0600 JSON)") fs.Parse(args[1:]) - switch sub { - case "create": - if *out == "" { - fmt.Fprintln(os.Stderr, "user create: --out 必填(保护交付文件)") + if sub == "reset-password" || sub == "issue-login-key" { + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery") + return 2 // reject before DSN/password reads, storage opens or output reservation + } + if sub == "create" && (*password != "" || *passwordStdin || *generatePassword) { + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only") + return 2 + } + 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 == "initialize-account" || sub == "bootstrap-admin" + if credentialAction && *outFile == "" && *out == "" { + fmt.Fprintln(os.Stderr, "user: --out or --out-file is required for protected credential delivery") + return 2 + } + 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 == "" { fmt.Fprintln(os.Stderr, "user create: --owner 必填") return 2 @@ -312,19 +450,20 @@ 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 } + fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID) return 0 case "initialize-account": - if *userID == "" || *expectedStateVersion < 1 || *expectedGeneration < 0 || *out == "" { - fmt.Fprintln(os.Stderr, "user initialize-account: --user-id、--expected-state-version、--expected-generation、--out 必填") + if *userID == "" || *expectedStateVersion < 1 || *expectedGeneration < 0 { + fmt.Fprintln(os.Stderr, "user initialize-account: --user-id、--expected-state-version、--expected-generation 必填") return 2 } plain, targetOwner, err := app.InitializeAccount(*dsn, *data, *userID, *expectedStateVersion, *expectedGeneration) @@ -332,13 +471,13 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user initialize-account:", err) return 1 } - if err := deliverCredential(*out, "activation", targetOwner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil { + if err := delivery.deliver("activation", targetOwner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil { fmt.Fprintln(os.Stderr, "user initialize-account:", err) return 1 } return 0 case "reset-password": - fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: administrators cannot set another user's password") + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED") return 2 case "list": users, err := app.ListUserAccounts(*dsn, *data, *openExisting) @@ -350,7 +489,7 @@ func runUser(args []string) int { fmt.Println(string(out)) return 0 case "issue-login-key": - fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery") + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED") return 2 case "revoke-login-key": if *keyID == "" { @@ -364,10 +503,6 @@ func runUser(args []string) int { fmt.Println("revoked") return 0 case "bootstrap-admin": - if *out == "" { - fmt.Fprintln(os.Stderr, "user bootstrap-admin: --out 必填(保护交付文件)") - return 2 - } if *owner == "" { fmt.Fprintln(os.Stderr, "user bootstrap-admin: --owner 必填") return 2 @@ -377,7 +512,7 @@ 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 } diff --git a/cmd/agent-system/user_contract_test.go b/cmd/agent-system/user_contract_test.go index 1abf705..bced563 100644 --- a/cmd/agent-system/user_contract_test.go +++ b/cmd/agent-system/user_contract_test.go @@ -18,7 +18,7 @@ func TestLegacyUserCredentialCommandsStopBeforeOpeningStorage(t *testing.T) { } } out := filepath.Join(data, "delivery.json") - if code := runUser([]string{"create", "--data", data, "--owner", "victim", "--password", "attacker-password", "--out", out}); code != 1 { + if code := runUser([]string{"create", "--data", data, "--owner", "victim", "--password", "attacker-password", "--out", out}); code != 2 { t.Fatalf("password-bearing local create accepted: %d", code) } if _, err := os.Stat(filepath.Join(data, "agent-system.db")); !os.IsNotExist(err) { 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 b7d96f3..fa4978d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -758,6 +758,9 @@ func Run(opt Options) error { } func assertLocalAccountOwner(st *store.Store, storageDSN, dataDir string) error { + if storageDSN == "" { + storageDSN = os.Getenv("AGENT_SYSTEM_DSN") + } if storageDSN != "" { var isOwner bool err := st.DB().QueryRow(`SELECT current_user = pg_get_userbyid(c.relowner) FROM pg_class c WHERE c.oid='users'::regclass`).Scan(&isOwner) @@ -777,34 +780,31 @@ func assertLocalAccountOwner(st *store.Store, storageDSN, dataDir string) error return nil } -// CreateUserAccount is the local owner-only atomic pending-account path. -// Password-bearing creation is rejected before opening or changing storage. -func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) { +// CreateUserAccount is the local owner-only, audited pending-account path. +// Password-bearing creation is rejected before storage is opened. +func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, string, error) { if password != "" || generatePassword { - return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only") + return "", "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only") } - var out string - err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { + var out store.AccountOutcome + err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { return err } - _, plain, err := st.CreateLocalPendingAccount(owner, name, isAdmin) - if err != nil { - return err - } - out = plain - return nil + _, next, err := st.CreateLocalPendingAccountWithAudit(owner, name, isAdmin, accountInstanceID()) + out = next + return err }) - return out, err + return out.Plain, out.OperationID, err } -// IssueUserLoginKey CLI 签发激活/恢复 Key。 +// IssueUserLoginKey is retained only as a rejecting compatibility entry. func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) { return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery") } -// InitializeAccount resumes a specific pending account. The database/file owner -// and the local OS UID are obtained from the live environment, not CLI flags. +// InitializeAccount resumes a specific pending account. The operator identity +// comes from the local OS UID; the target is fixed by stable user ID and CAS. func InitializeAccount(storageDSN, dataDir, userID string, expectedStateVersion, expectedGeneration int64) (string, string, error) { var plain, owner string err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { @@ -815,8 +815,8 @@ func InitializeAccount(storageDSN, dataDir, userID string, expectedStateVersion, if err != nil { return err } - plain, err = st.InitializePendingAccount(userID, expectedStateVersion, expectedGeneration) - owner = account.Owner + out, err := st.InitializePendingAccountWithAudit(userID, expectedStateVersion, expectedGeneration, accountInstanceID()) + plain, owner = out.Plain, account.Owner return err }) return plain, owner, err @@ -837,8 +837,10 @@ func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (strin if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { return err } - 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 @@ -875,83 +877,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 引导)。 @@ -980,6 +974,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/app/local_account_test.go b/internal/app/local_account_test.go index 716c7c8..5246863 100644 --- a/internal/app/local_account_test.go +++ b/internal/app/local_account_test.go @@ -9,11 +9,19 @@ import ( func TestLocalOwnerAccountCreationAndReissuePreserveIdentity(t *testing.T) { dir := t.TempDir() + seed, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + if err := seed.EnsureSystemIdentity("test-local-owner", "sqlite"); err != nil { + t.Fatal(err) + } + seed.Close() bootstrap, err := BootstrapAdmin("", dir, "root", false) if err != nil || bootstrap == "" { t.Fatalf("bootstrap: %v", err) } - first, err := CreateUserAccount("", dir, "alice", "", "", false, true, false) + first, _, err := CreateUserAccount("", dir, "alice", "", "", false, true, false) if err != nil || first == "" { t.Fatalf("local pending create: %v", err) } 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_model.go b/internal/store/account_model.go index b2c47a7..92bc682 100644 --- a/internal/store/account_model.go +++ b/internal/store/account_model.go @@ -123,6 +123,9 @@ func (s *Store) lockAccountUsers(tx *sql.Tx, owners ...string) (map[string]Accou if _, exists := accounts[owner]; exists { continue } + if err := s.dialect.lockAccount(tx, owner); err != nil { + return nil, err + } a, err := scanAccount(tx.QueryRow(query, owner)) if err != nil { return nil, err @@ -246,6 +249,9 @@ func (s *Store) PatchAccountProfile(actorOwner, userID string, expectedProfileVe if err := s.insertAccountEvent(tx, actorOwner, "account-profile", target.Owner, "profile_updated", fmt.Sprintf("version=%d fields=%s", expectedProfileVersion+1, strings.Join(fields, ","))); err != nil { return Account{}, err } + if err := s.auditAccountMutation(tx, target.Owner, actorOwner, "account.profile"); err != nil { + return Account{}, err + } if err := tx.Commit(); err != nil { return Account{}, err } @@ -279,6 +285,9 @@ func (s *Store) CreatePendingAccount(actorOwner, owner string, profile AccountPr if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "account_created_pending", a.ID); err != nil { return Account{}, err } + if err := s.auditAccountMutation(tx, owner, actorOwner, "account.create_pending"); err != nil { + return Account{}, err + } if err := tx.Commit(); err != nil { return Account{}, err } diff --git a/internal/store/account_model_test.go b/internal/store/account_model_test.go index 464c2fc..26f34c7 100644 --- a/internal/store/account_model_test.go +++ b/internal/store/account_model_test.go @@ -14,7 +14,7 @@ import ( func TestAccountModelLegacyMigrationDefaults(t *testing.T) { st := openTestStore(t) t.Logf("account model dialect=%s", st.Dialect()) - if err := st.MigrateDown(30); err != nil { + if err := st.MigrateDown(31); err != nil { t.Fatal(err) } if err := st.CreateAccount("legacy", "原姓名", "existing-hash", true); err != nil { @@ -23,8 +23,8 @@ func TestAccountModelLegacyMigrationDefaults(t *testing.T) { if err := st.CreateAccount("legacy-empty", "", "", false); err != nil { t.Fatal(err) } - // This fixture intentionally writes a v30 row before v31 migration. The - // current session API enforces the v31 account state and cannot be used here. + // This fixture intentionally writes a v31 row before v32 migration. The + // current session API enforces the v32 account state and cannot be used here. token := "sess_legacy_migration_fixture" _, err := st.db.Exec(st.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), hashKey(token), "legacy", nowISO(), time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeFull, 0) @@ -54,7 +54,7 @@ func TestAccountModelLegacyMigrationDefaults(t *testing.T) { if !enabled || profileVersion != 1 || stateVersion != 1 || origin != "legacy_unknown" || initialization != "legacy" || generation != 0 { t.Fatalf("unsafe migrated defaults: enabled=%v profile=%d state=%d origin=%q initialization=%q generation=%d", enabled, profileVersion, stateVersion, origin, initialization, generation) } - if SchemaCompatFloor() < 31 { + if SchemaCompatFloor() < 32 { t.Fatal("old authentication writers must be excluded by the compatibility floor") } empty, err := st.GetAccountByOwner("legacy-empty") 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..7e22bc1 --- /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.EnsureSystemIdentity("test-grant-audit", "sqlite"); err != nil { + t.Fatal(err) + } + out, err := s.BootstrapFirstAdminWrite(AccountWrite{Owner: "ada", Name: "Ada", Actor: "test"}) + if err != nil { + t.Fatal(err) + } + token, _, err := s.RedeemLoginKey(out.Plain) + 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..c5ed749 --- /dev/null +++ b/internal/store/account_serialization_pg_test.go @@ -0,0 +1,171 @@ +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.EnsureSystemIdentity("test-pg-grant-serialize", "pg"); err != nil { + t.Fatal(err) + } + out, err := s.BootstrapFirstAdminWrite(AccountWrite{Owner: "ada", Name: "Ada", Actor: "test"}) + if err != nil { + t.Fatal(err) + } + account, err := s.GetAccountByOwner("ada") + if err != nil { + t.Fatal(err) + } + token, _, err := s.RedeemLoginKey(out.Plain) + 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.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) + 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 != ErrConflict && err != ErrAccountForbidden { + t.Fatalf("completed initialization was reissued: %v", err) + } + if _, _, ok := s.SessionInfo(token); ok { + t.Fatal("old restricted session survived password set and key reissue") + } +} diff --git a/internal/store/account_state.go b/internal/store/account_state.go index 3cb3cc2..4494718 100644 --- a/internal/store/account_state.go +++ b/internal/store/account_state.go @@ -80,6 +80,9 @@ func (s *Store) SetAccountEnabled(actorOwner, userID string, expectedStateVersio if err := s.insertAccountEvent(tx, actorOwner, "account-state", target.Owner, action, ""); err != nil { return Account{}, err } + if err := s.auditAccountMutation(tx, target.Owner, actorOwner, "account.state"); err != nil { + return Account{}, err + } if err := tx.Commit(); err != nil { return Account{}, err } diff --git a/internal/store/account_two_process_pg_test.go b/internal/store/account_two_process_pg_test.go new file mode 100644 index 0000000..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..cc79947 --- /dev/null +++ b/internal/store/account_tx.go @@ -0,0 +1,411 @@ +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 = localOwnerActor() + } + 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 + } + account, err := s.insertPendingAccountTx(tx, w.Owner, AccountProfile{Name: w.Name}) + if err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), true, 1, account.ID); 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, err := newActivationPlain() + if err != nil { + return err + } + id := newID("lk") + now := time.Now().UTC() + if _, err := s.insertLoginKeyTx(tx, w.Owner, LoginKeyPurposeActivate, w.CreatedBy, plain, id, now, now.Add(defaultLoginKeyTTL), + grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil { + return err + } + if err := s.stampLoginKeyInstance(tx, id, w.InstanceID); 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.Action == "account.bootstrap" || w.Action == "account.create.local_pending" { + if err := s.dialect.lockBootstrap(tx); err != nil { + return err + } + } + 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 +} + +// auditAccountMutation records U3 account operations that have their own +// multi-account transaction and therefore cannot enter accountTx's single +// target callback. It must be called before the caller commits. +func (s *Store) auditAccountMutation(tx *sql.Tx, owner, actor, action string) error { + var instanceID string + if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&instanceID); err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + return insertAudit(s, tx, AccountWrite{Owner: owner, Actor: actor, Action: action, InstanceID: instanceID}, newID("op"), "success") +} + +func (s *Store) stampLoginKeyInstance(tx *sql.Tx, credentialID, instanceID string) error { + if instanceID == "" { + if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&instanceID); err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + } + _, err := tx.Exec(s.q(`UPDATE login_keys SET instance_id=? WHERE id=?`), instanceID, credentialID) + 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/account_u3_audit_test.go b/internal/store/account_u3_audit_test.go new file mode 100644 index 0000000..209f59e --- /dev/null +++ b/internal/store/account_u3_audit_test.go @@ -0,0 +1,35 @@ +package store + +import "testing" + +func TestAccountStateRollsBackWhenTransactionalAuditFails(t *testing.T) { + s := openTestStore(t) + if s.Dialect() != "sqlite" { + t.Skip("SQLite fault-injection trigger; PostgreSQL account tests run separately") + } + hash, err := HashPassword("owner-password") + if err != nil { + t.Fatal(err) + } + if err := s.CreateAccount("root", "Root", hash, true); err != nil { + t.Fatal(err) + } + if err := s.CreateAccount("ada", "Ada", "", false); err != nil { + t.Fatal(err) + } + target, err := s.GetAccountByOwner("ada") + if err != nil { + t.Fatal(err) + } + if _, err := s.DB().Exec(`CREATE TRIGGER fail_account_state_audit BEFORE INSERT ON audit_events + WHEN NEW.action='account.state' BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`); err != nil { + t.Fatal(err) + } + if _, err := s.SetAccountEnabled("root", target.ID, target.StateVersion, false); err == nil { + t.Fatal("state change committed without its audit event") + } + current, err := s.GetAccount(target.ID) + if err != nil || !current.Enabled || current.StateVersion != target.StateVersion { + t.Fatalf("audit failure left a partial state change: %+v %v", current, err) + } +} diff --git a/internal/store/auth.go b/internal/store/auth.go index 1b68b5d..bfdfc0f 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -236,19 +236,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 } @@ -332,31 +323,40 @@ func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (str if ttl <= 0 { ttl = sessionTTL } - state, err := s.GetUserAuthState(owner) - if err != nil { - return "", err - } - if !state.Enabled { - return "", ErrAccountForbidden - } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", err } token := "sess_" + hex.EncodeToString(raw) exp := time.Now().Add(ttl) - result, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) - SELECT ?,owner,?,?,?,?,auth_version FROM users WHERE owner=? AND enabled=? AND auth_version=?`), - hashKey(token), nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, owner, true, state.AuthVersion) + tx, err := s.db.Begin() if err != nil { return "", err } - if n, err := result.RowsAffected(); err != nil { + defer tx.Rollback() + if err := s.dialect.lockAccount(tx, owner); err != nil { return "", err - } else if n != 1 { - return "", ErrConflict } - return token, nil + var ver int + var enabled bool + query := s.q(`SELECT auth_version,enabled FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + if err := tx.QueryRow(query, owner).Scan(&ver, &enabled); errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } else if err != nil { + return "", err + } + if !enabled { + return "", ErrAccountForbidden + } + _, 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) + if err != nil { + return "", err + } + return token, tx.Commit() } // CreateSessionVerified 建会话并绑定期望认证版本:事务内重读账户版本,与密码验证 @@ -379,6 +379,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 var enabled bool var origin CredentialOrigin @@ -431,12 +434,18 @@ func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) { return "", "", false } var exp string - err := s.db.QueryRow(s.q(`SELECT s.owner,s.expires_at,COALESCE(s.scope,'full') FROM sessions s - JOIN users u ON u.owner=s.owner WHERE s.token_hash=? AND u.enabled=? AND s.auth_version=u.auth_version`), hashKey(token), true). - Scan(&owner, &exp, &scope) + var sessionVersion, accountVersion int + var enabled bool + err := s.db.QueryRow(s.q(`SELECT s.owner,s.expires_at,COALESCE(s.scope,'full'),s.auth_version,u.auth_version,u.enabled + FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)). + Scan(&owner, &exp, &scope, &sessionVersion, &accountVersion, &enabled) if err != nil { return "", "", false } + if !enabled || 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 5b76039..57b74d0 100644 --- a/internal/store/login_keys.go +++ b/internal/store/login_keys.go @@ -224,61 +224,11 @@ func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, i // 建管理员行 + 激活 Key + 审计共同提交;任何用户已存在返回 ErrConflict(拒绝接管)。 // createdBy 保留旧调用签名,但审计主体总从实际本地 OS UID 取得。 // SQLite 无咨询锁:插入后复查用户数恰为 1,双引导并发时后提交者回滚。 -func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string, err error) { - createdBy = fmt.Sprintf("local-owner:uid:%d", os.Geteuid()) - 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,initialization_state,initialization_generation) - VALUES(?,?,?,?,?,?,?,?,?,?)`), - newID("usr"), owner, name, nowISO(), "", "", false, true, InitializationPending, 1); 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, - grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); 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, Actor: fmt.Sprintf("%d", os.Geteuid()), CreatedBy: fmt.Sprintf("local-owner:uid:%d", os.Geteuid()), + }) + return out.Plain, err } // RedeemBlocked 兑换入口跨实例退避:事务内清理窗口外失败记录,判定该来源 IP @@ -354,6 +304,9 @@ func (s *Store) RevokeLoginKeyCascade(id, actor string, expectedOwner ...string) if len(expectedOwner) > 0 && expectedOwner[0] != owner { return "", ErrNotFound } + if err := s.dialect.lockAccount(tx, owner); 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 @@ -432,6 +385,9 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er return "", "", err } defer tx.Rollback() + if err := s.dialect.lockAccount(tx, routeOwner); err != nil { + return "", "", err + } locked, err := s.lockAccountUsers(tx, routeOwner) if err != nil { return "", "", err @@ -537,102 +493,100 @@ func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner st if err := s.db.QueryRow(s.q(`SELECT proposal_id FROM login_keys WHERE id=? AND owner=?`), routeGrant, owner).Scan(&routeProposal); err != nil { return "", ErrAccountForbidden } - tx, err := s.db.Begin() - if err != nil { - return "", err - } - defer tx.Rollback() - accounts, err := s.lockAccountUsers(tx, owner) - if err != nil { - return "", err - } - account := accounts[owner] - if !account.Enabled { - return "", ErrAccountForbidden - } - var proposalStatus, proposalOwner, proposalExpiry string - var proposalGeneration int64 - var proposalVersion int - if routeProposal != "" { - query := s.q(`SELECT owner,status,generation,expires_at,issued_auth_version FROM recovery_proposals WHERE id=?`) + err = s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.grant_setup"}, func(tx *sql.Tx, _ string) error { + accounts, err := s.lockAccountUsers(tx, owner) + if err != nil { + return err + } + account := accounts[owner] + if !account.Enabled { + return ErrAccountForbidden + } + var proposalStatus, proposalOwner, proposalExpiry string + var proposalGeneration int64 + var proposalVersion int + if routeProposal != "" { + query := s.q(`SELECT owner,status,generation,expires_at,issued_auth_version FROM recovery_proposals WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + if err := tx.QueryRow(query, routeProposal).Scan(&proposalOwner, &proposalStatus, &proposalGeneration, &proposalExpiry, &proposalVersion); err != nil { + return ErrAccountForbidden + } + } + grantQuery := s.q(`SELECT owner,purpose,status,expires_at,grant_origin,generation,issued_auth_version,proposal_id FROM login_keys WHERE id=?`) if s.Dialect() == "postgres" { - query += ` FOR UPDATE` + grantQuery += ` FOR UPDATE` } - if err := tx.QueryRow(query, routeProposal).Scan(&proposalOwner, &proposalStatus, &proposalGeneration, &proposalExpiry, &proposalVersion); err != nil { - return "", ErrAccountForbidden + var grantOwner, purpose, status, grantExpiry, origin, proposalID string + var generation int64 + var issuedVersion int + if err := tx.QueryRow(grantQuery, routeGrant).Scan(&grantOwner, &purpose, &status, &grantExpiry, &origin, &generation, &issuedVersion, &proposalID); err != nil { + return ErrAccountForbidden } - } - grantQuery := s.q(`SELECT owner,purpose,status,expires_at,grant_origin,generation,issued_auth_version,proposal_id FROM login_keys WHERE id=?`) - if s.Dialect() == "postgres" { - grantQuery += ` FOR UPDATE` - } - var grantOwner, purpose, status, grantExpiry, origin, proposalID string - var generation int64 - var issuedVersion int - if err := tx.QueryRow(grantQuery, routeGrant).Scan(&grantOwner, &purpose, &status, &grantExpiry, &origin, &generation, &issuedVersion, &proposalID); err != nil { - return "", ErrAccountForbidden - } - sessionQuery := s.q(`SELECT owner,scope,expires_at,auth_version,auth_method,credential_origin,grant_id FROM sessions WHERE token_hash=?`) - if s.Dialect() == "postgres" { - sessionQuery += ` FOR UPDATE` - } - var sessionOwner, scope, sessionExpiry, method, sessionOrigin, grantID string - var sessionVersion int - if err := tx.QueryRow(sessionQuery, hashKey(token)).Scan(&sessionOwner, &scope, &sessionExpiry, &sessionVersion, &method, &sessionOrigin, &grantID); err != nil { - return "", ErrNotFound - } - grantExpires, err := time.Parse(time.RFC3339Nano, grantExpiry) - if err != nil || time.Now().After(grantExpires) { - return "", ErrAccountForbidden - } - sessionExpires, err := time.Parse(time.RFC3339Nano, sessionExpiry) - if err != nil || time.Now().After(sessionExpires) || sessionOwner != owner || scope != SessionScopeSetPasswordOnly || method != AuthMethodGrant || - grantID != routeGrant || sessionVersion != account.AuthVersion || issuedVersion != account.AuthVersion || - grantOwner != owner || status != LoginKeyStatusConsumed || sessionOrigin != origin || proposalID != routeProposal { - return "", ErrAccountForbidden - } - switch { - case purpose == LoginKeyPurposeActivate && origin == string(CredentialOriginOwnerInitialization): - if routeProposal != "" || account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword { - return "", ErrAccountForbidden + sessionQuery := s.q(`SELECT owner,scope,expires_at,auth_version,auth_method,credential_origin,grant_id FROM sessions WHERE token_hash=?`) + if s.Dialect() == "postgres" { + sessionQuery += ` FOR UPDATE` } - case purpose == LoginKeyPurposeRecover && origin == string(CredentialOriginOwnerRecovery): - expires, perr := time.Parse(time.RFC3339Nano, proposalExpiry) - if routeProposal == "" || proposalOwner != owner || proposalStatus != "confirmed" || proposalGeneration != generation || - proposalVersion != account.AuthVersion || perr != nil || time.Now().After(expires) || !account.HasPassword { - return "", ErrAccountForbidden + var sessionOwner, scope, sessionExpiry, method, sessionOrigin, grantID string + var sessionVersion int + if err := tx.QueryRow(sessionQuery, hashKey(token)).Scan(&sessionOwner, &scope, &sessionExpiry, &sessionVersion, &method, &sessionOrigin, &grantID); err != nil { + return ErrNotFound } - default: - return "", ErrAccountForbidden - } - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1, + grantExpires, err := time.Parse(time.RFC3339Nano, grantExpiry) + if err != nil || time.Now().After(grantExpires) { + return ErrAccountForbidden + } + sessionExpires, err := time.Parse(time.RFC3339Nano, sessionExpiry) + if err != nil || time.Now().After(sessionExpires) || sessionOwner != owner || scope != SessionScopeSetPasswordOnly || method != AuthMethodGrant || + grantID != routeGrant || sessionVersion != account.AuthVersion || issuedVersion != account.AuthVersion || + grantOwner != owner || status != LoginKeyStatusConsumed || sessionOrigin != origin || proposalID != routeProposal { + return ErrAccountForbidden + } + switch { + case purpose == LoginKeyPurposeActivate && origin == string(CredentialOriginOwnerInitialization): + if routeProposal != "" || account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword { + return ErrAccountForbidden + } + case purpose == LoginKeyPurposeRecover && origin == string(CredentialOriginOwnerRecovery): + expires, perr := time.Parse(time.RFC3339Nano, proposalExpiry) + if routeProposal == "" || proposalOwner != owner || proposalStatus != "confirmed" || proposalGeneration != generation || + proposalVersion != account.AuthVersion || perr != nil || time.Now().After(expires) || !account.HasPassword { + return ErrAccountForbidden + } + default: + return ErrAccountForbidden + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1, credential_origin=?,initialization_state=?,state_version=state_version+1 WHERE owner=? AND auth_version=? AND enabled=?`), - passwordHash, nowISO(), false, origin, InitializationInitialized, owner, account.AuthVersion, true) - if err != nil { - return "", err - } - if n, err := res.RowsAffected(); err != nil || 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 _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { - return "", err - } - if routeProposal != "" { - if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='completed',version=version+1 WHERE id=? AND status='confirmed'`), routeProposal); err != nil { - return "", err + passwordHash, nowISO(), false, origin, InitializationInitialized, owner, account.AuthVersion, true) + if err != nil { + return err } - } - if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND id<>? AND status IN ('pending','confirmed')`), owner, routeProposal); err != nil { - return "", err - } - if err := s.insertAccountEvent(tx, owner, "trusted-grant", owner, "password_set", origin); err != nil { - return "", err - } - return owner, tx.Commit() + if n, err := res.RowsAffected(); err != nil || 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 _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return err + } + if routeProposal != "" { + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='completed',version=version+1 WHERE id=? AND status='confirmed'`), routeProposal); err != nil { + return err + } + } + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND id<>? AND status IN ('pending','confirmed')`), owner, routeProposal); err != nil { + return err + } + if err := s.insertAccountEvent(tx, owner, "trusted-grant", owner, "password_set", origin); err != nil { + return err + } + return nil + }) + return owner, err } // CompleteForcedPasswordChecked preserves the provenance of a historical @@ -646,173 +600,165 @@ func (s *Store) CompleteForcedPasswordChecked(token, current, passwordHash strin } return "", err } - tx, err := s.db.Begin() - if err != nil { - return "", err - } - defer tx.Rollback() - accounts, err := s.lockAccountUsers(tx, owner) - if err != nil { - return "", err - } - account := accounts[owner] - if !account.Enabled || !account.MustChangePassword || !account.HasPassword { - return "", ErrAccountForbidden - } - var hash string - if err := tx.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash); err != nil { - return "", err - } - query := s.q(`SELECT scope,auth_method,expires_at,auth_version FROM sessions WHERE token_hash=? AND owner=?`) - if s.Dialect() == "postgres" { - query += ` FOR UPDATE` - } - var scope, method, expiresAt string - var version int - if err := tx.QueryRow(query, hashKey(token), owner).Scan(&scope, &method, &expiresAt, &version); err != nil { - return "", ErrNotFound - } - expiry, err := time.Parse(time.RFC3339Nano, expiresAt) - if err != nil || time.Now().After(expiry) || version != account.AuthVersion || scope != SessionScopeSetPasswordOnly || method != AuthMethodPassword { - return "", ErrAccountForbidden - } - 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 + err := s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.forced"}, func(tx *sql.Tx, _ string) error { + accounts, err := s.lockAccountUsers(tx, owner) + if err != nil { + return err + } + account := accounts[owner] + if !account.Enabled || !account.MustChangePassword || !account.HasPassword { + return ErrAccountForbidden + } + var hash string + if err := tx.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash); err != nil { + return err + } + query := s.q(`SELECT scope,auth_method,expires_at,auth_version FROM sessions WHERE token_hash=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var scope, method, expiresAt string + var version int + if err := tx.QueryRow(query, hashKey(token), owner).Scan(&scope, &method, &expiresAt, &version); err != nil { + return ErrNotFound + } + expiry, err := time.Parse(time.RFC3339Nano, expiresAt) + if err != nil || time.Now().After(expiry) || version != account.AuthVersion || scope != SessionScopeSetPasswordOnly || method != AuthMethodPassword { + return ErrAccountForbidden + } + 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=? AND enabled=? AND must_change_password=?`), - passwordHash, nowISO(), false, owner, account.AuthVersion, true, true) - if err != nil { - return "", err - } - if n, err := res.RowsAffected(); err != nil || 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 _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil { - return "", err - } - if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { - return "", err - } - if err := s.insertAccountEvent(tx, owner, "password-session", owner, "password_change_forced", ""); err != nil { - return "", err - } - return owner, tx.Commit() + passwordHash, nowISO(), false, owner, account.AuthVersion, true, true) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil || 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 _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return err + } + if err := s.insertAccountEvent(tx, owner, "password-session", owner, "password_change_forced", ""); err != nil { + return err + } + return nil + }) + return owner, err } // 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, enabled bool - accountQuery := s.q(`SELECT password_hash,auth_version,must_change_password,enabled FROM users WHERE owner=?`) - if s.Dialect() == "postgres" { - accountQuery += ` FOR UPDATE` - } - err = tx.QueryRow(accountQuery, owner). - Scan(&hash, &ver, &mustChange, &enabled) - if errors.Is(err, sql.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - if !enabled { - return ErrAccountForbidden - } - 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 != "" { - kept, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=? WHERE token_hash=? AND owner=? AND auth_version=? AND scope=?`), ver+1, hashKey(keepToken), owner, ver, SessionScopeFull) + return s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.change"}, func(tx *sql.Tx, _ string) error { + var hash string + var ver int + var mustChange, enabled bool + accountQuery := s.q(`SELECT password_hash,auth_version,must_change_password,enabled FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + accountQuery += ` FOR UPDATE` + } + err := tx.QueryRow(accountQuery, owner). + Scan(&hash, &ver, &mustChange, &enabled) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } if err != nil { return err } - if n, err := kept.RowsAffected(); err != nil { - return err - } else if n != 1 { + if !enabled { + return ErrAccountForbidden + } + if mustChange || hash == "" { + // 强制态必须走受限设密路径,不在此处用旧密码换新密码。 return ErrConflict } - if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil { + 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 } - } else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { - return err - } - 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(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil { - return err - } - if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil { - return err - } - return tx.Commit() + if n, _ := res.RowsAffected(); n == 0 { + return ErrConflict // 并发改密:版本已前进 + } + if keepToken != "" { + kept, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=? WHERE token_hash=? AND owner=? AND auth_version=? AND scope=?`), ver+1, hashKey(keepToken), owner, ver, SessionScopeFull) + if err != nil { + return err + } + if n, err := kept.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrConflict + } + 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 := 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(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil { + return err + } + if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil { + return err + } + return nil + }) } // 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() - locked, err := s.lockAccountUsers(tx, owner) - if err != nil { - return err - } - if !locked[owner].Enabled { - return ErrAccountForbidden - } - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1, + return s.accountTx(AccountWrite{Owner: owner, Actor: actor, Action: "account.password.admin_reset"}, func(tx *sql.Tx, _ string) error { + locked, err := s.lockAccountUsers(tx, owner) + if err != nil { + return err + } + if !locked[owner].Enabled { + return ErrAccountForbidden + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1, credential_origin=?,initialization_state=? WHERE owner=? AND auth_version=? AND enabled=?`), - passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, owner, locked[owner].AuthVersion, true) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - 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 _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); 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() + passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, owner, locked[owner].AuthVersion, true) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + 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 _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); 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 nil + }) } // CountUnusedLoginKeys 未使用登录 Key 计数(list 展示)。 diff --git a/internal/store/migrate.go b/internal/store/migrate.go index e0951c9..8e19df1 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -848,14 +848,37 @@ 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_profile_state_and_provenance", + 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`, + }}}, + migration{version: 32, name: "account_profile_state_and_provenance", up: map[string][]string{ "sqlite": accountFoundationUp("INTEGER NOT NULL DEFAULT 1", "lower(hex(randomblob(16)))"), "postgres": accountFoundationUp("BOOLEAN NOT NULL DEFAULT TRUE", "md5(random()::text || clock_timestamp()::text)"), }, down: map[string][]string{"sqlite": accountFoundationDown, "postgres": accountFoundationDown}, }, - migration{version: 32, name: "owner_proof_recovery", + migration{version: 33, name: "owner_proof_recovery", up: map[string][]string{"sqlite": recoveryFoundationUp, "postgres": recoveryFoundationUp}, down: map[string][]string{"sqlite": recoveryFoundationDown, "postgres": recoveryFoundationDown}, }, @@ -891,7 +914,7 @@ var recoveryFoundationDown = []string{ `ALTER TABLE login_keys DROP COLUMN grant_origin`, } -// v31 preserves legacy identities and credentials. Only the explicit pending +// v32 preserves legacy identities and credentials. Only the explicit pending // account primitive sets pending initialization; migration cannot infer proof. func accountFoundationUp(enabledType, revisionExpression string) []string { return []string{ diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index c1cf23f..e22c16f 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -35,8 +35,9 @@ var migrationBreaksOldReaders = map[int]bool{ 28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响 29: false, // redeem_attempts:新表,旧读者不受影响 30: false, // project_event_publication_proofs:新表,旧读者不受影响 - 31: true, // 账号有效态/凭据来源是安全协议边界;即使旧 SQL 可读,也不得恢复旧认证写者 - 32: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除 + 31: false, // account_audit 给 audit_events 增加带默认值的列,旧制品按列名读取 + 32: true, // 账号有效态/凭据来源是安全协议边界;旧认证写者必须排除 + 33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 diff --git a/internal/store/owner_initialization.go b/internal/store/owner_initialization.go index 8f09eca..ca2856f 100644 --- a/internal/store/owner_initialization.go +++ b/internal/store/owner_initialization.go @@ -1,137 +1,18 @@ package store -import ( - "crypto/rand" - "encoding/hex" - "fmt" - "os" - "time" -) - // InitializePendingAccount is reserved for the local machine/DB owner CLI. It // accepts a stable user ID and expected versions, never an actor string from a // request. The audit actor is the actual local OS UID. The caller must use the // protected delivery channel; a failed delivery is retried with the same ID. func (s *Store) InitializePendingAccount(userID string, expectedStateVersion, expectedGeneration int64) (string, error) { - if userID == "" || expectedStateVersion < 1 || expectedGeneration < 0 { - return "", ErrConflict - } - pre, err := s.GetAccount(userID) - if err != nil { - return "", err - } - var raw [32]byte - if _, err := rand.Read(raw[:]); err != nil { - return "", err - } - plain := "alk_" + hex.EncodeToString(raw[:]) - operator := fmt.Sprintf("local-owner:uid:%d", os.Geteuid()) - now := time.Now().UTC() - tx, err := s.db.Begin() - if err != nil { - return "", err - } - defer tx.Rollback() - locked, err := s.lockAccountUsers(tx, pre.Owner) - if err != nil { - return "", err - } - account := locked[pre.Owner] - if account.ID != userID || account.StateVersion != expectedStateVersion || account.InitializationGeneration != expectedGeneration { - return "", ErrConflict - } - if !account.Enabled || account.InitializationState != InitializationPending || account.HasPassword || account.CredentialOrigin != CredentialOriginLegacyUnknown { - return "", ErrAccountForbidden - } - var unsafeCreds int - err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM api_keys WHERE owner=? AND revoked=0`), account.Owner).Scan(&unsafeCreds) - if err != nil { - return "", err - } - if unsafeCreds != 0 { - return "", ErrAccountForbidden - } - err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM sessions WHERE owner=? AND scope=?`), account.Owner, SessionScopeFull).Scan(&unsafeCreds) - if err != nil { - return "", err - } - if unsafeCreds != 0 { - return "", ErrAccountForbidden - } - err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM login_keys WHERE owner=? AND grant_origin<>?`), account.Owner, CredentialOriginOwnerInitialization).Scan(&unsafeCreds) - if err != nil { - return "", err - } - if unsafeCreds != 0 { - return "", ErrAccountForbidden - } - nextGeneration := account.InitializationGeneration + 1 - nextAuthVersion := account.AuthVersion + 1 - result, err := tx.Exec(s.q(`UPDATE users SET initialization_generation=?,state_version=state_version+1,auth_version=auth_version+1 - WHERE id=? AND state_version=? AND initialization_generation=? AND initialization_state=? AND password_hash='' AND enabled=?`), - nextGeneration, userID, expectedStateVersion, expectedGeneration, InitializationPending, true) - if err != nil { - return "", err - } - if n, err := result.RowsAffected(); err != nil || n != 1 { - return "", ErrConflict - } - if _, err := s.insertLoginKeyTx(tx, account.Owner, LoginKeyPurposeActivate, operator, plain, newID("lk"), now, now.Add(defaultLoginKeyTTL), - grantBinding{origin: CredentialOriginOwnerInitialization, generation: nextGeneration, version: nextAuthVersion}); err != nil { - return "", err - } - if err := s.insertAccountEvent(tx, operator, "local-owner-cli", account.Owner, "initialization_issued", fmt.Sprintf("user_id=%s generation=%d", userID, nextGeneration)); err != nil { - return "", err - } - if err := tx.Commit(); err != nil { - return "", err - } - return plain, nil + out, err := s.InitializePendingAccountWithAudit(userID, expectedStateVersion, expectedGeneration, "") + return out.Plain, err } // CreateLocalPendingAccount is the local owner-only new-account path. Account // and first activation grant commit together, including a local administrator // flag when explicitly requested by the machine/DB owner CLI. func (s *Store) CreateLocalPendingAccount(owner, name string, isAdmin bool) (Account, string, error) { - operator := fmt.Sprintf("local-owner:uid:%d", os.Geteuid()) - var raw [32]byte - if _, err := rand.Read(raw[:]); err != nil { - return Account{}, "", err - } - plain := "alk_" + hex.EncodeToString(raw[:]) - now := time.Now().UTC() - tx, err := s.db.Begin() - if err != nil { - return Account{}, "", err - } - defer tx.Rollback() - if err := s.dialect.lockBootstrap(tx); err != nil { - return Account{}, "", err - } - var users int - if err := tx.QueryRow(`SELECT COUNT(1) FROM users`).Scan(&users); err != nil { - return Account{}, "", err - } - if users == 0 { - return Account{}, "", ErrConflict // first administrator requires BootstrapFirstAdmin - } - account, err := s.insertPendingAccountTx(tx, owner, AccountProfile{Name: name}) - if err != nil { - return Account{}, "", err - } - if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), isAdmin, 1, account.ID); err != nil { - return Account{}, "", err - } - if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, operator, plain, newID("lk"), now, now.Add(defaultLoginKeyTTL), - grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil { - return Account{}, "", err - } - if err := s.insertAccountEvent(tx, operator, "local-owner-cli", owner, "local_account_created", account.ID); err != nil { - return Account{}, "", err - } - if err := tx.Commit(); err != nil { - return Account{}, "", err - } - account.IsAdmin, account.InitializationGeneration = isAdmin, 1 - return account, plain, nil + account, out, err := s.CreateLocalPendingAccountWithAudit(owner, name, isAdmin, "") + return account, out.Plain, err } diff --git a/internal/store/owner_initialization_audit.go b/internal/store/owner_initialization_audit.go new file mode 100644 index 0000000..6dc908e --- /dev/null +++ b/internal/store/owner_initialization_audit.go @@ -0,0 +1,141 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "os" + "strconv" + "time" +) + +func localOwnerActor() string { return fmt.Sprintf("local-owner:uid:%d", os.Geteuid()) } + +func newActivationPlain() (string, error) { + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", err + } + return "alk_" + hex.EncodeToString(raw[:]), nil +} + +// InitializePendingAccountWithAudit reissues only a trusted pending account's +// activation grant. The account mutation, grant, and success audit commit together. +func (s *Store) InitializePendingAccountWithAudit(userID string, expectedStateVersion, expectedGeneration int64, instanceID string) (AccountOutcome, error) { + var out AccountOutcome + if userID == "" || expectedStateVersion < 1 || expectedGeneration < 0 { + return out, ErrConflict + } + pre, err := s.GetAccount(userID) + if err != nil { + return out, err + } + plain, err := newActivationPlain() + if err != nil { + return out, err + } + operator := localOwnerActor() + w := AccountWrite{Owner: pre.Owner, Actor: strconv.Itoa(os.Geteuid()), InstanceID: instanceID, Action: "account.initialize"} + err = s.accountTx(w, func(tx *sql.Tx, opID string) error { + locked, err := s.lockAccountUsers(tx, pre.Owner) + if err != nil { + return err + } + account := locked[pre.Owner] + if account.ID != userID || account.StateVersion != expectedStateVersion || account.InitializationGeneration != expectedGeneration { + return ErrConflict + } + if !account.Enabled || account.InitializationState != InitializationPending || account.HasPassword || account.CredentialOrigin != CredentialOriginLegacyUnknown { + return ErrAccountForbidden + } + for _, query := range []string{ + `SELECT COUNT(1) FROM api_keys WHERE owner=? AND revoked=0`, + `SELECT COUNT(1) FROM sessions WHERE owner=? AND scope='full'`, + `SELECT COUNT(1) FROM login_keys WHERE owner=? AND grant_origin<>'owner_initialization'`, + } { + var unsafeCreds int + if err := tx.QueryRow(s.q(query), account.Owner).Scan(&unsafeCreds); err != nil { + return err + } + if unsafeCreds != 0 { + return ErrAccountForbidden + } + } + nextGeneration := account.InitializationGeneration + 1 + nextAuthVersion := account.AuthVersion + 1 + result, err := tx.Exec(s.q(`UPDATE users SET initialization_generation=?,state_version=state_version+1,auth_version=auth_version+1 + WHERE id=? AND state_version=? AND initialization_generation=? AND initialization_state=? AND password_hash='' AND enabled=?`), + nextGeneration, userID, expectedStateVersion, expectedGeneration, InitializationPending, true) + if err != nil { + return err + } + if n, err := result.RowsAffected(); err != nil || n != 1 { + return ErrConflict + } + now := time.Now().UTC() + id := newID("lk") + if _, err := s.insertLoginKeyTx(tx, account.Owner, LoginKeyPurposeActivate, operator, plain, id, now, now.Add(defaultLoginKeyTTL), + grantBinding{origin: CredentialOriginOwnerInitialization, generation: nextGeneration, version: nextAuthVersion}); err != nil { + return err + } + if err := s.stampLoginKeyInstance(tx, id, instanceID); err != nil { + return err + } + if err := s.insertAccountEvent(tx, operator, "local-owner-cli", account.Owner, "initialization_issued", fmt.Sprintf("user_id=%s generation=%d", userID, nextGeneration)); err != nil { + return err + } + out = AccountOutcome{Plain: plain, CredentialID: id, OperationID: opID} + return nil + }) + return out, err +} + +// CreateLocalPendingAccountWithAudit admits a new pending account through the +// same serialized, audited account transaction as other maintenance actions. +func (s *Store) CreateLocalPendingAccountWithAudit(owner, name string, isAdmin bool, instanceID string) (Account, AccountOutcome, error) { + var account Account + var out AccountOutcome + plain, err := newActivationPlain() + if err != nil { + return account, out, err + } + operator := localOwnerActor() + w := AccountWrite{Owner: owner, Actor: strconv.Itoa(os.Geteuid()), InstanceID: instanceID, Action: "account.create.local_pending"} + err = s.accountTx(w, func(tx *sql.Tx, opID string) error { + if err := s.dialect.lockBootstrap(tx); err != nil { + return err + } + var users int + if err := tx.QueryRow(`SELECT COUNT(1) FROM users`).Scan(&users); err != nil { + return err + } + if users == 0 { + return ErrConflict + } + var err error + account, err = s.insertPendingAccountTx(tx, owner, AccountProfile{Name: name}) + if err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), isAdmin, 1, account.ID); err != nil { + return err + } + now := time.Now().UTC() + id := newID("lk") + if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, operator, plain, id, now, now.Add(defaultLoginKeyTTL), + grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil { + return err + } + if err := s.stampLoginKeyInstance(tx, id, instanceID); err != nil { + return err + } + if err := s.insertAccountEvent(tx, operator, "local-owner-cli", owner, "local_account_created", account.ID); err != nil { + return err + } + account.IsAdmin, account.InitializationGeneration = isAdmin, 1 + out = AccountOutcome{Plain: plain, CredentialID: id, OperationID: opID} + return nil + }) + return account, out, err +} diff --git a/internal/store/recovery_proposals.go b/internal/store/recovery_proposals.go index 3679d14..8c9f131 100644 --- a/internal/store/recovery_proposals.go +++ b/internal/store/recovery_proposals.go @@ -188,6 +188,9 @@ func (s *Store) CreateRecoveryProposal(actorOwner, userID string, expectedStateV if err := s.insertAccountEvent(tx, actorOwner, "admin-http", target.Owner, "recovery_proposed", p.ID); err != nil { return RecoveryProposal{}, err } + if err := s.auditAccountMutation(tx, target.Owner, actorOwner, "account.recovery.propose"); err != nil { + return RecoveryProposal{}, err + } return p, tx.Commit() } @@ -283,6 +286,9 @@ func (s *Store) confirmOrRotateRecovery(token, proposalID string, expectedVersio if err := s.insertAccountEvent(tx, p.Owner, "owner-http", p.Owner, action, fmt.Sprintf("proposal=%s generation=%d", p.ID, p.Generation)); err != nil { return "", RecoveryProposal{}, err } + if err := s.auditAccountMutation(tx, p.Owner, p.Owner, "account.recovery."+action); err != nil { + return "", RecoveryProposal{}, err + } if err := tx.Commit(); err != nil { return "", RecoveryProposal{}, err } @@ -436,6 +442,9 @@ func (s *Store) RevokeRecoveryProposal(actorOwner, userID, proposalID string, ex if err := s.insertAccountEvent(tx, actorOwner, "admin-http", p.Owner, "recovery_revoked", p.ID); err != nil { return RecoveryProposal{}, err } + if err := s.auditAccountMutation(tx, p.Owner, actorOwner, "account.recovery.revoke"); err != nil { + return RecoveryProposal{}, err + } if err := tx.Commit(); err != nil { return RecoveryProposal{}, err } 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 }