コンテンツにスキップ

ADR 0005: Organization 書き込み(作成・編集・メンバー管理)の権限モデルと managed/unmanaged ライフサイクル設計

  • Status: Proposed
  • Date: 2026-10-08(2026-10-08 改訂1: レビューで指摘された3点——既存ユーザー招待を managed 化の根拠にできない、監査依存リストが target_user_id NOT NULL の旧制約解除を欠いている、権限マトリクスが「実装 PR で最終決定」のまま未確定——を反映。該当箇所は §3・§4・§2 および Consequences・残課題。2026-10-08 改訂2: 残課題4項目をすべてローカル dev Keycloak(26.7.5、使い捨ての identity-hub-keycloak コンテナ + MailDev)で実機検証し、結果を反映。本番 Keycloak には一切触れていない)

Issue #81(調査: Keycloak Organizations の機能範囲と Console 実装のスコープ)で、OrganizationsService.kt の読み取り専用実装を書き込みに広げる前に片付けるべき前提として、次の4点を前置設計として切り出した:

  1. 監査依存(ADR 0004 / #70 との関係)
  2. データ正本・書き込み範囲の境界
  3. 権限マトリクス
  4. managed/unmanaged のライフサイクル

本 ADR はこの4点のうち (2)(3)(4) を確定する——(4) はローカル dev Keycloak での実機検証(下記「残課題 → 実機検証済み」)まで完了済み。(1) は ADR 0004 側がすでに決定済みのため、本 ADR では「何が揃えば着手できるか」の依存関係を明記するが、確認の過程で Phase 1 だけでは不十分な点(§4 参照)が新たに見つかった。

調査で確認した現状(2026-10-08、本番 Keycloak 26.7.5 の管理画面で実機確認)

Section titled “調査で確認した現状(2026-10-08、本番 Keycloak 26.7.5 の管理画面で実機確認)”
  • ADR 0003 §3 がすでに次を決定済み: 関電工本体の Organization は人事マスターデータが正で Console からは書き込み不可。協力会社の Organization は関電工 IT 管理者が Console から作成し、代表者を招待する。協力会社メンバーは managed(Organization 経由で作成、ライフサイクル連動)、関電工社員は unmanaged(人事マスター由来の既存アカウント)。
  • Keycloak の Admin REST API は実機確認済みで、既存 realm user を Add realm user / Add member で直接追加すると membershipType: UNMANAGED として返る(ADR 0003 Consequences、および本 ADR 作成時に本番環境で再確認)。
  • identity-admin/src/main/kotlin/com/gcu/idplatform/admin/security/PermissionService.kt の ConsoleRole は、realm 全体に対するフラットな権限集合でしかない。特定の orgId に紐付けてロールをスコープする仕組みは存在しない。ADR 0003 §1 はこれを見越して「協力会社代表者の管理者ロールは、ロール名だけでなく具体的な orgId に紐付けて付与する(今回の PR 範囲外の書き込み機能で実装)」とすでに明記している——つまりこのギャップは ADR 0003 の時点で認識済みの先送り事項であり、本 ADR で初めて見つかった問題ではない。
  • identity-admin/src/main/kotlin/com/gcu/idplatform/admin/users/UserStateMutationService.kt に代表される既存の書き込みパターン(OperationService.execute(actor, request) { ... }、origin.operationRequest(body, action, target.id, reason, intent))は、target.id が呼び出し時点で非 null な文字列であることを前提にしている。ADR 0004 はこれを targetId: String?(nullable)に一般化する設計を決定済みだが、実装(#70, Phase 1)はまだ OPEN。
  • ADR 0004 §5(スコープ外)に明記の通り、TargetType enum への ORGANIZATION 追加、および「reserve() 時点で ID 未確定のまま意図を記録し、upstream 成功後に確定する」という作成系アクションの生命周期パターンの実装は、#70 の範囲外、Organization 機能の実装 PR 自身の責務。

1. 書き込みスコープの境界: Console が書き込めるのは協力会社の Organization のみ

Section titled “1. 書き込みスコープの境界: Console が書き込めるのは協力会社の Organization のみ”
  • 関電工本体の Organization(人事マスター同期対象)は、識別子を identity-admin の設定値として明示的に保持する(例: identity-admin.internal-organization-id、起動時に1回設定する運用値)。CONSOLE_PROTECTED_USER_IDS / CONSOLE_EMERGENCY_ADMIN_IDS と同じ「明示的な allowlist/denylist を設定値として持つ」既存の convention に合わせる。
    • 却下した代替案: ドメイン(gco.co.jp 等)や Organization 名の一致でヒューリスティックに「これは内部組織だ」と推測する方式。設定ミス・将来のドメイン追加で簡単に壊れる暗黙の判定であり、採用しない。
  • OrganizationsWriteService(新設予定)の全ての書き込みメソッドは、対象 organizationId が内部組織 ID と一致する場合、403 ではなく、“この操作はそもそも存在しない対象に対しては行えない” という意味で 404 相当のレスポンスを返す(明示的なエラーコードにするかは実装 PR で決める)。実装時の訂正(issue #81 実装 PR のレビュー): 当初案は「権限チェックより前」だったが、実装では organizations:write を持たない操作者には対象 ID の実在・保護有無に関わらず先に 403 を返す順序にした——権限のない呼び出し元に対象 ID 依存のレスポンス差を一切見せない方が安全という判断で、本 ADR の意図(内部組織を書き込み不可にすること自体)は変わらない。
  • 読み取り(OrganizationsService 既存の list/getDetail/listMembers)は従来通り内部・協力会社の両方を対象にしてよい——変更しない。

2. Permission モデルの拡張(本 ADR のスコープ: 関電工内部管理者のグローバル権限のみ)

Section titled “2. Permission モデルの拡張(本 ADR のスコープ: 関電工内部管理者のグローバル権限のみ)”
  • Permission に ORGANIZATIONS_WRITE を新設する(ORGANIZATIONS_READ と対になる、書き込み全般を一括りにする権限。将来細分化が必要になったら分割する)。
  • 初版で ORGANIZATIONS_WRITE を付与するのは PLATFORM_ADMIN のみと、本 ADR で確定する。 VIEWER/USER_ADMIN/SECURITY_ADMIN には付与しない——「実装 PR で最終決定する」という先送りはしない(過剰に狭く始めて実運用で足りないと分かってから広げる方が、広く配ってから絞るより安全、という判断に基づく確定事項)。将来 USER_ADMIN/SECURITY_ADMIN への拡大が必要になった場合は、本 ADR の改訂として扱う。
  • この ORGANIZATIONS_WRITE が対象とするのは決定1の範囲(協力会社 Organization の作成・ホワイトリストされた編集フィールド)のみ。 無効化/有効化、メンバーの追加・除外は §3 の通りまだ設計・実装されておらず、別途ライフサイクル設計が完了するまで ORGANIZATIONS_WRITE にそれらの操作を含めない(必要になった時点で新しい Permission を追加するか、本 ADR を改訂して明示的に範囲を広げる)。
  • 協力会社代表者の org スコープ権限は本 ADR の対象外のまま据え置く。 理由は2つ: (a) ADR 0003 §1 がすでに「書き込み機能の実装時に別途設計する」と明記している、(b) 代表者ロール自体が招待フロー(本 ADR の「後続」スコープ)を通じてしか発行されないため、依存順序としても今回の P0(内部管理者が協力会社 Organization を作成する)より後にしかなり得ない。現在の ConsoleRole がフラットな realm 全体の権限集合でしかないというギャップ(Context 参照)は、代表者スコープ権限を設計する ADR の方で解消する。

3. managed/unmanaged のライフサイクル方針

Section titled “3. managed/unmanaged のライフサイクル方針”
  • 業務制約として確定するのは「協力会社メンバーは managed でなければならない」ことそのものであり、「Invitations を使えば managed になる」は確認済みの事実ではない。 Keycloak 26.7.5 のソースを実際に確認した結果(services/src/main/java/org/keycloak/authentication/actiontoken/inviteorg/InviteOrgActionTokenHandler.java)、既存 realm user への招待(Invite realm user)は orgProvider.addMember(...) を呼ぶ——これは OrganizationProvider の javadoc 上 unmanaged メンバーを作るメソッドである(対になる addManagedMember(...) は、新規ユーザー登録フロー RegistrationUserCreation.java から呼ばれており、Invite new user(新規アカウント作成を伴う招待)側でのみ使われる)。
    • したがって Invite realm user(既存ユーザーの招待)は managed メンバーを作らない。 協力会社メンバーの正規の追加経路として使えるのは Invite new user(新規アカウント作成を伴う招待)のみと、本 ADR で確定する。
    • Add member(既存 realm user の直接追加)と Invite realm user(既存ユーザーの招待)は、どちらも協力会社の Organization に対しては使わせない。 両者とも unmanaged メンバーを作る点で区別する理由がなく、同じ禁止事由(ADR 0003 の「契約終了時に Organization 操作だけでログインも止まる」という保証を満たせない)が両方に当てはまる。
    • 2026-10-08、ローカル dev Keycloak で実機検証済み: Invite new user は実際に membershipType: MANAGED を作る。 協力会社メンバー追加の正規経路として確定する(詳細は「残課題 → 実機検証済み」参照)。
    • 関電工の Organization に対する「既存社員を unmanaged で関連付ける」操作自体は、本 ADR の決定1(内部組織は書き込み不可)によりそもそも Console からは行えない——これは人事マスター同期ジョブの仕事のままで変わらない。
    • 協力会社に対して既存ユーザーの直接追加/招待を例外的に許す必要が将来出てきた場合(例: 何らかの移行作業)、その例外運用とアカウントの離脱手順は本 ADR の改訂として扱う。デフォルトでは許可しない。
  • Organization の無効化(disable)とメンバー除外は、本 ADR では設計のみ決め、実装は別タスクとする(issue #81 の「独立タスク」)。2026-10-08、ローカル dev Keycloak で実機検証済み(詳細は「残課題 → 実機検証済み」参照): Organization を無効化すると managed メンバー全員のアカウントが Keycloak によって自動的に enabled: false にされ、実際にログインが止まる(再度有効化すれば復旧する、可逆な操作)。managed メンバーの除外は実アカウントの削除を伴う。unmanaged メンバーの除外はアカウントに影響しない。これで ADR 0003 が「未検証」としていた前提はすべて裏付けられた。
    • 設計方針: 無効化は Organization 自体の enabled フラグを false にする単純な更新だが、OperationAction としては通常の Organization 更新(ORGANIZATION_UPDATED 等)とは別の、専用のアクション(例: ORGANIZATION_DISABLED/ORGANIZATION_ENABLED)として記録する——実機検証で確認した通り「managed メンバー全員のログインを即座に止める」という重い副作用を持つため、通常のフィールド編集と同列に監査しない。
    • メンバー除外も同様に、membershipType によって実際に起きること(managed なら実アカウント削除、unmanaged なら関連付けの解除のみ)が全く異なることが実機確認済みのため、OperationAction も MEMBER_REMOVED(unmanaged、可逆性なし=関連付けの話)と MANAGED_MEMBER_ACCOUNT_DELETED(managed、実アカウント削除)のように区別して記録することを検討する——実装 PR で最終決定する。
  • Invitations(メール送信を伴う招待)自体は本 ADR のスコープ外(issue #81 の「後続」)。本 ADR は「協力会社メンバーは招待経由でしか増やさない」という方針だけを決め、招待フローの UX・メール基盤設計は別途行う。

4. 監査依存関係(ADR 0004 との関係の明文化、および Phase 1 だけでは解決しない互換性の穴)

Section titled “4. 監査依存関係(ADR 0004 との関係の明文化、および Phase 1 だけでは解決しない互換性の穴)”

着手前提条件(Organization 機能 PR の外、他 PR の完了を待つ必要があるもの)は1つだけ:

  • ADR 0004 Phase 1(#70)がマージされていること。

Organization 機能の実装 PR 自身が作り込む必要がある作業(着手条件ではなく、この PR の成果物):

  1. TargetType enum への ORGANIZATION 追加(ADR 0004 §5 でスコープ外と明記済み)。
  2. 「reserve() 時点で targetId = null のまま意図を記録し、upstream 成功後に確定する」という作成系アクションの生命周期パターンの実装(ADR 0004 は設計のみ決定済み)。
  3. (本 ADR で新たに特定した穴)旧 target_user_id 列・契約フィールドが NOT NULL のまま残っているという Phase 1 の制約そのものへの対応。 ADR 0004 Phase 1 は target_user_id(DB 列・契約フィールドとも non-null)と新しい target_id/target_type(nullable)への dual-write を前提にしており、これは「既存4アクションのように、対象ユーザーが実在し ID も分かっている」ケースしか想定していない。Organization 作成のように対象ユーザーが最初から存在しないアクションでは、dual-write 先であるはずの target_user_id に書ける値がそもそも無い——targetId を null にできても、target_user_id を NOT NULL のまま埋められない以上、Phase 1 のスキーマでは Organization 作成の意図を合法に INSERT できない。
    • 当て推量での回避策(Organization ID を target_user_id に流用する、存在しないダミーのユーザー ID を捏造する)は、旧列の意味論(「これはユーザーの ID である」)を汚染するため採用しない。
    • これは Organization 機能 PR が単独で解決できる問題ではない。 選択肢は (a) ADR 0004 を追補し、target_user_id を Phase 1 の時点で nullable に変更する(Phase 2 の列削除そのものではなく、NOT NULL 制約の解除だけを前倒しする)、(b) Organization のような非ユーザー対象のアクションに限り、Phase 2(contract)の完了を待ってから着手する、のいずれか。本 ADR はどちらを採るか決定せず、ADR 0004 のオーナーと調整の上、別途 ADR 0004 の改訂または新しい小さな ADR として決定することを次の一歩とする。
    • 解決済み(2026-10-09): 選択肢 (a) を採用。 ADR 0004 改訂2(docs/adr/0004-operation-generic-target.md)で target_user_id の NOT NULL 制約を V5 マイグレーションで解除し、ActionResult.resolvedTargetId → OperationService.execute() → OperationStore.complete() の配線も完了済み。下記「まとめ」の (3) はこれで解消——Organization 作成アクションの実装は (1)(2) のみが残る。

まとめ: #70 のマージは必要条件だが、それだけでは Organization 作成の意図を記録できる状態にはならない。 上記 (1)(2) に加えて (3) の整合性問題の解決方針が決まるまで、Organization 作成アクションの実装には着手できない。見積もり・スケジューリングでは (1)(2)(3) をそれぞれ独立した工数として扱い、(3) は他チームとの調整を要する不確実性の高い項目として扱う。(2026-10-09 追記: (3) は ADR 0004 改訂2 で解決済み。残るのは (1)(2) のみ。)

残課題 → 実機検証済み(ローカル dev Keycloak 26.7.5、本番には一切触れていない)

Section titled “残課題 → 実機検証済み(ローカル dev Keycloak 26.7.5、本番には一切触れていない)”

2026-10-08、使い捨てのローカル Keycloak(identity-hub-keycloak コンテナ、make kc-up dev-realm)+ MailDev(招待メールのキャプチャ用)で、テスト専用の Organization・ユーザーを作って以下をすべて実機確認した。テストデータは検証後に削除済み。

  • Invite new user(新規アカウント作成を伴う招待)→ membershipType: MANAGED。 実際に招待メールのリンクから登録フォームを完走し、Admin REST API で確認済み。ソースコード予測(RegistrationUserCreation.java が addManagedMember を呼ぶ)と一致。
  • Invite realm user(既存ユーザー招待)→ membershipType: UNMANAGED。 実際に招待を受諾させて確認済み。§3 のソースコード予測(addMember を呼ぶ)と一致——Add member(直接追加)と区別する理由がないことが実証された。
  • Organization を disabled にすると、managed メンバーのアカウントが Keycloak によって自動的に enabled: false にされ、ログインが invalid_grant / "Account disabled" で拒否される。 同じユーザーで disable 前はログイン成功(200, access_token 取得)、disable 直後に同じ認証情報で失敗することを確認。Organization を再度 enabled: true に戻すと、メンバーのアカウントも自動的に enabled: true に戻り、ログインも復活する——可逆な操作であることも確認済み。
  • managed メンバーを Organization から除外(DELETE /organizations/{id}/members/{userId})すると、実アカウントが削除される。 除外直後に GET /users/{id} が 404 を返すことを確認済み。
  • unmanaged メンバーを除外しても、実アカウントは削除されない。 除外後も GET /users/{id} が 200 を返し、enabled: true のまま存続することを確認済み。
  • Alias の更新は拒否される。 PUT /organizations/{id} に異なる alias を含めて送ると 400 Bad Request {"errorMessage":"Cannot change the alias"} が返ることを確認済み——正確なエラーメッセージ文言まで判明した。
  • (副産物)disabled な Organization は新規メンバーの参加(招待の受諾)も拒否する。 招待受諾の action token を disabled な Organization に対して実行すると error="org_disabled" で失敗する(Keycloak イベントログで確認)。

これで ADR 0005 が前提としていた業務上の仮定はすべて実機で裏付けられた。§3 の「Invite new user が実在する managed 化経路でなければ前提から見直しが要る」という条件分岐は不要になった——正規の経路として確定する。

  • Issue #81 の前置設計4項目のうち、データ正本境界・権限マトリクス・managed/unmanaged ライフサイクルの3つは本 ADR で確定し、業務制約(「協力会社メンバーは managed でなければならない」)を満たす具体的な実装経路(Invite new user)も 2026-10-08 のローカル dev Keycloak 実機検証で裏付け済み——disable によるログイン遮断・managed/unmanaged 除外の挙動差・alias 不変の正確なエラーまで含め、ADR 0003 が「未検証」としていた項目はすべて解消された。監査依存(4つ目)は、ADR 0004 Phase 1(#70)の完了だけでは不十分で、target_user_id NOT NULL の旧制約をどう扱うかという ADR 0004 側の追加決定を要することが判明した——これは本 ADR の範囲外として ADR 0004 側に投げ返した。(2026-10-09 追記)ADR 0004 改訂2 で解決済み: NOT NULL 制約を V5 マイグレーションで解除し、resolvedTargetId の配線も完了。
  • 協力会社代表者の org スコープ権限は依然として未設計のまま残る——これは本 ADR の決定であり見落としではない。今回の P0(内部管理者による協力会社 Organization 作成)には不要なため、着手をブロックしない。
  • ORGANIZATIONS_WRITE(PLATFORM_ADMIN のみに付与、決定1の範囲のみが対象)を追加しても、Organization 機能の実装 PR 自体は §4 の依存関係——(a) #70 のマージ、(b) TargetType.ORGANIZATION 追加、(c) ID 確定パターンの実装、(d) target_user_id NOT NULL 制約の解決方針の決定——がすべて揃うまで着手できない。(d) は 2026-10-09、ADR 0004 改訂2 で解決済み。 残るのは (b)(c) のみ——どちらも Organization 機能の実装 PR 自身が作り込む(§4 既出の通り)。
  • 「残課題」節の4項目はすべてローカル dev Keycloak(make kc-up dev-realm)でのスパイクとして実機検証済み(2026-10-08)。本番データに対する実験的操作は一切行っていない——本 ADR の方針通り、本番 Keycloak は読み取り専用の確認(§3/Context の一部)にのみ使い、書き込み系の検証は最初からローカルで行った。
  • ドメイン一致による内部/協力会社の自動判別: 設定ミスや将来のドメイン追加に弱い暗黙の推測であり、却下(決定1参照)。
  • 協力会社の既存ユーザー直接追加を許可し、UI 上で「unmanaged になります」と警告するだけに留める: 警告はクリックスルーされがちで、ADR 0003 が重視する「契約終了時に Organization 操作だけでログインも止まる」という保証を実質的に骨抜きにする。デフォルト禁止とし、例外が必要になった時点で別途 ADR 改訂する方針を採用。
  • 権限を細分化せず ORGANIZATIONS_READ のまま読み書き両方を許可する: 読み取りと書き込みでリスクの質が異なり(特に協力会社オンボーディング・オフボーディングという実アカウントのライフサイクルに関わる操作)、既存の USERS_READ/USERS_UPDATE_STATE や SESSIONS_READ/SESSIONS_REVOKE の read/write 分離パターンと一貫させるため、ORGANIZATIONS_WRITE を新設する方を採用。