Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion internal/handlers/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down
196 changes: 196 additions & 0 deletions internal/handlers/deploy_git_installation_token_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading