Skip to content

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
項目バージョン
Mac26.5
Keycloak26.5.6
Docker29.5.3
Docker Compose5.1.1

構築手順は、OAuth 2.0 Token Introspection を Keycloak で検証するを参照してください。

Terminal window
terraform -chdir=terraform init
terraform -chdir=terraform plan
terraform -chdir=terraform apply -auto-approve

ここからは、Next.js App Router、Keycloak、PostgreSQL を組み合わせたサンプルアプリを題材に、認可をどこに置くかを整理します。

ここでは、Next.js App Router のモジュラモノリスを前提に考えます。 ディレクトリは bounded context ごとに切り、その中で必要に応じて application / domain / infrastructure を持つ構成です。

この前提では、認可ロジックを Server Actions ごとに直接書くよりも、各 context の applicationpolicy に寄せた方が扱いやすいです。 Server ActionsRoute 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 │
└────────────────────┘
役割主な置き場所
認証と coarse-grained な claimsKeycloaksub、role、group、tenant claim
粗い認可(ルート・機能単位)Middleware / Layout/admin は admin のみ
ユースケース単位の認可bounded context の application / policy自分の申請だけ編集できる
データ境界の強制PostgreSQL の query / 必要なら RLS同一 tenant のデータだけ取得する
複数サービス横断の共有ポリシーKeycloak PDP または専用 PDP複数サービスで同じ承認規則を共有する

sample-app のような Next.js App Router の構成では、src/app に validation / authorization / domain の本体を置くのではなく、bounded context 配下に寄せて app は入口に留める方が整理しやすいです。

レイヤー / 関心主な配置場所主な呼び出し元役割の要点
認証Keycloak、identity contextMiddleware、Layout、Server Action、RSCログイン状態と claims を供給する
粗い認可src/app の Middleware / Layoutルート遷移時、画面表示前画面・機能単位で早めに弾く
ValidationapplicationServer Actionsform input を application が受け取れる command に正規化する。クライアントとサーバーでルールを共有できるよう application に配置する
ユースケース単位の認可application / policyServer Actions、RSC、Route Handlers、他の use caseactor と resource を見て操作可否を判定する
Domain rule / invariantbounded context の domainapplication 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 ActionsRoute Handlers、RSC などのサーバー側入口を PEP として扱います。入口では session / token から actor を作り、入力検証を行い、必要なリソース属性を PostgreSQL から取得してから、アプリケーションレイヤーの policy に認可判断を委ねます。クライアント側のボタン非表示や画面制御は UX のための補助であり、認可の本体にはしません。

一覧取得と単体操作も分けて考えます。単体操作では canApprove(actor, request) のような policy 関数で判断できますが、一覧取得では「取得してから UI で隠す」のではなく、最初から departmentIdownerId などで PostgreSQL の query を絞り込みます。必要であれば RLS を併用しますが、その場合でもアプリケーション側の policy と DB 側の制約が何を保証するのかを分けておきます。

処理の流れは、おおむね次の形にすると境界が曖昧になりにくいです。

  1. src/app の Server Action / Route Handler / RSC で認証済み session を確認する
  2. Keycloak の claim から actor を作る
  3. 入力値を validation して command / query に変換する
  4. PostgreSQL から認可判断に必要なリソース属性を取得する
  5. application / policy で認可判断する
  6. domain で業務ルールを検証し、状態変更する
  7. infrastructure で永続化する

この流れにすると、Keycloak は認証と主体属性、PostgreSQL はリソース属性とデータ境界、アプリケーションレイヤーはユースケース単位の認可判断、ドメインレイヤーは業務として成立する不変条件、という役割に分けられます。

サンプルプロジェクト: 社内経費申請システム

Section titled “サンプルプロジェクト: 社内経費申請システム”

題材は、社内の経費申請システムです。一般的なマルチテナント設計でいう tenant 境界を、このサンプルでは department 境界として表現します。

このサンプルでは簡略化のため、manager が扱える範囲は users.role = 'manager' かつ users.departmentId で表現します。 departments.managerId のような部署責任者のマスタや、複数部署の兼務は扱いません。

また、ここで扱うのは申請ドメインのルールだけです。 src/app/admin/users / src/app/admin/departments の管理画面の認可・検証ルールは対象外とします。

項目役割
id1DB上の内部ID(auto increment)
publicIdusr_3Hf4J1fCSMcw_UrT3pNIE公開用ID(nanoidで生成)
keycloakSubc1f7d7d2-7f11-4d8d-9c5d-6f8c2a4d9e21Keycloak上の主体との対応付け
employeeCodeE0001社員番号
name山田 太郎氏名
emailtaro.yamada@example.com連絡先
departmentId1所属部署の内部ID
rolemember / manager / admin認可に使う役割
createdAt / updatedAt2026-04-10T09:00:00+09:00監査用
項目役割
id1DB上の内部ID(auto increment)
publicIddep_8Kp2LmN4xQv7RsT1uYzAB公開用ID(nanoidで生成)
codeSALES部署コード
name営業部部署名
createdAt / updatedAt2026-04-10T09:00:00+09:00監査用

Validation を考える前提として、まずは申請データの最小セットを次のように置きます。

項目役割
id1DB上の内部ID(auto increment)
publicIdexp_3Hf4J1fCSMcw_UrT3pNIE公開用申請ID(nanoidで生成)
departmentId1申請時点の所属部署の内部ID
applicantId1申請者の内部ID
title4月の出張交通費申請の要約
amount12000金額
currencyJPY通貨
categorytravel経費種別
description大阪出張の新幹線代用途説明
occurredOn2026-04-10支出日
statusDraft / Submitted / Approved / Withdrawn / Settled / Invalidated状態管理
submittedAt2026-04-10T09:00:00+09:00申請日時
approvedAt2026-04-11T15:00:00+09:00承認日時
approverId10承認者の内部ID
receiptUrlhttps://example.com/receipts/exp_123.pdf領収書添付
createdAt / updatedAt2026-04-10T09:00:00+09:00監査用

3つのロールに対する、申請操作の可否を整理します。この表を元に、サンプルアプリで扱う認可モデルを設計します。

操作条件membermanageradmin
閲覧自分の申請
閲覧同部署の申請(Submitted 以降)○(全部署)
作成下書き(このサンプルでは title 必須)
編集下書き・自分の申請
申請下書き・自分の申請
取り下げ申請中・自分の申請
承認申請中・同部署・自己承認禁止○(全部署)
差し戻し申請中・同部署・自己承認禁止○(全部署)
削除下書き・自分の申請
無効化全状態・全部署
精算済みにする承認済み

申請の状態遷移を以下のように定義します。

申請を中心に、サンプル全体で登場するルールを Authorization / Domain / Validation で整理する

ルール整理フォーマットの例
ID分類操作 / 項目ルール文メモ
A-001Authorization閲覧member / manager / admin は自分の申請を閲覧できる所有者ベースの閲覧
A-002Authorization閲覧manager は自分と同じ departmentId を持つ Submitted 以降の申請を閲覧できる他人の Draft は閲覧不可
A-003Authorization閲覧admin は全部署の Submitted 以降の申請を閲覧できる他人の Draft は閲覧不可
A-004Authorization一覧取得member は自分の申請だけ一覧取得できる検索条件でも強制する
A-005Authorization一覧取得manager は自分と同じ departmentId を持つ Submitted 以降の申請だけ一覧取得できる他人の Draft は一覧にも出さない
A-006Authorization一覧取得admin は全部署の Submitted 以降の申請を一覧取得できる他人の Draft は一覧にも出さない
A-007Authorization作成member / manager / admin は申請を作成できる作成自体は全ロール可。未ログインユーザーは作成不可
A-022Authorization作成member / manager / admin は他人の名義で申請を作成できない代理申請は不可
A-008Authorization共通未ログインユーザーは申請関連の操作を実行できない一覧・閲覧・作成・更新・状態遷移すべて actor 前提
A-009Authorization承認待ち一覧manager / admin だけが承認待ち一覧を取得できるmember はアクセス不可(403)
D-016DomainapplicantIdapplicantId はログイン中ユーザーから補完され、申請は常にそれを持つ作成時に actor から決まる
D-017DomaindepartmentIddepartmentId は申請者の所属に基づいて決まる作成時に actor から決まる
A-010AuthorizationreceiptUrl領収書添付の参照権限は申請本体の閲覧権限に従う添付だけ直接見せない
A-011Authorization編集member / manager / admin は自分の申請を編集できる他人の申請は編集不可
D-001Domain編集Draft の申請だけ編集できる状態制約
A-012Authorization申請member / manager / admin は自分の申請を申請できる他人の申請は申請不可
D-002Domain申請Draft の申請だけ申請できるDraft → Submitted
D-003Domain申請申請時には title / amount / description / category / occurredOn がそろっていなければならない不完全な下書きは送信不可
A-013Authorization取り下げmember / manager / admin は自分の申請を取り下げできる他人の申請は取り下げ不可
D-004Domain取り下げSubmitted の申請だけ取り下げできるSubmitted → Withdrawn
A-014Authorization承認manager は自分と同じ departmentId を持つ申請を承認できる現在の組織配属で判定する
A-015Authorization承認admin は全部署の申請を承認できる部署横断
A-016Authorization承認待ち一覧承認待ち一覧には Submitted の申請だけを表示し、自分が申請したものは含めないUX 上のキュー制御であり、自己承認禁止の正本は D-006
D-018DomainapproverId承認時の approverId は承認操作を実行した本人から決まる任意の承認者を指定しない
D-005Domain承認Submitted の申請だけ承認できるSubmitted → Approved
D-006Domain承認申請者本人は自分の申請を承認できない自己承認禁止
D-007Domain承認承認時は approverIdapprovedAt を同時に確定する片方だけ更新しない
A-017Authorization差し戻しmanager は自分と同じ departmentId を持つ申請を差し戻しできる現在の組織配属で判定する
A-018Authorization差し戻しadmin は全部署の申請を差し戻しできる部署横断
D-008Domain差し戻しSubmitted の申請だけ差し戻しできるSubmitted → Draft
D-009Domain差し戻し申請者本人は自分の申請を差し戻しできない自己差し戻し禁止
A-019Authorization削除member / manager / admin は自分の申請を削除できる他人の申請は削除不可
D-010Domain削除Draft の申請だけ削除できるDraft から終了
A-020Authorization無効化admin は全部署の申請を無効化できるadmin 専用操作
D-011Domain無効化Submitted / Approved / Withdrawn の申請を無効化できる状態遷移図ベース
D-012Domain無効化無効化しても申請レコードと監査履歴は保持する物理削除しない
A-021Authorization精算済みにするadmin は全部署の申請を精算済みにできるadmin 専用操作
D-013Domain精算済みにするApproved の申請だけ精算済みにできるApproved → Settled
D-014Domain精算済みにするSettled は終端状態とし、以後の状態変更はできない精算後に巻き戻さない
D-015DomaindepartmentIddepartmentId は申請時点の所属部署のスナップショットとして保持し、後から所属変更で書き換えない過去申請の所属を固定する
D-020Domain作成申請作成時の初期状態は Draft であり、submittedAt / approvedAt / approverIdnull でなければならない初期値の固定
D-021Domain申請申請時は approvedAt / approverIdnull に戻す未承認状態へ正規化する
D-022Domain差し戻し差し戻し時は submittedAt / approvedAt / approverIdnull に戻すDraft に戻すときに承認痕跡を残さない
D-023Domainamount申請時の amount は 1以上でなければならない0円申請は不可
D-024DomainsubmittedAt申請時は submittedAt を現在時刻に設定する差し戻し後の再申請でも再設定し、初回申請日時は保持しない
D-026Domain承認申請者の role にかかわらず、承認可否は承認者の権限と自己承認禁止で判定するadmin の申請も同様に扱う
D-027DomainreceiptUrlreceiptUrl は Draft の申請だけ設定・更新できる修正したければ差し戻し後に再申請する
D-028Domaincurrencycurrency が未指定なら JPY を補完するこのサンプルでは円建てを既定とする
V-001ValidationpublicId必須 / 21文字であり一意である公開用ID
V-002ValidationdepartmentIddepartmentId はクライアント入力として受け付けない部署すり替え防止
V-003ValidationapplicantIdapplicantId はクライアント入力として受け付けないなりすまし防止
V-004ValidationtitleDraft では必須 / 前後空白を除去して空文字列は不可 / 最大文字数を持つこのサンプルの Draft は空保存を許さない
V-005Validationamount値を持つ場合は数値であり、アプリケーションで扱える最大桁数を超えない業務上の下限は D-023 で判定する
V-006Validationcurrency値を持つ場合は 3文字の通貨コードである未指定時の補完は D-028 で判定する
V-007ValidationcategoryDraft では任意 / 値を持つ場合は定義済みのカテゴリから選ぶ申請時の必須性は D-003 で判定する
V-008ValidationdescriptionDraft では任意 / 値を持つ場合は文字列であり、最大文字数を持つ申請時の必須性は D-003 で判定する
V-009ValidationoccurredOnDraft では任意 / 値を持つ場合は YYYY-MM-DD 形式の日付であり、未来日ではない申請時の必須性は D-003 で判定する
V-011ValidationsubmittedAt値を持つ場合は ISO 8601 形式の日時である未申請なら null
V-012ValidationapprovedAt値を持つ場合は ISO 8601 形式の日時である承認時刻
V-013ValidationapproverId値を持つ場合は空文字列は不可 / 既存のユーザーを参照する承認者ID
V-014ValidationreceiptUrl値を持つ場合は https:// で始まるURLである領収書添付
V-015ValidationcreatedAt / updatedAtISO 8601 形式の日時であるシステム管理項目
V-016ValidationemployeeCode必須 / 所定のフォーマットに一致し、一意である例: E0001
V-017Validationdepartment.code必須 / 空文字列は不可 / 一意である例: SALES
V-018ValidationsubmittedAt / approvedAt / approverIdsubmittedAt / approvedAt / approverId はクライアントから直接指定・更新してはならないシステム管理項目
V-019ValidationpublicIdpublicId はクライアント入力として受け付けず、サーバー側で生成する公開用IDの偽装防止
V-020Validationstatusstatus はクライアントから直接指定・更新してはならない状態遷移はサーバー側の操作からのみ行う
V-021ValidationcreatedAt / updatedAtcreatedAt / updatedAt はクライアントから直接指定・更新してはならないシステム管理項目
D-019Domainstatus / submittedAt / approvedAt / approverIdstatussubmittedAt / approvedAt / approverId の組み合わせは状態遷移図と整合していなければならない例: Submitted 以降は submittedAt が必要、Approved / Settled は approvedAtapproverId が必要

前回のブログと本ブログのサンプルで確認したかったのは、認可はロールだけを見れば終わるものではない、という点です。

画面に入れるかどうかの粗い認可、操作実行時の業務ルール、データ境界を守るための RLS は、それぞれ役割が異なります。
そのため、Next.js / Domain / PostgreSQL / Keycloak のどこに何を置くかを先に決めておく方が、あとから仕様変更が入っても崩れにいと考えています。

サンプルアプリは小さめですが、実務でも見る論点はほぼ同じです。
まずは「誰が」「どの状態のデータに」「どの操作をできるのか」を表で固定し、その後に実装へ落とす、という順番で進める方が安全です。

今は生成AIがあるので、実装し始める前に、最低限次の点を生成AIと壁打ちするなど、言語化しておくと良いと思います。

  • ロールだけで判定できるものと、申請状態や所属部署まで見るものは分かれているか
  • UI の無効化と、サーバー側の拒否が両方定義されているか
  • 「自分の申請だけ」「同じ部署だけ」のようなデータ境界が整理されているか
  • DB 側で防ぐべきものと、アプリ側で防ぐべきものが混ざっていないか
  • 監査や将来の仕様変更に耐えられる形でルールが一覧化されているか

認可はコードを書く前の整理でその後の運用がかなり楽になります。
運用フェーズに後から認可コードを入れると後方互換性を考慮して開発をしないといけません。

このサンプルで扱っていないもの

Section titled “このサンプルで扱っていないもの”

このサンプルは、認可設計の整理を主目的にしているため、実務では追加で検討が必要な点もあります。

  • 緊急時の例外運用
  • 代理申請や兼務の扱い
  • 組織変更時の権限再計算
  • 過去データ参照権限
  • 監査ログの保存方針