ここまで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
お問合せ&各種リンク
- お問合せ:GoogleForm
- ホームページ:https://libproc.com
- 運営会社:TodoONada株式会社
- Twitter:https://twitter.com/Todoonada_corp
- Instagram:https://www.instagram.com/todoonada_corp/
- Youtube:https://www.youtube.com/@todoonada_corp/
- Tiktok:https://www.tiktok.com/@todoonada_corp
presented by