ADR 0006: Keycloak Client(OIDC)管理を Console に引き上げる際の所有権・保護対象・シークレット・権限モデル
- Status: Proposed
- Date: 2026-10-09(2026-10-09 改訂1: レビューで指摘された2点の矛盾——関電工自身の Organization を Client の所有者に
できないと読める記述と「内部・外部どちらの Client も作る」という第一フェーズのスコープが矛盾していた点、および
作成失敗・冪等リプレイ時に secret を返せない具体的な挙動が未定義だった点——を解消。あわせて、スコープ割り当ての
記述・denylist の対象識別子・attribute 方式の参照整合性・保護対象リストの具体例・
manage-clients付与のリスク 説明・http://localhost許可条件・reveal の設計説明の矛盾など、指摘された残りの論点も反映。該当箇所は決定1・2・ 3・4・5・6・7 および Context・Non-Goals・Consequences/2026-10-09 改訂2: 実装着手時に判明した誤りを訂正——決定6が 「Client 作成は Organization 作成と同型でresolvedTargetIdによって ID を確定する」としていたのは誤りだった。 Organization の ID は作成時に Keycloak が採番するため未確定だが、Client のclientIdは呼び出し元が指定する値でreserve()の時点ですでに分かっており、resolvedTargetId機構を使わない。該当箇所は決定6のみ)
Context
Section titled “Context”Issue #58(Identity Console: Keycloak Client(OIDC)作成・設定機能を Console に引き上げる)は、計画段階のレビューで 複数の P1(実装仕様化前に解決必須)指摘を受けたまま止まっている。本 ADR はその P1 指摘をすべて決定し、実装 PR が 迷わず着手できる土台を作ることだけをスコープにする——Organization 書き込み(ADR 0003 → ADR 0005 → issue #81)と まったく同じ「先に ADR で前提を固める」進め方を踏襲する。
前提として先に揃っているもの(いずれも issue #81 の実装 PR 上で実装済み——本 ADR の執筆時点では develop に
Section titled “前提として先に揃っているもの(いずれも issue #81 の実装 PR 上で実装済み——本 ADR の執筆時点では develop に”未マージだが、コードとしては既に存在する。実装着手の前提条件はこの PR が develop にマージされていることのみ)
- 監査モデル: ADR 0004(+ 改訂2)で
Operationの target はtarget_id(nullable)+target_typeに一般化済み。 作成系アクション(reserve()時点で ID 未確定のまま意図を記録し、upstream 成功後にresolvedTargetIdで確定する) の生命周期もOperationService.execute()→OperationStore.complete()まで実装・結線済み (identity-admin/.../audit/ActionRunner.ktのActionResult.resolvedTargetId)。Issue #58 の P1 の1つだった 「作成前に ID が存在しない問題」は、Organization 書き込み(issue #81)がこの土台を使って実証済みである。 (本改訂2で訂正——旧版は「Client 作成もそのまま同じ機構に乗せられる」としていたが、これは誤り。) Client のclientIdは呼び出し元が指定する値であり(決定3)、reserve()の時点ですでに分かっている——Organization の ような「作成前に ID が存在しない問題」は Client の target ID には生じず、resolvedTargetId機構を使わない。 Client 特有の「作成前に値が存在しない問題」は secret についてのみ生じる(Keycloak が作成時に自動生成し、 作成成功後の2段階目の呼び出しでしか取得できない)——作成が失敗・不確定(PARTIAL/UNKNOWN)だった場合や冪等 リプレイ時に secret をどう扱うかは、Client 特有の論点として本 ADR の決定6で別途詰める(詳細は決定6参照)。 - 書き込み機能の実装パターン:
OrganizationsWriteService/OrganizationWriteRequests.kt(issue #81 実装)が 「対象 ID の保護チェック → ホワイトリストされたリクエスト DTO →OperationService.execute()」という型を確立済み。 本 ADR の決定も同じ型に乗せる前提で書く。 - サービスアカウントの既存ロール方針:
identity-admin/.../keycloak/KeycloakAdminClient.ktのクラス doc とKeycloakAccountVerifier.kt(43-44行目)がすでに明記している通り、view-clientsは意図的に付与していない—— 理由は「realm 内の全 client の secret を読めてしまう」ため。これは本 ADR の決定5(manage-clientsのリスク) の直接の前例であり、同じ懸念がmanage-clientsにも(より強い形で)当てはまることを前提に設計する。 - 権限モデルの命名規則:
Permission(identity-admin/.../security/PermissionService.kt)はORGANIZATIONS_READに続いて issue #81 の実装でORGANIZATIONS_WRITEが追加済み(read/write を別権限に分け、write はPLATFORM_ADMINのみに付与、という ADR 0005 の決定2のパターン)。本 ADR もこの命名・付与方針をそのまま踏襲する。
Issue #58 本文・レビューコメントで指摘された論点(本 ADR で解決する)
Section titled “Issue #58 本文・レビューコメントで指摘された論点(本 ADR で解決する)”P1(実装仕様化前に解決必須、の初期整理)4点 + その後のレビューで追加された P2(設計時に意識、ブロッカーではない)点を まとめて本 ADR で決定する——「P1 だけ解決すれば着手できる」という区分にこだわらず、未決定のまま残すと実装者が 迷う論点はすべてここで決める。
- 所有権(どの Organization が管理できるか)とログイン許可ポリシー(どのユーザー/組織がそのアプリにログインできるか)の分離。
identity-console/identity-console-serviceのような基盤 Client の保護(manage-clientsを持つサービスアカウントが 自分自身を改変できてしまうリスクを含む)。- Client secret のライフサイクルと監査経路の分離(
Operation/audit に secret を乗せない)。 - 作成前に ID が存在しない問題 → 一般的な機構は解決済み(上記)。Client 特有の失敗・リプレイ時の挙動は決定6で追加決定。
- 「OIDC Client」の範囲の確定(SPA/Web/M2M ごとの許可設定、任意の
ClientRepresentation透過の禁止)。 - (P2)既存 Client の所属の扱い、および所有権の権威ストアをどこに置くか。
Decision
Section titled “Decision”1. 第一フェーズで対象にする Client 種別とその許可設定
Section titled “1. 第一フェーズで対象にする Client 種別とその許可設定”第一フェーズは OIDC のみ(SAML は確定事項として後続フェーズ、issue #58 コメント参照)。対象は次の3種別のみとし、
呼び出し側は種別を選ぶだけで、種別ごとにサーバー側で固定した設定だけが Keycloak に送られる——任意の
ClientRepresentation や protocol mapper、role mapping を呼び出し側が指定できる経路は作らない。
| 種別 | 用途 | publicClient |
認証方式 | 必須設定 |
|---|---|---|---|---|
| SPA | ブラウザで完結するフロントエンド | true |
なし(public) | PKCE 必須(attributes["pkce.code.challenge.method"] = "S256")、redirectUris/webOrigins は完全一致のみ(ワイルドカード不可)、standardFlowEnabled = true、他の grant(direct access, implicit, service account)はすべて false |
| Web(サーバーサイド confidential) | サーバーサイドで完結するアプリケーション | false |
client secret | PKCE 推奨(必須にはしない——confidential なら secret 自体が第一要因になるため)、redirectUris 完全一致、standardFlowEnabled = true、serviceAccountsEnabled = false |
| M2M(service account) | 人間のログインを介さないサーバー間連携 | false |
client secret | serviceAccountsEnabled = true、standardFlowEnabled = false、redirectUris = []、webOrigins = [] |
fullScopeAllowedは常にfalseで作成する(issue #58 レビュー指摘: これがトークンに含まれるロールに直結する ため、“外部は有効期限を短くする” 程度の代替では済まない)。 (レビュー指摘により訂正) 「スコープを何も割り当てない」のは client scope(defaultClientScopes)の話では ない——これは realm のデフォルト client scope(basic/profile/email/roles/web-origins/acr等)を指し、 これらは Keycloak がPOST /clients時に自動付与するものをそのまま使う(明示的に[]を送って外すと、 トークンにsubが入らない・web-originsチェックが効かないなど、ログインそのものが壊れる)。本 ADR が 「何も割り当てない」と決めているのは role scope mapping(realm/client ロールをこの Client のトークンに 含める設定)のことであり、こちらは第一フェーズで一切付与しない。明示的なロールスコープ割り当て UI は 第一フェーズの範囲外(issue #58 の non-goal、下記)。- リダイレクト URI・Web Origins は作成・更新時にサーバー側でフォーマット検証する(スキームが
httpsであること、 ワイルドカードを含まないこと)。Keycloak 自身の検証に丸投げしない——Organization のredirectUrl/descriptionと 異なり、ここは実際の認可フローのセキュリティ境界そのものであるため。 (レビュー指摘により訂正) ローカル開発でのhttp://localhost許可は、realm 名(例:gcu)を根拠にしない—— 本番・開発で同じ realm 名を使い得る以上、realm 名からの環境推測は ADR 0005 §1 が却下したドメイン名推測と同じ 危うさを持つ。許可する場合は明示的な設定値(例: 専用の Spring profile、またはidentity-admin.client-management.allow-insecure-local-redirectのような真偽値)で制御する——具体的な名前・ 仕組みは実装 PR で決める。 - 第一フェーズで作らない設定は、デフォルトで最も安全な側に倒す(例:
directAccessGrantsEnabled = falseを常に明示)。
2. 所有権(ownerOrganizationId)とログイン許可ポリシーは別概念——後者は第一フェーズで変更しない
Section titled “2. 所有権(ownerOrganizationId)とログイン許可ポリシーは別概念——後者は第一フェーズで変更しない”- 所有権: どの Organization の管理者がこの Client の設定・secret を扱えるか。新設の Keycloak Client attribute
identity-hub.owner-organization-id(ADR 0005 §1 の「明示的な allowlist/denylist を設定値として持つ」convention とは違う層の話——こちらは Client 側のメタデータなので、Client representation 自身の attribute として持つ)。- 却下した代替案: identity-admin 側に独自の Client-Organization 対応テーブルを持つ。Keycloak 側で Client が 削除された場合にデータがずれる(孤立行が残る)というリスクが常につきまとう。Client attribute として持てば、 Client が削除されれば attribute も一緒に消え、参照整合性の問題がそもそも発生しない。 (レビュー指摘により明記) ただしこの一方向の整合性保証は「Client が削除された場合」にしか効かない—— 「Organization 側が無効化・削除された場合、所有している Client をどう扱うか」は対称的に未解決のまま残る。 現時点では Organization の無効化・削除機能自体が Console にまだ存在しない(issue #81/ADR 0005 §3 が どちらも「別タスク」として未実装のまま先送りしている)ため、本 ADR の時点ではこの状態は実際には発生し得ない。 Organization の無効化・削除が実装される時点で、「所有する Client がある Organization は無効化を拒否する」 「警告を出すだけで無効化は許す(Client 自体は生き続ける、孤児化した所有権として扱う)」のどちらを採るかを、 その実装 ADR の中で決定する——本 ADR はこれを先送り事項として明示するに留める(Non-Goals 参照)。
identity-hub.*名前空間の attribute は、このメタデータ専用に予約する。 呼び出し側が任意の attribute を 送れる経路は作らない(上記の「許可設定の allowlist」と同じ原則)——ownerOrganizationIdは作成時に Console が 自動で埋め、以後は読み取り専用(更新リクエストに含まれるフィールドではない。Organization のaliasと同じ 「作成時に決まり、以後変更不可」パターン。つまり所有権の移転は第一フェーズでは一切できない——Non-Goals 参照)。- (レビュー指摘により訂正——旧版の矛盾を解消) Client を作成できるのは
CLIENTS_WRITEを持つ内部管理者 (決定7、第一フェーズはPLATFORM_ADMINのみ)であり、ORGANIZATIONS_WRITEではない——Client 作成は Organization 自体への書き込みではなく、ownerOrganizationIdに既存の Organization の ID を指定する だけの操作なので、要求される権限は Client 側の権限(CLIENTS_WRITE)であって Organization 側の権限ではない。 対象 Organization の存在確認(keycloak.getOrganization(organizationId)、読み取りのみで十分)は行うが、 関電工自身の Organization(internal-organization-id)を拒否しない——issue #58 の第一フェーズは 「内部・外部システムの両方について Client を作成する」ことを明示的な要件にしており(Context・Non-Goals 参照)、関電工自身が運用する内部システムの Client も当然この仕組みで作られる必要があるため。 ここが旧版との決定的な違い: ADR 0005 §1 の「内部組織 ID チェック」は “この ID を書き込みの対象にしては いけない”(Organization 自体を編集・無効化させない)という話であり、“この ID を他の何かの外部キー値として 参照してはいけない” という話ではない。Client のownerOrganizationIdに関電工自身の Organization ID を 指定することは、Organization 自体への書き込みを一切伴わないため、ADR 0005 §1 の制約と衝突しない。 拒否するのは「実在しない Organization ID が指定された場合」のみ(400/404、具体的なコードは実装 PR で決める)。
- ログイン許可ポリシー: どのユーザー/どの Organization のメンバーがこの Client にログインできるか。
第一フェーズでは変更しない——Keycloak の既定動作(realm 内の任意のユーザーがどの Client にもログインできる)
のままにする。所有権(
ownerOrganizationId)を見てログイン可否を判定するコードは、identity-admin のどこにも 書かない——これは issue #58 レビューで指摘された「協力会社が運用するシステムに関電工社員がログインする」という 逆方向のケースを壊さないための、本 ADR で確定する禁止事項。将来ログイン制御が必要になった場合 (Organization への identity provider 紐付け・client scope によるドメイン制限など)は、別の ADR で設計する。 - 第一フェーズでの実効性に関する注記(レビュー指摘):
CLIENTS_WRITEは第一フェーズではPLATFORM_ADMINのみに 付与される(決定7)ため、ownerOrganizationIdを見て「この Organization の管理者だけがこの Client を扱える」と いう権限分岐は、第一フェーズの時点では何も強制していない(誰が触っても同じPLATFORM_ADMINだから)。 この属性は協力会社代表者のセルフサービス Client 管理(Non-Goals、後続フェーズ)が実装されて初めて認可判定に 使われる、現時点では純粋なメタデータである——読者がこれを見て「org スコープの認可が今すでに効いている」と 誤解しないよう明記する。
3. 保護対象(基盤)Client の denylist——アプリケーション層で強制する
Section titled “3. 保護対象(基盤)Client の denylist——アプリケーション層で強制する”AdminProperties.ProtectionにprotectedClientIds: Set<String>(clientId文字列、Keycloak の内部 UUID では ない——運用担当者が読んで分かる値で設定できるようにする)を追加する。protected-user-ids/internal-organization-idと同じ「明示的な allowlist/denylist を設定値として持つ」convention(env var:CONSOLE_PROTECTED_CLIENT_IDS)。 最低限、本リポジトリが実際に使っているidentity-console・identity-console-service・mypageに加え、 Keycloak が realm ごとに自動生成する組み込み Client(account・account-console・broker・realm-management・security-admin-console・admin-cli)も運用担当者が列挙する——(レビュー指摘により修正) これらは Console の 機能とは無関係に Keycloak 自身が最初から持っている Client であり、見落とすと「Console からは何も作っていないのに 書き込み可能な基盤 Client」が残ってしまう。Console がこれを自動検出する手段はない(internal-organization-idと 同じ理由: 検出ロジックを作るより、明示的なリストの方が設定ミスに強い)。clientId(人間可読な識別子、Keycloak の内部 UUID ではない)は、作成後は不変として扱う。 Organization のaliasと同じ「作成時に決まり、以後変更不可」パターン——更新リクエストにclientIdのフィールドは存在しない。 (レビュー指摘により追記) Keycloak の書き込み系・secret 系エンドポイント(PUT/GET/POST /clients/{id}/...)は いずれも内部 UUID でアドレッシングする一方、Console の API パス・denylist は人間可読なclientIdで扱う——両者の 対応付けはGET /clients?clientId={clientId}による読み取り専用の解決ステップが必要で、これ自体は denylist チェックの対象にしない(害のない読み取りであり、“Keycloak 呼び出しより前に拒否する” という原則は書き込み・ secret 系の呼び出しに対してのものである、と明確化する)。denylist 判定は、この解決ステップで得たclientId(パス変数として渡された値そのもの、Keycloak から返ってきた値ではなく)に対して行う——UUID とclientIdの 対応表を自前で持つ必要はない。- すべての Client 書き込み・機微操作メソッド(作成・更新・secret reveal・secret 再発行・削除——削除は
第一フェーズの範囲外だが、将来追加されても同じ規律を適用する)は、対象
clientIdがこの denylist に含まれる 場合、Keycloak 呼び出しより前に拒否する。(レビュー指摘により明記) 作成時にも必ずチェックする——呼び出し元が denylist に載っている名前(例:identity-console-service)を新規clientIdとして指定してきた場合、Keycloak 自体もいずれ重複エラー(409)で弾くが、Console 独自の明確なエラーとして先に拒否する方が診断しやすく、実装も 更新系と対称になる。Organization の内部組織 ID チェック(ADR 0005 §1、OrganizationsWriteService.update()実装) と同じ「404(保護されているのではなく、そもそも書き込み対象として存在しない)」の扱いにする。 - この denylist は実装 PR 自身が作り込む責務であり、Keycloak 側の権限設定(Fine-Grained Admin Permissions 等)には
依存しない。 Keycloak 側でより狭く
manage-clientsをスコープできないかの調査は、本 ADR では決定5で「第一フェーズの 必須要件にはしない、後続の多層防御タスクとして切り出す」と明記する——理由は決定5参照。
4. 既存 Client の扱い(P2 の解決)——インポートは第一フェーズでは行わない
Section titled “4. 既存 Client の扱い(P2 の解決)——インポートは第一フェーズでは行わない”ownerOrganizationIdattribute を持たない Client は、Console からの書き込みの対象に一切ならない。 これは「既存 Client の所属をどう推測するか」という問題そのものを発生させない、最もシンプルな解決——名前・作成者の 現在の所属・Client 管理者が自由に書き換えられる属性からの推測は最初から不要になる。- 第一フェーズには、既存 Client に
ownerOrganizationIdを事後的に設定する「インポート」機能を含めない。 必要になった時点で、どの Client を・誰が・どの Organization に帰属させるかという業務判断を伴う、別の ADR 改訂/実装 PR として扱う。 - 結果として、
identity-console・identity-console-serviceのような基盤 Client はこの仕組みにより 「そもそも対象でない」ため書き込めず、決定3の denylist は二重の防御として働く(attribute が万一 手動で設定されてしまった場合の最後の砦)。
- 第一フェーズには、既存 Client に
5. サービスアカウント権限の拡張と、自己改変リスクへの対応方針
Section titled “5. サービスアカウント権限の拡張と、自己改変リスクへの対応方針”identity-console-serviceに realm-management のmanage-clientsロールを追加付与する(Client の作成・更新・ secret 取得に必須)。(レビュー指摘により訂正) より細かいロール自体は存在する(create-client・query-clientsなど)が、更新・secret 取得の両方をカバーするロールはmanage-clientsの他にない—— 「より細かいロールが存在しない」のではなく「この用途に必要な操作をすべてカバーする、より細かいロールがない」。- この拡張がもたらす具体的なリスクを、過小評価せず正確に書く(レビュー指摘により訂正):
manage-clientsは Keycloak の実装上 view 相当の権限を内包する(manage-clientsを持てばview-clients相当の読み取りチェックも通る)。つまりこのロールを付与した瞬間、サービスアカウントはview-clientsを意図的に付与しないと決めた既存方針(Context 参照、identity-admin/README.md71-72行目・KeycloakAccountVerifier.kt43-44行目)を実質的に上書きする——「realm 内の全 Client の secret を読めて しまうため付与しない」としていたのと同じ能力を、manage-clientsという別の名前で結局手に入れることになる。 これはmanage-usersの「サービスアカウントのトークンが漏れれば realm 内の任意ユーザーを操作できる」という 既存リスクの同じ形の延長ではない——manage-usersの漏洩は「このシステム(gcu realm)のユーザーが乗っ取られる」 被害に留まるが、manage-clientsの漏洩は「このシステムに OIDC で接続している他のあらゆるシステムの client secret を読める」ところまで被害が及ぶ、横方向への被害拡大(lateral movement)を新たに生む。 規模として軽くない差であり、「同じリスクモデルの延長」という表現は本改訂で撤回し、正確に「新しい種類の 被害経路を1つ追加する」と記述する。BFF が侵害された場合、この経路で PLATFORM_ADMIN を自己発行できる可能性も ある(issue #58 レビュー指摘、decision 3 の denylist が正規の書き込みパスではこれを塞ぐ——下記)。 - 対応方針(第一フェーズで必須): 決定3の denylist を、Keycloak への書き込み呼び出しの手前で必ず通す
(
KeycloakAdminClient自体には防御を持たせない——OrganizationsWriteServiceと同じ「業務ロジック層でガードする」 原則どおり、ClientsWriteService相当の層で実装する)。これにより、正規の Console の書き込みパス経由での 自己改変は構造的に起こらない。 - 対応方針(第一フェーズでは必須としない、残存リスクとして明記): サービスアカウントのトークン自体が漏洩した
場合、攻撃者は identity-admin のコードを経由せず直接 Keycloak Admin REST API を呼べるため、decision 3 の
denylist はこのケースを防げない——上記で訂正した通り、この漏洩時の被害範囲は
manage-users単体の時より 実際に広がる(他システムの secret 露出を含む)。それでもトークン漏洩そのものへの対策(シークレットの保管・ ローテーション)は本 ADR のスコープ外とする——これはmanage-clientsに固有の対策ではなく、サービスアカウントの 認証情報全般の運用(既存のIDENTITY_CONSOLE_SERVICE_CLIENT_SECRETの管理)の話であるため。 - 実装 PR が合わせて更新すべきドキュメント(レビュー指摘により追加):
manage-clients付与により、 「サービスアカウントはview-clientsを持たないため他ユーザーの client ロールマッピングや secret を読めない」 という趣旨の既存の記述(identity-admin/README.md71-72行目、KeycloakAccountVerifier.kt43-44行目、KeycloakAdminClient.ktクラス doc、およびAdminProperties.Protection.protectedUserIdsの KDoc にある 「Console はロールから管理者を検出できない(それには view-clients が要るが、付与していない)」という記述)は、 本機能の実装後は不正確になる——manage-clientsの付与によって実質的に同等以上の読み取り能力を持つに至るため。 実装 PR はこれらの記述を「view-clients自体は個別に付与していないが、manage-clientsが同等の読み取り能力を 含むため、実質的な secret 非公開の保証はもはやない」という趣旨に更新する責務を負う。 - 第一フェーズの必須要件にしないもの: Keycloak の Fine-Grained Admin Permissions(FGAP v2)で
manage-clientsを特定 Client 群だけに制限できないかの調査。実際に機能するかどうか(Client 単位の除外ルールが構成可能か)が 現時点で未確認であり、調査自体に実機検証を要する不確実性の高いタスクのため、決定3(アプリケーション層の denylist)だけで第一フェーズの安全性は十分に確保できるという判断のもと、多層防御の後続強化タスクとして issue 化する(本 ADR の実装 PR の着手条件にはしない)。
6. Client secret のライフサイクル——Operation/監査経路に secret を一切乗せない
Section titled “6. Client secret のライフサイクル——Operation/監査経路に secret を一切乗せない”OperationDetails・OperationResult.details・監査イベントのallowlisted_payloadのいずれにも、clientSecretフィールドを追加しない。 これは「secret を記録しない」ではなく「secret を運べる場所を型として作らない」 という設計上の原則——OperationDetailsが「ここに定義されたフィールド以外は書き込みペイロードとして記録できない」 というアーキテクチャ(identity-admin/.../audit/OperationModel.ktの doc comment 参照)をそのまま守る。- 作成は2段階の upstream 呼び出しであることを前提に設計する(レビュー指摘により全面的に追記——旧版はこの点を
見落としていた)。 Keycloak の
POST /clientsは 201 +Locationヘッダのみを返し、body に secret は含まれない。 (本改訂2で訂正——旧版は「Organization 作成と同型で、resolvedTargetIdで ID を確定するところまでは同じ」と 書いていたが、これは誤り。 Organization の ID は Keycloak が作成時に採番するため作成前は未確定で、resolvedTargetId(ADR 0004 改訂2)による「成功後に確定」が必要になる。一方 Client のclientIdは呼び出し元が 指定する値であり(決定3)、reserve()の時点ですでに分かっている——targetIdはそのまま即座に記録でき、resolvedTargetId機構はそもそも使わない。この ADR が解決すべき「ID が未確定」という2段階呼び出しの課題は、 Client の場合は target ID ではなく secret(Keycloak が作成時に自動生成し、作成成功後の2段階目の呼び出しで しか取得できない値)についてのみ生じる——実装着手時にこの差異が判明し、本改訂で訂正した。)confidential な Client (Web/M2M。SPA は public client で secret という概念自体がない)の secret を得るには、作成に続けて 2段階目の呼び出し(GET /clients/{id}/client-secret、または secret を確定させるためにPOST /clients/{id}/client-secretで明示的に生成)が追加で必要になる。この2段階目が失敗しても、1段階目 (Client 自体の作成)はすでに成功している可能性がある——「Client は存在するが secret を一度も確認できていない」 という状態が実在し得ることを、以下の設計はすべて前提にする。 ClientCreateResponse { operation: OperationResult, secret: String? }とし、secretは常に nullable にする。 旧版のsecret: Stringは誤りだった——SPA(public client)は secret という概念自体を持たないため、常にnull。 confidential な Client でも、次のいずれかに該当する場合はnullを返す(secret を持っているかどうか不明な場合に 推測した値を返さない、という本モデル全体の原則をそのまま適用する):operation.statusがSUCCESS以外(PARTIAL/FAILED/UNKNOWN)——1段階目は成功していても2段階目が 未確認・失敗した可能性があるため。OperationExecution.replayed == true(同じ Idempotency-Key での再送)——冪等リプレイは保存済みのOperationResultをそのまま返す経路であり、secret の値自体は保存・再送しない(決定6冒頭の原則どおり)。 呼び出し元が最初のレスポンスを取り逃した場合、secret は失われたものとして扱い、決定6の reveal 経路で 改めて取得させる——「リプレイでは secret を含む応答を再現できない」ことを呼び出し元の実装(フロントエンド)が 前提にできるよう、本 ADR で明記する。- 対象が SPA(
publicClient = true)の場合——常にnull。
- 作成が不確実な結果に終わった場合の再試行・事後確認(レビュー指摘により追記): Keycloak の
clientIdは realm 内で一意という制約を利用する——同じclientIdで作成を再試行すると、既に存在していれば Keycloak が 重複として失敗(409 相当)を返す。これは「本当に作成されていたかどうか分からない」状態を解消する、呼び出し元 (運用担当者)向けの事後確認手段になる: 結果がUNKNOWN/PARTIALだった場合、(a) 同じclientIdで異なる Idempotency-Key を使って作成を再試行し、重複エラーが返れば「既に作成済み」と判断できる、(b) 読み取り専用のGET /clients?clientId=で存在確認する、のいずれかを運用手順として案内する——自動リトライはしない (ADR 0004 の既存原則どおり、不確実な書き込みの自動再実行はしない)。secret は上記のとおり reveal 経路 (下記)で別途取得させる。 - 事後の reveal:
GET .../client-secret相当の専用エンドポイント(例:POST /clients/{clientId}/secret/reveal)を新設する。 (レビュー指摘により訂正——旧版の自己矛盾を解消) これは「対象がすでに存在するリソースへの機微情報アクセス」 であり、ユーザー書き込みのような「対象の状態を変える」操作ではないが、作成時とまったく同じOperationService.execute()の型にそのまま乗せる——意図を先にreserve()で記録してから Keycloak を呼ぶ、 という順序そのものに価値がある(Keycloak 呼び出しが失敗してもリクエストが来たこと自体の記録は残る)ため、 「この型に馴染まない」という理由で別経路にする必要はない。reveal 自体を1個のOperation(新アクションCLIENT_SECRET_REVEALED)として記録する。 この Operation のdetailsは空(secret を含まない)—— 「いつ・誰が・どの Client の secret を見たか」という事実そのものが監査対象であり、見た内容(secret の値)は 対象外にする。secretはレスポンスの専用フィールドで返す(作成時と同じClientCreateResponse同型のスキーマ、 ただしoperation.replayed == trueの場合は作成時と同じ理由でsecret: nullになる——つまり reveal を 同じ Idempotency-Key で連打しても2回目以降は secret が返らない。再度見たい場合は新しい Idempotency-Key で reveal を呼び直す必要がある、と実装 PR・フロントエンドの両方で前提にする)。- reveal を呼ぶには通常の書き込みと同様に Idempotency-Key と
reasonが必要(OperationRequest.reasonは non-null——既存のReasonRequest/REASON_FIELDの convention をそのまま使う)。 - 権限は決定7の
CLIENTS_WRITE(reveal は読み取りではなく機微操作として扱う——SESSIONS_REVOKEと同じ、 読み取り権限とは別に機微操作権限を要求する既存の convention)。 - reauth(直近認証)は既存の
freshness.reauth-max-age(5分)をそのまま適用する——secret reveal 専用に 別の鮮度要件を新設しない。 - 再 reveal は許可する(強制ローテーションにしない)。 Keycloak 自体が「一度しか見れない」を強制しない
(
manage-clientsを持てばいつでもGET /clients/{id}/client-secretを呼べる)以上、Console 側だけ 「一度きり」を演出しても実効的なセキュリティにはならない——実効的な統制は「誰がいつ見たか」の監査証跡と 権限ゲートであり、レスポンスの一度きり性ではない、という判断を明記する(新しい Idempotency-Key を使えば 何度でも reveal できる——上記の「同じキーでの連打では返らない」のは冪等性の仕組みの副作用であり、 意図的な回数制限ではない)。
- reveal を呼ぶには通常の書き込みと同様に Idempotency-Key と
- キャッシュ禁止: 作成レスポンス・reveal レスポンスの両方に
Cache-Control: no-storeを付与する。フロントエンドは 共通 SWR キャッシュにこの2つのレスポンスを絶対に乗せない(useSWRのfetcherを共有せず、専用の一回限り呼び出し にする)。
7. 権限モデル
Section titled “7. 権限モデル”PermissionにCLIENTS_READ・CLIENTS_WRITEを新設する(ORGANIZATIONS_READ/ORGANIZATIONS_WRITEと同じ read/write 分離)。- 第一フェーズでは両方とも
PLATFORM_ADMINのみに付与する(ADR 0005 決定2と同じ判断: 狭く始めて、実運用で 足りないと分かってから広げる方が、広く配ってから絞るより安全)。CLIENTS_WRITEは secret reveal も含む (決定6参照)。 - (改訂: issue #166)Organization 限定の Client 管理権限を追加する。
CLIENTS_WRITEは全 Organization 対象・PLATFORM_ADMIN専用のまま変えず、別のCLIENTS_WRITE_OWN_ORG(clients:write:own-org)を新設し、新しい Console ロールorg-client-admin(CLIENTS_READ・CLIENTS_WRITE_OWN_ORG・ORGANIZATIONS_READ)に付与する。 ロールは既存4ロールと同じくidentity-consoleの client role をユーザーに直接割り当てる(Group は使わない)。 範囲判定は ADR 0003 §4 のとおり identity-admin の業務ロジック層(ClientScopeService)で行う:- 「自分の Organization」は Keycloak の Organization メンバーシップをリクエストごとにライブで引く(ADR 0003 の 「1ユーザー = ちょうど1 Organization」前提)。0件・複数件はデータ異常として扱い、どれかを選ばずに Client 系 API を すべて 403 にする(fail closed)。Keycloak に問い合わせられない場合も 502/503 で閉じ、全件表示へは戻らない。
- 作成:
ownerOrganizationIdが自組織でなければ 403。Organization の存在確認より前に判定するので、他組織の ID と実在しない ID は同じ 403 になり、Organization ID の存在は漏れない。 - 編集・secret reveal・詳細: 他組織の Client は未知の clientId と同じ 404(保護対象 clientId・Console 管理外 Client と 同じ「書き込み対象として存在しない」扱い)。403 にすると、詳細で隠したはずの Client の存在を書き込み API だけが 認めてしまうため。
- 一覧: ページング前に自組織の Client だけに絞る(他組織の clientId・redirect URI 等は BFF の外に出ない)。
CLIENTS_WRITEも持つ利用者は常に全 Organization 対象(PLATFORM_ADMINの挙動は変わらない)。 これにより決定2の「第一フェーズでの実効性に関する注記」は解消され、ownerOrganizationIdは認可判定にも使われる 属性になる。なお clientId は realm 内で一意なので、作成時の「clientId is already in use」から他組織の clientId の 存在自体は推測でき得る(Keycloak の仕様上避けられない。redirect URI 等の中身は漏れない)。
Non-Goals(本 ADR・issue #58 第一フェーズの範囲外。issue #58 本文/コメントで確定済みの先送り事項)
Section titled “Non-Goals(本 ADR・issue #58 第一フェーズの範囲外。issue #58 本文/コメントで確定済みの先送り事項)”- SAML Client: 後続フェーズ(issue #58 コメントで確定済み)。
- 協力会社代表者によるセルフサービス Client 作成: ADR 0003 のセルフサービス機構が固まってからの後続フェーズ (issue #58 コメントで確定済み)。第一フェーズは関電工管理者のみが内部・外部どちらの Client も作成する。
- 既存 Client のインポート: 決定4参照。
- Client secret のローテーション(強制的な再発行)UI: 決定6は「現在の secret を reveal する」までを扱う。 新しい secret を発行して古いものを失効させる機能は、呼び出し元システムへの影響(secret 入れ替えの運用調整)を 伴うため別タスクとする。
- Client scope・protocol mapper・role mapping の個別管理 UI: 決定1の固定設定以外、一切の調整 UI を持たない。
- Keycloak Fine-Grained Admin Permissions の調査: 決定5参照。後続の多層防御タスク。
- Client の削除: 第一フェーズは作成・編集・secret reveal のみ。
- 所有権(
ownerOrganizationId)の移転: 決定2参照。作成時に決まり、以後変更不可——移転 UI は作らない。 - Organization の無効化・削除と、それが所有する Client への影響: 決定2参照。Organization の無効化・削除自体が Console にまだ実装されていない(issue #81/ADR 0005 §3 が別途先送り)ため、本 ADR の時点では発生し得ない状態。 Organization の無効化・削除が実装される際に、その実装 ADR の中で改めて決定する。
Consequences
Section titled “Consequences”- Issue #58 のレビューで挙がった論点(Context 参照)はすべて本 ADR で決定済みになる。実装 PR は
OrganizationsWriteService/OrganizationWriteRequests.kt(issue #81)と同じ型に乗せて実装できる—— 監査モデル(ADR 0004 改訂2)・denylist パターン(ADR 0005 §1 の internal-organization-id と同型)・ secret を運ばないOperation設計のいずれも、新しい基盤コードを要求しない。ただし「成功時に ID を確定する」 部分は issue #81 からそのまま流用できる一方、作成が不確実に終わった場合・secret 取得が2段階目で失敗した場合・ 冪等リプレイ時の挙動(決定6)は Client 固有の追加設計であり、Organization の実装をそのままコピーするだけでは 足りない、という点は実装 PR の見積もりに反映すること。 - (本改訂で訂正)
manage-clients付与に伴う残存リスクは、manage-usersの延長という軽い表現を撤回し、 正確に記載し直した: 既存の「view-clientsは realm 内の全 client secret を読めてしまうため付与しない」という 方針(Context 参照)を実質的に上書きし、サービスアカウントのトークン漏洩時の被害範囲を「この realm のユーザーが 乗っ取られる」から「この realm に OIDC 接続している他のあらゆるシステムの secret が露出する」まで広げる、 新しい種類の被害経路である。本 ADR はこのリスクを、decision 3 の denylist(正規の書き込みパスでの自己改変を防ぐ) と、トークン漏洩そのものへの対策は別スコープとする整理のもとで、明示的に受容する——軽んじてはいないことを 明記する。 - 実装 PR は、
manage-clients付与によって不正確になる既存ドキュメント(identity-admin/README.md71-72行目、KeycloakAccountVerifier.kt43-44行目、KeycloakAdminClient.ktクラス doc、AdminProperties.Protection.protectedUserIdsの KDoc)を更新する責務を負う(決定5参照)。 - 第一フェーズの実装 PR のスコープは次のとおり明確になる:
TargetType.CLIENTの追加、OperationAction.CLIENT_CREATED/CLIENT_UPDATED/CLIENT_SECRET_REVEALEDの追加、OperationStepNameの対応する 値、console_operationの CHECK 制約を広げる migration(V6 と同型)、ClientsWriteService、ClientWriteRequests、AdminProperties.Protection.protectedClientIds、scripts/dev-setup-console.shへのmanage-clients追加、 Console フロントエンドの Client 作成・編集・secret reveal 画面。 - FGAP v2 の調査は本 ADR の決定に残存リスクとして明記されたので、着手するかどうか・いつ着手するかは本 ADR の 実装 PR をブロックしない別 issue として起票する。
- 本 ADR はあくまで静的な設計レビューであり、実機検証を行っていない。 次の点は実装 PR 側で Keycloak 26.7.5 の
実際の挙動として確認すること(ADR 0005 が Organization 書き込みで行ったのと同じ水準の実機検証を要求する):
(a) Client の
PUT更新がattributesをキー単位でマージするのか、redirectUris/webOriginsのような配列 フィールドを丸ごと置き換えるのか——Organization の「GET してから全体を PUT し直す」という型は踏襲できても、 フィールドごとのマージ/置換セマンティクスが Organization と同じとは限らない。(b)manage-clientsが実際にview-clients相当の読み取りを内包するかどうか(決定5の前提)。(c) 同じclientIdでの再作成が実際に Keycloak からどのようなエラー(ステータスコード・エラーボディ)で返るか(決定6の事後確認手段の前提)。
Alternatives considered
Section titled “Alternatives considered”ownerOrganizationIdを identity-admin 独自の DB テーブルで持つ: Organization 書き込み(issue #81)の レビューで Client 管理にも同じ論点が指摘されたが、Client が削除された際にテーブル側の行が孤立する (参照整合性が取れない)リスクが常につきまとう。Keycloak Client attribute として持てば、Client 削除と同時に メタデータも消えるため、この種のずれがそもそも発生しない。採用しない。- 所有権からログイン許可を推測する(Organization のメンバーだけがその Client にログインできる、とみなす): issue #58 レビューで指摘された「協力会社システムに関電工社員がログインする」という逆方向のケースを壊す。採用しない。
- Client secret を reveal 一度きりにし、再 reveal 不可にする: Keycloak 自身がこの制約を強制しないため
(
manage-clientsがあればいつでも取得できる)、Console 側だけ一度きりを演出しても実効的な安全性の向上にならず、 単に運用上の不便(再発行が必要になるケースの増加)を生むだけ。採用しない——実効的な統制は監査証跡と権限ゲート。 manage-clientsを避け、Fine-Grained Admin Permissions で狭いロールを最初から作る: 実現可能かどうかが 未検証で、検証自体に本 ADR の決定を待たせるコストが見合わない。第一フェーズはアプリケーション層の denylist だけで必要な安全性を満たせると判断し、FGAP は後続の多層防御タスクに切り出す(決定5)。- (本改訂で撤回)関電工自身の Organization を
ownerOrganizationIdの対象から一律で拒否する: 当初案はこれ だったが、issue #58 の第一フェーズは「内部・外部どちらの Client も作成する」ことを明示的な要件にしており、 関電工自身が運用する内部システムの Client が作れなくなってしまう——「Organization 自体への書き込みを拒否する」 (ADR 0005 §1)と「他の何かの外部キー値として参照することを拒否する」を混同していた誤りだった。決定2で訂正。