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)
+148
View File
@@ -1,5 +1,6 @@
"""apps.core 基础单元测试:SEOablePage 抽象基类字段与公共 Block。"""
import pytest
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from apps.core.blocks import FormBlock, TeamMemberChooserBlock
@@ -215,3 +216,150 @@ def test_site_settings_api_returns_defaults():
assert response.status_code == 200
assert response.data["icp_url"] == "https://beian.miit.gov.cn/"
# ---------------------------------------------------------------------------
# RBAC 权限组(setup_rbac_groups 管理命令)
# ---------------------------------------------------------------------------
def test_setup_rbac_groups_creates_all_expected_groups():
from django.contrib.auth.models import Group
from django.core.management import call_command
call_command("setup_rbac_groups")
expected_names = {"站点管理员", "编辑", "审核员", "作者", "查看者"}
assert expected_names <= set(Group.objects.values_list("name", flat=True))
def test_setup_rbac_groups_grants_access_admin_to_all_groups():
from django.contrib.auth.models import Group
from django.core.management import call_command
call_command("setup_rbac_groups")
for name in ["站点管理员", "编辑", "审核员", "作者", "查看者"]:
group = Group.objects.get(name=name)
assert group.permissions.filter(codename="access_admin").exists()
def test_setup_rbac_groups_page_permissions_match_role_matrix():
from django.contrib.auth.models import Group
from django.core.management import call_command
from wagtail.models import GroupPagePermission, Page
call_command("setup_rbac_groups")
root_page = Page.objects.get(depth=1)
def page_codenames(group_name):
group = Group.objects.get(name=group_name)
return set(
GroupPagePermission.objects.filter(group=group, page=root_page).values_list(
"permission__codename", flat=True
)
)
# 站点管理员拥有全部页面权限
assert page_codenames("站点管理员") == {
"add_page", "change_page", "publish_page", "delete_page",
"bulk_delete_page", "lock_page", "unlock_page", "view_page",
}
# 编辑可以创建/编辑但不能发布
editor_perms = page_codenames("编辑")
assert editor_perms == {"add_page", "change_page", "view_page"}
assert "publish_page" not in editor_perms
# 审核员可以编辑并发布(用于审批发布场景)
assert page_codenames("审核员") == {"change_page", "publish_page", "view_page"}
# 作者只有 add + view:只能创建页面,仅能编辑自己拥有的页面(Wagtail 内置 ownership 策略)
assert page_codenames("作者") == {"add_page", "view_page"}
assert "change_page" not in page_codenames("作者")
# 查看者仅能查看
assert page_codenames("查看者") == {"view_page"}
def test_setup_rbac_groups_author_can_only_edit_own_pages(home_page):
"""验证“Author 仅能编辑自己的页面”这一矩阵要求,由 Wagtail 内置的
OwnershipPermissionPolicy(仅 add 权限)自然实现,无需自定义 hook。"""
from django.contrib.auth.models import Group
from django.core.management import call_command
call_command("setup_rbac_groups")
User = get_user_model()
author = User.objects.create_user(username="author-user", password="pass1234")
other_author = User.objects.create_user(username="other-author", password="pass1234")
author.groups.add(Group.objects.get(name="作者"))
other_author.groups.add(Group.objects.get(name="作者"))
own_page = SimpleContentPage(title="自己的页面", slug="own-page", owner=author)
home_page.add_child(instance=own_page)
others_page = SimpleContentPage(title="他人的页面", slug="others-page", owner=other_author)
home_page.add_child(instance=others_page)
assert own_page.permissions_for_user(author).can_edit() is True
assert others_page.permissions_for_user(author).can_edit() is False
def test_setup_rbac_groups_snippet_permissions_restrict_lead_to_admin_and_reviewer():
from django.contrib.auth.models import Group
from django.core.management import call_command
call_command("setup_rbac_groups")
def lead_codenames(group_name):
group = Group.objects.get(name=group_name)
return set(
group.permissions.filter(content_type__app_label="forms", codename__endswith="lead")
.values_list("codename", flat=True)
)
assert lead_codenames("站点管理员") == {"add_lead", "change_lead", "delete_lead", "view_lead"}
assert lead_codenames("审核员") == {"view_lead"}
assert lead_codenames("编辑") == set()
assert lead_codenames("作者") == set()
assert lead_codenames("查看者") == set()
def test_setup_rbac_groups_only_site_admin_has_user_management_permissions():
from django.contrib.auth.models import Group
from django.core.management import call_command
call_command("setup_rbac_groups")
User = get_user_model()
user_ct_app_label = User._meta.app_label
for name in ["编辑", "审核员", "作者", "查看者"]:
group = Group.objects.get(name=name)
assert not group.permissions.filter(
content_type__app_label=user_ct_app_label, content_type__model="user"
).exists()
site_admin = Group.objects.get(name="站点管理员")
site_admin_codenames = set(
site_admin.permissions.filter(
content_type__app_label=user_ct_app_label, content_type__model="user"
).values_list("codename", flat=True)
)
assert site_admin_codenames == {"add_user", "change_user", "delete_user", "view_user"}
def test_setup_rbac_groups_is_idempotent():
from django.contrib.auth.models import Group
from django.core.management import call_command
from wagtail.models import GroupPagePermission, Page
call_command("setup_rbac_groups")
call_command("setup_rbac_groups")
assert Group.objects.filter(name="站点管理员").count() == 1
group = Group.objects.get(name="站点管理员")
root_page = Page.objects.get(depth=1)
# 重复运行不应产生重复的页面权限记录
assert GroupPagePermission.objects.filter(
group=group, page=root_page, permission__codename="add_page"
).count() == 1
assert group.permissions.filter(codename="access_admin").count() == 1