Skip to content

Commit 0e107ad

Browse files
feat(deploy): P4.2b — mint GitHub App installation token for source=git clones (#226)
* feat(deploy): P4.2b — mint GitHub App installation token for source=git clones A source=git deploy with no stored PAT but linked to a GitHub App installation now clones its private repo with a freshly-minted, short-lived installation token (contents:read, ~1h) instead of requiring a long-lived PAT — the private-repo payoff of the App integration. - DeployHandler gains an installationTokenMinter (interface; github.App satisfies it), wired in NewDeployHandler when GITHUB_APP_ENABLED + key valid (nil otherwise → git clones fall back to PAT/public). SetGitHubApp setter mirrors SetEmailClient/SetComputeProvider. - runDeploy → applyInstallationAuth → installationCloneToken: looks up the deploy's app_github_connection → installation_id, validates the installation belongs to the deploy's team AND isn't suspended (anti-hijack / revocation), mints via the minter. Fail-soft: any miss leaves GitAuth empty (public clone). A stored PAT takes precedence (no mint). - Migration-free (reuses app_github_connections.installation_id). Tests: sqlmock white-box covers applyInstallationAuth + installationCloneToken 100% across every branch (non-git / PAT / app-disabled / no-connection / no-installation-id / installation-missing / suspended / team-mismatch / mint-error / minted), plus NewDeployHandler minter-wiring (valid/bad/disabled). The end-to-end real-DB source=git runDeploy path is covered by the existing TestDeployNew_SourceGit_FlagOn_Accepted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(deploy): exercise SetGitHubApp setter (P4.2b coverage) deploy.go:414 (SetGitHubApp) was uncovered — the sqlmock tests set the minter via a struct literal. Route through h.SetGitHubApp(minter) in the test helper so the setter is exercised. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 030891b commit 0e107ad

2 files changed

Lines changed: 262 additions & 1 deletion

File tree

internal/handlers/deploy.go

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
"instant.dev/internal/config"
3939
"instant.dev/internal/crypto"
4040
"instant.dev/internal/email"
41+
"instant.dev/internal/github"
4142
"instant.dev/internal/metrics"
4243
"instant.dev/internal/middleware"
4344
"instant.dev/internal/models"
@@ -240,6 +241,43 @@ func applyGitSourceOpts(opts *compute.DeployOptions, d *models.Deployment, aesKe
240241
opts.GitAuth = plain
241242
}
242243

244+
// applyInstallationAuth fills opts.GitAuth with a minted GitHub App installation
245+
// token for a source=git deploy that has no stored PAT but is linked to a live
246+
// installation (P4.2b). No-op for non-git, PAT-present, or App-disabled deploys.
247+
// Fail-soft: any miss leaves GitAuth empty (public-repo clone).
248+
func (h *DeployHandler) applyInstallationAuth(ctx context.Context, opts *compute.DeployOptions, d *models.Deployment) {
249+
if opts.Source != "git" || opts.GitAuth != "" || h.githubApp == nil {
250+
return
251+
}
252+
if tok := h.installationCloneToken(ctx, d); tok != "" {
253+
opts.GitAuth = tok
254+
}
255+
}
256+
257+
// installationCloneToken mints a short-lived GitHub App installation token for
258+
// cloning the deploy's repo (P4.2b), or "" if the deploy isn't linked to a live
259+
// installation we own. Fail-soft throughout: any lookup/mint failure returns ""
260+
// so the build falls back to a public clone rather than erroring. The
261+
// installation MUST belong to the deploy's team and not be suspended — never
262+
// mint for an installation the team doesn't own / that's been revoked.
263+
func (h *DeployHandler) installationCloneToken(ctx context.Context, d *models.Deployment) string {
264+
conn, err := models.GetGitHubConnectionByAppID(ctx, h.db, d.ID)
265+
if err != nil || !conn.InstallationID.Valid {
266+
return ""
267+
}
268+
inst, ierr := models.GetGitHubInstallation(ctx, h.db, conn.InstallationID.Int64)
269+
if ierr != nil || inst.SuspendedAt.Valid || inst.TeamID != d.TeamID {
270+
return ""
271+
}
272+
tok, terr := h.githubApp.InstallationToken(ctx, conn.InstallationID.Int64)
273+
if terr != nil {
274+
slog.Warn("deploy.git.installation_token_mint_failed",
275+
"app_id", d.AppID, "installation_id", conn.InstallationID.Int64, "error", terr)
276+
return ""
277+
}
278+
return tok
279+
}
280+
243281
// encryptDeploySecret AES-256-GCM-encrypts a sensitive deploy input (a BYO
244282
// private-registry docker config JSON, or a private-repo git token) for at-rest
245283
// storage. ParseAESKey is the only failure mode worth a distinct branch (a
@@ -325,6 +363,16 @@ type DeployHandler struct {
325363
// circuit-broken *email.BreakingClient (P0-1
326364
// CIRCUIT-RETRY-AUDIT-2026-05-20).
327365
emailClient email.Mailer
366+
// githubApp mints short-lived installation tokens for source=git clones of
367+
// repos linked to a GitHub App installation (P4.2b). nil when
368+
// GITHUB_APP_ENABLED is off → git clones fall back to a stored PAT / public.
369+
// An interface so tests inject a fake minter (github.App satisfies it).
370+
githubApp installationTokenMinter
371+
}
372+
373+
// installationTokenMinter is the subset of *github.App the deploy path needs.
374+
type installationTokenMinter interface {
375+
InstallationToken(ctx context.Context, installationID int64) (string, error)
328376
}
329377

330378
// NewDeployHandler initialises the handler and selects the compute backend based on
@@ -346,9 +394,25 @@ func NewDeployHandler(db *sql.DB, rdb *redis.Client, cfg *config.Config, planReg
346394
default:
347395
cp = noop.New()
348396
}
349-
return &DeployHandler{db: db, rdb: rdb, cfg: cfg, compute: cp, planRegistry: planRegistry}
397+
h := &DeployHandler{db: db, rdb: rdb, cfg: cfg, compute: cp, planRegistry: planRegistry}
398+
// Wire the GitHub App token minter when enabled (P4.2b). Construction can
399+
// only fail on a malformed key, which config.Load already rejects at startup
400+
// when GITHUB_APP_ENABLED=true — log + leave nil (git clones fall back).
401+
if cfg.GitHubAppEnabled {
402+
if app, err := github.NewApp(cfg.GitHubAppID, cfg.GitHubAppPrivateKey, rdb); err != nil {
403+
slog.Error("deploy: github app minter init failed — git installation tokens disabled", "error", err)
404+
} else {
405+
h.githubApp = app
406+
}
407+
}
408+
return h
350409
}
351410

411+
// SetGitHubApp injects the installation-token minter (tests pass a fake; matches
412+
// the SetEmailClient/SetComputeProvider setter rationale — keep the constructor
413+
// signature stable).
414+
func (h *DeployHandler) SetGitHubApp(m installationTokenMinter) { h.githubApp = m }
415+
352416
// SetEmailClient wires the email client used by the two-step deletion
353417
// flow. Separate setter (rather than a constructor arg) to keep the
354418
// NewDeployHandler signature stable — every existing test would have
@@ -745,6 +809,7 @@ func (h *DeployHandler) runDeploy(d *models.Deployment, tarball []byte) {
745809
// via git context). Each helper is a no-op unless its source matches.
746810
applyImageSourceOpts(&opts, d, h.cfg.AESKey)
747811
applyGitSourceOpts(&opts, d, h.cfg.AESKey)
812+
h.applyInstallationAuth(ctx, &opts, d)
748813
result, err := h.compute.Deploy(ctx, opts)
749814
if err != nil {
750815
slog.Error("deploy.run_deploy.failed",
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package handlers
2+
3+
// deploy_git_installation_token_test.go — coverage for P4.2b: minting a GitHub
4+
// App installation token for a source=git clone (applyInstallationAuth +
5+
// installationCloneToken). Driven with sqlmock + a fake minter so every branch
6+
// (non-git / PAT-present / app-disabled / no-connection / no-installation-id /
7+
// installation-missing / suspended / team-mismatch / mint-error / minted) runs
8+
// synchronously without a real DB. The end-to-end source=git runDeploy path
9+
// (with a real DB) is exercised by TestDeployNew_SourceGit_FlagOn_Accepted.
10+
11+
import (
12+
"context"
13+
"crypto/rand"
14+
"crypto/rsa"
15+
"crypto/x509"
16+
"encoding/pem"
17+
"errors"
18+
"testing"
19+
"time"
20+
21+
"github.com/DATA-DOG/go-sqlmock"
22+
"github.com/google/uuid"
23+
24+
"instant.dev/internal/config"
25+
"instant.dev/internal/models"
26+
"instant.dev/internal/plans"
27+
"instant.dev/internal/providers/compute"
28+
)
29+
30+
// fakeTokenMinter is an installationTokenMinter double.
31+
type fakeTokenMinter struct {
32+
token string
33+
err error
34+
calls int
35+
}
36+
37+
func (f *fakeTokenMinter) InstallationToken(_ context.Context, _ int64) (string, error) {
38+
f.calls++
39+
return f.token, f.err
40+
}
41+
42+
// connRow builds an app_github_connections row for sqlmock. installID < 0 → NULL.
43+
func connRow(appID, team uuid.UUID, installID int64) *sqlmock.Rows {
44+
r := sqlmock.NewRows([]string{
45+
"id", "app_id", "team_id", "github_repo", "branch", "webhook_secret",
46+
"installation_id", "created_at", "last_deploy_at", "last_commit_sha",
47+
})
48+
var iid interface{}
49+
if installID >= 0 {
50+
iid = installID
51+
}
52+
return r.AddRow(uuid.New(), appID, team, "owner/repo", "main", "sec", iid, time.Now(), nil, nil)
53+
}
54+
55+
func instRow(team uuid.UUID, suspended bool) *sqlmock.Rows {
56+
var susp interface{}
57+
if suspended {
58+
susp = time.Now()
59+
}
60+
return sqlmock.NewRows([]string{
61+
"installation_id", "team_id", "account_login", "suspended_at", "created_at", "updated_at",
62+
}).AddRow(int64(42), team, "acme", susp, time.Now(), time.Now())
63+
}
64+
65+
// runApply builds a DeployHandler over a sqlmock DB + fake minter, runs
66+
// applyInstallationAuth on a git deploy, and returns the resolved GitAuth.
67+
func runApply(t *testing.T, setup func(sqlmock.Sqlmock), minter installationTokenMinter, opts *compute.DeployOptions, team uuid.UUID) {
68+
t.Helper()
69+
db, mock, err := sqlmock.New()
70+
if err != nil {
71+
t.Fatal(err)
72+
}
73+
defer func() { _ = db.Close() }()
74+
if setup != nil {
75+
setup(mock)
76+
}
77+
h := &DeployHandler{db: db}
78+
if minter != nil {
79+
h.SetGitHubApp(minter) // exercise the setter (not a struct-literal field write)
80+
}
81+
d := &models.Deployment{ID: uuid.New(), TeamID: team, AppID: "gitdep"}
82+
h.applyInstallationAuth(context.Background(), opts, d)
83+
}
84+
85+
func TestApplyInstallationAuth_Minted(t *testing.T) {
86+
team := uuid.New()
87+
m := &fakeTokenMinter{token: "ghs_minted"}
88+
opts := &compute.DeployOptions{Source: "git"}
89+
runApply(t, func(mk sqlmock.Sqlmock) {
90+
mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnRows(connRow(uuid.New(), team, 42))
91+
mk.ExpectQuery(`FROM github_installations WHERE installation_id`).WillReturnRows(instRow(team, false))
92+
}, m, opts, team)
93+
if opts.GitAuth != "ghs_minted" || m.calls != 1 {
94+
t.Errorf("want minted token + 1 call, got GitAuth=%q calls=%d", opts.GitAuth, m.calls)
95+
}
96+
}
97+
98+
func TestApplyInstallationAuth_EarlyReturns(t *testing.T) {
99+
team := uuid.New()
100+
m := &fakeTokenMinter{token: "ghs"}
101+
// non-git source → no-op, no queries.
102+
o1 := &compute.DeployOptions{Source: "image"}
103+
runApply(t, nil, m, o1, team)
104+
// PAT already present → no-op.
105+
o2 := &compute.DeployOptions{Source: "git", GitAuth: "ghp_pat"}
106+
runApply(t, nil, m, o2, team)
107+
// app disabled (nil minter) → no-op.
108+
o3 := &compute.DeployOptions{Source: "git"}
109+
runApply(t, nil, nil, o3, team)
110+
if o1.GitAuth != "" || o2.GitAuth != "ghp_pat" || o3.GitAuth != "" || m.calls != 0 {
111+
t.Errorf("early-returns must not mint: %q %q %q calls=%d", o1.GitAuth, o2.GitAuth, o3.GitAuth, m.calls)
112+
}
113+
}
114+
115+
func TestInstallationCloneToken_Misses(t *testing.T) {
116+
team := uuid.New()
117+
cases := map[string]func(sqlmock.Sqlmock){
118+
"no connection": func(mk sqlmock.Sqlmock) {
119+
mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnError(errors.New("nope"))
120+
},
121+
"connection without installation_id": func(mk sqlmock.Sqlmock) {
122+
mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnRows(connRow(uuid.New(), team, -1))
123+
},
124+
"installation missing": func(mk sqlmock.Sqlmock) {
125+
mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnRows(connRow(uuid.New(), team, 42))
126+
mk.ExpectQuery(`FROM github_installations WHERE installation_id`).WillReturnError(errors.New("gone"))
127+
},
128+
"installation suspended": func(mk sqlmock.Sqlmock) {
129+
mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnRows(connRow(uuid.New(), team, 42))
130+
mk.ExpectQuery(`FROM github_installations WHERE installation_id`).WillReturnRows(instRow(team, true))
131+
},
132+
"team mismatch": func(mk sqlmock.Sqlmock) {
133+
mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnRows(connRow(uuid.New(), team, 42))
134+
mk.ExpectQuery(`FROM github_installations WHERE installation_id`).WillReturnRows(instRow(uuid.New(), false)) // different team
135+
},
136+
}
137+
for name, setup := range cases {
138+
t.Run(name, func(t *testing.T) {
139+
opts := &compute.DeployOptions{Source: "git"}
140+
m := &fakeTokenMinter{token: "ghs"}
141+
runApply(t, setup, m, opts, team)
142+
if opts.GitAuth != "" {
143+
t.Errorf("%s: must not mint, got %q", name, opts.GitAuth)
144+
}
145+
if m.calls != 0 {
146+
t.Errorf("%s: minter must not be called, calls=%d", name, m.calls)
147+
}
148+
})
149+
}
150+
}
151+
152+
func TestInstallationCloneToken_MintError(t *testing.T) {
153+
team := uuid.New()
154+
m := &fakeTokenMinter{err: context.DeadlineExceeded}
155+
opts := &compute.DeployOptions{Source: "git"}
156+
runApply(t, func(mk sqlmock.Sqlmock) {
157+
mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnRows(connRow(uuid.New(), team, 42))
158+
mk.ExpectQuery(`FROM github_installations WHERE installation_id`).WillReturnRows(instRow(team, false))
159+
}, m, opts, team)
160+
if opts.GitAuth != "" || m.calls != 1 {
161+
t.Errorf("mint error must fail-soft: GitAuth=%q calls=%d", opts.GitAuth, m.calls)
162+
}
163+
}
164+
165+
// testRSAPEM returns a throwaway PKCS#1 RSA private key PEM (what GitHub issues).
166+
func testRSAPEM(t *testing.T) string {
167+
t.Helper()
168+
key, err := rsa.GenerateKey(rand.Reader, 2048)
169+
if err != nil {
170+
t.Fatal(err)
171+
}
172+
return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
173+
}
174+
175+
// NewDeployHandler wires the GitHub App minter when enabled + key valid, leaves
176+
// it nil on a bad key (logged), and nil when disabled (P4.2b).
177+
func TestNewDeployHandler_GitHubAppWiring(t *testing.T) {
178+
valid := NewDeployHandler(nil, nil, &config.Config{
179+
GitHubAppEnabled: true, GitHubAppID: "12345", GitHubAppPrivateKey: testRSAPEM(t),
180+
}, plans.Default())
181+
if valid.githubApp == nil {
182+
t.Error("enabled + valid key must wire the minter")
183+
}
184+
185+
badKey := NewDeployHandler(nil, nil, &config.Config{
186+
GitHubAppEnabled: true, GitHubAppID: "12345", GitHubAppPrivateKey: "not-a-pem",
187+
}, plans.Default())
188+
if badKey.githubApp != nil {
189+
t.Error("a malformed key must leave the minter nil (logged), not panic")
190+
}
191+
192+
disabled := NewDeployHandler(nil, nil, &config.Config{GitHubAppEnabled: false}, plans.Default())
193+
if disabled.githubApp != nil {
194+
t.Error("disabled must leave the minter nil")
195+
}
196+
}

0 commit comments

Comments
 (0)