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:
2026-10-14 05:27:42 +00:00
27 changed files with 2362 additions and 577 deletions
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
)
func runCapabilities(args []string) int {
fs := flag.NewFlagSet("capabilities", flag.ExitOnError)
asJSON := fs.Bool("json", false, "以 JSON 打印能力,不打开数据库")
_ = fs.Parse(args)
caps := map[string]any{
"accountTransaction": true,
"accountAudit": true,
"accountOpsOpen": true,
"passwordStdin": true,
"dsnFile": true,
"outFile": true,
}
if !*asJSON {
fmt.Fprintln(os.Stderr, "用法:agent-system capabilities --json")
return 2
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(caps); err != nil {
fmt.Fprintln(os.Stderr, "capabilities:", err)
return 1
}
return 0
}
func resolveAccountDSN(flagDSN, dsnFile string) (string, error) {
if flagDSN != "" {
fmt.Fprintln(os.Stderr, "warning: --dsn is deprecated; use AGENT_SYSTEM_DSN or --dsn-file")
}
if dsnFile != "" {
info, err := os.Stat(dsnFile)
if err != nil {
return "", err
}
if info.Mode().Perm() != 0o600 {
return "", fmt.Errorf("dsn file %s must be mode 0600", dsnFile)
}
raw, err := os.ReadFile(dsnFile)
if err != nil {
return "", err
}
return string(trimNewline(raw)), nil
}
if env := os.Getenv("AGENT_SYSTEM_DSN"); env != "" {
return env, nil
}
return flagDSN, nil
}
func resolveAccountPassword(flagPassword string, fromStdin bool) (string, error) {
if flagPassword != "" {
fmt.Fprintln(os.Stderr, "warning: --password is deprecated; use --password-stdin")
}
if !fromStdin {
return flagPassword, nil
}
raw, err := io.ReadAll(os.Stdin)
if err != nil {
return "", err
}
return string(trimNewline(raw)), nil
}
func writeExclusiveSecret(path, body string) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
defer f.Close()
_, err = io.WriteString(f, body+"\n")
return err
}
func trimNewline(raw []byte) []byte {
for len(raw) > 0 && (raw[len(raw)-1] == '\n' || raw[len(raw)-1] == '\r') {
raw = raw[:len(raw)-1]
}
return raw
}
+180
View File
@@ -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
View File
@@ -43,9 +43,11 @@ func main() {
os.Exit(runUser(os.Args[2:]))
case "identity":
os.Exit(runIdentity(os.Args[2:]))
case "capabilities":
os.Exit(runCapabilities(os.Args[2:]))
default:
fmt.Fprintf(os.Stderr, "agent-system: 未知子命令 %s\n", os.Args[1])
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config> [参数]")
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config|capabilities> [参数]")
os.Exit(2)
}
}
@@ -194,22 +196,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
}
+1 -1
View File
@@ -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) {
+58
View File
@@ -0,0 +1,58 @@
package app
import (
"agent-system-backend/internal/store"
"os"
"path/filepath"
"testing"
)
func TestAccountMaintenanceDefaultsToNonCreatingOpen(t *testing.T) {
t.Setenv("AGENT_SYSTEM_DSN", "")
for _, tc := range []struct {
name string
run func(string) error
}{
{"user", func(dir string) error {
_, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false)
return err
}},
{"apikey", func(dir string) error {
_, err := CreateAPIKey("", dir, "ada", "test")
return err
}},
{"identity verify", func(dir string) error {
_, err := VerifySystemIdentity("", dir, "fixture", "sqlite")
return err
}},
} {
t.Run(tc.name, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "absent")
if err := tc.run(dir); err == nil {
t.Error("maintenance created a missing database")
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Error("failed maintenance created a directory")
}
})
}
}
func TestAccountMaintenanceCannotImplicitlyMigrate(t *testing.T) {
t.Setenv("AGENT_SYSTEM_DSN", "")
dir := t.TempDir()
st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
if err != nil {
t.Fatal(err)
}
if err := st.EnsureSystemIdentity("test", "sqlite"); err != nil {
t.Fatal(err)
}
// Roll back the newest additive migration in this private fixture only.
if err := st.MigrateDown(store.SchemaTarget() - 1); err != nil {
t.Fatal(err)
}
st.Close()
if _, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false); err == nil {
t.Error("account command implicitly migrated an older schema")
}
}
+73 -71
View File
@@ -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 -1
View File
@@ -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)
}
+5
View File
@@ -64,6 +64,9 @@ func TestBootstrapFirstAdminSingleWinner(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.EnsureSystemIdentity("bootstrap-test", "sqlite"); err != nil {
t.Fatal(err)
}
var wins, conflicts atomic.Int32
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
@@ -78,6 +81,8 @@ func TestBootstrapFirstAdminSingleWinner(t *testing.T) {
wins.Add(1)
} else if err == ErrConflict {
conflicts.Add(1)
} else {
t.Errorf("bootstrap contender: %v", err)
}
}(owner)
}
+9
View File
@@ -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
}
+4 -4
View File
@@ -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")
}
}
+3
View File
@@ -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)
}
})
}
}
+411
View File
@@ -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
}
+156
View File
@@ -0,0 +1,156 @@
package store
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
)
func TestConcurrentCreateOneWinner(t *testing.T) {
st := openLife(t)
const n = 8
var wg sync.WaitGroup
wins := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := st.CreateActivatedAccount(AccountWrite{
Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, CreatedBy: "test",
Action: "account.create",
})
wins <- err
}()
}
wg.Wait()
close(wins)
okN, conflict := 0, 0
for err := range wins {
switch {
case err == nil:
okN++
case errors.Is(err, ErrConflict):
conflict++
default:
t.Fatal(err)
}
}
if okN != 1 || conflict != n-1 {
t.Fatalf("wins=%d conflicts=%d", okN, conflict)
}
}
func TestPasswordChangeIsAtomic(t *testing.T) {
st := openLife(t)
hash, err := HashPassword("old-password")
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash, Action: "account.create"}); err != nil {
t.Fatal(err)
}
plain, _, err := st.IssueLoginKey("ada", LoginKeyPurposeActivate, "test", 0)
if err != nil {
t.Fatal(err)
}
st.SetAccountFault(fmt.Errorf("injected"))
next, err := HashPassword("new-password")
if err != nil {
t.Fatal(err)
}
if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err == nil {
t.Fatal("faulted change returned success")
}
st.SetAccountFault(nil)
if _, ok := st.VerifyLogin("ada", "old-password"); !ok {
t.Fatal("old password did not survive the failed change")
}
if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err != nil {
t.Fatal(err)
}
if _, _, err := st.RedeemLoginKey(plain); err == nil {
t.Fatal("old key redeemed after password change")
}
if _, ok := st.VerifyLogin("ada", "new-password"); !ok {
t.Fatal("new password missing")
}
n, err := st.CountAudit("ada", "rejected")
if err != nil || n == 0 {
t.Fatalf("rejected audit n=%d err=%v", n, err)
}
}
func TestOpenExistingDoesNotMigrateOrCreate(t *testing.T) {
missing := filepath.Join(t.TempDir(), "absent", "agent-system.db")
if _, err := OpenExistingSQLite(missing); err == nil {
t.Fatal("missing file opened")
}
if _, err := os.Stat(filepath.Dir(missing)); !os.IsNotExist(err) {
t.Fatalf("parent created: %v", err)
}
path := filepath.Join(t.TempDir(), "agent-system.db")
st, err := OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
again, err := OpenExistingSQLite(path)
if err != nil {
t.Fatal(err)
}
again.Close()
}
func TestBootstrapRefusesExistingBusiness(t *testing.T) {
st := openLife(t)
if err := st.EnsureSystemIdentity("inst", "sqlite"); err != nil {
t.Fatal(err)
}
if _, err := st.CreateProject("owner", "p"); err != nil {
t.Fatal(err)
}
if _, err := st.BootstrapFirstAdminWrite(AccountWrite{Owner: "root", Name: "root"}); err == nil {
t.Fatal("bootstrap accepted a database that already has a project")
}
}
func TestPasswordSurvivesReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent-system.db")
st, err := OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
hash, err := HashPassword("keep-password")
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash}); err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
st, err = OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
if _, ok := st.VerifyLogin("ada", "keep-password"); !ok {
t.Fatal("password did not survive reopen")
}
}
func openLife(t *testing.T) *Store {
t.Helper()
st, err := OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
+35
View File
@@ -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
View File
@@ -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
+8
View File
@@ -20,6 +20,8 @@ type dialect interface {
// lockBootstrap 锁定首次管理员引导的「核验无用户→建号→激活 Key」串行段
//(KTD12:防两个进程并发引导出双管理员)。
lockBootstrap(tx *sql.Tx) error
// lockAccount serializes credential and session admission for one owner.
lockAccount(tx *sql.Tx, owner string) error
}
// ---- SQLite(嵌入式档)----
@@ -32,6 +34,7 @@ func (sqliteDialect) lockConversation(*sql.Tx, string) error { return nil }
func (sqliteDialect) lockScope(*sql.Tx, string, string) error { return nil }
func (sqliteDialect) lockAdmission(*sql.Tx) error { return nil }
func (sqliteDialect) lockBootstrap(*sql.Tx) error { return nil }
func (sqliteDialect) lockAccount(*sql.Tx, string) error { return nil }
// ---- PostgreSQL(多用户/多实例档)----
@@ -72,3 +75,8 @@ func (postgresDialect) lockBootstrap(tx *sql.Tx) error {
_, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "bootstrap:first-admin")
return err
}
func (postgresDialect) lockAccount(tx *sql.Tx, owner string) error {
_, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "account:"+owner)
return err
}
+238 -292
View File
@@ -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 展示)。
+26 -3
View File
@@ -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{
+3 -2
View File
@@ -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 版本。
+4 -123
View File
@@ -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
}
+9
View File
@@ -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
View File
@@ -77,6 +77,7 @@ type Store struct {
migrateMu sync.Mutex
migrateFault error // 非空时 migratePass 在「DDL 已执行、版本未登记」处失败(原子性测试)
accountFault error // 非空时账户事务在提交前失败(半态测试)
vecPGOnce sync.Once // pgvector 列探测(U6-3:一次性缓存)
vecPG bool
@@ -87,7 +88,7 @@ func OpenSQLite(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
return openSQLiteFile(path, true)
return openSQLiteFile(path, true, true)
}
// OpenIdentity 打开既有库做身份 ensure/verify:不创建文件或目录,不执行迁移。
@@ -171,8 +172,8 @@ func (s *Store) SchemaVersion() (int, error) {
return int(v.Int64), nil
}
// OpenExistingSQLite 打开既有嵌入式库:数据文件不存在则失败,不创建空库或父目录。
// 仍执行兼容迁移(requireCompatible);用于账户运维,避免恢复操作顺带建库。
// OpenExistingSQLite 打开既有嵌入式库:数据文件不存在则失败,不创建空库或父目录,也不迁移。
// schema 与本二进制不一致时拒绝,并提示使用 migrate 或先启动服务。
func OpenExistingSQLite(path string) (*Store, error) {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
@@ -180,10 +181,10 @@ func OpenExistingSQLite(path string) (*Store, error) {
}
return nil, err
}
return openSQLiteFile(path, false)
return openSQLiteFile(path, false, false)
}
func openSQLiteFile(path string, createArtifacts bool) (*Store, error) {
func openSQLiteFile(path string, createArtifacts, migrateSchema bool) (*Store, error) {
// _txlock=immediate:写事务在 BEGIN 即取库写锁(而非首个写语句升级),
// 跨进程迁移/写路径依赖 busy_timeout 排队而不是中途 SQLITE_BUSY(KTD8)。
// busy_timeout 30s:迁移竞争测试在慢速 CI 盘上 4 个迁移者串行全程可超 5s
@@ -195,7 +196,7 @@ func openSQLiteFile(path string, createArtifacts bool) (*Store, error) {
}
// SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。
db.SetMaxOpenConns(1)
return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts)
return finishOpenMode(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts, migrateSchema)
}
// OpenPostgres 打开 PostgreSQL 档存储并执行迁移(默认连接预算)。
@@ -214,15 +215,15 @@ func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*St
// 以 statement_timeout 运行参数附着到 DSN(对 URL 与键值两种形式均生效)。
// blobs 为 nil(含带类型的空指针,例如未配置端点时的 (*blobstore.S3)(nil))时回落本地目录。
func OpenPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store) (*Store, error) {
return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true)
return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true, true)
}
// OpenExistingPostgres 打开既有 PG 库并执行兼容迁移;不创建 artifacts 目录。
// OpenExistingPostgres 打开既有 PG 库,不创建 artifacts 目录,也不迁移。
func OpenExistingPostgres(dsn, artifactsDir string) (*Store, error) {
return openPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, nil, false)
return openPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, nil, false, false)
}
func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store, createArtifacts bool) (*Store, error) {
func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store, createArtifacts, migrateSchema bool) (*Store, error) {
pool = pool.effective()
if pool.QueryTimeoutSec > 0 {
dsn = withPGRuntimeParam(dsn, "statement_timeout", strconv.Itoa(pool.QueryTimeoutSec*1000))
@@ -237,7 +238,7 @@ func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs b
if pool.ConnMaxLifetimeSec > 0 {
db.SetConnMaxLifetime(time.Duration(pool.ConnMaxLifetimeSec) * time.Second)
}
return finishOpen(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts)
return finishOpenMode(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts, migrateSchema)
}
// withPGRuntimeParam 向 DSN 追加运行时参数(libpq 语义:URL 查询串或键值对)。
@@ -263,7 +264,13 @@ func AppendPGRuntimeParam(dsn, key, value string) string {
return dsn + sep + kv
}
// Preserve the existing initialization helper contract; maintenance opens select
// nonmigrating mode explicitly through finishOpenMode.
func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts bool) (*Store, error) {
return finishOpenMode(db, d, artifactsDir, blobs, pool, createArtifacts, true)
}
func finishOpenMode(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts, migrateSchema bool) (*Store, error) {
if blobstore.IsNil(blobs) {
var err error
if createArtifacts {
@@ -277,7 +284,12 @@ func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Stor
}
}
s := &Store{db: db, dialect: d, dataDir: artifactsDir, blobs: blobs, pool: pool}
if err := s.migrate(); err != nil {
if migrateSchema {
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
} else if err := s.requireCurrentSchema(); err != nil {
db.Close()
return nil, err
}