コンテンツにスキップ

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

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


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 で吸収
サインアップ メール招待コード制の公開オンボーディング (/registerPOST /signup) collector / discharger のみを対象にする
テナントモデル 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 / mig137)。 - driver は自ルートの dispatch_stops のみ更新可・vehicle_cost_entries は read/write とも不可。

サーバ側 (Hono API) にもロール判定ミドルウェアがある: - requireCollectorAdmin — collector かつ role='admin' (例: メンバー招待)。 - requireCollectorStaff — collector かつ role∈{admin,normal} (例: POST /vehicle-costs/ocr。RLS の cost_entries INSERT=admin|normal と同じ判定面。driver には課金 API を開放しない)。 - POST /manifests/ocrrequireManifestOcrCaller(作成権限者 + collector driver)で認可する。requireDischarger は現在本番ルート未配線。


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 依存の配車/車両ログ)

#### v2.1 Initial-Setup の8ステップ

1. **preflight** — dev API、service-role、既存 profile、接続先を読み取り専用で確認する。
2. **wipe** — develop の dev プロジェクトだけを限定削除する。本番・実発行メンバー・対象外テナントは触らない。
3. **4層の基礎データ** — test-data.sql でテナント、許可、工場、車両、取引先、回収先、契約、案件、マニフェストの基礎を投入する。
4. **17名 + RLS fixture** — setup-demo-users.mjs で product roster 17 名と分離検証用7名を Admin API 経由で upsert する。
5. **profile依存層** — 所属工場、専用車、コース、横乗りを driver-deps で投入する(mig177 の ride-along を含む)。
6. **5年完了実績** — annual.sql で rolling 60 か月の完了計量、請求、運行ログ、週末を含む履歴を投入する。
7. **当日 live 層** — today.sql と live.sql で未完了 stop、OCR レビュー、collecting run、返送期限、請求候補を当日に作る。
8. **週末配車の確認** — live 層の土日 stop と宵積み・横乗りを確認し、初期投入の完了を Step Summary と件数で検証する。

この順序で、dev は v2.1 + 5年履歴 + 当日 live + 週末配車の撮影可能な状態になります。画面からデータを足す必要はありません。

どのブランチから起動しても、**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](https://github.com/kyoei-paper/waste-link/actions/workflows/initial-setup.yml) を開く
3. **`Run workflow`** をクリック
4. 入力フォーム:
   - **`target`**: `develop` (production は選べない設計)
   - **`confirm`**: `yes` を手入力 (誤実行防止)
5. **`Run workflow`** をクリック
6. 1〜2 分で完了。Step Summary にログイン情報が出る。ログインは
   **login_id** (メールアドレスではない。内部で `<login_id>@members.waste-link.local`
   に決定的変換される)。共通パスワードは `Demo!2026`。例:

   ```
   disch1   / Demo!2026  (排出業者)
   admin1   / Demo!2026  (処理業者 admin)
   driver01  / 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 で:

```sql
-- 排出業者を作る場合
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@members.waste-link.local';

Step 3: 動作確認

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


2.4 ドライバーを追加する

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

Step 1: Add user

driver01@members.waste-link.local 等の専用メールでユーザー作成。

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@members.waste-link.local';

Step 3: 動作確認

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


2.5 横乗り(mig177)

横乗りはメンバーのロールを一時変更する機能ではありません。事務が当日 stop に同乗者を明示登録し、横乗りしたメンバーはその同乗先の完了・取消だけを行います。配車の並べ替え、応援依頼、目方入力、現場メモ変更は許可しません。

横乗りの対象・日付・テナントはサーバー側で検証し、当日ボードと driver PWA の表示を同じ assignment から導出します。解除後は画面復帰で本人の PWA からも消えます。Initial-Setup では driver-deps の profile 依存層で検証用の横乗りを投入します。

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. 制限事項と将来の改善

現状の制限

  • メール招待コード制の公開オンボーディングを実装済み(/registerPOST /signup・collector / discharger のみ)
  • 多対多テナント関係不可
  • パスワード再設定 UI なし (マジックリンクで代替)
  • 招待メールテンプレートは Supabase デフォルトのまま (日本語化未対応)
  • ロール権限は UI 出し分けに加え、RLS とサーバミドルウェアでも制御済み (§1.3 / §1.5)。 書込・工場スコープ READ は admin|normal|driver で差がある(admin/normal はテナント全読み、factory スコープは driver のみ)。vehicle_cost_entries は 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」 を選んで実行する。