権限まわりの事故は「仕様の隙間」から起きる
ログイン機能があるシステムでも、権限設計が弱いと簡単に事故が起きます。
たとえば、一般ユーザーがURLのIDを変えるだけで他人の請求書を見られる。サポート担当者が全ユーザーの個人情報を更新できる。退職者のアカウントに管理者権限が残っている。どれも派手な攻撃コードがなくても起こりうる、現場で怖いタイプの不具合です。
権限設計は「管理画面を作るときに考えるもの」ではありません。データモデル、API、画面、運用フローにまたがる、プロダクトの安全性そのものです。
認証と認可を分けて考える
まず、認証と認可を分けます。
| 概念 | 問い | 例 |
|---|---|---|
| 認証 | あなたは誰ですか? | ログイン、SSO、MFA |
| 認可 | あなたは何をしてよいですか? | 自分の請求書だけ閲覧できる、管理者だけ削除できる |
認証が成功したユーザーでも、すべての操作を許可してよいわけではありません。ここを曖昧にすると、「ログイン済みならOK」という粗い条件が増え、あとから修正しにくくなります。
権限設計では、次の3点を明文化します。
- 誰が: ユーザー、ロール、部署、契約状態
- 何を: 閲覧、作成、更新、削除、承認、エクスポート
- 何に対して: 注文、請求書、ユーザープロフィール、管理設定
この3点が曖昧なまま実装すると、レビューでもテストでも漏れやすくなります。
よくある失敗パターン
UIでボタンを隠しただけで安心している
{user.role === "admin" && <button>ユーザーを削除</button>}
このようなUI制御はUXとしては必要です。しかし、セキュリティ境界にはなりません。ブラウザの開発者ツールやHTTPクライアントからAPIを直接呼べば、画面にボタンがなくても操作できる可能性があります。
認可チェックは、必ずサーバー側のAPIやユースケース層で行います。
await authorize(currentUser, "delete", {
type: "user",
id: targetUserId,
});
リソースの所有者を確認していない
IDだけでデータを取得する実装は危険です。
// 危険: invoiceId を知っていれば取得できる
const invoice = await db.invoice.findUnique({
where: { id: invoiceId },
});
少なくとも、ログインユーザーとの関係を条件に含めます。
const invoice = await db.invoice.findFirst({
where: {
id: invoiceId,
ownerId: currentUser.id,
},
});
B2B SaaSなら tenantId、社内システムなら departmentId、ワークフローなら status や approverId も判断材料になります。
「管理者」ロールが強すぎる
小さなサービスでは admin と user だけでも回ります。しかし、機能が増えると admin に何でも詰め込みがちです。
- ユーザー管理
- 請求情報の閲覧
- 支払い設定の変更
- 個人情報のエクスポート
- 監査ログの閲覧
これらをすべて1つのロールにまとめると、権限付与の粒度が粗くなります。結果として「本当は閲覧だけでよい人」に強すぎる権限を渡してしまいます。
実装しやすい権限設計の進め方
1. 操作一覧を先に作る
いきなりロール名を考えるのではなく、まず操作を洗い出します。
| リソース | 操作 |
|---|---|
| 請求書 | 閲覧、作成、更新、承認、削除、PDF出力 |
| ユーザー | 招待、権限変更、停止、削除 |
| 監査ログ | 閲覧、検索、エクスポート |
操作を一覧化すると、「削除は誰ができるのか」「エクスポートは閲覧より危険ではないか」といった議論がしやすくなります。
2. ロールにまとめる
次に、操作をロールへ割り当てます。
admin
- user:invite
- user:disable
- invoice:read
- invoice:approve
accounting
- invoice:read
- invoice:create
- invoice:approve
support
- user:read
- audit_log:read
ここで大事なのは、ロールを「役職名」ではなく「システム上の責務」で考えることです。現実の役職は会社によって変わりますが、システム上の操作は比較的安定しています。
3. 例外条件を属性で表す
ロールだけでは表現できない条件は、属性で補います。
- 自分が作成したデータだけ編集できる
- 所属部署のデータだけ閲覧できる
- 承認待ち状態の請求書だけ承認できる
- 100万円以上は部長以上の承認が必要
このような条件をコードのあちこちに散らすと危険です。authorize() やポリシークラスに集約し、「どこを見れば権限判断が分かるか」を明確にします。
レビューで見るべきポイント
権限まわりのレビューでは、正常系だけを見ても足りません。むしろ、拒否されるべきケースを重点的に見ます。
□ 未ログインユーザーは拒否されるか
□ ログイン済みだが権限がないユーザーは拒否されるか
□ 他人のIDを指定したとき拒否されるか
□ 別テナントのデータを指定したとき拒否されるか
□ 削除・承認・エクスポートなど高リスク操作は追加条件があるか
□ 権限変更や重要操作の監査ログが残るか
テストも同じです。「できること」だけでなく「できてはいけないこと」をテストケースに入れます。
まとめ
権限設計は、あとから足すほど難しくなります。画面、API、データモデル、運用が絡むため、仕様が固まった後に直そうとすると影響範囲が大きくなりがちです。
押さえるべきポイントは3つです。
- 認証と認可を分け、
誰が・何を・何に対してを明文化する - UIではなくサーバー側で認可チェックする
- デフォルト拒否、最小権限、監査ログを前提に設計する
権限まわりは、動いているように見える実装ほど危険です。仕様書、API設計、レビュー、テストのすべてで「拒否されるべき操作」を扱えるようにしておきましょう。
実装レベルの整理は認可と権限設計、認証方式の基礎は認証・認可の仕組みで学べます。API設計とあわせて考える場合はAPI設計で押さえるべき原則も参考になります。