本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

ある組織で運用している複数のデータベースサイトについて、データ更新を担当する非エンジニアの方が自分で再ビルドや再インデックスを実行できるよう、統合の管理コンソールを構築しました。

GitHubアカウントもVercelアカウントも持たない作業者が、Webブラウザだけで操作できるようにするのが目的です。本記事ではその構成、特に GitHub への認証で Personal Access Token (PAT) ではなく GitHub App を選んだ理由 と、フロント側を Cloudflare Access (Zero Trust) で守ることでアプリ側の認証コードを省いた設計を整理します。

構成

[作業者]
  ↓ メール認証(Cloudflare Access / Zero Trust)
[admin.example.com]   ← Next.js on Cloudflare Pages
  ↓ GitHub App (installation token)
[GitHub Actions: workflow_dispatch]
[各リポジトリの admin.yml が
   npm run es:sync などを実行]
  • ホスティング: Cloudflare Pages(@opennextjs/cloudflare
  • 認証: Cloudflare Access(メールワンタイムパスワード)
  • 認可(vs GitHub): GitHub App の Installation Token
  • 実行基盤: GitHub Actions の workflow_dispatch

各サイトにあらかじめ用意してある npm run es:sync 等のスクリプトをそのまま GitHub Actions から呼ぶ形にしたため、既存スクリプトには手を入れていません。

認証の選択肢を整理する

GitHub に対してプログラムから操作するための認証方式は、大きく3種類あります。今回のように「サーバが組織のリポジトリに対して workflow_dispatch を叩く」用途で、それぞれを比較したのが下表です。

Personal Access Token (PAT)OAuth AppGitHub App
所有者個人ユーザーアプリ(個人または組織)アプリ(組織所有可)
認証主体トークン保持者 = ユーザーエンドユーザーが認可した範囲アプリのインストール先リポジトリ
トークン寿命最大1年(手動再発行)/無期限も選択可既定で無期限(8時間 + refresh token運用にも設定可)1時間(自動更新)
退職者対応個人を消すと連動して止まる個別ユーザーをrevoke影響なし
権限粒度機能 × リポジトリOAuthスコープ単位機能 × リポジトリ
監査ログ限定的ありあり(アプリの動作を完全追跡)
主な用途個人スクリプト、CIのSecretユーザーが自分のリポを操作するアプリサーバが組織リポを操作する基盤

PAT が適しているケース

PATは個人アカウントに紐づくトークンで、最も手軽に発行できます。

  • 自分のターミナルで使う一時的なスクリプト
  • 短期プロトタイプ
  • 個人プロジェクトのCI

「自分のターミナルの代わり」と捉えると分かりやすく、組織が長期運用する基盤には基本的に向きません。トークンの所有者が組織を抜けると、そのPATを使っている全システムが連動して止まります。

なお、GitHub の組織設定にも「Fine-grained personal access tokens」という画面がありますが、ここは 組織にアクセスするユーザーPATを管理者が承認・閲覧する画面 であり、組織として PAT を発行する場所ではありません。PAT は仕様上、必ず個人ユーザーに紐づきます。

OAuth App が適しているケース

OAuth Appは「ユーザーがブラウザで認可フローを通り、自分のGitHubアカウント権限をアプリに委譲する」モデルです。

  • ユーザーごとに自分のリポジトリを操作させるサービス(CI/CDサービス、コードレビューツール等)
  • ユーザー単位でスコープを分離したい場合

エンドユーザーの認可が前提なので、「サーバが裏で勝手に動く」用途には向きません。誰の代理で動いているのかが曖昧になります。

GitHub App が適しているケース

GitHub Appは「アプリ自身として動く」モデルです。

  • 組織が長期運用する管理基盤、CI、ボット
  • 特定ユーザーの代理ではなく、アプリとしてリポジトリを操作したい
  • 最小権限を厳密に定義したい
  • 監査ログでアプリの動作を追跡したい

複数の担当者・複数のリポジトリ・複数年運用という条件では、実質的にこの一択でした。

OAuth App と GitHub App の違い

両者は混同されがちですが、フローを並べると違いが明確になります。

OAuth App のフロー

[ユーザー] → [アプリ]: 「あなたのGitHub権限でリポジトリXを操作したい」
[ユーザー] → [GitHub]: 認可ページで「許可」を押す
[GitHub] → [アプリ]: ユーザー権限のtokenを発行
[アプリ] → [GitHub API]: 「そのユーザーとして」操作する

操作はユーザー権限で行われ、誰の許可のもとに動いているかが明示的です。

GitHub App のフロー

[組織管理者] → [GitHub]: アプリをリポジトリX, Yにインストール
[アプリ] → 自身が持つ秘密鍵で JWT を署名
[アプリ] → [GitHub]: JWT を渡して installation token を取得
[アプリ] → [GitHub API]: 「アプリとして」リポジトリX, Yに対して操作する

操作はアプリ自身の権限で行われ、ユーザーは介在しません。

両者の違いは「アプリは誰として動くか」の一点に集約されます。今回のように管理コンソールが裏で workflow_dispatch を叩く用途では、ユーザーの代理である必然性がないためGitHub Appが自然な選択になります。

GitHub App の作成と権限設定

GitHub App は組織設定または個人のDeveloper settingsから作成できます。今回は次のように設定しました。

項目
GitHub App name<your-org>-admin(GitHub全体でユニーク必須)
Homepage URLhttps://admin.example.com
Callback URL空(ユーザーOAuthフローは使わない)
WebhookActive のチェックを外す(イベント受信不要)
Repository permissionsActions: Read and write のみ
Organization permissionsすべて No access
Installation targetOnly on this account(自組織のみ)

権限を最小化することが要点です。Actions の Read and write だけあれば workflow_dispatch の起動と実行履歴の取得が可能で、それ以上の権限(コードの書き換え、Secretの変更、Issue操作など)は付与しません。仮にトークンが漏洩しても、攻撃者にできるのは「ワークフローを起動すること」だけです。

作成後、画面下部の Generate a private key から .pem ファイルをダウンロードします。これがアプリの身分証明になります。

Private Key と Client Secret は別物

GitHub App の画面には「Private key」とは別に「Client secret」という項目もあり、混同しやすいので整理しておきます。

Private Key (.pem)Client Secret
種別非対称鍵(公開鍵暗号)対称鍵(共有秘密)
用途アプリ自身として動くための JWT 署名OAuth でユーザーを認証する際のコード交換
通信での扱い絶対に送信しない(手元で署名し公開鍵を相手が検証)サーバ間で送る(HTTPS下)
今回の用途で必須不要

OAuthユーザー認証フローを使わないなら Client secret は生成しなくて構いません。

App ID と Installation ID の違い

もうひとつ混同しやすいのが App ID と Installation ID です。

App IDInstallation ID
意味アプリそのものの識別子アプリが特定の組織/ユーザーにインストールされた状態の識別子
ユニーク性アプリ1つに1つインストール1回ごとに1つ
用途JWT発行時に「どのアプリか」を示すInstallation Token取得時に「どこにインストールされた状態か」を示す

同じアプリを複数の組織にインストールすると、App IDは1つでもInstallation IDは複数できます。

Installation Token の取得

GitHub App が API を叩く際は、毎回 JWT を署名して installation token を発行します。@octokit/auth-app を使えば自動化できます。

import { createAppAuth } from '@octokit/auth-app'

const auth = createAppAuth({
  appId: process.env.GITHUB_APP_ID!,
  privateKey: process.env.GITHUB_APP_PRIVATE_KEY!,
  installationId: process.env.GITHUB_INSTALLATION_ID!,
})

const { token } = await auth({ type: 'installation' })
// この token を Authorization: Bearer に載せて Actions API を叩く

token は1時間で失効します。漏洩しても被害は1時間で消える設計です。Cloudflare 上で動かす場合は秘密鍵を Secret 種別の環境変数に入れますが、PEM の改行が扱いづらければ Base64 化しておくと無難です。

Cloudflare Access による前段保護

作業者の認証は アプリ側で実装せず、Cloudflare Access に任せます

Zero Trust ダッシュボードで Self-hosted Application としてドメインを登録し、Identity Provider に One-time PIN(メールワンタイム)を選択、ポリシーで許可するメールアドレスを列挙するだけです。

Application: admin.example.com
Identity Provider: One-time PIN
Policy:
  - Action: Allow
  - Selector: Emails
  - Value: <許可するメールアドレスを列挙>

これだけで、許可リストにないアクセスはCloudflareのエッジで弾かれます。アプリのコードに到達する前に認証が完了するため、Next.js 側に Basic 認証ミドルウェアを書く必要がなく、誰がいつアクセスしたかの監査ログも標準で残ります。

メリットを整理すると以下のようになります。

  • アプリ側に認証コードを書かない(メンテナンス対象が減る)
  • 共有パスワード方式と違い、退職者の削除はAccessポリシーから1人消すだけ
  • 「誰が」のログがエッジで取れる
  • 既存のIdP(Google Workspace等)があれば SSO に切り替え可能

なお Cloudflare Access が動くには、対象ドメインが Proxied(オレンジ雲) でDNS登録されている必要があります。グレー雲(DNS only)だとリクエストがエッジを経由しないため、Accessポリシーは効きません。

各リポジトリ側のワークフロー

各サイトのリポジトリに .github/workflows/admin.yml を1本配置します。中身は既存スクリプトを呼ぶだけです。

name: Admin - ES sync
on:
  workflow_dispatch:
    inputs:
      dry_run:
        description: '書き込まずに動作確認のみ'
        type: boolean
        default: false

jobs:
  sync:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - env:
          ES_URL: ${{ secrets.ES_URL }}
          ES_USERNAME: ${{ secrets.ES_USERNAME }}
          ES_PASSWORD: ${{ secrets.ES_PASSWORD }}
          GOOGLE_CREDENTIALS_BASE64: ${{ secrets.GOOGLE_CREDENTIALS_BASE64 }}
        run: |
          if [ "${{ inputs.dry_run }}" = "true" ]; then
            npm run es:sync:dry
          else
            npm run es:sync
          fi

ESや Google Spreadsheet の認証情報は 各リポジトリの Secrets に分離して持ちます。管理コンソール側にあるのは GitHub App の鍵だけ。各サイトのシークレットが管理コンソールに集約されない という分離が、運用上の安全性を高めます。

管理コンソールのフロント側

Next.js のサイト設定はファイル1つに集約しています。新サイトの追加はここに1エントリ書くだけで、UIカードと API ルートが連動します。

// src/lib/sites.ts
export const SITES: SiteDef[] = [
  {
    id: 'site-a',
    url: 'https://site-a.example.com/',
    action: {
      type: 'github-workflow',
      repoEnvKey: 'SITE_A_REPO',
      defaultRepo: 'your-org/site-a',
      inputs: [{ name: 'dry_run', type: 'boolean', default: false }],
    },
  },
  // 他のサイトを同形で追加
]

API ルートは siteId をパスから受け取り、対応するリポジトリの workflow を起動します。

// src/app/api/trigger/[siteId]/route.ts
export async function POST(req: NextRequest, ctx) {
  const { siteId } = await ctx.params
  const site = getSite(siteId)
  if (site?.action.type === 'github-workflow') {
    await dispatchWorkflow(repo, 'admin.yml', 'main', inputs)
    const { htmlUrl } = await fetchRunAfterDispatch(repo, 'admin.yml')
    return NextResponse.json({ ok: true, htmlUrl })
  }
}

fetchRunAfterDispatchworkflow_dispatch を叩いた直後に GitHub Actions API から最新の実行URLを取得するヘルパーです。レスポンスの htmlUrl をUIに渡し、作業者が「実行ログを見る」リンクから直接 GitHub Actions の画面に飛べるようにしています。

設計上の収穫

運用してみて、この組み合わせの効きどころは次の点でした。

  • 作業者の体験が極めてシンプル:URL → メール認証 → ボタン1つ。GitHubもVercelも知らなくてよい
  • 退職耐性:誰かが組織を抜けても、GitHub Appそのものは生きたまま
  • 権限の最小化:管理コンソールが触れるのは Actions API のみ。コードの書き換えやSecret変更はできない
  • 監査の二重化:「誰がコンソールにログインしたか」(CF Access)と「何の操作が走ったか」(GitHub Actions)が独立して記録される
  • 拡張性:新しいサイトを追加するときは、設定ファイルに1行 + 該当リポジトリにyaml1本配置で完了

選び方の整理

最後に、似たようなアプリを作ろうとしている方のために、認証方式の選び方を整理しておきます。

書こうとしているコードの性質は──

├── 自分のターミナルで一瞬動かすスクリプト
│     → PAT で十分
├── ユーザーがブラウザで「自分のGitHubに繋ぐ」アプリ
│     → OAuth App
└── サーバが組織のリポを長期間操作する基盤
      → GitHub App

PATは個人の延長で考えると最初に手が伸びる選択肢ですが、組織で動くインフラを作る瞬間からは別の道具が必要になります。複数サイトを少人数でまとめて運用する用途では、GitHub App + Cloudflare Access の組み合わせが、書くコード量を最小化しながら堅牢な基盤を与えてくれました。

参考リンク