Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

広告枠のcompany_idは契約終了後も残留し、自社枠チェックだけでは元顧客が終了枠に無審査で掲載できていた

公開 読了時間 約9分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

placements.company_idは、枠を解約しても自動では消えない。『自社の枠か』だけを見る自動承認は、終了した枠に元顧客がそのまま無審査で掲載できる認可漏れになる。

placements.company_idは、枠を解約しても自動では消えない。『自社の枠か』だけを見る自動承認は、終了した枠に元顧客がそのまま無審査で掲載できる認可漏れになる。

結論

広告枠(placements)のcompany_id列は、契約が終了して枠の販売状態が「終了」や「空き」に戻っても、値がクリアされず残留し続ける。 自動承認機能の最初の実装は「company_idが自社と一致するか」という所有権チェックだけで入稿を自動承認していたため、元顧客が契約終了後の枠に無審査で掲載できる状態になっていた。この欠陥は機能導入と同日のコードレビューで指摘され(Reviewer B-1)、実運用に出る前に、枠の販売状態と契約の有効性をその場で検証する関数isAutoApprovableの追加で塞がれている。

症状

  • placements.company_idは、申込時にその枠を予約した企業のIDを保持する外部キーで、契約終了・枠の再販売後もクリアされない
  • 自動承認機能の最初の実装は「入稿した企業のcompany_idが、入稿先の枠のcompany_idと一致するか」だけを安全性の根拠にしていた
  • この一致チェックは、枠を今その企業が予約しているかではなく、かつて予約していたかしか見ていない
  • 契約終了後に枠が再販売中(空き・商談中)や終了状態のままでも、元顧客のアカウントからは「自社の枠」として入稿・自動承認の対象に見える
  • 自動化ポリシー(auto_approve_creatives)が有効な設定では、この入稿は人間の審査を経ずに即時配送まで進む

原因

自動承認機能を最初に導入したコミットの安全性根拠は、次のように書かれていた。

// creative-auto-approve.ts(b1138b8c40, 16-35行)
/**
 * 入稿素材の自動承認(自動化ポリシー auto_approve_creatives / マネタイズ計画 §6)。
 *
 * 前提(安全性の根拠):
 *  - サーバー側 spec 再検査に**合格した**入稿だけが呼ばれる(submitCreative 内・クライアント不信)。
 *  - 顧客アカウントは契約「署名済」で初めて発行される(Flow A)。つまりログインして入稿できる時点で
 *    契約同意済み=広告掲載規約(表現ルール含む)に同意済み。
 *  - 枠(placement)は申込時に当該企業へ原子的に予約済み=他社の枠を上書きできない
 *    (submitCreative が company_id 一致を検証済み)。
 *
 * 処理は reviewCreative の承認パスと同じ RPC(approve_creative_and_enqueue)で
 * 承認+枠掲載中+outbox enqueue を単一 TX 実行 → 直後に当該行だけ即時配送を試みる。
 * RPC 失敗時は「審査中」のまま残す(人間の審査キューへフォールバック・fail-closed)。
 */
export async function autoApproveCreative(opts: {
  creativeId: string;
  placementId: string;
  companyId: string;
  title: string;
}): Promise<{ approved: boolean }> {

この根拠の3点目「枠は申込時に当該企業へ原子的に予約済み」は、予約した瞬間については正しい。しかし、その予約が今も有効かどうかは、placementsテーブルの列定義を見ると保証されていない。

-- 0023_self_serve_booking.sql(6f62043a46, 24-27行)
add column if not exists availability      text not null default '空き'
  check (availability in ('空き','商談中','申込済','掲載中','終了')),
add column if not exists company_id        uuid references public.companies(id) on delete set null,
add column if not exists contract_id       uuid references public.contracts(id) on delete set null,

company_idのon delete set nullは、企業そのものが削除された場合にだけ発火する。契約が終了してavailabilityが「終了」に変わっても、company_idはそのまま残る。つまり、最初の実装が根拠にしていた「company_id一致」は、かつてその企業が予約した枠であることしか示さず、今も契約が有効かどうかは何も検証していなかった。

直し方

修正は、承認するたびにその場で枠の販売状態と契約の有効性をDBに問い合わせて確認する関数を新設した。

// creative-auto-approve.ts(98e80a37ef, 16-53行)
/** 自動承認できる枠の販売状態。申込済=契約手続き完了(署名時に遷移)/ 掲載中=配信中素材の差し替え。 */
const AUTO_APPROVABLE_AVAILABILITY = ["申込済", "掲載中"];

/**
 * 自動承認の前提検証(Reviewer B-1): 枠の販売状態と契約の有効性を**毎回その場で**確認する。
 * placements.company_id は契約終了後も残留するため(0023)、「自社の枠」チェックだけでは
 * 元顧客が終了枠/再販売中の空き枠に無審査で掲載できてしまう。以下を全て満たすときだけ自動承認:
 *  - 枠が自社・販売状態が 申込済/掲載中(空き・商談中・終了は不可)
 *  - 枠に契約が紐付いており、その契約が active・署名済・掲載期間内
 * 満たさない場合は自動承認せず「審査中」(人間の審査キュー)に残す。
 */
async function isAutoApprovable(
  admin: ReturnType<typeof createSupabaseAdminClient>,
  placementId: string,
  companyId: string
): Promise<boolean> {
  const { data: slot } = await admin
    .from("placements")
    .select("id, company_id, availability, contract_id")
    .eq("id", placementId)
    .maybeSingle();
  if (!slot || slot.company_id !== companyId) return false;
  if (!AUTO_APPROVABLE_AVAILABILITY.includes(slot.availability as string)) return false;
  if (!slot.contract_id) return false;

  const { data: ct } = await admin
    .from("contracts")
    .select("status, application_status, period_end")
    .eq("id", slot.contract_id)
    .maybeSingle();
  if (!ct) return false;
  const today = new Date().toISOString().slice(0, 10);
  return (
    ct.status === "active" &&
    ct.application_status === "署名済" &&
    (!ct.period_end || (ct.period_end as string) >= today)
  );
}

この関数は、既存のcompany_id一致チェックをそのまま残しつつ、その上に「販売状態が申込済/掲載中か」「紐づく契約がactive・署名済・掲載期間内か」という今この瞬間の状態検証を追加している。呼び出し側では、これを満たさない限り承認処理(RPC呼び出し)自体に進まないようにした。

// creative-auto-approve.ts(98e80a37ef, 55-86行)
export async function autoApproveCreative(opts: {
  creativeId: string;
  placementId: string;
  companyId: string;
  title: string;
  mediaType: "image" | "video" | null;
}): Promise<{ approved: boolean }> {
  const { creativeId, placementId, companyId, title } = opts;
  // B-2: 画像のみ。動画は尺の規格をサーバーで強制できないため自動承認しない。
  if (opts.mediaType !== "image") return { approved: false };

  const admin = createSupabaseAdminClient();

  // B-1: 枠状態・契約有効性の検証(満たさなければ静かに手動審査へ)
  if (!(await isAutoApprovable(admin, placementId, companyId))) {
    return { approved: false };
  }

呼び出し元でも、どの素材種別を渡しているかを明示するよう変更されている。

// (portal)/creatives/actions.ts(98e80a37ef, 120-132行)
// 自動化ポリシー: 規格チェック合格+枠紐付けありなら自動承認→Flow B(即時配送)。
// 対象は画像のみ(動画は尺をサーバー検証できない)。枠の販売状態・契約の有効性は
// autoApproveCreative 側で検証され、満たさなければ「審査中」のまま手動審査キューへ(fail-closed)。
if (validPlacementId) {
  const auto = await getAutomationSettings();
  if (auto.auto_approve_creatives) {
    const r = await autoApproveCreative({
      creativeId: inserted.id as string,
      placementId: validPlacementId,
      companyId: user.companyId,
      title,
      mediaType: media,
    });

isAutoApprovableがfalseを返す経路は、RPC呼び出し失敗時と同じ「審査中のまま人間の審査キューに残す」というfail-closedの扱いに合流する。既存の承認ロジック(RPCapprove_creative_and_enqueue)自体は変更していない。

再発防止

この欠陥は、自動化ポリシー機能の導入コミットと同日のコードレビューで指摘されており(コミットログの「Reviewer 指摘の修正」という表現と、導入コミットの直後に修正コミットが続く履行順がそれを示している)、根拠ファイルにはこれが実運用で悪用された記録は無い。

構造的に見れば、この欠陥が最初のレビュー前実装で生まれたのは、「所有権(company_idの一致)」と「現在の契約状態の有効性」という2つの別の問いを、1つのチェックで済ませようとしたためだ。外部キーが「かつて関連があったことの記録」として永続する一方で、「今も関連が有効か」は別のテーブル(この場合はcontracts.status/application_status/period_end)を見ないと分からない。所有権チェックと有効性チェックが同じ1つの条件式に見えるとき、片方だけを見て両方を確認したと思い込みやすい。関係が時間とともに終了し得る2つのテーブルを外部キーで結ぶときは、「紐付いているか」と「その紐付けが今も有効か」を別の検証として明示的に分けて書く必要がある。

よくある質問

Q1これは実際に顧客が不正に掲載できてしまった事例ですか?

いいえ。この欠陥は自動化ポリシー自体を導入したコミットと同日のコードレビューで指摘され(Reviewer B-1)、修正コミットは機能導入コミットの直後に入っています。根拠ファイルには、この欠陥が実際に悪用された記録はありません。見つかったのは『仕組み上、元顧客が終了済みの枠にそのまま無審査で掲載できる』という設計上の穴で、実運用に出る前に修正されています。

Q2なぜ「自社の枠である」というチェックだけでは不十分だったのですか?

placements.company_idは、申込時にその枠を予約した企業のIDをそのまま持ち続けるカラムで、契約が終了して枠の販売状態(availability)が「終了」や「空き」に戻っても自動では消えません。migration 0023の定義はon delete set nullのみで、契約終了時にcompany_idをクリアする処理はありません。そのため『company_idが自社と一致するか』という所有権チェックだけでは、現在もその企業と有効な契約関係にあるかどうかを一切保証できません。

Q3修正では何を検証するようにしましたか?

isAutoApprovableという前提検証関数を新設し、承認するたびにその場でDBへ問い合わせて、枠の販売状態が「申込済」または「掲載中」であること、かつ紐づく契約がactive・署名済・掲載期間内(period_endが今日以降)であることを確認するようにしました。どれか一つでも満たさなければ自動承認せず、人間の審査キューに残すfail-closed設計です。あわせて、動画は尺をサーバー側で検証できないため自動承認の対象外とする変更(Reviewer B-2)も同じコミットに含まれています。

確認した環境

  • kimiteras-portal: next 16.2.7 / @supabase/supabase-js ^2.106.2
  • placements.company_id列の追加: migration 0023(2026-06-10、6f62043a4631)
  • 自動承認機能の導入: 2026-06-12(b1138b8c40、ゼロタッチ運用の一括実装)
  • Reviewer指摘による修正: 同日2026-06-12(98e80a37ef、導入コミットの直後)

この記事の根拠

  • SQLファイル 24〜27行目コミット 6f62043
  • TypeScriptファイル 16〜35行目コミット b1138b8
  • TypeScriptファイル 16〜53行目コミット 98e80a3
  • TypeScriptファイル 55〜86行目コミット 98e80a3
  • TypeScriptファイル 120〜132行目コミット 98e80a3

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。