MENU

問い合わせ


    【Nuxtで作るPDF管理システム 第5回】権限管理と「認可つきダウンロード」

    業務システムでは「ログインできる人なら誰でも何でもできる」では困ります。閲覧だけの人、ファイルを登録・編集する人、利用者を管理する人と、役割に応じてできる操作を分けるのが権限管理です。今回は、ロール(役割)による権限管理と、ログイン・権限を確認してから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="…" も並べて書くのが一般的です。

    📰 出典:MDN Content-Disposition

    📰 出典: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/不正な形式のID404
    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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (2件)

      目次