agent-exec transport e2fe8c1e9f3f7a12267d58526d14caee/2261db6dd595

This commit is contained in:
agent-exec-transport
2026-10-19 03:08:37 +00:00
parent c5dcb84a42
commit 69177feb3b
28 changed files with 1041 additions and 68 deletions
+3 -5
View File
@@ -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}
+14 -19
View File
@@ -433,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 <create|initialize-account|list|revoke-login-key|bootstrap-admin> [flags]")
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|initialize-account|list|revoke-login-key> [flags]")
return 2
}
sub := args[0]
@@ -450,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 参数(现已拒绝)")
@@ -468,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)
@@ -483,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
@@ -568,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
+58
View File
@@ -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")
+6 -12
View File
@@ -854,6 +854,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 {
@@ -899,19 +902,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 认证版本、
+4 -4
View File
@@ -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)
}
+9
View File
@@ -1,6 +1,7 @@
package app
import (
"errors"
"fmt"
"log"
@@ -9,6 +10,14 @@ import (
"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.
+14
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"errors"
"log"
"os"
"path/filepath"
"strings"
"testing"
@@ -113,3 +114,16 @@ func TestBootstrapSuperAdminSkippedWithoutAuthentication(t *testing.T) {
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)
}
}
@@ -22,6 +22,7 @@ func TestUnauthenticatedModeBlocksAccountAdministration(t *testing.T) {
{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", ""},
+4 -1
View File
@@ -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)
}
+60 -4
View File
@@ -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,
}
@@ -314,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
@@ -331,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)
@@ -341,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 {
@@ -355,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
@@ -372,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,
@@ -487,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) {
@@ -505,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
}
+2 -2
View File
@@ -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)
}
+1 -7
View File
@@ -3,7 +3,6 @@ package server
import (
"net/http"
"strconv"
"strings"
authzsvc "agent-system-backend/internal/authz"
"agent-system-backend/internal/store"
@@ -29,12 +28,7 @@ func (s *Server) requireSystemAction(w http.ResponseWriter, r *http.Request, act
// isAccountAdministrationAction covers user, role and permission management;
// the unauthenticated embedded mode must not expose any of them (R1).
func isAccountAdministrationAction(actionID string) bool {
for _, prefix := range []string{"system.users.", "system.roles.", "system.permissions."} {
if strings.HasPrefix(actionID, prefix) {
return true
}
}
return false
return store.IsAccountAdministrationAction(actionID)
}
func (s *Server) listPermissionCatalog(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -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)
}
+2
View File
@@ -312,6 +312,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)
@@ -896,6 +897,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)
@@ -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)
}
+5 -1
View File
@@ -2,6 +2,7 @@ package server
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -182,7 +183,10 @@ func TestSuperAdminFallbackRejectedWhenDisabled(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := st.SetAccountEnabled("ops", root.ID, root.StateVersion, false); err != nil {
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 {
+32 -2
View File
@@ -230,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
@@ -312,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
}
@@ -331,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
@@ -349,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
}
+5
View File
@@ -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
}
+13 -1
View File
@@ -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
}
}
+5 -3
View File
@@ -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,
@@ -526,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
}
@@ -536,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 != ""
+27 -4
View File
@@ -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)
@@ -735,13 +735,36 @@ func (s *Store) ChangeUserPasswordActiveWithFallback(owner, current, passwordHas
// 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,
+2 -1
View File
@@ -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
+6 -1
View File
@@ -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
+3
View File
@@ -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
}
+88
View File
@@ -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
+1
View File
@@ -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)
}
+126
View File
@@ -1,8 +1,10 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// ErrSuperAdminConflict means persisted account state cannot be reconciled
@@ -12,6 +14,130 @@ var ErrSuperAdminConflict = errors.New("store: super administrator identity conf
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 {
@@ -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)
}
}