Rebounder Tech Blog

Written by the people who actually run these systems in production.

Removing a Role-Change Seam With No Callers Left

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

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

結論

A function that can rewrite privileges keeps working after its callers are gone, and a docstring guarantee resting on a caller's constraints disappears the moment that caller does.

The short version

A function that can rewrite privileges keeps working in the code after every one of its callers is gone. changeIdpUserRole, which writes an arbitrary role and school_id into Identity Platform (IdP) custom claims, remained with zero production callers after the staff role-change flow — its only caller — was removed. The safety guarantee its docstring stated, “the calling action narrows the role”, lost its meaning the moment that caller ceased to exist.

Where it started

キミテラス v2 adopts a design (ADR-026) enforcing account deactivation/reactivation (ADR-026 D1) and role change (D2) as one unit, through Identity Platform (IdP) claims updates and refresh-token revocation. The seam implementing D2 was changeIdpUserRole(uid, role, schoolId) in apps/web/lib/auth/admin-mutations.ts.

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);
}

Its docstring carried a sentence stating the scope of the privilege.

schoolId is the tenant claim (for school_admin / teacher, the UUID of their school). This seam is for changes between staff roles (school_admin ↔ teacher); it does not cross schools or grant system_admin (the calling action narrows the role).

That is, the function itself constrains neither role nor school_id. The promises “never escalate to system_admin” and “never write another school’s claim” depended wholly on the calling code narrowing the role and schoolId values.

Why

On 2026-06-10, ADR-032 (line A = shared school password) removed the concept of teacher accounts entirely. With it, the one flow calling changeIdpUserRole — the staff role change — disappeared too. The function itself was not deleted from the repository and remained exactly as it was, with zero production callers.

What changed when the caller went. The constraint the docstring promised, “limited to school_admin ↔ teacher”, exists only in the calling code. With the caller gone, this function remains in the codebase, importable, as an exported function with no scope limits that writes any role including system_admin and any school_id into the claims of any uid.

Why this was not noticed until 2026-06-18: because the unit tests imported and called the function directly, verifying it with the Admin SDK’s setCustomUserClaims / revokeRefreshTokens mocked.

describe("changeIdpUserRole (ADR-026 D2)", () => {
  it("re-issues claims ({ role, school_id }) and revokes refresh tokens (both)", async () => {
    await changeIdpUserRole(UID, "school_admin", SCHOOL_ID);
    expect(setCustomUserClaims).toHaveBeenCalledWith(UID, {
      role: "school_admin",
      school_id: SCHOOL_ID,
    });
    expect(revokeRefreshTokens).toHaveBeenCalledWith(UID);
  });
  // ...
});

These tests verify only whether the function’s own internal logic is correct (the order of re-issuing claims before revoking, not revoking on failure, and so on); whether anything in production calls it is none of their business. Even with zero callers, they keep passing green. CI being green was no proof at all that a caller of this function existed.

Fixing it

We deleted the function and its docstring, and rewrote the module docstring to describe only D1 (deactivation/reactivation).

Provides deactivation / reactivation (ADR-026 D1). The role-change seam (changeIdpUserRole, ADR-026 D2) was removed because the staff role-change flow — its only caller — disappeared when teacher accounts were removed as a concept (2026-06-10), leaving it as a privilege-escalation primitive able to write any role (system_admin included) and any school into claims with zero production callers (see the withdrawal note on ADR-026 D2). If it is needed again, regenerate it from the ADR-026 D2 design.

The four unit tests calling it directly (describe("changeIdpUserRole ...")) and a mock line in another test file went too. A note recording “implementation withdrawn” was added to D2 of ADR-026, making explicit that the decision principle (IdP as the single source of claims, enforced as one unit by re-issuing and revoking) remains in force and only the implementation was withdrawn. D1 (deactivation/reactivation), the main enforcement path, was left unchanged.

Preventing a repeat

What the sources (the D2 note in ADR-026) record goes as far as the guidance for reintroduction — “the decision principle stands; regenerate from the ADR-026 D2 design” — and no permanent detection mechanism such as automatic unused-export detection was introduced. This was found while re-reading the code in a refactoring review, and it was because the docstring explicitly stated the premise “the calling action narrows the role” that the caller’s disappearance could be read directly as the safety’s disappearance. For a function whose safety depends on its caller’s constraints, stating that dependency in the docstring is one of the few clues available for noticing when the caller is gone.

よくある質問

Q1Why did a function with no callers stay in the repo?

Four unit tests imported and called it directly, mocking the Admin SDK calls to verify only its internal logic. Those tests keep passing regardless of whether anything in production calls it, so CI going green could not detect that the caller count had reached zero.

Q2What could this function do?

For any uid, write any role including system_admin and any school_id into the IdP custom claims. The docstring said it is for changes between staff roles, crossing no schools and granting no system_admin, but that constraint depended wholly on the calling code narrowing the values.

Q3Why was it not noticed until 2026-06-18?

Its only caller, the staff role-change flow, disappeared at the same time as ADR-032 (2026-06-10) removed the concept of teacher accounts entirely. The function was not deleted from the repository and remained with zero production callers until a refactoring review found it.

Q4How was it removed?

We deleted the function and its docstring, and updated the module docstring to describe only deactivation/reactivation (D1). The four unit tests calling it directly and a mock line in another file went too, and a note recording the implementation's withdrawal was added to D2 of ADR-026.

確認した環境

  • Next.js 16.2.6 / firebase-admin 13.10.0
  • Its only caller was removed on 2026-06-10; the function was removed on 2026-06-18

この記事の根拠

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

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