场景

  • 自建 OpenSearch + OpenSearch Dashboards 想用 Keycloak 做统一登录;或者已经接通了,浏览器能登录成功,但进去之后看不到索引和仪表盘,或者直接 403 no permissions for [...]。
  • 这个组合里有两个独立组件、两套配置文件、两条不同的 token 路径:Dashboards 的 security 插件发起 OIDC 登录并代表用户请求集群,OpenSearch 的 Security 插件负责验签、取用户名、取角色。「登录成功但没有权限」几乎都落在第二段的角色 claim 上,而 Keycloak 的出厂配置正好让 roles 只出现在 access token 里(见 1.1)。
  • 行为核对自 OpenSearch 官方文档(OpenID Connect 认证、OpenID Connect troubleshooting、Security 配置 API)、opensearch-project/security-dashboards-plugin 仓库的 opensearch_dashboards.json / server/index.ts / server/auth/types/openid/{routes,helper,openid_auth}.ts,以及 Keycloak issue #14617 里维护者对 roles client scope 默认状态的逐项确认;核查日期 2026-10-04,版本口径 OpenSearch 3.9.0(2026-09-29 发布)、OpenSearch Dashboards 3.8.0(最新发布版),插件源码按 security-dashboards-plugin 主干读取(opensearch_dashboards.json 声明版本 3.9.0.0);roles_key 列表取嵌套 claim 自 Security 插件 3.1.0.0 起可用(PR #5355,2025-06-06 合并)。

适用 / 不适用

场景是否适用
OpenSearch Dashboards 浏览器单点登录(auth.type: openid)✅ 本页主场景
应用 / 脚本用 Authorization: Bearer <access token> 直连 :9200✅ 但走另一条 token 路径,见 1.3
IdP 只提供 SAML❌ 配置项完全不同(auth.type: saml + Security 侧 saml_auth_domain),本页不涉及
想让 Keycloak 的组直接等于 Dashboards 里的索引 / 租户权限❌ 中间必须有一层 backend_roles → OpenSearch 角色映射,见第 4 节
集群是 AWS 托管的 OpenSearch Service⚠️ 托管版不能直改 config.yml、不能用 securityadmin.sh,只能走 REST API 与控制台,本页命令需换算

1. 两个组件、两套配置、两条 token 路径

  flowchart TD
    U[浏览器] -->|1 未登录| D[OpenSearch Dashboards]
    D -->|2 302 authorization_endpoint + scope| K[Keycloak]
    K -->|3 回调 /auth/openid/login?code| D
    D -->|4 code 换 token| K
    D -->|5 Bearer ID token 认证一次| S[OpenSearch Security 插件]
    S -->|6 验签 JWKS + 取 subject_key / roles_key| S
    D -->|7 后续每个请求带同一张 ID token| S
    A[应用 / curl] -->|Bearer access token| S

第 5 步是关键,可以直接在源码里读到:Dashboards 换到 token 后调用

// security-dashboards-plugin: server/auth/types/openid/routes.ts
const user = await this.securityClient.authenticateWithHeader(
  request, this.openIdAuthConfig.authHeaderName as string,
  `Bearer ${tokenResponse.idToken}`
);

随后写进会话凭据的也是 Bearer ${tokenResponse.idToken}。也就是说:浏览器 SSO 路径上,Security 插件看到的是 ID token,不是 access token。 这正是「Keycloak 里明明有角色、登录也成功,却拿不到角色」的第一层原因。

1.1 Keycloak 默认把 realm roles 写进 access token,不写 ID token

Keycloak 内置 roles client scope 里的 realm roles / client roles 两个 mapper,出厂状态只有 Add to access token 打开,Add to ID token 与 Add to userinfo 是关的(Keycloak issue #14617,维护者逐项核对后的结论;同一 issue 里也提到早期新版 Admin Console 曾把开关显示错,需要重新保存一次才落库)。

于是出现典型的静默失败组合:浏览器登录完全正常、Keycloak 只有认证成功记录,OpenSearch 侧只留下一行 warn:

Failed to get roles from JWT claims with roles_key '[...]'. Check if this key is correct and available in the JWT payload.

两条修法,选一条即可:

方案做法OpenSearch 侧 roles_key
A. 打开内置 mapperClient scopes → roles → Mappers → realm roles,勾上 Add to ID token嵌套路径,必须写成 YAML 列表(见 1.2)
B. 自定义扁平 claim(推荐)Client scopes → 新建 scope(如 os-roles)→ 加 User Realm Role mapper,Token Claim Name 设 roles,Multivalued 保持默认 true,勾 Add to ID token,再作为 default client scope 挂给该 clientroles

方案 B 的 claim 是扁平数组,不依赖嵌套取值语法,语义也比 realm_access.roles 清楚,多个组件共用同一个 client 时更省事。若还有应用要直连集群 API,mapper 的 Add to access token 也要打开(见 1.3)。

roles scope 本来就是 realm 的 default client scope,new client 不需要额外挂 scope;要改的只是 mapper 的开关,或者另建一个扁平 claim 的 scope。

1.2 嵌套 claim 只能写成 YAML 列表,点号写法取不到

realm_access.roles 是嵌套结构。官方文档写的是「You can combine roles_key as a list to extract roles from nested JWT claims」,实配形态是:

roles_key:
  - realm_access
  - roles

写成 roles_key: realm_access.roles 不会报配置错误,只会在每次请求时继续打同一句 warn。嵌套取值是 Security 插件 3.1.0.0 起才支持的(PR #5355,2025-06-06 合并、随 3.1.0.0 于 2025-06-24 发布);2.x 上只能靠扁平 claim,这也是建议方案 B 的原因之一。

1.3 浏览器路径用 ID token,API 路径用 access token

访问路径携带的 tokenroles claim 必须出现在
Dashboards 浏览器登录ID token(typ: "ID")ID token
应用 / curl 直连 :9200access token(typ: "Bearer")access token

排查时先解码 token 的 payload 看 typ,再决定去查哪个开关:

echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq '{typ, iss, aud, preferred_username, roles, realm_access}'

两条路径的 aud 口径也不同:ID token 按 OIDC Core 必须包含 client_id,所以 required_audience 填 client_id 在浏览器路径上成立;Keycloak 默认给 access token 的 aud 通常是 account( Keycloak + oauth2-proxy 篇 里的 expected audience got account 就是它),直连 API 路径要么补 audience mapper,要么不要在同一个 domain 上把 required_audience 当成通用闸门。

2. Keycloak 端配置

字段值
Client IDos-dashboards
Client authenticationON(Dashboards 用 client secret 换 token)
Standard flowON
Direct access grantsOFF(浏览器只走授权码流程)
Valid redirect URIshttps://osd.example.com/auth/openid/login
Signature AlgorithmRS256(Security 插件要能从 JWKS 取到公钥验签)

回调路径不是随手写的:security-dashboards-plugin/common/index.ts 里定义 OPENID_AUTH_LOGIN = '/auth/openid/login',redirect URL 由 getBaseRedirectUrl() 计算——优先用配置里的 opensearch_security.openid.base_redirect_url(去掉尾斜杠),否则取 Dashboards 自己认为的地址(server.host / server.port,即容器内地址)拼上 server.basePath——只有打开 openid.trust_dynamic_headers 时才会改用代理传来的 X-Forwarded-Host / X-Forwarded-Proto。所以反向代理后面部署时必须显式配 base_redirect_url,否则 Keycloak 收到的回调地址会是容器内地址,表现就是回调 400 / 登录后在 Dashboards 里打转。

3. OpenSearch 端配置(config/opensearch-security/config.yml)

_meta:
  type: "config"
  config_version: 2

config:
  dynamic:
    authc:
      basic_internal_auth_domain:
        http_enabled: true
        transport_enabled: true
        order: 0
        http_authenticator:
          type: basic
          challenge: false
        authentication_backend:
          type: internal
      openid_auth_domain:
        http_enabled: true
        transport_enabled: false
        order: 1
        http_authenticator:
          type: openid
          challenge: false
          config:
            subject_key: preferred_username
            roles_key: roles
            openid_connect_url: https://kc.example.com/realms/myrealm/.well-known/openid-configuration
            required_audience: os-dashboards
            jwt_clock_skew_tolerance_seconds: 30
            cache_jwks_endpoint: true
        authentication_backend:
          type: noop

几个参数的实际含义:

  • challenge: false + authentication_backend: noop:OpenID 域的 JWT 自带全部验证信息,不需要再向 IdP 发一次密码校验;把 challenge 开成 true 会让未认证请求收到 WWW-Authenticate 挑战,浏览器侧表现为登录页与 401 之间来回跳。
  • basic_internal_auth_domain:给 Dashboards 的服务账号(默认 kibanaserver)和运维脚本留一条本地入口。官方 OIDC 文档的表述是「Dashboards 并不严格要求 HTTP basic;以 OpenID 为主时把 challenge 设为 false;只有还要支持自动化服务的 HTTP basic 才需要配多个认证域」。生产上仍建议保留这个域——回滚时它就是那条退路(见第 8 节)。
  • cache_jwks_endpoint:OIDC 域下默认 false,只有配 jwks_uri 的 JWT 域才默认开启。不打开意味着每次需要刷新密钥时都回源 IdP,高 QPS 下给 IdP 的压力和首包延迟都不小;显式打开不影响密钥轮换(遇到未知 kid 仍会重取)。
  • jwt_clock_skew_tolerance_seconds:默认 30 秒。容器与 IdP 时钟漂移超过这个窗口,exp / nbf 校验失败,症状是「刚登录就 401」或间歇性掉登录。
  • subject_key:不配则用 RFC 7519 的 sub。Keycloak 的 sub 是用户 UUID,审计与映射表可读性差,实践里填 preferred_username。

改完用 securityadmin 重放,不要只改文件不生效:

/usr/share/opensearch/plugins/opensearch-security/tools/securityadmin.sh \
  -f /usr/share/opensearch/config/opensearch-security/config.yml \
  -icl -nhnv \
  -cacert /etc/opensearch/root-ca.pem \
  -cert /etc/opensearch/admin.pem \
  -key /etc/opensearch/admin-key.pem

4. Dashboards 端配置(config/opensearch_dashboards.yml)

server.host: 0.0.0.0
server.port: 5601
opensearch.hosts: ["https://os.example.com:9200"]
opensearch.ssl.verificationMode: none          # 仅内网自签证书时使用
opensearch.username: kibanaserver
opensearch.password: "${KIBANASERVER_PASSWORD}"
opensearch.requestHeadersAllowlist: ["Authorization", "securitytenant"]

opensearch_security.auth.type: "openid"
opensearch_security.openid.connect_url: "https://kc.example.com/realms/myrealm/.well-known/openid-configuration"
opensearch_security.openid.client_id: "os-dashboards"
opensearch_security.openid.client_secret: "${OIDC_CLIENT_SECRET}"
opensearch_security.openid.base_redirect_url: "https://osd.example.com"
opensearch_security.openid.scope: "openid profile email"

4.1 命名空间是 opensearch_security,不是 plugins.security

这是官方文档里长期存在的矛盾点:OpenID Connect 认证页与 multi-auth 页用的是 opensearch_security.*,而 troubleshooting 页引用的却是 plugins.security.openid.connect_url。以插件自己的 manifest 为准——securityDashboards/opensearch_dashboards.json 里声明:

"configPath": ["opensearch_security"]

configPath 决定配置项在 opensearch_dashboards.yml 里的前缀。写成 plugins.security.* 时 Dashboards 启动阶段就会失败,报错原文:

FATAL ValidationError: child "plugins" fails because ["security" is not allowed]

这个问题 2021 年就在 OpenSearch-Dashboards#686 里报过并修正过文档,但 troubleshooting 页至今仍留着旧写法。看到这类报错先改前缀,不要怀疑证书或 connect_url。

4.2 与 ID token 生命周期相关的两个设置

  • openid.refresh_tokens:schema 默认 true。登录时拿到的 refresh token 会在 ID token 过期后用来换新 ID token;换回来没有 id_token 时该会话判为失效,用户被送回登录页。IdP 侧不给 refresh token 或不允许返回 ID token,就会表现为「会话时长明显短于 session.ttl」。
  • cookie.ttl / session.ttl:控制的是 Dashboards 自己 cookie 的存活时间,不改 ID token 的 exp。把 cookie.ttl 调到一周并期望「一周不用重登」是常见误配,实际天花板由 IdP 的 token 生命周期与 refresh 行为决定(同类讨论见 security-dashboards-plugin#1711)。

4.3 登出不会顺带结束 Keycloak 会话

openid.logout_url 只在 IdP 的 discovery 文档没有 end_session_endpoint 时才需要配;Keycloak 会发布该端点,所以一般不用填。但 Dashboards 登出与 Keycloak 单点登出是两套会话,跨应用退出行为另配,思路见 IAM 单点登出排错。

5. 第二段静默失败:backend_roles 没有映射到角色

认证通过不等于有权限。roles_key 取出来的值是用户的 backend_roles,它们必须再映射到 OpenSearch 角色才有权限,否则报错信息里会带着实际取到的东西——这句话本身就是最好的诊断入口:

{
  "error": {
    "type": "security_exception",
    "reason": "no permissions for [cluster:monitor/main] and User [name=alice, backend_roles=[os_viewer], requestedTenant=null]",
    "status": 403
  }
}

backend_roles=[os_viewer] 说明 claim 取到了,缺的只是映射;backend_roles=[] 说明问题还在第 1 节。映射两种方式:

# REST API
curl -X PUT -u admin:"$PASSWORD" -k \
  "https://os.example.com:9200/_plugins/_security/api/rolesmapping/os_readonly" \
  -H 'Content-Type: application/json' \
  -d '{"backend_roles": ["os_viewer"]}'
  • 或在 Dashboards:Security → Roles → <角色> → Mapped users → Manage mapping,把值填进 Backend roles 一栏。
  • 不要填进 Users 一栏:backend role 与内部用户名是两套命名空间,把 IdP 组名当作内部用户名加进去不会有任何效果,现象与「没配映射」完全一样。

这套「claim → backend_roles → 角色/权限」的模型与 Keycloak 细粒度授权 里讲的 Groups/Roles 分工是同一件事的两端:IdP 只负责把值带过来,授权决定始终在 OpenSearch 的 roles / rolesmapping 里。

6. 验证顺序

顺序不能反,否则会在下游查上游的问题。

  1. 确认 discovery 与 issuer 一致,且从 Dashboards 所在的网络能访问到:
curl -s https://kc.example.com/realms/myrealm/.well-known/openid-configuration | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri}'
  1. 在 Keycloak 里先看 ID token(Clents → <client> → Client scopes → Evaluate → 选一个用户 → Generated ID token)。看不到 roles(或 realm_access.roles)就不要往下配 OpenSearch——下游必然失败。这一步能同时确认嵌套结构、值形状和 claim 名字。
  2. 解码 token 看 typ,区分浏览器路径与 API 路径(命令见 1.3)。
  3. 直连 API 看 Security 插件取到了什么:
curl -s -k -H "Authorization: Bearer $ACCESS_TOKEN" \
  https://os.example.com:9200/_plugins/_security/authinfo | jq '{user_name, backend_roles, roles}'

注意 user_name 来自 subject_key:用服务账号(Client Credentials)拿到的 token 里没有 preferred_username,此时 subject_key: preferred_username 会直接报 Failed to get subject from JWT claims。混合人类用户与服务账号的集群,建议给服务账号单独开一个 authc 域,别挤在同一个 openid 域里。 5. 浏览器正测试:能登录、能看到符合角色的仪表盘。 6. 负测试:给一个不含 roles 的账号登录,确认它进去之后没有超出预期的权限(而不是「也能看」)。没有负测试就无法区分「映射生效」与「角色本来就没限制」。 7. 需要原文时打开 trace 日志(config/log4j2.properties,重启节点):

logger.securityjwt.name = com.amazon.dlic.auth.http.jwt
logger.securityjwt.level = trace

文档给的是这个 logger 名;注意 3.0 起 security 插件把包从 com.amazon.dlic 迁到 org.opensearch.security,如果按上面的名字不出日志,按你实际版本的包名核对一次。

7. 常见错误对照表

症状 / 报错根因处理
OpenSearch 日志 Failed to get roles from JWT claims with roles_key '[...]'ID token 里没有该 claim:Keycloak 的 realm roles mapper 默认只写 access token勾 Add to ID token;嵌套用 YAML 列表;或换扁平 roles claim
Failed to get subject from JWT claimssubject_key 与 token 实际 claim 不匹配(access token 没有 preferred_username)用 ID token 的 preferred_username,或留空用 sub
Failed when trying to obtain the endpoints from your IdP组件到 connect_url 不可达:DNS、内网地址、证书链在容器内直接 curl 一次 discovery 地址;OIDC 域需要 enable_ssl / 证书配置
Dashboards 启动即 FATAL ValidationError: child "plugins" fails because ["security" is not allowed]配置里用了 plugins.security.*改成 opensearch_security.*(以插件 manifest 的 configPath 为准)
登录成功,但页面 403 no permissions for [...]backend_roles 取到了但没映射到 OpenSearch 角色rolesmapping 写 Backend roles,不是 Users
登录后立刻跳回登录页 / 频繁 401时钟漂移超出 30 秒容忍窗口,或 base_redirect_url 与实际外部地址不一致校准 NTP;配置 base_redirect_url 或 trust_dynamic_headers
会话比预期短很多ID token 生命周期与 refresh 行为决定上限,cookie.ttl 管不到核对 IdP 的 token 有效期与 openid.refresh_tokens
多租户相关操作报无权限,但角色正常opensearch.requestHeadersAllowlist 缺 securitytenant补上(旧教程里的 requestHeadersWhitelist 是改名前的写法)

8. 回滚

顺序:先摘 Dashboards,再动 OpenSearch,最后才是 Keycloak。

  1. Dashboards:把 opensearch_security.auth.type 改回 basicauth 并重启,用户立刻回到用户名密码登录;OIDC 段配置先留着,别删。
  2. OpenSearch:把 openid_auth_domain.http_enabled 置 false 后重放 config.yml(http_enabled: false 比直接删段更容易回滚)。此时依赖 basic_internal_auth_domain 的本地账号仍然可用。
  3. Keycloak:只禁用 client,不删除。禁用立刻停止新登录,redirect URI 与 mapper 全部保留;删掉就要重建整套配置。
  4. 全程不要动已建立的 rolesmapping 和已建用户:映射与账号是幂等资产,重建成本远高于留着。
  5. 回滚验证清单:kibanaserver / 内部管理员能登录、原有角色权限未变、自动化脚本(走 basic 或 API token 的)仍能访问集群。

常见问题(IAM 单点登录)

Q1:Keycloak 里用户的 roles 明明有值,为什么 OpenSearch 说取不到?

因为「有值」是在 access token 里。Dashboards 的 OIDC 登录流程在源码里用的是 Bearer ${idToken} 去认证集群并作为会话凭据,Security 插件看到的是 ID token。Keycloak 内置 roles scope 的两个 mapper 默认只写 access token,所以 OpenSearch 侧必然取不到——去 Client scopes → roles → Mappers 勾上 Add to ID token,或改用扁平 claim。

Q2:官方文档写 plugins.security.*,我照着写为什么起不来?

文档这一处是历史遗留。插件的 opensearch_dashboards.json 里 configPath 是 ["opensearch_security"],它决定配置前缀;用 plugins.security 会在启动校验阶段直接 FATAL。以插件 manifest 和发行版自带的 config.yaml 示例为准。

Q3:roles_key 取到值就等于有权限了吗?

不等于。取到的值是 backend_roles,必须再映射到 OpenSearch 角色。这也是为什么 403 报错里会把 backend_roles=[...] 打出来:它同时告诉你「claim 取到了什么」和「还缺哪一步」。

Q4:能不能让 Dashboards 只做登录,权限完全由 Keycloak 决定?

不能。Security 插件的授权判定发生在 OpenSearch 侧(索引/集群权限、DLS/FLS、租户),Keycloak 只负责把用户与角色值带进来。把 IdP 组名映射到哪个 OpenSearch 角色,是这套接入里唯一需要人工设计、且必须写进评审记录的部分。

Q5:CS 场景要不要额外给脚本开 Direct Access Grants?

不要。浏览器登录走授权码流程;脚本直连集群用 Client Credentials 拿 access token(并注意 subject_key 与服务账号 token 的兼容性,见第 6 节第 4 步)。ROPC 在 OAuth 2.1 中已被移除,不要为了省事打开它。

参考来源

相关章节: Keycloak IAM 第三方软件集成指南、 MinIO 接入 Keycloak OIDC:IAM 策略映射与 SSO 排错、 Harbor 接入 Keycloak OIDC、 GitLab 接入 Keycloak OIDC、 Keycloak + oauth2-proxy 集成指南、 Keycloak 细粒度授权、 IAM 单点登出排错。