Next.js + SupabaseでRLSを利用して安全なアプリを作ろう。

ここまでSupabaseとNext.jsを使って『認証機能の実装』や『ストレージ内画像のアクセス制御』を行なう方法ついてご紹介してきました。
この2つを使えば、ある程度堅牢なWEBサービスを作ることができますが、あと1箇所、気をつけなければならない箇所があります。

それはデータベースです。
もしユーザーが全てのデータベースにアクセス出来てしまう場合、個人情報や決済情報等が閲覧されてしまう恐れがあります。
更にGDPRやHIPAA等の、プライバシーに関する規制にも抵触してしまうでしょう。

そのような自体を避けるために実装したいのが『RLS(Row Level Security)』というセキュリティ機能です。

目次

RLSとは?

RLSは『Row Level Security』の略です。
『Row』は『行』を意味する通り、データベースのテーブルに対し行ごとにルールを与えて、SELECT/UPDATE/DELETE/INSERTといった操作を制限できる機能です。
今回は、Supabase Authと組み合わせて、社内ツールの想定でRLSを使ってみましょう。

Supabase事前準備

①:Supabaseプロジェクトの作成

Supabaseにログイン(登録していないければアカウント登録から)して、
トップページの「New Project」を押します。

すると、下記の様な画面が表示されます。


適当なプロジェクト名とデータベースのパスワードを設定し、新しいプロジェクトを作成しましょう。
※Regionはできれば自分の住んでいる地域の近くがいいです。私は日本にしました。

②:テーブル作成

まずはテーブル作成を行います。
Supabaseのダッシュボードから`SQL Editor`を開き、`New query`を押します。
右の入力欄に下記の内容を入力しましょう。

create table teams (
  id serial primary key,
  name text
);

create table members (
  id serial primary key,
  team_id bigint references teams,
  user_id uuid references auth.users,
  user_name text
);

create table memberinfo (
  id serial primary key,
  user_id uuid references auth.users,
  user_name text,
  user_birthday timestamp,
  user_hobby text
);

これで右下のRunを押すと今回利用するテーブル3つが作成されます。
`Table Editor`を見て下記の様にテーブルが並んでいれば成功です。

③:ユーザ作成

`Authentication`を開き、ユーザを4人作ります。
メールアドレスとパスワードが必要ですが、メールアドレスはテスト用として`example.com`のものを利用します。
『example.com』は例示用に確保されているセカンドレベルドメインなので、うっかりそのまま実装しても事故が起きにくいです。

右上の`Add User`→`Create New User`を押すと、下記の画面が出て来ます。


任意のパスワードで下記のメールアドレスのユーザを作ってください

※ユーザ作成時`Auto Confirm User?`のチェックは外さないでください。

下記のように追加ができていればOKです

④:テーブルデータ作成

ここからテーブルにデータを追加していきます。
まずは`teams`テーブルに下記URL内にある『teams_table.csv』をインポートしてください。
https://github.com/TodoONada/nextjs-supabase-rls/tree/main/sample_data

インポートは`Insert`ボタンの`Import data from CSV`から行うことが出来ます。

同様にmembersとmemberinfoもインポートしたいのですが、こちらに関しては、実際のUUIDを各CSVに適用した上でインポートしてください。(csvは同URLにおいてあります)

ユーザのUUIDは`Authentication`メニューの`User UID`をクリックするとコピー出来ます。

全てのデータのインポート後は添付のような見た目になります。

teams

members

memberinfo

RLSは後ほど指定するのでSupabase側の設定はここまででOKです。

Next.js事前準備

①:認証機能のリポジトリをクローン

今回はこちらの記事で紹介している認証機能(Supabase Auth)を元に実装を進めたいので、
まずはこちらの記事内にあるgithubのリンク から`git clone`してリポジトリを持ってきてください。

②:起動確認

Supabase Authが正しく動くかをまず確認しましょう。

クローンしたプロジェクトの直下に`.env.local`を新規作成し、
内容を下記の通り入力します

# Update these with your Supabase details from your project settings > API
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL={ここにProject URLを入れる}
NEXT_PUBLIC_SUPABASE_ANON_KEY={ここにProject API Anon Keyを入れる}

それぞれここに〜〜入れると書かれている部分に
Supabaseのダッシュボードの`Project Settings`の`API`から必要な情報をコピーします。

コピーが終わったら、

npm install

を実行した上で

npm run dev

を実行します。

下記の画面が表示されるか確認してください。

設定したメールアドレスでLoginを行い、Profileページが表示されたらOKです。

③:データベースの設定

認証機能のリポジトリには、データベースに関する設定が存在しないため追加を行います。
まず、データベースの型情報を保存するためのフォルダを作成します。
更に、プロジェクトのディレクトリ直下へ、`types`ディレクトリを作成します。

次に下記のコマンドでSupabase CLI上でログインします。
(対話的に進めていけばログイン出来ます。)

npx supabase login

その後、Databaseの型生成用のコマンドを実行します。
($PROJECT_IDは、SupabaseのDashboardでプロジェクトを選択した際のURLに表示されている`https://supabase.com/dashboard/project/`以下の文字列です)

npx supabase gen types typescript --project-id "$PROJECT_ID" --schema public > types/supabasetype.ts

これを実行すると、`types`フォルダ内に`supabasetype.ts`が生成されていることが確認出来ます。

utils/supabase/supabase.ts

Databaseにアクセスするため、`supabase.ts`を編集しましょう。

import { createClient } from '@supabase/supabase-js'
import { Database } from '@/types/supabasetype'

export const supabase = createClient<Database>(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);

④:その他実装

メインの部分とは関係ない実装を、先んじて行います。

components/navigation.tsx

ヘッダーに`Members`ページへのリンクを追加したいので編集します。

'use client';
import type { Session } from '@supabase/auth-helpers-nextjs';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import ModalCore from './modalCore';
import { ModalType } from './modal/modalType';
const Navigation = ({ session }: { session: Session | null }) => {
  const pathname = usePathname();
  const router = useRouter();
  if (session === null && pathname?.includes('/profile')) {
    router.push('/');
  }
  return (
    <header>
      <div className="flex items-center justify-between px-4 py-2 bg-white shadow-md">
        <nav className="hidden md:flex space-x-4">
          <div>
            <Link className="text-gray-600 hover:text-blue-600" href="/">
              Home
            </Link>
          </div>
          {session ? (
            <>
              <div>
                <Link
                  className="text-gray-600 hover:text-blue-600"
                  href="/profile"
                >
                  Profile
                </Link>
              </div>
              <div>
                <Link
                  className="text-gray-600 hover:text-blue-600"
                  href="/members"
                >
                  Members
                </Link>
              </div>
            </>
          ) : (
            <>
              <div>
                <ModalCore modalType={ModalType.SignIn}></ModalCore>
              </div>
              <div>
                <ModalCore modalType={ModalType.SignUp}></ModalCore>
              </div>
            </>
          )}
        </nav>
      </div>
    </header>
  )
}

export default Navigation

components/date.tsx

timestamp型のデータを、日本標準時に合わせてフォーマットします。

type Props = {
  timestamp: string
}

export default function DateFormatter({ timestamp }: Props) {
  const date = new Date(timestamp)
  var jstDate = date.toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" })

  return (
    <>
      {jstDate}
    </>
  )
}

下準備はこれでOKです。

主要部分の実装

いよいよRLSのメインとなる、DBからのデータ取得箇所を作っていきます。

app/profile/page.tsx

まずは`Profile`ページを編集し、個々人の情報が表示されるようにします。
ただし、RLSの機能を体感してもらうため、少し適切ではない実装を行います。

"use client"
import DateFormatter from "@/components/date";
import { Database } from "@/types/supabasetype";
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { useEffect, useState } from "react";

/**
 * ログイン後のマイページ
 */
const MyPage = () => {
  const supabase = createClientComponentClient();
  const [info, setInfo] = useState<Database["public"]["Tables"]["memberinfo"]["Row"][]>([])
  useEffect(() => {
    async function getData() {
      const { data: info, error } = await supabase.from("memberinfo").select("*");
      if (error) {
        throw error
      }
      const infoList = []
      for (let index = 0; index < info.length; index++) {
        infoList.push(info[index])
      }
      setInfo(infoList)
    }
    getData();
  }, []);

  return (
    <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pb-16 pt-20 text-center lg:pt-32">
      <h1 className="text-2xl font-bold">
        ユーザ情報
      </h1>
      <ul className="m-auto max-w-sm pt-10 space-y-1">
        {info.map((item, index) => (
          <li className="text-left" key={item.id}>
            <dl className="pt-2 border-b-2">
              <dt className="text-gray-600">
                ユーザ名
              </dt>
              <dd>
                {item.user_name}
              </dd>
            </dl>
            <dl className="pt-2 border-b-2">
              <dt className="text-gray-600">
                誕生日
              </dt>
              <dd>
                <DateFormatter timestamp={item.user_birthday!}></DateFormatter>
              </dd>
            </dl>
            <dl className="pt-2 border-b-2">
              <dt className="text-gray-600">
                趣味
              </dt>
              <dd>
                {item.user_hobby}
              </dd>
            </dl>
          </li>
        ))}
      </ul>
      <div className="pt-10">
        <form action="/auth/logout" method="post">
          <button
            className=" text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
            type="submit"
          >
            ログアウト
          </button>
        </form>
      </div>
    </div>
  )
}


export default MyPage;

下記部分を見ていただくと分かる通り、全データを取得するクエリを使っているため、現状では`memberinfo`テーブルの全てのデータが表示されてしまいます。

const { data: info, error } = await supabase.from("memberinfo").select("*");

app/members/page.tsx

次に、同じ組織のメンバーを表示する画面の実装を行います。


"use client"
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { useEffect, useState } from "react";

/**
 * 同部署の従業員一覧ページ
 */
const Members = () => {
  const supabase = createClientComponentClient();
  const [members, setMembers] = useState<string[]>([])
  useEffect(() => {
    async function getData() {
      const { data: members, error } = await supabase.from("members").select("*");
      if (error) {
        throw error
      }
      const memberList = []
      for (let index = 0; index < members.length; index++) {
        memberList.push(members[index]["user_name"])
      }
      setMembers(memberList)
    }
    getData();
  }, []);

  return (
    <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pb-16 pt-20 text-center lg:pt-32">
      <h1 className="text-2xl font-bold">
        同部署の従業員一覧
      </h1>
      <ul className="m-auto max-w-sm pt-10 space-y-1">
        {members.map((item, index) => (
          <li className="pt-2 text-left border-b-2" key={index}>
            {item}
          </li>
        ))}
      </ul>
      <div className="pt-10">
        <form action="/auth/logout" method="post">
          <button
            className=" text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
            type="submit"
          >
            ログアウト
          </button>
        </form>
      </div>
    </div>
  )
}


export default Members;

こちらも`Profile`ページ同様に全データを取得するクエリを作っているため、現状は全組織のユーザデータが表示されてしまいます。

const { data: members, error } = await supabase.from("members").select("*");

RLSを利用する

この通り、`Profile`ページと`Members`ページで実装をミスしてしまうと、本来表示してはいけない情報が表示されてしまいます。
このような事態は、本番では絶対に防がなければならないアクシデントです。

しかしRLSを利用しているならば、Supabase側でアクセスできるデータを制限することができるため、このような事態も防げます。

RLSの有効化

早速RLSを有効にするため、SQLを実行します。

`SQL Editor`を開き`New query`を押します。
SQL編集画面に下記をコピーして右下の`Run`を押して実行してください。

alter table teams enable row level security;
alter table members enable row level security;
alter table memberinfo enable row level security;

これでRLSが有効化されました。

memberinfoテーブルのRLS設定

まずは`Profile`ページで利用している`memberinfo`テーブルに対してRLSを設定しましょう。

`SQL Editor`を開き`New query`を押します。
下記をコピーして右下の`Run`を実行しましょう。

create policy "Individuals can view their own infos."
on memberinfo for select
using ( auth.uid() = user_id );

問題なく追加できれば`Authentication`メニューの`Policies`が添付画像のような見た目になるはずです。

このRLS設定によりログインしたユーザIDとおなじIDを持つテーブルのみ表示されるようになりました。

using ( auth.uid() = user_id );

実際に確認してみましょう。


Next.jsの実装は変更せず全てのデータを取得するクエリのままですが、ログインしたユーザの情報のみが表示されるように修正されました。

membersテーブルのRLS設定

`memberinfo`テーブルのRLSは簡単に設定出来ましたが、`members`の方はどうでしょうか?
こちらは同じユーザIDではなく、ログインしたユーザと同じ部署に属しているかどうかを確認しなければなりません。
このような複雑な絞り込みが必要な状況に備えて、Supabaseにはデータベースの関数が存在します。
実際に利用してみましょう。

先程と同じように`SQL Editor`を開き、`New query`を押します。
SQL編集画面に下記をコピーし`Run`を実行します。

create function get_teams_for_authenticated_user()
returns setof bigint
language sql
security definer
set search_path = public
stable
as $$
  select team_id
  from members
  where user_id = auth.uid()
$$;

create policy "Team members can select team members if they belong to the team."
on members
for select using (
  team_id in (
    select get_teams_for_authenticated_user()
  )
);

問題なく追加できれば`Authentication`メニューの`Policies`が添付画像のような見た目になるはずです。

こちらのポリシーは関数でチームのIDを取得し、それと同じIDを持つ`members`内のデータのみに絞り込む設定になっています。
これにより同じ組織(同じ`team_id`)のデータのみが表示されるはずです。

複雑なセキュリティ機能をBaaSに任せるメリット

このように複雑なデータベースへのアクセス制限も、Supabaseならば簡単な操作で実装することが出来ます。
もちろん自前のPostgreDBにRLSを実装することも出来ますが、Supabaseを使うのと比べて、そこそこ大きな工数がかかってしまうことでしょう。
セキュリティ部分を任せることで、時間と安心を買うことができる、という点もBaaSを使うメリットです。

なお今回のコードもgithub上で公開しております。下記よりご確認ください。

またこの記事を書くに当たり、参考にしたSupabaseの公式ドキュメントは下記となります。
ぜひこちらもご確認ください。

https://github.com/TodoONada/nextjs-supabase-rls/tree/main/sample_data

お問合せ&各種リンク

presented by

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