GitHub App 基盤 仕様書

GitHub Apps インストール・認証情報管理・Webhook 受信インフラ

ステータス: 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

カラム 型 制約 説明
id UUID PK  
project_id UUID NOT NULL, UNIQUE, FK→projects CASCADE プロジェクト 1 つにつき 1 リポジトリ
installation_id BIGINT NOT NULL GitHub App のインストール ID
repo_owner VARCHAR NOT NULL 例: myorg
repo_name VARCHAR NOT NULL 例: myapp
access_token_enc TEXT NOT NULL AES-256-GCM で暗号化した Installation Access Token
token_expires_at TIMESTAMPTZ NOT NULL 有効期限(1 時間)。期限切れ時に自動再取得
created_by UUID NOT NULL, FK→users  
created_at TIMESTAMPTZ NOT NULL DEFAULT now()  

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 権限:

権限 レベル 用途
Contents Read コミット読み取り
Pull requests Read & Write PR 読み取り + タスク URL コメント追加(PR #9b で使用)
Metadata Read リポジトリ情報
Webhooks — push / pull_request / create イベントを受信

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):

  1. X-Hub-Signature-256 ヘッダーを HMAC-SHA256 で検証 → 不一致は即 403
  2. X-GitHub-Event ヘッダーでイベント種別を識別
  3. installation_id で github_integrations を検索し対応プロジェクトを特定
  4. イベントを Apalis ジョブキューに積む(タスクリンク処理は PR #9b で実装するため、本 PR では受信確認のみ行い即 200 を返す)

PR #9b で追加する処理:

  • push / pull_request / create イベントに対するタスクリンク生成ロジック

7. API

メソッド パス 説明
GET /v1/tenants/{tid}/projects/{pid}/github/install インストール開始(state 生成・リダイレクト)
GET /v1/github/callback GitHub App インストールコールバック(公開、state 検証あり)
POST /v1/github/webhook GitHub Webhook 受信(公開、署名検証あり)
GET /v1/tenants/{tid}/projects/{pid}/github/integration 連携状態取得(token は返さない)
DELETE /v1/tenants/{tid}/projects/{pid}/github/integration 連携解除
GET /v1/tenants/{tid}/projects/{pid}/github/repositories 選択トークン(X-Github-Select-Token ヘッダー)に紐づく installation のリポジトリ一覧(トークンは消費しない)
POST /v1/tenants/{tid}/projects/{pid}/github/connect 選択したリポジトリで連携(成功時にトークンを破棄。検証で弾いたときは残すので選び直せる)
GET /v1/tenants/{tid}/projects/{pid}/github/installations 同じテナントで利用中のインストール(再利用候補、installation_id 単位で重複排除)
POST /v1/tenants/{tid}/projects/{pid}/github/reuse 候補からリポジトリ選択を開始して選択トークンを返す(Cache-Control: no-store。連携行はまだ作らない)
POST /v1/tenants/{tid}/projects/{pid}/github/import 連携リポジトリの Issue の取り込み開始(ジョブを積んで 202 を返す)

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 つを済ませないとバックエンドが起動しない (設定の検証で落ちる)。

  1. GitHub App の設定で Request user authorization (OAuth) during installation を有効にする。 これが無効だと callback に code が付かず、新規の連携が installation_authorization_required で止まる

  2. GITHUB_APP_CLIENT_ID / GITHUB_APP_CLIENT_SECRET(App 設定ページの Client ID / Client secret)を環境変数に足す


8. セキュリティ

脅威 対策
CSRF(インストール誤連携) state パラメータを Redis で管理(TTL 10 分、コールバック後即削除)。開始ユーザーと callback ユーザーの一致も確認
インストール先プロジェクトの誤連携 state に { tenant_id, project_id } を紐付けて保存。callback 時に復元して UPSERT
他人のインストールの紐付け installation_id はリクエストで受け取らず、選択トークン(Redis)に束縛。トークンの { tenant_id, project_id, user_id } とリクエスト経路・セッションの一致も確認
callback への他人の installation_id の差し込み インストール時のユーザー認可で受け取った code をユーザーアクセストークンに交換し、GET /user/installations に当該 installation_id が含まれることを確認。含まれなければ installation_forbidden で拒否(User インストールと Organization インストールのどちらも同じ経路で確かめられる)。この確認の導入後に作られた束縛済み installation は確認済みなので省く。導入前の github_integrations 行は鮮度チェックだけで作られているため、必要に応じて別途監査する
インストール内で自分に権限の無いリポジトリの連携 未対応(既知の制限)。選択候補は installation access token で列挙しているため、org の一部リポジトリしか権限が無いメンバーにも、そのインストールが見える全リポジトリ名が出る。ユーザーアクセストークンで GET /user/installations/{installation_id}/repositories を引いて絞る必要がある
他テナントのインストールの再利用 POST /github/reuse は installation_id を受け取らず、source_integration_id の行を対象テナントのプロジェクトに限って再取得して解決する。候補一覧も対象テナントの連携行だけから作る
選択トークンのアクセスログ流出 callback はフラグメント(#github_select=)で渡し、一覧取得は X-Github-Select-Token ヘッダー、確定はリクエストボディで受け取る。URL には載せない
可視範囲外リポジトリの連携 repo_owner / repo_name が installation の列挙結果に含まれることを検証。含まれなければ 400
Webhook 偽装 X-Hub-Signature-256 を HMAC-SHA256 で検証。不一致は即 403
Installation Access Token の漏洩 DB では AES-256-GCM で暗号化。API レスポンスでは一切返さない
Token 期限切れ token_expires_at を確認し、期限切れなら GitHub API で再取得してから使用

9. フロントエンド

GitHub 連携の UI はプロジェクト設定の「連携」セクションにある。専用ページは作っていない。

/{tenant_slug}/projects/{project_key}/settings?section=integrations
┌──────────────────────────────────────────────────────────────┐
│ 連携                                                          │
├──────────────────────────────────────────────────────────────┤
│  GitHub                                                       │
│  myorg/myapp を連携中(2026年7月1日 から)                     │
│                        [Issue を取り込む]  [連携を解除]        │
└──────────────────────────────────────────────────────────────┘
状態 表示
未連携 「連携する」ボタン。押すと GET /github/installations の候補(同じテナントで利用中のアカウント・組織)と「別の GitHub アカウント・組織を追加」を出す。後者だけが GET /github/install の URL へ遷移する。候補の取得に失敗しても自動では遷移せず、再試行を出す
連携済み リポジトリ名と連携日、「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・トークン・エラー表示を片付ける

コンポーネント ファイル
IntegrationsSection apps/frontend/src/components/projects/IntegrationsSection.vue