Keycloak とアプリケーション認可の責務分担を整理する ー 社内経費申請システムのサンプルで試す
前回の記事では、認可の基本的な概念と、PDP(Policy Decision Point)をアプリケーション内に置く場合の設計について整理しました。今回は、実際に Keycloak を PDP として使うサンプルアプリを作成し、認可ルールの実装と配置について具体的に試してみたいと思います。
https://github.com/kntks/blog-code/tree/main/2026/06/authorization-sample-app
以下をローカル環境で使用できる
- Docker
- Docker Compose
- Node.js
| 項目 | バージョン |
|---|---|
| Mac | 26.5 |
| Keycloak | 26.5.6 |
| Docker | 29.5.3 |
| Docker Compose | 5.1.1 |
構築手順は、OAuth 2.0 Token Introspection を Keycloak で検証するを参照してください。
terraform -chdir=terraform initterraform -chdir=terraform planterraform -chdir=terraform apply -auto-approveサンプルアプリで試す
Section titled “サンプルアプリで試す”ここからは、Next.js App Router、Keycloak、PostgreSQL を組み合わせたサンプルアプリを題材に、認可をどこに置くかを整理します。
サンプルアプリの前提
Section titled “サンプルアプリの前提”ここでは、Next.js App Router のモジュラモノリスを前提に考えます。
ディレクトリは bounded context ごとに切り、その中で必要に応じて application / domain / infrastructure を持つ構成です。
認可設計の方針
Section titled “認可設計の方針”この前提では、認可ロジックを Server Actions ごとに直接書くよりも、各 context の application や policy に寄せた方が扱いやすいです。
Server Actions、Route Handlers、RSC、将来の batch job から同じユースケースを呼べるためです。
┌────────────────────────────────────────────────────────────┐│ Next.js App Router ││ ││ Middleware / Layout ││ └── 粗い認可(ルート単位) ││ 例:/admin は admin role が必要 ││ ││ Server Actions / Route Handlers / RSC ││ └── 各 bounded context の application / policy を呼ぶ ││ 例:expenseRequestPolicy.canApprove(actor, request) │└────────────────────────────────────────────────────────────┘ │ │ │ claims を受け取る │ データを読む ▼ ▼ ┌─────────────────┐ ┌────────────────────┐ │ Keycloak │ │ PostgreSQL │ │ 認証 │ │ 業務データ │ │ role / group │ │ tenant filter / │ │ claim を供給 │ │ ownership check │ └─────────────────┘ │ 必要なら RLS │ └────────────────────┘使い分けの基準
Section titled “使い分けの基準”| 役割 | 主な置き場所 | 例 |
|---|---|---|
| 認証と coarse-grained な claims | Keycloak | sub、role、group、tenant claim |
| 粗い認可(ルート・機能単位) | Middleware / Layout | /admin は admin のみ |
| ユースケース単位の認可 | bounded context の application / policy | 自分の申請だけ編集できる |
| データ境界の強制 | PostgreSQL の query / 必要なら RLS | 同一 tenant のデータだけ取得する |
| 複数サービス横断の共有ポリシー | Keycloak PDP または専用 PDP | 複数サービスで同じ承認規則を共有する |
配置場所と呼び出し元の整理
Section titled “配置場所と呼び出し元の整理”sample-app のような Next.js App Router の構成では、src/app に validation / authorization / domain の本体を置くのではなく、bounded context 配下に寄せて app は入口に留める方が整理しやすいです。
| レイヤー / 関心 | 主な配置場所 | 主な呼び出し元 | 役割の要点 |
|---|---|---|---|
| 認証 | Keycloak、identity context | Middleware、Layout、Server Action、RSC | ログイン状態と claims を供給する |
| 粗い認可 | src/app の Middleware / Layout | ルート遷移時、画面表示前 | 画面・機能単位で早めに弾く |
| Validation | application | Server Actions | form input を application が受け取れる command に正規化する。クライアントとサーバーでルールを共有できるよう application に配置する |
| ユースケース単位の認可 | application / policy | Server Actions、RSC、Route Handlers、他の use case | actor と resource を見て操作可否を判定する |
| Domain rule / invariant | bounded context の domain | application service、domain service、aggregate 操作 | 業務状態として操作が成立するかを守る |
つまり呼び出しの流れは、src/app の Server Action が validation を呼んで command を作り、application が authorization を束ね、domain が業務ルールを守り、infrastructure がデータ境界を実装する、という形になります。
Next.js + PostgreSQL + Keycloak で実装する場合の配置
Section titled “Next.js + PostgreSQL + Keycloak で実装する場合の配置”Next.js、PostgreSQL、Keycloak を組み合わせる場合は、Keycloak の token claim だけで最終的な認可判断を完結させない方が扱いやすいです。Keycloak は「誰か」「どの role / group / claim を持つか」をアプリケーションへ渡す役割に寄せ、申請データの所有者、部署、状態のようなリソース属性は PostgreSQL から取得して判断します。
実装上は、Server Actions、Route Handlers、RSC などのサーバー側入口を PEP として扱います。入口では session / token から actor を作り、入力検証を行い、必要なリソース属性を PostgreSQL から取得してから、アプリケーションレイヤーの policy に認可判断を委ねます。クライアント側のボタン非表示や画面制御は UX のための補助であり、認可の本体にはしません。
一覧取得と単体操作も分けて考えます。単体操作では canApprove(actor, request) のような policy 関数で判断できますが、一覧取得では「取得してから UI で隠す」のではなく、最初から departmentId や ownerId などで PostgreSQL の query を絞り込みます。必要であれば RLS を併用しますが、その場合でもアプリケーション側の policy と DB 側の制約が何を保証するのかを分けておきます。
処理の流れは、おおむね次の形にすると境界が曖昧になりにくいです。
src/appの Server Action / Route Handler / RSC で認証済み session を確認する- Keycloak の claim から
actorを作る - 入力値を validation して command / query に変換する
- PostgreSQL から認可判断に必要なリソース属性を取得する
- application / policy で認可判断する
- domain で業務ルールを検証し、状態変更する
- infrastructure で永続化する
この流れにすると、Keycloak は認証と主体属性、PostgreSQL はリソース属性とデータ境界、アプリケーションレイヤーはユースケース単位の認可判断、ドメインレイヤーは業務として成立する不変条件、という役割に分けられます。
サンプルプロジェクト: 社内経費申請システム
Section titled “サンプルプロジェクト: 社内経費申請システム”題材は、社内の経費申請システムです。一般的なマルチテナント設計でいう tenant 境界を、このサンプルでは department 境界として表現します。
このサンプルでは簡略化のため、manager が扱える範囲は users.role = 'manager' かつ users.departmentId で表現します。
departments.managerId のような部署責任者のマスタや、複数部署の兼務は扱いません。
また、ここで扱うのは申請ドメインのルールだけです。
src/app/admin/users / src/app/admin/departments の管理画面の認可・検証ルールは対象外とします。
| 項目 | 例 | 役割 |
|---|---|---|
| id | 1 | DB上の内部ID(auto increment) |
| publicId | usr_3Hf4J1fCSMcw_UrT3pNIE | 公開用ID(nanoidで生成) |
| keycloakSub | c1f7d7d2-7f11-4d8d-9c5d-6f8c2a4d9e21 | Keycloak上の主体との対応付け |
| employeeCode | E0001 | 社員番号 |
| name | 山田 太郎 | 氏名 |
taro.yamada@example.com | 連絡先 | |
| departmentId | 1 | 所属部署の内部ID |
| role | member / manager / admin | 認可に使う役割 |
| createdAt / updatedAt | 2026-04-10T09:00:00+09:00 | 監査用 |
| 項目 | 例 | 役割 |
|---|---|---|
| id | 1 | DB上の内部ID(auto increment) |
| publicId | dep_8Kp2LmN4xQv7RsT1uYzAB | 公開用ID(nanoidで生成) |
| code | SALES | 部署コード |
| name | 営業部 | 部署名 |
| createdAt / updatedAt | 2026-04-10T09:00:00+09:00 | 監査用 |
Validation を考える前提として、まずは申請データの最小セットを次のように置きます。
| 項目 | 例 | 役割 |
|---|---|---|
| id | 1 | DB上の内部ID(auto increment) |
| publicId | exp_3Hf4J1fCSMcw_UrT3pNIE | 公開用申請ID(nanoidで生成) |
| departmentId | 1 | 申請時点の所属部署の内部ID |
| applicantId | 1 | 申請者の内部ID |
| title | 4月の出張交通費 | 申請の要約 |
| amount | 12000 | 金額 |
| currency | JPY | 通貨 |
| category | travel | 経費種別 |
| description | 大阪出張の新幹線代 | 用途説明 |
| occurredOn | 2026-04-10 | 支出日 |
| status | Draft / Submitted / Approved / Withdrawn / Settled / Invalidated | 状態管理 |
| submittedAt | 2026-04-10T09:00:00+09:00 | 申請日時 |
| approvedAt | 2026-04-11T15:00:00+09:00 | 承認日時 |
| approverId | 10 | 承認者の内部ID |
| receiptUrl | https://example.com/receipts/exp_123.pdf | 領収書添付 |
| createdAt / updatedAt | 2026-04-10T09:00:00+09:00 | 監査用 |
申請操作の認可ルール
Section titled “申請操作の認可ルール”3つのロールに対する、申請操作の可否を整理します。この表を元に、サンプルアプリで扱う認可モデルを設計します。
| 操作 | 条件 | member | manager | admin |
|---|---|---|---|---|
| 閲覧 | 自分の申請 | ○ | ○ | ○ |
| 閲覧 | 同部署の申請(Submitted 以降) | ✗ | ○ | ○(全部署) |
| 作成 | 下書き(このサンプルでは title 必須) | ○ | ○ | ○ |
| 編集 | 下書き・自分の申請 | ○ | ○ | ○ |
| 申請 | 下書き・自分の申請 | ○ | ○ | ○ |
| 取り下げ | 申請中・自分の申請 | ○ | ○ | ○ |
| 承認 | 申請中・同部署・自己承認禁止 | ✗ | ○ | ○(全部署) |
| 差し戻し | 申請中・同部署・自己承認禁止 | ✗ | ○ | ○(全部署) |
| 削除 | 下書き・自分の申請 | ○ | ○ | ○ |
| 無効化 | 全状態・全部署 | ✗ | ✗ | ○ |
| 精算済みにする | 承認済み | ✗ | ✗ | ○ |
申請ステータスの状態遷移
Section titled “申請ステータスの状態遷移”申請の状態遷移を以下のように定義します。
ルール整理フォーマット
Section titled “ルール整理フォーマット”申請を中心に、サンプル全体で登場するルールを Authorization / Domain / Validation で整理する
ルール整理フォーマットの例
| ID | 分類 | 操作 / 項目 | ルール文 | メモ |
|---|---|---|---|---|
| A-001 | Authorization | 閲覧 | member / manager / admin は自分の申請を閲覧できる | 所有者ベースの閲覧 |
| A-002 | Authorization | 閲覧 | manager は自分と同じ departmentId を持つ Submitted 以降の申請を閲覧できる | 他人の Draft は閲覧不可 |
| A-003 | Authorization | 閲覧 | admin は全部署の Submitted 以降の申請を閲覧できる | 他人の Draft は閲覧不可 |
| A-004 | Authorization | 一覧取得 | member は自分の申請だけ一覧取得できる | 検索条件でも強制する |
| A-005 | Authorization | 一覧取得 | manager は自分と同じ departmentId を持つ Submitted 以降の申請だけ一覧取得できる | 他人の Draft は一覧にも出さない |
| A-006 | Authorization | 一覧取得 | admin は全部署の Submitted 以降の申請を一覧取得できる | 他人の Draft は一覧にも出さない |
| A-007 | Authorization | 作成 | member / manager / admin は申請を作成できる | 作成自体は全ロール可。未ログインユーザーは作成不可 |
| A-022 | Authorization | 作成 | member / manager / admin は他人の名義で申請を作成できない | 代理申請は不可 |
| A-008 | Authorization | 共通 | 未ログインユーザーは申請関連の操作を実行できない | 一覧・閲覧・作成・更新・状態遷移すべて actor 前提 |
| A-009 | Authorization | 承認待ち一覧 | manager / admin だけが承認待ち一覧を取得できる | member はアクセス不可(403) |
| D-016 | Domain | applicantId | applicantId はログイン中ユーザーから補完され、申請は常にそれを持つ | 作成時に actor から決まる |
| D-017 | Domain | departmentId | departmentId は申請者の所属に基づいて決まる | 作成時に actor から決まる |
| A-010 | Authorization | receiptUrl | 領収書添付の参照権限は申請本体の閲覧権限に従う | 添付だけ直接見せない |
| A-011 | Authorization | 編集 | member / manager / admin は自分の申請を編集できる | 他人の申請は編集不可 |
| D-001 | Domain | 編集 | Draft の申請だけ編集できる | 状態制約 |
| A-012 | Authorization | 申請 | member / manager / admin は自分の申請を申請できる | 他人の申請は申請不可 |
| D-002 | Domain | 申請 | Draft の申請だけ申請できる | Draft → Submitted |
| D-003 | Domain | 申請 | 申請時には title / amount / description / category / occurredOn がそろっていなければならない | 不完全な下書きは送信不可 |
| A-013 | Authorization | 取り下げ | member / manager / admin は自分の申請を取り下げできる | 他人の申請は取り下げ不可 |
| D-004 | Domain | 取り下げ | Submitted の申請だけ取り下げできる | Submitted → Withdrawn |
| A-014 | Authorization | 承認 | manager は自分と同じ departmentId を持つ申請を承認できる | 現在の組織配属で判定する |
| A-015 | Authorization | 承認 | admin は全部署の申請を承認できる | 部署横断 |
| A-016 | Authorization | 承認待ち一覧 | 承認待ち一覧には Submitted の申請だけを表示し、自分が申請したものは含めない | UX 上のキュー制御であり、自己承認禁止の正本は D-006 |
| D-018 | Domain | approverId | 承認時の approverId は承認操作を実行した本人から決まる | 任意の承認者を指定しない |
| D-005 | Domain | 承認 | Submitted の申請だけ承認できる | Submitted → Approved |
| D-006 | Domain | 承認 | 申請者本人は自分の申請を承認できない | 自己承認禁止 |
| D-007 | Domain | 承認 | 承認時は approverId と approvedAt を同時に確定する | 片方だけ更新しない |
| A-017 | Authorization | 差し戻し | manager は自分と同じ departmentId を持つ申請を差し戻しできる | 現在の組織配属で判定する |
| A-018 | Authorization | 差し戻し | admin は全部署の申請を差し戻しできる | 部署横断 |
| D-008 | Domain | 差し戻し | Submitted の申請だけ差し戻しできる | Submitted → Draft |
| D-009 | Domain | 差し戻し | 申請者本人は自分の申請を差し戻しできない | 自己差し戻し禁止 |
| A-019 | Authorization | 削除 | member / manager / admin は自分の申請を削除できる | 他人の申請は削除不可 |
| D-010 | Domain | 削除 | Draft の申請だけ削除できる | Draft から終了 |
| A-020 | Authorization | 無効化 | admin は全部署の申請を無効化できる | admin 専用操作 |
| D-011 | Domain | 無効化 | Submitted / Approved / Withdrawn の申請を無効化できる | 状態遷移図ベース |
| D-012 | Domain | 無効化 | 無効化しても申請レコードと監査履歴は保持する | 物理削除しない |
| A-021 | Authorization | 精算済みにする | admin は全部署の申請を精算済みにできる | admin 専用操作 |
| D-013 | Domain | 精算済みにする | Approved の申請だけ精算済みにできる | Approved → Settled |
| D-014 | Domain | 精算済みにする | Settled は終端状態とし、以後の状態変更はできない | 精算後に巻き戻さない |
| D-015 | Domain | departmentId | departmentId は申請時点の所属部署のスナップショットとして保持し、後から所属変更で書き換えない | 過去申請の所属を固定する |
| D-020 | Domain | 作成 | 申請作成時の初期状態は Draft であり、submittedAt / approvedAt / approverId は null でなければならない | 初期値の固定 |
| D-021 | Domain | 申請 | 申請時は approvedAt / approverId を null に戻す | 未承認状態へ正規化する |
| D-022 | Domain | 差し戻し | 差し戻し時は submittedAt / approvedAt / approverId を null に戻す | Draft に戻すときに承認痕跡を残さない |
| D-023 | Domain | amount | 申請時の amount は 1以上でなければならない | 0円申請は不可 |
| D-024 | Domain | submittedAt | 申請時は submittedAt を現在時刻に設定する | 差し戻し後の再申請でも再設定し、初回申請日時は保持しない |
| D-026 | Domain | 承認 | 申請者の role にかかわらず、承認可否は承認者の権限と自己承認禁止で判定する | admin の申請も同様に扱う |
| D-027 | Domain | receiptUrl | receiptUrl は Draft の申請だけ設定・更新できる | 修正したければ差し戻し後に再申請する |
| D-028 | Domain | currency | currency が未指定なら JPY を補完する | このサンプルでは円建てを既定とする |
| V-001 | Validation | publicId | 必須 / 21文字であり一意である | 公開用ID |
| V-002 | Validation | departmentId | departmentId はクライアント入力として受け付けない | 部署すり替え防止 |
| V-003 | Validation | applicantId | applicantId はクライアント入力として受け付けない | なりすまし防止 |
| V-004 | Validation | title | Draft では必須 / 前後空白を除去して空文字列は不可 / 最大文字数を持つ | このサンプルの Draft は空保存を許さない |
| V-005 | Validation | amount | 値を持つ場合は数値であり、アプリケーションで扱える最大桁数を超えない | 業務上の下限は D-023 で判定する |
| V-006 | Validation | currency | 値を持つ場合は 3文字の通貨コードである | 未指定時の補完は D-028 で判定する |
| V-007 | Validation | category | Draft では任意 / 値を持つ場合は定義済みのカテゴリから選ぶ | 申請時の必須性は D-003 で判定する |
| V-008 | Validation | description | Draft では任意 / 値を持つ場合は文字列であり、最大文字数を持つ | 申請時の必須性は D-003 で判定する |
| V-009 | Validation | occurredOn | Draft では任意 / 値を持つ場合は YYYY-MM-DD 形式の日付であり、未来日ではない | 申請時の必須性は D-003 で判定する |
| V-011 | Validation | submittedAt | 値を持つ場合は ISO 8601 形式の日時である | 未申請なら null |
| V-012 | Validation | approvedAt | 値を持つ場合は ISO 8601 形式の日時である | 承認時刻 |
| V-013 | Validation | approverId | 値を持つ場合は空文字列は不可 / 既存のユーザーを参照する | 承認者ID |
| V-014 | Validation | receiptUrl | 値を持つ場合は https:// で始まるURLである | 領収書添付 |
| V-015 | Validation | createdAt / updatedAt | ISO 8601 形式の日時である | システム管理項目 |
| V-016 | Validation | employeeCode | 必須 / 所定のフォーマットに一致し、一意である | 例: E0001 |
| V-017 | Validation | department.code | 必須 / 空文字列は不可 / 一意である | 例: SALES |
| V-018 | Validation | submittedAt / approvedAt / approverId | submittedAt / approvedAt / approverId はクライアントから直接指定・更新してはならない | システム管理項目 |
| V-019 | Validation | publicId | publicId はクライアント入力として受け付けず、サーバー側で生成する | 公開用IDの偽装防止 |
| V-020 | Validation | status | status はクライアントから直接指定・更新してはならない | 状態遷移はサーバー側の操作からのみ行う |
| V-021 | Validation | createdAt / updatedAt | createdAt / updatedAt はクライアントから直接指定・更新してはならない | システム管理項目 |
| D-019 | Domain | status / submittedAt / approvedAt / approverId | status と submittedAt / approvedAt / approverId の組み合わせは状態遷移図と整合していなければならない | 例: Submitted 以降は submittedAt が必要、Approved / Settled は approvedAt と approverId が必要 |
前回のブログと本ブログのサンプルで確認したかったのは、認可はロールだけを見れば終わるものではない、という点です。
画面に入れるかどうかの粗い認可、操作実行時の業務ルール、データ境界を守るための RLS は、それぞれ役割が異なります。
そのため、Next.js / Domain / PostgreSQL / Keycloak のどこに何を置くかを先に決めておく方が、あとから仕様変更が入っても崩れにいと考えています。
サンプルアプリは小さめですが、実務でも見る論点はほぼ同じです。
まずは「誰が」「どの状態のデータに」「どの操作をできるのか」を表で固定し、その後に実装へ落とす、という順番で進める方が安全です。
実装に入る前の確認ポイント
Section titled “実装に入る前の確認ポイント”今は生成AIがあるので、実装し始める前に、最低限次の点を生成AIと壁打ちするなど、言語化しておくと良いと思います。
- ロールだけで判定できるものと、申請状態や所属部署まで見るものは分かれているか
- UI の無効化と、サーバー側の拒否が両方定義されているか
- 「自分の申請だけ」「同じ部署だけ」のようなデータ境界が整理されているか
- DB 側で防ぐべきものと、アプリ側で防ぐべきものが混ざっていないか
- 監査や将来の仕様変更に耐えられる形でルールが一覧化されているか
認可はコードを書く前の整理でその後の運用がかなり楽になります。
運用フェーズに後から認可コードを入れると後方互換性を考慮して開発をしないといけません。
このサンプルで扱っていないもの
Section titled “このサンプルで扱っていないもの”このサンプルは、認可設計の整理を主目的にしているため、実務では追加で検討が必要な点もあります。
- 緊急時の例外運用
- 代理申請や兼務の扱い
- 組織変更時の権限再計算
- 過去データ参照権限
- 監査ログの保存方針