Merge current Backend main into U3 candidate
ci / vet-and-test (pull_request) Successful in 46s

Why: Preserve the accepted U7 account transaction and identity safeguards while retaining U3 material checkpoint startup ordering and runtime fixes.

Evidence: The shared app.go edits are in separate startup/account-maintenance sections; full Go and parent composition checks will be rerun after this merge.

Impact: Includes mainline account changes without changing U3 public contracts or model-call budget.

Refs: U3 r0.4 resource-preparation sync; Dev main 05b997b
This commit is contained in:
2026-10-14 05:04:29 +00:00
19 changed files with 1969 additions and 281 deletions
+89
View File
@@ -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
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"agent-system-backend/internal/store"
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
)
func accountCLI(t *testing.T, args ...string) (int, string) {
t.Helper()
out, err := os.CreateTemp(t.TempDir(), "capture-")
if err != nil {
t.Fatal(err)
}
defer out.Close()
oldOut, oldErr := os.Stdout, os.Stderr
os.Stdout = out
os.Stderr = out
defer func() { os.Stdout = oldOut; os.Stderr = oldErr }()
rc := runUser(args)
out.Seek(0, 0)
raw, err := io.ReadAll(out)
if err != nil {
t.Fatal(err)
}
return rc, string(raw)
}
func cliStore(t *testing.T) (string, *store.Store) {
t.Helper()
t.Setenv("AGENT_SYSTEM_DSN", "")
dir := t.TempDir()
s, e := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { s.Close() })
if e := s.EnsureSystemIdentity("fixture", "sqlite"); e != nil {
t.Fatal(e)
}
return dir, s
}
func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) {
for _, sub := range []string{"create", "reset-password", "issue-login-key", "bootstrap-admin"} {
t.Run(sub, func(t *testing.T) {
dir, s := cliStore(t)
if sub != "create" && sub != "bootstrap-admin" {
if e := s.CreateAccount("ada", "Ada", "", false); e != nil {
t.Fatal(e)
}
}
out := filepath.Join(t.TempDir(), "secret")
rc, _ := accountCLI(t, sub, "--data", dir, "--owner", "ada", "--out-file", out)
if rc != 0 {
t.Fatalf("credential command failed: %d", rc)
}
info, e := os.Stat(out)
if e != nil {
t.Fatal("credential output file missing")
}
if info.Mode().Perm() != 0600 {
t.Fatal("credential output permissions are not private")
}
})
}
}
func TestExistingOutputAndConflictingFormatsRefuseBeforeMutation(t *testing.T) {
for _, kind := range []string{"existing", "conflicting"} {
t.Run(kind, func(t *testing.T) {
dir, s := cliStore(t)
out := filepath.Join(t.TempDir(), "secret")
args := []string{"create", "--data", dir, "--owner", "ada", "--out-file", out}
if kind == "existing" {
if e := os.WriteFile(out, []byte("existing"), 0600); e != nil {
t.Fatal(e)
}
} else {
args = append(args, "--out", filepath.Join(t.TempDir(), "json-secret"))
}
rc, _ := accountCLI(t, args...)
if rc == 0 {
t.Fatal("unsafe output selection succeeded")
}
if _, e := s.GetUser("ada"); e == nil {
t.Fatal("output refusal happened after account mutation")
}
})
}
}
func TestAPIKeyCreateUsesExclusivePrivateOutput(t *testing.T) {
if dir := os.Getenv("U7_APIKEY_CHILD_DIR"); dir != "" {
rc := runAPIKey([]string{"create", "--data", dir, "--owner", "ada", "--name", "fixture", "--out-file", os.Getenv("U7_APIKEY_CHILD_OUT")})
if rc != 0 {
t.Fatal("apikey create rejected its private output destination")
}
return
}
for _, existing := range []bool{false, true} {
name := "new"
if existing {
name = "existing"
}
t.Run(name, func(t *testing.T) {
dir, st := cliStore(t)
if err := st.CreateAccount("ada", "Ada", "", false); err != nil {
t.Fatal(err)
}
out := filepath.Join(t.TempDir(), "credential")
if existing {
if err := os.WriteFile(out, []byte("preserve"), 0600); err != nil {
t.Fatal(err)
}
}
cmd := exec.Command(os.Args[0], "-test.run=^TestAPIKeyCreateUsesExclusivePrivateOutput$")
cmd.Env = append(os.Environ(), "U7_APIKEY_CHILD_DIR="+dir, "U7_APIKEY_CHILD_OUT="+out)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if existing {
if err == nil {
t.Fatal("existing output was overwritten or mutation succeeded")
}
raw, readErr := os.ReadFile(out)
if readErr != nil || string(raw) != "preserve" {
t.Fatal("existing output was modified")
}
keys, listErr := st.ListAPIKeys("ada")
if listErr != nil || len(keys) != 0 {
t.Fatal("mutation happened before output preflight")
}
return
}
if err != nil {
t.Fatal("new private output was rejected")
}
info, statErr := os.Stat(out)
if statErr != nil || info.Mode().Perm() != 0600 {
t.Fatal("credential output missing or not private")
}
raw, readErr := os.ReadFile(out)
if readErr != nil || len(raw) == 0 {
t.Fatal("credential output empty")
}
if bytes.Contains(stdout.Bytes(), bytes.TrimSpace(raw)) {
t.Fatal("credential was written to stdout")
}
keys, listErr := st.ListAPIKeys("ada")
if listErr != nil || len(keys) != 1 {
t.Fatal("successful issue was not committed")
}
})
}
}
+150 -15
View File
@@ -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 <serve|version|identity|user|apikey|config> [参数]")
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config|capabilities> [参数]")
os.Exit(2)
}
}
@@ -194,22 +196,43 @@ func runAPIKey(args []string) int {
fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError)
data := fs.String("data", "./data", "数据目录")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
dsnFile := fs.String("dsn-file", "", "0600权限的PostgreSQL DSN文件")
owner := fs.String("owner", "", "租户名(owner)")
name := fs.String("name", "default", "密钥名称")
outFile := fs.String("out-file", "", "把明文密钥独占写入该文件(0600)")
fs.Parse(args[1:])
if *owner == "" {
fmt.Fprintln(os.Stderr, "apikey: --owner 必填")
return 2
}
if sub != "create" && *outFile != "" {
fmt.Fprintln(os.Stderr, "apikey: 此子命令不生成密钥")
return 2
}
resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey:", err)
return 1
}
*dsn = resolvedDSN
switch sub {
case "create":
delivery, err := reserveCredentialOutput(*outFile, "")
if err != nil {
fmt.Fprintln(os.Stderr, "apikey create:", err)
return 1
}
defer delivery.abort()
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey create:", err)
return 1
}
// 明文仅此一次输出;库中只存 SHA-256 摘要。
fmt.Println(key)
if err := delivery.deliver("api-key", *owner, key, nil); err != nil {
fmt.Fprintln(os.Stderr, "apikey create:", err)
return 1
}
return 0
case "list":
keys, err := app.ListAPIKeys(*dsn, *data, *owner)
@@ -253,13 +276,91 @@ func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) er
if err != nil {
return err
}
if err := os.WriteFile(outPath, buf, 0o600); err != nil {
if err := writeExclusiveSecret(outPath, string(buf)); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath)
return nil
}
type credentialOutput struct {
path string
file *os.File
json bool
mutated bool
}
// Reserve the destination before changing the account. O_EXCL also rejects an
// existing symlink, so an existing secret can never be overwritten.
func reserveCredentialOutput(rawPath, jsonPath string) (*credentialOutput, error) {
if rawPath != "" && jsonPath != "" {
return nil, fmt.Errorf("--out and --out-file are mutually exclusive")
}
out := &credentialOutput{}
out.path, out.json = rawPath, false
if jsonPath != "" {
out.path, out.json = jsonPath, true
}
if out.path == "" {
return out, nil
}
f, err := os.OpenFile(out.path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return nil, err
}
out.file = f
return out, nil
}
func (out *credentialOutput) abort() {
if out == nil || out.file == nil {
return
}
info, err := out.file.Stat()
_ = out.file.Close()
if out.mutated || err != nil {
return
}
pathInfo, pathErr := os.Stat(out.path)
if pathErr == nil && os.SameFile(info, pathInfo) {
_ = os.Remove(out.path)
}
}
func (out *credentialOutput) deliver(kind, owner, plaintext string, expiresAt any) error {
if out.file == nil {
return deliverCredential("", kind, owner, plaintext, expiresAt)
}
out.mutated = true // Keep the private recovery artifact if delivery fails after DB commit.
var body []byte
if out.json {
payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext}
if expiresAt != nil {
payload["expiresAt"] = expiresAt
}
var err error
body, err = json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
} else {
body = []byte(plaintext)
}
body = append(body, '\n')
if _, err := out.file.Write(body); err != nil {
return err
}
if err := out.file.Sync(); err != nil {
return err
}
if err := out.file.Close(); err != nil {
return err
}
out.file = nil
fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", out.path)
return nil
}
// runUser:create / reset-password / list / issue-login-key / revoke-login-key / bootstrap-admin。
// 默认 create 无初始密码并签发激活 Key;--generate-password 兼容旧随机密码路径。
func runUser(args []string) int {
@@ -270,13 +371,17 @@ func runUser(args []string) int {
sub := args[0]
fs := flag.NewFlagSet("user "+sub, flag.ExitOnError)
data := fs.String("data", "./data", "数据目录")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档;已弃用,优先 AGENT_SYSTEM_DSN 或 --dsn-file)")
dsnFile := fs.String("dsn-file", "", "0600 权限的 DSN 文件")
owner := fs.String("owner", "", "用户名(owner)")
name := fs.String("name", "", "显示名")
password := fs.String("password", "", "密码(create 空=激活 Key;加 --generate-password 则随机)")
password := fs.String("password", "", "密码(已弃用;请用 --password-stdin)")
passwordStdin := fs.Bool("password-stdin", false, "从 stdin 读取密码")
outFile := fs.String("out-file", "", "把明文凭据独占写入该文件(0600)")
instanceID := fs.String("instance-id", "", "严格核验库内服务身份")
admin := fs.Bool("admin", false, "管理员")
role := fs.String("role", "user", "角色 user|admin")
openExisting := fs.Bool("open-existing", false, "只打开既有存储")
openExisting := fs.Bool("open-existing", false, "兼容旧参数;账户命令始终只打开既有存储")
generatePassword := fs.Bool("generate-password", false, "create 时生成随机初始密码")
purpose := fs.String("purpose", "recover", "issue-login-key:activate|recover")
ttlMin := fs.Int("ttl-minutes", 30, "登录 Key 有效分钟(5–120)")
@@ -284,6 +389,35 @@ func runUser(args []string) int {
keyID := fs.String("id", "", "revoke-login-key 凭据编号")
out := fs.String("out", "", "凭据交付文件(0600 JSON,代替 stdout 明文;非交互推荐)")
fs.Parse(args[1:])
resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile)
if err != nil {
fmt.Fprintln(os.Stderr, "user:", err)
return 1
}
resolvedPassword, err := resolveAccountPassword(*password, *passwordStdin)
if err != nil {
fmt.Fprintln(os.Stderr, "user:", err)
return 1
}
if *instanceID != "" {
_ = os.Setenv("AGENT_SYSTEM_INSTANCE_ID", *instanceID)
}
*dsn = resolvedDSN
*password = resolvedPassword
credentialAction := sub == "create" || sub == "reset-password" || sub == "issue-login-key" || sub == "bootstrap-admin"
if !credentialAction && (*outFile != "" || *out != "") {
fmt.Fprintln(os.Stderr, "user: 此子命令不生成凭据")
return 2
}
var delivery *credentialOutput
if credentialAction {
delivery, err = reserveCredentialOutput(*outFile, *out)
if err != nil {
fmt.Fprintln(os.Stderr, "user:", err)
return 2
}
defer delivery.abort()
}
switch sub {
case "create":
if *owner == "" {
@@ -299,16 +433,17 @@ func runUser(args []string) int {
fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role)
return 2
}
pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
pw, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
if err != nil {
fmt.Fprintln(os.Stderr, "user create:", err)
return 1
}
if err := deliverCredential(*out, "login-key", *owner, pw, nil); err != nil {
if err := delivery.deliver("login-key", *owner, pw, nil); err != nil {
fmt.Fprintln(os.Stderr, "user create:", err)
return 1
}
if *out == "" {
fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID)
if *out == "" && *outFile == "" {
if *password == "" && !*generatePassword {
fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。")
} else if *password == "" {
@@ -326,11 +461,11 @@ func runUser(args []string) int {
fmt.Fprintln(os.Stderr, "user reset-password:", err)
return 1
}
if err := deliverCredential(*out, "password", *owner, pw, nil); err != nil {
if err := delivery.deliver("password", *owner, pw, nil); err != nil {
fmt.Fprintln(os.Stderr, "user reset-password:", err)
return 1
}
if *out == "" && *password == "" {
if *out == "" && *outFile == "" && *password == "" {
fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。")
}
return 0
@@ -357,11 +492,11 @@ func runUser(args []string) int {
fmt.Fprintln(os.Stderr, "user issue-login-key:", err)
return 1
}
if err := deliverCredential(*out, "login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil {
if err := delivery.deliver("login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil {
fmt.Fprintln(os.Stderr, "user issue-login-key:", err)
return 1
}
if *out == "" {
if *out == "" && *outFile == "" {
fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。")
}
return 0
@@ -386,11 +521,11 @@ func runUser(args []string) int {
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
return 1
}
if err := deliverCredential(*out, "bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
if err := delivery.deliver("bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
return 1
}
if *out == "" {
if *out == "" && *outFile == "" {
fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。")
}
return 0
+58
View File
@@ -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")
}
}
+77 -67
View File
@@ -740,8 +740,8 @@ func Run(opt Options) error {
// CreateUserAccount CLI 建号。
// password 空且 generatePassword=false:无初始密码,同事务签发 activate Key,返回明文 Key。
// generatePassword=true 或显式 password:兼容旧路径(随机/给定密码 + must_change)。
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) {
var out string
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, string, error) {
var out, opID string
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
if password != "" || generatePassword {
pw := password
@@ -752,23 +752,29 @@ func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmi
if err != nil {
return err
}
if err := st.CreateAccount(owner, name, hash, isAdmin); err != nil {
outc, err := st.CreateActivatedAccount(store.AccountWrite{
Owner: owner, Name: name, PasswordHash: hash, IsAdmin: isAdmin, MustChange: true,
Actor: cliActor(), InstanceID: accountInstanceID(), Action: "account.create",
})
if err != nil {
return err
}
out = pw
opID = outc.OperationID
return nil
}
if err := st.CreateAccount(owner, name, "", isAdmin); err != nil {
return err
}
plain, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli", 0)
outc, err := st.CreateActivatedAccount(store.AccountWrite{
Owner: owner, Name: name, IsAdmin: isAdmin, Purpose: store.LoginKeyPurposeActivate, CreatedBy: "cli",
Actor: cliActor(), InstanceID: accountInstanceID(), Action: "account.create",
})
if err != nil {
return err
}
out = plain
out = outc.Plain
opID = outc.OperationID
return nil
})
return out, err
return out, opID, err
}
// IssueUserLoginKey CLI 签发激活/恢复 Key。
@@ -776,10 +782,12 @@ func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting,
var plain string
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
if revokeAllCreds {
if err := st.RevokeAllAPIKeys(owner); err != nil {
return err
}
st.DeleteUserSessions(owner, "")
outc, err := st.RevokeAllAndIssue(store.AccountWrite{
Owner: owner, Purpose: purpose, CreatedBy: "cli", TTL: ttl,
Actor: cliActor(), InstanceID: accountInstanceID(),
})
plain = outc.Plain
return err
}
p, _, err := st.IssueLoginKey(owner, purpose, "cli", ttl)
plain = p
@@ -800,8 +808,10 @@ func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error
func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) {
var plain string
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
p, err := st.BootstrapFirstAdmin(owner, owner, "cli-bootstrap")
plain = p
outc, err := st.BootstrapFirstAdminWrite(store.AccountWrite{
Owner: owner, Name: owner, Actor: cliActor(), InstanceID: accountInstanceID(),
})
plain = outc.Plain
return err
})
return plain, err
@@ -818,7 +828,7 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting
return "", err
}
if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
return st.AdminResetUserPassword(owner, hash, "cli", "cli")
return st.AdminResetUserPassword(owner, hash, cliActor(), "cli")
}); err != nil {
return "", err
}
@@ -850,83 +860,75 @@ func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error)
return fn(st)
}
func withStoreMode(storageDSN, dataDir string, openExisting bool, fn func(*store.Store) error) error {
func withStoreMode(storageDSN, dataDir string, _ bool, fn func(*store.Store) error) error {
var st *store.Store
var err error
// DSN 等价安全输入(KTD14):argv 缺省时回落环境变量——凭据不落 shell history。
if storageDSN == "" {
storageDSN = os.Getenv("AGENT_SYSTEM_DSN")
}
// Account maintenance never initializes or migrates storage; the retained
// CLI compatibility flag cannot opt this operation into service initialization.
if storageDSN != "" {
if openExisting {
st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
}
st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
dbPath := filepath.Join(dataDir, "agent-system.db")
if openExisting {
st, err = store.OpenExistingSQLite(dbPath)
} else {
st, err = store.OpenSQLite(dbPath)
}
st, err = store.OpenExistingSQLite(filepath.Join(dataDir, "agent-system.db"))
}
if err != nil {
return err
}
defer st.Close()
if id := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); id != "" {
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
if dbref == "" {
if storageDSN != "" {
dbref = "pg"
} else {
dbref = "sqlite"
}
}
if err := st.VerifySystemIdentity(id, dbref); err != nil {
return err
}
}
return fn(st)
}
// CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。
func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) {
var st *store.Store
var err error
if storageDSN != "" {
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
}
if err != nil {
return "", err
}
defer st.Close()
return st.CreateAPIKey(owner, name)
var plain string
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
p, e := st.CreateAPIKey(owner, name)
plain = p
return e
})
return plain, err
}
// ListAPIKeys 列出租户密钥(摘要视图)。
func ListAPIKeys(storageDSN, dataDir, owner string) ([]store.APIKeyInfo, error) {
var st *store.Store
var err error
if storageDSN != "" {
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
}
if err != nil {
return nil, err
}
defer st.Close()
if _, err := st.GetUser(owner); err != nil {
return nil, fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner)
}
return st.ListAPIKeys(owner)
var out []store.APIKeyInfo
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
if _, err := st.GetUser(owner); err != nil {
return fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner)
}
var e error
out, e = st.ListAPIKeys(owner)
return e
})
return out, err
}
// RevokeAPIKey 撤销指定名称密钥。
func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) {
var st *store.Store
var err error
if storageDSN != "" {
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
}
if err != nil {
return false, err
}
defer st.Close()
return st.RevokeAPIKeyByName(owner, name)
var ok bool
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
var e error
ok, e = st.RevokeAPIKeyByName(owner, name)
return e
})
return ok, err
}
// EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。
@@ -955,6 +957,14 @@ func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) (int, e
return schemaVersion, err
}
func cliActor() string {
return fmt.Sprintf("%d", os.Getuid())
}
func accountInstanceID() string {
return strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID"))
}
// cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。
func cachedInt(ttl time.Duration, fn func() int) func() int {
var mu sync.Mutex
+3 -2
View File
@@ -286,7 +286,8 @@ func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
var req struct {
@@ -313,7 +314,7 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.st.CreateAccount(req.Owner, strings.TrimSpace(req.Name), hash, req.IsAdmin); err != nil {
if _, err := s.st.CreateActivatedAccount(store.AccountWrite{Owner: req.Owner, Name: strings.TrimSpace(req.Name), PasswordHash: hash, IsAdmin: req.IsAdmin, Actor: actor, Action: "account.create"}); err != nil {
writeStoreErr(w, err)
return
}
+5
View File
@@ -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)
}
@@ -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")
}
})
}
}
@@ -0,0 +1,187 @@
package store
import (
"errors"
"testing"
"time"
)
func activeAccount(t *testing.T) *Store {
t.Helper()
s := openTestStore(t)
h, e := HashPassword("old-password")
if e != nil {
t.Fatal(e)
}
if e := s.CreateAccount("ada", "Ada", h, false); e != nil {
t.Fatal(e)
}
if e := s.ClearMustChangePassword("ada"); e != nil {
t.Fatal(e)
}
return s
}
func TestAccountMaintenanceInvalidatesVerifiedLogin(t *testing.T) {
for _, kind := range []string{"password", "revoke-all"} {
t.Run(kind, func(t *testing.T) {
s := activeAccount(t)
_, ver, ok := s.VerifyLoginWithVersion("ada", "old-password")
if !ok {
t.Fatal("healthy login failed")
}
var err error
if kind == "password" {
h, e := HashPassword("new-password")
if e != nil {
t.Fatal(e)
}
_, err = s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h})
} else {
_, err = s.RevokeAllAndIssue(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin"})
}
if err != nil {
t.Fatal(err)
}
if _, err := s.CreateSessionVerified("ada", SessionScopeFull, time.Hour, ver); err == nil {
t.Fatal("stale in-flight login could create a session after credential replacement")
}
})
}
}
func TestActivatedAccountRetainsLoginKeySuccessEvent(t *testing.T) {
s := openTestStore(t)
if _, err := s.CreateActivatedAccount(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeActivate, CreatedBy: "admin", Actor: "admin"}); err != nil {
t.Fatal(err)
}
var n int
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM account_events WHERE owner=? AND action='login_key_issued'`), "ada").Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatal("atomic issuance dropped main account event")
}
}
func TestCheckedPasswordMutationRollsBackAuditFailure(t *testing.T) {
s := openTestStore(t)
if err := s.CreateAccount("ada", "Ada", "", false); err != nil {
t.Fatal(err)
}
key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeActivate, "admin", 0)
if err != nil {
t.Fatal(err)
}
token, _, err := s.RedeemLoginKey(key)
if err != nil {
t.Fatal(err)
}
h, err := HashPassword("new-password")
if err != nil {
t.Fatal(err)
}
s.SetAccountFault(ErrConflict)
if _, err := s.CompleteSetPasswordChecked(token, h); err == nil {
t.Fatal("checked password path bypassed the shared audited commit")
}
if a, err := s.GetUserAuthState("ada"); err != nil || a.HasPassword {
t.Fatal("failed checked change left a password behind")
}
if _, _, ok := s.SessionInfo(token); !ok {
t.Fatal("failed checked change consumed its session")
}
}
func TestRevokeAllRefusesMissingOwnerWithoutCredential(t *testing.T) {
s := openTestStore(t)
_, err := s.RevokeAllAndIssue(AccountWrite{Owner: "missing", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin", Actor: "admin"})
if !errors.Is(err, ErrNotFound) {
t.Fatalf("missing owner must be rejected before issuing any key: %v", err)
}
var n int
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM login_keys WHERE owner=?`), "missing").Scan(&n); err != nil || n != 0 {
t.Fatalf("orphan credential rows=%d err=%v", n, err)
}
}
func TestActivePasswordChangeRevokesUnusedRecoveryKey(t *testing.T) {
s := activeAccount(t)
key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0)
if err != nil {
t.Fatal(err)
}
session, err := s.CreateSession("ada")
if err != nil {
t.Fatal(err)
}
hash, err := HashPassword("replacement-password")
if err != nil {
t.Fatal(err)
}
if err := s.ChangeUserPasswordActive("ada", "old-password", hash, session); err != nil {
t.Fatal(err)
}
if _, _, err := s.RedeemLoginKey(key); err == nil {
t.Fatal("unused recovery key issued before active password change still redeems")
}
}
func TestRejectedActiveChangeIsAudited(t *testing.T) {
s := activeAccount(t)
h, err := HashPassword("next-password")
if err != nil {
t.Fatal(err)
}
err = s.ChangeUserPasswordActive("ada", "incorrect-current-password", h, "")
if !errors.Is(err, ErrBadCredentials) {
t.Fatalf("expected bad credentials, got %v", err)
}
count, err := s.CountAudit("ada", "rejected")
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("rejected checked account mutation has %d rejected audit rows, want1", count)
}
}
func TestDelayedOldVersionSessionIsRejectedAtUse(t *testing.T) {
s := activeAccount(t)
_, ver, ok := s.VerifyLoginWithVersion("ada", "old-password")
if !ok {
t.Fatal("healthy verification missing")
}
h, err := HashPassword("new-password")
if err != nil {
t.Fatal(err)
}
if _, err := s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h}); err != nil {
t.Fatal(err)
}
// Represents an insert racing after the replacement's earlier DELETE.
token := "sess_private_stale_fixture"
_, err = s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`),
hashKey(token), "ada", nowISO(), time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeFull, ver)
if err != nil {
t.Fatal(err)
}
if _, _, ok := s.SessionInfo(token); ok {
t.Fatal("session carrying the retired auth_version became authorized")
}
}
func TestActivePasswordChangePreservesCurrentVersionedSession(t *testing.T) {
s := activeAccount(t)
token, err := s.CreateSession("ada")
if err != nil {
t.Fatal(err)
}
h, err := HashPassword("new-password")
if err != nil {
t.Fatal(err)
}
if err := s.ChangeUserPasswordActive("ada", "old-password", h, token); err != nil {
t.Fatal(err)
}
if owner, scope, ok := s.SessionInfo(token); !ok || owner != "ada" || scope != SessionScopeFull {
t.Fatal("active change did not retain its current full session")
}
}
@@ -0,0 +1,167 @@
package store
import (
"context"
"testing"
"time"
)
// The trigger holds a transaction after its authorization read. A second
// connection must wait for that owner's lock before replacing credentials.
func holdPGAccountWrite(t *testing.T, s *Store, table, event, suffix string) func() {
t.Helper()
lock := "u7_account_" + suffix
_, err := s.db.Exec(`CREATE FUNCTION u7_pause_account() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_advisory_xact_lock(hashtext(current_schema() || ':` + lock + `')); RETURN NEW; END $$`)
if err != nil {
t.Fatal(err)
}
_, err = s.db.Exec(`CREATE TRIGGER u7_pause_account BEFORE ` + event + ` ON ` + table + ` FOR EACH ROW EXECUTE FUNCTION u7_pause_account()`)
if err != nil {
t.Fatal(err)
}
conn, err := s.db.Conn(context.Background())
if err != nil {
t.Fatal(err)
}
if _, err := conn.ExecContext(context.Background(), `SELECT pg_advisory_lock(hashtext(current_schema() || ':`+lock+`'))`); err != nil {
conn.Close()
t.Fatal(err)
}
released := false
release := func() {
if !released {
released = true
_, _ = conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock(hashtext(current_schema() || ':`+lock+`'))`)
_ = conn.Close()
}
}
t.Cleanup(release)
return release
}
func waitForPGAccountWrite(t *testing.T, s *Store, suffix string) {
t.Helper()
lock := "u7_account_" + suffix
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
var n int
err := s.db.QueryRow(`SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND NOT granted
AND objid=(hashtext(current_schema() || ':` + lock + `')::bigint & 4294967295)::oid`).Scan(&n)
if err != nil {
t.Fatal(err)
}
if n > 0 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("account write did not reach the deterministic PostgreSQL pause")
}
func awaitPGAccountResult[T any](t *testing.T, done <-chan T) T {
t.Helper()
select {
case result := <-done:
return result
case <-time.After(5 * time.Second):
t.Fatal("serialized account transaction did not finish")
var zero T
return zero
}
}
func TestPGVerifiedSessionAndPasswordChangeSerialize(t *testing.T) {
s := activeAccount(t)
if s.Dialect() != "postgres" {
t.Skip("real PostgreSQL required")
}
_, version, ok := s.VerifyLoginWithVersion("ada", "old-password")
if !ok {
t.Fatal("initial login failed")
}
release := holdPGAccountWrite(t, s, "sessions", "INSERT", "session")
type sessionResult struct {
token string
err error
}
sessionDone := make(chan sessionResult, 1)
go func() {
token, err := s.CreateSessionVerified("ada", SessionScopeFull, time.Hour, version)
sessionDone <- sessionResult{token, err}
}()
waitForPGAccountWrite(t, s, "session")
hash, err := HashPassword("replacement-password")
if err != nil {
t.Fatal(err)
}
changeDone := make(chan error, 1)
go func() {
_, err := s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: hash, Actor: "admin"})
changeDone <- err
}()
select {
case err := <-changeDone:
t.Fatalf("replacement committed ahead of the in-flight verified session: %v", err)
case <-time.After(100 * time.Millisecond):
}
release()
session := awaitPGAccountResult(t, sessionDone)
if session.err != nil {
t.Fatal(session.err)
}
if err := awaitPGAccountResult(t, changeDone); err != nil {
t.Fatal(err)
}
if _, _, ok := s.SessionInfo(session.token); ok {
t.Fatal("old verified session survived later password replacement")
}
}
func TestPGRestrictedPasswordSetAndKeyReissueSerialize(t *testing.T) {
s := openTestStore(t)
if s.Dialect() != "postgres" {
t.Skip("real PostgreSQL required")
}
if err := s.CreateAccount("ada", "Ada", "", false); err != nil {
t.Fatal(err)
}
key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0)
if err != nil {
t.Fatal(err)
}
token, _, err := s.RedeemLoginKey(key)
if err != nil {
t.Fatal(err)
}
hash, err := HashPassword("replacement-password")
if err != nil {
t.Fatal(err)
}
release := holdPGAccountWrite(t, s, "users", "UPDATE", "password")
setDone := make(chan error, 1)
go func() {
_, err := s.CompleteSetPasswordChecked(token, hash)
setDone <- err
}()
waitForPGAccountWrite(t, s, "password")
issueDone := make(chan error, 1)
go func() {
_, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0)
issueDone <- err
}()
select {
case err := <-issueDone:
t.Fatalf("key replacement committed ahead of the in-flight password set: %v", err)
case <-time.After(100 * time.Millisecond):
}
release()
if err := awaitPGAccountResult(t, setDone); err != nil {
t.Fatal(err)
}
if err := awaitPGAccountResult(t, issueDone); err != nil {
t.Fatal(err)
}
if _, _, ok := s.SessionInfo(token); ok {
t.Fatal("old restricted session survived password set and key reissue")
}
}
@@ -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)
}
})
}
}
+372
View File
@@ -0,0 +1,372 @@
package store
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
)
// AccountWrite 是一次账户变更的调用参数。成功审计与变更同事务。
type AccountWrite struct {
Owner string
Name string
PasswordHash string
IsAdmin bool
MustChange bool
ExceptToken string
Purpose string
CreatedBy string
TTL time.Duration
RevokeAllKeys bool
Actor string
InstanceID string
Action string
}
// AccountOutcome 是成功提交后可以对外展示的标识。不含密码、哈希或明文。
type AccountOutcome struct {
Plain string
CredentialID string
OperationID string
}
// CreateActivatedAccount 严格插入账户。passwordHash 为空且 Purpose 非空时,同一事务签发登录 Key。
func (s *Store) CreateActivatedAccount(w AccountWrite) (AccountOutcome, error) {
if w.Action == "" {
w.Action = "account.create"
}
var out AccountOutcome
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
if s.dialect.name() == "postgres" {
// A first ordinary account and first-admin bootstrap use the same
// admission lock. If bootstrap commits while this call waits, the
// earlier empty-database observation cannot authorize a second
// competing first account.
var before, after int
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&before); err != nil {
return err
}
if before == 0 {
if err := s.dialect.lockBootstrap(tx); err != nil {
return err
}
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&after); err != nil {
return err
}
if after != 0 {
return ErrConflict
}
}
}
if err := insertAccount(s, tx, w); err != nil {
return err
}
if w.PasswordHash == "" && w.Purpose != "" {
plain, id, err := insertLoginKey(s, tx, w)
if err != nil {
return err
}
out.Plain, out.CredentialID = plain, id
}
out.OperationID = opID
return nil
})
return out, err
}
// ApplyPasswordChange 在同一事务里写哈希、更新强制改密标志、作废未使用登录 Key,并处理会话。
func (s *Store) ApplyPasswordChange(w AccountWrite) (AccountOutcome, error) {
if w.Action == "" {
w.Action = "account.password"
}
var out AccountOutcome
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
w.PasswordHash, nowISO(), w.MustChange, w.Owner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, w.Owner, LoginKeyStatusUnused); err != nil {
return err
}
if w.ExceptToken != "" {
_, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), w.Owner, hashKey(w.ExceptToken))
} else {
_, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner)
}
if err != nil {
return err
}
if w.ExceptToken != "" {
if _, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=(SELECT auth_version FROM users WHERE owner=?) WHERE owner=? AND token_hash=?`),
w.Owner, w.Owner, hashKey(w.ExceptToken)); err != nil {
return err
}
}
out.OperationID = opID
return nil
})
return out, err
}
// BootstrapFirstAdmin 在立即事务(SQLite)或身份行锁(PostgreSQL)内复核空库后建首位管理员。
func (s *Store) BootstrapFirstAdminWrite(w AccountWrite) (AccountOutcome, error) {
w.IsAdmin = true
w.PasswordHash = ""
w.Purpose = LoginKeyPurposeActivate
if w.CreatedBy == "" {
w.CreatedBy = "cli-bootstrap"
}
if w.Action == "" {
w.Action = "account.bootstrap"
}
var out AccountOutcome
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
if err := s.dialect.lockBootstrap(tx); err != nil {
return err
}
if err := lockIdentity(s, tx); err != nil {
return err
}
if err := requireEmptyBusiness(s, tx); err != nil {
return err
}
if err := insertAccount(s, tx, w); err != nil {
return err
}
var users int
if err := tx.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil {
return err
}
if users != 1 {
return ErrConflict
}
if err := s.insertAccountEvent(tx, w.CreatedBy, entranceOf(w.CreatedBy), w.Owner, "bootstrap_admin", w.Owner); err != nil {
return err
}
plain, id, err := insertLoginKey(s, tx, w)
if err != nil {
return err
}
out.Plain, out.CredentialID, out.OperationID = plain, id, opID
return nil
})
return out, err
}
// RevokeAllAndIssue 把吊销长期密钥、删除会话、作废未使用登录 Key 和签发新 Key 放进一次提交。
func (s *Store) RevokeAllAndIssue(w AccountWrite) (AccountOutcome, error) {
if w.Action == "" {
w.Action = "account.revoke_credentials"
}
var out AccountOutcome
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
res, err := tx.Exec(s.q(`UPDATE users SET auth_version=auth_version+1 WHERE owner=?`), w.Owner)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n != 1 {
return ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND revoked=0`), w.Owner); err != nil {
return err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner); err != nil {
return err
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, w.Owner, LoginKeyStatusUnused); err != nil {
return err
}
plain, id, err := insertLoginKey(s, tx, w)
if err != nil {
return err
}
out.Plain, out.CredentialID, out.OperationID = plain, id, opID
return nil
})
return out, err
}
func (s *Store) accountTx(w AccountWrite, fn func(*sql.Tx, string) error) error {
opID := newID("op")
tx, err := s.db.Begin()
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
if w.Owner != "" {
if err := s.dialect.lockAccount(tx, w.Owner); err != nil {
return err
}
}
if w.InstanceID == "" {
if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&w.InstanceID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
}
if err := fn(tx, opID); err != nil {
_ = tx.Rollback()
committed = true
s.recordRejected(w, opID)
return err
}
if s.accountFault != nil {
err = s.accountFault
_ = tx.Rollback()
committed = true
s.recordRejected(w, opID)
return err
}
if err := insertAudit(s, tx, w, opID, "success"); err != nil {
_ = tx.Rollback()
committed = true
s.recordRejected(w, opID)
return err
}
if err := tx.Commit(); err != nil {
// Commit may have reached the database even if the client lost its
// response. Recording a rejection here would assert an unknown outcome.
return err
}
committed = true
return nil
}
func (s *Store) recordRejected(w AccountWrite, opID string) {
_, _ = s.db.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`),
nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, "rejected")
}
func insertAudit(s *Store, tx *sql.Tx, w AccountWrite, opID, outcome string) error {
_, err := tx.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`),
nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, outcome)
return err
}
func insertAccount(s *Store, tx *sql.Tx, w AccountWrite) error {
mustChange := w.PasswordHash != "" || w.MustChange
setAt := ""
if w.PasswordHash != "" {
setAt = nowISO()
}
_, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin) VALUES(?,?,?,?,?,?,?,?)`),
newID("usr"), w.Owner, w.Name, nowISO(), w.PasswordHash, setAt, mustChange, w.IsAdmin)
if isUniqueViolation(err) {
return ErrConflict
}
return err
}
func insertLoginKey(s *Store, tx *sql.Tx, w AccountWrite) (string, string, error) {
purpose := w.Purpose
if purpose != LoginKeyPurposeActivate && purpose != LoginKeyPurposeRecover {
return "", "", fmt.Errorf("login key: invalid purpose %q", purpose)
}
ttl := w.TTL
if ttl <= 0 {
ttl = defaultLoginKeyTTL
}
if ttl < minLoginKeyTTL {
ttl = minLoginKeyTTL
}
if ttl > maxLoginKeyTTL {
ttl = maxLoginKeyTTL
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", "", err
}
idRaw := make([]byte, 8)
if _, err := rand.Read(idRaw); err != nil {
return "", "", err
}
plain := "alk_" + hex.EncodeToString(raw)
id := "lk_" + hex.EncodeToString(idRaw)
now := time.Now().UTC()
if _, err := s.insertLoginKeyTx(tx, w.Owner, purpose, w.CreatedBy, plain, id, now, now.Add(ttl)); err != nil {
return "", "", err
}
_, err := tx.Exec(s.q(`UPDATE login_keys SET instance_id=? WHERE id=?`), w.InstanceID, id)
return plain, id, err
}
func lockIdentity(s *Store, tx *sql.Tx) error {
q := `SELECT instance_id FROM agent_system_identity`
if s.dialect.name() == "postgres" {
q += ` FOR UPDATE`
}
rows, err := tx.Query(q)
if err != nil {
return err
}
defer rows.Close()
n := 0
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
n++
}
if err := rows.Err(); err != nil {
return err
}
if n == 0 {
return fmt.Errorf("%w: agent_system_identity 无行", ErrSystemIdentityMissing)
}
return nil
}
func requireEmptyBusiness(s *Store, tx *sql.Tx) error {
for _, table := range []string{"users", "projects", "conversations", "messages", "runs", "api_keys", "sessions", "login_keys"} {
var n int
if err := tx.QueryRow(`SELECT COUNT(1) FROM ` + table).Scan(&n); err != nil {
return err
}
if n > 0 {
return ErrConflict
}
}
return nil
}
func (s *Store) requireCurrentSchema() error {
applied, err := s.MigrationStatus()
if err != nil || len(applied) == 0 {
return fmt.Errorf("schema 版本不可读;请运行 agent-system migrate,或先启动一次服务")
}
latest := migrations[len(migrations)-1].version
got := applied[len(applied)-1]
if got != latest {
return fmt.Errorf("schema v%d 与本二进制 v%d 不一致;请运行 agent-system migrate,或先启动一次服务", got, latest)
}
return nil
}
// SetAccountFault 让下一次账户事务在提交前失败,用于半态测试。
func (s *Store) SetAccountFault(err error) {
s.accountFault = err
}
// CountAudit 返回指定 outcome 的账户审计条数。
func (s *Store) CountAudit(owner, outcome string) (int, error) {
var n int
err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM audit_events WHERE owner=? AND entry=? AND outcome=?`), owner, "account", outcome).Scan(&n)
return n, err
}
+156
View File
@@ -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
}
+34 -19
View File
@@ -223,19 +223,10 @@ func VerifyPassword(hash, password string) bool {
// (原子化:旧实现先查后插再 UPDATE,两个并发 create 同名会在 UPDATE 步互相覆盖
// 角色与密码——R5/KTD12 并发建号不覆盖。)
func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) error {
mustChange := passwordHash != ""
setAt := ""
if passwordHash != "" {
setAt = nowISO()
}
// 布尔列一律绑 Go bool 参数、不写 1/0 字面量:SQLite 两侧都容忍,
// PostgreSQL 对 boolean 列收到 integer 会 42804(2026-10-05 user create 半失败根因)。
_, err := s.db.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin)
VALUES(?,?,?,?,?,?,?,?)`),
newID("usr"), owner, name, nowISO(), passwordHash, setAt, mustChange, isAdmin)
if isUniqueViolation(err) {
return ErrConflict
}
_, err := s.CreateActivatedAccount(AccountWrite{
Owner: owner, Name: name, PasswordHash: passwordHash, IsAdmin: isAdmin,
Action: "account.create",
})
return err
}
@@ -322,17 +313,32 @@ func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (str
if ttl <= 0 {
ttl = sessionTTL
}
ver := 0
_ = s.db.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver)
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := "sess_" + hex.EncodeToString(raw)
exp := time.Now().Add(ttl)
_, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`),
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, owner); err != nil {
return "", err
}
var ver int
if err := tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver); errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
} else if err != nil {
return "", err
}
_, err = tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`),
hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver)
return token, err
if err != nil {
return "", err
}
return token, tx.Commit()
}
// CreateSessionVerified 建会话并绑定期望认证版本:事务内重读账户版本,与密码验证
@@ -355,6 +361,9 @@ func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, ex
return "", err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, owner); err != nil {
return "", err
}
var ver int
err = tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver)
if errors.Is(err, sql.ErrNoRows) {
@@ -385,11 +394,17 @@ func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) {
return "", "", false
}
var exp string
err := s.db.QueryRow(s.q(`SELECT owner,expires_at,COALESCE(scope,'full') FROM sessions WHERE token_hash=?`), hashKey(token)).
Scan(&owner, &exp, &scope)
var sessionVersion, accountVersion int
err := s.db.QueryRow(s.q(`SELECT s.owner,s.expires_at,COALESCE(s.scope,'full'),s.auth_version,u.auth_version
FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)).
Scan(&owner, &exp, &scope, &sessionVersion, &accountVersion)
if err != nil {
return "", "", false
}
if sessionVersion != accountVersion {
_, _ = s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=? AND auth_version=?`), hashKey(token), sessionVersion)
return "", "", false
}
if expT, err := time.Parse(time.RFC3339Nano, exp); err != nil || time.Now().After(expT) {
s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=?`), hashKey(token))
return "", "", false
+8
View File
@@ -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
}
+151 -166
View File
@@ -112,6 +112,15 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio
return "", meta, err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, owner); err != nil {
return "", meta, err
}
var exists int
if err := tx.QueryRow(s.q(`SELECT 1 FROM users WHERE owner=?`), owner).Scan(&exists); errors.Is(err, sql.ErrNoRows) {
return "", meta, ErrNotFound
} else if err != nil {
return "", meta, err
}
meta, err = s.insertLoginKeyTx(tx, owner, purpose, createdBy, plain, id, now, exp)
if err != nil {
return "", meta, err
@@ -147,59 +156,9 @@ func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, i
// BootstrapFirstAdmin 首位管理员引导单事务:咨询锁串行(PG)→ 核验库内无用户 →
// 建管理员行 + 激活 Key + 审计共同提交;任何用户已存在返回 ErrConflict(拒绝接管)。
// SQLite 无咨询锁:插入后复查用户数恰为 1,双引导并发时后提交者回滚。
func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string, err error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
plain = "alk_" + hex.EncodeToString(raw)
idRaw := make([]byte, 8)
if _, err := rand.Read(idRaw); err != nil {
return "", err
}
id := "lk_" + hex.EncodeToString(idRaw)
now := time.Now().UTC()
exp := now.Add(defaultLoginKeyTTL)
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
if err := s.dialect.lockBootstrap(tx); err != nil {
return "", err
}
var n int
if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users`)).Scan(&n); err != nil {
return "", err
}
if n > 0 {
return "", ErrConflict // 有数据环境拒绝接管(R11)
}
if _, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin)
VALUES(?,?,?,?,?,?,?,?)`),
newID("usr"), owner, name, nowISO(), "", "", false, true); err != nil {
if isUniqueViolation(err) {
return "", ErrConflict
}
return "", err
}
// SQLite 并发兜底:本事务内用户数必须恰为 1(另一引导已提交则回滚)。
if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users`)).Scan(&n); err != nil {
return "", err
}
if n != 1 {
return "", ErrConflict
}
if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, createdBy, plain, id, now, exp); err != nil {
return "", err
}
if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "bootstrap_admin", owner); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return plain, nil
func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (string, error) {
out, err := s.BootstrapFirstAdminWrite(AccountWrite{Owner: owner, Name: name, CreatedBy: createdBy, Actor: createdBy})
return out.Plain, err
}
// RedeemBlocked 兑换入口跨实例退避:事务内清理窗口外失败记录,判定该来源 IP
@@ -254,11 +213,20 @@ func (s *Store) RevokeLoginKey(id string) error {
// RevokeLoginKeyCascade 撤销 Key 并作废该用户全部 set_password_only 会话(管理员
// HTTP 入口)。Key 已消费/过期时仍返回属主并作废会话;编号不存在才报 NotFound。
func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error) {
if err := s.db.QueryRow(s.q(`SELECT owner FROM login_keys WHERE id=?`), id).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
} else if err != nil {
return "", err
}
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, owner); err != nil {
return "", err
}
lockedOwner := owner
err = tx.QueryRow(s.q(`SELECT owner FROM login_keys WHERE id=?`), id).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
@@ -266,6 +234,9 @@ func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error
if err != nil {
return "", err
}
if owner != lockedOwner {
return "", ErrConflict
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`),
LoginKeyStatusRevoked, id, LoginKeyStatusUnused); err != nil {
return "", err
@@ -329,11 +300,20 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er
if plain == "" || len(plain) < 5 || plain[:4] != "alk_" {
return "", "", ErrNotFound
}
var lockOwner string
if err := s.db.QueryRow(s.q(`SELECT owner FROM login_keys WHERE key_hash=?`), hashKey(plain)).Scan(&lockOwner); errors.Is(err, sql.ErrNoRows) {
return "", "", ErrNotFound
} else if err != nil {
return "", "", err
}
tx, err := s.db.Begin()
if err != nil {
return "", "", err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, lockOwner); err != nil {
return "", "", err
}
var id, o, status, exp string
err = tx.QueryRow(s.q(`SELECT id,owner,status,expires_at FROM login_keys WHERE key_hash=?`), hashKey(plain)).
Scan(&id, &o, &status, &exp)
@@ -343,6 +323,9 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er
if err != nil {
return "", "", err
}
if o != lockOwner {
return "", "", ErrConflict
}
expT, err := time.Parse(time.RFC3339Nano, exp)
if err != nil || time.Now().After(expT) {
_, _ = tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusExpired, id, LoginKeyStatusUnused)
@@ -389,137 +372,139 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er
// 写 hash、清 must_change、bump auth_version、作废未用登录 Key、删除全部会话、
// 写成功审计。会话或版本失效时返回 ErrConflict,不做任何写入。
func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner string, err error) {
tx, err := s.db.Begin()
// Discover only the lock key here. Authorization is checked again after
// acquiring the owner's transaction lock, alongside key reissue/revocation.
if err := s.db.QueryRow(s.q(`SELECT owner FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
} else if err != nil {
return "", err
}
lockOwner := owner
err = s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.set_password"}, func(tx *sql.Tx, _ string) error {
var scope, sessExp, actualOwner string
var sessVer, userVer int
var mustChange bool
var hash sql.NullString
err := tx.QueryRow(s.q(`SELECT s.owner,s.scope,s.expires_at,s.auth_version,u.auth_version,u.must_change_password,u.password_hash
FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)).
Scan(&actualOwner, &scope, &sessExp, &sessVer, &userVer, &mustChange, &hash)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if actualOwner != lockOwner {
return ErrConflict
}
if expT, perr := time.Parse(time.RFC3339Nano, sessExp); perr != nil || time.Now().After(expT) {
return ErrNotFound
}
if scope != SessionScopeSetPasswordOnly && !mustChange && hash.Valid && hash.String != "" {
return ErrConflict
}
if sessVer != userVer {
return ErrConflict
}
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
passwordHash, nowISO(), false, lockOwner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n != 1 {
return ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, lockOwner, LoginKeyStatusUnused); err != nil {
return err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), lockOwner); err != nil {
return err
}
entrance := "must-change"
if scope == SessionScopeSetPasswordOnly {
entrance = "recovery"
}
return s.insertAccountEvent(tx, lockOwner, entrance, lockOwner, "password_set", entrance)
})
if err != nil {
return "", err
}
defer tx.Rollback()
var scope, sessExp string
var sessVer, userVer int
var mustChange bool
var hash sql.NullString
err = tx.QueryRow(s.q(`SELECT s.owner,s.scope,s.expires_at,s.auth_version,u.auth_version,u.must_change_password,u.password_hash
FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)).
Scan(&owner, &scope, &sessExp, &sessVer, &userVer, &mustChange, &hash)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
if expT, perr := time.Parse(time.RFC3339Nano, sessExp); perr != nil || time.Now().After(expT) {
return "", ErrNotFound
}
// 受限会话、待改密、未设密三者之一才允许免旧密码设密;其余按主动改密路径走。
if scope != SessionScopeSetPasswordOnly && !mustChange && (hash.Valid && hash.String != "") {
return "", ErrConflict
}
// 会话创建后账户凭据已变(并发改密/重置):本次授权过期,拒绝继续。
if sessVer != userVer {
return "", ErrConflict
}
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
passwordHash, nowISO(), false, owner)
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n == 0 {
return "", ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
return "", err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
return "", err
}
entrance := "must-change"
if scope == SessionScopeSetPasswordOnly {
entrance = "recovery"
}
if err := s.insertAccountEvent(tx, owner, entrance, owner, "password_set", entrance); err != nil {
return "", err
}
return owner, tx.Commit()
return lockOwner, nil
}
// ChangeUserPasswordActive 主动改密单事务:校验当前密码 → 更新(带认证版本乐观
// 检查)→ bump auth_version → 吊销其他会话(保留当前)→ 审计。中途失败无部分成功。
func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var hash string
var ver int
var mustChange bool
err = tx.QueryRow(s.q(`SELECT password_hash,auth_version,must_change_password FROM users WHERE owner=?`), owner).
Scan(&hash, &ver, &mustChange)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if mustChange || hash == "" {
// 强制态必须走受限设密路径,不在此处用旧密码换新密码。
return ErrConflict
}
if !VerifyPassword(hash, current) {
return ErrBadCredentials
}
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=? AND auth_version=?`),
passwordHash, nowISO(), false, owner, ver)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrConflict // 并发改密:版本已前进
}
if keepToken != "" {
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil {
return s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.change_password"}, func(tx *sql.Tx, _ string) error {
var hash string
var ver int
var mustChange bool
err := tx.QueryRow(s.q(`SELECT password_hash,auth_version,must_change_password FROM users WHERE owner=?`), owner).
Scan(&hash, &ver, &mustChange)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
} else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
return err
}
if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil {
return err
}
return tx.Commit()
if mustChange || hash == "" {
return ErrConflict
}
if !VerifyPassword(hash, current) {
return ErrBadCredentials
}
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=? AND auth_version=?`),
passwordHash, nowISO(), false, owner, ver)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n != 1 {
return ErrConflict
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
return err
}
if keepToken != "" {
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil {
return err
}
if _, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=? WHERE owner=? AND token_hash=?`),
ver+1, owner, hashKey(keepToken)); err != nil {
return err
}
} else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
return err
}
return s.insertAccountEvent(tx, owner, "settings", owner, "password_change", "")
})
}
// AdminResetUserPassword 管理员/CLI 重置密码单事务:写 hash、must_change=true、
// bump auth_version、作废未用登录 Key、吊销全部会话、审计(actor 为操作者)。
func (s *Store) AdminResetUserPassword(owner, passwordHash, actor, entrance string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
passwordHash, nowISO(), true, owner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
return err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
return err
}
if entrance == "" {
entrance = entranceOf(actor)
}
if err := s.insertAccountEvent(tx, actor, entrance, owner, "admin_password_reset", ""); err != nil {
return err
}
return tx.Commit()
return s.accountTx(AccountWrite{Owner: owner, Actor: actor, Action: "account.reset_password"}, func(tx *sql.Tx, _ string) error {
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
passwordHash, nowISO(), true, owner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n != 1 {
return ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
return err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
return err
}
return s.insertAccountEvent(tx, actor, entrance, owner, "admin_password_reset", "")
})
}
// CountUnusedLoginKeys 未使用登录 Key 计数(list 展示)。
+23
View File
@@ -848,6 +848,29 @@ func init() {
},
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS project_event_shares`}, "postgres": {`DROP TABLE IF EXISTS project_event_shares`}},
},
migration{version: 31, name: "account_audit",
up: map[string][]string{"sqlite": {
`ALTER TABLE audit_events ADD COLUMN entry TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN operation_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN outcome TEXT NOT NULL DEFAULT ''`,
}, "postgres": {
`ALTER TABLE audit_events ADD COLUMN entry TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN operation_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN outcome TEXT NOT NULL DEFAULT ''`,
}},
down: map[string][]string{"sqlite": {
`ALTER TABLE audit_events DROP COLUMN outcome`,
`ALTER TABLE audit_events DROP COLUMN operation_id`,
`ALTER TABLE audit_events DROP COLUMN instance_id`,
`ALTER TABLE audit_events DROP COLUMN entry`,
}, "postgres": {
`ALTER TABLE audit_events DROP COLUMN outcome`,
`ALTER TABLE audit_events DROP COLUMN operation_id`,
`ALTER TABLE audit_events DROP COLUMN instance_id`,
`ALTER TABLE audit_events DROP COLUMN entry`,
}}},
)
}
+1
View File
@@ -35,6 +35,7 @@ var migrationBreaksOldReaders = map[int]bool{
28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响
29: false, // redeem_attempts:新表,旧读者不受影响
30: false, // project_event_publication_proofs:新表,旧读者不受影响
31: false, // account_audit 给 audit_events 增加带默认值的列,旧制品按列名读取
}
// SchemaTarget 是本二进制会迁移到的 schema 版本。
+24 -12
View File
@@ -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
}