diff --git a/cmd/agent-system/account_io_test.go b/cmd/agent-system/account_io_test.go index c703ad5..7601a0a 100644 --- a/cmd/agent-system/account_io_test.go +++ b/cmd/agent-system/account_io_test.go @@ -44,13 +44,11 @@ func cliStore(t *testing.T) (string, *store.Store) { return dir, s } func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) { - for _, sub := range []string{"create", "initialize-account", "bootstrap-admin"} { + for _, sub := range []string{"create", "initialize-account"} { 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) - } + 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} diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index f5f43fc..437e791 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -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, @@ -422,7 +433,7 @@ func (out *credentialOutput) deliver(kind, owner, plaintext string, expiresAt an // runUser:本地 owner 账号初始化、续办和只读/撤销操作。 func runUser(args []string) int { if len(args) == 0 { - fmt.Fprintln(os.Stderr, "用法:agent-system user [flags]") + fmt.Fprintln(os.Stderr, "用法:agent-system user [flags]") return 2 } sub := args[0] @@ -439,8 +450,8 @@ func runUser(args []string) int { 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") + admin := fs.Bool("admin", false, "管理员(现已拒绝:仅超级管理员可在服务端任命管理员)") + role := fs.String("role", "user", "角色 user|admin(admin 现已拒绝)") openExisting := fs.Bool("open-existing", false, "兼容旧参数;账户命令始终只打开既有存储") generatePassword := fs.Bool("generate-password", false, "旧随机初始密码参数(现已拒绝)") fs.String("purpose", "recover", "旧 issue-login-key 参数(现已拒绝)") @@ -457,6 +468,16 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only") return 2 } + // No CLI path may appoint an administrator or establish the super + // administrator (R4, R5); reject before storage opens or output reservation. + if sub == "create" && (*admin || *role == "admin") { + fmt.Fprintln(os.Stderr, "user create:", app.ErrCLIAdminAppointment) + return 2 + } + if sub == "bootstrap-admin" { + fmt.Fprintln(os.Stderr, "user bootstrap-admin:", app.ErrCLIBootstrapAdminDisabled) + return 2 + } resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile) if err != nil { fmt.Fprintln(os.Stderr, "user:", err) @@ -472,7 +493,7 @@ func runUser(args []string) int { } *dsn = resolvedDSN *password = resolvedPassword - credentialAction := sub == "create" || sub == "initialize-account" || sub == "bootstrap-admin" + credentialAction := sub == "create" || sub == "initialize-account" if credentialAction && *outFile == "" && *out == "" { fmt.Fprintln(os.Stderr, "user: --out or --out-file is required for protected credential delivery") return 2 @@ -557,21 +578,6 @@ func runUser(args []string) int { } fmt.Println("revoked") return 0 - case "bootstrap-admin": - if *owner == "" { - fmt.Fprintln(os.Stderr, "user bootstrap-admin: --owner 必填") - return 2 - } - plain, err := app.BootstrapAdmin(*dsn, *data, *owner, *openExisting) - if err != nil { - fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err) - return 1 - } - 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 - } - return 0 default: fmt.Fprintln(os.Stderr, "user: 未知子命令", sub) return 2 diff --git a/cmd/agent-system/user_contract_test.go b/cmd/agent-system/user_contract_test.go index bced563..9c8fb0d 100644 --- a/cmd/agent-system/user_contract_test.go +++ b/cmd/agent-system/user_contract_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "agent-system-backend/internal/store" ) func TestLegacyUserCredentialCommandsStopBeforeOpeningStorage(t *testing.T) { @@ -29,6 +31,62 @@ func TestLegacyUserCredentialCommandsStopBeforeOpeningStorage(t *testing.T) { } } +func TestUserCLIRejectsAdministratorAppointmentBeforeOpeningStorage(t *testing.T) { + data := t.TempDir() + out := filepath.Join(data, "delivery.json") + for _, args := range [][]string{ + {"create", "--data", data, "--owner", "mallory", "--admin", "--out", out}, + {"create", "--data", data, "--owner", "mallory", "--role", "admin", "--out", out}, + {"bootstrap-admin", "--data", data, "--owner", "mallory", "--out", out}, + } { + if code := runUser(args); code != 2 { + t.Fatalf("administrator appointment accepted: %v code=%d", args, code) + } + } + if _, err := os.Stat(filepath.Join(data, "agent-system.db")); !os.IsNotExist(err) { + t.Fatalf("rejected commands opened/created a database: %v", err) + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatalf("rejected commands wrote a delivery file: %v", err) + } +} + +func TestUserCLIAdministratorAppointmentLeavesExistingStoreUnchanged(t *testing.T) { + dir, s := cliStore(t) + if _, err := s.BootstrapSuperAdmin("root", mustHash(t, "Root-Password-1")); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"create", "--data", dir, "--owner", "mallory", "--admin", "--out-file", filepath.Join(t.TempDir(), "a")}, + {"create", "--data", dir, "--owner", "mallory", "--role", "admin", "--out-file", filepath.Join(t.TempDir(), "b")}, + {"bootstrap-admin", "--data", dir, "--owner", "mallory", "--out-file", filepath.Join(t.TempDir(), "c")}, + } { + rc, output := accountCLI(t, args...) + if rc != 2 || !strings.Contains(output, "super administrator") { + t.Fatalf("%v: rc=%d output=%q", args, rc, output) + } + } + if _, err := s.GetUser("mallory"); err == nil { + t.Fatal("rejected CLI appointment created an account") + } + rc, _ := accountCLI(t, "create", "--data", dir, "--owner", "ada", "--out-file", filepath.Join(t.TempDir(), "ada")) + if rc != 0 { + t.Fatalf("ordinary local create failed: %d", rc) + } + if ada, err := s.GetAccountByOwner("ada"); err != nil || ada.IsAdmin || ada.IsSuperAdmin { + t.Fatalf("ordinary local create: %+v %v", ada, err) + } +} + +func mustHash(t *testing.T, password string) string { + t.Helper() + hash, err := store.HashPassword(password) + if err != nil { + t.Fatal(err) + } + return hash +} + func TestCredentialDeliveryCreatesExclusive0600File(t *testing.T) { dir := t.TempDir() out := filepath.Join(dir, "credential.json") diff --git a/internal/app/app.go b/internal/app/app.go index 202b399..175ae5c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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)", @@ -751,6 +760,7 @@ func Run(opt Options) error { // 认证策略(auto):PG 档默认开启(多用户面不能裸奔);嵌入式档默认关闭(本地单用户)。 srv.Auth = server.NewAuthenticator(st, authOn) if authOn { + srv.Auth.SetSuperAdminFallback(opt.SuperAdmin.Owner, opt.SuperAdmin.Password()) log.Printf("auth: API key required (create with: agent-system apikey create --owner )") } srv.Subscribe = func(conversationID string) (<-chan core.StreamEvent, func()) { @@ -840,6 +850,9 @@ func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmi if password != "" || generatePassword { return "", "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only") } + if isAdmin { + return "", "", ErrCLIAdminAppointment + } var out store.AccountOutcome err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { @@ -885,19 +898,10 @@ func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error // BootstrapAdmin 首位管理员引导:单事务串行(KTD12——PG 咨询锁/SQLite 插入后复查), // 建号、激活 Key 与审计共同提交;库内已有用户即拒绝接管。 +// 现已停用:超级管理员只由服务启动按 SUPER_ADMIN_OWNER/SUPER_ADMIN_PASSWORD +// 引导,其余管理员只由超级管理员在服务端任命;此入口在打开存储前拒绝。 func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) { - var plain string - err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { - return err - } - outc, err := st.BootstrapFirstAdminWrite(store.AccountWrite{ - Owner: owner, Name: owner, Actor: cliActor(), InstanceID: accountInstanceID(), - }) - plain = outc.Plain - return err - }) - return plain, err + return "", ErrCLIBootstrapAdminDisabled } // ResetUserPassword CLI 重置密码:单事务写 must_change=1、bump 认证版本、 diff --git a/internal/app/local_account_test.go b/internal/app/local_account_test.go index 5130c69..51da6d4 100644 --- a/internal/app/local_account_test.go +++ b/internal/app/local_account_test.go @@ -17,11 +17,11 @@ func TestLocalOwnerAccountCreationAndReissuePreserveIdentity(t *testing.T) { if err := seed.EnsureSystemIdentity("test-local-owner", "sqlite"); err != nil { t.Fatal(err) } - seed.Close() - bootstrap, err := BootstrapAdmin("", dir, "root", false) + bootstrap, err := seed.BootstrapFirstAdmin("root", "root", "test") if err != nil || bootstrap == "" { t.Fatalf("bootstrap: %v", err) } + seed.Close() first, _, err := CreateUserAccount("", dir, "alice", "", "", false, true, false) if err != nil || first == "" { t.Fatalf("local pending create: %v", err) @@ -65,14 +65,14 @@ func TestAPIKeyCLIRequiresExistingInitializedAccount(t *testing.T) { if err := st.EnsureSystemIdentity("test-api-key-admission", "sqlite"); err != nil { t.Fatal(err) } - st.Close() if _, err := CreateAPIKey("", dir, "alice", "integration"); !errors.Is(err, store.ErrNotFound) { t.Fatalf("missing account was implicitly created: %v", err) } - grant, err := BootstrapAdmin("", dir, "alice", true) + grant, err := st.BootstrapFirstAdmin("alice", "alice", "test") if err != nil { t.Fatal(err) } + st.Close() if _, err := CreateAPIKey("", dir, "alice", "integration"); !errors.Is(err, store.ErrAccountForbidden) { t.Fatalf("pending account received a full API key: %v", err) } diff --git a/internal/app/super_admin.go b/internal/app/super_admin.go new file mode 100644 index 0000000..7392153 --- /dev/null +++ b/internal/app/super_admin.go @@ -0,0 +1,58 @@ +package app + +import ( + "errors" + "fmt" + "log" + + "agent-system-backend/internal/config" + "agent-system-backend/internal/server" + "agent-system-backend/internal/store" +) + +// ErrCLIAdminAppointment rejects every local CLI attempt to create an +// administrator (R4, R5): only the server-side super administrator appoints. +var ErrCLIAdminAppointment = errors.New("administrator appointment is reserved to the super administrator; no CLI path may create an administrator") + +// ErrCLIBootstrapAdminDisabled rejects the retired local bootstrap-admin path: +// only service startup establishes the super administrator (KTD4). +var ErrCLIBootstrapAdminDisabled = errors.New("bootstrap-admin is disabled; the super administrator is established at service startup from SUPER_ADMIN_OWNER and SUPER_ADMIN_PASSWORD") + +// 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 +} diff --git a/internal/app/super_admin_test.go b/internal/app/super_admin_test.go new file mode 100644 index 0000000..96be25b --- /dev/null +++ b/internal/app/super_admin_test.go @@ -0,0 +1,129 @@ +package app + +import ( + "bytes" + "errors" + "log" + "os" + "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)) + } +} + +func TestCLIAccountPathsCannotAppointAdministrators(t *testing.T) { + dir := t.TempDir() + if _, _, err := CreateUserAccount("", dir, "mallory", "", "", true, true, false); !errors.Is(err, ErrCLIAdminAppointment) { + t.Fatalf("CLI create --admin: %v", err) + } + if _, err := BootstrapAdmin("", dir, "mallory", false); !errors.Is(err, ErrCLIBootstrapAdminDisabled) { + t.Fatalf("CLI bootstrap-admin: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "agent-system.db")); !os.IsNotExist(err) { + t.Fatalf("rejected CLI paths touched storage: %v", err) + } +} diff --git a/internal/config/superadmin.go b/internal/config/superadmin.go new file mode 100644 index 0000000..a9800a6 --- /dev/null +++ b/internal/config/superadmin.go @@ -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=}", s.Owner) + } + return fmt.Sprintf("SuperAdmin{owner=%q password=}", 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 +} diff --git a/internal/config/superadmin_test.go b/internal/config/superadmin_test.go new file mode 100644 index 0000000..dbbae40 --- /dev/null +++ b/internal/config/superadmin_test.go @@ -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) + } +} diff --git a/internal/server/account_admin_unauthenticated_test.go b/internal/server/account_admin_unauthenticated_test.go new file mode 100644 index 0000000..bd95473 --- /dev/null +++ b/internal/server/account_admin_unauthenticated_test.go @@ -0,0 +1,47 @@ +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.MethodPut, "/api/admin/users/usr_x/admin", `{"expectedStateVersion":1,"isAdmin":true}`}, + {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) + } +} diff --git a/internal/server/account_contract_test.go b/internal/server/account_contract_test.go index 429d519..5817175 100644 --- a/internal/server/account_contract_test.go +++ b/internal/server/account_contract_test.go @@ -116,10 +116,13 @@ func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) { for _, body := range []string{ `{"owner":"blocked-admin","isAdmin":true}`, } { - if rec := do(h, "POST", "/api/admin/users", body, adminCookie); rec.Code != http.StatusBadRequest { + if rec := do(h, "POST", "/api/admin/users", body, adminCookie); rec.Code != http.StatusForbidden { t.Fatalf("unsafe create accepted: %d %s", rec.Code, rec.Body.String()) } } + if _, err := st.GetUser("blocked-admin"); err != store.ErrNotFound { + t.Fatalf("unsafe create had side effect: %v", err) + } if _, err := st.GetUser("blocked-password"); err != store.ErrNotFound { t.Fatalf("unsafe create had side effect: %v", err) } diff --git a/internal/server/accounts.go b/internal/server/accounts.go index 7c92216..ad1982a 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -95,7 +95,7 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, maxA func authStateJSON(st store.UserAuthState) map[string]any { return map[string]any{ - "owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin, + "owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin, "isSuperAdmin": st.IsSuperAdmin, "mustChangePassword": st.MustChangePassword, "passwordSet": st.HasPassword, "auth": true, } @@ -167,7 +167,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests) return } - st, ver, ok := s.st.VerifyLoginWithVersion(owner, req.Password) + st, ver, ok := s.st.VerifyLoginWithFallback(owner, req.Password, s.Auth.fallbackCredential()) if !ok { loginFail(owner) http.Error(w, "invalid owner or password", http.StatusUnauthorized) @@ -250,7 +250,7 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { case store.PasswordChangeModeGrantSetup: _, completeErr = s.st.CompleteSetPasswordChecked(tok, hash) case store.PasswordChangeModeCurrentRequired: - _, completeErr = s.st.CompleteForcedPasswordChecked(tok, req.Current, hash) + _, completeErr = s.st.CompleteForcedPasswordCheckedWithFallback(tok, req.Current, hash, s.Auth.fallbackCredential()) default: http.Error(w, "unsupported password change mode", http.StatusForbidden) return @@ -274,7 +274,7 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) return } - if err := s.st.ChangeUserPasswordActive(owner, req.Current, hash, tok); err != nil { + if err := s.st.ChangeUserPasswordActiveWithFallback(owner, req.Current, hash, tok, s.Auth.fallbackCredential()); err != nil { if errors.Is(err, store.ErrBadCredentials) { http.Error(w, "current password incorrect", http.StatusUnauthorized) return @@ -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 @@ -310,6 +314,22 @@ func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, b return owner, true } +// requireSuperAdmin admits only the enabled super administrator (R3). The +// store rechecks the same identity inside the write transaction. +func (s *Server) requireSuperAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { + if s.Auth == nil || !s.Auth.Enabled { + http.Error(w, "account administration requires authentication", http.StatusForbidden) + return "", false + } + owner := s.owner(r) + st, err := s.st.GetUserAuthState(owner) + if err != nil || !st.Enabled || !st.IsSuperAdmin { + http.Error(w, "super administrator required", http.StatusForbidden) + return "", false + } + return owner, true +} + func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) { if _, ok := s.requireSystemAction(w, r, "system.users.view"); !ok { return @@ -327,6 +347,8 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { Owner string `json:"owner"` Profile store.AccountProfile `json:"profile"` Password *string `json:"password"` + IsAdmin *bool `json:"isAdmin"` + Role string `json:"role"` } if err := decodeStrictJSON(r, &req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -337,6 +359,21 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { http.Error(w, "owner required (no spaces or slashes)", http.StatusBadRequest) return } + isAdmin := req.IsAdmin != nil && *req.IsAdmin + switch req.Role { + case "", "user": + case "admin": + isAdmin = true + default: + http.Error(w, "role must be user or admin", http.StatusBadRequest) + return + } + if isAdmin { + if st, err := s.st.GetUserAuthState(actor); err != nil || !st.Enabled || !st.IsSuperAdmin { + http.Error(w, "super administrator required to appoint administrators", http.StatusForbidden) + return + } + } password := RandomPassword() generated := req.Password == nil if req.Password != nil { @@ -351,7 +388,7 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - account, err := s.st.CreateAdminPasswordAccount(actor, req.Owner, req.Profile, hash) + account, err := s.st.CreatePasswordAccountWithAuthority(actor, req.Owner, req.Profile, hash, isAdmin) if err != nil { writeStoreErr(w, err) return @@ -368,7 +405,7 @@ func accountProfileJSON(account store.Account) map[string]any { return map[string]any{ "userId": account.ID, "owner": account.Owner, "profile": account.Profile, "profileVersion": account.ProfileVersion, "enabled": account.Enabled, - "stateVersion": account.StateVersion, "isAdmin": account.IsAdmin, + "stateVersion": account.StateVersion, "isAdmin": account.IsAdmin, "isSuperAdmin": account.IsSuperAdmin, "passwordSet": account.HasPassword, "mustChangePassword": account.MustChangePassword, "initializationState": account.InitializationState, "initializationGeneration": account.InitializationGeneration, @@ -483,6 +520,29 @@ func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, accountProfileJSON(account)) } +// setAdminAccountAdmin:仅超级管理员任命或撤销普通系统管理员(R3/R4); +// 超级管理员账户本身不可被降级(R5)。 +func (s *Server) setAdminAccountAdmin(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireSuperAdmin(w, r) + if !ok { + return + } + var req struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + IsAdmin *bool `json:"isAdmin"` + } + if err := decodeStrictJSON(r, &req); err != nil || req.IsAdmin == nil { + http.Error(w, "invalid administrator change", http.StatusBadRequest) + return + } + account, err := s.st.SetAccountAdmin(actor, r.PathValue("userId"), req.ExpectedStateVersion, *req.IsAdmin) + if err != nil { + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, accountProfileJSON(account)) +} + // resetAdminUserPassword:管理员生成随机临时密码。单事务:must_change=1、bump // auth_version、作废未用登录 Key、吊销全部会话、审计。 func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) { @@ -501,7 +561,7 @@ func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) http.Error(w, err.Error(), http.StatusInternalServerError) return } - if err := s.st.AdminResetUserPassword(r.PathValue("owner"), hash, actor, "admin-http"); err != nil { + if err := s.st.AdminResetUserPasswordChecked(actor, r.PathValue("owner"), hash); err != nil { writeStoreErr(w, err) return } diff --git a/internal/server/auth.go b/internal/server/auth.go index 12bfa51..84f2471 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -2,6 +2,8 @@ package server import ( "context" + "crypto/sha256" + "crypto/subtle" "net/http" "strings" @@ -29,12 +31,43 @@ const ( type Authenticator struct { st *store.Store Enabled bool + + // superAdminOwner/superAdminDigest 是配置的超级管理员 fallback 凭据(KTD2): + // 只存口令 SHA-256 摘要于进程内存,不落库、不进日志与响应。 + superAdminOwner string + superAdminDigest [sha256.Size]byte } func NewAuthenticator(st *store.Store, enabled bool) *Authenticator { return &Authenticator{st: st, Enabled: enabled} } +// SetSuperAdminFallback installs the configured super administrator's +// environment password as an additional credential for that owner only. +// An empty owner or password leaves the fallback disabled. +func (a *Authenticator) SetSuperAdminFallback(owner, password string) { + if owner == "" || password == "" { + a.superAdminOwner, a.superAdminDigest = "", [sha256.Size]byte{} + return + } + a.superAdminOwner, a.superAdminDigest = owner, sha256.Sum256([]byte(password)) +} + +// fallbackCredential returns the verifier handed to store credential checks; +// nil when authentication is off or no fallback is configured. The store +// additionally requires the persisted super-admin marker and enabled state. +func (a *Authenticator) fallbackCredential() store.FallbackCredential { + if a == nil || !a.Enabled || a.superAdminOwner == "" { + return nil + } + wantOwner, wantDigest := a.superAdminOwner, a.superAdminDigest + return func(owner, password string) bool { + got := sha256.Sum256([]byte(password)) + passwordOK := subtle.ConstantTimeCompare(got[:], wantDigest[:]) == 1 + return owner == wantOwner && passwordOK + } +} + // SessionCookie 会话 cookie 名(HttpOnly;SSE 的 EventSource 同源自动携带)。 const SessionCookie = "as_session" diff --git a/internal/server/role_authorization_test.go b/internal/server/role_authorization_test.go index 570bd3a..e855a5b 100644 --- a/internal/server/role_authorization_test.go +++ b/internal/server/role_authorization_test.go @@ -58,7 +58,7 @@ func TestProjectCreateRequiresSystemOrRolePermission(t *testing.T) { func TestSystemUserActionsDoNotImplyRoleOrPermissionManagement(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - admin := bootAdmin(t, h, st) + admin := bootSuperAdmin(t, h, st) if err := st.CreateAccount("viewer", "Viewer", "", false); err != nil { t.Fatal(err) } @@ -102,7 +102,7 @@ func TestSystemUserActionsDoNotImplyRoleOrPermissionManagement(t *testing.T) { func TestDelegatedUserActionsReachTransactionalStoreChecks(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - admin := bootAdmin(t, h, st) + admin := bootSuperAdmin(t, h, st) if err := st.CreateAccount("user-manager", "User Manager", "", false); err != nil { t.Fatal(err) } diff --git a/internal/server/roles.go b/internal/server/roles.go index 5fdbfc0..6387a5c 100644 --- a/internal/server/roles.go +++ b/internal/server/roles.go @@ -11,6 +11,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 +25,12 @@ 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 { + return store.IsAccountAdministrationAction(actionID) +} + func (s *Server) listPermissionCatalog(w http.ResponseWriter, r *http.Request) { if _, ok := s.requireSystemAction(w, r, "system.permissions.view"); !ok { return diff --git a/internal/server/roles_test.go b/internal/server/roles_test.go index 5833d91..03c029e 100644 --- a/internal/server/roles_test.go +++ b/internal/server/roles_test.go @@ -92,7 +92,7 @@ func TestRoleHTTPCreateSearchPatchPermissionAndAssignment(t *testing.T) { func TestRoleHTTPUsesSpecificSystemActions(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - admin := bootAdmin(t, h, st) + admin := bootSuperAdmin(t, h, st) if err := st.CreateAccount("role-manager", "Role Manager", "", false); err != nil { t.Fatal(err) } diff --git a/internal/server/server.go b/internal/server/server.go index 8212776..e9f7919 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -314,6 +314,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/admin/users/{userId}", s.adminAccount) mux.HandleFunc("PATCH /api/admin/users/{userId}/profile", s.patchAdminAccountProfile) mux.HandleFunc("PUT /api/admin/users/{userId}/state", s.setAdminAccountState) + mux.HandleFunc("PUT /api/admin/users/{userId}/admin", s.setAdminAccountAdmin) mux.HandleFunc("POST /api/admin/users/{userId}/recovery-proposals", s.createAdminRecoveryProposal) mux.HandleFunc("GET /api/admin/users/{userId}/recovery-proposals", s.listAdminRecoveryProposals) mux.HandleFunc("GET /api/admin/users/{userId}/recovery-proposals/{id}", s.getAdminRecoveryProposal) @@ -907,6 +908,7 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { } if st, err := s.st.GetUserAuthState(owner); err == nil { out["isAdmin"] = st.IsAdmin + out["isSuperAdmin"] = st.IsSuperAdmin out["mustChangePassword"] = st.MustChangePassword out["passwordSet"] = st.HasPassword out["systemActions"] = s.authorizer.EffectiveSystemActions(owner) diff --git a/internal/server/super_admin_authority_test.go b/internal/server/super_admin_authority_test.go new file mode 100644 index 0000000..fe64ad0 --- /dev/null +++ b/internal/server/super_admin_authority_test.go @@ -0,0 +1,237 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "testing" + + "agent-system-backend/internal/store" +) + +const superAdminFixturePassword = "Super-Admin-Pass-1" + +// bootSuperAdmin establishes "admin" as the configured super administrator +// (as startup bootstrap does) and returns a full password session cookie. +func bootSuperAdmin(t *testing.T, h http.Handler, st *store.Store) string { + t.Helper() + hash, err := store.HashPassword(superAdminFixturePassword) + if err != nil { + t.Fatal(err) + } + if _, err := st.BootstrapSuperAdmin("admin", hash); err != nil { + t.Fatal(err) + } + return loginFixture(t, h, "admin", superAdminFixturePassword) +} + +// bootOrdinaryAdmin creates a legacy/ordinary administrator with a usable +// password session. +func bootOrdinaryAdmin(t *testing.T, h http.Handler, st *store.Store, owner string) string { + t.Helper() + hash, err := store.HashPassword("ordinary-admin-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount(owner, "", hash, true); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword(owner); err != nil { + t.Fatal(err) + } + return loginFixture(t, h, owner, "ordinary-admin-1") +} + +func loginFixture(t *testing.T, h http.Handler, owner, password string) string { + t.Helper() + loginClear(owner) + t.Cleanup(func() { loginClear(owner) }) + body, _ := json.Marshal(map[string]string{"owner": owner, "password": password}) + rec := do(h, "POST", "/api/auth/login", string(body), "") + if rec.Code != http.StatusOK { + t.Fatalf("login %s: %d %s", owner, rec.Code, rec.Body.String()) + } + cookie := cookieOf(rec) + if cookie == "" { + t.Fatalf("login %s: missing session cookie", owner) + } + return cookie +} + +func decodeAccountHTTP(t *testing.T, rec interface{ Bytes() []byte }) map[string]any { + t.Helper() + var out map[string]any + if err := json.Unmarshal(rec.Bytes(), &out); err != nil { + t.Fatal(err) + } + return out +} + +func stateVersionOf(t *testing.T, st *store.Store, owner string) (string, string) { + t.Helper() + account, err := st.GetAccountByOwner(owner) + if err != nil { + t.Fatal(err) + } + return account.ID, strconv.FormatInt(account.StateVersion, 10) +} + +func TestSuperAdminHTTPAppointsAdministrators(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + super := bootSuperAdmin(t, h, st) + + me := do(h, "GET", "/api/me", "", super) + if me.Code != http.StatusOK || decodeAccountHTTP(t, me.Body)["isSuperAdmin"] != true { + t.Fatalf("super admin /api/me: %d %s", me.Code, me.Body.String()) + } + if strings.Contains(me.Body.String(), superAdminFixturePassword) { + t.Fatal("/api/me leaked the super admin password") + } + + created := do(h, "POST", "/api/admin/users", `{"owner":"boss","isAdmin":true}`, super) + if created.Code != http.StatusOK { + t.Fatalf("super admin creates administrator: %d %s", created.Code, created.Body.String()) + } + out := decodeAccountHTTP(t, created.Body) + if out["isAdmin"] != true || out["isSuperAdmin"] != false { + t.Fatalf("created administrator projection: %v", out) + } + byRole := do(h, "POST", "/api/admin/users", `{"owner":"boss2","role":"admin"}`, super) + if byRole.Code != http.StatusOK || decodeAccountHTTP(t, byRole.Body)["isAdmin"] != true { + t.Fatalf("super admin creates administrator by role: %d %s", byRole.Code, byRole.Body.String()) + } + + if rec := do(h, "POST", "/api/admin/users", `{"owner":"carol"}`, super); rec.Code != http.StatusOK { + t.Fatalf("super admin creates user: %d %s", rec.Code, rec.Body.String()) + } + carolID, version := stateVersionOf(t, st, "carol") + promoted := do(h, "PUT", "/api/admin/users/"+carolID+"/admin", `{"expectedStateVersion":`+version+`,"isAdmin":true}`, super) + if promoted.Code != http.StatusOK || decodeAccountHTTP(t, promoted.Body)["isAdmin"] != true { + t.Fatalf("super admin promotes user: %d %s", promoted.Code, promoted.Body.String()) + } + _, version = stateVersionOf(t, st, "carol") + demoted := do(h, "PUT", "/api/admin/users/"+carolID+"/admin", `{"expectedStateVersion":`+version+`,"isAdmin":false}`, super) + if demoted.Code != http.StatusOK || decodeAccountHTTP(t, demoted.Body)["isAdmin"] != false { + t.Fatalf("super admin demotes administrator: %d %s", demoted.Code, demoted.Body.String()) + } + + list := do(h, "GET", "/api/admin/users", "", super) + if list.Code != http.StatusOK { + t.Fatalf("list users: %d %s", list.Code, list.Body.String()) + } + var users []map[string]any + if err := json.Unmarshal(list.Body.Bytes(), &users); err != nil { + t.Fatal(err) + } + for _, u := range users { + if u["isSuperAdmin"] != (u["owner"] == "admin") { + t.Fatalf("admin listing isSuperAdmin for %v = %v", u["owner"], u["isSuperAdmin"]) + } + } +} + +func TestOrdinaryAdminHTTPCannotGrantAdministrativeAuthority(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + super := bootSuperAdmin(t, h, st) + ops := bootOrdinaryAdmin(t, h, st, "ops") + + me := do(h, "GET", "/api/me", "", ops) + if me.Code != http.StatusOK || decodeAccountHTTP(t, me.Body)["isSuperAdmin"] != false { + t.Fatalf("ordinary admin /api/me: %d %s", me.Code, me.Body.String()) + } + for _, body := range []string{ + `{"owner":"sneaky","isAdmin":true}`, + `{"owner":"sneaky","role":"admin"}`, + `{"owner":"sneaky","isAdmin":false,"role":"admin"}`, + } { + if rec := do(h, "POST", "/api/admin/users", body, ops); rec.Code != http.StatusForbidden { + t.Fatalf("ordinary admin created administrator with %s: %d %s", body, rec.Code, rec.Body.String()) + } + } + if _, err := st.GetAccountByOwner("sneaky"); err != store.ErrNotFound { + t.Fatalf("rejected administrator creation left an account: %v", err) + } + created := do(h, "POST", "/api/admin/users", `{"owner":"dave","isAdmin":false}`, ops) + if created.Code != http.StatusOK || decodeAccountHTTP(t, created.Body)["isAdmin"] != false { + t.Fatalf("ordinary admin creates user: %d %s", created.Code, created.Body.String()) + } + daveID, version := stateVersionOf(t, st, "dave") + if rec := do(h, "PUT", "/api/admin/users/"+daveID+"/admin", `{"expectedStateVersion":`+version+`,"isAdmin":true}`, ops); rec.Code != http.StatusForbidden { + t.Fatalf("ordinary admin promoted a user: %d %s", rec.Code, rec.Body.String()) + } + epoch, _ := st.AuthorityEpoch() + epochText := strconv.FormatInt(epoch, 10) + if rec := do(h, "PUT", "/api/admin/users/"+daveID+"/system-grants", + `{"expectedAuthorityEpoch":`+epochText+`,"idempotencyKey":"ops-grant-dave","actions":["system.users.create"]}`, ops); rec.Code != http.StatusForbidden { + t.Fatalf("ordinary admin granted account administration: %d %s", rec.Code, rec.Body.String()) + } + opsID, _ := stateVersionOf(t, st, "ops") + if rec := do(h, "PUT", "/api/admin/users/"+opsID+"/system-grants", + `{"expectedAuthorityEpoch":`+epochText+`,"idempotencyKey":"ops-self","actions":[]}`, ops); rec.Code != http.StatusForbidden { + t.Fatalf("ordinary admin changed its own administrative grants: %d %s", rec.Code, rec.Body.String()) + } + if dave, _ := st.GetAccountByOwner("dave"); dave.IsAdmin { + t.Fatal("rejected requests elevated dave") + } + if actions, _, _ := st.SystemGrantsForUser(daveID); len(actions) != 0 { + t.Fatalf("rejected grant left actions: %v", actions) + } + if rec := do(h, "PUT", "/api/admin/users/"+daveID+"/system-grants", + `{"expectedAuthorityEpoch":`+epochText+`,"idempotencyKey":"super-grant-dave","actions":["system.users.view"]}`, super); rec.Code != http.StatusOK { + t.Fatalf("super admin grants account administration: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestOrdinaryAdminHTTPCannotMutateSuperAdmin(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + super := bootSuperAdmin(t, h, st) + ops := bootOrdinaryAdmin(t, h, st, "ops") + root, err := st.GetAccountByOwner("admin") + if err != nil { + t.Fatal(err) + } + version := strconv.FormatInt(root.StateVersion, 10) + role := do(h, "POST", "/api/admin/roles", `{"name":"普通职位"}`, ops) + if role.Code != http.StatusOK { + t.Fatalf("create role: %d %s", role.Code, role.Body.String()) + } + roleID := decodeRoleHTTP(t, role).RoleID + epoch, _ := st.AuthorityEpoch() + for _, route := range []struct{ method, path, body string }{ + {"PATCH", "/api/admin/users/" + root.ID + "/profile", `{"expectedProfileVersion":1,"patch":{"name":"renamed"}}`}, + {"PUT", "/api/admin/users/" + root.ID + "/state", `{"expectedStateVersion":` + version + `,"enabled":false}`}, + {"POST", "/api/admin/users/admin/password", `{}`}, + {"POST", "/api/admin/users/" + root.ID + "/recovery-proposals", `{"expectedStateVersion":` + version + `,"idempotencyKey":"ops-recover-root"}`}, + {"PUT", "/api/admin/users/" + root.ID + "/admin", `{"expectedStateVersion":` + version + `,"isAdmin":false}`}, + {"PUT", "/api/admin/users/" + root.ID + "/role", `{"roleId":"` + roleID + `","expectedStateVersion":` + version + `,"idempotencyKey":"ops-role-root"}`}, + {"PUT", "/api/admin/users/" + root.ID + "/system-grants", `{"expectedAuthorityEpoch":` + strconv.FormatInt(epoch, 10) + `,"idempotencyKey":"ops-grants-root","actions":[]}`}, + } { + if rec := do(h, route.method, route.path, route.body, ops); rec.Code != http.StatusForbidden { + t.Errorf("ordinary admin %s %s: %d %s; want 403", route.method, route.path, rec.Code, rec.Body.String()) + } + } + for _, route := range []struct{ method, path, body string }{ + {"PUT", "/api/admin/users/" + root.ID + "/state", `{"expectedStateVersion":` + version + `,"enabled":false}`}, + {"PUT", "/api/admin/users/" + root.ID + "/admin", `{"expectedStateVersion":` + version + `,"isAdmin":false}`}, + {"POST", "/api/admin/users/admin/password", `{}`}, + } { + if rec := do(h, route.method, route.path, route.body, super); rec.Code != http.StatusForbidden { + t.Errorf("super admin %s %s on itself: %d %s; want 403", route.method, route.path, rec.Code, rec.Body.String()) + } + } + after, err := st.GetAccountByOwner("admin") + if err != nil { + t.Fatal(err) + } + if after != root { + t.Fatalf("rejected requests changed the super admin: before=%+v after=%+v", root, after) + } + if rec := do(h, "GET", "/api/me", "", super); rec.Code != http.StatusOK { + t.Fatalf("super admin session revoked by rejected requests: %d", rec.Code) + } + loginFixture(t, h, "admin", superAdminFixturePassword) +} diff --git a/internal/server/super_admin_login_test.go b/internal/server/super_admin_login_test.go new file mode 100644 index 0000000..68336bc --- /dev/null +++ b/internal/server/super_admin_login_test.go @@ -0,0 +1,233 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "agent-system-backend/internal/store" +) + +const envSuperAdminPassword = "Env-Fallback-Pass-1" + +func newSuperAdminServer(t *testing.T) (http.Handler, *Server, *store.Store) { + t.Helper() + srv, st := newAuthTestServer(t) + hash, err := store.HashPassword(envSuperAdminPassword) + if err != nil { + t.Fatal(err) + } + if _, err := st.BootstrapSuperAdmin("root", hash); err != nil { + t.Fatal(err) + } + srv.Auth.SetSuperAdminFallback("root", envSuperAdminPassword) + for _, owner := range []string{"root", "alice", "legacy-admin", "ghost"} { + loginClear(owner) + } + t.Cleanup(func() { + for _, owner := range []string{"root", "alice", "legacy-admin", "ghost"} { + loginClear(owner) + } + }) + return srv.Handler(), srv, st +} + +func postLogin(h http.Handler, owner, password string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]string{"owner": owner, "password": password}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, reqWithCookie("POST", "/api/auth/login", string(body), "")) + return rec +} + +func postPasswordChange(h http.Handler, cookie, current, next string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]string{"current": current, "next": next}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, reqWithCookie("POST", "/api/auth/password", string(body), cookie)) + return rec +} + +func mustLogin(t *testing.T, h http.Handler, owner, password string) string { + t.Helper() + rec := postLogin(h, owner, password) + if rec.Code != http.StatusOK { + t.Fatalf("login %s: %d %s", owner, rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), envSuperAdminPassword) { + t.Fatal("login response leaked the environment password") + } + var session *http.Cookie + for _, ck := range rec.Result().Cookies() { + if ck.Name == SessionCookie { + session = ck + } + } + if session == nil || session.Value == "" || !session.HttpOnly { + t.Fatalf("login must issue an HttpOnly session cookie: %+v", session) + } + return session.Value +} + +func TestSuperAdminEnvironmentPasswordLogin(t *testing.T) { + h, _, _ := newSuperAdminServer(t) + rec := postLogin(h, "root", envSuperAdminPassword) + if rec.Code != http.StatusOK { + t.Fatalf("initial environment password login: %d %s", rec.Code, rec.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out["owner"] != "root" || out["sessionScope"] != store.SessionScopeFull || out["isAdmin"] != true { + t.Fatalf("unexpected login projection: %v", out) + } + mustLogin(t, h, "root", envSuperAdminPassword) +} + +func TestSuperAdminPasswordChangeKeepsFallback(t *testing.T) { + h, _, _ := newSuperAdminServer(t) + cookie := mustLogin(t, h, "root", envSuperAdminPassword) + if rec := postPasswordChange(h, cookie, envSuperAdminPassword, "db-password-1"); rec.Code != http.StatusOK { + t.Fatalf("change with environment password as current: %d %s", rec.Code, rec.Body.String()) + } + if rec := postPasswordChange(h, cookie, "db-password-1", "db-password-2"); rec.Code != http.StatusOK { + t.Fatalf("change with database password as current: %d %s", rec.Code, rec.Body.String()) + } + if rec := postLogin(h, "root", "db-password-1"); rec.Code != http.StatusUnauthorized { + t.Fatalf("old database password must be rejected: %d", rec.Code) + } + mustLogin(t, h, "root", "db-password-2") + fallbackCookie := mustLogin(t, h, "root", envSuperAdminPassword) + + // The fallback may also be presented as the current password. + if rec := postPasswordChange(h, fallbackCookie, envSuperAdminPassword, "db-password-3"); rec.Code != http.StatusOK { + t.Fatalf("fallback as current password: %d %s", rec.Code, rec.Body.String()) + } + if rec := postLogin(h, "root", "db-password-2"); rec.Code != http.StatusUnauthorized { + t.Fatalf("replaced database password must be rejected: %d", rec.Code) + } + mustLogin(t, h, "root", "db-password-3") + mustLogin(t, h, "root", envSuperAdminPassword) +} + +func TestSuperAdminFallbackSessionBoundToAuthVersion(t *testing.T) { + h, _, _ := newSuperAdminServer(t) + fallbackCookie := mustLogin(t, h, "root", envSuperAdminPassword) + changer := mustLogin(t, h, "root", envSuperAdminPassword) + if rec := postPasswordChange(h, changer, envSuperAdminPassword, "db-password-1"); rec.Code != http.StatusOK { + t.Fatalf("change: %d %s", rec.Code, rec.Body.String()) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, reqWithCookie("GET", "/api/me", "", fallbackCookie)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("fallback session must be revoked when the auth version advances: %d", rec.Code) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, reqWithCookie("GET", "/api/me", "", changer)) + if rec.Code != http.StatusOK { + t.Fatalf("changing session must survive: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestSuperAdminFallbackRejectedForOtherOwners(t *testing.T) { + h, srv, st := newSuperAdminServer(t) + for _, acct := range []struct { + owner string + admin bool + }{{"alice", false}, {"legacy-admin", true}} { + hash, err := store.HashPassword("own-password-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount(acct.owner, "", hash, acct.admin); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword(acct.owner); err != nil { + t.Fatal(err) + } + } + for _, owner := range []string{"alice", "legacy-admin", "ghost"} { + if rec := postLogin(h, owner, envSuperAdminPassword); rec.Code != http.StatusUnauthorized { + t.Fatalf("fallback accepted for %q: %d", owner, rec.Code) + } + } + // Even a fallback configured for an unmarked owner never authenticates it. + srv.Auth.SetSuperAdminFallback("alice", envSuperAdminPassword) + if rec := postLogin(h, "alice", envSuperAdminPassword); rec.Code != http.StatusUnauthorized { + t.Fatalf("fallback accepted for unmarked configured owner: %d", rec.Code) + } + if rec := postLogin(h, "root", envSuperAdminPassword); rec.Code != http.StatusOK { + t.Fatalf("root database password must still work: %d", rec.Code) + } + cookie := mustLogin(t, h, "alice", "own-password-1") + if rec := postPasswordChange(h, cookie, envSuperAdminPassword, "own-password-2"); rec.Code != http.StatusUnauthorized { + t.Fatalf("fallback accepted as current password for ordinary account: %d", rec.Code) + } +} + +func TestSuperAdminFallbackRejectedWhenDisabled(t *testing.T) { + h, _, st := newSuperAdminServer(t) + hash, err := store.HashPassword("ops-password-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("ops", "", hash, true); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("ops"); err != nil { + t.Fatal(err) + } + root, err := st.GetAccountByOwner("root") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("ops", root.ID, root.StateVersion, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("ordinary admin disabled the super admin: %v", err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=? WHERE owner=?`, false, "root"); err != nil { + t.Fatal(err) + } + if rec := postLogin(h, "root", envSuperAdminPassword); rec.Code != http.StatusUnauthorized { + t.Fatalf("disabled super admin authenticated with the fallback: %d", rec.Code) + } +} + +func TestSuperAdminFallbackRespectsLoginThrottle(t *testing.T) { + h, _, _ := newSuperAdminServer(t) + for i := 0; i < loginMaxFails; i++ { + if rec := postLogin(h, "root", "wrong-password-x"); rec.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: %d", i, rec.Code) + } + } + if rec := postLogin(h, "root", envSuperAdminPassword); rec.Code != http.StatusTooManyRequests { + t.Fatalf("throttled fallback attempt must be rejected: %d", rec.Code) + } +} + +func TestSuperAdminFallbackDisabledWithoutAuth(t *testing.T) { + a := NewAuthenticator(nil, false) + a.SetSuperAdminFallback("root", envSuperAdminPassword) + if a.fallbackCredential() != nil { + t.Fatal("fallback must be inactive when authentication is disabled") + } + a.Enabled = true + verify := a.fallbackCredential() + if verify == nil || !verify("root", envSuperAdminPassword) { + t.Fatal("configured fallback must verify for its owner") + } + for _, c := range [][2]string{{"alice", envSuperAdminPassword}, {"root", "other-password"}, {"root", ""}} { + if verify(c[0], c[1]) { + t.Fatalf("fallback matched %q", c[0]) + } + } + a.SetSuperAdminFallback("root", "") + if a.fallbackCredential() != nil { + t.Fatal("empty password must disable the fallback") + } + var nilAuth *Authenticator + if nilAuth.fallbackCredential() != nil { + t.Fatal("nil authenticator has no fallback") + } +} diff --git a/internal/store/account_model.go b/internal/store/account_model.go index de9b973..c72338b 100644 --- a/internal/store/account_model.go +++ b/internal/store/account_model.go @@ -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 @@ -229,6 +230,9 @@ func (s *Store) PatchAccountProfile(actorOwner, userID string, expectedProfileVe if !actor.Enabled { return Account{}, ErrAccountForbidden } + if err := requireSuperAdminTargetAccess(actor, target); err != nil { + return Account{}, err + } if actor.ID != target.ID { if err := s.requireSystemActionTx(tx, actor, "system.users.edit"); err != nil { return Account{}, err @@ -311,6 +315,14 @@ func (s *Store) CreatePendingAccount(actorOwner, owner string, profile AccountPr // supplied password hash. The account row, hash, and audit entries commit // together so a failed write cannot leave a credential without its account. func (s *Store) CreateAdminPasswordAccount(actorOwner, owner string, profile AccountProfile, passwordHash string) (Account, error) { + return s.CreatePasswordAccountWithAuthority(actorOwner, owner, profile, passwordHash, false) +} + +// CreatePasswordAccountWithAuthority is CreateAdminPasswordAccount with an +// optional administrator appointment. Appointment is reserved to the enabled +// super administrator and is rechecked under the actor lock (R3, R4); every +// other actor receives ErrAccountForbidden without any partial write. +func (s *Store) CreatePasswordAccountWithAuthority(actorOwner, owner string, profile AccountProfile, passwordHash string, isAdmin bool) (Account, error) { if passwordHash == "" { return Account{}, ErrConflict } @@ -330,14 +342,25 @@ func (s *Store) CreateAdminPasswordAccount(actorOwner, owner string, profile Acc if err := s.requireSystemActionTx(tx, actor, "system.users.create"); err != nil { return Account{}, err } + if isAdmin && !actor.IsSuperAdmin { + return Account{}, ErrAccountForbidden + } a, err := s.insertPendingAccountTx(tx, owner, profile) if err != nil { return Account{}, err } - if _, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,credential_origin=?,initialization_state=? WHERE id=?`), - passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, a.ID); err != nil { + if _, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,credential_origin=?,initialization_state=?,is_admin=? WHERE id=?`), + passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, isAdmin, a.ID); err != nil { return Account{}, err } + if isAdmin { + if err := s.seedSystemAdminGrantsTx(tx, owner); err != nil { + return Account{}, err + } + if err := s.bumpAuthorityEpochTx(tx); err != nil { + return Account{}, err + } + } a, err = s.accountByOwner(tx, owner) if err != nil { return Account{}, err @@ -348,6 +371,14 @@ func (s *Store) CreateAdminPasswordAccount(actorOwner, owner string, profile Acc if err := s.auditAccountMutation(tx, owner, actorOwner, "account.create_password"); err != nil { return Account{}, err } + if isAdmin { + if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "admin_appointed", a.ID); err != nil { + return Account{}, err + } + if err := s.auditAccountMutation(tx, owner, actorOwner, "account.admin_appoint"); err != nil { + return Account{}, err + } + } if err := tx.Commit(); err != nil { return Account{}, err } diff --git a/internal/store/account_state.go b/internal/store/account_state.go index 3f7c308..0d31b89 100644 --- a/internal/store/account_state.go +++ b/internal/store/account_state.go @@ -51,6 +51,11 @@ func (s *Store) SetAccountEnabled(actorOwner, userID string, expectedStateVersio if err := s.requireSystemActionTx(tx, actor, "system.users.disable"); err != nil { return Account{}, err } + // The unique super administrator is never disabled through account + // management, not even by itself (R5). + if target.IsSuperAdmin { + return Account{}, ErrAccountForbidden + } if target.ID != userID || target.StateVersion != expectedStateVersion || target.Enabled == enabled { return Account{}, ErrConflict } diff --git a/internal/store/account_tx.go b/internal/store/account_tx.go index 57015f1..5d19939 100644 --- a/internal/store/account_tx.go +++ b/internal/store/account_tx.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "sort" "time" ) @@ -24,6 +25,9 @@ type AccountWrite struct { Actor string InstanceID string Action string + // ActorAccount marks Actor as an account owner that accountTx locks + // together with Owner in owner order, so the callback can recheck it. + ActorAccount bool } // AccountOutcome 是成功提交后可以对外展示的标识。不含密码、哈希或明文。 @@ -231,8 +235,16 @@ func (s *Store) accountTx(w AccountWrite, fn func(*sql.Tx, string) error) error _ = tx.Rollback() } }() + var lockOwners []string if w.Owner != "" { - if err := s.dialect.lockAccount(tx, w.Owner); err != nil { + lockOwners = append(lockOwners, w.Owner) + } + if w.ActorAccount && w.Actor != "" && w.Actor != w.Owner { + lockOwners = append(lockOwners, w.Actor) + } + sort.Strings(lockOwners) + for _, owner := range lockOwners { + if err := s.dialect.lockAccount(tx, owner); err != nil { return err } } diff --git a/internal/store/auth.go b/internal/store/auth.go index 87085fa..8ab514c 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -204,6 +204,7 @@ type AdminUser struct { InitializationState string `json:"initializationState"` InitializationGeneration int64 `json:"initializationGeneration"` IsAdmin bool `json:"isAdmin"` + IsSuperAdmin bool `json:"isSuperAdmin"` MustChangePassword bool `json:"mustChangePassword"` HasPassword bool `json:"hasPassword"` PasswordSetAt *time.Time `json:"passwordSetAt,omitempty"` @@ -290,6 +291,7 @@ type UserAuthState struct { Owner string Name string IsAdmin bool + IsSuperAdmin bool MustChangePassword bool HasPassword bool Enabled bool @@ -307,7 +309,7 @@ func (s *Store) GetUserAuthState(owner string) (UserAuthState, error) { return UserAuthState{}, err } return UserAuthState{ - UserID: a.ID, Owner: a.Owner, Name: a.Profile.Name, IsAdmin: a.IsAdmin, + UserID: a.ID, Owner: a.Owner, Name: a.Profile.Name, IsAdmin: a.IsAdmin, IsSuperAdmin: a.IsSuperAdmin, MustChangePassword: a.MustChangePassword, HasPassword: a.HasPassword, Enabled: a.Enabled, StateVersion: a.StateVersion, AuthVersion: a.AuthVersion, CredentialOrigin: a.CredentialOrigin, InitializationState: a.InitializationState, @@ -325,10 +327,32 @@ func (s *Store) VerifyLogin(owner, password string) (UserAuthState, bool) { // 时必须以 CreateSessionVerified 传回该版本,堵「验证在途 → 恢复/改密完成 → // 旧请求又建会话」的竞态窗口。 func (s *Store) VerifyLoginWithVersion(owner, password string) (UserAuthState, int, bool) { + return s.VerifyLoginWithFallback(owner, password, nil) +} + +// FallbackCredential reports whether password equals the in-memory fallback +// credential configured for owner (KTD2). Implementations must compare in +// constant time and must never persist or log the secret. +type FallbackCredential func(owner, password string) bool + +// verifyAccountCredential checks the database hash first, then the fallback. +// The fallback is honoured only for an account carrying the persisted +// super-admin marker, so it can never authenticate any other account. +func verifyAccountCredential(hash string, isSuperAdmin bool, owner, password string, fallback FallbackCredential) bool { + if hash != "" && VerifyPassword(hash, password) { + return true + } + return isSuperAdmin && fallback != nil && fallback(owner, password) +} + +// VerifyLoginWithFallback 同 VerifyLoginWithVersion,另对已启用且带超级管理员标记的 +// 账户接受 fallback 凭据(R6);数据库密码优先。返回的认证版本语义不变。 +func (s *Store) VerifyLoginWithFallback(owner, password string, fallback FallbackCredential) (UserAuthState, int, bool) { var hash string var ver int - err := s.db.QueryRow(s.q(`SELECT password_hash,auth_version FROM users WHERE owner=? AND enabled=?`), owner, true).Scan(&hash, &ver) - if err != nil || hash == "" || !VerifyPassword(hash, password) { + var isSuperAdmin bool + err := s.db.QueryRow(s.q(`SELECT password_hash,auth_version,is_super_admin FROM users WHERE owner=? AND enabled=?`), owner, true).Scan(&hash, &ver, &isSuperAdmin) + if err != nil || !verifyAccountCredential(hash, isSuperAdmin, owner, password, fallback) { return UserAuthState{}, 0, false } st, err := s.GetUserAuthState(owner) @@ -504,7 +528,7 @@ func (s *Store) DeleteUserSessions(owner, exceptToken string) { // ListUsersAdmin 管理员用户列表(按创建序)。 func (s *Store) ListUsersAdmin() ([]AdminUser, error) { rows, err := s.db.Query(s.q(`SELECT id,owner,COALESCE(name,''),enabled,state_version,profile_version,initialization_state,initialization_generation, - password_hash,must_change_password,is_admin,password_set_at,created_at FROM users ORDER BY created_at`)) + password_hash,must_change_password,is_admin,is_super_admin,password_set_at,created_at FROM users ORDER BY created_at`)) if err != nil { return nil, err } @@ -514,7 +538,7 @@ func (s *Store) ListUsersAdmin() ([]AdminUser, error) { var u AdminUser var hash, psa, ca string if err := rows.Scan(&u.ID, &u.Owner, &u.Name, &u.Enabled, &u.StateVersion, &u.ProfileVersion, - &u.InitializationState, &u.InitializationGeneration, &hash, &u.MustChangePassword, &u.IsAdmin, &psa, &ca); err != nil { + &u.InitializationState, &u.InitializationGeneration, &hash, &u.MustChangePassword, &u.IsAdmin, &u.IsSuperAdmin, &psa, &ca); err != nil { return nil, err } u.HasPassword = hash != "" diff --git a/internal/store/login_keys.go b/internal/store/login_keys.go index 57b74d0..a4c2213 100644 --- a/internal/store/login_keys.go +++ b/internal/store/login_keys.go @@ -179,7 +179,7 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio if err != nil { return "", meta, err } - if !locked[owner].Enabled { + if !locked[owner].Enabled || locked[owner].IsSuperAdmin { return "", meta, ErrAccountForbidden } meta, err = s.insertLoginKeyTx(tx, owner, purpose, createdBy, plain, id, now, exp) @@ -593,6 +593,12 @@ func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner st // password-authenticated account. It requires the current password even when // the session is restricted by must_change_password. func (s *Store) CompleteForcedPasswordChecked(token, current, passwordHash string) (string, error) { + return s.CompleteForcedPasswordCheckedWithFallback(token, current, passwordHash, nil) +} + +// CompleteForcedPasswordCheckedWithFallback additionally accepts the +// super administrator's in-memory fallback as the current password (R6). +func (s *Store) CompleteForcedPasswordCheckedWithFallback(token, current, passwordHash string, fallback FallbackCredential) (string, error) { var owner string if err := s.db.QueryRow(s.q(`SELECT owner FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner); err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -626,7 +632,7 @@ func (s *Store) CompleteForcedPasswordChecked(token, current, passwordHash strin if err != nil || time.Now().After(expiry) || version != account.AuthVersion || scope != SessionScopeSetPasswordOnly || method != AuthMethodPassword { return ErrAccountForbidden } - if !VerifyPassword(hash, current) { + if !verifyAccountCredential(hash, account.IsSuperAdmin, owner, current, fallback) { return ErrBadCredentials } res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1 @@ -658,16 +664,22 @@ func (s *Store) CompleteForcedPasswordChecked(token, current, passwordHash strin // ChangeUserPasswordActive 主动改密单事务:校验当前密码 → 更新(带认证版本乐观 // 检查)→ bump auth_version → 吊销其他会话(保留当前)→ 审计。中途失败无部分成功。 func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken string) error { + return s.ChangeUserPasswordActiveWithFallback(owner, current, passwordHash, keepToken, nil) +} + +// ChangeUserPasswordActiveWithFallback 同 ChangeUserPasswordActive,另接受超级管理员 +// 的进程内 fallback 作为当前密码(R6);只更新数据库哈希,fallback 不受影响(KTD2)。 +func (s *Store) ChangeUserPasswordActiveWithFallback(owner, current, passwordHash, keepToken string, fallback FallbackCredential) error { 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=?`) + var mustChange, enabled, isSuperAdmin bool + accountQuery := s.q(`SELECT password_hash,auth_version,must_change_password,enabled,is_super_admin FROM users WHERE owner=?`) if s.Dialect() == "postgres" { accountQuery += ` FOR UPDATE` } err := tx.QueryRow(accountQuery, owner). - Scan(&hash, &ver, &mustChange, &enabled) + Scan(&hash, &ver, &mustChange, &enabled, &isSuperAdmin) if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } @@ -681,7 +693,7 @@ func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken // 强制态必须走受限设密路径,不在此处用旧密码换新密码。 return ErrConflict } - if !VerifyPassword(hash, current) { + if !verifyAccountCredential(hash, isSuperAdmin, owner, current, fallback) { 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=?`), @@ -723,13 +735,36 @@ func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken // AdminResetUserPassword 管理员/CLI 重置密码单事务:写 hash、must_change=true、 // bump auth_version、作废未用登录 Key、吊销全部会话、审计(actor 为操作者)。 +// The super-administrator account is never reset through this path (R5). func (s *Store) AdminResetUserPassword(owner, passwordHash, actor, entrance string) error { - 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) + return s.adminResetUserPassword(owner, passwordHash, actor, entrance, false) +} + +// AdminResetUserPasswordChecked is the account-management HTTP entry. The +// actor must still be an enabled administrator when re-read under the same +// owner-ordered locks as the target and the write (KTD3). +func (s *Store) AdminResetUserPasswordChecked(actorOwner, owner, passwordHash string) error { + return s.adminResetUserPassword(owner, passwordHash, actorOwner, "admin-http", true) +} + +func (s *Store) adminResetUserPassword(owner, passwordHash, actor, entrance string, actorAccount bool) error { + w := AccountWrite{Owner: owner, Actor: actor, Action: "account.password.admin_reset", ActorAccount: actorAccount} + return s.accountTx(w, func(tx *sql.Tx, _ string) error { + owners := []string{owner} + if actorAccount { + owners = append(owners, actor) + } + locked, err := s.lockAccountUsers(tx, owners...) + if errors.Is(err, ErrNotFound) && actorAccount { + return ErrAccountForbidden + } if err != nil { return err } - if !locked[owner].Enabled { + if actorAccount && (!locked[actor].Enabled || !locked[actor].IsAdmin) { + return ErrAccountForbidden + } + if !locked[owner].Enabled || locked[owner].IsSuperAdmin { return ErrAccountForbidden } res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1, diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 69945ad..dd6aecb 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -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`}, + }, + }, ) } diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index 473be98..02f4d13 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -57,6 +57,7 @@ var migrationBreaksOldReaders = map[int]bool{ 48: false, // document_render_deliveries:新增普通工作区文档交付回执表 49: false, // 角色权威仅新增独立表;消费者切换由公开提供者 readiness 单独围栏 50: false, // 角色导入 staging 为独立表;正式角色读取在激活前不变 + 51: true, // 超级管理员标记是账号权威边界;不认识它的旧账号写者必须排除 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 diff --git a/internal/store/owner_initialization.go b/internal/store/owner_initialization.go index ca2856f..91afb63 100644 --- a/internal/store/owner_initialization.go +++ b/internal/store/owner_initialization.go @@ -11,7 +11,8 @@ func (s *Store) InitializePendingAccount(userID string, expectedStateVersion, ex // 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. +// flag when explicitly requested by the machine/DB owner CLI. That flag is now +// rejected: administrator appointment is reserved to the super administrator. func (s *Store) CreateLocalPendingAccount(owner, name string, isAdmin bool) (Account, string, error) { account, out, err := s.CreateLocalPendingAccountWithAudit(owner, name, isAdmin, "") return account, out.Plain, err diff --git a/internal/store/owner_initialization_audit.go b/internal/store/owner_initialization_audit.go index 6dc908e..3ce5881 100644 --- a/internal/store/owner_initialization_audit.go +++ b/internal/store/owner_initialization_audit.go @@ -46,7 +46,7 @@ func (s *Store) InitializePendingAccountWithAudit(userID string, expectedStateVe if account.ID != userID || account.StateVersion != expectedStateVersion || account.InitializationGeneration != expectedGeneration { return ErrConflict } - if !account.Enabled || account.InitializationState != InitializationPending || account.HasPassword || account.CredentialOrigin != CredentialOriginLegacyUnknown { + if !account.Enabled || account.IsSuperAdmin || account.InitializationState != InitializationPending || account.HasPassword || account.CredentialOrigin != CredentialOriginLegacyUnknown { return ErrAccountForbidden } for _, query := range []string{ @@ -93,9 +93,14 @@ func (s *Store) InitializePendingAccountWithAudit(userID string, expectedStateVe // CreateLocalPendingAccountWithAudit admits a new pending account through the // same serialized, audited account transaction as other maintenance actions. +// No local CLI path may appoint an administrator (R4, R5): isAdmin is +// rejected before any write; only the server-side super administrator can. func (s *Store) CreateLocalPendingAccountWithAudit(owner, name string, isAdmin bool, instanceID string) (Account, AccountOutcome, error) { var account Account var out AccountOutcome + if isAdmin { + return account, out, fmt.Errorf("%w: administrator appointment is reserved to the super administrator", ErrAccountForbidden) + } plain, err := newActivationPlain() if err != nil { return account, out, err diff --git a/internal/store/recovery_proposals.go b/internal/store/recovery_proposals.go index 8c9f131..3a0d012 100644 --- a/internal/store/recovery_proposals.go +++ b/internal/store/recovery_proposals.go @@ -98,6 +98,9 @@ func (s *Store) CreateRecoveryProposal(actorOwner, userID string, expectedStateV if !actor.Enabled || !actor.IsAdmin || !target.Enabled { return RecoveryProposal{}, ErrAccountForbidden } + if err := requireSuperAdminTargetAccess(actor, target); err != nil { + return RecoveryProposal{}, err + } if target.ID != userID || target.StateVersion != expectedStateVersion { return RecoveryProposal{}, ErrConflict } diff --git a/internal/store/role_policy.go b/internal/store/role_policy.go index 5424a77..475a92f 100644 --- a/internal/store/role_policy.go +++ b/internal/store/role_policy.go @@ -406,6 +406,18 @@ func (s *Store) AssignUserRole(actor, userID, roleID string, expectedState int64 }{userID, roleID, expectedState}) var out RoleBinding err := s.policyWrite(actor, userID, func(tx *sql.Tx, actorAccount, target Account, epoch int64) error { + if err := requireSuperAdminTargetAccess(actorAccount, target); err != nil { + return err + } + if !actorAccount.IsSuperAdmin { + grantsAdministration, err := s.roleGrantsAccountAdministrationTx(tx, roleID) + if err != nil { + return err + } + if grantsAdministration { + return ErrAccountForbidden + } + } if err := s.requireRoleAuthorityWritableTx(tx); err != nil { return err } @@ -661,6 +673,70 @@ func (s *Store) RolePermissionAllowed(owner, actionID string) (bool, bool, error return allowed, capAllowed, err } +// roleGrantsAccountAdministrationTx reports whether a role allows any +// account-administration action. Catalog validation already keeps protected +// actions out of roles; this keeps role assignment from becoming an elevation +// path even if that invariant is ever broken. +func (s *Store) roleGrantsAccountAdministrationTx(tx *sql.Tx, roleID string) (bool, error) { + rows, err := tx.Query(s.q(`SELECT action_id FROM role_permissions WHERE role_id=? AND allowed=?`), roleID, true) + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var actionID string + if err := rows.Scan(&actionID); err != nil { + return false, err + } + if IsAccountAdministrationAction(actionID) { + return true, nil + } + } + return false, rows.Err() +} + +// changesAccountAdministrationGrantsTx reports whether replacing userID's +// explicit grants with actions would add or remove any account-administration +// action. Only the super administrator may make such a change (R4, R5). +func (s *Store) changesAccountAdministrationGrantsTx(tx *sql.Tx, userID string, actions []string) (bool, error) { + current := map[string]bool{} + rows, err := tx.Query(s.q(`SELECT action_id FROM system_grants WHERE user_id=? AND allowed=?`), userID, true) + if err != nil { + return false, err + } + for rows.Next() { + var actionID string + if err := rows.Scan(&actionID); err != nil { + rows.Close() + return false, err + } + if IsAccountAdministrationAction(actionID) { + current[actionID] = true + } + } + if err := rows.Close(); err != nil { + return false, err + } + if err := rows.Err(); err != nil { + return false, err + } + requested := map[string]bool{} + for _, actionID := range actions { + if IsAccountAdministrationAction(actionID) { + requested[actionID] = true + } + } + if len(current) != len(requested) { + return true, nil + } + for actionID := range requested { + if !current[actionID] { + return true, nil + } + } + return false, nil +} + func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, actions []string, key string) (PolicyReceipt, error) { requestHash := policyRequestHash(struct { UserID string @@ -669,6 +745,18 @@ func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, a }{userID, expectedEpoch, actions}) var out PolicyReceipt err := s.policyWrite(actor, userID, func(tx *sql.Tx, actorAccount, target Account, epoch int64) error { + if err := requireSuperAdminTargetAccess(actorAccount, target); err != nil { + return err + } + if !actorAccount.IsSuperAdmin { + changes, err := s.changesAccountAdministrationGrantsTx(tx, target.ID, actions) + if err != nil { + return err + } + if changes { + return ErrAccountForbidden + } + } replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "system.grants.replace", requestHash, &out) if err != nil || replayed { return err diff --git a/internal/store/role_policy_test.go b/internal/store/role_policy_test.go index 99665f4..2ec958e 100644 --- a/internal/store/role_policy_test.go +++ b/internal/store/role_policy_test.go @@ -12,6 +12,7 @@ func rolePolicyAccounts(t *testing.T, st *Store) (Account, Account, Account) { if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { t.Fatal(err) } + markSuperAdmin(t, st, "admin") if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { t.Fatal(err) } diff --git a/internal/store/super_admin.go b/internal/store/super_admin.go new file mode 100644 index 0000000..74e21da --- /dev/null +++ b/internal/store/super_admin.go @@ -0,0 +1,256 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +// 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" + +// accountAdministrationActionPrefixes name the system actions that confer +// account-administration authority. Only the super administrator may grant +// or withdraw them (R4, R5). +var accountAdministrationActionPrefixes = []string{"system.users.", "system.roles.", "system.permissions."} + +// IsAccountAdministrationAction reports whether actionID confers user, role or +// permission management authority. +func IsAccountAdministrationAction(actionID string) bool { + for _, prefix := range accountAdministrationActionPrefixes { + if strings.HasPrefix(actionID, prefix) { + return true + } + } + return false +} + +// requireSuperAdminTargetAccess rejects every actor other than the super +// administrator itself from mutating the super-administrator account (R5). +// Both accounts must have been read under the write transaction's locks. +func requireSuperAdminTargetAccess(actor, target Account) error { + if target.IsSuperAdmin && actor.ID != target.ID { + return ErrAccountForbidden + } + return nil +} + +func (s *Store) bumpAuthorityEpochTx(tx *sql.Tx) error { + query := `SELECT authority_epoch FROM authority_state WHERE id=1` + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var epoch int64 + if err := tx.QueryRow(query).Scan(&epoch); err != nil { + return err + } + _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, nowISO(), epoch) + return err +} + +// SetAccountAdmin appoints or withdraws an ordinary system administrator. Only +// the enabled super administrator may do so (R3, R4); the super-administrator +// account itself can never be demoted or re-appointed (R5). Actor and target +// are rechecked under the account locks that also cover the write. +func (s *Store) SetAccountAdmin(actorOwner, userID string, expectedStateVersion int64, isAdmin bool) (Account, error) { + if expectedStateVersion < 1 { + return Account{}, ErrConflict + } + pre, err := s.GetAccount(userID) + if errors.Is(err, ErrNotFound) { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + tx, err := s.db.Begin() + if err != nil { + return Account{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner, pre.Owner) + if errors.Is(err, ErrNotFound) { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + actor, target := locked[actorOwner], locked[pre.Owner] + if !actor.Enabled || !actor.IsSuperAdmin || target.IsSuperAdmin || target.ID != userID { + return Account{}, ErrAccountForbidden + } + if !target.Enabled || target.StateVersion != expectedStateVersion || target.IsAdmin == isAdmin { + return Account{}, ErrConflict + } + result, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,state_version=state_version+1 WHERE id=? AND state_version=? AND is_super_admin=?`), + isAdmin, userID, expectedStateVersion, false) + if err != nil { + return Account{}, err + } + if n, err := result.RowsAffected(); err != nil { + return Account{}, err + } else if n != 1 { + return Account{}, ErrConflict + } + if isAdmin { + if err := s.seedSystemAdminGrantsTx(tx, target.Owner); err != nil { + return Account{}, err + } + } else { + for _, actionID := range rolePolicyAdminActionsV49 { + if _, err := tx.Exec(s.q(`DELETE FROM system_grants WHERE user_id=? AND action_id=?`), userID, actionID); err != nil { + return Account{}, err + } + } + var managers int + if err := tx.QueryRow(s.q(`SELECT COUNT(DISTINCT g.user_id) FROM system_grants g JOIN users u ON u.id=g.user_id + WHERE g.action_id=? AND g.allowed=? AND u.enabled=?`), "system.permissions.manage", true, true).Scan(&managers); err != nil { + return Account{}, err + } + if managers == 0 { + return Account{}, ErrLastPermissionManager + } + } + if err := s.bumpAuthorityEpochTx(tx); err != nil { + return Account{}, err + } + event, action := "admin_appointed", "account.admin_appoint" + if !isAdmin { + event, action = "admin_withdrawn", "account.admin_withdraw" + } + if err := s.insertAccountEvent(tx, actorOwner, "account-management", target.Owner, event, target.ID); err != nil { + return Account{}, err + } + if err := s.auditAccountMutation(tx, target.Owner, actorOwner, action); err != nil { + return Account{}, err + } + if target, err = s.accountByOwner(tx, target.Owner); err != nil { + return Account{}, err + } + if err := tx.Commit(); err != nil { + return Account{}, err + } + return target, nil +} + +// 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 +} diff --git a/internal/store/super_admin_authority_test.go b/internal/store/super_admin_authority_test.go new file mode 100644 index 0000000..7aa5ab3 --- /dev/null +++ b/internal/store/super_admin_authority_test.go @@ -0,0 +1,312 @@ +package store + +import ( + "errors" + "sync" + "testing" + "time" +) + +func markSuperAdmin(t *testing.T, st *Store, owner string) { + t.Helper() + if _, err := st.db.Exec(st.q(`UPDATE users SET is_super_admin=? WHERE owner=?`), true, owner); err != nil { + t.Fatal(err) + } +} + +// superAdminAuthorityStore holds the configured super administrator "root", an +// ordinary administrator "ops" and an ordinary user "alice". +func superAdminAuthorityStore(t *testing.T) (*Store, Account, Account, Account) { + t.Helper() + st := openTestStore(t) + if _, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("ops", "Ops", superAdminHash(t, "ops-password-1"), true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", superAdminHash(t, "alice-password-1"), false); err != nil { + t.Fatal(err) + } + root, _ := st.GetAccountByOwner("root") + ops, _ := st.GetAccountByOwner("ops") + alice, _ := st.GetAccountByOwner("alice") + return st, root, ops, alice +} + +func explicitGrants(t *testing.T, st *Store, userID string) map[string]bool { + t.Helper() + actions, _, err := st.SystemGrantsForUser(userID) + if err != nil { + t.Fatal(err) + } + out := map[string]bool{} + for _, action := range actions { + out[action] = true + } + return out +} + +func TestSuperAdminProjectionsCarryMarker(t *testing.T) { + st, _, _, _ := superAdminAuthorityStore(t) + rootState, err := st.GetUserAuthState("root") + if err != nil || !rootState.IsSuperAdmin || !rootState.IsAdmin { + t.Fatalf("root auth state: %+v %v", rootState, err) + } + opsState, err := st.GetUserAuthState("ops") + if err != nil || opsState.IsSuperAdmin || !opsState.IsAdmin { + t.Fatalf("ops auth state: %+v %v", opsState, err) + } + users, err := st.ListUsersAdmin() + if err != nil { + t.Fatal(err) + } + for _, u := range users { + if u.IsSuperAdmin != (u.Owner == "root") { + t.Fatalf("admin listing marker for %s = %v", u.Owner, u.IsSuperAdmin) + } + } +} + +func TestSuperAdminCreatesAdministratorAccount(t *testing.T) { + st, _, _, _ := superAdminAuthorityStore(t) + before, _ := st.AuthorityEpoch() + boss, err := st.CreatePasswordAccountWithAuthority("root", "boss", AccountProfile{Name: "Boss"}, superAdminHash(t, "boss-password-1"), true) + if err != nil { + t.Fatal(err) + } + if !boss.IsAdmin || boss.IsSuperAdmin || !boss.MustChangePassword { + t.Fatalf("appointed account: %+v", boss) + } + if grants := explicitGrants(t, st, boss.ID); !grants["system.users.create"] || !grants["system.permissions.manage"] { + t.Fatalf("appointed administrator lacks seeded grants: %v", grants) + } + if after, _ := st.AuthorityEpoch(); after <= before { + t.Fatalf("authority epoch not advanced: %d -> %d", before, after) + } +} + +func TestOrdinaryAdminCreatesOnlyOrdinaryAccounts(t *testing.T) { + st, _, _, _ := superAdminAuthorityStore(t) + if _, err := st.CreatePasswordAccountWithAuthority("ops", "boss", AccountProfile{}, superAdminHash(t, "boss-password-1"), true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin appointed an administrator: %v", err) + } + if _, err := st.GetAccountByOwner("boss"); !errors.Is(err, ErrNotFound) { + t.Fatalf("rejected appointment left an account: %v", err) + } + if n, _ := st.CountAudit("boss", "success"); n != 0 { + t.Fatalf("rejected appointment audited success: %d", n) + } + carol, err := st.CreateAdminPasswordAccount("ops", "carol", AccountProfile{Name: "Carol"}, superAdminHash(t, "carol-password-1")) + if err != nil || carol.IsAdmin || carol.IsSuperAdmin { + t.Fatalf("ordinary account creation: %+v %v", carol, err) + } + if _, _, err := st.CreateLocalPendingAccountWithAudit("local-admin", "", true, ""); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("local CLI path appointed an administrator: %v", err) + } + if _, err := st.GetAccountByOwner("local-admin"); !errors.Is(err, ErrNotFound) { + t.Fatalf("rejected local appointment left an account: %v", err) + } +} + +func TestSetAccountAdminIsReservedToSuperAdmin(t *testing.T) { + st, root, ops, alice := superAdminAuthorityStore(t) + if _, err := st.SetAccountAdmin("ops", alice.ID, alice.StateVersion, true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin promoted a user: %v", err) + } + if _, err := st.SetAccountAdmin("alice", alice.ID, alice.StateVersion, true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("user promoted itself: %v", err) + } + if _, err := st.SetAccountAdmin("ops", ops.ID, ops.StateVersion, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin changed its own authority: %v", err) + } + promoted, err := st.SetAccountAdmin("root", alice.ID, alice.StateVersion, true) + if err != nil || !promoted.IsAdmin || promoted.IsSuperAdmin || promoted.StateVersion != alice.StateVersion+1 { + t.Fatalf("super admin promotion: %+v %v", promoted, err) + } + if grants := explicitGrants(t, st, alice.ID); !grants["system.users.create"] { + t.Fatalf("promotion did not seed administrator grants: %v", grants) + } + if _, err := st.SetAccountAdmin("root", alice.ID, alice.StateVersion, false); !errors.Is(err, ErrConflict) { + t.Fatalf("stale state version accepted: %v", err) + } + if _, err := st.SetAccountAdmin("ops", alice.ID, promoted.StateVersion, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin demoted an administrator: %v", err) + } + demoted, err := st.SetAccountAdmin("root", alice.ID, promoted.StateVersion, false) + if err != nil || demoted.IsAdmin { + t.Fatalf("super admin demotion: %+v %v", demoted, err) + } + for action := range explicitGrants(t, st, alice.ID) { + if IsAccountAdministrationAction(action) { + t.Fatalf("demotion kept administrative grant %s", action) + } + } + for _, actor := range []string{"root", "ops"} { + current, _ := st.GetAccount(root.ID) + if _, err := st.SetAccountAdmin(actor, root.ID, current.StateVersion, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("%s demoted the super admin: %v", actor, err) + } + } + if current, _ := st.GetAccount(root.ID); !current.IsAdmin || !current.IsSuperAdmin || !current.Enabled { + t.Fatalf("super admin identity changed: %+v", current) + } +} + +func TestOrdinaryAdminCannotElevateThroughGrantsOrRoles(t *testing.T) { + st, root, ops, alice := superAdminAuthorityStore(t) + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("ops", alice.ID, epoch, []string{"system.users.create"}, "ops-elevate-alice"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin granted account administration: %v", err) + } + if _, err := st.ReplaceSystemGrants("ops", alice.ID, epoch, []string{"system.permissions.manage"}, "ops-elevate-alice-2"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin granted permission management: %v", err) + } + if _, err := st.ReplaceSystemGrants("ops", ops.ID, epoch, []string{"project.create"}, "ops-self-demote"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin changed its own administrative grants: %v", err) + } + if _, err := st.ReplaceSystemGrants("ops", root.ID, epoch, []string{"system.users.view"}, "ops-root"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin changed the super admin's grants: %v", err) + } + if grants := explicitGrants(t, st, alice.ID); len(grants) != 0 { + t.Fatalf("rejected grant writes left grants: %v", grants) + } + if after, _ := st.AuthorityEpoch(); after != epoch { + t.Fatalf("rejected grant writes advanced the epoch: %d -> %d", epoch, after) + } + if _, err := st.ReplaceSystemGrants("ops", alice.ID, epoch, []string{"project.create"}, "ops-non-admin-grant"); err != nil { + t.Fatalf("ordinary admin non-administrative grant: %v", err) + } + epoch, _ = st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("root", alice.ID, epoch, []string{"project.create", "system.users.view"}, "root-grant"); err != nil { + t.Fatalf("super admin administrative grant: %v", err) + } + + role, err := st.CreateRole("ops", RoleCreate{Name: "普通职位"}) + if err != nil { + t.Fatal(err) + } + rootNow, _ := st.GetAccount(root.ID) + if _, err := st.AssignUserRole("ops", root.ID, role.RoleID, rootNow.StateVersion, "ops-role-root"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin assigned a role to the super admin: %v", err) + } + // Catalog validation keeps protected actions out of roles; plant one + // directly to prove assignment is not an elevation path regardless. + if _, err := st.db.Exec(st.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at) + VALUES(?,?,?,?,?,?,?)`), role.RoleID, "system.users.create", true, true, 1, ops.ID, nowISO()); err != nil { + t.Fatal(err) + } + aliceNow, _ := st.GetAccount(alice.ID) + if _, err := st.AssignUserRole("ops", alice.ID, role.RoleID, aliceNow.StateVersion, "ops-admin-role"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin assigned an administrative role: %v", err) + } + if view, err := st.RoleForUser(alice.ID); err != nil || view.Binding != nil { + t.Fatalf("rejected role assignment left a binding: %+v %v", view, err) + } +} + +func TestOrdinaryAdminCannotMutateSuperAdmin(t *testing.T) { + st, root, _, _ := superAdminAuthorityStore(t) + rootHash := storedPasswordHash(t, st, "root") + if _, err := st.PatchAccountProfile("ops", root.ID, root.ProfileVersion, map[string]string{"name": "renamed"}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin renamed the super admin: %v", err) + } + if _, err := st.SetAccountEnabled("ops", root.ID, root.StateVersion, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin disabled the super admin: %v", err) + } + if err := st.AdminResetUserPasswordChecked("ops", "root", superAdminHash(t, "attacker-pass-1")); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin reset the super admin password: %v", err) + } + if err := st.AdminResetUserPassword("root", superAdminHash(t, "attacker-pass-1"), "cli", "cli"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("CLI/legacy reset changed the super admin password: %v", err) + } + if _, _, err := st.IssueLoginKey("root", LoginKeyPurposeRecover, "cli", time.Minute); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("login key issued for the super admin: %v", err) + } + if _, err := st.CreateRecoveryProposal("ops", root.ID, root.StateVersion, "ops-recover-root"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin opened recovery for the super admin: %v", err) + } + if _, err := st.InitializePendingAccountWithAudit(root.ID, root.StateVersion, root.InitializationGeneration, ""); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("CLI initialization targeted the super admin: %v", err) + } + current, err := st.GetAccount(root.ID) + if err != nil { + t.Fatal(err) + } + if current != root || storedPasswordHash(t, st, "root") != rootHash { + t.Fatalf("rejected operations changed the super admin: before=%+v after=%+v", root, current) + } + if keys, err := st.ListLoginKeys("root"); err != nil || len(keys) != 0 { + t.Fatalf("rejected issuance left keys: %v %v", keys, err) + } +} + +func TestSuperAdminCannotRemoveOwnIdentity(t *testing.T) { + st, root, _, _ := superAdminAuthorityStore(t) + if _, err := st.SetAccountEnabled("root", root.ID, root.StateVersion, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("super admin disabled itself: %v", err) + } + if err := st.AdminResetUserPasswordChecked("root", "root", superAdminHash(t, "self-reset-pass-1")); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("super admin used administrator reset on itself: %v", err) + } + renamed, err := st.PatchAccountProfile("root", root.ID, root.ProfileVersion, map[string]string{"name": "Root Owner"}) + if err != nil || renamed.Profile.Name != "Root Owner" || !renamed.IsSuperAdmin { + t.Fatalf("super admin self profile edit: %+v %v", renamed, err) + } + if n := countSuperAdmins(t, st); n != 1 { + t.Fatalf("super admin markers = %d", n) + } +} + +func TestAdminResetCheckedRechecksActorAuthority(t *testing.T) { + st, _, _, _ := superAdminAuthorityStore(t) + if err := st.CreateAccount("bob", "Bob", superAdminHash(t, "bob-password-1"), false); err != nil { + t.Fatal(err) + } + bobHash := storedPasswordHash(t, st, "bob") + if err := st.AdminResetUserPasswordChecked("alice", "bob", superAdminHash(t, "attacker-pass-1")); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("non-admin actor reset a password: %v", err) + } + if err := st.AdminResetUserPasswordChecked("ghost", "bob", superAdminHash(t, "attacker-pass-1")); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing actor reset a password: %v", err) + } + if storedPasswordHash(t, st, "bob") != bobHash { + t.Fatal("rejected reset changed the password") + } + if err := st.AdminResetUserPasswordChecked("ops", "bob", superAdminHash(t, "temporary-pass-1")); err != nil { + t.Fatalf("ordinary admin reset of ordinary user: %v", err) + } +} + +func TestConcurrentAdminAppointmentAllowsOne(t *testing.T) { + st, _, _, alice := superAdminAuthorityStore(t) + var wg sync.WaitGroup + results := make(chan error, 4) + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := st.SetAccountAdmin("root", alice.ID, alice.StateVersion, true) + results <- err + }() + } + wg.Wait() + close(results) + successes := 0 + for err := range results { + switch { + case err == nil: + successes++ + case errors.Is(err, ErrConflict): + default: + t.Fatalf("unexpected concurrent appointment error: %v", err) + } + } + if successes != 1 { + t.Fatalf("concurrent appointments succeeded %d times", successes) + } + current, _ := st.GetAccount(alice.ID) + if !current.IsAdmin || current.StateVersion != alice.StateVersion+1 { + t.Fatalf("concurrent appointment state: %+v", current) + } +} diff --git a/internal/store/super_admin_fallback_test.go b/internal/store/super_admin_fallback_test.go new file mode 100644 index 0000000..481df47 --- /dev/null +++ b/internal/store/super_admin_fallback_test.go @@ -0,0 +1,154 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func rootFallback(owner, password string) bool { + return owner == "root" && password == superAdminTestPassword +} + +func anyOwnerFallback(_, password string) bool { + return password == superAdminTestPassword +} + +func bootstrapRoot(t *testing.T, st *Store) { + t.Helper() + if _, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)); err != nil { + t.Fatal(err) + } +} + +func TestSuperAdminFallbackSurvivesDatabasePasswordChanges(t *testing.T) { + st := openTestStore(t) + bootstrapRoot(t, st) + if _, _, ok := st.VerifyLoginWithFallback("root", superAdminTestPassword, rootFallback); !ok { + t.Fatal("initial environment password must authenticate") + } + if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-1"), "", rootFallback); err != nil { + t.Fatal(err) + } + if err := st.ChangeUserPasswordActiveWithFallback("root", "db-password-1", superAdminHash(t, "db-password-2"), "", rootFallback); err != nil { + t.Fatal(err) + } + if _, _, ok := st.VerifyLoginWithFallback("root", "db-password-1", rootFallback); ok { + t.Fatal("replaced database password must stop working") + } + if _, _, ok := st.VerifyLoginWithFallback("root", "db-password-2", rootFallback); !ok { + t.Fatal("current database password must authenticate") + } + state, ver, ok := st.VerifyLoginWithFallback("root", superAdminTestPassword, rootFallback) + if !ok || state.Owner != "root" || state.MustChangePassword { + t.Fatalf("environment fallback must still authenticate: ok=%v %+v", ok, state) + } + account, err := st.GetAccountByOwner("root") + if err != nil { + t.Fatal(err) + } + if ver != account.AuthVersion { + t.Fatalf("fallback login must report the account auth version: %d != %d", ver, account.AuthVersion) + } + if _, _, ok := st.VerifyLoginWithVersion("root", superAdminTestPassword); ok { + t.Fatal("fallback must not be persisted: plain verifier accepted the environment password") + } + if VerifyPassword(storedPasswordHash(t, st, "root"), superAdminTestPassword) { + t.Fatal("database hash must not be overwritten with the environment password") + } +} + +func TestSuperAdminFallbackAsCurrentPassword(t *testing.T) { + st := openTestStore(t) + bootstrapRoot(t, st) + if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-1"), "", rootFallback); err != nil { + t.Fatal(err) + } + if err := st.ChangeUserPasswordActive("root", superAdminTestPassword, superAdminHash(t, "db-password-2"), ""); !errors.Is(err, ErrBadCredentials) { + t.Fatalf("without fallback the environment password is not current: %v", err) + } + if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-2"), "", rootFallback); err != nil { + t.Fatalf("fallback must be accepted as current password: %v", err) + } + if _, _, ok := st.VerifyLoginWithFallback("root", "db-password-1", rootFallback); ok { + t.Fatal("previous database password must stop working") + } +} + +func TestSuperAdminFallbackCompletesForcedPasswordChange(t *testing.T) { + st := openTestStore(t) + bootstrapRoot(t, st) + if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-1"), "", rootFallback); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET must_change_password=? WHERE owner=?`), true, "root"); err != nil { + t.Fatal(err) + } + state, ver, ok := st.VerifyLoginWithFallback("root", superAdminTestPassword, rootFallback) + if !ok || !state.MustChangePassword { + t.Fatalf("fallback login on forced account: ok=%v %+v", ok, state) + } + tok, err := st.CreateSessionVerified("root", SessionScopeSetPasswordOnly, 10*time.Minute, ver) + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteForcedPasswordChecked(tok, superAdminTestPassword, superAdminHash(t, "db-password-2")); !errors.Is(err, ErrBadCredentials) { + t.Fatalf("without fallback the environment password is not current: %v", err) + } + if _, err := st.CompleteForcedPasswordCheckedWithFallback(tok, superAdminTestPassword, superAdminHash(t, "db-password-2"), rootFallback); err != nil { + t.Fatalf("fallback must complete forced change: %v", err) + } + if _, _, ok := st.SessionInfo(tok); ok { + t.Fatal("forced change must revoke the restricted session") + } + if _, _, ok := st.VerifyLoginWithFallback("root", "db-password-1", rootFallback); ok { + t.Fatal("previous database password must stop working") + } + for _, pw := range []string{"db-password-2", superAdminTestPassword} { + if _, _, ok := st.VerifyLoginWithFallback("root", pw, rootFallback); !ok { + t.Fatalf("credential %q must authenticate after forced change", pw) + } + } +} + +func TestSuperAdminFallbackRejectedForUnmarkedAccounts(t *testing.T) { + st := openTestStore(t) + bootstrapRoot(t, st) + if err := st.CreateAccount("alice", "", superAdminHash(t, "alice-pass-1"), false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("alice"); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("legacy-admin", "", superAdminHash(t, "legacy-pass-1"), true); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("legacy-admin"); err != nil { + t.Fatal(err) + } + for _, owner := range []string{"alice", "legacy-admin", "ghost"} { + if _, _, ok := st.VerifyLoginWithFallback(owner, superAdminTestPassword, anyOwnerFallback); ok { + t.Fatalf("fallback authenticated unmarked account %q", owner) + } + } + if err := st.ChangeUserPasswordActiveWithFallback("alice", superAdminTestPassword, superAdminHash(t, "alice-pass-2"), "", anyOwnerFallback); !errors.Is(err, ErrBadCredentials) { + t.Fatalf("fallback accepted as current password for unmarked account: %v", err) + } + if _, _, ok := st.VerifyLoginWithFallback("alice", "alice-pass-1", rootFallback); !ok { + t.Fatal("ordinary database login must be unaffected") + } +} + +func TestSuperAdminFallbackRejectedWhenDisabled(t *testing.T) { + st := openTestStore(t) + bootstrapRoot(t, st) + if _, err := st.db.Exec(st.q(`UPDATE users SET enabled=? WHERE owner=?`), false, "root"); err != nil { + t.Fatal(err) + } + if _, _, ok := st.VerifyLoginWithFallback("root", superAdminTestPassword, rootFallback); ok { + t.Fatal("disabled super admin must not authenticate with the fallback") + } + if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-1"), "", rootFallback); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled super admin password change: %v", err) + } +} diff --git a/internal/store/super_admin_test.go b/internal/store/super_admin_test.go new file mode 100644 index 0000000..d3c4b52 --- /dev/null +++ b/internal/store/super_admin_test.go @@ -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") + } +}