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:
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user