From f37571f395923cda5373b22aa927bba06deaa65b Mon Sep 17 00:00:00 2001 From: Zhengen TANG Date: Fri, 7 Aug 2026 17:02:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(rbac):=20=E8=90=BD=E5=9C=B0=20Wagtail=20Gr?= =?UTF-8?q?oup=20+=20GroupPagePermission/GroupCollectionPermission/Snippet?= =?UTF-8?q?=20=E6=9D=83=E9=99=90=E4=BD=93=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 setup_rbac_groups 幂等管理命令,按设计方案 §2.8 矩阵创建 5 个 Group(站点管理员/编辑/审核员/作者/查看者),配置页面/图片文档/Snippet/用户管理权限;Author 仅编辑自己页面利用 Wagtail 内置 OwnershipPermissionPolicy 实现,无需自定义 hook;新增 7 个单元测试,pytest 33→40 全部通过。 --- .../management/commands/setup_rbac_groups.py | 201 ++++++++++++++++++ apps/core/tests.py | 148 +++++++++++++ documents/设计方案分析与完善版.md | 12 +- 3 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 apps/core/management/commands/setup_rbac_groups.py diff --git a/apps/core/management/commands/setup_rbac_groups.py b/apps/core/management/commands/setup_rbac_groups.py new file mode 100644 index 0000000..b710883 --- /dev/null +++ b/apps/core/management/commands/setup_rbac_groups.py @@ -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 简写(自动拼接为 "_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) diff --git a/apps/core/tests.py b/apps/core/tests.py index 971f69c..e77d7e7 100644 --- a/apps/core/tests.py +++ b/apps/core/tests.py @@ -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 diff --git a/documents/设计方案分析与完善版.md b/documents/设计方案分析与完善版.md index cc3a1d5..99493b7 100644 --- a/documents/设计方案分析与完善版.md +++ b/documents/设计方案分析与完善版.md @@ -280,6 +280,16 @@ Next.js 侧对应 `revalidateTag` API Route,收到 webhook 后失效对应 ISR 落地方式:使用 Wagtail 原生 `Group` + `GroupPagePermission` + `collections` 权限,不需要额外引入 RBAC 框架。 +**已落地(`python manage.py setup_rbac_groups`,幂等命令,`apps/core/management/commands/setup_rbac_groups.py`):** + +- Super Admin 直接对应 Django `is_superuser=True`,不建单独 Group;其余 5 个角色对应 5 个 Wagtail `Group`:站点管理员/编辑/审核员/作者/查看者。 +- 页面权限(`GroupPagePermission`,挂载于页面树根节点 `depth=1`,适用于当前单站点部署):站点管理员拥有全部类型(add/change/publish/delete/bulk_delete/lock/unlock/view);编辑拥有 add/change/view(无 publish,创建的页面停留在草稿态等待发布);审核员拥有 change/publish/view(用于打开页面并执行发布,属于对“页面编辑=查看”矩阵条目的必要放宽);**作者仅拥有 add/view(不含 change)**——利用 Wagtail 内置 `OwnershipPermissionPolicy`:只有 `add` 权限时,用户可创建新页面,且只能编辑/删除自己拥有(`owner`)的页面,天然满足“仅自己”要求,无需自定义 `wagtail_hooks`;查看者仅有 view。 +- Snippet 权限(`TeamMember`/`Testimonial`/`Partner`/`NavigationMenu`/`FormDefinition`):站点管理员与编辑均为 add/change/delete/view,审核员仅 view,作者/查看者无权限。`Lead`(线索,含 PIPL 个人信息)出于数据保护考虑做了比矩阵更严格的收紧:仅站点管理员可管理,审核员仅 view,编辑/作者均不授予。`SiteSettings` 作为站点级单例配置仅站点管理员可 change/view。 +- Collection(图片/文档)权限(`GroupCollectionPermission`,挂载于根 Collection):站点管理员 add/change/delete/choose/view;编辑与作者 add/change/choose/view(便于上传 StreamField 图片);审核员 choose/view;查看者无。 +- 用户管理权限(`auth.add_user`/`change_user`/`delete_user`/`view_user`)仅授予站点管理员,对应矩阵“用户管理”列;“Django Admin”列不通过 Group 授予(Django Admin 访问由账号的 `is_staff` 属性决定,仅超级用户具备)。 +- 简化说明:设计方案 §2.9 的 Workflow 审核模块尚未实现,因此“提交审核/审批发布”当前通过“编辑者无 publish、审核员/站点管理员有 publish”的权限差异形成审核闸门,而非正式的 Wagtail `Workflow`/`TaskState`;待 Workflow 模块落地后可在现有 Group 基础上叠加 `GroupApprovalTask`,无需重新设计权限组。 +- 测试:`apps/core/tests.py` 新增 7 个用例覆盖 Group 创建、`access_admin` 授予、页面权限矩阵、Author 仅编辑自己页面、Lead 权限收紧、用户管理权限范围、命令幂等性。 + ### 2.9 工作流与内容审核 采用 Wagtail 5+ 内置 `Workflow` 模块,定义 `草稿 → 编辑自检 → 审核 → 通过 → 定时发布`,并在 `TaskState` 变更时通过企业微信机器人 Webhook 通知责任人(替代原文档 Slack 方案)。 @@ -376,9 +386,9 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线 - [x] Snippet:`TeamMember`/`Testimonial`/`Partner`(简单 `@register_snippet`,均含 `order` 排序字段)、`NavigationMenu`+`NavigationMenuItem`(`ClusterableModel`+`Orderable`+`InlinePanel`,内部页面/外部链接二选一)、`SiteSettings`(`wagtail.contrib.settings` + `BaseSiteSetting`,公司信息/ICP备案/社交账号全局配置)均已创建;只读 API 挂载于 `/api/v1/custom/core/`(`team/`、`testimonials/`、`partners/`、`navigation/?name=`、`site-settings/`,限流 public 100/min);前端 `Header`/`Footer` 已接入 `NavigationMenu`/`SiteSettings`(接口不可用时回退静态内容),`TeamMember`/`Testimonial`/`Partner` 已提供 service 层,页面级展示留待对应 StreamField Block(见下)落地时接入 - [x] 预览模式:后端引入 `wagtail-headless-preview`(0.9.0),`SEOablePage` 混入 `HeadlessPreviewMixin`(自动覆盖全部 6 个页面模型),新增自定义 `PagePreviewAPIViewSet` 挂载于 `/api/v2/page_preview/`;前端新增 `services/preview.service.ts`(`getPreviewOrFallback`/`fetchPreviewPage`/`resolvePreviewPath`)、`/preview`与 `/preview/disable` 两个 Route Handler(基于 Next.js Draft Mode + httpOnly cookie),并在首页/博客/产品/案例/解决方案详情页接入草稿优先逻辑,`layout.tsx` 新增预览模式提示条(含退出链接);`lint`/`build` 均验证通过,后端 31 个现有用例均通过(本次未新增自动化测试,仅依靠人工验证) - [x] 剩余 6 个 StreamField Block(`ProductCardBlock`/`PricingBlock`/`TimelineBlock`/`TeamBlock`/`TechStackBlock`/`VideoBlock`)已全部开发完成:`apps/core/blocks.py` 新增对应 Block 类并扩充 `COMMON_BLOCKS`(现共 14 个内容 Block + 1 个 `richtext`),因 Wagtail StreamField 会将 Block 结构写入迁移(`use_json_field=True` 不代表可跳过迁移),已为 blog/cases/home/products/solutions 五个 Page 模型生成并应用 `alter field body` 迁移;`TeamBlock` 复用 `FormBlock` 的 `get_api_representation` 覆写模式,新增 `TeamMemberChooserBlock(SnippetChooserBlock)` 展开 `TeamMember` 完整字段(含 `serialize_image` 复用),并补充对应的空值/展开单元测试(新增 2 个用例,后端共 33 个测试全部通过);前端 `types/wagtail.ts` 新增 6 个 Block 值类型与 `StreamFieldBlock` 联合类型分支,新建 6 个渲染组件(`ProductCardGrid`/`Pricing`/`Timeline`/`Team`/`TechStack`/`Video`)并接入 `BlockRenderer.tsx` 映射表,`lint`/`build` 均验证通过 +- [x] RBAC 落地:`python manage.py setup_rbac_groups` 幂等命令创建 5 个 Wagtail Group(站点管理员/编辑/审核员/作者/查看者)并按 §2.8 矩阵配置 `GroupPagePermission`/`GroupCollectionPermission`/Snippet 与用户管理 `Permission`;Author“仅自己”利用 Wagtail 内置 `OwnershipPermissionPolicy`(仅 add 权限)原生实现,无需自定义 hook;新增 7 个后端单元测试,全仓库 pytest 从 33 增至 40,全部通过 未完成(待规划排期): -- [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始 - [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线) - [x] 生产安全 settings(§2.11 中 `SECURE_*`/CORS/CSRF 白名单等)已在 `production.py` 中逐项落实,并补充 `SECURE_PROXY_SSL_HEADER`(适配国内云厂商 SLB/CLB 边缘终止 TLS 场景);实际域名需在部署时通过 `.env` 填入 - [x] ICP 备案、页脚备案号展示、隐私政策内容、Cookie 同意条控件、表单 PIPL 数据删除权支持、字体/地图/验证码/CDN 国内可访问排查均已完成,§2.15 合规与本地化清单已全部完成