Onyx 云托管外部应用凭据:OAuth 凭证集中化、租户预置与管理 API 锁定的实现机制
【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer
本文以 Onyx(danswer 项目)Craft 模块的设计文档 cloud-managed-app-credentials.md 为核心,系统讲解"云托管外部应用凭据"(Cloud-Managed External-App Credentials)方案:在 Onyx Cloud 上,Gmail、Google Calendar、Slack、Linear 等内置外部应用的 OAuth 客户端凭据由 Onyx 统一持有并按租户预置,租户管理员只能启停应用与配置动作策略,用户侧沿用标准 OAuth 流程。读完后你可以掌握该方案的OnyxManagedExtApp接口设计、EXT_APP_*环境变量配置规范、租户预置流程,以及管理 API 的云端锁定(lockdown)实现与测试验证方式。
1. 核心模型:Onyx 托管与自托管的归属边界
该方案要解决的问题是:在多云租户的 SaaS 场景下,如果每个租户管理员都自行到上游服务商(Google、Slack、Linear 等)注册 OAuth 应用,会产生大量配置成本、凭据泄露面与合规风险。Onyx Cloud 的做法是反转凭据所有权:
- Onyx 持有 OAuth 客户端凭据:内置外部应用(Gmail、Google Calendar、Slack、Linear)的
client_id/client_secret由 Onyx 平台统一注册、统一维护,每个租户创建时这些应用已按 Onyx 凭据预置完成,租户管理员无需注册自己的 OAuth 应用。 - 管理员权限收窄:在 Cloud 上,租户管理员只能对托管内置应用做两件事——启停(enable/disable)与设置动作策略(action policies);不能创建、不能编辑凭据/网关配置、不能删除。凭据与网关配置(
auth_template、upstream_url_patterns)永远不会下发给客户端。 - 用户侧零变化:应用一旦启用,每个用户照常走既有 OAuth 授权流程,针对 Onyx 的 OAuth 应用换取属于自己的 per-user token。
- 自托管不受影响:Self-hosted 部署维持原状——管理员自己创建内置应用并提供自己的凭据,应用保持可编辑。
文档 cloud-managed-app-credentials.md 的 "Behavior" 一节进一步给出三条约束:
- 每个租户每种类型至多一个内置应用(One built-in per type per tenant):同一
app_type在一个租户内最多存在一个内置应用,由内置 skill 的唯一 slug 强制;CUSTOM应用不受限、可重复创建。 - Cloud 上"种子化且默认禁用"(Seeded, disabled, on Cloud):租户创建时,所有 Onyx 托管内置应用都以禁用状态预置,Onyx 凭据已填入。
- 管理员只能开关与设策略(Admins toggle + set policies only):Cloud 上对内置应用的凭据/网关配置永远不返回给客户端。
这套"托管与否"的判断在代码层有唯一事实来源(single source of truth),即下一节的OnyxManagedExtApp接口。
2. 提供者接口:OnyxManagedExtApp 的声明与校验
设计文档指出,凡"凭据由 Onyx 持有"的内置提供者都必须继承OnyxManagedExtApp(定义于 base.py)。该接口的三个关键机制如下:
2.1 managed_org_credentials:声明 Onyx 持有的凭据映射
class OnyxManagedExtApp(ExternalAppProvider, abstract=True): # Onyx-owned credential values, sourced from the ``EXT_APP_<APP_TYPE>_<FIELD>`` # constants in ``app_configs``. Keys must match the spec's required fields. managed_org_credentials: ClassVar[dict[str, str]] = {}具体托管提供者只需把类变量managed_org_credentials的 key 映射到自身 spec 中required_org_credential_fields声明的字段即可。以 Gmail 为例,gmail.py 中:
class GmailProvider(GoogleOAuthProvider, OnyxManagedExtApp): spec = GoogleOAuthProvider.build_spec( app_type=ExternalAppType.GMAIL, app_name="Gmail", scope=_CLOUD_SCOPE if MULTI_TENANT else _SELF_HOSTED_SCOPE, upstream_url_patterns=["https://gmail\\.googleapis\\.com/gmail/.*"], google_api_name="Gmail API", endpoint_catalog=_ENDPOINTS, ) managed_org_credentials = { "client_id": EXT_APP_GMAIL_CLIENT_ID, "client_secret": EXT_APP_GMAIL_CLIENT_SECRET, }一个值得注意的细节是 scope 的双轨制(gmail.py):自托管模式使用gmail.modify全量 scope;Cloud 模式(MULTI_TENANT)受限于 Google 对 Onyx 自有 OAuth 客户端的受限 scope 策略,只申请gmail.send加gmail.drafts.create,所有标记requires_self_hosted_scope=True的动作(如读取消息)在云端目录中自动剔除。
2.2 类定义期的键集合校验
OnyxManagedExtApp.__init_subclass__在子类定义时立即校验managed_org_credentials的 key 集合是否与spec.descriptor.required_org_credential_fields的 key 集合完全相等,不一致直接抛出TypeError:
required = {f.key for f in cls.spec.descriptor.required_org_credential_fields} configured = set(cls.managed_org_credentials) if configured != required: raise TypeError( f"{cls.__name__} is an OnyxManagedExtApp but its " f"managed_org_credentials keys {sorted(configured)} do not " f"match its required credential fields {sorted(required)}." )这保证了预置(provisioning)时写入数据库的凭据 key 一定与上游授权模板(auth_template)所需字段精确对齐,错误在 import 阶段就暴露,而不是运行时。
2.3 configured_managed_credentials():三态解析语义
def configured_managed_credentials(self) -> dict[str, str] | None: """This provider's Onyx-owned credentials if fully configured, else None.""" creds = {k: v.strip() for k, v in self.managed_org_credentials.items()} if not any(creds.values()): return None # nothing configured if all(creds.values()): return creds # partially set — almost always a config mistake worth surfacing ... return None三态语义:全部为空 →None(未配置);全部非空 → 返回清洗后的凭据字典;部分配置 → 视为未配置并记录 warning(日志中会指出具体缺失的EXT_APP_<TYPE>_<FIELD>变量名,通常意味着运维漏配)。空白字符串(如" ")按未设置处理。test_managed_credentials.py 对这三态逐一做了单测覆盖(test_unset_is_none、test_full_set_resolves、test_partial_set_is_none、test_blank_value_counts_as_unset),并额外固定了"key 必须与 required 字段相等"这一不变式。
2.4 注册表与"托管判定"的唯一入口
registry.py 中的get_onyx_managed_provider是判定入口:
def get_onyx_managed_provider(app_type: ExternalAppType) -> OnyxManagedExtApp | None: provider = PROVIDERS.get(app_type) return provider if isinstance(provider, OnyxManagedExtApp) else None它的 docstring 明确说明:该函数不以MULTI_TENANT为门槛——"is not None就是'该应用是否 Onyx 托管'的检查",云端锁定由调用方叠加MULTI_TENANT条件组成。当前注册表中共 8 个内置提供者(Slack、Google Calendar、Google Drive、Gmail、Linear、GitHub、HubSpot、Notion),从源码结构看它们全部继承了OnyxManagedExtApp;test_managed_external_apps.py 中的test_all_built_ins_are_onyx_managed也把这一点固定为显式不变式(若未来某个内置应用改由管理员自配凭据,需刻意更新该测试)。
uses_cloud_scope()(registry.py)则是MULTI_TENANT and get_onyx_managed_provider(app_type) is not None的组合判断,供动作目录过滤复用;withheld_on_cloud()返回该应用在云端被剔除的动作列表。
3. 凭据配置:EXT_APP_* 环境变量
运维侧(Onyx 云团队)通过按字段拆分的环境变量注入凭据,这些常量定义在 app_configs.py:
EXT_APP_<APP_TYPE>_<FIELD> e.g. EXT_APP_GMAIL_CLIENT_ID, EXT_APP_SLACK_CLIENT_SECRET仓库中实际定义的常量覆盖 8 个托管应用,每个应用各一对*_CLIENT_ID/*_CLIENT_SECRET:
| 应用 | 环境变量 |
|---|---|
| Slack | EXT_APP_SLACK_CLIENT_ID、EXT_APP_SLACK_CLIENT_SECRET |
| Gmail | EXT_APP_GMAIL_CLIENT_ID、EXT_APP_GMAIL_CLIENT_SECRET |
| Google Calendar | EXT_APP_GOOGLE_CALENDAR_CLIENT_ID、EXT_APP_GOOGLE_CALENDAR_CLIENT_SECRET |
| Google Drive | EXT_APP_GOOGLE_DRIVE_CLIENT_ID、EXT_APP_GOOGLE_DRIVE_CLIENT_SECRET |
| Linear | EXT_APP_LINEAR_CLIENT_ID、EXT_APP_LINEAR_CLIENT_SECRET |
| GitHub | EXT_APP_GITHUB_CLIENT_ID、EXT_APP_GITHUB_CLIENT_SECRET |
| HubSpot | EXT_APP_HUBSPOT_CLIENT_ID、EXT_APP_HUBSPOT_CLIENT_SECRET |
| Notion | EXT_APP_NOTION_CLIENT_ID、EXT_APP_NOTION_CLIENT_SECRET |
同一段配置中还定义了总开关:
AUTO_PROVISION_DEFAULT_EXTERNAL_APPS = ( os.environ.get("AUTO_PROVISION_DEFAULT_EXTERNAL_APPS", "false").lower() == "true" )设计要点(对应文档 "Credential configuration" 一节):
- 命名防冲突:
EXT_APP_前缀 + 具体应用类型(如GMAIL而非GOOGLE),使其与 auth-flow 的GOOGLE_OAUTH_*变量清晰区分。每个常量映射到对应提供者managed_org_credentials的字段上。 - 落库加密:存储值写入
organization_credentials列,该列是EncryptedJson类型,静态加密(encrypt at rest)。 - 允许不配置:某个提供者的变量全部留空是合法状态——该应用仍会被预置,只是暂时没有凭据,配置完成前无法被有意义地启用。
4. 租户预置:种子化、幂等与部分失败回滚
文档 "Provisioning" 一节描述的设计是:provision_built_in_external_apps(db_session)(位于 provisioning.py)随setup_tenant执行,与configure_default_api_keys并列(当前仓库的setup_tenant确实按序调用configure_default_api_keys与setup_onyx,见 provisioning.py)。该流程由AUTO_PROVISION_DEFAULT_EXTERNAL_APPS门控,默认false,云上设为true。对每个托管内置应用:
- 首次创建:以禁用状态创建应用,填入运维侧凭据(未配置则为空);
- 重复执行(如
setup_tenant重试):就地刷新凭据,但不动启用状态与策略;且只有当该类型的凭据当前有配置时才覆写,因此重跑绝不会把配置中不再提及的凭据清空(credential refresh without wiping)。
单应用失败会被回滚并记录日志,保证一个坏应用不阻塞其余应用的预置。
从测试代码结构看,当前实现里对既有租户的种子化改由 Alembic 迁移承担:test_managed_external_apps.py 的模块注释明确写道 "Built-in apps are seeded into existing tenants by an Alembic migration rather than at tenant setup, so these tests seed directly viacreate_external_app",测试通过create_external_app直接种子化来模拟托管应用。与之配套的是 external_app.py 中的set_external_app_organization_credentials——docstring 说明它"供 Onyx 托管的预置/轮换路径使用,刻意不触碰其他任何状态(skill 偏好、策略、网关配置均保持不变)",这正是"刷新凭据不清空其他状态"的落点。
5. 管理 API 的云端锁定
管理端点实现于 api.py。文档 "Admin API" 一节列出的四条路由行为,均可在源码中逐一对应:
5.1 POST /admin/apps/built-in —— 自建托管内置应用被拒绝
if MULTI_TENANT and get_onyx_managed_provider(request.app_type) is not None: raise OnyxError( OnyxErrorCode.INVALID_INPUT, "Built-in apps are provided by Onyx; use PATCH /admin/apps/{id} to " "set action policies.", )(api.py)即:自托管模式下该端点照常可用(管理员自建并填自己的凭据);Cloud 模式下托管内置应用在此被拒绝,错误信息直接指引使用 PATCH 端点。CUSTOM类型则被要求走POST /admin/apps/custom。
5.2 PATCH /admin/apps/{id} —— 云端托管应用的唯一变更通道
PATCH 端点以 id 为唯一键,对托管应用只生效enabled与action_policies两个字段,其余请求字段一律按UNSET处理:
app = update_external_app( ... enabled=none_as_unset(request.enabled), name=none_as_unset(request.name), # Gateway config is Onyx-owned for managed built-ins; leave it untouched. upstream_url_patterns=( UNSET if managed else none_as_unset(request.upstream_url_patterns) ), auth_template=UNSET if managed else none_as_unset(request.auth_template), organization_credentials=( UNSET if managed else none_as_unset(request.organization_credentials) ), action_policies=action_policies, )(api.py)策略写入前还经过resolve_action_overrides校验与裁剪:只接受当前目录中存在的action_id,等于目录默认值或已不在目录中的条目会被剪掉——目录默认值从不物化成行,新发布的端点在读取时直接解析为default_policy,因此新增动作对全租户无迁移地生效(见 registry.py 的effective_policy/resolve_action_overrides)。
变更后的提交顺序也值得注意:端点先db_session.commit()落库,再执行 sandbox 推送(push_skills_for_users)并二次提交,使数据库成为唯一事实来源、sandbox 文件成为派生投影(api.py),避免推送失败让数据库"领先"于运行时。
5.3 DELETE /admin/apps/{id} —— 云端拒绝删除
if MULTI_TENANT and get_onyx_managed_provider(app.app_type) is not None: raise OnyxError( OnyxErrorCode.INVALID_INPUT, "Built-in apps are provided by Onyx and cannot be deleted.", )(api.py)
5.4 响应层脱敏:managed 置空 vs 自托管掩码
_to_admin_response(api.py)是客户端可见性的关键分界:
managed = MULTI_TENANT and get_onyx_managed_provider(app.app_type) is not None return ExternalAppAdminResponse( ... upstream_url_patterns=[] if managed else list(app.upstream_url_patterns), auth_template={} if managed else app.auth_template, organization_credentials=( {} if managed else app.organization_credentials.get_value(apply_mask=True) ), ... is_onyx_managed=managed, )- 托管应用:
organization_credentials、auth_template、upstream_url_patterns全部置空(blanked),并置is_onyx_managed=True,只暴露身份、启用状态与策略视图; - 自托管内置应用:凭据仍返回,但是**掩码(masked)**形态,且写入路径会把回显的掩码值还原为原值(
resolve_masked_credentials),防止"未改动的 secret 被覆写成它的掩码"。
响应模型中的is_onyx_managed: bool = False字段(models.py)供前端使用——文档指出前端(web/src/app/craft/v1/apps/)据此隐藏凭据表单、"添加内置应用"入口与删除控件,对托管应用只保留启用开关和策略编辑器。
6. 用户侧 OAuth 流程:零改动复用
文档 "OAuth" 一节确认:用户端点(GET /apps、POST /apps/{id}/credentials)与 OAuth 启动/回调路径在 Cloud 与自托管之间完全不变。云端仅共享一个 Onyx 自有的 OAuth 客户端、使用对所有租户固定的回调地址({WEB_DOMAIN}/craft/v1/apps/oauth/callback);凭据注入与 token 刷新直接读取种子化的organization_credentials,无需任何代码分叉。
源码侧的用户端点可印证这一"无感知"设计(api.py):
GET /apps只列出enabled_only=True的应用,并附该用户的凭据状态(credential_keys、掩码后的credential_values、authenticated标志),不暴露组织凭据与原始 auth 模板;POST /apps/{id}/credentials先检查app.enabled,被管理员禁用时直接报 "This app is currently disabled by an admin."——这正是管理员开关能即时作用于用户 OAuth 流程的落点。
令牌刷新的健壮性由基类 base.py 的OAuthExternalAppProvider模板方法保障:refresh_credentials以TokenRefreshTerminalError(grant 已死,需重新授权)与TokenRefreshTransientError(网络/5xx 等瞬时错误,保留现有 token 稍后重试)区分失败类别,并把新凭据合并到已存凭据上(而非整体替换),使 Slack 的team_id等仅在连接期返回的字段得以保留。云端与自托管共用这套刷新逻辑,读取的都是各自租户种子化的组织凭据。
7. 测试:可独立验证的不变式清单
文档 "Tests" 一节指向两个测试文件,覆盖了方案的关键承诺:
- test_managed_external_apps.py(外部依赖单测):
- 注册表不变式:provider 注册表与内置 skill id 集合一致,且当前全部为 Onyx 托管(
test_all_built_ins_are_onyx_managed); - 云端锁定(Cloud guards):
test_cloud_blocks_built_in_create验证创建被拒且数据库无落库;test_cloud_patch_updates_policies_and_protects_creds_and_config验证 PATCH 携带的攻击性配置字段(伪造 URL 模式、伪造 client_id/secret)被忽略、响应中凭据/配置置空、数据库中的种子凭据原样保留;test_cloud_blocks_built_in_delete验证删除被拒; - 自托管对照:
test_self_hosted_built_in_response_shows_config_and_masked_creds验证非MULTI_TENANT下配置可见、凭据为掩码而非置空,固定了 managed 与非 managed 的响应分界。
- 注册表不变式:provider 注册表与内置 skill id 集合一致,且当前全部为 Onyx 托管(
- test_managed_credentials.py(纯单元测试):凭据解析的三态语义(未设置/全量/部分/空白)以及 key 与 required 字段相等的显式不变式。
8. 小结:设计决策与适用边界
| 维度 | Onyx Cloud | Self-hosted |
|---|---|---|
| OAuth 客户端凭据 | Onyx 持有,EXT_APP_*环境变量注入 | 管理员自建并填写 |
| 租户内应用数量 | 每app_type至多一个内置,种子化且默认禁用 | 管理员自由创建 |
| 管理员可操作项 | 仅enabled+action_policies(PATCH) | 创建/编辑凭据与网关配置/删除 |
| 响应中凭据/网关配置 | 置空 +is_onyx_managed=True | 掩码凭据 |
| 用户 OAuth 流程 | 单客户端、固定回调,完全复用既有流程 | 不变 |
该方案的工程要点可以归纳为三点:接口即事实来源(是否托管只由是否继承OnyxManagedExtApp决定,isinstance检查贯穿注册表与 API);类定义期校验(__init_subclass__保证凭据映射与授权模板字段精确对齐,配置错误在 import 时即失败);锁定在写路径与读路径同时实施(PATCH 写路径把网关字段按UNSET丢弃、_to_admin_response读路径置空,双保险)。适用边界方面,云端锁定判断始终形如MULTI_TENANT and get_onyx_managed_provider(...) is not None,因此同一套代码在自托管单租户部署下退化为"管理员全权配置"模式,无需任何额外开关。
【免费下载链接】danswerOpen Source AI Platform - AI Chat with advanced features that works with every LLM项目地址: https://gitcode.com/GitHub_Trending/da/danswer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考