業務システムでは「ログインできる人なら誰でも何でもできる」では困ります。閲覧だけの人、ファイルを登録・編集する人、利用者を管理する人と、役割に応じてできる操作を分けるのが権限管理です。今回は、ロール(役割)による権限管理と、ログイン・権限を確認してからPDFを返す「認可つきダウンロード」を実装します。
前回の第4回:ログイン機能を入れるでは、nuxt-auth-utils でID・パスワードのログインを作り、APIをログイン必須にしました。今回はその上に「誰が何をしてよいか」を判定する仕組みを載せます。
「ダウンロードなんて、ファイルの置き場所のURLを画面に出すだけでは?」
結論から言うと、PDFは公開フォルダに置かず、必ず「ログインと権限を確認するAPI」を通して返すのが原則です。置き場所のURLを直接出すと、そのURLを知っている人なら誰でも(退職者や社外の人でも)開けてしまいます。この回では、認証(=あなたは誰か)と認可(=あなたはそれをしてよいか)を分けて考え、Nuxt 4 のサーバーAPIで実装します。
権限管理の設計:3つのロールと判定の場所
ロールとできること
| 操作 | 閲覧のみ(viewer) | 編集可(editor) | 管理者(admin) |
|---|---|---|---|
| 一覧・検索・ダウンロード | ○ | ○ | ○ |
| アップロード | × | ○ | ○ |
| ファイルの削除 | × | 自分が登録したものだけ | すべて |
| 利用者の追加・ロール変更・利用停止 | × | × | ○ |
第7回以降の編集機能(結合・分割、注釈など)も、editor 以上に許可する想定です。
判定はすべてサーバーで、ロールは毎回DBから読む
- 画面の出し分けは使い勝手のため:権限のないボタンを隠すのは親切ですが、防御にはなりません。APIを直接呼ばれても断れるよう、判定はサーバーで行います。
- ロールはCookieではなくDBの値で判定する:第4回のセッション(暗号化Cookie)にロールを入れて判定すると、管理者がロールを下げても、その人が再ログインするまで古い権限が使えてしまいます。リクエストのたびにDBから利用者を読み直すことで、変更がすぐに反映されます。
- 利用停止にしたら、ログイン中でも締め出す:第4回で「ログアウト後も古いCookieの値は有効期限まで使える」と書きました。DBの「利用中かどうか」を毎回確認することで、利用停止にした時点でAPIが使えなくなります。
📰 出典:OWASP Authorization Cheat Sheet
ロールを定義し、利用者テーブルに列を追加する
ロールの一覧と判定関数は、画面とサーバーの両方で使うため shared/utils/ に置きます。Nuxt 4 では、shared/utils/ 直下のファイルは画面側・サーバー側の両方で自動インポートされます。
📰 出典:Nuxt 公式ドキュメント shared ディレクトリ
shared/utils/roles.ts
export const ROLES = ['viewer', 'editor', 'admin'] as const
export type Role = (typeof ROLES)[number]
/** 画面表示用のロール名 */
export const ROLE_LABELS: Record<Role, string> = {
viewer: '閲覧のみ',
editor: '編集可',
admin: '管理者',
}
const ROLE_LEVEL: Record<Role, number> = { viewer: 1, editor: 2, admin: 3 }
/** role が required 以上の権限を持つか(admin は editor の操作もできる) */
export function hasRole(role: Role, required: Role): boolean {
return ROLE_LEVEL[role] >= ROLE_LEVEL[required]
}
server/db/schema.ts(users に追加した列)
/** ロール(viewer / editor / admin)。shared/utils/roles.ts の ROLES と同じ値 */
role: text('role', { enum: ROLES }).notNull().default('viewer'),
/** false にするとログイン中のセッションも含めて利用できなくなる(退職・異動時など) */
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
マイグレーションを生成したあと、第4回で作った最初の利用者を管理者にする1文を手で追記しました。新しい列の既定値は「閲覧のみ」なので、そのままでは管理者が誰もいなくなるためです。
server/db/migrations/0003_roles.sql
ALTER TABLE `users` ADD `role` text DEFAULT 'viewer' NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD `is_active` integer DEFAULT true NOT NULL;--> statement-breakpoint
-- 第4回で作った最初の利用者を管理者にする(手動で追記)
UPDATE `users` SET `role` = 'admin' WHERE `id` = (SELECT `id` FROM `users` ORDER BY `created_at`, `id` LIMIT 1);
新しく環境を作る場合は、起動時に作る最初の利用者(server/plugins/initial-admin.ts)に role: 'admin' を指定するよう変更しています。
サーバー側の認可ヘルパーを作る
server/utils/auth.ts(抜粋)
/**
* ログイン中の利用者を、セッションのIDをもとに「DBから」取得する。
* - 未ログイン → 401
* - 利用者が削除・無効化されている → セッションを消して 401
*/
export async function requireCurrentUser(event: H3Event): Promise<CurrentUser> {
if (event.context.currentUser) return event.context.currentUser
const session = await requireUserSession(event, { message: 'ログインしてください' })
const row = useDb()
.select({
id: users.id,
loginId: users.loginId,
displayName: users.displayName,
role: users.role,
isActive: users.isActive,
})
.from(users)
.where(eq(users.id, session.user.id))
.get()
if (!row || !row.isActive) {
await clearUserSession(event)
throw createError({ statusCode: 401, statusMessage: 'ログインしてください' })
}
const { isActive: _, ...user } = row
event.context.currentUser = user
return user
}
/** required 以上のロールを持つ利用者だけを通す(足りなければ 403) */
export async function requireRole(event: H3Event, required: Role): Promise<CurrentUser> {
const user = await requireCurrentUser(event)
if (!hasRole(user.role, required)) {
throw createError({ statusCode: 403, statusMessage: 'この操作を行う権限がありません' })
}
return user
}
/** ファイルを削除できるか:管理者はすべて、編集者は自分が登録したものだけ */
export function canDeleteFile(user: CurrentUser, file: { uploadedBy: string | null }): boolean {
if (user.role === 'admin') return true
return user.role === 'editor' && file.uploadedBy === user.id
}
- 401 は「ログインしていない(誰か分からない)」、403 は「誰かは分かったが、その操作は許可されていない」です。
- 読み込んだ利用者は
event.context(=1回のリクエストの間だけ使える入れ物)に入れ、同じリクエスト内でDBを何度も読まないようにしています。 - 第4回の
server/middleware/auth.tsもrequireCurrentUserを呼ぶように変えました。これで、利用停止された人はすべてのAPIで 401 になります。
各APIの先頭では、必要なロールを1行で宣言します。
// server/api/files.get.ts(一覧)
const user = await requireRole(event, 'viewer')
// server/api/files.post.ts(アップロード)
const user = await requireRole(event, 'editor')
// server/api/users.post.ts(利用者の追加)
await requireRole(event, 'admin')
一覧APIは各行に canDelete(この利用者が削除できるか)を付けて返すようにしました。画面は削除ボタンを出すかどうかを自分で判定せず、サーバーの判定結果に従います。
画面のロール表示もDBの値にそろえる
画面側の useUserSession() が読むセッション情報(/api/_auth/session)にも、DBの最新のロールを反映させます。nuxt-auth-utils には、セッションを返す直前に処理を差し込める sessionHooks があります。
server/plugins/session.ts
export default defineNitroPlugin(() => {
sessionHooks.hook('fetch', async (session, event) => {
const user = await requireCurrentUser(event)
session.user = { id: user.id, loginId: user.loginId, displayName: user.displayName, role: user.role }
})
})
📰 出典:nuxt-auth-utils(GitHub・README Extend Session)
利用停止された人の場合はここで 401 になり、画面側では「未ログイン」として扱われてログイン画面へ移動します。
認可つきダウンロードAPI
server/api/files/[id]/download.get.ts
import { eq } from 'drizzle-orm'
import { files } from '../../../db/schema'
/** PDFのダウンロード:ログイン中の利用者(viewer 以上)だけに、ストリームで返す */
export default defineEventHandler(async (event) => {
// 1. 認可(全ロールが閲覧可。ロールを絞るならここを変える)
await requireRole(event, 'viewer')
// 2. ファイルを探す(IDの形式違い・存在しないIDはどちらも 404)
const id = await getFileIdParam(event)
const file = useDb().select().from(files).where(eq(files.id, id)).get()
if (!file) {
throw createError({ statusCode: 404, statusMessage: 'ファイルが見つかりません' })
}
// 3. ストレージから読み出す。本体が無ければ 404(DBとストレージの不整合)
let stream
try {
stream = await useFileStorage().getStream(file.storageKey)
} catch {
throw createError({ statusCode: 404, statusMessage: 'ファイルが見つかりません' })
}
// 4. ヘッダーを付けて返す。inline=1 ならブラウザ内で表示、既定は保存
const inline = getQuery(event).inline === '1'
setResponseHeaders(event, {
'Content-Type': 'application/pdf',
'Content-Length': String(file.size),
'Content-Disposition': contentDisposition(file.originalName, inline ? 'inline' : 'attachment'),
'X-Content-Type-Options': 'nosniff',
// 認証が必要な内容なので、共有キャッシュ(プロキシ等)に保存させない
'Cache-Control': 'private, no-store',
})
return sendStream(event, stream)
})
- IDは zod で UUID 形式か確認してからDBを引きます(
getFileIdParam。形式違いも「見つかりません」として 404)。保存先のパスはDBのstorageKeyから作り、URLの値をそのままファイルパスに使いません。 - ストリームで返す:
sendStreamは、ファイルをメモリに全部読み込まずに少しずつ送る h3 の関数です。数十MBのPDFでもサーバーのメモリを圧迫しにくくなります。 - 本体が消えていたら 404:ローカル保存の
getStreamを「先にファイルを開いてからストリームを作る」形に変え、存在しない場合はここで例外になるようにしました。
📰 出典:h3 公式ドキュメント Response utils(sendStream)
日本語のファイル名のままダウンロードさせる
server/utils/content-disposition.ts
export function contentDisposition(fileName: string, type: 'attachment' | 'inline' = 'attachment'): string {
// eslint-disable-next-line no-control-regex
const fallback = fileName.replace(/[^\x20-\x7e]|["\\]/g, '_')
// encodeURIComponent がエンコードしない ' ( ) * もエンコードする
const encoded = encodeURIComponent(fileName).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`
}
HTTPのヘッダーには日本語をそのまま書けないため、filename*=UTF-8''… の形でURLエンコードしたファイル名を渡します。古いクライアント向けに、ASCII文字だけの filename="…" も並べて書くのが一般的です。
📰 出典:RFC 6266(HTTPにおけるContent-Dispositionの使用)
削除API:ロールと「所有者」の両方で判定する
server/api/files/[id].delete.ts
export default defineEventHandler(async (event) => {
const user = await requireRole(event, 'editor')
const id = await getFileIdParam(event)
const db = useDb()
const file = db.select().from(files).where(eq(files.id, id)).get()
if (!file) {
throw createError({ statusCode: 404, statusMessage: 'ファイルが見つかりません' })
}
if (!canDeleteFile(user, file)) {
throw createError({ statusCode: 403, statusMessage: 'このファイルを削除する権限がありません' })
}
// DBの行を消してから本体を消す(file_tags は外部キーの cascade で消える)
db.delete(files).where(eq(files.id, id)).run()
await useFileStorage().delete(file.storageKey)
setResponseStatus(event, 204)
return null
})
「editor 以上か」というロールの確認だけでなく、「そのファイルを登録したのは本人か」というデータ単位の確認もしています。ロールだけで判定すると、編集者同士で他人のファイルを消せてしまいます。
利用者管理(管理者のみ)
管理者が利用者を追加・変更するAPIと画面も用意しました(全体はサンプルコードを参照してください)。
| API | 内容 |
|---|---|
GET /api/users | 利用者の一覧(パスワードのハッシュ値は返さない) |
POST /api/users | 利用者の追加。初期パスワードは12文字以上、ログインIDの重複は 409 |
PATCH /api/users/[id] | ロールの変更、利用停止・再開。自分自身は変更できない(管理者不在を防ぐ) |
画面側は、ページごとに必要なロールを definePageMeta で宣言し、第4回のルートミドルウェアで確認します。
app/middleware/auth.global.ts(追加部分)
// ページごとに必要なロール(definePageMeta({ requiredRole: 'admin' }) など)
const required = to.meta.requiredRole
if (required && user.value && !hasRole(user.value.role, required)) {
throw createError({ statusCode: 403, statusMessage: 'このページを表示する権限がありません' })
}
<!-- app/pages/admin/users.vue(冒頭) -->
<script setup lang="ts">
definePageMeta({ requiredRole: 'admin' })
</script>
requiredRole を型として使えるよう、shared/types/page-meta.d.ts で PageMeta を拡張しています。一覧画面には「ダウンロード」リンク(通常の <a href> なのでCookieが送られます)と、canDelete が true の行だけに「削除」ボタンを付けました。
動作確認の方法
npm run db:migrate # 0003_roles.sql(role / is_active 列、最初の利用者を管理者に)
npm run build
node .output/server/index.mjs
筆者の環境で、第4回のDBをマイグレーションしたうえで、管理者・閲覧のみ(viewer1)・編集可(editor1、editor2)の4人で curl により確認しました。
| 操作 | 結果 |
|---|---|
| マイグレーション後の最初の利用者 | role = admin |
| 管理者が利用者を追加/同じIDで再追加/短いパスワード | 201/409/400 |
| viewer・editor が利用者一覧・追加APIを呼ぶ | 403 |
| viewer がアップロード | 403 |
| editor1 がアップロード → 一覧 | 201。自分のファイルだけ canDelete: true |
| Cookieなしでダウンロード | 401 |
| viewer がダウンロード | 200。元ファイルと一致、日本語名は filename*=UTF-8''…、Cache-Control: private, no-store |
| ランダムなUUID/不正な形式のID | 404 |
| viewer が削除/editor2 が editor1 のファイルを削除 | 403/403 |
| editor1 が自分のファイルを削除 | 204。保存先の本体も消え、以後のダウンロードは 404 |
| 管理者が他人のファイルを削除 | 204 |
| DBにあるが本体が無いファイルをダウンロード | 404 |
| 管理者が自分自身のロールを変更 | 400 |
| editor1 を viewer に変更 → 同じCookieでアップロード | 403(再ログインなしで即反映) |
| viewer1 を利用停止 → 同じCookieで一覧/再ログイン | 401/401 |
viewer が /admin/users・/files/upload を開く | 403 |
画面の表示(viewer にはアップロードのリンクが出ない、管理者には「利用者管理」と削除ボタンが出る)は、サーバー描画のHTMLで確認しました。ブラウザでのボタン操作(削除の確認ダイアログ、ロールの切り替え)とダウンロードしたファイル名の表示は、筆者の環境では実施していません。
つまずきやすい点・セキュリティ上の注意
public/にPDFを置かない:public/のファイルは誰でもURLで取得できます。保存先は必ずAPIの外(今回はuploads/)にし、APIを通して返します。- 「IDが推測できないから安全」ではない:保存名もIDもUUIDで推測は困難ですが、URLは共有・転送されます。推測されにくさと認可チェックは別物で、両方必要です。
- 404 と 403 の使い分け:「存在しない」と「権限がない」を区別すると、存在確認の手がかりになる場合があります。今回は全ロールが全ファイルを閲覧できる前提のため区別していますが、部署ごとに見えるファイルを分けるなら、見えないファイルは 404 で返す設計も検討します。
- 削除は取り消せない:今回は即時削除です。誤削除に備えるなら「削除フラグを立てて一定期間後に消す(論理削除)」やバックアップとの組み合わせを検討します。
- 利用停止しても、ダウンロード済みのPDFは回収できない:権限管理が守れるのはシステムの中だけです。
発注者向けメモ:「誰が何をできるか」は表で決めておく
権限管理の工数は、ロールの数よりも「判定の単位」で大きく変わります。
| 判定の単位 | 例 | 工数・リスクの勘所 |
|---|---|---|
| ロール単位(今回) | 閲覧のみ/編集可/管理者 | 比較的シンプル。画面とAPIで同じ表を使えばよい |
| 所有者単位(今回の削除) | 自分が登録したものだけ消せる | データごとの確認が必要。一覧・詳細・操作のすべてで漏れなく判定する必要がある |
| 部署・案件単位 | 営業部のファイルは営業部だけが見られる | 組織・案件のマスタ、異動時の付け替え、一覧の絞り込みまで影響し、工数が大きく増える |
要件定義では、上の「ロールとできること」のような表を、発注者側で作っておくと話が早くなります。
- 操作 × 役割の表を作る:行に操作(閲覧・登録・削除・利用者管理など)、列に役割を並べ、○×を埋めます。
- 「見えてはいけないファイル」があるか:部署や案件で閲覧範囲を分ける必要があるなら、早い段階で伝えます。後から追加すると、一覧・検索・ダウンロードのすべてに手が入ります。
- 退職・異動時の手順:誰が、いつ、どの画面でアカウントを止めるかを運用として決めます。
- ダウンロードの記録が必要か:「誰がいつどのファイルを落としたか」の記録(操作ログ)は、監査で求められることがあります。本連載では扱っていないため、必要なら要件に入れます。
打ち合わせでは、次のように聞いてみてください。
- 「ダウンロードのURLを他の人に転送した場合、その人も開けてしまいますか?」
- 「権限の確認は画面だけでなく、APIでも行っていますか?」
- 「利用者を停止したら、ログイン中の人もすぐに使えなくなりますか?」
- 「部署ごとに見えるファイルを分けたくなった場合、どのくらいの改修になりますか?」
- 「誰がどのファイルをダウンロード・削除したかの記録は残りますか?」
まとめと次回予告
第5回では、ロールによる権限管理と、認可つきのダウンロードを実装しました。
- ロールは viewer/editor/admin の3段階。判定はサーバーで、ロールは毎回DBから読む
- 利用停止した利用者は、ログイン中でもすべてのAPIで 401 になる
- 削除はロールに加えて「登録した本人か」も確認する
- PDFは公開フォルダに置かず、認可を通したAPIからストリームで返す。日本語名は
filename*で渡す
次回は「ブラウザでPDFをプレビューする」です。pdfjs-dist を使ってPDFを画面上に描画し、日本語PDFを文字化けさせずに表示する設定(cMap)、ページ送り・拡大縮小を実装します。今回作ったダウンロードAPIを、プレビュー用の読み込み元として使います。
この連載の記事一覧
この記事は連載「Nuxtで作るPDF管理システム」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Nuxtで作るPDF管理システム 第0回】全体像とNuxt 4プロジェクトの土台づくり
- 【Nuxtで作るPDF管理システム 第1回】PDFをアップロードして安全に保存する
- 【Nuxtで作るPDF管理システム 第2回】ファイル情報をDBに持ち、一覧を表示する
- 【Nuxtで作るPDF管理システム 第3回】検索・絞り込み・ページングをつける
- 【Nuxtで作るPDF管理システム 第4回】ログイン機能を入れる
- 【Nuxtで作るPDF管理システム 第5回】権限管理と「認可つきダウンロード」(この記事)
- 【Nuxtで作るPDF管理システム 第6回】ブラウザでPDFをプレビューする
- 【Nuxtで作るPDF管理システム 第7回】PDFの結合と分割
- 【Nuxtで作るPDF管理システム 第8回】注釈・スタンプ・署名画像を貼る
- 【Nuxtで作るPDF管理システム 第9回】PDFフォーム(AcroForm)に入力する
- 【Nuxtで作るPDF管理システム 第10回】見積書・請求書をデータから生成する
- 【Nuxtで作るPDF管理システム 第11回】本番運用へ:S3への切り替えとデプロイ










コメント
コメント一覧 (2件)
[…] 前回の第5回:権限管理と「認可つきダウンロード」では、ロールによる権限管理と、ログイン・権限を確認してからPDFを返すダウンロードAPIを作りました。今回はそのAPIをプレビューの読み込み元として使います。 […]
[…] 権限管理と「認可つきダウンロード」 […]