Django RBAC权限系统实战:从用户角色到接口权限的完整落地
2026/9/16 11:21:48 网站建设 项目流程

简介:基于Django与Django REST Framework的RBAC权限管理系统示例项目,面向Python开发者、毕业设计或内部管理系统二次开发场景,演示通过角色-权限映射实现细粒度访问控制,并以DRF提供标准RESTful API,支持前后端分离模式。压缩包共97个文件,主要包含38个Python后端源码、18个Vue前端组件、15个TypeScript定义,另有Dockerfile、JSON配置及图片文档,整体仅1.28MB,目录结构清晰,模块边界明确。目前已有47人学习,适合作为快速搭建权限管理后台的参考实例。项目完整覆盖用户认证、角色分配、API接口开发、Docker容器化部署等关键环节,并附带README说明与界面预览图,可帮助开发者理解RBAC从数据模型、序列化接口到权限校验中间件的完整链路,从而降低实际项目中的踩坑成本。

1. 为什么 Django 权限系统要选 RBAC:把 if 判断换成可配置的数据

后台管理系统上线半年后,最常见的失控方式是:新角色加权限只能改代码,运营开临时权限只能等开发,审计问谁改过角色没人答得上来。RBAC 把「谁拥有什么权限」从代码里抽出来,变成数据库里可配置的数据——用户挂角色,角色挂权限,改权限不动代码。Django 自带 auth 框架天然提供了 User、Group、Permission 三张基础表,Django REST Framework 又给出认证与权限两层扩展点,两者叠加就能搭出可维护的权限管理系统。接下来按示例项目包的常见结构走一遍数据模型、接口校验、前后端联动,最后给出跑通与排错清单。适合做管理后台的后端工程师,也适合准备权限系统面试时梳理完整链路。

2. Django Model 设计:User、Role、Permission 三张表怎么落地 RBAC

2.1 复用内置表还是自建 Role 表:两种 RBAC 落地方案的取舍

RBAC 落到 Django 上,第一个选择是用内置模型还是自建模型。最省事的方案是直接用 auth.Group 充当角色,把 Permission 挂到 Group,再用 user.groups 关联用户。这套方案零额外建表,Django Admin 原生支持维护,DjangoModelPermissions 也能直接识别 group 上的权限。代价是 Group 表没有 code、description 这类业务字段,前端拿不到固定的角色标识;而且内置 Permission 的粒度是「对某个 model 的增删改查」,管不了「导出用户数据」「审核订单」这类动作级权限。

多数示例项目会选折中方案:用户继承 AbstractUser,角色自建 Role 表,权限继续复用 auth.Permission,再在 Permission 之上挂菜单和按钮资源。这样既保留 Django Admin 的权限维护体验,又能扩展动作级权限。自建 Role 的关键是看清反向查询名,User 和 Role 是双向多对多,related_name 一旦写错,权限采集时拿到空集合,接口全部 403。这是最隐蔽的坑,后面权限类里会再次遇到。如果只是内部小工具、用户一两百,用 Group 方案完全够,别为理论上的优雅过度设计;一旦角色要挂业务属性、前端要做按钮级控制,自建 Role 表就是分水岭。

2.2 最小可跑的 models.py:用 AbstractUser 扩展用户并关联角色

先看模型层的最小实现:

# rbac/models.py from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): """扩展用户模型:RBAC 中用户只保留身份和基础信息,权限全部通过角色间接获取""" phone = models.CharField(max_length=20, blank=True, default='', verbose_name='手机号') department = models.CharField(max_length=50, blank=True, default='', verbose_name='部门') class Meta: verbose_name = '用户' verbose_name_plural = verbose_name class Role(models.Model): """角色表:RBAC 的核心实体,一个用户可挂多个角色,一个角色可绑多个权限""" name = models.CharField(max_length=50, verbose_name='角色名称') code = models.CharField(max_length=50, unique=True, verbose_name='角色编码') permission = models.ManyToManyField( 'auth.Permission', blank=True, related_name='rbac_roles', verbose_name='权限集合' ) user = models.ManyToManyField( User, blank=True, related_name='rbac_roles', verbose_name='用户集合' ) class Meta: verbose_name = '角色' verbose_name_plural = verbose_name

角色表的 code 字段是给权限判断和前端用的固定标识,name 可以随时改,code 定了就不要动,否则前端按钮和菜单的对应关系会全断。permission 和 user 两个 ManyToManyField 是本节的骨架,Django 会自动生成 rbac_role_permission 和 rbac_role_user 两张中间表,不需要手写 through 表;如果后续要记录「谁在什么时候给角色加了权限」,再加 through 表不迟,一开始加反而拖慢开发节奏。

菜单不是 RBAC 三张核心表之一,但管理后台的权限系统离不开它。示例项目通常把菜单作为权限的展示层资源,核心字段就一个:

class Menu(models.Model): """菜单表:用 permission_code 关联一个权限编码,作为前端渲染的过滤条件""" name = models.CharField(max_length=50, verbose_name='菜单名称') path = models.CharField(max_length=200, verbose_name='前端路由') icon = models.CharField(max_length=50, blank=True, default='', verbose_name='图标') parent = models.ForeignKey( 'self', null=True, blank=True, on_delete=models.CASCADE, related_name='children', verbose_name='父级菜单' ) permission_code = models.CharField( max_length=50, blank=True, default='', verbose_name='所需权限编码' ) sort = models.IntegerField(default=0, verbose_name='排序')

permission_code 对应 auth_permission 表里的 codename,第 4 章的菜单树接口就是靠这个字段做过滤的。菜单表和权限表没有外键,只用字符串关联,好处是权限编码可以在管理命令里集中注册、统一管理,避免菜单表里出现脏外键。

用户模型写好后,还要让 Django 知道用它替换默认 User:

# settings.py AUTH_USER_MODEL = 'rbac.User'

提示:AUTH_USER_MODEL 必须在第一次 migrate 之前配置好。先 migrate 生成了默认 auth_user 表,再改成自定义 User,迁移会直接报表冲突错误,项目往往在启动阶段就失败。

正确的顺序是 django创建app之后、写 models 的同时把 settings.py 配好,再执行第一次迁移。

2.3 makemigrations 与初始化权限数据:django创建app后的关键顺序

# 1. 确认 AUTH_USER_MODEL 指向 rbac.User,再依次执行 python manage.py makemigrations rbac python manage.py migrate # 2. 创建管理员,之后用它登录 Admin 分配角色和权限 python manage.py createsuperuser

migrate 成功后,每个 model 的 add/change/delete/view 内置权限会自动写入 auth_permission 表。要给「导出用户数据」这类动作建权限,常见做法是写一个管理命令,把业务动作注册成统一的 auth.Permission:

# rbac/management/commands/sync_permissions.py from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand from rbac.models import Role class Command(BaseCommand): """把动作级权限同步到 auth_permission 表,重复执行不产生脏数据""" help = '同步业务权限' def handle(self, *args, **options): # 绑定到 Role 的内容类型上,admin 权限列表里能看到归属 content_type = ContentType.objects.get_for_model(Role) permissions = [ ('view_dashboard', '查看仪表盘'), ('export_user_data', '导出用户数据'), ('approve_order', '审核订单'), ('manage_role', '管理角色'), ] for codename, name in permissions: Permission.objects.update_or_create( content_type=content_type, codename=codename, defaults={'name': name}, ) self.stdout.write(self.style.SUCCESS('业务权限同步完成,共 %d 条' % len(permissions)))

这段命令有两个细节。第一,Permission 表的唯一约束是 (content_type_id, codename) 这一对,codename 本身不全局唯一,所以必须固定挂在一个内容类型下,否则同名 codename 会被重复插入;挂在 Role 的内容类型上,Admin 的权限列表里也能正常归属。第二,update_or_create 让命令可重复执行,角色和权限在 Admin 里怎么改都不会被覆盖。

最后把 Role 和 User 注册进 Admin,方便人工维护:

# rbac/admin.py from django.contrib import admin from django.contrib.auth.admin import UserAdmin from .models import User, Role @admin.register(Role) class RoleAdmin(admin.ModelAdmin): list_display = ('name', 'code') filter_horizontal = ('permission', 'user') # 左右选择框,批量分配高效直观 @admin.register(User) class CustomUserAdmin(UserAdmin): fieldsets = UserAdmin.fieldsets + ( ('扩展信息', {'fields': ('phone', 'department')}), )

filter_horizontal 会把多对多字段渲染成两个左右联动选择框,是 Django admin 界面美化里最实用的一行配置。用户与角色的分配在这个界面完成,接口权限校验则由下一章的 DRF 权限类接管,这就构成了「权限数据可配置、校验逻辑代码化」的基本闭环。如果团队习惯用代码管种子数据,也可以把 sync_permissions 换成 fixtures,但管理命令的好处是能对接 CI,权限变更走代码 review,上线时执行一遍即可。

3. Django REST Framework 接口权限:自定义 Permission 类接管 RBAC 校验

3.1 认证先行:用 simplejwt 配置登录与刷新

RBAC 校验的前提是身份可靠,接口层第一步是认证。示例项目常见选择是 djangorestframework-simplejwt,相比 Session 认证,它更契合 Django 前后端分离的部署形态,Web 和移动端共用接口时也不需要处理 Cookie 跨域和 CSRF。JWT 的无状态模型把用户身份放进 token,后端只负责验签,省掉不少会话管理的心智负担。

# settings.py REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework_simplejwt.authentication.JWTAuthentication', ], 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticated', ], }
# 项目 urls.py from django.urls import path from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns = [ path('api/auth/login/', TokenObtainPairView.as_view(), name='login'), path('api/auth/refresh/', TokenRefreshView.as_view(), name='refresh'), ]

登录接口返回 access 和 refresh 两个 token,后续请求在 Header 里带 Authorization: Bearer 。simplejwt 的默认参数是 access 五分钟过期、refresh 一天,生产环境通常按下面这张表调整:

配置项默认值说明
ACCESS_TOKEN_LIFETIMEtimedelta(minutes=5)压短后权限变更能更快生效
REFRESH_TOKEN_LIFETIMEtimedelta(days=1)按业务会话长度调 1~7 天
ROTATE_REFRESH_TOKENSFalse每次刷新都换新 refresh,适合长会话
UPDATE_LAST_LOGINFalse置 True 可在登录时记录 last_login

access 压短的意义不只是安全:用户被移除角色后,最迟在 access 过期时权限即失效,不需要等 refresh 过期。权限变更的生效时间,本质上是「access token 剩余寿命 + 权限类读取的数据是否最新」这两件事决定的。

3.2 自定义 RbacPermission:从角色集合取权限而不是默认 user_permissions

Django 的 user.has_perm() 只查 user.user_permissions、user.groups 和 is_superuser,不会查自建的 Role.permission 多对多关系。只要自建了 Role 表,就不能直接用 DjangoModelPermissions,得写一个自定义权限类接管判断:

# rbac/permissions.py from rest_framework.permissions import BasePermission class RbacPermission(BasePermission): """ 从用户关联的角色上收集权限编码,与视图要求的 required_permission 比对。 视图里声明 required_permission = 'app_label.codename'。 """ def has_permission(self, request, view): # 超级管理员直接放行,避免把自己锁在门外 if request.user and request.user.is_superuser: return True # 视图没声明权限要求,默认放行(登录即可访问的接口) required = getattr(view, 'required_permission', None) if not required: return True app_label, codename = required.split('.') # 收集当前用户所有角色的权限,构成 (app_label, codename) 集合 user_perms = set() roles = request.user.rbac_roles.prefetch_related('permission').all() for role in roles: for perm in role.permission.all(): user_perms.add((perm.content_type.app_label, perm.codename)) return (app_label, codename) in user_perms

这段逻辑里有三个值得注意的点。第一,request.user 在未认证时是 AnonymousUser,所以先判空再做 is_superuser 判断,否则直接抛 AttributeError。第二,prefetch_related 把角色和权限一次性查出来,避免 N+1 查询;权限密集的接口如果发现数据库压力大,第一步先看这里有没有懒加载。第三,集合里存的是 (app_label, codename) 二元组而不是纯 codename,因为 Django 内置权限的 codename 只要求在同一内容类型内唯一,跨 app 时可能重名,带上 app_label 才真正唯一。如果需要细化到单条数据的可见性,可以在 has_object_permission 里再比对对象的部门字段,但更通用的做法是放到查询集过滤,见 4.3 的数据边界说明。

3.3 视图与 action 绑定 required_permission:给每个接口上锁

有了权限类,剩下的问题是让每个接口声明自己需要什么权限。常见做法是在视图中按 action 动态赋值:

# rbac/views.py from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from .models import User from .permissions import RbacPermission from .serializers import UserSerializer class UserViewSet(viewsets.ReadOnlyModelViewSet): queryset = User.objects.all() serializer_class = UserSerializer def get_permissions(self): # 每个 action 绑定一个权限标识,未列出的 action 走默认放行 if self.action == 'export': self.required_permission = 'rbac.export_user_data' # 认证在前、权限在后,先确认身份再做 RBAC 判断 return [IsAuthenticated(), RbacPermission()] @action(detail=False, methods=['post']) def export(self, request): # 只有持有 rbac.export_user_data 的用户能走到这里 return Response({'status': 'ok', 'count': self.get_queryset().count()})

self.required_permission 是实例属性,只影响当前请求,不会串权限。export 动作必须用 @action 包一层,ViewSet 才能把它注册到路由,不加装饰器的话,post 到 /api/users/export 会命中 create 或者直接 404。如果不用 ViewSet,直接在 APIView 里写 permission_classes = [IsAuthenticated(), RbacPermission()] 效果一样,required_permission 作为类属性声明即可;ViewSet 的好处只是把同一资源的动作集中管理。每个资源(用户、角色、菜单)都建一个类似 ViewSet,把动作和权限编码的对应关系集中写在 get_permissions 里,权限清单在代码里可检索、可 review,这是接口权限管理的核心。

4. 前后端分离下 RBAC 权限怎么下发:菜单树、按钮标识与数据边界

4.1 登录后返回角色与权限集合:Serializer 里合并多角色

后端把校验做好还不够,前端要渲染菜单和按钮,必须拿到当前用户的权限集合。登录成功之后,前端会再调一个 /api/auth/userinfo 接口拿用户信息,这个接口的序列化器要把多对多关系拍平成数组:

# rbac/serializers.py from django.contrib.auth import get_user_model from rest_framework import serializers User = get_user_model() class UserInfoSerializer(serializers.ModelSerializer): """登录用户信息:把角色编码和权限编码合并成两个数组,方便前端校验""" roles = serializers.SerializerMethodField() permissions = serializers.SerializerMethodField() class Meta: model = User fields = ('id', 'username', 'roles', 'permissions') def get_roles(self, obj): return list(obj.rbac_roles.values_list('code', flat=True)) def get_permissions(self, obj): # 多角色权限取并集,天然去重 codes = set() for role in obj.rbac_roles.prefetch_related('permission').all(): codes.update(role.permission.values_list('codename', flat=True)) # 这里可以过滤掉内置的 add/change/delete/view,只留业务权限 return sorted(codes)

用户挂了多个角色时,权限取并集,这里用 set 去重。接口返回的数据量很小,角色和权限一般就几十个字符串,不需要分页;真正要压的是查询次数,prefetch_related 在这里同样管用。如果嫌这个接口慢,可以把 permissions 结果按用户缓存到 Redis,角色变更时主动删 key 失效。

4.2 菜单树接口:按权限过滤再递归组装

菜单资源也走 RBAC。菜单表里的 permission_code 字段代表「看到这个菜单需要哪个权限」,接口返回树形结构,侧边栏才能直接渲染:

# rbac/views.py from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from .models import Menu from .permissions import RbacPermission class UserMenuView(APIView): """返回当前用户可见的菜单树;超级管理员直接返回全部菜单""" permission_classes = [IsAuthenticated, RbacPermission] def get(self, request): user_codes = set() if not request.user.is_superuser: for role in request.user.rbac_roles.prefetch_related('permission').all(): user_codes.update(role.permission.values_list('codename', flat=True)) menus = Menu.objects.filter( permission_code__in=user_codes ).order_by('sort') else: menus = Menu.objects.all().order_by('sort') # 先转字典,再挂 children,最后收集顶级节点 tree = [] nodes = {m.id: { 'id': m.id, 'name': m.name, 'path': m.path, 'icon': m.icon, 'children': [], } for m in menus} for m in menus: node = nodes[m.id] parent = nodes.get(m.parent_id) if parent is not None: parent['children'].append(node) else: tree.append(node) return Response(tree)

这段组装逻辑对子级菜单有一个坑:如果父菜单没有权限而子菜单有权限,子菜单的 parent 在 nodes 里找不到,会被当成顶级菜单直接返回,侧边栏会出现一个没有父级的悬浮项。处理办法是返回前把 parent 不在 nodes 里的菜单也过滤掉,或者查询时把祖先一并带上。另一个注意点是 order_by('sort') 只保证同级菜单的查询顺序,组装后 children 列表顺序和查询顺序一致,不需要再单独排序。

4.3 按钮级控制与数据边界:RBAC 管不到的行级权限

按钮级控制不需要再访问后端,用 userinfo 接口返回的 permissions 数组在前端判断就够了:

// 前端工具函数:按钮是否可用的唯一判断入口 export function hasPermission(code) { const permissions = store.state.user.permissions || [] return store.state.user.is_superuser || permissions.includes(code) }

在 Vue 模板里,删除按钮写成<el-button v-if="hasPermission('rbac.delete_user')">删除</el-button>即可。

注意:按钮判断永远对着权限编码,不要对着角色编码。判断「是不是 admin」会在新增角色时迫使前端跟着改代码,RBAC 的灵活性就丢了。

最后要提醒 RBAC 的边界:它管的是「能不能访问某个功能」,管不了「能看到哪些数据」。同一份订单列表,区域经理只能看自己区域的,这是行级数据权限,需要在查询集上按部门或数据范围过滤,很多团队把它做成独立的 DataScope 机制,和 RBAC 叠加使用。能意识到这两者的区别,是权限系统设计里最容易拉开差距的地方。

5. RBAC 示例项目最小启动命令与权限失效排查

5.1 最小启动命令

拿到项目包解压后,先确认本机 Python 3.10+ 已装好并加入 PATH,然后在项目根目录执行:

python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -r requirements.txt python manage.py migrate python manage.py createsuperuser python manage.py runserver 0.0.0.0:8000

migrate 必须放在 createsuperuser 之前,否则创建用户时表还不存在。访问 http://127.0.0.1:8000/admin 登录后,先建两个角色,把权限挂到角色上,再建一个测试用户关联其中一个角色,最后用测试用户调接口验证。本地跑通后部署到服务器(比如宝塔面板搭 Nginx 反代)时,记得把 runserver 换成 gunicorn,runserver 只适合开发环境。

5.2 权限不生效的排查清单

现象按顺序排查
所有接口 403请求头是否有 Authorization: Bearer ;access 是否过期
加了角色仍 403related_name 是否一致;是否用旧 token 请求,access 未过期前权限不刷新
部分接口绕过权限视图是否声明 permission_classes;required_permission 编码是否拼写一致

三个场景对应三个最常见的坑。权限校验发生在 DRF 视图的 initial() 阶段,发生在认证和限流之后,所以 403 先查认证再查权限,不要一上来就改数据库。给测试用户分配角色后接口仍报 403,先把 user.rbac_roles 反查名和 permissions.py 里用的是不是同一个 related_name 对一遍,这个字段写错不会报错,只会让收集到的权限集合为空。

再到项目里 grep required_permission,确认视图声明的编码和 sync_permissions 注册的 codename 完全一致,多一个空格或大小写不同都会匹配失败。把用户权限用 UserInfoSerializer 暴露出来后,前端可以用 permissions.includes() 复现一遍后端判断,前后端各测一次,权限问题在哪一层就清楚了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询