![[管理画面フォーマット開発編 #8 前編] 部署別ロール ─ DepartmentRoleテーブル導入とDB設計](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-department-role-db%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット開発編 #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 / Lib | Version | Purpose |
---|---|---|
React | 19.x | UIの土台。コンポーネント/フックで状態と表示を組み立てる |
Next.js | 15.x | フルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理 |
TypeScript | 5.x | 型安全・補完・リファクタリング |
shadcn/ui | latest | RadixベースのUIキット |
Tailwind CSS | 4.x | ユーティリティファーストCSSで素早くスタイリング |
Zod | 4.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 を追加します。ユーザーは Role か DepartmentRole のどちらか一方を参照できるようにし、整合性は制約で担保します。
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)」です。
フィールド | 型 | 必須 | 用途/説明 |
---|---|---|---|
id | UUID | ✔︎ | 主キー |
displayId | String | ✔︎ | 表示用ID(シーケンス + “DR”) |
departmentId | UUID | ✔︎ | 部署参照 |
roleId | UUID | override用:参照先のグローバルRole | |
nameOverride | String | override用:名称の上書き | |
badgeColorOverride | String | override用:バッジ色の上書き | |
code | String | custom用:部署ローカルのロールコード(部署内一意) | |
name | String | custom用:部署ローカルのロール名 | |
priority | Int | custom用:99以下に制約 | |
canDownloadData | Boolean | custom用:データDL権限 | |
canEditData | Boolean | custom用:データ編集権限 | |
isEnabled | Boolean | ✔︎ | 部署視点の利用可否(初期 true) |
createdAt / updatedAt | DateTime | ✔︎ | タイムスタンプ |
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 の XOR | roleId と departmentRoleId の同時指定禁止(DB制約 + フォーム/SA での二重検証) |
部署整合性 | User.departmentId = DepartmentRole.departmentId をアプリ側で必ず検証 |
マイグレーションの流れ
ゼロダウンタイムを意識し、既存ユーザーの
roleId
を尊重しつつ段階導入します。DepartmentRole
テーブル作成(シーケンス・displayId・一意制約・CHECK制約を設定)User
にdepartmentRoleId
追加(インデックス付与、roleId
との XOR 制約追加)- 既存データはそのまま稼働(全ユーザーは引き続き
roleId
を参照) - 必要な部署から順に override/custom を投入(
isEnabled
やコード規約で運用) - 将来、必要ユーザーのみ
departmentRoleId
へ移行(E2Eを含む移行テストを実施)
章のまとめ
- Role はグローバル共通 、 DepartmentRole は部署単位の上書き/新規ロール を担う。
- User は XOR (Role or DepartmentRole のどちらか一方)。
- custom の priority は 99 以下 、override は 名称・色のみ 上書き。
- 段階導入で既存運用を止めない設計にする。
3. Prismaモデルの更新
前章で整理したDB設計を、Prismaのスキーマファイルに反映します。Prismaでは制約やCHECKの一部を直接表現できないため、Prismaモデルで表現できる範囲はPrismaに任せ、残りはマイグレーションSQLで補完 する方針をとります。
DepartmentRoleモデル
まずは DepartmentRole のモデルを
このモデルは override と custom の両モードを1テーブルで表現します。
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}
これによりユーザーは
両方同時は不可であり、この制約はSQLで追加します。
roleId
か departmentRoleId
のどちらかを参照可能になります。両方同時は不可であり、この制約は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
このコマンドで
生成されたSQLには、以下の制約を手動で追加します。
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) | priority | canEdit / canDownload | 備考 |
---|---|---|---|---|---|
User.roleId のみ | Role | DepartmentRole があれば name/badgeColor | Role | Role | 見た目だけ差し替え可 |
User.departmentRoleId (override) | Role | name/badgeColor | Role | Role | Role値を信頼、表記のみ部署で変更 |
User.departmentRoleId (custom) | DR | なし | DR | DR | 部署ローカル定義(priority ≤ 99) |
意思決定の流れ
下図は、ユーザーの参照先と DR のモードに応じて、どの値を採用するかのフローです。
txt
1[入力] departmentId, user.roleId?, user.departmentRoleId?
2 │
3 ├─ 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
12 │
13 └─ NO → roleId を使う
14 │
15 ├─ 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で警告」方針を前提)。実装(ユーティリティの作成)
ユーティリティはサーバー専用で実装します。配置例は
戻り値は 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
をユニオンで定義し、roleId
とdepartmentRoleId
のどちらか必須を型で表現。 - 最小選択:必要なフィールドのみ
select
。クエリオーバーを避け、レスポンスを軽く。 - source フラグ:UIやログで「どの経路で合成されたか」を可視化。運用トラブルシュートに有効。
- isEnabledInDepartment:無効化された override/custom をUIで注記できるよう保持。
メニュー判定・ガード連携
この節のゴールは、I/Fを一切変えずに「実効ロール(Role+DepartmentRoleの合成結果)」を運用へ反映することです。鍵は
src/lib/auth/user-snapshot.ts
の getUserSnapshot()
の内部で 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 を「実効値」に
5 ↓
6guardHrefOrRedirect(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: ADMIN | source=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で警告/制限) |
参照崩れ | 不正ID | null 返却(呼び出し側で 401/403 ハンドリング) |
章のまとめ
- 実効ロールは Role を基点 に、部署側の override/custom を合成して一貫API化。
- UI・ガードは EffectiveRole のみを意識すればよい設計に収束。
source
とisEnabledInDepartment
を保持することで、運用時の見える化や段階的移行が容易になる。
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 Action | DepartmentRole の作成・更新・削除、override/custom のバリデーション |
管理UI | /masters/roles ページに DepartmentRole 一覧と編集フォームを追加 |
ガード処理 | Server Action 側で priority / 権限チェックを行い、安全な操作を保証 |
後編を終えると、「部署単位で調整可能な RBAC」 が管理画面に実装され、現場の柔軟な運用に耐える仕組みが完成します。
参考文献
本記事の設計・実装を進めるにあたり、以下の公式ドキュメントや参考資料を参照しました。
分類 | リンク |
---|---|
Prisma | Prisma Schema Reference |
Prisma | Relations in Prisma |
PostgreSQL | PostgreSQL: Documentation |
PostgreSQL | PostgreSQL – Constraints |
Next.js | Next.js App Router Documentation |
Next.js | Server-only Modules |
TypeScript | Utility Types |
RBAC | Role-Based Access Control (NIST Standard) |
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット開発編 #7] ユーザ管理UIをDB連携する
ユーザ一覧表示・新規登録・編集フォームをDBと連動させ、ユーザデータを操作できる形へ
2025/9/28公開
![[管理画面フォーマット開発編 #7] ユーザ管理UIをDB連携するのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-users%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #6] RBAC調整 ─ ページ単位のアクセス制御を実装する
これまでメニュー表示に適用していたRBACを、各ページのアクセス制御に拡張
2025/9/23公開
![[管理画面フォーマット開発編 #6] RBAC調整 ─ ページ単位のアクセス制御を実装するのイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-rbac-guard%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #5] ユーザプロフィール更新
プロフィール編集機能を拡張し「アバター削除」「メールアドレス変更新(メールでの本人認証+管理者承認)」「パスワード変更」を実装
2025/9/21公開
![[管理画面フォーマット開発編 #5] ユーザプロフィール更新のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-profile%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #4] Server Actionで実装するアバター画像のアップロードと表示
ユーザープロフィールに欠かせないアバター画像を、安全にアップロード・表示する仕組みを構築
2025/9/16公開
![[管理画面フォーマット開発編 #4] Server Actionで実装するアバター画像のアップロードと表示のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-avatar-upload%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有
ログイン成功直後に取得したユーザー情報をAuthProvider(Client Context)でアプリ全体に配布
2025/9/12公開
![[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-auth-provider%2Fhero-thumbnail.jpg&w=1200&q=75)