干过运维开发的人应该都有过这种体验系统功能越来越多账号越开越多权限却还靠着一张Excel表格在维护。谁要看哪个页面谁不能点哪个按钮全凭开发者的记忆和口头约定。我在维护一个后台项目时被这种局面折腾得很惨——明明客服只需要查订单结果他能误删数据财务应该只看成本报表结果他连用户隐私接口都能调。后面我把权限体系彻底重构用上了权限RBAC那一套设计思路总算把账号、角色、权限之间的关系理清了。这篇文章就围绕Django里的RBAC实现把从模型设计到前后端联动再到细粒度权限扩展的完整经验拆开讲。RBAC全称Role-Based Access Control即基于角色的访问控制。它的核心思想不复杂用户不直接绑定权限而是先归入角色角色再绑定一组权限。这个中间层看起来多了一步却能让整个权限系统的可维护性大幅提升。适合所有需要区分操作范围的业务系统尤其是后台管理、SaaS平台、企业内部系统这类用户角色差异明显的场景。下面我会从基础模型讲起结合代码和实际项目踩坑把这套体系完整落地。1. 先把账算清楚权限混乱带来的成本远比想象中高很多团队一开始并不觉得权限需要专门设计。项目刚开始就几个人一百个接口人人都有全部权限也无所谓。但等业务跑起来用户角色分化麻烦就来了。我在某个订单管理项目中亲身经历过这样几个阶段第一阶段所有登录用户都是超级用户接口全部放行。问题不大因为只有内部几个人用。第二阶段老板说外包客服也要用系统但又不能让他们看到成本价。于是开始在前端用v-if判断用户类型后端接口几乎不设防。第三阶段前端判断很快被绕过——有人直接拿着接口地址调数据后端一点防护都没有。我们被迫在后端每个接口里写if user.role ! admin: return 403没写到的接口就是漏网之鱼。第四阶段需求变成客服主管能看到所有客服的订单客服只能看自己跟进的订单代码里的if条件越来越多最后没人敢改权限相关的任何一行代码。这个演进过程特别典型。前端判断挡不住真正的越权请求散落在业务代码里的if判断没法统一维护更谈不上审计。等到我负责重构权限模块时做的第一件事就是统一接口权限判定入口。不再允许业务代码里自己写用户类型判断所有访问控制收敛到RBAC框架里角色、节点、接口三层模型。谁属于哪个角色角色能访问哪些节点节点对应哪些接口全部在后台配置。开发者只需要在接口上声明需要的权限标识剩下的交给统一拦截器处理。这个重构做完后权限变更不再需要发版运营人员直接在管理后台勾选角色权限24小时内生效。这就是RBAC最朴素的收益——把权限从代码中抽离出来变成可配置的数据。2. RBAC的底层模型角色这个中间层到底解决了什么要理解RBAC先看最原始的做法把权限直接挂在用户身上。两个用户就需要配两份权限二十个用户就是二十份每个新来的同事都要从零勾选一遍离职了还要逐个清理。权限变更时你根本不知道哪些用户还持有旧权限。RBAC引入角色后关系变成用户 ---- 角色 ---- 权限用户和角色是多对多角色和权限是多对多。一个用户可以拥有多个角色一个角色可以授予多个用户同理一个角色可以拥有多个权限一个权限也可以属于多个角色。这样做的好处是显而易见的按岗位授权不需要关心具体是谁在担任客服岗只需要维护好客服这个角色拥有的权限。新人入职给他绑定客服角色即可。批量变更公司统一规定客服不能看客户手机号只需修改客服角色的权限配置所有客服同时生效。最小权限原则角色拆分清晰后可以确保每个用户只拥有完成工作所必需的最小权限集。RBAC模型理论上有从0到3四个级别实际项目中用到最多的是RBAC0和RBAC1。模型级别核心特征适用场景RBAC0基础模型用户-角色-权限三方关联绝大多数中小项目RBAC1在RBAC0基础上增加角色继承组织层级分明如部门负责人继承部门成员权限RBAC2增加角色约束互斥、基数、先决条件财务与出纳互斥同一个人不能同时兼职RBAC3RBAC1 RBAC2 的组合大型企业级系统大部分Django项目做到RBAC0就已经够用配合Django Admin或自定义角色管理页面维护成本很低。只有在多层级组织架构里才需要引入角色继承比如部门主管自动拥有普通员工的全部权限再额外加一部分管理权限。Django里实现角色继承也不复杂在角色表加一个parent自关联字段查询权限时递归合并父角色的权限集合。不过要提醒一句递归带来的性能和逻辑复杂度会明显上升没有硬性需求别轻易上。3. Django自带的权限体系能用但天花板很低Django框架本身自带了一套基于Permission的权限机制包含在django.contrib.auth里。这套机制的基本要素有三个User、Group、Permission。3.1 权限标识的生成规则Django的Permission模型里有两个关键字段codename和content_type。每个权限都关联一个内容类型ContentType系统生成的权限标识格式为应用名.权限代号比如一个订单模型注册后自动生成四个默认权限order.view_order order.add_order order.change_order order.delete_ordercodename就是这个权限代号content_type对应order应用下的order模型。调用user.has_perm(order.delete_order)时Django会自动拆解这个标识先找app_label再找codename最后校验当前用户是否持有。3.2 自带机制的核心APIDjango提供了一整套权限判断相关的API# 判断用户是否有某个权限 user.has_perm(order.delete_order) # 一次性判断多个权限全部满足才为True user.has_perms([order.view_order, order.change_order]) # 给用户直接分配权限 user.user_permissions.add(permission_obj) # 通过分组分配权限 group.permissions.add(permission_obj) user.groups.add(group_obj)视图层面也给了现成装饰器from django.contrib.auth.decorators import permission_required permission_required(order.delete_order, raise_exceptionTrue) def delete_order(request, order_id): ...raise_exceptionTrue时没有权限会直接抛403否则默认跳转登录页。在前后端分离的项目里通常都会设置这个参数让接口统一返回403状态码。3.3 自带体系的实际局限Django自带机制作为入门配置是合格的但通读源码后会发现几个明显的天花板权限粒度是模型级的。add_order、change_order说的是能不能操作这个模型无法区分只能改自己创建的订单和能改所有人的订单。这是一个非常关键的缺口。Group的角色语义太弱。Group本身只是一个权限分组没有角色状态、角色编码、角色层级这些概念。想在Admin里对角色做精细管理得自己扩展。权限标识是全局唯一的。自定义权限时同一个应用里两个模型不能出现同名codename否则会引发唯一约束冲突。跨应用权限需要自己处理。一个订单接口可能同时依赖order应用和customer应用的权限这种复合判断自带机制没有原生支持。所以我的建议是小项目可以依赖Django自带的User、Group、Permission三个模型快速搭建省事、安全、有社区背书一旦权限体系要求角色可配置、权限可动态调整、数据范围可控就需要在自带基础上做一层RBAC扩展。4. 一套可落地的RBAC模型设计从表结构到代码下面给出我在多个项目中用过的一套RBAC模型设计。它兼容Django自带权限体系但多了一个角色层并支持权限动态配置。4.1 用户模型扩展首先自定义用户模型继承AbstractUser。强烈建议在项目初期就做这件事不要等到数据库迁移后再换用户模型那会非常痛苦。from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): # 扩展字段按需添加 phone models.CharField(手机号, max_length20, blankTrue) is_locked models.BooleanField(是否锁定, defaultFalse) class Meta: db_table auth_user verbose_name 用户用户模型只做基础扩展权限相关的关联关系我放在后面的角色关联表里不直接塞进User模型。这样每个模型的职责更纯粹。4.2 角色模型角色是最核心的中间层我设计了如下字段from django.contrib.auth.models import Permission from django.db import models class Role(models.Model): 角色表 name models.CharField(角色名称, max_length50, uniqueTrue) code models.CharField(角色编码, max_length50, uniqueTrue) description models.TextField(角色描述, blankTrue) permissions models.ManyToManyField( Permission, verbose_name拥有的权限, blankTrue, related_nameroles, ) is_active models.BooleanField(是否启用, defaultTrue) created_time models.DateTimeField(创建时间, auto_now_addTrue) updated_time models.DateTimeField(更新时间, auto_nowTrue) class Meta: db_table system_role verbose_name 角色 def __str__(self): return self.name这里单独建了Role而不是直接用Django的Group有几个原因角色需要一个稳定的code字段作为前端和接口判定的标识。比如前端判断用户是否是管理员直接比较user.roles[0].code admin比用数据库主键ID稳定得多。角色需要is_active状态控制。停用一个角色时所有关联用户立即失去该角色权限非常有用。角色需要扩展更多属性如数据范围类型、角色级别、父子角色用Group不方便。4.3 用户与角色关联用户与角色是多对多关系我单独建了一张关联表from django.conf import settings from django.db import models class UserRole(models.Model): 用户角色关联表 user models.ForeignKey( settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, related_namerole_relations, verbose_name用户, ) role models.ForeignKey( Role, on_deletemodels.CASCADE, related_nameuser_relations, verbose_name角色, ) created_time models.DateTimeField(绑定时间, auto_now_addTrue) class Meta: db_table system_user_role verbose_name 用户角色关联 constraints [ models.UniqueConstraint( fields[user, role], nameunique_user_role ) ]单独一张关联表的好处是可以在关联上记录额外信息比如绑定的时间、来源渠道、有效期。如果需求更复杂要控制某个角色只在特定时间段生效只需要在关联表加start_time和end_time字段查询权限时做过滤即可。4.4 自定义权限的声明除了模型自动生成的四个默认权限业务上经常需要自定义权限项。这一步必须在模型的Meta.permissions里声明class Order(models.Model): ... class Meta: permissions [ (export_order, 导出订单数据), (review_order, 审核订单), ]声明完成后执行makemigrations和migrate这两个权限就会写入auth_permission表。codename分别是export_order和review_order完整权限标识为order.export_order、order.review_order。注意这里的关键点自定义权限只能挂在某个模型下因为Permission必须要关联ContentType。如果你只是想要一些与任何模型无关的权限可以在任意一个业务模型下声明或者专门建一个空模型来挂权限。我习惯在后端建一个SystemPermission空模型来承载这些全局权限。4.5 权限合并查询工具函数用户拥有多个角色时需要把角色权限合并后去重。我封装了一个函数def get_user_permission_codes(user): 获取用户所有权限标识的集合 if user.is_superuser: return set( Permission.objects.values_list(codename, flatTrue) ) role_ids UserRole.objects.filter(useruser).values_list(role_id, flatTrue) return set( Permission.objects.filter( roles__id__inrole_ids, role__is_activeTrue, ).values_list(content_type__app_label, codename) .order_by() .distinct() )这里用content_type__app_label codename拼接成完整权限标识方便上游直接比对user.has_perm风格字符串。需要注意的是filter里带了role__is_activeTrue条件写进同一组过滤保证被停用的角色权限不会混入。5. 权限校验的三种写法装饰器、Mixin、DRF权限类模型层设计好了接下来是在接口层做权限拦截。我这里分享三种常用写法覆盖函数视图、类视图和DRF接口三类场景。5.1 函数视图用装饰器Django自带的permission_required只认permission标识要求用户必须拥有该单个权限。多个权限需要同时拥有时可以自己写一个组合装饰器from functools import wraps from django.core.exceptions import PermissionDenied def require_permissions(perm_list): 要求用户同时拥有多个权限 def decorator(view_func): wraps(view_func) def _wrapped_view(request, *args, **kwargs): if not request.user.is_authenticated: # 根据项目情况跳转登录或返回401 from django.shortcuts import redirect return redirect(/login/) for perm in perm_list: if not request.user.has_perm(perm): raise PermissionDenied(你没有访问该功能的权限) return view_func(request, *args, **kwargs) return _wrapped_view return decorator # 使用示例 require_permissions([order.view_order, order.export_order]) def export_orders(request): ...PermissionDenied异常会被Django内置的403处理器捕获并返回403页面。如果是前后端分离项目可以自定义一个JSON响应。5.2 类视图用Mixin基于类的视图写权限判断时最干净的做法是写一个Mixin重写dispatch方法from django.core.exceptions import PermissionDenied class RBACMixin: 权限校验Mixin子类设置required_permissions属性 required_permissions [] required_roles [] def check_permissions(self, request): if not request.user.is_authenticated: return False # 超级用户直接放行 if request.user.is_superuser: return True for perm in self.required_permissions: if not request.user.has_perm(perm): return False if self.required_roles: # 校验角色编码 user_role_codes set( request.user.role_relations.filter( role__is_activeTrue ).values_list(role__code, flatTrue) ) if not user_role_codes.intersection(self.required_roles): return False return True def dispatch(self, request, *args, **kwargs): if not self.check_permissions(request): raise PermissionDenied(没有操作权限) return super().dispatch(request, *args, **kwargs) # 使用示例 class OrderDeleteView(RBACMixin, TemplateView): required_permissions [order.delete_order] template_name order/delete.html这种写法把权限声明直接放在视图类里代码审查时一眼就能看出这个接口的访问门槛。另外required_roles和required_permissions可以同时使用满足必须是运营角色且拥有删除权限这种复合条件。5.3 DRF接口用权限类现在很多项目的前后端是分离的后端用Django REST Framework提供API接口。DRF的权限系统通过permission_classes控制自定义一个RBAC权限类from rest_framework.permissions import BasePermission from rest_framework.exceptions import PermissionDenied class RBACPermission(BasePermission): 基于权限标识的DRF权限类 def get_required_permissions(self, request, view): # 优先从视图属性读取 if hasattr(view, required_permissions): return view.required_permissions return [] def has_permission(self, request, view): user request.user if not user.is_authenticated: return False if user.is_superuser: return True required_perms self.get_required_permissions(request, view) if not required_perms: # 没有配置权限要求的接口默认登录即可访问 return True return user.has_perms(required_perms)在具体视图里声明访问门槛from rest_framework.viewsets import ModelViewSet class OrderViewSet(ModelViewSet): queryset Order.objects.all() serializer_class OrderSerializer permission_classes [RBACPermission] property def required_permissions(self): # 普通方法 return [order.view_order] def destroy(self, request, *args, **kwargs): # 删除方法需要更高权限 self.required_permissions [order.delete_order] return super().destroy(request, *args, **kwargs)DRF权限类的好处是天然和Django的has_perm机制打通完全复用前一节建好的RBAC数据模型。6. 前后端权限联动菜单、按钮按角色动态收敛后端权限校验只是安全底线用户体验层面同样重要。一个没有订单导出权限的客服如果把导出按钮灰掉比让他点击后收到403要好得多。这需要在登录态里把用户的权限标识下发到前端。6.1 登录接口返回权限码我通常在登录接口的返回结构里增加permissions字段{ token: xxxxx, user_info: { id: 1, username: demo, roles: [客服] }, permissions: [ order.view_order, order.change_order ] }这个permissions列表可以由上一节的get_user_permission_codes函数生成。需要注意这个返回值是用于前端展示控制的绝不能作为后端接口的安全依据。前端可以隐藏按钮但后端必须照样拦截。6.2 前端动态路由与菜单前端拿到权限码后可以做两件事一是根据权限码过滤路由表。比如order.export_order对应的导出页面路由没有这个权限就直接从路由表中移除用户手动输入URL也会走进404。二是根据权限码控制菜单渲染。常见做法是在路由配置里给每个菜单项加上permission字段渲染菜单时统一过滤const allMenus [ { title: 订单管理, path: /orders, permission: order.view_order }, { title: 订单导出, path: /orders/export, permission: order.export_order }, { title: 系统设置, path: /system, permission: system.change_setting } ]; function filterMenus(menus, permissions) { return menus.filter(menu { if (!menu.permission) return true; return permissions.includes(menu.permission); }); }按钮级的控制在具体页面里用指令或条件渲染el-button v-ifhasPerm(order.export_order) clickexportData 导出订单 /el-buttonhasPerm是在全局注册的一个方法用来判断当前用户权限码集合里是否包含某个标识。6.3 前后端权限不一致的排查思路实际项目中最容易出问题的就是前端权限码和后端权限码不一致。比如后端自定义权限order.review_order前端菜单配置里写成了order.check_order结果就是按钮一直不显示但又没有错误提示。我的排查习惯是登录后先把浏览器里存的permissions数组打印出来再对照后端的权限种子数据auth_permission表逐个核对。为了防止这种问题我会在前端维护一份权限码常量表所有调用hasPerm的地方只引用常量不手写字符串。这样即使权限码改了编译期或IDE就能发现引用问题。前端权限控制还有一个原则必须强调它纯粹是为了用户体验和界面整洁后端权限校验才是真正的安全边界。我见过有人只在前端做按钮隐藏后端接口完全不设防结果被恶意请求把整张表的数据拉走了。这个错误非常致命。7. 细粒度数据权限从模型级权限到行级数据隔离前面介绍的RBAC体系控制的都是能不能操作这个模型但很多时候权限要求会更细。典型场景是销售只能看自己的客户区域经理能看本区域的客户总部运营能看全部客户。这类同一模型不同行可见范围不同的需求属于数据级权限也叫行级权限。7.1 数据范围字段设计行级权限通常需要角色上带一个数据范围的概念from django.db import models class Role(models.Model): ... data_scope models.CharField( 数据范围, max_length20, choices[ (ALL, 全部数据), (DEPT, 本部门数据), (SELF, 仅本人数据), ], defaultSELF, )然后在查询基类里根据角色数据范围自动拼接过滤条件class BaseManager(models.Manager): def visible_to(self, user): if user.is_superuser: return self.get_queryset() # 取用户所有角色的数据范围取最宽松的 scopes set( user.role_relations.filter(role__is_activeTrue) .values_list(role__data_scope, flatTrue) ) if ALL in scopes: return self.get_queryset() if DEPT in scopes: return self.get_queryset().filter(deptuser.dept) # 默认仅本人 return self.get_queryset().filter(creatoruser) class Order(models.Model): ... objects BaseManager()使用visible_to(user)后任何列表接口都是先过滤再分页权限逻辑集中不会漏。需要注意的是这种方案不是数据库层面强制隔离只适用于业务代码统一走Manager的情况。7.2 第三方库的选择如果不想自己维护行级权限可以考虑引入开源库做对象级权限。Django生态里最成熟的是django-guardian它能在Django Permission体系之外再提供对象级别的权限判断from guardian.shortcuts import assign_perm # 把客户对象的查看权限授予某个用户 assign_perm(view_customer, user, customer_instance) # 判断 user.has_perm(view_customer, customer_instance)本地方案和第三方库该怎么选我的经验是如果只有一两个模型需要对象级权限直接用Manager过滤就够了不要额外引依赖。如果整个系统的对象权限模型很复杂需要细粒度授权、授权记录、权限继承那就认真评估django-guardian或类似的方案。永远优先考虑最小实现权限体系最怕过度设计。8. 实战中常见的坑每个都真实踩过最后整理一批我在RBAC落地过程中踩过或者排查过的高频问题都是常规文档里不会明说的经验。8.1 自定义权限码忘记迁移在Meta.permissions里加了权限声明但只做了makemigrations没有执行migrate权限数据就不会写入auth_permission表。这时候接口里用has_perm判断永远返回False前端也拿不到对应权限码。排查时可以先去数据库确认python manage.py migrate python manage.py shell -c from django.contrib.auth.models import Permission; print(list(Permission.objects.values_list(codename, flatTrue)))8.2 权限标识的拼接陷阱Django里has_perm(order.export_order)的参数看起来带点容易让人误以为codename就是export_order。实际上这个点是app_label.codename的分隔符。如果通过ORM查出codename后直接拼进has_perm很容易忘记加app_label。我封装get_user_permission_codes时返回的就是完整标识然后统一比对完整字符串避免前端后端各拼各的。8.3 超级用户的权限绕过is_superuserTrue的用户调has_perm恒为True这是Django的设计。但前端权限码列表通常不会给超管返回所有权限码这会导致一个常见现象超管后端全通但前端某些按钮因为权限码列表匹配不上而隐藏。解决方案是在登录接口返回权限码时对超管单独标记is_superuser前端根据标识放行所有按钮。更稳妥的方式是给超管也返回完整权限码列表一视同仁。8.4 角色上传配置完成后缓存问题后端权限判断在数据库里实时查询改了角色权限后无需用户重新登录即可生效。但前端因为权限码存在登录态里localStorage或store不会自动更新。我处理的方式是在角色权限修改成功后调用一个通知接口让在线用户重新拉取自己的权限码或者提供一个刷新权限按钮前端重新请求一次用户信息接口即可。8.5 N1查询与性能优化权限查询涉及多张关联表稍不注意就会产生大量SQL。尤其是列表页渲染几十个操作按钮时每个按钮都调一次has_perm对数据库的压力不小。优化思路是登录后把权限码集合序列化后续判断全部走内存集合不再触库。角色和权限的M2M关系尽量一次性select_related和prefetch_related查出。如果项目对性能极其敏感可以把每个用户的权限码集合缓存在Redis里修改角色权限时按role_id反向批量清理关联用户的缓存。8.6 权限命名冲突同一个应用里不同模型不能有相同的codename否则迁移或者创建权限时会报唯一约束错误。避免办法是自定义权限时带上语义化的前缀比如export_order、review_order这种带上模块名的命名方式比单纯的export、review安全得多。跨应用时因为有content_type隔离codename相同反而没事但为了可读性仍然建议加前缀。8.7 不要把is_staff当成权限用is_staff只是表示用户能否进入Admin后台的标识用它控制业务接口权限非常危险。我在某个系统里见过到处用user.is_staff判断管理员结果普通员工账号被误开了is_staff标记就能访问部分应受限的接口。业务权限请统一走RBAC不要复用Django后台的字段做别的用途。结尾权限系统不是加几张表就完事的它需要从模型设计、接口拦截、前端联动、数据隔离多个层面整体考虑。我在实际项目中最大的体会是把RBAC落地成角色可配置、权限可分配、数据可控制之后最大的收益其实不是安全感而是可操作性——业务方自己就能在管理后台调整角色权限不再需要每次改权限都来找开发者发版。新手做权限模块时建议从Django自带权限体系入手先把has_perm和permission_required用熟再逐步加入角色层、数据范围层。这套体系一旦成型后面项目的权限需求基本都能在现有框架内解决了。如果文章里的模型或代码对你有帮助直接复制到项目里按需调整就可以踩坑经验部分建议保存下来等上线后对照排查。