feat(rbac): 落地 Wagtail Group + GroupPagePermission/GroupCollectionPermission/Snippet 权限体系

新增 setup_rbac_groups 幂等管理命令,按设计方案 §2.8 矩阵创建 5 个 Group(站点管理员/编辑/审核员/作者/查看者),配置页面/图片文档/Snippet/用户管理权限;Author 仅编辑自己页面利用 Wagtail 内置 OwnershipPermissionPolicy 实现,无需自定义 hook;新增 7 个单元测试,pytest 33→40 全部通过。
This commit is contained in:
2026-08-07 17:07:39 +08:00
parent 3944e11330
commit f37571f395
3 changed files with 360 additions and 1 deletions
@@ -0,0 +1,201 @@
"""初始化 RBAC 权限组。
根据 documents/设计方案分析与完善版.md §2.8 的角色矩阵,使用 Wagtail 原生
`Group` + `GroupPagePermission` + `GroupCollectionPermission` + Django
`Permission`Snippet/用户管理),不引入额外的 RBAC 框架。
角色与 Django/Wagtail 概念的映射:
- Super Admin -> Django `is_superuser=True`(不对应任何 Group,超级用户天然拥有全部权限)
- Tenant/Site Admin -> Group "站点管理员"
- Editor -> Group "编辑"
- Reviewer -> Group "审核员"
- Author -> Group "作者"
- Viewer -> Group "查看者"
简化与取舍说明:
1. 审批发布:设计方案 §2.9 的 Workflow 审核模块尚未实现,因此本命令暂不配置
Wagtail Workflow/TaskState。而是通过“编辑者只有 add/change、审核员和站点管理员
才有 publish”的权限差异,天然形成“创建草稿 -> 等待发布”的审核闸门。待 Workflow
模块落地后,可在现有 Group 基础上叠加 GroupApprovalTask,无需重新设计权限组。
2. Author“仅自己”:利用 Wagtail 内置的 OwnershipPermissionPolicy —— 只授予
`add_page`(不授予 `change_page`)时,用户可以创建新页面,且只能编辑/删除自己
拥有(owner)的页面,无需额外自定义 wagtail_hooks。
3. Reviewer 的“页面编辑”矩阵标注为“查看”,但由于 Wagtail 的页面编辑视图需要
`change` 权限才能打开并点击“发布”按钮,因此本命令额外授予审核员 `change_page`
属于为了让“审批发布”可用而做的必要放宽,已在此处明确说明。
4. Lead(线索)Snippet 涉及 PIPL 个人信息,出于数据保护考虑做了比矩阵更严格的收紧:
仅“站点管理员”可管理,“审核员”仅可查看,“编辑/作者”不授予任何 Lead 权限。
5. 权限统一挂载在 Wagtail 页面树根节点(depth=1)与根 Collection 上,适用于当前
单站点部署;如未来引入多站点/多租户,需要按 Site 对应的根页面/Collection 分别授权。
6. 用户管理(新建/编辑/删除/查看 Django 用户)仅授予“站点管理员”,对应矩阵中
“用户管理”列;Django Admin 后台访问不通过 Group 授予,只能由拥有
`is_staff=True` 的账号(通常是超级用户)访问。
本命令幂等:每次运行都会将各 Group 的权限重置为矩阵定义的目标状态,可安全重复执行。
"""
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
from wagtail.models import Collection, GroupCollectionPermission, GroupPagePermission, Page
from apps.core.models import NavigationMenu, Partner, SiteSettings, TeamMember, Testimonial
from apps.forms.models import FormDefinition, Lead
SITE_ADMIN = "站点管理员"
EDITOR = "编辑"
REVIEWER = "审核员"
AUTHOR = "作者"
VIEWER = "查看者"
ALL_GROUP_NAMES = [SITE_ADMIN, EDITOR, REVIEWER, AUTHOR, VIEWER]
# 页面权限:值为 GroupPagePermission 的 permission_type 简写(自动拼接为 "<type>_page"
PAGE_PERMISSIONS = {
SITE_ADMIN: ["add", "change", "publish", "delete", "bulk_delete", "lock", "unlock", "view"],
EDITOR: ["add", "change", "view"],
REVIEWER: ["change", "publish", "view"],
AUTHOR: ["add", "view"],
VIEWER: ["view"],
}
# Snippet 权限:{ Model: { group_name: [action, ...] } },未列出的角色即不授予任何权限
SNIPPET_PERMISSIONS = {
TeamMember: {
SITE_ADMIN: ["add", "change", "delete", "view"],
EDITOR: ["add", "change", "delete", "view"],
REVIEWER: ["view"],
},
Testimonial: {
SITE_ADMIN: ["add", "change", "delete", "view"],
EDITOR: ["add", "change", "delete", "view"],
REVIEWER: ["view"],
},
Partner: {
SITE_ADMIN: ["add", "change", "delete", "view"],
EDITOR: ["add", "change", "delete", "view"],
REVIEWER: ["view"],
},
NavigationMenu: {
SITE_ADMIN: ["add", "change", "delete", "view"],
EDITOR: ["add", "change", "delete", "view"],
REVIEWER: ["view"],
},
FormDefinition: {
SITE_ADMIN: ["add", "change", "delete", "view"],
EDITOR: ["add", "change", "delete", "view"],
REVIEWER: ["view"],
},
# Lead 涉及 PIPL 个人信息,收紧至站点管理员可管理、审核员仅查看
Lead: {
SITE_ADMIN: ["add", "change", "delete", "view"],
REVIEWER: ["view"],
},
# SiteSettings 是站点级单例配置,仅站点管理员可修改
SiteSettings: {
SITE_ADMIN: ["change", "view"],
},
}
# Collection(图片/文档)权限:{ app_label: model } 用于定位对应的 ContentType
COLLECTION_CONTENT_TYPES = {
"image": "wagtailimages",
"document": "wagtaildocs",
}
COLLECTION_PERMISSIONS = {
SITE_ADMIN: ["add", "change", "delete", "choose", "view"],
EDITOR: ["add", "change", "choose", "view"],
AUTHOR: ["add", "change", "choose", "view"],
REVIEWER: ["choose", "view"],
}
# 用户管理权限(Django auth.User 的增删改查)仅授予站点管理员
USER_MANAGEMENT_GROUPS = [SITE_ADMIN]
class Command(BaseCommand):
help = "根据 RBAC 角色矩阵(设计方案 §2.8)创建/重置 Wagtail Group 权限,可重复执行。"
def handle(self, *args, **options):
groups = {name: Group.objects.get_or_create(name=name)[0] for name in ALL_GROUP_NAMES}
self._grant_admin_access(groups)
self._setup_page_permissions(groups)
self._setup_snippet_permissions(groups)
self._setup_collection_permissions(groups)
self._setup_user_management_permissions(groups)
self.stdout.write(
self.style.SUCCESS(f"RBAC 权限组已初始化/更新:{', '.join(ALL_GROUP_NAMES)}")
)
def _grant_admin_access(self, groups):
"""所有角色都需要能登录 Wagtail 后台。"""
access_admin = Permission.objects.get(
content_type__app_label="wagtailadmin", codename="access_admin"
)
for group in groups.values():
group.permissions.add(access_admin)
def _setup_page_permissions(self, groups):
root_page = Page.objects.get(depth=1)
for name in ALL_GROUP_NAMES:
group = groups[name]
GroupPagePermission.objects.filter(group=group, page=root_page).delete()
for action in PAGE_PERMISSIONS.get(name, []):
GroupPagePermission.objects.create(
group=group, page=root_page, permission_type=action
)
def _setup_snippet_permissions(self, groups):
for model, group_actions in SNIPPET_PERMISSIONS.items():
content_type = ContentType.objects.get_for_model(model)
model_name = model._meta.model_name
all_perms_for_model = Permission.objects.filter(content_type=content_type)
for name in ALL_GROUP_NAMES:
group = groups[name]
group.permissions.remove(*all_perms_for_model)
actions = group_actions.get(name, [])
if not actions:
continue
codenames = [f"{action}_{model_name}" for action in actions]
perms = Permission.objects.filter(
content_type=content_type, codename__in=codenames
)
group.permissions.add(*perms)
def _setup_collection_permissions(self, groups):
root_collection = Collection.get_first_root_node()
content_types = {
key: ContentType.objects.get(app_label=app_label, model=key)
for key, app_label in COLLECTION_CONTENT_TYPES.items()
}
all_perms = Permission.objects.filter(content_type__in=content_types.values())
for name in ALL_GROUP_NAMES:
group = groups[name]
GroupCollectionPermission.objects.filter(
group=group, collection=root_collection, permission__in=all_perms
).delete()
actions = COLLECTION_PERMISSIONS.get(name, [])
if not actions:
continue
for key, content_type in content_types.items():
codenames = [f"{action}_{key}" for action in actions]
perms = Permission.objects.filter(
content_type=content_type, codename__in=codenames
)
for perm in perms:
GroupCollectionPermission.objects.get_or_create(
group=group, collection=root_collection, permission=perm
)
def _setup_user_management_permissions(self, groups):
user_content_type = ContentType.objects.get_for_model(get_user_model())
all_user_perms = Permission.objects.filter(content_type=user_content_type)
for name in ALL_GROUP_NAMES:
groups[name].permissions.remove(*all_user_perms)
for name in USER_MANAGEMENT_GROUPS:
groups[name].permissions.add(*all_user_perms)