コンテンツにスキップ

ADR 0004: Operation 監査モデルの target を user 専用から汎用(target_id + target_type)に一般化する

  • Status: Proposed
  • Date: 2026-10-07(2026-10-07 改訂: レビューで指摘された3点——ローリングデプロイ中の新旧インスタンス互換性、作成系アクションの target ID 未確定問題、クエリ側の targetType 欠落——を反映。該当箇所は §1・§2・§3 および Consequences・Alternatives。2026-10-09 改訂2: ADR 0005(Organization 書き込み設計)のレビューで特定された穴——target_user_id が Phase 1 の間 NOT NULL のままでは、Organization/Client のような作成系アクションの意図(対象がまだ存在しない)をそもそも合法に INSERT できない——を解消。Phase 2(列削除)を待たず、NOT NULL 制約の解除だけを V5 マイグレーションで前倒しし、ActionResult.resolvedTargetId を OperationService.execute() まで配線して §2 のライフサイクルを実装済みにした。該当箇所は Decision §1・Consequences)

この一般化は、別々に立ち上がった2つの作業から共通の前提として要求されている:

  1. ADR 0003(docs/adr/0003-organization-and-vendor-structure.md)で読み取り専用の Organizations API(PR #53)を実装した際、Organization に対する書き込み(作成・招待・メンバー増減)は意図的にスコープ外にした。理由はコード中の doc comment(identity-admin/.../organizations/OrganizationsService.kt:11-15)に明記されている通り、Operation 監査モデルが targetUserId-only で、Organization を target にした書き込みを記録できないため。
  2. Issue #58(Keycloak Client の作成・設定機能を Console に引き上げる計画)のレビューで、Client 作成の監査記録にも全く同じ壁(target_user_id NOT NULL)があることを指摘した。Issue 内では「Organizations の書き込み対応と同じ拡張ポイントなので、どちらが先に着手してもこの Operation モデル拡張は1回で済ませ、重複実装を避ける」と明記されている。

つまり、Organization 書き込み・Client 書き込みのどちらを先に実装するにせよ、その前に Operation モデルの一般化が独立した前提作業として必要になる。本 ADR はその一般化だけをスコープにする(Organization/Client の書き込みロジック自体は含まない)。

調査で確認した現状(2026-10-07)

Section titled “調査で確認した現状(2026-10-07)”
  • マイグレーションは identity-admin/src/main/resources/db/migration/ に V1__console_operations.sql・V2__entra_signin_events.sql の2本のみ。console_operation テーブルを作るのは V1 で、target_user_id varchar(255) not null と action 列への CHECK 制約(USER_ENABLED/USER_DISABLED/SESSION_REVOKED/USER_LOGGED_OUT の4値のみ)がある。V2 以降、console_operation に触れるマイグレーションはない。
  • identity-admin は実際に本番相当の AWS インフラにデプロイ済み(Issue #35、identity-admin/docs/aws-deploy.md): 共有 RDS 上の専用データベース、ECS/Fargate サービス、共有 ALB、ECR への自動イメージビルド(.github/workflows/identity-admin-image.yml)が存在し、Flyway はサービス初回起動時に console_operation/console_operation_event を自動作成する。ただし aws-deploy.md の「まだ決まっていないこと」セクションには、本番用 Keycloak client の認証情報が未発行であること、Console フロントエンド(Cloudflare Pages)から本番 ALB ドメインへのルーティングがまだ配線されていないことが明記されている。このため、console_operation に実データが既に存在するかどうかはリポジトリの静的な内容からは確認できない(本番環境・チームの知見を直接確認しないと分からない)が、インフラ自体は実在するので、ローカル専用の使い捨てコードと同じ軽さでは扱えない。
  • ECS はローリングデプロイであり、新旧タスクが同じ RDS に対して一時的に同時稼働する。 Flyway はサービス起動時に自動でマイグレーションを当てるため、新タスクが起動してマイグレーションが走った瞬間から、まだ稼働中の旧タスク(旧い列名を SQL に埋め込んだコード)がその SQL で失敗し始める。本番にまだトラフィックが乗っていないという事実は「実データが壊れて困る人がまだいない」ことの根拠にはなっても、「ローリングデプロイ中に新旧タスクが同じスキーマを取り合う」というメカニズム自体を消すものではない——これは #58 のレビューで直接指摘された点で、単純な1段階 RENAME COLUMN では解決しない。
  • OperationService.execute()(identity-admin/.../audit/OperationService.kt:59-76)は「(1) reserve() で意図をその場で永続化 → (2) runner.run() で upstream(Keycloak)を呼ぶ → (3) store.complete() で結果を確定」という順序で動く。target は (1) の時点で必須の非 null 値として渡される(OperationRequest.targetUserId: String、null 非許容)。既存の4アクション(enable/disable/session-revoke/logout)はいずれも「操作対象のユーザーがすでに存在し、ID も分かっている」ケースなので、この前提は現状問題にならない。しかし Organization/Client の作成アクションでは、ID は Keycloak が作成に成功して初めて払い出すもので、reserve() の時点ではまだ存在しない。target_id を単純に「NOT NULL のまま型だけ汎用化する」と、作成系アクションの意図をそもそも記録できない。
  • AuditEventsService.kt は targetUserId でのフィルタに加え、ページングカーソルの signatureOf(realm, query)(AuditEventsService.kt:99-103)に query.targetUserId をハッシュ入力として含めている。クエリ側が target の「型」を区別せず ID だけでフィルタする設計のままだと、将来 Organization/Client が入ってきたときに、この同じ共有基盤(クエリパラメータとカーソル署名)をもう一度変更する必要が生じる。
  • targetUserId / target_user_id はコードベース全体で30箇所以上に出現する:
    • identity-admin 本体(9ファイル): audit/Operation.kt(JPA エンティティの列)、audit/OperationModel.kt(契約型 OperationResult.targetUserId)、audit/OperationStore.kt・audit/OperationService.kt・audit/WriteRequests.kt(書き込みの呼び出し経路)、audit/OperationRepository.kt(ネイティブ SQL の INSERT)、audit/OperationEventRepository.kt(JPQL)、audit/AuditEventModel.kt・audit/AuditEventsService.kt・audit/AuditEventsController.kt(監査クエリ系、GET /audit-events の targetUserId クエリパラメータを含む)。
    • identity-admin テスト(9ファイル): OperationPersistenceTest.kt、OperationServiceTest.kt、OperationsControllerTest.kt、AuditQueryTest.kt、OperationTestSupport.kt、SessionMutationOutcomeTest.kt、SessionMutationTest.kt、UserStateMutationTest.kt、OverviewControllerTest.kt。
    • contracts/console-v1.openapi.yaml: OperationResult・AuditEvent の2スキーマで targetUserId が必須フィールド、GET /audit-events のクエリパラメータとして1箇所。
    • console/ フロントエンド(9ファイル + テスト): api/types.js、api/audit.js、features/audit/useAuditEventsQuery.js、features/audit/UserAuditTab.jsx、features/audit/AuditEventsPage.jsx、features/audit/AuditEventDrawer.jsx、features/audit/OperationPage.jsx、mocks/handlers.js、mocks/fixtures.js、および AuditEventsPage.test.jsx・api/users.test.js・api/sessions.test.js・e2e/session-actions.spec.js・e2e/user-state.spec.js。
  • OperationAction(USER_ENABLED/USER_DISABLED/SESSION_REVOKED/USER_LOGGED_OUT)と OperationStepName はいずれも閉じた enum で、OperationModel.kt 以外に隠れた定義はない(repo 全体で確認済み)。現状4値はすべて user を target にした動作。

target_user_id(DB 列)・targetUserId(契約フィールド・Kotlin 型)を、汎用の target_id(nullable string)+ target_type(enum、今回は USER の1値のみ追加)に収束させる。 最終形は一本化(リネーム)だが、ローリングデプロイ中の新旧混在に耐えるよう、expand→contract の2段階で移行する。既存フィールドをそのまま恒久的に残す「純粋加算」方式ではない。理由は Alternatives を参照。

1. DB マイグレーション:expand → contract の2段階

Section titled “1. DB マイグレーション:expand → contract の2段階”

Phase 1(expand、本 ADR の実装 PR でここまで行う): V4__console_operation_generic_target.sql

(実装 PR(#70)作成時点では V3 だったが、develop 側で先に V3__console_bulk_logout_job.sql(#79、Session Bulk Logout)が入ったため、実装時に V4 へ採番し直した。)

-- console_operation の target を user 専用から汎用に一般化する(ADR 0004、Phase 1: expand)。
-- 既存の target_user_id 列には一切手を触れない — ローリングデプロイ中、まだ旧コードを積んだ
-- タスクが target_user_id を読み書きし続けても失敗しない。
alter table console_operation add column target_id varchar(255);
alter table console_operation add column target_type varchar(16);
alter table console_operation add constraint chk_console_operation_target_type
check (target_type in ('USER') or target_type is null);
-- 既存行(もしあれば)を埋める。新しいコードが book前に書かれた行は全部 USER。
update console_operation set target_id = target_user_id, target_type = 'USER' where target_id is null;

アプリケーション側は Phase 1 の期間、target_user_id と target_id/target_type の両方に同じ値を書く(dual-write)。読み取り(監査クエリ、GET /operations/{id})は新しい target_id/target_type から行う。こうすることで:

  • 新タスクも旧タスクも、自分が知っている列にだけ触れていれば両方動く。
  • 新タスクが先にデプロイされて旧タスクとしばらく共存しても(ECS のローリングデプロイの通常の挙動)、どちらのタスクも自分のスキーマ期待を裏切られない。

Phase 2(contract、デプロイ完了を確認してから、別途の小さな追加マイグレーション/PR で行う。本 ADR の実装 PR には含まない)

-- 全タスクが Phase 1 のコードで安定稼働していることを確認した後に実行する。
alter table console_operation drop column target_user_id;
alter table console_operation alter column target_id set not null;
alter table console_operation alter column target_type set not null;
alter table console_operation drop constraint chk_console_operation_target_type;
alter table console_operation add constraint chk_console_operation_target_type check (target_type in ('USER'));

「デプロイ完了の確認」は、ECS のデプロイイベント(新タスク定義への切り替えが完了し、旧タスク定義のタスクが0件になったこと)を確認する、またはデプロイ後に十分なベイクタイム(例: 1デプロイサイクル分)を置く、のどちらかで行う。本番トラフィックが(現時点では)まだ無いとしても、この確認手順自体は省略しない——トラフィックの有無はローリングデプロイの機構とは独立した話であるため。

console_operation_event 側は target_user_id を持たない(operation_id 参照 + allowlisted_payload jsonb のみ)ため、どちらのフェーズでも変更不要。

Phase 1.5(2026-10-09 改訂2 で追加、実装済み): V5__console_operation_target_user_id_nullable.sql

ADR 0005(Organization 書き込みの権限モデル・ライフサイクル設計)のレビューで、Phase 1 のスキーマには Phase 2 を待てない穴があることが分かった: 作成系アクション(Organization/Client)は upstream が ID を払い出すまで対象ユーザーが存在せず、target_user_id に書ける実在の値がそもそも無い。Phase 1 のまま target_user_id が NOT NULL だと、この種のアクションの意図(INTENT イベント)を合法に INSERT できない——Organization 書き込み・Client 作成のどちらの実装 PR も、着手前にこれを解決しないといけなくなる。

Phase 2(列削除・契約からの除去)そのものを前倒しする理由はない——dual-write を終わらせる判断(ローリングデプロイの安全な収束を確認する手順)とは別の話だからである。前倒しするのは NOT NULL 制約の解除だけ:

alter table console_operation alter column target_user_id drop not null;

これは Phase 1 の dual-write の形(両方の列に書く)を一切変えない——既存4アクションは今まで通り target_user_id に実在のユーザー ID を書き続ける。変わるのは「作成系アクションが target_user_id に何も書かない(null のまま)」ことが合法になる点だけ。Phase 2 の列削除自体は本改訂でも実施しない——引き続き、Phase 1 のデプロイが安定したことを確認してから別の小さな PR で行う。

あわせて、§2 で設計のみ決まっていた「store.complete() が resolvedTargetId を受け取って targetId を確定させる」ライフサイクルを、実際に呼び出し側まで配線した: ActionResult(ActionRunner.kt)に resolvedTargetId: String? = null を追加し、OperationService.execute() が runner.run() の結果からそれを store.complete(..., resolvedTargetId = outcome.resolvedTargetId) へ素通しする。失敗・タイムアウト(incomplete = true)の経路は resolvedTargetId を明示的に残さず null のままにする——「存在するかどうか分からない対象に実在する ID を推測で埋めない」という本 ADR の原則をそのまま適用している。これで Organization/Client の実装 PR は、この生命周期パターンを自分で組み立て直す必要がなく、ManagementAction から ActionResult(steps, resolvedTargetId = ...) を返すだけで済む。

2. identity-admin 側のコード変更(Phase 1 は次の実装 PR で実施。Phase 1.5 は本改訂で実施済み——下記は更新済みの記述)

Section titled “2. identity-admin 側のコード変更(Phase 1 は次の実装 PR で実施。Phase 1.5 は本改訂で実施済み——下記は更新済みの記述)”
  • Operation.kt: targetUserId: String?(Phase 1.5 で nullable に変更 — 作成系アクションは実在するユーザーを持たないため。Phase 1 の間は dual-write の書き込み先として引き続き使う)、targetId: String?(nullable — 作成系アクションで ID 未確定のまま意図を記録できるようにするため)+ targetType: TargetType?(enum class TargetType { USER } を新設)を追加する。
  • target のライフサイクル(作成系アクションのための設計、本 ADR で定義し、実装は各機能 PR で行う):
    • 既存4アクション(対象が既に存在するユーザーに対する操作)は、reserve() の時点で targetId/targetType も確定しているので、今まで通り OperationRequest に載せて渡す。
    • 将来の 作成系アクション(ORGANIZATION_CREATED 等)は、reserve() 時点で targetId = null のまま意図を記録し、runner.run() が Keycloak から払い出された ID を受け取ってから、store.complete() を resolvedTargetId: String? を受け取れるよう拡張した呼び出しで targetId を確定させる(completed_at/result が今も同じタイミングで確定しているのと同じパターン)。
    • 作成が失敗した場合、または upstream がタイムアウトして成功したかどうか不明な場合(OperationStatus.UNKNOWN)は、targetId は null のまま残す——「対象が実在するかどうか分からない操作に、存在するはずの ID を推測で埋めない」という、本モデルの既存の原則(spec §6.3 の UNKNOWN 扱いと同じ考え方)をそのまま適用する。
  • OperationRepository.kt(ネイティブ SQL)・OperationEventRepository.kt(JPQL)に target_id/target_type の読み書きを追加(Phase 1 では target_user_id 用の既存コードはそのまま残し、追加するだけ)。
  • OperationModel.kt の契約型に OperationResult.targetId: String? + targetType: TargetType? を追加(Phase 1 では targetUserId も残したまま両方公開する。契約からの targetUserId 削除は Phase 2 と合わせて行う)。AuditEventModel.kt の AuditEvent も同様。
  • OperationStore.kt・OperationService.kt・WriteRequests.kt の呼び出し経路(UserStateMutationService.kt・SessionMutationService.kt が使う WriteOrigin.operationRequest(...))は、既存4アクションについては targetType = TargetType.USER を明示的に渡すよう拡張するのみ。呼び出し側の実際の対象ユーザーは変わらない。
  • AuditEventsService.kt・AuditEventsController.kt に targetId・targetType の両方をクエリ対応させる(§3 参照)。

3. OpenAPI 契約(contracts/console-v1.openapi.yaml)

Section titled “3. OpenAPI 契約(contracts/console-v1.openapi.yaml)”
  • OperationResult・AuditEvent に targetId(string, nullable)+ targetType(enum [USER], nullable)を追加。Phase 1 の間は targetUserId(必須, string)も契約に残す(フロントエンドの移行を急がせないため)。Phase 2 で targetUserId を契約から削除する。
  • GET /audit-events に targetId と targetType の両方をクエリパラメータとして追加する(targetUserId は Phase 1 の間残す)。targetType を省略した場合のデフォルト挙動は定義せず、ユーザー監査ページ(UserAuditTab.jsx が叩く経路)は targetType=USER を明示的に渡す——型を指定しない「ID だけ一致すればヒット」という曖昧な挙動を最初から作らない。
  • ページングカーソルの署名(AuditEventsService.kt の signatureOf(...))のハッシュ入力に query.targetType も含める。型を跨いだ ID の意図しない混入を防ぐため。
  • api/types.js の2つの typedef、api/audit.js の auditParams、features/audit/useAuditEventsQuery.js、AuditEventsPage.jsx、AuditEventDrawer.jsx、OperationPage.jsx、mocks/handlers.js・mocks/fixtures.js に targetId/targetType を追加(Phase 1 の間、targetUserId の参照はそのまま残してよい——契約がまだ両方を提供しているため、フロントエンドの移行はバックエンドと同時でなくてもよい)。
  • targetId を /users/:id へのリンクとして描画する既存の挙動は、targetType === 'USER' のときだけそのリンクを出す、という条件分岐に変える(今は全件 USER なので見た目の挙動は変わらない。ORGANIZATION/CLIENT 向けのリンク先 UI は今回実装しない——リンクを出さない/プレーンテキスト表示に留める)。
  • 対応するテスト(AuditEventsPage.test.jsx・api/users.test.js・api/sessions.test.js)と e2e(session-actions.spec.js・user-state.spec.js)を追従させる。
  • Organization の書き込み(作成・招待・メンバー増減)、Client の作成・設定機能そのもの。どちらも本 ADR が提供する土台の上に、それぞれ別の ADR/PR でレビューする。
  • OperationAction/OperationStepName への新しい値(ORGANIZATION_CREATED 等)の追加。土台の型(TargetType enum に ORGANIZATION/CLIENT を増やす、OperationAction に新しい値を増やす)は各機能の実装 PR 側で行う。
  • Phase 2(contract:target_user_id 列・契約フィールドの削除)は本 ADR の実装 PR に含まない。Phase 1 のデプロイが安定したことを確認してから、別の小さな PR で行う。
  • ADR 0003 で先送りにした Organization 書き込みと、Issue #58 の Client 書き込みの両方が、同じ土台の上で設計・実装できるようになる。どちらが先に着手しても、この Operation モデル拡張を重複して行う必要がない(Issue #58 のレビューコメントで指摘したリスクの解消)。
  • Phase 1/Phase 2 に分けたことで、本番にトラフィックが乗っているかどうかに関係なく、ローリングデプロイ中の新旧タスク混在に対して安全になる。 「本番ルーティングがまだ配線されていないから今やるのが得」という実務上の理由(コンセンサスの取りやすさ、早いタイミングでの一本化)は変わらず有効だが、安全性そのものは expand/contract の構造自体が担保する——トラフィックの有無に依存しない。
  • 作成系アクション(Organization/Client 作成)が、target の ID がまだ確定していない時点で意図を記録できるようになる——targetId を nullable にし、store.complete() 相当の確定フェーズで埋める設計を本 ADR の時点で決めておくことで、Organization 書き込み PR・Client 作成 PR がそれぞれ独自に target ライフサイクルを設計し直す必要がなくなる。
  • 監査クエリ・カーソル署名が targetType を最初から扱うことで、将来 Organization/Client の監査データが増えた時に、同じ共有基盤(GET /audit-events のクエリパラメータ、ページングカーソル)をもう一度変更する必要がない。
  • console_operation に本番データが既に存在していたとしても、Phase 1 は追加のみで無損失。Phase 2 の列削除も、その時点で旧列を読む経路が残っていないことを確認してから実行するため安全。
  • 次の実装 PR のスコープは本 ADR が列挙した「Phase 1(expand)の土台の一般化」のみに限定し、Organization/Client の書き込みロジック自体、および Phase 2(contract)を同じ PR に混ぜない——ADR 0003・Issue #58 の双方が既に表明している「基盤を整えてから、各機能のビジネスロジックは別に実装する」という方針を踏襲する。
  • 本 ADR 自体はコード変更を含まない(計画のみ)。実装は別 PR で行う。
  • 本 ADR はあくまで静的な設計レビューであり、実際のマイグレーション実行・本番データでの検証は行っていない。次の実装 PR では少なくとも次を満たすこと: (a) Phase 1 のマイグレーションがロールバック可能であること(target_user_id を一切変更しないので、Phase 1 自体のロールバックは新しい列を drop するだけで済む)、(b) 作成系アクションが失敗・タイムアウトした場合に targetId が null のまま記録され、誤った ID が記録されないことをテストで保証すること、(c) targetType を含むクエリ・カーソルの型分離が実際に機能することをテストで保証すること。
  • (2026-10-09 改訂2) ADR 0005 が特定した「Phase 1 だけでは Organization/Client の作成系アクションの意図を記録できない」という穴は、Phase 2 を待たずに解消済み: target_user_id の NOT NULL 制約だけを V5 マイグレーションで前倒しして解除し、ActionResult.resolvedTargetId → OperationService.execute() → OperationStore.complete() の配線を完了させた。Organization 書き込み(Issue #81)・Client 作成(Issue #58)のどちらの実装 PR も、この土台の上で ManagementAction が resolvedTargetId を返すだけでよく、自分で target ライフサイクルを設計・配線し直す必要がない。Phase 2(列削除・契約からの除去)自体は変更なく、引き続き別の小さな PR として残る。
  • 純粋加算方式(恒久的に2組のフィールドを維持する): target_user_id/targetUserId を恒久的に残し、新しい target_id/target_type を並行してずっと維持する。既存のフロントエンド・テストに一切触れずに済み、短期的なリスクは最小。しかし長期的には同じテーブル・同じ契約の中に意味が重なる2組のフィールドが併存し続け、「USER を target にした操作は target_user_id と target_id/target_type のどちらを見ればよいのか」という曖昧さが読者・実装者に残り続ける。いずれ一本化の清算が必要になることが分かっている以上、採用しない。
  • 一時的な dual-write を経て一本化する(本 ADR の採用案): 上記の「恒久的併存」と違い、Phase 2 で旧列を削除して最終的に1組のフィールドに収束させる。ローリングデプロイ中の互換性は dual-write 期間でカバーしつつ、恒久的な技術的負債は残さない。Phase 1/Phase 2 を分けるぶん作業は2回になるが、どちらも小さく安全な変更で、総コストは「恒久的併存」より低いと判断。
  • 単発の RENAME COLUMN で一気に一本化する(当初案、本改訂で撤回): データの無損失性だけを根拠にしていたが、ローリングデプロイ中に新旧タスクが同じスキーマを取り合う問題を見落としていた。停止・切り戻し手順を整備すれば単発リネームも不可能ではないが、ECS のローリングデプロイでは新旧タスクの完全な原子的切り替えは保証されないため、停止ウィンドウを設けるコストが dual-write 方式より高くつく。採用しない。
  • Organization 書き込み PR または Client 作成 PR のどちらかが、実装のついでにこの一般化を行う: Issue #58 のレビューで指摘した通り、先に着手した方の PR にアーキテクチャ全体に影響する変更が紛れ込み、後から着手する方はレビューに参加しないままその恩恵だけを受けることになり、責任の所在が曖昧になる。独立した ADR + PR として切り出し、この変更自体を一人前のレビュー対象にする方が健全なため、採用しない。