用户与权限管理(2):Policy API 与管理后端
用户与权限管理(2):Policy API 与管理后端
教程版本:v2.0.0;定稿日期:2026-10-08。
本篇把第 1 篇的最小闭环升级为可维护的授权后端:集中能力目录、请求级 PolicySnapshot、has/require API、装饰器、模板标签、委派边界、事务锁后复检、管理视图和 CMDB 全局能力接入。为保证每篇严格小于 15 万字符,完整 HTML 模板与浏览器执行预演放在第 3 篇开头,但后端文件在本篇全部讲完。
| 篇次 | 固定地址 | 内容 |
|---|---|---|
| 第 1 篇 | https://www.cnblogs.com/lizexiong/p/22694083 | 本地登录、自研能力、角色、用户角色与第一次授权 |
| 第 2 篇 | https://www.cnblogs.com/lizexiong/p/22712552 | 本文:Policy API、管理后端、并发复检与 CMDB 全局能力 |
| 第 3 篇 | https://www.cnblogs.com/lizexiong/p/22712579 | 完整管理模板与 Automation 对象授权 |
| 第 4 篇 | https://www.cnblogs.com/lizexiong/p/22716152 | 第三方身份、迁移、完整源码与校验 |
1. 本篇交付物与安全目标
认证回答“你是谁”,授权回答“当前账号能做什么”。本文继续使用 Django 的会话、密码哈希和登录视图,但业务授权不再散落在页面条件、模型字段或各个 App 的临时判断里。项目只认稳定的能力编码,例如 accounts.user.view;角色只是能力集合;用户只绑定角色,不获得页面直接分配的能力。
完成后的关键不变量如下:
- 匿名用户与停用用户没有任何业务能力;停用的超级用户也没有旁路。
- 只有“已认证、激活、超级用户”同时成立时,才启用紧急运维旁路。
- 普通用户的有效能力必须同时经过激活角色与激活能力两层过滤。
- 一次 HTTP 请求可以复用快照;不同请求、不同进程、事务复检之间不能共享授权缓存。
- GET 页面隐藏按钮只改善可用性;每个写视图仍在后端拒绝伪造请求。
- 非超级用户只能委派自己能力集合的子集;角色关联的停用能力仍计入潜在委派范围。
- 所有敏感写入在事务取得锁之后,再用锁后的用户和新快照检查授权。
- 系统角色由代码目录和初始化命令维护,网页不能改写。
- Django Admin 是内部运维边界,只接受激活的超级用户,不复用学习端角色。
2. 从第 1 篇升级:哪些文件替换,哪些保持不变
2.1 操作矩阵
| 动作 | 文件 | 原因 |
|---|---|---|
| 新增 | accounts/capabilities.py | 集中声明稳定能力编码和内置角色目录。 |
| 新增 | accounts/policy.py、accounts/decorators.py | 建立唯一业务授权入口和视图装饰器。 |
| 新增 | accounts/templatetags/__init__.py、accounts/templatetags/policy_tags.py | 让模板通过请求上下文调用同一 Policy。 |
| 新增 | accounts/migrations/0002_custom_rbac.py、0003_migrate_legacy_access.py | 创建项目自有表,并把第 1 篇历史角色成员关系迁入新表。 |
| 新增 | accounts/management/commands/bootstrap_rbac.py | 幂等同步目录、修复漂移并退休已经移出代码的系统项。 |
| 整体替换 | accounts/models.py、forms.py、views.py、urls.py、admin.py | 模型、委派、事务复检、路由和 Admin 边界必须使用同一套语义。 |
| 整体替换 | templates/base.html、templates/403.html 与本文列出的用户、部门、角色、能力目录模板 | 所有可见入口改用 policy_can;旧页面条件不能继续承担业务授权。 |
| 保持第 1 篇版本 | manage.py、项目包的 settings.py、项目根路由、apps.py、0001_initial.py | 启动方式、自定义用户配置和初始历史迁移不需要重写。 |
| 保持第 1 篇版本 | accounts/static/accounts/style.css、密码修改两个模板、WSGI/ASGI 文件、依赖文件 | 本次没有新增样式依赖或认证依赖。 |
| 保持第 1 篇语义 | templates/accounts/login.html | 仍然只有本地用户名和密码。后文重新给出完整阶段版本,防止误带第 3 篇外部登录链接。 |
settings.py 中保留 Django 标准的 django.template.context_processors.request 即可。本文没有自定义 RBAC context processor;policy_can 直接消费普通模板上下文里的 request。如果第 1 篇配置已经存在,不要重复添加。
2.2 阶段目录
accounts/
├── admin.py
├── capabilities.py
├── decorators.py
├── forms.py
├── management/
│ └── commands/
│ └── bootstrap_rbac.py
├── migrations/
│ ├── 0001_initial.py
│ ├── 0002_custom_rbac.py
│ └── 0003_migrate_legacy_access.py
├── models.py
├── policy.py
├── templatetags/
│ ├── __init__.py
│ └── policy_tags.py
├── urls.py
└── views.py
templates/
├── 403.html
├── base.html
└── accounts/
├── login.html
├── home.html
├── user_*.html
├── department_*.html
├── role_*.html
└── permission_list.html
accounts/templatetags/__init__.py 是空文件。它让目录成为 Python 包;不要在其中注册标签,也不要创建全局快照。
3. 能力目录与数据模型
3.1 能力编码是代码契约,名称只是展示
accounts.user.assign_role 这样的 key 会被视图、模板、初始化命令和未来的 CMDB 同时引用,因此必须稳定、可审查。中文名称和说明可以迭代,key 不能因为页面文案变化而改名。内置角色同样有稳定 key;系统角色不是数据库里随手创建的一行,而是代码拥有、命令精确同步的数据。
完整文件:accounts/capabilities.py
# accounts/capabilities.py
"""项目自有能力目录与内置角色定义。"""
# 每个常量都保存一个不会随中文名称变化的能力编码,视图、模板和数据库用同一个值完成授权匹配。
# 查看管理首页:只控制进入管理首页,不自动包含任何用户、部门或角色操作能力。
DASHBOARD_VIEW = "accounts.dashboard.view"
# 查看用户:允许读取用户列表与详情,但不允许创建、编辑、停用或删除。
USER_VIEW = "accounts.user.view"
# 创建用户:允许提交新用户表单。
USER_CREATE = "accounts.user.create"
# 编辑用户:允许修改已有用户的业务资料。
USER_CHANGE = "accounts.user.change"
# 删除用户:允许走带最后一个超级用户保护的删除流程。
USER_DELETE = "accounts.user.delete"
# 管理用户状态:允许启用或停用账号。
USER_STATUS = "accounts.user.status"
# 分配用户角色:允许替换用户与项目自有角色的关联。
USER_ASSIGN_ROLE = "accounts.user.assign_role"
# 查看部门:允许读取部门层级及统计信息。
DEPARTMENT_VIEW = "accounts.department.view"
# 管理部门:允许创建、编辑和删除部门。
DEPARTMENT_MANAGE = "accounts.department.manage"
# 查看角色:允许读取角色及其能力集合。
ROLE_VIEW = "accounts.role.view"
# 管理角色:允许维护自定义角色及其能力关联。
ROLE_MANAGE = "accounts.role.manage"
# 查看能力目录:允许只读浏览系统声明的能力。
CAPABILITY_VIEW = "accounts.capability.view"
# 能力目录是由字典组成的元组;元组本身不可增删,但其中的字典仍可变,因此不要把它误称为深度不可变。
# key 写入授权关系,name 展示给用户,description 解释能力边界;bootstrap_rbac 会幂等同步。
CAPABILITY_CATALOG = (
{
"key": DASHBOARD_VIEW,
"name": "查看管理首页",
"description": "访问用户与权限管理首页。",
},
{
"key": USER_VIEW,
"name": "查看用户",
"description": "查看用户列表和用户详情。",
},
{
"key": USER_CREATE,
"name": "创建用户",
"description": "创建普通用户账号。",
},
{
"key": USER_CHANGE,
"name": "编辑用户",
"description": "编辑用户业务资料。",
},
{
"key": USER_DELETE,
"name": "删除用户",
"description": "删除用户账号,但不能破坏最后一个激活超级用户。",
},
{
"key": USER_STATUS,
"name": "管理用户状态",
"description": "启用或停用用户账号。",
},
{
"key": USER_ASSIGN_ROLE,
"name": "分配用户角色",
"description": "为用户替换项目自有角色,不提供直接权限分配。",
},
{
"key": DEPARTMENT_VIEW,
"name": "查看部门",
"description": "查看部门层级和部门用户统计。",
},
{
"key": DEPARTMENT_MANAGE,
"name": "管理部门",
"description": "创建、编辑和删除部门。",
},
{
"key": ROLE_VIEW,
"name": "查看角色",
"description": "查看项目自有角色及其能力。",
},
{
"key": ROLE_MANAGE,
"name": "管理角色",
"description": "创建、编辑和删除项目自有角色及能力关联。",
},
{
"key": CAPABILITY_VIEW,
"name": "查看能力目录",
"description": "只读查看项目自有能力目录。",
},
)
# 角色目录同样使用不可变元组集中声明;每个角色字典包含稳定编码、显示名称、职责说明和能力编码元组。
# 修改这里的集合后执行 bootstrap_rbac,数据库中的系统角色会被精确同步为声明状态。
ROLE_CATALOG = (
{
"key": "platform_admin",
"name": "平台管理员",
"description": "拥有本项目全部管理能力。",
# item["key"] 按字典键取值;for ... in ... 是生成器表达式,tuple(...) 把全部能力编码固化成元组。
"capabilities": tuple(item["key"] for item in CAPABILITY_CATALOG),
},
{
"key": "user_manager",
"name": "用户管理员",
"description": "负责用户资料、状态、角色分配及相关只读查询。",
"capabilities": (
DASHBOARD_VIEW,
USER_VIEW,
USER_CREATE,
USER_CHANGE,
USER_DELETE,
USER_STATUS,
USER_ASSIGN_ROLE,
DEPARTMENT_VIEW,
ROLE_VIEW,
CAPABILITY_VIEW,
),
},
{
"key": "permission_manager",
"name": "权限管理员",
"description": "负责角色能力配置和用户角色分配。",
"capabilities": (
DASHBOARD_VIEW,
USER_VIEW,
USER_ASSIGN_ROLE,
DEPARTMENT_VIEW,
ROLE_VIEW,
ROLE_MANAGE,
CAPABILITY_VIEW,
),
},
{
"key": "readonly_viewer",
"name": "只读查看员",
"description": "只能查看管理数据和能力目录。",
"capabilities": (
DASHBOARD_VIEW,
USER_VIEW,
DEPARTMENT_VIEW,
ROLE_VIEW,
CAPABILITY_VIEW,
),
},
)
# frozenset 是只读集合,适合做快速成员判断,也能避免运行时误改目录派生值。
# 所有能力编码集合供初始化命令校验“常量与目录是否完全一致”。
CAPABILITY_KEYS = frozenset(item["key"] for item in CAPABILITY_CATALOG)
# 所有内置角色编码集合供模型、表单和初始化命令保护保留编码。
ROLE_KEYS = frozenset(item["key"] for item in ROLE_CATALOG)
# 所有内置角色中文名称集合用于防止自定义角色占用系统名称。
ROLE_NAMES = frozenset(item["name"] for item in ROLE_CATALOG)
3.2 完整阶段模型
User 继续继承 AbstractUser,所以历史兼容字段仍存在,但新的业务代码不会读取它们。项目自有链路固定为 UserRole → Role → RoleCapability → Capability。中间模型显式保存分配人,并用唯一约束阻止重复关系。
部门的 clean() 沿父链检查自环、把后代设为父节点以及已有循环;删除仍由 PROTECT 和视图中的友好检查共同保护。角色模型在 clean() 与 save() 两层拒绝自定义数据占用内置 key 或名称,因为直接调用 save() 不会自动调用 clean()。
完整文件:accounts/models.py
# accounts/models.py
# AbstractUser 是 Django 已实现用户名、密码、登录状态等字段的可扩展用户模型基类。
from django.contrib.auth.models import AbstractUser
# ValidationError 表示业务数据校验失败;ModelForm 会把它转换成可展示的表单错误。
from django.core.exceptions import ValidationError
# models 命名空间提供 Model、字段类型、删除策略和数据库约束。
from django.db import models
# 点号表示从当前 accounts 包相对导入;两个只读集合保存系统保留角色编码与名称。
from .capabilities import ROLE_KEYS, ROLE_NAMES
# models.Model 提供 ORM 字段收集、查询和 save()/delete() 等模型能力。
class Department(models.Model):
# 第一个位置参数是后台/表单使用的 verbose_name;max_length=50 同时限制模型校验并定义数据库列长度;unique=True 建唯一约束且令表单必填。
code = models.CharField("部门编码", max_length=50, unique=True)
# “部门名称”是 verbose_name;max_length=100 限制字符串长度;未设 null/blank,数据库和表单默认都必填。
name = models.CharField("部门名称", max_length=100)
# "self" 表示外键指回 Department 自身;verbose_name 是中文标签;null=True 允许数据库 NULL,blank=True 允许表单留空。
# on_delete=PROTECT 主要由 Django ORM 的删除收集器阻止删除仍有下级的对象,并非所有原始 SQL 场景都具备的通用数据库保证;related_name="children" 提供 parent.children 反向查询名。
parent = models.ForeignKey(
"self",
verbose_name="上级部门",
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="children",
)
# “启用”是 verbose_name;default=True 在未赋值的新实例上提供默认值,BooleanField 默认不允许 NULL。
is_active = models.BooleanField("启用", default=True)
# “排序”是 verbose_name;default=0 提供默认值;PositiveIntegerField 在 Django 校验中要求非负整数。
sort_order = models.PositiveIntegerField("排序", default=0)
# auto_now_add=True 仅在首次创建时由 Django 写入当前时间;“创建时间”是 verbose_name。
created_at = models.DateTimeField("创建时间", auto_now_add=True)
# auto_now=True 在每次调用 save() 时由 Django 更新当前时间;QuerySet.update() 不会触发该机制。
updated_at = models.DateTimeField("更新时间", auto_now=True)
# 嵌套 Meta 不是数据库字段,而是声明该模型的查询和后台元数据。
class Meta:
# ordering 是未显式 order_by() 时的默认排序:先 sort_order,再 code。
ordering = ["sort_order", "code"]
# verbose_name 是模型单数中文名。
verbose_name = "部门"
# verbose_name_plural 是模型复数中文名;中文无需词形变化。
verbose_name_plural = "部门"
def clean(self):
# 输入:待校验的 Department 实例;输出:无返回值并规范校验结果;调用者:表单或显式 full_clean();失败:父级自指、成环时抛出 ValidationError,数据库读取异常继续上抛。
super().clean()
# 业务判断:没有上级部门时不存在父链成环问题,直接结束校验。
if not self.parent_id:
return
# 业务判断:同一节点不能同时成为自己的父节点。
if self.pk and self.parent_id == self.pk:
raise ValidationError({"parent": "上级部门不能是当前部门。"})
# 数据库读取与业务判断:逐级读取上级对象并检查重复节点,避免保存后形成无法遍历的环。
ancestor = self.parent
visited = set()
while ancestor is not None:
if self.pk and ancestor.pk == self.pk:
raise ValidationError({"parent": "上级部门不能是当前部门的后代。"})
if ancestor.pk in visited:
raise ValidationError({"parent": "部门层级中存在循环关系。"})
visited.add(ancestor.pk)
ancestor = ancestor.parent
# 返回:校验通过时自然返回 None,当前方法不保存数据库。
def __str__(self):
# 输入:当前 Department 实例;输出:“编码 - 名称”字符串;调用者:管理后台、模板及日志;失败:字段值异常时按 Python 字符串格式化规则上抛。
return "%s - %s" % (self.code, self.name)
# User 继承 AbstractUser:保留 Django 认证字段和方法,并在同一数据库表上增加业务字段。
class User(AbstractUser):
# verbose_name="姓名";max_length=100;blank=True 只放宽表单/模型校验,数据库仍以空字符串而不是 NULL 表示未填。
display_name = models.CharField("姓名", max_length=100, blank=True)
# verbose_name="工号";max_length=50;unique=True 建唯一约束;null=True 让多个未填值存为 NULL;blank=True 允许表单留空。
employee_number = models.CharField(
"工号",
max_length=50,
unique=True,
null=True,
blank=True,
)
# verbose_name="手机号";max_length=20;blank=True 允许表单空白,未设 null 因而数据库使用空字符串。
mobile = models.CharField("手机号", max_length=20, blank=True)
# Department 指定目标模型;verbose_name 是标签;null/blank 分别允许数据库 NULL 与表单留空。
# on_delete=PROTECT 主要通过 Django ORM 删除收集器保护仍有关联用户的部门,不应当描述成所有原始数据库删除都必然受保护;related_name="users" 提供 department.users。
department = models.ForeignKey(
Department,
verbose_name="部门",
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="users",
)
# groups 与 user_permissions 由 AbstractUser 保留,仅用于 Django 兼容。
# "Role" 用字符串延迟解析尚未定义的模型;verbose_name 是标签;through 指定带额外字段的中间模型。
# through_fields 明确中间表两端依次为 user、role;related_name="users" 提供 role.users;blank=True 允许表单不选角色。
roles = models.ManyToManyField(
"Role",
verbose_name="角色",
through="UserRole",
through_fields=("user", "role"),
related_name="users",
blank=True,
)
def clean(self):
# 输入:待校验的 User 实例;输出:无返回值并把空工号规范为 None;调用者:用户表单或显式 full_clean();失败:父类校验失败时抛出 ValidationError。
super().clean()
# 业务判断:唯一字段把空字符串统一成 NULL,允许多个用户暂不填写工号。
if not self.employee_number:
# 保存准备:这里只修改内存中的字段,真正写库由后续表单或调用者执行。
self.employee_number = None
# 返回:规范化完成后自然返回 None。
def __str__(self):
# 输入:当前 User 实例;输出:优先返回去除首尾空白的姓名,否则返回用户名;调用者:模板、后台及日志;失败:字段不是预期字符串时上抛属性错误。
return self.display_name.strip() or self.username
# Capability 继承 models.Model,表示代码可引用、角色可关联的一项业务能力。
class Capability(models.Model):
# verbose_name="能力编码";max_length=150;unique=True 在数据库和模型校验层要求编码唯一。
key = models.CharField("能力编码", max_length=150, unique=True)
# verbose_name="能力名称";max_length=100;默认不允许 NULL 或表单空白。
name = models.CharField("能力名称", max_length=100)
# TextField 不要求 max_length;verbose_name="说明";blank=True 允许表单留空,数据库保存空字符串。
description = models.TextField("说明", blank=True)
# 两个 BooleanField 的首参都是标签;default 分别在新实例未赋值时给出 False 与 True,默认不允许 NULL。
is_system = models.BooleanField("系统内置", default=False)
is_active = models.BooleanField("启用", default=True)
# auto_now_add 只写创建时间;auto_now 在 Model.save() 时刷新更新时间;首参是字段中文标签。
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
# Meta 汇总不属于实例字段的模型选项。
class Meta:
# ordering 指定未显式 order_by() 时按稳定能力编码 key 升序查询。
ordering = ["key"]
# verbose_name 是描述一条 Capability 记录时使用的单数名称。
verbose_name = "能力"
# verbose_name_plural 是描述多条记录时使用的复数名称;中文无需改变词形。
verbose_name_plural = "能力"
def clean(self):
# 输入:待校验的 Capability 实例;输出:无返回值并规范能力编码;调用者:模型表单或显式 full_clean();失败:父类校验失败时抛出 ValidationError。
super().clean()
# 业务判断:稳定编码统一去除首尾空白并转成小写。
self.key = self.key.strip().lower()
# 返回:规范化完成后自然返回 None,当前方法不保存数据库。
def save(self, *args, **kwargs):
# 输入:Model.save() 的位置与关键字参数;输出:父类 save() 的返回值;调用者:表单、命令或业务代码;失败:编码值异常或数据库写入失败时上抛对应异常。
# 保存准备:save() 不会自动调用 clean(),写库前再次统一稳定编码。
self.key = self.key.strip().lower()
# 保存与返回:交给 Django 写入数据库,并原样返回父类结果。
return super().save(*args, **kwargs)
def __str__(self):
# 输入:当前 Capability 实例;输出:“名称(编码)”字符串;调用者:模板、后台及日志;失败:字段值异常时按 Python 字符串格式化规则上抛。
return "%s(%s)" % (self.name, self.key)
# Role 继承 models.Model,保存角色主体;具体能力通过显式中间模型关联。
class Role(models.Model):
# 两个 CharField 的首参是中文标签;max_length 定义列长度并参与校验;unique=True 分别保证编码和名称唯一。
key = models.CharField("角色编码", max_length=100, unique=True)
name = models.CharField("角色名称", max_length=100, unique=True)
# TextField 适合可变长说明;blank=True 允许表单空白,未设 null 因而数据库保存空字符串。
description = models.TextField("说明", blank=True)
# default=False/True 是新实例默认值;BooleanField 默认不允许 NULL。
is_system = models.BooleanField("系统内置", default=False)
is_active = models.BooleanField("启用", default=True)
# auto_now_add 只记录首次创建;auto_now 在调用 save() 时更新;首参是中文标签。
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
# Capability 是目标模型;verbose_name 是标签;through 指定可记录 assigned_by/created_at 的 RoleCapability。
# related_name="roles" 提供 capability.roles 反向查询;blank=True 允许角色暂时没有能力。
capabilities = models.ManyToManyField(
Capability,
verbose_name="能力",
through="RoleCapability",
related_name="roles",
blank=True,
)
# Meta 声明默认排序和模型显示名称。
class Meta:
# 默认先按名称、再按稳定编码排序。
ordering = ["name", "key"]
# 单数与复数显示名均为“角色”。
verbose_name = "角色"
verbose_name_plural = "角色"
def clean(self):
# 输入:待校验的 Role 实例;输出:无返回值并规范编码;调用者:角色表单或显式 full_clean();失败:占用系统保留编码或名称时抛出 ValidationError。
super().clean()
# 业务判断:角色稳定编码统一去除首尾空白并转成小写。
self.key = self.key.strip().lower()
# 业务判断:内置编码和名称都只能属于 bootstrap 创建的系统角色;同时保护名称可避免自定义角色阻断目录修复。
if self.key in ROLE_KEYS and not self.is_system:
raise ValidationError({"key": "该角色编码由系统内置角色保留。"})
if self.name.strip() in ROLE_NAMES and not self.is_system:
raise ValidationError({"name": "该角色名称由系统内置角色保留。"})
# 返回:校验通过时自然返回 None,当前方法不保存数据库。
def save(self, *args, **kwargs):
# 输入:Model.save() 的位置与关键字参数;输出:父类 save() 的返回值;调用者:表单、命令或业务代码;失败:保留值冲突时抛出 ValidationError,写库失败时上抛数据库异常。
# 保存准备:save() 不会自动调用 clean(),因此先规范编码和名称。
self.key = self.key.strip().lower()
self.name = self.name.strip()
# 业务判断:在模型写入边界再次拒绝非系统角色占用保留编码或名称。
if self.key in ROLE_KEYS and not self.is_system:
raise ValidationError("该角色编码由系统内置角色保留。")
if self.name in ROLE_NAMES and not self.is_system:
raise ValidationError("该角色名称由系统内置角色保留。")
# 保存与返回:校验通过后交给 Django 写入数据库,并原样返回父类结果。
return super().save(*args, **kwargs)
def __str__(self):
# 输入:当前 Role 实例;输出:角色名称字符串;调用者:模板、后台及日志;失败:字段值异常时由调用上下文继续处理。
return self.name
# RoleCapability 继承 models.Model,是 Role 与 Capability 的显式多对多中间模型。
class RoleCapability(models.Model):
# Role 是目标模型;verbose_name 是标签;CASCADE 表示 ORM 删除角色时删除关联;related_name 提供 role.role_capabilities。
role = models.ForeignKey(
Role,
verbose_name="角色",
on_delete=models.CASCADE,
related_name="role_capabilities",
)
# Capability 是目标模型;CASCADE 清理随能力失效的关联;related_name 供反向查询。
capability = models.ForeignKey(
Capability,
verbose_name="能力",
on_delete=models.CASCADE,
related_name="role_capabilities",
)
# User 是操作人目标;null=True 允许数据库 NULL,blank=True 允许表单留空;SET_NULL 在操作人被 ORM 删除时保留审计行并置空;related_name 提供反向查询。
assigned_by = models.ForeignKey(
User,
# verbose_name="分配人" 是表单、错误信息和后台使用的人类可读字段名称。
verbose_name="分配人",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="assigned_role_capabilities",
)
# verbose_name="分配时间";auto_now_add=True 在关联首次创建时写入当前时间。
created_at = models.DateTimeField("分配时间", auto_now_add=True)
# Meta 为中间模型声明排序、数据库约束和显示名。
class Meta:
# 默认依次按外键存储列 role_id、capability_id 排序,避免访问关联对象。
ordering = ["role_id", "capability_id"]
# constraints 是随迁移创建的数据库约束列表。
constraints = [
# fields 指定联合唯一的两列;name 是稳定且可供迁移引用的数据库约束名。
models.UniqueConstraint(
fields=["role", "capability"],
name="accounts_unique_role_capability",
)
]
# verbose_name 是一条 RoleCapability 关系的单数显示名称。
verbose_name = "角色能力"
# verbose_name_plural 是多条关系的复数显示名称;中文仍使用“角色能力”。
verbose_name_plural = "角色能力"
def __str__(self):
# 输入:当前 RoleCapability 实例;输出:“角色:能力编码”字符串;调用者:模板、后台及日志;失败:访问已失效关联或数据库读取失败时上抛对应异常。
return "%s:%s" % (self.role, self.capability.key)
# UserRole 继承 models.Model,是 User 与 Role 的显式多对多中间模型。
class UserRole(models.Model):
# User 是目标模型;verbose_name 是标签;CASCADE 在 ORM 删除用户时清理关联;related_name 提供 user.user_roles。
user = models.ForeignKey(
User,
verbose_name="用户",
on_delete=models.CASCADE,
related_name="user_roles",
)
# Role 是目标模型;CASCADE 在 ORM 删除角色时清理关联;related_name 提供 role.user_roles。
role = models.ForeignKey(
Role,
verbose_name="角色",
on_delete=models.CASCADE,
related_name="user_roles",
)
# 操作人也指向 User;null/blank 允许没有操作人;SET_NULL 在操作人删除时保留目标授权;related_name 区分此反向关系。
assigned_by = models.ForeignKey(
User,
# verbose_name="分配人" 是表单、错误信息和后台使用的人类可读字段名称。
verbose_name="分配人",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="assigned_user_roles",
)
# auto_now_add=True 在关联首次创建时写入时间;首参是字段标签。
created_at = models.DateTimeField("分配时间", auto_now_add=True)
# Meta 声明中间模型的默认排序、联合唯一约束和中文名称。
class Meta:
# 直接按外键列排序,不必加载关联对象。
ordering = ["user_id", "role_id"]
# constraints 会由迁移落实为数据库约束。
constraints = [
# 同一用户与角色只允许一条关联;name 是显式约束名。
models.UniqueConstraint(
fields=["user", "role"],
name="accounts_unique_user_role",
)
]
# verbose_name 是一条 UserRole 关系的单数显示名称。
verbose_name = "用户角色"
# verbose_name_plural 是多条关系的复数显示名称;中文仍使用“用户角色”。
verbose_name_plural = "用户角色"
def __str__(self):
# 输入:当前 UserRole 实例;输出:“用户:角色”字符串;调用者:模板、后台及日志;失败:访问已失效关联或数据库读取失败时上抛对应异常。
return "%s:%s" % (self.user, self.role)
3.3 模型函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
4. 数据库迁移:结构与历史兼容分开
0002 只描述新表、新字段和唯一约束;0003 才迁移第 1 篇历史成员关系。迁移文件必须保存自己的目录快照,不能导入未来会变化的 capabilities.py。这样几年后从空库重放迁移,仍能得到当时确定的结果。
0003 中出现的 Django 历史组与权限模型只属于一次性兼容迁移,不是本文引入的新业务 API。运行态视图、表单、模板和 Policy 都不会调用这些对象。反向迁移故意不删除新业务数据,避免回滚代码时误删已经产生的角色分配。
完整文件:accounts/migrations/0002_custom_rbac.py
# accounts/migrations/0002_custom_rbac.py
# Generated by Django 5.2.17 on 2026-09-22 02:22
# deletion 命名空间保存迁移序列化后的 CASCADE/SET_NULL 等删除策略引用。
import django.db.models.deletion
# settings.AUTH_USER_MODEL 让迁移引用项目配置的用户模型,而不是写死 auth.User。
from django.conf import settings
# migrations 提供迁移操作和基类;models 提供此历史状态中的字段与约束构造器。
from django.db import migrations, models
# Migration 继承 migrations.Migration;Django 读取这个约定类来构建迁移依赖图和操作序列。
class Migration(migrations.Migration):
# dependencies 是“应用标签、迁移名”元组列表;Django 必须先应用 accounts.0001_initial。
dependencies = [
('accounts', '0001_initial'),
]
# operations 按列表顺序改变数据库结构和迁移状态;这里的模型是历史快照,不会运行当前 models.py 的方法。
operations = [
# CreateModel 的 name 是历史模型名;fields 列表每项为“字段名、字段对象”,options 保存当时 Meta 快照。
# 数据库结构:创建能力目录表,字段定义是当时模型状态的历史快照。
migrations.CreateModel(
name='Capability',
fields=[
# auto_created=True 表示 Django 自动生成;primary_key=True 设主键;serialize=False 不把值写入序列化对象;verbose_name 是标签。
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# max_length 定义列长度并参与校验;unique=True 建唯一约束;verbose_name 是中文标签。
('key', models.CharField(max_length=150, unique=True, verbose_name='能力编码')),
# max_length=100 限制长度;verbose_name 是历史字段标签;未设 null/blank,默认必填。
('name', models.CharField(max_length=100, verbose_name='能力名称')),
# blank=True 允许表单/模型校验留空;verbose_name 是标签;未设 null,数据库保存空字符串。
('description', models.TextField(blank=True, verbose_name='说明')),
# default=False 是新实例默认值;verbose_name 是标签;BooleanField 默认不允许 NULL。
('is_system', models.BooleanField(default=False, verbose_name='系统内置')),
# default=True 是新实例默认值;verbose_name 是标签;BooleanField 默认不允许 NULL。
('is_active', models.BooleanField(default=True, verbose_name='启用')),
# auto_now_add=True 仅在创建时写当前时间;verbose_name 是标签。
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
# auto_now=True 在调用模型 save() 时刷新;verbose_name 是标签。
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
# verbose_name/verbose_name_plural 是历史模型的单数/复数后台显示名。
'verbose_name': '能力',
'verbose_name_plural': '能力',
# ordering 是未显式排序时的历史默认顺序。
'ordering': ['key'],
},
),
# 数据库结构:创建角色表,后续再通过显式中间表连接能力。
migrations.CreateModel(
name='Role',
fields=[
# 自动主键参数与上表相同:Django 生成、主键、不序列化、标签为 ID。
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# max_length=100 限制长度;unique=True 建唯一约束;verbose_name 是标签。
('key', models.CharField(max_length=100, unique=True, verbose_name='角色编码')),
# max_length=100 限制长度;unique=True 建唯一约束;verbose_name 是标签。
('name', models.CharField(max_length=100, unique=True, verbose_name='角色名称')),
# blank=True 允许校验留空;verbose_name 是标签;未设 null,数据库保存空字符串。
('description', models.TextField(blank=True, verbose_name='说明')),
# default=False/verbose_name 分别声明默认值和标签。
('is_system', models.BooleanField(default=False, verbose_name='系统内置')),
# default=True/verbose_name 分别声明默认值和标签。
('is_active', models.BooleanField(default=True, verbose_name='启用')),
# auto_now_add=True 仅在创建时写时间;verbose_name 是标签。
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
# auto_now=True 在模型 save() 时刷新;verbose_name 是标签。
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
# 单数与复数后台显示名。
'verbose_name': '角色',
'verbose_name_plural': '角色',
# 默认依次按名称、编码排序。
'ordering': ['name', 'key'],
},
),
# AlterModelOptions 只更新迁移状态中的 User 元数据,不增加数据库字段;字典两项分别是单数与复数显示名。
migrations.AlterModelOptions(
name='user',
options={'verbose_name': 'user', 'verbose_name_plural': 'users'},
),
# 数据库结构:创建角色与能力的显式中间表,以便额外记录分配人和分配时间。
migrations.CreateModel(
name='RoleCapability',
fields=[
# 自动主键参数:auto_created、primary_key、serialize、verbose_name,语义与前两表相同。
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# auto_now_add=True 记录首次分配时间;verbose_name 是标签。
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='分配时间')),
# blank/null 允许空操作人;on_delete=SET_NULL 保留关联并置空;related_name 是反向名;to 指向配置用户;verbose_name 是标签。
('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_role_capabilities', to=settings.AUTH_USER_MODEL, verbose_name='分配人')),
# on_delete=CASCADE 随能力清理关联;related_name 是反向名;to 是目标历史模型;verbose_name 是标签。
('capability', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_capabilities', to='accounts.capability', verbose_name='能力')),
# on_delete=CASCADE 随角色清理关联;related_name 是反向名;to 是目标历史模型;verbose_name 是标签。
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_capabilities', to='accounts.role', verbose_name='角色')),
],
options={
# 中间模型的单数/复数后台名称。
'verbose_name': '角色能力',
'verbose_name_plural': '角色能力',
# 默认直接按外键列排序。
'ordering': ['role_id', 'capability_id'],
},
),
# model_name/name 指定把 capabilities 加到 Role;to 是目标;through 是中间模型;related_name 是反向名;blank 允许空;verbose_name 是标签。
migrations.AddField(
model_name='role',
name='capabilities',
field=models.ManyToManyField(blank=True, related_name='roles', through='accounts.RoleCapability', to='accounts.capability', verbose_name='能力'),
),
# 数据库结构:创建用户与角色的显式中间表,以便记录谁在何时执行了分配。
migrations.CreateModel(
name='UserRole',
fields=[
# 自动主键四个参数与前述中间表相同。
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
# auto_now_add=True 记录首次分配时间;verbose_name 是标签。
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='分配时间')),
# blank/null 允许空;SET_NULL 保留授权;related_name 是反向名;to 使用配置用户;verbose_name 是标签。
('assigned_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_user_roles', to=settings.AUTH_USER_MODEL, verbose_name='分配人')),
# CASCADE 随角色清理关联;related_name 是反向名;to 指向 Role;verbose_name 是标签。
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', to='accounts.role', verbose_name='角色')),
# CASCADE 随用户清理关联;related_name 是反向名;to 使用配置用户;verbose_name 是标签。
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_roles', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
# 中间模型的单数/复数后台名称。
'verbose_name': '用户角色',
'verbose_name_plural': '用户角色',
# 默认直接按外键列排序。
'ordering': ['user_id', 'role_id'],
},
),
# through_fields 明确中间表两端字段顺序;其余 blank/related_name/through/to/verbose_name 参数与角色字段同义。
migrations.AddField(
model_name='user',
name='roles',
field=models.ManyToManyField(blank=True, related_name='users', through='accounts.UserRole', through_fields=('user', 'role'), to='accounts.role', verbose_name='角色'),
),
# AddConstraint 把联合唯一约束加入迁移状态和数据库;fields 是字段元组,name 是稳定约束名。
migrations.AddConstraint(
model_name='rolecapability',
constraint=models.UniqueConstraint(fields=('role', 'capability'), name='accounts_unique_role_capability'),
),
# 同理,为 user/role 两列加入命名联合唯一约束,保证同一分配不重复。
migrations.AddConstraint(
model_name='userrole',
constraint=models.UniqueConstraint(fields=('user', 'role'), name='accounts_unique_user_role'),
),
]
完整文件:accounts/migrations/0003_migrate_legacy_access.py
# accounts/migrations/0003_migrate_legacy_access.py
# migrations 提供 Migration 基类和 RunPython 数据迁移操作。
from django.db import migrations
# 这份映射是 0001 版本中真正展示给用户的旧角色名称。
# 迁移文件必须保存历史快照,不能依赖未来可能继续变化的 capabilities.py。
LEGACY_ROLE_MAP = {
"平台管理员": "platform_admin",
"用户管理员": "user_manager",
"权限管理员": "permission_manager",
"只读用户查看员": "readonly_viewer",
}
# 能力目录在迁移内保存固定历史数据:每项依次是稳定编码、显示名称和说明。
# 不能从当前业务模块导入,因为未来修改目录会让旧迁移在新环境产生不同结果。
CAPABILITY_CATALOG = (
("accounts.dashboard.view", "查看管理首页", "访问用户与权限管理首页。"),
("accounts.user.view", "查看用户", "查看用户列表和用户详情。"),
("accounts.user.create", "创建用户", "创建普通用户账号。"),
("accounts.user.change", "编辑用户", "编辑用户业务资料。"),
("accounts.user.delete", "删除用户", "删除用户账号,但不能破坏最后一个激活超级用户。"),
("accounts.user.status", "管理用户状态", "启用或停用用户账号。"),
("accounts.user.assign_role", "分配用户角色", "为用户替换项目自有角色,不提供直接权限分配。"),
("accounts.department.view", "查看部门", "查看部门层级和部门用户统计。"),
("accounts.department.manage", "管理部门", "创建、编辑和删除部门。"),
("accounts.role.view", "查看角色", "查看项目自有角色及其能力。"),
("accounts.role.manage", "管理角色", "创建、编辑和删除项目自有角色及能力关联。"),
("accounts.capability.view", "查看能力目录", "只读查看项目自有能力目录。"),
)
# 角色目录的每项依次保存角色编码、名称、说明以及该角色应包含的能力编码元组。
ROLE_CATALOG = (
(
"platform_admin",
"平台管理员",
"拥有本项目全部管理能力。",
(
"accounts.dashboard.view",
"accounts.user.view",
"accounts.user.create",
"accounts.user.change",
"accounts.user.delete",
"accounts.user.status",
"accounts.user.assign_role",
"accounts.department.view",
"accounts.department.manage",
"accounts.role.view",
"accounts.role.manage",
"accounts.capability.view",
),
),
(
"user_manager",
"用户管理员",
"负责用户资料、状态、角色分配及相关只读查询。",
(
"accounts.dashboard.view",
"accounts.user.view",
"accounts.user.create",
"accounts.user.change",
"accounts.user.delete",
"accounts.user.status",
"accounts.user.assign_role",
"accounts.department.view",
"accounts.role.view",
"accounts.capability.view",
),
),
(
"permission_manager",
"权限管理员",
"负责角色能力配置和用户角色分配。",
(
"accounts.dashboard.view",
"accounts.user.view",
"accounts.user.assign_role",
"accounts.department.view",
"accounts.role.view",
"accounts.role.manage",
"accounts.capability.view",
),
),
(
"readonly_viewer",
"只读查看员",
"只能查看管理数据和能力目录。",
(
"accounts.dashboard.view",
"accounts.user.view",
"accounts.department.view",
"accounts.role.view",
"accounts.capability.view",
),
),
)
def create_catalog_and_migrate_legacy_groups(apps, schema_editor):
# 输入:RunPython 固定传入历史应用注册表 apps 与 schema_editor;输出:迁移成功后自然返回 None;调用者:Django 迁移执行器;失败:历史模型查询、约束或数据库写入失败时抛出对应异常并让迁移事务回滚。
# RunPython 固定传入 apps 历史应用注册表和 schema_editor;本函数不直接使用后者,但签名必须保留。
# apps.get_model() 返回迁移执行到此节点时的历史模型,只含当时字段/管理器;不能依赖当前 models.py 的自定义方法。
Capability = apps.get_model("accounts", "Capability")
Role = apps.get_model("accounts", "Role")
RoleCapability = apps.get_model("accounts", "RoleCapability")
UserRole = apps.get_model("accounts", "UserRole")
Group = apps.get_model("auth", "Group")
Permission = apps.get_model("auth", "Permission")
# for 会把每个三元素元组解包到 key/name/description;update_or_create 以 key 查找,defaults 精确更新其余字段。
capability_map = {}
for key, name, description in CAPABILITY_CATALOG:
# 返回值是“对象、是否新建”二元组;_ 是 Python 对不使用值的惯用变量名。
capability, _ = Capability.objects.update_or_create(
key=key,
defaults={
"name": name,
"description": description,
"is_system": True,
"is_active": True,
},
)
capability_map[key] = capability
# 保存阶段:逐项创建或更新系统角色,再补齐该角色声明的每一条能力关联。
role_map = {}
for key, name, description, capability_keys in ROLE_CATALOG:
role, _ = Role.objects.update_or_create(
key=key,
defaults={
"name": name,
"description": description,
"is_system": True,
"is_active": True,
},
)
role_map[key] = role
for capability_key in capability_keys:
RoleCapability.objects.get_or_create(
role=role,
capability=capability_map[capability_key],
)
# 数据库读取与业务判断:按旧 Group 中文名称查找历史成员;不存在的旧组直接跳过。
for legacy_name, role_key in LEGACY_ROLE_MAP.items():
group = Group.objects.filter(name=legacy_name).first()
if group is None:
continue
role = role_map[role_key]
# 保存阶段:把旧组中的每个用户幂等映射到对应的新项目角色,且迁移数据没有操作人。
for user in group.user_set.all():
UserRole.objects.get_or_create(
user_id=user.pk,
role_id=role.pk,
defaults={"assigned_by_id": None},
)
# 数据库读取与业务判断:只选中旧版 accounts.User 上已经废弃的两项 Django 权限。
obsolete_permissions = Permission.objects.filter(
content_type__app_label="accounts",
content_type__model="user",
codename__in=("set_user_status", "assign_user_roles"),
)
# 保存阶段:删除旧权限,避免新旧两套授权入口并存。
obsolete_permissions.delete()
# 返回:全部迁移步骤完成后自然返回 None。
def noop(apps, schema_editor):
# 输入:Django 传入的历史应用注册表与迁移编辑器;输出:None;调用者:RunPython 反向迁移;失败:本函数不读写数据库,正常情况下不会主动失败。
# 业务判断:回滚结构版本不等于删除已经成为业务数据的角色、能力和用户角色。
# 返回:明确返回 None,表示反向迁移无需执行数据修改。
return None
# Migration 继承 migrations.Migration;Django 按该类的依赖和操作把数据迁移插入图中。
class Migration(migrations.Migration):
# 先有 0002 创建的 RBAC 表,数据迁移才能取得这些历史模型。
dependencies = [
("accounts", "0002_custom_rbac"),
]
# operations 按顺序执行;RunPython 第一参是正向函数,第二参是回滚时调用的反向函数。
operations = [
migrations.RunPython(create_catalog_and_migrate_legacy_groups, noop),
]
4.1 数据迁移函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
5. PolicySnapshot:一个请求内的一致授权视图
5.1 输入、捕获状态、查询和结果
构造快照时输入是一个用户对象。快照立即捕获 is_authenticated、is_active、is_superuser 和不可变的 capability_keys。匿名或停用用户直接得到空集合,不访问授权表;普通激活用户执行一条带 DISTINCT 的关联查询;激活超级用户读取当前激活能力目录用于展示和诊断。
超级用户的 has_capability() 对任意 key 返回真,包括暂未登记的 key。这是明确的 break-glass 行为,目的是让激活超级用户在目录漂移或紧急修复时仍能进入内部路径;但 capability_keys 仍只包含激活目录,不能把它误解为“超级用户只能拥有这些 key”。停用超级用户先在 is_active 分支失败,因此没有旁路。
5.2 request、user、snapshot 三种输入
- 传入
request:快照保存在该 request 的私有属性上,本请求后续判断复用同一个对象。 - 传入
user:每次创建新快照,适合事务锁后复检。 - 传入已有
PolicySnapshot:原样返回,便于服务层显式复用。
缓存绝不放进模块全局变量、进程缓存、Session 或数据库。一次请求中的授权变更不会反向改写旧快照;敏感写入必须在锁后传入新读取的用户对象,重新构造快照。这正是“请求页面的一致读取”和“提交时读取最新授权”之间的边界。
完整文件:accounts/policy.py
# accounts/policy.py
# PermissionDenied 是 Django 识别的授权异常;未捕获时由框架生成 403 Forbidden 响应。
from django.core.exceptions import PermissionDenied
# 相对导入能力模型和用户角色中间模型,策略只沿项目自有 RBAC 关系查询。
from .models import Capability, UserRole
_REQUEST_SNAPSHOT_ATTRIBUTE = "_accounts_policy_snapshot"
# 普通 Python 类不继承 Django 模型;它把一次授权判断需要的数据封装成内存快照。
class PolicySnapshot:
# __init__ 是实例构造后自动调用的初始化方法;self 表示正在初始化的对象。
def __init__(self, user):
# 输入:用户对象、匿名用户或 None;输出:初始化当前快照实例;调用者:policy_snapshot() 或显式实例化;失败:读取用户属性或查询能力数据库失败时上抛对应异常。
# bool(...) 强制得到 True/False;and 从左到右短路,前项为假时不会访问后项。
# getattr(user, "is_authenticated", False) 安全读取属性,缺失时使用 False,避免匿名/None 输入报错。
self.user = user
self.is_authenticated = bool(
user is not None and getattr(user, "is_authenticated", False)
)
self.is_active = bool(self.is_authenticated and user.is_active)
self.is_superuser = bool(self.is_active and user.is_superuser)
# 数据库读取:初始化阶段只调用一次能力加载方法,模板中的重复判断复用结果。
self.capability_keys = self._load_capability_keys()
# 方法名前单下划线表示“内部使用”的命名约定,Python 不会强制私有。
def _load_capability_keys(self):
# 输入:当前快照 self 中已经计算好的用户状态;输出:当前请求可复用的能力编码 frozenset;调用者:PolicySnapshot.__init__();失败:能力或角色关系查询失败时上抛数据库异常。
# frozenset() 创建不可修改且支持快速 in 判断的集合;空调用表示没有能力。
# 业务判断:未激活用户不进入数据库授权查询,也不会获得任何能力。
if not self.is_active:
return frozenset()
if self.is_superuser:
# 数据库读取与返回:超级用户绕过业务角色,但快照目录仍只载入当前启用能力。
return frozenset(
Capability.objects.filter(is_active=True).values_list("key", flat=True)
)
# __ 分隔跨关系字段路径;values_list(..., flat=True) 只取一列标量;distinct() 让数据库去重。
# QuerySet 到 frozenset 构造时才求值,并把结果冻结,便于快速成员判断。
return frozenset(
UserRole.objects.filter(
user_id=self.user.pk,
role__is_active=True,
role__role_capabilities__capability__is_active=True,
)
.values_list("role__role_capabilities__capability__key", flat=True)
.distinct()
)
def has_capability(self, capability_key):
# 输入:一个能力编码字符串;输出:是否允许的布尔值;调用者:策略辅助函数、装饰器或模板标签;失败:本方法只读快照,通常不主动抛错。
# 业务判断与返回:未启用用户始终拒绝,激活超级用户始终放行,普通用户检查集合成员关系。
if not self.is_active:
return False
if self.is_superuser:
return True
return capability_key in self.capability_keys
def has_any_capability(self, capability_keys):
# 输入:可迭代的能力编码;输出:至少命中一项时为 True;调用者:require_any_capability() 或业务代码;失败:参数不可迭代时抛出 TypeError。
# 业务判断:先固化为元组,生成器也只消费一次,再逐项复用单能力判断。
keys = tuple(capability_keys)
# 返回:any() 遇到首个 True 即停止,空集合返回 False。
return any(self.has_capability(key) for key in keys)
def has_all_capabilities(self, capability_keys):
# 输入:可迭代的能力编码;输出:全部命中且输入非空时为 True;调用者:require_all_capabilities() 或业务代码;失败:参数不可迭代时抛出 TypeError。
keys = tuple(capability_keys)
# 业务判断与返回:空集合不代表业务授权,避免配置错误时意外放行。
if not keys:
return False
# 返回:all() 遇到首个 False 即停止。
return all(self.has_capability(key) for key in keys)
def policy_snapshot(subject):
# 输入:PolicySnapshot、request 或用户对象;输出:可复用的 PolicySnapshot;调用者:所有 has/require 策略函数;失败:主题缺少所需用户属性或数据库读取失败时上抛对应异常。
# isinstance() 判断对象是否为该类或其子类实例;已是快照就直接复用。
if isinstance(subject, PolicySnapshot):
return subject
# hasattr() 只问属性是否存在;getattr(..., None) 带默认值读取缓存;setattr() 按字符串属性名写入 request。
if hasattr(subject, "user"):
snapshot = getattr(subject, _REQUEST_SNAPSHOT_ATTRIBUTE, None)
if snapshot is None:
# 数据库读取:首次判断时创建快照并加载能力集合。
snapshot = PolicySnapshot(subject.user)
# 保存:这里只把快照写入 request 的临时属性,不写数据库。
setattr(subject, _REQUEST_SNAPSHOT_ATTRIBUTE, snapshot)
# 返回:同一 request 后续调用拿到同一个快照对象。
return snapshot
# 返回:普通用户对象或 None 每次构造一个独立快照。
return PolicySnapshot(subject)
def has_capability(subject, capability_key):
# 输入:request/用户/快照与单个能力编码;输出:布尔值;调用者:装饰器、模板标签和业务代码;失败:快照构造或数据库读取失败时上抛对应异常。
return policy_snapshot(subject).has_capability(capability_key)
def has_any_capability(subject, capability_keys):
# 输入:request/用户/快照与能力编码可迭代对象;输出:是否至少拥有一项;调用者:授权守卫或业务代码;失败:参数不可迭代或快照加载失败时上抛对应异常。
return policy_snapshot(subject).has_any_capability(capability_keys)
def has_all_capabilities(subject, capability_keys):
# 输入:request/用户/快照与能力编码可迭代对象;输出:是否拥有全部且集合非空;调用者:授权守卫或业务代码;失败:参数不可迭代或快照加载失败时上抛对应异常。
return policy_snapshot(subject).has_all_capabilities(capability_keys)
def require_capability(subject, capability_key):
# 输入:request/用户/快照与必需能力编码;输出:通过时自然返回 None;调用者:视图装饰器或事务内二次校验;失败:缺少能力时抛出 PermissionDenied。
# 业务判断:复用布尔策略结果,拒绝时统一生成包含能力编码的 403 异常。
if not has_capability(subject, capability_key):
raise PermissionDenied("缺少能力:%s。" % capability_key)
# 返回:具备能力时自然返回 None。
def require_any_capability(subject, capability_keys):
# 输入:request/用户/快照与候选能力编码;输出:至少命中一项时自然返回 None;调用者:组合授权装饰器或业务代码;失败:全部缺失时抛出 PermissionDenied。
# 业务判断:先固化输入,确保检查与错误消息使用完全相同的一组编码。
keys = tuple(capability_keys)
if not has_any_capability(subject, keys):
raise PermissionDenied("至少需要以下一项能力:%s。" % "、".join(keys))
# 返回:至少具备一项能力时自然返回 None。
def require_all_capabilities(subject, capability_keys):
# 输入:request/用户/快照与必需能力编码;输出:全部命中时自然返回 None;调用者:组合授权装饰器或业务代码;失败:缺少任一项或输入为空时抛出 PermissionDenied。
# 业务判断:先固化输入,确保检查与错误消息使用完全相同的一组编码。
keys = tuple(capability_keys)
if not has_all_capabilities(subject, keys):
raise PermissionDenied("需要具备以下全部能力:%s。" % "、".join(keys))
# 返回:具备全部能力时自然返回 None。
5.3 Policy 函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
5.4 单项、任一、全部与空集合
| 调用 | 空输入 | 缺能力 | 成功 |
|---|---|---|---|
has_capability | 不适用 | False | True |
has_any_capability | False | False | 至少一项为真 |
has_all_capabilities | False | False | 每一项都为真 |
require_* | 对应规则失败并抛出 403 异常 | 抛出 PermissionDenied | 返回 None |
数学上“空集合的全部元素满足条件”可以被视为真,但授权配置中的空列表往往来自漏填常量或拼装错误。这里选择 fail closed,避免本应要求多项能力的视图因配置为空而放行。
6. 装饰器与模板标签
6.1 为什么匿名是 302、已登录越权是 403
自定义装饰器最外层使用 login_required。匿名请求先被 Django 重定向到登录页,状态码是 302,并带 next;已经登录的用户继续进入 require_*,缺少能力时抛出 PermissionDenied,由 403.html 渲染 403。两者不能混成同一种响应:未登录者需要完成认证,已登录者则不应该通过反复登录绕过授权。
完整文件:accounts/decorators.py
# accounts/decorators.py
# wraps 复制被包装函数的名称、文档等元数据,便于 Django 调试和工具识别原视图。
from functools import wraps
# login_required 是 Django 登录守卫:匿名请求重定向到登录页,并保留 next 参数。
from django.contrib.auth.decorators import login_required
# 相对导入三种“缺权即抛 PermissionDenied”的策略守卫。
from .policy import (
require_all_capabilities,
require_any_capability,
require_capability,
)
def capability_required(capability_key):
# 输入:视图要求的单个能力编码;输出:可应用到视图函数的 decorator;调用者:使用 @capability_required(...) 的 Django 视图;失败:编码本身在装饰阶段不查数据库,请求阶段的授权或原视图异常继续上抛。
# 这是装饰器工厂:外层调用先把 capability_key 捕获在闭包中,再返回真正接收视图的 decorator。
def decorator(view_func):
# 输入:待保护的视图函数;输出:增加登录与能力检查的 wrapped 函数;调用者:Python 装饰器机制;失败:这里只创建闭包,请求执行期异常由 wrapped 继续上抛。
# @decorator 等价于 view = decorator(view);这里再叠加两个装饰器,离函数最近的 @wraps 先应用。
# @wraps(view_func) 保留原视图元数据,@login_required 位于外层,所以匿名请求先被重定向而不查询能力。
@login_required
@wraps(view_func)
def wrapped(request, *args, **kwargs):
# 输入:Django request 及原视图的额外参数;输出:授权通过后的原视图响应;调用者:URL 分发器;失败:匿名用户被 login_required 重定向,缺能力时抛 PermissionDenied,原视图异常继续上抛。
# *args 把额外位置参数收成元组,**kwargs 把 pk 等命名参数收成字典,转发时再分别展开。
require_capability(request, capability_key)
# 返回:授权通过后原样调用视图并返回其 HttpResponse。
return view_func(request, *args, **kwargs)
# 返回:把完成登录与能力保护的函数交还装饰器机制。
return wrapped
# 返回:外层工厂把绑定 capability_key 的装饰器交给视图定义。
return decorator
def any_capability_required(capability_keys):
# 输入:候选能力编码可迭代对象;输出:视图装饰器函数;调用者:需要“任一能力”的 Django 视图;失败:参数不可迭代时在 tuple() 处抛出 TypeError。
# 业务判断:装饰时固化能力集合,避免生成器在多次请求之间被耗尽。
keys = tuple(capability_keys)
def decorator(view_func):
# 输入:待保护的视图函数;输出:保留原视图元数据的包装函数;调用者:Python 装饰器机制;失败:装饰阶段通常不主动失败。
@login_required
@wraps(view_func)
def wrapped(request, *args, **kwargs):
# 输入:Django request 及原视图参数;输出:原视图响应;调用者:URL 分发器;失败:未登录时跳转,全部能力缺失时抛出 PermissionDenied,原视图异常继续上抛。
# 业务判断:请求用户拥有候选集合中的任一能力才可进入原视图。
require_any_capability(request, keys)
# 返回:授权通过后原样返回原视图响应。
return view_func(request, *args, **kwargs)
# 返回:完成组合授权保护的包装函数。
return wrapped
# 返回:绑定不可变 keys 的装饰器。
return decorator
def all_capabilities_required(capability_keys):
# 输入:必需能力编码可迭代对象;输出:视图装饰器函数;调用者:需要“全部能力”的 Django 视图;失败:参数不可迭代时在 tuple() 处抛出 TypeError。
# 业务判断:装饰时固化能力集合,保证每次请求检查同一组编码。
keys = tuple(capability_keys)
def decorator(view_func):
# 输入:待保护的视图函数;输出:保留原视图元数据的包装函数;调用者:Python 装饰器机制;失败:装饰阶段通常不主动失败。
@login_required
@wraps(view_func)
def wrapped(request, *args, **kwargs):
# 输入:Django request 及原视图参数;输出:原视图响应;调用者:URL 分发器;失败:未登录时跳转,缺少任一能力或集合为空时抛出 PermissionDenied,原视图异常继续上抛。
# 业务判断:请求用户必须具备集合中的全部能力才能进入原视图。
require_all_capabilities(request, keys)
# 返回:授权通过后原样返回原视图响应。
return view_func(request, *args, **kwargs)
# 返回:完成组合授权保护的包装函数。
return wrapped
# 返回:绑定不可变 keys 的装饰器。
return decorator
6.2 装饰器函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
6.3 policy_can 使用普通 request 上下文
标签声明 takes_context=True,优先从普通模板上下文取得 request,于是同一页面十几个按钮仍复用一次请求快照;只有没有 request 时才退回 user。没有自定义 RBAC context processor,也不需要把能力集合手工塞进每个视图的 context。
完整文件:accounts/templatetags/policy_tags.py
# accounts/templatetags/policy_tags.py
# django.template 提供模板标签注册表 Library;Django 会发现 templatetags 包中的本模块。
from django import template
# 绝对导入项目策略函数,模板标签只负责把模板参数转交给统一授权层。
from accounts.policy import has_capability
# 每个模板标签模块都要创建注册表;模块被加载后,装饰器会把下面的函数登记进去。
register = template.Library()
# @ 是 Python 装饰器语法;simple_tag 会解析参数、处理模板原生的“as 变量名”语法,
# 并在使用 as 时把函数返回值本身存入变量,因此函数签名不应自定义 asvar 参数。
# takes_context=True 让 Django 把当前模板 Context 作为第一个参数传入。
@register.simple_tag(takes_context=True)
def policy_can(context, capability_key):
# 输入:模板上下文和能力编码;输出:授权布尔值;调用者:{% policy_can key as can_x %};失败:策略查询异常继续上抛,缺少 request/user 时按匿名用户拒绝。
# context.get() 在键不存在时返回 None;优先传 request 可复用同一请求上的策略快照。
request = context.get("request")
if request is not None:
return has_capability(request, capability_key)
# 返回值始终是 bool;是否保存到模板变量由 simple_tag 自己处理。
return has_capability(context.get("user"), capability_key)
6.4 模板标签函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
模板隐藏按钮不是后端安全边界。攻击者可以手写 POST、修改 DOM、重放旧表单或直接访问 URL。标签只负责不向用户展示无效操作;真正的授权由装饰器、事务内 require_capability()、对象保护和模型约束共同完成。
7. 表单:委派只能向下,POST 必须重新验证
7.1 两种子集检查
UserAccessForm 判断“候选角色的完整能力集合是否为操作人有效能力的子集”;RoleForm 判断“提交的激活能力是否都在操作人的可选能力中”。两处都在字段 queryset 之外再次计算 ID 集合,因而修改 HTML、伪造 POST 或直接提交不可见主键都不能越过边界。
role_capability_keys() 故意不加 capability__is_active=True。停用能力今天不会让角色生效,但它仍是角色的潜在范围;如果忽略它,有限管理员可以接管这个角色,等能力重新启用后突然获得超出委派范围的控制。角色只要含有操作人不具备的停用能力,普通操作人就不能分配或编辑它。
表单故意直接构造新的 PolicySnapshot(operator),而不是复用 request 快照。第一次表单用于快速反馈;事务内第二个表单拿到的是等待锁之后重新读取的 operator,它必须查询当前角色关系,才能发现等待期间发生的授权撤销或角色扩张。
完整文件:accounts/forms.py
# accounts/forms.py
# forms 命名空间提供 Form、ModelForm、字段、控件和 ValidationError。
from django import forms
# AuthenticationForm 实现登录认证校验;UserCreationForm 实现两次密码匹配、密码验证及安全哈希保存。
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
# 相对导入系统保留角色编码和名称,供自定义角色表单拒绝占用。
from .capabilities import ROLE_KEYS, ROLE_NAMES
# 导入本文件生成字段、查询选项和写入中间表所需的 ORM 模型。
from .models import Capability, Department, Role, RoleCapability, User, UserRole
# PolicySnapshot 一次加载操作人的能力集合,供委派范围判断复用。
from .policy import PolicySnapshot
def operator_capability_queryset(operator):
# 输入:当前操作用户 operator;输出:其可委派的启用 Capability QuerySet;调用者:RoleForm 初始化和能力提交复检;失败:用户属性异常或数据库查询失败时上抛对应异常。
# QuerySet 是可继续 filter()/order_by() 的惰性查询描述,通常到迭代、list()、set() 等求值时才真正访问数据库。
# filter 使用字段查找语法,order_by("key") 生成稳定升序;这里先构造全部启用能力范围。
capabilities = Capability.objects.filter(is_active=True).order_by("key")
# 业务判断与返回:激活超级用户可管理全部启用能力。
if operator.is_active and operator.is_superuser:
return capabilities
# 数据库读取:普通用户从策略快照取得自己实际拥有的能力编码。
capability_keys = PolicySnapshot(operator).capability_keys
# 返回:只保留操作人能力集合中的数据库记录。
return capabilities.filter(key__in=capability_keys)
def role_capability_keys(role):
# 输入:一个 Role 实例;输出:该角色全部关联能力编码的 set;调用者:角色可委派范围判断;失败:关联查询失败时上抛数据库异常。
# 数据库读取:停用能力也属于角色潜在授权范围,不能借停用状态绕过委派边界。
# 返回:使用 set 便于后续执行子集比较。
return set(
RoleCapability.objects.filter(role=role).values_list(
"capability__key", flat=True
)
)
# 兼容教程前文的命名;安全语义已经包含全部关联能力。
active_role_capability_keys = role_capability_keys
def assignable_role_queryset(operator):
# 输入:当前操作用户;输出:该用户可以分配给他人的启用角色 QuerySet;调用者:UserAccessForm 初始化与清理;失败:角色或能力关联查询失败时上抛数据库异常。
# 数据库读取:先取得所有启用角色,并保持名称、编码的稳定排序。
roles = Role.objects.filter(is_active=True).order_by("name", "key")
# 业务判断与返回:激活超级用户可以分配全部启用角色。
if operator.is_active and operator.is_superuser:
return roles
# 数据库读取:普通用户只允许向下授予自己能力集合的子集。
operator_keys = PolicySnapshot(operator).capability_keys
role_ids = []
# 业务判断:逐个角色读取完整能力集合,并执行集合包含关系比较。
for role in roles:
if role_capability_keys(role) <= operator_keys:
role_ids.append(role.pk)
# 返回:用通过检查的主键重新构造可继续链式查询的 QuerySet。
return Role.objects.filter(pk__in=role_ids).order_by("name", "key")
def role_capabilities_within_limit(operator, role):
# 输入:操作用户与待管理角色;输出:角色能力是否不超出操作人范围的布尔值;调用者:角色视图的越权守卫;失败:能力关联查询失败时上抛数据库异常。
# 业务判断与返回:激活超级用户不受委派子集限制。
if operator.is_active and operator.is_superuser:
return True
# 数据库读取、业务判断与返回:比较角色完整能力集合是否为操作人能力集合的子集。
return role_capability_keys(role) <= PolicySnapshot(operator).capability_keys
# LoginForm 继承 AuthenticationForm,只改字段展示;父类仍负责 authenticate()、停用账号检查和 get_user()。
class LoginForm(AuthenticationForm):
# label 是表单标签;TextInput 是 HTML 文本框;attrs 字典生成 autofocus/autocomplete 属性;CharField 默认 required=True。
username = forms.CharField(
label="用户名",
# widget 指定 TextInput 文本控件;attrs 字典把 autofocus 和 autocomplete 写成 HTML 属性,不改变后端校验规则。
widget=forms.TextInput(attrs={"autofocus": True, "autocomplete": "username"}),
)
# strip=False 禁止 CharField 自动去除密码两端空白;PasswordInput 生成 type="password";autocomplete 提示浏览器填写当前密码。
password = forms.CharField(
label="密码",
strip=False,
# widget 指定 PasswordInput 密码控件,它只遮蔽浏览器显示,不负责加密或保存密码。
widget=forms.PasswordInput(
# attrs 把 autocomplete 写入 HTML,current-password 提示浏览器选择已有密码。
attrs={"autocomplete": "current-password"}
),
)
# UserCreateForm 继承 UserCreationForm,自动提供 password1/password2、密码一致性校验和哈希保存,而非保存明文。
class UserCreateForm(UserCreationForm):
# 继续继承父类 Meta,保留其用户创建配置,再替换模型和可编辑字段列表。
class Meta(UserCreationForm.Meta):
# model 指定 ModelForm 要创建本项目自定义 User,而不是 Django 默认用户。
model = User
# fields 控制页面展示和保存的模型字段;父类声明的 password1/password2 是非模型字段,仍由 UserCreationForm 提供。
fields = (
"username",
"display_name",
"employee_number",
"email",
"mobile",
"department",
"is_active",
)
# UserUpdateForm 继承 ModelForm:根据 User 字段生成控件、运行字段/模型校验,并由 save() 更新传入 instance。
class UserUpdateForm(forms.ModelForm):
# Meta 是 ModelForm 读取的配置类。
class Meta:
# model 指定绑定 User。
model = User
# fields 是允许普通编辑流程提交的白名单,不含密码、超级用户和员工权限标记。
fields = (
"username",
"display_name",
"employee_number",
"email",
"mobile",
"department",
)
# UserAccessForm 继承普通 Form:它不自动对应单个模型,角色替换逻辑由自定义 save() 明确实现。
class UserAccessForm(forms.Form):
# ModelMultipleChoiceField 把提交的多个主键校验并转换为 Role QuerySet;label 是标签;none() 初始时不允许任何选项。
# required=False 允许空集合;CheckboxSelectMultiple 类作为 widget 生成一组复选框,真实 queryset 在 __init__ 中按操作人替换。
roles = forms.ModelMultipleChoiceField(
label="角色",
queryset=Role.objects.none(),
required=False,
widget=forms.CheckboxSelectMultiple,
)
def __init__(self, *args, operator, instance=None, **kwargs):
# 输入:标准 Form 参数、必填关键字 operator 和可选目标用户 instance;输出:完成当前表单实例初始化并自然返回 None;调用者:用户角色分配视图;失败:参数、角色查询或父类初始化失败时上抛对应异常。
# *args 收集位置参数,单独写在其后的 operator 是必填“仅限关键字”参数,instance 默认 None,**kwargs 收集其余命名参数。
# self 保存当前表单状态;先记录自定义参数,再用 super() 调用 Form.__init__,否则 self.fields/is_bound/initial 尚不存在。
self.operator = operator
self.instance = instance
super().__init__(*args, **kwargs)
# QuerySet 在迭代前通常是惰性的;把它赋给字段既控制显示选项,也让字段拒绝不在集合内的伪造主键。
self.fields["roles"].queryset = assignable_role_queryset(operator)
# is_bound 表示是否传入 data;仅 GET 的未绑定表单才设置 initial,避免覆盖 POST;pk 表示目标已保存。
if not self.is_bound and instance is not None and instance.pk:
self.initial["roles"] = list(
UserRole.objects.filter(user=instance).values_list("role_id", flat=True)
)
# __init__ 按 Python 约定不返回表单对象,而是自然返回 None。
def clean_roles(self):
# 输入:self.cleaned_data 中已经转换的 roles QuerySet;输出:通过委派边界检查的同一 QuerySet;调用者:Form.is_valid() 的字段清理流程;失败:提交越权角色时抛出 forms.ValidationError,数据库异常继续上抛。
# clean_<字段名>() 在基础字段校验成功后自动调用;cleaned_data 保存已转换的安全值,而不是原始 POST 字符串。
roles = self.cleaned_data["roles"]
allowed_role_ids = set(
assignable_role_queryset(self.operator).values_list("pk", flat=True)
)
submitted_role_ids = set(roles.values_list("pk", flat=True))
# 业务判断:提交集合必须是允许集合的子集。
if not submitted_role_ids <= allowed_role_ids:
raise forms.ValidationError("不能分配能力范围高于自己的角色。")
# 返回:验证通过后把角色 QuerySet 交回表单清理流程。
return roles
def save(self):
# 输入:已通过 is_valid() 且带已保存目标用户的当前表单;输出:角色已完全替换的 User;调用者:事务内的 user_access 视图;失败:目标未保存时抛 ValueError,关联写入失败时上抛数据库异常。
# 普通 Form 没有自动模型 save() 语义,这是本项目自定义方法;调用前必须先 is_valid(),否则 cleaned_data 不可靠。
# 本方法“先删后建”包含多条 SQL,本身不创建 transaction.atomic();调用者必须在原子事务中调用,视图已这样做。
# 业务判断:只有已有主键的用户才能成为 UserRole 外键目标。
if self.instance is None or not self.instance.pk:
raise ValueError("保存用户角色前必须提供已保存的用户。")
# 保存准备:把清理后的 QuerySet 固化为列表,供批量创建逐项使用。
roles = list(self.cleaned_data["roles"])
# 保存阶段:先删除再创建,明确表达“本次提交完全替换旧角色”。
UserRole.objects.filter(user=self.instance).delete()
UserRole.objects.bulk_create(
[
UserRole(
user=self.instance,
role=role,
assigned_by=self.operator,
)
for role in roles
]
)
# 返回:关联替换完成后返回目标用户,便于调用者继续使用。
return self.instance
# DepartmentForm 继承 ModelForm,自动按 Department 字段生成控件并在 is_valid() 中调用模型校验。
class DepartmentForm(forms.ModelForm):
# Meta 配置模型与允许编辑的字段白名单。
class Meta:
# model 指定保存目标。
model = Department
# fields 的顺序也决定默认表单展示顺序。
fields = ("code", "name", "parent", "is_active", "sort_order")
def __init__(self, *args, **kwargs):
# 输入:data、files、instance、initial 等标准 ModelForm 参数;输出:完成父部门选项收窄的表单并自然返回 None;调用者:部门创建/编辑视图;失败:父类初始化或部门查询失败时上抛对应异常。
# *args/**kwargs 原样接收 data、files、instance、initial 等 ModelForm 参数;super() 先完成字段构建。
super().__init__(*args, **kwargs)
# 数据库读取:父部门选项按业务排序加载全部部门。
queryset = Department.objects.order_by("sort_order", "code")
# 业务判断:编辑已有部门时排除自身,先阻止最直接的自引用选择。
if self.instance.pk:
queryset = queryset.exclude(pk=self.instance.pk)
# 保存准备:这里只设置表单字段查询集,不写数据库。
self.fields["parent"].queryset = queryset
# 返回:初始化方法自然返回 None。
# RoleForm 继承 ModelForm,角色主体由父类保存,显式 through 中间表由自定义 save() 维护。
class RoleForm(forms.ModelForm):
# ModelMultipleChoiceField 把多个主键转成 Capability QuerySet;none() 是安全的类定义期占位查询集。
# label 改显示文字;required=False 允许空角色;CheckboxSelectMultiple 类生成复选框组。
capabilities = forms.ModelMultipleChoiceField(
label="包含的能力",
queryset=Capability.objects.none(),
required=False,
# widget 指定 CheckboxSelectMultiple,把多个可选能力渲染成复选框组,不改变 queryset 的安全边界。
widget=forms.CheckboxSelectMultiple,
)
# Meta 告诉 ModelForm 如何从 Role 构造其余字段。
class Meta:
# model 指定角色模型。
model = Role
# fields 同时包含模型字段和上面显式声明的非自动中间表字段 capabilities,并决定顺序。
fields = ("key", "name", "description", "is_active", "capabilities")
# labels 字典按字段名覆盖默认标签;未列出的 capabilities 使用字段声明中的 label。
labels = {
"key": "角色编码",
"name": "角色名称",
"description": "说明",
"is_active": "启用",
}
def __init__(self, *args, operator, **kwargs):
# 输入:标准 ModelForm 参数和必填关键字 operator;输出:完成能力选项与系统角色只读状态初始化并自然返回 None;调用者:角色创建/编辑视图;失败:父类初始化或授权查询失败时上抛对应异常。
# operator 位于 *args 后,是调用者必须按名称传入的仅限关键字参数;其余参数传给 ModelForm。
self.operator = operator
super().__init__(*args, **kwargs)
# 数据库读取:能力选项只包含操作人有权委派的启用能力。
self.fields["capabilities"].queryset = operator_capability_queryset(operator)
# 业务判断与数据库读取:编辑已有角色时加载当前启用能力作为初始勾选。
if self.instance.pk:
self.initial["capabilities"] = list(
RoleCapability.objects.filter(
role=self.instance,
capability__is_active=True,
).values_list("capability_id", flat=True)
)
# 业务判断:系统角色的稳定编码不能被网页表单改名。
if self.instance.pk and self.instance.is_system:
self.fields["key"].disabled = True
# 返回:初始化方法自然返回 None。
def clean_key(self):
# 输入:基础字段校验后的角色编码;输出:去除首尾空白并转小写的编码;调用者:Form.is_valid();失败:新角色占用系统保留编码时抛出 ValidationError。
# 保存准备:先把编码规范为模型和目录共同使用的稳定形式。
key = self.cleaned_data["key"].strip().lower()
# 业务判断:已有系统角色保留自己的编码是唯一允许使用保留值的情况。
is_existing_system_key = (
self.instance.pk
and self.instance.is_system
and self.instance.key == key
)
if key in ROLE_KEYS and not is_existing_system_key:
raise forms.ValidationError("该角色编码由系统内置角色保留。")
# 返回:把规范化后的编码交回表单,并最终用于模型保存。
return key
def clean_name(self):
# 输入:基础字段校验后的角色名称;输出:去除首尾空白的名称;调用者:Form.is_valid();失败:新角色占用系统保留名称时抛出 ValidationError。
# 保存准备:先去除无意义首尾空白,避免视觉相同但内容不同的名称。
name = self.cleaned_data["name"].strip()
# 业务判断:已有系统角色保留自己的名称是唯一允许使用保留值的情况。
is_existing_system_name = (
self.instance.pk
and self.instance.is_system
and self.instance.name == name
)
if name in ROLE_NAMES and not is_existing_system_name:
raise forms.ValidationError("该角色名称由系统内置角色保留。")
# 返回:把规范化后的名称交回表单,并最终用于模型保存。
return name
def clean_capabilities(self):
# 输入:基础字段转换后的能力 QuerySet;输出:通过委派边界检查的同一 QuerySet;调用者:Form.is_valid();失败:提交自身不具备的能力时抛出 ValidationError。
capabilities = self.cleaned_data["capabilities"]
# 数据库读取:重新计算操作人当前可用能力主键,不能依赖浏览器显示的复选框。
allowed_ids = set(
operator_capability_queryset(self.operator).values_list("pk", flat=True)
)
submitted_ids = set(capabilities.values_list("pk", flat=True))
# 业务判断:提交集合必须是操作人允许集合的子集。
if not submitted_ids <= allowed_ids:
raise forms.ValidationError("不能把自己不具备的能力加入角色。")
# 返回:校验通过后把能力 QuerySet 交回表单清理流程。
return capabilities
def save(self, commit=True):
# 输入:已通过 is_valid() 的表单和是否立即保存主体的 commit;输出:保存或待保存的 Role;调用者:角色创建/编辑视图或标准 ModelForm 调用方;失败:模型校验、约束或中间表写入失败时上抛对应异常。
# ModelForm.save(commit=True) 写角色主体;commit=False 只返回尚待调用者保存的实例。调用前必须 is_valid(),才能读取 cleaned_data。
# 角色保存与中间表“先删后建”是多条 SQL;本方法不自行开启事务,当前写视图用 transaction.atomic() 包住它们。
role = super().save(commit=commit)
# 业务判断:commit=False 时角色可能没有主键,不能写 through 表;调用者日后保存主体后还需自行处理这些自定义关联。
if not commit:
return role
# 保存准备:固化清理后的能力列表,随后完全替换中间表关联。
capabilities = list(self.cleaned_data["capabilities"])
# 保存阶段:显式维护中间模型,才能记录分配人并保持写入路径清晰。
RoleCapability.objects.filter(role=role).delete()
RoleCapability.objects.bulk_create(
[
RoleCapability(
role=role,
capability=capability,
assigned_by=self.operator,
)
for capability in capabilities
]
)
# 返回:角色与能力关联保存完成后返回角色实例。
return role
7.2 表单函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
8. 管理视图:快速拒绝、稳定加锁、锁后复检
8.1 为什么要验证两次
装饰器和第一个表单在事务外执行,目标是快速拒绝、提供正常的页面错误,并避免把数据库锁持有在用户输入校验期间。但它们不能证明提交时权限仍然存在。请求可能等待另一个事务;等待期间,操作人的角色可能被撤销、候选角色可能增加高权限能力、目标用户可能获得一个操作人无权管理的角色。
因此每个敏感 POST 在 transaction.atomic() 中重复以下动作:按固定顺序锁授权目录;重新读取并锁住操作人/目标用户;锁住能力来源 UserRole;用锁后的操作人运行 require_capability;重新执行对象级保护;用锁后的对象创建新表单并再次 is_valid();最后才写入。
8.2 全项目稳定锁顺序
- 所有
Role行,按主键升序; - 所有
Capability行,按主键升序; - 所有
RoleCapability行,按主键升序; - 涉及“最后一个超级用户”时,锁激活超级用户集合;
- 操作人和目标
User按排序后的主键顺序; - 相关
UserRole按主键升序; - 再锁具体部门或已被目录锁覆盖的角色对象。
稳定顺序减少两个事务反向拿锁导致的死锁,并保证授权复检后能力来源不会在本事务结束前被另一个受同一协议约束的写入替换。SQLite 适合页面烟雾测试,但不能展示真正的行级锁语义;在 MySQL InnoDB 或 PostgreSQL 上才能验证等待与撤销场景。
8.3 自身、超级用户与系统角色保护
- 不能修改自己的角色、状态或删除自己。
- 普通管理员不能修改超级用户;超级用户账号仍受“最后一个激活超级用户”保护。
- 非超级用户不能编辑自己当前持有的角色,防止把临时管理能力固化进长期角色。
- 系统角色即使由超级用户访问网页,也不能编辑或删除,只能由 bootstrap 命令同步。
- 目标用户只要含有操作人无法授予的角色,普通操作人连“替换并移除”也不能执行,必须交给更高边界处理。
完整文件:accounts/views.py
# accounts/views.py
# messages 把一次性成功/错误提示写入请求消息存储,通常在下一次模板渲染时显示。
from django.contrib import messages
# PermissionDenied 未捕获时生成 403,表示“已理解请求但没有权限”,不同于对象不存在的 404。
from django.core.exceptions import PermissionDenied
# Paginator 对有序 QuerySet 分页,只取当前页所需切片并提供页码元数据。
from django.core.paginator import Paginator
# transaction.atomic() 把代码块作为一个事务:异常离开时回滚,正常离开时提交或释放保存点。
from django.db import transaction
# Q 组合 OR/AND/NOT 查询条件;Count 在数据库侧聚合计数。
from django.db.models import Count, Q
# ProtectedError 是 Django ORM 删除收集器发现 PROTECT 引用时抛出的异常。
from django.db.models.deletion import ProtectedError
# get_object_or_404 查询不到时抛 Http404;redirect 生成重定向响应;render 用上下文渲染模板为响应。
from django.shortcuts import get_object_or_404, redirect, render
# require_http_methods 限定允许的方法,require_POST 只允许 POST;不匹配时框架返回 405 Method Not Allowed。
from django.views.decorators.http import require_http_methods, require_POST
# 导入稳定能力编码,避免视图散落易拼错的授权字符串。
from .capabilities import (
CAPABILITY_VIEW,
DASHBOARD_VIEW,
DEPARTMENT_MANAGE,
DEPARTMENT_VIEW,
ROLE_MANAGE,
ROLE_VIEW,
USER_ASSIGN_ROLE,
USER_CHANGE,
USER_CREATE,
USER_DELETE,
USER_STATUS,
USER_VIEW,
)
# capability_required 组合登录检查与项目能力检查。
from .decorators import capability_required
# 导入页面表单及委派范围辅助函数。
from .forms import (
DepartmentForm,
RoleForm,
UserAccessForm,
UserCreateForm,
UserUpdateForm,
assignable_role_queryset,
role_capabilities_within_limit,
)
# 导入本文件查询和写入的模型;RoleCapability/UserRole 是显式中间表。
from .models import Capability, Department, Role, RoleCapability, User, UserRole
# require_capability 用于事务加锁后的第二次授权检查。
from .policy import require_capability
# @ 装饰器在模块导入时把 home 替换成授权包装函数;缺登录会重定向,缺能力会得到 403。
@capability_required(DASHBOARD_VIEW)
def home(request):
# 输入:已登录且具备首页能力的 request;输出:管理首页 HttpResponse;调用者:accounts 根路由;失败:未登录或缺权由装饰器处理,模板加载失败时上抛 TemplateDoesNotExist。
# 返回:渲染不带额外上下文的管理首页模板。
return render(request, "accounts/home.html")
def _deny_non_superuser_managing_superuser(operator, user_obj):
# 输入:操作用户与目标用户;输出:允许时自然返回 None;调用者:用户编辑、角色、状态和删除流程;失败:普通用户试图管理超级用户时抛出 PermissionDenied。
# 业务判断:只有超级用户可以修改另一个超级用户账号。
if user_obj.is_superuser and not operator.is_superuser:
raise PermissionDenied("只有超级用户可以修改超级用户账号。")
# 返回:不触发拒绝条件时自然返回 None。
def _locked_active_superuser_ids():
# 输入:无显式参数,使用当前事务连接;输出:按主键排序的激活超级用户 ID 列表;调用者:用户状态和删除视图;失败:必须在 transaction.atomic() 内调用,数据库查询失败时上抛异常。
# select_for_update() 只会在支持行锁的数据库上锁住查询实际匹配且已经存在的行;它不会锁住尚不存在的未来行或整个条件范围。
# SQLite 会忽略 SELECT ... FOR UPDATE,因此此并发保护在那里无效;生产环境需使用支持该锁语义的数据库。
# list() 立即求值原本惰性的 QuerySet,使支持数据库在当前 atomic 事务内取得这些行锁。
return list(
User.objects.select_for_update()
.filter(is_superuser=True, is_active=True)
.order_by("pk")
.values_list("pk", flat=True)
)
def _last_active_superuser(user_obj, active_superuser_ids):
# 输入:目标用户与已锁定的激活超级用户主键列表;输出:目标是否为最后一个激活超级用户;调用者:状态变更与删除视图;失败:参数缺少预期属性或长度协议时上抛对应异常。
# 业务判断与返回:目标必须同时是超级用户、处于激活状态,且锁定集合数量不超过一个。
return (
user_obj.is_superuser
and user_obj.is_active
and len(active_superuser_ids) <= 1
)
def _lock_role_catalog():
# 输入:无显式参数,使用当前事务连接;输出:已锁定的 Role 主键列表;调用者:所有会写账号或授权数据的事务视图;失败:必须在 transaction.atomic() 内调用,数据库查询失败时上抛异常。
# 三个 QuerySet 都用 list() 立即执行;在支持行锁的数据库上只锁住此刻已存在且匹配的行,不能阻止另一个事务插入新行。
# SQLite 忽略 select_for_update(),所以这些调用在那里不提供行锁;固定模型和主键顺序只降低支持数据库上的死锁机会。
role_ids = list(
Role.objects.select_for_update().order_by("pk").values_list("pk", flat=True)
)
list(
Capability.objects.select_for_update()
.order_by("pk")
.values_list("pk", flat=True)
)
list(
RoleCapability.objects.select_for_update()
.order_by("pk")
.values_list("pk", flat=True)
)
# 返回:角色主键列表主要用于确保查询已经求值并获得行锁。
return role_ids
def _require_locked_capability(operator, capability_key):
# 输入:事务内重新加锁读取的操作用户与能力编码;输出:允许时自然返回 None;调用者:所有写视图;失败:能力已被并发撤销时抛出 PermissionDenied。
# 业务判断:外层装饰器只负责快速拒绝,事务内必须使用锁后账号重新检查。
require_capability(operator, capability_key)
# 返回:具备能力时自然返回 None。
def _deny_role_outside_capability_limit(operator, role):
# 输入:操作用户与待管理角色;输出:允许时自然返回 None;调用者:角色编辑和删除流程;失败:角色能力超出操作人范围时抛出 PermissionDenied。
# 数据库读取与业务判断:重新读取角色完整能力集合并执行委派子集检查。
if not role_capabilities_within_limit(operator, role):
raise PermissionDenied("不能管理能力范围高于自己的角色。")
# 返回:角色处于可管理范围时自然返回 None。
def _deny_editing_system_role(role):
# 输入:待编辑或删除的 Role;输出:自定义角色时自然返回 None;调用者:角色编辑和删除流程;失败:系统内置角色时抛出 PermissionDenied。
# 业务判断:系统角色来自代码目录,只能由 bootstrap_rbac 精确同步,网页修改会被覆盖并可能造成短暂越权。
if role.is_system:
raise PermissionDenied("系统内置角色只能通过初始化命令维护。")
# 返回:非系统角色自然返回 None。
def _deny_editing_own_role(operator, role):
# 输入:操作用户与待管理角色;输出:允许时自然返回 None;调用者:角色编辑和删除流程;失败:普通用户管理自己当前持有的角色时抛出 PermissionDenied。
# 业务判断与返回:激活超级用户保留内部运维旁路。
if operator.is_active and operator.is_superuser:
return
# 数据库读取与业务判断:检查操作人是否持有该角色,禁止把临时能力固化到自己的长期角色中。
if UserRole.objects.filter(user=operator, role=role).exists():
raise PermissionDenied("不能编辑当前账号已经持有的角色。")
# 返回:操作人未持有该角色时自然返回 None。
def _deny_user_roles_outside_capability_limit(operator, user_obj):
# 输入:操作用户与目标用户;输出:允许时自然返回 None;调用者:用户角色分配流程;失败:目标含操作人无权管理的角色时抛出 PermissionDenied。
# 业务判断与返回:激活超级用户可以管理任意用户角色集合。
if operator.is_active and operator.is_superuser:
return
# 数据库读取:分别取得操作人当前可分配角色和目标用户现有角色的主键集合。
allowed_role_ids = set(
assignable_role_queryset(operator).values_list("pk", flat=True)
)
existing_role_ids = set(
UserRole.objects.filter(user=user_obj).values_list("role_id", flat=True)
)
# 业务判断:不能借“替换角色”删除自己无权授予的高权限或停用角色。
if not existing_role_ids <= allowed_role_ids:
raise PermissionDenied("目标用户包含当前账号无权管理的角色。")
# 返回:现有角色全部可管理时自然返回 None。
def _locked_users(operator_id, target_id):
# 输入:操作人与目标用户主键;输出:以主键为键、锁后 User 为值的字典;调用者:用户编辑、授权、状态和删除事务;失败:必须在 transaction.atomic() 内调用,数据库查询失败时上抛异常。
# 在支持数据库上只锁查询命中的现有用户行;SQLite 不执行行锁,且不存在的目标行无法被此查询锁住。
# sorted({..}) 先用集合去重再排序,保证操作人与目标相同时只查一次,并让并发事务采用同一锁顺序。
ids = sorted({operator_id, target_id})
# 数据库读取与返回:立即迭代 select_for_update() 查询并构造便于按主键取值的字典。
return {
user.pk: user
for user in User.objects.select_for_update().filter(pk__in=ids).order_by("pk")
}
def _lock_user_roles(user_ids):
# 输入:需要稳定其角色来源的用户主键可迭代对象;输出:已锁定 UserRole 行的主键列表;调用者:账号和授权写事务;失败:必须在 transaction.atomic() 内调用,参数不可迭代或数据库查询失败时上抛异常。
# 只锁查询时已经存在且匹配的中间表行,不锁“当前没有角色”这一空范围,也不阻止随后插入;SQLite 上无行锁效果。
# sorted(set(...)) 去重并固定过滤参数,list() 立即执行查询。
return list(
UserRole.objects.select_for_update()
.filter(user_id__in=sorted(set(user_ids)))
.order_by("pk")
.values_list("pk", flat=True)
)
@capability_required(USER_VIEW)
def user_list(request):
# 输入:包含可选 q、department、status、page 查询参数的 request;输出:用户列表页面 HttpResponse;调用者:users/ 路由;失败:缺权由装饰器处理,数据库或模板异常继续上抛。
# QuerySet 是惰性查询描述;select_related 用 SQL JOIN 取单值外键,prefetch_related 另发查询批量装入多值关系,当前行不会立刻执行查询。
users = User.objects.select_related("department").prefetch_related(
"user_roles__role"
)
# 业务输入:读取并去除筛选参数首尾空白,缺失时使用空字符串。
keyword = request.GET.get("q", "").strip()
department_id = request.GET.get("department", "").strip()
status = request.GET.get("status", "").strip()
# Q 对象让多个条件用 | 组成 SQL OR;若不用 Q,多次 filter() 默认是 AND。icontains 表示不区分大小写的包含匹配。
if keyword:
users = users.filter(
Q(username__icontains=keyword)
| Q(display_name__icontains=keyword)
| Q(employee_number__icontains=keyword)
| Q(email__icontains=keyword)
| Q(mobile__icontains=keyword)
)
# 业务判断:先验证部门参数只含 ASCII 十进制数字且长度安全,再转换整数,避免异常和超大输入。
department_pk = None
if department_id.isascii() and department_id.isdecimal() and len(department_id) <= 19:
candidate = int(department_id)
if 0 < candidate <= 9223372036854775807:
department_pk = candidate
# 数据库读取准备:合法部门主键加入过滤条件;非法值清空以便模板不保留错误选项。
if department_pk is not None:
users = users.filter(department_id=department_pk)
department_id = str(department_pk)
else:
department_id = ""
# 业务判断:状态只接受 active 或 inactive,其他值不增加状态过滤。
if status == "active":
users = users.filter(is_active=True)
elif status == "inactive":
users = users.filter(is_active=False)
# Paginator 保存每页 10 条规则并通过 QuerySet 切片惰性取当前页;get_page() 对非整数页码回第一页、过大页码回最后一页。
page_obj = Paginator(users.order_by("username"), 10).get_page(request.GET.get("page"))
# 返回准备:部门选项只显示启用部门,并把筛选值回传模板保持界面状态。
context = {
"page_obj": page_obj,
"departments": Department.objects.filter(is_active=True),
"keyword": keyword,
"selected_department": department_id,
"selected_status": status,
}
# 返回:渲染用户列表及分页、筛选上下文。
return render(request, "accounts/user_list.html", context)
@capability_required(USER_VIEW)
def user_detail(request, pk):
# 输入:request 与目标用户主键 pk;输出:用户详情页面 HttpResponse;调用者:users/<pk>/ 路由;失败:缺权时 403,用户不存在时 404,数据库或模板异常继续上抛。
# 数据库读取:一次取回用户、所属部门和角色关联,供详情模板展示。
user_obj = get_object_or_404(
User.objects.select_related("department").prefetch_related("user_roles__role"),
pk=pk,
)
# 返回:把目标用户放入模板上下文并渲染详情页。
return render(request, "accounts/user_detail.html", {"user_obj": user_obj})
# 叠加装饰器从下往上应用:先建立 GET/POST 方法守卫,再由外层能力守卫包装;其他方法最终得到 405。
@capability_required(USER_CREATE)
@require_http_methods(["GET", "POST"])
def user_create(request):
# 输入:GET 或 POST request;输出:空白/错误表单页面或创建成功重定向;调用者:users/create/ 路由;失败:方法、登录和能力由装饰器处理,数据库写入异常触发事务回滚。
# 只有 POST 才把 QueryDict 绑定为表单数据;空 POST 也必须保持“已绑定”,这样 required 字段会产生错误,而不会被当成 GET 空表单。
form = UserCreateForm(request.POST if request.method == "POST" else None)
if request.method == "POST" and form.is_valid():
# 保存阶段:写操作放入原子事务,任一步失败都会整体回滚。
with transaction.atomic():
# 数据库读取:按统一顺序锁定角色目录、操作人和其角色来源。
_lock_role_catalog()
operator = User.objects.select_for_update().get(pk=request.user.pk)
_lock_user_roles([operator.pk])
# 业务判断:使用锁后的用户状态和授权重新检查创建能力,防止检查后被并发撤权。
_require_locked_capability(operator, USER_CREATE)
# 业务判断:锁内重新绑定并校验表单,避免使用事务外的陈旧清理结果。
locked_form = UserCreateForm(request.POST)
if locked_form.is_valid():
# 保存阶段:创建用户并写入成功消息。
user_obj = locked_form.save()
messages.success(request, "用户“%s”已创建。" % user_obj)
# 返回:重定向到新用户详情页,避免刷新页面重复提交。
return redirect("accounts:user_detail", pk=user_obj.pk)
form = locked_form
# 返回:GET 或校验失败时渲染表单及错误信息。
return render(request, "accounts/user_form.html", {"form": form, "title": "新建用户"})
@capability_required(USER_CHANGE)
@require_http_methods(["GET", "POST"])
def user_update(request, pk):
# 输入:GET/POST request 与目标用户主键;输出:编辑页面或成功重定向;调用者:users/<pk>/edit/ 路由;失败:用户不存在时 404,越权时 403,数据库异常触发事务回滚。
# 数据库读取:先读取目标用户供 GET 展示和事务外快速拒绝。
user_obj = get_object_or_404(User, pk=pk)
# 业务判断:普通用户不能修改超级用户。
_deny_non_superuser_managing_superuser(request.user, user_obj)
# 空 POST 也传给表单,确保它是绑定表单并显示必填错误;GET 才传 None。instance 使 ModelForm 更新该用户而非新建。
form = UserUpdateForm(
request.POST if request.method == "POST" else None,
instance=user_obj,
)
if request.method == "POST" and form.is_valid():
# 保存阶段:锁内重读、重检并保存,失败时整体回滚。
with transaction.atomic():
_lock_role_catalog()
# 数据库读取:以固定顺序锁定操作人和目标用户,再从字典取锁后对象。
locked_users = _locked_users(request.user.pk, pk)
operator = locked_users[request.user.pk]
user_obj = locked_users.get(pk)
# 业务判断:目标在事务开始前后被删除时明确返回 403,而不继续使用旧实例。
if user_obj is None:
raise PermissionDenied("目标用户已不存在。")
_lock_user_roles([operator.pk])
# 业务判断:基于锁后状态重新检查编辑能力和超级用户边界。
_require_locked_capability(operator, USER_CHANGE)
_deny_non_superuser_managing_superuser(operator, user_obj)
# 业务判断:使用锁后的目标实例重新校验提交数据。
locked_form = UserUpdateForm(request.POST, instance=user_obj)
if locked_form.is_valid():
# 保存阶段:更新用户并记录成功消息。
user_obj = locked_form.save()
messages.success(request, "用户“%s”已更新。" % user_obj)
# 返回:重定向到更新后的用户详情页。
return redirect("accounts:user_detail", pk=user_obj.pk)
form = locked_form
# 返回:GET 或校验失败时渲染编辑表单。
return render(
request,
"accounts/user_form.html",
{"form": form, "title": "编辑用户", "user_obj": user_obj},
)
@capability_required(USER_ASSIGN_ROLE)
@require_http_methods(["GET", "POST"])
def user_access(request, pk):
# 输入:GET/POST request 与目标用户主键;输出:角色分配页面或重定向;调用者:users/<pk>/access/ 路由;失败:用户不存在时 404,越权时 403,数据库异常触发事务回滚。
# 数据库读取:先读取目标用户供页面展示和事务外快速检查。
user_obj = get_object_or_404(User, pk=pk)
# 业务判断与返回:禁止修改自己的角色,避免当前请求主动改变自身授权来源。
if user_obj.pk == request.user.pk:
messages.error(request, "不能修改自己的角色。")
return redirect("accounts:user_detail", pk=user_obj.pk)
# 业务判断:限制普通用户管理超级用户,并防止替换自己无权管理的既有角色。
_deny_non_superuser_managing_superuser(request.user, user_obj)
_deny_user_roles_outside_capability_limit(request.user, user_obj)
# 数据库读取准备:按操作人的能力范围动态设置角色选项,并加载目标当前角色。
form = UserAccessForm(
request.POST if request.method == "POST" else None,
instance=user_obj,
operator=request.user,
)
if request.method == "POST" and form.is_valid():
# 保存阶段:所有锁、重检和角色替换在同一原子事务内完成。
with transaction.atomic():
_lock_role_catalog()
# 数据库读取:锁定操作人和目标用户,避免状态在授权检查后改变。
locked_users = _locked_users(request.user.pk, user_obj.pk)
operator = locked_users[request.user.pk]
user_obj = locked_users.get(user_obj.pk)
if user_obj is None:
raise PermissionDenied("目标用户已不存在。")
# 数据库读取:锁住双方角色关联,稳定操作人的能力来源和目标的待替换集合。
_lock_user_roles([operator.pk, user_obj.pk])
# 业务判断:事务内重新检查分配能力、自修改、超级用户和委派边界。
_require_locked_capability(operator, USER_ASSIGN_ROLE)
if user_obj.pk == operator.pk:
messages.error(request, "不能修改自己的角色。")
return redirect("accounts:user_detail", pk=user_obj.pk)
_deny_non_superuser_managing_superuser(operator, user_obj)
_deny_user_roles_outside_capability_limit(operator, user_obj)
# 业务判断:旧关联已加锁,重新构造表单后替换操作不会与另一个分配请求交错。
locked_form = UserAccessForm(
request.POST,
instance=user_obj,
operator=operator,
)
if locked_form.is_valid():
# 保存阶段:完全替换目标用户角色并记录当前操作人。
locked_form.save()
messages.success(request, "用户“%s”的角色已更新。" % user_obj)
# 返回:重定向到目标用户详情页。
return redirect("accounts:user_detail", pk=user_obj.pk)
form = locked_form
# 返回:GET 或校验失败时渲染角色分配表单。
return render(
request,
"accounts/user_access_form.html",
{"form": form, "user_obj": user_obj},
)
# require_POST 对非 POST 返回 405;能力装饰器仍是外层,先处理登录和授权。
@capability_required(USER_STATUS)
@require_POST
def user_status(request, pk):
# 输入:POST request 与目标用户主键;输出:用户详情页重定向;调用者:users/<pk>/status/ 路由;失败:方法/能力不符时拒绝,数据库异常触发事务回滚。
# 保存阶段:状态切换及全部并发保护在同一原子事务内执行。
with transaction.atomic():
_lock_role_catalog()
# 数据库读取:锁定激活超级用户全集及操作人、目标用户,保证最后超级用户判断稳定。
active_superuser_ids = _locked_active_superuser_ids()
locked_users = _locked_users(request.user.pk, pk)
operator = locked_users[request.user.pk]
user_obj = locked_users.get(pk)
# 数据库读取:目标未出现在锁定结果时用标准 404 查询保持既有响应语义。
if user_obj is None:
user_obj = get_object_or_404(User, pk=pk)
_lock_user_roles([operator.pk])
# 业务判断:锁后重新检查操作能力与超级用户管理边界。
_require_locked_capability(operator, USER_STATUS)
_deny_non_superuser_managing_superuser(operator, user_obj)
# 业务判断:不能改变自己的登录状态,也不能停用最后一个激活超级用户。
if user_obj.pk == operator.pk:
messages.error(request, "不能停用或变更自己的登录状态。")
elif user_obj.is_active and _last_active_superuser(user_obj, active_superuser_ids):
messages.error(request, "不能停用最后一个激活的超级用户。")
else:
# 保存阶段:翻转布尔状态,并只更新 is_active 字段减少无关写入。
user_obj.is_active = not user_obj.is_active
user_obj.save(update_fields=["is_active"])
state = "启用" if user_obj.is_active else "停用"
messages.success(request, "用户“%s”已%s。" % (user_obj, state))
# 返回:无论执行切换还是被业务规则阻止,都回到目标用户详情页显示消息。
return redirect("accounts:user_detail", pk=pk)
@capability_required(USER_DELETE)
@require_http_methods(["GET", "POST"])
def user_delete(request, pk):
# 输入:GET/POST request 与目标用户主键;输出:确认页或删除后的重定向;调用者:users/<pk>/delete/ 路由;失败:用户不存在时 404,越权时 403,数据库异常触发事务回滚。
# 业务判断:只有 POST 真正执行删除,GET 仅展示确认页。
if request.method == "POST":
# 保存阶段:锁定、重检和删除在一个原子事务内完成。
with transaction.atomic():
_lock_role_catalog()
# 数据库读取:锁定激活超级用户全集以及操作人、目标用户。
active_superuser_ids = _locked_active_superuser_ids()
locked_users = _locked_users(request.user.pk, pk)
operator = locked_users[request.user.pk]
user_obj = locked_users.get(pk)
if user_obj is None:
user_obj = get_object_or_404(User, pk=pk)
_lock_user_roles([operator.pk])
# 业务判断:锁后重新验证删除能力和超级用户管理边界。
_require_locked_capability(operator, USER_DELETE)
_deny_non_superuser_managing_superuser(operator, user_obj)
# 业务判断与返回:禁止删除自己或最后一个激活超级用户。
if user_obj.pk == operator.pk:
messages.error(request, "不能删除自己。")
return redirect("accounts:user_detail", pk=user_obj.pk)
if _last_active_superuser(user_obj, active_superuser_ids):
messages.error(request, "不能删除最后一个激活的超级用户。")
return redirect("accounts:user_detail", pk=user_obj.pk)
# 保存阶段:先保留显示文本,再删除用户及其 CASCADE 关联。
label = str(user_obj)
user_obj.delete()
messages.success(request, "用户“%s”已删除。" % label)
# 返回:事务提交后跳转用户列表。
return redirect("accounts:user_list")
# 数据库读取:GET 确认页读取目标用户,并执行事务外快速权限边界检查。
user_obj = get_object_or_404(User, pk=pk)
_deny_non_superuser_managing_superuser(request.user, user_obj)
# 返回:渲染删除确认页,不在 GET 请求中修改数据库。
return render(request, "accounts/user_confirm_delete.html", {"user_obj": user_obj})
@capability_required(DEPARTMENT_VIEW)
def department_list(request):
# 输入:已授权 request;输出:部门列表页面 HttpResponse;调用者:departments/ 路由;失败:缺权时 403,数据库或模板异常继续上抛。
# annotate() 让数据库把两个 Count 聚合结果附加为非模型字段;distinct=True 避免同时 JOIN children/users 造成笛卡尔积式重复计数。
departments = Department.objects.select_related("parent").annotate(
child_count=Count("children", distinct=True),
user_count=Count("users", distinct=True),
)
# 返回:把带统计字段的部门查询集交给列表模板。
return render(request, "accounts/department_list.html", {"departments": departments})
@capability_required(DEPARTMENT_MANAGE)
@require_http_methods(["GET", "POST"])
def department_create(request):
# 输入:GET/POST request;输出:部门表单页面或创建成功重定向;调用者:departments/create/ 路由;失败:校验失败回显表单,数据库异常触发事务回滚。
# POST 即使没有字段也必须绑定,才能显示必填错误;GET 才传 None 创建未绑定表单。
form = DepartmentForm(request.POST if request.method == "POST" else None)
if request.method == "POST" and form.is_valid():
# 保存阶段:在原子事务内锁定授权来源并重新校验后写入。
with transaction.atomic():
_lock_role_catalog()
# 数据库读取:锁定操作人及其角色关联,稳定事务内授权结果。
operator = User.objects.select_for_update().get(pk=request.user.pk)
_lock_user_roles([operator.pk])
# 业务判断:锁后重新检查部门管理能力。
_require_locked_capability(operator, DEPARTMENT_MANAGE)
locked_form = DepartmentForm(request.POST)
if locked_form.is_valid():
# 保存阶段:写入部门并记录成功消息。
department = locked_form.save()
messages.success(request, "部门“%s”已创建。" % department)
# 返回:重定向列表页,避免刷新重复提交。
return redirect("accounts:department_list")
form = locked_form
# 返回:GET 或校验失败时渲染部门表单。
return render(request, "accounts/department_form.html", {"form": form, "title": "新建部门"})
@capability_required(DEPARTMENT_MANAGE)
@require_http_methods(["GET", "POST"])
def department_update(request, pk):
# 输入:GET/POST request 与部门主键;输出:编辑页面或成功重定向;调用者:departments/<pk>/edit/ 路由;失败:部门不存在时 404,校验失败回显,数据库异常触发回滚。
# 数据库读取:先读取部门供 GET 展示与事务外快速校验。
department = get_object_or_404(Department, pk=pk)
# 空 POST 仍绑定以保留必填错误;GET 传 None;instance 指定要更新的部门。
form = DepartmentForm(
request.POST if request.method == "POST" else None,
instance=department,
)
if request.method == "POST" and form.is_valid():
# 保存阶段:授权重检和部门更新在同一原子事务内完成。
with transaction.atomic():
_lock_role_catalog()
# 数据库读取:锁定操作人及其授权来源。
operator = User.objects.select_for_update().get(pk=request.user.pk)
_lock_user_roles([operator.pk])
# 业务判断:基于锁后状态重新检查部门管理能力。
_require_locked_capability(operator, DEPARTMENT_MANAGE)
# 数据库读取:锁定目标部门,避免并发编辑覆盖彼此结果。
department = get_object_or_404(
Department.objects.select_for_update(),
pk=pk,
)
# 业务判断:使用锁后的部门实例重新执行字段和模型层级校验。
locked_form = DepartmentForm(request.POST, instance=department)
if locked_form.is_valid():
# 保存阶段:更新部门并写入成功消息。
department = locked_form.save()
messages.success(request, "部门“%s”已更新。" % department)
# 返回:重定向部门列表。
return redirect("accounts:department_list")
form = locked_form
# 返回:GET 或校验失败时渲染编辑表单。
return render(
request,
"accounts/department_form.html",
{"form": form, "title": "编辑部门", "department": department},
)
@capability_required(DEPARTMENT_MANAGE)
@require_http_methods(["GET", "POST"])
def department_delete(request, pk):
# 输入:GET/POST request 与部门主键;输出:确认页或列表重定向;调用者:departments/<pk>/delete/ 路由;失败:部门不存在时 404,受保护引用时提示,其他数据库异常触发回滚。
# 数据库读取:GET 和 POST 都先确认目标部门存在。
department = get_object_or_404(Department, pk=pk)
if request.method == "POST":
# 保存阶段:授权重检、引用检查和删除在同一原子事务内完成。
with transaction.atomic():
_lock_role_catalog()
# 数据库读取:锁定操作人及其角色来源后重新检查能力。
operator = User.objects.select_for_update().get(pk=request.user.pk)
_lock_user_roles([operator.pk])
_require_locked_capability(operator, DEPARTMENT_MANAGE)
# 数据库读取:锁定目标部门,避免并发修改其本行。
department = get_object_or_404(
Department.objects.select_for_update(),
pk=pk,
)
# 业务判断与返回:仍有子部门或用户时不执行删除。
if department.children.exists() or department.users.exists():
messages.error(request, "该部门仍有子部门或用户,不能删除。")
return redirect("accounts:department_list")
# 保存准备:删除前保留显示文本,供成功消息使用。
label = str(department)
try:
# 保存阶段:执行 ORM 删除;on_delete=PROTECT 的主要保障来自 Django 删除收集器,并非对所有原始 SQL 删除的通用保证。
department.delete()
except ProtectedError:
messages.error(request, "该部门仍被其他数据引用,不能删除。")
else:
messages.success(request, "部门“%s”已删除。" % label)
# 返回:无论删除成功还是被保护引用拒绝,都回到部门列表。
return redirect("accounts:department_list")
# 返回:GET 只渲染确认页,不修改数据库。
return render(
request,
"accounts/department_confirm_delete.html",
{"department": department},
)
@capability_required(ROLE_VIEW)
def role_list(request):
# 输入:已授权 request;输出:角色列表页面 HttpResponse;调用者:roles/ 路由;失败:缺权时 403,数据库或模板异常继续上抛。
# 数据库读取:统计每个角色的用户数并预取角色能力及能力对象,避免模板逐角色查询。
roles = Role.objects.annotate(user_count=Count("user_roles", distinct=True)).prefetch_related(
"role_capabilities__capability"
)
# 返回:按名称、编码排序后渲染角色列表。
return render(request, "accounts/role_list.html", {"roles": roles.order_by("name", "key")})
@capability_required(ROLE_MANAGE)
@require_http_methods(["GET", "POST"])
def role_create(request):
# 输入:GET/POST request;输出:角色表单页面或创建成功重定向;调用者:roles/create/ 路由;失败:校验失败回显,越权时 403,数据库异常触发回滚。
# operator 决定可委派能力;POST 包括空提交都要绑定以显示必填错误,GET 才传 None。
form = RoleForm(
request.POST if request.method == "POST" else None,
operator=request.user,
)
if request.method == "POST" and form.is_valid():
# 保存阶段:目录加锁、授权重检、角色及关联写入在同一事务内完成。
with transaction.atomic():
_lock_role_catalog()
# 数据库读取:锁定操作人和其角色来源,稳定能力快照。
operator = User.objects.select_for_update().get(pk=request.user.pk)
_lock_user_roles([operator.pk])
# 业务判断:基于锁后状态重新检查角色管理能力。
_require_locked_capability(operator, ROLE_MANAGE)
# 业务判断:用锁后的操作人重新建立表单并校验可委派能力。
locked_form = RoleForm(request.POST, operator=operator)
if locked_form.is_valid():
# 保存阶段:创建角色并完全写入其能力中间表关联。
role = locked_form.save()
messages.success(request, "角色“%s”已创建。" % role.name)
# 返回:重定向角色列表。
return redirect("accounts:role_list")
form = locked_form
# 返回:GET 或校验失败时渲染角色表单。
return render(request, "accounts/role_form.html", {"form": form, "title": "新建角色"})
@capability_required(ROLE_MANAGE)
@require_http_methods(["GET", "POST"])
def role_update(request, pk):
# 输入:GET/POST request 与角色主键;输出:编辑页面或成功重定向;调用者:roles/<pk>/edit/ 路由;失败:角色不存在时 404,越权时 403,数据库异常触发回滚。
# 数据库读取:先取目标角色供页面展示和事务外快速拒绝。
role = get_object_or_404(Role, pk=pk)
# 业务判断:拒绝网页编辑系统角色、超出委派范围的角色和操作人自己持有的角色。
_deny_editing_system_role(role)
_deny_role_outside_capability_limit(request.user, role)
_deny_editing_own_role(request.user, role)
# instance 指定更新目标,operator 限制能力选项;空 POST 也保持绑定,只有 GET 传 None。
form = RoleForm(
request.POST if request.method == "POST" else None,
instance=role,
operator=request.user,
)
if request.method == "POST" and form.is_valid():
# 保存阶段:所有目录锁、授权重检和关联替换在同一事务内完成。
with transaction.atomic():
_lock_role_catalog()
# 数据库读取:锁定操作人及其角色来源后重新校验角色管理能力。
operator = User.objects.select_for_update().get(pk=request.user.pk)
_lock_user_roles([operator.pk])
_require_locked_capability(operator, ROLE_MANAGE)
# 数据库读取:锁定目标角色,避免并发编辑。
role = get_object_or_404(Role.objects.select_for_update(), pk=pk)
# 业务判断:基于锁后的角色与操作人再次执行全部越权边界检查。
_deny_editing_system_role(role)
_deny_role_outside_capability_limit(operator, role)
_deny_editing_own_role(operator, role)
locked_form = RoleForm(request.POST, instance=role, operator=operator)
if locked_form.is_valid():
# 保存阶段:更新角色主体并完全替换能力关联。
role = locked_form.save()
messages.success(request, "角色“%s”已更新。" % role.name)
# 返回:重定向角色列表。
return redirect("accounts:role_list")
form = locked_form
# 返回:GET 或锁内校验失败时渲染编辑表单。
return render(
request,
"accounts/role_form.html",
{"form": form, "title": "编辑角色", "role": role},
)
@capability_required(ROLE_MANAGE)
@require_http_methods(["GET", "POST"])
def role_delete(request, pk):
# 输入:GET/POST request 与角色主键;输出:确认页或列表重定向;调用者:roles/<pk>/delete/ 路由;失败:角色不存在时 404,越权时 403,数据库异常触发回滚。
# 数据库读取:先读取目标角色供确认页和事务外快速边界检查。
role = get_object_or_404(Role, pk=pk)
# 业务判断:系统角色、超范围角色和操作人自己持有的角色都不能删除。
_deny_editing_system_role(role)
_deny_role_outside_capability_limit(request.user, role)
_deny_editing_own_role(request.user, role)
if request.method == "POST":
# 保存阶段:目录锁定、授权重检、引用检查与删除在同一事务内完成。
with transaction.atomic():
_lock_role_catalog()
# 数据库读取:锁定操作人及其授权来源。
operator = User.objects.select_for_update().get(pk=request.user.pk)
_lock_user_roles([operator.pk])
_require_locked_capability(operator, ROLE_MANAGE)
# 数据库读取:锁定目标角色,防止并发修改。
role = get_object_or_404(Role.objects.select_for_update(), pk=pk)
# 业务判断:用锁后数据再次执行所有管理边界。
_deny_editing_system_role(role)
_deny_role_outside_capability_limit(operator, role)
_deny_editing_own_role(operator, role)
# 数据库读取与业务判断:仍分配给用户的角色不能删除。
if role.user_roles.exists():
messages.error(request, "该角色仍有已分配用户,不能删除。")
return redirect("accounts:role_list")
# 保存阶段:先保留名称,再删除角色及其 CASCADE 能力关联。
label = role.name
role.delete()
messages.success(request, "角色“%s”已删除。" % label)
# 返回:事务提交后重定向角色列表。
return redirect("accounts:role_list")
# 返回:GET 仅渲染删除确认页。
return render(request, "accounts/role_confirm_delete.html", {"role": role})
@capability_required(CAPABILITY_VIEW)
def permission_list(request):
# 输入:含可选 app_label 与 q 查询参数的 request;输出:能力目录页面 HttpResponse;调用者:permissions/ 路由;失败:缺权时 403,数据库或模板异常继续上抛。
# 数据库读取准备:从全部能力开始构造只读筛选查询集。
capabilities = Capability.objects.all()
# 业务输入:读取应用标签和关键词并去除首尾空白。
app_label = request.GET.get("app_label", "").strip()
keyword = request.GET.get("q", "").strip()
# 业务判断:本阶段只有 accounts 能力命名空间,其他非空标签返回空查询集。
if app_label and app_label != "accounts":
capabilities = capabilities.none()
# 数据库读取准备:关键词存在时匹配能力编码、名称或说明。
if keyword:
capabilities = capabilities.filter(
Q(key__icontains=keyword)
| Q(name__icontains=keyword)
| Q(description__icontains=keyword)
)
# 返回准备:按稳定编码排序并回传当前筛选值。
context = {
"capabilities": capabilities.order_by("key"),
"app_labels": ["accounts"],
"selected_app_label": app_label,
"keyword": keyword,
}
# 返回:渲染只读能力目录页面。
return render(request, "accounts/permission_list.html", context)
8.4 视图辅助函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
8.5 页面视图函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
8.6 403 与 404 的顺序是信息边界
装饰器在函数体和对象查询之前执行,因此已登录但无能力的用户请求 /users/999999/ 时得到 403,而不是借 404 判断对象是否存在。通过能力检查后,正常的 get_object_or_404() 才可以返回 404。
锁等待期间对象可能消失。user_update 与 user_access 已经在事务外找到对象,锁后发现目标不见时用 PermissionDenied("目标用户已不存在。") 终止陈旧写流程;状态、删除、部门和角色路径在锁内重新查询时可返回 404。这里没有把所有失败强行统一,而是保证未授权者先得到 403、授权者再进入对象语义。
9. 路由与 Django Admin 边界
9.1 阶段路由必须到能力目录为止
下面文件没有任何第 3 篇路由,也没有指向不存在名称的 reverse() 或模板 URL。登录、退出和修改密码仍使用 Django 内建视图;所有业务页面指向本文定义的函数。
完整文件:accounts/urls.py
# accounts/urls.py
# 将 django.contrib.auth.views 模块起别名 auth_views,避免与本应用 views 混淆。
from django.contrib.auth import views as auth_views
# login_required 把匿名请求重定向到登录页,这里用于包装内置类视图生成的函数。
from django.contrib.auth.decorators import login_required
# path 声明路由;reverse_lazy 返回延迟解析对象,等 URL 配置加载完成后再按名称求地址。
from django.urls import path, reverse_lazy
# 相对导入本应用视图模块,后续以 views.home 等属性引用。
from . import views
# LoginForm 替换 Django LoginView 默认认证表单的字段展示。
from .forms import LoginForm
# app_name 建立 URL 命名空间,模板和 redirect() 可使用 accounts:路由名,避免与其他应用重名。
app_name = "accounts"
# urlpatterns 按从上到下的顺序匹配请求路径;每个 name 都是反向解析时使用的稳定接口。
urlpatterns = [
# 空路径进入受 DASHBOARD_VIEW 能力保护的管理首页。
path("", views.home, name="home"),
# LoginView 是类视图;as_view() 按这些关键字配置创建 URL 分发器可调用的视图函数,每次请求再实例化类视图。
# template_name 选择模板,authentication_form 选择表单类,redirect_authenticated_user=True 让已登录用户转走。
path(
"login/",
auth_views.LoginView.as_view(
template_name="accounts/login.html",
authentication_form=LoginForm,
redirect_authenticated_user=True,
),
name="login",
),
# 登出路由额外套 login_required,匿名用户会先进入登录流程。
path(
"logout/",
login_required(auth_views.LogoutView.as_view()),
name="logout",
),
# 修改密码路由使用 Django 内置视图,成功后通过 reverse_lazy 延迟解析完成页地址。
path(
"password/change/",
login_required(
auth_views.PasswordChangeView.as_view(
template_name="accounts/password_change_form.html",
success_url=reverse_lazy("accounts:password_change_done"),
)
),
name="password_change",
),
# 密码修改完成页同样要求登录,只负责展示成功提示。
path(
"password/change/done/",
login_required(
auth_views.PasswordChangeDoneView.as_view(
template_name="accounts/password_change_done.html"
)
),
name="password_change_done",
),
# 用户列表、创建、详情、编辑、角色分配、状态切换和删除分别映射到带对应能力守卫的函数视图。
path("users/", views.user_list, name="user_list"),
path("users/create/", views.user_create, name="user_create"),
# <int:pk> 是路径转换器:只匹配非负整数字符串,转换成 Python int,并以关键字参数 pk 传给视图;name 供反向解析。
path("users/<int:pk>/", views.user_detail, name="user_detail"),
path("users/<int:pk>/edit/", views.user_update, name="user_update"),
path("users/<int:pk>/access/", views.user_access, name="user_access"),
path("users/<int:pk>/status/", views.user_status, name="user_status"),
path("users/<int:pk>/delete/", views.user_delete, name="user_delete"),
# 部门列表与增删改路由把 int 转换器解析出的主键作为 pk 参数传给对应视图。
path("departments/", views.department_list, name="department_list"),
path("departments/create/", views.department_create, name="department_create"),
path("departments/<int:pk>/edit/", views.department_update, name="department_update"),
path("departments/<int:pk>/delete/", views.department_delete, name="department_delete"),
# 角色列表与增删改路由只暴露项目自有 RBAC 角色管理入口。
path("roles/", views.role_list, name="role_list"),
path("roles/create/", views.role_create, name="role_create"),
path("roles/<int:pk>/edit/", views.role_update, name="role_update"),
path("roles/<int:pk>/delete/", views.role_delete, name="role_delete"),
# permissions/ 沿用教程前文的地址名称,但页面实际展示的是项目自有 Capability 能力目录。
path("permissions/", views.permission_list, name="permission_list"),
]
9.2 Admin 不是普通业务角色的第二入口
Admin 作为内部运维边界,只接受激活超级用户。仅有 staff 标志不足以查看模块;删除权限统一关闭;两张授权关系表只读。用户 Admin 不展示历史兼容字段,也不直接展示自定义 roles,避免绕过网页事务协议。已有用户的 is_active 与 is_superuser 设为只读,防止绕过最后一人保护。
完整文件:accounts/admin.py
# accounts/admin.py
# admin 提供站点注册装饰器和 ModelAdmin 基类。
from django.contrib import admin
# UserAdmin 是适配 Django 用户密码、权限字段和新增流程的专用后台类。
from django.contrib.auth.admin import UserAdmin
# 导入要注册到后台的六个项目模型。
from .models import Capability, Department, Role, RoleCapability, User, UserRole
def _active_superuser(request):
# 输入:Django Admin request;输出:当前用户是否同时激活且为超级用户;调用者:SuperuserOnlyAdminMixin 权限方法;失败:request 缺少 user 或用户缺少状态属性时上抛 AttributeError。
# 业务判断与返回:后台运维入口要求两个条件同时成立。
return request.user.is_active and request.user.is_superuser
# 不写父类表示继承 object;Mixin 只提供可被多个 ModelAdmin 复用的权限钩子。
class SuperuserOnlyAdminMixin:
# 项目自有管理后台只作为内部运维入口,不复用学习端 RBAC。
def has_module_permission(self, request):
# 输入:后台 request;输出:是否在 Admin 首页显示并进入该模型模块;调用者:Django Admin 权限框架;失败:用户属性异常时继续上抛。
return _active_superuser(request)
def has_view_permission(self, request, obj=None):
# 输入:后台 request 与可选模型对象;输出:是否允许查看列表或对象;调用者:Django Admin 权限框架;失败:用户属性异常时继续上抛。
return _active_superuser(request)
def has_add_permission(self, request):
# 输入:后台 request;输出:是否允许打开并提交新增页;调用者:Django Admin 权限框架;失败:用户属性异常时继续上抛。
return _active_superuser(request)
def has_change_permission(self, request, obj=None):
# 输入:后台 request 与可选模型对象;输出:是否允许编辑;调用者:Django Admin 权限框架;失败:用户属性异常时继续上抛。
return _active_superuser(request)
def has_delete_permission(self, request, obj=None):
# 输入:后台 request 与可选模型对象;输出:固定 False;调用者:Django Admin 权限框架;失败:本方法不读取数据库,正常情况下不会主动失败。
# 业务判断与返回:即使是超级用户也不从通用后台删除这些核心数据。
return False
# @admin.register(User) 在模块导入时把下面的后台类登记给 User,等价于 admin.site.register(User, CustomUserAdmin)。
@admin.register(User)
# 多继承的 MRO 从左到右查找:先使用 SuperuserOnlyAdminMixin 的权限钩子,再复用 UserAdmin 的用户表单和密码逻辑。
class CustomUserAdmin(SuperuserOnlyAdminMixin, UserAdmin):
# fieldsets 控制“编辑已有用户”页面的分组、顺序和字段;每项是“标题 + 配置字典”。
fieldsets = (
(None, {"fields": ("username", "password")}),
(
"业务信息",
{
"fields": (
"display_name",
"employee_number",
"email",
"mobile",
"department",
)
},
),
(
"内部状态",
{
"fields": (
"is_active",
"is_staff",
"is_superuser",
)
},
),
("重要日期", {"fields": ("last_login", "date_joined")}),
)
# add_fieldsets 单独控制新增用户页;classes=("wide",) 添加 Admin CSS 类,fields 指定创建时字段顺序。
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": (
"username",
"password1",
"password2",
"display_name",
"employee_number",
"email",
"mobile",
"department",
"is_active",
"is_staff",
"is_superuser",
),
},
),
)
# list_display 决定列表页每行显示哪些模型属性。
list_display = (
"username",
"display_name",
"employee_number",
"department",
"is_active",
"is_staff",
)
# list_filter 在右侧生成这些字段的筛选器。
list_filter = ("is_active", "is_staff", "is_superuser", "department")
# search_fields 让搜索框对这些字段执行文本查询。
search_fields = ("username", "display_name", "employee_number", "email", "mobile")
# ordering 是后台列表默认排序;元组末尾逗号表示单元素元组。
ordering = ("username",)
# filter_horizontal 指定哪些多选字段使用横向筛选控件;空元组表示没有字段使用该控件,groups/user_permissions 也未列入上面的 fieldsets。
filter_horizontal = ()
def get_readonly_fields(self, request, obj=None):
# 输入:后台 request 与可选 User 对象;输出:当前页面只读字段元组;调用者:Django UserAdmin 表单构建流程;失败:父类处理异常时继续上抛。
# 数据读取:先复用父类根据请求和对象计算出的只读字段。
readonly_fields = super().get_readonly_fields(request, obj)
# 业务判断与返回:新增用户时不额外增加限制。
if obj is None:
return readonly_fields
# * 在元组字面量中展开父类返回的字段,再追加两个名称;这是 Python 的可迭代解包语法。
return (*readonly_fields, "is_active", "is_superuser")
@admin.register(Department)
# DepartmentAdmin 继承 SuperuserOnlyAdminMixin 和 admin.ModelAdmin:前者覆盖权限钩子,后者提供列表、表单与保存等后台行为。
class DepartmentAdmin(SuperuserOnlyAdminMixin, admin.ModelAdmin):
# list_display 控制列;list_filter 控制侧栏过滤;search_fields 控制搜索字段;ordering 控制默认排序。
list_display = ("code", "name", "parent", "is_active", "sort_order")
list_filter = ("is_active",)
search_fields = ("code", "name")
ordering = ("sort_order", "code")
@admin.register(Capability)
# CapabilityAdmin 继承 SuperuserOnlyAdminMixin 和 admin.ModelAdmin;MRO 让超级用户限制覆盖默认权限判断,其余后台能力由 ModelAdmin 提供。
class CapabilityAdmin(SuperuserOnlyAdminMixin, admin.ModelAdmin):
list_display = ("key", "name", "is_system", "is_active", "updated_at")
list_filter = ("is_system", "is_active")
search_fields = ("key", "name", "description")
ordering = ("key",)
def get_readonly_fields(self, request, obj=None):
# 输入:后台 request 与可选 Capability 对象;输出:只读字段元组;调用者:Django ModelAdmin 表单构建流程;失败:对象属性异常时继续上抛。
# 业务判断:系统标记始终只读,防止后台把普通能力伪装成系统目录项。
fields = ["is_system"]
if obj is not None and obj.is_system:
# 业务判断:已有系统能力的稳定编码也不可从后台修改。
fields.append("key")
# 返回:Django Admin 要求的不可变字段名元组。
return tuple(fields)
@admin.register(Role)
# RoleAdmin 继承 SuperuserOnlyAdminMixin 和 admin.ModelAdmin;左侧 Mixin 的权限钩子优先,ModelAdmin 继续提供角色后台页面。
class RoleAdmin(SuperuserOnlyAdminMixin, admin.ModelAdmin):
list_display = ("key", "name", "is_system", "is_active", "updated_at")
list_filter = ("is_system", "is_active")
search_fields = ("key", "name", "description")
ordering = ("name", "key")
def get_readonly_fields(self, request, obj=None):
# 输入:后台 request 与可选 Role 对象;输出:只读字段元组;调用者:Django ModelAdmin 表单构建流程;失败:对象属性异常时继续上抛。
# 业务判断:系统标记始终只读,不能从后台把自定义角色提升为系统角色。
fields = ["is_system"]
if obj is not None and obj.is_system:
# 业务判断:已有系统角色的稳定编码由代码目录维护,不允许后台改名。
fields.append("key")
# 返回:Django Admin 要求的不可变字段名元组。
return tuple(fields)
# 该后台基类在 SuperuserOnlyAdminMixin 与 ModelAdmin 之上继续覆盖新增、修改权限,形成只读关联审计页。
class ReadOnlyAssignmentAdmin(SuperuserOnlyAdminMixin, admin.ModelAdmin):
# 关联表在学习端通过带事务和越权检查的服务流程写入。
def has_add_permission(self, request):
# 输入:后台 request;输出:固定 False;调用者:Django Admin 权限框架;失败:本方法不查询数据库,正常情况下不会主动失败。
# 业务判断与返回:禁止从后台绕过业务流程手工新增授权关联。
return False
def has_change_permission(self, request, obj=None):
# 输入:后台 request 与可选关联对象;输出:固定 False;调用者:Django Admin 权限框架;失败:本方法不查询数据库,正常情况下不会主动失败。
# 业务判断与返回:授权关联只供后台只读审计,不允许直接编辑。
return False
@admin.register(RoleCapability)
# 单继承 ReadOnlyAssignmentAdmin,继承其超级用户限定和禁止增删改策略,只配置列表展示。
class RoleCapabilityAdmin(ReadOnlyAssignmentAdmin):
list_display = ("role", "capability", "assigned_by", "created_at")
list_filter = ("role", "capability")
search_fields = ("role__name", "capability__key", "capability__name")
ordering = ("role", "capability")
@admin.register(UserRole)
# UserRoleAdmin 继承 ReadOnlyAssignmentAdmin,复用其超级用户限定和禁止新增、修改、删除的只读审计策略。
class UserRoleAdmin(ReadOnlyAssignmentAdmin):
list_display = ("user", "role", "assigned_by", "created_at")
list_filter = ("role",)
search_fields = ("user__username", "user__display_name", "role__name")
ordering = ("user", "role")
9.3 Admin 函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
10. 初始化命令:幂等同步不是简单 get-or-create
迁移负责数据库历史,bootstrap 负责“今天代码声明的系统目录应该是什么”。每次运行先验证目录类型、必填字符串、命名空间、重复 key/名称、角色引用和派生常量;然后在一个原子事务里按网页相同顺序加锁。
命令拒绝接管被非系统行占用的保留 key;修复名称、说明、系统标志、激活状态等漂移;精确删除系统角色多余关系并补齐缺失关系;把代码中已经删除的系统能力停用并解除系统身份;把退出目录的系统角色清空能力和用户关系、重命名并停用。名称冲突的旧自定义角色会被安全重命名,其成员关系保留。任何中途错误都会回滚整批变更。
完整文件:accounts/management/commands/bootstrap_rbac.py
# accounts/management/commands/bootstrap_rbac.py
# BaseCommand 定义管理命令接口;CommandError 会让 manage.py 以清晰错误信息和失败状态结束。
from django.core.management.base import BaseCommand, CommandError
# transaction.atomic() 为整次目录同步提供提交/回滚边界。
from django.db import transaction
# 绝对导入代码中的能力/角色声明及其派生键集合,用于验证和精确同步。
from accounts.capabilities import CAPABILITY_CATALOG, CAPABILITY_KEYS, ROLE_CATALOG, ROLE_KEYS
# 导入同步主体和两张授权中间表;ORM 写入都通过这些模型执行。
from accounts.models import Capability, Role, RoleCapability, UserRole
def validate_catalog():
# 输入:无显式参数,读取 capabilities.py 中的目录常量;输出:校验通过时自然返回 None;调用者:Command.handle();失败:结构、字段、重复项或引用不一致时抛出 CommandError。
# 业务判断:能力目录必须是非空列表或元组,避免初始化命令把空配置当成合法目录。
if not isinstance(CAPABILITY_CATALOG, (list, tuple)) or not CAPABILITY_CATALOG:
raise CommandError("能力目录必须是非空列表或元组。")
# 业务判断:逐项验证能力字典结构、必填字符串和命名空间,同时收集编码用于全局一致性检查。
capability_keys = []
for index, item in enumerate(CAPABILITY_CATALOG, start=1):
if not isinstance(item, dict):
raise CommandError("第 %s 项能力定义必须是字典。" % index)
for field_name in ("key", "name", "description"):
value = item.get(field_name)
if not isinstance(value, str) or not value.strip():
raise CommandError(
"第 %s 项能力缺少有效的 %s。" % (index, field_name)
)
if not item["key"].startswith("accounts."):
raise CommandError("能力编码必须使用 accounts.* 命名空间。")
capability_keys.append(item["key"])
# 业务判断:能力编码不能重复,且目录派生结果必须与 CAPABILITY_KEYS 完全一致。
if len(capability_keys) != len(set(capability_keys)):
raise CommandError("能力目录存在重复编码。")
if set(capability_keys) != set(CAPABILITY_KEYS):
raise CommandError("能力编码常量与能力目录不一致。")
# 业务判断:角色目录同样必须是非空列表或元组。
if not isinstance(ROLE_CATALOG, (list, tuple)) or not ROLE_CATALOG:
raise CommandError("角色目录必须是非空列表或元组。")
# 业务判断:逐项验证角色字段、能力集合及其引用,同时收集编码和名称检查唯一性。
role_keys = []
role_names = []
for index, role in enumerate(ROLE_CATALOG, start=1):
if not isinstance(role, dict):
raise CommandError("第 %s 项角色定义必须是字典。" % index)
for field_name in ("key", "name", "description"):
value = role.get(field_name)
if not isinstance(value, str) or not value.strip():
raise CommandError(
"第 %s 项角色缺少有效的 %s。" % (index, field_name)
)
capabilities = role.get("capabilities")
if not isinstance(capabilities, (list, tuple)):
raise CommandError("角色 %s 的能力集合必须是列表或元组。" % role["key"])
if any(not isinstance(key, str) or not key for key in capabilities):
raise CommandError("角色 %s 包含无效能力编码。" % role["key"])
if len(capabilities) != len(set(capabilities)):
raise CommandError("角色 %s 存在重复能力。" % role["key"])
missing = sorted(set(capabilities) - set(capability_keys))
if missing:
raise CommandError(
"角色 %s 引用了不存在的能力:%s。"
% (role["key"], ", ".join(missing))
)
role_keys.append(role["key"])
role_names.append(role["name"])
# 业务判断:角色编码、名称都必须唯一,编码集合还必须与 ROLE_KEYS 完全一致。
if len(role_keys) != len(set(role_keys)):
raise CommandError("角色目录存在重复编码。")
if len(role_names) != len(set(role_names)):
raise CommandError("角色目录存在重复名称。")
if set(role_keys) != set(ROLE_KEYS):
raise CommandError("角色编码常量与角色目录不一致。")
# 返回:全部静态目录检查通过后自然返回 None,当前函数不访问数据库。
# Django 按 management/commands/<文件名>.py 发现命令;类名必须是 Command,文件名 bootstrap_rbac 就是命令名。
# 继承 BaseCommand 后只需声明元数据并实现 handle()。
class Command(BaseCommand):
# help 显示在 manage.py help 中;requires_migrations_checks=True 在执行前提醒未应用迁移,但不替代数据库异常处理。
help = "幂等创建项目自有能力和四个基础角色"
requires_migrations_checks = True
def handle(self, *args, **options):
# 输入:BaseCommand 传入的位置参数和已解析 options;输出:同步完成后自然返回 None,并向 stdout 写摘要;调用者:manage.py bootstrap_rbac;失败:目录无效时抛 CommandError,数据库异常使 atomic() 回滚并继续上抛。
# *args 收集位置参数,**options 收集 BaseCommand 解析后的命名选项;本命令未自定义参数,所以不读取它们。
# 业务判断:任何数据库写入前先验证代码目录,避免用错误配置改动现有授权数据。
validate_catalog()
# 保存准备:计数器分别记录本次实际创建、更新、停用和关联变更数量。
counts = {
"capabilities_created": 0,
"capabilities_updated": 0,
"roles_created": 0,
"roles_updated": 0,
"links_created": 0,
"links_deleted": 0,
"capabilities_retired": 0,
"roles_retired": 0,
}
# atomic() 是整次精确同步的事务边界:正常退出统一提交,异常则回滚其中所有 ORM 写入。
with transaction.atomic():
# list() 强制执行三个原本惰性的 QuerySet;在支持行锁的数据库上,仅锁住此刻已存在且匹配的行,不锁未来插入。
# SQLite 忽略 select_for_update(),因此这些调用在那里没有行锁效果;固定模型/主键顺序只减少支持数据库上的死锁机会。
list(Role.objects.select_for_update().order_by("pk").values_list("pk", flat=True))
list(Capability.objects.select_for_update().order_by("pk").values_list("pk", flat=True))
list(RoleCapability.objects.select_for_update().order_by("pk").values_list("pk", flat=True))
# 保存准备:能力对象索引供角色关联复用,声明编码列表供识别已从代码目录移除的系统项。
capability_map = {}
declared_capability_keys = [item["key"] for item in CAPABILITY_CATALOG]
declared_role_keys = [item["key"] for item in ROLE_CATALOG]
# 数据库读取与业务判断:系统目录不得接管同编码的非系统能力,否则会悄悄改变自定义数据语义。
for item in CAPABILITY_CATALOG:
existing = Capability.objects.filter(key=item["key"]).first()
if existing is not None and not existing.is_system:
raise CommandError(
"能力编码 %s 已被非系统能力占用,拒绝接管。" % item["key"]
)
# 数据库读取与业务判断:系统目录同样拒绝接管同编码的自定义角色。
for item in ROLE_CATALOG:
existing = Role.objects.filter(key=item["key"]).first()
if existing is not None and not existing.is_system:
raise CommandError(
"角色编码 %s 已被非系统角色占用,拒绝接管。" % item["key"]
)
# “精确同步”不仅补齐声明项:过期系统项会退出系统目录并停用,系统角色的能力关联也会删多补少到完全等于代码声明。
# 数据库读取:找出仍标记为系统内置、但已不在当前代码目录中的过期能力和角色。
stale_capabilities = Capability.objects.filter(is_system=True).exclude(
key__in=declared_capability_keys
)
stale_roles = list(
Role.objects.filter(is_system=True).exclude(key__in=declared_role_keys)
)
# 保存阶段:过期能力退出系统目录并停用,但保留记录用于历史数据可追溯。
counts["capabilities_retired"] = stale_capabilities.update(
is_system=False,
is_active=False,
)
# 保存阶段:逐个退休过期系统角色,清除授权关联并生成不会占用原系统名称的停用名称。
for stale_role in stale_roles:
# 业务判断:退休角色不能继续携带旧能力和用户成员关系;仅停用会允许日后误启用并恢复旧授权。
RoleCapability.objects.filter(role=stale_role).delete()
UserRole.objects.filter(role=stale_role).delete()
# 保存准备:名称字段唯一,先截断基础名称,再在冲突时附加主键和递增编号。
base_name = "[已停用] %s" % stale_role.name
candidate_name = base_name[:100]
counter = 0
while Role.objects.filter(name=candidate_name).exclude(
pk=stale_role.pk
).exists():
counter += 1
suffix = "-%s-%s" % (stale_role.pk, counter)
candidate_name = "%s%s" % (
base_name[: 100 - len(suffix)],
suffix,
)
# 保存阶段:直接 update() 解除系统标记并停用,避免模型保留名称校验阻挡目录修复。
Role.objects.filter(pk=stale_role.pk).update(
name=candidate_name,
is_system=False,
is_active=False,
)
counts["roles_retired"] = len(stale_roles)
# 数据库读取与业务判断:检查系统角色名称是否被其他编码的历史或自定义角色占用。
for item in ROLE_CATALOG:
conflict = Role.objects.filter(name=item["name"]).exclude(
key=item["key"]
).first()
if conflict is not None:
# 早期版本只保留系统角色编码,没有保留中文名称。
# 这里保留冲突角色本身及其成员关系,只为它生成一个不冲突的新名称,
# 让初始化命令仍能恢复代码目录声明的系统角色。
base_name = "[名称冲突] %s" % conflict.name
candidate_name = base_name[:100]
counter = 0
while Role.objects.filter(name=candidate_name).exclude(
pk=conflict.pk
).exists():
counter += 1
suffix = "-%s-%s" % (conflict.pk, counter)
candidate_name = "%s%s" % (
base_name[: 100 - len(suffix)],
suffix,
)
# 保存阶段:只重命名冲突角色,保留它本身及其用户成员关系。
Role.objects.filter(pk=conflict.pk).update(name=candidate_name)
# 保存阶段:逐项创建缺失能力,或把已有系统能力精确更新为代码目录状态。
for item in CAPABILITY_CATALOG:
capability, created = Capability.objects.get_or_create(
key=item["key"],
defaults={
"name": item["name"],
"description": item["description"],
"is_system": True,
"is_active": True,
},
)
# 业务判断:新建项累计 created;已有项先比较业务字段,只有实际漂移才累计 updated。
if created:
counts["capabilities_created"] += 1
else:
changed = (
capability.name != item["name"]
or capability.description != item["description"]
or not capability.is_system
or not capability.is_active
)
Capability.objects.filter(pk=capability.pk).update(
name=item["name"],
description=item["description"],
is_system=True,
is_active=True,
)
if changed:
counts["capabilities_updated"] += 1
# 保存准备:无论新建还是更新,都缓存能力对象供角色能力关联查主键。
capability_map[item["key"]] = capability
# 保存阶段:逐项创建或更新系统角色,并把能力关联精确同步为目录声明集合。
for item in ROLE_CATALOG:
role, created = Role.objects.get_or_create(
key=item["key"],
defaults={
"name": item["name"],
"description": item["description"],
"is_system": True,
"is_active": True,
},
)
# 业务判断:区分新建与已存在角色,并只在目录字段确有漂移时记录更新计数。
if created:
counts["roles_created"] += 1
else:
changed = (
role.name != item["name"]
or role.description != item["description"]
or not role.is_system
or not role.is_active
)
Role.objects.filter(pk=role.pk).update(
name=item["name"],
description=item["description"],
is_system=True,
is_active=True,
)
if changed:
counts["roles_updated"] += 1
# 数据库读取与业务判断:比较目录期望能力主键和当前中间表主键,计算应删与应增差集。
expected_ids = {
capability_map[key].pk for key in item["capabilities"]
}
existing_ids = set(
RoleCapability.objects.filter(role=role).values_list(
"capability_id", flat=True
)
)
extra_ids = existing_ids - expected_ids
# 保存阶段:先删除目录外的多余角色能力关联,并累计数据库实际删除行数。
if extra_ids:
deleted, _ = RoleCapability.objects.filter(
role=role,
capability_id__in=extra_ids,
).delete()
counts["links_deleted"] += deleted
# 保存阶段:按主键排序补齐缺失关联,使多次运行结果一致且输出稳定。
for capability_id in sorted(expected_ids - existing_ids):
_, created = RoleCapability.objects.get_or_create(
role=role,
capability_id=capability_id,
)
if created:
counts["links_created"] += 1
# 返回输出:事务成功提交后,把各类变更计数写成一行绿色成功消息;handle() 随后自然返回 None。
self.stdout.write(
self.style.SUCCESS(
"能力:新建 %s、更新 %s、停用 %s;角色:新建 %s、更新 %s、停用 %s;角色能力:新建 %s、删除 %s。"
% (
counts["capabilities_created"],
counts["capabilities_updated"],
counts["capabilities_retired"],
counts["roles_created"],
counts["roles_updated"],
counts["roles_retired"],
counts["links_created"],
counts["links_deleted"],
)
)
)
10.1 命令函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
11. CMDB 如何接入同一全局 Policy
本节只说明跨 App 集成,不把 CMDB 文件复制进当前 accounts 阶段。第 3 篇的 Automation/CMDB 整合项目会把 cmdb.* 能力加入全局目录,并把 bootstrap 的命名空间校验扩展为已登记命名空间;不要直接把 CMDB key 塞进本文只允许 accounts.* 的独立命令。
接入原则是:HTML 视图、JSON API、服务入口和模板都引用 accounts.capabilities 的稳定常量,并调用本文同一套 helper。不要在 CMDB 里另造授权缓存,也不要让模板可见性取代后端检查。
11.1 HTML 首页:任一能力进入,按单项能力查询数据
# cmdb/views.py:全局 Policy 接入示例
# 导入 login_required 只确认用户已经登录;它不会判断 CMDB 业务能力。
from django.contrib.auth.decorators import login_required
# PermissionDenied 是 Django 的拒绝异常,未捕获时框架返回 403。
from django.core.exceptions import PermissionDenied
# render 用上下文渲染 CMDB 首页模板并生成 HttpResponse。
from django.shortcuts import render
# 四个常量分别代表云账号、云厂商、计算实例和同步记录的查看能力,避免在视图中手写字符串。
from accounts.capabilities import (
CMDB_CLOUD_ACCOUNT_VIEW,
CMDB_CLOUD_PROVIDER_VIEW,
CMDB_COMPUTE_INSTANCE_VIEW,
CMDB_SYNC_RUN_VIEW,
)
# has_any_capability 判断是否至少命中一项;has_capability 判断单项,并在同一 request 内复用策略快照。
from accounts.policy import has_any_capability, has_capability
# 四个模型分别提供首页按能力裁剪后的统计与最近同步记录查询。
from .models import CloudAccount, CloudProvider, ComputeInstance, SyncRun
@login_required
def home(request):
# 输入:已经通过 Django 登录认证的 request;输出:按能力裁剪的 CMDB 首页;调用者:CMDB 首页路由;失败:没有任一查看能力时抛 PermissionDenied,数据库异常向上抛出。
# 业务判断:先定义允许进入首页的能力集合;这里是 any-of,用户具备任意一个模块的查看能力即可进入。
view_capabilities = (
CMDB_CLOUD_PROVIDER_VIEW,
CMDB_CLOUD_ACCOUNT_VIEW,
CMDB_COMPUTE_INSTANCE_VIEW,
CMDB_SYNC_RUN_VIEW,
)
# 业务判断:入口级检查必须在任何 CMDB 查询之前执行,避免无权用户借统计结果探测资源数量。
if not has_any_capability(request, view_capabilities):
raise PermissionDenied
# 授权读取:同一个 request 会复用 PolicySnapshot;四次判断不会重复查询角色与能力。
can_view_providers = has_capability(request, CMDB_CLOUD_PROVIDER_VIEW)
can_view_accounts = has_capability(request, CMDB_CLOUD_ACCOUNT_VIEW)
can_view_instances = has_capability(request, CMDB_COMPUTE_INSTANCE_VIEW)
can_view_sync_runs = has_capability(request, CMDB_SYNC_RUN_VIEW)
# 数据库读取:每项统计都先检查对应能力;无权项返回 None,而且不会执行该项 ORM 查询。
# 不能先查询全部统计再在模板隐藏,因为后端查询本身已经读取了无权数据。
# 数据库读取:每项统计都先检查对应能力;无权项返回 None,而且不会执行该项 ORM 查询。
# 不能先查询全部统计再在模板隐藏,因为后端查询本身已经读取了无权数据。
context = {
"can_view_providers": can_view_providers,
"can_view_accounts": can_view_accounts,
"can_view_instances": can_view_instances,
"can_view_sync_runs": can_view_sync_runs,
"provider_count": (
CloudProvider.objects.filter(is_active=True).count()
if can_view_providers
else None
),
"account_count": (
CloudAccount.objects.filter(is_active=True).count()
if can_view_accounts
else None
),
"instance_count": (
ComputeInstance.objects.exclude(
lifecycle_state=ComputeInstance.LifecycleState.RETIRED
).count()
if can_view_instances
else None
),
"missing_count": (
ComputeInstance.objects.filter(
lifecycle_state=ComputeInstance.LifecycleState.MISSING
).count()
if can_view_instances
else None
),
"recent_sync_runs": (
SyncRun.objects.select_related("account", "account__provider")[:5]
if can_view_sync_runs
else None
),
}
# 返回:模板只收到已经按能力裁剪的数据和显隐标记。
# 返回:模板只收到已经按能力裁剪的数据和显隐标记。
return render(request, "cmdb/home.html", context)
11.2 JSON API:同一装饰器保护机器可读响应
# cmdb/api_views.py:JSON API 接入示例
# 导入 Paginator 对有序 QuerySet 分页,并提供总数、当前页和总页数。
from django.core.paginator import Paginator
# JsonResponse 把 Python 字典或列表序列化为 JSON HTTP 响应。
from django.http import JsonResponse
# require_GET 拒绝非 GET 方法并返回 405,避免同一路由接受未设计的写请求。
from django.views.decorators.http import require_GET
# CMDB_CLOUD_ACCOUNT_VIEW 是云账号读取接口要求的稳定业务能力编码。
from accounts.capabilities import CMDB_CLOUD_ACCOUNT_VIEW
# capability_required 先检查登录,再通过项目 Policy 检查上面的全局能力。
from accounts.decorators import capability_required
# CloudAccount 提供分页数据;select_related 会一并取得它关联的 provider。
from .models import CloudAccount
API_VERSION = "v1"
def _json_response(data, status=200):
# 输入:可被 JSON 序列化的数据和 HTTP 状态码;输出:保留中文的 JsonResponse;调用者:CMDB API 视图;失败:数据不可序列化时由 Django 抛出异常。
# 返回:ensure_ascii=False 让中文直接出现在响应体,不能替代 HTTP 的 UTF-8 Content-Type。
return JsonResponse(
data,
status=status,
json_dumps_params={"ensure_ascii": False},
)
@capability_required(CMDB_CLOUD_ACCOUNT_VIEW)
@require_GET
def account_list(request):
# 输入:具备云账号查看能力的 GET request;输出:分页云账号 JSON;调用者:CMDB API 路由;失败:匿名 302、缺能力 403、非 GET 405、非法 page_size 400,数据库异常向上抛出。
# 数据库准备:select_related 在读取账号时联表取得 provider,避免序列化循环产生 N+1 查询。
accounts = CloudAccount.objects.select_related("provider").order_by(
"provider__code",
"account_key",
)
# 输入校验:客户端只能请求 1~100 条;先转整数,再用 max/min 夹住边界。
# 输入校验:客户端只能请求 1~100 条;先转整数,再用 max/min 夹住边界。
try:
page_size = min(max(int(request.GET.get("page_size", 50)), 1), 100)
except ValueError:
return _json_response(
{"api_version": API_VERSION, "error": "page_size 必须是整数。"},
status=400,
)
# 数据库读取:Paginator 在需要时执行总数与当前页查询;get_page 会把越界页码归一到有效页。
# 数据库读取:Paginator 在需要时执行总数与当前页查询;get_page 会把越界页码归一到有效页。
page_obj = Paginator(accounts, page_size).get_page(request.GET.get("page"))
# 数据整理:只序列化已批准的非秘密字段;凭据资料不能因为账号可见就进入 API。
# 数据整理:只序列化已批准的非秘密字段;凭据资料不能因为账号可见就进入 API。
data = [
{
"id": account.id,
"provider": account.provider.code,
"account_key": account.account_key,
"name": account.name,
"credential_profile": account.credential_profile,
"region_allowlist": account.region_allowlist,
"is_active": account.is_active,
"sync_enabled": account.sync_enabled,
"last_successful_sync_at": (
account.last_successful_sync_at.isoformat()
if account.last_successful_sync_at
else None
),
}
for account in page_obj
]
# 返回:响应同时给出总数、当前页、总页数和当前页记录,便于前端稳定分页。
# 返回:响应同时给出总数、当前页、总页数和当前页记录,便于前端稳定分页。
return _json_response(
{
"api_version": API_VERSION,
"count": page_obj.paginator.count,
"page": page_obj.number,
"pages": page_obj.paginator.num_pages,
"results": data,
}
)
11.3 CMDB 模板:请求快照复用
{# 模板片段:只展示 CMDB 导航按钮如何复用 Policy 结果,不是完整模板文件。 #}
{# load 会加载项目自定义 policy_tags 标签库,之后才能调用 policy_can。 #}
{% load policy_tags %}
{% policy_can "cmdb.cloudaccount.create" as can_create_account %}
{% policy_can "cmdb.cloudaccount.view" as can_view_account %}
{% policy_can "cmdb.computeinstance.view" as can_view_instance %}
{% if can_create_account and can_view_account %}
<a class="button" href="{% url 'cmdb:account_create' %}">添加云账号</a>
{% endif %}
{% if can_view_instance %}
<a class="button button-secondary" href="{% url 'cmdb:instance_list' %}">查看资产</a>
{% endif %}
11.4 CMDB 示例函数契约
输入、输出、调用者、数据库读取、业务检查、写入、返回与失败路径已经紧贴上方函数逐段说明;这里不再重复转录函数合同表。
同一规则也适用于写 API:例如 HTML 同步入口可用 all_capabilities_required((CMDB_CLOUD_ACCOUNT_SYNC, CMDB_CLOUD_ACCOUNT_VIEW)),API 同步入口至少用 capability_required(CMDB_CLOUD_ACCOUNT_SYNC),再叠加 require_POST。装饰器顺序意味着未授权者先被拒绝;已授权却使用 GET 的调用者得到 405。
这里的 API 使用浏览器 Session 认证,所以匿名仍会按登录装饰器得到 302。将来若引入令牌认证,可以替换认证层对匿名的响应格式,但业务能力判断仍应调用同一 require_* 语义。
12. 第 3 篇边界
第 3 篇固定地址是 https://www.cnblogs.com/lizexiong/p/22712579。它先给出本篇管理端的全部模板、命令执行顺序和浏览器验收清单,再进入 Automation 对象授权。
模板中的 policy_can 只控制按钮和导航是否显示,不能替代本篇已经完成的后端装饰器、事务锁和锁后复检。跨篇只是为了按完整文件边界控制字符数,不代表后端可以先省略或用伪代码代替。

浙公网安备 33010602011771号