Rebounder Tech Blog

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

唯一の呼出し元が消えたロール変更 seam を、それ自体が攻撃面として撤去する

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

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

結論

呼出し元が消えても、権限を書き換えられる関数はコードの中で機能し続ける。docstringが書く安全保証が呼出し元の制約に依存している以上、呼出し元が消えた瞬間にその保証も同時に消えている。

結論

呼出し元がすべて消えたあとも、権限を書き換えられる関数はコードの中で機能し続ける。 IdP (Identity Platform) のカスタムclaimへ任意のroleとschool_idを書き込めるchangeIdpUserRoleは、唯一の呼出し元だった教職員ロール変更フローが撤去されたあとも、本番の呼出し元ゼロのまま残存していた。docstringが書いていた安全保証「呼出側actionがroleを限定する」は、その呼出側が存在しなくなった時点で意味を失っていた。

発端

キミテラスv2は、アカウントの無効化・再有効化(ADR-026 D1)とロール変更(D2)を、Identity Platform(IdP)のclaims更新とリフレッシュトークン失効で一体的にエンフォースする設計(ADR-026)を採用している。D2を実装するseamがapps/web/lib/auth/admin-mutations.tschangeIdpUserRole(uid, role, schoolId)だった。

export async function changeIdpUserRole(
  uid: string,
  role: TenantRole,
  schoolId: string,
): Promise<void> {
  const auth = getAdminAuth();
  await auth.setCustomUserClaims(uid, { role, school_id: schoolId });
  await auth.revokeRefreshTokens(uid);
}

このdocstringには、権限の範囲を明記した一文がある。

schoolIdはテナントclaim(school_admin / teacherは所属校UUID)。本seamは教職員ロール間の変更(school_admin↔teacher)に使い、school横断やsystem_admin化はしない(呼出側actionがroleを限定)。

つまり関数自体はroleにもschool_idにも制限を持たない。「system_adminへは昇格させない」「他校のclaimは書かない」という約束は、丸ごと呼出し元のコードがroleとschoolIdの値を絞っていることに依存していた。

原因

2026-06-10、ADR-032(系統A=学校共通パスワード)によって教員アカウントという概念そのものが撤去された。これに伴い、changeIdpUserRoleを呼んでいた唯一のフロー(教職員ロール変更)も同時に消滅した。しかし関数自体はリポジトリから削除されず、本番の呼出し元がゼロになった状態でそのまま残った。

呼出し元が無くなったことで何が変わったか。docstringが約束していた「school_admin↔teacherに限定する」という制約は、呼出し元のコードにしか存在しない。呼出し元が消えれば、この関数は任意のuidに対してsystem_adminを含む任意のroleと任意のschool_idをclaimへ書ける、範囲制限の無いエクスポート関数として、import可能な状態のままコードベースに残る。

なぜこれが2026-06-18まで気づかれなかったのか。単体テストがこの関数を直接importして呼び出し、Admin SDKのsetCustomUserClaims / revokeRefreshTokensをモックして検証する形になっていたためだ。

describe("changeIdpUserRole (ADR-026 D2)", () => {
  it("claims を再付与し ({ role, school_id})、リフレッシュトークンを失効する (両方)", async () => {
    await changeIdpUserRole(UID, "school_admin", SCHOOL_ID);
    expect(setCustomUserClaims).toHaveBeenCalledWith(UID, {
      role: "school_admin",
      school_id: SCHOOL_ID,
    });
    expect(revokeRefreshTokens).toHaveBeenCalledWith(UID);
  });
  // ...
});

このテストは関数自身の内部ロジック(claimsを再付与してからrevokeする順序、失敗時にrevokeしないこと等)が正しいかどうかしか検証しておらず、「本番のどこかがこの関数を呼んでいるか」は関知しない。呼出し元がゼロになっても、このテストはグリーンのまま通り続ける。CIが緑であることは、この関数の呼出し元が実在することの証明には全くなっていなかった。

直し方

関数とそのdocstringを削除した。モジュールdocstringも、D1(無効化/再有効化)のみを提供する内容に書き換えている。

無効化 / 再有効化 (ADR-026 D1) を提供する。ロール変更 (ADR-026 D2) の seam (changeIdpUserRole) は教員アカウント概念の撤去 (2026-06-10) で唯一の呼出側だった教職員ロール変更フローが消滅し、本番呼出元ゼロのまま任意 role (system_admin 含む) / 任意 school を claim に書ける権限昇格プリミティブとして残っていたため撤去した (ADR-026 D2 の撤回注記参照)。再導入が必要になったら ADR-026 D2 の設計から再生する。

直接呼び出していた単体テスト4件(describe("changeIdpUserRole ..."))と、別のテストファイルにあったモック用の1行も合わせて削除した。設計原則を記録するADR-026のD2にも「実装撤回」の注記を追加し、決定原則(IdPをclaimsの単一ソースとし、再付与とrevokeで一体エンフォースする)自体は有効なまま、実装だけを撤回したことを明記している。エンフォースの主経路であるD1(無効化/再有効化)は変更していない。

再発防止

根拠(ADR-026のD2注記)に書かれているのは「決定原則は有効。再導入時はADR-026 D2の設計から再生する」という再導入時の指針までで、未使用exportを自動検出する仕組みのような恒久的な検知手段は導入されていない。今回見つかったのはリファクタリングのレビューでコードを読み直したときで、docstringが「呼出側actionがroleを限定する」という前提を明記していたからこそ、呼出側の消滅がそのまま安全性の消滅だと判断できた。呼出し元の制約に安全性を依存させる関数は、その依存自体をdocstringに明記しておくことが、呼出し元が消えたときに気づくための数少ない手がかりになる。

よくある質問

Q1なぜ呼出し元の無い関数がそのまま残っていたのですか?

この関数を直接importして呼び出す単体テストが4件あり、Admin SDKの呼び出し(setCustomUserClaims / revokeRefreshTokens)をモックして関数自身の内部ロジックだけを検証していました。本番のどこかがこの関数を呼んでいるかどうかとは無関係にテストは通り続けるため、呼出し元がゼロになったこと自体はCIのグリーンでは検知できませんでした。

Q2この関数はどんな操作ができましたか?

任意のuidに対して、system_adminを含む任意のroleと任意のschool_idをIdPのカスタムclaimへ書き込めました。docstringには「教職員ロール間の変更に使い、school横断やsystem_admin化はしない」と書かれていましたが、これは呼出し元のコードがroleとschoolIdの値を絞っていることに丸ごと依存する制約で、関数自体には範囲の制限がありませんでした。

Q3なぜ2026-06-18まで気づかれなかったのですか?

唯一の呼出し元だった教職員ロール変更フローは、教員アカウントという概念そのものを撤去したADR-032(2026-06-10)によって同時に消えました。関数はリポジトリから削除されず、本番呼出元ゼロの状態のままリファクタリングのレビューで見つかるまで残っていました。

Q4この関数はどのように撤去しましたか?

関数とそのdocstringを削除し、モジュールdocstringも無効化/再有効化(D1)のみを提供する内容に更新しました。直接呼び出していた単体テスト4件と、別ファイルにあったモック用の1行も合わせて削除し、設計原則を記録するADR-026のD2に実装撤回の注記を追加しています。ロール変更という決定原則自体は有効なままで、再導入する場合はこのADRの設計から作り直します。

確認した環境

  • Next.js 16.2.6 / firebase-admin 13.10.0
  • 2026-06-10 に唯一の呼出し元が撤去され、2026-06-18 に関数を撤去

この記事の根拠

  • TypeScriptファイル 52〜75行目コミット 02d0732
  • TypeScriptファイル 6〜22行目コミット b924e45
  • TypeScriptファイル 117〜149行目コミット 02d0732
  • ドキュメントファイル 49〜51行目コミット b924e45

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