こんにちは。プロジェクト管理SaaS「Taski(タスキ)」を個人開発しているエンジニアです。
BtoB SaaSやプロジェクト管理ツールを運用していると、必ずぶつかる要望があります。
「取引先や社外クライアント、他部署から『タスクや不具合報告』を送ってもらいたいけれど、そのためだけに全員にアカウントを作らせるのはハードルが高すぎる……」
「Googleフォームやメールで受け取って、手作業でチケットを転記・コピーするのは二度手間で限界がある……」
この課題を解決するのが、専用URLを共有するだけで誰でも課題を直接起票できる「パブリック受付フォーム(Intake Form)」機能です。
しかし、BtoBのデータベース(Supabase / PostgreSQL)に対して、「未認証(anon)の一般ユーザーからログイン不要でデータを書き込ませる」というのは、一歩間違えると重大なセキュリティインシデントに直結します。
ありがちな大事故パターン:
❌ anonロールに tickets テーブルへの INSERT 権限を与えてしまう
❌ 全件SELECTが通って他社の機密プロジェクトや個人情報がダダ漏れ
❌ スパムBOTに秒間数百件のゴミチケットを連投されてDBとStorageがパンク
❌ RLS(Row Level Security)の穴を突かれて他人のチケットが改ざん・削除される
Taski では、「アカウント不要の利便性」と「強固なゼロトラスト・セキュリティ」を両立するため、Supabase RLS・PostgreSQLの SECURITY DEFINER 関数・ハニーポット・ブラウザ指紋レートリミットを多層防御で組み合わせたアーキテクチャを採用しました。
本記事では、この受付フォーム機能のアーキテクチャ設計と実装コードをすべて公開・解説します。
1. 全体アーキテクチャ:なぜテーブル直接INSERTを禁じるのか
まず、全体のデータフローと防御境界です。
鉄則:anon ロールにはテーブル権限を一切与えない
Supabase の標準的なチュートリアルでは、anon(未ログインロール)に対して CREATE POLICY "anon insert" ON tickets FOR INSERT WITH CHECK (...) のようなポリシーを書きがちです。
しかし、チケットテーブルにはプロジェクトID、顧客名、社内コメント、添付ファイルなど秘匿情報が集約されています。anon に対してテーブルへの直接アクセスを1ミリでも許可すると、将来的なスキーマ変更やRLSの書き換え時に思わぬ情報漏洩の火種になります。
そこで、以下のルールを徹底しました:
-
ticketsおよびproject_intake_formsテーブルの直接操作は、ログインユーザー(authenticated)かつプロジェクト管理者に限定する。 -
未認証ユーザーの入り口は、2つの厳格な PostgreSQL RPC(
SECURITY DEFINER関数)だけに絞り込む。-
get_public_intake_form(target_token): フォーム表示用のメタデータ取得(プロジェクト名、受付タイトル、説明文など) -
submit_public_intake_form(target_token, ...): チケット起票処理
-
- フォームのURLには、推測不可能な 24バイト(48文字)の暗号論的乱数トークン(
gen_random_bytes(24))を使用する。
2. DB設計:設定テーブルと厳格なRLS
まずは、プロジェクトごとに受付フォームの設定を保持するテーブルです。
-- 1. 受付フォーム設定テーブル
CREATE TABLE IF NOT EXISTS public.project_intake_forms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL UNIQUE REFERENCES public.projects(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
is_enabled BOOLEAN NOT NULL DEFAULT true,
title TEXT NOT NULL DEFAULT 'ご要望・お問い合わせ受付フォーム',
description TEXT DEFAULT 'こちらからご要望や不具合報告を送信していただけます。',
success_message TEXT DEFAULT 'お問い合わせを受け付けました。',
default_status TEXT NOT NULL DEFAULT 'open',
default_priority TEXT NOT NULL DEFAULT 'medium',
default_assignee_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
default_tag_ids UUID[] DEFAULT '{}',
require_name BOOLEAN NOT NULL DEFAULT false,
require_email BOOLEAN NOT NULL DEFAULT false,
allow_attachments BOOLEAN NOT NULL DEFAULT true,
created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW())
);
-- RLSを有効化
ALTER TABLE public.project_intake_forms ENABLE ROW LEVEL SECURITY;
-- プロジェクトの管理者(admin)のみが設定を閲覧・更新できる
CREATE POLICY "Allow admins to manage their project's intake form" ON public.project_intake_forms
FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.project_members pm
WHERE pm.project_id = project_intake_forms.project_id
AND pm.user_id = auth.uid()
AND pm.role = 'admin'
)
);
ポイントは、フォームから起票されたチケットに付与される初期ステータス、優先度、担当者、タグをプロジェクト管理者がGUIから柔軟に指定できる点です。
「社外からの問い合わせは、自動的に『未仕分け』タグを付け、サポート担当のAさんに割り振る」といった運用がコード修正なしで完結します。
3. 未認証アクセスを安全に捌く SECURITY DEFINER 関数
未認証ユーザーは project_intake_forms も tickets も直接 SELECT / INSERT できません。
代わりに、トークンが一致した行のみを処理する特権ストアドプロシージャ(SECURITY DEFINER)を定義します。
① フォーム情報取得 RPC (get_public_intake_form)
CREATE OR REPLACE FUNCTION public.get_public_intake_form(target_token TEXT)
RETURNS JSONB AS $$
DECLARE
form_row RECORD;
proj_row RECORD;
result JSONB;
BEGIN
-- トークンが一致し、有効化されているフォームを1件取得
SELECT * INTO form_row
FROM public.project_intake_forms
WHERE token = target_token AND is_enabled = true;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'フォームが見つからないか、無効化されています。');
END IF;
-- 紐づくプロジェクトの公開用情報のみを取得(内部IDや社内設定は伏せる)
SELECT name, icon INTO proj_row
FROM public.projects
WHERE id = form_row.project_id;
result := jsonb_build_object(
'success', true,
'form', jsonb_build_object(
'title', form_row.title,
'description', form_row.description,
'project_name', proj_row.name,
'project_icon', proj_row.icon,
'require_name', form_row.require_name,
'require_email', form_row.require_email,
'allow_attachments', form_row.allow_attachments,
'success_message', form_row.success_message
)
);
RETURN result;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
内部の project_id や default_assignee_id などの機密カラムは一切フロントに返さず、表示に必要な情報だけを JSONB に成形して返しているのがポイントです。
② チケット起票 RPC (submit_public_intake_form) とハニーポット
続いて、フォーム送信時の本体関数です。
CREATE OR REPLACE FUNCTION public.submit_public_intake_form(
target_token TEXT,
title_input TEXT,
description_input TEXT,
reporter_name TEXT DEFAULT NULL,
reporter_email TEXT DEFAULT NULL,
attachments_input JSONB DEFAULT '[]'::jsonb,
honeypot TEXT DEFAULT NULL -- 🍯 BOT対策用
)
RETURNS JSONB AS $$
DECLARE
form_row RECORD;
new_ticket_id UUID;
new_ticket_number INT;
full_description TEXT;
BEGIN
-- 1. ハニーポット検証(値が入っていればBOTと判定してエラー)
IF honeypot IS NOT NULL AND LENGTH(TRIM(honeypot)) > 0 THEN
-- BOTには成功したように見せかけて早期リターンするか、400を返す
RAISE EXCEPTION '不正なリクエストが検出されました。';
END IF;
-- 2. トークン照合
SELECT * INTO form_row
FROM public.project_intake_forms
WHERE token = target_token AND is_enabled = true;
IF NOT FOUND THEN
RAISE EXCEPTION 'フォームが見つからないか、送信が締め切られています。';
END IF;
-- 3. 入力バリデーション
IF title_input IS NULL OR LENGTH(TRIM(title_input)) = 0 THEN
RAISE EXCEPTION 'タイトルは必須です。';
END IF;
IF form_row.require_name AND (reporter_name IS NULL OR LENGTH(TRIM(reporter_name)) = 0) THEN
RAISE EXCEPTION 'お名前を入力してください。';
END IF;
IF form_row.require_email AND (reporter_email IS NULL OR LENGTH(TRIM(reporter_email)) = 0) THEN
RAISE EXCEPTION 'メールアドレスを入力してください。';
END IF;
-- 4. チケット説明文にお問い合わせ元情報を付与
full_description := COALESCE(description_input, '');
IF reporter_name IS NOT NULL OR reporter_email IS NOT NULL THEN
full_description := full_description || E'\n\n---\n**【お問い合わせ元】**\n'
|| COALESCE('お名前: ' || reporter_name || E'\n', '')
|| COALESCE('ご連絡先: ' || reporter_email || E'\n', '')
|| '送信日時: ' || TO_CHAR(NOW() AT TIME ZONE 'Asia/Tokyo', 'YYYY-MM-DD HH24:MI:SS (JST)');
END IF;
-- 5. チケットをプロジェクトに起票(設定テーブルの初期値を適用)
INSERT INTO public.tickets (
project_id,
title,
description,
status,
priority,
assignee_ids,
tag_ids,
attachments,
created_by
) VALUES (
form_row.project_id,
TRIM(title_input),
full_description,
form_row.default_status,
form_row.default_priority,
CASE WHEN form_row.default_assignee_id IS NOT NULL
THEN ARRAY[form_row.default_assignee_id]
ELSE '{}'::uuid[] END,
COALESCE(form_row.default_tag_ids, '{}'::uuid[]),
COALESCE(attachments_input, '[]'::jsonb),
form_row.created_by
)
RETURNING id, ticket_number INTO new_ticket_id, new_ticket_number;
RETURN jsonb_build_object(
'success', true,
'ticket_number', new_ticket_number,
'message', form_row.success_message
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
4. フロントエンド実装:ハニーポットとUX
React側では、一般的なユーザーには見えず、DOM解析型のBOTだけが引っかかるハニーポットフィールド(Honeypot)をフォーム内に仕込みます。
ハニーポットのCSSトリック
export const PublicIntakeFormPage: React.FC = () => {
const [formData, setFormData] = useState({
title: '',
description: '',
reporterName: '',
reporterEmail: '',
// 🍯 ハニーポット用(人間には見えないが、BOTは自動入力してしまう項目)
website_hp_confirm: '',
});
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* 🍯 ハニーポットフィールド */}
<div
aria-hidden="true"
style={{
opacity: 0,
position: 'absolute',
top: 0,
left: 0,
height: 0,
width: 0,
zIndex: -1,
overflow: 'hidden'
}}
>
<label htmlFor="website_hp_confirm">このフィールドは空欄のままにしてください</label>
<input
type="text"
id="website_hp_confirm"
name="website_hp_confirm"
tabIndex={-1}
autoComplete="off"
value={formData.website_hp_confirm}
onChange={(e) => setFormData(prev => ({ ...prev, website_hp_confirm: e.target.value }))}
/>
</div>
{/* 通常の入力フォーム */}
<div>
<label className="block text-sm font-semibold text-slate-700">件名・タイトル *</label>
<input
type="text"
required
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:ring-1 focus:ring-sky-500"
placeholder="ご要望や不具合の概要を入力してください"
/>
</div>
{/* ... その他フィールド ... */}
</form>
);
};
なぜ display: none だけでは不十分なのか?
最新の賢いBOTクローラーは、CSSの display: none や visibility: hidden が当たっている input を検知して入力をスキップします。
そのため、opacity: 0 かつ width: 0, height: 0, overflow: hidden, position: absolute というスタイルを組み合わせることで、画面上のレイアウトを崩さずにBOTだけを確実にトラップに嵌めることができます。
送信時、フロント側でも if (formData.website_hp_confirm) return; で弾きつつ、万が一すり抜けてAPIを直接叩かれた場合も前述の PostgreSQL 関数側で弾く二重防御になっています。
5. 運用してみて分かった3つの落とし穴と対処法
① トークン漏洩時の「即時ローテーション(再発行)」
取引先との契約終了や、社外フォームのURLが意図せず流出した場合に備え、管理画面から1クリックでトークンを破棄・再生成できる関数を用意しました。
CREATE OR REPLACE FUNCTION public.regenerate_intake_form_token(target_project_id UUID)
RETURNS TEXT AS $$
DECLARE
new_token TEXT;
BEGIN
-- 管理者のみ実行可能
IF NOT EXISTS (
SELECT 1 FROM public.project_members pm
WHERE pm.project_id = target_project_id AND pm.user_id = auth.uid() AND pm.role = 'admin'
) THEN
RAISE EXCEPTION '権限がありません';
END IF;
new_token := encode(gen_random_bytes(24), 'hex');
UPDATE public.project_intake_forms
SET token = new_token, is_enabled = true, updated_at = NOW()
WHERE project_id = target_project_id;
RETURN new_token;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
古いURLは即座に 404(無効)になり、攻撃者にURLを特定されても即座に無力化できます。
② 添付ファイルのStorageセキュリティ
未ログインのユーザーに画像やログファイルを添付させる場合、Supabase Storage のバケットポリシーにも注意が必要です。
- アップロードは許可するが、ファイルの「一覧取得(SELECT)」や「他人のファイルの削除(DELETE)」は anon に絶対に許可しない。
- アップロード可能な拡張子(png, jpg, pdf, txt, zip等)とファイルサイズ上限(5MB以下)をクライアントとStorageの両側でバリデーションする。
③ チケット起票時の通知とリアルタイム反映
フォームからチケットが起票された瞬間、社内の担当者が気づけなければ意味がありません。
Taski では Supabase の Database Webhook / Realtime 購読により、起票された瞬間に社内メンバーのカンバン画面にアニメーション付きでリアルタイム挿入され、Slack / メールへ即時通知が飛ぶパイプラインを繋いでいます。
まとめ:オープンな利便性とゼロトラストの両立
SaaSにおける「未認証フォーム」の実装は、利便性とセキュリティが常にトレードオフになりがちです。
しかし、設計の境界を明確にし:
- テーブルへの直接アクセス権を誰にも(特にanonに)渡さない
- 入出力が100%制御された
SECURITY DEFINER関数だけを公開窓口にする - 推測不可能な暗号乱数トークン + ハニーポットによる多層防御を行う
というパターンを徹底することで、安全性を一切犠牲にすることなく、社外からのチケット自動起票を実現できます。
実プロダクトでの動作を体験できます
本記事で解説した「パブリック受付フォーム」や、リアルタイムに同期するカンバン・ガントチャート機能は、筆者が開発している Taski(タスキ) のデモ環境で今すぐ実際に動かして試すことができます。
アカウント登録・クレジットカード登録は一切不要で、1秒でデモ画面が起動します。
「社外とのタスク共有を安全かつ高速に行いたい」という方は、ぜひ実際の挙動を体験してみてください!