diff --git a/internal/db/migrations/020_deployment_access_control.sql b/internal/db/migrations/020_deployment_access_control.sql new file mode 100644 index 0000000..f1ab5e4 --- /dev/null +++ b/internal/db/migrations/020_deployment_access_control.sql @@ -0,0 +1,31 @@ +-- 020_deployment_access_control.sql — Private deploy access control on deployments. +-- +-- Track A of the private-deploys feature. Adds two columns: +-- +-- private: true → the Ingress carries +-- nginx.ingress.kubernetes.io/whitelist-source-range so only +-- allowed IPs can reach the app. +-- allowed_ips: comma-joined list of CIDRs / IPs. NOT a JSONB array — these +-- are surfaced into the Ingress annotation as a comma-joined +-- string anyway, and the existing string-handling code paths +-- (scanDeployment, deploymentToMap) keep their shape with a +-- plain TEXT field. Validation (net.ParseCIDR / net.ParseIP, +-- max 32 entries, non-empty when private=true) lives in the +-- handler — the column is just storage. +-- +-- Default false / '' is the critical backward-compat guarantee: existing +-- deployments stay public exactly as they were. The Ingress annotation is +-- only set when private=true, so the legacy code path produces byte-identical +-- Ingress objects. +-- +-- Tier gating (Pro / Team / Growth only) is enforced in the handler before +-- the row is inserted — no DB-level constraint required. +-- +-- Rollback (NOT executed — kept for runbook only): +-- ALTER TABLE deployments +-- DROP COLUMN IF EXISTS allowed_ips, +-- DROP COLUMN IF EXISTS private; + +ALTER TABLE deployments + ADD COLUMN IF NOT EXISTS private BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS allowed_ips TEXT NOT NULL DEFAULT ''; diff --git a/internal/handlers/agent_action.go b/internal/handlers/agent_action.go index 381b2bd..287a447 100644 --- a/internal/handlers/agent_action.go +++ b/internal/handlers/agent_action.go @@ -90,6 +90,21 @@ func newAgentActionDeploymentLimitReached(tier string, limit int) string { ) } +// ───────────────────────────────────────────────────────────────────────────── +// Private-deploy walls (Track A — migration 020) +// ───────────────────────────────────────────────────────────────────────────── + +// AgentActionPrivateDeployRequiresPro is returned when a hobby / anonymous / +// free team tries to set private=true on POST /deploy/new. Names the gated +// feature ("private deploys"), the required tier ("Pro"), and points at the +// exact upgrade URL — satisfying all four contract requirements. +const AgentActionPrivateDeployRequiresPro = "Tell the user private deploys require Pro tier. Upgrade at https://instanode.dev/pricing — takes 30 seconds." + +// AgentActionPrivateDeployRequiresAllowedIPs is returned when a caller sets +// private=true but supplies no allowed_ips. We do NOT allow a "private deploy +// with zero allowed IPs" — that would silently make the app unreachable. +const AgentActionPrivateDeployRequiresAllowedIPs = "Tell the user a private deploy needs at least one allowed IP or CIDR. Have them pass allowed_ips like [\"1.2.3.4\",\"10.0.0.0/8\"] — see https://instanode.dev/docs/private-deploys." + // ───────────────────────────────────────────────────────────────────────────── // Storage / vault tier walls (called from respondErrorWithAgentAction) // ───────────────────────────────────────────────────────────────────────────── diff --git a/internal/handlers/agent_action_contract_test.go b/internal/handlers/agent_action_contract_test.go index 67cf251..52a80b6 100644 --- a/internal/handlers/agent_action_contract_test.go +++ b/internal/handlers/agent_action_contract_test.go @@ -27,11 +27,13 @@ import ( func agentActionContractCases() map[string]string { cases := map[string]string{ // Static constants. - "AgentActionMultiEnvUpgradeRequired": AgentActionMultiEnvUpgradeRequired, - "AgentActionStackPromoteMissingImageRef": AgentActionStackPromoteMissingImageRef, - "AgentActionBindingFamilyDisabled": AgentActionBindingFamilyDisabled, - "AgentActionBindingLookupFailed": AgentActionBindingLookupFailed, - "RecycleGateAgentAction": RecycleGateAgentAction, + "AgentActionMultiEnvUpgradeRequired": AgentActionMultiEnvUpgradeRequired, + "AgentActionStackPromoteMissingImageRef": AgentActionStackPromoteMissingImageRef, + "AgentActionBindingFamilyDisabled": AgentActionBindingFamilyDisabled, + "AgentActionBindingLookupFailed": AgentActionBindingLookupFailed, + "RecycleGateAgentAction": RecycleGateAgentAction, + "AgentActionPrivateDeployRequiresPro": AgentActionPrivateDeployRequiresPro, + "AgentActionPrivateDeployRequiresAllowedIPs": AgentActionPrivateDeployRequiresAllowedIPs, // Builders — representative inputs covering tier/env/role/limit // interpolation. diff --git a/internal/handlers/deploy.go b/internal/handlers/deploy.go index 57070ba..4427061 100644 --- a/internal/handlers/deploy.go +++ b/internal/handlers/deploy.go @@ -41,6 +41,21 @@ import ( "instant.dev/internal/providers/compute/noop" ) +// maxAllowedIPs caps the size of the allowed_ips list on a private deploy. +// Anything bigger belongs in a real VPN / CF Access policy — the goal here is +// "agent locks the staging app to the office IP", not corporate networking. +const maxAllowedIPs = 32 + +// privateDeployAllowedTiers is the set of tiers permitted to use private=true. +// Hobby / anonymous / free fall through to the 402 wall. +var privateDeployAllowedTiers = map[string]bool{ + "pro": true, + "pro_yearly": true, + "team": true, + "team_yearly": true, + "growth": true, +} + // DeployHandler handles all /deploy endpoints. type DeployHandler struct { db *sql.DB @@ -91,6 +106,13 @@ func generateAppID() (string, error) { // field for the new env scope (production / staging / dev / ...). Callers can // continue to read .env as a map of vars; .environment is the scope name. func deploymentToMap(d *models.Deployment) fiber.Map { + // allowed_ips is always emitted (as [] when empty) so a Pro-tier dashboard + // can branch on "is this deployment private?" without having to special-case + // the missing-key path. private mirrors the column verbatim. + allowedIPs := d.AllowedIPs + if allowedIPs == nil { + allowedIPs = []string{} + } m := fiber.Map{ "id": d.ID, "token": d.AppID, // public-facing alias @@ -102,6 +124,8 @@ func deploymentToMap(d *models.Deployment) fiber.Map { "status": d.Status, "env": d.EnvVars, "environment": d.Env, + "private": d.Private, + "allowed_ips": allowedIPs, "created_at": d.CreatedAt, "updated_at": d.UpdatedAt, "team_id": d.TeamID, @@ -161,12 +185,14 @@ func (h *DeployHandler) runDeploy(d *models.Deployment, tarball []byte) { } opts := compute.DeployOptions{ - AppID: d.AppID, - Token: d.ID.String(), - Tarball: tarball, - Port: d.Port, - Tier: d.Tier, - EnvVars: resolvedEnv, + AppID: d.AppID, + Token: d.ID.String(), + Tarball: tarball, + Port: d.Port, + Tier: d.Tier, + EnvVars: resolvedEnv, + Private: d.Private, + AllowedIPs: d.AllowedIPs, } result, err := h.compute.Deploy(ctx, opts) if err != nil { @@ -334,6 +360,26 @@ func (h *DeployHandler) New(c *fiber.Ctx) error { } } + // ── Private deploy fields (Track A — migration 020) ───────────────────── + // + // Two new multipart fields gate ingress access for the deployed app: + // private: "true" / "1" / "yes" → set the nginx + // whitelist-source-range annotation on the Ingress + // allowed_ips: comma-separated list of IPs or CIDRs + // (e.g. "1.2.3.4,10.0.0.0/8"); required when private=true. + // + // Validation order matters: + // 1. Tier gate FIRST so hobby/anonymous never sees a 400 for "missing + // allowed_ips" when the real failure is "your plan can't do this". + // Hides ladder-rung knowledge from low-tier callers. + // 2. Then non-empty allowed_ips. + // 3. Then per-entry parsing. + // 4. Then the 32-entry cap. + private, allowedIPs, privErr := parsePrivateDeployFields(c, form, team.PlanTier) + if privErr != nil { + return privErr // respondError already called inside parsePrivateDeployFields + } + // ── Tier-limit enforcement (plans.yaml: deployments_apps) ──────────────── // // Count the team's currently-active deployments and reject when over the @@ -360,12 +406,14 @@ func (h *DeployHandler) New(c *fiber.Ctx) error { } saved, err := models.CreateDeployment(c.Context(), h.db, models.CreateDeploymentParams{ - TeamID: team.ID, - AppID: appID, - Port: port, - Tier: team.PlanTier, - Env: environment, - EnvVars: initEnv, + TeamID: team.ID, + AppID: appID, + Port: port, + Tier: team.PlanTier, + Env: environment, + EnvVars: initEnv, + Private: private, + AllowedIPs: allowedIPs, }) if err != nil { slog.Error("deploy.new.db_create_failed", diff --git a/internal/handlers/deploy_private.go b/internal/handlers/deploy_private.go new file mode 100644 index 0000000..832ddff --- /dev/null +++ b/internal/handlers/deploy_private.go @@ -0,0 +1,164 @@ +package handlers + +// deploy_private.go — Helpers for the private-deploy multipart fields on +// POST /deploy/new (Track A, migration 020). +// +// Kept in a separate file so the U3 reviewer can audit the whole rule-set — +// tier gate, validation, agent_action wiring — in one place. The handler in +// deploy.go calls parsePrivateDeployFields once before persisting the row. + +import ( + "fmt" + "net" + "strings" + + "github.com/gofiber/fiber/v2" + "instant.dev/internal/middleware" + + "log/slog" + + "mime/multipart" +) + +// parsePrivateDeployFields extracts and validates the optional `private` and +// `allowed_ips` multipart fields from POST /deploy/new. +// +// Returns (private, allowedIPs, nil) on success. On failure, it writes the +// 400/402 response inline and returns a non-nil error — caller MUST propagate +// the error and return immediately (mirrors the pattern in requireTeam). +// +// Validation order (tier first — see U3 note in deploy.go): +// +// 1. private not set / "false" / empty → return (false, nil, nil) — no +// allowed_ips check, no tier gate. Existing public-deploy path is byte- +// identical to before this commit. +// 2. private=true on a hobby/anonymous/free/yearly-free team → 402 with +// AgentActionPrivateDeployRequiresPro. Does NOT reveal whether the rest +// of the request would have passed. +// 3. private=true with no allowed_ips → 400 with +// AgentActionPrivateDeployRequiresAllowedIPs. We refuse "private deploy +// reachable by no-one" because it silently bricks the app. +// 4. Each allowed_ips entry must be a valid IP or CIDR (net.ParseIP / +// net.ParseCIDR). Bad entries surface verbatim in the 400 message so +// the caller can fix the literal that broke. +// 5. > maxAllowedIPs entries → 400. Anything larger is a VPN / CF Access +// problem, not a Pro deploy. +func parsePrivateDeployFields(c *fiber.Ctx, form *multipart.Form, planTier string) (bool, []string, error) { + rawPrivate := firstFormValue(form, "private") + private := parseTruthy(rawPrivate) + rawAllowedIPs := firstFormValue(form, "allowed_ips") + + if !private { + // Public deploy — even if allowed_ips is set, it is ignored and not + // persisted. Surfaced as a `slog.Debug` so callers wondering why + // allowed_ips "doesn't work" can find the breadcrumb in logs. + if rawAllowedIPs != "" { + slog.Debug("deploy.new.allowed_ips_ignored_public", + "team_tier", planTier, + "request_id", middleware.GetRequestID(c)) + } + return false, nil, nil + } + + // Tier gate FIRST — hides downstream validation rules from tiers that + // don't have access to the feature at all. + if !privateDeployAllowedTiers[planTier] { + return false, nil, respondErrorWithAgentAction(c, + fiber.StatusPaymentRequired, + "private_deploy_requires_pro", + fmt.Sprintf("Private deploys are a Pro feature. Your team is on %s.", planTier), + AgentActionPrivateDeployRequiresPro, + "https://instanode.dev/pricing") + } + + // Required-field gate. + entries := splitAllowedIPsField(rawAllowedIPs) + if len(entries) == 0 { + return false, nil, respondErrorWithAgentAction(c, + fiber.StatusBadRequest, + "private_deploy_requires_allowed_ips", + "private=true requires a non-empty allowed_ips list (e.g. \"1.2.3.4,10.0.0.0/8\").", + AgentActionPrivateDeployRequiresAllowedIPs, + "") + } + + // Cap enforcement BEFORE per-entry parsing — a 200-entry pathological + // list would otherwise burn CPU through 200 net.ParseCIDR calls before + // being rejected anyway. 32 is the max we'll ever stuff into an nginx + // annotation responsibly; bigger lists belong in CF Access. + if len(entries) > maxAllowedIPs { + return false, nil, respondError(c, + fiber.StatusBadRequest, + "too_many_allowed_ips", + fmt.Sprintf("allowed_ips has %d entries; max is %d. For larger allowlists use a real VPN or Cloudflare Access — see https://instanode.dev/docs/private-deploys.", + len(entries), maxAllowedIPs)) + } + + // Per-entry validation. Surface the bad literal verbatim — the LLM agent + // gets to feed the typo back to the human. + for _, entry := range entries { + if !isValidIPOrCIDR(entry) { + return false, nil, respondError(c, + fiber.StatusBadRequest, + "invalid_allowed_ip", + fmt.Sprintf("allowed_ips entry %q is not a valid IP or CIDR. Examples: \"1.2.3.4\", \"10.0.0.0/8\", \"2001:db8::/32\".", entry)) + } + } + + return true, entries, nil +} + +// firstFormValue returns the first value for a multipart field, or "" when +// absent. multipart.Form.Value is map[string][]string with empty slices on +// missing keys — explicit check avoids the panic-on-index pattern. +func firstFormValue(form *multipart.Form, key string) string { + if vals := form.Value[key]; len(vals) > 0 { + return vals[0] + } + return "" +} + +// parseTruthy normalises the `private` field across reasonable inputs. The +// surface is loose on purpose: agents come from JS / Python / curl and each +// stringifies booleans differently. Anything not on this list is false. +func parseTruthy(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "true", "1", "yes", "y", "on": + return true + } + return false +} + +// splitAllowedIPsField parses the multipart `allowed_ips` value. Accepts the +// canonical comma-joined form ("1.2.3.4,10.0.0.0/8") and trims whitespace per +// entry. Empty entries (e.g. trailing commas) are skipped — they're a common +// concatenation typo and not worth a 400 on their own. Returns nil on empty. +func splitAllowedIPsField(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// isValidIPOrCIDR returns true if s is either a literal IP (v4 or v6) or a +// CIDR block. Used by parsePrivateDeployFields to validate each allowed_ips +// entry. nginx accepts both forms in whitelist-source-range. +func isValidIPOrCIDR(s string) bool { + if _, _, err := net.ParseCIDR(s); err == nil { + return true + } + if ip := net.ParseIP(s); ip != nil { + return true + } + return false +} diff --git a/internal/handlers/deploy_private_test.go b/internal/handlers/deploy_private_test.go new file mode 100644 index 0000000..d093dae --- /dev/null +++ b/internal/handlers/deploy_private_test.go @@ -0,0 +1,391 @@ +package handlers_test + +// deploy_private_test.go — POST /deploy/new private / allowed_ips fields. +// +// Track A of the private-deploys feature (migration 020). Seven cases, mirror +// the brief's spec: +// +// 1. Pro tier + private=true + 1 IP → 202 (deployment created) +// 2. Hobby tier + private=true → 402 + agent_action +// 3. Pro tier + private=true + empty IPs → 400 + agent_action +// 4. Pro tier + private=true + invalid IP → 400 (bad literal surfaced) +// 5. Pro tier + private=true + 33 IPs → 400 (cap enforced) +// 6. Pro tier + private=false (default) → 202 (existing path) +// 7. GET /deploy/:id round-trip → private + allowed_ips +// +// All tests run against the noop compute provider — k8s isn't involved. +// We assert handler-level behaviour: status codes, error keys, agent_action +// text, and the persisted shape via GET /deploy/:id. + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/testhelpers" +) + +// privateDeployBody is like multipartDeployBody but with named convenience +// for the private+allowed_ips fields. Stays a tiny helper so each test still +// reads top-to-bottom. +func privateDeployBody(t *testing.T, private string, allowedIPs string, extra map[string]string) (*bytes.Buffer, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + fw, err := w.CreateFormFile("tarball", "app.tar.gz") + require.NoError(t, err) + _, err = fw.Write([]byte("fake-tarball-bytes")) + require.NoError(t, err) + if private != "" { + require.NoError(t, w.WriteField("private", private)) + } + if allowedIPs != "" { + require.NoError(t, w.WriteField("allowed_ips", allowedIPs)) + } + for k, v := range extra { + require.NoError(t, w.WriteField(k, v)) + } + require.NoError(t, w.Close()) + return buf, w.FormDataContentType() +} + +// TestDeployNew_Private_Pro_Accepts is case 1: Pro + private=true + 1 IP → +// 202. Asserts the persisted record carries private=true and the allowed_ips +// list round-trips. Uses GET /deploy/:id to read the row back (handler is +// the contract — bypassing it to read the DB directly hides surface bugs). +func TestDeployNew_Private_Pro_Accepts(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "10000000-0000-0000-0000-000000000001", teamID, "agent-priv-pro@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + body, ct := privateDeployBody(t, "true", "1.2.3.4,10.0.0.0/8", nil) + req := httptest.NewRequest(http.MethodPost, "/deploy/new", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.20.0.1") + + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + + require.Equal(t, http.StatusAccepted, resp.StatusCode, + "Pro + private=true + valid IPs must be 202; got %d, body: %s", resp.StatusCode, string(bodyBytes)) + + var created struct { + Item struct { + AppID string `json:"app_id"` + Private bool `json:"private"` + AllowedIPs []string `json:"allowed_ips"` + } `json:"item"` + } + require.NoError(t, json.Unmarshal(bodyBytes, &created)) + assert.True(t, created.Item.Private, "private must be true on the response item") + assert.Equal(t, []string{"1.2.3.4", "10.0.0.0/8"}, created.Item.AllowedIPs, + "allowed_ips must be parsed into the slice in original order") + + // Round-trip via GET /deploy/:id — proves the row was persisted, not + // just echoed back from the request. + getReq := httptest.NewRequest(http.MethodGet, "/deploy/"+created.Item.AppID, nil) + getReq.Header.Set("Authorization", "Bearer "+sessionJWT) + getResp, err := app.Test(getReq, 5000) + require.NoError(t, err) + defer getResp.Body.Close() + require.Equal(t, http.StatusOK, getResp.StatusCode) + var fetched struct { + Item struct { + Private bool `json:"private"` + AllowedIPs []string `json:"allowed_ips"` + } `json:"item"` + } + require.NoError(t, json.NewDecoder(getResp.Body).Decode(&fetched)) + assert.True(t, fetched.Item.Private, "private must round-trip through GET") + assert.Equal(t, []string{"1.2.3.4", "10.0.0.0/8"}, fetched.Item.AllowedIPs, + "allowed_ips must round-trip through GET") +} + +// TestDeployNew_Private_Hobby_Returns402 is case 2: hobby tier hitting +// private=true gets the 402 wall with the Pro-required agent_action. +// Critical: the message must point at the upgrade URL, not "contact support". +func TestDeployNew_Private_Hobby_Returns402(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + sessionJWT := testhelpers.MustSignSessionJWT(t, "20000000-0000-0000-0000-000000000002", teamID, "agent-priv-hobby@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + body, ct := privateDeployBody(t, "true", "1.2.3.4", nil) + req := httptest.NewRequest(http.MethodPost, "/deploy/new", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.20.0.2") + + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusPaymentRequired, resp.StatusCode, + "hobby + private=true must be 402") + + var errBody struct { + Error string `json:"error"` + AgentAction string `json:"agent_action"` + UpgradeURL string `json:"upgrade_url"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody)) + assert.Equal(t, "private_deploy_requires_pro", errBody.Error, + "error key must be private_deploy_requires_pro so agents can branch") + assert.True(t, strings.HasPrefix(errBody.AgentAction, "Tell the user"), + "agent_action must satisfy the U3 contract; got: %q", errBody.AgentAction) + assert.Contains(t, errBody.AgentAction, "https://instanode.dev/pricing", + "agent_action must contain the upgrade URL verbatim") + assert.Equal(t, "https://instanode.dev/pricing", errBody.UpgradeURL, + "upgrade_url must be set so dashboards can render a CTA without parsing the agent_action sentence") +} + +// TestDeployNew_Private_EmptyAllowedIPs_Returns400 is case 3: private=true +// with no allowed_ips is the silent-brick path we explicitly refuse. +func TestDeployNew_Private_EmptyAllowedIPs_Returns400(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "30000000-0000-0000-0000-000000000003", teamID, "agent-priv-empty@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + body, ct := privateDeployBody(t, "true", "", nil) + req := httptest.NewRequest(http.MethodPost, "/deploy/new", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.20.0.3") + + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + + var errBody struct { + Error string `json:"error"` + AgentAction string `json:"agent_action"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody)) + assert.Equal(t, "private_deploy_requires_allowed_ips", errBody.Error) + assert.Contains(t, errBody.AgentAction, "Tell the user") + assert.Contains(t, errBody.AgentAction, "allowed_ips") +} + +// TestDeployNew_Private_InvalidIP_Returns400 is case 4: a malformed entry +// must surface verbatim in the 400 message — the LLM agent reads it back +// to the human verbatim and fixes the literal in the next prompt. +func TestDeployNew_Private_InvalidIP_Returns400(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "40000000-0000-0000-0000-000000000004", teamID, "agent-priv-invalid@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + const badEntry = "not.a.real.ip" + body, ct := privateDeployBody(t, "true", "1.2.3.4,"+badEntry, nil) + req := httptest.NewRequest(http.MethodPost, "/deploy/new", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.20.0.4") + + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + + var errBody struct { + Error string `json:"error"` + Message string `json:"message"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody)) + assert.Equal(t, "invalid_allowed_ip", errBody.Error) + assert.Contains(t, errBody.Message, badEntry, + "message must include the bad literal verbatim — the agent has to fix the exact thing the user passed; got %q", errBody.Message) +} + +// TestDeployNew_Private_TooManyIPs_Returns400 is case 5: cap enforcement. +// 33 entries trips the maxAllowedIPs=32 ceiling. Larger lists belong in CF +// Access or a VPN, not an nginx annotation. +func TestDeployNew_Private_TooManyIPs_Returns400(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + sessionJWT := testhelpers.MustSignSessionJWT(t, "50000000-0000-0000-0000-000000000005", teamID, "agent-priv-flood@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + // 33 distinct /32s. + ips := make([]string, 0, 33) + for i := 0; i < 33; i++ { + ips = append(ips, fmt.Sprintf("10.99.%d.1", i)) + } + body, ct := privateDeployBody(t, "true", strings.Join(ips, ","), nil) + req := httptest.NewRequest(http.MethodPost, "/deploy/new", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.20.0.5") + + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + + var errBody struct { + Error string `json:"error"` + Message string `json:"message"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody)) + assert.Equal(t, "too_many_allowed_ips", errBody.Error, + "error key must be too_many_allowed_ips so agents can disambiguate from invalid_allowed_ip") + assert.Contains(t, errBody.Message, "33", + "message must surface the actual entry count (33)") + assert.Contains(t, errBody.Message, "32", + "message must surface the cap (32) so the agent knows what to trim to") +} + +// TestDeployNew_Public_Default is case 6: no `private` field at all (the +// existing public-deploy path) must continue to return 202 with the new +// fields zero-valued in the response. Guards against silent regression for +// every existing caller in the wild. +func TestDeployNew_Public_Default(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + sessionJWT := testhelpers.MustSignSessionJWT(t, "60000000-0000-0000-0000-000000000006", teamID, "agent-pub-default@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + // No private, no allowed_ips — the existing two-field path. + body, ct := privateDeployBody(t, "", "", nil) + req := httptest.NewRequest(http.MethodPost, "/deploy/new", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.20.0.6") + + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + + require.Equal(t, http.StatusAccepted, resp.StatusCode, + "public deploy (no private field) must still be 202; got %d, body: %s", resp.StatusCode, string(bodyBytes)) + + var created struct { + Item struct { + Private bool `json:"private"` + AllowedIPs []string `json:"allowed_ips"` + } `json:"item"` + } + require.NoError(t, json.Unmarshal(bodyBytes, &created)) + assert.False(t, created.Item.Private, "default deploy must have private=false") + assert.Equal(t, []string{}, created.Item.AllowedIPs, + "default deploy must emit empty allowed_ips (not null) so dashboards always see a list") +} + +// TestDeployNew_Private_GetReturnsFields is case 7: the GET endpoint must +// surface the private + allowed_ips fields on read. (Same surface as case 1, +// but here the assertion lives in a dedicated test that won't silently pass +// if case 1 ever loses its GET round-trip.) +func TestDeployNew_Private_GetReturnsFields(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + teamID := testhelpers.MustCreateTeamDB(t, db, "team") + sessionJWT := testhelpers.MustSignSessionJWT(t, "70000000-0000-0000-0000-000000000007", teamID, "agent-priv-get@example.com") + + app, cleanApp := testhelpers.NewTestAppWithServices(t, db, rdb, "postgres,redis,mongodb,queue,webhook,storage,deploy") + defer cleanApp() + + body, ct := privateDeployBody(t, "true", "203.0.113.0/24", nil) + req := httptest.NewRequest(http.MethodPost, "/deploy/new", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+sessionJWT) + req.Header.Set("X-Forwarded-For", "10.20.0.7") + + resp, err := app.Test(req, 10000) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusAccepted, resp.StatusCode) + + var created struct { + Item struct { + AppID string `json:"app_id"` + } `json:"item"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) + + // List endpoint round-trip too — covers GET /api/v1/deployments which + // the dashboard reads. + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil) + listReq.Header.Set("Authorization", "Bearer "+sessionJWT) + listResp, err := app.Test(listReq, 5000) + require.NoError(t, err) + defer listResp.Body.Close() + require.Equal(t, http.StatusOK, listResp.StatusCode) + + var listed struct { + Items []struct { + AppID string `json:"app_id"` + Private bool `json:"private"` + AllowedIPs []string `json:"allowed_ips"` + } `json:"items"` + } + require.NoError(t, json.NewDecoder(listResp.Body).Decode(&listed)) + + var found bool + for _, it := range listed.Items { + if it.AppID == created.Item.AppID { + found = true + assert.True(t, it.Private, "private deploy must surface private=true on list") + assert.Equal(t, []string{"203.0.113.0/24"}, it.AllowedIPs, + "private deploy must surface allowed_ips on list") + } + } + assert.True(t, found, "the just-created deployment must appear in the team's list") +} diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index ee32bd3..6307a21 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -258,8 +258,9 @@ const openAPISpec = `{ "requestBody": { "required": true, "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/DeployRequest" } } } }, "responses": { "202": { "description": "Deployment accepted, building", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeployResponse" } } } }, - "400": { "description": "Bad request — invalid env_vars JSON, or invalid_resource_binding (resource_bindings value is not a UUID or family:)" }, + "400": { "description": "Bad request — invalid env_vars JSON, invalid_resource_binding (resource_bindings value is not a UUID or family:), private_deploy_requires_allowed_ips (private=true with no IPs), invalid_allowed_ip (bad CIDR/IP literal), or too_many_allowed_ips (>32 entries)" }, "401": { "description": "Unauthorized" }, + "402": { "description": "deployment_limit_reached OR private_deploy_requires_pro — hobby/anonymous/free trying to set private=true. agent_action points to https://instanode.dev/pricing." }, "403": { "description": "Blocked by team env_policy, OR resource_binding_forbidden (binding references a resource owned by a different team)" }, "404": { "description": "resource_binding_not_found — the resource or family root id supplied in resource_bindings does not exist" }, "409": { "description": "no_env_twin — resource_bindings used family: but the family has no member in the deploy's env. agent_action tells the user to call POST /api/v1/resources/:id/provision-twin first." }, @@ -1671,7 +1672,9 @@ const openAPISpec = `{ "port": { "type": "integer", "description": "Container port (default 8080)" }, "env": { "type": "string", "description": "Environment scope (production / staging / dev / ...)" }, "env_vars": { "type": "string", "description": "Optional JSON object of env vars to inject into the deployed pod on the FIRST build — e.g. '{\"DATABASE_URL\":\"postgres://...\",\"REDIS_URL\":\"redis://...\"}'. Avoids the (POST /deploy/new) → (PATCH /env) → (POST /redeploy) round-trip pattern. Values may use 'vault://KEY' refs which resolve at deploy time. Keys starting with underscore are reserved and ignored." }, - "resource_bindings": { "type": "string", "description": "Optional JSON object mapping env-var-name to a resource reference. Values can be either 'family:' (resolved at submit time to the family member matching the deploy's env — one manifest works across all envs) or a raw resource-token UUID (legacy path; resolves to that specific resource regardless of env). Resolved values are merged into env_vars, with explicit env_vars taking precedence on key collision. Example: '{\"DATABASE_URL\":\"family:7a3f2c91-...\",\"REDIS_URL\":\"family:9bd5f3e0-...\"}'." } + "resource_bindings": { "type": "string", "description": "Optional JSON object mapping env-var-name to a resource reference. Values can be either 'family:' (resolved at submit time to the family member matching the deploy's env — one manifest works across all envs) or a raw resource-token UUID (legacy path; resolves to that specific resource regardless of env). Resolved values are merged into env_vars, with explicit env_vars taking precedence on key collision. Example: '{\"DATABASE_URL\":\"family:7a3f2c91-...\",\"REDIS_URL\":\"family:9bd5f3e0-...\"}'." }, + "private": { "type": "string", "description": "Optional flag (\"true\" / \"1\" / \"yes\") that turns this into a private deploy. When set, the resulting Ingress carries an nginx whitelist-source-range annotation built from allowed_ips. Pro / Team / Growth only — hobby/anonymous/free return 402 with agent_action: \"Tell the user private deploys require Pro tier. Upgrade at https://instanode.dev/pricing — takes 30 seconds.\"" }, + "allowed_ips": { "type": "string", "description": "Comma-separated list of CIDRs or IP literals (e.g. \"1.2.3.4,10.0.0.0/8,2001:db8::/32\"). Required when private=true; max 32 entries. Each entry is validated via Go's net.ParseCIDR / net.ParseIP — invalid entries surface in the 400 message so an agent can fix the literal that broke. Larger allowlists belong in CF Access or a real VPN, not an nginx annotation." } }, "required": ["tarball"] }, @@ -1690,6 +1693,8 @@ const openAPISpec = `{ "environment": { "type": "string", "description": "Env scope (production/staging/dev). Note: 'env' on this object is the env_vars map, not the scope." }, "env": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Env vars map — vault://KEY references resolve at deploy time" }, "port": { "type": "integer" }, + "private": { "type": "boolean", "description": "True when the Ingress is locked down via nginx whitelist-source-range. Pro / Team / Growth feature." }, + "allowed_ips": { "type": "array", "items": { "type": "string" }, "description": "CIDRs / IPs whitelisted on the Ingress when private=true. Empty array on a public deploy." }, "team_id": { "type": "string", "format": "uuid" } } }, diff --git a/internal/models/deployment.go b/internal/models/deployment.go index d19e62f..f63c2d5 100644 --- a/internal/models/deployment.go +++ b/internal/models/deployment.go @@ -5,12 +5,19 @@ import ( "database/sql" "encoding/json" "fmt" + "strings" "time" "github.com/google/uuid" ) // Deployment represents a user app hosted on instant.dev infrastructure (Phase 6). +// +// Private / AllowedIPs back the private-deploy feature (migration 020). When +// Private is true, the underlying k8s Ingress carries an +// nginx.ingress.kubernetes.io/whitelist-source-range annotation. AllowedIPs +// is stored as a comma-joined TEXT column (not JSONB) — keeps the model's +// scalar-friendly shape and matches the Ingress annotation format byte-for-byte. type Deployment struct { ID uuid.UUID TeamID uuid.UUID @@ -23,6 +30,8 @@ type Deployment struct { Port int Tier string Env string // dev | staging | production | ; defaults to "production" + Private bool + AllowedIPs []string // parsed from the comma-joined `allowed_ips` column ErrorMessage string CreatedAt time.Time UpdatedAt time.Time @@ -37,6 +46,8 @@ type CreateDeploymentParams struct { Tier string Env string // empty string is normalised to EnvProduction EnvVars map[string]string + Private bool + AllowedIPs []string // each entry must already be a valid IP or CIDR } // ErrDeploymentNotFound is returned when a deployment lookup yields no rows. @@ -50,10 +61,11 @@ func (e *ErrDeploymentNotFound) Error() string { // deploymentColumns is the canonical column list shared by all deployment SELECTs. const deploymentColumns = `id, team_id, resource_id, app_id, provider_id, status, app_url, - env_vars, port, tier, env, error_message, created_at, updated_at` + env_vars, port, tier, env, private, allowed_ips, error_message, created_at, updated_at` // scanDeployment reads a single deployments row into a Deployment struct. // env_vars is stored as JSONB; error_message, provider_id, and app_url are nullable. +// allowed_ips is a comma-joined TEXT column — empty string parses to a nil slice. func scanDeployment(row interface { Scan(dest ...any) error }) (*Deployment, error) { @@ -61,11 +73,14 @@ func scanDeployment(row interface { var envVarsRaw []byte var providerID, appURL, errorMessage sql.NullString var resourceID uuid.NullUUID + var allowedIPsRaw string if err := row.Scan( &d.ID, &d.TeamID, &resourceID, &d.AppID, &providerID, &d.Status, &appURL, - &envVarsRaw, &d.Port, &d.Tier, &d.Env, &errorMessage, + &envVarsRaw, &d.Port, &d.Tier, &d.Env, + &d.Private, &allowedIPsRaw, + &errorMessage, &d.CreatedAt, &d.UpdatedAt, ); err != nil { return nil, err @@ -75,6 +90,7 @@ func scanDeployment(row interface { d.ProviderID = providerID.String d.AppURL = appURL.String d.ErrorMessage = errorMessage.String + d.AllowedIPs = splitAllowedIPs(allowedIPsRaw) if len(envVarsRaw) > 0 { if err := json.Unmarshal(envVarsRaw, &d.EnvVars); err != nil { @@ -88,6 +104,34 @@ func scanDeployment(row interface { return d, nil } +// splitAllowedIPs parses the comma-joined `allowed_ips` column into a slice. +// Empty string returns nil so JSON marshalling emits `null`/omits the field +// for legacy rows instead of `[]`. Whitespace around entries is trimmed. +func splitAllowedIPs(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// joinAllowedIPs is the inverse of splitAllowedIPs — produces the canonical +// comma-joined form used by the DB column AND the nginx whitelist-source-range +// annotation. Exported for the k8s compute provider so it doesn't have to +// know the storage convention. +func JoinAllowedIPs(ips []string) string { + return strings.Join(ips, ",") +} + // CreateDeployment inserts a new deployment row and returns it. func CreateDeployment(ctx context.Context, db *sql.DB, p CreateDeploymentParams) (*Deployment, error) { var resourceID interface{} @@ -114,12 +158,17 @@ func CreateDeployment(ctx context.Context, db *sql.DB, p CreateDeploymentParams) env = EnvProduction } + // allowed_ips is stored as a comma-joined string — keeps it identical to + // the form the nginx whitelist-source-range annotation already requires. + allowedIPs := JoinAllowedIPs(p.AllowedIPs) + row := db.QueryRowContext(ctx, ` INSERT INTO deployments - (team_id, resource_id, app_id, port, tier, env, env_vars) - VALUES ($1, $2, $3, $4, $5, $6, $7) + (team_id, resource_id, app_id, port, tier, env, env_vars, private, allowed_ips) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING `+deploymentColumns, - p.TeamID, resourceID, p.AppID, port, p.Tier, env, envVarsJSON) + p.TeamID, resourceID, p.AppID, port, p.Tier, env, envVarsJSON, + p.Private, allowedIPs) d, err := scanDeployment(row) if err != nil { diff --git a/internal/providers/compute/k8s/client.go b/internal/providers/compute/k8s/client.go index 887f354..6546d6e 100644 --- a/internal/providers/compute/k8s/client.go +++ b/internal/providers/compute/k8s/client.go @@ -507,8 +507,11 @@ func (p *K8sProvider) Deploy(ctx context.Context, opts compute.DeployOptions) (* // Step 8: Create Ingress (+ cert-manager TLS) when DEPLOY_DOMAIN is set. // Falls back to the NodePort URL on local clusters that don't have an - // ingress controller or public domain configured. - ingressURL, err := p.applyIngressForDeploy(ctx, ns, svcName, opts.AppID, opts.Port) + // ingress controller or public domain configured. When opts.Private is + // true, the Ingress carries an nginx whitelist-source-range annotation + // built from opts.AllowedIPs — see applyIngressForDeploy for the precise + // annotation key and how it's joined. + ingressURL, err := p.applyIngressForDeploy(ctx, ns, svcName, opts.AppID, opts.Port, opts.Private, opts.AllowedIPs) if err != nil { return nil, fmt.Errorf("k8s.Deploy: apply ingress: %w", err) } @@ -1113,12 +1116,26 @@ func (p *K8sProvider) applyServiceInNS(ctx context.Context, ns, name, deployName // (e.g. local Rancher Desktop), no ingress is created and the caller falls back // to the NodePort URL. // +// When private is true, the Ingress also carries +// `nginx.ingress.kubernetes.io/whitelist-source-range` with allowedIPs +// comma-joined — only requests originating from one of those CIDRs reach the +// backend. nginx serves a 403 to everything else. private=false produces an +// Ingress identical to pre-private behaviour. +// // Returns the public URL on success, or "" if no ingress was created (callers // should then fall back to the NodePort URL). -func (p *K8sProvider) applyIngressForDeploy(ctx context.Context, ns, svcName, appID string, port int) (string, error) { +func (p *K8sProvider) applyIngressForDeploy(ctx context.Context, ns, svcName, appID string, port int, private bool, allowedIPs []string) (string, error) { domain := os.Getenv("DEPLOY_DOMAIN") if domain == "" { // No public domain configured — skip ingress creation (local dev path). + // On local dev the NodePort fallback bypasses nginx anyway, so the + // private flag has no enforcement surface. We log it so the dev + // understands the flag won't take effect until they wire DEPLOY_DOMAIN. + if private { + slog.Warn("k8s.applyIngressForDeploy: private=true but DEPLOY_DOMAIN is unset; no enforcement on local NodePort", + "app_id", appID, + ) + } return "", nil } host := appID + "." + domain @@ -1135,6 +1152,13 @@ func (p *K8sProvider) applyIngressForDeploy(ctx context.Context, ns, svcName, ap }} scheme = "https" } + // Private deploy → nginx whitelist-source-range. Empty allowedIPs here + // would silently lock everyone out, so the handler enforces non-empty + // before this is reached. Belt-and-suspenders: skip the annotation when + // the slice is empty to avoid an accidental "allow nobody" Ingress. + if private && len(allowedIPs) > 0 { + annotations["nginx.ingress.kubernetes.io/whitelist-source-range"] = strings.Join(allowedIPs, ",") + } publicURL := scheme + "://" + host ing := &networkingv1.Ingress{ diff --git a/internal/providers/compute/provider.go b/internal/providers/compute/provider.go index 047b826..5d5951d 100644 --- a/internal/providers/compute/provider.go +++ b/internal/providers/compute/provider.go @@ -7,13 +7,26 @@ import ( ) // DeployOptions describes an app deployment request. +// +// Private / AllowedIPs are the access-control fields wired by Track A of the +// private-deploys feature (migration 020). The compute provider treats them +// as a single unit: when Private is true, the resulting Ingress carries the +// nginx whitelist annotation with AllowedIPs comma-joined; when false (the +// zero value), the Ingress is created exactly as before — no annotation, no +// behaviour change for existing public deploys. +// +// Validation of AllowedIPs (CIDR / IP parsing, max 32 entries, non-empty +// when Private=true) lives in the handler — the compute layer trusts the +// caller and is reused unchanged for both public and private deploys. type DeployOptions struct { - AppID string // short slug, used as k8s Deployment name and subdomain - Token string // instant.dev resource token (for env var injection) - Tarball []byte // gzipped tar archive of the source directory (must contain Dockerfile) - EnvVars map[string]string // merged: infra resource URLs + user-defined vars - Port int // port the app listens on (default 8080) - Tier string // hobby|pro|team → resource requests/limits + AppID string // short slug, used as k8s Deployment name and subdomain + Token string // instant.dev resource token (for env var injection) + Tarball []byte // gzipped tar archive of the source directory (must contain Dockerfile) + EnvVars map[string]string // merged: infra resource URLs + user-defined vars + Port int // port the app listens on (default 8080) + Tier string // hobby|pro|team → resource requests/limits + Private bool // true → Ingress carries whitelist-source-range annotation + AllowedIPs []string // CIDRs / IPs allowed when Private=true; ignored otherwise } // AppDeployment represents the live state of a deployed app.