diff --git a/internal/handlers/deploy.go b/internal/handlers/deploy.go index 63daff31..cf44bd03 100644 --- a/internal/handlers/deploy.go +++ b/internal/handlers/deploy.go @@ -38,6 +38,7 @@ import ( "instant.dev/internal/config" "instant.dev/internal/crypto" "instant.dev/internal/email" + "instant.dev/internal/github" "instant.dev/internal/metrics" "instant.dev/internal/middleware" "instant.dev/internal/models" @@ -240,6 +241,43 @@ func applyGitSourceOpts(opts *compute.DeployOptions, d *models.Deployment, aesKe opts.GitAuth = plain } +// applyInstallationAuth fills opts.GitAuth with a minted GitHub App installation +// token for a source=git deploy that has no stored PAT but is linked to a live +// installation (P4.2b). No-op for non-git, PAT-present, or App-disabled deploys. +// Fail-soft: any miss leaves GitAuth empty (public-repo clone). +func (h *DeployHandler) applyInstallationAuth(ctx context.Context, opts *compute.DeployOptions, d *models.Deployment) { + if opts.Source != "git" || opts.GitAuth != "" || h.githubApp == nil { + return + } + if tok := h.installationCloneToken(ctx, d); tok != "" { + opts.GitAuth = tok + } +} + +// installationCloneToken mints a short-lived GitHub App installation token for +// cloning the deploy's repo (P4.2b), or "" if the deploy isn't linked to a live +// installation we own. Fail-soft throughout: any lookup/mint failure returns "" +// so the build falls back to a public clone rather than erroring. The +// installation MUST belong to the deploy's team and not be suspended — never +// mint for an installation the team doesn't own / that's been revoked. +func (h *DeployHandler) installationCloneToken(ctx context.Context, d *models.Deployment) string { + conn, err := models.GetGitHubConnectionByAppID(ctx, h.db, d.ID) + if err != nil || !conn.InstallationID.Valid { + return "" + } + inst, ierr := models.GetGitHubInstallation(ctx, h.db, conn.InstallationID.Int64) + if ierr != nil || inst.SuspendedAt.Valid || inst.TeamID != d.TeamID { + return "" + } + tok, terr := h.githubApp.InstallationToken(ctx, conn.InstallationID.Int64) + if terr != nil { + slog.Warn("deploy.git.installation_token_mint_failed", + "app_id", d.AppID, "installation_id", conn.InstallationID.Int64, "error", terr) + return "" + } + return tok +} + // encryptDeploySecret AES-256-GCM-encrypts a sensitive deploy input (a BYO // private-registry docker config JSON, or a private-repo git token) for at-rest // storage. ParseAESKey is the only failure mode worth a distinct branch (a @@ -325,6 +363,16 @@ type DeployHandler struct { // circuit-broken *email.BreakingClient (P0-1 // CIRCUIT-RETRY-AUDIT-2026-05-20). emailClient email.Mailer + // githubApp mints short-lived installation tokens for source=git clones of + // repos linked to a GitHub App installation (P4.2b). nil when + // GITHUB_APP_ENABLED is off → git clones fall back to a stored PAT / public. + // An interface so tests inject a fake minter (github.App satisfies it). + githubApp installationTokenMinter +} + +// installationTokenMinter is the subset of *github.App the deploy path needs. +type installationTokenMinter interface { + InstallationToken(ctx context.Context, installationID int64) (string, error) } // 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 default: cp = noop.New() } - return &DeployHandler{db: db, rdb: rdb, cfg: cfg, compute: cp, planRegistry: planRegistry} + h := &DeployHandler{db: db, rdb: rdb, cfg: cfg, compute: cp, planRegistry: planRegistry} + // Wire the GitHub App token minter when enabled (P4.2b). Construction can + // only fail on a malformed key, which config.Load already rejects at startup + // when GITHUB_APP_ENABLED=true — log + leave nil (git clones fall back). + if cfg.GitHubAppEnabled { + if app, err := github.NewApp(cfg.GitHubAppID, cfg.GitHubAppPrivateKey, rdb); err != nil { + slog.Error("deploy: github app minter init failed — git installation tokens disabled", "error", err) + } else { + h.githubApp = app + } + } + return h } +// SetGitHubApp injects the installation-token minter (tests pass a fake; matches +// the SetEmailClient/SetComputeProvider setter rationale — keep the constructor +// signature stable). +func (h *DeployHandler) SetGitHubApp(m installationTokenMinter) { h.githubApp = m } + // SetEmailClient wires the email client used by the two-step deletion // flow. Separate setter (rather than a constructor arg) to keep the // NewDeployHandler signature stable — every existing test would have @@ -745,6 +809,7 @@ func (h *DeployHandler) runDeploy(d *models.Deployment, tarball []byte) { // via git context). Each helper is a no-op unless its source matches. applyImageSourceOpts(&opts, d, h.cfg.AESKey) applyGitSourceOpts(&opts, d, h.cfg.AESKey) + h.applyInstallationAuth(ctx, &opts, d) result, err := h.compute.Deploy(ctx, opts) if err != nil { slog.Error("deploy.run_deploy.failed", diff --git a/internal/handlers/deploy_git_installation_token_test.go b/internal/handlers/deploy_git_installation_token_test.go new file mode 100644 index 00000000..2349f316 --- /dev/null +++ b/internal/handlers/deploy_git_installation_token_test.go @@ -0,0 +1,196 @@ +package handlers + +// deploy_git_installation_token_test.go — coverage for P4.2b: minting a GitHub +// App installation token for a source=git clone (applyInstallationAuth + +// installationCloneToken). Driven with sqlmock + a fake minter so every branch +// (non-git / PAT-present / app-disabled / no-connection / no-installation-id / +// installation-missing / suspended / team-mismatch / mint-error / minted) runs +// synchronously without a real DB. The end-to-end source=git runDeploy path +// (with a real DB) is exercised by TestDeployNew_SourceGit_FlagOn_Accepted. + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + + "instant.dev/internal/config" + "instant.dev/internal/models" + "instant.dev/internal/plans" + "instant.dev/internal/providers/compute" +) + +// fakeTokenMinter is an installationTokenMinter double. +type fakeTokenMinter struct { + token string + err error + calls int +} + +func (f *fakeTokenMinter) InstallationToken(_ context.Context, _ int64) (string, error) { + f.calls++ + return f.token, f.err +} + +// connRow builds an app_github_connections row for sqlmock. installID < 0 → NULL. +func connRow(appID, team uuid.UUID, installID int64) *sqlmock.Rows { + r := sqlmock.NewRows([]string{ + "id", "app_id", "team_id", "github_repo", "branch", "webhook_secret", + "installation_id", "created_at", "last_deploy_at", "last_commit_sha", + }) + var iid interface{} + if installID >= 0 { + iid = installID + } + return r.AddRow(uuid.New(), appID, team, "owner/repo", "main", "sec", iid, time.Now(), nil, nil) +} + +func instRow(team uuid.UUID, suspended bool) *sqlmock.Rows { + var susp interface{} + if suspended { + susp = time.Now() + } + return sqlmock.NewRows([]string{ + "installation_id", "team_id", "account_login", "suspended_at", "created_at", "updated_at", + }).AddRow(int64(42), team, "acme", susp, time.Now(), time.Now()) +} + +// runApply builds a DeployHandler over a sqlmock DB + fake minter, runs +// applyInstallationAuth on a git deploy, and returns the resolved GitAuth. +func runApply(t *testing.T, setup func(sqlmock.Sqlmock), minter installationTokenMinter, opts *compute.DeployOptions, team uuid.UUID) { + t.Helper() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + if setup != nil { + setup(mock) + } + h := &DeployHandler{db: db} + if minter != nil { + h.SetGitHubApp(minter) // exercise the setter (not a struct-literal field write) + } + d := &models.Deployment{ID: uuid.New(), TeamID: team, AppID: "gitdep"} + h.applyInstallationAuth(context.Background(), opts, d) +} + +func TestApplyInstallationAuth_Minted(t *testing.T) { + team := uuid.New() + m := &fakeTokenMinter{token: "ghs_minted"} + opts := &compute.DeployOptions{Source: "git"} + runApply(t, func(mk sqlmock.Sqlmock) { + mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnRows(connRow(uuid.New(), team, 42)) + mk.ExpectQuery(`FROM github_installations WHERE installation_id`).WillReturnRows(instRow(team, false)) + }, m, opts, team) + if opts.GitAuth != "ghs_minted" || m.calls != 1 { + t.Errorf("want minted token + 1 call, got GitAuth=%q calls=%d", opts.GitAuth, m.calls) + } +} + +func TestApplyInstallationAuth_EarlyReturns(t *testing.T) { + team := uuid.New() + m := &fakeTokenMinter{token: "ghs"} + // non-git source → no-op, no queries. + o1 := &compute.DeployOptions{Source: "image"} + runApply(t, nil, m, o1, team) + // PAT already present → no-op. + o2 := &compute.DeployOptions{Source: "git", GitAuth: "ghp_pat"} + runApply(t, nil, m, o2, team) + // app disabled (nil minter) → no-op. + o3 := &compute.DeployOptions{Source: "git"} + runApply(t, nil, nil, o3, team) + if o1.GitAuth != "" || o2.GitAuth != "ghp_pat" || o3.GitAuth != "" || m.calls != 0 { + t.Errorf("early-returns must not mint: %q %q %q calls=%d", o1.GitAuth, o2.GitAuth, o3.GitAuth, m.calls) + } +} + +func TestInstallationCloneToken_Misses(t *testing.T) { + team := uuid.New() + cases := map[string]func(sqlmock.Sqlmock){ + "no connection": func(mk sqlmock.Sqlmock) { + mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnError(errors.New("nope")) + }, + "connection without installation_id": func(mk sqlmock.Sqlmock) { + mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnRows(connRow(uuid.New(), team, -1)) + }, + "installation missing": func(mk sqlmock.Sqlmock) { + mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnRows(connRow(uuid.New(), team, 42)) + mk.ExpectQuery(`FROM github_installations WHERE installation_id`).WillReturnError(errors.New("gone")) + }, + "installation suspended": func(mk sqlmock.Sqlmock) { + mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnRows(connRow(uuid.New(), team, 42)) + mk.ExpectQuery(`FROM github_installations WHERE installation_id`).WillReturnRows(instRow(team, true)) + }, + "team mismatch": func(mk sqlmock.Sqlmock) { + mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnRows(connRow(uuid.New(), team, 42)) + mk.ExpectQuery(`FROM github_installations WHERE installation_id`).WillReturnRows(instRow(uuid.New(), false)) // different team + }, + } + for name, setup := range cases { + t.Run(name, func(t *testing.T) { + opts := &compute.DeployOptions{Source: "git"} + m := &fakeTokenMinter{token: "ghs"} + runApply(t, setup, m, opts, team) + if opts.GitAuth != "" { + t.Errorf("%s: must not mint, got %q", name, opts.GitAuth) + } + if m.calls != 0 { + t.Errorf("%s: minter must not be called, calls=%d", name, m.calls) + } + }) + } +} + +func TestInstallationCloneToken_MintError(t *testing.T) { + team := uuid.New() + m := &fakeTokenMinter{err: context.DeadlineExceeded} + opts := &compute.DeployOptions{Source: "git"} + runApply(t, func(mk sqlmock.Sqlmock) { + mk.ExpectQuery(`FROM app_github_connections WHERE app_id`).WillReturnRows(connRow(uuid.New(), team, 42)) + mk.ExpectQuery(`FROM github_installations WHERE installation_id`).WillReturnRows(instRow(team, false)) + }, m, opts, team) + if opts.GitAuth != "" || m.calls != 1 { + t.Errorf("mint error must fail-soft: GitAuth=%q calls=%d", opts.GitAuth, m.calls) + } +} + +// testRSAPEM returns a throwaway PKCS#1 RSA private key PEM (what GitHub issues). +func testRSAPEM(t *testing.T) string { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) +} + +// NewDeployHandler wires the GitHub App minter when enabled + key valid, leaves +// it nil on a bad key (logged), and nil when disabled (P4.2b). +func TestNewDeployHandler_GitHubAppWiring(t *testing.T) { + valid := NewDeployHandler(nil, nil, &config.Config{ + GitHubAppEnabled: true, GitHubAppID: "12345", GitHubAppPrivateKey: testRSAPEM(t), + }, plans.Default()) + if valid.githubApp == nil { + t.Error("enabled + valid key must wire the minter") + } + + badKey := NewDeployHandler(nil, nil, &config.Config{ + GitHubAppEnabled: true, GitHubAppID: "12345", GitHubAppPrivateKey: "not-a-pem", + }, plans.Default()) + if badKey.githubApp != nil { + t.Error("a malformed key must leave the minter nil (logged), not panic") + } + + disabled := NewDeployHandler(nil, nil, &config.Config{GitHubAppEnabled: false}, plans.Default()) + if disabled.githubApp != nil { + t.Error("disabled must leave the minter nil") + } +}