fix(admin): 收紧 isSuper、plan-prices RBAC 与封禁状态机

避免 roles.write 绕过全部 can();定价读写挂 membership.plans 权限;去掉快捷封禁双路径并让 ban/unban 走 lifecycle。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-13 01:56:00 +08:00
co-authored by Cursor
parent 62cd8c45dd
commit 0d50c0ee73
10 changed files with 44 additions and 64 deletions
+2 -2
View File
@@ -36,8 +36,8 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
authed.POST("/users/:id/membership/grant", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipGrant), h.GrantMembership)
authed.POST("/users/:id/ask-quota/grant", middleware.RequireAdminPermission(h.Svc, admin.PermAskQuotaGrant), h.GrantAskQuota)
authed.GET("/orders", middleware.RequireAdminPermission(h.Svc, admin.PermOrdersRead), h.ListOrders)
authed.GET("/membership/plan-prices", h.ListPlanPrices)
authed.PUT("/membership/plan-prices", h.PutPlanPrices)
authed.GET("/membership/plan-prices", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansRead), h.ListPlanPrices)
authed.PUT("/membership/plan-prices", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansWrite), h.PutPlanPrices)
authed.GET("/audit-logs", middleware.RequireAdminPermission(h.Svc, admin.PermAuditRead), h.ListAudit)
authed.GET("/analytics/overview", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsOverview)
authed.GET("/analytics/pages", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsPages)
+10 -2
View File
@@ -14,8 +14,8 @@ import (
)
func (h *AdminHandler) registerSystem(authed *gin.RouterGroup) {
authed.POST("/users/:id/ban", h.BanUser)
authed.POST("/users/:id/unban", h.UnbanUser)
authed.POST("/users/:id/ban", middleware.RequireAdminPermission(h.Svc, admin.PermUsersStatusWrite), h.BanUser)
authed.POST("/users/:id/unban", middleware.RequireAdminPermission(h.Svc, admin.PermUsersStatusWrite), h.UnbanUser)
authed.GET("/admins", h.ListAdmins)
authed.PATCH("/admins/:id", h.PatchAdmin)
authed.GET("/push-jobs", h.ListPushJobs)
@@ -39,6 +39,10 @@ func (h *AdminHandler) BanUser(c *gin.Context) {
response.Fail(c, http.StatusNotFound, 40401, "user not found")
return
}
if errors.Is(err, admin.ErrReasonRequired) || errors.Is(err, admin.ErrInvalidStatusEdge) {
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
return
}
response.Fail(c, http.StatusInternalServerError, 50040, "ban failed")
return
}
@@ -61,6 +65,10 @@ func (h *AdminHandler) UnbanUser(c *gin.Context) {
response.Fail(c, http.StatusNotFound, 40401, "user not found")
return
}
if errors.Is(err, admin.ErrReasonRequired) || errors.Is(err, admin.ErrInvalidStatusEdge) {
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
return
}
response.Fail(c, http.StatusInternalServerError, 50041, "unban failed")
return
}
@@ -188,9 +188,26 @@ func insertOpsAdmin(t *testing.T, username, password string) {
if err != nil {
t.Fatalf("hash: %v", err)
}
var roleID uuid.UUID
err = pool.QueryRow(ctx, `
INSERT INTO admin_roles(name, system)
VALUES ('ops', false)
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
RETURNING id`).Scan(&roleID)
if err != nil {
t.Fatalf("ensure ops role: %v", err)
}
_, err = pool.Exec(ctx, `
INSERT INTO admin_accounts(username, password_hash, role, status)
VALUES ($1,$2,'ops','active')`, username, string(hash))
INSERT INTO admin_role_permissions(role_id, code) VALUES
($1, 'admin.users.read'),
($1, 'admin.users.status.write')
ON CONFLICT DO NOTHING`, roleID)
if err != nil {
t.Fatalf("ops role perms: %v", err)
}
_, err = pool.Exec(ctx, `
INSERT INTO admin_accounts(username, password_hash, role, status, role_id)
VALUES ($1,$2,'ops','active',$3)`, username, string(hash), roleID)
if err != nil {
t.Fatalf("insert ops admin: %v", err)
}
+1 -3
View File
@@ -234,10 +234,8 @@ func (s *Service) ListPlanPrices(ctx context.Context) ([]repository.PlanPrice, e
}
// UpsertPlanPrices updates display prices (not historical order amounts).
// Caller must enforce admin.membership.plans.write.
func (s *Service) UpsertPlanPrices(ctx context.Context, adminID uuid.UUID, items []repository.PlanPrice) error {
if err := s.RequireSuper(ctx, adminID); err != nil {
return err
}
if len(items) == 0 {
return ErrInvalidPlan
}
+4 -28
View File
@@ -39,38 +39,14 @@ func (s *Service) RequireSuper(ctx context.Context, adminID uuid.UUID) error {
return nil
}
// BanUser sets users.status=banned.
// BanUser transitions user status to banned via the lifecycle state machine.
func (s *Service) BanUser(ctx context.Context, adminID, userID uuid.UUID) error {
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return err
}
if !ok {
return ErrUserNotFound
}
meta, _ := json.Marshal(map[string]any{"status": "banned"})
err = s.Repo.SetUserStatusWithAudit(ctx, adminID, userID, "banned", "user.ban", meta)
if errors.Is(err, repository.ErrUserStatusNotFound) {
return ErrUserNotFound
}
return err
return s.TransitionUserStatus(ctx, adminID, userID, "banned", "admin ban")
}
// UnbanUser sets users.status=active.
// UnbanUser transitions user status to active via the lifecycle state machine.
func (s *Service) UnbanUser(ctx context.Context, adminID, userID uuid.UUID) error {
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return err
}
if !ok {
return ErrUserNotFound
}
meta, _ := json.Marshal(map[string]any{"status": "active"})
err = s.Repo.SetUserStatusWithAudit(ctx, adminID, userID, "active", "user.unban", meta)
if errors.Is(err, repository.ErrUserStatusNotFound) {
return ErrUserNotFound
}
return err
return s.TransitionUserStatus(ctx, adminID, userID, "active", "admin unban")
}
// ListAdmins returns admin accounts (super only caller).