Integrate U3 account contract with serialized account transactions
Why: U7 account audit and identity gates advanced on main while U3 account initialization and recovery were in progress. Evidence: SELF merge-tree checks passed with go test ./... and isolated PostgreSQL store/server tests; exact-head replay follows. Impact: Preserve pending-account grants, proof-linked recovery, protected CLI delivery, account serialization, and atomic audit. Refs: IAM U3 r1; U7 PR #185
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,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func accountCLI(t *testing.T, args ...string) (int, string) {
|
||||
t.Helper()
|
||||
out, err := os.CreateTemp(t.TempDir(), "capture-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer out.Close()
|
||||
oldOut, oldErr := os.Stdout, os.Stderr
|
||||
os.Stdout = out
|
||||
os.Stderr = out
|
||||
defer func() { os.Stdout = oldOut; os.Stderr = oldErr }()
|
||||
rc := runUser(args)
|
||||
out.Seek(0, 0)
|
||||
raw, err := io.ReadAll(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rc, string(raw)
|
||||
}
|
||||
func cliStore(t *testing.T) (string, *store.Store) {
|
||||
t.Helper()
|
||||
t.Setenv("AGENT_SYSTEM_DSN", "")
|
||||
dir := t.TempDir()
|
||||
s, e := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
if e := s.EnsureSystemIdentity("fixture", "sqlite"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return dir, s
|
||||
}
|
||||
func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) {
|
||||
for _, sub := range []string{"create", "initialize-account", "bootstrap-admin"} {
|
||||
t.Run(sub, func(t *testing.T) {
|
||||
dir, s := cliStore(t)
|
||||
if sub != "bootstrap-admin" {
|
||||
if _, e := s.BootstrapFirstAdminWrite(store.AccountWrite{Owner: "root", Name: "Root", Actor: "test"}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "secret")
|
||||
args := []string{sub, "--data", dir, "--owner", "ada", "--out-file", out}
|
||||
if sub == "initialize-account" {
|
||||
a, e := s.CreatePendingAccount("root", "ada", store.AccountProfile{Name: "Ada"})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
args = append(args, "--user-id", a.ID, "--expected-state-version", "1", "--expected-generation", "0")
|
||||
}
|
||||
rc, _ := accountCLI(t, args...)
|
||||
if rc != 0 {
|
||||
t.Fatalf("credential command failed: %d", rc)
|
||||
}
|
||||
info, e := os.Stat(out)
|
||||
if e != nil {
|
||||
t.Fatal("credential output file missing")
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatal("credential output permissions are not private")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestExistingOutputAndConflictingFormatsRefuseBeforeMutation(t *testing.T) {
|
||||
for _, kind := range []string{"existing", "conflicting"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
dir, s := cliStore(t)
|
||||
out := filepath.Join(t.TempDir(), "secret")
|
||||
args := []string{"create", "--data", dir, "--owner", "ada", "--out-file", out}
|
||||
if kind == "existing" {
|
||||
if e := os.WriteFile(out, []byte("existing"), 0600); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
} else {
|
||||
args = append(args, "--out", filepath.Join(t.TempDir(), "json-secret"))
|
||||
}
|
||||
rc, _ := accountCLI(t, args...)
|
||||
if rc == 0 {
|
||||
t.Fatal("unsafe output selection succeeded")
|
||||
}
|
||||
if _, e := s.GetUser("ada"); e == nil {
|
||||
t.Fatal("output refusal happened after account mutation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyCreateWithoutProtectedDestinationRefusesBeforeMutation(t *testing.T) {
|
||||
dir, st := cliStore(t)
|
||||
if err := st.CreateAccount("ada", "Ada", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc := runAPIKey([]string{"create", "--data", dir, "--owner", "ada", "--name", "fixture"}); rc != 2 {
|
||||
t.Fatalf("API key creation without private destination returned %d", rc)
|
||||
}
|
||||
var count int
|
||||
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM api_keys WHERE owner='ada'`).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("rejected request created %d API keys: %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyCreateUsesExclusivePrivateOutput(t *testing.T) {
|
||||
if dir := os.Getenv("U7_APIKEY_CHILD_DIR"); dir != "" {
|
||||
rc := runAPIKey([]string{"create", "--data", dir, "--owner", "ada", "--name", "fixture", "--out-file", os.Getenv("U7_APIKEY_CHILD_OUT")})
|
||||
if rc != 0 {
|
||||
t.Fatal("apikey create rejected its private output destination")
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, existing := range []bool{false, true} {
|
||||
name := "new"
|
||||
if existing {
|
||||
name = "existing"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir, st := cliStore(t)
|
||||
if err := st.CreateAccount("ada", "Ada", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "credential")
|
||||
if existing {
|
||||
if err := os.WriteFile(out, []byte("preserve"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestAPIKeyCreateUsesExclusivePrivateOutput$")
|
||||
cmd.Env = append(os.Environ(), "U7_APIKEY_CHILD_DIR="+dir, "U7_APIKEY_CHILD_OUT="+out)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
if existing {
|
||||
if err == nil {
|
||||
t.Fatal("existing output was overwritten or mutation succeeded")
|
||||
}
|
||||
raw, readErr := os.ReadFile(out)
|
||||
if readErr != nil || string(raw) != "preserve" {
|
||||
t.Fatal("existing output was modified")
|
||||
}
|
||||
keys, listErr := st.ListAPIKeys("ada")
|
||||
if listErr != nil || len(keys) != 0 {
|
||||
t.Fatal("mutation happened before output preflight")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal("new private output was rejected")
|
||||
}
|
||||
info, statErr := os.Stat(out)
|
||||
if statErr != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatal("credential output missing or not private")
|
||||
}
|
||||
raw, readErr := os.ReadFile(out)
|
||||
if readErr != nil || len(raw) == 0 {
|
||||
t.Fatal("credential output empty")
|
||||
}
|
||||
if bytes.Contains(stdout.Bytes(), bytes.TrimSpace(raw)) {
|
||||
t.Fatal("credential was written to stdout")
|
||||
}
|
||||
keys, listErr := st.ListAPIKeys("ada")
|
||||
if listErr != nil || len(keys) != 1 {
|
||||
t.Fatal("successful issue was not committed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+173
-38
@@ -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,47 @@ func runAPIKey(args []string) int {
|
||||
fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError)
|
||||
data := fs.String("data", "./data", "数据目录")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
|
||||
dsnFile := fs.String("dsn-file", "", "0600权限的PostgreSQL DSN文件")
|
||||
owner := fs.String("owner", "", "租户名(owner)")
|
||||
name := fs.String("name", "default", "密钥名称")
|
||||
outFile := fs.String("out-file", "", "把明文密钥独占写入该文件(0600)")
|
||||
fs.Parse(args[1:])
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "apikey: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
if sub != "create" && *outFile != "" {
|
||||
fmt.Fprintln(os.Stderr, "apikey: 此子命令不生成密钥")
|
||||
return 2
|
||||
}
|
||||
resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey:", err)
|
||||
return 1
|
||||
}
|
||||
*dsn = resolvedDSN
|
||||
switch sub {
|
||||
case "create":
|
||||
if *outFile == "" {
|
||||
fmt.Fprintln(os.Stderr, "apikey create: --out-file is required for protected credential delivery")
|
||||
return 2
|
||||
}
|
||||
delivery, err := reserveCredentialOutput(*outFile, "")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey create:", err)
|
||||
return 1
|
||||
}
|
||||
defer delivery.abort()
|
||||
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey create:", err)
|
||||
return 1
|
||||
}
|
||||
// 明文仅此一次输出;库中只存 SHA-256 摘要。
|
||||
fmt.Println(key)
|
||||
if err := delivery.deliver("api-key", *owner, key, nil); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey create:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "list":
|
||||
keys, err := app.ListAPIKeys(*dsn, *data, *owner)
|
||||
@@ -238,11 +265,11 @@ func runAPIKey(args []string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// deliverCredential writes a new 0600 file without following/overwriting an
|
||||
// existing path. A secret never goes to stdout or a world-readable old file.
|
||||
// deliverCredential writes only to a new private file. The secret never goes
|
||||
// to stdout or an existing path; a JSON payload may include expiresAt.
|
||||
func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error {
|
||||
if outPath == "" {
|
||||
return fmt.Errorf("protected --out delivery path required")
|
||||
return fmt.Errorf("protected credential delivery path required")
|
||||
}
|
||||
payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext}
|
||||
if expiresAt != nil {
|
||||
@@ -252,22 +279,92 @@ func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) er
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := file.Write(buf); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
if err := writeExclusiveSecret(outPath, string(buf)); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runUser manages local owner initialization and metadata-only account actions.
|
||||
type credentialOutput struct {
|
||||
path string
|
||||
file *os.File
|
||||
json bool
|
||||
mutated bool
|
||||
}
|
||||
|
||||
// Reserve the destination before changing the account. O_EXCL also rejects an
|
||||
// existing symlink, so an existing secret can never be overwritten.
|
||||
func reserveCredentialOutput(rawPath, jsonPath string) (*credentialOutput, error) {
|
||||
if rawPath != "" && jsonPath != "" {
|
||||
return nil, fmt.Errorf("--out and --out-file are mutually exclusive")
|
||||
}
|
||||
out := &credentialOutput{}
|
||||
out.path, out.json = rawPath, false
|
||||
if jsonPath != "" {
|
||||
out.path, out.json = jsonPath, true
|
||||
}
|
||||
if out.path == "" {
|
||||
return out, nil
|
||||
}
|
||||
f, err := os.OpenFile(out.path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.file = f
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (out *credentialOutput) abort() {
|
||||
if out == nil || out.file == nil {
|
||||
return
|
||||
}
|
||||
info, err := out.file.Stat()
|
||||
_ = out.file.Close()
|
||||
if out.mutated || err != nil {
|
||||
return
|
||||
}
|
||||
pathInfo, pathErr := os.Stat(out.path)
|
||||
if pathErr == nil && os.SameFile(info, pathInfo) {
|
||||
_ = os.Remove(out.path)
|
||||
}
|
||||
}
|
||||
|
||||
func (out *credentialOutput) deliver(kind, owner, plaintext string, expiresAt any) error {
|
||||
if out.file == nil {
|
||||
return fmt.Errorf("protected credential delivery path required")
|
||||
}
|
||||
out.mutated = true // Keep the private recovery artifact if delivery fails after DB commit.
|
||||
var body []byte
|
||||
if out.json {
|
||||
payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext}
|
||||
if expiresAt != nil {
|
||||
payload["expiresAt"] = expiresAt
|
||||
}
|
||||
var err error
|
||||
body, err = json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
body = []byte(plaintext)
|
||||
}
|
||||
body = append(body, '\n')
|
||||
if _, err := out.file.Write(body); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := out.file.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := out.file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
out.file = nil
|
||||
fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", out.path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runUser:本地 owner 账号初始化、续办和只读/撤销操作。
|
||||
func runUser(args []string) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|initialize-account|list|revoke-login-key|bootstrap-admin> [flags]")
|
||||
@@ -276,29 +373,70 @@ func runUser(args []string) int {
|
||||
sub := args[0]
|
||||
fs := flag.NewFlagSet("user "+sub, flag.ExitOnError)
|
||||
data := fs.String("data", "./data", "数据目录")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档;已弃用,优先 AGENT_SYSTEM_DSN 或 --dsn-file)")
|
||||
dsnFile := fs.String("dsn-file", "", "0600 权限的 DSN 文件")
|
||||
owner := fs.String("owner", "", "用户名(owner)")
|
||||
userID := fs.String("user-id", "", "待初始化账号稳定 ID")
|
||||
expectedStateVersion := fs.Int64("expected-state-version", 0, "待初始化账号当前 stateVersion")
|
||||
expectedGeneration := fs.Int64("expected-generation", -1, "待初始化账号当前 generation")
|
||||
userID := fs.String("user-id", "", "待初始化账号稳定ID")
|
||||
expectedStateVersion := fs.Int64("expected-state-version", 0, "待初始化账号状态版本")
|
||||
expectedGeneration := fs.Int64("expected-generation", -1, "待初始化账号代数")
|
||||
name := fs.String("name", "", "显示名")
|
||||
password := fs.String("password", "", "旧建号密码参数(现已拒绝)")
|
||||
password := fs.String("password", "", "密码(已弃用;请用 --password-stdin)")
|
||||
passwordStdin := fs.Bool("password-stdin", false, "从 stdin 读取密码")
|
||||
outFile := fs.String("out-file", "", "把明文凭据独占写入该文件(0600)")
|
||||
instanceID := fs.String("instance-id", "", "严格核验库内服务身份")
|
||||
admin := fs.Bool("admin", false, "管理员")
|
||||
role := fs.String("role", "user", "角色 user|admin")
|
||||
openExisting := fs.Bool("open-existing", false, "只打开既有存储")
|
||||
openExisting := fs.Bool("open-existing", false, "兼容旧参数;账户命令始终只打开既有存储")
|
||||
generatePassword := fs.Bool("generate-password", false, "旧随机初始密码参数(现已拒绝)")
|
||||
fs.String("purpose", "recover", "旧 issue-login-key 参数(现已拒绝)")
|
||||
fs.Int("ttl-minutes", 30, "旧 issue-login-key 参数(现已拒绝)")
|
||||
fs.Bool("revoke-all-credentials", false, "旧 issue-login-key 参数(现已拒绝)")
|
||||
keyID := fs.String("id", "", "revoke-login-key 凭据编号")
|
||||
out := fs.String("out", "", "凭据保护交付文件(0600 JSON,必填)")
|
||||
out := fs.String("out", "", "凭据保护交付文件(0600 JSON)")
|
||||
fs.Parse(args[1:])
|
||||
switch sub {
|
||||
case "create":
|
||||
if *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "user create: --out 必填(保护交付文件)")
|
||||
if sub == "reset-password" || sub == "issue-login-key" {
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery")
|
||||
return 2 // reject before DSN/password reads, storage opens or output reservation
|
||||
}
|
||||
if sub == "create" && (*password != "" || *passwordStdin || *generatePassword) {
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only")
|
||||
return 2
|
||||
}
|
||||
resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user:", err)
|
||||
return 1
|
||||
}
|
||||
resolvedPassword, err := resolveAccountPassword(*password, *passwordStdin)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user:", err)
|
||||
return 1
|
||||
}
|
||||
if *instanceID != "" {
|
||||
_ = os.Setenv("AGENT_SYSTEM_INSTANCE_ID", *instanceID)
|
||||
}
|
||||
*dsn = resolvedDSN
|
||||
*password = resolvedPassword
|
||||
credentialAction := sub == "create" || sub == "initialize-account" || sub == "bootstrap-admin"
|
||||
if credentialAction && *outFile == "" && *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "user: --out or --out-file is required for protected credential delivery")
|
||||
return 2
|
||||
}
|
||||
if !credentialAction && (*outFile != "" || *out != "") {
|
||||
fmt.Fprintln(os.Stderr, "user: 此子命令不生成凭据")
|
||||
return 2
|
||||
}
|
||||
var delivery *credentialOutput
|
||||
if credentialAction {
|
||||
delivery, err = reserveCredentialOutput(*outFile, *out)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user:", err)
|
||||
return 2
|
||||
}
|
||||
defer delivery.abort()
|
||||
}
|
||||
switch sub {
|
||||
case "create":
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "user create: --owner 必填")
|
||||
return 2
|
||||
@@ -312,19 +450,20 @@ func runUser(args []string) int {
|
||||
fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role)
|
||||
return 2
|
||||
}
|
||||
pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
|
||||
pw, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user create:", err)
|
||||
return 1
|
||||
}
|
||||
if err := deliverCredential(*out, "login-key", *owner, pw, nil); err != nil {
|
||||
if err := delivery.deliver("login-key", *owner, pw, nil); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user create:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID)
|
||||
return 0
|
||||
case "initialize-account":
|
||||
if *userID == "" || *expectedStateVersion < 1 || *expectedGeneration < 0 || *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "user initialize-account: --user-id、--expected-state-version、--expected-generation、--out 必填")
|
||||
if *userID == "" || *expectedStateVersion < 1 || *expectedGeneration < 0 {
|
||||
fmt.Fprintln(os.Stderr, "user initialize-account: --user-id、--expected-state-version、--expected-generation 必填")
|
||||
return 2
|
||||
}
|
||||
plain, targetOwner, err := app.InitializeAccount(*dsn, *data, *userID, *expectedStateVersion, *expectedGeneration)
|
||||
@@ -332,13 +471,13 @@ func runUser(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, "user initialize-account:", err)
|
||||
return 1
|
||||
}
|
||||
if err := deliverCredential(*out, "activation", targetOwner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
|
||||
if err := delivery.deliver("activation", targetOwner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user initialize-account:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "reset-password":
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: administrators cannot set another user's password")
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED")
|
||||
return 2
|
||||
case "list":
|
||||
users, err := app.ListUserAccounts(*dsn, *data, *openExisting)
|
||||
@@ -350,7 +489,7 @@ func runUser(args []string) int {
|
||||
fmt.Println(string(out))
|
||||
return 0
|
||||
case "issue-login-key":
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery")
|
||||
fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED")
|
||||
return 2
|
||||
case "revoke-login-key":
|
||||
if *keyID == "" {
|
||||
@@ -364,10 +503,6 @@ func runUser(args []string) int {
|
||||
fmt.Println("revoked")
|
||||
return 0
|
||||
case "bootstrap-admin":
|
||||
if *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin: --out 必填(保护交付文件)")
|
||||
return 2
|
||||
}
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin: --owner 必填")
|
||||
return 2
|
||||
@@ -377,7 +512,7 @@ func runUser(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
|
||||
return 1
|
||||
}
|
||||
if err := deliverCredential(*out, "bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
|
||||
if err := delivery.deliver("bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestLegacyUserCredentialCommandsStopBeforeOpeningStorage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
out := filepath.Join(data, "delivery.json")
|
||||
if code := runUser([]string{"create", "--data", data, "--owner", "victim", "--password", "attacker-password", "--out", out}); code != 1 {
|
||||
if code := runUser([]string{"create", "--data", data, "--owner", "victim", "--password", "attacker-password", "--out", out}); code != 2 {
|
||||
t.Fatalf("password-bearing local create accepted: %d", code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(data, "agent-system.db")); !os.IsNotExist(err) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+73
-71
@@ -758,6 +758,9 @@ func Run(opt Options) error {
|
||||
}
|
||||
|
||||
func assertLocalAccountOwner(st *store.Store, storageDSN, dataDir string) error {
|
||||
if storageDSN == "" {
|
||||
storageDSN = os.Getenv("AGENT_SYSTEM_DSN")
|
||||
}
|
||||
if storageDSN != "" {
|
||||
var isOwner bool
|
||||
err := st.DB().QueryRow(`SELECT current_user = pg_get_userbyid(c.relowner) FROM pg_class c WHERE c.oid='users'::regclass`).Scan(&isOwner)
|
||||
@@ -777,34 +780,31 @@ func assertLocalAccountOwner(st *store.Store, storageDSN, dataDir string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUserAccount is the local owner-only atomic pending-account path.
|
||||
// Password-bearing creation is rejected before opening or changing storage.
|
||||
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) {
|
||||
// CreateUserAccount is the local owner-only, audited pending-account path.
|
||||
// Password-bearing creation is rejected before storage is opened.
|
||||
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, string, error) {
|
||||
if password != "" || generatePassword {
|
||||
return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only")
|
||||
return "", "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only")
|
||||
}
|
||||
var out string
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
var out store.AccountOutcome
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
_, plain, err := st.CreateLocalPendingAccount(owner, name, isAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = plain
|
||||
return nil
|
||||
_, next, err := st.CreateLocalPendingAccountWithAudit(owner, name, isAdmin, accountInstanceID())
|
||||
out = next
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
return out.Plain, out.OperationID, err
|
||||
}
|
||||
|
||||
// IssueUserLoginKey CLI 签发激活/恢复 Key。
|
||||
// IssueUserLoginKey is retained only as a rejecting compatibility entry.
|
||||
func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) {
|
||||
return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery")
|
||||
}
|
||||
|
||||
// InitializeAccount resumes a specific pending account. The database/file owner
|
||||
// and the local OS UID are obtained from the live environment, not CLI flags.
|
||||
// InitializeAccount resumes a specific pending account. The operator identity
|
||||
// comes from the local OS UID; the target is fixed by stable user ID and CAS.
|
||||
func InitializeAccount(storageDSN, dataDir, userID string, expectedStateVersion, expectedGeneration int64) (string, string, error) {
|
||||
var plain, owner string
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
@@ -815,8 +815,8 @@ func InitializeAccount(storageDSN, dataDir, userID string, expectedStateVersion,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plain, err = st.InitializePendingAccount(userID, expectedStateVersion, expectedGeneration)
|
||||
owner = account.Owner
|
||||
out, err := st.InitializePendingAccountWithAudit(userID, expectedStateVersion, expectedGeneration, accountInstanceID())
|
||||
plain, owner = out.Plain, account.Owner
|
||||
return err
|
||||
})
|
||||
return plain, owner, err
|
||||
@@ -837,8 +837,10 @@ func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (strin
|
||||
if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := st.BootstrapFirstAdmin(owner, owner, "cli-bootstrap")
|
||||
plain = p
|
||||
outc, err := st.BootstrapFirstAdminWrite(store.AccountWrite{
|
||||
Owner: owner, Name: owner, Actor: cliActor(), InstanceID: accountInstanceID(),
|
||||
})
|
||||
plain = outc.Plain
|
||||
return err
|
||||
})
|
||||
return plain, err
|
||||
@@ -875,83 +877,75 @@ func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error)
|
||||
return fn(st)
|
||||
}
|
||||
|
||||
func withStoreMode(storageDSN, dataDir string, openExisting bool, fn func(*store.Store) error) error {
|
||||
func withStoreMode(storageDSN, dataDir string, _ bool, fn func(*store.Store) error) error {
|
||||
var st *store.Store
|
||||
var err error
|
||||
// DSN 等价安全输入(KTD14):argv 缺省时回落环境变量——凭据不落 shell history。
|
||||
if storageDSN == "" {
|
||||
storageDSN = os.Getenv("AGENT_SYSTEM_DSN")
|
||||
}
|
||||
// Account maintenance never initializes or migrates storage; the retained
|
||||
// CLI compatibility flag cannot opt this operation into service initialization.
|
||||
if storageDSN != "" {
|
||||
if openExisting {
|
||||
st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
}
|
||||
st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
dbPath := filepath.Join(dataDir, "agent-system.db")
|
||||
if openExisting {
|
||||
st, err = store.OpenExistingSQLite(dbPath)
|
||||
} else {
|
||||
st, err = store.OpenSQLite(dbPath)
|
||||
}
|
||||
st, err = store.OpenExistingSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
if id := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); id != "" {
|
||||
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
|
||||
if dbref == "" {
|
||||
if storageDSN != "" {
|
||||
dbref = "pg"
|
||||
} else {
|
||||
dbref = "sqlite"
|
||||
}
|
||||
}
|
||||
if err := st.VerifySystemIdentity(id, dbref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fn(st)
|
||||
}
|
||||
|
||||
// CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。
|
||||
func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer st.Close()
|
||||
return st.CreateAPIKey(owner, name)
|
||||
var plain string
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
p, e := st.CreateAPIKey(owner, name)
|
||||
plain = p
|
||||
return e
|
||||
})
|
||||
return plain, err
|
||||
}
|
||||
|
||||
// ListAPIKeys 列出租户密钥(摘要视图)。
|
||||
func ListAPIKeys(storageDSN, dataDir, owner string) ([]store.APIKeyInfo, error) {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer st.Close()
|
||||
if _, err := st.GetUser(owner); err != nil {
|
||||
return nil, fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner)
|
||||
}
|
||||
return st.ListAPIKeys(owner)
|
||||
var out []store.APIKeyInfo
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
if _, err := st.GetUser(owner); err != nil {
|
||||
return fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner)
|
||||
}
|
||||
var e error
|
||||
out, e = st.ListAPIKeys(owner)
|
||||
return e
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// RevokeAPIKey 撤销指定名称密钥。
|
||||
func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer st.Close()
|
||||
return st.RevokeAPIKeyByName(owner, name)
|
||||
var ok bool
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
var e error
|
||||
ok, e = st.RevokeAPIKeyByName(owner, name)
|
||||
return e
|
||||
})
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。
|
||||
@@ -980,6 +974,14 @@ func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) (int, e
|
||||
return schemaVersion, err
|
||||
}
|
||||
|
||||
func cliActor() string {
|
||||
return fmt.Sprintf("%d", os.Getuid())
|
||||
}
|
||||
|
||||
func accountInstanceID() string {
|
||||
return strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID"))
|
||||
}
|
||||
|
||||
// cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。
|
||||
func cachedInt(ttl time.Duration, fn func() int) func() int {
|
||||
var mu sync.Mutex
|
||||
|
||||
@@ -9,11 +9,19 @@ import (
|
||||
|
||||
func TestLocalOwnerAccountCreationAndReissuePreserveIdentity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
seed, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seed.EnsureSystemIdentity("test-local-owner", "sqlite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed.Close()
|
||||
bootstrap, err := BootstrapAdmin("", dir, "root", false)
|
||||
if err != nil || bootstrap == "" {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
first, err := CreateUserAccount("", dir, "alice", "", "", false, true, false)
|
||||
first, _, err := CreateUserAccount("", dir, "alice", "", "", false, true, false)
|
||||
if err != nil || first == "" {
|
||||
t.Fatalf("local pending create: %v", err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -123,6 +123,9 @@ func (s *Store) lockAccountUsers(tx *sql.Tx, owners ...string) (map[string]Accou
|
||||
if _, exists := accounts[owner]; exists {
|
||||
continue
|
||||
}
|
||||
if err := s.dialect.lockAccount(tx, owner); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := scanAccount(tx.QueryRow(query, owner))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -246,6 +249,9 @@ func (s *Store) PatchAccountProfile(actorOwner, userID string, expectedProfileVe
|
||||
if err := s.insertAccountEvent(tx, actorOwner, "account-profile", target.Owner, "profile_updated", fmt.Sprintf("version=%d fields=%s", expectedProfileVersion+1, strings.Join(fields, ","))); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if err := s.auditAccountMutation(tx, target.Owner, actorOwner, "account.profile"); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
@@ -279,6 +285,9 @@ func (s *Store) CreatePendingAccount(actorOwner, owner string, profile AccountPr
|
||||
if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "account_created_pending", a.ID); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if err := s.auditAccountMutation(tx, owner, actorOwner, "account.create_pending"); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func TestAccountModelLegacyMigrationDefaults(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
t.Logf("account model dialect=%s", st.Dialect())
|
||||
if err := st.MigrateDown(30); err != nil {
|
||||
if err := st.MigrateDown(31); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("legacy", "原姓名", "existing-hash", true); err != nil {
|
||||
@@ -23,8 +23,8 @@ func TestAccountModelLegacyMigrationDefaults(t *testing.T) {
|
||||
if err := st.CreateAccount("legacy-empty", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// This fixture intentionally writes a v30 row before v31 migration. The
|
||||
// current session API enforces the v31 account state and cannot be used here.
|
||||
// This fixture intentionally writes a v31 row before v32 migration. The
|
||||
// current session API enforces the v32 account state and cannot be used here.
|
||||
token := "sess_legacy_migration_fixture"
|
||||
_, err := st.db.Exec(st.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version)
|
||||
VALUES(?,?,?,?,?,?,?)`), hashKey(token), "legacy", nowISO(), time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeFull, 0)
|
||||
@@ -54,7 +54,7 @@ func TestAccountModelLegacyMigrationDefaults(t *testing.T) {
|
||||
if !enabled || profileVersion != 1 || stateVersion != 1 || origin != "legacy_unknown" || initialization != "legacy" || generation != 0 {
|
||||
t.Fatalf("unsafe migrated defaults: enabled=%v profile=%d state=%d origin=%q initialization=%q generation=%d", enabled, profileVersion, stateVersion, origin, initialization, generation)
|
||||
}
|
||||
if SchemaCompatFloor() < 31 {
|
||||
if SchemaCompatFloor() < 32 {
|
||||
t.Fatal("old authentication writers must be excluded by the compatibility floor")
|
||||
}
|
||||
empty, err := st.GetAccountByOwner("legacy-empty")
|
||||
|
||||
@@ -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.EnsureSystemIdentity("test-grant-audit", "sqlite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := s.BootstrapFirstAdminWrite(AccountWrite{Owner: "ada", Name: "Ada", Actor: "test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _, err := s.RedeemLoginKey(out.Plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := HashPassword("new-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetAccountFault(ErrConflict)
|
||||
if _, err := s.CompleteSetPasswordChecked(token, h); err == nil {
|
||||
t.Fatal("checked password path bypassed the shared audited commit")
|
||||
}
|
||||
if a, err := s.GetUserAuthState("ada"); err != nil || a.HasPassword {
|
||||
t.Fatal("failed checked change left a password behind")
|
||||
}
|
||||
if _, _, ok := s.SessionInfo(token); !ok {
|
||||
t.Fatal("failed checked change consumed its session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeAllRefusesMissingOwnerWithoutCredential(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.RevokeAllAndIssue(AccountWrite{Owner: "missing", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin", Actor: "admin"})
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("missing owner must be rejected before issuing any key: %v", err)
|
||||
}
|
||||
var n int
|
||||
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM login_keys WHERE owner=?`), "missing").Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("orphan credential rows=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivePasswordChangeRevokesUnusedRecoveryKey(t *testing.T) {
|
||||
s := activeAccount(t)
|
||||
key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := s.CreateSession("ada")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := HashPassword("replacement-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ChangeUserPasswordActive("ada", "old-password", hash, session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.RedeemLoginKey(key); err == nil {
|
||||
t.Fatal("unused recovery key issued before active password change still redeems")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedActiveChangeIsAudited(t *testing.T) {
|
||||
s := activeAccount(t)
|
||||
h, err := HashPassword("next-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = s.ChangeUserPasswordActive("ada", "incorrect-current-password", h, "")
|
||||
if !errors.Is(err, ErrBadCredentials) {
|
||||
t.Fatalf("expected bad credentials, got %v", err)
|
||||
}
|
||||
count, err := s.CountAudit("ada", "rejected")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("rejected checked account mutation has %d rejected audit rows, want1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelayedOldVersionSessionIsRejectedAtUse(t *testing.T) {
|
||||
s := activeAccount(t)
|
||||
_, ver, ok := s.VerifyLoginWithVersion("ada", "old-password")
|
||||
if !ok {
|
||||
t.Fatal("healthy verification missing")
|
||||
}
|
||||
h, err := HashPassword("new-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Represents an insert racing after the replacement's earlier DELETE.
|
||||
token := "sess_private_stale_fixture"
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`),
|
||||
hashKey(token), "ada", nowISO(), time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeFull, ver)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, ok := s.SessionInfo(token); ok {
|
||||
t.Fatal("session carrying the retired auth_version became authorized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivePasswordChangePreservesCurrentVersionedSession(t *testing.T) {
|
||||
s := activeAccount(t)
|
||||
token, err := s.CreateSession("ada")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := HashPassword("new-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ChangeUserPasswordActive("ada", "old-password", h, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owner, scope, ok := s.SessionInfo(token); !ok || owner != "ada" || scope != SessionScopeFull {
|
||||
t.Fatal("active change did not retain its current full session")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The trigger holds a transaction after its authorization read. A second
|
||||
// connection must wait for that owner's lock before replacing credentials.
|
||||
func holdPGAccountWrite(t *testing.T, s *Store, table, event, suffix string) func() {
|
||||
t.Helper()
|
||||
lock := "u7_account_" + suffix
|
||||
_, err := s.db.Exec(`CREATE FUNCTION u7_pause_account() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_advisory_xact_lock(hashtext(current_schema() || ':` + lock + `')); RETURN NEW; END $$`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.db.Exec(`CREATE TRIGGER u7_pause_account BEFORE ` + event + ` ON ` + table + ` FOR EACH ROW EXECUTE FUNCTION u7_pause_account()`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, err := s.db.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.ExecContext(context.Background(), `SELECT pg_advisory_lock(hashtext(current_schema() || ':`+lock+`'))`); err != nil {
|
||||
conn.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
released := false
|
||||
release := func() {
|
||||
if !released {
|
||||
released = true
|
||||
_, _ = conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock(hashtext(current_schema() || ':`+lock+`'))`)
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
t.Cleanup(release)
|
||||
return release
|
||||
}
|
||||
|
||||
func waitForPGAccountWrite(t *testing.T, s *Store, suffix string) {
|
||||
t.Helper()
|
||||
lock := "u7_account_" + suffix
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND NOT granted
|
||||
AND objid=(hashtext(current_schema() || ':` + lock + `')::bigint & 4294967295)::oid`).Scan(&n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n > 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("account write did not reach the deterministic PostgreSQL pause")
|
||||
}
|
||||
|
||||
func awaitPGAccountResult[T any](t *testing.T, done <-chan T) T {
|
||||
t.Helper()
|
||||
select {
|
||||
case result := <-done:
|
||||
return result
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("serialized account transaction did not finish")
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGVerifiedSessionAndPasswordChangeSerialize(t *testing.T) {
|
||||
s := activeAccount(t)
|
||||
if s.Dialect() != "postgres" {
|
||||
t.Skip("real PostgreSQL required")
|
||||
}
|
||||
_, version, ok := s.VerifyLoginWithVersion("ada", "old-password")
|
||||
if !ok {
|
||||
t.Fatal("initial login failed")
|
||||
}
|
||||
release := holdPGAccountWrite(t, s, "sessions", "INSERT", "session")
|
||||
type sessionResult struct {
|
||||
token string
|
||||
err error
|
||||
}
|
||||
sessionDone := make(chan sessionResult, 1)
|
||||
go func() {
|
||||
token, err := s.CreateSessionVerified("ada", SessionScopeFull, time.Hour, version)
|
||||
sessionDone <- sessionResult{token, err}
|
||||
}()
|
||||
waitForPGAccountWrite(t, s, "session")
|
||||
hash, err := HashPassword("replacement-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changeDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: hash, Actor: "admin"})
|
||||
changeDone <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-changeDone:
|
||||
t.Fatalf("replacement committed ahead of the in-flight verified session: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
session := awaitPGAccountResult(t, sessionDone)
|
||||
if session.err != nil {
|
||||
t.Fatal(session.err)
|
||||
}
|
||||
if err := awaitPGAccountResult(t, changeDone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, ok := s.SessionInfo(session.token); ok {
|
||||
t.Fatal("old verified session survived later password replacement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGRestrictedPasswordSetAndKeyReissueSerialize(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if s.Dialect() != "postgres" {
|
||||
t.Skip("real PostgreSQL required")
|
||||
}
|
||||
if err := s.EnsureSystemIdentity("test-pg-grant-serialize", "pg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := s.BootstrapFirstAdminWrite(AccountWrite{Owner: "ada", Name: "Ada", Actor: "test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := s.GetAccountByOwner("ada")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _, err := s.RedeemLoginKey(out.Plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := HashPassword("replacement-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release := holdPGAccountWrite(t, s, "users", "UPDATE", "password")
|
||||
setDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.CompleteSetPasswordChecked(token, hash)
|
||||
setDone <- err
|
||||
}()
|
||||
waitForPGAccountWrite(t, s, "password")
|
||||
issueDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration)
|
||||
issueDone <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-issueDone:
|
||||
t.Fatalf("key replacement committed ahead of the in-flight password set: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
if err := awaitPGAccountResult(t, setDone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := awaitPGAccountResult(t, issueDone); err != ErrConflict && err != ErrAccountForbidden {
|
||||
t.Fatalf("completed initialization was reissued: %v", err)
|
||||
}
|
||||
if _, _, ok := s.SessionInfo(token); ok {
|
||||
t.Fatal("old restricted session survived password set and key reissue")
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ func (s *Store) SetAccountEnabled(actorOwner, userID string, expectedStateVersio
|
||||
if err := s.insertAccountEvent(tx, actorOwner, "account-state", target.Owner, action, ""); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if err := s.auditAccountMutation(tx, target.Owner, actorOwner, "account.state"); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
|
||||
@@ -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,411 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AccountWrite 是一次账户变更的调用参数。成功审计与变更同事务。
|
||||
type AccountWrite struct {
|
||||
Owner string
|
||||
Name string
|
||||
PasswordHash string
|
||||
IsAdmin bool
|
||||
MustChange bool
|
||||
ExceptToken string
|
||||
Purpose string
|
||||
CreatedBy string
|
||||
TTL time.Duration
|
||||
RevokeAllKeys bool
|
||||
Actor string
|
||||
InstanceID string
|
||||
Action string
|
||||
}
|
||||
|
||||
// AccountOutcome 是成功提交后可以对外展示的标识。不含密码、哈希或明文。
|
||||
type AccountOutcome struct {
|
||||
Plain string
|
||||
CredentialID string
|
||||
OperationID string
|
||||
}
|
||||
|
||||
// CreateActivatedAccount 严格插入账户。passwordHash 为空且 Purpose 非空时,同一事务签发登录 Key。
|
||||
func (s *Store) CreateActivatedAccount(w AccountWrite) (AccountOutcome, error) {
|
||||
if w.Action == "" {
|
||||
w.Action = "account.create"
|
||||
}
|
||||
var out AccountOutcome
|
||||
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
|
||||
if s.dialect.name() == "postgres" {
|
||||
// A first ordinary account and first-admin bootstrap use the same
|
||||
// admission lock. If bootstrap commits while this call waits, the
|
||||
// earlier empty-database observation cannot authorize a second
|
||||
// competing first account.
|
||||
var before, after int
|
||||
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&before); err != nil {
|
||||
return err
|
||||
}
|
||||
if before == 0 {
|
||||
if err := s.dialect.lockBootstrap(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&after); err != nil {
|
||||
return err
|
||||
}
|
||||
if after != 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := insertAccount(s, tx, w); err != nil {
|
||||
return err
|
||||
}
|
||||
if w.PasswordHash == "" && w.Purpose != "" {
|
||||
plain, id, err := insertLoginKey(s, tx, w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Plain, out.CredentialID = plain, id
|
||||
}
|
||||
out.OperationID = opID
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ApplyPasswordChange 在同一事务里写哈希、更新强制改密标志、作废未使用登录 Key,并处理会话。
|
||||
func (s *Store) ApplyPasswordChange(w AccountWrite) (AccountOutcome, error) {
|
||||
if w.Action == "" {
|
||||
w.Action = "account.password"
|
||||
}
|
||||
var out AccountOutcome
|
||||
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
|
||||
w.PasswordHash, nowISO(), w.MustChange, w.Owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
|
||||
LoginKeyStatusRevoked, w.Owner, LoginKeyStatusUnused); err != nil {
|
||||
return err
|
||||
}
|
||||
if w.ExceptToken != "" {
|
||||
_, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), w.Owner, hashKey(w.ExceptToken))
|
||||
} else {
|
||||
_, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if w.ExceptToken != "" {
|
||||
if _, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=(SELECT auth_version FROM users WHERE owner=?) WHERE owner=? AND token_hash=?`),
|
||||
w.Owner, w.Owner, hashKey(w.ExceptToken)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
out.OperationID = opID
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// BootstrapFirstAdmin 在立即事务(SQLite)或身份行锁(PostgreSQL)内复核空库后建首位管理员。
|
||||
func (s *Store) BootstrapFirstAdminWrite(w AccountWrite) (AccountOutcome, error) {
|
||||
w.IsAdmin = true
|
||||
w.PasswordHash = ""
|
||||
w.Purpose = LoginKeyPurposeActivate
|
||||
if w.CreatedBy == "" {
|
||||
w.CreatedBy = localOwnerActor()
|
||||
}
|
||||
if w.Action == "" {
|
||||
w.Action = "account.bootstrap"
|
||||
}
|
||||
var out AccountOutcome
|
||||
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
|
||||
if err := s.dialect.lockBootstrap(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := lockIdentity(s, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireEmptyBusiness(s, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
account, err := s.insertPendingAccountTx(tx, w.Owner, AccountProfile{Name: w.Name})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), true, 1, account.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
var users int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil {
|
||||
return err
|
||||
}
|
||||
if users != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, w.CreatedBy, entranceOf(w.CreatedBy), w.Owner, "bootstrap_admin", w.Owner); err != nil {
|
||||
return err
|
||||
}
|
||||
plain, err := newActivationPlain()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := newID("lk")
|
||||
now := time.Now().UTC()
|
||||
if _, err := s.insertLoginKeyTx(tx, w.Owner, LoginKeyPurposeActivate, w.CreatedBy, plain, id, now, now.Add(defaultLoginKeyTTL),
|
||||
grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.stampLoginKeyInstance(tx, id, w.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Plain, out.CredentialID, out.OperationID = plain, id, opID
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// RevokeAllAndIssue 把吊销长期密钥、删除会话、作废未使用登录 Key 和签发新 Key 放进一次提交。
|
||||
func (s *Store) RevokeAllAndIssue(w AccountWrite) (AccountOutcome, error) {
|
||||
if w.Action == "" {
|
||||
w.Action = "account.revoke_credentials"
|
||||
}
|
||||
var out AccountOutcome
|
||||
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET auth_version=auth_version+1 WHERE owner=?`), w.Owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND revoked=0`), w.Owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
|
||||
LoginKeyStatusRevoked, w.Owner, LoginKeyStatusUnused); err != nil {
|
||||
return err
|
||||
}
|
||||
plain, id, err := insertLoginKey(s, tx, w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Plain, out.CredentialID, out.OperationID = plain, id, opID
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Store) accountTx(w AccountWrite, fn func(*sql.Tx, string) error) error {
|
||||
opID := newID("op")
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
if w.Action == "account.bootstrap" || w.Action == "account.create.local_pending" {
|
||||
if err := s.dialect.lockBootstrap(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if w.Owner != "" {
|
||||
if err := s.dialect.lockAccount(tx, w.Owner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if w.InstanceID == "" {
|
||||
if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&w.InstanceID); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := fn(tx, opID); err != nil {
|
||||
_ = tx.Rollback()
|
||||
committed = true
|
||||
s.recordRejected(w, opID)
|
||||
return err
|
||||
}
|
||||
if s.accountFault != nil {
|
||||
err = s.accountFault
|
||||
_ = tx.Rollback()
|
||||
committed = true
|
||||
s.recordRejected(w, opID)
|
||||
return err
|
||||
}
|
||||
if err := insertAudit(s, tx, w, opID, "success"); err != nil {
|
||||
_ = tx.Rollback()
|
||||
committed = true
|
||||
s.recordRejected(w, opID)
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
// Commit may have reached the database even if the client lost its
|
||||
// response. Recording a rejection here would assert an unknown outcome.
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) recordRejected(w AccountWrite, opID string) {
|
||||
_, _ = s.db.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`),
|
||||
nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, "rejected")
|
||||
}
|
||||
|
||||
func insertAudit(s *Store, tx *sql.Tx, w AccountWrite, opID, outcome string) error {
|
||||
_, err := tx.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`),
|
||||
nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, outcome)
|
||||
return err
|
||||
}
|
||||
|
||||
// auditAccountMutation records U3 account operations that have their own
|
||||
// multi-account transaction and therefore cannot enter accountTx's single
|
||||
// target callback. It must be called before the caller commits.
|
||||
func (s *Store) auditAccountMutation(tx *sql.Tx, owner, actor, action string) error {
|
||||
var instanceID string
|
||||
if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&instanceID); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
return insertAudit(s, tx, AccountWrite{Owner: owner, Actor: actor, Action: action, InstanceID: instanceID}, newID("op"), "success")
|
||||
}
|
||||
|
||||
func (s *Store) stampLoginKeyInstance(tx *sql.Tx, credentialID, instanceID string) error {
|
||||
if instanceID == "" {
|
||||
if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&instanceID); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := tx.Exec(s.q(`UPDATE login_keys SET instance_id=? WHERE id=?`), instanceID, credentialID)
|
||||
return err
|
||||
}
|
||||
|
||||
func insertAccount(s *Store, tx *sql.Tx, w AccountWrite) error {
|
||||
mustChange := w.PasswordHash != "" || w.MustChange
|
||||
setAt := ""
|
||||
if w.PasswordHash != "" {
|
||||
setAt = nowISO()
|
||||
}
|
||||
_, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin) VALUES(?,?,?,?,?,?,?,?)`),
|
||||
newID("usr"), w.Owner, w.Name, nowISO(), w.PasswordHash, setAt, mustChange, w.IsAdmin)
|
||||
if isUniqueViolation(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func insertLoginKey(s *Store, tx *sql.Tx, w AccountWrite) (string, string, error) {
|
||||
purpose := w.Purpose
|
||||
if purpose != LoginKeyPurposeActivate && purpose != LoginKeyPurposeRecover {
|
||||
return "", "", fmt.Errorf("login key: invalid purpose %q", purpose)
|
||||
}
|
||||
ttl := w.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = defaultLoginKeyTTL
|
||||
}
|
||||
if ttl < minLoginKeyTTL {
|
||||
ttl = minLoginKeyTTL
|
||||
}
|
||||
if ttl > maxLoginKeyTTL {
|
||||
ttl = maxLoginKeyTTL
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
idRaw := make([]byte, 8)
|
||||
if _, err := rand.Read(idRaw); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
plain := "alk_" + hex.EncodeToString(raw)
|
||||
id := "lk_" + hex.EncodeToString(idRaw)
|
||||
now := time.Now().UTC()
|
||||
if _, err := s.insertLoginKeyTx(tx, w.Owner, purpose, w.CreatedBy, plain, id, now, now.Add(ttl)); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
_, err := tx.Exec(s.q(`UPDATE login_keys SET instance_id=? WHERE id=?`), w.InstanceID, id)
|
||||
return plain, id, err
|
||||
}
|
||||
|
||||
func lockIdentity(s *Store, tx *sql.Tx) error {
|
||||
q := `SELECT instance_id FROM agent_system_identity`
|
||||
if s.dialect.name() == "postgres" {
|
||||
q += ` FOR UPDATE`
|
||||
}
|
||||
rows, err := tx.Query(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
n := 0
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
n++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%w: agent_system_identity 无行", ErrSystemIdentityMissing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireEmptyBusiness(s *Store, tx *sql.Tx) error {
|
||||
for _, table := range []string{"users", "projects", "conversations", "messages", "runs", "api_keys", "sessions", "login_keys"} {
|
||||
var n int
|
||||
if err := tx.QueryRow(`SELECT COUNT(1) FROM ` + table).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) requireCurrentSchema() error {
|
||||
applied, err := s.MigrationStatus()
|
||||
if err != nil || len(applied) == 0 {
|
||||
return fmt.Errorf("schema 版本不可读;请运行 agent-system migrate,或先启动一次服务")
|
||||
}
|
||||
latest := migrations[len(migrations)-1].version
|
||||
got := applied[len(applied)-1]
|
||||
if got != latest {
|
||||
return fmt.Errorf("schema v%d 与本二进制 v%d 不一致;请运行 agent-system migrate,或先启动一次服务", got, latest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAccountFault 让下一次账户事务在提交前失败,用于半态测试。
|
||||
func (s *Store) SetAccountFault(err error) {
|
||||
s.accountFault = err
|
||||
}
|
||||
|
||||
// CountAudit 返回指定 outcome 的账户审计条数。
|
||||
func (s *Store) CountAudit(owner, outcome string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM audit_events WHERE owner=? AND entry=? AND outcome=?`), owner, "account", outcome).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAccountStateRollsBackWhenTransactionalAuditFails(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if s.Dialect() != "sqlite" {
|
||||
t.Skip("SQLite fault-injection trigger; PostgreSQL account tests run separately")
|
||||
}
|
||||
hash, err := HashPassword("owner-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CreateAccount("root", "Root", hash, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CreateAccount("ada", "Ada", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := s.GetAccountByOwner("ada")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.DB().Exec(`CREATE TRIGGER fail_account_state_audit BEFORE INSERT ON audit_events
|
||||
WHEN NEW.action='account.state' BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.SetAccountEnabled("root", target.ID, target.StateVersion, false); err == nil {
|
||||
t.Fatal("state change committed without its audit event")
|
||||
}
|
||||
current, err := s.GetAccount(target.ID)
|
||||
if err != nil || !current.Enabled || current.StateVersion != target.StateVersion {
|
||||
t.Fatalf("audit failure left a partial state change: %+v %v", current, err)
|
||||
}
|
||||
}
|
||||
+39
-30
@@ -236,19 +236,10 @@ func VerifyPassword(hash, password string) bool {
|
||||
// (原子化:旧实现先查后插再 UPDATE,两个并发 create 同名会在 UPDATE 步互相覆盖
|
||||
// 角色与密码——R5/KTD12 并发建号不覆盖。)
|
||||
func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) error {
|
||||
mustChange := passwordHash != ""
|
||||
setAt := ""
|
||||
if passwordHash != "" {
|
||||
setAt = nowISO()
|
||||
}
|
||||
// 布尔列一律绑 Go bool 参数、不写 1/0 字面量:SQLite 两侧都容忍,
|
||||
// PostgreSQL 对 boolean 列收到 integer 会 42804(2026-10-05 user create 半失败根因)。
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin)
|
||||
VALUES(?,?,?,?,?,?,?,?)`),
|
||||
newID("usr"), owner, name, nowISO(), passwordHash, setAt, mustChange, isAdmin)
|
||||
if isUniqueViolation(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
_, err := s.CreateActivatedAccount(AccountWrite{
|
||||
Owner: owner, Name: name, PasswordHash: passwordHash, IsAdmin: isAdmin,
|
||||
Action: "account.create",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -332,31 +323,40 @@ func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (str
|
||||
if ttl <= 0 {
|
||||
ttl = sessionTTL
|
||||
}
|
||||
state, err := s.GetUserAuthState(owner)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !state.Enabled {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := "sess_" + hex.EncodeToString(raw)
|
||||
exp := time.Now().Add(ttl)
|
||||
result, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version)
|
||||
SELECT ?,owner,?,?,?,?,auth_version FROM users WHERE owner=? AND enabled=? AND auth_version=?`),
|
||||
hashKey(token), nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, owner, true, state.AuthVersion)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, err := result.RowsAffected(); err != nil {
|
||||
defer tx.Rollback()
|
||||
if err := s.dialect.lockAccount(tx, owner); err != nil {
|
||||
return "", err
|
||||
} else if n != 1 {
|
||||
return "", ErrConflict
|
||||
}
|
||||
return token, nil
|
||||
var ver int
|
||||
var enabled bool
|
||||
query := s.q(`SELECT auth_version,enabled FROM users WHERE owner=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
if err := tx.QueryRow(query, owner).Scan(&ver, &enabled); errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !enabled {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
_, err = tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`),
|
||||
hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, tx.Commit()
|
||||
}
|
||||
|
||||
// CreateSessionVerified 建会话并绑定期望认证版本:事务内重读账户版本,与密码验证
|
||||
@@ -379,6 +379,9 @@ func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, ex
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.dialect.lockAccount(tx, owner); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var ver int
|
||||
var enabled bool
|
||||
var origin CredentialOrigin
|
||||
@@ -431,12 +434,18 @@ func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) {
|
||||
return "", "", false
|
||||
}
|
||||
var exp string
|
||||
err := s.db.QueryRow(s.q(`SELECT s.owner,s.expires_at,COALESCE(s.scope,'full') FROM sessions s
|
||||
JOIN users u ON u.owner=s.owner WHERE s.token_hash=? AND u.enabled=? AND s.auth_version=u.auth_version`), hashKey(token), true).
|
||||
Scan(&owner, &exp, &scope)
|
||||
var sessionVersion, accountVersion int
|
||||
var enabled bool
|
||||
err := s.db.QueryRow(s.q(`SELECT s.owner,s.expires_at,COALESCE(s.scope,'full'),s.auth_version,u.auth_version,u.enabled
|
||||
FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)).
|
||||
Scan(&owner, &exp, &scope, &sessionVersion, &accountVersion, &enabled)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
if !enabled || sessionVersion != accountVersion {
|
||||
_, _ = s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=? AND auth_version=?`), hashKey(token), sessionVersion)
|
||||
return "", "", false
|
||||
}
|
||||
if expT, err := time.Parse(time.RFC3339Nano, exp); err != nil || time.Now().After(expT) {
|
||||
s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=?`), hashKey(token))
|
||||
return "", "", false
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+238
-292
@@ -224,61 +224,11 @@ func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, i
|
||||
// 建管理员行 + 激活 Key + 审计共同提交;任何用户已存在返回 ErrConflict(拒绝接管)。
|
||||
// createdBy 保留旧调用签名,但审计主体总从实际本地 OS UID 取得。
|
||||
// SQLite 无咨询锁:插入后复查用户数恰为 1,双引导并发时后提交者回滚。
|
||||
func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string, err error) {
|
||||
createdBy = fmt.Sprintf("local-owner:uid:%d", os.Geteuid())
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain = "alk_" + hex.EncodeToString(raw)
|
||||
idRaw := make([]byte, 8)
|
||||
if _, err := rand.Read(idRaw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := "lk_" + hex.EncodeToString(idRaw)
|
||||
now := time.Now().UTC()
|
||||
exp := now.Add(defaultLoginKeyTTL)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.dialect.lockBootstrap(tx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var n int
|
||||
if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users`)).Scan(&n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n > 0 {
|
||||
return "", ErrConflict // 有数据环境拒绝接管(R11)
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin,initialization_state,initialization_generation)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`),
|
||||
newID("usr"), owner, name, nowISO(), "", "", false, true, InitializationPending, 1); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return "", ErrConflict
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
// SQLite 并发兜底:本事务内用户数必须恰为 1(另一引导已提交则回滚)。
|
||||
if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users`)).Scan(&n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n != 1 {
|
||||
return "", ErrConflict
|
||||
}
|
||||
if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, createdBy, plain, id, now, exp,
|
||||
grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "bootstrap_admin", owner); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return plain, nil
|
||||
func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (string, error) {
|
||||
out, err := s.BootstrapFirstAdminWrite(AccountWrite{
|
||||
Owner: owner, Name: name, Actor: fmt.Sprintf("%d", os.Geteuid()), CreatedBy: fmt.Sprintf("local-owner:uid:%d", os.Geteuid()),
|
||||
})
|
||||
return out.Plain, err
|
||||
}
|
||||
|
||||
// RedeemBlocked 兑换入口跨实例退避:事务内清理窗口外失败记录,判定该来源 IP
|
||||
@@ -354,6 +304,9 @@ func (s *Store) RevokeLoginKeyCascade(id, actor string, expectedOwner ...string)
|
||||
if len(expectedOwner) > 0 && expectedOwner[0] != owner {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err := s.dialect.lockAccount(tx, owner); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`),
|
||||
LoginKeyStatusRevoked, id, LoginKeyStatusUnused); err != nil {
|
||||
return "", err
|
||||
@@ -432,6 +385,9 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er
|
||||
return "", "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.dialect.lockAccount(tx, routeOwner); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
locked, err := s.lockAccountUsers(tx, routeOwner)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -537,102 +493,100 @@ func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner st
|
||||
if err := s.db.QueryRow(s.q(`SELECT proposal_id FROM login_keys WHERE id=? AND owner=?`), routeGrant, owner).Scan(&routeProposal); err != nil {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
accounts, err := s.lockAccountUsers(tx, owner)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
account := accounts[owner]
|
||||
if !account.Enabled {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
var proposalStatus, proposalOwner, proposalExpiry string
|
||||
var proposalGeneration int64
|
||||
var proposalVersion int
|
||||
if routeProposal != "" {
|
||||
query := s.q(`SELECT owner,status,generation,expires_at,issued_auth_version FROM recovery_proposals WHERE id=?`)
|
||||
err = s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.grant_setup"}, func(tx *sql.Tx, _ string) error {
|
||||
accounts, err := s.lockAccountUsers(tx, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
account := accounts[owner]
|
||||
if !account.Enabled {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
var proposalStatus, proposalOwner, proposalExpiry string
|
||||
var proposalGeneration int64
|
||||
var proposalVersion int
|
||||
if routeProposal != "" {
|
||||
query := s.q(`SELECT owner,status,generation,expires_at,issued_auth_version FROM recovery_proposals WHERE id=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
if err := tx.QueryRow(query, routeProposal).Scan(&proposalOwner, &proposalStatus, &proposalGeneration, &proposalExpiry, &proposalVersion); err != nil {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
}
|
||||
grantQuery := s.q(`SELECT owner,purpose,status,expires_at,grant_origin,generation,issued_auth_version,proposal_id FROM login_keys WHERE id=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
grantQuery += ` FOR UPDATE`
|
||||
}
|
||||
if err := tx.QueryRow(query, routeProposal).Scan(&proposalOwner, &proposalStatus, &proposalGeneration, &proposalExpiry, &proposalVersion); err != nil {
|
||||
return "", ErrAccountForbidden
|
||||
var grantOwner, purpose, status, grantExpiry, origin, proposalID string
|
||||
var generation int64
|
||||
var issuedVersion int
|
||||
if err := tx.QueryRow(grantQuery, routeGrant).Scan(&grantOwner, &purpose, &status, &grantExpiry, &origin, &generation, &issuedVersion, &proposalID); err != nil {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
}
|
||||
grantQuery := s.q(`SELECT owner,purpose,status,expires_at,grant_origin,generation,issued_auth_version,proposal_id FROM login_keys WHERE id=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
grantQuery += ` FOR UPDATE`
|
||||
}
|
||||
var grantOwner, purpose, status, grantExpiry, origin, proposalID string
|
||||
var generation int64
|
||||
var issuedVersion int
|
||||
if err := tx.QueryRow(grantQuery, routeGrant).Scan(&grantOwner, &purpose, &status, &grantExpiry, &origin, &generation, &issuedVersion, &proposalID); err != nil {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
sessionQuery := s.q(`SELECT owner,scope,expires_at,auth_version,auth_method,credential_origin,grant_id FROM sessions WHERE token_hash=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
sessionQuery += ` FOR UPDATE`
|
||||
}
|
||||
var sessionOwner, scope, sessionExpiry, method, sessionOrigin, grantID string
|
||||
var sessionVersion int
|
||||
if err := tx.QueryRow(sessionQuery, hashKey(token)).Scan(&sessionOwner, &scope, &sessionExpiry, &sessionVersion, &method, &sessionOrigin, &grantID); err != nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
grantExpires, err := time.Parse(time.RFC3339Nano, grantExpiry)
|
||||
if err != nil || time.Now().After(grantExpires) {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
sessionExpires, err := time.Parse(time.RFC3339Nano, sessionExpiry)
|
||||
if err != nil || time.Now().After(sessionExpires) || sessionOwner != owner || scope != SessionScopeSetPasswordOnly || method != AuthMethodGrant ||
|
||||
grantID != routeGrant || sessionVersion != account.AuthVersion || issuedVersion != account.AuthVersion ||
|
||||
grantOwner != owner || status != LoginKeyStatusConsumed || sessionOrigin != origin || proposalID != routeProposal {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
switch {
|
||||
case purpose == LoginKeyPurposeActivate && origin == string(CredentialOriginOwnerInitialization):
|
||||
if routeProposal != "" || account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword {
|
||||
return "", ErrAccountForbidden
|
||||
sessionQuery := s.q(`SELECT owner,scope,expires_at,auth_version,auth_method,credential_origin,grant_id FROM sessions WHERE token_hash=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
sessionQuery += ` FOR UPDATE`
|
||||
}
|
||||
case purpose == LoginKeyPurposeRecover && origin == string(CredentialOriginOwnerRecovery):
|
||||
expires, perr := time.Parse(time.RFC3339Nano, proposalExpiry)
|
||||
if routeProposal == "" || proposalOwner != owner || proposalStatus != "confirmed" || proposalGeneration != generation ||
|
||||
proposalVersion != account.AuthVersion || perr != nil || time.Now().After(expires) || !account.HasPassword {
|
||||
return "", ErrAccountForbidden
|
||||
var sessionOwner, scope, sessionExpiry, method, sessionOrigin, grantID string
|
||||
var sessionVersion int
|
||||
if err := tx.QueryRow(sessionQuery, hashKey(token)).Scan(&sessionOwner, &scope, &sessionExpiry, &sessionVersion, &method, &sessionOrigin, &grantID); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
default:
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1,
|
||||
grantExpires, err := time.Parse(time.RFC3339Nano, grantExpiry)
|
||||
if err != nil || time.Now().After(grantExpires) {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
sessionExpires, err := time.Parse(time.RFC3339Nano, sessionExpiry)
|
||||
if err != nil || time.Now().After(sessionExpires) || sessionOwner != owner || scope != SessionScopeSetPasswordOnly || method != AuthMethodGrant ||
|
||||
grantID != routeGrant || sessionVersion != account.AuthVersion || issuedVersion != account.AuthVersion ||
|
||||
grantOwner != owner || status != LoginKeyStatusConsumed || sessionOrigin != origin || proposalID != routeProposal {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
switch {
|
||||
case purpose == LoginKeyPurposeActivate && origin == string(CredentialOriginOwnerInitialization):
|
||||
if routeProposal != "" || account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
case purpose == LoginKeyPurposeRecover && origin == string(CredentialOriginOwnerRecovery):
|
||||
expires, perr := time.Parse(time.RFC3339Nano, proposalExpiry)
|
||||
if routeProposal == "" || proposalOwner != owner || proposalStatus != "confirmed" || proposalGeneration != generation ||
|
||||
proposalVersion != account.AuthVersion || perr != nil || time.Now().After(expires) || !account.HasPassword {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
default:
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1,
|
||||
credential_origin=?,initialization_state=?,state_version=state_version+1 WHERE owner=? AND auth_version=? AND enabled=?`),
|
||||
passwordHash, nowISO(), false, origin, InitializationInitialized, owner, account.AuthVersion, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil || n != 1 {
|
||||
return "", ErrConflict
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
|
||||
LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if routeProposal != "" {
|
||||
if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='completed',version=version+1 WHERE id=? AND status='confirmed'`), routeProposal); err != nil {
|
||||
return "", err
|
||||
passwordHash, nowISO(), false, origin, InitializationInitialized, owner, account.AuthVersion, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND id<>? AND status IN ('pending','confirmed')`), owner, routeProposal); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, owner, "trusted-grant", owner, "password_set", origin); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return owner, tx.Commit()
|
||||
if n, err := res.RowsAffected(); err != nil || n != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
|
||||
LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if routeProposal != "" {
|
||||
if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='completed',version=version+1 WHERE id=? AND status='confirmed'`), routeProposal); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND id<>? AND status IN ('pending','confirmed')`), owner, routeProposal); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, owner, "trusted-grant", owner, "password_set", origin); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return owner, err
|
||||
}
|
||||
|
||||
// CompleteForcedPasswordChecked preserves the provenance of a historical
|
||||
@@ -646,173 +600,165 @@ func (s *Store) CompleteForcedPasswordChecked(token, current, passwordHash strin
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
accounts, err := s.lockAccountUsers(tx, owner)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
account := accounts[owner]
|
||||
if !account.Enabled || !account.MustChangePassword || !account.HasPassword {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
var hash string
|
||||
if err := tx.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash); err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := s.q(`SELECT scope,auth_method,expires_at,auth_version FROM sessions WHERE token_hash=? AND owner=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
var scope, method, expiresAt string
|
||||
var version int
|
||||
if err := tx.QueryRow(query, hashKey(token), owner).Scan(&scope, &method, &expiresAt, &version); err != nil {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
expiry, err := time.Parse(time.RFC3339Nano, expiresAt)
|
||||
if err != nil || time.Now().After(expiry) || version != account.AuthVersion || scope != SessionScopeSetPasswordOnly || method != AuthMethodPassword {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
if !VerifyPassword(hash, current) {
|
||||
return "", ErrBadCredentials
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1
|
||||
err := s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.forced"}, func(tx *sql.Tx, _ string) error {
|
||||
accounts, err := s.lockAccountUsers(tx, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
account := accounts[owner]
|
||||
if !account.Enabled || !account.MustChangePassword || !account.HasPassword {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
var hash string
|
||||
if err := tx.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash); err != nil {
|
||||
return err
|
||||
}
|
||||
query := s.q(`SELECT scope,auth_method,expires_at,auth_version FROM sessions WHERE token_hash=? AND owner=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
var scope, method, expiresAt string
|
||||
var version int
|
||||
if err := tx.QueryRow(query, hashKey(token), owner).Scan(&scope, &method, &expiresAt, &version); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
expiry, err := time.Parse(time.RFC3339Nano, expiresAt)
|
||||
if err != nil || time.Now().After(expiry) || version != account.AuthVersion || scope != SessionScopeSetPasswordOnly || method != AuthMethodPassword {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
if !VerifyPassword(hash, current) {
|
||||
return ErrBadCredentials
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1
|
||||
WHERE owner=? AND auth_version=? AND enabled=? AND must_change_password=?`),
|
||||
passwordHash, nowISO(), false, owner, account.AuthVersion, true, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil || n != 1 {
|
||||
return "", ErrConflict
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, owner, "password-session", owner, "password_change_forced", ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return owner, tx.Commit()
|
||||
passwordHash, nowISO(), false, owner, account.AuthVersion, true, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil || n != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, owner, "password-session", owner, "password_change_forced", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return owner, err
|
||||
}
|
||||
|
||||
// ChangeUserPasswordActive 主动改密单事务:校验当前密码 → 更新(带认证版本乐观
|
||||
// 检查)→ bump auth_version → 吊销其他会话(保留当前)→ 审计。中途失败无部分成功。
|
||||
func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var hash string
|
||||
var ver int
|
||||
var mustChange, enabled bool
|
||||
accountQuery := s.q(`SELECT password_hash,auth_version,must_change_password,enabled FROM users WHERE owner=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
accountQuery += ` FOR UPDATE`
|
||||
}
|
||||
err = tx.QueryRow(accountQuery, owner).
|
||||
Scan(&hash, &ver, &mustChange, &enabled)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
if mustChange || hash == "" {
|
||||
// 强制态必须走受限设密路径,不在此处用旧密码换新密码。
|
||||
return ErrConflict
|
||||
}
|
||||
if !VerifyPassword(hash, current) {
|
||||
return ErrBadCredentials
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=? AND auth_version=?`),
|
||||
passwordHash, nowISO(), false, owner, ver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrConflict // 并发改密:版本已前进
|
||||
}
|
||||
if keepToken != "" {
|
||||
kept, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=? WHERE token_hash=? AND owner=? AND auth_version=? AND scope=?`), ver+1, hashKey(keepToken), owner, ver, SessionScopeFull)
|
||||
return s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.change"}, func(tx *sql.Tx, _ string) error {
|
||||
var hash string
|
||||
var ver int
|
||||
var mustChange, enabled bool
|
||||
accountQuery := s.q(`SELECT password_hash,auth_version,must_change_password,enabled FROM users WHERE owner=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
accountQuery += ` FOR UPDATE`
|
||||
}
|
||||
err := tx.QueryRow(accountQuery, owner).
|
||||
Scan(&hash, &ver, &mustChange, &enabled)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := kept.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
if !enabled {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
if mustChange || hash == "" {
|
||||
// 强制态必须走受限设密路径,不在此处用旧密码换新密码。
|
||||
return ErrConflict
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil {
|
||||
if !VerifyPassword(hash, current) {
|
||||
return ErrBadCredentials
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=? AND auth_version=?`),
|
||||
passwordHash, nowISO(), false, owner, ver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrConflict // 并发改密:版本已前进
|
||||
}
|
||||
if keepToken != "" {
|
||||
kept, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=? WHERE token_hash=? AND owner=? AND auth_version=? AND scope=?`), ver+1, hashKey(keepToken), owner, ver, SessionScopeFull)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := kept.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// AdminResetUserPassword 管理员/CLI 重置密码单事务:写 hash、must_change=true、
|
||||
// bump auth_version、作废未用登录 Key、吊销全部会话、审计(actor 为操作者)。
|
||||
func (s *Store) AdminResetUserPassword(owner, passwordHash, actor, entrance string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
locked, err := s.lockAccountUsers(tx, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !locked[owner].Enabled {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1,
|
||||
return s.accountTx(AccountWrite{Owner: owner, Actor: actor, Action: "account.password.admin_reset"}, func(tx *sql.Tx, _ string) error {
|
||||
locked, err := s.lockAccountUsers(tx, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !locked[owner].Enabled {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1,
|
||||
credential_origin=?,initialization_state=? WHERE owner=? AND auth_version=? AND enabled=?`),
|
||||
passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, owner, locked[owner].AuthVersion, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
|
||||
LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if entrance == "" {
|
||||
entrance = entranceOf(actor)
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, actor, entrance, owner, "admin_password_reset", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, owner, locked[owner].AuthVersion, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
|
||||
LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if entrance == "" {
|
||||
entrance = entranceOf(actor)
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, actor, entrance, owner, "admin_password_reset", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CountUnusedLoginKeys 未使用登录 Key 计数(list 展示)。
|
||||
|
||||
@@ -848,14 +848,37 @@ func init() {
|
||||
},
|
||||
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS project_event_shares`}, "postgres": {`DROP TABLE IF EXISTS project_event_shares`}},
|
||||
},
|
||||
migration{version: 31, name: "account_profile_state_and_provenance",
|
||||
migration{version: 31, name: "account_audit",
|
||||
up: map[string][]string{"sqlite": {
|
||||
`ALTER TABLE audit_events ADD COLUMN entry TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE audit_events ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE audit_events ADD COLUMN operation_id TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE audit_events ADD COLUMN outcome TEXT NOT NULL DEFAULT ''`,
|
||||
}, "postgres": {
|
||||
`ALTER TABLE audit_events ADD COLUMN entry TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE audit_events ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE audit_events ADD COLUMN operation_id TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE audit_events ADD COLUMN outcome TEXT NOT NULL DEFAULT ''`,
|
||||
}},
|
||||
down: map[string][]string{"sqlite": {
|
||||
`ALTER TABLE audit_events DROP COLUMN outcome`,
|
||||
`ALTER TABLE audit_events DROP COLUMN operation_id`,
|
||||
`ALTER TABLE audit_events DROP COLUMN instance_id`,
|
||||
`ALTER TABLE audit_events DROP COLUMN entry`,
|
||||
}, "postgres": {
|
||||
`ALTER TABLE audit_events DROP COLUMN outcome`,
|
||||
`ALTER TABLE audit_events DROP COLUMN operation_id`,
|
||||
`ALTER TABLE audit_events DROP COLUMN instance_id`,
|
||||
`ALTER TABLE audit_events DROP COLUMN entry`,
|
||||
}}},
|
||||
migration{version: 32, name: "account_profile_state_and_provenance",
|
||||
up: map[string][]string{
|
||||
"sqlite": accountFoundationUp("INTEGER NOT NULL DEFAULT 1", "lower(hex(randomblob(16)))"),
|
||||
"postgres": accountFoundationUp("BOOLEAN NOT NULL DEFAULT TRUE", "md5(random()::text || clock_timestamp()::text)"),
|
||||
},
|
||||
down: map[string][]string{"sqlite": accountFoundationDown, "postgres": accountFoundationDown},
|
||||
},
|
||||
migration{version: 32, name: "owner_proof_recovery",
|
||||
migration{version: 33, name: "owner_proof_recovery",
|
||||
up: map[string][]string{"sqlite": recoveryFoundationUp, "postgres": recoveryFoundationUp},
|
||||
down: map[string][]string{"sqlite": recoveryFoundationDown, "postgres": recoveryFoundationDown},
|
||||
},
|
||||
@@ -891,7 +914,7 @@ var recoveryFoundationDown = []string{
|
||||
`ALTER TABLE login_keys DROP COLUMN grant_origin`,
|
||||
}
|
||||
|
||||
// v31 preserves legacy identities and credentials. Only the explicit pending
|
||||
// v32 preserves legacy identities and credentials. Only the explicit pending
|
||||
// account primitive sets pending initialization; migration cannot infer proof.
|
||||
func accountFoundationUp(enabledType, revisionExpression string) []string {
|
||||
return []string{
|
||||
|
||||
@@ -35,8 +35,9 @@ var migrationBreaksOldReaders = map[int]bool{
|
||||
28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响
|
||||
29: false, // redeem_attempts:新表,旧读者不受影响
|
||||
30: false, // project_event_publication_proofs:新表,旧读者不受影响
|
||||
31: true, // 账号有效态/凭据来源是安全协议边界;即使旧 SQL 可读,也不得恢复旧认证写者
|
||||
32: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除
|
||||
31: false, // account_audit 给 audit_events 增加带默认值的列,旧制品按列名读取
|
||||
32: true, // 账号有效态/凭据来源是安全协议边界;旧认证写者必须排除
|
||||
33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除
|
||||
}
|
||||
|
||||
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
||||
|
||||
@@ -1,137 +1,18 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// InitializePendingAccount is reserved for the local machine/DB owner CLI. It
|
||||
// accepts a stable user ID and expected versions, never an actor string from a
|
||||
// request. The audit actor is the actual local OS UID. The caller must use the
|
||||
// protected delivery channel; a failed delivery is retried with the same ID.
|
||||
func (s *Store) InitializePendingAccount(userID string, expectedStateVersion, expectedGeneration int64) (string, error) {
|
||||
if userID == "" || expectedStateVersion < 1 || expectedGeneration < 0 {
|
||||
return "", ErrConflict
|
||||
}
|
||||
pre, err := s.GetAccount(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain := "alk_" + hex.EncodeToString(raw[:])
|
||||
operator := fmt.Sprintf("local-owner:uid:%d", os.Geteuid())
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
locked, err := s.lockAccountUsers(tx, pre.Owner)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
account := locked[pre.Owner]
|
||||
if account.ID != userID || account.StateVersion != expectedStateVersion || account.InitializationGeneration != expectedGeneration {
|
||||
return "", ErrConflict
|
||||
}
|
||||
if !account.Enabled || account.InitializationState != InitializationPending || account.HasPassword || account.CredentialOrigin != CredentialOriginLegacyUnknown {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
var unsafeCreds int
|
||||
err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM api_keys WHERE owner=? AND revoked=0`), account.Owner).Scan(&unsafeCreds)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if unsafeCreds != 0 {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM sessions WHERE owner=? AND scope=?`), account.Owner, SessionScopeFull).Scan(&unsafeCreds)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if unsafeCreds != 0 {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM login_keys WHERE owner=? AND grant_origin<>?`), account.Owner, CredentialOriginOwnerInitialization).Scan(&unsafeCreds)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if unsafeCreds != 0 {
|
||||
return "", ErrAccountForbidden
|
||||
}
|
||||
nextGeneration := account.InitializationGeneration + 1
|
||||
nextAuthVersion := account.AuthVersion + 1
|
||||
result, err := tx.Exec(s.q(`UPDATE users SET initialization_generation=?,state_version=state_version+1,auth_version=auth_version+1
|
||||
WHERE id=? AND state_version=? AND initialization_generation=? AND initialization_state=? AND password_hash='' AND enabled=?`),
|
||||
nextGeneration, userID, expectedStateVersion, expectedGeneration, InitializationPending, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, err := result.RowsAffected(); err != nil || n != 1 {
|
||||
return "", ErrConflict
|
||||
}
|
||||
if _, err := s.insertLoginKeyTx(tx, account.Owner, LoginKeyPurposeActivate, operator, plain, newID("lk"), now, now.Add(defaultLoginKeyTTL),
|
||||
grantBinding{origin: CredentialOriginOwnerInitialization, generation: nextGeneration, version: nextAuthVersion}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, operator, "local-owner-cli", account.Owner, "initialization_issued", fmt.Sprintf("user_id=%s generation=%d", userID, nextGeneration)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return plain, nil
|
||||
out, err := s.InitializePendingAccountWithAudit(userID, expectedStateVersion, expectedGeneration, "")
|
||||
return out.Plain, err
|
||||
}
|
||||
|
||||
// CreateLocalPendingAccount is the local owner-only new-account path. Account
|
||||
// and first activation grant commit together, including a local administrator
|
||||
// flag when explicitly requested by the machine/DB owner CLI.
|
||||
func (s *Store) CreateLocalPendingAccount(owner, name string, isAdmin bool) (Account, string, error) {
|
||||
operator := fmt.Sprintf("local-owner:uid:%d", os.Geteuid())
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
plain := "alk_" + hex.EncodeToString(raw[:])
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.dialect.lockBootstrap(tx); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
var users int
|
||||
if err := tx.QueryRow(`SELECT COUNT(1) FROM users`).Scan(&users); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
if users == 0 {
|
||||
return Account{}, "", ErrConflict // first administrator requires BootstrapFirstAdmin
|
||||
}
|
||||
account, err := s.insertPendingAccountTx(tx, owner, AccountProfile{Name: name})
|
||||
if err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), isAdmin, 1, account.ID); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, operator, plain, newID("lk"), now, now.Add(defaultLoginKeyTTL),
|
||||
grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, operator, "local-owner-cli", owner, "local_account_created", account.ID); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
account.IsAdmin, account.InitializationGeneration = isAdmin, 1
|
||||
return account, plain, nil
|
||||
account, out, err := s.CreateLocalPendingAccountWithAudit(owner, name, isAdmin, "")
|
||||
return account, out.Plain, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func localOwnerActor() string { return fmt.Sprintf("local-owner:uid:%d", os.Geteuid()) }
|
||||
|
||||
func newActivationPlain() (string, error) {
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "alk_" + hex.EncodeToString(raw[:]), nil
|
||||
}
|
||||
|
||||
// InitializePendingAccountWithAudit reissues only a trusted pending account's
|
||||
// activation grant. The account mutation, grant, and success audit commit together.
|
||||
func (s *Store) InitializePendingAccountWithAudit(userID string, expectedStateVersion, expectedGeneration int64, instanceID string) (AccountOutcome, error) {
|
||||
var out AccountOutcome
|
||||
if userID == "" || expectedStateVersion < 1 || expectedGeneration < 0 {
|
||||
return out, ErrConflict
|
||||
}
|
||||
pre, err := s.GetAccount(userID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
plain, err := newActivationPlain()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
operator := localOwnerActor()
|
||||
w := AccountWrite{Owner: pre.Owner, Actor: strconv.Itoa(os.Geteuid()), InstanceID: instanceID, Action: "account.initialize"}
|
||||
err = s.accountTx(w, func(tx *sql.Tx, opID string) error {
|
||||
locked, err := s.lockAccountUsers(tx, pre.Owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
account := locked[pre.Owner]
|
||||
if account.ID != userID || account.StateVersion != expectedStateVersion || account.InitializationGeneration != expectedGeneration {
|
||||
return ErrConflict
|
||||
}
|
||||
if !account.Enabled || account.InitializationState != InitializationPending || account.HasPassword || account.CredentialOrigin != CredentialOriginLegacyUnknown {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
for _, query := range []string{
|
||||
`SELECT COUNT(1) FROM api_keys WHERE owner=? AND revoked=0`,
|
||||
`SELECT COUNT(1) FROM sessions WHERE owner=? AND scope='full'`,
|
||||
`SELECT COUNT(1) FROM login_keys WHERE owner=? AND grant_origin<>'owner_initialization'`,
|
||||
} {
|
||||
var unsafeCreds int
|
||||
if err := tx.QueryRow(s.q(query), account.Owner).Scan(&unsafeCreds); err != nil {
|
||||
return err
|
||||
}
|
||||
if unsafeCreds != 0 {
|
||||
return ErrAccountForbidden
|
||||
}
|
||||
}
|
||||
nextGeneration := account.InitializationGeneration + 1
|
||||
nextAuthVersion := account.AuthVersion + 1
|
||||
result, err := tx.Exec(s.q(`UPDATE users SET initialization_generation=?,state_version=state_version+1,auth_version=auth_version+1
|
||||
WHERE id=? AND state_version=? AND initialization_generation=? AND initialization_state=? AND password_hash='' AND enabled=?`),
|
||||
nextGeneration, userID, expectedStateVersion, expectedGeneration, InitializationPending, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := result.RowsAffected(); err != nil || n != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
id := newID("lk")
|
||||
if _, err := s.insertLoginKeyTx(tx, account.Owner, LoginKeyPurposeActivate, operator, plain, id, now, now.Add(defaultLoginKeyTTL),
|
||||
grantBinding{origin: CredentialOriginOwnerInitialization, generation: nextGeneration, version: nextAuthVersion}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.stampLoginKeyInstance(tx, id, instanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, operator, "local-owner-cli", account.Owner, "initialization_issued", fmt.Sprintf("user_id=%s generation=%d", userID, nextGeneration)); err != nil {
|
||||
return err
|
||||
}
|
||||
out = AccountOutcome{Plain: plain, CredentialID: id, OperationID: opID}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// CreateLocalPendingAccountWithAudit admits a new pending account through the
|
||||
// same serialized, audited account transaction as other maintenance actions.
|
||||
func (s *Store) CreateLocalPendingAccountWithAudit(owner, name string, isAdmin bool, instanceID string) (Account, AccountOutcome, error) {
|
||||
var account Account
|
||||
var out AccountOutcome
|
||||
plain, err := newActivationPlain()
|
||||
if err != nil {
|
||||
return account, out, err
|
||||
}
|
||||
operator := localOwnerActor()
|
||||
w := AccountWrite{Owner: owner, Actor: strconv.Itoa(os.Geteuid()), InstanceID: instanceID, Action: "account.create.local_pending"}
|
||||
err = s.accountTx(w, func(tx *sql.Tx, opID string) error {
|
||||
if err := s.dialect.lockBootstrap(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
var users int
|
||||
if err := tx.QueryRow(`SELECT COUNT(1) FROM users`).Scan(&users); err != nil {
|
||||
return err
|
||||
}
|
||||
if users == 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
var err error
|
||||
account, err = s.insertPendingAccountTx(tx, owner, AccountProfile{Name: name})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), isAdmin, 1, account.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
id := newID("lk")
|
||||
if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, operator, plain, id, now, now.Add(defaultLoginKeyTTL),
|
||||
grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.stampLoginKeyInstance(tx, id, instanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, operator, "local-owner-cli", owner, "local_account_created", account.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
account.IsAdmin, account.InitializationGeneration = isAdmin, 1
|
||||
out = AccountOutcome{Plain: plain, CredentialID: id, OperationID: opID}
|
||||
return nil
|
||||
})
|
||||
return account, out, err
|
||||
}
|
||||
@@ -188,6 +188,9 @@ func (s *Store) CreateRecoveryProposal(actorOwner, userID string, expectedStateV
|
||||
if err := s.insertAccountEvent(tx, actorOwner, "admin-http", target.Owner, "recovery_proposed", p.ID); err != nil {
|
||||
return RecoveryProposal{}, err
|
||||
}
|
||||
if err := s.auditAccountMutation(tx, target.Owner, actorOwner, "account.recovery.propose"); err != nil {
|
||||
return RecoveryProposal{}, err
|
||||
}
|
||||
return p, tx.Commit()
|
||||
}
|
||||
|
||||
@@ -283,6 +286,9 @@ func (s *Store) confirmOrRotateRecovery(token, proposalID string, expectedVersio
|
||||
if err := s.insertAccountEvent(tx, p.Owner, "owner-http", p.Owner, action, fmt.Sprintf("proposal=%s generation=%d", p.ID, p.Generation)); err != nil {
|
||||
return "", RecoveryProposal{}, err
|
||||
}
|
||||
if err := s.auditAccountMutation(tx, p.Owner, p.Owner, "account.recovery."+action); err != nil {
|
||||
return "", RecoveryProposal{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", RecoveryProposal{}, err
|
||||
}
|
||||
@@ -436,6 +442,9 @@ func (s *Store) RevokeRecoveryProposal(actorOwner, userID, proposalID string, ex
|
||||
if err := s.insertAccountEvent(tx, actorOwner, "admin-http", p.Owner, "recovery_revoked", p.ID); err != nil {
|
||||
return RecoveryProposal{}, err
|
||||
}
|
||||
if err := s.auditAccountMutation(tx, p.Owner, actorOwner, "account.recovery.revoke"); err != nil {
|
||||
return RecoveryProposal{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return RecoveryProposal{}, err
|
||||
}
|
||||
|
||||
+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