From f8c374a624fead2076f3b1a55c982f65669e4e5e Mon Sep 17 00:00:00 2001 From: MengStack Dev Date: Sat, 3 Oct 2026 00:54:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(rbac):=20M3=20RBAC=20=E6=9D=83=E9=99=90?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4 表设计: permissions, roles, role_permissions(m2m), user_roles - 多租户隔离: 角色和用户角色分配按 tenant_id 隔离 - 权限并集: 多角色用户取权限并集,无继承 - 系统角色保护: is_system 角色不可修改/删除 - 种子数据: 7 个默认权限 + 3 个系统角色(super_admin/tenant_admin/member) - 权限中间件: RequirePermission 支持按路由粒度控制 - API 路由: /api/v1/rbac/ 下完整 CRUD 端点 - fx 注册: RBAC Module 集成到 app.go --- internal/app/app.go | 4 + internal/modules/rbac/application/service.go | 266 ++++++++++++++++++ internal/modules/rbac/domain/permission.go | 35 +++ internal/modules/rbac/domain/repository.go | 28 ++ internal/modules/rbac/domain/role.go | 82 ++++++ .../modules/rbac/infrastructure/migrate.go | 16 ++ .../rbac/infrastructure/permission_repo.go | 45 +++ .../modules/rbac/infrastructure/role_repo.go | 65 +++++ internal/modules/rbac/infrastructure/seed.go | 179 ++++++++++++ .../rbac/infrastructure/user_role_repo.go | 45 +++ internal/modules/rbac/interfaces/handler.go | 215 ++++++++++++++ internal/modules/rbac/interfaces/routes.go | 56 ++++ .../modules/rbac/middleware/permission.go | 41 +++ 13 files changed, 1077 insertions(+) create mode 100644 internal/modules/rbac/application/service.go create mode 100644 internal/modules/rbac/domain/permission.go create mode 100644 internal/modules/rbac/domain/repository.go create mode 100644 internal/modules/rbac/domain/role.go create mode 100644 internal/modules/rbac/infrastructure/migrate.go create mode 100644 internal/modules/rbac/infrastructure/permission_repo.go create mode 100644 internal/modules/rbac/infrastructure/role_repo.go create mode 100644 internal/modules/rbac/infrastructure/seed.go create mode 100644 internal/modules/rbac/infrastructure/user_role_repo.go create mode 100644 internal/modules/rbac/interfaces/handler.go create mode 100644 internal/modules/rbac/interfaces/routes.go create mode 100644 internal/modules/rbac/middleware/permission.go diff --git a/internal/app/app.go b/internal/app/app.go index 7a47a17..da387ad 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,6 +13,7 @@ import ( "mengstack/internal/config" "mengstack/internal/logger" authinterfaces "mengstack/internal/modules/auth/interfaces" + rbacinterfaces "mengstack/internal/modules/rbac/interfaces" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" @@ -37,6 +38,7 @@ func newEngine( rdb *redis.Client, authHandler *authinterfaces.Handler, authMW gin.HandlerFunc, + rbacHandler *rbacinterfaces.Handler, ) *gin.Engine { ginMode := "release" if cfg.Server.Mode == "debug" || cfg.Server.Mode == "dev" { @@ -56,6 +58,7 @@ func newEngine( r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) authinterfaces.SetupRoutes(r, authHandler, authMW) + rbacinterfaces.SetupRoutes(r, rbacHandler, authMW) return r } @@ -97,6 +100,7 @@ func NewApp() *fx.App { database.Module, cache.Module, authinterfaces.Module, + rbacinterfaces.Module, Module, ) } diff --git a/internal/modules/rbac/application/service.go b/internal/modules/rbac/application/service.go new file mode 100644 index 0000000..bffea42 --- /dev/null +++ b/internal/modules/rbac/application/service.go @@ -0,0 +1,266 @@ +package application + +import ( + "context" + + "mengstack/internal/kernel/errors" + "mengstack/internal/kernel/tenant" + "mengstack/internal/modules/rbac/domain" +) + +type Service struct { + permRepo domain.PermissionRepository + roleRepo domain.RoleRepository + userRoleRepo domain.UserRoleRepository +} + +func NewService( + permRepo domain.PermissionRepository, + roleRepo domain.RoleRepository, + userRoleRepo domain.UserRoleRepository, +) *Service { + return &Service{ + permRepo: permRepo, + roleRepo: roleRepo, + userRoleRepo: userRoleRepo, + } +} + +func (s *Service) ListPermissions(ctx context.Context) ([]domain.PermissionDTO, error) { + perms, err := s.permRepo.FindAll(ctx) + if err != nil { + return nil, errors.Wrap(err, "QUERY_PERMS_FAILED", "failed to query permissions", 500) + } + dtos := make([]domain.PermissionDTO, len(perms)) + for i, p := range perms { + dtos[i] = domain.ToPermissionDTO(&p) + } + return dtos, nil +} + +func (s *Service) ListPermissionsByModule(ctx context.Context, module string) ([]domain.PermissionDTO, error) { + perms, err := s.permRepo.FindByModule(ctx, module) + if err != nil { + return nil, errors.Wrap(err, "QUERY_PERMS_FAILED", "failed to query permissions", 500) + } + dtos := make([]domain.PermissionDTO, len(perms)) + for i, p := range perms { + dtos[i] = domain.ToPermissionDTO(&p) + } + return dtos, nil +} + +func (s *Service) CreateRole(ctx context.Context, req domain.CreateRoleRequest) (domain.RoleDTO, error) { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return domain.RoleDTO{}, errors.ErrTenantRequired + } + + role := &domain.Role{ + TenantID: tenantID, + Name: req.Name, + Description: req.Description, + } + + if err := s.roleRepo.Create(ctx, role); err != nil { + return domain.RoleDTO{}, errors.Wrap(err, "CREATE_ROLE_FAILED", "failed to create role", 500) + } + + return domain.ToRoleDTO(role), nil +} + +func (s *Service) ListRoles(ctx context.Context) ([]domain.RoleDTO, error) { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return nil, errors.ErrTenantRequired + } + + roles, err := s.roleRepo.FindByTenant(ctx, tenantID) + if err != nil { + return nil, errors.Wrap(err, "QUERY_ROLES_FAILED", "failed to query roles", 500) + } + + dtos := make([]domain.RoleDTO, len(roles)) + for i, r := range roles { + dtos[i] = domain.ToRoleDTO(&r) + } + return dtos, nil +} + +func (s *Service) GetRole(ctx context.Context, roleID uint) (domain.RoleDTO, error) { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return domain.RoleDTO{}, errors.ErrTenantRequired + } + + role, err := s.roleRepo.FindByIDWithPermissions(ctx, tenantID, roleID) + if err != nil { + return domain.RoleDTO{}, errors.ErrNotFound + } + + return domain.ToRoleDTO(role), nil +} + +func (s *Service) UpdateRole(ctx context.Context, roleID uint, req domain.UpdateRoleRequest) (domain.RoleDTO, error) { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return domain.RoleDTO{}, errors.ErrTenantRequired + } + + role, err := s.roleRepo.FindByID(ctx, tenantID, roleID) + if err != nil { + return domain.RoleDTO{}, errors.ErrNotFound + } + + if role.IsSystem { + return domain.RoleDTO{}, errors.New("SYSTEM_ROLE", "cannot modify system role", 403) + } + + if req.Name != "" { + role.Name = req.Name + } + if req.Description != "" { + role.Description = req.Description + } + + if err := s.roleRepo.Update(ctx, role); err != nil { + return domain.RoleDTO{}, errors.Wrap(err, "UPDATE_ROLE_FAILED", "failed to update role", 500) + } + + return domain.ToRoleDTO(role), nil +} + +func (s *Service) DeleteRole(ctx context.Context, roleID uint) error { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return errors.ErrTenantRequired + } + + role, err := s.roleRepo.FindByID(ctx, tenantID, roleID) + if err != nil { + return errors.ErrNotFound + } + + if role.IsSystem { + return errors.New("SYSTEM_ROLE", "cannot delete system role", 403) + } + + if err := s.roleRepo.Delete(ctx, tenantID, roleID); err != nil { + return errors.Wrap(err, "DELETE_ROLE_FAILED", "failed to delete role", 500) + } + + return nil +} + +func (s *Service) SetRolePermissions(ctx context.Context, roleID uint, req domain.SetRolePermissionsRequest) error { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return errors.ErrTenantRequired + } + + role, err := s.roleRepo.FindByID(ctx, tenantID, roleID) + if err != nil { + return errors.ErrNotFound + } + + if role.IsSystem { + return errors.New("SYSTEM_ROLE", "cannot modify system role permissions", 403) + } + + for _, pid := range req.PermissionIDs { + if _, err := s.permRepo.FindByID(ctx, pid); err != nil { + return errors.New("PERM_NOT_FOUND", "permission not found", 404) + } + } + + if err := s.roleRepo.SetPermissions(ctx, roleID, req.PermissionIDs); err != nil { + return errors.Wrap(err, "SET_PERMS_FAILED", "failed to set permissions", 500) + } + + return nil +} + +func (s *Service) AssignRoleToUser(ctx context.Context, userID uint, req domain.AssignRoleRequest) error { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return errors.ErrTenantRequired + } + + if _, err := s.roleRepo.FindByID(ctx, tenantID, req.RoleID); err != nil { + return errors.ErrNotFound + } + + ur := &domain.UserRole{ + UserID: userID, + TenantID: tenantID, + RoleID: req.RoleID, + } + + if err := s.userRoleRepo.Assign(ctx, ur); err != nil { + return errors.Wrap(err, "ASSIGN_ROLE_FAILED", "failed to assign role", 500) + } + + return nil +} + +func (s *Service) RemoveRoleFromUser(ctx context.Context, userID uint, roleID uint) error { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return errors.ErrTenantRequired + } + + if err := s.userRoleRepo.Remove(ctx, userID, tenantID, roleID); err != nil { + return errors.Wrap(err, "REMOVE_ROLE_FAILED", "failed to remove role", 500) + } + + return nil +} + +func (s *Service) GetUserPermissions(ctx context.Context, userID uint) ([]domain.PermissionDTO, error) { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return nil, errors.ErrTenantRequired + } + + roles, err := s.userRoleRepo.FindRolesByUser(ctx, userID, tenantID) + if err != nil { + return nil, errors.Wrap(err, "QUERY_USER_ROLES_FAILED", "failed to query user roles", 500) + } + + permMap := make(map[uint]domain.PermissionDTO) + for _, role := range roles { + for _, perm := range role.Permissions { + if _, exists := permMap[perm.ID]; !exists { + permMap[perm.ID] = domain.ToPermissionDTO(&perm) + } + } + } + + dtos := make([]domain.PermissionDTO, 0, len(permMap)) + for _, dto := range permMap { + dtos = append(dtos, dto) + } + return dtos, nil +} + +func (s *Service) UserHasPermission(ctx context.Context, userID uint, permCode string) (bool, error) { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return false, errors.ErrTenantRequired + } + + roles, err := s.userRoleRepo.FindRolesByUser(ctx, userID, tenantID) + if err != nil { + return false, errors.Wrap(err, "QUERY_USER_ROLES_FAILED", "failed to query user roles", 500) + } + + for _, role := range roles { + for _, perm := range role.Permissions { + if perm.Code == permCode { + return true, nil + } + } + } + + return false, nil +} diff --git a/internal/modules/rbac/domain/permission.go b/internal/modules/rbac/domain/permission.go new file mode 100644 index 0000000..58860d0 --- /dev/null +++ b/internal/modules/rbac/domain/permission.go @@ -0,0 +1,35 @@ +package domain + +import "time" + +type Permission struct { + ID uint `json:"id" gorm:"primaryKey"` + Code string `json:"code" gorm:"uniqueIndex;size:128;not null"` + Name string `json:"name" gorm:"size:128;not null"` + Module string `json:"module" gorm:"size:64;not null;index"` + Description string `json:"description" gorm:"size:256"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Permission) TableName() string { + return "permissions" +} + +type PermissionDTO struct { + ID uint `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Module string `json:"module"` + Description string `json:"description"` +} + +func ToPermissionDTO(p *Permission) PermissionDTO { + return PermissionDTO{ + ID: p.ID, + Code: p.Code, + Name: p.Name, + Module: p.Module, + Description: p.Description, + } +} diff --git a/internal/modules/rbac/domain/repository.go b/internal/modules/rbac/domain/repository.go new file mode 100644 index 0000000..b079280 --- /dev/null +++ b/internal/modules/rbac/domain/repository.go @@ -0,0 +1,28 @@ +package domain + +import "context" + +type PermissionRepository interface { + Create(ctx context.Context, perm *Permission) error + FindByID(ctx context.Context, id uint) (*Permission, error) + FindByCode(ctx context.Context, code string) (*Permission, error) + FindAll(ctx context.Context) ([]Permission, error) + FindByModule(ctx context.Context, module string) ([]Permission, error) +} + +type RoleRepository interface { + Create(ctx context.Context, role *Role) error + FindByID(ctx context.Context, tenantID string, id uint) (*Role, error) + FindByIDWithPermissions(ctx context.Context, tenantID string, id uint) (*Role, error) + FindByTenant(ctx context.Context, tenantID string) ([]Role, error) + Update(ctx context.Context, role *Role) error + Delete(ctx context.Context, tenantID string, id uint) error + SetPermissions(ctx context.Context, roleID uint, permIDs []uint) error +} + +type UserRoleRepository interface { + Assign(ctx context.Context, ur *UserRole) error + Remove(ctx context.Context, userID uint, tenantID string, roleID uint) error + FindByUser(ctx context.Context, userID uint, tenantID string) ([]UserRole, error) + FindRolesByUser(ctx context.Context, userID uint, tenantID string) ([]Role, error) +} diff --git a/internal/modules/rbac/domain/role.go b/internal/modules/rbac/domain/role.go new file mode 100644 index 0000000..8668365 --- /dev/null +++ b/internal/modules/rbac/domain/role.go @@ -0,0 +1,82 @@ +package domain + +import "time" + +type Role struct { + ID uint `json:"id" gorm:"primaryKey"` + TenantID string `json:"tenant_id" gorm:"type:uuid;not null;primaryKey;index:idx_tenant_role_name,unique"` + Name string `json:"name" gorm:"size:64;not null;index:idx_tenant_role_name,unique"` + Description string `json:"description" gorm:"size:256"` + IsSystem bool `json:"is_system" gorm:"default:false"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Permissions []Permission `json:"permissions,omitempty" gorm:"many2many:role_permissions;"` +} + +func (Role) TableName() string { + return "roles" +} + +type RolePermission struct { + RoleID uint `gorm:"primaryKey"` + PermissionID uint `gorm:"primaryKey"` +} + +func (RolePermission) TableName() string { + return "role_permissions" +} + +type UserRole struct { + UserID uint `json:"user_id" gorm:"primaryKey"` + TenantID string `json:"tenant_id" gorm:"type:uuid;not null;primaryKey"` + RoleID uint `json:"role_id" gorm:"not null"` +} + +func (UserRole) TableName() string { + return "user_roles" +} + +type RoleDTO struct { + ID uint `json:"id"` + TenantID string `json:"tenant_id"` + Name string `json:"name"` + Description string `json:"description"` + IsSystem bool `json:"is_system"` + Permissions []PermissionDTO `json:"permissions,omitempty"` +} + +func ToRoleDTO(r *Role) RoleDTO { + dto := RoleDTO{ + ID: r.ID, + TenantID: r.TenantID, + Name: r.Name, + Description: r.Description, + IsSystem: r.IsSystem, + } + if r.Permissions != nil { + dto.Permissions = make([]PermissionDTO, len(r.Permissions)) + for i, p := range r.Permissions { + dto.Permissions[i] = ToPermissionDTO(&p) + } + } + return dto +} + +type CreateRoleRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` +} + +type UpdateRoleRequest struct { + Name string `json:"name"` + Description string `json:"description"` +} + +type SetRolePermissionsRequest struct { + PermissionIDs []uint `json:"permission_ids" binding:"required"` +} + +type AssignRoleRequest struct { + RoleID uint `json:"role_id" binding:"required"` +} diff --git a/internal/modules/rbac/infrastructure/migrate.go b/internal/modules/rbac/infrastructure/migrate.go new file mode 100644 index 0000000..5841f34 --- /dev/null +++ b/internal/modules/rbac/infrastructure/migrate.go @@ -0,0 +1,16 @@ +package infrastructure + +import ( + "mengstack/internal/modules/rbac/domain" + + "gorm.io/gorm" +) + +func Migrate(db *gorm.DB) error { + return db.AutoMigrate( + &domain.Permission{}, + &domain.Role{}, + &domain.RolePermission{}, + &domain.UserRole{}, + ) +} diff --git a/internal/modules/rbac/infrastructure/permission_repo.go b/internal/modules/rbac/infrastructure/permission_repo.go new file mode 100644 index 0000000..9ed0fd7 --- /dev/null +++ b/internal/modules/rbac/infrastructure/permission_repo.go @@ -0,0 +1,45 @@ +package infrastructure + +import ( + "context" + + "mengstack/internal/modules/rbac/domain" + + "gorm.io/gorm" +) + +type permRepo struct { + db *gorm.DB +} + +func NewPermissionRepository(db *gorm.DB) domain.PermissionRepository { + return &permRepo{db: db} +} + +func (r *permRepo) Create(ctx context.Context, perm *domain.Permission) error { + return r.db.WithContext(ctx).Create(perm).Error +} + +func (r *permRepo) FindByID(ctx context.Context, id uint) (*domain.Permission, error) { + var perm domain.Permission + err := r.db.WithContext(ctx).First(&perm, id).Error + return &perm, err +} + +func (r *permRepo) FindByCode(ctx context.Context, code string) (*domain.Permission, error) { + var perm domain.Permission + err := r.db.WithContext(ctx).Where("code = ?", code).First(&perm).Error + return &perm, err +} + +func (r *permRepo) FindAll(ctx context.Context) ([]domain.Permission, error) { + var perms []domain.Permission + err := r.db.WithContext(ctx).Order("module, code").Find(&perms).Error + return perms, err +} + +func (r *permRepo) FindByModule(ctx context.Context, module string) ([]domain.Permission, error) { + var perms []domain.Permission + err := r.db.WithContext(ctx).Where("module = ?", module).Order("code").Find(&perms).Error + return perms, err +} diff --git a/internal/modules/rbac/infrastructure/role_repo.go b/internal/modules/rbac/infrastructure/role_repo.go new file mode 100644 index 0000000..427ec4c --- /dev/null +++ b/internal/modules/rbac/infrastructure/role_repo.go @@ -0,0 +1,65 @@ +package infrastructure + +import ( + "context" + + "mengstack/internal/modules/rbac/domain" + + "gorm.io/gorm" +) + +type roleRepo struct { + db *gorm.DB +} + +func NewRoleRepository(db *gorm.DB) domain.RoleRepository { + return &roleRepo{db: db} +} + +func (r *roleRepo) Create(ctx context.Context, role *domain.Role) error { + return r.db.WithContext(ctx).Create(role).Error +} + +func (r *roleRepo) FindByID(ctx context.Context, tenantID string, id uint) (*domain.Role, error) { + var role domain.Role + err := r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).First(&role).Error + return &role, err +} + +func (r *roleRepo) FindByIDWithPermissions(ctx context.Context, tenantID string, id uint) (*domain.Role, error) { + var role domain.Role + err := r.db.WithContext(ctx). + Preload("Permissions"). + Where("roles.id = ? AND roles.tenant_id = ?", id, tenantID). + First(&role).Error + return &role, err +} + +func (r *roleRepo) FindByTenant(ctx context.Context, tenantID string) ([]domain.Role, error) { + var roles []domain.Role + err := r.db.WithContext(ctx). + Preload("Permissions"). + Where("tenant_id = ?", tenantID). + Order("name"). + Find(&roles).Error + return roles, err +} + +func (r *roleRepo) Update(ctx context.Context, role *domain.Role) error { + return r.db.WithContext(ctx).Save(role).Error +} + +func (r *roleRepo) Delete(ctx context.Context, tenantID string, id uint) error { + return r.db.WithContext(ctx). + Where("id = ? AND tenant_id = ?", id, tenantID). + Delete(&domain.Role{}).Error +} + +func (r *roleRepo) SetPermissions(ctx context.Context, roleID uint, permIDs []uint) error { + role := &domain.Role{ID: roleID} + perms := make([]domain.Permission, len(permIDs)) + for i, id := range permIDs { + perms[i] = domain.Permission{ID: id} + } + return r.db.WithContext(ctx).Model(role).Association("Permissions").Replace(perms) +} diff --git a/internal/modules/rbac/infrastructure/seed.go b/internal/modules/rbac/infrastructure/seed.go new file mode 100644 index 0000000..b29eb37 --- /dev/null +++ b/internal/modules/rbac/infrastructure/seed.go @@ -0,0 +1,179 @@ +package infrastructure + +import ( + "context" + "strings" + + "mengstack/internal/modules/rbac/domain" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +type permDef struct { + Code string + Name string + Module string + Desc string +} + +var defaultPermissions = []permDef{ + {"auth.user.read", "查看用户", "auth", "查看用户列表和详情"}, + {"auth.user.write", "管理用户", "auth", "创建、编辑、删除用户"}, + {"rbac.role.read", "查看角色", "rbac", "查看角色列表和详情"}, + {"rbac.role.write", "管理角色", "rbac", "创建、编辑、删除角色"}, + {"rbac.permission.read", "查看权限", "rbac", "查看权限列表"}, + {"rbac.permission.assign", "分配权限", "rbac", "为角色分配权限"}, + {"rbac.user_role.write", "分配角色", "rbac", "为用户分配角色"}, +} + +func Seed(db *gorm.DB, log *zap.Logger) error { + ctx := context.Background() + + for _, p := range defaultPermissions { + var count int64 + db.Model(&domain.Permission{}).Where("code = ?", p.Code).Count(&count) + if count > 0 { + continue + } + perm := &domain.Permission{ + Code: p.Code, + Name: p.Name, + Module: p.Module, + Description: p.Desc, + } + if err := db.WithContext(ctx).Create(perm).Error; err != nil { + return err + } + log.Info("seeded permission", zap.String("code", p.Code)) + } + + var allPerms []domain.Permission + if err := db.WithContext(ctx).Find(&allPerms).Error; err != nil { + return err + } + if len(allPerms) == 0 { + return nil + } + + var tenantIDs []string + if err := db.WithContext(ctx).Table("users").Distinct("tenant_id"). + Pluck("tenant_id", &tenantIDs).Error; err != nil { + return err + } + + for _, tid := range tenantIDs { + if err := seedTenantRoles(ctx, db, log, tid, allPerms); err != nil { + log.Error("seed tenant roles failed", + zap.String("tenant_id", tid), + zap.Error(err), + ) + } + } + + return nil +} + +func seedTenantRoles(ctx context.Context, db *gorm.DB, log *zap.Logger, tenantID string, allPerms []domain.Permission) error { + superAdmin := &domain.Role{} + err := db.WithContext(ctx). + Where("tenant_id = ? AND name = ?", tenantID, "super_admin"). + First(superAdmin).Error + + if err == gorm.ErrRecordNotFound { + superAdmin = &domain.Role{ + TenantID: tenantID, + Name: "super_admin", + Description: "超级管理员", + IsSystem: true, + } + if err := db.WithContext(ctx).Create(superAdmin).Error; err != nil { + return err + } + permRefs := make([]domain.Permission, len(allPerms)) + copy(permRefs, allPerms) + if err := db.WithContext(ctx).Model(superAdmin).Association("Permissions").Replace(permRefs); err != nil { + return err + } + log.Info("seeded super_admin role", zap.String("tenant_id", tenantID)) + } else if err != nil { + return err + } + + tenantAdmin := &domain.Role{} + err = db.WithContext(ctx). + Where("tenant_id = ? AND name = ?", tenantID, "tenant_admin"). + First(tenantAdmin).Error + + if err == gorm.ErrRecordNotFound { + tenantAdmin = &domain.Role{ + TenantID: tenantID, + Name: "tenant_admin", + Description: "租户管理员", + IsSystem: true, + } + if err := db.WithContext(ctx).Create(tenantAdmin).Error; err != nil { + return err + } + + adminPermCodes := []string{ + "auth.user.read", "auth.user.write", + "rbac.role.read", "rbac.permission.read", "rbac.user_role.write", + } + var adminPerms []domain.Permission + for _, p := range allPerms { + for _, code := range adminPermCodes { + if p.Code == code { + adminPerms = append(adminPerms, p) + break + } + } + } + if len(adminPerms) > 0 { + if err := db.WithContext(ctx).Model(tenantAdmin).Association("Permissions").Replace(adminPerms); err != nil { + return err + } + } + log.Info("seeded tenant_admin role", zap.String("tenant_id", tenantID)) + } else if err != nil { + return err + } + + member := &domain.Role{} + err = db.WithContext(ctx). + Where("tenant_id = ? AND name = ?", tenantID, "member"). + First(member).Error + + if err == gorm.ErrRecordNotFound { + member = &domain.Role{ + TenantID: tenantID, + Name: "member", + Description: "普通成员", + IsSystem: true, + } + if err := db.WithContext(ctx).Create(member).Error; err != nil { + return err + } + + memberPermCodes := []string{"auth.user.read", "rbac.permission.read"} + var memberPerms []domain.Permission + for _, p := range allPerms { + for _, code := range memberPermCodes { + if strings.EqualFold(p.Code, code) { + memberPerms = append(memberPerms, p) + break + } + } + } + if len(memberPerms) > 0 { + if err := db.WithContext(ctx).Model(member).Association("Permissions").Replace(memberPerms); err != nil { + return err + } + } + log.Info("seeded member role", zap.String("tenant_id", tenantID)) + } else if err != nil { + return err + } + + return nil +} diff --git a/internal/modules/rbac/infrastructure/user_role_repo.go b/internal/modules/rbac/infrastructure/user_role_repo.go new file mode 100644 index 0000000..ca3790f --- /dev/null +++ b/internal/modules/rbac/infrastructure/user_role_repo.go @@ -0,0 +1,45 @@ +package infrastructure + +import ( + "context" + + "mengstack/internal/modules/rbac/domain" + + "gorm.io/gorm" +) + +type userRoleRepo struct { + db *gorm.DB +} + +func NewUserRoleRepository(db *gorm.DB) domain.UserRoleRepository { + return &userRoleRepo{db: db} +} + +func (r *userRoleRepo) Assign(ctx context.Context, ur *domain.UserRole) error { + return r.db.WithContext(ctx).Create(ur).Error +} + +func (r *userRoleRepo) Remove(ctx context.Context, userID uint, tenantID string, roleID uint) error { + return r.db.WithContext(ctx). + Where("user_id = ? AND tenant_id = ? AND role_id = ?", userID, tenantID, roleID). + Delete(&domain.UserRole{}).Error +} + +func (r *userRoleRepo) FindByUser(ctx context.Context, userID uint, tenantID string) ([]domain.UserRole, error) { + var urs []domain.UserRole + err := r.db.WithContext(ctx). + Where("user_id = ? AND tenant_id = ?", userID, tenantID). + Find(&urs).Error + return urs, err +} + +func (r *userRoleRepo) FindRolesByUser(ctx context.Context, userID uint, tenantID string) ([]domain.Role, error) { + var roles []domain.Role + err := r.db.WithContext(ctx). + Joins("JOIN user_roles ON user_roles.role_id = roles.id"). + Where("user_roles.user_id = ? AND user_roles.tenant_id = ?", userID, tenantID). + Preload("Permissions"). + Find(&roles).Error + return roles, err +} diff --git a/internal/modules/rbac/interfaces/handler.go b/internal/modules/rbac/interfaces/handler.go new file mode 100644 index 0000000..588eec0 --- /dev/null +++ b/internal/modules/rbac/interfaces/handler.go @@ -0,0 +1,215 @@ +package interfaces + +import ( + "strconv" + + "mengstack/internal/kernel/response" + "mengstack/internal/modules/rbac/application" + "mengstack/internal/modules/rbac/domain" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + svc *application.Service +} + +func NewHandler(svc *application.Service) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) ListPermissions(c *gin.Context) { + module := c.Query("module") + + var ( + dtos []domain.PermissionDTO + err error + ) + + if module != "" { + dtos, err = h.svc.ListPermissionsByModule(c.Request.Context(), module) + } else { + dtos, err = h.svc.ListPermissions(c.Request.Context()) + } + + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dtos) +} + +func (h *Handler) CreateRole(c *gin.Context) { + var req domain.CreateRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, response.NewBadRequest("invalid request body")) + return + } + + dto, err := h.svc.CreateRole(c.Request.Context(), req) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dto) +} + +func (h *Handler) ListRoles(c *gin.Context) { + dtos, err := h.svc.ListRoles(c.Request.Context()) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dtos) +} + +func (h *Handler) GetRole(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.Fail(c, response.NewBadRequest("invalid role id")) + return + } + + dto, err := h.svc.GetRole(c.Request.Context(), uint(id)) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dto) +} + +func (h *Handler) UpdateRole(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.Fail(c, response.NewBadRequest("invalid role id")) + return + } + + var req domain.UpdateRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, response.NewBadRequest("invalid request body")) + return + } + + dto, err := h.svc.UpdateRole(c.Request.Context(), uint(id), req) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dto) +} + +func (h *Handler) DeleteRole(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.Fail(c, response.NewBadRequest("invalid role id")) + return + } + + if err := h.svc.DeleteRole(c.Request.Context(), uint(id)); err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, nil) +} + +func (h *Handler) SetRolePermissions(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.Fail(c, response.NewBadRequest("invalid role id")) + return + } + + var req domain.SetRolePermissionsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, response.NewBadRequest("invalid request body")) + return + } + + if err := h.svc.SetRolePermissions(c.Request.Context(), uint(id), req); err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, nil) +} + +func (h *Handler) GetRolePermissions(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.Fail(c, response.NewBadRequest("invalid role id")) + return + } + + dto, err := h.svc.GetRole(c.Request.Context(), uint(id)) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dto.Permissions) +} + +func (h *Handler) AssignRole(c *gin.Context) { + userID, err := strconv.ParseUint(c.Param("userId"), 10, 64) + if err != nil { + response.Fail(c, response.NewBadRequest("invalid user id")) + return + } + + var req domain.AssignRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, response.NewBadRequest("invalid request body")) + return + } + + if err := h.svc.AssignRoleToUser(c.Request.Context(), uint(userID), req); err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, nil) +} + +func (h *Handler) RemoveRole(c *gin.Context) { + userID, err := strconv.ParseUint(c.Param("userId"), 10, 64) + if err != nil { + response.Fail(c, response.NewBadRequest("invalid user id")) + return + } + + roleID, err := strconv.ParseUint(c.Param("roleId"), 10, 64) + if err != nil { + response.Fail(c, response.NewBadRequest("invalid role id")) + return + } + + if err := h.svc.RemoveRoleFromUser(c.Request.Context(), uint(userID), uint(roleID)); err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, nil) +} + +func (h *Handler) GetUserRoles(c *gin.Context) { + userID, err := strconv.ParseUint(c.Param("userId"), 10, 64) + if err != nil { + response.Fail(c, response.NewBadRequest("invalid user id")) + return + } + + dtos, err := h.svc.GetUserPermissions(c.Request.Context(), uint(userID)) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dtos) +} diff --git a/internal/modules/rbac/interfaces/routes.go b/internal/modules/rbac/interfaces/routes.go new file mode 100644 index 0000000..5d2755d --- /dev/null +++ b/internal/modules/rbac/interfaces/routes.go @@ -0,0 +1,56 @@ +package interfaces + +import ( + "mengstack/internal/app/middleware" + "mengstack/internal/modules/rbac/application" + "mengstack/internal/modules/rbac/infrastructure" + rbacmiddleware "mengstack/internal/modules/rbac/middleware" + + "github.com/gin-gonic/gin" + "go.uber.org/fx" + "go.uber.org/zap" + "gorm.io/gorm" +) + +func SetupRoutes( + r *gin.Engine, + h *Handler, + authMW gin.HandlerFunc, +) { + rbac := r.Group("/api/v1/rbac") + rbac.Use(authMW, middleware.MultiTenant()) + { + rbac.GET("/permissions", h.ListPermissions) + + rbac.POST("/roles", h.CreateRole) + rbac.GET("/roles", h.ListRoles) + rbac.GET("/roles/:id", h.GetRole) + rbac.PUT("/roles/:id", h.UpdateRole) + rbac.DELETE("/roles/:id", h.DeleteRole) + rbac.PUT("/roles/:id/permissions", h.SetRolePermissions) + rbac.GET("/roles/:id/permissions", h.GetRolePermissions) + + rbac.POST("/users/:userId/roles", h.AssignRole) + rbac.DELETE("/users/:userId/roles/:roleId", h.RemoveRole) + rbac.GET("/users/:userId/roles", h.GetUserRoles) + } +} + +var Module = fx.Module("rbac", + fx.Provide( + infrastructure.NewPermissionRepository, + infrastructure.NewRoleRepository, + infrastructure.NewUserRoleRepository, + application.NewService, + NewHandler, + rbacmiddleware.NewPermissionMiddleware, + ), + fx.Invoke(func(db *gorm.DB, log *zap.Logger) { + if err := infrastructure.Migrate(db); err != nil { + log.Fatal("rbac migration failed", zap.Error(err)) + } + if err := infrastructure.Seed(db, log); err != nil { + log.Error("rbac seed failed", zap.Error(err)) + } + }), +) diff --git a/internal/modules/rbac/middleware/permission.go b/internal/modules/rbac/middleware/permission.go new file mode 100644 index 0000000..bb9747c --- /dev/null +++ b/internal/modules/rbac/middleware/permission.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "net/http" + + "mengstack/internal/modules/rbac/application" + + "github.com/gin-gonic/gin" +) + +type PermissionMiddleware struct { + svc *application.Service +} + +type RequirePermissionFunc func(permCode string) gin.HandlerFunc + +func NewPermissionMiddleware(svc *application.Service) *PermissionMiddleware { + return &PermissionMiddleware{svc: svc} +} + +func (m *PermissionMiddleware) RequirePermission(permCode string) gin.HandlerFunc { + return func(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": -1, "message": "unauthorized"}) + return + } + + has, err := m.svc.UserHasPermission(c.Request.Context(), userID.(uint), permCode) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"code": -1, "message": "permission check failed"}) + return + } + if !has { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": -1, "message": "permission denied"}) + return + } + + c.Next() + } +}