Supabaseに入門する
概要
認証基盤としてSupabaseがどの程度使いやすいか?を調査する。
サインアップ、サインイン、GoogleなどのOpenID プロバイダーによる認証をどのように実装していくか調査する。
アカウント作成
GitHubアカウントで登録が出来るので登録する。
プロジェクト作成
next-supabase-examples という検証用プロジェクトを作ったので、とりあえずこれと同じ名前でプロジェクトを作成する。
必要事項を入力、特に難しくはない。
Planはとりあえずフリーを選択。
公式の Supabase Auth with the Next.js App Router を参照しながら進める
以下のガイドに従って進めていく。
npx create-next-app -e with-supabase
とするとプロジェクトの初期構成を作ってくれるようだ。
ただしこの生成方法で生成されたテンプレートにはいくつか問題があったので通常の create-next-app
で生成されたファイルからマージしている。
- ESLintの設定がなかったので追加(本家の
create-next-app
の結果にはある) - パッケージのバージョンが常に latest になっていたりするので、こちらはバージョン固定をした
-
tsconfig.json
のpaths
の設定が消えてしまったいたのでcreate-next-app
の結果と同じ設定に変更
具体的な変更内容は以下のPRにまとめている。
Supabase のアクセストークンをAPI認証として利用出来るか?
ServerComponents内で以下のようにすると、JWT形式のアクセストークンを取得出来た。
const supabase = createServerComponentClient({ cookies })
const token = await supabase.auth.getSession();
console.log(token);
ちなみにpayloadの形は以下のような形。
{
"aud": "authenticated",
"exp": 1689956112,
"iat": 1689952512,
"iss": "https://https://xxxxxxx.supabase.co/auth/v1",
"sub": "UUIDv4形式",
"email": "keita@exmple.com",
"phone": "",
"app_metadata": {
"provider": "email",
"providers": [
"email"
]
},
"user_metadata": {},
"role": "authenticated",
"aal": "aal1",
"amr": [
{
"method": "password",
"timestamp": 1689952512
}
],
"session_id": "UUIDv4形式"
}
このJWTを検証すれば、自作で作ったAPIサーバー等でもこの値を認証・認可に利用出来そう。
Supabaseのアクセストークンを検証
以下は jose を使って検証を実施している例。(実行しているのはAppRouterのServerComponents内)
import { cookies } from 'next/headers';
import * as jose from 'jose';
const supabase = createServerComponentClient({ cookies });
const token = await supabase.auth.getSession();
const secretKey = new TextEncoder().encode(
'https://supabase.com/dashboard/project/{自分のプロジェクトID}/settings/api 内からコピーしたJWT Secretを設定',
);
const jwtVerifyResult = await jose.jwtVerify(String(token.data.session?.access_token), secretKey, {issuer: 'https://https://{自分のプロジェクトID}.supabase.co/auth/v1'});
console.log(jwtVerifyResult);
もちろん自作したAPIサーバー内でも標準的な機能を備えたJWT用ライブラリを使って検証を実施出来る。
例えばPython + LangChain + FastAPIで作成した自作API内でもSupabaseのアクセストークンを利用してアクセス制御を行う事が可能。
Googleログインの実装
以下を見ながら実装していく。
Supabase の以下のURLからGoogleによる認証を有効化する。
project-id
には実際のプロジェクトIDが入る。
https://supabase.com/dashboard/project/{project-id}/auth/providers
管理画面のメニューの Authentication → Providers から遷移する。(UIが変わる可能性はアリ)
OAuthの情報を入力する必要があるので、Google CloudのAPIとサービス → 認証情報から OAuth 2.0 クライアント ID を作成する。(Callback URLは後でGoogle Cloud側で使うのでコピーしておく)
Client IDとClient Secretをコピーして Supabase 側で以下の情報を入力して事前準備完了。
- Client ID (for OAuth)
- Client Secret (for OAuth)
- Authorized Client IDs これは空でOK。
以下は実装時のPR。
最初はReactServerComponents内で supabase.auth.signInWithOAuth
を使ってGoogleログインの実装を試みたが invalid request: both auth code and code verifier should be non-empty
というエラーが発生してしまい上手くいかなかった。
これはissueが報告されており、どうやらNext.js側の問題らしい。
仕方がないのでGoogleログイン用のButtonComponentを作って onClick
時に実行する方法を取った。
ちなみに認証成功時のアクセストークンのpayloadは以下のような感じだ。
emailログインの時よりも情報量が多くpayloadからGoogleアカウントの情報が取得出来るようになっている。
{
"aud": "authenticated",
"exp": 1690123901,
"iat": 1690120301,
"iss": "https://https://xxxxxxx.supabase.co/auth/v1",
"sub": "UUIDv4形式",
"email": "Gmailのメールアドレス",
"phone": "",
"app_metadata": {
"provider": "google",
"providers": [
"google"
]
},
"user_metadata": {
"avatar_url": "アバター画像のURL",
"email": "Gmailのメールアドレス",
"email_verified": true,
"full_name": "ユーザー名のフルネーム",
"iss": "https://accounts.google.com",
"name": "ユーザー名",
"picture": "エンドユーザーの画像URL",
"provider_id": "数値(型は文字列)",
"sub": "数値(型は文字列)"
},
"role": "authenticated",
"aal": "aal1",
"amr": [
{
"method": "oauth",
"timestamp": 1690120301
}
],
"session_id": "UUIDv4形式"
}
ちなみに先にメールアドレスとパスワードで登録して同じGmailのアカウントを使ってGoogleログインを実行すると同じユーザーと見なしてくれる。
逆に先にGoogleログインで登録して後でパスワードとemailで登録しようとすると、重複していると見なされてしまい、登録APIでエラーが発生してしまった。
同一のメールアドレスで登録している他のソーシャルアカウント(例えばFacebookとか)で登録を試みた場合はどうなるのかが気になる。
細かい問題
無料版だとGoogleログイン時の同意画面に自分の supabase
のプロジェクトのDomainが表示されてしまう。
ここを独自Domainにしたい場合は有料プランを契約してCustomDomainsの設定をする必要がある。
Supabaseの良いと思った点
- 安いのに高機能、認証基盤としての機能は十分で認証の履歴等も閲覧可能
- パスワードレス認証(マジックリンクによるログインやSMS認証に対応)に対応している
- ドキュメントが充実している
- RDBが付いているのは嬉しい
Supabaseのちょっと残念なところ
- 対応しているソーシャルログインが少ない + B to B向けの物が多い
ちなみに対応しているソーシャルログインは以下の通り。
今後実装される可能性もあるが現状だとカスタムAuthもないので、LINEログイン等は実装出来ない。
今回は要件としてLINEログインが必須だったので残念ながら不採用とした。
とは言えサービスのポテンシャルはかなり感じたので、今後別の機会にいつか使ったみたい。
Supabase採用に向いているプロダクト開発が始まったので約1年ぶりに調査を再開します。
このアプリケーションは運営側からアカウントを作成してエンドユーザーにサインアップしてもらうタイプなのでまずはそれが出来るかどうかを調査していく。
運営側からアカウントを作成してエンドユーザーにサインアップしてもらう
まずはこれを試してみる。
以下でプロジェクトの雛形を作成する。
npx create-next-app -e with-supabase
以前試した時と比べて結構変わっていた。
@supabase/ssr
というPackageがReactServerComponentsやMiddleware等で利用されている。
ローカルでNext.jsのアプリを起動させた後でSupabaseのプロジェクト管理画面でAuthentication → User の「Add User」→「Send invitation」からユーザーを招待する。
認証待ち状態のユーザーは管理画面からは以下のように表示される。
メールを確認すると以下のようなメールが届いているので「Accept the invite」のリンクにアクセスする。
「Accept the invite」のリンクにアクセスする。URLは以下のように構成されている。
http://localhost:24000/#access_token=アクセストークン&expires_at=1716372194&expires_in=3600&refresh_token=リフレッシュトークン&token_type=bearer&type=invite
この瞬間に認証待ち状態が解除される。
しかしこの方法で追加されたユーザーは認証手段を持たないのでログイン可能な状態にする為にパスワード等を設定する必要がある。
次はこのあたりをどうやって解決するのか調査する。
テスト中に Failed to invite user: failed to make invite request: Email rate limit exceeded
が出るようになった。
SMTP Settingsを実施すればレートリミットを軽減出来そうなので進めていく。
メールのレートリミット軽減に関してはResendを連携させるのが最も簡単そう。
Resendに関しては前回まとめている、Gmailの迷惑メールポリシー強化の影響があるので必ずSPF, DKIM, DMARCの設定を終わらせておく。
以下から「Add integration」を押下して進めていく。
Select an organization to grant API access to
で組織名を間違えないように注意する。
連携完了後は以下に必要項目を入力していく。
Supabaseのプロジェクト管理画面でAuthentication → User の「Add User」→「Send invitation」から招待したユーザーはパスワードが設定されていない。
その為、初期パスワードの設定が必要。
以下のようなパスワード設定フォームを作る事で対応した。
useEffect
は使いたくないがURLが http://localhost:24000/#access_token=アクセストークン&expires_at=1716372194&expires_in=3600&refresh_token=リフレッシュトークン&token_type=bearer&type=invite
な感じで #
に refresh_token
等がくっついているのでクライアント側でしか処理出来ないのでこうしている。
'use client';
import { SubmitButton } from '@/app/login/submit-button';
import { type FormEvent, useEffect, useState } from 'react';
export const NewPasswordForm = () => {
const [refreshToken, setRefreshToken] = useState<string>('');
useEffect(() => {
if (typeof window !== 'undefined' && window.location.hash) {
// Remove the leading #
const urlParams = new URLSearchParams(window.location.hash.substring(1));
const extractedRefreshToken = urlParams.get('refresh_token');
if (extractedRefreshToken != null) {
setRefreshToken(extractedRefreshToken as string);
}
}
}, []);
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const password = new FormData(event.currentTarget).get('password') as string;
if (password !== '' && refreshToken !== '') {
const createPasswordResponse = await fetch('http://localhost:24000/auth/password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken, password }),
});
const createPasswordResponseBody = (await createPasswordResponse.json()) as {
isSuccess: boolean;
user?: {
email: string;
};
errorMessage?: string;
};
// TODO パスワードリセットに失敗した場合のエラーハンドリングを追加する
const email = createPasswordResponseBody.user?.email;
if (email != null && typeof window !== 'undefined') {
const loginResponse = await fetch('http://localhost:24000/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
const responseBody = (await loginResponse.json()) as { loginSuccess: boolean; errorMessage?: string };
if (responseBody.loginSuccess) {
window.location.href = 'http://localhost:24000/protected';
}
// TODO ログイン失敗時のエラーハンドリングを考える
}
}
};
return (
<div className="flex-1 flex flex-col w-full px-8 sm:max-w-md justify-center gap-2">
<form className="animate-in flex-1 flex flex-col w-full justify-center gap-2 text-foreground" onSubmit={onSubmit}>
<label className="text-md" htmlFor="password">
Password
</label>
<input
className="rounded-md px-4 py-2 bg-inherit border mb-6"
type="password"
name="password"
placeholder="••••••••"
required
/>
<SubmitButton
className="bg-green-700 rounded-md px-4 py-2 text-foreground mb-2"
pendingText="パスワードを設定中..."
>
パスワードを設定
</SubmitButton>
</form>
</div>
);
};
POST /auth/password
でパスワードの初期設定、POST /auth/login
でログイン処理を実装してログイン済のページにリダイレクトしている。
POST /auth/password
と POST /auth/login
はそれぞれ以下のように実装されている。
POST /auth/password
import { createClient } from '@/utils/supabase/server';
import { NextResponse } from 'next/server';
export const runtime = 'edge';
type RequestBody = {
refreshToken: string;
password: string;
};
type ResponseBody = {
isSuccess: boolean;
user?: {
email: string;
};
errorMessage?: string;
};
export async function POST(request: Request) {
const requestBody = (await request.json()) as RequestBody;
const supabase = createClient();
await supabase.auth.refreshSession({ refresh_token: requestBody.refreshToken });
const { data, error } = await supabase.auth.updateUser({
password: requestBody.password,
});
const email = data.user?.email;
if (email && error == null) {
const status = 200;
const responseBody = {
isSuccess: true,
user: {
email,
},
} as const satisfies ResponseBody;
return NextResponse.json(responseBody, { status });
}
const status = 500;
const responseBody = {
isSuccess: false,
errorMessage: 'failed to set password',
} as const satisfies ResponseBody;
return NextResponse.json(responseBody, { status });
}
POST /auth/login
import { createClient } from '@/utils/supabase/server';
import { NextResponse } from 'next/server';
export const runtime = 'edge';
type RequestBody = {
email: string;
password: string;
};
type ResponseBody = {
loginSuccess: boolean;
errorMessage?: string;
};
export async function POST(request: Request) {
const requestBody = (await request.json()) as RequestBody;
const supabase = createClient();
const { error } = await supabase.auth.signInWithPassword({
email: requestBody.email,
password: requestBody.password,
});
if (error) {
const status = 500;
const responseBody = {
loginSuccess: false,
errorMessage: 'failed to login',
} as const satisfies ResponseBody;
return NextResponse.json(responseBody, { status });
}
const status = 200;
const responseBody = {
loginSuccess: true,
} as const satisfies ResponseBody;
return NextResponse.json(responseBody, { status });
}
GitHubにも上げているのでソースを貼っておく。
ちなみにSupabaseの管理画面からユーザーを招待した場合認証完了後のリダイレクト先のURLが強制的に /
になってしまう。
直接Supabaseの inviteUserByEmail を利用して redirectTo
に任意のURLを指定する事で戻り先のURLを変える事が出来る。
ちなみに指定するURLはRedirect URLsから事前に登録しておく必要がある。
次に調査する事
以下のやり方が分かれば最低限実践で利用出来るかなと思う。
- Vercelのプレビュー環境でのSupabaseを使った認証が利用出来るようにする
- 複数環境での運用方法(ローカル、ステージング、本番の3環境で運用する場合どのような構成にするか)
- RDBのマイグレーション方法
Magic Link Loginの実装
先ほど作成したのは一般的なパスワード認証によるログインフォームだがパスワードログインはセキュリティ的な問題が多く、パスワード忘れ時のフローも考慮しないといけないので正直メリットが少ない。
そこでMagic Link Loginというパスワードレスのログインを実装する。
メールアドレスに認証URLを送信してエンドユーザーが認証URLにアクセスする事でログイン処理を実現する。
パスワードが存在しないのでパスワード流出による不正ログインは起こらないし、エンドユーザーもパスワードを覚えておく必要がないので開発者、エンドユーザー共にメリットが大きい手法だと思う。
以下のようなフォームを用意して signInWithOtp というメソッドを利用する事で簡単に実現出来た。
以下は実行時のPR。
VercelとSupabaseの連携
Vercelのプレビュー環境でのSupabaseを使った認証が出来ないとかなり不便なのでそれが可能になるように設定を実施する。
まずはVercelへのデプロイを実施する。手順はGitHubのリポジトリを連携するだけの簡単な方法なので省略。
ちなみにSupabase周りの環境変数は後で自動連携されるので、この時点では設定しなくてよい。
以下からSupabase、Vercelの連携を実施していく。
1. Add Integrationを押下
2. Vercelに連携しているGitHub Orgとプロジェクトを連携する
3. Supabaseのorgを選択する
4. SupabaseのプロジェクトとVercelのプロジェクトを選択する
5. Vercel側で環境変数が同期されている事を確認
ちなみに注意点としてこれらの環境変数はVercelのProduction環境でしか読み込む事が出来ない、プレビュー環境やローカル環境で運用する為の方法はこれから調査していく。
Supabaseの認証をVercelのプレビュー環境で実現する
サインアップやMagicLinkのログイン(パスワードレスのログイン)は以下の方法で実現出来る。
1. SupabaseのRedirect URLsにVercelのプレビュー環境を登録しておく
Vercelのプレビュー環境は https://{プロジェクト名やGitのBranch名}-{VercelのOrg名}.vercel.app
のようなURLで構成されているので https://*nekochans.vercel.app/auth/callback
のようにアスタリクスを使った形で登録しておく。
2. Supabaseに渡すURLはVercelのプレビュー環境のドメインを含める
以下のように next/headers
を使う方法で https:://ドメイン名
の部分を取得するようにする。
import { headers } from 'next/headers';
const signIn = async (formData: FormData) => {
'use server';
const email = formData.get('email') as string;
const supabase = createClient();
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${headers().get('origin')}/auth/callback`,
},
});
// 省略
};
もしくは以下のように NEXT_PUBLIC_VERCEL_URL
から取得するような関数を実装するのも良いと思います。
export const appUrl = (): string => {
if (process.env.NEXT_PUBLIC_VERCEL_URL) {
return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`;
}
return 'http://localhost:24000';
};
今回はパスワードレスのメールアドレスを行ったログインの場合は問題はない。
しかしGoogleログイン等の外部の認証プロバイダを利用している場合は結構難しいと思う。
外部の認証プロバイダ側(Google)にもVercelのプレビュー環境のURLをリダイレクトURIをその都度登録する必要があるので運用するのは結構辛いと思う。
開発環境用のSupabaseプロジェクトを作成する
以下でも記載されているようにBranch機能はまだ正式版ではない。
以下のディスカッションを読んでみたが現時点では複数環境で利用する為には複数プロジェクトを作るのが無難。
無料プランでも2つまでSupabaseプロジェクトを作成出来るので検証だけなら無料プランでも可能。
ResendのSMTPサーバー等は本番と同じ物を利用するが、実運用ではこれは分けたほうが無難。
PostgreSQLのMigrationを実行出来るようにする
Supabase CLIのインストール
Prismaを使うのも良いがなるべく安定した仕組みを使いたいので公式の Supabase CLI を利用する。
以下でインストールを実行する。
npm install supabase --save-dev
ローカルでのSupabaseの起動
まずは以下のコマンドを実行して初期化を行う。
./node_modules/.bin/supabase init
するといくつかファイルが生成される、これはGitにコミットしておけば良さそう。
supabase/config.toml
を眺めてみるとSupabaseの設定が色々書いてある事がわかる。
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "supabase-private-platform-example"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` is always included.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request. `public` is always included.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 15
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# 以下省略
以下でローカルのサーバーを起動する。
./node_modules/.bin/supabase start
コンテナが使われているので Docker Desktop 等がインストールされている必要がある。
(OrbStack等で動作するかは試していない)
以下のURLでアクセスが可能。
いつものSupabaseの画面が表示されている。
停止させるには以下のコマンドを実行する。
./node_modules/.bin/supabase stop
ローカル環境を動かす上での注意点
初見で自分が引っかかった点を記載しておく。
SupabaseのURLとANON_KEYについて
ローカルで立ち上がっているSupabaseを参照する為にはこれらの値を環境変数として登録する必要がある。
Next.jsを使っている場合 .env
に NEXT_PUBLIC_SUPABASE_URL
と NEXT_PUBLIC_SUPABASE_ANON_KEY
を設定しておく必要がある。
以下のコマンドを実行する事で確認が可能。
./node_modules/.bin/supabase status
以下のように結果が表示される。
API URL: http://127.0.0.1:54321
GraphQL URL: http://127.0.0.1:54321/graphql/v1
S3 Storage URL: http://127.0.0.1:54321/storage/v1/s3
DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres
Studio URL: http://127.0.0.1:54323
Inbucket URL: http://127.0.0.1:54324
JWT secret: super-secret-jwt-token-with-at-least-32-characters-long
anon key: eeeeeee.yyyyy.xxxxx
service_role key: eeeeeee.zzzzzz.zzzzzz
S3 Secret Key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
S3 Region: local
以下のように設定すればOK。
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eeeeeee.yyyyy.xxxxx
ローカルSupabaseでのメールの受信
ローカルでメールを受信する為には Inbucket
というツールを利用する。
supabase start
を実行した時に Inbucket URL
が表示されているのでここにアクセスする。
http://127.0.0.1:54324/monitor
から確認すると以下のようにメールの本文が確認可能なのでこれでサインアップやMagicLinkのログインがローカルでも実行可能。
supabase/config.toml
に site_url
や additional_redirect_urls
を設定しておく
自分はローカル環境のNext.jsのURLを http://localhost:24000
としていたので、site_url
や additional_redirect_urls
を書き換える必要がある。
# 省略
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:24000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["http://localhost:24000/auth/callback"]
# 省略
なおローカルだとサインアップ時にメールの受信確認をしない設定になっているので、enable_confirmations
を true
に設定しておくと受信確認が出来るようになる。
# 省略
[auth.email]
# 省略
enable_confirmations = true
# 省略
ローカルでMigrationファイルの作成
以下のようにMigrationファイルを作成する。
./node_modules/.bin/supabase migration new create_triggers_functions_refresh_updated_at
./node_modules/.bin/supabase migration new create_table_users
./node_modules/.bin/supabase migration new create_triggers_on_users
作成したのは以下の3つ。
supabase/migrations/20240529151643_create_triggers_functions_refresh_updated_at.sql
CREATE FUNCTION refresh_updated_at_step1() RETURNS trigger AS
$$
BEGIN
IF NEW.updated_at = OLD.updated_at THEN
NEW.updated_at := NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE FUNCTION refresh_updated_at_step2() RETURNS trigger AS
$$
BEGIN
IF NEW.updated_at IS NULL THEN
NEW.updated_at := OLD.updated_at;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE FUNCTION refresh_updated_at_step3() RETURNS trigger AS
$$
BEGIN
IF NEW.updated_at IS NULL THEN
NEW.updated_at := CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
supabase/migrations/20240529151715_create_table_users.sql
CREATE TABLE users(
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
supabase/migrations/20240529151723_create_triggers_on_users.sql
CREATE TRIGGER refresh_users_updated_at_step1
BEFORE UPDATE ON users FOR EACH ROW
EXECUTE PROCEDURE refresh_updated_at_step1();
CREATE TRIGGER refresh_users_updated_at_step2
BEFORE UPDATE OF updated_at ON users FOR EACH ROW
EXECUTE PROCEDURE refresh_updated_at_step2();
CREATE TRIGGER refresh_users_updated_at_step3
BEFORE UPDATE ON users FOR EACH ROW
EXECUTE PROCEDURE refresh_updated_at_step3();
作成したトリガーは updated_at
を更新する為の物。
MySQLと違い updated_at
を自動更新する仕組みが存在しない為、これを追加している。
このあたりの内容に関しては以下の記事を参考にしました。
このトリガー関数を利用する事でMySQLの updated_at
の挙動を再現可能です。
以下の記事がとても参考になりそうなので読んでみる。
リモート環境でのSupabase
本番用、開発用それぞれのSupabaseプロジェクトを作ったので、以下のタイミングでそれぞれMigrationが実行されるように設定していく。
- Vercelのプレビュー環境デプロイ時(GitHubにBranchが作成されコミットが作られたタイミング)で開発用のSupabaseプロジェクトに対してMigrationを実行する
- Vercelの本番環境にデプロイされた時に本番用のSupabaseプロジェクトに対してMigrationを実行する
以下の記事がとても参考になるので読んでみる。
公式がGitHub Actionsを用意しているようなのでそれを使ってみる。
開発用のMigration用workflowの実装
まずはVercelのプレビュー環境デプロイ時に動作するGitHubActionsを作っていく。
以下の3つの環境変数をGitHub上に設定する。
DEV_SUPABASE_ACCESS_TOKEN
DEV_SUPABASE_DB_PASSWORD
DEV_SUPABASE_PROJECT_ID
DEV_SUPABASE_ACCESS_TOKEN
に関しては https://supabase.com/dashboard/account/tokens からAccessTokenを発行すればOK。
以下のようなGitHubActionsのworkflowを追加する。
branches-ignore
に main
を指定しているのは main
へのコミットが走った時はVercelの本番環境へのデプロイが行われる時なので開発環境のSupabaseにMigrationを適応する必要がない為。
name: dev-supabase-db-migrate
on:
workflow_dispatch:
push:
branches-ignore:
- main
jobs:
migrate:
runs-on: ubuntu-22.04
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
SUPABASE_PROJECT_ID: ${{ secrets.STAGING_PROJECT_ID }}
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: 1.169.8
- run: supabase link --project-ref $SUPABASE_PROJECT_ID
- run: supabase db push
ログを見ると以下のように成功している事が確認出来る。
Connecting to remote database...
Do you want to push these migrations to the remote database?
• 20240529151643_create_triggers_functions_refresh_updated_at.sql
• 20240529151715_create_table_users.sql
• 20240529151723_create_triggers_on_users.sql
[Y/n]
Applying migration 20240529151643_create_triggers_functions_refresh_updated_at.sql...
Applying migration 20240529151715_create_table_users.sql...
Applying migration 20240529151723_create_triggers_on_users.sql...
Finished supabase db push.
A new version of Supabase CLI is available: v1.172.2 (currently installed v1.169.8)
We recommend updating regularly for new features and bug fixes: https://supabase.com/docs/guides/cli/getting-started#updating-the-supabase-cli
後は同じように本番用のworkflowを作ればと思ったが、それだとGitHubActionsのほうで本番用Migrationが行われ、Vercelのほうでデプロイが行われる事になる。
そうすると本番用Migrationだけ成功してVercelデプロイが失敗(その逆もありえる)という事が起こってしまう可能性がある。
Migrationが成功した後でVercelデプロイが実施されるようにするにはどうすれば良いか考える必要がある。
結論
Vercelのビルドステップ上でMigrationの実行も一緒にするようにした。
最初に package.json
に以下の npm scriptを作成する。
"supabase:login": "supabase login --token $SUPABASE_ACCESS_TOKEN",
"supabase:link": "supabase link --project-ref $SUPABASE_PROJECT_ID",
"supabase:db:push": "supabase db push",
"vercel:build": "npm run supabase:login && npm run supabase:link && npm run build && npm run supabase:db:push"
以下のようにVercelのビルド設定を上書きして npm run vercel:build
が実行されるようにする。
以下の環境変数はVercel上に登録しておく。
SUPABASE_PROJECT_ID
SUPABASE_ACCESS_TOKEN
SUPABASE_DB_PASSWORD
これで理論上はアプリケーション側のデプロイが失敗して、DBのMigrationが実行されるという事態は防ぐ事が出来る。
しかもVercelの環境変数はPreview環境のBranch指定が可能なので今後ステージング環境等の固定のURLを持つ環境が出てきたとしても対応が可能になる。
ちなみに supabase db lint
などもVercel上で実行したかったが、Vercelのビルド環境にはDocker等のコンテナ実行環境が存在しないようなので断念した。
以下の公式サンプルがやっているようにCI Workflowの中で supabase db lint
と supabase db push --dry-run
を実行するようにした。
ちなみに supabase db lint
や supabase db push --dry-run
はSQLの構文エラーまでは見てくれない模様。
検証用に supabase/migrations/20240602071050_create_test.sql
というファイルを追加してわざとシンタックスエラーになるSQLを追加してコミットしても --dry-run
は正常に通ってしまう。
CREATE TABLE tests(
id UUID DEFAULT gen_random_uuid(),
supabase_user_id NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (ids),
UNIQUE (user_id)
);
ただ当然だがMigrationには失敗するのでVercel上ではちゃんとエラーになってくれる。(以下はその時のメッセージ)
ERROR: syntax error at or near "NOT" (SQLSTATE 42601)
At statement 0: CREATE TABLE tests(
id UUID DEFAULT gen_random_uuid(),
supabase_user_id NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (ids),
UNIQUE (user_id)
)
Try rerunning the command with --debug to troubleshoot the error.
Error: Command "npm run vercel:build" exited with 1
CIとしては不完全ではあるが、少なくとも間違ったテーブル等が本番環境に作られる事は防ぐ事が出来るので一旦この運用で行こうと思う。
以下はその変更時のPR。
Migrationのロールバックについて
何かしらの事情があってDBのMigrationをロールバックする場合はSupabase上に作成される schema_migrations
のデータを修正する事でMigrationのロールバックを実現する事になる。
以下は schema_migrations
の中身。
このデータを直接触るのではなく supabase migration repair
というコマンドを使って操作する事が可能な模様。
ただしPlanetScaleのSafe migrationsのような無停止で安全にschemaのロールバックが出来るような仕組みではないので、これらのロールバックは本当の緊急時以外はやらないほうが良いと思う。
一旦運用出来そうなところまで調査が完了したのでこのスクラップはクローズ。
とても有用な記事です。
スクラップという機能があることも本日始めて知りました。
スクラップ見やすくてよいですね。
ありがとうございます🙏
自分用のメモとして残した物ですが見て頂けて良かったです👍
追記 データベースを利用する際のSecurity対策
Defaultだと anon key でpublicSchemaのテーブルに対してCRUD操作が出来てしまう。
よってこれを防ぐことは運用上必須であると思われる。
以下の記事に対策が紹介されていたので実施していく。
既存権限を確認する
SupabaseのSQL Editorで以下のSQLを入力。
SELECT grantee, table_name, privilege_type
FROM information_schema.role_table_grants
WHERE table_schema = 'public'
ORDER BY grantee, table_name, privilege_type;
以下のように表示されてしまったらCRUD操作が出来る状態。ちなみに1つでもテーブルがないと先ほどのSQLを実行しても結果は得られない。
anon keyからCRUD権限を剥奪する
以下を実行する。
REVOKE all privileges
ON all tables
IN SCHEMA public
FROM anon;
今後作成されるテーブルについても対策
既存のテーブルに対しては権限を剥奪したが、新規テーブルを作成した場合はanon権限が付与されるので対策。
以下を実行することで新規作成されるテーブルにもanon権限が付与されなくなる。
ALTER DEFAULT privileges IN SCHEMA public REVOKE all ON tables FROM anon;
ALTER DEFAULT privileges IN SCHEMA public REVOKE all ON functions FROM anon;
ALTER DEFAULT privileges IN SCHEMA public REVOKE all ON sequences FROM anon;
特定テーブルだけanon権限付与
GRANT SELECT ON posts TO anon;
アプリケーション側で制御することも可能なので必須ではないが認証ユーザーのみデータを作成するように制御することも可能。
Row Level Security(RLS)
PostgresSQLを使用した行単位のセキュリティ。
この機能を使用することで、認証ユーザーのみデータを作成、更新ができるといった制御が可能になる。(データ行単位のアクセス制御)
SQLでポリシー作成するにあたり、理解しておくべき用語があります。
USING expression
ここで指定した条件を満たす行を操作できるようになります。言い換えるならSQLでいうWHERE句になります。
WITH CHECK expression
登録、更新をする際に指定した条件を満たす行のみ操作できるようになります。追加の条件を指定したい場合はこちらを使用します。
満たされない場合はエラーが返されます。
また、ポリシーで使える便利な関数が存在します。
以下のようにポリシーで使える便利な関数が存在します。
auth.uid() // ログイン中のユーザーIDを取得
auth.jwt() // ログイン中のJWTを取得
データ取得
全ユーザーにデータ取得を許可する
CREATE policy "Allow public read access"
ON todos
FOR select
USING ( true );
認証ユーザーのデータのみ取得する
CREATE policy "View own todos."
ON todos
FOR select
USING ( auth.uid() = user_id );
クライアント側からリクエストを受けて、実際に実行されるSQLは以下のようになる。
SELECT *
FROM todos
WHERE auth.uid() = todos.user_id;
クライアント側でSQLを組み立てる必要がなくなります。
const { data, error } = await supabase
.from('todos')
.select('*')
データ登録
CREATE policy "Register own todo."
ON todos
FOR insert
TO authenticated
WITH CHECK ( ( auth.uid() = user_id ) );
データ更新
CREATE policy "Update own todo."
ON todos
FOR update
TO authenticated
USING ( ( auth.uid() = user_id ) )
WITH CHECK ( ( auth.uid() = user_id ) );
データ削除
CREATE policy "Delete own todo."
ON todos
FOR delete
TO authenticated
USING ( ( auth.uid() = user_id ) );