通过认证管理群组成员

@david 继续讨论:

正如你可能猜到的,我也非常希望能看到一个针对此问题的稳定解决方案,而该问题并不局限于 openid-connect 插件。让我们看看能否找到一条前进的道路。

针对你提出的观点:

你如何决定将用户添加到哪些组或从哪些组中移除?这又如何影响在 Discourse 本身中手动添加/移除用户组?

  1. 用户通过 OIDC 认证,其组别为 ['group1', 'group2']
  2. 在 Discourse UI 中,用户被添加到 group3
  3. 稍后,同一用户通过 OIDC 认证,其组别为 ['group1']

作为人类检查,我们可以看出最终状态应为 group1, group3(应移除 group2)。但我认为目前追踪的状态不足以在程序上做出该决定。同样:

  1. 用户通过 OIDC 认证,其组别为 ['group1', 'group2']
  2. 在 Discourse UI 中,用户从 group1 中被移除
  3. 稍后,同一用户通过 OIDC 认证,其组别为 ['group1', 'group2']

现在会发生什么?管理员明确将用户从 group1 中移除,但 OIDC 却将其重新添加。这对管理员来说是非常令人困惑的用户体验。我们可能需要某种方式将组别标识为“外部管理”,并隐藏所有添加/移除 UI :thinking:

我认为我们有几种方法可以处理这个问题(它们并不互斥):

组和令牌属性标识

在任何实现中,必须明确标识:

  1. 哪些组的成员资格可以通过认证进行管理;以及
  2. 认证令牌中的哪些属性管辖成员资格

无论是通过站点设置、组设置还是其他方式,默认值应为“关闭”。

严格或宽松处理

如果当标识的令牌属性中缺少该组时用户会被移除,则处理方式为“严格”。如果当标识的令牌属性中缺少该组时用户不会被移除,则处理方式为“宽松”。

严格/宽松状态将按组单独设置。这里有一个在站点设置中如何实现此功能的示例:https://github.com/discourse/discourse-openid-connect/pull/7/commits/1665bdccbb492788f64ba6844ad6f9ae1a41d95e。同样的方法也可以通过组设置来实现。

正如你所建议的,如果处理方式为“严格”,你可以禁用组管理中的成员资格添加/移除功能。

成员资格来源标识

你还可以将成员资格的来源存储在 group_users 表中,以允许组内采用“混合”方法,即管理员无法移除通过认证令牌创建的成员资格。

我越想越觉得,这应该通过组设置来实现。

11 个赞

感谢 @angus 发起这个话题!我知道很多人非常期待这项功能!

很有趣!我一直设想用户组会在身份验证过程中自动创建,并且组名与身份提供商上的组名保持 1:1 对应。DiscourseConnect 目前就是按这种方式工作的。

但我其实非常喜欢这种更明确的选项!这意味着用户的 Discourse 实例不会被身份提供商中不需要的组所污染,同时也允许管理员根据喜好自定义组名。这与现有的基于电子邮件域名的成员管理方式具有高度一致性。

从技术角度来看,这听起来不错。我唯一的担忧是,向用户或管理员解释起来可能会有难度。如果我们采用你提出的“明确识别”由身份验证管理的用户组的方案,我认为我们可以直接实现“严格”处理模式?

当某个组被配置为由身份验证管理时,手动添加/移除成员的功能将被隐藏在一条醒目的警告之后,其行为将类似于你描述的“严格”模式。

你觉得这样如何?

另外:我们还应确保所有自动添加/移除用户的操作都记录在组日志中。这将有助于每个人更清楚地了解发生了什么以及原因。

7 个赞

是的,我认为至少在这个功能集的第一版中,明确指定会更好。我还没有遇到过必须自动创建群组的用例,也就是说,管理员完全可以在处理任何声明之前手动设置和配置群组。

也许在完成明确版本后,我们可以将自动创建作为一个选项引入?

就“群组设置”而言,可以这样设计:

设置部分:成员资格(即现有部分)
设置组标题:身份验证管理
设置

  • 服务:身份验证服务列表。“全部”将作为一个选项。此设置也将作为此功能集的“启用/禁用”状态。即默认值为“无”。
  • 声明:用于标识 ID 令牌声明的文本输入。该功能的“描述”(或许可以在 meta 上的这篇帖子中)将解释支持的格式,例如布尔值、逗号分隔的字符串等。
  • 模式:见下文

是的,如果存在“严格/宽松”设置,我们就必须对其进行简明扼要的解释。我认为处理这个问题的方法是聚焦于“添加”和“移除”。你可以这样描述:

设置:“模式”

选项 1(宽松):

  • 标签:“添加成员”
  • 描述:“允许在身份验证时将成员添加到该群组”

选项 2(严格):

  • 标签:“添加和移除成员”
  • 描述:“允许在身份验证时添加和移除成员。这将禁用手动成员控制。”

我之所以坚持保留“宽松”选项,是因为之前在与客户合作处理此类问题时,这是最常见的用例,即:

  • 主要需求是根据外部服务中的状态允许访问某个群组

  • 根据外部服务中的状态移除访问权限的需求相对次要,即人们确实会失去访问权限并应被移除,但这相对不那么重要

  • 通常希望保留在 Discourse 中手动控制成员资格的能力。当我实施“严格”方法时,这曾让人感到“意外”(即“为什么 X 失去了成员资格?”),尽管已经进行了说明,且功能在技术上按预期运行。

是的,这很重要,因为人们经常会想知道“为什么”某人被添加或移除,特别是在严格模式下,而我们(即"Discourse")无法控制外部身份验证服务所做出的声明的真实性。

或许可以新增一种“移除用户”和“添加用户”操作类型,其中包含负责该操作的身份验证服务,例如“移除用户([服务名称])”。

3 个赞

另一个需要注意的“陷阱”是,我们需要明确说明,这并非解决“我希望某个组的成员资格基于外部服务 X"这一用例的万能方案,因为用户并不会频繁进行身份验证,至少不符合此类标准用例的需求。

身份验证管理是处理此类用例的必要环节,但要真正满足其需求,你还需要配置基于事件的集成,例如 Webhook 接收器(我们有一个专为群组管理设计的私有 Webhook 接收器插件,我希望能很快将其开源)。

5 个赞

@david 大家对这件事怎么看?如果我来准备一个 PR,你愿意接受吗?

2 个赞

你在这里做的工作非常有价值!:sunflower:

作为功能对比,我想分享一下我们在全球法律赋能网络(Global Legal Empowerment Network)的做法。我们使用 WordPress 搭配 Discourse 的 WordPress 插件。我们将系统设置为:每当 WordPress 中的用户信息更新时,Discourse 也会同步更新。这包括一些特殊群组(例如核心成员、资源贡献者等)以及个人资料详情。我们还添加了一个隐藏的“最后更新时间”用户字段,这有助于故障排查并确保同步功能正常运行。

我们锁定了这些由远程管理的群组,防止用户在 Discourse 中自行加入或退出,但并未限制管理员对群组成员关系的管理。你上面尝试的方案看起来不错,不过说实话,对我来说有点超出能力范围了。

我们有一些 Discourse for Teams 的客户正在积极使用 Okta 来管理其所有公司应用的访问权限。他们也在 Okta 中设置了角色,这些角色随后被用于支持例如为 Microsoft Tableau 等应用提供不同级别的访问权限。Okta 本身也是一个目录服务,因此他们通过它来管理用户头像、简介、位置以及其他个人资料信息。这些客户也希望在 Okta 外部,通过 Teams 来同步更新这些个人资料信息。

5 个赞

如果可能的话,我们应该尽量避免在组设置中包含过于技术性的内容。如果需要链接到语法文档,这可能意味着应该对其进行简化,或者将其移至管理员站点设置。我认为这部分应该留给各个认证插件去实现,因为差异可能非常大。

以 OIDC 为例,该插件将添加一个新的站点设置 openid_connect_roles_claim。如果 使用 Okta,管理员会将该设置配置为 groups。我认为字符串数组是 OIDC 非常标准的格式,但如果确实有必要,我们也可以在此探索更复杂的选项。

为了接收这些信息,Auth::Result 将新增一个 roles 属性,该属性接受一个简单的字符串数组。核心代码随后将处理此内容(在 Auth::Result#apply_user_attributes! 中),并将其加载到一个新的 UserAssociatedRoles 表中,该表包含列 (provider_name, user_id, role)。这个 UserAssociatedRoles 表为我们提供了你在原帖(OP)中提到的“成员资格来源标识”。

我想象中的组设置大致如下:

角色名称将以 provider_name 为前缀。自动补全将基于 UserAssociatedRoles 表中所有现有的值,但我们也接受非自动补全的值,以防 Discourse 尚未见过该角色。

在数据库中拥有完整的 user_associated_roles 表的好处在于,管理员可以随意操作 Discourse 组,成员资格将立即更新,而无需用户再次登录。

这很合理。我认为最好尽可能保持简单,至少对于 v1 版本是这样,所以我宁愿不要有多个“模式”。我们是否可以将以下行为设为默认:

  • 当用户拥有来自认证提供商的匹配角色时,将其添加为成员
  • 当该角色消失时,移除成员
  • 允许在 Discourse 中添加/移除成员
  • 如果管理员尝试移除通过认证提供商添加的用户,则显示警告:“该用户下次登录时可能会被重新添加”

我认为我们应该在此默认采用“安全优先”的原则,当用户在身份提供商处失去角色时,将其从成员中移除。随着成员资格日志的改进,我希望这比当前的现状更不容易令人困惑。

对于那些确实不想这样做的站点,我们可以设置一个站点选项,例如 remove_group_membership_when_auth_role_lost(默认为 true)。

是的,确实如此。我们在其他用户元数据(如姓名、头像等)上也存在这个问题。我认为在不久的将来,我们需要考虑为其他认证插件创建一个等效于 sync_sso 的功能。这将传递所有通常通过 OIDC / SAML 等传递的信息,包括这个新的“角色”信息。不过,这可能是一个独立的项目。


这一切听起来怎么样,@angus?这比单独的严格/宽松模式稍微缺乏一些灵活性,但我认为只有一种模式将使故障排除/文档/支持工作变得更加容易。


按照该计划,以下是从管理员角度看到的概览:

初始设置

  1. 设置 Okta 认证,并在 Okta 端 启用 groups claim

  2. 在 Discourse 中,将 openid_connect_roles_claim 设置为 groups

设置新组

  1. 像往常一样创建 Discourse 组。将名称/全名/装饰/等配置为你喜欢的任何内容

  2. 进入“成员资格”偏好设置,将光标放在 SSO 角色下拉菜单中,并从下拉菜单中选择一个角色。如果 Discourse 尚未见过有人使用该角色登录,则必须手动输入

    你可以为单个 Discourse 组指定多个角色。例如,Discourse 中的“团队”组可以是 oidc:employeesoidc:contractors 的组合

  3. 点击保存,组成员资格将立即使用 Discourse 从之前的登录中缓存的角色信息进行更新。

  4. 在未来的登录中,身份提供商角色的任何更改都将反映在 Discourse 组中

  5. 你仍然可以在原生 Discourse UI 中向组添加/移除用户

    • 如果你尝试移除通过身份提供商添加的用户,将显示警告,提示该用户可能在下次登录时被重新添加
  6. 如果你后来决定不希望该 IDP 角色与组关联,你可以将其移除,所有通过该角色成为成员的用户也将被移除

2 个赞

我同意。

嗯,是的,不过在某些情况下支持布尔值声明也是有意义的。但没错,我同意目前先保持简单,仅支持字符串数组。

按认证器逐个实现的话,我猜我们还需要在 Auth::Authenticator 中定义一个 group_sync_enabled? 方法,该方法会在各个认证器中被重写,而在通用认证器中则根据相关设置中是否存在值来决定。

这实际上引出了一个有趣的问题。特定的服务认证器(如 Facebook(“groups”?)、Google(“groups”?)和 Discord(“roles”?))也可以利用这一点。我不确定它们的 ID 令牌是否包含此类信息,但从技术设计的角度来看(即未来添加此功能的能力),这或许值得考虑。不过我认为这并不是 v1 的主要关注点。

这些机制听起来没问题,但我好奇为什么我们在这里改用“roles”而不是“groups”。这不是什么大问题,但我想确认是否遗漏了什么。

从用户体验的角度来看,这种术语的使用(即同时出现“roles”和“groups”)可能会引起混淆,即使其背后有合理的技术区分。如果开发者在没有这些背景知识的情况下接触此功能,也可能产生困惑。

我喜欢使用单独的表,但也许我们应该用外键关联到 user_associated_accounts,而不是使用 provider_name?这在清理操作等场景中可能会很有用。我猜这个问题的答案部分取决于我们希望在产品层面将群组/角色关联与关联账户关联到何种程度。现在将两者关联起来会有什么缺点吗?

** 编辑,我想我们已经通过 user_id 建立了关联。

这听起来没问题,但我想到我们其实已经通过你提出的网站设置,按提供商拥有了这些信息,这也涵盖了角色尚未被识别的情况。也许我们可以利用 Discourse.authenticators,即一个包含提供商及其声明的内存列表?

我认为这是一个很好的折中方案。特别是如果我们还包含下面提到的网站设置。

这是基于插件(即认证器)的吗?如果是的话,我完全同意。这应该能满足 v1 的需求。

很好的用户流程总结 :+1:除了我提出的那些相对微小的建议外,我同意这个方向。

1 个赞

:+1:

没错。我特别想到了 Google,因为人们喜欢用它来管理他们的 GSuite 组织,我想这些组织应该有一些“组”的概念。

我想我是为了避免“身份提供商组”和"Discourse 组”之间的混淆……但也可能我改个名字反而让事情更混乱了。在这里继续使用“组”也没问题。

我的考虑是,我们仍然支持非托管认证器的认证提供商,因此可能不存在 user_associated_account 记录。不过我们可以像你所说的那样,通过 user_id 进行关联 :+1:

我不确定站点设置在这里是否有帮助。如果我们讨论的是一个代表“角色”的字符串数组,那么站点设置无法指定这些角色具体是什么,它只能指定如何获取该数组。这样理解对吗?

听起来很棒 :smiley:

1 个赞

是的,你说得对。我还在考虑之前的实现方式,当时我在设置中直接包含了具体的群组,但这里我们并不这样做,这没问题。

我认为目前的信息已足够开始着手 PR 的工作了。我计划本周晚些时候开始,除非你还有其他顾虑?如果在过程中遇到任何问题,我会在这里更新。

听起来很棒——很高兴在过程中为您提供任何帮助或解答任何问题 :slight_smile:

2 个赞

好的,我终于有一个进行中的版本可以在此分享了

几点说明。

在初始实现中,我选择了 Google Apps 托管域名(hd)组这一稍显复杂的情况,因为我认为这有助于厘清各种可能的组合,例如需要处理来自提供商的特定于域名的组。

为了实现该用例,我还必须在认证阶段引入“二次授权”的新概念,以支持增量授权。我考虑过几种不同的实现方式来请求特定用户的组权限(即如果他们使用 hd 进行认证),而这种方式看起来最为可行。我承认这在这一方面可能比预期的改动更大,但或许值得讨论。

请注意,要实现 Google 托管域名组的情况,您需要授予 Google Apps 托管域名组中的非管理员成员委托管理员权限,以便列出他们的组(通过管理员目录 API)。实际上,有一个名为“组读取器(Groups Reader)”的“测试版”预建管理员角色非常适合此用途。详见 Prebuilt administrator roles  |  User management  |  Google Workspace Help

Google 实现已生效。如果您设置好并使用托管域名进行认证,您的 Google 托管域名组将出现在自动组成员资格设置中;如果选中了该托管域名组,您将被添加到对应的 Discourse 组中;如果该组被移除,您也会被移除(这两个操作都会被详细记录);随后在该 Google 托管域名组中认证的用户将立即被添加。

具体细节从代码和测试用例中应该显而易见。您还会注意到,我最终添加了三个新表。我尝试过几种更“轻量级”的解决方案,但在处理用户关联组以及组关联组的更新时,它们最终都变得更加复杂且效率低下。为每个场景创建新表似乎难以避免。不过,我非常欢迎关于数据建模以及其他方面的建议。


还有一些技术待办事项(除了上述提出的概念/产品问题)。在此方面也欢迎提出建议:

  • 或许将 associated_groups 的 label 进行序列化(而不是在客户端建模)。
  • 补充缺失的测试用例和 QUnit 测试。
  • 或许将 user_associated_group / group_associated_group 的创建和销毁移至后台任务执行,因为当数量庞大时,这可能会很慢。
3 个赞

这看起来非常酷!

我对“二级授权”这个概念以及 provider_domain 列有点犹豫。你能再详细解释一下为什么需要它们吗?感觉它们似乎非常针对 Google……我们是否无法在首次认证请求时直接请求 admin.directory.group.readonly 作用域?或者,是否可以直接在组名前加上域名前缀?(或者干脆省略域名,因为我假设大家只会将此用于单个 Google“托管域名”?)

完全同意在这里使用 3 张表——这样能让结构更清晰。

同意 :+1:

我们需要在这里小心。任何组成员资格都必须在用户首次加载网站之前分配完毕。否则,他们首次登录时将无法看到特定于组的内容(例如安全分类)。因此,我认为对 user_associated_group 记录的更改应该同步处理。

但对于 group_associated_group 记录的更改,我认为你是对的。那里的更改可能会影响数千名用户,因此需要使用 in_batches 进行处理。我想可以先尝试同步处理,并在 UI 中显示加载转圈动画。这样,管理员就能清楚地看到操作何时运行/完成。

如果看到耗时接近 30 秒(Unicorn 请求超时时间),我们可能就需要考虑使用后台作业了。

我们可能还需要考虑在此处添加一些 DistributedMutex 锁。例如,如果用户在 group_associated_group 更改正在处理时登录,会发生什么情况?等我们最终确定整体架构后,我很乐意在 GitHub 上讨论这类问题。

对此很感兴趣——顺便提一个相关问题:Does `sso overrides groups` work with Oauth2?

就我的使用场景而言,将 Discourse Connect 的行为扩展到其他认证提供者,我完全满意。

2 个赞

太好了 :slight_smile: 我们正在逐步推进。

我会逐一处理这些问题。

增量授权

你无法在首次请求时请求组权限,是因为你此时还不知道是谁在登录,也不知道他们愿意分享哪些信息。你可以通过 google_oauth2_hd 设置将 Google 关联组映射限制为使用特定托管域的用户,但这会相当大地限制该功能的适用范围。拥有一个团队,其中既使用 Google Apps,又有希望使用 Google 认证的“公开”用户,这种情况相当常见。

*编辑:我需要澄清一点,如果你请求了组范围,但用户无法授予(例如,他们的托管域尚未像上述那样将权限委托给非管理员用户),那么认证将会失败。你不能在请求必需范围的同时请求可选范围。

此外,这种方法(即在实施各种认证方法时,将请求组范围作为标准做法)可以说比替代方案更针对 Google,因为你并不总是拥有类似“托管域系统”的机制来限制登录。例如,我可能错了,但我不认为有一种方法可以将 Github OAuth2 登录限制为特定的 Github 组织。

换句话说,在许多情况下,你将面临这样的选择:要么要求所有使用该认证方法的用户授予相关的 groups 范围,要么根本不使用该功能。这种方法在某些上下文中可能有效,但在许多情况下行不通。这种增量授权方法为不同的认证方法在实施该功能时提供了更大的灵活性。

诚然,Google 一直在 OAuth2 领域倡导增量授权,例如,关于该主题的工作论文都是由 Google 员工撰写的:

然而,这个概念并非 Google 独有(其他人不一定称之为“增量授权”)。它在移动应用的不同形式中相当常见,并且正在被其他提供商在 OAuth2 中采用。例如,这是 Facebook 关于同一主题的文档

你可能已经熟悉这个领域了,但以下做法被认为是“最佳实践”:

  • 告知用户你将请求比标准基本信息更多的权限;
  • 也许还要说明原因。

如果用户在 Discourse 登录表单中点击“使用 Facebook 注册”,然后除了电子邮件外,还被要求访问其 Facebook 组,他们可能会放弃。Facebook 是这样表述的:

一般而言,应用程序请求的权限越多,用户使用 Facebook 登录该应用程序的可能性就越小。事实上,我们的研究表明,请求超过四个权限的应用程序在完成的登录数量上会出现显著下降。

这就引出了一个问题:在认证时请求额外范围是否是一个好主意?基本上,我得出的结论是我们没有其他好的选择。在某些场景下(正如你所暗示的),需要立即访问组信息;此外,现实情况是,如果不在一开始就请求,许多用户不会在个人资料或组页面等服务中采取额外的步骤来授权其组。

这必须在认证时完成,这又回到了上述问题,也是我实现“二次授权”系统的原因。它确实旨在成为一个轻量级的“系统”,因为其他服务(如 Facebook 或 Github)实施二次授权请求以在用户认证后(并可选地通过与其基本信息相关的某些测试)获取用户组访问权限相对容易。

每个提供商只需:

  • 返回一个包含 secondary_authorization_url 的结果

  • 使用 state 参数来检测用户处于哪个授权请求阶段

  • users/omniauth_callbacks/secondary_authorization.html.erb 提供 omniauth_secondary_authorization_description。例如,这是 Google 的示例,用户在确认二次授权重定向前会看到它:

    由于您使用 %{domain} 邮箱登录,我们需要请求查看您的 %{domain} 组的权限。

这些部分都不是 Google 特有的。

我想在这里做的是允许用户对二次请求说“不”,但仍能完成认证。在 Google Apps HD 场景中,这其实不是问题,因为如果他们的账户属于托管域,他们不太可能想或能够拒绝。不过,为了涵盖各种认证场景,我认为应该对此进行适配。

最后,还需要注意的是,associated_groups 要正常工作,并不必须进行二次授权。认证提供商可以直接在首次请求时请求组范围,然后在收到第一个响应后将组添加到认证结果中。事实上,我们应该在基本的 oauth2 和 openid connect 插件中将此作为一个选项构建进去。

提供商域名

我认为在 associated_groups 表中确实需要某种形式的次要标识符,以便站点管理员可读。存在许多场景,仅凭组名可能不足以区分。例如,每个服务中类似概念之间可能存在名称冲突:

  1. 多域名 Google 组管理(一个工作区中也可以有多个域名)
  2. 多组织 Github 组管理
  3. 多服务器 Discord 角色管理
    等等

我们或许可以将 domain 改为 namespace。我们可以将其包含在组的 name 中,但这会给我们带来任何优势吗?也许在某个时候按“域名”或“命名空间”进行查询会很有用。是的,也许 namespacedomain 更好。

它需要是“管理员可读”的原因是,它用于管理员在组 UI 中看到的标签,部分是为了消除歧义。

我正在考虑是否尝试在此处也存储一个 provider_id(如果存在的话)。未来或许会很有用。

是的,同意所有这些,也感谢你的建议。我会尝试处理这部分,我们可以在 PR 中进一步讨论。

4 个赞

@david 我刚刚推送了一些更新,包括:

  • group_associated_group 中的 DistributedMutxesin_batches
  • 验收测试(之前已有 rspec)

毫无疑问还需要进一步的工作,但目前它已按规范运行,且所有测试均通过。请试用一下,告诉我你的想法以及你希望进行的更改。

5 个赞

或许现在先将其标记为非草稿?

1 个赞

你好 @angus!我想知道你在这方面是否取得了进一步的进展?我对简单的“严格”行为非常感兴趣,据我了解,由于我们控制自己的 OAuth2/OpenID Connect 提供商,我并不担心“二次授权”的情况。有没有可能尽快实现类似的功能?

如果有所帮助,我们的环境文档在此:https://fedoraproject.org/wiki/Infrastructure/Authentication,我已将 Discourse 配置为请求 OAuth2 范围 openid profile email https://id.fedoraproject.org/scope/groups

基本上,我只希望:

  • 保持信任级别和工作人员组不变
  • 如果 SSO 列表中的现有 Discourse 组中尚未包含该用户,则将其添加到这些组中
  • 从用户所属但不在列表中的任何组中移除该用户

我坦率承认,我并不完全理解所有细节……是否有什么我尚未了解的复杂情况?

2 个赞

我本周末已留出时间处理此事,Matt。下周我会提供更新,可能是在 GitHub 的 PR 上。

2 个赞

太棒了——非常感谢。我无意催促,但 Discourse 支持团队建议在此话题中发帖是了解当前状况的最佳方式。:slight_smile:

我对你们在这方面的进展感到兴奋,因为一旦我们拥有这个功能,Fedora 的 Discourse 站点将能实现许多目前无法做到的事情!

2 个赞