agent-exec transport c02cd0aaeb66ee1daf163331e6d123da/dffc8e4ac3b0
This commit is contained in:
@@ -80,6 +80,7 @@ func main() {
|
||||
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)")
|
||||
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)")
|
||||
adoptGuard := fs.Bool("adopt-instance-guard", false, "数据库被替换或锁文件被重建后,显式重写 SQLite 活动服务守卫")
|
||||
envFile := fs.String("env-file", "", "受保护 dotenv 文件(权限 600;补齐进程环境未设置的 SUPER_ADMIN_OWNER/SUPER_ADMIN_PASSWORD;空 = 取 AGENT_SYSTEM_ENV_FILE)")
|
||||
extension.RegisterCLIFlags(fs)
|
||||
fs.Parse(os.Args[1:])
|
||||
handled, hookErr := extension.RunCLIHooks(extension.Options{
|
||||
@@ -95,6 +96,7 @@ func main() {
|
||||
}
|
||||
cliSet := visited(fs)
|
||||
delete(cliSet, "adopt-instance-guard")
|
||||
delete(cliSet, "env-file")
|
||||
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
@@ -114,6 +116,14 @@ func main() {
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
superAdmin, err := config.LoadSuperAdmin(os.LookupEnv, *envFile, resolved.AuthEnabled())
|
||||
if err == nil {
|
||||
err = app.ValidateSuperAdmin(resolved.AuthEnabled(), superAdmin)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
pluginDefaults, merr := extension.MergeHostOptions(extension.Options{})
|
||||
if merr != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", merr)
|
||||
@@ -147,6 +157,7 @@ func main() {
|
||||
ExecWorkspace: resolved.ExecWorkspace, ExecEnvRoot: resolved.ExecEnvRoot,
|
||||
Tier: resolved.Tier,
|
||||
AdoptInstanceGuard: *adoptGuard,
|
||||
SuperAdmin: superAdmin,
|
||||
PGPool: store.PoolOptions{
|
||||
MaxOpenConn: resolved.PGMaxOpen, MaxIdleConn: resolved.PGMaxIdle,
|
||||
ConnMaxIdleTimeSec: resolved.PGConnMaxIdleSec, ConnMaxLifetimeSec: resolved.PGConnMaxLifetimeSec,
|
||||
|
||||
@@ -100,6 +100,9 @@ type Options struct {
|
||||
PGPool store.PoolOptions
|
||||
// AdoptInstanceGuard 在数据库被替换或锁文件被重建后,显式重写守卫记录。
|
||||
AdoptInstanceGuard bool
|
||||
// SuperAdmin 启动期超级管理员凭据(R1):开启认证时必填且启动即引导;
|
||||
// 口令只留在进程内存,不落库、不进日志。
|
||||
SuperAdmin config.SuperAdmin
|
||||
}
|
||||
|
||||
// instanceTag 稳定执行身份:tier=team 时强制 host:addr(多实例互异),
|
||||
@@ -178,11 +181,17 @@ func Run(opt Options) error {
|
||||
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
|
||||
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
|
||||
}
|
||||
if err := ValidateSuperAdmin(resolved.AuthEnabled(), opt.SuperAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
st, releaseStore, err := openServiceStore(opt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer releaseStore()
|
||||
if err := BootstrapSuperAdmin(st, resolved.AuthEnabled(), opt.SuperAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
pool := st.Pool()
|
||||
if versions, verr := st.MigrationStatus(); verr == nil {
|
||||
log.Printf("storage tier: %s (dialect=%s pool=%d/%d idleSec=%d lifetimeSec=%d schema=v%d)",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"agent-system-backend/internal/config"
|
||||
"agent-system-backend/internal/server"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// ValidateSuperAdmin is the startup gate for R1: with authentication enabled,
|
||||
// missing, partial or policy-violating configuration refuses startup. The
|
||||
// unauthenticated embedded mode neither requires nor uses the credentials.
|
||||
func ValidateSuperAdmin(authOn bool, sa config.SuperAdmin) error {
|
||||
if !authOn {
|
||||
return nil
|
||||
}
|
||||
if err := sa.Validate(server.ValidatePassword); err != nil {
|
||||
return fmt.Errorf("super admin: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BootstrapSuperAdmin establishes the configured super administrator in
|
||||
// persistent account state (KTD4). Identity conflicts stop startup; logs carry
|
||||
// the owner only, never the password or its hash.
|
||||
func BootstrapSuperAdmin(st *store.Store, authOn bool, sa config.SuperAdmin) error {
|
||||
if !authOn {
|
||||
return nil
|
||||
}
|
||||
if err := ValidateSuperAdmin(authOn, sa); err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := store.HashPassword(sa.Password())
|
||||
if err != nil {
|
||||
return fmt.Errorf("super admin: hash password: %w", err)
|
||||
}
|
||||
out, err := st.BootstrapSuperAdmin(sa.Owner, hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("super admin %q: %w", sa.Owner, err)
|
||||
}
|
||||
if out.Created {
|
||||
log.Printf("super admin: established account %q", sa.Owner)
|
||||
} else {
|
||||
log.Printf("super admin: account %q already established", sa.Owner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/config"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
const appSuperAdminPassword = "Sup3r-Secret-Value"
|
||||
|
||||
func openSuperAdminStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func captureLog(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
prev, flags := log.Writer(), log.Flags()
|
||||
log.SetOutput(&buf)
|
||||
t.Cleanup(func() { log.SetOutput(prev); log.SetFlags(flags) })
|
||||
return &buf
|
||||
}
|
||||
|
||||
func TestValidateSuperAdminStartupGate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sa config.SuperAdmin
|
||||
}{
|
||||
{"both absent", config.SuperAdmin{}},
|
||||
{"owner only", config.NewSuperAdmin("root", "")},
|
||||
{"password only", config.NewSuperAdmin("", appSuperAdminPassword)},
|
||||
{"invalid password", config.NewSuperAdmin("root", "short")},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateSuperAdmin(true, tc.sa)
|
||||
if !errors.Is(err, config.ErrSuperAdminConfig) {
|
||||
t.Fatalf("authentication-enabled startup accepted %s: %v", tc.name, err)
|
||||
}
|
||||
if err := ValidateSuperAdmin(false, tc.sa); err != nil {
|
||||
t.Fatalf("unauthenticated mode must not require credentials: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := ValidateSuperAdmin(true, config.NewSuperAdmin("root", appSuperAdminPassword)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminStartup(t *testing.T) {
|
||||
st := openSuperAdminStore(t)
|
||||
logs := captureLog(t)
|
||||
sa := config.NewSuperAdmin("root", appSuperAdminPassword)
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := BootstrapSuperAdmin(st, true, sa); err != nil {
|
||||
t.Fatalf("bootstrap %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
account, err := st.GetAccountByOwner("root")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !account.IsSuperAdmin || !account.IsAdmin {
|
||||
t.Fatalf("startup did not establish super admin: %+v", account)
|
||||
}
|
||||
if strings.Contains(logs.String(), appSuperAdminPassword) || strings.Contains(logs.String(), "argon2") {
|
||||
t.Fatalf("startup log leaks credential material: %s", logs.String())
|
||||
}
|
||||
if !strings.Contains(logs.String(), "established account") || !strings.Contains(logs.String(), "already established") {
|
||||
t.Fatalf("startup log lacks bootstrap outcome: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminStartupFailsClosed(t *testing.T) {
|
||||
st := openSuperAdminStore(t)
|
||||
if err := st.CreateAccount("root", "", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := BootstrapSuperAdmin(st, true, config.NewSuperAdmin("root", appSuperAdminPassword))
|
||||
if !errors.Is(err, store.ErrSuperAdminConflict) {
|
||||
t.Fatalf("conflicting owner did not stop startup: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), appSuperAdminPassword) {
|
||||
t.Fatalf("startup error leaks password: %v", err)
|
||||
}
|
||||
if err := BootstrapSuperAdmin(st, true, config.NewSuperAdmin("root", "short")); !errors.Is(err, config.ErrSuperAdminConfig) {
|
||||
t.Fatalf("invalid password reached storage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminSkippedWithoutAuthentication(t *testing.T) {
|
||||
st := openSuperAdminStore(t)
|
||||
if err := BootstrapSuperAdmin(st, false, config.SuperAdmin{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, err := st.ListUsersAdmin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != 0 {
|
||||
t.Fatalf("unauthenticated mode created accounts: %d", len(users))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// 超级管理员启动凭据(R1):进程环境优先,未设置的键从受保护的 dotenv 文件补齐。
|
||||
const (
|
||||
EnvSuperAdminOwner = "SUPER_ADMIN_OWNER"
|
||||
EnvSuperAdminPassword = "SUPER_ADMIN_PASSWORD"
|
||||
EnvFileKey = "AGENT_SYSTEM_ENV_FILE"
|
||||
)
|
||||
|
||||
// ErrSuperAdminConfig 标记缺失、不完整、非法或不可读的超级管理员配置;
|
||||
// 错误文本只含键名与文件路径,绝不含取值。
|
||||
var ErrSuperAdminConfig = errors.New("super admin config")
|
||||
|
||||
// SuperAdmin 持有启动期超级管理员身份与口令。口令只在进程内存中,
|
||||
// 格式化输出与 JSON 序列化一律脱敏。
|
||||
type SuperAdmin struct {
|
||||
Owner string
|
||||
password string
|
||||
}
|
||||
|
||||
// NewSuperAdmin 供测试与组合根直接构造(不做校验,校验见 Validate)。
|
||||
func NewSuperAdmin(owner, password string) SuperAdmin {
|
||||
return SuperAdmin{Owner: owner, password: password}
|
||||
}
|
||||
|
||||
// Password 返回配置口令;调用方不得记录或回传。
|
||||
func (s SuperAdmin) Password() string { return s.password }
|
||||
|
||||
func (s SuperAdmin) String() string {
|
||||
if s.password == "" {
|
||||
return fmt.Sprintf("SuperAdmin{owner=%q password=<unset>}", s.Owner)
|
||||
}
|
||||
return fmt.Sprintf("SuperAdmin{owner=%q password=<redacted>}", s.Owner)
|
||||
}
|
||||
|
||||
func (s SuperAdmin) GoString() string { return s.String() }
|
||||
|
||||
func (s SuperAdmin) MarshalJSON() ([]byte, error) {
|
||||
return []byte(fmt.Sprintf(`{"owner":%q,"passwordConfigured":%t}`, s.Owner, s.password != "")), nil
|
||||
}
|
||||
|
||||
// Validate 检查身份与口令的结构有效性;passwordPolicy 为认证边界的口令策略
|
||||
// (由调用方注入,避免在配置层复制策略)。
|
||||
func (s SuperAdmin) Validate(passwordPolicy func(string) error) error {
|
||||
switch {
|
||||
case s.Owner == "" && s.password == "":
|
||||
return fmt.Errorf("%w: %s and %s are required when authentication is enabled", ErrSuperAdminConfig, EnvSuperAdminOwner, EnvSuperAdminPassword)
|
||||
case s.Owner == "":
|
||||
return fmt.Errorf("%w: %s is missing (partial configuration)", ErrSuperAdminConfig, EnvSuperAdminOwner)
|
||||
case s.password == "":
|
||||
return fmt.Errorf("%w: %s is missing (partial configuration)", ErrSuperAdminConfig, EnvSuperAdminPassword)
|
||||
}
|
||||
if strings.TrimSpace(s.Owner) != s.Owner {
|
||||
return fmt.Errorf("%w: %s must not have surrounding whitespace", ErrSuperAdminConfig, EnvSuperAdminOwner)
|
||||
}
|
||||
for _, r := range s.Owner {
|
||||
if unicode.IsControl(r) {
|
||||
return fmt.Errorf("%w: %s contains control characters", ErrSuperAdminConfig, EnvSuperAdminOwner)
|
||||
}
|
||||
}
|
||||
if passwordPolicy != nil {
|
||||
if err := passwordPolicy(s.password); err != nil {
|
||||
return fmt.Errorf("%w: %s violates password policy: %v", ErrSuperAdminConfig, EnvSuperAdminPassword, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadSuperAdmin 读取超级管理员凭据:envLookup 中非空的键优先;未设置或空的键从
|
||||
// envFile(为空时取 AGENT_SYSTEM_ENV_FILE)补齐。文件必须存在且不得对组/其他
|
||||
// 用户开放任何权限。未开认证时不读取也不要求(嵌入式单用户档不暴露账号管理)。
|
||||
func LoadSuperAdmin(envLookup func(string) (string, bool), envFile string, authEnabled bool) (SuperAdmin, error) {
|
||||
if !authEnabled {
|
||||
return SuperAdmin{}, nil
|
||||
}
|
||||
owner, _ := envLookup(EnvSuperAdminOwner)
|
||||
password, _ := envLookup(EnvSuperAdminPassword)
|
||||
ownerSet, passwordSet := owner != "", password != ""
|
||||
if envFile == "" {
|
||||
envFile, _ = envLookup(EnvFileKey)
|
||||
}
|
||||
if (!ownerSet || !passwordSet) && envFile != "" {
|
||||
values, err := ReadEnvFile(envFile)
|
||||
if err != nil {
|
||||
return SuperAdmin{}, err
|
||||
}
|
||||
if !ownerSet {
|
||||
owner = values[EnvSuperAdminOwner]
|
||||
}
|
||||
if !passwordSet {
|
||||
password = values[EnvSuperAdminPassword]
|
||||
}
|
||||
}
|
||||
return SuperAdmin{Owner: owner, password: password}, nil
|
||||
}
|
||||
|
||||
var envFileLine = regexp.MustCompile(`^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$`)
|
||||
|
||||
// ReadEnvFile 按 svc.sh 使用的 dotenv 格式安全解析:
|
||||
// KEY=VALUE、# 注释、export 前缀、剥成对引号;非法行忽略;不执行内容。
|
||||
// 文件对组/其他用户有任何权限位即拒绝(受保护 .env 约定)。
|
||||
func ReadEnvFile(path string) (map[string]string, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: env file: %v", ErrSuperAdminConfig, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%w: env file %s is not a regular file", ErrSuperAdminConfig, path)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm&0o077 != 0 {
|
||||
return nil, fmt.Errorf("%w: env file %s is accessible by group/other (mode %04o; chmod 600)", ErrSuperAdminConfig, path, perm)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: env file: %v", ErrSuperAdminConfig, err)
|
||||
}
|
||||
values := map[string]string{}
|
||||
scanner := bufio.NewScanner(bytes.NewReader(raw))
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSuffix(scanner.Text(), "\r")
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
m := envFileLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
values[m[1]] = unquoteEnvValue(strings.TrimLeft(m[2], " \t"))
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("%w: env file %s: unreadable line", ErrSuperAdminConfig, path)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func unquoteEnvValue(v string) string {
|
||||
if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {
|
||||
return v[1 : len(v)-1]
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testSuperPassword = "Sup3r-Secret-Value"
|
||||
|
||||
func lookupMap(m map[string]string) func(string) (string, bool) {
|
||||
return func(k string) (string, bool) {
|
||||
v, ok := m[k]
|
||||
return v, ok
|
||||
}
|
||||
}
|
||||
|
||||
func testPolicy(pw string) error {
|
||||
if len(pw) < 8 || len(pw) > 256 {
|
||||
return errors.New("password must be 8-256 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeEnvFile(t *testing.T, body string, perm os.FileMode) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), ".env")
|
||||
if err := os.WriteFile(path, []byte(body), perm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, perm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func assertNoSecret(t *testing.T, err error, secret string) {
|
||||
t.Helper()
|
||||
if err != nil && secret != "" && strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("error leaks secret: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminValidateFailsClosed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, owner, password, want string
|
||||
}{
|
||||
{"both absent", "", "", "SUPER_ADMIN_OWNER and SUPER_ADMIN_PASSWORD are required"},
|
||||
{"owner only", "root", "", "SUPER_ADMIN_PASSWORD is missing"},
|
||||
{"password only", "", testSuperPassword, "SUPER_ADMIN_OWNER is missing"},
|
||||
{"short password", "root", "short", "SUPER_ADMIN_PASSWORD violates password policy"},
|
||||
{"owner whitespace", " root", testSuperPassword, "surrounding whitespace"},
|
||||
{"owner control", "ro\x01ot", testSuperPassword, "control characters"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sa, err := LoadSuperAdmin(lookupMap(map[string]string{EnvSuperAdminOwner: tc.owner, EnvSuperAdminPassword: tc.password}), "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = sa.Validate(testPolicy)
|
||||
if !errors.Is(err, ErrSuperAdminConfig) || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err=%v want %q", err, tc.want)
|
||||
}
|
||||
assertNoSecret(t, err, tc.password)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminLoadFromProcessEnvironment(t *testing.T) {
|
||||
sa, err := LoadSuperAdmin(lookupMap(map[string]string{EnvSuperAdminOwner: "root", EnvSuperAdminPassword: testSuperPassword}), "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sa.Owner != "root" || sa.Password() != testSuperPassword {
|
||||
t.Fatalf("got owner=%q", sa.Owner)
|
||||
}
|
||||
if err := sa.Validate(testPolicy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminEnvFileFillsUnsetKeysOnly(t *testing.T) {
|
||||
path := writeEnvFile(t, "# comment\nexport SUPER_ADMIN_OWNER=\"file-root\"\nSUPER_ADMIN_PASSWORD='"+testSuperPassword+"'\r\nnot a line\n", 0o600)
|
||||
sa, err := LoadSuperAdmin(lookupMap(map[string]string{}), path, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sa.Owner != "file-root" || sa.Password() != testSuperPassword {
|
||||
t.Fatalf("file values not loaded: owner=%q", sa.Owner)
|
||||
}
|
||||
sa, err = LoadSuperAdmin(lookupMap(map[string]string{EnvSuperAdminOwner: "env-root", EnvSuperAdminPassword: ""}), path, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sa.Owner != "env-root" || sa.Password() != testSuperPassword {
|
||||
t.Fatalf("process environment must win, empty must fall back: owner=%q", sa.Owner)
|
||||
}
|
||||
sa, err = LoadSuperAdmin(lookupMap(map[string]string{EnvFileKey: path}), "", true)
|
||||
if err != nil || sa.Owner != "file-root" {
|
||||
t.Fatalf("AGENT_SYSTEM_ENV_FILE not honored: owner=%q err=%v", sa.Owner, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminEnvFilePartialFailsValidation(t *testing.T) {
|
||||
path := writeEnvFile(t, "SUPER_ADMIN_OWNER=root\n", 0o600)
|
||||
sa, err := LoadSuperAdmin(lookupMap(map[string]string{}), path, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sa.Validate(testPolicy); err == nil || !strings.Contains(err.Error(), "SUPER_ADMIN_PASSWORD is missing") {
|
||||
t.Fatalf("partial file accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminEnvFileMustBeProtected(t *testing.T) {
|
||||
path := writeEnvFile(t, "SUPER_ADMIN_OWNER=root\nSUPER_ADMIN_PASSWORD="+testSuperPassword+"\n", 0o644)
|
||||
_, err := LoadSuperAdmin(lookupMap(map[string]string{}), path, true)
|
||||
if !errors.Is(err, ErrSuperAdminConfig) || !strings.Contains(err.Error(), "group/other") {
|
||||
t.Fatalf("world-readable env file accepted: %v", err)
|
||||
}
|
||||
assertNoSecret(t, err, testSuperPassword)
|
||||
_, err = LoadSuperAdmin(lookupMap(map[string]string{}), filepath.Join(t.TempDir(), "missing.env"), true)
|
||||
if !errors.Is(err, ErrSuperAdminConfig) {
|
||||
t.Fatalf("missing env file accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminNotRequiredWithoutAuthentication(t *testing.T) {
|
||||
sa, err := LoadSuperAdmin(lookupMap(map[string]string{EnvSuperAdminOwner: "root"}), filepath.Join(t.TempDir(), "missing.env"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("auth-disabled mode must not read or require credentials: %v", err)
|
||||
}
|
||||
if sa.Owner != "" || sa.Password() != "" {
|
||||
t.Fatalf("auth-disabled mode loaded credentials: owner=%q", sa.Owner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminRedactsPassword(t *testing.T) {
|
||||
sa := NewSuperAdmin("root", testSuperPassword)
|
||||
for _, out := range []string{fmt.Sprint(sa), fmt.Sprintf("%v %+v %#v %s", sa, sa, sa, sa), fmt.Sprint(&sa)} {
|
||||
if strings.Contains(out, testSuperPassword) {
|
||||
t.Fatalf("formatted output leaks password: %s", out)
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(struct{ SuperAdmin SuperAdmin }{sa})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), testSuperPassword) || !strings.Contains(string(raw), `"passwordConfigured":true`) {
|
||||
t.Fatalf("json leaks password or lost status: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The unauthenticated embedded single-user mode must not expose any account
|
||||
// administration operation (R1), regardless of request fields.
|
||||
func TestUnauthenticatedModeBlocksAccountAdministration(t *testing.T) {
|
||||
srv, st := newTestServer(t)
|
||||
if srv.Auth != nil && srv.Auth.Enabled {
|
||||
t.Fatal("expected unauthenticated test server")
|
||||
}
|
||||
h := srv.Handler()
|
||||
routes := []struct{ method, path, body string }{
|
||||
{http.MethodGet, "/api/admin/users", ""},
|
||||
{http.MethodGet, "/api/admin/users/search?q=a", ""},
|
||||
{http.MethodPost, "/api/admin/users", `{"owner":"mallory","isAdmin":true,"role":"admin"}`},
|
||||
{http.MethodGet, "/api/admin/users/usr_x", ""},
|
||||
{http.MethodPatch, "/api/admin/users/usr_x/profile", `{"expectedProfileVersion":1,"patch":{"name":"x"}}`},
|
||||
{http.MethodPut, "/api/admin/users/usr_x/state", `{"enabled":false}`},
|
||||
{http.MethodPost, "/api/admin/users/root/password", `{"password":"Another-Pass-1"}`},
|
||||
{http.MethodPost, "/api/admin/users/usr_x/recovery-proposals", `{}`},
|
||||
{http.MethodGet, "/api/admin/permissions/catalog", ""},
|
||||
{http.MethodGet, "/api/admin/roles", ""},
|
||||
{http.MethodPost, "/api/admin/roles", `{"name":"x"}`},
|
||||
{http.MethodPut, "/api/admin/users/usr_x/role", `{"roleId":"admin"}`},
|
||||
{http.MethodGet, "/api/admin/users/usr_x/system-grants", ""},
|
||||
{http.MethodPut, "/api/admin/users/usr_x/system-grants", `{"actions":["system.users.create"]}`},
|
||||
}
|
||||
for _, route := range routes {
|
||||
req := httptest.NewRequest(route.method, route.path, strings.NewReader(route.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s: status=%d body=%s; want 403", route.method, route.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if users, err := st.ListUsersAdmin(); err != nil || len(users) != 0 {
|
||||
t.Fatalf("unauthenticated request created accounts: %d err=%v", len(users), err)
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,10 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ValidatePassword is the authentication boundary's password policy, shared
|
||||
// with startup validation of the configured super administrator.
|
||||
func ValidatePassword(pw string) error { return validPassword(pw) }
|
||||
|
||||
func validPassword(pw string) error {
|
||||
if len(pw) < 8 {
|
||||
return errBadPassword
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
authzsvc "agent-system-backend/internal/authz"
|
||||
"agent-system-backend/internal/store"
|
||||
@@ -11,6 +12,10 @@ import (
|
||||
func (s *Server) requireSystemAction(w http.ResponseWriter, r *http.Request, actionID string) (string, bool) {
|
||||
actor := s.owner(r)
|
||||
if s.Auth == nil || !s.Auth.Enabled {
|
||||
if isAccountAdministrationAction(actionID) {
|
||||
http.Error(w, "account administration requires authentication", http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
return actor, true
|
||||
}
|
||||
decision := s.authorizer.AuthorizeSystem(actor, actionID)
|
||||
@@ -21,6 +26,17 @@ func (s *Server) requireSystemAction(w http.ResponseWriter, r *http.Request, act
|
||||
return actor, true
|
||||
}
|
||||
|
||||
// isAccountAdministrationAction covers user, role and permission management;
|
||||
// the unauthenticated embedded mode must not expose any of them (R1).
|
||||
func isAccountAdministrationAction(actionID string) bool {
|
||||
for _, prefix := range []string{"system.users.", "system.roles.", "system.permissions."} {
|
||||
if strings.HasPrefix(actionID, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) listPermissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAction(w, r, "system.permissions.view"); !ok {
|
||||
return
|
||||
|
||||
@@ -58,6 +58,7 @@ type Account struct {
|
||||
Enabled bool
|
||||
StateVersion int64
|
||||
IsAdmin bool
|
||||
IsSuperAdmin bool
|
||||
HasPassword bool
|
||||
MustChangePassword bool
|
||||
AuthVersion int
|
||||
@@ -78,7 +79,7 @@ type AccountCursorKey struct {
|
||||
}
|
||||
|
||||
const accountColumns = `id,owner,COALESCE(name,''),phone,email,staff_no,department,notes,
|
||||
profile_version,enabled,state_version,is_admin,password_hash<>'',must_change_password,
|
||||
profile_version,enabled,state_version,is_admin,is_super_admin,password_hash<>'',must_change_password,
|
||||
auth_version,credential_origin,initialization_state,initialization_generation`
|
||||
|
||||
type accountQuerier interface {
|
||||
@@ -91,7 +92,7 @@ func scanAccount(row accountScanner) (Account, error) {
|
||||
var a Account
|
||||
err := row.Scan(&a.ID, &a.Owner, &a.Profile.Name, &a.Profile.Phone, &a.Profile.Email,
|
||||
&a.Profile.StaffNo, &a.Profile.Department, &a.Profile.Notes, &a.ProfileVersion,
|
||||
&a.Enabled, &a.StateVersion, &a.IsAdmin, &a.HasPassword, &a.MustChangePassword,
|
||||
&a.Enabled, &a.StateVersion, &a.IsAdmin, &a.IsSuperAdmin, &a.HasPassword, &a.MustChangePassword,
|
||||
&a.AuthVersion, &a.CredentialOrigin, &a.InitializationState, &a.InitializationGeneration)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
|
||||
@@ -1207,6 +1207,24 @@ func init() {
|
||||
up: map[string][]string{"sqlite": roleAuthorityImportStageUp, "postgres": roleAuthorityImportStageUp},
|
||||
down: map[string][]string{"sqlite": {`DROP TABLE role_authority_import_state`}, "postgres": {`DROP TABLE role_authority_import_state`}},
|
||||
},
|
||||
migration{version: 51, name: "super_admin_identity",
|
||||
// 超级管理员是独立的持久标记(KTD1):不从 is_admin/角色/成员资格推断;
|
||||
// 部分唯一索引在库层保证至多一个标记行。
|
||||
up: map[string][]string{
|
||||
"sqlite": {
|
||||
`ALTER TABLE users ADD COLUMN is_super_admin INTEGER NOT NULL DEFAULT 0`,
|
||||
`CREATE UNIQUE INDEX idx_users_single_super_admin ON users(is_super_admin) WHERE is_super_admin=1`,
|
||||
},
|
||||
"postgres": {
|
||||
`ALTER TABLE users ADD COLUMN is_super_admin BOOLEAN NOT NULL DEFAULT FALSE`,
|
||||
`CREATE UNIQUE INDEX idx_users_single_super_admin ON users(is_super_admin) WHERE is_super_admin`,
|
||||
},
|
||||
},
|
||||
down: map[string][]string{
|
||||
"sqlite": {`DROP INDEX IF EXISTS idx_users_single_super_admin`, `ALTER TABLE users DROP COLUMN is_super_admin`},
|
||||
"postgres": {`DROP INDEX IF EXISTS idx_users_single_super_admin`, `ALTER TABLE users DROP COLUMN is_super_admin`},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ var migrationBreaksOldReaders = map[int]bool{
|
||||
48: false, // document_render_deliveries:新增普通工作区文档交付回执表
|
||||
49: false, // 角色权威仅新增独立表;消费者切换由公开提供者 readiness 单独围栏
|
||||
50: false, // 角色导入 staging 为独立表;正式角色读取在激活前不变
|
||||
51: true, // 超级管理员标记是账号权威边界;不认识它的旧账号写者必须排除
|
||||
}
|
||||
|
||||
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrSuperAdminConflict means persisted account state cannot be reconciled
|
||||
// with the configured super administrator without promoting, replacing or
|
||||
// duplicating an identity. Startup must stop instead of repairing it.
|
||||
var ErrSuperAdminConflict = errors.New("store: super administrator identity conflict")
|
||||
|
||||
const superAdminBootstrapActor = "startup-config:super-admin"
|
||||
|
||||
// SuperAdminBootstrap reports what startup reconciliation did. It never
|
||||
// carries credential material.
|
||||
type SuperAdminBootstrap struct {
|
||||
Account Account
|
||||
Created bool
|
||||
}
|
||||
|
||||
// BootstrapSuperAdmin establishes the single configured super administrator
|
||||
// (KTD4). An absent account is created with the marker, administrator flag and
|
||||
// passwordHash in one audited transaction. A matching marked account succeeds
|
||||
// without touching its credentials, except that an account without any
|
||||
// password receives passwordHash. Every other state is a fail-closed conflict:
|
||||
// the owner held by an unmarked account, a marker on a different owner, or more
|
||||
// than one marker. The marker is never derived from is_admin or roles.
|
||||
func (s *Store) BootstrapSuperAdmin(owner, passwordHash string) (SuperAdminBootstrap, error) {
|
||||
if passwordHash == "" {
|
||||
return SuperAdminBootstrap{}, fmt.Errorf("%w: password hash is required", ErrSuperAdminConflict)
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.dialect.lockBootstrap(tx); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
if err := s.dialect.lockAccount(tx, owner); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
rows, err := tx.Query(s.q(`SELECT owner FROM users WHERE is_super_admin=? ORDER BY owner`), true)
|
||||
if err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
var marked []string
|
||||
for rows.Next() {
|
||||
var markedOwner string
|
||||
if err := rows.Scan(&markedOwner); err != nil {
|
||||
rows.Close()
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
marked = append(marked, markedOwner)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
switch {
|
||||
case len(marked) > 1:
|
||||
return SuperAdminBootstrap{}, fmt.Errorf("%w: %d accounts carry the super-admin marker", ErrSuperAdminConflict, len(marked))
|
||||
case len(marked) == 1 && marked[0] != owner:
|
||||
return SuperAdminBootstrap{}, fmt.Errorf("%w: another account already carries the super-admin marker", ErrSuperAdminConflict)
|
||||
case len(marked) == 1:
|
||||
account, err := s.accountByOwner(tx, owner)
|
||||
if err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
if !account.IsAdmin {
|
||||
return SuperAdminBootstrap{}, fmt.Errorf("%w: marked account lacks administrator flag", ErrSuperAdminConflict)
|
||||
}
|
||||
if account.HasPassword {
|
||||
return SuperAdminBootstrap{Account: account}, nil
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,credential_origin=?,initialization_state=? WHERE id=? AND password_hash=''`),
|
||||
passwordHash, nowISO(), false, CredentialOriginOwnerInitialization, InitializationInitialized, account.ID); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, superAdminBootstrapActor, "startup-config", owner, "super_admin_password_initialized", account.ID); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
if err := s.auditAccountMutation(tx, owner, superAdminBootstrapActor, "account.super_admin_initialize"); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
if account, err = s.accountByOwner(tx, owner); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
return SuperAdminBootstrap{Account: account}, nil
|
||||
}
|
||||
if _, err := s.accountByOwner(tx, owner); err == nil {
|
||||
return SuperAdminBootstrap{}, fmt.Errorf("%w: configured owner belongs to an existing account without the super-admin marker", ErrSuperAdminConflict)
|
||||
} else if !errors.Is(err, ErrNotFound) {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
account, err := s.insertPendingAccountTx(tx, owner, AccountProfile{Name: owner})
|
||||
if err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,is_super_admin=?,password_hash=?,password_set_at=?,must_change_password=?,
|
||||
credential_origin=?,initialization_state=?,initialization_generation=? WHERE id=?`),
|
||||
true, true, passwordHash, nowISO(), false, CredentialOriginOwnerInitialization, InitializationInitialized, 1, account.ID); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return SuperAdminBootstrap{}, fmt.Errorf("%w: super-admin marker already taken", ErrSuperAdminConflict)
|
||||
}
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
if err := s.seedSystemAdminGrantsTx(tx, owner); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, superAdminBootstrapActor, "startup-config", owner, "super_admin_bootstrapped", account.ID); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
if err := s.auditAccountMutation(tx, owner, superAdminBootstrapActor, "account.super_admin_bootstrap"); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
if account, err = s.accountByOwner(tx, owner); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return SuperAdminBootstrap{}, err
|
||||
}
|
||||
return SuperAdminBootstrap{Account: account, Created: true}, nil
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const superAdminTestPassword = "Sup3r-Secret-Value"
|
||||
|
||||
func superAdminHash(t *testing.T, password string) string {
|
||||
t.Helper()
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
func countSuperAdmins(t *testing.T, st *Store) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM users WHERE is_super_admin=?`), true).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func storedPasswordHash(t *testing.T, st *Store, owner string) string {
|
||||
t.Helper()
|
||||
var hash string
|
||||
if err := st.db.QueryRow(st.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
func TestSuperAdminMigrationAddsDefaultFalseMarker(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateAccount("legacy-admin", "", superAdminHash(t, "legacy-pass-1"), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, err := st.GetAccountByOwner("legacy-admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !account.IsAdmin || account.IsSuperAdmin {
|
||||
t.Fatalf("legacy admin must not be inferred as super admin: %+v", account)
|
||||
}
|
||||
if !migrationBreaksOldReaders[51] {
|
||||
t.Fatal("v51 must exclude old account writers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminFirstAndRepeated(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
out, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := out.Account
|
||||
if !out.Created || !a.IsSuperAdmin || !a.IsAdmin || !a.Enabled || !a.HasPassword || a.MustChangePassword {
|
||||
t.Fatalf("first bootstrap account: %+v", out)
|
||||
}
|
||||
if a.InitializationState != InitializationInitialized {
|
||||
t.Fatalf("initialization state = %q", a.InitializationState)
|
||||
}
|
||||
firstHash := storedPasswordHash(t, st, "root")
|
||||
if !VerifyPassword(firstHash, superAdminTestPassword) {
|
||||
t.Fatal("initial password hash does not verify")
|
||||
}
|
||||
if strings.Contains(firstHash, superAdminTestPassword) {
|
||||
t.Fatal("password stored in plaintext")
|
||||
}
|
||||
if n, err := st.CountAudit("root", "success"); err != nil || n != 1 {
|
||||
t.Fatalf("bootstrap audit count=%d err=%v", n, err)
|
||||
}
|
||||
var grants int
|
||||
if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM system_grants WHERE user_id=? AND allowed=?`), a.ID, true).Scan(&grants); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if grants == 0 {
|
||||
t.Fatal("super admin received no administrator system grants")
|
||||
}
|
||||
|
||||
again, err := st.BootstrapSuperAdmin("root", superAdminHash(t, "a-different-password"))
|
||||
if err != nil {
|
||||
t.Fatalf("repeated bootstrap: %v", err)
|
||||
}
|
||||
if again.Created || again.Account.ID != a.ID || !again.Account.IsSuperAdmin {
|
||||
t.Fatalf("repeated bootstrap not idempotent: %+v", again)
|
||||
}
|
||||
if storedPasswordHash(t, st, "root") != firstHash {
|
||||
t.Fatal("repeated bootstrap replaced existing credentials")
|
||||
}
|
||||
if n, _ := st.CountAudit("root", "success"); n != 1 {
|
||||
t.Fatalf("repeated bootstrap wrote audit rows: %d", n)
|
||||
}
|
||||
if countSuperAdmins(t, st) != 1 {
|
||||
t.Fatal("repeated bootstrap changed marker count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminRejectsConflictingOwner(t *testing.T) {
|
||||
for _, isAdmin := range []bool{false, true} {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateAccount("root", "", superAdminHash(t, "existing-pass"), isAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := storedPasswordHash(t, st, "root")
|
||||
_, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword))
|
||||
if !errors.Is(err, ErrSuperAdminConflict) {
|
||||
t.Fatalf("isAdmin=%v: conflicting owner accepted: %v", isAdmin, err)
|
||||
}
|
||||
account, err := st.GetAccountByOwner("root")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account.IsSuperAdmin || account.IsAdmin != isAdmin || storedPasswordHash(t, st, "root") != before {
|
||||
t.Fatalf("isAdmin=%v: conflicting account was modified: %+v", isAdmin, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminRejectsMarkerOnAnotherOwner(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if _, err := st.BootstrapSuperAdmin("first-root", superAdminHash(t, superAdminTestPassword)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := st.BootstrapSuperAdmin("second-root", superAdminHash(t, superAdminTestPassword))
|
||||
if !errors.Is(err, ErrSuperAdminConflict) {
|
||||
t.Fatalf("second super admin accepted: %v", err)
|
||||
}
|
||||
if _, err := st.GetAccountByOwner("second-root"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("rejected bootstrap left an account behind: %v", err)
|
||||
}
|
||||
if countSuperAdmins(t, st) != 1 {
|
||||
t.Fatal("marker count changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminMarkerIsUniqueInSchema(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if _, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("other", "", superAdminHash(t, "other-pass-1"), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`UPDATE users SET is_super_admin=? WHERE owner=?`), true, "other"); err == nil {
|
||||
t.Fatal("schema accepted a second super-admin marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminRejectsDuplicateMarkers(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
for _, owner := range []string{"root", "other"} {
|
||||
if err := st.CreateAccount(owner, "", superAdminHash(t, "existing-pass"), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := st.db.Exec(`DROP INDEX idx_users_single_super_admin`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`UPDATE users SET is_super_admin=?`), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword))
|
||||
if !errors.Is(err, ErrSuperAdminConflict) || !strings.Contains(err.Error(), "2 accounts") {
|
||||
t.Fatalf("duplicate markers accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminLegacyAdminsStayOrdinary(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateAccount("legacy-admin", "", superAdminHash(t, "legacy-pass-1"), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword))
|
||||
if err != nil {
|
||||
t.Fatalf("legacy database with admins but no super admin: %v", err)
|
||||
}
|
||||
if !out.Created || !out.Account.IsSuperAdmin {
|
||||
t.Fatalf("super admin not established: %+v", out)
|
||||
}
|
||||
legacy, err := st.GetAccountByOwner("legacy-admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !legacy.IsAdmin || legacy.IsSuperAdmin {
|
||||
t.Fatalf("legacy admin changed: %+v", legacy)
|
||||
}
|
||||
if countSuperAdmins(t, st) != 1 {
|
||||
t.Fatal("expected exactly one super admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminInitializesMarkedAccountWithoutPassword(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateAccount("root", "", "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`UPDATE users SET is_super_admin=? WHERE owner=?`), true, "root"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Created || !out.Account.HasPassword || !VerifyPassword(storedPasswordHash(t, st, "root"), superAdminTestPassword) {
|
||||
t.Fatalf("uninitialized super admin not given initial password: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapSuperAdminRejectsMarkedNonAdmin(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateAccount("root", "", superAdminHash(t, "existing-pass"), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`UPDATE users SET is_super_admin=? WHERE owner=?`), true, "root"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)); !errors.Is(err, ErrSuperAdminConflict) {
|
||||
t.Fatalf("marked non-admin accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuperAdminMigrationDown(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if _, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.MigrateDown(50); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(`SELECT is_super_admin FROM users`); err == nil {
|
||||
t.Fatal("v51 down left is_super_admin column")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user