Open edX 课程创作 AuthZ 迁移的双轨权限路径设计:以 ADR 0027 为例
【免费下载链接】openedx-platformThe Open edX LMS & Studio, powering education sites around the world!项目地址: https://gitcode.com/GitHub_Trending/ed/openedx-platform
本文围绕 Open edX 的架构决策记录docs/decisions/0027-dual-path-course-authoring-migration.rst展开,讲清AUTHZ_COURSE_AUTHORING_FLAG门控的"从 legacyCourseAccessRole迁移到 openedx-authz"过程中如何引入角色感知的双路径判断。读完后你能掌握:enable_authz_course_authoring(course_key, role)的设计动机与行为细节、course_creator_group等未迁移角色如何安全回退到 legacy 路径,以及调用方何时必须传入role参数以避免复发 500/403 故障。
背景:迁移中的角色覆盖不全问题
Open edX 正在把课程创作权限的授权引擎从 legacy 的CourseAccessRole表切换到 openedx-authz。这个切换由 waffle 开关AUTHZ_COURSE_AUTHORING_FLAG(authz.enable_course_authoring)整体门控,定义在 toggles.py 中:
# .. toggle_description: This toggle will enable the new openedx-authz authorization engine for course authoring. # .. toggle_use_cases: temporary # .. toggle_creation_date: 2026-02-05 # .. toggle_target_removal_date: 2027-06-09 AUTHZ_COURSE_AUTHORING_FLAG = CourseWaffleFlag('authz.enable_course_authoring', __name__)注意几个关键属性:
toggle_use_cases: temporary:这是一个临时开关,迁移完成后会移除;toggle_target_removal_date: 2027-06-09:计划移除时间,即本 ADR 所说的"stopgap";CourseWaffleFlag:按course_key粒度启用,不是全局开关。
问题在于:迁移尚未完成,只有部分 legacy 角色在 openedx-authz 中有对应角色(由 openedx-authz 的LEGACY_COURSE_ROLE_EQUIVALENCES定义),而course_creator_group和org_course_creator_group尚无对应角色。最初的实现只判断 waffle 开关,从不检查"正在处理的这个角色是否已经迁移",于是打开开关后出现两个故障(均记录在 openedx-authz issue #353、#354):
- 通过 Django admin 授予 course creator 权限时报 500;
- 已有 legacy course creator 授权的用户在创建课程时收到 403。
两者的共同根因是同一个:代码试图对一个在 openedx-authz 中尚无对应角色的 legacy 角色使用 authz 路径,导致授权解析失败(异常或权限被拒)。
决策:给enable_authz_course_authoring增加可选role参数
ADR 的核心决定是:让enable_authz_course_authoring接受一个可选的role参数,以便基于具体角色做决策。
在 roles.py 中的实现如下:
def enable_authz_course_authoring(course_key: CourseKey | None = None, role: str | None = None) -> bool: """ True only if the authz.enable_course_authoring waffle flag is enabled and, when `role` is given, that role has a migrated authz equivalent. ... """ if not AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(course_key): return False if role is not None and get_authz_role_from_legacy_role(role) is None: return False return True行为规则完全对应 ADR 的三条决策点:
| 条件 | 返回值 | 说明 |
|---|---|---|
开关关闭(对给定course_key) | False | 函数提前返回,从不评估role,零额外开销 |
开关开启 + 未传role | True | 保持旧行为,跟随开关(通用权限检查调用方适用) |
开关开启 + 传入role且该角色在LEGACY_COURSE_ROLE_EQUIVALENCES中无对应 authz 角色 | False | 回退 legacy 路径,避免 500/403 |
开关开启 + 传入role且存在对应 authz 角色 | True | 走 authz 路径 |
其中角色映射函数同样在 roles.py:
def get_authz_role_from_legacy_role(legacy_role: str) -> str: return authz_roles.LEGACY_COURSE_ROLE_EQUIVALENCES.get(legacy_role, None) def get_legacy_role_from_authz_role(authz_role: str) -> str: return next((k for k, v in authz_roles.LEGACY_COURSE_ROLE_EQUIVALENCES.items() if v == authz_role), None)LEGACY_COURSE_ROLE_EQUIVALENCES来自openedx_authz.constants.roles(外部依赖 openedx-authz 包),本仓库只负责查表。从源码结构看,role参数只有在开关已开启时才被求值(第一行if not AUTHZ_COURSE_AUTHORING_FLAG.is_enabled(course_key): return False),这印证了 ADR "role check only runs when the flag is on" 的表述。
调用方约定:操作具体角色时必须传role
ADR 的调用方规则很明确:操作具体角色的调用方(授予/撤销)应把该角色传入;不关心具体角色的调用方(通用权限检查)可省略。
在源码中,RoleBase的所有双路径入口都遵循了这个约定,例如 roles.py:
def add_users(self, *users): if enable_authz_course_authoring(self.course_key, role=self._role_name): self._authz_add_users(users) else: self._legacy_add_users(users) def remove_users(self, *users): if enable_authz_course_authoring(self.course_key, role=self._role_name): self._authz_remove_users(users) else: self._legacy_remove_users(users)类似的调用点还包括users_with_role、get_orgs_for_user、has_org_for_user(均在RoleBase上),以及CourseRole.course_group_already_exists(注意:这个方法不传role,因为它检查的是"该课程是否已存在任何角色授权"这种通用判断,符合"不关心具体角色则省略"的规则)。
相反,CourseCreatorRole(ROLE = "course_creator_group")与OrgContentCreatorRole(ROLE = "org_course_creator_group")目前没有 authz 等价角色。ADR 中提到的is_content_creator检查正是因此完全跳过开关判断:
# common/djangoapps/student/auth.py def is_content_creator(user, org): """ Neither CourseCreatorRole nor OrgContentCreatorRole has a migrated AuthZ equivalent yet (see ADR 0027), so this always checks the legacy role-based permission system. Once either role gets a migrated equivalent, this should also check AuthZ, gated on that role. """ return _has_legacy_content_creator_access(user, org) def _has_legacy_content_creator_access(user, org): return (user_has_role(user, CourseCreatorRole()) or user_has_role(user, OrgContentCreatorRole(org=org)))见 auth.py。而 Studio 的课程创建入口(views/course.py 中的create_course)正是用is_content_creator(request.user, org)来放行:
has_course_creator_role = is_content_creator(request.user, org) if not has_course_creator_role: raise PermissionDenied()这条链路解释了故障 #354 的成因与修复方式:由于is_content_creator始终走 legacy 判断,已有 legacycourse_creator_group授权的用户即使在开关开启后依然能通过创建权限校验。
未迁移角色的读取路径:RoleCache 双源合并
除了写路径(grant/revoke),ADR 隐含的第二个风险是读路径:开关开启后,若只从 openedx-authz 读角色,legacy 表中的未迁移授权会被"漏掉"。仓库中的实际解法是 roles.py 中的AuthzCompatCourseAccessRole兼容数据类与RoleCache:
@dataclass(frozen=True) class AuthzCompatCourseAccessRole: """ Generic data class for storing CourseAccessRole-compatible data to be used inside BulkRoleCache and RoleCache. This allows the cache to store both legacy and openedx-authz compatible roles """ user_id: int username: str org: str course_id: str | None role: strRoleCache.__init__在缓存未命中时会同时加载两个来源:
- openedx-authz 兼容角色:
get_authz_compat_course_access_roles_for_user(user)把用户在 authz 中的课程/组织/平台级授权转换为 legacy 兼容记录(无 legacy 映射的 authz 角色被跳过); - legacy 角色:
CourseAccessRole.objects.filter(user=user)直接查表。
BulkRoleCache.prefetch(批量预取,roles.py)同样合并两个来源。这意味着RoleCache.has_role对未迁移角色(如course_creator_group)的判定始终基于 legacy 表数据,与is_content_creator的"始终走 legacy"策略一致,避免了"写走 legacy、读走 authz"导致读不到的不一致。
影响与后续清理计划(Consequences)
ADR 的"Consequences"一节列出了四条影响,结合源码可逐条对应验证:
- 未迁移角色在开关开启后继续正常工作:
RoleBase.add_users/remove_users/users_with_role传入role后自动回退 legacy,is_content_creator始终走 legacy——对应修复 #353(Django admin 授予 500)与 #354(已有授权用户 403)。 - 不传
role的调用方零成本:role是可选参数,缺省时函数只在开关开启时直接返回True,无任何额外查表。 - 主要风险是未来调用方忘传
role:ADR 明确指出,若某调用方操作具体角色却忘记传入,"会静默地复现本决策修复的同一个 bug"。这是给后续开发者的硬性约束——凡是对某个具体 legacy 角色做 grant/revoke/list 的操作,调用enable_authz_course_authoring时必须带上role=self._role_name。 - 这是过渡方案,不是最终形态:ADR 强调"stopgap, not a permanent shape"。当所有 legacy 角色都有 authz 等价角色后,角色检查以及开关本身都会移除——与 toggles.py 中
toggle_target_removal_date: 2027-06-09的标注一致。届时is_content_creator的 docstring 也已预埋了待办:"Once either role gets a migrated equivalent, this should also check AuthZ, gated on that role."
适用前提与限制
- 本 ADR 描述的行为适用于当前仓库版本:
AUTHZ_COURSE_AUTHORING_FLAG按course key 粒度生效(CourseWaffleFlag),同一平台内可以部分课程开启、部分关闭; - 角色映射表
LEGACY_COURSE_ROLE_EQUIVALENCES维护在外部依赖 openedx-authz 中(见 openedx-authz ADR 0011 的角色映射表),本仓库 roles.py 只是查表入口; - 开关的
toggle_warning提示:启用该开关会触发在 legacy 与 openedx-authz 之间的角色授权数据迁移,生产环境开启前需评估数据迁移影响; - 文中提到的 openedx-authz issue #353 / #354 属于外部仓库(openedx-authz),本仓库内无法直接验证其细节,事实依据以 ADR 原文陈述为准。
关键文件索引
| 内容 | 路径 |
|---|---|
| 本 ADR 原文 | 0027-dual-path-course-authoring-migration.rst |
| 开关定义 | toggles.py |
enable_authz_course_authoring/ 角色映射 / 双路径角色类 | roles.py |
is_content_creator始终走 legacy | auth.py |
| Studio 课程创建权限入口 | views/course.py |
| 基于开关的权限装饰器(不关心具体角色的调用方示例) | decorators.py |
【免费下载链接】openedx-platformThe Open edX LMS & Studio, powering education sites around the world!项目地址: https://gitcode.com/GitHub_Trending/ed/openedx-platform
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考