コンテンツにスキップ

認証・テナント運用ガイド

waste-link の認証モデル、テナント割当、招待運用についての実務ハンドブック。


1. 認証モデル

1.1 全体像

auth.users (Supabase)
├── id                    : UUID
├── email
├── raw_app_meta_data     : サーバ側のみ書込可
│     ├── tenant_id       : tenants.id
│     ├── tenant_type     : 'discharger' | 'collector' | 'external_partner'
│     │                                                  (テナント種別を JWT に
│     │                                                   denormalize)
│     └── role            : 'admin' | 'normal' | 'driver' (collector のみ意味あり)
└── raw_user_meta_data    : ユーザー編集可
        │ サインイン時に JWT へ自動エンコード
JWT claims
├── sub             : auth.users.id
├── email
├── app_metadata    : 上記 raw_app_meta_data の中身
└── user_metadata   : 同上
        │ RLS / アプリ層から参照
DB helpers
├── public.current_tenant_id()    → tenants.id
├── public.current_tenant_type()  → 'discharger' | 'collector' | 'external_partner'
└── public.current_user_role()    → 'admin' | 'normal' | 'driver' | NULL

1.2 テナント種別

tenants.type 説明 アプリ内画面 (デフォルト遷移先)
discharger 排出業者 (廃棄物を出す側) /discharger
collector 処理業者 (収集・運搬・処分) admin/normal → /collector / driver → /driver
external_partner 委託先 (衛星テナント・KYO-106 Phase3)。自前業務データを持たず、collector が保持する自社帰属の委託料精算を越境読取のみで閲覧 (role='admin' 固定) /external-partner/settlements

external_partner (委託先) は §1.1 の 3 値目。tenants.type 制約 (mig97 tenants_type_check) が discharger / collector / external_partner を許可し、Web の TENANT_TYPES allowlist もこの 3 値 (AuthContext.tsx:140)。衛星テナントは運営スクリプト + service-role INSERT でのみ作成し (公開サインアップは 3 値化しない)、越境精算閲覧は DEFINER RPC (mig98) 経由に限定される。権威: apps/web/src/contexts/AuthContext.tsx:16-21,244-246 + mig97。

1.3 ロール (role)

ロール値は profiles_role_check 制約 (mig27) で admin / normal / driver に限定される (public.is_valid_role)。

app_metadata.role 対応テナント種別 用途
admin discharger, collector の両方 管理者画面・全機能アクセス。工場/車両の新規作成 (INSERT) は admin 限定 (mig44)
normal collector (事務) 事務系の全操作。RLS の書込 (factories/vehicles/cost_entries 等の UPDATE/DELETE・cost_entries の INSERT) は admin と同水準 (mig27/mig43)。工場/車両の新規作成のみ不可 (mig44)
driver collector のみ ドライバー画面 (配車・現場操作)。事務操作・課金 API は不可
未設定 (null) collector 実効権限なし。RoleGuard / RLS / isCollectorAdmin いずれも通らない

排出業者は当面ロール区分なし。role を設定しなくても admin 相当として動く (resolveLandingPath が discharger は無条件に /discharger へ送る)。

collector で role が未設定 (null) の場合、resolveLandingPath は admin/未設定をまとめて /collector (admin Dashboard) へ送るが、実効権限は付かない (RLS の current_user_role() が null のため書込ポリシーに非合致)。この誤解を避けるため、UI のロール表示は未設定を 「処理業者 - 権限未設定」 と正直に出す (管理者と偽らない。PR #135・apps/web/src/lib/role-display.ts)。 修復は §3 の運用 (チーム管理 UI または scripts/set-member-role.mjs) で role を付与する。

実効ロールの権威 (JWT app_metadata.role) と profiles.role

権限の権威は auth.users.raw_app_meta_data.role (JWT app_metadata.role 経由)。RLS の current_user_role() はこれを JWT から読み、フロントの useAuth().roleapp_metadata.role 由来。 profiles.role表示用の副次コピーであり、認可判定には使わない。チーム管理 UI (apps/api routes/team.ts) や scripts/set-member-role.mjs は両者を同時に更新する。

⚠️ 反映タイミング: app_metadata.role の変更は、対象ユーザーの JWT が更新される (再ログイン / トークンリフレッシュ) まで RLS・画面に反映されない。ロールを変えた直後は 対象者に再ログインを促すこと。

1.4 採用方針サマリー

項目 採用 理由
認証方式 メール+パスワード + マジックリンク 業務アプリで一般的、パスワード忘れも OTP で吸収
サインアップ 招待制 (enable_signup = false) 勝手な登録を防ぐ
テナントモデル 1ユーザー = 1テナント シンプル。将来 memberships で多対多へ拡張可能
テナント種別の付与 auth.users.raw_app_meta_data.tenant_type JWT に自動投入、RLS / アプリ層共通
Auth Hook 不要 app_metadata 経路で十分

1.5 RLS / アクセス制御

public.current_tenant_id() の挙動:

状態 返却値
未ログイン (anon) NULL
ログイン済 / tenant_id 未割当 NULL
ログイン済 / tenant_id 設定済み テナントの UUID

tenants テーブルや業務テーブルの RLS は tenant_id = public.current_tenant_id() で絞り込み。tenant_id 未割当のユーザーは何もアクセスできない (フェイルセーフ)。

current_user_role() を使ったロール差分は既に導入済み: - 書込 (INSERT/UPDATE/DELETE) は概ね admin|normal に限定 (mig27)。 - 工場・車両の新規作成 (INSERT) は admin 限定 (mig44)。cost_entries/daily_logs の INSERT は admin|normal (mig43)。 - 工場スコープの READ: admin=テナント全工場 / normal・driver=所属工場のみ (current_user_can_access_factory / mig43)。 - driver は自ルートの dispatch_stops のみ更新可・cost_entries は不可 (mig30)。

サーバ側 (Hono API) にもロール判定ミドルウェアがある: - requireCollectorAdmin — collector かつ role='admin' (例: メンバー招待)。 - requireCollectorStaff — collector かつ role∈{admin,normal} (例: POST /vehicle-costs/ocr。RLS の cost_entries INSERT=admin|normal と同じ判定面。driver には課金 API を開放しない)。 - requireDischarger — discharger のみ (例: POST /manifests/ocr)。


2. 新規テナント + 初回ユーザーの作成

2.0 α 検証用デモアカウント (推奨: Initial Setup ワークフロー)

dev Supabase プロジェクト (gkpzjbzhknghwclrofhc) を α 検証用に wipe → 再投入 できる GitHub Actions ワークフロー。DB に余計なごみが溜まっても 1 クリックで真っさらに戻る。 (Supabase Branching は廃止済み。dev / 本番は独立した 2 プロジェクト構成。)

仕組み

.github/workflows/initial-setup.yml  (workflow_dispatch only / develop 固定)
   ├── ① psql で wipe
   │      → truncate public.* cascade
   │      → auth.users から旧 demo-*@waste-link.test と新テスト login_id メールを限定削除
   ├── ② psql で docs/test-data.sql 投入
   │      → tenants / permits / service_areas / vehicles / pricing_rules / jobs
   ├── ③ scripts/setup-demo-users.mjs (Node)
   │      → Supabase Admin API でデモアカウントを upsert (login_id でログイン可能化)
   │      → app_metadata に tenant_id / tenant_type / role を埋める
   └── ④ psql で docs/test-data-driver-deps.sql 投入 (profile 依存の配車/車両ログ)

どのブランチから起動しても、checkout は必ず ref: develop で固定される ため、参照する test-data.sql / script は常に develop 最新になる (適用先は git ブランチではなく dev プロジェクト)。

auth.users への直接 SQL INSERT は GoTrue 内部スキーマと相性が悪く ("Database error querying schema" を発生させる)、必ず Admin API 経由 で作成する。

必要な GitHub Secrets

Secret 取得場所 用途
SUPABASE_DEV_API_URL dev プロジェクトの API URL をそのまま (例: https://gkpzjbzhknghwclrofhc.supabase.co) Admin API の base URL
SUPABASE_DEV_DB_URL dev プロジェクトの Connection string > Session pooler タブの URI (パスワード入りでコピー) psql で wipe + test-data 投入
SUPABASE_DEV_SERVICE_ROLE_KEY dev プロジェクトの API > "Project API keys" の service_role の Reveal デモユーザー作成 (Admin API)

service_role キーは DB 全権限を持つ root レベル の鍵。漏洩すると DB が 丸ごと触られるため、GitHub Secrets でのみ保管し UI / git / Slack に絶対 に貼らないこと。

SUPABASE_DEV_DB_URL も同様にパスワードを含むので機密扱い。 直結エンドポイント (db.<ref>.supabase.co) は IPv6 only で GitHub Actions ランナーから到達できないため、必ず Session pooler の URI を使うこと。

実行手順

  1. GitHub Secrets に上記 3 つが揃っていることを確認
  2. Actions タブの "Initial Setup (develop branch test data)" workflow を開く
  3. Run workflow をクリック
  4. 入力フォーム:
  5. target: develop (production は選べない設計)
  6. confirm: yes を手入力 (誤実行防止)
  7. Run workflow をクリック
  8. 1〜2 分で完了。Step Summary にログイン情報が出る。ログインは login_id (メールアドレスではない。内部で <login_id>@members.waste-link.local に決定的変換される)。共通パスワードは Demo!2026。例:
disch1   / Demo!2026  (排出業者)
admin1   / Demo!2026  (処理業者 admin)
driver1  / Demo!2026  (処理業者 driver)

全アカウントの一覧 (処理業者 A/B・排出業者・閲覧ポータル用) は README.md「テストアカウント」節と scripts/setup-demo-users.mjs を参照。

冪等なので何度走らせても OK。実行のたびに wipe → 再投入されるため、 画面操作で増えたごみデータも一掃される。

⚠️ production には絶対に流さないこと。 workflow_dispatch の入力で target 選択肢を develop のみに限定しているが、二重チェックとして SUPABASE_DEV_* 系の secret しか参照しないようにしている。

手作業派の場合

自分の手で投入したい場合は §2.1〜§2.4 に従う(以下のセクション)。 ただしデモアカウント作成は手数が多いので、本 §2.0 の workflow を 強く推奨。

2.1 共通: テナント作成

Supabase Dashboard (dev プロジェクト または 本番プロジェクト) の SQL Editor で:

-- 排出業者を作る場合
insert into public.tenants (name, type)
values ('株式会社○○商事', 'discharger')
returning id;

-- 処理業者を作る場合
insert into public.tenants (name, type)
values ('株式会社○○環境', 'collector')
returning id;

返ってきた id をコピー (例: 123e4567-...)。

2.2 排出業者ユーザーを作る

Step 1: Authentication > Users > Add user

項目
Email admin@kaisha.example.jp
Password 強パスワード
Auto Confirm User

Step 2: tenant_id + tenant_type を紐付け

update auth.users
set raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb)
  || jsonb_build_object(
       'tenant_id',   '<2.1 でコピーした tenant_id>',
       'tenant_type', 'discharger'
     )
where email = 'admin@kaisha.example.jp';

role は省略可 (排出業者はロール区分なし)。

Step 3: 動作確認

/signin でログイン → / → 自動で /discharger へリダイレクトされる。


2.3 処理業者 (管理者) を作る

Step 1: Add user

同上。Email を該当の管理者にする。

Step 2: tenant_id + tenant_type + role(admin) を紐付け

update auth.users
set raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb)
  || jsonb_build_object(
       'tenant_id',   '<処理業者 tenant の id>',
       'tenant_type', 'collector',
       'role',        'admin'
     )
where email = 'admin@shorigyo.example.jp';

Step 3: 動作確認

ログイン → //collector (admin Dashboard) へリダイレクト。


2.4 ドライバーを追加する

事前に処理業者テナント (collector) が作成済みである前提。

Step 1: Add user

driver01@shorigyo.example.jp 等の専用メールでユーザー作成。

Step 2: role = 'driver' を紐付け

update auth.users
set raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb)
  || jsonb_build_object(
       'tenant_id',   '<同じ処理業者 tenant の id>',
       'tenant_type', 'collector',
       'role',        'driver'
     )
where email = 'driver01@shorigyo.example.jp';

Step 3: 動作確認

ログイン → //driver へリダイレクト。 モバイル幅レイアウトのシンプル画面が出れば成功。


3. ユーザーのテナント / ロール変更

テナント間移動

update auth.users
set raw_app_meta_data = raw_app_meta_data
  || jsonb_build_object('tenant_id', '<新tenant_id>')
where email = 'user@example.com';

ユーザーは次回サインイン時から新テナントのデータを参照するようになる。

ロール昇格 (driver → admin / normal など)

通常はチーム管理 UI (apps/api routes/team.ts/members/:id/role) から変更する (app_metadata.roleprofiles.role の両方を更新)。SQL で直接変える場合:

update auth.users
set raw_app_meta_data = raw_app_meta_data
  || jsonb_build_object('role', 'admin')   -- 'admin' | 'normal' | 'driver'
where email = 'user@example.com';

SQL 直変更は profiles.role (表示用の副次コピー) を更新しない。表示のズレを避けるため 可能ならチーム管理 UI か下記スクリプトを使う。いずれも再ログインまで反映されない

実効 admin が 1 人もいないとき (卵と鶏)

チーム管理 UI は呼び出し元に有効な admin が必要なため、「テナント内に実効 admin (app_metadata.role='admin') が 1 人もいない」状態は UI から修復できない。この場合は service-role で解く運用スクリプトを使う:

SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... node scripts/set-member-role.mjs \
  --email <対象メール> --collector-tenant-id <uuid> --role admin [--dry-run]

team.ts /members/:id/role と同一セマンティクス (app_metadata + profiles 両更新・ 「最後の管理者」降格ガード・失敗時ロールバック)。service_role は RLS をバイパスするため --collector-tenant-id は必須で、対象ユーザーの tenant_id 一致を明示検証する (AGENTS.md 不変条件)。反映は対象者の再ログイン後。

不正な値が混入していないか確認

select email, raw_app_meta_data
from auth.users
where coalesce(raw_app_meta_data ->> 'tenant_type', '') not in ('discharger', 'collector', 'external_partner')
   or coalesce(raw_app_meta_data ->> 'role', '') not in ('admin', 'normal', 'driver')
   or raw_app_meta_data ->> 'tenant_id' is null;
-- coalesce で NULL を検出対象に含める(`NULL not in (...)` は NULL 評価で検出漏れするため、
-- tenant_type / role が欠落している行も「不正」として拾う)。

該当行が出たら設定漏れ。SQL で修復。


4. パスワードリセット

現状アプリ内には UI なし。以下の 2 つの代替 がある:

4.1 ユーザー自身が SignIn 画面でマジックリンク利用

/signin でタブを「マジックリンク」に切り替え → メール送信 → メールから直接ログイン。

4.2 管理者が Dashboard から再発行

AuthenticationUsers → 該当ユーザー → Send password recovery

将来: /reset-password 画面を実装し、Supabase の標準フローに統合する。


5. 制限事項と将来の改善

現状の制限

  • サインアップ機能なし (招待制を意図)
  • 多対多テナント関係不可
  • パスワード再設定 UI なし (マジックリンクで代替)
  • 招待メールテンプレートは Supabase デフォルトのまま (日本語化未対応)
  • ロール権限は UI 出し分けに加え、RLS とサーバミドルウェアでも制御済み (§1.3 / §1.5)。 書込・工場スコープ READ は admin|normal|driver で差がある (admin/normal がテナント全データを 見えるとは限らない: normal/driver は工場スコープ)。

将来検討する改善

項目 概要 想定タイミング
招待 Edge Function UI から招待 → Edge Function が auth.admin.inviteUserByEmail を叩く → 招待リンクで自動的に tenant_id / tenant_type / role 紐付け 管理者画面実装と同時
memberships テーブル 1ユーザー複数テナント。会計事務所が複数排出業者をまとめて見る 会計事務所ユース時
role-based RLS (追加分) 基本のロール差分・工場スコープは導入済み (§1.5)。残る細粒度 (例: jobs の driver=assigned のみ) は業務機能に応じて拡張 業務機能実装時
メールテンプレ日本語化 Authentication > Email Templates 本番導入直前
SAML / OIDC SSO 大口顧客向け 50社規模到達後

6. トラブルシューティング

ログインできるが / で「テナント未割当」表示

→ Step 2 の SQL UPDATE が漏れている、または tenant_type を入れ忘れた。

select email, raw_app_meta_data from auth.users where email = '<対象>';

tenant_idtenant_type の両方があるか確認。

ドライバーなのに /collector (admin Dashboard) に飛ぶ

role: 'driver' が設定されていない。Step 2 の SQL を確認・再実行。

/driver を開いてもすぐ /collector にリダイレクトされる

→ 自分が can_drive を持たない admin/normal なので RoleGuard が弾いている。これは正常動作。 ただし can_drive=true を付与された admin/normal は /driver に入れる(判定は canActAsDriver = role が driver、または can_drive 付きの admin/normal を許可・ AuthContext.tsxcanActAsDriver / resolveLandingPath)。純粋な admin(can_drive なし)のみ弾かれる。

マジックリンクをクリックしても /signin に戻される

→ Supabase の該当プロジェクト (dev / 本番) の Authentication > URL Configuration に Preview ドメインの redirect URL が登録されていない。

auth.users の SQL UPDATE が失敗する

→ SQL Editor 上部の Run 設定で 「Run as: service_role」 を選んで実行する。