0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

取引先ごとに見えるデータが変わる「請求書ポータル」をつくってみた:ログインしたら自社の分だけが出てくる

0
Last updated at Posted at 2026-10-22

対象: 前の記事でRLS(行レベルセキュリティ)を試して、「で、実際のアプリだと、どう見えるの?」が気になった方
所要時間: 40〜60分(前の記事のテーブルとポリシーができている場合) / 使うもの: ブラウザ、ターミナル、Supabaseのプロジェクト(Freeプランで可)
確認日: 2026年10月(画面や料金は変わることがあります)
前の記事(手順編): https://qiita.com/tomozilla/items/5dc8b3940f671f5bc302
ビジネス編(やさしい解説・note): https://note.com/tomozilla/n/n5d457af8981d
サンプルコード一式(GitHub): https://github.com/tomozilla/rls-multitenancy-demo
記事の中のコードは日本語コメントです。リポジトリの本体は英語コメントで、日本語コメント版(この記事と同じもの)は ja/ フォルダーにあります。

前の記事では、SQL Editorで「Aliceになりきる」「Bobになりきる」をして、RLSで見える行が変わることを確かめました。でも、あれはSQLの世界の話です。

実際のお客さまは、ログイン画面から入ってきます。 そのとき、画面はどう見えて、アプリのコードは何を書けばいいのでしょう? 受託開発のお客さま用ポータルや、代理店用の画面を想像しながら、**取引先ごとの「請求書ポータル」**をつくってみました。

0. 先に伝えておきたいこと(正直ベース)

  • 前の記事のテーブル(tenants / memberships / invoices)とポリシーができている前提です。
  • ユーザーは、デモ用の架空の2人です。パスワードは、このリポジトリにも記事にも載せていません。
  • 画面は最小構成です。1人が複数の取引先に所属する場合、管理者、パスワードの再設定、メール確認は、扱っていません。
  • 費用:この画面は、ユーザー認証(ログイン)とデータベースへの読み書きだけを使います。Freeプランでは、月間アクティブユーザー(MAU)が5万人まで含まれます(ドキュメントの料金表より)。私が確認した料金表に、APIの呼び出し1回ごとの項目はありませんでした(通信量は別に数えられます)。私は実際の使用量は測っていません。
  • 未検証:件数が多いときの速度(ポリシーの中の副問い合わせは、行が増えると重くなることがあります)、スマホでの表示、他のブラウザ(Macのchromeだけで確認しました)。

1. つくったもの

ログイン画面と、ログイン後の「請求書ポータル」の2枚です。

ログイン画面

Acme社のユーザーでログインすると、こうなりました。

Acme社のユーザーのポータル

右上に取引先の名前(Acme Inc.)、中央に請求書が2件・合計¥150,000。同じ画面に、Globex社のユーザーでログインすると、こうなります。

Globex社のユーザーのポータル

同じ画面、同じコードなのに、中身はまったく別です。

同じ画面・同じコードで、見える行が変わる

2. つくり方

ユーザーを用意する(つまずき:パスワードを決められなかった)

前の記事の alice@example.com と bob@example.com でログインしようとして、気づきました。ダッシュボードの既存ユーザーの画面には、パスワードを決める場所がありません(出てくるのは、再設定メールを送るボタンだけで、example.com のメールは受け取れません)。

そこで、新しいデモユーザーを2人作ることにしました(Authentication → Users → Add user → Create new user、Auto Confirm にチェック)。そして、SQLで会社に結びつけます。

insert into public.memberships (tenant_id, user_id)
select t.id, u.id from public.tenants t
join auth.users u on (t.name = 'Acme Inc.' and u.email = 'demo-acme@example.com')
                  or (t.name = 'Globex Corp.' and u.email = 'demo-globex@example.com')
on conflict do nothing;

画面のコード(要点)

画面は、HTML 1枚です(ビルド不要)。supabase-js は、CDNから読み込みます。

import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const supabase = createClient(cfg.SUPABASE_URL, cfg.SUPABASE_PUBLISHABLE_KEY)

// ログイン
await supabase.auth.signInWithPassword({ email, password })

ログインしたら、「自分の所属」と「請求書」を読みます。

// 自分の所属(RLSのおかげで、自分の行しか返ってこない)
const { data: mem } = await supabase.from('memberships').select('tenant_id, tenants(name)')

// ★ ここに「tenant_idで絞る」条件は書いていません
const { data: invoices } = await supabase.from('invoices').select('id, title, amount_jpy').order('id')

2つ目の select に、where も eq もありません。 取引先で絞る条件を、画面のコードに1行も書いていないのに、自社の分だけが返ってきます。見える行は、前の記事で書いたポリシー(using (tenant_id in (...自分の所属...)))が決めています。

請求書を追加するときだけ、自分の取引先のIDを入れます(ポリシーの with check が、他社のIDを拒否します)。

await supabase.from('invoices').insert({ tenant_id: tenant.id, title, amount_jpy: amount })

動かす

cd app
cp config.example.js config.js     # URL と publishable キーを入れる
python3 -m http.server 8765 --bind 127.0.0.1   # → http://127.0.0.1:8765/index.html

3. 使ってみる

請求書を追加する

「件名」と「金額」を入れて、追加します。

請求書を追加すると、一覧と合計が更新される

3件・合計¥200,000に更新されました。(このとき、新しい請求書の番号が 9 になりました。拒否された追加(前の記事の試しと、この画面のテスト)のぶんだけ、番号(ID)が進んでいたからだと思います。「拒否されても、番号は消費される」ようです。理由の確認まではしていません。)

「ほんとうにRLSで守られているか」を、画面から試す

画面の下に、開発者向けテストを付けました。

絞り込みなしで読む、他社のIDで追加する

  1. 絞り込みなしで、全件を読んでみる:全社分を頼んだのに、返ってきたのは自社の2件だけでした。
  2. 他社のIDで、請求書を追加してみる:他社のIDは、そもそも見えません(RLSで、他社の取引先の行も見えないため)。なので、適当なID(ランダムなUUID)で試しました。結果は、42501 new row violates row-level security policy で拒否されました。

前の記事のSQLの「なりきり」と、同じ結果が、本物のログインでも出ました。これは、記事を書いたときに確かめていなかったことなので、うれしかった点です。

4. つまずいたことと、気づいたこと

① 画面のコードに「絞り込み」が無い、のは怖くもある

画面は、select で全件を頼んでいます。つまり、RLSが効いていなかったら、全社の請求書が画面に出てしまいます。 前の記事で、RLSをオンにする前は、ログインしていない人にも全件が見えたことを思い出してください。安心の源が、データベースのルール1か所なのは、強みでもあり、そこが外れたら全部が外れる弱点でもあります。

だから、開発者向けテストのような「ほんとうに守られているか」を確かめる仕組みを、画面やテストに組み込んでおくと安心です(今回の画面のテストは手動のボタンです。自動のテストは、私は作っていません)。

② 所属が複数あると、このコードは足りない

今回のコードは、memberships の最初の1件だけを使います。1人が複数の取引先に所属する場合(代理店など)は、どの取引先として見るかを選べる画面が要ります。

③ 取引先のIDを、画面から送っている

追加のとき、tenant_id は画面から送っています。ポリシーが他社のIDを拒否してくれるので、安全側ですが、ほんとうは、サーバー側で自動で入れる(既定値をユーザーの所属から決める)ほうが、間違いが起きにくそうです(私は試していません)。

5. 実際のWebアプリにするなら

どれも、私は試していません。

  • ログインまわり:メール確認、パスワードの再設定、ログインの方法(SSOなど)
  • ロール:管理者と一般ユーザーで、できることを分ける
  • 書き込みのルール:更新・削除のポリシー。請求書は、追加はできても書き換え・削除は禁止にしたい場合もあります
  • 性能:ポリシーの副問い合わせが、件数の増加でどう効くか
  • 自動テスト:「Aliceに、Globexの行が見えない」をテストコードにする

まとめ

  • RLSがあると、画面のコードに「取引先で絞る」条件を書かなくても、ログインした人の分だけが出てくる
  • 前の記事のSQLでの「なりきり」と、本物のログインでも同じ結果だった
  • 他社のIDで追加しようとすると、42501 で拒否された
  • ダッシュボードの既存ユーザーには、パスワードを決める場所がなかった。デモ用に新しいユーザーを作って、SQLで会社に結びつけた
  • 見える行を決めるのがデータベースのルール1か所だからこそ、「ほんとうに効いているか」を確かめる仕組みが大事

コードは、上のGitHubにあります。次は、AIエージェントに渡す前の「データの棚卸し」を、Webアプリにしてみます。


スクリーンショットは、記事用に作った無料プランのデモ環境で撮影したものです。メールアドレスは、架空のもの(example.com)です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?