GitHub App 基盤 仕様書
ステータス: Draft / 作成日: 2026-05-27 PR #9a — 依存: なし(
projects+usersテーブルのみ使用)タスクコア(PR #1)と並行実装可。タスクリンク機能は PR #9b で追加する。
1. 概要
GitHub Apps(個人 OAuth トークンではない)を使い、プロジェクトと GitHub リポジトリを接続する基盤。
インストールベースのため token が失効しにくく、org 単位での権限管理ができる。
本 PR の責務:
- GitHub App インストール・コールバック処理
- Installation Access Token の取得・暗号化保存・自動更新
- GitHub Webhook 受信エンドポイント(署名検証のみ。タスクリンク処理は PR #9b)
2. データモデル
github_integrations
3. マイグレーション
CREATE TABLE github_integrations (
id UUID PRIMARY KEY,
project_id UUID NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
installation_id BIGINT NOT NULL,
repo_owner VARCHAR NOT NULL,
repo_name VARCHAR NOT NULL,
access_token_enc TEXT NOT NULL,
token_expires_at TIMESTAMPTZ NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
4. GitHub App セットアップフロー
1. テナントオーナーが設定画面の「GitHub と連携」をクリック
→ フロントが GET /v1/tenants/{tid}/projects/{pid}/github/install を呼ぶ
2. バックエンドが CSRF 対策のための state を生成・保存:
- state = 32 バイト乱数(URL-safe base64)
- Redis に state をキー、{ tenant_id, project_id, user_id } を値として保存(TTL: 10 分)
→ GitHub App インストール URL へリダイレクト:
https://github.com/apps/{APP_NAME}/installations/new
?state={state}
3. ユーザーがリポジトリを選択してインストール後、GitHub がコールバックへリダイレクト:
GET /v1/github/callback?installation_id=12345&state={state}&code={code}
(code は GitHub App 側で「Request user authorization (OAuth) during installation」を
有効にしていると付く。無効だと 6 の所有者確認ができず連携が止まる)
4. バックエンドが state を検証:
- Redis から state を取得(存在しない・期限切れ → 400 Bad Request)
- Redis から削除(再利用防止)
- state に紐付く { tenant_id, project_id, user_id } を復元
5. セッションユーザーが state の user_id と一致するか確認(不一致 → 403)
6. installation の所有者を確認する(新しく束縛するときだけ)
- code を GitHub App の Client ID / Client secret でユーザーアクセストークンに交換し、
GET /user/installations に installation_id が含まれるかを見る
- 含まれない・code が無効 → ?...&github_error=installation_forbidden で戻す
(対処は自分がアクセスできるアカウント / Organization へ入れ直すこと)
- code そのものが無い → ?...&github_error=installation_authorization_required で戻す
(対処は App 設定でユーザー認可を有効にすること。入れ直しても直らないので理由を分ける)
- 交換や照会が通信レベルで失敗したときは ?...&github_error=github_unavailable で戻す
- 束縛先が既に決まっているとき(state に載せた連携済みの ID / このプロジェクトの
選択待ちの控え / 同じテナントが使用中のインストール)は省く。この所有者確認の導入後に
作られた束縛は、どれも確認済みの callback からしか生まれないため。導入前からある
`github_integrations` 行は鮮度チェックだけで作られており、この不変条件の対象外なので、
必要に応じて既存行を別途監査する。ここで code を要求すると、リポジトリを足して戻る・
選び直すといった復旧の動線が、GitHub が code を付け直すかどうかに左右される
7. installation_id を検証して Installation Access Token を取得
- 検証に落ちたとき(state に束縛された ID と不一致、選択待ちの控えも切れた古い
インストール)は、設定画面へ ?...&github_error=installation_rejected で戻す
(対処は GitHub 側での入れ直し)
- GitHub 側から消えている(404 / 410)ときは ?...&github_error=installation_gone で戻す
(アンインストールする対象が無いので、別のアカウント・組織への追加を案内する)
- GitHub API 自体が応答しないときは ?...&github_error=github_unavailable で戻す
8. installation が見えるリポジトリを列挙する
- 0 件 → 連携先を選びようがないので、設定画面へ ?...&github_error=no_repositories で戻す
- 1 件 → そのまま連携先に決める(9 へ)
- 複数 → 連携レコードは作らず、選択トークン(32 バイト乱数)を Redis に保存
(値は { tenant_id, project_id, user_id, installation_id }、TTL: 10 分)
→ 設定画面へ ?...#github_select={token} 付きでリダイレクトし、選択 UI に任せる
(フラグメントに載せるのは、クエリだと frontend / CDN のアクセスログにトークンが
残るため。フラグメントは次の HTTP リクエストに乗らない。ブラウザ履歴には一時的に
残るが、frontend が読んだ直後に URL から落とす)
9. トークンを AES-256-GCM で暗号化して DB に保存(project_id で github_integrations を UPSERT)
10. 設定完了 → フロントの設定画面へリダイレクト
同じインストールを複数プロジェクトで使う場合: GitHub App のインストールはアカウント単位で 1 つなので、「同じ org の別リポジトリを別プロジェクトへ」は同一
installation_idを複数のgithub_integrations行が持つ形になる。このため callback は、同じテナントの別プロジェクトが 既に使っているインストールなら鮮度チェックを免除する(そのテナントが正規に入れたものだと 分かっているため)。連携解除も、他のプロジェクトがまだ使っている間は GitHub 側の アンインストールを行わない。この共有判定が連携・解除の同時実行でずれないよう、github_integrationsを変更する経路(UPSERT と解除)はすべてinstallation_idを キーにした Postgres の transaction-scoped advisory lock で直列化する。別の installation へ付け替える UPSERT は新旧両方の ID を昇順にロックする。
既存インストールの再利用(callback を経ない経路): 同じ Organization を別プロジェクトで 使うとき、GitHub のインストール画面へ進むと既存アプリの管理画面になり、Task の callback に 戻らないことがある(戻り先は App 設定の Redirect on update などに依存する)。そこで 「連携する」は、まず
GET /github/installationsで同じテナントの連携行から候補を出す。 候補はinstallation_id単位で重複排除し、最初の連携行を代表(source_integration_id)にする。 表示名は代表行のrepo_owner。他テナントの連携や App 全体のインストール一覧は出さない。 候補を選ぶとPOST /github/reuseがその行を再取得してテナント所属を確かめ、サーバー側でinstallation_idを解決する(リクエストのinstallation_idは受け取らない。信頼する範囲は callback の「同じテナントが使用中のインストール」と同じ)。GitHub で Installation Access Token を 取れることを確かめてから、callback と同じ選択トークン(TTL 10 分)を発行し、以降はGET /github/repositories→POST /github/connectの既存経路に乗せる。使用中の連携から来て いるので、鮮度チェックや新しいcodeは求めない。この経路では 1 件でも自動接続せず選ばせる。 GitHub 側で消えていれば(404/410)410、一時障害は5xxで再試行させ、再利用元の連携行は消さない。 候補を取れたことだけでは認可済みとせず、POSTのたびにテナントオーナーと所属を確かめる。
確定時にロックの内側で GitHub を確かめる理由:
POST /github/connect(と callback の自動接続)の UPSERT は、advisory lock を取ったあとに Installation Access Token を取り直し、それを保存する。 検証をロックの外で済ませると、その後に「最後の参照の解除」が同じロック下でアンインストールと 行削除を終え、消えた installation を指す連携行だけが残る。ロック内で取り直せば、解除が先なら GitHub が 404 を返して400で止まり、一時障害なら5xxのまま選択トークンを戻して再試行できる。 callback の自動接続でこの取り直しに失敗したときは、素のエラーを返さず設定画面へ戻す。消えていればinstallation_gone、一時障害なら installation を控えたうえでgithub_unavailable(控えがあるので、state の鮮度が切れたあとの再試行も弾かれない)。
なぜ選択トークンが必要か: GitHub App のインストールはアカウント単位で 1 つしか作れないため、 1 org に複数リポジトリを含むインストールが普通に発生する。
installation_idをリクエストで 直接受け取ると、他人のインストール ID を送るだけで自分のプロジェクトへ紐付けられてしまうので、 installation_id は選択トークン側(Redis)に束縛し、リクエストでは受け取らない。 送られてきたrepo_owner/repo_nameが、その installation の可視範囲に含まれることも検証する。
選択トークンをどう送るか: 一覧取得は
X-Github-Select-Tokenヘッダーで受け取る。 callback がフラグメントで渡しているのは、クエリだと frontend / CDN のアクセスログと Referer に 残るためで、そのトークンをクエリに載せ直すと backend とその手前のプロキシのアクセスログに残り、 手当てが最後まで効かない。確定(POST /github/connect)はリクエストボディで受け取る。
フラグメントを frontend のどこで読むか: Vike の client entry(
src/pages/+client.ts)で 読み、sessionStorageへ退避する。フラグメントはサーバーへ送られずpageContextにも現れないため、 ハイドレーション時の history 書き換えで URL から落ちる。連携セクションは設定ページが テナント / プロジェクトの ID を API で解決し終わるまでマウントされないので、 セクション自身のonMountedで読むと間に合わず、選択 UI が出ないまま「連携する」ボタンだけが 残る(本番で発生)。受け取りはsrc/lib/github-select-token.tsに寄せてある。
選択トークンをいつ消すか: 一覧取得(
GET /github/repositories)では消さない(選び直せるように)。 確定(POST /github/connect)では、検証をすべて通した後・DB を更新する直前に、Redis 上で 原子的に取り上げる(GET + PTTL + DEL)。同じトークンでのPOSTが同時に来ても、取れた 1 本だけが DB 更新へ進むので、連携先が後勝ちで入れ替わらない。DB 更新に失敗したときは、取り上げた時点の 残り TTL のまま書き戻して選び直せるようにする(有効期限は延ばさない)。書き戻し自体に失敗した ときはトークンが失われるが、選択待ちのinstallation_idは控えたままなので、インストールを やり直せば選択画面に戻れる。
なぜ state が必要か: GitHub App インストールの callback は公開エンドポイントのため、攻撃者が細工した
installation_idを含む URL をターゲットユーザーに踏ませて誤連携させる CSRF が成立しうる。state を Redis で管理することで、正規の開始フローを経たリクエストのみを受け付ける。
GET /v1/tenants/{tid}/projects/{pid}/github/install — インストール開始(セッション必須、テナントオーナー限定):
202 Accepted → Location: https://github.com/apps/{APP_NAME}/installations/new?state=xxx
必要な GitHub App 権限:
5. Token 管理
Installation Access Token の有効期限は 1 時間。期限切れを検出したら GitHub API で再取得して DB を上書きする。
API 呼び出し前:
token_expires_at < now() + 5min → GitHub API で再取得 → DB 更新 → 使用
それ以外 → DB の暗号化済みトークンを復号して使用
トークンは常に AES-256-GCM で暗号化して保存し、API レスポンスでは一切返さない。
6. Webhook 受信
POST /v1/github/webhook はタスク機能に依存しない公開エンドポイント。
本 PR での処理(PR #9a):
X-Hub-Signature-256ヘッダーを HMAC-SHA256 で検証 → 不一致は即403X-GitHub-Eventヘッダーでイベント種別を識別installation_idでgithub_integrationsを検索し対応プロジェクトを特定- イベントを Apalis ジョブキューに積む(タスクリンク処理は PR #9b で実装するため、本 PR では受信確認のみ行い即 200 を返す)
PR #9b で追加する処理:
push/pull_request/createイベントに対するタスクリンク生成ロジック
7. API
POST /github/integration リクエスト:
{
"installation_id": 12345,
"repo_owner": "myorg",
"repo_name": "myapp"
}
GET /github/repositories リクエスト(選択トークンはヘッダー):
X-Github-Select-Token: xxx
レスポンス:
{
"repositories": [
{ "owner": "myorg", "name": "myapp" },
{ "owner": "myorg", "name": "backend" }
]
}
POST /github/connect リクエスト:
{
"select_token": "xxx",
"repo_owner": "myorg",
"repo_name": "myapp"
}
GET /github/installations レスポンス(0 件は空配列):
{
"installations": [
{ "source_integration_id": "5b7c…", "account_login": "myorg" }
]
}
POST /github/reuse リクエスト / レスポンス(403 非オーナー、404 別テナント・存在しない再利用元、
410 GitHub 側で削除済み。いずれも選択トークンを発行しない):
{ "source_integration_id": "5b7c…" }
{ "select_token": "xxx" }
GET /github/integration レスポンス:
{
"connected": true,
"repo_owner": "myorg",
"repo_name": "myapp",
"connected_at": "2026-05-27T10:00:00Z"
}
アップグレード時の作業
この機能を既に有効にしている環境では、次の 2 つを済ませないとバックエンドが起動しない (設定の検証で落ちる)。
-
GitHub App の設定で Request user authorization (OAuth) during installation を有効にする。 これが無効だと callback に
codeが付かず、新規の連携がinstallation_authorization_requiredで止まる -
GITHUB_APP_CLIENT_ID/GITHUB_APP_CLIENT_SECRET(App 設定ページの Client ID / Client secret)を環境変数に足す
8. セキュリティ
9. フロントエンド
GitHub 連携の UI はプロジェクト設定の「連携」セクションにある。専用ページは作っていない。
/{tenant_slug}/projects/{project_key}/settings?section=integrations
┌──────────────────────────────────────────────────────────────┐
│ 連携 │
├──────────────────────────────────────────────────────────────┤
│ GitHub │
│ myorg/myapp を連携中(2026年7月1日 から) │
│ [Issue を取り込む] [連携を解除] │
└──────────────────────────────────────────────────────────────┘
「Issue を取り込む」は POST /github/import を呼ぶだけで、取り込み自体はジョブが非同期に進める。
そのため完了は待たず「取り込みを開始しました」とだけ伝える。以降の Issue の変更は webhook で反映される。
取り込み・webhook による新規作成は「GitHub Issueからタスクを作成しました」、 既存タスクへの変更反映は「GitHub Issueからタスクを同期しました」とアクティビティに表示し、 リポジトリ名と Issue 番号を併記する。システム操作としてタスク更新と同時に記録し、 同じ内容や古い通知では追加しない(イベント定義)。
202 が返るのはジョブを積んだ時点なので、連打するとリポジトリの Issue 全ページ取得が押した回数だけ積まれる (タスクは冪等に処理されるが、GitHub API のレート制限とワーカー時間は消費される)。 そのため開始に成功したあとは 60 秒間ボタンを塞ぎ、ラベルを「取り込み中…」にする。開始に失敗したときは塞がず、すぐ再試行できる。 連携が切れたとき・連携先リポジトリが変わったときは、取り込みの結果表示とこの待ち時間をリセットする (別タブや他のユーザーが解除した場合も含めるため、解除操作ではなく連携状態の変化で判定する)。
画面の待ち時間はブラウザのローカル状態にすぎず、リロード・画面の再訪・別タブでは消える。
実際に重複を止めるのはサーバー側で、POST /github/import はプロジェクト単位のロック
(Redis の github:import:lock:{project_id})を取れたときだけジョブを積む。取れなかったときは
ジョブを積まずに 409 Conflict を返し、画面は「取り込みは既に実行中です」と伝える。
ロックの値には取得ごとのランダムトークンを保存し、ジョブが取り込みを終えた時点でトークンが一致するときだけ
成否によらず解放する。これにより TTL を超えた古いジョブが後続のロックを消さない。ワーカーが落ちて
解放されなかった場合に備えて TTL 15 分で必ず明け、連携解除時には再連携を妨げないよう現在のロックを破棄する。
インストール後に ?section=integrations#github_select={token} で戻ってきた場合は、
GET /github/repositories(トークンは X-Github-Select-Token ヘッダーで送る)の結果を並べて
1 件選ばせ、POST /github/connect で確定する。数百リポジトリの org でも選べるよう、一覧の上に
owner/name の部分一致で絞り込む入力欄を置く(クライアント側のみ)。
選ばずに離脱したときはトークンが TTL で切れ、未連携の表示に戻る。
このとき GitHub 側にはインストールだけが残るので、選択待ちの installation_id は
github_pending_install:{project_id}(Redis, TTL 24 時間)に控えておき、
callback がこの ID と一致する installation を受け取ったときだけ、新規インストール扱いの
鮮度チェック(state の TTL 内に作成されたもののみ)を免除する。控えておかないと、選択を
放棄したインストールへは二度と戻れない(GitHub から App を消すまで詰む)。
一致しなければ通常の新規インストール判定に戻すので、別のインストールへ乗り換える動線は
塞がない。連携が確定したときと連携解除のときは消す。消すときは「控えている ID が
指定のものと一致するときだけ削除」を Redis 上で 1 操作として行う(比較してから削除するまでの間に
別のフローが新しい installation を控えると、古い処理が新しい控えを消してしまうため)。
候補を選ぶと POST /github/reuse で選択トークンを受け取り、上と同じ選択 UI に進む(GitHub へは遷移しない)。
トークンは callback 経由のものと同じくタブ内(sessionStorage)に保持する。
選択の途中で「別の GitHub アカウント・組織を追加」から callback で戻ったときは、戻ってきたトークンで
それまでの選択を上書きする(古い方を優先すると新しいアカウント・組織を選べず、古い方が期限切れなら
新しい方まで一緒に捨ててしまう)。
-
選択トークンが切れたら(400)、出ている候補から同じものを選び直せば
POST /github/reuseで再開できる。 入れ直しは要らない。ページを再訪した場合も「連携する」から候補を選べば再開できる -
再利用元の連携が解除されていたら(404)候補を取り直す。GitHub 側で削除済み(410)なら、 別のアカウント・組織の追加を案内する。一時障害(5xx)は候補とトークンを残して押し直させる
-
リポジトリが 0 件、または目的のものが無いときは「GitHub でアクセス対象を追加」と「再読み込み」を出す。 前者は
GET /github/installの URL を別タブで開き(元のタブの選択状態は残す)、対象アカウント・組織の Configure からリポジトリを追加・保存してもらう。GitHub 側から callback は来ないので、元のタブの 「再読み込み」で最新の一覧を取り直して続ける(App 設定の Redirect on update には依存しない) -
選ばずに離れた場合は未連携のまま。接続できたら連携状態を取り直し、候補・選択 UI・トークン・エラー表示を片付ける