DELOGs
[管理画面フォーマット開発編 #8 前編] 部署別ロール ─ DepartmentRoleテーブル導入とDB設計

管理画面フォーマット開発編 #8 前編
部署別ロール ─ DepartmentRoleテーブル導入とDB設計

グローバルで一貫したRoleテーブルを保ちながら、部署ごとにロールをカスタマイズするために「DepartmentRole」テーブルを新設

初回公開日

最終更新日

0. はじめに

「管理画面フォーマット開発編」では、UIだけでなくバックエンド開発も徐々に取り込みながら進めてきました。ログイン画面の実装から始まり、JWT+Cookieによる認証、ユーザ一覧や詳細・新規作成フォーム、プロフィール編集やアバターアップロード、さらにRBACによるロールベースのアクセス制御までを整備してきました。これにより、管理画面として最低限の「ログインできる・ユーザを扱える・権限を分けられる」仕組みがすでに動いています。

部署ごとのロール管理の必要性

実際の業務システムでは、単純な「管理者」「閲覧者」といったロールだけでは足りません。部署ごとに必要な権限は微妙に異なり、呼称や区分も柔軟に調整したい場合があります。たとえば営業部だけに付与する「案件編集」ロールや、部署ごとに色分けされたバッジでロールを見分けたいケースです。こうした要件に対応する仕組みとして、グローバルのRoleを維持しつつ部署単位でカスタマイズ可能な DepartmentRole テーブルを導入します。

前編と後編に分けて進めます

ロールのDB連携は範囲が広く、DBスキーマからPrismaモデル、Server Action、UI改修、ガード処理まで関わります。すべてを一記事で扱うと分量が大きくなるため、今回は 前編(DBとPrisma編)後編(Server ActionとUI編) に分けて解説する方針としました。前編では基盤整備を中心に扱い、後編で実際の操作画面やロール管理機能を仕上げていきます。

本記事で扱う範囲

本記事(前編)では、以下の内容を取り扱います。
範囲内容
要件整理部署ごとのロール調整とグローバルRoleの関係を明確化
DB設計DepartmentRoleテーブル導入、Userテーブルへの拡張(departmentRoleId追加)
Prisma更新schema.prismaへの追記と制約の扱い方
実効ロールoverride/customの適用ルールと合成方法
ここまでを押さえることで、次回の「Server Action実装とUI改修」に向けた基盤が完成します。

技術スタック

Tool / LibVersionPurpose
React19.xUIの土台。コンポーネント/フックで状態と表示を組み立てる
Next.js15.xフルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理
TypeScript5.x型安全・補完・リファクタリング
shadcn/uilatestRadixベースのUIキット
Tailwind CSS4.xユーティリティファーストCSSで素早くスタイリング
Zod4.xスキーマ定義と実行時バリデーション
本記事では、前回の記事 【管理画面フォーマット開発編 #7】部署別ロール ─ DepartmentRoleテーブル導入とDB設計 までのソースコードを引き継いで追加・編集していきます。

1. 要件整理と設計方針

グローバルRoleとDepartmentRoleの役割分担

まず大前提として、既存の Roleテーブルはグローバル共通 です。すべての部署で意味が一貫し、コードや権限フラグは統一されます。一方で、部署ごとに「名前を変えたい」「色を変えたい」「独自のロールを作りたい」というニーズも存在します。そこで新設するのが DepartmentRoleテーブル です。
テーブル役割
Roleグローバル共通。コード・priority・権限フラグを一元管理する
DepartmentRole部署単位の調整。グローバルRoleの名称・バッジ色の上書き、または部署専用ロールの追加
User各ユーザーは roleId または departmentRoleId のどちらかを参照する

overrideモードとcustomモード

DepartmentRoleは2つの使い方を想定しています。グローバルRoleを基にした「override」と、部署専用に新規作成する「custom」です。
モード特徴制約条件
overrideグローバルRoleに名前・バッジ色だけ上書きするRole.priorityや権限フラグは変更不可
custom部署専用に新規作成。コード・名前・priority・権限を定義できるpriorityは99以下に制限

priorityルール

ロールの強さを示すpriorityは、ガード処理やメニュー表示に直結します。そのため、部署ごとに新規作成できるロールのpriorityは 99以下(ADMIN未満) に制約します。これにより、グローバルで定義される「システム管理者」以上のロールを部署側が勝手に作れないようになります。
種別priority範囲管理主体
システム管理者系100以上グローバル
部署ローカル系99以下DepartmentRole

XORルール(roleId と departmentRoleId)

Userモデルでは、ユーザーが参照できるのは roleId(グローバルRole) または departmentRoleId(部署ローカルRole) のいずれかです。同時指定は許されず、XOR制約 を付けることで整合性を保ちます。
text
1User 2 ├─ roleId (グローバルRole) → Role.id を参照 3 └─ departmentRoleId (部署ロール) → DepartmentRole.id を参照 4※ 両方同時にはNULL不可

設計方針のまとめ

今回の設計方針を整理すると次の通りです。
項目方針
Roleテーブルグローバル共通。変更不可。コード・priority・権限フラグを一元管理
DepartmentRoleテーブル部署単位の調整。override(名称・色のみ上書き)とcustom(新規作成)をサポート
priorityルールcustomは99以下に制約し、ADMIN以上はグローバルのみ定義可能
UserテーブルroleId または departmentRoleId のどちらか一方を参照(XOR制約)

2. DB設計とマイグレーション

部署ごとのロール調整を可能にするため、グローバル共通の Role を維持しつつ、部署単位で上書き/新規作成を担う DepartmentRole を追加します。ユーザーは RoleDepartmentRole のどちらか一方を参照できるようにし、整合性は制約で担保します。

ER図と責務分担(Role / DepartmentRole / Userの関係)

Role・DepartmentRole・Userの関係を文字図で示すと次のようになります。
txt
1Role (グローバル) 2 ├─ id 3 ├─ code / name / priority / 権限フラグ 4 └─ users (roleId経由) 5 6DepartmentRole (部署別) 7 ├─ id 8 ├─ departmentId 9 ├─ roleId (override用: グローバル参照) 10 ├─ code / name / priority (custom用) 11 └─ users (departmentRoleId経由) 12 13User 14 ├─ departmentId 15 ├─ roleId (グローバル参照) 16 └─ departmentRoleId (部署参照) 17 ※ roleId と departmentRoleId は XOR制約

DepartmentRole テーブルの定義

DepartmentRole は 2モード を1テーブルで表現します。override は「名称・バッジ色のみ上書き」、custom は「部署固有の新規ロール作成(priority ≤ 99)」です。
フィールド必須用途/説明
idUUID✔︎主キー
displayIdString✔︎表示用ID(シーケンス + “DR”)
departmentIdUUID✔︎部署参照
roleIdUUIDoverride用:参照先のグローバルRole
nameOverrideStringoverride用:名称の上書き
badgeColorOverrideStringoverride用:バッジ色の上書き
codeStringcustom用:部署ローカルのロールコード(部署内一意)
nameStringcustom用:部署ローカルのロール名
priorityIntcustom用:99以下に制約
canDownloadDataBooleancustom用:データDL権限
canEditDataBooleancustom用:データ編集権限
isEnabledBoolean✔︎部署視点の利用可否(初期 true)
createdAt / updatedAtDateTime✔︎タイムスタンプ

User テーブルの拡張(XOR)

User は Role または DepartmentRole のどちらか一方を参照します。編集フォーム/サーバ側バリデーションの両方で XOR を強制します。
text
1User 2├─ departmentId 3├─ roleId? # 参照:Role.id 4└─ departmentRoleId? # 参照:DepartmentRole.id 5※ (roleId が NULL) XOR (departmentRoleId が NULL)

制約ルール(整合性保護)

整合性と一貫性を担保するため、次の制約を設けます。
区分内容
override一意(departmentId, roleId) を一意(部署×対象Roleに対して1定義まで)
custom一意(departmentId, code) を一意(部署内で同コード重複禁止)
priority上限custom の priority ≤ 99 を DB 制約で保証
User の XORroleIddepartmentRoleId の同時指定禁止(DB制約 + フォーム/SA での二重検証)
部署整合性User.departmentId = DepartmentRole.departmentIdアプリ側で必ず検証

マイグレーションの流れ

ゼロダウンタイムを意識し、既存ユーザーの roleId を尊重しつつ段階導入します。
  1. DepartmentRole テーブル作成(シーケンス・displayId・一意制約・CHECK制約を設定)
  2. UserdepartmentRoleId 追加(インデックス付与、roleId との XOR 制約追加)
  3. 既存データはそのまま稼働(全ユーザーは引き続き roleId を参照)
  4. 必要な部署から順に override/custom を投入(isEnabled やコード規約で運用)
  5. 将来、必要ユーザーのみ departmentRoleId へ移行(E2Eを含む移行テストを実施)

章のまとめ

  • Role はグローバル共通DepartmentRole は部署単位の上書き/新規ロール を担う。
  • User は XOR (Role or DepartmentRole のどちらか一方)。
  • custom の priority は 99 以下 、override は 名称・色のみ 上書き。
  • 段階導入で既存運用を止めない設計にする。

3. Prismaモデルの更新

前章で整理したDB設計を、Prismaのスキーマファイルに反映します。Prismaでは制約やCHECKの一部を直接表現できないため、Prismaモデルで表現できる範囲はPrismaに任せ、残りはマイグレーションSQLで補完 する方針をとります。

DepartmentRoleモデル

まずは DepartmentRole のモデルを schema.prisma に追記します。
このモデルは override と custom の両モードを1テーブルで表現します。
prisma
1model DepartmentRole { 2 id String @id @default(uuid()) 3 displayId String @unique @default(dbgenerated("generate_display_id('department_role_display_id_seq','DR')")) @db.VarChar(10) 4 createdAt DateTime @default(now()) @db.Timestamptz 5 updatedAt DateTime @updatedAt @db.Timestamptz 6 7 departmentId String 8 department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict) 9 10 // override モード 11 roleId String? 12 role Role? @relation(fields: [roleId], references: [id], onDelete: Restrict) 13 nameOverride String? 14 badgeColorOverride String? 15 isEnabled Boolean @default(true) 16 17 // custom モード 18 code String? @db.VarChar(50) 19 name String? 20 priority Int? 21 badgeColor String? 22 canDownloadData Boolean? 23 canEditData Boolean? 24 isSystem Boolean? 25 remarks String? 26 27 users User[] 28 29 @@unique([departmentId, roleId]) 30 @@unique([departmentId, code]) 31 @@index([departmentId]) 32 @@index([roleId]) 33}
この定義では、override/customの両方を許容しています。ただしXORやpriorityの上限など、Prismaモデルで表現できない部分は マイグレーションSQLでCHECK制約を追加 します。

Userモデルの拡張

Userモデルには departmentRoleId を追加し、RoleかDepartmentRoleのどちらか一方を参照できるようにします。
prisma
1model User { 2 id String @id @default(uuid()) 3 displayId String @unique @default(dbgenerated("generate_display_id('user_display_id_seq','US')")) @db.VarChar(10) 4 isActive Boolean @default(true) 5 createdAt DateTime @default(now()) @db.Timestamptz 6 updatedAt DateTime @updatedAt @db.Timestamptz 7 deletedAt DateTime? @db.Timestamptz 8 9 departmentId String 10 roleId String 11 email String 12 hashedPassword String 13 name String 14 phone String? 15 remarks String? 16 departmentRoleId String? // ← 追加 17 18 // ログイン失敗ロック 19 failedLoginCount Int @default(0) 20 lockedUntil DateTime? @db.Timestamptz 21 22 // アバター画像 23 avatar String? // UUID+拡張子を格納 24 25 // リレーション 26 department Department @relation(fields: [departmentId], references: [id], onDelete: Restrict) 27 role Role @relation(fields: [roleId], references: [id], onDelete: Restrict) 28 // ↓追加 29 departmentRole DepartmentRole? @relation(fields: [departmentRoleId], references: [id], onDelete: Restrict) 30 31 32 // Session への逆側(双方向)リレーション 33 sessions Session[] @relation("UserSessions") 34 35 /// 逆リレーション: このユーザーが出したメール変更申請 36 emailChangeRequests EmailChangeRequest[] 37 38 // 部署内メール一意(ログインキー) 39 @@unique([departmentId, email]) 40 @@index([departmentId]) 41 @@index([roleId]) 42 // ↓追加 43 @@index([departmentRoleId]) 44 @@index([isActive]) 45 @@index([createdAt]) 46}
これによりユーザーは roleIddepartmentRoleId のどちらかを参照可能になります。
両方同時は不可であり、この制約はSQLで追加します。

Departmentモデルの修正

Departmentは多数の DepartmentRole を持つため、逆リレーションを追加します。
prisma
1model Department { 2 id String @id @default(uuid()) 3 displayId String @unique @default(dbgenerated("generate_display_id('department_display_id_seq','DP')")) @db.VarChar(10) 4 isActive Boolean @default(true) 5 createdAt DateTime @default(now()) @db.Timestamptz 6 updatedAt DateTime @updatedAt @db.Timestamptz 7 deletedAt DateTime? @db.Timestamptz 8 9 // ログイン用コード(人間入力・推測困難な固定文字列) 10 code String @unique 11 12 branchId String 13 name String 14 phone String? 15 remarks String? 16 17 // リレーション 18 branch Branch @relation(fields: [branchId], references: [id], onDelete: Restrict) 19 contacts Contact[] 20 subscriptions Subscription[] 21 users User[] 22 // ↓追加 23 departmentRoles DepartmentRole[] 24 25 /// 逆リレーション: 部署に紐づく許可ドメイン一覧 26 allowedDomains AllowedEmailDomain[] 27 /// 逆リレーション: 部署配下ユーザーのメール変更申請 28 emailChangeRequests EmailChangeRequest[] 29 30 @@index([branchId]) 31 @@index([isActive]) 32 @@index([createdAt]) 33}

Roleモデルの修正

Roleも DepartmentRole に参照されるため、逆リレーションを追加します。
prisma
1model Role { 2 id String @id @default(uuid()) 3 displayId String @unique @default(dbgenerated("generate_display_id('role_display_id_seq','RL')")) @db.VarChar(10) 4 isActive Boolean @default(true) 5 createdAt DateTime @default(now()) @db.Timestamptz 6 updatedAt DateTime @updatedAt @db.Timestamptz 7 deletedAt DateTime? @db.Timestamptz 8 9 code String @unique // 例: ADMIN / EDITOR / VIEWER ... 10 name String 11 priority Int 12 badgeColor String? 13 isSystem Boolean @default(false) 14 canDownloadData Boolean 15 canEditData Boolean 16 remarks String? 17 18 users User[] 19 // ↓追加 20 departmentRoles DepartmentRole[] 21 22 @@index([priority]) 23 @@index([isActive]) 24}

マイグレーション実行手順

ここまで定義したら、マイグレーションを生成・修正・適用します。
zsh
1# マイグレーションファイルの生成 2npx prisma migrate dev --create-only --name add_department_role
このコマンドで prisma/migrations/xxxx_add_department_role/migration.sql が生成されます。
生成されたSQLには、以下の制約を手動で追加します。

マイグレーションSQLの修正例

追記が必要なのは下記の4箇所になります。(追記)と記載したSQL文を追加します。
sql
1-- (追記)先頭に追加、displayId用シーケンスを先に作成 2CREATE SEQUENCE IF NOT EXISTS "public".department_role_display_id_seq; 3 4-- 省略 5 6-- AlterTable 7ALTER TABLE "public"."User" ADD COLUMN "departmentRoleId" TEXT, 8ALTER COLUMN "displayId" SET DEFAULT generate_display_id('user_display_id_seq','US'); 9 10-- (追記)User の XOR制約(roleId と departmentRoleId の同時指定禁止) 11ALTER TABLE "public"."User" 12 ADD CONSTRAINT user_role_xor CHECK ( 13 ("roleId" IS NOT NULL) <> ("departmentRoleId" IS NOT NULL) 14 ); 15 16-- CreateTable 17CREATE TABLE "public"."DepartmentRole" ( 18 "id" TEXT NOT NULL, 19 "displayId" VARCHAR(10) NOT NULL DEFAULT generate_display_id('department_role_display_id_seq','DR'), 20 "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, 21 "updatedAt" TIMESTAMPTZ NOT NULL, 22 "departmentId" TEXT NOT NULL, 23 "roleId" TEXT, 24 "nameOverride" TEXT, 25 "badgeColorOverride" TEXT, 26 "isEnabled" BOOLEAN NOT NULL DEFAULT true, 27 "code" VARCHAR(50), 28 "name" TEXT, 29 "priority" INTEGER, 30 "badgeColor" TEXT, 31 "canDownloadData" BOOLEAN, 32 "canEditData" BOOLEAN, 33 "isSystem" BOOLEAN, 34 "remarks" TEXT, 35 36 CONSTRAINT "DepartmentRole_pkey" PRIMARY KEY ("id") 37); 38 39-- (追記)DepartmentRole の priority 制約 40ALTER TABLE "public"."DepartmentRole" 41 ADD CONSTRAINT department_role_priority_cap CHECK ( 42 "priority" IS NULL OR "priority" <= 99 43 ); 44 45-- (追記)DepartmentRole の override/custom XOR制約 46ALTER TABLE "public"."DepartmentRole" 47 ADD CONSTRAINT department_role_mode_check CHECK ( 48 ( 49 "roleId" IS NOT NULL AND 50 "code" IS NULL AND "name" IS NULL AND "priority" IS NULL AND 51 "badgeColor" IS NULL AND "canDownloadData" IS NULL AND "canEditData" IS NULL AND "isSystem" IS NULL 52 ) 53 OR 54 ( 55 "roleId" IS NULL AND 56 "code" IS NOT NULL AND "name" IS NOT NULL AND "priority" IS NOT NULL 57 ) 58 ); 59 60-- 省略
この修正で、DepartmentRoleのモードが常に明示的になり、UserがグローバルRoleか部署Roleのどちらか一方を持つことを保証できます。

マイグレーション適用と検証

修正が終わったら、マイグレーションを適用し、型を再生成します。
zsh
1# マイグレーション適用 2npx prisma migrate dev 3 4# Prisma Client 再生成 5npx prisma generate
適用後は、Prisma Clientから prisma.departmentRole.findMany()
prisma.user.findMany({ include: { departmentRole: true } }) を実行して整合性を確認します。
既存ユーザーは引き続き roleId を参照しているため、移行の影響はありません。

章のまとめ

  • DepartmentRoleモデルを追加し、override/custom両対応を表現
  • Userに departmentRoleId を追加し、RoleとのXORを設計
  • Department と Role に逆リレーションを追加し、クエリをシンプル化
  • マイグレーション生成後に priority制約・override/custom XOR・UserのXOR をSQLで追記
  • 適用後、既存データを維持したまま新しい構造に移行可能
これにより、スキーマ上もアプリケーション上も安全な設計になります。

4. 実効ロールの合成(effective role)

この章では、 データベースに反映済みのスキーマ を前提に、画面やガードで用いる「実効ロール(effective role)」をどのように合成するかを定義・実装します。グローバル Role と部署単位の DepartmentRole(override/custom)を統一インターフェースで扱えるようにし、以降の Server Action/UI 実装の土台にします。

合成規則の整理

実効ロールは、 「どのIDを参照しているか」「DepartmentRole が override か custom か」 に応じて決定します。表示名やバッジ色の扱いなど、細かな挙動を明示します。
ケースベース上書き(override)prioritycanEdit / canDownload備考
User.roleId のみRoleDepartmentRole があれば name/badgeColorRoleRole見た目だけ差し替え可
User.departmentRoleId (override)Rolename/badgeColorRoleRoleRole値を信頼、表記のみ部署で変更
User.departmentRoleId (custom)DRなしDRDR部署ローカル定義(priority ≤ 99)

意思決定の流れ

下図は、ユーザーの参照先と DR のモードに応じて、どの値を採用するかのフローです。
txt
1[入力] departmentId, user.roleId?, user.departmentRoleId? 23 ├─ departmentRoleId がある? 4 │ │ 5 │ ├─ YES → DepartmentRole を取得 6 │ │ ├─ roleId が NULL → custom: 7 │ │ │ 実効 = DR の code/name/priority/badge/can* 8 │ │ └─ roleId が有 → override: 9 │ │ 実効 = Role をベースに name/badgeColor を DR で上書き 10 │ │ 11 │ └─ 戻り値: effective role 1213 └─ NO → roleId を使う 1415 ├─ Role を取得 16 ├─ 同 departmentId の DepartmentRole(override) があれば 17 │ name/badgeColor のみ上書き(あれば) 18 └─ 戻り値: effective role

データ取得の最小戦略

クライアント/ガードでの呼び出し頻度が高いため、 1回の合成で必要十分 なフィールドだけに絞って取得します。
  • Role: code, name, priority, badgeColor, canEditData, canDownloadData
  • DepartmentRole(override): nameOverride, badgeColorOverride, isEnabled
  • DepartmentRole(custom): code, name, priority, badgeColor, canEditData, canDownloadData, isEnabled
isEnabled=false の DR は ログイン中ユーザーに割り当てられていた場合の扱い を運用で決めます(ここでは「実効ロールは返すがUIで警告」方針を前提)。

実装(ユーティリティの作成)

ユーティリティはサーバー専用で実装します。配置例は src/lib/auth/effective-role.ts
戻り値は UI/ガード双方で使いやすい最小セットに揃えます。
ts
1// src/lib/auth/effective-role.ts 2import "server-only"; 3import { prisma } from "@/lib/database"; 4 5export type EffectiveRole = { 6 code: string; // 表示やロギングに使用(customはDR.code、override/roleIdのみはRole.code) 7 name: string; // 表示名(override適用済み) 8 priority: number; // ガード判定に使用 9 badgeColor: string | null; // バッジ表示(override適用済み) 10 canEditData: boolean; // 書き込み系ガード 11 canDownloadData: boolean; // エクスポート系ガード 12 isEnabledInDepartment: boolean; // 部署視点の使用可否(DRが無ければ true) 13 source: "role" | "override" | "custom"; // どの経路で合成されたか 14}; 15 16type Input = 17 | { departmentId: string; roleId: string; departmentRoleId?: undefined } 18 | { departmentId: string; roleId?: undefined; departmentRoleId: string }; 19 20/** 21 * 実効ロール合成 22 * - roleId のみ : Role をベースに、あれば override の name/badge を適用 23 * - departmentRole : custom なら DR 値、override なら Role 値 + name/badge 上書き 24 */ 25export async function getEffectiveRole( 26 input: Input, 27): Promise<EffectiveRole | null> { 28 const { departmentId } = input; 29 30 if (input.departmentRoleId) { 31 const dr = await prisma.departmentRole.findUnique({ 32 where: { id: input.departmentRoleId }, 33 select: { 34 isEnabled: true, 35 // custom 用 36 code: true, 37 name: true, 38 priority: true, 39 badgeColor: true, 40 canDownloadData: true, 41 canEditData: true, 42 // override 用 43 roleId: true, 44 nameOverride: true, 45 badgeColorOverride: true, 46 // 参照 Role 情報 47 role: { 48 select: { 49 code: true, 50 name: true, 51 priority: true, 52 badgeColor: true, 53 canDownloadData: true, 54 canEditData: true, 55 }, 56 }, 57 }, 58 }); 59 60 if (!dr) return null; 61 62 // custom: roleId が NULL 63 if (!dr.roleId) { 64 // priority, can* は DR の値が必須(DB制約により存在) 65 return { 66 code: dr.code ?? "UNKNOWN", 67 name: dr.name ?? "Unnamed", 68 priority: dr.priority ?? 0, 69 badgeColor: dr.badgeColor ?? null, 70 canEditData: dr.canEditData ?? false, 71 canDownloadData: dr.canDownloadData ?? false, 72 isEnabledInDepartment: dr.isEnabled, 73 source: "custom", 74 }; 75 } 76 77 // override: Role をベースに name/badge を上書き 78 const r = dr.role; 79 if (!r) return null; 80 81 return { 82 code: r.code, 83 name: dr.nameOverride ?? r.name, 84 priority: r.priority, 85 badgeColor: dr.badgeColorOverride ?? r.badgeColor ?? null, 86 canEditData: r.canEditData, 87 canDownloadData: r.canDownloadData, 88 isEnabledInDepartment: dr.isEnabled, 89 source: "override", 90 }; 91 } 92 93 // roleId のみ 94 if (input.roleId) { 95 // Role を取得しつつ、同 department の override があれば1件拾う 96 const r = await prisma.role.findUnique({ 97 where: { id: input.roleId }, 98 select: { 99 code: true, 100 name: true, 101 priority: true, 102 badgeColor: true, 103 canDownloadData: true, 104 canEditData: true, 105 departmentRoles: { 106 where: { departmentId, roleId: { not: null } }, 107 take: 1, 108 select: { 109 isEnabled: true, 110 nameOverride: true, 111 badgeColorOverride: true, 112 }, 113 }, 114 }, 115 }); 116 if (!r) return null; 117 118 const ov = r.departmentRoles[0]; // 0 or 1 119 return { 120 code: r.code, 121 name: ov?.nameOverride ?? r.name, 122 priority: r.priority, 123 badgeColor: ov?.badgeColorOverride ?? r.badgeColor ?? null, 124 canEditData: r.canEditData, 125 canDownloadData: r.canDownloadData, 126 isEnabledInDepartment: ov ? ov.isEnabled : true, 127 source: ov ? "override" : "role", 128 }; 129 } 130 131 return null; 132}
上記コードのポイント
  • 型安全:引数 Input をユニオンで定義し、roleIddepartmentRoleId のどちらか必須を型で表現。
  • 最小選択:必要なフィールドのみ select。クエリオーバーを避け、レスポンスを軽く。
  • source フラグ:UIやログで「どの経路で合成されたか」を可視化。運用トラブルシュートに有効。
  • isEnabledInDepartment:無効化された override/custom をUIで注記できるよう保持。

メニュー判定・ガード連携

この節のゴールは、I/Fを一切変えずに「実効ロール(Role+DepartmentRoleの合成結果)」を運用へ反映することです。鍵は src/lib/auth/user-snapshot.tsgetUserSnapshot() の内部で rolePriority 等を“実効値”に置き換える こと。
これにより、既存のsrc/lib/auth/guard.ssr.tsで実施しているガード判定関数である guardHrefOrRedirect() は従来どおり user だけを返し、ページやサイドバーは user.rolePriority をそのまま使う だけで、正しくRBACが効きます。
txt
1# データフロー(変更は user-snapshot の内部実装のみ) 2Cookie → Session復元 → userId 3 → getUserSnapshot(userId) # ここで getEffectiveRole を内部呼び出し 4 → user.roleCode / rolePriority / canEditData / canDownloadData を「実効値」に 56guardHrefOrRedirect(href) は これまで通り { ok, user } を返す 7ページ側 / サイドバー側は user.rolePriority を使って `filterMenuRecordsByPriority` を適用(現状のまま)
ts
1// src/lib/auth/user-snapshot.ts 2import { prisma } from "@/lib/database"; 3import type { AuthUserSnapshot } from "./types"; 4import { getEffectiveRole } from "./effective-role"; 5 6/** 7 * セッションで得られた userId から、Context 用の最小スナップショットを作る。 8 * PII を最小化し、重い JOIN は避け、必要な列だけ select する。 9 */ 10export async function getUserSnapshot( 11 userId: string, 12): Promise<AuthUserSnapshot | null> { 13 const user = await prisma.user.findUnique({ 14 where: { id: userId }, 15 select: { 16 id: true, 17 name: true, 18 email: true, 19 avatar: true, 20 departmentId: true, 21 roleId: true, 22 departmentRoleId: true, 23 // 既存I/Fではロール権限はスナップショットに投影するので、 24 // 「DB生のRole値」に依存せず、この後に「実効ロール」で上書きする。 25 }, 26 }); 27 28 if (!user) return null; 29 30 // 実効ロールを“内部で”合成(スナップショットI/Fには必要な値だけを反映) 31 const eff = await getEffectiveRole( 32 user.departmentRoleId 33 ? { 34 departmentId: user.departmentId, 35 departmentRoleId: user.departmentRoleId, 36 } 37 : { departmentId: user.departmentId, roleId: user.roleId! }, 38 ); 39 if (!eff) return null; 40 41 return { 42 userId: user.id, 43 name: user.name, 44 email: user.email, 45 avatarUrl: user.avatar ? `/avatar/${user.id}` : null, 46 roleCode: eff.code, // ← 実効ロールの code を適用 47 rolePriority: eff.priority, // ← 実効ロールの priority を適用 48 canEditData: eff.canEditData, // ← 実効ロールの権限フラグを適用 49 canDownloadData: eff.canDownloadData, // ← 実効ロールの権限フラグを適用 50 }; 51}
説明
  • 返却I/F(AuthUserSnapshot)は増減なしroleCode / rolePriority / canEditData / canDownloadData実効値を入れるだけ。

テスト観点(最小セット)

ユニットテスト・E2Eの双方で、代表的なケースを押さえます。
ケース入力(user参照)期待される結果
roleId のみRole: ADMINsource=role, name=Role.name, priority=Role.priority
roleId + override 定義ありRole: EDITOR + DR(override)source=override, name/badge=override適用
departmentRoleId (custom)DR(custom)source=custom, priority/can* は DR 値
isEnabled=falseいずれかisEnabledInDepartment=false(UIで警告/制限)
参照崩れ不正IDnull 返却(呼び出し側で 401/403 ハンドリング)

章のまとめ

  • 実効ロールは Role を基点 に、部署側の override/custom を合成して一貫API化。
  • UI・ガードは EffectiveRole のみを意識すればよい設計に収束。
  • sourceisEnabledInDepartment を保持することで、運用時の見える化や段階的移行が容易になる。

5. まとめと次回予告

この前編では、グローバル Role と部署単位 DepartmentRole を組み合わせる仕組みを設計し、DBスキーマ・Prismaモデル・マイグレーション、さらに「実効ロール(effective role)」の合成処理までを実装しました。これにより、ユーザーは roleId または departmentRoleId を持ちつつ、画面やガードからは常に 統一された rolePriority / 権限フラグ を利用できるようになりました。

本記事で押さえたポイント

項目内容
DB設計DepartmentRole テーブルを新設し、override/custom の両モードを定義
PrismaモデルUser/Department/Role にリレーションを追加し、整合性を保証
マイグレーションpriority 制約や XOR 制約を SQL レベルで補完
実効ロールの合成Role と DepartmentRole を統合し、UI/ガードから統一的に参照可能にした
スナップショット統合getUserSnapshot() 内で実効ロールを反映し、I/F変更なしで利用可能化

この設計のメリット

  • UI側は単純化
    ページやサイドバーは、従来通り user.rolePriority を参照するだけで RBAC が効く。
  • 既存コードの影響最小化
    guardHrefOrRedirect() の戻り値や AuthUserSnapshot の型は変更なし。
  • 拡張性の確保
    今後部署単位のロール追加や名称変更があっても、DBに投入するだけで即時反映可能。

次回(後編)の内容

次回は Server Action と UI での実装 に進みます。実効ロールの仕組みを活用して、部署ごとのロールを実際に管理できる画面とAPIを作り込みます。
項目内容
Server ActionDepartmentRole の作成・更新・削除、override/custom のバリデーション
管理UI/masters/roles ページに DepartmentRole 一覧と編集フォームを追加
ガード処理Server Action 側で priority / 権限チェックを行い、安全な操作を保証
後編を終えると、「部署単位で調整可能な RBAC」 が管理画面に実装され、現場の柔軟な運用に耐える仕組みが完成します。

参考文献

本記事の設計・実装を進めるにあたり、以下の公式ドキュメントや参考資料を参照しました。
この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。