OpenSearch Dashboards 接入 Keycloak OIDC:IAM 单点登录与 roles claim 取不到的排错 | IDaaS Book
场景
- 自建 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 里维护者对rolesclient 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. 打开内置 mapper | Client 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 挂给该 client | roles |
方案 B 的 claim 是扁平数组,不依赖嵌套取值语法,语义也比 realm_access.roles 清楚,多个组件共用同一个 client 时更省事。若还有应用要直连集群 API,mapper 的 Add to access token 也要打开(见 1.3)。
rolesscope 本来就是 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
| 访问路径 | 携带的 token | roles claim 必须出现在 |
|---|---|---|
| Dashboards 浏览器登录 | ID token(typ: "ID") | ID token |
应用 / curl 直连 :9200 | access 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 ID | os-dashboards |
| Client authentication | ON(Dashboards 用 client secret 换 token) |
| Standard flow | ON |
| Direct access grants | OFF(浏览器只走授权码流程) |
| Valid redirect URIs | https://osd.example.com/auth/openid/login |
| Signature Algorithm | RS256(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.pem4. 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. 验证顺序
顺序不能反,否则会在下游查上游的问题。
- 确认 discovery 与 issuer 一致,且从 Dashboards 所在的网络能访问到:
curl -s https://kc.example.com/realms/myrealm/.well-known/openid-configuration | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri}'- 在 Keycloak 里先看 ID token(Clents →
<client>→ Client scopes → Evaluate → 选一个用户 → Generated ID token)。看不到roles(或realm_access.roles)就不要往下配 OpenSearch——下游必然失败。这一步能同时确认嵌套结构、值形状和 claim 名字。 - 解码 token 看
typ,区分浏览器路径与 API 路径(命令见 1.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 claims | subject_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。
- Dashboards:把
opensearch_security.auth.type改回basicauth并重启,用户立刻回到用户名密码登录;OIDC 段配置先留着,别删。 - OpenSearch:把
openid_auth_domain.http_enabled置false后重放 config.yml(http_enabled: false比直接删段更容易回滚)。此时依赖basic_internal_auth_domain的本地账号仍然可用。 - Keycloak:只禁用 client,不删除。禁用立刻停止新登录,redirect URI 与 mapper 全部保留;删掉就要重建整套配置。
- 全程不要动已建立的 rolesmapping 和已建用户:映射与账号是幂等资产,重建成本远高于留着。
- 回滚验证清单:
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 中已被移除,不要为了省事打开它。
参考来源
- OpenSearch: OpenID Connect authentication(
openid_auth_domain最小配置、challenge: false与authentication_backend: noop、subject_key/roles_key/required_audience/required_issuer/jwt_clock_skew_tolerance_seconds/cache_jwks_endpoint的参数语义、roles_key支持列表形式取嵌套 claim、Dashboards 侧opensearch_security.openid.*配置项) - OpenSearch: OpenID Connect troubleshooting(
Failed to get roles from JWT claims with roles_key、Failed to get subject from JWT claims、Failed when trying to obtain the endpoints from your IdP三条报错的官方定位方法;logger.securityjwttrace 日志;该页 Dashboards 段仍使用plugins.security.*写法) - OpenSearch: Security configuration APIs(
roles_key取出的 backend roles 用于 role mapping、role_mappings示例、_plugins/_security/api/rolesmapping的写法) - OpenSearch: JSON Web Token authentication(
no permissions for [cluster:monitor/main] and User [name=..., backend_roles=[...]]原文;backend roles 与内部用户是两个命名空间) - opensearch-project/security-dashboards-plugin(
opensearch_dashboards.json的configPath: ["opensearch_security"];server/auth/types/openid/routes.ts用Bearer ${tokenResponse.idToken}认证并写入会话;common/index.ts的OPENID_AUTH_LOGIN = '/auth/openid/login';server/auth/types/openid/helper.ts的getBaseRedirectUrl();server/index.ts的openid.*schema 默认值) - [OpenSearch-Dashboards#686 — ValidationError: child “plugins” fails because
“security” is not allowed(
plugins.security前缀在 Dashboards 上不合法、正确前缀是opensearch_security的原始记录) - security-dashboards-plugin#2250 — Support extracting roles from accessToken in OIDC flow(OIDC 登录流程使用 ID token 向集群发请求、access token 只作为可选来源的讨论)
- security-dashboards-plugin#1711 — conflicting token expiration and cookie + session config(
cookie.ttl/session.ttl与 token 过期的相互关系) - keycloak/keycloak#14617 — ID token is not including roles(维护者确认
rolesclient scope 的realm roles/client rolesmapper 出厂只开启 Add to access token;开启 Add to ID token 后 ID token 才带角色) - OpenSearch forum: Nested claim for JWT auth, can’t find roles(嵌套 claim 必须写成 YAML 列表、点号写法取不到;区分
typ: ID与typ: Bearer的实测过程)
相关章节: Keycloak IAM 第三方软件集成指南、 MinIO 接入 Keycloak OIDC:IAM 策略映射与 SSO 排错、 Harbor 接入 Keycloak OIDC、 GitLab 接入 Keycloak OIDC、 Keycloak + oauth2-proxy 集成指南、 Keycloak 细粒度授权、 IAM 单点登出排错。