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
31 changes: 31 additions & 0 deletions internal/db/migrations/020_deployment_access_control.sql
Original file line number Diff line number Diff line change
@@ -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 '';
15 changes: 15 additions & 0 deletions internal/handlers/agent_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
12 changes: 7 additions & 5 deletions internal/handlers/agent_action_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
72 changes: 60 additions & 12 deletions internal/handlers/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down
164 changes: 164 additions & 0 deletions internal/handlers/deploy_private.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading