diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 0699e91..803ac04 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -10,6 +10,7 @@ import ( ) //go:embed migrations/sqlite/0001_initial.sql migrations/sqlite/0002_domain_options.sql migrations/sqlite/0003_deployment.sql migrations/sqlite/0004_task_attempts.sql migrations/sqlite/0005_users.sql migrations/sqlite/0006_server_validation.sql migrations/sqlite/0007_server_password_auth.sql migrations/mysql/0001_initial.sql migrations/mysql/0002_domain_options.sql migrations/mysql/0003_deployment.sql migrations/mysql/0004_task_attempts.sql migrations/mysql/0005_users.sql migrations/mysql/0006_server_validation.sql migrations/mysql/0007_server_password_auth.sql migrations/postgres/0001_initial.sql migrations/postgres/0002_domain_options.sql migrations/postgres/0003_deployment.sql migrations/postgres/0004_task_attempts.sql migrations/postgres/0005_users.sql migrations/postgres/0006_server_validation.sql migrations/postgres/0007_server_password_auth.sql +//go:embed migrations/sqlite/0008_user_groups.sql migrations/mysql/0008_user_groups.sql migrations/postgres/0008_user_groups.sql var migrationFiles embed.FS func Migrate(ctx context.Context, db *sql.DB, driver string) error { @@ -38,7 +39,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error { return nil } -const latestMigrationVersion = 7 +const latestMigrationVersion = 8 func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) error { path := fmt.Sprintf("migrations/%s/%04d_%s.sql", driver, version, migrationName(version)) @@ -133,6 +134,8 @@ func migrationName(version int) string { return "server_validation" case 7: return "server_password_auth" + case 8: + return "user_groups" default: return "unknown" } diff --git a/backend/internal/database/migrations/mysql/0008_user_groups.sql b/backend/internal/database/migrations/mysql/0008_user_groups.sql new file mode 100644 index 0000000..a2940a5 --- /dev/null +++ b/backend/internal/database/migrations/mysql/0008_user_groups.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN groups_json TEXT; diff --git a/backend/internal/database/migrations/postgres/0008_user_groups.sql b/backend/internal/database/migrations/postgres/0008_user_groups.sql new file mode 100644 index 0000000..a2940a5 --- /dev/null +++ b/backend/internal/database/migrations/postgres/0008_user_groups.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN groups_json TEXT; diff --git a/backend/internal/database/migrations/sqlite/0008_user_groups.sql b/backend/internal/database/migrations/sqlite/0008_user_groups.sql new file mode 100644 index 0000000..a2940a5 --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0008_user_groups.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN groups_json TEXT; diff --git a/backend/internal/database/user_groups_test.go b/backend/internal/database/user_groups_test.go new file mode 100644 index 0000000..01eb030 --- /dev/null +++ b/backend/internal/database/user_groups_test.go @@ -0,0 +1,56 @@ +package database + +import ( + "database/sql" + "path/filepath" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +func TestSQLiteUpgradePreservesUngroupedUsers(t *testing.T) { + cfg := config.Default(t.TempDir()).Database + cfg.DSN = filepath.Join(t.TempDir(), "upgrade.db") + db, err := Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.DB.ExecContext(t.Context(), migrationTableSQL("sqlite")); err != nil { + t.Fatal(err) + } + for version := 1; version <= 7; version++ { + if err := applyMigration(t.Context(), db.DB, "sqlite", version); err != nil { + t.Fatal(err) + } + } + if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO users + (id, username, email, password_hash, role, status) + VALUES ('legacy', 'legacy', '', 'existing-hash', 'admin', 'active')`); err != nil { + t.Fatal(err) + } + for attempt := 0; attempt < 2; attempt++ { + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + } + var username, passwordHash, role, status string + var groups sql.NullString + if err := db.DB.QueryRowContext(t.Context(), `SELECT username, password_hash, role, status, groups_json + FROM users WHERE id = 'legacy'`).Scan(&username, &passwordHash, &role, &status, &groups); err != nil { + t.Fatal(err) + } + if username != "legacy" || passwordHash != "existing-hash" || role != "admin" || status != "active" { + t.Fatalf("unexpected legacy user: %q %q %q", username, role, status) + } + if groups.Valid && groups.String != "[]" { + t.Fatalf("unexpected legacy groups: %v", groups) + } + var version int + if err := db.DB.QueryRowContext(t.Context(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil { + t.Fatal(err) + } + if version != latestMigrationVersion { + t.Fatalf("unexpected version: %d", version) + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 041561f..0c04056 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -177,12 +177,12 @@ func (s *Server) registerRoutes() { s.router.POST("/api/v1/auth/password", s.requireAuthenticated, s.changePassword) s.router.POST("/api/v1/password-reset/resolve", s.resolvePasswordResetAccount) adminAPI := s.router.Group("/api/v1", s.requireAdmin) - userAPI := s.router.Group("/api/v1/users", s.requireSuperAdmin) + userAPI := s.router.Group("/api/v1/users", s.requireAuthenticated) userAPI.GET("", s.listUsers) - userAPI.POST("", s.createUser) + userAPI.POST("", s.requireSuperAdmin, s.createUser) userAPI.GET("/:id", s.getUser) - userAPI.PUT("/:id", s.updateUser) - userAPI.DELETE("/:id", s.deleteUser) + userAPI.PUT("/:id", s.requireSuperAdmin, s.updateUser) + userAPI.DELETE("/:id", s.requireSuperAdmin, s.deleteUser) adminAPI.GET("/domains", s.listDomains) adminAPI.POST("/domains", s.dnsOperation, s.createDomain) adminAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain) diff --git a/backend/internal/httpapi/user_groups.go b/backend/internal/httpapi/user_groups.go new file mode 100644 index 0000000..e546060 --- /dev/null +++ b/backend/internal/httpapi/user_groups.go @@ -0,0 +1,80 @@ +package httpapi + +import ( + "net/http" + + "git.pcux.cn/xuans/Scert/backend/internal/repository" + "github.com/gin-gonic/gin" +) + +// userListViewer 每次从仓储读取当前分组,避免调组后沿用会话中的旧权限。 +func (s *Server) userListViewer(c *gin.Context) (repository.User, bool) { + session, ok := s.currentAuthSession(c) + if !ok { + s.writeError(c, http.StatusUnauthorized, "请先登录管理后台", nil) + return repository.User{}, false + } + if session.Role == repository.UserRoleSuperAdmin { + return repository.User{ID: session.UserID, Role: session.Role}, true + } + viewer, err := s.users.GetUserByID(c.Request.Context(), session.UserID) + if err != nil || viewer.Status != repository.UserStatusActive || viewer.Role != session.Role { + s.writeError(c, http.StatusForbidden, "当前账号无法读取用户列表", nil) + return repository.User{}, false + } + return viewer, true +} + +func userVisibleTo(viewer, item repository.User) bool { + if viewer.Role == repository.UserRoleSuperAdmin || viewer.ID == item.ID { + return true + } + if viewer.Role != repository.UserRoleAdmin || item.Role == repository.UserRoleSuperAdmin { + return false + } + if len(viewer.Groups) == 0 && len(item.Groups) == 0 { + return true + } + for _, group := range viewer.Groups { + for _, memberGroup := range item.Groups { + if group == memberGroup { + return true + } + } + } + return false +} + +// scopedUserGroups 不向普通用户输出分组,也不向管理员泄露其他分组名称。 +func scopedUserGroups(viewer, item repository.User) repository.User { + if viewer.Role == repository.UserRoleSuperAdmin { + return item + } + groups := make([]string, 0) + if viewer.Role == repository.UserRoleAdmin { + for _, group := range item.Groups { + for _, allowed := range viewer.Groups { + if group == allowed { + groups = append(groups, group) + break + } + } + } + } + item.Groups = groups + return item +} + +func (s *Server) applyRequestedUserGroups(c *gin.Context, item *repository.User, requested *[]string) bool { + groups := item.Groups + if requested != nil { + groups = *requested + } + var err error + item.Groups, err = repository.NormalizeUserGroups(item.Role, groups) + if err != nil { + s.writeError(c, http.StatusBadRequest, err.Error(), nil) + return false + } + return true +} diff --git a/backend/internal/httpapi/user_groups_test.go b/backend/internal/httpapi/user_groups_test.go new file mode 100644 index 0000000..5946287 --- /dev/null +++ b/backend/internal/httpapi/user_groups_test.go @@ -0,0 +1,139 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "reflect" + "sort" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func TestUserGroupVisibilityAndWriteProtection(t *testing.T) { + server, users, _ := protectedUserServer(t) + password := "Member123a" + hash := httpUserPasswordHash(t, password) + for _, item := range []repository.User{ + {ID: "leader", Username: "leader", Role: "admin", Groups: []string{"甲组", "乙组"}}, + {ID: "peer", Username: "peer", Role: "admin", Groups: []string{"乙组", "隐藏组"}}, + {ID: "first", Username: "first", Role: "user", Groups: []string{"甲组"}}, + {ID: "second", Username: "second", Role: "user", Groups: []string{"乙组"}}, + {ID: "other", Username: "other", Role: "user", Groups: []string{"隐藏组"}}, + {ID: "default-user", Username: "default-user", Role: "user"}, + {ID: "default-admin", Username: "default-admin", Role: "admin"}, + } { + item.PasswordHash, item.Status = hash, "active" + if err := users.CreateUser(t.Context(), item); err != nil { + t.Fatal(err) + } + } + for _, tc := range []struct { + name string + want []string + }{ + {"leader", []string{"first", "leader", "peer", "second"}}, + {"first", []string{"first"}}, + {"default-admin", []string{"default-admin", "default-user"}}, + } { + t.Run(tc.name, func(t *testing.T) { + cookie := loginAuthTest(t, server, tc.name, password, false).Result().Cookies()[0] + response := serveUserRequest(server, cookie, http.MethodGet, "/api/v1/users", "") + if response.Code != http.StatusOK { + t.Fatalf("读取分组失败:%d %s", response.Code, response.Body.String()) + } + var body struct { + Items []repository.User `json:"items"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + ids := make([]string, 0, len(body.Items)) + for _, item := range body.Items { + ids = append(ids, item.ID) + } + sort.Strings(ids) + if !reflect.DeepEqual(ids, tc.want) { + t.Fatalf("用户范围错误:%v", ids) + } + if strings.Contains(response.Body.String(), "隐藏组") { + t.Fatal("泄露组外分组名称") + } + if tc.name == "first" && strings.Contains(response.Body.String(), `"groups"`) { + t.Fatal("普通用户不应收到分组信息") + } + outside := serveUserRequest(server, cookie, http.MethodGet, "/api/v1/users/other", "") + if outside.Code != http.StatusNotFound { + t.Fatalf("组外读取应返回 404:%d", outside.Code) + } + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} { + path := "/api/v1/users/first" + if method == http.MethodPost { + path = "/api/v1/users" + } + result := serveUserRequest(server, cookie, method, path, `{"username":"first","role":"admin","groups":["隐藏组"]}`) + if result.Code != http.StatusForbidden { + t.Fatalf("越权写入应返回 403:%s %d", method, result.Code) + } + } + }) + } + superCookie := loginAuthTest(t, server, "admin", "AdminPass123", false).Result().Cookies()[0] + all := serveUserRequest(server, superCookie, http.MethodGet, "/api/v1/users", "") + if all.Code != http.StatusOK || !strings.Contains(all.Body.String(), "隐藏组") || !strings.Contains(all.Body.String(), "default-user") { + t.Fatal("超级管理员应读取所有分组") + } + for _, payload := range []string{ + `{"username":"new-user","password":"Member123a","role":"user","groups":["甲组","乙组"]}`, + `{"username":"new-user","password":"Member123a","role":"user","groups":["默认分组"]}`, + `{"username":"new-user","password":"Member123a","role":"user","groups":[""]}`, + } { + response := serveUserRequest(server, superCookie, http.MethodPost, "/api/v1/users", payload) + if response.Code != http.StatusBadRequest { + t.Fatalf("非法分组未拒绝:%d", response.Code) + } + } + // 降级时不得静默丢弃管理员的多组成员关系。 + badRole := serveUserRequest(server, superCookie, http.MethodPut, "/api/v1/users/leader", `{"username":"leader","role":"user"}`) + if badRole.Code != http.StatusBadRequest { + t.Fatal("多组管理员降级应要求明确选择单组") + } + updated := serveUserRequest(server, superCookie, http.MethodPut, "/api/v1/users/leader", `{"username":"leader","role":"user","groups":["乙组"]}`) + if updated.Code != http.StatusOK { + t.Fatalf("单组降级失败:%s", updated.Body.String()) + } + cleared := serveUserRequest(server, superCookie, http.MethodPut, "/api/v1/users/leader", `{"username":"leader","groups":[]}`) + if cleared.Code != http.StatusOK { + t.Fatalf("回到默认分组失败:%s", cleared.Body.String()) + } + item, err := users.GetUserByID(t.Context(), "leader") + if err != nil || len(item.Groups) != 0 { + t.Fatalf("分组清空未保存:%v", err) + } +} + +func TestUserGroupsAreReadFromCurrentRepository(t *testing.T) { + server, users, _ := protectedUserServer(t) + hash := httpUserPasswordHash(t, "Member123a") + for _, item := range []repository.User{ + {ID: "leader", Username: "leader", Role: "admin", Groups: []string{"甲组"}}, + {ID: "first", Username: "first", Role: "user", Groups: []string{"甲组"}}, + {ID: "second", Username: "second", Role: "user", Groups: []string{"乙组"}}, + } { + item.PasswordHash, item.Status = hash, "active" + if err := users.CreateUser(t.Context(), item); err != nil { + t.Fatal(err) + } + } + cookie := loginAuthTest(t, server, "leader", "Member123a", false).Result().Cookies()[0] + item, _ := users.GetUserByID(t.Context(), "leader") + item.Groups = []string{"乙组"} + if err := users.UpdateUser(t.Context(), item); err != nil { + t.Fatal(err) + } + response := serveUserRequest(server, cookie, http.MethodGet, "/api/v1/users", "") + if response.Code != http.StatusOK || strings.Contains(response.Body.String(), `"id":"first"`) || !strings.Contains(response.Body.String(), `"id":"second"`) { + t.Fatalf("旧会话未应用新分组范围:%s", response.Body.String()) + } +} diff --git a/backend/internal/httpapi/users_api.go b/backend/internal/httpapi/users_api.go index 293ceec..43bef36 100644 --- a/backend/internal/httpapi/users_api.go +++ b/backend/internal/httpapi/users_api.go @@ -14,11 +14,12 @@ import ( ) type userRequest struct { - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` - Role string `json:"role"` - Status string `json:"status"` + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` + Status string `json:"status"` + Groups *[]string `json:"groups"` } func (s *Server) requireSuperAdmin(c *gin.Context) { @@ -53,12 +54,22 @@ func (s *Server) listUsers(c *gin.Context) { s.writeError(c, http.StatusNotImplemented, "用户管理接口尚未配置", errors.New("用户仓储未配置")) return } + viewer, ok := s.userListViewer(c) + if !ok { + return + } items, err := s.users.ListUsers(c.Request.Context()) if err != nil { s.writeError(c, http.StatusInternalServerError, "查询用户失败", err) return } - c.JSON(http.StatusOK, gin.H{"items": items}) + visible := make([]repository.User, 0, len(items)) + for _, item := range items { + if userVisibleTo(viewer, item) { + visible = append(visible, scopedUserGroups(viewer, item)) + } + } + c.JSON(http.StatusOK, gin.H{"items": visible, "current_user_id": viewer.ID, "current_role": viewer.Role}) } func (s *Server) getUser(c *gin.Context) { @@ -66,6 +77,10 @@ func (s *Server) getUser(c *gin.Context) { s.writeError(c, http.StatusNotImplemented, "用户管理接口尚未配置", errors.New("用户仓储未配置")) return } + viewer, ok := s.userListViewer(c) + if !ok { + return + } item, err := s.users.GetUserByID(c.Request.Context(), c.Param("id")) if err != nil { if repository.IsNotFound(err) { @@ -75,7 +90,11 @@ func (s *Server) getUser(c *gin.Context) { s.writeError(c, http.StatusInternalServerError, "读取用户失败", err) return } - c.JSON(http.StatusOK, item) + if !userVisibleTo(viewer, item) { + s.writeError(c, http.StatusNotFound, "用户不存在", nil) + return + } + c.JSON(http.StatusOK, scopedUserGroups(viewer, item)) } func (s *Server) createUser(c *gin.Context) { @@ -97,6 +116,9 @@ func (s *Server) createUser(c *gin.Context) { s.writeError(c, http.StatusBadRequest, "系统只允许初始化时创建一个超级管理员", nil) return } + if !s.applyRequestedUserGroups(c, &item, request.Groups) { + return + } if err := s.users.CreateUser(c.Request.Context(), item); err != nil { s.writeError(c, userErrorStatus(err), "创建用户失败", err) return @@ -143,6 +165,10 @@ func (s *Server) updateUser(c *gin.Context) { if request.Status == "" { item.Status = existing.Status } + item.Groups = existing.Groups + if !s.applyRequestedUserGroups(c, &item, request.Groups) { + return + } if existing.Role != repository.UserRoleSuperAdmin && item.Role == repository.UserRoleSuperAdmin { s.writeError(c, http.StatusBadRequest, "不能将用户提升为超级管理员", nil) return diff --git a/backend/internal/httpapi/users_api_test.go b/backend/internal/httpapi/users_api_test.go index 0ef4a8c..729b7e5 100644 --- a/backend/internal/httpapi/users_api_test.go +++ b/backend/internal/httpapi/users_api_test.go @@ -54,8 +54,8 @@ func TestUserManagementAuthorizationAndCRUD(t *testing.T) { } regularCookie := httpUserSessionCookie(server, "user-1", repository.UserRoleUser) forbidden := serveUserRequest(server, regularCookie, http.MethodGet, "/api/v1/users", "") - if forbidden.Code != http.StatusForbidden { - t.Fatalf("普通用户请求应返回 403:%d %s", forbidden.Code, forbidden.Body.String()) + if forbidden.Code != http.StatusOK || strings.Contains(forbidden.Body.String(), "primary-super-admin") || strings.Contains(forbidden.Body.String(), `"groups"`) { + t.Fatalf("普通用户只能读取自己且不显示分组:%d %s", forbidden.Code, forbidden.Body.String()) } adminCookie := httpUserSessionCookie(server, "primary-super-admin", repository.UserRoleSuperAdmin) diff --git a/backend/internal/repository/user_groups.go b/backend/internal/repository/user_groups.go new file mode 100644 index 0000000..f8f5d37 --- /dev/null +++ b/backend/internal/repository/user_groups.go @@ -0,0 +1,47 @@ +package repository + +import ( + "encoding/json" + "errors" + "sort" + "strings" + "unicode" +) + +// NormalizeUserGroups 将未分类表示为空列表,并统一校验分组数量及名称。 +func NormalizeUserGroups(role string, groups []string) ([]string, error) { + if len(groups) > 32 { + return nil, errors.New("分组数量不能超过 32 个") + } + result := make([]string, 0, len(groups)) + seen := make(map[string]bool) + for _, name := range groups { + name = strings.TrimSpace(name) + if name == "" || name == "默认分组" || len([]rune(name)) > 32 { + return nil, errors.New("分组名称无效:请输入 1 到 32 个字符,默认分组请保持未分组") + } + for _, char := range name { + if unicode.IsControl(char) { + return nil, errors.New("分组名称不能包含控制字符") + } + } + if !seen[name] { + result = append(result, name) + seen[name] = true + } + } + if strings.EqualFold(strings.TrimSpace(role), UserRoleUser) && len(result) > 1 { + return nil, errors.New("普通用户只能属于一个分组") + } + sort.Strings(result) + return result, nil +} + +func encodeUserGroups(item User) (string, error) { + groups, err := NormalizeUserGroups(item.Role, item.Groups) + if err != nil { + return "", err + } + data, err := json.Marshal(groups) + return string(data), err +} diff --git a/backend/internal/repository/user_groups_test.go b/backend/internal/repository/user_groups_test.go new file mode 100644 index 0000000..4fcf68a --- /dev/null +++ b/backend/internal/repository/user_groups_test.go @@ -0,0 +1,41 @@ +package repository + +import ( + "reflect" + "strings" + "testing" +) + +func TestUserGroupsValidationAndPersistence(t *testing.T) { + db := openUserTestDatabase(t) + defer db.Close() + repo := NewSQLUserRepository(db.DB) + item := User{ID: "group-admin", Username: "group-admin", Role: UserRoleAdmin, Status: UserStatusActive, PasswordHash: userTestPasswordHash(t, "Member123a"), Groups: []string{" beta ", "alpha", "alpha"}} + if err := repo.CreateUser(t.Context(), item); err != nil { + t.Fatal(err) + } + got, err := repo.GetUserByID(t.Context(), item.ID) + if err != nil || !reflect.DeepEqual(got.Groups, []string{"alpha", "beta"}) { + t.Fatalf("分组未规范化保存:%+v %v", got.Groups, err) + } + got.Role = UserRoleUser + if err := repo.UpdateUser(t.Context(), got); err == nil { + t.Fatal("仓储层应拒绝普通用户多组") + } + got.Groups = []string{"alpha"} + if err := repo.UpdateUser(t.Context(), got); err != nil { + t.Fatal(err) + } + if _, err := db.DB.Exec("UPDATE users SET groups_json = NULL WHERE id = ?", item.ID); err != nil { + t.Fatal(err) + } + legacy, err := repo.GetUserByID(t.Context(), item.ID) + if err != nil || len(legacy.Groups) != 0 { + t.Fatal("旧用户应归入默认分组") + } + for _, groups := range [][]string{{""}, {"默认分组"}, {"bad\nname"}, {strings.Repeat("组", 33)}, make([]string, 33)} { + if _, err := NormalizeUserGroups(UserRoleAdmin, groups); err == nil { + t.Fatalf("非法分组未拒绝:%v", groups) + } + } +} diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index ee24f98..6423684 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "strings" @@ -30,6 +31,7 @@ type User struct { Email string `json:"email"` PasswordHash string `json:"-"` Role string `json:"role"` + Groups []string `json:"groups,omitempty"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -131,16 +133,20 @@ func (r *SQLUserRepository) CreateUser(ctx context.Context, item User) error { if err := validateUser(item, false); err != nil { return err } + groups, err := encodeUserGroups(item) + if err != nil { + return err + } if item.CreatedAt.IsZero() { item.CreatedAt = time.Now().UTC() } if item.UpdatedAt.IsZero() { item.UpdatedAt = item.CreatedAt } - _, err := r.db.ExecContext(ctx, r.query(`INSERT INTO users - (id, username, email, password_hash, role, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Username, r.emailValue(item.Email), item.PasswordHash, - item.Role, item.Status, item.CreatedAt, item.UpdatedAt) + _, err = r.db.ExecContext(ctx, r.query(`INSERT INTO users + (id, username, email, password_hash, role, status, created_at, updated_at, groups_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Username, r.emailValue(item.Email), item.PasswordHash, + item.Role, item.Status, item.CreatedAt, item.UpdatedAt, groups) if err != nil { return fmt.Errorf("保存用户失败:%w", err) } @@ -155,8 +161,12 @@ func (r *SQLUserRepository) UpdateUser(ctx context.Context, item User) error { if err := validateUser(item, false); err != nil { return err } - result, err := r.db.ExecContext(ctx, r.query(`UPDATE users SET username = ?, email = ?, password_hash = ?, role = ?, status = ?, updated_at = ? WHERE id = ?`), - item.Username, r.emailValue(item.Email), item.PasswordHash, item.Role, item.Status, time.Now().UTC(), item.ID) + groups, err := encodeUserGroups(item) + if err != nil { + return err + } + result, err := r.db.ExecContext(ctx, r.query(`UPDATE users SET username = ?, email = ?, password_hash = ?, role = ?, status = ?, updated_at = ?, groups_json = ? WHERE id = ?`), + item.Username, r.emailValue(item.Email), item.PasswordHash, item.Role, item.Status, time.Now().UTC(), groups, item.ID) if err != nil { return fmt.Errorf("更新用户失败:%w", err) } @@ -283,15 +293,17 @@ func validateUser(item User, allowEmptyID bool) error { if item.Status != UserStatusActive && item.Status != UserStatusDisabled { return fmt.Errorf("用户状态无效:%s", item.Status) } - return nil + _, err := NormalizeUserGroups(item.Role, item.Groups) + return err } -const userSelect = `SELECT id, username, email, password_hash, role, status, created_at, updated_at FROM users` +const userSelect = `SELECT id, username, email, password_hash, role, status, created_at, updated_at, COALESCE(groups_json, '[]') FROM users` func scanUser(row interface{ Scan(...any) error }) (User, error) { var item User var email sql.NullString - if err := row.Scan(&item.ID, &item.Username, &email, &item.PasswordHash, &item.Role, &item.Status, &item.CreatedAt, &item.UpdatedAt); err != nil { + var groups string + if err := row.Scan(&item.ID, &item.Username, &email, &item.PasswordHash, &item.Role, &item.Status, &item.CreatedAt, &item.UpdatedAt, &groups); err != nil { if errors.Is(err, sql.ErrNoRows) { return User{}, sql.ErrNoRows } @@ -300,5 +312,13 @@ func scanUser(row interface{ Scan(...any) error }) (User, error) { if email.Valid { item.Email = email.String } + if err := json.Unmarshal([]byte(groups), &item.Groups); err != nil { + return User{}, fmt.Errorf("读取用户分组失败:%w", err) + } + var err error + item.Groups, err = NormalizeUserGroups(item.Role, item.Groups) + if err != nil { + return User{}, err + } return normalizeUser(item), nil } diff --git a/doc/user-groups.md b/doc/user-groups.md new file mode 100644 index 0000000..ff6fc2c --- /dev/null +++ b/doc/user-groups.md @@ -0,0 +1,44 @@ +# 用户分组接口与权限说明 + +适用项目:Scert。更新日期:2026-09-25。 + +## 页面入口 + +- 系统设置位于侧栏的系统管理区域,打开已有设置面板。 +- 修改密码位于用户管理页面,修改当前登录账号的密码。 +- 默认分组置顶并始终展开;命名分组初始折叠。 +- 普通用户进入用户中心,只显示本人资料及修改密码,不显示分组。 + +## 可见范围 + +| 角色 | 用户读取范围 | 分组关系 | 用户新增、编辑、删除 | +|---|---|---|---| +| 超级管理员 | 全部用户和分组 | 主账号保持受保护 | 允许,主账号保护规则不变 | +| 管理员 | 自己及所属组成员的并集 | 可加入多个命名分组 | 不允许 | +| 普通用户 | 仅自己 | 最多一个命名分组 | 不允许 | + +没有命名分组的账号属于默认分组。未分组管理员可见默认组成员,但不可见其他超级管理员。管理员读取多组成员时,只返回双方共同的分组,不泄露对方的其他组名。这里限定的是用户列表可见范围,不代表域名、证书、服务器资源已经实现分组隔离。 + +## 接口 + +沿用现有会话认证与同源校验。 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/v1/users` | 按角色过滤的用户列表 | +| GET | `/api/v1/users/:id` | 查询可见用户;越权与不存在均返回 404 | +| POST | `/api/v1/users` | 超级管理员创建用户 | +| PUT | `/api/v1/users/:id` | 超级管理员更新用户 | +| DELETE | `/api/v1/users/:id` | 超级管理员删除用户 | + +列表响应增加 `current_role`、`current_user_id`。用户对象的 `groups` 为字符串数组;默认组和普通用户读取时省略该字段。密码摘要不返回。 + +新增和更新请求支持 `groups`:省略或传 `null` 时,新增使用默认组,更新保留原关系;传 `[]` 清空命名分组、回到默认组。组名去首尾空白、去重并排序,区分大小写,长度为 1 到 32 个字符,不允许控制字符或保留名称“默认分组”。最多 32 个组,普通用户最多 1 个组。多组管理员降级为普通用户时,必须明确保留不超过一个组,否则返回 400,不会静默丢失分组。 + +分组名称由保存用户时创建;没有独立空组、组重命名或组删除接口。删除最后一个组内成员后,该组不再显示。 + +## 数据升级与验证边界 + +迁移版本 8 为用户表增加可空的 `groups_json` 文本字段。旧记录的空值解释为默认分组,保留原账号、角色和密码摘要。每次读取用户列表时重新读取当前账号分组,调组不依赖重新登录。 + +SQLite 的旧版本升级与重复迁移由专项测试覆盖;MySQL 和 PostgreSQL 提供迁移文件,但本次专项测试不代表这两种数据库已完成真实环境升级验收。 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ac50f0c..389720a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,6 @@ + diff --git a/frontend/src/views/UserManagement/UserGroupEditor.js b/frontend/src/views/UserManagement/UserGroupEditor.js new file mode 100644 index 0000000..29cfe95 --- /dev/null +++ b/frontend/src/views/UserManagement/UserGroupEditor.js @@ -0,0 +1,30 @@ +import { ref } from "vue"; + +export function useUserGroupEditor(props, emit) { + const groupName = ref(""); + const groupError = ref(""); + function addGroup() { + const name = groupName.value.trim(); + groupError.value = ""; + if (!name || name === "默认分组" || [...name].length > 32 || /[\u0000-\u001f\u007f-\u009f]/u.test(name)) { + groupError.value = "请输入 1 到 32 个字符的分组名称;默认分组无需添加"; + return; + } + const groups = [...new Set([...props.modelValue, name])]; + if (props.role === "user" && groups.length > 1) { + groupError.value = "普通用户只能属于一个分组,请先移除原分组"; + return; + } + if (groups.length > 32) { + groupError.value = "分组数量不能超过 32 个"; + return; + } + emit("update:modelValue", groups); + groupName.value = ""; + } + function removeGroup(name) { + emit("update:modelValue", props.modelValue.filter((item) => item !== name)); + groupError.value = ""; + } + return { groupName, groupError, addGroup, removeGroup }; +} diff --git a/frontend/src/views/UserManagement/UserGroupEditor.vue b/frontend/src/views/UserManagement/UserGroupEditor.vue new file mode 100644 index 0000000..8fe5511 --- /dev/null +++ b/frontend/src/views/UserManagement/UserGroupEditor.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/frontend/src/views/UserManagement/UserManagement.css b/frontend/src/views/UserManagement/UserManagement.css index a954918..9015287 100644 --- a/frontend/src/views/UserManagement/UserManagement.css +++ b/frontend/src/views/UserManagement/UserManagement.css @@ -1,5 +1,79 @@ .user-management { margin-top: 18px; + padding: 24px; + border: 1px solid #e2e8f2; + border-radius: 20px; + background: rgba(255, 255, 255, 0.86); +} +.user-groups { + display: grid; + gap: 12px; +} +.user-group { + min-width: 0; + border: 1px solid #e2e8f2; + border-radius: 12px; + overflow: hidden; +} +.user-group-heading { + margin: 0; + padding: 12px 16px; + color: #32405a; + background: #f7faff; + font-size: 14px; +} +summary.user-group-heading { + cursor: pointer; +} +.user-group-heading span { + margin-left: 12px; + color: #71809a; + font-size: 12px; + font-weight: 400; +} +.user-group-empty { + margin: 0; + padding: 16px; + color: #71809a; + font-size: 13px; +} +.group-editor { + margin: 16px 0; + padding: 12px; + border: 1px solid #d5ddea; + border-radius: 10px; +} +.group-editor legend { + color: #394963; + font-size: 13px; +} +.group-selection, +.group-input-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 8px 0; +} +.group-input-row input { + flex: 1; + min-width: 120px; + padding: 10px; + border: 1px solid #d5ddea; + border-radius: 8px; +} +.group-selection button { + border: 1px solid #d5ddea; + padding: 5px 8px; + border-radius: 6px; + color: #315aaa; + background: #edf3ff; + cursor: pointer; +} +.user-group-heading:focus-visible, +.user-table-wrap:focus-visible { + outline: 2px solid #315aaa; + outline-offset: -2px; } .user-management-heading { display: flex; diff --git a/frontend/src/views/UserManagement/UserManagement.js b/frontend/src/views/UserManagement/UserManagement.js index ea29612..f473094 100644 --- a/frontend/src/views/UserManagement/UserManagement.js +++ b/frontend/src/views/UserManagement/UserManagement.js @@ -11,8 +11,27 @@ const statusLabels = { disabled: "已禁用", }; +export function groupUsers(users) { + const groups = new Map(); + const defaults = { name: "默认分组", isDefault: true, users: [] }; + for (const user of users) { + const names = [...new Set(user.groups || [])]; + if (!names.length) defaults.users.push(user); + for (const name of names) { + if (!groups.has(name)) groups.set(name, { name, isDefault: false, users: [] }); + groups.get(name).users.push(user); + } + } + return [defaults, ...[...groups.values()].sort((a, b) => a.name.localeCompare(b.name, "zh-CN"))]; +} + export function useUserManagement() { - const visible = ref(false); + const visible = ref(true); + const currentRole = ref(""); + const canManage = computed(() => currentRole.value === "super_admin"); + const showGroups = computed(() => ["super_admin", "admin"].includes(currentRole.value)); + const groupedUsers = computed(() => groupUsers(users.value)); + const availableGroups = computed(() => groupedUsers.value.filter((group) => !group.isDefault).map((group) => group.name)); const users = ref([]); const loading = ref(false); const saving = ref(false); @@ -54,6 +73,7 @@ export function useUserManagement() { const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "读取用户列表失败"); users.value = result.items || []; + currentRole.value = result.current_role || ""; visible.value = true; } catch (err) { if (err.message !== "登录会话已过期,请重新登录") { @@ -71,10 +91,12 @@ export function useUserManagement() { password: "", role: "user", status: "active", + groups: [], }; } function openCreate() { + if (!canManage.value) return; editingId.value = ""; editingRole.value = ""; form.value = createEmptyForm(); @@ -83,7 +105,7 @@ export function useUserManagement() { } function openEdit(item) { - if (isPrimaryAccount(item)) return; + if (!canManage.value || isPrimaryAccount(item)) return; editingId.value = item.id; editingRole.value = item.role; form.value = { @@ -92,6 +114,7 @@ export function useUserManagement() { password: "", role: item.role || "user", status: item.status || "active", + groups: [...(item.groups || [])], }; formError.value = ""; showForm.value = true; @@ -103,11 +126,19 @@ export function useUserManagement() { async function saveUser() { formError.value = ""; + if (!canManage.value) { + formError.value = "只有超级管理员可以修改用户"; + return; + } if (editingId.value === "primary-super-admin") { formError.value = "主超级管理员不能通过用户管理修改"; return; } const payload = { ...form.value }; + if (payload.role.trim().toLowerCase() === "user" && payload.groups.length > 1) { + formError.value = "普通用户只能属于一个分组,请保留一个分组或清空为默认分组"; + return; + } if (payload.role.trim().toLowerCase() === "super_admin" && editingRole.value !== "super_admin") { formError.value = "不能新增或提升为超级管理员"; return; @@ -143,7 +174,7 @@ export function useUserManagement() { } async function toggleStatus(item) { - if (isPrimaryAccount(item)) return; + if (!canManage.value || isPrimaryAccount(item)) return; const nextStatus = item.status === "active" ? "disabled" : "active"; if (item.role === "super_admin" && nextStatus === "disabled") { notice.value = "最后一个超级管理员不能被禁用"; @@ -169,6 +200,7 @@ export function useUserManagement() { } async function deleteUser(item) { + if (!canManage.value) return; if (isPrimaryAccount(item)) return; if (!window.confirm(`确定删除用户“${item.username}”吗?`)) return; try { @@ -191,6 +223,11 @@ export function useUserManagement() { onMounted(loadUsers); return { + currentRole, + canManage, + showGroups, + groupedUsers, + availableGroups, visible, users, loading, diff --git a/frontend/src/views/UserManagement/UserManagement.test.js b/frontend/src/views/UserManagement/UserManagement.test.js index 729efaa..13ec7a0 100644 --- a/frontend/src/views/UserManagement/UserManagement.test.js +++ b/frontend/src/views/UserManagement/UserManagement.test.js @@ -1,13 +1,14 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { useUserManagement } from "./UserManagement.js"; +import { groupUsers, useUserManagement } from "./UserManagement.js"; +import { useUserGroupEditor } from "./UserGroupEditor.js"; import { mountComposable } from "../../test-support/mount.js"; async function setup(t) { const calls = []; const mounted = await mountComposable(t, useUserManagement, async (path, options) => { calls.push({ path, options }); - return Response.json({ items: [] }); + return Response.json({ items: [], current_role: "super_admin" }); }); calls.length = 0; return { ...mounted, calls }; @@ -51,3 +52,89 @@ test("管理员和普通用户仍能正常创建及编辑", async (t) => { assert.deepEqual(writes.map(({ options }) => options.method), ["POST", "PUT", "POST", "PUT"]); assert.equal(state.formError.value, ""); }); + +test("默认分组置顶,多组管理员同时出现在所属分组且不重复", () => { + const members = [ + { id: "multi", groups: ["乙组", "甲组", "甲组"] }, + { id: "single", groups: ["甲组"] }, + { id: "default" }, + ]; + const result = groupUsers(members); + assert.equal(result[0].isDefault, true); + assert.deepEqual(result[0].users.map((user) => user.id), ["default"]); + assert.equal(result.find((group) => group.name === "甲组").users.length, 2); + assert.equal(result.find((group) => group.name === "乙组").users.length, 1); +}); + +test("管理员只能读取成员,普通用户不显示分组,未知权限默认关闭写入", async (t) => { + for (const role of ["admin", "user", ""]) { + const writes = []; + const { state } = await mountComposable(t, useUserManagement, async (path, options) => { + if (options.method) writes.push(path); + return Response.json({ items: [], current_role: role }); + }); + assert.equal(state.canManage.value, false); + assert.equal(state.showGroups.value, role === "admin"); + state.openCreate(); + state.openEdit({ id: "member", role: "user" }); + assert.equal(state.showForm.value, false); + await state.saveUser(); + await state.toggleStatus({ id: "member", status: "active" }); + await state.deleteUser({ id: "member" }); + assert.deepEqual(writes, []); + } +}); + +test("普通用户不允许多组,降级时保留选择供用户修正", async (t) => { + const { state, calls } = await setup(t); + state.openEdit({ id: "leader", username: "leader", role: "admin", groups: ["甲组", "乙组"] }); + state.form.value.role = "user"; + await state.saveUser(); + assert.match(state.formError.value, /只能属于一个分组/); + assert.deepEqual(state.form.value.groups, ["甲组", "乙组"]); + assert.equal(calls.length, 0); + state.form.value.groups = ["乙组"]; + await state.saveUser(); + assert.deepEqual(JSON.parse(calls.find((call) => call.options.method === "PUT").options.body).groups, ["乙组"]); +}); + +test("分组编辑器支持管理员多组并拒绝普通用户多组及保留名称", () => { + const props = { modelValue: [], role: "admin" }; + const editor = useUserGroupEditor(props, (_, value) => { props.modelValue = value; }); + for (const name of [" 甲组 ", "乙组", "甲组"]) { + editor.groupName.value = name; + editor.addGroup(); + } + assert.deepEqual(props.modelValue, ["甲组", "乙组"]); + props.role = "user"; + editor.groupName.value = "丙组"; + editor.addGroup(); + assert.match(editor.groupError.value, /只能属于一个分组/); + editor.removeGroup("乙组"); + assert.deepEqual(props.modelValue, ["甲组"]); + editor.groupName.value = "默认分组"; + editor.addGroup(); + assert.match(editor.groupError.value, /默认分组无需添加/); +}); + +test("分组页面结构可编译,默认组不折叠,命名组默认关闭,密码入口仅在用户管理", async () => { + const { readFile } = await import("node:fs/promises"); + const { parse, compileScript, compileTemplate } = await import("@vue/compiler-sfc"); + for (const file of ["UserManagement.vue", "UserTable.vue", "UserGroupEditor.vue", "UserWorkspace.vue"]) { + const source = await readFile(new URL(file, import.meta.url), "utf8"); + const { descriptor, errors } = parse(source); + assert.deepEqual(errors, []); + assert.ok(compileScript(descriptor, { id: file })); + assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: file, id: file }).errors, []); + } + const source = await readFile(new URL("UserManagement.vue", import.meta.url), "utf8"); + assert.match(source, /section v-if="group.isDefault"/); + assert.match(source, /
/); + assert.match(source, /v-else-if="showGroups"/); + assert.match(source, / import HelpTip from "../components/HelpTip/HelpTip.vue"; import { useUserManagement } from "./UserManagement.js"; +import ChangePassword from "../components/ChangePassword/ChangePassword.vue"; +import UserTable from "./UserTable.vue"; +import UserGroupEditor from "./UserGroupEditor.vue"; const { + canManage, + showGroups, + groupedUsers, + availableGroups, visible, users, loading, @@ -38,10 +45,11 @@ const {
+ - +
@@ -49,42 +57,37 @@ const {

{{ notice }}

正在读取用户列表……
-
暂无数据库用户,配置中的超级管理员仍可登录。
-
- - - - - - - - - - - - - - - - - - - - - -
账号邮箱角色状态创建时间操作
{{ item.username }}{{ item.email || "未绑定" }}{{ roleLabels[item.role] || item.role }}{{ statusLabels[item.status] || item.status }}{{ new Date(item.created_at).toLocaleString("zh-CN") }} - 受保护账号 -
- - - -
-
+
+
+ -