
做后端的人都绕不过权限管理这关。早几年我在某公司负责一个后台管理系统业务功能加了一堆用户角色越来越乱每次梳理权限都在加班补窟窿。那段时间我光是整理权限清单就要花小半天更别提上线之后各种“这个功能明明能用换个账号就报403”的反馈。后来花了两个晚上把整个项目的权限模块推倒重来用RBAC模型重新建模才彻底把这块理顺了。如果你也在用Django做后台系统或者正准备给项目加权限控制这篇就把我的方案、踩过的坑、以及排查思路完整讲一遍。Django自带的auth应用里其实已经有Group用户组和Permission权限两张表也能做到基于角色的粗粒度控制但真正落到业务里你会发现原生的东西离“够用”还差得远。比如“菜单级权限”“按钮级权限”“不同部门的相同角色拥有不同数据范围”这些需求光靠Django原生那套很难优雅实现。所以我基于RBAC的核心思想在Django上做了一套更完整的权限体系包含用户、角色、权限、菜单四个维度下面从设计思路开始逐个拆解。1. RBAC权限模型的设计思路1.1 RBAC到底解决了什么问题在RBACRole-Based Access Control基于角色的访问控制之前很多小项目用的是最原始的方式在用户表里直接存“是不是管理员”“能不能导出”“能不能删数据”之类的布尔字段。用户少的时候没什么用户一多、功能一多就乱了——今天A要加一个导出权限明天B也要加后天发现C不该有但代码里已经写死了判断逻辑只能频繁改代码、改数据库最后谁都不敢动权限相关的东西。RBAC的核心思路是引入“角色”这个中间层。用户不直接绑定权限而是绑定角色角色再拥有权限。这样一来权限的分配变成角色与权限的配置而不再需要频繁修改用户代码或表结构。类比一下一家公司的门禁卡系统员工不是直接拥有“进财务室”的权利而是因为“财务部员工”这个岗位角色天然就被授权进入了财务室。换岗的时候只需要换角色不需要挨个改门禁记录。加上“角色”这层抽象之后权限管理从“点对点”变成了“面到面”数据集小、逻辑清晰、可追溯。这就是RBAC能成为主流权限方案的根本原因。1.2 为什么最终选了RBAC而不是ACL或ABAC做权限选型的时候市面上主要有三种模型ACLAccess Control List、RBACRole-Based Access Control、ABACAttribute-Based Access Control。ACL是最朴素的思路——直接把某个用户和某种操作权限挂上钩。适合小型工单系统、个人博客后台但一旦用户量上百、功能上百ACL表的膨胀速度非常惊人而且无法自然表达“一类人”的共性权限。ABAC则更灵活用属性规则来判断比如说“销售部门的员工只能看本部门客户的工单”但要维护规则引擎、表达式解析复杂度直接上一个台阶中小型项目一般是hold不住的。所以在绝大多数管理后台、中后台系统的场景里RBAC是性价比最高的方案。它管住了“谁能做什么”又不像ABAC那样引入巨大的规则复杂度。而如果后面又出现“不同角色只能看不同部门数据”这种行级数据权限需求也完全可以在RBAC的基础上增加一个数据范围字段来配合实现不需要推翻整体设计。这也是我在实操中最终选择RBAC并保留扩展空间的原因。1.3 Django自带的auth权限体系够不够用凡是开过Django项目的人都知道django.contrib.auth里已经提供了User、Group、Permission还自带has_perm()方法和permission_required装饰器。那为什么我还要自己再撸一套RBAC因为原生的设计目标是“给Django管理后台admin提供权限控制”而不是直接给业务系统做权限中台。它的问题主要集中在几点第一权限粒度是按模型来的一个Permission对应“添加/修改/删除/查看某个模型”但你做业务系统时会有“导出报表”“审批工单”“分配任务”这类跟模型CRUD不对应的操作权限原生比较难表达。第二菜单和接口权限没有天然建模。你做的是带侧边栏的后台侧边栏菜单谁可见谁不可见原生完全不管需要自己去查询、渲染。第三Group虽然可以作为角色表用但它只能关联权限不能挂级联的数据范围、角色层级、排序等扩展字段使用起来限制很大。所以实操中我保留了Django原生的Permission来承接原子操作权限再用自定义的Role、Menu等模型来承载业务权限结构。原生框架的理念值得沿用但必须做扩展才能真正支撑起一个灵活的后台权限系统。2. 数据模型与表结构设计2.1 五张核心表的职责划分在给后台系统设计RBAC表结构时我最终拆出了五张核心表外加两张关联中间表。五张核心表的职责非常清晰User用户表直接用Django的AbstractUser扩展增加外键关联到角色也可保留原生的多对多关系。Role角色表存储角色名称、角色标识符、排序、状态、数据范围等。Permission权限表存储权限编码、名称可以直接沿用Django自带的Permission也可以自定义一张权限表看项目规模。Menu菜单表存储系统菜单、按钮包括菜单类型目录/菜单/按钮、路由地址、权限标识等。MenuRole、RoleUser关联表由于角色和菜单、角色和用户都是多对多关系所以需要中间表来维护。这里有个关键取舍我是直接用Django的Permission还是另建一张Permission表如果你的项目是在Django Admin体系内做管理直接复用即可但如果你要写一个独立的后台管理系统我更建议自定义Permission表或菜单表自带权限标识因为你后期会有大量“查询权限是否存在”“校验某个请求URL是否匹配某个权限标识”的场景自己的表更灵活可控不用跟Django的ContentType混在一起。下面给出我实际使用的一套模型定义。2.2 核心模型代码实例先扩展用户模型。用AbstractUser加一个roles多对多字段兼容Django原本的groups和user_permissions。from django.contrib.auth.models import AbstractUser, Permission from django.db import models class User(AbstractUser): 扩展用户表增加与角色的多对多关系 roles models.ManyToManyField( Role, throughUserRole, related_nameusers, blankTrue, verbose_name角色列表 ) # 如果业务需要可以增加部门、岗位等字段 department models.CharField(max_length64, blankTrue, verbose_name部门) class Meta(AbstractUser.Meta): verbose_name 用户 verbose_name_plural verbose_name然后是Role模型给角色增加数据范围和状态等扩展字段。class Role(models.Model): 角色表 DATA_SCOPE_CHOICES [ (1, 全部数据), (2, 本部门及以下), (3, 仅本部门), (4, 仅本人), ] name models.CharField(max_length64, uniqueTrue, verbose_name角色名称) code models.CharField(max_length64, uniqueTrue, verbose_name角色标识) data_scope models.IntegerField(choicesDATA_SCOPE_CHOICES, default3, verbose_name数据范围) status models.BooleanField(defaultTrue, verbose_name状态) sort models.IntegerField(default0, verbose_name排序) permissions models.ManyToManyField(Permission, blankTrue, verbose_name权限列表) desc models.CharField(max_length256, blankTrue, verbose_name角色描述) created_at models.DateTimeField(auto_now_addTrue) def __str__(self): return self.name class Meta: db_table system_role verbose_name 角色 verbose_name_plural verbose_name这里要注意permissions字段与Django原生的Permission建立多对多关系这样一方面可以兼容has_perm相关的逻辑另一方面也给权限的批量管理留好了口子。接下来是Menu表。它承载了两个功能一是后台侧边栏菜单的渲染二是按钮级权限的判断。因为很多权限校验最终都落到“某个按钮标识是否在用户的权限集合里”所以菜单表需要一个唯一的perms字段来标识。class Menu(models.Model): 菜单与按钮权限表 MENU_TYPE_CHOICES [ (1, 目录), (2, 菜单), (3, 按钮), ] parent models.ForeignKey( self, on_deletemodels.CASCADE, nullTrue, blankTrue, related_namechildren, verbose_name父级菜单 ) name models.CharField(max_length64, verbose_name菜单名称) path models.CharField(max_length255, blankTrue, verbose_name路由地址) component models.CharField(max_length255, blankTrue, verbose_name前端组件路径) perms models.CharField(max_length255, blankTrue, verbose_name权限标识) type models.IntegerField(choicesMENU_TYPE_CHOICES, default2, verbose_name菜单类型) icon models.CharField(max_length128, blankTrue, verbose_name图标) sort models.IntegerField(default0, verbose_name排序) visible models.BooleanField(defaultTrue, verbose_name是否显示) status models.BooleanField(defaultTrue, verbose_name状态) roles models.ManyToManyField(Role, throughMenuRole, related_namemenus, blankTrue, verbose_name角色列表) class Meta: db_table system_menu verbose_name 菜单 verbose_name_plural verbose_name def __str__(self): return self.name最后是两个中间表。虽然Django会自动生成但我需要给中间表增加自定义字段所以显式写出来。class UserRole(models.Model): 用户-角色中间表 user models.ForeignKey(User, on_deletemodels.CASCADE, related_nameuser_roles) role models.ForeignKey(Role, on_deletemodels.CASCADE, related_nameuser_roles) class MenuRole(models.Model): 角色-菜单中间表 role models.ForeignKey(Role, on_deletemodels.CASCADE, related_namemenu_roles) menu models.ForeignKey(Menu, on_deletemodels.CASCADE, related_namemenu_roles) class Meta: unique_together (role, menu)从实操角度看这样的模型设计已经能覆盖95%以上后台系统的权限需求。如果还要再升级可以将User和Role改成多对多扁平结构允许一个用户拥有多个角色数据权限则可以借助data_scope字段在查询中动态拼接SQL或ORM过滤条件。2.3 表关系与预置数据规划表关系上整体是典型的“星型结构”用户表在中心左边连着角色右边连着权限菜单表作为前端渲染和权限标识的锚点和角色建立关联。为了让查询性能更好我在Role里直接持有permissions多对多关系在Menu里直接持有roles多对多关系这样查询用户时可以先拿到角色再由角色快速关联到菜单和权限。初始化数据时建议做一个基础数据脚本用Django的management command来跑不要手动往库里插否则环境差异很容易造成数据不一致。一个典型的初始化流程是创建超级管理员角色并绑定所有权限标识。创建通用角色如普通用户、运维只读角色绑定对应的菜单和权限。创建系统的菜单结构目录、菜单、按钮三个层级分开录入。把默认的超级管理员的用户ID与角色绑定。我自己实际跑起来的时候发现最费时间的不是写模型而是整理菜单和权限标识的清单。这个清单直接决定后续权限管理的易用性。权限标识的命名我建议统一用冒号分层例如system:user:add、system:user:edit、system:user:delete。这样正则匹配容易前端按钮和后端接口校验也好统一对。3. 实操落地权限校验完整实现3.1 项目初始化与配置第一步自然是搭一个可运行的Django项目。我习惯先用独立的目录把项目建好再逐步合入权限模块。基础配置里需要把自定义用户模型设置好在settings.py里加AUTH_USER_MODEL然后注册到INSTALLED_APPS。# settings.py INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, rest_framework, # 如果走API型后台建议集成DRF system, # 权限所在的业务模块 ] AUTH_USER_MODEL system.User我用Django REST Framework的原因是当前后台管理基本都是前后端分离的后端只提供JSON接口权限校验放在视图层和中间件层。如果不打算前后端分离也可以忽略DRF直接用View和模板渲染菜单。随后执行迁移命令确认模型建表没问题。python manage.py makemigrations python manage.py migrate这个时候有个非常容易踩的坑如果你是在已有项目上引入AUTH_USER_MODEL数据库里可能已经有了其他表的外键关联到原生的auth.User迁移会直接报错。解决方法是先确保被关联的表比如日志表、工单表要么用settings.AUTH_USER_MODEL做外键要么在引入之前处理好悬空外键的问题。3.2 权限校验装饰器与中间件有了模型之后最核心的部分就是把“权限标识”和“接口请求”关联起来。我封了一个装饰器permission_check用于视图函数或DRF视图类。from django.http import JsonResponse from functools import wraps def permission_check(perm_code): 简单的权限校验装饰器支持DRF视图 def decorator(view_func): wraps(view_func) def wrapped_view(request, *args, **kwargs): if not request.user.is_authenticated: return JsonResponse({code: 401, msg: 未登录}, status401) if request.user.is_superuser: return view_func(request, *args, **kwargs) # 从request中取出用户拥有的权限集合 perm_set request.user.get_all_permissions() if perm_code not in perm_set: return JsonResponse({code: 403, msg: 无权限访问}, status403) return view_func(request, *args, **kwargs) return wrapped_view return decorator permission_check(system:user:add) def add_user(request): # 新增用户逻辑 pass这里的关键点在于request.user.get_all_permissions()为了让它每次都返回最新权限我在User模型上自定义了一个方法。这个方法的核心逻辑是从当前用户关联的角色找到这些角色绑定的权限收集所有权限编码。from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): roles models.ManyToManyField(Role, throughUserRole, related_nameusers, blankTrue) def get_all_permissions(self): if self.is_superuser: # 超级管理员直接返回一个始终包含通过的标记 return {*,} # 通过角色收集权限编码 perms set() role_ids self.roles.filter(statusTrue).values_list(id, flatTrue) for role_id in role_ids: # 从Role关联到Permission permissions Role.objects.get(pkrole_id).permissions.all() for p in permissions: perms.add(p.codename) # 如果还涉及菜单表权限标识 menu_perm Menu.objects.filter(roles__id__inrole_ids, perms__isnullFalse).values_list(perms, flatTrue) perms.update(menu_perm) return perms这里有一个容易混淆的点Django原生Permission自带codename这个codename是像add_userchange_user这样的短名称但为了方便统一控制我更建议直接把权限标识写入codename或新建一个字段比如system:user:add。实际项目中为了让菜单权限和后端权限共用一个权限集合下面这段逻辑是主流做法。不过要注意get_all_permissions这个方法是DjangoBaseUser里就存在的钩子我在覆写时保留一致的返回类型后面很多内置功能都不会出问题。如果不想在每一个视图函数上加装饰器也可以在中间件层做统一拦截。以下是我常用的一个轻量中间件它把“白名单 登录态”自动处理了。class RbacMiddleware: def __init__(self, get_response): self.get_response get_response def __call__(self, request): # 对不需要权限校验的路由放行 path request.path allow_list [ /api/login/, /api/captcha/, /static/, ] if any(path.startswith(prefix) for prefix in allow_list): return self.get_response(request) if not request.user.is_authenticated: return JsonResponse({code: 401, msg: 未登录}, status401) # 判断当前请求的URL是否需要“资源权限”校验 # 通过一个服务函数获取 URL - 权限标识 的映射关系 perm_code matcher.get_permission_by_path(path) if perm_code and not request.user.is_superuser: if perm_code not in request.user.get_all_permissions(): return JsonResponse({code: 403, msg: 无权限访问}, status403) return self.get_response(request)用中间件的好处是接口不用逐个加装饰器新增接口只要在路由配置的时候做好权限标识和路径的映射就行。但它也有一个劣势——每个请求都会跑一遍权限检查对性能有一定影响所以通常会和缓存方案配合使用。后面第4章我会专门讲性能优化。3.3 前端菜单与按钮级权限控制做后台管理系统前端核心诉求就两个菜单按角色动态渲染按钮按权限动态显示。前端拿到当前用户后调用一个获取用户信息的接口接口返回该用户可见的菜单树和权限标识集合。在Django视图里获取菜单树的逻辑大概是这样的def get_user_menu_tree(request): user request.user if user.is_superuser: menus Menu.objects.filter(statusTrue, type__in[1, 2]).order_by(sort) else: role_ids user.roles.filter(statusTrue).values_list(id, flatTrue) menus Menu.objects.filter(roles__id__inrole_ids, statusTrue, type__in[1, 2]).order_by(sort) tree build_menu_tree(menus) return JsonResponse({code: 200, data: tree}) def build_menu_tree(menus): 将扁平的菜单列表转换为树形结构 menu_list list(menus.values(id, parent_id, name, path, component, icon, sort, type)) tree [] # 转化为dict便于快速查找 menu_map {} for item in menu_list: parent_id item.pop(parent_id) item[children] [] menu_map[item[id]] item # 组装树 for item in menu_list: item.pop(parent_id, None) return menu_list上面的代码为了简化示例我忽略了递归组树的过程实际需要递归根据parent_id挂到对应的children里。前端拿到这棵菜单树之后直接遍历渲染侧边栏即可。按钮级权限同理接口返回一个扁平的权限标识列表前端在按钮上判断const hasPerm (perm) { return store.state.permissions.includes(perm); }这样就能实现“角色变了按钮自动隐藏”的效果。需要注意的是前端权限控制只是体验优化真正的安全防线永远在后端。接口和数据的权限校验不能因为前端隐藏了入口就省略一定要在视图层再校验一次防止有人直接请求接口地址。4. 性能优化与缓存方案4.1 权限查询的N1问题RBAC模型在实现之后最容易暴露的问题就是查询性能。原因很简单用户 - 角色 - 权限 - 菜单是多层关联如果不做优化一次权限查询会实时发起大量SQL。举个例子如果用户有3个角色每个角色有20个权限标识那么获取用户全部权限时如果用遍历角色的方式逐个查询权限SQL次数就是“1次查角色 N次查权限”。在用户登录、用户访问接口、获取菜单树这些关键节点都会被放大多倍。解决方案有两个层面。第一个层面是使用prefetch_related预取关联数据在查询角色列表时就把每个角色的权限集合一次性取出来例如roles Role.objects.filter(statusTrue).prefetch_related(permissions)第二个层面更彻底——把权限集合做缓存不用每次请求都去数据库里查。4.2 缓存策略与Redis集成考虑到权限数据的特点是“读多写少”非常适合用缓存。我在项目里把用户权限集合、菜单树都放到了Redis里缓存前缀按用户ID区分例如rbac:perms:user:{user_id}缓存时间设为30分钟。集成Redis之后get_all_permissions()的改造逻辑是这样的from django.core.cache import cache def get_all_permissions(self): cache_key frbac:perms:user:{self.id} perms cache.get(cache_key) if perms is not None: return perms # 未命中则从数据库查询 if self.is_superuser: perms {*,} else: perms set() role_list self.roles.filter(statusTrue).prefetch_related(permissions) for role in role_list: for p in role.permissions.all(): perms.add(p.codename) # 补充菜单权限标识 menu_perms Menu.objects.filter( roles__id__inself.roles.filter(statusTrue), perms__isnullFalse ).values_list(perms, flatTrue) perms.update(menu_perms) # 写入缓存 cache.set(cache_key, perms, timeout1800) return perms这里有个细节perms是无序集合在缓存时要注意cache.set能否接受set类型。大部分Redis缓存后端会自动把set序列化成list取回来就变成list了所以最好在写入前统一list(perms)读取时再转成集合避免类型不一致导致后续in判断出错。另外我强烈建议把缓存的键设计成可控制的版本号比如rbac:perms:user:{user_id}:v2。这样当权限逻辑升级时只需要改版本号让旧缓存自然失效避免老用户因为旧缓存一直看到旧的权限数据。4.3 权限变更即时生效缓存能提升性能但也带来一个常见问题管理员修改了角色权限用户的缓存却没有及时刷新导致新权限迟迟不生效。解决办法是在权限变更的业务逻辑里主动清除对应用户的缓存。实际处理时我封装了一个服务类专门负责角色权限的修改与缓存清理class RbacService: staticmethod def assign_permissions_to_role(role, perm_codes): 给角色分配权限 # 处理权限编码的更新 role.permissions.clear() ... # 清理所有拥有该角色的用户的缓存 user_ids role.users.values_list(user_id, flatTrue) for user_id in user_ids: cache_key frbac:perms:user:{user_id} cache.delete(cache_key)有同学可能会问如果没有Redis缓存而是本地内存缓存这个失效怎么处理我建议不要把本地缓存作为核心方案因为多进程部署下本地缓存无法联动失效最终导致权限数据不一致。生产环境直接用Redis省事也可靠。5. 常见问题与排查技巧实录5.1 权限明明加了却总校验失败这是我遇到最多的问题场景通常是“给角色勾选了某个权限前端菜单也显示了但调接口还是403”。排查路径一般按下面几步走确认当前登录的用户其角色是否真的已经关联到该接口对应的权限标识。很多情况是权限标识写错了比如接口校验的是system:user:add菜单按钮绑定的是system:User:add大小写不一致直接匹配失败。确认是否有缓存。如果有Redis缓存先手动清一下该用户的缓存键再重新调用接口。确认是否走的是超级管理员逻辑。is_superuserTrue的用户在部分实现里会被直接放行如果业务希望超级管理员也需要绑定角色要注意代码分支。一个比较稳妥的习惯是在权限校验代码里临时打印当前用户的权限集合再打印接口需要的权限标识一眼就能看出问题在哪。logger.debug(fcurrent user perms: {request.user.get_all_permissions()}) logger.debug(frequire perm: {perm_code})5.2 超级用户权限处理误区Django的is_superuser是一个“一票通过”的开关默认情况下他拥有所有权限。但在RBAC落地时存在两个容易出问题的地方。第一个是“页面和菜单渲染”。如果前端拿到is_superuser就直接返回全部菜单那角色配置功能就完全用不上了可如果超级管理员连菜单都要跟着角色走又容易漏配导致他自己也看不到某些管理入口。我通常的取舍是超级管理员直接返回全部菜单和全部权限但前端在“用户管理”和“角色管理”页面仍然可以按角色查询普通用户实现视角分离。第二个是“数据权限”。超级管理员的数据权限一般默认为“全部数据”但普通角色即使有权限标识数据范围也可能限制为“仅本人”。所以在查询列表数据时除了权限校验还要额外根据角色data_scope进行数据范围过滤这一步超级管理员是直接全部放行的。我把这两个逻辑串起来讲是想强调权限校验和数据范围过滤是两个完全不同的层级千万不要混在同一个函数里判断否则很容易出现“有权限看见按钮却看不到按钮背后的任何数据”这种怪异现象。5.3 多角色权限合并与优先级冲突实际业务里一个用户往往有多种身份既是项目成员又是部门经理还可能兼任某个专员。这时权限集合不是“取最新一个角色”而是“所有角色权限的并集”。我常用的模型是“一用户多角色”在权限集合采用set进行合并天然去重。但如果不同角色的同一个权限标识对应的数据范围不同例如“项目成员”只能看个人数据“部门经理”能看本部门数据这时简单的并集无法表达。我采用的方案是数据范围取所有角色中的“最大范围”比如其中一个角色是“全部数据”那这个用户的数据范围就是“全部数据”如果一个是“本部门”一个是“仅本人”那就取“本部门”。这个策略在大多数业务场景下都能说得通而且实现起来也简单。如果你遇到的要求是“不同数据范围都要能单独限制到某个菜单”那RBAC基础模型就有点不够用了建议引入“数据权限规则表”在RBAC上层增加一个策略引擎。但这个复杂度一般项目不会用到我在这里只提一句。5.4 排查命令速查最后把我在排查Django RBAC问题时常用的一些命令和SQL整理成速查表方便你直接抄。场景排查命令确认用户角色python manage.py shell里执行user.roles.all()确认角色权限role.permissions.values_list(codename, flatTrue)确认菜单权限标识Menu.objects.filter(perms__isnullFalse).values_list(perms, flatTrue)清空某个用户的权限缓存cache.delete(rbac:perms:user:12)强制迁移构建表python manage.py makemigrations system python manage.py migrate查看接口路由对应视图python manage.py show_urls或查项目的urls.py这些操作基本覆盖了日常权限模块的调试场景。每次遇到问题先做数据层面的核对再考虑代码逻辑最后怀疑缓存基本能解决90%的问题。最后再分享一个小技巧权限标识的命名和菜单路由最好在项目启动前就规划好而不是边写边取。你可以先画一张“系统功能权限清单”表格列出功能模块、菜单路径、按钮名称、权限标识、对应接口评审通过后再落到代码里。很多权限混乱的烂摊子根源不在代码而在没有一个清晰的权限台账。我后来维护这套RBAC模块时总是一边查代码一边对着这台账看自己都没想到能省这么多时间。