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:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 展示)。
|
||||
|
||||
@@ -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`,
|
||||
}}},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user